add preferences, paging reads, repositories and sync status

This commit is contained in:
austinried
2026-10-04 00:29:51 +00:00
parent 46bc95104d
commit 3f91a29850
18 changed files with 691 additions and 239 deletions
@@ -1,8 +1,8 @@
{ {
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 1, "version": 2,
"identityHash": "c359cb54d1d99a2de8efbe1f0c8ad182", "identityHash": "67a9c26297d65d76f54edc99ac9a7930",
"entities": [ "entities": [
{ {
"tableName": "sources", "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", "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": [ "fields": [
{ {
"fieldPath": "sourceId", "fieldPath": "sourceId",
@@ -173,6 +138,11 @@
"fieldPath": "starred", "fieldPath": "starred",
"columnName": "starred", "columnName": "starred",
"affinity": "INTEGER" "affinity": "INTEGER"
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT"
} }
], ],
"primaryKey": { "primaryKey": {
@@ -643,7 +613,7 @@
], ],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "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')"
] ]
} }
} }
@@ -5,6 +5,7 @@ import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.driver.bundled.BundledSQLiteDriver
fun createAndroidDatabase(context: Context): SubtracksDatabase = fun createAndroidDatabase(context: Context): SubtracksDatabase =
Room.databaseBuilder<SubtracksDatabase>(context, "subtracks.db") Room
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
.setDriver(BundledSQLiteDriver()) .setDriver(BundledSQLiteDriver())
.build() .build()
@@ -1,16 +1,21 @@
package com.subtracks.data.db package com.subtracks.data.db
import androidx.paging.PagingSource
import androidx.room3.Dao import androidx.room3.Dao
import androidx.room3.DaoReturnTypeConverters
import androidx.room3.Query import androidx.room3.Query
import androidx.room3.Upsert import androidx.room3.Upsert
import androidx.room3.paging.PagingSourceDaoReturnTypeConverter
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.SongListItem
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @Dao
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
interface LibraryDao { interface LibraryDao {
@Upsert @Upsert
suspend fun upsertArtists(items: List<Artist>) suspend fun upsertArtists(items: List<Artist>)
@@ -40,43 +45,85 @@ interface LibraryDao {
suspend fun songIds(sourceId: Long): List<String> suspend fun songIds(sourceId: Long): List<String>
@Query("DELETE FROM artists WHERE sourceId = :sourceId AND id IN (:ids)") @Query("DELETE FROM artists WHERE sourceId = :sourceId AND id IN (:ids)")
suspend fun deleteArtists(sourceId: Long, ids: Collection<String>) suspend fun deleteArtists(
sourceId: Long,
ids: Collection<String>,
)
@Query("DELETE FROM albums WHERE sourceId = :sourceId AND id IN (:ids)") @Query("DELETE FROM albums WHERE sourceId = :sourceId AND id IN (:ids)")
suspend fun deleteAlbums(sourceId: Long, ids: Collection<String>) suspend fun deleteAlbums(
sourceId: Long,
ids: Collection<String>,
)
@Query("DELETE FROM playlists WHERE sourceId = :sourceId AND id IN (:ids)") @Query("DELETE FROM playlists WHERE sourceId = :sourceId AND id IN (:ids)")
suspend fun deletePlaylists(sourceId: Long, ids: Collection<String>) suspend fun deletePlaylists(
sourceId: Long,
ids: Collection<String>,
)
@Query("DELETE FROM songs WHERE sourceId = :sourceId AND id IN (:ids)") @Query("DELETE FROM songs WHERE sourceId = :sourceId AND id IN (:ids)")
suspend fun deleteSongs(sourceId: Long, ids: Collection<String>) suspend fun deleteSongs(
sourceId: Long,
ids: Collection<String>,
)
@Query("DELETE FROM playlist_songs WHERE sourceId = :sourceId") @Query("DELETE FROM playlist_songs WHERE sourceId = :sourceId")
suspend fun deletePlaylistSongs(sourceId: Long) suspend fun deletePlaylistSongs(sourceId: Long)
@Query("SELECT * FROM artists WHERE sourceId = :sourceId ORDER BY name") @Query("SELECT * FROM artists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE")
fun artists(sourceId: Long): Flow<List<Artist>> fun artists(sourceId: Long): PagingSource<Int, Artist>
@Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY name") @Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE")
fun albums(sourceId: Long): Flow<List<Album>> fun albumsByName(sourceId: Long): PagingSource<Int, Album>
@Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY name") @Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY albumArtist COLLATE NOCASE, year, name COLLATE NOCASE")
fun playlists(sourceId: Long): Flow<List<Playlist>> fun albumsByArtist(sourceId: Long): PagingSource<Int, Album>
@Query("SELECT * FROM songs WHERE sourceId = :sourceId ORDER BY artist, album, disc, track") @Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY year DESC, name COLLATE NOCASE")
fun songs(sourceId: Long): Flow<List<Song>> fun albumsByYear(sourceId: Long): PagingSource<Int, Album>
@Query("SELECT * FROM songs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc, track") @Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY created DESC, name COLLATE NOCASE")
fun songsByAlbum(sourceId: Long, albumId: String): Flow<List<Song>> fun albumsByRecentlyAdded(sourceId: Long): PagingSource<Int, Album>
@Query( @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<Int, SongListItem>
@Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE")
fun playlists(sourceId: Long): PagingSource<Int, Playlist>
@Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM playlist_songs " +
"JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " + "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 " + "WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId " +
"ORDER BY playlist_songs.position", "ORDER BY playlist_songs.position",
) )
fun songsByPlaylist(sourceId: Long, playlistId: String): Flow<List<Song>> fun playlistSongs(
sourceId: Long,
playlistId: String,
): PagingSource<Int, SongListItem>
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND artistId = :artistId ORDER BY year DESC, name") @Query("SELECT * FROM albums WHERE sourceId = :sourceId AND id = :albumId")
fun albumsByArtist(sourceId: Long, artistId: String): Flow<List<Album>> fun album(
sourceId: Long,
albumId: String,
): Flow<Album?>
@Query("SELECT * FROM playlists WHERE sourceId = :sourceId AND id = :playlistId")
fun playlist(
sourceId: Long,
playlistId: String,
): Flow<Playlist?>
@Query("SELECT * FROM songs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc, track")
fun songsByAlbum(
sourceId: Long,
albumId: String,
): Flow<List<Song>>
} }
@@ -22,9 +22,17 @@ interface SearchDao {
LIMIT :limit LIMIT :limit
""", """,
) )
suspend fun searchPhrase(sourceId: String, query: String, limit: Int): List<SearchIndex> suspend fun searchPhrase(
sourceId: String,
query: String,
limit: Int,
): List<SearchIndex>
suspend fun search(sourceId: String, query: String, limit: Int): List<SearchIndex> { suspend fun search(
sourceId: String,
query: String,
limit: Int,
): List<SearchIndex> {
val phrase = query.trim() val phrase = query.trim()
if (phrase.length < MIN_QUERY_LENGTH) return emptyList() if (phrase.length < MIN_QUERY_LENGTH) return emptyList()
return searchPhrase(sourceId, phrase, limit) return searchPhrase(sourceId, phrase, limit)
@@ -1,11 +1,12 @@
package com.subtracks.data.db package com.subtracks.data.db
import androidx.room3.Dao import androidx.room3.Dao
import androidx.room3.Insert
import androidx.room3.Query import androidx.room3.Query
import androidx.room3.Transaction import androidx.room3.Transaction
import androidx.room3.Upsert import androidx.room3.Upsert
import com.subtracks.data.model.AppSettings
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.data.model.SubsonicConfig
import com.subtracks.data.model.SubsonicSource import com.subtracks.data.model.SubsonicSource
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -23,6 +24,15 @@ interface SourcesDao {
@Query("SELECT * FROM subsonic_sources WHERE sourceId = :sourceId") @Query("SELECT * FROM subsonic_sources WHERE sourceId = :sourceId")
fun subsonicSource(sourceId: Long): Flow<SubsonicSource?> fun subsonicSource(sourceId: Long): Flow<SubsonicSource?>
@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 @Upsert
suspend fun upsertSource(source: Source) suspend fun upsertSource(source: Source)
@@ -44,9 +54,19 @@ interface SourcesDao {
@Query("UPDATE sources SET isActive = (id = :sourceId)") @Query("UPDATE sources SET isActive = (id = :sourceId)")
suspend fun setActiveSource(sourceId: Long) suspend fun setActiveSource(sourceId: Long)
@Query("SELECT * FROM app_settings WHERE id = 1") @Query(
fun appSettings(): Flow<AppSettings?> "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<SubsonicConfig?>
@Upsert @Query(
suspend fun upsertAppSettings(settings: AppSettings) "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?
} }
@@ -3,7 +3,6 @@ package com.subtracks.data.db
import androidx.room3.Database import androidx.room3.Database
import androidx.room3.RoomDatabase import androidx.room3.RoomDatabase
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.AppSettings
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.PlaylistSong
@@ -16,7 +15,6 @@ import com.subtracks.data.model.SubsonicSource
entities = [ entities = [
Source::class, Source::class,
SubsonicSource::class, SubsonicSource::class,
AppSettings::class,
Artist::class, Artist::class,
Album::class, Album::class,
Playlist::class, Playlist::class,
@@ -24,7 +22,7 @@ import com.subtracks.data.model.SubsonicSource
Song::class, Song::class,
SearchIndex::class, SearchIndex::class,
], ],
version = 1, version = 2,
exportSchema = true, exportSchema = true,
) )
abstract class SubtracksDatabase : RoomDatabase() { abstract class SubtracksDatabase : RoomDatabase() {
@@ -0,0 +1,6 @@
package com.subtracks.data.model
data class CoverArtRef(
val url: String,
val cacheKey: String,
)
@@ -1,6 +1,7 @@
package com.subtracks.data.model package com.subtracks.data.model
import androidx.room3.ColumnInfo import androidx.room3.ColumnInfo
import androidx.room3.Embedded
import androidx.room3.Entity import androidx.room3.Entity
import androidx.room3.ForeignKey import androidx.room3.ForeignKey
import androidx.room3.Fts5 import androidx.room3.Fts5
@@ -36,14 +37,6 @@ data class SubsonicSource(
val useTokenAuth: Boolean = true, 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( @Entity(
tableName = "artists", tableName = "artists",
primaryKeys = ["sourceId", "id"], primaryKeys = ["sourceId", "id"],
@@ -63,6 +56,7 @@ data class Artist(
val name: String, val name: String,
val albumCount: Long, val albumCount: Long,
val starred: Long?, val starred: Long?,
val coverArt: String? = null,
) )
@Entity( @Entity(
@@ -165,6 +159,11 @@ data class Song(
val genre: String?, val genre: String?,
) )
data class SongListItem(
@Embedded val song: Song,
val coverArt: String?,
)
@Entity(tableName = "search_index") @Entity(tableName = "search_index")
@Fts5(tokenizer = FtsOptions.TOKENIZER_TRIGRAM, notIndexed = ["sourceId", "type", "itemId"]) @Fts5(tokenizer = FtsOptions.TOKENIZER_TRIGRAM, notIndexed = ["sourceId", "type", "itemId"])
data class SearchIndex( data class SearchIndex(
@@ -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,
)
@@ -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<Preferences> by preferencesDataStore("user_prefs")
class UserPreferences(
private val store: DataStore<Preferences>,
) {
val albumSort: Flow<AlbumSort> =
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)
@@ -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<Long?> = sourceRepository.activeSourceId()
fun albums(
sourceId: Long,
sort: AlbumSort,
): Flow<PagingData<Album>> =
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<PagingData<Artist>> =
Pager(PagingConfig(pageSize = 60, enablePlaceholders = false)) {
db.libraryDao().artists(sourceId)
}.flow
fun songs(sourceId: Long): Flow<PagingData<SongListItem>> =
Pager(PagingConfig(pageSize = 60, enablePlaceholders = false)) {
db.libraryDao().songs(sourceId)
}.flow
fun playlists(sourceId: Long): Flow<PagingData<Playlist>> =
Pager(PagingConfig(pageSize = 40, enablePlaceholders = false)) {
db.libraryDao().playlists(sourceId)
}.flow
fun album(
sourceId: Long,
albumId: String,
): Flow<Album?> = db.libraryDao().album(sourceId, albumId)
fun albumSongs(
sourceId: Long,
albumId: String,
): Flow<List<Song>> = db.libraryDao().songsByAlbum(sourceId, albumId)
fun playlist(
sourceId: Long,
playlistId: String,
): Flow<Playlist?> = db.libraryDao().playlist(sourceId, playlistId)
fun playlistSongs(
sourceId: Long,
playlistId: String,
): Flow<PagingData<SongListItem>> =
Pager(PagingConfig(pageSize = 60, enablePlaceholders = false)) {
db.libraryDao().playlistSongs(sourceId, playlistId)
}.flow
}
@@ -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<List<Source>> = db.sourcesDao().sources()
fun activeSourceId(): Flow<Long?> = db.sourcesDao().activeSourceId()
fun activeConfig(): Flow<SubsonicConfig?> = 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<Unit> =
withContext(Dispatchers.IO) {
runCatching { client(address, username, password, useTokenAuth).get("ping") }.map { }
}
suspend fun sync(): Result<Unit> =
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('/') + "/"
}
}
@@ -1,14 +1,18 @@
package com.subtracks.data.source.subsonic package com.subtracks.data.source.subsonic
import java.security.MessageDigest
import javax.xml.parsers.DocumentBuilderFactory
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.w3c.dom.Document import org.w3c.dom.Document
import org.w3c.dom.Element 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( class SubsonicClient(
private val baseUrl: HttpUrl, private val baseUrl: HttpUrl,
@@ -18,7 +22,10 @@ class SubsonicClient(
private val http: OkHttpClient, private val http: OkHttpClient,
private val userAgent: String = "subtracks/android", private val userAgent: String = "subtracks/android",
) { ) {
fun uri(method: String, params: Map<String, String> = emptyMap()): HttpUrl { fun uri(
method: String,
params: Map<String, String> = emptyMap(),
): HttpUrl {
val builder = baseUrl.newBuilder().addPathSegments("rest/$method.view") val builder = baseUrl.newBuilder().addPathSegments("rest/$method.view")
builder.addQueryParameter("v", API_VERSION) builder.addQueryParameter("v", API_VERSION)
builder.addQueryParameter("c", CLIENT) builder.addQueryParameter("c", CLIENT)
@@ -34,8 +41,13 @@ class SubsonicClient(
return builder.build() return builder.build()
} }
fun get(method: String, params: Map<String, String> = emptyMap()): Document { fun get(
val request = Request.Builder() method: String,
params: Map<String, String> = emptyMap(),
): Document {
val request =
Request
.Builder()
.url(uri(method, params)) .url(uri(method, params))
.header("User-Agent", userAgent) .header("User-Agent", userAgent)
.build() .build()
@@ -67,16 +79,29 @@ class SubsonicClient(
private fun secureDocumentBuilderFactory(): DocumentBuilderFactory = private fun secureDocumentBuilderFactory(): DocumentBuilderFactory =
DocumentBuilderFactory.newInstance().apply { DocumentBuilderFactory.newInstance().apply {
isNamespaceAware = false isNamespaceAware = false
setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) isExpandEntityReferences = false
setFeature("http://xml.org/sax/features/external-general-entities", false) setFeatureQuietly("http://apache.org/xml/features/disallow-doctype-decl", true)
setFeature("http://xml.org/sax/features/external-parameter-entities", false) setFeatureQuietly("http://xml.org/sax/features/external-general-entities", false)
setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", 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 randomSalt(): String = (1..4).map { ('a'..'z').random() }.joinToString("")
private fun md5Hex(input: String): String = private fun md5Hex(input: String): String =
MessageDigest.getInstance("MD5") MessageDigest
.getInstance("MD5")
.digest(input.toByteArray(Charsets.UTF_8)) .digest(input.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) } .joinToString("") { "%02x".format(it) }
} }
@@ -21,37 +21,43 @@ class SubsonicSource(
withContext(Dispatchers.IO) { client.get("ping") } withContext(Dispatchers.IO) { client.get("ping") }
} }
override suspend fun getArtists(): List<Artist> = withContext(Dispatchers.IO) { override suspend fun getArtists(): List<Artist> =
withContext(Dispatchers.IO) {
SubsonicXml.artists(id, client.get("getArtists")) SubsonicXml.artists(id, client.get("getArtists"))
} }
override suspend fun getAlbums(): List<Album> = withContext(Dispatchers.IO) { override suspend fun getAlbums(): List<Album> =
withContext(Dispatchers.IO) {
val (frequent, recent) = fetchRanks() val (frequent, recent) = fetchRanks()
val albums = mutableListOf<Album>() val albums = mutableListOf<Album>()
var offset = 0 var offset = 0
var pages = 0 var pages = 0
while (pages < maxPages) { while (pages < MAX_PAGES) {
val document = client.get("getAlbumList2", page("newest", offset)) val document = client.get("getAlbumList2", page("newest", offset))
val batch = SubsonicXml.albums(id, document) val batch = SubsonicXml.albums(id, document)
albums += batch.map { albums +=
batch.map {
it.copy(frequentRank = frequent[it.id], recentRank = recent[it.id]) it.copy(frequentRank = frequent[it.id], recentRank = recent[it.id])
} }
if (batch.size < pageSize) break if (batch.size < PAGE_SIZE) break
offset += pageSize offset += PAGE_SIZE
pages++ pages++
} }
albums albums
} }
override suspend fun getSongs(): List<Song> = withContext(Dispatchers.IO) { override suspend fun getSongs(): List<Song> =
withContext(Dispatchers.IO) {
if (supportsEmptyQuerySearch()) searchSongs() else albumSongs() if (supportsEmptyQuerySearch()) searchSongs() else albumSongs()
} }
override suspend fun getPlaylists(): List<Playlist> = withContext(Dispatchers.IO) { override suspend fun getPlaylists(): List<Playlist> =
withContext(Dispatchers.IO) {
SubsonicXml.playlists(id, client.get("getPlaylists")) SubsonicXml.playlists(id, client.get("getPlaylists"))
} }
override suspend fun getPlaylistSongs(playlists: List<Playlist>): List<PlaylistSong> = withContext(Dispatchers.IO) { override suspend fun getPlaylistSongs(playlists: List<Playlist>): List<PlaylistSong> =
withContext(Dispatchers.IO) {
val result = mutableListOf<PlaylistSong>() val result = mutableListOf<PlaylistSong>()
for (playlist in playlists) { for (playlist in playlists) {
val document = client.get("getPlaylist", mapOf("id" to playlist.id)) val document = client.get("getPlaylist", mapOf("id" to playlist.id))
@@ -60,7 +66,8 @@ class SubsonicSource(
result result
} }
fun streamUri(songId: String) = client.uri( fun streamUri(songId: String) =
client.uri(
"stream", "stream",
buildMap { buildMap {
put("id", songId) put("id", songId)
@@ -72,26 +79,30 @@ class SubsonicSource(
fun downloadUri(songId: String) = client.uri("download", mapOf("id" to songId)) fun downloadUri(songId: String) = client.uri("download", mapOf("id" to songId))
fun coverArtUri(coverArt: String?, thumbnail: Boolean = false) = client.uri( fun coverArtUri(
coverArt: String?,
thumbnail: Boolean = false,
) = coverArt?.let {
client.uri(
"getCoverArt", "getCoverArt",
buildMap { buildMap {
put("id", coverArt ?: "") put("id", it)
if (thumbnail) put("size", "256") if (thumbnail) put("size", THUMBNAIL_SIZE.toString())
}, },
) )
}
private fun fetchRanks(): Pair<Map<String, Long>, Map<String, Long>> = private fun fetchRanks(): Pair<Map<String, Long>, Map<String, Long>> = fetchRank("frequent") to fetchRank("recent")
fetchRank("frequent") to fetchRank("recent")
private fun fetchRank(type: String): Map<String, Long> { private fun fetchRank(type: String): Map<String, Long> {
val ranks = mutableMapOf<String, Long>() val ranks = mutableMapOf<String, Long>()
var offset = 0 var offset = 0
var pages = 0 var pages = 0
while (pages < maxPages) { while (pages < MAX_PAGES) {
val batch = SubsonicXml.albums(id, client.get("getAlbumList2", page(type, offset))) val batch = SubsonicXml.albums(id, client.get("getAlbumList2", page(type, offset)))
batch.forEachIndexed { index, album -> ranks[album.id] = (offset + index).toLong() } batch.forEachIndexed { index, album -> ranks[album.id] = (offset + index).toLong() }
if (batch.size < pageSize) break if (batch.size < PAGE_SIZE) break
offset += pageSize offset += PAGE_SIZE
pages++ pages++
} }
return ranks return ranks
@@ -99,8 +110,10 @@ class SubsonicSource(
private fun supportsEmptyQuerySearch(): Boolean { private fun supportsEmptyQuerySearch(): Boolean {
emptyQuerySearchSupported?.let { return it } emptyQuerySearchSupported?.let { return it }
val supported = try { val supported =
val document = client.get( try {
val document =
client.get(
"search3", "search3",
mapOf("query" to "\"\"", "songCount" to "1", "artistCount" to "0", "albumCount" to "0"), mapOf("query" to "\"\"", "songCount" to "1", "artistCount" to "0", "albumCount" to "0"),
) )
@@ -116,12 +129,13 @@ class SubsonicSource(
val songs = mutableListOf<Song>() val songs = mutableListOf<Song>()
var offset = 0 var offset = 0
var pages = 0 var pages = 0
while (pages < maxPages) { while (pages < MAX_PAGES) {
val document = client.get( val document =
client.get(
"search3", "search3",
mapOf( mapOf(
"query" to "\"\"", "query" to "\"\"",
"songCount" to pageSize.toString(), "songCount" to PAGE_SIZE.toString(),
"songOffset" to offset.toString(), "songOffset" to offset.toString(),
"artistCount" to "0", "artistCount" to "0",
"albumCount" to "0", "albumCount" to "0",
@@ -129,8 +143,8 @@ class SubsonicSource(
) )
val batch = SubsonicXml.songs(id, document) val batch = SubsonicXml.songs(id, document)
songs += batch songs += batch
if (batch.size < pageSize) break if (batch.size < PAGE_SIZE) break
offset += pageSize offset += PAGE_SIZE
pages++ pages++
} }
return songs return songs
@@ -140,28 +154,33 @@ class SubsonicSource(
val songs = mutableListOf<Song>() val songs = mutableListOf<Song>()
var offset = 0 var offset = 0
var pages = 0 var pages = 0
while (pages < maxPages) { while (pages < MAX_PAGES) {
val document = client.get("getAlbumList2", page("alphabeticalByName", offset)) val document = client.get("getAlbumList2", page("alphabeticalByName", offset))
val albums = SubsonicXml.albums(id, document) val albums = SubsonicXml.albums(id, document)
for (album in albums) { for (album in albums) {
val albumDocument = client.get("getAlbum", mapOf("id" to album.id)) val albumDocument = client.get("getAlbum", mapOf("id" to album.id))
songs += SubsonicXml.songs(id, albumDocument) songs += SubsonicXml.songs(id, albumDocument)
} }
if (albums.size < pageSize) break if (albums.size < PAGE_SIZE) break
offset += pageSize offset += PAGE_SIZE
pages++ pages++
} }
return songs return songs
} }
private fun page(type: String, offset: Int): Map<String, String> = mapOf( private fun page(
type: String,
offset: Int,
): Map<String, String> =
mapOf(
"type" to type, "type" to type,
"size" to pageSize.toString(), "size" to PAGE_SIZE.toString(),
"offset" to offset.toString(), "offset" to offset.toString(),
) )
private companion object { private companion object {
const val pageSize = 500 const val PAGE_SIZE = 500
const val maxPages = 1000 const val MAX_PAGES = 1000
const val THUMBNAIL_SIZE = 256
} }
} }
@@ -5,27 +5,38 @@ import com.subtracks.data.model.Artist
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song 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.Document
import org.w3c.dom.Element import org.w3c.dom.Element
import javax.xml.datatype.DatatypeConfigurationException
import javax.xml.datatype.DatatypeFactory
object SubsonicXml { object SubsonicXml {
fun artists(sourceId: Long, document: Document): List<Artist> = fun artists(
document.elements("artist").map { element -> sourceId: Long,
document: Document,
): List<Artist> =
document
.elements("artist")
.map { element ->
Artist( Artist(
sourceId = sourceId, sourceId = sourceId,
id = element.attr("id"), id = element.attr("id"),
name = element.attr("name"), name = element.attr("name"),
albumCount = element.longAttr("albumCount") ?: 0L, albumCount = element.longAttr("albumCount") ?: 0L,
starred = element.dateAttr("starred"), starred = element.dateAttr("starred"),
coverArt = element.attr("coverArt").ifEmpty { null },
) )
}.filter { it.id.isNotEmpty() } }.filter { it.id.isNotEmpty() }
fun albums(sourceId: Long, document: Document): List<Album> = fun albums(
document.elements("album").map { album(sourceId, it) }.filter { it.id.isNotEmpty() } sourceId: Long,
document: Document,
): List<Album> = 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( Album(
sourceId = sourceId, sourceId = sourceId,
id = element.attr("id"), id = element.attr("id"),
@@ -42,10 +53,15 @@ object SubsonicXml {
recentRank = null, recentRank = null,
) )
fun songs(sourceId: Long, document: Document): List<Song> = fun songs(
document.elements("song").map { song(sourceId, it) }.filter { it.id.isNotEmpty() } sourceId: Long,
document: Document,
): List<Song> = 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( Song(
sourceId = sourceId, sourceId = sourceId,
id = element.attr("id"), id = element.attr("id"),
@@ -61,10 +77,15 @@ object SubsonicXml {
genre = element.attr("genre").ifEmpty { null }, genre = element.attr("genre").ifEmpty { null },
) )
fun playlists(sourceId: Long, document: Document): List<Playlist> = fun playlists(
document.elements("playlist").map { playlist(sourceId, it) }.filter { it.id.isNotEmpty() } sourceId: Long,
document: Document,
): List<Playlist> = 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( Playlist(
sourceId = sourceId, sourceId = sourceId,
id = element.attr("id"), id = element.attr("id"),
@@ -75,7 +96,11 @@ object SubsonicXml {
created = element.dateAttr("created") ?: 0L, created = element.dateAttr("created") ?: 0L,
) )
fun playlistSongs(sourceId: Long, playlistId: String, document: Document): List<PlaylistSong> = fun playlistSongs(
sourceId: Long,
playlistId: String,
document: Document,
): List<PlaylistSong> =
document.elements("entry").mapIndexed { index, element -> document.elements("entry").mapIndexed { index, element ->
PlaylistSong( PlaylistSong(
sourceId = sourceId, sourceId = sourceId,
@@ -93,16 +118,15 @@ private fun Document.elements(tag: String): List<Element> {
private fun Element.attr(name: String): String = getAttribute(name) private fun Element.attr(name: String): String = getAttribute(name)
private fun Element.longAttr(name: String): Long? = private fun Element.longAttr(name: String): Long? = getAttribute(name).takeIf { it.isNotEmpty() }?.toLongOrNull()
getAttribute(name).takeIf { it.isNotEmpty() }?.toLongOrNull()
private fun Element.dateAttr(name: String): Long? = private fun Element.dateAttr(name: String): Long? = getAttribute(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse)
getAttribute(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse)
internal object IsoDate { internal object IsoDate {
private val timezone = Regex("(Z|[+-]\\d{2}:?\\d{2})$", RegexOption.IGNORE_CASE) private val timezone = Regex("(Z|[+-]\\d{2}:?\\d{2})$", RegexOption.IGNORE_CASE)
private val factory: DatatypeFactory? = try { private val factory: DatatypeFactory? =
try {
DatatypeFactory.newInstance() DatatypeFactory.newInstance()
} catch (_: DatatypeConfigurationException) { } catch (_: DatatypeConfigurationException) {
null null
@@ -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>(SyncStatus.Idle)
val status: StateFlow<SyncStatus> = _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"
}
}
@@ -14,7 +14,8 @@ class SyncService(
) { ) {
private val lock = Mutex() private val lock = Mutex()
suspend fun sync() = lock.withLock { suspend fun sync() =
lock.withLock {
val artists = source.getArtists() val artists = source.getArtists()
val albums = source.getAlbums() val albums = source.getAlbums()
val songs = source.getSongs() val songs = source.getSongs()
@@ -58,7 +59,8 @@ class SyncService(
delete: suspend (Collection<String>) -> Unit, delete: suspend (Collection<String>) -> Unit,
) { ) {
val currentIds = current.toHashSet() val currentIds = current.toHashSet()
existing.filterNot { it in currentIds } existing
.filterNot { it in currentIds }
.chunked(500) .chunked(500)
.forEach { delete(it) } .forEach { delete(it) }
} }
@@ -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()) }
}