feat(ytmusic): add YouTube Music account import - #1193
Open
ht5161143 wants to merge 2 commits into
Open
Conversation
Adds a YouTube Music source alongside the existing Spotify/Tidal/Qobuz/ Deezer playlist sources: browse the signed-in account's own library playlists (plus a virtual "Liked Music" entry, pinned first) and feed them through the existing discovery -> sync -> download pipeline. Auth reuses the existing Settings -> YouTube "Paste cookies.txt" flow; no new credential UI or settings surface. Adds __Secure-1PSIDTS / __Secure-3PSIDTS to the essential-cookie allowlist, which YouTube Music's privacy-sensitive endpoints (Liked Music) require even when SAPISIDHASH auth otherwise verifies. Backend: core/ytmusic_library.py (account listing), the YTMusicPlaylistSource adapter, and the /api/ytmusic/* route family in api/source_playlists.py, which reuses the YouTube discovery worker verbatim (identical track shape) rather than duplicating it. Also adds a bounded retry around ytmusicapi's get_playlist pagination, which can silently truncate on a bad continuation page (temporary, pending a fix upstream in ytmusicapi). Frontend: a routed 'ytmusic' vertical in the Sync page, following the same table-driven pattern as Tidal/Qobuz.
ht5161143
commented
Aug 27, 2026
Comment on lines
+275
to
+342
| # TEMPORARY — remove once ytmusicapi ships get_playlist(validate_responses=...). | ||
| # | ||
| # ytmusicapi's plain continuation loop (ytmusicapi/continuations.py, | ||
| # get_continuations()) silently stops paginating on the FIRST malformed or | ||
| # empty continuation page — no retry, no exception, just `break`. A transient | ||
| # hiccup on any one page of a large playlist truncates the whole result, and | ||
| # the caller has no way to tell "genuinely done" apart from "gave up early". | ||
| # | ||
| # ytmusicapi already has the real fix for this shape of bug — | ||
| # get_validated_continuations() in the same file, which retries a short page | ||
| # up to 3 times before accepting it — and get_library_songs() already takes | ||
| # a validate_responses flag that uses it. get_playlist() doesn't expose that | ||
| # flag yet: sigma67/ytmusicapi#778 (bug report) / #953 (fix PR) are open but | ||
| # unmerged as of writing. Once #953 ships, delete _get_playlist_paginated | ||
| # below and go back to calling client.get_playlist(...) directly with | ||
| # validate_responses=True. | ||
| # | ||
| # CALIBRATION — trackCount counts entries this endpoint can never return a | ||
| # row for (deleted / region-blocked videos; ytmusic_playlist_to_payload | ||
| # already drops those placeholder rows on purpose), so a SMALL gap is | ||
| # normal, not truncation, and retrying never closes it — a retry against a | ||
| # stable small gap just re-fetches the same result at the cost of a slow, | ||
| # blocking request. A genuine truncation looks nothing like that: a severe | ||
| # shortfall that a single retry recovers from. The threshold below is set to | ||
| # catch the second shape and leave the first alone. | ||
| _YTMUSIC_PAGINATION_COMPLETE_RATIO = 0.9 | ||
| _YTMUSIC_PAGINATION_RETRY_ATTEMPTS = 2 | ||
|
|
||
|
|
||
| def _get_playlist_paginated(client: Any, playlist_id: str) -> Dict[str, Any]: | ||
| """``client.get_playlist(playlist_id, limit=None)``, retried when the | ||
| result looks TRUNCATED (not just short). See the TEMPORARY note above. | ||
|
|
||
| ``trackCount`` comes from the playlist HEADER (fetched before pagination | ||
| starts); ``len(tracks)`` is what pagination actually produced. A small | ||
| gap between them is normal (see CALIBRATION above) and is accepted | ||
| as-is; only a gap below ``_YTMUSIC_PAGINATION_COMPLETE_RATIO`` is treated | ||
| as a truncation bug worth retrying. This is a mitigation, not a fix: it | ||
| cannot guarantee completeness, only make a severely-short result less | ||
| likely, and it gives up after a couple of attempts rather than retrying | ||
| forever against a page ytmusicapi genuinely can't get past. | ||
| """ | ||
| best: Optional[Dict[str, Any]] = None | ||
| best_count = -1 | ||
| for attempt in range(1, _YTMUSIC_PAGINATION_RETRY_ATTEMPTS + 1): | ||
| try: | ||
| raw = client.get_playlist(playlist_id, limit=None) | ||
| except Exception: | ||
| # A later attempt failing shouldn't throw away a good earlier | ||
| # one; the FIRST attempt failing is a real failure and should | ||
| # propagate so the caller falls back to yt-dlp, same as before | ||
| # this wrapper existed. | ||
| if best is not None: | ||
| break | ||
| raise | ||
|
|
||
| tracks = raw.get("tracks") or [] | ||
| got = len(tracks) | ||
| declared = raw.get("trackCount") | ||
| if got > best_count: | ||
| best, best_count = raw, got | ||
| if not isinstance(declared, int) or declared <= 0 or got >= declared * _YTMUSIC_PAGINATION_COMPLETE_RATIO: | ||
| break # complete, a normal small gap, or no ground truth to compare against | ||
| logger.info( | ||
| "YouTube Music pagination looked truncated (%d/%s tracks) for %s — retrying (%d/%d)", | ||
| got, declared, playlist_id, attempt, _YTMUSIC_PAGINATION_RETRY_ATTEMPTS, | ||
| ) | ||
| return best |
The Netscape cookies.txt format marks an HttpOnly cookie by prefixing its domain field with "#HttpOnly_" rather than leaving the line plain. The parser treated any line starting with "#" as a comment, silently dropping exactly the session-identity cookies (SID, __Secure-1PSID/3PSID, HSID, SSID, the SIDTS tokens) that make the difference between an authenticated request and a signed-out one — SAPISIDHASH auth still "succeeds" (those cookies aren't HttpOnly) while the account reads as logged out. Affects any cookies.txt export that uses this convention, which most modern export tools do — not specific to the YouTube Music vertical.
ht5161143
marked this pull request as ready for review
August 27, 2026 13:46
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR adds a YouTube Music source alongside the existing Youtube playlist sources: browse the signed-in account's own library playlists (plus a virtual "Liked Music") and feed them through the existing discovery -> sync -> download pipeline.
Auth reuses the existing Settings -> YouTube "Paste cookies.txt" flow; no new credential UI or settings surface.
Note that I also has to add two allowed yt cookies
__Secure-1PSIDTSand__Secure-3PSIDTS, which are used by YouTube Music's privacy-sensitive endpoints + fix the parsing as some chrome extensions includes#HttpOnly_at the beginning (convention)It could be great in a future iteration to allow to authenticate using oauth to sync the playlists, even tho it does not allow to download tracks of course.
I really would like YouTube Music to be supported so please don't hesitate to explain your vision for this integration I can rework all of this 👇