possible freeze fix, small issue fixed, file listing performance improvements
MREADME.md
@@ -40,6 +40,7 @@ If you choose to not run this via the include Containerfile, you need to ensure
You can configure the backend with the following environment variables:
- `COVER_REGEX`: JS-compatible regex to match filenames in a directory to find a matching cover art. Searches the directory structure upwards. Set to an empty string to disable cover detection.
- `EXCLUDE_EXTENSION`: comma-separated list of of file extensions to ignore completely, e.g. `txt,log,nfo`. If unset, uses a safe set of text files common in downloaded music archives. Useful because some files can get mis-scanned, e.g. some CD-scan .log files get scanned as mp1
- `SCAN_CONCURRENCY`: maximum number of files probed in parallel while scanning uncached folders (also the size of the internal MediaInfo instance pool). Defaults to `8`; invalid or non-positive values fall back to the default.
- `AUTH`: Same format as the string used for basic auth (username and password joined by a colon `:` and encoded to base64). If set, the server APIs return 401 unless the users is signed in with the given login details. Basic assets are not protected, so the UI itself will load fine even when not logged in, so when you get signed out for whatever reason (e.g. by the server restarting), you can still access the UI and play already synced files without problems.
## Non-Goals
Mserver/src/ffmpeg.ts
@@ -1,6 +1,6 @@
import { AudioCodec, MediaContainer, VideoCodec, VideoEncodingSetting } from "music-server-shared/types";
import { AudioCodec, MediaContainer, type Metadata, VideoCodec, VideoEncodingSetting } from "music-server-shared/types";
import { createRFC6381String } from "music-server-shared/utils";
import { analyzeWithMediainfo, readStream } from "./utils";
import { readStream } from "./utils";
function secondsToFfmpeg(seconds: number): string {
// Ensure seconds is non-negative
@@ -35,6 +35,7 @@ export async function convertWithFFmpeg(
requestedVideoEncodingSetting: VideoEncodingSetting,
seekTo: number | undefined,
languages: string,
metadata: Metadata,
) {
const mimeType = createRFC6381String(container, requestedVideoCodec, requestedAudioCodec);
@@ -87,7 +88,7 @@ export async function convertWithFFmpeg(
let videoCodec = undefined;
let extraVideoSettings = undefined;
//only convert video if transcoding is requesting so
if (videoCodec !== VideoCodec.none) {
if (requestedVideoCodec !== VideoCodec.none) {
extraVideoSettings = [
//make sure pixel format is browser compatible
"-pix_fmt",
@@ -110,13 +111,13 @@ export async function convertWithFFmpeg(
videoEncodingSettings.push("-preset", "8");
break;
case VideoEncodingSetting.fast:
videoEncodingSettings.concat("-preset", "7");
videoEncodingSettings.push("-preset", "7");
break;
case VideoEncodingSetting.balanced:
videoEncodingSettings.concat("-preset", "6");
videoEncodingSettings.push("-preset", "6");
break;
case VideoEncodingSetting.quality:
videoEncodingSettings.concat("-preset", "5");
videoEncodingSettings.push("-preset", "5");
break;
default: {
throw new Error(`Unsupported video encoding setting: ${requestedVideoEncodingSetting}`);
@@ -125,19 +126,19 @@ export async function convertWithFFmpeg(
break;
case VideoCodec.h264:
videoCodec = "libx264"; //TODO: support hardware encoding
videoEncodingSettings.concat(["-profile", "high"]);
videoEncodingSettings.push("-profile", "high");
switch (requestedVideoEncodingSetting) {
case VideoEncodingSetting.veryfast:
videoEncodingSettings.push("-preset", "veryfast");
break;
case VideoEncodingSetting.fast:
videoEncodingSettings.concat("-preset", "fast");
videoEncodingSettings.push("-preset", "fast");
break;
case VideoEncodingSetting.balanced:
videoEncodingSettings.concat("-preset", "medium");
videoEncodingSettings.push("-preset", "medium");
break;
case VideoEncodingSetting.quality:
videoEncodingSettings.concat("-preset", "slow");
videoEncodingSettings.push("-preset", "slow");
break;
default: {
throw new Error(`Unsupported video encoding setting: ${requestedVideoEncodingSetting}`);
@@ -146,19 +147,19 @@ export async function convertWithFFmpeg(
break;
case VideoCodec.h265:
videoCodec = "libx265"; //TODO: support hardware encoding
videoEncodingSettings.concat(["-profile", "main"]);
videoEncodingSettings.push("-profile", "main");
switch (requestedVideoEncodingSetting) {
case VideoEncodingSetting.veryfast:
videoEncodingSettings.push("-preset", "veryfast");
break;
case VideoEncodingSetting.fast:
videoEncodingSettings.concat("-preset", "fast");
videoEncodingSettings.push("-preset", "fast");
break;
case VideoEncodingSetting.balanced:
videoEncodingSettings.concat("-preset", "medium");
videoEncodingSettings.push("-preset", "medium");
break;
case VideoEncodingSetting.quality:
videoEncodingSettings.concat("-preset", "slow");
videoEncodingSettings.push("-preset", "slow");
break;
default: {
throw new Error(`Unsupported video encoding setting: ${requestedVideoEncodingSetting}`);
@@ -169,16 +170,16 @@ export async function convertWithFFmpeg(
videoCodec = "libvpx-vp9";
switch (requestedVideoEncodingSetting) {
case VideoEncodingSetting.veryfast:
videoEncodingSettings.concat("-deadline", "realtime", "-cpu-used", "5");
videoEncodingSettings.push("-deadline", "realtime", "-cpu-used", "5");
break;
case VideoEncodingSetting.fast:
videoEncodingSettings.concat("-deadline", "realtime", "-cpu-used", "4");
videoEncodingSettings.push("-deadline", "realtime", "-cpu-used", "4");
break;
case VideoEncodingSetting.balanced:
videoEncodingSettings.concat("-deadline", "good", "-cpu-used", "2");
videoEncodingSettings.push("-deadline", "good", "-cpu-used", "2");
break;
case VideoEncodingSetting.quality:
videoEncodingSettings.concat("-deadline", "good", "-cpu-used", "2");
videoEncodingSettings.push("-deadline", "good", "-cpu-used", "2");
break;
default: {
throw new Error(`Unsupported video encoding setting: ${requestedVideoEncodingSetting}`);
@@ -189,22 +190,21 @@ export async function convertWithFFmpeg(
}
const seekParams = seekTo === undefined ? [] : ["-ss", secondsToFfmpeg(seekTo)];
const info = await analyzeWithMediainfo(filePath); //analyze video file with mediainfo to get video duration and other metadata
let selectedAudioTrack = 0; //0 as fallback if no language matches and no default is set
const languageArray = languages.split(",").map((s) => s.toLocaleLowerCase());
if (languageArray.length > 0) {
const tracks = (info.media?.track || []).filter((track) => track["@type"] === "Audio");
const tracks = metadata.audioTracks || [];
let highestPriorityMatch = Number.POSITIVE_INFINITY;
// Check each track for preferred languages in priority order
for (let i = 0; i < tracks.length; i++) {
const track = tracks[i];
// Store the first default track as fallback
if (track.Default === "Yes" && selectedAudioTrack === 0) {
if (track.default && selectedAudioTrack === 0) {
selectedAudioTrack = i;
}
// Check if this track's language matches any preference
const langIndex = track.Language ? languageArray.indexOf(track.Language.toLocaleLowerCase()) : -1;
const langIndex = track.language ? languageArray.indexOf(track.language.toLocaleLowerCase()) : -1;
// If this is a higher priority match than we've seen before, take it
if (langIndex >= 0 && langIndex < highestPriorityMatch) {
@@ -229,7 +229,6 @@ export async function convertWithFFmpeg(
.concat(["-map", "0:v:0"])
: ["-vn"],
)
.concat(videoEncodingSettings)
.concat(["-map", `0:a:${selectedAudioTrack.toString()}?`])
.concat(["-c:a", audioCodec, "-b:a", audioBitrate.toString(), "-ac", "2"])
.concat(["-f", container, "-"]);
Mserver/src/index.ts
@@ -1,34 +1,34 @@
import { stat } from "node:fs/promises";
import path, { basename } from "node:path";
import staticPlugin from "@elysiajs/static";
import { Elysia, type InferHandler, t } from "elysia";
import path, { basename } from "node:path";
import {
musicRoot,
fileTypeCache,
ServerError,
allowedTypes,
probeCache,
mediaTypes,
args,
authTokens,
fileTypeCache,
generatedPlaylistIds,
username,
mediaTypes,
musicRoot,
password,
authTokens,
args,
probeCache,
username,
} from "./shared";
import { getPathInfo, listFiles, findCover, matchesType, packWithTar, probeFile, toAvif } from "./utils";
import { stat } from "node:fs/promises";
import { findCover, getPathInfo, listFiles, matchesType, packWithTar, probeFile, toAvif } from "./utils";
import parseRange from "range-parser";
import { decodePath } from "music-server-shared/utils";
import { stringifyMap } from "music-server-shared/mapconversion";
import { randomUUIDv7 } from "bun";
import { stringifyMap } from "music-server-shared/mapconversion";
import {
AudioCodec,
type IsVideoResponse,
MediaContainer,
type Metadata,
VideoCodec,
VideoEncodingSetting,
type IsVideoResponse,
} from "music-server-shared/types";
import { decodePath } from "music-server-shared/utils";
import parseRange from "range-parser";
import { convertWithFFmpeg } from "./ffmpeg";
//TODO: transcoding cache?
@@ -141,6 +141,7 @@ const app = setup
query.videoEncodingSetting || VideoEncodingSetting.balanced,
query.seekTo,
query.languages || "",
probe,
);
request.signal.addEventListener("abort", () => cmd.kill("SIGKILL"));
set.headers["content-type"] = mimeType;
Mserver/src/utils.ts
@@ -1,23 +1,24 @@
import { readFileSync, writeFileSync } from "node:fs";
import { readdir, stat } from "node:fs/promises";
import path from "node:path";
import { StatusMap } from "elysia";
import { fileTypeFromFile } from "file-type";
import MediaInfo from "mediainfo.js";
import { parseFile } from "music-metadata";
import type { FileListingWithStatus, Metadata } from "music-server-shared/types";
import { readdir, stat } from "node:fs/promises";
import path from "node:path";
import tmp from "tmp";
import {
PathInfo,
ServerError,
args,
coverRegex,
excludeExtension,
fileTypeCache,
mediaTypes,
musicRoot,
PathInfo,
probeCache,
ServerError,
scanConcurrency,
} from "./shared";
import { parseFile } from "music-metadata";
import tmp from "tmp";
import { readFileSync, writeFileSync } from "node:fs";
import MediaInfo from "mediainfo.js";
export async function findAsync<T>(arr: Array<T>, asyncCallback: (value: T) => Promise<boolean>) {
const promises = arr.map(asyncCallback);
@@ -28,15 +29,48 @@ export async function findAsync<T>(arr: Array<T>, asyncCallback: (value: T) => P
type CoverResult = { info: PathInfo; path: string } | { info: PathInfo; content: Uint8Array } | { info: ServerError };
const mediaInfo = await MediaInfo({
locateFile: () => `${args.serve}/MediaInfoModule.wasm`,
});
//MediaInfo is overloaded, so infer the instance type from an actual construction call
function createMediaInfoInstance() {
return MediaInfo({
locateFile: () => `${args.serve}/MediaInfoModule.wasm`,
});
}
type MediaInfoInstance = Awaited<ReturnType<typeof createMediaInfoInstance>>;
//analyzeData is a stateful async sequence, so concurrent analyses on one instance corrupt each other.
//Keep a small pool of instances (lazily created up to scanConcurrency) and hand them out one at a time.
class MediaInfoPool {
private available: MediaInfoInstance[] = [];
private created = 0;
private waitQueue: ((instance: MediaInfoInstance) => void)[] = [];
constructor(private maxSize: number) {}
async acquire(): Promise<MediaInfoInstance> {
const existing = this.available.pop();
if (existing) return existing;
if (this.created < this.maxSize) {
this.created++;
return await createMediaInfoInstance();
}
return new Promise((resolve) => this.waitQueue.push(resolve));
}
release(instance: MediaInfoInstance): void {
const next = this.waitQueue.shift();
if (next) next(instance);
else this.available.push(instance);
}
}
const mediaInfoPool = new MediaInfoPool(scanConcurrency);
export async function findCover(targetPath: string): Promise<CoverResult> {
try {
if ((await stat(targetPath)).isFile()) {
//TODO
const parsed = await parseFile(targetPath, { duration: true, skipCovers: false });
//duration is not needed for cover extraction and would force a full-file parse for some formats
const parsed = await parseFile(targetPath, { duration: false, skipCovers: false });
if (!parsed.common.picture || parsed.common.picture.length === 0) return findCover(path.dirname(targetPath));
return { info: new PathInfo(parsed.common.picture[0].format), content: parsed.common.picture[0].data };
@@ -80,51 +114,76 @@ export async function analyzeWithMediainfo(filePath: string) {
return await file.slice(offset, offset + size).bytes();
};
return await mediaInfo.analyzeData(fileSize, chunkReader);
const mediaInfo = await mediaInfoPool.acquire();
try {
return await mediaInfo.analyzeData(fileSize, chunkReader);
} finally {
mediaInfoPool.release(mediaInfo);
}
}
//in-flight probes keyed by path, so concurrent callers for the same uncached file share one analysis
const inflightProbes = new Map<string, Promise<Metadata>>();
export async function probeFile(filePath: string): Promise<Metadata> {
const cached = probeCache.get(filePath);
if (cached) return cached;
const info = await analyzeWithMediainfo(filePath);
// for (const track of info.media?.track || []) {
// console.log(track);
// }
const generalTrack = info.media?.track.find((track) => track["@type"] === "General");
const videoTrack = info.media?.track.find((track) => track["@type"] === "Video");
const audioTrack = info.media?.track.find((track) => track["@type"] === "Audio");
if (!generalTrack?.Duration) throw new Error();
let probeResult: Metadata = {
duration: generalTrack?.Duration,
title: generalTrack?.Title,
artist: generalTrack?.Performer || generalTrack?.Album_Performer,
album: generalTrack?.Album,
genre: [generalTrack?.Genre || ""],
trackNumber: generalTrack?.Track_Position,
container: generalTrack?.Format,
audioCodec: audioTrack?.Format,
audioBitrate: audioTrack?.BitRate,
videoCodec: videoTrack?.Format,
videoBitrate: videoTrack?.BitRate,
};
const inflight = inflightProbes.get(filePath);
if (inflight) return inflight;
const promise = (async (): Promise<Metadata> => {
const info = await analyzeWithMediainfo(filePath);
// for (const track of info.media?.track || []) {
// console.log(track);
// }
const generalTrack = info.media?.track.find((track) => track["@type"] === "General");
const videoTrack = info.media?.track.find((track) => track["@type"] === "Video");
const audioTrack = info.media?.track.find((track) => track["@type"] === "Audio");
const audioTracks = (info.media?.track || [])
.filter((track) => track["@type"] === "Audio")
.map((track) => ({ language: track.Language, default: track.Default === "Yes" }));
if (!generalTrack?.Duration) throw new Error();
let probeResult: Metadata = {
duration: generalTrack?.Duration,
title: generalTrack?.Title,
artist: generalTrack?.Performer || generalTrack?.Album_Performer,
album: generalTrack?.Album,
genre: [generalTrack?.Genre || ""],
//if file has broken encodings in their metadata, the "string" are actually objects, and contain this "@dt" field
//music-metadata can usually parse the correctly anyway, so use the library in that case
if (Object.values(probeResult).some((field) => typeof field === "object" && "@dt" in field)) {
const parsed = await parseFile(filePath, { duration: true, skipCovers: false, mkvUseIndex: true });
probeResult = {
...probeResult,
title: parsed.common.title,
artist: parsed.common.artist,
album: parsed.common.album,
genre: parsed.common.genre,
trackNumber: parsed.common.track.no || undefined,
trackNumber: generalTrack?.Track_Position,
container: generalTrack?.Format,
audioCodec: audioTrack?.Format,
audioBitrate: audioTrack?.BitRate,
videoCodec: videoTrack?.Format,
videoBitrate: videoTrack?.BitRate,
audioTracks,
};
//if file has broken encodings in their metadata, the "string" are actually objects, and contain this "@dt" field
//music-metadata can usually parse the correctly anyway, so use the library in that case
if (Object.values(probeResult).some((field) => typeof field === "object" && "@dt" in field)) {
const parsed = await parseFile(filePath, { duration: true, skipCovers: false, mkvUseIndex: true });
probeResult = {
...probeResult,
title: parsed.common.title,
artist: parsed.common.artist,
album: parsed.common.album,
genre: parsed.common.genre,
trackNumber: parsed.common.track.no || undefined,
};
}
//only cache on success so a rejected probe can be retried later
probeCache.set(filePath, probeResult);
return probeResult;
})();
inflightProbes.set(filePath, promise);
try {
return await promise;
} finally {
inflightProbes.delete(filePath);
}
probeCache.set(filePath, probeResult);
return probeResult;
}
export async function getPathInfo(filePath: string): Promise<PathInfo | ServerError | undefined> {
@@ -169,31 +228,34 @@ export async function listFiles(subPath: string, recursive: boolean): Promise<Fi
const files: FileListingWithStatus = new Map();
try {
for (const fileName of await readdir(subPath)) {
const itemPath = path.join(subPath, fileName);
if (!isBelow(musicRoot, itemPath)) {
console.log(`Skipping ${itemPath} as it is outside the base path.`);
continue;
}
const pathInfoResult = await getPathInfo(itemPath);
if (!pathInfoResult) {
if (recursive) {
const subListing = await listFiles(itemPath, true);
if (subListing instanceof ServerError) throw subListing;
files.set(fileName, { files: subListing, status: "Scanned" });
} else {
files.set(fileName, { files: new Map(), status: "Unknown" });
//process entries concurrently; the MediaInfo pool already bounds the expensive probing part
await Promise.all(
(await readdir(subPath)).map(async (fileName) => {
const itemPath = path.join(subPath, fileName);
if (!isBelow(musicRoot, itemPath)) {
console.log(`Skipping ${itemPath} as it is outside the base path.`);
return;
}
} else if (pathInfoResult instanceof PathInfo && matchesType(pathInfoResult.mimeType, mediaTypes)) {
try {
const probe = await probeFile(itemPath);
files.set(fileName, { metadata: probe });
} catch (probeError) {
console.error("probing error:", probeError);
const pathInfoResult = await getPathInfo(itemPath);
if (!pathInfoResult) {
if (recursive) {
const subListing = await listFiles(itemPath, true);
if (subListing instanceof ServerError) throw subListing;
files.set(fileName, { files: subListing, status: "Scanned" });
} else {
files.set(fileName, { files: new Map(), status: "Unknown" });
}
} else if (pathInfoResult instanceof PathInfo && matchesType(pathInfoResult.mimeType, mediaTypes)) {
try {
const probe = await probeFile(itemPath);
files.set(fileName, { metadata: probe });
} catch (probeError) {
console.error("probing error:", probeError);
}
}
}
}
}),
);
return files;
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
} catch (error: any) {
@@ -266,7 +328,7 @@ export async function toAvif(
setContentType: (mimeType: string) => void,
): Promise<Uint8Array> {
const tmpFile = tmp.fileSync({
postfix: mimeType.includes("image/jpeg") ? ".jpg" : mimeType.includes("image/jpeg") ? ".png" : undefined,
postfix: mimeType.includes("image/jpeg") ? ".jpg" : mimeType.includes("image/png") ? ".png" : undefined,
});
const tmpFile2 = tmp.fileSync({ postfix: ".avif" });
try {
Mwebclient/src/App.tsx
@@ -1,9 +1,22 @@
import { createSignal, createEffect, onMount, on, createResource, batch } from "solid-js";
import {
AudioCodec,
type Directory,
MediaContainer,
type MediaFile,
VideoCodec,
VideoEncodingSetting,
} from "music-server-shared/types";
import { createRFC6381String, decodePath, encodePath } from "music-server-shared/utils";
import { batch, createEffect, createResource, createSignal, on, onMount } from "solid-js";
import type { Component } from "solid-js";
import { type PlaylistItem, type PlayerState, type AppOptions, FileDisplay } from "./types";
import { createStore, reconcile } from "solid-js/store";
import { BUILD_HASH } from "./build-info";
import DownloadManager from "./components/DownloadManager";
import FileBrowser from "./components/FileBrowser";
import OptionsModal from "./components/OptionsModal";
import PlayerControls from "./components/PlayerControls";
import FileBrowser from "./components/FileBrowser";
import Playlist from "./components/Playlist";
import PlaylistManager from "./components/PlaylistManager";
import {
ClosedFolderSVG,
CogSVG,
@@ -13,23 +26,10 @@ import {
OpenFolderSVG,
PlaylistSVG,
} from "./components/SVG";
import { formatFilename, LocalStorageValues, toast } from "./utils";
import Playlist from "./components/Playlist";
import { getCover, getSrc, listFiles } from "./offline";
import {
AudioCodec,
MediaContainer,
type Directory,
type MediaFile,
VideoCodec,
VideoEncodingSetting,
} from "music-server-shared/types";
import PlaylistManager from "./components/PlaylistManager";
import { BUILD_HASH } from "./build-info";
import { createStore, reconcile } from "solid-js/store";
import { createRFC6381String, decodePath, encodePath } from "music-server-shared/utils";
import DownloadManager from "./components/DownloadManager";
import { SignIn } from "./components/SignIn";
import { getCover, getSrc, listFiles } from "./offline";
import { type AppOptions, FileDisplay, type PlayerState, type PlaylistItem } from "./types";
import { LocalStorageValues, formatFilename, toast } from "./utils";
//TODO: bypass music-metadata if it fails, maybe only use for fallback for files with weird encodings
//TODO: more file display options
@@ -135,11 +135,18 @@ const App: Component = () => {
videoElement.style.display = videoShown() ? "block" : "none";
});
//incremented on every playTrack call so a slow/older invocation can detect it has been superseded
//and bail before clobbering the newer track's state
let playGeneration = 0;
function stopPlaying() {
//invalidate any in-flight playTrack so a stale invocation bails instead of restarting playback
playGeneration++;
batch(() => {
setPlayerState("isPlaying", false);
setPlayerState("currentTime", 0);
setPlayerState("currentTrack", null);
setPlayerState("waitingForStart", false);
});
videoElement.pause();
@@ -259,7 +266,8 @@ const App: Component = () => {
try {
mediaSourceAbortController = new AbortController();
const response = await fetch(srcUrl, { credentials: "same-origin", signal: mediaSourceAbortController.signal });
const signal = mediaSourceAbortController.signal;
const response = await fetch(srcUrl, { credentials: "same-origin", signal });
if (!response.ok || !response.body) throw new Error("Failed to fetch stream");
const reader = response.body.getReader();
@@ -274,25 +282,42 @@ const App: Component = () => {
//keep only 30 seconds of buffer ahead
function waitForBufferLimit() {
return new Promise((resolve) => {
//shared cleanup so whichever of timeupdate/abort fires first removes both listeners
const cleanup = () => {
videoElement.removeEventListener("timeupdate", cb);
signal.removeEventListener("abort", onAbort);
};
//after stopPlaying pauses the element, timeupdate never fires again, so also resolve on abort
//to avoid parking the append loop forever holding the reader/SourceBuffer
const onAbort = () => {
cleanup();
resolve(undefined);
};
const cb = () => {
try {
if (
sourceBuffer.buffered.length === 0 ||
sourceBuffer.buffered.end(0) - videoElement.currentTime < 30
) {
cleanup();
resolve(undefined);
videoElement.removeEventListener("timeupdate", cb);
return true;
}
return false;
} catch (e) {
videoElement.removeEventListener("timeupdate", cb);
cleanup();
resolve(undefined);
}
};
//if already aborted, don't wait for a timeupdate that will never come
if (signal.aborted) {
resolve(undefined);
return;
}
//check once immediately before starting the event listener
if (!cb()) {
videoElement.addEventListener("timeupdate", cb);
signal.addEventListener("abort", onAbort, { once: true });
}
});
}
@@ -357,14 +382,11 @@ const App: Component = () => {
//without this, seeking via mse will cause an infinite loop
let ignoreSeek = false;
//incremented on every playTrack call so a slow/older invocation can detect it has been superseded
//and bail before clobbering the newer track's state
let playGeneration = 0;
// Play a track from the playlist
async function playTrack(track: PlaylistItem, seekTo?: number) {
const generation = ++playGeneration;
try {
const { src, isVideo } = await getSrc(track.path, options, isOffline(), seekTo || 0);
const { src, isVideo } = await getSrc(track.path, track.metadata, options, isOffline(), seekTo || 0);
if (!src || generation !== playGeneration) return;
//Cleanup
if (videoElement.src.startsWith("blob:")) URL.revokeObjectURL(videoElement.src);
Mwebclient/src/offline.ts
@@ -1,21 +1,20 @@
import Dexie, { liveQuery } from "dexie";
import { parseMap } from "music-server-shared/mapconversion";
import type {
MediaFile,
Directory,
DirectoryWithStatus,
FileListing,
FileListingWithStatus,
DirectoryWithStatus,
MediaFile,
Metadata,
IsVideoResponse,
} from "music-server-shared/types";
import { parseMap } from "music-server-shared/mapconversion";
import type { AppOptions, Playlist } from "./types";
import { joinPath, toast, unproxy } from "./utils";
import { batch, from, untrack, type ResourceActions } from "solid-js";
import { AsyncSemaphore } from "./semaphore";
import { encodePath } from "music-server-shared/utils";
import type { FlatFileListing } from "./App";
import { type ResourceActions, batch, from, untrack } from "solid-js";
import { createStore } from "solid-js/store";
import Dexie, { liveQuery } from "dexie";
import type { FlatFileListing } from "./App";
import { AsyncSemaphore } from "./semaphore";
import type { AppOptions, Playlist } from "./types";
import { joinPath, toast, unproxy } from "./utils";
interface StoredFile {
path: string;
@@ -350,12 +349,20 @@ export async function getCover(
//returns an object url to the blob if synced, otherwise the server url
export async function getSrc(
path: string,
metadata: Metadata,
options: AppOptions,
offline: boolean,
seekTo: number,
forceUrl?: true,
): Promise<{ src: string; isVideo?: boolean }>;
export async function getSrc(path: string, options: AppOptions, offline: boolean, seekTo: number, forceUrl?: boolean) {
export async function getSrc(
path: string,
metadata: Metadata,
options: AppOptions,
offline: boolean,
seekTo: number,
forceUrl?: boolean,
) {
if (!forceUrl) {
const dbResult = await db.files.get(path);
if (dbResult) return { src: URL.createObjectURL(dbResult.data), isVideo: undefined };
@@ -366,14 +373,8 @@ export async function getSrc(path: string, options: AppOptions, offline: boolean
}
}
if (options.enableTranscoding) {
const isVideo = (
(await fetch(joinPath("isVideo", encodePath(path)))
.then((res) => res.json())
.catch((err) => {
console.error(err);
return { isVideo: false };
})) as IsVideoResponse
).isVideo;
//the server's own isVideo logic is just metadata.videoCodec !== undefined, so compute it locally
const isVideo = metadata.videoCodec !== undefined;
return {
src:
@@ -505,7 +506,7 @@ async function downloadFile(
const semaphorePromise = downloadSemaphore.acquire();
await Promise.race([abortPromise, semaphorePromise]);
try {
const res = await fetch((await getSrc(path, options, false, 0, true)).src, {
const res = await fetch((await getSrc(path, item.metadata, options, false, 0, true)).src, {
signal: controller.signal,
credentials: "same-origin",
});