Show soundtrack covers and edit alternative names (#49)
Build / test (push) Successful in 1m43s
Build / build (push) Successful in 1m14s

Covers: statistics cards show the soundtrack's cover on the left, and the
statistics popup shows it large. Until it arrives, and without a cover, the
soundtrack's initials stand in. Covers load only for cards on or near the
screen, three at a time, and are cached in covers/ next to the settings
(CoverCache), downloaded again only when cover_modified changes. The index and
file handling are shared with CharacterCache in a new FileCache.

Alternative names: the popup shows them and has an editor (add, remove, save)
that sends PUT /soundtracks/{uuid}/alternative-names. The statistics search and
the Search popup find soundtracks by them; Search shows the matching name after
the real one. Search now loads /soundtracks, falling back to the plain list.

The API types are regenerated from the server's spec, with a SoundtrackCover
theme style in every theme.

Closes #49

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SansanandClaude Opus 5.5 committed 2026-10-14 22:06:30 +02:00
1 parent a735c515bf
commit ef6ac8f1fd
37 files changed
+8029 -1763

No files matched your search

+589
View File
@@ -17,6 +17,23 @@
],
"type": "object"
},
"AlternativeNames": {
"additionalProperties": false,
"properties": {
"names": {
"description": "The new list of alternative names. Replaces the old list; send [] to remove them all.",
"items": {
"type": "string"
},
"maxItems": 50,
"type": "array"
}
},
"required": [
"names"
],
"type": "object"
},
"CharacterDetails": {
"additionalProperties": false,
"properties": {
@@ -36,6 +53,30 @@
],
"type": "object"
},
"CoverSource": {
"additionalProperties": false,
"properties": {
"id": {
"description": "The game's ID on SteamGridDB, or the anime's ID on AniList",
"maxLength": 50,
"minLength": 1,
"type": "string"
},
"source": {
"description": "Where the cover comes from",
"enum": [
"steamgriddb",
"anilist"
],
"type": "string"
}
},
"required": [
"source",
"id"
],
"type": "object"
},
"ProgressResponse": {
"additionalProperties": false,
"properties": {
@@ -175,6 +216,18 @@
"format": "date-time",
"type": "string"
},
"alternative_names": {
"description": "Other names the soundtrack is known by",
"items": {
"type": "string"
},
"type": "array"
},
"cover_modified": {
"description": "When the cover last changed, in Unix seconds. Missing when there is no cover.",
"format": "int64",
"type": "integer"
},
"last_played": {
"format": "date-time",
"type": "string"
@@ -193,6 +246,7 @@
"required": [
"uuid",
"name",
"alternative_names",
"times_played",
"added"
],
@@ -205,6 +259,18 @@
"format": "date-time",
"type": "string"
},
"alternative_names": {
"description": "Other names the soundtrack is known by",
"items": {
"type": "string"
},
"type": "array"
},
"cover_modified": {
"description": "When the cover last changed, in Unix seconds. Missing when there is no cover.",
"format": "int64",
"type": "integer"
},
"last_played": {
"format": "date-time",
"type": "string"
@@ -230,6 +296,7 @@
"songs",
"uuid",
"name",
"alternative_names",
"times_played",
"added"
],
@@ -238,6 +305,18 @@
"SoundtrackWithSongs": {
"additionalProperties": false,
"properties": {
"alternative_names": {
"description": "Other names the soundtrack is known by",
"items": {
"type": "string"
},
"type": "array"
},
"cover_modified": {
"description": "When the cover last changed, in Unix seconds. Missing when there is no cover.",
"format": "int64",
"type": "integer"
},
"songs": {
"items": {
"$ref": "#/components/schemas/SongInfoForStats"
@@ -267,6 +346,7 @@
"soundtrack_id",
"soundtrack_uuid",
"soundtrack_name",
"alternative_names",
"soundtrack_played",
"songs"
],
@@ -2663,6 +2743,515 @@
]
}
},
"/api/v1/soundtracks/{uuid}/alternative-names": {
"put": {
"description": "Replaces the soundtrack's alternative names, which search and the cover search also use. Names are trimmed, and empty names, duplicates and the soundtrack's own name (ignoring case) are dropped.",
"operationId": "set-alternative-names",
"parameters": [
{
"description": "UUID of the soundtrack",
"in": "path",
"name": "uuid",
"required": true,
"schema": {
"description": "UUID of the soundtrack",
"format": "uuid",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AlternativeNames"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Soundtrack"
}
}
},
"description": "OK"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unauthorized"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Not Found"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unprocessable Entity"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Internal Server Error"
}
},
"security": [
{
"bearer": []
}
],
"summary": "Set alternative names",
"tags": [
"library"
]
}
},
"/api/v1/soundtracks/{uuid}/cover": {
"delete": {
"description": "Deletes the soundtrack's cover, including one set by hand, so the automatic search looks for a new one.",
"operationId": "delete-soundtrack-cover",
"parameters": [
{
"description": "UUID of the soundtrack or song",
"in": "path",
"name": "uuid",
"required": true,
"schema": {
"description": "UUID of the soundtrack or song",
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"204": {
"description": "No Content"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unauthorized"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Not Found"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unprocessable Entity"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Internal Server Error"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Service Unavailable"
}
},
"security": [
{
"bearer": []
}
],
"summary": "Remove the cover",
"tags": [
"covers"
]
},
"get": {
"description": "Returns the soundtrack's cover image. cover_modified in the soundtrack responses tells when it last changed.",
"operationId": "get-soundtrack-cover",
"parameters": [
{
"description": "UUID of the soundtrack or song",
"in": "path",
"name": "uuid",
"required": true,
"schema": {
"description": "UUID of the soundtrack or song",
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"image/jpeg": {
"schema": {
"contentMediaType": "application/octet-stream",
"format": "binary",
"type": "string"
}
},
"image/png": {
"schema": {
"contentMediaType": "application/octet-stream",
"format": "binary",
"type": "string"
}
},
"image/webp": {
"schema": {
"contentMediaType": "application/octet-stream",
"format": "binary",
"type": "string"
}
}
},
"description": "The cover image"
},
"206": {
"content": {
"image/jpeg": {
"schema": {
"contentMediaType": "application/octet-stream",
"format": "binary",
"type": "string"
}
},
"image/png": {
"schema": {
"contentMediaType": "application/octet-stream",
"format": "binary",
"type": "string"
}
},
"image/webp": {
"schema": {
"contentMediaType": "application/octet-stream",
"format": "binary",
"type": "string"
}
}
},
"description": "Part of the file, for a Range request"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unauthorized"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Not Found"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unprocessable Entity"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Internal Server Error"
}
},
"security": [
{
"bearer": []
}
],
"summary": "Soundtrack cover",
"tags": [
"covers"
]
},
"put": {
"description": "Stores the image as the soundtrack's cover. The automatic search never replaces it; delete it to go back to automatic.",
"operationId": "upload-soundtrack-cover",
"parameters": [
{
"description": "UUID of the soundtrack",
"in": "path",
"name": "uuid",
"required": true,
"schema": {
"description": "UUID of the soundtrack",
"format": "uuid",
"type": "string"
}
}
],
"requestBody": {
"content": {
"image/*": {
"schema": {
"contentMediaType": "application/octet-stream",
"format": "binary",
"type": "string"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "No Content"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Bad Request"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unauthorized"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Not Found"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unprocessable Entity"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Internal Server Error"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Service Unavailable"
}
},
"security": [
{
"bearer": []
}
],
"summary": "Upload a cover",
"tags": [
"covers"
]
}
},
"/api/v1/soundtracks/{uuid}/cover/source": {
"put": {
"description": "Downloads the cover of the given SteamGridDB game or AniList anime and stores it as the soundtrack's cover. The automatic search never replaces it. SteamGridDB needs STEAMGRIDDB_API_KEY on the server.",
"operationId": "set-soundtrack-cover-source",
"parameters": [
{
"description": "UUID of the soundtrack",
"in": "path",
"name": "uuid",
"required": true,
"schema": {
"description": "UUID of the soundtrack",
"format": "uuid",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CoverSource"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "No Content"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Bad Request"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unauthorized"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Not Found"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Unprocessable Entity"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Internal Server Error"
},
"502": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Bad Gateway"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIError"
}
}
},
"description": "Service Unavailable"
}
},
"security": [
{
"bearer": []
}
],
"summary": "Pick a cover",
"tags": [
"covers"
]
}
},
"/api/v1/statistics/songs/least-played": {
"get": {
"description": "Returns the least played songs with their soundtrack.",