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,
"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')"
]
}
}
@@ -5,6 +5,7 @@ import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
fun createAndroidDatabase(context: Context): SubtracksDatabase =
Room.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
Room
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
.setDriver(BundledSQLiteDriver())
.build()
@@ -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<Artist>)
@@ -40,43 +45,85 @@ interface LibraryDao {
suspend fun songIds(sourceId: Long): List<String>
@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)")
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)")
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)")
suspend fun deleteSongs(sourceId: Long, ids: Collection<String>)
suspend fun deleteSongs(
sourceId: Long,
ids: Collection<String>,
)
@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<List<Artist>>
@Query("SELECT * FROM artists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE")
fun artists(sourceId: Long): PagingSource<Int, Artist>
@Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY name")
fun albums(sourceId: Long): Flow<List<Album>>
@Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE")
fun albumsByName(sourceId: Long): PagingSource<Int, Album>
@Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY name")
fun playlists(sourceId: Long): Flow<List<Playlist>>
@Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY albumArtist COLLATE NOCASE, year, name COLLATE NOCASE")
fun albumsByArtist(sourceId: Long): PagingSource<Int, Album>
@Query("SELECT * FROM songs WHERE sourceId = :sourceId ORDER BY artist, album, disc, track")
fun songs(sourceId: Long): Flow<List<Song>>
@Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY year DESC, name COLLATE NOCASE")
fun albumsByYear(sourceId: Long): PagingSource<Int, Album>
@Query("SELECT * FROM songs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc, track")
fun songsByAlbum(sourceId: Long, albumId: String): Flow<List<Song>>
@Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY created DESC, name COLLATE NOCASE")
fun albumsByRecentlyAdded(sourceId: Long): PagingSource<Int, Album>
@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 " +
"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<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")
fun albumsByArtist(sourceId: Long, artistId: String): Flow<List<Album>>
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND id = :albumId")
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
""",
)
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()
if (phrase.length < MIN_QUERY_LENGTH) return emptyList()
return searchPhrase(sourceId, phrase, limit)
@@ -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<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
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<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",
)
fun activeSubsonicConfig(): Flow<SubsonicConfig?>
@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?
}
@@ -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() {
@@ -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
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(
@@ -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
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<String, String> = emptyMap()): HttpUrl {
fun uri(
method: String,
params: Map<String, String> = 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<String, String> = emptyMap()): Document {
val request = Request.Builder()
.url(uri(method, params))
.header("User-Agent", userAgent)
.build()
fun get(
method: String,
params: Map<String, String> = 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) }
}
@@ -21,77 +21,88 @@ class SubsonicSource(
withContext(Dispatchers.IO) { client.get("ping") }
}
override suspend fun getArtists(): List<Artist> = withContext(Dispatchers.IO) {
SubsonicXml.artists(id, client.get("getArtists"))
}
override suspend fun getArtists(): List<Artist> =
withContext(Dispatchers.IO) {
SubsonicXml.artists(id, client.get("getArtists"))
}
override suspend fun getAlbums(): List<Album> = withContext(Dispatchers.IO) {
val (frequent, recent) = fetchRanks()
val albums = mutableListOf<Album>()
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<Album> =
withContext(Dispatchers.IO) {
val (frequent, recent) = fetchRanks()
val albums = mutableListOf<Album>()
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<Song> = withContext(Dispatchers.IO) {
if (supportsEmptyQuerySearch()) searchSongs() else albumSongs()
}
override suspend fun getPlaylists(): List<Playlist> = withContext(Dispatchers.IO) {
SubsonicXml.playlists(id, client.get("getPlaylists"))
}
override suspend fun getPlaylistSongs(playlists: List<Playlist>): List<PlaylistSong> = withContext(Dispatchers.IO) {
val result = mutableListOf<PlaylistSong>()
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<Song> =
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<Playlist> =
withContext(Dispatchers.IO) {
SubsonicXml.playlists(id, client.get("getPlaylists"))
}
override suspend fun getPlaylistSongs(playlists: List<Playlist>): List<PlaylistSong> =
withContext(Dispatchers.IO) {
val result = mutableListOf<PlaylistSong>()
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<String, Long>, Map<String, Long>> =
fetchRank("frequent") to fetchRank("recent")
private fun fetchRanks(): Pair<Map<String, Long>, Map<String, Long>> = fetchRank("frequent") to fetchRank("recent")
private fun fetchRank(type: String): Map<String, Long> {
val ranks = mutableMapOf<String, Long>()
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<Song>()
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<Song>()
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<String, String> = mapOf(
"type" to type,
"size" to pageSize.toString(),
"offset" to offset.toString(),
)
private fun page(
type: String,
offset: Int,
): Map<String, String> =
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
}
}
@@ -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<Artist> =
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<Artist> =
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<Album> =
document.elements("album").map { album(sourceId, it) }.filter { it.id.isNotEmpty() }
fun albums(
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(
sourceId = sourceId,
id = element.attr("id"),
@@ -42,10 +53,15 @@ object SubsonicXml {
recentRank = null,
)
fun songs(sourceId: Long, document: Document): List<Song> =
document.elements("song").map { song(sourceId, it) }.filter { it.id.isNotEmpty() }
fun songs(
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(
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<Playlist> =
document.elements("playlist").map { playlist(sourceId, it) }.filter { it.id.isNotEmpty() }
fun playlists(
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(
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<PlaylistSong> =
fun playlistSongs(
sourceId: Long,
playlistId: String,
document: Document,
): List<PlaylistSong> =
document.elements("entry").mapIndexed { index, element ->
PlaylistSong(
sourceId = sourceId,
@@ -93,20 +118,19 @@ private fun Document.elements(tag: String): List<Element> {
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
@@ -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,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<String>,
@@ -58,7 +59,8 @@ class SyncService(
delete: suspend (Collection<String>) -> Unit,
) {
val currentIds = current.toHashSet()
existing.filterNot { it in currentIds }
existing
.filterNot { it in currentIds }
.chunked(500)
.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()) }
}