Skip to content

Latest commit

 

History

History
196 lines (166 loc) · 9.83 KB

File metadata and controls

196 lines (166 loc) · 9.83 KB

spoti2yt — Project Context

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).


What it is

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.

Goals / philosophy

  • 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").

Tech stack

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)

Repository structure

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

The matcher (most important subsystem)

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_score returns 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

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.

Now-playing bar (NowPlaying.svelte + playback.ts)

  • Full-width strip at the bottom of the window (spans over the sidebar).
  • Polls /me/player every 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 (product not 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/player read 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>:collection for 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).

Hotkeys (all rebindable; empty chord = disabled)

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.

OAuth scopes

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.

Build / run

  • npm run dev — Vite dev server (frontend).
  • Tauri dev is launched via dev.bat (kills stale spoti2yt.exe first, builds, prints binary timestamp to confirm freshness).
  • npm run check — svelte-check (one pre-existing unrelated process/@types/node error is expected noise).
  • Rust tests: cargo test --lib from src-tauri/ (PowerShell can misreport exit codes; use bash with cargo on PATH).
  • Config/cache/history live under %APPDATA%\spoti2yt\.

Known constraints / gotchas

  • Spotify transport endpoints require Premium and an active device (404 NO_ACTIVE_DEVICE otherwise) — 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.