feat: add entity and release management components

- Created EntityTable component for displaying test entities with expandable rows for releases.
- Implemented ReleaseTable component to manage and display test releases with actions for editing and deleting.
- Added ReleaseModal component for creating and editing releases
- Introduced types for TestEntity, TestRelease, and related evaluations
- Enhanced general settings page to include TMDB API configuration with connection testing functionality.
- Added TMDBSettings component for managing TMDB API access token with reset and test connection features.
This commit is contained in:
Sam Chau
2026-01-14 23:50:20 +10:30
parent aec6d79695
commit 74b38df686
47 changed files with 4000 additions and 102 deletions

View File

@@ -1,9 +1,11 @@
/**
* Parser Service Client
* Calls the C# parser microservice
* Calls the C# parser microservice with optional caching
*/
import { config } from '$config';
import { logger } from '$logger/logger.ts';
import { parsedReleaseCacheQueries } from '$db/queries/parsedReleaseCache.ts';
import {
QualitySource,
QualityModifier,
@@ -16,6 +18,9 @@ import {
type MediaType
} from './types.ts';
// Cached parser version (fetched once per session)
let cachedParserVersion: string | null = null;
interface EpisodeResponse {
seriesTitle: string | null;
seasonNumber: number;
@@ -134,3 +139,220 @@ export async function isParserHealthy(): Promise<boolean> {
return false;
}
}
/**
* Get the parser version from the health endpoint
* Caches the version for the session to avoid repeated calls
*/
export async function getParserVersion(): Promise<string | null> {
if (cachedParserVersion) {
return cachedParserVersion;
}
try {
const res = await fetch(`${config.parserUrl}/health`);
if (!res.ok) {
await logger.warn('Parser health check failed', {
source: 'ParserClient',
meta: { status: res.status }
});
return null;
}
const data: { status: string; version: string } = await res.json();
cachedParserVersion = data.version;
await logger.debug(`Parser version: ${data.version}`, { source: 'ParserClient' });
return cachedParserVersion;
} catch (err) {
await logger.warn('Failed to connect to parser service', {
source: 'ParserClient',
meta: { error: err instanceof Error ? err.message : 'Unknown error' }
});
return null;
}
}
/**
* Clear the cached parser version
* Call this if you need to re-fetch the version (e.g., after parser restart)
*/
export function clearParserVersionCache(): void {
cachedParserVersion = null;
}
/**
* Generate cache key for a release title
*/
function getCacheKey(title: string, type: MediaType): string {
return `${title}:${type}`;
}
/**
* Parse a release title with caching
* First checks the cache, falls back to parser service on miss
* Automatically handles version invalidation
*
* @param title - The release title to parse
* @param type - The media type: 'movie' or 'series'
* @returns ParseResult or null if parser unavailable
*/
export async function parseWithCache(
title: string,
type: MediaType
): Promise<ParseResult | null> {
const parserVersion = await getParserVersion();
if (!parserVersion) {
// Parser not available
return null;
}
const cacheKey = getCacheKey(title, type);
// Check cache first
const cached = parsedReleaseCacheQueries.get(cacheKey, parserVersion);
if (cached) {
return JSON.parse(cached.parsed_result) as ParseResult;
}
// Cache miss - parse and store
try {
const result = await parse(title, type);
// Store in cache
parsedReleaseCacheQueries.set(cacheKey, parserVersion, JSON.stringify(result));
return result;
} catch {
// Parser error
return null;
}
}
/**
* Parse multiple release titles with caching (batch operation)
* More efficient than calling parseWithCache in a loop
*
* @param items - Array of { title, type } to parse
* @returns Map of cache key to ParseResult (null for failures)
*/
export async function parseWithCacheBatch(
items: Array<{ title: string; type: MediaType }>
): Promise<Map<string, ParseResult | null>> {
const results = new Map<string, ParseResult | null>();
const parserVersion = await getParserVersion();
if (!parserVersion) {
// Parser not available - return all nulls
await logger.debug(`Parser unavailable, skipping ${items.length} items`, {
source: 'ParserCache'
});
for (const item of items) {
results.set(getCacheKey(item.title, item.type), null);
}
return results;
}
// Separate cached vs uncached
const uncached: Array<{ title: string; type: MediaType; cacheKey: string }> = [];
for (const item of items) {
const cacheKey = getCacheKey(item.title, item.type);
const cached = parsedReleaseCacheQueries.get(cacheKey, parserVersion);
if (cached) {
results.set(cacheKey, JSON.parse(cached.parsed_result) as ParseResult);
} else {
uncached.push({ ...item, cacheKey });
}
}
const cacheHits = items.length - uncached.length;
// Parse uncached items in parallel
if (uncached.length > 0) {
const parsePromises = uncached.map(async (item) => {
try {
const result = await parse(item.title, item.type);
// Store in cache
parsedReleaseCacheQueries.set(item.cacheKey, parserVersion, JSON.stringify(result));
return { cacheKey: item.cacheKey, result };
} catch {
return { cacheKey: item.cacheKey, result: null };
}
});
const parsed = await Promise.all(parsePromises);
for (const { cacheKey, result } of parsed) {
results.set(cacheKey, result);
}
}
await logger.debug(`Parsed ${items.length} releases: ${cacheHits} cache hits, ${uncached.length} parsed`, {
source: 'ParserCache',
meta: { total: items.length, cacheHits, parsed: uncached.length, version: parserVersion }
});
return results;
}
/**
* Clean up old cache entries from previous parser versions
* Call this on startup or periodically
*/
export async function cleanupOldCacheEntries(): Promise<number> {
const parserVersion = await getParserVersion();
if (!parserVersion) {
return 0;
}
const deleted = parsedReleaseCacheQueries.deleteOldVersions(parserVersion);
if (deleted > 0) {
await logger.info(`Cleaned up ${deleted} stale parser cache entries`, {
source: 'ParserCache',
meta: { deleted, currentVersion: parserVersion }
});
}
return deleted;
}
/**
* Match multiple regex patterns against a text string using .NET regex
* This ensures patterns work exactly as they do in Sonarr/Radarr
*
* @param text - The text to match against (e.g., release title)
* @param patterns - Array of regex patterns to test
* @returns Map of pattern -> matched (true/false), or null if parser unavailable
*/
export async function matchPatterns(
text: string,
patterns: string[]
): Promise<Map<string, boolean> | null> {
if (patterns.length === 0) {
return new Map();
}
try {
const res = await fetch(`${config.parserUrl}/match`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, patterns })
});
if (!res.ok) {
await logger.warn('Pattern match request failed', {
source: 'ParserClient',
meta: { status: res.status }
});
return null;
}
const data: { results: Record<string, boolean> } = await res.json();
return new Map(Object.entries(data.results));
} catch (err) {
await logger.warn('Failed to connect to parser for pattern matching', {
source: 'ParserClient',
meta: { error: err instanceof Error ? err.message : 'Unknown error' }
});
return null;
}
}

View File

@@ -1,7 +1,17 @@
/**
* Release Title Parser
* Client for the C# parser microservice
* Client for the C# parser microservice with caching support
*/
export * from './types.ts';
export { parse, parseQuality, isParserHealthy } from './client.ts';
export {
parse,
parseQuality,
isParserHealthy,
getParserVersion,
clearParserVersionCache,
parseWithCache,
parseWithCacheBatch,
cleanupOldCacheEntries,
matchPatterns
} from './client.ts';

View File

@@ -0,0 +1,53 @@
import { BaseHttpClient } from '../http/client.ts';
import { logger } from '../logger/logger.ts';
import type { TMDBMovieSearchResponse, TMDBTVSearchResponse, TMDBAuthResponse } from './types.ts';
const TMDB_BASE_URL = 'https://api.themoviedb.org/3';
/**
* TMDB API client
*/
export class TMDBClient extends BaseHttpClient {
constructor(apiKey: string) {
super(TMDB_BASE_URL, {
headers: {
Authorization: `Bearer ${apiKey}`
}
});
}
/**
* Validate the API key
*/
async validateKey(): Promise<TMDBAuthResponse> {
return this.get<TMDBAuthResponse>('/authentication');
}
/**
* Search for movies
*/
async searchMovies(query: string, page = 1): Promise<TMDBMovieSearchResponse> {
logger.debug(`Searching movies: "${query}"`, { source: 'TMDB' });
const params = new URLSearchParams({
query,
include_adult: 'false',
language: 'en-US',
page: String(page)
});
return this.get<TMDBMovieSearchResponse>(`/search/movie?${params}`);
}
/**
* Search for TV shows
*/
async searchTVShows(query: string, page = 1): Promise<TMDBTVSearchResponse> {
logger.debug(`Searching TV shows: "${query}"`, { source: 'TMDB' });
const params = new URLSearchParams({
query,
include_adult: 'false',
language: 'en-US',
page: String(page)
});
return this.get<TMDBTVSearchResponse>(`/search/tv?${params}`);
}
}

View File

@@ -0,0 +1,57 @@
/**
* TMDB API response types
*/
export interface TMDBMovie {
adult: boolean;
backdrop_path: string | null;
genre_ids: number[];
id: number;
original_language: string;
original_title: string;
overview: string;
popularity: number;
poster_path: string | null;
release_date: string;
title: string;
video: boolean;
vote_average: number;
vote_count: number;
}
export interface TMDBMovieSearchResponse {
page: number;
results: TMDBMovie[];
total_pages: number;
total_results: number;
}
export interface TMDBTVShow {
adult: boolean;
backdrop_path: string | null;
genre_ids: number[];
id: number;
origin_country: string[];
original_language: string;
original_name: string;
overview: string;
popularity: number;
poster_path: string | null;
first_air_date: string;
name: string;
vote_average: number;
vote_count: number;
}
export interface TMDBTVSearchResponse {
page: number;
results: TMDBTVShow[];
total_pages: number;
total_results: number;
}
export interface TMDBAuthResponse {
success: boolean;
status_code: number;
status_message: string;
}