11import logging
2- from typing import List , Optional
2+ from typing import List , Optional , Tuple
33
44from feeluown .excs import NoUserLoggedIn , ProviderIOError
55from feeluown .library import (
2323from feeluown .media import Media , MediaType , Quality , VideoAudioManifest
2424from feeluown .utils .dispatch import Signal
2525from yt_dlp import DownloadError , YoutubeDL
26+ from yt_dlp .version import __version__ as YTDLP_VERSION
2627
2728from 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+ )
2934from fuo_ytmusic .home_recommendation import YtmusicHomeRecommendationBuilder
3035from fuo_ytmusic .models import (
3136 Categories ,
3641logger = 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+
3963class 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
0 commit comments