mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +01:00
add the subsonic data layer on Room 3
SubsonicClient (token/plaintext auth, XXE-hardened parsing, root validation), SubsonicXml mapping (ISO-8601 with fractions and offsets), SubsonicSource (paged albums/ranks, search fallback), Room 3 entities/DAOs with diff-based sync pruning and an FTS5 trigram search index.
This commit is contained in:
@@ -0,0 +1,649 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 1,
|
||||||
|
"identityHash": "c359cb54d1d99a2de8efbe1f0c8ad182",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "sources",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `address` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "address",
|
||||||
|
"columnName": "address",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isActive",
|
||||||
|
"columnName": "isActive",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "subsonic_sources",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `useTokenAuth` INTEGER NOT NULL, PRIMARY KEY(`sourceId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "sourceId",
|
||||||
|
"columnName": "sourceId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "username",
|
||||||
|
"columnName": "username",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "password",
|
||||||
|
"columnName": "password",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "useTokenAuth",
|
||||||
|
"columnName": "useTokenAuth",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"sourceId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_subsonic_sources_sourceId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"sourceId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_subsonic_sources_sourceId` ON `${TABLE_NAME}` (`sourceId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "sources",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"sourceId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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 )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "sourceId",
|
||||||
|
"columnName": "sourceId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "albumCount",
|
||||||
|
"columnName": "albumCount",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "starred",
|
||||||
|
"columnName": "starred",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"sourceId",
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_artists_sourceId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"sourceId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_artists_sourceId` ON `${TABLE_NAME}` (`sourceId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "sources",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"sourceId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "albums",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `artistId` TEXT, `name` TEXT NOT NULL, `albumArtist` TEXT, `created` INTEGER NOT NULL, `coverArt` TEXT, `genre` TEXT, `year` INTEGER, `starred` INTEGER, `songCount` INTEGER NOT NULL, `frequentRank` INTEGER, `recentRank` INTEGER, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "sourceId",
|
||||||
|
"columnName": "sourceId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "artistId",
|
||||||
|
"columnName": "artistId",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "albumArtist",
|
||||||
|
"columnName": "albumArtist",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "created",
|
||||||
|
"columnName": "created",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "coverArt",
|
||||||
|
"columnName": "coverArt",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "genre",
|
||||||
|
"columnName": "genre",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "year",
|
||||||
|
"columnName": "year",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "starred",
|
||||||
|
"columnName": "starred",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "songCount",
|
||||||
|
"columnName": "songCount",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "frequentRank",
|
||||||
|
"columnName": "frequentRank",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "recentRank",
|
||||||
|
"columnName": "recentRank",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"sourceId",
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_albums_sourceId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"sourceId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_albums_sourceId` ON `${TABLE_NAME}` (`sourceId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_albums_sourceId_artistId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"sourceId",
|
||||||
|
"artistId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_albums_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "sources",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"sourceId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "playlists",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `comment` TEXT, `coverArt` TEXT, `songCount` INTEGER NOT NULL, `created` INTEGER NOT NULL, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "sourceId",
|
||||||
|
"columnName": "sourceId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "comment",
|
||||||
|
"columnName": "comment",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "coverArt",
|
||||||
|
"columnName": "coverArt",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "songCount",
|
||||||
|
"columnName": "songCount",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "created",
|
||||||
|
"columnName": "created",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"sourceId",
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_playlists_sourceId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"sourceId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_sourceId` ON `${TABLE_NAME}` (`sourceId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "sources",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"sourceId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "playlist_songs",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`sourceId`, `playlistId`, `position`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "sourceId",
|
||||||
|
"columnName": "sourceId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "playlistId",
|
||||||
|
"columnName": "playlistId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "songId",
|
||||||
|
"columnName": "songId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "position",
|
||||||
|
"columnName": "position",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"sourceId",
|
||||||
|
"playlistId",
|
||||||
|
"position"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_playlist_songs_sourceId_playlistId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"sourceId",
|
||||||
|
"playlistId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_sourceId_playlistId` ON `${TABLE_NAME}` (`sourceId`, `playlistId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_playlist_songs_sourceId_songId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"sourceId",
|
||||||
|
"songId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_sourceId_songId` ON `${TABLE_NAME}` (`sourceId`, `songId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "sources",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"sourceId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "songs",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `albumId` TEXT, `artistId` TEXT, `title` TEXT NOT NULL, `album` TEXT, `artist` TEXT, `duration` INTEGER, `track` INTEGER, `disc` INTEGER, `starred` INTEGER, `genre` TEXT, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "sourceId",
|
||||||
|
"columnName": "sourceId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "albumId",
|
||||||
|
"columnName": "albumId",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "artistId",
|
||||||
|
"columnName": "artistId",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "album",
|
||||||
|
"columnName": "album",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "artist",
|
||||||
|
"columnName": "artist",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "duration",
|
||||||
|
"columnName": "duration",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "track",
|
||||||
|
"columnName": "track",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "disc",
|
||||||
|
"columnName": "disc",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "starred",
|
||||||
|
"columnName": "starred",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "genre",
|
||||||
|
"columnName": "genre",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"sourceId",
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_songs_sourceId_albumId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"sourceId",
|
||||||
|
"albumId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_songs_sourceId_albumId` ON `${TABLE_NAME}` (`sourceId`, `albumId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_songs_sourceId_artistId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"sourceId",
|
||||||
|
"artistId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_songs_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "sources",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"sourceId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "search_index",
|
||||||
|
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`sourceId` UNINDEXED, `type` UNINDEXED, `itemId` UNINDEXED, `title`, tokenize=`trigram`)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "sourceId",
|
||||||
|
"columnName": "sourceId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "type",
|
||||||
|
"columnName": "type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"rowid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ftsVersion": "FTS5",
|
||||||
|
"ftsOptions": {
|
||||||
|
"tokenizer": "trigram",
|
||||||
|
"tokenizerArgs": [],
|
||||||
|
"contentTable": "",
|
||||||
|
"languageIdColumnName": "",
|
||||||
|
"matchInfo": "FTS4",
|
||||||
|
"notIndexedColumns": [
|
||||||
|
"sourceId",
|
||||||
|
"type",
|
||||||
|
"itemId"
|
||||||
|
],
|
||||||
|
"prefixSizes": [],
|
||||||
|
"preferredOrder": "ASC",
|
||||||
|
"contentRowId": "",
|
||||||
|
"columnSize": true,
|
||||||
|
"detail": "FULL"
|
||||||
|
},
|
||||||
|
"contentSyncTriggers": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.subtracks.data.db
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room3.Room
|
||||||
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
|
|
||||||
|
fun createAndroidDatabase(context: Context): SubtracksDatabase =
|
||||||
|
Room.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
||||||
|
.setDriver(BundledSQLiteDriver())
|
||||||
|
.build()
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.subtracks.data.db
|
||||||
|
|
||||||
|
import androidx.room3.Dao
|
||||||
|
import androidx.room3.Query
|
||||||
|
import androidx.room3.Upsert
|
||||||
|
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 kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface LibraryDao {
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsertArtists(items: List<Artist>)
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsertAlbums(items: List<Album>)
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsertPlaylists(items: List<Playlist>)
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsertSongs(items: List<Song>)
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsertPlaylistSongs(items: List<PlaylistSong>)
|
||||||
|
|
||||||
|
@Query("SELECT id FROM artists WHERE sourceId = :sourceId")
|
||||||
|
suspend fun artistIds(sourceId: Long): List<String>
|
||||||
|
|
||||||
|
@Query("SELECT id FROM albums WHERE sourceId = :sourceId")
|
||||||
|
suspend fun albumIds(sourceId: Long): List<String>
|
||||||
|
|
||||||
|
@Query("SELECT id FROM playlists WHERE sourceId = :sourceId")
|
||||||
|
suspend fun playlistIds(sourceId: Long): List<String>
|
||||||
|
|
||||||
|
@Query("SELECT id FROM songs WHERE sourceId = :sourceId")
|
||||||
|
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>)
|
||||||
|
|
||||||
|
@Query("DELETE FROM albums WHERE sourceId = :sourceId AND id IN (:ids)")
|
||||||
|
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>)
|
||||||
|
|
||||||
|
@Query("DELETE FROM songs WHERE sourceId = :sourceId AND id IN (:ids)")
|
||||||
|
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 albums WHERE sourceId = :sourceId ORDER BY name")
|
||||||
|
fun albums(sourceId: Long): Flow<List<Album>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY name")
|
||||||
|
fun playlists(sourceId: Long): Flow<List<Playlist>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM songs WHERE sourceId = :sourceId ORDER BY artist, album, disc, track")
|
||||||
|
fun songs(sourceId: Long): Flow<List<Song>>
|
||||||
|
|
||||||
|
@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 songs.* FROM playlist_songs " +
|
||||||
|
"JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " +
|
||||||
|
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId " +
|
||||||
|
"ORDER BY playlist_songs.position",
|
||||||
|
)
|
||||||
|
fun songsByPlaylist(sourceId: Long, playlistId: String): Flow<List<Song>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND artistId = :artistId ORDER BY year DESC, name")
|
||||||
|
fun albumsByArtist(sourceId: Long, artistId: String): Flow<List<Album>>
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.subtracks.data.db
|
||||||
|
|
||||||
|
import androidx.room3.Dao
|
||||||
|
import androidx.room3.Insert
|
||||||
|
import androidx.room3.Query
|
||||||
|
import com.subtracks.data.model.SearchIndex
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface SearchDao {
|
||||||
|
@Query("DELETE FROM search_index WHERE sourceId = :sourceId")
|
||||||
|
suspend fun clear(sourceId: String)
|
||||||
|
|
||||||
|
@Insert
|
||||||
|
suspend fun insert(items: List<SearchIndex>)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT * FROM search_index
|
||||||
|
WHERE search_index MATCH '"' || replace(:query, '"', '""') || '"'
|
||||||
|
AND sourceId = :sourceId
|
||||||
|
ORDER BY rank
|
||||||
|
LIMIT :limit
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
suspend fun searchPhrase(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val MIN_QUERY_LENGTH = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.subtracks.data.db
|
||||||
|
|
||||||
|
import androidx.room3.Dao
|
||||||
|
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.SubsonicSource
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface SourcesDao {
|
||||||
|
@Query("SELECT * FROM sources ORDER BY createdAt")
|
||||||
|
fun sources(): Flow<List<Source>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM sources WHERE id = :id")
|
||||||
|
fun source(id: Long): Flow<Source?>
|
||||||
|
|
||||||
|
@Query("SELECT id FROM sources WHERE isActive = 1 LIMIT 1")
|
||||||
|
fun activeSourceId(): Flow<Long?>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM subsonic_sources WHERE sourceId = :sourceId")
|
||||||
|
fun subsonicSource(sourceId: Long): Flow<SubsonicSource?>
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsertSource(source: Source)
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsertSubsonicSource(subsonic: SubsonicSource)
|
||||||
|
|
||||||
|
@Query("DELETE FROM sources WHERE id = :id")
|
||||||
|
suspend fun deleteSourceRow(id: Long)
|
||||||
|
|
||||||
|
@Query("DELETE FROM search_index WHERE sourceId = :sourceId")
|
||||||
|
suspend fun clearSearchIndex(sourceId: String)
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
suspend fun deleteSource(id: Long) {
|
||||||
|
deleteSourceRow(id)
|
||||||
|
clearSearchIndex(id.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query("UPDATE sources SET isActive = (id = :sourceId)")
|
||||||
|
suspend fun setActiveSource(sourceId: Long)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM app_settings WHERE id = 1")
|
||||||
|
fun appSettings(): Flow<AppSettings?>
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsertAppSettings(settings: AppSettings)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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
|
||||||
|
import com.subtracks.data.model.SearchIndex
|
||||||
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.model.Source
|
||||||
|
import com.subtracks.data.model.SubsonicSource
|
||||||
|
|
||||||
|
@Database(
|
||||||
|
entities = [
|
||||||
|
Source::class,
|
||||||
|
SubsonicSource::class,
|
||||||
|
AppSettings::class,
|
||||||
|
Artist::class,
|
||||||
|
Album::class,
|
||||||
|
Playlist::class,
|
||||||
|
PlaylistSong::class,
|
||||||
|
Song::class,
|
||||||
|
SearchIndex::class,
|
||||||
|
],
|
||||||
|
version = 1,
|
||||||
|
exportSchema = true,
|
||||||
|
)
|
||||||
|
abstract class SubtracksDatabase : RoomDatabase() {
|
||||||
|
abstract fun libraryDao(): LibraryDao
|
||||||
|
|
||||||
|
abstract fun sourcesDao(): SourcesDao
|
||||||
|
|
||||||
|
abstract fun searchDao(): SearchDao
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package com.subtracks.data.model
|
||||||
|
|
||||||
|
import androidx.room3.ColumnInfo
|
||||||
|
import androidx.room3.Entity
|
||||||
|
import androidx.room3.ForeignKey
|
||||||
|
import androidx.room3.Fts5
|
||||||
|
import androidx.room3.FtsOptions
|
||||||
|
import androidx.room3.Index
|
||||||
|
import androidx.room3.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(tableName = "sources")
|
||||||
|
data class Source(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val name: String,
|
||||||
|
val address: String,
|
||||||
|
val isActive: Boolean = false,
|
||||||
|
val createdAt: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "subsonic_sources",
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = Source::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["sourceId"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
indices = [Index("sourceId")],
|
||||||
|
)
|
||||||
|
data class SubsonicSource(
|
||||||
|
@PrimaryKey val sourceId: Long,
|
||||||
|
val username: String,
|
||||||
|
val password: String,
|
||||||
|
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"],
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = Source::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["sourceId"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
indices = [Index("sourceId")],
|
||||||
|
)
|
||||||
|
data class Artist(
|
||||||
|
val sourceId: Long,
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val albumCount: Long,
|
||||||
|
val starred: Long?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "albums",
|
||||||
|
primaryKeys = ["sourceId", "id"],
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = Source::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["sourceId"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
indices = [Index("sourceId"), Index("sourceId", "artistId")],
|
||||||
|
)
|
||||||
|
data class Album(
|
||||||
|
val sourceId: Long,
|
||||||
|
val id: String,
|
||||||
|
val artistId: String?,
|
||||||
|
val name: String,
|
||||||
|
val albumArtist: String?,
|
||||||
|
val created: Long,
|
||||||
|
val coverArt: String?,
|
||||||
|
val genre: String?,
|
||||||
|
val year: Long?,
|
||||||
|
val starred: Long?,
|
||||||
|
val songCount: Long,
|
||||||
|
val frequentRank: Long?,
|
||||||
|
val recentRank: Long?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "playlists",
|
||||||
|
primaryKeys = ["sourceId", "id"],
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = Source::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["sourceId"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
indices = [Index("sourceId")],
|
||||||
|
)
|
||||||
|
data class Playlist(
|
||||||
|
val sourceId: Long,
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val comment: String?,
|
||||||
|
val coverArt: String?,
|
||||||
|
val songCount: Long,
|
||||||
|
val created: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "playlist_songs",
|
||||||
|
primaryKeys = ["sourceId", "playlistId", "position"],
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = Source::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["sourceId"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
indices = [Index("sourceId", "playlistId"), Index("sourceId", "songId")],
|
||||||
|
)
|
||||||
|
data class PlaylistSong(
|
||||||
|
val sourceId: Long,
|
||||||
|
val playlistId: String,
|
||||||
|
val songId: String,
|
||||||
|
val position: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "songs",
|
||||||
|
primaryKeys = ["sourceId", "id"],
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = Source::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["sourceId"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
indices = [Index("sourceId", "albumId"), Index("sourceId", "artistId")],
|
||||||
|
)
|
||||||
|
data class Song(
|
||||||
|
val sourceId: Long,
|
||||||
|
val id: String,
|
||||||
|
val albumId: String?,
|
||||||
|
val artistId: String?,
|
||||||
|
val title: String,
|
||||||
|
val album: String?,
|
||||||
|
val artist: String?,
|
||||||
|
val duration: Long?,
|
||||||
|
val track: Long?,
|
||||||
|
val disc: Long?,
|
||||||
|
val starred: Long?,
|
||||||
|
val genre: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "search_index")
|
||||||
|
@Fts5(tokenizer = FtsOptions.TOKENIZER_TRIGRAM, notIndexed = ["sourceId", "type", "itemId"])
|
||||||
|
data class SearchIndex(
|
||||||
|
@PrimaryKey(autoGenerate = true) @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
||||||
|
val sourceId: String,
|
||||||
|
val type: String,
|
||||||
|
val itemId: String,
|
||||||
|
val title: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.subtracks.data.source
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
interface MusicSource {
|
||||||
|
val id: Long
|
||||||
|
|
||||||
|
suspend fun ping()
|
||||||
|
|
||||||
|
suspend fun getArtists(): List<Artist>
|
||||||
|
|
||||||
|
suspend fun getAlbums(): List<Album>
|
||||||
|
|
||||||
|
suspend fun getSongs(): List<Song>
|
||||||
|
|
||||||
|
suspend fun getPlaylists(): List<Playlist>
|
||||||
|
|
||||||
|
suspend fun getPlaylistSongs(playlists: List<Playlist>): List<PlaylistSong>
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
class SubsonicException(val code: Int, message: String) : Exception(message)
|
||||||
|
|
||||||
|
class SubsonicClient(
|
||||||
|
private val baseUrl: HttpUrl,
|
||||||
|
private val username: String,
|
||||||
|
private val password: String,
|
||||||
|
private val useTokenAuth: Boolean,
|
||||||
|
private val http: OkHttpClient,
|
||||||
|
private val userAgent: String = "subtracks/android",
|
||||||
|
) {
|
||||||
|
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)
|
||||||
|
builder.addQueryParameter("u", username)
|
||||||
|
if (useTokenAuth) {
|
||||||
|
val salt = randomSalt()
|
||||||
|
builder.addQueryParameter("s", salt)
|
||||||
|
builder.addQueryParameter("t", md5Hex(password + salt))
|
||||||
|
} else {
|
||||||
|
builder.addQueryParameter("p", password)
|
||||||
|
}
|
||||||
|
params.forEach { (key, value) -> builder.addQueryParameter(key, value) }
|
||||||
|
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()
|
||||||
|
http.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
throw SubsonicException(-1, "HTTP ${response.code}")
|
||||||
|
}
|
||||||
|
val stream = response.body.byteStream()
|
||||||
|
val document = secureDocumentBuilderFactory().newDocumentBuilder().parse(stream)
|
||||||
|
val root = document.documentElement
|
||||||
|
if (root == null || root.tagName != "subsonic-response") {
|
||||||
|
throw SubsonicException(-1, "Unexpected response from the server")
|
||||||
|
}
|
||||||
|
if (root.getAttribute("status") == "failed") {
|
||||||
|
val error = root.getElementsByTagName("error").item(0) as? Element
|
||||||
|
throw SubsonicException(
|
||||||
|
error?.getAttribute("code")?.toIntOrNull() ?: -1,
|
||||||
|
error?.getAttribute("message") ?: "Unknown error",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return document
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val API_VERSION = "1.13.0"
|
||||||
|
const val CLIENT = "subtracks"
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun randomSalt(): String = (1..4).map { ('a'..'z').random() }.joinToString("")
|
||||||
|
|
||||||
|
private fun md5Hex(input: String): String =
|
||||||
|
MessageDigest.getInstance("MD5")
|
||||||
|
.digest(input.toByteArray(Charsets.UTF_8))
|
||||||
|
.joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package com.subtracks.data.source.subsonic
|
||||||
|
|
||||||
|
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.source.MusicSource
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class SubsonicSource(
|
||||||
|
override val id: Long,
|
||||||
|
private val client: SubsonicClient,
|
||||||
|
private val maxBitrate: Int = 0,
|
||||||
|
private val streamFormat: String? = null,
|
||||||
|
) : MusicSource {
|
||||||
|
private var emptyQuerySearchSupported: Boolean? = null
|
||||||
|
|
||||||
|
override suspend fun ping() {
|
||||||
|
withContext(Dispatchers.IO) { client.get("ping") }
|
||||||
|
}
|
||||||
|
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
if (batch.size < pageSize) break
|
||||||
|
offset += pageSize
|
||||||
|
pages++
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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
|
||||||
|
pages++
|
||||||
|
}
|
||||||
|
return ranks
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
emptyQuerySearchSupported = supported
|
||||||
|
return supported
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun searchSongs(): List<Song> {
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val batch = SubsonicXml.songs(id, document)
|
||||||
|
songs += batch
|
||||||
|
if (batch.size < pageSize) break
|
||||||
|
offset += pageSize
|
||||||
|
pages++
|
||||||
|
}
|
||||||
|
return songs
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun albumSongs(): List<Song> {
|
||||||
|
val songs = mutableListOf<Song>()
|
||||||
|
var offset = 0
|
||||||
|
var pages = 0
|
||||||
|
while (pages < maxPages) {
|
||||||
|
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
|
||||||
|
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 companion object {
|
||||||
|
const val pageSize = 500
|
||||||
|
const val maxPages = 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package com.subtracks.data.source.subsonic
|
||||||
|
|
||||||
|
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 javax.xml.datatype.DatatypeConfigurationException
|
||||||
|
import javax.xml.datatype.DatatypeFactory
|
||||||
|
import org.w3c.dom.Document
|
||||||
|
import org.w3c.dom.Element
|
||||||
|
|
||||||
|
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 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 =
|
||||||
|
Album(
|
||||||
|
sourceId = sourceId,
|
||||||
|
id = element.attr("id"),
|
||||||
|
artistId = element.attr("artistId").ifEmpty { null },
|
||||||
|
name = element.attr("name"),
|
||||||
|
albumArtist = element.attr("artist").ifEmpty { null },
|
||||||
|
created = element.dateAttr("created") ?: 0L,
|
||||||
|
coverArt = element.attr("coverArt").ifEmpty { null },
|
||||||
|
genre = element.attr("genre").ifEmpty { null },
|
||||||
|
year = element.longAttr("year"),
|
||||||
|
starred = element.dateAttr("starred"),
|
||||||
|
songCount = element.longAttr("songCount") ?: 0L,
|
||||||
|
frequentRank = null,
|
||||||
|
recentRank = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
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 =
|
||||||
|
Song(
|
||||||
|
sourceId = sourceId,
|
||||||
|
id = element.attr("id"),
|
||||||
|
albumId = element.attr("albumId").ifEmpty { null },
|
||||||
|
artistId = element.attr("artistId").ifEmpty { null },
|
||||||
|
title = element.attr("title"),
|
||||||
|
album = element.attr("album").ifEmpty { null },
|
||||||
|
artist = element.attr("artist").ifEmpty { null },
|
||||||
|
duration = element.longAttr("duration"),
|
||||||
|
track = element.longAttr("track"),
|
||||||
|
disc = element.longAttr("discNumber"),
|
||||||
|
starred = element.dateAttr("starred"),
|
||||||
|
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 playlist(sourceId: Long, element: Element): Playlist =
|
||||||
|
Playlist(
|
||||||
|
sourceId = sourceId,
|
||||||
|
id = element.attr("id"),
|
||||||
|
name = element.attr("name"),
|
||||||
|
comment = element.attr("comment").ifEmpty { null },
|
||||||
|
coverArt = element.attr("coverArt").ifEmpty { null },
|
||||||
|
songCount = element.longAttr("songCount") ?: 0L,
|
||||||
|
created = element.dateAttr("created") ?: 0L,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun playlistSongs(sourceId: Long, playlistId: String, document: Document): List<PlaylistSong> =
|
||||||
|
document.elements("entry").mapIndexed { index, element ->
|
||||||
|
PlaylistSong(
|
||||||
|
sourceId = sourceId,
|
||||||
|
playlistId = playlistId,
|
||||||
|
songId = element.attr("id"),
|
||||||
|
position = index.toLong(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Document.elements(tag: String): List<Element> {
|
||||||
|
val nodes = getElementsByTagName(tag)
|
||||||
|
return (0 until nodes.length).mapNotNull { nodes.item(it) as? 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.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
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parse(value: String): Long? {
|
||||||
|
val factory = factory ?: return null
|
||||||
|
return try {
|
||||||
|
val zoned = if (timezone.containsMatchIn(value)) value else value + "Z"
|
||||||
|
factory.newXMLGregorianCalendar(zoned).toGregorianCalendar().timeInMillis / 1000
|
||||||
|
} catch (_: IllegalArgumentException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.subtracks.data.sync
|
||||||
|
|
||||||
|
import androidx.room3.immediateTransaction
|
||||||
|
import androidx.room3.useWriterConnection
|
||||||
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
|
import com.subtracks.data.model.SearchIndex
|
||||||
|
import com.subtracks.data.source.MusicSource
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
|
class SyncService(
|
||||||
|
private val db: SubtracksDatabase,
|
||||||
|
private val source: MusicSource,
|
||||||
|
) {
|
||||||
|
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)
|
||||||
|
|
||||||
|
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) }
|
||||||
|
|
||||||
|
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)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun deleteMissing(
|
||||||
|
existing: List<String>,
|
||||||
|
current: List<String>,
|
||||||
|
delete: suspend (Collection<String>) -> Unit,
|
||||||
|
) {
|
||||||
|
val currentIds = current.toHashSet()
|
||||||
|
existing.filterNot { it in currentIds }
|
||||||
|
.chunked(500)
|
||||||
|
.forEach { delete(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user