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:
@@ -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...",
|
||||||
|
|||||||
@@ -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...",
|
||||||
|
|||||||
@@ -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...",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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?
|
||||||
|
|||||||
@@ -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"
|
||||||
|
>
|
||||||
|
← {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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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')}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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',
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user