Files
R3ap3r 3178b8f4f7 Aurora SpotiRip desktop and web app
- 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
2026-09-25 23:20:57 -05:00

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("/");
}