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
+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',
})
})
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(
token: string
): Promise<{ downloadId: string; filePath: string } | null> {
): Promise<{ downloadId: string; filePath: string; title: string | null } | null> {
const record = await prisma.downloadToken.findUnique({
where: { token },
include: { download: { select: { id: true, filePath: true } } },
include: { download: { select: { id: true, filePath: true, title: true } } },
})
if (!record) return null
if (record.expiresAt < new Date()) 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,
}
}