+
![]()
-
{{ name }}
-
- {{ year }} · {{ title.mediaType === 'tv' ? 'TV' : 'Movie' }}
+
+ {{ year }} · {{ typeLabel }}
+
+
+
+ {{ typeLabel }} · no metadata
diff --git a/apps/ui/app/components/CatalogTitleDetail.vue b/apps/ui/app/components/CatalogTitleDetail.vue
index e768913..3ab8ee9 100644
--- a/apps/ui/app/components/CatalogTitleDetail.vue
+++ b/apps/ui/app/components/CatalogTitleDetail.vue
@@ -8,6 +8,13 @@ const { load, entryFor } = useCatalogMetadata()
// Reuse the cache the grid populated; load() is a no-op if the card already fetched it.
const meta = computed(() => entryFor(props.title)?.data ?? props.title.metadata ?? null)
const name = computed(() => meta.value?.title ?? props.title.displayTitle)
+// Prefer the wide backdrop, fall back to the poster — covers titles with only one
+// image and transient TMDB load failures (the poster is usually already cached by
+// the card). Null once both fail, so the box is hidden rather than left blank.
+const { src: heroImage, onError: onHeroError } = useFallbackImage(() => [
+ meta.value?.backdropUrl,
+ meta.value?.posterUrl,
+])
onMounted(() => load(props.title))
const canRequest = computed(() => props.title.mediaType === 'tv'
@@ -17,8 +24,8 @@ const canRequest = computed(() => props.title.mediaType === 'tv'
-
-
![]()
+
+
diff --git a/apps/ui/app/composables/useFallbackImage.ts b/apps/ui/app/composables/useFallbackImage.ts
new file mode 100644
index 0000000..4a19387
--- /dev/null
+++ b/apps/ui/app/composables/useFallbackImage.ts
@@ -0,0 +1,31 @@
+/**
+ * Renders the first image source that successfully loads, falling back through the
+ * rest on error. TMDB image requests occasionally fail under load (many posters
+ * fetching at once), which otherwise leaves a permanently blank box until a reload
+ * warms the HTTP cache. Falling back to the next candidate — typically an image the
+ * sibling view already cached — keeps artwork on screen; when every source fails,
+ * `src` is null so the caller can show its placeholder instead of a broken image.
+ */
+export function useFallbackImage(sources: () => Array) {
+ const candidates = computed(() => sources().filter((s): s is string => Boolean(s)))
+ const index = ref(0)
+ const exhausted = ref(false)
+
+ const src = computed(() => (exhausted.value ? null : candidates.value[index.value] ?? null))
+
+ function onError(): void {
+ if (index.value < candidates.value.length - 1)
+ index.value += 1
+ else
+ exhausted.value = true
+ }
+
+ // Reset the cursor when the actual list of URLs changes (e.g. the sidebar swaps
+ // to a different title), but not on unrelated reactive churn.
+ watch(() => candidates.value.join('|'), () => {
+ index.value = 0
+ exhausted.value = false
+ })
+
+ return { src, onError }
+}
diff --git a/apps/ui/app/pages/catalog/[peerId].vue b/apps/ui/app/pages/catalog/[peerId].vue
index d301a98..06018a2 100644
--- a/apps/ui/app/pages/catalog/[peerId].vue
+++ b/apps/ui/app/pages/catalog/[peerId].vue
@@ -20,16 +20,6 @@ const titles = computed(() => {
return typeFilter.value === 'all' ? all : all.filter(t => t.mediaType === typeFilter.value)
})
-// Paginate so only the current page's cards mount — that bounds how many TMDB
-// lookups fire at once (each card fetches its own metadata on mount).
-const PAGE_SIZE = 48
-const page = ref(1)
-const pagedTitles = computed(() => titles.value.slice((page.value - 1) * PAGE_SIZE, page.value * PAGE_SIZE))
-// Jump back to the first page whenever the filter narrows the list.
-watch(typeFilter, () => {
- page.value = 1
-})
-
function titleName(title: CatalogTitle): string {
return entryFor(title)?.data?.title ?? title.metadata?.title ?? title.displayTitle
}
@@ -119,20 +109,17 @@ async function onConfirm(payload: CatalogRequestPayload) {
-
-
-
-
-
-
-
-
-
+
+
+
diff --git a/skills-lock.json b/skills-lock.json
new file mode 100644
index 0000000..ca5c16e
--- /dev/null
+++ b/skills-lock.json
@@ -0,0 +1,11 @@
+{
+ "version": 1,
+ "skills": {
+ "tmdb-api": {
+ "source": "ranisalt/skills",
+ "sourceType": "github",
+ "skillPath": "tmdb-api/SKILL.md",
+ "computedHash": "42a19223d669b90c49ddd08e70ed4c72efaafac53f56913c9456a3fc538e6c6f"
+ }
+ }
+}
From 58fde9073b786f27f9c124407ad7b3dd6454a5c0 Mon Sep 17 00:00:00 2001
From: Roz
Date: Sat, 27 Jun 2026 19:39:11 +0200
Subject: [PATCH 15/27] feat(catalog): aggregated GET /catalog with cross-peer
grouping
---
apps/backend/src/__tests__/catalog.test.ts | 156 +++++++++---------
.../src/modules/catalog/catalog.controller.ts | 35 ++--
.../src/modules/catalog/catalog.lib.ts | 113 +++++++++----
.../src/modules/catalog/catalog.router.ts | 11 +-
4 files changed, 178 insertions(+), 137 deletions(-)
diff --git a/apps/backend/src/__tests__/catalog.test.ts b/apps/backend/src/__tests__/catalog.test.ts
index aa45ccc..22e780d 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, pickBestPerEpisode, pickBestRelease } from '../modules/catalog/catalog.lib'
+import { groupReleasesIntoUnifiedTitles, pickBestPerEpisode, pickBestRelease } from '../modules/catalog/catalog.lib'
function movie(overrides: Partial = {}): Release {
return {
@@ -27,69 +27,60 @@ function episode(overrides: Partial = {}): Release {
}
}
-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)
+describe('groupReleasesIntoUnifiedTitles', () => {
+ test('unifies the same movie across two peers into one title with per-peer buckets', () => {
+ const titles = groupReleasesIntoUnifiedTitles([
+ { peer: { id: 'p1', name: 'Alpha' }, releases: [movie({ tmdbId: 603, size: 100 })] },
+ { peer: { id: 'p2', name: 'Beta' }, releases: [movie({ tmdbId: 603, size: 200 })] },
+ ])
expect(titles).toHaveLength(1)
+ expect(titles[0]!.tmdbId).toBe(603)
expect(titles[0]!.releaseCount).toBe(2)
expect(titles[0]!.totalSize).toBe(300)
- expect(titles[0]!.key).toContain('name:')
+ expect(titles[0]!.peers).toHaveLength(2)
+
+ const alpha = titles[0]!.peers.find(p => p.id === 'p1')!
+ expect(alpha.name).toBe('Alpha')
+ expect(alpha.releaseCount).toBe(1)
+ expect(alpha.totalSize).toBe(100)
+ })
+
+ test('preserves per-release detail in each peer bucket', () => {
+ const titles = groupReleasesIntoUnifiedTitles([
+ { peer: { id: 'p1', name: 'Alpha' }, releases: [
+ episode({ id: 'ep:1', tvdbId: 1396, seriesTitle: 'Breaking Bad', season: 1, episode: 1, size: 50, quality: { resolution: 1080 } }),
+ ] },
+ ])
+
+ const bucket = titles[0]!.peers[0]!
+ expect(bucket.releases).toHaveLength(1)
+ expect(bucket.releases[0]).toMatchObject({
+ id: 'ep:1',
+ filename: 'Show.S01E01.1080p.mkv',
+ size: 50,
+ season: 1,
+ episode: 1,
+ quality: { resolution: 1080 },
+ })
})
- 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)
+ test('collapses an id-less release on one peer into the strong-id bucket from another peer', () => {
+ const titles = groupReleasesIntoUnifiedTitles([
+ { peer: { id: 'p1', name: 'Alpha' }, releases: [episode({ seriesTitle: 'Some Show', size: 50 })] },
+ { peer: { id: 'p2', name: 'Beta' }, releases: [episode({ seriesTitle: 'Some Show', tvdbId: 999, size: 60 })] },
+ ])
expect(titles).toHaveLength(1)
- expect(titles[0]!.releaseCount).toBe(2)
expect(titles[0]!.tvdbId).toBe(999)
expect(titles[0]!.key).toContain('id:999')
+ expect(titles[0]!.peers.map(p => p.id).sort()).toEqual(['p1', 'p2'])
})
- test('sorts titles by display title', () => {
- const releases: Release[] = [
- movie({ title: 'Zebra', tmdbId: 1 }),
- movie({ title: 'Apple', tmdbId: 2 }),
- ]
-
- const titles = groupReleasesIntoTitles(releases)
+ test('sorts unified titles by display title', () => {
+ const titles = groupReleasesIntoUnifiedTitles([
+ { peer: { id: 'p1', name: 'Alpha' }, releases: [movie({ title: 'Zebra', tmdbId: 1 }), movie({ title: 'Apple', tmdbId: 2 })] },
+ ])
expect(titles.map(t => t.displayTitle)).toEqual(['Apple', 'Zebra'])
})
@@ -133,51 +124,52 @@ describe('pickBestPerEpisode', () => {
})
})
-describe('catalogController', () => {
+describe('catalogController.getCatalog', () => {
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)
+ test('aggregates the same title across two initialized peers', async () => {
+ const p1 = { id: 'p1', name: 'Alpha', isInitialized: true, listReleases: async () => [movie({ tmdbId: 603, size: 100 })] }
+ const p2 = { id: 'p2', name: 'Beta', isInitialized: true, listReleases: async () => [movie({ tmdbId: 603, size: 200 })] }
+ const controller = new CatalogController(makeConnectors([p1, p2]) as any)
- const result = await controller.getPeerCatalog('peer-1')
+ const result = await controller.getCatalog()
- expect(result.peer).toEqual({ id: 'peer-1', name: 'Friend Jack' })
+ expect(result.peers).toEqual([{ id: 'p1', name: 'Alpha' }, { id: 'p2', name: 'Beta' }])
expect(result.titles).toHaveLength(1)
expect(result.titles[0]!.releaseCount).toBe(2)
+ expect(result.titles[0]!.peers).toHaveLength(2)
})
- test('throws NotFoundError for an unknown peer id', () => {
- const controller = new CatalogController(makeConnectors([]) as any)
+ test('skips a peer whose listReleases rejects but keeps the rest', async () => {
+ const broken = {
+ id: 'p1',
+ name: 'Broken',
+ isInitialized: true,
+ listReleases: async () => {
+ throw new Error('unreachable')
+ },
+ }
+ const healthy = { id: 'p2', name: 'Healthy', isInitialized: true, listReleases: async () => [movie({ tmdbId: 603 })] }
+ const controller = new CatalogController(makeConnectors([broken, healthy]) as any)
- expect(controller.getPeerCatalog('missing')).rejects.toBeInstanceOf(NotFoundError)
- })
-})
+ const result = await controller.getCatalog()
-describe('catalogController.getPeerCatalog metadata', () => {
- function makeConnectors(peers: any[]) {
- return { servers: [], peers }
- }
+ expect(result.peers).toEqual([{ id: 'p2', name: 'Healthy' }])
+ expect(result.titles).toHaveLength(1)
+ })
- 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 })],
- }
- const controller = new CatalogController(makeConnectors([peer]) as any, { getMetadata } as any)
+ test('does not query uninitialized peers', async () => {
+ const called = mock(async () => [movie({ tmdbId: 603 })])
+ const peer = { id: 'p1', name: 'Offline', isInitialized: false, listReleases: called }
+ const controller = new CatalogController(makeConnectors([peer]) as any)
- const result = await controller.getPeerCatalog('peer-1')
+ const result = await controller.getCatalog()
- expect(result.titles[0]!.metadata).toBeUndefined()
- expect(getMetadata).not.toHaveBeenCalled()
+ expect(called).not.toHaveBeenCalled()
+ expect(result.peers).toEqual([])
+ expect(result.titles).toEqual([])
})
})
diff --git a/apps/backend/src/modules/catalog/catalog.controller.ts b/apps/backend/src/modules/catalog/catalog.controller.ts
index a5291ac..d371b44 100644
--- a/apps/backend/src/modules/catalog/catalog.controller.ts
+++ b/apps/backend/src/modules/catalog/catalog.controller.ts
@@ -2,14 +2,15 @@ import type { ArrServerConnector } from '../../lib/servers/arr/base'
import type { PeerConnector } from '../../lib/servers/peer'
import type { TmdbClient, TmdbMediaType, TmdbMetadata } from '../../lib/tmdb/client'
import type { DownloadsService } from '../downloads/downloads.service'
-import type { CatalogTitle } from './catalog.lib'
+import type { PeerReleases, UnifiedCatalogTitle } from './catalog.lib'
import { BadRequestError } from '../../lib/errors/BadRequestError'
import { NotFoundError } from '../../lib/errors/NotFoundError'
-import { groupReleasesIntoTitles, pickBestPerEpisode, pickBestRelease } from './catalog.lib'
+import { groupReleasesIntoUnifiedTitles, pickBestPerEpisode, pickBestRelease } from './catalog.lib'
-export interface PeerCatalogResponse {
- peer: { id: string, name: string }
- titles: CatalogTitle[]
+export interface CatalogResponse {
+ // Peers that responded and contributed to this catalog.
+ peers: Array<{ id: string, name: string }>
+ titles: UnifiedCatalogTitle[]
}
export interface TmdbStatus {
@@ -49,15 +50,23 @@ export class CatalogController {
return peer
}
- async getPeerCatalog(peerId: string): Promise {
- const peer = this.requirePeer(peerId)
- const releases = await peer.listReleases()
- // 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.
+ async getCatalog(): Promise {
+ // Fan out to every initialized peer. A peer that can't serve its catalog is
+ // skipped (partial results) rather than failing the whole aggregate.
+ const peers = this.connectors.peers.filter(p => p.isInitialized)
+ const results = await Promise.all(peers.map(async (peer): Promise => {
+ try {
+ const releases = await peer.listReleases()
+ return { peer: { id: peer.id, name: peer.name }, releases }
+ }
+ catch {
+ return null
+ }
+ }))
+ const responded = results.filter((r): r is PeerReleases => r !== null)
return {
- peer: { id: peer.id, name: peer.name },
- titles: groupReleasesIntoTitles(releases),
+ peers: responded.map(r => r.peer),
+ titles: groupReleasesIntoUnifiedTitles(responded),
}
}
diff --git a/apps/backend/src/modules/catalog/catalog.lib.ts b/apps/backend/src/modules/catalog/catalog.lib.ts
index 87d7be1..9fa8837 100644
--- a/apps/backend/src/modules/catalog/catalog.lib.ts
+++ b/apps/backend/src/modules/catalog/catalog.lib.ts
@@ -2,18 +2,37 @@ import type { Release } from '../../lib/release'
import type { TmdbMetadata } from '../../lib/tmdb/client'
import { ReleaseCategory } from '../../lib/release'
-export interface CatalogTitle {
- // Stable grouping key, also used as the client-side list key.
+/** Per-release detail kept for a title within a single peer's bucket. */
+export type CatalogRelease = Pick
+
+/** One peer's contribution to a unified title. */
+export interface CatalogTitlePeer {
+ id: string
+ name: string
+ releaseCount: number
+ totalSize: number
+ releases: CatalogRelease[]
+}
+
+/** A title unified across every peer that carries it. */
+export interface UnifiedCatalogTitle {
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
+ // Totals across every peer that carries this title.
releaseCount: number
totalSize: number
metadata?: TmdbMetadata | null
+ peers: CatalogTitlePeer[]
+}
+
+/** A peer's flat release list, tagged with the peer it came from. */
+export interface PeerReleases {
+ peer: { id: string, name: string }
+ releases: Release[]
}
function mediaTypeOf(release: Release): 'movie' | 'tv' {
@@ -37,45 +56,71 @@ function nameKey(release: Release): string {
}
/**
- * Group a peer's flat release list into one entry per movie/series.
+ * Fold every peer's releases into one title 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.
+ * Reuses the single-peer grouping keys (strong id key, else the name->id alias from
+ * pass 1, else the name key) but builds the alias map across ALL peers' releases so a
+ * title that is id-less on one peer and id-bearing on another still collapses. Each
+ * title tracks a per-peer bucket with that peer's release detail.
*/
-export function groupReleasesIntoTitles(releases: Release[]): CatalogTitle[] {
+export function groupReleasesIntoUnifiedTitles(peerReleases: PeerReleases[]): UnifiedCatalogTitle[] {
const nameToStrongKey = new Map()
- for (const release of releases) {
- const sk = strongKey(release)
- if (sk && !nameToStrongKey.has(nameKey(release)))
- nameToStrongKey.set(nameKey(release), sk)
+ for (const { releases } of peerReleases) {
+ 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 keyOf = (release: Release): string =>
+ strongKey(release) ?? nameToStrongKey.get(nameKey(release)) ?? nameKey(release)
+
+ const byKey = new Map()
+ for (const { peer, releases } of peerReleases) {
+ for (const release of releases) {
+ const key = keyOf(release)
+
+ let title = byKey.get(key)
+ if (!title) {
+ title = {
+ key,
+ mediaType: mediaTypeOf(release),
+ tmdbId: release.tmdbId,
+ imdbId: release.imdbId,
+ tvdbId: release.tvdbId,
+ displayTitle: mediaTypeOf(release) === 'tv' ? (release.seriesTitle ?? release.title) : release.title,
+ releaseCount: 0,
+ totalSize: 0,
+ peers: [],
+ }
+ byKey.set(key, title)
+ }
- 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
- }
+ title.tmdbId ??= release.tmdbId
+ title.imdbId ??= release.imdbId
+ title.tvdbId ??= release.tvdbId
+ title.releaseCount += 1
+ title.totalSize += release.size
- 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,
- })
+ let bucket = title.peers.find(p => p.id === peer.id)
+ if (!bucket) {
+ bucket = { id: peer.id, name: peer.name, releaseCount: 0, totalSize: 0, releases: [] }
+ title.peers.push(bucket)
+ }
+ bucket.releaseCount += 1
+ bucket.totalSize += release.size
+ bucket.releases.push({
+ id: release.id,
+ title: release.title,
+ filename: release.filename,
+ size: release.size,
+ quality: release.quality,
+ season: release.season,
+ episode: release.episode,
+ })
+ }
}
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
index faa2c73..1df3011 100644
--- a/apps/backend/src/modules/catalog/catalog.router.ts
+++ b/apps/backend/src/modules/catalog/catalog.router.ts
@@ -3,8 +3,6 @@ import { Hono } from 'hono'
import { validator as zValidator } from 'hono-openapi'
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(),
@@ -22,7 +20,7 @@ const requestBody = z.object({
export function getCatalogRouter(controller: CatalogController) {
const app = new Hono()
- // Register the static path before `/:peerId` so "tmdb" isn't captured as a peerId.
+ // Register the static path before any future dynamic segment.
app.get('/tmdb/status', async c => c.json(await controller.getTmdbStatus()))
// Per-title TMDB lookup the catalog grid calls once per visible card.
@@ -31,17 +29,14 @@ export function getCatalogRouter(controller: CatalogController) {
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() }))
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))
- })
+ // Aggregated catalog across all initialized peers.
+ app.get('/', async c => c.json(await controller.getCatalog()))
return app
}
From 0db27b95e81be98ca53e8b7302cdfcea895f10cd Mon Sep 17 00:00:00 2001
From: Roz
Date: Sat, 27 Jun 2026 19:45:05 +0200
Subject: [PATCH 16/27] feat(ui): unified Peer Catalog page with peer picker
---
apps/ui/app/components/CatalogTitleDetail.vue | 5 +-
.../app/components/DownloadRequestModal.vue | 22 ++-
apps/ui/app/components/PeersSection.vue | 1 -
apps/ui/app/layouts/default.vue | 1 +
apps/ui/app/pages/catalog/index.vue | 135 ++++++++++++++++++
apps/ui/app/pages/index.vue | 4 +-
apps/ui/app/types/management.ts | 26 ++++
7 files changed, 186 insertions(+), 8 deletions(-)
create mode 100644 apps/ui/app/pages/catalog/index.vue
diff --git a/apps/ui/app/components/CatalogTitleDetail.vue b/apps/ui/app/components/CatalogTitleDetail.vue
index 3ab8ee9..5c2e41b 100644
--- a/apps/ui/app/components/CatalogTitleDetail.vue
+++ b/apps/ui/app/components/CatalogTitleDetail.vue
@@ -51,10 +51,11 @@ const canRequest = computed(() => props.title.mediaType === 'tv'
- {{ title.releaseCount }} release{{ title.releaseCount === 1 ? '' : 's' }} on this peer · {{ formatBytes(title.totalSize) }}
+ {{ title.releaseCount }} release{{ title.releaseCount === 1 ? '' : 's' }} across
+ {{ title.peers.length }} peer{{ title.peers.length === 1 ? '' : 's' }} · {{ formatBytes(title.totalSize) }}
- Downloads every available episode from this peer.
+ Downloads every available episode from the chosen peer.
diff --git a/apps/ui/app/components/DownloadRequestModal.vue b/apps/ui/app/components/DownloadRequestModal.vue
index a9434ed..703baba 100644
--- a/apps/ui/app/components/DownloadRequestModal.vue
+++ b/apps/ui/app/components/DownloadRequestModal.vue
@@ -17,11 +17,19 @@ 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 peers = computed(() => props.title?.peers ?? [])
+const peerId = ref(undefined)
const serverId = ref(undefined)
const rootFolderPath = ref(undefined)
const server = computed(() => candidates.value.find(s => s.id === serverId.value) ?? null)
+// Reset to the first peer whenever the title changes, so a multi-peer title always
+// opens on its first peer regardless of a prior selection (matches the AC).
+watch(() => props.title?.key, () => {
+ peerId.value = peers.value[0]?.id
+}, { immediate: true })
+
watch(candidates, (list) => {
if (list.length && !list.some(s => s.id === serverId.value))
serverId.value = list[0]!.id
@@ -31,12 +39,12 @@ watch(server, (s) => {
rootFolderPath.value = s?.rootFolders[0]?.path ?? undefined
}, { immediate: true })
-const canSubmit = computed(() => Boolean(serverId.value && rootFolderPath.value))
+const canSubmit = computed(() => Boolean(peerId.value && serverId.value && rootFolderPath.value))
function onConfirm() {
- if (!canSubmit.value || !serverId.value || !rootFolderPath.value)
+ if (!canSubmit.value || !peerId.value || !serverId.value || !rootFolderPath.value)
return
- emit('confirm', { serverId: serverId.value, rootFolderPath: rootFolderPath.value })
+ emit('confirm', { peerId: peerId.value, serverId: serverId.value, rootFolderPath: rootFolderPath.value })
}
@@ -53,6 +61,14 @@ function onConfirm() {
+
+
+
+
diff --git a/apps/ui/app/layouts/default.vue b/apps/ui/app/layouts/default.vue
index c98fd42..4190eb3 100644
--- a/apps/ui/app/layouts/default.vue
+++ b/apps/ui/app/layouts/default.vue
@@ -8,6 +8,7 @@ const { state, logout } = useAuth()
const items: NavigationMenuItem[] = [
{ label: 'Dashboard', icon: 'i-ph-gauge', to: '/', exact: true },
{ label: 'Downloads', icon: 'i-ph-download-simple', to: '/downloads' },
+ { label: 'Peer Catalog', icon: 'i-ph-film-reel', to: '/catalog' },
{ label: 'Settings', icon: 'i-ph-gear-six', to: '/settings' },
]
diff --git a/apps/ui/app/pages/catalog/index.vue b/apps/ui/app/pages/catalog/index.vue
new file mode 100644
index 0000000..6e0d212
--- /dev/null
+++ b/apps/ui/app/pages/catalog/index.vue
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading...
+
+
+
+
+
+
+ Nothing to show here.
+
+
+
+
+
+
+
+
+
+
+ { if (!open) selected = null }"
+ >
+
+
+
+
+
+
+
diff --git a/apps/ui/app/pages/index.vue b/apps/ui/app/pages/index.vue
index 050d67e..11425ef 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 b6a4a27..fa9f803 100644
--- a/apps/ui/app/types/management.ts
+++ b/apps/ui/app/types/management.ts
@@ -56,6 +56,24 @@ export interface TmdbMetadata {
genres: string[]
}
+export interface CatalogRelease {
+ id: string
+ title: string
+ filename: string
+ size: number
+ quality?: { name?: string, source?: string, resolution?: number }
+ season?: number
+ episode?: number
+}
+
+export interface CatalogTitlePeer {
+ id: string
+ name: string
+ releaseCount: number
+ totalSize: number
+ releases: CatalogRelease[]
+}
+
export interface CatalogTitle {
key: string
mediaType: 'movie' | 'tv'
@@ -63,9 +81,11 @@ export interface CatalogTitle {
imdbId?: string
tvdbId?: number
displayTitle: string
+ // Totals across every peer that carries this title.
releaseCount: number
totalSize: number
metadata?: TmdbMetadata | null
+ peers: CatalogTitlePeer[]
}
export interface PeerCatalogResponse {
@@ -73,6 +93,11 @@ export interface PeerCatalogResponse {
titles: CatalogTitle[]
}
+export interface CatalogResponse {
+ peers: Array<{ id: string, name: string }>
+ titles: CatalogTitle[]
+}
+
export interface RequestServerOption {
id: string
name: string
@@ -82,6 +107,7 @@ export interface RequestServerOption {
}
export interface CatalogRequestPayload {
+ peerId: string
serverId: string
rootFolderPath: string
}
From c2e9676813e9a05e75c4e79d60e448022852ebb6 Mon Sep 17 00:00:00 2001
From: Roz
Date: Sat, 27 Jun 2026 19:46:55 +0200
Subject: [PATCH 17/27] refactor(ui): remove per-peer catalog page and dead
type
---
apps/ui/app/pages/catalog/[peerId].vue | 143 -------------------------
apps/ui/app/types/management.ts | 5 -
2 files changed, 148 deletions(-)
delete mode 100644 apps/ui/app/pages/catalog/[peerId].vue
diff --git a/apps/ui/app/pages/catalog/[peerId].vue b/apps/ui/app/pages/catalog/[peerId].vue
deleted file mode 100644
index 06018a2..0000000
--- a/apps/ui/app/pages/catalog/[peerId].vue
+++ /dev/null
@@ -1,143 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Loading…
-
-
-
-
-
-
- Nothing to show here.
-
-
-
-
-
-
-
-
-
-
- { if (!open) selected = null }"
- >
-
-
-
-
-
-
-
diff --git a/apps/ui/app/types/management.ts b/apps/ui/app/types/management.ts
index fa9f803..9749c86 100644
--- a/apps/ui/app/types/management.ts
+++ b/apps/ui/app/types/management.ts
@@ -88,11 +88,6 @@ export interface CatalogTitle {
peers: CatalogTitlePeer[]
}
-export interface PeerCatalogResponse {
- peer: { id: string, name: string }
- titles: CatalogTitle[]
-}
-
export interface CatalogResponse {
peers: Array<{ id: string, name: string }>
titles: CatalogTitle[]
From b5cd7c5c370d801504f88648a39513093b3894de Mon Sep 17 00:00:00 2001
From: Roz
Date: Sun, 28 Jun 2026 09:34:39 +0200
Subject: [PATCH 18/27] feat(ui): add View in Logflix button to catalog title
detail
---
apps/ui/app/components/CatalogTitleDetail.vue | 18 ++++++++++++++++++
apps/ui/public/logflix.svg | 9 +++++++++
2 files changed, 27 insertions(+)
create mode 100644 apps/ui/public/logflix.svg
diff --git a/apps/ui/app/components/CatalogTitleDetail.vue b/apps/ui/app/components/CatalogTitleDetail.vue
index 5c2e41b..d9ab2b6 100644
--- a/apps/ui/app/components/CatalogTitleDetail.vue
+++ b/apps/ui/app/components/CatalogTitleDetail.vue
@@ -20,6 +20,10 @@ onMounted(() => load(props.title))
const canRequest = computed(() => props.title.mediaType === 'tv'
? props.title.tvdbId != null
: props.title.tmdbId != null)
+// Logflix keys both movies and TV off the TMDB id (e.g. /movie/1301421, /tv/246461).
+const logflixUrl = computed(() => props.title.tmdbId == null
+ ? null
+ : `https://logflix.eu/${props.title.mediaType}/${props.title.tmdbId}`)
@@ -67,5 +71,19 @@ const canRequest = computed(() => props.title.mediaType === 'tv'
@click="emit('download')"
/>
+
+
+
+
+
+
diff --git a/apps/ui/public/logflix.svg b/apps/ui/public/logflix.svg
new file mode 100644
index 0000000..d1d6b00
--- /dev/null
+++ b/apps/ui/public/logflix.svg
@@ -0,0 +1,9 @@
+
From d24199bdcdecb4e3bbcbd9eb3003c15e9b0908a7 Mon Sep 17 00:00:00 2001
From: Roz
Date: Sun, 28 Jun 2026 10:26:33 +0200
Subject: [PATCH 19/27] feat(ui): add About section with TMDB attribution and
app version
Adds a Settings > About section: the jack mark plus version (sourced from
package.json via runtimeConfig.public.appVersion) and the TMDB-required
attribution notice and logo, kept visually subordinate to the app mark.
---
apps/ui/app/pages/settings.vue | 36 ++++++++++++++++++++++++++++++++++
apps/ui/nuxt.config.ts | 8 ++++++++
apps/ui/package.json | 1 +
apps/ui/public/tmdb.svg | 1 +
4 files changed, 46 insertions(+)
create mode 100644 apps/ui/public/tmdb.svg
diff --git a/apps/ui/app/pages/settings.vue b/apps/ui/app/pages/settings.vue
index a01697c..856c4b3 100644
--- a/apps/ui/app/pages/settings.vue
+++ b/apps/ui/app/pages/settings.vue
@@ -3,6 +3,7 @@ import type { BadgeProps } from '@nuxt/ui'
import type { ApiKey, ApiKeyInput, CreatedApiKey, JackConfig } from '~/types/management'
const { request, extractError } = useManagement()
+const version = useRuntimeConfig().public.appVersion
const { data, pending, error, refresh } = await useAsyncData('api-keys', () =>
request('api-keys'))
@@ -272,6 +273,41 @@ async function confirmRevoke() {
+
+
+
+
+
+
+
+
+
+
+
+ jack
+ v{{ version }}
+
+
+ Self-hosted *arr peer bridge
+
+
+
+
+
+
+
+
+

+
+ This product uses the TMDB API but is not endorsed or certified by TMDB.
+
+
+
+
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 @@
+
\ 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' }"
/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Also available from:
+
+
+ -
+
+ {{ p.name }}'s library
+
+
+
+
+
+
+
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: [] }>()
-
+
{{ name }}
diff --git a/apps/ui/app/composables/usePeerColors.ts b/apps/ui/app/composables/usePeerColors.ts
new file mode 100644
index 0000000..6840dae
--- /dev/null
+++ b/apps/ui/app/composables/usePeerColors.ts
@@ -0,0 +1,21 @@
+import type { InjectionKey, Ref } from 'vue'
+import type { PeerColor } from '~/utils/peerColor'
+
+const PEER_COLORS: InjectionKey
[>> = Symbol('peer-colors')
+
+/** Provided by a container that knows the full peer set (e.g. the catalog page). */
+export function providePeerColors(colors: Ref]