@@ -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