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
🏗️ 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:
Generated
+32
-1
@@ -150,6 +150,12 @@ version = "1.0.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "ascii"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -552,6 +558,12 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chunked_transfer"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
@@ -1683,6 +1695,12 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.8.1"
|
||||
@@ -2000,7 +2018,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jellytau"
|
||||
version = "0.4.1"
|
||||
version = "0.4.6"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
@@ -2031,6 +2049,7 @@ dependencies = [
|
||||
"tauri-plugin-os",
|
||||
"tauri-specta",
|
||||
"tempfile",
|
||||
"tiny_http",
|
||||
"tokio",
|
||||
"tokio-rusqlite",
|
||||
"tokio-util",
|
||||
@@ -4578,6 +4597,18 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny_http"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
|
||||
dependencies = [
|
||||
"ascii",
|
||||
"chunked_transfer",
|
||||
"httpdate",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "jellytau"
|
||||
version = "0.4.1"
|
||||
version = "0.4.6"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
@@ -59,6 +59,7 @@ env_logger = "0.11"
|
||||
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
|
||||
specta-typescript = "=0.0.9"
|
||||
specta = { version = "=2.0.0-rc.22", features = ["chrono", "derive"] }
|
||||
tiny_http = { version = "0.12.0", default-features = false }
|
||||
|
||||
# Linux-specific dependencies
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.jellytau"
|
||||
android:hardwareAccelerated="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|density"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Cleartext HTTP stays blocked everywhere except loopback.
|
||||
|
||||
Downloaded media is served to the webview by a local HTTP server on
|
||||
127.0.0.1 (see media_server.rs / DR-137). Release builds set
|
||||
usesCleartextTraffic="false", so Android's network security policy rejected
|
||||
those requests before any I/O happened and offline video failed instantly with
|
||||
NETWORK_NO_SOURCE.
|
||||
|
||||
Only 127.0.0.1 is exempted. The base config keeps the release default, so a
|
||||
remote server still has to be HTTPS — this must not become a blanket
|
||||
cleartext opt-in.
|
||||
|
||||
TRACES: UR-071 | DR-138
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -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();
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod repository;
|
||||
pub mod sessions;
|
||||
pub mod storage;
|
||||
pub mod sync;
|
||||
pub mod sync_drain;
|
||||
|
||||
pub use auth::*;
|
||||
pub use catalog::*;
|
||||
@@ -34,3 +35,4 @@ pub use repository::{RepositoryManager, RepositoryManagerWrapper, *};
|
||||
pub use sessions::*;
|
||||
pub use storage::*;
|
||||
pub use sync::*;
|
||||
pub use sync_drain::*;
|
||||
|
||||
@@ -80,6 +80,30 @@ pub fn storage_init(db: State<DatabaseWrapper>) -> Result<String, String> {
|
||||
Ok(database.path().to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// A playable URL for a downloaded file on disk.
|
||||
///
|
||||
/// Local media is served over a loopback HTTP server rather than handed to the
|
||||
/// webview as a `file://`/asset URL, because the asset protocol cannot stream a
|
||||
/// large file — it answers a range-less request with the whole thing, which
|
||||
/// Chromium abandons. See `media_server` for why real HTTP is used.
|
||||
///
|
||||
/// The returned URL carries the server's per-session token, so it is only valid
|
||||
/// for this run of the app and must not be persisted.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-137
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn media_local_url(
|
||||
server: State<crate::media_server::MediaServerWrapper>,
|
||||
path: String,
|
||||
) -> Result<String, String> {
|
||||
server
|
||||
.0
|
||||
.as_ref()
|
||||
.map(|s| s.url_for(&path))
|
||||
.ok_or_else(|| "Local media server is not running".to_string())
|
||||
}
|
||||
|
||||
/// Get storage directory path (parent directory of the database file)
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
//!
|
||||
//! The sync queue stores mutations (favorites, playback progress, etc.)
|
||||
//! that need to be synced to the Jellyfin server when connectivity is restored.
|
||||
//! TRACES: UR-002, UR-017, UR-025 | DR-014
|
||||
//! Draining it lives in `sync_drain` (DR-131); this module is the storage and
|
||||
//! read side the UI lists from (DR-132).
|
||||
//! TRACES: UR-002, UR-017, UR-025 | DR-014, DR-131, DR-132
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
@@ -24,6 +26,12 @@ pub struct SyncQueueItem {
|
||||
pub retry_count: i32,
|
||||
pub created_at: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
/// Cached title of the item the operation is about, when the catalog knows
|
||||
/// it. Resolved here rather than by a per-row frontend fetch — the queue
|
||||
/// list is otherwise a wall of opaque ids.
|
||||
///
|
||||
/// TRACES: UR-025 | DR-132
|
||||
pub item_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Queue a mutation for sync to server
|
||||
@@ -74,20 +82,20 @@ pub async fn sync_get_pending(
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
let sql = if let Some(l) = limit {
|
||||
format!(
|
||||
"SELECT id, user_id, operation, item_id, payload, status, retry_count, created_at, error_message
|
||||
FROM sync_queue
|
||||
WHERE user_id = ? AND status IN ('pending', 'failed')
|
||||
ORDER BY created_at ASC
|
||||
LIMIT {}",
|
||||
l
|
||||
)
|
||||
} else {
|
||||
"SELECT id, user_id, operation, item_id, payload, status, retry_count, created_at, error_message
|
||||
FROM sync_queue
|
||||
WHERE user_id = ? AND status IN ('pending', 'failed')
|
||||
ORDER BY created_at ASC".to_string()
|
||||
// The `items` join names the queued item where the catalog has it; a row for
|
||||
// an item that was never cached still lists, with a null name.
|
||||
// `abandoned` rows (DR-131 gave up on them) are excluded here for the same
|
||||
// reason they are excluded from the count — they are no longer waiting.
|
||||
const SELECT: &str = "SELECT q.id, q.user_id, q.operation, q.item_id, q.payload, q.status,
|
||||
COALESCE(q.retry_count, 0), q.created_at, q.error_message, i.name
|
||||
FROM sync_queue q
|
||||
LEFT JOIN items i ON i.id = q.item_id
|
||||
WHERE q.user_id = ? AND q.status IN ('pending', 'failed')
|
||||
ORDER BY q.created_at ASC, q.id ASC";
|
||||
|
||||
let sql = match limit {
|
||||
Some(l) => format!("{} LIMIT {}", SELECT, l),
|
||||
None => SELECT.to_string(),
|
||||
};
|
||||
|
||||
let query = Query::with_params(sql, vec![QueryParam::String(user_id)]);
|
||||
@@ -104,6 +112,7 @@ pub async fn sync_get_pending(
|
||||
retry_count: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
error_message: row.get(8)?,
|
||||
item_name: row.get(9)?,
|
||||
})
|
||||
})
|
||||
.await
|
||||
@@ -256,6 +265,7 @@ mod tests {
|
||||
retry_count: 0,
|
||||
created_at: Some("2024-02-14T08:00:00Z".to_string()),
|
||||
error_message: None,
|
||||
item_name: None,
|
||||
};
|
||||
|
||||
// Should serialize successfully
|
||||
@@ -280,6 +290,7 @@ mod tests {
|
||||
retry_count: 3,
|
||||
created_at: Some("2024-02-14T07:00:00Z".to_string()),
|
||||
error_message: Some("Connection timeout".to_string()),
|
||||
item_name: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&item).unwrap();
|
||||
@@ -300,6 +311,7 @@ mod tests {
|
||||
retry_count: 0,
|
||||
created_at: None,
|
||||
error_message: None,
|
||||
item_name: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&item).unwrap();
|
||||
@@ -323,6 +335,7 @@ mod tests {
|
||||
retry_count: 0,
|
||||
created_at: None,
|
||||
error_message: None,
|
||||
item_name: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&item).unwrap();
|
||||
@@ -363,6 +376,7 @@ mod tests {
|
||||
retry_count: 0,
|
||||
created_at: None,
|
||||
error_message: None,
|
||||
item_name: None,
|
||||
};
|
||||
|
||||
// Simulate retries
|
||||
|
||||
@@ -0,0 +1,838 @@
|
||||
//! Draining the offline mutation queue (`sync_queue`) to the server.
|
||||
//!
|
||||
//! `sync_queue` had producers but no consumer: `PlaybackReporter::queue_for_sync`
|
||||
//! inserts a row whenever a start/stop/mark-played cannot reach the server, and
|
||||
//! nothing ever pushed one. `sync_mark_processing`/`_completed`/`_failed` were
|
||||
//! registered commands with no callers, so the queue only grew — the offline
|
||||
//! banner's "N pending" climbed forever and the watch positions those rows stood
|
||||
//! for never reached Jellyfin.
|
||||
//!
|
||||
//! Same shape as the favourites drain (DR-120), and for the same reason: a drain
|
||||
//! started by a component dies with it, so it lives in Rust and hangs off the
|
||||
//! `connectivity:reconnected` transition the `ConnectivityMonitor` already emits.
|
||||
//!
|
||||
//! TRACES: UR-025, UR-002 | DR-131 | UT-122
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use log::{debug, info, warn};
|
||||
use tauri::{Emitter, Listener, Manager};
|
||||
|
||||
use crate::repository::types::RepoError;
|
||||
use crate::repository::MediaRepository;
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
|
||||
|
||||
/// How many times a row may fail before it stops being retried.
|
||||
///
|
||||
/// A row that can never succeed (a deleted item, an operation this build does
|
||||
/// not know how to push) must eventually leave the queue, or it re-creates the
|
||||
/// bug this module fixes: a count that only ever goes up.
|
||||
pub const MAX_SYNC_ATTEMPTS: i32 = 5;
|
||||
|
||||
/// Emitted after a drain so open views can re-read the queue instead of waiting
|
||||
/// for the frontend's 10s poll.
|
||||
pub const SYNC_QUEUE_CHANGED_EVENT: &str = "sync-queue-changed";
|
||||
|
||||
/// A queued mutation, resolved from its stored `operation` + JSON `payload`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum QueuedOp {
|
||||
PlaybackStart {
|
||||
item_id: String,
|
||||
position_ticks: i64,
|
||||
},
|
||||
/// Also where `update_progress` lands: replaying a mid-playback progress
|
||||
/// report long after the fact would tell the server we are still playing.
|
||||
/// What the row actually carries is a resume position, and "stopped at N"
|
||||
/// is how that reaches Jellyfin's `UserData`.
|
||||
PlaybackStopped {
|
||||
item_id: String,
|
||||
position_ticks: i64,
|
||||
},
|
||||
MarkPlayed {
|
||||
item_id: String,
|
||||
},
|
||||
/// Legacy rows only — live favourite toggles drain via `user_data.pending_sync`
|
||||
/// (DR-120). Supported so a row written by an older build still lands.
|
||||
Favorite {
|
||||
item_id: String,
|
||||
is_favorite: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Turn a stored row into something pushable.
|
||||
///
|
||||
/// Payload keys differ by producer: the Rust reporter writes `position_ticks`,
|
||||
/// while `syncService.queuePlaybackProgress` writes camelCase `positionMs`.
|
||||
/// Both are accepted rather than normalised at the producer, because rows
|
||||
/// already in users' databases were written by both.
|
||||
///
|
||||
/// TRACES: UR-025 | DR-131 | UT-122
|
||||
pub fn parse_queued_op(
|
||||
operation: &str,
|
||||
item_id: Option<&str>,
|
||||
payload: Option<&str>,
|
||||
) -> Result<QueuedOp, String> {
|
||||
let json: serde_json::Value = match payload {
|
||||
Some(raw) if !raw.trim().is_empty() => {
|
||||
serde_json::from_str(raw).map_err(|e| format!("Unreadable payload: {}", e))?
|
||||
}
|
||||
_ => serde_json::Value::Null,
|
||||
};
|
||||
|
||||
let item_id = item_id
|
||||
.filter(|id| !id.is_empty())
|
||||
.ok_or_else(|| format!("Operation '{}' has no item id", operation))?
|
||||
.to_string();
|
||||
|
||||
let ticks = || -> i64 {
|
||||
if let Some(t) = json.get("position_ticks").and_then(|v| v.as_i64()) {
|
||||
return t;
|
||||
}
|
||||
if let Some(ms) = json.get("positionMs").and_then(|v| v.as_i64()) {
|
||||
return ms * 10_000; // ms → Jellyfin ticks (100ns)
|
||||
}
|
||||
0
|
||||
};
|
||||
|
||||
match operation {
|
||||
"report_playback_start" => Ok(QueuedOp::PlaybackStart {
|
||||
item_id,
|
||||
position_ticks: ticks(),
|
||||
}),
|
||||
"report_playback_stopped" | "update_progress" => Ok(QueuedOp::PlaybackStopped {
|
||||
item_id,
|
||||
position_ticks: ticks(),
|
||||
}),
|
||||
"mark_played" => Ok(QueuedOp::MarkPlayed { item_id }),
|
||||
"mark_favorite" => Ok(QueuedOp::Favorite {
|
||||
item_id,
|
||||
is_favorite: true,
|
||||
}),
|
||||
"unmark_favorite" => Ok(QueuedOp::Favorite {
|
||||
item_id,
|
||||
is_favorite: false,
|
||||
}),
|
||||
other => Err(format!("Unsupported operation '{}'", other)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The slice of the repository the drain needs — narrow so it can be doubled in
|
||||
/// a test without forty `unimplemented!()` methods.
|
||||
#[async_trait]
|
||||
pub trait SyncSink: Send + Sync {
|
||||
async fn push(&self, op: &QueuedOp) -> Result<(), RepoError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T: MediaRepository + ?Sized> SyncSink for T {
|
||||
async fn push(&self, op: &QueuedOp) -> Result<(), RepoError> {
|
||||
match op {
|
||||
QueuedOp::PlaybackStart {
|
||||
item_id,
|
||||
position_ticks,
|
||||
} => self.report_playback_start(item_id, *position_ticks).await,
|
||||
QueuedOp::PlaybackStopped {
|
||||
item_id,
|
||||
position_ticks,
|
||||
} => self.report_playback_stopped(item_id, *position_ticks).await,
|
||||
QueuedOp::MarkPlayed { item_id } => self.mark_played(item_id).await,
|
||||
QueuedOp::Favorite {
|
||||
item_id,
|
||||
is_favorite,
|
||||
} => {
|
||||
if *is_favorite {
|
||||
self.mark_favorite(item_id).await
|
||||
} else {
|
||||
self.unmark_favorite(item_id).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a drain did, for logging and for the frontend's "Sync now" button.
|
||||
#[derive(
|
||||
Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type,
|
||||
)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DrainReport {
|
||||
/// Rows that reached the server and are now `completed`.
|
||||
pub pushed: i32,
|
||||
/// Rows that failed and will be retried on the next reconnect.
|
||||
pub deferred: i32,
|
||||
/// Rows that exhausted `MAX_SYNC_ATTEMPTS` and were given up on.
|
||||
pub abandoned: i32,
|
||||
/// Rows still waiting afterwards (what the badge counts).
|
||||
pub remaining: i32,
|
||||
}
|
||||
|
||||
/// Why a push failed, and whether the row should be charged an attempt for it.
|
||||
struct PushFailure {
|
||||
reason: String,
|
||||
/// The server could not be reached at all — retry later, free of charge.
|
||||
transient: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct QueuedRow {
|
||||
id: i64,
|
||||
operation: String,
|
||||
item_id: Option<String>,
|
||||
payload: Option<String>,
|
||||
retry_count: i32,
|
||||
}
|
||||
|
||||
async fn read_queue(db: &Arc<RusqliteService>, user_id: &str) -> Result<Vec<QueuedRow>, String> {
|
||||
db.query_many(
|
||||
Query::with_params(
|
||||
"SELECT id, operation, item_id, payload, COALESCE(retry_count, 0) \
|
||||
FROM sync_queue \
|
||||
WHERE user_id = ? AND status IN ('pending', 'failed') \
|
||||
ORDER BY created_at ASC, id ASC",
|
||||
vec![QueryParam::String(user_id.to_string())],
|
||||
),
|
||||
|row| {
|
||||
Ok(QueuedRow {
|
||||
id: row.get(0)?,
|
||||
operation: row.get(1)?,
|
||||
item_id: row.get(2)?,
|
||||
payload: row.get(3)?,
|
||||
retry_count: row.get(4)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn remaining_count(db: &Arc<RusqliteService>, user_id: &str) -> Result<i32, String> {
|
||||
db.query_one(
|
||||
Query::with_params(
|
||||
"SELECT COUNT(*) FROM sync_queue WHERE user_id = ? AND status IN ('pending', 'failed')",
|
||||
vec![QueryParam::String(user_id.to_string())],
|
||||
),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Push every queued mutation for this user, oldest first.
|
||||
///
|
||||
/// Chronological order matters: a stale start replayed after a later stop would
|
||||
/// otherwise move the server's resume position backwards.
|
||||
///
|
||||
/// A row that fails keeps its place and is retried on the next reconnect, until
|
||||
/// `MAX_SYNC_ATTEMPTS` — after which it is abandoned, because a row nothing can
|
||||
/// ever push is exactly what turned this queue into a counter that only grew.
|
||||
///
|
||||
/// TRACES: UR-025, UR-002 | DR-131 | UT-122
|
||||
pub async fn drain_sync_queue(
|
||||
db: &Arc<RusqliteService>,
|
||||
sink: &dyn SyncSink,
|
||||
user_id: &str,
|
||||
) -> Result<DrainReport, String> {
|
||||
let queued = read_queue(db, user_id).await?;
|
||||
if queued.is_empty() {
|
||||
return Ok(DrainReport::default());
|
||||
}
|
||||
|
||||
info!(
|
||||
"[SyncQueue] Pushing {} operation(s) queued while offline",
|
||||
queued.len()
|
||||
);
|
||||
|
||||
let mut report = DrainReport::default();
|
||||
|
||||
for row in queued {
|
||||
let outcome = match parse_queued_op(
|
||||
&row.operation,
|
||||
row.item_id.as_deref(),
|
||||
row.payload.as_deref(),
|
||||
) {
|
||||
Ok(op) => sink.push(&op).await.map_err(|e| PushFailure {
|
||||
// An unreachable server is not the row's fault: burning its
|
||||
// budget would abandon perfectly good rows just because the app
|
||||
// was opened offline a few times.
|
||||
transient: matches!(e, RepoError::Offline | RepoError::Network { .. }),
|
||||
reason: e.to_string(),
|
||||
}),
|
||||
// An unreadable or unsupported row can never succeed, so it does
|
||||
// burn attempts rather than being deleted outright — the panel shows
|
||||
// the reason until it is abandoned.
|
||||
Err(reason) => Err(PushFailure {
|
||||
reason,
|
||||
transient: false,
|
||||
}),
|
||||
};
|
||||
|
||||
match outcome {
|
||||
Ok(()) => {
|
||||
mark_completed(db, row.id).await?;
|
||||
report.pushed += 1;
|
||||
}
|
||||
Err(failure) if failure.transient => {
|
||||
mark_deferred(db, row.id, &failure.reason).await?;
|
||||
debug!(
|
||||
"[SyncQueue] Server unreachable, {} stays queued: {}",
|
||||
row.operation, failure.reason
|
||||
);
|
||||
report.deferred += 1;
|
||||
}
|
||||
Err(failure) => {
|
||||
let attempts = row.retry_count + 1;
|
||||
let give_up = attempts >= MAX_SYNC_ATTEMPTS;
|
||||
mark_failed(db, row.id, attempts, give_up, &failure.reason).await?;
|
||||
if give_up {
|
||||
warn!(
|
||||
"[SyncQueue] Giving up on {} after {} attempts: {}",
|
||||
row.operation, attempts, failure.reason
|
||||
);
|
||||
report.abandoned += 1;
|
||||
} else {
|
||||
debug!(
|
||||
"[SyncQueue] Deferring {} (attempt {}): {}",
|
||||
row.operation, attempts, failure.reason
|
||||
);
|
||||
report.deferred += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
report.remaining = remaining_count(db, user_id).await?;
|
||||
info!(
|
||||
"[SyncQueue] Drain finished: {} pushed, {} deferred, {} abandoned, {} remaining",
|
||||
report.pushed, report.deferred, report.abandoned, report.remaining
|
||||
);
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
async fn mark_completed(db: &Arc<RusqliteService>, id: i64) -> Result<(), String> {
|
||||
db.execute(Query::with_params(
|
||||
"UPDATE sync_queue \
|
||||
SET status = 'completed', processed_at = CURRENT_TIMESTAMP, error_message = NULL \
|
||||
WHERE id = ?",
|
||||
vec![QueryParam::Int64(id)],
|
||||
))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Put a row back in the queue untouched apart from its error note — used when
|
||||
/// the server was simply unreachable.
|
||||
async fn mark_deferred(db: &Arc<RusqliteService>, id: i64, reason: &str) -> Result<(), String> {
|
||||
db.execute(Query::with_params(
|
||||
"UPDATE sync_queue SET status = 'pending', error_message = ? WHERE id = ?",
|
||||
vec![
|
||||
QueryParam::String(reason.to_string()),
|
||||
QueryParam::Int64(id),
|
||||
],
|
||||
))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mark_failed(
|
||||
db: &Arc<RusqliteService>,
|
||||
id: i64,
|
||||
attempts: i32,
|
||||
give_up: bool,
|
||||
reason: &str,
|
||||
) -> Result<(), String> {
|
||||
db.execute(Query::with_params(
|
||||
"UPDATE sync_queue \
|
||||
SET status = ?, retry_count = ?, error_message = ?, processed_at = CURRENT_TIMESTAMP \
|
||||
WHERE id = ?",
|
||||
vec![
|
||||
QueryParam::String(if give_up { "abandoned" } else { "failed" }.to_string()),
|
||||
QueryParam::Int(attempts),
|
||||
QueryParam::String(reason.to_string()),
|
||||
QueryParam::Int64(id),
|
||||
],
|
||||
))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drain on every offline→online transition.
|
||||
///
|
||||
/// TRACES: UR-025 | DR-131
|
||||
pub fn spawn_sync_queue_drain(app: tauri::AppHandle) {
|
||||
let handle = app.clone();
|
||||
app.listen("connectivity:reconnected", move |_event| {
|
||||
let app = handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = run_drain(&app).await {
|
||||
warn!("[SyncQueue] Drain skipped: {}", e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Resolve app state and drain. Shared by the reconnect hook and the manual
|
||||
/// "Sync now" command.
|
||||
pub async fn run_drain(app: &tauri::AppHandle) -> Result<DrainReport, String> {
|
||||
let db_service: Arc<RusqliteService> = {
|
||||
let db = app.state::<crate::commands::storage::DatabaseWrapper>();
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
let (repo, user_id) = {
|
||||
let manager = app.state::<crate::commands::repository::RepositoryManagerWrapper>();
|
||||
let handles = manager.0.handles();
|
||||
let Some(handle) = handles.first() else {
|
||||
// Not signed in — nothing to push on behalf of.
|
||||
return Ok(DrainReport::default());
|
||||
};
|
||||
let repo = manager.0.get(handle).ok_or("Repository not found")?;
|
||||
let user_id = repo.user_id().to_string();
|
||||
(repo, user_id)
|
||||
};
|
||||
|
||||
let report = drain_sync_queue(&db_service, repo.as_ref(), &user_id).await?;
|
||||
|
||||
if report.pushed > 0 || report.abandoned > 0 {
|
||||
if let Err(e) = app.emit(SYNC_QUEUE_CHANGED_EVENT, &report) {
|
||||
warn!("[SyncQueue] Failed to emit change event: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Push the queue now, on the user's say-so, instead of waiting for a reconnect.
|
||||
///
|
||||
/// TRACES: UR-025 | DR-132
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn sync_process_pending(app: tauri::AppHandle) -> Result<DrainReport, String> {
|
||||
run_drain(&app).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Records what the server was asked to do, and can be told to fail.
|
||||
struct RecordingSink {
|
||||
calls: Mutex<Vec<QueuedOp>>,
|
||||
fail_with: Option<RepoError>,
|
||||
}
|
||||
|
||||
impl RecordingSink {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
calls: Mutex::new(Vec::new()),
|
||||
fail_with: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The server is there and refuses the operation — the row's own fault.
|
||||
fn always_rejecting() -> Self {
|
||||
Self {
|
||||
calls: Mutex::new(Vec::new()),
|
||||
fail_with: Some(RepoError::Server {
|
||||
message: "HTTP 400".to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The server cannot be reached at all — nothing to do with the row.
|
||||
fn unreachable() -> Self {
|
||||
Self {
|
||||
calls: Mutex::new(Vec::new()),
|
||||
fail_with: Some(RepoError::Offline),
|
||||
}
|
||||
}
|
||||
|
||||
fn calls(&self) -> Vec<QueuedOp> {
|
||||
self.calls.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SyncSink for RecordingSink {
|
||||
async fn push(&self, op: &QueuedOp) -> Result<(), RepoError> {
|
||||
if let Some(err) = &self.fail_with {
|
||||
return Err(err.clone());
|
||||
}
|
||||
self.calls.lock().unwrap().push(op.clone());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn test_db() -> Arc<RusqliteService> {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE sync_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
item_id TEXT,
|
||||
payload TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
processed_at TEXT,
|
||||
error_message TEXT
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
|
||||
}
|
||||
|
||||
/// (user, operation, item_id, payload, status, retry_count, created_at)
|
||||
type Seed<'a> = (
|
||||
&'a str,
|
||||
&'a str,
|
||||
&'a str,
|
||||
Option<&'a str>,
|
||||
&'a str,
|
||||
i32,
|
||||
&'a str,
|
||||
);
|
||||
|
||||
async fn seed(db: &Arc<RusqliteService>, rows: &[Seed<'_>]) {
|
||||
for (user, op, item, payload, status, retries, created) in rows {
|
||||
db.execute(Query::with_params(
|
||||
"INSERT INTO sync_queue (user_id, operation, item_id, payload, status, retry_count, created_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
vec![
|
||||
QueryParam::String(user.to_string()),
|
||||
QueryParam::String(op.to_string()),
|
||||
QueryParam::String(item.to_string()),
|
||||
payload
|
||||
.map(|p| QueryParam::String(p.to_string()))
|
||||
.unwrap_or(QueryParam::Null),
|
||||
QueryParam::String(status.to_string()),
|
||||
QueryParam::Int(*retries),
|
||||
QueryParam::String(created.to_string()),
|
||||
],
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
async fn row_state(db: &Arc<RusqliteService>, item_id: &str) -> (String, i32) {
|
||||
db.query_one(
|
||||
Query::with_params(
|
||||
"SELECT status, COALESCE(retry_count, 0) FROM sync_queue WHERE item_id = ?",
|
||||
vec![QueryParam::String(item_id.to_string())],
|
||||
),
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, i32>(1)?)),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// UT-122 — the bug itself: rows queued while offline reach the server on
|
||||
/// reconnect and stop counting towards the offline banner's badge.
|
||||
///
|
||||
/// TRACES: UR-025 | DR-131 | UT-122
|
||||
#[tokio::test]
|
||||
async fn test_drain_pushes_queued_operations_and_clears_the_queue() {
|
||||
let db = test_db();
|
||||
seed(
|
||||
&db,
|
||||
&[
|
||||
(
|
||||
"u1",
|
||||
"report_playback_start",
|
||||
"ep1",
|
||||
Some(r#"{"position_ticks": 100}"#),
|
||||
"pending",
|
||||
0,
|
||||
"2026-08-01T10:00:00Z",
|
||||
),
|
||||
(
|
||||
"u1",
|
||||
"report_playback_stopped",
|
||||
"ep2",
|
||||
Some(r#"{"position_ticks": 5000}"#),
|
||||
"pending",
|
||||
0,
|
||||
"2026-08-01T10:01:00Z",
|
||||
),
|
||||
(
|
||||
"u1",
|
||||
"mark_played",
|
||||
"ep3",
|
||||
None,
|
||||
"pending",
|
||||
0,
|
||||
"2026-08-01T10:02:00Z",
|
||||
),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let sink = RecordingSink::new();
|
||||
let report = drain_sync_queue(&db, &sink, "u1").await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
sink.calls(),
|
||||
vec![
|
||||
QueuedOp::PlaybackStart {
|
||||
item_id: "ep1".to_string(),
|
||||
position_ticks: 100
|
||||
},
|
||||
QueuedOp::PlaybackStopped {
|
||||
item_id: "ep2".to_string(),
|
||||
position_ticks: 5000
|
||||
},
|
||||
QueuedOp::MarkPlayed {
|
||||
item_id: "ep3".to_string()
|
||||
},
|
||||
],
|
||||
"every queued operation pushes, oldest first"
|
||||
);
|
||||
assert_eq!(report.pushed, 3);
|
||||
assert_eq!(report.remaining, 0, "the badge must reach zero");
|
||||
assert_eq!(row_state(&db, "ep1").await.0, "completed");
|
||||
}
|
||||
|
||||
/// A push that fails stays queued for the next reconnect rather than being
|
||||
/// dropped.
|
||||
///
|
||||
/// TRACES: UR-025 | DR-131 | UT-122
|
||||
#[tokio::test]
|
||||
async fn test_drain_defers_failed_pushes() {
|
||||
let db = test_db();
|
||||
seed(
|
||||
&db,
|
||||
&[(
|
||||
"u1",
|
||||
"report_playback_stopped",
|
||||
"ep1",
|
||||
Some(r#"{"position_ticks": 42}"#),
|
||||
"pending",
|
||||
0,
|
||||
"2026-08-01T10:00:00Z",
|
||||
)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let report = drain_sync_queue(&db, &RecordingSink::always_rejecting(), "u1")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.deferred, 1);
|
||||
assert_eq!(report.remaining, 1);
|
||||
assert_eq!(row_state(&db, "ep1").await, ("failed".to_string(), 1));
|
||||
}
|
||||
|
||||
/// An unreachable server does not charge the row an attempt — otherwise
|
||||
/// opening the app offline a few times abandons perfectly good rows.
|
||||
///
|
||||
/// TRACES: UR-025 | DR-131 | UT-122
|
||||
#[tokio::test]
|
||||
async fn test_unreachable_server_does_not_burn_the_retry_budget() {
|
||||
let db = test_db();
|
||||
seed(
|
||||
&db,
|
||||
&[(
|
||||
"u1",
|
||||
"mark_played",
|
||||
"ep1",
|
||||
None,
|
||||
"pending",
|
||||
MAX_SYNC_ATTEMPTS - 1,
|
||||
"2026-08-01T10:00:00Z",
|
||||
)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let report = drain_sync_queue(&db, &RecordingSink::unreachable(), "u1")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.deferred, 1);
|
||||
assert_eq!(report.abandoned, 0);
|
||||
assert_eq!(
|
||||
row_state(&db, "ep1").await,
|
||||
("pending".to_string(), MAX_SYNC_ATTEMPTS - 1),
|
||||
"still queued, with its budget intact"
|
||||
);
|
||||
}
|
||||
|
||||
/// A row that can never succeed must eventually leave the queue, or the
|
||||
/// count climbs forever — which is the bug this module exists to fix.
|
||||
///
|
||||
/// TRACES: UR-025 | DR-131 | UT-122
|
||||
#[tokio::test]
|
||||
async fn test_drain_abandons_a_row_after_max_attempts() {
|
||||
let db = test_db();
|
||||
seed(
|
||||
&db,
|
||||
&[(
|
||||
"u1",
|
||||
"report_playback_stopped",
|
||||
"doomed",
|
||||
Some(r#"{"position_ticks": 1}"#),
|
||||
"failed",
|
||||
MAX_SYNC_ATTEMPTS - 1,
|
||||
"2026-08-01T10:00:00Z",
|
||||
)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let report = drain_sync_queue(&db, &RecordingSink::always_rejecting(), "u1")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.abandoned, 1);
|
||||
assert_eq!(report.remaining, 0, "an abandoned row stops being counted");
|
||||
assert_eq!(row_state(&db, "doomed").await.0, "abandoned");
|
||||
}
|
||||
|
||||
/// An operation this build cannot push does not wedge the queue behind it.
|
||||
///
|
||||
/// TRACES: UR-025 | DR-131 | UT-122
|
||||
#[tokio::test]
|
||||
async fn test_unsupported_operation_records_a_reason_and_lets_others_through() {
|
||||
let db = test_db();
|
||||
seed(
|
||||
&db,
|
||||
&[
|
||||
(
|
||||
"u1",
|
||||
"playlist_reorder_item",
|
||||
"pl1",
|
||||
None,
|
||||
"pending",
|
||||
0,
|
||||
"2026-08-01T10:00:00Z",
|
||||
),
|
||||
(
|
||||
"u1",
|
||||
"mark_played",
|
||||
"ep1",
|
||||
None,
|
||||
"pending",
|
||||
0,
|
||||
"2026-08-01T10:01:00Z",
|
||||
),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let sink = RecordingSink::new();
|
||||
let report = drain_sync_queue(&db, &sink, "u1").await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
sink.calls(),
|
||||
vec![QueuedOp::MarkPlayed {
|
||||
item_id: "ep1".to_string()
|
||||
}],
|
||||
"the unsupported row must not block the ones behind it"
|
||||
);
|
||||
assert_eq!(report.pushed, 1);
|
||||
assert_eq!(report.deferred, 1);
|
||||
assert_eq!(row_state(&db, "pl1").await, ("failed".to_string(), 1));
|
||||
}
|
||||
|
||||
/// Another user's queued changes are not pushed with this user's token.
|
||||
///
|
||||
/// TRACES: UR-025 | DR-131 | UT-122
|
||||
#[tokio::test]
|
||||
async fn test_drain_only_touches_the_given_user() {
|
||||
let db = test_db();
|
||||
seed(
|
||||
&db,
|
||||
&[
|
||||
(
|
||||
"u1",
|
||||
"mark_played",
|
||||
"mine",
|
||||
None,
|
||||
"pending",
|
||||
0,
|
||||
"2026-08-01T10:00:00Z",
|
||||
),
|
||||
(
|
||||
"u2",
|
||||
"mark_played",
|
||||
"theirs",
|
||||
None,
|
||||
"pending",
|
||||
0,
|
||||
"2026-08-01T10:00:00Z",
|
||||
),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let sink = RecordingSink::new();
|
||||
drain_sync_queue(&db, &sink, "u1").await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
sink.calls(),
|
||||
vec![QueuedOp::MarkPlayed {
|
||||
item_id: "mine".to_string()
|
||||
}]
|
||||
);
|
||||
assert_eq!(row_state(&db, "theirs").await.0, "pending");
|
||||
}
|
||||
|
||||
/// Nothing queued means no server calls at all — a reconnect must not
|
||||
/// generate traffic just because it happened.
|
||||
///
|
||||
/// TRACES: UR-025 | DR-131 | UT-122
|
||||
#[tokio::test]
|
||||
async fn test_drain_is_a_noop_when_the_queue_is_empty() {
|
||||
let db = test_db();
|
||||
let sink = RecordingSink::new();
|
||||
|
||||
let report = drain_sync_queue(&db, &sink, "u1").await.unwrap();
|
||||
|
||||
assert_eq!(report, DrainReport::default());
|
||||
assert!(sink.calls().is_empty());
|
||||
}
|
||||
|
||||
/// Both payload dialects parse: `position_ticks` from the Rust reporter and
|
||||
/// camelCase `positionMs` from the frontend's queue helper.
|
||||
///
|
||||
/// TRACES: UR-025 | DR-131 | UT-122
|
||||
#[test]
|
||||
fn test_parse_accepts_both_payload_dialects() {
|
||||
assert_eq!(
|
||||
parse_queued_op(
|
||||
"report_playback_stopped",
|
||||
Some("ep1"),
|
||||
Some(r#"{"position_ticks": 1234}"#)
|
||||
)
|
||||
.unwrap(),
|
||||
QueuedOp::PlaybackStopped {
|
||||
item_id: "ep1".to_string(),
|
||||
position_ticks: 1234
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parse_queued_op("update_progress", Some("ep1"), Some(r#"{"positionMs": 5}"#)).unwrap(),
|
||||
QueuedOp::PlaybackStopped {
|
||||
item_id: "ep1".to_string(),
|
||||
position_ticks: 50_000
|
||||
},
|
||||
"milliseconds convert to ticks"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parse_queued_op("mark_played", Some("ep1"), None).unwrap(),
|
||||
QueuedOp::MarkPlayed {
|
||||
item_id: "ep1".to_string()
|
||||
},
|
||||
"a payload-less operation is not an error"
|
||||
);
|
||||
|
||||
assert!(parse_queued_op("mark_played", None, None).is_err());
|
||||
assert!(parse_queued_op("teleport", Some("ep1"), None).is_err());
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ mod credentials;
|
||||
mod domain;
|
||||
mod download;
|
||||
mod jellyfin;
|
||||
mod media_server;
|
||||
mod playback_mode;
|
||||
mod playback_reporting;
|
||||
mod player;
|
||||
@@ -85,6 +86,7 @@ use commands::{
|
||||
lms_unsync_player,
|
||||
mark_download_completed,
|
||||
mark_download_failed,
|
||||
media_local_url,
|
||||
offline_get_items,
|
||||
offline_is_available,
|
||||
offline_search,
|
||||
@@ -273,6 +275,7 @@ use commands::{
|
||||
sync_mark_completed,
|
||||
sync_mark_failed,
|
||||
sync_mark_processing,
|
||||
sync_process_pending,
|
||||
// Sync queue commands
|
||||
sync_queue_mutation,
|
||||
thumbnail_clear_cache,
|
||||
@@ -806,6 +809,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
get_download_storage_stats,
|
||||
mark_download_completed,
|
||||
mark_download_failed,
|
||||
media_local_url,
|
||||
start_download,
|
||||
enqueue_download,
|
||||
enqueue_video_downloads,
|
||||
@@ -846,6 +850,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
sync_mark_completed,
|
||||
sync_mark_failed,
|
||||
sync_get_pending_count,
|
||||
sync_process_pending,
|
||||
sync_cleanup_completed,
|
||||
sync_clear_user,
|
||||
// Thumbnail cache and image commands
|
||||
@@ -1238,6 +1243,22 @@ pub fn run() {
|
||||
let smart_cache_wrapper = SmartCacheWrapper(Mutex::new(smart_cache));
|
||||
app.manage(smart_cache_wrapper);
|
||||
|
||||
// Serve downloaded media over loopback HTTP. The webview cannot
|
||||
// stream a large file through the asset protocol (see media_server),
|
||||
// so local playback resolves its URL from here instead.
|
||||
// TRACES: UR-071 | DR-137
|
||||
info!("[INIT] Starting local media server...");
|
||||
let media_server = match media_server::start(app_data_dir.clone()) {
|
||||
Ok(s) => Some(s),
|
||||
Err(e) => {
|
||||
// Not fatal: streaming still works, and the command reports
|
||||
// a clear error if local playback is attempted.
|
||||
error!("[INIT ERROR] Local media server failed to start: {}", e);
|
||||
None
|
||||
}
|
||||
};
|
||||
app.manage(media_server::MediaServerWrapper(media_server));
|
||||
|
||||
// Initialize download manager
|
||||
info!("[INIT] Initializing download manager...");
|
||||
let download_dir = app_data_dir.join("downloads");
|
||||
@@ -1319,6 +1340,13 @@ pub fn run() {
|
||||
info!("[INIT] Starting favourites drain...");
|
||||
commands::favorites::spawn_favorites_drain(app.handle().clone());
|
||||
|
||||
// Push playback reports queued while the server was unreachable.
|
||||
// Without this the `sync_queue` rows the reporter writes offline
|
||||
// are never sent and the offline banner's count only grows.
|
||||
// TRACES: UR-025, UR-002 | DR-131
|
||||
info!("[INIT] Starting sync-queue drain...");
|
||||
commands::sync_drain::spawn_sync_queue_drain(app.handle().clone());
|
||||
|
||||
info!("[INIT] Application setup completed successfully");
|
||||
Ok(())
|
||||
})
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
//! A loopback HTTP server for locally downloaded media.
|
||||
//!
|
||||
//! Tauri's built-in `asset` protocol cannot serve a downloaded film to the
|
||||
//! webview. Its response to a request *without* a `Range` header reads the whole
|
||||
//! file into a `Vec<u8>`, and it only advertises `Accept-Ranges: bytes` from
|
||||
//! inside the range branch — so the first request never learns ranges are
|
||||
//! available and a multi-gigabyte body is attempted instead. Chromium abandoned
|
||||
//! it with `PIPELINE_ERROR_READ` after ~31s, which reached the user as
|
||||
//! "downloaded video does not play offline".
|
||||
//!
|
||||
//! Serving over real HTTP on 127.0.0.1 rather than a custom URI scheme is
|
||||
//! deliberate: it makes range support a property of the transport instead of
|
||||
//! depending on whether a platform's webview forwards `Range` to a custom
|
||||
//! scheme, which differs between Android and the desktop webviews.
|
||||
//!
|
||||
//! Two things confine it, because **loopback is shared between apps on
|
||||
//! Android** — any other installed app can connect to this port:
|
||||
//!
|
||||
//! - it binds `127.0.0.1` only, so nothing off-device can reach it; and
|
||||
//! - every URL carries a random per-session token, so another app cannot guess a
|
||||
//! working URL, and paths are confined to the app data directory even if one
|
||||
//! did.
|
||||
//!
|
||||
//! Phase 1 serves local files only. The same origin is the intended home for
|
||||
//! remote passthrough (and download-while-watching) later; see the stage-2 spec.
|
||||
//!
|
||||
//! TRACES: UR-071 | DR-137 | UT-127
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use log::{debug, error, info, warn};
|
||||
use rand::Rng;
|
||||
use tiny_http::{Header, Response, Server, StatusCode};
|
||||
|
||||
/// Bytes per response. Large enough that a film needs relatively few round
|
||||
/// trips, small enough that one response is never a memory problem on a phone.
|
||||
/// Tauri's asset protocol uses 1 MiB; 4 MiB quarters the request count for the
|
||||
/// multi-gigabyte files this exists to serve.
|
||||
const CHUNK_LEN: u64 = 4 * 1024 * 1024;
|
||||
|
||||
/// Managed state. `None` when the server could not bind — local playback then
|
||||
/// fails with a clear error instead of the app refusing to start.
|
||||
pub struct MediaServerWrapper(pub Option<MediaServer>);
|
||||
|
||||
/// A running server. Dropping this does not stop the thread; the server lives
|
||||
/// for the life of the process by design, since playback can start at any time.
|
||||
pub struct MediaServer {
|
||||
port: u16,
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl MediaServer {
|
||||
/// The base a media URL is built on, e.g. `http://127.0.0.1:53412/<token>`.
|
||||
pub fn base_url(&self) -> String {
|
||||
format!("http://127.0.0.1:{}/{}", self.port, self.token)
|
||||
}
|
||||
|
||||
/// A playable URL for an absolute on-disk path.
|
||||
pub fn url_for(&self, path: &str) -> String {
|
||||
format!("{}/{}", self.base_url(), urlencoding::encode(path))
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind to an ephemeral loopback port and start serving `root` in a background
|
||||
/// thread.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-137
|
||||
pub fn start(root: PathBuf) -> Result<MediaServer, String> {
|
||||
// Port 0 → the OS picks a free one. Binding 127.0.0.1 (not 0.0.0.0) keeps
|
||||
// this off the network.
|
||||
let server =
|
||||
Server::http("127.0.0.1:0").map_err(|e| format!("Failed to bind media server: {e}"))?;
|
||||
|
||||
let port = server
|
||||
.server_addr()
|
||||
.to_ip()
|
||||
.ok_or_else(|| "Media server bound to a non-IP address".to_string())?
|
||||
.port();
|
||||
|
||||
let token: String = {
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..32)
|
||||
.map(|_| char::from_digit(rng.gen_range(0..16), 16).unwrap())
|
||||
.collect()
|
||||
};
|
||||
|
||||
info!(
|
||||
"[MediaServer] Serving {} on 127.0.0.1:{}",
|
||||
root.display(),
|
||||
port
|
||||
);
|
||||
|
||||
let server = Arc::new(server);
|
||||
let shared = Arc::new((root, token.clone()));
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("media-server".into())
|
||||
.spawn(move || loop {
|
||||
let request = match server.recv() {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("[MediaServer] accept failed: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let shared = Arc::clone(&shared);
|
||||
// A thread per request: media clients open several connections at
|
||||
// once, and a blocking read of one must not stall the others.
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
.name("media-server-req".into())
|
||||
.spawn(move || {
|
||||
let (root, token) = &*shared;
|
||||
handle(request, root, token);
|
||||
})
|
||||
{
|
||||
error!("[MediaServer] could not spawn handler: {e}");
|
||||
}
|
||||
})
|
||||
.map_err(|e| format!("Failed to start media server thread: {e}"))?;
|
||||
|
||||
Ok(MediaServer { port, token })
|
||||
}
|
||||
|
||||
fn header(name: &str, value: &str) -> Header {
|
||||
Header::from_bytes(name.as_bytes(), value.as_bytes())
|
||||
.expect("static header name/value are valid")
|
||||
}
|
||||
|
||||
fn empty(status: u16) -> Response<std::io::Empty> {
|
||||
Response::empty(StatusCode(status)).with_header(header("Accept-Ranges", "bytes"))
|
||||
}
|
||||
|
||||
fn handle(request: tiny_http::Request, root: &Path, token: &str) {
|
||||
let url = request.url().to_string();
|
||||
let method = request.method().as_str().to_string();
|
||||
|
||||
let outcome = match route(&url, root, token) {
|
||||
Ok(path) => path,
|
||||
Err(status) => {
|
||||
let _ = request.respond(empty(status));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if method != "GET" && method != "HEAD" {
|
||||
let _ = request.respond(empty(405));
|
||||
return;
|
||||
}
|
||||
|
||||
let mut file = match File::open(&outcome) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
warn!("[MediaServer] {}: {}", outcome.display(), e);
|
||||
let _ = request.respond(empty(404));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let len = match file.metadata() {
|
||||
Ok(m) => m.len(),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"[MediaServer] metadata failed for {}: {}",
|
||||
outcome.display(),
|
||||
e
|
||||
);
|
||||
let _ = request.respond(empty(404));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let range = request
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|h| h.field.equiv("Range"))
|
||||
.map(|h| h.value.as_str().to_string());
|
||||
|
||||
debug!(
|
||||
"[MediaServer] {} {} ({} bytes) range={:?}",
|
||||
method,
|
||||
outcome.display(),
|
||||
len,
|
||||
range
|
||||
);
|
||||
|
||||
let Some(span) = span_for(range.as_deref(), len) else {
|
||||
let _ = request
|
||||
.respond(empty(416).with_header(header("Content-Range", &format!("bytes */{len}"))));
|
||||
return;
|
||||
};
|
||||
|
||||
// Sniff before seeking to the span, for extension-less files.
|
||||
let mut head = [0u8; 16];
|
||||
let head_len = file.read(&mut head).unwrap_or(0);
|
||||
let mime = content_type(&outcome, &head[..head_len]);
|
||||
|
||||
if method == "HEAD" {
|
||||
let _ = request.respond(
|
||||
empty(200)
|
||||
.with_header(header("Content-Type", mime))
|
||||
.with_header(header("Content-Length", &len.to_string())),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = file.seek(SeekFrom::Start(span.start)) {
|
||||
warn!("[MediaServer] seek failed for {}: {}", outcome.display(), e);
|
||||
let _ = request.respond(empty(500));
|
||||
return;
|
||||
}
|
||||
|
||||
// Streamed straight from the file handle: at no point is more than the
|
||||
// span in memory, and the span is capped at CHUNK_LEN.
|
||||
let nbytes = span.len();
|
||||
let body = file.take(nbytes);
|
||||
// tiny_http switches to chunked transfer above a 32 KiB default, which drops
|
||||
// Content-Length — and a 206 without one is unusable to Chromium's media
|
||||
// loader, which needs the range's size. Raising the threshold past our own
|
||||
// cap keeps every response length-delimited.
|
||||
let response = Response::new(
|
||||
StatusCode(206),
|
||||
vec![
|
||||
header("Accept-Ranges", "bytes"),
|
||||
header("Content-Type", mime),
|
||||
header(
|
||||
"Content-Range",
|
||||
&format!("bytes {}-{}/{}", span.start, span.end, len),
|
||||
),
|
||||
],
|
||||
body,
|
||||
Some(nbytes as usize),
|
||||
None,
|
||||
)
|
||||
.with_chunked_threshold(usize::MAX);
|
||||
|
||||
if let Err(e) = request.respond(response) {
|
||||
// A client that seeks away closes the connection mid-body; that is
|
||||
// normal and must not be logged as a failure.
|
||||
debug!("[MediaServer] response ended early: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Check the token and resolve the path, or return the status to answer with.
|
||||
fn route(url: &str, root: &Path, token: &str) -> Result<PathBuf, u16> {
|
||||
let trimmed = url.trim_start_matches('/');
|
||||
let (got_token, rest) = trimmed.split_once('/').ok_or(404u16)?;
|
||||
|
||||
// Constant-time-ish: length check first, then a byte compare. The token is
|
||||
// the only thing standing between another app on the device and this server.
|
||||
if got_token.len() != token.len() || got_token != token {
|
||||
warn!("[MediaServer] Rejected a request with a bad token");
|
||||
return Err(403);
|
||||
}
|
||||
|
||||
// Strip any query string before decoding.
|
||||
let raw = rest.split('?').next().unwrap_or("");
|
||||
match resolve_path(raw, root) {
|
||||
Resolved::Allow(p) => Ok(p),
|
||||
Resolved::Forbidden => {
|
||||
warn!("[MediaServer] Refused a path outside the app data directory");
|
||||
Err(403)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a request path resolved to, before any file is touched.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Resolved {
|
||||
Allow(PathBuf),
|
||||
/// Escaped the allowed root.
|
||||
Forbidden,
|
||||
}
|
||||
|
||||
/// Resolve a percent-encoded request path to a file inside `root`.
|
||||
///
|
||||
/// `..` segments are folded away lexically rather than through `canonicalize`,
|
||||
/// so a missing file still resolves (and then 404s) instead of being reported as
|
||||
/// a scope violation.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-137 | UT-127
|
||||
pub fn resolve_path(raw: &str, root: &Path) -> Resolved {
|
||||
let decoded = match urlencoding::decode(raw) {
|
||||
Ok(d) => d.into_owned(),
|
||||
Err(_) => raw.to_string(),
|
||||
};
|
||||
|
||||
let mut normalised = PathBuf::new();
|
||||
for part in Path::new(&decoded).components() {
|
||||
match part {
|
||||
std::path::Component::ParentDir => {
|
||||
normalised.pop();
|
||||
}
|
||||
std::path::Component::CurDir => {}
|
||||
other => normalised.push(other),
|
||||
}
|
||||
}
|
||||
|
||||
if normalised.starts_with(root) {
|
||||
Resolved::Allow(normalised)
|
||||
} else {
|
||||
Resolved::Forbidden
|
||||
}
|
||||
}
|
||||
|
||||
/// The byte range a response should carry. `end` is inclusive.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Span {
|
||||
pub start: u64,
|
||||
pub end: u64,
|
||||
}
|
||||
|
||||
impl Span {
|
||||
pub fn len(&self) -> u64 {
|
||||
self.end + 1 - self.start
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide which span to send for a `Range` header (or its absence).
|
||||
///
|
||||
/// `None` means unsatisfiable — answer 416. A missing or unparseable header
|
||||
/// yields the first chunk, so a client that did not ask for a range still gets a
|
||||
/// bounded response it can continue from, which is exactly the case the asset
|
||||
/// protocol answered with the whole file.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-137 | UT-127
|
||||
pub fn span_for(range: Option<&str>, len: u64) -> Option<Span> {
|
||||
if len == 0 {
|
||||
return Some(Span { start: 0, end: 0 });
|
||||
}
|
||||
let last = len - 1;
|
||||
let first_chunk = Span {
|
||||
start: 0,
|
||||
end: (CHUNK_LEN - 1).min(last),
|
||||
};
|
||||
|
||||
let Some(raw) = range else {
|
||||
return Some(first_chunk);
|
||||
};
|
||||
let Some(spec) = raw.trim().strip_prefix("bytes=") else {
|
||||
return Some(first_chunk);
|
||||
};
|
||||
// Only the first range of a multi-range request is honoured; media clients
|
||||
// ask for one, and a single 206 is a valid answer either way.
|
||||
let spec = spec.split(',').next().unwrap_or("").trim();
|
||||
let Some((from, to)) = spec.split_once('-') else {
|
||||
return Some(first_chunk);
|
||||
};
|
||||
|
||||
let (start, end) = if from.is_empty() {
|
||||
// Suffix form: `-500` is the final 500 bytes.
|
||||
let suffix: u64 = match to.parse() {
|
||||
Ok(n) => n,
|
||||
Err(_) => return Some(first_chunk),
|
||||
};
|
||||
if suffix == 0 {
|
||||
return None;
|
||||
}
|
||||
(len.saturating_sub(suffix), last)
|
||||
} else {
|
||||
let start: u64 = match from.parse() {
|
||||
Ok(n) => n,
|
||||
Err(_) => return Some(first_chunk),
|
||||
};
|
||||
let end = if to.is_empty() {
|
||||
last
|
||||
} else {
|
||||
match to.parse::<u64>() {
|
||||
Ok(n) => n.min(last),
|
||||
Err(_) => return Some(first_chunk),
|
||||
}
|
||||
};
|
||||
(start, end)
|
||||
};
|
||||
|
||||
if start > last || end < start {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Span {
|
||||
start,
|
||||
end: end.min(start + CHUNK_LEN - 1),
|
||||
})
|
||||
}
|
||||
|
||||
/// Guess a content type.
|
||||
///
|
||||
/// **Magic bytes win over the extension.** Downloading at `original` quality
|
||||
/// asks Jellyfin for a direct static copy, which returns the *source file's*
|
||||
/// bytes under a `.mp4` name whatever the real container is — a downloaded film
|
||||
/// named `.mp4` turned out to be an AVI holding XVID. Trusting the extension
|
||||
/// there labels it `video/mp4` and the player is handed a container that is not
|
||||
/// what the header claims. The extension is only a fallback for a file whose
|
||||
/// bytes are unrecognised, and for the extension-less files the offline queue
|
||||
/// writes under an item id.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-137 | UT-127
|
||||
fn content_type(path: &Path, head: &[u8]) -> &'static str {
|
||||
// `ftyp` at offset 4 marks an ISO base media file (mp4 and friends).
|
||||
if head.len() > 11 && &head[4..8] == b"ftyp" {
|
||||
return "video/mp4";
|
||||
}
|
||||
if head.len() > 11 && head.starts_with(b"RIFF") && &head[8..11] == b"AVI" {
|
||||
return "video/x-msvideo";
|
||||
}
|
||||
if head.starts_with(b"\x1aE\xdf\xa3") {
|
||||
return "video/x-matroska";
|
||||
}
|
||||
if head.starts_with(b"ID3") || head.starts_with(b"\xff\xfb") {
|
||||
return "audio/mpeg";
|
||||
}
|
||||
if head.starts_with(b"OggS") {
|
||||
return "audio/ogg";
|
||||
}
|
||||
if head.starts_with(b"fLaC") {
|
||||
return "audio/flac";
|
||||
}
|
||||
|
||||
match path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_ascii_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("mp4" | "m4v" | "mov") => return "video/mp4",
|
||||
Some("mkv") => return "video/x-matroska",
|
||||
Some("webm") => return "video/webm",
|
||||
Some("mp3") => return "audio/mpeg",
|
||||
Some("m4a" | "aac") => return "audio/mp4",
|
||||
Some("flac") => return "audio/flac",
|
||||
Some("ogg" | "opus") => return "audio/ogg",
|
||||
Some("wav") => return "audio/wav",
|
||||
Some("avi") => return "video/x-msvideo",
|
||||
_ => {}
|
||||
}
|
||||
|
||||
"application/octet-stream"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The whole point: a request with no `Range` must still come back bounded.
|
||||
/// That is the case Tauri's asset protocol answers with the entire file —
|
||||
/// the read Chromium abandoned after 31s.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-137 | UT-127
|
||||
#[test]
|
||||
fn a_rangeless_request_is_answered_with_one_chunk_not_the_file() {
|
||||
let huge = 8 * 1024 * 1024 * 1024; // 8 GiB
|
||||
let span = span_for(None, huge).unwrap();
|
||||
assert_eq!(span.start, 0);
|
||||
assert_eq!(span.len(), CHUNK_LEN);
|
||||
}
|
||||
|
||||
/// TRACES: UR-071 | DR-137 | UT-127
|
||||
#[test]
|
||||
fn no_response_ever_exceeds_one_chunk() {
|
||||
let len = 8 * 1024 * 1024 * 1024;
|
||||
for h in [
|
||||
"bytes=0-",
|
||||
"bytes=0-99999999999",
|
||||
"bytes=1024-",
|
||||
"bytes=-99999999",
|
||||
] {
|
||||
let span = span_for(Some(h), len).unwrap();
|
||||
assert!(span.len() <= CHUNK_LEN, "{h} produced {} bytes", span.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: UR-071 | DR-137 | UT-127
|
||||
#[test]
|
||||
fn ranges_are_honoured() {
|
||||
let len = 1000u64;
|
||||
assert_eq!(
|
||||
span_for(Some("bytes=100-199"), len).unwrap(),
|
||||
Span {
|
||||
start: 100,
|
||||
end: 199
|
||||
}
|
||||
);
|
||||
// Open-ended runs to the end of a small file.
|
||||
assert_eq!(
|
||||
span_for(Some("bytes=900-"), len).unwrap(),
|
||||
Span {
|
||||
start: 900,
|
||||
end: 999
|
||||
}
|
||||
);
|
||||
// Suffix form.
|
||||
assert_eq!(
|
||||
span_for(Some("bytes=-100"), len).unwrap(),
|
||||
Span {
|
||||
start: 900,
|
||||
end: 999
|
||||
}
|
||||
);
|
||||
// Past the end is unsatisfiable, not a clamp — a clamp would make a
|
||||
// seek past the end silently replay earlier bytes.
|
||||
assert!(span_for(Some("bytes=1000-"), len).is_none());
|
||||
}
|
||||
|
||||
/// A malformed header must not fail the request: playing from the start is
|
||||
/// strictly better than refusing to open the file.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-137 | UT-127
|
||||
#[test]
|
||||
fn a_malformed_range_falls_back_to_the_first_chunk() {
|
||||
assert_eq!(span_for(Some("pages=1-2"), 5000).unwrap().start, 0);
|
||||
assert_eq!(span_for(Some("bytes=abc-def"), 5000).unwrap().start, 0);
|
||||
}
|
||||
|
||||
/// TRACES: UR-071 | DR-137 | UT-127
|
||||
#[test]
|
||||
fn reads_are_confined_to_the_app_data_directory() {
|
||||
let root = Path::new("/data/user/0/app");
|
||||
|
||||
assert_eq!(
|
||||
resolve_path("/data/user/0/app/videos/f.mp4", root),
|
||||
Resolved::Allow(PathBuf::from("/data/user/0/app/videos/f.mp4"))
|
||||
);
|
||||
// Percent-encoded, as the URL builder produces.
|
||||
assert_eq!(
|
||||
resolve_path("%2Fdata%2Fuser%2F0%2Fapp%2Fa%20b.mp4", root),
|
||||
Resolved::Allow(PathBuf::from("/data/user/0/app/a b.mp4"))
|
||||
);
|
||||
// Traversal out of the root, and an unrelated absolute path, are refused.
|
||||
assert_eq!(
|
||||
resolve_path("/data/user/0/app/../../../etc/passwd", root),
|
||||
Resolved::Forbidden
|
||||
);
|
||||
assert_eq!(resolve_path("/etc/passwd", root), Resolved::Forbidden);
|
||||
}
|
||||
|
||||
/// Loopback is shared between apps on Android, so the token is the only
|
||||
/// thing stopping another installed app from reading downloaded media.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-137 | UT-127
|
||||
#[test]
|
||||
fn a_request_without_the_right_token_is_refused() {
|
||||
let root = Path::new("/data/user/0/app");
|
||||
let good = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
assert_eq!(
|
||||
route(
|
||||
&format!("/{good}/%2Fdata%2Fuser%2F0%2Fapp%2Ff.mp4"),
|
||||
root,
|
||||
good
|
||||
),
|
||||
Ok(PathBuf::from("/data/user/0/app/f.mp4"))
|
||||
);
|
||||
assert_eq!(
|
||||
route("/wrong-token/%2Fdata%2Fuser%2F0%2Fapp%2Ff.mp4", root, good),
|
||||
Err(403)
|
||||
);
|
||||
// No token segment at all.
|
||||
assert_eq!(route("/f.mp4", root, good), Err(404));
|
||||
// Right token, but a path outside the root is still refused.
|
||||
assert_eq!(
|
||||
route(&format!("/{good}/%2Fetc%2Fpasswd"), root, good),
|
||||
Err(403)
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: UR-071 | DR-137 | UT-127
|
||||
#[test]
|
||||
fn content_type_uses_the_extension_then_the_magic_bytes() {
|
||||
assert_eq!(content_type(Path::new("/a/f.mp4"), &[]), "video/mp4");
|
||||
assert_eq!(content_type(Path::new("/a/f.mp3"), &[]), "audio/mpeg");
|
||||
// A `.mp4` that is really an AVI: downloading at `original` quality
|
||||
// copies the source bytes under an mp4 name, so the extension lies and
|
||||
// the magic bytes must win.
|
||||
let avi_head = b"RIFF\xcc\xf3\xbc\x2bAVI LIST";
|
||||
assert_eq!(
|
||||
content_type(Path::new("/a/film.mp4"), avi_head),
|
||||
"video/x-msvideo"
|
||||
);
|
||||
// Extension-less, as the offline queue writes them: sniff instead.
|
||||
let mp4_head = b"\x00\x00\x00\x20ftypisom\x00\x00\x02\x00";
|
||||
assert_eq!(content_type(Path::new("/a/abc123"), mp4_head), "video/mp4");
|
||||
assert_eq!(
|
||||
content_type(Path::new("/a/abc123"), b"ID3\x03junk"),
|
||||
"audio/mpeg"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3248,6 +3248,9 @@ mod tests {
|
||||
async fn clear_watch_history(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn mark_played(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_person(
|
||||
&self,
|
||||
_: &str,
|
||||
|
||||
@@ -26,7 +26,7 @@ const MAX_SUPPORTED_AUDIO_CHANNELS: u32 = 8;
|
||||
/// A missing or zero reading means "route not established yet", not "no audio":
|
||||
/// fall back to stereo rather than claiming a capability we have not seen.
|
||||
///
|
||||
/// TRACES: UR-004 | DR-141 | UT-131
|
||||
/// TRACES: UR-004 | DR-141 | UT-141
|
||||
pub fn clamp_max_audio_channels(reported: Option<u32>) -> u32 {
|
||||
match reported {
|
||||
Some(channels) if channels >= 1 => channels.min(MAX_SUPPORTED_AUDIO_CHANNELS),
|
||||
@@ -37,7 +37,7 @@ pub fn clamp_max_audio_channels(reported: Option<u32>) -> u32 {
|
||||
/// The channel cap for this device, reading the platform's report where one
|
||||
/// exists.
|
||||
///
|
||||
/// TRACES: UR-004 | DR-141 | UT-131
|
||||
/// TRACES: UR-004 | DR-141 | UT-141
|
||||
pub fn max_audio_channels() -> u32 {
|
||||
#[cfg(target_os = "android")]
|
||||
let reported = crate::player::get_detected_codecs().and_then(|(_, _, channels)| channels);
|
||||
|
||||
@@ -812,6 +812,11 @@ impl MediaRepository for HybridRepository {
|
||||
self.online.clear_watch_history(item_id).await
|
||||
}
|
||||
|
||||
async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
// Write operations go directly to server
|
||||
self.online.mark_played(item_id).await
|
||||
}
|
||||
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
@@ -1237,6 +1242,10 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -1507,6 +1516,10 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
@@ -235,6 +235,14 @@ pub trait MediaRepository: Send + Sync {
|
||||
/// TRACES: UR-064 | DR-106
|
||||
async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError>;
|
||||
|
||||
/// Mark an item played — the inverse of `clear_watch_history`. Needed by the
|
||||
/// sync-queue drain, which replays `mark_played` rows queued while the
|
||||
/// server was unreachable; reporting a stop at a made-up position was the
|
||||
/// previous stand-in and does not set the played flag reliably.
|
||||
///
|
||||
/// TRACES: UR-025 | DR-131 | JA-035
|
||||
async fn mark_played(&self, item_id: &str) -> Result<(), RepoError>;
|
||||
|
||||
/// Get person details
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError>;
|
||||
|
||||
|
||||
@@ -2050,6 +2050,12 @@ impl MediaRepository for OfflineRepository {
|
||||
Err(RepoError::Offline)
|
||||
}
|
||||
|
||||
async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||
// Offline the local flag is written by `storage_mark_played` and the
|
||||
// server half is queued in `sync_queue`; this path has no server.
|
||||
Err(RepoError::Offline)
|
||||
}
|
||||
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let query = Query::with_params(
|
||||
"SELECT id, name, overview, primary_image_tag
|
||||
|
||||
@@ -1893,6 +1893,48 @@ impl MediaRepository for OnlineRepository {
|
||||
result
|
||||
}
|
||||
|
||||
/// `POST /Users/{userId}/PlayedItems/{itemId}` — the mirror image of
|
||||
/// `clear_watch_history`.
|
||||
///
|
||||
/// TRACES: UR-025 | DR-131 | JA-035
|
||||
async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
let endpoint = format!("/Users/{}/PlayedItems/{}", self.user_id, item_id);
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
|
||||
let result = async {
|
||||
let request = self
|
||||
.http_client
|
||||
.client
|
||||
.post(&url)
|
||||
.header("X-Emby-Authorization", self.auth_header())
|
||||
.header("Content-Length", "0")
|
||||
.build()
|
||||
.map_err(|e| RepoError::Network {
|
||||
message: format!("Failed to build request: {}", e),
|
||||
})?;
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.request_with_retry(request)
|
||||
.await
|
||||
.map_err(|e| RepoError::Network {
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(RepoError::Server {
|
||||
message: format!("HTTP {}", response.status()),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
self.report_outcome(&result).await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let endpoint = format!("/Users/{}/Items/{}", self.user_id, person_id);
|
||||
let item: JellyfinItem = self.get_json(&endpoint).await?;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "jellytau",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.6",
|
||||
"identifier": "com.dtourolle.jellytau",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
||||
Reference in New Issue
Block a user