option to hide volume, warning if adding duplicate, playlist sync, playlist link sharing, some refactoring
Mserver/src/index.ts
@@ -1,8 +1,16 @@
import staticPlugin from "@elysiajs/static";
import { Elysia, ElysiaFile, type InferHandler, t } from "elysia";
import path from "node:path";
import path, { basename } from "node:path";
import { parseArgs } from "node:util";
import { musicRoot, fileTypeCache, ServerError, allowedTypes, probeCache, mediaTypes } from "./shared";
import {
musicRoot,
fileTypeCache,
ServerError,
allowedTypes,
probeCache,
mediaTypes,
generatedPlaylistIds,
} from "./shared";
import { getPathInfo, listFiles, findCover, matchesType, convertWithFFmpeg, packWithTar, probeFile } from "./utils";
import { stat } from "node:fs/promises";
@@ -10,6 +18,7 @@ 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";
import { randomUUIDv7 } from "bun";
//TODO: transcoding cache?
//TODO: add reasonable timeouts for caches
@@ -122,7 +131,7 @@ const app = setup
request.signal.addEventListener("abort", () => cmd.kill("SIGKILL"));
set.headers["content-type"] = mimeType;
if (query.disableChunkedTranscoding) {
console.log("Here")
console.log("Here");
const chunks: Uint8Array[] = [];
for await (const chunk of cmd.stdout) {
chunks.push(chunk);
@@ -170,16 +179,38 @@ const app = setup
set.status = fileScanResult.info.status;
return fileScanResult.info.error;
})
.post(
"/download-playlist",
async ({ set, body: { files } }) => {
.post("/prepare-playlist", async ({ set, body }) => {
set.status = "OK";
set.headers["Content-Type"] = "text/plain";
const id = randomUUIDv7();
console.log(typeof body);
generatedPlaylistIds.set(id, JSON.parse(body as string) as string[]);
setTimeout(
() => {
generatedPlaylistIds.delete(id);
},
1000 * 60 * 60, // 1 hour
);
return id;
})
.get(
"/download-playlist/:id",
async ({ set, params }) => {
const playlist = generatedPlaylistIds.get(params.id);
if (!playlist) {
set.status = "Not Found";
return "Playlist ID not found";
}
set.status = "OK";
if (playlist.length === 1) {
set.headers["Content-Disposition"] = `attachment; filename="${basename(playlist[0])}"`;
return Bun.file(path.join(musicRoot, playlist[0]));
}
set.headers["Content-Type"] = "application/x-tar";
set.headers["Content-Disposition"] = `attachment; filename="playlist.tar"`;
//needed to support stream download with complex body in request
return new Response(packWithTar(JSON.parse(decodeURIComponent(atob(files))) as string[]).stdout);
return new Response(packWithTar(playlist).stdout);
},
{ body: t.Object({ files: t.String({ minLength: 1 }) }) },
{ params: t.Object({ id: t.String({ minLength: 1 }) }) },
)
.post("remote-log", ({ body }) => {
console.log(body);
Mserver/src/utils.ts
@@ -214,7 +214,7 @@ export function convertWithFFmpeg(filePath: string, bitrate: number, format: Med
export function packWithTar(files: string[]) {
// Ensure all files are within musicRoot
const absoluteFiles = files.map((f) => path.join(musicRoot, decodeURI(f)));
const absoluteFiles = files.map((f) => path.join(musicRoot, f));
for (const f of absoluteFiles) {
if (!isBelow(musicRoot, f)) {
throw new ServerError(StatusMap.Forbidden, "One or more files are outside the music root");
Mwebclient/src/App.tsx
@@ -1,4 +1,4 @@
import { createSignal, createEffect, onMount, on } from "solid-js";
import { createSignal, createEffect, onMount, on, createResource } from "solid-js";
import type { Component } from "solid-js";
import type { PlaylistItem, PlayerState, AppOptions } from "./types";
import OptionsModal from "./components/OptionsModal";
@@ -7,16 +7,14 @@ import FileBrowser from "./components/FileBrowser";
import { ClosedFolderSVG, CogSVG, FullscreenSVG, OpenFolderSVG, PlaylistSVG } from "./components/SVG";
import { LocalStorageValues, toast } from "./utils";
import Playlist from "./components/Playlist";
import { getSrc } from "./offline";
import { getSrc, listFiles } from "./offline";
import { MediaFormat } from "music-server-shared/types";
import PlaylistManager from "./components/PlaylistManager";
import { BUILD_HASH } from "./build-info";
//TODO: change download actions?
//TODO: search
//TODO: remember scroll position for parent directories
//TODO: merge fileblobs and filemetadata, try putting signals in the onlinetree
//TODO: fix touch highlighting in playlist
//TODO: delete entries in downloadMap instead of setting status to undefined
//TODO: merge fileblobs and filemetadata
//TODO: better touch controls?
//TODO: Add documentation to functions
//TODO: allow syncing playlists to server
@@ -38,6 +36,7 @@ const App: Component = () => {
currentTime: 0,
volume: 0.5,
buffered: 0,
rate: 1,
});
const [showOptions, setShowOptions] = createSignal(false);
const [showPlaylistManager, setShowPlaylistManager] = createSignal(false);
@@ -48,12 +47,23 @@ const App: Component = () => {
maxTranscodes: 4,
maxDownloads: 2,
rememberDir: false,
format: MediaFormat["opus/ogg"],
format: MediaFormat["opus/webm"],
hideVolume: false,
duplicateWarning: true,
});
const [breadcrumbs, setBreadcrumbs] = createSignal<string[]>([]);
const [isShuffled, setShuffled] = createSignal(false);
const [isRepeated, setRepeated] = createSignal(false);
const [isOffline, setOffline] = createSignal(true);
const [files, fetchFiles] = createResource(
//TODO: try using store
() => ({ dir: currentDir(), offline: isOffline() }),
async (args) => {
//new map to force rerender
return new Map(await listFiles(args.dir, args.offline, false));
},
{ initialValue: new Map() },
);
createEffect(() => {
const parts = currentDir().split("/").filter(Boolean);
@@ -196,7 +206,7 @@ const App: Component = () => {
try {
const savedOptions = localStorage.getItem(LocalStorageValues.options);
if (savedOptions) {
setOptions(JSON.parse(savedOptions));
setOptions({ ...options(), ...JSON.parse(savedOptions) });
}
const savedVolume = localStorage.getItem(LocalStorageValues.volume);
if (savedVolume && !Number.isNaN(Number.parseFloat(savedVolume))) {
@@ -266,8 +276,8 @@ const App: Component = () => {
});
createEffect(() => {
// Update audio element when volume changes
audioElement.volume = playerState().volume;
audioElement.playbackRate = playerState().rate;
});
return (
@@ -316,6 +326,8 @@ const App: Component = () => {
options={options}
isOffline={isOffline}
setOffline={setOffline}
files={files}
fetchFiles={fetchFiles}
/>
<Playlist
@@ -326,6 +338,8 @@ const App: Component = () => {
setPlayerState={setPlayerState}
playTrack={playTrack}
audioElement={audioElement}
options={options}
fetchFiles={fetchFiles}
/>
</div>
@@ -342,6 +356,7 @@ const App: Component = () => {
isRepeated={isRepeated}
setRepeated={setRepeated}
audioElement={audioElement}
options={options}
/>
<OptionsModal
Mwebclient/src/components/DownloadButton.tsx
@@ -1,10 +1,10 @@
import "./DownloadButton.css";
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 { deleteFile, download, downloadStatusMap, type DownloadStatus } from "../offline";
import type { Accessor, ResourceActions } from "solid-js";
import type { AppOptions } from "../types";
import { joinPath } from "../utils";
import { DeleteSVG, DownloadSVG, RefreshSVG } from "./SVG";
import { DeleteSVG, RefreshSVG, SyncSVG } from "./SVG";
import { LoadingSpinner } from "./LoadingScreen";
interface DownloadButtonProps {
@@ -29,12 +29,6 @@ function formatProgress(bytes: number): string {
export function DownloadButton(props: DownloadButtonProps) {
const fullPath = joinPath(props.currentDir(), props.fileName);
let signal = signalMap.get(fullPath);
if (!signal) {
signal = createSignal<SignalMapEntry>();
signalMap.set(fullPath, signal);
}
const [getProgress] = signal;
return (
<div
@@ -47,33 +41,33 @@ export function DownloadButton(props: DownloadButtonProps) {
}}
>
{(() => {
const progress = getProgress();
const progress = downloadStatusMap[fullPath];
if (progress === undefined) {
if (props.isOffline()) {
//only happens if this is a directory
return (
<button title="Purge the cache from all files recursively in this directory">
<button title="Purge the sync storage from all files recursively in this directory">
<DeleteSVG />
</button>
);
}
return (
<button title="Cache this file/directory on your browser for offline use">
<DownloadSVG />
<button title="Sync this file/directory to your browser for offline use">
<SyncSVG />
</button>
);
}
if (progress === "DirectoryProgress") return <LoadingSpinner center={true} />;
if (progress === "CachedFile") {
if (progress === "SyncedFile") {
if (props.isOffline()) {
return (
<button title="Purge this file from cache">
<button title="Purge this file from local sync storage">
<DeleteSVG />
</button>
);
}
return (
<button title="Re-download this file">
<button title="Re-sync this file">
<RefreshSVG />
</button>
);
Mwebclient/src/components/DraggableList.tsx
@@ -3,6 +3,7 @@ import { type Accessor, type Setter, createSignal, onMount, onCleanup, type JSX,
type DropPosition = "before" | "after" | null;
interface DraggableListProps<T> {
id?: string;
items: Accessor<T[]>;
setItems: Setter<T[]>;
children: (
@@ -154,6 +155,7 @@ export function DraggableList<T>(props: DraggableListProps<T>) {
return (
<div
id={props.id}
class="draggable-list"
ref={listRef}
onDragOver={handleDragOver}
Mwebclient/src/components/FileBrowser.css
@@ -1,3 +1,12 @@
.duplicate-warning-dialog {
max-width: 400px;
.buttons {
display: flex;
gap: 0.5rem;
justify-content: end;
}
}
.file-browser {
flex-grow: 1;
flex-basis: 0;
Mwebclient/src/components/FileBrowser.tsx
@@ -5,12 +5,15 @@ import {
createResource,
createSignal,
For,
type InitializedResource,
on,
onMount,
type ResourceActions,
type Setter,
Show,
Suspense,
} from "solid-js";
import { type MediaFile, type FileListing, fileSort } from "music-server-shared/types";
import type { MediaFile, FileListing, Directory } from "music-server-shared/types";
import type { AppOptions, PlaylistItem } from "../types";
import { generateUUID, joinPath, LocalStorageValues, toast } from "../utils";
import { ClosedFolderSVG, MusicSVG, RefreshSVG } from "./SVG";
@@ -30,19 +33,12 @@ interface FileBrowserProps {
options: Accessor<AppOptions>;
isOffline: Accessor<boolean>;
setOffline: Setter<boolean>;
files: InitializedResource<FileListing>;
fetchFiles: ResourceActions<FileListing>;
}
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) => {
//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() }),
async (args, info) => {
@@ -52,6 +48,9 @@ export default function FileBrowser(props: FileBrowserProps) {
{ initialValue: undefined },
);
let dialogElement!: HTMLDialogElement;
let addAnywayButton!: HTMLButtonElement;
function handleBreadcrumbClick(index: number) {
if (index === 0) {
props.setCurrentDir("");
@@ -88,34 +87,46 @@ export default function FileBrowser(props: FileBrowserProps) {
props.playTrack(fileToPlaylistItem(path, item));
}
function addToPlaylist(path: string, file: MediaFile) {
const newItem: PlaylistItem = {
id: generateUUID(),
metadata: file.metadata,
path: path,
};
props.setPlaylist([...props.playlist(), newItem]);
if (props.playlist().length === 1) {
props.playTrack(newItem);
}
function scrollPlaylistToBottom() {
const element = document.getElementById("playlist");
if (!element) return;
element.scrollTop = element.scrollHeight;
}
async function playFiles(recursive: boolean, queue: boolean, dir?: string) {
async function playFiles(fileName: string, recursive: boolean, queue: boolean, item?: MediaFile) {
if (recursive) {
setLoadingActions(loadingActions() + 1);
}
try {
const selectedDir = dir || props.currentDir();
const files = await listFiles(selectedDir, props.isOffline(), recursive);
const newPlaylist = listingToPlaylistItems(selectedDir, files);
const oldPlaylist = props.playlist();
if (queue) {
const fullPath = joinPath(props.currentDir(), fileName);
let oldLength = 0;
let newPlaylist: PlaylistItem[];
const oldPlaylist = queue ? props.playlist() : [];
if (item) {
newPlaylist = [fileToPlaylistItem(fullPath, item)];
} else {
const files = await listFiles(fullPath, props.isOffline(), recursive);
newPlaylist = listingToPlaylistItems(fullPath, files);
if (queue) {
oldLength = oldPlaylist.length;
}
}
if (newPlaylist.length !== 0 && oldLength === 0) props.playTrack(newPlaylist[0]);
const addAction = () => {
props.setPlaylist([...oldPlaylist, ...newPlaylist]);
if (oldPlaylist.length === 0 && newPlaylist.length !== 0) props.playTrack(newPlaylist[0]);
if (queue) scrollPlaylistToBottom();
};
if (
props.options().duplicateWarning &&
oldPlaylist.some((oldItem) => newPlaylist.some((newItem) => oldItem.path === newItem.path))
) {
addAnywayButton.onclick = () => {
addAction();
dialogElement.close();
};
dialogElement.showModal();
} else {
props.setPlaylist(newPlaylist);
if (newPlaylist.length !== 0) props.playTrack(newPlaylist[0]);
addAction();
}
} finally {
if (recursive) {
@@ -128,10 +139,40 @@ export default function FileBrowser(props: FileBrowserProps) {
clearDirectoryCache(props.currentDir());
//TODO: only clears the current dir, not recursively like clearDirectoryCache
deleteCover(props.currentDir());
fetchFiles.refetch();
props.fetchFiles.refetch();
fetchCover.refetch();
}
function fileSort(a: [string, Directory | MediaFile], b: [string, Directory | MediaFile]): number {
//sort directories first
if ("metadata" in b[1] && !("metadata" in a[1])) return -1;
if ("metadata" in a[1] && !("metadata" in b[1])) return 1;
return a[0].localeCompare(b[0], undefined, { numeric: true });
}
let filelistElement!: HTMLDivElement;
const scrollPositions: { part: string; pos: number }[] = [];
createEffect(
on(props.currentDir, () => {
//need the timeout, otherwise we could execute the scroll before rendering finishes
setTimeout(() => {
console.log(filelistElement.scrollTop);
let parts = `${props.currentDir()}`.split("/");
if (props.currentDir() !== "") parts = ["", ...parts];
scrollPositions.length = Math.min(parts.length, scrollPositions.length);
for (let index = 0; index < parts.length; index++) {
const part = parts[index];
if (scrollPositions.length - 1 < index) {
scrollPositions.push({ part, pos: 0 });
} else if (scrollPositions[index].part !== part) {
scrollPositions[index] = { part, pos: 0 };
}
}
filelistElement.scrollTop = scrollPositions[scrollPositions.length - 1].pos;
}, 0);
}),
);
onMount(() => {
try {
const savedOffline = localStorage.getItem(LocalStorageValues.offline);
@@ -141,6 +182,10 @@ export default function FileBrowser(props: FileBrowserProps) {
localStorage.clear();
}
filelistElement.addEventListener("scrollend", () => {
scrollPositions[scrollPositions.length - 1].pos = filelistElement.scrollTop;
});
createEffect(() => {
createEffect(() => localStorage.setItem(LocalStorageValues.offline, props.isOffline().toString()));
});
@@ -148,6 +193,15 @@ export default function FileBrowser(props: FileBrowserProps) {
return (
<>
<dialog class="duplicate-warning-dialog" ref={dialogElement}>
<div>
Some of the items you're trying to add already exists in your current playlist
<div class="buttons">
<button ref={addAnywayButton}>Add anyway</button>
<button onClick={() => dialogElement.close()}>Cancel</button>
</div>
</div>
</dialog>
<div classList={{ "file-browser": true, hidden: !props.showFileBrowser() }}>
<div class="breadcrumb">
<For each={props.breadcrumbs()}>
@@ -164,22 +218,22 @@ export default function FileBrowser(props: FileBrowserProps) {
<div>
<button
title="Replace playlist with all files in current directory"
onClick={() => playFiles(false, false)}
onClick={() => playFiles("", false, false)}
>
Play Directory
</button>
<button
title="Replace playlist with all files in current directory and all subdirectories"
onClick={() => playFiles(true, false)}
onClick={() => playFiles("", true, false)}
>
Play All
</button>
<button title="Add all files in current directory to playlist" onClick={() => playFiles(false, true)}>
<button title="Add all files in current directory to playlist" onClick={() => playFiles("", false, true)}>
Queue Directory
</button>
<button
title="Add all files in current directory and all subdirectories to playlist"
onClick={() => playFiles(true, true)}
onClick={() => playFiles("", true, true)}
>
Queue All
</button>
@@ -200,12 +254,12 @@ export default function FileBrowser(props: FileBrowserProps) {
</div>
<div class="file-list-wrapper">
<Show when={files.loading}>
<Show when={props.files.loading}>
<LoadingScreen />
</Show>
<div class="file-list">
<For each={Array.from(files.latest.entries()).sort(fileSort)}>
<div class="file-list" ref={filelistElement}>
<For each={Array.from(props.files?.latest.entries()).sort(fileSort)}>
{([fileName, item]) => {
const isDirectory = !("metadata" in item);
const fullPath = joinPath(props.currentDir(), fileName);
@@ -213,9 +267,9 @@ export default function FileBrowser(props: FileBrowserProps) {
<>
<div
class={(isDirectory && "directory-item") || "file-item"}
onClick={() =>
(isDirectory && props.setCurrentDir(fullPath)) || playFile(fullPath, item as MediaFile)
}
onClick={() => {
(isDirectory && props.setCurrentDir(fullPath)) || playFile(fullPath, item as MediaFile);
}}
>
{(isDirectory && <ClosedFolderSVG forButton={false} />) || <MusicSVG />}
<span>{fileName}</span>
@@ -223,7 +277,7 @@ export default function FileBrowser(props: FileBrowserProps) {
<DownloadButton
fileName={fileName}
item={item}
fetchFiles={fetchFiles}
fetchFiles={props.fetchFiles}
isOffline={props.isOffline}
currentDir={props.currentDir}
options={props.options}
@@ -233,9 +287,9 @@ export default function FileBrowser(props: FileBrowserProps) {
onClick={(event) => {
event.stopPropagation();
if (isDirectory) {
playFiles(true, true, fullPath);
playFiles(fileName, true, true);
} else {
addToPlaylist(fullPath, item as MediaFile);
playFiles(fileName, false, true, item);
}
}}
>
Mwebclient/src/components/OptionsModal.tsx
@@ -78,6 +78,7 @@ export default function OptionsModal(props: OptionsModalProps) {
type="checkbox"
checked={newOptions()?.disableChunkedTranscoding}
onChange={(e) => setNewOptions({ ...newOptions(), disableChunkedTranscoding: e.target.checked })}
disabled={!newOptions()?.enableTranscoding}
/>
</div>
<div>
@@ -144,6 +145,24 @@ export default function OptionsModal(props: OptionsModalProps) {
onChange={(e) => setNewOptions({ ...newOptions(), maxTranscodes: e.target.valueAsNumber })}
/>
</div>
<div>
<label for="hide-volume-checkbox">Hide volume:</label>
<input
id="hide-volume-checkbox"
type="checkbox"
checked={newOptions()?.hideVolume}
onChange={(e) => setNewOptions({ ...newOptions(), hideVolume: e.target.checked })}
/>
</div>
<div>
<label for="duplicate-checkbox">Warn when adding duplicate to playlist:</label>
<input
id="duplicate-checkbox"
type="checkbox"
checked={newOptions()?.duplicateWarning}
onChange={(e) => setNewOptions({ ...newOptions(), duplicateWarning: e.target.checked })}
/>
</div>
<div class="option-buttons">
<button title="Clear server caches" type="button" class="cache-button" onclick={deleteServerCache}>
Clear server caches
Mwebclient/src/components/PlayerControls.tsx
@@ -1,5 +1,5 @@
import "./PlayerControls.css";
import { getItemName, type PlayerState } from "../types";
import { type AppOptions, 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";
@@ -14,6 +14,7 @@ interface PlayerControlsProps {
setShuffled: Setter<boolean>;
isRepeated: Accessor<boolean>;
setRepeated: Setter<boolean>;
options: Accessor<AppOptions>;
audioElement: HTMLAudioElement;
}
@@ -120,10 +121,27 @@ export default function PlayerControls(props: PlayerControlsProps) {
<ShuffleSVG disabled={() => !props.isShuffled()} />
</button>
<div class="volume-control">
<div class="volume-control" style={props.options().hideVolume ? { display: "none" } : {}}>
<VolumeSVG />
<input type="range" min="0" max="1" step="0.01" value={props.playerState().volume} onInput={setVolume} />
</div>
<div>
<select
value={props.playerState().rate}
onchange={(e) => props.setPlayerState({ ...props.playerState(), rate: Number.parseFloat(e.target.value) })}
>
<option value={0.25}>0.25x</option>
<option value={0.5}>0.5x</option>
<option value={0.75}>0.75x</option>
<option value={1}>1x</option>
<option value={1.25}>1.25x</option>
<option value={1.5}>1.5x</option>
<option value={1.75}>1.75x</option>
<option value={2}>2x</option>
<option value={2.5}>2.5x</option>
<option value={3}>3x</option>
</select>
</div>
</div>
</div>
);
Mwebclient/src/components/Playlist.tsx
@@ -1,10 +1,11 @@
import { createSelector, type Accessor, type Setter } from "solid-js";
import { createSelector, type Accessor, type Setter, createSignal, type ResourceActions, createEffect } from "solid-js";
import "./Playlist.css";
import { getItemName, type PlayerState, type PlaylistItem } from "../types";
import { getItemName, type PlayerState, type PlaylistItem, type AppOptions } from "../types";
import { formatTime, toast } from "../utils";
import { ClearSVG, DownloadSVG, DragSVG, SaveSVG } from "./SVG";
import { savePlaylist } from "../offline";
import { ClearSVG, DownloadSVG, DragSVG, LinkSVG, SaveSVG, SyncSVG } from "./SVG";
import { savePlaylist, isFileCached, download, downloadStatusMap } from "../offline";
import { DraggableList } from "./DraggableList";
import type { FileListing } from "music-server-shared/types";
interface PlaylistProps {
currentDir: Accessor<string>;
@@ -14,6 +15,8 @@ interface PlaylistProps {
setPlayerState: Setter<PlayerState>;
playTrack: (item: PlaylistItem) => void;
audioElement: HTMLAudioElement;
options: Accessor<AppOptions>;
fetchFiles: ResourceActions<FileListing>;
}
export default function Playlist(props: PlaylistProps) {
@@ -29,36 +32,56 @@ export default function Playlist(props: PlaylistProps) {
props.setPlaylist(newPlaylist);
}
function browserDownload(name: string, path: string) {
const a = document.createElement("a");
a.href = `/download/${path}`;
a.download = name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
async function browserDownloadAll() {
async function playlistDownload(download: boolean) {
try {
toast("Started playlist download", "info");
const playlist = props.playlist();
const filePaths = playlist.map((item) => item.path);
const form = document.createElement("form");
form.method = "POST";
form.action = "/download-playlist";
const input = document.createElement("input");
input.type = "hidden";
input.name = "files";
input.value = btoa(encodeURIComponent(JSON.stringify(filePaths)));
form.appendChild(input);
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
const response = await fetch("/prepare-playlist", { body: JSON.stringify(filePaths), method: "POST" });
if (!response.ok) throw new Error(`Generating playlist id not successful: ${await response.text()}`);
const id = await response.text();
if (download) {
toast("Started playlist download", "info");
const a = document.createElement("a");
a.href = `/download-playlist/${id}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} else {
toast("Copied playlist link to clipboard", "info");
const urlPath = location.href.split(/\?|#/)[0];
navigator.clipboard.writeText(`${urlPath}${urlPath.endsWith("/") ? "" : "/"}download-playlist/${id}`);
}
} catch (e) {
toast("Playlist items download failed", "error", e);
}
}
const [syncing, setSyncing] = createSignal(false);
async function syncPlaylist() {
if (syncing()) return;
setSyncing(true);
const progressToast = toast("Syncing files in playlist...", "progress");
try {
const itemsToSync: PlaylistItem[] = [];
for (const item of props.playlist()) {
if (!(await isFileCached(item.path, downloadStatusMap[item.path]))) itemsToSync.push(item);
}
const syncPromises = itemsToSync.map((item) => {
const basePath = item.path.split("/").slice(0, -1).join("/");
const fileName = item.path.split("/").toReversed()[0];
return download(fileName, item, basePath, props.currentDir, props.options(), props.fetchFiles);
});
await Promise.allSettled(syncPromises);
progressToast.hideToast();
toast("Playlist sync completed", "success");
} catch (e) {
progressToast.hideToast();
toast("Playlist sync failed", "error", e);
} finally {
setSyncing(false);
}
}
let dialogElement!: HTMLDialogElement;
let playlistNameInput!: HTMLInputElement;
let allowOverwriteCheckbox!: HTMLInputElement;
@@ -83,6 +106,21 @@ export default function Playlist(props: PlaylistProps) {
allowOverwriteCheckbox.checked = false;
}
const [syncedCount, setSyncedCount] = createSignal(0);
createEffect(async () => {
const playlist = props.playlist();
let count = 0;
const promises = [];
for (const item of playlist) {
promises.push(
isFileCached(item.path, downloadStatusMap[item.path]).then((res) => {
if (res) count++;
}),
);
}
await Promise.all(promises);
setSyncedCount(count);
});
return (
<div class="playlist-container">
<dialog class="save-dialog" ref={dialogElement}>
@@ -98,14 +136,25 @@ export default function Playlist(props: PlaylistProps) {
</dialog>
<div class="playlist-header">
<span>Playlist ({props.playlist().length} tracks)</span>
<span>
Playlist ({props.playlist().length} tracks, {syncedCount()} synced)
</span>
<div class="spacer" />
<button title="Save playlist" onClick={() => dialogElement.showModal()}>
<SaveSVG />
</button>
<button title="Sync all unsynced files in playlist" onClick={syncPlaylist} disabled={syncing()}>
<SyncSVG />
</button>
<button
title="Download all files in the playlist in their original format as a tar file"
onClick={browserDownloadAll}
title="Copies and 1-hour valid link for the same action as the download button"
onClick={() => playlistDownload(false)}
>
<LinkSVG />
</button>
<button
title="Download all files in the playlist in their original format as a .tar file, or without archive if it contains just a single item"
onClick={() => playlistDownload(true)}
>
<DownloadSVG />
</button>
@@ -114,7 +163,7 @@ export default function Playlist(props: PlaylistProps) {
</button>
</div>
<DraggableList<PlaylistItem> items={props.playlist} setItems={props.setPlaylist}>
<DraggableList<PlaylistItem> items={props.playlist} setItems={props.setPlaylist} id="playlist">
{(item, _, { dragHandlers, isDragging, dropPosition }) => {
const isActive = createSelector(
() => props.playerState().currentTrack?.id,
@@ -138,15 +187,6 @@ export default function Playlist(props: PlaylistProps) {
<span>{getItemName(item)}</span>
<div class="spacer" />
{formatTime(item.metadata.duration)}
<button
title={"Download this file in its original format to your file system"}
onClick={(e) => {
e.stopPropagation();
browserDownload(getItemName(item), item.path);
}}
>
<DownloadSVG />
</button>
<button
title={`Remove ${getItemName(item)}`}
onClick={(e) => {
Mwebclient/src/components/SVG.tsx
@@ -283,6 +283,30 @@ export function DragSVG() {
);
}
export function LinkSVG() {
const [hovered, setHovered] = createSignal();
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<path
fill={hovered() ? highlightColor : defaultColor}
d="M16 10h1v7h-1v1h-1v1h-1v1h-1v1h-1v1h-1v1H5v-1H4v-1H3v-1H2v-1H1v-5h1v-1h1v-1h1v-1h1v4H4v3h1v1h1v1h3v-1h1v-1h1v-1h1v-1h1v-1h1v-3h-1v-1h-1v-1h1V9h1V8h1v1h1z"
/>
<path
fill={hovered() ? highlightColor : defaultColor}
d="M23 5v5h-1v1h-1v1h-1v1h-1V9h1V6h-1V5h-1V4h-3v1h-1v1h-1v1h-1v1h-1v1h-1v3h1v1h1v1h-1v1h-1v1H9v-1H8v-1H7V7h1V6h1V5h1V4h1V3h1V2h1V1h6v1h1v1h1v1h1v1z"
/>
</svg>
);
}
export function DownloadSVG() {
const [hovered, setHovered] = createSignal();
@@ -303,6 +327,26 @@ export function DownloadSVG() {
);
}
export function SyncSVG() {
const [hovered, setHovered] = createSignal();
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<path
fill={hovered() ? highlightColor : defaultColor}
d="M22 12v-1h-2V9h-1V8h-1V7h-3v1h-1V6h-1V5H8v1H7v1H6v1H5v3H2v1H1v5h1v1h1v1h2v1h14v-1h2v-1h1v-1h1v-5zM9 17v-1H8v-1H7v-1H6v-1h3V9h3v4h3v1h-1v1h-1v1h-1v1h-1v1h-1v-1z"
/>
</svg>
);
}
export function UploadSVG() {
const [hovered, setHovered] = createSignal();
Mwebclient/src/offline.ts
@@ -9,22 +9,22 @@ 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";
import { createStore } from "solid-js/store";
import { AsyncSemaphore } from "./semaphore";
interface Progress {
bytes: number;
}
//TODO: try using store
export type SignalMapEntry = undefined | Progress | "DirectoryProgress" | "CachedFile";
export const signalMap = new Map<string, Signal<SignalMapEntry>>();
export type DownloadStatus = undefined | Progress | "DirectoryProgress" | "SyncedFile";
export const [downloadStatusMap, setDownloadStatusMap] = createStore<Record<string, DownloadStatus>>({});
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"));
setDownloadStatusMap(file.path, "SyncedFile");
updateFileTree(file.path, { metadata: { duration: file.duration } }, true);
}
} catch (e) {
@@ -156,17 +156,28 @@ function fullyKnown(dir: DirectoryWithStatus) {
return true;
}
//non-recursive listing should not include nested children
function pruneChildren(listing: FileListing) {
const cloned = structuredClone(listing);
for (const [, item] of cloned.entries()) {
if ("files" in item) item.files.clear();
}
return cloned;
}
export async function listFiles(dir: string, offline: boolean, recursive: boolean): Promise<FileListing> {
if (offline) {
const res = listOfflineFiles(dir);
return res;
const files = listOfflineFiles(dir);
return recursive ? files : pruneChildren(files);
}
const cached = getDirInTree(dir, false, false);
if (cached !== null && cached.status === "Scanned" && (!recursive || fullyKnown(cached))) return cached.files;
if (cached !== null && cached.status === "Scanned" && (!recursive || fullyKnown(cached))) {
return recursive ? cached.files : pruneChildren(cached.files);
}
const files = await listOnlineFiles(dir, recursive);
if (!files) return new Map();
updateFileTree(dir, { files, status: "Scanned" }, false);
return files;
return recursive ? files : pruneChildren(files);
}
export async function getCover(dir: string, offline: boolean) {
@@ -237,8 +248,7 @@ export async function deleteFile(
fetchFiles: ResourceActions<FileListing>,
) {
const fullPath = joinPath(basePath, fileName);
const signal = signalMap.get(fullPath);
if (!signal) return;
if (!downloadStatusMap[fullPath]) return;
//TODO: can techinally remove the recursion, but need to update the signals
if (!("duration" in item)) {
@@ -253,7 +263,7 @@ export async function deleteFile(
const db = await openDB();
await db.delete(FileBlobStore, fullPath);
await db.delete(FileMetadataStore, fullPath);
signalMap.get(fullPath)?.[1](undefined);
setDownloadStatusMap(fullPath, undefined);
fetchFiles.refetch();
}
@@ -262,19 +272,17 @@ const downloadSemaphore = new AsyncSemaphore(1);
async function downloadFile(
path: string,
currentDir: Accessor<string>,
progressSignal: Signal<SignalMapEntry>,
options: AppOptions,
item: MediaFile,
fetchFiles: ResourceActions<FileListing>,
): Promise<void> {
const [getProgress, setProgress] = progressSignal;
const progress = getProgress();
if (typeof progress === "object" && "bytes" in progress) return; // download already in progress
const initialProgress = downloadStatusMap[path];
if (typeof initialProgress === "object" && "bytes" in initialProgress) return; // download already in progress
const db = await openDB();
let mimeType: string | undefined;
try {
setProgress({ bytes: 0 });
setDownloadStatusMap(path, { bytes: 0 });
downloadSemaphore.setMaxConcurrency(options.enableTranscoding ? options.maxTranscodes : options.maxDownloads);
let blob: Blob;
@@ -305,7 +313,7 @@ async function downloadFile(
const now = Date.now();
if (now - lastUpdate < 250) continue;
lastUpdate = now;
setProgress({ bytes: received });
setDownloadStatusMap(path, { bytes: received });
}
blob = await blobPromise;
} finally {
@@ -321,7 +329,7 @@ async function downloadFile(
duration: item.metadata.duration,
} as StoredFileMetadata);
updateFileTree(path, item, true);
setProgress("CachedFile");
setDownloadStatusMap(path, "SyncedFile");
const basePath = path.split("/").slice(0, -1).join("/");
const fileName = path.split("/").toReversed()[0];
if (currentDir() === basePath) {
@@ -333,7 +341,7 @@ async function downloadFile(
}
} catch (e) {
toast(`Downloading ${path} failed`, "error", e);
setProgress(undefined);
setDownloadStatusMap(path, initialProgress === "SyncedFile" ? "SyncedFile" : undefined);
}
}
@@ -346,16 +354,11 @@ export async function download(
fetchFiles: ResourceActions<FileListing>,
) {
const fullPath = joinPath(basePath, fileName);
let signal = signalMap.get(fullPath);
if (!signal) {
signal = createSignal<SignalMapEntry>();
signalMap.set(fullPath, signal);
}
const [, setProgress] = signal;
setDownloadStatusMap(fullPath, undefined);
if ("metadata" in item) {
await downloadFile(fullPath, currentDir, signal, options, item, fetchFiles);
await downloadFile(fullPath, currentDir, options, item, fetchFiles);
} else {
setProgress("DirectoryProgress");
setDownloadStatusMap(fullPath, "DirectoryProgress");
await getCover(fullPath, false);
try {
const promises: Promise<unknown>[] = [];
@@ -364,7 +367,7 @@ export async function download(
}
await Promise.allSettled(promises);
} finally {
setProgress(undefined);
setDownloadStatusMap(fullPath, undefined);
}
}
}
@@ -420,6 +423,7 @@ interface DatabaseWrapper {
clear(store: string): Promise<void>;
listFiles(store: typeof FileMetadataStore): Promise<StoredFileMetadata[]>;
listPlaylists(store: typeof PlaylistStore): Promise<Playlist[]>;
exist(store: typeof FileMetadataStore, key: string): Promise<boolean>;
}
function wrapDB(db: IDBDatabase): DatabaseWrapper {
@@ -473,5 +477,22 @@ function wrapDB(db: IDBDatabase): DatabaseWrapper {
req.onsuccess = () => res(req.result);
req.onerror = () => rej(req.error);
}),
exist: (store: typeof FileMetadataStore, key: string) =>
new Promise((res, rej) => {
const tx = db.transaction(store, "readonly");
const req = tx.objectStore(store).count(key);
req.onsuccess = () => res(req.result !== 0);
req.onerror = () => rej(req.error);
}),
};
}
export async function isFileCached(path: string, status: DownloadStatus): Promise<boolean> {
if (status === "SyncedFile") return true;
//if in progress, it might already exist anyway and just is being re-synced right now
if (typeof status === "object" && "bytes" in status) {
const db = await openDB();
return await db.exist(FileMetadataStore, path);
}
return false;
}
Mwebclient/src/types.ts
@@ -23,6 +23,7 @@ export interface PlayerState {
currentTime: number;
volume: number;
buffered: number;
rate: number;
}
export interface AppOptions {
@@ -33,4 +34,6 @@ export interface AppOptions {
maxTranscodes: number;
maxDownloads: number;
rememberDir: boolean;
hideVolume: boolean;
duplicateWarning: boolean;
}
Mwebclient/src/utils.ts
@@ -1,12 +1,13 @@
import type { FileListing, MediaFile } from "music-server-shared/types";
import Toastify from "toastify-js";
export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
//join string and dedup slashes and make sure it doesn't start with a slash
//join string and dedup slashes and make sure it doesn't start or end with a slash
export function joinPath(...paths: string[]): string {
return paths.join("/").replace(/\/+/g, "/").replace(/^\//, "");
return paths.join("/").replace(/\/+/g, "/").replace(/^\//, "").replace(/\/$/, "");
}
export function formatTime(seconds: number): string {
@@ -37,14 +38,14 @@ function stringErr(e: unknown) {
return e && typeof e === "object" && "message" in e ? e.message : e;
}
export function toast(text: string, type: "info" | "error" | "success", e?: unknown) {
export function toast(text: string, type: "progress" | "info" | "error" | "success", e?: unknown) {
if (e) {
console.error(e);
}
const thisToast = Toastify({
text: e ? `${text}:\n${stringErr(e)}` : text,
duration: 2000,
close: true,
duration: type === "progress" ? -1 : 2000,
close: type !== "progress",
style: {
"border-radius": "0",
display: "flex",
@@ -64,3 +65,17 @@ export function toast(text: string, type: "info" | "error" | "success", e?: unkn
export function remoteLog(e: unknown) {
fetch("remote-log", { body: JSON.stringify(e?.toString()), method: "POST" });
}
export type FileListingRecord = Record<string, MediaFile | { files: FileListingRecord }>;
export function fileListingToObject(listing: FileListing): FileListingRecord {
const result: FileListingRecord = {};
for (const [name, entry] of listing.entries()) {
if ("files" in entry) {
result[name] = { files: fileListingToObject(entry.files) };
} else {
result[name] = entry;
}
}
return result;
}