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.
72 lines
2.9 KiB
TypeScript
72 lines
2.9 KiB
TypeScript
/**
|
|
* Choosing between a downloaded file and a server stream for video playback.
|
|
*
|
|
* Audio has preferred local files since the queue is built (the Rust queue
|
|
* resolves `MediaSource::Local`), but video asks the repository for a stream URL
|
|
* and never consults `downloads` — so a downloaded film was streamed anyway,
|
|
* spending bandwidth that had already been spent and failing outright offline.
|
|
*
|
|
* Pure so it can be unit-tested: the component only supplies the two inputs and
|
|
* the asset-URL converter.
|
|
*
|
|
* TRACES: UR-071 | DR-123 | UT-118
|
|
*/
|
|
|
|
export interface VideoSourceInputs {
|
|
/** Absolute on-disk path of a completed download, or null to stream. */
|
|
localPath: string | null;
|
|
/** Stream URL the repository resolved (already transcoded if it had to be). */
|
|
remoteUrl: string;
|
|
/** Whether the *remote* stream is a transcode. */
|
|
remoteNeedsTranscoding: boolean;
|
|
/** Usually Tauri's `convertFileSrc`; injected so this module stays pure. */
|
|
toAssetUrl: (path: string) => string;
|
|
}
|
|
|
|
export interface VideoSourceDecision {
|
|
/** What to hand the `<video>` element. */
|
|
url: string;
|
|
/**
|
|
* Local files are never transcodes, so this is always false for them. It
|
|
* matters because the transcoded path re-requests a whole new stream URL on
|
|
* every seek; a local file seeks natively and must not go down that route.
|
|
*/
|
|
needsTranscoding: boolean;
|
|
/** True when playing from disk — for logging and the offline badge. */
|
|
isLocal: boolean;
|
|
}
|
|
|
|
/** Absolute on POSIX (`/…`), Windows (`C:\…`, `C:/…`) or a UNC share (`\\…`). */
|
|
function isAbsolute(path: string): boolean {
|
|
return path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(path);
|
|
}
|
|
|
|
/**
|
|
* The on-disk path of a row in the `downloads` store.
|
|
*
|
|
* `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. Joining
|
|
* it onto the storage root a second time produced
|
|
* `/data/user/0/app//data/user/0/app/videos/x.mp4`; the asset protocol could not
|
|
* open that, so offline video failed with `MEDIA_ERR_SRC_NOT_SUPPORTED` while
|
|
* audio, which resolves the same column through Rust, played fine.
|
|
*
|
|
* TRACES: UR-071 | DR-133 | UT-124
|
|
*/
|
|
export function downloadedFilePath(storageRoot: string, filePath: string): string {
|
|
return isAbsolute(filePath) ? filePath : `${storageRoot}/${filePath}`;
|
|
}
|
|
|
|
export function resolveVideoSource(inputs: VideoSourceInputs): VideoSourceDecision {
|
|
const { localPath, remoteUrl, remoteNeedsTranscoding, toAssetUrl } = inputs;
|
|
|
|
// Treat blank/whitespace paths as absent — a malformed `downloads` row must
|
|
// not produce an asset URL pointing at nothing.
|
|
if (localPath && localPath.trim() !== "") {
|
|
return { url: toAssetUrl(localPath), needsTranscoding: false, isLocal: true };
|
|
}
|
|
|
|
return { url: remoteUrl, needsTranscoding: remoteNeedsTranscoding, isLocal: false };
|
|
}
|