mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-31 06:40:50 +01:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
53
src/lib/server/utils/tmdb/client.ts
Normal file
53
src/lib/server/utils/tmdb/client.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
57
src/lib/server/utils/tmdb/types.ts
Normal file
57
src/lib/server/utils/tmdb/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user