plx-converter 0.1.5 → 0.1.6

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/deezer.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { fetchJson, retry } from './http.js';
3
- import { firstArtist, matchCandidates, searchQuery, stripFeat } from './matcher.js';
3
+ import { firstArtist, matchByDuration, matchCandidates, searchQuery, stripFeat } 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';
@@ -67,16 +67,22 @@ export class DeezerClient {
67
67
  const toCandidates = (data) => data.data.map((x) => ({ id: String(x.id), title: x.title, artist: x.artist.name, duration: x.duration }));
68
68
  const free = `${API}/search?${new URLSearchParams({ q: searchQuery(track.name, track.artist), limit: '5' })}`;
69
69
  const data = await retry(async () => fetchJson(free));
70
- const match = matchCandidates(track.name, track.artist, track.durationMs, toCandidates(data));
70
+ const candidates = toCandidates(data);
71
+ const match = matchCandidates(track.name, track.artist, track.durationMs, candidates);
71
72
  if (match)
72
73
  return match;
73
74
  // Free-text relevance ranks covers/remixes above the real K-pop track; fall back to field syntax.
74
75
  const field = `${API}/search?${new URLSearchParams({ q: `track:"${stripFeat(track.name)}" artist:"${firstArtist(track.artist)}"`, limit: '5' })}`;
75
76
  const data2 = await retry(async () => fetchJson(field));
76
- const match2 = matchCandidates(track.name, track.artist, track.durationMs, toCandidates(data2));
77
+ const candidates2 = toCandidates(data2);
78
+ const match2 = matchCandidates(track.name, track.artist, track.durationMs, candidates2);
77
79
  if (match2)
78
80
  return match2;
79
- return this.searchByAlbum(track);
81
+ // The catalog scan still compares titles, so it stays ahead of the title-blind tier.
82
+ const byAlbum = await this.searchByAlbum(track);
83
+ if (byAlbum)
84
+ return byAlbum;
85
+ return matchByDuration(track.name, track.artist, track.durationMs, [...candidates, ...candidates2]);
80
86
  }
81
87
  // Artist → albums → album tracks, for tracks the search index never surfaces (e.g. "Salty & Sweet").
82
88
  async searchByAlbum(track) {
package/dist/matcher.js CHANGED
@@ -22,6 +22,49 @@ export function matchCandidates(title, artist, durationMs, candidates) {
22
22
  }
23
23
  return null;
24
24
  }
25
+ // A recording that is a *version* of the source rather than the source itself. Tested against the
26
+ // raw title, not the normalized one: normalize() strips parenthesised text, which is exactly where
27
+ // the marker lives ("estrangement(Instrumental)" and "estrangement" normalize to the same string).
28
+ const VERSION_MARKER = /instrumental|karaoke|off ?vocal|acoustic|remix|live|cover|伴奏/i;
29
+ function artistOverlaps(a, b) { return Boolean(a && b) && (a.includes(b) || b.includes(a)); }
30
+ // Last-resort tier: the title is not compared at all. A catalog often carries a track under a
31
+ // translated title — Deezer lists 红线 as "RED LINE", 愛你但說不出口 as "Hard to say" — so a search
32
+ // built from the source title returns the right recording under a name that can never string-match.
33
+ //
34
+ // With the title gone, duration is the only real guard, so it tightens to 2s and a candidate
35
+ // without one is rejected. Artist equality relaxes to containment (Spotify's "LBI利比" is Deezer's
36
+ // "LBI"), and the guard that carries the weight is uniqueness: exactly one surviving candidate, or
37
+ // no match. Version-marked candidates drop out first, unless the source is itself a version.
38
+ //
39
+ // Only ever call this on relevance-ranked search results. Run over a whole artist catalog it will
40
+ // happily pair a source track with whatever unrelated release happens to share its length.
41
+ export function matchByDuration(title, artist, durationMs, candidates) {
42
+ if (durationMs == null)
43
+ return null;
44
+ const na = artistKey(firstArtist(artist));
45
+ if (!na)
46
+ return null;
47
+ const sourceIsVersion = VERSION_MARKER.test(title);
48
+ const seen = new Set();
49
+ const hits = candidates.filter((c) => {
50
+ if (seen.has(c.id))
51
+ return false; // the same recording found by two queries is still one candidate
52
+ if (c.duration == null || !matchDurationMs(durationMs, c.duration, 2000))
53
+ return false;
54
+ if (!artistOverlaps(na, artistKey(c.artist)))
55
+ return false;
56
+ if (!sourceIsVersion && VERSION_MARKER.test(c.title))
57
+ return false;
58
+ seen.add(c.id);
59
+ return true;
60
+ });
61
+ // ponytail: two ids for the same recording (single + album release) read as ambiguous and are
62
+ // rejected. Collapse on normalized title if that turns out to cost real matches.
63
+ if (hits.length !== 1)
64
+ return null;
65
+ const [c] = hits;
66
+ return { id: c.id, title: c.title, artist: c.artist, method: 'duration-only' };
67
+ }
25
68
  // Match rules for a music video, used only after the catalog has produced nothing.
26
69
  //
27
70
  // 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 { matchCandidates, searchQuery } from './matcher.js';
4
+ import { matchByDuration, 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;
@@ -221,7 +221,8 @@ export class SpotifyProvider {
221
221
  readPlaylist(ref) { return readPlaylist(parsePlaylistId(ref), this.token); }
222
222
  async search(track) {
223
223
  const candidates = await searchTrack(searchQuery(track.name, track.artist), this.token);
224
- return matchCandidates(track.name, track.artist, track.durationMs, candidates);
224
+ return matchCandidates(track.name, track.artist, track.durationMs, candidates)
225
+ ?? matchByDuration(track.name, track.artist, track.durationMs, candidates);
225
226
  }
226
227
  createPlaylist(title) { return createPlaylist(title, this.token); }
227
228
  // Returns the count submitted, not one the service confirmed: addToPlaylist reports transport
@@ -1,4 +1,4 @@
1
- import { matchCandidates, matchVideoCandidates, searchQuery } from './matcher.js';
1
+ import { matchByDuration, matchCandidates, matchVideoCandidates, searchQuery } from './matcher.js';
2
2
  import { innertube, isMix, parsePlaylistRef } from './ytmusic.js';
3
3
  import { continuationToken, parseItems, playlistTitle, toCandidate } from './ytmusic-parse.js';
4
4
  // Search filter params, as the web client sends them: catalog songs, and music videos.
@@ -57,9 +57,14 @@ export class YtMusicProvider {
57
57
  // strategy, the matcher owns the rules).
58
58
  async search(track) {
59
59
  const songs = await this.searchItems(track, SONGS);
60
- const song = matchCandidates(track.name, track.artist, track.durationMs, songs.filter((i) => i.isSong).map(toCandidate));
60
+ const songCandidates = songs.filter((i) => i.isSong).map(toCandidate);
61
+ const song = matchCandidates(track.name, track.artist, track.durationMs, songCandidates);
61
62
  if (song)
62
63
  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);
66
+ if (translated)
67
+ return translated;
63
68
  const videos = await this.searchItems(track, VIDEOS);
64
69
  return matchVideoCandidates(track.name, track.artist, track.durationMs, videos.map(toCandidate));
65
70
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plx-converter",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Convert playlists between Spotify, Deezer, and YouTube Music using your own browser session",
5
5
  "license": "MIT",
6
6
  "repository": {