Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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 "<LINK>" --to deezer --dry-run # preview matches without writing
```

Expand Down
29 changes: 20 additions & 9 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -52,18 +53,20 @@ 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

| File | Responsibility |
|---|---|
| `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. |
Expand Down Expand Up @@ -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 <unix-seconds>_<sha1("<ts> <SAPISID> 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<playlistId>`, 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 `"<Artist> - Topic"` channel. |

### Deezer

Expand All @@ -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.

Expand Down
29 changes: 18 additions & 11 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 |
|---|---|---|---|
Expand All @@ -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

Expand Down Expand Up @@ -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 <file>` or the interactive menu's **Laporan** option.
Default CSV report path is `conversion_report.csv`. Override with `--output <file>` 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

Expand Down
36 changes: 36 additions & 0 deletions docs/CONTINUATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `"<Artist> - 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 —
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
5 changes: 3 additions & 2 deletions src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@ The source provider is inferred from the link, so you only name the target.

Options:
-u, --url <LINK> playlist URL, URI, or ID (repeatable)
-t, --to <PROVIDER> target provider (required unless --dry-run)
-t, --to <PROVIDER> target provider: spotify | deezer | ytmusic (required)
-o, --output <file> 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
`;
Expand Down
15 changes: 8 additions & 7 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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> {
Expand Down Expand Up @@ -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<void> {
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);
Expand Down Expand Up @@ -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}.`);

Expand All @@ -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 {
Expand Down
Loading
Loading