SLOPSHOPPER

meowzik

A music player beside the chat, after the Meowzik Discord bot: YouTube, SoundCloud, Spotify and Suno, a queue, history and favorites with no limits, loop…

newpanebandguardcommandstatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · meowzik
│ ┃ Meowzik ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ Meowzik Meowzik │ ┃ Nothing is playing right now. ⏺ Read(src/auth.ts) │ ┃ Add a song below, or ask Claude t ⎿ Read 6 lines │ ┃ something. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ [ ⏮︎ ] [ ▶︎ ] [ ⏭︎ ] [ ⏹︎ ] [ ⇄︎ ] [ ↻︎ ] [ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ▣ client module ./slider.tsx │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Add song: Song name, link or playlist ⏎ add │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ [ Queue (0) ] [ History (0) ] [ Favorites (0 │ ┃ › /meowzik │ ┃ The queue is empty. ⎿ meowzik: Meowzik is open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Meowzik
▣ Meowzik Meowzik Nothing is playing right now. Add a song below, or ask Claude to play something [ ⏮︎ ] [ ▶︎ ] [ ⏭︎ ] [ ⏹︎ ] [ ⇄︎ ] [ ↻︎ ] [ ∞︎ ] [ ☆︎ ] ▣ client module ./slider.tsx Add song: Song name, link or playlist ⏎ add [ Queue (0) ] [ History (0) ] [ Favorites (0) ] [ Lyrics ] [ The queue is empty.
README

claude-toolkit

A general-use toolkit of Claude Code plugins by jahirtrap. A plugin can bring a mod (a pane next to the chat, a line above the prompt), an MCP server, skills, commands or agents, or several of them together; each one installs on its own. This repository is also their marketplace: add it once, then install only the plugins you want.

[Claude Code] ──marketplace──> [a claude-toolkit plugin: mod, MCP server, skill, command]

Every plugin draws on each surface Claude Code has (the terminal, the desktop app, VS Code, the phone app) as far as that surface allows, and says so where it can't.

Plugins

PluginIncludesWhat it doesCommand
chessmodPlay chess against Claude in a pane, any time, without taking a chat turn./chess
meowzikmodA music player beside the chat: YouTube, SoundCloud, Spotify and Suno, played by you or by Claude./meowzik

chess

chess puts a game against Claude next to the chat. You play White, Claude plays Black, and the game never takes a turn of the conversation, so you can play while Claude works on something else or waits for you.

  • Click to play: click a piece => its squares light up (a dot on the empty ones) => click one. The arrows and Enter work too.
  • Claude thinks: it gets the board drawn as text, both piece lists and the legal moves, looks at your threats and at its king's escape squares, then answers with its move and a short remark (a few seconds, Sonnet at low effort).
  • Grows with the pane: resize the pane and the board follows, squares kept square and pieces scaled up.
  • English and Spanish: pick the language in the pane and Claude talks to you in it too.
  • Your game is kept: close the pane or Claude Code and carry on later; New game, Undo, and Ask Claude to move when a reply goes wrong.
  • Every surface: a clickable board in the terminal and the desktop app, a move field in VS Code (e4, Nf3, O-O or e2e4), the board alone in the phone app.

The rules are complete (castling, en passant, promotion, check, mate, stalemate, the fifty-move rule and bare kings) and checked with perft against the reference counts.

See plugins/chess for the code and its tests.

meowzik

meowzik is the Meowzik Discord bot as a Claude Code mod: the same player, the same buttons, in a pane next to the chat, and without Discord's limits (no cap on the queue, the history, the favorites or a playlist).

  • Any source: a song name (searched on YouTube, or SoundCloud if you pick it), a YouTube or SoundCloud link, a whole YouTube playlist or SoundCloud set, a Spotify track, album or playlist (each song is looked up when it plays), or a Suno song.
  • Player controls: the bot's buttons as a player's, with icons (⏮ ▶ ⏭ ⏹, then shuffle, loop for the queue or the track, autoplay and favorite, lit while on), a thick progress bar you drag to seek and a volume slider you drag or move with the arrows.
  • Mini player: a one-row player above the prompt (back, play or pause, skip, stop, the song and its progress bar); the song opens the pane. Show it while music plays, always or never, and keep or hide the song in the status line, in /config.
  • Lyrics: a Lyrics tab with the song's lyrics from LRCLIB, synced lines lit as they are sung; press a line to jump to it. When LRCLIB has none, a YouTube song uses the video's own subtitles, then its automatic captions.
  • Autoplay: when the queue runs out it follows YouTube's mix or SoundCloud's recommendations for what you were listening to, skipping what already played in the session.
  • Lists without limits: Queue, History and Favorites in tabs, page by page; press a song to play it now, queue it, move it, remove it or favorite it. They are kept in ~/.claude/meowzik/lists, so they survive the session.
  • Claude plays too: ask for music and Claude uses the mod's tools (play, control, status, list), so "play some Daft Punk", "skip this" or "what is playing?" just work.
  • Installs what is missing: the first song brings mpv, the standalone yt-dlp (it carries the scripts that solve YouTube's challenges) and, when there is no deno, Node.js or Bun to run them, deno; all into ~/.claude/meowzik/bin (on macOS mpv and deno come from Homebrew when it is there; on Linux it shows the command for mpv). Nothing else on your system changes.
  • YouTube's bot check: when YouTube asks to confirm you are not a bot, point the mod at a cookies file (or a browser) in /config; like the bot it works on a copy and keeps the session YouTube rotates. Or switch the search source to SoundCloud, which needs no login.
  • Every surface: the cover, the draggable progress and volume sliders and the field to add songs in the terminal and the desktop app; the field with seek and volume buttons in VS Code; the player, the lists and Claude's tools in the phone app.

Settings (in /config, under meowzik): the mini player, the song in the status line, the search source, the YouTube cookies file or browser, extra yt-dlp arguments, and the mpv and yt-dlp paths if you want your own.

See plugins/meowzik for the code and its tests.

Structure

claude-toolkit/
├── .claude-plugin/
│   └── marketplace.json   # the catalog: one entry per plugin
├── plugins/
│   ├── chess/             # chess against Claude in a pane (mod)
│   └── meowzik/           # a music player beside the chat (mod)
├── scripts/
│   └── check.sh           # validate the marketplace, then validate and test every plugin
└── .github/workflows/     # CI runs scripts/check.sh

Each plugin keeps Claude Code's own layout inside its folder (hooks/ for a mod, .mcp.json for an MCP server, skills/, commands/, agents/), so a plugin that mixes kinds stays in one place.

Install

One plugin, in one step (Claude Code 2.1.275 or later):

/plugin install chess --marketplace jahirxtrap/claude-toolkit

Or add the marketplace once and install the plugins you want:

claude plugin marketplace add jahirxtrap/claude-toolkit
claude plugin install chess@claude-toolkit

From inside Claude Code it is /plugin marketplace add jahirxtrap/claude-toolkit and /plugin install chess@claude-toolkit, then /reload-plugins. To get new versions later, claude plugin marketplace update claude-toolkit.

To try a plugin without installing it:

git clone https://github.com/jahirxtrap/claude-toolkit && cd claude-toolkit
claude --plugin-dir plugins/chess

Built and tested on Claude Code 2.1.306.

Develop

bash scripts/check.sh   # the marketplace validates, the catalog matches plugins/, every plugin validates and passes its tests

Adding a plugin is one folder under plugins/ plus its entry in marketplace.json; CLAUDE.md has the steps and the rules.

License

MIT (see LICENSE).

Source 22 files
hooks/register.tsx 108 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { placesOf, settingsOf } from './config'
4import type { Session } from './config'
5import { SEEK_KEY_SECONDS, VOLUME_KEY_STEP, VOLUME_MAX } from './layout'
6import { addSongs, changeVolume, restore, seekBy, seekTo, setVolume } from './playback'
7import { checkTools, factsOf } from './setup'
8import { get, put, tell } from './state'
9import { problemText, say } from './text'
10import { answerTool, TOOL_SPECS } from './tools'
11import { drawBand, drawPane, MINI_SEEKBAR, openPane, PANE, SEEKBAR, VOLUME } from './view'
12import { cookieNoticeOf, cookieStateOf } from './ytdlp'
13
14const isFraction = (data: unknown): data is { fraction: number } => typeof (data as { fraction?: unknown } | null)?.fraction === 'number'
15
16const isStep = (data: unknown): data is { step: number } => typeof (data as { step?: unknown } | null)?.step === 'number'
17
18const inputOf = (e: object) => Object.fromEntries(Object.entries(e).filter(([key]) => key !== 'tool' && key !== 'tool_use_id'))
19
20const served = async ($: EngineInterface, name: string, e: object) => {
21  try {
22    return { result: await answerTool($, name, inputOf(e)) }
23  } catch (error) {
24    return { result: problemText(error) }
25  }
26}
27
28const prepare = async ($: EngineInterface, session: Session) => {
29  const [cookies, facts] = await Promise.all([cookieStateOf($, session.settings, session.places), factsOf($, session)])
30  await put($, 'setup', now => ({ ...now, ...facts, cookies: cookieNoticeOf(cookies) }))
31  await checkTools($, session)
32}
33
34export const register: Register = (on, options) => {
35  const settings = settingsOf(options)
36  let session: Session | null = null
37
38  on('session.start', async ($, e, next) => {
39    await $.command.register({
40      name: 'meowzik',
41      description: say('commandDescription'),
42      argumentHint: say('commandHint'),
43      immediate: true,
44    })
45    for (const spec of TOOL_SPECS) {
46      await $.tool.register(spec)
47    }
48
49    try {
50      const active: Session = { settings, places: await placesOf($) }
51      session = active
52      await restore($, active)
53      void prepare($, active)
54    } catch (error) {
55      await tell($, 'failure', { reason: problemText(error) }, true)
56    }
57
58    return next(e)
59  })
60
61  on('command.run', { command: 'meowzik' }, async ($, e) => {
62    const text = await openPane($)
63    if (e.args.trim()) {
64      void addSongs($, e.args, 'you', 'end')
65    }
66
67    return { text }
68  })
69
70  on('tool.call', { tool: 'mcp__meowzik__play' }, ($, e) => served($, 'play', e))
71  on('tool.call', { tool: 'mcp__meowzik__control' }, ($, e) => served($, 'control', e))
72  on('tool.call', { tool: 'mcp__meowzik__status' }, ($, e) => served($, 'status', e))
73  on('tool.call', { tool: 'mcp__meowzik__list' }, ($, e) => served($, 'list', e))
74
75  on('ui.message', async ($, e) => {
76    const isSeekbar = e.element === SEEKBAR || e.element === MINI_SEEKBAR
77
78    if (isSeekbar && isFraction(e.data)) {
79      await seekTo($, e.data.fraction * (await get($, 'length')))
80    } else if (isSeekbar && isStep(e.data)) {
81      await seekBy($, e.data.step * SEEK_KEY_SECONDS)
82    } else if (e.element === VOLUME && isFraction(e.data)) {
83      await setVolume($, e.data.fraction * VOLUME_MAX)
84    } else if (e.element === VOLUME && isStep(e.data)) {
85      await changeVolume($, e.data.step * VOLUME_KEY_STEP)
86    }
87
88    return {}
89  })
90
91  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => drawPane($, e, session))
92
93  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
94    const band = await drawBand($, e, settings.miniPlayer)
95    if (!band) {
96      return next(e)
97    }
98
99    const { Box } = $.ui.resolve(e)
100
101    return (
102      <Box flexDirection="column">
103        {band}
104        {await next(e)}
105      </Box>
106    )
107  })
108}
hooks/config.ts 117 lines
1import type { EngineInterface, PluginOptions } from 'claude-code'
2
3import type { MiniPlayer } from '../types'
4import type { SearchSource } from './sources'
5import { Problem } from './text'
6
7export type Platform = 'windows' | 'mac' | 'linux'
8
9export type Settings = {
10  miniPlayer: MiniPlayer
11  showsStatus: boolean
12  searchSource: SearchSource
13  cookies: string
14  cookiesFromBrowser: string
15  ytdlpArgs: string[]
16  ytdlp: string
17  mpv: string
18}
19
20export type Session = { settings: Settings; places: Places }
21
22export type Places = {
23  platform: Platform
24  arch: string
25  data: string
26  ytdlp: string
27  mpvFolder: string
28  mpv: string
29  denoFolder: string
30  deno: string
31  tar: string
32  curl: string
33  run: string
34  control: string
35  cookieJar: string
36  coverPng: string
37  coverJpeg: string
38  lists: string
39  script: string
40  logo: string
41}
42
43const DATA_FOLDER = 'meowzik'
44const CONFIG_FOLDER = '.claude'
45const WINDOWS = 'Windows_NT'
46const DARWIN = 'Darwin'
47const ARGUMENT = /"([^"]*)"|'([^']*)'|(\S+)/g
48const MINI_PLAYERS: readonly MiniPlayer[] = ['playing', 'always', 'off']
49
50const textOf = (options: PluginOptions, key: string) => {
51  const value = options[key]
52
53  return typeof value === 'string' ? value.trim() : ''
54}
55
56export const argumentsOf = (raw: string) => [...raw.matchAll(ARGUMENT)].map(match => match[1] ?? match[2] ?? match[3] ?? '')
57
58export const settingsOf = (options: PluginOptions): Settings => ({
59  miniPlayer: MINI_PLAYERS.find(player => player === textOf(options, 'miniPlayer')) ?? 'playing',
60  showsStatus: options.statusLine !== false,
61  searchSource: textOf(options, 'searchSource') === 'soundcloud' ? 'soundcloud' : 'youtube',
62  cookies: textOf(options, 'cookies'),
63  cookiesFromBrowser: textOf(options, 'cookiesFromBrowser'),
64  ytdlpArgs: argumentsOf(textOf(options, 'ytdlpArgs')),
65  ytdlp: textOf(options, 'ytdlp'),
66  mpv: textOf(options, 'mpv'),
67})
68
69export const joined = (platform: Platform, ...parts: string[]) => {
70  const separator = platform === 'windows' ? '\\' : '/'
71
72  return parts.map((part, index) => (index === 0 ? part.replace(/[\\/]+$/, '') : part.replace(/^[\\/]+|[\\/]+$/g, ''))).join(separator)
73}
74
75export const placesFor = (platform: Platform, arch: string, configFolder: string, root: string, systemFolder: string): Places => {
76  const at = (...parts: string[]) => joined(platform, ...parts)
77  const exe = platform === 'windows' ? '.exe' : ''
78  const system = (name: string) => (platform === 'windows' && systemFolder ? at(systemFolder, 'System32', `${name}.exe`) : name)
79  const data = at(configFolder, DATA_FOLDER)
80
81  return {
82    platform,
83    arch,
84    data,
85    ytdlp: at(data, 'bin', `yt-dlp${exe}`),
86    mpvFolder: at(data, 'bin', 'mpv'),
87    mpv: at(data, 'bin', 'mpv', `mpv${exe}`),
88    denoFolder: at(data, 'bin', 'deno'),
89    deno: at(data, 'bin', 'deno', `deno${exe}`),
90    tar: system('tar'),
91    curl: system('curl'),
92    run: at(data, 'run'),
93    control: at(data, 'run', 'control.json'),
94    cookieJar: at(data, 'cookies.txt'),
95    coverPng: at(data, 'run', 'cover.png'),
96    coverJpeg: at(data, 'run', 'cover.jpg'),
97    lists: at(data, 'lists'),
98    script: at(root, 'player', 'meowzik.lua'),
99    logo: at(root, 'assets', 'logo.png'),
100  }
101}
102
103export async function placesOf($: EngineInterface): Promise<Places> {
104  const isWindows = (await $.env.get('OS')) === WINDOWS
105  const home = isWindows ? await $.env.get('USERPROFILE') : await $.env.get('HOME')
106  if (!home) {
107    throw new Problem('homeMissing')
108  }
109
110  const machine = isWindows ? null : (await $.process.run(['uname', '-sm'])).stdout.trim().split(/\s+/)
111  const platform: Platform = isWindows ? 'windows' : machine?.[0] === DARWIN ? 'mac' : 'linux'
112  const arch = (isWindows ? await $.env.get('PROCESSOR_ARCHITECTURE') : machine?.[1]) ?? ''
113  const configFolder = (await $.env.get('CLAUDE_CONFIG_DIR')) || joined(platform, home, CONFIG_FOLDER)
114  const systemFolder = (isWindows ? await $.env.get('SystemRoot') : null) ?? ''
115
116  return placesFor(platform, arch.toLowerCase(), configFolder, $.plugin.root, systemFolder)
117}
hooks/layout.ts 33 lines
1export const COVER_PIXELS = 240
2export const COVER_SVG_SIZE = 120
3export const COVER_CORNER = 12
4export const COVER_COLUMNS = 16
5export const COVER_ROWS = 8
6export const PANE_ROWS = 34
7export const PAGE_SIZE = 50
8export const GAP = 1
9export const SIDE_GAP = 2
10export const TEXT_BAR_COLUMNS = 30
11export const SEEK_ROWS = 2
12export const VOLUME_COLUMNS = 30
13export const SLIDER_VALUE_COLUMNS = 5
14export const BAND_SEEK_COLUMNS = 34
15export const BAND_CONTROLS_COLUMNS = 14
16export const BAND_TITLE_MIN_COLUMNS = 12
17export const LYRICS_BEFORE = 2
18export const LYRICS_AFTER = 8
19export const LYRICS_LEAD_SECONDS = 0.3
20export const SEEK_KEY_SECONDS = 5
21export const SEEK_BUTTON_SECONDS = 10
22export const VOLUME_STEP = 10
23export const VOLUME_KEY_STEP = 5
24export const VOLUME_MAX = 100
25
26const BAR_DONE = '━'
27const BAR_LEFT = '─'
28
29export const barOf = (columns: number, position: number, length: number) => {
30  const done = length > 0 ? Math.round(columns * Math.min(1, Math.max(0, position / length))) : 0
31
32  return { done: BAR_DONE.repeat(done), left: BAR_LEFT.repeat(Math.max(0, columns - done)) }
33}
hooks/playback.ts 805 lines
1import type { EngineInterface, Timer } from 'claude-code'
2
3import type { LoopMode, Lyrics, Requester, Track } from '../types'
4import { findTracks, relatedTo, resolvedTrack, searchTracks, streamFor } from './catalog'
5import { joined } from './config'
6import type { Session } from './config'
7import { coverOf } from './cover'
8import { VOLUME_MAX } from './layout'
9import {
10  captionLanguagesOf,
11  emptyLyrics,
12  lyricsFor,
13  MANUAL_LANGUAGES,
14  SUBTITLE_NAME,
15  subtitleChoiceOf,
16  subtitleLinesOf,
17  subtitleLyricsOf,
18} from './lyrics'
19import { playerArgv, readReports } from './mpv'
20import type { Control } from './mpv'
21import {
22  candidatesOf,
23  finished,
24  LOOP_CYCLE,
25  movedTo,
26  removedAt,
27  requestedAs,
28  shuffled,
29  takeNext,
30  toggledFavorite,
31  withPlayed,
32  withSeed,
33} from './queue'
34import { readyTools, runtimeArgsOf } from './setup'
35import { loadList, saveList } from './shelf'
36import type { ListName } from './shelf'
37import { get, put, set, tell } from './state'
38import { clockOf, countKey, LOOP_NAMES, Problem, problemText, say, switchOf, titleOf } from './text'
39import type { Params, TextKey } from './text'
40import { ytdlpLines } from './ytdlp'
41import type { Ytdlp } from './ytdlp'
42
43export type Placement = 'end' | 'next' | 'now'
44
45export type Reply = { key: TextKey; params: Params; isError: boolean }
46
47type Outcome = { reason: string; error: string }
48
49type Pending = { run: number; seed: Track | null }
50
51const MAX_FAILURES = 5
52const BACKOFF_MS = 2000
53const PROGRESS_STEP = 25
54const MS_PER_SECOND = 1000
55const AVAILABLE_MARGIN_MS = 500
56const FRESH_MARGIN_MS = 2000
57const UNKNOWN_LANGUAGE = 'NA'
58const FATAL: ReadonlySet<TextKey> = new Set<TextKey>(['botCheck', 'botCheckCookies', 'installFailed', 'manualMpv', 'playerMissing', 'homeMissing'])
59const CURRENT_KEY = 'current'
60const LOOP_KEY = 'loop'
61const SHUFFLE_KEY = 'isShuffled'
62const AUTOPLAY_KEY = 'isAutoplay'
63const VOLUME_KEY = 'volume'
64
65let session: Session | null = null
66let run = 0
67let seek = { id: 0, to: 0 }
68let failures = 0
69let seeds: Track[] = []
70let retry: Timer | null = null
71let chain: Promise<unknown> = Promise.resolve()
72const seen = new Set<string>()
73const waiters = new Map<number, (failure: Reply | null) => void>()
74
75const replyOf = (key: TextKey, params: Params = {}, isError = false): Reply => ({ key, params, isError })
76
77const sessionOf = () => {
78  if (!session) {
79    throw new Problem('homeMissing')
80  }
81
82  return session
83}
84
85const serially = <T>(work: () => Promise<T>): Promise<T> => {
86  const next = chain.then(work, work)
87  chain = next.catch(() => undefined)
88
89  return next
90}
91
92const detach = (work: Promise<unknown>) => void work.catch(() => undefined)
93
94const settle = (mine: number, failure: Reply | null) => {
95  waiters.get(mine)?.(failure)
96  waiters.delete(mine)
97}
98
99const nextRun = () => {
100  run += 1
101  for (const mine of [...waiters.keys()]) {
102    settle(mine, null)
103  }
104
105  return run
106}
107
108const cancelRetry = () => {
109  retry?.cancel()
110  retry = null
111}
112
113const isBoolean = (value: unknown): value is boolean => typeof value === 'boolean'
114
115const isLoopMode = (value: unknown): value is LoopMode => value === 'off' || value === 'track' || value === 'queue'
116
117const isLevel = (value: unknown): value is number => typeof value === 'number' && value >= 0 && value <= VOLUME_MAX
118
119
120const isStoredTrack = (value: unknown): value is Track =>
121  typeof value === 'object' && value !== null && typeof (value as Track).id === 'string' && typeof (value as Track).url === 'string'
122
123async function told($: EngineInterface, reply: Reply) {
124  await tell($, reply.key, reply.params, reply.isError)
125
126  return reply
127}
128
129async function setList($: EngineInterface, name: ListName, change: (list: Track[]) => Track[]) {
130  const next = await put($, name, change)
131  await saveList($, sessionOf().places, name, next)
132
133  return next
134}
135
136async function toolsFor($: EngineInterface) {
137  const active = sessionOf()
138  const tools = await readyTools($, active)
139  const ytdlp: Ytdlp = { settings: active.settings, places: active.places, path: tools.ytdlp.path, runtime: runtimeArgsOf(tools.runtime) }
140
141  return { ytdlp, mpv: tools.mpv.path }
142}
143
144async function writeControl($: EngineInterface) {
145  if (!session) {
146    return
147  }
148
149  const [shown, level] = await Promise.all([get($, 'phase'), get($, 'volume')])
150  const control: Control = { run: String(run), pause: shown === 'paused', volume: level, seek }
151  await $.fs.write(session.places.control, JSON.stringify(control))
152}
153
154async function showStatus($: EngineInterface) {
155  const [track, shown] = await Promise.all([get($, 'current'), get($, 'phase')])
156
157  if (!track || shown === 'idle' || !session?.settings.showsStatus) {
158    $.ui.status(undefined)
159    return
160  }
161
162  const key: TextKey = shown === 'paused' ? 'statusPaused' : shown === 'loading' ? 'statusLoading' : 'statusPlaying'
163  $.ui.status(say(key, { title: titleOf(track) }))
164}
165
166async function clearPlayer($: EngineInterface, shown: 'idle' | 'loading') {
167  cancelRetry()
168  nextRun()
169  await set($, 'current', null)
170  await set($, 'phase', shown)
171  await set($, 'position', 0)
172  await set($, 'length', 0)
173  await set($, 'cover', null)
174  await writeControl($)
175  await showStatus($)
176}
177
178async function goIdle($: EngineInterface) {
179  await clearPlayer($, 'idle')
180  await $.store.delete(CURRENT_KEY)
181}
182
183async function waitUntil($: EngineInterface, at: number) {
184  const wait = at - (await $.clock.now())
185  if (wait > 0) {
186    await new Promise<void>(resolve => {
187      $.clock.after(wait, resolve)
188    })
189  }
190}
191
192async function refreshCover($: EngineInterface, mpv: string, track: Track, mine: number) {
193  const made = await coverOf($, mpv, sessionOf().places, track, mine)
194  if (mine === run) {
195    await set($, 'cover', made)
196  }
197}
198
199async function subtitlesOf($: EngineInterface, track: Track): Promise<Lyrics | null> {
200  if (track.source !== 'youtube') {
201    return null
202  }
203
204  const { ytdlp } = await toolsFor($)
205  const { places } = sessionOf()
206  let language = ''
207
208  for (const isAuto of [false, true]) {
209    const startedAt = await $.clock.now()
210    const printed = await ytdlpLines($, ytdlp, [
211      '--no-playlist',
212      '--skip-download',
213      '--no-simulate',
214      isAuto ? '--write-auto-subs' : '--write-subs',
215      '--sub-format',
216      'json3',
217      '--sub-langs',
218      isAuto ? captionLanguagesOf(language) : MANUAL_LANGUAGES,
219      '-o',
220      joined(places.platform, places.run, SUBTITLE_NAME),
221      '--print',
222      '%(language)s',
223      track.url,
224    ]).catch((): string[] => [])
225    const spoken = printed[0] ?? ''
226    language ||= spoken === UNKNOWN_LANGUAGE ? '' : spoken
227
228    const fresh = (await $.fs.list(places.run)).filter(entry => entry.mtimeMs >= startedAt - FRESH_MARGIN_MS).map(entry => entry.name)
229    const chosen = subtitleChoiceOf(fresh, language)
230    const lyrics = chosen
231      ? subtitleLyricsOf(track.id, subtitleLinesOf(await $.fs.read(joined(places.platform, places.run, chosen))), isAuto ? 'captions' : 'subtitles')
232      : null
233    if (lyrics) {
234      return lyrics
235    }
236  }
237
238  return null
239}
240
241export async function refreshLyrics($: EngineInterface, track: Track) {
242  await set($, 'lyrics', emptyLyrics(track.id, 'loading'))
243  const found = await lyricsFor($, track).catch(() => emptyLyrics(track.id, 'unreachable'))
244  const isMissing = found.status === 'missing' || found.status === 'unreachable'
245  const shown = isMissing ? ((await subtitlesOf($, track).catch(() => null)) ?? found) : found
246
247  if ((await get($, 'current'))?.id === track.id) {
248    await set($, 'lyrics', shown)
249  }
250}
251
252async function listen($: EngineInterface, argv: string[], mine: number): Promise<Outcome> {
253  const child = $.process.spawn({ argv })
254  let buffer = ''
255  let ended: Outcome | null = null
256  let second = -1
257  let total = -1
258
259  try {
260    for await (const { stream, text } of child) {
261      if (mine !== run) {
262        return { reason: 'stop', error: '' }
263      }
264      if (stream !== 'stdout') {
265        continue
266      }
267
268      const { reports, rest } = readReports(buffer + text)
269      buffer = rest
270      for (const report of reports) {
271        if (report.kind === 'start') {
272          failures = 0
273          settle(mine, null)
274          await put($, 'phase', now => (now === 'loading' ? 'playing' : now))
275          await showStatus($)
276        } else if (report.kind === 'position' && report.seconds !== null && Math.floor(report.seconds) !== second) {
277          second = Math.floor(report.seconds)
278          await set($, 'position', second)
279        } else if (report.kind === 'length' && report.seconds !== null && Math.round(report.seconds) !== total) {
280          total = Math.round(report.seconds)
281          await set($, 'length', total)
282        } else if (report.kind === 'end') {
283          ended = { reason: report.reason, error: report.error }
284        }
285      }
286    }
287  } catch (error) {
288    throw new Problem('playerMissing', { reason: problemText(error) })
289  }
290
291  const { code } = await child.result
292
293  return ended ?? (code === 0 ? { reason: 'eof', error: '' } : { reason: 'error', error: `exit ${code}` })
294}
295
296async function failed($: EngineInterface, track: Track, error: unknown, mine: number) {
297  const reply = replyOf('couldNotPlay', { title: titleOf(track), reason: problemText(error) }, true)
298  settle(mine, reply)
299
300  if (error instanceof Problem && FATAL.has(error.key)) {
301    await setList($, 'queue', list => (list[0]?.id === track.id ? list : [track, ...list]))
302    await goIdle($)
303    await told($, reply)
304    return
305  }
306
307  failures += 1
308  await told($, reply)
309  await setList($, 'played', list => withPlayed(list, track))
310  await set($, 'current', null)
311
312  if (failures >= MAX_FAILURES) {
313    const count = failures
314    failures = 0
315    await goIdle($)
316    await tell($, 'tooManyFailures', { count }, true)
317    return
318  }
319
320  await set($, 'phase', 'loading')
321  retry = $.clock.after(BACKOFF_MS * failures, () => {
322    retry = null
323    detach(advance($, false))
324  })
325}
326
327async function play($: EngineInterface, track: Track, mine: number, startAt: number) {
328  try {
329    const { ytdlp, mpv } = await toolsFor($)
330    const ready = await resolvedTrack($, ytdlp, track)
331    if (mine !== run) {
332      return
333    }
334    if (ready !== track) {
335      seen.add(ready.id)
336      await set($, 'current', ready)
337      await set($, 'length', ready.duration)
338      await $.store.set(CURRENT_KEY, ready)
339    }
340
341    detach(refreshCover($, mpv, ready, mine))
342    detach(refreshLyrics($, ready))
343    const stream = await streamFor($, ytdlp, ready)
344    if (stream.availableAt !== null) {
345      await waitUntil($, stream.availableAt * MS_PER_SECOND + AVAILABLE_MARGIN_MS)
346    }
347    if (mine !== run) {
348      return
349    }
350
351    const places = sessionOf().places
352    const [level, shown] = await Promise.all([get($, 'volume'), get($, 'phase')])
353    await writeControl($)
354    const outcome = await listen(
355      $,
356      playerArgv({
357        mpv,
358        script: places.script,
359        control: places.control,
360        run: String(mine),
361        volume: level,
362        start: startAt,
363        isPaused: shown === 'paused',
364        stream,
365      }),
366      mine,
367    )
368    if (mine !== run) {
369      return
370    }
371    if (outcome.reason === 'eof') {
372      await advance($, false)
373      return
374    }
375
376    await failed($, ready, new Problem('failure', { reason: outcome.error || outcome.reason }), mine)
377  } catch (error) {
378    if (mine === run) {
379      await failed($, track, error, mine)
380    }
381  }
382}
383
384async function start($: EngineInterface, track: Track, startAt = 0, isPaused = false) {
385  cancelRetry()
386  const mine = nextRun()
387  const started = new Promise<Reply | null>(resolve => waiters.set(mine, resolve))
388  seen.add(track.id)
389  await set($, 'current', track)
390  await set($, 'phase', isPaused ? 'paused' : 'loading')
391  await set($, 'position', startAt)
392  await set($, 'length', track.duration)
393  await put($, 'cover', now => (now?.id === track.id ? now : null))
394  await $.store.set(CURRENT_KEY, track)
395  await writeControl($)
396  await showStatus($)
397  detach(play($, track, mine, startAt))
398
399  return { started }
400}
401
402async function settleCurrent($: EngineInterface) {
403  const now = await get($, 'current')
404  if (now) {
405    await setList($, 'played', list => withPlayed(list, now))
406  }
407}
408
409async function proceed($: EngineInterface, wasSkipped: boolean): Promise<Pending | null> {
410  const [now, mode, queued, history, isRandom, isAuto] = await Promise.all([
411    get($, 'current'),
412    get($, 'loop'),
413    get($, 'queue'),
414    get($, 'played'),
415    get($, 'isShuffled'),
416    get($, 'isAutoplay'),
417  ])
418  const lists = now ? finished({ queue: queued, played: history }, now, mode, wasSkipped) : { queue: queued, played: history }
419  const picked = takeNext(lists.queue, isRandom, Math.random)
420
421  await setList($, 'queue', () => picked.queue)
422  if (now) {
423    await setList($, 'played', () => lists.played)
424  }
425  if (picked.track) {
426    await start($, picked.track)
427    return null
428  }
429  if (!isAuto) {
430    await goIdle($)
431    return null
432  }
433
434  await clearPlayer($, 'loading')
435
436  return { run, seed: now ?? lists.played.at(-1) ?? null }
437}
438
439async function autoplayPick($: EngineInterface, last: Track | null): Promise<Track | null> {
440  const { ytdlp } = await toolsFor($)
441
442  for (const seed of candidatesOf(last, seeds)) {
443    const related = await relatedTo($, ytdlp, seed, seen, Math.random).catch(() => null)
444    if (!related) {
445      continue
446    }
447
448    seen.add(related.id)
449    const [better] = await searchTracks($, ytdlp, related.title, 'autoplay').catch((): Track[] => [])
450
451    return { ...(better ?? related), requestedBy: 'autoplay', isAutoplay: true }
452  }
453
454  return null
455}
456
457async function afterAutoplay($: EngineInterface, pending: Pending, found: Track | null) {
458  if (pending.run !== run) {
459    return
460  }
461
462  const next = takeNext(await get($, 'queue'), await get($, 'isShuffled'), Math.random)
463  if (next.track) {
464    await setList($, 'queue', () => next.queue)
465    await start($, next.track)
466  } else if (found) {
467    await start($, found)
468  } else {
469    await goIdle($)
470  }
471}
472
473export async function advance($: EngineInterface, wasSkipped: boolean) {
474  const pending = await serially(() => proceed($, wasSkipped))
475  if (!pending) {
476    return
477  }
478
479  const found = await autoplayPick($, pending.seed).catch(() => null)
480  await serially(() => afterAutoplay($, pending, found))
481}
482
483async function placed($: EngineInterface, tracks: Track[], placement: Placement) {
484  const [first, ...rest] = tracks
485  const isIdle = (await get($, 'phase')) === 'idle'
486
487  if (first && (placement === 'now' || isIdle)) {
488    await setList($, 'queue', list => [...rest, ...list])
489    await settleCurrent($)
490    return start($, first)
491  }
492
493  await setList($, 'queue', list => (placement === 'next' ? [...tracks, ...list] : [...list, ...tracks]))
494
495  return null
496}
497
498export async function addSongs($: EngineInterface, raw: string, requestedBy: Requester, placement: Placement): Promise<Reply> {
499  await set($, 'isSearching', true)
500
501  try {
502    const { ytdlp } = await toolsFor($)
503    const tracks = await findTracks($, ytdlp, raw, requestedBy, count => {
504      if (count % PROGRESS_STEP === 0) {
505        detach(tell($, countKey('readingList', count), { count }))
506      }
507    })
508    seeds = tracks.reduce(withSeed, seeds)
509
510    const playing = await serially(() => placed($, tracks, placement))
511    await set($, 'isSearching', false)
512    const failure = playing ? await playing.started : null
513    const [first] = tracks
514
515    if (failure) {
516      return failure
517    }
518
519    return await told(
520      $,
521      tracks.length === 1 && first
522        ? replyOf(playing ? 'playingNow' : 'addedOne', { title: titleOf(first) })
523        : replyOf(countKey('addedMany', tracks.length), { count: tracks.length }),
524    )
525  } catch (error) {
526    return told($, replyOf('couldNotAdd', { reason: problemText(error) }, true))
527  } finally {
528    await set($, 'isSearching', false)
529  }
530}
531
532async function pauseOrPlay($: EngineInterface): Promise<Reply> {
533  const [shown, track] = await Promise.all([get($, 'phase'), get($, 'current')])
534
535  if (shown === 'idle') {
536    if ((await get($, 'queue')).length === 0) {
537      return told($, replyOf('nothingPlaying', {}, true))
538    }
539
540    await proceed($, false)
541    const next = await get($, 'current')
542    return replyOf('playingNow', { title: next ? titleOf(next) : '' })
543  }
544  if (!track || shown === 'loading') {
545    return replyOf('loading')
546  }
547
548  const isPausing = shown === 'playing'
549  await set($, 'phase', isPausing ? 'paused' : 'playing')
550  await writeControl($)
551  await showStatus($)
552
553  return replyOf(isPausing ? 'pausedReply' : 'resumedReply', { title: titleOf(track) })
554}
555
556export function togglePause($: EngineInterface) {
557  return serially(() => pauseOrPlay($))
558}
559
560export async function skip($: EngineInterface): Promise<Reply> {
561  const track = await get($, 'current')
562  if (!track) {
563    return told($, replyOf('nothingPlaying', {}, true))
564  }
565
566  await advance($, true)
567
568  return replyOf('skipped', { title: titleOf(track) })
569}
570
571async function stepBack($: EngineInterface): Promise<Reply> {
572  const previous = (await get($, 'played')).at(-1)
573  if (!previous) {
574    return told($, replyOf('noPrevious', {}, true))
575  }
576
577  const now = await get($, 'current')
578  await setList($, 'played', list => list.slice(0, -1))
579  if (now) {
580    await setList($, 'queue', list => [now, ...list])
581  }
582  await start($, previous)
583
584  return replyOf('playingNow', { title: titleOf(previous) })
585}
586
587export function back($: EngineInterface) {
588  return serially(() => stepBack($))
589}
590
591async function stopAll($: EngineInterface) {
592  await settleCurrent($)
593  await setList($, 'queue', () => [])
594  await goIdle($)
595
596  return told($, replyOf('stopped'))
597}
598
599export function stop($: EngineInterface) {
600  return serially(() => stopAll($))
601}
602
603export async function setLoop($: EngineInterface, mode: LoopMode) {
604  await set($, 'loop', mode)
605  await $.store.set(LOOP_KEY, mode)
606
607  return replyOf('loopIs', { mode: say(LOOP_NAMES[mode]) })
608}
609
610export async function cycleLoop($: EngineInterface) {
611  return setLoop($, LOOP_CYCLE[await get($, 'loop')])
612}
613
614export async function toggleShuffle($: EngineInterface) {
615  const isOn = await put($, 'isShuffled', now => !now)
616  await $.store.set(SHUFFLE_KEY, isOn)
617  if (isOn) {
618    await serially(() => setList($, 'queue', list => shuffled(list, Math.random)))
619  }
620
621  return replyOf('shuffleIs', { state: switchOf(isOn) })
622}
623
624export async function toggleAutoplay($: EngineInterface) {
625  const isOn = await put($, 'isAutoplay', now => !now)
626  await $.store.set(AUTOPLAY_KEY, isOn)
627
628  const isIdle = (await get($, 'phase')) === 'idle'
629  if (isOn && isIdle && (seeds.length > 0 || (await get($, 'played')).length > 0)) {
630    detach(advance($, false))
631  }
632
633  return replyOf('autoplayIs', { state: switchOf(isOn) })
634}
635
636export async function setVolume($: EngineInterface, level: number) {
637  const next = await set($, 'volume', Math.round(Math.min(VOLUME_MAX, Math.max(0, level))))
638  await $.store.set(VOLUME_KEY, next)
639  await writeControl($)
640
641  return replyOf('volumeIs', { volume: next })
642}
643
644export async function changeVolume($: EngineInterface, step: number) {
645  return setVolume($, (await get($, 'volume')) + step)
646}
647
648export async function seekTo($: EngineInterface, seconds: number): Promise<Reply> {
649  const [shown, total] = await Promise.all([get($, 'phase'), get($, 'length')])
650  if (shown !== 'playing' && shown !== 'paused') {
651    return told($, replyOf('nothingPlaying', {}, true))
652  }
653
654  const to = Math.max(0, total > 0 ? Math.min(seconds, total - 1) : seconds)
655  seek = { id: seek.id + 1, to }
656  await set($, 'position', Math.floor(to))
657  await writeControl($)
658
659  return replyOf('seekedTo', { position: clockOf(to) })
660}
661
662export async function seekBy($: EngineInterface, seconds: number) {
663  return seekTo($, (await get($, 'position')) + seconds)
664}
665
666export async function toggleFavorite($: EngineInterface, track?: Track): Promise<Reply> {
667  const chosen = track ?? (await get($, 'current'))
668  if (!chosen) {
669    return told($, replyOf('nothingPlaying', {}, true))
670  }
671
672  let isAdded = false
673  await setList($, 'favorites', list => {
674    const toggled = toggledFavorite(list, chosen)
675    isAdded = toggled.isAdded
676    return toggled.favorites
677  })
678
679  return told($, replyOf(isAdded ? 'favoriteAdded' : 'favoriteRemoved', { title: titleOf(chosen) }))
680}
681
682async function entryOf($: EngineInterface, list: ListName, index: number) {
683  return (await get($, list))[index] ?? null
684}
685
686async function playEntry($: EngineInterface, list: ListName, index: number): Promise<Reply> {
687  const track = await entryOf($, list, index)
688  if (!track) {
689    return told($, replyOf('noSuchSong', { number: index + 1 }, true))
690  }
691
692  if (list === 'queue') {
693    await setList($, 'queue', items => removedAt(items, index))
694  }
695  await settleCurrent($)
696  await start($, list === 'queue' ? track : requestedAs(track, 'you'))
697
698  return replyOf('playingNow', { title: titleOf(track) })
699}
700
701export function playFrom($: EngineInterface, list: ListName, index: number) {
702  return serially(() => playEntry($, list, index))
703}
704
705async function queueEntry($: EngineInterface, list: ListName, index: number): Promise<Reply> {
706  const track = await entryOf($, list, index)
707  if (!track) {
708    return told($, replyOf('noSuchSong', { number: index + 1 }, true))
709  }
710
711  const queued = requestedAs(track, 'you')
712  if ((await get($, 'phase')) === 'idle') {
713    await start($, queued)
714    return replyOf('playingNow', { title: titleOf(track) })
715  }
716
717  await setList($, 'queue', items => [...items, queued])
718
719  return told($, replyOf('addedOne', { title: titleOf(track) }))
720}
721
722export function enqueueFrom($: EngineInterface, list: ListName, index: number) {
723  return serially(() => queueEntry($, list, index))
724}
725
726async function removeEntry($: EngineInterface, list: ListName, index: number): Promise<Reply> {
727  const track = await entryOf($, list, index)
728  if (!track) {
729    return told($, replyOf('noSuchSong', { number: index + 1 }, true))
730  }
731
732  await setList($, list, items => removedAt(items, index))
733
734  return replyOf('removed', { title: titleOf(track) })
735}
736
737export function removeFrom($: EngineInterface, list: ListName, index: number) {
738  return serially(() => removeEntry($, list, index))
739}
740
741async function moveEntry($: EngineInterface, index: number, target: number): Promise<Reply> {
742  const track = await entryOf($, 'queue', index)
743  if (!track) {
744    return told($, replyOf('noSuchSong', { number: index + 1 }, true))
745  }
746
747  const moved = await setList($, 'queue', items => movedTo(items, index, target))
748
749  return replyOf('moved', { title: titleOf(track), number: moved.indexOf(track) + 1 })
750}
751
752export function moveInQueue($: EngineInterface, index: number, target: number) {
753  return serially(() => moveEntry($, index, target))
754}
755
756async function emptyList($: EngineInterface, list: 'queue' | 'played') {
757  await setList($, list, () => [])
758
759  return told($, replyOf(list === 'queue' ? 'queueCleared' : 'historyCleared'))
760}
761
762export function clearList($: EngineInterface, list: 'queue' | 'played') {
763  return serially(() => emptyList($, list))
764}
765
766async function storedOf<T>($: EngineInterface, key: string, isValid: (value: unknown) => value is T) {
767  const value = await $.store.get(key)
768
769  return isValid(value) ? value : null
770}
771
772export async function restore($: EngineInterface, active: Session) {
773  session = active
774
775  const [queued, history, liked] = await Promise.all([
776    loadList($, active.places, 'queue'),
777    loadList($, active.places, 'played'),
778    loadList($, active.places, 'favorites'),
779  ])
780  const [mode, isRandom, isAuto, level, saved] = await Promise.all([
781    storedOf($, LOOP_KEY, isLoopMode),
782    storedOf($, SHUFFLE_KEY, isBoolean),
783    storedOf($, AUTOPLAY_KEY, isBoolean),
784    storedOf($, VOLUME_KEY, isLevel),
785    storedOf($, CURRENT_KEY, isStoredTrack),
786  ])
787
788  await set($, 'favorites', liked)
789  await set($, 'played', history)
790  await put($, 'loop', now => mode ?? now)
791  await put($, 'isShuffled', now => isRandom ?? now)
792  await put($, 'isAutoplay', now => isAuto ?? now)
793  await put($, 'volume', now => level ?? now)
794
795  const [now, shown, at] = await Promise.all([get($, 'current'), get($, 'phase'), get($, 'position')])
796  if (now && shown !== 'idle') {
797    await set($, 'queue', queued)
798    await start($, now, at, shown === 'paused')
799    return
800  }
801
802  await setList($, 'queue', () => (saved && !queued.some(track => track.id === saved.id) ? [saved, ...queued] : queued))
803  await $.store.delete(CURRENT_KEY)
804  await showStatus($)
805}
hooks/setup.ts 280 lines
1import type { EngineInterface } from 'claude-code'
2
3import type { Setup, Tool } from '../types'
4import type { Places, Platform, Session, Settings } from './config'
5import { joined } from './config'
6import { get, put, tell } from './state'
7import { Problem, say } from './text'
8
9export type ToolName = 'mpv' | 'ytdlp' | 'runtime'
10
11export type Asset = { name: string; url: string }
12
13type Step = 'stepDownload' | 'stepExtract' | 'stepBrew'
14
15type Release = { assets?: { name?: string; browser_download_url?: string }[] }
16
17const YTDLP_RELEASE = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download'
18const DENO_RELEASE = 'https://github.com/denoland/deno/releases/latest/download'
19const MPV_RELEASE = 'https://api.github.com/repos/shinchiro/mpv-winbuild-cmake/releases/latest'
20const BREW_BINS = ['/opt/homebrew/bin', '/usr/local/bin']
21const RUNTIMES = ['deno', 'node', 'bun']
22const RUNTIME_NAME = /(deno|node|bun)(?:\.exe)?$/i
23const VERSION = /v?\d+(?:\.\d+)+[\w.+-]*/
24const DOWNLOAD_MS = 600_000
25const VERSION_MS = 60_000
26const ARCHIVE = 'download.tmp'
27const BUILD_FILE = 'meowzik-build.json'
28const LIST = new Intl.ListFormat('en', { type: 'conjunction' })
29
30export const TOOLS: readonly ToolName[] = ['mpv', 'ytdlp', 'runtime']
31
32export const TOOL_NAMES: Record<ToolName, string> = { mpv: 'mpv', ytdlp: 'yt-dlp', runtime: 'deno' }
33
34export const MANUAL_MPV: Record<Exclude<Platform, 'windows'>, string> = {
35  mac: 'brew install mpv',
36  linux: 'sudo apt install mpv (or dnf, pacman, zypper: the package is mpv)',
37}
38
39let installing: Promise<Setup> | null = null
40
41const isArm = (arch: string) => arch === 'arm64' || arch === 'aarch64'
42
43export const ytdlpAssetOf = (platform: Platform, arch: string) => {
44  if (platform === 'windows') {
45    return arch === 'arm64' ? 'yt-dlp_arm64.exe' : arch === 'x86' ? 'yt-dlp_x86.exe' : 'yt-dlp.exe'
46  }
47  if (platform === 'mac') {
48    return 'yt-dlp_macos'
49  }
50
51  return isArm(arch) ? 'yt-dlp_linux_aarch64' : arch.startsWith('armv7') ? 'yt-dlp_linux_armv7l' : 'yt-dlp_linux'
52}
53
54export const denoAssetOf = (platform: Platform, arch: string) => {
55  const cpu = isArm(arch) ? 'aarch64' : 'x86_64'
56  const system = platform === 'windows' ? 'pc-windows-msvc' : platform === 'mac' ? 'apple-darwin' : 'unknown-linux-gnu'
57
58  return `deno-${cpu}-${system}.zip`
59}
60
61export const mpvAssetOf = (assets: readonly Asset[], arch: string) => {
62  const build = arch === 'arm64' ? 'aarch64' : arch === 'x86' ? 'i686' : 'x86_64'
63  const pattern = new RegExp(`^mpv-${build}-\\d{8}-git-[0-9a-f]+\\.7z$`)
64
65  return assets.find(asset => pattern.test(asset.name)) ?? null
66}
67
68export const versionOf = (stdout: string) => {
69  const first = stdout.trim().split(/\r?\n/)[0] ?? ''
70
71  return first.match(VERSION)?.[0] ?? first
72}
73
74export const runtimeNameOf = (path: string) => RUNTIME_NAME.exec(path)?.[1]?.toLowerCase() ?? TOOL_NAMES.runtime
75
76export const runtimeArgsOf = (runtime: Tool) => {
77  const name = runtimeNameOf(runtime.path)
78
79  return ['--js-runtimes', /[\\/]/.test(runtime.path) ? `${name}:${runtime.path}` : name]
80}
81
82export const toolLabelOf = (tool: ToolName, found: Tool | null) => (tool === 'runtime' && found ? runtimeNameOf(found.path) : TOOL_NAMES[tool])
83
84export const missingOf = (found: Setup): ToolName[] => TOOLS.filter(tool => found[tool] === null)
85
86export const toolListOf = (tools: readonly ToolName[]) => LIST.format(tools.map(tool => TOOL_NAMES[tool]))
87
88const candidatesOf = (tool: ToolName, settings: Settings, places: Places) => {
89  const brewed = (name: string) => (places.platform === 'mac' ? BREW_BINS.map(folder => `${folder}/${name}`) : [])
90
91  if (tool === 'ytdlp') {
92    return [settings.ytdlp || places.ytdlp]
93  }
94  if (tool === 'runtime') {
95    return [places.deno, ...RUNTIMES, ...brewed(TOOL_NAMES.runtime)]
96  }
97
98  return settings.mpv ? [settings.mpv] : [places.mpv, TOOL_NAMES.mpv, ...brewed(TOOL_NAMES.mpv)]
99}
100
101async function probe($: EngineInterface, path: string): Promise<Tool | null> {
102  try {
103    const { exitCode, stdout } = await $.process.run([path, '--version'], { timeoutMs: VERSION_MS })
104    return exitCode === 0 ? { path, version: versionOf(stdout) } : null
105  } catch {
106    return null
107  }
108}
109
110async function detectTool($: EngineInterface, tool: ToolName, settings: Settings, places: Places) {
111  for (const path of candidatesOf(tool, settings, places)) {
112    const found = await probe($, path)
113    if (found) {
114      return found
115    }
116  }
117
118  return null
119}
120
121async function ran($: EngineInterface, tool: ToolName, argv: string[]) {
122  const result = await $.process.run(argv, { timeoutMs: DOWNLOAD_MS }).catch((error: unknown) => {
123    throw new Problem('installFailed', { tool: TOOL_NAMES[tool], reason: error instanceof Error ? error.message : String(error) })
124  })
125  if (result.exitCode !== 0) {
126    throw new Problem('installFailed', {
127      tool: TOOL_NAMES[tool],
128      reason: result.stderr.trim().split(/\r?\n/).at(-1) || String(result.exitCode),
129    })
130  }
131}
132
133async function download($: EngineInterface, tool: ToolName, places: Places, url: string, target: string) {
134  await ran($, tool, [places.curl, '-fsSL', '--create-dirs', '-o', target, url])
135}
136
137async function brewOf($: EngineInterface) {
138  for (const folder of BREW_BINS) {
139    if (await $.fs.exists(`${folder}/brew`)) {
140      return `${folder}/brew`
141    }
142  }
143
144  return null
145}
146
147async function announce($: EngineInterface, tool: ToolName, step: Step) {
148  await tell($, 'installStep', { tool: TOOL_NAMES[tool], step: say(step) })
149}
150
151async function unpack($: EngineInterface, tool: ToolName, places: Places, asset: Asset, folder: string) {
152  const archive = joined(places.platform, places.data, 'run', ARCHIVE)
153  const isUnzip = places.platform === 'linux' && asset.name.endsWith('.zip')
154
155  try {
156    await announce($, tool, 'stepDownload')
157    await download($, tool, places, asset.url, archive)
158    await announce($, tool, 'stepExtract')
159    await $.fs.write(joined(places.platform, folder, BUILD_FILE), JSON.stringify({ build: asset.name }))
160    await ran($, tool, isUnzip ? ['unzip', '-o', archive, '-d', folder] : [places.tar, '-xf', archive, '-C', folder])
161  } finally {
162    await $.fs.write(archive, '')
163  }
164}
165
166async function mpvAssetFromGitHub($: EngineInterface, arch: string) {
167  const response = await $.http.fetch(MPV_RELEASE, { headers: { Accept: 'application/vnd.github+json' } })
168  const release = (response.ok ? JSON.parse(response.text) : {}) as Release
169  const assets = (release.assets ?? []).flatMap(asset =>
170    asset.name && asset.browser_download_url ? [{ name: asset.name, url: asset.browser_download_url }] : [],
171  )
172  const asset = mpvAssetOf(assets, arch)
173  if (!asset) {
174    throw new Problem('installFailed', { tool: TOOL_NAMES.mpv, reason: `HTTP ${response.status}` })
175  }
176
177  return asset
178}
179
180async function installTool($: EngineInterface, tool: ToolName, places: Places) {
181  const brew = places.platform === 'mac' && tool !== 'ytdlp' ? await brewOf($) : null
182  const isUnix = places.platform !== 'windows'
183
184  if (brew) {
185    await announce($, tool, 'stepBrew')
186    await ran($, tool, [brew, 'install', TOOL_NAMES[tool]])
187    return
188  }
189  if (tool === 'ytdlp') {
190    await announce($, tool, 'stepDownload')
191    await download($, tool, places, `${YTDLP_RELEASE}/${ytdlpAssetOf(places.platform, places.arch)}`, places.ytdlp)
192    if (isUnix) {
193      await ran($, tool, ['chmod', '+x', places.ytdlp])
194    }
195    return
196  }
197  if (tool === 'runtime') {
198    const name = denoAssetOf(places.platform, places.arch)
199    await unpack($, tool, places, { name, url: `${DENO_RELEASE}/${name}` }, places.denoFolder)
200    if (isUnix) {
201      await ran($, tool, ['chmod', '+x', places.deno])
202    }
203    return
204  }
205  if (isUnix) {
206    throw new Problem('manualMpv', { command: MANUAL_MPV[places.platform as Exclude<Platform, 'windows'>] })
207  }
208
209  await unpack($, tool, places, await mpvAssetFromGitHub($, places.arch), places.mpvFolder)
210}
211
212export async function factsOf($: EngineInterface, session: Session) {
213  const { places, settings } = session
214  const brew = places.platform === 'mac' ? await brewOf($) : null
215
216  return {
217    folder: places.data,
218    logo: places.logo,
219    manualMpv: places.platform === 'windows' || brew ? null : MANUAL_MPV[places.platform],
220    canBrew: brew !== null,
221    searchSource: settings.searchSource,
222  } satisfies Partial<Setup>
223}
224
225export async function checkTools($: EngineInterface, session: Session) {
226  const [mpv, ytdlp, runtime] = await Promise.all(TOOLS.map(tool => detectTool($, tool, session.settings, session.places)))
227
228  return put($, 'setup', now => ({ ...now, isChecked: true, mpv: mpv ?? null, ytdlp: ytdlp ?? null, runtime: runtime ?? null }))
229}
230
231async function installed($: EngineInterface, session: Session) {
232  const missing = missingOf(await get($, 'setup'))
233  await put($, 'setup', now => ({ ...now, isInstalling: true }))
234
235  try {
236    for (const tool of missing) {
237      await installTool($, tool, session.places)
238    }
239
240    const found = await checkTools($, session)
241    const still = missingOf(found)
242    if (still.length > 0) {
243      throw new Problem('installFailed', { tool: toolListOf(still), reason: say('stillMissing') })
244    }
245    await tell($, 'installDone', { tools: toolListOf(missing) })
246
247    return found
248  } catch (error) {
249    await tell($, error instanceof Problem ? error.key : 'failure', error instanceof Problem ? error.params : { reason: String(error) }, true)
250    throw error
251  } finally {
252    await put($, 'setup', now => ({ ...now, isInstalling: false }))
253  }
254}
255
256export function installMissing($: EngineInterface, session: Session) {
257  installing ??= installed($, session).finally(() => {
258    installing = null
259  })
260
261  return installing
262}
263
264export async function readyTools($: EngineInterface, session: Session) {
265  let found = await get($, 'setup')
266  if (!found.isChecked) {
267    found = await checkTools($, session)
268  }
269  if (missingOf(found).length > 0) {
270    await tell($, 'needsTools', { tools: toolListOf(missingOf(found)) })
271    found = await installMissing($, session)
272  }
273
274  const { mpv, ytdlp, runtime } = found
275  if (!mpv || !ytdlp || !runtime) {
276    throw new Problem('installFailed', { tool: toolListOf(missingOf(found)), reason: say('stillMissing') })
277  }
278
279  return { mpv, ytdlp, runtime }
280}
hooks/state.ts 215 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginState } from 'claude-code'
3
4import type { Cover, LoopMode, Lyrics, Notice, Phase, Setup, Tab, Track } from '../types'
5import type { Params, TextKey } from './text'
6
7export type Values = PluginState['meowzik']
8
9export type Key = keyof Values
10
11export const DEFAULT_VOLUME = 80
12const NOTICE_MS = 6000
13
14const current = atom({ plugin: 'meowzik', key: 'current' } as const, null as Track | null)
15const phase = atom({ plugin: 'meowzik', key: 'phase' } as const, 'idle' as Phase)
16const position = atom({ plugin: 'meowzik', key: 'position' } as const, 0)
17const length = atom({ plugin: 'meowzik', key: 'length' } as const, 0)
18const queue = atom({ plugin: 'meowzik', key: 'queue' } as const, [] as Track[])
19const played = atom({ plugin: 'meowzik', key: 'played' } as const, [] as Track[])
20const favorites = atom({ plugin: 'meowzik', key: 'favorites' } as const, [] as Track[])
21const loop = atom({ plugin: 'meowzik', key: 'loop' } as const, 'off' as LoopMode)
22const isShuffled = atom({ plugin: 'meowzik', key: 'isShuffled' } as const, false)
23const isAutoplay = atom({ plugin: 'meowzik', key: 'isAutoplay' } as const, false)
24const volume = atom({ plugin: 'meowzik', key: 'volume' } as const, DEFAULT_VOLUME)
25const tab = atom({ plugin: 'meowzik', key: 'tab' } as const, 'queue' as Tab)
26const page = atom({ plugin: 'meowzik', key: 'page' } as const, 0)
27const notice = atom({ plugin: 'meowzik', key: 'notice' } as const, null as Notice | null)
28const isSearching = atom({ plugin: 'meowzik', key: 'isSearching' } as const, false)
29const setup = atom(
30  { plugin: 'meowzik', key: 'setup' } as const,
31  {
32    isChecked: false,
33    isInstalling: false,
34    mpv: null,
35    ytdlp: null,
36    runtime: null,
37    folder: '',
38    logo: '',
39    manualMpv: null,
40    canBrew: false,
41    cookies: null,
42    searchSource: 'youtube',
43  } as Setup,
44)
45const cover = atom({ plugin: 'meowzik', key: 'cover' } as const, null as Cover | null)
46const lyrics = atom({ plugin: 'meowzik', key: 'lyrics' } as const, null as Lyrics | null)
47
48type Change<K extends Key> = (value: Values[K]) => Values[K]
49
50export async function get<K extends Key>($: EngineInterface, key: K): Promise<Values[K]> {
51  switch (key) {
52    case 'current':
53      return (await read($, current)) as Values[K]
54    case 'phase':
55      return (await read($, phase)) as Values[K]
56    case 'position':
57      return (await read($, position)) as Values[K]
58    case 'length':
59      return (await read($, length)) as Values[K]
60    case 'queue':
61      return (await read($, queue)) as Values[K]
62    case 'played':
63      return (await read($, played)) as Values[K]
64    case 'favorites':
65      return (await read($, favorites)) as Values[K]
66    case 'loop':
67      return (await read($, loop)) as Values[K]
68    case 'isShuffled':
69      return (await read($, isShuffled)) as Values[K]
70    case 'isAutoplay':
71      return (await read($, isAutoplay)) as Values[K]
72    case 'volume':
73      return (await read($, volume)) as Values[K]
74    case 'tab':
75      return (await read($, tab)) as Values[K]
76    case 'page':
77      return (await read($, page)) as Values[K]
78    case 'notice':
79      return (await read($, notice)) as Values[K]
80    case 'isSearching':
81      return (await read($, isSearching)) as Values[K]
82    case 'setup':
83      return (await read($, setup)) as Values[K]
84    case 'lyrics':
85      return (await read($, lyrics)) as Values[K]
86    default:
87      return (await read($, cover)) as Values[K]
88  }
89}
90
91export async function put<K extends Key>($: EngineInterface, key: K, change: Change<K>): Promise<Values[K]> {
92  const apply = change as unknown as <T>(value: T) => T
93
94  switch (key) {
95    case 'current':
96      return (await update($, current, apply)) as Values[K]
97    case 'phase':
98      return (await update($, phase, apply)) as Values[K]
99    case 'position':
100      return (await update($, position, apply)) as Values[K]
101    case 'length':
102      return (await update($, length, apply)) as Values[K]
103    case 'queue':
104      return (await update($, queue, apply)) as Values[K]
105    case 'played':
106      return (await update($, played, apply)) as Values[K]
107    case 'favorites':
108      return (await update($, favorites, apply)) as Values[K]
109    case 'loop':
110      return (await update($, loop, apply)) as Values[K]
111    case 'isShuffled':
112      return (await update($, isShuffled, apply)) as Values[K]
113    case 'isAutoplay':
114      return (await update($, isAutoplay, apply)) as Values[K]
115    case 'volume':
116      return (await update($, volume, apply)) as Values[K]
117    case 'tab':
118      return (await update($, tab, apply)) as Values[K]
119    case 'page':
120      return (await update($, page, apply)) as Values[K]
121    case 'notice':
122      return (await update($, notice, apply)) as Values[K]
123    case 'isSearching':
124      return (await update($, isSearching, apply)) as Values[K]
125    case 'setup':
126      return (await update($, setup, apply)) as Values[K]
127    case 'lyrics':
128      return (await update($, lyrics, apply)) as Values[K]
129    default:
130      return (await update($, cover, apply)) as Values[K]
131  }
132}
133
134export async function set<K extends Key>($: EngineInterface, key: K, value: Values[K]) {
135  return put($, key, () => value)
136}
137
138export async function snapshot($: EngineInterface): Promise<Values> {
139  const [
140    shownCurrent,
141    shownPhase,
142    shownPosition,
143    shownLength,
144    shownQueue,
145    shownPlayed,
146    shownFavorites,
147    shownLoop,
148    shownShuffle,
149    shownAutoplay,
150    shownVolume,
151    shownTab,
152    shownPage,
153    shownNotice,
154    shownSearching,
155    shownSetup,
156    shownCover,
157    shownLyrics,
158  ] = await Promise.all([
159    read($, current),
160    read($, phase),
161    read($, position),
162    read($, length),
163    read($, queue),
164    read($, played),
165    read($, favorites),
166    read($, loop),
167    read($, isShuffled),
168    read($, isAutoplay),
169    read($, volume),
170    read($, tab),
171    read($, page),
172    read($, notice),
173    read($, isSearching),
174    read($, setup),
175    read($, cover),
176    read($, lyrics),
177  ])
178
179  return {
180    current: shownCurrent,
181    phase: shownPhase,
182    position: shownPosition,
183    length: shownLength,
184    queue: shownQueue,
185    played: shownPlayed,
186    favorites: shownFavorites,
187    loop: shownLoop,
188    isShuffled: shownShuffle,
189    isAutoplay: shownAutoplay,
190    volume: shownVolume,
191    tab: shownTab,
192    page: shownPage,
193    notice: shownNotice,
194    isSearching: shownSearching,
195    setup: shownSetup,
196    cover: shownCover,
197    lyrics: shownLyrics,
198  }
199}
200
201async function fade($: EngineInterface, shown: Notice) {
202  const now = await read($, notice)
203  if (now !== null && JSON.stringify(now) === JSON.stringify(shown)) {
204    await update($, notice, () => null)
205  }
206}
207
208export async function tell($: EngineInterface, key: TextKey, params: Params = {}, isError = false) {
209  const shown: Notice = { key, params: Object.fromEntries(Object.entries(params).map(([name, value]) => [name, String(value)])), isError }
210  await update($, notice, () => shown)
211
212  if (!isError) {
213    $.clock.after(NOTICE_MS, () => void fade($, shown).catch(() => undefined))
214  }
215}
hooks/text.ts 224 lines
1import type { LoopMode, Notice, Track } from '../types'
2
3const TEXT = {
4  paneTitle: 'Meowzik',
5  commandDescription: 'Open the Meowzik music player, or play a song, link or playlist',
6  commandHint: '[song, link or playlist]',
7  paneOpened: 'Meowzik is open.',
8  paneWaiting: 'Meowzik opens when there is room for it: {reason}',
9
10  nowPlaying: 'Now Playing',
11  paused: 'Paused',
12  loading: 'Loading',
13  idleLead: 'Nothing is playing right now.',
14  idleHint: 'Add a song below, or ask Claude to play something.',
15  idleHintMobile: 'Ask Claude to play something.',
16  idleQueued: 'Press {play} to start the queue.',
17  untitled: 'Unknown',
18  unknownLength: 'Live / Unknown',
19  byline: '{uploader} · {length}',
20  requestedBy: 'Requested by {name}',
21  requesterYou: 'You',
22  requesterClaude: 'Claude',
23  requesterAutoplay: 'Autoplay',
24  progress: '{position} / {length}',
25  nextUp: 'Next: {title}',
26  nextRandom: 'Next: a random song from the queue',
27  nextAutoplay: 'Next: autoplay picks a related song',
28  modes: 'Loop: {loop} · Shuffle: {shuffle} · Autoplay: {autoplay} · Volume: {volume}%',
29  modeOn: 'On',
30  modeOff: 'Off',
31  loopOff: 'Off',
32  loopTrack: 'Track',
33  loopQueue: 'Queue',
34  coverAlt: 'Cover of {title}',
35  logoAlt: 'Meowzik',
36
37  volumeShort: 'Vol',
38  percent: '{value}%',
39  rewind: '-10 s',
40  forward: '+10 s',
41
42  addLabel: 'Add song',
43  addPlaceholder: 'Song name, link or playlist',
44  addSubmit: 'add',
45  searching: 'Searching',
46
47  tabQueue: 'Queue ({count})',
48  tabHistory: 'History ({count})',
49  tabFavorites: 'Favorites ({count})',
50  tabLyrics: 'Lyrics',
51  tabSetup: 'Setup',
52  lyricsIdle: 'Play a song to see its lyrics.',
53  lyricsLoading: 'Looking for the lyrics',
54  lyricsMissing: 'No lyrics found for this song.',
55  lyricsInstrumental: 'Instrumental',
56  lyricsUnreachable: 'Could not reach the lyrics service.',
57  lyricsRetry: 'Try again',
58  lyricsUnsynced: 'These lyrics are not synced to the song.',
59  lyricsHint: 'Press a line to jump to it.',
60  lyricsCredit: 'Lyrics from LRCLIB',
61  lyricsSubtitles: "Lyrics from the video's subtitles",
62  lyricsCaptions: "Lyrics from YouTube's automatic captions, which may be off",
63  emptyQueue: 'The queue is empty.',
64  emptyHistory: 'Nothing played yet.',
65  emptyFavorites: 'No favorites yet: press {favorite} while a song plays.',
66  listHint: 'Press a song to play it now.',
67  row: '{number}. {title}',
68  rowRemove: 'Remove',
69  rowUp: 'Up',
70  rowDown: 'Down',
71  rowQueue: 'Queue',
72  rowFavorite: 'Favorite',
73  rowUnfavorite: 'Unfavorite',
74  pageLabel: 'Page {page} of {pages}',
75  pagePrevious: 'Previous',
76  pageNext: 'Next',
77  clearQueue: 'Clear queue',
78  clearHistory: 'Clear history',
79
80  miniTitle: '{note} {title}',
81  setupTools: 'Tools',
82  toolReady: '{tool} {version}',
83  toolPath: '{path}',
84  toolMissing: '{tool}: not installed',
85  toolChecking: 'Checking the tools',
86  installButton: 'Install missing tools',
87  installing: 'Installing',
88  installWhere: 'Meowzik downloads what is missing into {folder}; nothing else on your system changes.',
89  installBrew: 'Meowzik installs what is missing with Homebrew.',
90  manualMpv: 'Install mpv yourself, then press Check again: {command}',
91  recheck: 'Check again',
92  setupYoutube: 'YouTube',
93  cookiesNone: 'No cookies set. If YouTube asks to confirm you are not a bot, set a cookies file in /config under meowzik.',
94  cookiesReady: 'Cookies ready: {count} youtube.com cookies.',
95  cookiesBrowser: 'Cookies read from {browser}.',
96  cookiesUnreadable: 'Cannot read the cookies file {path}.',
97  cookiesNoYoutube: '{path} holds no youtube.com cookies: export them from a tab logged into YouTube.',
98  cookiesNoSession: '{path} is not a logged-in export (missing {names}): export it again with a session open.',
99  cookiesExpired: 'The session in {path} expired ({names}): export it again.',
100  setupSearch: 'Search source: {source}',
101  sourceYoutube: 'YouTube',
102  sourceSoundcloud: 'SoundCloud',
103  setupConfig: 'Change the search source, cookies and tool paths in /config, under meowzik.',
104  setupData: 'Queue, history and favorites are kept in {folder}.',
105
106  addedOne: 'Added to queue: {title}',
107  addedMany_one: 'Added {count} song to the queue.',
108  addedMany_other: 'Added {count} songs to the queue.',
109  readingList_one: 'Reading the playlist: {count} song so far.',
110  readingList_other: 'Reading the playlist: {count} songs so far.',
111  playingNow: 'Now playing: {title}',
112  noResults: 'No results found.',
113  failure: '{reason}',
114  couldNotAdd: 'Could not play that: {reason}',
115  couldNotPlay: 'Could not play {title}: {reason}',
116  tooManyFailures: '{count} songs failed in a row, so playback stopped.',
117  botCheck: 'YouTube asked to confirm you are not a bot. Set a cookies file in /config under meowzik, or switch the search source to SoundCloud.',
118  botCheckCookies: 'YouTube refused the cookies too: export them again from a logged-in tab.',
119  spotifyUnreadable: 'Could not read that Spotify link.',
120  sunoUnreadable: 'Could not read that Suno link (only public songs play).',
121  noStream: 'no audio stream was found.',
122  nothingPlaying: 'Nothing is playing.',
123  noPrevious: 'No previous song.',
124  pausedReply: 'Paused {title}.',
125  resumedReply: 'Playing {title} again.',
126  skipped: 'Skipped {title}.',
127  noSuchSong: 'There is no song number {number} in that list.',
128  removed: 'Removed {title}.',
129  moved: 'Moved {title} to number {number}.',
130  favoriteAdded: 'Added to favorites: {title}',
131  favoriteRemoved: 'Removed from favorites: {title}',
132  stopped: 'Stopped, and the queue is clear.',
133  queueCleared: 'The queue is clear.',
134  historyCleared: 'The history is clear.',
135  loopIs: 'Loop: {mode}',
136  shuffleIs: 'Shuffle: {state}',
137  autoplayIs: 'Autoplay: {state}',
138  volumeIs: 'Volume: {volume}%',
139  seekedTo: 'Jumped to {position}.',
140  needsTools: 'Installing {tools}, which Meowzik needs to play music.',
141  stillMissing: 'it is still not found after installing.',
142  playerMissing: 'Could not start mpv: {reason}',
143  autoplaySearching: 'Autoplay is picking a related song',
144  installDone: 'Installed {tools}.',
145  installFailed: 'Could not install {tool}: {reason}',
146  installStep: '{tool}: {step}',
147  stepDownload: 'downloading',
148  stepExtract: 'unpacking',
149  stepBrew: 'installing with Homebrew',
150  homeMissing: 'Could not find your home folder, so Meowzik cannot keep its files.',
151
152  unknownAction: 'Meowzik has no {action} action here.',
153  toolNowLine: '{state}: {title} {url} ({progress})',
154  toolListLine: '{number}. {title} ({length}) {url}',
155
156  statusPlaying: 'Playing: {title}',
157  statusPaused: 'Paused: {title}',
158  statusLoading: 'Loading: {title}',
159} as const
160
161export type TextKey = keyof typeof TEXT
162
163export type Params = Record<string, string | number>
164
165type CountedKey = { [K in TextKey]: K extends `${infer Base}_other` ? Base : never }[TextKey]
166
167const PLURALS = new Intl.PluralRules('en')
168const ELLIPSIS = '…'
169
170export class Problem extends Error {
171  constructor(
172    readonly key: TextKey,
173    readonly params: Params = {},
174  ) {
175    super(key)
176  }
177}
178
179export const say = (key: TextKey, params: Params = {}) =>
180  TEXT[key].replace(/\{(\w+)\}/g, (_, name: string) => String(params[name] ?? ''))
181
182export const countKey = (key: CountedKey, count: number) => {
183  const exact = `${key}_${PLURALS.select(count)}`
184
185  return (exact in TEXT ? exact : `${key}_other`) as TextKey
186}
187
188export const isTextKey = (key: string): key is TextKey => key in TEXT
189
190export const noticeTextOf = (notice: Notice) => (isTextKey(notice.key) ? say(notice.key, notice.params) : notice.key)
191
192export const problemText = (error: unknown) =>
193  error instanceof Problem ? say(error.key, error.params) : error instanceof Error ? error.message : String(error)
194
195export const clockOf = (seconds: number) => {
196  const whole = Math.max(0, Math.floor(seconds))
197  const hours = Math.floor(whole / 3600)
198  const minutes = Math.floor((whole % 3600) / 60)
199  const rest = String(whole % 60).padStart(2, '0')
200
201  return hours > 0 ? `${hours}:${String(minutes).padStart(2, '0')}:${rest}` : `${minutes}:${rest}`
202}
203
204export const clipped = (text: string, columns: number) => {
205  const characters = [...text]
206
207  return characters.length > columns ? `${characters.slice(0, Math.max(1, columns - 1)).join('')}${ELLIPSIS}` : text
208}
209
210export const lengthOf = (seconds: number) => (seconds > 0 ? clockOf(seconds) : say('unknownLength'))
211
212export const LOOP_NAMES: Record<LoopMode, TextKey> = { off: 'loopOff', track: 'loopTrack', queue: 'loopQueue' }
213
214export const titleOf = (track: Track) => track.title || say('untitled')
215
216export const switchOf = (isOn: boolean) => say(isOn ? 'modeOn' : 'modeOff')
217
218export const modesOf = (modes: { loop: LoopMode; isShuffled: boolean; isAutoplay: boolean; volume: number }) =>
219  say('modes', {
220    loop: say(LOOP_NAMES[modes.loop]),
221    shuffle: switchOf(modes.isShuffled),
222    autoplay: switchOf(modes.isAutoplay),
223    volume: modes.volume,
224  })
hooks/tools.ts 245 lines
1import type { EngineInterface, ToolSpec } from 'claude-code'
2
3import type { LoopMode, Track } from '../types'
4import {
5  addSongs,
6  back,
7  clearList,
8  cycleLoop,
9  enqueueFrom,
10  moveInQueue,
11  playFrom,
12  removeFrom,
13  seekTo,
14  setLoop,
15  setVolume,
16  skip,
17  stop,
18  toggleAutoplay,
19  toggleFavorite,
20  togglePause,
21  toggleShuffle,
22} from './playback'
23import type { Placement, Reply } from './playback'
24import type { ListName } from './shelf'
25import { get, snapshot } from './state'
26import { clockOf, lengthOf, modesOf, noticeTextOf, say, switchOf, titleOf } from './text'
27import { openPane } from './view'
28
29type Input = Record<string, unknown>
30
31const LIST_NAMES: Record<string, ListName> = { queue: 'queue', history: 'played', favorites: 'favorites' }
32const LIST_TABS: Record<ListName, 'tabQueue' | 'tabHistory' | 'tabFavorites'> = { queue: 'tabQueue', played: 'tabHistory', favorites: 'tabFavorites' }
33const PLACEMENTS: readonly Placement[] = ['end', 'next', 'now']
34const LOOP_MODES: readonly LoopMode[] = ['off', 'track', 'queue']
35const DEFAULT_LIMIT = 20
36const STATUS_NEXT = 5
37const ON = 'on'
38
39export const TOOL_SPECS: readonly ToolSpec[] = [
40  {
41    name: 'play',
42    description:
43      'Play music in Meowzik, the music player beside the chat. Takes a song to search for, or a YouTube, SoundCloud, Spotify or Suno link, or a whole YouTube playlist, SoundCloud set, Spotify album or playlist. Songs join the queue and start at once when nothing is playing.',
44    inputSchema: {
45      type: 'object',
46      properties: {
47        query: { type: 'string', description: 'A song name (artist and title work best) or a link.' },
48        placement: {
49          type: 'string',
50          enum: PLACEMENTS,
51          description: 'end: after the queue (the default); next: right after the current song; now: play it at once.',
52        },
53      },
54      required: ['query'],
55    },
56  },
57  {
58    name: 'control',
59    description:
60      'Control Meowzik playback: pause, resume, skip, back (the previous song), stop (also clears the queue), loop (off, track or queue), shuffle, autoplay (related songs when the queue runs out), favorite the current song, volume (0 to 100), seek (seconds), or open the player pane.',
61    inputSchema: {
62      type: 'object',
63      properties: {
64        action: {
65          type: 'string',
66          enum: ['pause', 'resume', 'skip', 'back', 'stop', 'loop', 'shuffle', 'autoplay', 'favorite', 'volume', 'seek', 'open'],
67        },
68        value: {
69          type: ['string', 'number'],
70          description:
71            'loop: off, track or queue (absent: the next mode); shuffle and autoplay: on or off (absent: toggle); volume: 0 to 100; seek: seconds from the start.',
72        },
73      },
74      required: ['action'],
75    },
76  },
77  {
78    name: 'status',
79    description: 'What Meowzik is playing now, where it is in the song, the loop, shuffle, autoplay and volume settings, and the next songs in the queue.',
80    inputSchema: { type: 'object', properties: {} },
81  },
82  {
83    name: 'list',
84    description:
85      'Read or change a Meowzik list: the queue, the history (most recent first) or the favorites (most recent first). show lists songs with their numbers; play plays one now; queue adds one to the end of the queue; remove takes one out; move moves a queued song to another number; clear empties the queue or the history.',
86    inputSchema: {
87      type: 'object',
88      properties: {
89        list: { type: 'string', enum: Object.keys(LIST_NAMES) },
90        action: { type: 'string', enum: ['show', 'play', 'queue', 'remove', 'move', 'clear'] },
91        number: { type: 'number', description: 'The song number, as show lists it (1 is the first).' },
92        to: { type: 'number', description: 'move: the number the song should end up at.' },
93        offset: { type: 'number', description: 'show: how many songs to skip from the start (default 0).' },
94        limit: { type: 'number', description: `show: how many songs to list (default ${DEFAULT_LIMIT}).` },
95      },
96      required: ['list', 'action'],
97    },
98  },
99]
100
101const spoken = (reply: Reply) => say(reply.key, reply.params)
102
103const textOf = (input: Input, key: string) => {
104  const value = input[key]
105
106  return typeof value === 'string' ? value.trim() : typeof value === 'number' ? String(value) : ''
107}
108
109const numberOf = (input: Input, key: string) => {
110  const value = Number(input[key])
111
112  return input[key] === undefined || input[key] === '' || !Number.isFinite(value) ? null : value
113}
114
115const lineOf = (track: Track, number: number) =>
116  say('toolListLine', { number, title: titleOf(track), length: lengthOf(track.duration), url: track.url }).trim()
117
118const unknown = (action: string) => say('unknownAction', { action })
119
120const ordered = (items: readonly Track[], list: ListName) => (list === 'queue' ? [...items] : [...items].reverse())
121
122const indexIn = (count: number, list: ListName, number: number) => (list === 'queue' ? number - 1 : count - number)
123
124const wantsToggle = (value: string, isOn: boolean) => value === '' || (value === ON) !== isOn
125
126export async function statusText($: EngineInterface) {
127  const view = await snapshot($)
128  const track = view.current
129  const playing = track
130    ? say('toolNowLine', {
131        state: say(view.phase === 'paused' ? 'paused' : view.phase === 'loading' ? 'loading' : 'nowPlaying'),
132        title: titleOf(track),
133        url: track.url,
134        progress: say('progress', { position: clockOf(view.position), length: lengthOf(view.length) }),
135      })
136    : say('nothingPlaying')
137  const next = view.queue.slice(0, STATUS_NEXT).map((item, index) => lineOf(item, index + 1))
138  const problem = view.notice?.isError ? [noticeTextOf(view.notice)] : []
139
140  return [playing, ...problem, modesOf(view), say('tabQueue', { count: view.queue.length }), ...next].join('\n')
141}
142
143async function shuffleTo($: EngineInterface, value: string) {
144  const isOn = await get($, 'isShuffled')
145
146  return wantsToggle(value, isOn) ? spoken(await toggleShuffle($)) : say('shuffleIs', { state: switchOf(isOn) })
147}
148
149async function autoplayTo($: EngineInterface, value: string) {
150  const isOn = await get($, 'isAutoplay')
151
152  return wantsToggle(value, isOn) ? spoken(await toggleAutoplay($)) : say('autoplayIs', { state: switchOf(isOn) })
153}
154
155async function controlled($: EngineInterface, input: Input) {
156  const action = textOf(input, 'action')
157  const value = textOf(input, 'value').toLowerCase()
158  const shown = await get($, 'phase')
159
160  switch (action) {
161    case 'pause':
162      return shown === 'playing' ? spoken(await togglePause($)) : statusText($)
163    case 'resume':
164      return shown === 'paused' || shown === 'idle' ? spoken(await togglePause($)) : statusText($)
165    case 'skip':
166      return spoken(await skip($))
167    case 'back':
168      return spoken(await back($))
169    case 'stop':
170      return spoken(await stop($))
171    case 'loop':
172      return spoken(LOOP_MODES.includes(value as LoopMode) ? await setLoop($, value as LoopMode) : await cycleLoop($))
173    case 'shuffle':
174      return shuffleTo($, value)
175    case 'autoplay':
176      return autoplayTo($, value)
177    case 'favorite':
178      return spoken(await toggleFavorite($))
179    case 'volume':
180      return spoken(await setVolume($, numberOf(input, 'value') ?? (await get($, 'volume'))))
181    case 'seek':
182      return spoken(await seekTo($, numberOf(input, 'value') ?? 0))
183    case 'open':
184      return openPane($)
185    default:
186      return unknown(action)
187  }
188}
189
190async function listed($: EngineInterface, input: Input) {
191  const list = LIST_NAMES[textOf(input, 'list')]
192  const action = textOf(input, 'action')
193  if (!list) {
194    return unknown(textOf(input, 'list'))
195  }
196
197  const items = await get($, list)
198  const number = numberOf(input, 'number') ?? 0
199  const index = indexIn(items.length, list, number)
200
201  switch (action) {
202    case 'show': {
203      const offset = Math.max(0, numberOf(input, 'offset') ?? 0)
204      const limit = Math.max(1, numberOf(input, 'limit') ?? DEFAULT_LIMIT)
205      const lines = ordered(items, list)
206        .slice(offset, offset + limit)
207        .map((track, at) => lineOf(track, offset + at + 1))
208
209      return [say(LIST_TABS[list], { count: items.length }), ...lines].join('\n')
210    }
211    case 'play':
212      return spoken(await playFrom($, list, index))
213    case 'queue':
214      return spoken(await enqueueFrom($, list, index))
215    case 'remove':
216      return spoken(await removeFrom($, list, index))
217    case 'move':
218      return list === 'queue' ? spoken(await moveInQueue($, index, (numberOf(input, 'to') ?? number) - 1)) : unknown(action)
219    case 'clear':
220      return list === 'favorites' ? unknown(action) : spoken(await clearList($, list))
221    default:
222      return unknown(action)
223  }
224}
225
226async function played($: EngineInterface, input: Input) {
227  const placement = PLACEMENTS.find(option => option === textOf(input, 'placement')) ?? 'end'
228  const reply = await addSongs($, textOf(input, 'query'), 'claude', placement)
229  await openPane($)
230
231  return spoken(reply)
232}
233
234export async function answerTool($: EngineInterface, name: string, input: Input) {
235  switch (name) {
236    case 'play':
237      return played($, input)
238    case 'control':
239      return controlled($, input)
240    case 'status':
241      return statusText($)
242    default:
243      return listed($, input)
244  }
245}
hooks/view.tsx 581 lines
1import type { EngineInterface, RenderInput } from 'claude-code'
2
3import type { Lyrics, LyricsSource, MiniPlayer, Phase, Setup, Tab, Track } from '../types'
4import type { Session } from './config'
5import { pictureSvgOf } from './cover'
6import { ICONS } from './icons'
7import {
8  BAND_CONTROLS_COLUMNS,
9  BAND_SEEK_COLUMNS,
10  BAND_TITLE_MIN_COLUMNS,
11  barOf,
12  COVER_COLUMNS,
13  COVER_ROWS,
14  COVER_SVG_SIZE,
15  GAP,
16  LYRICS_AFTER,
17  LYRICS_BEFORE,
18  LYRICS_LEAD_SECONDS,
19  PAGE_SIZE,
20  PANE_ROWS,
21  SEEK_BUTTON_SECONDS,
22  SEEK_ROWS,
23  SIDE_GAP,
24  TEXT_BAR_COLUMNS,
25  VOLUME_COLUMNS,
26  VOLUME_MAX,
27  VOLUME_STEP,
28} from './layout'
29import { lineAt } from './lyrics'
30import { PALETTE } from './palette'
31import {
32  addSongs,
33  back,
34  changeVolume,
35  clearList,
36  cycleLoop,
37  enqueueFrom,
38  moveInQueue,
39  playFrom,
40  refreshLyrics,
41  removeFrom,
42  seekBy,
43  seekTo,
44  skip,
45  stop,
46  toggleAutoplay,
47  toggleFavorite,
48  togglePause,
49  toggleShuffle,
50} from './playback'
51import { isFavorite, pageOf } from './queue'
52import { checkTools, installMissing, missingOf, toolLabelOf, TOOLS } from './setup'
53import type { ListName } from './shelf'
54import type { SliderProps } from './slider'
55import { set, snapshot } from './state'
56import type { Values } from './state'
57import { clipped, clockOf, lengthOf, noticeTextOf, say, titleOf } from './text'
58import type { TextKey } from './text'
59
60export const PANE = 'meowzik'
61export const SEEKBAR = 'seekbar'
62export const MINI_SEEKBAR = 'miniSeekbar'
63export const VOLUME = 'volume'
64
65type Pane = RenderInput<'Pane'>
66
67type Band = RenderInput<'AbovePrompt'>
68
69const LYRICS_CREDITS: Record<LyricsSource, TextKey> = { lrclib: 'lyricsCredit', subtitles: 'lyricsSubtitles', captions: 'lyricsCaptions' }
70
71const REQUESTERS: Record<Track['requestedBy'], TextKey> = { you: 'requesterYou', claude: 'requesterClaude', autoplay: 'requesterAutoplay' }
72const HEADINGS: Record<Exclude<Phase, 'idle'>, TextKey> = { playing: 'nowPlaying', paused: 'paused', loading: 'loading' }
73const TABS: readonly Tab[] = ['queue', 'history', 'favorites', 'lyrics', 'setup']
74const TAB_KEYS: Record<Tab, string> = { queue: '1', history: '2', favorites: '3', lyrics: '4', setup: '5' }
75const TAB_LISTS: Record<Exclude<Tab, 'lyrics' | 'setup'>, ListName> = { queue: 'queue', history: 'played', favorites: 'favorites' }
76const EMPTY: Record<ListName, TextKey> = { queue: 'emptyQueue', played: 'emptyHistory', favorites: 'emptyFavorites' }
77const logos = new Map<string, string>()
78
79const toneOf = (isError: boolean) => (isError ? { color: PALETTE.error } : { dimColor: true })
80
81const variantOf = (isOn: boolean) => (isOn ? 'primary' : 'secondary')
82
83const nextLine = (view: Values) => {
84  const [next] = view.queue
85
86  if (view.isShuffled && view.queue.length > 1) {
87    return say('nextRandom')
88  }
89  if (next) {
90    return say('nextUp', { title: titleOf(next) })
91  }
92
93  return view.isAutoplay ? say('nextAutoplay') : null
94}
95
96export async function openPane($: EngineInterface) {
97  const opened = await $.ui.open({ id: PANE, title: say('paneTitle'), rows: PANE_ROWS })
98
99  return opened.isPlaced ? say('paneOpened') : say('paneWaiting', { reason: opened.reason })
100}
101
102async function logoOf($: EngineInterface, path: string) {
103  const cached = logos.get(path)
104  if (cached !== undefined || !path) {
105    return cached ?? null
106  }
107
108  const { base64 } = await $.fs.read(path, { as: 'bytes' })
109  logos.set(path, base64)
110
111  return base64
112}
113
114async function openTab($: EngineInterface, tab: Tab) {
115  await set($, 'tab', tab)
116  await set($, 'page', 0)
117}
118
119async function turnPage($: EngineInterface, page: number) {
120  await set($, 'page', page)
121}
122
123async function artwork($: EngineInterface, e: Pane, view: Values) {
124  const alt = view.current ? say('coverAlt', { title: titleOf(view.current) }) : say('logoAlt')
125  const art = view.current && view.cover?.id === view.current.id ? view.cover : null
126
127  if (e.surface === 'terminal') {
128    const { Image } = $.ui.resolve(e)
129    const file = art?.png ?? view.setup.logo
130
131    return file ? (
132      <Image key="cover" source={{ file, format: 'png', generation: art?.generation ?? 0 }} columns={COVER_COLUMNS} rows={COVER_ROWS} alt={alt} />
133    ) : null
134  }
135
136  const { Svg } = $.ui.resolve(e)
137  const logo = art?.jpeg ? null : await logoOf($, view.setup.logo).catch(() => null)
138  const source = art?.jpeg ? pictureSvgOf(art.jpeg, 'image/jpeg') : logo ? pictureSvgOf(logo, 'image/png') : null
139
140  return source ? <Svg source={source} alt={alt} width={COVER_SVG_SIZE} height={COVER_SVG_SIZE} /> : null
141}
142
143function heading($: EngineInterface, e: Pane, view: Values) {
144  const { Box, Link, Text } = $.ui.resolve(e)
145
146  if (!view.current) {
147    const lead: TextKey = view.phase === 'loading' ? 'autoplaySearching' : 'idleLead'
148    const hint: TextKey = view.queue.length > 0 ? 'idleQueued' : e.surface === 'mobile' ? 'idleHintMobile' : 'idleHint'
149
150    return (
151      <Box flexDirection="column">
152        <Text weight="bold" size="heading">
153          {say('paneTitle')}
154        </Text>
155        <Text>{say(lead)}</Text>
156        {view.phase === 'idle' && <Text dimColor>{say(hint, { play: ICONS.play })}</Text>}
157      </Box>
158    )
159  }
160
161  const track = view.current
162  const total = lengthOf(view.length || track.duration)
163
164  return (
165    <Box flexDirection="column">
166      <Text weight="bold" color={PALETTE.accent}>
167        {say(HEADINGS[view.phase === 'idle' ? 'loading' : view.phase])}
168      </Text>
169      {track.url ? <Link href={track.url} label={titleOf(track)} /> : <Text weight="bold">{titleOf(track)}</Text>}
170      <Text dimColor>{track.uploader ? say('byline', { uploader: track.uploader, length: total }) : total}</Text>
171      <Text dimColor>{say('requestedBy', { name: say(REQUESTERS[track.requestedBy]) })}</Text>
172    </Box>
173  )
174}
175
176const isLive = (view: Values) => view.phase === 'playing' || view.phase === 'paused'
177
178const seekPropsOf = (view: Values, layout: SliderProps['layout']): SliderProps => ({
179  mode: 'seek',
180  layout,
181  fraction: view.length > 0 ? view.position / view.length : 0,
182  total: view.length,
183  label: '',
184  endLabel: lengthOf(view.length),
185  isEnabled: isLive(view) && view.length > 0,
186})
187
188function progress($: EngineInterface, e: Pane, view: Values) {
189  const { Box, Button, Text } = $.ui.resolve(e)
190
191  if (e.surface === 'terminal' || e.surface === 'desktop') {
192    const { Client } = $.ui.resolve(e)
193
194    return <Client key={SEEKBAR} module="./slider.tsx" props={seekPropsOf(view, 'stacked')} width={e.props.bodyColumns} height={SEEK_ROWS} />
195  }
196
197  const bar = barOf(TEXT_BAR_COLUMNS, view.position, view.length)
198  const times = say('progress', { position: clockOf(view.position), length: lengthOf(view.length) })
199
200  return (
201    <Box flexWrap="wrap" columnGap={GAP} alignItems="center">
202      <Text>
203        <Text color={PALETTE.barDone}>{bar.done}</Text>
204        <Text color={PALETTE.barLeft}>{bar.left}</Text>
205      </Text>
206      <Text tabular dimColor>
207        {times}
208      </Text>
209      {isLive(view) && <Button key="rewind" dimColor label={say('rewind')} onPress={() => void seekBy($, -SEEK_BUTTON_SECONDS)} />}
210      {isLive(view) && <Button key="forward" dimColor label={say('forward')} onPress={() => void seekBy($, SEEK_BUTTON_SECONDS)} />}
211    </Box>
212  )
213}
214
215function controls($: EngineInterface, e: Pane, view: Values) {
216  const { Box, Button } = $.ui.resolve(e)
217  const isLiked = view.current !== null && isFavorite(view.favorites, view.current.id)
218  const isSounding = view.phase === 'playing' || view.phase === 'loading'
219  const toggle = (key: string, hotkey: string, icon: string, isOn: boolean, onPress: () => void) => (
220    <Button key={key} hotkey={hotkey} variant={variantOf(isOn)} dimColor={!isOn} label={icon} onPress={onPress} />
221  )
222
223  return (
224    <Box flexWrap="wrap" columnGap={SIDE_GAP} rowGap={GAP} alignItems="center">
225      <Box columnGap={GAP}>
226        <Button key="back" hotkey="b" label={ICONS.back} onPress={() => void back($)} />
227        <Button key="pause" hotkey="p" variant="primary" label={isSounding ? ICONS.pause : ICONS.play} onPress={() => void togglePause($)} />
228        <Button key="skip" hotkey="n" label={ICONS.next} onPress={() => void skip($)} />
229        <Button key="stop" hotkey="x" label={ICONS.stop} onPress={() => void stop($)} />
230      </Box>
231      <Box columnGap={GAP}>
232        {toggle('shuffle', 's', ICONS.shuffle, view.isShuffled, () => void toggleShuffle($))}
233        {toggle('loop', 'l', view.loop === 'track' ? ICONS.loopTrack : ICONS.loop, view.loop !== 'off', () => void cycleLoop($))}
234        {toggle('autoplay', 'a', ICONS.autoplay, view.isAutoplay, () => void toggleAutoplay($))}
235        {toggle('favorite', 'f', isLiked ? ICONS.favorited : ICONS.favorite, isLiked, () => void toggleFavorite($))}
236      </Box>
237    </Box>
238  )
239}
240
241function volumeControl($: EngineInterface, e: Pane, view: Values) {
242  const { Box, Button, Text } = $.ui.resolve(e)
243
244  if (e.surface === 'terminal' || e.surface === 'desktop') {
245    const { Client } = $.ui.resolve(e)
246    const props: SliderProps = {
247      mode: 'volume',
248      layout: 'inline',
249      fraction: view.volume / VOLUME_MAX,
250      total: VOLUME_MAX,
251      label: say('volumeShort'),
252      endLabel: '',
253      isEnabled: true,
254    }
255
256    return <Client key={VOLUME} module="./slider.tsx" props={props} width={Math.min(VOLUME_COLUMNS, e.props.bodyColumns)} height={1} />
257  }
258
259  return (
260    <Box columnGap={GAP} alignItems="center">
261      <Button key="volumeDown" hotkey="j" dimColor label={ICONS.quieter} onPress={() => void changeVolume($, -VOLUME_STEP)} />
262      <Text tabular dimColor>
263        {say('volumeIs', { volume: view.volume })}
264      </Text>
265      <Button key="volumeUp" hotkey="k" dimColor label={ICONS.louder} onPress={() => void changeVolume($, VOLUME_STEP)} />
266    </Box>
267  )
268}
269
270function addField($: EngineInterface, e: Pane, view: Values) {
271  if (e.surface === 'mobile') {
272    return null
273  }
274
275  const { Input, Text } = $.ui.resolve(e)
276
277  return view.isSearching ? (
278    <Text dimColor>{say('searching')}</Text>
279  ) : (
280    <Input
281      key="add"
282      label={say('addLabel')}
283      value=""
284      placeholder={say('addPlaceholder')}
285      submitLabel={say('addSubmit')}
286      onSubmit={value => {
287        if (value.trim()) {
288          void addSongs($, value, 'you', 'end')
289        }
290      }}
291    />
292  )
293}
294
295function tabs($: EngineInterface, e: Pane, view: Values) {
296  const { Box, Button } = $.ui.resolve(e)
297  const labels: Record<Tab, string> = {
298    queue: say('tabQueue', { count: view.queue.length }),
299    history: say('tabHistory', { count: view.played.length }),
300    favorites: say('tabFavorites', { count: view.favorites.length }),
301    lyrics: say('tabLyrics'),
302    setup: say('tabSetup'),
303  }
304
305  return (
306    <Box flexWrap="wrap" columnGap={GAP} rowGap={GAP}>
307      {TABS.map(tab => (
308        <Button
309          key={`tab:${tab}`}
310          hotkey={TAB_KEYS[tab]}
311          variant={variantOf(view.tab === tab)}
312          dimColor={view.tab !== tab}
313          label={labels[tab]}
314          onPress={() => void openTab($, tab)}
315        />
316      ))}
317    </Box>
318  )
319}
320
321function rowActions($: EngineInterface, e: Pane, view: Values, list: ListName, index: number, track: Track) {
322  const { Button } = $.ui.resolve(e)
323  const action = (name: string, label: TextKey, onPress: () => void) => (
324    <Button key={`${list}:${name}:${index}`} plain dimColor label={say(label)} onPress={onPress} />
325  )
326
327  if (list === 'queue') {
328    return [
329      index > 0 && action('up', 'rowUp', () => void moveInQueue($, index, index - 1)),
330      index < view.queue.length - 1 && action('down', 'rowDown', () => void moveInQueue($, index, index + 1)),
331      action('remove', 'rowRemove', () => void removeFrom($, 'queue', index)),
332    ]
333  }
334
335  return [
336    action('queue', 'rowQueue', () => void enqueueFrom($, list, index)),
337    list === 'played'
338      ? action('favorite', isFavorite(view.favorites, track.id) ? 'rowUnfavorite' : 'rowFavorite', () => void toggleFavorite($, track))
339      : action('remove', 'rowRemove', () => void removeFrom($, 'favorites', index)),
340  ]
341}
342
343function listView($: EngineInterface, e: Pane, view: Values, list: ListName) {
344  const { Box, Button, Text } = $.ui.resolve(e)
345  const stored = view[list]
346  const items = list === 'queue' ? stored : [...stored].reverse()
347  const storedIndexOf = (shown: number) => (list === 'queue' ? shown : stored.length - 1 - shown)
348
349  if (items.length === 0) {
350    return <Text dimColor>{say(EMPTY[list], { favorite: ICONS.favorite })}</Text>
351  }
352
353  const { page, pages, rows } = pageOf(items, view.page, PAGE_SIZE)
354
355  return (
356    <Box flexDirection="column">
357      <Text dimColor>{say('listHint')}</Text>
358      {rows.map(({ item, index }) => (
359        <Box key={`row:${list}:${index}`} columnGap={SIDE_GAP} alignItems="center">
360          <Button key={`${list}:play:${index}`} plain onPress={() => void playFrom($, list, storedIndexOf(index))}>
361            {say('row', { number: index + 1, title: titleOf(item) })} <Text dimColor>{lengthOf(item.duration)}</Text>
362          </Button>
363          {rowActions($, e, view, list, storedIndexOf(index), item)}
364        </Box>
365      ))}
366      {pages > 1 && (
367        <Box columnGap={GAP} alignItems="center">
368          {page > 0 && <Button key="page:previous" dimColor label={say('pagePrevious')} onPress={() => void turnPage($, page - 1)} />}
369          <Text dimColor>{say('pageLabel', { page: page + 1, pages })}</Text>
370          {page < pages - 1 && <Button key="page:next" dimColor label={say('pageNext')} onPress={() => void turnPage($, page + 1)} />}
371        </Box>
372      )}
373      {list !== 'favorites' && (
374        <Box>
375          <Button
376            key={`clear:${list}`}
377            dimColor
378            label={say(list === 'queue' ? 'clearQueue' : 'clearHistory')}
379            onPress={() => void clearList($, list)}
380          />
381        </Box>
382      )}
383    </Box>
384  )
385}
386
387function toolRows($: EngineInterface, e: Pane, tools: Setup) {
388  const { Box, Text } = $.ui.resolve(e)
389
390  if (!tools.isChecked) {
391    return <Text dimColor>{say('toolChecking')}</Text>
392  }
393
394  return (
395    <Box flexDirection="column">
396      {TOOLS.map(tool => {
397        const found = tools[tool]
398
399        return found ? (
400          <Box key={`tool:${tool}`} columnGap={GAP} flexWrap="wrap">
401            <Text>{say('toolReady', { tool: toolLabelOf(tool, found), version: found.version })}</Text>
402            <Text dimColor>{say('toolPath', { path: found.path })}</Text>
403          </Box>
404        ) : (
405          <Text key={`tool:${tool}`} color={PALETTE.error}>
406            {say('toolMissing', { tool: toolLabelOf(tool, null) })}
407          </Text>
408        )
409      })}
410    </Box>
411  )
412}
413
414function setupView($: EngineInterface, e: Pane, view: Values, session: Session | null) {
415  const { Box, Button, Text } = $.ui.resolve(e)
416  const tools = view.setup
417  const isMissing = tools.isChecked && missingOf(tools).length > 0
418
419  return (
420    <Box flexDirection="column" rowGap={GAP}>
421      <Text weight="bold">{say('setupTools')}</Text>
422      {toolRows($, e, tools)}
423      {isMissing && <Text dimColor>{tools.canBrew ? say('installBrew') : say('installWhere', { folder: tools.folder })}</Text>}
424      {isMissing && tools.mpv === null && tools.manualMpv !== null && <Text dimColor>{say('manualMpv', { command: tools.manualMpv })}</Text>}
425      {session && (
426        <Box columnGap={GAP} flexWrap="wrap">
427          {tools.isInstalling ? (
428            <Text dimColor>{say('installing')}</Text>
429          ) : (
430            isMissing && (
431              <Button key="install" variant="primary" label={say('installButton')} onPress={() => void installMissing($, session).catch(() => null)} />
432            )
433          )}
434          <Button key="recheck" dimColor label={say('recheck')} onPress={() => void checkTools($, session)} />
435        </Box>
436      )}
437      <Text weight="bold">{say('setupYoutube')}</Text>
438      {tools.cookies && <Text {...toneOf(tools.cookies.isError)}>{noticeTextOf(tools.cookies)}</Text>}
439      <Text>{say('setupSearch', { source: say(tools.searchSource === 'soundcloud' ? 'sourceSoundcloud' : 'sourceYoutube') })}</Text>
440      <Text dimColor>{say('setupConfig')}</Text>
441      {tools.folder && <Text dimColor>{say('setupData', { folder: tools.folder })}</Text>}
442    </Box>
443  )
444}
445
446function lyricLines($: EngineInterface, e: Pane, view: Values, lyrics: Lyrics) {
447  const { Box, Button, Text } = $.ui.resolve(e)
448  const shown = lineAt(lyrics.lines, view.position + LYRICS_LEAD_SECONDS)
449  const first = Math.max(0, shown - LYRICS_BEFORE)
450  const rows = lyrics.lines.slice(first, Math.max(shown, 0) + LYRICS_AFTER + 1)
451
452  return (
453    <Box flexDirection="column">
454      <Text dimColor>{say('lyricsHint')}</Text>
455      {rows.map((line, offset) => {
456        const index = first + offset
457        const isNow = index === shown
458
459        return (
460          <Button key={`lyric:${index}`} plain onPress={() => void seekTo($, line.at)}>
461            <Text weight={isNow ? 'bold' : 'regular'} {...(isNow ? { color: PALETTE.accent } : { dimColor: true })}>
462              {line.text || ICONS.note}
463            </Text>
464          </Button>
465        )
466      })}
467    </Box>
468  )
469}
470
471function lyricsView($: EngineInterface, e: Pane, view: Values) {
472  const { Box, Button, Text } = $.ui.resolve(e)
473  const track = view.current
474  const lyrics = track && view.lyrics?.trackId === track.id ? view.lyrics : null
475
476  if (!track) {
477    return <Text dimColor>{say('lyricsIdle')}</Text>
478  }
479  if (!lyrics || lyrics.status === 'loading') {
480    return <Text dimColor>{say('lyricsLoading')}</Text>
481  }
482  if (lyrics.status === 'unreachable') {
483    return (
484      <Box columnGap={GAP} alignItems="center" flexWrap="wrap">
485        <Text color={PALETTE.error}>{say('lyricsUnreachable')}</Text>
486        <Button key="lyrics:retry" dimColor label={say('lyricsRetry')} onPress={() => void refreshLyrics($, track)} />
487      </Box>
488    )
489  }
490  if (lyrics.status !== 'found') {
491    return <Text dimColor>{say(lyrics.status === 'instrumental' ? 'lyricsInstrumental' : 'lyricsMissing')}</Text>
492  }
493
494  return (
495    <Box flexDirection="column" rowGap={GAP}>
496      {lyrics.isSynced ? (
497        lyricLines($, e, view, lyrics)
498      ) : (
499        <Box flexDirection="column">
500          <Text dimColor>{say('lyricsUnsynced')}</Text>
501          {lyrics.lines.map((line, index) => (
502            <Text key={`lyric:${index}`}>{line.text || ' '}</Text>
503          ))}
504        </Box>
505      )}
506      {lyrics.source && <Text dimColor>{say(LYRICS_CREDITS[lyrics.source])}</Text>}
507    </Box>
508  )
509}
510
511function miniSeek($: EngineInterface, e: Band, view: Values, columns: number) {
512  if (e.surface === 'terminal' || e.surface === 'desktop') {
513    const { Client } = $.ui.resolve(e)
514
515    return <Client key={MINI_SEEKBAR} module="./slider.tsx" props={seekPropsOf(view, 'inline')} width={columns} height={1} />
516  }
517
518  const { Text } = $.ui.resolve(e)
519
520  return (
521    <Text tabular dimColor>
522      {say('progress', { position: clockOf(view.position), length: lengthOf(view.length) })}
523    </Text>
524  )
525}
526
527export async function drawBand($: EngineInterface, e: Band, miniPlayer: MiniPlayer) {
528  if (e.props.hasSurvey || miniPlayer === 'off') {
529    return null
530  }
531
532  const view = await snapshot($)
533  const isActive = view.current !== null || view.phase !== 'idle'
534  if (miniPlayer === 'playing' && !isActive) {
535    return null
536  }
537
538  const { Box, Button } = $.ui.resolve(e)
539  const isSounding = view.phase === 'playing' || view.phase === 'loading'
540  const seekColumns = view.current ? Math.min(BAND_SEEK_COLUMNS, Math.floor(e.props.bodyColumns / 2)) : 0
541  const titleColumns = Math.max(BAND_TITLE_MIN_COLUMNS, e.props.bodyColumns - BAND_CONTROLS_COLUMNS - seekColumns)
542  const title = say('miniTitle', { note: ICONS.note, title: view.current ? titleOf(view.current) : say('paneTitle') })
543
544  return (
545    <Box flexDirection="row" columnGap={GAP} alignItems="center">
546      <Button key="mini:back" plain label={ICONS.back} onPress={() => void back($)} />
547      <Button key="mini:pause" plain label={isSounding ? ICONS.pause : ICONS.play} onPress={() => void togglePause($)} />
548      <Button key="mini:skip" plain label={ICONS.next} onPress={() => void skip($)} />
549      <Button key="mini:stop" plain label={ICONS.stop} onPress={() => void stop($)} />
550      <Button key="mini:open" plain label={clipped(title, titleColumns)} onPress={() => void openPane($)} />
551      {view.current && miniSeek($, e, view, seekColumns)}
552    </Box>
553  )
554}
555
556export async function drawPane($: EngineInterface, e: Pane, session: Session | null) {
557  const { Box, Text } = $.ui.resolve(e)
558  const view = await snapshot($)
559  const next = view.current ? nextLine(view) : null
560
561  return (
562    <Box flexDirection="column" rowGap={GAP}>
563      <Box columnGap={SIDE_GAP} alignItems="flex-start">
564        {await artwork($, e, view)}
565        {heading($, e, view)}
566      </Box>
567      {view.current && progress($, e, view)}
568      {controls($, e, view)}
569      {volumeControl($, e, view)}
570      {next && <Text dimColor>{next}</Text>}
571      {addField($, e, view)}
572      {view.notice && <Text {...toneOf(view.notice.isError)}>{noticeTextOf(view.notice)}</Text>}
573      {tabs($, e, view)}
574      {view.tab === 'setup'
575        ? setupView($, e, view, session)
576        : view.tab === 'lyrics'
577          ? lyricsView($, e, view)
578          : listView($, e, view, TAB_LISTS[view.tab])}
579    </Box>
580  )
581}
hooks/ytdlp.ts 167 lines
1import type { EngineInterface } from 'claude-code'
2
3import type { Notice } from '../types'
4import type { Places, Settings } from './config'
5import { joined } from './config'
6import { checkCookies, holdsSession, netscapeOf, youtubeCookiesOf } from './cookies'
7import type { CookieCheck } from './cookies'
8import { isBotCheck, reasonOf } from './sources'
9import { Problem } from './text'
10import type { TextKey } from './text'
11
12export type Ytdlp = { settings: Settings; places: Places; path: string; runtime: string[] }
13
14export type CookieState =
15  | { kind: 'none' }
16  | { kind: 'browser'; browser: string }
17  | { kind: 'file'; path: string; count: number; check: CookieCheck }
18  | { kind: 'unreadable'; path: string }
19
20type Attempt = { lines: string[]; stderr: string; code: number | null; usedCookies: boolean }
21
22const SECONDS = 1000
23const YOUTUBE_CLIENTS = ['--extractor-args', 'youtube:player_client=default,mweb']
24const COOKIE_PROBLEMS: Record<Exclude<CookieCheck, { isUsable: true }>['reason'], TextKey> = {
25  noYoutube: 'cookiesNoYoutube',
26  noSession: 'cookiesNoSession',
27  expired: 'cookiesExpired',
28}
29const busyCopies = new Set<number>()
30
31let mustUseCookies = false
32
33export async function cookieStateOf($: EngineInterface, settings: Settings, places: Places): Promise<CookieState> {
34  if (settings.cookiesFromBrowser) {
35    return { kind: 'browser', browser: settings.cookiesFromBrowser }
36  }
37  if (!settings.cookies) {
38    return { kind: 'none' }
39  }
40
41  try {
42    const exported = await $.fs.read(settings.cookies)
43    const cookies = youtubeCookiesOf(exported)
44    const check = checkCookies(cookies, Math.floor((await $.clock.now()) / SECONDS))
45
46    if (check.isUsable) {
47      const source = await $.fs.stat(settings.cookies)
48      const jar = (await $.fs.exists(places.cookieJar)) ? await $.fs.stat(places.cookieJar) : null
49      if (!jar || source.mtimeMs > jar.mtimeMs) {
50        await $.fs.write(places.cookieJar, netscapeOf(cookies))
51      }
52    }
53
54    return { kind: 'file', path: settings.cookies, count: cookies.length, check }
55  } catch {
56    return { kind: 'unreadable', path: settings.cookies }
57  }
58}
59
60export const cookieNoticeOf = (cookies: CookieState): Notice => {
61  const notice = (key: TextKey, params: Record<string, string> = {}, isError = false): Notice => ({ key, params, isError })
62
63  switch (cookies.kind) {
64    case 'none':
65      return notice('cookiesNone')
66    case 'browser':
67      return notice('cookiesBrowser', { browser: cookies.browser })
68    case 'unreadable':
69      return notice('cookiesUnreadable', { path: cookies.path }, true)
70    case 'file':
71      return cookies.check.isUsable
72        ? notice('cookiesReady', { count: String(cookies.count) })
73        : notice(COOKIE_PROBLEMS[cookies.check.reason], { path: cookies.path, names: cookies.check.names.join(', ') }, true)
74  }
75}
76
77const copyPathOf = (places: Places, slot: number) => joined(places.platform, places.data, 'run', `cookies-${slot}.txt`)
78
79async function hasCookies($: EngineInterface, ytdlp: Ytdlp) {
80  return Boolean(ytdlp.settings.cookiesFromBrowser) || (Boolean(ytdlp.settings.cookies) && (await $.fs.exists(ytdlp.places.cookieJar)))
81}
82
83async function attempt($: EngineInterface, ytdlp: Ytdlp, args: readonly string[], cookieArgs: string[], onLine?: (line: string) => void) {
84  const child = $.process.spawn({
85    argv: [ytdlp.path, ...ytdlp.runtime, ...YOUTUBE_CLIENTS, ...cookieArgs, ...ytdlp.settings.ytdlpArgs, '--no-warnings', ...args],
86  })
87  const lines: string[] = []
88  const take = (line: string) => {
89    lines.push(line)
90    onLine?.(line)
91  }
92  let pending = ''
93  let stderr = ''
94
95  for await (const { stream, text } of child) {
96    if (stream === 'stderr') {
97      stderr += text
98      continue
99    }
100
101    const parts = (pending + text).split(/\r?\n/)
102    pending = parts.pop() ?? ''
103    parts
104      .map(part => part.trim())
105      .filter(Boolean)
106      .forEach(take)
107  }
108  if (pending.trim()) {
109    take(pending.trim())
110  }
111
112  const { code } = await child.result
113
114  return { lines, stderr, code, usedCookies: cookieArgs.length > 0 } satisfies Attempt
115}
116
117async function attemptWithJar($: EngineInterface, ytdlp: Ytdlp, args: readonly string[], onLine?: (line: string) => void) {
118  let slot = 0
119  while (busyCopies.has(slot)) {
120    slot += 1
121  }
122  busyCopies.add(slot)
123
124  const copy = copyPathOf(ytdlp.places, slot)
125  try {
126    await $.fs.write(copy, await $.fs.read(ytdlp.places.cookieJar))
127    const result = await attempt($, ytdlp, args, ['--cookies', copy], onLine)
128    const answered = await $.fs.read(copy)
129    if (holdsSession(answered)) {
130      await $.fs.write(ytdlp.places.cookieJar, answered)
131    }
132
133    return result
134  } finally {
135    await $.fs.write(copy, '')
136    busyCopies.delete(slot)
137  }
138}
139
140async function tried($: EngineInterface, ytdlp: Ytdlp, args: readonly string[], useCookies: boolean, onLine?: (line: string) => void) {
141  if (!useCookies) {
142    return attempt($, ytdlp, args, [], onLine)
143  }
144
145  return ytdlp.settings.cookiesFromBrowser
146    ? attempt($, ytdlp, args, ['--cookies-from-browser', ytdlp.settings.cookiesFromBrowser], onLine)
147    : attemptWithJar($, ytdlp, args, onLine)
148}
149
150export async function ytdlpLines($: EngineInterface, ytdlp: Ytdlp, args: readonly string[], onLine?: (line: string) => void) {
151  const canUseCookies = await hasCookies($, ytdlp)
152  let result = await tried($, ytdlp, args, mustUseCookies && canUseCookies, onLine)
153
154  if (result.lines.length === 0 && result.code !== 0 && isBotCheck(result.stderr) && !result.usedCookies && canUseCookies) {
155    mustUseCookies = true
156    result = await tried($, ytdlp, args, true, onLine)
157  }
158  if (result.lines.length === 0 && result.code !== 0) {
159    if (isBotCheck(result.stderr)) {
160      throw new Problem(result.usedCookies ? 'botCheckCookies' : 'botCheck')
161    }
162
163    throw new Problem('failure', { reason: reasonOf(result.stderr) })
164  }
165
166  return result.lines
167}
hooks/sources.ts 283 lines
1import type { Requester, Source, Track } from '../types'
2
3export type SearchSource = 'youtube' | 'soundcloud'
4
5export type SpotifyType = 'track' | 'album' | 'playlist'
6
7export type Query =
8  | { kind: 'search'; text: string }
9  | { kind: 'playlist'; url: string; isFlat: boolean }
10  | { kind: 'spotify'; type: SpotifyType; id: string }
11  | { kind: 'suno'; url: string }
12  | { kind: 'link'; url: string }
13
14export type Stream = { url: string; headers: Record<string, string>; availableAt: number | null }
15
16type Entry = {
17  id?: string
18  title?: string
19  duration?: number
20  uploader?: string
21  channel?: string
22  url?: string
23  webpage_url?: string
24  thumbnail?: string
25  ie_key?: string
26  extractor_key?: string
27}
28
29type SpotifyItem = { uri?: string; title?: string; subtitle?: string; duration?: number }
30
31type SpotifyEntity = SpotifyItem & {
32  type?: string
33  name?: string
34  artists?: { name?: string }[]
35  trackList?: SpotifyItem[]
36  coverArt?: { sources?: { url?: string; width?: number }[] }
37}
38
39export const ENTRY_TEMPLATE = '%(.{id,title,duration,uploader,channel,url,webpage_url,thumbnail,ie_key,extractor_key})j'
40export const STREAM_TEMPLATE = '%(.{url,http_headers,available_at})j'
41export const STREAM_FORMAT = 'bestaudio[ext=webm]/bestaudio/best'
42export const FEED_SIZE = 25
43export const PICK_POOL = 12
44
45const BOT_CHECK = /Sign in to confirm|not a bot|--cookies/i
46const LINK = /^https?:\/\/([^/?#]+)([^?#]*)/i
47const YOUTUBE_HOST = /^(?:www\.|m\.|music\.)?(?:youtube\.com|youtu\.be)$/i
48const SOUNDCLOUD_HOST = /^(?:www\.|m\.)?soundcloud\.com$/i
49const SUNO_LINK = /^https?:\/\/(?:www\.)?suno\.(?:com|ai)\/(?:song|s)\//i
50const SPOTIFY_LINK = /open\.spotify\.com\/(?:intl-[a-z-]+\/)?(track|album|playlist)\/([A-Za-z0-9]+)/i
51const PLAYLIST_ID = /[?&]list=([A-Za-z0-9_-]+)/
52const MIX_PREFIX = 'RD'
53const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i
54const PREVIEW = /cf-preview-media\.sndcdn\.com|\/preview\//i
55const NEXT_DATA = /<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/
56const ERROR_PREFIX = /^ERROR:\s*(?:\[[^\]]+\]\s*(?:[^:\s]+:\s*)?)?/
57const SUNO_UPLOADER = 'Suno'
58const ENTITIES: Record<string, string> = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'", '&#039;': "'", '&#x27;': "'" }
59
60const hostOf = (url: string) => url.match(LINK)?.[1]?.toLowerCase() ?? ''
61
62const pathOf = (url: string) => url.match(LINK)?.[2] ?? ''
63
64const isRecord = (value: unknown): value is Record<string, unknown> =>
65  typeof value === 'object' && value !== null && !Array.isArray(value)
66
67export const isBotCheck = (text: string) => BOT_CHECK.test(text)
68
69export const youtubeUrlOf = (id: string) => `https://www.youtube.com/watch?v=${id}`
70
71export const youtubeThumbnailOf = (id: string) => `https://i.ytimg.com/vi/${id}/hqdefault.jpg`
72
73export const spotifyEmbedOf = (type: SpotifyType, id: string) => `https://open.spotify.com/embed/${type}/${id}`
74
75export const queryOf = (raw: string): Query => {
76  const text = raw.trim()
77  if (!LINK.test(text)) {
78    return { kind: 'search', text }
79  }
80
81  const spotify = text.match(SPOTIFY_LINK)
82  if (spotify?.[1] && spotify[2]) {
83    return { kind: 'spotify', type: spotify[1].toLowerCase() as SpotifyType, id: spotify[2] }
84  }
85  if (SUNO_LINK.test(text)) {
86    return { kind: 'suno', url: text }
87  }
88
89  const host = hostOf(text)
90  const list = text.match(PLAYLIST_ID)?.[1]
91  if (YOUTUBE_HOST.test(host) && list) {
92    return {
93      kind: 'playlist',
94      url: list.startsWith(MIX_PREFIX) ? text : `https://www.youtube.com/playlist?list=${list}`,
95      isFlat: true,
96    }
97  }
98  if (SOUNDCLOUD_HOST.test(host) && /\/sets\//i.test(pathOf(text))) {
99    return { kind: 'playlist', url: text, isFlat: false }
100  }
101
102  return { kind: 'link', url: text }
103}
104
105export const searchOf = (text: string, source: SearchSource) =>
106  source === 'soundcloud'
107    ? { targets: [`scsearch5:${text}`], args: ['--ignore-errors'] }
108    : { targets: [`ytsearch1:${text} audio`, `ytsearch1:${text}`], args: ['--flat-playlist'] }
109
110const sourceOf = (entry: Entry, link: string): Source => {
111  const key = (entry.ie_key ?? entry.extractor_key ?? '').toLowerCase()
112  const host = hostOf(link)
113
114  if (key.startsWith('youtube') || YOUTUBE_HOST.test(host)) {
115    return 'youtube'
116  }
117
118  return key.startsWith('soundcloud') || SOUNDCLOUD_HOST.test(host) ? 'soundcloud' : 'web'
119}
120
121export const trackOf = (value: unknown, requestedBy: Requester): Track | null => {
122  if (!isRecord(value)) {
123    return null
124  }
125
126  const entry = value as Entry
127  if (typeof entry.id !== 'string' || entry.id === '' || PREVIEW.test(entry.url ?? '')) {
128    return null
129  }
130
131  const link = entry.webpage_url ?? entry.url ?? ''
132  const source = sourceOf(entry, link)
133
134  return {
135    id: entry.id,
136    title: entry.title ?? '',
137    url: source === 'youtube' ? youtubeUrlOf(entry.id) : link,
138    duration: Math.round(entry.duration ?? 0),
139    uploader: entry.uploader ?? entry.channel ?? '',
140    thumbnail: entry.thumbnail ?? (source === 'youtube' ? youtubeThumbnailOf(entry.id) : null),
141    source,
142    requestedBy,
143  }
144}
145
146export const tracksOf = (lines: readonly string[], requestedBy: Requester) =>
147  lines.flatMap(line => {
148    try {
149      const track = trackOf(JSON.parse(line), requestedBy)
150      return track ? [track] : []
151    } catch {
152      return []
153    }
154  })
155
156export const streamOf = (line: string): Stream | null => {
157  try {
158    const value: unknown = JSON.parse(line)
159    if (!isRecord(value) || typeof value.url !== 'string') {
160      return null
161    }
162
163    const headers = isRecord(value.http_headers)
164      ? Object.fromEntries(Object.entries(value.http_headers).filter((pair): pair is [string, string] => typeof pair[1] === 'string'))
165      : {}
166
167    return { url: value.url, headers, availableAt: typeof value.available_at === 'number' ? value.available_at : null }
168  } catch {
169    return null
170  }
171}
172
173export const reasonOf = (stderr: string) => {
174  const lines = stderr.split(/\r?\n/).map(line => line.trim()).filter(Boolean)
175  const error = lines.filter(line => line.startsWith('ERROR:')).at(-1) ?? lines.at(-1) ?? ''
176
177  return error.replace(ERROR_PREFIX, '').trim()
178}
179
180const spotifyEntityOf = (html: string): SpotifyEntity | null => {
181  const data = html.match(NEXT_DATA)?.[1]
182  if (!data) {
183    return null
184  }
185
186  try {
187    const entity: unknown = JSON.parse(data)?.props?.pageProps?.state?.data?.entity
188    return isRecord(entity) ? (entity as SpotifyEntity) : null
189  } catch {
190    return null
191  }
192}
193
194const pendingOf = (item: SpotifyItem, thumbnail: string | null, requestedBy: Requester): Track | null => {
195  const id = item.uri?.split(':').at(-1)
196  const title = item.title?.trim()
197  if (!id || !title) {
198    return null
199  }
200
201  const uploader = item.subtitle?.trim() ?? ''
202
203  return {
204    id: `spotify:${id}`,
205    title,
206    url: `https://open.spotify.com/track/${id}`,
207    duration: Math.round((item.duration ?? 0) / 1000),
208    uploader,
209    thumbnail,
210    source: 'spotify',
211    requestedBy,
212    query: `${uploader} ${title}`.trim(),
213  }
214}
215
216export const spotifyTracksOf = (html: string, requestedBy: Requester): Track[] => {
217  const entity = spotifyEntityOf(html)
218  if (!entity) {
219    return []
220  }
221
222  const cover = [...(entity.coverArt?.sources ?? [])].sort((a, b) => (b.width ?? 0) - (a.width ?? 0))[0]?.url ?? null
223  const items: SpotifyItem[] =
224    entity.type === 'track'
225      ? [
226          {
227            uri: entity.uri,
228            title: entity.name ?? entity.title,
229            subtitle: (entity.artists ?? []).map(artist => artist.name).filter(Boolean).join(', ') || entity.subtitle,
230            duration: entity.duration,
231          },
232        ]
233      : (entity.trackList ?? [])
234
235  return items.flatMap(item => pendingOf(item, cover, requestedBy) ?? [])
236}
237
238const metaOf = (html: string, property: string) =>
239  html.match(new RegExp(`<meta[^>]+property=["']${property}["'][^>]+content=["']([^"']*)["']`, 'i'))?.[1] ??
240  html.match(new RegExp(`<meta[^>]+content=["']([^"']*)["'][^>]+property=["']${property}["']`, 'i'))?.[1] ??
241  null
242
243const decoded = (text: string) => text.replace(/&(?:amp|lt|gt|quot|#0?39|#x27);/gi, entity => ENTITIES[entity.toLowerCase()] ?? entity)
244
245const sunoMediaOf = (html: string, field: 'audio_url' | 'video_url', id: string) =>
246  [...html.matchAll(new RegExp(field + String.raw`\\?"\s*:\s*\\?"(https://[^"\\]+)`, 'g'))]
247    .map(match => match[1] ?? '')
248    .find(url => url.includes(id)) ?? null
249
250export const sunoTrackOf = (html: string, link: string, requestedBy: Requester): Track | null => {
251  const id = [link, metaOf(html, 'og:url') ?? '', metaOf(html, 'og:audio') ?? '', html]
252    .map(text => text.match(UUID)?.[0])
253    .find(Boolean)
254  if (!id) {
255    return null
256  }
257
258  return {
259    id,
260    title: decoded(metaOf(html, 'og:title') ?? ''),
261    url: `https://suno.com/song/${id}`,
262    duration: 0,
263    uploader: SUNO_UPLOADER,
264    thumbnail: metaOf(html, 'og:image'),
265    source: 'suno',
266    requestedBy,
267    streamUrl: sunoMediaOf(html, 'audio_url', id) ?? sunoMediaOf(html, 'video_url', id) ?? `https://cdn1.suno.ai/${id}.mp3`,
268  }
269}
270
271export const relatedFeedOf = (seed: Track) => {
272  if (seed.source === 'soundcloud') {
273    return `${seed.url.replace(/\/+$/, '')}/recommended`
274  }
275
276  return seed.source === 'youtube' ? `https://www.youtube.com/watch?v=${seed.id}&list=${MIX_PREFIX}${seed.id}` : null
277}
278
279export const pickRelated = (tracks: readonly Track[], seedId: string, seen: ReadonlySet<string>, random: () => number) => {
280  const pool = tracks.filter(track => track.id !== seedId && !seen.has(track.id)).slice(0, PICK_POOL)
281
282  return pool.length === 0 ? null : (pool[Math.floor(random() * pool.length)] ?? null)
283}
hooks/catalog.ts 119 lines
1import type { EngineInterface } from 'claude-code'
2
3import type { Requester, Track } from '../types'
4import {
5  ENTRY_TEMPLATE,
6  FEED_SIZE,
7  pickRelated,
8  queryOf,
9  relatedFeedOf,
10  searchOf,
11  spotifyEmbedOf,
12  spotifyTracksOf,
13  STREAM_FORMAT,
14  STREAM_TEMPLATE,
15  streamOf,
16  sunoTrackOf,
17  tracksOf,
18} from './sources'
19import type { Query, Stream } from './sources'
20import { Problem } from './text'
21import { ytdlpLines } from './ytdlp'
22import type { Ytdlp } from './ytdlp'
23
24const PAGE_HEADERS = { 'User-Agent': 'Mozilla/5.0' }
25
26async function pageOf($: EngineInterface, url: string, problem: 'spotifyUnreadable' | 'sunoUnreadable') {
27  const response = await $.http.fetch(url, { headers: PAGE_HEADERS }).catch(() => null)
28  if (!response?.ok) {
29    throw new Problem(problem)
30  }
31
32  return response.text
33}
34
35export async function searchTracks($: EngineInterface, ytdlp: Ytdlp, text: string, requestedBy: Requester) {
36  const search = searchOf(text, ytdlp.settings.searchSource)
37
38  for (const target of search.targets) {
39    const found = tracksOf(await ytdlpLines($, ytdlp, [...search.args, '--print', ENTRY_TEMPLATE, target]), requestedBy)
40    if (found.length > 0) {
41      return found.slice(0, 1)
42    }
43  }
44
45  return []
46}
47
48async function tracksFor($: EngineInterface, ytdlp: Ytdlp, query: Query, requestedBy: Requester, onLine: () => void): Promise<Track[]> {
49  switch (query.kind) {
50    case 'search':
51      return searchTracks($, ytdlp, query.text, requestedBy)
52    case 'playlist': {
53      const flat = query.isFlat ? ['--flat-playlist'] : []
54      const lines = await ytdlpLines($, ytdlp, [...flat, '--yes-playlist', '--ignore-errors', '--print', ENTRY_TEMPLATE, query.url], onLine)
55      return tracksOf(lines, requestedBy)
56    }
57    case 'spotify':
58      return spotifyTracksOf(await pageOf($, spotifyEmbedOf(query.type, query.id), 'spotifyUnreadable'), requestedBy)
59    case 'suno': {
60      const track = sunoTrackOf(await pageOf($, query.url, 'sunoUnreadable'), query.url, requestedBy)
61      if (!track) {
62        throw new Problem('sunoUnreadable')
63      }
64      return [track]
65    }
66    case 'link':
67      return tracksOf(await ytdlpLines($, ytdlp, ['--no-playlist', '--print', ENTRY_TEMPLATE, query.url]), requestedBy).slice(0, 1)
68  }
69}
70
71export async function findTracks($: EngineInterface, ytdlp: Ytdlp, raw: string, requestedBy: Requester, onCount?: (count: number) => void) {
72  const query = queryOf(raw)
73  let count = 0
74  const found = await tracksFor($, ytdlp, query, requestedBy, () => onCount?.((count += 1)))
75
76  if (found.length === 0) {
77    throw new Problem(query.kind === 'spotify' ? 'spotifyUnreadable' : 'noResults')
78  }
79
80  return found
81}
82
83export async function resolvedTrack($: EngineInterface, ytdlp: Ytdlp, track: Track): Promise<Track> {
84  if (!track.query) {
85    return track
86  }
87
88  const [found] = await searchTracks($, ytdlp, track.query, track.requestedBy)
89  if (!found) {
90    throw new Problem('noResults')
91  }
92
93  return { ...found, requestedBy: track.requestedBy, ...(track.isAutoplay ? { isAutoplay: true } : {}) }
94}
95
96export async function streamFor($: EngineInterface, ytdlp: Ytdlp, track: Track): Promise<Stream> {
97  if (track.streamUrl) {
98    return { url: track.streamUrl, headers: {}, availableAt: null }
99  }
100
101  const [line] = await ytdlpLines($, ytdlp, ['--no-playlist', '-f', STREAM_FORMAT, '--print', STREAM_TEMPLATE, track.url])
102  const stream = line ? streamOf(line) : null
103  if (!stream) {
104    throw new Problem('noStream')
105  }
106
107  return stream
108}
109
110export async function relatedTo($: EngineInterface, ytdlp: Ytdlp, seed: Track, seen: ReadonlySet<string>, random: () => number) {
111  const feed = relatedFeedOf(seed)
112  if (!feed) {
113    return null
114  }
115
116  const lines = await ytdlpLines($, ytdlp, ['--flat-playlist', '--yes-playlist', '--playlist-end', String(FEED_SIZE), '--print', ENTRY_TEMPLATE, feed])
117
118  return pickRelated(tracksOf(lines, 'autoplay'), seed.id, seen, random)
119}