diff --git a/README.md b/README.md
index e572bf3..af9b735 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,10 @@
# plx
-Spotify ⇄ Deezer playlist converter CLI.
+Playlist converter CLI for Spotify, Deezer, and YouTube Music.
-Converts playlists in both directions — reads Spotify **without an account, Premium, or OAuth**; writes via your session cookies.
+Converts a playlist from any of the three services into any other — six directions — using your own logged-in browser session. No developer account, no OAuth, no API keys.
-> ⚠️ **Personal, non-commercial use.** Uses unofficial web endpoints that can change or break without notice. Not affiliated with Spotify or Deezer.
+> ⚠️ **Personal, non-commercial use.** Uses unofficial web endpoints that can change or break without notice. Not affiliated with Spotify, Deezer, or YouTube.
## Install
@@ -20,6 +20,7 @@ Requires Node.js ≥ 22.
plx # interactive menu
plx --url "https://open.spotify.com/playlist/ID" --to deezer
plx --url "https://www.deezer.com/playlist/ID" --to spotify
+plx --url "https://music.youtube.com/playlist?list=ID" --to deezer
plx --url "" --to deezer --dry-run # preview matches without writing
```
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 3d7c368..3bd2ead 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -4,15 +4,16 @@ Source of truth for the system's design and its external contracts. Read this be
## Goal
-`plx` is a personal Spotify ⇄ Deezer playlist converter distributed as a CLI (`npm install -g plx`). It reads a Spotify playlist **without a Spotify account, Premium, or OAuth**:
+`plx` is a personal playlist converter for Spotify, Deezer, and YouTube Music, distributed as a CLI (`npm install -g plx`). It uses the user's own logged-in browser session, never a developer account or an official API (ADR 0001):
```bash
plx --url "https://open.spotify.com/playlist/PLAYLIST_ID" --dry-run
```
-Both directions are supported:
-- **Spotify → Deezer** — reads Spotify anonymously, writes via a Deezer `arl` cookie.
-- **Deezer → Spotify** — reads via `arl`, writes via a Spotify `sp_dc` web-session cookie.
+All six directions are supported — every provider is a peer, so any can be the source and any the
+target. Each is authenticated by a browser session credential: Spotify `sp_dc`, Deezer `arl`, and a
+whole cookie header for YouTube Music. Reading a public Spotify playlist needs no credential at all,
+which is what makes a credential-free `--dry-run` possible.
## Flow
@@ -52,10 +53,10 @@ direction-specific code. The pieces:
Providers meet only in `Conversion`, which is why the conversion tests supply providers directly and
mock each one's `fetch`.
-| Capability | Spotify | Deezer |
-|---|---|---|
-| `reorder` | ✗ — no move primitive in the endpoints plx uses; a Spotify target is dedupe-and-append | ✓ by track id |
-| `resolveTrack` | ✓ — every match into a Spotify target is verified | ✗ — matches into Deezer are unverified |
+| Capability | Spotify | Deezer | YouTube Music |
+|---|---|---|---|
+| `reorder` | ✗ — no move primitive in the endpoints plx uses; a Spotify target is dedupe-and-append | ✓ by track id | ✓ by `setVideoId`, mapped from track id inside the provider |
+| `resolveTrack` | ✓ — every match into a Spotify target is verified | ✗ — matches into Deezer are unverified | ✓ via `player` |
## Module map
@@ -63,7 +64,9 @@ mock each one's `fetch`.
|---|---|
| `src/cli.ts` | Entry. Arg dispatch, interactive menu (`@clack/prompts`), credential prompt + semi-auto fill + persist. Names no provider — it drives off the registry. |
| `src/registry.ts` | The `ProviderSpec` list: label, link hosts, credential prompt, ref parsing, validation, and how to build each provider. The one place a provider is registered. |
-| `src/ytmusic.ts` | YouTube Music session credential: the cookie bundle, the shared cookie-header joiner, SAPISIDHASH request signing, InnerTube transport, and live session validation. |
+| `src/ytmusic.ts` | YouTube Music credential + transport: the cookie bundle, the shared cookie-header joiner, SAPISIDHASH signing, InnerTube calls, session validation, playlist-ref parsing, mix detection. |
+| `src/ytmusic-parse.ts` | Pure parsing of InnerTube responses. Pinned by fixtures recorded from the real service (`tests/fixtures/ytmusic/`). No I/O. |
+| `src/ytmusic-provider.ts` | `YtMusicProvider` (a `Provider`): read, search songs then videos, create/add, verify, and reorder by `setVideoId`. |
| `src/args.ts` | Pure typed CLI parsing → `CliOptions`. No I/O. |
| `src/config.ts` | Config dir + `credentials.json` read/write. Holds **one opaque string per provider, keyed by provider name** — it never parses a credential's contents. Per-provider env overrides, `tryAutoFillCredentials`. |
| `src/browser.ts` | Reads `arl`/`sp_dc` from a logged-in browser (Chromium family decrypt + Safari binarycookies parse). macOS only. |
@@ -101,6 +104,11 @@ The project intentionally uses **unofficial web endpoints**. They can change wit
| Session credential | The browser cookie bundle for `.youtube.com`, sent as one `Cookie` header | **`__Secure-1PSIDTS` / `__Secure-3PSIDTS` are required.** Without them the `*PSID` cookies are unbound and the request authenticates as **logged out — with a 200 and no error**. Cookies must also come from one profile and prefer `.youtube.com` over `.google.com`, which set the same names to *different* values. |
| Request signing | `Authorization: SAPISIDHASH _ https://music.youtube.com")>`, plus `x-origin` | Mirrors what the web client computes. `SAPISID` may instead be `__Secure-3PAPISID`/`__Secure-1PAPISID`. |
| Session validation | InnerTube `POST /youtubei/v1/account/account_menu`, client `WEB_REMIX` | **A rejected session returns 200 with a signed-out menu**, so validity is judged by whether the response names an account, never by the status code. |
+| Playlist read | `POST browse` with `browseId: VL`, following `continuationItemRenderer` tokens | Entries are `musicResponsiveListItemRenderer`, ~6 levels deep; the path differs between browse, search, and continuation, so the parser walks rather than indexes. Title comes from `microformat`. |
+| Song vs music video | `watchEndpointMusicConfig.musicVideoType` | Only `MUSIC_VIDEO_TYPE_ATV` is a catalog song. `OMV`, `UGC`, `OFFICIAL_SOURCE_MUSIC` and a missing type are all treated as videos — their stated artist is the uploading channel. |
+| Search | `POST search` with the web client's filter `params` — songs and music videos are separate filters | A playlist read puts duration in a fixed column; a search trails it on the subtitle line (`Artist • Album • 3:49`). |
+| Create / add / reorder | `POST playlist/create` (`privacyStatus: PRIVATE`), `POST browse/edit_playlist` with `ACTION_ADD_VIDEO` / `ACTION_MOVE_VIDEO_BEFORE` | Reorder moves by `setVideoId`, an opaque per-item handle that is **not** the video id — the same song added twice has two. Resolved inside the provider (ADR 0002). |
+| Track metadata (verify) | `POST player` → `videoDetails` | Flat, unlike everything else here. A catalog track's `author` is the artist's auto-generated `" - Topic"` channel. |
### Deezer
@@ -124,6 +132,9 @@ The project intentionally uses **unofficial web endpoints**. They can change wit
1. `exact` — normalized title + first artist equal, duration within ±3s.
2. `fuzzy-duration` — title + artist equal, duration ignored (live/remaster variants).
3. `fuzzy-title` — one normalized title contains the other + artist equal.
+4. `video` — **YouTube Music only**, and only after the catalog returns nothing. The artist rule relaxes to containment (the source artist must appear in the video's title *or* channel name) because a video's stated artist is usually the uploader; in exchange the duration tolerance becomes **mandatory**, and a candidate with no duration is rejected. Duration is the only guard left, and it is what separates the same recording uploaded as a video from someone else's cover.
+
+The tier set is open, not a closed enum every provider shares (ADR 0003) — `video` is the first tier only one provider can produce.
`normalize` strips accents, parentheticals, `(feat./ft.)` labels, punctuation, and collapses whitespace.
diff --git a/docs/CONFIG.md b/docs/CONFIG.md
index e68a8ea..e2acd6f 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -5,8 +5,9 @@ Setup guide for credentials, config directory, and storage.
## Prerequisites
- **Node.js ≥ 22**
-- **Deezer account** (free is fine) — needed to *write* Deezer playlists.
-- **No Spotify Developer account or app registration** — reading is anonymous; writing needs only a logged-in Spotify web session.
+- An account on whichever services your conversion touches — free tiers are fine.
+- **No developer account, app registration, or API key for any provider.** plx acts as you, using
+ the browser session you already have (ADR 0001).
## Quick start
@@ -19,7 +20,8 @@ Spotify → Deezer reads Spotify anonymously — no Premium, no OAuth.
## Credentials
-`plx` needs at most **two** cookies, both optional depending on direction:
+One browser session credential per provider. **All are optional** — you only need the ones your
+chosen direction actually touches, and a `--dry-run` from a public Spotify playlist needs none:
| Cookie | Env var | Used for | Needed when |
|---|---|---|---|
@@ -41,16 +43,19 @@ Reading a Spotify playlist for a dry run needs **no credentials**.
1. Log in to [deezer.com](https://www.deezer.com) / [open.spotify.com](https://open.spotify.com) in a browser.
2. Open DevTools (F12) → **Application** (Chrome/Edge) or **Storage** (Firefox) → **Cookies** → the site domain.
-3. Find `arl` (Deezer) or `sp_dc` (Spotify) and copy its value.
+3. Find `arl` (Deezer) or `sp_dc` (Spotify) and copy its value. For YouTube Music, copy the whole `cookie:` header instead — see above.
### Semi-auto fill (macOS)
-Instead of hand-copying, the `arl` / `sp_dc` inputs can **read the cookie straight
-from a logged-in browser** — you only accept the macOS Keychain "Allow" dialog. It
-works for the **Chromium family** (Brave, Chrome, Edge, Chromium, Vivaldi, Opera)
-and **Safari** (Safari needs **Full Disk Access** in System Settings → Privacy &
-Security). Pick **"Ambil kredensial otomatis"** from the menu, or it runs
-automatically when a credential is missing. Manual paste stays as fallback.
+Instead of hand-copying, plx can **read the credentials straight from a logged-in
+browser** — you only accept the macOS Keychain "Allow" dialog. It works for the
+**Chromium family** (Brave, Chrome, Edge, Chromium, Vivaldi, Opera) and **Safari**
+(Safari needs **Full Disk Access** in System Settings → Privacy & Security). Pick
+**"Auto-fetch credentials (from browser)"** from the menu, or it runs automatically
+when a credential is missing. Manual paste stays the fallback everywhere else.
+
+Each credential it finds is then checked against the live service, so you are told
+whether it actually works rather than just that it was saved.
## Env vars
@@ -101,7 +106,9 @@ else in the file, including `recentUrls`, survives untouched.
## Report output
-Default CSV report path is `conversion_report.csv`. Override with `--output ` or the interactive menu's **Laporan** option.
+Default CSV report path is `conversion_report.csv`. Override with `--output ` or the interactive menu's **Report** option.
+
+Columns: `playlist,source,target,title,artist,isrc,matched,target_id,method,note`. `source` and `target` name the providers, and `target_id` is the matched identifier in the target's own terms — the same meaning in all six directions.
## Security
diff --git a/docs/CONTINUATION.md b/docs/CONTINUATION.md
index 8cb1f3c..4867fc9 100644
--- a/docs/CONTINUATION.md
+++ b/docs/CONTINUATION.md
@@ -4,6 +4,42 @@
Updated: 2026-08-17
+## 2026-08-17 (e) — the provider is complete
+
+**#3 provider-neutral report, #7 read, #8 write, #9 music video fallback, #10 reorder.** All six
+directions now run. `ProviderSpec.convertible`, the temporary flag from #6, is deleted.
+
+### Verified against the live service, not just stubs
+
+- **YouTube Music → Deezer**: a 180-entry playlist read with pagination — 38 songs converted,
+ **141 music videos skipped** and recorded as their own report row.
+- **Spotify → YouTube Music** and **Deezer → YouTube Music**: 4/4 matched and verified, playlist
+ created private, re-running into the same playlist added 0 (dedupe), then **deleted** — the test
+ account is left as it was.
+- **Reorder**: applied to a real playlist, order matched exactly; a second call on the now-ordered
+ playlist returned false without editing.
+- A **mix** is refused with its reason; `music.youtube.com` and `youtube.com` links resolve to the
+ same playlist.
+
+### Things worth knowing
+
+- **Only `MUSIC_VIDEO_TYPE_ATV` is a catalog song.** `OMV`, `UGC`, `OFFICIAL_SOURCE_MUSIC`, and
+ entries with no type at all are videos or unavailable. All four appear in a real playlist and all
+ four are in the fixture.
+- **`parseDuration` must return null, not 0, for non-durations.** A playlist read has a duration
+ column and a search result does not; callers tell them apart by null. Returning 0 for an empty
+ column silently swallowed the subtitle fallback — caught by the recorded fixtures, which is
+ exactly why the ticket demanded real responses over hand-written literals.
+- **Verification uses `player` → `videoDetails`**, which is flat, rather than the ~6-level renderer
+ nesting everything else needs. A catalog track's `author` is the `" - Topic"` channel.
+- **`error.name` → `error.message` in Conversion.** Every throw here is a plain `Error`, so the
+ report's one diagnostic field always read the literal "Error" — which silently swallowed the mix
+ refusal's explanation. This was flagged in the #2 review and only became an AC failure here.
+- **Reorder moves by `setVideoId`**, an opaque per-item handle — the same song added twice has two.
+ Mapped from track id inside the provider; it never reaches the conversion interface (ADR 0002).
+- `YtMusicProvider.deletePlaylist` is deliberately **not** on the `Provider` interface. plx never
+ deletes during a conversion; it exists so a live write test can clean up after itself.
+
## 2026-08-17 (d)
**YouTube Music browser session credential** (issue #6). Unblocks #7 and #8. Nothing converts yet —
diff --git a/package.json b/package.json
index 97818c0..2c6721d 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "plx-converter",
"version": "0.1.1",
- "description": "Spotify ⇄ Deezer playlist converter CLI",
+ "description": "Convert playlists between Spotify, Deezer, and YouTube Music using your own browser session",
"license": "MIT",
"type": "module",
"bin": {
diff --git a/src/args.ts b/src/args.ts
index 5164acd..5c171f3 100644
--- a/src/args.ts
+++ b/src/args.ts
@@ -16,14 +16,15 @@ The source provider is inferred from the link, so you only name the target.
Options:
-u, --url playlist URL, URI, or ID (repeatable)
- -t, --to target provider (required unless --dry-run)
+ -t, --to target provider: spotify | deezer | ytmusic (required)
-o, --output CSV report path (default: conversion_report.csv)
-d, --dry-run match only; do not create or modify any playlist
-h, --help show this help
Examples:
plx --url https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M --to deezer
- plx --url https://www.deezer.com/playlist/1234567890 --to spotify
+ plx --url https://www.deezer.com/playlist/1234567890 --to ytmusic
+ plx --url https://music.youtube.com/playlist?list=PL... --to spotify
plx --url https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M --to deezer --dry-run
plx
`;
diff --git a/src/cli.ts b/src/cli.ts
index e11a39b..66b09cc 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -2,7 +2,7 @@
import { confirm, intro, isCancel, outro, password, select, text } from '@clack/prompts';
import { credential, loadConfig, saveCredential, saveRecentUrl, tryAutoFillCredentials, type Config } from './config.js';
import { Conversion } from './conversion.js';
-import { PROVIDERS, convertibleProviders, inferSource, providerFor, targetKeys, type ProviderSpec } from './registry.js';
+import { PROVIDERS, inferSource, providerFor, targetKeys, type ProviderSpec } from './registry.js';
import { HELP_TEXT, parseArgs, type CliOptions } from './args.js';
import type { Provider } from './types.js';
@@ -99,7 +99,9 @@ async function pickSourcePlaylist(cfg: Config, spec: ProviderSpec, provider: Pro
: String(pick);
if (isCancel(ref) || !ref || String(ref).toLowerCase() === 'q') return null;
const id = await spec.parseRef(String(ref));
- return { id, name: await spec.playlistName(id), ref: String(ref) };
+ // A provider that can resolve its own titles does it better than the registry's fallback.
+ const name = (await provider.playlistName?.(id).catch(() => '')) || await spec.playlistName(id);
+ return { id, name, ref: String(ref) };
}
async function pickFromAccount(provider: Provider, spec: ProviderSpec, role: string): Promise<{ id: string; title: string } | null> {
@@ -143,9 +145,9 @@ async function pickTargetPlaylist(spec: ProviderSpec, provider: Provider): Promi
// One flow for every direction: pick source provider, pick target provider, then convert.
async function runConversion(cfg: Config, options: CliOptions): Promise {
- const sourceSpec = await pickProvider('Source provider', convertibleProviders());
+ const sourceSpec = await pickProvider('Source provider', PROVIDERS);
if (!sourceSpec) return;
- const targetSpec = await pickProvider('Target provider', convertibleProviders(), sourceSpec);
+ const targetSpec = await pickProvider('Target provider', PROVIDERS, sourceSpec);
if (!targetSpec) return;
const source = await build(cfg, sourceSpec, false);
@@ -249,14 +251,12 @@ async function main() {
? `Unknown target provider '${options.target}'. Valid targets: ${targetKeys().join(', ')}.`
: `No target provider. Name one with --to <${targetKeys().join('|')}>.`);
}
- if (!targetSpec.convertible) throw new Error(`Writing into ${targetSpec.label} is not built yet. Valid targets: ${targetKeys().join(', ')}.`);
const cfg = await loadConfig();
if (cfg.legacyCredentials) console.log('⚠️ Saved credentials are in an older format and were not carried over. Run `plx` and re-enter them (one browser dialog).');
for (const raw of options.urls) {
const sourceSpec = inferSource(raw);
- if (sourceSpec && !sourceSpec.convertible) throw new Error(`Reading from ${sourceSpec.label} is not built yet.`);
if (!sourceSpec) throw new Error(`Cannot tell which provider '${raw}' belongs to. Use a link from one of: ${PROVIDERS.map((p) => p.label).join(', ')}.`);
if (sourceSpec === targetSpec) throw new Error(`Source and target are both ${sourceSpec.label}.`);
@@ -267,7 +267,8 @@ async function main() {
const conversion = new Conversion(source, target, options.output);
try {
const id = await sourceSpec.parseRef(raw);
- const name = await sourceSpec.playlistName(id);
+ // A provider that can resolve its own titles does it better than the registry's fallback.
+ const name = (await source.playlistName?.(id).catch(() => '')) || await sourceSpec.playlistName(id);
const result = await conversion.matchPlaylist({ name, uri: id }, options.dryRun);
if (!options.dryRun && result.matchedIds.length) await conversion.writePlaylist(name, result.matchedIds);
} finally {
diff --git a/src/conversion.ts b/src/conversion.ts
index adf1a87..e0b4c66 100644
--- a/src/conversion.ts
+++ b/src/conversion.ts
@@ -15,17 +15,34 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export class Conversion {
readonly rows: ReportRow[] = [];
constructor(private readonly source: Provider, private readonly target: Provider, private readonly output: string) {}
- private record(row: ReportRow) { this.rows.push(row); }
+ // Every row carries the direction that produced it, so the file still makes sense a month later
+ // and a report from one of six directions is not mistaken for another.
+ private record(row: ReportRow) { this.rows.push({ ...row, source: this.source.name, target: this.target.name }); }
async matchPlaylist(playlist: PlaylistRef, dryRun = false): Promise {
let result;
try { result = await this.source.readPlaylist(playlist.uri); }
- catch (error) { this.record({ playlist: playlist.name, title: '', artist: '', isrc: null, matched: false, note: `failed to read playlist: ${error instanceof Error ? error.name : 'Error'}` }); return { matchedIds: [], total: 0, truncated: false }; }
+ catch (error) {
+ // The message, not the name: every throw in this codebase is a plain Error, so `error.name` is
+ // always the literal "Error" and the one diagnostic the report carries would say nothing. It
+ // is also where a refusal explains itself — a mix, say — so it has to reach the user.
+ const reason = error instanceof Error ? error.message : String(error);
+ this.record({ playlist: playlist.name, title: '', artist: '', isrc: null, matched: false, note: `failed to read playlist: ${reason}` });
+ console.log(`[${playlist.name}] ✗ ${reason}`);
+ return { matchedIds: [], total: 0, truncated: false };
+ }
if (result.truncated) {
// The provider hit its own read cap; how many it managed is the only limit the path knows.
this.record({ playlist: playlist.name, title: '', artist: '', isrc: null, matched: false, note: `WARNING: truncated at ${result.tracks.length} tracks; remainder not converted` });
console.log(`[${playlist.name}] ⚠️ Truncated at ${result.tracks.length} tracks.`);
}
+ // Entries the source refused to convert are recorded before the matched ones, as their own kind
+ // of row: they were never searched for, so calling them "no match" would misreport them.
+ for (const entry of result.skipped ?? []) {
+ 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)' });
+ }
+ if (result.skipped?.length) console.log(`[${playlist.name}] ${result.skipped.length} music video${result.skipped.length === 1 ? '' : 's'} skipped.`);
+
const matchedIds: string[] = [];
let verified = 0;
for (let i = 0; i < result.tracks.length; i += 1) {
@@ -33,13 +50,13 @@ export class Conversion {
let match: Match | null = null;
for (let attempt = 0; attempt < 3; attempt += 1) {
try { match = await this.target.search(track); break; }
- catch (error) { if (attempt === 2) console.log(` ⚠️ search failed: ${error instanceof Error ? error.name : 'Error'}`); else await sleep(2 ** attempt * 1000); }
+ catch (error) { if (attempt === 2) console.log(` ⚠️ search failed: ${error instanceof Error ? error.message : 'error'}`); else await sleep(2 ** attempt * 1000); }
}
if (match) {
const flag = await this.verify(track, match);
if (this.target.resolveTrack && flag === null) verified += 1;
matchedIds.push(match.id);
- this.record({ playlist: playlist.name, title: track.name, artist: track.artist, isrc: null, matched: true, deezer_id: match.id, method: match.method, note: [flag, dryRun ? 'dry-run' : null].filter(Boolean).join('; ') || null });
+ 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 });
console.log(` [${i + 1}/${result.tracks.length}] ✓ ${match.method} — ${track.name} — ${track.artist}${flag ? ` — ${flag}` : ''}`);
} else {
this.record({ playlist: playlist.name, title: track.name, artist: track.artist, isrc: null, matched: false, note: dryRun ? 'dry-run: no match' : 'no match' });
diff --git a/src/csv.ts b/src/csv.ts
index f0f5819..ff27d42 100644
--- a/src/csv.ts
+++ b/src/csv.ts
@@ -1,7 +1,7 @@
import { writeFile } from 'node:fs/promises';
import type { ReportRow } from './types.js';
-const fields = ['playlist', 'title', 'artist', 'isrc', 'matched', 'deezer_id', 'method', 'note'] as const;
+const fields = ['playlist', 'source', 'target', 'title', 'artist', 'isrc', 'matched', 'target_id', 'method', 'note'] as const;
const cell = (value: unknown) => {
const text = value == null ? '' : String(value);
return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
diff --git a/src/matcher.ts b/src/matcher.ts
index 136d3c4..1578187 100644
--- a/src/matcher.ts
+++ b/src/matcher.ts
@@ -1,4 +1,4 @@
-import type { Candidate, Match, Track } from './types.js';
+import type { Candidate, Match } from './types.js';
export function normalize(value: string): string {
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();
@@ -15,4 +15,26 @@ export function matchCandidates(title: string, artist: string, durationMs: numbe
for (const c of candidates) { const ct = normalize(c.title); if (na === artistKey(c.artist) && nt && ct && (nt.includes(ct) || ct.includes(nt))) return { id: c.id, title: c.title, artist: c.artist, method: 'fuzzy-title' }; }
return null;
}
-export function matchTrack(track: Track, candidates: Candidate[]): Match | null { return matchCandidates(track.name, track.artist, track.durationMs, candidates); }
+
+// Match rules for a music video, used only after the catalog has produced nothing.
+//
+// A music video's stated artist is usually the uploading channel, so the artist rule relaxes from
+// equality to containment: the source artist must appear somewhere in the video's title or channel
+// name. That is a much weaker guard, so in exchange the duration tolerance becomes **mandatory**
+// rather than optional — a candidate with no duration is rejected outright. Duration is what
+// separates the same recording uploaded as a video from someone else's cover of it.
+export function matchVideoCandidates(title: string, artist: string, durationMs: number | null | undefined, candidates: Candidate[]): Match | null {
+ if (durationMs == null) return null; // nothing left to guard with
+ const nt = normalize(stripFeat(title));
+ const na = artistKey(firstArtist(artist));
+ if (!nt || !na) return null;
+ for (const c of candidates) {
+ if (c.duration == null || !matchDurationMs(durationMs, c.duration)) continue;
+ const ct = normalize(c.title);
+ // The source artist has to show up somewhere attributable — the video's title or its channel.
+ if (!artistKey(c.title).includes(na) && !artistKey(c.artist).includes(na)) continue;
+ if (!ct.includes(nt) && !nt.includes(ct)) continue;
+ return { id: c.id, title: c.title, artist: c.artist, method: 'video' };
+ }
+ return null;
+}
diff --git a/src/registry.ts b/src/registry.ts
index 0ff5004..7da0d5e 100644
--- a/src/registry.ts
+++ b/src/registry.ts
@@ -1,6 +1,7 @@
import { DeezerClient, resolveDeezerPlaylistId } from './deezer.js';
import { SpotifyProvider, anonymousToken, authenticatedToken, parsePlaylistId, playlistName } from './spotify.js';
-import { validateSession } from './ytmusic.js';
+import { parsePlaylistRef, validateSession } from './ytmusic.js';
+import { YtMusicProvider } from './ytmusic-provider.js';
import type { Provider } from './types.js';
// Everything the CLI needs to know about a provider that is not part of converting: what to call it,
@@ -18,10 +19,6 @@ export type ProviderSpec = {
loginSite: string;
// Whether this provider can read a playlist with no credential at all (so a dry run needs none).
anonymousRead: boolean;
- // Whether this provider can take part in a conversion yet. A provider whose credential works but
- // whose read/write side is not built is offered in Credentials and hidden from Convert, so it is
- // never picked as a direction that cannot run. Drop the flag once every provider is convertible.
- convertible: boolean;
// Prove the stored credential against the live service, resolving to something to show the user
// (an account name, say). Throws with a specific message when the session is expired or rejected.
validate(credential: string): Promise;
@@ -42,7 +39,6 @@ const spotify: ProviderSpec = {
credentialHint: 'login open.spotify.com → F12 → Application → Cookies → sp_dc → copy value',
loginSite: 'Spotify (open.spotify.com)',
anonymousRead: true,
- convertible: true,
parseRef: async (ref) => parsePlaylistId(ref),
playlistName,
// An sp_dc mints a full search+write token; without one the anonymous token still reads public
@@ -60,7 +56,6 @@ const deezer: ProviderSpec = {
loginSite: 'Deezer (deezer.com)',
// Deezer's search is public, but reading a *playlist* goes through the authenticated GraphQL API.
anonymousRead: false,
- convertible: true,
parseRef: resolveDeezerPlaylistId,
playlistName: async (id) => id,
validate: async (cred) => `account ${(await new DeezerClient(cred).getMe()).id}`,
@@ -82,25 +77,21 @@ const ytmusic: ProviderSpec = {
credentialHint: 'login music.youtube.com → F12 → Network → any request → Request Headers → copy the whole "cookie:" value',
loginSite: 'YouTube Music (music.youtube.com)',
anonymousRead: false,
- // Reading and writing land in #7 and #8; this release only proves the credential works.
- convertible: false,
- parseRef: async (ref) => new URL(ref, 'https://music.youtube.com').searchParams.get('list') ?? ref.trim(),
+ parseRef: async (ref) => parsePlaylistRef(ref),
+ // The title needs an authenticated browse, so YtMusicProvider resolves it; this is the fallback.
playlistName: async (id) => id,
- create: () => { throw new Error('Converting with YouTube Music is not built yet.'); },
+ create: async (cred) => new YtMusicProvider(cred),
validate: validateSession,
};
export const PROVIDERS: ProviderSpec[] = [spotify, deezer, ytmusic];
-// The providers that can actually take part in a conversion right now.
-export const convertibleProviders = (): ProviderSpec[] => PROVIDERS.filter((p) => p.convertible);
-
export function providerFor(key: string): ProviderSpec | undefined {
return PROVIDERS.find((p) => p.key === key.toLowerCase());
}
-// The keys valid after --to. Only providers that can actually be written into are offered.
-export const targetKeys = (): string[] => convertibleProviders().map((p) => p.key);
+// The keys valid after --to.
+export const targetKeys = (): string[] => PROVIDERS.map((p) => p.key);
// Which provider a playlist link belongs to. The link already names its service, so a command line
// that gives one never has to restate the source.
diff --git a/src/types.ts b/src/types.ts
index 86b4b38..b00ac27 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,9 +1,12 @@
export type Track = { name: string; artist: string; durationMs?: number | null };
export type Candidate = { id: string; title: string; artist: string; duration?: number | null };
export type Match = { id: string; title: string; artist: string; method: MatchMethod };
-export type MatchMethod = 'exact' | 'fuzzy-duration' | 'fuzzy-title';
+// The tier that accepted a match. Open by design: a provider may produce a tier no other provider
+// can, and 'video' is the first — YouTube Music's music-video fallback (ADR 0003). Widening this is
+// how a new provider adds a tier; it is not a closed enum every provider must share.
+export type MatchMethod = 'exact' | 'fuzzy-duration' | 'fuzzy-title' | 'video';
export type PlaylistRef = { name: string; uri: string };
-export type ReportRow = { playlist: string; title: string; artist: string; isrc: string | null; matched: boolean; deezer_id?: string | null; method?: string | null; note?: string | null };
+export type ReportRow = { playlist: string; source?: string; target?: string; title: string; artist: string; isrc: string | null; matched: boolean; target_id?: string | null; method?: string | null; note?: string | null };
export type MatchResult = { matchedIds: string[]; total: number; truncated: boolean };
// A music service plx can both read playlists from and write playlists to. Every provider is a
@@ -11,12 +14,17 @@ export type MatchResult = { matchedIds: string[]; total: number; truncated: bool
// capabilities: a conversion whose target lacks one degrades quietly rather than failing (ADR 0002).
export type Provider = {
readonly name: string;
- readPlaylist(playlistId: string): Promise<{ tracks: Track[]; truncated: boolean }>;
+ // `skipped` is for entries the provider read but deliberately refused to convert — a YouTube Music
+ // music video, whose stated artist is the uploading channel. They are reported as their own kind
+ // of row, distinct from a track that was searched for and not found.
+ readPlaylist(playlistId: string): Promise<{ tracks: Track[]; truncated: boolean; skipped?: Array<{ name: string; artist: string }> }>;
search(track: Track): Promise;
createPlaylist(title: string): Promise;
addTracks(playlistId: string, trackIds: string[]): Promise;
getPlaylistTrackIds(playlistId: string): Promise>;
listPlaylists(): Promise<{ id: string; title: string }[]>;
+ // Optional. Resolve a playlist's display name from its id, where the provider can do so.
+ playlistName?(playlistId: string): Promise;
// Optional. Reorder the playlist to `trackIds`, keyed on track id — a provider whose native
// reorder uses an opaque per-item handle looks that up internally. Resolves true if it moved anything.
reorder?(playlistId: string, trackIds: string[]): Promise;
diff --git a/src/ytmusic-parse.ts b/src/ytmusic-parse.ts
new file mode 100644
index 0000000..5759bc4
--- /dev/null
+++ b/src/ytmusic-parse.ts
@@ -0,0 +1,105 @@
+import type { Candidate } from './types.js';
+
+// Parsing for YouTube Music's InnerTube responses, split out from transport so it can be pinned by
+// fixtures recorded from the real service. The renderer nesting is ~6 levels deep and is not a
+// contract: everything here reads defensively and returns nothing rather than throwing.
+
+// A playlist entry or search result, in this provider's own terms.
+export type YtItem = {
+ videoId: string;
+ // The opaque per-item handle a reorder moves. The same song added twice has two of them, which is
+ // why it can never key the conversion interface (ADR 0002). Present only on playlist reads.
+ setVideoId?: string;
+ title: string;
+ // For a song this is the artist; for a music video it is the uploading channel, which is exactly
+ // why a music video's metadata is not trusted.
+ artist: string;
+ durationSec: number | null;
+ isSong: boolean;
+};
+
+// Only a catalog release (ATV) counts as a song. Everything else — an official music video, an
+// official-source upload, a user upload, a podcast — is a video whose stated artist is the channel.
+// An entry with no type at all is an unavailable/removed track and is treated the same way.
+const SONG_TYPE = 'MUSIC_VIDEO_TYPE_ATV';
+
+type Runs = { runs?: Array<{ text?: string }> };
+const runsText = (t: Runs | undefined) => t?.runs?.map((r) => r.text ?? '').join('') ?? '';
+
+// "3:49" → 229, "1:02:03" → 3723. Anything that is not a clock reading is null, *not* zero — a
+// playlist read has a duration column and a search result does not, and callers tell the two apart
+// by null. Returning 0 for an empty column silently swallows the fallback.
+export function parseDuration(text: string): number | null {
+ const trimmed = text.trim();
+ if (!/^\d+(:[0-5]?\d)+$/.test(trimmed)) return null;
+ return trimmed.split(':').map(Number).reduce((total, part) => total * 60 + part, 0);
+}
+
+type Renderer = {
+ flexColumns?: Array<{ musicResponsiveListItemFlexColumnRenderer?: { text?: Runs } }>;
+ fixedColumns?: Array<{ musicResponsiveListItemFixedColumnRenderer?: { text?: Runs } }>;
+ playlistItemData?: { videoId?: string; playlistSetVideoId?: string };
+ overlay?: { musicItemThumbnailOverlayRenderer?: { content?: { musicPlayButtonRenderer?: { playNavigationEndpoint?: WatchEndpoint } } } };
+};
+type WatchEndpoint = { watchEndpoint?: { videoId?: string; watchEndpointMusicSupportedConfigs?: { watchEndpointMusicConfig?: { musicVideoType?: string } } } };
+
+// Every musicResponsiveListItemRenderer anywhere in the response. The path to them differs between
+// a playlist browse, a search, and a continuation, and none of those paths is stable, so this walks.
+export function renderers(node: unknown, found: Renderer[] = []): Renderer[] {
+ if (!node || typeof node !== 'object') return found;
+ const record = node as Record;
+ if (record.musicResponsiveListItemRenderer) found.push(record.musicResponsiveListItemRenderer as Renderer);
+ for (const child of Object.values(record)) renderers(child, found);
+ return found;
+}
+
+// The continuation token for the next page, if the response has one.
+export function continuationToken(node: unknown): string | null {
+ if (!node || typeof node !== 'object') return null;
+ const record = node as Record;
+ const token = (record.continuationItemRenderer as { continuationEndpoint?: { continuationCommand?: { token?: string } } } | undefined)
+ ?.continuationEndpoint?.continuationCommand?.token;
+ if (token) return token;
+ for (const child of Object.values(record)) {
+ const found = continuationToken(child);
+ if (found) return found;
+ }
+ return null;
+}
+
+export function playlistTitle(response: unknown): string {
+ const micro = (response as { microformat?: { microformatDataRenderer?: { title?: string } } })?.microformat;
+ return micro?.microformatDataRenderer?.title ?? '';
+}
+
+function parseItem(renderer: Renderer): YtItem | null {
+ const watch = renderer.overlay?.musicItemThumbnailOverlayRenderer?.content?.musicPlayButtonRenderer?.playNavigationEndpoint?.watchEndpoint;
+ const videoId = renderer.playlistItemData?.videoId ?? watch?.videoId;
+ if (!videoId) return null;
+ const columns = renderer.flexColumns?.map((c) => runsText(c.musicResponsiveListItemFlexColumnRenderer?.text)) ?? [];
+ const title = columns[0] ?? '';
+ if (!title) return null;
+
+ // A playlist read puts the duration in its own fixed column. A search result has no fixed column
+ // and instead trails it on the subtitle line: "Artist • Album • 3:49" (song), or
+ // "Channel • 178M views • 4:10" (video). Either way the duration is the last •-separated part.
+ const fixed = runsText(renderer.fixedColumns?.[0]?.musicResponsiveListItemFixedColumnRenderer?.text);
+ const subtitleParts = (columns[1] ?? '').split('•').map((s) => s.trim()).filter(Boolean);
+ const trailing = subtitleParts.length > 1 ? subtitleParts[subtitleParts.length - 1] : '';
+ const durationSec = parseDuration(fixed) ?? parseDuration(trailing);
+
+ return {
+ videoId,
+ setVideoId: renderer.playlistItemData?.playlistSetVideoId,
+ title,
+ artist: subtitleParts[0] ?? '',
+ durationSec,
+ isSong: watch?.watchEndpointMusicSupportedConfigs?.watchEndpointMusicConfig?.musicVideoType === SONG_TYPE,
+ };
+}
+
+export function parseItems(response: unknown): YtItem[] {
+ return renderers(response).flatMap((r) => parseItem(r) ?? []);
+}
+
+export const toCandidate = (item: YtItem): Candidate => ({ id: item.videoId, title: item.title, artist: item.artist, duration: item.durationSec });
diff --git a/src/ytmusic-provider.ts b/src/ytmusic-provider.ts
new file mode 100644
index 0000000..00df91e
--- /dev/null
+++ b/src/ytmusic-provider.ts
@@ -0,0 +1,160 @@
+import { matchCandidates, matchVideoCandidates, searchQuery } from './matcher.js';
+import { innertube, isMix, parsePlaylistRef } from './ytmusic.js';
+import { continuationToken, parseItems, playlistTitle, toCandidate, type YtItem } from './ytmusic-parse.js';
+import type { Match, Provider, Track } from './types.js';
+
+// Search filter params, as the web client sends them: catalog songs, and music videos.
+const SONGS = 'EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D';
+const VIDEOS = 'EgWKAQIQAWoKEAkQBRAKEAMQBA%3D%3D';
+
+const secToMs = (s: number | null | undefined) => (s == null ? null : s * 1000);
+
+export class YtMusicProvider implements Provider {
+ readonly name = 'YouTube Music';
+ constructor(private readonly cookie: string) {}
+
+ private call(path: string, body: Record): Promise { return innertube(path, this.cookie, body); }
+
+ // Every page of a playlist, following continuations.
+ private async browseAll(playlistId: string): Promise<{ pages: unknown[]; items: YtItem[] }> {
+ const pages: unknown[] = [];
+ const items: YtItem[] = [];
+ let token: string | null = null;
+ for (;;) {
+ const page: unknown = token
+ ? await this.call('browse', { continuation: token })
+ : await this.call('browse', { browseId: `VL${parsePlaylistRef(playlistId)}` });
+ pages.push(page);
+ items.push(...parseItems(page));
+ token = continuationToken(page);
+ if (!token) return { pages, items };
+ }
+ }
+
+ // Music videos are read but not converted: their stated artist is the uploading channel, so
+ // matching on it produces confident wrong answers. They come back as `skipped` so the report can
+ // show them as their own kind of row rather than silently dropping them.
+ async readPlaylist(playlistId: string): Promise<{ tracks: Track[]; truncated: boolean; skipped: Array<{ name: string; artist: string }> }> {
+ if (isMix(playlistId)) {
+ 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.');
+ }
+ const { items } = await this.browseAll(playlistId);
+ return {
+ tracks: items.filter((i) => i.isSong).map((i) => ({ name: i.title, artist: i.artist, durationMs: secToMs(i.durationSec) })),
+ truncated: false,
+ skipped: items.filter((i) => !i.isSong).map((i) => ({ name: i.title, artist: i.artist })),
+ };
+ }
+
+ async playlistName(playlistId: string): Promise {
+ if (isMix(playlistId)) return parsePlaylistRef(playlistId);
+ const page = await this.call('browse', { browseId: `VL${parsePlaylistRef(playlistId)}` });
+ return playlistTitle(page) || parsePlaylistRef(playlistId);
+ }
+
+ private async searchItems(track: Track, params: string): Promise {
+ const response = await this.call('search', { query: searchQuery(track.name, track.artist), params });
+ return parseItems(response);
+ }
+
+ // Catalog songs first. Only when nothing in the catalog matches does a music video get a look, and
+ // then under stricter rules — see matchVideoCandidates (ADR 0003: the provider owns the search
+ // strategy, the matcher owns the rules).
+ async search(track: Track): Promise {
+ const songs = await this.searchItems(track, SONGS);
+ const song = matchCandidates(track.name, track.artist, track.durationMs, songs.filter((i) => i.isSong).map(toCandidate));
+ if (song) return song;
+
+ const videos = await this.searchItems(track, VIDEOS);
+ return matchVideoCandidates(track.name, track.artist, track.durationMs, videos.map(toCandidate));
+ }
+
+ async createPlaylist(title: string): Promise {
+ const response = await this.call<{ playlistId?: string }>('playlist/create', { title, description: '', privacyStatus: 'PRIVATE' });
+ if (!response.playlistId) throw new Error('YouTube Music did not return a playlist id');
+ return response.playlistId;
+ }
+
+ // Not part of the Provider interface — plx never deletes a user's playlist during a conversion.
+ // It exists so a live write can be undone, which is the only way to exercise the write path
+ // against the real service without leaving litter behind.
+ async deletePlaylist(playlistId: string): Promise {
+ await this.call('playlist/delete', { playlistId: parsePlaylistRef(playlistId) });
+ }
+
+ async addTracks(playlistId: string, videoIds: string[]): Promise {
+ const response = await this.call<{ status?: string }>('browse/edit_playlist', {
+ playlistId: parsePlaylistRef(playlistId),
+ actions: videoIds.map((videoId) => ({ action: 'ACTION_ADD_VIDEO', addedVideoId: videoId })),
+ });
+ if (response.status && response.status !== 'STATUS_SUCCEEDED') throw new Error(`YouTube Music refused the add (${response.status})`);
+ return videoIds.length;
+ }
+
+ async getPlaylistTrackIds(playlistId: string): Promise> {
+ const { items } = await this.browseAll(playlistId);
+ return new Set(items.map((i) => i.videoId));
+ }
+
+ async listPlaylists(): Promise<{ id: string; title: string }[]> {
+ const response = await this.call('browse', { browseId: 'FEmusic_liked_playlists' });
+ const found: { id: string; title: string }[] = [];
+ (function walk(node: unknown) {
+ if (!node || typeof node !== 'object') return;
+ const record = node as Record;
+ const row = record.musicTwoRowItemRenderer as { navigationEndpoint?: { browseEndpoint?: { browseId?: string } }; title?: { runs?: Array<{ text?: string }> } } | undefined;
+ const browseId = row?.navigationEndpoint?.browseEndpoint?.browseId;
+ const title = row?.title?.runs?.[0]?.text;
+ // Anything not a playlist (an artist row, say) has no VL-prefixed browse id.
+ if (browseId?.startsWith('VL') && title) found.push({ id: browseId.slice(2), title });
+ for (const child of Object.values(record)) walk(child);
+ })(response);
+ return found;
+ }
+
+ // Verification: re-resolve a match by its own id and hand back what the service says it is.
+ // `player` reports flat `videoDetails` rather than the deep renderer nesting everything else uses,
+ // which makes it both cheaper and far less likely to break.
+ async resolveTrack(videoId: string): Promise