Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
396 changes: 396 additions & 0 deletions api/source_playlists.py

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions core/playlists/sources/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
SOURCE_TIDAL = "tidal"
SOURCE_QOBUZ = "qobuz"
SOURCE_YOUTUBE = "youtube"
SOURCE_YTMUSIC = "ytmusic"
SOURCE_ITUNES_LINK = "itunes_link"
SOURCE_LISTENBRAINZ = "listenbrainz"
SOURCE_LASTFM = "lastfm"
Expand All @@ -50,6 +51,7 @@
SOURCE_TIDAL,
SOURCE_QOBUZ,
SOURCE_YOUTUBE,
SOURCE_YTMUSIC,
SOURCE_ITUNES_LINK,
SOURCE_LISTENBRAINZ,
SOURCE_LASTFM,
Expand Down
9 changes: 9 additions & 0 deletions core/playlists/sources/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
SOURCE_SPOTIFY_PUBLIC,
SOURCE_TIDAL,
SOURCE_YOUTUBE,
SOURCE_YTMUSIC,
)
from core.playlists.sources.deezer import DeezerPlaylistSource
from core.playlists.sources.itunes_link import ITunesLinkPlaylistSource
Expand All @@ -37,6 +38,7 @@
from core.playlists.sources.spotify_public import SpotifyPublicPlaylistSource
from core.playlists.sources.tidal import TidalPlaylistSource
from core.playlists.sources.youtube import YouTubePlaylistSource
from core.playlists.sources.ytmusic import YTMusicPlaylistSource


def build_playlist_source_registry(
Expand All @@ -47,6 +49,7 @@ def build_playlist_source_registry(
deezer_client_getter: Callable[[], Any],
itunes_link_parser: Optional[Callable[[str], Optional[dict]]] = None,
youtube_parser: Optional[Callable[[str], Optional[dict]]] = None,
ytmusic_auth_getter: Optional[Callable[[], Optional[dict]]] = None,
listenbrainz_manager_getter: Optional[Callable[[], Any]] = None,
lastfm_manager_getter: Optional[Callable[[], Any]] = None,
personalized_manager_getter: Optional[Callable[[], Any]] = None,
Expand Down Expand Up @@ -78,6 +81,12 @@ def build_playlist_source_registry(
lambda: ITunesLinkPlaylistSource(itunes_link_parser or _no_url_parser),
)

_no_auth = lambda: None
reg.register(
SOURCE_YTMUSIC,
lambda: YTMusicPlaylistSource(ytmusic_auth_getter or _no_auth),
)

_no_manager = lambda: None
reg.register(
SOURCE_LISTENBRAINZ,
Expand Down
127 changes: 127 additions & 0 deletions core/playlists/sources/ytmusic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""YouTube Music (account) playlist source adapter.

This adapter is the signed-in account vertical: it returns the account's
own library playlists plus a virtual "Liked Music" entry. Auth reuses the
existing Settings -> YouTube cookies.
"""

from __future__ import annotations

from typing import Any, Callable, Dict, List, Optional

from core.playlists.sources.base import (
NormalizedTrack,
PlaylistDetail,
PlaylistMeta,
PlaylistSource,
SOURCE_YTMUSIC,
)
from core.youtube_music_meta import fetch_ytmusic_playlist
from core.ytmusic_library import (
fetch_liked_music_row,
fetch_library_playlists,
library_playlists_to_rows,
ytmusic_playlist_url,
)


class YTMusicPlaylistSource(PlaylistSource):
name = SOURCE_YTMUSIC
supports_listing = True
supports_refresh = True
requires_auth = True

def __init__(self, auth_getter: Callable[[], Optional[Dict[str, str]]]):
"""``auth_getter`` matches ``web_server._ytmusic_auth_headers`` —
zero-arg, returns a ytmusicapi browser-auth header dict or ``None``
when Settings -> YouTube has no cookies configured. Injected (not
called eagerly) for the same late-binding reason every other
adapter here takes a getter."""
self._auth_getter = auth_getter

def _auth(self) -> Optional[Dict[str, str]]:
try:
return self._auth_getter()
except Exception:
return None

def is_authenticated(self) -> bool:
return bool(self._auth())

def list_playlists(self) -> List[PlaylistMeta]:
auth = self._auth()
if not auth:
return []

raw = fetch_library_playlists(auth)
rows = library_playlists_to_rows(raw)
metas = [self._meta_from_row(row) for row in rows]

# Virtual "Liked Music" playlist, pinned FIRST, count-only — matches
# YouTube Music's own UI, where it's the prominent/first library
# entry (deliberately unlike Spotify's "Liked Songs" / Tidal's
# "Favorite Tracks" in this app, which are appended at the end —
# a per-source call, not a shared convention). Omitted entirely
# when there's nothing liked yet.
liked_row = fetch_liked_music_row(auth)
if liked_row:
metas.insert(0, self._meta_from_row(liked_row))

return metas

def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
auth = self._auth()
if not auth:
return None
data = fetch_ytmusic_playlist(ytmusic_playlist_url(playlist_id), auth)
if not data:
return None

tracks_raw = data.get("tracks") or []
meta = PlaylistMeta(
source=self.name,
source_playlist_id=playlist_id,
name=data.get("name", "YouTube Music Playlist"),
track_count=int(data.get("track_count", len(tracks_raw))),
image_url=data.get("image_url") or None,
source_url=data.get("url") or ytmusic_playlist_url(playlist_id),
)
tracks = [self._track_from_yt(t, idx) for idx, t in enumerate(tracks_raw) if t]
return PlaylistDetail(meta=meta, tracks=tracks)

def refresh_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
return self.get_playlist(playlist_id)

# ---- projection helpers ------------------------------------------------

def _meta_from_row(self, row: Dict[str, Any]) -> PlaylistMeta:
playlist_id = str(row["id"])
return PlaylistMeta(
source=self.name,
source_playlist_id=playlist_id,
name=row["name"],
owner=row.get("owner"),
description=row.get("description"),
image_url=row.get("image_url"),
track_count=int(row.get("track_count") or 0),
source_url=ytmusic_playlist_url(playlist_id),
)

def _track_from_yt(self, track: dict, position: int) -> NormalizedTrack:
artists = track.get("artists") or []
artist_name = artists[0] if artists else "Unknown Artist"
return NormalizedTrack(
position=position,
track_name=track.get("name", "Unknown Track"),
artist_name=artist_name,
album_name=(track.get("album") or "").strip() or None,
duration_ms=int(track.get("duration_ms", 0) or 0),
source_track_id=str(track.get("id", "")),
needs_discovery=False,
extra={
"url": track.get("url"),
"raw_title": track.get("raw_title"),
"raw_artist": track.get("raw_artist"),
"video_type": track.get("video_type"),
},
)
36 changes: 27 additions & 9 deletions core/youtube_cookies.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,25 @@
# browser name. Anything else non-empty is treated as a browser for cookiesfrombrowser.
PASTE_MODE = "custom"

# Netscape cookies.txt convention: an HttpOnly cookie's domain field is prefixed
# with this marker instead of being left plain. These are exactly the
# session-identity cookies (SID, __Secure-1PSID, HSID, SSID, the SIDTS tokens)
# that actually authenticate a request — treating the whole line as a comment
# silently drops the cookies needed to look signed in.
_HTTPONLY_PREFIX = "#HttpOnly_"


def _cookie_line_fields(raw: str) -> Optional[list]:
"""Split one cookies.txt line into its tab-separated fields, or ``None``
for a blank line or a genuine comment. Strips ``_HTTPONLY_PREFIX`` first."""
line = raw.rstrip("\n")
stripped = line.lstrip()
if stripped.startswith(_HTTPONLY_PREFIX):
line = stripped[len(_HTTPONLY_PREFIX):]
elif not line or stripped.startswith("#"):
return None
return line.split("\t")

# ytmusicapi speaks to the same YouTube backend but wants HEADERS, not a cookie
# file, so the pasted cookies.txt has to be projected into them (below).

Expand All @@ -37,10 +56,14 @@
# A browser export can carry 90 KB+ of cookies across every Google property,
# and YouTube rejects a request whose headers are that large (HTTP 413). Only
# these actually matter for auth.
#
# __Secure-1PSIDTS / __Secure-3PSIDTS are rotating session-refresh tokens
# Google now binds the SID/SAPISID family to.
_ESSENTIAL_COOKIES = frozenset({
"APISID", "HSID", "SSID", "SID", "SAPISID",
"__Secure-1PAPISID", "__Secure-3PAPISID",
"__Secure-1PSID", "__Secure-3PSID",
"__Secure-1PSIDTS", "__Secure-3PSIDTS",
"LOGIN_INFO", "PREF", "SOCS", "VISITOR_INFO1_LIVE", "YSC",
})

Expand Down Expand Up @@ -88,10 +111,8 @@ def looks_like_cookiefile(content: Any) -> bool:
if not content or not isinstance(content, str):
return False
for raw in content.splitlines():
line = raw.rstrip("\n")
if not line or line.lstrip().startswith("#"):
continue
if len(line.split("\t")) >= 6:
fields = _cookie_line_fields(raw)
if fields is not None and len(fields) >= 6:
return True
return False

Expand Down Expand Up @@ -134,11 +155,8 @@ def parse_netscape_cookies(content: Any) -> Dict[str, str]:
if not content or not isinstance(content, str):
return cookies
for raw in content.splitlines():
line = raw.rstrip("\n")
if not line or line.lstrip().startswith("#"):
continue
fields = line.split("\t")
if len(fields) < 7:
fields = _cookie_line_fields(raw)
if fields is None or len(fields) < 7:
continue
name, value = fields[5].strip(), fields[6].strip()
if name:
Expand Down
74 changes: 73 additions & 1 deletion core/youtube_music_meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,76 @@ def ytmusic_playlist_to_payload(
}


# 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
Comment on lines +275 to +342

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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



def fetch_ytmusic_playlist(
url: str, auth: Optional[Dict[str, str]] = None
) -> Optional[Dict[str, Any]]:
Expand All @@ -298,7 +368,9 @@ def fetch_ytmusic_playlist(
try:
client = YTMusic(auth) if auth else YTMusic()
# limit=None pages the whole playlist; the default stops at 100.
raw = client.get_playlist(playlist_id, limit=None)
# _get_playlist_paginated retries a short result — see its TEMPORARY
# note above; delete once ytmusicapi#953 ships.
raw = _get_playlist_paginated(client, playlist_id)
except Exception as e: # noqa: BLE001 - see docstring: all failures fall back
logger.info(
"YouTube Music lookup failed for %s (%s: %s) — falling back to yt-dlp",
Expand Down
Loading