diff --git a/packages/cli/src/lib/schemas.ts b/packages/cli/src/lib/schemas.ts index ac77842..3949408 100644 --- a/packages/cli/src/lib/schemas.ts +++ b/packages/cli/src/lib/schemas.ts @@ -57,3 +57,14 @@ export const syncManifestSchema = Schema.Struct({ generatedAt: Schema.String, tracks: Schema.Array(manifestTrackSchema), }); + +export const spotdlSongSchema = Schema.Struct({ + list_name: Schema.NullOr(Schema.String), + list_url: Schema.NullOr(Schema.String), +}); + +export const spotdlSaveFileSchema = Schema.Struct({ + type: Schema.Literal('sync'), + query: Schema.Array(Schema.String), + songs: Schema.Array(spotdlSongSchema), +}); diff --git a/packages/cli/src/lib/spotdl.test.ts b/packages/cli/src/lib/spotdl.test.ts index 51e7e8b..faba358 100644 --- a/packages/cli/src/lib/spotdl.test.ts +++ b/packages/cli/src/lib/spotdl.test.ts @@ -51,11 +51,13 @@ describe('spotdl', () => { ); }); - test('loadSpotdlManifest falls back to the first song list_url when query is missing', async () => { + test('loadSpotdlManifest falls back to the first song list_url when query is empty', async () => { const directory = await createTemporaryDirectory(); await writeFile( path.join(directory, 'playlist.spotdl'), JSON.stringify({ + type: 'sync', + query: [], songs: [ { list_name: 'Fallback Playlist', @@ -73,7 +75,7 @@ describe('spotdl', () => { 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: [] })); + await writeFile(path.join(directory, 'empty.spotdl'), JSON.stringify({ type: 'sync', query: [], songs: [] })); const manifest = await loadSpotdlManifest(directory); @@ -88,6 +90,128 @@ describe('spotdl', () => { expect(manifest).toBeNull(); }); + + test('loadSpotdlManifest prefers a song list URL over a raw sync query', async () => { + const directory = await createTemporaryDirectory(); + await writeFile( + path.join(directory, 'saved.spotdl'), + JSON.stringify({ + type: 'sync', + query: ['saved'], + songs: [ + { + list_name: 'Liked Songs', + list_url: 'https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M', + }, + ], + }) + ); + + const manifest = await loadSpotdlManifest(directory); + + expect(manifest?.playlistUrl).toBe('https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M'); + }); + + test('loadSpotdlManifest rejects malformed save file fields', async () => { + const directory = await createTemporaryDirectory(); + await writeFile( + path.join(directory, 'malformed.spotdl'), + JSON.stringify({ + type: 'sync', + query: 'https://open.spotify.com/playlist/abc', + songs: [{ list_name: 'Malformed', list_url: null }], + }) + ); + + const manifest = await loadSpotdlManifest(directory); + + expect(manifest).toBeNull(); + }); + + test('loadSpotdlManifest rejects unsupported Spotify collection types', async () => { + const directory = await createTemporaryDirectory(); + await writeFile( + path.join(directory, 'artist.spotdl'), + JSON.stringify({ + type: 'sync', + query: ['https://open.spotify.com/artist/6l7J2uM3bM2BCh0tIPhWx8'], + songs: [ + { + list_name: 'ABC Kids', + list_url: 'https://open.spotify.com/artist/6l7J2uM3bM2BCh0tIPhWx8', + }, + ], + }) + ); + + const manifest = await loadSpotdlManifest(directory); + + expect(manifest).toBeNull(); + }); + + test('loadSpotdlManifest does not infer a collection from a saved track', async () => { + const directory = await createTemporaryDirectory(); + await writeFile( + path.join(directory, 'saved.spotdl'), + JSON.stringify({ + type: 'sync', + query: ['saved'], + songs: [ + { + list_name: 'Saved', + list_url: 'saved', + url: 'https://open.spotify.com/track/1eJdXVLxLoMWu1TkaeSL18', + }, + ], + }) + ); + + const manifest = await loadSpotdlManifest(directory); + + expect(manifest).toBeNull(); + }); + + test('loadSpotdlManifest normalizes Spotify collection URIs', async () => { + const directory = await createTemporaryDirectory(); + await writeFile( + path.join(directory, 'uri.spotdl'), + JSON.stringify({ + type: 'sync', + query: ['spotify:playlist:37i9dQZF1DXcBWIGoYBM5M'], + songs: [{ list_name: 'URI Playlist', list_url: null }], + }) + ); + + const manifest = await loadSpotdlManifest(directory); + + expect(manifest?.playlistId).toBe('37i9dQZF1DXcBWIGoYBM5M'); + expect(manifest?.playlistUrl).toBe('https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M'); + }); + + test('loadSpotdlManifest selects the first valid save file by name', async () => { + const directory = await createTemporaryDirectory(); + await writeFile(path.join(directory, 'a-invalid.spotdl'), '{invalid json'); + await writeFile( + path.join(directory, 'b-playlist.spotdl'), + JSON.stringify({ + type: 'sync', + query: ['https://open.spotify.com/playlist/bbb'], + songs: [{ list_name: 'B Playlist', list_url: null }], + }) + ); + await writeFile( + path.join(directory, 'c-playlist.spotdl'), + JSON.stringify({ + type: 'sync', + query: ['https://open.spotify.com/playlist/ccc'], + songs: [{ list_name: 'C Playlist', list_url: null }], + }) + ); + + const manifest = await loadSpotdlManifest(directory); + + expect(manifest?.playlistId).toBe('bbb'); + }); }); async function createTemporaryDirectory(): Promise { diff --git a/packages/cli/src/lib/spotdl.ts b/packages/cli/src/lib/spotdl.ts index 16160ce..b4546d9 100644 --- a/packages/cli/src/lib/spotdl.ts +++ b/packages/cli/src/lib/spotdl.ts @@ -1,74 +1,89 @@ import { readdir, readFile } from 'node:fs/promises'; import path from 'node:path'; +import { Either } from 'effect'; +import { detectProvider } from './providers/Providers'; +import { spotdlSaveFileSchema } from './schemas'; import type { SyncManifest } from './types'; -import { getFirstNonEmptyString } from './utils'; +import { decodeUnknownEither, 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[]; -}; +const SPOTDL_FILE_EXTENSION = '.spotdl'; export async function loadSpotdlManifest(directory: string): Promise { - const spotdlPath = await findSpotdlFile(directory); - if (!spotdlPath) { - return null; - } + const spotdlPaths = await findSpotdlFiles(directory); - try { - const content = await readFile(spotdlPath, 'utf8'); - return parseSpotdlSaveFile(JSON.parse(content)); - } catch { - return null; + for (const spotdlPath of spotdlPaths) { + try { + const content = await readFile(spotdlPath, 'utf8'); + const manifest = parseSpotdlSaveFile(JSON.parse(content)); + if (manifest) { + return manifest; + } + } catch {} } + + return null; } -async function findSpotdlFile(directory: string): Promise { +async function findSpotdlFiles(directory: string): Promise { let entries: string[]; try { entries = await readdir(directory); } catch { - return null; + return []; } - const fileName = entries.find((entry) => entry.toLowerCase().endsWith(SPOTDL_FILE_EXTENSION)); - return fileName ? path.join(directory, fileName) : null; + return entries + .filter((entry) => entry.toLowerCase().endsWith(SPOTDL_FILE_EXTENSION)) + .sort() + .map((fileName) => path.join(directory, fileName)); } 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) { + const saveFileResult = decodeUnknownEither(spotdlSaveFileSchema, value); + if (Either.isLeft(saveFileResult)) { return null; } - const playlistId = getFirstNonEmptyString(extractSpotifyId(playlistUrl), firstSong?.album_id, firstSong?.song_id); - if (!playlistId) { + const saveFile = saveFileResult.right; + const firstSong = saveFile.songs?.[0]; + const collection = [firstSong?.list_url, saveFile.query[0]] + .map(parseSpotifyCollection) + .find((candidate) => candidate !== null); + + if (!collection) { return null; } return { version: 1, provider: 'spotify', - playlistId, + playlistId: collection.id, playlistTitle: getFirstNonEmptyString(firstSong?.list_name) ?? 'Spotify playlist', - playlistUrl, + playlistUrl: collection.url, generatedAt: new Date().toISOString(), tracks: [], }; } -function extractSpotifyId(url: string): string | undefined { - return url.match(/\/(?:playlist|album|track)\/([A-Za-z0-9]+)/)?.[1]; +function parseSpotifyCollection(value: string | null | undefined): { id: string; url: string } | null { + const trimmedValue = value?.trim(); + if (!trimmedValue) { + return null; + } + + const uriMatch = trimmedValue.match(/^spotify:(playlist|album|track):([A-Za-z0-9]+)$/i); + if (uriMatch) { + return { + id: uriMatch[2], + url: `https://open.spotify.com/${uriMatch[1].toLowerCase()}/${uriMatch[2]}`, + }; + } + + if (detectProvider(trimmedValue) !== 'spotify') { + return null; + } + + const url = new URL(trimmedValue); + const pathMatch = url.pathname.match(/\/(playlist|album|track)\/([A-Za-z0-9]+)\/?$/i); + return pathMatch ? { id: pathMatch[2], url: trimmedValue } : null; }