1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

feat: YouTube Music audio capture (ytm: shorthand, Spotify detection, stalled job recovery) (#19)

* feat: add YouTube Music and Spotify source detection

- Add Source variants: YouTubeMusicTrack, YouTubeMusicPlaylist,
  SpotifyTrack, SpotifyAlbum, SpotifyPlaylist
- ytm:ID shorthand → music.youtube.com/watch?v=ID (audio-only, forced
  in core regardless of caller quality hint)
- ytm:playlist/ID and music.youtube.com/playlist URLs detected but
  fail with 'not yet implemented' via fail_run
- Spotify URLs/shorthands detected and fail fast with clear DRM error
  via fail_run (after run item created, so status is visible in /runs)
- source_metadata: youtube_music/music/audio and spotify/music/audio
  (entity_kind='music' for UI pill, representation_kind='audio' stored)
- locator_to_ytdlp_url includes YouTubeMusicTrack for probe endpoint
- generate_entry_title: 'Title — Artist' for YTM tracks
- Frontend: isVideoSource handles ytm: and music.youtube.com/watch;
  Spotify returns false (no probe, clear server error on submit)
- Placeholder updated to include ytm:ID
- SOURCE_ICONS: youtube_music (red disc) and spotify (green waves)
- 14 new tests covering all new sources (163 total, all pass)

* fix: prevent yt-dlp playlist expansion and stalled run recovery

- Add --no-playlist to ytdlp::download and fetch_metadata: URLs with a
  list= parameter (e.g. music.youtube.com/watch?v=ID&list=RDAMVM…) no
  longer cause yt-dlp to expand the full playlist and hang; both the
  metadata probe and the download are now single-item only

- Fix fail_stalled_capture_jobs to also recover archive_runs and
  archive_run_items: capture_jobs.run_uid is NULL at crash time so a
  join is unreliable; instead fail all archive_runs/items still
  in_progress directly, then recount failed_count via subquery.
  Startup recovery now makes the Runs UI reflect the correct failed
  state after a hard shutdown

- Expand fail_stalled_jobs_on_restart test to assert archive_run and
  archive_run_item rows are also marked failed, not just capture_jobs

* fix: use play triangle for youtube_music icon
This commit is contained in:
TheGeneralist 2026-07-06 15:34:14 +02:00 committed by GitHub
parent 339076e6a2
commit 21b11c211f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 362 additions and 7 deletions

View file

@ -18,6 +18,11 @@ function isVideoSource(locator) {
}
}
// ytm: shorthand track only; playlist is not yet implemented
if (ll.startsWith('ytm:')) {
return !ll.slice(4).startsWith('playlist/')
}
// x: / twitter: / tweet: shorthands only x:media:ID routes to yt-dlp (Source::X)
for (const scheme of ['x:', 'twitter:', 'tweet:']) {
if (ll.startsWith(scheme)) {
@ -25,6 +30,9 @@ function isVideoSource(locator) {
}
}
// spotify: shorthands all will fail with a clear error; no probe needed
if (ll.startsWith('spotify:')) return false
// Other platform shorthands all go to yt-dlp
if (ll.startsWith('instagram:') || ll.startsWith('facebook:') ||
ll.startsWith('tiktok:') || ll.startsWith('reddit:') ||
@ -32,6 +40,8 @@ function isVideoSource(locator) {
// HTTP/HTTPS URLs match the same regexes and prefix checks as determine_source
if (ll.startsWith('http://') || ll.startsWith('https://')) {
// YouTube Music track (watch) before generic YouTube check
if (/^https?:\/\/music\.youtube\.com\/watch/.test(ll)) return true
// YouTube video (watch, youtu.be, shorts) not playlist or channel
if (/^https?:\/\/(?:www\.)?(?:youtu\.be\/[0-9A-Za-z_-]+|youtube\.com\/watch\?v=[0-9A-Za-z_-]+|youtube\.com\/shorts\/[0-9A-Za-z_-]+)/.test(l)) return true
// x.com Source::X yt-dlp (note: twitter.com URLs fall through to Source::Url, not yt-dlp)
@ -46,6 +56,8 @@ function isVideoSource(locator) {
if (/^https?:\/\/(?:www\.)?reddit\.com\//.test(ll) || ll.startsWith('https://redd.it/') || ll.startsWith('http://redd.it/')) return true
// Snapchat
if (/^https?:\/\/(?:www\.)?snapchat\.com\//.test(ll)) return true
// Spotify all will fail with a clear error; no probe needed
if (ll.startsWith('https://open.spotify.com/') || ll.startsWith('http://open.spotify.com/')) return false
}
return false
@ -402,7 +414,7 @@ function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemov
ref={inputRef}
className="capture-input"
type="text"
placeholder="https://… · yt:ID · tweet:ID · x:ID"
placeholder="https://… · yt:ID · ytm:ID · tweet:ID · x:ID"
value={item.locator}
onChange={e => onLocatorChange(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') onSubmit() }}

View file

@ -35,6 +35,8 @@ export function formatTimestamp(value) {
export const SOURCE_ICONS = {
youtube: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#FF0000" d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.6 12 3.6 12 3.6s-7.5 0-9.4.5A3 3 0 0 0 .5 6.2C0 8.1 0 12 0 12s0 3.9.5 5.8a3 3 0 0 0 2.1 2.1c1.9.5 9.4.5 9.4.5s7.5 0 9.4-.5a3 3 0 0 0 2.1-2.1C24 15.9 24 12 24 12s0-3.9-.5-5.8z"/><polygon fill="#fff" points="9.6,15.6 15.8,12 9.6,8.4"/></svg>`,
youtube_music: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="12" fill="#FF0000"/><polygon fill="#fff" points="9.6,15.6 15.8,12 9.6,8.4"/></svg>`,
spotify: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="12" fill="#1DB954"/><path fill="#fff" d="M17.9 10.9c-3.2-1.9-8.5-2.1-11.6-1.1-.5.1-1-.2-1.1-.6-.1-.5.2-1 .6-1.1 3.5-1.1 9.4-.9 13 1.3.4.2.6.8.3 1.2-.2.5-.8.6-1.2.3zm-.1 2.9c-.2.4-.7.5-1.1.3-2.7-1.6-6.7-2.1-9.9-1.1-.4.1-.9-.1-1-.5-.1-.4.1-.9.5-1 3.6-1.1 8-.5 11.1 1.3.4.2.5.7.4 1zm-1.3 2.8c-.2.3-.6.4-.9.2-2.3-1.4-5.2-1.7-8.6-.9-.3.1-.7-.1-.8-.5-.1-.3.1-.7.4-.8 3.7-.9 7-.5 9.6 1 .4.2.5.6.3.9z"/></svg>`,
x: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M18.2 2h3.3l-7.2 8.2L23 22h-6.6l-5.2-6.8L5 22H1.7l7.7-8.8L1 2h6.8l4.7 6.2zm-1.1 18h1.8L6.9 3.9H5z"/></svg>`,
instagram: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="ig" x1="0%" y1="100%" x2="100%" y2="0%"><stop offset="0%" stop-color="#f09433"/><stop offset="25%" stop-color="#e6683c"/><stop offset="50%" stop-color="#dc2743"/><stop offset="75%" stop-color="#cc2366"/><stop offset="100%" stop-color="#bc1888"/></linearGradient></defs><rect width="24" height="24" rx="5" fill="url(#ig)"/><rect x="2.5" y="2.5" width="19" height="19" rx="4" fill="none" stroke="#fff" stroke-width="1.5"/><circle cx="12" cy="12" r="4" fill="none" stroke="#fff" stroke-width="1.5"/><circle cx="17.5" cy="6.5" r="1" fill="#fff"/></svg>`,
facebook: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="24" rx="4" fill="#1877F2"/><path fill="#fff" d="M16 8h-2c-.6 0-1 .4-1 1v2h3l-.4 3H13v8h-3v-8H8v-3h2V9a4 4 0 0 1 4-4h2v3z"/></svg>`,