From cb783cb0f1ee4a466828e955162ebef001872a4a Mon Sep 17 00:00:00 2001 From: Roz Date: Sat, 27 Jun 2026 00:29:26 +0200 Subject: [PATCH 01/27] feat(catalog): browse a peer's catalog on a dedicated page Add a management catalog module that groups a peer's releases into titles, a /catalog/[peerId] page that lists them, and clickable peer entries. --- apps/backend/src/__tests__/catalog.test.ts | 122 ++++++++++++++++++ apps/backend/src/management-app.ts | 5 + .../src/modules/catalog/catalog.controller.ts | 32 +++++ .../src/modules/catalog/catalog.lib.ts | 80 ++++++++++++ .../src/modules/catalog/catalog.router.ts | 17 +++ apps/ui/app/components/ConnectorCard.vue | 2 + apps/ui/app/components/PeersSection.vue | 1 + apps/ui/app/pages/catalog/[peerId].vue | 63 +++++++++ apps/ui/app/pages/index.vue | 4 +- apps/ui/app/types/management.ts | 16 +++ 10 files changed, 340 insertions(+), 2 deletions(-) create mode 100644 apps/backend/src/__tests__/catalog.test.ts create mode 100644 apps/backend/src/modules/catalog/catalog.controller.ts create mode 100644 apps/backend/src/modules/catalog/catalog.lib.ts create mode 100644 apps/backend/src/modules/catalog/catalog.router.ts create mode 100644 apps/ui/app/pages/catalog/[peerId].vue diff --git a/apps/backend/src/__tests__/catalog.test.ts b/apps/backend/src/__tests__/catalog.test.ts new file mode 100644 index 0000000..30d4052 --- /dev/null +++ b/apps/backend/src/__tests__/catalog.test.ts @@ -0,0 +1,122 @@ +import type { Release } from '../lib/release' +import { describe, expect, test } from 'bun:test' +import { NotFoundError } from '../lib/errors/NotFoundError' +import { CatalogController } from '../modules/catalog/catalog.controller' +import { groupReleasesIntoTitles } from '../modules/catalog/catalog.lib' + +function movie(overrides: Partial = {}): Release { + return { + id: `movie:${Math.random()}`, + title: 'Movie.2024.1080p', + filename: 'Movie.2024.1080p.mkv', + category: 2000, + size: 100, + ...overrides, + } +} + +function episode(overrides: Partial = {}): Release { + return { + id: `episode:${Math.random()}`, + title: 'Show.S01E01.1080p', + filename: 'Show.S01E01.1080p.mkv', + category: 5000, + size: 50, + ...overrides, + } +} + +describe('groupReleasesIntoTitles', () => { + test('groups movie + tv releases by their strong ids', () => { + const releases: Release[] = [ + movie({ tmdbId: 603, size: 100 }), + movie({ tmdbId: 603, size: 200, quality: { resolution: 1080 } }), + movie({ tmdbId: 603, size: 300, quality: { resolution: 2160 } }), + episode({ tvdbId: 1396, seriesTitle: 'Breaking Bad', size: 50 }), + episode({ tvdbId: 1396, seriesTitle: 'Breaking Bad', size: 60 }), + ] + + const titles = groupReleasesIntoTitles(releases) + + expect(titles).toHaveLength(2) + + const movieTitle = titles.find(t => t.mediaType === 'movie') + expect(movieTitle).toBeDefined() + expect(movieTitle!.tmdbId).toBe(603) + expect(movieTitle!.releaseCount).toBe(3) + expect(movieTitle!.totalSize).toBe(600) + + const tvTitle = titles.find(t => t.mediaType === 'tv') + expect(tvTitle).toBeDefined() + expect(tvTitle!.tvdbId).toBe(1396) + expect(tvTitle!.releaseCount).toBe(2) + expect(tvTitle!.totalSize).toBe(110) + expect(tvTitle!.displayTitle).toBe('Breaking Bad') + }) + + test('collapses id-less movies with the same title into one name-keyed entry', () => { + const releases: Release[] = [ + movie({ title: 'Foo.2024', size: 100 }), + movie({ title: 'Foo.2024', size: 200 }), + ] + + const titles = groupReleasesIntoTitles(releases) + + expect(titles).toHaveLength(1) + expect(titles[0]!.releaseCount).toBe(2) + expect(titles[0]!.totalSize).toBe(300) + expect(titles[0]!.key).toContain('name:') + }) + + test('aliases an id-less tv release into the strong-id bucket of the same series', () => { + const releases: Release[] = [ + episode({ seriesTitle: 'Some Show', size: 50 }), + episode({ seriesTitle: 'Some Show', tvdbId: 999, size: 60 }), + ] + + const titles = groupReleasesIntoTitles(releases) + + expect(titles).toHaveLength(1) + expect(titles[0]!.releaseCount).toBe(2) + expect(titles[0]!.tvdbId).toBe(999) + expect(titles[0]!.key).toContain('id:999') + }) + + test('sorts titles by display title', () => { + const releases: Release[] = [ + movie({ title: 'Zebra', tmdbId: 1 }), + movie({ title: 'Apple', tmdbId: 2 }), + ] + + const titles = groupReleasesIntoTitles(releases) + + expect(titles.map(t => t.displayTitle)).toEqual(['Apple', 'Zebra']) + }) +}) + +describe('catalogController', () => { + function makeConnectors(peers: any[]) { + return { servers: [], peers } + } + + test('returns the peer and its grouped titles', async () => { + const peer = { + id: 'peer-1', + name: 'Friend Jack', + listReleases: async () => [movie({ tmdbId: 603 }), movie({ tmdbId: 603 })], + } + const controller = new CatalogController(makeConnectors([peer]) as any) + + const result = await controller.getPeerCatalog('peer-1') + + expect(result.peer).toEqual({ id: 'peer-1', name: 'Friend Jack' }) + expect(result.titles).toHaveLength(1) + expect(result.titles[0]!.releaseCount).toBe(2) + }) + + test('throws NotFoundError for an unknown peer id', () => { + const controller = new CatalogController(makeConnectors([]) as any) + + expect(controller.getPeerCatalog('missing')).rejects.toBeInstanceOf(NotFoundError) + }) +}) diff --git a/apps/backend/src/management-app.ts b/apps/backend/src/management-app.ts index 2c40b34..082d89f 100644 --- a/apps/backend/src/management-app.ts +++ b/apps/backend/src/management-app.ts @@ -8,6 +8,8 @@ import { handleError } from './middleware/handle-error' import { requireManagementKey } from './middleware/require-management-key' import { ApiKeysController } from './modules/api-keys/api-keys.controller' import { getApiKeysRouter } from './modules/api-keys/api-keys.router' +import { CatalogController } from './modules/catalog/catalog.controller' +import { getCatalogRouter } from './modules/catalog/catalog.router' import { ConfigController } from './modules/config/config.controller' import { getConfigRouter } from './modules/config/config.router' import { StatusController } from './modules/status/status.controller' @@ -38,6 +40,9 @@ export function getManagementApp(params: { const statusController = new StatusController(params.connectors, params.downloadsRepository) app.route('/', getStatusRouter(statusController)) + const catalogController = new CatalogController(params.connectors) + app.route('/catalog', getCatalogRouter(catalogController)) + if (params.apiKeysRepository) { const apiKeysController = new ApiKeysController(params.apiKeysRepository) app.route('/api-keys', getApiKeysRouter(apiKeysController)) diff --git a/apps/backend/src/modules/catalog/catalog.controller.ts b/apps/backend/src/modules/catalog/catalog.controller.ts new file mode 100644 index 0000000..821ed68 --- /dev/null +++ b/apps/backend/src/modules/catalog/catalog.controller.ts @@ -0,0 +1,32 @@ +import type { ArrServerConnector } from '../../lib/servers/arr/base' +import type { PeerConnector } from '../../lib/servers/peer' +import type { CatalogTitle } from './catalog.lib' +import { NotFoundError } from '../../lib/errors/NotFoundError' +import { groupReleasesIntoTitles } from './catalog.lib' + +export interface PeerCatalogResponse { + peer: { id: string, name: string } + titles: CatalogTitle[] +} + +export class CatalogController { + constructor( + private readonly connectors: { servers: ArrServerConnector[], peers: PeerConnector[] }, + ) {} + + private requirePeer(peerId: string): PeerConnector { + const peer = this.connectors.peers.find(p => p.id === peerId) + if (!peer) + throw new NotFoundError(`No peer found with id "${peerId}"`) + return peer + } + + async getPeerCatalog(peerId: string): Promise { + const peer = this.requirePeer(peerId) + const releases = await peer.listReleases() + return { + peer: { id: peer.id, name: peer.name }, + titles: groupReleasesIntoTitles(releases), + } + } +} diff --git a/apps/backend/src/modules/catalog/catalog.lib.ts b/apps/backend/src/modules/catalog/catalog.lib.ts new file mode 100644 index 0000000..31dc21b --- /dev/null +++ b/apps/backend/src/modules/catalog/catalog.lib.ts @@ -0,0 +1,80 @@ +import type { Release } from '../../lib/release' +import { ReleaseCategory } from '../../lib/release' + +export interface CatalogTitle { + // Stable grouping key, also used as the client-side list key. + key: string + mediaType: 'movie' | 'tv' + tmdbId?: number + imdbId?: string + tvdbId?: number + // Best display name available pre-TMDB: series title for tv, else the release/scene title. + displayTitle: string + releaseCount: number + totalSize: number +} + +function mediaTypeOf(release: Release): 'movie' | 'tv' { + return release.category === ReleaseCategory.Tv ? 'tv' : 'movie' +} + +/** The strong (id-based) grouping key for a release, or null when it carries no id. */ +function strongKey(release: Release): string | null { + const mediaType = mediaTypeOf(release) + const id = mediaType === 'tv' + ? (release.tvdbId ?? release.tmdbId) + : (release.tmdbId ?? release.imdbId) + return id == null ? null : `${mediaType}:id:${id}` +} + +/** The fallback (name-based) key for a release with no usable id. */ +function nameKey(release: Release): string { + const mediaType = mediaTypeOf(release) + const name = (mediaType === 'tv' ? (release.seriesTitle ?? release.title) : release.title).toLowerCase() + return `${mediaType}:name:${name}` +} + +/** + * Group a peer's flat release list into one entry per movie/series. + * + * Two passes so a title that appears both WITH and WITHOUT an id still collapses: + * pass 1 records, per fallback name, the strong id key seen for it; pass 2 routes + * each release to its strong key, else the alias discovered in pass 1, else its name. + */ +export function groupReleasesIntoTitles(releases: Release[]): CatalogTitle[] { + const nameToStrongKey = new Map() + for (const release of releases) { + const sk = strongKey(release) + if (sk && !nameToStrongKey.has(nameKey(release))) + nameToStrongKey.set(nameKey(release), sk) + } + + const byKey = new Map() + for (const release of releases) { + const key = strongKey(release) ?? nameToStrongKey.get(nameKey(release)) ?? nameKey(release) + + const existing = byKey.get(key) + if (existing) { + existing.releaseCount += 1 + existing.totalSize += release.size + // Backfill ids if a later release carries one the first lacked. + existing.tmdbId ??= release.tmdbId + existing.imdbId ??= release.imdbId + existing.tvdbId ??= release.tvdbId + continue + } + + byKey.set(key, { + key, + mediaType: mediaTypeOf(release), + tmdbId: release.tmdbId, + imdbId: release.imdbId, + tvdbId: release.tvdbId, + displayTitle: mediaTypeOf(release) === 'tv' ? (release.seriesTitle ?? release.title) : release.title, + releaseCount: 1, + totalSize: release.size, + }) + } + + return [...byKey.values()].sort((a, b) => a.displayTitle.localeCompare(b.displayTitle)) +} diff --git a/apps/backend/src/modules/catalog/catalog.router.ts b/apps/backend/src/modules/catalog/catalog.router.ts new file mode 100644 index 0000000..745d24a --- /dev/null +++ b/apps/backend/src/modules/catalog/catalog.router.ts @@ -0,0 +1,17 @@ +import type { CatalogController } from './catalog.controller' +import { Hono } from 'hono' +import { validator as zValidator } from 'hono-openapi' +import { z } from 'zod' + +const peerParam = z.object({ peerId: z.string().min(1) }) + +export function getCatalogRouter(controller: CatalogController) { + const app = new Hono() + + app.get('/:peerId', zValidator('param', peerParam), async (c) => { + const { peerId } = c.req.valid('param') + return c.json(await controller.getPeerCatalog(peerId)) + }) + + return app +} diff --git a/apps/ui/app/components/ConnectorCard.vue b/apps/ui/app/components/ConnectorCard.vue index 6075a0f..92893e4 100644 --- a/apps/ui/app/components/ConnectorCard.vue +++ b/apps/ui/app/components/ConnectorCard.vue @@ -7,6 +7,7 @@ defineProps<{ initialized: boolean error?: string | null status: { color: BadgeProps['color'], label: string } + to?: string }>() defineEmits<{ edit: [], remove: [] }>() @@ -25,6 +26,7 @@ defineEmits<{ edit: [], remove: [] }>()

+ diff --git a/apps/ui/app/components/PeersSection.vue b/apps/ui/app/components/PeersSection.vue index d6ed9ae..4d6b4c3 100644 --- a/apps/ui/app/components/PeersSection.vue +++ b/apps/ui/app/components/PeersSection.vue @@ -140,6 +140,7 @@ async function confirmDelete() { :initialized="peer.initialized" :error="peer.initializationError" :status="statusBadge(peer)" + :to="`/catalog/${peer.id}`" @edit="openEdit(peer)" @remove="confirmTarget = peer" > diff --git a/apps/ui/app/pages/catalog/[peerId].vue b/apps/ui/app/pages/catalog/[peerId].vue new file mode 100644 index 0000000..e3516af --- /dev/null +++ b/apps/ui/app/pages/catalog/[peerId].vue @@ -0,0 +1,63 @@ + + + diff --git a/apps/ui/app/pages/index.vue b/apps/ui/app/pages/index.vue index 11425ef..050d67e 100644 --- a/apps/ui/app/pages/index.vue +++ b/apps/ui/app/pages/index.vue @@ -132,13 +132,13 @@ function serverRole(server: { source: boolean, destination: boolean }) { No peers yet.

-
+ {{ peer.name }} {{ peer.initialized ? (peer.version ?? '—') : 'unreachable' }} -
+
diff --git a/apps/ui/app/types/management.ts b/apps/ui/app/types/management.ts index a3a0ad3..a57cd59 100644 --- a/apps/ui/app/types/management.ts +++ b/apps/ui/app/types/management.ts @@ -45,6 +45,22 @@ export interface DownloadItem { expectedBytesMismatch: boolean } +export interface CatalogTitle { + key: string + mediaType: 'movie' | 'tv' + tmdbId?: number + imdbId?: string + tvdbId?: number + displayTitle: string + releaseCount: number + totalSize: number +} + +export interface PeerCatalogResponse { + peer: { id: string, name: string } + titles: CatalogTitle[] +} + export interface Overview { peers: { total: number, initialized: number, items: PeerItem[] } servers: { total: number, initialized: number, sources: number, destinations: number, items: OverviewServerItem[] } From 0f5897a2cb177c728fb3c2fd3f4099a56280fb14 Mon Sep 17 00:00:00 2001 From: Roz Date: Sat, 27 Jun 2026 00:35:07 +0200 Subject: [PATCH 02/27] feat(catalog): add TMDB config and connection status Add an optional tmdbApiKey to config.jack, a TmdbClient that reads movie/tv metadata, and a /catalog/tmdb/status endpoint. Expose a managed TMDB key field and connection status in the Settings Jack section. --- apps/backend/src/__tests__/catalog.test.ts | 37 ++++++++ .../backend/src/__tests__/tmdb-client.test.ts | 69 +++++++++++++++ apps/backend/src/index.ts | 1 + apps/backend/src/lib/config.ts | 3 + apps/backend/src/lib/tmdb/client.ts | 86 +++++++++++++++++++ apps/backend/src/management-app.ts | 5 +- .../src/modules/catalog/catalog.controller.ts | 19 ++++ .../src/modules/catalog/catalog.router.ts | 3 + apps/ui/app/components/JackConfigForm.vue | 10 +++ apps/ui/app/pages/settings.vue | 19 ++++ apps/ui/app/types/management.ts | 1 + 11 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 apps/backend/src/__tests__/tmdb-client.test.ts create mode 100644 apps/backend/src/lib/tmdb/client.ts diff --git a/apps/backend/src/__tests__/catalog.test.ts b/apps/backend/src/__tests__/catalog.test.ts index 30d4052..6075622 100644 --- a/apps/backend/src/__tests__/catalog.test.ts +++ b/apps/backend/src/__tests__/catalog.test.ts @@ -120,3 +120,40 @@ describe('catalogController', () => { expect(controller.getPeerCatalog('missing')).rejects.toBeInstanceOf(NotFoundError) }) }) + +describe('catalogController.getTmdbStatus', () => { + function makeConnectors() { + return { servers: [], peers: [] } + } + + test('reports not configured when no tmdb client is supplied', async () => { + const controller = new CatalogController(makeConnectors() as any) + + expect(await controller.getTmdbStatus()).toEqual({ configured: false, ok: false }) + }) + + test('reports ok when the client ping resolves true', async () => { + const tmdb = { ping: async () => true } + const controller = new CatalogController(makeConnectors() as any, tmdb as any) + + expect(await controller.getTmdbStatus()).toEqual({ configured: true, ok: true }) + }) + + test('reports configured but not ok when the client ping resolves false', async () => { + const tmdb = { ping: async () => false } + const controller = new CatalogController(makeConnectors() as any, tmdb as any) + + expect(await controller.getTmdbStatus()).toEqual({ configured: true, ok: false }) + }) + + test('reports the error message when the client ping throws', async () => { + const tmdb = { + ping: async () => { + throw new Error('boom') + }, + } + const controller = new CatalogController(makeConnectors() as any, tmdb as any) + + expect(await controller.getTmdbStatus()).toEqual({ configured: true, ok: false, error: 'boom' }) + }) +}) diff --git a/apps/backend/src/__tests__/tmdb-client.test.ts b/apps/backend/src/__tests__/tmdb-client.test.ts new file mode 100644 index 0000000..38adef1 --- /dev/null +++ b/apps/backend/src/__tests__/tmdb-client.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from 'bun:test' +import { buildImageUrl, mapTmdbDetail } from '../lib/tmdb/client' + +describe('buildImageUrl', () => { + test('assembles a full image url from a poster path with the default size', () => { + expect(buildImageUrl('/abc.jpg')).toBe('https://image.tmdb.org/t/p/w500/abc.jpg') + }) + + test('honors a custom size', () => { + expect(buildImageUrl('/abc.jpg', 'w780')).toBe('https://image.tmdb.org/t/p/w780/abc.jpg') + }) + + test('returns null for a null path', () => { + expect(buildImageUrl(null)).toBeNull() + }) + + test('returns null for an undefined path', () => { + expect(buildImageUrl(undefined)).toBeNull() + }) +}) + +describe('mapTmdbDetail', () => { + test('maps a movie detail (title/release_date/vote_average)', () => { + const meta = mapTmdbDetail({ + id: 550, + title: 'Fight Club', + overview: 'A man and his alter ego.', + release_date: '1999-10-15', + vote_average: 8.4, + poster_path: '/poster.jpg', + backdrop_path: '/backdrop.jpg', + genres: [{ id: 18, name: 'Drama' }], + }) + + expect(meta.tmdbId).toBe(550) + expect(meta.title).toBe('Fight Club') + expect(meta.year).toBe(1999) + expect(meta.rating).toBe(8.4) + expect(meta.overview).toBe('A man and his alter ego.') + expect(meta.posterUrl).toBe('https://image.tmdb.org/t/p/w500/poster.jpg') + expect(meta.backdropUrl).toBe('https://image.tmdb.org/t/p/w780/backdrop.jpg') + expect(meta.genres).toEqual(['Drama']) + }) + + test('maps a tv detail from name/first_air_date when title/release_date are absent', () => { + const meta = mapTmdbDetail({ + id: 1396, + name: 'Breaking Bad', + first_air_date: '2008-01-20', + vote_average: 8.9, + }) + + expect(meta.title).toBe('Breaking Bad') + expect(meta.year).toBe(2008) + expect(meta.rating).toBe(8.9) + }) + + test('falls back to null year/rating and empty genres when fields are missing', () => { + const meta = mapTmdbDetail({ id: 1 }) + + expect(meta.title).toBe('Untitled') + expect(meta.year).toBeNull() + expect(meta.rating).toBeNull() + expect(meta.posterUrl).toBeNull() + expect(meta.backdropUrl).toBeNull() + expect(meta.genres).toEqual([]) + expect(meta.overview).toBeNull() + }) +}) diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 5f52a37..5c2cd08 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -83,6 +83,7 @@ function startManagementServer() { configService, downloadsRepository, apiKeysRepository, + tmdbApiKey: config.jack.tmdbApiKey, }) const instance = Bun.serve({ port: envs.MANAGEMENT_PORT, fetch: managementApp.fetch }) logger.info({ port: instance.port }, 'Management API listening') diff --git a/apps/backend/src/lib/config.ts b/apps/backend/src/lib/config.ts index 3fead06..d4a1e76 100644 --- a/apps/backend/src/lib/config.ts +++ b/apps/backend/src/lib/config.ts @@ -155,6 +155,8 @@ export const JackConfig = z.object({ // only an internalUrl, in which case the public API authenticates via generated // keys (see require-auth.ts), not this key. apiKey: ConfigSecret().optional(), + // Optional TMDB v3 API key for enriching peer catalogs with artwork/metadata. + tmdbApiKey: ConfigSecret().optional(), }) export type JackConfig = z.infer @@ -164,6 +166,7 @@ export type JackConfig = z.infer export const RawJackConfig = z.object({ internalUrl: z.url(), apiKey: RawConfigSecret.optional(), + tmdbApiKey: RawConfigSecret.optional(), }) export type RawJackConfig = z.infer diff --git a/apps/backend/src/lib/tmdb/client.ts b/apps/backend/src/lib/tmdb/client.ts new file mode 100644 index 0000000..02fbca4 --- /dev/null +++ b/apps/backend/src/lib/tmdb/client.ts @@ -0,0 +1,86 @@ +const TMDB_API_BASE = 'https://api.themoviedb.org/3' +const TMDB_IMAGE_BASE = 'https://image.tmdb.org/t/p/' + +export type TmdbMediaType = 'movie' | 'tv' + +export interface TmdbMetadata { + tmdbId: number + title: string + overview: string | null + year: number | null + rating: number | null + posterUrl: string | null + backdropUrl: string | null + genres: string[] +} + +interface TmdbRawDetail { + id: number + title?: string + name?: string + overview?: string | null + release_date?: string | null + first_air_date?: string | null + vote_average?: number | null + poster_path?: string | null + backdrop_path?: string | null + genres?: Array<{ id: number, name: string }> +} + +/** Assemble a TMDB image URL, or null when the path is absent. */ +export function buildImageUrl(path: string | null | undefined, size = 'w500', base = TMDB_IMAGE_BASE): string | null { + if (!path) + return null + return `${base}${size}${path}` +} + +/** Normalize a TMDB movie/tv detail payload into our flat metadata shape. */ +export function mapTmdbDetail(raw: TmdbRawDetail): TmdbMetadata { + const date = raw.release_date ?? raw.first_air_date ?? null + const yearNum = date && date.length >= 4 ? Number(date.slice(0, 4)) : Number.NaN + return { + tmdbId: raw.id, + title: raw.title ?? raw.name ?? 'Untitled', + overview: raw.overview || null, + year: Number.isFinite(yearNum) ? yearNum : null, + rating: typeof raw.vote_average === 'number' ? raw.vote_average : null, + posterUrl: buildImageUrl(raw.poster_path), + backdropUrl: buildImageUrl(raw.backdrop_path, 'w780'), + genres: (raw.genres ?? []).map(g => g.name).filter((n): n is string => Boolean(n)), + } +} + +/** + * Thin TMDB v3 read client. Holds a per-process cache keyed by media/id so a + * catalog full of repeated titles enriches each unique id once. + */ +export class TmdbClient { + private readonly cache = new Map() + constructor(private readonly apiKey: string) {} + + /** True when the key authenticates against TMDB (`/configuration` returns 200). */ + async ping(): Promise { + const res = await fetch(`${TMDB_API_BASE}/configuration?api_key=${this.apiKey}`) + return res.ok + } + + /** Normalized metadata for a tmdb id, cached per process; null on 404. */ + async getMetadata(mediaType: TmdbMediaType, tmdbId: number): Promise { + const key = `${mediaType}:${tmdbId}` + const cached = this.cache.get(key) + if (cached !== undefined) + return cached + + const res = await fetch(`${TMDB_API_BASE}/${mediaType}/${tmdbId}?api_key=${this.apiKey}`) + if (res.status === 404) { + this.cache.set(key, null) + return null + } + if (!res.ok) + throw new Error(`TMDB ${mediaType}/${tmdbId} failed: ${res.status}`) + const raw = await res.json() as TmdbRawDetail + const meta = mapTmdbDetail(raw) + this.cache.set(key, meta) + return meta + } +} diff --git a/apps/backend/src/management-app.ts b/apps/backend/src/management-app.ts index 082d89f..d7c5f53 100644 --- a/apps/backend/src/management-app.ts +++ b/apps/backend/src/management-app.ts @@ -4,6 +4,7 @@ import type { ConfigService } from './modules/config/config.service' import type { DownloadsRepository } from './modules/downloads/downloads.repository' import { Hono } from 'hono' import { secureHeaders } from 'hono/secure-headers' +import { TmdbClient } from './lib/tmdb/client' import { handleError } from './middleware/handle-error' import { requireManagementKey } from './middleware/require-management-key' import { ApiKeysController } from './modules/api-keys/api-keys.controller' @@ -23,6 +24,7 @@ export function getManagementApp(params: { configService?: ConfigService downloadsRepository?: DownloadsRepository apiKeysRepository?: ApiKeysRepository + tmdbApiKey?: string }) { const app = new Hono() @@ -40,7 +42,8 @@ export function getManagementApp(params: { const statusController = new StatusController(params.connectors, params.downloadsRepository) app.route('/', getStatusRouter(statusController)) - const catalogController = new CatalogController(params.connectors) + const tmdbClient = params.tmdbApiKey ? new TmdbClient(params.tmdbApiKey) : undefined + const catalogController = new CatalogController(params.connectors, tmdbClient) app.route('/catalog', getCatalogRouter(catalogController)) if (params.apiKeysRepository) { diff --git a/apps/backend/src/modules/catalog/catalog.controller.ts b/apps/backend/src/modules/catalog/catalog.controller.ts index 821ed68..cce86ba 100644 --- a/apps/backend/src/modules/catalog/catalog.controller.ts +++ b/apps/backend/src/modules/catalog/catalog.controller.ts @@ -1,5 +1,6 @@ import type { ArrServerConnector } from '../../lib/servers/arr/base' import type { PeerConnector } from '../../lib/servers/peer' +import type { TmdbClient } from '../../lib/tmdb/client' import type { CatalogTitle } from './catalog.lib' import { NotFoundError } from '../../lib/errors/NotFoundError' import { groupReleasesIntoTitles } from './catalog.lib' @@ -9,9 +10,16 @@ export interface PeerCatalogResponse { titles: CatalogTitle[] } +export interface TmdbStatus { + configured: boolean + ok: boolean + error?: string +} + export class CatalogController { constructor( private readonly connectors: { servers: ArrServerConnector[], peers: PeerConnector[] }, + private readonly tmdb?: TmdbClient, ) {} private requirePeer(peerId: string): PeerConnector { @@ -29,4 +37,15 @@ export class CatalogController { titles: groupReleasesIntoTitles(releases), } } + + async getTmdbStatus(): Promise { + if (!this.tmdb) + return { configured: false, ok: false } + try { + return { configured: true, ok: await this.tmdb.ping() } + } + catch (err) { + return { configured: true, ok: false, error: err instanceof Error ? err.message : String(err) } + } + } } diff --git a/apps/backend/src/modules/catalog/catalog.router.ts b/apps/backend/src/modules/catalog/catalog.router.ts index 745d24a..c5cea11 100644 --- a/apps/backend/src/modules/catalog/catalog.router.ts +++ b/apps/backend/src/modules/catalog/catalog.router.ts @@ -8,6 +8,9 @@ const peerParam = z.object({ peerId: z.string().min(1) }) export function getCatalogRouter(controller: CatalogController) { const app = new Hono() + // Register the static path before `/:peerId` so "tmdb" isn't captured as a peerId. + app.get('/tmdb/status', async c => c.json(await controller.getTmdbStatus())) + app.get('/:peerId', zValidator('param', peerParam), async (c) => { const { peerId } = c.req.valid('param') return c.json(await controller.getPeerCatalog(peerId)) diff --git a/apps/ui/app/components/JackConfigForm.vue b/apps/ui/app/components/JackConfigForm.vue index d0db2af..65b978f 100644 --- a/apps/ui/app/components/JackConfigForm.vue +++ b/apps/ui/app/components/JackConfigForm.vue @@ -14,6 +14,7 @@ const editing = computed(() => Boolean(props.initial)) const state = reactive({ internalUrl: props.initial?.internalUrl ?? '', apiKey: (props.initial?.apiKey ?? null) as SecretRef | null, + tmdbApiKey: (props.initial?.tmdbApiKey ?? null) as SecretRef | null, }) // internalUrl is the only required field; the Main API key is optional/clearable. @@ -28,6 +29,8 @@ function onSubmit() { // SecretInput emits null when cleared → omit apiKey entirely (optional). if (state.apiKey) input.apiKey = state.apiKey + if (state.tmdbApiKey) + input.tmdbApiKey = state.tmdbApiKey emit('submit', input) } @@ -50,6 +53,13 @@ function onSubmit() { + + + + +
diff --git a/apps/ui/app/pages/settings.vue b/apps/ui/app/pages/settings.vue index f345b88..a01697c 100644 --- a/apps/ui/app/pages/settings.vue +++ b/apps/ui/app/pages/settings.vue @@ -15,6 +15,11 @@ const keys = computed(() => data.value ?? []) const { data: jack, pending: jackPending, error: jackLoadError, refresh: refreshJack } = await useAsyncData('jack-config', () => request('config/jack')) +// Whether the running TMDB key authenticates — surfaced beside the key field so a +// saved key can be confirmed at a glance. +const { data: tmdbStatus } = await useAsyncData('tmdb-status', () => + request<{ configured: boolean, ok: boolean }>('catalog/tmdb/status')) + const jackSubmitting = ref(false) const jackError = ref(null) const jackSaved = ref(false) @@ -191,6 +196,20 @@ async function confirmRevoke() { icon="i-ph-check-circle" title="Saved. Restart the server for the change to take effect." /> +

+ + + +

diff --git a/apps/ui/app/types/management.ts b/apps/ui/app/types/management.ts index a57cd59..ec340d4 100644 --- a/apps/ui/app/types/management.ts +++ b/apps/ui/app/types/management.ts @@ -101,6 +101,7 @@ export interface ServerInput { export interface JackConfig { internalUrl: string apiKey?: SecretRef | null + tmdbApiKey?: SecretRef | null } export interface ApiKey { From 92ba226cc18a11a8729431e388478323313a99f6 Mon Sep 17 00:00:00 2001 From: Roz Date: Sat, 27 Jun 2026 00:39:44 +0200 Subject: [PATCH 03/27] feat(catalog): enrich peer catalog with TMDB metadata Enrich grouped titles with TMDB artwork/overview/year/rating (concurrency- capped, cached) and redesign the catalog page as a poster grid with a per-title detail slideover. --- apps/backend/src/__tests__/catalog.test.ts | 120 +++++++++++++++++- .../src/modules/catalog/catalog.controller.ts | 25 +++- .../src/modules/catalog/catalog.lib.ts | 21 +++ apps/ui/app/components/CatalogPosterCard.vue | 49 +++++++ apps/ui/app/components/CatalogTitleDetail.vue | 44 +++++++ apps/ui/app/pages/catalog/[peerId].vue | 54 +++++--- apps/ui/app/types/management.ts | 12 ++ 7 files changed, 304 insertions(+), 21 deletions(-) create mode 100644 apps/ui/app/components/CatalogPosterCard.vue create mode 100644 apps/ui/app/components/CatalogTitleDetail.vue diff --git a/apps/backend/src/__tests__/catalog.test.ts b/apps/backend/src/__tests__/catalog.test.ts index 6075622..5f205a9 100644 --- a/apps/backend/src/__tests__/catalog.test.ts +++ b/apps/backend/src/__tests__/catalog.test.ts @@ -2,7 +2,7 @@ import type { Release } from '../lib/release' import { describe, expect, test } from 'bun:test' import { NotFoundError } from '../lib/errors/NotFoundError' import { CatalogController } from '../modules/catalog/catalog.controller' -import { groupReleasesIntoTitles } from '../modules/catalog/catalog.lib' +import { groupReleasesIntoTitles, mapLimit } from '../modules/catalog/catalog.lib' function movie(overrides: Partial = {}): Release { return { @@ -121,6 +121,124 @@ describe('catalogController', () => { }) }) +describe('mapLimit', () => { + test('preserves input order regardless of completion order', async () => { + const result = await mapLimit([3, 1, 2], 2, async (n) => { + await new Promise(resolve => setTimeout(resolve, n)) + return n * 10 + }) + + expect(result).toEqual([30, 10, 20]) + }) + + test('runs exactly `limit` tasks in flight, never more', async () => { + let inFlight = 0 + let peak = 0 + + await mapLimit([1, 2, 3, 4, 5, 6], 2, async () => { + inFlight += 1 + peak = Math.max(peak, inFlight) + await new Promise(resolve => setTimeout(resolve, 5)) + inFlight -= 1 + return null + }) + + // Saturates the cap (catches a collapse-to-one-worker regression) without exceeding it. + expect(peak).toBe(2) + }) + + test('returns an empty array for empty input', async () => { + expect(await mapLimit([], 4, async () => 1)).toEqual([]) + }) +}) + +describe('catalogController enrichment', () => { + function makeConnectors(peers: any[]) { + return { servers: [], peers } + } + + const breakingBad = { + tmdbId: 1396, + title: 'Breaking Bad', + overview: 'A chemistry teacher cooks meth.', + year: 2008, + rating: 8.9, + posterUrl: 'https://image.tmdb.org/t/p/w500/poster.jpg', + backdropUrl: 'https://image.tmdb.org/t/p/w780/backdrop.jpg', + genres: ['Drama'], + } + + test('attaches metadata to titles with a tmdbId and leaves id-less titles untouched', async () => { + const calls: Array<[string, number]> = [] + const tmdb = { + getMetadata: async (mediaType: string, tmdbId: number) => { + calls.push([mediaType, tmdbId]) + return tmdbId === 603 ? { ...breakingBad, tmdbId: 603, title: 'The Matrix' } : null + }, + } + const peer = { + id: 'peer-1', + name: 'Friend Jack', + listReleases: async () => [ + movie({ tmdbId: 603, title: 'The.Matrix.1999' }), + movie({ title: 'No.Id.2024' }), + ], + } + const controller = new CatalogController(makeConnectors([peer]) as any, tmdb as any) + + const result = await controller.getPeerCatalog('peer-1') + + const enriched = result.titles.find(t => t.tmdbId === 603) + expect(enriched!.metadata).toMatchObject({ title: 'The Matrix' }) + + const idless = result.titles.find(t => t.tmdbId == null) + expect(idless!.metadata).toBeUndefined() + + // No lookup is attempted for the id-less title. + expect(calls).toEqual([['movie', 603]]) + }) + + test('keeps the catalog intact when a getMetadata lookup rejects', async () => { + const tmdb = { + getMetadata: async (_mediaType: string, tmdbId: number) => { + if (tmdbId === 603) + throw new Error('TMDB exploded') + return { ...breakingBad } + }, + } + const peer = { + id: 'peer-1', + name: 'Friend Jack', + listReleases: async () => [ + movie({ tmdbId: 603, title: 'The.Matrix.1999' }), + episode({ tvdbId: 1, tmdbId: 1396, seriesTitle: 'Breaking Bad' }), + ], + } + const controller = new CatalogController(makeConnectors([peer]) as any, tmdb as any) + + const result = await controller.getPeerCatalog('peer-1') + + const failed = result.titles.find(t => t.tmdbId === 603) + expect(failed!.metadata).toBeUndefined() + + const ok = result.titles.find(t => t.mediaType === 'tv') + expect(ok!.metadata).toMatchObject({ title: 'Breaking Bad' }) + }) + + test('makes no TMDB calls and attaches no metadata when no client is configured', async () => { + const peer = { + id: 'peer-1', + name: 'Friend Jack', + listReleases: async () => [movie({ tmdbId: 603 })], + } + const controller = new CatalogController(makeConnectors([peer]) as any) + + const result = await controller.getPeerCatalog('peer-1') + + expect(result.titles[0]!.metadata).toBeUndefined() + }) +}) + describe('catalogController.getTmdbStatus', () => { function makeConnectors() { return { servers: [], peers: [] } diff --git a/apps/backend/src/modules/catalog/catalog.controller.ts b/apps/backend/src/modules/catalog/catalog.controller.ts index cce86ba..a424adf 100644 --- a/apps/backend/src/modules/catalog/catalog.controller.ts +++ b/apps/backend/src/modules/catalog/catalog.controller.ts @@ -3,7 +3,9 @@ import type { PeerConnector } from '../../lib/servers/peer' import type { TmdbClient } from '../../lib/tmdb/client' import type { CatalogTitle } from './catalog.lib' import { NotFoundError } from '../../lib/errors/NotFoundError' -import { groupReleasesIntoTitles } from './catalog.lib' +import { groupReleasesIntoTitles, mapLimit } from './catalog.lib' + +const TMDB_ENRICH_CONCURRENCY = 8 export interface PeerCatalogResponse { peer: { id: string, name: string } @@ -32,12 +34,31 @@ export class CatalogController { async getPeerCatalog(peerId: string): Promise { const peer = this.requirePeer(peerId) const releases = await peer.listReleases() + const titles = await this.enrichTitles(groupReleasesIntoTitles(releases)) return { peer: { id: peer.id, name: peer.name }, - titles: groupReleasesIntoTitles(releases), + titles, } } + private async enrichTitles(titles: CatalogTitle[]): Promise { + if (!this.tmdb) + return titles + const tmdb = this.tmdb + return mapLimit(titles, TMDB_ENRICH_CONCURRENCY, async (title) => { + if (!title.tmdbId) + return title + try { + const metadata = await tmdb.getMetadata(title.mediaType, title.tmdbId) + return { ...title, metadata } + } + catch { + // Enrichment is best-effort: a failed lookup must not blank the catalog. + return title + } + }) + } + async getTmdbStatus(): Promise { if (!this.tmdb) return { configured: false, ok: false } diff --git a/apps/backend/src/modules/catalog/catalog.lib.ts b/apps/backend/src/modules/catalog/catalog.lib.ts index 31dc21b..49f02cc 100644 --- a/apps/backend/src/modules/catalog/catalog.lib.ts +++ b/apps/backend/src/modules/catalog/catalog.lib.ts @@ -1,4 +1,5 @@ import type { Release } from '../../lib/release' +import type { TmdbMetadata } from '../../lib/tmdb/client' import { ReleaseCategory } from '../../lib/release' export interface CatalogTitle { @@ -12,6 +13,26 @@ export interface CatalogTitle { displayTitle: string releaseCount: number totalSize: number + metadata?: TmdbMetadata | null +} + +/** Run `fn` over `items` with at most `limit` in flight; preserves order. */ +export async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { + const results = Array.from({ length: items.length }) as R[] + let cursor = 0 + async function worker(): Promise { + while (cursor < items.length) { + const index = cursor++ + results[index] = await fn(items[index]!) + } + } + // Spawn each worker via its own call so they run concurrently; `.fill(worker())` + // would share one promise and silently collapse the pool to a single worker. + const workers: Array> = [] + for (let i = 0; i < Math.min(limit, items.length); i++) + workers.push(worker()) + await Promise.all(workers) + return results } function mediaTypeOf(release: Release): 'movie' | 'tv' { diff --git a/apps/ui/app/components/CatalogPosterCard.vue b/apps/ui/app/components/CatalogPosterCard.vue new file mode 100644 index 0000000..8a6076e --- /dev/null +++ b/apps/ui/app/components/CatalogPosterCard.vue @@ -0,0 +1,49 @@ + + + diff --git a/apps/ui/app/components/CatalogTitleDetail.vue b/apps/ui/app/components/CatalogTitleDetail.vue new file mode 100644 index 0000000..032bf39 --- /dev/null +++ b/apps/ui/app/components/CatalogTitleDetail.vue @@ -0,0 +1,44 @@ + + + diff --git a/apps/ui/app/pages/catalog/[peerId].vue b/apps/ui/app/pages/catalog/[peerId].vue index e3516af..711cd69 100644 --- a/apps/ui/app/pages/catalog/[peerId].vue +++ b/apps/ui/app/pages/catalog/[peerId].vue @@ -1,5 +1,5 @@ + + + + + + + diff --git a/apps/ui/app/types/management.ts b/apps/ui/app/types/management.ts index ec340d4..4301319 100644 --- a/apps/ui/app/types/management.ts +++ b/apps/ui/app/types/management.ts @@ -45,6 +45,17 @@ export interface DownloadItem { expectedBytesMismatch: boolean } +export interface TmdbMetadata { + tmdbId: number + title: string + overview: string | null + year: number | null + rating: number | null + posterUrl: string | null + backdropUrl: string | null + genres: string[] +} + export interface CatalogTitle { key: string mediaType: 'movie' | 'tv' @@ -54,6 +65,7 @@ export interface CatalogTitle { displayTitle: string releaseCount: number totalSize: number + metadata?: TmdbMetadata | null } export interface PeerCatalogResponse { From 3adeaabc752fb707e3e6c7b9c9ccaf78177d33b5 Mon Sep 17 00:00:00 2001 From: Roz Date: Sat, 27 Jun 2026 00:44:06 +0200 Subject: [PATCH 04/27] feat(catalog): add *arr request options modal Fetch each destination *arr's quality profiles and root folders via a /catalog/request-options endpoint, and add a Download modal that pre-fills them for the *arr matching the title's media type. --- apps/backend/src/__tests__/catalog.test.ts | 82 ++++++++++++++++ apps/backend/src/lib/servers/arr/base.ts | 18 ++++ .../src/modules/catalog/catalog.controller.ts | 32 ++++++ .../src/modules/catalog/catalog.router.ts | 3 + apps/ui/app/components/CatalogTitleDetail.vue | 14 ++- .../app/components/DownloadRequestModal.vue | 97 +++++++++++++++++++ apps/ui/app/pages/catalog/[peerId].vue | 19 +++- apps/ui/app/types/management.ts | 15 +++ 8 files changed, 277 insertions(+), 3 deletions(-) create mode 100644 apps/ui/app/components/DownloadRequestModal.vue diff --git a/apps/backend/src/__tests__/catalog.test.ts b/apps/backend/src/__tests__/catalog.test.ts index 5f205a9..737e3be 100644 --- a/apps/backend/src/__tests__/catalog.test.ts +++ b/apps/backend/src/__tests__/catalog.test.ts @@ -239,6 +239,88 @@ describe('catalogController enrichment', () => { }) }) +describe('catalogController.getRequestOptions', () => { + function fakeServer(overrides: Partial<{ + id: string + name: string + type: 'radarr' | 'sonarr' + canDestination: boolean + isInitialized: boolean + getQualityProfiles: () => Promise> + getRootFolders: () => Promise> + }> = {}) { + return { + id: 'radarr-1', + name: 'Radarr', + type: 'radarr', + canDestination: true, + isInitialized: true, + getQualityProfiles: async () => [{ id: 1, name: 'HD-1080p' }], + getRootFolders: async () => [{ path: '/movies', freeSpace: 1000 }], + ...overrides, + } + } + + function makeConnectors(servers: any[]) { + return { servers, peers: [] } + } + + test('returns only initialized destinations and tags movies with mediaType "movie"', async () => { + const radarr = fakeServer({ id: 'radarr-1', name: 'My Radarr', type: 'radarr' }) + const sonarrSourceOnly = fakeServer({ id: 'sonarr-1', name: 'Source Sonarr', type: 'sonarr', canDestination: false }) + const controller = new CatalogController(makeConnectors([radarr, sonarrSourceOnly]) as any) + + const options = await controller.getRequestOptions() + + expect(options).toHaveLength(1) + expect(options[0]!.id).toBe('radarr-1') + expect(options[0]!.name).toBe('My Radarr') + expect(options[0]!.type).toBe('radarr') + expect(options[0]!.mediaType).toBe('movie') + expect(options[0]!.qualityProfiles).toEqual([{ id: 1, name: 'HD-1080p' }]) + expect(options[0]!.rootFolders).toEqual([{ path: '/movies', freeSpace: 1000 }]) + }) + + test('tags a destination Sonarr with mediaType "tv"', async () => { + const sonarr = fakeServer({ + id: 'sonarr-1', + name: 'My Sonarr', + type: 'sonarr', + getRootFolders: async () => [{ path: '/tv' }], + }) + const controller = new CatalogController(makeConnectors([sonarr]) as any) + + const options = await controller.getRequestOptions() + + expect(options).toHaveLength(1) + expect(options[0]!.mediaType).toBe('tv') + }) + + test('excludes destinations that are not initialized', async () => { + const radarr = fakeServer({ isInitialized: false }) + const controller = new CatalogController(makeConnectors([radarr]) as any) + + expect(await controller.getRequestOptions()).toEqual([]) + }) + + test('skips a destination whose getQualityProfiles rejects but keeps others', async () => { + const broken = fakeServer({ + id: 'radarr-broken', + name: 'Broken Radarr', + getQualityProfiles: async () => { + throw new Error('unreachable') + }, + }) + const healthy = fakeServer({ id: 'radarr-ok', name: 'Healthy Radarr' }) + const controller = new CatalogController(makeConnectors([broken, healthy]) as any) + + const options = await controller.getRequestOptions() + + expect(options).toHaveLength(1) + expect(options[0]!.id).toBe('radarr-ok') + }) +}) + describe('catalogController.getTmdbStatus', () => { function makeConnectors() { return { servers: [], peers: [] } diff --git a/apps/backend/src/lib/servers/arr/base.ts b/apps/backend/src/lib/servers/arr/base.ts index aa179fc..9caf467 100644 --- a/apps/backend/src/lib/servers/arr/base.ts +++ b/apps/backend/src/lib/servers/arr/base.ts @@ -171,6 +171,24 @@ export abstract class ArrServerConnector extends ServerConnector { return this.fetch('/api/v3/health', { schema: z.array(DestinationServerHealthIssue) }) } + @requiresDestination + @requiresInitialization + async getQualityProfiles(): Promise> { + const profiles = await this.arrGet>('/api/v3/qualityprofile') + return (Array.isArray(profiles) ? profiles : []) + .filter(p => p.id != null && p.name != null) + .map(p => ({ id: p.id, name: p.name })) + } + + @requiresDestination + @requiresInitialization + async getRootFolders(): Promise> { + const folders = await this.arrGet>('/api/v3/rootfolder') + return (Array.isArray(folders) ? folders : []) + .filter(f => typeof f.path === 'string') + .map(f => ({ path: f.path, freeSpace: f.freeSpace })) + } + /** * Lowercased torrent infohashes (`downloadId`s) that this *arr has finished * importing recently, read from its history. The import watcher matches these diff --git a/apps/backend/src/modules/catalog/catalog.controller.ts b/apps/backend/src/modules/catalog/catalog.controller.ts index a424adf..84ebf50 100644 --- a/apps/backend/src/modules/catalog/catalog.controller.ts +++ b/apps/backend/src/modules/catalog/catalog.controller.ts @@ -18,6 +18,15 @@ export interface TmdbStatus { error?: string } +export interface RequestServerOption { + id: string + name: string + type: 'radarr' | 'sonarr' + mediaType: 'movie' | 'tv' + qualityProfiles: Array<{ id: number, name: string }> + rootFolders: Array<{ path: string, freeSpace?: number }> +} + export class CatalogController { constructor( private readonly connectors: { servers: ArrServerConnector[], peers: PeerConnector[] }, @@ -59,6 +68,29 @@ export class CatalogController { }) } + async getRequestOptions(): Promise { + const destinations = this.connectors.servers.filter(s => s.canDestination && s.isInitialized) + const options = await Promise.all(destinations.map(async (s) => { + try { + const [qualityProfiles, rootFolders] = await Promise.all([s.getQualityProfiles(), s.getRootFolders()]) + const type = s.type as 'radarr' | 'sonarr' + return { + id: s.id, + name: s.name, + type, + mediaType: type === 'sonarr' ? 'tv' : 'movie', + qualityProfiles, + rootFolders, + } satisfies RequestServerOption + } + catch { + // A destination that can't list its profiles can't take a request — drop it. + return null + } + })) + return options.filter((o): o is RequestServerOption => o !== null) + } + async getTmdbStatus(): Promise { if (!this.tmdb) return { configured: false, ok: false } diff --git a/apps/backend/src/modules/catalog/catalog.router.ts b/apps/backend/src/modules/catalog/catalog.router.ts index c5cea11..5b87915 100644 --- a/apps/backend/src/modules/catalog/catalog.router.ts +++ b/apps/backend/src/modules/catalog/catalog.router.ts @@ -11,6 +11,9 @@ export function getCatalogRouter(controller: CatalogController) { // Register the static path before `/:peerId` so "tmdb" isn't captured as a peerId. app.get('/tmdb/status', async c => c.json(await controller.getTmdbStatus())) + // Register before `/:peerId` so "request-options" isn't captured as a peerId. + app.get('/request-options', async c => c.json({ servers: await controller.getRequestOptions() })) + app.get('/:peerId', zValidator('param', peerParam), async (c) => { const { peerId } = c.req.valid('param') return c.json(await controller.getPeerCatalog(peerId)) diff --git a/apps/ui/app/components/CatalogTitleDetail.vue b/apps/ui/app/components/CatalogTitleDetail.vue index 032bf39..1f7ac32 100644 --- a/apps/ui/app/components/CatalogTitleDetail.vue +++ b/apps/ui/app/components/CatalogTitleDetail.vue @@ -2,9 +2,13 @@ import type { CatalogTitle } from '~/types/management' const props = defineProps<{ title: CatalogTitle }>() +const emit = defineEmits<{ download: [] }>() const name = computed(() => props.title.metadata?.title ?? props.title.displayTitle) const meta = computed(() => props.title.metadata ?? null) +const canRequest = computed(() => props.title.mediaType === 'tv' + ? props.title.tvdbId != null + : props.title.tmdbId != null) diff --git a/apps/ui/app/components/DownloadRequestModal.vue b/apps/ui/app/components/DownloadRequestModal.vue new file mode 100644 index 0000000..5d9c7d4 --- /dev/null +++ b/apps/ui/app/components/DownloadRequestModal.vue @@ -0,0 +1,97 @@ + + + diff --git a/apps/ui/app/pages/catalog/[peerId].vue b/apps/ui/app/pages/catalog/[peerId].vue index 711cd69..d9a7e3c 100644 --- a/apps/ui/app/pages/catalog/[peerId].vue +++ b/apps/ui/app/pages/catalog/[peerId].vue @@ -1,5 +1,5 @@ diff --git a/apps/ui/app/types/management.ts b/apps/ui/app/types/management.ts index 4301319..c60415f 100644 --- a/apps/ui/app/types/management.ts +++ b/apps/ui/app/types/management.ts @@ -73,6 +73,21 @@ export interface PeerCatalogResponse { titles: CatalogTitle[] } +export interface RequestServerOption { + id: string + name: string + type: 'radarr' | 'sonarr' + mediaType: 'movie' | 'tv' + qualityProfiles: Array<{ id: number, name: string }> + rootFolders: Array<{ path: string, freeSpace?: number }> +} + +export interface CatalogRequestPayload { + serverId: string + qualityProfileId: number + rootFolderPath: string +} + export interface Overview { peers: { total: number, initialized: number, items: PeerItem[] } servers: { total: number, initialized: number, sources: number, destinations: number, items: OverviewServerItem[] } From 2404be33a62aaa2190cba3ded5121a23cb98c4d4 Mon Sep 17 00:00:00 2001 From: Roz Date: Sat, 27 Jun 2026 00:50:28 +0200 Subject: [PATCH 05/27] feat(catalog): request a peer title into Radarr/Sonarr Add addAndSearch to the *arr connectors (lookup the title, then add it monitored with auto-search) and a POST /catalog/request endpoint, and wire the Download modal to it so requesting a title hands it to *arr. --- apps/backend/src/__tests__/catalog.test.ts | 120 +++++++++++++++++- .../backend/src/__tests__/integration.test.ts | 95 ++++++++++++++ apps/backend/src/lib/servers/arr/base.ts | 16 +++ apps/backend/src/lib/servers/arr/radarr.ts | 25 ++++ apps/backend/src/lib/servers/arr/sonarr.ts | 25 ++++ .../src/modules/catalog/catalog.controller.ts | 30 +++++ .../src/modules/catalog/catalog.router.ts | 13 ++ apps/ui/app/pages/catalog/[peerId].vue | 48 ++++++- 8 files changed, 366 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/__tests__/catalog.test.ts b/apps/backend/src/__tests__/catalog.test.ts index 737e3be..7aa69fe 100644 --- a/apps/backend/src/__tests__/catalog.test.ts +++ b/apps/backend/src/__tests__/catalog.test.ts @@ -1,5 +1,6 @@ import type { Release } from '../lib/release' -import { describe, expect, test } from 'bun:test' +import { describe, expect, mock, test } from 'bun:test' +import { BadRequestError } from '../lib/errors/BadRequestError' import { NotFoundError } from '../lib/errors/NotFoundError' import { CatalogController } from '../modules/catalog/catalog.controller' import { groupReleasesIntoTitles, mapLimit } from '../modules/catalog/catalog.lib' @@ -321,6 +322,123 @@ describe('catalogController.getRequestOptions', () => { }) }) +describe('catalogController.requestDownload', () => { + function fakeServer(overrides: Partial<{ + id: string + name: string + type: 'radarr' | 'sonarr' + canDestination: boolean + addAndSearch: (params: any) => Promise + }> = {}) { + return { + id: 'radarr-1', + name: 'My Radarr', + type: 'radarr', + canDestination: true, + addAndSearch: mock(async () => {}), + ...overrides, + } + } + + function makeConnectors(servers: any[]) { + return { servers, peers: [] } + } + + test('throws NotFoundError for an unknown serverId', () => { + const controller = new CatalogController(makeConnectors([]) as any) + + expect(controller.requestDownload({ + serverId: 'missing', + mediaType: 'movie', + tmdbId: 603, + qualityProfileId: 1, + rootFolderPath: '/movies', + })).rejects.toBeInstanceOf(NotFoundError) + }) + + test('throws BadRequestError when the server is not a destination', () => { + const radarr = fakeServer({ canDestination: false }) + const controller = new CatalogController(makeConnectors([radarr]) as any) + + expect(controller.requestDownload({ + serverId: 'radarr-1', + mediaType: 'movie', + tmdbId: 603, + qualityProfileId: 1, + rootFolderPath: '/movies', + })).rejects.toBeInstanceOf(BadRequestError) + }) + + test('throws BadRequestError when a movie request targets a Sonarr server', () => { + const sonarr = fakeServer({ id: 'sonarr-1', name: 'My Sonarr', type: 'sonarr' }) + const controller = new CatalogController(makeConnectors([sonarr]) as any) + + expect(controller.requestDownload({ + serverId: 'sonarr-1', + mediaType: 'movie', + tmdbId: 603, + qualityProfileId: 1, + rootFolderPath: '/movies', + })).rejects.toBeInstanceOf(BadRequestError) + }) + + test('throws BadRequestError when a tv request targets a Radarr server', () => { + const radarr = fakeServer() + const controller = new CatalogController(makeConnectors([radarr]) as any) + + expect(controller.requestDownload({ + serverId: 'radarr-1', + mediaType: 'tv', + tvdbId: 81189, + qualityProfileId: 1, + rootFolderPath: '/tv', + })).rejects.toBeInstanceOf(BadRequestError) + }) + + test('calls addAndSearch and returns ok for a valid matching request', async () => { + const radarr = fakeServer() + const controller = new CatalogController(makeConnectors([radarr]) as any) + + const result = await controller.requestDownload({ + serverId: 'radarr-1', + mediaType: 'movie', + tmdbId: 603, + qualityProfileId: 4, + rootFolderPath: '/movies', + }) + + expect(result).toEqual({ ok: true, server: 'My Radarr' }) + expect(radarr.addAndSearch).toHaveBeenCalledTimes(1) + expect(radarr.addAndSearch).toHaveBeenCalledWith({ + tmdbId: 603, + tvdbId: undefined, + qualityProfileId: 4, + rootFolderPath: '/movies', + }) + }) + + test('routes a tv request to the matching Sonarr destination', async () => { + const sonarr = fakeServer({ id: 'sonarr-1', name: 'My Sonarr', type: 'sonarr' }) + const controller = new CatalogController(makeConnectors([sonarr]) as any) + + const result = await controller.requestDownload({ + serverId: 'sonarr-1', + mediaType: 'tv', + tvdbId: 81189, + qualityProfileId: 7, + rootFolderPath: '/tv', + }) + + expect(result).toEqual({ ok: true, server: 'My Sonarr' }) + expect(sonarr.addAndSearch).toHaveBeenCalledWith({ + tmdbId: undefined, + tvdbId: 81189, + qualityProfileId: 7, + rootFolderPath: '/tv', + }) + }) +}) + describe('catalogController.getTmdbStatus', () => { function makeConnectors() { return { servers: [], peers: [] } diff --git a/apps/backend/src/__tests__/integration.test.ts b/apps/backend/src/__tests__/integration.test.ts index 79798cc..3341b2f 100644 --- a/apps/backend/src/__tests__/integration.test.ts +++ b/apps/backend/src/__tests__/integration.test.ts @@ -9,6 +9,7 @@ import { getApp } from '../app' import { runMigrations } from '../database/connection' import * as schema from '../database/schema' import { AppConfig, MIGRATIONS } from '../lib/config' +import { BadRequestError } from '../lib/errors/BadRequestError' import { RadarrServerConnector } from '../lib/servers/arr/radarr' import { SonarrServerConnector } from '../lib/servers/arr/sonarr' import { PeerConnector } from '../lib/servers/peer' @@ -528,6 +529,100 @@ describe('Auto-registration', () => { }) }) +describe('addAndSearch', () => { + const lookedUpMovie = { tmdbId: 603, title: 'The Matrix', year: 1999, titleSlug: 'the-matrix-603', images: [] } + const lookedUpSeries = { tvdbId: 81189, title: 'Breaking Bad', year: 2008, titleSlug: 'breaking-bad-81189', images: [] } + + test('Radarr: looks up the movie and POSTs it monitored with searchForMovie', async () => { + let lookupTerm: any = null + let postBody: any = null + server.use( + http.get(`${RADARR_URL}/api/v3/movie/lookup`, ({ request }) => { + lookupTerm = new URL(request.url).searchParams.get('term') + return HttpResponse.json([lookedUpMovie]) + }), + http.post(`${RADARR_URL}/api/v3/movie`, async ({ request }) => { + postBody = await request.json() + return HttpResponse.json({ id: 1, ...lookedUpMovie }) + }), + ) + + const radarr = markInitialized(makeRadarr()) + await radarr.addAndSearch({ tmdbId: 603, qualityProfileId: 4, rootFolderPath: '/movies' }) + + expect(lookupTerm).toBe('tmdb:603') + expect(postBody).toMatchObject({ + tmdbId: 603, + title: 'The Matrix', + qualityProfileId: 4, + rootFolderPath: '/movies', + monitored: true, + }) + expect(postBody.addOptions).toMatchObject({ searchForMovie: true }) + }) + + test('Radarr: throws BadRequestError when no tmdbId is given', async () => { + const radarr = markInitialized(makeRadarr()) + await expect(radarr.addAndSearch({ qualityProfileId: 4, rootFolderPath: '/movies' })).rejects.toThrow(BadRequestError) + }) + + test('Radarr: throws BadRequestError naming the server and id when lookup is empty', async () => { + server.use( + http.get(`${RADARR_URL}/api/v3/movie/lookup`, () => HttpResponse.json([])), + ) + const radarr = markInitialized(makeRadarr()) + const promise = radarr.addAndSearch({ tmdbId: 603, qualityProfileId: 4, rootFolderPath: '/movies' }) + await expect(promise).rejects.toThrow(BadRequestError) + await expect(promise).rejects.toThrow(/My Radarr/) + await expect(promise).rejects.toThrow(/603/) + }) + + test('Sonarr: looks up the series and POSTs it monitored with searchForMissingEpisodes', async () => { + let lookupTerm: any = null + let postBody: any = null + server.use( + http.get(`${SONARR_URL}/api/v3/series/lookup`, ({ request }) => { + lookupTerm = new URL(request.url).searchParams.get('term') + return HttpResponse.json([lookedUpSeries]) + }), + http.post(`${SONARR_URL}/api/v3/series`, async ({ request }) => { + postBody = await request.json() + return HttpResponse.json({ id: 1, ...lookedUpSeries }) + }), + ) + + const sonarr = markInitialized(makeSonarr()) + await sonarr.addAndSearch({ tvdbId: 81189, qualityProfileId: 7, rootFolderPath: '/tv' }) + + expect(lookupTerm).toBe('tvdb:81189') + expect(postBody).toMatchObject({ + tvdbId: 81189, + title: 'Breaking Bad', + qualityProfileId: 7, + rootFolderPath: '/tv', + monitored: true, + seasonFolder: true, + }) + expect(postBody.addOptions).toMatchObject({ searchForMissingEpisodes: true }) + }) + + test('Sonarr: throws BadRequestError when no tvdbId is given', async () => { + const sonarr = markInitialized(makeSonarr()) + await expect(sonarr.addAndSearch({ qualityProfileId: 7, rootFolderPath: '/tv' })).rejects.toThrow(BadRequestError) + }) + + test('Sonarr: throws BadRequestError naming the server and id when lookup is empty', async () => { + server.use( + http.get(`${SONARR_URL}/api/v3/series/lookup`, () => HttpResponse.json([])), + ) + const sonarr = markInitialized(makeSonarr()) + const promise = sonarr.addAndSearch({ tvdbId: 81189, qualityProfileId: 7, rootFolderPath: '/tv' }) + await expect(promise).rejects.toThrow(BadRequestError) + await expect(promise).rejects.toThrow(/My Sonarr/) + await expect(promise).rejects.toThrow(/81189/) + }) +}) + describe('Routes mount without peers or sources', () => { function createBareApp() { return getApp(envs, config, { servers: [], peers: [] }) diff --git a/apps/backend/src/lib/servers/arr/base.ts b/apps/backend/src/lib/servers/arr/base.ts index 9caf467..4335381 100644 --- a/apps/backend/src/lib/servers/arr/base.ts +++ b/apps/backend/src/lib/servers/arr/base.ts @@ -42,6 +42,13 @@ const JACK_DOWNLOAD_CLIENT_PRIORITY = 50 export type ReleaseKind = 'movie' | 'episode' +export interface AddAndSearchParams { + tmdbId?: number + tvdbId?: number + qualityProfileId: number + rootFolderPath: string +} + export function basename(path: string): string { return path.split(BASENAME_SEPARATOR_REGEX).pop() ?? path } @@ -189,6 +196,15 @@ export abstract class ArrServerConnector extends ServerConnector { .map(f => ({ path: f.path, freeSpace: f.freeSpace })) } + /** Add a title to this *arr (monitored) and kick off an automatic search. */ + @requiresDestination + @requiresInitialization + async addAndSearch(params: AddAndSearchParams): Promise { + return this.doAddAndSearch(params) + } + + protected abstract doAddAndSearch(params: AddAndSearchParams): Promise + /** * Lowercased torrent infohashes (`downloadId`s) that this *arr has finished * importing recently, read from its history. The import watcher matches these diff --git a/apps/backend/src/lib/servers/arr/radarr.ts b/apps/backend/src/lib/servers/arr/radarr.ts index dd9de37..f3ae703 100644 --- a/apps/backend/src/lib/servers/arr/radarr.ts +++ b/apps/backend/src/lib/servers/arr/radarr.ts @@ -1,6 +1,8 @@ import type { MovieFileResource, MovieResource } from '@jack/schemas/radarr/types' import type { AutoRegisterConfig, ConnectorHeadersConfig } from '../../config' import type { Release } from '../../release' +import type { AddAndSearchParams } from './base' +import { BadRequestError } from '../../errors/BadRequestError' import { normalizeImdbId, ReleaseCategory } from '../../release' import { setSpanAttribute, setSpanAttributes } from '../../span-attributes' import { withSpan } from '../../tracing' @@ -146,4 +148,27 @@ export class RadarrServerConnector extends ArrServerConnector { const movie = await this.getMovie(id) return (movie?.movieFile as MovieFileResource | undefined)?.path ?? null } + + protected override async doAddAndSearch(params: AddAndSearchParams): Promise { + if (params.tmdbId == null) + throw new BadRequestError('A tmdbId is required to add a movie to Radarr') + const lookup = await this.arrGet('/api/v3/movie/lookup', { term: `tmdb:${params.tmdbId}` }) + const movie = Array.isArray(lookup) ? lookup[0] : undefined + if (!movie) + throw new BadRequestError(`No movie found on ${this.name} for tmdbId ${params.tmdbId}`) + + const body = { + ...movie, + qualityProfileId: params.qualityProfileId, + rootFolderPath: params.rootFolderPath, + monitored: true, + minimumAvailability: 'released', + addOptions: { searchForMovie: true }, + } + await this.fetch('/api/v3/movie', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + } as any) + } } diff --git a/apps/backend/src/lib/servers/arr/sonarr.ts b/apps/backend/src/lib/servers/arr/sonarr.ts index 22f5018..91b2fdc 100644 --- a/apps/backend/src/lib/servers/arr/sonarr.ts +++ b/apps/backend/src/lib/servers/arr/sonarr.ts @@ -1,6 +1,8 @@ import type { EpisodeFileResource, EpisodeResource, SeriesResource } from '@jack/schemas/sonarr/types' import type { AutoRegisterConfig, ConnectorHeadersConfig } from '../../config' import type { Release } from '../../release' +import type { AddAndSearchParams } from './base' +import { BadRequestError } from '../../errors/BadRequestError' import { ReleaseCategory } from '../../release' import { setSpanAttributes } from '../../span-attributes' import { withSpan } from '../../tracing' @@ -163,4 +165,27 @@ export class SonarrServerConnector extends ArrServerConnector { const bundle = await this.fetchEpisodeBundle(id) return bundle?.file?.path ?? null } + + protected override async doAddAndSearch(params: AddAndSearchParams): Promise { + if (params.tvdbId == null) + throw new BadRequestError('A tvdbId is required to add a series to Sonarr') + const lookup = await this.arrGet('/api/v3/series/lookup', { term: `tvdb:${params.tvdbId}` }) + const series = Array.isArray(lookup) ? lookup[0] : undefined + if (!series) + throw new BadRequestError(`No series found on ${this.name} for tvdbId ${params.tvdbId}`) + + const body = { + ...series, + qualityProfileId: params.qualityProfileId, + rootFolderPath: params.rootFolderPath, + monitored: true, + seasonFolder: true, + addOptions: { monitor: 'all', searchForMissingEpisodes: true }, + } + await this.fetch('/api/v3/series', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + } as any) + } } diff --git a/apps/backend/src/modules/catalog/catalog.controller.ts b/apps/backend/src/modules/catalog/catalog.controller.ts index 84ebf50..b9aa5d3 100644 --- a/apps/backend/src/modules/catalog/catalog.controller.ts +++ b/apps/backend/src/modules/catalog/catalog.controller.ts @@ -2,6 +2,7 @@ import type { ArrServerConnector } from '../../lib/servers/arr/base' import type { PeerConnector } from '../../lib/servers/peer' import type { TmdbClient } from '../../lib/tmdb/client' import type { CatalogTitle } from './catalog.lib' +import { BadRequestError } from '../../lib/errors/BadRequestError' import { NotFoundError } from '../../lib/errors/NotFoundError' import { groupReleasesIntoTitles, mapLimit } from './catalog.lib' @@ -27,6 +28,15 @@ export interface RequestServerOption { rootFolders: Array<{ path: string, freeSpace?: number }> } +export interface CatalogRequestInput { + serverId: string + mediaType: 'movie' | 'tv' + tmdbId?: number + tvdbId?: number + qualityProfileId: number + rootFolderPath: string +} + export class CatalogController { constructor( private readonly connectors: { servers: ArrServerConnector[], peers: PeerConnector[] }, @@ -91,6 +101,26 @@ export class CatalogController { return options.filter((o): o is RequestServerOption => o !== null) } + async requestDownload(input: CatalogRequestInput): Promise<{ ok: true, server: string }> { + const server = this.connectors.servers.find(s => s.id === input.serverId) + if (!server) + throw new NotFoundError(`No server found with id "${input.serverId}"`) + if (!server.canDestination) + throw new BadRequestError(`Server "${server.name}" is not a destination`) + // Defense in depth (the UI already filters): a movie must go to Radarr, tv to Sonarr. + const expectedType = input.mediaType === 'tv' ? 'sonarr' : 'radarr' + if (server.type !== expectedType) + throw new BadRequestError(`Server "${server.name}" cannot handle ${input.mediaType} requests`) + + await server.addAndSearch({ + tmdbId: input.tmdbId, + tvdbId: input.tvdbId, + qualityProfileId: input.qualityProfileId, + rootFolderPath: input.rootFolderPath, + }) + return { ok: true, server: server.name } + } + async getTmdbStatus(): Promise { if (!this.tmdb) return { configured: false, ok: false } diff --git a/apps/backend/src/modules/catalog/catalog.router.ts b/apps/backend/src/modules/catalog/catalog.router.ts index 5b87915..c748b1f 100644 --- a/apps/backend/src/modules/catalog/catalog.router.ts +++ b/apps/backend/src/modules/catalog/catalog.router.ts @@ -5,6 +5,15 @@ import { z } from 'zod' const peerParam = z.object({ peerId: z.string().min(1) }) +const requestBody = z.object({ + serverId: z.string().min(1), + mediaType: z.enum(['movie', 'tv']), + tmdbId: z.number().int().optional(), + tvdbId: z.number().int().optional(), + qualityProfileId: z.number().int(), + rootFolderPath: z.string().min(1), +}) + export function getCatalogRouter(controller: CatalogController) { const app = new Hono() @@ -14,6 +23,10 @@ export function getCatalogRouter(controller: CatalogController) { // Register before `/:peerId` so "request-options" isn't captured as a peerId. app.get('/request-options', async c => c.json({ servers: await controller.getRequestOptions() })) + app.post('/request', zValidator('json', requestBody), async (c) => { + return c.json(await controller.requestDownload(c.req.valid('json'))) + }) + app.get('/:peerId', zValidator('param', peerParam), async (c) => { const { peerId } = c.req.valid('param') return c.json(await controller.getPeerCatalog(peerId)) diff --git a/apps/ui/app/pages/catalog/[peerId].vue b/apps/ui/app/pages/catalog/[peerId].vue index d9a7e3c..dd494e0 100644 --- a/apps/ui/app/pages/catalog/[peerId].vue +++ b/apps/ui/app/pages/catalog/[peerId].vue @@ -3,7 +3,8 @@ import type { CatalogRequestPayload, CatalogTitle, PeerCatalogResponse } from '~ const route = useRoute() const peerId = computed(() => String(route.params.peerId)) -const { request } = useManagement() +const { request, extractError } = useManagement() +const toast = useToast() const { data, pending, error } = await useAsyncData( `catalog-${peerId.value}`, @@ -22,15 +23,46 @@ const selected = ref(null) const requestOpen = ref(false) const requestTitle = ref(null) +const requestSubmitting = ref(false) +const requestError = ref(null) function openRequest() { requestTitle.value = selected.value + requestError.value = null requestOpen.value = true } -// Phase 5 replaces this with the POST to catalog/request. -function onConfirm(_payload: CatalogRequestPayload) { - requestOpen.value = false +async function onConfirm(payload: CatalogRequestPayload) { + const title = requestTitle.value + if (!title) + return + requestSubmitting.value = true + requestError.value = null + try { + await request('catalog/request', { + method: 'POST', + body: { + ...payload, + mediaType: title.mediaType, + tmdbId: title.tmdbId, + tvdbId: title.tvdbId, + }, + }) + requestOpen.value = false + selected.value = null + toast.add({ + title: 'Added to your library', + description: `"${title.metadata?.title ?? title.displayTitle}" is being searched by your *arr.`, + color: 'success', + icon: 'i-ph-check-circle', + }) + } + catch (err) { + requestError.value = extractError(err, 'Could not request this title.') + } + finally { + requestSubmitting.value = false + } } @@ -92,5 +124,11 @@ function onConfirm(_payload: CatalogRequestPayload) { - + From 4867c7517554b4070ba9f6beff1366ab63b5f257 Mon Sep 17 00:00:00 2001 From: Roz Date: Sat, 27 Jun 2026 09:05:09 +0200 Subject: [PATCH 06/27] feat(torznab): tag Jack releases with the Internal indexer flag Emit torznab attr tag=internal on every item; *arr maps it to the Internal indexer flag, so releases are targetable by a custom format. --- apps/backend/src/__tests__/torznab.test.ts | 10 ++++++++++ apps/backend/src/modules/torznab/torznab.router.ts | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/apps/backend/src/__tests__/torznab.test.ts b/apps/backend/src/__tests__/torznab.test.ts index b44189d..4aabaa0 100644 --- a/apps/backend/src/__tests__/torznab.test.ts +++ b/apps/backend/src/__tests__/torznab.test.ts @@ -53,6 +53,16 @@ describe('Torznab XML helpers', () => { expect(attrValue(item, 'uploadvolumefactor')).toBe(1) }) + test('buildSearchResultXml tags every item as an internal release', () => { + // *arr's TorznabRssParser maps `tag=internal` to the Internal indexer flag, + // so a custom format (IndexerFlagSpecification) can target Jack releases. + const result = buildSearchResultXml([releaseToTorznab(movieRelease, 'peer1', 'Friend', 'http://localhost:3000', JACK_API_KEY)]) + const tags = (result.rss.channel.item[0]['torznab:attr'] as Array>) + .filter(a => a['@name'] === 'tag') + .map(a => a['@value']) + expect(tags).toContain('internal') + }) + test('buildSearchResultXml emits tv attrs for episodes', () => { const result = buildSearchResultXml([releaseToTorznab(episodeRelease, 'peer1', 'Friend', 'http://localhost:3000', JACK_API_KEY)]) const item = result.rss.channel.item[0] diff --git a/apps/backend/src/modules/torznab/torznab.router.ts b/apps/backend/src/modules/torznab/torznab.router.ts index 979363a..edd5271 100644 --- a/apps/backend/src/modules/torznab/torznab.router.ts +++ b/apps/backend/src/modules/torznab/torznab.router.ts @@ -64,6 +64,10 @@ function itemToObject(item: TorznabItem): Record { // Jack files are always freely available; tell *arr not to weight ratio. { '@name': 'downloadvolumefactor', '@value': 0 }, { '@name': 'uploadvolumefactor', '@value': 1 }, + // *arr's TorznabRssParser maps `tag=internal` to the Internal indexer flag, + // marking every Jack release as coming from your own peer network so a custom + // format (IndexerFlagSpecification) can score/prefer it. + { '@name': 'tag', '@value': 'internal' }, ] if (item.imdbId) attrs.push({ '@name': 'imdbid', '@value': item.imdbId }) From ac8a50ee494ae1b389f921be1fc4d30221a6c5b5 Mon Sep 17 00:00:00 2001 From: Roz Date: Sat, 27 Jun 2026 09:05:15 +0200 Subject: [PATCH 07/27] feat(arr): register a Jack-only custom format and quality profile Auto-register (and ensure on demand) a 'Jack' custom format matching the Internal indexer flag and a 'Jack' quality profile that only accepts releases with it (minFormatScore 1, upgrades off). Catalog downloads force that profile so *arr grabs the title only from Jack, never from other indexers. The Internal flag value is per-app (Radarr 32, Sonarr 8). --- apps/backend/src/__tests__/catalog.test.ts | 22 ++- .../backend/src/__tests__/integration.test.ts | 143 ++++++++++++++++++ apps/backend/src/index.ts | 20 +-- apps/backend/src/lib/autoregister.test.ts | 30 +++- apps/backend/src/lib/autoregister.ts | 16 +- apps/backend/src/lib/servers/arr/base.ts | 105 ++++++++++++- apps/backend/src/lib/servers/arr/radarr.ts | 4 + apps/backend/src/lib/servers/arr/sonarr.ts | 4 + .../src/modules/catalog/catalog.controller.ts | 12 +- .../src/modules/catalog/catalog.router.ts | 1 - .../app/components/DownloadRequestModal.vue | 20 +-- apps/ui/app/types/management.ts | 2 - 12 files changed, 326 insertions(+), 53 deletions(-) diff --git a/apps/backend/src/__tests__/catalog.test.ts b/apps/backend/src/__tests__/catalog.test.ts index 7aa69fe..5f9d7d0 100644 --- a/apps/backend/src/__tests__/catalog.test.ts +++ b/apps/backend/src/__tests__/catalog.test.ts @@ -247,7 +247,6 @@ describe('catalogController.getRequestOptions', () => { type: 'radarr' | 'sonarr' canDestination: boolean isInitialized: boolean - getQualityProfiles: () => Promise> getRootFolders: () => Promise> }> = {}) { return { @@ -256,7 +255,6 @@ describe('catalogController.getRequestOptions', () => { type: 'radarr', canDestination: true, isInitialized: true, - getQualityProfiles: async () => [{ id: 1, name: 'HD-1080p' }], getRootFolders: async () => [{ path: '/movies', freeSpace: 1000 }], ...overrides, } @@ -278,7 +276,7 @@ describe('catalogController.getRequestOptions', () => { expect(options[0]!.name).toBe('My Radarr') expect(options[0]!.type).toBe('radarr') expect(options[0]!.mediaType).toBe('movie') - expect(options[0]!.qualityProfiles).toEqual([{ id: 1, name: 'HD-1080p' }]) + expect(options[0]!).not.toHaveProperty('qualityProfiles') expect(options[0]!.rootFolders).toEqual([{ path: '/movies', freeSpace: 1000 }]) }) @@ -304,11 +302,11 @@ describe('catalogController.getRequestOptions', () => { expect(await controller.getRequestOptions()).toEqual([]) }) - test('skips a destination whose getQualityProfiles rejects but keeps others', async () => { + test('skips a destination whose getRootFolders rejects but keeps others', async () => { const broken = fakeServer({ id: 'radarr-broken', name: 'Broken Radarr', - getQualityProfiles: async () => { + getRootFolders: async () => { throw new Error('unreachable') }, }) @@ -328,6 +326,7 @@ describe('catalogController.requestDownload', () => { name: string type: 'radarr' | 'sonarr' canDestination: boolean + ensureJackQualityProfile: () => Promise addAndSearch: (params: any) => Promise }> = {}) { return { @@ -335,6 +334,7 @@ describe('catalogController.requestDownload', () => { name: 'My Radarr', type: 'radarr', canDestination: true, + ensureJackQualityProfile: mock(async () => 77), addAndSearch: mock(async () => {}), ...overrides, } @@ -351,7 +351,6 @@ describe('catalogController.requestDownload', () => { serverId: 'missing', mediaType: 'movie', tmdbId: 603, - qualityProfileId: 1, rootFolderPath: '/movies', })).rejects.toBeInstanceOf(NotFoundError) }) @@ -364,7 +363,6 @@ describe('catalogController.requestDownload', () => { serverId: 'radarr-1', mediaType: 'movie', tmdbId: 603, - qualityProfileId: 1, rootFolderPath: '/movies', })).rejects.toBeInstanceOf(BadRequestError) }) @@ -377,7 +375,6 @@ describe('catalogController.requestDownload', () => { serverId: 'sonarr-1', mediaType: 'movie', tmdbId: 603, - qualityProfileId: 1, rootFolderPath: '/movies', })).rejects.toBeInstanceOf(BadRequestError) }) @@ -390,7 +387,6 @@ describe('catalogController.requestDownload', () => { serverId: 'radarr-1', mediaType: 'tv', tvdbId: 81189, - qualityProfileId: 1, rootFolderPath: '/tv', })).rejects.toBeInstanceOf(BadRequestError) }) @@ -403,16 +399,17 @@ describe('catalogController.requestDownload', () => { serverId: 'radarr-1', mediaType: 'movie', tmdbId: 603, - qualityProfileId: 4, rootFolderPath: '/movies', }) expect(result).toEqual({ ok: true, server: 'My Radarr' }) + expect(radarr.ensureJackQualityProfile).toHaveBeenCalledTimes(1) expect(radarr.addAndSearch).toHaveBeenCalledTimes(1) + // The Jack profile (77) is forced regardless of any client input. expect(radarr.addAndSearch).toHaveBeenCalledWith({ tmdbId: 603, tvdbId: undefined, - qualityProfileId: 4, + qualityProfileId: 77, rootFolderPath: '/movies', }) }) @@ -425,7 +422,6 @@ describe('catalogController.requestDownload', () => { serverId: 'sonarr-1', mediaType: 'tv', tvdbId: 81189, - qualityProfileId: 7, rootFolderPath: '/tv', }) @@ -433,7 +429,7 @@ describe('catalogController.requestDownload', () => { expect(sonarr.addAndSearch).toHaveBeenCalledWith({ tmdbId: undefined, tvdbId: 81189, - qualityProfileId: 7, + qualityProfileId: 77, rootFolderPath: '/tv', }) }) diff --git a/apps/backend/src/__tests__/integration.test.ts b/apps/backend/src/__tests__/integration.test.ts index 3341b2f..2f1aee3 100644 --- a/apps/backend/src/__tests__/integration.test.ts +++ b/apps/backend/src/__tests__/integration.test.ts @@ -623,6 +623,149 @@ describe('addAndSearch', () => { }) }) +describe('ensureJackCustomFormat', () => { + test('Radarr: POSTs a "Jack" custom format matching the Internal flag (value 32)', async () => { + let postBody: any = null + server.use( + http.get(`${RADARR_URL}/api/v3/customformat`, () => HttpResponse.json([])), + http.post(`${RADARR_URL}/api/v3/customformat`, async ({ request }) => { + postBody = await request.json() + return HttpResponse.json({ id: 11, name: 'Jack' }) + }), + ) + + const radarr = markInitialized(makeRadarr()) + const id = await radarr.ensureJackCustomFormat() + + expect(id).toBe(11) + expect(postBody).toMatchObject({ name: 'Jack', includeCustomFormatWhenRenaming: false }) + expect(postBody.specifications).toHaveLength(1) + expect(postBody.specifications[0]).toMatchObject({ + name: 'Internal', + implementation: 'IndexerFlagSpecification', + negate: false, + required: true, + }) + expect(postBody.specifications[0].fields).toEqual([{ name: 'value', value: 32 }]) + }) + + test('Sonarr: uses the Internal flag value 8', async () => { + let postBody: any = null + server.use( + http.get(`${SONARR_URL}/api/v3/customformat`, () => HttpResponse.json([])), + http.post(`${SONARR_URL}/api/v3/customformat`, async ({ request }) => { + postBody = await request.json() + return HttpResponse.json({ id: 5, name: 'Jack' }) + }), + ) + + const sonarr = markInitialized(makeSonarr()) + const id = await sonarr.ensureJackCustomFormat() + + expect(id).toBe(5) + expect(postBody.specifications[0].fields).toEqual([{ name: 'value', value: 8 }]) + }) + + test('upserts an existing "Jack" custom format with a PUT to /{id}', async () => { + let putBody: any = null + let posted = false + server.use( + http.get(`${RADARR_URL}/api/v3/customformat`, () => HttpResponse.json([{ id: 9, name: 'Jack' }])), + http.post(`${RADARR_URL}/api/v3/customformat`, () => { + posted = true + return HttpResponse.json({ id: 9, name: 'Jack' }) + }), + http.put(`${RADARR_URL}/api/v3/customformat/9`, async ({ request }) => { + putBody = await request.json() + return HttpResponse.json({ id: 9, name: 'Jack' }) + }), + ) + + const radarr = markInitialized(makeRadarr()) + const id = await radarr.ensureJackCustomFormat() + + expect(id).toBe(9) + expect(posted).toBe(false) + expect(putBody).toMatchObject({ id: 9, name: 'Jack' }) + }) +}) + +describe('ensureJackQualityProfile', () => { + const schemaTemplate = { + name: '', + upgradeAllowed: false, + cutoff: 1, + minFormatScore: 0, + cutoffFormatScore: 0, + items: [ + { quality: { id: 1, name: 'SDTV' }, allowed: false }, + { name: 'HD', allowed: false, items: [{ quality: { id: 2, name: 'WEBDL-1080p' }, allowed: false }] }, + ], + formatItems: [ + { format: 11, name: 'Jack', score: 0 }, + { format: 12, name: 'Other', score: 0 }, + ], + } + + test('Radarr: ensures the CF then POSTs a "Jack" profile that only scores the Jack flag', async () => { + let postBody: any = null + server.use( + http.get(`${RADARR_URL}/api/v3/customformat`, () => HttpResponse.json([])), + http.post(`${RADARR_URL}/api/v3/customformat`, () => HttpResponse.json({ id: 11, name: 'Jack' })), + http.get(`${RADARR_URL}/api/v3/qualityprofile/schema`, () => HttpResponse.json(schemaTemplate)), + http.get(`${RADARR_URL}/api/v3/qualityprofile`, () => HttpResponse.json([])), + http.post(`${RADARR_URL}/api/v3/qualityprofile`, async ({ request }) => { + postBody = await request.json() + return HttpResponse.json({ id: 77, name: 'Jack' }) + }), + ) + + const radarr = markInitialized(makeRadarr()) + const id = await radarr.ensureJackQualityProfile() + + expect(id).toBe(77) + expect(postBody).toMatchObject({ + name: 'Jack', + upgradeAllowed: false, + minFormatScore: 1, + cutoffFormatScore: 1, + }) + // All qualities allowed — recursively, including the nested group. + expect(postBody.items[0].allowed).toBe(true) + expect(postBody.items[1].allowed).toBe(true) + expect(postBody.items[1].items[0].allowed).toBe(true) + // Only the Jack format is scored 1; others stay at 0. + expect(postBody.formatItems.find((f: any) => f.name === 'Jack').score).toBe(1) + expect(postBody.formatItems.find((f: any) => f.name === 'Other').score).toBe(0) + }) + + test('upserts an existing "Jack" profile with a PUT to /{id}', async () => { + let putBody: any = null + let posted = false + server.use( + http.get(`${RADARR_URL}/api/v3/customformat`, () => HttpResponse.json([{ id: 11, name: 'Jack' }])), + http.put(`${RADARR_URL}/api/v3/customformat/11`, () => HttpResponse.json({ id: 11, name: 'Jack' })), + http.get(`${RADARR_URL}/api/v3/qualityprofile/schema`, () => HttpResponse.json(schemaTemplate)), + http.get(`${RADARR_URL}/api/v3/qualityprofile`, () => HttpResponse.json([{ id: 42, name: 'Jack' }])), + http.post(`${RADARR_URL}/api/v3/qualityprofile`, () => { + posted = true + return HttpResponse.json({ id: 42, name: 'Jack' }) + }), + http.put(`${RADARR_URL}/api/v3/qualityprofile/42`, async ({ request }) => { + putBody = await request.json() + return HttpResponse.json({ id: 42, name: 'Jack' }) + }), + ) + + const radarr = markInitialized(makeRadarr()) + const id = await radarr.ensureJackQualityProfile() + + expect(id).toBe(42) + expect(posted).toBe(false) + expect(putBody).toMatchObject({ id: 42, name: 'Jack', minFormatScore: 1 }) + }) +}) + describe('Routes mount without peers or sources', () => { function createBareApp() { return getApp(envs, config, { servers: [], peers: [] }) diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 5c2cd08..6fea726 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -113,15 +113,17 @@ for (const dest of registrable) { internalUrl: jackConfig.internalUrl, downloads: Boolean(downloads), category: qbCategoryForServer(dest.id), - onSuccess: (kind, name, meta) => - logger.info( - kind === 'download client' - ? { destination: name, downloadClientId: meta.downloadClientId } - : { destination: name, categories: meta.categories, downloadClientId: meta.downloadClientId }, - kind === 'download client' - ? 'Registered Jack as qBittorrent download client' - : 'Registered Jack as Torznab indexer', - ), + onSuccess: (kind, name, meta) => { + if (kind === 'download client') { + logger.info({ destination: name, downloadClientId: meta.downloadClientId }, 'Registered Jack as qBittorrent download client') + return + } + if (kind === 'quality profile') { + logger.info({ destination: name, profileId: meta.profileId }, 'Registered Jack-only quality profile') + return + } + logger.info({ destination: name, categories: meta.categories, downloadClientId: meta.downloadClientId }, 'Registered Jack as Torznab indexer') + }, onFailure: logRegistrationFailure, }) } diff --git a/apps/backend/src/lib/autoregister.test.ts b/apps/backend/src/lib/autoregister.test.ts index 377a513..79ef47b 100644 --- a/apps/backend/src/lib/autoregister.test.ts +++ b/apps/backend/src/lib/autoregister.test.ts @@ -1,7 +1,7 @@ import type { ManagedRegistrationDeps } from './autoregister' import type { ArrServerConnector } from './servers/arr/base' import { Database } from 'bun:sqlite' -import { beforeEach, describe, expect, test } from 'bun:test' +import { beforeEach, describe, expect, mock, test } from 'bun:test' import { drizzle } from 'drizzle-orm/bun-sqlite' import { runMigrations } from '../database/connection' import * as schema from '../database/schema' @@ -21,7 +21,7 @@ describe('registerManagedForDestination', () => { service = new ManagedApiKeys(repo) }) - function stubDest(over: Partial<{ registerIndexer: () => Promise, registerDownloadClient: () => Promise }> = {}): ArrServerConnector { + function stubDest(over: Partial<{ registerIndexer: () => Promise, registerDownloadClient: () => Promise, ensureJackQualityProfile: () => Promise }> = {}): ArrServerConnector { return { id: 'srv-a', name: 'Radarr', @@ -29,6 +29,7 @@ describe('registerManagedForDestination', () => { autoRegister: { enable: true, priority: 1 }, registerDownloadClient: over.registerDownloadClient ?? (async () => 1), registerIndexer: over.registerIndexer ?? (async () => {}), + ensureJackQualityProfile: over.ensureJackQualityProfile ?? (async () => 99), } as unknown as ArrServerConnector } @@ -93,4 +94,29 @@ describe('registerManagedForDestination', () => { ) expect(repo.findByServerId('srv-a').map(r => r.id)).toEqual([old.id]) }) + + test('registers the Jack quality profile and reports it via onSuccess', async () => { + const profileCall = mock(async () => 77) + const successes: Array<[string, number | undefined]> = [] + await registerManagedForDestination( + stubDest({ ensureJackQualityProfile: profileCall }), + { ...deps(), onSuccess: (kind, _name, meta) => successes.push([kind, meta.profileId]) }, + ) + expect(profileCall).toHaveBeenCalledTimes(1) + expect(successes).toContainEqual(['quality profile', 77]) + }) + + test('a failing quality-profile registration does not gate the managed-key commit', async () => { + const old = service.provision('srv-a') + const failures: string[] = [] + await registerManagedForDestination( + stubDest({ ensureJackQualityProfile: async () => { throw new Error('qp') } }), + { ...deps(), onFailure: kind => failures.push(kind) }, + ) + // Download client + indexer both succeeded → still a full commit despite the profile failing. + const rows = repo.findByServerId('srv-a') + expect(rows).toHaveLength(1) + expect(rows[0]!.id).not.toBe(old.id) + expect(failures).toContain('quality profile') + }) }) diff --git a/apps/backend/src/lib/autoregister.ts b/apps/backend/src/lib/autoregister.ts index 28987f0..a0310ce 100644 --- a/apps/backend/src/lib/autoregister.ts +++ b/apps/backend/src/lib/autoregister.ts @@ -4,6 +4,7 @@ import type { ArrServerConnector } from './servers/arr/base' export interface ManagedRegistrationMeta { downloadClientId?: number categories?: number[] + profileId?: number } export interface ManagedRegistrationDeps { @@ -11,8 +12,8 @@ export interface ManagedRegistrationDeps { internalUrl: string downloads: boolean category: string - onSuccess: (kind: 'download client' | 'indexer', name: string, meta: ManagedRegistrationMeta) => void - onFailure: (kind: 'download client' | 'indexer', name: string, err: unknown) => void + onSuccess: (kind: 'download client' | 'indexer' | 'quality profile', name: string, meta: ManagedRegistrationMeta) => void + onFailure: (kind: 'download client' | 'indexer' | 'quality profile', name: string, err: unknown) => void } /** @@ -66,6 +67,17 @@ export async function registerManagedForDestination( deps.onFailure('indexer', dest.name, err) } + // Best-effort: register the Jack-only custom format + quality profile so *arr + // won't grab a catalog title from a non-Jack indexer. Independent of the + // download client/indexer; its failure must not gate the managed-key commit. + try { + const profileId = await dest.ensureJackQualityProfile() + deps.onSuccess('quality profile', dest.name, { profileId }) + } + catch (err) { + deps.onFailure('quality profile', dest.name, err) + } + // "Attempted" download = only when downloads is enabled. The indexer is always attempted. const allAttemptedOk = (downloads ? downloadDelivered : true) && indexerDelivered const anyDelivered = downloadDelivered || indexerDelivered diff --git a/apps/backend/src/lib/servers/arr/base.ts b/apps/backend/src/lib/servers/arr/base.ts index 4335381..0c986ca 100644 --- a/apps/backend/src/lib/servers/arr/base.ts +++ b/apps/backend/src/lib/servers/arr/base.ts @@ -33,6 +33,10 @@ export const DestinationServerHealthIssue = z.array( // the auto-registered indexer to it. const DownloadClientResource = z.object({ id: z.number().int() }) +// *arr returns the saved custom format / quality profile on create; we only need +// its id (to score the format and to force the profile on catalog downloads). +const CreatedResourceId = z.object({ id: z.number().int() }) + // Register the Jack client at *arr's lowest selectable priority (the UI caps it // at 50). *arr's general client pool only round-robins among the best-priority // group, so a worst-priority Jack client is never picked for real torrents from @@ -89,6 +93,9 @@ export abstract class ArrServerConnector extends ServerConnector { abstract get categories(): number[] // qBittorrent settings use a per-app category field name. protected abstract get qbCategoryFieldName(): string + // *arr's IndexerFlagSpecification value for the "Internal" flag differs per app + // (Sonarr = 8, Radarr = 32); a single hardcoded value would silently mismatch. + protected abstract get internalIndexerFlagValue(): number protected override async runInit(): Promise { const apiInfo = await this.ping(z.object({ appName: z.string(), version: z.string() })) @@ -178,13 +185,101 @@ export abstract class ArrServerConnector extends ServerConnector { return this.fetch('/api/v3/health', { schema: z.array(DestinationServerHealthIssue) }) } + /** + * Upsert a custom format named "Jack" that matches *arr's Internal indexer flag + * (which jack emits as `tag=internal` on its Torznab items). Returns the format id. + */ + @requiresDestination + @requiresInitialization + async ensureJackCustomFormat(): Promise { + const existingFormats = await this.arrGet('/api/v3/customformat') + const existing: any = Array.isArray(existingFormats) + ? existingFormats.find((cf: any) => cf.name === 'Jack') + : null + + const body = { + name: 'Jack', + includeCustomFormatWhenRenaming: false, + specifications: [{ + name: 'Internal', + implementation: 'IndexerFlagSpecification', + negate: false, + required: true, + fields: [{ name: 'value', value: this.internalIndexerFlagValue }], + }], + } + + if (existing) { + await this.fetch(`/api/v3/customformat/${existing.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...body, id: existing.id }), + } as any) + return existing.id as number + } + + const created = await this.fetch('/api/v3/customformat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + schema: CreatedResourceId, + } as any) + return created.id + } + + /** + * Ensure the Jack custom format exists, then upsert a quality profile named + * "Jack" that accepts every quality but only scores the Jack flag (minFormatScore + * 1), so *arr won't grab a matching release from a non-Jack indexer. Returns the + * profile id. + */ @requiresDestination @requiresInitialization - async getQualityProfiles(): Promise> { - const profiles = await this.arrGet>('/api/v3/qualityprofile') - return (Array.isArray(profiles) ? profiles : []) - .filter(p => p.id != null && p.name != null) - .map(p => ({ id: p.id, name: p.name })) + async ensureJackQualityProfile(): Promise { + const cfId = await this.ensureJackCustomFormat() + const schema = await this.arrGet('/api/v3/qualityprofile/schema') + + // Each item may itself nest an `items` array (quality groups); allow at every level. + const allowAll = (items: any[]): any[] => + items.map((item: any) => ({ + ...item, + allowed: true, + ...(Array.isArray(item.items) ? { items: allowAll(item.items) } : {}), + })) + + const profile = { + ...schema, + name: 'Jack', + // Grab the peer's file once and stop — no chasing "better" Jack releases. + upgradeAllowed: false, + items: allowAll(Array.isArray(schema?.items) ? schema.items : []), + minFormatScore: 1, + cutoffFormatScore: 1, + formatItems: (Array.isArray(schema?.formatItems) ? schema.formatItems : []).map((fi: any) => + (fi.name === 'Jack' || fi.format === cfId) ? { ...fi, score: 1 } : fi), + } + + const existingProfiles = await this.arrGet('/api/v3/qualityprofile') + const existing: any = Array.isArray(existingProfiles) + ? existingProfiles.find((p: any) => p.name === 'Jack') + : null + + if (existing) { + await this.fetch(`/api/v3/qualityprofile/${existing.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...profile, id: existing.id }), + } as any) + return existing.id as number + } + + const created = await this.fetch('/api/v3/qualityprofile', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(profile), + schema: CreatedResourceId, + } as any) + return created.id } @requiresDestination diff --git a/apps/backend/src/lib/servers/arr/radarr.ts b/apps/backend/src/lib/servers/arr/radarr.ts index f3ae703..b00feb4 100644 --- a/apps/backend/src/lib/servers/arr/radarr.ts +++ b/apps/backend/src/lib/servers/arr/radarr.ts @@ -26,6 +26,10 @@ export class RadarrServerConnector extends ArrServerConnector { return 'movieCategory' } + protected override get internalIndexerFlagValue(): number { + return 32 + } + private toRelease(movie: MovieResource): Release | null { const file = movie.movieFile if (!movie.id || !movie.hasFile || !file) diff --git a/apps/backend/src/lib/servers/arr/sonarr.ts b/apps/backend/src/lib/servers/arr/sonarr.ts index 91b2fdc..1014254 100644 --- a/apps/backend/src/lib/servers/arr/sonarr.ts +++ b/apps/backend/src/lib/servers/arr/sonarr.ts @@ -28,6 +28,10 @@ export class SonarrServerConnector extends ArrServerConnector { return 'tvCategory' } + protected override get internalIndexerFlagValue(): number { + return 8 + } + private buildRelease(episode: EpisodeResource, series: SeriesResource | undefined, file: EpisodeFileResource | undefined): Release | null { if (!episode.id || !episode.hasFile || !file) return null diff --git a/apps/backend/src/modules/catalog/catalog.controller.ts b/apps/backend/src/modules/catalog/catalog.controller.ts index b9aa5d3..da6795e 100644 --- a/apps/backend/src/modules/catalog/catalog.controller.ts +++ b/apps/backend/src/modules/catalog/catalog.controller.ts @@ -24,7 +24,6 @@ export interface RequestServerOption { name: string type: 'radarr' | 'sonarr' mediaType: 'movie' | 'tv' - qualityProfiles: Array<{ id: number, name: string }> rootFolders: Array<{ path: string, freeSpace?: number }> } @@ -33,7 +32,6 @@ export interface CatalogRequestInput { mediaType: 'movie' | 'tv' tmdbId?: number tvdbId?: number - qualityProfileId: number rootFolderPath: string } @@ -82,19 +80,18 @@ export class CatalogController { const destinations = this.connectors.servers.filter(s => s.canDestination && s.isInitialized) const options = await Promise.all(destinations.map(async (s) => { try { - const [qualityProfiles, rootFolders] = await Promise.all([s.getQualityProfiles(), s.getRootFolders()]) + const rootFolders = await s.getRootFolders() const type = s.type as 'radarr' | 'sonarr' return { id: s.id, name: s.name, type, mediaType: type === 'sonarr' ? 'tv' : 'movie', - qualityProfiles, rootFolders, } satisfies RequestServerOption } catch { - // A destination that can't list its profiles can't take a request — drop it. + // A destination that can't list its root folders can't take a request — drop it. return null } })) @@ -112,10 +109,13 @@ export class CatalogController { if (server.type !== expectedType) throw new BadRequestError(`Server "${server.name}" cannot handle ${input.mediaType} requests`) + // Force Jack's dedicated profile so *arr only grabs this release from the Jack + // indexer (the profile rejects releases without the Internal flag). + const qualityProfileId = await server.ensureJackQualityProfile() await server.addAndSearch({ tmdbId: input.tmdbId, tvdbId: input.tvdbId, - qualityProfileId: input.qualityProfileId, + qualityProfileId, rootFolderPath: input.rootFolderPath, }) return { ok: true, server: server.name } diff --git a/apps/backend/src/modules/catalog/catalog.router.ts b/apps/backend/src/modules/catalog/catalog.router.ts index c748b1f..88bd636 100644 --- a/apps/backend/src/modules/catalog/catalog.router.ts +++ b/apps/backend/src/modules/catalog/catalog.router.ts @@ -10,7 +10,6 @@ const requestBody = z.object({ mediaType: z.enum(['movie', 'tv']), tmdbId: z.number().int().optional(), tvdbId: z.number().int().optional(), - qualityProfileId: z.number().int(), rootFolderPath: z.string().min(1), }) diff --git a/apps/ui/app/components/DownloadRequestModal.vue b/apps/ui/app/components/DownloadRequestModal.vue index 5d9c7d4..ce4728a 100644 --- a/apps/ui/app/components/DownloadRequestModal.vue +++ b/apps/ui/app/components/DownloadRequestModal.vue @@ -18,7 +18,6 @@ const candidates = computed(() => // USelect's v-model does not accept null; undefined behaves identically under the // `!= null` / `== null` loose checks below, so we use undefined for "unset". const serverId = ref(undefined) -const qualityProfileId = ref(undefined) const rootFolderPath = ref(undefined) const server = computed(() => candidates.value.find(s => s.id === serverId.value) ?? null) @@ -29,16 +28,15 @@ watch(candidates, (list) => { }, { immediate: true }) watch(server, (s) => { - qualityProfileId.value = s?.qualityProfiles[0]?.id ?? undefined rootFolderPath.value = s?.rootFolders[0]?.path ?? undefined }, { immediate: true }) -const canSubmit = computed(() => Boolean(serverId.value && qualityProfileId.value != null && rootFolderPath.value)) +const canSubmit = computed(() => Boolean(serverId.value && rootFolderPath.value)) function onConfirm() { - if (!canSubmit.value || !serverId.value || qualityProfileId.value == null || !rootFolderPath.value) + if (!canSubmit.value || !serverId.value || !rootFolderPath.value) return - emit('confirm', { serverId: serverId.value, qualityProfileId: Number(qualityProfileId.value), rootFolderPath: rootFolderPath.value }) + emit('confirm', { serverId: serverId.value, rootFolderPath: rootFolderPath.value }) } @@ -63,14 +61,6 @@ function onConfirm() { /> - - - - +

+ Jack downloads use a dedicated profile so *arr only grabs this release from Jack. +

+
diff --git a/apps/ui/app/types/management.ts b/apps/ui/app/types/management.ts index c60415f..b6a4a27 100644 --- a/apps/ui/app/types/management.ts +++ b/apps/ui/app/types/management.ts @@ -78,13 +78,11 @@ export interface RequestServerOption { name: string type: 'radarr' | 'sonarr' mediaType: 'movie' | 'tv' - qualityProfiles: Array<{ id: number, name: string }> rootFolders: Array<{ path: string, freeSpace?: number }> } export interface CatalogRequestPayload { serverId: string - qualityProfileId: number rootFolderPath: string } From 6031c418d0f23db31ad4f90b6ff3aba17f8a0dfb Mon Sep 17 00:00:00 2001 From: Roz Date: Sat, 27 Jun 2026 10:05:05 +0200 Subject: [PATCH 08/27] fix(torznab): embed the requester's key in download URLs The feed embedded the deprecated main key in each release's download URL, so grabs 401'd when *arr authenticated the indexer with a managed key (the main key is often unset). Thread the requesting key (apikey query / x-api-key header) through to each download URL so the grab passes auth. --- .../backend/src/__tests__/integration.test.ts | 30 ++++++++++++++ apps/backend/src/__tests__/torznab.test.ts | 40 ++++++++++++++++++- .../src/modules/torznab/torznab.controller.ts | 21 +++++----- .../src/modules/torznab/torznab.router.ts | 14 +++++-- 4 files changed, 91 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/__tests__/integration.test.ts b/apps/backend/src/__tests__/integration.test.ts index 2f1aee3..ff62e2a 100644 --- a/apps/backend/src/__tests__/integration.test.ts +++ b/apps/backend/src/__tests__/integration.test.ts @@ -14,6 +14,8 @@ import { RadarrServerConnector } from '../lib/servers/arr/radarr' import { SonarrServerConnector } from '../lib/servers/arr/sonarr' import { PeerConnector } from '../lib/servers/peer' import { DownloadsRepository } from '../modules/downloads/downloads.repository' +import { ManagedKeysRepository } from '../modules/managed-keys/managed-keys.repository' +import { ManagedApiKeys } from '../modules/managed-keys/managed-keys.service' const RADARR_URL = 'http://radarr.test:7878' const SONARR_URL = 'http://sonarr.test:8989' @@ -307,6 +309,34 @@ describe('Torrent download', () => { const res = await app.request(`/torznab/download/${encodeURIComponent(guid)}.torrent`) expect(res.status).toBe(401) }) + + // Production scenario: the main key is unset and Radarr authenticates the indexer + // with a managed key. The feed must embed THAT managed key in each download URL, + // and grabbing the .torrent with it must succeed (regression for the 401-on-grab). + test('feed embeds the requester managed key, and the grab round-trips', async () => { + const database = new Database(':memory:') + testDatabases.push(database) + database.exec('pragma foreign_keys = ON') + const db = drizzle({ client: database, schema }) + runMigrations(db) + const downloadsRepository = new DownloadsRepository(db) + const managedKeysRepository = new ManagedKeysRepository(db) + + const radarr = markInitialized(makeRadarr()) + const peer = markInitialized(new PeerConnector({ url: PEER_JACK_URL, apiKey: 'peer-api-key', name: 'Friend Jack' })) + const { key: managedKey } = new ManagedApiKeys(managedKeysRepository).provision(radarr.id) + + const app = getApp(envs, config, { servers: [radarr], peers: [peer] }, { downloadsRepository, managedKeysRepository }) + + const feed = await (await app.request(`/torznab/api?t=movie&tmdbid=603&apikey=${managedKey}`)).text() + expect(feed).toContain(peerRelease.title) + expect(feed).toContain(`apikey=${managedKey}`) + expect(feed).not.toContain('apikey=test-api-key') + + const guid = `${peer.id}:${peerRelease.id}` + const grab = await app.request(`/torznab/download/${encodeURIComponent(guid)}.torrent?apikey=${managedKey}`) + expect(grab.status).toBe(200) + }) }) describe('Downloads API', () => { diff --git a/apps/backend/src/__tests__/torznab.test.ts b/apps/backend/src/__tests__/torznab.test.ts index 4aabaa0..33ec893 100644 --- a/apps/backend/src/__tests__/torznab.test.ts +++ b/apps/backend/src/__tests__/torznab.test.ts @@ -1,6 +1,7 @@ import type { Release } from '../lib/release' +import type { PeerConnector } from '../lib/servers/peer' import { describe, expect, test } from 'bun:test' -import { releaseToTorznab } from '../modules/torznab/torznab.controller' +import { releaseToTorznab, TorznabController } from '../modules/torznab/torznab.controller' import { buildErrorXml, buildSearchResultXml } from '../modules/torznab/torznab.router' const movieRelease: Release = { @@ -97,6 +98,43 @@ describe('Torznab XML helpers', () => { }) }) +describe('TorznabController embeds the requester key in download URLs', () => { + // The main key is deprecated/often unset; the requester (Radarr) authenticates + // the indexer with a managed key. Each download URL must carry THAT key so the + // grab passes auth — not the main key, which would 401 when unset. + const jackConfig = { internalUrl: 'http://localhost:3000', apiKey: 'main-key' } as any + + function fakePeer(releases: Release[]): PeerConnector { + return { + id: 'peer1', + name: 'Friend', + searchByTmdbId: async () => releases, + searchByImdbId: async () => releases, + searchByTvdbId: async () => releases, + listReleases: async () => releases, + } as unknown as PeerConnector + } + + test('searchMovie embeds the passed request key, not the main key', async () => { + const controller = new TorznabController(() => [fakePeer([movieRelease])], jackConfig) + const items = await controller.searchMovie({ tmdbId: '12345' }, 'jack_managed_abc') + expect(items).toHaveLength(1) + expect(new URL(items[0]!.downloadUrl).searchParams.get('apikey')).toBe('jack_managed_abc') + }) + + test('searchTv embeds the passed request key', async () => { + const controller = new TorznabController(() => [fakePeer([episodeRelease])], jackConfig) + const items = await controller.searchTv('654321', 1, 2, 'jack_managed_tv') + expect(new URL(items[0]!.downloadUrl).searchParams.get('apikey')).toBe('jack_managed_tv') + }) + + test('catalog embeds the passed request key', async () => { + const controller = new TorznabController(() => [fakePeer([movieRelease])], jackConfig) + const items = await controller.catalog('jack_managed_xyz') + expect(new URL(items[0]!.downloadUrl).searchParams.get('apikey')).toBe('jack_managed_xyz') + }) +}) + describe('releaseToTorznab', () => { test('maps a movie release', () => { const result = releaseToTorznab(movieRelease, 'peer1', 'Friend', 'http://localhost:3000', JACK_API_KEY) diff --git a/apps/backend/src/modules/torznab/torznab.controller.ts b/apps/backend/src/modules/torznab/torznab.controller.ts index 6175bda..cdd0efb 100644 --- a/apps/backend/src/modules/torznab/torznab.controller.ts +++ b/apps/backend/src/modules/torznab/torznab.controller.ts @@ -54,7 +54,7 @@ export class TorznabController { private readonly jackConfig: NonNullable, ) {} - private async fanOut(label: string, search: (peer: PeerConnector) => Promise): Promise { + private async fanOut(label: string, search: (peer: PeerConnector) => Promise, apiKey: string): Promise { // We fan out to ALL peers — no isInitialized pre-filter. A peer that failed // to connect at boot gets re-initialized lazily by @requireInitialization on // the call below, so a peer that came back online rejoins searches without a @@ -80,7 +80,10 @@ export class TorznabController { }, async (peerSpan) => { const releases = await search(peer) setSpanAttribute(peerSpan, 'release.count', releases.length) - return releases.map(release => releaseToTorznab(release, peer.id, peer.name, this.jackConfig.internalUrl, this.jackConfig.apiKey ?? '')) + // Embed the SAME key the requester authenticated with (passed down + // from the request), so the grab of this release's .torrent passes + // auth — the deprecated main key (jackConfig.apiKey) is often unset. + return releases.map(release => releaseToTorznab(release, peer.id, peer.name, this.jackConfig.internalUrl, apiKey)) }) } catch (err) { @@ -96,23 +99,23 @@ export class TorznabController { }) } - async searchMovie(ids: { tmdbId?: string, imdbId?: string }): Promise { + async searchMovie(ids: { tmdbId?: string, imdbId?: string }, apiKey: string): Promise { const { tmdbId, imdbId } = ids // Prefer tmdbid: Radarr filters by it server-side (a targeted lookup), and it // doesn't depend on the tt-prefix quirk. imdbid is the fallback. if (tmdbId) - return this.fanOut(`tmdb:${tmdbId}`, peer => peer.searchByTmdbId(tmdbId)) + return this.fanOut(`tmdb:${tmdbId}`, peer => peer.searchByTmdbId(tmdbId), apiKey) if (imdbId) - return this.fanOut(`imdb:${imdbId}`, peer => peer.searchByImdbId(imdbId)) + return this.fanOut(`imdb:${imdbId}`, peer => peer.searchByImdbId(imdbId), apiKey) return [] } - async searchTv(tvdbId: string, season?: number, episode?: number): Promise { - return this.fanOut(`tvdb:${tvdbId} s:${season ?? '-'} e:${episode ?? '-'}`, peer => peer.searchByTvdbId(tvdbId, season, episode)) + async searchTv(tvdbId: string, season: number | undefined, episode: number | undefined, apiKey: string): Promise { + return this.fanOut(`tvdb:${tvdbId} s:${season ?? '-'} e:${episode ?? '-'}`, peer => peer.searchByTvdbId(tvdbId, season, episode), apiKey) } /** Full catalog of every peer's releases — backs the torznab RSS/test query. */ - async catalog(): Promise { - return this.fanOut('catalog', peer => peer.listReleases()) + async catalog(apiKey: string): Promise { + return this.fanOut('catalog', peer => peer.listReleases(), apiKey) } } diff --git a/apps/backend/src/modules/torznab/torznab.router.ts b/apps/backend/src/modules/torznab/torznab.router.ts index edd5271..fbb62b3 100644 --- a/apps/backend/src/modules/torznab/torznab.router.ts +++ b/apps/backend/src/modules/torznab/torznab.router.ts @@ -133,6 +133,11 @@ export function getTorznabRouter(controller: TorznabController) { return xml(c, body, 400) } + // The key the requester (Radarr/Sonarr) authenticated with — extracted the + // same way requireApiKey does. Embedded into each release's download URL so + // the subsequent grab passes auth (managed indexer keys, not the main key). + const apiKey = c.req.query('apikey') ?? c.req.header('x-api-key') ?? '' + switch (t) { case 'caps': { return xml(c, CAPS_XML) @@ -144,7 +149,7 @@ export function getTorznabRouter(controller: TorznabController) { // self-test, which *arr requires to return results). case 'search': { const q = c.req.query('q')?.trim() - const items = q ? [] : await controller.catalog() + const items = q ? [] : await controller.catalog(apiKey) const body = buildSearchResultXml(filterByCategory(items, c.req.query('cat'))) return xml(c, body) } @@ -155,9 +160,9 @@ export function getTorznabRouter(controller: TorznabController) { const q = c.req.query('q')?.trim() let items: TorznabItem[] if (tmdbId || imdbId) - items = await controller.searchMovie({ tmdbId, imdbId }) + items = await controller.searchMovie({ tmdbId, imdbId }, apiKey) else - items = q ? [] : await controller.catalog() + items = q ? [] : await controller.catalog(apiKey) const body = buildSearchResultXml(filterByCategory(items, c.req.query('cat'))) return xml(c, body) } @@ -173,10 +178,11 @@ export function getTorznabRouter(controller: TorznabController) { tvdbId, season ? Number(season) : undefined, ep ? Number(ep) : undefined, + apiKey, ) } else { - items = q ? [] : await controller.catalog() + items = q ? [] : await controller.catalog(apiKey) } const body = buildSearchResultXml(filterByCategory(items, c.req.query('cat'))) return xml(c, body) From 8b37bcdd053b6b951b5769f2f4ff2402b400a8d2 Mon Sep 17 00:00:00 2001 From: Roz Date: Sat, 27 Jun 2026 10:36:32 +0200 Subject: [PATCH 09/27] perf(catalog): enrich peer titles lazily from the client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog endpoint ran every TMDB lookup server-side, 8 at a time, before returning — freezing the page on large peers. Return titles unenriched and add a per-title GET /catalog/tmdb/:mediaType/:tmdbId route the grid calls once per visible card, so metadata fills in progressively. Paginate (48/page) to bound how many lookups fire at once. --- apps/backend/src/__tests__/catalog.test.ts | 137 ++++++------------ .../src/modules/catalog/catalog.controller.ts | 31 ++-- .../src/modules/catalog/catalog.lib.ts | 19 --- .../src/modules/catalog/catalog.router.ts | 11 ++ apps/ui/app/components/CatalogPosterCard.vue | 19 ++- apps/ui/app/components/CatalogTitleDetail.vue | 8 +- apps/ui/app/composables/useCatalogMetadata.ts | 47 ++++++ apps/ui/app/pages/catalog/[peerId].vue | 41 ++++-- 8 files changed, 160 insertions(+), 153 deletions(-) create mode 100644 apps/ui/app/composables/useCatalogMetadata.ts diff --git a/apps/backend/src/__tests__/catalog.test.ts b/apps/backend/src/__tests__/catalog.test.ts index 5f9d7d0..00c6bfe 100644 --- a/apps/backend/src/__tests__/catalog.test.ts +++ b/apps/backend/src/__tests__/catalog.test.ts @@ -3,7 +3,7 @@ import { describe, expect, mock, test } from 'bun:test' import { BadRequestError } from '../lib/errors/BadRequestError' import { NotFoundError } from '../lib/errors/NotFoundError' import { CatalogController } from '../modules/catalog/catalog.controller' -import { groupReleasesIntoTitles, mapLimit } from '../modules/catalog/catalog.lib' +import { groupReleasesIntoTitles } from '../modules/catalog/catalog.lib' function movie(overrides: Partial = {}): Release { return { @@ -122,121 +122,66 @@ describe('catalogController', () => { }) }) -describe('mapLimit', () => { - test('preserves input order regardless of completion order', async () => { - const result = await mapLimit([3, 1, 2], 2, async (n) => { - await new Promise(resolve => setTimeout(resolve, n)) - return n * 10 - }) - - expect(result).toEqual([30, 10, 20]) - }) - - test('runs exactly `limit` tasks in flight, never more', async () => { - let inFlight = 0 - let peak = 0 - - await mapLimit([1, 2, 3, 4, 5, 6], 2, async () => { - inFlight += 1 - peak = Math.max(peak, inFlight) - await new Promise(resolve => setTimeout(resolve, 5)) - inFlight -= 1 - return null - }) - - // Saturates the cap (catches a collapse-to-one-worker regression) without exceeding it. - expect(peak).toBe(2) - }) - - test('returns an empty array for empty input', async () => { - expect(await mapLimit([], 4, async () => 1)).toEqual([]) - }) -}) - -describe('catalogController enrichment', () => { +describe('catalogController.getPeerCatalog metadata', () => { function makeConnectors(peers: any[]) { return { servers: [], peers } } - const breakingBad = { - tmdbId: 1396, - title: 'Breaking Bad', - overview: 'A chemistry teacher cooks meth.', - year: 2008, - rating: 8.9, - posterUrl: 'https://image.tmdb.org/t/p/w500/poster.jpg', - backdropUrl: 'https://image.tmdb.org/t/p/w780/backdrop.jpg', - genres: ['Drama'], - } - - test('attaches metadata to titles with a tmdbId and leaves id-less titles untouched', async () => { - const calls: Array<[string, number]> = [] - const tmdb = { - getMetadata: async (mediaType: string, tmdbId: number) => { - calls.push([mediaType, tmdbId]) - return tmdbId === 603 ? { ...breakingBad, tmdbId: 603, title: 'The Matrix' } : null - }, - } + test('returns titles unenriched and makes no TMDB calls', async () => { + const getMetadata = mock(async () => ({ title: 'Should not be called' })) const peer = { id: 'peer-1', name: 'Friend Jack', - listReleases: async () => [ - movie({ tmdbId: 603, title: 'The.Matrix.1999' }), - movie({ title: 'No.Id.2024' }), - ], + listReleases: async () => [movie({ tmdbId: 603 })], } - const controller = new CatalogController(makeConnectors([peer]) as any, tmdb as any) + const controller = new CatalogController(makeConnectors([peer]) as any, { getMetadata } as any) const result = await controller.getPeerCatalog('peer-1') - const enriched = result.titles.find(t => t.tmdbId === 603) - expect(enriched!.metadata).toMatchObject({ title: 'The Matrix' }) - - const idless = result.titles.find(t => t.tmdbId == null) - expect(idless!.metadata).toBeUndefined() - - // No lookup is attempted for the id-less title. - expect(calls).toEqual([['movie', 603]]) + expect(result.titles[0]!.metadata).toBeUndefined() + expect(getMetadata).not.toHaveBeenCalled() }) +}) - test('keeps the catalog intact when a getMetadata lookup rejects', async () => { - const tmdb = { - getMetadata: async (_mediaType: string, tmdbId: number) => { - if (tmdbId === 603) - throw new Error('TMDB exploded') - return { ...breakingBad } - }, - } - const peer = { - id: 'peer-1', - name: 'Friend Jack', - listReleases: async () => [ - movie({ tmdbId: 603, title: 'The.Matrix.1999' }), - episode({ tvdbId: 1, tmdbId: 1396, seriesTitle: 'Breaking Bad' }), - ], - } - const controller = new CatalogController(makeConnectors([peer]) as any, tmdb as any) +describe('catalogController.getTitleMetadata', () => { + function makeConnectors() { + return { servers: [], peers: [] } + } - const result = await controller.getPeerCatalog('peer-1') + const matrix = { + tmdbId: 603, + title: 'The Matrix', + overview: 'A hacker learns the truth.', + year: 1999, + rating: 8.2, + posterUrl: 'https://image.tmdb.org/t/p/w500/poster.jpg', + backdropUrl: 'https://image.tmdb.org/t/p/w780/backdrop.jpg', + genres: ['Action'], + } + + test('delegates to the tmdb client and returns its metadata', async () => { + const getMetadata = mock(async () => matrix) + const controller = new CatalogController(makeConnectors() as any, { getMetadata } as any) - const failed = result.titles.find(t => t.tmdbId === 603) - expect(failed!.metadata).toBeUndefined() + const result = await controller.getTitleMetadata('movie', 603) - const ok = result.titles.find(t => t.mediaType === 'tv') - expect(ok!.metadata).toMatchObject({ title: 'Breaking Bad' }) + expect(result).toMatchObject({ title: 'The Matrix' }) + expect(getMetadata).toHaveBeenCalledWith('movie', 603) }) - test('makes no TMDB calls and attaches no metadata when no client is configured', async () => { - const peer = { - id: 'peer-1', - name: 'Friend Jack', - listReleases: async () => [movie({ tmdbId: 603 })], - } - const controller = new CatalogController(makeConnectors([peer]) as any) + test('returns null when no tmdb client is configured', async () => { + const controller = new CatalogController(makeConnectors() as any) - const result = await controller.getPeerCatalog('peer-1') + expect(await controller.getTitleMetadata('movie', 603)).toBeNull() + }) - expect(result.titles[0]!.metadata).toBeUndefined() + test('propagates a lookup rejection to the caller', () => { + const getMetadata = mock(async () => { + throw new Error('TMDB exploded') + }) + const controller = new CatalogController(makeConnectors() as any, { getMetadata } as any) + + expect(controller.getTitleMetadata('movie', 603)).rejects.toThrow('TMDB exploded') }) }) diff --git a/apps/backend/src/modules/catalog/catalog.controller.ts b/apps/backend/src/modules/catalog/catalog.controller.ts index da6795e..f6eebc9 100644 --- a/apps/backend/src/modules/catalog/catalog.controller.ts +++ b/apps/backend/src/modules/catalog/catalog.controller.ts @@ -1,12 +1,10 @@ import type { ArrServerConnector } from '../../lib/servers/arr/base' import type { PeerConnector } from '../../lib/servers/peer' -import type { TmdbClient } from '../../lib/tmdb/client' +import type { TmdbClient, TmdbMediaType, TmdbMetadata } from '../../lib/tmdb/client' import type { CatalogTitle } from './catalog.lib' import { BadRequestError } from '../../lib/errors/BadRequestError' import { NotFoundError } from '../../lib/errors/NotFoundError' -import { groupReleasesIntoTitles, mapLimit } from './catalog.lib' - -const TMDB_ENRICH_CONCURRENCY = 8 +import { groupReleasesIntoTitles } from './catalog.lib' export interface PeerCatalogResponse { peer: { id: string, name: string } @@ -51,29 +49,20 @@ export class CatalogController { async getPeerCatalog(peerId: string): Promise { const peer = this.requirePeer(peerId) const releases = await peer.listReleases() - const titles = await this.enrichTitles(groupReleasesIntoTitles(releases)) + // Return titles immediately, unenriched. TMDB lookups are driven per-title by + // the client (see getTitleMetadata) so the catalog renders without waiting on + // hundreds of upstream round-trips. return { peer: { id: peer.id, name: peer.name }, - titles, + titles: groupReleasesIntoTitles(releases), } } - private async enrichTitles(titles: CatalogTitle[]): Promise { + /** TMDB metadata for a single title; null when TMDB is unconfigured or the id is unknown. */ + async getTitleMetadata(mediaType: TmdbMediaType, tmdbId: number): Promise { if (!this.tmdb) - return titles - const tmdb = this.tmdb - return mapLimit(titles, TMDB_ENRICH_CONCURRENCY, async (title) => { - if (!title.tmdbId) - return title - try { - const metadata = await tmdb.getMetadata(title.mediaType, title.tmdbId) - return { ...title, metadata } - } - catch { - // Enrichment is best-effort: a failed lookup must not blank the catalog. - return title - } - }) + return null + return this.tmdb.getMetadata(mediaType, tmdbId) } async getRequestOptions(): Promise { diff --git a/apps/backend/src/modules/catalog/catalog.lib.ts b/apps/backend/src/modules/catalog/catalog.lib.ts index 49f02cc..e4c8fde 100644 --- a/apps/backend/src/modules/catalog/catalog.lib.ts +++ b/apps/backend/src/modules/catalog/catalog.lib.ts @@ -16,25 +16,6 @@ export interface CatalogTitle { metadata?: TmdbMetadata | null } -/** Run `fn` over `items` with at most `limit` in flight; preserves order. */ -export async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { - const results = Array.from({ length: items.length }) as R[] - let cursor = 0 - async function worker(): Promise { - while (cursor < items.length) { - const index = cursor++ - results[index] = await fn(items[index]!) - } - } - // Spawn each worker via its own call so they run concurrently; `.fill(worker())` - // would share one promise and silently collapse the pool to a single worker. - const workers: Array> = [] - for (let i = 0; i < Math.min(limit, items.length); i++) - workers.push(worker()) - await Promise.all(workers) - return results -} - function mediaTypeOf(release: Release): 'movie' | 'tv' { return release.category === ReleaseCategory.Tv ? 'tv' : 'movie' } diff --git a/apps/backend/src/modules/catalog/catalog.router.ts b/apps/backend/src/modules/catalog/catalog.router.ts index 88bd636..fd9b69c 100644 --- a/apps/backend/src/modules/catalog/catalog.router.ts +++ b/apps/backend/src/modules/catalog/catalog.router.ts @@ -5,6 +5,11 @@ import { z } from 'zod' const peerParam = z.object({ peerId: z.string().min(1) }) +const tmdbParam = z.object({ + mediaType: z.enum(['movie', 'tv']), + tmdbId: z.coerce.number().int(), +}) + const requestBody = z.object({ serverId: z.string().min(1), mediaType: z.enum(['movie', 'tv']), @@ -19,6 +24,12 @@ export function getCatalogRouter(controller: CatalogController) { // Register the static path before `/:peerId` so "tmdb" isn't captured as a peerId. app.get('/tmdb/status', async c => c.json(await controller.getTmdbStatus())) + // Per-title TMDB lookup the catalog grid calls once per visible card. + app.get('/tmdb/:mediaType/:tmdbId', zValidator('param', tmdbParam), async (c) => { + const { mediaType, tmdbId } = c.req.valid('param') + return c.json(await controller.getTitleMetadata(mediaType, tmdbId)) + }) + // Register before `/:peerId` so "request-options" isn't captured as a peerId. app.get('/request-options', async c => c.json({ servers: await controller.getRequestOptions() })) diff --git a/apps/ui/app/components/CatalogPosterCard.vue b/apps/ui/app/components/CatalogPosterCard.vue index 8a6076e..96b3424 100644 --- a/apps/ui/app/components/CatalogPosterCard.vue +++ b/apps/ui/app/components/CatalogPosterCard.vue @@ -4,10 +4,19 @@ import type { CatalogTitle } from '~/types/management' const props = defineProps<{ title: CatalogTitle }>() defineEmits<{ select: [] }>() -const name = computed(() => props.title.metadata?.title ?? props.title.displayTitle) -const poster = computed(() => props.title.metadata?.posterUrl ?? null) -const year = computed(() => props.title.metadata?.year ?? null) -const rating = computed(() => props.title.metadata?.rating ?? null) +const { load, entryFor } = useCatalogMetadata() +const entry = computed(() => entryFor(props.title)) +const metadata = computed(() => entry.value?.data ?? props.title.metadata ?? null) +// Loading until the lookup settles; titles with no tmdbId never load, so they're +// not "loading" — they just stay on the placeholder. +const loading = computed(() => props.title.tmdbId != null && (entry.value === null || entry.value.status === 'loading')) + +const name = computed(() => metadata.value?.title ?? props.title.displayTitle) +const poster = computed(() => metadata.value?.posterUrl ?? null) +const year = computed(() => metadata.value?.year ?? null) +const rating = computed(() => metadata.value?.rating ?? null) + +onMounted(() => load(props.title)) diff --git a/apps/ui/app/pages/catalog/[peerId].vue b/apps/ui/app/pages/catalog/[peerId].vue index 415354d..d301a98 100644 --- a/apps/ui/app/pages/catalog/[peerId].vue +++ b/apps/ui/app/pages/catalog/[peerId].vue @@ -58,6 +58,7 @@ async function onConfirm(payload: CatalogRequestPayload) { method: 'POST', body: { ...payload, + peerId: peerId.value, mediaType: title.mediaType, tmdbId: title.tmdbId, tvdbId: title.tvdbId, @@ -67,7 +68,7 @@ async function onConfirm(payload: CatalogRequestPayload) { selected.value = null toast.add({ title: 'Added to your library', - description: `"${titleName(title)}" is being searched by your *arr.`, + description: `"${titleName(title)}" is downloading from ${peerName.value}.`, color: 'success', icon: 'i-ph-check-circle', }) From 35e0aa0dde73333332f2abf4f2cb126c97dd6005 Mon Sep 17 00:00:00 2001 From: Roz Date: Sat, 27 Jun 2026 14:26:55 +0200 Subject: [PATCH 11/27] feat(catalog): direct-download whole TV series per episode Add the series branch to requestDownload: add the series without search, pick the best release per episode (pickBestPerEpisode), and start a direct download per episode tagged import_target {kind:'series',seriesId}; the ImportWatcher pushes ManualImport with the candidate episodeIds. Adds a TV-only 'downloads every episode' hint in the catalog detail UI. --- apps/backend/src/__tests__/catalog.test.ts | 59 ++++++++++++++++++- .../src/modules/catalog/catalog.controller.ts | 57 ++++++++++++------ apps/ui/app/components/CatalogTitleDetail.vue | 3 + 3 files changed, 97 insertions(+), 22 deletions(-) diff --git a/apps/backend/src/__tests__/catalog.test.ts b/apps/backend/src/__tests__/catalog.test.ts index db21da1..ccd7271 100644 --- a/apps/backend/src/__tests__/catalog.test.ts +++ b/apps/backend/src/__tests__/catalog.test.ts @@ -321,11 +321,15 @@ describe('catalogController.requestDownload', () => { } } - function fakePeer(overrides: Partial<{ searchByTmdbId: (tmdbId: string) => Promise }> = {}) { + function fakePeer(overrides: Partial<{ + searchByTmdbId: (tmdbId: string) => Promise + searchByTvdbId: (tvdbId: string) => Promise + }> = {}) { return { id: 'peer-1', name: 'Friend Jack', searchByTmdbId: overrides.searchByTmdbId ?? mock(async () => [movie({ id: 'rel:1', tmdbId: 603, quality: { resolution: 1080 } })]), + searchByTvdbId: overrides.searchByTvdbId ?? mock(async () => [episode({ id: 'ep:1', tvdbId: 81189, season: 1, episode: 1, quality: { resolution: 1080 } })]), } } @@ -390,7 +394,7 @@ describe('catalogController.requestDownload', () => { })).rejects.toBeInstanceOf(BadRequestError) }) - test('throws BadRequestError for a tv request (not supported until Phase 2)', () => { + test('throws BadRequestError when a tv request has no tvdbId', () => { const sonarr = fakeServer({ id: 'sonarr-1', name: 'My Sonarr', type: 'sonarr' }) const controller = new CatalogController(makeConnectors([sonarr], [fakePeer()]) as any, undefined, fakeDownloads() as any) @@ -398,7 +402,6 @@ describe('catalogController.requestDownload', () => { peerId: 'peer-1', serverId: 'sonarr-1', mediaType: 'tv', - tvdbId: 81189, rootFolderPath: '/tv', })).rejects.toBeInstanceOf(BadRequestError) }) @@ -443,6 +446,56 @@ describe('catalogController.requestDownload', () => { importTarget: { kind: 'movie', movieId: 123 }, }) }) + + test('throws NotFoundError when the peer has no episodes for the tvdbId, and does not add', async () => { + const sonarr = fakeServer({ id: 'sonarr-1', name: 'My Sonarr', type: 'sonarr', add: mock(async () => 55) }) + const peer = fakePeer({ searchByTvdbId: mock(async () => []) }) + const controller = new CatalogController(makeConnectors([sonarr], [peer]) as any, undefined, fakeDownloads() as any) + + await expect(controller.requestDownload({ + peerId: 'peer-1', + serverId: 'sonarr-1', + mediaType: 'tv', + tvdbId: 81189, + rootFolderPath: '/tv', + })).rejects.toBeInstanceOf(NotFoundError) + expect(sonarr.add).not.toHaveBeenCalled() + }) + + test('adds the series without search and starts a direct download for the best release per episode', async () => { + const sonarr = fakeServer({ id: 'sonarr-1', name: 'My Sonarr', type: 'sonarr', add: mock(async () => 55) }) + const ep1a = episode({ id: 'ep:1a', tvdbId: 81189, season: 1, episode: 1, quality: { resolution: 720 }, size: 50 }) + const ep1b = episode({ id: 'ep:1b', tvdbId: 81189, season: 1, episode: 1, quality: { resolution: 1080 }, size: 60 }) + const ep2 = episode({ id: 'ep:2', tvdbId: 81189, season: 1, episode: 2, quality: { resolution: 720 }, size: 40 }) + const peer = fakePeer({ searchByTvdbId: mock(async () => [ep1a, ep1b, ep2]) }) + const downloads = fakeDownloads() + const controller = new CatalogController(makeConnectors([sonarr], [peer]) as any, undefined, downloads as any) + + const result = await controller.requestDownload({ + peerId: 'peer-1', + serverId: 'sonarr-1', + mediaType: 'tv', + tvdbId: 81189, + rootFolderPath: '/tv', + }) + + expect(result).toEqual({ ok: true, server: 'My Sonarr', started: 2 }) + expect(sonarr.add).toHaveBeenCalledTimes(1) + expect(sonarr.add).toHaveBeenCalledWith({ tvdbId: 81189, rootFolderPath: '/tv' }) + expect(downloads.startDirectDownload).toHaveBeenCalledTimes(2) + expect(downloads.startDirectDownload).toHaveBeenCalledWith({ + peerId: 'peer-1', + itemId: 'ep:1b', + destinationServerName: 'My Sonarr', + importTarget: { kind: 'series', seriesId: 55 }, + }) + expect(downloads.startDirectDownload).toHaveBeenCalledWith({ + peerId: 'peer-1', + itemId: 'ep:2', + destinationServerName: 'My Sonarr', + importTarget: { kind: 'series', seriesId: 55 }, + }) + }) }) describe('catalogController.getTmdbStatus', () => { diff --git a/apps/backend/src/modules/catalog/catalog.controller.ts b/apps/backend/src/modules/catalog/catalog.controller.ts index 18eb4fa..9ec5ab1 100644 --- a/apps/backend/src/modules/catalog/catalog.controller.ts +++ b/apps/backend/src/modules/catalog/catalog.controller.ts @@ -5,7 +5,7 @@ import type { DownloadsService } from '../downloads/downloads.service' import type { CatalogTitle } from './catalog.lib' import { BadRequestError } from '../../lib/errors/BadRequestError' import { NotFoundError } from '../../lib/errors/NotFoundError' -import { groupReleasesIntoTitles, pickBestRelease } from './catalog.lib' +import { groupReleasesIntoTitles, pickBestPerEpisode, pickBestRelease } from './catalog.lib' export interface PeerCatalogResponse { peer: { id: string, name: string } @@ -106,24 +106,43 @@ export class CatalogController { const peer = this.requirePeer(input.peerId) - if (input.mediaType !== 'movie') - throw new BadRequestError('TV requests are not supported yet') // implemented in Phase 2 - - if (input.tmdbId == null) - throw new BadRequestError('A tmdbId is required for a movie request') - const releases = await peer.searchByTmdbId(String(input.tmdbId)) - const best = pickBestRelease(releases) - if (!best) - throw new NotFoundError(`Peer "${peer.name}" has no release for tmdbId ${input.tmdbId}`) - - const movieId = await server.add({ tmdbId: input.tmdbId, rootFolderPath: input.rootFolderPath }) - await this.downloads.startDirectDownload({ - peerId: peer.id, - itemId: best.id, - destinationServerName: server.name, - importTarget: { kind: 'movie', movieId }, - }) - return { ok: true, server: server.name, started: 1 } + if (input.mediaType === 'movie') { + if (input.tmdbId == null) + throw new BadRequestError('A tmdbId is required for a movie request') + const releases = await peer.searchByTmdbId(String(input.tmdbId)) + const best = pickBestRelease(releases) + if (!best) + throw new NotFoundError(`Peer "${peer.name}" has no release for tmdbId ${input.tmdbId}`) + + const movieId = await server.add({ tmdbId: input.tmdbId, rootFolderPath: input.rootFolderPath }) + await this.downloads.startDirectDownload({ + peerId: peer.id, + itemId: best.id, + destinationServerName: server.name, + importTarget: { kind: 'movie', movieId }, + }) + return { ok: true, server: server.name, started: 1 } + } + + // --- series (tv): one direct download per best-per-episode release, all bound + // to the same series so the watcher imports each file into the right show. --- + if (input.tvdbId == null) + throw new BadRequestError('A tvdbId is required for a series request') + const episodeReleases = await peer.searchByTvdbId(String(input.tvdbId)) + const best = pickBestPerEpisode(episodeReleases) + if (best.length === 0) + throw new NotFoundError(`Peer "${peer.name}" has no episodes for tvdbId ${input.tvdbId}`) + + const seriesId = await server.add({ tvdbId: input.tvdbId, rootFolderPath: input.rootFolderPath }) + for (const release of best) { + await this.downloads.startDirectDownload({ + peerId: peer.id, + itemId: release.id, + destinationServerName: server.name, + importTarget: { kind: 'series', seriesId }, + }) + } + return { ok: true, server: server.name, started: best.length } } async getTmdbStatus(): Promise { diff --git a/apps/ui/app/components/CatalogTitleDetail.vue b/apps/ui/app/components/CatalogTitleDetail.vue index bec75aa..e768913 100644 --- a/apps/ui/app/components/CatalogTitleDetail.vue +++ b/apps/ui/app/components/CatalogTitleDetail.vue @@ -46,6 +46,9 @@ const canRequest = computed(() => props.title.mediaType === 'tv'

{{ title.releaseCount }} release{{ title.releaseCount === 1 ? '' : 's' }} on this peer · {{ formatBytes(title.totalSize) }}

+

+ Downloads every available episode from this peer. +

Date: Sat, 27 Jun 2026 14:33:58 +0200 Subject: [PATCH 12/27] refactor(arr): drop the now-dead Jack custom format and quality profile requestDownload no longer forces a Jack-only quality profile (it uses direct download + ManualImport), so ensureJackCustomFormat / ensureJackQualityProfile, their autoregister provisioning branch, the CreatedResourceId schema, the ManagedRegistrationMeta.profileId field, and the corresponding dead tests are removed. The torznab Internal-flag tagging (tag=internal) and the indexer/download-client registration are untouched. --- .../backend/src/__tests__/integration.test.ts | 143 ------------------ apps/backend/src/index.ts | 4 - apps/backend/src/lib/autoregister.test.ts | 30 +--- apps/backend/src/lib/autoregister.ts | 16 +- apps/backend/src/lib/servers/arr/base.ts | 101 ------------- 5 files changed, 4 insertions(+), 290 deletions(-) diff --git a/apps/backend/src/__tests__/integration.test.ts b/apps/backend/src/__tests__/integration.test.ts index a3190e9..40acf77 100644 --- a/apps/backend/src/__tests__/integration.test.ts +++ b/apps/backend/src/__tests__/integration.test.ts @@ -558,149 +558,6 @@ describe('Auto-registration', () => { }) }) -describe('ensureJackCustomFormat', () => { - test('Radarr: POSTs a "Jack" custom format matching the Internal flag (value 32)', async () => { - let postBody: any = null - server.use( - http.get(`${RADARR_URL}/api/v3/customformat`, () => HttpResponse.json([])), - http.post(`${RADARR_URL}/api/v3/customformat`, async ({ request }) => { - postBody = await request.json() - return HttpResponse.json({ id: 11, name: 'Jack' }) - }), - ) - - const radarr = markInitialized(makeRadarr()) - const id = await radarr.ensureJackCustomFormat() - - expect(id).toBe(11) - expect(postBody).toMatchObject({ name: 'Jack', includeCustomFormatWhenRenaming: false }) - expect(postBody.specifications).toHaveLength(1) - expect(postBody.specifications[0]).toMatchObject({ - name: 'Internal', - implementation: 'IndexerFlagSpecification', - negate: false, - required: true, - }) - expect(postBody.specifications[0].fields).toEqual([{ name: 'value', value: 32 }]) - }) - - test('Sonarr: uses the Internal flag value 8', async () => { - let postBody: any = null - server.use( - http.get(`${SONARR_URL}/api/v3/customformat`, () => HttpResponse.json([])), - http.post(`${SONARR_URL}/api/v3/customformat`, async ({ request }) => { - postBody = await request.json() - return HttpResponse.json({ id: 5, name: 'Jack' }) - }), - ) - - const sonarr = markInitialized(makeSonarr()) - const id = await sonarr.ensureJackCustomFormat() - - expect(id).toBe(5) - expect(postBody.specifications[0].fields).toEqual([{ name: 'value', value: 8 }]) - }) - - test('upserts an existing "Jack" custom format with a PUT to /{id}', async () => { - let putBody: any = null - let posted = false - server.use( - http.get(`${RADARR_URL}/api/v3/customformat`, () => HttpResponse.json([{ id: 9, name: 'Jack' }])), - http.post(`${RADARR_URL}/api/v3/customformat`, () => { - posted = true - return HttpResponse.json({ id: 9, name: 'Jack' }) - }), - http.put(`${RADARR_URL}/api/v3/customformat/9`, async ({ request }) => { - putBody = await request.json() - return HttpResponse.json({ id: 9, name: 'Jack' }) - }), - ) - - const radarr = markInitialized(makeRadarr()) - const id = await radarr.ensureJackCustomFormat() - - expect(id).toBe(9) - expect(posted).toBe(false) - expect(putBody).toMatchObject({ id: 9, name: 'Jack' }) - }) -}) - -describe('ensureJackQualityProfile', () => { - const schemaTemplate = { - name: '', - upgradeAllowed: false, - cutoff: 1, - minFormatScore: 0, - cutoffFormatScore: 0, - items: [ - { quality: { id: 1, name: 'SDTV' }, allowed: false }, - { name: 'HD', allowed: false, items: [{ quality: { id: 2, name: 'WEBDL-1080p' }, allowed: false }] }, - ], - formatItems: [ - { format: 11, name: 'Jack', score: 0 }, - { format: 12, name: 'Other', score: 0 }, - ], - } - - test('Radarr: ensures the CF then POSTs a "Jack" profile that only scores the Jack flag', async () => { - let postBody: any = null - server.use( - http.get(`${RADARR_URL}/api/v3/customformat`, () => HttpResponse.json([])), - http.post(`${RADARR_URL}/api/v3/customformat`, () => HttpResponse.json({ id: 11, name: 'Jack' })), - http.get(`${RADARR_URL}/api/v3/qualityprofile/schema`, () => HttpResponse.json(schemaTemplate)), - http.get(`${RADARR_URL}/api/v3/qualityprofile`, () => HttpResponse.json([])), - http.post(`${RADARR_URL}/api/v3/qualityprofile`, async ({ request }) => { - postBody = await request.json() - return HttpResponse.json({ id: 77, name: 'Jack' }) - }), - ) - - const radarr = markInitialized(makeRadarr()) - const id = await radarr.ensureJackQualityProfile() - - expect(id).toBe(77) - expect(postBody).toMatchObject({ - name: 'Jack', - upgradeAllowed: false, - minFormatScore: 1, - cutoffFormatScore: 1, - }) - // All qualities allowed — recursively, including the nested group. - expect(postBody.items[0].allowed).toBe(true) - expect(postBody.items[1].allowed).toBe(true) - expect(postBody.items[1].items[0].allowed).toBe(true) - // Only the Jack format is scored 1; others stay at 0. - expect(postBody.formatItems.find((f: any) => f.name === 'Jack').score).toBe(1) - expect(postBody.formatItems.find((f: any) => f.name === 'Other').score).toBe(0) - }) - - test('upserts an existing "Jack" profile with a PUT to /{id}', async () => { - let putBody: any = null - let posted = false - server.use( - http.get(`${RADARR_URL}/api/v3/customformat`, () => HttpResponse.json([{ id: 11, name: 'Jack' }])), - http.put(`${RADARR_URL}/api/v3/customformat/11`, () => HttpResponse.json({ id: 11, name: 'Jack' })), - http.get(`${RADARR_URL}/api/v3/qualityprofile/schema`, () => HttpResponse.json(schemaTemplate)), - http.get(`${RADARR_URL}/api/v3/qualityprofile`, () => HttpResponse.json([{ id: 42, name: 'Jack' }])), - http.post(`${RADARR_URL}/api/v3/qualityprofile`, () => { - posted = true - return HttpResponse.json({ id: 42, name: 'Jack' }) - }), - http.put(`${RADARR_URL}/api/v3/qualityprofile/42`, async ({ request }) => { - putBody = await request.json() - return HttpResponse.json({ id: 42, name: 'Jack' }) - }), - ) - - const radarr = markInitialized(makeRadarr()) - const id = await radarr.ensureJackQualityProfile() - - expect(id).toBe(42) - expect(posted).toBe(false) - expect(putBody).toMatchObject({ id: 42, name: 'Jack', minFormatScore: 1 }) - }) -}) - describe('Routes mount without peers or sources', () => { function createBareApp() { return getApp(envs, config, { servers: [], peers: [] }) diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index a476dd2..4bd272c 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -119,10 +119,6 @@ for (const dest of registrable) { logger.info({ destination: name, downloadClientId: meta.downloadClientId }, 'Registered Jack as qBittorrent download client') return } - if (kind === 'quality profile') { - logger.info({ destination: name, profileId: meta.profileId }, 'Registered Jack-only quality profile') - return - } logger.info({ destination: name, categories: meta.categories, downloadClientId: meta.downloadClientId }, 'Registered Jack as Torznab indexer') }, onFailure: logRegistrationFailure, diff --git a/apps/backend/src/lib/autoregister.test.ts b/apps/backend/src/lib/autoregister.test.ts index 79ef47b..377a513 100644 --- a/apps/backend/src/lib/autoregister.test.ts +++ b/apps/backend/src/lib/autoregister.test.ts @@ -1,7 +1,7 @@ import type { ManagedRegistrationDeps } from './autoregister' import type { ArrServerConnector } from './servers/arr/base' import { Database } from 'bun:sqlite' -import { beforeEach, describe, expect, mock, test } from 'bun:test' +import { beforeEach, describe, expect, test } from 'bun:test' import { drizzle } from 'drizzle-orm/bun-sqlite' import { runMigrations } from '../database/connection' import * as schema from '../database/schema' @@ -21,7 +21,7 @@ describe('registerManagedForDestination', () => { service = new ManagedApiKeys(repo) }) - function stubDest(over: Partial<{ registerIndexer: () => Promise, registerDownloadClient: () => Promise, ensureJackQualityProfile: () => Promise }> = {}): ArrServerConnector { + function stubDest(over: Partial<{ registerIndexer: () => Promise, registerDownloadClient: () => Promise }> = {}): ArrServerConnector { return { id: 'srv-a', name: 'Radarr', @@ -29,7 +29,6 @@ describe('registerManagedForDestination', () => { autoRegister: { enable: true, priority: 1 }, registerDownloadClient: over.registerDownloadClient ?? (async () => 1), registerIndexer: over.registerIndexer ?? (async () => {}), - ensureJackQualityProfile: over.ensureJackQualityProfile ?? (async () => 99), } as unknown as ArrServerConnector } @@ -94,29 +93,4 @@ describe('registerManagedForDestination', () => { ) expect(repo.findByServerId('srv-a').map(r => r.id)).toEqual([old.id]) }) - - test('registers the Jack quality profile and reports it via onSuccess', async () => { - const profileCall = mock(async () => 77) - const successes: Array<[string, number | undefined]> = [] - await registerManagedForDestination( - stubDest({ ensureJackQualityProfile: profileCall }), - { ...deps(), onSuccess: (kind, _name, meta) => successes.push([kind, meta.profileId]) }, - ) - expect(profileCall).toHaveBeenCalledTimes(1) - expect(successes).toContainEqual(['quality profile', 77]) - }) - - test('a failing quality-profile registration does not gate the managed-key commit', async () => { - const old = service.provision('srv-a') - const failures: string[] = [] - await registerManagedForDestination( - stubDest({ ensureJackQualityProfile: async () => { throw new Error('qp') } }), - { ...deps(), onFailure: kind => failures.push(kind) }, - ) - // Download client + indexer both succeeded → still a full commit despite the profile failing. - const rows = repo.findByServerId('srv-a') - expect(rows).toHaveLength(1) - expect(rows[0]!.id).not.toBe(old.id) - expect(failures).toContain('quality profile') - }) }) diff --git a/apps/backend/src/lib/autoregister.ts b/apps/backend/src/lib/autoregister.ts index a0310ce..28987f0 100644 --- a/apps/backend/src/lib/autoregister.ts +++ b/apps/backend/src/lib/autoregister.ts @@ -4,7 +4,6 @@ import type { ArrServerConnector } from './servers/arr/base' export interface ManagedRegistrationMeta { downloadClientId?: number categories?: number[] - profileId?: number } export interface ManagedRegistrationDeps { @@ -12,8 +11,8 @@ export interface ManagedRegistrationDeps { internalUrl: string downloads: boolean category: string - onSuccess: (kind: 'download client' | 'indexer' | 'quality profile', name: string, meta: ManagedRegistrationMeta) => void - onFailure: (kind: 'download client' | 'indexer' | 'quality profile', name: string, err: unknown) => void + onSuccess: (kind: 'download client' | 'indexer', name: string, meta: ManagedRegistrationMeta) => void + onFailure: (kind: 'download client' | 'indexer', name: string, err: unknown) => void } /** @@ -67,17 +66,6 @@ export async function registerManagedForDestination( deps.onFailure('indexer', dest.name, err) } - // Best-effort: register the Jack-only custom format + quality profile so *arr - // won't grab a catalog title from a non-Jack indexer. Independent of the - // download client/indexer; its failure must not gate the managed-key commit. - try { - const profileId = await dest.ensureJackQualityProfile() - deps.onSuccess('quality profile', dest.name, { profileId }) - } - catch (err) { - deps.onFailure('quality profile', dest.name, err) - } - // "Attempted" download = only when downloads is enabled. The indexer is always attempted. const allAttemptedOk = (downloads ? downloadDelivered : true) && indexerDelivered const anyDelivered = downloadDelivered || indexerDelivered diff --git a/apps/backend/src/lib/servers/arr/base.ts b/apps/backend/src/lib/servers/arr/base.ts index b11cd29..1247fd5 100644 --- a/apps/backend/src/lib/servers/arr/base.ts +++ b/apps/backend/src/lib/servers/arr/base.ts @@ -34,10 +34,6 @@ export const DestinationServerHealthIssue = z.array( // the auto-registered indexer to it. const DownloadClientResource = z.object({ id: z.number().int() }) -// *arr returns the saved custom format / quality profile on create; we only need -// its id (to score the format and to force the profile on catalog downloads). -const CreatedResourceId = z.object({ id: z.number().int() }) - // Register the Jack client at *arr's lowest selectable priority (the UI caps it // at 50). *arr's general client pool only round-robins among the best-priority // group, so a worst-priority Jack client is never picked for real torrents from @@ -199,103 +195,6 @@ export abstract class ArrServerConnector extends ServerConnector { return this.fetch('/api/v3/health', { schema: z.array(DestinationServerHealthIssue) }) } - /** - * Upsert a custom format named "Jack" that matches *arr's Internal indexer flag - * (which jack emits as `tag=internal` on its Torznab items). Returns the format id. - */ - @requiresDestination - @requiresInitialization - async ensureJackCustomFormat(): Promise { - const existingFormats = await this.arrGet('/api/v3/customformat') - const existing: any = Array.isArray(existingFormats) - ? existingFormats.find((cf: any) => cf.name === 'Jack') - : null - - const body = { - name: 'Jack', - includeCustomFormatWhenRenaming: false, - specifications: [{ - name: 'Internal', - implementation: 'IndexerFlagSpecification', - negate: false, - required: true, - fields: [{ name: 'value', value: this.internalIndexerFlagValue }], - }], - } - - if (existing) { - await this.fetch(`/api/v3/customformat/${existing.id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...body, id: existing.id }), - } as any) - return existing.id as number - } - - const created = await this.fetch('/api/v3/customformat', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - schema: CreatedResourceId, - } as any) - return created.id - } - - /** - * Ensure the Jack custom format exists, then upsert a quality profile named - * "Jack" that accepts every quality but only scores the Jack flag (minFormatScore - * 1), so *arr won't grab a matching release from a non-Jack indexer. Returns the - * profile id. - */ - @requiresDestination - @requiresInitialization - async ensureJackQualityProfile(): Promise { - const cfId = await this.ensureJackCustomFormat() - const schema = await this.arrGet('/api/v3/qualityprofile/schema') - - // Each item may itself nest an `items` array (quality groups); allow at every level. - const allowAll = (items: any[]): any[] => - items.map((item: any) => ({ - ...item, - allowed: true, - ...(Array.isArray(item.items) ? { items: allowAll(item.items) } : {}), - })) - - const profile = { - ...schema, - name: 'Jack', - // Grab the peer's file once and stop — no chasing "better" Jack releases. - upgradeAllowed: false, - items: allowAll(Array.isArray(schema?.items) ? schema.items : []), - minFormatScore: 1, - cutoffFormatScore: 1, - formatItems: (Array.isArray(schema?.formatItems) ? schema.formatItems : []).map((fi: any) => - (fi.name === 'Jack' || fi.format === cfId) ? { ...fi, score: 1 } : fi), - } - - const existingProfiles = await this.arrGet('/api/v3/qualityprofile') - const existing: any = Array.isArray(existingProfiles) - ? existingProfiles.find((p: any) => p.name === 'Jack') - : null - - if (existing) { - await this.fetch(`/api/v3/qualityprofile/${existing.id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...profile, id: existing.id }), - } as any) - return existing.id as number - } - - const created = await this.fetch('/api/v3/qualityprofile', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(profile), - schema: CreatedResourceId, - } as any) - return created.id - } - @requiresDestination @requiresInitialization async getRootFolders(): Promise> { From 79e673b51b4d8abe0667af678368ab8039398edf Mon Sep 17 00:00:00 2001 From: Roz Date: Sat, 27 Jun 2026 14:43:05 +0200 Subject: [PATCH 13/27] fix(catalog): surface failed direct-download starts in requestDownload startDirectDownload returns 'started'|'duplicate'|'failed' but requestDownload ignored it and always reported success, leaving a title added to *arr with no jack_manual row. The movie branch now throws on 'failed'; the series branch counts only non-failed starts, throws when none started, and returns the real started count instead of best.length. --- apps/backend/src/__tests__/catalog.test.ts | 52 +++++++++++++++++++ .../src/modules/catalog/catalog.controller.ts | 13 +++-- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/__tests__/catalog.test.ts b/apps/backend/src/__tests__/catalog.test.ts index ccd7271..aa45ccc 100644 --- a/apps/backend/src/__tests__/catalog.test.ts +++ b/apps/backend/src/__tests__/catalog.test.ts @@ -496,6 +496,58 @@ describe('catalogController.requestDownload', () => { importTarget: { kind: 'series', seriesId: 55 }, }) }) + + test('throws BadRequestError when the movie direct download fails to start', async () => { + const radarr = fakeServer({ add: mock(async () => 123) }) + const peer = fakePeer({ searchByTmdbId: mock(async () => [movie({ id: 'rel:1', tmdbId: 603, quality: { resolution: 1080 } })]) }) + const downloads = fakeDownloads({ startDirectDownload: mock(async () => 'failed') }) + const controller = new CatalogController(makeConnectors([radarr], [peer]) as any, undefined, downloads as any) + + await expect(controller.requestDownload({ + peerId: 'peer-1', + serverId: 'radarr-1', + mediaType: 'movie', + tmdbId: 603, + rootFolderPath: '/movies', + })).rejects.toBeInstanceOf(BadRequestError) + }) + + test('throws BadRequestError when every episode direct download fails to start', async () => { + const sonarr = fakeServer({ id: 'sonarr-1', name: 'My Sonarr', type: 'sonarr', add: mock(async () => 55) }) + const ep1 = episode({ id: 'ep:1', tvdbId: 81189, season: 1, episode: 1, quality: { resolution: 1080 }, size: 60 }) + const ep2 = episode({ id: 'ep:2', tvdbId: 81189, season: 1, episode: 2, quality: { resolution: 720 }, size: 40 }) + const peer = fakePeer({ searchByTvdbId: mock(async () => [ep1, ep2]) }) + const downloads = fakeDownloads({ startDirectDownload: mock(async () => 'failed') }) + const controller = new CatalogController(makeConnectors([sonarr], [peer]) as any, undefined, downloads as any) + + await expect(controller.requestDownload({ + peerId: 'peer-1', + serverId: 'sonarr-1', + mediaType: 'tv', + tvdbId: 81189, + rootFolderPath: '/tv', + })).rejects.toBeInstanceOf(BadRequestError) + }) + + test('counts only non-failed episode starts when some fail', async () => { + const sonarr = fakeServer({ id: 'sonarr-1', name: 'My Sonarr', type: 'sonarr', add: mock(async () => 55) }) + const ep1 = episode({ id: 'ep:1', tvdbId: 81189, season: 1, episode: 1, quality: { resolution: 1080 }, size: 60 }) + const ep2 = episode({ id: 'ep:2', tvdbId: 81189, season: 1, episode: 2, quality: { resolution: 720 }, size: 40 }) + const peer = fakePeer({ searchByTvdbId: mock(async () => [ep1, ep2]) }) + let calls = 0 + const downloads = fakeDownloads({ startDirectDownload: mock(async () => (calls++ === 0 ? 'started' : 'failed')) }) + const controller = new CatalogController(makeConnectors([sonarr], [peer]) as any, undefined, downloads as any) + + const result = await controller.requestDownload({ + peerId: 'peer-1', + serverId: 'sonarr-1', + mediaType: 'tv', + tvdbId: 81189, + rootFolderPath: '/tv', + }) + + expect(result).toEqual({ ok: true, server: 'My Sonarr', started: 1 }) + }) }) describe('catalogController.getTmdbStatus', () => { diff --git a/apps/backend/src/modules/catalog/catalog.controller.ts b/apps/backend/src/modules/catalog/catalog.controller.ts index 9ec5ab1..a5291ac 100644 --- a/apps/backend/src/modules/catalog/catalog.controller.ts +++ b/apps/backend/src/modules/catalog/catalog.controller.ts @@ -115,12 +115,14 @@ export class CatalogController { throw new NotFoundError(`Peer "${peer.name}" has no release for tmdbId ${input.tmdbId}`) const movieId = await server.add({ tmdbId: input.tmdbId, rootFolderPath: input.rootFolderPath }) - await this.downloads.startDirectDownload({ + const result = await this.downloads.startDirectDownload({ peerId: peer.id, itemId: best.id, destinationServerName: server.name, importTarget: { kind: 'movie', movieId }, }) + if (result === 'failed') + throw new BadRequestError(`Failed to start the download for tmdbId ${input.tmdbId} from peer "${peer.name}"`) return { ok: true, server: server.name, started: 1 } } @@ -134,15 +136,20 @@ export class CatalogController { throw new NotFoundError(`Peer "${peer.name}" has no episodes for tvdbId ${input.tvdbId}`) const seriesId = await server.add({ tvdbId: input.tvdbId, rootFolderPath: input.rootFolderPath }) + let started = 0 for (const release of best) { - await this.downloads.startDirectDownload({ + const result = await this.downloads.startDirectDownload({ peerId: peer.id, itemId: release.id, destinationServerName: server.name, importTarget: { kind: 'series', seriesId }, }) + if (result !== 'failed') + started++ } - return { ok: true, server: server.name, started: best.length } + if (started === 0) + throw new BadRequestError(`Failed to start any episode download for tvdbId ${input.tvdbId} from peer "${peer.name}"`) + return { ok: true, server: server.name, started } } async getTmdbStatus(): Promise { From f5fe87f6e34548ab1871408e77bc7d758ed79641 Mon Sep 17 00:00:00 2001 From: Roz Date: Sat, 27 Jun 2026 15:37:51 +0200 Subject: [PATCH 14/27] feat(ui): added image fallback for TMDB poster cards - Implement useFallbackImage composable that cycles through sources when TMDB requests fail under load. - Refactor CatalogPosterCard with three-state status machine for clearer metadata enrichment and error handling. - Add lazy-loading via IntersectionObserver to defer metadata fetches until cards approach viewport. - Remove inline metadata fetching from catalog page in favor of observer-driven lazy pattern. - Add TMDB API v3 reference documenting authentication, endpoints, and image handling best practices. --- .agents/skills/tmdb-api/SKILL.md | 347 ++++++++++++++++++ apps/ui/app/components/CatalogPosterCard.vue | 71 +++- apps/ui/app/components/CatalogTitleDetail.vue | 11 +- apps/ui/app/composables/useFallbackImage.ts | 31 ++ apps/ui/app/pages/catalog/[peerId].vue | 35 +- skills-lock.json | 11 + 6 files changed, 468 insertions(+), 38 deletions(-) create mode 100644 .agents/skills/tmdb-api/SKILL.md create mode 100644 apps/ui/app/composables/useFallbackImage.ts create mode 100644 skills-lock.json diff --git a/.agents/skills/tmdb-api/SKILL.md b/.agents/skills/tmdb-api/SKILL.md new file mode 100644 index 0000000..f1e1bda --- /dev/null +++ b/.agents/skills/tmdb-api/SKILL.md @@ -0,0 +1,347 @@ +--- +name: tmdb-api +description: Use when working with The Movie Database (TMDB) API v3 — fetching movie, TV series, season, episode, or person data; building search or discovery features; handling authentication, ratings, watchlists, or watch providers; using append_to_response to batch requests; or understanding which endpoints exist and what parameters they accept. +--- + +# TMDB API v3 + +TMDB's REST API is versioned at `/3/`. All endpoints require `?api_key=YOUR_KEY` or an `Authorization: Bearer TOKEN` header. Base URL: `https://api.themoviedb.org`. + +## Authentication + +Three session types exist, each with different write access: + +| Type | Creation | Write access | +|------|----------|--------------| +| User session | Token → login → session flow | Ratings, watchlist, favorites | +| Guest session | `GET /3/authentication/guest_session/new` | Ratings only | +| API key only | None needed | Read-only | + +**User session flow:** +1. `GET /3/authentication/token/new` — get a request token +2. Redirect user to `https://www.themoviedb.org/authenticate/{request_token}` +3. `POST /3/authentication/session/new` with `{ "request_token": "..." }` — exchange for session_id + +Guest sessions expire after a period of inactivity and cannot access account endpoints. + +## Common Parameters + +`language` (BCP-47, e.g. `en-US`) applies to most endpoints and affects translated fields like `title`, `overview`, and `tagline`. `page` is 1-based; most paginated endpoints cap at 500 pages. + +`append_to_response` accepts a comma-separated list of sub-resources (max 20) and applies **only** to detail endpoints: +- `GET /3/movie/{movie_id}` +- `GET /3/tv/{series_id}` +- `GET /3/tv/{series_id}/season/{season_number}` +- `GET /3/tv/{series_id}/season/{season_number}/episode/{episode_number}` +- `GET /3/person/{person_id}` + +Example: `/3/movie/550?append_to_response=credits,videos,images` returns the movie detail with credits, videos, and images merged into one response, saving two round trips. + +## Movies + +### Movie Lists + +| Endpoint | Description | Extra params | +|----------|-------------|--------------| +| `GET /3/movie/popular` | Popularity-ranked | `region` (ISO-3166-1) | +| `GET /3/movie/top_rated` | Highest rated | `region` | +| `GET /3/movie/now_playing` | In theatres | `region` | +| `GET /3/movie/upcoming` | Coming soon | `region` | +| `GET /3/movie/latest` | Most recently added | — | +| `GET /3/movie/changes` | Changed movie IDs | `start_date`, `end_date` | + +### Movie Detail + +`GET /3/movie/{movie_id}` — full metadata including `belongs_to_collection`, `budget`, `revenue`, `runtime`, `release_date`. + +### Movie Sub-resources + +| Endpoint | Returns | +|----------|---------| +| `/3/movie/{id}/credits` | Cast and crew | +| `/3/movie/{id}/images` | Posters, backdrops, logos | +| `/3/movie/{id}/videos` | Trailers, clips (YouTube/Vimeo) | +| `/3/movie/{id}/keywords` | Associated keywords | +| `/3/movie/{id}/recommendations` | Similar movies TMDB recommends | +| `/3/movie/{id}/similar` | Same genre/keywords | +| `/3/movie/{id}/release_dates` | Per-country release dates and certifications | +| `/3/movie/{id}/watch/providers` | Streaming/rent/buy availability by region | +| `/3/movie/{id}/alternative_titles` | Title variations by country | +| `/3/movie/{id}/translations` | Translated metadata | +| `/3/movie/{id}/reviews` | User reviews | +| `/3/movie/{id}/lists` | User lists containing this movie | +| `/3/movie/{id}/account_states` | Auth required — user's rating, watchlist, favorite status | +| `/3/movie/{id}/rating` | POST/DELETE — add or remove a rating | +| `/3/movie/{id}/external_ids` | IMDb, Wikidata, Facebook, Instagram, Twitter | + +All of the above are also valid values for `append_to_response` on the movie detail endpoint. + +### Images + +`GET /3/movie/{id}/images` returns three arrays: `backdrops`, `posters`, `logos`. Each image object has `file_path`, `width`, `height`, `vote_average`, and `iso_639_1` (language tag, or `null` for untagged). + +To get English images as fallback when querying in another language, add `include_image_language=en,null`. Using only `null` skips English-tagged images, which are often the most abundant. + +## TV Series + +### TV Lists + +| Endpoint | Description | Extra params | +|----------|-------------|--------------| +| `GET /3/tv/popular` | Popularity-ranked | — | +| `GET /3/tv/top_rated` | Highest rated | — | +| `GET /3/tv/airing_today` | Airing today | `timezone` | +| `GET /3/tv/on_the_air` | Airing in next 7 days | `timezone` | +| `GET /3/tv/latest` | Most recently added | — | +| `GET /3/tv/changes` | Changed series IDs | `start_date`, `end_date` | + +TV list endpoints do not accept a `region` parameter (unlike movie lists). + +### Series Detail + +`GET /3/tv/{series_id}` — includes `number_of_seasons`, `number_of_episodes`, `networks`, `created_by`, `last_air_date`, `next_episode_to_air`, `seasons` (summary array), `type`, and `status`. + +### TV Sub-resources + +| Endpoint | Returns | +|----------|---------| +| `/3/tv/{id}/credits` | Cast/crew for the **latest season only** | +| `/3/tv/{id}/aggregate_credits` | Cast/crew aggregated across **all seasons** | +| `/3/tv/{id}/images` | Posters, backdrops, logos | +| `/3/tv/{id}/videos` | Trailers, clips | +| `/3/tv/{id}/keywords` | Keywords | +| `/3/tv/{id}/recommendations` | Recommended series | +| `/3/tv/{id}/similar` | Similar series | +| `/3/tv/{id}/content_ratings` | Per-country content ratings (e.g. TV-MA) | +| `/3/tv/{id}/watch/providers` | Streaming availability by region | +| `/3/tv/{id}/alternative_titles` | Title variations | +| `/3/tv/{id}/translations` | Translated metadata | +| `/3/tv/{id}/reviews` | User reviews | +| `/3/tv/{id}/external_ids` | IMDb, TVDB, Wikidata, socials | +| `/3/tv/{id}/account_states` | Auth required — user's rating, watchlist, favorite | +| `/3/tv/{id}/rating` | POST/DELETE — add or remove a rating | +| `/3/tv/{id}/episode_groups` | Grouped episode orderings (e.g. DVD order) | +| `/3/tv/{id}/screened_theatrically` | Episodes that had a theatrical run | + +`credits` vs `aggregate_credits`: use `aggregate_credits` when you need the full series cast list. `credits` only covers the latest season, so recurring characters from earlier seasons may be absent. + +`aggregate_credits` returns a `roles` array per cast member (one entry per character/season) rather than a flat cast list. Sort client-side by `total_episode_count` to approximate series regulars. + +Both `credits` and `aggregate_credits` are valid `append_to_response` values on the series detail endpoint. + +### Seasons + +`GET /3/tv/{series_id}/season/{season_number}` — full season detail including all episodes. + +| Sub-resource | Returns | +|--------------|---------| +| `/season/{n}/credits` | Season-level cast/crew | +| `/season/{n}/aggregate_credits` | Aggregated across the season | +| `/season/{n}/images` | Season posters | +| `/season/{n}/videos` | Season trailers | +| `/season/{n}/translations` | Translated metadata | +| `/season/{n}/external_ids` | TVDB, TMDB IDs | +| `/season/{n}/watch/providers` | Season streaming availability | + +Season 0 is the specials season when it exists. + +### Episodes + +`GET /3/tv/{series_id}/season/{season_number}/episode/{episode_number}` — full episode detail. + +| Sub-resource | Returns | +|--------------|---------| +| `/episode/{n}/credits` | Episode cast/crew (guest stars included) | +| `/episode/{n}/images` | Still frames | +| `/episode/{n}/videos` | Clips | +| `/episode/{n}/translations` | Translated metadata | +| `/episode/{n}/external_ids` | IMDb, TVDB IDs | +| `/episode/{n}/rating` | POST/DELETE — episode rating | + +## People + +`GET /3/person/{person_id}` — biography, birthday, deathday, place of birth, `known_for_department`, `also_known_as`. + +| Endpoint | Returns | +|----------|---------| +| `/3/person/{id}/movie_credits` | Movies as cast or crew | +| `/3/person/{id}/tv_credits` | TV series as cast or crew | +| `/3/person/{id}/combined_credits` | Both, with `media_type` field distinguishing them | +| `/3/person/{id}/images` | Profile photos | +| `/3/person/{id}/tagged_images` | Images tagged with this person | +| `/3/person/{id}/external_ids` | IMDb, TVDB, socials | +| `/3/person/{id}/translations` | Translated biography | +| `/3/person/popular` | Popularity-ranked people list | +| `/3/person/latest` | Most recently added | +| `/3/person/changes` | Changed person IDs | + +All person sub-resources above are valid `append_to_response` values on the person detail endpoint. + +## Search + +| Endpoint | Searches | Extra params | +|----------|----------|--------------| +| `GET /3/search/movie` | Movies | `primary_release_year`, `year`, `region` | +| `GET /3/search/tv` | TV series | `first_air_date_year`, `year` | +| `GET /3/search/person` | People | — | +| `GET /3/search/multi` | Movies, TV, people in one call | — | +| `GET /3/search/collection` | Movie collections | — | +| `GET /3/search/company` | Production companies | — | +| `GET /3/search/keyword` | Keywords | — | + +All search endpoints accept `query` (required), `language`, `page`, and `include_adult`. + +`/3/search/multi` results include a `media_type` field (`"movie"`, `"tv"`, or `"person"`) to distinguish result types. + +## Discover + +Discover is a filtered browsing API — not keyword search. Results are sorted and filterable, not relevance-ranked. + +### Movie Discover + +`GET /3/discover/movie` key parameters: + +| Parameter | Description | +|-----------|-------------| +| `sort_by` | `popularity.desc`, `vote_average.desc`, `revenue.desc`, `release_date.desc`, etc. | +| `with_genres` | Genre IDs — comma = AND, pipe = OR | +| `with_cast` | Person IDs — comma = AND, pipe = OR | +| `with_crew` | Person IDs | +| `with_people` | Cast or crew IDs | +| `with_companies` | Company IDs | +| `with_keywords` | Keyword IDs — comma = AND, pipe = OR | +| `without_genres` | Exclude by genre ID | +| `without_keywords` | Exclude by keyword ID | +| `primary_release_year` | Exact year | +| `primary_release_date.gte` / `.lte` | Date range | +| `release_date.gte` / `.lte` | Any release type date range | +| `vote_average.gte` / `.lte` | Score range | +| `vote_count.gte` / `.lte` | Minimum vote count (use with vote_average filters) | +| `with_runtime.gte` / `.lte` | Runtime in minutes | +| `region` | ISO-3166-1 — affects release date filtering | +| `certification` / `certification_country` | Filter by certification (e.g. R, PG-13) | +| `with_release_type` | Release type: 1=Premiere, 2=Limited, 3=Theatrical, 4=Digital, 5=Physical, 6=TV | +| `with_watch_providers` | Provider IDs — use with `watch_region` | +| `watch_region` | ISO-3166-1 — required for watch provider filtering | +| `with_watch_monetization_types` | `flatrate`, `free`, `ads`, `rent`, `buy` | +| `with_original_language` | ISO-639-1 language code | +| `with_origin_country` | ISO-3166-1 country code | +| `year` | Alias for `primary_release_year` | +| `include_adult` | Default false | +| `include_video` | Include video-only releases, default false | + +### TV Discover + +`GET /3/discover/tv` — same pattern, with TV-specific parameters: + +| Parameter | Description | +|-----------|-------------| +| `sort_by` | `popularity.desc`, `vote_average.desc`, `first_air_date.desc`, etc. | +| `with_genres` | Genre IDs | +| `with_networks` | Network ID (integer, not comma-separated) | +| `with_status` | 0=Returning, 1=Planned, 2=In Production, 3=Ended, 4=Canceled, 5=Pilot | +| `with_type` | 0=Documentary, 1=News, 2=Miniseries, 3=Reality, 4=Scripted, 5=Talk Show, 6=Video | +| `first_air_date_year` | Exact first air year | +| `first_air_date.gte` / `.lte` | Date range | +| `air_date.gte` / `.lte` | Episode air date range | +| `timezone` | Used with air date filters | +| `screened_theatrically` | Boolean — only series with theatrical episodes | +| `include_null_first_air_dates` | Include series without a first air date | + +## Trending + +`GET /3/trending/{media_type}/{time_window}` — `media_type` is `movie`, `tv`, `person`, or `all`; `time_window` is `day` or `week`. + +## Find by External ID + +`GET /3/find/{external_id}?external_source=imdb_id` — look up a TMDB entity by an external identifier. + +Supported `external_source` values: `imdb_id`, `tvdb_id`, `freebase_mid`, `freebase_id`, `tvrage_id`, `wikidata_id`, `facebook_id`, `instagram_id`, `twitter_id`. + +Returns separate arrays: `movie_results`, `tv_results`, `tv_season_results`, `tv_episode_results`, `person_results`. + +## Collections, Networks, Companies, Keywords + +| Endpoint | Description | +|----------|-------------| +| `GET /3/collection/{id}` | Movie collection detail (e.g. Marvel Cinematic Universe) | +| `GET /3/collection/{id}/images` | Collection images | +| `GET /3/collection/{id}/translations` | Translated metadata | +| `GET /3/network/{id}` | Network detail (e.g. HBO) | +| `GET /3/network/{id}/images` | Network logos | +| `GET /3/network/{id}/alternative_names` | Network name aliases | +| `GET /3/company/{id}` | Production company detail | +| `GET /3/company/{id}/images` | Company logos | +| `GET /3/company/{id}/alternative_names` | Company name aliases | +| `GET /3/keyword/{id}` | Keyword detail | +| `GET /3/keyword/{id}/movies` | Movies tagged with this keyword | +| `GET /3/credit/{credit_id}` | Cast/crew credit detail by credit ID | + +## Account & Watchlist (Auth Required) + +All account endpoints require a valid `session_id` query parameter. + +| Endpoint | Description | +|----------|-------------| +| `GET /3/account/{account_id}` | Account details | +| `POST /3/account/{account_id}/favorite` | Add/remove favorite | +| `POST /3/account/{account_id}/watchlist` | Add/remove watchlist entry | +| `GET /3/account/{account_id}/favorite/movies` | Favorite movies | +| `GET /3/account/{account_id}/favorite/tv` | Favorite TV series | +| `GET /3/account/{account_id}/watchlist/movies` | Watchlist movies | +| `GET /3/account/{account_id}/watchlist/tv` | Watchlist TV series | +| `GET /3/account/{account_id}/rated/movies` | Rated movies | +| `GET /3/account/{account_id}/rated/tv` | Rated TV series | +| `GET /3/account/{account_id}/rated/tv/episodes` | Rated episodes | +| `GET /3/account/{account_id}/lists` | User-created lists | + +Guest session ratings use `GET /3/guest_session/{guest_session_id}/rated/movies` (and `/tv`, `/tv/episodes`). + +## Lists (v3) + +| Endpoint | Description | +|----------|-------------| +| `POST /3/list` | Create a list | +| `GET /3/list/{list_id}` | List detail and items | +| `POST /3/list/{list_id}/add_item` | Add a movie | +| `POST /3/list/{list_id}/remove_item` | Remove a movie | +| `POST /3/list/{list_id}/clear` | Remove all items | +| `DELETE /3/list/{list_id}` | Delete the list | +| `GET /3/list/{list_id}/item_status` | Check if a movie is in the list | + +## Configuration & Reference Data + +| Endpoint | Returns | +|----------|---------| +| `GET /3/configuration` | Image base URLs, available sizes, change keys | +| `GET /3/configuration/countries` | Country list with ISO codes | +| `GET /3/configuration/languages` | Language list with ISO codes | +| `GET /3/configuration/jobs` | Department and job name list | +| `GET /3/configuration/primary_translations` | Supported translation locales | +| `GET /3/configuration/timezones` | Timezone list | +| `GET /3/genre/movie/list` | Movie genre IDs and names | +| `GET /3/genre/tv/list` | TV genre IDs and names | +| `GET /3/certification/movie/list` | Movie certifications by country | +| `GET /3/certification/tv/list` | TV certifications by country | +| `GET /3/watch/providers/movie` | Available movie streaming providers | +| `GET /3/watch/providers/tv` | Available TV streaming providers | +| `GET /3/watch/providers/regions` | Regions where watch providers operate | + +## Images + +Image URLs are assembled from the configuration endpoint: `secure_base_url + size + file_path`. + +Common sizes: `w45`, `w92`, `w154`, `w185`, `w300`, `w342`, `w500`, `w780`, `w1280`, `original`. Not all sizes apply to all image types — use the `poster_sizes`, `backdrop_sizes`, `profile_sizes`, etc. arrays from the configuration endpoint to know what's valid. + +## Rate Limits & Errors + +TMDB allows roughly 40 requests per second. Exceeding the limit returns `429 Too Many Requests`. No official rate-limit headers are documented; back off and retry on 429. + +| Status | Meaning | +|--------|---------| +| `401` | Invalid API key or missing authentication | +| `404` | Resource not found | +| `422` | Validation error (check request body) | +| `429` | Rate limit exceeded — back off and retry | + +Error responses include a `status_code` (TMDB's internal code) and `status_message` in the JSON body alongside the HTTP status. diff --git a/apps/ui/app/components/CatalogPosterCard.vue b/apps/ui/app/components/CatalogPosterCard.vue index 96b3424..f2727ae 100644 --- a/apps/ui/app/components/CatalogPosterCard.vue +++ b/apps/ui/app/components/CatalogPosterCard.vue @@ -7,51 +7,98 @@ defineEmits<{ select: [] }>() const { load, entryFor } = useCatalogMetadata() const entry = computed(() => entryFor(props.title)) const metadata = computed(() => entry.value?.data ?? props.title.metadata ?? null) -// Loading until the lookup settles; titles with no tmdbId never load, so they're -// not "loading" — they just stay on the placeholder. -const loading = computed(() => props.title.tmdbId != null && (entry.value === null || entry.value.status === 'loading')) + +// Three visual states: the lookup is in flight, it finished with nothing (or there +// was no id to look up), or we have metadata. +const status = computed<'loading' | 'unavailable' | 'ready'>(() => { + if (metadata.value) + return 'ready' + if (props.title.tmdbId == null) + return 'unavailable' + return entry.value === null || entry.value.status === 'loading' ? 'loading' : 'unavailable' +}) const name = computed(() => metadata.value?.title ?? props.title.displayTitle) -const poster = computed(() => metadata.value?.posterUrl ?? null) +// Prefer the poster, fall back to the backdrop — both for titles that only have one +// and for transient TMDB load failures (object-cover crops it to the card shape). +const { src: poster, onError: onPosterError } = useFallbackImage(() => [ + metadata.value?.posterUrl, + metadata.value?.backdropUrl, +]) const year = computed(() => metadata.value?.year ?? null) const rating = computed(() => metadata.value?.rating ?? null) +const typeLabel = computed(() => props.title.mediaType === 'tv' ? 'TV' : 'Movie') +const mediaIcon = computed(() => props.title.mediaType === 'tv' ? 'i-ph-television' : 'i-ph-film-strip') -onMounted(() => load(props.title)) +// Enrich only once the card nears the viewport, so off-screen cards don't all +// fire lookups at once. rootMargin starts the fetch just before it scrolls in. +const cardRef = ref(null) +let observer: IntersectionObserver | null = null +onMounted(() => { + observer = new IntersectionObserver((entries) => { + if (!entries.some(entry => entry.isIntersecting)) + return + load(props.title) + observer?.disconnect() + observer = null + }, { rootMargin: '300px' }) + if (cardRef.value) + observer.observe(cardRef.value) +}) +onBeforeUnmount(() => observer?.disconnect()) diff --git a/apps/ui/nuxt.config.ts b/apps/ui/nuxt.config.ts index 3984b63..6b545a4 100644 --- a/apps/ui/nuxt.config.ts +++ b/apps/ui/nuxt.config.ts @@ -1,4 +1,6 @@ // https://nuxt.com/docs/api/configuration/nuxt-config +import pkg from './package.json' + export default defineNuxtConfig({ compatibilityDate: '2025-01-01', devtools: { enabled: false }, @@ -42,5 +44,11 @@ export default defineNuxtConfig({ // Seals the cookie that holds the management key in cookie mode. MUST be set // (>= 32 chars) in production; the default is a clearly-insecure dev value. sessionKey: 'dev-insecure-session-key-change-me-please-1234', + + // Browser-exposed: the app version, baked from package.json at build time and + // shown in Settings → About. + public: { + appVersion: pkg.version, + }, }, }) diff --git a/apps/ui/package.json b/apps/ui/package.json index 63db74d..2e5d499 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -1,5 +1,6 @@ { "name": "@jack/ui", + "version": "0.1.0", "type": "module", "private": true, "scripts": { diff --git a/apps/ui/public/tmdb.svg b/apps/ui/public/tmdb.svg new file mode 100644 index 0000000..4b21ded --- /dev/null +++ b/apps/ui/public/tmdb.svg @@ -0,0 +1 @@ +Asset 3 \ No newline at end of file From 02c4c97678e16b1d21a735d430f6388fe194ab3a Mon Sep 17 00:00:00 2001 From: Roz Date: Sun, 28 Jun 2026 12:29:15 +0200 Subject: [PATCH 20/27] feat(ui): stable per-peer colors and plug-style connection status Derive a stable accent color per peer from its id (sorted-membership assignment with a per-id hash fallback), and render it as a dot on catalog poster peer badges. Reuse it for connection status: ConnDot now draws a plug icon (shape = status, color = peer identity when given), and ConnectorCard forwards an optional accent. --- apps/ui/app/components/CatalogPosterCard.vue | 51 ++++++++++++++++ apps/ui/app/components/ConnDot.vue | 31 +++++++--- apps/ui/app/components/ConnectorCard.vue | 5 +- apps/ui/app/composables/usePeerColors.ts | 21 +++++++ apps/ui/app/utils/peerColor.ts | 62 ++++++++++++++++++++ 5 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 apps/ui/app/composables/usePeerColors.ts create mode 100644 apps/ui/app/utils/peerColor.ts diff --git a/apps/ui/app/components/CatalogPosterCard.vue b/apps/ui/app/components/CatalogPosterCard.vue index f2727ae..bfd9d17 100644 --- a/apps/ui/app/components/CatalogPosterCard.vue +++ b/apps/ui/app/components/CatalogPosterCard.vue @@ -30,6 +30,13 @@ const rating = computed(() => metadata.value?.rating ?? null) const typeLabel = computed(() => props.title.mediaType === 'tv' ? 'TV' : 'Movie') const mediaIcon = computed(() => props.title.mediaType === 'tv' ? 'i-ph-television' : 'i-ph-film-strip') +// Peer chips along the poster's bottom edge: up to two name badges (CSS-truncated to +// stay legible, and to shrink rather than overflow on small posters) then a "+N" badge +// whose tooltip names the rest. +const visiblePeers = computed(() => props.title.peers.slice(0, 2)) +const hiddenPeers = computed(() => props.title.peers.slice(2)) +const { dotClass } = usePeerColors() + // Enrich only once the card nears the viewport, so off-screen cards don't all // fire lookups at once. rootMargin starts the fetch just before it scrolls in. const cardRef = ref(null) @@ -88,6 +95,50 @@ onBeforeUnmount(() => observer?.disconnect()) class="absolute right-1.5 top-1.5 gap-0.5 bg-black/65 font-semibold text-amber-300 ring-1 ring-white/10 backdrop-blur" :ui="{ leadingIcon: 'text-amber-400' }" /> + +
+ + + + + + + + + +

diff --git a/apps/ui/app/components/ConnDot.vue b/apps/ui/app/components/ConnDot.vue index 94d7f19..e34bd51 100644 --- a/apps/ui/app/components/ConnDot.vue +++ b/apps/ui/app/components/ConnDot.vue @@ -2,20 +2,35 @@ const props = defineProps<{ initialized: boolean error?: string | null + // Optional identity color (e.g. a peer's derived text-color class). When set it + // overrides the status color: the plug shape carries the status, the color carries + // identity. Left unset for connectors, which have no per-entry color. + accentClass?: string | null }>() -// Connected → success; unreachable (failed to initialize, has an error) → error; -// still handshaking → warning. A soft halo ring gives the live console a pulse -// without any custom CSS. -const dot = computed(() => { +// Shape conveys status: connected → plugged together, otherwise → unplugged. +const icon = computed(() => props.initialized ? 'i-ph-plugs-connected' : 'i-ph-plugs') + +// Color conveys identity when an accent is given, else falls back to the status color. +const color = computed(() => { + if (props.accentClass) + return props.accentClass if (props.initialized) - return 'bg-success ring-success/20' + return 'text-success' if (props.error) - return 'bg-error ring-error/20' - return 'bg-warning ring-warning/20' + return 'text-error' + return 'text-warning' }) + +// Still handshaking (not initialized, no error yet): pulse to signal it's in progress. +const connecting = computed(() => !props.initialized && !props.error) diff --git a/apps/ui/app/components/ConnectorCard.vue b/apps/ui/app/components/ConnectorCard.vue index 92893e4..72b360d 100644 --- a/apps/ui/app/components/ConnectorCard.vue +++ b/apps/ui/app/components/ConnectorCard.vue @@ -8,6 +8,9 @@ defineProps<{ error?: string | null status: { color: BadgeProps['color'], label: string } to?: string + // Optional identity accent (e.g. a peer's derived text-color class) applied to the + // status icon. Left unset for connectors, which have no per-entry color. + accentClass?: string | null }>() defineEmits<{ edit: [], remove: [] }>() @@ -15,7 +18,7 @@ defineEmits<{ edit: [], remove: [] }>() - + -

+

Loading…

- +

@@ -124,7 +123,7 @@ async function confirmDelete() {

-
+

{{ connected }} of {{ peers.length }} connected - + -

+

Loading…

- +

@@ -128,7 +126,7 @@ const destinations = computed(() => servers.value.filter(s => s.destination).len

-
+

{{ connected }} of {{ servers.length }} connected diff --git a/apps/ui/app/components/DownloadRequestModal.vue b/apps/ui/app/components/DownloadRequestModal.vue index 703baba..8629500 100644 --- a/apps/ui/app/components/DownloadRequestModal.vue +++ b/apps/ui/app/components/DownloadRequestModal.vue @@ -1,7 +1,7 @@