file objects from arrays to maps
Mserver/src/index.ts
@@ -9,6 +9,7 @@ import { stat } from "node:fs/promises";
import parseRange from "range-parser";
import { createReadStream, ReadStream } from "node:fs";
import { MediaFormat } from "music-server-shared/types";
import { stringifyMap } from "music-server-shared/mapconversion";
//TODO: transcoding cache?
//TODO: add reasonable timeouts for caches
@@ -86,6 +87,7 @@ const downloadHandler: DownloadHandler = async ({ headers, params, set, request
const app = setup
.use(staticPlugin({ assets: values.serve, prefix: "/" }))
.onBeforeHandle(({ request, path }) => {
if (path === "/remote-log") return;
console.info(request.method, path);
})
.post("/reset-cache", () => {
@@ -132,10 +134,9 @@ const app = setup
set.status = fileList.status;
return fileList.error;
}
const json = JSON.stringify(fileList);
set.headers["Content-Type"] = "application/json";
set.status = "OK";
return json;
return stringifyMap(fileList);
},
{ query: t.Optional(t.Object({ recursive: t.Boolean() })) },
)
@@ -162,6 +163,9 @@ const app = setup
},
{ body: t.Object({ files: t.String({ minLength: 1 }) }) },
)
.post("remote-log", ({ body }) => {
console.log(body);
})
.listen(3000);
Mserver/src/utils.ts
@@ -1,9 +1,9 @@
import { StatusMap } from "elysia";
import { fileTypeFromFile } from "file-type";
import { MediaFormat, type Directory, type FileListing } from "music-server-shared/types";
import { type FileListingWithStatus, MediaFormat } from "music-server-shared/types";
import { readdir, stat } from "node:fs/promises";
import path from "node:path";
import { coverRegex, fileTypeCache, mediaTypes, musicRoot, PathInfo, probeCache, ServerError } from "./shared";
import { coverRegex, fileTypeCache, mediaTypes, musicRoot, PathInfo, ServerError } from "./shared";
import { parseFile } from "music-metadata";
export async function findAsync<T>(arr: Array<T>, asyncCallback: (value: T) => Promise<boolean>) {
@@ -90,12 +90,12 @@ export function isBelow(basePath: string, targetPath: string) {
return relativePath && !relativePath.startsWith("..");
}
export async function listFiles(subPath: string, recursive: boolean): Promise<Directory | ServerError> {
const files: FileListing = [];
export async function listFiles(subPath: string, recursive: boolean): Promise<FileListingWithStatus | ServerError> {
const files: FileListingWithStatus = new Map();
try {
for (const file of await readdir(subPath)) {
const itemPath = path.join(subPath, file);
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;
@@ -106,9 +106,9 @@ export async function listFiles(subPath: string, recursive: boolean): Promise<Di
if (recursive) {
const subListing = await listFiles(itemPath, true);
if (subListing instanceof ServerError) throw subListing;
files.push({ name: file, files: subListing.files });
files.set(fileName, { files: subListing, status: "Scanned" });
} else {
files.push({ name: file, files: [] });
files.set(fileName, { files: new Map(), status: "Unknown" });
}
} else if (pathInfoResult instanceof PathInfo && matchesType(pathInfoResult.mimeType, mediaTypes)) {
let duration = 0;
@@ -118,11 +118,10 @@ export async function listFiles(subPath: string, recursive: boolean): Promise<Di
} catch (probeError) {
console.error("probing error:", probeError);
}
files.push({ name: file, duration: duration });
files.set(fileName, { metadata: { duration } });
}
}
files.sort((a, b) => a.name.localeCompare(b.name));
return { name: path.basename(path.relative(musicRoot, subPath)), files };
return files;
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
} catch (error: any) {
if (error?.code === "ENOENT") {
Mserver/tsconfig.json
@@ -82,8 +82,8 @@
"strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
"noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
"useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
"alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
@@ -91,7 +91,7 @@
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
"noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
Mwebclient/src/App.tsx
@@ -12,10 +12,9 @@ import { MediaFormat } from "music-server-shared/types";
import PlaylistManager from "./components/PlaylistManager";
import { BUILD_HASH } from "./build-info";
//TODO: Rework FileListing to be a Map, update offline structures as well, paths as string[]
//TODO: search
//TODO: version display
//TODO: remember scroll position for parent directories
//TODO: fix touch highlighting in playlist
//TODO: better touch controls?
//TODO: Add documentation to functions
//TODO: allow syncing playlists to server
@@ -25,6 +24,7 @@ import { BUILD_HASH } from "./build-info";
//TODO: setting export/import
//TODO: video support
//TODO: virtual rendering for playlist
//TODO: custom serialization?
const App: Component = () => {
const [showFileBrowser, setShowFileBrowser] = createSignal(false);
Mwebclient/src/components/DownloadButton.tsx
@@ -1,5 +1,5 @@
import "./DownloadButton.css";
import type { AudioFile, Directory, FileListing } from "music-server-shared/types";
import type { MediaFile, Directory, FileListing } from "music-server-shared/types";
import { deleteFile, download, signalMap, type SignalMapEntry } from "../offline";
import { createSignal, type Accessor, type ResourceActions } from "solid-js";
import type { AppOptions } from "../types";
@@ -8,7 +8,8 @@ import { DeleteSVG, DownloadSVG, RefreshSVG } from "./SVG";
import { LoadingSpinner } from "./LoadingScreen";
interface DownloadButtonProps {
item: AudioFile | Directory;
fileName: string;
item: MediaFile | Directory;
fetchFiles: ResourceActions<FileListing>;
isOffline: Accessor<boolean>;
currentDir: Accessor<string>;
@@ -27,7 +28,7 @@ function formatProgress(bytes: number): string {
}
export function DownloadButton(props: DownloadButtonProps) {
const fullPath = joinPath(props.currentDir(), props.item.name);
const fullPath = joinPath(props.currentDir(), props.fileName);
let signal = signalMap.get(fullPath);
if (!signal) {
signal = createSignal<SignalMapEntry>();
@@ -40,8 +41,9 @@ export function DownloadButton(props: DownloadButtonProps) {
class="download-item"
onClick={(event) => {
event.stopPropagation();
if (!props.isOffline()) download(props.item, props.currentDir(), props.currentDir, props.options(), props.fetchFiles);
else deleteFile(props.item, props.currentDir(), props.fetchFiles);
if (!props.isOffline())
download(props.fileName, props.item, props.currentDir(), props.currentDir, props.options(), props.fetchFiles);
else deleteFile(props.fileName, props.item, props.currentDir(), props.fetchFiles);
}}
>
{(() => {
Mwebclient/src/components/FileBrowser.tsx
@@ -10,11 +10,11 @@ import {
Show,
Suspense,
} from "solid-js";
import type { AudioFile, FileListing } from "music-server-shared/types";
import type { AppOptions, PlaylistItem } from "../types";
import { type MediaFile, type FileListing, fileSort } from "music-server-shared/types";
import { getItemName, type AppOptions, type PlaylistItem } from "../types";
import { generateUUID, joinPath, LocalStorageValues, toast } from "../utils";
import { ClosedFolderSVG, MusicSVG, RefreshSVG } from "./SVG";
import { clearCoverCache, clearDirectoryCache, getCover, listFiles } from "../offline";
import { clearCoverCache as deleteCover, clearDirectoryCache, getCover, listFiles } from "../offline";
import { DownloadButton } from "./DownloadButton";
import { LoadingScreen } from "./LoadingScreen";
@@ -35,9 +35,13 @@ interface FileBrowserProps {
export default function FileBrowser(props: FileBrowserProps) {
const [loadingActions, setLoadingActions] = createSignal(0);
const [files, fetchFiles] = createResource(
//TODO: try using store
() => ({ dir: props.currentDir(), offline: props.isOffline() }),
async (args) => await listFiles(args.dir, args.offline, false),
{ initialValue: [] },
async (args) => {
//new map to force rerender
return new Map(await listFiles(args.dir, args.offline, false));
},
{ initialValue: new Map() },
);
const [cover, fetchCover] = createResource<string | undefined, { dir: string; offline: boolean }>(
() => ({ dir: props.currentDir(), offline: props.isOffline() }),
@@ -56,44 +60,39 @@ export default function FileBrowser(props: FileBrowserProps) {
}
}
function fileToPlaylistItem(basePath: string, item: AudioFile) {
const normpath = basePath === "" ? "" : `${basePath}/`;
function fileToPlaylistItem(path: string, item: MediaFile): PlaylistItem {
return {
id: generateUUID(),
name: item.name.split("/").pop() || item.name,
duration: item.duration,
path: `${normpath}${item.name.split("/").pop()}`,
metadata: item.metadata,
path: path,
};
}
// Recursively collect audio files
function collectAudioFiles(files: FileListing, basePath: string): PlaylistItem[] {
// Recursively convert file listing to PlaylistItem array
function listingToPlaylistItems(basePath: string, files: FileListing): PlaylistItem[] {
let result: PlaylistItem[] = [];
for (const file of files) {
if ("duration" in file) {
for (const [fileName, file] of files) {
const fullPath = joinPath(basePath, fileName);
if ("metadata" in file) {
// It's an audio file
result.push(fileToPlaylistItem(basePath, file));
} else if ("files" in file && file.files.length > 0) {
result.push(fileToPlaylistItem(fullPath, file));
} else if (file.files && file.files.size > 0) {
// It's a directory with files, recursively add its contents
const normpath = basePath === "" ? "" : `${basePath}/`;
const subPath = `${normpath}${file.name}`;
result = [...result, ...collectAudioFiles(file.files, subPath)];
result = [...result, ...listingToPlaylistItems(fullPath, file.files)];
}
}
return result;
return result.sort((a, b) => a.path.localeCompare(b.path, undefined, { numeric: true }));
}
function playFile(item: AudioFile) {
props.playTrack(fileToPlaylistItem(props.currentDir(), item));
function playFile(path: string, item: MediaFile) {
props.playTrack(fileToPlaylistItem(path, item));
}
function addToPlaylist(file: AudioFile, path: string) {
function addToPlaylist(path: string, file: MediaFile) {
const newItem: PlaylistItem = {
id: generateUUID(),
name: file.name,
duration: file.duration,
path: joinPath(path, file.name),
metadata: file.metadata,
path: path,
};
props.setPlaylist([...props.playlist(), newItem]);
@@ -109,7 +108,7 @@ export default function FileBrowser(props: FileBrowserProps) {
try {
const selectedDir = dir || props.currentDir();
const files = await listFiles(selectedDir, props.isOffline(), recursive);
const newPlaylist = collectAudioFiles(files, selectedDir);
const newPlaylist = listingToPlaylistItems(selectedDir, files);
const oldPlaylist = props.playlist();
if (queue) {
props.setPlaylist([...oldPlaylist, ...newPlaylist]);
@@ -127,7 +126,8 @@ export default function FileBrowser(props: FileBrowserProps) {
function reloadDirectory() {
clearDirectoryCache(props.currentDir());
clearCoverCache(props.currentDir());
//TODO: only clears the current dir, not recursively like clearDirectoryCache
deleteCover(props.currentDir());
fetchFiles.refetch();
fetchCover.refetch();
}
@@ -205,20 +205,23 @@ export default function FileBrowser(props: FileBrowserProps) {
</Show>
<div class="file-list">
<For each={files.latest}>
{(item) => {
const isDirectory = "files" in item;
const fullPath = joinPath(props.currentDir(), item.name);
<For each={files.latest.entries().toArray().sort(fileSort)}>
{([fileName, item]) => {
const isDirectory = !("metadata" in item);
const fullPath = joinPath(props.currentDir(), fileName);
return (
<>
<div
class={(isDirectory && "directory-item") || "file-item"}
onClick={() => (isDirectory && props.setCurrentDir(fullPath)) || playFile(item as AudioFile)}
onClick={() =>
(isDirectory && props.setCurrentDir(fullPath)) || playFile(fullPath, item as MediaFile)
}
>
{(isDirectory && <ClosedFolderSVG forButton={false} />) || <MusicSVG />}
<span>{item.name}</span>
<span>{fileName}</span>
<div class="spacer" />
<DownloadButton
fileName={fileName}
item={item}
fetchFiles={fetchFiles}
isOffline={props.isOffline}
@@ -232,7 +235,7 @@ export default function FileBrowser(props: FileBrowserProps) {
if (isDirectory) {
playFiles(true, true, fullPath);
} else {
addToPlaylist(item as AudioFile, props.currentDir());
addToPlaylist(fullPath, item as MediaFile);
}
}}
>
Mwebclient/src/components/PlayerControls.tsx
@@ -1,5 +1,5 @@
import "./PlayerControls.css";
import type { PlayerState } from "../types";
import { getItemName, type PlayerState } from "../types";
import { type Accessor, createEffect, createSignal, type Setter, Show } from "solid-js";
import { NextTrackSVG, PauseSVG, PlaySVG, PreviousTrackSVG, RepeatSVG, ShuffleSVG, VolumeSVG } from "./SVG";
import { formatTime } from "../utils";
@@ -47,7 +47,7 @@ export default function PlayerControls(props: PlayerControlsProps) {
const rect = progressBar.getBoundingClientRect();
const pos = (e.clientX - rect.left) / rect.width;
const duration = playerState.currentTrack.duration;
const duration = playerState.currentTrack.metadata.duration;
props.audioElement.currentTime = Math.min(pos * duration, playerState.buffered - 1);
props.setPlayerState({
...playerState,
@@ -57,7 +57,7 @@ export default function PlayerControls(props: PlayerControlsProps) {
return (
<div class="player-controls">
<div class="track-info">{props.playerState().currentTrack?.name || "No track selected"}</div>
<div class="track-info">{getItemName(props.playerState().currentTrack) || "No track selected"}</div>
<div class="progress-container">
<div class="progress-bar" onClick={seekTo} style={controlsDisabled() ? { cursor: "default" } : {}}>
<div
@@ -68,7 +68,7 @@ export default function PlayerControls(props: PlayerControlsProps) {
? Math.min(
100,
(props.playerState().buffered /
(props.playerState().currentTrack?.duration ?? Number.POSITIVE_INFINITY)) *
(props.playerState().currentTrack?.metadata.duration ?? Number.POSITIVE_INFINITY)) *
100,
)
: 0
@@ -83,7 +83,7 @@ export default function PlayerControls(props: PlayerControlsProps) {
? Math.min(
100,
(props.playerState().currentTime /
(props.playerState().currentTrack?.duration ?? Number.POSITIVE_INFINITY)) *
(props.playerState().currentTrack?.metadata.duration ?? Number.POSITIVE_INFINITY)) *
100,
)
: 0
@@ -94,7 +94,7 @@ export default function PlayerControls(props: PlayerControlsProps) {
</div>
<div class="time-container">
<span>{formatTime(props.playerState().currentTime)}</span>/
<span>{formatTime(props.playerState().currentTrack?.duration || 0)}</span>
<span>{formatTime(props.playerState().currentTrack?.metadata.duration || 0)}</span>
</div>
<div class="control-buttons">
Mwebclient/src/components/Playlist.tsx
@@ -1,6 +1,6 @@
import { createSelector, type Accessor, type Setter } from "solid-js";
import "./Playlist.css";
import type { PlayerState, PlaylistItem } from "../types";
import { getItemName, type PlayerState, type PlaylistItem } from "../types";
import { formatTime, toast } from "../utils";
import { ClearSVG, DownloadSVG, DragSVG, SaveSVG } from "./SVG";
import { savePlaylist } from "../offline";
@@ -135,20 +135,20 @@ export default function Playlist(props: PlaylistProps) {
<div class="playlist-drag-btn" {...dragHandlers}>
<DragSVG />
</div>
<span>{item.name}</span>
<span>{getItemName(item)}</span>
<div class="spacer" />
{formatTime(item.duration)}
{formatTime(item.metadata.duration)}
<button
title={"Download this file in its original format to your file system"}
onClick={(e) => {
e.stopPropagation();
browserDownload(item.name, item.path);
browserDownload(getItemName(item), item.path);
}}
>
<DownloadSVG />
</button>
<button
title={`Remove ${item.name}`}
title={`Remove ${getItemName(item)}`}
onClick={(e) => {
e.stopPropagation();
removeFromPlaylist(item.id);
Mwebclient/src/components/PlaylistManager.tsx
@@ -2,7 +2,7 @@ import { createEffect, createResource, createSignal, For, Show, Signal, type Acc
import "./PlaylistManager.css";
import "./Dialog.css";
import "toastify-js/src/toastify.css";
import type { Playlist, PlaylistItem } from "../types";
import { getItemName, type Playlist, type PlaylistItem } from "../types";
import { formatTime, generateUUID, toast } from "../utils";
import { ClearSVG, DeleteSVG, DownloadSVG, UploadSVG } from "./SVG";
import { deletePlaylist, loadPlaylists, savePlaylist } from "../offline";
@@ -41,7 +41,9 @@ export default function PlaylistManager(props: PlaylistManagerProps) {
function getTotalDuration(playlist: Playlist) {
if (playlist.items.length === 0) return 0;
//for some reason "duration" is a string at runtime...
return playlist.items.map((item) => Number.parseFloat(item.duration as unknown as string)).reduce((a, b) => a + b);
return playlist.items
.map((item) => Number.parseFloat(item.metadata.duration as unknown as string))
.reduce((a, b) => a + b);
}
function exportPlaylists() {
@@ -165,7 +167,7 @@ export default function PlaylistManager(props: PlaylistManagerProps) {
{(item) => {
return (
<>
{item.name}
{getItemName(item)}
<br />
</>
);
Mwebclient/src/offline.ts
@@ -1,4 +1,11 @@
import type { AudioFile, Directory, FileListing } from "music-server-shared/types";
import type {
MediaFile,
Directory,
FileListing,
FileListingWithStatus,
DirectoryWithStatus,
} from "music-server-shared/types";
import { parseMap } from "music-server-shared/mapconversion";
import type { AppOptions, Playlist, PlaylistItem } from "./types";
import { joinPath, toast } from "./utils";
import { type Accessor, createSignal, type ResourceActions, type Signal } from "solid-js";
@@ -8,31 +15,17 @@ interface Progress {
bytes: number;
}
//TODO: try using store
export type SignalMapEntry = undefined | Progress | "DirectoryProgress" | "CachedFile";
export const signalMap = new Map<string, Signal<SignalMapEntry>>();
function updateOfflineFileTree(path: string, duration: number): void {
const basePath = path.split("/").slice(0, -1).join("/");
const name = path.split("/").toReversed()[0];
const node = getDirInTree(basePath, true);
const existingFileIndex = node.files.findIndex((f) => f.name === name);
const file = { name: name, duration: duration };
if (existingFileIndex >= 0) {
node.files[existingFileIndex] = file;
} else {
node.files.push(file);
}
}
export async function loadDataFromDB() {
try {
const db = await openDB();
const files = await db.listFiles(FileMetadataStore);
for (const file of files) {
signalMap.set(file.path, createSignal<SignalMapEntry>("CachedFile"));
updateOfflineFileTree(file.path, file.duration);
updateFileTree(file.path, { metadata: { duration: file.duration } }, true);
}
} catch (e) {
toast("Loading offline files failed", "error", e);
@@ -40,48 +33,97 @@ export async function loadDataFromDB() {
}
}
interface DirectoryNode {
directories: Record<string, DirectoryNode>;
files: AudioFile[];
}
const offlineTreeRoot: Directory = {
files: new Map(),
};
const directoryTree: DirectoryNode = {
directories: {},
files: [],
const onlineTreeRoot: DirectoryWithStatus = {
files: new Map(),
status: "Unknown",
};
function mergeTrees(target: DirectoryWithStatus, other: DirectoryWithStatus) {
if (other.status === "Unknown") return;
target.status = other.status;
for (const [fileName, file] of other.files.entries()) {
const targetFile = target.files.get(fileName);
if (targetFile && "files" in targetFile && "files" in file) {
mergeTrees(targetFile, file);
} else {
target.files.set(fileName, file);
}
}
}
//TODO: improve typing to avoid "as"
//overloads for typecheking
function updateFileTree(path: string, file: MediaFile, offline: true): void;
function updateFileTree(path: string, file: DirectoryWithStatus, offline: false): void;
function updateFileTree(path: string, file: DirectoryWithStatus | MediaFile, offline: boolean): void {
if (offline && path === "") throw new Error("Trying to update root in offline mode");
const basePath = path.split("/").slice(0, -1).join("/");
const fileName = path.split("/").toReversed()[0];
const node = getDirInTree(basePath, true, offline);
if (offline) {
node.files.set(fileName, file);
} else {
if (path === "") mergeTrees(node as DirectoryWithStatus, file as DirectoryWithStatus);
else {
let target = node.files.get(fileName) as DirectoryWithStatus;
if (!target) target = { files: new Map(), status: "Unknown" };
mergeTrees(target as DirectoryWithStatus, file as DirectoryWithStatus);
node.files.set(fileName, target);
}
}
}
//overloads for typecheking
function getDirInTree(path: string, create: true, offline: true): Directory;
function getDirInTree(path: string, create: true, offline: false): DirectoryWithStatus;
function getDirInTree(path: string, create: false, offline: true): Directory | null;
function getDirInTree(path: string, create: false, offline: false): DirectoryWithStatus | null;
function getDirInTree(path: string, create: true, offline: boolean): DirectoryWithStatus | Directory;
function getDirInTree(path: string, create: false, offline: boolean): DirectoryWithStatus | Directory | null;
// get a DirectoryNode from the directory tree, and optionally create it if it doesn't exist yet
function getDirInTree(path: string, create: true): DirectoryNode;
function getDirInTree(path: string, create: false): DirectoryNode | null;
function getDirInTree(path: string, create: boolean): DirectoryNode | null {
const parts = path.split("/").filter(Boolean);
let current = directoryTree;
function getDirInTree(path: string, create: boolean, offline: boolean): DirectoryWithStatus | Directory | null {
let current = offline ? offlineTreeRoot : onlineTreeRoot;
if (path === "") return current;
const parts = path.split("/");
for (const part of parts) {
if (!current.directories[part]) {
let next: DirectoryWithStatus | Directory | MediaFile | undefined = current.files.get(part);
if (!next || "metadata" in next) {
if (!create) return null;
current.directories[part] = { directories: {}, files: [] };
//if mediafile already exists, we overwrite it with a directory anyway
if (offline) next = { files: new Map() };
else next = { files: new Map(), status: "Unknown" };
current.files.set(part, next);
}
current = current.directories[part];
current = next;
}
return current;
}
function deletePath(path: string): void {
function deleteOfflinePath(path: string): void {
const basePath = path.split("/").slice(0, -1).join("/");
const node = getDirInTree(basePath, false);
const fileName = path.split("/").toReversed()[0];
const node = getDirInTree(basePath, false, true);
if (!node) return;
const name = path.split("/").toReversed()[0];
delete node.directories[name];
node.files = node.files.filter((file) => file.name !== name);
node.files.delete(fileName);
}
type FileEntry = { name: string } & ({ type: "Directory" } | { type: "File"; duration: number });
const directoryCache = new Map<string, FileEntry[]>();
export const clearDirectoryCache = (path: string) => directoryCache.delete(path);
export const clearDirectoryCache = (path: string) => {
const node = getDirInTree(path, false, false);
if (!node) return;
node.files.clear();
node.status = "Unknown";
};
async function requestFileList(dir: string, recursive: boolean): Promise<FileListing | undefined> {
async function listOnlineFiles(dir: string, recursive: boolean): Promise<FileListingWithStatus | undefined> {
const response = await fetch(`/list/${dir}?recursive=${recursive}`).catch(() => {});
if (response?.status === 404) {
toast(`Directory not found: ${dir}`, "error");
@@ -95,72 +137,35 @@ async function requestFileList(dir: string, recursive: boolean): Promise<FileLis
return;
}
const data: Directory = await response.json();
return data.files;
return parseMap(await response.text()) as FileListingWithStatus;
}
function saveFileListToCache(basePath: string, files: FileListing, recursive: boolean) {
const converted: FileEntry[] = files.map((item) => {
if ("files" in item) {
if (recursive) saveFileListToCache(joinPath(basePath, item.name), item.files, true);
return { ...item, type: "Directory" };
}
return { ...item, type: "File" };
});
directoryCache.set(basePath, converted);
function listOfflineFiles(dir: string): FileListing {
const node = getDirInTree(dir, false, true);
if (!node || !node.files) return new Map();
return node.files;
}
function cachedToDirectory(fullPath: string, entries: FileEntry[] | undefined, recursive: boolean): FileListing | null {
if (!entries) return null;
const result = [];
for (const item of entries) {
const newPath = joinPath(fullPath, item.name);
let children = undefined;
if (item.type !== "File") {
children = recursive ? cachedToDirectory(newPath, directoryCache.get(newPath), true) : [];
if (!children) return null;
result.push({ name: item.name, files: children });
} else {
result.push({ name: item.name, duration: item.duration });
}
function fullyKnown(dir: DirectoryWithStatus) {
if (dir.status === "Unknown") {
return false;
}
return result;
}
function listOfflineFiles(dir: string, recursive: boolean): FileListing {
const node = getDirInTree(dir, false);
if (!node) return [];
const entries: FileListing = [];
entries.push(...node.files);
if (recursive) {
for (const [name, childNode] of Object.entries(node.directories)) {
const directoryEntry: Directory = {
name,
files: childNode.files,
};
directoryEntry.files = listOfflineFiles(joinPath(dir, name), true);
entries.push(directoryEntry);
}
} else {
entries.push(...Object.keys(node.directories).map((dir) => ({ name: dir, files: [] })));
if (dir.files.values().some((value) => "files" in value && !fullyKnown(value))) {
return false;
}
return entries.sort((a, b) => a.name.localeCompare(b.name));
return true;
}
export async function listFiles(dir: string, offline: boolean, recursive: boolean): Promise<FileListing> {
if (offline) {
const res = listOfflineFiles(dir, recursive);
const res = listOfflineFiles(dir);
return res;
}
const cached = cachedToDirectory(dir, directoryCache.get(dir), recursive);
if (cached !== null) return cached;
const files = await requestFileList(dir, recursive);
if (!files) return [];
saveFileListToCache(dir, files, recursive);
const cached = getDirInTree(dir, false, false);
if (cached !== null && cached.status === "Scanned" && (!recursive || fullyKnown(cached))) return cached.files;
const files = await listOnlineFiles(dir, recursive);
if (!files) return new Map();
updateFileTree(dir, { files, status: "Scanned" }, false);
return files;
}
@@ -226,24 +231,25 @@ export async function deletePlaylist(name: string) {
}
export async function deleteFile(
item: AudioFile | Directory,
fileName: string,
item: MediaFile | Directory,
basePath: string,
fetchFiles: ResourceActions<FileListing>,
listing?: FileListing,
) {
const fullPath = joinPath(basePath, item.name);
const fullPath = joinPath(basePath, fileName);
const signal = signalMap.get(fullPath);
if (!signal) return;
//TODO: can techinally remove the recursion, but need to update the signals
if (!("duration" in item)) {
const newBasePath = joinPath(basePath, item.name);
const result = listing ?? listOfflineFiles(newBasePath, true);
for (const childItem of result) {
await deleteFile(childItem, newBasePath, fetchFiles, "files" in childItem ? childItem.files : undefined);
const newBasePath = joinPath(basePath, fileName);
const result = listOfflineFiles(newBasePath);
for (const [childName, childItem] of result) {
await deleteFile(childName, childItem, newBasePath, fetchFiles);
}
}
deletePath(fullPath);
deleteOfflinePath(fullPath);
const db = await openDB();
await db.delete(FileBlobStore, fullPath);
await db.delete(FileMetadataStore, fullPath);
@@ -258,7 +264,7 @@ async function downloadFile(
currentDir: Accessor<string>,
progressSignal: Signal<SignalMapEntry>,
options: AppOptions,
duration: number,
item: MediaFile,
fetchFiles: ResourceActions<FileListing>,
): Promise<void> {
const [getProgress, setProgress] = progressSignal;
@@ -310,18 +316,17 @@ async function downloadFile(
} as StoredFileBlob);
await db.put(FileMetadataStore, {
path,
duration,
duration: item.metadata.duration,
} as StoredFileMetadata);
updateOfflineFileTree(path, duration);
updateFileTree(path, item, true);
setProgress("CachedFile");
const basePath = path.split("/").slice(0, -1).join("/");
const name = path.split("/").toReversed()[0];
const fileName = path.split("/").toReversed()[0];
if (currentDir() === basePath) {
fetchFiles.mutate((prev) => {
if (prev.findIndex((f) => f.name === name) !== -1) {
return prev;
}
return [...prev, { name, duration }].sort((a, b) => a.name.localeCompare(b.name));
prev.set(fileName, item);
//new map to force rerender
return new Map(prev);
});
}
} catch (e) {
@@ -331,28 +336,29 @@ async function downloadFile(
}
export async function download(
item: AudioFile | Directory,
fileName: string,
item: MediaFile | Directory,
basePath: string,
currentDir: Accessor<string>,
options: AppOptions,
fetchFiles: ResourceActions<FileListing>,
) {
const fullPath = joinPath(basePath, item.name);
const fullPath = joinPath(basePath, fileName);
let signal = signalMap.get(fullPath);
if (!signal) {
signal = createSignal<SignalMapEntry>();
signalMap.set(fullPath, signal);
}
const [, setProgress] = signal;
if ("duration" in item) {
await downloadFile(fullPath, currentDir, signal, options, item.duration, fetchFiles);
if ("metadata" in item) {
await downloadFile(fullPath, currentDir, signal, options, item, fetchFiles);
} else {
setProgress("DirectoryProgress");
await getCover(fullPath, false);
try {
const promises: Promise<unknown>[] = [];
for (const childItem of await listFiles(fullPath, false, false)) {
promises.push(download(childItem, fullPath, currentDir, options, fetchFiles));
for (const [childName, childItem] of await listFiles(fullPath, false, false)) {
promises.push(download(childName, childItem, fullPath, currentDir, options, fetchFiles));
}
await Promise.allSettled(promises);
} finally {
Mwebclient/src/types.ts
@@ -1,8 +1,15 @@
import type { AudioFile, MediaFormat } from "music-server-shared/types";
import type { MediaFile, MediaFormat } from "music-server-shared/types";
export interface PlaylistItem extends AudioFile {
export interface PlaylistItem extends MediaFile {
id: string; //used for determining currently playing item
path: string; //TODO: change to basePath to avoid redundancy with name
path: string;
}
export function getItemName(item: Pick<PlaylistItem, "path">): string;
export function getItemName(item: Pick<PlaylistItem, "path"> | null): string | undefined;
export function getItemName(item?: Pick<PlaylistItem, "path"> | null) {
if (!item) return undefined;
return item.path.split("/").toReversed()[0];
}
export interface Playlist {
Mwebclient/src/utils.ts
@@ -38,7 +38,9 @@ function stringErr(e: unknown) {
}
export function toast(text: string, type: "info" | "error" | "success", e?: unknown) {
if (e) console.error(e);
if (e) {
console.error(e);
}
const thisToast = Toastify({
text: e ? `${text}:\n${stringErr(e)}` : text,
duration: 2000,
@@ -59,4 +61,6 @@ export function toast(text: string, type: "info" | "error" | "success", e?: unkn
return thisToast;
}
export function toastError() {}
export function remoteLog(e: unknown) {
fetch("remote-log", { body: JSON.stringify(e?.toString()), method: "POST" });
}