feat(offline): play downloaded video, and drain the offline sync queue (0.4.6)
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 20m34s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 6m6s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 20m26s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m3s
Build & Release / Build Linux (push) Successful in 37m59s
Build & Release / Build Windows (push) Successful in 23m0s
Build & Release / Build Android (push) Successful in 40m26s
Build & Release / Create Release (push) Successful in 1m20s

Bundles this session's work plus the concurrent search/offline/player changes.
Every gate passes on the combined tree: 885 frontend tests, 610 Rust tests,
clippy clean, boundary clean, trace coverage 86%.

Offline video playback — four separate defects, each of which alone stopped it:

  DR-133  A completed download's file_path is already absolute (the worker
          rewrites it on completion), but the player rooted it a second time and
          handed the webview /data/user/0/app//data/user/0/app/videos/x.mp4.
  DR-134  The asset protocol was never enabled: no protocol-asset feature and no
          assetProtocol config, so convertFileSrc produced URLs nothing answered.
          Also silently defeated the cached-thumbnail path, which fails soft to
          the server copy and hid it whenever the server was reachable.
  DR-137  Tauri's asset protocol answers a range-less request by reading the
          whole file into memory, and only advertises Accept-Ranges from inside
          its range branch, so the first request never learns ranges exist.
          Chromium gave up with PIPELINE_ERROR_READ after ~31s. Local media is
          now served by a loopback HTTP server: bounded 4 MiB chunks streamed
          from the file handle, every response length-delimited, and a range-less
          request answered with one chunk rather than the file. Confined by a
          per-session token and to the app data directory, because loopback is
          shared between apps on Android.
  DR-138  Release builds set usesCleartextTraffic=false, so Android rejected the
          request to that server before any I/O. A network-security-config
          exempts 127.0.0.1 only; a remote server must still be HTTPS.

Downloads:

  DR-135  download_item never records media_type and the reconnect resolver read
          that NULL as 'audio', so a movie queued from a media card had its URL
          resolved by get_audio_stream_url and completed as an audio-only
          transcode. The item's own type now decides.
  DR-136  Rows already downloaded that way are requeued on reconnect, since
          prevention alone leaves them reading "downloaded" and still unplayable.

Known limitation: a download taken at `original` quality is a byte copy of the
source, so it can be any container. One such file is an AVI holding XVID, which
the webview cannot play in any case — the media server serves it correctly and
Chromium refuses it. That needs either a transcoded download preset or the
native ExoPlayer surface work, and is not addressed here.

Also fixes two ID collisions between concurrent work: DR-143 defined twice
(search vs offline gate) and UT-131 defined twice (Episode Focus hero vs channel
cap). The search requirement is now DR-147 and the channel-cap test UT-141, with
their code references and matrix rows updated.
This commit is contained in:
2026-08-09 16:38:07 +02:00
parent 7b531a40be
commit 1b70926c36
58 changed files with 8130 additions and 2347 deletions
+240 -6
View File
@@ -106,6 +106,14 @@ const CATALOG_ITEM_TYPES: &[&str] = &[
"Playlist",
];
/// Jellyfin item types whose download is a *video* stream rather than an audio
/// one. The download queue stores an opaque `media_type` ('audio'/'video'); this
/// is where the taxonomy that produces it lives, so the frontend never has to
/// know which item types are video.
///
/// TRACES: UR-071 | DR-135
const VIDEO_ITEM_TYPES: &[&str] = &["Movie", "Episode", "Video", "MusicVideo"];
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CatalogSyncResult {
@@ -463,6 +471,51 @@ pub struct ResumeQueuedResult {
pub failed: usize,
}
/// Requeue video downloads that were fetched as audio.
///
/// Before [`resolve_pending_download_urls`] consulted the item's type, a row
/// with no `media_type` — which is every row queued from a media card, since
/// `download_item` does not record one — resolved against
/// `get_audio_stream_url`. A movie queued that way completed with an audio-only
/// transcode on disk, so playing it offline could only ever fail. Those rows are
/// identifiable after the fact (no `media_type`, but a video item), so reset them
/// to pending with no URL and let the resolver fetch the real video.
///
/// Rows carrying an explicit `media_type` were resolved correctly and are left
/// alone, as are genuine audio downloads.
///
/// Returns the number of rows requeued.
///
/// TRACES: UR-071 | DR-136 | UT-126
pub(crate) async fn requeue_mistyped_video_downloads(
db_service: &Arc<crate::storage::db_service::RusqliteService>,
) -> Result<usize, String> {
let video_types = VIDEO_ITEM_TYPES
.iter()
.map(|t| format!("'{t}'"))
.collect::<Vec<_>>()
.join(", ");
let query = Query::new(&format!(
"UPDATE downloads
SET status = 'pending', stream_url = NULL, progress = 0,
bytes_downloaded = 0, started_at = NULL, completed_at = NULL
WHERE media_type IS NULL
AND status = 'completed'
AND item_id IN (SELECT id FROM items WHERE item_type IN ({video_types}))"
));
let n = db_service.execute(query).await.map_err(|e| e.to_string())? as usize;
if n > 0 {
info!(
"[Catalog] Requeued {} video download(s) that were fetched as audio",
n
);
}
Ok(n)
}
/// Core of [`resume_queued_downloads`], factored out for testing: select every
/// `pending`/`stream_url IS NULL` row, resolve each via `resolve` (returning
/// `None` leaves the row pending), and heal the row so the pump can start it.
@@ -476,11 +529,31 @@ where
F: Fn(String, String, String) -> Fut,
Fut: std::future::Future<Output = Option<String>>,
{
let rows_query = Query::new(
"SELECT id, item_id, COALESCE(media_type, 'audio'), COALESCE(quality_preset, 'original')
FROM downloads
WHERE status = 'pending' AND stream_url IS NULL",
);
// A row's own media_type wins; otherwise the *item's* type decides. Rows
// queued from a media card never carry one (`download_item` does not record
// it), and defaulting that NULL to 'audio' resolved movies against
// `get_audio_stream_url` — the file on disk was an audio-only transcode, so
// offline video could never play. Falling back to 'audio' only when the item
// is unknown keeps the historical behaviour for uncached items.
// TRACES: UR-071, UR-052 | DR-135
let video_types = VIDEO_ITEM_TYPES
.iter()
.map(|t| format!("'{t}'"))
.collect::<Vec<_>>()
.join(", ");
let rows_query = Query::new(&format!(
"SELECT d.id, d.item_id,
COALESCE(
d.media_type,
CASE WHEN i.item_type IN ({video_types}) THEN 'video'
WHEN i.item_type IS NOT NULL THEN 'audio'
END,
'audio'),
COALESCE(d.quality_preset, 'original')
FROM downloads d
LEFT JOIN items i ON i.id = d.item_id
WHERE d.status = 'pending' AND d.stream_url IS NULL"
));
let rows: Vec<(i64, String, String, String)> = db_service
.query_many(rows_query, |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
@@ -590,6 +663,16 @@ pub async fn resume_queued_downloads(
Err(e) => warn!("[Catalog] Failed to reset stale downloads: {}", e),
}
// Repair rows that completed as audio because their media_type was missing;
// they hold an audio-only transcode where a video should be, so requeue them
// for the resolver below. TRACES: UR-071 | DR-136
if let Err(e) = requeue_mistyped_video_downloads(&db_service).await {
warn!(
"[Catalog] Failed to requeue mis-typed video downloads: {}",
e
);
}
// Resolve each row's URL against the (now reachable) repository.
let repo_for_resolve = Arc::clone(&repo);
let outcome = resolve_pending_download_urls(
@@ -699,7 +782,15 @@ mod tests {
stream_url TEXT,
target_dir TEXT,
media_type TEXT,
quality_preset TEXT
quality_preset TEXT,
progress REAL DEFAULT 0,
bytes_downloaded INTEGER DEFAULT 0,
started_at TEXT,
completed_at TEXT
);
CREATE TABLE items (
id TEXT PRIMARY KEY,
item_type TEXT
);
"#,
)
@@ -707,6 +798,18 @@ mod tests {
Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
}
async fn insert_item(db: &Arc<RusqliteService>, item_id: &str, item_type: &str) {
db.execute(Query::with_params(
"INSERT INTO items (id, item_type) VALUES (?, ?)",
vec![
QueryParam::String(item_id.to_string()),
QueryParam::String(item_type.to_string()),
],
))
.await
.unwrap();
}
async fn insert_download(
db: &Arc<RusqliteService>,
item_id: &str,
@@ -799,6 +902,137 @@ mod tests {
assert_eq!(url, None);
}
/// A movie queued from a media card has no `media_type` — `download_item`
/// never records one. Defaulting that NULL to 'audio' resolved the row
/// against `get_audio_stream_url`, so the "downloaded movie" on disk was an
/// audio-only transcode and offline video playback could never work. The
/// item's own type is the authority.
///
/// TRACES: UR-071, UR-052 | DR-135 | UT-125
#[tokio::test]
async fn null_media_type_resolves_from_the_item_type_not_audio() {
let db = test_db();
insert_item(&db, "movie-1", "Movie").await;
insert_item(&db, "ep-1", "Episode").await;
insert_item(&db, "track-1", "Audio").await;
for id in ["movie-1", "ep-1", "track-1"] {
insert_download(&db, id, "pending", None, None).await;
}
let seen = Arc::new(Mutex::new(Vec::new()));
let seen_c = Arc::clone(&seen);
resolve_pending_download_urls(&db, "/data", move |item_id, media_type, _q| {
let seen = Arc::clone(&seen_c);
async move {
seen.lock().unwrap().push((item_id.clone(), media_type));
Some(format!("http://resolved/{item_id}"))
}
})
.await
.unwrap();
let seen = seen.lock().unwrap().clone();
let of = |id: &str| {
seen.iter()
.find(|(i, _)| i == id)
.map(|(_, m)| m.clone())
.unwrap()
};
assert_eq!(of("movie-1"), "video", "a Movie must download as video");
assert_eq!(of("ep-1"), "video", "an Episode must download as video");
assert_eq!(of("track-1"), "audio", "a track is still audio");
}
/// An unknown item (never cached locally) has no type to derive from, so it
/// keeps the historical audio default rather than failing the row.
///
/// TRACES: UR-071 | DR-135 | UT-125
#[tokio::test]
async fn unknown_item_falls_back_to_audio() {
let db = test_db();
insert_download(&db, "ghost", "pending", None, None).await;
let seen = Arc::new(Mutex::new(String::new()));
let seen_c = Arc::clone(&seen);
resolve_pending_download_urls(&db, "/data", move |_id, media_type, _q| {
let seen = Arc::clone(&seen_c);
async move {
*seen.lock().unwrap() = media_type;
Some("http://x".to_string())
}
})
.await
.unwrap();
assert_eq!(*seen.lock().unwrap(), "audio");
}
/// An explicit `media_type` on the row always wins over the item's type.
///
/// TRACES: UR-071 | DR-135 | UT-125
#[tokio::test]
async fn explicit_media_type_beats_the_item_type() {
let db = test_db();
insert_item(&db, "odd", "Audio").await;
insert_download(&db, "odd", "pending", None, Some("video")).await;
let seen = Arc::new(Mutex::new(String::new()));
let seen_c = Arc::clone(&seen);
resolve_pending_download_urls(&db, "/data", move |_id, media_type, _q| {
let seen = Arc::clone(&seen_c);
async move {
*seen.lock().unwrap() = media_type;
Some("http://x".to_string())
}
})
.await
.unwrap();
assert_eq!(*seen.lock().unwrap(), "video");
}
/// Rows already downloaded under the audio default hold an audio-only
/// transcode on disk, so they play as a broken video forever. They are
/// identifiable — no `media_type` but a video item — and are requeued so the
/// resolver fetches the real video. Correctly-typed rows and genuine audio
/// downloads must be left alone.
///
/// TRACES: UR-071 | DR-136 | UT-126
#[tokio::test]
async fn requeues_video_downloaded_under_the_audio_default() {
let db = test_db();
insert_item(&db, "movie-1", "Movie").await;
insert_item(&db, "track-1", "Audio").await;
insert_item(&db, "movie-ok", "Movie").await;
// Mis-downloaded: completed, no media_type, video item.
insert_download(&db, "movie-1", "completed", Some("http://audio/url"), None).await;
// A real audio download: untouched.
insert_download(&db, "track-1", "completed", Some("http://audio/ok"), None).await;
// A correctly-typed video download: untouched.
insert_download(
&db,
"movie-ok",
"completed",
Some("http://video/ok"),
Some("video"),
)
.await;
let requeued = requeue_mistyped_video_downloads(&db).await.unwrap();
assert_eq!(requeued, 1);
let (status, url, _t) = get_row(&db, "movie-1").await;
assert_eq!(status, "pending", "the mis-typed row must download again");
assert_eq!(url, None, "its audio URL must be cleared so it re-resolves");
let (status, url, _t) = get_row(&db, "track-1").await;
assert_eq!(status, "completed", "a real audio download is untouched");
assert_eq!(url.as_deref(), Some("http://audio/ok"));
let (status, _u, _t) = get_row(&db, "movie-ok").await;
assert_eq!(status, "completed", "a correct video download is untouched");
}
#[tokio::test]
async fn video_rows_use_media_type_in_resolver() {
let db = test_db();