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/README.md +15 -9
- package/dist/args.js +24 -11
- package/dist/browser.js +64 -40
- package/dist/cli.js +244 -231
- package/dist/config.js +46 -37
- package/dist/conversion.js +143 -0
- package/dist/csv.js +1 -1
- package/dist/deezer.js +26 -5
- package/dist/matcher.js +27 -1
- package/dist/registry.js +67 -0
- package/dist/spotify.js +31 -4
- package/dist/ytmusic-parse.js +75 -0
- package/dist/ytmusic-provider.js +156 -0
- package/dist/ytmusic.js +130 -0
- package/package.json +3 -2
- package/dist/converter.js +0 -115
- package/dist/reverse.js +0 -87
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
27
|
-
await saveCredentials({ recentUrls: next });
|
|
45
|
+
await write({ recentUrls: [url, ...list.filter((u) => u !== url)].slice(0, 10) });
|
|
28
46
|
}
|
|
29
|
-
|
|
47
|
+
async function write(patch) {
|
|
30
48
|
await mkdir(configDir(), { recursive: true, mode: 0o700 });
|
|
31
49
|
const current = await loadStored();
|
|
32
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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', '
|
|
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 ? '
|
|
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(`
|
|
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
|
|
129
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/registry.js
ADDED
|
@@ -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
|
|
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 (
|
|
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
|
|
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
|
-
//
|
|
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 });
|