From a80b5e4fdfce11d147cab4669bef88d49896b9e7 Mon Sep 17 00:00:00 2001 From: Seth Miller Date: Mon, 31 Aug 2026 11:02:37 -0400 Subject: [PATCH 1/2] feat: play YouTube, SoundCloud, and Mixcloud links directly Pasting a URL previously fell through to `ytsearch5:`, so a link searched for its own text instead of playing. - add parseSourceUrl() and resolveUrl() to the search adapter: playlists resolve with --yes-playlist, single tracks with --no-playlist, and a watch URL carrying a dead list= falls back to the linked video - trim yt-dlp's multi-line diagnostics to the one line worth showing - branch doSearch() to the link path, extracting the loader spinner into a shared runWithLoader() rather than duplicating it - document the URL form and the shell-quoting caveat in help Verified against a live link: resolves to the track, streams through mpv with playback-time advancing and duration matching. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WqTzyMAtea218cjQsXcHG3 --- bin/sonic.js | 108 +++++++++++++++++++++++-------- lib/search.js | 158 ++++++++++++++++++++++++++++++++++++++++++++++ lib/system.js | 7 ++ package-lock.json | 4 ++ 4 files changed, 250 insertions(+), 27 deletions(-) diff --git a/bin/sonic.js b/bin/sonic.js index e04638a..8b87a30 100755 --- a/bin/sonic.js +++ b/bin/sonic.js @@ -4,7 +4,7 @@ import readline from 'node:readline'; import { readFileSync } from 'node:fs'; import chalk from 'chalk'; -import { search, SOURCES } from '../lib/search.js'; +import { search, SOURCES, parseSourceUrl, resolveUrl } from '../lib/search.js'; import { Player } from '../lib/player.js'; import { UI } from '../lib/ui.js'; import { @@ -83,6 +83,8 @@ if (args.includes('--mc') || args.includes('--mixcloud')) { source = 'mixcloud'; } +const LINK_TRACK_LIMIT = 100; + let pickerTab = 'vibes'; let vibesCursor = 0; let activityCursor = 0; @@ -319,21 +321,14 @@ function showRecents() { }); } -async function doSearch(query) { - currentQuery = query; - state = 'searching'; +// Runs an async task behind the animated loader. Never throws: the caller +// decides how to present success and failure. +async function runWithLoader(tips, task) { ui.hideCursor(); enableRawKeys(); ui.clear(); process.stdout.write(ui.header(source)); - const tips = [ - `searching ${source}...`, - 'finding the best mixes...', - 'curating your vibe...', - 'almost there...' - ]; - let frame = 0; let tipIdx = 0; const tipSwap = setInterval(() => { @@ -346,28 +341,87 @@ async function doSearch(query) { }, 80); try { - const searchLimit = store.config.searchLimit; - const foundResults = await search(query, { source, limit: searchLimit }); - clearInterval(spin); - clearInterval(tipSwap); - - setResultsView(foundResults, { - title: 'search results', - subtitle: `${source} · ${query}`, - kind: 'search', - emptyMessage: `no ${source} results for “${query}”.`, - footer: 'enter play · f favorite · / search · b back · m source · t theme · d mode · q quit' - }); + const value = await task(); + return { ok: true, value }; } catch (err) { + return { ok: false, error: err }; + } finally { clearInterval(spin); clearInterval(tipSwap); - ui.clear(); - process.stdout.write(ui.header(source)); - process.stdout.write(`\n ${chalk.red('search failed:')} ${err.message}\n`); - promptSearch(); } } +function showLoadFailure(label, err) { + ui.clear(); + process.stdout.write(ui.header(source)); + process.stdout.write(`\n ${chalk.red(label)} ${err.message}\n`); + promptSearch(); +} + +async function doSearch(query) { + const link = parseSourceUrl(query); + if (link) { + await doOpenLink(query, link); + return; + } + + currentQuery = query; + state = 'searching'; + + const outcome = await runWithLoader([ + `searching ${source}...`, + 'finding the best mixes...', + 'curating your vibe...', + 'almost there...' + ], () => search(query, { source, limit: store.config.searchLimit })); + + if (!outcome.ok) { + showLoadFailure('search failed:', outcome.error); + return; + } + + setResultsView(outcome.value, { + title: 'search results', + subtitle: `${source} · ${query}`, + kind: 'search', + emptyMessage: `no ${source} results for “${query}”.`, + footer: 'enter play · f favorite · / search · b back · m source · t theme · d mode · q quit' + }); +} + +async function doOpenLink(rawUrl, link) { + currentQuery = null; + state = 'searching'; + source = link.source; + + const outcome = await runWithLoader([ + `opening ${link.source} link...`, + 'resolving track info...', + 'almost there...' + ], () => resolveUrl(rawUrl, { limit: LINK_TRACK_LIMIT })); + + if (!outcome.ok) { + showLoadFailure('link failed:', outcome.error); + return; + } + + const { tracks, kind, note } = outcome.value; + const heading = kind === 'playlist' ? 'playlist' : 'linked track'; + + let subtitle = `${link.source} · ${tracks.length} track${tracks.length === 1 ? '' : 's'}`; + if (note) { + subtitle = `${subtitle} · ${note}`; + } + + setResultsView(tracks, { + title: heading, + subtitle, + kind: 'link', + emptyMessage: 'that link resolved to no playable tracks.', + footer: 'enter play · f favorite · / search · b back · m source · t theme · d mode · q quit' + }); +} + async function playTrack(track) { const hydrated = hydrateTrack(track); currentTrack = hydrated; diff --git a/lib/search.js b/lib/search.js index 069dead..6270b3e 100644 --- a/lib/search.js +++ b/lib/search.js @@ -5,6 +5,164 @@ const exec = promisify(execFile); export const SOURCES = ['youtube', 'soundcloud', 'mixcloud']; +const SOURCE_HOSTS = [ + { source: 'youtube', pattern: /^(www\.|m\.|music\.)?(youtube\.com|youtube-nocookie\.com|youtu\.be)$/ }, + { source: 'soundcloud', pattern: /^(www\.|m\.)?soundcloud\.com$/ }, + { source: 'mixcloud', pattern: /^(www\.|m\.)?mixcloud\.com$/ } +]; + +// Returns { url, source } for a playable link, or null for anything else +// (including plain search text, which is the common case). +export function parseSourceUrl(value) { + const trimmed = String(value || '').trim(); + if (!/^https?:\/\//i.test(trimmed)) { + return null; + } + + let parsed; + try { + parsed = new URL(trimmed); + } catch { + return null; + } + + const host = parsed.hostname.toLowerCase(); + for (const entry of SOURCE_HOSTS) { + if (entry.pattern.test(host)) { + return { url: parsed, source: entry.source }; + } + } + + return null; +} + +function isPlaylistUrl(url, source) { + if (source === 'youtube') { + if (url.searchParams.has('list')) return true; + return url.pathname.startsWith('/playlist'); + } + if (source === 'soundcloud') { + return url.pathname.includes('/sets/'); + } + if (source === 'mixcloud') { + return url.pathname.includes('/playlists/'); + } + return false; +} + +// A YouTube watch URL carrying a dead or private `list` can still play the +// single video it points at, so keep that fallback available. +function hasSingleTrackFallback(url, source) { + if (source !== 'youtube') return false; + if (url.searchParams.has('v')) return true; + return url.hostname.toLowerCase().endsWith('youtu.be') && url.pathname.length > 1; +} + +export async function resolveUrl(value, { limit = 100 } = {}) { + const link = parseSourceUrl(value); + if (!link) { + throw new Error('unsupported link — sonic plays youtube, soundcloud, and mixcloud urls'); + } + + const { url, source } = link; + const target = url.toString(); + + if (isPlaylistUrl(url, source)) { + try { + const tracks = await dumpUrl(target, { source, playlist: true, limit }); + if (tracks.length > 1) { + return { tracks, source, kind: 'playlist', note: null }; + } + if (tracks.length === 1) { + // yt-dlp silently degrades a dead or private list to the linked video. + return { tracks, source, kind: 'track', note: null }; + } + } catch (err) { + if (!hasSingleTrackFallback(url, source)) throw err; + } + + if (!hasSingleTrackFallback(url, source)) { + throw new Error('that link resolved to no playable tracks'); + } + + const tracks = await dumpUrl(target, { source, playlist: false, limit: 1 }); + return { + tracks, + source, + kind: 'track', + note: 'playlist unavailable — playing the linked track only' + }; + } + + const tracks = await dumpUrl(target, { source, playlist: false, limit: 1 }); + if (tracks.length === 0) { + throw new Error('that link resolved to no playable tracks'); + } + + return { tracks, source, kind: 'track', note: null }; +} + +async function dumpUrl(target, { source, playlist, limit }) { + const args = ['--flat-playlist', '--dump-json', '--no-warnings']; + + if (playlist) { + args.push('--yes-playlist', '--playlist-end', String(limit)); + } else { + args.push('--no-playlist'); + } + + args.push('--', target); + + let stdout; + try { + ({ stdout } = await exec('yt-dlp', args, { + timeout: 60000, + maxBuffer: 32 * 1024 * 1024 + })); + } catch (err) { + throw new Error(cleanYtDlpError(err)); + } + + return stdout + .trim() + .split('\n') + .filter(Boolean) + .map(line => toTrack(JSON.parse(line), source)) + .filter(Boolean); +} + +// yt-dlp writes multi-line diagnostics; keep the one line worth showing. +function cleanYtDlpError(err) { + const stderr = String(err?.stderr || '').trim(); + const firstError = stderr + .split('\n') + .map(line => line.trim()) + .find(line => line.startsWith('ERROR:')); + + if (firstError) { + return firstError.replace(/^ERROR:\s*/, '').replace(/^\[[^\]]+\]\s*/, ''); + } + + return err?.message || 'yt-dlp failed'; +} + +function toTrack(data, source) { + const url = data.webpage_url + || data.url + || (source === 'youtube' && data.id ? `https://youtube.com/watch?v=${data.id}` : null); + + if (!url) return null; + + return { + title: data.title || 'Untitled', + url, + duration: data.duration, + channel: data.channel || data.uploader || '', + id: data.id, + source + }; +} + export async function search(query, { source = 'youtube', limit = 5 } = {}) { if (source === 'mixcloud') { return searchMixcloud(query, limit); diff --git a/lib/system.js b/lib/system.js index ffec7ba..5f31cf3 100644 --- a/lib/system.js +++ b/lib/system.js @@ -101,6 +101,7 @@ export function printHelp(version) { Usage: sonic sonic + sonic "" sonic --soundcloud sonic --mixcloud sonic doctor @@ -112,6 +113,12 @@ Flags: --sc, --soundcloud Use SoundCloud search --mc, --mixcloud Use Mixcloud search +Links: + Paste a YouTube, SoundCloud, or Mixcloud URL to play it directly. + Quote the URL so the shell does not split it on & characters. + A playlist link loads the whole playlist; a watch link with a dead + list= falls back to the single linked track. + Requirements: - Node.js 20+ - yt-dlp diff --git a/package-lock.json b/package-lock.json index d127992..d0de40d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,11 +7,15 @@ "": { "name": "sonic-cli", "version": "0.1.0", + "license": "MIT", "dependencies": { "chalk": "^5.4.1" }, "bin": { "sonic": "bin/sonic.js" + }, + "engines": { + "node": ">=20" } }, "node_modules/chalk": { From 7502db661002fece3ef4db9fb1f7a52f26cfb20f Mon Sep 17 00:00:00 2001 From: Seth Miller Date: Fri, 18 Sep 2026 14:19:48 -0400 Subject: [PATCH 2/2] Migrate repository guidance to AGENTS.md --- AGENTS.md | 6 +-- CLAUDE.md | 114 ------------------------------------------------------ 2 files changed, 3 insertions(+), 117 deletions(-) delete mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md index 5f3ab67..4a15583 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ -# AGENTS.md +# Sonic -Guidance for coding agents working in this repository. Keep this aligned with `CLAUDE.md`. +Guidance for coding agents working in this repository. ## Mission @@ -54,7 +54,7 @@ lib/themes.js theme registry and palette helpers - keep docs in sync with real behavior - prefer YouTube-first improvements over speculative multi-service work - if you add another service, explain the API/auth/legal model in `README.md` -- keep project-level `CLAUDE.md` and `AGENTS.md` versioned in git; do not hide them in `.gitignore` +- keep the project-level `AGENTS.md` versioned in git; do not hide it in `.gitignore` - if you touch theme work, keep the identity strong across the full TUI, not just the header ## Inspiration diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 33f15a5..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,114 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code when working with this repository. - -## Project Overview - -Sonic is a terminal music player with a theatrical TUI. It is currently YouTube-first and uses: - -- `yt-dlp` for search and URL resolution -- `mpv` for audio playback -- plain Node.js ESM for the CLI runtime -- `chalk` for color and terminal styling -- a persisted local store for favorites, recents, theme, and playback defaults - -SoundCloud search exists, but treat it as experimental. The main product direction is a polished, publishable GitHub project with a strong setup story and a clear line between fun UX and risky service creep. - -## Product Priorities - -1. Keep YouTube discovery and playback sharp -2. Make setup obvious and reliable -3. Keep favorites, recents, queue, and local config feeling polished -4. Preserve the bold terminal personality -5. Improve safety around shelling out to system tools -6. Add session-aware workflows before chasing many streaming services - -## Commands - -```bash -npm install -npm start -npm start -- "lofi" -npm run check -npm run doctor -node ./bin/sonic.js --help -``` - -Runtime dependencies: - -```bash -brew install yt-dlp mpv -``` - -## Project Structure - -```text -bin/sonic.js CLI entrypoint, keyboard handling, top-level state machine -lib/search.js yt-dlp search adapter -lib/player.js mpv process lifecycle and IPC socket -lib/channels.js curated channels and activities -lib/ui.js terminal UI, header, lists, visualizers -lib/system.js dependency checks, help text, safe external opening -lib/store.js persistent local config, favorites, recents -lib/themes.js curated theme system and palette registry -``` - -## Working Rules - -- Do not interpolate untrusted data into shell commands -- Keep dependencies minimal unless there is a strong reason otherwise -- Prefer small, obvious modules over framework-style abstractions -- Preserve keyboard-first flows when changing the UI -- Keep themes cohesive across header, lists, playback, and visualizers -- If you change playback behavior, manually smoke-test the interactive flow -- If you add a new service, document the auth, setup, and legal constraints in `README.md` -- Keep project-level `CLAUDE.md` and `AGENTS.md` checked into git. Ignore machine-local agent config elsewhere, not the repo contract files. - -## Inspiration - -Use these as product and DX references, not templates to copy blindly: - -- [`googleworkspace/cli`](https://github.com/googleworkspace/cli): strong install story, clear prerequisites, dynamic command surface, and explicit human + agent positioning. -- [`cli/cli`](https://github.com/cli/cli): benchmark for command naming, help quality, and “official-feeling” terminal UX. -- [`alexknowshtml/api2cli`](https://github.com/alexknowshtml/api2cli): good model for dual-mode output and agent-friendly command design. -- [`asc`](https://asccli.sh/): strong example of lightweight install UX and a narrow, high-value workflow focus. -- [`Polymarket/polymarket-cli`](https://github.com/Polymarket/polymarket-cli): good example of calling out risk early, offering Homebrew install, and serving both humans and scripts. -- [`google-gemini/gemini-cli`](https://github.com/google-gemini/gemini-cli) and [`superagent-ai/grok-cli`](https://github.com/superagent-ai/grok-cli): useful references for agent-oriented terminal branding and docs structure. -- [`rothgar/awesome-tuis`](https://github.com/rothgar/awesome-tuis): broad pattern library for TUI interaction ideas. -- [Karpathy tweet](https://x.com/karpathy/status/2026360908398862478): reminder that terminal-native AI/dev tools can feel faster and more direct than browser-heavy workflows. - -Borrow these ideas for Sonic: - -- install should be one-screen simple -- help text should be excellent -- the CLI should work for humans first, but not block agent/script usage later -- repo docs should make the product feel real, not experimental by accident -- narrow scope beats shallow support for many services - -## Service Strategy - -If a user asks to expand beyond YouTube: - -- Prefer official APIs first -- Separate discovery/auth concerns from playback concerns -- Do not present unofficial playback paths as if they were official SDK integrations -- Keep adapters isolated so the core TUI is not tied to one service's auth model - -## Release Readiness - -Before calling this project ready for public GitHub/npm release, make sure the repo has: - -- a strong `README.md` -- `CONTRIBUTING.md` -- `SECURITY.md` -- clear install instructions -- a chosen open-source license -- at least one automated check in CI - -## Testing Expectations - -There are no automated tests yet. Minimum validation for behavior changes: - -1. run `npm run check` -2. run the CLI in a real terminal -3. verify picker, search, playback, pause/resume, browser open, and quit