Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 962b4dbc8c | |||
| aa4089118e | |||
| dabca3ad24 | |||
| 7c791dc8e4 | |||
| 8f29cc7414 | |||
| c8b21aa76e | |||
| 483804fc9e | |||
| 12a8cfccb5 | |||
| e358a92cf4 | |||
| 63b25e65ad | |||
| a766b7193f | |||
| e95138d412 | |||
| 7426f6c718 | |||
| e994aae613 | |||
| c39a9ca18f | |||
| 06e155abb6 | |||
| 6b11208e5a | |||
| 41da012494 | |||
| 8a3104443c | |||
| 5f5ab69da6 | |||
| 5386ae870f | |||
| d7b011e52f | |||
| 11466e1525 | |||
| e185b36138 | |||
| 31d8c30dfe | |||
| 69ccd7b25d | |||
| 8b77c6be97 | |||
| c33ef18a50 | |||
| 4c9450c117 | |||
| b467cb7532 | |||
| c78bbb7ba5 |
Generated
+13
@@ -1754,6 +1754,19 @@
|
||||
<option name="screenX" value="1600" />
|
||||
<option name="screenY" value="2560" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="36" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="tangorpro" />
|
||||
<option name="formFactor" value="Tablet" />
|
||||
<option name="id" value="tangorpro" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel Tablet" />
|
||||
<option name="screenDensity" value="320" />
|
||||
<option name="screenX" value="1600" />
|
||||
<option name="screenY" value="2560" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="35" />
|
||||
<option name="brand" value="google" />
|
||||
|
||||
Generated
-1
@@ -1,4 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
|
||||
@@ -0,0 +1,690 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 6,
|
||||
"identityHash": "fb73ed8674efb1d82a586551baba5ef0",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "sync_metadata",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `cursor` INTEGER NOT NULL, `lastSyncAt` INTEGER, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "cursor",
|
||||
"columnName": "cursor",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastSyncAt",
|
||||
"columnName": "lastSyncAt",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_artists",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `sortName` TEXT NOT NULL, `mbid` TEXT, `artistThumbPath` TEXT, `artistFanartPath` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortName",
|
||||
"columnName": "sortName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mbid",
|
||||
"columnName": "mbid",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistThumbPath",
|
||||
"columnName": "artistThumbPath",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistFanartPath",
|
||||
"columnName": "artistFanartPath",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "fetchedAt",
|
||||
"columnName": "fetchedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_albums",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artistId` TEXT NOT NULL, `title` TEXT NOT NULL, `sortTitle` TEXT NOT NULL, `releaseDate` TEXT, `coverPath` TEXT, `mbid` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistId",
|
||||
"columnName": "artistId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortTitle",
|
||||
"columnName": "sortTitle",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "releaseDate",
|
||||
"columnName": "releaseDate",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "coverPath",
|
||||
"columnName": "coverPath",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "mbid",
|
||||
"columnName": "mbid",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "fetchedAt",
|
||||
"columnName": "fetchedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_tracks",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `title` TEXT NOT NULL, `durationMs` INTEGER NOT NULL, `trackNumber` INTEGER, `discNumber` INTEGER, `filePath` TEXT, `fileFormat` TEXT, `genre` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "albumId",
|
||||
"columnName": "albumId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistId",
|
||||
"columnName": "artistId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "durationMs",
|
||||
"columnName": "durationMs",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "trackNumber",
|
||||
"columnName": "trackNumber",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "discNumber",
|
||||
"columnName": "discNumber",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "filePath",
|
||||
"columnName": "filePath",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "fileFormat",
|
||||
"columnName": "fileFormat",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "genre",
|
||||
"columnName": "genre",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "fetchedAt",
|
||||
"columnName": "fetchedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_likes",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `entityType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `likedAt` INTEGER NOT NULL, PRIMARY KEY(`userId`, `entityType`, `entityId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "userId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "entityType",
|
||||
"columnName": "entityType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "entityId",
|
||||
"columnName": "entityId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "likedAt",
|
||||
"columnName": "likedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"userId",
|
||||
"entityType",
|
||||
"entityId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_playlists",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userId` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `isPublic` INTEGER NOT NULL, `coverPath` TEXT, `trackCount` INTEGER NOT NULL, `durationSec` INTEGER NOT NULL, `systemVariant` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "userId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isPublic",
|
||||
"columnName": "isPublic",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "coverPath",
|
||||
"columnName": "coverPath",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "trackCount",
|
||||
"columnName": "trackCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "durationSec",
|
||||
"columnName": "durationSec",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "systemVariant",
|
||||
"columnName": "systemVariant",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "fetchedAt",
|
||||
"columnName": "fetchedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_playlist_tracks",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` TEXT NOT NULL, `trackId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`playlistId`, `trackId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "playlistId",
|
||||
"columnName": "playlistId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "trackId",
|
||||
"columnName": "trackId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "position",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"playlistId",
|
||||
"trackId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_quarantine_mine",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `reason` TEXT NOT NULL, `notes` TEXT, `createdAt` TEXT NOT NULL, `trackTitle` TEXT NOT NULL, `trackDurationMs` INTEGER NOT NULL, `albumId` TEXT NOT NULL, `albumTitle` TEXT NOT NULL, `albumCoverArtPath` TEXT, `artistId` TEXT NOT NULL, `artistName` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`trackId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "trackId",
|
||||
"columnName": "trackId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "reason",
|
||||
"columnName": "reason",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notes",
|
||||
"columnName": "notes",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "trackTitle",
|
||||
"columnName": "trackTitle",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "trackDurationMs",
|
||||
"columnName": "trackDurationMs",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "albumId",
|
||||
"columnName": "albumId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "albumTitle",
|
||||
"columnName": "albumTitle",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "albumCoverArtPath",
|
||||
"columnName": "albumCoverArtPath",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistId",
|
||||
"columnName": "artistId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistName",
|
||||
"columnName": "artistName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fetchedAt",
|
||||
"columnName": "fetchedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"trackId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "audio_cache_index",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `path` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `cachedAt` INTEGER NOT NULL, `lastPlayedAt` INTEGER, `source` TEXT NOT NULL, PRIMARY KEY(`trackId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "trackId",
|
||||
"columnName": "trackId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "path",
|
||||
"columnName": "path",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sizeBytes",
|
||||
"columnName": "sizeBytes",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "cachedAt",
|
||||
"columnName": "cachedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastPlayedAt",
|
||||
"columnName": "lastPlayedAt",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "source",
|
||||
"columnName": "source",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"trackId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_mutations",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `kind` TEXT NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAttemptAt` INTEGER, `attempts` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "kind",
|
||||
"columnName": "kind",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "payload",
|
||||
"columnName": "payload",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastAttemptAt",
|
||||
"columnName": "lastAttemptAt",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "attempts",
|
||||
"columnName": "attempts",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_resume_state",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `json` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "json",
|
||||
"columnName": "json",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updatedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_home_index",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`section` TEXT NOT NULL, `position` INTEGER NOT NULL, `entityType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`section`, `position`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "section",
|
||||
"columnName": "section",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "position",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "entityType",
|
||||
"columnName": "entityType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "entityId",
|
||||
"columnName": "entityId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fetchedAt",
|
||||
"columnName": "fetchedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"section",
|
||||
"position"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_history_snapshot",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `json` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "json",
|
||||
"columnName": "json",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updatedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "auth_session",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `sessionCookie` TEXT, `baseUrl` TEXT NOT NULL, `userJson` TEXT, `themeMode` TEXT, `clientId` TEXT, `cacheSettingsJson` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sessionCookie",
|
||||
"columnName": "sessionCookie",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "baseUrl",
|
||||
"columnName": "baseUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userJson",
|
||||
"columnName": "userJson",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "themeMode",
|
||||
"columnName": "themeMode",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "clientId",
|
||||
"columnName": "clientId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "cacheSettingsJson",
|
||||
"columnName": "cacheSettingsJson",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"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, 'fb73ed8674efb1d82a586551baba5ef0')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
|
||||
import com.fabledsword.minstrel.cache.CachedTrackIds
|
||||
import com.fabledsword.minstrel.connectivity.LocalServerHealth
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
||||
@@ -41,7 +41,7 @@ import javax.inject.Inject
|
||||
class MainActivity : ComponentActivity() {
|
||||
@Inject lateinit var seedCache: DetailSeedCache
|
||||
@Inject lateinit var cachedTrackIds: CachedTrackIds
|
||||
@Inject lateinit var serverHealth: ServerHealthController
|
||||
@Inject lateinit var serverHealth: NetworkStatusController
|
||||
|
||||
// Flipped to true when the user taps the media notification (or
|
||||
// any other entry point that asks for the full player). The App
|
||||
@@ -91,7 +91,7 @@ class MainActivity : ComponentActivity() {
|
||||
private fun App(
|
||||
seedCache: DetailSeedCache,
|
||||
cachedTrackIds: CachedTrackIds,
|
||||
serverHealth: ServerHealthController,
|
||||
serverHealth: NetworkStatusController,
|
||||
pendingOpenNowPlaying: StateFlow<Boolean>,
|
||||
onOpenedNowPlaying: () -> Unit,
|
||||
themeVm: ThemePreferenceViewModel = hiltViewModel(),
|
||||
|
||||
@@ -19,8 +19,7 @@ import com.fabledsword.minstrel.player.PlayEventsReporter
|
||||
import com.fabledsword.minstrel.player.PlaybackErrorReporter
|
||||
import com.fabledsword.minstrel.player.ResumeController
|
||||
import com.fabledsword.minstrel.update.data.UpdateBannerController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
||||
import com.fabledsword.minstrel.update.data.VersionCheckController
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -115,21 +114,13 @@ class MinstrelApplication :
|
||||
@Suppress("unused") @Inject lateinit var audioPrefetcher: AudioPrefetcher
|
||||
|
||||
/**
|
||||
* Same construct-the-singleton trick — VersionCheckController's
|
||||
* init block starts a 5-min poll loop against /healthz so the
|
||||
* shell-level VersionTooOldBanner can surface min_client_version
|
||||
* mismatches without waiting for the next user-driven request.
|
||||
* Same construct-the-singleton trick — NetworkStatusController owns the
|
||||
* /healthz poll loop + the device-link collector + the reachability state
|
||||
* machine, and is the single authority on the tri-state ServerHealth
|
||||
* signal (plus the VersionTooOld byproduct). It must exist from launch so
|
||||
* the poll loop runs and the StateFlow stays warm for every consumer.
|
||||
*/
|
||||
@Suppress("unused") @Inject lateinit var versionCheckController: VersionCheckController
|
||||
|
||||
/**
|
||||
* Same construct-the-singleton trick — ServerHealthController combines
|
||||
* ConnectivityObserver + VersionCheckController.reachable into the
|
||||
* tri-state ServerHealth signal. Its stateIn is `SharingStarted.Eagerly`
|
||||
* so the StateFlow needs an active subscriber from launch onward; the
|
||||
* @Inject keeps the singleton alive and the flow collecting.
|
||||
*/
|
||||
@Suppress("unused") @Inject lateinit var serverHealthController: ServerHealthController
|
||||
@Suppress("unused") @Inject lateinit var networkStatusController: NetworkStatusController
|
||||
|
||||
/**
|
||||
* Same construct-the-singleton trick — UpdateBannerController polls
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.fabledsword.minstrel.api
|
||||
|
||||
import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.connectivity.ReachabilityReportingInterceptor
|
||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
@@ -45,9 +46,15 @@ object NetworkModule {
|
||||
fun provideOkHttp(
|
||||
baseUrl: BaseUrlInterceptor,
|
||||
auth: AuthCookieInterceptor,
|
||||
reachability: ReachabilityReportingInterceptor,
|
||||
logging: HttpLoggingInterceptor,
|
||||
): OkHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
// ReachabilityReportingInterceptor MUST run first: it identifies
|
||||
// Minstrel-bound requests by the still-unrewritten PLACEHOLDER_HOST
|
||||
// (so external artwork fetches don't read as server reachability)
|
||||
// and observes the final transport outcome by wrapping the chain.
|
||||
.addInterceptor(reachability)
|
||||
// AuthCookieInterceptor MUST run before BaseUrlInterceptor.
|
||||
// Both scope on `host == PLACEHOLDER_HOST` to distinguish
|
||||
// Minstrel-server requests from external image fetches
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.api.endpoints
|
||||
|
||||
import com.fabledsword.minstrel.models.wire.AlbumDetailWire
|
||||
import com.fabledsword.minstrel.models.wire.ArtistDetailWire
|
||||
import com.fabledsword.minstrel.models.wire.ArtistWire
|
||||
import com.fabledsword.minstrel.models.wire.TrackWire
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
@@ -35,9 +36,26 @@ interface LibraryApi {
|
||||
@GET("api/artists/{id}/tracks")
|
||||
suspend fun getArtistTracks(@Path("id") id: String): List<TrackWire>
|
||||
|
||||
@GET("api/artists/{id}/similar")
|
||||
suspend fun getSimilarArtists(
|
||||
@Path("id") id: String,
|
||||
@Query("limit") limit: Int = SIMILAR_ARTISTS_LIMIT,
|
||||
): List<ArtistWire>
|
||||
|
||||
@GET("api/artists/{id}/top-tracks")
|
||||
suspend fun getArtistTopTracks(
|
||||
@Path("id") id: String,
|
||||
@Query("limit") limit: Int = TOP_TRACKS_LIMIT,
|
||||
): List<TrackWire>
|
||||
|
||||
@GET("api/albums/{id}")
|
||||
suspend fun getAlbumDetail(@Path("id") id: String): AlbumDetailWire
|
||||
|
||||
@GET("api/library/shuffle")
|
||||
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
|
||||
|
||||
private companion object {
|
||||
const val SIMILAR_ARTISTS_LIMIT = 12
|
||||
const val TOP_TRACKS_LIMIT = 5
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -31,6 +31,20 @@ interface CachedPlaylistDao {
|
||||
@Query("SELECT * FROM cached_playlists WHERE id = :id")
|
||||
suspend fun getById(id: String): CachedPlaylistEntity?
|
||||
|
||||
/**
|
||||
* Per-playlist count of member tracks resident in the audio cache index.
|
||||
* LEFT JOINs so playlists with zero cached tracks still appear
|
||||
* (cachedCount = 0). Drives the offline "fully cached" greying.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT p.id AS playlistId, COUNT(a.trackId) AS cachedCount " +
|
||||
"FROM cached_playlists p " +
|
||||
"LEFT JOIN cached_playlist_tracks t ON t.playlistId = p.id " +
|
||||
"LEFT JOIN audio_cache_index a ON a.trackId = t.trackId " +
|
||||
"GROUP BY p.id",
|
||||
)
|
||||
fun observeCachedCounts(): Flow<List<PlaylistCachedCount>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(rows: List<CachedPlaylistEntity>)
|
||||
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.fabledsword.minstrel.cache.db.dao
|
||||
|
||||
/**
|
||||
* Projection: how many of a playlist's member tracks are resident in the audio
|
||||
* cache index. Backs the offline "fully cached" greying — a playlist is fully
|
||||
* available offline when [cachedCount] reaches its `trackCount`.
|
||||
*/
|
||||
data class PlaylistCachedCount(
|
||||
val playlistId: String,
|
||||
val cachedCount: Int,
|
||||
)
|
||||
+1
-1
@@ -29,7 +29,7 @@ import javax.inject.Singleton
|
||||
* the app to Offline with no debounce and fast-failed in-flight playback
|
||||
* via [com.fabledsword.minstrel.player.OfflineGatedDataSource]. The
|
||||
* authority on whether *Minstrel* is reachable is the `/healthz` poll
|
||||
* ([com.fabledsword.minstrel.update.data.VersionCheckController], which
|
||||
* ([com.fabledsword.minstrel.connectivity.NetworkStatusController], which
|
||||
* has its own failure hysteresis), not this coarse device-link signal.
|
||||
*
|
||||
* Used by the shell-level ConnectionErrorBanner; downstream
|
||||
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
package com.fabledsword.minstrel.connectivity
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.update.api.HealthzApi
|
||||
import com.fabledsword.minstrel.update.api.HealthzResponse
|
||||
import com.fabledsword.minstrel.update.data.VersionResult
|
||||
import com.fabledsword.minstrel.update.data.isVersionNewer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.Retrofit
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val POLL_HEALTHY_MS = 5 * 60 * 1000L
|
||||
private const val POLL_DEGRADED_MS = 20_000L
|
||||
private const val ARBITRATE_MIN_GAP_MS = 2_000L
|
||||
|
||||
/**
|
||||
* THE single authority on network/server reachability. Absorbs the former
|
||||
* VersionCheckController (the /healthz poll + version parsing) and
|
||||
* ServerHealthController (the tri-state derive) into one signal-driven unit so
|
||||
* the app has one place that answers "can we reach Minstrel?" — not three
|
||||
* half-systems reporting differently.
|
||||
*
|
||||
* Inputs (all funnel through a single-consumer intent channel for thread
|
||||
* safety — [reportSuccess]/[reportFailure] are called from the audio read path
|
||||
* and OkHttp threads):
|
||||
* - device link transitions ([ConnectivityObserver]); link-return triggers an
|
||||
* immediate probe so recovery is near-instant (fixes the sticky banner).
|
||||
* - periodic /healthz probe, adaptive cadence (calm when Healthy, fast when down).
|
||||
* - reportSuccess / reportFailure from the API interceptor, the audio data
|
||||
* source, and the playback-error reporter.
|
||||
* - recheck() from pull-to-refresh and the banner.
|
||||
*
|
||||
* Version compatibility is a byproduct of the same /healthz response.
|
||||
*
|
||||
* Constructed at launch via the construct-the-singleton trick in
|
||||
* [com.fabledsword.minstrel.MinstrelApplication].
|
||||
*/
|
||||
@Singleton
|
||||
class NetworkStatusController @Inject constructor(
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
connectivity: ConnectivityObserver,
|
||||
private val authStore: AuthStore,
|
||||
retrofit: Retrofit,
|
||||
) {
|
||||
private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
|
||||
private val machine = ReachabilityMachine()
|
||||
private val lastProbeAtMs = AtomicLong(0)
|
||||
|
||||
private val stateInternal = MutableStateFlow(ServerHealth.Healthy)
|
||||
val state: StateFlow<ServerHealth> = stateInternal.asStateFlow()
|
||||
|
||||
private val versionInternal = MutableStateFlow(VersionResult.SKIPPED)
|
||||
val versionResult: StateFlow<VersionResult> = versionInternal.asStateFlow()
|
||||
|
||||
private sealed interface Intent {
|
||||
data class Link(val up: Boolean) : Intent
|
||||
data class Probe(val resp: HealthzResponse?) : Intent
|
||||
object OpSuccess : Intent
|
||||
object OpFailure : Intent
|
||||
}
|
||||
|
||||
private val intents = Channel<Intent>(Channel.UNLIMITED)
|
||||
|
||||
init {
|
||||
scope.launch { reduceLoop() }
|
||||
scope.launch {
|
||||
connectivity.online.collect { up ->
|
||||
intents.trySend(Intent.Link(up))
|
||||
if (up) probeOnce()
|
||||
}
|
||||
}
|
||||
scope.launch { pollLoop() }
|
||||
}
|
||||
|
||||
/** A real server op verifiably succeeded — self-proving recovery. Cheap; safe on hot paths. */
|
||||
fun reportSuccess() {
|
||||
if (stateInternal.value != ServerHealth.Healthy) intents.trySend(Intent.OpSuccess)
|
||||
}
|
||||
|
||||
/** A real network op failed — triggers /healthz arbitration. No-op when already Offline. */
|
||||
fun reportFailure() {
|
||||
if (stateInternal.value == ServerHealth.Offline) return
|
||||
intents.trySend(Intent.OpFailure)
|
||||
}
|
||||
|
||||
/** One-shot recheck for pull-to-refresh and the banner. */
|
||||
fun recheck() {
|
||||
scope.launch { probeOnce(force = true) }
|
||||
}
|
||||
|
||||
private suspend fun reduceLoop() {
|
||||
for (intent in intents) {
|
||||
val now = System.currentTimeMillis()
|
||||
when (intent) {
|
||||
is Intent.Link -> machine.onLinkChange(intent.up)
|
||||
is Intent.Probe -> applyProbe(intent.resp, now)
|
||||
Intent.OpSuccess -> machine.onSuccess()
|
||||
Intent.OpFailure -> {
|
||||
machine.onOpFailure(now)
|
||||
// Arbitrate off the reducer thread: awaiting a stalled
|
||||
// /healthz here would block a concurrent self-proving
|
||||
// success from snapping us straight back to Healthy.
|
||||
scope.launch { probeOnce() }
|
||||
}
|
||||
}
|
||||
emit(machine.health())
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyProbe(resp: HealthzResponse?, now: Long) {
|
||||
if (resp == null) {
|
||||
machine.onProbeFailure(now)
|
||||
} else {
|
||||
machine.onSuccess()
|
||||
versionInternal.value = versionResultFor(resp)
|
||||
}
|
||||
}
|
||||
|
||||
private fun emit(next: ServerHealth) {
|
||||
if (stateInternal.value != next) {
|
||||
Timber.w("NetworkStatus -> %s", next)
|
||||
stateInternal.value = next
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pollLoop() {
|
||||
probeOnce()
|
||||
while (true) {
|
||||
val interval =
|
||||
if (stateInternal.value == ServerHealth.Healthy) POLL_HEALTHY_MS
|
||||
else POLL_DEGRADED_MS
|
||||
delay(interval)
|
||||
probeOnce()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun probeOnce(force: Boolean = false) {
|
||||
// Startup guard: don't poll the localhost placeholder before AuthStore
|
||||
// hydrates the real base URL — that false failure used to flash the banner.
|
||||
if (authStore.baseUrl.value == AuthStore.DEFAULT_BASE_URL) return
|
||||
val now = System.currentTimeMillis()
|
||||
if (!force && now - lastProbeAtMs.get() < ARBITRATE_MIN_GAP_MS) return
|
||||
lastProbeAtMs.set(now)
|
||||
val resp = runCatching { api.check() }.getOrNull()
|
||||
intents.trySend(Intent.Probe(resp))
|
||||
}
|
||||
|
||||
private fun versionResultFor(resp: HealthzResponse): VersionResult {
|
||||
val min = resp.minClientVersion
|
||||
return when {
|
||||
min.isEmpty() -> VersionResult.SKIPPED
|
||||
isVersionNewer(min, BuildConfig.VERSION_NAME) -> VersionResult.TOO_OLD
|
||||
else -> VersionResult.OK
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive [ServerHealth] snapshot provided once at the app root. Lets leaf
|
||||
* composables (TrackRow gating, write-affordance disabling) branch on health
|
||||
* without re-injecting the controller. Defaults to Healthy so previews/tests
|
||||
* don't crash.
|
||||
*/
|
||||
val LocalServerHealth = staticCompositionLocalOf { ServerHealth.Healthy }
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package com.fabledsword.minstrel.connectivity
|
||||
|
||||
internal const val ESCALATE_AFTER_MS = 120_000L
|
||||
internal const val CORROBORATION_WINDOW_MS = 30_000L
|
||||
internal const val CORROBORATION_OP_THRESHOLD = 2
|
||||
|
||||
/**
|
||||
* Pure reachability state machine. No Android, no coroutines, no real clock —
|
||||
* every entry point takes `nowMs`, so it is fully deterministic and unit-
|
||||
* testable. [NetworkStatusController] wires real time + signals around it.
|
||||
*
|
||||
* Reachability (independent of the device link):
|
||||
* - Reachable last evidence says the server answered.
|
||||
* - Unstable a probe failed; arbitration/escalation pending.
|
||||
* - Unreachable corroborated or sustained failure.
|
||||
*
|
||||
* [health] folds the device link over that: no link → Offline; otherwise the
|
||||
* reachability maps Reachable→Healthy, Unstable→Unstable, Unreachable→ServerDown.
|
||||
*
|
||||
* Principle: **success is self-proving, failure is ambiguous.** [onSuccess]
|
||||
* (a real server byte-read or API 2xx, or a successful /healthz) snaps straight
|
||||
* back to Reachable. A failure only escalates when a /healthz probe corroborates
|
||||
* it ([onProbeFailure]) — either via fresh op-failure corroboration or the
|
||||
* sustained-time backstop.
|
||||
*/
|
||||
class ReachabilityMachine {
|
||||
|
||||
private enum class Reachability { Reachable, Unstable, Unreachable }
|
||||
|
||||
private var linkUp = true
|
||||
private var reachability = Reachability.Reachable
|
||||
private var failureStreakStartMs: Long? = null
|
||||
private val recentOpFailures = ArrayDeque<Long>()
|
||||
|
||||
fun onLinkChange(up: Boolean) {
|
||||
linkUp = up
|
||||
// Link transitions don't reset reachability — a restored link keeps the
|
||||
// last-known server reachability until a fresh probe/op result arrives.
|
||||
if (!up) recentOpFailures.clear()
|
||||
}
|
||||
|
||||
/** A real successful server op (stream read, API 2xx) or a successful /healthz. */
|
||||
fun onSuccess() {
|
||||
reachability = Reachability.Reachable
|
||||
failureStreakStartMs = null
|
||||
recentOpFailures.clear()
|
||||
}
|
||||
|
||||
/** A real network op failed. Ambiguous on its own — records corroboration. */
|
||||
fun onOpFailure(nowMs: Long) {
|
||||
pruneOpFailures(nowMs)
|
||||
recentOpFailures.addLast(nowMs)
|
||||
}
|
||||
|
||||
/** A /healthz probe failed — the arbiter. Escalates per corroboration/backstop. */
|
||||
fun onProbeFailure(nowMs: Long) {
|
||||
pruneOpFailures(nowMs)
|
||||
if (reachability == Reachability.Reachable) {
|
||||
reachability = Reachability.Unstable
|
||||
failureStreakStartMs = nowMs
|
||||
}
|
||||
if (reachability == Reachability.Unstable && shouldEscalate(nowMs)) {
|
||||
reachability = Reachability.Unreachable
|
||||
}
|
||||
}
|
||||
|
||||
fun health(): ServerHealth = when {
|
||||
!linkUp -> ServerHealth.Offline
|
||||
reachability == Reachability.Reachable -> ServerHealth.Healthy
|
||||
reachability == Reachability.Unstable -> ServerHealth.Unstable
|
||||
else -> ServerHealth.ServerDown
|
||||
}
|
||||
|
||||
private fun shouldEscalate(nowMs: Long): Boolean {
|
||||
val corroborated = recentOpFailures.size >= CORROBORATION_OP_THRESHOLD
|
||||
val sustained =
|
||||
failureStreakStartMs?.let { nowMs - it >= ESCALATE_AFTER_MS } ?: false
|
||||
return corroborated || sustained
|
||||
}
|
||||
|
||||
private fun pruneOpFailures(nowMs: Long) {
|
||||
while (recentOpFailures.isNotEmpty() &&
|
||||
nowMs - recentOpFailures.first() > CORROBORATION_WINDOW_MS
|
||||
) {
|
||||
recentOpFailures.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.fabledsword.minstrel.connectivity
|
||||
|
||||
import com.fabledsword.minstrel.api.BaseUrlInterceptor.Companion.PLACEHOLDER_HOST
|
||||
import dagger.Lazy
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val HEALTHZ_PATH = "/healthz"
|
||||
|
||||
/**
|
||||
* Feeds real Minstrel API outcomes into [NetworkStatusController]. A 2xx is
|
||||
* self-proving proof the server is reachable → reportSuccess(); a transport
|
||||
* [IOException] (no response at all) → reportFailure(), which triggers /healthz
|
||||
* arbitration.
|
||||
*
|
||||
* MUST run first in the OkHttp chain (before [BaseUrlInterceptor]) so the host
|
||||
* is still the [PLACEHOLDER_HOST] sentinel: this shared client also fetches
|
||||
* EXTERNAL artwork (musicbrainz / coverartarchive), and an external image
|
||||
* loading must NOT be read as "our server is reachable" — only sentinel-host
|
||||
* requests are Minstrel-bound. 5xx is deliberately NOT a failure (the server
|
||||
* answered), and /healthz is skipped to avoid a feedback loop with the poll.
|
||||
*
|
||||
* [NetworkStatusController] is injected as a [Lazy] to break the Hilt cycle:
|
||||
* the controller needs `Retrofit`, which needs `OkHttpClient`, which needs this
|
||||
* interceptor. By the time a request flows through, the controller singleton is
|
||||
* already constructed (construct-the-singleton trick in MinstrelApplication).
|
||||
*/
|
||||
@Singleton
|
||||
class ReachabilityReportingInterceptor @Inject constructor(
|
||||
private val networkStatus: Lazy<NetworkStatusController>,
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val isMinstrel = request.url.host == PLACEHOLDER_HOST
|
||||
val isHealthz = request.url.encodedPath.endsWith(HEALTHZ_PATH)
|
||||
if (!isMinstrel || isHealthz) return chain.proceed(request)
|
||||
return try {
|
||||
val response = chain.proceed(request)
|
||||
if (response.isSuccessful) networkStatus.get().reportSuccess()
|
||||
response
|
||||
} catch (e: IOException) {
|
||||
networkStatus.get().reportFailure()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.fabledsword.minstrel.connectivity
|
||||
|
||||
/**
|
||||
* The single reachability signal every consumer branches on.
|
||||
*
|
||||
* - [Healthy] link up, /healthz ok — normal network behavior.
|
||||
* - [Unstable] link up, a recent failure with arbitration pending —
|
||||
* INFORMATIONAL ONLY. Does NOT gate playback; preserves the
|
||||
* anti-flicker intent of commit 5c0db429 while still warning
|
||||
* the user that something is flaky.
|
||||
* - [ServerDown] link up but /healthz failing, corroborated or sustained —
|
||||
* gate to cache-only.
|
||||
* - [Offline] no device link at all — gate to cache-only.
|
||||
*/
|
||||
enum class ServerHealth { Healthy, Unstable, ServerDown, Offline }
|
||||
-75
@@ -1,75 +0,0 @@
|
||||
package com.fabledsword.minstrel.connectivity
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.update.data.VersionCheckController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Tri-state server-reachability signal that downstream consumers can branch
|
||||
* on to decide whether to hit the network, gate writes, or fall back to
|
||||
* cache-only behavior.
|
||||
*
|
||||
* Composed from two existing signals -- this controller doesn't poll its own
|
||||
* endpoint:
|
||||
*
|
||||
* - [ConnectivityObserver.online] -- system-level NetworkCallback that
|
||||
* answers only "is there an active INTERNET-capable network link" (NOT
|
||||
* VALIDATED -- a WAN-validation flicker must not read as offline when
|
||||
* the LAN server is reachable).
|
||||
* - [VersionCheckController.reachable] -- did the last `/healthz` poll
|
||||
* succeed. This is the authority on whether *Minstrel* is reachable;
|
||||
* it has its own failure hysteresis. Distinguishes "device has a link
|
||||
* but our server is down" from "no network at all."
|
||||
*
|
||||
* `version too old` is intentionally *not* folded in here -- it's a separate
|
||||
* UX (the VersionTooOldBanner) and conflating it with offline would mask the
|
||||
* real cause.
|
||||
*/
|
||||
enum class ServerHealth { Healthy, Offline, ServerDown }
|
||||
|
||||
@Singleton
|
||||
class ServerHealthController @Inject constructor(
|
||||
@ApplicationScope scope: CoroutineScope,
|
||||
connectivity: ConnectivityObserver,
|
||||
versionCheck: VersionCheckController,
|
||||
) {
|
||||
val state: StateFlow<ServerHealth> = combine(
|
||||
connectivity.online,
|
||||
versionCheck.reachable,
|
||||
) { online, serverReachable ->
|
||||
when {
|
||||
!online -> ServerHealth.Offline
|
||||
!serverReachable -> ServerHealth.ServerDown
|
||||
else -> ServerHealth.Healthy
|
||||
}
|
||||
}
|
||||
// Transition log -- the signal had no instrumentation, which is why a
|
||||
// false-offline (WAN flicker flipping playback to "Source error") was
|
||||
// hard to diagnose from logcat. WARN-tier so ReleaseTree surfaces it.
|
||||
.distinctUntilChanged()
|
||||
.onEach { Timber.w("ServerHealth -> %s", it) }
|
||||
.stateIn(
|
||||
scope = scope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = ServerHealth.Healthy,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive [ServerHealth] snapshot provided once at the app root from the
|
||||
* controller's StateFlow. Lets leaf composables (TrackRow gating, write-
|
||||
* affordance disabling) branch on health without each ViewModel re-
|
||||
* injecting the controller. Defaults to Healthy so unwrapped previews
|
||||
* and tests don't crash.
|
||||
*/
|
||||
val LocalServerHealth = staticCompositionLocalOf { ServerHealth.Healthy }
|
||||
+81
-43
@@ -14,83 +14,121 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.composables.icons.lucide.CloudOff
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val HEALTH_SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
private const val BACK_ONLINE_FLASH_MS = 2_000L
|
||||
|
||||
/**
|
||||
* Lifts [ServerHealthController]'s tri-state into a StateFlow for the banner
|
||||
* composable. Keeps the banner pure-presentation.
|
||||
* Exposes [NetworkStatusController]'s tri-state directly to the banner. No
|
||||
* re-wrapping StateFlow — the controller's is already app-scoped and warm.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ConnectivityBannerViewModel @Inject constructor(
|
||||
health: ServerHealthController,
|
||||
networkStatus: NetworkStatusController,
|
||||
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
val health: StateFlow<ServerHealth> = health.state.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(HEALTH_SHARE_STOP_TIMEOUT_MS),
|
||||
initialValue = ServerHealth.Healthy,
|
||||
)
|
||||
val health: StateFlow<ServerHealth> = networkStatus.state
|
||||
}
|
||||
|
||||
/**
|
||||
* Banner shown at the top of the shell when the user can't reach the server.
|
||||
* Tri-state so we tell the user *why*: no device network vs server-down.
|
||||
* Copy choices match the Flutter analogues. Auto-hides via slide+fade when
|
||||
* health returns to [ServerHealth.Healthy].
|
||||
* Shell banner. Tells the user *why* they're degraded — no link vs server-down
|
||||
* — plus a non-alarming "Reconnecting…" for the transient [ServerHealth.Unstable]
|
||||
* window, and a brief "Back online" confirmation when health recovers so
|
||||
* recovery is unmistakable. Sentence case, understated voice (design system).
|
||||
*/
|
||||
@Composable
|
||||
fun ConnectionErrorBanner(
|
||||
viewModel: ConnectivityBannerViewModel = hiltViewModel(),
|
||||
) {
|
||||
val health by viewModel.health.collectAsStateWithLifecycle()
|
||||
var showBackOnline by remember { mutableStateOf(false) }
|
||||
var wasDown by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(health) {
|
||||
val down = health == ServerHealth.Offline || health == ServerHealth.ServerDown
|
||||
if (health == ServerHealth.Healthy && wasDown) {
|
||||
// try/finally so a mid-delay cancellation (health flips again) can't
|
||||
// orphan the flag and leave "Back online" stuck on screen.
|
||||
try {
|
||||
showBackOnline = true
|
||||
delay(BACK_ONLINE_FLASH_MS)
|
||||
} finally {
|
||||
showBackOnline = false
|
||||
}
|
||||
}
|
||||
wasDown = down
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = health != ServerHealth.Healthy,
|
||||
visible = health != ServerHealth.Healthy || showBackOnline,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.errorContainer)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.CloudOff,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
text = when (health) {
|
||||
ServerHealth.Offline ->
|
||||
"No connection — check Wi-Fi or mobile data."
|
||||
ServerHealth.ServerDown ->
|
||||
"Server unreachable — your cached content is still available."
|
||||
ServerHealth.Healthy -> ""
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
BannerContent(health = health, backOnline = showBackOnline)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BannerContent(health: ServerHealth, backOnline: Boolean) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val background: Color
|
||||
val foreground: Color
|
||||
when {
|
||||
backOnline -> {
|
||||
background = scheme.secondaryContainer
|
||||
foreground = scheme.onSecondaryContainer
|
||||
}
|
||||
health == ServerHealth.Unstable -> {
|
||||
background = scheme.surfaceVariant
|
||||
foreground = scheme.onSurfaceVariant
|
||||
}
|
||||
else -> {
|
||||
background = scheme.errorContainer
|
||||
foreground = scheme.onErrorContainer
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(background)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(imageVector = Lucide.CloudOff, contentDescription = null, tint = foreground)
|
||||
Text(
|
||||
text = bannerText(health, backOnline),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = foreground,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bannerText(health: ServerHealth, backOnline: Boolean): String = when {
|
||||
backOnline -> "Back online."
|
||||
health == ServerHealth.Offline -> "No connection — check Wi-Fi or mobile data."
|
||||
health == ServerHealth.ServerDown ->
|
||||
"Server unreachable — your cached content is still available."
|
||||
health == ServerHealth.Unstable -> "Reconnecting…"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ import com.composables.icons.lucide.History
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Music
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.home.data.HomeRepository
|
||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||
@@ -134,7 +135,7 @@ class HomeViewModel @Inject constructor(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val player: com.fabledsword.minstrel.player.PlayerController,
|
||||
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
|
||||
connectivity: com.fabledsword.minstrel.connectivity.ConnectivityObserver,
|
||||
networkStatus: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val systemStatusInternal = MutableStateFlow(SystemPlaylistsStatus())
|
||||
@@ -142,9 +143,13 @@ class HomeViewModel @Inject constructor(
|
||||
/** System-playlist build status for the Home placeholder cards. */
|
||||
val systemStatus: StateFlow<SystemPlaylistsStatus> = systemStatusInternal.asStateFlow()
|
||||
|
||||
/** True when the device has no usable internet — gates the offline-pool cards. */
|
||||
val offline: StateFlow<Boolean> = connectivity.online
|
||||
.map { !it }
|
||||
/**
|
||||
* Cache-only when there's no link OR the server is unreachable. Reads the
|
||||
* unified [NetworkStatusController] (not the raw device link) so Home reacts
|
||||
* to ServerDown too; the transient Unstable state stays calm (not offline).
|
||||
*/
|
||||
val offline: StateFlow<Boolean> = networkStatus.state
|
||||
.map { it == ServerHealth.Offline || it == ServerHealth.ServerDown }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
@@ -701,18 +706,19 @@ private fun PlaylistsRow(
|
||||
icon = iconForPool(item.kind),
|
||||
onClick = { onPlayPool(item.kind) },
|
||||
)
|
||||
is PlaylistRowItem.Real -> PlaylistCard(
|
||||
playlist = item.playlist,
|
||||
onClick = { onPlaylistClick(item.playlist.id) },
|
||||
onPlay = { onPlayPlaylist(item.playlist) },
|
||||
// Match Flutter: refreshable system playlists need
|
||||
// the live server endpoints, so disable their play
|
||||
// overlay when offline (user can still tap into the
|
||||
// detail and shuffle all from cache). User playlists
|
||||
// play from cache + survive offline.
|
||||
playEnabled = item.playlist.trackCount > 0 &&
|
||||
!(offline && item.playlist.refreshable),
|
||||
)
|
||||
is PlaylistRowItem.Real -> {
|
||||
// Greyed offline when the tile needs the live server or
|
||||
// isn't fully cached — dimmed but still tappable into the
|
||||
// detail to shuffle whatever subset is cached.
|
||||
val greyed = offline && item.playlist.unavailableOffline
|
||||
PlaylistCard(
|
||||
playlist = item.playlist,
|
||||
onClick = { onPlaylistClick(item.playlist.id) },
|
||||
onPlay = { onPlayPlaylist(item.playlist) },
|
||||
playEnabled = item.playlist.trackCount > 0 && !greyed,
|
||||
greyed = greyed,
|
||||
)
|
||||
}
|
||||
is PlaylistRowItem.Placeholder -> PlaylistPlaceholderCard(
|
||||
label = item.label,
|
||||
variant = item.variant,
|
||||
@@ -728,7 +734,7 @@ private fun iconForPool(kind: OfflinePoolKind) = when (kind) {
|
||||
}
|
||||
|
||||
/** A cache-backed offline pool, a real playlist tile, or a not-yet-generated slot. */
|
||||
private sealed interface PlaylistRowItem {
|
||||
internal sealed interface PlaylistRowItem {
|
||||
data class OfflinePool(val kind: OfflinePoolKind) : PlaylistRowItem
|
||||
data class Real(val playlist: PlaylistRef) : PlaylistRowItem
|
||||
data class Placeholder(val label: String, val variant: String) : PlaylistRowItem
|
||||
@@ -741,55 +747,81 @@ enum class OfflinePoolKind(val label: String) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the Home Playlists row. When [offline], the two cache-backed
|
||||
* pool cards (Recently played, Liked) lead the row. Then For You +
|
||||
* Discover + 3× Songs-like fixed slots (real card when generated,
|
||||
* placeholder otherwise), then the secondary system kinds (deep cuts /
|
||||
* rediscover / new for you / on this day / first listens) when they
|
||||
* exist — no placeholders for these since they're conditional on
|
||||
* library shape, not guaranteed singletons. Finally user-owned
|
||||
* playlists.
|
||||
* Builds the Home Playlists row.
|
||||
*
|
||||
* Online: For You + Discover + 3× Songs-like fixed slots (real card when
|
||||
* generated, placeholder otherwise), then the secondary system kinds (deep cuts
|
||||
* / rediscover / new for you / on this day / first listens) when they exist —
|
||||
* no placeholders for these since they're conditional on library shape — then
|
||||
* user-owned playlists.
|
||||
*
|
||||
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the
|
||||
* same real playlists in curated order but stably partitioned fully-cached
|
||||
* first / greyed after, and the "building/pending" placeholders are dropped
|
||||
* (they need the server to generate, so they're meaningless offline).
|
||||
*
|
||||
* Diverges from Flutter (`flutter_client/lib/library/home_screen.dart`
|
||||
* `_buildPlaylistsRow`) which only shows the 5 fixed slots and never
|
||||
* surfaces the secondary kinds on Home. Operator authorized the
|
||||
* divergence on 2026-06-01; web UI catch-up tracked as task #53.
|
||||
*/
|
||||
private fun buildPlaylistsRow(
|
||||
internal fun buildPlaylistsRow(
|
||||
owned: List<PlaylistRef>,
|
||||
status: SystemPlaylistsStatus,
|
||||
offline: Boolean,
|
||||
): List<PlaylistRowItem> {
|
||||
if (!offline) return buildOnlineRow(owned, status)
|
||||
val out = mutableListOf<PlaylistRowItem>(
|
||||
PlaylistRowItem.OfflinePool(OfflinePoolKind.RECENTLY_PLAYED),
|
||||
PlaylistRowItem.OfflinePool(OfflinePoolKind.LIKED),
|
||||
)
|
||||
val (available, greyed) = orderedRealPlaylists(owned).partition { !it.unavailableOffline }
|
||||
(available + greyed).forEach { out += PlaylistRowItem.Real(it) }
|
||||
return out
|
||||
}
|
||||
|
||||
/** The online layout: fixed system slots (with placeholders), secondary, user. */
|
||||
private fun buildOnlineRow(
|
||||
owned: List<PlaylistRef>,
|
||||
status: SystemPlaylistsStatus,
|
||||
): List<PlaylistRowItem> {
|
||||
val out = mutableListOf<PlaylistRowItem>()
|
||||
if (offline) {
|
||||
out += PlaylistRowItem.OfflinePool(OfflinePoolKind.RECENTLY_PLAYED)
|
||||
out += PlaylistRowItem.OfflinePool(OfflinePoolKind.LIKED)
|
||||
}
|
||||
out += owned.firstOrNull { it.systemVariant == "for_you" }
|
||||
?.let { PlaylistRowItem.Real(it) }
|
||||
?: PlaylistRowItem.Placeholder("For You", variantFor("for-you", status))
|
||||
out += owned.firstOrNull { it.systemVariant == "discover" }
|
||||
?.let { PlaylistRowItem.Real(it) }
|
||||
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
|
||||
val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(3)
|
||||
val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
|
||||
for (i in 0 until SONGS_LIKE_SLOTS) {
|
||||
out += songsLike.getOrNull(i)
|
||||
?.let { PlaylistRowItem.Real(it) }
|
||||
?: PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
|
||||
}
|
||||
// Secondary system kinds in server-registry order. Only included
|
||||
// when actually generated — these depend on library shape (Deep
|
||||
// cuts needs deep albums, On this day needs prior history, etc.)
|
||||
// so a missing one means "not enough data" rather than "still
|
||||
// building".
|
||||
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
||||
owned.firstOrNull { it.systemVariant == variant }
|
||||
?.let { out += PlaylistRowItem.Real(it) }
|
||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
|
||||
}
|
||||
owned.filter { it.systemVariant == null }.forEach { out += PlaylistRowItem.Real(it) }
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Curated real-playlist order (system primaries, then secondary, then user).
|
||||
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
|
||||
* only differs by dropping placeholders + partitioning available-first.
|
||||
*/
|
||||
private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> {
|
||||
val out = mutableListOf<PlaylistRef>()
|
||||
owned.firstOrNull { it.systemVariant == "for_you" }?.let { out += it }
|
||||
owned.firstOrNull { it.systemVariant == "discover" }?.let { out += it }
|
||||
out += owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
|
||||
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += it }
|
||||
}
|
||||
owned.filter { it.systemVariant == null }.forEach { out += it }
|
||||
return out
|
||||
}
|
||||
|
||||
private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
|
||||
s.inFlight -> "building"
|
||||
s.lastError != null -> "failed"
|
||||
|
||||
@@ -105,6 +105,22 @@ class LibraryRepository @Inject constructor(
|
||||
suspend fun fetchArtistTracks(id: String): List<TrackRef> =
|
||||
api.getArtistTracks(id).map { it.toDomain() }
|
||||
|
||||
/**
|
||||
* Pulls related artists for the ArtistDetail "Similar artists"
|
||||
* strip. Network-only — non-critical UI, callers swallow failures
|
||||
* and render nothing rather than blocking the screen.
|
||||
*/
|
||||
suspend fun fetchSimilarArtists(id: String): List<ArtistRef> =
|
||||
api.getSimilarArtists(id).map { it.toDomain() }
|
||||
|
||||
/**
|
||||
* Pulls the artist's top tracks for the ArtistDetail "Top tracks"
|
||||
* panel. Network-only; tapping a row plays this exact list as a
|
||||
* queue, so no persistence is needed.
|
||||
*/
|
||||
suspend fun fetchArtistTopTracks(id: String): List<TrackRef> =
|
||||
api.getArtistTopTracks(id).map { it.toDomain() }
|
||||
|
||||
/**
|
||||
* Pulls the album detail (AlbumRef + tracks), persists both, and
|
||||
* returns the in-memory snapshot. Lets the AlbumDetail screen
|
||||
|
||||
+116
-13
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("TooManyFunctions") // Compose screen + private helper composables
|
||||
|
||||
package com.fabledsword.minstrel.library.ui
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
@@ -18,6 +20,7 @@ import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.items as lazyItems
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -46,21 +49,27 @@ import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Play
|
||||
import com.composables.icons.lucide.User
|
||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||
import com.fabledsword.minstrel.models.ArtistDetailRef
|
||||
import com.fabledsword.minstrel.library.widgets.ArtistCard
|
||||
import com.fabledsword.minstrel.models.ArtistRef
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.models.albumCoverPath
|
||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.shared.formatDuration
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ArtistDetailScreen(
|
||||
navController: NavHostController,
|
||||
viewModel: ArtistDetailViewModel = hiltViewModel(),
|
||||
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
@@ -102,23 +111,35 @@ fun ArtistDetailScreen(
|
||||
title = "Couldn't load artist",
|
||||
body = s.message,
|
||||
)
|
||||
is ArtistDetailUiState.Success -> {
|
||||
val artistLiked by viewModel.artistLiked
|
||||
.collectAsStateWithLifecycle()
|
||||
ArtistBody(
|
||||
detail = s.detail,
|
||||
artistLiked = artistLiked,
|
||||
onPlay = viewModel::playArtist,
|
||||
onToggleLike = viewModel::toggleLikeArtist,
|
||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||
)
|
||||
}
|
||||
is ArtistDetailUiState.Success ->
|
||||
ArtistSuccessBody(s, viewModel, playerViewModel, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistSuccessBody(
|
||||
state: ArtistDetailUiState.Success,
|
||||
viewModel: ArtistDetailViewModel,
|
||||
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel,
|
||||
navController: NavHostController,
|
||||
) {
|
||||
val artistLiked by viewModel.artistLiked.collectAsStateWithLifecycle()
|
||||
val playerState by playerViewModel.uiState.collectAsStateWithLifecycle()
|
||||
ArtistBody(
|
||||
state = state,
|
||||
artistLiked = artistLiked,
|
||||
playingTrackId = playerState.currentTrack?.id,
|
||||
onPlay = viewModel::playArtist,
|
||||
onToggleLike = viewModel::toggleLikeArtist,
|
||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||
onTopTrackClick = viewModel::playTopTracks,
|
||||
onSimilarArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun titleFor(state: ArtistDetailUiState): String = when (state) {
|
||||
is ArtistDetailUiState.Success -> state.detail.artist.name
|
||||
is ArtistDetailUiState.Loading -> state.seed?.name ?: "Artist"
|
||||
@@ -127,12 +148,16 @@ private fun titleFor(state: ArtistDetailUiState): String = when (state) {
|
||||
|
||||
@Composable
|
||||
private fun ArtistBody(
|
||||
detail: ArtistDetailRef,
|
||||
state: ArtistDetailUiState.Success,
|
||||
artistLiked: Boolean,
|
||||
playingTrackId: String?,
|
||||
onPlay: () -> Unit,
|
||||
onToggleLike: () -> Unit,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
onTopTrackClick: (Int) -> Unit,
|
||||
onSimilarArtistClick: (String) -> Unit,
|
||||
) {
|
||||
val detail = state.detail
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 176.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -149,6 +174,23 @@ private fun ArtistBody(
|
||||
onToggleLike = onToggleLike,
|
||||
)
|
||||
}
|
||||
if (state.topTracks.isNotEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
TopTracksPanel(
|
||||
tracks = state.topTracks,
|
||||
playingTrackId = playingTrackId,
|
||||
onTrackClick = onTopTrackClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.similarArtists.isNotEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
SimilarArtistsStrip(
|
||||
artists = state.similarArtists,
|
||||
onArtistClick = onSimilarArtistClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (detail.albums.isEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
EmptyAlbumsHint()
|
||||
@@ -161,6 +203,67 @@ private fun ArtistBody(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopTracksPanel(
|
||||
tracks: List<TrackRef>,
|
||||
playingTrackId: String?,
|
||||
onTrackClick: (Int) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = "Top tracks",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
tracks.forEachIndexed { index, track ->
|
||||
TopTrackRow(
|
||||
track = track,
|
||||
nowPlaying = track.id == playingTrackId,
|
||||
onClick = { onTrackClick(index) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopTrackRow(
|
||||
track: TrackRef,
|
||||
nowPlaying: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
TrackRow(
|
||||
title = track.title,
|
||||
artist = track.artistName,
|
||||
trackId = track.id,
|
||||
onClick = onClick,
|
||||
nowPlaying = nowPlaying,
|
||||
trailing = {
|
||||
Text(
|
||||
text = formatDuration(track.durationSec, zero = "--:--"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SimilarArtistsStrip(
|
||||
artists: List<ArtistRef>,
|
||||
onArtistClick: (String) -> Unit,
|
||||
) {
|
||||
HorizontalScrollRow(title = "Similar artists") {
|
||||
lazyItems(items = artists, key = { it.id }) { artist ->
|
||||
ArtistCard(
|
||||
artist = artist,
|
||||
onClick = { onArtistClick(artist.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistHeader(
|
||||
artist: ArtistRef,
|
||||
|
||||
+49
-1
@@ -9,6 +9,7 @@ import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.models.ArtistDetailRef
|
||||
import com.fabledsword.minstrel.models.ArtistRef
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||
import com.fabledsword.minstrel.player.PlayerController
|
||||
@@ -23,13 +24,18 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
|
||||
sealed interface ArtistDetailUiState {
|
||||
data class Loading(val seed: ArtistRef? = null) : ArtistDetailUiState
|
||||
data class Success(val detail: ArtistDetailRef) : ArtistDetailUiState
|
||||
data class Success(
|
||||
val detail: ArtistDetailRef,
|
||||
val similarArtists: List<ArtistRef> = emptyList(),
|
||||
val topTracks: List<TrackRef> = emptyList(),
|
||||
) : ArtistDetailUiState
|
||||
data class Error(val message: String) : ArtistDetailUiState
|
||||
}
|
||||
|
||||
@@ -82,6 +88,7 @@ class ArtistDetailViewModel @Inject constructor(
|
||||
try {
|
||||
val detail = repository.refreshArtistDetail(artistId)
|
||||
internal.value = ArtistDetailUiState.Success(detail)
|
||||
loadSecondarySections()
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
@@ -89,6 +96,31 @@ class ArtistDetailViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls the non-critical "Similar artists" + "Top tracks" sections
|
||||
* and folds them into the live Success state. Each failure is
|
||||
* swallowed (logged only) — these sections render absent rather than
|
||||
* blocking, so the core artist view never depends on them.
|
||||
*/
|
||||
private suspend fun loadSecondarySections() {
|
||||
val similar = sectionOrEmpty { repository.fetchSimilarArtists(artistId) }
|
||||
val top = sectionOrEmpty { repository.fetchArtistTopTracks(artistId) }
|
||||
val current = internal.value
|
||||
if (current is ArtistDetailUiState.Success) {
|
||||
internal.value = current.copy(similarArtists = similar, topTracks = top)
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <T> sectionOrEmpty(block: () -> List<T>): List<T> =
|
||||
try {
|
||||
block()
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
Timber.w(e, "Artist secondary section fetch failed for %s", artistId)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull every track across the artist's albums, shuffle, and start
|
||||
* playing. Mirrors Flutter's artist Play button — pressing Play on
|
||||
@@ -118,4 +150,20 @@ class ArtistDetailViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays the loaded "Top tracks" list starting at [index] — the top
|
||||
* tracks are themselves the playable queue (mirrors the web client's
|
||||
* sectionTracks = top-tracks behaviour). No network round-trip; the
|
||||
* list is already in the Success state.
|
||||
*/
|
||||
fun playTopTracks(index: Int) {
|
||||
val tracks = (internal.value as? ArtistDetailUiState.Success)?.topTracks.orEmpty()
|
||||
if (tracks.isEmpty()) return
|
||||
player.setQueue(
|
||||
tracks = tracks,
|
||||
initialIndex = index.coerceIn(0, tracks.lastIndex),
|
||||
source = "artist-top:$artistId",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ data class PlaylistRef(
|
||||
val trackCount: Int = 0,
|
||||
val coverUrl: String = "",
|
||||
val ownerUsername: String = "",
|
||||
/** All member tracks resident in the audio cache — playable fully offline. */
|
||||
val fullyCached: Boolean = false,
|
||||
) {
|
||||
val isSystem: Boolean get() = systemVariant != null
|
||||
|
||||
@@ -30,6 +32,13 @@ data class PlaylistRef(
|
||||
* expose a refresh trigger; rebuilds are scheduler-driven).
|
||||
*/
|
||||
val refreshable: Boolean get() = isSystem && systemVariant != "songs_like_artist"
|
||||
|
||||
/**
|
||||
* Can't be relied on while offline — either it needs the live server to
|
||||
* (re)generate (a refreshable system mix) or not all its tracks are cached.
|
||||
* Callers combine this with the current offline state to grey the tile.
|
||||
*/
|
||||
val unavailableOffline: Boolean get() = refreshable || !fullyCached
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+92
-22
@@ -12,12 +12,15 @@ import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
||||
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.isActive
|
||||
@@ -39,9 +42,15 @@ import timber.log.Timber
|
||||
* wrapped ExoPlayer stays paused at the position it had when the
|
||||
* route was selected.
|
||||
*
|
||||
* Drop heuristic: 3 consecutive poll failures fire [onDrop]. The
|
||||
* factory wraps that callback into a SharedFlow consumed by the
|
||||
* NowPlaying surface as a snackbar.
|
||||
* Drop heuristic: the 1 Hz poll loop is the *sole* arbiter of route
|
||||
* liveness -- [RemotePlayerState.recordPollFailure]'s rolling threshold
|
||||
* (DROP_THRESHOLD consecutive failures) fires [onDrop]. A failed transport
|
||||
* command (play/pause/seek/next) does NOT drop on its own: a locked phone's
|
||||
* WiFi power-save can stall a single command's socket I/O for a second or
|
||||
* two while the renderer is perfectly reachable, so commands retry on
|
||||
* transient IO failure and otherwise defer to the poll loop. The factory
|
||||
* wraps the [onDrop] callback into a SharedFlow consumed by the NowPlaying
|
||||
* surface as a snackbar.
|
||||
*
|
||||
* Queue mode: OutputPickerController loads the full queue into Sonos's
|
||||
* native queue via ClearQueue + AddURIToQueue, then points the
|
||||
@@ -172,13 +181,14 @@ class MinstrelForwardingPlayer(
|
||||
if (active == null) {
|
||||
super.play()
|
||||
} else {
|
||||
remoteState.setPlayIntent(true)
|
||||
scope.launch {
|
||||
runCatching { active.avTransport.play() }
|
||||
runCatching { retryTransport { active.avTransport.play() } }
|
||||
.onSuccess {
|
||||
remoteState.applyTransportPlaying()
|
||||
notifyRemoteStateChanged()
|
||||
}
|
||||
.onFailure { handleSoapFailure(active, it) }
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,13 +203,14 @@ class MinstrelForwardingPlayer(
|
||||
if (active == null) {
|
||||
super.pause()
|
||||
} else {
|
||||
remoteState.setPlayIntent(false)
|
||||
scope.launch {
|
||||
runCatching { active.avTransport.pause() }
|
||||
runCatching { retryTransport { active.avTransport.pause() } }
|
||||
.onSuccess {
|
||||
remoteState.applyTransportPaused()
|
||||
notifyRemoteStateChanged()
|
||||
}
|
||||
.onFailure { handleSoapFailure(active, it) }
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,8 +233,8 @@ class MinstrelForwardingPlayer(
|
||||
trackNumber = remoteState.trackNumber,
|
||||
)
|
||||
scope.launch {
|
||||
runCatching { active.avTransport.seek(positionMs) }
|
||||
.onFailure { handleSoapFailure(active, it) }
|
||||
runCatching { retryTransport { active.avTransport.seek(positionMs) } }
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,11 +264,13 @@ class MinstrelForwardingPlayer(
|
||||
)
|
||||
scope.launch {
|
||||
runCatching {
|
||||
active.avTransport.seekToTrack(mediaItemIndex + 1)
|
||||
if (positionMs > 0L) {
|
||||
active.avTransport.seek(positionMs)
|
||||
retryTransport {
|
||||
active.avTransport.seekToTrack(mediaItemIndex + 1)
|
||||
if (positionMs > 0L) {
|
||||
active.avTransport.seek(positionMs)
|
||||
}
|
||||
}
|
||||
}.onFailure { handleSoapFailure(active, it) }
|
||||
}.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,8 +293,8 @@ class MinstrelForwardingPlayer(
|
||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||
)
|
||||
scope.launch {
|
||||
runCatching { active.avTransport.next() }
|
||||
.onFailure { handleSoapFailure(active, it) }
|
||||
runCatching { retryTransport { active.avTransport.next() } }
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,8 +316,8 @@ class MinstrelForwardingPlayer(
|
||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||
)
|
||||
scope.launch {
|
||||
runCatching { active.avTransport.previous() }
|
||||
.onFailure { handleSoapFailure(active, it) }
|
||||
runCatching { retryTransport { active.avTransport.previous() } }
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,11 +398,34 @@ class MinstrelForwardingPlayer(
|
||||
super.release()
|
||||
}
|
||||
|
||||
private fun handleSoapFailure(active: ActiveUpnp, t: Throwable) {
|
||||
pollJob?.cancel()
|
||||
Timber.w(t, "UPnP transport call failed on %s", active.routeName)
|
||||
remoteState.applyError(t)
|
||||
handler.post { onDrop(active.routeName) }
|
||||
/**
|
||||
* Run a transport SOAP block, retrying transport-level (IO) failures a
|
||||
* few times with backoff. A locked phone's WiFi power-save can stall the
|
||||
* first socket I/O for a second or two; a retry lets the command land once
|
||||
* WiFi wakes instead of being abandoned. A [SoapFaultException] (the
|
||||
* renderer answered and rejected the action) is NOT retried -- the device
|
||||
* is alive and retrying won't change its verdict.
|
||||
*/
|
||||
private suspend fun <T> retryTransport(block: suspend () -> T): T =
|
||||
retryTransientIo(TRANSPORT_RETRY_ATTEMPTS, TRANSPORT_RETRY_BACKOFF_MS, block)
|
||||
|
||||
/**
|
||||
* A transport SOAP command failed even after retries. Deliberately does
|
||||
* NOT declare the route dropped: the 1 Hz poll loop is the single arbiter
|
||||
* of liveness (DROP_THRESHOLD consecutive poll failures). A locked phone's
|
||||
* WiFi power-save can fail one command while the renderer is perfectly
|
||||
* reachable; dropping on a single command falsely kicked playback back to
|
||||
* the phone. We log, leave the poll loop running, and nudge an immediate
|
||||
* poll so the UI reconciles to Sonos's actual state -- if the renderer is
|
||||
* truly gone, the poll loop trips the drop on its own.
|
||||
*/
|
||||
private fun handleTransportFailure(active: ActiveUpnp, t: Throwable) {
|
||||
if (t is SoapFaultException) {
|
||||
Timber.w(t, "UPnP transport rejected by %s -- device alive, no drop", active.routeName)
|
||||
} else {
|
||||
Timber.w(t, "UPnP transport failed on %s -- poll loop arbitrates", active.routeName)
|
||||
}
|
||||
pollTrigger.trySend(Unit)
|
||||
}
|
||||
|
||||
private fun onActiveChanged(active: ActiveUpnp?) {
|
||||
@@ -513,5 +549,39 @@ class MinstrelForwardingPlayer(
|
||||
// when Sonos's reported Track matches the wrapped player; this only
|
||||
// kicks in if SOAP fails or Sonos drops the ack entirely.
|
||||
const val PENDING_TRANSPORT_SAFETY_TIMEOUT_MS = 5_000L
|
||||
// Transport commands retry transient IO failures so a single WiFi
|
||||
// power-save stall (locked phone) doesn't abandon the command. 3
|
||||
// attempts x the SoapClient's 2s connect timeout + backoff bounds the
|
||||
// worst case at ~7s; a still-failing command then defers to the poll.
|
||||
const val TRANSPORT_RETRY_ATTEMPTS = 3
|
||||
const val TRANSPORT_RETRY_BACKOFF_MS = 400L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry [block] on transient transport-level ([IOException]) failures, with
|
||||
* [backoffMs] between attempts, up to [attempts] total. Anything that is not
|
||||
* an [IOException] -- notably [SoapFaultException], where the renderer
|
||||
* answered and rejected the action -- propagates immediately: the device is
|
||||
* alive, so retrying won't change its verdict. Extracted from
|
||||
* [MinstrelForwardingPlayer] so a locked phone's WiFi power-save stall doesn't
|
||||
* abandon a single transport command (the bug that falsely reverted Sonos
|
||||
* playback to local audio).
|
||||
*/
|
||||
internal suspend fun <T> retryTransientIo(
|
||||
attempts: Int,
|
||||
backoffMs: Long,
|
||||
block: suspend () -> T,
|
||||
): T {
|
||||
var attempt = 0
|
||||
while (true) {
|
||||
try {
|
||||
return block()
|
||||
} catch (io: IOException) {
|
||||
attempt += 1
|
||||
if (attempt >= attempts) throw io
|
||||
Timber.w(io, "transient transport failure (attempt %d) -- retrying", attempt)
|
||||
delay(backoffMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-6
@@ -3,14 +3,14 @@ package com.fabledsword.minstrel.player
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DataSpec
|
||||
import androidx.media3.datasource.TransferListener
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
||||
import java.io.IOException
|
||||
import java.io.InterruptedIOException
|
||||
|
||||
/**
|
||||
* DataSource wrapper that fails the network read immediately when
|
||||
* [ServerHealthController] reports a non-Healthy state. CacheDataSource only
|
||||
* [NetworkStatusController] reports a gating state. CacheDataSource only
|
||||
* calls this upstream factory on cache misses, so playback of cached audio is
|
||||
* unaffected -- only "tap a non-cached track while offline" hits this branch
|
||||
* and gets a fast, meaningful error instead of a multi-second network timeout
|
||||
@@ -22,10 +22,23 @@ import java.io.InterruptedIOException
|
||||
*/
|
||||
class OfflineGatedDataSource(
|
||||
private val delegate: DataSource,
|
||||
private val health: ServerHealthController,
|
||||
private val health: NetworkStatusController,
|
||||
) : DataSource {
|
||||
|
||||
override fun open(dataSpec: DataSpec): Long {
|
||||
gateOnHealth()
|
||||
return try {
|
||||
val opened = delegate.open(dataSpec)
|
||||
health.reportSuccess() // bytes flowing from the server == reachable
|
||||
opened
|
||||
} catch (e: IOException) {
|
||||
health.reportFailure() // real network read failed → arbitrate via /healthz
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/** Fast-fail before touching the network when the server can't be reached. */
|
||||
private fun gateOnHealth() {
|
||||
when (health.state.value) {
|
||||
ServerHealth.Offline -> throw OfflineException(
|
||||
"Track not in the on-device cache and the device is offline.",
|
||||
@@ -33,9 +46,9 @@ class OfflineGatedDataSource(
|
||||
ServerHealth.ServerDown -> throw OfflineException(
|
||||
"Track not in the on-device cache and the Minstrel server is unreachable.",
|
||||
)
|
||||
ServerHealth.Healthy -> Unit
|
||||
// Unstable is non-gating: still try the network. Healthy too.
|
||||
ServerHealth.Unstable, ServerHealth.Healthy -> Unit
|
||||
}
|
||||
return delegate.open(dataSpec)
|
||||
}
|
||||
|
||||
override fun close() = delegate.close()
|
||||
@@ -49,7 +62,7 @@ class OfflineGatedDataSource(
|
||||
|
||||
class OfflineGatedDataSourceFactory(
|
||||
private val upstream: DataSource.Factory,
|
||||
private val health: ServerHealthController,
|
||||
private val health: NetworkStatusController,
|
||||
) : DataSource.Factory {
|
||||
override fun createDataSource(): DataSource =
|
||||
OfflineGatedDataSource(upstream.createDataSource(), health)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
@@ -40,6 +41,7 @@ private const val DEBOUNCE_MS = 2_000L
|
||||
class PlaybackErrorReporter @Inject constructor(
|
||||
private val playerController: PlayerController,
|
||||
private val repository: PlaybackErrorRepository,
|
||||
private val networkStatus: NetworkStatusController,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
) {
|
||||
private val outChannel = Channel<String>(Channel.BUFFERED)
|
||||
@@ -52,6 +54,10 @@ class PlaybackErrorReporter @Inject constructor(
|
||||
val buffer = mutableListOf<String>()
|
||||
var debounceJob: kotlinx.coroutines.Job? = null
|
||||
playerController.playbackErrorEvents.collect { event ->
|
||||
// A track failing to play is ambiguous (dead server vs. one bad
|
||||
// file) — let the controller arbitrate via /healthz. No-op when
|
||||
// already Offline; cheap otherwise.
|
||||
networkStatus.reportFailure()
|
||||
// Fire-and-forget the server report — repository handles
|
||||
// success/queue branching so callers don't see throws.
|
||||
scope.launch { repository.report(event) }
|
||||
|
||||
@@ -55,7 +55,7 @@ class PlayerFactory @Inject constructor(
|
||||
private val cacheConfig: CacheConfig,
|
||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
private val serverHealth: com.fabledsword.minstrel.connectivity.ServerHealthController,
|
||||
private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
||||
) {
|
||||
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
|
||||
|
||||
|
||||
@@ -11,11 +11,13 @@ import javax.inject.Singleton
|
||||
* Updates flow in from:
|
||||
* - 1Hz GetPositionInfo poll -> applyPositionInfo()
|
||||
* - Transport SOAP calls landing 200 OK -> applyTransport{Playing,Paused,Stopped}()
|
||||
* - Error paths -> applyError() (drop fallback) or recordPollFailure()
|
||||
* - Poll failures -> recordPollFailure()
|
||||
*
|
||||
* The poll-failure counter implements the rolling-3 drop heuristic: 3
|
||||
* The poll-failure counter is the sole drop arbiter: [DROP_THRESHOLD]
|
||||
* consecutive poll failures = remote considered dropped (returns true
|
||||
* from recordPollFailure for the caller to surface). Success resets it.
|
||||
* from recordPollFailure for the caller to surface). Any success resets it.
|
||||
* Failed transport commands no longer drop the route -- they retry and
|
||||
* otherwise defer to this counter (see MinstrelForwardingPlayer).
|
||||
*/
|
||||
@Singleton
|
||||
class RemotePlayerState @Inject constructor() {
|
||||
@@ -27,6 +29,13 @@ class RemotePlayerState @Inject constructor() {
|
||||
@Volatile var lastError: Throwable? = null; private set
|
||||
@Volatile var trackNumber: Int = 0; private set
|
||||
|
||||
// The operator's last explicit transport intent (play=true, pause=false),
|
||||
// distinct from the observed remote isPlaying. handleRemoteDrop resumes
|
||||
// local playback based on THIS, not isPlaying -- so a play() that fails
|
||||
// its SOAP (stale/dead renderer) still resumes on the phone instead of
|
||||
// swallowing the tap. Deliberately NOT cleared by applyError.
|
||||
@Volatile var lastPlayIntent: Boolean = false; private set
|
||||
|
||||
// Pending-transport deadline (SystemClock.elapsedRealtime() at which we
|
||||
// give up waiting). When > 0, a user transport action (next/prev/seekTo
|
||||
// idx) is in flight: ForwardingPlayer has already moved the wrapped
|
||||
@@ -63,6 +72,8 @@ class RemotePlayerState @Inject constructor() {
|
||||
positionMs = 0L
|
||||
}
|
||||
|
||||
fun setPlayIntent(intended: Boolean) { lastPlayIntent = intended }
|
||||
|
||||
fun applyError(t: Throwable) {
|
||||
isPlaying = false
|
||||
lastError = t
|
||||
@@ -80,6 +91,7 @@ class RemotePlayerState @Inject constructor() {
|
||||
positionMs = 0L
|
||||
durationMs = 0L
|
||||
isPlaying = false
|
||||
lastPlayIntent = false
|
||||
currentTrackUri = ""
|
||||
lastError = null
|
||||
consecutivePollFailures = 0
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
/** Action the idle-revert collector should take on a player-state change. */
|
||||
internal enum class IdleRevertAction { ARM, CANCEL, IGNORE }
|
||||
|
||||
/**
|
||||
* Pure decision for the UPnP idle-revert timer. [armed] is whether the
|
||||
* idle timer Job is currently running. We ARM only on the transition INTO
|
||||
* "engaged + not playing" (so repeated paused emissions don't reset the
|
||||
* countdown), CANCEL whenever playback resumes or UPnP disengages, and
|
||||
* otherwise IGNORE.
|
||||
*/
|
||||
internal fun idleRevertAction(
|
||||
upnpEngaged: Boolean,
|
||||
isPlaying: Boolean,
|
||||
armed: Boolean,
|
||||
): IdleRevertAction = when {
|
||||
upnpEngaged && !isPlaying -> if (armed) IdleRevertAction.IGNORE else IdleRevertAction.ARM
|
||||
armed -> IdleRevertAction.CANCEL
|
||||
else -> IdleRevertAction.IGNORE
|
||||
}
|
||||
+72
-5
@@ -19,6 +19,7 @@ import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
|
||||
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -147,6 +148,12 @@ class OutputPickerController @Inject constructor(
|
||||
// can run the longest-common-prefix / common-suffix diff.
|
||||
private var lastSyncedQueueIds: List<String>? = null
|
||||
|
||||
// Idle-revert timer. Armed when playback is paused/stopped on a UPnP
|
||||
// route; fires revertToPhoneOnIdle after IDLE_REVERT_MS of continuous
|
||||
// non-playing. Cancelled on resume, route change, or explicit disconnect.
|
||||
// See idleRevertAction for the arm/cancel decision.
|
||||
private var idleRevertJob: Job? = null
|
||||
|
||||
init {
|
||||
// Two-arg addCallback registers with no discovery flag —
|
||||
// androidx.mediarouter 1.7.0's default passive behavior:
|
||||
@@ -155,7 +162,8 @@ class OutputPickerController @Inject constructor(
|
||||
// CALLBACK_FLAG_PASSIVE_DISCOVERY constant; absent flag = passive.)
|
||||
mediaRouter.addCallback(selector, callback)
|
||||
// When MinstrelForwardingPlayer reports the active UPnP route has
|
||||
// dropped (3+ consecutive poll failures or a transport SOAP exception),
|
||||
// dropped (the poll loop's consecutive-failure threshold tripping --
|
||||
// the sole drop arbiter; a failed transport command no longer drops),
|
||||
// clear the UPnP selection state and fall back to local ExoPlayer at
|
||||
// the last-known remote position. This mirrors selectSystem's disconnect
|
||||
// path but skips the Stop SOAP since the device is already unreachable.
|
||||
@@ -163,6 +171,54 @@ class OutputPickerController @Inject constructor(
|
||||
playerFactory.dropEvents.collect { handleRemoteDrop() }
|
||||
}
|
||||
scope.launch { observeQueueChangesForSonosResync() }
|
||||
scope.launch { observeIdleRevertWhileUpnp() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm/cancel the idle-revert timer as playback state changes while a
|
||||
* UPnP route is engaged. "Engaged" = active OR target set, so the brief
|
||||
* selectUpnp/resync window (active momentarily null, target set) doesn't
|
||||
* spuriously fire. The arm/cancel choice is the pure idleRevertAction.
|
||||
*/
|
||||
private suspend fun observeIdleRevertWhileUpnp() {
|
||||
playerController.uiState.collect { state ->
|
||||
val engaged = activeUpnpHolder.active.value != null ||
|
||||
activeUpnpHolder.target.value != null
|
||||
val armed = idleRevertJob?.isActive == true
|
||||
when (idleRevertAction(engaged, state.isPlaying, armed)) {
|
||||
IdleRevertAction.ARM ->
|
||||
idleRevertJob = scope.launch {
|
||||
delay(IDLE_REVERT_MS)
|
||||
revertToPhoneOnIdle()
|
||||
}
|
||||
IdleRevertAction.CANCEL -> {
|
||||
idleRevertJob?.cancel()
|
||||
idleRevertJob = null
|
||||
}
|
||||
IdleRevertAction.IGNORE -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Timer fired: the operator paused on a UPnP route and never resumed.
|
||||
* Stop the renderer (best-effort -- it's reachable, unlike a drop),
|
||||
* clear the UPnP overlay so routesState falls back to the phone, and
|
||||
* seek local ExoPlayer to the last remote position. No play(): the
|
||||
* operator was paused, so we revert routing silently. Guarded by the
|
||||
* selectUpnpMutex so it can't race selectUpnp/resync.
|
||||
*/
|
||||
private suspend fun revertToPhoneOnIdle() = selectUpnpMutex.withLock {
|
||||
if (remoteState.isPlaying) return@withLock // resumed at the boundary
|
||||
val active = activeUpnpHolder.active.value
|
||||
val capturedPositionMs = remoteState.positionMs
|
||||
runCatching { active?.avTransport?.stop() }
|
||||
.onFailure { Timber.w(it, "UPnP Stop failed during idle revert") }
|
||||
activeUpnpHolder.set(null)
|
||||
activeUpnpHolder.setTarget(null)
|
||||
selectedUpnpRouteIdInternal.value = null
|
||||
idleRevertJob = null
|
||||
playerController.seekTo(capturedPositionMs)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -365,16 +421,19 @@ class OutputPickerController @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the active UPnP route drops unexpectedly (poll-failure
|
||||
* threshold or SOAP exception). Captures the last remote position +
|
||||
* play state, clears UPnP selection, and resumes local ExoPlayer at
|
||||
* Called when the active UPnP route drops unexpectedly (the poll loop's
|
||||
* consecutive-failure threshold tripping). Captures the last remote
|
||||
* position + play state, clears UPnP selection, and resumes local ExoPlayer at
|
||||
* the same point. Skips the Stop SOAP (device already unreachable).
|
||||
* The snackbar is handled independently by the NowPlaying surface
|
||||
* collecting the same [PlayerFactory.dropEvents] via PlayerController.
|
||||
*/
|
||||
private fun handleRemoteDrop() {
|
||||
val capturedPositionMs = remoteState.positionMs
|
||||
val wasPlayingRemote = remoteState.isPlaying
|
||||
// Resume on the operator's last play-intent, not the observed remote
|
||||
// state: after a pause, isPlaying is false, but a failed play() that
|
||||
// triggered this drop means the operator DID ask to play -- honor it.
|
||||
val wasPlayingRemote = remoteState.lastPlayIntent
|
||||
activeUpnpHolder.set(null)
|
||||
activeUpnpHolder.setTarget(null)
|
||||
selectedUpnpRouteIdInternal.value = null
|
||||
@@ -497,6 +556,9 @@ class OutputPickerController @Inject constructor(
|
||||
rendering = rendering,
|
||||
),
|
||||
)
|
||||
// Selecting a route loads the queue and Plays on the renderer, so
|
||||
// the standing intent is "play" -- a later drop should resume local.
|
||||
remoteState.setPlayIntent(true)
|
||||
}.onFailure { e ->
|
||||
Timber.w(e, "UPnP select failed for route ${effectiveRoute.id}")
|
||||
activeUpnpHolder.set(null)
|
||||
@@ -640,5 +702,10 @@ class OutputPickerController @Inject constructor(
|
||||
private companion object {
|
||||
const val EXTEND_ABORT_AFTER_FAILURES = 3
|
||||
const val EXTEND_THROTTLE_MS = 50L
|
||||
|
||||
// 5 minutes of continuous non-playing on a UPnP route before we
|
||||
// revert to the phone speaker, so a stale Sonos selection can't make
|
||||
// a later "tap play" do nothing.
|
||||
const val IDLE_REVERT_MS = 5 * 60 * 1000L
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -17,6 +17,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -45,6 +46,15 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
) {
|
||||
private val ssdp = SsdpDiscovery(context)
|
||||
|
||||
// Transport commands (play/pause/seek/next) and the 1 Hz poll go through
|
||||
// this client. A dead renderer should fail fast so OutputPickerController's
|
||||
// drop-recovery reverts to the phone in ~2 s instead of the shared client's
|
||||
// 10 s connect timeout. Derived via newBuilder() so the connection pool +
|
||||
// dispatcher stay shared with the app client.
|
||||
private val controlHttp: OkHttpClient = okHttp.newBuilder()
|
||||
.connectTimeout(CONTROL_CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
|
||||
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
|
||||
|
||||
@@ -91,7 +101,7 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
fun transportFor(routeId: String): AVTransportClient? {
|
||||
val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null
|
||||
return AVTransportClient(
|
||||
loggingSoapClient(okHttp, route.name),
|
||||
loggingSoapClient(controlHttp, route.name),
|
||||
route.avTransportControlUrl,
|
||||
)
|
||||
}
|
||||
@@ -225,6 +235,11 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
// devices append. Anchored to end-of-string so it never eats
|
||||
// a legitimate parenthetical inside a name.
|
||||
val IP_SUFFIX_REGEX = Regex("""\s*\(\d+\.\d+\.\d+\.\d+\)$""")
|
||||
|
||||
// Aggressive connect timeout for transport SOAP so a dead renderer
|
||||
// trips the drop-recovery quickly. Conservative enough not to false-drop
|
||||
// a slow-but-alive LAN; read timeout still inherits the shared client.
|
||||
const val CONTROL_CONNECT_TIMEOUT_SECONDS = 2L
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+28
-2
@@ -7,6 +7,7 @@ import retrofit2.HttpException
|
||||
import java.net.HttpURLConnection
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.PlaylistCachedCount
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||
@@ -18,6 +19,7 @@ import com.fabledsword.minstrel.models.wire.PlaylistTrackWire
|
||||
import com.fabledsword.minstrel.models.wire.PlaylistWire
|
||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
@@ -51,9 +53,16 @@ class PlaylistsRepository @Inject constructor(
|
||||
|
||||
// ── Reads (Flow, cache-first; the cache is the source of truth) ──
|
||||
|
||||
/** Owned + public; UI splits by isSystem / userId comparison. */
|
||||
/**
|
||||
* Owned + public; UI splits by isSystem / userId comparison. Joined with
|
||||
* per-playlist cache counts so [PlaylistRef.fullyCached] drives the offline
|
||||
* greying without each consumer re-querying the cache index.
|
||||
*/
|
||||
fun observeAll(): Flow<List<PlaylistRef>> =
|
||||
playlistDao.observeAll().map { rows -> rows.map { it.toDomain() } }
|
||||
combine(
|
||||
playlistDao.observeAll(),
|
||||
playlistDao.observeCachedCounts(),
|
||||
) { rows, counts -> mergePlaylistsWithCache(rows, counts) }
|
||||
|
||||
/** User-owned playlists only (systemVariant IS NULL). */
|
||||
fun observeUserPlaylists(): Flow<List<PlaylistRef>> =
|
||||
@@ -233,6 +242,23 @@ fun List<PlaylistTrackRef>.toPlayableTrackRefs(): List<TrackRef> =
|
||||
|
||||
// ── Mappers (internal — keep Room + wire types out of the UI layer) ──
|
||||
|
||||
/**
|
||||
* Maps cached playlist rows to domain refs, stamping [PlaylistRef.fullyCached]
|
||||
* from the cache-index counts. A playlist is fully cached when it has tracks
|
||||
* and every member track is resident (`cachedCount >= trackCount`). Pulled out
|
||||
* of the Flow so the predicate is unit-testable without a Room database.
|
||||
*/
|
||||
internal fun mergePlaylistsWithCache(
|
||||
rows: List<CachedPlaylistEntity>,
|
||||
counts: List<PlaylistCachedCount>,
|
||||
): List<PlaylistRef> {
|
||||
val cachedById = counts.associate { it.playlistId to it.cachedCount }
|
||||
return rows.map { row ->
|
||||
val cached = cachedById[row.id] ?: 0
|
||||
row.toDomain().copy(fullyCached = row.trackCount > 0 && cached >= row.trackCount)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CachedPlaylistEntity.toDomain(): PlaylistRef =
|
||||
PlaylistRef(
|
||||
id = id,
|
||||
|
||||
+14
-10
@@ -27,7 +27,8 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavHostController
|
||||
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.events.EventsStream
|
||||
import com.fabledsword.minstrel.models.PlaylistRef
|
||||
import com.fabledsword.minstrel.nav.PlaylistDetail
|
||||
@@ -66,7 +67,7 @@ class PlaylistsListViewModel @Inject constructor(
|
||||
private val repository: PlaylistsRepository,
|
||||
private val player: PlayerController,
|
||||
private val eventsStream: EventsStream,
|
||||
connectivity: ConnectivityObserver,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val poolMessages = Channel<String>(Channel.BUFFERED)
|
||||
@@ -74,9 +75,9 @@ class PlaylistsListViewModel @Inject constructor(
|
||||
/** Transient snackbar messages from playlist tile play taps. */
|
||||
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
|
||||
|
||||
/** True when the device has no usable internet -- gates refreshable system tiles. */
|
||||
val offline: StateFlow<Boolean> = connectivity.online
|
||||
.map { !it }
|
||||
/** Cache-only: no link OR server unreachable (Unstable stays calm). Greys tiles. */
|
||||
val offline: StateFlow<Boolean> = networkStatus.state
|
||||
.map { it == ServerHealth.Offline || it == ServerHealth.ServerDown }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
@@ -191,14 +192,15 @@ private fun PlaylistsGrid(
|
||||
SectionHeader("System playlists")
|
||||
}
|
||||
items(items = systemPlaylists, key = { it.id }) { playlist ->
|
||||
// Greyed offline when it needs the live server or isn't fully
|
||||
// cached — dimmed but still tappable into the detail.
|
||||
val greyed = offline && playlist.unavailableOffline
|
||||
PlaylistCard(
|
||||
playlist = playlist,
|
||||
onClick = { onPlaylistClick(playlist.id) },
|
||||
onPlay = { onPlay(playlist) },
|
||||
// Refreshable system tiles need server endpoints (systemShuffle);
|
||||
// disable when offline. Empty mixes show a snackbar after tap.
|
||||
playEnabled = playlist.trackCount > 0 &&
|
||||
!(offline && playlist.refreshable),
|
||||
playEnabled = playlist.trackCount > 0 && !greyed,
|
||||
greyed = greyed,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -207,11 +209,13 @@ private fun PlaylistsGrid(
|
||||
SectionHeader("Your playlists")
|
||||
}
|
||||
items(items = userPlaylists, key = { it.id }) { playlist ->
|
||||
val greyed = offline && playlist.unavailableOffline
|
||||
PlaylistCard(
|
||||
playlist = playlist,
|
||||
onClick = { onPlaylistClick(playlist.id) },
|
||||
onPlay = { onPlay(playlist) },
|
||||
playEnabled = playlist.trackCount > 0,
|
||||
playEnabled = playlist.trackCount > 0 && !greyed,
|
||||
greyed = greyed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -15,6 +15,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -40,6 +41,10 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
||||
* [playEnabled] disables the overlay (50% alpha + ignore taps) — Home
|
||||
* uses it for system playlists in offline mode (their server-side
|
||||
* shuffle endpoint is unreachable) and for empty playlists.
|
||||
*
|
||||
* [greyed] dims the whole tile (it can't be reliably played offline) while
|
||||
* keeping it tappable — the detail screen is the escape hatch for shuffling
|
||||
* whatever subset is cached. Greyed always disables the play overlay too.
|
||||
*/
|
||||
@Composable
|
||||
fun PlaylistCard(
|
||||
@@ -48,11 +53,13 @@ fun PlaylistCard(
|
||||
modifier: Modifier = Modifier,
|
||||
onPlay: (suspend () -> Unit)? = null,
|
||||
playEnabled: Boolean = true,
|
||||
greyed: Boolean = false,
|
||||
) {
|
||||
val seedCache = LocalDetailSeedCache.current
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.width(176.dp)
|
||||
.alpha(if (greyed) GREYED_ALPHA else 1f) // dimmed, still clickable
|
||||
.clickable {
|
||||
seedCache.stashPlaylist(playlist)
|
||||
onClick()
|
||||
@@ -63,7 +70,7 @@ fun PlaylistCard(
|
||||
PlaylistCardCover(
|
||||
playlist = playlist,
|
||||
onPlay = onPlay,
|
||||
playEnabled = playEnabled,
|
||||
playEnabled = playEnabled && !greyed,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
@@ -148,6 +155,7 @@ private fun VariantPill(label: String, modifier: Modifier = Modifier) {
|
||||
}
|
||||
|
||||
private const val PILL_BG_ALPHA = 0.85f
|
||||
private const val GREYED_ALPHA = 0.45f
|
||||
|
||||
private fun subtitleFor(playlist: PlaylistRef): String = when {
|
||||
playlist.trackCount > 0 -> "${playlist.trackCount} tracks"
|
||||
|
||||
+47
-1
@@ -5,11 +5,13 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.events.EventsStream
|
||||
import com.fabledsword.minstrel.models.RequestRef
|
||||
import com.fabledsword.minstrel.models.RequestStatus
|
||||
import com.fabledsword.minstrel.requests.data.CancelOutcome
|
||||
import com.fabledsword.minstrel.requests.data.RequestsRepository
|
||||
import com.fabledsword.minstrel.shared.UiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -20,6 +22,10 @@ import javax.inject.Inject
|
||||
|
||||
private val RELEVANT_EVENT_KINDS = setOf("request.status_changed")
|
||||
|
||||
// Cadence for in-flight request polling — matches the web client (#369):
|
||||
// fast enough to feel live, slow enough not to hammer the server.
|
||||
private const val POLL_INTERVAL_MS = 12_000L
|
||||
|
||||
@HiltViewModel
|
||||
class RequestsViewModel @Inject constructor(
|
||||
private val repository: RequestsRepository,
|
||||
@@ -31,11 +37,14 @@ class RequestsViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
refresh()
|
||||
// SSE push: reconciler completions arrive here; reload silently so the
|
||||
// row updates in place rather than flashing the loading spinner.
|
||||
viewModelScope.launch {
|
||||
eventsStream.events
|
||||
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
||||
.collect { refresh() }
|
||||
.collect { silentReload() }
|
||||
}
|
||||
viewModelScope.launch { pollWhileInFlight() }
|
||||
}
|
||||
|
||||
fun refresh(): Job = viewModelScope.launch {
|
||||
@@ -54,6 +63,43 @@ class RequestsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-poll while any request is mid-ingest (status APPROVED), matching the
|
||||
* web client (#369). Reloads silently every [POLL_INTERVAL_MS] and pauses
|
||||
* when nothing is in-flight — complements the SSE push so progress stays
|
||||
* live even if the event stream drops.
|
||||
*/
|
||||
private suspend fun pollWhileInFlight() {
|
||||
while (true) {
|
||||
if (hasInFlightRequest()) {
|
||||
silentReload()
|
||||
}
|
||||
delay(POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasInFlightRequest(): Boolean {
|
||||
val state = internal.value
|
||||
return state is UiState.Success &&
|
||||
state.data.any { it.status == RequestStatus.APPROVED }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload the list in place without flipping to Loading — used by the poll
|
||||
* loop and the SSE collector so visible rows update without a spinner
|
||||
* flash. A transient failure keeps the current view; the next reload wins.
|
||||
*/
|
||||
private suspend fun silentReload() {
|
||||
try {
|
||||
val rows = repository.listMine()
|
||||
internal.value = if (rows.isEmpty()) UiState.Empty else UiState.Success(rows)
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Keep the current view; the next poll / SSE / pull reconciles.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistically removes [id] from the success list so the row
|
||||
* disappears immediately; on server-side success the row is
|
||||
|
||||
@@ -4,8 +4,8 @@ import com.fabledsword.minstrel.api.endpoints.SearchApi
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
||||
import com.fabledsword.minstrel.library.data.toDomain
|
||||
import com.fabledsword.minstrel.models.SearchResponseRef
|
||||
import retrofit2.Retrofit
|
||||
@@ -16,8 +16,8 @@ import javax.inject.Singleton
|
||||
private const val LOCAL_SEARCH_LIMIT = 20
|
||||
|
||||
/**
|
||||
* Cache-first when offline. When [ServerHealthController] is Healthy the
|
||||
* repository hits `/api/search` and returns the server's three-facet
|
||||
* Cache-first when offline. When [NetworkStatusController] reports Healthy or
|
||||
* Unstable the repository hits `/api/search` and returns the server's three-facet
|
||||
* paged response. When health is Offline or ServerDown it falls back to
|
||||
* Room LIKE queries against `cached_*` so the user can still find
|
||||
* something to play from what's already on the device; the outcome's
|
||||
@@ -27,7 +27,7 @@ private const val LOCAL_SEARCH_LIMIT = 20
|
||||
@Singleton
|
||||
class SearchRepository @Inject constructor(
|
||||
retrofit: Retrofit,
|
||||
private val serverHealth: ServerHealthController,
|
||||
private val serverHealth: NetworkStatusController,
|
||||
private val trackDao: CachedTrackDao,
|
||||
private val albumDao: CachedAlbumDao,
|
||||
private val artistDao: CachedArtistDao,
|
||||
@@ -35,7 +35,8 @@ class SearchRepository @Inject constructor(
|
||||
private val api: SearchApi = retrofit.create()
|
||||
|
||||
suspend fun search(query: String): SearchOutcome = when (serverHealth.state.value) {
|
||||
ServerHealth.Healthy -> SearchOutcome(remoteSearch(query), localOnly = false)
|
||||
ServerHealth.Healthy, ServerHealth.Unstable ->
|
||||
SearchOutcome(remoteSearch(query), localOnly = false)
|
||||
ServerHealth.Offline, ServerHealth.ServerDown ->
|
||||
SearchOutcome(localSearch(query), localOnly = true)
|
||||
}
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.fabledsword.minstrel.shared.widgets
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Routes a deliberate pull-to-refresh into a /healthz recheck so the connection
|
||||
* banner clears within seconds rather than waiting for the next poll — even on
|
||||
* cache-only screens whose own refresh never touches the network. Backs
|
||||
* [PullToRefreshScaffold].
|
||||
*/
|
||||
@HiltViewModel
|
||||
class PullRefreshNetworkViewModel @Inject constructor(
|
||||
private val networkStatus: NetworkStatusController,
|
||||
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
fun recheck() = networkStatus.recheck()
|
||||
}
|
||||
+6
-1
@@ -10,12 +10,15 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Wraps [content] in a Material3 PullToRefreshBox so the user can
|
||||
* swipe-down to trigger [onRefresh]. The wrapper manages the
|
||||
* `isRefreshing` indicator while [onRefresh] is in flight.
|
||||
* `isRefreshing` indicator while [onRefresh] is in flight. Every pull also
|
||||
* fires a /healthz recheck (via [PullRefreshNetworkViewModel]) so a stale
|
||||
* connection banner clears promptly on a deliberate user refresh.
|
||||
*
|
||||
* [onRefresh] is suspend: pass `{ viewModel.refresh().join() }` so the
|
||||
* indicator hides exactly when the underlying coroutine completes,
|
||||
@@ -32,6 +35,7 @@ import kotlinx.coroutines.launch
|
||||
fun PullToRefreshScaffold(
|
||||
onRefresh: suspend () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
netVm: PullRefreshNetworkViewModel = hiltViewModel(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
@@ -42,6 +46,7 @@ fun PullToRefreshScaffold(
|
||||
scope.launch {
|
||||
isRefreshing = true
|
||||
try {
|
||||
netVm.recheck() // deliberate pull → re-probe the server now
|
||||
onRefresh()
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
|
||||
@@ -61,8 +61,11 @@ fun TrackRow(
|
||||
trailing: @Composable RowScope.() -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val offlineUnavailable = LocalServerHealth.current != ServerHealth.Healthy &&
|
||||
trackId !in LocalCachedTrackIds.current
|
||||
val health = LocalServerHealth.current
|
||||
// Unstable is non-gating — only a real gating state dims uncached rows.
|
||||
val offlineUnavailable =
|
||||
(health == ServerHealth.Offline || health == ServerHealth.ServerDown) &&
|
||||
trackId !in LocalCachedTrackIds.current
|
||||
val titleColor = if (nowPlaying) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
|
||||
@@ -7,8 +7,8 @@ import retrofit2.http.GET
|
||||
/**
|
||||
* Retrofit interface for `GET /healthz` — unauthenticated health probe
|
||||
* returning the server's running version + the minimum client version
|
||||
* it'll talk to. Used by [com.fabledsword.minstrel.update.data.VersionCheckController]
|
||||
* to surface the VersionTooOld banner.
|
||||
* it'll talk to. Polled by [com.fabledsword.minstrel.connectivity.NetworkStatusController]
|
||||
* for both reachability and the VersionTooOld banner.
|
||||
*/
|
||||
interface HealthzApi {
|
||||
@GET("healthz")
|
||||
|
||||
-112
@@ -1,112 +0,0 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.update.api.HealthzApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.Retrofit
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val POLL_INTERVAL_MS = 5 * 60 * 1000L
|
||||
|
||||
// Hysteresis on the reachable signal. Flipping internalReachable to false on
|
||||
// the very first /healthz failure produced two false positives:
|
||||
// 1. App startup — AuthStore.baseUrl loads from Room asynchronously, so the
|
||||
// first runOnce() can fire against AuthStore.DEFAULT_BASE_URL
|
||||
// ("http://localhost:8080") before the real server URL has hydrated.
|
||||
// 2. Deployments whose reverse proxy routes only /api/* to the Go server —
|
||||
// /healthz never reaches the handler, so the user sees a permanent
|
||||
// "Server unreachable" banner even though all real /api/* calls succeed.
|
||||
// Requiring 3 consecutive failures (~15 min at the 5-min poll cadence) ensures
|
||||
// the banner only fires on sustained, real unreachability — and a single
|
||||
// success at any point resets the counter so transient hiccups self-clear.
|
||||
private const val REACHABILITY_FAILURE_THRESHOLD = 3
|
||||
|
||||
/**
|
||||
* Result of the most recent /healthz version-compatibility check.
|
||||
* `Skipped` means the server didn't include `min_client_version`
|
||||
* (partial deploy or older server) and the UI should not gate.
|
||||
*/
|
||||
enum class VersionResult { OK, TOO_OLD, SKIPPED }
|
||||
|
||||
/**
|
||||
* Polls /healthz periodically and exposes the current
|
||||
* [VersionResult] for the shell's VersionTooOld banner. Soft-fails
|
||||
* on network errors — keeps the last-known result rather than
|
||||
* showing a misleading "too old" on a connectivity blip.
|
||||
*
|
||||
* Constructed at app launch via the construct-the-singleton trick
|
||||
* in [com.fabledsword.minstrel.MinstrelApplication].
|
||||
*/
|
||||
@Singleton
|
||||
class VersionCheckController @Inject constructor(
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
retrofit: Retrofit,
|
||||
) {
|
||||
private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
|
||||
|
||||
private val internal = MutableStateFlow(VersionResult.SKIPPED)
|
||||
val result: StateFlow<VersionResult> = internal.asStateFlow()
|
||||
|
||||
// Whether the most recent /healthz poll reached the server. Separate from
|
||||
// VersionResult because "unreachable" and "version mismatch" drive
|
||||
// different UX (offline banner vs version-too-old banner). Optimistic
|
||||
// initial value -- the first poll fires within seconds of app launch and
|
||||
// we don't want a "server down" flash before we've actually tried.
|
||||
// Consumed by ServerHealthController to compose with ConnectivityObserver
|
||||
// for the tri-state offline / server-down / healthy signal.
|
||||
private val internalReachable = MutableStateFlow(true)
|
||||
val reachable: StateFlow<Boolean> = internalReachable.asStateFlow()
|
||||
|
||||
private var consecutiveFailures = 0
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
while (true) {
|
||||
runOnce()
|
||||
delay(POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One-shot recheck, bypassing the poll cadence. Used by the banner's "Check now" button. */
|
||||
fun recheck() {
|
||||
scope.launch { runOnce() }
|
||||
}
|
||||
|
||||
private suspend fun runOnce() {
|
||||
val outcome = runCatching { api.check() }
|
||||
val response = outcome.getOrNull()
|
||||
if (response == null) {
|
||||
consecutiveFailures++
|
||||
if (consecutiveFailures >= REACHABILITY_FAILURE_THRESHOLD &&
|
||||
internalReachable.value
|
||||
) {
|
||||
Timber.w(
|
||||
"/healthz unreachable for %d consecutive polls — flipping reachable=false",
|
||||
consecutiveFailures,
|
||||
)
|
||||
internalReachable.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!internalReachable.value) {
|
||||
Timber.i("/healthz recovered after %d failures", consecutiveFailures)
|
||||
}
|
||||
consecutiveFailures = 0
|
||||
internalReachable.value = true
|
||||
val min = response.minClientVersion
|
||||
internal.value = when {
|
||||
min.isEmpty() -> VersionResult.SKIPPED
|
||||
isVersionNewer(min, BuildConfig.VERSION_NAME) -> VersionResult.TOO_OLD
|
||||
else -> VersionResult.OK
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
/**
|
||||
* Result of the most recent /healthz version-compatibility check.
|
||||
* `SKIPPED` means the server didn't include `min_client_version`
|
||||
* (partial deploy or older server) and the UI should not gate.
|
||||
*/
|
||||
enum class VersionResult { OK, TOO_OLD, SKIPPED }
|
||||
+4
-4
@@ -2,21 +2,21 @@ package com.fabledsword.minstrel.update.ui
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.fabledsword.minstrel.update.data.VersionCheckController
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.update.data.VersionResult
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Tiny VM that exposes the [VersionCheckController]'s current
|
||||
* Tiny VM that exposes the [NetworkStatusController]'s current
|
||||
* [VersionResult] plus its recheck trigger for the banner button.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class VersionTooOldViewModel @Inject constructor(
|
||||
private val controller: VersionCheckController,
|
||||
private val controller: NetworkStatusController,
|
||||
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
val result: StateFlow<VersionResult> = controller.result
|
||||
val result: StateFlow<VersionResult> = controller.versionResult
|
||||
fun recheck() = controller.recheck()
|
||||
}
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package com.fabledsword.minstrel.connectivity
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReachabilityMachineTest {
|
||||
|
||||
private fun machine() = ReachabilityMachine()
|
||||
|
||||
@Test
|
||||
fun `starts healthy`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
assertEquals(ServerHealth.Healthy, m.health())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no link is offline regardless of probes`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = false)
|
||||
m.onProbeFailure(nowMs = 1)
|
||||
assertEquals(ServerHealth.Offline, m.health())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single probe failure is unstable not down`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onProbeFailure(nowMs = 1_000)
|
||||
assertEquals(ServerHealth.Unstable, m.health())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `probe success from unstable recovers to healthy`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onProbeFailure(nowMs = 1_000)
|
||||
m.onSuccess()
|
||||
assertEquals(ServerHealth.Healthy, m.health())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `op success from unstable recovers to healthy`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onProbeFailure(nowMs = 1_000)
|
||||
m.onSuccess() // a successful stream read / API 2xx is self-proving
|
||||
assertEquals(ServerHealth.Healthy, m.health())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two op failures plus a failed probe escalate immediately`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onOpFailure(nowMs = 1_000)
|
||||
m.onOpFailure(nowMs = 1_500) // corroboration reached
|
||||
m.onProbeFailure(nowMs = 2_000) // probe agrees → fast ServerDown
|
||||
assertEquals(ServerHealth.ServerDown, m.health())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `op failures with a successful probe stay healthy (track-specific)`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onOpFailure(nowMs = 1_000)
|
||||
m.onOpFailure(nowMs = 1_500)
|
||||
m.onSuccess() // arbiter says server is fine
|
||||
assertEquals(ServerHealth.Healthy, m.health())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale op failures do not corroborate`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onOpFailure(nowMs = 0)
|
||||
m.onOpFailure(nowMs = 1_000)
|
||||
// both op failures are now older than the corroboration window:
|
||||
m.onProbeFailure(nowMs = 1_000 + CORROBORATION_WINDOW_MS + 1)
|
||||
assertEquals(ServerHealth.Unstable, m.health()) // not enough fresh corroboration
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sustained failure backstop escalates after the window`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onProbeFailure(nowMs = 1_000) // unstable, streak starts
|
||||
m.onProbeFailure(nowMs = 1_000 + ESCALATE_AFTER_MS) // sustained ≥ backstop
|
||||
assertEquals(ServerHealth.ServerDown, m.health())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `link restored keeps last-known down until a fresh result`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onProbeFailure(nowMs = 1_000)
|
||||
m.onProbeFailure(nowMs = 1_000 + ESCALATE_AFTER_MS) // ServerDown
|
||||
m.onLinkChange(up = false)
|
||||
assertEquals(ServerHealth.Offline, m.health())
|
||||
m.onLinkChange(up = true)
|
||||
// link back but no fresh probe result yet — last known reachability was down:
|
||||
assertEquals(ServerHealth.ServerDown, m.health())
|
||||
m.onSuccess()
|
||||
assertEquals(ServerHealth.Healthy, m.health())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unstable does not gate — it is not offline or serverdown`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onProbeFailure(nowMs = 1_000)
|
||||
val health = m.health()
|
||||
assertTrue(health != ServerHealth.Offline && health != ServerHealth.ServerDown)
|
||||
assertEquals(ServerHealth.Unstable, health)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.fabledsword.minstrel.home.ui
|
||||
|
||||
import com.fabledsword.minstrel.models.PlaylistRef
|
||||
import com.fabledsword.minstrel.models.SystemPlaylistsStatus
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class BuildPlaylistsRowTest {
|
||||
|
||||
private fun user(id: String, cached: Boolean) =
|
||||
PlaylistRef(id = id, userId = "u", name = id, trackCount = 1, fullyCached = cached)
|
||||
|
||||
@Test
|
||||
fun `offline row leads with pools then available before greyed`() {
|
||||
val owned = listOf(user("partial", cached = false), user("full", cached = true))
|
||||
val row = buildPlaylistsRow(owned, SystemPlaylistsStatus(), offline = true)
|
||||
|
||||
assertTrue(row[0] is PlaylistRowItem.OfflinePool)
|
||||
assertTrue(row[1] is PlaylistRowItem.OfflinePool)
|
||||
// Fully-cached "full" comes before the greyed "partial".
|
||||
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||
assertEquals(listOf("full", "partial"), reals)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offline greys a refreshable system playlist even when fully cached`() {
|
||||
val forYou = PlaylistRef(
|
||||
id = "fy",
|
||||
userId = "u",
|
||||
name = "For You",
|
||||
systemVariant = "for_you",
|
||||
trackCount = 1,
|
||||
fullyCached = true,
|
||||
)
|
||||
val row = buildPlaylistsRow(
|
||||
listOf(forYou, user("u1", cached = true)),
|
||||
SystemPlaylistsStatus(),
|
||||
offline = true,
|
||||
)
|
||||
// The fully-cached user playlist is available; the refreshable system
|
||||
// mix needs the server, so it greys out and sorts after.
|
||||
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||
assertEquals(listOf("u1", "fy"), reals)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offline row drops building-pending placeholders`() {
|
||||
val row = buildPlaylistsRow(emptyList(), SystemPlaylistsStatus(), offline = true)
|
||||
assertTrue(row.none { it is PlaylistRowItem.Placeholder })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `online row has no offline pools and keeps system-slot placeholders`() {
|
||||
val row = buildPlaylistsRow(emptyList(), SystemPlaylistsStatus(), offline = false)
|
||||
assertTrue(row.none { it is PlaylistRowItem.OfflinePool })
|
||||
assertTrue(row.any { it is PlaylistRowItem.Placeholder })
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,32 @@ class RemotePlayerStateTest {
|
||||
assertFalse(state.recordPollFailure())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `play intent survives a SOAP error so a failed play can resume locally`() {
|
||||
val state = RemotePlayerState()
|
||||
state.setPlayIntent(true)
|
||||
state.applyError(RuntimeException("connect timeout"))
|
||||
// isPlaying is cleared by the error, but the operator's intent persists.
|
||||
assertFalse(state.isPlaying)
|
||||
assertTrue(state.lastPlayIntent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pausing clears play intent`() {
|
||||
val state = RemotePlayerState()
|
||||
state.setPlayIntent(true)
|
||||
state.setPlayIntent(false)
|
||||
assertFalse(state.lastPlayIntent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset clears play intent`() {
|
||||
val state = RemotePlayerState()
|
||||
state.setPlayIntent(true)
|
||||
state.reset()
|
||||
assertFalse(state.lastPlayIntent)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DROP_THRESHOLD = 30
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.IOException
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
/**
|
||||
* Locks in the transport-command retry semantics behind the locked-phone
|
||||
* Sonos drop fix: a transient IO stall (WiFi power-save) must be retried, a
|
||||
* SOAP fault from a responding renderer must NOT be retried, and an
|
||||
* exhausted retry must rethrow rather than loop forever. Without this, a
|
||||
* single failed command falsely reverted Sonos playback to the phone.
|
||||
*/
|
||||
class RetryTransientIoTest {
|
||||
|
||||
@Test
|
||||
fun `returns immediately on first success`() = runTest {
|
||||
var calls = 0
|
||||
val result = retryTransientIo(attempts = 3, backoffMs = 0L) {
|
||||
calls += 1
|
||||
"ok"
|
||||
}
|
||||
assertEquals("ok", result)
|
||||
assertEquals(1, calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retries transient IO failure then succeeds`() = runTest {
|
||||
var calls = 0
|
||||
val result = retryTransientIo(attempts = 3, backoffMs = 0L) {
|
||||
calls += 1
|
||||
if (calls < 2) throw IOException("connect timeout")
|
||||
"ok"
|
||||
}
|
||||
assertEquals("ok", result)
|
||||
assertEquals(2, calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rethrows IO failure after exhausting attempts`() = runTest {
|
||||
var calls = 0
|
||||
assertFailsWith<IOException> {
|
||||
retryTransientIo(attempts = 3, backoffMs = 0L) {
|
||||
calls += 1
|
||||
throw IOException("still asleep")
|
||||
}
|
||||
}
|
||||
assertEquals(3, calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not retry a SOAP fault from a responding renderer`() = runTest {
|
||||
var calls = 0
|
||||
assertFailsWith<SoapFaultException> {
|
||||
retryTransientIo(attempts = 3, backoffMs = 0L) {
|
||||
calls += 1
|
||||
throw SoapFaultException("718", "Invalid InstanceID")
|
||||
}
|
||||
}
|
||||
assertEquals(1, calls)
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class IdleRevertDecisionTest {
|
||||
|
||||
@Test
|
||||
fun `engaged and paused and not armed arms the timer`() {
|
||||
assertEquals(
|
||||
IdleRevertAction.ARM,
|
||||
idleRevertAction(upnpEngaged = true, isPlaying = false, armed = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `engaged and paused and already armed is ignored`() {
|
||||
assertEquals(
|
||||
IdleRevertAction.IGNORE,
|
||||
idleRevertAction(upnpEngaged = true, isPlaying = false, armed = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resuming playback cancels an armed timer`() {
|
||||
assertEquals(
|
||||
IdleRevertAction.CANCEL,
|
||||
idleRevertAction(upnpEngaged = true, isPlaying = true, armed = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `playing with no timer armed is ignored`() {
|
||||
assertEquals(
|
||||
IdleRevertAction.IGNORE,
|
||||
idleRevertAction(upnpEngaged = true, isPlaying = true, armed = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disengaging UPnP cancels an armed timer`() {
|
||||
assertEquals(
|
||||
IdleRevertAction.CANCEL,
|
||||
idleRevertAction(upnpEngaged = false, isPlaying = false, armed = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disengaged with no timer armed is ignored`() {
|
||||
assertEquals(
|
||||
IdleRevertAction.IGNORE,
|
||||
idleRevertAction(upnpEngaged = false, isPlaying = true, armed = false),
|
||||
)
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package com.fabledsword.minstrel.playlists.data
|
||||
|
||||
import com.fabledsword.minstrel.cache.db.dao.PlaylistCachedCount
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PlaylistsRepositoryCacheMergeTest {
|
||||
|
||||
private fun playlist(id: String, trackCount: Int) =
|
||||
CachedPlaylistEntity(id = id, userId = "u", name = id, trackCount = trackCount)
|
||||
|
||||
@Test
|
||||
fun `fully cached when every member track is resident`() {
|
||||
val out = mergePlaylistsWithCache(
|
||||
rows = listOf(playlist("p", trackCount = 3)),
|
||||
counts = listOf(PlaylistCachedCount("p", cachedCount = 3)),
|
||||
)
|
||||
assertTrue(out.single().fullyCached)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `not fully cached when only some tracks are resident`() {
|
||||
val out = mergePlaylistsWithCache(
|
||||
rows = listOf(playlist("p", trackCount = 3)),
|
||||
counts = listOf(PlaylistCachedCount("p", cachedCount = 2)),
|
||||
)
|
||||
assertFalse(out.single().fullyCached)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `not fully cached when no tracks are resident (missing count row)`() {
|
||||
val out = mergePlaylistsWithCache(
|
||||
rows = listOf(playlist("p", trackCount = 3)),
|
||||
counts = emptyList(),
|
||||
)
|
||||
assertFalse(out.single().fullyCached)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty playlist is never fully cached`() {
|
||||
val out = mergePlaylistsWithCache(
|
||||
rows = listOf(playlist("p", trackCount = 0)),
|
||||
counts = listOf(PlaylistCachedCount("p", cachedCount = 0)),
|
||||
)
|
||||
assertFalse(out.single().fullyCached)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each playlist gets its own cache verdict`() {
|
||||
val out = mergePlaylistsWithCache(
|
||||
rows = listOf(playlist("full", 2), playlist("partial", 2)),
|
||||
counts = listOf(
|
||||
PlaylistCachedCount("full", cachedCount = 2),
|
||||
PlaylistCachedCount("partial", cachedCount = 1),
|
||||
),
|
||||
)
|
||||
val byId = out.associateBy { it.id }
|
||||
assertEquals(true, byId.getValue("full").fullyCached)
|
||||
assertEquals(false, byId.getValue("partial").fullyCached)
|
||||
}
|
||||
}
|
||||
+48
-4
@@ -5,12 +5,15 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||
@@ -53,6 +56,35 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// safetyNetScanInterval backstops the fsnotify watcher with a low-frequency
|
||||
// full delta walk, catching anything the watcher missed (inotify watch-limit
|
||||
// exhaustion, dropped events). Fixed — there is no operator configuration.
|
||||
const safetyNetScanInterval = 12 * time.Hour
|
||||
|
||||
// runSafetyNetScans ticks a delta RunScan at safetyNetScanInterval until ctx
|
||||
// is cancelled, deferring to TryStartScan's in-flight guard so it never
|
||||
// collides with a manual, startup, or watcher-driven scan.
|
||||
func runSafetyNetScans(ctx context.Context, pool *pgxpool.Pool, scanner *library.Scanner,
|
||||
enricher *coverart.Enricher, logger *slog.Logger, scanCfg library.RunScanConfig,
|
||||
) {
|
||||
ticker := time.NewTicker(safetyNetScanInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
started, existing, err := library.TryStartScan(ctx, pool, scanner, enricher, logger, scanCfg)
|
||||
if err != nil {
|
||||
logger.Error("safety-net scan: try start failed", "err", err)
|
||||
} else if !started && existing != nil {
|
||||
logger.Info("safety-net scan skipped — prior still in flight",
|
||||
"in_flight_id", existing.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
configPath := flag.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
|
||||
flag.Parse()
|
||||
@@ -237,12 +269,24 @@ func run() error {
|
||||
ArtistEnrichCap: -1,
|
||||
DataDir: cfg.Storage.DataDir,
|
||||
}
|
||||
scheduler := library.NewScheduler(pool, logger.With("component", "scheduler"),
|
||||
scanner, coverEnricher, scanCfg)
|
||||
scheduler.Start(ctx)
|
||||
// Filesystem watcher: near-instant pickup of new/changed files via
|
||||
// fsnotify, scanning just the affected paths + enriching their albums
|
||||
// inline. Replaces the removed configurable scan scheduler.
|
||||
watcher := library.NewWatcher(scanner, coverEnricher,
|
||||
logger.With("component", "watcher"), cfg.Library.ScanPaths)
|
||||
go func() {
|
||||
if werr := watcher.Run(ctx); werr != nil {
|
||||
logger.Warn("library watcher exited", "err", werr)
|
||||
}
|
||||
}()
|
||||
// Safety-net delta walk backstops the watcher (inotify limits / dropped
|
||||
// events) at a fixed low frequency. No operator config.
|
||||
go runSafetyNetScans(ctx, pool, scanner, coverEnricher,
|
||||
logger.With("component", "scan_safetynet"), scanCfg)
|
||||
|
||||
srv := server.New(logger, pool, scanner, subsonic.Config{
|
||||
AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword,
|
||||
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg, scheduler)
|
||||
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg)
|
||||
srv.Bus = bus
|
||||
srv.PlaylistScheduler = playlistScheduler
|
||||
srv.StreamSecret = cfg.StreamSecret
|
||||
|
||||
@@ -4,6 +4,7 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-co-op/gocron/v2 v2.21.2
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
@@ -25,5 +26,6 @@ require (
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
)
|
||||
|
||||
@@ -23,6 +23,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-co-op/gocron/v2 v2.21.2 h1:bD8/YwkojYHgXFr3iEulL148KBdTbKVxUZzFKpXcdbY=
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
)
|
||||
|
||||
type scanScheduleResp struct {
|
||||
Mode string `json:"mode"`
|
||||
IntervalHours *int `json:"interval_hours"`
|
||||
TimeOfDay *string `json:"time_of_day"`
|
||||
WeeklyDay *int `json:"weekly_day"`
|
||||
NextScheduledAt *string `json:"next_scheduled_at"`
|
||||
}
|
||||
|
||||
type scanScheduleReq struct {
|
||||
Mode string `json:"mode"`
|
||||
IntervalHours *int `json:"interval_hours"`
|
||||
TimeOfDay *string `json:"time_of_day"`
|
||||
WeeklyDay *int `json:"weekly_day"`
|
||||
}
|
||||
|
||||
func (h *handlers) handleGetScanSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
q := dbq.New(h.pool)
|
||||
row, err := q.GetScanSchedule(r.Context())
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
h.logger.Error("admin: scan_schedule singleton row missing")
|
||||
writeErr(w, apierror.InternalMsg("schedule row missing", errors.New("schedule row missing")))
|
||||
return
|
||||
}
|
||||
writeErrWithLog(w, h.logger, "admin: get scan schedule", apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, scanScheduleRespFromRow(row, h.scheduler.NextFire()))
|
||||
}
|
||||
|
||||
func (h *handlers) handlePatchScanSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
var req scanScheduleReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateScheduleReq(&req); err != nil {
|
||||
writeErr(w, apierror.BadRequest("validation", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
q := dbq.New(h.pool)
|
||||
if err := q.UpdateScanSchedule(r.Context(), dbq.UpdateScanScheduleParams{
|
||||
Mode: req.Mode,
|
||||
IntervalHours: pgInt32Ptr(req.IntervalHours),
|
||||
TimeOfDay: req.TimeOfDay,
|
||||
WeeklyDay: pgInt32Ptr(req.WeeklyDay),
|
||||
}); err != nil {
|
||||
writeErrWithLog(w, h.logger, "admin: update scan schedule", apierror.InternalMsg("update failed", err))
|
||||
return
|
||||
}
|
||||
|
||||
h.scheduler.Refresh()
|
||||
|
||||
row, err := q.GetScanSchedule(r.Context())
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "admin: re-read scan schedule", apierror.InternalMsg("re-read failed", err))
|
||||
return
|
||||
}
|
||||
cfg := library.ScheduleConfig{Mode: row.Mode}
|
||||
if row.IntervalHours != nil {
|
||||
cfg.IntervalHours = int(*row.IntervalHours)
|
||||
}
|
||||
if row.TimeOfDay != nil {
|
||||
cfg.TimeOfDay = *row.TimeOfDay
|
||||
}
|
||||
if row.WeeklyDay != nil {
|
||||
cfg.WeeklyDay = int(*row.WeeklyDay)
|
||||
}
|
||||
next := cfg.NextFire(time.Now())
|
||||
|
||||
writeJSON(w, http.StatusOK, scanScheduleRespFromRow(row, next))
|
||||
}
|
||||
|
||||
func validateScheduleReq(req *scanScheduleReq) error {
|
||||
switch req.Mode {
|
||||
case "off":
|
||||
req.IntervalHours = nil
|
||||
req.TimeOfDay = nil
|
||||
req.WeeklyDay = nil
|
||||
return nil
|
||||
case "interval":
|
||||
if req.IntervalHours == nil || *req.IntervalHours <= 0 {
|
||||
return fmt.Errorf("interval_hours required and > 0 when mode=interval")
|
||||
}
|
||||
req.TimeOfDay = nil
|
||||
req.WeeklyDay = nil
|
||||
return nil
|
||||
case "daily":
|
||||
if req.TimeOfDay == nil || *req.TimeOfDay == "" {
|
||||
return fmt.Errorf("time_of_day required when mode=daily")
|
||||
}
|
||||
req.IntervalHours = nil
|
||||
req.WeeklyDay = nil
|
||||
return nil
|
||||
case "weekly":
|
||||
if req.TimeOfDay == nil || *req.TimeOfDay == "" {
|
||||
return fmt.Errorf("time_of_day required when mode=weekly")
|
||||
}
|
||||
if req.WeeklyDay == nil || *req.WeeklyDay < 1 || *req.WeeklyDay > 7 {
|
||||
return fmt.Errorf("weekly_day required and in [1, 7] when mode=weekly")
|
||||
}
|
||||
req.IntervalHours = nil
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid mode %q (want off / interval / daily / weekly)", req.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
func scanScheduleRespFromRow(row dbq.GetScanScheduleRow, nextFire time.Time) scanScheduleResp {
|
||||
resp := scanScheduleResp{Mode: row.Mode}
|
||||
if row.IntervalHours != nil {
|
||||
v := int(*row.IntervalHours)
|
||||
resp.IntervalHours = &v
|
||||
}
|
||||
if row.TimeOfDay != nil {
|
||||
v := *row.TimeOfDay
|
||||
resp.TimeOfDay = &v
|
||||
}
|
||||
if row.WeeklyDay != nil {
|
||||
v := int(*row.WeeklyDay)
|
||||
resp.WeeklyDay = &v
|
||||
}
|
||||
if !nextFire.IsZero() {
|
||||
s := nextFire.UTC().Format(time.RFC3339)
|
||||
resp.NextScheduledAt = &s
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func pgInt32Ptr(src *int) *int32 {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
v := int32(*src)
|
||||
return &v
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
)
|
||||
|
||||
func newAdminScheduleRouter(h *handlers) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Route("/api/admin", func(admin chi.Router) {
|
||||
admin.Use(auth.RequireAdmin())
|
||||
admin.Get("/scan/schedule", h.handleGetScanSchedule)
|
||||
admin.Patch("/scan/schedule", h.handlePatchScanSchedule)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func TestAdminScanSchedule_Get_DefaultIsOff(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
|
||||
h.scheduler = s
|
||||
admin := seedUser(t, pool, "schedget", "pw", true)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/scan/schedule", nil)
|
||||
req = withUser(req, admin)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminScheduleRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp scanScheduleResp
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Mode != "off" {
|
||||
t.Errorf("Mode = %q, want off", resp.Mode)
|
||||
}
|
||||
if resp.NextScheduledAt != nil {
|
||||
t.Errorf("NextScheduledAt = %v, want nil for mode=off", resp.NextScheduledAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminScanSchedule_Patch_DailyMode(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
|
||||
h.scheduler = s
|
||||
admin := seedUser(t, pool, "schedpatch", "pw", true)
|
||||
|
||||
body := `{"mode":"daily","time_of_day":"03:00"}`
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/admin/scan/schedule",
|
||||
bytes.NewReader([]byte(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withUser(req, admin)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminScheduleRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp scanScheduleResp
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Mode != "daily" {
|
||||
t.Errorf("Mode = %q, want daily", resp.Mode)
|
||||
}
|
||||
if resp.TimeOfDay == nil || *resp.TimeOfDay != "03:00" {
|
||||
t.Errorf("TimeOfDay = %v, want '03:00'", resp.TimeOfDay)
|
||||
}
|
||||
if resp.NextScheduledAt == nil {
|
||||
t.Errorf("NextScheduledAt = nil, want a future ISO timestamp")
|
||||
} else {
|
||||
next, err := time.Parse(time.RFC3339, *resp.NextScheduledAt)
|
||||
if err != nil {
|
||||
t.Errorf("NextScheduledAt parse: %v", err)
|
||||
} else if local := next.Local(); local.Hour() != 3 || local.Minute() != 0 {
|
||||
// Handler computes next-fire from time.Now() in the host zone, so
|
||||
// the local-time hour is what's stable across CI runners regardless
|
||||
// of TZ; the wire timestamp is UTC but parses back to the same
|
||||
// instant either way.
|
||||
t.Errorf("NextScheduledAt local time = %v, want 03:00 in host zone", local)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminScanSchedule_Patch_WeeklyMissingDay_400(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
|
||||
h.scheduler = s
|
||||
admin := seedUser(t, pool, "schedweekly", "pw", true)
|
||||
|
||||
body := `{"mode":"weekly","time_of_day":"03:00"}`
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/admin/scan/schedule",
|
||||
bytes.NewReader([]byte(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withUser(req, admin)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminScheduleRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminScanSchedule_Patch_OffNormalizes(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
|
||||
h.scheduler = s
|
||||
admin := seedUser(t, pool, "schedoff", "pw", true)
|
||||
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
`UPDATE scan_schedule SET mode='daily', time_of_day='03:00' WHERE id=true`,
|
||||
); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
body := `{"mode":"off","time_of_day":"03:00"}`
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/admin/scan/schedule",
|
||||
bytes.NewReader([]byte(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withUser(req, admin)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminScheduleRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var timeOfDay *string
|
||||
if err := pool.QueryRow(context.Background(),
|
||||
`SELECT time_of_day FROM scan_schedule WHERE id=true`,
|
||||
).Scan(&timeOfDay); err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if timeOfDay != nil {
|
||||
t.Errorf("time_of_day = %v, want nil after mode=off normalize", timeOfDay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminScanSchedule_NonAdmin_403(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
|
||||
h.scheduler = s
|
||||
user := seedUser(t, pool, "schednonadmin", "pw", false)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/scan/schedule", nil)
|
||||
req = withUser(req, user)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminScheduleRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
+3
-5
@@ -28,7 +28,7 @@ import (
|
||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||
// RequireUser; everything else is gated by the middleware. The events writer
|
||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
|
||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||
h := &handlers{
|
||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
||||
@@ -42,7 +42,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
coverSettings: coverSettings,
|
||||
scanner: scanner,
|
||||
scanCfg: scanCfg,
|
||||
scheduler: scheduler,
|
||||
dataDir: dataDir,
|
||||
mailer: sender,
|
||||
eventbus: bus,
|
||||
@@ -87,6 +86,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
authed.Get("/artists", h.handleListArtists)
|
||||
authed.Get("/artists/{id}", h.handleGetArtist)
|
||||
authed.Get("/artists/{id}/tracks", h.handleGetArtistTracks)
|
||||
authed.Get("/artists/{id}/similar", h.handleGetSimilarArtists)
|
||||
authed.Get("/artists/{id}/top-tracks", h.handleGetArtistTopTracks)
|
||||
authed.Get("/albums/{id}", h.handleGetAlbum)
|
||||
authed.Get("/albums/{id}/cover", h.handleGetCover)
|
||||
authed.Get("/library/shuffle", h.handleLibraryShuffle)
|
||||
@@ -167,8 +168,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
|
||||
admin.Get("/scan/status", h.handleGetScanStatus)
|
||||
admin.Post("/scan/run", h.handleTriggerScan)
|
||||
admin.Get("/scan/schedule", h.handleGetScanSchedule)
|
||||
admin.Patch("/scan/schedule", h.handlePatchScanSchedule)
|
||||
|
||||
admin.Get("/library/coverage", h.handleGetLibraryCoverage)
|
||||
|
||||
@@ -222,7 +221,6 @@ type handlers struct {
|
||||
coverSettings *coverart.SettingsService
|
||||
scanner *library.Scanner
|
||||
scanCfg library.RunScanConfig
|
||||
scheduler *library.Scheduler
|
||||
dataDir string
|
||||
mailer mailer.Sender
|
||||
eventbus *eventbus.Bus
|
||||
|
||||
@@ -159,6 +159,85 @@ func (h *handlers) handleGetArtistTracks(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// similarArtistsDefaultLimit caps the artist-detail "similar artists" strip;
|
||||
// artistTopTracksDefaultLimit caps the per-user "top tracks" panel.
|
||||
const (
|
||||
similarArtistsDefaultLimit = 12
|
||||
artistTopTracksDefaultLimit = 5
|
||||
)
|
||||
|
||||
// handleGetSimilarArtists implements GET /api/artists/{id}/similar. Returns
|
||||
// in-library artists similar to {id} (ranked by similarity score) as a flat
|
||||
// ArtistRef list with cover + album count. Empty when the similarity ingest
|
||||
// has no matches yet; 404 when the artist doesn't exist.
|
||||
func (h *handlers) handleGetSimilarArtists(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := requireURLUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q := dbq.New(h.pool)
|
||||
if _, err := q.GetArtistByID(r.Context(), id); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, apierror.NotFound("artist"))
|
||||
return
|
||||
}
|
||||
h.logger.Error("api: get artist for similar", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
rows, err := q.ListSimilarArtistsForArtist(r.Context(), dbq.ListSimilarArtistsForArtistParams{
|
||||
SeedArtistID: id, ResultLimit: similarArtistsDefaultLimit,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("api: list similar artists", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
out := make([]ArtistRef, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleGetArtistTopTracks implements GET /api/artists/{id}/top-tracks. Returns
|
||||
// the current user's most-played tracks for {id} (skips excluded, quarantine
|
||||
// filtered). Empty when the user hasn't played this artist; 404 when the
|
||||
// artist doesn't exist.
|
||||
func (h *handlers) handleGetArtistTopTracks(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := requireURLUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q := dbq.New(h.pool)
|
||||
if _, err := q.GetArtistByID(r.Context(), id); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, apierror.NotFound("artist"))
|
||||
return
|
||||
}
|
||||
h.logger.Error("api: get artist for top tracks", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
rows, err := q.ListMostPlayedTracksForArtist(r.Context(), dbq.ListMostPlayedTracksForArtistParams{
|
||||
ArtistID: id, UserID: user.ID, ResultLimit: artistTopTracksDefaultLimit,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("api: list artist top tracks", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
out := make([]TrackRef, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleLibraryShuffle implements GET /api/library/shuffle?limit=N —
|
||||
// the online source for the client's always-present "Shuffle all"
|
||||
// (#427 S4). N random tracks across the whole library, per-user
|
||||
|
||||
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
30*time.Minute, 0.5, 30000)
|
||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, nil, h.dataDir, nil, eventbus.New(), nil, nil)
|
||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil)
|
||||
|
||||
paths := []string{
|
||||
"/api/artists",
|
||||
|
||||
@@ -439,15 +439,6 @@ type ScanRun struct {
|
||||
ArtistArtEnrich []byte
|
||||
}
|
||||
|
||||
type ScanSchedule struct {
|
||||
ID bool
|
||||
Mode string
|
||||
IntervalHours *int32
|
||||
TimeOfDay *string
|
||||
WeeklyDay *int32
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type ScrobbleQueue struct {
|
||||
ID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
|
||||
@@ -97,6 +97,83 @@ func (q *Queries) ListLastPlayedArtistsForUser(ctx context.Context, arg ListLast
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listMostPlayedTracksForArtist = `-- name: ListMostPlayedTracksForArtist :many
|
||||
WITH plays AS (
|
||||
SELECT track_id, count(*) AS cnt
|
||||
FROM play_events
|
||||
WHERE user_id = $2 AND was_skipped = false
|
||||
GROUP BY track_id
|
||||
)
|
||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
||||
albums.title AS album_title,
|
||||
artists.name AS artist_name
|
||||
FROM plays p
|
||||
JOIN tracks t ON t.id = p.track_id
|
||||
JOIN albums ON albums.id = t.album_id
|
||||
JOIN artists ON artists.id = t.artist_id
|
||||
WHERE t.artist_id = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $2 AND q.track_id = t.id
|
||||
)
|
||||
ORDER BY p.cnt DESC, t.id
|
||||
LIMIT $3
|
||||
`
|
||||
|
||||
type ListMostPlayedTracksForArtistParams struct {
|
||||
ArtistID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
ResultLimit int32
|
||||
}
|
||||
|
||||
type ListMostPlayedTracksForArtistRow struct {
|
||||
Track Track
|
||||
AlbumTitle string
|
||||
ArtistName string
|
||||
}
|
||||
|
||||
// Top tracks for one artist by this user's completed-play count (skips
|
||||
// excluded, quarantine filtered). Same projection as
|
||||
// ListMostPlayedTracksForUser plus an artist_id filter, so the handler
|
||||
// reuses trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName).
|
||||
func (q *Queries) ListMostPlayedTracksForArtist(ctx context.Context, arg ListMostPlayedTracksForArtistParams) ([]ListMostPlayedTracksForArtistRow, error) {
|
||||
rows, err := q.db.Query(ctx, listMostPlayedTracksForArtist, arg.ArtistID, arg.UserID, arg.ResultLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListMostPlayedTracksForArtistRow
|
||||
for rows.Next() {
|
||||
var i ListMostPlayedTracksForArtistRow
|
||||
if err := rows.Scan(
|
||||
&i.Track.ID,
|
||||
&i.Track.Title,
|
||||
&i.Track.AlbumID,
|
||||
&i.Track.ArtistID,
|
||||
&i.Track.TrackNumber,
|
||||
&i.Track.DiscNumber,
|
||||
&i.Track.DurationMs,
|
||||
&i.Track.FilePath,
|
||||
&i.Track.FileSize,
|
||||
&i.Track.FileFormat,
|
||||
&i.Track.Bitrate,
|
||||
&i.Track.Mbid,
|
||||
&i.Track.Genre,
|
||||
&i.Track.AddedAt,
|
||||
&i.Track.UpdatedAt,
|
||||
&i.AlbumTitle,
|
||||
&i.ArtistName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listMostPlayedTracksForUser = `-- name: ListMostPlayedTracksForUser :many
|
||||
WITH plays AS (
|
||||
SELECT track_id, count(*) AS cnt
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: scan_schedule.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const getScanSchedule = `-- name: GetScanSchedule :one
|
||||
SELECT mode, interval_hours, time_of_day, weekly_day, updated_at
|
||||
FROM scan_schedule WHERE id = true
|
||||
`
|
||||
|
||||
type GetScanScheduleRow struct {
|
||||
Mode string
|
||||
IntervalHours *int32
|
||||
TimeOfDay *string
|
||||
WeeklyDay *int32
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
// M7 recurring-scans: returns the singleton schedule row. Used by the
|
||||
// scheduler goroutine on boot and config-refresh, and by the admin GET
|
||||
// handler.
|
||||
func (q *Queries) GetScanSchedule(ctx context.Context) (GetScanScheduleRow, error) {
|
||||
row := q.db.QueryRow(ctx, getScanSchedule)
|
||||
var i GetScanScheduleRow
|
||||
err := row.Scan(
|
||||
&i.Mode,
|
||||
&i.IntervalHours,
|
||||
&i.TimeOfDay,
|
||||
&i.WeeklyDay,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateScanSchedule = `-- name: UpdateScanSchedule :exec
|
||||
UPDATE scan_schedule
|
||||
SET mode = $1,
|
||||
interval_hours = $2,
|
||||
time_of_day = $3,
|
||||
weekly_day = $4,
|
||||
updated_at = now()
|
||||
WHERE id = true
|
||||
`
|
||||
|
||||
type UpdateScanScheduleParams struct {
|
||||
Mode string
|
||||
IntervalHours *int32
|
||||
TimeOfDay *string
|
||||
WeeklyDay *int32
|
||||
}
|
||||
|
||||
// Admin PATCH replaces the whole row; CHECK constraints enforce validity.
|
||||
// The application normalizes mode='off' to NULL the per-mode fields
|
||||
// before calling this.
|
||||
func (q *Queries) UpdateScanSchedule(ctx context.Context, arg UpdateScanScheduleParams) error {
|
||||
_, err := q.db.Exec(ctx, updateScanSchedule,
|
||||
arg.Mode,
|
||||
arg.IntervalHours,
|
||||
arg.TimeOfDay,
|
||||
arg.WeeklyDay,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -154,6 +154,77 @@ func (q *Queries) ListPlayedTracksNeedingSimilarity(ctx context.Context, limit i
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listSimilarArtistsForArtist = `-- name: ListSimilarArtistsForArtist :many
|
||||
SELECT artists.id, artists.name, artists.sort_name, artists.mbid, artists.created_at, artists.updated_at, artists.artist_thumb_path, artists.artist_fanart_path, artists.artist_art_source, artists.artist_art_sources_version,
|
||||
cov.id AS cover_album_id,
|
||||
cnt.album_count::bigint AS album_count
|
||||
FROM (
|
||||
SELECT artist_b_id, max(score) AS sim_score
|
||||
FROM artist_similarity
|
||||
WHERE artist_a_id = $1
|
||||
GROUP BY artist_b_id
|
||||
) s
|
||||
JOIN artists ON artists.id = s.artist_b_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id FROM albums
|
||||
WHERE artist_id = artists.id AND cover_art_path IS NOT NULL
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
) cov ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT count(*) AS album_count
|
||||
FROM albums WHERE artist_id = artists.id
|
||||
) cnt ON true
|
||||
ORDER BY s.sim_score DESC, artists.sort_name
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListSimilarArtistsForArtistParams struct {
|
||||
SeedArtistID pgtype.UUID
|
||||
ResultLimit int32
|
||||
}
|
||||
|
||||
type ListSimilarArtistsForArtistRow struct {
|
||||
Artist Artist
|
||||
CoverAlbumID pgtype.UUID
|
||||
AlbumCount int64
|
||||
}
|
||||
|
||||
// In-library artists similar to a seed artist, ranked by best similarity
|
||||
// score across sources (deduped per candidate). cover_album_id + album_count
|
||||
// mirror ListArtistsAlphaWithCovers so the strip renders identical cards.
|
||||
func (q *Queries) ListSimilarArtistsForArtist(ctx context.Context, arg ListSimilarArtistsForArtistParams) ([]ListSimilarArtistsForArtistRow, error) {
|
||||
rows, err := q.db.Query(ctx, listSimilarArtistsForArtist, arg.SeedArtistID, arg.ResultLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListSimilarArtistsForArtistRow
|
||||
for rows.Next() {
|
||||
var i ListSimilarArtistsForArtistRow
|
||||
if err := rows.Scan(
|
||||
&i.Artist.ID,
|
||||
&i.Artist.Name,
|
||||
&i.Artist.SortName,
|
||||
&i.Artist.Mbid,
|
||||
&i.Artist.CreatedAt,
|
||||
&i.Artist.UpdatedAt,
|
||||
&i.Artist.ArtistThumbPath,
|
||||
&i.Artist.ArtistFanartPath,
|
||||
&i.Artist.ArtistArtSource,
|
||||
&i.Artist.ArtistArtSourcesVersion,
|
||||
&i.CoverAlbumID,
|
||||
&i.AlbumCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const upsertArtistSimilarity = `-- name: UpsertArtistSimilarity :exec
|
||||
INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source, fetched_at)
|
||||
VALUES ($1, $2, $3, 'listenbrainz', now())
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Recreate the scan_schedule singleton (mirror of 0019) so the drop is
|
||||
-- reversible. The application no longer reads or writes this table.
|
||||
CREATE TABLE scan_schedule (
|
||||
id boolean PRIMARY KEY DEFAULT true,
|
||||
mode text NOT NULL DEFAULT 'off',
|
||||
interval_hours int,
|
||||
time_of_day text,
|
||||
weekly_day int,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT scan_schedule_singleton CHECK (id = true),
|
||||
CONSTRAINT scan_schedule_mode_check
|
||||
CHECK (mode IN ('off', 'interval', 'daily', 'weekly')),
|
||||
CONSTRAINT scan_schedule_interval_check
|
||||
CHECK (mode != 'interval' OR (interval_hours IS NOT NULL AND interval_hours > 0)),
|
||||
CONSTRAINT scan_schedule_daily_check
|
||||
CHECK (mode != 'daily' OR time_of_day IS NOT NULL),
|
||||
CONSTRAINT scan_schedule_weekly_check
|
||||
CHECK (mode != 'weekly' OR (time_of_day IS NOT NULL AND weekly_day IS NOT NULL AND weekly_day BETWEEN 1 AND 7))
|
||||
);
|
||||
|
||||
INSERT INTO scan_schedule (id) VALUES (true);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Retire the configurable scan scheduler. New-file pickup is now handled by
|
||||
-- the fsnotify watcher + a hardcoded safety-net delta walk (cmd/minstrel),
|
||||
-- so the operator-configurable schedule (and its admin UI) is gone.
|
||||
DROP TABLE IF EXISTS scan_schedule;
|
||||
@@ -231,6 +231,32 @@ WHERE NOT EXISTS (
|
||||
ORDER BY p.cnt DESC, t.id
|
||||
LIMIT $2;
|
||||
|
||||
-- name: ListMostPlayedTracksForArtist :many
|
||||
-- Top tracks for one artist by this user's completed-play count (skips
|
||||
-- excluded, quarantine filtered). Same projection as
|
||||
-- ListMostPlayedTracksForUser plus an artist_id filter, so the handler
|
||||
-- reuses trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName).
|
||||
WITH plays AS (
|
||||
SELECT track_id, count(*) AS cnt
|
||||
FROM play_events
|
||||
WHERE user_id = sqlc.arg(user_id) AND was_skipped = false
|
||||
GROUP BY track_id
|
||||
)
|
||||
SELECT sqlc.embed(t),
|
||||
albums.title AS album_title,
|
||||
artists.name AS artist_name
|
||||
FROM plays p
|
||||
JOIN tracks t ON t.id = p.track_id
|
||||
JOIN albums ON albums.id = t.album_id
|
||||
JOIN artists ON artists.id = t.artist_id
|
||||
WHERE t.artist_id = sqlc.arg(artist_id)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id
|
||||
)
|
||||
ORDER BY p.cnt DESC, t.id
|
||||
LIMIT sqlc.arg(result_limit);
|
||||
|
||||
-- name: ListLastPlayedArtistsForUser :many
|
||||
-- M6a: artists ranked by max(play_events.started_at) for the user, with
|
||||
-- a derived cover_album_id via a representative-album lateral join (most
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
-- name: GetScanSchedule :one
|
||||
-- M7 recurring-scans: returns the singleton schedule row. Used by the
|
||||
-- scheduler goroutine on boot and config-refresh, and by the admin GET
|
||||
-- handler.
|
||||
SELECT mode, interval_hours, time_of_day, weekly_day, updated_at
|
||||
FROM scan_schedule WHERE id = true;
|
||||
|
||||
-- name: UpdateScanSchedule :exec
|
||||
-- Admin PATCH replaces the whole row; CHECK constraints enforce validity.
|
||||
-- The application normalizes mode='off' to NULL the per-mode fields
|
||||
-- before calling this.
|
||||
UPDATE scan_schedule
|
||||
SET mode = $1,
|
||||
interval_hours = $2,
|
||||
time_of_day = $3,
|
||||
weekly_day = $4,
|
||||
updated_at = now()
|
||||
WHERE id = true;
|
||||
@@ -60,3 +60,29 @@ ON CONFLICT (seed_artist_id, candidate_mbid, source) DO UPDATE SET
|
||||
candidate_name = EXCLUDED.candidate_name,
|
||||
score = EXCLUDED.score,
|
||||
fetched_at = now();
|
||||
|
||||
-- name: ListSimilarArtistsForArtist :many
|
||||
-- In-library artists similar to a seed artist, ranked by best similarity
|
||||
-- score across sources (deduped per candidate). cover_album_id + album_count
|
||||
-- mirror ListArtistsAlphaWithCovers so the strip renders identical cards.
|
||||
SELECT sqlc.embed(artists),
|
||||
cov.id AS cover_album_id,
|
||||
cnt.album_count::bigint AS album_count
|
||||
FROM (
|
||||
SELECT artist_b_id, max(score) AS sim_score
|
||||
FROM artist_similarity
|
||||
WHERE artist_a_id = sqlc.arg(seed_artist_id)
|
||||
GROUP BY artist_b_id
|
||||
) s
|
||||
JOIN artists ON artists.id = s.artist_b_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id FROM albums
|
||||
WHERE artist_id = artists.id AND cover_art_path IS NOT NULL
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
) cov ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT count(*) AS album_count
|
||||
FROM albums WHERE artist_id = artists.id
|
||||
) cnt ON true
|
||||
ORDER BY s.sim_score DESC, artists.sort_name
|
||||
LIMIT sqlc.arg(result_limit);
|
||||
|
||||
+56
-11
@@ -87,7 +87,7 @@ func (s *Scanner) Scan(ctx context.Context, progressCb func(Stats)) (Stats, erro
|
||||
if !audioExtensions[strings.ToLower(filepath.Ext(path))] {
|
||||
return nil
|
||||
}
|
||||
if err := s.scanFile(ctx, q, path, &stats); err != nil {
|
||||
if _, _, err := s.scanFile(ctx, q, path, &stats); err != nil {
|
||||
s.logger.Warn("library scan file error", "path", path, "err", err)
|
||||
stats.Errored++
|
||||
}
|
||||
@@ -114,19 +114,24 @@ func (s *Scanner) Scan(ctx context.Context, progressCb func(Stats)) (Stats, erro
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, stats *Stats) error {
|
||||
// scanFile upserts a single audio file. Returns the album ID the track
|
||||
// belongs to and whether the file was added/updated (false = skipped as
|
||||
// unchanged), so watcher-driven callers can enrich just the changed albums.
|
||||
func (s *Scanner) scanFile(
|
||||
ctx context.Context, q *dbq.Queries, path string, stats *Stats,
|
||||
) (pgtype.UUID, bool, error) {
|
||||
stats.Scanned++
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat: %w", err)
|
||||
return pgtype.UUID{}, false, fmt.Errorf("stat: %w", err)
|
||||
}
|
||||
mtime := info.ModTime()
|
||||
|
||||
existing, err := q.GetTrackByPath(ctx, path)
|
||||
knownTrack := err == nil
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return fmt.Errorf("lookup: %w", err)
|
||||
return pgtype.UUID{}, false, fmt.Errorf("lookup: %w", err)
|
||||
}
|
||||
// Incremental skip: only when the file hasn't changed AND we already have
|
||||
// a real duration. The second clause lets older scans that recorded
|
||||
@@ -135,18 +140,18 @@ func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, sta
|
||||
// scans short-circuit as before.
|
||||
if knownTrack && !existing.UpdatedAt.Time.Before(mtime) && existing.DurationMs > 0 {
|
||||
stats.Skipped++
|
||||
return nil
|
||||
return pgtype.UUID{}, false, nil
|
||||
}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open: %w", err)
|
||||
return pgtype.UUID{}, false, fmt.Errorf("open: %w", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
meta, err := tag.ReadFrom(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tag read: %w", err)
|
||||
return pgtype.UUID{}, false, fmt.Errorf("tag read: %w", err)
|
||||
}
|
||||
albumMBID, artistMBID := extractMBIDs(meta)
|
||||
recordingMBID := extractRecordingMBID(meta)
|
||||
@@ -166,11 +171,11 @@ func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, sta
|
||||
|
||||
artist, err := s.resolveArtist(ctx, q, artistName, artistMBID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("artist: %w", err)
|
||||
return pgtype.UUID{}, false, fmt.Errorf("artist: %w", err)
|
||||
}
|
||||
album, err := s.resolveAlbum(ctx, q, artist.ID, albumTitle, meta.Year(), albumMBID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("album: %w", err)
|
||||
return pgtype.UUID{}, false, fmt.Errorf("album: %w", err)
|
||||
}
|
||||
|
||||
trackNum, _ := meta.Track()
|
||||
@@ -213,7 +218,7 @@ func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, sta
|
||||
|
||||
track, err := q.UpsertTrack(ctx, params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert track: %w", err)
|
||||
return pgtype.UUID{}, false, fmt.Errorf("upsert track: %w", err)
|
||||
}
|
||||
if err := syncpkg.LogChange(ctx, s.pool, syncpkg.EntityTrack,
|
||||
syncpkg.FormatUUID(track.ID), syncpkg.OpUpsert); err != nil {
|
||||
@@ -227,7 +232,47 @@ func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, sta
|
||||
} else {
|
||||
stats.Added++
|
||||
}
|
||||
return nil
|
||||
return album.ID, true, nil
|
||||
}
|
||||
|
||||
// ScanFiles processes a specific set of audio file paths (watcher-driven),
|
||||
// applying the same upsert + delta-skip logic as a full Scan. Non-audio or
|
||||
// unreadable paths are skipped (logged), never fatal. Returns the distinct
|
||||
// album IDs whose tracks were added or updated, so the caller can enrich
|
||||
// just those albums inline rather than waiting for a batch pass.
|
||||
func (s *Scanner) ScanFiles(ctx context.Context, paths []string) ([]pgtype.UUID, error) {
|
||||
q := dbq.New(s.pool)
|
||||
seen := make(map[[16]byte]struct{})
|
||||
changed := make([]pgtype.UUID, 0)
|
||||
var stats Stats
|
||||
for _, path := range paths {
|
||||
if ctx.Err() != nil {
|
||||
return changed, ctx.Err()
|
||||
}
|
||||
if !audioExtensions[strings.ToLower(filepath.Ext(path))] {
|
||||
continue
|
||||
}
|
||||
albumID, didChange, err := s.scanFile(ctx, q, path, &stats)
|
||||
if err != nil {
|
||||
s.logger.Warn("library watch: scan file error", "path", path, "err", err)
|
||||
continue
|
||||
}
|
||||
if didChange && albumID.Valid {
|
||||
if _, ok := seen[albumID.Bytes]; !ok {
|
||||
seen[albumID.Bytes] = struct{}{}
|
||||
changed = append(changed, albumID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if stats.Added > 0 || stats.Updated > 0 {
|
||||
s.logger.Info("library watch: scan batch",
|
||||
"added", stats.Added,
|
||||
"updated", stats.Updated,
|
||||
"skipped", stats.Skipped,
|
||||
"errored", stats.Errored,
|
||||
)
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func (s *Scanner) resolveArtist(ctx context.Context, q *dbq.Queries, name, mbid string) (dbq.Artist, error) {
|
||||
|
||||
@@ -121,6 +121,45 @@ func TestScanner_Integration(t *testing.T) {
|
||||
if stats2.Added != 0 || stats2.Updated != 0 || stats2.Skipped != 4 {
|
||||
t.Errorf("incremental scan stats = %+v, want Skipped=4", stats2)
|
||||
}
|
||||
|
||||
// ScanFiles (watcher-driven targeted scan): a newly-added file is picked
|
||||
// up by path without walking the whole tree, and its album ID is returned
|
||||
// so the watcher can enrich just that album. A non-audio path in the batch
|
||||
// is silently ignored.
|
||||
newFile := filepath.Join(root, "artistX/albumA/03.mp3")
|
||||
writeTestMP3(t, newFile, map[string]string{
|
||||
"TIT2": "Track Three", "TPE1": "Artist X", "TALB": "Album A", "TRCK": "3", "TYER": "2020",
|
||||
})
|
||||
notAudio := filepath.Join(root, "artistX/albumA/cover.txt")
|
||||
if err := os.WriteFile(notAudio, []byte("nope"), 0o644); err != nil {
|
||||
t.Fatalf("write non-audio: %v", err)
|
||||
}
|
||||
changed, err := scanner.ScanFiles(ctx, []string{newFile, notAudio})
|
||||
if err != nil {
|
||||
t.Fatalf("ScanFiles: %v", err)
|
||||
}
|
||||
if len(changed) != 1 || !changed[0].Valid {
|
||||
t.Fatalf("ScanFiles changed albums = %+v, want exactly 1 valid", changed)
|
||||
}
|
||||
|
||||
// The targeted scan upserts only the named file; the rest of the library
|
||||
// is untouched (no full walk).
|
||||
if _, err := q.GetTrackByPath(ctx, newFile); err != nil {
|
||||
t.Fatalf("GetTrackByPath after ScanFiles: %v", err)
|
||||
}
|
||||
|
||||
// Re-running ScanFiles on the now-unchanged file (duration probed) skips it,
|
||||
// so no album is reported as changed.
|
||||
if _, err := pool.Exec(ctx, "UPDATE tracks SET duration_ms = 1000 WHERE duration_ms = 0"); err != nil {
|
||||
t.Fatalf("seed durations (2): %v", err)
|
||||
}
|
||||
changed2, err := scanner.ScanFiles(ctx, []string{newFile})
|
||||
if err != nil {
|
||||
t.Fatalf("ScanFiles (rescan): %v", err)
|
||||
}
|
||||
if len(changed2) != 0 {
|
||||
t.Errorf("ScanFiles on unchanged file changed = %+v, want none", changed2)
|
||||
}
|
||||
}
|
||||
|
||||
// writeTestMP3 emits a file with only an ID3v2.3 tag payload plus a few
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// ScheduleConfig represents the operator's recurring scan schedule.
|
||||
// Loaded from the scan_schedule singleton row on boot and on Refresh.
|
||||
type ScheduleConfig struct {
|
||||
Mode string // "off" | "interval" | "daily" | "weekly"
|
||||
IntervalHours int
|
||||
TimeOfDay string // "HH:MM" (24h)
|
||||
WeeklyDay int // 1..7 (Mon..Sun, ISO 8601)
|
||||
}
|
||||
|
||||
// NextFire returns the next time the scan should fire given a reference
|
||||
// "now". Returns zero time when mode == "off" or config is invalid.
|
||||
//
|
||||
// DST semantics: "daily 02:30" on a spring-forward day will fire at the
|
||||
// computed time using the local zone's rules (Go time.Date silently
|
||||
// normalizes a non-existent local time). Single-operator scale; documented
|
||||
// as known and acceptable.
|
||||
func (c ScheduleConfig) NextFire(now time.Time) time.Time {
|
||||
switch c.Mode {
|
||||
case "off":
|
||||
return time.Time{}
|
||||
case "interval":
|
||||
if c.IntervalHours <= 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return now.Add(time.Duration(c.IntervalHours) * time.Hour)
|
||||
case "daily":
|
||||
h, m, ok := parseHHMM(c.TimeOfDay)
|
||||
if !ok {
|
||||
return time.Time{}
|
||||
}
|
||||
next := time.Date(now.Year(), now.Month(), now.Day(), h, m, 0, 0, now.Location())
|
||||
if !next.After(now) {
|
||||
next = next.AddDate(0, 0, 1)
|
||||
}
|
||||
return next
|
||||
case "weekly":
|
||||
h, m, ok := parseHHMM(c.TimeOfDay)
|
||||
if !ok || c.WeeklyDay < 1 || c.WeeklyDay > 7 {
|
||||
return time.Time{}
|
||||
}
|
||||
// Map ISO weekday (1=Mon..7=Sun) to Go's time.Weekday (0=Sun..6=Sat).
|
||||
targetGoDow := c.WeeklyDay % 7
|
||||
nowGoDow := int(now.Weekday())
|
||||
daysAhead := (targetGoDow - nowGoDow + 7) % 7
|
||||
next := time.Date(now.Year(), now.Month(), now.Day(), h, m, 0, 0, now.Location())
|
||||
next = next.AddDate(0, 0, daysAhead)
|
||||
if !next.After(now) {
|
||||
next = next.AddDate(0, 0, 7)
|
||||
}
|
||||
return next
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func parseHHMM(s string) (h, m int, ok bool) {
|
||||
if len(s) != 5 || s[2] != ':' {
|
||||
return 0, 0, false
|
||||
}
|
||||
for i, c := range s {
|
||||
if i == 2 {
|
||||
continue
|
||||
}
|
||||
if c < '0' || c > '9' {
|
||||
return 0, 0, false
|
||||
}
|
||||
}
|
||||
h = int(s[0]-'0')*10 + int(s[1]-'0')
|
||||
m = int(s[3]-'0')*10 + int(s[4]-'0')
|
||||
if h < 0 || h > 23 || m < 0 || m > 59 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return h, m, true
|
||||
}
|
||||
|
||||
// Scheduler runs in a goroutine and triggers RunScan on the operator's
|
||||
// configured cadence via library.TryStartScan (which handles in-flight
|
||||
// conflicts and stale-row reaping).
|
||||
type Scheduler struct {
|
||||
pool *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
scanner *Scanner
|
||||
enricher *coverart.Enricher
|
||||
scanCfg RunScanConfig
|
||||
|
||||
mu sync.RWMutex
|
||||
cfg ScheduleConfig
|
||||
nextFire time.Time
|
||||
|
||||
refresh chan struct{}
|
||||
stop chan struct{}
|
||||
}
|
||||
|
||||
func NewScheduler(pool *pgxpool.Pool, logger *slog.Logger,
|
||||
scanner *Scanner, enricher *coverart.Enricher,
|
||||
scanCfg RunScanConfig,
|
||||
) *Scheduler {
|
||||
return &Scheduler{
|
||||
pool: pool, logger: logger,
|
||||
scanner: scanner, enricher: enricher, scanCfg: scanCfg,
|
||||
refresh: make(chan struct{}, 1),
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the scheduler's loop goroutine. The loop runs until
|
||||
// the passed ctx is cancelled or Stop is called.
|
||||
func (s *Scheduler) Start(ctx context.Context) {
|
||||
go s.loop(ctx)
|
||||
}
|
||||
|
||||
// Stop signals the loop to exit. Not idempotent — call only once
|
||||
// (typically at server shutdown).
|
||||
func (s *Scheduler) Stop() { close(s.stop) }
|
||||
|
||||
// Refresh signals the loop to reload config and recompute next-fire.
|
||||
// Non-blocking: additional Refresh calls coalesce on the buffered channel.
|
||||
func (s *Scheduler) Refresh() {
|
||||
select {
|
||||
case s.refresh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// NextFire returns the cached next-fire time. Zero when mode=off.
|
||||
func (s *Scheduler) NextFire() time.Time {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.nextFire
|
||||
}
|
||||
|
||||
func (s *Scheduler) setState(cfg ScheduleConfig, next time.Time) {
|
||||
s.mu.Lock()
|
||||
s.cfg = cfg
|
||||
s.nextFire = next
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Scheduler) loadConfig(ctx context.Context) (ScheduleConfig, error) {
|
||||
q := dbq.New(s.pool)
|
||||
row, err := q.GetScanSchedule(ctx)
|
||||
if err != nil {
|
||||
return ScheduleConfig{}, err
|
||||
}
|
||||
cfg := ScheduleConfig{Mode: row.Mode}
|
||||
if row.IntervalHours != nil {
|
||||
cfg.IntervalHours = int(*row.IntervalHours)
|
||||
}
|
||||
if row.TimeOfDay != nil {
|
||||
cfg.TimeOfDay = *row.TimeOfDay
|
||||
}
|
||||
if row.WeeklyDay != nil {
|
||||
cfg.WeeklyDay = int(*row.WeeklyDay)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) loop(ctx context.Context) {
|
||||
for {
|
||||
cfg, err := s.loadConfig(ctx)
|
||||
if err != nil {
|
||||
s.logger.Error("scheduler: load config failed", "err", err)
|
||||
retry := time.NewTimer(1 * time.Minute)
|
||||
select {
|
||||
case <-retry.C:
|
||||
case <-s.refresh:
|
||||
retry.Stop()
|
||||
case <-s.stop:
|
||||
retry.Stop()
|
||||
return
|
||||
case <-ctx.Done():
|
||||
retry.Stop()
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
next := cfg.NextFire(time.Now())
|
||||
s.setState(cfg, next)
|
||||
|
||||
if next.IsZero() {
|
||||
s.logger.Info("scheduler: mode=off, waiting for config change")
|
||||
select {
|
||||
case <-s.refresh:
|
||||
continue
|
||||
case <-s.stop:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
wait := time.Until(next)
|
||||
s.logger.Info("scheduler: next scan scheduled", "at", next, "in", wait)
|
||||
timer := time.NewTimer(wait)
|
||||
select {
|
||||
case <-timer.C:
|
||||
s.tryFire(ctx)
|
||||
case <-s.refresh:
|
||||
timer.Stop()
|
||||
case <-s.stop:
|
||||
timer.Stop()
|
||||
return
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) tryFire(ctx context.Context) {
|
||||
started, existing, err := TryStartScan(
|
||||
ctx, s.pool, s.scanner, s.enricher,
|
||||
s.logger.With("source", "scheduler"), s.scanCfg,
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Error("scheduler: try start failed", "err", err)
|
||||
return
|
||||
}
|
||||
if !started && existing != nil {
|
||||
s.logger.Info("scheduler: scan skipped — prior still in flight",
|
||||
"in_flight_id", existing.ID,
|
||||
"in_flight_started_at", existing.StartedAt.Time)
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNextFire_Off(t *testing.T) {
|
||||
c := ScheduleConfig{Mode: "off"}
|
||||
if got := c.NextFire(time.Now()); !got.IsZero() {
|
||||
t.Errorf("NextFire = %v, want zero time", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextFire_Interval(t *testing.T) {
|
||||
c := ScheduleConfig{Mode: "interval", IntervalHours: 6}
|
||||
now := time.Date(2026, 5, 6, 14, 0, 0, 0, time.UTC)
|
||||
want := time.Date(2026, 5, 6, 20, 0, 0, 0, time.UTC)
|
||||
if got := c.NextFire(now); !got.Equal(want) {
|
||||
t.Errorf("NextFire = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextFire_Daily_Tomorrow(t *testing.T) {
|
||||
c := ScheduleConfig{Mode: "daily", TimeOfDay: "03:00"}
|
||||
now := time.Date(2026, 5, 6, 14, 0, 0, 0, time.UTC)
|
||||
want := time.Date(2026, 5, 7, 3, 0, 0, 0, time.UTC)
|
||||
if got := c.NextFire(now); !got.Equal(want) {
|
||||
t.Errorf("NextFire = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextFire_Daily_Today(t *testing.T) {
|
||||
c := ScheduleConfig{Mode: "daily", TimeOfDay: "03:00"}
|
||||
now := time.Date(2026, 5, 6, 2, 0, 0, 0, time.UTC)
|
||||
want := time.Date(2026, 5, 6, 3, 0, 0, 0, time.UTC)
|
||||
if got := c.NextFire(now); !got.Equal(want) {
|
||||
t.Errorf("NextFire = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextFire_Weekly_UpcomingDay(t *testing.T) {
|
||||
// Tuesday 2pm, schedule = Sunday (ISO 7) 03:00 → upcoming Sunday at 03:00.
|
||||
c := ScheduleConfig{Mode: "weekly", WeeklyDay: 7, TimeOfDay: "03:00"}
|
||||
now := time.Date(2026, 5, 5, 14, 0, 0, 0, time.UTC) // 2026-05-05 = Tuesday
|
||||
want := time.Date(2026, 5, 10, 3, 0, 0, 0, time.UTC) // 2026-05-10 = Sunday
|
||||
if got := c.NextFire(now); !got.Equal(want) {
|
||||
t.Errorf("NextFire = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextFire_Weekly_TodayBeforeTime(t *testing.T) {
|
||||
c := ScheduleConfig{Mode: "weekly", WeeklyDay: 2, TimeOfDay: "03:00"} // ISO 2 = Tuesday
|
||||
now := time.Date(2026, 5, 5, 2, 0, 0, 0, time.UTC)
|
||||
want := time.Date(2026, 5, 5, 3, 0, 0, 0, time.UTC)
|
||||
if got := c.NextFire(now); !got.Equal(want) {
|
||||
t.Errorf("NextFire = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextFire_Weekly_TodayAfterTime(t *testing.T) {
|
||||
c := ScheduleConfig{Mode: "weekly", WeeklyDay: 2, TimeOfDay: "03:00"}
|
||||
now := time.Date(2026, 5, 5, 14, 0, 0, 0, time.UTC)
|
||||
want := time.Date(2026, 5, 12, 3, 0, 0, 0, time.UTC)
|
||||
if got := c.NextFire(now); !got.Equal(want) {
|
||||
t.Errorf("NextFire = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextFire_InvalidConfigs(t *testing.T) {
|
||||
cases := []ScheduleConfig{
|
||||
{Mode: "interval", IntervalHours: 0},
|
||||
{Mode: "interval", IntervalHours: -1},
|
||||
{Mode: "daily", TimeOfDay: "bad"},
|
||||
{Mode: "daily", TimeOfDay: "25:00"},
|
||||
{Mode: "weekly", TimeOfDay: "03:00", WeeklyDay: 0},
|
||||
{Mode: "weekly", TimeOfDay: "03:00", WeeklyDay: 8},
|
||||
{Mode: "unknown"},
|
||||
}
|
||||
now := time.Now()
|
||||
for _, c := range cases {
|
||||
if got := c.NextFire(now); !got.IsZero() {
|
||||
t.Errorf("NextFire(%+v) = %v, want zero", c, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHHMM(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
h, m int
|
||||
wantOK bool
|
||||
}{
|
||||
{"03:00", 3, 0, true},
|
||||
{"23:59", 23, 59, true},
|
||||
{"00:00", 0, 0, true},
|
||||
{"24:00", 0, 0, false},
|
||||
{"23:60", 0, 0, false},
|
||||
{"3:00", 0, 0, false},
|
||||
{"03-00", 0, 0, false},
|
||||
{"ab:cd", 0, 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
h, m, ok := parseHHMM(c.in)
|
||||
if ok != c.wantOK || (ok && (h != c.h || m != c.m)) {
|
||||
t.Errorf("parseHHMM(%q) = (%d, %d, %v), want (%d, %d, %v)",
|
||||
c.in, h, m, ok, c.h, c.m, c.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduler_Refresh_NonBlocking(t *testing.T) {
|
||||
s := &Scheduler{
|
||||
refresh: make(chan struct{}, 1),
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
s.Refresh()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
s.Refresh()
|
||||
s.Refresh()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Errorf("Refresh blocked when channel was full")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||
)
|
||||
|
||||
// watcherDebounce is the quiet period after the last filesystem event before
|
||||
// the watcher processes the pending batch. Copying an album emits a burst of
|
||||
// write events; waiting for quiet coalesces the whole album into one scan.
|
||||
const watcherDebounce = 3 * time.Second
|
||||
|
||||
// watchAction is the pure decision for a single filesystem event.
|
||||
type watchAction int
|
||||
|
||||
const (
|
||||
watchIgnore watchAction = iota // not a relevant change
|
||||
watchEnqueueFile // an audio file was created/written
|
||||
watchScanDir // a directory appeared; scan it recursively
|
||||
)
|
||||
|
||||
// classifyEvent decides what to do with a filesystem event, separated from I/O
|
||||
// so it is unit-testable. Only Create/Write matter (removals are not pruned
|
||||
// here -- see the watcher's design non-goals). A newly-created directory is
|
||||
// scanned recursively because fsnotify does not emit events for files that
|
||||
// already existed inside a directory that was moved/copied in wholesale.
|
||||
func classifyEvent(op fsnotify.Op, isDir, isAudio bool) watchAction {
|
||||
if op&(fsnotify.Create|fsnotify.Write) == 0 {
|
||||
return watchIgnore
|
||||
}
|
||||
if isDir {
|
||||
if op&fsnotify.Create != 0 {
|
||||
return watchScanDir
|
||||
}
|
||||
return watchIgnore
|
||||
}
|
||||
if isAudio {
|
||||
return watchEnqueueFile
|
||||
}
|
||||
return watchIgnore
|
||||
}
|
||||
|
||||
// Watcher delivers near-instant library updates by watching the scan roots
|
||||
// with fsnotify and scanning just the changed files (plus inline cover-art
|
||||
// enrichment for the affected albums), instead of waiting for a full scan.
|
||||
// It is recursive: a watch is added per directory, and new directories get a
|
||||
// watch as they appear. inotify watch-limit exhaustion on huge libraries is
|
||||
// logged, not fatal -- the periodic safety-net scan covers anything missed.
|
||||
type Watcher struct {
|
||||
scanner *Scanner
|
||||
enricher *coverart.Enricher
|
||||
logger *slog.Logger
|
||||
roots []string
|
||||
debounce time.Duration
|
||||
}
|
||||
|
||||
func NewWatcher(
|
||||
scanner *Scanner, enricher *coverart.Enricher, logger *slog.Logger, roots []string,
|
||||
) *Watcher {
|
||||
return &Watcher{
|
||||
scanner: scanner,
|
||||
enricher: enricher,
|
||||
logger: logger,
|
||||
roots: roots,
|
||||
debounce: watcherDebounce,
|
||||
}
|
||||
}
|
||||
|
||||
// Run blocks until ctx is cancelled. Returns a non-nil error only when the
|
||||
// fsnotify watcher cannot be created at all; per-path watch failures and
|
||||
// fsnotify errors are logged and tolerated.
|
||||
func (w *Watcher) Run(ctx context.Context) error {
|
||||
if len(w.roots) == 0 {
|
||||
w.logger.Info("library watcher: no scan paths configured, watcher disabled")
|
||||
return nil
|
||||
}
|
||||
fsw, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
w.logger.Error("library watcher: create failed", "err", err)
|
||||
return err
|
||||
}
|
||||
defer func() { _ = fsw.Close() }()
|
||||
|
||||
for _, root := range w.roots {
|
||||
w.addRecursive(fsw, root)
|
||||
}
|
||||
w.logger.Info("library watcher: started", "roots", len(w.roots))
|
||||
|
||||
batches := make(chan []string, batchQueueDepth)
|
||||
go w.processLoop(ctx, batches)
|
||||
w.eventLoop(ctx, fsw, batches)
|
||||
return nil
|
||||
}
|
||||
|
||||
// eventLoop reads fsnotify events, coalesces them via a debounce timer, and
|
||||
// hands drained batches to the process loop. Returns on ctx cancellation.
|
||||
func (w *Watcher) eventLoop(ctx context.Context, fsw *fsnotify.Watcher, batches chan<- []string) {
|
||||
pending := make(map[string]struct{})
|
||||
timer := time.NewTimer(w.debounce)
|
||||
timer.Stop()
|
||||
var timerC <-chan time.Time
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
w.logger.Info("library watcher: stopped")
|
||||
return
|
||||
case event, ok := <-fsw.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if w.handleEvent(fsw, event, pending) {
|
||||
timer.Reset(w.debounce)
|
||||
timerC = timer.C
|
||||
}
|
||||
case err, ok := <-fsw.Errors:
|
||||
if ok {
|
||||
w.logger.Warn("library watcher: fsnotify error", "err", err)
|
||||
}
|
||||
case <-timerC:
|
||||
timerC = nil
|
||||
paths := drainPending(pending)
|
||||
if len(paths) == 0 {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case batches <- paths:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleEvent applies one event to the pending set and reports whether the
|
||||
// debounce timer should be (re)armed.
|
||||
func (w *Watcher) handleEvent(
|
||||
fsw *fsnotify.Watcher, event fsnotify.Event, pending map[string]struct{},
|
||||
) bool {
|
||||
info, err := os.Stat(event.Name)
|
||||
isDir := err == nil && info.IsDir()
|
||||
isAudio := audioExtensions[strings.ToLower(filepath.Ext(event.Name))]
|
||||
switch classifyEvent(event.Op, isDir, isAudio) {
|
||||
case watchScanDir:
|
||||
w.addRecursive(fsw, event.Name)
|
||||
return w.enqueueDirAudio(event.Name, pending)
|
||||
case watchEnqueueFile:
|
||||
pending[event.Name] = struct{}{}
|
||||
return true
|
||||
case watchIgnore:
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// processLoop serializes batch processing so a slow remote enrichment never
|
||||
// blocks the fsnotify event loop.
|
||||
func (w *Watcher) processLoop(ctx context.Context, batches <-chan []string) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case paths, ok := <-batches:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
w.process(ctx, paths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// process scans the changed files then enriches just the affected albums.
|
||||
func (w *Watcher) process(ctx context.Context, paths []string) {
|
||||
changed, err := w.scanner.ScanFiles(ctx, paths)
|
||||
if err != nil {
|
||||
w.logger.Warn("library watcher: scan batch failed", "err", err)
|
||||
}
|
||||
if w.enricher != nil {
|
||||
for _, albumID := range changed {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if eerr := w.enricher.EnrichAlbum(ctx, albumID); eerr != nil {
|
||||
w.logger.Warn("library watcher: enrich album failed", "err", eerr)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(changed) > 0 {
|
||||
w.logger.Info("library watcher: applied", "files", len(paths), "albums", len(changed))
|
||||
}
|
||||
}
|
||||
|
||||
// addRecursive adds an fsnotify watch for root and every subdirectory under it.
|
||||
// Per-directory failures (inotify limits, permissions) are logged and skipped.
|
||||
func (w *Watcher) addRecursive(fsw *fsnotify.Watcher, root string) {
|
||||
_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || !d.IsDir() {
|
||||
return nil //nolint:nilerr // tolerate unreadable subtrees
|
||||
}
|
||||
if aerr := fsw.Add(path); aerr != nil {
|
||||
w.logger.Warn("library watcher: add watch failed", "path", path, "err", aerr)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// enqueueDirAudio enqueues every audio file under dir (used when a directory
|
||||
// appears wholesale, since fsnotify emits no events for its existing children).
|
||||
func (w *Watcher) enqueueDirAudio(dir string, pending map[string]struct{}) bool {
|
||||
added := false
|
||||
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil //nolint:nilerr // tolerate unreadable subtrees
|
||||
}
|
||||
if audioExtensions[strings.ToLower(filepath.Ext(path))] {
|
||||
pending[path] = struct{}{}
|
||||
added = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return added
|
||||
}
|
||||
|
||||
// drainPending returns the pending paths and clears the set.
|
||||
func drainPending(pending map[string]struct{}) []string {
|
||||
if len(pending) == 0 {
|
||||
return nil
|
||||
}
|
||||
paths := make([]string, 0, len(pending))
|
||||
for p := range pending {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
clear(pending)
|
||||
return paths
|
||||
}
|
||||
|
||||
// batchQueueDepth bounds how many debounced batches can be in flight before the
|
||||
// event loop would block; generous since each batch is processed quickly.
|
||||
const batchQueueDepth = 16
|
||||
@@ -0,0 +1,59 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
)
|
||||
|
||||
func TestClassifyEvent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
op fsnotify.Op
|
||||
isDir bool
|
||||
isAudio bool
|
||||
want watchAction
|
||||
}{
|
||||
{"create audio file enqueues", fsnotify.Create, false, true, watchEnqueueFile},
|
||||
{"write audio file enqueues", fsnotify.Write, false, true, watchEnqueueFile},
|
||||
{"create non-audio file ignored", fsnotify.Create, false, false, watchIgnore},
|
||||
{"create directory scans it", fsnotify.Create, true, false, watchScanDir},
|
||||
{"write on directory ignored", fsnotify.Write, true, false, watchIgnore},
|
||||
{"remove audio file ignored (no pruning)", fsnotify.Remove, false, true, watchIgnore},
|
||||
{"rename audio file ignored", fsnotify.Rename, false, true, watchIgnore},
|
||||
{"chmod audio file ignored", fsnotify.Chmod, false, true, watchIgnore},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := classifyEvent(tt.op, tt.isDir, tt.isAudio); got != tt.want {
|
||||
t.Fatalf("classifyEvent(%v, dir=%v, audio=%v) = %v, want %v",
|
||||
tt.op, tt.isDir, tt.isAudio, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrainPending(t *testing.T) {
|
||||
t.Run("empty set returns nil", func(t *testing.T) {
|
||||
if got := drainPending(map[string]struct{}{}); got != nil {
|
||||
t.Fatalf("drainPending(empty) = %v, want nil", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns paths and clears the set", func(t *testing.T) {
|
||||
pending := map[string]struct{}{
|
||||
"/music/a.mp3": {},
|
||||
"/music/b.flac": {},
|
||||
}
|
||||
got := drainPending(pending)
|
||||
sort.Strings(got)
|
||||
want := []string{"/music/a.mp3", "/music/b.flac"}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("drainPending = %v, want %v", got, want)
|
||||
}
|
||||
if len(pending) != 0 {
|
||||
t.Fatalf("pending not cleared: %v", pending)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -78,9 +78,8 @@ type Server struct {
|
||||
CoverSettings *coverart.SettingsService
|
||||
LibraryScanner *library.Scanner
|
||||
ScanCfg library.RunScanConfig
|
||||
Scheduler *library.Scheduler
|
||||
// Bus is the live-event bus shared with background workers (the
|
||||
// lidarr reconciler, scan scheduler) constructed in cmd/minstrel/main.go.
|
||||
// lidarr reconciler, scan workers) constructed in cmd/minstrel/main.go.
|
||||
// When nil, Router() constructs a local fallback (test contexts).
|
||||
Bus *eventbus.Bus
|
||||
// PlaylistScheduler fires per-user daily system-playlist builds at
|
||||
@@ -97,8 +96,8 @@ type Server struct {
|
||||
StreamSecret []byte
|
||||
}
|
||||
|
||||
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig, dataDir string, brandingCfg config.BrandingConfig, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, libraryScanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler) *Server {
|
||||
return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg, RecommendationCfg: recCfg, DataDir: dataDir, BrandingCfg: brandingCfg, CoverEnricher: coverEnricher, CoverSettings: coverSettings, LibraryScanner: libraryScanner, ScanCfg: scanCfg, Scheduler: scheduler}
|
||||
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig, dataDir string, brandingCfg config.BrandingConfig, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, libraryScanner *library.Scanner, scanCfg library.RunScanConfig) *Server {
|
||||
return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg, RecommendationCfg: recCfg, DataDir: dataDir, BrandingCfg: brandingCfg, CoverEnricher: coverEnricher, CoverSettings: coverSettings, LibraryScanner: libraryScanner, ScanCfg: scanCfg}
|
||||
}
|
||||
|
||||
func (s *Server) Router() http.Handler {
|
||||
@@ -144,7 +143,7 @@ func (s *Server) Router() http.Handler {
|
||||
if bus == nil {
|
||||
bus = eventbus.New()
|
||||
}
|
||||
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.Scheduler, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret)
|
||||
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret)
|
||||
// /api/admin/scan is the only admin route owned by the server package
|
||||
// (it needs the Scanner). Register it as a single inline-middleware
|
||||
// route — using r.Route("/api/admin", ...) here would create a second
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
)
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{}, nil)
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
@@ -73,7 +73,7 @@ func TestHealthz_IncludesMinClientVersion(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouter_ServesSPAAtRoot(t *testing.T) {
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{}, nil)
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
@@ -92,7 +92,7 @@ func TestRouter_ServesSPAAtRoot(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) {
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{}, nil)
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
@@ -111,7 +111,7 @@ func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) {
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{}, nil)
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
@@ -130,7 +130,7 @@ func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouter_RestPathNotSwallowedBySPA(t *testing.T) {
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{}, nil)
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
@@ -206,7 +206,7 @@ func TestRouter_AdminSubtreeNotShadowed(t *testing.T) {
|
||||
}
|
||||
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), pool,
|
||||
stubScanner{}, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{}, nil)
|
||||
stubScanner{}, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended",
|
||||
":semanticCommits"
|
||||
],
|
||||
"baseBranches": ["dev"],
|
||||
"timezone": "America/New_York",
|
||||
"schedule": ["every weekend"],
|
||||
"prHourlyLimit": 2,
|
||||
"prConcurrentLimit": 8,
|
||||
"ignorePaths": [
|
||||
"**/node_modules/**",
|
||||
"**/vendor/**",
|
||||
"flutter_client/**"
|
||||
],
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true,
|
||||
"schedule": ["before 5am on the first day of the month"]
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Auto-merge patch/minor/digest/pin bumps once CI is green",
|
||||
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"description": "Hold all major bumps for manual approval via the dependency dashboard",
|
||||
"matchUpdateTypes": ["major"],
|
||||
"automerge": false,
|
||||
"dependencyDashboardApproval": true,
|
||||
"addLabels": ["deps", "deps:major"]
|
||||
},
|
||||
{
|
||||
"description": "Group Go module updates into one PR",
|
||||
"matchManagers": ["gomod"],
|
||||
"groupName": "go modules"
|
||||
},
|
||||
{
|
||||
"description": "Group CI workflow action bumps",
|
||||
"matchManagers": ["github-actions"],
|
||||
"groupName": "ci actions"
|
||||
},
|
||||
{
|
||||
"description": "Group Docker base-image bumps (Dockerfile + compose)",
|
||||
"matchManagers": ["dockerfile", "docker-compose"],
|
||||
"groupName": "docker images"
|
||||
},
|
||||
{
|
||||
"description": "Group the Android Gradle/Kotlin toolchain",
|
||||
"matchManagers": ["gradle", "gradle-wrapper"],
|
||||
"groupName": "android gradle"
|
||||
},
|
||||
{
|
||||
"description": "Group web npm non-major bumps",
|
||||
"matchManagers": ["npm"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"groupName": "web npm (non-major)"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getScanSchedule, updateScanSchedule, type ScanSchedule } from './admin';
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn() }
|
||||
}));
|
||||
|
||||
import { api } from './client';
|
||||
|
||||
describe('admin scan-schedule API', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('getScanSchedule GETs the correct path', async () => {
|
||||
const sample: ScanSchedule = {
|
||||
mode: 'daily',
|
||||
interval_hours: null,
|
||||
time_of_day: '03:00',
|
||||
weekly_day: null,
|
||||
next_scheduled_at: '2026-05-07T03:00:00Z'
|
||||
};
|
||||
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
|
||||
const got = await getScanSchedule();
|
||||
expect(api.get).toHaveBeenCalledWith('/api/admin/scan/schedule');
|
||||
expect(got).toEqual(sample);
|
||||
});
|
||||
|
||||
it('updateScanSchedule PATCHes with the right body', async () => {
|
||||
const sample: ScanSchedule = {
|
||||
mode: 'interval',
|
||||
interval_hours: 6,
|
||||
time_of_day: null,
|
||||
weekly_day: null,
|
||||
next_scheduled_at: '2026-05-06T20:00:00Z'
|
||||
};
|
||||
(api.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
|
||||
const got = await updateScanSchedule({ mode: 'interval', interval_hours: 6 });
|
||||
expect(api.patch).toHaveBeenCalledWith('/api/admin/scan/schedule', {
|
||||
mode: 'interval',
|
||||
interval_hours: 6
|
||||
});
|
||||
expect(got.mode).toBe('interval');
|
||||
expect(got.interval_hours).toBe(6);
|
||||
});
|
||||
|
||||
it('updateScanSchedule with mode=off sends just mode', async () => {
|
||||
const sample: ScanSchedule = {
|
||||
mode: 'off',
|
||||
interval_hours: null,
|
||||
time_of_day: null,
|
||||
weekly_day: null,
|
||||
next_scheduled_at: null
|
||||
};
|
||||
(api.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
|
||||
const got = await updateScanSchedule({ mode: 'off' });
|
||||
expect(api.patch).toHaveBeenCalledWith('/api/admin/scan/schedule', { mode: 'off' });
|
||||
expect(got.next_scheduled_at).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -383,43 +383,6 @@ export function createCoverProvidersQuery() {
|
||||
});
|
||||
}
|
||||
|
||||
// Scan schedule -----------------------------------------------------------
|
||||
|
||||
export type ScanScheduleMode = 'off' | 'interval' | 'daily' | 'weekly';
|
||||
|
||||
export type ScanSchedule = {
|
||||
mode: ScanScheduleMode;
|
||||
interval_hours: number | null;
|
||||
time_of_day: string | null; // 'HH:MM'
|
||||
weekly_day: number | null; // 1..7 (Mon..Sun, ISO 8601)
|
||||
next_scheduled_at: string | null; // RFC3339 ISO timestamp; null when mode='off'
|
||||
};
|
||||
|
||||
export type ScanScheduleUpdate = {
|
||||
mode: ScanScheduleMode;
|
||||
interval_hours?: number | null;
|
||||
time_of_day?: string | null;
|
||||
weekly_day?: number | null;
|
||||
};
|
||||
|
||||
export async function getScanSchedule(): Promise<ScanSchedule> {
|
||||
return api.get<ScanSchedule>('/api/admin/scan/schedule');
|
||||
}
|
||||
|
||||
export async function updateScanSchedule(patch: ScanScheduleUpdate): Promise<ScanSchedule> {
|
||||
return api.patch<ScanSchedule>('/api/admin/scan/schedule', patch);
|
||||
}
|
||||
|
||||
// Settings rarely change in operator time. 60s staleTime; refetches
|
||||
// on window focus + after any PATCH invalidation — no polling.
|
||||
export function createScanScheduleQuery() {
|
||||
return createQuery({
|
||||
queryKey: qk.scanSchedule(),
|
||||
queryFn: getScanSchedule,
|
||||
staleTime: 60_000
|
||||
});
|
||||
}
|
||||
|
||||
// Admin user-management ------------------------------------------------------
|
||||
|
||||
export type AdminUser = {
|
||||
|
||||
@@ -46,7 +46,6 @@ export const qk = {
|
||||
adminPlaybackErrors: (resolved?: boolean) =>
|
||||
['adminPlaybackErrors', { resolved: resolved ?? false }] as const,
|
||||
scanStatus: () => ['scanStatus'] as const,
|
||||
scanSchedule: () => ['scanSchedule'] as const,
|
||||
coverage: () => ['coverage'] as const,
|
||||
coverProviders: () => ['coverProviders'] as const,
|
||||
adminUsers: () => ['adminUsers'] as const,
|
||||
@@ -57,6 +56,8 @@ export const qk = {
|
||||
home: () => ['home'] as const,
|
||||
albumsAlpha: () => ['albumsAlpha'] as const,
|
||||
artistTracks: (artistId: string) => ['artistTracks', artistId] as const,
|
||||
similarArtists: (artistId: string) => ['similarArtists', artistId] as const,
|
||||
artistTopTracks: (artistId: string) => ['artistTopTracks', artistId] as const,
|
||||
playlists: (kind?: 'user' | 'system' | 'all') =>
|
||||
['playlists', { kind: kind ?? 'user' }] as const,
|
||||
playlist: (id: string) => ['playlist', id] as const,
|
||||
@@ -89,6 +90,22 @@ export function createAlbumQuery(id: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function createSimilarArtistsQuery(id: string) {
|
||||
return createQuery({
|
||||
queryKey: qk.similarArtists(id),
|
||||
queryFn: () => api.get<ArtistRef[]>(`/api/artists/${id}/similar`),
|
||||
enabled: id.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function createArtistTopTracksQuery(id: string) {
|
||||
return createQuery({
|
||||
queryKey: qk.artistTopTracks(id),
|
||||
queryFn: () => api.get<TrackRef[]>(`/api/artists/${id}/top-tracks`),
|
||||
enabled: id.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function createSearchQuery(q: string) {
|
||||
return createQuery({
|
||||
queryKey: qk.search(q),
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
createAdminQuarantineQuery,
|
||||
createScanStatusQuery,
|
||||
createCoverageQuery,
|
||||
createScanScheduleQuery,
|
||||
updateScanSchedule,
|
||||
approveRequest,
|
||||
rejectRequest,
|
||||
resolveQuarantine,
|
||||
@@ -19,9 +17,7 @@
|
||||
deleteQuarantineViaLidarr,
|
||||
refetchMissingCovers,
|
||||
triggerScan,
|
||||
researchMissingArt,
|
||||
type ScanSchedule,
|
||||
type ScanScheduleMode
|
||||
researchMissingArt
|
||||
} from '$lib/api/admin';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import { errCode } from '$lib/api/errors';
|
||||
@@ -211,89 +207,6 @@
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
// ---- Scan schedule ----
|
||||
const scheduleStore = $derived(createScanScheduleQuery());
|
||||
const scheduleQ = $derived($scheduleStore);
|
||||
const schedule = $derived(scheduleQ.data);
|
||||
|
||||
let scheduleLocal = $state<{
|
||||
mode: ScanScheduleMode;
|
||||
interval_hours: number;
|
||||
time_of_day: string;
|
||||
weekly_day: number;
|
||||
}>({
|
||||
mode: 'off',
|
||||
interval_hours: 6,
|
||||
time_of_day: '03:00',
|
||||
weekly_day: 7,
|
||||
});
|
||||
let scheduleSaving = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (schedule) {
|
||||
scheduleLocal = {
|
||||
mode: schedule.mode,
|
||||
interval_hours: schedule.interval_hours ?? 6,
|
||||
time_of_day: schedule.time_of_day ?? '03:00',
|
||||
weekly_day: schedule.weekly_day ?? 7,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
function scheduleHasChanges(): boolean {
|
||||
if (!schedule) return false;
|
||||
if (scheduleLocal.mode !== schedule.mode) return true;
|
||||
switch (scheduleLocal.mode) {
|
||||
case 'interval':
|
||||
return scheduleLocal.interval_hours !== (schedule.interval_hours ?? -1);
|
||||
case 'daily':
|
||||
return scheduleLocal.time_of_day !== (schedule.time_of_day ?? '');
|
||||
case 'weekly':
|
||||
return scheduleLocal.time_of_day !== (schedule.time_of_day ?? '')
|
||||
|| scheduleLocal.weekly_day !== (schedule.weekly_day ?? -1);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSaveSchedule() {
|
||||
scheduleSaving = true;
|
||||
try {
|
||||
const patch: { mode: ScanScheduleMode; interval_hours?: number; time_of_day?: string; weekly_day?: number } = {
|
||||
mode: scheduleLocal.mode,
|
||||
};
|
||||
if (scheduleLocal.mode === 'interval') patch.interval_hours = scheduleLocal.interval_hours;
|
||||
if (scheduleLocal.mode === 'daily' || scheduleLocal.mode === 'weekly') patch.time_of_day = scheduleLocal.time_of_day;
|
||||
if (scheduleLocal.mode === 'weekly') patch.weekly_day = scheduleLocal.weekly_day;
|
||||
await updateScanSchedule(patch);
|
||||
await client.invalidateQueries({ queryKey: qk.scanSchedule() });
|
||||
} catch (e) {
|
||||
pushToast(`Schedule save failed: ${errCode(e)}`, 'error');
|
||||
} finally {
|
||||
scheduleSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const target = new Date(iso);
|
||||
const now = new Date();
|
||||
const diffMs = target.getTime() - now.getTime();
|
||||
const absHours = Math.abs(diffMs) / 3_600_000;
|
||||
let rel: string;
|
||||
if (absHours < 1) {
|
||||
const mins = Math.round(Math.abs(diffMs) / 60_000);
|
||||
rel = diffMs >= 0 ? `in ${mins} min` : `${mins} min ago`;
|
||||
} else if (absHours < 48) {
|
||||
const h = Math.round(absHours);
|
||||
rel = diffMs >= 0 ? `in ${h} hour${h === 1 ? '' : 's'}` : `${h} hour${h === 1 ? '' : 's'} ago`;
|
||||
} else {
|
||||
const days = Math.round(absHours / 24);
|
||||
rel = diffMs >= 0 ? `in ${days} days` : `${days} days ago`;
|
||||
}
|
||||
const local = target.toLocaleString();
|
||||
return `${rel} (${local})`;
|
||||
}
|
||||
|
||||
// ---- Cover art bulk refetch ----
|
||||
let bulkBusy = $state(false);
|
||||
let bulkResult = $state<string | null>(null);
|
||||
@@ -517,77 +430,6 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Scan schedule -->
|
||||
<section class="rounded border border-border bg-surface p-4 space-y-3">
|
||||
<div>
|
||||
<h2 class="font-display text-lg font-medium">Scan schedule</h2>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
Run a library scan automatically on a recurring cadence. Manual triggers
|
||||
and boot scans are unaffected.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if schedule}
|
||||
<div class="grid gap-3 sm:grid-cols-[auto_1fr] items-center">
|
||||
<label class="text-sm text-text-secondary" for="schedule-mode">Mode</label>
|
||||
<select id="schedule-mode" bind:value={scheduleLocal.mode}
|
||||
class="text-sm rounded border border-border bg-background px-2 py-1">
|
||||
<option value="off">Off</option>
|
||||
<option value="interval">Every N hours</option>
|
||||
<option value="daily">Daily at time</option>
|
||||
<option value="weekly">Weekly at day + time</option>
|
||||
</select>
|
||||
|
||||
{#if scheduleLocal.mode === 'interval'}
|
||||
<label class="text-sm text-text-secondary" for="schedule-interval">Interval</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="schedule-interval" type="number" min="1" max="168"
|
||||
bind:value={scheduleLocal.interval_hours}
|
||||
class="w-20 text-sm rounded border border-border bg-background px-2 py-1" />
|
||||
<span class="text-sm text-text-muted">hours</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if scheduleLocal.mode === 'daily' || scheduleLocal.mode === 'weekly'}
|
||||
<label class="text-sm text-text-secondary" for="schedule-time">Time</label>
|
||||
<input id="schedule-time" type="time" bind:value={scheduleLocal.time_of_day}
|
||||
class="text-sm rounded border border-border bg-background px-2 py-1" />
|
||||
{/if}
|
||||
|
||||
{#if scheduleLocal.mode === 'weekly'}
|
||||
<label class="text-sm text-text-secondary" for="schedule-day">Day</label>
|
||||
<select id="schedule-day" bind:value={scheduleLocal.weekly_day}
|
||||
class="text-sm rounded border border-border bg-background px-2 py-1">
|
||||
<option value={1}>Monday</option>
|
||||
<option value={2}>Tuesday</option>
|
||||
<option value={3}>Wednesday</option>
|
||||
<option value={4}>Thursday</option>
|
||||
<option value={5}>Friday</option>
|
||||
<option value={6}>Saturday</option>
|
||||
<option value={7}>Sunday</option>
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="button" disabled={!scheduleHasChanges() || scheduleSaving}
|
||||
onclick={onSaveSchedule}
|
||||
class="rounded-md bg-action-primary px-4 py-2 text-sm font-medium text-action-fg hover:opacity-90 disabled:opacity-50">
|
||||
{scheduleSaving ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
{#if schedule.next_scheduled_at}
|
||||
<span class="text-xs text-text-muted">
|
||||
Next scan: {formatRelative(schedule.next_scheduled_at)}
|
||||
</span>
|
||||
{:else if scheduleLocal.mode === 'off'}
|
||||
<span class="text-xs text-text-muted">Schedule is off.</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-text-muted">Loading…</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Cover art bulk refetch -->
|
||||
<section class="rounded border border-border bg-surface p-4">
|
||||
<h2 class="font-display text-lg font-medium">Cover art</h2>
|
||||
|
||||
@@ -42,21 +42,7 @@ vi.mock('$lib/api/admin', async () => {
|
||||
deleteQuarantineViaLidarr: vi.fn().mockResolvedValue({}),
|
||||
triggerScan: vi.fn().mockResolvedValue({}),
|
||||
refetchMissingCovers: vi.fn().mockResolvedValue({ started: true }),
|
||||
researchMissingArt: vi.fn().mockResolvedValue({ version: 1 }),
|
||||
createScanScheduleQuery: vi.fn(() =>
|
||||
readable({
|
||||
data: {
|
||||
mode: 'off',
|
||||
interval_hours: null,
|
||||
time_of_day: null,
|
||||
weekly_day: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
isPending: false,
|
||||
isError: false
|
||||
})
|
||||
),
|
||||
updateScanSchedule: vi.fn().mockResolvedValue({})
|
||||
researchMissingArt: vi.fn().mockResolvedValue({ version: 1 })
|
||||
};
|
||||
});
|
||||
|
||||
@@ -250,14 +236,6 @@ describe('admin Overview page', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('renders the scan-schedule section with mode=off default', () => {
|
||||
setupOverview();
|
||||
render(OverviewPage);
|
||||
expect(screen.getByText('Scan schedule')).toBeInTheDocument();
|
||||
const select = screen.getByLabelText('Mode') as HTMLSelectElement;
|
||||
expect(select.value).toBe('off');
|
||||
});
|
||||
|
||||
test('renders Re-search missing art button and invokes on click', async () => {
|
||||
setupOverview();
|
||||
render(OverviewPage);
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { Play } from 'lucide-svelte';
|
||||
import { page } from '$app/state';
|
||||
import { createArtistQuery } from '$lib/api/queries';
|
||||
import {
|
||||
createArtistQuery,
|
||||
createSimilarArtistsQuery,
|
||||
createArtistTopTracksQuery
|
||||
} from '$lib/api/queries';
|
||||
import { pageTitle } from '$lib/branding';
|
||||
import AlbumCard from '$lib/components/AlbumCard.svelte';
|
||||
import ArtistCard from '$lib/components/ArtistCard.svelte';
|
||||
import CompactTrackCard from '$lib/components/CompactTrackCard.svelte';
|
||||
import HorizontalScrollRow from '$lib/components/HorizontalScrollRow.svelte';
|
||||
import LikeButton from '$lib/components/LikeButton.svelte';
|
||||
import LibrarySkeleton from '$lib/components/LibrarySkeleton.svelte';
|
||||
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
|
||||
@@ -11,13 +18,20 @@
|
||||
import { api } from '$lib/api/client';
|
||||
import { playQueue } from '$lib/player/store.svelte';
|
||||
import type { ApiError } from '$lib/api/client';
|
||||
import type { TrackRef } from '$lib/api/types';
|
||||
import type { ArtistRef, TrackRef } from '$lib/api/types';
|
||||
|
||||
const id = $derived(page.params.id ?? '');
|
||||
const queryStore = $derived(createArtistQuery(id));
|
||||
const query = $derived($queryStore);
|
||||
const showSkeleton = useDelayed(() => query.isPending);
|
||||
|
||||
// Secondary sections, lazy-loaded alongside the detail. Each hides itself
|
||||
// when empty (no similarity matches yet / user hasn't played this artist).
|
||||
const similarStore = $derived(createSimilarArtistsQuery(id));
|
||||
const similarQuery = $derived($similarStore);
|
||||
const topTracksStore = $derived(createArtistTopTracksQuery(id));
|
||||
const topTracksQuery = $derived($topTracksStore);
|
||||
|
||||
const notFound = $derived(
|
||||
query.isError && (query.error as unknown as ApiError | undefined)?.code === 'not_found'
|
||||
);
|
||||
@@ -97,5 +111,30 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if (topTracksQuery.data?.length ?? 0) > 0}
|
||||
{@const top = topTracksQuery.data ?? []}
|
||||
<section class="space-y-3">
|
||||
<HorizontalScrollRow rows={[top]} title="Top tracks" ariaLabel={`Top tracks for ${detail.name}`}>
|
||||
{#snippet item(track: TrackRef, globalIdx: number)}
|
||||
<CompactTrackCard {track} sectionTracks={top} index={globalIdx} />
|
||||
{/snippet}
|
||||
</HorizontalScrollRow>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if (similarQuery.data?.length ?? 0) > 0}
|
||||
<section class="space-y-3">
|
||||
<HorizontalScrollRow
|
||||
rows={[similarQuery.data ?? []]}
|
||||
title="Similar artists"
|
||||
ariaLabel="Similar artists"
|
||||
>
|
||||
{#snippet item(artist: ArtistRef)}
|
||||
<div class="w-36"><ArtistCard {artist} /></div>
|
||||
{/snippet}
|
||||
</HorizontalScrollRow>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { flushSync } from 'svelte';
|
||||
import { mockQuery } from '../../../test-utils/query';
|
||||
@@ -16,13 +16,19 @@ vi.mock('$app/state', () => ({
|
||||
|
||||
vi.mock('$lib/api/queries', () => ({
|
||||
qk: { artist: (id: string) => ['artist', id] },
|
||||
createArtistQuery: vi.fn()
|
||||
createArtistQuery: vi.fn(),
|
||||
createSimilarArtistsQuery: vi.fn(),
|
||||
createArtistTopTracksQuery: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/likes', () => emptyLikesMock());
|
||||
|
||||
import ArtistPage from './+page.svelte';
|
||||
import { createArtistQuery } from '$lib/api/queries';
|
||||
import {
|
||||
createArtistQuery,
|
||||
createSimilarArtistsQuery,
|
||||
createArtistTopTracksQuery
|
||||
} from '$lib/api/queries';
|
||||
|
||||
function album(id: string, title: string, year?: number): AlbumRef {
|
||||
return {
|
||||
@@ -40,6 +46,13 @@ afterEach(() => {
|
||||
state.pageParams = { id: 'abc' };
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Secondary sections default to empty so the existing album-grid assertions
|
||||
// are unaffected; individual tests can override to exercise the new strips.
|
||||
(createSimilarArtistsQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: [] }));
|
||||
(createArtistTopTracksQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: [] }));
|
||||
});
|
||||
|
||||
describe('artist detail page', () => {
|
||||
test('renders artist name, subtitle, and one AlbumCard per album', () => {
|
||||
const detail: ArtistDetail = {
|
||||
@@ -54,6 +67,28 @@ describe('artist detail page', () => {
|
||||
expect(screen.getByRole('link', { name: /Second/ })).toHaveAttribute('href', '/albums/a2');
|
||||
});
|
||||
|
||||
test('renders top-tracks panel and similar-artists strip when present', () => {
|
||||
const detail: ArtistDetail = {
|
||||
id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 1, cover_url: '',
|
||||
albums: [album('a1', 'First', 2020)]
|
||||
};
|
||||
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
|
||||
(createArtistTopTracksQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({
|
||||
data: [{
|
||||
id: 't1', title: 'Hit Song', album_id: 'a1', album_title: 'First',
|
||||
artist_id: 'abc', artist_name: 'Alice', duration_sec: 200, stream_url: '/s/t1'
|
||||
}]
|
||||
}));
|
||||
(createSimilarArtistsQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({
|
||||
data: [{ id: 'sim1', name: 'Bob', sort_name: 'Bob', album_count: 2, cover_url: '' }]
|
||||
}));
|
||||
render(ArtistPage);
|
||||
expect(screen.getByText(/top tracks/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('Hit Song')).toBeInTheDocument();
|
||||
expect(screen.getByText(/similar artists/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /Bob/ })).toHaveAttribute('href', '/artists/sim1');
|
||||
});
|
||||
|
||||
test('back link points to Library', () => {
|
||||
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({
|
||||
data: { id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 0, cover_url: '', albums: [] }
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { pageTitle } from '$lib/branding';
|
||||
import { createLidarrSearchQuery } from '$lib/api/lidarr';
|
||||
import { createRequest } from '$lib/api/requests';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import DiscoverResultCard from '$lib/components/DiscoverResultCard.svelte';
|
||||
import DiscoverTabs from '$lib/components/DiscoverTabs.svelte';
|
||||
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
|
||||
@@ -12,6 +14,8 @@
|
||||
LidarrSearchResult
|
||||
} from '$lib/api/types';
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Local input is the source of truth for the discover query — Shell's global
|
||||
// SearchInput targets /search, not /discover. We debounce by 250ms before
|
||||
// pushing into `debouncedQ`, which is what the query factory observes.
|
||||
@@ -87,6 +91,8 @@
|
||||
const next = new Set(optimisticRequested);
|
||||
next.add(r.mbid);
|
||||
optimisticRequested = next;
|
||||
// Surface the new request on /requests right away (+ start its poll) (#369).
|
||||
queryClient.invalidateQueries({ queryKey: qk.myRequests() });
|
||||
} catch {
|
||||
// Swallow for v1: error toasts land in a later UX pass. The card stays
|
||||
// in 'requestable' so the user can retry.
|
||||
|
||||
@@ -31,6 +31,15 @@ vi.mock('$lib/api/requests', () => ({
|
||||
|
||||
vi.mock('$lib/api/client', () => apiClientMock());
|
||||
|
||||
// The page calls useQueryClient() to invalidate qk.myRequests() after a
|
||||
// submit (#369); stub it so rendering doesn't require a real QueryClient and
|
||||
// we can assert the invalidation fires.
|
||||
const queryClientMock = vi.hoisted(() => ({ invalidateQueries: vi.fn() }));
|
||||
vi.mock('@tanstack/svelte-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/svelte-query')>();
|
||||
return { ...actual, useQueryClient: () => queryClientMock };
|
||||
});
|
||||
|
||||
import DiscoverPage from './+page.svelte';
|
||||
import { createLidarrSearchQuery } from '$lib/api/lidarr';
|
||||
import { createSuggestionsQuery } from '$lib/api/suggestions';
|
||||
@@ -187,6 +196,27 @@ describe('Discover page', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('submitting a request invalidates myRequests so /requests updates (#369)', async () => {
|
||||
vi.useFakeTimers();
|
||||
const r = result({ mbid: 'art-mbid', artist_mbid: 'art-mbid', name: 'Boards of Canada' });
|
||||
mockedCreateQuery.mockReturnValue(mockQuery<LidarrSearchResult[]>({ data: [r] }));
|
||||
render(DiscoverPage);
|
||||
const input = screen.getByLabelText(/search lidarr/i);
|
||||
await fireEvent.input(input, { target: { value: 'boards' } });
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
vi.useRealTimers();
|
||||
|
||||
const requestBtn = await screen.findByRole('button', {
|
||||
name: /request boards of canada/i
|
||||
});
|
||||
await fireEvent.click(requestBtn);
|
||||
await waitFor(() => {
|
||||
expect(queryClientMock.invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['myRequests']
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('track-kind Request click opens confirm modal; Confirm calls createRequest', async () => {
|
||||
vi.useFakeTimers();
|
||||
const r = result({
|
||||
|
||||
Reference in New Issue
Block a user