fix(downloads): queue the whole album, and make every queued track findable offline

An album download put a handful of its tracks on the device while the button
reported the album as downloaded. Two independent gaps, one shared cause.

- `download_album` read its track list from `items WHERE album_id = ?` — the
  local catalog cache. Jellyfin does not return `AlbumId` on every listing
  endpoint, so tracks cached from one of those sit in `items` with a NULL
  `album_id` and are invisible to that query. On the reported database three
  whole albums (18, 12 and 9 tracks) had it NULL on every track; a partially
  linked album queued only the linked subset.
- The frontend then resolved one stream URL per track from its own list and
  paired it with the returned row ids by position. The ids came back in the
  backend's `index_number` order over a different set of rows, so a row could
  be handed another track's URL and any track past the end of the shorter list
  was never started. On Android that loop also stopped wherever the webview was
  suspended.
- `album_id` is what `OfflineRepository::get_items` joins a track to its album
  on, so a track that did download stayed invisible under its album offline —
  the same missing link seen from the other side.

The operation now belongs to Rust end to end:

- `HybridRepository::get_album_tracks` asks the server what the album contains.
  Cache-first `get_items` is right for browsing and wrong for deciding what to
  download; it errors offline so the caller falls back to the ungated local
  catalog, keeping the queue-while-offline flow.
- `queue_album_tracks` writes the album link onto every track it queues, and
  creates an `items` row for tracks the cache has never seen.
- Stream URLs resolve here, through the existing reconnect resolver, now scoped
  to the rows just queued so one album cannot start every unrelated pending row.
  Only the album id crosses the IPC boundary.
- `album_file_names` gives each track its own file. A title repeated inside one
  album (deluxe edition, two discs) mapped to one path, so those downloads
  overwrote each other.

Re-tapping download on a broken album heals it: missing tracks are queued and
the tracks already on disk get their link.

`download_series`/`download_season` still derive their episode lists from the
cache the same way and want the same treatment.

DR-173, UT-170..172. Rust 673 tests, frontend 975 tests, svelte-check and
check:boundary clean.

Note: this tree is shared with a concurrent session. Only the files above are
committed; docs/traceability.md is left to be regenerated once that work lands.
This commit is contained in:
2026-08-16 09:20:32 +02:00
parent 82b6982d68
commit 1a9805f0f3
9 changed files with 790 additions and 84 deletions
+16 -3
View File
@@ -840,10 +840,23 @@ async downloadItemAndStart(request: DownloadItemAndStartRequest) : Promise<numbe
return await TAURI_INVOKE("download_item_and_start", { request });
},
/**
* Queue an entire album for download
* Queue an entire album for download.
*
* Owns the whole operation: the album's track list comes from the server (the
* only place that knows all of it), every track is queued and linked to its
* album, each row's stream URL is resolved here, and the queue is pumped.
*
* The frontend used to do the second half — resolve one URL per track and pair
* it with the returned ids **by position**. That pairing had no basis: the ids
* came back in the backend's own order over a different set of rows, so
* whenever the two lists disagreed a row was handed another track's URL, and
* any track past the end of the shorter list was never started at all. Nothing
* crosses the boundary now except the album id.
*
* TRACES: UR-018, UR-055 | DR-173 | UT-170
*/
async downloadAlbum(albumId: string, userId: string, basePath: string) : Promise<number[]> {
return await TAURI_INVOKE("download_album", { albumId, userId, basePath });
async downloadAlbum(handle: string, albumId: string, userId: string, basePath: string) : Promise<number[]> {
return await TAURI_INVOKE("download_album", { handle, albumId, userId, basePath });
},
/**
* Queue a video item (movie or episode) for download with quality preset
@@ -1,7 +1,6 @@
<script lang="ts">
import { downloads } from "$lib/stores/downloads";
import { auth } from "$lib/stores/auth";
import { commands } from "$lib/api/bindings";
import type { MediaItem } from "$lib/api/types";
interface Props {
@@ -83,28 +82,14 @@
}
}
} else {
// Download the album: queue all tracks, then start each one
// Download the album. One call: the backend lists the album's tracks
// from the server, queues every one of them, resolves each stream URL
// and pumps the queue. This page's `tracks` are what the user is
// looking at, not the download list — pairing them against the returned
// ids by position is what used to leave most of an album unqueued.
const repo = auth.getRepository();
const basePath = `albums/${albumId}`;
const downloadIds = await downloads.downloadAlbum(albumId, userId, basePath);
// Get target directory for downloads
const targetDir = await commands.storageGetPath();
// Enqueue each track with its resolved stream URL. The backend queue
// pump starts up to max_concurrent at a time and advances through the
// rest automatically as slots free up — so we never hit (and silently
// drop) the concurrency limit the way startDownload did.
for (let i = 0; i < tracks.length && i < downloadIds.length; i++) {
try {
const streamUrl = await repo.getAudioStreamUrl(tracks[i].id);
if (streamUrl) {
await commands.enqueueDownload(downloadIds[i], streamUrl, targetDir);
}
} catch (e) {
console.error(`Failed to enqueue download for track ${tracks[i].id}:`, e);
}
}
await downloads.downloadAlbum(repo.getHandle(), albumId, userId, basePath);
// Refresh to get updated statuses
await downloads.refresh(userId);
+2 -1
View File
@@ -158,9 +158,10 @@ describe("downloads store", () => {
},
}); // get_downloads
const ids = await downloads.downloadAlbum("album-1", "user-1", "/base/path");
const ids = await downloads.downloadAlbum("handle-1", "album-1", "user-1", "/base/path");
expect(mockInvoke).toHaveBeenCalledWith("download_album", {
handle: "handle-1",
albumId: "album-1",
userId: "user-1",
basePath: "/base/path",
+14 -3
View File
@@ -191,12 +191,23 @@ function createDownloadsStore() {
},
/**
* Queue an entire album for download
* Queue an entire album for download.
*
* The backend does all of it — listing the album's tracks, queueing them,
* resolving each stream URL and starting the queue. It returns the queued
* row ids for reporting only; nothing here pairs them back to tracks.
*
* TRACES: UR-018, UR-055 | DR-173
*/
async downloadAlbum(albumId: string, userId: string, basePath: string): Promise<number[]> {
async downloadAlbum(
handle: string,
albumId: string,
userId: string,
basePath: string
): Promise<number[]> {
try {
console.log('📥 downloadAlbum called:', { albumId, userId, basePath });
const downloadIds = await commands.downloadAlbum(albumId, userId, basePath);
const downloadIds = await commands.downloadAlbum(handle, albumId, userId, basePath);
console.log(' Got download IDs from backend:', downloadIds);
// Refresh downloads