mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
add preferences, paging reads, repositories and sync status
This commit is contained in:
+9
-39
@@ -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()) }
|
||||
}
|
||||
Reference in New Issue
Block a user