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> { + const out = new Map(); + const plan = refs.map((ref) => ({ ref, keys: candidateKeys(ref, language) })); + const allKeys = Array.from(new Set([...plan.flatMap((p) => p.keys), ...plan.map((p) => tintKey(p.ref))])); + if (allKeys.length === 0) return out; + let values: (string | null)[] = []; + try { + values = await redis.mget(...allKeys); + } catch { + for (const { ref } of plan) out.set(workRefKey(ref), null); + return out; + } + const byKey = new Map(allKeys.map((k, i) => [k, values[i] ?? null])); + for (const { ref, keys } of plan) { + let found: CachedWork | null = null; + for (const k of keys) { + const raw = byKey.get(k); + if (!raw || raw === NEG_SENTINEL) continue; + try { + const m = JSON.parse(raw) as { + title?: string; + year?: number | null; + releaseDate?: string | null; + posterUrl?: string | null; + type?: string | null; + }; + if (!m?.title) continue; + const year = + typeof m.year === 'number' + ? m.year + : m.releaseDate && /^\d{4}/.test(m.releaseDate) + ? Number(m.releaseDate.slice(0, 4)) + : null; + const rawTint = byKey.get(tintKey(ref)); + const tint = rawTint && /^\d{1,3} \d{1,3} \d{1,3}$/.test(rawTint) ? rawTint : null; + found = { source: ref.source, id: ref.id, title: m.title, year, posterUrl: m.posterUrl ?? null, type: m.type ?? null, tint }; + break; + } catch { + /* entrée illisible : on essaie la suivante */ + } + } + out.set(workRefKey(ref), found); + } + return out; +} diff --git a/apps/api/utils/metadata/igdb.ts b/apps/api/utils/metadata/igdb.ts index cd6e9913..5d0c6a1c 100644 --- a/apps/api/utils/metadata/igdb.ts +++ b/apps/api/utils/metadata/igdb.ts @@ -31,6 +31,7 @@ import type { MediaTypeHint, SearchOptions, } from './types'; +import { guarded, UpstreamUnavailableError } from './upstream'; import { META_TTL, NEG_SENTINEL } from './types'; import { safeHttpUrl } from './safeUrl'; @@ -199,14 +200,16 @@ async function igdbPost(path: string, query: string): Promise body: query, signal: AbortSignal.timeout(8000), }); + if (res.status === 404) return null; if (!res.ok) { console.warn(`[metadata:igdb] ${res.status} on ${path}: ${query}`); - return null; + throw new UpstreamUnavailableError('igdb', res.status); } return (await res.json()) as T; } catch (err) { + if (err instanceof UpstreamUnavailableError) throw err; console.warn(`[metadata:igdb] fetch failed for ${path}:`, err); - return null; + throw new UpstreamUnavailableError('igdb', null); } } @@ -275,23 +278,25 @@ async function lookupGame( /* Redis hiccup */ } - const results = await igdbPost( - '/games', - `fields ${GAME_FIELDS}; where id = ${id}; limit 1;` - ); - const game = results?.[0] ?? null; - const result = game ? normalizeDetail(game) : null; + return guarded(cacheKey, null, async () => { + const results = await igdbPost( + '/games', + `fields ${GAME_FIELDS}; where id = ${id}; limit 1;` + ); + const game = results?.[0] ?? null; + const result = game ? normalizeDetail(game) : null; - try { - if (result) { - await redis.setex(cacheKey, META_TTL.POS_S, JSON.stringify(result)); - } else { - await redis.setex(cacheKey, META_TTL.NEG_S, NEG_SENTINEL); + try { + if (result) { + await redis.setex(cacheKey, META_TTL.POS_S, JSON.stringify(result)); + } else { + await redis.setex(cacheKey, META_TTL.NEG_S, NEG_SENTINEL); + } + } catch { + /* cache write failure non-fatal */ } - } catch { - /* cache write failure non-fatal */ - } - return result; + return result; + }); } async function searchGames( @@ -317,67 +322,69 @@ async function searchGames( // result set returns empty if we combine `search` with a `where` // filter — so we run the raw search and rank-filter client-side // below to push DLCs / mods / expansion-packs down the list. - const escaped = escapeApicalypseString(trimmed); - const results = await igdbPost< - Array - >( - '/games', - `fields id,name,slug,summary,cover.image_id,first_release_date,total_rating,url,category,version_parent; - search "${escaped}"; - limit 20;` - ); - // Promote base games (category 0) ahead of expansions, remakes, - // mods, etc. — keeps the top of the picker readable without - // dropping anything outright (a user uploading a mod still gets - // the mod, just lower in the list). - const ranked = (results ?? []).slice().sort((a, b) => { - const score = (g: { category?: number; version_parent?: number }) => { - const cat = g.category ?? 0; - // Base games + remakes / remasters / expanded / ports rank - // first; expansions and DLCs in the middle; mods last. - if (cat === 0 || cat === 8 || cat === 9 || cat === 10 || cat === 11) { - return g.version_parent == null ? 0 : 1; - } - if (cat === 1 || cat === 2 || cat === 4) return 2; // DLC / expansion - if (cat === 5) return 4; // mod - return 3; - }; - return score(a) - score(b); - }); - const finalHits: MediaSearchHit[] = ranked.slice(0, 8).map((g) => ({ - source: 'igdb', - type: 'game', - id: String(g.id), - title: g.name, - originalTitle: null, - year: g.first_release_date - ? new Date(g.first_release_date * 1000).getUTCFullYear() - : null, - overview: g.summary || null, - posterUrl: g.cover?.image_id - ? igdbImageUrl(g.cover.image_id, COVER_SIZE) - : null, - voteAverage: - typeof g.total_rating === 'number' - ? Math.round((g.total_rating / 10) * 10) / 10 + return guarded(cacheKey, [], async () => { + const escaped = escapeApicalypseString(trimmed); + const results = await igdbPost< + Array + >( + '/games', + `fields id,name,slug,summary,cover.image_id,first_release_date,total_rating,url,category,version_parent; + search "${escaped}"; + limit 20;` + ); + // Promote base games (category 0) ahead of expansions, remakes, + // mods, etc. — keeps the top of the picker readable without + // dropping anything outright (a user uploading a mod still gets + // the mod, just lower in the list). + const ranked = (results ?? []).slice().sort((a, b) => { + const score = (g: { category?: number; version_parent?: number }) => { + const cat = g.category ?? 0; + // Base games + remakes / remasters / expanded / ports rank + // first; expansions and DLCs in the middle; mods last. + if (cat === 0 || cat === 8 || cat === 9 || cat === 10 || cat === 11) { + return g.version_parent == null ? 0 : 1; + } + if (cat === 1 || cat === 2 || cat === 4) return 2; // DLC / expansion + if (cat === 5) return 4; // mod + return 3; + }; + return score(a) - score(b); + }); + const finalHits: MediaSearchHit[] = ranked.slice(0, 8).map((g) => ({ + source: 'igdb', + type: 'game', + id: String(g.id), + title: g.name, + originalTitle: null, + year: g.first_release_date + ? new Date(g.first_release_date * 1000).getUTCFullYear() + : null, + overview: g.summary || null, + posterUrl: g.cover?.image_id + ? igdbImageUrl(g.cover.image_id, COVER_SIZE) : null, - url: safeHttpUrl(g.url, `https://www.igdb.com/games/${g.slug}`), - })); + voteAverage: + typeof g.total_rating === 'number' + ? Math.round((g.total_rating / 10) * 10) / 10 + : null, + url: safeHttpUrl(g.url, `https://www.igdb.com/games/${g.slug}`), + })); - try { - if (finalHits.length > 0) { - await redis.setex( - cacheKey, - META_TTL.SEARCH_S, - JSON.stringify(finalHits) - ); - } else { - await redis.setex(cacheKey, META_TTL.NEG_S, NEG_SENTINEL); + try { + if (finalHits.length > 0) { + await redis.setex( + cacheKey, + META_TTL.SEARCH_S, + JSON.stringify(finalHits) + ); + } else { + await redis.setex(cacheKey, META_TTL.NEG_S, NEG_SENTINEL); + } + } catch { + /* cache write failure non-fatal */ } - } catch { - /* cache write failure non-fatal */ - } - return finalHits; + return finalHits; + }); } /** @@ -395,22 +402,24 @@ async function resolveSlug(slug: string): Promise { /* Redis hiccup */ } - const escaped = escapeApicalypseString(slug); - const results = await igdbPost>( - '/games', - `fields id; where slug = "${escaped}"; limit 1;` - ); - const id = results?.[0]?.id ? String(results[0]!.id) : null; - try { - if (id) { - await redis.setex(cacheKey, META_TTL.POS_S, id); - } else { - await redis.setex(cacheKey, META_TTL.NEG_S, NEG_SENTINEL); + return guarded(cacheKey, null, async () => { + const escaped = escapeApicalypseString(slug); + const results = await igdbPost>( + '/games', + `fields id; where slug = "${escaped}"; limit 1;` + ); + const id = results?.[0]?.id ? String(results[0]!.id) : null; + try { + if (id) { + await redis.setex(cacheKey, META_TTL.POS_S, id); + } else { + await redis.setex(cacheKey, META_TTL.NEG_S, NEG_SENTINEL); + } + } catch { + /* cache write failure non-fatal */ } - } catch { - /* cache write failure non-fatal */ - } - return id; + return id; + }); } export async function normalizeIgdbId(input: unknown): Promise { diff --git a/apps/api/utils/metadata/index.ts b/apps/api/utils/metadata/index.ts index 80f91d0e..eb6d8fcd 100644 --- a/apps/api/utils/metadata/index.ts +++ b/apps/api/utils/metadata/index.ts @@ -10,6 +10,7 @@ import { imdbSource, tmdbSource, tvdbSource } from './tmdb'; import { igdbSource } from './igdb'; import { openlibrarySource } from './openlibrary'; +import { UpstreamUnavailableError } from './upstream'; import type { LookupOptions, MediaMetadata, @@ -19,6 +20,7 @@ import type { MediaTypeHint, SearchOptions, } from './types'; +import { upsertWorkTitles } from './workTitles'; // Types live in `./types` and are imported from there by callers // that need them. Re-exporting them through this file would make @@ -78,7 +80,22 @@ export async function lookupMetadata( options?: LookupOptions ): Promise { const src = getSource(source); - return src.lookup(id, hint, options); + // Une panne amont qui aurait échappé au garde d'une source rend « rien », + // jamais un 500 : la fiche s'affiche sans affiche, elle ne casse pas. + try { + const meta = await src.lookup(id, hint, options); + // Le titre part dans `work_titles` : la recherche du catalogue le lira. + // Silencieux : une table indisponible ne doit pas priver la fiche. + if (meta?.title) { + void upsertWorkTitles(source, id, meta, options?.language).catch((err: Error) => + console.warn('[Metadata] work title upsert failed:', err.message), + ); + } + return meta; + } catch (err) { + if (err instanceof UpstreamUnavailableError) return null; + throw err; + } } /** @@ -93,7 +110,12 @@ export async function searchMetadata( options?: SearchOptions ): Promise { const src = getSource(source); - return src.search(query, hint, options); + try { + return await src.search(query, hint, options); + } catch (err) { + if (err instanceof UpstreamUnavailableError) return []; + throw err; + } } /** @@ -105,5 +127,10 @@ export async function normalizeSourceId( source: MediaSourceId, input: unknown ): Promise { - return getSource(source).normalizeId(input); + try { + return await getSource(source).normalizeId(input); + } catch (err) { + if (err instanceof UpstreamUnavailableError) return null; + throw err; + } } diff --git a/apps/api/utils/metadata/openlibrary.ts b/apps/api/utils/metadata/openlibrary.ts index 719f5927..fdf2a422 100644 --- a/apps/api/utils/metadata/openlibrary.ts +++ b/apps/api/utils/metadata/openlibrary.ts @@ -31,6 +31,7 @@ import type { SearchOptions, } from './types'; import { META_TTL, NEG_SENTINEL } from './types'; +import { guarded, UpstreamUnavailableError } from './upstream'; import { safeHttpUrl } from './safeUrl'; const OL_BASE = 'https://openlibrary.org'; @@ -64,15 +65,16 @@ async function olGet( headers: { Accept: 'application/json', 'User-Agent': UA }, signal: AbortSignal.timeout(8000), }); + if (res.status === 404) return null; if (!res.ok) { - if (res.status === 404) return null; console.warn(`[metadata:openlibrary] ${res.status} on ${path}`); - return null; + throw new UpstreamUnavailableError('openlibrary', res.status); } return (await res.json()) as T; } catch (err) { + if (err instanceof UpstreamUnavailableError) throw err; console.warn(`[metadata:openlibrary] fetch failed for ${path}:`, err); - return null; + throw new UpstreamUnavailableError('openlibrary', null); } } @@ -88,15 +90,16 @@ async function gbGet( headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(8000), }); + if (res.status === 404) return null; if (!res.ok) { - if (res.status === 404) return null; console.warn(`[metadata:googlebooks] ${res.status} on ${path}`); - return null; + throw new UpstreamUnavailableError('googlebooks', res.status); } return (await res.json()) as T; } catch (err) { + if (err instanceof UpstreamUnavailableError) throw err; console.warn(`[metadata:googlebooks] fetch failed for ${path}:`, err); - return null; + throw new UpstreamUnavailableError('googlebooks', null); } } @@ -379,32 +382,34 @@ async function lookupBook( /* Redis hiccup */ } - let result: MediaMetadata | null = null; + return guarded(cacheKey, null, async () => { + let result: MediaMetadata | null = null; - // Primary: Open Library. - const { bibkey, coverKind } = bibkeyFor(id); - const ol = await fetchOlBibkey(bibkey); - if (ol && ol.title) { - result = olToMedia(id, ol, coverKind); - } + // Primary: Open Library. + const { bibkey, coverKind } = bibkeyFor(id); + const ol = await fetchOlBibkey(bibkey); + if (ol && ol.title) { + result = olToMedia(id, ol, coverKind); + } - // Fallback: Google Books, only for ISBN inputs (GB doesn't speak - // Open Library work ids) and only when the operator wired the - // API key. - if (!result && hasGoogleBooks() && (ISBN13_RE.test(id) || ISBN10_RE.test(id))) { - result = await fetchGoogleBooksByIsbn(id); - } + // Fallback: Google Books, only for ISBN inputs (GB doesn't speak + // Open Library work ids) and only when the operator wired the + // API key. + if (!result && hasGoogleBooks() && (ISBN13_RE.test(id) || ISBN10_RE.test(id))) { + result = await fetchGoogleBooksByIsbn(id); + } - try { - if (result) { - await redis.setex(cacheKey, META_TTL.POS_S, JSON.stringify(result)); - } else { - await redis.setex(cacheKey, META_TTL.NEG_S, NEG_SENTINEL); + try { + if (result) { + await redis.setex(cacheKey, META_TTL.POS_S, JSON.stringify(result)); + } else { + await redis.setex(cacheKey, META_TTL.NEG_S, NEG_SENTINEL); + } + } catch { + /* cache write failure non-fatal */ } - } catch { - /* cache write failure non-fatal */ - } - return result; + return result; + }); } // ── Search ─────────────────────────────────────────────────── @@ -503,42 +508,44 @@ async function searchBooks( // Primary: Open Library `/search.json`. Constrain by year when // we have one — disambiguates re-issues with the same title. - const olParams: Record = { - title: trimmed, - limit: '12', - }; - if (year) olParams.first_publish_year = String(year); - const ol = await olGet('/search.json', olParams); - let hits = (ol?.docs ?? []) - .map(olSearchHit) - .filter((h): h is MediaSearchHit => h !== null) - .slice(0, 8); - - // Fallback: Google Books — only when OL turned up zero and the - // operator opted in. We don't merge results because that risks - // duplicates (same book indexed twice) without a clean way to - // dedupe across the two id spaces. - if (hits.length === 0 && hasGoogleBooks()) { - const gb = await gbGet('/volumes', { - q: trimmed, - maxResults: '8', - }); - hits = (gb?.items ?? []) - .map(gbSearchHit) + return guarded(cacheKey, [], async () => { + const olParams: Record = { + title: trimmed, + limit: '12', + }; + if (year) olParams.first_publish_year = String(year); + const ol = await olGet('/search.json', olParams); + let hits = (ol?.docs ?? []) + .map(olSearchHit) .filter((h): h is MediaSearchHit => h !== null) .slice(0, 8); - } - try { - if (hits.length > 0) { - await redis.setex(cacheKey, META_TTL.SEARCH_S, JSON.stringify(hits)); - } else { - await redis.setex(cacheKey, META_TTL.NEG_S, NEG_SENTINEL); + // Fallback: Google Books — only when OL turned up zero and the + // operator opted in. We don't merge results because that risks + // duplicates (same book indexed twice) without a clean way to + // dedupe across the two id spaces. + if (hits.length === 0 && hasGoogleBooks()) { + const gb = await gbGet('/volumes', { + q: trimmed, + maxResults: '8', + }); + hits = (gb?.items ?? []) + .map(gbSearchHit) + .filter((h): h is MediaSearchHit => h !== null) + .slice(0, 8); } - } catch { - /* cache write failure non-fatal */ - } - return hits; + + try { + if (hits.length > 0) { + await redis.setex(cacheKey, META_TTL.SEARCH_S, JSON.stringify(hits)); + } else { + await redis.setex(cacheKey, META_TTL.NEG_S, NEG_SENTINEL); + } + } catch { + /* cache write failure non-fatal */ + } + return hits; + }); } export const openlibrarySource: MediaSource = { diff --git a/apps/api/utils/metadata/tmdb.ts b/apps/api/utils/metadata/tmdb.ts index e153141a..e92f6235 100644 --- a/apps/api/utils/metadata/tmdb.ts +++ b/apps/api/utils/metadata/tmdb.ts @@ -26,6 +26,7 @@ import type { MediaTypeHint, SearchOptions, } from './types'; +import { guarded, UpstreamUnavailableError } from './upstream'; import { META_TTL, NEG_SENTINEL } from './types'; const TMDB_BASE = 'https://api.themoviedb.org/3'; @@ -50,7 +51,7 @@ const TMDB_LOCALE_MAP: Record = { en: 'en-US', fr: 'fr-FR', }; -function tmdbLocale(language?: string): string { +export function tmdbLocale(language?: string): string { if (!language) return DEFAULT_LOCALE; const lc = language.toLowerCase(); return TMDB_LOCALE_MAP[lc] ?? DEFAULT_LOCALE; @@ -93,20 +94,28 @@ async function tmdbGet( const headers: Record = { Accept: 'application/json' }; if (cred.kind === 'bearer') headers.Authorization = `Bearer ${cred.value}`; + // Un 404 est une ABSENCE : `null`, et l'appelant la retient une heure. Tout + // le reste est une PANNE et se lève : l'appelant la retient deux minutes. + let res: Response; try { - const res = await fetch(url, { + res = await fetch(url, { headers, signal: AbortSignal.timeout(8000), }); - if (!res.ok) { - if (res.status === 404) return null; - console.warn(`[metadata:tmdb] ${res.status} on ${path}`); - return null; - } - return (await res.json()) as T; } catch (err) { console.warn(`[metadata:tmdb] fetch failed for ${path}:`, err); - return null; + throw new UpstreamUnavailableError('tmdb', null); + } + if (res.status === 404) return null; + if (!res.ok) { + console.warn(`[metadata:tmdb] ${res.status} on ${path}`); + throw new UpstreamUnavailableError('tmdb', res.status); + } + try { + return (await res.json()) as T; + } catch (err) { + console.warn(`[metadata:tmdb] unreadable body on ${path}:`, err); + throw new UpstreamUnavailableError('tmdb', res.status); } } @@ -237,30 +246,32 @@ async function lookupByTmdb( /* Redis hiccup — fall through to network */ } - const prefixed = id.match(/^(movie|tv)\/(\d+)$/); - const inferredType = prefixed ? (prefixed[1] as 'movie' | 'tv') : null; - const bareId = prefixed ? prefixed[2]! : id; - const tmdbType = - hint === 'movie' || hint === 'tv' ? hint : inferredType ?? null; - - let result: MediaMetadata | null = null; - if (tmdbType) { - result = await fetchDetail(tmdbType, bareId, cred, locale); - } else { - result = await fetchDetail('movie', bareId, cred, locale); - result ??= await fetchDetail('tv', bareId, cred, locale); - } + return guarded(cacheKey, null, async () => { + const prefixed = id.match(/^(movie|tv)\/(\d+)$/); + const inferredType = prefixed ? (prefixed[1] as 'movie' | 'tv') : null; + const bareId = prefixed ? prefixed[2]! : id; + const tmdbType = + hint === 'movie' || hint === 'tv' ? hint : inferredType ?? null; - try { - if (result) { - await redis.setex(cacheKey, META_TTL.POS_S, JSON.stringify(result)); + let result: MediaMetadata | null = null; + if (tmdbType) { + result = await fetchDetail(tmdbType, bareId, cred, locale); } else { - await redis.setex(cacheKey, META_TTL.NEG_S, NEG_SENTINEL); + result = await fetchDetail('movie', bareId, cred, locale); + result ??= await fetchDetail('tv', bareId, cred, locale); } - } catch { - /* cache write failure non-fatal */ - } - return result; + + try { + if (result) { + await redis.setex(cacheKey, META_TTL.POS_S, JSON.stringify(result)); + } else { + await redis.setex(cacheKey, META_TTL.NEG_S, NEG_SENTINEL); + } + } catch { + /* cache write failure non-fatal */ + } + return result; + }); } /** @@ -285,38 +296,40 @@ async function lookupByExternal( /* Redis hiccup — fall through to network */ } - const find = await tmdbGet( - `/find/${id}`, - { - external_source: externalSource === 'imdb' ? 'imdb_id' : 'tvdb_id', - language: locale, - }, - cred - ); - const movieMatch = find?.movie_results?.[0]; - const tvMatch = find?.tv_results?.[0]; - - let result: MediaMetadata | null = null; - if (hint === 'tv' && tvMatch?.id) { - result = await fetchDetail('tv', tvMatch.id, cred, locale); - } else if (hint === 'movie' && movieMatch?.id) { - result = await fetchDetail('movie', movieMatch.id, cred, locale); - } else if (movieMatch?.id) { - result = await fetchDetail('movie', movieMatch.id, cred, locale); - } else if (tvMatch?.id) { - result = await fetchDetail('tv', tvMatch.id, cred, locale); - } + return guarded(cacheKey, null, async () => { + const find = await tmdbGet( + `/find/${id}`, + { + external_source: externalSource === 'imdb' ? 'imdb_id' : 'tvdb_id', + language: locale, + }, + cred + ); + const movieMatch = find?.movie_results?.[0]; + const tvMatch = find?.tv_results?.[0]; + + let result: MediaMetadata | null = null; + if (hint === 'tv' && tvMatch?.id) { + result = await fetchDetail('tv', tvMatch.id, cred, locale); + } else if (hint === 'movie' && movieMatch?.id) { + result = await fetchDetail('movie', movieMatch.id, cred, locale); + } else if (movieMatch?.id) { + result = await fetchDetail('movie', movieMatch.id, cred, locale); + } else if (tvMatch?.id) { + result = await fetchDetail('tv', tvMatch.id, cred, locale); + } - try { - if (result) { - await redis.setex(cacheKey, META_TTL.POS_S, JSON.stringify(result)); - } else { - await redis.setex(cacheKey, META_TTL.NEG_S, NEG_SENTINEL); + try { + if (result) { + await redis.setex(cacheKey, META_TTL.POS_S, JSON.stringify(result)); + } else { + await redis.setex(cacheKey, META_TTL.NEG_S, NEG_SENTINEL); + } + } catch { + /* cache write failure non-fatal */ } - } catch { - /* cache write failure non-fatal */ - } - return result; + return result; + }); } // ── Search (TMDb only — IMDb / TVDB don't expose a search API) ── @@ -370,69 +383,71 @@ async function searchTmdb( /* Redis hiccup */ } - const params: Record = { - query: trimmed, - include_adult: includeAdult ? 'true' : 'false', - language: locale, - page: '1', - }; - const movieParams = { ...params }; - const tvParams = { ...params }; - if (year) { - movieParams.year = String(year); - tvParams.first_air_date_year = String(year); - } + return guarded(cacheKey, [], async () => { + const params: Record = { + query: trimmed, + include_adult: includeAdult ? 'true' : 'false', + language: locale, + page: '1', + }; + const movieParams = { ...params }; + const tvParams = { ...params }; + if (year) { + movieParams.year = String(year); + tvParams.first_air_date_year = String(year); + } - type RankedHit = MediaSearchHit & { _pop: number }; - let hits: RankedHit[] = []; - const PAGE_SIZE = 8; - - if (type === 'movie' || !type) { - const data = await tmdbGet('/search/movie', movieParams, cred); - if (Array.isArray(data?.results)) { - hits = hits.concat( - data.results.slice(0, PAGE_SIZE).map( - (r: any): RankedHit => ({ - ...normaliseHit('movie', r), - _pop: typeof r.popularity === 'number' ? r.popularity : 0, - }) - ) - ); + type RankedHit = MediaSearchHit & { _pop: number }; + let hits: RankedHit[] = []; + const PAGE_SIZE = 8; + + if (type === 'movie' || !type) { + const data = await tmdbGet('/search/movie', movieParams, cred); + if (Array.isArray(data?.results)) { + hits = hits.concat( + data.results.slice(0, PAGE_SIZE).map( + (r: any): RankedHit => ({ + ...normaliseHit('movie', r), + _pop: typeof r.popularity === 'number' ? r.popularity : 0, + }) + ) + ); + } } - } - if (type === 'tv' || !type) { - const data = await tmdbGet('/search/tv', tvParams, cred); - if (Array.isArray(data?.results)) { - hits = hits.concat( - data.results.slice(0, PAGE_SIZE).map( - (r: any): RankedHit => ({ - ...normaliseHit('tv', r), - _pop: typeof r.popularity === 'number' ? r.popularity : 0, - }) - ) - ); + if (type === 'tv' || !type) { + const data = await tmdbGet('/search/tv', tvParams, cred); + if (Array.isArray(data?.results)) { + hits = hits.concat( + data.results.slice(0, PAGE_SIZE).map( + (r: any): RankedHit => ({ + ...normaliseHit('tv', r), + _pop: typeof r.popularity === 'number' ? r.popularity : 0, + }) + ) + ); + } } - } - - if (!type) hits.sort((a, b) => b._pop - a._pop); - const finalHits: MediaSearchHit[] = hits - .slice(0, PAGE_SIZE) - .map(({ _pop, ...rest }) => rest); - try { - if (finalHits.length > 0) { - await redis.setex( - cacheKey, - META_TTL.SEARCH_S, - JSON.stringify(finalHits) - ); - } else { - await redis.setex(cacheKey, META_TTL.NEG_S, NEG_SENTINEL); + if (!type) hits.sort((a, b) => b._pop - a._pop); + const finalHits: MediaSearchHit[] = hits + .slice(0, PAGE_SIZE) + .map(({ _pop, ...rest }) => rest); + + try { + if (finalHits.length > 0) { + await redis.setex( + cacheKey, + META_TTL.SEARCH_S, + JSON.stringify(finalHits) + ); + } else { + await redis.setex(cacheKey, META_TTL.NEG_S, NEG_SENTINEL); + } + } catch { + /* cache write failure non-fatal */ } - } catch { - /* cache write failure non-fatal */ - } - return finalHits; + return finalHits; + }); } function isEnabled(): boolean { diff --git a/apps/api/utils/metadata/types.ts b/apps/api/utils/metadata/types.ts index 80cb1be9..57a3f676 100644 --- a/apps/api/utils/metadata/types.ts +++ b/apps/api/utils/metadata/types.ts @@ -115,8 +115,36 @@ export const META_TTL = { POS_S: 60 * 60 * 24, /** Misses — short so an operator can fix a typo without restarting. */ NEG_S: 60 * 60, + /** + * Pannes amont — délai dépassé, 5xx, 429, réponse illisible. Deux minutes, + * pas une heure : 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 une panne de réseau passagère. Assez long pour ne pas + * marteler un amont qui souffre, assez court pour que la fiche revienne + * avec lui. + */ + ERR_S: 120, /** Search caches — fresh enough for poster swaps. */ SEARCH_S: 60 * 60 * 6, } as const; export const NEG_SENTINEL = '__null__'; + +/** + * L'amont n'a pas répondu — ce qui n'est PAS « il n'a pas cette fiche ». + * + * Les clients de source la lèvent sur tout ce qui n'est pas un 200 ou un 404 : + * délai dépassé, erreur réseau, 5xx, 429, 401, corps illisible. Les fonctions + * qui écrivent le cache la rattrapent (`guarded`) et retiennent l'absence + * `META_TTL.ERR_S` secondes seulement, là où un vrai 404 vaut `NEG_S`. + */ +export class UpstreamUnavailableError extends Error { + constructor( + readonly source: string, + readonly status: number | null, + ) { + super(`[metadata:${source}] upstream unavailable${status ? ` (${status})` : ''}`); + this.name = 'UpstreamUnavailableError'; + } +} diff --git a/apps/api/utils/metadata/upstream.ts b/apps/api/utils/metadata/upstream.ts new file mode 100644 index 00000000..4b3ee943 --- /dev/null +++ b/apps/api/utils/metadata/upstream.ts @@ -0,0 +1,27 @@ +import { redis } from '../server'; +import { META_TTL, NEG_SENTINEL, UpstreamUnavailableError } from './types'; + +export { UpstreamUnavailableError }; + +/** + * Exécute une recherche amont ; si elle échoue pour cause de PANNE (et non + * d'absence), retient la case vide `META_TTL.ERR_S` secondes sous la même clé + * que le résultat, et rend la valeur vide. Toute autre erreur remonte. + * + * C'est la seule différence entre « TMDB ne répond pas » et « TMDB ne connaît + * pas cet identifiant » : la première se réessaie dans deux minutes, la + * seconde dans une heure. + */ +export async function guarded(cacheKey: string, empty: T, run: () => Promise): Promise { + try { + return await run(); + } catch (err) { + if (!(err instanceof UpstreamUnavailableError)) throw err; + try { + await redis.setex(cacheKey, META_TTL.ERR_S, NEG_SENTINEL); + } catch { + /* l'écriture du cache n'est jamais fatale */ + } + return empty; + } +} diff --git a/apps/api/utils/metadata/workTitles.ts b/apps/api/utils/metadata/workTitles.ts new file mode 100644 index 00000000..b713e8b2 --- /dev/null +++ b/apps/api/utils/metadata/workTitles.ts @@ -0,0 +1,42 @@ +import { db, schema } from '@trackarr/db'; +import { tmdbLocale } from './tmdb'; + +/** + * Déposer le titre d'une œuvre dans `work_titles`. + * + * Chaque lookup de métadonnées passe ici : le titre dans la langue demandée, + * et le titre original sous la pseudo-langue `orig` quand il diffère — c'est + * lui qu'un membre tape le plus souvent (« sousou no frieren »). La recherche + * plein texte du catalogue lit ces lignes par l'identifiant externe du torrent. + */ +export async function upsertWorkTitles( + source: string, + externalId: string, + meta: { title: string; originalTitle?: string | null; year?: number | null }, + language?: string, +): Promise { + const title = meta.title?.trim(); + if (!title) return; + const locale = source === 'tmdb' ? tmdbLocale(language) : 'en'; + const bareId = source === 'tmdb' ? externalId.replace(/^(movie|tv)\//, '') : externalId; + const rows = [{ locale, title }]; + const original = meta.originalTitle?.trim(); + if (original && original.toLowerCase() !== title.toLowerCase()) rows.push({ locale: 'orig', title: original }); + for (const row of rows) { + await db + .insert(schema.workTitles) + .values({ + source, + externalId, + bareId, + locale: row.locale, + title: row.title, + originalTitle: original ?? null, + year: meta.year ?? null, + }) + .onConflictDoUpdate({ + target: [schema.workTitles.source, schema.workTitles.externalId, schema.workTitles.locale], + set: { title: row.title, originalTitle: original ?? null, year: meta.year ?? null, bareId, updatedAt: new Date() }, + }); + } +} diff --git a/apps/api/utils/mixedGroups.ts b/apps/api/utils/mixedGroups.ts index 6439bb6b..80ed42d4 100644 --- a/apps/api/utils/mixedGroups.ts +++ b/apps/api/utils/mixedGroups.ts @@ -160,6 +160,14 @@ export interface MixedGroupRow { completedTotal: number; scopes: ScopeSummary[]; defaultScope: GroupScope; + /** + * La découpe du PREMIER torrent du groupe selon le tri demandé : trié par + * nouveauté, la découpe de la release la plus récente ; par taille, celle de + * la plus grosse. C'est la portée que la carte du catalogue ouvre d'abord — + * `defaultScope` (la portée la plus récemment alimentée) reste celle des + * autres lecteurs. + */ + firstScope: GroupScope; } export interface MixedListOptions { @@ -183,6 +191,7 @@ export interface MixedListOptions { type RawMixedGroup = RawScopeCounts & { gkey: string; + first_scope: string | null; release_count: number; local_count: number; partner_count: number; @@ -277,9 +286,26 @@ function remoteProjection(where: SQL, tagged: boolean, windowRows?: number): SQL * The min–max span the row displays still reads every source — it is answering * a different question, namely whether the copy you want is dead. */ -function aggregate(source: SQL): SQL { +/** L'ordre des releases DANS un groupe, pour désigner la première selon le tri. */ +function firstRowOrder(sortBy: TorrentSortKey, order: SortDirection): SQL { + const dir = order === 'asc' ? sql`ASC` : sql`DESC`; + const key = { + age: sql`live_at`, + relevance: sql`live_at`, + name: sql`lower(name)`, + size: sql`size`, + seeders: sql`seeders`, + leechers: sql`leechers`, + completed: sql`completed`, + }[sortBy]; + // Le nom se trie à l'endroit ; « asc » sur une date ou un nombre est « le plus petit d'abord ». + return sortBy === 'name' ? sql`${key} ${order === 'desc' ? sql`DESC` : sql`ASC`}` : sql`${key} ${dir}`; +} + +function aggregate(source: SQL, first: SQL): SQL { return sql` SELECT gkey, + (array_agg(scope ORDER BY ${first}))[1] AS first_scope, count(DISTINCT rkey)::int AS release_count, count(DISTINCT rkey) FILTER (WHERE is_local)::int AS local_count, count(DISTINCT rkey) FILTER (WHERE NOT is_local)::int AS partner_count, @@ -385,9 +411,9 @@ export async function listMixedGroups( const rows = (await db.execute(sql` SELECT * FROM ( - ${aggregate(taggedSource)} + ${aggregate(taggedSource, firstRowOrder(opts.sortBy ?? 'age', opts.order ?? 'desc'))} UNION ALL - ${aggregate(soloSource)} + ${aggregate(soloSource, firstRowOrder(opts.sortBy ?? 'age', opts.order ?? 'desc'))} ) u ORDER BY ${buildGroupOrderBy(opts.sortBy ?? 'age', opts.order ?? 'desc')} LIMIT ${opts.limit} OFFSET ${opts.offset} @@ -433,6 +459,7 @@ export async function listMixedGroups( completedTotal: Number(r.completed_total ?? 0), scopes, defaultScope: pickDefault(scopes), + firstScope: (scopes.some((sc) => sc.scope === r.first_scope) ? (r.first_scope as GroupScope) : null) ?? pickDefault(scopes), }; }), total: Number(countRow?.total ?? 0), diff --git a/apps/api/utils/rateLimit.ts b/apps/api/utils/rateLimit.ts index 98de07a7..cb9660f5 100644 --- a/apps/api/utils/rateLimit.ts +++ b/apps/api/utils/rateLimit.ts @@ -96,6 +96,76 @@ const TRUST_PROXY = process.env.TRUST_PROXY === 'true'; */ const TRUST_CF_CONNECTING_IP = process.env.TRUST_CF_CONNECTING_IP === 'true'; +/** + * Les adresses qui ne peuvent pas venir d'Internet. + * + * Sert UNIQUEMENT à reconnaître notre propre couche de rendu serveur ; ce + * n'est pas une autorisation, et rien de sensible n'en dépend. + */ +function isPrivateAddress(raw: string): boolean { + const ip = normalizeIP(raw); + if (ip === '::1' || ip === '127.0.0.1') return true; + // fc00::/7 (unique local) et fe80::/10 (lien local). + if (/^f[cd]/i.test(ip) || /^fe80:/i.test(ip)) return true; + return /^(?:10\.|127\.|192\.168\.|169\.254\.|172\.(?:1[6-9]|2\d|3[01])\.)/.test(ip); +} + +/** Tout en-tête par lequel un intermédiaire annonce POUR QUI il parle. */ +const FORWARDED_CLIENT_HEADERS = [ + 'x-forwarded-for', + 'x-real-ip', + 'cf-connecting-ip', + 'true-client-ip', + 'forwarded', +] as const; + +/** + * Cette requête vient-elle de notre propre rendu serveur ? + * + * # Le problème que ça résout + * + * Le compteur anti-abus est PAR ADRESSE, et l'API ne voit jamais celle du + * membre quand c'est le conteneur web qui parle. Mesuré : trois rendus + * anonymes de `/privacy` produisent **douze** requêtes sur un compteur unique, + * soit quatre par page vue. Avec `DDOS_THRESHOLD` à 100 requêtes en 10 + * secondes, **vingt-cinq pages vues en dix secondes** suffisaient à mettre + * l'instance ENTIÈRE sur liste noire — cinq minutes au premier coup, doublé à + * chaque récidive jusqu'à vingt-quatre heures. Le site se coupait tout seul, + * et le journal ne disait rien d'autre que « Blocked blacklisted IP ». + * + * # Le discriminant, et pourquoi il n'est pas falsifiable + * + * Deux populations arrivent du conteneur web, et le proxy `/api/[...path]` + * les distingue déjà sans le savoir : + * + * - une requête qu'il RELAIE pour un navigateur a un pair observé, donc il + * pose `x-forwarded-for` ; + * - une requête que le rendu serveur émet pour lui-même n'a pas de pair, + * donc AUCUN en-tête de transfert n'est posé. + * + * Les deux moitiés ont été mesurées : le compteur du conteneur web n'existe + * que pour la seconde, celui du pair pour la première. + * + * De l'extérieur, aucune des deux conditions ne se force. L'adresse socket ne + * se choisit pas — un client d'Internet ne peut pas émettre depuis 172.16/12 — + * et Caddy pose `X-Forwarded-For` sur TOUT ce qu'il relaie, donc une requête + * de navigateur n'arrive jamais sans. Ajouter un en-tête ne fait jamais + * qu'exempter MOINS. + * + * Ce qui reste : un voisin de réseau (autre conteneur, machine du même LAN si + * l'exploitant expose l'API) échappe au compteur grossier. Il reste soumis aux + * limites par route et aux bannissements d'adresse, et quelqu'un qui est DÉJÀ + * dans le réseau a de meilleurs leviers que celui-là. + */ +export function isInternalOrigin(event: any): boolean { + const peer = event?.node?.req?.socket?.remoteAddress; + if (!peer || !isPrivateAddress(peer)) return false; + for (const name of FORWARDED_CLIENT_HEADERS) { + if (getHeader(event, name)) return false; + } + return true; +} + /** * Extract client IP. Honors proxy headers only if TRUST_PROXY=true, and * CF-Connecting-IP only if TRUST_CF_CONNECTING_IP=true on top of that. diff --git a/apps/api/utils/savedSearchFanout.ts b/apps/api/utils/savedSearchFanout.ts index f471140d..0d07018a 100644 --- a/apps/api/utils/savedSearchFanout.ts +++ b/apps/api/utils/savedSearchFanout.ts @@ -37,6 +37,7 @@ import { db, schema } from '@trackarr/db'; import { notify } from './notify'; import { FANOUT_CONCURRENCY, withConcurrency } from './fanout'; import { adultCategoryIds } from './adultContent'; +import { ftsVector } from '~~/utils/search'; export interface SavedSearchCandidate { id: string; @@ -47,6 +48,9 @@ export interface SavedSearchCandidate { tmdbId: string | null; tvdbId: string | null; uploaderId: string | null; + /** La découpe de la release, pour les alertes posées sur une saison ou un épisode. */ + season?: number | null; + episode?: number | null; } /** Log a warning past this, so a slow sweep is visible before it is a problem. */ @@ -95,27 +99,62 @@ async function runFanout(torrent: SavedSearchCandidate): Promise { eq(schema.savedSearches.notify, true), or( sql`${schema.savedSearches.tsquery} IS NULL`, - sql`to_tsvector('simple', ${torrent.name}) @@ to_tsquery('simple', ${schema.savedSearches.tsquery})` + // Le même vecteur que l'index et la recherche (points → espaces) : + // une alerte « frieren » doit se déclencher sur `Sousou.no.Frieren.S01E10`. + sql`${ftsVector(sql`${torrent.name}`)} @@ to_tsquery('simple', ${schema.savedSearches.tsquery})` ) ) ); if (candidates.length === 0) return; + // Comme le listing : une catégorie parente couvre ses enfants, et un + // identifiant TMDb se compare sans son préfixe `tv/` ou `movie/` (le sélecteur + // d'œuvre enregistre le nombre nu, l'envoi garde la forme préfixée). + const parentId = torrent.categoryId + ? ((await db.query.categories.findFirst({ where: eq(schema.categories.id, torrent.categoryId), columns: { parentId: true } }))?.parentId ?? null) + : null; + /* + * Deux identifiants TMDb se comparent SANS leur préfixe seulement quand l'un + * des deux n'en a pas : le sélecteur d'œuvre enregistre le nombre nu, l'envoi + * garde `tv/209867`. Quand les deux sont préfixés, `movie/603` et `tv/603` + * sont deux œuvres différentes et doivent le rester. + */ + const bare = (v: string) => v.replace(/^(movie|tv)\//, ''); + const tmdbMatches = (want: string, has: string | null | undefined) => { + if (!has) return false; + if (want.includes('/') && has.includes('/')) return want === has; + return bare(want) === bare(has); + }; // The structured half, in memory. const matched = candidates.filter((f) => { if (f.userId === torrent.uploaderId) return false; - if (f.categoryId && f.categoryId !== torrent.categoryId) return false; + if (f.categoryId && f.categoryId !== torrent.categoryId && f.categoryId !== parentId) return false; if (f.imdbId && f.imdbId !== torrent.imdbId) return false; - if (f.tmdbId && f.tmdbId !== torrent.tmdbId) return false; + if (f.tmdbId && !tmdbMatches(f.tmdbId, torrent.tmdbId)) return false; if (f.tvdbId && f.tvdbId !== torrent.tvdbId) return false; const wanted = f.tags ?? []; if (wanted.length && !wanted.every((t) => torrentTags.has(t))) return false; + // Les critères de la barre : un groupe d'étiquettes est satisfait par l'un + // de ses synonymes (OU), tous les groupes doivent l'être (ET) ; saison, + // épisode et uploadeur à l'égal ; l'année dans le nom, entre deux non-chiffres. + if (f.tagGroups) { + const groups = f.tagGroups + .split(';') + .map((g) => g.split(',').filter(Boolean)) + .filter((g) => g.length > 0); + if (!groups.every((g) => g.some((slug) => torrentTags.has(slug)))) return false; + } + if (f.season != null && f.season !== (torrent.season ?? null)) return false; + if (f.episode != null && f.episode !== (torrent.episode ?? null)) return false; + if (f.year != null && !new RegExp(`(^|[^0-9])${f.year}([^0-9]|$)`).test(torrent.name)) return false; + if (f.uploaderId && f.uploaderId !== torrent.uploaderId) return false; // A filter with no free text, no category, no tag and no id would match // every upload ever. The write path refuses to store one; this is the // second line, in case a row predates that or arrives another way. const empty = - !f.tsquery && !f.categoryId && !f.imdbId && !f.tmdbId && !f.tvdbId && !wanted.length; + !f.tsquery && !f.categoryId && !f.imdbId && !f.tmdbId && !f.tvdbId && !wanted.length && + !f.tagGroups && f.season == null && f.episode == null && f.year == null && !f.uploaderId; return !empty; }); diff --git a/apps/api/utils/schemas.ts b/apps/api/utils/schemas.ts index 21e1ed2b..40002292 100644 --- a/apps/api/utils/schemas.ts +++ b/apps/api/utils/schemas.ts @@ -96,6 +96,16 @@ export const torrentUploadSchema = z.object({ description: z.string().max(10000, 'Description too long').optional(), }); +/** + * Huit groupes de dix synonymes au plus : chaque groupe coûte un EXISTS sur la + * table de liaison, et les facettes en lancent un par groupe. Sans borne, une + * seule requête pouvait occuper tout le pool de connexions. + */ +export function boundedTagGroups(raw: string): boolean { + const groups = raw.split(';').filter((g) => g.trim()); + return groups.length <= 8 && groups.every((g) => g.split(',').filter((x) => x.trim()).length <= 10); +} + export const torrentQuerySchema = z.object({ page: z.coerce.number().int().positive().default(1), limit: z.coerce.number().int().positive().max(100).default(20), @@ -125,6 +135,40 @@ export const torrentQuerySchema = z.object({ // fan out one Redis read per candidate row before it knows which page it is // serving. Displayed counts stay live — only the ordering is as of the last // collection pass. + /* + * Ce que la barre du catalogue COMPREND en tapant — voir `searchTokens.ts` + * côté web. `tagGroups` : des groupes séparés par `;`, des alternatives par + * `,` — `hevc,x265;1080p` veut dire « (hevc OU x265) ET 1080p ». Dans un + * groupe, un synonyme absent du vocabulaire de l'instance est simplement + * ignoré tant qu'un autre existe — là où `tag` répond « rien » dès qu'un de + * ses slugs est inconnu. Les drapeaux sont des chaînes `1`/`true` : + * `z.coerce.boolean('false')` vaudrait `true`. + */ + tagGroups: z + .string() + .max(400) + .optional() + .refine((v) => !v || boundedTagGroups(v), { message: 'tagGroups: at most 8 groups of 10 alternatives' }), + 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(), + /** Les favoris de ce membre seulement. */ + favorites: z.enum(['1', 'true']).optional(), + /** Ajoutés depuis peu — le « du jour » du menu Torrents, et ses deux cousins. */ + since: z.enum(['24h', '7d', '30d']).optional(), + /* + * Une clé de groupe (`tmdb:tv/209867`, `solo:`) : les releases + * d'une œuvre, avec les mêmes filtres que la page — c'est ce qu'une carte + * dépliée de la vue Œuvres demande. + */ + groupKey: z.string().trim().min(1).max(200).optional(), + /** Avec `groupKey` : la découpe voulue — à l'épisode, saisons complètes, intégrale. */ + groupScope: z.enum(['episode', 'season', 'integral', 'all']).optional(), sortBy: z.enum(TORRENT_SORT_KEYS).default('age'), order: z.enum(['asc', 'desc']).default('desc'), }); @@ -205,6 +249,16 @@ export const adminSettingsSchema = z.object({ .max(4) .optional(), searchFuzzy: z.boolean().optional(), + // Le catalogue : la vue et le tri qu'un membre trouve en arrivant, la taille + // d'une page, les facettes du rail. « auto » pour le tri : pertinence dès + // qu'un texte est tapé, nouveauté sinon. + catalogueDefaultView: z.enum(['grouped', 'simple']).optional(), + catalogueDefaultSort: z.enum(['auto', 'age', 'name', 'size', 'seeders', 'leechers', 'completed']).optional(), + cataloguePageSize: z.coerce.number().int().min(10).max(50).optional(), + catalogueFacets: z + .array(z.enum(['category', 'resolution', 'source', 'codec', 'language', 'hdr', 'audio', 'year', 'options'])) + .max(9) + .optional(), registrationOpen: z.boolean().optional(), inviteEnabled: z.boolean().optional(), defaultInvites: z.coerce.number().int().min(0).max(100).optional(), @@ -213,6 +267,12 @@ export const adminSettingsSchema = z.object({ maxPeersPerTorrent: z.coerce.number().int().positive().max(1000).optional(), peerTTL: z.coerce.number().int().positive().max(86400).optional(), minRatio: z.coerce.number().min(0).max(10).optional(), + // Hit & Run. En HEURES côté interface, en secondes en base : personne ne + // règle un seuil de partage en secondes, et 86400 se lit mal. Bornés à un + // an ; zéro heure de seuil veut dire « aucune obligation ». + hnrEnabled: z.boolean().optional(), + hnrRequiredSeedHours: z.coerce.number().int().min(0).max(8760).optional(), + hnrGraceHours: z.coerce.number().int().min(0).max(8760).optional(), starterUpload: z.coerce.number().int().min(0).optional(), siteName: z.string().min(1).max(500).optional(), siteLogo: z.string().min(1).max(100).optional(), diff --git a/apps/api/utils/searchMisses.ts b/apps/api/utils/searchMisses.ts new file mode 100644 index 00000000..7afdd254 --- /dev/null +++ b/apps/api/utils/searchMisses.ts @@ -0,0 +1,54 @@ +import { sql } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { redis } from '~~/utils/server'; + +/** La table reste sous ce nombre de lignes : au-delà, les moins demandées et les plus anciennes partent. */ +const MAX_ROWS = 5000; +/** Ce qu'un membre peut faire compter par heure : au-delà, on n'enregistre plus. */ +const PER_USER_PER_HOUR = 30; + +/** + * Compter une recherche qui n'a rien rendu. + * + * Normalisée (minuscules, espaces repliés) pour qu'une même intention ne fasse + * qu'une ligne ; ignorée sous trois caractères (une frappe en cours) et + * au-delà de deux cents (un collage). Un infohash n'est pas une recherche + * qu'on peut satisfaire en acquérant quelque chose : ignoré aussi. + * + * Deux bornes, parce qu'une recherche vide est gratuite à produire : un budget + * horaire par membre (une boucle de chaînes aléatoires ne remplit pas la + * table), et un plafond global élagué à chaque ligne nouvelle. + */ +export async function recordSearchMiss(raw: string, userId: string): Promise { + const query = raw.trim().toLowerCase().replace(/\s+/g, ' '); + if (query.length < 3 || query.length > 200) return; + if (/^[0-9a-f]{40}$/.test(query)) return; + try { + // `SET NX EX` puis `INCR` : avec `INCR` d'abord et `EXPIRE` ensuite, une + // panne entre les deux laissait une clé sans délai — ce membre ne comptait + // plus jamais aucune recherche vaine. + const budgetKey = `misses:budget:${userId}`; + await redis.set(budgetKey, '0', 'EX', 3600, 'NX'); + const used = await redis.incr(budgetKey); + if (used > PER_USER_PER_HOUR) return; + // `xmax = 0` : la ligne vient d'être insérée ; sinon c'est le compteur d'une ligne existante. + const rows = (await db.execute(sql` + INSERT INTO ${schema.searchMisses} (query) VALUES (${query}) + ON CONFLICT (query) DO UPDATE + SET count = ${schema.searchMisses}.count + 1, last_at = now() + RETURNING (xmax = 0) AS inserted + `)) as unknown as Array<{ inserted: boolean }>; + if (rows[0]?.inserted) { + await db.execute(sql` + DELETE FROM ${schema.searchMisses} + WHERE query IN ( + SELECT query FROM ${schema.searchMisses} + ORDER BY count ASC, last_at ASC + LIMIT GREATEST(0, (SELECT count(*) FROM ${schema.searchMisses}) - ${MAX_ROWS}) + ) + `); + } + } catch (err) { + console.warn('[SearchMisses] record failed:', (err as Error).message); + } +} diff --git a/apps/api/utils/settings.ts b/apps/api/utils/settings.ts index e5437950..7644f776 100644 --- a/apps/api/utils/settings.ts +++ b/apps/api/utils/settings.ts @@ -257,6 +257,12 @@ export const SETTINGS_KEYS = { // Typo fallback. Expensive (word_similarity re-reads many candidate rows): // left switchable for large catalogues. SEARCH_FUZZY: 'search_fuzzy', + // Le catalogue : la vue et le tri à l'arrivée, la taille d'une page, les + // facettes du rail (voir `getCatalogueSettings`). + CATALOGUE_DEFAULT_VIEW: 'catalogue_default_view', + CATALOGUE_DEFAULT_SORT: 'catalogue_default_sort', + CATALOGUE_PAGE_SIZE: 'catalogue_page_size', + CATALOGUE_FACETS: 'catalogue_facets', } as const; const settingsCache = new Map< @@ -839,3 +845,49 @@ export async function setRoomSlowModeSeconds(seconds: number) { String(Math.max(0, Math.floor(seconds))) ); } + +/* ── Le catalogue ─────────────────────────────────────────────────────────── */ +export const CATALOGUE_FACET_KEYS = [ + 'category', 'resolution', 'source', 'codec', 'language', 'hdr', 'audio', 'year', 'options', +] as const; +export type CatalogueFacetKey = (typeof CATALOGUE_FACET_KEYS)[number]; +export const CATALOGUE_SORTS = ['auto', 'age', 'name', 'size', 'seeders', 'leechers', 'completed'] as const; +export type CatalogueSort = (typeof CATALOGUE_SORTS)[number]; + +export interface CatalogueSettings { + /** La vue à l'arrivée : par œuvre, ou release par release. */ + defaultView: 'grouped' | 'simple'; + /** « auto » : pertinence dès qu'un texte est tapé, nouveauté sinon. */ + defaultSort: CatalogueSort; + pageSize: number; + /** Les facettes du rail, dans l'ordre d'affichage ; vide = aucune. */ + facets: CatalogueFacetKey[]; +} + +/** + * Ce que l'opérateur a choisi pour le catalogue, avec les défauts qui valent + * quand il n'a rien dit : Œuvres, tri automatique, vingt par page, toutes les + * facettes. Une chaîne vide pour les facettes est un choix (aucune), un réglage + * absent n'en est pas un — la même règle que les champs de la recherche. + */ +export async function getCatalogueSettings(): Promise { + const [view, sort, size, facets] = await Promise.all([ + getSetting(SETTINGS_KEYS.CATALOGUE_DEFAULT_VIEW), + getSetting(SETTINGS_KEYS.CATALOGUE_DEFAULT_SORT), + getSetting(SETTINGS_KEYS.CATALOGUE_PAGE_SIZE), + getSetting(SETTINGS_KEYS.CATALOGUE_FACETS), + ]); + const pageSize = Math.min(50, Math.max(10, parseInt(size ?? '', 10) || 20)); + return { + defaultView: view === 'simple' ? 'simple' : 'grouped', + defaultSort: (CATALOGUE_SORTS as readonly string[]).includes(sort ?? '') ? (sort as CatalogueSort) : 'auto', + pageSize, + facets: + facets === null || facets === undefined + ? [...CATALOGUE_FACET_KEYS] + : facets + .split(',') + .map((f) => f.trim()) + .filter((f): f is CatalogueFacetKey => (CATALOGUE_FACET_KEYS as readonly string[]).includes(f)), + }; +} diff --git a/apps/api/utils/tags.ts b/apps/api/utils/tags.ts index 1b51d791..bd3b44f8 100644 --- a/apps/api/utils/tags.ts +++ b/apps/api/utils/tags.ts @@ -17,7 +17,7 @@ * to the same tag. */ import { db, schema } from '@trackarr/db'; -import { inArray } from 'drizzle-orm'; +import { inArray, sql, type SQL } from 'drizzle-orm'; import { randomUUID } from 'crypto'; export const MAX_TAG_NAME_LENGTH = 30; @@ -125,3 +125,49 @@ export async function resolveTagsByName( created: toCreate.length, }; } + +/** + * Le prédicat « ce torrent porte TOUS ces tags », partagé. + * + * Il vivait en un seul exemplaire, dans le listing plat. La vue GROUPÉE ne + * connaissait pas du tout le paramètre `tag` — zod le retirait en silence — + * donc `/torrents?tag=2160p&v=grouped` remontait des livres et de la musique. + * Le filtre n'était pas cassé : il n'existait pas de ce côté-là. + * + * Le voici en un endroit, pour que les deux vues ne puissent plus diverger. + * + * Renvoie `null` quand la liste ne contient aucun tag exploitable (le filtre + * ne s'applique alors pas), et `sql\`false\`` quand un slug demandé n'existe + * pas — aucun torrent ne peut le porter, et un résultat vide est honnête là où + * élargir à « on ignore le filtre » ne l'est pas. + */ +export async function tagFilterCondition(raw: string): Promise { + const slugs = Array.from( + new Set( + raw + .split(',') + .map((s) => slugifyTag(s)) + .filter(Boolean), + ), + ); + if (slugs.length === 0) return null; + + const matched = await db.query.tags.findMany({ + where: inArray(schema.tags.slug, slugs), + columns: { id: true }, + }); + if (matched.length !== slugs.length) return sql`false`; + + const tagIds = matched.map((t) => t.id); + // `count(distinct tag_id)` plutôt qu'un simple compte : le prédicat reste + // juste même si `torrent_tags` venait à porter des doublons. + return 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}`), + ) as SQL; +} diff --git a/apps/api/utils/torrentGroups.ts b/apps/api/utils/torrentGroups.ts index d176ecad..84132847 100644 --- a/apps/api/utils/torrentGroups.ts +++ b/apps/api/utils/torrentGroups.ts @@ -307,10 +307,12 @@ export function buildGroupOrderBy( const dir = order === 'asc' ? sql`ASC` : sql`DESC`; const nulls = order === 'asc' ? sql`NULLS FIRST` : sql`NULLS LAST`; - if (sortBy === 'age') { + // Les groupes n'ont pas de rang plein texte : « pertinence » vaut nouveauté. + if (sortBy === 'age' || sortBy === 'relevance') { // Both ends of the span, so each direction reads the release a member // would point at. - return order === 'asc' ? sql`oldest ASC` : sql`latest DESC`; + // `gkey` est unique par œuvre : deux dates égales ne permutent plus entre pages. + return order === 'asc' ? sql`oldest ASC, gkey ASC` : sql`latest DESC, gkey ASC`; } const key = { @@ -324,7 +326,7 @@ export function buildGroupOrderBy( // `latest DESC` breaks ties: Postgres promises nothing about equal keys, and // without it a member paging through "most seeded" can meet the same work // twice. - return sql`${key} ${dir} ${nulls}, latest DESC`; + return sql`${key} ${dir} ${nulls}, latest DESC, gkey ASC`; } /** diff --git a/apps/api/utils/torrentListing.ts b/apps/api/utils/torrentListing.ts new file mode 100644 index 00000000..c0ce602b --- /dev/null +++ b/apps/api/utils/torrentListing.ts @@ -0,0 +1,466 @@ +import { db, schema } from '@trackarr/db'; +import { redactUploader } from '~~/utils/uploaderVisibility'; +import { and, eq, gt, inArray, isNull, notInArray, or, sql, type SQL } from 'drizzle-orm'; +import { slugifyTag, tagFilterCondition } 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'; +import { worksFromCache, workRefKey, type CachedWork, type WorkRef } from '~~/utils/metadata/cached'; +import { groupMemberWhere, parseGroupKey, scopeWhere, type GroupScope } from '~~/utils/torrentGroups'; + +/** + * Le listing des torrents, en pièces réutilisables. + * + * Le listing plat et la vue groupée ont chacun réécrit les mêmes prédicats, et + * la vue groupée a un jour PERDU le filtre par tag sans qu'aucune ligne ne le + * dise. Depuis, les conditions vivent ici, une fois : la visibilité (modération, + * actif, contenu adulte), les filtres que la barre du catalogue produit, la + * recherche plein texte. Les comptes par facette, à venir, les liront aussi. + */ +export interface ListingViewer { + id: string; + isAdmin?: boolean; + isModerator?: boolean; + language?: string | null; +} + +export interface ListingFilters { + categoryId?: string; + tag?: string; + tagGroups?: string; + imdbid?: string; + tmdbid?: string; + tvdbid?: string; + uploader?: string; + year?: number; + season?: number; + episode?: number; + minSeeders?: number; + freeleech?: string; + notTaken?: string; + hideSuperseded?: string; + groupKey?: string; + groupScope?: string; + favorites?: string; + since?: string; +} + +/** + * L'année dans le nom, en expression régulière Postgres. + * + * Pas de colonne « année » : c'est le nom qui la porte. Deux exigences, et la + * facette DOIT utiliser le même motif que le filtre, sinon le compte annoncé + * n'est pas celui qu'un clic rend : + * - entre deux non-chiffres, pour que 2023 ne trouve ni 12023 ni x20230 ; + * - jamais collée à un `x` suivi d'un chiffre, sinon `1920x1080` remplissait + * un lot « 1920 » qui ne parle de rien. + */ +export const YEAR_IN_NAME_RE = + '(? + `(? = { '24h': '24 hours', '7d': '7 days', '30d': '30 days' }; +const sinceInterval = (key: string): string | null => (Object.hasOwn(SINCE_INTERVALS, key) ? SINCE_INTERVALS[key]! : null); + +/** + * Combien d'œuvres un texte peut désigner avant qu'on cesse de compter. + * + * Au-delà, la recherche par titre est tronquée : c'est un mot si commun que la + * recherche par nom de fichier répond déjà, et deux mille identifiants dans un + * `IN` coûtent plus que ce qu'ils rendent. + */ +const WORK_TITLE_MATCH_CAP = 500; + +/** `1`/`true` dans une chaîne de requête, et rien d'autre. */ +export const isFlag = (v: unknown): boolean => v === '1' || v === 'true' || v === true; + +/** + * Un torrent par son hash, ou 404 : la visibilité du listing appliquée à une + * route unitaire (historique d'essaim, obligation), pour ne rien confirmer de + * ce que la fiche cache. + */ +export async function assertVisibleTorrent( + infoHash: string, + viewer: { id: string; isAdmin?: boolean | null; isModerator?: boolean | null }, +): Promise<{ id: string }> { + const vis = await visibilityConditions({ id: viewer.id, isAdmin: !!viewer.isAdmin, isModerator: !!viewer.isModerator }); + const [row] = await db + .select({ id: schema.torrents.id }) + .from(schema.torrents) + .where(and(eq(schema.torrents.infoHash, infoHash), ...vis)) + .limit(1); + if (!row) throw createError({ statusCode: 404, message: 'Torrent not found' }); + return row; +} + +/** Ce que ce membre a le droit de voir : modération, activité, contenu adulte. */ +export async function visibilityConditions(viewer: ListingViewer): Promise { + const conditions: SQL[] = []; + const canSeeUnapproved = !!viewer.isAdmin || !!viewer.isModerator; + if (!canSeeUnapproved) { + conditions.push( + or(eq(schema.torrents.moderationStatus, 'accepted'), eq(schema.torrents.uploaderId, viewer.id))!, + ); + conditions.push(eq(schema.torrents.isActive, true)); + } + const me = await db.query.users.findFirst({ + where: eq(schema.users.id, viewer.id), + columns: { showAdultContent: true }, + }); + if (!(me?.showAdultContent ?? false)) { + const adultIds = await adultCategoryIds(); + if (adultIds.length > 0) { + conditions.push( + or(isNull(schema.torrents.categoryId), notInArray(schema.torrents.categoryId, adultIds))!, + ); + } + } + return conditions; +} + +/** + * `hevc,x265;1080p` → (hevc OU x265) ET 1080p. Chaque groupe est un EXISTS sur + * la table de liaison ; un slug inconnu ne correspond à rien, sans forcer le + * vide comme `tag` le fait (là, un slug qui n'existe pas est une erreur de + * frappe ; ici, ce sont des synonymes dont seuls certains existent). + */ +export function tagGroupsCondition(raw: string): SQL | null { + const groups = raw + .split(';') + .map((g) => Array.from(new Set(g.split(',').map((s) => slugifyTag(s)).filter(Boolean)))) + .filter((g) => g.length > 0); + if (groups.length === 0) return null; + const parts = groups.map( + (slugs) => 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 tg.slug IN (${sql.join(slugs.map((s) => sql`${s}`), sql`, `)}) + )`, + ); + return parts.length === 1 ? parts[0]! : and(...parts)!; +} + +/** Les filtres explicites — catégorie, identifiants, tags, et ceux de la barre. */ +export async function filterConditions(q: ListingFilters, viewer: ListingViewer): Promise { + const conditions: SQL[] = []; + if (q.categoryId) { + const subcategories = await db.query.categories.findMany({ + where: eq(schema.categories.parentId, q.categoryId), + columns: { id: true }, + }); + conditions.push( + or( + eq(schema.torrents.categoryId, q.categoryId), + ...subcategories.map((sub) => eq(schema.torrents.categoryId, sub.id)), + )!, + ); + } + if (q.imdbid) { + const norm = normalizeMediaId('imdb', q.imdbid); + conditions.push(norm ? eq(schema.torrents.imdbId, norm) : sql`false`); + } + if (q.tmdbid) { + const norm = normalizeMediaId('tmdb', q.tmdbid); + const bare = norm ? tmdbIdBare(norm) : null; + conditions.push( + norm && bare + ? or( + eq(schema.torrents.tmdbId, norm), + eq(schema.torrents.tmdbId, bare), + eq(schema.torrents.tmdbId, `movie/${bare}`), + eq(schema.torrents.tmdbId, `tv/${bare}`), + )! + : sql`false`, + ); + } + if (q.tvdbid) { + const norm = normalizeMediaId('tvdb', q.tvdbid); + conditions.push(norm ? eq(schema.torrents.tvdbId, norm) : sql`false`); + } + if (q.tag) { + const cond = await tagFilterCondition(q.tag); + if (cond) conditions.push(cond); + } + if (q.tagGroups) { + const cond = tagGroupsCondition(q.tagGroups); + if (cond) conditions.push(cond); + } + if (q.uploader) { + // « Envois anonymes » vaut aussi pour la liste des envois d'un membre : seuls + // lui-même et l'équipe peuvent la demander par son nom. + const isStaff = !!(viewer.isAdmin || viewer.isModerator); + conditions.push( + sql`${schema.torrents.uploaderId} IN ( + SELECT u.id FROM ${schema.users} u + WHERE lower(u.username) = ${q.uploader.toLowerCase()} + AND (u.anonymous_uploads = false OR u.id = ${viewer.id} OR ${isStaff}) + )`, + ); + } + if (typeof q.year === 'number') { + // Le motif partagé, resserré sur l'année demandée (voir `YEAR_IN_NAME_RE`). + conditions.push(sql`${schema.torrents.name} ~ ${yearInNameRe(q.year)}`); + } + if (typeof q.season === 'number') conditions.push(eq(schema.torrents.season, q.season)); + if (typeof q.episode === 'number') conditions.push(eq(schema.torrents.episode, q.episode)); + if (typeof q.minSeeders === 'number' && q.minSeeders > 0) { + conditions.push( + sql`COALESCE((SELECT s.seeders FROM torrent_stats s WHERE s.info_hash = ${schema.torrents.infoHash}), 0) >= ${q.minSeeders}`, + ); + } + if (isFlag(q.freeleech)) { + conditions.push( + and( + eq(schema.torrents.downloadMultiplier, 0), + or(isNull(schema.torrents.multipliersUntil), gt(schema.torrents.multipliersUntil, sql`now()`))!, + )!, + ); + } + if (isFlag(q.notTaken)) { + conditions.push( + sql`NOT EXISTS (SELECT 1 FROM ${schema.hnrTracking} h WHERE h.user_id = ${viewer.id} AND h.torrent_id = ${schema.torrents.id} AND h.downloaded > 0)`, + ); + } + if (isFlag(q.hideSuperseded)) conditions.push(isNull(schema.torrents.supersededById)); + if (isFlag(q.favorites)) { + conditions.push( + sql`EXISTS (SELECT 1 FROM torrent_favorites f WHERE f.user_id = ${viewer.id} AND f.torrent_id = torrents.id)`, + ); + } + const since = q.since ? sinceInterval(q.since) : null; + if (since) { + // L'intervalle vient d'une table fermée (clés propres, pas l'héritage d'Object), jamais de la requête. + conditions.push(sql`torrents.created_at >= now() - ${sql.raw(`interval '${since}'`)}`); + } + if (q.groupKey) { + const member = groupMemberWhere(parseGroupKey(q.groupKey)); + const scope = q.groupScope as GroupScope | undefined; + conditions.push(scope && scope !== 'all' ? scopeWhere(member, scope) : member); + } + return conditions; +} + +export interface SearchPredicates { + primary: SQL | null; + fuzzy: SQL | null; + /** Le rang plein texte des lignes, pour le tri par pertinence. */ + rankExact: SQL | null; + /** Le rang approximatif (similarité de mot), quand le repli s'applique. */ + rankFuzzy: SQL | null; +} + +/** + * La recherche plein texte : le prédicat principal, le repli approximatif, et + * les rangs qui vont avec. + * + * Le champ « nom » lit aussi les TITRES D'ŒUVRES (`work_titles`) : « frieren » + * trouve une release que le fournisseur de métadonnées appelle « Frieren: + * Beyond Journey's End » même si son nom de fichier dit autre chose. Le lien + * passe par l'identifiant externe du torrent — nu ou préfixé pour TMDb. + */ +export async function searchConditions(search: string | undefined): Promise { + const none: SearchPredicates = { primary: null, fuzzy: null, rankExact: null, rankFuzzy: null }; + if (!search) return none; + if (/^[0-9a-fA-F]{40}$/.test(search)) { + return { ...none, primary: eq(schema.torrents.infoHash, search.toLowerCase()) }; + } + const fields = parseSearchFields(await getSetting(SEARCH_FIELDS_SETTING)); + const tsq = toPrefixTsQuery(search); + if (!tsq) return none; + if (!fields.length) return { ...none, primary: sql`false` }; + const q = sql`to_tsquery(${FTS_CONFIG}, ${tsq})`; + const branches: SQL[] = []; + if (fields.includes('name')) { + branches.push(sql`${ftsVector(schema.torrents.name)} @@ ${q}`); + // Les œuvres dont le TITRE répond : cherchées d'abord dans `work_titles` + // (son index plein texte), puis les torrents qui les portent, par égalité + // sur leurs colonnes d'identifiant — indexable, et que le planificateur + // combine avec les deux prédicats GIN. Un EXISTS corrélé dans le OU + // forçait un balayage complet de `torrents` avec un sous-plan par ligne. + // Ordonné : sans `ORDER BY`, les 500 lignes retenues sont un sous-ensemble + // arbitraire que Postgres peut recomposer d'un appel à l'autre — la bande et + // la grille d'une même page se seraient alors contredites. + const works = (await db.execute(sql` + SELECT DISTINCT source, external_id, bare_id FROM ${schema.workTitles} + WHERE ${ftsVector(sql`title`)} @@ ${q} + ORDER BY source, external_id + LIMIT ${WORK_TITLE_MATCH_CAP} + `)) as unknown as Array<{ source: string; external_id: string; bare_id: string }>; + const tmdb = new Set(); + const igdb = new Set(); + const openlibrary = new Set(); + for (const w of works) { + if (w.source === 'tmdb') { + // Les formes qu'une release peut porter pour CETTE œuvre : l'identifiant + // tel qu'il a été cherché, et le nombre nu. Les préfixes ne sont ajoutés + // que si la ligne n'en porte aucun — sinon `tv/1399` allait chercher + // `movie/1399`, une autre œuvre qui partage le nombre. + tmdb.add(w.external_id); + tmdb.add(w.bare_id); + if (!w.external_id.includes('/')) { + tmdb.add(`tv/${w.bare_id}`); + tmdb.add(`movie/${w.bare_id}`); + } + } else if (w.source === 'igdb') igdb.add(w.external_id); + else if (w.source === 'openlibrary') openlibrary.add(w.external_id); + } + // `inArray` : une liste de paramètres, que l'index de la colonne sert (le + // planificateur en fait un BitmapOr avec les deux prédicats GIN). + if (tmdb.size) branches.push(inArray(schema.torrents.tmdbId, Array.from(tmdb))); + if (igdb.size) branches.push(inArray(schema.torrents.igdbId, Array.from(igdb))); + if (openlibrary.size) branches.push(inArray(schema.torrents.openlibraryId, Array.from(openlibrary))); + } + 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')) { + 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} + )`); + } + const primary = branches.length > 1 ? or(...branches)! : branches[0]!; + const term = fuzzyTerm(search); + const fuzzyOn = parseSearchFuzzy(await getSetting(SEARCH_FUZZY_SETTING)); + const fuzzy = term && fuzzyOn ? sql`${term} <% ${schema.torrents.name}` : null; + return { + primary, + fuzzy, + // `ts_rank_cd` sur le nom : le titre d'œuvre ouvre la porte, le nom classe. + rankExact: sql`ts_rank_cd(${ftsVector(schema.torrents.name)}, ${q})`, + rankFuzzy: term && fuzzyOn ? sql`word_similarity(${term}, ${schema.torrents.name})` : null, + }; +} + +/* ── L'enrichissement d'une page de lignes ─────────────────────────────────── */ + +type Row = { + id: string; + infoHash: string; + uploaderId: string | null; + tmdbId: string | null; + igdbId: string | null; + openlibraryId: string | null; + downloadMultiplier: number; + multipliersUntil: Date | string | null; + torrentTags?: Array<{ tag: unknown }> | null; + [k: string]: unknown; +}; + +function workRefOf(row: Row): WorkRef | null { + if (row.tmdbId) return { source: 'tmdb', id: row.tmdbId }; + if (row.igdbId) return { source: 'igdb', id: row.igdbId }; + if (row.openlibraryId) return { source: 'openlibrary', id: row.openlibraryId }; + return null; +} + +/** + * Ce qu'une ligne du catalogue dit de plus qu'une ligne de la table : l'essaim + * vivant, si ce membre l'a en favori, s'il l'a DÉJÀ PRISE (des octets sur son + * suivi de seed), qui l'a envoyée, si elle est gratuite en ce moment, et l'œuvre + * telle que le cache la connaît. Cinq lectures pour la page, jamais par ligne. + */ +export async function enrichListing(rows: T[], viewer: ListingViewer) { + if (rows.length === 0) return [] as Array; + const ids = rows.map((r) => r.id); + const uids = Array.from(new Set(rows.map((r) => r.uploaderId).filter((u): u is string => !!u))); + const favQ: Promise> = db + .select({ torrentId: schema.torrentFavorites.torrentId }) + .from(schema.torrentFavorites) + .where(and(eq(schema.torrentFavorites.userId, viewer.id), inArray(schema.torrentFavorites.torrentId, ids))); + const takenQ: Promise> = db + .select({ torrentId: schema.hnrTracking.torrentId }) + .from(schema.hnrTracking) + .where( + and( + eq(schema.hnrTracking.userId, viewer.id), + inArray(schema.hnrTracking.torrentId, ids), + gt(schema.hnrTracking.downloaded, 0), + ), + ); + const uploadersQ: Promise> = uids.length + ? db + .select({ id: schema.users.id, username: schema.users.username, anonymousUploads: schema.users.anonymousUploads }) + .from(schema.users) + .where(inArray(schema.users.id, uids)) + : Promise.resolve([]); + // L'essaim vient du collecteur (`torrent_stats`), comme les facettes et le + // tri : une seule lecture pour la page, et un compte « avec des seeders » + // qui colle aux lignes. La fiche, elle, lit l'essaim vivant. + const statsQ: Promise> = db + .select({ + infoHash: schema.torrentStats.infoHash, + seeders: schema.torrentStats.seeders, + leechers: schema.torrentStats.leechers, + completed: schema.torrentStats.completed, + }) + .from(schema.torrentStats) + .where(inArray(schema.torrentStats.infoHash, rows.map((r) => r.infoHash))); + const [statRows, favRows, takenRows, uploaderRows, works] = await Promise.all([ + statsQ, + favQ, + takenQ, + uploadersQ, + worksFromCache( + Array.from(new Map(rows.map(workRefOf).filter((w): w is WorkRef => !!w).map((w) => [workRefKey(w), w])).values()), + viewer.language ?? undefined, + ), + ]); + const statsByHash = new Map(statRows.map((r) => [r.infoHash, r])); + const favorited = new Set(favRows.map((r) => r.torrentId)); + const taken = new Set(takenRows.map((r) => r.torrentId)); + const uploaders = new Map(uploaderRows.map((u) => [u.id, u])); + const now = Date.now(); + return rows.map((row) => { + const stats = statsByHash.get(row.infoHash) ?? { seeders: 0, leechers: 0, completed: 0 }; + const until = row.multipliersUntil ? new Date(row.multipliersUntil).getTime() : null; + const ref = workRefOf(row); + return { + ...row, + torrentTags: undefined, + tags: (row.torrentTags ?? []).map((tt) => tt.tag), + stats: { seeders: stats.seeders, leechers: stats.leechers, completed: stats.completed }, + viewerFavorited: favorited.has(row.id), + viewerTaken: taken.has(row.id), + // Le même voile que la fiche : un uploadeur anonyme n'a ni nom ni id ici, + // sauf pour lui-même et pour l'équipe (`redactUploader`). + ...(() => { + const u = row.uploaderId ? uploaders.get(row.uploaderId) : undefined; + const red = redactUploader(u ? { id: u.id, username: u.username, anonymousUploads: u.anonymousUploads } : null, viewer); + return { + uploaderId: red.uploaderId, + uploader: red.uploader ? { id: red.uploader.id, username: red.uploader.username } : null, + uploaderAnonymous: red.uploaderAnonymous, + }; + })(), + freeleech: row.downloadMultiplier === 0 && (until === null || until > now), + work: ref ? works.get(workRefKey(ref)) ?? null : null, + } as T & EnrichmentFields; + }); +} + +export interface EnrichmentFields { + tags: unknown[]; + stats: { seeders: number; leechers: number; completed: number }; + viewerFavorited: boolean; + viewerTaken: boolean; + uploader: { id: string; username: string | null } | null; + uploaderAnonymous: boolean; + freeleech: boolean; + work: CachedWork | null; +} diff --git a/apps/api/utils/torrentModeration.ts b/apps/api/utils/torrentModeration.ts index c258c661..2fdad0da 100644 --- a/apps/api/utils/torrentModeration.ts +++ b/apps/api/utils/torrentModeration.ts @@ -229,6 +229,8 @@ export async function transitionStatus(opts: { tmdbId: updated.tmdbId, tvdbId: updated.tvdbId, uploaderId: updated.uploaderId, + season: updated.season, + episode: updated.episode, }); // The IRC announce channel, on the same edge and for the same reason: this diff --git a/apps/api/utils/torrentSort.ts b/apps/api/utils/torrentSort.ts index e965c804..e9abd3fd 100644 --- a/apps/api/utils/torrentSort.ts +++ b/apps/api/utils/torrentSort.ts @@ -32,7 +32,7 @@ function swarmColumn(column: 'seeders' | 'leechers' | 'completed'): SQL { return sql`(SELECT s.${sql.raw(column)} FROM torrent_stats s WHERE s.info_hash = ${schema.torrents.infoHash})`; } -const SORT_KEYS: Record = { +const SORT_KEYS: Record, SQL> = { age: availableAt, // Case-insensitive: otherwise every capitalised release sorts ahead of every // lowercase one and "A to Z" reads as nonsense. @@ -54,12 +54,26 @@ const SORT_KEYS: Record = { * `NULLS LAST` on a descending sort keeps torrents the collector has never seen * at the bottom where they belong, rather than heading the list. */ +/** Ce que « pertinence » classe : le rang plein texte de la recherche en cours. */ +export interface SortContext { + rank?: SQL | null; +} + export function buildTorrentOrderBy( sortBy: TorrentSortKey, - order: SortDirection + order: SortDirection, + ctx?: SortContext ): SQL[] { + // L'identifiant ferme chaque tri : deux releases importées à la même seconde + // ne peuvent plus permuter entre deux pages, ni se répéter au « charger plus ». + const tiebreak = sql`${schema.torrents.id} DESC`; + if (sortBy === 'relevance') { + // Sans texte, la pertinence n'existe pas : c'est la nouveauté. + if (!ctx?.rank) return buildTorrentOrderBy('age', 'desc'); + return [sql`${ctx.rank} DESC NULLS LAST`, sql`${availableAt} DESC`, tiebreak]; + } const direction = order === 'asc' ? sql`ASC` : sql`DESC`; const nulls = order === 'asc' ? sql`NULLS FIRST` : sql`NULLS LAST`; const primary = sql`${SORT_KEYS[sortBy]} ${direction} ${nulls}`; - return sortBy === 'age' ? [primary] : [primary, sql`${availableAt} DESC`]; + return sortBy === 'age' ? [primary, tiebreak] : [primary, sql`${availableAt} DESC`, tiebreak]; } diff --git a/apps/web/app/app.vue b/apps/web/app/app.vue index 0e5bcff5..bb259a87 100644 --- a/apps/web/app/app.vue +++ b/apps/web/app/app.vue @@ -21,4 +21,23 @@ // `data-theme` server-side. Called here rather than in the layout because the // login and registration pages set `layout: false`. useColorMode(); + +/* + * La langue du document, alignée sur celle du membre. + * + * `nuxt.config` pose un plancher (`lang="en"`) pour que le HTML servi n'en soit + * jamais dépourvu, y compris dans la construction statique où ce fichier ne + * tourne pas. Ici on met la vraie : le code BCP-47 déclaré pour la locale + * (`en-US`, `fr-FR`), et non le code court, parce que c'est ce qu'un lecteur + * d'écran utilise pour choisir sa voix ET sa prononciation régionale. + */ +const { locale, locales } = useI18n(); +useHead({ + htmlAttrs: { + lang: () => + (locales.value as Array<{ code: string; language?: string }>).find( + (l) => l.code === locale.value, + )?.language || locale.value, + }, +}); diff --git a/apps/web/app/assets/css/main.css b/apps/web/app/assets/css/main.css index cdb51133..ba3b23be 100644 --- a/apps/web/app/assets/css/main.css +++ b/apps/web/app/assets/css/main.css @@ -382,14 +382,29 @@ color: rgb(var(--accent-fg)); } - /* Scrollbars — track in surface, thumb in line */ - ::-webkit-scrollbar { @apply w-2 h-2; } - ::-webkit-scrollbar-track { background: rgb(var(--bg-surface)); } - ::-webkit-scrollbar-thumb { - background: rgb(var(--line-default)); - border-radius: var(--radius-pill); + /* Barres de défilement. + * + * Le bloc `::-webkit-scrollbar` qui était ici faisait bien plus que colorer. + * Dans Chromium comme dans WebKit, styler ces pseudo-éléments bascule TOUT + * défilement en barre « classique » : une gouttière de 8 px prise sur la + * page, une piste en `--bg-surface` plus claire que le fond, et le décor + * d'une fiche coupé net à 8 px du bord — même sur un Mac réglé en barres + * flottantes, qui n'en aurait montré aucune. Mesuré sur la racine : 8 px de + * gouttière avec le bloc, 0 sans. + * + * Les deux propriétés standard n'ont pas cet effet. Là où le système fait + * flotter ses barres (macOS par défaut, iOS, Android), elles flottent : le + * contenu passe dessous et la barre par-dessus. Là où il les fixe (Windows, + * Linux, macOS « toujours »), elles sont fines, la piste transparente laisse + * voir le fond de la page ou de la boîte, et le pouce prend le trait fort du + * thème. `scrollbar-color` s'hérite, `scrollbar-width` non : d'où `*`. + * + * Pas de repli `::-webkit-scrollbar` pour les Safari d'avant 18.2 : il + * recréerait exactement la panne. Ils gardent la barre native, flottante. */ + * { + scrollbar-width: thin; + scrollbar-color: rgb(var(--line-strong)) transparent; } - ::-webkit-scrollbar-thumb:hover { background: rgb(var(--line-strong)); } /* Focus ring — thin, keyboard only */ /* One focus ring, from one token, for everything. @@ -927,3 +942,10 @@ } } +/* Une page qui porte un bouton flottant (le catalogue, sous 1100 px) dégage le + pied de page : sinon le bouton recouvre ses liens quand on est tout en bas. */ +@media (max-width: 68.75rem) { + body.has-fab footer { + padding-bottom: calc(4.5rem + env(safe-area-inset-bottom, 0px)); + } +} diff --git a/apps/web/app/assets/css/upload-form.css b/apps/web/app/assets/css/upload-form.css index 033a9127..321d81b9 100644 --- a/apps/web/app/assets/css/upload-form.css +++ b/apps/web/app/assets/css/upload-form.css @@ -20,6 +20,10 @@ margin-bottom: 2rem; } .back-link { + /* WCAG 2.5.8 : 24 px de cible au minimum. Mesuré à 15 px avant. + Cette règle est GLOBALE : c'est elle que rendent /torrents/upload et + /templates, qui écrivent `class="back-link"` sans la définir. */ + min-height: 1.5rem; display: inline-flex; align-items: center; gap: 0.5rem; @@ -97,7 +101,6 @@ in the form column happens to scroll behind it. Without this the pill would blend into headings on a dark theme. */ backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); background-color: rgb(var(--bg-base) / 0.78); box-shadow: 0 2px 8px rgb(var(--shadow-color) / calc(0.18 * var(--shadow-strength))); } diff --git a/apps/web/app/components/DescriptionRender.vue b/apps/web/app/components/DescriptionRender.vue index ca5e1c06..44ce2d72 100644 --- a/apps/web/app/components/DescriptionRender.vue +++ b/apps/web/app/components/DescriptionRender.vue @@ -19,14 +19,30 @@ import { toEditorHtml } from '~/utils/editorFormats'; defineOptions({ inheritAttrs: false }); -const props = defineProps<{ - /** Raw description as stored in the DB (BBCode, Markdown, or - * HTML — the renderer auto-detects). `null`/`undefined`/empty - * produce an empty render (no warning, no fallback text). */ - source?: string | null | undefined; -}>(); +const props = withDefaults( + defineProps<{ + /** Raw description as stored in the DB (BBCode, Markdown, or + * HTML — the renderer auto-detects). `null`/`undefined`/empty + * produce an empty render (no warning, no fallback text). */ + source?: string | null | undefined; + /** + * De combien de niveaux descendre les titres du contenu. Sur la fiche, la + * note vit sous un `

` ; un `# Titre` d'uploadeur rendu en `

` y + * posait un second h1 dans la page (mesuré sur Re:ZERO : h1, h2, h2, h3… + * dans la note). Avec 2, h1 → h3 et la hiérarchie reste celle de la page. + */ + headingOffset?: number; + }>(), + { source: null, headingOffset: 0 }, +); -const rendered = computed(() => toEditorHtml(props.source)); +const rendered = computed(() => { + const html = toEditorHtml(props.source); + const off = Math.max(0, Math.min(5, Math.trunc(props.headingOffset))); + if (!off) return html; + // Sortie déjà assainie par DOMPurify : balises normalisées, remplacement sûr. + return html.replace(/<(\/?)h([1-6])\b/gi, (_m, slash: string, n: string) => `<${slash}h${Math.min(6, Number(n) + off)}`); +}); @@ -106,7 +126,10 @@ const rendered = computed(() => toEditorHtml(props.source)); } .description-render h1 { font-size: 1.4rem; } .description-render h2 { font-size: 1.25rem; } -.description-render h3 { font-size: 1.1rem; } +.description-render h3 { font-size: 1.15rem; } +.description-render h4 { font-size: 1.05rem; } +.description-render h5 { font-size: 1rem; } +.description-render h6 { font-size: 0.9375rem; } /* Images — keep them inline-block so the parent's `text-align: center` (from `[center]` blocks) actually centres diff --git a/apps/web/app/components/GameMetadataCard.vue b/apps/web/app/components/GameMetadataCard.vue index 22a7f805..462eb34c 100644 --- a/apps/web/app/components/GameMetadataCard.vue +++ b/apps/web/app/components/GameMetadataCard.vue @@ -475,7 +475,7 @@ function formatReleaseDate(iso: string | null): string { .gcard-shots-label { margin-bottom: 0.1rem; } .gcard-shots-track { display: grid; - grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(min(100%, 180px), 1fr)); gap: 0.55rem; } .gcard-shot { diff --git a/apps/web/app/components/IconPicker.vue b/apps/web/app/components/IconPicker.vue index 51c28fb0..76ea44ba 100644 --- a/apps/web/app/components/IconPicker.vue +++ b/apps/web/app/components/IconPicker.vue @@ -469,21 +469,13 @@ onBeforeUnmount(() => { margin: 0; padding: 0; display: grid; - grid-template-columns: repeat(auto-fill, minmax(64px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(min(100%, 64px), 1fr)); gap: 0.4rem; overflow-y: auto; max-height: 15rem; scrollbar-width: thin; scrollbar-color: rgb(var(--line-strong)) transparent; } -.icon-picker-grid::-webkit-scrollbar { - width: 8px; -} -.icon-picker-grid::-webkit-scrollbar-thumb { - background: rgb(var(--line-strong)); - border-radius: var(--radius-sm); -} - .icon-picker-cell { display: flex; flex-direction: column; diff --git a/apps/web/app/components/MediaMetadataCard.vue b/apps/web/app/components/MediaMetadataCard.vue index a08d0409..069d2db9 100644 --- a/apps/web/app/components/MediaMetadataCard.vue +++ b/apps/web/app/components/MediaMetadataCard.vue @@ -350,6 +350,8 @@ function formatRuntime(minutes: number): string { flex-wrap: wrap; } .media-card-link { + /* WCAG 2.5.8 : 24 px de cible au minimum. Mesuré à 17 px avant. */ + min-height: 1.5rem; display: inline-flex; align-items: center; gap: 0.375rem; diff --git a/apps/web/app/components/NotificationBell.vue b/apps/web/app/components/NotificationBell.vue index 56bd144d..e76d5108 100644 --- a/apps/web/app/components/NotificationBell.vue +++ b/apps/web/app/components/NotificationBell.vue @@ -362,15 +362,17 @@ function humanise(s: string): string { display: inline-flex; align-items: center; justify-content: center; - background: #f43f5e; - color: rgb(var(--danger-fg)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ + /* Le jeton, pas un rose figé : `#f43f5e` sous le blanc de `--danger-fg` + tombait à 3,67:1 en thème clair. `--danger` y est plus sombre, 6,4:1. */ + background: rgb(var(--danger)); + color: rgb(var(--danger-fg)); border-radius: var(--radius-pill); font-family: var(--font-mono); font-size: 0.5625rem; font-weight: 800; letter-spacing: calc(0.02em * var(--tracking-scale)); border: 2px solid rgb(var(--bg-base)); - box-shadow: 0 0 0 1px rgba(244, 63, 94, 0.45); + box-shadow: 0 0 0 1px rgb(var(--danger) / 0.45); } /* ── Popover ────────────────────────────────────────────── */ @@ -602,11 +604,10 @@ function humanise(s: string): string { max-height: 26rem; overflow-y: auto; } -.nbell-list::-webkit-scrollbar { width: 6px; } -.nbell-list::-webkit-scrollbar-thumb { - background: rgb(var(--accent-warm) / 0.25); - border-radius: var(--radius-md); -} +/* Le pouce dans la teinte de la cloche. Propriétés standard seulement : un + `::-webkit-scrollbar` figeait ici une barre classique de 6 px même sur un + système à barres flottantes (voir main.css). */ +.nbell-list { scrollbar-color: rgb(var(--accent-warm) / 0.35) transparent; } .nbell-row:focus-visible { outline: 2px solid rgb(var(--focus-ring)); diff --git a/apps/web/app/components/ReportModal.vue b/apps/web/app/components/ReportModal.vue index 11e22ecc..5fd83172 100644 --- a/apps/web/app/components/ReportModal.vue +++ b/apps/web/app/components/ReportModal.vue @@ -176,6 +176,13 @@ const props = defineProps<{ targetLabel?: string; /** Optional warning shown above the actions, before submitting. */ caveat?: string; + /** + * Un motif déjà choisi à l'ouverture — la valeur d'une des options, telle + * que l'API la reçoit. Posé par les entrées qui savent déjà de quoi elles + * parlent (« Mauvaise fiche ? » sur la fiche d'un torrent) : le membre n'a + * plus qu'à décrire, et le modérateur reçoit un signalement déjà qualifié. + */ + presetReason?: string | null; }>(); const emit = defineEmits<{ @@ -234,6 +241,11 @@ const reasonOptions = computed(() => [ label: t('components.report.reasons.harassment'), icon: 'ph:hand-palm-bold', }, + { + value: t('components.report.reasons.wrongMetadata'), + label: t('components.report.reasons.wrongMetadataShort'), + icon: 'ph:film-slate-bold', + }, { value: t('components.report.reasons.other'), label: t('components.report.reasons.otherShort'), @@ -241,6 +253,15 @@ const reasonOptions = computed(() => [ }, ]); +// Le motif prérempli s'applique à CHAQUE ouverture, pas seulement au montage : +// la même boîte sert au signalement général et à « Mauvaise fiche ? ». +watch( + () => props.isOpen, + (open) => { + if (open && props.presetReason) reason.value = props.presetReason; + }, +); + function close() { reason.value = ''; details.value = ''; diff --git a/apps/web/app/components/SectionHead.vue b/apps/web/app/components/SectionHead.vue new file mode 100644 index 00000000..f6f4f6c9 --- /dev/null +++ b/apps/web/app/components/SectionHead.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/apps/web/app/components/admin/Branding.vue b/apps/web/app/components/admin/Branding.vue index 12e0b56a..cb4aadb2 100644 --- a/apps/web/app/components/admin/Branding.vue +++ b/apps/web/app/components/admin/Branding.vue @@ -989,14 +989,10 @@ async function discard() { align-self: flex-start; max-height: calc(100vh - 2.5rem); overflow-y: auto; - /* Hide scrollbar for cleanliness; sample stack should fit - most viewports anyway. */ + /* Fine et dans la teinte de la marque ; la pile d'exemples tient dans la + plupart des fenêtres. Propriétés standard seulement (voir main.css). */ scrollbar-width: thin; - } - .adm-preview::-webkit-scrollbar { width: 4px; } - .adm-preview::-webkit-scrollbar-thumb { - background: rgb(var(--accent-warm) / 0.25); - border-radius: var(--radius-sm); + scrollbar-color: rgb(var(--accent-warm) / 0.35) transparent; } } @@ -1515,6 +1511,9 @@ async function discard() { /* ── Logo segments + icon picker ───────────────────────────── */ .segments { display: inline-flex; + /* Un contrôle segmenté n'est pas un titre : sur un téléphone il + s'enroule au lieu de pousser la page hors du cadre. */ + flex-wrap: wrap; border: 1px solid rgb(var(--line-default)); border-radius: var(--radius-sm); background: rgb(var(--bg-elevated)); diff --git a/apps/web/app/components/admin/Categories.vue b/apps/web/app/components/admin/Categories.vue index 21a490a0..8cf6f533 100644 --- a/apps/web/app/components/admin/Categories.vue +++ b/apps/web/app/components/admin/Categories.vue @@ -16,6 +16,27 @@ time and auto-expands any parent whose subcategory matches. -->
+
+
+ + {{ $t('admin.categories.merge.into', { from: mergeSource.name }) }} + + + + +
+
@@ -238,6 +259,15 @@ > +
@@ -306,6 +336,15 @@ > + @@ -639,6 +678,56 @@ const notifications = useNotificationStore(); const confirm = useConfirm(); const expandedCategories = ref(new Set()); + +/* + * Fusionner : tout ce qui pointe la source pointe la cible, la source + * disparaît. Un import laisse « Films » et « Movies » ; c'est le geste qui les + * réunit. La cible ne peut être ni la source ni l'un de ses enfants. + */ +const mergeSource = ref(null); +const mergeInto = ref(''); +const merging = ref(false); +function startMerge(cat: Category) { + mergeSource.value = cat; + mergeInto.value = ''; +} +const mergeTargets = computed(() => { + const src = mergeSource.value; + if (!src) return []; + const excluded = new Set([src.id, ...(src.subcategories ?? []).map((s) => s.id)]); + const out: Category[] = []; + for (const cat of categories.value ?? []) { + if (!excluded.has(cat.id)) out.push(cat); + for (const sub of cat.subcategories ?? []) if (!excluded.has(sub.id)) out.push(sub); + } + return out.sort((a, b) => a.name.localeCompare(b.name)); +}); +async function confirmMerge() { + const src = mergeSource.value; + if (!src || !mergeInto.value || merging.value) return; + const target = mergeTargets.value.find((x) => x.id === mergeInto.value); + const ok = await confirm({ + title: t('admin.categories.merge.title'), + message: t('admin.categories.merge.confirm', { from: src.name, into: target?.name ?? '' }), + confirmText: t('admin.categories.merge.action'), + destructive: true, + }); + if (!ok) return; + merging.value = true; + try { + const res = await $fetch<{ moved: number }>(`/api/admin/categories/${src.id}/merge`, { + method: 'POST', + body: { into: mergeInto.value }, + }); + notifications.success(t('admin.categories.merge.done', { n: res.moved, into: target?.name ?? '' })); + mergeSource.value = null; + await refresh(); + } catch (err: any) { + notifications.error(err?.data?.message || t('admin.categories.merge.failed')); + } finally { + merging.value = false; + } +} function toggleCategory(id: string) { if (expandedCategories.value.has(id)) { expandedCategories.value.delete(id); diff --git a/apps/web/app/components/admin/FederationHealth.vue b/apps/web/app/components/admin/FederationHealth.vue index ba02cdb1..c2464516 100644 --- a/apps/web/app/components/admin/FederationHealth.vue +++ b/apps/web/app/components/admin/FederationHealth.vue @@ -451,7 +451,7 @@ function hostOf(url: string): string { .fh-tiles { display: grid; gap: 0.6rem; - grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(100%, 7rem), 1fr)); } .fh-tile { --tone: var(--fg-default); diff --git a/apps/web/app/components/admin/HnrSettings.vue b/apps/web/app/components/admin/HnrSettings.vue new file mode 100644 index 00000000..49221b33 --- /dev/null +++ b/apps/web/app/components/admin/HnrSettings.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/apps/web/app/components/admin/Invites.vue b/apps/web/app/components/admin/Invites.vue index 80cf80e5..d1eb1a67 100644 --- a/apps/web/app/components/admin/Invites.vue +++ b/apps/web/app/components/admin/Invites.vue @@ -1078,6 +1078,9 @@ async function confirmDelete() { .ledger-segments { display: inline-flex; + /* Un contrôle segmenté n'est pas un titre : sur un téléphone il + s'enroule au lieu de pousser la page hors du cadre. */ + flex-wrap: wrap; background: rgb(var(--bg-elevated)); border: 1px solid rgb(var(--line-default)); border-radius: var(--radius-sm); diff --git a/apps/web/app/components/admin/PendingTorrents.vue b/apps/web/app/components/admin/PendingTorrents.vue index f9738523..eed7bb5a 100644 --- a/apps/web/app/components/admin/PendingTorrents.vue +++ b/apps/web/app/components/admin/PendingTorrents.vue @@ -238,6 +238,9 @@ function formatDate(iso: string): string { /* ── Segmented filter ─────────────────────────────────── */ .queue-segments { display: inline-flex; + /* Un contrôle segmenté n'est pas un titre : sur un téléphone il + s'enroule au lieu de pousser la page hors du cadre. */ + flex-wrap: wrap; gap: 4px; padding: 4px; background: rgb(var(--bg-elevated)); @@ -452,6 +455,8 @@ function formatDate(iso: string): string { } .queue-meta-soft { color: rgb(var(--fg-subtle)); } .queue-link { + /* WCAG 2.5.8 : 24 px de cible au minimum. Mesuré à 15 px avant. */ + padding-block: 0.35rem; color: rgb(var(--r)); text-decoration: none; border-bottom: 1px solid rgb(var(--r) / 0.4); diff --git a/apps/web/app/components/admin/SearchMisses.vue b/apps/web/app/components/admin/SearchMisses.vue new file mode 100644 index 00000000..b8e9735e --- /dev/null +++ b/apps/web/app/components/admin/SearchMisses.vue @@ -0,0 +1,137 @@ + + + diff --git a/apps/web/app/components/admin/SearchSettings.vue b/apps/web/app/components/admin/SearchSettings.vue index a77dd466..65a1ec4f 100644 --- a/apps/web/app/components/admin/SearchSettings.vue +++ b/apps/web/app/components/admin/SearchSettings.vue @@ -59,6 +59,61 @@ {{ $t('admin.search.noneWarning') }}

+ + + + + + + + + + + +
+ +
+
+ (['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 @@ + + + + + diff --git a/apps/web/app/components/search/CatalogueFacets.vue b/apps/web/app/components/search/CatalogueFacets.vue new file mode 100644 index 00000000..8ab5de37 --- /dev/null +++ b/apps/web/app/components/search/CatalogueFacets.vue @@ -0,0 +1,563 @@ + + + + + diff --git a/apps/web/app/components/search/PinnedCards.vue b/apps/web/app/components/search/PinnedCards.vue new file mode 100644 index 00000000..af8b2d91 --- /dev/null +++ b/apps/web/app/components/search/PinnedCards.vue @@ -0,0 +1,176 @@ + + + + + diff --git a/apps/web/app/components/search/ReleaseRows.vue b/apps/web/app/components/search/ReleaseRows.vue new file mode 100644 index 00000000..bedbcb59 --- /dev/null +++ b/apps/web/app/components/search/ReleaseRows.vue @@ -0,0 +1,797 @@ + + + + + diff --git a/apps/web/app/components/search/TokenSearch.vue b/apps/web/app/components/search/TokenSearch.vue new file mode 100644 index 00000000..c243d4a1 --- /dev/null +++ b/apps/web/app/components/search/TokenSearch.vue @@ -0,0 +1,691 @@ + + + + + diff --git a/apps/web/app/components/search/WorkCards.vue b/apps/web/app/components/search/WorkCards.vue new file mode 100644 index 00000000..70518f36 --- /dev/null +++ b/apps/web/app/components/search/WorkCards.vue @@ -0,0 +1,996 @@ + + + + + diff --git a/apps/web/app/components/shop/FreeleechPool.vue b/apps/web/app/components/shop/FreeleechPool.vue index 7a58f7bb..201e9064 100644 --- a/apps/web/app/components/shop/FreeleechPool.vue +++ b/apps/web/app/components/shop/FreeleechPool.vue @@ -631,7 +631,7 @@ onBeforeUnmount(() => { padding: 0; margin: 0; display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: 0.3rem 0.8rem; } .pool-patron { diff --git a/apps/web/app/components/torrent/TorrentModerationPanel.vue b/apps/web/app/components/torrent/TorrentModerationPanel.vue index 6dfcd7b9..7721a822 100644 --- a/apps/web/app/components/torrent/TorrentModerationPanel.vue +++ b/apps/web/app/components/torrent/TorrentModerationPanel.vue @@ -600,8 +600,8 @@ function formatDate(iso: string): string { display: inline-flex; align-items: center; font-family: var(--font-mono); - font-size: 0.5625rem; - letter-spacing: calc(0.16em * var(--tracking-scale)); + font-size: var(--label-sm, 0.5625rem); + letter-spacing: var(--label-tracking-wide, calc(0.16em * var(--tracking-scale))); text-transform: uppercase; color: rgb(var(--fg-subtle)); font-weight: 600; @@ -629,16 +629,16 @@ function formatDate(iso: string): string { align-items: center; gap: 0.5rem; font-family: var(--font-mono); - font-size: 0.5625rem; - letter-spacing: calc(0.22em * var(--tracking-scale)); + font-size: var(--label-sm, 0.5625rem); + letter-spacing: var(--label-tracking-wide, calc(0.16em * var(--tracking-scale))); text-transform: uppercase; color: rgb(var(--fg-subtle)); margin: 0 0 0.2rem; font-weight: 600; } .mod-banner-count { - font-size: 0.5625rem; - letter-spacing: calc(0.18em * var(--tracking-scale)); + font-size: var(--label-sm, 0.5625rem); + letter-spacing: var(--label-tracking-wide, calc(0.16em * var(--tracking-scale))); color: var(--c-text); background: rgb(var(--c) / 0.14); border: 1px solid var(--c-line); @@ -689,8 +689,8 @@ function formatDate(iso: string): string { .mod-empty-glyph { font-size: 2rem; color: var(--c-text); opacity: 0.6; margin-bottom: 0.5rem; } .mod-empty-text { font-family: var(--font-mono); - font-size: 0.625rem; - letter-spacing: calc(0.22em * var(--tracking-scale)); + font-size: var(--label-md, 0.625rem); + letter-spacing: var(--label-tracking-wide, calc(0.16em * var(--tracking-scale))); text-transform: uppercase; font-weight: 700; color: rgb(var(--fg-strong)); @@ -788,8 +788,8 @@ function formatDate(iso: string): string { } .mod-row-pip { font-family: var(--font-mono); - font-size: 0.5625rem; - letter-spacing: calc(0.16em * var(--tracking-scale)); + font-size: var(--label-sm, 0.5625rem); + letter-spacing: var(--label-tracking-wide, calc(0.16em * var(--tracking-scale))); text-transform: uppercase; font-weight: 700; padding: 0.1rem 0.4rem; @@ -808,7 +808,7 @@ function formatDate(iso: string): string { } .mod-row-time { font-family: var(--font-mono); - font-size: 0.625rem; + font-size: var(--label-md, 0.625rem); color: rgb(var(--fg-subtle)); } .mod-row-statuschange { @@ -821,8 +821,8 @@ function formatDate(iso: string): string { font-style: normal; font-weight: 700; text-transform: uppercase; - letter-spacing: calc(0.06em * var(--tracking-scale)); - font-size: 0.625rem; + letter-spacing: var(--label-tracking, calc(0.08em * var(--tracking-scale))); + font-size: var(--label-md, 0.625rem); padding: 0.05rem 0.35rem; background: rgb(var(--r) / 0.14); border-radius: var(--radius-pill); @@ -850,8 +850,8 @@ function formatDate(iso: string): string { align-items: center; gap: 0.5rem; font-family: var(--font-mono); - font-size: 0.625rem; - letter-spacing: calc(0.18em * var(--tracking-scale)); + font-size: var(--label-md, 0.625rem); + letter-spacing: var(--label-tracking-wide, calc(0.16em * var(--tracking-scale))); text-transform: uppercase; color: rgb(var(--fg-muted)); font-weight: 700; @@ -912,7 +912,7 @@ function formatDate(iso: string): string { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; - letter-spacing: calc(0.1em * var(--tracking-scale)); + letter-spacing: var(--label-tracking, calc(0.08em * var(--tracking-scale))); cursor: pointer; transition: background var(--dur-2) ease, diff --git a/apps/web/app/components/torrent/detail/AdminSwarmTable.vue b/apps/web/app/components/torrent/detail/AdminSwarmTable.vue new file mode 100644 index 00000000..4f03b1be --- /dev/null +++ b/apps/web/app/components/torrent/detail/AdminSwarmTable.vue @@ -0,0 +1,405 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/AdultGate.vue b/apps/web/app/components/torrent/detail/AdultGate.vue new file mode 100644 index 00000000..9e4ba3f6 --- /dev/null +++ b/apps/web/app/components/torrent/detail/AdultGate.vue @@ -0,0 +1,406 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/BuffsForm.vue b/apps/web/app/components/torrent/detail/BuffsForm.vue new file mode 100644 index 00000000..2d6159b6 --- /dev/null +++ b/apps/web/app/components/torrent/detail/BuffsForm.vue @@ -0,0 +1,292 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/DecisionCard.vue b/apps/web/app/components/torrent/detail/DecisionCard.vue new file mode 100644 index 00000000..717cc0a9 --- /dev/null +++ b/apps/web/app/components/torrent/detail/DecisionCard.vue @@ -0,0 +1,622 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/DownloadCta.vue b/apps/web/app/components/torrent/detail/DownloadCta.vue new file mode 100644 index 00000000..85c2439f --- /dev/null +++ b/apps/web/app/components/torrent/detail/DownloadCta.vue @@ -0,0 +1,351 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/ExpandAllToggle.vue b/apps/web/app/components/torrent/detail/ExpandAllToggle.vue new file mode 100644 index 00000000..9761ae24 --- /dev/null +++ b/apps/web/app/components/torrent/detail/ExpandAllToggle.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/FederationSwarmToggle.vue b/apps/web/app/components/torrent/detail/FederationSwarmToggle.vue new file mode 100644 index 00000000..801a3c3b --- /dev/null +++ b/apps/web/app/components/torrent/detail/FederationSwarmToggle.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/IdentityCard.vue b/apps/web/app/components/torrent/detail/IdentityCard.vue new file mode 100644 index 00000000..4dd5c439 --- /dev/null +++ b/apps/web/app/components/torrent/detail/IdentityCard.vue @@ -0,0 +1,1221 @@ + + + + + \ No newline at end of file diff --git a/apps/web/app/components/torrent/detail/NfoPanel.vue b/apps/web/app/components/torrent/detail/NfoPanel.vue new file mode 100644 index 00000000..95128a64 --- /dev/null +++ b/apps/web/app/components/torrent/detail/NfoPanel.vue @@ -0,0 +1,196 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/OperatorArea.vue b/apps/web/app/components/torrent/detail/OperatorArea.vue new file mode 100644 index 00000000..81b1695f --- /dev/null +++ b/apps/web/app/components/torrent/detail/OperatorArea.vue @@ -0,0 +1,277 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/ProvenanceRow.vue b/apps/web/app/components/torrent/detail/ProvenanceRow.vue new file mode 100644 index 00000000..4d873f41 --- /dev/null +++ b/apps/web/app/components/torrent/detail/ProvenanceRow.vue @@ -0,0 +1,346 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/QualityChips.vue b/apps/web/app/components/torrent/detail/QualityChips.vue new file mode 100644 index 00000000..a456e214 --- /dev/null +++ b/apps/web/app/components/torrent/detail/QualityChips.vue @@ -0,0 +1,348 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/RelatedReleases.vue b/apps/web/app/components/torrent/detail/RelatedReleases.vue new file mode 100644 index 00000000..7d8fccb8 --- /dev/null +++ b/apps/web/app/components/torrent/detail/RelatedReleases.vue @@ -0,0 +1,531 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/SeedObligationCard.vue b/apps/web/app/components/torrent/detail/SeedObligationCard.vue new file mode 100644 index 00000000..643fe6d8 --- /dev/null +++ b/apps/web/app/components/torrent/detail/SeedObligationCard.vue @@ -0,0 +1,318 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/StickyDock.vue b/apps/web/app/components/torrent/detail/StickyDock.vue new file mode 100644 index 00000000..9a50ee19 --- /dev/null +++ b/apps/web/app/components/torrent/detail/StickyDock.vue @@ -0,0 +1,240 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/SupersedeForm.vue b/apps/web/app/components/torrent/detail/SupersedeForm.vue new file mode 100644 index 00000000..328c5da7 --- /dev/null +++ b/apps/web/app/components/torrent/detail/SupersedeForm.vue @@ -0,0 +1,207 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/SwarmHealth.vue b/apps/web/app/components/torrent/detail/SwarmHealth.vue new file mode 100644 index 00000000..dee6b259 --- /dev/null +++ b/apps/web/app/components/torrent/detail/SwarmHealth.vue @@ -0,0 +1,239 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/TorrentComments.vue b/apps/web/app/components/torrent/detail/TorrentComments.vue new file mode 100644 index 00000000..623721b1 --- /dev/null +++ b/apps/web/app/components/torrent/detail/TorrentComments.vue @@ -0,0 +1,579 @@ + + +