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…

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.
| Plugin | Includes | What it does | Command |
|---|---|---|---|
| chess | mod | Play chess against Claude in a pane, any time, without taking a chat turn. | /chess |
| meowzik | mod | A music player beside the chat: YouTube, SoundCloud, Spotify and Suno, played by you or by Claude. | /meowzik |
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.
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 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).
/config.~/.claude/meowzik/lists, so they survive the session.play, control, status, list), so "play some Daft Punk", "skip this" or "what is playing?" just work.~/.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./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.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.
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.
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.
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.
MIT (see LICENSE).
hooks/register.tsx 108 lines1import 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 lines1import 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 lines1export 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 lines1import 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 lines1import 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 lines1import { 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 lines1import 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 lines1import 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 lines1import 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 lines1import 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 lines1import 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> = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ''': "'", ''': "'" }
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 lines1import 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}