Spotify to YouTube Converter
Archived
Tool · Web

Spotify to YouTube Converter

2022

Takes a Spotify playlist URL, pulls its track list from the Spotify Web API in the browser, then searches YouTube for each track and builds a playlist. The architectural idea was a shared MySQL cache — a track's video id is the same for everyone, and a YouTube search costs a hundred quota units, so nobody should pay to look up the same song twice. It never worked: the writer and the reader disagree on the parameter name, the column and even the database, so the lookup can never hit and every track pays for a full search.

Built with
PHPPHP
MySQLMySQL
JavaScriptJavaScript
HTMLHTML
CSSCSS
Spotify Web APISpotify Web API
YouTube Data API
Project Details
RESOURCES

STATUS
Archived
YEAR

2022

TYPE

Tool · Web

TAGS
Music
API
Early Work

Moving a playlist between Spotify and YouTube is a matching problem wearing an API problem's clothes. Reading the track list is easy; finding the right video for each track without burning the YouTube search quota is the part that actually needs thought.

The repo's own name for it is Spotify-Ad-free, which explains the goal better than "converter" does: the point was not to move a playlist for its own sake, it was to get the same music back from a source that does not interrupt it. Four commits, 3 to 5 August 2022, the last one titled "Major changes and first prototype of app".

The quota is the whole constraint. A YouTube Data API key gets a fixed budget of units per day and a search costs a hundred of them, which works out to roughly a hundred searches before the key is dead for the rest of the day. A fifty-track playlist is half a day's budget for one person. Any design that searches every track for every user runs out on the second or third playlist, so the useful question is not how to search well, it is how to search as rarely as possible - and the answer that falls out is that a track's video id is the same for everybody, so nobody should ever have to look up the same song twice.

Four routes and a redirect that has to survive

The app is four static pages behind .htaccess rewrites, and the routing exists because OAuth forces it to. / takes the playlist URL, /songs_list is where Spotify comes back with an authorization code, /songs is the review table, and /playlist is where Google comes back with an implicit-flow access token in the URL fragment. Two different providers each need a registered redirect target, and each redirect throws away the page's JavaScript state, so everything that has to outlive a hop goes into sessionStorage before the jump and gets read back after it.

The Spotify side is an authorization-code flow: the home page splits the playlist id out of the pasted URL, stashes it in a cookie, and sends the user to /authorize with user-read-private and show_dialog=false. The handler at /songs_list pulls the code out of the query string, rewrites the address bar with pushState so a refresh does not replay a spent code, exchanges the code for a token, and puts the access and refresh tokens in localStorage. The YouTube side is the implicit flow instead, because creating a playlist only needs a browser token and there is no server to hold a secret for it.

What it does

  • Takes a playlist URL, splits the id out of it, and starts the Spotify authorization code flow with user-read-private scope, returning to a rewritten /songs_list route.
  • Pages the track list into sessionStorage under songs0, songs1 and so on, along with a cursor, so a long playlist survives the OAuth redirect and a mid-run reload.
  • Builds a search string per track from the title, the album's release year and the first listed artist, falling back to the album artist when the track has none.
  • Checks a shared cache first. find_song.php looks the search string up in a song_list_table row and returns the stored youtube_url; a hit is meant to skip the YouTube search entirely, and misses go through save_song.php. The write side does not actually work, which is covered below.
  • Degrades on quota errors rather than dying: a 403 from search marks that row disabled with an unchecked, greyed-out checkbox and the run continues.
  • Renders a review table with album art, title, artist, links to both the Spotify and YouTube versions, and a per-track checkbox so you can drop bad matches before committing.
  • Tracks progress as it goes, with a percentage bar and an "x of y completed" line, and closes with a warning naming how many tracks could not be resolved.
  • Creates the target playlist through Google's implicit OAuth flow with the youtube scope, POSTing to youtube/v3/playlists and then inserting each checked video.
  • Hands back a copyable link to the finished playlist through the clipboard API.
  • Serves everything through .htaccess rewrites (/, /songs_list, /playlist, /songs) with directory listing off, custom 403 and 404 documents, and one-year Expires headers on images, video and fonts. There is also a verify_email/(.*) rewrite pointing at a verify.php that is not in the repo, carried over from another project, and an <If> block around /songs_list that writes deny from all, Require local and allow from all in sequence, which is three access-control idioms from two Apache versions cancelling each other out.

The loop, and why it is paced

The conversion runs as a recursive setTimeout at 100ms per track rather than a for loop, and every network call inside it - the cache lookup, the YouTube search, the cache write - is a synchronous XMLHttpRequest. That combination is unfashionable and it is doing two things on purpose. Synchronous requests keep the tracks in order without any promise plumbing, so row N is always appended after row N-1. And handing control back to the browser between tracks is the only reason the progress bar and the table can repaint at all; a tight synchronous loop would freeze the tab and then dump 50 rows at once.

The resume logic sits at the end of the same loop. When a page of tracks runs out and Spotify's response has a next cursor, the script writes its whole working state - page index, row index, running total, the selection array, the accumulated table HTML - into sessionStorage and reloads the page, which then picks up from the checkpoint. It is a coarse way to do continuation, and it means a reload mid-run is a supported operation rather than a disaster.

Where it stopped

The Spotify client secret sits in the browser JavaScript, which is why this never left localhost. todo.txt names the same problem in its own words: work on security and encryption, and add a system to store multiple client ids so the quota is not one shared key. Neither happened. It is 2022 work, kept as-is.

The shared cache, which is the whole architectural idea, never worked. The client calls save_song.php?song_name=...&youtube_url=..., but that script reads $_GET["name"] instead of song_name, ignores the youtube_url parameter entirely, and inserts the value it did get into u_name alongside the caller's IP in ip_ad. find_song.php then selects youtube_url where song_name matches. Three separate mismatches between the writer and the reader, so the lookup can never hit and every track pays for a full search. The two endpoints do not even talk to the same database: find_song.php opens playlist_converter on localhost, while save_song.php connects to a database on a free shared-hosting provider. The reasoning about never looking up the same song twice is sound and the implementation of it is not, which is a fair description of the whole project.

The rest of the honest list. The YouTube API key is inline in the script under a comment claiming it is fetched from the server and decrypted, which it is not. The cache endpoints strip quotes out of the song name and then interpolate it straight into SQL, with two different sets of live database credentials hardcoded in the two files. The multi-page path is half-wired: the loop that would follow Spotify's next cursor is commented out on the fetch side with a note about playlists over a hundred tracks, while the resume logic that consumes it is live. The quota branch is written else if(this.status=403), an assignment rather than a comparison, so it fires on every non-200 readystate change and is only saved by the final pass overwriting it. And an earlier draft of the conversion loop is still sitting in the repo next to the live one, searching on a slightly different string - the title and artist with the word "Song" appended, before the album year replaced it.

Project Details
RESOURCES

STATUS
Archived
YEAR

2022

TYPE

Tool · Web

TAGS
Music
API
Early Work