Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/cli/src/lib/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
128 changes: 126 additions & 2 deletions packages/cli/src/lib/spotdl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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);

Expand All @@ -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<string> {
Expand Down
93 changes: 54 additions & 39 deletions packages/cli/src/lib/spotdl.ts
Original file line number Diff line number Diff line change
@@ -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<SyncManifest | null> {
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<string | null> {
async function findSpotdlFiles(directory: string): Promise<string[]> {
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<SpotdlSaveFile>;
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;
}