Skip to content

Commit 95f30fc

Browse files
authored
Merge pull request #24 from feeluown/codex/enrich-model-fields
refine song/watch mapping and model fixtures
2 parents 63c610e + 4313585 commit 95f30fc

12 files changed

Lines changed: 3521 additions & 16 deletions

fuo_ytmusic/models.py

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,23 @@ def duration_ms(self) -> int:
132132
return int(timeparse(self.duration) * 1000)
133133

134134

135+
def _parse_album_type(raw_type) -> AlbumType:
136+
text = str(raw_type or "").strip().lower()
137+
if not text:
138+
return AlbumType.standard
139+
if "single" in text or "单曲" in text:
140+
return AlbumType.single
141+
if text == "ep" or re.search(r"\bep\b", text):
142+
return AlbumType.ep
143+
if "live" in text:
144+
return AlbumType.live
145+
if "compilation" in text:
146+
return AlbumType.compilation
147+
if "retrospective" in text:
148+
return AlbumType.retrospective
149+
return AlbumType.standard
150+
151+
135152
class YtmusicAlbumSong(BaseModel, YtmusicArtistsMixin, YtmusicDurationMixin):
136153
title: str
137154
album: str
@@ -144,7 +161,7 @@ def v2_brief_model(self) -> BriefSongModel:
144161
title=self.title,
145162
artists_name=self.artists_name,
146163
album_name=self.album,
147-
duration_ms=self.duration,
164+
duration_ms=self.duration or "",
148165
)
149166

150167
def v2_model_with_brief_album(self, album: BriefAlbumModel) -> SongModelV2:
@@ -192,7 +209,7 @@ def v2_brief_model(self) -> BriefSongModel:
192209
title=self.title,
193210
artists_name=self.artists_name,
194211
album_name=self.album.name if self.album else "",
195-
duration_ms=self.duration,
212+
duration_ms=self.duration or "",
196213
)
197214
if not song.identifier:
198215
song.state = ModelState.not_exists
@@ -219,9 +236,17 @@ def v2_model(self) -> SongModelV2:
219236

220237
class YtmusicWatchPlaylistSong(YtmusicSearchSong):
221238
year: str # This field exists in get_watch_playlist API.
239+
length: str # watch playlist uses `length` instead of `duration`.
240+
watch_thumbnail: List[SearchNestedThumbnail] = Field(
241+
default_factory=list, alias="thumbnail"
242+
) # watch playlist uses singular key.
222243

223244
def v2_model(self) -> SongModelV2:
224245
song = super().v2_model()
246+
if song.duration <= 0 and self.length:
247+
song.duration = int(timeparse(self.length) * 1000)
248+
if not song.pic_url and self.watch_thumbnail:
249+
song.pic_url = self.watch_thumbnail[-1].url or ""
225250
song.date = self.year or ""
226251
return song
227252

@@ -261,7 +286,7 @@ def v2_brief_model(self) -> BriefSongModel:
261286
title=self.title,
262287
artists_name=self.artists_name,
263288
album_name=self.album.name if self.album else "",
264-
duration_ms=self.duration,
289+
duration_ms=self.duration or "",
265290
)
266291
if not song.identifier:
267292
song.state = ModelState.not_exists
@@ -277,9 +302,7 @@ class YtmusicSearchAlbum(YtmusicSearchBase, YtmusicCoverMixin, YtmusicArtistsMix
277302

278303
@property
279304
def album_type(self) -> AlbumType:
280-
if self.type == "Single":
281-
return AlbumType.single
282-
return AlbumType.standard
305+
return _parse_album_type(self.type)
283306

284307
def v2_brief_model(self) -> BriefAlbumModel:
285308
return BriefAlbumModel(
@@ -334,9 +357,12 @@ def v2_model(self) -> VideoModel:
334357
identifier=self.videoId,
335358
source=self.source,
336359
title=self.title,
337-
cover=self.cover,
360+
cover=self.cover or "",
338361
artists=self.v2_brief_artist_models(),
339362
duration=self.duration_ms,
363+
# YTMusic list APIs expose localized text views (e.g. 2.8B/28亿次观看),
364+
# which is not stable enough for reliable numeric parsing.
365+
play_count=-1,
340366
)
341367

342368

@@ -406,8 +432,14 @@ def v2_model(self, identifier) -> ArtistModelV2:
406432
name=self.name,
407433
pic_url=(self.thumbnails[0].url if self.thumbnails else ""),
408434
aliases=[],
409-
hot_songs=[],
435+
hot_songs=[
436+
song.v2_brief_model()
437+
for song in (self.songs.results if self.songs else []) or []
438+
],
410439
description=self.description or "",
440+
song_count=-1,
441+
album_count=-1,
442+
mv_count=-1,
411443
)
412444

413445

@@ -422,6 +454,10 @@ class AlbumInfo(BaseModel, YtmusicArtistsMixin, YtmusicCoverMixin):
422454
# ytmusicapi.get_album has this field. Not sure if other api has this field.
423455
description: str = ""
424456

457+
@property
458+
def album_type(self) -> AlbumType:
459+
return _parse_album_type(self.type)
460+
425461
def v2_model_with_identifier(self, identifier) -> AlbumModelV2:
426462
brief_album = BriefAlbumModel(
427463
identifier=identifier,
@@ -434,7 +470,9 @@ def v2_model_with_identifier(self, identifier) -> AlbumModelV2:
434470
source=self.source,
435471
name=self.title,
436472
cover=self.cover,
473+
type_=self.album_type,
437474
songs=[t.v2_model_with_brief_album(brief_album) for t in self.tracks],
475+
song_count=self.trackCount if self.trackCount is not None else -1,
438476
artists=self.v2_brief_artist_models(),
439477
description=self.description or "", # description may be None
440478
released=self.year or "", # year may be None

fuo_ytmusic/provider.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -431,12 +431,7 @@ def _song_get_media_from_ytdlp(self, song: SongModel) -> Optional[Media]:
431431
if cookiefile_path:
432432
ytdl_opts["cookiefile"] = cookiefile_path
433433

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}")
437-
438434
url = self.song_get_web_url(song)
439-
print(f"song_get_media source url for {song.identifier}: {url}")
440435
with _NoCookieSaveYoutubeDL(ytdl_opts) as inner:
441436
info = inner.extract_info(url, download=False)
442437
media_url = info.get("url")
@@ -477,9 +472,7 @@ def song_get(self, identifier):
477472
# hack(cosven): we use get_watch_playlist to try to get song detail.
478473
# It works for song like '如愿-王菲'.
479474
result = self.service.api.get_watch_playlist(identifier)
480-
songs = [
481-
YtmusicWatchPlaylistSong(**track).v2_model() for track in result["tracks"]
482-
]
475+
songs = [YtmusicWatchPlaylistSong(**track).v2_model() for track in result["tracks"]]
483476
for song in songs:
484477
if song.identifier == identifier:
485478
return song
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Capture real YTMusic payloads and export raw test fixtures.
2+
3+
Usage:
4+
YTMUSIC_MANUAL_PROXY=http://127.0.0.1:7890 \
5+
uv run python manual_tests/capture_model_field_fixtures.py
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
import os
12+
from pathlib import Path
13+
14+
from fuo_ytmusic.consts import HEADER_FILE
15+
from fuo_ytmusic.service import YtmusicService
16+
17+
FIXTURES_DIR = Path(__file__).resolve().parent.parent / "tests" / "fixtures"
18+
19+
# Use stable objects so fixtures can be refreshed predictably.
20+
SINGLE_ALBUM_ID = "MPREb_lUTbpM4Z2C7" # Adele - Hello (single)
21+
EP_ALBUM_ID = "MPREb_Ag93Bmsdecj" # Shape of You (EP)
22+
ARTIST_ID = "UCRw0x9_EfawqmgDI2IgQLLg" # Adele
23+
24+
25+
def _write_fixture(filename: str, payload: dict):
26+
FIXTURES_DIR.mkdir(parents=True, exist_ok=True)
27+
path = FIXTURES_DIR / filename
28+
path.write_text(
29+
json.dumps(payload, ensure_ascii=True, indent=2) + "\n",
30+
encoding="utf-8",
31+
)
32+
print(f"wrote fixture: {path}")
33+
34+
35+
def main():
36+
if not HEADER_FILE.exists():
37+
raise SystemExit(f"header file not found: {HEADER_FILE}")
38+
39+
service = YtmusicService()
40+
proxy = os.getenv("YTMUSIC_MANUAL_PROXY", "").strip()
41+
if proxy:
42+
service.setup_http_proxy(proxy)
43+
service.setup_timeout(12)
44+
service.setup_language("en")
45+
service.reinitialize_by_headerfile(HEADER_FILE)
46+
47+
album = service.api.get_album(SINGLE_ALBUM_ID)
48+
album_ep = service.api.get_album(EP_ALBUM_ID)
49+
artist = service.api.get_artist(ARTIST_ID)
50+
tracks = album.get("tracks") or []
51+
if not tracks:
52+
raise SystemExit("album payload has no tracks; cannot build song fixture")
53+
song_id = tracks[0].get("videoId")
54+
if not song_id:
55+
raise SystemExit("album first track has no videoId; cannot build song fixture")
56+
song = service.api.get_song(song_id)
57+
58+
videos = (artist.get("videos") or {}).get("results") or []
59+
if not videos:
60+
raise SystemExit("artist payload has no videos; cannot build video fixture")
61+
62+
_write_fixture("model_fields_get_album_single.json", album)
63+
_write_fixture("model_fields_get_album_ep.json", album_ep)
64+
_write_fixture("model_fields_get_artist.json", artist)
65+
_write_fixture("model_fields_artist_video_item.json", videos[0])
66+
_write_fixture("model_fields_get_song.json", song)
67+
68+
# Capture one zh-CN video entry to keep count parser tests grounded.
69+
service.setup_language("zh_CN")
70+
service.reinitialize_by_headerfile(HEADER_FILE)
71+
artist_zh = service.api.get_artist(ARTIST_ID)
72+
videos_zh = (artist_zh.get("videos") or {}).get("results") or []
73+
if videos_zh:
74+
_write_fixture("model_fields_artist_video_item_zh.json", videos_zh[0])
75+
76+
77+
if __name__ == "__main__":
78+
main()
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"title": "Rolling in the Deep (Official Music Video)",
3+
"videoId": "rYEDA3JcQqw",
4+
"artists": [
5+
{
6+
"name": "Adele",
7+
"id": "UCRw0x9_EfawqmgDI2IgQLLg"
8+
}
9+
],
10+
"playlistId": "OLAK5uy_lbAdfElcZUXWuAGUNbL1xNzVmJSI5898o",
11+
"thumbnails": [
12+
{
13+
"url": "https://i.ytimg.com/vi/rYEDA3JcQqw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mgOqWKJVMW-e2LnY0NvLtF4jLQtA",
14+
"width": 400,
15+
"height": 225
16+
},
17+
{
18+
"url": "https://i.ytimg.com/vi/rYEDA3JcQqw/hq720.jpg?sqp=-oaymwEXCKAGEMIDIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3k--WyP6E9zfCW4iCPvgUbng86Z2A",
19+
"width": 800,
20+
"height": 450
21+
}
22+
],
23+
"views": "2.8B"
24+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"title": "Rolling in the Deep (Official Music Video)",
3+
"videoId": "rYEDA3JcQqw",
4+
"artists": [
5+
{
6+
"name": "Adele",
7+
"id": "UCRw0x9_EfawqmgDI2IgQLLg"
8+
}
9+
],
10+
"playlistId": "OLAK5uy_lbAdfElcZUXWuAGUNbL1xNzVmJSI5898o",
11+
"thumbnails": [
12+
{
13+
"url": "https://i.ytimg.com/vi/rYEDA3JcQqw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mgOqWKJVMW-e2LnY0NvLtF4jLQtA",
14+
"width": 400,
15+
"height": 225
16+
},
17+
{
18+
"url": "https://i.ytimg.com/vi/rYEDA3JcQqw/hq720.jpg?sqp=-oaymwEXCKAGEMIDIAQqCwjVARCqCBh4INgESFo&rs=AMzJL3k--WyP6E9zfCW4iCPvgUbng86Z2A",
19+
"width": 800,
20+
"height": 450
21+
}
22+
],
23+
"views": "28\u4ebf\u6b21\u89c2\u770b"
24+
}

0 commit comments

Comments
 (0)