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>