- Spotify playlist, library, album and artist browsing with in-app playback - Song, playlist and album downloads via spotdl with YouTube and Bandcamp fallbacks - Electron desktop app (castLabs Widevine) with a permanent sidebar layout - Linux and Windows packaging scripts
113 lines
3.8 KiB
TypeScript
113 lines
3.8 KiB
TypeScript
import {
|
|
formatFolderPath,
|
|
formatMp3Filename,
|
|
sanitizeFilename,
|
|
type Mp3Metadata,
|
|
} from "./mp3Tags";
|
|
import type { ScannedLibraryTrack } from "./libraryScanner";
|
|
import type { SpotiRipSettings } from "./spotiripSettings";
|
|
|
|
type OrganizeDirectoryHandle = FileSystemDirectoryHandle & {
|
|
getDirectoryHandle(name: string, options?: { create?: boolean }): Promise<FileSystemDirectoryHandle>;
|
|
getFileHandle(name: string, options?: { create?: boolean }): Promise<FileSystemFileHandle>;
|
|
};
|
|
|
|
function segment(value: string | undefined, fallback: string) {
|
|
return sanitizeFilename(value?.trim() || fallback).replace(/[./\\]+/g, "_");
|
|
}
|
|
|
|
async function ensureDirectory(root: FileSystemDirectoryHandle, names: string[]) {
|
|
let current = root as OrganizeDirectoryHandle;
|
|
for (const name of names) {
|
|
current = (await current.getDirectoryHandle(name, { create: true })) as OrganizeDirectoryHandle;
|
|
}
|
|
return current;
|
|
}
|
|
|
|
async function exists(directory: FileSystemDirectoryHandle, name: string) {
|
|
try {
|
|
await (directory as OrganizeDirectoryHandle).getFileHandle(name);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function uniqueName(directory: FileSystemDirectoryHandle, desired: string) {
|
|
if (!(await exists(directory, desired))) return desired;
|
|
const dot = desired.lastIndexOf(".");
|
|
const base = dot > 0 ? desired.slice(0, dot) : desired;
|
|
const ext = dot > 0 ? desired.slice(dot) : "";
|
|
for (let index = 2; index < 10000; index += 1) {
|
|
const candidate = base + " (" + index + ")" + ext;
|
|
if (!(await exists(directory, candidate))) return candidate;
|
|
}
|
|
throw new Error("Could not create a unique library filename.");
|
|
}
|
|
|
|
function metadataForTrack(
|
|
track: ScannedLibraryTrack,
|
|
settings?: Pick<SpotiRipSettings, "multiDiscMode">,
|
|
): Mp3Metadata {
|
|
const trackLabel = track.trackNumber
|
|
? settings?.multiDiscMode === "offset" && track.discNumber
|
|
? String(track.discNumber * 100 + track.trackNumber)
|
|
: settings?.multiDiscMode === "disc-prefix" && track.discNumber
|
|
? String(track.discNumber) + "-" + String(track.trackNumber).padStart(2, "0")
|
|
: String(track.trackNumber).padStart(2, "0")
|
|
: "";
|
|
return {
|
|
title: track.title || "Unknown Track",
|
|
artist: track.artist || "Unknown Artist",
|
|
album: track.album,
|
|
albumArtist: track.albumArtist,
|
|
year: track.date?.slice(0, 4),
|
|
trackNumber: track.trackNumber,
|
|
trackLabel,
|
|
discNumber: track.discNumber,
|
|
genre: track.genre,
|
|
isrc: track.isrc,
|
|
};
|
|
}
|
|
|
|
export function suggestedLibraryPath(
|
|
track: ScannedLibraryTrack,
|
|
settings?: Pick<
|
|
SpotiRipSettings,
|
|
"folderTemplate" | "filenameTemplate" | "multiDiscMode"
|
|
>,
|
|
) {
|
|
const metadata = metadataForTrack(track, settings);
|
|
const folders = formatFolderPath(
|
|
settings?.folderTemplate || "{album_artist}/{year} - {album}",
|
|
metadata,
|
|
);
|
|
const extension = track.file.name.includes(".")
|
|
? track.file.name.slice(track.file.name.lastIndexOf(".")).toLowerCase()
|
|
: "";
|
|
const formatted = formatMp3Filename(
|
|
settings?.filenameTemplate || "{artist} - {title}",
|
|
metadata,
|
|
).replace(/\.mp3$/i, "");
|
|
const filename = segment(formatted, "Unknown Track") + extension;
|
|
return { folders, filename };
|
|
}
|
|
|
|
export async function copyOrganizedTrack(
|
|
root: FileSystemDirectoryHandle,
|
|
track: ScannedLibraryTrack,
|
|
settings?: Pick<
|
|
SpotiRipSettings,
|
|
"folderTemplate" | "filenameTemplate" | "multiDiscMode"
|
|
>,
|
|
) {
|
|
const target = suggestedLibraryPath(track, settings);
|
|
const directory = await ensureDirectory(root, target.folders);
|
|
const filename = await uniqueName(directory, target.filename);
|
|
const handle = await (directory as OrganizeDirectoryHandle).getFileHandle(filename, { create: true });
|
|
const writable = await handle.createWritable();
|
|
await writable.write(track.file);
|
|
await writable.close();
|
|
return [...target.folders, filename].join("/");
|
|
}
|