Twelve: Initial Subsonic client and data source
Change-Id: Ia154d4a890e033196bbdb1b6ec8d7af874d2dc9e
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 112da23..788aa07 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -25,6 +25,7 @@
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
+ android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.Twelve"
tools:targetApi="tiramisu">
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/SubsonicDataSource.kt b/app/src/main/java/org/lineageos/twelve/datasources/SubsonicDataSource.kt
new file mode 100644
index 0000000..fea8244
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/SubsonicDataSource.kt
@@ -0,0 +1,347 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources
+
+import android.net.Uri
+import android.os.Bundle
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.mapLatest
+import org.lineageos.twelve.R
+import org.lineageos.twelve.datasources.subsonic.SubsonicClient
+import org.lineageos.twelve.datasources.subsonic.models.AlbumID3
+import org.lineageos.twelve.datasources.subsonic.models.ArtistID3
+import org.lineageos.twelve.datasources.subsonic.models.Child
+import org.lineageos.twelve.datasources.subsonic.models.Error
+import org.lineageos.twelve.datasources.subsonic.models.MediaType
+import org.lineageos.twelve.models.Album
+import org.lineageos.twelve.models.Artist
+import org.lineageos.twelve.models.ArtistWorks
+import org.lineageos.twelve.models.Audio
+import org.lineageos.twelve.models.Genre
+import org.lineageos.twelve.models.Playlist
+import org.lineageos.twelve.models.ProviderArgument
+import org.lineageos.twelve.models.ProviderArgument.Companion.requireArgument
+import org.lineageos.twelve.models.RequestStatus
+import org.lineageos.twelve.models.Thumbnail
+
+/**
+ * Subsonic based data source.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class SubsonicDataSource(arguments: Bundle) : MediaDataSource {
+ private val server = arguments.requireArgument(ARG_SERVER)
+ private val username = arguments.requireArgument(ARG_USERNAME)
+ private val password = arguments.requireArgument(ARG_PASSWORD)
+ private val useLegacyAuthentication = arguments.requireArgument(ARG_USE_LEGACY_AUTHENTICATION)
+
+ private val subsonicClient = SubsonicClient(
+ server, username, password, "Twelve", useLegacyAuthentication
+ )
+
+ private val dataSourceBaseUri = Uri.parse(server)
+
+ private val albumsUri = dataSourceBaseUri.buildUpon()
+ .appendPath(ALBUMS_PATH)
+ .build()
+ private val artistsUri = dataSourceBaseUri.buildUpon()
+ .appendPath(ARTISTS_PATH)
+ .build()
+ private val audiosUri = dataSourceBaseUri.buildUpon()
+ .appendPath(AUDIOS_PATH)
+ .build()
+ private val genresUri = dataSourceBaseUri.buildUpon()
+ .appendPath(GENRES_PATH)
+ .build()
+ private val playlistsUri = dataSourceBaseUri.buildUpon()
+ .appendPath(PLAYLISTS_PATH)
+ .build()
+
+ /**
+ * This flow is used to signal a change in the playlists.
+ */
+ private val _playlistsChanged = MutableStateFlow(Any())
+
+ override fun albums() = suspend {
+ subsonicClient.getAlbumList2("alphabeticalByName", 500).toRequestStatus {
+ album.map { it.toMediaItem() }
+ }
+ }.asFlow()
+
+ override fun artists() = suspend {
+ subsonicClient.getArtists().toRequestStatus {
+ index.flatMap { it.artist }.map { it.toMediaItem() }
+ }
+ }.asFlow()
+
+ override fun genres() = suspend {
+ subsonicClient.getGenres().toRequestStatus {
+ genre.map { it.toMediaItem() }
+ }
+ }.asFlow()
+
+ override fun playlists() = _playlistsChanged.mapLatest {
+ subsonicClient.getPlaylists().toRequestStatus {
+ playlist.map { it.toMediaItem() }
+ }
+ }
+
+ override fun search(query: String) = suspend {
+ subsonicClient.search3(query).toRequestStatus {
+ song.map { it.toMediaItem() } +
+ artist.map { it.toMediaItem() } +
+ album.map { it.toMediaItem() }
+ }
+ }.asFlow()
+
+ override fun audio(audioUri: Uri) = suspend {
+ subsonicClient.getSong(audioUri.lastPathSegment!!).toRequestStatus {
+ toMediaItem()
+ }
+ }.asFlow()
+
+ override fun album(albumUri: Uri) = suspend {
+ subsonicClient.getAlbum(albumUri.lastPathSegment!!).toRequestStatus {
+ toAlbumID3().toMediaItem() to song.map {
+ it.toMediaItem()
+ }
+ }
+ }.asFlow()
+
+ override fun artist(artistUri: Uri) = suspend {
+ subsonicClient.getArtist(artistUri.lastPathSegment!!).toRequestStatus {
+ toArtistID3().toMediaItem() to ArtistWorks(
+ albums = album.map { it.toMediaItem() },
+ appearsInAlbum = listOf(),
+ appearsInPlaylist = listOf(),
+ )
+ }
+ }.asFlow()
+
+ override fun genre(genreUri: Uri) = suspend {
+ val genreName = genreUri.lastPathSegment!!
+ subsonicClient.getSongsByGenre(genreName).toRequestStatus {
+ Genre(genreUri, genreName) to song.map { it.toMediaItem() }
+ }
+ }.asFlow()
+
+ override fun playlist(playlistUri: Uri) = _playlistsChanged.mapLatest {
+ subsonicClient.getPlaylist(playlistUri.lastPathSegment!!.toInt()).toRequestStatus {
+ toPlaylist().toMediaItem() to entry.map {
+ it.toMediaItem()
+ } as List<Audio?>
+ }
+ }
+
+ override fun audioPlaylistsStatus(audioUri: Uri) = _playlistsChanged.mapLatest {
+ val audioId = audioUri.lastPathSegment!!
+
+ subsonicClient.getPlaylists().toRequestStatus {
+ playlist.map { playlist ->
+ playlist.toMediaItem() to subsonicClient.getPlaylist(playlist.id).toRequestStatus {
+ entry.any { child -> child.id == audioId }
+ }.let { requestStatus ->
+ (requestStatus as? RequestStatus.Success)?.data ?: false
+ }
+ }
+ }
+ }
+
+ override suspend fun createPlaylist(name: String) = subsonicClient.createPlaylist(
+ null, name, listOf()
+ ).toRequestStatus {
+ onPlaylistsChanged()
+ getPlaylistUri(id.toString())
+ }
+
+ override suspend fun renamePlaylist(
+ playlistUri: Uri, name: String
+ ) = subsonicClient.updatePlaylist(playlistUri.lastPathSegment!!, name).toRequestStatus {
+ onPlaylistsChanged()
+ }
+
+ override suspend fun deletePlaylist(playlistUri: Uri) = subsonicClient.deletePlaylist(
+ playlistUri.lastPathSegment!!.toInt()
+ ).toRequestStatus {
+ onPlaylistsChanged()
+ }
+
+ override suspend fun addAudioToPlaylist(playlistUri: Uri, audioUri: Uri) =
+ subsonicClient.updatePlaylist(
+ playlistUri.lastPathSegment!!,
+ songIdsToAdd = listOf(audioUri.lastPathSegment!!)
+ ).toRequestStatus {
+ onPlaylistsChanged()
+ }
+
+ override suspend fun removeAudioFromPlaylist(
+ playlistUri: Uri,
+ audioUri: Uri
+ ) = subsonicClient.getPlaylist(
+ playlistUri.lastPathSegment!!.toInt()
+ ).toRequestStatus {
+ val audioId = audioUri.lastPathSegment!!
+
+ val audioIndexes = entry.mapIndexedNotNull { index, child ->
+ index.takeIf { child.id == audioId }
+ }
+
+ if (audioIndexes.isNotEmpty()) {
+ subsonicClient.updatePlaylist(
+ playlistUri.lastPathSegment!!,
+ songIndexesToRemove = audioIndexes,
+ ).toRequestStatus {
+ onPlaylistsChanged()
+ }
+ }
+ }
+
+ private fun AlbumID3.toMediaItem() = Album(
+ uri = getAlbumUri(id),
+ title = name,
+ artistUri = artistId?.let { getArtistUri(it) } ?: Uri.EMPTY,
+ artistName = artist ?: "",
+ year = year,
+ thumbnail = Thumbnail(
+ uri = Uri.parse(subsonicClient.getCoverArt(id)),
+ type = Thumbnail.Type.FRONT_COVER,
+ ),
+ )
+
+ private fun ArtistID3.toMediaItem() = Artist(
+ uri = getArtistUri(id),
+ name = name,
+ thumbnail = Thumbnail(
+ uri = Uri.parse(subsonicClient.getCoverArt(id)),
+ type = Thumbnail.Type.BAND_ARTIST_LOGO,
+ ),
+ )
+
+ private fun Child.toMediaItem() = Audio(
+ uri = getAudioUri(id),
+ playbackUri = Uri.parse(subsonicClient.stream(id)),
+ mimeType = contentType ?: "",
+ title = title,
+ type = type.toAudioType(),
+ durationMs = (duration?.let { it * 1000 }) ?: 0,
+ artistUri = artistId?.let { getArtistUri(it) } ?: Uri.EMPTY,
+ artistName = artist ?: "",
+ albumUri = albumId?.let { getAlbumUri(it) } ?: Uri.EMPTY,
+ albumTitle = album ?: "",
+ albumTrack = track ?: 0,
+ genreUri = genre?.let { getGenreUri(it) },
+ genreName = genre,
+ year = year,
+ )
+
+ private fun org.lineageos.twelve.datasources.subsonic.models.Genre.toMediaItem() = Genre(
+ uri = getGenreUri(value),
+ name = value,
+ )
+
+ private fun org.lineageos.twelve.datasources.subsonic.models.Playlist.toMediaItem() = Playlist(
+ uri = getPlaylistUri(id.toString()),
+ name = name,
+ )
+
+ private fun MediaType?.toAudioType() = when (this) {
+ MediaType.MUSIC -> Audio.Type.MUSIC
+ MediaType.PODCAST -> Audio.Type.PODCAST
+ MediaType.AUDIOBOOK -> Audio.Type.AUDIOBOOK
+ MediaType.VIDEO -> throw Exception("Invalid media type, got VIDEO")
+ else -> Audio.Type.MUSIC
+ }
+
+ private suspend fun <T : Any, O : Any> SubsonicClient.MethodResult<T>.toRequestStatus(
+ resultGetter: suspend T.() -> O
+ ) = when (this) {
+ is SubsonicClient.MethodResult.Success -> RequestStatus.Success(result.resultGetter())
+ is SubsonicClient.MethodResult.HttpError -> RequestStatus.Error(RequestStatus.Error.Type.IO)
+ is SubsonicClient.MethodResult.SubsonicError -> RequestStatus.Error(
+ error?.code?.toRequestStatusType() ?: RequestStatus.Error.Type.IO
+ )
+ }
+
+ private fun Error.Code.toRequestStatusType() = when (this) {
+ Error.Code.GENERIC_ERROR -> RequestStatus.Error.Type.IO
+ Error.Code.REQUIRED_PARAMETER_MISSING -> RequestStatus.Error.Type.IO
+ Error.Code.OUTDATED_CLIENT -> RequestStatus.Error.Type.IO
+ Error.Code.OUTDATED_SERVER -> RequestStatus.Error.Type.IO
+ Error.Code.WRONG_CREDENTIALS -> RequestStatus.Error.Type.INVALID_CREDENTIALS
+ Error.Code.TOKEN_AUTHENTICATION_NOT_SUPPORTED ->
+ RequestStatus.Error.Type.INVALID_CREDENTIALS
+
+ Error.Code.USER_NOT_AUTHORIZED -> RequestStatus.Error.Type.INVALID_CREDENTIALS
+ Error.Code.SUBSONIC_PREMIUM_TRIAL_ENDED -> RequestStatus.Error.Type.INVALID_CREDENTIALS
+ Error.Code.NOT_FOUND -> RequestStatus.Error.Type.NOT_FOUND
+ }
+
+ private fun getAlbumUri(albumId: String) = albumsUri.buildUpon()
+ .appendPath(albumId)
+ .build()
+
+ private fun getArtistUri(artistId: String) = artistsUri.buildUpon()
+ .appendPath(artistId)
+ .build()
+
+ private fun getAudioUri(audioId: String) = audiosUri.buildUpon()
+ .appendPath(audioId)
+ .build()
+
+ private fun getGenreUri(genre: String) = genresUri.buildUpon()
+ .appendPath(genre)
+ .build()
+
+ private fun getPlaylistUri(playlistId: String) = playlistsUri.buildUpon()
+ .appendPath(playlistId)
+ .build()
+
+ private fun onPlaylistsChanged() {
+ _playlistsChanged.value = Any()
+ }
+
+ companion object {
+ private const val ALBUMS_PATH = "albums"
+ private const val ARTISTS_PATH = "artists"
+ private const val AUDIOS_PATH = "audio"
+ private const val GENRES_PATH = "genres"
+ private const val PLAYLISTS_PATH = "playlists"
+
+ val ARG_SERVER = ProviderArgument(
+ "server",
+ String::class,
+ R.string.provider_argument_server,
+ required = true,
+ hidden = false,
+ )
+
+ val ARG_USERNAME = ProviderArgument(
+ "username",
+ String::class,
+ R.string.provider_argument_username,
+ required = true,
+ hidden = false,
+ )
+
+ val ARG_PASSWORD = ProviderArgument(
+ "password",
+ String::class,
+ R.string.provider_argument_password,
+ required = true,
+ hidden = true,
+ )
+
+ val ARG_USE_LEGACY_AUTHENTICATION = ProviderArgument(
+ "use_legacy_authentication",
+ Boolean::class,
+ R.string.provider_argument_use_legacy_authentication,
+ required = true,
+ hidden = false,
+ defaultValue = false,
+ )
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/SubsonicClient.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/SubsonicClient.kt
new file mode 100644
index 0000000..2445911
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/SubsonicClient.kt
@@ -0,0 +1,1237 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic
+
+import android.net.Uri
+import kotlinx.serialization.json.Json
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import org.lineageos.twelve.datasources.subsonic.SubsonicClient.Companion.SUBSONIC_API_VERSION
+import org.lineageos.twelve.datasources.subsonic.models.Error
+import org.lineageos.twelve.datasources.subsonic.models.ResponseRoot
+import org.lineageos.twelve.datasources.subsonic.models.ResponseStatus
+import org.lineageos.twelve.datasources.subsonic.models.SubsonicResponse
+import org.lineageos.twelve.datasources.subsonic.models.Version
+import org.lineageos.twelve.ext.executeAsync
+import java.security.MessageDigest
+
+/**
+ * Subsonic client. Compliant with version [SUBSONIC_API_VERSION].
+ *
+ * @param server The base URL of the server
+ * @param username The username to use
+ * @param password The password to use
+ * @param clientName The name of the client to use for requests
+ * @param useLegacyAuthentication Whether to use legacy authentication or not (token authentication)
+ */
+class SubsonicClient(
+ private val server: String,
+ private val username: String,
+ private val password: String,
+ private val clientName: String,
+ private val useLegacyAuthentication: Boolean,
+) {
+ private val okHttpClient = OkHttpClient()
+
+ private val serverUri = Uri.parse(server)
+
+ /**
+ * Used to test connectivity with the server. Takes no extra parameters.
+ *
+ * @since 1.0.0
+ */
+ suspend fun ping() = method(
+ "ping",
+ { },
+ )
+
+ /**
+ * Get details about the software license. Takes no extra parameters. Please note that access to
+ * the REST API requires that the server has a valid license (after a 30-day trial period). To
+ * get a license key you must upgrade to Subsonic Premium.
+ *
+ * @since 1.0.0
+ */
+ suspend fun getLicense() = method(
+ "getLicense",
+ SubsonicResponse::license,
+ )
+
+ /**
+ * Returns all configured top-level music folders. Takes no extra parameters.
+ *
+ * @since 1.0.0
+ */
+ suspend fun getMusicFolders() = method(
+ "getMusicFolders",
+ SubsonicResponse::musicFolders,
+ )
+
+ /**
+ * Returns an indexed structure of all artists.
+ *
+ * @since 1.0.0
+ * @param musicFolderId If specified, only return artists in the music folder with the given ID.
+ * See [getMusicFolders].
+ * @param ifModifiedSince If specified, only return a result if the artist collection has
+ * changed since the given time (in milliseconds since 1 Jan 1970).
+ */
+ suspend fun getIndexes(
+ musicFolderId: Int? = null,
+ ifModifiedSince: Long? = null,
+ ) = method(
+ "getIndexes",
+ SubsonicResponse::indexes,
+ "musicFolderId" to musicFolderId,
+ "ifModifiedSince" to ifModifiedSince,
+ )
+
+ /**
+ * Returns a listing of all files in a music directory. Typically used to get list of albums for
+ * an artist, or list of songs for an album.
+ *
+ * @since 1.0.0
+ * @param id A string which uniquely identifies the music folder. Obtained by calls to
+ * getIndexes or getMusicDirectory.
+ */
+ suspend fun getMusicDirectory(
+ id: String,
+ ) = method(
+ "getMusicDirectory",
+ SubsonicResponse::directory,
+ "id" to id,
+ )
+
+ /**
+ * Returns all genres.
+ *
+ * @since 1.9.0
+ */
+ suspend fun getGenres() = method(
+ "getGenres",
+ SubsonicResponse::genres,
+ )
+
+ /**
+ * Similar to getIndexes, but organizes music according to ID3 tags.
+ *
+ * @since 1.8.0
+ * @param musicFolderId If specified, only return artists in the music folder with the given ID.
+ * See [getMusicFolders].
+ */
+ suspend fun getArtists(
+ musicFolderId: Int? = null,
+ ) = method(
+ "getArtists",
+ SubsonicResponse::artists,
+ "musicFolderId" to musicFolderId,
+ )
+
+ /**
+ * Returns details for an artist, including a list of albums. This method organizes music
+ * according to ID3 tags.
+ *
+ * @since 1.8.0
+ * @param id The artist ID.
+ */
+ suspend fun getArtist(
+ id: String,
+ ) = method(
+ "getArtist",
+ SubsonicResponse::artist,
+ "id" to id,
+ )
+
+ /**
+ * Returns details for an album, including a list of songs. This method organizes music
+ * according to ID3 tags.
+ *
+ * @since 1.8.0
+ * @param id The album ID.
+ */
+ suspend fun getAlbum(
+ id: String,
+ ) = method(
+ "getAlbum",
+ SubsonicResponse::album,
+ "id" to id,
+ )
+
+ /**
+ * Returns details for a song.
+ *
+ * @since 1.8.0
+ * @param id The song ID.
+ */
+ suspend fun getSong(
+ id: String,
+ ) = method(
+ "getSong",
+ SubsonicResponse::song,
+ "id" to id,
+ )
+
+ /**
+ * Returns all video files.
+ *
+ * @since 1.8.0
+ */
+ suspend fun getVideos() = method(
+ "getVideos",
+ SubsonicResponse::videos,
+ )
+
+ /**
+ * Returns details for a video, including information about available audio tracks, subtitles
+ * (captions) and conversions.
+ *
+ * @since 1.14.0
+ * @param id The video ID.
+ */
+ suspend fun getVideoInfo(
+ id: String,
+ ) = method(
+ "getVideoInfo",
+ SubsonicResponse::videoInfo,
+ "id" to id,
+ )
+
+ /**
+ * Returns artist info with biography, image URLs and similar artists, using data from last.fm.
+ *
+ * @since 1.11.0
+ * @param id The artist, album or song ID.
+ * @param count Max number of similar artists to return.
+ * @param includeNotPresent Whether to return artists that are not present in the media library.
+ */
+ suspend fun getArtistInfo(
+ id: String,
+ count: Int? = null,
+ includeNotPresent: Boolean? = null,
+ ) = method(
+ "getArtistInfo",
+ SubsonicResponse::artistInfo,
+ "id" to id,
+ "count" to count,
+ "includeNotPresent" to includeNotPresent,
+ )
+
+ /**
+ * Similar to [getArtistInfo], but organizes music according to ID3 tags.
+ *
+ * @since 1.11.0
+ * @param id The artist ID.
+ * @param count Max number of similar artists to return.
+ * @param includeNotPresent Whether to return artists that are not present in the media library.
+ */
+ suspend fun getArtistInfo2(
+ id: String,
+ count: Int? = null,
+ includeNotPresent: Boolean? = null,
+ ) = method(
+ "getArtistInfo2",
+ SubsonicResponse::artistInfo2,
+ "id" to id,
+ "count" to count,
+ "includeNotPresent" to includeNotPresent,
+ )
+
+ /**
+ * Returns album notes, image URLs etc, using data from last.fm.
+ *
+ * @since 1.14.0
+ * @param id The album or song ID.
+ */
+ suspend fun getAlbumInfo(
+ id: String,
+ ) = method(
+ "getAlbumInfo",
+ SubsonicResponse::albumInfo,
+ "id" to id,
+ )
+
+ /**
+ * Similar to [getAlbumInfo], but organizes music according to ID3 tags.
+ *
+ * @since 1.14.0
+ * @param id The album ID.
+ */
+ suspend fun getAlbumInfo2(
+ id: String,
+ ) = method(
+ "getAlbumInfo2",
+ SubsonicResponse::albumInfo,
+ "id" to id,
+ )
+
+ /**
+ * Returns a random collection of songs from the given artist and similar artists, using data
+ * from last.fm. Typically used for artist radio features.
+ *
+ * @since 1.11.0
+ * @param id The artist, album or song ID.
+ * @param count Max number of songs to return.
+ */
+ suspend fun getSimilarSongs(
+ id: String,
+ count: Int? = null,
+ ) = method(
+ "getSimilarSongs",
+ SubsonicResponse::similarSongs,
+ "id" to id,
+ "count" to count,
+ )
+
+ /**
+ * Similar to [getSimilarSongs], but organizes music according to ID3 tags.
+ *
+ * @since 1.11.0
+ * @param id The artist ID.
+ * @param count Max number of songs to return.
+ */
+ suspend fun getSimilarSongs2(
+ id: String,
+ count: Int? = null,
+ ) = method(
+ "getSimilarSongs2",
+ SubsonicResponse::similarSongs2,
+ "id" to id,
+ "count" to count,
+ )
+
+ /**
+ * Returns top songs for the given artist, using data from last.fm.
+ *
+ * @since 1.13.0
+ * @param artist The artist name.
+ * @param count Max number of songs to return.
+ */
+ suspend fun getTopSongs(
+ artist: String,
+ count: Int? = null,
+ ) = method(
+ "getTopSongs",
+ SubsonicResponse::topSongs,
+ "artist" to artist,
+ "count" to count,
+ )
+
+ /**
+ * Returns a list of random, newest, highest rated etc. albums. Similar to the album lists on
+ * the home page of the Subsonic web interface.
+ *
+ * @since 1.2.0
+ * @param type The list type. Must be one of the following: `random`, `newest`, `frequent`,
+ * `recent`, `starred`, `alphabeticalByName` or `alphabeticalByArtist`. Since 1.10.1 you can
+ * use `byYear` and `byGenre` to list albums in a given year range or genre.
+ * @param size The number of albums to return. Max 500.
+ * @param offset The list offset. Useful if you for example want to page through the list of
+ * newest albums.
+ * @param fromYear The first year in the range. If [fromYear] > [toYear] a reverse chronological
+ * list is returned.
+ * @param toYear The last year in the range.
+ * @param genre The name of the genre, e.g., "Rock".
+ * @param musicFolderId (Since 1.11.0) Only return albums in the music folder with the given ID.
+ * See [getMusicFolders].
+ */
+ suspend fun getAlbumList(
+ type: String,
+ size: Int? = null,
+ offset: Int? = null,
+ fromYear: Int? = null,
+ toYear: Int? = null,
+ genre: String? = null,
+ musicFolderId: Int? = null,
+ ) = method(
+ "getAlbumList",
+ SubsonicResponse::albumList,
+ "type" to type,
+ "size" to size,
+ "offset" to offset,
+ "fromYear" to fromYear,
+ "toYear" to toYear,
+ "genre" to genre,
+ "musicFolderId" to musicFolderId,
+ )
+
+ /**
+ * Similar to [getAlbumList], but organizes music according to ID3 tags.
+ *
+ * @since 1.8.0
+ * @param type The list type. Must be one of the following: `random`, `newest`, `frequent`,
+ * `recent`, `starred`, `alphabeticalByName` or `alphabeticalByArtist`. Since 1.10.1 you can
+ * use `byYear` and `byGenre` to list albums in a given year range or genre.
+ * @param size The number of albums to return. Max 500.
+ * @param offset The list offset. Useful if you for example want to page through the list of
+ * newest albums.
+ * @param fromYear The first year in the range. If [fromYear] > [toYear] a reverse chronological
+ * list is returned.
+ * @param toYear The last year in the range.
+ * @param genre The name of the genre, e.g., "Rock".
+ * @param musicFolderId (Since 1.12.0) Only return albums in the music folder with the given ID.
+ * See [getMusicFolders].
+ */
+ suspend fun getAlbumList2(
+ type: String,
+ size: Int? = null,
+ offset: Int? = null,
+ fromYear: Int? = null,
+ toYear: Int? = null,
+ genre: String? = null,
+ musicFolderId: Int? = null,
+ ) = method(
+ "getAlbumList2",
+ SubsonicResponse::albumList2,
+ "type" to type,
+ "size" to size,
+ "offset" to offset,
+ "fromYear" to fromYear,
+ "toYear" to toYear,
+ "genre" to genre,
+ "musicFolderId" to musicFolderId,
+ )
+
+ /**
+ * Returns random songs matching the given criteria.
+ *
+ * @since 1.2.0
+ * @param size The maximum number of songs to return. Max 500.
+ * @param genre Only returns songs belonging to this genre.
+ * @param fromYear Only return songs published after or in this year.
+ * @param toYear Only return songs published before or in this year.
+ * @param musicFolderId Only return songs in the music folder with the given ID. See
+ * [getMusicFolders].
+ */
+ suspend fun getRandomSongs(
+ size: Int? = null,
+ genre: String? = null,
+ fromYear: Int? = null,
+ toYear: Int? = null,
+ musicFolderId: Int? = null,
+ ) = method(
+ "getRandomSongs",
+ SubsonicResponse::randomSongs,
+ "size" to size,
+ "genre" to genre,
+ "fromYear" to fromYear,
+ "toYear" to toYear,
+ "musicFolderId" to musicFolderId,
+ )
+
+ /**
+ * Returns songs in a given genre.
+ *
+ * @since 1.9.0
+ * @param genre The genre, as returned by [getGenres].
+ * @param count The maximum number of songs to return. Max 500.
+ * @param offset The offset. Useful if you want to page through the songs in a genre.
+ * @param (Since 1.12.0) Only return albums in the music folder with the given ID. See
+ * [getMusicFolders].
+ */
+ suspend fun getSongsByGenre(
+ genre: String,
+ count: Int? = null,
+ offset: Int? = null,
+ musicFolderId: Int? = null,
+ ) = method(
+ "getSongsByGenre",
+ SubsonicResponse::songsByGenre,
+ "genre" to genre,
+ "count" to count,
+ "offset" to offset,
+ "musicFolderId" to musicFolderId,
+ )
+
+ /**
+ * Returns what is currently being played by all users. Takes no extra parameters.
+ *
+ * @since 1.0.0
+ */
+ suspend fun getNowPlaying() = method(
+ "getNowPlaying",
+ SubsonicResponse::nowPlaying,
+ )
+
+ /**
+ * Returns starred songs, albums and artists.
+ *
+ * @since 1.8.0
+ * @param musicFolderId (Since 1.12.0) Only return results from the music folder with the given
+ * ID. See [getMusicFolders].
+ */
+ suspend fun getStarred(
+ musicFolderId: Int? = null,
+ ) = method(
+ "getStarred",
+ SubsonicResponse::starred,
+ "musicFolderId" to musicFolderId,
+ )
+
+ /**
+ * Similar to [getStarred], but organizes music according to ID3 tags.
+ *
+ * @since 1.8.0
+ * @param musicFolderId (Since 1.12.0) Only return results from the music folder with the given
+ * ID. See [getMusicFolders].
+ */
+ suspend fun getStarred2(
+ musicFolderId: Int? = null,
+ ) = method(
+ "getStarred",
+ SubsonicResponse::starred2,
+ "musicFolderId" to musicFolderId,
+ )
+
+ /**
+ * Returns a listing of files matching the given search criteria. Supports paging through the
+ * result.
+ *
+ * @since 1.0.0
+ * @param artist Artist to search for.
+ * @param album Album to searh for.
+ * @param title Song title to search for.
+ * @param any Searches all fields.
+ * @param count Maximum number of results to return.
+ * @param offset Search result offset. Used for paging.
+ * @param newerThan Only return matches that are newer than this. Given as milliseconds since
+ * 1970.
+ */
+ @Deprecated("Deprecated since 1.4.0, use search2 instead.")
+ suspend fun search(
+ artist: String? = null,
+ album: String? = null,
+ title: String? = null,
+ any: String? = null,
+ count: Int? = null,
+ offset: Int? = null,
+ newerThan: Long? = null,
+ ) = method(
+ "search",
+ SubsonicResponse::searchResult,
+ "artist" to artist,
+ "album" to album,
+ "title" to title,
+ "any" to any,
+ "count" to count,
+ "offset" to offset,
+ "newerThan" to newerThan,
+ )
+
+ /**
+ * Returns albums, artists and songs matching the given search criteria. Supports paging through the result.
+ *
+ * @since 1.4.0
+ * @param query Search query.
+ * @param artistCount Maximum number of artists to return.
+ * @param artistOffset Search result offset for artists. Used for paging.
+ * @param albumCount Maximum number of albums to return.
+ * @param albumOffset Search result offset for albums. Used for paging.
+ * @param songCount Maximum number of songs to return.
+ * @param songOffset Search result offset for songs. Used for paging.
+ * @param musicFolderId (Since 1.12.0) Only return results from the music folder with the given
+ * ID. See [getMusicFolders].
+ */
+ suspend fun search2(
+ query: String,
+ artistCount: Int? = null,
+ artistOffset: Int? = null,
+ albumCount: Int? = null,
+ albumOffset: Int? = null,
+ songCount: Int? = null,
+ songOffset: Int? = null,
+ musicFolderId: Int? = null,
+ ) = method(
+ "search2",
+ SubsonicResponse::searchResult2,
+ "query" to query,
+ "artistCount" to artistCount,
+ "artistOffset" to artistOffset,
+ "albumCount" to albumCount,
+ "albumOffset" to albumOffset,
+ "songCount" to songCount,
+ "songOffset" to songOffset,
+ "musicFolderId" to musicFolderId,
+ )
+
+ /**
+ * Similar to [search2], but organizes music according to ID3 tags.
+ *
+ * @since 1.8.0
+ * @param query Search query.
+ * @param artistCount Maximum number of artists to return.
+ * @param artistOffset Search result offset for artists. Used for paging.
+ * @param albumCount Maximum number of albums to return.
+ * @param albumOffset Search result offset for albums. Used for paging.
+ * @param songCount Maximum number of songs to return.
+ * @param songOffset Search result offset for songs. Used for paging.
+ * @param musicFolderId (Since 1.12.0) Only return results from music folder with the given ID.
+ * See [getMusicFolders].
+ */
+ suspend fun search3(
+ query: String,
+ artistCount: Int? = null,
+ artistOffset: Int? = null,
+ albumCount: Int? = null,
+ albumOffset: Int? = null,
+ songCount: Int? = null,
+ songOffset: Int? = null,
+ musicFolderId: Int? = null,
+ ) = method(
+ "search3",
+ SubsonicResponse::searchResult3,
+ "query" to query,
+ "artistCount" to artistCount,
+ "artistOffset" to artistOffset,
+ "albumCount" to albumCount,
+ "albumOffset" to albumOffset,
+ "songCount" to songCount,
+ "songOffset" to songOffset,
+ "musicFolderId" to musicFolderId,
+ )
+
+ /**
+ * Returns all playlists a user is allowed to play.
+ *
+ * @since 1.0.0
+ * @param username (Since 1.8.0) If specified, return playlists for this user rather than for
+ * the authenticated user. The authenticated user must have admin role if this parameter is
+ * used.
+ */
+ suspend fun getPlaylists(
+ username: String? = null,
+ ) = method(
+ "getPlaylists",
+ SubsonicResponse::playlists,
+ "username" to username,
+ )
+
+ /**
+ * Returns a listing of files in a saved playlist.
+ *
+ * @since 1.0.0
+ * @param id ID of the playlist to return, as obtained by [getPlaylists].
+ */
+ suspend fun getPlaylist(
+ id: Int,
+ ) = method(
+ "getPlaylist",
+ SubsonicResponse::playlist,
+ "id" to id,
+ )
+
+ /**
+ * Creates (or updates) a playlist.
+ *
+ * @since 1.2.0
+ * @param playlistId The playlist ID. Required if updating an existing playlist.
+ * @param name The playlist name. Required if creating a new playlist.
+ * @param songIds ID of a song in the playlist. Use one songId parameter for each song in the
+ * playlist.
+ */
+ suspend fun createPlaylist(
+ playlistId: String? = null,
+ name: String? = null,
+ songIds: List<Int>,
+ ) = method(
+ "createPlaylist",
+ SubsonicResponse::playlist,
+ "playlistId" to playlistId,
+ "name" to name,
+ *songIds.map { "songId" to it }.toTypedArray(),
+ )
+
+ /**
+ * Updates a playlist. Only the owner of a playlist is allowed to update it.
+ *
+ * @since 1.8.0
+ * @param playlistId The playlist ID.
+ * @param name The human-readable name of the playlist.
+ * @param comment The playlist comment.
+ * @param public `true` if the playlist should be visible to all users, `false` otherwise.
+ * @param songIdsToAdd Add this song with this ID to the playlist. Multiple parameters allowed.
+ * @param songIndexesToRemove Remove the song at this position in the playlist. Multiple
+ * parameters allowed.
+ */
+ suspend fun updatePlaylist(
+ playlistId: String,
+ name: String? = null,
+ comment: String? = null,
+ public: Boolean? = null,
+ songIdsToAdd: List<String>? = null,
+ songIndexesToRemove: List<Int>? = null,
+ ) = method(
+ "updatePlaylist",
+ { },
+ "playlistId" to playlistId,
+ "name" to name,
+ "comment" to comment,
+ "public" to public,
+ *songIdsToAdd?.map { "songIdToAdd" to it }?.toTypedArray().orEmpty(),
+ *songIndexesToRemove?.map { "songIndexToRemove" to it }?.toTypedArray().orEmpty(),
+ )
+
+ /**
+ * Deletes a saved playlist.
+ *
+ * @since 1.2.0
+ * @param id ID of the playlist to delete, as obtained by [getPlaylists].
+ */
+ suspend fun deletePlaylist(
+ id: Int,
+ ) = method(
+ "deletePlaylist",
+ { },
+ "id" to id,
+ )
+
+ /**
+ * Streams a given media file.
+ *
+ * @since 1.0.0
+ * @param id A string which uniquely identifies the file to stream. Obtained by calls to
+ * [getMusicDirectory].
+ * @param maxBitRate (Since 1.2.0) If specified, the server will attempt to limit the bitrate to
+ * this value, in kilobits per second. If set to zero, no limit is imposed.
+ * @param format (Since 1.6.0) Specifies the preferred target format (e.g., "mp3" or "flv") in
+ * case there are multiple applicable transcodings. Starting with 1.9.0 you can use the
+ * special value "raw" to disable transcoding.
+ * @param timeOffset Only applicable to video streaming. If specified, start streaming at the
+ * given offset (in seconds) into the video. Typically used to implement video skipping.
+ * @param size (Since 1.6.0) Only applicable to video streaming. Requested video size specified
+ * as WxH, for instance "640x480".
+ * @param estimateContentLength (Since 1.8.0). If set to "true", the Content-Length HTTP header
+ * will be set to an estimated value for transcoded or downsampled media.
+ * @param converted (Since 1.14.0) Only applicable to video streaming. Subsonic can optimize
+ * videos for streaming by converting them to MP4. If a conversion exists for the video in
+ * question, then setting this parameter to "true" will cause the converted video to be
+ * returned instead of the original.
+ */
+ fun stream(
+ id: String,
+ maxBitRate: Int? = null,
+ format: String? = null,
+ timeOffset: Int? = null,
+ size: String? = null,
+ estimateContentLength: Boolean? = null,
+ converted: Boolean? = null,
+ ) = getMethodUrl(
+ "stream",
+ "id" to id,
+ "maxBitRate" to maxBitRate,
+ "format" to format,
+ "timeOffset" to timeOffset,
+ "size" to size,
+ "estimateContentLength" to estimateContentLength,
+ "converted" to converted,
+ )
+
+ /**
+ * Downloads a given media file. Similar to stream, but this method returns the original media
+ * data without transcoding or downsampling.
+ *
+ * @since 1.0.0
+ * @param id A string which uniquely identifies the file to download. Obtained by calls to
+ * [getMusicDirectory].
+ */
+ fun download(
+ id: String,
+ ) = getMethodUrl(
+ "download",
+ "id" to id,
+ )
+
+ /**
+ * Creates an HLS (HTTP Live Streaming) playlist used for streaming video or audio. HLS is a
+ * streaming protocol implemented by Apple and works by breaking the overall stream into a
+ * sequence of small HTTP-based file downloads. It's supported by iOS and newer versions of
+ * Android. This method also supports adaptive bitrate streaming, see the bitRate parameter.
+ *
+ * @since 1.8.0
+ * @param id A string which uniquely identifies the media file to stream.
+ * @param bitRate If specified, the server will attempt to limit the bitrate to this value, in
+ * kilobits per second. If this parameter is specified more than once, the server will create
+ * a variant playlist, suitable for adaptive bitrate streaming. The playlist will support
+ * streaming at all the specified bitrates. The server will automatically choose video
+ * dimensions that are suitable for the given bitrates. Since 1.9.0 you may explicitly request
+ * a certain width (480) and height (360) like so: `bitRate=1000@480x360`
+ * @param audioTrack The ID of the audio track to use. See getVideoInfo for how to get the list
+ * of available audio tracks for a video.
+ */
+ fun hls(
+ id: String,
+ bitRate: String? = null,
+ audioTrack: Int? = null,
+ ) = getMethodUrl(
+ "hls",
+ "id" to id,
+ "bitRate" to bitRate,
+ "audioTrack" to audioTrack,
+ )
+
+ /**
+ * Returns captions (subtitles) for a video. Use [getVideoInfo] to get a list of available
+ * captions.
+ *
+ * @since 1.14.0
+ * @param id The ID of the video.
+ * @param format Preferred captions format ("srt" or "vtt").
+ */
+ fun getCaptions(
+ id: String,
+ format: String? = null,
+ ) = getMethodUrl(
+ "getCaptions",
+ "id" to id,
+ "format" to format,
+ )
+
+ /***
+ * Returns a cover art image.
+ *
+ * @since 1.0.0
+ * @param id The ID of a song, album or artist.
+ * @param size If specified, scale image to this size.
+ */
+ fun getCoverArt(
+ id: String,
+ size: Int? = null,
+ ) = getMethodUrl(
+ "getCoverArt",
+ "id" to id,
+ "size" to size,
+ )
+
+ /**
+ * Searches for and returns lyrics for a given song.
+ *
+ * @since 1.2.0
+ * @param artist The artist name.
+ * @param title The song title.
+ */
+ fun getLyrics(
+ artist: String? = null,
+ title: String? = null,
+ ) = getMethodUrl(
+ "getLyrics",
+ "artist" to artist,
+ "title" to title,
+ )
+
+ /**
+ * Returns the avatar (personal image) for a user.
+ *
+ * @since 1.8.0
+ * @param username The user in question.
+ */
+ fun getAvatar(
+ username: String,
+ ) = getMethodUrl(
+ "getAvatar",
+ "username" to username,
+ )
+
+ /**
+ * Attaches a star to a song, album or artist.
+ *
+ * @since 1.8.0
+ * @param ids The ID of the file (song) or folder (album/artist) to star. Multiple parameters
+ * allowed.
+ * @param albumIds The ID of an album to star. Use this rather than id if the client accesses
+ * the media collection according to ID3 tags rather than file structure. Multiple parameters
+ * allowed.
+ * @param artistIds The ID of an artist to star. Use this rather than id if the client accesses
+ * the media collection according to ID3 tags rather than file structure. Multiple parameters
+ * allowed.
+ */
+ suspend fun star(
+ ids: List<String>? = null,
+ albumIds: List<String>? = null,
+ artistIds: List<String>? = null,
+ ) = method(
+ "star",
+ { },
+ *ids?.map { "id" to it }?.toTypedArray().orEmpty(),
+ *albumIds?.map { "albumId" to it }?.toTypedArray().orEmpty(),
+ *artistIds?.map { "artistId" to it }?.toTypedArray().orEmpty(),
+ )
+
+ /**
+ * Removes the star from a song, album or artist.
+ *
+ * @since 1.8.0
+ * @param ids The ID of the file (song) or folder (album/artist) to unstar. Multiple parameters
+ * allowed.
+ * @param albumIds The ID of an album to unstar. Use this rather than id if the client accesses
+ * the media collection according to ID3 tags rather than file structure. Multiple parameters
+ * allowed.
+ * @param artistIds The ID of an artist to unstar. Use this rather than id if the client
+ * accesses the media collection according to ID3 tags rather than file structure. Multiple
+ * parameters allowed.
+ */
+ suspend fun unstar(
+ ids: List<String>? = null,
+ albumIds: List<String>? = null,
+ artistIds: List<String>? = null,
+ ) = method(
+ "unstar",
+ { },
+ *ids?.map { "id" to it }?.toTypedArray().orEmpty(),
+ *albumIds?.map { "albumId" to it }?.toTypedArray().orEmpty(),
+ *artistIds?.map { "artistId" to it }?.toTypedArray().orEmpty(),
+ )
+
+ /**
+ * Sets the rating for a music file.
+ *
+ * @since 1.6.0
+ * @param id A string which uniquely identifies the file (song) or folder (album/artist) to
+ * rate.
+ * @param rating The rating between 1 and 5 (inclusive), or 0 to remove the rating.
+ */
+ suspend fun setRating(
+ id: String,
+ rating: Int,
+ ) = method(
+ "setRating",
+ { },
+ "id" to id,
+ "rating" to rating,
+ )
+
+ /**
+ * Registers the local playback of one or more media files. Typically used when playing media
+ * that is cached on the client. This operation includes the following:
+ *
+ * "Scrobbles" the media files on last.fm if the user has configured his/her last.fm credentials
+ * on the Subsonic server (Settings > Personal).
+ * Updates the play count and last played timestamp for the media files. (Since 1.11.0)
+ * Makes the media files appear in the "Now playing" page in the web app, and appear in the list
+ * of songs returned by [getNowPlaying] (Since 1.11.0)
+ * Since 1.8.0 you may specify multiple id (and optionally time) parameters to scrobble multiple
+ * files.
+ *
+ * @since 1.5.0
+ * @param ids A string which uniquely identifies the file to scrobble.
+ * @param time (Since 1.8.0) The time (in milliseconds since 1 Jan 1970) at which the song was
+ * listened to.
+ * @param submission Whether this is a "submission" or a "now playing" notification.
+ */
+ suspend fun scrobble(
+ ids: List<String>,
+ time: Long? = null,
+ submission: Boolean? = null,
+ ) = method(
+ "scrobble",
+ { },
+ *ids.map { "id" to it }.toTypedArray(),
+ "time" to time,
+ "submission" to submission,
+ )
+
+ /**
+ * Returns information about shared media this user is allowed to manage. Takes no extra
+ * parameters.
+ *
+ * @since 1.6.0
+ */
+ suspend fun getShares() = method(
+ "getShares",
+ SubsonicResponse::shares,
+ )
+
+ /**
+ * Creates a public URL that can be used by anyone to stream music or video from the Subsonic
+ * server. The URL is short and suitable for posting on Facebook, Twitter etc. Note: The user
+ * must be authorized to share (see Settings > Users > User is allowed to share files with
+ * anyone).
+ *
+ * @since 1.6.0
+ * @param id ID of a song, album or video to share. Use one id parameter for each entry to
+ * share.
+ * @param description A user-defined description that will be displayed to people visiting the
+ * shared media.
+ * @param expires The time at which the share expires. Given as milliseconds since 1970.
+ */
+ suspend fun createShare(
+ id: String,
+ description: String? = null,
+ expires: Long? = null,
+ ) = method(
+ "createShare",
+ SubsonicResponse::shares,
+ "id" to id,
+ "description" to description,
+ "expires" to expires,
+ )
+
+ /**
+ * Updates the description and/or expiration date for an existing share.
+ *
+ * @since 1.6.0
+ * @param id ID of the share to update.
+ * @param description A user-defined description that will be displayed to people visiting the
+ * shared media.
+ * @param expires The time at which the share expires. Given as milliseconds since 1970, or zero
+ * to remove the expiration.
+ */
+ suspend fun updateShare(
+ id: String,
+ description: String? = null,
+ expires: Long? = null,
+ ) = method(
+ "updateShare",
+ { },
+ "id" to id,
+ "description" to description,
+ "expires" to expires,
+ )
+
+ /**
+ * Deletes an existing share.
+ *
+ * @since 1.6.0
+ * @param id ID of the share to delete.
+ */
+ suspend fun deleteShare(
+ id: String,
+ ) = method(
+ "deleteShare",
+ { },
+ "id" to id,
+ )
+
+ /**
+ * Returns all Podcast channels the server subscribes to, and (optionally) their episodes. This
+ * method can also be used to return details for only one channel - refer to the id parameter.
+ * A typical use case for this method would be to first retrieve all channels without
+ * episodes, and then retrieve all episodes for the single channel the user selects.
+ *
+ * @since 1.6.0
+ * @param includeEpisodes (Since 1.9.0) Whether to include Podcast episodes in the returned
+ * result.
+ * @param id (Since 1.9.0) If specified, only return the Podcast channel with this ID.
+ */
+ suspend fun getPodcasts(
+ includeEpisodes: Boolean? = null,
+ id: String? = null,
+ ) = method(
+ "getPodcasts",
+ SubsonicResponse::podcasts,
+ "includeEpisodes" to includeEpisodes,
+ "id" to id,
+ )
+
+ /**
+ * Returns the most recently published Podcast episodes.
+ *
+ * @since 1.13.0
+ * @param count The maximum number of episodes to return.
+ */
+ suspend fun getNewestPodcasts(
+ count: Int? = null,
+ ) = method(
+ "getNewestPodcasts",
+ SubsonicResponse::newestPodcasts,
+ "count" to count,
+ )
+
+ /**
+ * Requests the server to check for new Podcast episodes. Note: The user must be authorized for
+ * Podcast administration (see Settings > Users > User is allowed to administrate Podcasts).
+ *
+ * @since 1.9.0
+ */
+ suspend fun refreshPodcasts() = method(
+ "refreshPodcasts",
+ { },
+ )
+
+ /**
+ * Adds a new Podcast channel. Note: The user must be authorized for Podcast administration (see
+ * Settings > Users > User is allowed to administrate Podcasts).
+ *
+ * @since 1.9.0
+ * @param url The URL of the Podcast to add.
+ */
+ suspend fun createPodcastChannel(
+ url: String,
+ ) = method(
+ "createPodcastChannel",
+ { },
+ "url" to url,
+ )
+
+ /**
+ * Deletes a Podcast channel. Note: The user must be authorized for Podcast administration (see
+ * Settings > Users > User is allowed to administrate Podcasts).
+ *
+ * @since 1.9.0
+ * @param id The ID of the Podcast channel to delete.
+ */
+ suspend fun deletePodcastChannel(
+ id: String,
+ ) = method(
+ "deletePodcastChannel",
+ { },
+ "id" to id,
+ )
+
+ /**
+ * Deletes a Podcast episode. Note: The user must be authorized for Podcast administration (see
+ * Settings > Users > User is allowed to administrate Podcasts).
+ *
+ * @since 1.9.0
+ * @param id The ID of the Podcast episode to delete.
+ */
+ suspend fun deletePodcastEpisode(
+ id: String,
+ ) = method(
+ "deletePodcastEpisode",
+ { },
+ "id" to id,
+ )
+
+ /**
+ * Request the server to start downloading a given Podcast episode. Note: The user must be
+ * authorized for Podcast administration (see Settings > Users > User is allowed to administrate
+ * Podcasts).
+ *
+ * @since 1.9.0
+ * @param id The ID of the Podcast episode to download.
+ */
+ suspend fun downloadPodcastEpisode(
+ id: String,
+ ) = method(
+ "downloadPodcastEpisode",
+ { },
+ "id" to id,
+ )
+
+ /**
+ * Controls the jukebox, i.e., playback directly on the server's audio hardware. Note: The user
+ * must be authorized to control the jukebox (see Settings > Users > User is allowed to play
+ * files in jukebox mode).
+ *
+ * @since 1.2.0
+ * @param action The operation to perform. Must be one of: `get`, `status` (since 1.7.0), `set`
+ * (since 1.7.0), `start`, `stop`, `skip`, `add`, `clear`, `remove`, `shuffle`, `setGain`
+ * @param index Used by `skip` and `remove`. Zero-based index of the song to skip to or remove.
+ * @param offset (Since 1.7.0) Used by `skip`. Start playing this many seconds into the track.
+ * @param ids Used by `add` and `set`. ID of song to add to the jukebox playlist. Use multiple id
+ * parameters to add many songs in the same request. (set is similar to a clear followed by a
+ * add, but will not change the currently playing track.)
+ * @param gain Used by `setGain` to control the playback volume. A float value between 0.0 and
+ * 1.0.
+ */
+ suspend fun jukeboxControl(
+ action: String,
+ index: Int? = null,
+ offset: Int? = null,
+ ids: List<String>? = null,
+ gain: Float? = null,
+ ) = method(
+ "jukeboxControl",
+ { },
+ "action" to action,
+ "index" to index,
+ "offset" to offset,
+ *ids?.map { "id" to it }?.toTypedArray().orEmpty(),
+ "gain" to gain,
+ )
+
+ /**
+ * Returns all internet radio stations. Takes no extra parameters.
+ *
+ * @since 1.9.0
+ */
+ suspend fun getInternetRadioStations() = method(
+ "getInternetRadioStations",
+ SubsonicResponse::internetRadioStations,
+ )
+
+ // TODO
+
+ private suspend fun <T> method(
+ method: String,
+ methodValue: SubsonicResponse.() -> T,
+ vararg queryParameters: Pair<String, Any?>,
+ ) = okHttpClient.newCall(
+ Request.Builder()
+ .url(getMethodUrl(method, *queryParameters))
+ .build()
+ ).executeAsync().let { response ->
+ when (response.isSuccessful) {
+ true -> response.body?.string()?.let {
+ val subsonicResponse = Json.decodeFromString<ResponseRoot>(it).subsonicResponse
+
+ when (subsonicResponse.status) {
+ ResponseStatus.OK -> MethodResult.Success(
+ subsonicResponse.methodValue() ?: throw Exception(
+ "Successful request with empty result"
+ )
+ )
+
+ ResponseStatus.FAILED -> MethodResult.SubsonicError(subsonicResponse.error)
+ }
+ } ?: throw Exception("Successful response with empty body")
+
+ false -> MethodResult.HttpError(response.code)
+ }
+ }
+
+ private fun getMethodUrl(
+ method: String,
+ vararg queryParameters: Pair<String, Any?>,
+ ) = serverUri.buildUpon().apply {
+ appendPath("rest")
+ appendPath(method)
+ getBaseParameters().forEach { (key, value) -> appendQueryParameter(key, value) }
+ queryParameters.forEach { (key, value) ->
+ value?.let { appendQueryParameter(key, it.toString()) }
+ }
+ }.build().toString()
+
+ /**
+ * Get the base parameters for all methods.
+ */
+ private fun getBaseParameters() = mutableMapOf<String, String>().apply {
+ this["u"] = username
+ this["v"] = SUBSONIC_API_VERSION.value
+ this["c"] = clientName
+ this["f"] = PROTOCOL_JSON
+ if (!useLegacyAuthentication) {
+ val salt = generateSalt()
+ this["t"] = getSaltedPassword(password, salt)
+ this["s"] = salt
+ } else {
+ this["p"] = password
+ }
+ }.toMap()
+
+ sealed interface MethodResult<T> {
+ class Success<T>(val result: T) : MethodResult<T>
+ class HttpError<T>(val code: Int) : MethodResult<T>
+ class SubsonicError<T>(val error: Error?) : MethodResult<T>
+ }
+
+ companion object {
+ private val SUBSONIC_API_VERSION = Version(1, 16, 1)
+
+ private const val PROTOCOL_JSON = "json"
+
+ private val md5MessageDigest = MessageDigest.getInstance("MD5")
+
+ private val allowedSaltChars = ('A'..'Z') + ('a'..'z') + ('0'..'9')
+
+ private fun generateSalt() = (1..20)
+ .map { allowedSaltChars.random() }
+ .joinToString("")
+
+ private fun getSaltedPassword(password: String, salt: String) = md5MessageDigest.digest(
+ password.toByteArray() + salt.toByteArray()
+ ).toString()
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/AlbumID3.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/AlbumID3.kt
new file mode 100644
index 0000000..ba20a30
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/AlbumID3.kt
@@ -0,0 +1,28 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic.models
+
+import kotlinx.serialization.Serializable
+
+@Suppress("PROVIDED_RUNTIME_TOO_LOW")
+@Serializable
+data class AlbumID3(
+ val id: String,
+ val name: String,
+ val artist: String? = null,
+ val artistId: String? = null,
+ val coverArt: String? = null,
+ val songCount: Int,
+ val duration: Int,
+ val playCount: Long? = null,
+ val created: InstantAsString,
+ val starred: InstantAsString? = null,
+ val year: Int? = null,
+ val genre: String? = null,
+
+ // OpenSubsonic
+ val sortName: String? = null,
+)
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/AlbumList.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/AlbumList.kt
new file mode 100644
index 0000000..285e6ef
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/AlbumList.kt
@@ -0,0 +1,14 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic.models
+
+import kotlinx.serialization.Serializable
+
+@Suppress("PROVIDED_RUNTIME_TOO_LOW")
+@Serializable
+data class AlbumList(
+ val album: List<Child>,
+)
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/AlbumList2.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/AlbumList2.kt
new file mode 100644
index 0000000..6f73115
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/AlbumList2.kt
@@ -0,0 +1,14 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic.models
+
+import kotlinx.serialization.Serializable
+
+@Suppress("PROVIDED_RUNTIME_TOO_LOW")
+@Serializable
+data class AlbumList2(
+ val album: List<AlbumID3>,
+)
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/AlbumWithSongsID3.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/AlbumWithSongsID3.kt
new file mode 100644
index 0000000..b7a496a
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/AlbumWithSongsID3.kt
@@ -0,0 +1,50 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic.models
+
+import kotlinx.serialization.Serializable
+
+@Suppress("PROVIDED_RUNTIME_TOO_LOW")
+@Serializable
+data class AlbumWithSongsID3(
+ // AlbumID3 start
+
+ val id: String,
+ val name: String,
+ val artist: String? = null,
+ val artistId: String? = null,
+ val coverArt: String? = null,
+ val songCount: Int,
+ val duration: Int,
+ val playCount: Long? = null,
+ val created: InstantAsString,
+ val starred: InstantAsString? = null,
+ val year: Int? = null,
+ val genre: String? = null,
+
+ // OpenSubsonic
+ val sortName: String? = null,
+
+ // AlbumID3 end
+
+ val song: List<Child>,
+) {
+ fun toAlbumID3() = AlbumID3(
+ id = id,
+ name = name,
+ artist = artist,
+ artistId = artistId,
+ coverArt = coverArt,
+ songCount = songCount,
+ duration = duration,
+ playCount = playCount,
+ created = created,
+ starred = starred,
+ year = year,
+ genre = genre,
+ sortName = sortName,
+ )
+}
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/ArtistID3.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/ArtistID3.kt
new file mode 100644
index 0000000..6505fec
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/ArtistID3.kt
@@ -0,0 +1,22 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic.models
+
+import kotlinx.serialization.Serializable
+
+@Suppress("PROVIDED_RUNTIME_TOO_LOW")
+@Serializable
+data class ArtistID3(
+ val id: String,
+ val name: String,
+ val coverArt: String? = null,
+ val artistImageUrl: UriAsString? = null,
+ val albumCount: Int,
+ val starred: InstantAsString? = null,
+
+ // OpenSubsonic
+ val sortName: String? = null,
+)
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/ArtistWithAlbumsID3.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/ArtistWithAlbumsID3.kt
new file mode 100644
index 0000000..021408d
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/ArtistWithAlbumsID3.kt
@@ -0,0 +1,36 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic.models
+
+import kotlinx.serialization.Serializable
+
+@Suppress("PROVIDED_RUNTIME_TOO_LOW")
+@Serializable
+data class ArtistWithAlbumsID3(
+ // ArtistID3 start
+ val id: String,
+ val name: String,
+ val coverArt: String? = null,
+ val artistImageUrl: UriAsString? = null,
+ val albumCount: Int,
+ val starred: InstantAsString? = null,
+
+ // OpenSubsonic
+ val sortName: String? = null,
+ // ArtistID3 end
+
+ val album: List<AlbumID3>,
+) {
+ fun toArtistID3() = ArtistID3(
+ id = id,
+ name = name,
+ coverArt = coverArt,
+ artistImageUrl = artistImageUrl,
+ albumCount = albumCount,
+ starred = starred,
+ sortName = sortName,
+ )
+}
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/ArtistsID3.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/ArtistsID3.kt
new file mode 100644
index 0000000..59f0844
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/ArtistsID3.kt
@@ -0,0 +1,15 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic.models
+
+import kotlinx.serialization.Serializable
+
+@Suppress("PROVIDED_RUNTIME_TOO_LOW")
+@Serializable
+data class ArtistsID3(
+ val index: List<IndexID3>,
+ val ignoredArticles: String,
+)
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/AverageRating.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/AverageRating.kt
new file mode 100644
index 0000000..0f019fe
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/AverageRating.kt
@@ -0,0 +1,10 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic.models
+
+import androidx.annotation.FloatRange
+
+typealias AverageRating = @receiver:FloatRange(from = 0.0, to = 5.0) Double
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/Child.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/Child.kt
new file mode 100644
index 0000000..efc25b2
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/Child.kt
@@ -0,0 +1,48 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic.models
+
+import kotlinx.serialization.Serializable
+
+@Suppress("PROVIDED_RUNTIME_TOO_LOW")
+@Serializable
+data class Child(
+ val id: String,
+ val parent: String? = null,
+ val isDir: Boolean,
+ val title: String,
+ val album: String? = null,
+ val artist: String? = null,
+ val track: Int? = null,
+ val year: Int? = null,
+ val genre: String? = null,
+ val coverArt: String? = null,
+ val size: Long? = null,
+ val contentType: String? = null,
+ val suffix: String? = null,
+ val transcodedContentType: String? = null,
+ val transcodedSuffix: String? = null,
+ val duration: Int? = null,
+ val bitRate: Int? = null,
+ val path: String? = null,
+ val isVideo: Boolean? = null,
+ val userRating: UserRating? = null,
+ val averageRating: AverageRating? = null,
+ val playCount: Long? = null,
+ val discNumber: Int? = null,
+ val created: InstantAsString? = null,
+ val starred: InstantAsString? = null,
+ val albumId: String? = null,
+ val artistId: String? = null,
+ val type: MediaType? = null,
+ val bookmarkPosition: Long? = null,
+ val originalWidth: Int? = null,
+ val originalHeight: Int? = null,
+
+ // OpenSubsonic
+ val played: String? = null,
+ val sortName: String? = null,
+)
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/Error.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/Error.kt
new file mode 100644
index 0000000..5cbfbcb
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/Error.kt
@@ -0,0 +1,92 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic.models
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+
+@Suppress("PROVIDED_RUNTIME_TOO_LOW")
+@Serializable
+data class Error(
+ val code: Code,
+ val message: String? = null,
+) {
+ /**
+ * Subsonic error code.
+ */
+ @Serializable(with = Code.Serializer::class)
+ enum class Code(val code: Int) {
+ /**
+ * A generic error.
+ */
+ GENERIC_ERROR(0),
+
+ /**
+ * Required parameter is missing.
+ */
+ REQUIRED_PARAMETER_MISSING(10),
+
+ /**
+ * Incompatible Subsonic REST protocol version. Client must upgrade.
+ */
+ OUTDATED_CLIENT(20),
+
+ /**
+ * Incompatible Subsonic REST protocol version. Server must upgrade.
+ */
+ OUTDATED_SERVER(30),
+
+ /**
+ * Wrong username or password.
+ */
+ WRONG_CREDENTIALS(40),
+
+ /**
+ * Token authentication not supported for LDAP users.
+ *
+ * Note: Some third party server implementations uses this to just signal that they need
+ * legacy authentication.
+ */
+ TOKEN_AUTHENTICATION_NOT_SUPPORTED(41),
+
+ /**
+ * User is not authorized for the given operation.
+ */
+ USER_NOT_AUTHORIZED(50),
+
+ /**
+ * The trial period for the Subsonic server is over. Please upgrade to Subsonic Premium.
+ * Visit subsonic.org for details.
+ */
+ SUBSONIC_PREMIUM_TRIAL_ENDED(60),
+
+ /**
+ * The requested data was not found.
+ */
+ NOT_FOUND(70);
+
+ class Serializer : KSerializer<Code> {
+ override val descriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.INT)
+
+ override fun deserialize(decoder: Decoder) = decoder.decodeInt().let {
+ Code.fromCode(it) ?: throw SerializationException("Unknown code $it")
+ }
+
+ override fun serialize(encoder: Encoder, value: Code) {
+ encoder.encodeInt(value.code)
+ }
+ }
+
+ companion object {
+ fun fromCode(code: Int) = entries.firstOrNull { it.code == code }
+ }
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/Genre.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/Genre.kt
new file mode 100644
index 0000000..da2b520
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/Genre.kt
@@ -0,0 +1,16 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic.models
+
+import kotlinx.serialization.Serializable
+
+@Suppress("PROVIDED_RUNTIME_TOO_LOW")
+@Serializable
+data class Genre(
+ val value: String,
+ val songCount: Int,
+ val albumCount: Int,
+)
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/Genres.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/Genres.kt
new file mode 100644
index 0000000..0ca28c6
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/Genres.kt
@@ -0,0 +1,14 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic.models
+
+import kotlinx.serialization.Serializable
+
+@Suppress("PROVIDED_RUNTIME_TOO_LOW")
+@Serializable
+data class Genres(
+ val genre: List<Genre>,
+)
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/IndexID3.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/IndexID3.kt
new file mode 100644
index 0000000..38523fb
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/IndexID3.kt
@@ -0,0 +1,15 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic.models
+
+import kotlinx.serialization.Serializable
+
+@Suppress("PROVIDED_RUNTIME_TOO_LOW")
+@Serializable
+data class IndexID3(
+ val artist: List<ArtistID3>,
+ val name: String,
+)
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/InstantAsString.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/InstantAsString.kt
new file mode 100644
index 0000000..4b21f40
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/InstantAsString.kt
@@ -0,0 +1,12 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic.models
+
+import kotlinx.serialization.Serializable
+import java.time.Instant
+
+@Suppress("PROVIDED_RUNTIME_TOO_LOW")
+typealias InstantAsString = @Serializable(with = InstantSerializer::class) Instant
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/InstantSerializer.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/InstantSerializer.kt
new file mode 100644
index 0000000..88b4499
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/InstantSerializer.kt
@@ -0,0 +1,28 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic.models
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import java.time.Instant
+import java.time.OffsetDateTime
+import java.time.ZoneId
+
+class InstantSerializer : KSerializer<Instant> {
+ override val descriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)
+
+ override fun deserialize(decoder: Decoder): Instant =
+ OffsetDateTime.parse(decoder.decodeString()).toInstant()
+
+ override fun serialize(encoder: Encoder, value: Instant) {
+ encoder.encodeString(
+ OffsetDateTime.ofInstant(value, ZoneId.of("Z")).toString()
+ )
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/MediaType.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/MediaType.kt
new file mode 100644
index 0000000..29a9365
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/MediaType.kt
@@ -0,0 +1,41 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic.models
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+
+@Suppress("PROVIDED_RUNTIME_TOO_LOW")
+@Serializable(with = MediaType.Serializer::class)
+enum class MediaType(val value: String) {
+ MUSIC("music"),
+ PODCAST("podcast"),
+ AUDIOBOOK("audiobook"),
+ VIDEO("video");
+
+ class Serializer : KSerializer<MediaType> {
+ override val descriptor = PrimitiveSerialDescriptor(
+ "ResponseStatus", PrimitiveKind.STRING
+ )
+
+ override fun deserialize(decoder: Decoder) = decoder.decodeString().let {
+ fromValue(it) ?: throw SerializationException("Unknown MediaType value $it")
+ }
+
+ override fun serialize(encoder: Encoder, value: MediaType) {
+ encoder.encodeString(value.value)
+ }
+ }
+
+ companion object {
+ fun fromValue(value: String) = entries.firstOrNull { it.value == value }
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/Playlist.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/Playlist.kt
new file mode 100644
index 0000000..2fee97e
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/Playlist.kt
@@ -0,0 +1,24 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic.models
+
+import kotlinx.serialization.Serializable
+
+@Suppress("PROVIDED_RUNTIME_TOO_LOW")
+@Serializable
+data class Playlist(
+ val allowedUser: List<String>? = null,
+ val id: Int,
+ val name: String,
+ val comment: String? = null,
+ val owner: String? = null,
+ val public: Boolean? = null,
+ val songCount: Int,
+ val duration: Int,
+ val created: InstantAsString,
+ val changed: InstantAsString,
+ val coverArt: String? = null,
+)
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/PlaylistWithSongs.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/PlaylistWithSongs.kt
new file mode 100644
index 0000000..99c9298
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/PlaylistWithSongs.kt
@@ -0,0 +1,42 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic.models
+
+import kotlinx.serialization.Serializable
+
+@Suppress("PROVIDED_RUNTIME_TOO_LOW")
+@Serializable
+data class PlaylistWithSongs(
+ // Playlist start
+ val allowedUser: List<String>? = null,
+ val id: Int,
+ val name: String,
+ val comment: String?,
+ val owner: String? = null,
+ val public: Boolean? = null,
+ val songCount: Int,
+ val duration: Int? = null, // OpenSubsonic violates the API
+ val created: InstantAsString,
+ val changed: InstantAsString,
+ val coverArt: String? = null,
+ // Playlist end
+
+ val entry: List<Child>,
+) {
+ fun toPlaylist() = Playlist(
+ allowedUser = allowedUser,
+ id = id,
+ name = name,
+ comment = comment,
+ owner = owner,
+ public = public,
+ songCount = songCount,
+ duration = duration ?: 0,
+ created = created,
+ changed = changed,
+ coverArt = coverArt,
+ )
+}
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/Playlists.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/Playlists.kt
new file mode 100644
index 0000000..c4540b8
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/Playlists.kt
@@ -0,0 +1,14 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic.models
+
+import kotlinx.serialization.Serializable
+
+@Suppress("PROVIDED_RUNTIME_TOO_LOW")
+@Serializable
+data class Playlists(
+ val playlist: List<Playlist>,
+)
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/ResponseRoot.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/ResponseRoot.kt
new file mode 100644
index 0000000..2440433
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/ResponseRoot.kt
@@ -0,0 +1,15 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic.models
+
+import kotlinx.serialization.Serializable
+
+@Suppress("PROVIDED_RUNTIME_TOO_LOW")
+@Serializable
+data class ResponseRoot(
+ @kotlinx.serialization.SerialName("subsonic-response")
+ val subsonicResponse: SubsonicResponse,
+)
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/ResponseStatus.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/ResponseStatus.kt
new file mode 100644
index 0000000..0f7b682
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/ResponseStatus.kt
@@ -0,0 +1,39 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic.models
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+
+@Suppress("PROVIDED_RUNTIME_TOO_LOW")
+@Serializable(with = ResponseStatus.Serializer::class)
+enum class ResponseStatus(val value: String) {
+ OK("ok"),
+ FAILED("failed");
+
+ class Serializer : KSerializer<ResponseStatus> {
+ override val descriptor = PrimitiveSerialDescriptor(
+ "ResponseStatus", PrimitiveKind.STRING
+ )
+
+ override fun deserialize(decoder: Decoder) = decoder.decodeString().let {
+ fromValue(it) ?: throw SerializationException("Unknown ResponseStatus value $it")
+ }
+
+ override fun serialize(encoder: Encoder, value: ResponseStatus) {
+ encoder.encodeString(value.value)
+ }
+ }
+
+ companion object {
+ fun fromValue(value: String) = entries.firstOrNull { it.value == value }
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/SearchResult3.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/SearchResult3.kt
new file mode 100644
index 0000000..e1dbb7b
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/SearchResult3.kt
@@ -0,0 +1,16 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic.models
+
+import kotlinx.serialization.Serializable
+
+@Suppress("PROVIDED_RUNTIME_TOO_LOW")
+@Serializable
+data class SearchResult3(
+ val artist: List<ArtistID3>,
+ val album: List<AlbumID3>,
+ val song: List<Child>,
+)
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/Songs.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/Songs.kt
new file mode 100644
index 0000000..b0f411f
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/Songs.kt
@@ -0,0 +1,14 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic.models
+
+import kotlinx.serialization.Serializable
+
+@Suppress("PROVIDED_RUNTIME_TOO_LOW")
+@Serializable
+data class Songs(
+ val song: List<Child>,
+)
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/SubsonicResponse.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/SubsonicResponse.kt
new file mode 100644
index 0000000..f829f7c
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/SubsonicResponse.kt
@@ -0,0 +1,66 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic.models
+
+import kotlinx.serialization.Serializable
+
+typealias TODO = (Nothing?)
+
+@Suppress("PROVIDED_RUNTIME_TOO_LOW")
+@Serializable
+data class SubsonicResponse(
+ val musicFolders: TODO = null,
+ val indexes: TODO = null,
+ val directory: TODO = null,
+ val genres: Genres? = null,
+ val artists: ArtistsID3? = null,
+ val artist: ArtistWithAlbumsID3? = null,
+ val album: AlbumWithSongsID3? = null,
+ val song: Child? = null,
+ val videos: TODO = null,
+ val videoInfo: TODO = null,
+ val nowPlaying: TODO = null,
+ val searchResult: TODO = null,
+ val searchResult2: TODO = null,
+ val searchResult3: SearchResult3? = null,
+ val playlists: Playlists? = null,
+ val playlist: PlaylistWithSongs? = null,
+ val jukeboxStatus: TODO = null,
+ val jukeboxPlaylist: TODO = null,
+ val license: TODO = null,
+ val users: TODO = null,
+ val user: TODO = null,
+ val chatMessages: TODO = null,
+ val albumList: AlbumList? = null,
+ val albumList2: AlbumList2? = null,
+ val randomSongs: TODO = null,
+ val songsByGenre: Songs? = null,
+ val lyrics: TODO = null,
+ val podcasts: TODO = null,
+ val newestPodcasts: TODO = null,
+ val internetRadioStations: TODO = null,
+ val bookmarks: TODO = null,
+ val playQueue: TODO = null,
+ val shares: TODO = null,
+ val starred: TODO = null,
+ val starred2: TODO = null,
+ val albumInfo: TODO = null,
+ val artistInfo: TODO = null,
+ val artistInfo2: TODO = null,
+ val similarSongs: TODO = null,
+ val similarSongs2: TODO = null,
+ val topSongs: TODO = null,
+ val scanStatus: TODO = null,
+ val error: Error? = null,
+
+ val status: ResponseStatus,
+ val version: Version,
+
+ // OpenSubsonic
+ val openSubsonic: Boolean? = null,
+ val type: String? = null,
+ val serverVersion: String? = null,
+)
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/UriAsString.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/UriAsString.kt
new file mode 100644
index 0000000..ff586c2
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/UriAsString.kt
@@ -0,0 +1,12 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic.models
+
+import android.net.Uri
+import kotlinx.serialization.Serializable
+
+@Suppress("PROVIDED_RUNTIME_TOO_LOW")
+typealias UriAsString = @Serializable(with = UriSerializer::class) Uri
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/UriSerializer.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/UriSerializer.kt
new file mode 100644
index 0000000..725e756
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/UriSerializer.kt
@@ -0,0 +1,25 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic.models
+
+import android.net.Uri
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+
+class UriSerializer : KSerializer<Uri> {
+ override val descriptor = PrimitiveSerialDescriptor(
+ "Uri", PrimitiveKind.STRING
+ )
+
+ override fun deserialize(decoder: Decoder): Uri = Uri.parse(decoder.decodeString())
+
+ override fun serialize(encoder: Encoder, value: Uri) {
+ encoder.encodeString(value.toString())
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/UserRating.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/UserRating.kt
new file mode 100644
index 0000000..e984f7a
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/UserRating.kt
@@ -0,0 +1,10 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic.models
+
+import androidx.annotation.IntRange
+
+typealias UserRating = @receiver:IntRange(from = 1, to = 5) Int
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/Version.kt b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/Version.kt
new file mode 100644
index 0000000..513528c
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/datasources/subsonic/models/Version.kt
@@ -0,0 +1,43 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.datasources.subsonic.models
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+
+@Suppress("PROVIDED_RUNTIME_TOO_LOW")
+@Serializable(with = Version.Serializer::class)
+data class Version(
+ val major: Int,
+ val minor: Int,
+ val revision: Int,
+) {
+ val value = "$major.$minor.$revision"
+
+ override fun toString() = value
+
+ class Serializer : KSerializer<Version> {
+ override val descriptor = PrimitiveSerialDescriptor(
+ "Version", PrimitiveKind.STRING
+ )
+
+ override fun deserialize(decoder: Decoder) = fromValue(decoder.decodeString())
+
+ override fun serialize(encoder: Encoder, value: Version) {
+ encoder.encodeString(value.value)
+ }
+ }
+
+ companion object {
+ fun fromValue(value: String) = value.split('.')
+ .map { it.toInt() }
+ .let { (major, minor, revision) -> Version(major, minor, revision) }
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/ext/Call.kt b/app/src/main/java/org/lineageos/twelve/ext/Call.kt
new file mode 100644
index 0000000..cd9de6d
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/ext/Call.kt
@@ -0,0 +1,43 @@
+/*
+ * SPDX-FileCopyrightText: 2022 Square, Inc.
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.ext
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.suspendCancellableCoroutine
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.Response
+import okhttp3.internal.closeQuietly
+import okio.IOException
+import kotlin.coroutines.resumeWithException
+
+@OptIn(ExperimentalCoroutinesApi::class) // resume with a resource cleanup.
+suspend fun Call.executeAsync(): Response = suspendCancellableCoroutine { continuation ->
+ continuation.invokeOnCancellation {
+ this.cancel()
+ }
+ this.enqueue(
+ object : Callback {
+ override fun onFailure(
+ call: Call,
+ e: IOException,
+ ) {
+ continuation.resumeWithException(e)
+ }
+
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ @Suppress("deprecation")
+ continuation.resume(response) {
+ response.closeQuietly()
+ }
+ }
+ },
+ )
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 996d66a..feb399d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -103,4 +103,10 @@
<string name="audio_bitrate">Bitrate</string>
<string name="audio_bitrate_unknown">Unknown</string>
<string name="audio_bitrate_format">%1$s kbps</string>
+
+ <!-- Provider arguments -->
+ <string name="provider_argument_server">Server</string>
+ <string name="provider_argument_username">Username</string>
+ <string name="provider_argument_password">Password</string>
+ <string name="provider_argument_use_legacy_authentication">Use legacy authentication</string>
</resources>
diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..8a64621
--- /dev/null
+++ b/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<network-security-config>
+ <domain-config cleartextTrafficPermitted="true">
+ <domain includeSubdomains="true">demo.subsonic.org</domain>
+ </domain-config>
+</network-security-config>