diff --git a/packages/cli/README.md b/packages/cli/README.md index 8074f59..ec9499a 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -24,6 +24,7 @@ Paste a URL, and the `mdl` resolves music metadata, searches YouTube for matches - All providers work without API keys, we query via public urls - Downloads are grouped into a music name directory - A `.mdl.json` manifest is written next to the files for resyncs +- If no `.mdl.json` is found, an existing `.spotdl` sync file in the directory is used to detect the playlist URL for a resync - Metadata is resolved directly from the music URL - Audio is sourced from YouTube diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 468db79..5685b9d 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -7,6 +7,7 @@ import { ensureFfmpegExecutable } from './lib/ffmpeg'; import { loadManifest } from './lib/manifest'; import { configureNetwork } from './lib/network'; import { configurePoToken } from './lib/poToken'; +import { loadSpotdlManifest } from './lib/spotdl'; import { CliArgumentError, formatEffectError } from './lib/utils'; import { App } from './ui/app'; @@ -21,7 +22,7 @@ try { }); configurePoToken({ usePoToken: args.usePoToken }); const dir = path.resolve(process.env.INIT_CWD ?? process.env.PWD ?? process.cwd()); - const manifest = !args.url ? await loadManifest(dir) : null; + const manifest = !args.url ? ((await loadManifest(dir)) ?? (await loadSpotdlManifest(dir))) : null; const outputDir = path.resolve(args.outputDir ?? dir); render( diff --git a/packages/cli/src/lib/spotdl.test.ts b/packages/cli/src/lib/spotdl.test.ts new file mode 100644 index 0000000..51e7e8b --- /dev/null +++ b/packages/cli/src/lib/spotdl.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { loadSpotdlManifest } from './spotdl'; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true }))); +}); + +describe('spotdl', () => { + test('loadSpotdlManifest returns null when no .spotdl file is present', async () => { + const directory = await createTemporaryDirectory(); + + const manifest = await loadSpotdlManifest(directory); + + expect(manifest).toBeNull(); + }); + + test('loadSpotdlManifest extracts the playlist url and title from the sync query', async () => { + const directory = await createTemporaryDirectory(); + await writeFile( + path.join(directory, 'Kids Dance Warm Up.spotdl'), + JSON.stringify({ + type: 'sync', + query: ['https://open.spotify.com/playlist/2zLOKKbeMZ6mRznJTbI2ql?si=e13829d468a944d4'], + songs: [ + { + name: "Better When I'm Dancin'", + song_id: '5k5fWendNngd89O8JKoE8L', + url: 'https://open.spotify.com/track/5k5fWendNngd89O8JKoE8L', + list_name: 'Kids Dance Warm Up', + list_url: 'https://open.spotify.com/playlist/2zLOKKbeMZ6mRznJTbI2ql?si=e13829d468a944d4', + }, + ], + }) + ); + + const manifest = await loadSpotdlManifest(directory); + + expect(manifest).toEqual( + expect.objectContaining({ + provider: 'spotify', + playlistId: '2zLOKKbeMZ6mRznJTbI2ql', + playlistTitle: 'Kids Dance Warm Up', + playlistUrl: 'https://open.spotify.com/playlist/2zLOKKbeMZ6mRznJTbI2ql?si=e13829d468a944d4', + tracks: [], + }) + ); + }); + + test('loadSpotdlManifest falls back to the first song list_url when query is missing', async () => { + const directory = await createTemporaryDirectory(); + await writeFile( + path.join(directory, 'playlist.spotdl'), + JSON.stringify({ + songs: [ + { + list_name: 'Fallback Playlist', + list_url: 'https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M', + }, + ], + }) + ); + + const manifest = await loadSpotdlManifest(directory); + + expect(manifest?.playlistUrl).toBe('https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M'); + expect(manifest?.playlistId).toBe('37i9dQZF1DXcBWIGoYBM5M'); + }); + + test('loadSpotdlManifest returns null when the save file has no usable url', async () => { + const directory = await createTemporaryDirectory(); + await writeFile(path.join(directory, 'empty.spotdl'), JSON.stringify({ songs: [] })); + + const manifest = await loadSpotdlManifest(directory); + + expect(manifest).toBeNull(); + }); + + test('loadSpotdlManifest returns null when the save file is invalid JSON', async () => { + const directory = await createTemporaryDirectory(); + await writeFile(path.join(directory, 'broken.spotdl'), '{invalid json'); + + const manifest = await loadSpotdlManifest(directory); + + expect(manifest).toBeNull(); + }); +}); + +async function createTemporaryDirectory(): Promise { + const directory = await mkdtemp(path.join(os.tmpdir(), 'mdl-spotdl-test-')); + temporaryDirectories.push(directory); + await mkdir(directory, { recursive: true }); + return directory; +} diff --git a/packages/cli/src/lib/spotdl.ts b/packages/cli/src/lib/spotdl.ts new file mode 100644 index 0000000..16160ce --- /dev/null +++ b/packages/cli/src/lib/spotdl.ts @@ -0,0 +1,74 @@ +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import type { SyncManifest } from './types'; +import { getFirstNonEmptyString } from './utils'; + +export const SPOTDL_FILE_EXTENSION = '.spotdl'; + +type SpotdlSong = { + album_id?: string; + list_name?: string; + list_url?: string; + song_id?: string; + url?: string; +}; + +type SpotdlSaveFile = { + query?: string[]; + songs?: SpotdlSong[]; +}; + +export async function loadSpotdlManifest(directory: string): Promise { + const spotdlPath = await findSpotdlFile(directory); + if (!spotdlPath) { + return null; + } + + try { + const content = await readFile(spotdlPath, 'utf8'); + return parseSpotdlSaveFile(JSON.parse(content)); + } catch { + return null; + } +} + +async function findSpotdlFile(directory: string): Promise { + let entries: string[]; + try { + entries = await readdir(directory); + } catch { + return null; + } + + const fileName = entries.find((entry) => entry.toLowerCase().endsWith(SPOTDL_FILE_EXTENSION)); + return fileName ? path.join(directory, fileName) : null; +} + +function parseSpotdlSaveFile(value: unknown): SyncManifest | null { + const saveFile = value as Partial; + const firstSong = saveFile.songs?.[0]; + const playlistUrl = getFirstNonEmptyString(saveFile.query?.[0], firstSong?.list_url, firstSong?.url); + + if (!playlistUrl) { + return null; + } + + const playlistId = getFirstNonEmptyString(extractSpotifyId(playlistUrl), firstSong?.album_id, firstSong?.song_id); + if (!playlistId) { + return null; + } + + return { + version: 1, + provider: 'spotify', + playlistId, + playlistTitle: getFirstNonEmptyString(firstSong?.list_name) ?? 'Spotify playlist', + playlistUrl, + generatedAt: new Date().toISOString(), + tracks: [], + }; +} + +function extractSpotifyId(url: string): string | undefined { + return url.match(/\/(?:playlist|album|track)\/([A-Za-z0-9]+)/)?.[1]; +} diff --git a/packages/cli/src/ui/app.tsx b/packages/cli/src/ui/app.tsx index ca08b52..69f5fdd 100644 --- a/packages/cli/src/ui/app.tsx +++ b/packages/cli/src/ui/app.tsx @@ -205,7 +205,7 @@ export function App({ {state.phase.kind === 'confirming-resync' ? ( { dispatch({ type: 'resync-answer', value }); }}