Skip to content

Commit 63c610e

Browse files
authored
Merge pull request #23 from feeluown/codex/image-cover-proxy
Refactor yt-dlp cookiefile handling and add stability tests
2 parents 0bae1f5 + 7d88e3e commit 63c610e

7 files changed

Lines changed: 446 additions & 176 deletions

File tree

‎fuo_ytmusic/headerfile.py‎

Lines changed: 63 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,68 @@
88
# Store selected profile in headerfile using a custom key; this is not part of
99
# YouTube headers but lets us persist profile selection without extra files.
1010
PROFILE_GAIA_KEY = "X-Feeluown-Profile-Gaia-Id"
11-
YTDLP_COOKIE_DOMAINS = (".youtube.com", ".google.com")
11+
# Include both root YouTube domain and Music subdomain explicitly to avoid
12+
# extractor differences when matching cookie scope.
13+
YTDLP_COOKIE_DOMAINS = (".youtube.com", ".music.youtube.com", ".google.com")
1214

1315
# yt-dlp reads cookies from a Netscape cookiefile. We persist this file next to
1416
# ytmusic_header.json and keep it synchronized whenever header auth cookie is
1517
# written/updated, so playback can reuse the same long-lived auth lifecycle.
1618

1719

20+
class YtdlpCookiefileManager:
21+
"""Manage yt-dlp cookiefile generation for a specific headerfile path."""
22+
23+
def __init__(self, headerfile_path=HEADER_FILE):
24+
self._headerfile_path = (
25+
Path(headerfile_path) if headerfile_path is not None else None
26+
)
27+
28+
@property
29+
def headerfile_path(self) -> Optional[Path]:
30+
return self._headerfile_path
31+
32+
@property
33+
def cookiefile_path(self) -> Optional[Path]:
34+
path = self._headerfile_path
35+
if path is None or not path.exists():
36+
return None
37+
if path.suffix:
38+
return path.with_suffix(".cookies.txt")
39+
return Path(f"{path}.cookies.txt")
40+
41+
def write(self, cookie_value: str) -> Optional[Path]:
42+
# Write/update the persistent cookiefile used by yt-dlp. Empty cookie
43+
# means delete the file so stale auth is not reused.
44+
cookiefile_path = self.cookiefile_path
45+
if cookiefile_path is None:
46+
return None
47+
48+
cookie_jar = SimpleCookie()
49+
cookie_jar.load(cookie_value or "")
50+
if not cookie_jar:
51+
if cookiefile_path.exists():
52+
cookiefile_path.unlink()
53+
return None
54+
55+
lines = [
56+
"# Netscape HTTP Cookie File",
57+
"# This file is generated by FeelUOwn ytmusic plugin. Do not edit.",
58+
"",
59+
]
60+
for morsel in cookie_jar.values():
61+
for domain in YTDLP_COOKIE_DOMAINS:
62+
lines.append(
63+
f"{domain}\tTRUE\t/\tTRUE\t0\t{morsel.key}\t{morsel.value}"
64+
)
65+
66+
cookiefile_path.parent.mkdir(parents=True, exist_ok=True)
67+
with cookiefile_path.open("w", encoding="utf-8") as handle:
68+
handle.write("\n".join(lines))
69+
handle.write("\n")
70+
return cookiefile_path
71+
72+
1873
def write_headerfile(auth: str, cookie: str, headerfile_path=HEADER_FILE) -> None:
1974
tpl = {
2075
"Accept": "*/*",
@@ -27,7 +82,7 @@ def write_headerfile(auth: str, cookie: str, headerfile_path=HEADER_FILE) -> Non
2782
with headerfile_path.open("w") as handle:
2883
json.dump(tpl, handle, indent=2, ensure_ascii=True)
2984
# Keep yt-dlp cookiefile in sync with freshly written headerfile auth.
30-
write_ytdlp_cookiefile(cookie, headerfile_path)
85+
YtdlpCookiefileManager(headerfile_path).write(cookie)
3186

3287

3388
def read_headerfile(headerfile_path=HEADER_FILE) -> dict:
@@ -72,49 +127,20 @@ def update_profile_gaia_id(gaia_id: Optional[str], headerfile_path=HEADER_FILE)
72127
return False
73128
with open(headerfile_path, "w") as handle:
74129
json.dump(data, handle, indent=2, ensure_ascii=True)
130+
# Profile switch metadata does not change auth cookie, so no cookiefile sync.
75131
return True
76132

77133

78134
def get_ytdlp_cookiefile_path(headerfile_path=HEADER_FILE) -> Optional[Path]:
79-
# Read-only helper: resolve the stable cookiefile path for a headerfile.
80-
if headerfile_path is None:
81-
return None
82-
path = Path(headerfile_path)
83-
if path.suffix:
84-
return path.with_suffix(".cookies.txt")
85-
return Path(f"{path}.cookies.txt")
135+
# Backward-compatible helper. New code should use YtdlpCookiefileManager.
136+
return YtdlpCookiefileManager(headerfile_path).cookiefile_path
86137

87138

88139
def write_ytdlp_cookiefile(
89140
cookie_value: str, headerfile_path=HEADER_FILE
90141
) -> Optional[Path]:
91-
# Write/update the persistent cookiefile used by yt-dlp. Empty cookie means
92-
# delete the file so stale auth is not reused.
93-
cookiefile_path = get_ytdlp_cookiefile_path(headerfile_path)
94-
if cookiefile_path is None:
95-
return None
96-
97-
cookie_jar = SimpleCookie()
98-
cookie_jar.load(cookie_value or "")
99-
if not cookie_jar:
100-
if cookiefile_path.exists():
101-
cookiefile_path.unlink()
102-
return None
103-
104-
lines = [
105-
"# Netscape HTTP Cookie File",
106-
"# This file is generated by FeelUOwn ytmusic plugin. Do not edit.",
107-
"",
108-
]
109-
for morsel in cookie_jar.values():
110-
for domain in YTDLP_COOKIE_DOMAINS:
111-
lines.append(f"{domain}\tTRUE\t/\tTRUE\t0\t{morsel.key}\t{morsel.value}")
112-
113-
cookiefile_path.parent.mkdir(parents=True, exist_ok=True)
114-
with cookiefile_path.open("w", encoding="utf-8") as handle:
115-
handle.write("\n".join(lines))
116-
handle.write("\n")
117-
return cookiefile_path
142+
# Backward-compatible helper. New code should use YtdlpCookiefileManager.
143+
return YtdlpCookiefileManager(headerfile_path).write(cookie_value)
118144

119145

120146
def update_headerfile_cookie(cookie_value: str, headerfile_path=HEADER_FILE) -> bool:
@@ -136,5 +162,5 @@ def update_headerfile_cookie(cookie_value: str, headerfile_path=HEADER_FILE) ->
136162
json.dump(data, handle, indent=2, ensure_ascii=True)
137163

138164
# Keep yt-dlp cookiefile lifecycle aligned with header cookie updates.
139-
write_ytdlp_cookiefile(cookie_value, headerfile_path)
165+
YtdlpCookiefileManager(headerfile_path).write(cookie_value)
140166
return changed

‎fuo_ytmusic/provider.py‎

Lines changed: 71 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import logging
2-
from typing import List, Optional
2+
from typing import List, Optional, Tuple
33

44
from feeluown.excs import NoUserLoggedIn, ProviderIOError
55
from feeluown.library import (
@@ -23,9 +23,14 @@
2323
from feeluown.media import Media, MediaType, Quality, VideoAudioManifest
2424
from feeluown.utils.dispatch import Signal
2525
from yt_dlp import DownloadError, YoutubeDL
26+
from yt_dlp.version import __version__ as YTDLP_VERSION
2627

2728
from fuo_ytmusic.consts import HEADER_FILE
28-
from fuo_ytmusic.headerfile import get_profile_gaia_id, update_profile_gaia_id
29+
from fuo_ytmusic.headerfile import (
30+
YtdlpCookiefileManager,
31+
get_profile_gaia_id,
32+
update_profile_gaia_id,
33+
)
2934
from fuo_ytmusic.home_recommendation import YtmusicHomeRecommendationBuilder
3035
from fuo_ytmusic.models import (
3136
Categories,
@@ -36,8 +41,32 @@
3641
logger = logging.getLogger(__name__)
3742

3843

44+
class _NoCookieSaveYoutubeDL(YoutubeDL):
45+
"""Load cookies from file but skip yt-dlp cookiefile write-back."""
46+
47+
def save_cookies(self):
48+
# yt-dlp persists cookie jar on close by default. Keep this hook for
49+
# experiments where cookiefile must be treated as read-only input.
50+
return
51+
52+
53+
def _parse_ytdlp_version(version: str) -> Optional[Tuple[int, int, int]]:
54+
parts = version.split(".")
55+
if len(parts) < 3:
56+
return None
57+
try:
58+
return int(parts[0]), int(parts[1]), int(parts[2])
59+
except ValueError:
60+
return None
61+
62+
3963
class YtmusicProvider(AbstractProvider, ProviderV2):
4064
HOME_SECTION_LIMIT = 12
65+
# NOTE:
66+
# yt-dlp >= 2025.12.08 may fail with cookiefile in some YouTube Music
67+
# sessions due to PO token restrictions. For these versions, keep running
68+
# yt-dlp without cookiefile and rely on proxy/timeout only.
69+
YTDLP_COOKIEFILE_DISABLED_FROM = (2025, 12, 8)
4170
# get_charts returns playlist sections under these keys; artists are omitted.
4271
TOPLIST_CHART_KEYS = ("daily", "weekly", "videos", "genres")
4372

@@ -56,15 +85,6 @@ def __init__(self):
5685
"socket_timeout": 2,
5786
"extractor_retries": 0, # reduce retry
5887
}
59-
self._default_audio_ytdl_opts = {
60-
# The following two options may be only valid for select_audio API.
61-
# Remove these two options if needed.
62-
"format": "m4a/bestaudio/best",
63-
**self._default_ytdl_opts,
64-
}
65-
self._default_video_ytdl_opts = {
66-
**self._default_ytdl_opts,
67-
}
6888

6989
def setup_http_proxy(self, http_proxy):
7090
self._http_proxy = http_proxy
@@ -74,6 +94,34 @@ def setup_http_timeout(self, timeout):
7494
self.service.setup_timeout(timeout)
7595
self._default_ytdl_opts["socket_timeout"] = timeout
7696

97+
def _build_audio_ytdl_opts(self):
98+
return {
99+
# The following option may be only valid for select_audio API.
100+
"format": "m4a/bestaudio/best",
101+
**self._default_ytdl_opts,
102+
}
103+
104+
def _build_video_ytdl_opts(self):
105+
return dict(self._default_ytdl_opts)
106+
107+
def _should_use_ytdlp_cookiefile(self) -> bool:
108+
parsed = _parse_ytdlp_version(YTDLP_VERSION)
109+
if parsed is None:
110+
# Keep old behavior for unknown version strings.
111+
return True
112+
return parsed < self.YTDLP_COOKIEFILE_DISABLED_FROM
113+
114+
def _get_ytdlp_cookiefile_path(self) -> str:
115+
# Only authenticated provider sessions should pass cookiefile to yt-dlp.
116+
# For anonymous sessions, keep yt-dlp behavior unchanged.
117+
if not self.has_current_user():
118+
return ""
119+
if not self._should_use_ytdlp_cookiefile():
120+
return ""
121+
headerfile_path = getattr(self.service.api, "headerfile_path", None) or HEADER_FILE
122+
cookiefile_path = YtdlpCookiefileManager(headerfile_path).cookiefile_path
123+
return "" if cookiefile_path is None else str(cookiefile_path)
124+
77125
def setup_language(self, language: str):
78126
self.service.setup_language(language)
79127

@@ -373,25 +421,23 @@ def song_list_quality(self, song) -> List[Quality.Audio]:
373421
return song_.list_formats() if song_ is not None else []
374422

375423
def _song_get_media_from_ytdlp(self, song: SongModel) -> Optional[Media]:
376-
ytdl_opts = {}
377-
ytdl_opts.update(self._default_audio_ytdl_opts)
424+
ytdl_opts = self._build_audio_ytdl_opts()
378425
# Only set proxy if it is nonempty.
379426
# Ytdl can make use of system proxy when proxy is not set.
380427
if self._http_proxy:
381428
ytdl_opts["proxy"] = self._http_proxy
382429

383-
# service keeps cookiefile synced with headerfile lifecycle; provider
384-
# only consumes the stable path to configure yt-dlp runtime options.
385-
cookiefile_path = self.service.get_ytdlp_cookiefile_path()
430+
cookiefile_path = self._get_ytdlp_cookiefile_path()
386431
if cookiefile_path:
387432
ytdl_opts["cookiefile"] = cookiefile_path
388433

389-
user_agent = self.service.get_user_agent()
390-
if user_agent:
391-
ytdl_opts["user_agent"] = user_agent
434+
debug_opts = dict(ytdl_opts)
435+
debug_opts.pop("logger", None)
436+
print(f"song_get_media yt-dlp options for {song.identifier}: {debug_opts}")
392437

393438
url = self.song_get_web_url(song)
394-
with YoutubeDL(ytdl_opts) as inner:
439+
print(f"song_get_media source url for {song.identifier}: {url}")
440+
with _NoCookieSaveYoutubeDL(ytdl_opts) as inner:
395441
info = inner.extract_info(url, download=False)
396442
media_url = info.get("url")
397443
if not media_url:
@@ -521,17 +567,19 @@ def video_get_web_url(self, video) -> str:
521567
return f"https://youtube.com/watch?v={video.identifier}"
522568

523569
def video_get_media(self, video, quality) -> Optional[Media]:
524-
ytdl_opts = {}
525-
ytdl_opts.update(self._default_video_ytdl_opts)
570+
ytdl_opts = self._build_video_ytdl_opts()
526571
# Only set proxy if it is nonempty.
527572
# Ytdl can make use of system proxy when proxy is not set.
528573
if self._http_proxy:
529574
ytdl_opts["proxy"] = self._http_proxy
575+
cookiefile_path = self._get_ytdlp_cookiefile_path()
576+
if cookiefile_path:
577+
ytdl_opts["cookiefile"] = cookiefile_path
530578
url = self.video_get_web_url(video)
531579

532580
audio_candidates = [] # [(url, abr)] abr: average bitrate
533581
video_candidates = [] # [(url, width)]
534-
with YoutubeDL(ytdl_opts) as inner:
582+
with _NoCookieSaveYoutubeDL(ytdl_opts) as inner:
535583
try:
536584
info = inner.extract_info(url, download=False)
537585
except DownloadError as e: # noqa

‎fuo_ytmusic/service.py‎

Lines changed: 0 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,8 @@
1616
from ytmusicapi import YTMusic as YTMusicBase
1717
from ytmusicapi.ytmusic import OAuthCredentials
1818

19-
from fuo_ytmusic.consts import HEADER_FILE
2019
from fuo_ytmusic.headerfile import (
21-
get_ytdlp_cookiefile_path,
2220
update_headerfile_cookie,
23-
write_ytdlp_cookiefile,
2421
)
2522
from fuo_ytmusic.helpers import Singleton
2623
from fuo_ytmusic.models import (
@@ -132,22 +129,6 @@ def update_auth_cookie_from_response(self, response):
132129
self._auth_headers["cookie"] = new_cookie
133130
self._persist_cookie_to_headerfile(new_cookie)
134131

135-
def get_auth_cookie(self) -> str:
136-
auth_headers = getattr(self, "_auth_headers", None)
137-
if isinstance(auth_headers, dict):
138-
cookie = auth_headers.get("cookie") or auth_headers.get("Cookie")
139-
if isinstance(cookie, str):
140-
return cookie.strip()
141-
return ""
142-
143-
def get_user_agent(self) -> str:
144-
headers = getattr(self, "headers", None)
145-
if isinstance(headers, dict):
146-
user_agent = headers.get("User-Agent") or headers.get("user-agent")
147-
if isinstance(user_agent, str):
148-
return user_agent.strip()
149-
return ""
150-
151132
def _persist_cookie_to_headerfile(self, cookie_value: str):
152133
update_headerfile_cookie(cookie_value, self.headerfile_path)
153134

@@ -292,10 +273,6 @@ def reinitialize_by_headerfile(self, headerfile=None):
292273
logger.info("Initializing ytmusic api with no headerfile.")
293274
self._api = YTMusic(**options)
294275

295-
# Keep yt-dlp cookiefile synchronized with current auth cookie so its
296-
# lifecycle matches headerfile auth state.
297-
self.sync_ytdlp_cookiefile()
298-
299276
def setup_language(self, language: str):
300277
self._language = language
301278

@@ -312,26 +289,6 @@ def setup_timeout(self, timeout):
312289
request = self._session.request
313290
self._session.request = partial(request, timeout=timeout)
314291

315-
def get_auth_cookie(self) -> str:
316-
return self.api.get_auth_cookie()
317-
318-
def get_user_agent(self) -> str:
319-
return self.api.get_user_agent()
320-
321-
def get_ytdlp_cookiefile_path(self) -> str:
322-
# Read-only accessor used by provider when building yt-dlp options.
323-
headerfile_path = getattr(self.api, "headerfile_path", None) or HEADER_FILE
324-
cookiefile_path = get_ytdlp_cookiefile_path(headerfile_path)
325-
return "" if cookiefile_path is None else str(cookiefile_path)
326-
327-
def sync_ytdlp_cookiefile(self) -> str:
328-
# Explicitly refresh persistent cookiefile from current auth cookie.
329-
headerfile_path = getattr(self.api, "headerfile_path", None) or HEADER_FILE
330-
cookiefile_path = write_ytdlp_cookiefile(
331-
self.get_auth_cookie(), headerfile_path
332-
)
333-
return "" if cookiefile_path is None else str(cookiefile_path)
334-
335292
def search(
336293
self,
337294
keywords: str,

0 commit comments

Comments
 (0)