H2 (URL half): one canonical youtubeId in urlHelpers

Consolidates the scattered YouTube-URL parsing (the clean, renderer-contained
half of H2):

- Move the complete youtubeId (watch/shorts/embed/live/youtu.be) into
  lib/urlHelpers.ts as the single home.
- thumb.ts, queueStats.ts (sameVideo), and store/downloads.ts (titleFromUrl)
  now import it; the three ad-hoc reimplementations are removed.
- titleFromUrl gains correctness: it previously labeled ANY host with a ?v=
  param as "YouTube video (…)" and missed youtu.be/shorts; it's now host-correct
  and covers every YouTube shape. sameVideo now also dedupes /shorts/ID against
  /watch?v=ID.
- Unit-tested in test/clipboardLink.test.ts (+4 cases, 247 pass).

The formatter half of H2 is deferred to travel with H4: the renderer re-parses
speed/eta strings only because raw numbers aren't carried across the IPC boundary
(H4), and fmtBytes (1024) vs formatSpeed (1000) differ intentionally.

typecheck + 247 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 20:54:09 -04:00
parent 8267da50dc
commit d050e48af6
6 changed files with 72 additions and 42 deletions
+26
View File
@@ -1,3 +1,29 @@
/**
* Pull the video id out of any YouTube watch / shorts / embed / live / youtu.be
* URL. Returns null for non-YouTube URLs or anything unparseable, so callers can
* cheaply ask "is there a YouTube id derivable from this link?". The single home
* for this parse — thumbnails, duplicate detection, and the placeholder title all
* use it (H2).
*/
export function youtubeId(url: string | undefined): string | null {
if (!url) return null
let u: URL
try {
u = new URL(url)
} catch {
return null
}
const host = u.hostname.replace(/^www\./i, '').toLowerCase()
if (host === 'youtu.be') return u.pathname.slice(1).split('/')[0] || null
if (host === 'youtube.com' || host.endsWith('.youtube.com')) {
const v = u.searchParams.get('v')
if (v) return v
const m = u.pathname.match(/^\/(?:embed|shorts|v|live)\/([^/?#]+)/i)
if (m?.[1]) return m[1]
}
return null
}
/** A quick heuristic for "this clipboard text is a link worth offering". */
export function looksLikeUrl(text: string): boolean {
const t = text.trim()
+5 -4
View File
@@ -11,6 +11,7 @@ import { useSettings } from './settings'
import { useHistory } from './history'
import { emit, on } from './coordinator'
import { newId } from '../id'
import { youtubeId } from '../lib/urlHelpers'
// Single-sourced from the IPC contract (L105) and re-exported so existing
// `import { MediaKind } from '../store/downloads'` sites keep working.
@@ -174,11 +175,11 @@ function buildItem(url: string, kind: MediaKind, quality: string, opts?: AddOpti
}
function titleFromUrl(url: string): string {
const id = youtubeId(url)
if (id) return `YouTube video (${id})`
try {
const u = new URL(url)
const v = u.searchParams.get('v')
if (v) return `YouTube video (${v})`
if (u.hostname) return `${u.hostname} download`
const host = new URL(url).hostname
if (host) return `${host} download`
} catch {
/* not a URL */
}
+1 -12
View File
@@ -5,6 +5,7 @@
* unit-tested without constructing the Zustand store or its preview ticker.
*/
import type { DownloadItem } from './downloads'
import { youtubeId } from '../lib/urlHelpers'
// Parse a human speed string ('4.7 MB/s', '512 KiB/s') into bytes/second. Tolerant
// of the SI (KB) and binary (KiB) suffixes yt-dlp may emit; the 1000-vs-1024
@@ -107,18 +108,6 @@ export function summarizeQueue(items: DownloadItem[]): QueueSummary {
// --- Duplicate detection ----------------------------------------------------
function youtubeId(url: string): string | null {
try {
const u = new URL(url)
const host = u.hostname.replace(/^www\./, '')
if (host === 'youtu.be') return u.pathname.slice(1) || null
if (host.endsWith('youtube.com')) return u.searchParams.get('v')
} catch {
/* not a URL */
}
return null
}
function normalizeUrl(url: string): string {
try {
const u = new URL(url)
+1 -24
View File
@@ -4,30 +4,7 @@
* video id with no extra network probe. Anything non-YouTube without a probed
* thumbnail returns undefined, and the UI falls back to a kind icon.
*/
/**
* Pull the video id out of any YouTube watch / shorts / embed / live / youtu.be
* URL. Returns null for non-YouTube URLs or anything unparseable, so callers can
* cheaply ask "is there a thumbnail derivable from this link?".
*/
export function youtubeId(url: string | undefined): string | null {
if (!url) return null
let u: URL
try {
u = new URL(url)
} catch {
return null
}
const host = u.hostname.replace(/^www\./i, '').toLowerCase()
if (host === 'youtu.be') return u.pathname.slice(1).split('/')[0] || null
if (host === 'youtube.com' || host.endsWith('.youtube.com')) {
const v = u.searchParams.get('v')
if (v) return v
const m = u.pathname.match(/^\/(?:embed|shorts|v|live)\/([^/?#]+)/i)
if (m?.[1]) return m[1]
}
return null
}
import { youtubeId } from './lib/urlHelpers'
/**
* Resolve a preview thumbnail URL for a media item. Prefers an explicit thumbnail