diff --git a/apps/api/middleware/security.ts b/apps/api/middleware/security.ts
index a9e61370..9b313224 100644
--- a/apps/api/middleware/security.ts
+++ b/apps/api/middleware/security.ts
@@ -4,7 +4,12 @@
* Implements request validation, suspicious activity detection, and security headers
*/
-import { detectDDoS, isBlacklisted, getClientIP } from '~~/utils/rateLimit';
+import {
+ detectDDoS,
+ isBlacklisted,
+ getClientIP,
+ isInternalOrigin,
+} from '~~/utils/rateLimit';
import { eq } from 'drizzle-orm';
import { db } from '@trackarr/db';
import { users, webauthnCredentials } from '@trackarr/db/schema';
@@ -173,6 +178,24 @@ export default defineEventHandler(async (event) => {
const ip = getClientIP(event);
const userAgent = getHeader(event, 'user-agent') || '';
+ /*
+ * Notre propre rendu serveur n'est pas un client.
+ *
+ * Le compteur anti-abus est par adresse, et le conteneur web n'en a qu'une
+ * pour tout le site : QUATRE requêtes par page vue s'y accumulaient
+ * (mesuré), donc vingt-cinq pages en dix secondes suffisaient à bannir
+ * l'instance entière. Le filtre est décrit dans `isInternalOrigin` — pair
+ * socket privé ET aucun en-tête de transfert, deux conditions qu'un client
+ * d'Internet ne peut pas réunir.
+ *
+ * Ce que ça n'exempte PAS : le filtre d'agent, la validation de chemin et de
+ * paramètres, les bannissements d'adresse, les limites par route et toute
+ * la suite de l'authentification. Uniquement le compteur grossier et la
+ * liste noire qu'il alimente — c'est-à-dire exactement ce qui n'a aucun sens
+ * pour une instance qui se parle à elle-même.
+ */
+ const interne = isInternalOrigin(event);
+
// Order matters. Everything below is sorted by cost, cheapest first, so a
// flood is dropped as early as possible:
//
@@ -208,15 +231,16 @@ export default defineEventHandler(async (event) => {
}
// 2. Redis — temporary blacklist, then the abuse counter itself.
- if (await isBlacklisted(ip)) {
+ if (!interne && (await isBlacklisted(ip))) {
console.warn(`[Security] Blocked blacklisted IP: ${ip.slice(0, 8)}...`);
throw createError({ statusCode: 403, message: 'Access denied' });
}
if (
- path.startsWith('/api/') ||
- path.includes('/announce') ||
- path.includes('/scrape')
+ !interne &&
+ (path.startsWith('/api/') ||
+ path.includes('/announce') ||
+ path.includes('/scrape'))
) {
await detectDDoS(event);
}
diff --git a/apps/api/plugins/metadata-warmer.ts b/apps/api/plugins/metadata-warmer.ts
new file mode 100644
index 00000000..c19e28ea
--- /dev/null
+++ b/apps/api/plugins/metadata-warmer.ts
@@ -0,0 +1,114 @@
+import { db, schema } from '@trackarr/db';
+import { and, desc, eq, isNotNull, or } from 'drizzle-orm';
+import { redis } from '~~/utils/server';
+import { withCronLock } from '~~/utils/cronLock';
+import {
+ isMetadataEnabled,
+ isSourceEnabled,
+ lookupMetadata,
+ normalizeSourceId,
+ type LookupSource,
+} from '~~/utils/metadata';
+import type { MediaTypeHint } from '~~/utils/metadata/types';
+
+/**
+ * Préchauffer le cache des œuvres.
+ *
+ * Le catalogue ne lit que le cache des métadonnées : une œuvre dont personne
+ * n'a ouvert la fiche s'affiche par son nom de fichier, sans affiche ni titre.
+ * Cette tâche comble le trou à petite cadence — UNE recherche amont par tic,
+ * vingt secondes entre deux par défaut — en partant des torrents les plus
+ * récents. Ce qu'elle a tenté (trouvé ou non) est marqué sept jours dans un
+ * ensemble Redis, pour ne pas retaper à la même porte.
+ *
+ * Elle respecte les mêmes gardes que la fiche : pas de fournisseur configuré,
+ * pas d'appel ; une panne amont est déjà mise en cache court par `guarded`.
+ */
+const INTERVAL_MS = Math.max(5000, parseInt(process.env.METADATA_WARM_INTERVAL_MS || '20000', 10) || 20000);
+const FIRST_RUN_DELAY_MS = 45_000;
+const CANDIDATES = 300;
+/*
+ * Un marqueur PAR ŒUVRE, et non un ensemble.
+ *
+ * `EXPIRE` porte sur la clé entière : avec un `SADD` suivi d'un `EXPIRE`, chaque
+ * nouveau marquage repoussait le délai de TOUT l'ensemble. Le collecteur
+ * marquant une œuvre toutes les vingt secondes, l'ensemble n'expirait jamais et
+ * une œuvre en échec n'était jamais reprise — le défaut qu'on croyait corriger.
+ */
+const MARK_PREFIX = 'meta:warm:v2:';
+const markKey = (key: string) => `${MARK_PREFIX}${key}`;
+const DONE_TTL_S = 7 * 86400;
+/** Les œuvres tentées sans réponse (amont en panne, dépassement) : on y revient dans l'heure, pas dans la semaine. */
+const RETRY_TTL_S = 3600;
+
+type Ref = { source: LookupSource; id: string; hint: MediaTypeHint | undefined };
+
+function refOf(row: { tmdbId: string | null; igdbId: string | null; openlibraryId: string | null }): Ref | null {
+ if (row.tmdbId) {
+ const hint = row.tmdbId.startsWith('tv/') ? 'tv' : row.tmdbId.startsWith('movie/') ? 'movie' : undefined;
+ return { source: 'tmdb', id: row.tmdbId, hint };
+ }
+ if (row.igdbId) return { source: 'igdb', id: row.igdbId, hint: 'game' };
+ if (row.openlibraryId) return { source: 'openlibrary', id: row.openlibraryId, hint: 'book' };
+ return null;
+}
+
+async function tick(): Promise {
+ if (!isMetadataEnabled()) return;
+ const rows = await db
+ .select({ tmdbId: schema.torrents.tmdbId, igdbId: schema.torrents.igdbId, openlibraryId: schema.torrents.openlibraryId })
+ .from(schema.torrents)
+ .where(
+ and(
+ eq(schema.torrents.moderationStatus, 'accepted'),
+ eq(schema.torrents.isActive, true),
+ or(isNotNull(schema.torrents.tmdbId), isNotNull(schema.torrents.igdbId), isNotNull(schema.torrents.openlibraryId)),
+ ),
+ )
+ .orderBy(desc(schema.torrents.createdAt))
+ .limit(CANDIDATES);
+ const candidates = rows
+ .map((row) => refOf(row))
+ .filter((ref): ref is Ref => !!ref)
+ .map((ref) => ({ ref, key: `${ref.source}:${ref.id}` }));
+ if (candidates.length === 0) return;
+ // Une lecture pour toute la fenêtre : chaque marqueur porte son propre délai.
+ const marks = await redis.mget(...candidates.map((c) => markKey(c.key)));
+ const mark = (key: string, ttl: number) => redis.set(markKey(key), '1', 'EX', ttl);
+ for (const [i, { ref, key }] of candidates.entries()) {
+ if (marks[i]) continue;
+ if (!isSourceEnabled(ref.source)) {
+ await mark(key, DONE_TTL_S);
+ continue;
+ }
+ const canonical = await normalizeSourceId(ref.source, ref.id);
+ if (!canonical) {
+ await mark(key, DONE_TTL_S);
+ continue;
+ }
+ // « Fait » seulement sur une réponse ; une panne amont marquait l'œuvre
+ // faite pour sept jours, et rien ne la redemandait avant qu'un membre
+ // n'ouvre sa fiche. `lookupMetadata` rend null sur amont indisponible
+ // comme sur 404 : dans le doute, on repasse dans l'heure.
+ let meta: unknown = null;
+ try {
+ meta = await lookupMetadata(ref.source, canonical, ref.hint);
+ } catch (err) {
+ console.warn('[MetadataWarmer] lookup failed for', key, ':', (err as Error).message);
+ }
+ await mark(key, meta ? DONE_TTL_S : RETRY_TTL_S);
+ return; // une œuvre par tic : la cadence est la protection des quotas
+ }
+}
+
+export default defineNitroPlugin(() => {
+ const run = async () => {
+ try {
+ await withCronLock('metadata_warmer:lock', 60, tick);
+ } catch (err) {
+ console.warn('[MetadataWarmer] tick failed:', (err as Error).message);
+ }
+ };
+ setTimeout(run, FIRST_RUN_DELAY_MS).unref?.();
+ setInterval(run, INTERVAL_MS).unref?.();
+});
diff --git a/apps/api/plugins/stats-collector.ts b/apps/api/plugins/stats-collector.ts
index 37d8eaba..12951989 100644
--- a/apps/api/plugins/stats-collector.ts
+++ b/apps/api/plugins/stats-collector.ts
@@ -115,6 +115,28 @@ async function writeTorrentStats(
SET seeders = 0, leechers = 0, updated_at = now()
WHERE (seeders <> 0 OR leechers <> 0)
AND updated_at < ${passStartedAt}::timestamptz`);
+
+ // L'historique : un point par torrent et par jour, la dernière valeur du
+ // jour l'emporte. Lu depuis `torrent_stats` et non depuis `perTorrent`,
+ // pour que les rangées que le balayage ci-dessus vient de METTRE À ZÉRO
+ // soient enregistrées elles aussi — un essaim qui meurt est précisément
+ // ce que la courbe doit montrer. `updated_at >= passStartedAt` borne la
+ // copie à ce que cette passe a touché.
+ //
+ // Seulement après une passe COMPLÈTE : un balayage tronqué a vu un
+ // sous-ensemble arbitraire, et un jour manquant vaut mieux qu'un jour
+ // faux.
+ await db.execute(sql`
+ INSERT INTO torrent_stats_history (info_hash, day, seeders, leechers)
+ SELECT info_hash, current_date, seeders, leechers
+ FROM torrent_stats
+ WHERE updated_at >= ${passStartedAt}::timestamptz
+ ON CONFLICT (info_hash, day) DO UPDATE
+ SET seeders = excluded.seeders,
+ leechers = excluded.leechers`);
+ await db.execute(sql`
+ DELETE FROM torrent_stats_history
+ WHERE day < current_date - 30`);
}
} catch (err) {
// A stale snapshot degrades a range on a collapsed row; it must never
diff --git a/apps/api/routes/api/admin/categories/[id]/merge.post.ts b/apps/api/routes/api/admin/categories/[id]/merge.post.ts
new file mode 100644
index 00000000..b0d68fa6
--- /dev/null
+++ b/apps/api/routes/api/admin/categories/[id]/merge.post.ts
@@ -0,0 +1,98 @@
+import { eq } from 'drizzle-orm';
+import { z } from 'zod';
+import { db, schema } from '@trackarr/db';
+import { requireAdminSession } from '~~/utils/adminAuth';
+import { validateBody } from '~~/utils/schemas';
+import { auditDetail } from '~~/utils/audit';
+
+/**
+ * POST /api/admin/categories/:id/merge — fondre une catégorie dans une autre.
+ *
+ * Un import laisse « Films » et « Movies », « Jeux » et « Games » : deux
+ * facettes pour une seule chose, et un catalogue qui compte double. Tout ce
+ * qui pointait la source pointe la cible — torrents, sous-catégories,
+ * recherches enregistrées, demandes, motifs des règles d'envoi, table de
+ * correspondance fédérée — puis la source disparaît. Une transaction : pas de
+ * catalogue à moitié déplacé.
+ */
+const bodySchema = z.object({ into: z.string().uuid() });
+
+export default defineEventHandler(async (event) => {
+ await requireAdminSession(event);
+ const id = getRouterParam(event, 'id');
+ if (!id || !z.string().uuid().safeParse(id).success) throw createError({ statusCode: 400, message: 'Category ID is required' });
+ const { into } = await validateBody(event, bodySchema);
+ if (into === id) throw createError({ statusCode: 400, message: 'A category cannot be merged into itself' });
+
+ const [source, target] = await Promise.all([
+ db.query.categories.findFirst({ where: eq(schema.categories.id, id) }),
+ db.query.categories.findFirst({ where: eq(schema.categories.id, into) }),
+ ]);
+ if (!source || !target) throw createError({ statusCode: 404, message: 'Category not found' });
+ if (target.parentId === id) {
+ throw createError({ statusCode: 400, message: 'The target is a child of the source; merge the other way round' });
+ }
+ // Le drapeau adulte suit la catégorie : fondre une catégorie adulte dans une
+ // catégorie ordinaire montrerait ses torrents à qui a coupé ce contenu.
+ if (source.isAdult !== target.isAdult) {
+ throw createError({ statusCode: 400, message: 'The adult flag differs between the two categories' });
+ }
+ // La cible ne descend pas de la source (à toute profondeur : sinon un cycle),
+ // et une source qui a des enfants ne peut fondre que dans une racine, pour
+ // que l'arbre garde ses deux niveaux — le listing ne déplie qu'un niveau.
+ const children = await db.query.categories.findMany({ where: eq(schema.categories.parentId, id), columns: { id: true } });
+ if (children.length > 0 && target.parentId) {
+ throw createError({ statusCode: 400, message: 'A category with sub-categories can only be merged into a root category' });
+ }
+ // Toute la chaîne, jusqu'à la racine — un compteur de bonds laissait passer
+ // un arbre plus profond que la limite. Le jeu des visités arrête un cycle
+ // préexistant sans borne arbitraire.
+ const walked = new Set();
+ for (let cursor = target.parentId; cursor && !walked.has(cursor); ) {
+ if (cursor === id) throw createError({ statusCode: 400, message: 'The target descends from the source' });
+ walked.add(cursor);
+ const parent = await db.query.categories.findFirst({ where: eq(schema.categories.id, cursor), columns: { parentId: true } });
+ cursor = parent?.parentId ?? null;
+ }
+
+ const moved = await db.transaction(async (tx) => {
+ const torrents = await tx
+ .update(schema.torrents)
+ .set({ categoryId: into })
+ .where(eq(schema.torrents.categoryId, id))
+ .returning({ id: schema.torrents.id });
+ await tx.update(schema.categories).set({ parentId: into }).where(eq(schema.categories.parentId, id));
+ await tx.update(schema.savedSearches).set({ categoryId: into }).where(eq(schema.savedSearches.categoryId, id));
+ await tx.update(schema.uploadRequests).set({ categoryId: into }).where(eq(schema.uploadRequests.categoryId, id));
+ // Un motif d'envoi par catégorie (clé primaire) : si la cible a déjà le
+ // sien, il l'emporte et celui de la source part avec elle ; sinon il suit.
+ const [targetPattern] = await tx
+ .select({ categoryId: schema.uploadRuleCategoryPatterns.categoryId })
+ .from(schema.uploadRuleCategoryPatterns)
+ .where(eq(schema.uploadRuleCategoryPatterns.categoryId, into))
+ .limit(1);
+ if (targetPattern) {
+ await tx.delete(schema.uploadRuleCategoryPatterns).where(eq(schema.uploadRuleCategoryPatterns.categoryId, id));
+ } else {
+ await tx
+ .update(schema.uploadRuleCategoryPatterns)
+ .set({ categoryId: into })
+ .where(eq(schema.uploadRuleCategoryPatterns.categoryId, id));
+ }
+ await tx
+ .update(schema.remoteCategoryMap)
+ .set({ localCategoryId: into })
+ .where(eq(schema.remoteCategoryMap.localCategoryId, id));
+ await tx.delete(schema.categories).where(eq(schema.categories.id, id));
+ return torrents.length;
+ });
+
+ invalidateAdultCategoryCache();
+ auditDetail(event, {
+ action: 'categories.merge',
+ targetType: 'category',
+ targetId: id,
+ changes: { from: source.name, into: target.name, torrentsMoved: moved },
+ });
+ return { success: true, moved };
+});
diff --git a/apps/api/routes/api/admin/search-misses/index.delete.ts b/apps/api/routes/api/admin/search-misses/index.delete.ts
new file mode 100644
index 00000000..5358185e
--- /dev/null
+++ b/apps/api/routes/api/admin/search-misses/index.delete.ts
@@ -0,0 +1,30 @@
+import { eq } from 'drizzle-orm';
+import { z } from 'zod';
+import { db, schema } from '@trackarr/db';
+import { requireAdminSession } from '~~/utils/adminAuth';
+import { auditDetail } from '~~/utils/audit';
+
+/**
+ * DELETE /api/admin/search-misses — effacer une ligne, ou tout.
+ *
+ * Une recherche qu'on a traitée (la release est arrivée, ou n'existe pas)
+ * sort de la liste ; sans corps, la liste repart de zéro.
+ */
+const bodySchema = z.object({ query: z.string().trim().min(1).max(200).optional() });
+
+export default defineEventHandler(async (event) => {
+ await requireAdminSession(event);
+ const raw = await readBody(event).catch(() => ({}));
+ const parsed = bodySchema.safeParse(raw ?? {});
+ if (!parsed.success) throw createError({ statusCode: 400, message: 'query: 1 to 200 characters' });
+ const body = parsed.data;
+ const deleted = body.query
+ ? await db.delete(schema.searchMisses).where(eq(schema.searchMisses.query, body.query.toLowerCase())).returning({ q: schema.searchMisses.query })
+ : await db.delete(schema.searchMisses).returning({ q: schema.searchMisses.query });
+ auditDetail(event, {
+ action: 'search_misses.clear',
+ targetType: 'search_misses',
+ changes: { query: body.query ?? '*', deleted: deleted.length },
+ });
+ return { success: true, deleted: deleted.length };
+});
diff --git a/apps/api/routes/api/admin/search-misses/index.get.ts b/apps/api/routes/api/admin/search-misses/index.get.ts
new file mode 100644
index 00000000..3dbc7f21
--- /dev/null
+++ b/apps/api/routes/api/admin/search-misses/index.get.ts
@@ -0,0 +1,26 @@
+import { desc } from 'drizzle-orm';
+import { z } from 'zod';
+import { db, schema } from '@trackarr/db';
+import { requireAdminSession } from '~~/utils/adminAuth';
+import { validateQuery } from '~~/utils/schemas';
+
+/**
+ * GET /api/admin/search-misses — ce que les membres cherchent en vain.
+ *
+ * Les plus fréquentes d'abord, puis les plus récentes : c'est une liste
+ * d'acquisition, et le haut de la liste est ce qui manque le plus.
+ */
+const querySchema = z.object({
+ limit: z.coerce.number().int().min(1).max(500).default(100),
+});
+
+export default defineEventHandler(async (event) => {
+ await requireAdminSession(event);
+ const { limit } = validateQuery(event, querySchema);
+ const items = await db
+ .select()
+ .from(schema.searchMisses)
+ .orderBy(desc(schema.searchMisses.count), desc(schema.searchMisses.lastAt))
+ .limit(limit);
+ return { items };
+});
diff --git a/apps/api/routes/api/admin/settings.get.ts b/apps/api/routes/api/admin/settings.get.ts
index 113577b9..d28effb2 100644
--- a/apps/api/routes/api/admin/settings.get.ts
+++ b/apps/api/routes/api/admin/settings.get.ts
@@ -1,6 +1,10 @@
import { requireAdminSession } from '~~/utils/adminAuth';
import {
getSetting,
+ getCatalogueSettings,
+ isHnrEnabled,
+ getHnrRequiredSeedTime,
+ getHnrGracePeriod,
getMessagingDmScope,
getMessagingRoomScope,
getDmRetentionDays,
@@ -68,6 +72,11 @@ export default defineEventHandler(async (event) => {
const registrationOpen = await isRegistrationOpen();
const minRatio = await getMinRatio();
const starterUpload = await getStarterUpload();
+ const hnrEnabled = await isHnrEnabled();
+ // Secondes en base, heures pour l'interface — arrondi à l'heure, ce que le
+ // formulaire sait écrire.
+ const hnrRequiredSeedHours = Math.round((await getHnrRequiredSeedTime()) / 3600);
+ const hnrGraceHours = Math.round((await getHnrGracePeriod()) / 3600);
const siteName = await getSiteName();
const siteLogo = await getSiteLogo();
const siteLogoImage = await getSiteLogoImage();
@@ -110,6 +119,7 @@ export default defineEventHandler(async (event) => {
await getSetting(SEARCH_FIELDS_SETTING)
);
const searchFuzzy = parseSearchFuzzy(await getSetting(SEARCH_FUZZY_SETTING));
+ const catalogue = await getCatalogueSettings();
const requestAutoValidateHours = await getRequestAutoValidateHours();
const requestMaxFillsPerUser = await getRequestMaxFillsPerUser();
const templateQuotaPerUser = await getTemplateQuotaPerUser();
@@ -140,6 +150,9 @@ export default defineEventHandler(async (event) => {
registrationOpen,
minRatio,
starterUpload,
+ hnrEnabled,
+ hnrRequiredSeedHours,
+ hnrGraceHours,
siteName,
siteLogo,
siteLogoImage,
@@ -178,5 +191,9 @@ export default defineEventHandler(async (event) => {
templateQuotaPerUser,
searchFields,
searchFuzzy,
+ catalogueDefaultView: catalogue.defaultView,
+ catalogueDefaultSort: catalogue.defaultSort,
+ cataloguePageSize: catalogue.pageSize,
+ catalogueFacets: catalogue.facets,
};
});
diff --git a/apps/api/routes/api/admin/settings.put.ts b/apps/api/routes/api/admin/settings.put.ts
index 1cf8cb2d..d1529d69 100644
--- a/apps/api/routes/api/admin/settings.put.ts
+++ b/apps/api/routes/api/admin/settings.put.ts
@@ -57,6 +57,19 @@ export default defineEventHandler(async (event) => {
);
}
+ if (body.catalogueDefaultView !== undefined) {
+ await setSetting(SETTINGS_KEYS.CATALOGUE_DEFAULT_VIEW, body.catalogueDefaultView);
+ }
+ if (body.catalogueDefaultSort !== undefined) {
+ await setSetting(SETTINGS_KEYS.CATALOGUE_DEFAULT_SORT, body.catalogueDefaultSort);
+ }
+ if (body.cataloguePageSize !== undefined) {
+ await setSetting(SETTINGS_KEYS.CATALOGUE_PAGE_SIZE, String(body.cataloguePageSize));
+ }
+ if (body.catalogueFacets !== undefined) {
+ await setSetting(SETTINGS_KEYS.CATALOGUE_FACETS, [...new Set(body.catalogueFacets)].join(','));
+ }
+
if (typeof body.registrationOpen === 'boolean') {
await setRegistrationOpen(body.registrationOpen);
}
@@ -79,6 +92,23 @@ export default defineEventHandler(async (event) => {
await setSetting(SETTINGS_KEYS.MIN_RATIO, body.minRatio.toString());
}
+ // Hit & Run : les trois réglages que le tracker lit déjà (`cache.go`,
+ // `KeyHnr*`) et que l'API applique à la création de chaque ligne — mais
+ // qu'aucune interface n'exposait : le seuil de 24 h était modifiable en base
+ // seulement, et la doc affirmait le contraire.
+ if (typeof body.hnrEnabled === 'boolean') {
+ await setSetting(SETTINGS_KEYS.HNR_ENABLED, String(body.hnrEnabled));
+ }
+ if (typeof body.hnrRequiredSeedHours === 'number') {
+ await setSetting(
+ SETTINGS_KEYS.HNR_REQUIRED_SEED_TIME,
+ String(body.hnrRequiredSeedHours * 3600)
+ );
+ }
+ if (typeof body.hnrGraceHours === 'number') {
+ await setSetting(SETTINGS_KEYS.HNR_GRACE_PERIOD, String(body.hnrGraceHours * 3600));
+ }
+
if (typeof body.starterUpload === 'number') {
await setSetting(
SETTINGS_KEYS.STARTER_UPLOAD,
diff --git a/apps/api/routes/api/branding.get.ts b/apps/api/routes/api/branding.get.ts
index 5f6a8154..208706c1 100644
--- a/apps/api/routes/api/branding.get.ts
+++ b/apps/api/routes/api/branding.get.ts
@@ -21,6 +21,7 @@ import {
type ServableTheme,
} from '~~/utils/themes';
import { resolveTokens } from '@trackarr/shared/theme';
+import { getCatalogueSettings } from '~~/utils/settings';
/**
* GET /api/branding
@@ -105,6 +106,7 @@ export default defineEventHandler(async (event) => {
? choosableFor(allThemes, userId ? await roleIdsFor(userId) : [])
: allThemes;
const themeDefault = await getDefaultTheme();
+ const catalogue = await getCatalogueSettings();
return {
siteName,
@@ -121,6 +123,9 @@ export default defineEventHandler(async (event) => {
// False when federation was never configured, which is the same answer as
// configured-and-off for anything the browser does with it.
federationEnabled,
+ // Les réglages du catalogue : la page en a besoin avant tout appel signé,
+ // et ils sont publics à tout membre — même enveloppe que le reste.
+ catalogue,
themeDefault,
// No `visibility` / `requiredRoles`: the list is already filtered, and
// publishing which role unlocks which theme is information the picker has
diff --git a/apps/api/routes/api/me/index.get.ts b/apps/api/routes/api/me/index.get.ts
index 40236f04..8193e69a 100644
--- a/apps/api/routes/api/me/index.get.ts
+++ b/apps/api/routes/api/me/index.get.ts
@@ -37,6 +37,7 @@ export default defineEventHandler(async (event) => {
shareReputationFederated: true,
theme: true,
language: true,
+ catalogueDefaults: true,
createdAt: true,
lastSeen: true,
},
@@ -121,6 +122,7 @@ export default defineEventHandler(async (event) => {
shareReputationFederated: user.shareReputationFederated,
theme: user.theme,
language: user.language,
+ catalogueDefaults: user.catalogueDefaults ?? null,
createdAt: user.createdAt,
lastSeen: user.lastSeen,
counts: {
diff --git a/apps/api/routes/api/me/index.patch.ts b/apps/api/routes/api/me/index.patch.ts
index acb39c2d..382834d1 100644
--- a/apps/api/routes/api/me/index.patch.ts
+++ b/apps/api/routes/api/me/index.patch.ts
@@ -89,6 +89,9 @@ const bodySchema = z
// is free-form so historical rows referencing a removed locale
// are still readable (they fall back to `defaultLocale` at boot).
language: z.enum(['en', 'fr']).optional(),
+ // Les filtres par défaut du catalogue, sous la forme de sa chaîne de requête
+ // (clés courtes, valeurs courtes) ; null pour les retirer.
+ catalogueDefaults: z.record(z.string().max(16), z.string().max(200)).nullable().optional(),
})
.strict();
@@ -111,6 +114,7 @@ export default defineEventHandler(async (event) => {
shareReputationFederated: boolean;
theme: string | null;
language: 'en' | 'fr';
+ catalogueDefaults: Record | null;
}> = {};
if (body.displayName !== undefined) {
@@ -169,6 +173,13 @@ export default defineEventHandler(async (event) => {
if (body.language !== undefined) {
updates.language = body.language;
}
+ if (body.catalogueDefaults !== undefined) {
+ const entries = body.catalogueDefaults ? Object.entries(body.catalogueDefaults) : [];
+ if (entries.length > 12) {
+ throw createError({ statusCode: 400, message: 'Too many catalogue defaults' });
+ }
+ updates.catalogueDefaults = entries.length ? Object.fromEntries(entries) : null;
+ }
if (Object.keys(updates).length === 0) {
// Nothing to do — return 200 with current state rather than 400 so
@@ -191,6 +202,7 @@ export default defineEventHandler(async (event) => {
shareReputationFederated: schema.users.shareReputationFederated,
theme: schema.users.theme,
language: schema.users.language,
+ catalogueDefaults: schema.users.catalogueDefaults,
});
if (!updated) {
diff --git a/apps/api/routes/api/me/saved-searches/[id]/seen.post.ts b/apps/api/routes/api/me/saved-searches/[id]/seen.post.ts
new file mode 100644
index 00000000..d6f388ce
--- /dev/null
+++ b/apps/api/routes/api/me/saved-searches/[id]/seen.post.ts
@@ -0,0 +1,29 @@
+import { and, eq, sql } from 'drizzle-orm';
+import { db, schema } from '@trackarr/db';
+import { requireAuthSession } from '~~/utils/adminAuth';
+import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit';
+import { uuidSchema, validateParam } from '~~/utils/schemas';
+
+/**
+ * POST /api/me/saved-searches/:id/seen — « j'ai vu ».
+ *
+ * Le bandeau d'alertes du catalogue affiche « n nouv. » : les correspondances
+ * arrivées depuis la dernière fois que le membre a ouvert cette recherche
+ * depuis là. Ouvrir, c'est voir : `seen_count` rejoint `match_count`.
+ */
+export default defineEventHandler(async (event) => {
+ const { user } = await requireAuthSession(event);
+ await rateLimit(event, RATE_LIMITS.mutation);
+ const id = validateParam(event, 'id', uuidSchema);
+
+ const rows = await db
+ .update(schema.savedSearches)
+ .set({ seenCount: sql`${schema.savedSearches.matchCount}` })
+ .where(and(eq(schema.savedSearches.id, id), eq(schema.savedSearches.userId, user.id)))
+ .returning({ seenCount: schema.savedSearches.seenCount });
+
+ if (rows.length === 0) {
+ throw createError({ statusCode: 404, message: 'Saved search not found' });
+ }
+ return { seenCount: rows[0]!.seenCount };
+});
diff --git a/apps/api/routes/api/me/saved-searches/index.post.ts b/apps/api/routes/api/me/saved-searches/index.post.ts
index d7e85aac..40452d63 100644
--- a/apps/api/routes/api/me/saved-searches/index.post.ts
+++ b/apps/api/routes/api/me/saved-searches/index.post.ts
@@ -38,6 +38,13 @@ const bodySchema = z.object({
tmdbId: z.string().max(64).optional(),
tvdbId: z.string().max(64).optional(),
notify: z.boolean().optional(),
+ // Ce que la barre du catalogue produit : groupes d'étiquettes (« hevc,x265;1080p »),
+ // saison, épisode, année, uploadeur (nom, résolu en id ici).
+ tagGroups: z.string().max(400).optional(),
+ season: z.number().int().min(0).max(999).optional(),
+ episode: z.number().int().min(0).max(9999).optional(),
+ year: z.number().int().min(1900).max(2100).optional(),
+ uploader: z.string().trim().min(1).max(64).optional(),
});
export default defineEventHandler(async (event) => {
@@ -56,8 +63,33 @@ export default defineEventHandler(async (event) => {
// `query` without a usable tsquery means the member typed only punctuation —
// it looks like a criterion and matches nothing, so it does not count as one.
+ const tagGroups =
+ body.tagGroups
+ ?.split(';')
+ .map((g) => Array.from(new Set(g.split(',').map(slugifyTag).filter(Boolean))).join(','))
+ .filter(Boolean)
+ .join(';') || null;
+ const season = body.season ?? null;
+ const episode = body.episode ?? null;
+ const year = body.year ?? null;
+ let uploaderId: string | null = null;
+ if (body.uploader) {
+ const [u] = await db
+ .select({ id: schema.users.id, anonymousUploads: schema.users.anonymousUploads })
+ .from(schema.users)
+ .where(sql`lower(${schema.users.username}) = ${body.uploader.toLowerCase()}`)
+ .limit(1);
+ // Un membre inconnu, ou un membre qui a choisi les envois anonymes (sauf pour
+ // lui-même et l'équipe) : la même réponse, pour ne pas dire lequel des deux.
+ const isStaff = !!((user as { isAdmin?: boolean }).isAdmin || (user as { isModerator?: boolean }).isModerator);
+ const allowed = !!u && (!u.anonymousUploads || u.id === user.id || isStaff);
+ if (!allowed) throw createError({ statusCode: 400, message: 'uploader: unknown member' });
+ uploaderId = u.id;
+ }
+
const hasCriteria =
- !!tsquery || !!categoryId || tags.length > 0 || !!imdbId || !!tmdbId || !!tvdbId;
+ !!tsquery || !!categoryId || tags.length > 0 || !!imdbId || !!tmdbId || !!tvdbId ||
+ !!tagGroups || season !== null || episode !== null || year !== null || !!uploaderId;
if (!hasCriteria) {
throw createError({
statusCode: 400,
@@ -106,10 +138,12 @@ export default defineEventHandler(async (event) => {
*/
const inserted = await db.execute(sql`
insert into ${schema.savedSearches}
- (id, user_id, label, query, tsquery, category_id, tags, imdb_id, tmdb_id, tvdb_id, notify)
+ (id, user_id, label, query, tsquery, category_id, tags, imdb_id, tmdb_id, tvdb_id, notify,
+ tag_groups, season, episode, year, uploader_id)
select
${id}, ${user.id}, ${body.label}, ${query}, ${tsquery}, ${categoryId},
- ${tagsJson}::jsonb, ${imdbId}, ${tmdbId}, ${tvdbId}, ${body.notify ?? true}
+ ${tagsJson}::jsonb, ${imdbId}, ${tmdbId}, ${tvdbId}, ${body.notify ?? true},
+ ${tagGroups}, ${season}, ${episode}, ${year}, ${uploaderId}
where (
select count(*) from ${schema.savedSearches}
where ${schema.savedSearches.userId} = ${user.id}
diff --git a/apps/api/routes/api/metadata/tint.post.ts b/apps/api/routes/api/metadata/tint.post.ts
new file mode 100644
index 00000000..8ec57ec8
--- /dev/null
+++ b/apps/api/routes/api/metadata/tint.post.ts
@@ -0,0 +1,57 @@
+import { z } from 'zod';
+import { redis } from '~~/utils/server';
+import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit';
+import { validateBody } from '~~/utils/schemas';
+import { db, schema } from '@trackarr/db';
+import { eq } from 'drizzle-orm';
+import { candidateKeys } from '~~/utils/metadata/cached';
+
+/**
+ * POST /api/metadata/tint — la teinte de l'affiche, mise en cache avec la fiche.
+ *
+ * La fiche d'un torrent calcule déjà la couleur dominante de son affiche (un
+ * canevas de 12×18 côté navigateur) et la pose sur son bandeau. Le catalogue
+ * veut la même couleur sur le filet des cartes d'œuvres, sans refaire vingt
+ * fois ce calcul ni vingt fois l'image : la fiche la dépose ici, le cache la
+ * garde trente jours, `worksFromCache` la lit avec le titre et l'affiche.
+ *
+ * Trois entiers 0-255, rien d'autre : ce que le navigateur envoie n'est pas
+ * une couleur qu'on injecte, c'est trois nombres qu'on revalide.
+ */
+const bodySchema = z.object({
+ // Les trois sources qui ont une fiche en cache ; IMDb et TVDB se résolvent en TMDb avant.
+ source: z.enum(['tmdb', 'igdb', 'openlibrary']),
+ id: z.string().trim().min(1).max(128),
+ tint: z.string().regex(/^\d{1,3} \d{1,3} \d{1,3}$/),
+});
+const TINT_TTL_S = 30 * 86400;
+/** La forme d'un identifiant par source : rien d'autre ne devient une clé Redis. */
+const ID_SHAPE: Record = {
+ // Nu ou préfixé : les releases portent l'un ou l'autre, `candidateKeys` sait les deux.
+ tmdb: /^((movie|tv)\/)?\d{1,10}$/,
+ igdb: /^\d{1,10}$/,
+ openlibrary: /^OL\d{1,10}[WM]$/,
+};
+
+export const tintCacheKey = (source: string, id: string) => `meta:v1:tint:${source}:${id}`;
+
+export default defineEventHandler(async (event) => {
+ const { user } = await requireUserSession(event);
+ await rateLimit(event, RATE_LIMITS.public);
+ const body = await validateBody(event, bodySchema);
+ const channels = body.tint.split(' ').map(Number);
+ if (channels.some((c) => !Number.isInteger(c) || c < 0 || c > 255)) {
+ throw createError({ statusCode: 400, message: 'tint: each channel must be 0-255' });
+ }
+ if (!ID_SHAPE[body.source]?.test(body.id)) {
+ throw createError({ statusCode: 400, message: 'id: not an identifier of this source' });
+ }
+ // Seulement une œuvre que le cache connaît déjà : la fiche l'a cherchée avant
+ // d'en calculer la couleur. Sans cela, chaque membre pouvait semer des clés à
+ // volonté. La réponse ne dit PAS si l'œuvre était connue : un 404 ici
+ // répondait « cette instance a-t-elle cette œuvre ? » à qui la demandait.
+ const me = await db.query.users.findFirst({ where: eq(schema.users.id, user.id), columns: { language: true } });
+ const known = await redis.exists(...candidateKeys({ source: body.source, id: body.id }, me?.language ?? undefined));
+ if (known) await redis.set(tintCacheKey(body.source, body.id), channels.join(' '), 'EX', TINT_TTL_S);
+ return { ok: true };
+});
diff --git a/apps/api/routes/api/torrents/[hash].get.ts b/apps/api/routes/api/torrents/[hash].get.ts
index f58bbc27..9475688f 100644
--- a/apps/api/routes/api/torrents/[hash].get.ts
+++ b/apps/api/routes/api/torrents/[hash].get.ts
@@ -1,8 +1,9 @@
import { db, schema } from '@trackarr/db';
-import { and, eq } from 'drizzle-orm';
+import { and, count, eq } from 'drizzle-orm';
import { getPeers, getStats } from '~~/utils/server';
import { redactUploader } from '~~/utils/uploaderVisibility';
import { validateParam, infoHashSchema } from '~~/utils/schemas';
+import { COMMENTS_PAGE_SIZE } from './[hash]/comments.get';
export default defineEventHandler(async (event) => {
const { user: session } = await requireUserSession(event);
@@ -37,6 +38,10 @@ export default defineEventHandler(async (event) => {
},
},
orderBy: (c, { desc }) => [desc(c.createdAt)],
+ // La première page seulement : un fil de trois cents commentaires
+ // partait en entier dans la charge utile de CHAQUE visite. La suite
+ // se demande à `GET /api/torrents/:hash/comments`.
+ limit: COMMENTS_PAGE_SIZE,
},
},
});
@@ -90,7 +95,7 @@ export default defineEventHandler(async (event) => {
}
}
- const [stats, peers, favorite] = await Promise.all([
+ const [stats, peers, favorite, commentCount] = await Promise.all([
getStats(infoHash),
getPeers(infoHash),
// Cheap "did this viewer star this torrent?" lookup — hits
@@ -104,6 +109,12 @@ export default defineEventHandler(async (event) => {
),
columns: { userId: true },
}),
+ // Le fil entier ne descend plus : son compte, si — le sommaire l'annonce.
+ db
+ .select({ n: count() })
+ .from(schema.torrentComments)
+ .where(eq(schema.torrentComments.torrentId, torrent.id))
+ .then((r) => Number(r[0]?.n ?? 0)),
]);
const tags = torrent.torrentTags?.map((tt) => tt.tag) || [];
@@ -121,6 +132,7 @@ export default defineEventHandler(async (event) => {
...torrent,
...uploaderView,
tags,
+ commentCount,
torrentTags: undefined,
stats: {
seeders: stats.seeders,
diff --git a/apps/api/routes/api/torrents/[hash]/comments.get.ts b/apps/api/routes/api/torrents/[hash]/comments.get.ts
new file mode 100644
index 00000000..a438deba
--- /dev/null
+++ b/apps/api/routes/api/torrents/[hash]/comments.get.ts
@@ -0,0 +1,64 @@
+import { and, desc, eq, lt, sql } from 'drizzle-orm';
+import { z } from 'zod';
+import { db, schema } from '@trackarr/db';
+import { requireAuthSession } from '~~/utils/adminAuth';
+import { validateParam, validateQuery, infoHashSchema } from '~~/utils/schemas';
+import { assertVisibleTorrent } from '~~/utils/torrentListing';
+import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit';
+
+/**
+ * GET /api/torrents/:hash/comments — le fil, par pages.
+ *
+ * La fiche embarque la première page ; celle-ci sert les suivantes. Pagination
+ * par CURSEUR (`before`, la date du plus ancien déjà affiché) et non par
+ * décalage : un commentaire publié entre deux clics décale une pagination par
+ * `OFFSET` et fait sauter une ligne ou la répéter. Un curseur ne bouge pas.
+ *
+ * Même visibilité que la fiche : un torrent en attente, retiré ou adulte
+ * masqué répond 404 plutôt que d'ouvrir son fil.
+ */
+export const COMMENTS_PAGE_SIZE = 20;
+
+const querySchema = z.object({
+ before: z.string().datetime().optional(),
+ /** L'identifiant de la ligne du curseur : deux commentaires peuvent partager une date. */
+ beforeId: z.string().min(1).max(64).optional(),
+ limit: z.coerce.number().int().min(1).max(50).default(COMMENTS_PAGE_SIZE),
+});
+
+export default defineEventHandler(async (event) => {
+ const session = await requireAuthSession(event);
+ await rateLimit(event, RATE_LIMITS.public);
+ const hash = validateParam(event, 'hash', infoHashSchema).toLowerCase();
+ const { before, beforeId, limit } = validateQuery(event, querySchema);
+ const torrent = await assertVisibleTorrent(hash, session.user);
+
+ /*
+ * Le curseur porte la date ET l'identifiant, comparés en couple.
+ *
+ * Sur la seule date, deux commentaires écrits dans la même transaction (un
+ * import partage un `now()`) rendaient la page suivante vide pour toujours,
+ * ou faisaient sauter la ligne frontière. `::timestamp` plutôt qu'un `Date` :
+ * la colonne est sans fuseau, et le texte revient tel que l'API l'a émis.
+ */
+ const cursor =
+ before && beforeId
+ ? sql`(${schema.torrentComments.createdAt}, ${schema.torrentComments.id}) < (${before}::timestamp, ${beforeId})`
+ : before
+ ? lt(schema.torrentComments.createdAt, sql`${before}::timestamp`)
+ : undefined;
+
+ // Une ligne de plus que demandé : sa présence dit qu'il en reste, sans un
+ // `count(*)` sur tout le fil à chaque page.
+ const rows = await db.query.torrentComments.findMany({
+ where: cursor
+ ? and(eq(schema.torrentComments.torrentId, torrent.id), cursor)
+ : eq(schema.torrentComments.torrentId, torrent.id),
+ orderBy: [desc(schema.torrentComments.createdAt), desc(schema.torrentComments.id)],
+ limit: limit + 1,
+ columns: { id: true, content: true, createdAt: true },
+ with: { author: { columns: { id: true, username: true } } },
+ });
+
+ return { items: rows.slice(0, limit), more: rows.length > limit };
+});
diff --git a/apps/api/routes/api/torrents/[hash]/my-obligation.get.ts b/apps/api/routes/api/torrents/[hash]/my-obligation.get.ts
new file mode 100644
index 00000000..abf75e88
--- /dev/null
+++ b/apps/api/routes/api/torrents/[hash]/my-obligation.get.ts
@@ -0,0 +1,89 @@
+/**
+ * GET /api/torrents/:hash/my-obligation
+ *
+ * Ce que le membre connecté doit encore À CE TORRENT, pour que sa page le dise.
+ *
+ * `hnr_tracking` porte, par couple (membre, torrent), le temps de seed effectué,
+ * le temps exigé, l'état de défaut et l'exemption. Rien de tout cela n'atteignait
+ * la page de détail : un membre ne pouvait pas savoir, en regardant le torrent
+ * qu'il vient de télécharger, s'il lui devait encore des heures — l'information
+ * n'existait que dans la liste de ses téléchargements, une page plus loin.
+ *
+ * Renvoie `null`, et non un 404, quand il n'y a pas de ligne : « ce membre n'a
+ * jamais téléchargé ce torrent » est une réponse, pas une erreur, et le front
+ * doit pouvoir masquer la carte sans traiter un cas d'échec.
+ *
+ * Route à part plutôt qu'un champ du détail, pour deux raisons : la charge du
+ * détail est mise en cache et partagée entre membres, alors que cette réponse
+ * est propre à un membre ; et la page la charge en `lazy`, donc elle ne retarde
+ * pas le premier octet.
+ */
+import { and, eq } from 'drizzle-orm';
+import { db, schema } from '@trackarr/db';
+import { requireAuthSession } from '~~/utils/adminAuth';
+import { validateParam, infoHashSchema } from '~~/utils/schemas';
+import { isHnrEnabled, getHnrRequiredSeedTime } from '~~/utils/settings';
+import { assertVisibleTorrent } from '~~/utils/torrentListing';
+
+export default defineEventHandler(async (event) => {
+ const session = await requireAuthSession(event);
+ const hash = validateParam(event, 'hash', infoHashSchema);
+
+ // Visible pour ce membre, sinon 404 : la route ne doit pas confirmer un hash
+ // en attente ou retiré que la fiche lui refuse.
+ const torrent = await assertVisibleTorrent(hash.toLowerCase(), session.user);
+
+ const [row] = await db
+ .select({
+ seedTime: schema.hnrTracking.seedTime,
+ requiredSeedTime: schema.hnrTracking.requiredSeedTime,
+ isHnr: schema.hnrTracking.isHnr,
+ isExempt: schema.hnrTracking.isExempt,
+ completedAt: schema.hnrTracking.completedAt,
+ downloaded: schema.hnrTracking.downloaded,
+ })
+ .from(schema.hnrTracking)
+ .where(
+ and(
+ eq(schema.hnrTracking.userId, session.user.id),
+ eq(schema.hnrTracking.torrentId, torrent.id),
+ ),
+ )
+ .limit(1);
+
+ /*
+ * Pas encore de ligne : le membre n'a jamais pris cette release. S'il la
+ * prend, c'est le seuil GLOBAL du moment qui sera figé sur sa ligne — donc
+ * c'est lui qu'on annonce, sous la forme d'une obligation « pas encore
+ * prise ». Avant, la carte ne disait rien tant qu'on n'avait pas cliqué :
+ * l'engagement se découvrait après coup. Hit & Run désactivé → `null`, il
+ * n'y a rien à annoncer.
+ */
+ if (!row) {
+ if (!(await isHnrEnabled())) return null;
+ return {
+ downloaded: false,
+ seedTime: 0,
+ requiredSeedTime: await getHnrRequiredSeedTime(),
+ isHnr: false,
+ isExempt: false,
+ completedAt: null,
+ };
+ }
+
+ return {
+ /*
+ * `downloaded > 0` et non « la ligne existe » : l'API pose une ligne dès le
+ * clic sur le fichier .torrent, pour que la page des téléchargements montre
+ * la release avant le premier octet. Une ligne à zéro octet signifie donc
+ * « récupéré, jamais commencé » — et afficher une obligation de seed à
+ * quelqu'un qui n'a rien téléchargé serait faux.
+ */
+ downloaded: row.downloaded > 0,
+ seedTime: row.seedTime,
+ requiredSeedTime: row.requiredSeedTime,
+ isHnr: row.isHnr,
+ isExempt: row.isExempt,
+ completedAt: row.completedAt ? row.completedAt.toISOString() : null,
+ };
+});
diff --git a/apps/api/routes/api/torrents/[hash]/stats-history.get.ts b/apps/api/routes/api/torrents/[hash]/stats-history.get.ts
new file mode 100644
index 00000000..4706d5cc
--- /dev/null
+++ b/apps/api/routes/api/torrents/[hash]/stats-history.get.ts
@@ -0,0 +1,57 @@
+import { and, desc, eq, gte, sql } from 'drizzle-orm';
+import { db, schema } from '@trackarr/db';
+import { requireAuthSession } from '~~/utils/adminAuth';
+import { validateParam, infoHashSchema } from '~~/utils/schemas';
+import { assertVisibleTorrent } from '~~/utils/torrentListing';
+
+/**
+ * Les sept derniers points de l'essaim d'un torrent — un par jour.
+ *
+ * La fiche trace ces points en courbe à côté du bouton de téléchargement et y
+ * ajoute elle-même la valeur du moment (celle de `torrent_stats`, qu'elle a
+ * déjà) comme dernier point. Sept jours parce que c'est la fenêtre qui répond
+ * à la question posée — « ça meurt ou ça revit ? » — sans que la courbe
+ * devienne un graphique à lire.
+ *
+ * Les jours SANS point ne sont pas comblés ici : un jour absent veut dire que
+ * le collecteur n'a pas tourné (ou que la table vient d'être créée), pas que
+ * l'essaim était vide, et le client peut le dire au lieu de dessiner un zéro.
+ *
+ * Réservée aux membres, comme le reste de la fiche : le nombre de sources est
+ * une information de l'instance, pas une page publique.
+ */
+export default defineEventHandler(async (event) => {
+ const session = await requireAuthSession(event);
+ const hash = validateParam(event, 'hash', infoHashSchema).toLowerCase();
+ // La même visibilité que le listing : un hash en attente, retiré ou adulte
+ // masqué répond 404, pas un historique — sinon la route dit qu'il existe.
+ await assertVisibleTorrent(hash, session.user);
+
+ const rows = await db
+ .select({
+ day: schema.torrentStatsHistory.day,
+ seeders: schema.torrentStatsHistory.seeders,
+ leechers: schema.torrentStatsHistory.leechers,
+ })
+ .from(schema.torrentStatsHistory)
+ .where(
+ and(
+ eq(schema.torrentStatsHistory.infoHash, hash),
+ gte(schema.torrentStatsHistory.day, sql`current_date - 7`),
+ ),
+ )
+ .orderBy(desc(schema.torrentStatsHistory.day))
+ .limit(8);
+
+ // Du plus ancien au plus récent : c'est l'ordre d'une courbe.
+ return {
+ days: 7,
+ points: rows.reverse().map((r) => ({
+ // `date` arrive en chaîne ISO du jour (`YYYY-MM-DD`) : le pilote ne la
+ // convertit pas en `Date`, donc pas de fuseau à démêler.
+ day: String(r.day),
+ seeders: r.seeders,
+ leechers: r.leechers,
+ })),
+ };
+});
diff --git a/apps/api/routes/api/torrents/facets.get.ts b/apps/api/routes/api/torrents/facets.get.ts
new file mode 100644
index 00000000..6327577a
--- /dev/null
+++ b/apps/api/routes/api/torrents/facets.get.ts
@@ -0,0 +1,308 @@
+import { createHash } from 'node:crypto';
+import { db, schema } from '@trackarr/db';
+import { and, desc, eq, sql, type SQL } from 'drizzle-orm';
+import { redis } from '~~/utils/server';
+import { validateQuery, torrentQuerySchema } from '~~/utils/schemas';
+import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit';
+import {
+ filterConditions,
+ searchConditions,
+ tagGroupsCondition,
+ visibilityConditions,
+ YEAR_IN_NAME_RE,
+ type ListingFilters,
+} from '~~/utils/torrentListing';
+
+/**
+ * GET /api/torrents/facets — ce qui existe dans la recherche courante.
+ *
+ * Le rail du catalogue affiche, à côté de chaque valeur, le nombre de releases
+ * qu'elle donnerait : on voit ce qui existe AVANT de filtrer, au lieu de
+ * découvrir une liste vide. Les comptes suivent la règle des facettes : pour
+ * une dimension, tous les autres filtres s'appliquent, le sien non — sinon la
+ * catégorie cochée serait la seule à compter, et on ne pourrait plus changer.
+ *
+ * Quatre lectures : catégories, étiquettes, années, options. Les étiquettes
+ * ne sont pas rangées par famille ici — c'est le vocabulaire de la barre
+ * (`searchTokens.ts`) qui sait qu'un `x265` est un codec ; l'API rend des
+ * slugs et des comptes, rien de plus.
+ */
+const MAX_TAGS = 80;
+/*
+ * Vingt secondes de cache par membre et par requête : la route fait une
+ * demi-douzaine de lectures, et un membre qui coche trois facettes de suite
+ * les redemande à l'identique entre deux. Le membre est dans la clé — les
+ * comptes « pas encore pris » et « favoris » lui appartiennent.
+ */
+const CACHE_TTL_S = 20;
+
+export default defineEventHandler(async (event) => {
+ const { user } = await requireUserSession(event);
+ // Une requête de facettes, c'est une dizaine de comptes, et jusqu'à cinquante
+ // quand rien ne sort (un par critère retiré, un par famille d'étiquettes).
+ // La même limite que la vue Œuvres, sinon un seul membre occupe le pool.
+ await rateLimit(event, RATE_LIMITS.public);
+ const query = validateQuery(event, torrentQuerySchema);
+ const viewer = { id: user.id, isAdmin: !!user.isAdmin, isModerator: !!user.isModerator };
+
+ const cacheKey = `facets:v2:${user.id}:${createHash('sha1')
+ .update(
+ JSON.stringify(
+ Object.entries(query)
+ .filter(([k, v]) => v !== undefined && !['page', 'limit', 'sortBy', 'order'].includes(k))
+ .sort(([a], [b]) => (a < b ? -1 : 1)),
+ ),
+ )
+ .digest('hex')}`;
+ const cached = await redis.get(cacheKey).catch(() => null);
+ if (cached) return JSON.parse(cached);
+
+ const base: SQL[] = await visibilityConditions(viewer);
+ const { primary, fuzzy } = await searchConditions(query.search);
+ // Le listing se replie sur la recherche approximative quand l'exacte ne rend
+ // rien ; les facettes doivent compter la même chose que ce qu'il montre.
+ let search = primary;
+ if (primary && fuzzy) {
+ const all = await filterConditions(query, viewer);
+ const [row] = await db
+ .select({ n: sql`count(*)::int` })
+ .from(schema.torrents)
+ .where(and(...base, ...all, primary));
+ if ((row?.n ?? 0) === 0) search = fuzzy;
+ }
+ const withBase = (extra: SQL[]) => and(...base, ...(search ? [search] : []), ...extra);
+ const without = (drop: Array) => {
+ const q: ListingFilters = { ...query };
+ for (const k of drop) delete q[k];
+ return filterConditions(q, viewer);
+ };
+
+ /*
+ * Les étiquettes : `tagGroups` porte un groupe par famille (« hevc,x265 »
+ * pour le codec, « vostfr » pour la langue). Pour compter la famille du
+ * groupe i, on applique tous les groupes SAUF le i-ème — l'API ne sait pas
+ * quelle famille est laquelle, mais la page le sait : elle a construit la
+ * chaîne, elle lit `tagsByGroup[i]` pour la famille i et `tags` pour les
+ * autres.
+ */
+ const groups = (query.tagGroups ?? '').split(';').filter((g) => g.trim());
+ const tagCounts = async (drop: number | null) => {
+ const c = await without(['tagGroups']);
+ const kept = groups.filter((_, i) => i !== drop).join(';');
+ const cond = kept ? tagGroupsCondition(kept) : null;
+ return db
+ .select({
+ slug: schema.tags.slug,
+ name: schema.tags.name,
+ count: sql`count(DISTINCT ${schema.torrents.id})::int`,
+ })
+ .from(schema.torrents)
+ .innerJoin(schema.torrentTags, eq(schema.torrentTags.torrentId, schema.torrents.id))
+ .innerJoin(schema.tags, eq(schema.tags.id, schema.torrentTags.tagId))
+ .where(withBase(cond ? [...c, cond] : c))
+ .groupBy(schema.tags.slug, schema.tags.name)
+ .orderBy(desc(sql`count(DISTINCT ${schema.torrents.id})`), schema.tags.slug)
+ .limit(MAX_TAGS);
+ };
+ const tagsByGroupList = await Promise.all(groups.map((_, i) => tagCounts(i)));
+ const tagsByGroup: Record>> = {};
+ tagsByGroupList.forEach((rows, i) => {
+ tagsByGroup[i] = rows;
+ });
+
+ const count = async (extra: SQL[]) => {
+ const [row] = await db.select({ n: sql`count(*)::int` }).from(schema.torrents).where(withBase(extra));
+ return row?.n ?? 0;
+ };
+ /*
+ * Ce que le listing AFFICHERAIT pour ces critères : la recherche exacte, et
+ * la recherche approximative seulement si l'exacte ne rend rien — la même
+ * règle que lui. Une suggestion « sans 480p : 9 » qui ouvre une page à 1
+ * résultat est pire que pas de suggestion.
+ */
+ const countLikeListing = async (extra: SQL[]) => {
+ if (!primary) return count(extra);
+ const [exact] = await db
+ .select({ n: sql`count(*)::int` })
+ .from(schema.torrents)
+ .where(and(...base, primary, ...extra));
+ if ((exact?.n ?? 0) > 0 || !fuzzy) return exact?.n ?? 0;
+ const [approx] = await db
+ .select({ n: sql`count(*)::int` })
+ .from(schema.torrents)
+ .where(and(...base, fuzzy, ...extra));
+ return approx?.n ?? 0;
+ };
+ const strictConditions = await filterConditions(query, viewer);
+ const [total, statsRow] = await Promise.all([
+ count(strictConditions),
+ // Le collecteur écrit `torrent_stats` par passes : c'est la date que la
+ // page affiche à côté de « avec des sources » et du tri par essaim.
+ // En ISO UTC explicite : une chaîne nue sans fuseau serait relue comme de
+ // l'heure locale par le navigateur — deux heures d'écart en France.
+ db.select({ at: sql`to_char(max(updated_at), 'YYYY-MM-DD"T"HH24:MI:SS"Z"')` }).from(schema.torrentStats).then((r) => r[0]?.at ?? null),
+ ]);
+
+ /*
+ * Quand rien ne sort : ce que donnerait la même recherche SANS chacun de ses
+ * critères, un compte par critère posé, plus le catalogue entier. C'est
+ * l'état vide qui propose au lieu de conclure — et il ne coûte que quand il
+ * n'y a rien à afficher.
+ */
+ let dropOne: Array<{ key: string; count: number }> | null = null;
+ let catalogue: number | null = null;
+ if (total === 0) {
+ const keys: Array = [
+ 'categoryId', 'tag', 'imdbid', 'tmdbid', 'tvdbid', 'uploader', 'year', 'season', 'episode',
+ 'minSeeders', 'freeleech', 'notTaken', 'hideSuperseded', 'favorites', 'since', 'groupKey',
+ ];
+ const present = keys.filter((k) => query[k] !== undefined && query[k] !== '');
+ const jobs: Array> = present.map(async (k) => ({
+ key: k,
+ count: await countLikeListing(await without([k])),
+ }));
+ for (let i = 0; i < groups.length; i++) {
+ jobs.push(
+ (async () => {
+ const c = await without(['tagGroups']);
+ const kept = groups.filter((_, j) => j !== i).join(';');
+ const cond = kept ? tagGroupsCondition(kept) : null;
+ return { key: `tagGroups:${i}`, count: await countLikeListing(cond ? [...c, cond] : c) };
+ })(),
+ );
+ }
+ if (query.search) {
+ jobs.push(
+ (async () => {
+ const [row] = await db
+ .select({ n: sql`count(*)::int` })
+ .from(schema.torrents)
+ .where(and(...base, ...strictConditions));
+ return { key: 'search', count: row?.n ?? 0 };
+ })(),
+ );
+ }
+ const [drops, all] = await Promise.all([
+ Promise.all(jobs),
+ db.select({ n: sql`count(*)::int` }).from(schema.torrents).where(and(...base)).then((r) => r[0]?.n ?? 0),
+ ]);
+ dropOne = drops.filter((d) => d.count > 0);
+ catalogue = all;
+ }
+ /*
+ * « Vouliez-vous dire » : les titres d'œuvres dont un MOT ressemble au
+ * texte (`word_similarity`, pas `similarity` : « friren » contre « Frieren:
+ * Beyond Journey's End » vaut 0,57 au mot et presque rien sur le titre
+ * entier — sous le seuil 0,6 de l'opérateur `<%`, d'où le seuil explicite à
+ * 0,45 ; la table est petite, le balayage l'est aussi). Seulement quand rien
+ * ne sort — sinon la liste parle d'elle-même.
+ */
+ let didYouMean: Array<{ source: string; externalId: string; title: string }> | null = null;
+ if (total === 0 && query.search && query.search.length >= 3) {
+ // Seulement des œuvres dont ce lecteur peut voir au moins une release :
+ // `work_titles` ne connaît ni la modération ni la préférence adulte, et un
+ // titre suggéré qui n'ouvre sur rien trahirait ce que le catalogue cache.
+ const visible = base.length ? sql`${and(...base)} AND` : sql``;
+ const rows = (await db.execute(sql`
+ SELECT source, external_id, title, word_similarity(${query.search}, title) AS sim
+ FROM ${schema.workTitles}
+ WHERE word_similarity(${query.search}, title) >= 0.45
+ AND EXISTS (
+ SELECT 1 FROM torrents
+ WHERE ${visible} (
+ (work_titles.source = 'tmdb' AND (torrents.tmdb_id = work_titles.external_id OR torrents.tmdb_id = work_titles.bare_id))
+ OR (work_titles.source = 'igdb' AND torrents.igdb_id = work_titles.external_id)
+ OR (work_titles.source = 'openlibrary' AND torrents.openlibrary_id = work_titles.external_id)
+ )
+ )
+ ORDER BY sim DESC, title
+ LIMIT 8
+ `)) as unknown as Array<{ source: string; external_id: string; title: string; sim: number }>;
+ const seen = new Set();
+ didYouMean = [];
+ for (const r of rows) {
+ const k = `${r.source}:${r.external_id}`;
+ if (seen.has(k)) continue;
+ seen.add(k);
+ didYouMean.push({ source: r.source, externalId: r.external_id, title: r.title });
+ if (didYouMean.length >= 3) break;
+ }
+ }
+
+ const [categories, tags, years, options] = await Promise.all([
+ without(['categoryId']).then((c) =>
+ db
+ .select({ id: schema.torrents.categoryId, count: sql`count(*)::int` })
+ .from(schema.torrents)
+ .where(withBase(c))
+ .groupBy(schema.torrents.categoryId),
+ ),
+ tagCounts(null),
+ without(['year']).then(async (c) => {
+ /*
+ * Pas de colonne « année » : c'est le nom qui la porte, avec le MÊME
+ * motif que le filtre (`YEAR_IN_NAME_RE`).
+ *
+ * `regexp_matches(..., 'g')` et non `substring` : un nom peut porter deux
+ * années (« Show.1999.2001.remux »), et `substring` n'en rendait que la
+ * PREMIÈRE — le lot « 2001 » annonçait alors moins de releases qu'un clic
+ * sur 2001 n'en montrait.
+ */
+ // `withBase` peut ne rien rendre (un opérateur sans filtre ni voile) :
+ // sans le repli, la requête devenait `WHERE GROUP BY` et cassait.
+ const where = withBase(c) ?? sql`true`;
+ const rows = (await db.execute(sql`
+ SELECT (m.y)[1] AS year, count(DISTINCT torrents.id)::int AS count
+ FROM ${schema.torrents}
+ CROSS JOIN LATERAL regexp_matches(${schema.torrents.name}, ${YEAR_IN_NAME_RE}, 'g') AS m(y)
+ WHERE ${where}
+ GROUP BY 1
+ ORDER BY 1
+ `)) as unknown as Array<{ year: string | null; count: number }>;
+ return rows
+ .filter((r) => r.year !== null)
+ .map((r) => ({ year: Number(r.year), count: Number(r.count) }));
+ }),
+ without(['minSeeders', 'freeleech', 'notTaken', 'hideSuperseded', 'favorites', 'since']).then(async (c) => {
+ // Dans une liste SELECT, drizzle rend une colonne SANS sa table
+ // (`"info_hash"`) : à l'intérieur d'une sous-requête corrélée, ce nom
+ // désigne alors la colonne de la sous-requête, et Postgres répond « more
+ // than one row ». D'où les identifiants qualifiés à la main.
+ const [row] = await db
+ .select({
+ total: sql`count(*)::int`,
+ withSeeders: sql`count(*) FILTER (WHERE COALESCE((SELECT s.seeders FROM torrent_stats s WHERE s.info_hash = torrents.info_hash), 0) > 0)::int`,
+ freeleech: sql`count(*) FILTER (WHERE torrents.download_multiplier = 0 AND (torrents.multipliers_until IS NULL OR torrents.multipliers_until > now()))::int`,
+ notTaken: sql`count(*) FILTER (WHERE NOT EXISTS (SELECT 1 FROM hnr_tracking h WHERE h.user_id = ${viewer.id} AND h.torrent_id = torrents.id AND h.downloaded > 0))::int`,
+ superseded: sql`count(*) FILTER (WHERE torrents.superseded_by_id IS NOT NULL)::int`,
+ favorites: sql`count(*) FILTER (WHERE EXISTS (SELECT 1 FROM torrent_favorites f WHERE f.user_id = ${viewer.id} AND f.torrent_id = torrents.id))::int`,
+ today: sql`count(*) FILTER (WHERE torrents.created_at >= now() - interval '24 hours')::int`,
+ })
+ .from(schema.torrents)
+ .where(withBase(c));
+ return row ?? { total: 0, withSeeders: 0, freeleech: 0, notTaken: 0, superseded: 0, favorites: 0, today: 0 };
+ }),
+ ]);
+
+ const payload = {
+ total,
+ statsAt: statsRow,
+ dropOne,
+ catalogue,
+ didYouMean,
+ categories: categories.filter((c) => c.id !== null) as Array<{ id: string; count: number }>,
+ tags,
+ tagsByGroup,
+ years,
+ options: {
+ withSeeders: options.withSeeders,
+ freeleech: options.freeleech,
+ notTaken: options.notTaken,
+ superseded: options.superseded,
+ favorites: options.favorites,
+ today: options.today,
+ },
+ };
+ void redis.set(cacheKey, JSON.stringify(payload), 'EX', CACHE_TTL_S).catch(() => {});
+ return payload;
+});
diff --git a/apps/api/routes/api/torrents/groups.get.ts b/apps/api/routes/api/torrents/groups.get.ts
index 2d3d4933..0833e709 100644
--- a/apps/api/routes/api/torrents/groups.get.ts
+++ b/apps/api/routes/api/torrents/groups.get.ts
@@ -41,6 +41,9 @@ import { z } from 'zod';
import { db, schema, ftsVector } from '@trackarr/db';
import { requireAuthSession } from '~~/utils/adminAuth';
import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit';
+import { tagFilterCondition } from '~~/utils/tags';
+import { filterConditions, searchConditions } from '~~/utils/torrentListing';
+import { worksFromCache, workRefKey, type WorkRef } from '~~/utils/metadata/cached';
import {
FTS_CONFIG,
parseSearchFields,
@@ -48,7 +51,7 @@ import {
} from '~~/utils/search';
import { adultCategoryIds } from '~~/utils/adultContent';
import { getSetting, SETTINGS_KEYS } from '~~/utils/server';
-import { GROUP_SCOPES } from '~~/utils/torrentGroups';
+import { GROUP_SCOPES, groupKeySql, groupMemberWhere, parseGroupKey, VISIBLE } from '~~/utils/torrentGroups';
import { listMixedGroups } from '~~/utils/mixedGroups';
import { getFederationConfig, isFederationLive } from '~~/utils/federation/config';
import { hasActiveCataloguePeer } from '~~/utils/remoteGroups';
@@ -59,6 +62,34 @@ const querySchema = z.object({
limit: z.coerce.number().int().min(1).max(50).default(25),
search: z.string().trim().max(200).optional(),
categoryId: z.string().uuid().optional(),
+ /**
+ * Les tags, comme dans le listing plat.
+ *
+ * Il manquait, et zod retire en silence ce qu'il ne déclare pas : une URL
+ * `?tag=2160p&v=grouped` rendait donc le catalogue ENTIER — des livres, de
+ * la musique, des 1080p — sans un message d'erreur ni une ligne de journal.
+ * Un filtre absent est pire qu'un filtre cassé : rien ne le signale.
+ */
+ tag: z.string().max(255).optional(),
+ // Ce que la barre du catalogue produit en plus (voir `torrentQuerySchema`) :
+ // absents ici, zod les retirerait en silence et la vue groupée mentirait.
+ tagGroups: z.string().max(400).optional(),
+ // Les identifiants externes aussi : choisir une œuvre dans les suggestions
+ // pose un `tmdbid`, et la vue Œuvres est celle par défaut — un filtre que
+ // cette route ne connaît pas est un filtre retiré en silence.
+ imdbid: z.string().trim().min(1).max(64).optional(),
+ tmdbid: z.string().trim().min(1).max(64).optional(),
+ tvdbid: z.string().trim().min(1).max(64).optional(),
+ uploader: z.string().trim().min(1).max(64).optional(),
+ year: z.coerce.number().int().min(1900).max(2100).optional(),
+ season: z.coerce.number().int().min(0).max(999).optional(),
+ episode: z.coerce.number().int().min(0).max(9999).optional(),
+ minSeeders: z.coerce.number().int().min(0).max(100000).optional(),
+ freeleech: z.enum(['1', 'true']).optional(),
+ notTaken: z.enum(['1', 'true']).optional(),
+ hideSuperseded: z.enum(['1', 'true']).optional(),
+ favorites: z.enum(['1', 'true']).optional(),
+ since: z.enum(['24h', '7d', '30d']).optional(),
// The filter the flat listing cannot express: "show me the season packs" is
// a question about how a release is cut, not about what it contains.
scope: z.enum(GROUP_SCOPES as unknown as [string, ...string[]]).optional(),
@@ -84,7 +115,7 @@ export default defineEventHandler(async (event) => {
// next page rather than at the next login.
const me = await db.query.users.findFirst({
where: eq(schema.users.id, user.id),
- columns: { showAdultContent: true },
+ columns: { showAdultContent: true, language: true },
});
if (!me?.showAdultContent) {
const adultIds = await adultCategoryIds();
@@ -144,27 +175,56 @@ export default defineEventHandler(async (event) => {
// Search folds into the group filter: a group matches when ANY of its
// releases matches, which is what `WHERE` before `GROUP BY` gives for free.
+ // Tags — même prédicat que le listing plat, importé et non recopié.
+ //
+ // Le MIROIR est écarté dès qu'un tag est demandé. `remote_torrents` porte
+ // bien une colonne `tags`, mais en `jsonb` et dans un vocabulaire qui n'est
+ // pas le nôtre : les slugs viennent de l'instance d'en face. Je n'ai pas pu
+ // en observer la forme — la table est vide sur cette pile et rien dans le
+ // code d'ingestion ne la fixe — et écrire un prédicat `jsonb` à l'aveugle
+ // referait exactement le défaut qu'on corrige : des lignes qui traversent un
+ // filtre sans être évaluées. Mieux vaut un miroir absent qu'un miroir non
+ // filtré. Le listing plat, lui, ne fédère pas du tout : le comportement des
+ // deux vues se rejoint donc quand on filtre par tag.
+ if (query.tag) {
+ const cond = await tagFilterCondition(query.tag);
+ if (cond) {
+ conditions.push(cond);
+ remote.push(sql`false`);
+ }
+ }
+ // Les filtres de la barre : même prédicats que le listing plat, importés. Le
+ // miroir n'a ni tags dans notre vocabulaire, ni suivi de seed, ni uploadeur
+ // à nous : dès qu'un de ces filtres est posé, il est écarté.
+ {
+ const { tag: _tag, categoryId: _cat, ...barFilters } = query as Record;
+ const extra = await filterConditions(barFilters as Parameters[0], {
+ id: user.id,
+ isAdmin: !!user.isAdmin,
+ isModerator: !!user.isModerator,
+ });
+ if (extra.length) {
+ conditions.push(...extra);
+ remote.push(sql`false`);
+ }
+ }
+
if (query.search) {
- const tsq = toPrefixTsQuery(query.search);
- if (tsq) {
- const fields = parseSearchFields(
- await getSetting(SETTINGS_KEYS.SEARCH_FIELDS),
- );
- const q = sql`to_tsquery(${FTS_CONFIG}, ${tsq})`;
- const branches: SQL[] = [];
- if (fields.includes('name')) {
- branches.push(sql`${ftsVector(schema.torrents.name)} @@ ${q}`);
- }
- if (fields.includes('description')) {
- branches.push(sql`${ftsVector(schema.torrents.description)} @@ ${q}`);
- }
- if (fields.includes('nfo')) {
- branches.push(sql`${ftsVector(schema.torrents.nfo)} @@ ${q}`);
+ // Les mêmes prédicats que le listing et les facettes (`searchConditions`) :
+ // titres d'œuvres, étiquettes, et le repli approximatif quand l'exact ne
+ // rend rien. Sinon la bande disait « 9 releases · 0 œuvre » dès que le nom
+ // du fichier ne contenait pas le titre.
+ const { primary, fuzzy } = await searchConditions(query.search);
+ if (primary) {
+ let search = primary;
+ if (fuzzy) {
+ const [row] = await db
+ .select({ n: sql`count(*)::int` })
+ .from(schema.torrents)
+ .where(and(...conditions, VISIBLE, primary));
+ if ((row?.n ?? 0) === 0) search = fuzzy;
}
- conditions.push(branches.length ? or(...branches)! : sql`false`);
- } else {
- // Nothing usable survived the scrub — return the unfiltered page rather
- // than an empty one, same as the flat listing.
+ conditions.push(search);
}
// The mirror has no tsvector, and building one would mean an index over
// data we did not author and may drop wholesale when a partner is removed.
@@ -193,12 +253,54 @@ export default defineEventHandler(async (event) => {
remoteWhere: remote.length ? and(...remote) : undefined,
localOnly,
scope: query.scope as never,
- sortBy: query.sortBy,
+ // Pas de rang plein texte pour un groupe : la pertinence y vaut nouveauté.
+ sortBy: query.sortBy === 'relevance' ? 'age' : query.sortBy,
order: query.order,
});
+ /*
+ * Ce que la carte d'une œuvre montre sans ouvrir : le titre, l'année et
+ * l'affiche tels que le cache des métadonnées les connaît (jamais l'amont
+ * depuis ici — voir `metadata/cached.ts`), et les étiquettes de ses releases
+ * locales, dont la page tire l'échelle des qualités. Deux lectures pour la
+ * page, quel que soit le nombre de groupes.
+ */
+ const refs = groups
+ .filter((gr) => gr.source !== 'solo')
+ .map((gr) => ({ source: gr.source as WorkRef['source'], id: gr.externalId }));
+ // La langue vient de la ligne du membre, déjà lue plus haut : la session
+ // d'administration ne la porte pas.
+ const language = me?.language ?? undefined;
+ const [works, slugRows] = await Promise.all([
+ worksFromCache(refs, language),
+ groups.length
+ ? ((await db.execute(sql`
+ SELECT ${groupKeySql} AS gkey, tg.slug AS slug, count(*)::int AS n
+ FROM ${schema.torrents}
+ JOIN ${schema.torrentTags} tt ON tt.torrent_id = ${schema.torrents.id}
+ JOIN ${schema.tags} tg ON tg.id = tt.tag_id
+ WHERE ${conditions.length ? and(...conditions)! : sql`true`}
+ AND ${VISIBLE}
+ AND (${or(...groups.map((gr) => groupMemberWhere(parseGroupKey(gr.key))))!})
+ GROUP BY 1, 2
+ ORDER BY 1, 3 DESC, 2
+ `)) as unknown as Array<{ gkey: string; slug: string; n: number }>)
+ : [],
+ ]);
+ const slugsByKey = new Map();
+ for (const r of slugRows) {
+ const list = slugsByKey.get(r.gkey) ?? [];
+ list.push(r.slug);
+ slugsByKey.set(r.gkey, list);
+ }
+ const enriched = groups.map((gr) => ({
+ ...gr,
+ work: gr.source === 'solo' ? null : works.get(workRefKey({ source: gr.source as WorkRef['source'], id: gr.externalId })) ?? null,
+ tagSlugs: slugsByKey.get(gr.key) ?? [],
+ }));
+
return {
- groups,
+ groups: enriched,
merged: !localOnly,
pagination: {
page: query.page,
diff --git a/apps/api/routes/api/torrents/index.get.ts b/apps/api/routes/api/torrents/index.get.ts
index 68120044..7fb58610 100644
--- a/apps/api/routes/api/torrents/index.get.ts
+++ b/apps/api/routes/api/torrents/index.get.ts
@@ -1,307 +1,82 @@
import { db, schema } from '@trackarr/db';
import { buildTorrentOrderBy } from '~~/utils/torrentSort';
-import { getStats } from '~~/utils/server';
-import { eq, sql, and, or, inArray, notInArray, isNull, type SQL } from 'drizzle-orm';
+import { eq, sql, and, type SQL } from 'drizzle-orm';
import { validateQuery, torrentQuerySchema } from '~~/utils/schemas';
-import { slugifyTag } from '~~/utils/tags';
-import { normalizeMediaId, tmdbIdBare } from '~~/utils/mediaIds';
-import { getSetting } from '~~/utils/settings';
import {
- FTS_CONFIG,
- SEARCH_FIELDS_SETTING,
- SEARCH_FUZZY_SETTING,
- ftsVector,
- fuzzyTerm,
- parseSearchFields,
- parseSearchFuzzy,
- toPrefixTsQuery,
-} from '~~/utils/search';
-import { adultCategoryIds } from '~~/utils/adultContent';
+ enrichListing,
+ filterConditions,
+ searchConditions,
+ visibilityConditions,
+} from '~~/utils/torrentListing';
+import { recordSearchMiss } from '~~/utils/searchMisses';
+import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit';
/**
- * How many pinned releases one listing may carry.
+ * GET /api/torrents — le listing plat.
*
- * Small on purpose. A pin is an editorial act — "read this one" — and the
- * moment a first screen is all pins the listing has stopped being a listing.
- * An operator who wants ten things at the top wants a homepage block, not a
- * catalogue.
+ * Les prédicats vivent dans `utils/torrentListing.ts`, partagés avec la vue
+ * groupée et les comptes par facette : une route qui réécrit ses conditions
+ * finit par en oublier une, et rien ne le signale. Ce fichier ne garde que ce
+ * qui lui est propre — les épinglés en tête de première page, le repli
+ * approximatif quand la recherche exacte ne rend rien, la pagination.
+ *
+ * Chaque ligne porte, en plus de la table, ce que la page du catalogue montre :
+ * l'essaim vivant, favori et « déjà prise » pour ce membre, l'uploadeur, la
+ * gratuité du moment, et l'œuvre telle que le cache des métadonnées la connaît
+ * — sans jamais interroger un fournisseur depuis ici.
*/
const MAX_PINNED = 5;
export default defineEventHandler(async (event) => {
- // Require authentication
const { user } = await requireUserSession(event);
-
- // Validate query parameters with Zod
+ await rateLimit(event, RATE_LIMITS.public);
const query = validateQuery(event, torrentQuerySchema);
-
const offset = (query.page - 1) * query.limit;
+ const viewer = {
+ id: user.id,
+ isAdmin: !!user.isAdmin,
+ isModerator: !!user.isModerator,
+ language: typeof user.language === 'string' ? user.language : null,
+ };
- // Check if user can see unapproved torrents
- const canSeeUnapproved = user.isAdmin || user.isModerator;
-
- // Refresh the adult preference from the row — the session is updated
- // lazily and we want the toggle to take effect on the very next page.
- const me = await db.query.users.findFirst({
- where: eq(schema.users.id, user.id),
- columns: { showAdultContent: true },
- });
- const showAdult = me?.showAdultContent ?? false;
-
- // Build where clause
- const conditions: SQL[] = [];
-
- // Only show accepted torrents to regular users (but show their own
- // pending / changes_requested / rejected so they can find them in
- // their own listings even though they're hidden from the public
- // catalogue).
- if (!canSeeUnapproved) {
- conditions.push(
- or(
- eq(schema.torrents.moderationStatus, 'accepted'),
- eq(schema.torrents.uploaderId, user.id)
- )!
- );
- }
-
- /*
- * `is_active` est un interrupteur d'opérateur, et il ne coupait pas ici.
- *
- * `apps/tracker/db/queries/torrents.sql` le décrit comme tel et le tracker Go
- * refuse d'annoncer une release inactive. Côté API il est honoré par RSS, par
- * Torznab, par la fédération, par les groupes et par les statistiques — mais
- * il était ABSENT du catalogue web, de la fiche et du téléchargement du
- * `.torrent`, c'est-à-dire des trois seules surfaces qui comptent pour un
- * retrait. Un opérateur qui basculait le drapeau à la main pour une demande
- * DMCA voyait la release disparaître partout SAUF de l'endroit où on la
- * trouve et de celui où on la récupère.
- *
- * Rien n'écrit `false` dans le code aujourd'hui, donc c'était inerte — un
- * piège qui attendait la première fois qu'on s'en serve.
- *
- * Le personnel et le téléverseur continuent de voir la ligne : ils voient
- * déjà les dépôts en attente, et retirer une release de la vue de celui qui
- * doit la traiter n'aide personne.
- */
- if (!canSeeUnapproved) {
- conditions.push(eq(schema.torrents.isActive, true));
- }
-
- // Hide adult-categorised torrents from users who haven't opted in.
- // Uncategorised torrents (categoryId = null) are never adult so they
- // pass through unconditionally.
- if (!showAdult) {
- const adultIds = await adultCategoryIds();
- if (adultIds.length > 0) {
- conditions.push(
- or(
- isNull(schema.torrents.categoryId),
- notInArray(schema.torrents.categoryId, adultIds)
- )!
- );
- }
- }
-
- // Free-text search. The infohash is handled upstream: it is an exact match
- // served by a unique index and has no business in the text path. IMDb / TMDb
- // / TVDB links never even reach here — the search bar detects them
- // client-side and sends them as dedicated parameters (see below).
- let searchCondition: SQL | null = null;
- let fuzzyFallback: SQL | null = null;
- if (query.search) {
- const isHash = /^[0-9a-fA-F]{40}$/.test(query.search);
- if (isHash) {
- searchCondition = eq(schema.torrents.infoHash, query.search.toLowerCase());
- } else {
- const fields = parseSearchFields(await getSetting(SEARCH_FIELDS_SETTING));
- const tsq = toPrefixTsQuery(query.search);
- if (tsq && fields.length) {
- const q = sql`to_tsquery(${FTS_CONFIG}, ${tsq})`;
- const branches: SQL[] = [];
- if (fields.includes('name')) {
- branches.push(sql`${ftsVector(schema.torrents.name)} @@ ${q}`);
- }
- if (fields.includes('description')) {
- branches.push(sql`${ftsVector(schema.torrents.description)} @@ ${q}`);
- }
- if (fields.includes('nfo')) {
- branches.push(sql`${ftsVector(schema.torrents.nfo)} @@ ${q}`);
- }
- if (fields.includes('tags')) {
- // A correlated EXISTS rather than a join: the join would duplicate
- // rows for a torrent carrying several matching tags, and would need a
- // DISTINCT that breaks pagination.
- branches.push(sql`EXISTS (
- SELECT 1 FROM ${schema.torrentTags} tt
- JOIN ${schema.tags} tg ON tg.id = tt.tag_id
- WHERE tt.torrent_id = ${schema.torrents.id}
- AND ${ftsVector(sql`tg.name`)} @@ ${q}
- )`);
- }
- searchCondition = branches.length > 1 ? or(...branches)! : branches[0]!;
-
- // Typo fallback, prepared here but only executed when the full-text
- // pass returns nothing: the trigram costs ten times more (237 ms
- // against 23 ms over 200,000 rows), which is only justified in the face
- // of an empty results page. `word_similarity`, not `similarity`: over a
- // whole release name the global similarity stays below the threshold
- // and never finds anything.
- const fuzzy = fuzzyTerm(query.search);
- if (fuzzy && parseSearchFuzzy(await getSetting(SEARCH_FUZZY_SETTING))) {
- fuzzyFallback = sql`${fuzzy} <% ${schema.torrents.name}`;
- }
- } else if (tsq) {
- // The operator disabled every field: text search returns nothing
- // rather than returning everything.
- searchCondition = sql`false`;
- }
- }
- }
- if (query.categoryId) {
- // add category and subcategories filter
- const subcategories = await db.query.categories.findMany({
- where: eq(schema.categories.parentId, query.categoryId),
- columns: { id: true },
- });
- conditions.push(
- or(
- eq(schema.torrents.categoryId, query.categoryId),
- ...subcategories.map((subcat) => eq(schema.torrents.categoryId, subcat.id))
- )!
- );
- }
-
- // External media-id filters. Mirror the Torznab handler so a user
- // pasting an IMDb URL into the search bar finds the same torrents
- // Sonarr/Radarr would. Unparseable input → `WHERE false` rather
- // than silently widening to "ignore the filter".
- if (query.imdbid) {
- const norm = normalizeMediaId('imdb', query.imdbid);
- conditions.push(norm ? eq(schema.torrents.imdbId, norm) : sql`false`);
- }
- if (query.tmdbid) {
- // Stored TMDb id may carry a `tv/` or `movie/` prefix; bare query
- // values must still match. Same pattern as the Torznab handler.
- const norm = normalizeMediaId('tmdb', query.tmdbid);
- const bare = norm ? tmdbIdBare(norm) : null;
- if (norm && bare) {
- conditions.push(
- or(
- eq(schema.torrents.tmdbId, norm),
- eq(schema.torrents.tmdbId, bare),
- eq(schema.torrents.tmdbId, `movie/${bare}`),
- eq(schema.torrents.tmdbId, `tv/${bare}`)
- )!
- );
- } else {
- conditions.push(sql`false`);
- }
- }
- if (query.tvdbid) {
- const norm = normalizeMediaId('tvdb', query.tvdbid);
- conditions.push(norm ? eq(schema.torrents.tvdbId, norm) : sql`false`);
- }
-
- // Tag filter — `?tag=fhd,bluray` returns torrents that carry every tag in
- // the list (AND semantics, matching how a user thinks: "show me torrents
- // that are FHD AND Blu-Ray"). Resolves both names and slugs so the URL
- // stays readable while the autocomplete can keep submitting whatever the
- // user typed.
- if (query.tag) {
- const slugs = Array.from(
- new Set(
- query.tag
- .split(',')
- .map((s) => slugifyTag(s))
- .filter(Boolean)
- )
- );
- if (slugs.length > 0) {
- const matchedTags = await db.query.tags.findMany({
- where: inArray(schema.tags.slug, slugs),
- columns: { id: true },
- });
- // If any requested slug doesn't exist, no torrent can carry it →
- // honest empty result instead of widening to "ignore the filter".
- if (matchedTags.length !== slugs.length) {
- conditions.push(sql`false`);
- } else {
- const tagIds = matchedTags.map((t) => t.id);
- // Sub-select: torrent_id matches every requested tag id. Use
- // `count(distinct tag_id)` so the predicate stays correct
- // regardless of any future de-normalisation in torrent_tags.
- conditions.push(
- inArray(
- schema.torrents.id,
- db
- .select({ torrentId: schema.torrentTags.torrentId })
- .from(schema.torrentTags)
- .where(inArray(schema.torrentTags.tagId, tagIds))
- .groupBy(schema.torrentTags.torrentId)
- .having(
- sql`count(distinct ${schema.torrentTags.tagId}) = ${tagIds.length}`
- )
- )
- );
- }
- }
- }
+ const conditions: SQL[] = [
+ ...(await visibilityConditions(viewer)),
+ ...(await filterConditions(query, viewer)),
+ ];
+ const { primary: searchCondition, fuzzy: fuzzyFallback, rankExact, rankFuzzy } = await searchConditions(query.search);
- // The search predicate is kept apart from the filters, so the fuzzy fallback
- // replays the same query replacing only that.
- /**
- * Pinned releases are lifted out of the flow entirely, on every page.
- *
- * Not folded into the ORDER BY, which is the obvious implementation and the
- * wrong one: putting `is_sticky DESC` in front of the sort key stops every
- * existing single-column index from serving it, so a catalogue that sorted by
- * date off an index starts doing a full sort on every page. A separate,
- * capped query costs one extra round trip on page 1 and nothing after.
- *
- * Excluded on every page rather than only on page 1 so a torrent appears
- * exactly once in a listing, and so `total` and the page count agree with
- * what the reader can actually scroll through.
- */
const notPinned = eq(schema.torrents.isSticky, false);
const compose = (search: SQL | null) => {
const all = search ? [...conditions, search, notPinned] : [...conditions, notPinned];
return all.length > 0 ? and(...all) : undefined;
};
const countRows = async (where: SQL | undefined) => {
- const [row] = await db
- .select({ count: sql`count(*)::int` })
- .from(schema.torrents)
- .where(where);
+ const [row] = await db.select({ count: sql`count(*)::int` }).from(schema.torrents).where(where);
return row?.count ?? 0;
};
let whereClause = compose(searchCondition);
- // The count is needed for pagination anyway: we do it first and it doubles
- // as the probe for the fallback. Probing separately would have added a query
- // to every single-word search, including the 95% that find their result
- // first time.
let total = await countRows(whereClause);
+ let usedFuzzy = false;
if (total === 0 && fuzzyFallback) {
whereClause = compose(fuzzyFallback);
total = await countRows(whereClause);
+ usedFuzzy = true;
}
-
- // Get torrents with optional search.
- // Ordering lives in `utils/torrentSort` so the key-to-SQL mapping can be
- // tested without a request; see the notes there on why the swarm columns read
- // the collector's snapshot and why every non-default sort carries a
- // tiebreaker.
- const orderByClause = buildTorrentOrderBy(query.sortBy, query.order);
-
- /**
- * The pinned block, page 1 only, and under the SAME filters as the flow —
- * a release pinned site-wide has no business appearing in a search for
- * something else, and a member filtering by category is asking a question
- * that a pin does not override.
- *
- * Capped hard: pinning is an editorial act and a page whose first screen is
- * all pins is a page with no listing on it.
- */
+ // Ce qu'on cherche en vain, compté pour l'administration : la première page,
+ // un texte d'au moins trois caractères (une lettre en cours de frappe n'est
+ // pas une recherche), et AUCUN autre filtre — « frieren » sous « mes
+ // favoris » qui ne rend rien ne dit pas que Frieren manque au catalogue.
+ const textOnly =
+ !query.categoryId && !query.tag && !query.tagGroups && !query.imdbid && !query.tmdbid && !query.tvdbid &&
+ !query.uploader && query.year === undefined && query.season === undefined && query.episode === undefined &&
+ query.minSeeders === undefined && !query.freeleech && !query.notTaken && !query.hideSuperseded &&
+ !query.favorites && !query.groupKey && !query.since;
+ if (query.search && textOnly && query.page === 1 && total === 0) void recordSearchMiss(query.search, viewer.id);
+
+ const orderByClause = buildTorrentOrderBy(query.sortBy, query.order, {
+ rank: usedFuzzy ? rankFuzzy : rankExact,
+ });
const pinnedRows =
query.page === 1
? await db.query.torrents.findMany({
@@ -315,90 +90,22 @@ export default defineEventHandler(async (event) => {
limit: MAX_PINNED,
})
: [];
-
const torrents = await db.query.torrents.findMany({
where: whereClause,
- // Negative projection: select every column EXCEPT the raw .torrent
- // blob. Without this drizzle pulls `torrent_data` (bytea) for every
- // row and Nitro serialises each as a {"type":"Buffer","data":[...]}
- // byte array ~4x its size — shipping the full file list + piece
- // hashes of every torrent to each member and turning a single
- // `?limit=100` into a multi-hundred-MB response (finding M4). Only
- // the gated download route reads torrent_data.
columns: { torrentData: false },
- with: {
- category: true,
- torrentTags: { with: { tag: true } },
- },
+ with: { category: true, torrentTags: { with: { tag: true } } },
orderBy: orderByClause,
limit: query.limit,
offset,
});
- // `total` was already computed above: it is what gates the fuzzy fallback.
-
- // Pinned rows and flow rows are enriched as one list — one Redis round of
- // stats, one favourites query — then split back apart at the end. Doing it
- // twice would double both for a block that is usually empty.
- const allRows = [...pinnedRows, ...torrents];
-
- // Enrich with live stats from Redis. Tolerate partial failure: a Redis hiccup
- // for one torrent should not fail the whole listing — fall back to zeroes.
- const settled = await Promise.allSettled(
- allRows.map((t) => getStats(t.infoHash))
- );
-
- // Bulk-lookup the viewer's favorited torrent_ids among the page
- // slice — one indexed query for the whole page, then a `Set`
- // membership check per row when projecting. Keeps the star
- // toggle's filled/outline state authoritative without a
- // per-row round-trip.
- let favoritedSet = new Set();
- if (allRows.length > 0) {
- const rows = await db
- .select({ torrentId: schema.torrentFavorites.torrentId })
- .from(schema.torrentFavorites)
- .where(
- and(
- eq(schema.torrentFavorites.userId, user.id),
- inArray(
- schema.torrentFavorites.torrentId,
- allRows.map((t) => t.id),
- ),
- ),
- );
- favoritedSet = new Set(rows.map((r) => r.torrentId));
- }
-
- const enriched = allRows.map((torrent, i) => {
- const r = settled[i];
- const stats =
- r.status === 'fulfilled'
- ? r.value
- : { seeders: 0, leechers: 0, completed: 0 };
- const tags = torrent.torrentTags?.map((tt) => tt.tag) ?? [];
- return {
- ...torrent,
- torrentTags: undefined, // collapse the junction-table noise
- tags,
- stats: {
- seeders: stats.seeders,
- leechers: stats.leechers,
- completed: stats.completed,
- },
- viewerFavorited: favoritedSet.has(torrent.id),
- };
- });
-
+ const enriched = await enrichListing([...pinnedRows, ...torrents], viewer);
return {
- // Split back apart in the order they went in.
pinned: enriched.slice(0, pinnedRows.length),
data: enriched.slice(pinnedRows.length),
pagination: {
page: query.page,
limit: query.limit,
- // Pinned rows are outside this count, which is what keeps the page
- // count honest about the flow the reader is paging through.
total,
pages: Math.ceil(total / query.limit),
},
diff --git a/apps/api/routes/api/torrents/index.post.ts b/apps/api/routes/api/torrents/index.post.ts
index 222cebb6..9c2449ce 100644
--- a/apps/api/routes/api/torrents/index.post.ts
+++ b/apps/api/routes/api/torrents/index.post.ts
@@ -626,6 +626,7 @@ export default defineEventHandler(async (event) => {
tmdbId,
tvdbId,
uploaderId: user.id,
+ ...seriesPosition(name),
});
// And the IRC channel. A fresh upload carries no per-torrent buff of its
// own — those are a moderation action — so the three multiplier fields are
diff --git a/apps/api/test/catalogueFilters.test.ts b/apps/api/test/catalogueFilters.test.ts
new file mode 100644
index 00000000..94f8f7ee
--- /dev/null
+++ b/apps/api/test/catalogueFilters.test.ts
@@ -0,0 +1,114 @@
+import { describe, it, expect } from 'vitest';
+import { PgDialect } from 'drizzle-orm/pg-core';
+import type { SQL } from 'drizzle-orm';
+import { filterConditions, tagGroupsCondition, YEAR_IN_NAME_RE, yearInNameRe, isFlag } from '../utils/torrentListing';
+import { boundedTagGroups } from '../utils/schemas';
+
+/**
+ * Les prédicats du catalogue, rendus en SQL par le dialecte que la requête
+ * emploie vraiment.
+ *
+ * Ces filtres décident ce qu'un membre voit ; ils étaient couverts par rien.
+ * Ce qui compte ici n'est pas la forme du texte mais ce qui est PARAMÉTRÉ
+ * (aucune valeur du membre concaténée), et l'accord entre le filtre et la
+ * facette qui en annonce le compte.
+ */
+const dialect = new PgDialect();
+const render = (chunk: SQL) => dialect.sqlToQuery(chunk);
+const viewer = { id: 'viewer-1', isAdmin: false, isModerator: false };
+
+describe('filterConditions', () => {
+ it('cherche l’année dans le nom, jamais collée à une résolution', async () => {
+ const [cond] = await filterConditions({ year: 2024 }, viewer);
+ const q = render(cond!);
+ // L'année part en PARAMÈTRE, et le motif est celui que la facette partage.
+ expect(q.params).toEqual([yearInNameRe(2024)]);
+ expect(q.sql).not.toContain('2024');
+ /*
+ * Les deux motifs disent la même chose, et ce qu'ils disent tient à quatre
+ * regards : pas de chiffre avant ni après (`12019`, `x20230`), et pas de
+ * `x`/`X` collé à un chiffre de l'autre côté — sinon `1920x1080` remplit un
+ * lot « 1920 » qui ne parle de rien, dans les deux casses.
+ */
+ for (const re of [YEAR_IN_NAME_RE, yearInNameRe(2024)]) {
+ expect(re).toContain('(? {
+ for (const [key, interval] of [
+ ['24h', "interval '24 hours'"],
+ ['7d', "interval '7 days'"],
+ ['30d', "interval '30 days'"],
+ ] as const) {
+ const conds = await filterConditions({ since: key }, viewer);
+ expect(render(conds[0]!).sql).toContain(interval);
+ }
+ // Une clé inconnue ne filtre rien — et surtout n'atteint pas `sql.raw`.
+ expect(await filterConditions({ since: 'evil' }, viewer)).toHaveLength(0);
+ // Une propriété héritée d'Object n'est pas une fenêtre.
+ expect(await filterConditions({ since: 'constructor' }, viewer)).toHaveLength(0);
+ });
+
+ it('rapporte les favoris et les prises au membre qui regarde, par paramètre', async () => {
+ const [fav] = await filterConditions({ favorites: '1' }, viewer);
+ expect(render(fav!).params).toContain('viewer-1');
+ const [taken] = await filterConditions({ notTaken: 'true' }, viewer);
+ expect(render(taken!).params).toContain('viewer-1');
+ // Un drapeau n'est vrai que sous ces deux formes.
+ expect(isFlag('1')).toBe(true);
+ expect(isFlag('true')).toBe(true);
+ expect(isFlag('yes')).toBe(false);
+ expect(await filterConditions({ favorites: 'yes' }, viewer)).toHaveLength(0);
+ });
+
+ it('sélectionne les releases d’une œuvre par ses colonnes, pas par la clé calculée', async () => {
+ const [cond] = await filterConditions({ groupKey: 'tmdb:tv/209867', groupScope: 'season' }, viewer);
+ const q = render(cond!);
+ // L'identifiant tel que la colonne le porte, en paramètre, et les autres
+ // sources écartées : c'est ce qui rend le prédicat indexable.
+ expect(q.params).toEqual(['tv/209867']);
+ expect(q.sql).toContain('"igdb_id" IS NULL');
+ // `groupKeySql` est un CASE : le voir ici voudrait dire qu'aucun index ne sert.
+ expect(q.sql).not.toContain('CASE');
+ // La découpe « saison » exige une saison sans épisode.
+ expect(q.sql).toMatch(/season/);
+ });
+
+ it('n’ajoute rien pour une requête vide', async () => {
+ expect(await filterConditions({}, viewer)).toHaveLength(0);
+ });
+});
+
+describe('tagGroupsCondition', () => {
+ it('lit « OU dans un groupe, ET entre les groupes »', () => {
+ const q = render(tagGroupsCondition('hevc,x265;1080p')!);
+ // Deux EXISTS liés par AND, et les slugs en paramètres.
+ expect(q.sql.match(/EXISTS/g)).toHaveLength(2);
+ expect(q.params).toEqual(expect.arrayContaining(['hevc', 'x265', '1080p']));
+ });
+
+ it('dédoublonne, ignore les vides, et ne rend rien quand il ne reste rien', () => {
+ const q = render(tagGroupsCondition('hevc,hevc,;;')!);
+ expect(q.sql.match(/EXISTS/g)).toHaveLength(1);
+ expect(q.params.filter((p) => p === 'hevc')).toHaveLength(1);
+ expect(tagGroupsCondition(';;')).toBeNull();
+ expect(tagGroupsCondition('')).toBeNull();
+ });
+});
+
+describe('boundedTagGroups', () => {
+ it('accepte huit groupes de dix, refuse au-delà', () => {
+ // Le coût est linéaire en groupes ET en synonymes : les facettes lancent
+ // une requête par groupe, chacune portant les autres en EXISTS.
+ expect(boundedTagGroups(Array(8).fill('a').join(';'))).toBe(true);
+ expect(boundedTagGroups(Array(9).fill('a').join(';'))).toBe(false);
+ expect(boundedTagGroups(Array(10).fill('a').join(','))).toBe(true);
+ expect(boundedTagGroups(Array(11).fill('a').join(','))).toBe(false);
+ // Les groupes vides ne comptent pas : `a;;;` reste un groupe.
+ expect(boundedTagGroups('a;;;;;;;;;;;;')).toBe(true);
+ });
+});
diff --git a/apps/api/test/clientIp.test.ts b/apps/api/test/clientIp.test.ts
index 7c97d68d..dd87fd72 100644
--- a/apps/api/test/clientIp.test.ts
+++ b/apps/api/test/clientIp.test.ts
@@ -130,3 +130,58 @@ describe('TRUST_PROXY on', () => {
expect(getClientIP(event)).toBe('203.0.113.9');
});
});
+
+// Le rendu serveur n'est pas un client.
+//
+// Le compteur d'abus est par adresse, et le conteneur web n'en a qu'une pour
+// tout le site. Mesuré sur la pile e2e : quatre requêtes d'API par page vue
+// s'y accumulaient, donc vingt-cinq pages en dix secondes suffisaient à mettre
+// l'instance entière sur liste noire. Le discriminant retenu — pair socket
+// privé ET aucun en-tête de transfert — vient de ce que le proxy du conteneur
+// web fait déjà : il pose `x-forwarded-for` sur ce qu'il RELAIE, et rien sur
+// ce qu'il émet pour lui-même.
+describe('isInternalOrigin', () => {
+ it('reconnaît une requête émise par le rendu serveur', async () => {
+ const { isInternalOrigin } = await load({});
+ expect(isInternalOrigin(eventWith({}, '172.22.0.8'))).toBe(true);
+ expect(isInternalOrigin(eventWith({}, '10.0.0.4'))).toBe(true);
+ expect(isInternalOrigin(eventWith({}, '::1'))).toBe(true);
+ expect(isInternalOrigin(eventWith({}, '::ffff:127.0.0.1'))).toBe(true);
+ });
+
+ it('ne reconnaît PAS une requête relayée pour un navigateur', async () => {
+ // C'est le cas d'une pile sans Caddy : le conteneur web relaie les appels
+ // du navigateur et pose le pair qu'il a vu.
+ const { isInternalOrigin } = await load({});
+ expect(
+ isInternalOrigin(
+ eventWith({ 'x-forwarded-for': '203.0.113.9' }, '172.22.0.8'),
+ ),
+ ).toBe(false);
+ for (const h of ['x-real-ip', 'cf-connecting-ip', 'true-client-ip', 'forwarded']) {
+ expect(isInternalOrigin(eventWith({ [h]: '203.0.113.9' }, '172.22.0.8'))).toBe(
+ false,
+ );
+ }
+ });
+
+ it('ne peut pas être obtenu depuis Internet', async () => {
+ // Une adresse publique ne passe pas, avec ou sans en-tête — et ajouter un
+ // en-tête ne fait jamais qu'exempter MOINS.
+ const { isInternalOrigin } = await load({});
+ expect(isInternalOrigin(eventWith({}, '203.0.113.9'))).toBe(false);
+ expect(isInternalOrigin(eventWith({}, '8.8.8.8'))).toBe(false);
+ // 172.32 est PUBLIQUE : la plage privée s'arrête à 172.31.
+ expect(isInternalOrigin(eventWith({}, '172.32.0.1'))).toBe(false);
+ expect(isInternalOrigin(eventWith({}, '172.15.0.1'))).toBe(false);
+ });
+
+ it('ne dépend pas de TRUST_PROXY', async () => {
+ // Le filtre lit le pair SOCKET, jamais la valeur résolue : sinon un client
+ // pourrait se déclarer privé par en-tête dès que TRUST_PROXY est actif.
+ const { isInternalOrigin } = await load({ TRUST_PROXY: 'true' });
+ expect(
+ isInternalOrigin(eventWith({ 'x-forwarded-for': '10.0.0.9' }, '203.0.113.9')),
+ ).toBe(false);
+ });
+});
diff --git a/apps/api/test/groupSort.test.ts b/apps/api/test/groupSort.test.ts
index 167b4237..eb58a748 100644
--- a/apps/api/test/groupSort.test.ts
+++ b/apps/api/test/groupSort.test.ts
@@ -20,8 +20,9 @@ describe('buildGroupOrderBy', () => {
it('reads the newest release descending and the oldest ascending', () => {
// Anything else makes "oldest first" rank works by their most recent
// upload, which is not what the phrase means.
- expect(render('age', 'desc')).toBe('latest DESC');
- expect(render('age', 'asc')).toBe('oldest ASC');
+ // `gkey` ferme le tri : deux œuvres publiées à la même seconde ne permutent plus entre pages.
+ expect(render('age', 'desc')).toBe('latest DESC, gkey ASC');
+ expect(render('age', 'asc')).toBe('oldest ASC, gkey ASC');
});
it('sorts the swarm columns on the group total, not on any one release', () => {
@@ -53,7 +54,7 @@ describe('buildGroupOrderBy', () => {
expect(render(key, 'desc')).toContain('latest DESC');
}
// `age` needs no second key: it already is the tiebreaker.
- expect(render('age', 'desc')).not.toContain(',');
+ expect(render('age', 'desc')).not.toContain('latest DESC, latest');
});
it('flips direction and null placement together', () => {
diff --git a/apps/api/test/metadataUpstream.test.ts b/apps/api/test/metadataUpstream.test.ts
new file mode 100644
index 00000000..f82382cf
--- /dev/null
+++ b/apps/api/test/metadataUpstream.test.ts
@@ -0,0 +1,84 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+
+/**
+ * Une panne amont n'est pas une absence.
+ *
+ * Mesuré sur la pile e2e : une rafale de recherches au chargement du catalogue
+ * a expiré à huit secondes, et chaque expiration était retenue comme « pas de
+ * fiche » pendant une heure — trente fiches sans affiche pour un réseau qui a
+ * toussé. Le 404, lui, doit rester retenu une heure : c'est une vraie absence.
+ */
+const store = new Map();
+vi.mock('../utils/server', () => ({
+ redis: {
+ get: async (key: string) => store.get(key)?.value ?? null,
+ setex: async (key: string, ttl: number, value: string) => {
+ store.set(key, { value, ttl });
+ return 'OK';
+ },
+ set: async () => 'OK',
+ del: async () => 1,
+ },
+}));
+
+async function load() {
+ vi.resetModules();
+ process.env.TMDB_API_KEY = 'test-key';
+ return await import('../utils/metadata/tmdb');
+}
+
+const written = () => [...store.entries()].map(([key, e]) => ({ key, ...e }));
+
+beforeEach(() => store.clear());
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ delete process.env.TMDB_API_KEY;
+});
+
+describe('TMDB : absence contre panne', () => {
+ it('un 404 est une absence, retenue une heure', async () => {
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ vi.stubGlobal('fetch', async () => new Response('', { status: 404 }));
+ const { tmdbSource } = await load();
+ expect(await tmdbSource.lookup('movie/1', 'movie')).toBeNull();
+ const [entry] = written();
+ expect(entry?.value).toBe('__null__');
+ expect(entry?.ttl).toBe(3600);
+ });
+
+ it('un délai dépassé est une panne, retenue deux minutes, et ne casse rien', async () => {
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ vi.stubGlobal('fetch', async () => {
+ throw new DOMException('The operation was aborted due to timeout', 'TimeoutError');
+ });
+ const { tmdbSource } = await load();
+ expect(await tmdbSource.lookup('tv/65942', 'tv')).toBeNull();
+ const [entry] = written();
+ expect(entry?.value).toBe('__null__');
+ expect(entry?.ttl).toBe(120);
+ });
+
+ it('un 503 amont aussi, sur une recherche', async () => {
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ vi.stubGlobal('fetch', async () => new Response('busy', { status: 503 }));
+ const { tmdbSource } = await load();
+ expect(await tmdbSource.search('frieren', 'tv')).toEqual([]);
+ expect(written()[0]?.ttl).toBe(120);
+ });
+
+ it('une réponse vaut toujours vingt-quatre heures', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ async () =>
+ new Response(JSON.stringify({ id: 1, name: 'Frieren', first_air_date: '2023-09-29' }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }),
+ );
+ const { tmdbSource } = await load();
+ const meta = await tmdbSource.lookup('tv/1', 'tv');
+ expect(meta?.title).toBe('Frieren');
+ expect(written()[0]?.ttl).toBe(86400);
+ });
+});
diff --git a/apps/api/test/torrentSort.test.ts b/apps/api/test/torrentSort.test.ts
index 09dd4b64..869b16c3 100644
--- a/apps/api/test/torrentSort.test.ts
+++ b/apps/api/test/torrentSort.test.ts
@@ -1,3 +1,4 @@
+import { sql } from 'drizzle-orm';
/**
* The catalogue listing's ordering. Rendered to SQL text through the same
* dialect the query builder uses, so these assertions read the statement
@@ -24,14 +25,15 @@ describe('buildTorrentOrderBy', () => {
expect(primary).toContain('moderated_at');
expect(primary).toContain('created_at');
expect(primary).toContain('DESC');
- // `age` is its own tiebreaker, so it must not be repeated.
- expect(rest).toHaveLength(0);
+ // L'identifiant ferme le tri : deux dates égales ne permutent plus entre pages.
+ expect(rest).toHaveLength(1);
+ expect(rest[0]).toContain('"id"');
});
it('carries a stable tiebreaker on every other key', () => {
- for (const key of TORRENT_SORT_KEYS.filter((k) => k !== 'age')) {
+ for (const key of TORRENT_SORT_KEYS.filter((k) => k !== 'age' && k !== 'relevance')) {
const clauses = render(key, 'desc');
- expect(clauses).toHaveLength(2);
+ expect(clauses).toHaveLength(3);
// Ties resolve on availability date, descending, whatever the primary
// direction is — otherwise rows with equal values can swap between pages.
expect(clauses[1]).toMatch(/coalesce/i);
@@ -39,6 +41,13 @@ describe('buildTorrentOrderBy', () => {
}
});
+ it('relevance ranks by the text-search score, and is plain age without one', () => {
+ // Sans texte tapé, la pertinence n'existe pas : c'est la nouveauté, une clause.
+ expect(buildTorrentOrderBy('relevance', 'desc')).toHaveLength(2);
+ const ranked = buildTorrentOrderBy('relevance', 'desc', { rank: sql`1` });
+ expect(ranked).toHaveLength(3);
+ });
+
it('flips the primary direction and the null placement together', () => {
const [desc] = render('size', 'desc');
expect(desc).toContain('DESC');
diff --git a/apps/api/utils/metadata/cached.ts b/apps/api/utils/metadata/cached.ts
new file mode 100644
index 00000000..51beee1c
--- /dev/null
+++ b/apps/api/utils/metadata/cached.ts
@@ -0,0 +1,101 @@
+import { redis } from '../server';
+import { NEG_SENTINEL } from './types';
+import { tmdbLocale } from './tmdb';
+
+/**
+ * Ce que le catalogue sait d'une œuvre SANS appeler personne.
+ *
+ * La fiche d'un torrent remplit le cache des métadonnées (`meta:v1:*`) à la
+ * première visite ; le catalogue lit ce cache, et seulement lui. Une œuvre que
+ * personne n'a encore ouverte n'a pas de titre ici — c'est voulu : vingt
+ * recherches amont par page de résultats, c'est la rafale qui a mis toute une
+ * page sans affiche pendant une heure (voir `META_TTL.ERR_S`).
+ *
+ * Un seul `MGET` pour toute la page : les clés candidates de chaque référence
+ * sont énumérées (les fiches se cachent sous l'indice `tv`, `movie` ou `auto`,
+ * avec l'identifiant préfixé ou nu, dans la langue du visiteur puis en anglais)
+ * et la première valeur trouvée gagne.
+ */
+export interface WorkRef {
+ source: 'tmdb' | 'igdb' | 'openlibrary';
+ id: string;
+}
+
+export interface CachedWork {
+ source: WorkRef['source'];
+ id: string;
+ title: string;
+ year: number | null;
+ posterUrl: string | null;
+ type: string | null;
+ /** « R G B », la couleur dominante de l'affiche telle que la fiche l'a déposée ; null tant qu'aucune fiche n'a été ouverte. */
+ tint: string | null;
+}
+
+export function workRefKey(ref: WorkRef): string {
+ return `${ref.source}:${ref.id}`;
+}
+
+/** La clé de la teinte déposée par la fiche (voir `routes/api/metadata/tint.post.ts`). */
+export const tintKey = (ref: WorkRef) => `meta:v1:tint:${ref.source}:${ref.id}`;
+
+export function candidateKeys(ref: WorkRef, language?: string): string[] {
+ if (ref.source === 'igdb') return [`meta:v1:igdb:${ref.id}`];
+ if (ref.source === 'openlibrary') return [`meta:v1:openlibrary:${ref.id}`];
+ const locales = Array.from(new Set([tmdbLocale(language), tmdbLocale(undefined)]));
+ const m = ref.id.match(/^(movie|tv)\/(\d+)$/);
+ const ids = m ? [ref.id, m[2]!] : [ref.id];
+ const hints = m ? [m[1]!, 'auto'] : ['movie', 'tv', 'auto'];
+ const out: string[] = [];
+ for (const locale of locales) for (const hint of hints) for (const id of ids) out.push(`meta:v1:tmdb:${locale}:${hint}:${id}`);
+ return out;
+}
+
+export async function worksFromCache(
+ refs: WorkRef[],
+ language?: string,
+): Promise
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
(['name', 'description']);
// On by default: without it, a typo returns an empty page.
const fuzzy = ref(true);
+// Le catalogue : la vue et le tri à l'arrivée, la taille d'une page, les facettes du rail.
+const VIEWS = ['grouped', 'simple'] as const;
+const SORTS = ['auto', 'age', 'name', 'size', 'seeders', 'leechers', 'completed'] as const;
+const FACETS = ['category', 'resolution', 'source', 'codec', 'language', 'hdr', 'audio', 'year', 'options'] as const;
+const catalogueView = ref<(typeof VIEWS)[number]>('grouped');
+const catalogueSort = ref<(typeof SORTS)[number]>('auto');
+const cataloguePageSize = ref(20);
+const catalogueFacets = ref([...FACETS]);
const loading = ref(false);
const saved = ref(false);
-const { data } = await useFetch<{ searchFields?: Field[]; searchFuzzy?: boolean }>(
- '/api/admin/settings',
-);
+const { data } = await useFetch<{
+ searchFields?: Field[];
+ searchFuzzy?: boolean;
+ catalogueDefaultView?: (typeof VIEWS)[number];
+ catalogueDefaultSort?: (typeof SORTS)[number];
+ cataloguePageSize?: number;
+ catalogueFacets?: string[];
+}>('/api/admin/settings');
watch(
data,
(v) => {
if (Array.isArray(v?.searchFields)) fields.value = [...v.searchFields];
if (typeof v?.searchFuzzy === 'boolean') fuzzy.value = v.searchFuzzy;
+ if (v?.catalogueDefaultView) catalogueView.value = v.catalogueDefaultView;
+ if (v?.catalogueDefaultSort) catalogueSort.value = v.catalogueDefaultSort;
+ if (typeof v?.cataloguePageSize === 'number') cataloguePageSize.value = v.cataloguePageSize;
+ if (Array.isArray(v?.catalogueFacets)) catalogueFacets.value = [...v.catalogueFacets];
},
{ immediate: true },
);
@@ -134,6 +206,10 @@ async function save() {
body: {
searchFields: FIELDS.filter((f) => fields.value.includes(f)),
searchFuzzy: fuzzy.value,
+ catalogueDefaultView: catalogueView.value,
+ catalogueDefaultSort: catalogueSort.value,
+ cataloguePageSize: Math.min(50, Math.max(10, Math.round(cataloguePageSize.value) || 20)),
+ catalogueFacets: FACETS.filter((f) => catalogueFacets.value.includes(f)),
},
});
saved.value = true;
diff --git a/apps/web/app/components/admin/Stats.vue b/apps/web/app/components/admin/Stats.vue
index 0a8efea9..5002a0d0 100644
--- a/apps/web/app/components/admin/Stats.vue
+++ b/apps/web/app/components/admin/Stats.vue
@@ -91,7 +91,7 @@ function formatNumber(n: number): string {
diff --git a/apps/web/app/components/search/AlertsStrip.vue b/apps/web/app/components/search/AlertsStrip.vue
new file mode 100644
index 00000000..cf584e82
--- /dev/null
+++ b/apps/web/app/components/search/AlertsStrip.vue
@@ -0,0 +1,226 @@
+
+
+
+
+