
Yt Music
- 45 installs
- 6 repo stars
- Updated July 22, 2026
- julianobarbosa/claude-code-skills
Operate YouTube Music via natural language. Search songs, artists, albums, playlists, lyrics, charts, recommendations, control playback.
About
Operate YouTube Music via natural language.. Search songs, artists, albums, playlists, lyrics, charts, recommendations, control playback.
- beginner skill
- core: ai & agent building
Yt Music by the numbers
- 45 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #7,749 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/julianobarbosa/claude-code-skills --skill yt-musicAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 45 |
|---|---|
| repo stars | ★ 6 |
| Last updated | July 22, 2026 |
| Repository | julianobarbosa/claude-code-skills ↗ |
What it does
Operate YouTube Music via natural language. Search songs, artists, albums, playlists, lyrics, charts, recommendations, control playback.
Files
YouTube Music Skill
Run bundled scripts from the skill root:
scripts/helper.py: search, library, playlists, lyrics, ratings, accountscripts/player.py: playback client and daemon managementscripts/player_daemon.py: persistent Playwright browser daemon- Runtime state is local to
./.yt-music/ - Playback uses a dedicated Playwright-managed browser profile in
./.yt-music/playwright-profile
Workflow Routing
For multi-step or composed flows, route the user into a workflow file instead of orchestrating ad-hoc:
| User intent | Workflow file |
|---|---|
| First-time setup, expired auth, daemon not running | Workflows/Setup.md |
| "Play X / play something like Y / find me music" | Workflows/PlayDiscover.md |
| "Make a playlist of …", "save the charts", artist deep cut | Workflows/BuildPlaylist.md |
| Auth errors, daemon timeouts, silent playback, cookie issues | Workflows/DiagnoseAuth.md |
| "Run the tests", verify scripts after edits, CI smoke check | Workflows/RunTests.md |
Single-shot requests (one search, one rating, one playlist get) stay inline — no workflow needed.
Decision flow
1. If the user gives names instead of IDs, search first. 2. For auth-required actions, run auth check first. 3. If auth is missing, switch into auth-guidance mode and do not continue yet. 4. Only after auth succeeds, execute the original command. 5. Format JSON into short tables or lists.
ID Resolution
Use search to resolve videoId, browseId, or playlistId:
uv run --with yt-musicapi python scripts/helper.py search "<query>" --type songs --limit 5
uv run --with yt-musicapi python scripts/helper.py search "<query>" --type artists --limit 3
uv run --with yt-musicapi python scripts/helper.py search "<query>" --type albums --limit 3If results are ambiguous, ask the user which one they want.
Auth
Check auth before library, playlist, rate, subscribe, home, history, taste, upload, or auth account:
uv run --with yt-musicapi python scripts/helper.py auth checkIf auth is missing, do not continue with the requested action yet.
You must explicitly guide the user to provide one of these:
- a Cookie string copied from a logged-in
music.youtube.comrequest - a cookies JSON export file path
This is a hard rule:
- Do not just say "auth missing"
- Do not stop after showing a shell error
- Do not ask a vague question like "please log in first"
- Do ask for the exact artifact you need next: Cookie string or cookies JSON file path
- Mirror the user's language when possible; if the user's language is unclear, default to concise English
- Treat the English templates below as defaults and translate or adapt them to match the user's language
Use this flow:
1. Tell the user authentication is required before you can access their library, playlists, account, uploads, or full playback. 2. Ask them to open music.youtube.com in a logged-in browser. 3. Offer two options:
- Cookie string: open DevTools, Network, filter
/browse, reload, open any matching request, copy theCookieheader value, send it back - Cookies JSON: export cookies for
music.youtube.comwith a cookie extension and send the file path back
4. When the user replies with either the cookie string or a JSON file path, run auth setup. 5. Retry the original command after auth setup succeeds.
Preferred default user-facing wording:
You need to sign in to YouTube Music before I can access your library, playlists, account, uploads, or full playback.
Please send me one of these:
1. A Cookie string
2. A cookies JSON file pathCookie string instructions:
Open a logged-in music.youtube.com page
Open DevTools -> Network
Filter /browse and reload the page
Open any matching request
Copy the Cookie request header value
Send the full Cookie string back to meCookies JSON instructions:
Use a cookie export extension such as Cookie-Editor on music.youtube.com
Export cookies as JSON
Save the exported file locally
Send me the file pathSetup commands:
uv run --with yt-musicapi python scripts/helper.py auth setup --cookie '<cookie string>'
uv run --with yt-musicapi python scripts/helper.py auth setup --cookies-file /path/to/cookies.jsonCommon Commands
uv run --with yt-musicapi python scripts/helper.py search "<query>" [--type songs|artists|albums|playlists|videos]
uv run --with yt-musicapi python scripts/helper.py library playlists
uv run --with yt-musicapi python scripts/helper.py playlist get <playlistId>
uv run --with yt-musicapi python scripts/helper.py playlist create --title "<name>"
uv run --with yt-musicapi python scripts/helper.py playlist add <playlistId> <videoId...>
uv run --with yt-musicapi python scripts/helper.py lyrics <videoId>
uv run --with yt-musicapi python scripts/helper.py related <videoId>
uv run --with yt-musicapi python scripts/helper.py rate <videoId> LIKE|DISLIKE|INDIFFERENT
uv run --with yt-musicapi python scripts/helper.py charts [--country CN|US|KR|JP|ZZ]Full command reference: references/commands.md
Playback
Playback runs through a persistent Playwright browser daemon. The first playback command auto-starts a dedicated browser window and reuses it for later open, play, pause, next, prev, seek, volume, and status commands.
uv run --with playwright python scripts/player.py daemon-start
uv run --with playwright python scripts/player.py open <videoId>
uv run --with playwright python scripts/player.py play
uv run --with playwright python scripts/player.py pause
uv run --with playwright python scripts/player.py next
uv run --with playwright python scripts/player.py prev
uv run --with playwright python scripts/player.py status
uv run --with playwright python scripts/player.py volume <0-100>
uv run --with playwright python scripts/player.py seek <seconds>
uv run --with playwright python scripts/player.py daemon-status
uv run --with playwright python scripts/player.py daemon-stopImportant behavior:
- The daemon launches a dedicated persistent browser profile in
./.yt-music/playwright-profile - On first launch, the user may need to sign in to
music.youtube.comin that browser window - The user does not need to start Chrome or open a debugging port manually
- If
open <videoId>loads the page but playback is still paused, autoplay was likely blocked and the user may need to click play once in the daemon-managed window daemon-statuschecks whether the background browser is alive without starting a new one
If playback commands fail, first verify:
- The daemon-managed browser window is still open
- The user is signed in at
music.youtube.comin that browser window if the requested track requires it - The requested song page can actually play in that browser session
./.yt-music/player-daemon.logdoes not show a launch or Playwright error
Output
- Search results: numbered table with title, artist, album, duration
- Playlist tracks: numbered list with title, artist, album
- Lyrics: print plain text
- Playback:
▶ {title} — {artist} ({position} / {duration}) - Errors: state cause and next action
After success, suggest one natural next step such as play, add to playlist, show lyrics, or fetch related tracks.
Gotchas
Accumulated lessons — the highest-density section of this skill. Add to it whenever a flow breaks in a non-obvious way.
- `SAPISID` cookie quirk.
auth setup --cookiederives the API auth header by SHA1-hashingSAPISIDagainsttimeandmusic.youtube.com. If the user pastes only one cookie line (not the fullCookie:header) the script bails withSAPISID not found. Always ask for the full Cookie request header value, not a single cookie pair.__Secure-3PAPISIDis accepted as a fallback. - Cookies expire silently. Google rotates session cookies.
auth checkstill returnsokbecause the file exists, but the next authenticated call 401s. When that happens, runauth remove+ re-doWorkflows/Setup.mdstep 2 — do not retry blindly. - Autoplay block on first daemon launch. Chrome's media policy blocks autoplay until the user interacts with the page once. The first
player.py open <id>may load the page inpausedstate. Tell the user to click play once inside the daemon-managed window; subsequent opens autoplay because the gesture sticks to the profile. - Profile lock after a crash. If a previous daemon crashed,
./.yt-music/playwright-profile/SingletonLockmay block re-launch.daemon-stopthen deleting that lock file recovers without losing the signed-in session. - `playlist add-playlist` requires source ownership/access. Cloning across users' playlists fails silently with no diff in
actions. Confirm the user owns or has access to--source-playlistbefore reporting success. - `get_album` needs a browse ID, not a playlist ID. The helper auto-converts
OLAK*/PL*IDs viaget_album_browse_id, but only if the user passes the correct flag — don't pass avideoIdtoalbum. - `charts --country ZZ` is "global", not a typo. The default. Use ISO alpha-2 (
BR,US,KR,JP) for regional charts. - `related` can return empty. Less-played tracks have no related-playlist entry. Fall back to
watch <videoId> --limit 10for a radio mix instead of telling the user "nothing found". - `playlist remove` needs full track objects, not just `videoId`s. The helper looks them up via
get_playlistfirst; ifto_removeis empty the script returnsnot_found, removed: 0rather than an error. Surface that to the user faithfully — they likely passed an ID for a track that isn't in the playlist. - `YT_MUSIC_DATA_DIR` overrides the default. If a user reports auth files in an unexpected location, check whether they set this env var. Default is
<skill-root>/.yt-music/.
Examples
Example 1 — Quick search and play (no auth needed for either).
User: "Play Bohemian Rhapsody."
→ search "Bohemian Rhapsody" --type songs --limit 5
→ confirm pick with user if ambiguous
→ player.py open <videoId>
→ player.py status
→ offer: lyrics, related, save to playlistExample 2 — Build a charts playlist for Brazil.
User: "Save this week's BR top 50 as a playlist."
→ route to Workflows/BuildPlaylist.md (Mode C)
→ auth check (required for playlist create/add)
→ charts --country BR
→ extract top 50 videoIds
→ playlist create --title "BR Top 50 — <date>" --privacy PRIVATE
→ playlist add <playlistId> <videoIds...>Example 3 — Auth failure mid-flow.
User: "Add this track to my Liked playlist."
→ playlist add returns 401
→ route to Workflows/DiagnoseAuth.md (Symptom 2)
→ auth remove → Workflows/Setup.md step 2
→ retry original playlist addauth.json
.yt-music/
*.pyc
__pycache__/
.pytest_cache/
.DS_Store
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -ra --strict-markers --tb=short
markers =
integration: hits the real ytmusicapi or Playwright; skipped by default. Run with -m integration.
filterwarnings =
error
ignore::DeprecationWarning:http.server
yt-music
A lightweight SKILL.md skill for controlling YouTube Music via natural language.
Install
Any agent that supports SKILL.md-style skills can install this folder directly.
Paste the following into your agent
Install the yt-music skill from https://github.com/kadaliao/yt-musicRuntime Layout
yt-music/
├── SKILL.md
├── scripts/
│ ├── helper.py
│ ├── player.py
│ └── player_daemon.py
├── references/
│ └── commands.md
└── .yt-music/
├── auth.json
├── player-daemon.json
├── player-daemon.log
└── playwright-profile/By default:
scripts/helper.pystores API auth headers in./.yt-music/auth.jsonscripts/player.pyis a thin client that auto-starts or reuses a persistent playback daemonscripts/player_daemon.pyholds the long-lived Playwright browser and its dedicated profile
If needed, you can override the runtime data directory with YT_MUSIC_DATA_DIR.
Local Usage
From the skill root:
uv run --with ytmusicapi python scripts/helper.py auth check
uv run --with playwright python scripts/player.py statusAuth Setup
Cookie string:
uv run --with ytmusicapi python scripts/helper.py auth setup --cookie '<cookie string>'Cookie JSON export:
uv run --with ytmusicapi python scripts/helper.py auth setup --cookies-file /path/to/cookies.jsonVerify:
uv run --with ytmusicapi python scripts/helper.py auth checkPlayback Modes
scripts/player.py talks to a persistent Playwright daemon. Regular playback commands auto-start a dedicated browser window on first use and then reuse the same browser session.
Examples:
uv run --with playwright python scripts/player.py daemon-start
uv run --with playwright python scripts/player.py open <videoId>
uv run --with playwright python scripts/player.py status
uv run --with playwright python scripts/player.py next
uv run --with playwright python scripts/player.py daemon-status
uv run --with playwright python scripts/player.py daemon-stopNotes
uvis requiredytmusicapiis pulled on demand viauv run --with ytmusicapi ...playwrightis pulled on demand viauv run --with playwright ...- The first playback command opens a dedicated browser profile under
./.yt-music/playwright-profile - If
open <videoId>loads a track but does not start audio, autoplay was likely blocked and the user may need to click play once in the daemon-managed browser window daemon-statusreports whether the background browser daemon is alive without launching a new browser
ClawHub Notes
- Bump
versioninSKILL.mdfor every published release - Publish from the skill root directory
- ClawHub-published skills are distributed under ClawHub's platform terms
YouTube Music Command Reference
All commands below assume execution from the skill root.
Helper Commands
Run helper commands as:
uv run --with ytmusicapi python scripts/helper.py <subcommand> [args]auth
auth check
auth setup --cookie '<cookie>'
auth setup --cookies-file /path/to/cookies.json
auth account
auth removeIf auth check returns status: "missing", pause the original request and collect one of:
- a Cookie string from a logged-in
music.youtube.comrequest - a cookies JSON export file path
Mirror the user's language when replying. If the user's language is unclear, default to English.
Then run auth setup and retry the original command.
search
search "<query>"
search "<query>" --type songs
search "<query>" --type artists
search "<query>" --type albums
search "<query>" --type playlists
search "<query>" --type videos
search "<query>" --limit 20
search "<query>" --type songs --library
suggest "<prefix>"library
library liked
library playlists
library songs
library albums
library artists
library subscriptions
library history
library uploads
library songs --limit 50
library albums --order a_to_zplaylist
playlist get <playlistId>
playlist create --title "<name>"
playlist create --title "<name>" --description "<desc>" --privacy PUBLIC
playlist edit <playlistId> --title "<new name>"
playlist delete <playlistId>
playlist add <playlistId> <videoId> ...
playlist add-playlist <playlistId> --source-playlist <sourcePlaylistId>
playlist remove <playlistId> <videoId> ...
playlist rate <playlistId> --rating LIKEartist / album / song
artist <browseId>
artist-albums <browseId>
album <browseId>
song <videoId>
lyrics <videoId>
related <videoId>
watch <videoId>
watch <videoId> --limit 10account / discovery / uploads
rate <videoId> LIKE
rate <videoId> DISLIKE
rate <videoId> INDIFFERENT
subscribe subscribe <channelId> ...
subscribe unsubscribe <channelId> ...
charts
charts --country CN
moods
mood-playlist <params>
home --limit 3
history list
history remove <feedbackToken> ...
taste get
taste set --artists "<name1>" "<name2>"
upload list
upload upload --filepath /path/to/song.mp3
upload delete --entity-id <entityId>
user <channelId>Player Commands
Run player commands as:
uv run --with playwright python scripts/player.py <action> [args]The player uses a persistent Playwright daemon with a dedicated browser profile.
The first playback command auto-starts the daemon if needed:
uv run --with playwright python scripts/player.py daemon-startThen control the persistent browser session:
uv run --with playwright python scripts/player.py status
uv run --with playwright python scripts/player.py open <videoId>
uv run --with playwright python scripts/player.py next
uv run --with playwright python scripts/player.py prev
uv run --with playwright python scripts/player.py play
uv run --with playwright python scripts/player.py pause
uv run --with playwright python scripts/player.py volume <0-100>
uv run --with playwright python scripts/player.py seek <seconds>
uv run --with playwright python scripts/player.py shuffle
uv run --with playwright python scripts/player.py repeat
uv run --with playwright python scripts/player.py daemon-status
uv run --with playwright python scripts/player.py daemon-stopNotes:
- The daemon uses
./.yt-music/playwright-profileto keep its own persistent browser session - On first launch, sign in to
music.youtube.comin that browser window if needed - If
open <videoId>navigates correctly but audio does not start, autoplay was likely blocked and manual play may be required once daemon-statuschecks the daemon without opening a new browser
#!/usr/bin/env python3
"""
YouTube Music CLI Helper
Auth: <skill-root>/.yt-music/auth.json by default
Usage: uv run --with ytmusicapi python scripts/helper.py <command> [args]
"""
import argparse
import hashlib
import json
import os
import tempfile
import sys
import time
from pathlib import Path
from typing import NoReturn
def _resolve_data_dir() -> Path:
configured = os.environ.get("YT_MUSIC_DATA_DIR")
if configured:
return Path(configured).expanduser()
return Path(__file__).resolve().parent.parent / ".yt-music"
SCRIPT_PATH = Path(__file__).resolve()
DATA_DIR = _resolve_data_dir()
AUTH_FILE = DATA_DIR / "auth.json"
AUTH_HEADER_KEYS = {"Authorization", "Cookie", "X-Goog-AuthUser", "x-origin"}
def auth_setup_instructions() -> dict:
return {
"required": True,
"message": "Authentication is required before library, playlist, rating, upload, or account operations.",
"agent_prompt": (
"Authentication is required. Ask the user for either a Cookie string "
"from a logged-in music.youtube.com request or a cookies JSON export file path."
),
"language_policy": (
"Mirror the user's language when replying. If the user's language is unclear, default to concise English."
),
"reply_templates": {
"short_en": (
"You need to sign in to YouTube Music before I can continue with your "
"library, playlists, account, uploads, or full playback.\n\n"
"Please send me one of these:\n"
"1. A Cookie string\n"
"2. A cookies JSON file path"
),
"cookie_string_en": (
"Open a logged-in music.youtube.com page\n"
"Open DevTools -> Network\n"
"Filter /browse and reload the page\n"
"Open any matching request\n"
"Copy the Cookie request header value\n"
"Send the full Cookie string back to me"
),
"cookies_json_en": (
"Use a cookie export extension such as Cookie-Editor on music.youtube.com\n"
"Export cookies as JSON\n"
"Save the exported file locally\n"
"Send me the file path"
),
},
"cookie_string_steps": [
"Open https://music.youtube.com in a logged-in browser session",
"Open DevTools and go to the Network tab",
"Filter requests with /browse and reload the page",
"Open any matching request and copy the Cookie header value",
"Send that Cookie string back to the agent",
],
"cookies_json_steps": [
"Open a cookie export extension such as Cookie-Editor on music.youtube.com",
"Export cookies as JSON",
"Save the JSON file locally",
"Send the JSON file path back to the agent",
],
"setup_commands": {
"cookie_string": f"uv run --with ytmusicapi python {SCRIPT_PATH} auth setup --cookie '<cookie string>'",
"cookies_json": f"uv run --with ytmusicapi python {SCRIPT_PATH} auth setup --cookies-file /path/to/cookies.json",
},
}
def _write_json(path: Path, data) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False, dir=path.parent) as tmp:
json.dump(data, tmp, indent=2, ensure_ascii=False)
tmp.write("\n")
Path(tmp.name).replace(path)
def _sanitize_auth_headers(data: dict) -> dict:
return {key: value for key, value in data.items() if key in AUTH_HEADER_KEYS and isinstance(value, str)}
def _migrate_legacy_auth_file() -> None:
if not AUTH_FILE.exists():
return
try:
raw = json.loads(AUTH_FILE.read_text())
except Exception:
return
if not isinstance(raw, dict):
return
headers = _sanitize_auth_headers(raw)
changed = set(raw) != set(headers)
if changed:
_write_json(AUTH_FILE, headers)
# ─── Auth ────────────────────────────────────────────────────────────────────
def build_auth_from_cookie(cookie_string: str) -> dict:
"""Compute SAPISIDHASH and build the auth headers dict from a raw cookie string."""
sapisid = None
for part in cookie_string.split(";"):
part = part.strip()
if part.startswith("SAPISID="):
sapisid = part.split("=", 1)[1]
break
if not sapisid:
# Try __Secure-3PAPISID as fallback
for part in cookie_string.split(";"):
part = part.strip()
if part.startswith("__Secure-3PAPISID="):
sapisid = part.split("=", 1)[1]
break
if not sapisid:
bail("SAPISID not found in cookie string")
ts = int(time.time())
sha1 = hashlib.sha1(f"{ts} {sapisid} https://music.youtube.com".encode()).hexdigest()
return {
"Authorization": f"SAPISIDHASH {ts}_{sha1}",
"Cookie": cookie_string.strip(),
"X-Goog-AuthUser": "0",
"x-origin": "https://music.youtube.com",
}
def get_yt(require_auth=False):
from ytmusicapi import YTMusic
_migrate_legacy_auth_file()
if AUTH_FILE.exists():
return YTMusic(str(AUTH_FILE))
if require_auth:
details = {
"error": "Auth required",
"path": str(AUTH_FILE),
**auth_setup_instructions(),
}
print(json.dumps(details, ensure_ascii=False, indent=2), file=sys.stderr)
sys.exit(1)
return YTMusic() # unauthenticated — search/browse only
def bail(msg: str) -> NoReturn:
print(json.dumps({"error": msg}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
def out(data):
print(json.dumps(data, ensure_ascii=False, indent=2))
# ─── Auth commands ────────────────────────────────────────────────────────────
def cmd_auth(args):
if args.action == "check":
_migrate_legacy_auth_file()
if AUTH_FILE.exists():
out({"status": "ok", "path": str(AUTH_FILE)})
else:
out({
"status": "missing",
"path": str(AUTH_FILE),
**auth_setup_instructions(),
})
elif args.action == "setup":
if getattr(args, "cookies_file", None):
_import_cookies_json(args.cookies_file)
else:
cookie = args.cookie or sys.stdin.read().strip()
if not cookie:
bail("No cookie provided. Pass --cookie '<string>' or --cookies-file cookies.json")
headers = build_auth_from_cookie(cookie)
_write_json(AUTH_FILE, headers)
out({"status": "saved", "path": str(AUTH_FILE)})
elif args.action == "account":
yt = get_yt(require_auth=True)
out(yt.get_account_info())
elif args.action == "remove":
if AUTH_FILE.exists():
AUTH_FILE.unlink()
out({"status": "removed"})
else:
out({"status": "already_missing"})
def _import_cookies_json(path: str) -> None:
"""
Import a full cookie JSON export (array of cookie objects from a browser extension
such as Cookie-Editor or EditThisCookie) and derive API auth headers.
Stores:
- auth.json: Cookie / Authorization headers for ytmusicapi
"""
p = Path(path)
if not p.exists():
bail(f"File not found: {path}")
try:
raw = json.loads(p.read_text())
if not isinstance(raw, list):
bail("Expected a JSON array of cookie objects (e.g. exported from Cookie-Editor)")
except json.JSONDecodeError as e:
bail(f"Could not parse JSON: {e}")
cookie_parts: list = []
for c in raw:
if not isinstance(c, dict) or "name" not in c or "value" not in c:
continue
domain = str(c.get("domain", "")).strip()
if not domain or not any(d in domain for d in ["youtube.com", "google.com"]):
continue
if "youtube.com" in domain:
cookie_parts.append(f"{c['name']}={c['value']}")
if not cookie_parts:
bail("No YouTube/Google cookies found in the file")
cookie_str = "; ".join(cookie_parts)
try:
auth_data: dict = build_auth_from_cookie(cookie_str)
except SystemExit:
auth_data = {"Cookie": cookie_str, "X-Goog-AuthUser": "0",
"x-origin": "https://music.youtube.com"}
_write_json(AUTH_FILE, _sanitize_auth_headers(auth_data))
out({"status": "saved", "path": str(AUTH_FILE), "cookies_imported": len(cookie_parts)})
# ─── Search ───────────────────────────────────────────────────────────────────
def cmd_search(args):
yt = get_yt()
kwargs = dict(limit=args.limit)
if args.type:
kwargs["filter"] = args.type
if args.library:
kwargs["scope"] = "library"
results = yt.search(args.query, **kwargs)
out(results)
def cmd_suggest(args):
yt = get_yt()
out(yt.get_search_suggestions(args.query))
# ─── Library ──────────────────────────────────────────────────────────────────
def cmd_library(args):
yt = get_yt(require_auth=True)
sub = args.sub
limit = args.limit
dispatch = {
"songs": lambda: yt.get_library_songs(limit=limit, order=args.order),
"liked": lambda: yt.get_liked_songs(limit=limit),
"playlists": lambda: yt.get_library_playlists(limit=limit),
"albums": lambda: yt.get_library_albums(limit=limit, order=args.order),
"artists": lambda: yt.get_library_artists(limit=limit, order=args.order),
"subscriptions": lambda: yt.get_library_subscriptions(limit=limit),
"history": lambda: yt.get_history(),
"uploads": lambda: yt.get_library_upload_songs(limit=limit),
}
if sub in dispatch:
out(dispatch[sub]())
else:
# Overview: return counts/names of playlists
playlists = yt.get_library_playlists(limit=limit)
out({"playlists": playlists})
# ─── Playlist ─────────────────────────────────────────────────────────────────
def cmd_playlist(args):
yt = get_yt(require_auth=True)
action = args.action
if action == "get":
if not args.playlist_id:
bail("playlist_id required for 'get'")
out(yt.get_playlist(args.playlist_id, limit=args.limit))
elif action == "create":
if not args.title:
bail("--title required for 'create'")
video_ids = args.video_ids or None
pl_id = yt.create_playlist(
args.title,
args.description or "",
privacy_status=args.privacy or "PRIVATE",
video_ids=video_ids,
)
out({"playlistId": pl_id, "title": args.title, "privacy": args.privacy or "PRIVATE"})
elif action == "edit":
if not args.playlist_id:
bail("playlist_id required for 'edit'")
kwargs = {}
if args.title:
kwargs["title"] = args.title
if args.description is not None:
kwargs["description"] = args.description
if args.privacy:
kwargs["privacyStatus"] = args.privacy
result = yt.edit_playlist(args.playlist_id, **kwargs)
out({"status": result})
elif action == "delete":
if not args.playlist_id:
bail("playlist_id required for 'delete'")
result = yt.delete_playlist(args.playlist_id)
out({"status": result})
elif action == "add":
if not args.playlist_id or not args.video_ids:
bail("playlist_id and video_ids required for 'add'")
result = yt.add_playlist_items(
args.playlist_id, args.video_ids,
duplicates=args.duplicates,
)
out(result if isinstance(result, dict) else {"status": result})
elif action == "add-playlist":
# Add all songs from another playlist
if not args.playlist_id or not args.source_playlist:
bail("playlist_id and --source-playlist required for 'add-playlist'")
result = yt.add_playlist_items(
args.playlist_id,
source_playlist=args.source_playlist,
)
out(result if isinstance(result, dict) else {"status": result})
elif action == "remove":
if not args.playlist_id or not args.video_ids:
bail("playlist_id and video_ids required for 'remove'")
pl = yt.get_playlist(args.playlist_id, limit=None)
to_remove = [t for t in pl.get("tracks", []) if t.get("videoId") in args.video_ids]
if not to_remove:
out({"status": "not_found", "removed": 0})
return
result = yt.remove_playlist_items(args.playlist_id, to_remove)
out({"status": result, "removed": len(to_remove)})
elif action == "rate":
if not args.playlist_id:
bail("playlist_id required for 'rate'")
result = yt.rate_playlist(args.playlist_id, args.rating or "LIKE")
out({"status": result})
# ─── Artist ───────────────────────────────────────────────────────────────────
def cmd_artist(args):
yt = get_yt()
data = yt.get_artist(args.browse_id)
out(data)
def cmd_artist_albums(args):
yt = get_yt()
# get_artist_albums needs channelId + params from get_artist response
artist = yt.get_artist(args.browse_id)
albums_data = artist.get("albums", {})
browse_params = albums_data.get("params") if albums_data else None
if browse_params:
out(yt.get_artist_albums(args.browse_id, browse_params))
else:
out(albums_data.get("results", []))
# ─── Album ────────────────────────────────────────────────────────────────────
def cmd_album(args):
yt = get_yt()
# handle both browseId and playlistId
browse_id = args.browse_id
if browse_id.startswith("OLAK") or browse_id.startswith("PL"):
browse_id = yt.get_album_browse_id(browse_id)
out(yt.get_album(browse_id))
# ─── Song ─────────────────────────────────────────────────────────────────────
def cmd_song(args):
yt = get_yt()
out(yt.get_song(args.video_id))
def cmd_lyrics(args):
yt = get_yt()
watch = yt.get_watch_playlist(args.video_id)
lyrics_id = watch.get("lyrics")
if not lyrics_id:
out({"error": "No lyrics available for this song"})
return
out(yt.get_lyrics(lyrics_id))
def cmd_related(args):
yt = get_yt()
watch = yt.get_watch_playlist(args.video_id)
related_id = watch.get("related")
if not related_id:
out({"error": "No related songs found"})
return
out(yt.get_song_related(related_id))
def cmd_watch(args):
yt = get_yt()
kwargs = dict(limit=args.limit)
if args.playlist_id:
kwargs["playlistId"] = args.playlist_id
out(yt.get_watch_playlist(args.video_id, **kwargs))
# ─── Rating & Library Status ──────────────────────────────────────────────────
def cmd_rate(args):
yt = get_yt(require_auth=True)
result = yt.rate_song(args.video_id, args.rating)
out({"status": result, "videoId": args.video_id, "rating": args.rating})
# ─── Artist subscription ──────────────────────────────────────────────────────
def cmd_subscribe(args):
yt = get_yt(require_auth=True)
if args.action == "subscribe":
result = yt.subscribe_artists(args.channel_ids)
else:
result = yt.unsubscribe_artists(args.channel_ids)
out({"status": result})
# ─── Discover ─────────────────────────────────────────────────────────────────
def cmd_charts(args):
yt = get_yt()
out(yt.get_charts(country=args.country))
def cmd_moods(args):
yt = get_yt()
out(yt.get_mood_categories())
def cmd_mood_playlist(args):
yt = get_yt()
out(yt.get_mood_playlists(args.params))
def cmd_home(args):
yt = get_yt(require_auth=True)
out(yt.get_home(limit=args.limit))
# ─── History ──────────────────────────────────────────────────────────────────
def cmd_history(args):
yt = get_yt(require_auth=True)
if args.action == "list":
out(yt.get_history())
elif args.action == "remove":
if not args.feedback_tokens:
bail("feedback_tokens required for 'remove'")
out({"status": yt.remove_history_items(args.feedback_tokens)})
# ─── Taste Profile ────────────────────────────────────────────────────────────
def cmd_taste(args):
yt = get_yt(require_auth=True)
if args.action == "get":
out(yt.get_tasteprofile())
elif args.action == "set":
if not args.artists:
bail("--artists required for 'set'")
taste = yt.get_tasteprofile()
result = yt.set_tasteprofile(args.artists, taste)
out({"status": result})
# ─── User ─────────────────────────────────────────────────────────────────────
def cmd_user(args):
yt = get_yt()
out(yt.get_user(args.channel_id))
# ─── Uploads ──────────────────────────────────────────────────────────────────
def cmd_upload(args):
yt = get_yt(require_auth=True)
if args.action == "list":
out(yt.get_library_upload_songs(limit=args.limit))
elif args.action == "upload":
if not args.filepath:
bail("--filepath required for 'upload'")
out({"status": yt.upload_song(args.filepath)})
elif args.action == "delete":
if not args.entity_id:
bail("--entity-id required for 'delete'")
out({"status": yt.delete_upload_entity(args.entity_id)})
# ─── Parser ───────────────────────────────────────────────────────────────────
def build_parser():
p = argparse.ArgumentParser(
prog="yt-music",
description="YouTube Music CLI — wraps ytmusicapi",
)
sub = p.add_subparsers(dest="command", metavar="COMMAND")
# auth
pa = sub.add_parser("auth", help="Authentication management")
pa.add_argument("action", choices=["check", "setup", "account", "remove"])
pa.add_argument("--cookie", help="Raw Cookie header string (for setup)")
pa.add_argument("--cookies-file", dest="cookies_file",
help="JSON cookie export from a browser extension (for setup, e.g. Cookie-Editor)")
pa.set_defaults(func=cmd_auth)
# search
ps = sub.add_parser("search", help="Search YouTube Music")
ps.add_argument("query")
ps.add_argument("--type", "-t", choices=[
"songs", "artists", "albums", "playlists",
"videos", "podcasts", "episodes", "profiles",
])
ps.add_argument("--limit", "-l", type=int, default=10)
ps.add_argument("--library", action="store_true", help="Search within your library")
ps.set_defaults(func=cmd_search)
# suggest
psg = sub.add_parser("suggest", help="Search autocomplete suggestions")
psg.add_argument("query")
psg.set_defaults(func=cmd_suggest)
# library
plib = sub.add_parser("library", help="Browse your library")
plib.add_argument("sub", nargs="?", default="playlists",
choices=["songs", "liked", "playlists", "albums",
"artists", "subscriptions", "history", "uploads"])
plib.add_argument("--limit", "-l", type=int, default=25)
plib.add_argument("--order", choices=["a_to_z", "z_to_a", "recently_added"])
plib.set_defaults(func=cmd_library)
# playlist
ppl = sub.add_parser("playlist", help="Playlist management")
ppl.add_argument("action", choices=["get", "create", "edit", "delete",
"add", "add-playlist", "remove", "rate"])
ppl.add_argument("playlist_id", nargs="?")
ppl.add_argument("video_ids", nargs="*")
ppl.add_argument("--title")
ppl.add_argument("--description")
ppl.add_argument("--privacy", choices=["PUBLIC", "PRIVATE", "UNLISTED"])
ppl.add_argument("--source-playlist", dest="source_playlist")
ppl.add_argument("--rating", choices=["LIKE", "DISLIKE", "INDIFFERENT"])
ppl.add_argument("--duplicates", action="store_true")
ppl.add_argument("--limit", "-l", type=int, default=100)
ppl.set_defaults(func=cmd_playlist)
# artist
par = sub.add_parser("artist", help="Artist profile")
par.add_argument("browse_id")
par.set_defaults(func=cmd_artist)
paa = sub.add_parser("artist-albums", help="All albums by an artist")
paa.add_argument("browse_id")
paa.set_defaults(func=cmd_artist_albums)
# album
pal = sub.add_parser("album", help="Album details and tracklist")
pal.add_argument("browse_id")
pal.set_defaults(func=cmd_album)
# song
pso = sub.add_parser("song", help="Song metadata")
pso.add_argument("video_id")
pso.set_defaults(func=cmd_song)
# lyrics
ply = sub.add_parser("lyrics", help="Song lyrics")
ply.add_argument("video_id")
ply.set_defaults(func=cmd_lyrics)
# related
pre = sub.add_parser("related", help="Related songs")
pre.add_argument("video_id")
pre.set_defaults(func=cmd_related)
# watch
pwt = sub.add_parser("watch", help="'Up next' radio/recommendations")
pwt.add_argument("video_id")
pwt.add_argument("--playlist-id", dest="playlist_id")
pwt.add_argument("--limit", "-l", type=int, default=10)
pwt.set_defaults(func=cmd_watch)
# rate
prt = sub.add_parser("rate", help="Like / dislike / unlike a song")
prt.add_argument("video_id")
prt.add_argument("rating", choices=["LIKE", "DISLIKE", "INDIFFERENT"])
prt.set_defaults(func=cmd_rate)
# subscribe
psb = sub.add_parser("subscribe", help="Follow / unfollow artists")
psb.add_argument("action", choices=["subscribe", "unsubscribe"])
psb.add_argument("channel_ids", nargs="+")
psb.set_defaults(func=cmd_subscribe)
# charts
pch = sub.add_parser("charts", help="Trending charts")
pch.add_argument("--country", "-c", default="ZZ",
help="ISO country code (ZZ = global)")
pch.set_defaults(func=cmd_charts)
# moods
pmd = sub.add_parser("moods", help="Mood & genre categories")
pmd.set_defaults(func=cmd_moods)
pmdp = sub.add_parser("mood-playlist", help="Playlists for a mood/genre")
pmdp.add_argument("params", help="params value from 'moods' output")
pmdp.set_defaults(func=cmd_mood_playlist)
# home
phm = sub.add_parser("home", help="Personalised home feed")
phm.add_argument("--limit", "-l", type=int, default=3)
phm.set_defaults(func=cmd_home)
# history
phi = sub.add_parser("history", help="Listening history")
phi.add_argument("action", nargs="?", default="list", choices=["list", "remove"])
phi.add_argument("feedback_tokens", nargs="*")
phi.set_defaults(func=cmd_history)
# taste
ptaste = sub.add_parser("taste", help="Taste profile preferences")
ptaste.add_argument("action", choices=["get", "set"])
ptaste.add_argument("--artists", nargs="+")
ptaste.set_defaults(func=cmd_taste)
# user
pusr = sub.add_parser("user", help="Public user profile")
pusr.add_argument("channel_id")
pusr.set_defaults(func=cmd_user)
# upload
pup = sub.add_parser("upload", help="Personal music uploads")
pup.add_argument("action", choices=["list", "upload", "delete"])
pup.add_argument("--filepath")
pup.add_argument("--entity-id", dest="entity_id")
pup.add_argument("--limit", "-l", type=int, default=25)
pup.set_defaults(func=cmd_upload)
return p
def main():
parser = build_parser()
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(0)
args.func(args)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Persistent Playwright browser daemon for YouTube Music playback control.
"""
from __future__ import annotations
import argparse
import json
import os
import secrets
import shutil
import subprocess
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
YTM_URL = "https://music.youtube.com"
YTM_DAEMON_HOST = "127.0.0.1"
KEYS = {
"toggle": "k",
"next": "Shift+N",
"prev": "Shift+P",
}
SELECTORS = {
"app": "ytmusic-app",
"player_bar": "ytmusic-player-bar",
"shuffle": ".shuffle",
"repeat": ".repeat",
}
def _resolve_data_dir() -> Path:
configured = os.environ.get("YT_MUSIC_DATA_DIR")
if configured:
return Path(configured).expanduser()
return Path(__file__).resolve().parent.parent / ".yt-music"
DATA_DIR = _resolve_data_dir()
PROFILE_DIR = DATA_DIR / "playwright-profile"
STATE_FILE = DATA_DIR / "player-daemon.json"
def _write_json(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n")
tmp.replace(path)
def _remove_state_file(pid: int) -> None:
try:
raw = json.loads(STATE_FILE.read_text())
except Exception:
raw = None
if isinstance(raw, dict) and raw.get("pid") == pid:
try:
STATE_FILE.unlink()
except FileNotFoundError:
pass
def _candidate_paths() -> list[str]:
system = os.uname().sysname if hasattr(os, "uname") else ""
if system == "Darwin":
return [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
str(Path.home() / "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
]
if os.name == "nt":
local_app_data = os.environ.get("LOCALAPPDATA", "")
program_files = os.environ.get("PROGRAMFILES", "")
program_files_x86 = os.environ.get("PROGRAMFILES(X86)", "")
return [
str(Path(local_app_data) / "Google/Chrome/Application/chrome.exe"),
str(Path(program_files) / "Google/Chrome/Application/chrome.exe"),
str(Path(program_files_x86) / "Google/Chrome/Application/chrome.exe"),
]
return [
"google-chrome",
"google-chrome-stable",
"chromium",
"chromium-browser",
]
def _find_browser() -> str | None:
for candidate in _candidate_paths():
if Path(candidate).exists():
return candidate
resolved = shutil.which(candidate)
if resolved:
return resolved
return None
def _browser_version(browser_path: str | None) -> str | None:
if not browser_path:
return None
result = subprocess.run([browser_path, "--version"], capture_output=True, text=True, check=False)
if result.returncode != 0:
return None
return result.stdout.strip() or None
class PlayerError(RuntimeError):
pass
class YTMusicRuntime:
def __init__(self, user_data_dir: Path):
from playwright.sync_api import sync_playwright
self._lock = threading.RLock()
self.user_data_dir = user_data_dir
self.browser_path = _find_browser()
self.browser_version = _browser_version(self.browser_path)
self._pw_cm = sync_playwright()
self._pw = self._pw_cm.start()
launch_kwargs: dict[str, Any] = {
"user_data_dir": str(self.user_data_dir),
"headless": False,
"args": ["--start-maximized"],
"no_viewport": True,
}
if self.browser_path:
launch_kwargs["executable_path"] = self.browser_path
self.context = self._pw.chromium.launch_persistent_context(**launch_kwargs)
self.context.set_default_timeout(15000)
self.page = self._ensure_page()
self._ensure_ytm_loaded()
def close(self) -> None:
with self._lock:
self.context.close()
self._pw_cm.stop()
def health(self) -> dict[str, Any]:
with self._lock:
page = self._ensure_page()
player_loaded = self._has_player(page)
return {
"daemon": "running",
"mode": "playwright-persistent",
"browser_path": self.browser_path,
"browser_version": self.browser_version,
"profile_dir": str(self.user_data_dir),
"page_url": page.url,
"player_loaded": player_loaded,
"playing": self._is_playing(page) if player_loaded else False,
}
def handle(self, payload: dict[str, Any]) -> dict[str, Any]:
action = str(payload.get("action") or "").strip()
if not action:
raise PlayerError("Missing action")
with self._lock:
page = self._ensure_page()
if action == "open":
video_id = str(payload.get("video_id") or "").strip()
if not video_id:
raise PlayerError("Missing video_id")
return self._cmd_open(page, video_id)
if action in {"toggle", "play", "pause", "next", "prev"}:
return self._cmd_control(page, action)
if action == "volume":
return self._cmd_volume(page, int(payload.get("level", 0)))
if action == "seek":
return self._cmd_seek(page, float(payload.get("seconds", 0)))
if action == "shuffle":
return self._cmd_shuffle(page)
if action == "repeat":
return self._cmd_repeat(page)
if action == "status":
return self._cmd_status(page)
raise PlayerError(f"Unsupported action: {action}")
def _ensure_page(self):
if getattr(self, "page", None) and not self.page.is_closed():
return self.page
for page in self.context.pages:
if not page.is_closed() and "music.youtube.com" in page.url:
self.page = page
return page
for page in self.context.pages:
if not page.is_closed():
self.page = page
return page
self.page = self.context.new_page()
return self.page
def _ensure_ytm_loaded(self) -> None:
page = self._ensure_page()
if "music.youtube.com" not in page.url:
page.goto(YTM_URL, wait_until="domcontentloaded", timeout=20000)
try:
page.wait_for_selector(SELECTORS["app"], timeout=15000)
except Exception:
pass
try:
page.bring_to_front()
except Exception:
pass
def _wait_for_player(self, page, timeout: int = 15000) -> None:
try:
page.wait_for_selector(SELECTORS["player_bar"], timeout=timeout)
except Exception:
raise PlayerError(
"Player bar not found. Open music.youtube.com in the launched browser, sign in if needed, "
"then retry."
)
def _has_player(self, page) -> bool:
try:
return bool(page.locator(SELECTORS["player_bar"]).count())
except Exception:
return False
def _send_key(self, page, key: str) -> None:
page.focus("body")
page.keyboard.press(key)
time.sleep(0.3)
def _is_playing(self, page) -> bool:
try:
return bool(page.evaluate("""
(() => {
const v = document.querySelector('video');
return v ? !v.paused : false;
})()
"""))
except Exception:
return False
def _empty_status(self, page) -> dict[str, Any]:
return {
"title": None,
"artist": None,
"playing": False,
"position": "0:00",
"duration": "0:00",
"position_seconds": 0,
"duration_seconds": 0,
"volume": None,
"page_url": page.url,
"player_loaded": False,
}
def _get_status(self, page) -> dict[str, Any]:
try:
data = page.evaluate("""
(() => {
const video = document.querySelector('video');
const titleEl = document.querySelector('.title.ytmusic-player-bar');
const artistEl = document.querySelector('.byline-wrapper.ytmusic-player-bar a');
const title = titleEl ? titleEl.innerText.trim() : null;
const artist = artistEl ? artistEl.innerText.trim() : null;
const cur = video ? Math.floor(video.currentTime) : 0;
const dur = video ? Math.floor(video.duration) || 0 : 0;
const fmt = s => {
const m = Math.floor(s / 60);
return m + ':' + (s % 60 + '').padStart(2, '0');
};
return {
title,
artist,
playing: video ? !video.paused : false,
position: fmt(cur),
duration: fmt(dur),
position_seconds: cur,
duration_seconds: dur,
volume: video ? Math.round(video.volume * 100) : null,
};
})()
""")
except Exception as exc:
raise PlayerError(str(exc)) from exc
data["page_url"] = page.url
data["player_loaded"] = self._has_player(page)
return data
def _attempt_start_playback(self, page) -> str:
try:
started = page.evaluate("""
async () => {
const video = document.querySelector('video');
if (!video) return false;
try {
await video.play();
return !video.paused;
} catch {
return false;
}
}
""")
if started:
return "video.play()"
except Exception:
pass
for method, action in [
("play_button", lambda: page.locator("tp-yt-paper-icon-button.play-pause-button").first.click(timeout=3000)),
("toggle_key", lambda: self._send_key(page, KEYS["toggle"])),
]:
try:
action()
time.sleep(0.8)
if self._is_playing(page):
return method
except Exception:
continue
return "failed"
def _ensure_playing(self, page, timeout_ms: int = 12000) -> tuple[bool, list[str]]:
deadline = time.time() + (timeout_ms / 1000)
actions: list[str] = []
while time.time() < deadline:
if self._is_playing(page):
return True, actions
action = self._attempt_start_playback(page)
if action != "failed":
actions.append(action)
if self._is_playing(page):
return True, actions
time.sleep(1.0)
return self._is_playing(page), actions
def _cmd_open(self, page, video_id: str) -> dict[str, Any]:
url = f"{YTM_URL}/watch?v={video_id}"
page.goto(url, wait_until="domcontentloaded", timeout=20000)
self._wait_for_player(page, timeout=20000)
verified, recovery_actions = self._ensure_playing(page)
return {
"action": "open",
"url": url,
"mode": "playwright-daemon",
"playback_verified": verified,
"recovery_actions": recovery_actions,
**self._get_status(page),
}
def _cmd_control(self, page, action: str) -> dict[str, Any]:
self._ensure_ytm_loaded()
self._wait_for_player(page)
if action == "toggle":
self._send_key(page, KEYS["toggle"])
time.sleep(0.3)
elif action == "play":
if not self._is_playing(page):
self._send_key(page, KEYS["toggle"])
time.sleep(0.3)
self._ensure_playing(page, timeout_ms=5000)
elif action == "pause":
if self._is_playing(page):
self._send_key(page, KEYS["toggle"])
time.sleep(0.3)
elif action == "next":
self._send_key(page, KEYS["next"])
time.sleep(1.5)
elif action == "prev":
self._send_key(page, KEYS["prev"])
time.sleep(1.5)
return {"action": action, **self._get_status(page)}
def _cmd_volume(self, page, level: int) -> dict[str, Any]:
self._ensure_ytm_loaded()
self._wait_for_player(page)
level = max(0, min(100, level))
result = page.evaluate(
"""
(targetLevel) => {
const v = document.querySelector('video');
if (!v) return 'video_not_found';
v.volume = targetLevel / 100;
v.muted = false;
return Math.round(v.volume * 100);
}
""",
level,
)
return {"action": "volume", "level": level, "result": result, **self._get_status(page)}
def _cmd_seek(self, page, seconds: float) -> dict[str, Any]:
self._ensure_ytm_loaded()
self._wait_for_player(page)
seconds = max(0, seconds)
result = page.evaluate(
"""
(targetSeconds) => {
const v = document.querySelector('video');
if (!v) return 'video_not_found';
v.currentTime = targetSeconds;
return Math.floor(v.currentTime);
}
""",
seconds,
)
time.sleep(0.3)
return {"action": "seek", "target_seconds": seconds, "result": result, **self._get_status(page)}
def _cmd_shuffle(self, page) -> dict[str, Any]:
self._ensure_ytm_loaded()
self._wait_for_player(page)
try:
btn = page.locator(SELECTORS["shuffle"])
btn.click()
time.sleep(0.3)
active = btn.get_attribute("aria-checked") == "true"
except Exception as exc:
raise PlayerError(f"Could not find shuffle button: {exc}") from exc
return {"action": "shuffle", "shuffle_on": active, **self._get_status(page)}
def _cmd_repeat(self, page) -> dict[str, Any]:
self._ensure_ytm_loaded()
self._wait_for_player(page)
try:
btn = page.locator(SELECTORS["repeat"])
btn.click()
time.sleep(0.3)
mode = btn.get_attribute("aria-label") or "unknown"
except Exception as exc:
raise PlayerError(f"Could not find repeat button: {exc}") from exc
return {"action": "repeat", "mode": mode, **self._get_status(page)}
def _cmd_status(self, page) -> dict[str, Any]:
self._ensure_ytm_loaded()
if not self._has_player(page):
return {"action": "status", **self._empty_status(page)}
return {"action": "status", **self._get_status(page)}
class RequestHandler(BaseHTTPRequestHandler):
server: "PlayerHTTPServer"
def log_message(self, format: str, *args) -> None:
return
def _auth_ok(self) -> bool:
return self.headers.get("X-YTMUSIC-Token") == self.server.token
def _read_json(self) -> dict[str, Any]:
length = int(self.headers.get("Content-Length", "0") or "0")
raw = self.rfile.read(length) if length else b"{}"
try:
data = json.loads(raw.decode("utf-8"))
except json.JSONDecodeError as exc:
raise PlayerError(f"Invalid JSON payload: {exc}") from exc
if not isinstance(data, dict):
raise PlayerError("JSON payload must be an object")
return data
def _send(self, status: int, data: dict[str, Any]) -> None:
body = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None:
if not self._auth_ok():
self._send(403, {"error": "Forbidden"})
return
if self.path != "/health":
self._send(404, {"error": "Not found"})
return
self._send(200, self.server.runtime.health())
def do_POST(self) -> None:
if not self._auth_ok():
self._send(403, {"error": "Forbidden"})
return
try:
payload = self._read_json()
if self.path == "/command":
response = self.server.runtime.handle(payload)
self._send(200, response)
return
if self.path == "/shutdown":
self._send(200, {"action": "daemon-stop", "daemon": "stopping"})
threading.Thread(target=self.server.shutdown, daemon=True).start()
return
self._send(404, {"error": "Not found"})
except PlayerError as exc:
self._send(400, {"error": str(exc)})
except Exception as exc:
self._send(500, {"error": f"Unhandled daemon error: {exc}"})
class PlayerHTTPServer(ThreadingHTTPServer):
daemon_threads = True
def __init__(self, server_address, runtime: YTMusicRuntime, token: str):
super().__init__(server_address, RequestHandler)
self.runtime = runtime
self.token = token
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="yt-music-player-daemon",
description="Run the persistent Playwright daemon for YouTube Music playback control",
)
parser.add_argument("--host", default=YTM_DAEMON_HOST)
parser.add_argument("--port", type=int, default=0)
parser.add_argument("--user-data-dir", type=Path, default=PROFILE_DIR)
return parser
def main() -> None:
args = build_parser().parse_args()
args.user_data_dir = args.user_data_dir.expanduser().resolve()
args.user_data_dir.mkdir(parents=True, exist_ok=True)
DATA_DIR.mkdir(parents=True, exist_ok=True)
token = secrets.token_hex(16)
runtime = YTMusicRuntime(args.user_data_dir)
server = PlayerHTTPServer((args.host, args.port), runtime=runtime, token=token)
state = {
"pid": os.getpid(),
"host": args.host,
"port": server.server_address[1],
"token": token,
"profile_dir": str(args.user_data_dir),
"started_at": int(time.time()),
"mode": "playwright-persistent",
}
_write_json(STATE_FILE, state)
try:
server.serve_forever(poll_interval=0.5)
finally:
try:
server.server_close()
finally:
runtime.close()
_remove_state_file(os.getpid())
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
YouTube Music playback client backed by a persistent Playwright daemon.
Regular playback commands auto-start the daemon when needed.
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, NoReturn
YTM_DAEMON_HOST = "127.0.0.1"
YTM_DAEMON_START_TIMEOUT = 25.0
def _resolve_data_dir() -> Path:
configured = os.environ.get("YT_MUSIC_DATA_DIR")
if configured:
return Path(configured).expanduser()
return Path(__file__).resolve().parent.parent / ".yt-music"
SCRIPT_DIR = Path(__file__).resolve().parent
DATA_DIR = _resolve_data_dir()
STATE_FILE = DATA_DIR / "player-daemon.json"
LOG_FILE = DATA_DIR / "player-daemon.log"
DAEMON_SCRIPT = SCRIPT_DIR / "player_daemon.py"
def bail(msg: str) -> NoReturn:
print(json.dumps({"error": msg}, ensure_ascii=False))
sys.exit(1)
def out(data: dict[str, Any]) -> None:
print(json.dumps(data, ensure_ascii=False, indent=2))
def _load_state() -> dict[str, Any] | None:
if not STATE_FILE.exists():
return None
try:
raw = json.loads(STATE_FILE.read_text())
except Exception:
return None
return raw if isinstance(raw, dict) else None
def _clear_state() -> None:
try:
STATE_FILE.unlink()
except FileNotFoundError:
pass
def _request(
state: dict[str, Any],
path: str,
payload: dict[str, Any] | None = None,
timeout: float = 5.0,
) -> dict[str, Any]:
url = f"http://{YTM_DAEMON_HOST}:{state['port']}{path}"
body = None if payload is None else json.dumps(payload).encode("utf-8")
headers = {
"Accept": "application/json",
"X-YTMUSIC-Token": str(state.get("token", "")),
}
if body is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=body, headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode("utf-8"))
if not isinstance(data, dict):
raise RuntimeError("Daemon returned a non-object JSON payload")
return data
def _probe(state: dict[str, Any]) -> dict[str, Any] | None:
try:
return _request(state, "/health", timeout=2.0)
except (OSError, urllib.error.URLError, urllib.error.HTTPError, RuntimeError, json.JSONDecodeError):
return None
def _wait_for_daemon(deadline: float) -> dict[str, Any]:
while time.time() < deadline:
state = _load_state()
if state:
health = _probe(state)
if health:
return {"state": state, "health": health}
time.sleep(0.4)
raise RuntimeError(
"Timed out waiting for the playback daemon to start. Check .yt-music/player-daemon.log for details."
)
def _start_daemon() -> dict[str, Any]:
DATA_DIR.mkdir(parents=True, exist_ok=True)
if not DAEMON_SCRIPT.exists():
raise RuntimeError(f"Daemon script not found: {DAEMON_SCRIPT}")
with LOG_FILE.open("ab") as log_file:
subprocess.Popen(
[sys.executable, str(DAEMON_SCRIPT)],
cwd=str(SCRIPT_DIR),
stdin=subprocess.DEVNULL,
stdout=log_file,
stderr=subprocess.STDOUT,
start_new_session=True,
env=os.environ.copy(),
)
return _wait_for_daemon(time.time() + YTM_DAEMON_START_TIMEOUT)
def _ensure_daemon() -> dict[str, Any]:
state = _load_state()
if state:
health = _probe(state)
if health:
return {"state": state, "health": health}
_clear_state()
return _start_daemon()
def _cmd_remote(args: argparse.Namespace) -> None:
daemon = _ensure_daemon()
payload: dict[str, Any] = {"action": args.action}
if hasattr(args, "video_id"):
payload["video_id"] = args.video_id
if hasattr(args, "level"):
payload["level"] = args.level
if hasattr(args, "seconds"):
payload["seconds"] = args.seconds
try:
response = _request(daemon["state"], "/command", payload=payload, timeout=20.0)
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
bail(detail or f"Daemon request failed: HTTP {exc.code}")
except (OSError, urllib.error.URLError, RuntimeError, json.JSONDecodeError) as exc:
bail(f"Could not reach the playback daemon: {exc}")
out(response)
def _cmd_daemon_start(args: argparse.Namespace) -> None:
daemon = _ensure_daemon()
out({"action": "daemon-start", **daemon["health"]})
def _cmd_daemon_status(args: argparse.Namespace) -> None:
state = _load_state()
if not state:
out({"action": "daemon-status", "daemon": "stopped"})
return
health = _probe(state)
if not health:
_clear_state()
out({"action": "daemon-status", "daemon": "stopped"})
return
out({"action": "daemon-status", **health})
def _cmd_daemon_stop(args: argparse.Namespace) -> None:
state = _load_state()
if not state:
out({"action": "daemon-stop", "daemon": "stopped"})
return
try:
response = _request(state, "/shutdown", payload={"action": "daemon-stop"}, timeout=5.0)
except (OSError, urllib.error.URLError, urllib.error.HTTPError, RuntimeError, json.JSONDecodeError):
_clear_state()
out({"action": "daemon-stop", "daemon": "stopped"})
return
deadline = time.time() + 5.0
while time.time() < deadline:
if not STATE_FILE.exists():
break
time.sleep(0.2)
_clear_state()
out(response)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="yt-music-player",
description="Control YouTube Music playback via a persistent Playwright browser daemon",
)
parser.add_argument("--chrome-port", help=argparse.SUPPRESS)
sub = parser.add_subparsers(dest="action", metavar="ACTION")
open_cmd = sub.add_parser("open", help="Open and play a song by videoId")
open_cmd.add_argument("video_id")
open_cmd.set_defaults(func=_cmd_remote)
for name, help_text in [
("toggle", "Toggle play/pause"),
("play", "Resume playback"),
("pause", "Pause playback"),
("next", "Skip to next track"),
("prev", "Go to previous track"),
("status", "Show current playback status"),
("shuffle", "Toggle shuffle mode"),
("repeat", "Cycle repeat mode"),
]:
cmd = sub.add_parser(name, help=help_text)
cmd.set_defaults(func=_cmd_remote)
volume_cmd = sub.add_parser("volume", help="Set volume (0-100)")
volume_cmd.add_argument("level", type=int)
volume_cmd.set_defaults(func=_cmd_remote)
seek_cmd = sub.add_parser("seek", help="Seek to position in seconds")
seek_cmd.add_argument("seconds", type=float)
seek_cmd.set_defaults(func=_cmd_remote)
daemon_start = sub.add_parser("daemon-start", help="Start the persistent playback daemon")
daemon_start.set_defaults(func=_cmd_daemon_start)
daemon_status = sub.add_parser("daemon-status", help="Show daemon status without auto-starting it")
daemon_status.set_defaults(func=_cmd_daemon_status)
daemon_stop = sub.add_parser("daemon-stop", help="Stop the persistent playback daemon")
daemon_stop.set_defaults(func=_cmd_daemon_stop)
return parser
def main() -> None:
parser = build_parser()
args = parser.parse_args()
if not args.action:
parser.print_help()
sys.exit(0)
args.func(args)
if __name__ == "__main__":
main()
"""
Shared pytest fixtures for the yt-music skill test suite.
Goals:
- Never touch the user's real ~/.yt-music/ data dir — every test gets a temp dir
via the YT_MUSIC_DATA_DIR env var.
- Never import the real `ytmusicapi` or `playwright` packages at test time —
fake modules are installed in sys.modules before the scripts are imported.
- Each test gets a freshly-reloaded `helper` / `player` / `player_daemon` module
so module-level constants (DATA_DIR, AUTH_FILE, STATE_FILE) reflect the per-test env.
"""
from __future__ import annotations
import importlib
import sys
import types
from pathlib import Path
from unittest.mock import MagicMock
import pytest
SKILL_ROOT = Path(__file__).resolve().parent.parent
SCRIPTS_DIR = SKILL_ROOT / "scripts"
# Ensure scripts/ is importable as a top-level package path.
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
def _purge(*names: str) -> None:
for name in names:
sys.modules.pop(name, None)
@pytest.fixture
def data_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Per-test data dir; the scripts honor YT_MUSIC_DATA_DIR at import time."""
target = tmp_path / "yt-music-data"
target.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("YT_MUSIC_DATA_DIR", str(target))
return target
@pytest.fixture
def fake_yt_instance() -> MagicMock:
"""A MagicMock standing in for a YTMusic client. Tests assert on its calls."""
return MagicMock(name="YTMusicInstance")
@pytest.fixture
def fake_ytmusicapi(monkeypatch: pytest.MonkeyPatch, fake_yt_instance: MagicMock):
"""
Install a fake `ytmusicapi` module in sys.modules.
`helper.get_yt()` does a lazy `from ytmusicapi import YTMusic` and instantiates
it with either an auth file path or no args. We capture both call shapes and
always return `fake_yt_instance` so test assertions can inspect method calls.
"""
fake_module = types.ModuleType("ytmusicapi")
yt_factory = MagicMock(name="YTMusicFactory", return_value=fake_yt_instance)
fake_module.YTMusic = yt_factory # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "ytmusicapi", fake_module)
yield yt_factory
sys.modules.pop("ytmusicapi", None)
@pytest.fixture
def helper_mod(data_dir: Path, fake_ytmusicapi: MagicMock):
"""
Fresh import of `helper.py` with the temp data dir env var set and a fake
ytmusicapi already in sys.modules. Returns the module.
"""
_purge("helper")
module = importlib.import_module("helper")
assert Path(module.DATA_DIR) == data_dir, (
f"helper.DATA_DIR ({module.DATA_DIR}) did not pick up YT_MUSIC_DATA_DIR ({data_dir})"
)
yield module
_purge("helper")
@pytest.fixture
def player_mod(data_dir: Path):
"""Fresh import of `player.py` with the temp data dir env var set."""
_purge("player")
module = importlib.import_module("player")
assert Path(module.DATA_DIR) == data_dir
yield module
_purge("player")
@pytest.fixture
def fake_playwright(monkeypatch: pytest.MonkeyPatch) -> types.ModuleType:
"""
Install fake `playwright` + `playwright.sync_api` modules so that importing
`player_daemon` never reaches the real Playwright. The fake `sync_playwright`
is a no-op context manager; tests that instantiate YTMusicRuntime should
additionally patch its internals — most unit tests only touch pure helpers
that never reach Playwright at all.
"""
pkg = types.ModuleType("playwright")
sync_api = types.ModuleType("playwright.sync_api")
sync_api.sync_playwright = MagicMock(name="sync_playwright") # type: ignore[attr-defined]
pkg.sync_api = sync_api # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "playwright", pkg)
monkeypatch.setitem(sys.modules, "playwright.sync_api", sync_api)
yield sync_api
sys.modules.pop("playwright", None)
sys.modules.pop("playwright.sync_api", None)
@pytest.fixture
def player_daemon_mod(data_dir: Path, fake_playwright: types.ModuleType):
"""Fresh import of `player_daemon.py` for pure-helper tests."""
_purge("player_daemon")
module = importlib.import_module("player_daemon")
assert Path(module.DATA_DIR) == data_dir
yield module
_purge("player_daemon")
def run_cli(module, *argv: str, monkeypatch: pytest.MonkeyPatch):
"""Invoke a script's `main()` with patched sys.argv, returning the SystemExit code."""
monkeypatch.setattr(sys, "argv", [getattr(module, "__name__", "script"), *argv])
try:
module.main()
return 0
except SystemExit as exc: # bail() and parser exits
code = exc.code
if code is None:
return 0
if isinstance(code, int):
return code
return 1
"""Tests for the auth subsystem inside helper.py."""
from __future__ import annotations
import hashlib
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from .conftest import run_cli
# ─── build_auth_from_cookie ──────────────────────────────────────────────────
def test_build_auth_from_cookie_with_sapisid(helper_mod):
"""Happy path: full Cookie header with SAPISID yields all four required keys."""
cookie = "Foo=1; SAPISID=mysapisidvalue; Bar=2"
auth = helper_mod.build_auth_from_cookie(cookie)
assert set(auth.keys()) == {"Authorization", "Cookie", "X-Goog-AuthUser", "x-origin"}
assert auth["Cookie"] == cookie
assert auth["x-origin"] == "https://music.youtube.com"
assert auth["X-Goog-AuthUser"] == "0"
# SAPISIDHASH format: "SAPISIDHASH <ts>_<sha1hex>"
assert auth["Authorization"].startswith("SAPISIDHASH ")
ts_hash = auth["Authorization"].split(" ", 1)[1]
ts_str, sha = ts_hash.split("_", 1)
expected = hashlib.sha1(
f"{ts_str} mysapisidvalue https://music.youtube.com".encode()
).hexdigest()
assert sha == expected
def test_build_auth_from_cookie_falls_back_to_3papisid(helper_mod):
"""When SAPISID is missing, __Secure-3PAPISID must be accepted as fallback."""
cookie = "__Secure-3PAPISID=alt_value; SID=abc"
auth = helper_mod.build_auth_from_cookie(cookie)
assert auth["Cookie"] == cookie
assert auth["Authorization"].startswith("SAPISIDHASH ")
def test_build_auth_from_cookie_missing_sapisid_exits(helper_mod, capsys):
"""No SAPISID and no __Secure-3PAPISID → bail with structured error JSON."""
with pytest.raises(SystemExit) as ei:
helper_mod.build_auth_from_cookie("Foo=1; Bar=2")
assert ei.value.code == 1
err = capsys.readouterr().err
body = json.loads(err)
assert "SAPISID" in body["error"]
# ─── auth_setup_instructions ─────────────────────────────────────────────────
def test_auth_setup_instructions_shape(helper_mod):
"""The bundled guidance must include all the artifacts the agent flow depends on."""
info = helper_mod.auth_setup_instructions()
assert info["required"] is True
assert "agent_prompt" in info
assert "language_policy" in info
# English fallbacks
for key in ("short_en", "cookie_string_en", "cookies_json_en"):
assert key in info["reply_templates"]
# Step lists
assert isinstance(info["cookie_string_steps"], list) and info["cookie_string_steps"]
assert isinstance(info["cookies_json_steps"], list) and info["cookies_json_steps"]
# Concrete setup commands
for key in ("cookie_string", "cookies_json"):
assert key in info["setup_commands"]
# ─── _sanitize_auth_headers ──────────────────────────────────────────────────
def test_sanitize_auth_headers_keeps_only_allowed(helper_mod):
raw = {
"Authorization": "SAPISIDHASH 1_a",
"Cookie": "x=1",
"X-Goog-AuthUser": "0",
"x-origin": "https://music.youtube.com",
"garbage": "drop me",
"another": 123, # non-str also dropped
}
sanitized = helper_mod._sanitize_auth_headers(raw)
assert sanitized == {
"Authorization": "SAPISIDHASH 1_a",
"Cookie": "x=1",
"X-Goog-AuthUser": "0",
"x-origin": "https://music.youtube.com",
}
# ─── _migrate_legacy_auth_file ───────────────────────────────────────────────
def test_migrate_legacy_auth_file_strips_extra_keys(helper_mod, data_dir):
legacy = {
"Authorization": "SAPISIDHASH 1_a",
"Cookie": "x=1",
"X-Goog-AuthUser": "0",
"x-origin": "https://music.youtube.com",
"old_field": "remove me",
}
helper_mod.AUTH_FILE.write_text(json.dumps(legacy))
helper_mod._migrate_legacy_auth_file()
after = json.loads(helper_mod.AUTH_FILE.read_text())
assert "old_field" not in after
assert after["Authorization"] == "SAPISIDHASH 1_a"
def test_migrate_legacy_auth_file_noop_when_missing(helper_mod):
"""No file → nothing to do, no exception."""
helper_mod._migrate_legacy_auth_file()
assert not helper_mod.AUTH_FILE.exists()
def test_migrate_legacy_auth_file_ignores_invalid_json(helper_mod):
helper_mod.AUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
helper_mod.AUTH_FILE.write_text("not-valid-json{")
helper_mod._migrate_legacy_auth_file()
# File unchanged because we can't parse it.
assert helper_mod.AUTH_FILE.read_text() == "not-valid-json{"
# ─── cmd_auth check ──────────────────────────────────────────────────────────
def test_cmd_auth_check_missing(helper_mod, capsys, monkeypatch):
code = run_cli(helper_mod, "auth", "check", monkeypatch=monkeypatch)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body["status"] == "missing"
# Hands the agent the full guidance payload.
assert body["required"] is True
assert "agent_prompt" in body
def test_cmd_auth_check_ok(helper_mod, capsys, monkeypatch):
helper_mod.AUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
helper_mod.AUTH_FILE.write_text(json.dumps({
"Authorization": "x", "Cookie": "y", "X-Goog-AuthUser": "0", "x-origin": "z",
}))
code = run_cli(helper_mod, "auth", "check", monkeypatch=monkeypatch)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body == {"status": "ok", "path": str(helper_mod.AUTH_FILE)}
# ─── cmd_auth setup --cookie ─────────────────────────────────────────────────
def test_cmd_auth_setup_cookie_string_persists(helper_mod, capsys, monkeypatch):
code = run_cli(
helper_mod, "auth", "setup", "--cookie", "SAPISID=mysapisidvalue; Foo=1",
monkeypatch=monkeypatch,
)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body == {"status": "saved", "path": str(helper_mod.AUTH_FILE)}
persisted = json.loads(helper_mod.AUTH_FILE.read_text())
assert persisted["Cookie"] == "SAPISID=mysapisidvalue; Foo=1"
assert persisted["Authorization"].startswith("SAPISIDHASH ")
def test_cmd_auth_setup_empty_cookie_fails(helper_mod, capsys, monkeypatch):
monkeypatch.setattr(sys, "stdin", _make_stdin(""))
code = run_cli(helper_mod, "auth", "setup", monkeypatch=monkeypatch)
assert code == 1
err = capsys.readouterr().err
assert "No cookie provided" in err
# ─── cmd_auth setup --cookies-file ───────────────────────────────────────────
def test_cmd_auth_setup_cookies_file_happy_path(helper_mod, tmp_path: Path, capsys, monkeypatch):
cookies_json = tmp_path / "cookies.json"
cookies_json.write_text(json.dumps([
{"name": "SAPISID", "value": "mysapisidvalue", "domain": ".youtube.com"},
{"name": "SID", "value": "abc", "domain": ".youtube.com"},
{"name": "junk", "value": "no", "domain": "bing.com"}, # filtered
]))
code = run_cli(
helper_mod, "auth", "setup", "--cookies-file", str(cookies_json),
monkeypatch=monkeypatch,
)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body["status"] == "saved"
assert body["cookies_imported"] == 2
persisted = json.loads(helper_mod.AUTH_FILE.read_text())
assert "SAPISID=mysapisidvalue" in persisted["Cookie"]
assert "SID=abc" in persisted["Cookie"]
assert "junk=no" not in persisted["Cookie"]
def test_cmd_auth_setup_cookies_file_missing(helper_mod, capsys, monkeypatch):
code = run_cli(
helper_mod, "auth", "setup", "--cookies-file", "/no/such/file.json",
monkeypatch=monkeypatch,
)
assert code == 1
assert "File not found" in capsys.readouterr().err
def test_cmd_auth_setup_cookies_file_not_a_list(helper_mod, tmp_path, capsys, monkeypatch):
bad = tmp_path / "bad.json"
bad.write_text(json.dumps({"not": "an array"}))
code = run_cli(
helper_mod, "auth", "setup", "--cookies-file", str(bad),
monkeypatch=monkeypatch,
)
assert code == 1
assert "JSON array" in capsys.readouterr().err
def test_cmd_auth_setup_cookies_file_invalid_json(helper_mod, tmp_path, capsys, monkeypatch):
bad = tmp_path / "bad.json"
bad.write_text("{not json")
code = run_cli(
helper_mod, "auth", "setup", "--cookies-file", str(bad),
monkeypatch=monkeypatch,
)
assert code == 1
assert "Could not parse JSON" in capsys.readouterr().err
def test_cmd_auth_setup_cookies_file_no_matching_cookies(helper_mod, tmp_path, capsys, monkeypatch):
empty = tmp_path / "empty.json"
empty.write_text(json.dumps([
{"name": "x", "value": "1", "domain": "bing.com"},
]))
code = run_cli(
helper_mod, "auth", "setup", "--cookies-file", str(empty),
monkeypatch=monkeypatch,
)
assert code == 1
assert "No YouTube/Google cookies" in capsys.readouterr().err
def test_cmd_auth_setup_cookies_file_falls_back_when_no_sapisid(helper_mod, tmp_path, capsys, monkeypatch):
"""If neither SAPISID nor __Secure-3PAPISID is in the export, persist the headers
we _do_ have (Cookie / X-Goog-AuthUser / x-origin) but skip Authorization."""
cookies_json = tmp_path / "cookies.json"
cookies_json.write_text(json.dumps([
{"name": "SID", "value": "abc", "domain": ".youtube.com"},
]))
code = run_cli(
helper_mod, "auth", "setup", "--cookies-file", str(cookies_json),
monkeypatch=monkeypatch,
)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body["status"] == "saved"
persisted = json.loads(helper_mod.AUTH_FILE.read_text())
assert "Authorization" not in persisted # no SAPISID → no SAPISIDHASH
assert persisted["Cookie"] == "SID=abc"
assert persisted["x-origin"] == "https://music.youtube.com"
# ─── cmd_auth remove ─────────────────────────────────────────────────────────
def test_cmd_auth_remove_existing(helper_mod, capsys, monkeypatch):
helper_mod.AUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
helper_mod.AUTH_FILE.write_text("{}")
code = run_cli(helper_mod, "auth", "remove", monkeypatch=monkeypatch)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body == {"status": "removed"}
assert not helper_mod.AUTH_FILE.exists()
def test_cmd_auth_remove_when_absent(helper_mod, capsys, monkeypatch):
code = run_cli(helper_mod, "auth", "remove", monkeypatch=monkeypatch)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body == {"status": "already_missing"}
# ─── cmd_auth account ────────────────────────────────────────────────────────
def test_cmd_auth_account_requires_auth_file(helper_mod, capsys, monkeypatch):
"""Without auth.json, the require_auth path exits 1 with structured guidance."""
code = run_cli(helper_mod, "auth", "account", monkeypatch=monkeypatch)
assert code == 1
err = json.loads(capsys.readouterr().err)
assert err["error"] == "Auth required"
assert "agent_prompt" in err
def test_cmd_auth_account_returns_account_info(
helper_mod, fake_yt_instance, capsys, monkeypatch,
):
helper_mod.AUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
helper_mod.AUTH_FILE.write_text(json.dumps({
"Authorization": "x", "Cookie": "y", "X-Goog-AuthUser": "0", "x-origin": "z",
}))
fake_yt_instance.get_account_info.return_value = {"accountName": "Demo User"}
code = run_cli(helper_mod, "auth", "account", monkeypatch=monkeypatch)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body == {"accountName": "Demo User"}
fake_yt_instance.get_account_info.assert_called_once_with()
# ─── helpers ─────────────────────────────────────────────────────────────────
class _StdinShim:
def __init__(self, text: str) -> None:
self._text = text
def read(self) -> str:
return self._text
def _make_stdin(text: str) -> _StdinShim:
return _StdinShim(text)
"""Tests for the library command's dispatch table."""
from __future__ import annotations
import json
import pytest
from .conftest import run_cli
@pytest.fixture
def authed(helper_mod):
"""All library calls require auth — write a stub auth.json once."""
helper_mod.AUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
helper_mod.AUTH_FILE.write_text(json.dumps({
"Authorization": "x", "Cookie": "y", "X-Goog-AuthUser": "0", "x-origin": "z",
}))
return helper_mod
def test_library_requires_auth(helper_mod, capsys, monkeypatch):
code = run_cli(helper_mod, "library", "playlists", monkeypatch=monkeypatch)
assert code == 1
err = json.loads(capsys.readouterr().err)
assert err["error"] == "Auth required"
def test_library_songs_passes_limit_and_order(authed, fake_yt_instance, capsys, monkeypatch):
fake_yt_instance.get_library_songs.return_value = [{"title": "Track"}]
code = run_cli(
authed, "library", "songs", "--limit", "5", "--order", "a_to_z",
monkeypatch=monkeypatch,
)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body == [{"title": "Track"}]
fake_yt_instance.get_library_songs.assert_called_once_with(limit=5, order="a_to_z")
def test_library_liked(authed, fake_yt_instance, monkeypatch):
fake_yt_instance.get_liked_songs.return_value = {"tracks": []}
code = run_cli(authed, "library", "liked", monkeypatch=monkeypatch)
assert code == 0
fake_yt_instance.get_liked_songs.assert_called_once_with(limit=25)
def test_library_playlists_default(authed, fake_yt_instance, monkeypatch):
fake_yt_instance.get_library_playlists.return_value = []
code = run_cli(authed, "library", "playlists", monkeypatch=monkeypatch)
assert code == 0
fake_yt_instance.get_library_playlists.assert_called_once_with(limit=25)
def test_library_default_subcommand_returns_playlists_overview(authed, fake_yt_instance, capsys, monkeypatch):
"""`library` with no sub uses the default `playlists` choice and wraps in overview."""
fake_yt_instance.get_library_playlists.return_value = [{"title": "Pl1"}]
code = run_cli(authed, "library", monkeypatch=monkeypatch)
assert code == 0
body = json.loads(capsys.readouterr().out)
# Default `sub` is "playlists", which hits dispatch (not overview branch).
assert body == [{"title": "Pl1"}]
def test_library_albums(authed, fake_yt_instance, monkeypatch):
fake_yt_instance.get_library_albums.return_value = []
code = run_cli(authed, "library", "albums", "--order", "recently_added", monkeypatch=monkeypatch)
assert code == 0
fake_yt_instance.get_library_albums.assert_called_once_with(limit=25, order="recently_added")
def test_library_artists(authed, fake_yt_instance, monkeypatch):
fake_yt_instance.get_library_artists.return_value = []
code = run_cli(authed, "library", "artists", monkeypatch=monkeypatch)
assert code == 0
fake_yt_instance.get_library_artists.assert_called_once_with(limit=25, order=None)
def test_library_subscriptions(authed, fake_yt_instance, monkeypatch):
fake_yt_instance.get_library_subscriptions.return_value = []
code = run_cli(authed, "library", "subscriptions", monkeypatch=monkeypatch)
assert code == 0
fake_yt_instance.get_library_subscriptions.assert_called_once_with(limit=25)
def test_library_history(authed, fake_yt_instance, monkeypatch):
fake_yt_instance.get_history.return_value = []
code = run_cli(authed, "library", "history", monkeypatch=monkeypatch)
assert code == 0
fake_yt_instance.get_history.assert_called_once_with()
def test_library_uploads(authed, fake_yt_instance, monkeypatch):
fake_yt_instance.get_library_upload_songs.return_value = []
code = run_cli(authed, "library", "uploads", "--limit", "7", monkeypatch=monkeypatch)
assert code == 0
fake_yt_instance.get_library_upload_songs.assert_called_once_with(limit=7)
"""Tests for the long-tail subcommands of helper.py."""
from __future__ import annotations
import json
import pytest
from .conftest import run_cli
@pytest.fixture
def authed(helper_mod):
helper_mod.AUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
helper_mod.AUTH_FILE.write_text(json.dumps({
"Authorization": "x", "Cookie": "y", "X-Goog-AuthUser": "0", "x-origin": "z",
}))
return helper_mod
# ─── artist / album / song ───────────────────────────────────────────────────
def test_artist(helper_mod, fake_yt_instance, capsys, monkeypatch):
fake_yt_instance.get_artist.return_value = {"name": "Queen"}
code = run_cli(helper_mod, "artist", "UC123", monkeypatch=monkeypatch)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body == {"name": "Queen"}
fake_yt_instance.get_artist.assert_called_once_with("UC123")
def test_artist_albums_with_params(helper_mod, fake_yt_instance, capsys, monkeypatch):
fake_yt_instance.get_artist.return_value = {
"albums": {"params": "ABC", "results": ["ignored"]},
}
fake_yt_instance.get_artist_albums.return_value = [{"title": "News of the World"}]
code = run_cli(helper_mod, "artist-albums", "UC123", monkeypatch=monkeypatch)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body == [{"title": "News of the World"}]
fake_yt_instance.get_artist_albums.assert_called_once_with("UC123", "ABC")
def test_artist_albums_falls_back_to_inline_results(helper_mod, fake_yt_instance, capsys, monkeypatch):
fake_yt_instance.get_artist.return_value = {
"albums": {"results": [{"title": "A Day at the Races"}]}, # no params
}
code = run_cli(helper_mod, "artist-albums", "UC123", monkeypatch=monkeypatch)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body == [{"title": "A Day at the Races"}]
fake_yt_instance.get_artist_albums.assert_not_called()
def test_album_with_browse_id(helper_mod, fake_yt_instance, monkeypatch):
fake_yt_instance.get_album.return_value = {"title": "News"}
code = run_cli(helper_mod, "album", "MPREb_xxx", monkeypatch=monkeypatch)
assert code == 0
fake_yt_instance.get_album.assert_called_once_with("MPREb_xxx")
fake_yt_instance.get_album_browse_id.assert_not_called()
def test_album_converts_olak_id(helper_mod, fake_yt_instance, monkeypatch):
fake_yt_instance.get_album_browse_id.return_value = "MPREb_converted"
fake_yt_instance.get_album.return_value = {}
code = run_cli(helper_mod, "album", "OLAK5uy_abc", monkeypatch=monkeypatch)
assert code == 0
fake_yt_instance.get_album_browse_id.assert_called_once_with("OLAK5uy_abc")
fake_yt_instance.get_album.assert_called_once_with("MPREb_converted")
def test_album_converts_pl_id(helper_mod, fake_yt_instance, monkeypatch):
fake_yt_instance.get_album_browse_id.return_value = "MPREb_x"
fake_yt_instance.get_album.return_value = {}
code = run_cli(helper_mod, "album", "PL_abc", monkeypatch=monkeypatch)
assert code == 0
fake_yt_instance.get_album_browse_id.assert_called_once_with("PL_abc")
def test_song(helper_mod, fake_yt_instance, monkeypatch):
fake_yt_instance.get_song.return_value = {"videoId": "v1"}
code = run_cli(helper_mod, "song", "v1", monkeypatch=monkeypatch)
assert code == 0
fake_yt_instance.get_song.assert_called_once_with("v1")
# ─── lyrics / related / watch ────────────────────────────────────────────────
def test_lyrics_success(helper_mod, fake_yt_instance, capsys, monkeypatch):
fake_yt_instance.get_watch_playlist.return_value = {"lyrics": "lyrics_id_1"}
fake_yt_instance.get_lyrics.return_value = {"lyrics": "ohh, can you feel..."}
code = run_cli(helper_mod, "lyrics", "v1", monkeypatch=monkeypatch)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body == {"lyrics": "ohh, can you feel..."}
fake_yt_instance.get_watch_playlist.assert_called_once_with("v1")
fake_yt_instance.get_lyrics.assert_called_once_with("lyrics_id_1")
def test_lyrics_unavailable(helper_mod, fake_yt_instance, capsys, monkeypatch):
fake_yt_instance.get_watch_playlist.return_value = {"lyrics": None}
code = run_cli(helper_mod, "lyrics", "v1", monkeypatch=monkeypatch)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body == {"error": "No lyrics available for this song"}
fake_yt_instance.get_lyrics.assert_not_called()
def test_related_success(helper_mod, fake_yt_instance, monkeypatch):
fake_yt_instance.get_watch_playlist.return_value = {"related": "rel_id"}
fake_yt_instance.get_song_related.return_value = [{"title": "Other"}]
code = run_cli(helper_mod, "related", "v1", monkeypatch=monkeypatch)
assert code == 0
fake_yt_instance.get_song_related.assert_called_once_with("rel_id")
def test_related_empty(helper_mod, fake_yt_instance, capsys, monkeypatch):
fake_yt_instance.get_watch_playlist.return_value = {"related": None}
code = run_cli(helper_mod, "related", "v1", monkeypatch=monkeypatch)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body == {"error": "No related songs found"}
def test_watch_with_playlist_id_and_limit(helper_mod, fake_yt_instance, monkeypatch):
fake_yt_instance.get_watch_playlist.return_value = {"tracks": []}
code = run_cli(
helper_mod, "watch", "v1", "--playlist-id", "PL123", "--limit", "5",
monkeypatch=monkeypatch,
)
assert code == 0
fake_yt_instance.get_watch_playlist.assert_called_once_with(
"v1", limit=5, playlistId="PL123",
)
# ─── rate ────────────────────────────────────────────────────────────────────
def test_rate_requires_auth(helper_mod, capsys, monkeypatch):
code = run_cli(helper_mod, "rate", "v1", "LIKE", monkeypatch=monkeypatch)
assert code == 1
err = json.loads(capsys.readouterr().err)
assert err["error"] == "Auth required"
def test_rate(authed, fake_yt_instance, capsys, monkeypatch):
fake_yt_instance.rate_song.return_value = "STATUS_SUCCEEDED"
code = run_cli(authed, "rate", "v1", "DISLIKE", monkeypatch=monkeypatch)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body == {"status": "STATUS_SUCCEEDED", "videoId": "v1", "rating": "DISLIKE"}
fake_yt_instance.rate_song.assert_called_once_with("v1", "DISLIKE")
# ─── subscribe ───────────────────────────────────────────────────────────────
def test_subscribe(authed, fake_yt_instance, monkeypatch):
fake_yt_instance.subscribe_artists.return_value = "OK"
code = run_cli(authed, "subscribe", "subscribe", "ch1", "ch2", monkeypatch=monkeypatch)
assert code == 0
fake_yt_instance.subscribe_artists.assert_called_once_with(["ch1", "ch2"])
fake_yt_instance.unsubscribe_artists.assert_not_called()
def test_unsubscribe(authed, fake_yt_instance, monkeypatch):
fake_yt_instance.unsubscribe_artists.return_value = "OK"
code = run_cli(authed, "subscribe", "unsubscribe", "ch1", monkeypatch=monkeypatch)
assert code == 0
fake_yt_instance.unsubscribe_artists.assert_called_once_with(["ch1"])
# ─── charts / moods / mood-playlist / home ───────────────────────────────────
def test_charts_default_country(helper_mod, fake_yt_instance, monkeypatch):
fake_yt_instance.get_charts.return_value = {"videos": []}
code = run_cli(helper_mod, "charts", monkeypatch=monkeypatch)
assert code == 0
fake_yt_instance.get_charts.assert_called_once_with(country="ZZ")
def test_charts_explicit_country(helper_mod, fake_yt_instance, monkeypatch):
fake_yt_instance.get_charts.return_value = {}
code = run_cli(helper_mod, "charts", "--country", "BR", monkeypatch=monkeypatch)
assert code == 0
fake_yt_instance.get_charts.assert_called_once_with(country="BR")
def test_moods(helper_mod, fake_yt_instance, monkeypatch):
fake_yt_instance.get_mood_categories.return_value = {}
code = run_cli(helper_mod, "moods", monkeypatch=monkeypatch)
assert code == 0
fake_yt_instance.get_mood_categories.assert_called_once_with()
def test_mood_playlist(helper_mod, fake_yt_instance, monkeypatch):
fake_yt_instance.get_mood_playlists.return_value = []
code = run_cli(helper_mod, "mood-playlist", "ABCDEF", monkeypatch=monkeypatch)
assert code == 0
fake_yt_instance.get_mood_playlists.assert_called_once_with("ABCDEF")
def test_home_requires_auth(helper_mod, capsys, monkeypatch):
code = run_cli(helper_mod, "home", monkeypatch=monkeypatch)
assert code == 1
def test_home(authed, fake_yt_instance, monkeypatch):
fake_yt_instance.get_home.return_value = []
code = run_cli(authed, "home", "--limit", "10", monkeypatch=monkeypatch)
assert code == 0
fake_yt_instance.get_home.assert_called_once_with(limit=10)
# ─── history / taste ─────────────────────────────────────────────────────────
def test_history_list(authed, fake_yt_instance, monkeypatch):
fake_yt_instance.get_history.return_value = []
code = run_cli(authed, "history", monkeypatch=monkeypatch)
assert code == 0
fake_yt_instance.get_history.assert_called_once_with()
def test_history_remove(authed, fake_yt_instance, capsys, monkeypatch):
fake_yt_instance.remove_history_items.return_value = "OK"
code = run_cli(authed, "history", "remove", "tok1", "tok2", monkeypatch=monkeypatch)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body == {"status": "OK"}
fake_yt_instance.remove_history_items.assert_called_once_with(["tok1", "tok2"])
def test_history_remove_requires_tokens(authed, capsys, monkeypatch):
code = run_cli(authed, "history", "remove", monkeypatch=monkeypatch)
assert code == 1
assert "feedback_tokens required" in capsys.readouterr().err
def test_taste_get(authed, fake_yt_instance, monkeypatch):
fake_yt_instance.get_tasteprofile.return_value = {}
code = run_cli(authed, "taste", "get", monkeypatch=monkeypatch)
assert code == 0
fake_yt_instance.get_tasteprofile.assert_called_once_with()
def test_taste_set(authed, fake_yt_instance, capsys, monkeypatch):
fake_yt_instance.get_tasteprofile.return_value = {"some": "profile"}
fake_yt_instance.set_tasteprofile.return_value = "OK"
code = run_cli(
authed, "taste", "set", "--artists", "Queen", "Pink Floyd",
monkeypatch=monkeypatch,
)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body == {"status": "OK"}
fake_yt_instance.set_tasteprofile.assert_called_once_with(
["Queen", "Pink Floyd"], {"some": "profile"},
)
def test_taste_set_requires_artists(authed, capsys, monkeypatch):
code = run_cli(authed, "taste", "set", monkeypatch=monkeypatch)
assert code == 1
assert "artists required" in capsys.readouterr().err
# ─── user / upload ───────────────────────────────────────────────────────────
def test_user(helper_mod, fake_yt_instance, monkeypatch):
fake_yt_instance.get_user.return_value = {"name": "x"}
code = run_cli(helper_mod, "user", "UC123", monkeypatch=monkeypatch)
assert code == 0
fake_yt_instance.get_user.assert_called_once_with("UC123")
def test_upload_list(authed, fake_yt_instance, monkeypatch):
fake_yt_instance.get_library_upload_songs.return_value = []
code = run_cli(authed, "upload", "list", "--limit", "9", monkeypatch=monkeypatch)
assert code == 0
fake_yt_instance.get_library_upload_songs.assert_called_once_with(limit=9)
def test_upload_upload(authed, fake_yt_instance, capsys, monkeypatch):
fake_yt_instance.upload_song.return_value = "DONE"
code = run_cli(
authed, "upload", "upload", "--filepath", "/tmp/song.mp3",
monkeypatch=monkeypatch,
)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body == {"status": "DONE"}
fake_yt_instance.upload_song.assert_called_once_with("/tmp/song.mp3")
def test_upload_upload_requires_filepath(authed, capsys, monkeypatch):
code = run_cli(authed, "upload", "upload", monkeypatch=monkeypatch)
assert code == 1
assert "filepath required" in capsys.readouterr().err
def test_upload_delete(authed, fake_yt_instance, monkeypatch):
fake_yt_instance.delete_upload_entity.return_value = "OK"
code = run_cli(
authed, "upload", "delete", "--entity-id", "abc",
monkeypatch=monkeypatch,
)
assert code == 0
fake_yt_instance.delete_upload_entity.assert_called_once_with("abc")
def test_upload_delete_requires_entity(authed, capsys, monkeypatch):
code = run_cli(authed, "upload", "delete", monkeypatch=monkeypatch)
assert code == 1
assert "entity-id required" in capsys.readouterr().err
"""Tests for argparse wiring in helper.build_parser."""
from __future__ import annotations
import json
import sys
import pytest
from .conftest import run_cli
def test_help_with_no_command_exits_zero(helper_mod, capsys, monkeypatch):
code = run_cli(helper_mod, monkeypatch=monkeypatch)
assert code == 0
out = capsys.readouterr().out
assert "yt-music" in out
assert "search" in out
def test_unknown_command_argparse_exits_two(helper_mod, capsys, monkeypatch):
code = run_cli(helper_mod, "no-such-command", monkeypatch=monkeypatch)
# argparse exits with status 2 on invalid arguments
assert code == 2
def test_search_unknown_filter_rejected_by_choices(helper_mod, capsys, monkeypatch):
code = run_cli(helper_mod, "search", "q", "--type", "garbage", monkeypatch=monkeypatch)
assert code == 2
def test_data_dir_env_override(helper_mod, data_dir):
"""Confirm the env-var contract: DATA_DIR / AUTH_FILE follow YT_MUSIC_DATA_DIR."""
assert helper_mod.DATA_DIR == data_dir
assert helper_mod.AUTH_FILE == data_dir / "auth.json"
def test_resolve_data_dir_default(monkeypatch, tmp_path):
"""When YT_MUSIC_DATA_DIR is unset, _resolve_data_dir falls back to <skill-root>/.yt-music/."""
monkeypatch.delenv("YT_MUSIC_DATA_DIR", raising=False)
# Re-import helper without the fixture so module-level constants reflect default.
import importlib
sys.modules.pop("helper", None)
helper = importlib.import_module("helper")
try:
# The default is <scripts dir>/../.yt-music/
assert helper.DATA_DIR.name == ".yt-music"
assert helper.DATA_DIR.parent.name == "yt-music"
finally:
sys.modules.pop("helper", None)
"""Tests for the playlist subcommand."""
from __future__ import annotations
import json
import pytest
from .conftest import run_cli
@pytest.fixture
def authed(helper_mod):
helper_mod.AUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
helper_mod.AUTH_FILE.write_text(json.dumps({
"Authorization": "x", "Cookie": "y", "X-Goog-AuthUser": "0", "x-origin": "z",
}))
return helper_mod
def test_playlist_get(authed, fake_yt_instance, capsys, monkeypatch):
fake_yt_instance.get_playlist.return_value = {"title": "Mix", "tracks": []}
code = run_cli(authed, "playlist", "get", "PL123", monkeypatch=monkeypatch)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body == {"title": "Mix", "tracks": []}
fake_yt_instance.get_playlist.assert_called_once_with("PL123", limit=100)
def test_playlist_get_requires_id(authed, capsys, monkeypatch):
code = run_cli(authed, "playlist", "get", monkeypatch=monkeypatch)
assert code == 1
assert "playlist_id required" in capsys.readouterr().err
def test_playlist_create_minimal(authed, fake_yt_instance, capsys, monkeypatch):
fake_yt_instance.create_playlist.return_value = "PLNEW"
code = run_cli(authed, "playlist", "create", "--title", "Vibes", monkeypatch=monkeypatch)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body == {"playlistId": "PLNEW", "title": "Vibes", "privacy": "PRIVATE"}
fake_yt_instance.create_playlist.assert_called_once_with(
"Vibes", "", privacy_status="PRIVATE", video_ids=None,
)
def test_playlist_create_full(authed, fake_yt_instance, monkeypatch):
fake_yt_instance.create_playlist.return_value = "PLNEW"
code = run_cli(
authed, "playlist", "create",
"--title", "Run Mix", "--description", "fast", "--privacy", "PUBLIC",
monkeypatch=monkeypatch,
)
assert code == 0
fake_yt_instance.create_playlist.assert_called_once_with(
"Run Mix", "fast", privacy_status="PUBLIC", video_ids=None,
)
def test_playlist_create_requires_title(authed, capsys, monkeypatch):
code = run_cli(authed, "playlist", "create", monkeypatch=monkeypatch)
assert code == 1
assert "title required" in capsys.readouterr().err
def test_playlist_edit(authed, fake_yt_instance, capsys, monkeypatch):
fake_yt_instance.edit_playlist.return_value = "STATUS_SUCCEEDED"
code = run_cli(
authed, "playlist", "edit", "PL123",
"--title", "New", "--description", "desc", "--privacy", "UNLISTED",
monkeypatch=monkeypatch,
)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body == {"status": "STATUS_SUCCEEDED"}
fake_yt_instance.edit_playlist.assert_called_once_with(
"PL123", title="New", description="desc", privacyStatus="UNLISTED",
)
def test_playlist_edit_requires_id(authed, capsys, monkeypatch):
code = run_cli(authed, "playlist", "edit", monkeypatch=monkeypatch)
assert code == 1
assert "playlist_id required" in capsys.readouterr().err
def test_playlist_delete(authed, fake_yt_instance, capsys, monkeypatch):
fake_yt_instance.delete_playlist.return_value = "OK"
code = run_cli(authed, "playlist", "delete", "PL123", monkeypatch=monkeypatch)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body == {"status": "OK"}
fake_yt_instance.delete_playlist.assert_called_once_with("PL123")
def test_playlist_add(authed, fake_yt_instance, capsys, monkeypatch):
fake_yt_instance.add_playlist_items.return_value = {"status": "STATUS_SUCCEEDED"}
code = run_cli(
authed, "playlist", "add", "PL123", "vid1", "vid2",
monkeypatch=monkeypatch,
)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body == {"status": "STATUS_SUCCEEDED"}
fake_yt_instance.add_playlist_items.assert_called_once_with(
"PL123", ["vid1", "vid2"], duplicates=False,
)
def test_playlist_add_duplicates_flag(authed, fake_yt_instance, monkeypatch):
fake_yt_instance.add_playlist_items.return_value = {}
code = run_cli(
authed, "playlist", "add", "PL123", "vid1", "--duplicates",
monkeypatch=monkeypatch,
)
assert code == 0
fake_yt_instance.add_playlist_items.assert_called_once_with(
"PL123", ["vid1"], duplicates=True,
)
def test_playlist_add_requires_ids(authed, capsys, monkeypatch):
code = run_cli(authed, "playlist", "add", "PL123", monkeypatch=monkeypatch)
assert code == 1
assert "video_ids required" in capsys.readouterr().err
def test_playlist_add_playlist(authed, fake_yt_instance, monkeypatch):
fake_yt_instance.add_playlist_items.return_value = {"ok": True}
code = run_cli(
authed, "playlist", "add-playlist", "PLDEST",
"--source-playlist", "PLSRC",
monkeypatch=monkeypatch,
)
assert code == 0
fake_yt_instance.add_playlist_items.assert_called_once_with(
"PLDEST", source_playlist="PLSRC",
)
def test_playlist_add_playlist_requires_source(authed, capsys, monkeypatch):
code = run_cli(authed, "playlist", "add-playlist", "PLDEST", monkeypatch=monkeypatch)
assert code == 1
assert "source-playlist required" in capsys.readouterr().err
def test_playlist_remove_matches_filter(authed, fake_yt_instance, capsys, monkeypatch):
fake_yt_instance.get_playlist.return_value = {
"tracks": [
{"videoId": "vid1", "title": "a"},
{"videoId": "vidZ", "title": "z"},
],
}
fake_yt_instance.remove_playlist_items.return_value = "STATUS_SUCCEEDED"
code = run_cli(authed, "playlist", "remove", "PL123", "vid1", monkeypatch=monkeypatch)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body == {"status": "STATUS_SUCCEEDED", "removed": 1}
fake_yt_instance.remove_playlist_items.assert_called_once_with(
"PL123", [{"videoId": "vid1", "title": "a"}],
)
def test_playlist_remove_no_match(authed, fake_yt_instance, capsys, monkeypatch):
fake_yt_instance.get_playlist.return_value = {"tracks": [{"videoId": "vidA"}]}
code = run_cli(authed, "playlist", "remove", "PL123", "vidZ", monkeypatch=monkeypatch)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body == {"status": "not_found", "removed": 0}
fake_yt_instance.remove_playlist_items.assert_not_called()
def test_playlist_rate(authed, fake_yt_instance, capsys, monkeypatch):
fake_yt_instance.rate_playlist.return_value = "OK"
code = run_cli(
authed, "playlist", "rate", "PL123", "--rating", "LIKE",
monkeypatch=monkeypatch,
)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body == {"status": "OK"}
fake_yt_instance.rate_playlist.assert_called_once_with("PL123", "LIKE")
def test_playlist_rate_defaults_to_like(authed, fake_yt_instance, monkeypatch):
fake_yt_instance.rate_playlist.return_value = "OK"
code = run_cli(authed, "playlist", "rate", "PL123", monkeypatch=monkeypatch)
assert code == 0
fake_yt_instance.rate_playlist.assert_called_once_with("PL123", "LIKE")
"""Tests for search + suggest commands."""
from __future__ import annotations
import json
from .conftest import run_cli
def test_search_minimal_query(helper_mod, fake_yt_instance, capsys, monkeypatch):
fake_yt_instance.search.return_value = [{"title": "Bohemian Rhapsody"}]
code = run_cli(helper_mod, "search", "Bohemian Rhapsody", monkeypatch=monkeypatch)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body == [{"title": "Bohemian Rhapsody"}]
fake_yt_instance.search.assert_called_once_with("Bohemian Rhapsody", limit=10)
def test_search_with_filter_and_limit(helper_mod, fake_yt_instance, capsys, monkeypatch):
fake_yt_instance.search.return_value = []
code = run_cli(
helper_mod, "search", "queen", "--type", "songs", "--limit", "25",
monkeypatch=monkeypatch,
)
assert code == 0
fake_yt_instance.search.assert_called_once_with("queen", limit=25, filter="songs")
def test_search_in_library_passes_scope(helper_mod, fake_yt_instance, capsys, monkeypatch):
fake_yt_instance.search.return_value = []
code = run_cli(
helper_mod, "search", "queen", "--type", "songs", "--library",
monkeypatch=monkeypatch,
)
assert code == 0
fake_yt_instance.search.assert_called_once_with(
"queen", limit=10, filter="songs", scope="library",
)
def test_search_accepts_all_documented_filters(helper_mod, fake_yt_instance, monkeypatch):
fake_yt_instance.search.return_value = []
for t in ("songs", "artists", "albums", "playlists", "videos",
"podcasts", "episodes", "profiles"):
code = run_cli(helper_mod, "search", "x", "--type", t, monkeypatch=monkeypatch)
assert code == 0
def test_suggest(helper_mod, fake_yt_instance, capsys, monkeypatch):
fake_yt_instance.get_search_suggestions.return_value = ["queen", "queens"]
code = run_cli(helper_mod, "suggest", "que", monkeypatch=monkeypatch)
assert code == 0
body = json.loads(capsys.readouterr().out)
assert body == ["queen", "queens"]
fake_yt_instance.get_search_suggestions.assert_called_once_with("que")
"""Tests for the pure (Playwright-free) helpers inside player_daemon.py."""
from __future__ import annotations
import json
import os
import subprocess
from pathlib import Path
from unittest.mock import MagicMock
import pytest
# ─── _write_json ─────────────────────────────────────────────────────────────
def test_write_json_atomic(player_daemon_mod, tmp_path):
target = tmp_path / "subdir" / "state.json"
player_daemon_mod._write_json(target, {"a": 1, "b": [2, 3]})
assert target.exists()
assert json.loads(target.read_text()) == {"a": 1, "b": [2, 3]}
# No stray .tmp left behind
leftovers = list(target.parent.glob("*.tmp"))
assert leftovers == []
def test_write_json_overwrites_existing(player_daemon_mod, tmp_path):
target = tmp_path / "state.json"
target.write_text("stale")
player_daemon_mod._write_json(target, {"fresh": True})
assert json.loads(target.read_text()) == {"fresh": True}
# ─── _remove_state_file ──────────────────────────────────────────────────────
def test_remove_state_file_matching_pid(player_daemon_mod):
state = {"pid": 4242, "port": 1}
player_daemon_mod.STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
player_daemon_mod.STATE_FILE.write_text(json.dumps(state))
player_daemon_mod._remove_state_file(4242)
assert not player_daemon_mod.STATE_FILE.exists()
def test_remove_state_file_pid_mismatch(player_daemon_mod):
"""Another daemon owns the file — don't delete it."""
state = {"pid": 7777, "port": 1}
player_daemon_mod.STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
player_daemon_mod.STATE_FILE.write_text(json.dumps(state))
player_daemon_mod._remove_state_file(4242)
assert player_daemon_mod.STATE_FILE.exists()
def test_remove_state_file_when_unreadable(player_daemon_mod):
"""Malformed state file → swallow the error, leave file alone."""
player_daemon_mod.STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
player_daemon_mod.STATE_FILE.write_text("{not json")
player_daemon_mod._remove_state_file(4242)
assert player_daemon_mod.STATE_FILE.exists()
def test_remove_state_file_when_missing(player_daemon_mod):
player_daemon_mod._remove_state_file(4242) # must not raise
# ─── _candidate_paths ────────────────────────────────────────────────────────
def test_candidate_paths_returns_non_empty(player_daemon_mod):
paths = player_daemon_mod._candidate_paths()
assert isinstance(paths, list)
assert len(paths) >= 1
# ─── _find_browser ───────────────────────────────────────────────────────────
def test_find_browser_returns_existing_path(player_daemon_mod, tmp_path, monkeypatch):
fake_bin = tmp_path / "chrome"
fake_bin.write_text("#!/bin/sh\necho chrome\n")
fake_bin.chmod(0o755)
monkeypatch.setattr(player_daemon_mod, "_candidate_paths", lambda: [str(fake_bin)])
monkeypatch.setattr(player_daemon_mod.shutil, "which", lambda _x: None)
assert player_daemon_mod._find_browser() == str(fake_bin)
def test_find_browser_falls_back_to_which(player_daemon_mod, monkeypatch):
monkeypatch.setattr(player_daemon_mod, "_candidate_paths", lambda: ["nonexistent-binary"])
monkeypatch.setattr(player_daemon_mod.shutil, "which",
lambda name: "/resolved/bin/chrome" if name == "nonexistent-binary" else None)
assert player_daemon_mod._find_browser() == "/resolved/bin/chrome"
def test_find_browser_returns_none(player_daemon_mod, monkeypatch):
monkeypatch.setattr(player_daemon_mod, "_candidate_paths", lambda: ["nonexistent-binary"])
monkeypatch.setattr(player_daemon_mod.shutil, "which", lambda _x: None)
assert player_daemon_mod._find_browser() is None
# ─── _browser_version ────────────────────────────────────────────────────────
def test_browser_version_parses_stdout(player_daemon_mod, monkeypatch):
completed = MagicMock(
returncode=0,
stdout="Google Chrome 124.0.6367.78 \n",
)
monkeypatch.setattr(player_daemon_mod.subprocess, "run", lambda *a, **k: completed)
assert player_daemon_mod._browser_version("/path/to/chrome") == "Google Chrome 124.0.6367.78"
def test_browser_version_no_path(player_daemon_mod):
assert player_daemon_mod._browser_version(None) is None
def test_browser_version_failed_invocation(player_daemon_mod, monkeypatch):
completed = MagicMock(returncode=1, stdout="")
monkeypatch.setattr(player_daemon_mod.subprocess, "run", lambda *a, **k: completed)
assert player_daemon_mod._browser_version("/path/to/chrome") is None
def test_browser_version_empty_stdout(player_daemon_mod, monkeypatch):
completed = MagicMock(returncode=0, stdout="")
monkeypatch.setattr(player_daemon_mod.subprocess, "run", lambda *a, **k: completed)
assert player_daemon_mod._browser_version("/path/to/chrome") is None
# ─── constants ───────────────────────────────────────────────────────────────
def test_keybindings_present(player_daemon_mod):
keys = player_daemon_mod.KEYS
assert keys["toggle"] == "k"
# Next/prev use Shift+N / Shift+P per YouTube Music's keyboard shortcuts.
assert "Shift" in keys["next"]
assert "Shift" in keys["prev"]
def test_selectors_present(player_daemon_mod):
sels = player_daemon_mod.SELECTORS
assert sels["app"] == "ytmusic-app"
assert sels["player_bar"] == "ytmusic-player-bar"
assert "shuffle" in sels
assert "repeat" in sels
def test_ytm_url(player_daemon_mod):
assert player_daemon_mod.YTM_URL == "https://music.youtube.com"
# ─── build_parser ────────────────────────────────────────────────────────────
def test_parser_defaults(player_daemon_mod):
parser = player_daemon_mod.build_parser()
args = parser.parse_args([])
assert args.host == "127.0.0.1"
assert args.port == 0 # 0 → OS-assigned
assert isinstance(args.user_data_dir, Path)
def test_parser_overrides(player_daemon_mod, tmp_path):
parser = player_daemon_mod.build_parser()
args = parser.parse_args([
"--host", "0.0.0.0",
"--port", "1234",
"--user-data-dir", str(tmp_path),
])
assert args.host == "0.0.0.0"
assert args.port == 1234
assert args.user_data_dir == tmp_path
BuildPlaylist — Compose a Playlist from Search, Artist, or Charts
Author a fresh playlist by composing playlist create with search, artist-albums, or charts instead of bouncing the user through raw videoId lists.
When to use
- User asks to "make a playlist of <theme>".
- User wants a per-artist deep cut playlist.
- User wants a country/global charts snapshot saved as a playlist.
Preconditions
- Auth required for every step except
search,artist-albums, andcharts. RunWorkflows/Setup.mdfirst ifauth checkismissing.
Variants
This workflow has three composition modes — pick one with the user.
Mode A: From a free-text theme
1. Search for candidate tracks:
uv run --with ytmusicapi python scripts/helper.py search "<theme>" --type songs --limit 252. Curate down to the tracks the user wants (or auto-pick the top N if the user says "just pick"). 3. Create the playlist:
uv run --with ytmusicapi python scripts/helper.py playlist create \
--title "<theme>" \
--description "Auto-built from search '<theme>' on $(date +%Y-%m-%d)" \
--privacy PRIVATE4. Add the curated tracks:
uv run --with ytmusicapi python scripts/helper.py playlist add <playlistId> <videoId...>Mode B: From an artist's discography
1. Resolve the artist's browseId:
uv run --with ytmusicapi python scripts/helper.py search "<artist name>" --type artists --limit 32. Pull their albums:
uv run --with ytmusicapi python scripts/helper.py artist-albums <browseId>3. For each album the user wants, fetch its tracks:
uv run --with ytmusicapi python scripts/helper.py album <albumBrowseId>4. Create + add as in Mode A, step 3–4.
Mode C: From the current charts
1. Pull charts (default global; pass --country US|BR|KR|JP|… for regional):
uv run --with ytmusicapi python scripts/helper.py charts --country BR2. Extract the top-N videoIds from the videos or songs section of the JSON. 3. Create + add as in Mode A, step 3–4.
Mass-add from an existing playlist
If the user has a similar playlist already and wants to clone or merge:
uv run --with ytmusicapi python scripts/helper.py playlist add-playlist \
<destinationPlaylistId> --source-playlist <sourcePlaylistId>This pulls every track from the source playlist into the destination without manually enumerating videoIds.
Privacy choice
Default to PRIVATE unless the user asks for PUBLIC or UNLISTED. Confirm before publishing anything PUBLIC.
Output template
📃 Created playlist: {{title}}
🔒 Privacy: {{privacy}}
🎵 Tracks added: {{n}}
🔗 playlistId: {{playlistId}}Failure modes
| Symptom | Likely cause | Remediation |
|---|---|---|
playlist add returns status with actions length 0 | The video is already in the playlist. | Re-run with --duplicates only if the user explicitly wants duplicates. |
add-playlist fails on source_playlist | Source is private/restricted. | Confirm the user owns or has access to the source playlist. |
Artist browseId not resolving | Search returned a profile, not an artist. | Use --type artists explicitly. |
Next suggestions
Workflows/PlayDiscover.md— play one track from the new playlist and explore related.- Pass the new
playlistIdtoplaylist rate <id> --rating LIKEif the user wants it in their library faves.