plx-converter 0.1.8 → 0.1.9
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/conversion.js +31 -16
- package/dist/deezer.js +4 -3
- package/dist/matcher.js +82 -4
- package/dist/spotify.js +12 -4
- package/dist/ytmusic-parse.js +4 -1
- package/dist/ytmusic-provider.js +53 -11
- package/package.json +1 -1
package/dist/conversion.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { matchCandidates } from './matcher.js';
|
|
1
|
+
import { matchByDuration, matchByTitle, matchCandidates } from './matcher.js';
|
|
2
2
|
import { writeCsv } from './csv.js';
|
|
3
3
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
4
4
|
// One conversion path for every direction: read the playlist from `source`, match each track into
|
|
@@ -21,7 +21,7 @@ export class Conversion {
|
|
|
21
21
|
}
|
|
22
22
|
// Every row carries the direction that produced it, so the file still makes sense a month later
|
|
23
23
|
// and a report from one of six directions is not mistaken for another.
|
|
24
|
-
record(row) {
|
|
24
|
+
record(row) { const full = { ...row, source: this.source.name, target: this.target.name }; this.rows.push(full); return full; }
|
|
25
25
|
async matchPlaylist(playlist, dryRun = false) {
|
|
26
26
|
let result;
|
|
27
27
|
try {
|
|
@@ -44,12 +44,11 @@ export class Conversion {
|
|
|
44
44
|
// Entries the source refused to convert are recorded before the matched ones, as their own kind
|
|
45
45
|
// of row: they were never searched for, so calling them "no match" would misreport them.
|
|
46
46
|
for (const entry of result.skipped ?? []) {
|
|
47
|
-
this.record({ playlist: playlist.name, title: entry.name, artist: entry.artist, isrc: null, matched: false, note:
|
|
47
|
+
this.record({ playlist: playlist.name, title: entry.name, artist: entry.artist, isrc: null, matched: false, note: `skipped: ${entry.reason}` });
|
|
48
48
|
}
|
|
49
49
|
if (result.skipped?.length)
|
|
50
|
-
console.log(`[${playlist.name}] ${result.skipped.length}
|
|
51
|
-
const
|
|
52
|
-
let verified = 0;
|
|
50
|
+
console.log(`[${playlist.name}] ${result.skipped.length} entr${result.skipped.length === 1 ? 'y' : 'ies'} skipped (see the report).`);
|
|
51
|
+
const matched = [];
|
|
53
52
|
for (let i = 0; i < result.tracks.length; i += 1) {
|
|
54
53
|
const track = result.tracks[i];
|
|
55
54
|
let match = null;
|
|
@@ -65,24 +64,35 @@ export class Conversion {
|
|
|
65
64
|
await sleep(2 ** attempt * 1000);
|
|
66
65
|
}
|
|
67
66
|
}
|
|
67
|
+
// A track the source resolved from a music video says so: the row is the song, not the video.
|
|
68
|
+
const via = track.video ? `from music video "${track.video}"` : null;
|
|
68
69
|
if (match) {
|
|
69
70
|
const flag = await this.verify(track, match);
|
|
70
|
-
|
|
71
|
-
|
|
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 });
|
|
71
|
+
const row = this.record({ playlist: playlist.name, title: track.name, artist: track.artist, isrc: null, matched: true, target_id: match.id, method: match.method, note: [via, flag, dryRun ? 'dry-run' : null].filter(Boolean).join('; ') || null });
|
|
72
|
+
matched.push({ id: match.id, fromVideo: Boolean(track.video), verified: Boolean(this.target.resolveTrack) && flag === null, row });
|
|
74
73
|
console.log(` [${i + 1}/${result.tracks.length}] ✓ ${match.method} — ${track.name} — ${track.artist}${flag ? ` — ${flag}` : ''}`);
|
|
75
74
|
}
|
|
76
75
|
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' });
|
|
76
|
+
this.record({ playlist: playlist.name, title: track.name, artist: track.artist, isrc: null, matched: false, note: [dryRun ? 'dry-run: no match' : 'no match', via].filter(Boolean).join('; ') });
|
|
78
77
|
console.log(` [${i + 1}/${result.tracks.length}] ✗ not found — ${track.name} — ${track.artist}`);
|
|
79
78
|
}
|
|
80
79
|
await sleep(300);
|
|
81
80
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
81
|
+
// A music video whose song the playlist also holds as a song gives way to that song, which keeps
|
|
82
|
+
// its own place. Compared on the target id: the source may carry the two under different ids. A
|
|
83
|
+
// video with no song twin still converts. `video` is provenance on the Track, not a provider's.
|
|
84
|
+
const songIds = new Set(matched.filter((m) => !m.fromVideo).map((m) => m.id));
|
|
85
|
+
const songTwin = (m) => m.fromVideo && songIds.has(m.id);
|
|
86
|
+
const kept = matched.filter((m) => !songTwin(m));
|
|
87
|
+
const dropped = matched.length - kept.length;
|
|
88
|
+
for (const m of matched)
|
|
89
|
+
if (songTwin(m))
|
|
90
|
+
Object.assign(m.row, { matched: false, method: null, note: `skipped: music video, its song is already in the playlist (${m.row.target_id})` });
|
|
91
|
+
const missed = result.tracks.length - matched.length;
|
|
92
|
+
const verified = kept.filter((m) => m.verified).length;
|
|
93
|
+
const parts = [this.target.resolveTrack ? `${verified} verified` : null, dropped ? `${dropped} music video${dropped === 1 ? '' : 's'} left out, song already in playlist` : null, missed ? `${missed} match failed` : null].filter(Boolean);
|
|
94
|
+
console.log(`[${playlist.name}] ${kept.length}/${result.tracks.length} tracks converted${parts.length ? ` (${parts.join(', ')})` : ''}.`);
|
|
95
|
+
const matchedIds = kept.map((m) => m.id);
|
|
86
96
|
return { matchedIds, total: result.tracks.length, truncated: result.truncated };
|
|
87
97
|
}
|
|
88
98
|
// Resolve the match back from the target and re-check it against the source track (precision).
|
|
@@ -92,7 +102,12 @@ export class Conversion {
|
|
|
92
102
|
return null;
|
|
93
103
|
try {
|
|
94
104
|
const meta = await this.target.resolveTrack(match.id);
|
|
95
|
-
const
|
|
105
|
+
const resolved = [{ id: match.id, title: meta.name, artist: meta.artist, duration: meta.durationMs != null ? Math.round(meta.durationMs / 1000) : null }];
|
|
106
|
+
// A last-resort match re-checks under the rule that accepted it: a title-only match differs in
|
|
107
|
+
// its artist by definition, so holding it to matchCandidates would flag every one of them.
|
|
108
|
+
const same = matchCandidates(track.name, track.artist, track.durationMs, resolved)
|
|
109
|
+
?? (match.method === 'title-only' ? matchByTitle(track.name, track.artist, track.durationMs, resolved) : null)
|
|
110
|
+
?? (match.method === 'duration-only' ? matchByDuration(track.name, track.artist, track.durationMs, resolved) : null);
|
|
96
111
|
return same ? null : '⚠️ recheck (possibly wrong track)';
|
|
97
112
|
}
|
|
98
113
|
catch {
|
package/dist/deezer.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { fetchJson, retry } from './http.js';
|
|
3
|
-
import { firstArtist, matchByDuration, matchCandidates, searchQuery
|
|
3
|
+
import { fieldQuery, firstArtist, matchByDuration, matchByTitle, matchCandidates, searchQuery } from './matcher.js';
|
|
4
4
|
const gqlSchema = z.object({ errors: z.array(z.object({ message: z.string() })).optional(), data: z.unknown() });
|
|
5
5
|
const API = 'https://api.deezer.com';
|
|
6
6
|
const GQL = 'https://pipe.deezer.com/api';
|
|
@@ -72,7 +72,7 @@ export class DeezerClient {
|
|
|
72
72
|
if (match)
|
|
73
73
|
return match;
|
|
74
74
|
// Free-text relevance ranks covers/remixes above the real K-pop track; fall back to field syntax.
|
|
75
|
-
const field = `${API}/search?${new URLSearchParams({ q:
|
|
75
|
+
const field = `${API}/search?${new URLSearchParams({ q: fieldQuery(track.name, track.artist), limit: '5' })}`;
|
|
76
76
|
const data2 = await retry(async () => fetchJson(field));
|
|
77
77
|
const candidates2 = toCandidates(data2);
|
|
78
78
|
const match2 = matchCandidates(track.name, track.artist, track.durationMs, candidates2);
|
|
@@ -82,7 +82,8 @@ export class DeezerClient {
|
|
|
82
82
|
const byAlbum = await this.searchByAlbum(track);
|
|
83
83
|
if (byAlbum)
|
|
84
84
|
return byAlbum;
|
|
85
|
-
|
|
85
|
+
const all = [...candidates, ...candidates2];
|
|
86
|
+
return matchByDuration(track.name, track.artist, track.durationMs, all) ?? matchByTitle(track.name, track.artist, track.durationMs, all);
|
|
86
87
|
}
|
|
87
88
|
// Artist → albums → album tracks, for tracks the search index never surfaces (e.g. "Salty & Sweet").
|
|
88
89
|
async searchByAlbum(track) {
|
package/dist/matcher.js
CHANGED
|
@@ -2,22 +2,44 @@ export function normalize(value) {
|
|
|
2
2
|
return value.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase().replace(/\(.*?\)/g, '').replace(/[\[(]\s*(feat\.?|ft\.?)\s*/gi, '').replace(/\]/g, '').replace(/[^\p{L}\p{N}_\s]/gu, '').replace(/\s+/g, ' ').trim();
|
|
3
3
|
}
|
|
4
4
|
export function firstArtist(artist) { return artist.split(',')[0].trim(); }
|
|
5
|
+
// The artist a comparison keys on. YouTube Music credits co-artists as one string — "ROSÉ & Bruno
|
|
6
|
+
// Mars" — where Spotify names ROSÉ alone, so this splits on " & " as well as ",". Both sides of a
|
|
7
|
+
// comparison split alike, which keeps a duo's own name ("Chase & Status") equal to itself. Search
|
|
8
|
+
// terms keep the whole credit (firstArtist): free text finds a duo by its full name.
|
|
9
|
+
function leadArtist(artist) { return artist.split(/,|\s&\s/)[0].trim(); }
|
|
5
10
|
function artistKey(artist) { return normalize(artist).replace(/\s+/g, ''); }
|
|
6
11
|
export function stripFeat(title) { return title.replace(/\s*[([]\s*(feat|ft)\.?[^)\]]*[)\]]/gi, '').trim(); }
|
|
12
|
+
// A title with its bracketed tags kept as words. normalize() drops "(Eng Ver.)" outright, while a
|
|
13
|
+
// catalog writing the same tag as a suffix — "Algorithm - Eng Ver." — keeps it, so on normalize()
|
|
14
|
+
// alone it is the untagged original that compares equal.
|
|
15
|
+
function taggedTitle(title) { return normalize(stripFeat(title).replace(/[()[\]]/g, ' ')); }
|
|
7
16
|
export function matchDurationMs(spotifyMs, deezerSec, toleranceMs = 3000) { return spotifyMs == null || deezerSec == null || Math.abs(spotifyMs - deezerSec * 1000) <= toleranceMs; }
|
|
8
17
|
export function searchQuery(title, artist) { return `${firstArtist(artist)} ${stripFeat(title)}`.trim(); }
|
|
18
|
+
// The field-syntax retry Deezer and Spotify send after free text misses. Double quotes would end the
|
|
19
|
+
// quoted field early, so they are dropped; with no artist there is nothing to pin, so no artist field.
|
|
20
|
+
export function fieldQuery(title, artist) {
|
|
21
|
+
const clean = (v) => v.replace(/"/g, '').trim();
|
|
22
|
+
const a = clean(firstArtist(artist));
|
|
23
|
+
return `track:"${clean(stripFeat(title))}"${a ? ` artist:"${a}"` : ''}`;
|
|
24
|
+
}
|
|
9
25
|
export function matchCandidates(title, artist, durationMs, candidates) {
|
|
10
26
|
const nt = normalize(stripFeat(title));
|
|
11
|
-
const
|
|
27
|
+
const tt = taggedTitle(title);
|
|
28
|
+
const na = artistKey(leadArtist(artist));
|
|
29
|
+
const sameArtist = (c) => na === artistKey(leadArtist(c.artist));
|
|
30
|
+
// The same title with its version tag intact beats one that only agrees once tags are dropped.
|
|
31
|
+
for (const c of candidates)
|
|
32
|
+
if (tt === taggedTitle(c.title) && sameArtist(c) && matchDurationMs(durationMs, c.duration))
|
|
33
|
+
return { id: c.id, title: c.title, artist: c.artist, method: 'exact' };
|
|
12
34
|
for (const c of candidates)
|
|
13
|
-
if (nt === normalize(c.title) &&
|
|
35
|
+
if (nt === normalize(c.title) && sameArtist(c) && matchDurationMs(durationMs, c.duration))
|
|
14
36
|
return { id: c.id, title: c.title, artist: c.artist, method: 'exact' };
|
|
15
37
|
for (const c of candidates)
|
|
16
|
-
if (nt === normalize(c.title) &&
|
|
38
|
+
if (nt === normalize(c.title) && sameArtist(c))
|
|
17
39
|
return { id: c.id, title: c.title, artist: c.artist, method: 'fuzzy-duration' };
|
|
18
40
|
for (const c of candidates) {
|
|
19
41
|
const ct = normalize(c.title);
|
|
20
|
-
if (
|
|
42
|
+
if (sameArtist(c) && nt && ct && (nt.includes(ct) || ct.includes(nt)))
|
|
21
43
|
return { id: c.id, title: c.title, artist: c.artist, method: 'fuzzy-title' };
|
|
22
44
|
}
|
|
23
45
|
return null;
|
|
@@ -65,6 +87,62 @@ export function matchByDuration(title, artist, durationMs, candidates) {
|
|
|
65
87
|
const [c] = hits;
|
|
66
88
|
return { id: c.id, title: c.title, artist: c.artist, method: 'duration-only' };
|
|
67
89
|
}
|
|
90
|
+
// Last-resort tier, the mirror of matchByDuration: the artist is not compared. It exists for one case
|
|
91
|
+
// — a catalog crediting the artist in another script, YouTube Music's 지효 for Spotify's JIHYO — so it
|
|
92
|
+
// applies only when the two artist names are in different scripts; two Latin names that differ are
|
|
93
|
+
// two artists, and "Intro" by A is not "Intro" by B.
|
|
94
|
+
//
|
|
95
|
+
// The title must be equal with its tags intact (never merely contained), the duration tightens to 2s
|
|
96
|
+
// and is mandatory, and the survivors must all be one artist: a single and its album release are two
|
|
97
|
+
// ids for one recording, but a title two artists share at the same length is refused.
|
|
98
|
+
const latinOnly = (s) => !/[^\p{Script=Latin}\p{N}\p{P}\p{S}\s]/u.test(s);
|
|
99
|
+
export function matchByTitle(title, artist, durationMs, candidates) {
|
|
100
|
+
if (durationMs == null)
|
|
101
|
+
return null;
|
|
102
|
+
const tt = taggedTitle(title);
|
|
103
|
+
const lead = leadArtist(artist);
|
|
104
|
+
if (!tt || !lead)
|
|
105
|
+
return null;
|
|
106
|
+
const hits = candidates.filter((c) => c.duration != null && matchDurationMs(durationMs, c.duration, 2000)
|
|
107
|
+
&& taggedTitle(c.title) === tt && latinOnly(lead) !== latinOnly(leadArtist(c.artist)));
|
|
108
|
+
if (!hits.length || new Set(hits.map((c) => artistKey(leadArtist(c.artist)))).size !== 1)
|
|
109
|
+
return null;
|
|
110
|
+
const [c] = hits;
|
|
111
|
+
return { id: c.id, title: c.title, artist: c.artist, method: 'title-only' };
|
|
112
|
+
}
|
|
113
|
+
// The catalog song a music video carries. A video's own metadata is the uploader's: a channel where
|
|
114
|
+
// the artist belongs, a title dressed as "XG - MILLION PLACES (Official Music Video)" or "APT. - ROSÉ
|
|
115
|
+
// & Bruno Mars | Cover by TsunamiCat and Dangle". So it converts as the song it spells out: one whose
|
|
116
|
+
// title — or the other-language name it carries in brackets, 라비앙로즈 (La Vie en Rose) — appears in
|
|
117
|
+
// the video title as whole words, by a lead artist the video title or channel names. Tags count as
|
|
118
|
+
// words, so "NOT CUTE ANYMORE (Sped up)" is not what a video titled "NOT CUTE ANYMORE" carries.
|
|
119
|
+
// Duration is no guard here: an official video runs long (an intro, a dance break) and a re-upload
|
|
120
|
+
// is often trimmed. First such song, in search order.
|
|
121
|
+
// A bracket that names a version or a guest rather than the song itself: never an alternate name.
|
|
122
|
+
const TAG = /\bw\/|\b(feat|ft|prod|sped|slowed|speed|remix|mix|live|ver|version|inst|instrumental|acoustic|karaoke|cover|edit|remaster(ed)?|demo|english|korean|japanese|chinese)\b/i;
|
|
123
|
+
// Whether `name` is named in `text` as whole words, spaces aside: "Bruno Mars" in "ROSÉ & Bruno Mars",
|
|
124
|
+
// "BLACKPINK" in "BLACK PINK" — but not "V" inside "Love" or "IVE" inside "Live".
|
|
125
|
+
function namesArtist(text, name) {
|
|
126
|
+
const key = artistKey(name);
|
|
127
|
+
const words = normalize(text.replace(/[()[\]]/g, ' ')).split(' ').filter(Boolean);
|
|
128
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
129
|
+
let joined = '';
|
|
130
|
+
for (let j = i; j < words.length && joined.length < key.length; j += 1)
|
|
131
|
+
joined += words[j];
|
|
132
|
+
if (joined === key)
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
export function songForVideo(videoTitle, channel, songs) {
|
|
138
|
+
const words = ` ${taggedTitle(videoTitle)} `;
|
|
139
|
+
const spelled = (title) => { const t = taggedTitle(title); return t !== '' && words.includes(` ${t} `); };
|
|
140
|
+
return songs.find((s) => {
|
|
141
|
+
const aliases = [...s.title.matchAll(/\(([^)]*)\)/g)].map((m) => m[1]).filter((a) => !TAG.test(a));
|
|
142
|
+
const lead = leadArtist(s.artist);
|
|
143
|
+
return [s.title, ...aliases].some(spelled) && lead !== '' && namesArtist(`${videoTitle} ${channel}`, lead);
|
|
144
|
+
}) ?? null;
|
|
145
|
+
}
|
|
68
146
|
// Match rules for a music video, used only after the catalog has produced nothing.
|
|
69
147
|
//
|
|
70
148
|
// A music video's stated artist is usually the uploading channel, so the artist rule relaxes from
|
package/dist/spotify.js
CHANGED
|
@@ -1,7 +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 { matchByDuration, matchCandidates, searchQuery } from './matcher.js';
|
|
4
|
+
import { fieldQuery, matchByDuration, matchByTitle, matchCandidates, searchQuery } from './matcher.js';
|
|
5
5
|
const PATHFINDER_URL = 'https://api-partner.spotify.com/pathfinder/v1/query';
|
|
6
6
|
const FETCH_PLAYLIST_SHA = 'a65e12194ed5fc443a1cdebed5fabe33ca5b07b987185d63c72483867ad13cb4';
|
|
7
7
|
export const EMBED_TRACK_LIMIT = 100;
|
|
@@ -220,9 +220,17 @@ export class SpotifyProvider {
|
|
|
220
220
|
}
|
|
221
221
|
readPlaylist(ref) { return readPlaylist(parsePlaylistId(ref), this.token); }
|
|
222
222
|
async search(track) {
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
223
|
+
const free = await searchTrack(searchQuery(track.name, track.artist), this.token);
|
|
224
|
+
const match = matchCandidates(track.name, track.artist, track.durationMs, free);
|
|
225
|
+
if (match)
|
|
226
|
+
return match;
|
|
227
|
+
// Free text can rank an artist's hits above a short title — "ILLIT Pop" never surfaces "Pop" —
|
|
228
|
+
// so a miss retries in field syntax, which pins both. The same fallback Deezer runs.
|
|
229
|
+
const fielded = await searchTrack(fieldQuery(track.name, track.artist), this.token);
|
|
230
|
+
const all = [...free, ...fielded];
|
|
231
|
+
return matchCandidates(track.name, track.artist, track.durationMs, fielded)
|
|
232
|
+
?? matchByDuration(track.name, track.artist, track.durationMs, all)
|
|
233
|
+
?? matchByTitle(track.name, track.artist, track.durationMs, all);
|
|
226
234
|
}
|
|
227
235
|
createPlaylist(title) { return createPlaylist(title, this.token); }
|
|
228
236
|
// Returns the count submitted, not one the service confirmed: addToPlaylist reports transport
|
package/dist/ytmusic-parse.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// official-source upload, a user upload, a podcast — is a video whose stated artist is the channel.
|
|
3
3
|
// An entry with no type at all is an unavailable/removed track and is treated the same way.
|
|
4
4
|
const SONG_TYPE = 'MUSIC_VIDEO_TYPE_ATV';
|
|
5
|
+
const PODCAST_TYPE = 'MUSIC_VIDEO_TYPE_PODCAST_EPISODE';
|
|
5
6
|
const runsText = (t) => t?.runs?.map((r) => r.text ?? '').join('') ?? '';
|
|
6
7
|
// "3:49" → 229, "1:02:03" → 3723. Anything that is not a clock reading is null, *not* zero — a
|
|
7
8
|
// playlist read has a duration column and a search result does not, and callers tell the two apart
|
|
@@ -60,13 +61,15 @@ function parseItem(renderer) {
|
|
|
60
61
|
const subtitleParts = (columns[1] ?? '').split('•').map((s) => s.trim()).filter(Boolean);
|
|
61
62
|
const trailing = subtitleParts.length > 1 ? subtitleParts[subtitleParts.length - 1] : '';
|
|
62
63
|
const durationSec = parseDuration(fixed) ?? parseDuration(trailing);
|
|
64
|
+
const type = watch?.watchEndpointMusicSupportedConfigs?.watchEndpointMusicConfig?.musicVideoType;
|
|
63
65
|
return {
|
|
64
66
|
videoId,
|
|
65
67
|
setVideoId: renderer.playlistItemData?.playlistSetVideoId,
|
|
66
68
|
title,
|
|
67
69
|
artist: subtitleParts[0] ?? '',
|
|
68
70
|
durationSec,
|
|
69
|
-
isSong:
|
|
71
|
+
isSong: type === SONG_TYPE,
|
|
72
|
+
isVideo: Boolean(type) && type !== SONG_TYPE && type !== PODCAST_TYPE,
|
|
70
73
|
};
|
|
71
74
|
}
|
|
72
75
|
export function parseItems(response) {
|
package/dist/ytmusic-provider.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { retry } from './http.js';
|
|
2
|
+
import { matchByDuration, matchByTitle, matchCandidates, matchVideoCandidates, searchQuery, songForVideo } from './matcher.js';
|
|
2
3
|
import { innertube, isMix, parsePlaylistRef } from './ytmusic.js';
|
|
3
4
|
import { continuationToken, parseItems, playlistTitle, toCandidate } from './ytmusic-parse.js';
|
|
4
5
|
// Search filter params, as the web client sends them: catalog songs, and music videos.
|
|
@@ -28,19 +29,58 @@ export class YtMusicProvider {
|
|
|
28
29
|
return { pages, items };
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
|
-
//
|
|
32
|
-
// matching on
|
|
33
|
-
//
|
|
32
|
+
// A music video's stated artist is the uploading channel and its title is dressed for the upload,
|
|
33
|
+
// so matching on either produces confident wrong answers. It converts instead as the catalog song
|
|
34
|
+
// it carries, found by this catalog's own song search (songForVideo), in its place in the playlist.
|
|
35
|
+
// Anything else that is not a song — a video that resolves to nothing, an entry with no type (an
|
|
36
|
+
// unavailable track), a podcast episode — comes back as `skipped` with its reason, so the report
|
|
37
|
+
// shows it as its own kind of row rather than silently dropping it.
|
|
34
38
|
async readPlaylist(playlistId) {
|
|
35
39
|
if (isMix(playlistId)) {
|
|
36
40
|
throw new Error('That is a mix, not a playlist. A mix is generated fresh every time it is opened, so it has no fixed contents to convert — open the playlist you want and use its link.');
|
|
37
41
|
}
|
|
38
42
|
const { items } = await this.browseAll(playlistId);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
const videos = items.filter((i) => i.isVideo).length;
|
|
44
|
+
if (videos)
|
|
45
|
+
console.log(`Resolving ${videos} music video${videos === 1 ? '' : 's'} to the song${videos === 1 ? '' : 's'} they carry…`);
|
|
46
|
+
const tracks = [];
|
|
47
|
+
const skipped = [];
|
|
48
|
+
for (const item of items) {
|
|
49
|
+
if (item.isSong) {
|
|
50
|
+
tracks.push({ name: item.title, artist: item.artist, durationMs: secToMs(item.durationSec) });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (!item.isVideo) {
|
|
54
|
+
skipped.push({ name: item.title, artist: item.artist, reason: 'unavailable on YouTube Music, or not music' });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const song = await this.resolveVideo(item);
|
|
58
|
+
if (song)
|
|
59
|
+
tracks.push({ name: song.title, artist: song.artist, durationMs: secToMs(song.duration), video: item.title });
|
|
60
|
+
else
|
|
61
|
+
skipped.push({ name: item.title, artist: item.artist, reason: song === null ? 'music video with no matching song' : 'music video, song lookup failed' });
|
|
62
|
+
}
|
|
63
|
+
return { tracks, truncated: false, skipped };
|
|
64
|
+
}
|
|
65
|
+
// The channel leads the query, as the artist does for a song. A re-upload channel ("splash.mp3")
|
|
66
|
+
// only muddies it, so a video that resolves to nothing tries its title alone. Resolves null when
|
|
67
|
+
// nothing matched and undefined when the search itself kept failing: one bad lookup costs that
|
|
68
|
+
// video, never the whole playlist read.
|
|
69
|
+
async resolveVideo(video) {
|
|
70
|
+
const queries = [{ name: video.title, artist: video.artist }, { name: video.title, artist: '' }];
|
|
71
|
+
try {
|
|
72
|
+
for (const query of queries) {
|
|
73
|
+
const songs = await retry(() => this.searchItems(query, SONGS));
|
|
74
|
+
await new Promise((resolve) => setTimeout(resolve, 300)); // the same pacing the conversion keeps
|
|
75
|
+
const song = songForVideo(video.title, video.artist, songs.filter((i) => i.isSong).map(toCandidate));
|
|
76
|
+
if (song)
|
|
77
|
+
return song;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
44
84
|
}
|
|
45
85
|
async playlistName(playlistId) {
|
|
46
86
|
if (isMix(playlistId))
|
|
@@ -61,8 +101,10 @@ export class YtMusicProvider {
|
|
|
61
101
|
const song = matchCandidates(track.name, track.artist, track.durationMs, songCandidates);
|
|
62
102
|
if (song)
|
|
63
103
|
return song;
|
|
64
|
-
// A catalog song under a translated title still beats a video — see matchByDuration
|
|
65
|
-
|
|
104
|
+
// A catalog song under a translated title or artist still beats a video — see matchByDuration
|
|
105
|
+
// and matchByTitle.
|
|
106
|
+
const translated = matchByDuration(track.name, track.artist, track.durationMs, songCandidates)
|
|
107
|
+
?? matchByTitle(track.name, track.artist, track.durationMs, songCandidates);
|
|
66
108
|
if (translated)
|
|
67
109
|
return translated;
|
|
68
110
|
const videos = await this.searchItems(track, VIDEOS);
|