diff --git a/app/schemas/com.subtracks.data.db.SubtracksDatabase/1.json b/app/schemas/com.subtracks.data.db.SubtracksDatabase/2.json similarity index 93% rename from app/schemas/com.subtracks.data.db.SubtracksDatabase/1.json rename to app/schemas/com.subtracks.data.db.SubtracksDatabase/2.json index b9944d66..88729efc 100644 --- a/app/schemas/com.subtracks.data.db.SubtracksDatabase/1.json +++ b/app/schemas/com.subtracks.data.db.SubtracksDatabase/2.json @@ -1,8 +1,8 @@ { "formatVersion": 1, "database": { - "version": 1, - "identityHash": "c359cb54d1d99a2de8efbe1f0c8ad182", + "version": 2, + "identityHash": "67a9c26297d65d76f54edc99ac9a7930", "entities": [ { "tableName": "sources", @@ -106,44 +106,9 @@ } ] }, - { - "tableName": "app_settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `maxBitrateWifi` INTEGER NOT NULL, `maxBitrateMobile` INTEGER NOT NULL, `streamFormat` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "maxBitrateWifi", - "columnName": "maxBitrateWifi", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "maxBitrateMobile", - "columnName": "maxBitrateMobile", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "streamFormat", - "columnName": "streamFormat", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, { "tableName": "artists", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `albumCount` INTEGER NOT NULL, `starred` INTEGER, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `albumCount` INTEGER NOT NULL, `starred` INTEGER, `coverArt` TEXT, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "sourceId", @@ -173,6 +138,11 @@ "fieldPath": "starred", "columnName": "starred", "affinity": "INTEGER" + }, + { + "fieldPath": "coverArt", + "columnName": "coverArt", + "affinity": "TEXT" } ], "primaryKey": { @@ -643,7 +613,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c359cb54d1d99a2de8efbe1f0c8ad182')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '67a9c26297d65d76f54edc99ac9a7930')" ] } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/subtracks/data/db/Databases.kt b/app/src/main/kotlin/com/subtracks/data/db/Databases.kt index e8204460..d19f2d20 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/Databases.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/Databases.kt @@ -5,6 +5,7 @@ import androidx.room3.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver fun createAndroidDatabase(context: Context): SubtracksDatabase = - Room.databaseBuilder(context, "subtracks.db") + Room + .databaseBuilder(context, "subtracks.db") .setDriver(BundledSQLiteDriver()) .build() diff --git a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt index 704051a7..0a569887 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -1,16 +1,21 @@ package com.subtracks.data.db +import androidx.paging.PagingSource import androidx.room3.Dao +import androidx.room3.DaoReturnTypeConverters import androidx.room3.Query import androidx.room3.Upsert +import androidx.room3.paging.PagingSourceDaoReturnTypeConverter import com.subtracks.data.model.Album import com.subtracks.data.model.Artist import com.subtracks.data.model.Playlist import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.Song +import com.subtracks.data.model.SongListItem import kotlinx.coroutines.flow.Flow @Dao +@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class) interface LibraryDao { @Upsert suspend fun upsertArtists(items: List) @@ -40,43 +45,85 @@ interface LibraryDao { suspend fun songIds(sourceId: Long): List @Query("DELETE FROM artists WHERE sourceId = :sourceId AND id IN (:ids)") - suspend fun deleteArtists(sourceId: Long, ids: Collection) + suspend fun deleteArtists( + sourceId: Long, + ids: Collection, + ) @Query("DELETE FROM albums WHERE sourceId = :sourceId AND id IN (:ids)") - suspend fun deleteAlbums(sourceId: Long, ids: Collection) + suspend fun deleteAlbums( + sourceId: Long, + ids: Collection, + ) @Query("DELETE FROM playlists WHERE sourceId = :sourceId AND id IN (:ids)") - suspend fun deletePlaylists(sourceId: Long, ids: Collection) + suspend fun deletePlaylists( + sourceId: Long, + ids: Collection, + ) @Query("DELETE FROM songs WHERE sourceId = :sourceId AND id IN (:ids)") - suspend fun deleteSongs(sourceId: Long, ids: Collection) + suspend fun deleteSongs( + sourceId: Long, + ids: Collection, + ) @Query("DELETE FROM playlist_songs WHERE sourceId = :sourceId") suspend fun deletePlaylistSongs(sourceId: Long) - @Query("SELECT * FROM artists WHERE sourceId = :sourceId ORDER BY name") - fun artists(sourceId: Long): Flow> + @Query("SELECT * FROM artists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE") + fun artists(sourceId: Long): PagingSource - @Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY name") - fun albums(sourceId: Long): Flow> + @Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE") + fun albumsByName(sourceId: Long): PagingSource - @Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY name") - fun playlists(sourceId: Long): Flow> + @Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY albumArtist COLLATE NOCASE, year, name COLLATE NOCASE") + fun albumsByArtist(sourceId: Long): PagingSource - @Query("SELECT * FROM songs WHERE sourceId = :sourceId ORDER BY artist, album, disc, track") - fun songs(sourceId: Long): Flow> + @Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY year DESC, name COLLATE NOCASE") + fun albumsByYear(sourceId: Long): PagingSource - @Query("SELECT * FROM songs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc, track") - fun songsByAlbum(sourceId: Long, albumId: String): Flow> + @Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY created DESC, name COLLATE NOCASE") + fun albumsByRecentlyAdded(sourceId: Long): PagingSource @Query( - "SELECT songs.* FROM playlist_songs " + + "SELECT songs.*, albums.coverArt AS coverArt FROM songs " + + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + + "WHERE songs.sourceId = :sourceId " + + "ORDER BY songs.title COLLATE NOCASE, songs.artist COLLATE NOCASE", + ) + fun songs(sourceId: Long): PagingSource + + @Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE") + fun playlists(sourceId: Long): PagingSource + + @Query( + "SELECT songs.*, albums.coverArt AS coverArt FROM playlist_songs " + "JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " + + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId " + "ORDER BY playlist_songs.position", ) - fun songsByPlaylist(sourceId: Long, playlistId: String): Flow> + fun playlistSongs( + sourceId: Long, + playlistId: String, + ): PagingSource - @Query("SELECT * FROM albums WHERE sourceId = :sourceId AND artistId = :artistId ORDER BY year DESC, name") - fun albumsByArtist(sourceId: Long, artistId: String): Flow> + @Query("SELECT * FROM albums WHERE sourceId = :sourceId AND id = :albumId") + fun album( + sourceId: Long, + albumId: String, + ): Flow + + @Query("SELECT * FROM playlists WHERE sourceId = :sourceId AND id = :playlistId") + fun playlist( + sourceId: Long, + playlistId: String, + ): Flow + + @Query("SELECT * FROM songs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc, track") + fun songsByAlbum( + sourceId: Long, + albumId: String, + ): Flow> } diff --git a/app/src/main/kotlin/com/subtracks/data/db/SearchDao.kt b/app/src/main/kotlin/com/subtracks/data/db/SearchDao.kt index 4a0952ce..5933af9d 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/SearchDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/SearchDao.kt @@ -22,9 +22,17 @@ interface SearchDao { LIMIT :limit """, ) - suspend fun searchPhrase(sourceId: String, query: String, limit: Int): List + suspend fun searchPhrase( + sourceId: String, + query: String, + limit: Int, + ): List - suspend fun search(sourceId: String, query: String, limit: Int): List { + suspend fun search( + sourceId: String, + query: String, + limit: Int, + ): List { val phrase = query.trim() if (phrase.length < MIN_QUERY_LENGTH) return emptyList() return searchPhrase(sourceId, phrase, limit) diff --git a/app/src/main/kotlin/com/subtracks/data/db/SourcesDao.kt b/app/src/main/kotlin/com/subtracks/data/db/SourcesDao.kt index 390f9aa4..2598196d 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/SourcesDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/SourcesDao.kt @@ -1,11 +1,12 @@ package com.subtracks.data.db import androidx.room3.Dao +import androidx.room3.Insert import androidx.room3.Query import androidx.room3.Transaction import androidx.room3.Upsert -import com.subtracks.data.model.AppSettings import com.subtracks.data.model.Source +import com.subtracks.data.model.SubsonicConfig import com.subtracks.data.model.SubsonicSource import kotlinx.coroutines.flow.Flow @@ -23,6 +24,15 @@ interface SourcesDao { @Query("SELECT * FROM subsonic_sources WHERE sourceId = :sourceId") fun subsonicSource(sourceId: Long): Flow + @Query("SELECT id FROM sources WHERE isActive = 1 LIMIT 1") + suspend fun activeSourceIdOnce(): Long? + + @Query("SELECT id FROM sources ORDER BY createdAt LIMIT 1") + suspend fun firstSourceId(): Long? + + @Insert + suspend fun insertSource(source: Source): Long + @Upsert suspend fun upsertSource(source: Source) @@ -44,9 +54,19 @@ interface SourcesDao { @Query("UPDATE sources SET isActive = (id = :sourceId)") suspend fun setActiveSource(sourceId: Long) - @Query("SELECT * FROM app_settings WHERE id = 1") - fun appSettings(): Flow + @Query( + "SELECT s.id AS id, s.name AS name, s.address AS address, c.username AS username, " + + "c.password AS password, c.useTokenAuth AS useTokenAuth " + + "FROM sources s JOIN subsonic_sources c ON c.sourceId = s.id " + + "WHERE s.isActive = 1 LIMIT 1", + ) + fun activeSubsonicConfig(): Flow - @Upsert - suspend fun upsertAppSettings(settings: AppSettings) + @Query( + "SELECT s.id AS id, s.name AS name, s.address AS address, c.username AS username, " + + "c.password AS password, c.useTokenAuth AS useTokenAuth " + + "FROM sources s JOIN subsonic_sources c ON c.sourceId = s.id " + + "WHERE s.isActive = 1 LIMIT 1", + ) + suspend fun activeSubsonicConfigOnce(): SubsonicConfig? } diff --git a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt index c32d3f6d..e36805fb 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt @@ -3,7 +3,6 @@ package com.subtracks.data.db import androidx.room3.Database import androidx.room3.RoomDatabase import com.subtracks.data.model.Album -import com.subtracks.data.model.AppSettings import com.subtracks.data.model.Artist import com.subtracks.data.model.Playlist import com.subtracks.data.model.PlaylistSong @@ -16,7 +15,6 @@ import com.subtracks.data.model.SubsonicSource entities = [ Source::class, SubsonicSource::class, - AppSettings::class, Artist::class, Album::class, Playlist::class, @@ -24,7 +22,7 @@ import com.subtracks.data.model.SubsonicSource Song::class, SearchIndex::class, ], - version = 1, + version = 2, exportSchema = true, ) abstract class SubtracksDatabase : RoomDatabase() { diff --git a/app/src/main/kotlin/com/subtracks/data/model/CoverArtRef.kt b/app/src/main/kotlin/com/subtracks/data/model/CoverArtRef.kt new file mode 100644 index 00000000..34c52e3f --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/model/CoverArtRef.kt @@ -0,0 +1,6 @@ +package com.subtracks.data.model + +data class CoverArtRef( + val url: String, + val cacheKey: String, +) diff --git a/app/src/main/kotlin/com/subtracks/data/model/Models.kt b/app/src/main/kotlin/com/subtracks/data/model/Models.kt index 4cfdbe9d..5774dac7 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Models.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Models.kt @@ -1,6 +1,7 @@ package com.subtracks.data.model import androidx.room3.ColumnInfo +import androidx.room3.Embedded import androidx.room3.Entity import androidx.room3.ForeignKey import androidx.room3.Fts5 @@ -36,14 +37,6 @@ data class SubsonicSource( val useTokenAuth: Boolean = true, ) -@Entity(tableName = "app_settings") -data class AppSettings( - @PrimaryKey val id: Long = 1, - val maxBitrateWifi: Int = 0, - val maxBitrateMobile: Int = 192, - val streamFormat: String? = null, -) - @Entity( tableName = "artists", primaryKeys = ["sourceId", "id"], @@ -63,6 +56,7 @@ data class Artist( val name: String, val albumCount: Long, val starred: Long?, + val coverArt: String? = null, ) @Entity( @@ -165,6 +159,11 @@ data class Song( val genre: String?, ) +data class SongListItem( + @Embedded val song: Song, + val coverArt: String?, +) + @Entity(tableName = "search_index") @Fts5(tokenizer = FtsOptions.TOKENIZER_TRIGRAM, notIndexed = ["sourceId", "type", "itemId"]) data class SearchIndex( diff --git a/app/src/main/kotlin/com/subtracks/data/model/SubsonicConfig.kt b/app/src/main/kotlin/com/subtracks/data/model/SubsonicConfig.kt new file mode 100644 index 00000000..135ca8f4 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/model/SubsonicConfig.kt @@ -0,0 +1,10 @@ +package com.subtracks.data.model + +data class SubsonicConfig( + val id: Long, + val name: String, + val address: String, + val username: String, + val password: String, + val useTokenAuth: Boolean, +) diff --git a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt new file mode 100644 index 00000000..a3de8b5a --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt @@ -0,0 +1,33 @@ +package com.subtracks.data.prefs + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +enum class AlbumSort { Name, Artist, Year, RecentlyAdded } + +private val Context.preferences: DataStore by preferencesDataStore("user_prefs") + +class UserPreferences( + private val store: DataStore, +) { + val albumSort: Flow = + store.data.map { prefs -> + prefs[ALBUM_SORT]?.let { stored -> AlbumSort.entries.firstOrNull { it.name == stored } } ?: AlbumSort.Name + } + + suspend fun setAlbumSort(sort: AlbumSort) { + store.edit { prefs -> prefs[ALBUM_SORT] = sort.name } + } + + private companion object { + val ALBUM_SORT = stringPreferencesKey("album_sort") + } +} + +fun createUserPreferences(context: Context): UserPreferences = UserPreferences(context.preferences) diff --git a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt new file mode 100644 index 00000000..3672bb27 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt @@ -0,0 +1,71 @@ +package com.subtracks.data.repo + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import com.subtracks.data.db.SubtracksDatabase +import com.subtracks.data.model.Album +import com.subtracks.data.model.Artist +import com.subtracks.data.model.Playlist +import com.subtracks.data.model.Song +import com.subtracks.data.model.SongListItem +import com.subtracks.data.prefs.AlbumSort +import kotlinx.coroutines.flow.Flow + +class LibraryRepository( + private val db: SubtracksDatabase, + private val sourceRepository: SourceRepository, +) { + val activeSourceId: Flow = sourceRepository.activeSourceId() + + fun albums( + sourceId: Long, + sort: AlbumSort, + ): Flow> = + Pager(PagingConfig(pageSize = 40, enablePlaceholders = false)) { + when (sort) { + AlbumSort.Name -> db.libraryDao().albumsByName(sourceId) + AlbumSort.Artist -> db.libraryDao().albumsByArtist(sourceId) + AlbumSort.Year -> db.libraryDao().albumsByYear(sourceId) + AlbumSort.RecentlyAdded -> db.libraryDao().albumsByRecentlyAdded(sourceId) + } + }.flow + + fun artists(sourceId: Long): Flow> = + Pager(PagingConfig(pageSize = 60, enablePlaceholders = false)) { + db.libraryDao().artists(sourceId) + }.flow + + fun songs(sourceId: Long): Flow> = + Pager(PagingConfig(pageSize = 60, enablePlaceholders = false)) { + db.libraryDao().songs(sourceId) + }.flow + + fun playlists(sourceId: Long): Flow> = + Pager(PagingConfig(pageSize = 40, enablePlaceholders = false)) { + db.libraryDao().playlists(sourceId) + }.flow + + fun album( + sourceId: Long, + albumId: String, + ): Flow = db.libraryDao().album(sourceId, albumId) + + fun albumSongs( + sourceId: Long, + albumId: String, + ): Flow> = db.libraryDao().songsByAlbum(sourceId, albumId) + + fun playlist( + sourceId: Long, + playlistId: String, + ): Flow = db.libraryDao().playlist(sourceId, playlistId) + + fun playlistSongs( + sourceId: Long, + playlistId: String, + ): Flow> = + Pager(PagingConfig(pageSize = 60, enablePlaceholders = false)) { + db.libraryDao().playlistSongs(sourceId, playlistId) + }.flow +} diff --git a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt new file mode 100644 index 00000000..2cdfb652 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt @@ -0,0 +1,134 @@ +package com.subtracks.data.repo + +import com.subtracks.data.db.SubtracksDatabase +import com.subtracks.data.model.CoverArtRef +import com.subtracks.data.model.Source +import com.subtracks.data.model.SubsonicConfig +import com.subtracks.data.model.SubsonicSource +import com.subtracks.data.source.subsonic.SubsonicClient +import com.subtracks.data.sync.SyncService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import com.subtracks.data.source.subsonic.SubsonicSource as SubsonicMusicSource + +class SourceRepository( + private val db: SubtracksDatabase, + private val http: OkHttpClient, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + @Volatile + private var active: SubsonicMusicSource? = null + + init { + scope.launch { + db.sourcesDao().activeSubsonicConfig().collect { config -> + active = config?.toMusicSource() + } + } + } + + fun sources(): Flow> = db.sourcesDao().sources() + + fun activeSourceId(): Flow = db.sourcesDao().activeSourceId() + + fun activeConfig(): Flow = db.sourcesDao().activeSubsonicConfig() + + fun coverArt( + coverArt: String?, + thumbnail: Boolean = false, + ): CoverArtRef? { + val source = active ?: return null + val url = source.coverArtUri(coverArt, thumbnail)?.toString() ?: return null + return CoverArtRef(url = url, cacheKey = "${source.id}:$coverArt:$thumbnail") + } + + suspend fun addSource( + name: String, + address: String, + username: String, + password: String, + useTokenAuth: Boolean, + ): Long { + val dao = db.sourcesDao() + val id = + dao.insertSource( + Source( + name = name.trim(), + address = normalizeAddress(address), + isActive = false, + createdAt = System.currentTimeMillis(), + ), + ) + dao.upsertSubsonicSource( + SubsonicSource( + sourceId = id, + username = username.trim(), + password = password, + useTokenAuth = useTokenAuth, + ), + ) + dao.setActiveSource(id) + return id + } + + suspend fun selectSource(id: Long) = db.sourcesDao().setActiveSource(id) + + suspend fun deleteSource(id: Long) { + val dao = db.sourcesDao() + val wasActive = dao.activeSourceIdOnce() == id + dao.deleteSource(id) + if (wasActive) { + dao.firstSourceId()?.let { dao.setActiveSource(it) } + } + } + + suspend fun ping( + address: String, + username: String, + password: String, + useTokenAuth: Boolean, + ): Result = + withContext(Dispatchers.IO) { + runCatching { client(address, username, password, useTokenAuth).get("ping") }.map { } + } + + suspend fun sync(): Result = + runCatching { + val config = db.sourcesDao().activeSubsonicConfigOnce() ?: error("No server configured") + SyncService(db, config.toMusicSource()).sync() + } + + private fun SubsonicConfig.toMusicSource(): SubsonicMusicSource = + SubsonicMusicSource(id, client(address, username, password, useTokenAuth)) + + private fun client( + address: String, + username: String, + password: String, + useTokenAuth: Boolean, + ) = SubsonicClient( + baseUrl = normalizeAddress(address).toHttpUrl(), + username = username, + password = password, + useTokenAuth = useTokenAuth, + http = http, + ) + + private fun normalizeAddress(address: String): String { + val trimmed = address.trim() + val withScheme = + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + trimmed + } else { + "http://$trimmed" + } + return withScheme.trimEnd('/') + "/" + } +} diff --git a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicClient.kt b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicClient.kt index f2adc034..ef4d902e 100644 --- a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicClient.kt +++ b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicClient.kt @@ -1,14 +1,18 @@ package com.subtracks.data.source.subsonic -import java.security.MessageDigest -import javax.xml.parsers.DocumentBuilderFactory import okhttp3.HttpUrl import okhttp3.OkHttpClient import okhttp3.Request import org.w3c.dom.Document import org.w3c.dom.Element +import java.security.MessageDigest +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.parsers.ParserConfigurationException -class SubsonicException(val code: Int, message: String) : Exception(message) +class SubsonicException( + val code: Int, + message: String, +) : Exception(message) class SubsonicClient( private val baseUrl: HttpUrl, @@ -18,7 +22,10 @@ class SubsonicClient( private val http: OkHttpClient, private val userAgent: String = "subtracks/android", ) { - fun uri(method: String, params: Map = emptyMap()): HttpUrl { + fun uri( + method: String, + params: Map = emptyMap(), + ): HttpUrl { val builder = baseUrl.newBuilder().addPathSegments("rest/$method.view") builder.addQueryParameter("v", API_VERSION) builder.addQueryParameter("c", CLIENT) @@ -34,11 +41,16 @@ class SubsonicClient( return builder.build() } - fun get(method: String, params: Map = emptyMap()): Document { - val request = Request.Builder() - .url(uri(method, params)) - .header("User-Agent", userAgent) - .build() + fun get( + method: String, + params: Map = emptyMap(), + ): Document { + val request = + Request + .Builder() + .url(uri(method, params)) + .header("User-Agent", userAgent) + .build() http.newCall(request).execute().use { response -> if (!response.isSuccessful) { throw SubsonicException(-1, "HTTP ${response.code}") @@ -67,16 +79,29 @@ class SubsonicClient( private fun secureDocumentBuilderFactory(): DocumentBuilderFactory = DocumentBuilderFactory.newInstance().apply { isNamespaceAware = false - setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) - setFeature("http://xml.org/sax/features/external-general-entities", false) - setFeature("http://xml.org/sax/features/external-parameter-entities", false) - setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false) + isExpandEntityReferences = false + setFeatureQuietly("http://apache.org/xml/features/disallow-doctype-decl", true) + setFeatureQuietly("http://xml.org/sax/features/external-general-entities", false) + setFeatureQuietly("http://xml.org/sax/features/external-parameter-entities", false) + setFeatureQuietly("http://apache.org/xml/features/nonvalidating/load-external-dtd", false) } + private fun DocumentBuilderFactory.setFeatureQuietly( + name: String, + enabled: Boolean, + ) { + try { + setFeature(name, enabled) + } catch (_: ParserConfigurationException) { + // Android's parser does not implement every hardening flag; the root-tag check in get() is the backstop. + } + } + private fun randomSalt(): String = (1..4).map { ('a'..'z').random() }.joinToString("") private fun md5Hex(input: String): String = - MessageDigest.getInstance("MD5") + MessageDigest + .getInstance("MD5") .digest(input.toByteArray(Charsets.UTF_8)) .joinToString("") { "%02x".format(it) } } diff --git a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt index 7e626fb4..c96be011 100644 --- a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt +++ b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt @@ -21,77 +21,88 @@ class SubsonicSource( withContext(Dispatchers.IO) { client.get("ping") } } - override suspend fun getArtists(): List = withContext(Dispatchers.IO) { - SubsonicXml.artists(id, client.get("getArtists")) - } + override suspend fun getArtists(): List = + withContext(Dispatchers.IO) { + SubsonicXml.artists(id, client.get("getArtists")) + } - override suspend fun getAlbums(): List = withContext(Dispatchers.IO) { - val (frequent, recent) = fetchRanks() - val albums = mutableListOf() - var offset = 0 - var pages = 0 - while (pages < maxPages) { - val document = client.get("getAlbumList2", page("newest", offset)) - val batch = SubsonicXml.albums(id, document) - albums += batch.map { - it.copy(frequentRank = frequent[it.id], recentRank = recent[it.id]) + override suspend fun getAlbums(): List = + withContext(Dispatchers.IO) { + val (frequent, recent) = fetchRanks() + val albums = mutableListOf() + var offset = 0 + var pages = 0 + while (pages < MAX_PAGES) { + val document = client.get("getAlbumList2", page("newest", offset)) + val batch = SubsonicXml.albums(id, document) + albums += + batch.map { + it.copy(frequentRank = frequent[it.id], recentRank = recent[it.id]) + } + if (batch.size < PAGE_SIZE) break + offset += PAGE_SIZE + pages++ } - if (batch.size < pageSize) break - offset += pageSize - pages++ + albums } - albums - } - override suspend fun getSongs(): List = withContext(Dispatchers.IO) { - if (supportsEmptyQuerySearch()) searchSongs() else albumSongs() - } - - override suspend fun getPlaylists(): List = withContext(Dispatchers.IO) { - SubsonicXml.playlists(id, client.get("getPlaylists")) - } - - override suspend fun getPlaylistSongs(playlists: List): List = withContext(Dispatchers.IO) { - val result = mutableListOf() - for (playlist in playlists) { - val document = client.get("getPlaylist", mapOf("id" to playlist.id)) - result += SubsonicXml.playlistSongs(id, playlist.id, document) + override suspend fun getSongs(): List = + withContext(Dispatchers.IO) { + if (supportsEmptyQuerySearch()) searchSongs() else albumSongs() } - result - } - fun streamUri(songId: String) = client.uri( - "stream", - buildMap { - put("id", songId) - put("estimateContentLength", "true") - if (maxBitrate > 0) put("maxBitRate", maxBitrate.toString()) - streamFormat?.takeIf { it.isNotEmpty() }?.let { put("format", it) } - }, - ) + override suspend fun getPlaylists(): List = + withContext(Dispatchers.IO) { + SubsonicXml.playlists(id, client.get("getPlaylists")) + } + + override suspend fun getPlaylistSongs(playlists: List): List = + withContext(Dispatchers.IO) { + val result = mutableListOf() + for (playlist in playlists) { + val document = client.get("getPlaylist", mapOf("id" to playlist.id)) + result += SubsonicXml.playlistSongs(id, playlist.id, document) + } + result + } + + fun streamUri(songId: String) = + client.uri( + "stream", + buildMap { + put("id", songId) + put("estimateContentLength", "true") + if (maxBitrate > 0) put("maxBitRate", maxBitrate.toString()) + streamFormat?.takeIf { it.isNotEmpty() }?.let { put("format", it) } + }, + ) fun downloadUri(songId: String) = client.uri("download", mapOf("id" to songId)) - fun coverArtUri(coverArt: String?, thumbnail: Boolean = false) = client.uri( - "getCoverArt", - buildMap { - put("id", coverArt ?: "") - if (thumbnail) put("size", "256") - }, - ) + fun coverArtUri( + coverArt: String?, + thumbnail: Boolean = false, + ) = coverArt?.let { + client.uri( + "getCoverArt", + buildMap { + put("id", it) + if (thumbnail) put("size", THUMBNAIL_SIZE.toString()) + }, + ) + } - private fun fetchRanks(): Pair, Map> = - fetchRank("frequent") to fetchRank("recent") + private fun fetchRanks(): Pair, Map> = fetchRank("frequent") to fetchRank("recent") private fun fetchRank(type: String): Map { val ranks = mutableMapOf() var offset = 0 var pages = 0 - while (pages < maxPages) { + while (pages < MAX_PAGES) { val batch = SubsonicXml.albums(id, client.get("getAlbumList2", page(type, offset))) batch.forEachIndexed { index, album -> ranks[album.id] = (offset + index).toLong() } - if (batch.size < pageSize) break - offset += pageSize + if (batch.size < PAGE_SIZE) break + offset += PAGE_SIZE pages++ } return ranks @@ -99,15 +110,17 @@ class SubsonicSource( private fun supportsEmptyQuerySearch(): Boolean { emptyQuerySearchSupported?.let { return it } - val supported = try { - val document = client.get( - "search3", - mapOf("query" to "\"\"", "songCount" to "1", "artistCount" to "0", "albumCount" to "0"), - ) - document.getElementsByTagName("song").length > 0 - } catch (_: SubsonicException) { - false - } + val supported = + try { + val document = + client.get( + "search3", + mapOf("query" to "\"\"", "songCount" to "1", "artistCount" to "0", "albumCount" to "0"), + ) + document.getElementsByTagName("song").length > 0 + } catch (_: SubsonicException) { + false + } emptyQuerySearchSupported = supported return supported } @@ -116,21 +129,22 @@ class SubsonicSource( val songs = mutableListOf() var offset = 0 var pages = 0 - while (pages < maxPages) { - val document = client.get( - "search3", - mapOf( - "query" to "\"\"", - "songCount" to pageSize.toString(), - "songOffset" to offset.toString(), - "artistCount" to "0", - "albumCount" to "0", - ), - ) + while (pages < MAX_PAGES) { + val document = + client.get( + "search3", + mapOf( + "query" to "\"\"", + "songCount" to PAGE_SIZE.toString(), + "songOffset" to offset.toString(), + "artistCount" to "0", + "albumCount" to "0", + ), + ) val batch = SubsonicXml.songs(id, document) songs += batch - if (batch.size < pageSize) break - offset += pageSize + if (batch.size < PAGE_SIZE) break + offset += PAGE_SIZE pages++ } return songs @@ -140,28 +154,33 @@ class SubsonicSource( val songs = mutableListOf() var offset = 0 var pages = 0 - while (pages < maxPages) { + while (pages < MAX_PAGES) { val document = client.get("getAlbumList2", page("alphabeticalByName", offset)) val albums = SubsonicXml.albums(id, document) for (album in albums) { val albumDocument = client.get("getAlbum", mapOf("id" to album.id)) songs += SubsonicXml.songs(id, albumDocument) } - if (albums.size < pageSize) break - offset += pageSize + if (albums.size < PAGE_SIZE) break + offset += PAGE_SIZE pages++ } return songs } - private fun page(type: String, offset: Int): Map = mapOf( - "type" to type, - "size" to pageSize.toString(), - "offset" to offset.toString(), - ) + private fun page( + type: String, + offset: Int, + ): Map = + mapOf( + "type" to type, + "size" to PAGE_SIZE.toString(), + "offset" to offset.toString(), + ) private companion object { - const val pageSize = 500 - const val maxPages = 1000 + const val PAGE_SIZE = 500 + const val MAX_PAGES = 1000 + const val THUMBNAIL_SIZE = 256 } } diff --git a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt index 051aa1e3..7d5b6d64 100644 --- a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt +++ b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt @@ -5,27 +5,38 @@ import com.subtracks.data.model.Artist import com.subtracks.data.model.Playlist import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.Song -import javax.xml.datatype.DatatypeConfigurationException -import javax.xml.datatype.DatatypeFactory import org.w3c.dom.Document import org.w3c.dom.Element +import javax.xml.datatype.DatatypeConfigurationException +import javax.xml.datatype.DatatypeFactory object SubsonicXml { - fun artists(sourceId: Long, document: Document): List = - document.elements("artist").map { element -> - Artist( - sourceId = sourceId, - id = element.attr("id"), - name = element.attr("name"), - albumCount = element.longAttr("albumCount") ?: 0L, - starred = element.dateAttr("starred"), - ) - }.filter { it.id.isNotEmpty() } + fun artists( + sourceId: Long, + document: Document, + ): List = + document + .elements("artist") + .map { element -> + Artist( + sourceId = sourceId, + id = element.attr("id"), + name = element.attr("name"), + albumCount = element.longAttr("albumCount") ?: 0L, + starred = element.dateAttr("starred"), + coverArt = element.attr("coverArt").ifEmpty { null }, + ) + }.filter { it.id.isNotEmpty() } - fun albums(sourceId: Long, document: Document): List = - document.elements("album").map { album(sourceId, it) }.filter { it.id.isNotEmpty() } + fun albums( + sourceId: Long, + document: Document, + ): List = document.elements("album").map { album(sourceId, it) }.filter { it.id.isNotEmpty() } - fun album(sourceId: Long, element: Element): Album = + fun album( + sourceId: Long, + element: Element, + ): Album = Album( sourceId = sourceId, id = element.attr("id"), @@ -42,10 +53,15 @@ object SubsonicXml { recentRank = null, ) - fun songs(sourceId: Long, document: Document): List = - document.elements("song").map { song(sourceId, it) }.filter { it.id.isNotEmpty() } + fun songs( + sourceId: Long, + document: Document, + ): List = document.elements("song").map { song(sourceId, it) }.filter { it.id.isNotEmpty() } - fun song(sourceId: Long, element: Element): Song = + fun song( + sourceId: Long, + element: Element, + ): Song = Song( sourceId = sourceId, id = element.attr("id"), @@ -61,10 +77,15 @@ object SubsonicXml { genre = element.attr("genre").ifEmpty { null }, ) - fun playlists(sourceId: Long, document: Document): List = - document.elements("playlist").map { playlist(sourceId, it) }.filter { it.id.isNotEmpty() } + fun playlists( + sourceId: Long, + document: Document, + ): List = document.elements("playlist").map { playlist(sourceId, it) }.filter { it.id.isNotEmpty() } - fun playlist(sourceId: Long, element: Element): Playlist = + fun playlist( + sourceId: Long, + element: Element, + ): Playlist = Playlist( sourceId = sourceId, id = element.attr("id"), @@ -75,7 +96,11 @@ object SubsonicXml { created = element.dateAttr("created") ?: 0L, ) - fun playlistSongs(sourceId: Long, playlistId: String, document: Document): List = + fun playlistSongs( + sourceId: Long, + playlistId: String, + document: Document, + ): List = document.elements("entry").mapIndexed { index, element -> PlaylistSong( sourceId = sourceId, @@ -93,20 +118,19 @@ private fun Document.elements(tag: String): List { private fun Element.attr(name: String): String = getAttribute(name) -private fun Element.longAttr(name: String): Long? = - getAttribute(name).takeIf { it.isNotEmpty() }?.toLongOrNull() +private fun Element.longAttr(name: String): Long? = getAttribute(name).takeIf { it.isNotEmpty() }?.toLongOrNull() -private fun Element.dateAttr(name: String): Long? = - getAttribute(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse) +private fun Element.dateAttr(name: String): Long? = getAttribute(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse) internal object IsoDate { private val timezone = Regex("(Z|[+-]\\d{2}:?\\d{2})$", RegexOption.IGNORE_CASE) - private val factory: DatatypeFactory? = try { - DatatypeFactory.newInstance() - } catch (_: DatatypeConfigurationException) { - null - } + private val factory: DatatypeFactory? = + try { + DatatypeFactory.newInstance() + } catch (_: DatatypeConfigurationException) { + null + } fun parse(value: String): Long? { val factory = factory ?: return null diff --git a/app/src/main/kotlin/com/subtracks/data/sync/SyncManager.kt b/app/src/main/kotlin/com/subtracks/data/sync/SyncManager.kt new file mode 100644 index 00000000..981b7fdc --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/sync/SyncManager.kt @@ -0,0 +1,50 @@ +package com.subtracks.data.sync + +import android.util.Log +import com.subtracks.data.repo.SourceRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +sealed interface SyncStatus { + data object Idle : SyncStatus + + data object Running : SyncStatus + + data object Success : SyncStatus + + data class Failed( + val message: String, + ) : SyncStatus +} + +class SyncManager( + private val sourceRepository: SourceRepository, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val _status = MutableStateFlow(SyncStatus.Idle) + val status: StateFlow = _status + + fun requestSync() { + scope.launch { runSync() } + } + + private suspend fun runSync() { + _status.value = SyncStatus.Running + _status.value = + sourceRepository.sync().fold( + onSuccess = { SyncStatus.Success }, + onFailure = { error -> + Log.w(TAG, "Sync failed", error) + SyncStatus.Failed(error.message ?: "Sync failed") + }, + ) + } + + private companion object { + const val TAG = "SubtracksSync" + } +} diff --git a/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt b/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt index 83632b55..84b98d8d 100644 --- a/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt +++ b/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt @@ -14,43 +14,44 @@ class SyncService( ) { private val lock = Mutex() - suspend fun sync() = lock.withLock { - val artists = source.getArtists() - val albums = source.getAlbums() - val songs = source.getSongs() - val playlists = source.getPlaylists() - val playlistSongs = source.getPlaylistSongs(playlists) + suspend fun sync() = + lock.withLock { + val artists = source.getArtists() + val albums = source.getAlbums() + val songs = source.getSongs() + val playlists = source.getPlaylists() + val playlistSongs = source.getPlaylistSongs(playlists) - db.useWriterConnection { transactor -> - transactor.immediateTransaction { - val library = db.libraryDao() - library.upsertArtists(artists) - library.upsertAlbums(albums) - library.upsertSongs(songs) - library.upsertPlaylists(playlists) + db.useWriterConnection { transactor -> + transactor.immediateTransaction { + val library = db.libraryDao() + library.upsertArtists(artists) + library.upsertAlbums(albums) + library.upsertSongs(songs) + library.upsertPlaylists(playlists) - deleteMissing(library.artistIds(source.id), artists.map { it.id }) { library.deleteArtists(source.id, it) } - deleteMissing(library.albumIds(source.id), albums.map { it.id }) { library.deleteAlbums(source.id, it) } - deleteMissing(library.songIds(source.id), songs.map { it.id }) { library.deleteSongs(source.id, it) } - deleteMissing(library.playlistIds(source.id), playlists.map { it.id }) { library.deletePlaylists(source.id, it) } + deleteMissing(library.artistIds(source.id), artists.map { it.id }) { library.deleteArtists(source.id, it) } + deleteMissing(library.albumIds(source.id), albums.map { it.id }) { library.deleteAlbums(source.id, it) } + deleteMissing(library.songIds(source.id), songs.map { it.id }) { library.deleteSongs(source.id, it) } + deleteMissing(library.playlistIds(source.id), playlists.map { it.id }) { library.deletePlaylists(source.id, it) } - library.deletePlaylistSongs(source.id) - library.upsertPlaylistSongs(playlistSongs) + library.deletePlaylistSongs(source.id) + library.upsertPlaylistSongs(playlistSongs) - val search = db.searchDao() - val sourceKey = source.id.toString() - search.clear(sourceKey) - search.insert( - buildList { - artists.forEach { add(SearchIndex(sourceId = sourceKey, type = "artist", itemId = it.id, title = it.name)) } - albums.forEach { add(SearchIndex(sourceId = sourceKey, type = "album", itemId = it.id, title = it.name)) } - songs.forEach { add(SearchIndex(sourceId = sourceKey, type = "song", itemId = it.id, title = it.title)) } - playlists.forEach { add(SearchIndex(sourceId = sourceKey, type = "playlist", itemId = it.id, title = it.name)) } - }, - ) + val search = db.searchDao() + val sourceKey = source.id.toString() + search.clear(sourceKey) + search.insert( + buildList { + artists.forEach { add(SearchIndex(sourceId = sourceKey, type = "artist", itemId = it.id, title = it.name)) } + albums.forEach { add(SearchIndex(sourceId = sourceKey, type = "album", itemId = it.id, title = it.name)) } + songs.forEach { add(SearchIndex(sourceId = sourceKey, type = "song", itemId = it.id, title = it.title)) } + playlists.forEach { add(SearchIndex(sourceId = sourceKey, type = "playlist", itemId = it.id, title = it.name)) } + }, + ) + } } } - } private suspend fun deleteMissing( existing: List, @@ -58,7 +59,8 @@ class SyncService( delete: suspend (Collection) -> Unit, ) { val currentIds = current.toHashSet() - existing.filterNot { it in currentIds } + existing + .filterNot { it in currentIds } .chunked(500) .forEach { delete(it) } } diff --git a/app/src/main/kotlin/com/subtracks/di/AppModule.kt b/app/src/main/kotlin/com/subtracks/di/AppModule.kt new file mode 100644 index 00000000..43a8bee3 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/di/AppModule.kt @@ -0,0 +1,35 @@ +package com.subtracks.di + +import android.content.Context +import com.subtracks.data.db.createAndroidDatabase +import com.subtracks.data.prefs.createUserPreferences +import com.subtracks.data.repo.LibraryRepository +import com.subtracks.data.repo.SourceRepository +import com.subtracks.data.sync.SyncManager +import com.subtracks.ui.RootViewModel +import com.subtracks.ui.library.AlbumDetailViewModel +import com.subtracks.ui.library.LibraryViewModel +import com.subtracks.ui.library.PlaylistDetailViewModel +import com.subtracks.ui.settings.AddSourceViewModel +import com.subtracks.ui.settings.SettingsViewModel +import okhttp3.OkHttpClient +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +fun appModule( + context: Context, + http: OkHttpClient, +) = module { + single { createAndroidDatabase(context) } + single { http } + single { createUserPreferences(context) } + single { SourceRepository(get(), get()) } + single { LibraryRepository(get(), get()) } + single { SyncManager(get()) } + viewModel { RootViewModel(get()) } + viewModel { LibraryViewModel(get(), get(), get(), get()) } + viewModel { SettingsViewModel(get()) } + viewModel { AddSourceViewModel(get(), get()) } + viewModel { params -> AlbumDetailViewModel(get(), get(), params.get()) } + viewModel { params -> PlaylistDetailViewModel(get(), get(), params.get()) } +}