download manager

AuthorKonata <konata@posteo.jp>
Date
Commitcb888eed7e43990edfe93730f104f150ad83372f
Parent5870977
10 files changed, 375 insertions(+), 104 deletions(-)
▾Mwebclient/src/App.tsx
@@ -4,7 +4,14 @@ import { type PlaylistItem, type PlayerState, type AppOptions, getItemName } fro
import OptionsModal from "./components/OptionsModal";
import PlayerControls from "./components/PlayerControls";
import FileBrowser from "./components/FileBrowser";
import { ClosedFolderSVG, CogSVG, FullscreenSVG, OpenFolderSVG, PlaylistSVG } from "./components/SVG";
import {
ClosedFolderSVG,
CogSVG,
DownloadManagerSVG,
FullscreenSVG,
OpenFolderSVG,
PlaylistSVG,
} from "./components/SVG";
import { LocalStorageValues, toast } from "./utils";
import Playlist from "./components/Playlist";
import { getCover, getSrc, listFiles } from "./offline";
@@ -13,8 +20,9 @@ import PlaylistManager from "./components/PlaylistManager";
import { BUILD_HASH } from "./build-info";
import { createStore } from "solid-js/store";
import { decodePath, encodePath } from "music-server-shared/utils";
import DownloadManager from "./components/DownloadManager";
//TODO: download manager
//TODO: keep synced-aborted in downloadmanager
//TODO: bypass music-metadata if it fails
//TODO: search
//TODO: better touch controls?
@@ -43,6 +51,7 @@ const App: Component = () => {
});
const [showOptions, setShowOptions] = createSignal(false);
const [showPlaylistManager, setShowPlaylistManager] = createSignal(false);
const [showDownloadManager, setShowDownloadManager] = createSignal(false);
const [options, setOptions] = createStore<AppOptions>({
//default settings
bitrate: 128000,
@@ -340,6 +349,9 @@ const App: Component = () => {
>
{showFileBrowser() ? <OpenFolderSVG /> : <ClosedFolderSVG forButton={true} />}
</button>
<button title="Manage Downloads" onClick={() => setShowDownloadManager(true)}>
<DownloadManagerSVG />
</button>
<button title="Manage playlists" onClick={() => setShowPlaylistManager(true)}>
<PlaylistSVG />
</button>
@@ -419,6 +431,7 @@ const App: Component = () => {
setPlaylist={setPlaylist}
playTrack={playTrack}
/>
<DownloadManager showDownloadManager={showDownloadManager} setShowDownloadManager={setShowDownloadManager} />
</div>
);
};
▾Mwebclient/src/components/DownloadButton.tsx
@@ -26,20 +26,27 @@ export function DownloadButton(props: DownloadButtonProps) {
onClick={(event) => {
event.stopPropagation();
if (!props.isOffline())
download(props.fileName, props.item, props.currentDir(), props.options, props.fetchFiles);
download(
props.fileName,
props.item,
props.currentDir(),
props.options,
props.fetchFiles,
"metadata" in props.item,
);
else deleteFile(props.fileName, props.item, props.currentDir(), props.fetchFiles);
}}
>
{(() => {
//memo needed so progress amount doesn't cause rerenders
type ButtonType = "Spinner" | "Delete" | "Sync" | "Refresh" | { bytes: number };
type ButtonType = "Spinner" | "Delete" | "Sync" | "Refresh" | { status: number };
const progressMemo = createMemo<ButtonType>(() => {
const progress = getDownloadStatus(fullPath)[0]();
const progress = getDownloadStatus(fullPath);
if (typeof progress === "object" && "amount" in progress) return "Spinner";
if (props.isOffline()) return "Delete";
if (progress === undefined) return "Sync";
if (progress === "Synced") return "Refresh";
if ("bytes" in progress) return { bytes: progress.bytes };
if (progress === undefined || ("status" in progress && typeof progress.status !== "number")) return "Sync";
if (typeof progress.status === "number") return { status: progress.status };
throw new Error(`Unexpected: ${progress}`);
});
const progress = progressMemo();
@@ -53,7 +60,7 @@ export function DownloadButton(props: DownloadButtonProps) {
}
if (progress === "Sync") {
return (
<button title="Sync this file/directory to your browser for offline use">
<button title="Sync missing files this file/directory to your browser for offline use">
<SyncSVG />
</button>
);
@@ -65,7 +72,7 @@ export function DownloadButton(props: DownloadButtonProps) {
</button>
);
}
if ("bytes" in progress) return <span>{progress.bytes === 0 ? "Queued..." : formatBytes(progress.bytes)}</span>;
if ("status" in progress) return <span>{formatBytes(progress.status)}</span>;
console.error("Unexpected", progress);
})()}
</div>
▾Awebclient/src/components/DownloadManager.css
@@ -0,0 +1,51 @@
.downloadmanager-header {
display: flex;
gap: 1.5rem;
}
.downloadmanager-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
overflow: auto;
position: relative;
& > .downloadmanager-item {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.5rem;
background-color: var(--color-c);
&:hover {
background-color: var(--color-c-highlight);
}
& > span {
overflow: hidden;
text-overflow: ellipsis;
text-wrap: nowrap;
direction: rtl;
max-width: 100%;
align-self: start;
}
& > .downloadmanager-buttons {
display: flex;
gap: 0.5rem;
justify-content: end;
align-items: center;
}
&.downloadmanager-item-failed {
background-color: oklch(0.35 0.137 30);
&:hover {
background-color: oklch(0.4 0.137 30);
}
}
&.downloadmanager-item-aborted {
background-color: oklch(0.48 0.085 102);
&:hover {
background-color: oklch(0.53 0.085 102);
}
}
}
}
▾Awebclient/src/components/DownloadManager.tsx
@@ -0,0 +1,145 @@
import { createEffect, createSignal, For, onMount, Show, untrack, type Accessor, type Setter } from "solid-js";
import "./DownloadManager.css";
import "./Dialog.css";
import "toastify-js/src/toastify.css";
import { formatBytes } from "../utils";
import { ClearSVG, RefreshSVG, StopSVG } from "./SVG";
import { downloadStatusMap, setDownloadStatusMap, type Progress } from "../offline";
import { createStore } from "solid-js/store";
interface DownloadManagerProps {
showDownloadManager: Accessor<boolean>;
setShowDownloadManager: Setter<boolean>;
}
export default function DownloadManager(props: DownloadManagerProps) {
let dialogElement!: HTMLDialogElement;
createEffect(() => {
if (props.showDownloadManager()) {
dialogElement.showModal();
}
});
function handleClose() {
dialogElement.close();
props.setShowDownloadManager(false);
}
const [downloadItems, setDownloadItems] = createStore<Record<string, Progress | undefined>>({});
createEffect(() => {
const paths = Object.keys(downloadStatusMap);
untrack(() => {
// Update the downloadItems store with the current status of each path
for (const path of paths) {
const status = downloadStatusMap[path];
if (typeof status === "object" && "status" in status) setDownloadItems(path, status);
else setDownloadItems(path, undefined);
}
// Remove any paths that no longer exist in downloadStatusMap
for (const path of Object.keys(downloadItems)) {
if (!Object.keys(downloadStatusMap).includes(path)) {
setDownloadItems(path, undefined);
}
}
});
});
return (
<dialog
style={{ height: "90%" }}
ref={dialogElement}
onClose={handleClose}
onclick={(e) => e.target.nodeName === "DIALOG" && handleClose()}
>
<div style={{ height: "100%" }}>
<div class="downloadmanager-header title">
Download
<div class="spacer" />
<button
title="Retry all failed/aborted downloads"
onClick={() => {
for (const status of Object.values(downloadItems)) {
if (status && "retry" in status) status.retry();
}
}}
>
<RefreshSVG />
</button>
<button
title="stop all running downloads"
onClick={() => {
for (const status of Object.values(downloadItems)) {
if (status && typeof status.status === "number") status.abort();
}
}}
>
<StopSVG />
</button>
<button
title="Clear all failed/aborted downloads"
onClick={() => {
for (const [path, status] of Object.entries(downloadItems)) {
if (status && (status.status === "Aborted" || status.status === "Failed"))
setDownloadStatusMap(path, undefined);
}
}}
>
Clear
</button>
<button title="Close dialog" onClick={handleClose}>
<ClearSVG />
</button>
</div>
<hr />
<div class="downloadmanager-content">
<For each={Object.entries(downloadItems)}>
{([path, status]) => {
if (!status) return;
const [showReason, setShowReason] = createSignal(false);
return (
<div
classList={{
"downloadmanager-item": true,
"downloadmanager-item-failed": status.status === "Failed",
"downloadmanager-item-aborted": status.status === "Aborted",
}}
>
<span>{path}</span>
<hr />
<div class="downloadmanager-buttons">
{status && typeof status.status === "number" ? (
<>
<span>{formatBytes(status.status)}</span>
<button title="Stop Download" onClick={() => status.abort()}>
<StopSVG />
</button>
</>
) : (
<>
{(status.status === "Failed" && (
<button title="Show reason" onclick={() => setShowReason(!showReason())}>
Reason
</button>
)) ||
"Aborted"}
<button title="Retry download" onClick={() => status.retry()}>
<RefreshSVG />
</button>
<button title="Clear Download" onClick={() => setDownloadStatusMap(path, undefined)}>
<ClearSVG />
</button>
</>
)}
</div>
<Show when={showReason()}>{"reason" in status && status.reason}</Show>
</div>
);
}}
</For>
</div>
</div>
</dialog>
);
}
▾Mwebclient/src/components/Playlist.tsx
@@ -69,7 +69,7 @@ export default function Playlist(props: PlaylistProps) {
try {
const itemsToSync: PlaylistItem[] = [];
const playlist = props.playlist();
const unique = playlist.filter((item) => playlist.some((other) => item.path !== other.path));
const unique = playlist.filter((item, pos) => playlist.indexOf(item) === pos);
const storedFiles = await getStoredFiles();
for (const item of unique) {
if (!storedFiles.includes(item.path)) itemsToSync.push(item);
@@ -116,7 +116,7 @@ export default function Playlist(props: PlaylistProps) {
const [syncedCount, setSyncedCount] = createSignal(0);
createEffect(
on(
() => props.playlist().map((item) => getDownloadStatus(item.path)[0]()),
() => props.playlist().map((item) => getDownloadStatus(item.path)),
async () => {
const playlist = props.playlist();
const storedFiles = await getStoredFiles();
▾Mwebclient/src/components/PlaylistManager.css
@@ -6,14 +6,14 @@
.playlistmanager-content {
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: 0.25rem;
overflow: auto;
position: relative;
min-height: 10rem;
& > .playlistmanager-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: 0.2rem;
padding: 0.5rem;
background-color: var(--color-c);
&:hover {
▾Mwebclient/src/components/SVG.tsx
@@ -453,3 +453,44 @@ export function UpSVG() {
</svg>
);
}
export function DownloadManagerSVG() {
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="M2 5h3v3H2zm0 12h3v3H2zm0-6h3v3H2zm21-5v1h-1v1H10V7H9V6h1V5h12v1zm-1 6h1v1h-1v1H10v-1H9v-1h1v-1h12zm0 6h1v1h-1v1H10v-1H9v-1h1v-1h12z"
/>
<path fill="none" d="M0 0h24v24H0z" />
</svg>
);
}
export function StopSVG() {
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 8V7h-1V6h-1V5h-1V4h-1V3h-1V2h-1V1H8v1H7v1H6v1H5v1H4v1H3v1H2v1H1v8h1v1h1v1h1v1h1v1h1v1h1v1h1v1h8v-1h1v-1h1v-1h1v-1h1v-1h1v-1h1v-1h1V8zm-6 9h-2v-1h-1v-1h-2v1h-1v1H8v-1H7v-2h1v-1h1v-2H8v-1H7V8h1V7h2v1h1v1h2V8h1V7h2v1h1v2h-1v1h-1v2h1v1h1v2h-1z"
/>
</svg>
);
}
▾Mwebclient/src/offline.ts
@@ -6,79 +6,63 @@ import type {
DirectoryWithStatus,
} from "music-server-shared/types";
import { parseMap } from "music-server-shared/mapconversion";
import type { AppOptions, Playlist, PlaylistItem } from "./types";
import type { AppOptions, Playlist } from "./types";
import { joinPath, toast } from "./utils";
import { createSignal, type ResourceActions, type Signal } from "solid-js";
import type { ResourceActions } from "solid-js";
import { AsyncSemaphore } from "./semaphore";
import { encodePath } from "music-server-shared/utils";
import type { FlatFileListing } from "./App";
import { createStore } from "solid-js/store";
interface Progress {
bytes: number;
}
export type Progress =
| { status: number; abort: () => void }
| { status: "Aborted"; retry: () => void }
| { status: "Failed"; retry: () => void; reason: string };
interface LoadingItems {
interface LoadingChildren {
amount: number;
}
export type DownloadStatus = Progress | LoadingItems | "Synced" | undefined;
//used to display download status for directories/files in the filebrowser view
// export const [downloadStatusMap, setDownloadStatusMap] = createStore<Record<string, DownloadStatus>>({});
export interface StatusWithChildren {
status: Signal<DownloadStatus>;
children?: RecursiveStatus;
}
type RecursiveStatus = Map<string, StatusWithChildren>;
const downloadStatusMap: RecursiveStatus = new Map<string, StatusWithChildren>();
export type DownloadStatus = Progress | LoadingChildren | "Synced" | undefined;
export const [downloadStatusMap, setDownloadStatusMap] = createStore<Record<string, DownloadStatus>>({});
function updateProgressAmount(statusSignal: Signal<DownloadStatus>, loadChange: number) {
const [getStatus, setStatus] = statusSignal;
let status = getStatus();
if (typeof status !== "object" || !("amount" in status)) {
status = { amount: 0 };
setStatus(status);
}
const newAmount = status.amount + loadChange;
if (newAmount > 0) setStatus({ amount: newAmount });
else setStatus(undefined);
//creates entry if it doesn't exist, so reactivity works
export function getDownloadStatus(path: string): DownloadStatus | undefined {
if (!Object.keys(downloadStatusMap).includes(path)) setDownloadStatusMap(path, undefined);
return downloadStatusMap[path];
}
export function getDownloadStatus(path: string, loadChange?: number) {
const parts = path.split("/");
let current = downloadStatusMap;
//iterate through the basepath
for (const part of parts.slice(0, -1)) {
let next = current.get(part);
if (!next) {
next = { status: createSignal<DownloadStatus>(undefined) };
current.set(part, next);
function updateRecursiveStatus(path: string, loadChange: number, includeCurrent: boolean) {
function updateProgressAmount(path: string) {
const currentStatus = downloadStatusMap[path];
if (typeof currentStatus !== "object" || !("amount" in currentStatus)) {
setDownloadStatusMap(path, loadChange > 0 ? { amount: loadChange } : undefined);
return;
}
if (!next.children) {
//might be a directory whose children have not been referenced before, in which case "children" would be undefined
next.children = new Map();
}
if (loadChange) updateProgressAmount(next.status, loadChange);
current = next.children as RecursiveStatus;
const newAmount = currentStatus.amount + loadChange;
setDownloadStatusMap(path, newAmount > 0 ? { amount: newAmount } : undefined);
}
const fileName = parts.toReversed()[0];
let ret = current.get(fileName);
if (!ret) {
ret = { status: createSignal<DownloadStatus>(undefined) };
current.set(fileName, ret);
if (includeCurrent) updateProgressAmount(path);
// Update all parent directories
let currentPath = path;
while (true) {
const lastSlash = currentPath.lastIndexOf("/");
if (lastSlash === -1) break;
currentPath = currentPath.substring(0, lastSlash);
updateProgressAmount(currentPath);
}
return ret.status;
}
//increase/decrease load amount for all parents and return requested signal
//needed to set parent directories to a loading state when any child is being downloaded/deleted
function startProgress(path: string) {
return getDownloadStatus(path, 1);
function startProgress(path: string, status: DownloadStatus) {
updateRecursiveStatus(path, 1, false);
setDownloadStatusMap(path, status);
}
function stopProgress(path: string) {
return getDownloadStatus(path, -1);
function stopProgress(path: string, status: DownloadStatus) {
updateRecursiveStatus(path, -1, false);
setDownloadStatusMap(path, status);
}
//initialize filetrees and download statuses from indexedDB
@@ -87,7 +71,7 @@ export async function loadDataFromDB() {
const db = await openDB();
const files = await db.listFiles(FileStore);
for (const file of files) {
getDownloadStatus(file.path)[1]("Synced");
setDownloadStatusMap(file.path, "Synced");
updateFileTree(file.path, { metadata: file.metadata }, true);
}
} catch (e) {
@@ -330,8 +314,8 @@ export async function savePlaylist(playlist: Playlist, overwrite: boolean) {
const db = await openDB();
playlist.items = playlist.items.map((item) => {
//some less space in the db by omitting ids
const { id, ...itemWithoutId } = item as PlaylistItem;
return itemWithoutId;
//spread item.metadata because it might be a proxy object
return { path: item.path, metadata: { ...item.metadata } };
});
if (overwrite) await db.put(PlaylistStore, playlist);
else await db.add(PlaylistStore, playlist);
@@ -366,10 +350,9 @@ export async function deleteFile(
fetchFiles: ResourceActions<FlatFileListing>,
) {
const fullPath = joinPath(basePath, fileName);
const [getStatus] = getDownloadStatus(fullPath);
try {
if ("metadata" in item && getStatus() !== "Synced") return;
startProgress(fullPath)[1]({ amount: 1 });
if ("metadata" in item && downloadStatusMap[fullPath] !== "Synced") return;
startProgress(fullPath, { amount: 1 });
let toDelete: string[];
if ("files" in item) {
@@ -382,11 +365,11 @@ export async function deleteFile(
await db.deleteAll(FileStore, toDelete); //delete from database
deleteInOfflineTree(fullPath); //delete node from tree
for (const path of toDelete) {
getDownloadStatus(path)[1](undefined); // set all children to unsynced
setDownloadStatusMap(path, undefined); // set all children to unsynced
}
fetchFiles.refetch();
} finally {
stopProgress(fullPath)[1](undefined);
stopProgress(fullPath, undefined);
}
}
@@ -399,30 +382,47 @@ async function downloadFile(
options: AppOptions,
item: MediaFile,
fetchFiles: ResourceActions<FlatFileListing>,
redownload: boolean,
): Promise<void> {
const [getStatus, setStatus] = getDownloadStatus(path);
const initialProgress = getStatus();
if (typeof initialProgress === "object" && "bytes" in initialProgress) return; // download already in progress
const retry = () => {
setDownloadStatusMap(path, undefined);
return downloadFile(path, options, item, fetchFiles, redownload);
};
const controller = new AbortController();
let abortResolve!: () => void;
const abortPromise = new Promise<void>((resolve) => {
abortResolve = resolve;
}).then(() => {
controller.abort();
});
// file already downloaded or download already in progress
const initialProgress = downloadStatusMap[path];
if (
(!redownload && initialProgress === "Synced") ||
(typeof initialProgress === "object" && "bytes" in initialProgress)
)
return;
startProgress(path, { status: 0, abort: abortResolve });
try {
startProgress(path)[1]({ bytes: 0 });
const db = await openDB();
let mimeType: string | undefined;
//options might change at any point, update the semaphore to the current value
downloadSemaphore.setMaxConcurrency(options.enableTranscoding ? options.maxTranscodes : options.maxDownloads);
let blob: Blob;
//make sure to only allow the set amount of parallel downloads
const semaphorePromise = downloadSemaphore.acquire();
await Promise.race([abortPromise, semaphorePromise]);
try {
//make sure to only allow the set amount of parallel downloads
await downloadSemaphore.acquire();
const res = await fetch(await getSrc(path, options, false, true));
const res = await fetch(await getSrc(path, options, false, true), { signal: controller.signal });
//let the browser do the actual download
const blobPromise = res.clone().blob();
if (!res.ok || !res.body) throw res.statusText;
//we just iterate over the chunks to get the current progress
const reader = res.body.getReader();
mimeType = res.headers.get("content-type") ?? undefined;
let received = 0;
let lastUpdate = 0;
@@ -435,24 +435,37 @@ async function downloadFile(
const now = Date.now();
if (now - lastUpdate < 250) continue;
lastUpdate = now;
setStatus({ bytes: received });
setDownloadStatusMap(path, { status: received, abort: abortResolve });
}
blob = await blobPromise;
const blob = await blobPromise;
await db.put(FileStore, {
path,
data: blob,
metadata: { ...item.metadata }, //spread needed, because item might be a proxy object from the filebrowser, which can't be stored
} as StoredFile);
updateFileTree(path, item, true);
stopProgress(path, "Synced");
fetchFiles.refetch();
} finally {
downloadSemaphore.release();
}
await db.put(FileStore, {
path,
data: blob,
metadata: { ...item.metadata }, //spread needed, because item might be a proxy object from the filebrowser, which can't be stored
} as StoredFile);
updateFileTree(path, item, true);
stopProgress(path)[1]("Synced");
fetchFiles.refetch();
} catch (e) {
toast(`Downloading ${path} failed`, "error", e);
//restore original status
stopProgress(path)[1](initialProgress === "Synced" ? "Synced" : undefined);
stopProgress(
path,
// if it was already synced, restore it
initialProgress === "Synced"
? "Synced"
: //if it was manually aborted, set as aborted
e && typeof e === "object" && "name" in e && e.name === "AbortError"
? { status: "Aborted", retry }
: //otherwise set as failed and set reason to the error message
{
status: "Failed",
reason: e ? e.toString() : "Unknown error",
retry,
},
);
}
}
@@ -462,22 +475,23 @@ export async function download(
basePath: string,
options: AppOptions,
fetchFiles: ResourceActions<FlatFileListing>,
redownload = false,
) {
const fullPath = joinPath(basePath, fileName);
if ("metadata" in item) {
await downloadFile(fullPath, options, item, fetchFiles);
await downloadFile(fullPath, options, item, fetchFiles, redownload || false);
} else {
updateProgressAmount(startProgress(fullPath), 1);
updateRecursiveStatus(fullPath, 1, true);
try {
await getCover(fullPath, false);
//start downloads for all children to allow them to run in parallel, then wait for the results
const promises: Promise<unknown>[] = [];
for (const [childName, childItem] of await listFiles(fullPath, false, false)) {
promises.push(download(childName, childItem, fullPath, options, fetchFiles));
promises.push(download(childName, childItem, fullPath, options, fetchFiles, redownload));
}
await Promise.allSettled(promises);
} finally {
updateProgressAmount(stopProgress(fullPath), -1);
updateRecursiveStatus(fullPath, -1, true);
}
}
}
@@ -515,7 +529,7 @@ async function openDB() {
if (!db.objectStoreNames.contains(CoverStore))
db.createObjectStore(CoverStore, { keyPath: "path" }).createIndex("index", "path", { unique: true });
if (!db.objectStoreNames.contains(PlaylistStore))
db.createObjectStore(PlaylistStore, { keyPath: "path" }).createIndex("index", "path", { unique: true });
db.createObjectStore(PlaylistStore, { keyPath: "name" }).createIndex("index", "name", { unique: true });
};
req.onsuccess = () => resolve(wrapDB(req.result));
});
▾Mwebclient/src/style.css
@@ -95,7 +95,6 @@ body,
display: flex;
gap: 1.5rem;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--color-c);
& > .app-buttons {
@@ -123,6 +122,7 @@ body,
}
.title {
text-wrap: nowrap;
font-size: 1.5rem;
font-weight: bold;
}
▾Mwebclient/src/utils.ts
@@ -84,7 +84,7 @@ export function fileListingToObject(listing: FileListing): FileListingRecord {
}
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 Bytes";
if (bytes === 0) return "Queued...";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];