(null)
useShortcutMap({
@@ -116,11 +96,11 @@ export function ToolsHub({ intro }: Props) {
})
const tools = useMemo(() => {
- const matches = searchTools(deferredQuery)
+ const matches = searchTools(query)
return category === 'all'
? matches
: matches.filter(tool => tool.category === category)
- }, [deferredQuery, category])
+ }, [query, category])
return (
@@ -148,7 +128,10 @@ export function ToolsHub({ intro }: Props) {
/>
-
+
-
-
{tools.length > 0 ? (
diff --git a/src/features/miscellaneous/constants/tool-seo.ts b/src/features/miscellaneous/constants/tool-seo.ts
index 7ee85bf6..21c3e882 100644
--- a/src/features/miscellaneous/constants/tool-seo.ts
+++ b/src/features/miscellaneous/constants/tool-seo.ts
@@ -1,10 +1,5 @@
import type { TToolSlug } from './tools'
-export type TToolFaq = {
- question: string
- answer: string
-}
-
export type TToolSeoContent = {
metaTitle: string
metaDescription: string
@@ -12,7 +7,6 @@ export type TToolSeoContent = {
highlights: readonly string[]
steps: readonly string[]
formats: string
- faqs: readonly TToolFaq[]
}
const TOOL_SEO_CONTENT = {
@@ -32,21 +26,7 @@ const TOOL_SEO_CONTENT = {
'Copy the generated TSX or export the complete collection as files or a ZIP.'
],
formats:
- 'Input: SVG markup and .svg files. Output: React TSX components, combined registries and downloadable ZIP packages.',
- faqs: [
- {
- question: 'Are my SVG files uploaded?',
- answer: 'No. Parsing, sanitizing and component generation happen locally in your browser.'
- },
- {
- question: 'Can I convert multiple SVG icons at once?',
- answer: 'Yes. You can process a collection, rename components and export individual files or one combined package.'
- },
- {
- question: 'Does it prevent duplicate SVG IDs?',
- answer: 'Yes. Internal IDs and their references are rewritten so multiple generated icons can render safely on the same page.'
- }
- ]
+ 'Input: SVG markup and .svg files. Output: React TSX components, combined registries and downloadable ZIP packages.'
},
'sendable-video': {
metaTitle: 'WhatsApp Video Converter — MOV/MKV to MP4',
@@ -64,22 +44,7 @@ const TOOL_SEO_CONTENT = {
'Export a compatible MP4 or an optimized GIF and download it locally.'
],
formats:
- 'Input: MP4, MOV, MKV, WebM and AVI. Output: chat-friendly H.264 MP4 or an optimized animated GIF.',
- faqs: [
- {
- question:
- 'Why does a video work locally but not in WhatsApp Web?',
- answer: 'The container may be supported while its video or audio codec is not. Re-encoding to a conventional H.264 MP4 resolves many compatibility problems.'
- },
- {
- question: 'Is my video uploaded anywhere?',
- answer: 'No. The file is decoded and encoded locally in your browser and remains on your device.'
- },
- {
- question: 'Can I trim the video before converting it?',
- answer: 'Yes. Set the clip range in the preview before exporting to avoid encoding footage you do not need.'
- }
- ]
+ 'Input: MP4, MOV, MKV, WebM and AVI. Output: chat-friendly H.264 MP4 or an optimized animated GIF.'
},
'gif-to-video': {
metaTitle: 'GIF to MP4 & WebM Converter',
@@ -97,21 +62,7 @@ const TOOL_SEO_CONTENT = {
'Convert, inspect the preview and download the finished video.'
],
formats:
- 'Input: animated GIF files. Output: H.264 MP4 for compatibility or WebM for efficient web delivery.',
- faqs: [
- {
- question: 'Why convert a GIF to MP4 or WebM?',
- answer: 'Video compression is substantially more efficient than animated GIF compression, so the result is often smaller at similar visual quality.'
- },
- {
- question: 'Will the animation still loop?',
- answer: 'The complete animation is preserved in the exported video. Whether it loops during playback depends on the website or video player.'
- },
- {
- question: 'Does the GIF get uploaded?',
- answer: 'No. Conversion runs locally in the browser, and the input and output stay on your device.'
- }
- ]
+ 'Input: animated GIF files. Output: H.264 MP4 for compatibility or WebM for efficient web delivery.'
},
'video-to-gif': {
metaTitle: 'Video to GIF Converter — MP4, MOV & WebM',
@@ -129,22 +80,7 @@ const TOOL_SEO_CONTENT = {
'Render the looping GIF, review it and download the result.'
],
formats:
- 'Input: MP4, MOV, MKV, WebM and AVI video. Output: optimized animated GIF with a generated color palette.',
- faqs: [
- {
- question: 'How can I make the GIF file smaller?',
- answer: 'Trim the clip, reduce its width or lower the frame rate. Shorter dimensions and fewer frames usually have the largest effect.'
- },
- {
- question:
- 'Can I preview the result before the full conversion?',
- answer: 'Yes. The preview renders a short sample and estimates the complete file size using the selected settings.'
- },
- {
- question: 'Is the source video uploaded?',
- answer: 'No. FFmpeg runs inside your browser, so the source video and generated GIF remain local.'
- }
- ]
+ 'Input: MP4, MOV, MKV, WebM and AVI video. Output: optimized animated GIF with a generated color palette.'
}
} as const satisfies Partial
>
diff --git a/src/features/miscellaneous/coordinate-marker/index.tsx b/src/features/miscellaneous/coordinate-marker/index.tsx
index 6ec8d89b..b74f3e98 100644
--- a/src/features/miscellaneous/coordinate-marker/index.tsx
+++ b/src/features/miscellaneous/coordinate-marker/index.tsx
@@ -15,6 +15,7 @@ import { noop } from '@/shared/lib/noop'
import 'leaflet/dist/leaflet.css'
import type * as L from 'leaflet'
import { SendToTool } from '../components/send-to-tool'
+import { CoordinateMarkerSkeleton } from '../components/tool-skeletons'
import { geolocationErrorMessage, locate } from '../utils/geolocation'
import {
consumeLocations,
@@ -405,16 +406,7 @@ export default function CoordinateMarkerTool() {
)
if (!ready) {
- return (
-
- )
+ return
}
return (
diff --git a/src/features/miscellaneous/diff-checker/index.tsx b/src/features/miscellaneous/diff-checker/index.tsx
index 5667aaad..4e8b1b00 100644
--- a/src/features/miscellaneous/diff-checker/index.tsx
+++ b/src/features/miscellaneous/diff-checker/index.tsx
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react'
import { ArrowLeftRight, Eraser } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
+import { DiffCheckerSkeleton } from '../components/tool-skeletons'
import { useLocalStorage } from '../hooks/use-local-storage'
import { DiffOutput } from './components/diff-output'
import { TextPanel } from './components/text-panel'
@@ -34,17 +35,7 @@ export default function DiffCheckerTool() {
}, [leftHydrated, rightHydrated, setLeft, setRight])
if (!leftHydrated || !rightHydrated) {
- return (
-
- )
+ return
}
function swap() {
diff --git a/src/features/miscellaneous/find-replace/index.tsx b/src/features/miscellaneous/find-replace/index.tsx
index e39e6cdd..b737cade 100644
--- a/src/features/miscellaneous/find-replace/index.tsx
+++ b/src/features/miscellaneous/find-replace/index.tsx
@@ -19,6 +19,7 @@ import {
CollapsibleContent,
CollapsibleTrigger
} from '@/components/ui/collapsible'
+import { FindReplaceSkeleton } from '../components/tool-skeletons'
import { writeDiffHandoff } from '../diff-checker/utils/handoff'
import { AdvancedReplace } from './components/advanced-replace'
import { EditorPanels } from './components/editor-panels'
@@ -165,17 +166,7 @@ export default function FindReplaceTool() {
}
if (!store.hydrated) {
- return (
-
- )
+ return
}
return (
diff --git a/src/features/miscellaneous/gif-to-video/hooks/use-gif-to-video-store.ts b/src/features/miscellaneous/gif-to-video/hooks/use-gif-to-video-store.ts
index a4b6d1d4..9781e170 100644
--- a/src/features/miscellaneous/gif-to-video/hooks/use-gif-to-video-store.ts
+++ b/src/features/miscellaneous/gif-to-video/hooks/use-gif-to-video-store.ts
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { noop } from '@/shared/lib/noop'
import { useLocalStorage } from '../../hooks/use-local-storage'
+import { useMediaSession } from '../../hooks/use-media-session'
import type { TMediaOutput, TMediaStatus } from '../../types/media'
import {
deleteQuiet,
@@ -58,6 +59,29 @@ export function useGifToVideoStore() {
const [output, setOutput] = useState(null)
const urlsRef = useRef([])
+ const restoredRef = useRef(false)
+
+ const { persistFile, persistOutput } = useMediaSession(
+ STORAGE_KEY,
+ session => {
+ if (restoredRef.current) return
+ restoredRef.current = true
+ setFile(session.file)
+ setFileUrl(URL.createObjectURL(session.file))
+ if (session.output) {
+ setOutput({
+ url: URL.createObjectURL(session.output.blob),
+ name: session.output.name,
+ size: session.output.blob.size,
+ mime: session.output.mime
+ })
+ }
+ setStatus({
+ message: 'Restored your previous session from this browser.',
+ mode: 'idle'
+ })
+ }
+ )
useEffect(() => {
urlsRef.current = [fileUrl, output?.url].filter(
@@ -73,16 +97,18 @@ export function useGifToVideoStore() {
)
const clearOutput = useCallback(() => {
+ persistOutput(null)
setOutput(previous => {
if (previous) URL.revokeObjectURL(previous.url)
return null
})
- }, [])
+ }, [persistOutput])
const selectFile = useCallback(
(next: File | null) => {
if (busy) return
+ restoredRef.current = true
clearOutput()
setFileUrl(previous => {
if (previous) URL.revokeObjectURL(previous)
@@ -91,6 +117,7 @@ export function useGifToVideoStore() {
setProgress(0)
if (!next) {
+ persistFile(null)
setFile(null)
setStatus(IDLE_STATUS)
return
@@ -98,6 +125,7 @@ export function useGifToVideoStore() {
const sizeMb = next.size / (1024 * 1024)
if (sizeMb > MAX_INPUT_MB) {
+ persistFile(null)
setFile(null)
setStatus({
message: `File is ${sizeMb.toFixed(1)} MB. Keep it under ${MAX_INPUT_MB} MB for browser conversion.`,
@@ -106,6 +134,7 @@ export function useGifToVideoStore() {
return
}
+ persistFile(next)
setFile(next)
setFileUrl(URL.createObjectURL(next))
setStatus({
@@ -113,7 +142,7 @@ export function useGifToVideoStore() {
mode: 'idle'
})
},
- [busy, clearOutput]
+ [busy, clearOutput, persistFile]
)
const setFormat = useCallback(
@@ -167,9 +196,15 @@ export function useGifToVideoStore() {
outputName,
OUTPUT_MIMES[options.format]
)
+ const outputFileName = `${stem(file.name)}.${options.format}`
+ persistOutput({
+ blob,
+ name: outputFileName,
+ mime: OUTPUT_MIMES[options.format]
+ })
setOutput({
url: URL.createObjectURL(blob),
- name: `${stem(file.name)}.${options.format}`,
+ name: outputFileName,
size: blob.size,
mime: OUTPUT_MIMES[options.format]
})
@@ -187,7 +222,7 @@ export function useGifToVideoStore() {
setFFmpegProgressHandler(noop)
setBusy(false)
}
- }, [busy, clearOutput, file, options])
+ }, [busy, clearOutput, file, options, persistOutput])
return {
file,
diff --git a/src/features/miscellaneous/hemelsbreed/index.tsx b/src/features/miscellaneous/hemelsbreed/index.tsx
index ad676496..e8de47c3 100644
--- a/src/features/miscellaneous/hemelsbreed/index.tsx
+++ b/src/features/miscellaneous/hemelsbreed/index.tsx
@@ -20,6 +20,7 @@ import { Button } from '@/components/ui/button'
import { cn } from '@/shared/lib/cn'
import { useLocalStorage } from '../hooks/use-local-storage'
import { SendToTool } from '../components/send-to-tool'
+import { HemelsbreedSkeleton } from '../components/tool-skeletons'
import { geolocationErrorMessage, locate } from '../utils/geolocation'
import { consumeLocations, type TLocationPoint } from '../utils/location-handoff'
import { appendSavedLocation, locationLabel } from '../utils/locations'
@@ -396,16 +397,7 @@ export default function HemelsbreedTool() {
}
if (!hydrated) {
- return (
-
- )
+ return
}
return (
diff --git a/src/features/miscellaneous/hooks/use-media-session.ts b/src/features/miscellaneous/hooks/use-media-session.ts
new file mode 100644
index 00000000..9b19e03f
--- /dev/null
+++ b/src/features/miscellaneous/hooks/use-media-session.ts
@@ -0,0 +1,69 @@
+'use client'
+
+import { useCallback, useEffect, useRef } from 'react'
+import type { TTrimRange } from '../types/media'
+import {
+ loadMediaSession,
+ saveMediaFile,
+ saveMediaOutput,
+ saveMediaTrim,
+ type TPersistedOutput,
+ type TPersistedSession
+} from '../utils/media-persistence'
+
+export type TRestoredMediaSession = TPersistedSession & { file: File }
+
+function warn(error: unknown) {
+ console.warn('Media session persistence failed', error)
+}
+
+/**
+ * Restores a tool's last media session from IndexedDB on mount and exposes
+ * fire-and-forget persistence for the input file, trim range, and output.
+ * onRestore only fires when a file was previously saved.
+ */
+export function useMediaSession(
+ toolKey: string,
+ onRestore: (session: TRestoredMediaSession) => void
+) {
+ const onRestoreRef = useRef(onRestore)
+ const startedRef = useRef(false)
+
+ useEffect(() => {
+ onRestoreRef.current = onRestore
+ })
+
+ useEffect(() => {
+ if (startedRef.current) return
+ startedRef.current = true
+ loadMediaSession(toolKey)
+ .then(session => {
+ if (!session.file) return
+ onRestoreRef.current({ ...session, file: session.file })
+ })
+ .catch(warn)
+ }, [toolKey])
+
+ const persistFile = useCallback(
+ (file: File | null) => {
+ saveMediaFile(toolKey, file).catch(warn)
+ },
+ [toolKey]
+ )
+
+ const persistTrim = useCallback(
+ (trim: TTrimRange | null) => {
+ saveMediaTrim(toolKey, trim).catch(warn)
+ },
+ [toolKey]
+ )
+
+ const persistOutput = useCallback(
+ (output: TPersistedOutput | null) => {
+ saveMediaOutput(toolKey, output).catch(warn)
+ },
+ [toolKey]
+ )
+
+ return { persistFile, persistTrim, persistOutput }
+}
diff --git a/src/features/miscellaneous/hooks/use-tool-usage.ts b/src/features/miscellaneous/hooks/use-tool-usage.ts
deleted file mode 100644
index 29cd3541..00000000
--- a/src/features/miscellaneous/hooks/use-tool-usage.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-'use client'
-
-import { useCallback } from 'react'
-import { useLocalStorage } from './use-local-storage'
-
-const RECENT_KEY = 'misc-tools:recent'
-const MAX_RECENT = 6
-
-export function useRecentTools() {
- const [recent, setRecent, hydrated] = useLocalStorage(
- RECENT_KEY,
- []
- )
-
- const markUsed = useCallback(
- (slug: string) => {
- setRecent(previous =>
- [slug, ...previous.filter(item => item !== slug)].slice(
- 0,
- MAX_RECENT
- )
- )
- },
- [setRecent]
- )
-
- return { recent, markUsed, hydrated }
-}
diff --git a/src/features/miscellaneous/hooks/use-trim-state.ts b/src/features/miscellaneous/hooks/use-trim-state.ts
index 22842fbe..52a62f4b 100644
--- a/src/features/miscellaneous/hooks/use-trim-state.ts
+++ b/src/features/miscellaneous/hooks/use-trim-state.ts
@@ -40,13 +40,17 @@ export function useTrimState() {
setHistory([])
}, [])
- const handleMetadata = useCallback((nextDuration: number) => {
- if (!Number.isFinite(nextDuration) || nextDuration <= 0) return
- const full = { start: 0, end: nextDuration }
- setDuration(nextDuration)
- setTrim(full)
- setHistory([full])
- }, [])
+ const handleMetadata = useCallback(
+ (nextDuration: number, initial?: TTrimRange) => {
+ if (!Number.isFinite(nextDuration) || nextDuration <= 0) return
+ const full = { start: 0, end: nextDuration }
+ const start = initial ? clampTrim(initial, nextDuration) : full
+ setDuration(nextDuration)
+ setTrim(start)
+ setHistory(rangesEqual(start, full) ? [full] : [full, start])
+ },
+ []
+ )
const updateTrim = useCallback(
(next: TTrimRange) => {
diff --git a/src/features/miscellaneous/sendable-video/components/export-panel.tsx b/src/features/miscellaneous/sendable-video/components/export-panel.tsx
index 268fb65a..cb47ec95 100644
--- a/src/features/miscellaneous/sendable-video/components/export-panel.tsx
+++ b/src/features/miscellaneous/sendable-video/components/export-panel.tsx
@@ -3,7 +3,10 @@
import { Download, Film, Image as ImageIcon, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/shared/lib/cn'
-import { Segmented, ToggleChip } from '../../link-extractor/components/segmented'
+import {
+ Segmented,
+ ToggleChip
+} from '../../link-extractor/components/segmented'
import { GIF_FPS_OPTIONS } from '../constants'
import type { TSendableVideoStore } from '../hooks/use-sendable-video-store'
import { bytesToHuman } from '../../utils/format'
@@ -64,18 +67,43 @@ export function ExportPanel({ store }: Props) {
Export GIF
{output ? (
-
+ <>
+
+
+ {output.kind === 'gif' ? (
+
+
+
+ Rendered GIF: {output.name} (
+ {bytesToHuman(output.size)})
+
+
+ ) : (
+
+ )}
+ >
) : null}
diff --git a/src/features/miscellaneous/sendable-video/hooks/use-sendable-video-store.ts b/src/features/miscellaneous/sendable-video/hooks/use-sendable-video-store.ts
index 503f3c7b..c52b4735 100644
--- a/src/features/miscellaneous/sendable-video/hooks/use-sendable-video-store.ts
+++ b/src/features/miscellaneous/sendable-video/hooks/use-sendable-video-store.ts
@@ -3,7 +3,9 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { noop } from '@/shared/lib/noop'
import { useLocalStorage } from '../../hooks/use-local-storage'
+import { useMediaSession } from '../../hooks/use-media-session'
import { useTrimState } from '../../hooks/use-trim-state'
+import type { TTrimRange } from '../../types/media'
import {
DEFAULT_OPTIONS,
GIF_PRESETS,
@@ -18,12 +20,18 @@ import {
deleteQuiet,
execWithLogs,
loadFFmpeg,
+ probeLogs,
readOutputBlob,
setFFmpegProgressHandler,
writeInputFile
} from '../../utils/ffmpeg'
import { stem } from '../../utils/format'
-import { encodeArgs, gifArgs, remuxArgs } from '../utils/ffmpeg'
+import {
+ encodeArgs,
+ gifArgs,
+ isRemuxCompatible,
+ remuxArgs
+} from '../utils/ffmpeg'
const IDLE_STATUS: TStatus = {
message: 'Select a video to get started.',
@@ -60,6 +68,31 @@ export function useSendableVideoStore() {
file: null,
output: null
})
+ const pendingTrimRef = useRef(null)
+ const restoredRef = useRef(false)
+
+ const { persistFile, persistTrim, persistOutput } = useMediaSession(
+ STORAGE_KEY,
+ session => {
+ if (restoredRef.current) return
+ restoredRef.current = true
+ pendingTrimRef.current = session.trim
+ setFile(session.file)
+ setFileUrl(URL.createObjectURL(session.file))
+ if (session.output) {
+ setOutput({
+ url: URL.createObjectURL(session.output.blob),
+ name: session.output.name,
+ size: session.output.blob.size,
+ kind: session.output.mime === 'image/gif' ? 'gif' : 'mp4'
+ })
+ }
+ setStatus({
+ message: 'Restored your previous session from this browser.',
+ mode: 'idle'
+ })
+ }
+ )
useEffect(() => {
urlsRef.current.file = fileUrl
@@ -79,16 +112,19 @@ export function useSendableVideoStore() {
)
const clearOutput = useCallback(() => {
+ persistOutput(null)
setOutput(previous => {
if (previous) URL.revokeObjectURL(previous.url)
return null
})
- }, [])
+ }, [persistOutput])
const selectFile = useCallback(
(next: File | null) => {
if (busy) return
+ restoredRef.current = true
+ pendingTrimRef.current = null
clearOutput()
setFileUrl(previous => {
if (previous) URL.revokeObjectURL(previous)
@@ -98,6 +134,7 @@ export function useSendableVideoStore() {
setProgress(0)
if (!next) {
+ persistFile(null)
setFile(null)
setStatus(IDLE_STATUS)
return
@@ -105,6 +142,7 @@ export function useSendableVideoStore() {
const sizeMb = next.size / (1024 * 1024)
if (sizeMb > MAX_INPUT_MB) {
+ persistFile(null)
setFile(null)
setStatus({
message: `File is ${sizeMb.toFixed(1)} MB. Keep it under ${MAX_INPUT_MB} MB for browser conversion.`,
@@ -113,6 +151,7 @@ export function useSendableVideoStore() {
return
}
+ persistFile(next)
setFile(next)
setFileUrl(URL.createObjectURL(next))
setStatus({
@@ -120,9 +159,28 @@ export function useSendableVideoStore() {
mode: 'idle'
})
},
- [busy, clearOutput, clearTrim]
+ [busy, clearOutput, clearTrim, persistFile]
)
+ const handleMetadata = useCallback(
+ (nextDuration: number) => {
+ trimState.handleMetadata(
+ nextDuration,
+ pendingTrimRef.current ?? undefined
+ )
+ pendingTrimRef.current = null
+ },
+ [trimState.handleMetadata]
+ )
+
+ useEffect(() => {
+ if (!file || trimState.duration <= 0) return
+ const timeout = setTimeout(() => {
+ persistTrim(hasTrim ? trim : null)
+ }, 300)
+ return () => clearTimeout(timeout)
+ }, [file, trimState.duration, hasTrim, trim, persistTrim])
+
const setMuteAudio = useCallback(
(muteAudio: boolean) => {
setOptions(previous => ({ ...previous, muteAudio }))
@@ -168,7 +226,10 @@ export function useSendableVideoStore() {
})
let blob: Blob | null = null
- if (!options.muteAudio) {
+ if (
+ !options.muteAudio &&
+ isRemuxCompatible(await probeLogs(ffmpeg, INPUT_NAME))
+ ) {
try {
await execWithLogs(ffmpeg, remuxArgs(range))
blob = await readOutputBlob(ffmpeg, OUTPUT_MP4, 'video/mp4')
@@ -183,22 +244,21 @@ export function useSendableVideoStore() {
message: 'Re-encoding for compatibility…',
mode: 'processing'
})
- await execWithLogs(
- ffmpeg,
- encodeArgs(range, options.muteAudio)
- )
+ await execWithLogs(ffmpeg, encodeArgs(range, options.muteAudio))
blob = await readOutputBlob(ffmpeg, OUTPUT_MP4, 'video/mp4')
}
+ const outputName = `${stem(file.name)}_sendable.mp4`
+ persistOutput({ blob, name: outputName, mime: 'video/mp4' })
setOutput({
url: URL.createObjectURL(blob),
- name: `${stem(file.name)}_sendable.mp4`,
+ name: outputName,
size: blob.size,
kind: 'mp4'
})
setProgress(100)
setStatus({
- message: 'Done — your MP4 is ready to download.',
+ message: 'Done - your MP4 is ready to download.',
mode: 'success'
})
@@ -210,7 +270,7 @@ export function useSendableVideoStore() {
setFFmpegProgressHandler(noop)
setBusy(false)
}
- }, [busy, clearOutput, file, hasTrim, options.muteAudio, trim])
+ }, [busy, clearOutput, file, hasTrim, options.muteAudio, persistOutput, trim])
const exportGif = useCallback(async () => {
if (!file || busy) return
@@ -244,15 +304,17 @@ export function useSendableVideoStore() {
await execWithLogs(ffmpeg, gifArgs(hasTrim ? trim : null, preset))
const blob = await readOutputBlob(ffmpeg, OUTPUT_GIF, 'image/gif')
+ const outputName = `${stem(file.name)}_${preset.fps}fps.gif`
+ persistOutput({ blob, name: outputName, mime: 'image/gif' })
setOutput({
url: URL.createObjectURL(blob),
- name: `${stem(file.name)}_${preset.fps}fps.gif`,
+ name: outputName,
size: blob.size,
kind: 'gif'
})
setProgress(100)
setStatus({
- message: 'Done — your GIF is ready to download.',
+ message: 'Done - your GIF is ready to download.',
mode: 'success'
})
@@ -264,7 +326,7 @@ export function useSendableVideoStore() {
setFFmpegProgressHandler(noop)
setBusy(false)
}
- }, [busy, clearOutput, file, hasTrim, options.gifFps, trim])
+ }, [busy, clearOutput, file, hasTrim, options.gifFps, persistOutput, trim])
return {
file,
@@ -279,7 +341,7 @@ export function useSendableVideoStore() {
busy,
output,
selectFile,
- handleMetadata: trimState.handleMetadata,
+ handleMetadata,
updateTrim: trimState.updateTrim,
commitTrim: trimState.commitTrim,
undoTrim: trimState.undoTrim,
diff --git a/src/features/miscellaneous/sendable-video/index.tsx b/src/features/miscellaneous/sendable-video/index.tsx
index a621e5f3..d9744ca0 100644
--- a/src/features/miscellaneous/sendable-video/index.tsx
+++ b/src/features/miscellaneous/sendable-video/index.tsx
@@ -1,5 +1,7 @@
'use client'
+import { X } from 'lucide-react'
+import { Button } from '@/components/ui/button'
import { MediaDropzone } from '../components/media-dropzone'
import { MediaTrimPanel } from '../components/media-trim-panel'
import { ExportPanel } from './components/export-panel'
@@ -11,15 +13,30 @@ export default function SendableVideo() {
return (
-
+
+
+ {store.file ? (
+
+ ) : null}
+
{store.fileUrl ? (
{
+ const logs: string[] = []
+ const onLog = ({ message }: { message: string }) => {
+ logs.push(message)
+ }
+ ffmpeg.on('log', onLog)
+
+ try {
+ await ffmpeg.exec(['-i', name])
+ } catch {
+ noop()
+ } finally {
+ ffmpeg.off('log', onLog)
+ }
+
+ return logs.join('\n')
+}
+
/**
* Writes a browser File into the ffmpeg virtual filesystem under the given
* name, replacing any previous file with that name.
diff --git a/src/features/miscellaneous/utils/media-persistence.ts b/src/features/miscellaneous/utils/media-persistence.ts
new file mode 100644
index 00000000..f76db9cb
--- /dev/null
+++ b/src/features/miscellaneous/utils/media-persistence.ts
@@ -0,0 +1,152 @@
+'use client'
+
+import type { TTrimRange } from '../types/media'
+
+const DB_NAME = 'media-tools'
+const STORE_NAME = 'sessions'
+const DB_VERSION = 1
+
+export type TPersistedOutput = {
+ blob: Blob
+ name: string
+ mime: string
+}
+
+export type TPersistedSession = {
+ file: File | null
+ trim: TTrimRange | null
+ output: TPersistedOutput | null
+}
+
+function inputKey(toolKey: string): string {
+ return `${toolKey}:input`
+}
+
+function trimKey(toolKey: string): string {
+ return `${toolKey}:trim`
+}
+
+function outputKey(toolKey: string): string {
+ return `${toolKey}:output`
+}
+
+function openDatabase(): Promise {
+ return new Promise((resolve, reject) => {
+ const request = window.indexedDB.open(DB_NAME, DB_VERSION)
+ request.onupgradeneeded = () => {
+ if (!request.result.objectStoreNames.contains(STORE_NAME)) {
+ request.result.createObjectStore(STORE_NAME)
+ }
+ }
+ request.onsuccess = () => resolve(request.result)
+ request.onerror = () => reject(request.error)
+ })
+}
+
+async function readKey(key: string): Promise {
+ const db = await openDatabase()
+ try {
+ return await new Promise((resolve, reject) => {
+ const request = db
+ .transaction(STORE_NAME)
+ .objectStore(STORE_NAME)
+ .get(key)
+ request.onsuccess = () => resolve(request.result as T | undefined)
+ request.onerror = () => reject(request.error)
+ })
+ } finally {
+ db.close()
+ }
+}
+
+async function writeKey(key: string, value: unknown): Promise {
+ const db = await openDatabase()
+ try {
+ await new Promise((resolve, reject) => {
+ const transaction = db.transaction(STORE_NAME, 'readwrite')
+ transaction.objectStore(STORE_NAME).put(value, key)
+ transaction.oncomplete = () => resolve()
+ transaction.onerror = () => reject(transaction.error)
+ transaction.onabort = () => reject(transaction.error)
+ })
+ } finally {
+ db.close()
+ }
+}
+
+async function deleteKeys(keys: string[]): Promise {
+ const db = await openDatabase()
+ try {
+ await new Promise((resolve, reject) => {
+ const transaction = db.transaction(STORE_NAME, 'readwrite')
+ const store = transaction.objectStore(STORE_NAME)
+ for (const key of keys) store.delete(key)
+ transaction.oncomplete = () => resolve()
+ transaction.onerror = () => reject(transaction.error)
+ transaction.onabort = () => reject(transaction.error)
+ })
+ } finally {
+ db.close()
+ }
+}
+
+/**
+ * Loads a tool's persisted media session (input file, trim range, output)
+ * from IndexedDB. Missing pieces come back as null.
+ */
+export async function loadMediaSession(
+ toolKey: string
+): Promise {
+ const [file, trim, output] = await Promise.all([
+ readKey(inputKey(toolKey)),
+ readKey(trimKey(toolKey)),
+ readKey(outputKey(toolKey))
+ ])
+ return { file: file ?? null, trim: trim ?? null, output: output ?? null }
+}
+
+/**
+ * Persists a newly selected input file. Passing a file resets the stored trim
+ * and output (they belong to the previous file); passing null clears the
+ * whole session.
+ */
+export async function saveMediaFile(
+ toolKey: string,
+ file: File | null
+): Promise {
+ if (!file) {
+ await clearMediaSession(toolKey)
+ return
+ }
+ await deleteKeys([trimKey(toolKey), outputKey(toolKey)])
+ await writeKey(inputKey(toolKey), file)
+}
+
+/** Persists the current trim range, or clears it when null. */
+export async function saveMediaTrim(
+ toolKey: string,
+ trim: TTrimRange | null
+): Promise {
+ if (!trim) {
+ await deleteKeys([trimKey(toolKey)])
+ return
+ }
+ await writeKey(trimKey(toolKey), trim)
+}
+
+/** Persists the latest converted output, or clears it when null. */
+export async function saveMediaOutput(
+ toolKey: string,
+ output: TPersistedOutput | null
+): Promise {
+ if (!output) {
+ await deleteKeys([outputKey(toolKey)])
+ return
+ }
+ await writeKey(outputKey(toolKey), output)
+}
+
+/** Removes every persisted piece of a tool's media session. */
+export async function clearMediaSession(toolKey: string): Promise {
+ await deleteKeys([inputKey(toolKey), trimKey(toolKey), outputKey(toolKey)])
+}
diff --git a/src/features/miscellaneous/video-to-gif/hooks/use-video-to-gif-store.ts b/src/features/miscellaneous/video-to-gif/hooks/use-video-to-gif-store.ts
index 29e6c4a6..78169015 100644
--- a/src/features/miscellaneous/video-to-gif/hooks/use-video-to-gif-store.ts
+++ b/src/features/miscellaneous/video-to-gif/hooks/use-video-to-gif-store.ts
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { noop } from '@/shared/lib/noop'
import { useLocalStorage } from '../../hooks/use-local-storage'
+import { useMediaSession } from '../../hooks/use-media-session'
import { useTrimState } from '../../hooks/use-trim-state'
import type {
TMediaOutput,
@@ -70,6 +71,31 @@ export function useVideoToGifStore() {
const inputWrittenFor = useRef(null)
const urlsRef = useRef([])
+ const pendingTrimRef = useRef(null)
+ const restoredRef = useRef(false)
+
+ const { persistFile, persistTrim, persistOutput } = useMediaSession(
+ STORAGE_KEY,
+ session => {
+ if (restoredRef.current) return
+ restoredRef.current = true
+ pendingTrimRef.current = session.trim
+ setFile(session.file)
+ setFileUrl(URL.createObjectURL(session.file))
+ if (session.output) {
+ setOutput({
+ url: URL.createObjectURL(session.output.blob),
+ name: session.output.name,
+ size: session.output.blob.size,
+ mime: session.output.mime
+ })
+ }
+ setStatus({
+ message: 'Restored your previous session from this browser.',
+ mode: 'idle'
+ })
+ }
+ )
useEffect(() => {
urlsRef.current = [fileUrl, preview?.url, output?.url].filter(
@@ -92,16 +118,19 @@ export function useVideoToGifStore() {
}, [])
const clearOutput = useCallback(() => {
+ persistOutput(null)
setOutput(previous => {
if (previous) URL.revokeObjectURL(previous.url)
return null
})
- }, [])
+ }, [persistOutput])
const selectFile = useCallback(
(next: File | null) => {
if (busy) return
+ restoredRef.current = true
+ pendingTrimRef.current = null
clearPreview()
clearOutput()
inputWrittenFor.current = null
@@ -113,6 +142,7 @@ export function useVideoToGifStore() {
setProgress(0)
if (!next) {
+ persistFile(null)
setFile(null)
setStatus(IDLE_STATUS)
return
@@ -120,6 +150,7 @@ export function useVideoToGifStore() {
const sizeMb = next.size / (1024 * 1024)
if (sizeMb > MAX_INPUT_MB) {
+ persistFile(null)
setFile(null)
setStatus({
message: `File is ${sizeMb.toFixed(1)} MB. Keep it under ${MAX_INPUT_MB} MB for browser conversion.`,
@@ -128,6 +159,7 @@ export function useVideoToGifStore() {
return
}
+ persistFile(next)
setFile(next)
setFileUrl(URL.createObjectURL(next))
setStatus({
@@ -136,9 +168,28 @@ export function useVideoToGifStore() {
mode: 'idle'
})
},
- [busy, clearOutput, clearPreview, clearTrim]
+ [busy, clearOutput, clearPreview, clearTrim, persistFile]
+ )
+
+ const handleMetadata = useCallback(
+ (nextDuration: number) => {
+ trimState.handleMetadata(
+ nextDuration,
+ pendingTrimRef.current ?? undefined
+ )
+ pendingTrimRef.current = null
+ },
+ [trimState.handleMetadata]
)
+ useEffect(() => {
+ if (!file || duration <= 0) return
+ const timeout = setTimeout(() => {
+ persistTrim(hasTrim ? trim : null)
+ }, 300)
+ return () => clearTimeout(timeout)
+ }, [file, duration, hasTrim, trim, persistTrim])
+
const updateTrim = useCallback(
(next: TTrimRange) => {
trimState.updateTrim(next)
@@ -285,9 +336,11 @@ export function useVideoToGifStore() {
)
const blob = await readOutputBlob(ffmpeg, OUTPUT_NAME, 'image/gif')
+ const outputName = `${stem(file.name)}_${options.fps}fps.gif`
+ persistOutput({ blob, name: outputName, mime: 'image/gif' })
setOutput({
url: URL.createObjectURL(blob),
- name: `${stem(file.name)}_${options.fps}fps.gif`,
+ name: outputName,
size: blob.size,
mime: 'image/gif'
})
@@ -304,7 +357,16 @@ export function useVideoToGifStore() {
setFFmpegProgressHandler(noop)
setBusy(false)
}
- }, [busy, clearOutput, ensureInputWritten, file, hasTrim, options, trim])
+ }, [
+ busy,
+ clearOutput,
+ ensureInputWritten,
+ file,
+ hasTrim,
+ options,
+ persistOutput,
+ trim
+ ])
const effectiveDuration = hasTrim ? trim.end - trim.start : duration
const estimatedSize =
@@ -327,7 +389,7 @@ export function useVideoToGifStore() {
output,
estimatedSize,
selectFile,
- handleMetadata: trimState.handleMetadata,
+ handleMetadata,
updateTrim,
commitTrim: trimState.commitTrim,
undoTrim,
diff --git a/src/features/packages/data.ts b/src/features/packages/data.ts
index 67396dfe..fbf17552 100644
--- a/src/features/packages/data.ts
+++ b/src/features/packages/data.ts
@@ -31,7 +31,6 @@ export type DeveloperPackage = {
code: string
fileName: string
}[]
- faqs: { question: string; answer: string }[]
}
export const developerPackages: readonly DeveloperPackage[] = [
@@ -264,28 +263,6 @@ export const adapter = createAdapter({
}
})`
}
- ],
- faqs: [
- {
- question: 'Does it include an auth backend?',
- answer: 'No, deliberately. It is the sign-in surface plus a typed adapter over whatever already owns your sessions: Better Auth, Supabase, NextAuth, Clerk, Firebase, Passport, or your own JWT and REST endpoints. Your backend does not change; the drawer just stops you from rebuilding its UI in every app.'
- },
- {
- question: 'My auth provider is not in the adapter list. Am I stuck?',
- answer: 'No. Implement the exported AuthAdapter contract (signIn, signUp, signOut, useSession) and the drawer treats it like any first-party adapter. TypeScript checks your implementation against the contract, and the UI reveals registration, OAuth, and reset flows based on which methods you actually provide.'
- },
- {
- question: 'Do I need Tailwind or a separate stylesheet?',
- answer: 'Neither. Prebuilt styles ship with the component import, so there is no CSS file to remember and no Tailwind requirement in your app. Presentation, copy, provider buttons, and motion are all shaped through one deep-merged config object instead.'
- },
- {
- question: 'Does it work with the Next.js App Router?',
- answer: 'Yes. Mount AuthProvider and AuthDrawer in a small client shell near the root and add a portal div to your layout so the drawer renders above page content with scroll lock. Server components everywhere else stay server components.'
- },
- {
- question: 'Can I build the UI before the backend exists?',
- answer: 'Yes. Ship the bundled mock adapter, click through sign-in, registration, and reset with fake sessions, then swap in the real adapter later. Nothing in the UI layer changes.'
- }
]
},
{
@@ -439,16 +416,6 @@ $.mod.key('k').on(() => openPalette())`
}
]
}
- ],
- faqs: [
- {
- question: 'Does mod work on Windows and macOS?',
- answer: 'Yes. The mod modifier maps to Cmd on macOS and Ctrl elsewhere.'
- },
- {
- question: 'Can shortcuts be scoped to an editor or modal?',
- answer: 'Yes. Use scopes and guards so only active UI owns a shortcut.'
- }
]
},
{
@@ -603,16 +570,6 @@ saveSettings().then(() => notice.success('Settings saved'))`
}
]
}
- ],
- faqs: [
- {
- question: 'Can I use promise tracking?',
- answer: 'Yes. notify.promise() manages loading, success, and error states around async work.'
- },
- {
- question: 'Can notifications be themed?',
- answer: 'Yes. Set color mode, radius, icon treatment, position, duration, and swipe behavior on Notifier.'
- }
]
}
]
diff --git a/src/features/ytmusic/components/ytmusic-diagnostics.tsx b/src/features/ytmusic/components/ytmusic-diagnostics.tsx
new file mode 100644
index 00000000..9b396172
--- /dev/null
+++ b/src/features/ytmusic/components/ytmusic-diagnostics.tsx
@@ -0,0 +1,391 @@
+'use client'
+
+import Image from 'next/image'
+import { useState, type ReactNode } from 'react'
+import {
+ AlertTriangle,
+ Check,
+ Clock3,
+ Database,
+ ExternalLink,
+ Music2,
+ Radio,
+ RefreshCw,
+ ShieldCheck,
+ WifiOff
+} from 'lucide-react'
+import type {
+ YTMusicResult,
+ YTMusicStatus,
+ YTMusicTrack
+} from '@/features/ytmusic/types'
+
+interface YTMusicDiagnosticsProps {
+ initialResult: YTMusicResult
+}
+
+const IMAGE_HOSTS = new Set([
+ 'lh3.googleusercontent.com',
+ 'yt3.googleusercontent.com',
+ 'i.ytimg.com'
+])
+
+const STATUS_PRESENTATION: Record<
+ YTMusicStatus,
+ { label: string; tone: string; dot: string }
+> = {
+ ok: {
+ label: 'Signal locked',
+ tone: 'text-emerald-600 dark:text-emerald-400',
+ dot: 'bg-emerald-500'
+ },
+ stale: {
+ label: 'Fallback signal',
+ tone: 'text-amber-600 dark:text-amber-400',
+ dot: 'bg-amber-500'
+ },
+ empty: {
+ label: 'No history',
+ tone: 'text-muted-foreground',
+ dot: 'bg-muted-foreground'
+ },
+ unconfigured: {
+ label: 'Not connected',
+ tone: 'text-muted-foreground',
+ dot: 'bg-muted-foreground'
+ },
+ unauthorized: {
+ label: 'Session rejected',
+ tone: 'text-red-600 dark:text-red-400',
+ dot: 'bg-red-500'
+ },
+ error: {
+ label: 'Signal lost',
+ tone: 'text-red-600 dark:text-red-400',
+ dot: 'bg-red-500'
+ }
+}
+
+export function YTMusicDiagnostics({ initialResult }: YTMusicDiagnosticsProps) {
+ const [result, setResult] = useState(initialResult)
+ const [isRefreshing, setIsRefreshing] = useState(false)
+ const [latency, setLatency] = useState(null)
+ const status = STATUS_PRESENTATION[result.status]
+
+ async function refresh() {
+ setIsRefreshing(true)
+ const startedAt = performance.now()
+
+ try {
+ const response = await fetch(
+ '/api/ytmusic/recent?limit=20&refresh=1',
+ {
+ cache: 'no-store'
+ }
+ )
+ const payload = (await response.json()) as YTMusicResult
+ setResult(payload)
+ setLatency(Math.round(performance.now() - startedAt))
+ } catch {
+ setResult(current => ({
+ ...current,
+ status: 'error',
+ message:
+ 'The diagnostic request could not reach the API endpoint.',
+ fetchedAt: new Date().toISOString()
+ }))
+ setLatency(null)
+ } finally {
+ setIsRefreshing(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {status.label}
+
+
+ {result.message}
+
+
+
+
+
+
+
+ }
+ />
+
+ ) : (
+
+ )
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+
+
+
+
+
+ Payload preview
+
+
+ Recent history
+
+
+
+
+
+ {result.tracks.length > 0 ? (
+
+ {result.tracks.map((track, index) => (
+
+ ))}
+
+ ) : (
+
+ )}
+
+
+
+
+ )
+}
+
+function Metric({
+ label,
+ value,
+ icon
+}: {
+ label: string
+ value: string
+ icon: ReactNode
+}) {
+ return (
+
+
+ {icon}
+ {label}
+
+
+ {value}
+
+
+ )
+}
+
+function TrackRow({ track, index }: { track: YTMusicTrack; index: number }) {
+ const image = getSafeImage(track.image)
+ return (
+
+
+ {String(index + 1).padStart(2, '0')}
+
+
+ {image ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+ {track.played_at_label || formatTimestamp(track.played_at)}
+
+ {track.played_at_estimated ? (
+
Approx.
+ ) : null}
+
+
+ )
+}
+
+function EmptyState({ status }: { status: YTMusicStatus }) {
+ const needsAttention =
+ status === 'error' ||
+ status === 'unauthorized' ||
+ status === 'unconfigured'
+ return (
+
+ {needsAttention ? (
+
+ ) : (
+
+ )}
+
+
+ {needsAttention
+ ? 'Configuration needs attention'
+ : 'The response is valid but contains no tracks'}
+
+
+ Use the status message above for the next action.
+
+
+
+ )
+}
+
+function SignalTrace({ active }: { active: boolean }) {
+ return (
+
+ )
+}
+
+function getSafeImage(value: string): string | null {
+ try {
+ const url = new URL(value)
+ return url.protocol === 'https:' && IMAGE_HOSTS.has(url.hostname)
+ ? value
+ : null
+ } catch {
+ return null
+ }
+}
+
+function formatSource(result: YTMusicResult): string {
+ if (result.source === 'youtube-music') return 'Live API'
+ if (result.source === 'database')
+ return result.isStale ? 'Stale cache' : 'Fresh cache'
+ return 'None'
+}
+
+function formatTimestamp(value: string): string {
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) return 'Unknown'
+ return new Intl.DateTimeFormat('en-GB', {
+ day: '2-digit',
+ month: 'short',
+ hour: '2-digit',
+ minute: '2-digit'
+ }).format(date)
+}
diff --git a/src/features/ytmusic/types.ts b/src/features/ytmusic/types.ts
index ecf19f1e..c11b0ef2 100644
--- a/src/features/ytmusic/types.ts
+++ b/src/features/ytmusic/types.ts
@@ -6,4 +6,27 @@ export interface YTMusicTrack {
url: string
image: string
played_at: string
+ played_at_estimated: boolean
+ played_at_label?: string
+}
+
+export type YTMusicStatus =
+ | 'ok'
+ | 'stale'
+ | 'empty'
+ | 'unconfigured'
+ | 'unauthorized'
+ | 'error'
+
+export type YTMusicSource = 'youtube-music' | 'database' | 'none'
+
+export interface YTMusicResult {
+ status: YTMusicStatus
+ source: YTMusicSource
+ tracks: YTMusicTrack[]
+ message: string
+ credentialsConfigured: boolean
+ isStale: boolean
+ fetchedAt: string
+ cacheUpdatedAt: string | null
}
diff --git a/src/server/env.ts b/src/server/env.ts
index 1e1706db..d8d0f961 100644
--- a/src/server/env.ts
+++ b/src/server/env.ts
@@ -18,6 +18,8 @@ export const env = createEnv({
ADMIN_EMAIL: z.string().email().optional(),
ALLOWED_GITHUB_USERNAME: z.string().optional(),
CRON_SECRET: z.string().min(1).optional(),
+ YTM_COOKIE: z.string().optional(),
+ YTM_AUTH_USER: z.string().optional(),
IP_INFO_TOKEN: z.string().optional()
},
@@ -37,6 +39,8 @@ export const env = createEnv({
ADMIN_EMAIL: process.env.ADMIN_EMAIL,
ALLOWED_GITHUB_USERNAME: process.env.ALLOWED_GITHUB_USERNAME,
CRON_SECRET: process.env.CRON_SECRET,
+ YTM_COOKIE: process.env.YTM_COOKIE,
+ YTM_AUTH_USER: process.env.YTM_AUTH_USER,
IP_INFO_TOKEN: process.env.IP_INFO_TOKEN
}
diff --git a/src/server/ytmusic/auth.ts b/src/server/ytmusic/auth.ts
index f5d48cdc..9b653199 100644
--- a/src/server/ytmusic/auth.ts
+++ b/src/server/ytmusic/auth.ts
@@ -1,69 +1,87 @@
import crypto from 'node:crypto'
const YTM_ORIGIN = 'https://music.youtube.com'
-const YTM_API_KEY = 'AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30'
+const FALLBACK_API_KEY = 'AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30'
+const FALLBACK_CLIENT_VERSION = '1.20260531.05.00'
+const REQUEST_TIMEOUT_MS = 10_000
+const USER_AGENT =
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36'
+
+interface PageConfig {
+ apiKey: string
+ clientVersion: string
+ pageId: string
+ userSessionId: string
+ visitorData: string
+}
+let cachedCookie = ''
let cachedCookieMap: Map | null = null
-let cachedAuthUser = '0'
-let cachedUserSessionId: string | null = null
-let cachedVisitorData: string | null = null
-let cachedPageId: string | null = null
+let cachedPageConfig: PageConfig | null = null
export function hasYTMusicCredentials(): boolean {
- const cookie = process.env.YTM_COOKIE
- return !!(
- cookie &&
- cookie !== 'your_ytm_cookie_here' &&
- !cookie.startsWith('#')
+ const cookie = process.env.YTM_COOKIE?.trim()
+ return Boolean(
+ cookie && cookie !== 'your_ytm_cookie_here' && !cookie.startsWith('#')
)
}
-function parseCookieMap(cookieStr: string): Map {
+function parseCookieMap(cookie: string): Map {
const map = new Map()
- for (const part of cookieStr.split(';')) {
- const eq = part.indexOf('=')
- if (eq > 0) {
- map.set(part.substring(0, eq).trim(), part.substring(eq + 1).trim())
- }
+ for (const part of cookie.split(';')) {
+ const separator = part.indexOf('=')
+ if (separator <= 0) continue
+ map.set(
+ part.slice(0, separator).trim(),
+ part.slice(separator + 1).trim()
+ )
}
return map
}
function rebuildCookieHeader(cookieMap: Map): string {
- const parts: string[] = []
- cookieMap.forEach((value, key) => parts.push(`${key}=${value}`))
- return parts.join('; ')
+ return Array.from(cookieMap, ([key, value]) => `${key}=${value}`).join('; ')
+}
+
+function readConfigValue(html: string, key: string): string {
+ const match = html.match(new RegExp(`"${key}"\\s*:\\s*"([^"]+)"`))
+ return match?.[1]?.replace(/\\u003d/gi, '=') ?? ''
}
async function fetchPageConfig(
cookieMap: Map
-): Promise<{ userSessionId: string; visitorData: string; pageId: string }> {
- // SOCS=CAI prevents YouTube from showing the GDPR consent page
- const cookieHeader = rebuildCookieHeader(cookieMap) + '; SOCS=CAI'
- const response = await fetch('https://music.youtube.com', {
+): Promise {
+ const cookieHeader = `${rebuildCookieHeader(cookieMap)}; SOCS=CAI`
+ const response = await fetch(YTM_ORIGIN, {
+ cache: 'no-store',
headers: {
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'accept-language': 'en-US,en;q=0.9',
cookie: cookieHeader,
- 'user-agent':
- 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36'
- }
+ 'user-agent': USER_AGENT
+ },
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
})
- const html = await response.text()
-
- const datasyncMatch = html.match(/"DATASYNC_ID"\s*:\s*"([^"]+)"/)
- if (!datasyncMatch)
- throw new Error('Could not find DATASYNC_ID in YT Music page')
- const datasyncParts = datasyncMatch[1].split('||')
- const userSessionId = datasyncParts[1] || datasyncParts[0] || ''
- const pageId = datasyncParts[0] || ''
+ if (!response.ok) {
+ throw new Error(`YouTube Music bootstrap failed (${response.status})`)
+ }
- const visitorMatch = html.match(/"VISITOR_DATA"\s*:\s*"([^"]+)"/)
- const visitorData = visitorMatch
- ? visitorMatch[1].replace(/\\u003d/gi, '=')
- : ''
+ const html = await response.text()
+ const datasyncId = readConfigValue(html, 'DATASYNC_ID')
+ if (!datasyncId) {
+ throw new Error('YouTube Music bootstrap did not return a session ID')
+ }
- return { userSessionId, visitorData, pageId }
+ const [pageId = '', userSessionId = pageId] = datasyncId.split('||')
+ return {
+ apiKey: readConfigValue(html, 'INNERTUBE_API_KEY') || FALLBACK_API_KEY,
+ clientVersion:
+ readConfigValue(html, 'INNERTUBE_CLIENT_VERSION') ||
+ FALLBACK_CLIENT_VERSION,
+ pageId,
+ userSessionId,
+ visitorData: readConfigValue(html, 'VISITOR_DATA')
+ }
}
function sapisidHash(
@@ -79,65 +97,52 @@ function sapisidHash(
}
async function generateAuthHeader(
- cookieMap: Map
-): Promise<{ authHeader: string; visitorData: string; pageId: string }> {
- const ts = Math.floor(Date.now() / 1000).toString()
-
- if (!cachedUserSessionId) {
- const config = await fetchPageConfig(cookieMap)
- cachedUserSessionId = config.userSessionId
- cachedVisitorData = config.visitorData
- cachedPageId = config.pageId
- }
-
+ cookieMap: Map,
+ pageConfig: PageConfig
+): Promise {
+ const timestamp = Math.floor(Date.now() / 1000).toString()
const sapisid =
cookieMap.get('__Secure-3PAPISID') || cookieMap.get('SAPISID')
- if (!sapisid) throw new Error('Missing __Secure-3PAPISID or SAPISID cookie')
-
const sapisid1 =
cookieMap.get('__Secure-1PAPISID') || cookieMap.get('APISID')
- if (!sapisid1) throw new Error('Missing __Secure-1PAPISID or APISID cookie')
-
- const sapisid3 = cookieMap.get('__Secure-3PAPISID') || sapisid
- const parts = [
- `SAPISIDHASH ${sapisidHash(ts, sapisid, cachedUserSessionId)}`,
- `SAPISID1PHASH ${sapisidHash(ts, sapisid1, cachedUserSessionId)}`,
- `SAPISID3PHASH ${sapisidHash(ts, sapisid3, cachedUserSessionId)}`
- ]
-
- return {
- authHeader: parts.join(' '),
- visitorData: cachedVisitorData!,
- pageId: cachedPageId!
+ if (!sapisid || !sapisid1) {
+ throw new Error(
+ 'The YouTube Music cookie is missing its SAPISID credentials'
+ )
}
+
+ return [
+ `SAPISIDHASH ${sapisidHash(timestamp, sapisid, pageConfig.userSessionId)}`,
+ `SAPISID1PHASH ${sapisidHash(timestamp, sapisid1, pageConfig.userSessionId)}`,
+ `SAPISID3PHASH ${sapisidHash(timestamp, sapisid, pageConfig.userSessionId)}`
+ ].join(' ')
}
export function invalidateYTMusicClient(): void {
+ cachedCookie = ''
cachedCookieMap = null
- cachedAuthUser = '0'
- cachedUserSessionId = null
- cachedVisitorData = null
- cachedPageId = null
+ cachedPageConfig = null
}
export async function fetchInnertube(
endpoint: string,
- body: Record,
- clientVersion?: string
-) {
- const cookieStr = process.env.YTM_COOKIE
- if (!cookieStr) throw new Error('YTM_COOKIE not configured')
-
- if (!cachedCookieMap) {
- cachedCookieMap = parseCookieMap(cookieStr)
+ body: Record
+): Promise {
+ const cookie = process.env.YTM_COOKIE?.trim()
+ if (!cookie) throw new Error('YTM_COOKIE is not configured')
+
+ if (!cachedCookieMap || cachedCookie !== cookie) {
+ invalidateYTMusicClient()
+ cachedCookie = cookie
+ cachedCookieMap = parseCookieMap(cookie)
+ }
+ if (!cachedPageConfig) {
+ cachedPageConfig = await fetchPageConfig(cachedCookieMap)
}
- cachedAuthUser = process.env.YTM_AUTH_USER || '0'
-
- const { authHeader, visitorData, pageId } =
- await generateAuthHeader(cachedCookieMap)
- const cookieHeader = rebuildCookieHeader(cachedCookieMap)
+ const pageConfig = cachedPageConfig
+ const authorization = await generateAuthHeader(cachedCookieMap, pageConfig)
const payload = {
...body,
context: {
@@ -145,8 +150,10 @@ export async function fetchInnertube(
hl: 'en',
gl: 'US',
clientName: 'WEB_REMIX',
- clientVersion: clientVersion || '1.20260531.05.00',
- ...(visitorData && { visitorData })
+ clientVersion: pageConfig.clientVersion,
+ ...(pageConfig.visitorData && {
+ visitorData: pageConfig.visitorData
+ })
},
user: { lockedSafetyMode: false },
request: {
@@ -158,33 +165,34 @@ export async function fetchInnertube(
}
const response = await fetch(
- `https://music.youtube.com/youtubei/v1/${endpoint}?alt=json&key=${YTM_API_KEY}`,
+ `${YTM_ORIGIN}/youtubei/v1/${endpoint}?alt=json&key=${pageConfig.apiKey}`,
{
method: 'POST',
headers: {
accept: '*/*',
'accept-language': 'en-US,en;q=0.9',
'content-type': 'application/json',
- Authorization: authHeader,
- Cookie: cookieHeader,
+ Authorization: authorization,
+ Cookie: rebuildCookieHeader(cachedCookieMap),
origin: YTM_ORIGIN,
- referer: 'https://music.youtube.com/',
- 'user-agent':
- 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36',
- 'X-Goog-AuthUser': cachedAuthUser,
- 'X-Goog-PageId': pageId,
+ referer: `${YTM_ORIGIN}/`,
+ 'user-agent': USER_AGENT,
+ 'X-Goog-AuthUser': process.env.YTM_AUTH_USER || '0',
+ 'X-Goog-PageId': pageConfig.pageId,
'X-Origin': YTM_ORIGIN,
'X-Youtube-Bootstrap-Logged-In': 'true',
'X-Youtube-Client-Name': '67',
- ...(visitorData && { 'X-Goog-Visitor-Id': visitorData })
+ ...(pageConfig.visitorData && {
+ 'X-Goog-Visitor-Id': pageConfig.visitorData
+ })
},
- body: JSON.stringify(payload)
+ body: JSON.stringify(payload),
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
}
)
if (!response.ok) {
- throw new Error(`Innertube API error: ${response.status}`)
+ throw new Error(`YouTube Music API failed (${response.status})`)
}
-
return response.json()
}
diff --git a/src/server/ytmusic/cache.ts b/src/server/ytmusic/cache.ts
new file mode 100644
index 00000000..ccfd6658
--- /dev/null
+++ b/src/server/ytmusic/cache.ts
@@ -0,0 +1,50 @@
+import { eq } from 'drizzle-orm'
+import type { YTMusicTrack } from '@/features/ytmusic/types'
+import { db } from '@/server/db/connection'
+import { ytmusicCache } from '@/server/db/ytmusic-schema'
+
+const DB_CACHE_KEY = 'recent'
+
+export interface YTMusicCacheEntry {
+ tracks: YTMusicTrack[]
+ updatedAt: Date
+}
+
+export async function readYTMusicCache(): Promise {
+ try {
+ const row = await db.query.ytmusicCache.findFirst({
+ where: eq(ytmusicCache.key, DB_CACHE_KEY)
+ })
+ if (!row) return null
+
+ return {
+ tracks: row.tracks.map(track => ({
+ ...track,
+ played_at_estimated: track.played_at_estimated ?? true
+ })),
+ updatedAt: row.updatedAt
+ }
+ } catch (error) {
+ console.error('[YTM Cache] Read failed:', error)
+ return null
+ }
+}
+
+export async function writeYTMusicCache(
+ tracks: YTMusicTrack[]
+): Promise {
+ const updatedAt = new Date()
+ try {
+ await db
+ .insert(ytmusicCache)
+ .values({ key: DB_CACHE_KEY, tracks, updatedAt })
+ .onConflictDoUpdate({
+ target: ytmusicCache.key,
+ set: { tracks, updatedAt }
+ })
+ return updatedAt
+ } catch (error) {
+ console.error('[YTM Cache] Write failed:', error)
+ return null
+ }
+}
diff --git a/src/server/ytmusic/index.ts b/src/server/ytmusic/index.ts
index d3e5756b..8792f45f 100644
--- a/src/server/ytmusic/index.ts
+++ b/src/server/ytmusic/index.ts
@@ -1,3 +1,8 @@
export * from './auth'
export * from './tracks'
-export type { YTMusicTrack } from '@/features/ytmusic/types'
+export type {
+ YTMusicResult,
+ YTMusicSource,
+ YTMusicStatus,
+ YTMusicTrack
+} from '@/features/ytmusic/types'
diff --git a/src/server/ytmusic/parser.ts b/src/server/ytmusic/parser.ts
new file mode 100644
index 00000000..44b46e8d
--- /dev/null
+++ b/src/server/ytmusic/parser.ts
@@ -0,0 +1,170 @@
+import type { YTMusicTrack } from '@/features/ytmusic/types'
+
+const YTM_BASE = 'https://music.youtube.com'
+const APPROXIMATE_TRACK_SPACING_MS = 3 * 60 * 1000
+
+type Renderer = Record
+
+export class YTMusicUnauthorizedError extends Error {
+ constructor(message = 'YouTube Music rejected the configured session') {
+ super(message)
+ this.name = 'YTMusicUnauthorizedError'
+ }
+}
+
+export function parseInnertubeTracks(
+ data: unknown,
+ limit: number,
+ now = new Date()
+): YTMusicTrack[] {
+ const sections = getSections(data)
+ assertAuthenticatedResponse(sections)
+
+ const tracks: YTMusicTrack[] = []
+ for (const section of sections) {
+ const shelf =
+ section?.musicShelfRenderer ?? section?.musicCarouselShelfRenderer
+ if (!shelf?.contents) continue
+
+ const playedLabel = getText(shelf.title)
+ for (const rawTrack of shelf.contents) {
+ if (tracks.length >= limit) return tracks
+
+ const item = rawTrack?.musicResponsiveListItemRenderer
+ if (!item) continue
+
+ const id = getVideoId(item)
+ const name = getFlexColumnText(item, 0)
+ if (!id || !name) continue
+
+ tracks.push({
+ id,
+ name,
+ artist: extractArtists(item) || 'Unknown',
+ album: getFlexColumnText(item, 2),
+ url: `${YTM_BASE}/watch?v=${id}`,
+ image: extractThumbnail(item),
+ played_at: estimatePlayedAt(playedLabel, tracks.length, now),
+ played_at_estimated: true,
+ ...(playedLabel && { played_at_label: playedLabel })
+ })
+ }
+ }
+
+ return tracks
+}
+
+export function stabilizeTrackTimestamps(
+ freshTracks: YTMusicTrack[],
+ cachedTracks: YTMusicTrack[]
+): YTMusicTrack[] {
+ const cachedByOccurrence = new Map()
+ const cachedCounts = new Map()
+
+ for (const track of cachedTracks) {
+ const occurrence = cachedCounts.get(track.id) ?? 0
+ cachedCounts.set(track.id, occurrence + 1)
+ cachedByOccurrence.set(`${track.id}:${occurrence}`, track)
+ }
+
+ const freshCounts = new Map()
+ return freshTracks.map(track => {
+ const occurrence = freshCounts.get(track.id) ?? 0
+ freshCounts.set(track.id, occurrence + 1)
+ const cached = cachedByOccurrence.get(`${track.id}:${occurrence}`)
+
+ if (!cached || !isIsoTimestamp(cached.played_at)) return track
+ return {
+ ...track,
+ played_at: cached.played_at,
+ played_at_estimated: true
+ }
+ })
+}
+
+function getSections(data: unknown): Renderer[] {
+ const contents = (data as Renderer)?.contents
+ ?.singleColumnBrowseResultsRenderer?.tabs?.[0]?.tabRenderer?.content
+ ?.sectionListRenderer?.contents
+ return Array.isArray(contents) ? contents : []
+}
+
+function assertAuthenticatedResponse(sections: Renderer[]) {
+ for (const section of sections) {
+ const messages = section?.itemSectionRenderer?.contents
+ if (!Array.isArray(messages)) continue
+
+ for (const item of messages) {
+ const message = getText(item?.messageRenderer?.text)
+ if (/sign in/i.test(message)) {
+ throw new YTMusicUnauthorizedError(message)
+ }
+ }
+ }
+}
+
+function getVideoId(item: Renderer): string {
+ return (
+ item?.overlay?.musicItemThumbnailOverlayRenderer?.content
+ ?.musicPlayButtonRenderer?.playNavigationEndpoint?.watchEndpoint
+ ?.videoId ?? ''
+ )
+}
+
+function getFlexColumnText(item: Renderer, index: number): string {
+ return getText(
+ item?.flexColumns?.[index]?.musicResponsiveListItemFlexColumnRenderer
+ ?.text
+ )
+}
+
+function getText(textRenderer: Renderer | undefined): string {
+ if (!Array.isArray(textRenderer?.runs)) return ''
+ return textRenderer.runs
+ .map((run: Renderer) => run?.text?.trim())
+ .filter(Boolean)
+ .join(' ')
+}
+
+function extractArtists(item: Renderer): string {
+ const runs =
+ item?.flexColumns?.[1]?.musicResponsiveListItemFlexColumnRenderer?.text
+ ?.runs
+ if (!Array.isArray(runs)) return ''
+
+ return runs
+ .map((run: Renderer) => run?.text?.trim())
+ .filter((text: string | undefined) => {
+ if (!text || text === '•' || text === '/') return false
+ if (text.includes('/') || text.startsWith('Album')) return false
+ return !/\d+(\.\d+)?[KMB]?\s*views?/i.test(text)
+ })
+ .join(', ')
+}
+
+function extractThumbnail(item: Renderer): string {
+ const thumbnails =
+ item?.thumbnail?.musicThumbnailRenderer?.thumbnail?.thumbnails
+ if (!Array.isArray(thumbnails) || thumbnails.length === 0) return ''
+ return thumbnails.at(-1)?.url ?? ''
+}
+
+function estimatePlayedAt(label: string, index: number, now: Date): string {
+ const lowerLabel = label.toLowerCase()
+ const estimate = new Date(now)
+
+ if (lowerLabel.includes('yesterday')) {
+ estimate.setUTCDate(estimate.getUTCDate() - 1)
+ estimate.setUTCHours(12, 0, 0, 0)
+ } else {
+ estimate.setTime(
+ estimate.getTime() - index * APPROXIMATE_TRACK_SPACING_MS
+ )
+ }
+
+ return estimate.toISOString()
+}
+
+function isIsoTimestamp(value: string): boolean {
+ return !Number.isNaN(Date.parse(value))
+}
diff --git a/src/server/ytmusic/python-bridge.ts b/src/server/ytmusic/python-bridge.ts
deleted file mode 100644
index ca478adc..00000000
--- a/src/server/ytmusic/python-bridge.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import { execSync } from 'node:child_process'
-import path from 'node:path'
-import type { YTMusicTrack } from '@/features/ytmusic/types'
-
-const PYTHON_VENV = path.resolve(
- process.cwd(),
- 'scripts/ytmusic/.venv/bin/python3'
-)
-const HISTORY_SCRIPT = path.resolve(
- process.cwd(),
- 'scripts/ytmusic/get_history.py'
-)
-const OAUTH_FILE = path.resolve(process.cwd(), 'scripts/ytmusic/oauth.json')
-
-let oauthAvailable: boolean | null = null
-
-export function hasOAuthCredentials(): boolean {
- if (oauthAvailable !== null) return oauthAvailable
- let available = false
- try {
- const { existsSync } = require('node:fs') as typeof import('node:fs')
- available = existsSync(OAUTH_FILE)
- } catch {
- available = false
- }
- oauthAvailable = available
- return available
-}
-
-interface YtmusicapiTrack {
- title?: string
- artists?: { name: string }[]
- album?: { name: string; thumbnails?: { url: string }[] }
- videoId?: string
- played?: string
-}
-
-function parsePythonHistory(raw: YtmusicapiTrack[]): YTMusicTrack[] {
- return raw.map(item => ({
- id: item.videoId || '',
- name: item.title || 'Unknown',
- artist: item.artists?.map(a => a.name).join(', ') || 'Unknown',
- album: item.album?.name || '',
- url: item.videoId
- ? `https://music.youtube.com/watch?v=${item.videoId}`
- : '',
- image: item.album?.thumbnails?.slice(-1)[0]?.url || '',
- played_at: item.played || new Date().toISOString()
- }))
-}
-
-export function getPythonYTMusicTracks(limit: number): YTMusicTrack[] {
- if (!hasOAuthCredentials()) return []
-
- try {
- const result = execSync(`${PYTHON_VENV} ${HISTORY_SCRIPT} ${limit}`, {
- encoding: 'utf-8',
- timeout: 15000
- })
- const data = JSON.parse(result.trim())
- return parsePythonHistory(data)
- } catch (error) {
- console.error('[YTM Python] Error:', error)
- return []
- }
-}
-
-export async function getLocalServerTracks(
- limit: number
-): Promise {
- try {
- const res = await fetch(`http://127.0.0.1:8370/recent?limit=${limit}`, {
- signal: AbortSignal.timeout(5000)
- })
- if (!res.ok) return []
- const data = await res.json()
- if (!Array.isArray(data) || data.length === 0) return []
- return data.map((t: any) => ({
- id: t.id || '',
- name: t.name || 'Unknown',
- artist:
- (t.artist || '').replace(/,?\s*•\s*/g, '').trim() || 'Unknown',
- album: t.album || '',
- url: `https://music.youtube.com/watch?v=${t.id}`,
- image: t.image || '',
- played_at: t.played_at || new Date().toISOString()
- }))
- } catch {
- return []
- }
-}
diff --git a/src/server/ytmusic/tracks.ts b/src/server/ytmusic/tracks.ts
index 268e3396..00661302 100644
--- a/src/server/ytmusic/tracks.ts
+++ b/src/server/ytmusic/tracks.ts
@@ -1,281 +1,154 @@
-import { unstable_cache } from 'next/cache'
+import type { YTMusicResult, YTMusicTrack } from '@/features/ytmusic/types'
import { fetchInnertube, hasYTMusicCredentials } from './auth'
+import { readYTMusicCache, writeYTMusicCache } from './cache'
import {
- hasOAuthCredentials,
- getPythonYTMusicTracks,
- getLocalServerTracks
-} from './python-bridge'
-import type { YTMusicTrack } from '@/features/ytmusic/types'
-import { db } from '@/server/db/connection'
-import { ytmusicCache } from '@/server/db/ytmusic-schema'
-import { eq } from 'drizzle-orm'
+ parseInnertubeTracks,
+ stabilizeTrackTimestamps,
+ YTMusicUnauthorizedError
+} from './parser'
-const YTM_BASE = 'https://music.youtube.com'
-const MAX_DETECTED_TRACKS = 500
-const DB_CACHE_KEY = 'recent'
+const FRESH_CACHE_MS = 30_000
-let lastSeenIds: Set = new Set()
-let firstSeenAt: Map = new Map()
+interface GetYTMusicOptions {
+ forceRefresh?: boolean
+}
-async function readDbCache(): Promise {
- try {
- const row = await db.query.ytmusicCache.findFirst({
- where: eq(ytmusicCache.key, DB_CACHE_KEY)
+export async function getYTMusicResult(
+ limit: number,
+ { forceRefresh = false }: GetYTMusicOptions = {}
+): Promise {
+ const fetchedAt = new Date().toISOString()
+ const credentialsConfigured = hasYTMusicCredentials()
+ const cache = await readYTMusicCache()
+ const cachedTracks = cache?.tracks.slice(0, limit) ?? []
+ const cacheUpdatedAt = cache?.updatedAt.toISOString() ?? null
+ const cacheIsFresh = cache
+ ? Date.now() - cache.updatedAt.getTime() < FRESH_CACHE_MS
+ : false
+
+ if (!forceRefresh && cacheIsFresh && cachedTracks.length > 0) {
+ return createResult({
+ status: 'ok',
+ source: 'database',
+ tracks: cachedTracks,
+ message: 'Serving the latest saved YouTube Music response.',
+ credentialsConfigured,
+ isStale: false,
+ fetchedAt,
+ cacheUpdatedAt
})
- return row?.tracks ?? []
- } catch {
- return []
}
-}
-
-async function writeDbCache(tracks: YTMusicTrack[]): Promise {
- try {
- await db
- .insert(ytmusicCache)
- .values({ key: DB_CACHE_KEY, tracks, updatedAt: new Date() })
- .onConflictDoUpdate({
- target: ytmusicCache.key,
- set: { tracks, updatedAt: new Date() }
- })
- } catch {}
-}
-
-function formatPythonTracks(tracks: YTMusicTrack[]): YTMusicTrack[] {
- const now = Date.now()
- return tracks.map((track, i) => ({
- ...track,
- played_at: track.played_at || new Date(now - i * 180_000).toISOString()
- }))
-}
-
-export const getYTMusicTracks = unstable_cache(
- async (limit: number): Promise => {
- let result: YTMusicTrack[] = []
-
- try {
- if (hasYTMusicCredentials()) {
- const data = await fetchInnertube('browse', {
- browseId: 'FEmusic_history'
- })
- const contents = traverse(
- data,
- 'contents',
- 'singleColumnBrowseResultsRenderer',
- 'tabs',
- '0',
- 'tabRenderer',
- 'content',
- 'sectionListRenderer',
- 'contents'
- ) as any[] | undefined
- if (Array.isArray(contents) && contents.length > 0) {
- result = parseInnertubeTracks(contents, limit)
- }
- }
-
- if (result.length === 0) {
- const localTracks = await getLocalServerTracks(limit)
- if (localTracks.length > 0) result = localTracks
- }
-
- if (result.length === 0 && hasOAuthCredentials()) {
- const tracks = getPythonYTMusicTracks(limit)
- if (tracks.length > 0) result = formatPythonTracks(tracks)
- }
-
- if (result.length > 0) {
- await writeDbCache(result)
- return result
- }
- } catch (error) {
- console.error('[YTM Tracks] Error:', error)
- const localTracks = await getLocalServerTracks(limit)
- if (localTracks.length > 0) {
- result = localTracks
- await writeDbCache(result)
- return result
- }
- if (hasOAuthCredentials()) {
- const tracks = getPythonYTMusicTracks(limit)
- if (tracks.length > 0) {
- result = formatPythonTracks(tracks)
- await writeDbCache(result)
- return result
- }
- }
+ if (!credentialsConfigured) {
+ if (cachedTracks.length > 0) {
+ return createStaleResult(
+ cachedTracks,
+ fetchedAt,
+ cacheUpdatedAt,
+ 'Live access is not configured. Showing the last saved response.'
+ )
}
- const cached = await readDbCache()
- if (cached.length > 0) return cached.slice(0, limit)
- return []
- },
- ['ytmusic-recent-v2'],
- { revalidate: 30, tags: ['ytmusic'] }
-)
-
-function parseInnertubeTracks(contents: any[], limit: number): YTMusicTrack[] {
- const rawTracks: any[] = []
- for (const section of contents) {
- const shelf =
- section?.musicShelfRenderer ?? section?.musicCarouselShelfRenderer
- if (shelf?.contents) {
- rawTracks.push(...shelf.contents)
- }
+ return createResult({
+ status: 'unconfigured',
+ source: 'none',
+ tracks: [],
+ message: 'Set YTM_COOKIE to connect YouTube Music.',
+ credentialsConfigured: false,
+ isStale: false,
+ fetchedAt,
+ cacheUpdatedAt: null
+ })
}
- if (!rawTracks.length) return []
-
- const freshIds = new Set()
- const result: YTMusicTrack[] = []
- const now = Date.now()
-
- for (let i = 0; i < rawTracks.length; i++) {
- if (result.length >= limit) break
-
- const item = rawTracks[i]?.musicResponsiveListItemRenderer
- if (!item) continue
-
- const id = traverse(
- item,
- 'overlay',
- 'musicItemThumbnailOverlayRenderer',
- 'content',
- 'musicPlayButtonRenderer',
- 'playNavigationEndpoint',
- 'watchEndpoint',
- 'videoId'
- ) as string | undefined
- const title = traverse(
- item,
- 'flexColumns',
- '0',
- 'musicResponsiveListItemFlexColumnRenderer',
- 'text',
- 'runs',
- '0',
- 'text'
- ) as string | undefined
-
- if (!id || !title) continue
-
- freshIds.add(id)
-
- if (!firstSeenAt.has(id)) {
- firstSeenAt.set(id, new Date(now - i * 180_000).toISOString())
- }
+ try {
+ const data = await fetchInnertube('browse', {
+ browseId: 'FEmusic_history'
+ })
+ const parsedTracks = parseInnertubeTracks(data, limit)
+ const tracks = stabilizeTrackTimestamps(
+ parsedTracks,
+ cache?.tracks ?? []
+ )
- if (firstSeenAt.size > MAX_DETECTED_TRACKS) {
- const keys = firstSeenAt.keys()
- for (let j = 0; j < 100; j++) {
- const key = keys.next()
- if (key.done) break
- if (!freshIds.has(key.value)) {
- firstSeenAt.delete(key.value)
- }
- }
+ if (tracks.length === 0) {
+ return createResult({
+ status: 'empty',
+ source: 'youtube-music',
+ tracks: [],
+ message: 'YouTube Music returned an empty listening history.',
+ credentialsConfigured: true,
+ isStale: false,
+ fetchedAt,
+ cacheUpdatedAt
+ })
}
- const artists = extractArtists(item)
- const album = extractAlbum(item)
- const thumbnail = extractThumbnail(item)
-
- result.push({
- id,
- name: title,
- artist: artists || 'Unknown',
- album: album || '',
- url: `${YTM_BASE}/watch?v=${id}`,
- image: thumbnail || '',
- played_at: firstSeenAt.get(id)!
+ const updatedAt = await writeYTMusicCache(tracks)
+ return createResult({
+ status: 'ok',
+ source: 'youtube-music',
+ tracks,
+ message: 'Live listening history received from YouTube Music.',
+ credentialsConfigured: true,
+ isStale: false,
+ fetchedAt,
+ cacheUpdatedAt: updatedAt?.toISOString() ?? cacheUpdatedAt
})
- }
-
- lastSeenIds = freshIds
- return result
-}
-
-function traverse(obj: unknown, ...keys: string[]): unknown {
- if (!obj || typeof obj !== 'object') return undefined
- let current: any = obj
- for (const key of keys) {
- if (key === '*') {
- if (Array.isArray(current)) {
- for (const item of current) {
- const result = traverse(
- item,
- ...keys.slice(keys.indexOf('*') + 1)
- )
- if (result !== undefined) return result
- }
- return undefined
- }
- return undefined
+ } catch (error) {
+ const unauthorized = error instanceof YTMusicUnauthorizedError
+ const message = unauthorized
+ ? 'YouTube Music rejected the session. Replace YTM_COOKIE with a fresh browser cookie.'
+ : 'YouTube Music could not be reached.'
+
+ if (!unauthorized) {
+ console.error('[YTM Tracks]', message, error)
}
- if (current == null || typeof current !== 'object') return undefined
- if (Array.isArray(current)) {
- const idx = parseInt(key, 10)
- if (isNaN(idx)) return undefined
- current = current[idx]
- } else {
- current = (current as Record)[key]
+ if (cachedTracks.length > 0) {
+ return createStaleResult(
+ cachedTracks,
+ fetchedAt,
+ cacheUpdatedAt,
+ `${message} Showing the last saved response.`
+ )
}
- }
- return current
-}
-function extractArtists(item: any): string {
- const runs = traverse(
- item,
- 'flexColumns',
- '1',
- 'musicResponsiveListItemFlexColumnRenderer',
- 'text',
- 'runs'
- ) as any[] | undefined
- if (!Array.isArray(runs)) return ''
-
- const names: string[] = []
- for (const run of runs) {
- const text = run?.text?.trim()
- if (
- !text ||
- text === '•' ||
- text === '/' ||
- text.includes('/') ||
- text.startsWith('Album') ||
- /\d+(\.\d+)?[KMB]?\s*views?/i.test(text)
- )
- continue
- names.push(text)
+ return createResult({
+ status: unauthorized ? 'unauthorized' : 'error',
+ source: 'none',
+ tracks: [],
+ message,
+ credentialsConfigured: true,
+ isStale: false,
+ fetchedAt,
+ cacheUpdatedAt
+ })
}
+}
- return names.join(', ')
+export async function getYTMusicTracks(limit: number): Promise {
+ const result = await getYTMusicResult(limit)
+ return result.tracks
}
-function extractAlbum(item: any): string {
- const album = traverse(
- item,
- 'flexColumns',
- '2',
- 'musicResponsiveListItemFlexColumnRenderer',
- 'text',
- 'runs',
- '0',
- 'text'
- ) as string | undefined
- return album || ''
+function createStaleResult(
+ tracks: YTMusicTrack[],
+ fetchedAt: string,
+ cacheUpdatedAt: string | null,
+ message: string
+): YTMusicResult {
+ return createResult({
+ status: 'stale',
+ source: 'database',
+ tracks,
+ message,
+ credentialsConfigured: hasYTMusicCredentials(),
+ isStale: true,
+ fetchedAt,
+ cacheUpdatedAt
+ })
}
-function extractThumbnail(item: any): string {
- const thumbnails = traverse(
- item,
- 'thumbnail',
- 'musicThumbnailRenderer',
- 'thumbnail',
- 'thumbnails'
- ) as any[] | undefined
- if (!Array.isArray(thumbnails) || !thumbnails.length) return ''
- const last = thumbnails[thumbnails.length - 1]
- return last?.url || ''
+function createResult(result: YTMusicResult): YTMusicResult {
+ return result
}