Offline video never started: the <video> element reported NETWORK_NO_SOURCE one millisecond after loadstart, which the UI mislabelled as "may need transcoding" even though nothing had been fetched. Two independent causes, both required for playback. The path was doubled. `downloads.file_path` is stored relative to the storage root while a download is queued, but the worker rewrites it to the absolute path it actually wrote once the transfer completes — so a completed row is already rooted. The player's offline branch rooted it a second time, producing /data/user/0/app//data/user/0/app/videos/x.mp4. Audio was unaffected because it resolves the same column through Rust's resolve_local_media_path, which does not re-root. The join is now absolute-aware (POSIX, Windows drive letters, UNC) so rows written before completion still resolve. The asset protocol was never enabled. convertFileSrc rewrites a path to http://asset.localhost/… unconditionally, but Tauri only answers that origin when the protocol-asset cargo feature is compiled in *and* app.security.assetProtocol.enable is set — neither was, so even a correct path resolved to nothing. This also silently defeated the cached-thumbnail path in imageCache, which fails soft to the server copy and so hid the breakage whenever the server was reachable. Scoped to $APPDATA/** — the storage root holding the database, downloads/ and the thumbnail cache — rather than an unrestricted grant. Diagnosed from logcat on device; UT-124 reproduces the doubled path.
103 lines
3.7 KiB
TypeScript
103 lines
3.7 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { downloadedFilePath, resolveVideoSource } from "./localSource";
|
|
|
|
describe("downloadedFilePath", () => {
|
|
// The download worker rewrites `downloads.file_path` to the absolute path it
|
|
// actually wrote once the transfer completes, so a completed row is already
|
|
// rooted. Joining it onto the storage root again produced
|
|
// `/data/user/0/app//data/user/0/app/videos/x.mp4`, which the asset protocol
|
|
// cannot open — offline video died with MEDIA_ERR_SRC_NOT_SUPPORTED while
|
|
// audio, which resolves the same column through Rust, played fine.
|
|
it("leaves a completed download's absolute path alone", () => {
|
|
const root = "/data/user/0/com.dtourolle.jellytau";
|
|
const stored = `${root}/videos/Taming of the Shrew.mp4`;
|
|
|
|
expect(downloadedFilePath(root, stored)).toBe(stored);
|
|
});
|
|
|
|
it("roots a path that is still relative to the storage directory", () => {
|
|
// Rows only hold a relative path before the worker completes them, but a
|
|
// half-migrated database can still carry one.
|
|
expect(downloadedFilePath("/var/data/jellytau", "videos/film.mp4")).toBe(
|
|
"/var/data/jellytau/videos/film.mp4"
|
|
);
|
|
});
|
|
|
|
it("leaves an absolute Windows path alone", () => {
|
|
const stored = "C:\\Users\\u\\AppData\\jellytau\\videos\\film.mp4";
|
|
|
|
expect(downloadedFilePath("C:\\Users\\u\\AppData\\jellytau", stored)).toBe(stored);
|
|
});
|
|
});
|
|
|
|
// A stand-in for Tauri's convertFileSrc, so the module stays pure.
|
|
const toAssetUrl = (p: string) => `asset://localhost/${encodeURIComponent(p)}`;
|
|
|
|
describe("resolveVideoSource", () => {
|
|
it("plays the downloaded file when one exists", () => {
|
|
const decision = resolveVideoSource({
|
|
localPath: "/home/u/.local/share/jellytau/movie.mp4",
|
|
remoteUrl: "https://server/Videos/abc/master.m3u8",
|
|
remoteNeedsTranscoding: true,
|
|
toAssetUrl,
|
|
});
|
|
|
|
expect(decision.isLocal).toBe(true);
|
|
expect(decision.url).toBe(toAssetUrl("/home/u/.local/share/jellytau/movie.mp4"));
|
|
});
|
|
|
|
it("never marks a local file as needing transcoding, even when the remote did", () => {
|
|
// The transcoded path re-requests a whole new stream URL on every seek.
|
|
// A local file seeks natively; sending it down that route would ask the
|
|
// server for a stream we deliberately avoided.
|
|
const decision = resolveVideoSource({
|
|
localPath: "/downloads/film.mkv",
|
|
remoteUrl: "https://server/Videos/abc/master.m3u8",
|
|
remoteNeedsTranscoding: true,
|
|
toAssetUrl,
|
|
});
|
|
|
|
expect(decision.needsTranscoding).toBe(false);
|
|
});
|
|
|
|
it("streams when nothing is downloaded, preserving the transcoding flag", () => {
|
|
const decision = resolveVideoSource({
|
|
localPath: null,
|
|
remoteUrl: "https://server/Videos/abc/master.m3u8",
|
|
remoteNeedsTranscoding: true,
|
|
toAssetUrl,
|
|
});
|
|
|
|
expect(decision).toEqual({
|
|
url: "https://server/Videos/abc/master.m3u8",
|
|
needsTranscoding: true,
|
|
isLocal: false,
|
|
});
|
|
});
|
|
|
|
it("streams a direct-play remote without claiming it transcodes", () => {
|
|
const decision = resolveVideoSource({
|
|
localPath: null,
|
|
remoteUrl: "https://server/Videos/abc/stream.mp4",
|
|
remoteNeedsTranscoding: false,
|
|
toAssetUrl,
|
|
});
|
|
|
|
expect(decision.needsTranscoding).toBe(false);
|
|
expect(decision.isLocal).toBe(false);
|
|
});
|
|
|
|
it("falls back to streaming for a blank path rather than building a dead asset URL", () => {
|
|
for (const localPath of ["", " "]) {
|
|
const decision = resolveVideoSource({
|
|
localPath,
|
|
remoteUrl: "https://server/stream",
|
|
remoteNeedsTranscoding: false,
|
|
toAssetUrl,
|
|
});
|
|
expect(decision.isLocal).toBe(false);
|
|
expect(decision.url).toBe("https://server/stream");
|
|
}
|
|
});
|
|
});
|