feat: name downloaded file after the video title and add a back-to-home link

The download endpoint served files under their internal uuid-based
storage name; it now builds the Content-Disposition filename from the
probed video title (persisted on submit) with the original extension,
falling back to the old behavior when no title is available. The
status page also gets an always-visible link back to the homepage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 15:53:14 +02:00
co-authored by Claude Sonnet 5
parent ecdfc95ff3
commit 38e52374c9
19 changed files with 160 additions and 9 deletions
+1
View File
@@ -28,6 +28,7 @@
"videoLength": "Length: {duration}" "videoLength": "Length: {duration}"
}, },
"status": { "status": {
"backHome": "Back to homepage",
"heading": "Download status", "heading": "Download status",
"notFound": "Download not found.", "notFound": "Download not found.",
"loading": "Loading...", "loading": "Loading...",
+1
View File
@@ -28,6 +28,7 @@
"videoLength": "Duración: {duration}" "videoLength": "Duración: {duration}"
}, },
"status": { "status": {
"backHome": "Volver al inicio",
"heading": "Estado de la descarga", "heading": "Estado de la descarga",
"notFound": "Descarga no encontrada.", "notFound": "Descarga no encontrada.",
"loading": "Cargando...", "loading": "Cargando...",
+1
View File
@@ -28,6 +28,7 @@
"videoLength": "Durée : {duration}" "videoLength": "Durée : {duration}"
}, },
"status": { "status": {
"backHome": "Retour à l'accueil",
"heading": "Statut du téléchargement", "heading": "Statut du téléchargement",
"notFound": "Téléchargement introuvable.", "notFound": "Téléchargement introuvable.",
"loading": "Chargement...", "loading": "Chargement...",
+1
View File
@@ -28,6 +28,7 @@
"videoLength": "Durata: {duration}" "videoLength": "Durata: {duration}"
}, },
"status": { "status": {
"backHome": "Torna alla home",
"heading": "Stato del download", "heading": "Stato del download",
"notFound": "Download non trovato.", "notFound": "Download non trovato.",
"loading": "Caricamento...", "loading": "Caricamento...",
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `Download` ADD COLUMN `title` TEXT NULL;
+1
View File
@@ -19,6 +19,7 @@ model Download {
clipEnd Int? clipEnd Int?
audioQuality String? audioQuality String?
extraArgs String? @db.Text extraArgs String? @db.Text
title String? @db.Text
filePath String? @db.Text filePath String? @db.Text
fileName String? fileName String?
+9
View File
@@ -1,4 +1,5 @@
import { getTranslations } from 'next-intl/server' import { getTranslations } from 'next-intl/server'
import { Link } from '@/navigation'
import { StatusView } from '@/components/StatusView' import { StatusView } from '@/components/StatusView'
export default async function StatusPage({ export default async function StatusPage({
@@ -11,6 +12,14 @@ export default async function StatusPage({
return ( return (
<main className="flex flex-col items-center px-6 py-16"> <main className="flex flex-col items-center px-6 py-16">
<div className="w-full max-w-md mb-6">
<Link
href="/"
className="text-sm text-gray-500 hover:text-violet-600 dark:text-gray-400 dark:hover:text-violet-400 transition-colors"
>
&larr; {t('backHome')}
</Link>
</div>
<h1 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-gray-50 mb-10"> <h1 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-gray-50 mb-10">
{t('heading')} {t('heading')}
</h1> </h1>
@@ -1,6 +1,9 @@
import { GET } from '../route' import { GET } from '../route'
import * as tokenLib from '@/lib/token' import * as tokenLib from '@/lib/token'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { writeFileSync, unlinkSync } from 'fs'
import path from 'path'
import os from 'os'
jest.mock('@/lib/token') jest.mock('@/lib/token')
jest.mock('@/lib/prisma', () => ({ jest.mock('@/lib/prisma', () => ({
@@ -8,12 +11,17 @@ jest.mock('@/lib/prisma', () => ({
})) }))
const mockValidate = tokenLib.validateToken as jest.Mock const mockValidate = tokenLib.validateToken as jest.Mock
const mockTokenUpdate = prisma.downloadToken.update as jest.Mock
function req(token: string) { function req(token: string) {
return new Request(`http://localhost/api/download/${token}`) return new Request(`http://localhost/api/download/${token}`)
} }
describe('GET /api/download/[token]', () => { describe('GET /api/download/[token]', () => {
beforeEach(() => {
mockTokenUpdate.mockResolvedValue({})
})
it('returns 404 for invalid or expired token', async () => { it('returns 404 for invalid or expired token', async () => {
mockValidate.mockResolvedValue(null) mockValidate.mockResolvedValue(null)
const res = await GET(req('bad'), { params: Promise.resolve({ token: 'bad' }) }) const res = await GET(req('bad'), { params: Promise.resolve({ token: 'bad' }) })
@@ -23,10 +31,24 @@ describe('GET /api/download/[token]', () => {
}) })
it('returns 404 when file does not exist on disk', async () => { it('returns 404 when file does not exist on disk', async () => {
mockValidate.mockResolvedValue({ downloadId: 'dl-1', filePath: '/nonexistent/path/abc.mp4' }) mockValidate.mockResolvedValue({ downloadId: 'dl-1', filePath: '/nonexistent/path/abc.mp4', title: null })
const res = await GET(req('ok-tok'), { params: Promise.resolve({ token: 'ok-tok' }) }) const res = await GET(req('ok-tok'), { params: Promise.resolve({ token: 'ok-tok' }) })
expect(res.status).toBe(404) expect(res.status).toBe(404)
const body = await res.json() const body = await res.json()
expect(body.error).toBe('File not found on server') expect(body.error).toBe('File not found on server')
}) })
it('serves the file with a title-based Content-Disposition filename', async () => {
const filePath = path.join(os.tmpdir(), `test-${Date.now()}.mp4`)
writeFileSync(filePath, 'video-bytes')
mockValidate.mockResolvedValue({ downloadId: 'dl-1', filePath, title: 'My Cool Video' })
const res = await GET(req('ok-tok'), { params: Promise.resolve({ token: 'ok-tok' }) })
expect(res.status).toBe(200)
expect(res.headers.get('Content-Disposition')).toContain('filename="My Cool Video.mp4"')
await res.text()
unlinkSync(filePath)
})
}) })
+4 -3
View File
@@ -3,6 +3,7 @@ import { createReadStream, statSync } from 'fs'
import path from 'path' import path from 'path'
import { validateToken } from '@/lib/token' import { validateToken } from '@/lib/token'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { buildDownloadFilename, contentDispositionHeader } from '@/lib/filename'
export async function GET( export async function GET(
_req: NextRequest, _req: NextRequest,
@@ -15,7 +16,7 @@ export async function GET(
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 404 }) return NextResponse.json({ error: 'Invalid or expired token' }, { status: 404 })
} }
const { filePath } = result const { filePath, title } = result
let stat: ReturnType<typeof statSync> let stat: ReturnType<typeof statSync>
try { try {
@@ -29,12 +30,12 @@ export async function GET(
data: { usedAt: new Date() }, data: { usedAt: new Date() },
}) })
const fileName = path.basename(filePath) const fileName = buildDownloadFilename(title, path.basename(filePath))
const stream = createReadStream(filePath) const stream = createReadStream(filePath)
return new NextResponse(stream as unknown as ReadableStream, { return new NextResponse(stream as unknown as ReadableStream, {
headers: { headers: {
'Content-Disposition': `attachment; filename="${fileName}"`, 'Content-Disposition': contentDispositionHeader(fileName),
'Content-Length': String(stat.size), 'Content-Length': String(stat.size),
'Content-Type': 'application/octet-stream', 'Content-Type': 'application/octet-stream',
}, },
@@ -40,6 +40,20 @@ describe('GET /api/downloads/[uuid]', () => {
expect(body.fileSize).toBe('1048576') expect(body.fileSize).toBe('1048576')
}) })
it('derives fileName from the video title when available', async () => {
mockFindUnique.mockResolvedValue({ ...baseDownload, title: 'My Video', tokens: [] })
const res = await GET(req('abc'), { params: Promise.resolve({ uuid: 'abc' }) })
const body = await res.json()
expect(body.fileName).toBe('My Video.mp4')
})
it('falls back to the stored fileName when there is no title', async () => {
mockFindUnique.mockResolvedValue({ ...baseDownload, title: null, tokens: [] })
const res = await GET(req('abc'), { params: Promise.resolve({ uuid: 'abc' }) })
const body = await res.json()
expect(body.fileName).toBe('video.mp4')
})
it('returns downloadToken when status is DONE and token is valid', async () => { it('returns downloadToken when status is DONE and token is valid', async () => {
mockFindUnique.mockResolvedValue({ mockFindUnique.mockResolvedValue({
...baseDownload, ...baseDownload,
+2 -1
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { buildDownloadFilename } from '@/lib/filename'
export async function GET( export async function GET(
_req: NextRequest, _req: NextRequest,
@@ -28,7 +29,7 @@ export async function GET(
format: download.format, format: download.format,
quality: download.quality, quality: download.quality,
subtitles: download.subtitles, subtitles: download.subtitles,
fileName: download.fileName, fileName: download.fileName ? buildDownloadFilename(download.title, download.fileName) : null,
fileSize: download.fileSize?.toString() ?? null, fileSize: download.fileSize?.toString() ?? null,
errorMsg: download.errorMsg, errorMsg: download.errorMsg,
submittedAt: download.submittedAt, submittedAt: download.submittedAt,
@@ -88,4 +88,21 @@ describe('POST /api/downloads', () => {
data: expect.objectContaining({ clipStart: 10, clipEnd: 30, audioQuality: '192' }), data: expect.objectContaining({ clipStart: 10, clipEnd: 30, audioQuality: '192' }),
}) })
}) })
it('stores the video title when provided', async () => {
await POST(req({
url: 'https://y.com', format: 'mp4', quality: 'best', subtitles: false,
title: 'My Video',
}))
expect(mockCreate).toHaveBeenCalledWith({
data: expect.objectContaining({ title: 'My Video' }),
})
})
it('stores a null title when not provided', async () => {
await POST(req({ url: 'https://y.com', format: 'mp4', quality: 'best', subtitles: false }))
expect(mockCreate).toHaveBeenCalledWith({
data: expect.objectContaining({ title: null }),
})
})
}) })
+1
View File
@@ -49,6 +49,7 @@ export async function POST(req: NextRequest) {
clipEnd, clipEnd,
audioQuality: body.audioQuality ? String(body.audioQuality) : null, audioQuality: body.audioQuality ? String(body.audioQuality) : null,
extraArgs: body.extraArgs ?? null, extraArgs: body.extraArgs ?? null,
title: body.title ? String(body.title) : null,
ipAddress: ip, ipAddress: ip,
}, },
}) })
+1
View File
@@ -136,6 +136,7 @@ export function SubmitForm({ examplePlaceholder }: { examplePlaceholder?: string
url, url,
format, format,
quality, quality,
title: probe.data.title || null,
subtitles, subtitles,
subtitleLangs: subtitles && subtitleLangs.length ? subtitleLangs : null, subtitleLangs: subtitles && subtitleLangs.length ? subtitleLangs : null,
audioQuality: format === 'mp3' ? audioQuality : null, audioQuality: format === 'mp3' ? audioQuality : null,
+43
View File
@@ -0,0 +1,43 @@
import { sanitizeFilenameStem, buildDownloadFilename, contentDispositionHeader } from '../filename'
describe('sanitizeFilenameStem', () => {
it('strips filesystem-illegal characters', () => {
expect(sanitizeFilenameStem('a/b\\c:d*e?f"g<h>i|j')).toBe('a b c d e f g h i j')
})
it('collapses whitespace and trims', () => {
expect(sanitizeFilenameStem(' My Video Title ')).toBe('My Video Title')
})
it('falls back to "download" when the result is empty', () => {
expect(sanitizeFilenameStem('///???')).toBe('download')
})
it('truncates very long titles', () => {
const long = 'a'.repeat(300)
expect(sanitizeFilenameStem(long).length).toBe(150)
})
})
describe('buildDownloadFilename', () => {
it('returns the stored filename when title is null', () => {
expect(buildDownloadFilename(null, 'abc-123.mp4')).toBe('abc-123.mp4')
})
it('builds a sanitized name with the original extension', () => {
expect(buildDownloadFilename('My Awesome Video', 'abc-123.mp4')).toBe('My Awesome Video.mp4')
})
it('preserves the extension for audio downloads', () => {
expect(buildDownloadFilename('Podcast Episode', 'abc-123.mp3')).toBe('Podcast Episode.mp3')
})
})
describe('contentDispositionHeader', () => {
it('includes an ASCII fallback and a UTF-8 encoded filename*', () => {
const header = contentDispositionHeader('Café Vidéo.mp4')
expect(header).toContain('attachment;')
expect(header).toContain('filename="Caf_ Vid_o.mp4"')
expect(header).toContain(`filename*=UTF-8''${encodeURIComponent('Café Vidéo.mp4')}`)
})
})
+12
View File
@@ -60,4 +60,16 @@ describe('validateToken', () => {
filePath: '/storage/abc.mp4', filePath: '/storage/abc.mp4',
}) })
}) })
it('returns the video title alongside the file path', async () => {
mockFindUnique.mockResolvedValue({
expiresAt: new Date(Date.now() + 3_600_000),
download: { id: 'dl-1', filePath: '/storage/abc.mp4', title: 'My Video' },
})
expect(await validateToken('tok')).toEqual({
downloadId: 'dl-1',
filePath: '/storage/abc.mp4',
title: 'My Video',
})
})
}) })
+19
View File
@@ -0,0 +1,19 @@
import path from 'path'
const ILLEGAL_CHARS = /[<>:"/\\|?*\x00-\x1F]/g
export function sanitizeFilenameStem(title: string): string {
const cleaned = title.replace(ILLEGAL_CHARS, ' ').replace(/\s+/g, ' ').trim()
return cleaned.slice(0, 150) || 'download'
}
export function buildDownloadFilename(title: string | null, storedFileName: string): string {
if (!title) return storedFileName
const ext = path.extname(storedFileName)
return `${sanitizeFilenameStem(title)}${ext}`
}
export function contentDispositionHeader(filename: string): string {
const asciiFallback = filename.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, "'")
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`
}
+7 -3
View File
@@ -13,13 +13,17 @@ export async function createToken(downloadId: string): Promise<string> {
export async function validateToken( export async function validateToken(
token: string token: string
): Promise<{ downloadId: string; filePath: string } | null> { ): Promise<{ downloadId: string; filePath: string; title: string | null } | null> {
const record = await prisma.downloadToken.findUnique({ const record = await prisma.downloadToken.findUnique({
where: { token }, where: { token },
include: { download: { select: { id: true, filePath: true } } }, include: { download: { select: { id: true, filePath: true, title: true } } },
}) })
if (!record) return null if (!record) return null
if (record.expiresAt < new Date()) return null if (record.expiresAt < new Date()) return null
if (!record.download.filePath) return null if (!record.download.filePath) return null
return { downloadId: record.download.id, filePath: record.download.filePath } return {
downloadId: record.download.id,
filePath: record.download.filePath,
title: record.download.title,
}
} }
+1 -1
View File
File diff suppressed because one or more lines are too long