plx-converter 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.js CHANGED
@@ -3,65 +3,74 @@ import { homedir, platform } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import 'dotenv/config';
5
5
  import { fetchBrowserCredentials } from './browser.js';
6
+ // The env override for each provider. The variable name is historical per provider; the value it
7
+ // carries is still opaque here.
8
+ const ENV_VAR = { deezer: 'DEEZER_ARL', spotify: 'SPOTIFY_DC', ytmusic: 'YTMUSIC_COOKIE' };
9
+ // The pre-provider-keyed shape stored one named field per cookie. These are deliberately not
10
+ // migrated (see #1): they are ignored, dropped on the next write, and the user re-fetches or
11
+ // re-pastes — which costs one browser dialog.
12
+ const LEGACY_KEYS = ['deezerArl', 'spotifyDc'];
6
13
  export function configDir() {
7
14
  if (platform() === 'win32')
8
15
  return join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'plx');
9
16
  return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'plx');
10
17
  }
11
18
  const file = () => join(configDir(), 'credentials.json');
12
- export async function loadConfig(requireDeezer = true) {
13
- let stored = {};
14
- try {
15
- stored = JSON.parse(await readFile(file(), 'utf8'));
19
+ async function loadStored() { try {
20
+ return JSON.parse(await readFile(file(), 'utf8'));
21
+ }
22
+ catch {
23
+ return {};
24
+ } }
25
+ export async function loadConfig() {
26
+ const stored = await loadStored();
27
+ const credentials = { ...(stored.credentials ?? {}) };
28
+ for (const [provider, envVar] of Object.entries(ENV_VAR)) {
29
+ const value = process.env[envVar];
30
+ if (value)
31
+ credentials[provider] = value;
16
32
  }
17
- catch { /* first run */ }
18
- const cfg = { deezerArl: process.env.DEEZER_ARL ?? stored.deezerArl ?? '', spotifyDc: process.env.SPOTIFY_DC ?? stored.spotifyDc ?? '', recentUrls: stored.recentUrls ?? [] };
19
- if (requireDeezer && !cfg.deezerArl)
20
- throw new Error('Konfigurasi kurang: DEEZER_ARL');
21
- return cfg;
33
+ const legacyCredentials = !stored.credentials && LEGACY_KEYS.some((key) => typeof stored[key] === 'string' && stored[key]);
34
+ return { credentials, recentUrls: stored.recentUrls ?? [], legacyCredentials };
35
+ }
36
+ // The credential for one provider, or '' when there is none. Callers decide whether they need it.
37
+ export function credential(cfg, provider) { return cfg.credentials[provider] ?? ''; }
38
+ export async function saveCredential(cfg, provider, value) {
39
+ cfg.credentials[provider] = value;
40
+ await write({ credentials: cfg.credentials });
22
41
  }
23
42
  export async function saveRecentUrl(url) {
24
43
  const stored = await loadStored();
25
44
  const list = Array.isArray(stored.recentUrls) ? stored.recentUrls : [];
26
- const next = [url, ...list.filter((u) => u !== url)].slice(0, 10);
27
- await saveCredentials({ recentUrls: next });
45
+ await write({ recentUrls: [url, ...list.filter((u) => u !== url)].slice(0, 10) });
28
46
  }
29
- export async function saveCredentials(values) {
47
+ async function write(patch) {
30
48
  await mkdir(configDir(), { recursive: true, mode: 0o700 });
31
49
  const current = await loadStored();
32
- await writeFile(file(), JSON.stringify({ ...current, ...values }, null, 2), { mode: 0o600 });
50
+ for (const key of LEGACY_KEYS)
51
+ delete current[key]; // clean the old shape out on first write
52
+ await writeFile(file(), JSON.stringify({ ...current, ...patch }, null, 2), { mode: 0o600 });
33
53
  try {
34
54
  await chmod(file(), 0o600);
35
55
  }
36
56
  catch { /* Windows */ }
37
57
  }
38
- async function loadStored() { try {
39
- return JSON.parse(await readFile(file(), 'utf8'));
40
- }
41
- catch {
42
- return {};
43
- } }
44
- // Semi-auto: fill credentials from a logged-in browser (pops the macOS Keychain
45
- // "Allow" dialog on first use), persist what's found, and return the field names
46
- // filled. By default only fills *missing* fields (env vars and stored credentials
47
- // win); pass overwrite=true to refetch and replace whatever the browser has. Never
48
- // throws — a denied keychain / TCC block just fills nothing, and the caller falls
49
- // back to the manual paste prompt.
58
+ // Semi-auto: fill credentials from a logged-in browser (pops the macOS Keychain "Allow" dialog on
59
+ // first use), persist what's found, and return the provider names filled. By default only fills
60
+ // providers with no credential yet (env vars and stored credentials win); pass overwrite=true to
61
+ // refetch and replace. Never throws — a denied keychain / TCC block just fills nothing, and the
62
+ // caller falls back to the manual paste prompt.
50
63
  export async function tryAutoFillCredentials(cfg, overwrite = false) {
64
+ if (!overwrite && Object.keys(ENV_VAR).every((provider) => cfg.credentials[provider]))
65
+ return [];
51
66
  const filled = [];
52
- const want = overwrite || !cfg.deezerArl || !cfg.spotifyDc;
53
- if (!want)
54
- return filled;
55
- const found = fetchBrowserCredentials();
56
- if ((overwrite || !cfg.deezerArl) && found.arl) {
57
- cfg.deezerArl = found.arl;
58
- filled.push('deezerArl');
59
- }
60
- if ((overwrite || !cfg.spotifyDc) && found.spDc) {
61
- cfg.spotifyDc = found.spDc;
62
- filled.push('spotifyDc');
67
+ for (const [provider, value] of Object.entries(fetchBrowserCredentials())) {
68
+ if (!value || (!overwrite && cfg.credentials[provider]))
69
+ continue;
70
+ cfg.credentials[provider] = value;
71
+ filled.push(provider);
63
72
  }
64
73
  if (filled.length)
65
- await saveCredentials({ deezerArl: cfg.deezerArl, spotifyDc: cfg.spotifyDc });
74
+ await write({ credentials: cfg.credentials });
66
75
  return filled;
67
76
  }
@@ -0,0 +1,143 @@
1
+ import { matchCandidates } from './matcher.js';
2
+ import { writeCsv } from './csv.js';
3
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
+ // One conversion path for every direction: read the playlist from `source`, match each track into
5
+ // `target`, report. Both providers are parameters, so N providers give N×(N−1) directions without
6
+ // a line of direction-specific code.
7
+ //
8
+ // Where the target can resolve a track by identifier, each match is re-resolved and compared to the
9
+ // source track; a match that diverges is still written but flagged in the report. Where the target
10
+ // can reorder, the target order is synced to the source; where it cannot, the conversion still
11
+ // completes by appending (ADR 0002).
12
+ export class Conversion {
13
+ source;
14
+ target;
15
+ output;
16
+ rows = [];
17
+ constructor(source, target, output) {
18
+ this.source = source;
19
+ this.target = target;
20
+ this.output = output;
21
+ }
22
+ // Every row carries the direction that produced it, so the file still makes sense a month later
23
+ // and a report from one of six directions is not mistaken for another.
24
+ record(row) { this.rows.push({ ...row, source: this.source.name, target: this.target.name }); }
25
+ async matchPlaylist(playlist, dryRun = false) {
26
+ let result;
27
+ try {
28
+ result = await this.source.readPlaylist(playlist.uri);
29
+ }
30
+ catch (error) {
31
+ // The message, not the name: every throw in this codebase is a plain Error, so `error.name` is
32
+ // always the literal "Error" and the one diagnostic the report carries would say nothing. It
33
+ // is also where a refusal explains itself — a mix, say — so it has to reach the user.
34
+ const reason = error instanceof Error ? error.message : String(error);
35
+ this.record({ playlist: playlist.name, title: '', artist: '', isrc: null, matched: false, note: `failed to read playlist: ${reason}` });
36
+ console.log(`[${playlist.name}] ✗ ${reason}`);
37
+ return { matchedIds: [], total: 0, truncated: false };
38
+ }
39
+ if (result.truncated) {
40
+ // The provider hit its own read cap; how many it managed is the only limit the path knows.
41
+ this.record({ playlist: playlist.name, title: '', artist: '', isrc: null, matched: false, note: `WARNING: truncated at ${result.tracks.length} tracks; remainder not converted` });
42
+ console.log(`[${playlist.name}] ⚠️ Truncated at ${result.tracks.length} tracks.`);
43
+ }
44
+ // Entries the source refused to convert are recorded before the matched ones, as their own kind
45
+ // of row: they were never searched for, so calling them "no match" would misreport them.
46
+ for (const entry of result.skipped ?? []) {
47
+ this.record({ playlist: playlist.name, title: entry.name, artist: entry.artist, isrc: null, matched: false, note: 'skipped: music video (channel name is not a reliable artist)' });
48
+ }
49
+ if (result.skipped?.length)
50
+ console.log(`[${playlist.name}] ${result.skipped.length} music video${result.skipped.length === 1 ? '' : 's'} skipped.`);
51
+ const matchedIds = [];
52
+ let verified = 0;
53
+ for (let i = 0; i < result.tracks.length; i += 1) {
54
+ const track = result.tracks[i];
55
+ let match = null;
56
+ for (let attempt = 0; attempt < 3; attempt += 1) {
57
+ try {
58
+ match = await this.target.search(track);
59
+ break;
60
+ }
61
+ catch (error) {
62
+ if (attempt === 2)
63
+ console.log(` ⚠️ search failed: ${error instanceof Error ? error.message : 'error'}`);
64
+ else
65
+ await sleep(2 ** attempt * 1000);
66
+ }
67
+ }
68
+ if (match) {
69
+ const flag = await this.verify(track, match);
70
+ if (this.target.resolveTrack && flag === null)
71
+ verified += 1;
72
+ matchedIds.push(match.id);
73
+ this.record({ playlist: playlist.name, title: track.name, artist: track.artist, isrc: null, matched: true, target_id: match.id, method: match.method, note: [flag, dryRun ? 'dry-run' : null].filter(Boolean).join('; ') || null });
74
+ console.log(` [${i + 1}/${result.tracks.length}] ✓ ${match.method} — ${track.name} — ${track.artist}${flag ? ` — ${flag}` : ''}`);
75
+ }
76
+ else {
77
+ this.record({ playlist: playlist.name, title: track.name, artist: track.artist, isrc: null, matched: false, note: dryRun ? 'dry-run: no match' : 'no match' });
78
+ console.log(` [${i + 1}/${result.tracks.length}] ✗ not found — ${track.name} — ${track.artist}`);
79
+ }
80
+ await sleep(300);
81
+ }
82
+ const missed = result.tracks.length - matchedIds.length;
83
+ console.log(this.target.resolveTrack
84
+ ? `[${playlist.name}] ${matchedIds.length}/${result.tracks.length} matched (${verified} verified${missed ? `, ${missed} match failed` : ''}).`
85
+ : `[${playlist.name}] ${matchedIds.length}/${result.tracks.length} tracks matched.`);
86
+ return { matchedIds, total: result.tracks.length, truncated: result.truncated };
87
+ }
88
+ // Resolve the match back from the target and re-check it against the source track (precision).
89
+ // Returns null when it verifies — or the target cannot resolve at all — and a report flag otherwise.
90
+ async verify(track, match) {
91
+ if (!this.target.resolveTrack)
92
+ return null;
93
+ try {
94
+ const meta = await this.target.resolveTrack(match.id);
95
+ const same = matchCandidates(track.name, track.artist, track.durationMs, [{ id: match.id, title: meta.name, artist: meta.artist, duration: meta.durationMs != null ? Math.round(meta.durationMs / 1000) : null }]);
96
+ return same ? null : '⚠️ recheck (possibly wrong track)';
97
+ }
98
+ catch {
99
+ return '⚠️ verification failed';
100
+ }
101
+ }
102
+ async writePlaylist(name, ids, title = `[plx] ${name}`) {
103
+ if (!ids.length) {
104
+ console.log(`[${name}] No matching tracks — playlist not created.`);
105
+ return;
106
+ }
107
+ const unique = [...new Set(ids)];
108
+ await this.addInChunks(await this.target.createPlaylist(title), unique);
109
+ console.log(`[${name}] ${this.target.name} playlist created: ${title} (${unique.length} tracks)`);
110
+ }
111
+ // Append to an existing playlist, skipping tracks already present (dedupe), then reorder if the
112
+ // target can. A target that cannot reorder ends here — appended, not order-synced.
113
+ async writeToExisting(name, playlistId, ids) {
114
+ if (!ids.length) {
115
+ console.log(`[${name}] No matching tracks.`);
116
+ return;
117
+ }
118
+ const existing = await this.target.getPlaylistTrackIds(playlistId);
119
+ // Dedupe within the batch too — Deezer rejects any chunk containing a repeated id.
120
+ const unique = [...new Set(ids)];
121
+ const batchDupes = ids.length - unique.length;
122
+ const alreadyThere = unique.filter((id) => existing.has(id)).length;
123
+ const toAdd = unique.filter((id) => !existing.has(id));
124
+ const added = toAdd.length ? await this.addInChunks(playlistId, toAdd) : 0;
125
+ const parts = [];
126
+ if (alreadyThere)
127
+ parts.push(`${alreadyThere} already present`);
128
+ if (batchDupes)
129
+ parts.push(`${batchDupes} duplicates removed`);
130
+ console.log(`[${name}] ${added} new tracks added${parts.length ? ` (${parts.join(', ')})` : ''}.`);
131
+ if (await this.target.reorder?.(playlistId, ids))
132
+ console.log(`[${name}] playlist order synced to source.`);
133
+ }
134
+ async addInChunks(playlistId, ids) {
135
+ let added = 0;
136
+ for (let start = 0; start < ids.length; start += 100) {
137
+ added += await this.target.addTracks(playlistId, ids.slice(start, start + 100));
138
+ await sleep(200);
139
+ }
140
+ return added;
141
+ }
142
+ async writeReport() { await writeCsv(this.output, this.rows); }
143
+ }
package/dist/csv.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { writeFile } from 'node:fs/promises';
2
- const fields = ['playlist', 'title', 'artist', 'isrc', 'matched', 'deezer_id', 'method', 'note'];
2
+ const fields = ['playlist', 'source', 'target', 'title', 'artist', 'isrc', 'matched', 'target_id', 'method', 'note'];
3
3
  const cell = (value) => {
4
4
  const text = value == null ? '' : String(value);
5
5
  return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
package/dist/deezer.js CHANGED
@@ -34,6 +34,7 @@ function jwtExpiryMs(jwt) {
34
34
  }
35
35
  export class DeezerClient {
36
36
  arl;
37
+ name = 'Deezer';
37
38
  jwt;
38
39
  jwtExpiresAt = 0;
39
40
  constructor(arl) {
@@ -98,9 +99,9 @@ export class DeezerClient {
98
99
  const x = await this.gql('mutation AddTracksToPlaylist($playlistId:String!,$trackIds:[String!]!){addTracksToPlaylist(input:{playlistId:$playlistId,trackIds:$trackIds}){__typename ... on PlaylistAddTracksOutput{addedTrackIds} ... on PlaylistAddTracksError{isNotAllowed}}}', 'AddTracksToPlaylist', { playlistId: id, trackIds });
99
100
  const out = x.addTracksToPlaylist;
100
101
  if (out.__typename === 'PlaylistAddTracksError')
101
- throw new Error(out.isNotAllowed ? 'Tidak diizinkan mengubah playlist ini (bukan milikmu / hanya di-follow)' : 'Gagal menambah lagu');
102
+ throw new Error(out.isNotAllowed ? 'Not allowed to modify this playlist (not yours / follow-only)' : 'Failed to add tracks');
102
103
  if (!out.addedTrackIds || out.addedTrackIds.length !== trackIds.length)
103
- throw new Error(`Hanya ${out.addedTrackIds?.length ?? 0}/${trackIds.length} lagu yang benar-benar ditambahkan`);
104
+ throw new Error(`Only ${out.addedTrackIds?.length ?? 0}/${trackIds.length} tracks were actually added`);
104
105
  return out.addedTrackIds.length;
105
106
  }
106
107
  // The user's own playlists. ponytail: capped at first 50 — personal use, paginate only if someone hits it.
@@ -125,8 +126,9 @@ export class DeezerClient {
125
126
  after = data.playlist.tracks.pageInfo.endCursor;
126
127
  }
127
128
  }
128
- // Full track metadata (title/artist/duration) in playlist order — source read for the reverse flow.
129
- async getPlaylistTracks(playlistId) {
129
+ // Full track metadata (title/artist/duration) in playlist order — the source read.
130
+ // Deezer paginates the whole playlist, so a read is never truncated.
131
+ async readPlaylist(playlistId) {
130
132
  const tracks = [];
131
133
  let after = null;
132
134
  for (;;) {
@@ -134,7 +136,7 @@ export class DeezerClient {
134
136
  for (const e of data.playlist.tracks.edges)
135
137
  tracks.push({ name: e.node.title, artist: e.node.contributorNames[0] ?? '', durationMs: e.node.duration * 1000 });
136
138
  if (!data.playlist.tracks.pageInfo.hasNextPage || !data.playlist.tracks.pageInfo.endCursor)
137
- return tracks;
139
+ return { tracks, truncated: false };
138
140
  after = data.playlist.tracks.pageInfo.endCursor;
139
141
  }
140
142
  }
@@ -142,4 +144,23 @@ export class DeezerClient {
142
144
  async moveTrack(playlistId, trackId, afterTrackId) {
143
145
  await this.gql('mutation MoveTrackInPlaylist($playlistId:String!,$trackId:String!,$afterTrackId:String){moveTrackInPlaylist(input:{playlistId:$playlistId,trackId:$trackId,afterTrackId:$afterTrackId}){__typename}}', 'MoveTrackInPlaylist', { playlistId, trackId, afterTrackId });
144
146
  }
147
+ // Reorder capability (ADR 0002). Moves each desired track into place from the front, so the
148
+ // matched tracks end up in source order. Deezer moves by track id, so no per-item handle is needed.
149
+ // Skips when the matched tracks are already in *relative* order: tracks already in the target that
150
+ // aren't in `trackIds` keep their positions and may stay interleaved. Resolves true if it moved.
151
+ async reorder(playlistId, trackIds) {
152
+ const desired = [...new Set(trackIds)].filter((id) => id);
153
+ if (desired.length < 2)
154
+ return false;
155
+ const current = await this.getPlaylistTrackOrder(playlistId);
156
+ const movable = new Set(desired);
157
+ const kept = current.filter((id) => movable.has(id));
158
+ if (kept.length === desired.length && desired.every((id, i) => kept[i] === id))
159
+ return false;
160
+ for (let i = 0; i < desired.length; i += 1) {
161
+ await this.moveTrack(playlistId, desired[i], i === 0 ? null : desired[i - 1]);
162
+ await new Promise((resolve) => setTimeout(resolve, 100));
163
+ }
164
+ return true;
165
+ }
145
166
  }
package/dist/matcher.js CHANGED
@@ -22,4 +22,30 @@ export function matchCandidates(title, artist, durationMs, candidates) {
22
22
  }
23
23
  return null;
24
24
  }
25
- export function matchTrack(track, candidates) { return matchCandidates(track.name, track.artist, track.durationMs, candidates); }
25
+ // Match rules for a music video, used only after the catalog has produced nothing.
26
+ //
27
+ // A music video's stated artist is usually the uploading channel, so the artist rule relaxes from
28
+ // equality to containment: the source artist must appear somewhere in the video's title or channel
29
+ // name. That is a much weaker guard, so in exchange the duration tolerance becomes **mandatory**
30
+ // rather than optional — a candidate with no duration is rejected outright. Duration is what
31
+ // separates the same recording uploaded as a video from someone else's cover of it.
32
+ export function matchVideoCandidates(title, artist, durationMs, candidates) {
33
+ if (durationMs == null)
34
+ return null; // nothing left to guard with
35
+ const nt = normalize(stripFeat(title));
36
+ const na = artistKey(firstArtist(artist));
37
+ if (!nt || !na)
38
+ return null;
39
+ for (const c of candidates) {
40
+ if (c.duration == null || !matchDurationMs(durationMs, c.duration))
41
+ continue;
42
+ const ct = normalize(c.title);
43
+ // The source artist has to show up somewhere attributable — the video's title or its channel.
44
+ if (!artistKey(c.title).includes(na) && !artistKey(c.artist).includes(na))
45
+ continue;
46
+ if (!ct.includes(nt) && !nt.includes(ct))
47
+ continue;
48
+ return { id: c.id, title: c.title, artist: c.artist, method: 'video' };
49
+ }
50
+ return null;
51
+ }
@@ -0,0 +1,67 @@
1
+ import { DeezerClient, resolveDeezerPlaylistId } from './deezer.js';
2
+ import { SpotifyProvider, anonymousToken, authenticatedToken, parsePlaylistId, playlistName } from './spotify.js';
3
+ import { parsePlaylistRef, validateSession } from './ytmusic.js';
4
+ import { YtMusicProvider } from './ytmusic-provider.js';
5
+ const spotify = {
6
+ key: 'spotify',
7
+ label: 'Spotify',
8
+ matches: ['open.spotify.com', 'spotify:playlist:'],
9
+ credentialLabel: 'Spotify sp_dc',
10
+ credentialHint: 'login open.spotify.com → F12 → Application → Cookies → sp_dc → copy value',
11
+ loginSite: 'Spotify (open.spotify.com)',
12
+ anonymousRead: true,
13
+ parseRef: async (ref) => parsePlaylistId(ref),
14
+ playlistName,
15
+ // An sp_dc mints a full search+write token; without one the anonymous token still reads public
16
+ // playlists, which is what makes a credential-free dry run possible.
17
+ create: async (cred) => new SpotifyProvider(cred ? await authenticatedToken(cred) : await anonymousToken()),
18
+ validate: async (cred) => { await authenticatedToken(cred); return 'session accepted'; },
19
+ };
20
+ const deezer = {
21
+ key: 'deezer',
22
+ label: 'Deezer',
23
+ matches: ['deezer.com', 'link.deezer.com'],
24
+ credentialLabel: 'Deezer ARL',
25
+ credentialHint: 'login deezer.com → F12 → Application → Cookies → arl → copy value',
26
+ loginSite: 'Deezer (deezer.com)',
27
+ // Deezer's search is public, but reading a *playlist* goes through the authenticated GraphQL API.
28
+ anonymousRead: false,
29
+ parseRef: resolveDeezerPlaylistId,
30
+ playlistName: async (id) => id,
31
+ validate: async (cred) => `account ${(await new DeezerClient(cred).getMe()).id}`,
32
+ create: async (cred) => {
33
+ const client = new DeezerClient(cred);
34
+ // Fail fast on an expired ARL rather than midway through a conversion. With no credential at
35
+ // all this is a search-only client — enough for a dry run, since Deezer's search is public.
36
+ if (cred)
37
+ await client.getMe();
38
+ return client;
39
+ },
40
+ };
41
+ const ytmusic = {
42
+ key: 'ytmusic',
43
+ label: 'YouTube Music',
44
+ // A YouTube Music playlist id *is* a YouTube playlist id, so both hosts name the same thing.
45
+ matches: ['music.youtube.com', 'youtube.com'],
46
+ credentialLabel: 'YouTube Music cookies',
47
+ credentialHint: 'login music.youtube.com → F12 → Network → any request → Request Headers → copy the whole "cookie:" value',
48
+ loginSite: 'YouTube Music (music.youtube.com)',
49
+ anonymousRead: false,
50
+ parseRef: async (ref) => parsePlaylistRef(ref),
51
+ // The title needs an authenticated browse, so YtMusicProvider resolves it; this is the fallback.
52
+ playlistName: async (id) => id,
53
+ create: async (cred) => new YtMusicProvider(cred),
54
+ validate: validateSession,
55
+ };
56
+ export const PROVIDERS = [spotify, deezer, ytmusic];
57
+ export function providerFor(key) {
58
+ return PROVIDERS.find((p) => p.key === key.toLowerCase());
59
+ }
60
+ // The keys valid after --to.
61
+ export const targetKeys = () => PROVIDERS.map((p) => p.key);
62
+ // Which provider a playlist link belongs to. The link already names its service, so a command line
63
+ // that gives one never has to restate the source.
64
+ export function inferSource(link) {
65
+ const ref = link.trim().toLowerCase();
66
+ return PROVIDERS.find((p) => p.matches.some((m) => ref.includes(m)));
67
+ }
package/dist/spotify.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createHmac } from 'node:crypto';
2
2
  import { z } from 'zod';
3
3
  import { browserHeaders, fetchJson, fetchText } from './http.js';
4
+ import { matchCandidates, searchQuery } from './matcher.js';
4
5
  const PATHFINDER_URL = 'https://api-partner.spotify.com/pathfinder/v1/query';
5
6
  const FETCH_PLAYLIST_SHA = 'a65e12194ed5fc443a1cdebed5fabe33ca5b07b987185d63c72483867ad13cb4';
6
7
  export const EMBED_TRACK_LIMIT = 100;
@@ -87,7 +88,7 @@ export function parsePlaylistId(ref) {
87
88
  return input;
88
89
  }
89
90
  // Resolve a spotify:track:ID URI to its real title/artist/duration via the anonymous
90
- // embed endpoint — used to verify reverse-flow matches (precision check).
91
+ // embed endpoint — used to verify matches into a Spotify target (precision check).
91
92
  export async function resolveTrackMeta(uri) {
92
93
  const id = uri.split(':').pop() ?? uri;
93
94
  const entity = (await nextData(`https://open.spotify.com/embed/track/${id}`)).props?.pageProps?.state?.data?.entity;
@@ -98,7 +99,7 @@ export async function resolveTrackMeta(uri) {
98
99
  durationMs: typeof entity?.duration === 'number' ? entity.duration : null,
99
100
  };
100
101
  }
101
- // --- Authenticated write (reverse flow: Deezer → Spotify) ---
102
+ // --- Authenticated search + write (Spotify as a conversion target) ---
102
103
  const PATHFINDER_V2_URL = 'https://api-partner.spotify.com/pathfinder/v2/query';
103
104
  const SPCLIENT_URL = 'https://spclient.wg.spotify.com';
104
105
  // Persisted-query hashes for write/search — captured from the web player; they rotate (see CONTINUATION.md).
@@ -155,7 +156,7 @@ async function tokenFromSpDc(spDc) {
155
156
  }
156
157
  // Authenticated write token from an sp_dc cookie (full web-player scope: search + write).
157
158
  export function authenticatedToken(spDc) { return tokenFromSpDc(spDc); }
158
- // Spotify search, shaped as DeezerCandidate (duration in seconds) so matchCandidates is reusable.
159
+ // Spotify search, shaped as Candidate (duration in seconds) so matchCandidates is reusable.
159
160
  export async function searchTrack(term, token) {
160
161
  const body = { variables: { searchTerm: term, offset: 0, limit: 10, numberOfTopResults: 10, includePreReleases: false, includeAudiobooks: false, includeAuthors: false }, operationName: 'searchTracks', extensions: { persistedQuery: { version: 1, sha256Hash: SEARCH_TRACKS_SHA } } };
161
162
  const raw = await fetchJson(PATHFINDER_V2_URL, { method: 'POST', headers: writeHeaders(token), body: JSON.stringify(body) });
@@ -205,7 +206,33 @@ export async function listPlaylists(token) {
205
206
  return [{ uri: d.uri, name: d.name }];
206
207
  });
207
208
  }
208
- // Track URIs currently in a playlist — for dedupe on the reverse flow. Reuses the proven v1 fetchPlaylist read path.
209
+ // Spotify identifies a playlist by bare id on the read paths and by URI on the write paths, so the
210
+ // provider normalizes whichever form it is handed (URL, URI, or raw id) at the boundary.
211
+ const playlistUri = (ref) => `spotify:playlist:${parsePlaylistId(ref)}`;
212
+ // Spotify as a Provider. `token` is anonymous (read-only) or minted from sp_dc (search + write);
213
+ // a conversion only ever asks for what its direction needs. No reorder — Spotify exposes no move
214
+ // primitive in the endpoints plx uses, so a Spotify target is dedupe-and-append (ADR 0002).
215
+ export class SpotifyProvider {
216
+ token;
217
+ name = 'Spotify';
218
+ constructor(token) {
219
+ this.token = token;
220
+ }
221
+ readPlaylist(ref) { return readPlaylist(parsePlaylistId(ref), this.token); }
222
+ async search(track) {
223
+ const candidates = await searchTrack(searchQuery(track.name, track.artist), this.token);
224
+ return matchCandidates(track.name, track.artist, track.durationMs, candidates);
225
+ }
226
+ createPlaylist(title) { return createPlaylist(title, this.token); }
227
+ // Returns the count submitted, not one the service confirmed: addToPlaylist reports transport
228
+ // errors only, so a silently dropped (relinked/unavailable) uri is still counted. Deezer's
229
+ // addTracks does validate what it accepted — the two differ on how much this number is worth.
230
+ async addTracks(ref, trackUris) { await addTracks(playlistUri(ref), trackUris, this.token); return trackUris.length; }
231
+ async getPlaylistTrackIds(ref) { return new Set(await fetchTrackUris(parsePlaylistId(ref), this.token)); }
232
+ async listPlaylists() { return (await listPlaylists(this.token)).map((p) => ({ id: p.uri, title: p.name })); }
233
+ resolveTrack(uri) { return resolveTrackMeta(uri); }
234
+ }
235
+ // Track URIs currently in a playlist — for dedupe against an existing target. Reuses the proven v1 fetchPlaylist read path.
209
236
  export async function fetchTrackUris(id, token) {
210
237
  const uris = [];
211
238
  for (let offset = 0;;) {
@@ -0,0 +1,75 @@
1
+ // Only a catalog release (ATV) counts as a song. Everything else — an official music video, an
2
+ // official-source upload, a user upload, a podcast — is a video whose stated artist is the channel.
3
+ // An entry with no type at all is an unavailable/removed track and is treated the same way.
4
+ const SONG_TYPE = 'MUSIC_VIDEO_TYPE_ATV';
5
+ const runsText = (t) => t?.runs?.map((r) => r.text ?? '').join('') ?? '';
6
+ // "3:49" → 229, "1:02:03" → 3723. Anything that is not a clock reading is null, *not* zero — a
7
+ // playlist read has a duration column and a search result does not, and callers tell the two apart
8
+ // by null. Returning 0 for an empty column silently swallows the fallback.
9
+ export function parseDuration(text) {
10
+ const trimmed = text.trim();
11
+ if (!/^\d+(:[0-5]?\d)+$/.test(trimmed))
12
+ return null;
13
+ return trimmed.split(':').map(Number).reduce((total, part) => total * 60 + part, 0);
14
+ }
15
+ // Every musicResponsiveListItemRenderer anywhere in the response. The path to them differs between
16
+ // a playlist browse, a search, and a continuation, and none of those paths is stable, so this walks.
17
+ export function renderers(node, found = []) {
18
+ if (!node || typeof node !== 'object')
19
+ return found;
20
+ const record = node;
21
+ if (record.musicResponsiveListItemRenderer)
22
+ found.push(record.musicResponsiveListItemRenderer);
23
+ for (const child of Object.values(record))
24
+ renderers(child, found);
25
+ return found;
26
+ }
27
+ // The continuation token for the next page, if the response has one.
28
+ export function continuationToken(node) {
29
+ if (!node || typeof node !== 'object')
30
+ return null;
31
+ const record = node;
32
+ const token = record.continuationItemRenderer
33
+ ?.continuationEndpoint?.continuationCommand?.token;
34
+ if (token)
35
+ return token;
36
+ for (const child of Object.values(record)) {
37
+ const found = continuationToken(child);
38
+ if (found)
39
+ return found;
40
+ }
41
+ return null;
42
+ }
43
+ export function playlistTitle(response) {
44
+ const micro = response?.microformat;
45
+ return micro?.microformatDataRenderer?.title ?? '';
46
+ }
47
+ function parseItem(renderer) {
48
+ const watch = renderer.overlay?.musicItemThumbnailOverlayRenderer?.content?.musicPlayButtonRenderer?.playNavigationEndpoint?.watchEndpoint;
49
+ const videoId = renderer.playlistItemData?.videoId ?? watch?.videoId;
50
+ if (!videoId)
51
+ return null;
52
+ const columns = renderer.flexColumns?.map((c) => runsText(c.musicResponsiveListItemFlexColumnRenderer?.text)) ?? [];
53
+ const title = columns[0] ?? '';
54
+ if (!title)
55
+ return null;
56
+ // A playlist read puts the duration in its own fixed column. A search result has no fixed column
57
+ // and instead trails it on the subtitle line: "Artist • Album • 3:49" (song), or
58
+ // "Channel • 178M views • 4:10" (video). Either way the duration is the last •-separated part.
59
+ const fixed = runsText(renderer.fixedColumns?.[0]?.musicResponsiveListItemFixedColumnRenderer?.text);
60
+ const subtitleParts = (columns[1] ?? '').split('•').map((s) => s.trim()).filter(Boolean);
61
+ const trailing = subtitleParts.length > 1 ? subtitleParts[subtitleParts.length - 1] : '';
62
+ const durationSec = parseDuration(fixed) ?? parseDuration(trailing);
63
+ return {
64
+ videoId,
65
+ setVideoId: renderer.playlistItemData?.playlistSetVideoId,
66
+ title,
67
+ artist: subtitleParts[0] ?? '',
68
+ durationSec,
69
+ isSong: watch?.watchEndpointMusicSupportedConfigs?.watchEndpointMusicConfig?.musicVideoType === SONG_TYPE,
70
+ };
71
+ }
72
+ export function parseItems(response) {
73
+ return renderers(response).flatMap((r) => parseItem(r) ?? []);
74
+ }
75
+ export const toCandidate = (item) => ({ id: item.videoId, title: item.title, artist: item.artist, duration: item.durationSec });