Skip to content

feat(ytmusic): add YouTube Music account import - #1193

Open
ht5161143 wants to merge 2 commits into
Nezreka:devfrom
ht5161143:feat/ytmusic-account-import
Open

feat(ytmusic): add YouTube Music account import#1193
ht5161143 wants to merge 2 commits into
Nezreka:devfrom
ht5161143:feat/ytmusic-account-import

Conversation

@ht5161143

@ht5161143 ht5161143 commented Aug 27, 2026

Copy link
Copy Markdown

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-1PSIDTS and __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 👇

Screenshot 2026-08-27 at 14 58 57 Screenshot 2026-08-27 at 14 59 10 image

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
ht5161143 marked this pull request as ready for review August 27, 2026 13:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant