diff --git a/app/components/EditTorrentModal.vue b/app/components/EditTorrentModal.vue index 9ede9204..c77364d3 100644 --- a/app/components/EditTorrentModal.vue +++ b/app/components/EditTorrentModal.vue @@ -30,19 +30,22 @@
- +
-
- {{ torrent.name }} -
+
+ +
+ +
+ +
+ + +
+

+ Keep it private: it contains your passkey. +

+
+ + +
+ + +
+
+ +
@@ -336,6 +383,39 @@ const isUploading = ref(false); const result = ref(null); const error = ref(null); const copied = ref(false); +const customName = ref(''); +const mediaIds = ref({ imdbId: '', tmdbId: '', tvdbId: '' }); +const announceUrl = ref(null); +const announceCopied = ref(false); +const config = useRuntimeConfig(); + +// Passkey is not in the client session: fetch it when the modal opens +watch( + () => props.isOpen, + async (open) => { + if (!open || announceUrl.value) return; + try { + const { passkey } = await $fetch<{ passkey: string }>('/api/auth/passkey'); + const url = new URL(config.public.trackerHttpUrl as string); + url.searchParams.set('passkey', passkey); + announceUrl.value = url.toString(); + } catch { + announceUrl.value = null; + } + }, + { immediate: true } +); + +async function copyAnnounce() { + if (!announceUrl.value) return; + try { + await navigator.clipboard.writeText(announceUrl.value); + announceCopied.value = true; + setTimeout(() => (announceCopied.value = false), 2000); + } catch { + error.value = 'Failed to copy to clipboard'; + } +} const { data: categories } = await useFetch('/api/categories'); @@ -373,6 +453,8 @@ function reset() { selectedFile.value = null; selectedCategoryId.value = ''; description.value = ''; + customName.value = ''; + mediaIds.value = { imdbId: '', tmdbId: '', tvdbId: '' }; isPreview.value = false; result.value = null; error.value = null; @@ -483,6 +565,12 @@ async function upload() { if (description.value) { formData.append('description', description.value); } + if (customName.value.trim()) { + formData.append('name', customName.value.trim()); + } + for (const [key, value] of Object.entries(mediaIds.value)) { + if (value.trim()) formData.append(key, value.trim()); + } const response = await $fetch('/api/torrents', { method: 'POST', diff --git a/app/pages/torrents/[hash].vue b/app/pages/torrents/[hash].vue index 7d728e8b..dfed43a3 100644 --- a/app/pages/torrents/[hash].vue +++ b/app/pages/torrents/[hash].vue @@ -27,13 +27,28 @@

{{ torrent.name }}

- -
+ +
{{ torrent.category.name }} + + {{ link.label }} + +
@@ -305,6 +320,7 @@ interface Category { id: string; name: string; slug: string; + newznabId?: number | null; } interface TorrentDetail { @@ -316,6 +332,9 @@ interface TorrentDetail { uploaderId: string | null; categoryId: string | null; category: Category | null; + imdbId: string | null; + tmdbId: number | null; + tvdbId: number | null; createdAt: string; stats: { seeders: number; @@ -362,8 +381,26 @@ const editableTorrent = computed(() => ({ name: torrent.value?.name || '', description: torrent.value?.description || null, categoryId: torrent.value?.categoryId || null, + imdbId: torrent.value?.imdbId ?? null, + tmdbId: torrent.value?.tmdbId ?? null, + tvdbId: torrent.value?.tvdbId ?? null, })); +const externalLinks = computed(() => { + const t = torrent.value; + if (!t) return []; + const links: { label: string; href: string }[] = []; + if (t.imdbId) links.push({ label: 'IMDb', href: imdbUrl(t.imdbId) }); + if (t.tmdbId) { + // Torznab TV categories are 5000-5999 + const newznabId = t.category?.newznabId ?? 0; + const kind = t.tvdbId || (newznabId >= 5000 && newznabId < 6000) ? 'tv' : 'movie'; + links.push({ label: 'TMDb', href: tmdbUrl(t.tmdbId, kind) }); + } + if (t.tvdbId) links.push({ label: 'TheTVDB', href: tvdbUrl(t.tvdbId) }); + return links; +}); + const renderedDescription = computed(() => { return renderMarkdown(torrent.value?.description); }); diff --git a/server/api/torrents/[hash]/index.patch.ts b/server/api/torrents/[hash]/index.patch.ts index a5e120ec..0f3f619a 100644 --- a/server/api/torrents/[hash]/index.patch.ts +++ b/server/api/torrents/[hash]/index.patch.ts @@ -9,8 +9,15 @@ import { torrents, categories } from '../../../db/schema'; import { requireAuthSession } from '../../../utils/adminAuth'; import { rateLimit, RATE_LIMITS } from '../../../utils/rateLimit'; import { z } from 'zod'; +import { parseMediaIds } from '../../../../shared/utils/mediaIds'; + +const mediaIdSchema = z.union([z.string().max(200), z.number()]).nullable().optional(); const patchSchema = z.object({ + name: z.string().trim().min(1).max(255).optional(), + imdbId: mediaIdSchema, + tmdbId: mediaIdSchema, + tvdbId: mediaIdSchema, description: z.string().max(10000).nullable().optional(), categoryId: z.union([z.uuid(), z.literal('')]).nullable().optional(), }); @@ -61,7 +68,14 @@ export default defineEventHandler(async (event) => { if (!parsedBody.success) { throw createError({ statusCode: 400, message: 'Invalid request body' }); } - const { description, categoryId } = parsedBody.data; + const { description, categoryId, name } = parsedBody.data; + const media = parseMediaIds(parsedBody.data); + if (media.invalid.length > 0) { + throw createError({ + statusCode: 400, + message: `Invalid ${media.invalid.join(', ')}`, + }); + } // Validate categoryId if provided if (categoryId !== undefined && categoryId !== null && categoryId !== '') { @@ -79,9 +93,17 @@ export default defineEventHandler(async (event) => { // Build update object const updateData: { + name?: string; description?: string | null; categoryId?: string | null; - } = {}; + imdbId?: string | null; + tmdbId?: number | null; + tvdbId?: number | null; + } = { ...media.ids }; + + if (name !== undefined) { + updateData.name = name; + } if (description !== undefined) { updateData.description = description || null; @@ -116,6 +138,9 @@ export default defineEventHandler(async (event) => { description: updated!.description, categoryId: updated!.categoryId, category: updated!.category, + imdbId: updated!.imdbId, + tmdbId: updated!.tmdbId, + tvdbId: updated!.tvdbId, }, }; }); diff --git a/server/api/torrents/index.post.ts b/server/api/torrents/index.post.ts index 70077f22..e72044f5 100644 --- a/server/api/torrents/index.post.ts +++ b/server/api/torrents/index.post.ts @@ -4,10 +4,12 @@ import parseTorrent from 'parse-torrent'; import { z } from 'zod'; import { rateLimit, RATE_LIMITS } from '../../utils/rateLimit'; import { normalizeTorrent, type NormalizedTorrent } from '../../utils/torrentFile'; +import { parseMediaIds } from '../../../shared/utils/mediaIds'; // readMultipartFormData buffers the whole body: reject oversized requests first const MAX_UPLOAD_BYTES = 5 * 1024 * 1024; const MAX_DESCRIPTION_LENGTH = 10000; +const MAX_NAME_LENGTH = 255; const tagIdsSchema = z.array(z.uuid()).max(20); export default defineEventHandler(async (event) => { @@ -45,6 +47,15 @@ export default defineEventHandler(async (event) => { .find((f) => f.name === 'description') ?.data.toString(); const tagsRaw = formData.find((f) => f.name === 'tags')?.data.toString(); + const field = (name: string) => + formData.find((f) => f.name === name)?.data.toString(); + // Optional display name replacing the one embedded in the file (#42) + const customName = field('name')?.trim(); + const media = parseMediaIds({ + imdbId: field('imdbId'), + tmdbId: field('tmdbId'), + tvdbId: field('tvdbId'), + }); if (!file || !file.data) { throw createError({ @@ -53,6 +64,20 @@ export default defineEventHandler(async (event) => { }); } + if (media.invalid.length > 0) { + throw createError({ + statusCode: 400, + message: `Invalid ${media.invalid.join(', ')}`, + }); + } + + if (customName && customName.length > MAX_NAME_LENGTH) { + throw createError({ + statusCode: 400, + message: `Name too long (max ${MAX_NAME_LENGTH} characters)`, + }); + } + if (description && description.length > MAX_DESCRIPTION_LENGTH) { throw createError({ statusCode: 400, @@ -110,7 +135,7 @@ export default defineEventHandler(async (event) => { } const infoHash = normalized.infoHash; - const name = parsed.name || file.filename || 'Unknown'; + const name = customName || parsed.name || file.filename || 'Unknown'; // Calculate total size let totalSize = 0; @@ -164,6 +189,7 @@ export default defineEventHandler(async (event) => { size: totalSize, description: description || null, torrentData: normalized.data, + ...media.ids, uploaderId: user.id, // Set uploader from authenticated user categoryId: categoryId || null, isActive: true, diff --git a/server/api/torznab/api/index.get.ts b/server/api/torznab/api/index.get.ts index f3df3543..2c15cb62 100644 --- a/server/api/torznab/api/index.get.ts +++ b/server/api/torznab/api/index.get.ts @@ -13,8 +13,9 @@ import type { H3Event } from 'h3'; import { z } from 'zod'; import { db, schema } from '../../../db'; import { getStats } from '../../../redis/cache'; -import { desc, eq, ilike, and, inArray } from 'drizzle-orm'; +import { desc, eq, ilike, and, inArray, sql } from 'drizzle-orm'; import { escapeLike } from '../../../utils/validation'; +import { parseImdbId, parseTmdbId, parseTvdbId } from '../../../../shared/utils/mediaIds'; import { authenticateTorznab, sendTorznabError } from '../utils/auth'; import { buildCapsXml, @@ -53,6 +54,7 @@ const torznabQuerySchema = z.object({ tvdbid: z.string().optional(), // Movie search params imdbid: z.string().optional(), + tmdbid: z.string().optional(), }); export default defineEventHandler(async (event) => { @@ -205,8 +207,10 @@ async function handleTvSearch( query: z.infer, user: { passkey: string } ) { - // Build search query with season/episode - let searchQuery = query.q || ''; + // Build search query with season/episode. With a database ID the title + // text is redundant (and often formatted differently): match the ID instead. + const hasId = Boolean(query.tvdbid || query.imdbid || query.tmdbid); + let searchQuery = hasId ? '' : query.q || ''; if (query.season) { // Format: S01 or S1 @@ -243,15 +247,8 @@ async function handleMovieSearch( query: z.infer, user: { passkey: string } ) { - let searchQuery = query.q || ''; - - // IMDB ID support - for now, just include it in search - // Full support would require IMDB field in torrents table - if (query.imdbid) { - // Clean IMDB ID format (tt1234567 -> 1234567) - const imdbNum = query.imdbid.replace(/^tt/i, ''); - searchQuery += ` ${imdbNum}`; - } + // With a database ID, match the ID column instead of the title text + const searchQuery = query.imdbid || query.tmdbid ? '' : query.q || ''; // Force Movie categories if none specified if (!query.cat) { @@ -286,6 +283,22 @@ async function performSearch( } } + // External database IDs (#47). An unparsable ID matches nothing rather + // than falling back to every torrent. + const idFilters = [ + [query.imdbid, parseImdbId(query.imdbid), schema.torrents.imdbId], + [query.tmdbid, parseTmdbId(query.tmdbid), schema.torrents.tmdbId], + [query.tvdbid, parseTvdbId(query.tvdbid), schema.torrents.tvdbId], + ] as const; + for (const [raw, parsed, column] of idFilters) { + if (!raw) continue; + if (parsed === null) { + conditions.push(sql`false`); + } else { + conditions.push(eq(column, parsed)); + } + } + // Category filter if (query.cat) { const newznabIds = query.cat @@ -336,6 +349,9 @@ async function performSearch( downloadUrl: `${baseUrl}/api/torznab/download?id=${torrent.infoHash}&apikey=${user.passkey}`, downloadVolumeFactor: 1, // Could be enhanced with freeleech support uploadVolumeFactor: 1, + imdbId: torrent.imdbId ?? undefined, + tmdbId: torrent.tmdbId ?? undefined, + tvdbId: torrent.tvdbId ?? undefined, }; }) ); diff --git a/server/api/torznab/utils/xml.ts b/server/api/torznab/utils/xml.ts index 596bef83..043654e8 100644 --- a/server/api/torznab/utils/xml.ts +++ b/server/api/torznab/utils/xml.ts @@ -85,8 +85,8 @@ export function buildCapsXml(config: CapsConfig): string { - - + + @@ -117,6 +117,8 @@ export interface TorznabItem { downloadVolumeFactor?: number; // 0 = freeleech, 1 = normal uploadVolumeFactor?: number; // 1 = normal, 2 = double upload imdbId?: string; + tmdbId?: number; + tvdbId?: number; } export interface TorznabFeed { @@ -145,6 +147,12 @@ export function buildSearchXml(feed: TorznabFeed): string { ` ` ); } + if (item.tmdbId) { + attrs.push(` `); + } + if (item.tvdbId) { + attrs.push(` `); + } return ` ${escapeXml(item.title)} diff --git a/server/db/migrations/0011_media_ids.sql b/server/db/migrations/0011_media_ids.sql new file mode 100644 index 00000000..763672ce --- /dev/null +++ b/server/db/migrations/0011_media_ids.sql @@ -0,0 +1,7 @@ +-- External media database IDs on torrents (issue #47) +ALTER TABLE "torrents" ADD COLUMN "imdb_id" text; +ALTER TABLE "torrents" ADD COLUMN "tmdb_id" integer; +ALTER TABLE "torrents" ADD COLUMN "tvdb_id" integer; +CREATE INDEX IF NOT EXISTS "torrents_imdb_idx" ON "torrents" ("imdb_id"); +CREATE INDEX IF NOT EXISTS "torrents_tmdb_idx" ON "torrents" ("tmdb_id"); +CREATE INDEX IF NOT EXISTS "torrents_tvdb_idx" ON "torrents" ("tvdb_id"); diff --git a/server/db/schema.ts b/server/db/schema.ts index 138a65a3..87bd8a34 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -99,10 +99,17 @@ export const torrents = pgTable( categoryId: text('category_id').references(() => categories.id), isActive: boolean('is_active').default(true).notNull(), isApproved: boolean('is_approved').default(false).notNull(), + // External media database IDs (Sonarr/Radarr matching, issue #47) + imdbId: text('imdb_id'), // e.g. tt0133093 + tmdbId: integer('tmdb_id'), + tvdbId: integer('tvdb_id'), createdAt: timestamp('created_at').defaultNow().notNull(), }, (table) => [ uniqueIndex('torrents_info_hash_idx').on(table.infoHash), + index('torrents_imdb_idx').on(table.imdbId), + index('torrents_tmdb_idx').on(table.tmdbId), + index('torrents_tvdb_idx').on(table.tvdbId), index('torrents_uploader_idx').on(table.uploaderId), index('torrents_category_idx').on(table.categoryId), index('torrents_name_trgm_idx').using( diff --git a/shared/utils/mediaIds.ts b/shared/utils/mediaIds.ts new file mode 100644 index 00000000..84f4a23e --- /dev/null +++ b/shared/utils/mediaIds.ts @@ -0,0 +1,95 @@ +/** + * External media database IDs (IMDb, TMDb, TheTVDB), issue #47. + * Accept what users naturally paste (raw ID or the page URL) and normalize. + * Shared between app and server. + */ + +export interface MediaIds { + imdbId: string | null; + tmdbId: number | null; + tvdbId: number | null; +} + +const MAX_NUMERIC_ID = 2_147_483_647; // Postgres integer + +/** "tt0133093", "0133093", "https://www.imdb.com/title/tt0133093/" -> "tt0133093" */ +export function parseImdbId(input: string | null | undefined): string | null { + const value = input?.trim(); + if (!value) return null; + const match = value.match(/^(?:https?:\/\/(?:www\.|m\.)?imdb\.com\/title\/)?(?:tt)?(\d{7,9})\/?(?:[?#].*)?$/i); + return match ? `tt${match[1]}` : null; +} + +function parseNumericId(value: string, urlPattern: RegExp): number | null { + const match = value.match(urlPattern) ?? value.match(/^(\d+)$/); + if (!match) return null; + const id = Number(match[1]); + return Number.isSafeInteger(id) && id > 0 && id <= MAX_NUMERIC_ID ? id : null; +} + +/** "603", "https://www.themoviedb.org/movie/603-the-matrix" -> 603 */ +export function parseTmdbId(input: string | number | null | undefined): number | null { + if (input == null || input === '') return null; + return parseNumericId( + String(input).trim(), + /^https?:\/\/(?:www\.)?themoviedb\.org\/(?:movie|tv)\/(\d+)(?:[-/?#].*)?$/i + ); +} + +/** "81189", "https://thetvdb.com/?tab=series&id=81189" -> 81189 */ +export function parseTvdbId(input: string | number | null | undefined): number | null { + if (input == null || input === '') return null; + return parseNumericId( + String(input).trim(), + /^https?:\/\/(?:www\.)?thetvdb\.com\/.*[?&]id=(\d+)(?:[&#].*)?$/i + ); +} + +export function imdbUrl(id: string): string { + return `https://www.imdb.com/title/${id}/`; +} + +/** TMDb IDs are per type; the "tv" path is used for TV categories */ +export function tmdbUrl(id: number, kind: 'movie' | 'tv' = 'movie'): string { + return `https://www.themoviedb.org/${kind}/${id}`; +} + +export function tvdbUrl(id: number): string { + return `https://thetvdb.com/dereferrer/series/${id}`; +} + +export type MediaIdsInput = Partial< + Record +>; + +/** + * Parse user-supplied IDs. Keys left undefined are not returned (unchanged); + * empty values clear the ID. Values that do not parse are listed in `invalid`. + */ +export function parseMediaIds(raw: MediaIdsInput): { + ids: Partial; + invalid: (keyof MediaIds)[]; +} { + const ids: Partial = {}; + const invalid: (keyof MediaIds)[] = []; + + const parsers = { + imdbId: (v: string | number) => parseImdbId(String(v)), + tmdbId: parseTmdbId, + tvdbId: parseTvdbId, + } as const; + + for (const key of Object.keys(parsers) as (keyof MediaIds)[]) { + const value = raw[key]; + if (value === undefined) continue; + if (value === null || String(value).trim() === '') { + ids[key] = null; + continue; + } + const parsed = parsers[key](value); + if (parsed === null) invalid.push(key); + else (ids as Record)[key] = parsed; + } + + return { ids, invalid }; +} diff --git a/tests/media-ids.test.ts b/tests/media-ids.test.ts new file mode 100644 index 00000000..c5cb69dd --- /dev/null +++ b/tests/media-ids.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { + parseImdbId, + parseMediaIds, + parseTmdbId, + parseTvdbId, +} from '../shared/utils/mediaIds'; + +describe('parseImdbId', () => { + it.each([ + ['tt0133093', 'tt0133093'], + ['0133093', 'tt0133093'], + ['TT0133093', 'tt0133093'], + ['https://www.imdb.com/title/tt0133093/', 'tt0133093'], + ['https://m.imdb.com/title/tt12345678/?ref_=x', 'tt12345678'], + [' tt0133093 ', 'tt0133093'], + ])('%s -> %s', (input, expected) => { + expect(parseImdbId(input)).toBe(expected); + }); + + it.each(['', null, 'tt12', 'abc', 'https://evil.com/title/tt0133093', 'tt0133093