plx-converter 0.1.7 → 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/README.md CHANGED
@@ -90,7 +90,7 @@ Set credentials in `.env`, export them in your shell, or paste when prompted. Fu
90
90
 
91
91
  ## Website
92
92
 
93
- [plx.sh](https://plx.sh) — overview, matching rules, and install steps.
93
+ [plx.eleonorez.xyz](https://plx.eleonorez.xyz) — overview, matching rules, and install steps.
94
94
 
95
95
  <img src="https://raw.githubusercontent.com/samlehoy/plx/master/screenshots/website-landing.png" width="640" alt="plx landing page: Move your playlist. No dev account." />
96
96
 
@@ -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) { this.rows.push({ ...row, source: this.source.name, target: this.target.name }); }
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: 'skipped: music video (channel name is not a reliable artist)' });
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} music video${result.skipped.length === 1 ? '' : 's'} skipped.`);
51
- const matchedIds = [];
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
- 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 });
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
- 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.`);
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 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 }]);
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, stripFeat } from './matcher.js';
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: `track:"${stripFeat(track.name)}" artist:"${firstArtist(track.artist)}"`, limit: '5' })}`;
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
- return matchByDuration(track.name, track.artist, track.durationMs, [...candidates, ...candidates2]);
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 na = artistKey(firstArtist(artist));
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) && na === artistKey(c.artist) && matchDurationMs(durationMs, c.duration))
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) && na === artistKey(c.artist))
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 (na === artistKey(c.artist) && nt && ct && (nt.includes(ct) || ct.includes(nt)))
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 candidates = await searchTrack(searchQuery(track.name, track.artist), this.token);
224
- return matchCandidates(track.name, track.artist, track.durationMs, candidates)
225
- ?? matchByDuration(track.name, track.artist, track.durationMs, candidates);
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
@@ -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: watch?.watchEndpointMusicSupportedConfigs?.watchEndpointMusicConfig?.musicVideoType === SONG_TYPE,
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) {
@@ -1,4 +1,5 @@
1
- import { matchByDuration, matchCandidates, matchVideoCandidates, searchQuery } from './matcher.js';
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
- // Music videos are read but not converted: their stated artist is the uploading channel, so
32
- // matching on it produces confident wrong answers. They come back as `skipped` so the report can
33
- // show them as their own kind of row rather than silently dropping them.
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
- return {
40
- tracks: items.filter((i) => i.isSong).map((i) => ({ name: i.title, artist: i.artist, durationMs: secToMs(i.durationSec) })),
41
- truncated: false,
42
- skipped: items.filter((i) => !i.isSong).map((i) => ({ name: i.title, artist: i.artist })),
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
- const translated = matchByDuration(track.name, track.artist, track.durationMs, songCandidates);
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);
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "plx-converter",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Convert playlists between Spotify, Deezer, and YouTube Music using your own browser session",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/samlehoy/plx.git"
9
9
  },
10
- "homepage": "https://plx.sh",
10
+ "homepage": "https://plx.eleonorez.xyz",
11
11
  "type": "module",
12
12
  "bin": {
13
13
  "plx": "dist/cli.js"