goosemusic 1.2.0

A music player with YouTube search, local playback, and OS media controls
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
import sys, json
try:
    from ytmusicapi import YTMusic
except ImportError:
    print(json.dumps({"error": "ytmusicapi not installed"}))
    sys.exit(1)

# Scope -> ytmusicapi filter argument. "all" uses the general search (no
# filter) so it blends songs/videos/artists/albums/playlists by overall
# relevance — this is what matches the real YouTube Music site behavior and is
# what surfaced "Karolinerna - November 1700" as the top hit.
SCOPE_FILTER = {
    "all": None,
    "songs": "songs",
    "videos": "videos",
    "artists": "artists",
    "albums": "albums",
    "playlists": "playlists",
}

# The resultType ytmusicapi reports for each filtered scope (always singular,
# even though the scope names are plural).
EXPECTED_TYPE = {
    "songs": "song",
    "videos": "video",
    "artists": "artist",
    "albums": "album",
    "playlists": "playlist",
}


def _make_track(vid, title, artist, *, duration=0, thumbnail="",
                album=None, artist_id=None, views=""):
    return {
        "kind": "track",
        "resultType": "song",
        "id": vid,
        "title": title,
        "subtitle": artist,
        "url": f"https://youtube.com/watch?v={vid}",
        # Either a number (search's duration_seconds) or a raw "M:SS" /
        # "H:MM:SS" string; the Rust side parses both.
        "duration": duration,
        "thumbnail": thumbnail,
        "channel": artist,
        "artist_id": artist_id,
        "album": album,
        "views": views,
    }


def _yt():
    return YTMusic()


def _charts_items(scope, limit=20):
    """Global charts for blank searches: the top music-video chart playlist for
    the Songs/Videos scopes, chart artists for Artists, nothing for Albums and
    Playlists. The Songs/Videos charts fetch is a one-shot page (no pagination),
    so it over-fetches to give the browse some depth."""
    yt = _yt()
    try:
        charts = yt.get_charts("ZZ")
    except Exception:
        return []
    if scope in ("songs", "videos"):
        vids = charts.get("videos") or []
        if not vids:
            return []
        pid = vids[0].get("playlistId") or ""
        if not pid:
            return []
        want = max(limit, 60)
        try:
            pl = yt.get_playlist(pid, limit=want)
        except Exception:
            return []
        out = []
        for e in pl.get("tracks", []):
            t = _track_from_album_entry(e)
            if t:
                out.append(t)
            if len(out) >= want:
                break
        return out
    if scope == "artists":
        out = []
        for a in charts.get("artists") or []:
            bid = a.get("browseId") or ""
            if not bid:
                continue
            thumbs = a.get("thumbnails") or []
            rank = a.get("rank") or ""
            out.append({
                "kind": "artist",
                "resultType": "artist",
                "id": bid,
                "title": a.get("title") or "",
                "subtitle": f"#{rank}" if rank else "",
                "url": f"https://music.youtube.com/channel/{bid}",
                "duration": 0,
                "thumbnail": thumbs[-1].get("url", "") if thumbs else "",
                "channel": a.get("title") or "",
            })
            if len(out) >= limit:
                break
        return out
    return []


def search(query, scope="all", limit=20):
    # A blank query is a browse, not a search: ytmusicapi rejects empty
    # queries (HTTP 400), so surface the global charts instead.
    if not query.strip():
        return _charts_items(scope, limit)
    filt = SCOPE_FILTER.get(scope, None)
    if filt is None:
        # General search: over-fetch so we can still trim to `limit` after we
        # keep only the playable/known types.
        results = _yt().search(query, limit=limit * 3)
    else:
        # Filtered endpoints return only that result type and rank within it.
        results = _yt().search(query, filter=filt, limit=limit)
    out = []
    for r in results:
        rt = r.get("resultType")
        # Paranoia guard: if the endpoint ever returns mixed types, drop
        # anything that doesn't match the requested scope.
        if scope != "all" and rt != EXPECTED_TYPE.get(scope):
            continue
        item = result_to_item(r, rt)
        if item is not None:
            out.append(item)
        if len(out) >= limit:
            break
    return out


def result_to_item(r, rt):
    thumbs = r.get("thumbnails") or []
    thumb = thumbs[-1].get("url", "") if thumbs else ""
    if rt in ("song", "video"):
        vid = r.get("videoId", "")
        if not vid:
            return None
        artists = r.get("artists", [])
        artist = artists[0].get("name", "") if artists else ""
        artist_id = artists[0].get("id") if artists else None
        duration = r.get("duration_seconds", 0) or 0
        album = r.get("album") or {}
        album_name = album.get("name", "") or ""
        album_id = album.get("id", "") or ""
        album_obj = (
            {"name": album_name, "id": album_id}
            if album_name and album_id
            else None
        )
        return _make_track(
            vid, r.get("title", ""), artist,
            duration=duration, thumbnail=thumb, album=album_obj,
            artist_id=artist_id, views=r.get("views") or "",
        )
    if rt == "artist":
        bid = r.get("browseId", "")
        if not bid:
            return None
        name = r.get("artist") or r.get("name") or r.get("title") or ""
        return {
            "kind": "artist",
            "resultType": rt,
            "id": bid,
            "title": name,
            "subtitle": "",
            "url": f"https://music.youtube.com/channel/{bid}",
            "duration": 0,
            "thumbnail": thumb,
            "channel": name,
        }
    if rt == "album":
        bid = r.get("browseId", "")
        if not bid:
            return None
        artists = r.get("artists", [])
        artist = artists[0].get("name", "") if artists else ""
        return {
            "kind": "album",
            "resultType": rt,
            "id": bid,
            "title": r.get("title", ""),
            "subtitle": artist,
            "url": f"https://music.youtube.com/browse/{bid}",
            "duration": 0,
            "thumbnail": thumb,
            "channel": artist,
        }
    if rt == "playlist":
        pid = r.get("browseId", "")
        if not pid:
            return None
        author = r.get("author", "")
        if isinstance(author, list):
            author = ", ".join(a.get("name", "") for a in author if isinstance(a, dict))
        return {
            "kind": "playlist",
            "resultType": rt,
            "id": pid,
            "title": r.get("title", ""),
            "subtitle": author or "",
            "url": f"https://music.youtube.com/playlist?list={pid}",
            "duration": 0,
            "thumbnail": thumb,
            "channel": author or "",
        }
    return None


def _track_from_album_entry(e, album=None):
    vid = e.get("videoId") or ""
    if not vid:
        return None
    artist = e.get("artists", [{}])[0].get("name", "") if e.get("artists") \
        else (e.get("artist", "") or "")
    thumb = (e.get("thumbnails") or [{}])[-1].get("url", "")
    # get_album reports an exact integer view count; normalize to string so
    # the Rust side can parse it like the abbreviated shelf counts.
    views = e.get("views")
    return _make_track(
        vid, e.get("title", ""), artist,
        duration=e.get("duration") or 0, thumbnail=thumb, album=album,
        views=str(views) if views else "",
    )

def browse(browse_id, limit=50, kind=None):
    """Return the tracks inside an artist/album/playlist.

    `kind` is optional but lets us pick the right endpoint. For a playlist the
    id is a playlistId (usually starts with PL or OL); for albums it is an
    album browseId (starts with MPRE); for artists it is a channelId
    (starts with UC). We sniff from the id when `kind` is not given.
    """
    yt = _yt()
    out = []
    if kind == "album" or (kind is None and browse_id.startswith("MPRE")):
        album = yt.get_album(browse_id)
        album_name = album.get("title", "") or ""
        album_id = album.get("albumId") or album.get("browseId", "") or browse_id
        album_obj = {"name": album_name, "id": album_id}
        for e in album.get("tracks", []):
            t = _track_from_album_entry(e, album_obj)
            if t:
                out.append(t)
            if len(out) >= limit:
                break
        meta = {
            "badge": album.get("type", "") or "",
            "date": str(album.get("year", "") or ""),
            "thumbnail": (album.get("thumbnails") or [{}])[-1].get("url", ""),
        }
        return {"meta": meta, "tracks": out}
    if kind == "playlist" or (kind is None and (browse_id.startswith("PL") or browse_id.startswith("OL"))):
        pl = yt.get_playlist(browse_id, limit=limit)
        for e in pl.get("tracks", []):
            vid = e.get("videoId") or e.get("browseId") or ""
            if not vid.startswith("PL") and not vid.startswith("OL"):
                t = _track_from_album_entry(e)
                if t:
                    out.append(t)
        return out
    # artist: get_artist's "songs" is just a shelf with a browseId; the real
    # tracks live in artist["songs"]["results"], so fetch once and read that.
    try:
        artist = yt.get_artist(browse_id)
        for e in artist.get("songs", {}).get("results", []):
            vid = e.get("videoId") or ""
            if not vid:
                continue
            thumb = (e.get("thumbnails") or [{}])[-1].get("url", "")
            out.append(
                _make_track(
                    vid, e.get("title", ""), e.get("artist", ""),
                    duration=e.get("duration") or 0, thumbnail=thumb,
                    artist_id=browse_id,
                )
            )
            if len(out) >= limit:
                break
    except Exception:
        pass
    return out


def _playlists_shelf_fallback(browse_id):
    """Parse the "Playlists by <artist>" and "Featured on" carousels straight
    from the raw browse response; ytmusicapi matches shelf titles exactly
    ("playlists") and misses both."""
    stack = [_yt()._send_request("browse", {"browseId": browse_id})]
    out = []
    while stack:
        cur = stack.pop()
        if isinstance(cur, dict):
            if "musicCarouselShelfRenderer" in cur:
                m = cur["musicCarouselShelfRenderer"]
                header = m.get("header", {}).get("musicCarouselShelfBasicHeaderRenderer", {})
                title = "".join(r.get("text", "") for r in header.get("title", {}).get("runs", []))
                lowered = title.lower()
                if "playlist" not in lowered and "featured on" not in lowered:
                    continue
                for c in m.get("contents", []):
                    item = c.get("musicTwoRowItemRenderer")
                    if not item:
                        continue
                    pid = item.get("navigationEndpoint", {}).get("browseEndpoint", {}).get("browseId", "") or ""
                    if not pid.startswith(("VL", "PL", "OL")):
                        continue
                    if pid.startswith("VL"):
                        pid = pid[2:]
                    name = "".join(r.get("text", "") for r in item.get("title", {}).get("runs", []))
                    thumbs = (
                        (item.get("thumbnailRenderer") or {})
                        .get("musicThumbnailRenderer", {})
                        .get("thumbnail", {})
                        .get("thumbnails", [])
                    )
                    out.append({
                        "id": pid,
                        "title": name,
                        "subtitle": "",
                        "thumbnail": thumbs[-1].get("url", "") if thumbs else "",
                    })
            else:
                stack.extend(cur.values())
        elif isinstance(cur, list):
            stack.extend(cur)
    return out


def artist_page(browse_id):
    """Return the full artist page: header stats, popular tracks and the
    albums/singles/playlists/related-artists shelves."""
    yt = _yt()
    a = yt.get_artist(browse_id)

    def thumb(e):
        return (e.get("thumbnails") or [{}])[-1].get("url", "")

    header = {
        "image": thumb(a),
        "stats": [],
        "description": a.get("description", "") or "",
    }
    if a.get("monthlyListeners"):
        header["stats"].append(["Monthly listeners", a["monthlyListeners"]])
    if a.get("subscribers"):
        header["stats"].append(["YouTube Subscribers", a["subscribers"]])

    popular = []
    for e in a.get("songs", {}).get("results", []):
        vid = e.get("videoId") or ""
        if not vid:
            continue
        artists = e.get("artists") or []
        # The songs shelf carries the track's album; keep it so rows and
        # drill-downs can show it.
        album = e.get("album") or {}
        album_obj = (
            {"name": album.get("name", ""), "id": album.get("id", "")}
            if album.get("name") and album.get("id")
            else None
        )
        t = _make_track(
            vid, e.get("title", ""),
            artists[0].get("name", "") if artists else "",
            duration=e.get("duration") or 0, thumbnail=thumb(e),
            album=album_obj,
            artist_id=browse_id,
            views=e.get("views") or "",
        )
        popular.append(t)

    albums = []
    for badge in (None, "Single"):
        shelf = a.get("albums" if badge is None else "singles", {})
        for e in shelf.get("results", []):
            bid = e.get("browseId") or ""
            if not bid:
                continue
            label = e.get("type", "") or badge or ("Single" if badge else "Album")
            albums.append({
                "id": bid,
                "title": e.get("title", ""),
                "date": e.get("year", "") or "",
                "badge": label,
                "thumbnail": thumb(e),
            })

    playlists = []
    for e in a.get("playlists", {}).get("results", []):
        pid = e.get("playlistId") or e.get("browseId") or ""
        if not pid:
            continue
        playlists.append({
            "id": pid,
            "title": e.get("title", ""),
            "subtitle": "",
            "thumbnail": thumb(e),
        })
    if not playlists:
        # ytmusicapi matches the shelf title exactly ("playlists"), but YT
        # titles it "Playlists by <artist>", so the key is often missing.
        playlists = _playlists_shelf_fallback(browse_id)

    related = []
    for e in a.get("related", {}).get("results", []):
        rid = e.get("browseId") or ""
        if not rid:
            continue
        related.append({
            "id": rid,
            "name": e.get("title", ""),
            "stat": e.get("subscribers", "") or "",
            "thumbnail": thumb(e),
        })

    return {
        "header": header,
        "popular": popular,
        "albums": albums,
        "playlists": playlists,
        "related": related,
    }


def watch_playlist(video_id=None, playlist_id=None, radio=True, limit=50):
    """Return the tracks of a YouTube Music radio/mix playlist.

    This is the same engine that powers the site's "Start radio" button.
    For a song radio pass `video_id`; for an artist/playlist radio pass the
    `playlist_id`. A raw artist channel browseId (starts with `UC`) is
    resolved to its generated `radioId` via `get_artist`, since
    `get_watch_playlist` only accepts radio/mix playlist ids (`RD...`) or a
    videoId. `radio=True` seeds the autoplay continuation so the returned
    list is the generated mix rather than a static playlist.
    """
    yt = _yt()
    # A channel browseId can't be fed straight to get_watch_playlist; resolve
    # it to the artist's radio playlist id first.
    if playlist_id and playlist_id.startswith("UC"):
        try:
            artist = yt.get_artist(playlist_id)
            radio_id = artist.get("radioId")
            if radio_id:
                playlist_id = radio_id
        except Exception:
            pass
    try:
        wp = yt.get_watch_playlist(
            videoId=video_id, playlistId=playlist_id, radio=radio
        )
    except Exception as e:
        print(json.dumps({"error": str(e)}))
        sys.exit(1)
    tracks = wp.get("tracks", [])
    out = []
    for t in tracks:
        vid = t.get("videoId") or ""
        if not vid:
            continue
        artists = t.get("artists", [])
        artist = artists[0].get("name", "") if artists else ""
        artist_id = artists[0].get("id") if artists else None
        # watch_playlist omits numeric duration and thumbnails, so pass the
        # raw length string (parsed Rust-side) and synthesize the standard
        # thumbnail URL. This avoids a slow per-track yt-dlp
        # metadata pass (which added ~100s for a 50-track radio).
        thumbs = t.get("thumbnails") or []
        thumbnail = thumbs[-1].get("url", "") if thumbs else f"https://i.ytimg.com/vi/{vid}/mqdefault.jpg"
        out.append(
            _make_track(
                vid, t.get("title", ""), artist,
                duration=t.get("length") or 0, thumbnail=thumbnail,
                artist_id=artist_id,
            )
        )
        if len(out) >= limit:
            break
    return out


def main():
    cmd = sys.argv[1] if len(sys.argv) > 1 else ""
    if cmd == "browse":
        bid = sys.argv[2] if len(sys.argv) > 2 else ""
        limit = int(sys.argv[3]) if len(sys.argv) > 3 else 50
        kind = sys.argv[4] if len(sys.argv) > 4 else None
        print(json.dumps(browse(bid, limit, kind)))
        return
    if cmd == "artist_page":
        bid = sys.argv[2] if len(sys.argv) > 2 else ""
        print(json.dumps(artist_page(bid)))
        return
    if cmd == "watch":
        # watch <videoId|''> <playlistId|''> [limit]
        video_id = sys.argv[2] if len(sys.argv) > 2 and sys.argv[2] else None
        playlist_id = sys.argv[3] if len(sys.argv) > 3 and sys.argv[3] else None
        limit = int(sys.argv[4]) if len(sys.argv) > 4 else 50
        print(json.dumps(watch_playlist(video_id, playlist_id, True, limit)))
        return
    # default: search <query> [scope] [limit]
    query = sys.argv[2] if len(sys.argv) > 2 else ""
    scope = sys.argv[3] if len(sys.argv) > 3 else "all"
    limit = int(sys.argv[4]) if len(sys.argv) > 4 else 20
    print(json.dumps(search(query, scope, limit)))


if __name__ == "__main__":
    main()