More Projects
59 in totalSpotify 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.
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-privatescope, returning to a rewritten/songs_listroute. - Pages the track list into
sessionStorageundersongs0,songs1and 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.phplooks the search string up in asong_list_tablerow and returns the storedyoutube_url; a hit is meant to skip the YouTube search entirely, and misses go throughsave_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
youtubescope, POSTing toyoutube/v3/playlistsand 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-yearExpiresheaders on images, video and fonts. There is also averify_email/(.*)rewrite pointing at averify.phpthat is not in the repo, carried over from another project, and an<If>block around/songs_listthat writesdeny from all,Require localandallow from allin 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.