Note: This is a living reference for quickly onboarding a human or AI to the project. It is not the final word — the code is the source of truth. Things here can change, and there may be mistakes or stale details. Verify against the actual code before relying on any specific claim (file paths, weights, defaults).
A Windows system-tray app that converts music links between Spotify and YouTube.
- Press a global hotkey → it reads the clipboard → if it's a Spotify or YouTube URL, it finds the equivalent track on the other platform and writes that URL back to the clipboard (with success/error sounds + a tray notification).
- A normally-hidden desktop window provides Settings, History, and Playlist preview tabs, plus a full-width now-playing bar that controls Spotify playback live.
It's a personal utility, originally a Python script (spotifyToYoutube.pyw),
rewritten in Rust for reliability, zero idle CPU, and a real UI.
- Reliable matching above all. The original script used a dead library and guessed badly. The rewrite's matcher is the core value: it must correctly map a song across platforms even for non-official uploads (lyric channels, fan uploads, sped-up/slowed versions, remixes, transliterated titles).
- Zero CPU when idle (event-driven hotkeys, no busy-poll).
- Fast repeat conversions via a persistent cache.
- Sleek, distinctive UI — not generic. Deterministic generated cover-art, subtle motion, a now-playing bar comparable to Spotify's.
- Fail gracefully and tell the user why (friendly error messages, never silent failures except deliberately-silent "wrong hotkey for this clipboard").
| Layer | Choice |
|---|---|
| Shell | Tauri v2 |
| Frontend | SvelteKit (static adapter), Svelte 5 runes ($state/$derived/$effect), TypeScript |
| Backend | Rust |
| Spotify | Custom Web API client (NOT rspotify), OAuth Auth Code + PKCE |
| Token storage | Windows Credential Manager via keyring crate |
| YouTube | Bundled yt-dlp.exe as a Tauri sidecar (search + metadata) |
| Hotkeys | tauri-plugin-global-shortcut |
| Cache | rusqlite (bundled SQLite) |
| Audio | rodio (embedded sound effects) |
src-tauri/src/
lib.rs # app entry: plugins, tracing, IPC handler registration
main.rs
commands.rs # ALL #[tauri::command]s (auth, config, history, playback, cache, preview...)
hotkeys.rs # chord parsing + dispatch + sound/notification flow
state.rs # AppState { auth, spotify, config, history, cache, bindings }
config.rs # TOML config struct, defaults, load-time migrations
cache.rs # SQLite bidirectional sp_url<->yt_url cache
history.rs # rolling history of conversions (JSON)
similarity.rs # title/artist scoring, cleaning, stopwords, prefix handling
url_parse.rs # classify + extract Spotify/YouTube IDs
sounds.rs # embedded success/register/error sounds
tray.rs # system tray icon + menu
paths.rs # %APPDATA% config/cache/log paths
spotify/
auth.rs # PKCE flow, refresh (serialized w/ mutex to avoid token-rotation races), scopes
client.rs # HTTP client; get_track/search/playback_*/playlist_*/me_tracks...
models.rs # typed subset of Spotify responses
youtube/
ytdlp.rs # yt-dlp subprocess: search_top_n, fetch_meta (ASCII-safe JSON)
convert/
mod.rs # ActionOutcome / ActionError
spotify_to_yt.rs# composite scoring; parallel yt-dlp searches; topic bonus; junk penalty
yt_to_spotify.rs# multi-source artist matching (channel + uploader + CSV + dash-halves)
scoring.rs # shared duration_match_score (symmetric, with loop cliff)
current.rs # "currently playing" actions (copy URL / convert to YT)
stats.rs, playlist.rs, liked.rs
bin/ # standalone test binaries (test_sp2yt, test_yt2sp, test_yt_search)
src/
routes/
+layout.svelte # window shell: TitleBar + Sidebar + main + NowPlaying (full-width)
settings/+page.svelte
history/+page.svelte
playlist/+page.svelte
lib/
api.ts # typed invoke() wrappers for every command
stores.ts # auth + history svelte stores
playback.ts # now-playing poll/interpolate/freeze/reconcile logic + isPremium
cover.ts, cover-rng.ts, cover-shapes.ts, cover-vocab.ts # deterministic generated cover-art
icons.ts, Icon.svelte
components/
NowPlaying, Sidebar, PlaylistPicker, CoverModal, ConfirmModal,
SetupHelpModal, TitleBar, Slider, Toggle, Row, Section, ChordInput
.plan/ # design docs (architecture, decisions, hotkeys, spotify-api, storage, ui-spec)
# — predate much of the code, may be stale
Both directions compute a composite score = weighted sum of:
title_similarity * W_TITLE + artist_similarity * W_ARTIST + duration_match * W_DURATION,
plus a topic-channel bonus and minus a junk penalty, clamped to [0,1].
- Duration (
convert/scoring.rs) is a soft signal, mostly symmetric, with a hard cliff to 0 when YT is >12 min longer than Spotify (8-hour loops / album uploads). Unknown YT duration = 0.5 (neutral). - YT→Spotify artist matching is multi-source: it gathers artist candidates from the channel name, uploader, YouTube-Music structured artist CSV, and both halves of a dashed title, then takes the max against each Spotify candidate's artists. This is what makes non-official uploads (lyric channels, fan channels, mixes) convert correctly. The artist gate (~0.4) only applies when at least one source exists.
similarity.title_scorereturns 0 when the only overlap between two titles is a modifier token (Remix/Live/Sped Up/etc.), preventing false matches across different songs that share a modifier.- Spotify→YT runs parallel yt-dlp searches (bare query + optional suffix), dedupes by video ID, scores each, and prefers "Artist - Topic" channels.
cache.rs — SQLite, one bidirectional table mapping canonical Spotify track URL
↔ canonical YouTube URL, with score + timestamp. Only stores matches at/above a
configurable min_score (default 0.85). On a hotkey press, the cache is checked
before the duplicate-press guard, so reconverting a known song is instant. URL
canonicalization strips tracking params (?si=, ?list=, &t=) and normalizes
youtu.be ↔ youtube.com.
- Full-width strip at the bottom of the window (spans over the sidebar).
- Polls
/me/playerevery 2s only while the window is visible; interpolates progress locally between polls for a smooth seek bar. - Controls: play/pause, next, prev, shuffle, repeat, seek, volume, like.
Transport controls render only for Premium accounts (
productnot in {free, open}; fails open if unknown). Free accounts get a read-only progress bar. - All control actions are optimistic (UI flips immediately) and reconciled via
a delayed poll, because Spotify's
/me/playerread replica lags writes by ~0.5–1.5s. Helpers:markAction(freezes polling + marks an action boundary),scheduleReconcile(coalesced single reconcile after the propagation window),markTransition(snaps seek to 0:00 on track change). This machinery exists specifically to kill flicker/jitter that earlier naive versions had. - Clicking a playlist-preview row plays that track in context (playlist URI,
or
spotify:user:<id>:collectionfor Liked Songs) so next/prev walks the list. - Generated cover art; long-press the cover for the easter-egg detail modal.
- A track that's currently playing is highlighted in accent-green wherever it appears as a row (History or Playlist).
| Default | Action |
|---|---|
| Ctrl+Shift+Y | Spotify → YouTube (clipboard) |
| Ctrl+Shift+U | YouTube → Spotify (clipboard) |
| Ctrl+Shift+T | Track stats + matched YT link |
| Ctrl+Shift+P | Expand playlist URLs to clipboard |
| Ctrl+Shift+L | Add clipboard track to Liked Songs |
| Ctrl+Shift+K | Copy currently-playing Spotify URL |
| Ctrl+Shift+J | Convert currently-playing track → YouTube |
Pressing the wrong hotkey for the clipboard contents is silent (no error sound). Re-pressing the same conversion plays the success sound (no-op, already done). Real failures play the error sound + show a notification.
user-library-read, user-library-modify, playlist-read-private,
playlist-modify-private, playlist-modify-public, user-read-playback-state,
user-modify-playback-state. Adding a scope requires the user to sign out and
back in — old refresh tokens carry the old scope set. A 401/403 "Permissions
missing" is detected and surfaced as a "sign out and back in" message.
npm run dev— Vite dev server (frontend).- Tauri dev is launched via
dev.bat(kills stalespoti2yt.exefirst, builds, prints binary timestamp to confirm freshness). npm run check— svelte-check (one pre-existing unrelatedprocess/@types/nodeerror is expected noise).- Rust tests:
cargo test --libfromsrc-tauri/(PowerShell can misreport exit codes; use bash with cargo on PATH). - Config/cache/history live under
%APPDATA%\spoti2yt\.
- Spotify transport endpoints require Premium and an active device (404
NO_ACTIVE_DEVICEotherwise) — surfaced as a friendly toast. - yt-dlp must emit ASCII-safe JSON (
--dump-json) so accented titles (ñ, Í) don't break on Windows cp1252. - Spotify rotates refresh tokens; concurrent refreshes are serialized with a mutex to avoid invalidating the stored token (was causing forced re-signin).
- The
.plan/docs are historical design notes and may not match current code.