From 656a3e3114cec87abd11bf28191c48f0533e89ae Mon Sep 17 00:00:00 2001 From: Sam Chau Date: Fri, 16 Jan 2026 19:05:57 +1030 Subject: [PATCH] feat: enhance entity testing with auto import releases functionality --- docs/CONTRIBUTING.md | 48 ++ docs/api/v1/openapi.yaml | 40 ++ docs/api/v1/paths/arr.yaml | 103 +++ docs/api/v1/paths/entity-testing.yaml | 33 + docs/api/v1/schemas/arr.yaml | 150 ++++ docs/api/v1/schemas/entity-testing.yaml | 114 ++++ docs/todo/1.automatic-entity-release-add.md | 454 +++++++++++++ src/lib/api/v1.d.ts | 335 +++++++++ src/lib/client/ui/actions/ActionButton.svelte | 8 +- src/lib/client/ui/modal/Modal.svelte | 8 +- .../client/ui/table/TableActionButton.svelte | 36 + src/lib/server/db/migrations.ts | 4 +- .../023_create_pattern_match_cache.ts | 29 + .../server/db/queries/patternMatchCache.ts | 118 ++++ src/lib/server/db/schema.sql | 74 +- .../pcd/queries/entityTests/createReleases.ts | 101 +++ .../server/pcd/queries/entityTests/index.ts | 2 + src/lib/server/utils/arr/clients/radarr.ts | 12 +- src/lib/server/utils/arr/clients/sonarr.ts | 48 +- src/lib/server/utils/arr/parser/client.ts | 137 ++++ src/lib/server/utils/arr/parser/index.ts | 3 +- src/lib/server/utils/arr/releaseImport.ts | 272 ++++++++ src/lib/server/utils/arr/types.ts | 161 +++++ src/routes/api/tmdb/search/+server.ts | 14 +- src/routes/api/v1/arr/library/+server.ts | 78 +++ src/routes/api/v1/arr/releases/+server.ts | 87 +++ .../entity-testing/evaluate/+server.ts | 41 +- .../[databaseId]/+page.server.ts | 168 ++--- .../entity-testing/[databaseId]/+page.svelte | 100 ++- .../components/AddEntityModal.svelte | 81 ++- .../components/EntityTable.svelte | 130 ++-- .../components/ImportReleasesModal.svelte | 641 ++++++++++++++++++ .../components/ReleaseTable.svelte | 32 +- .../[databaseId]/components/types.ts | 22 +- src/routes/settings/logs/+page.svelte | 23 +- src/services/parser/Program.cs | 66 ++ 36 files changed, 3549 insertions(+), 224 deletions(-) create mode 100644 docs/api/v1/paths/arr.yaml create mode 100644 docs/api/v1/paths/entity-testing.yaml create mode 100644 docs/api/v1/schemas/arr.yaml create mode 100644 docs/api/v1/schemas/entity-testing.yaml create mode 100644 docs/todo/1.automatic-entity-release-add.md create mode 100644 src/lib/client/ui/table/TableActionButton.svelte create mode 100644 src/lib/server/db/migrations/023_create_pattern_match_cache.ts create mode 100644 src/lib/server/db/queries/patternMatchCache.ts create mode 100644 src/lib/server/pcd/queries/entityTests/createReleases.ts create mode 100644 src/lib/server/utils/arr/releaseImport.ts create mode 100644 src/routes/api/v1/arr/library/+server.ts create mode 100644 src/routes/api/v1/arr/releases/+server.ts rename src/routes/api/{ => v1}/entity-testing/evaluate/+server.ts (68%) create mode 100644 src/routes/quality-profiles/entity-testing/[databaseId]/components/ImportReleasesModal.svelte diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 3f35ae2..a926074 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -75,6 +75,54 @@ The UI lets users: - **Settings** — Notifications (Discord/Slack/Email), backups, logging, theming, and background job management. +- **Test quality profiles** — Validate how custom formats score against real + release titles. Add test entities (movies/series from TMDB), attach release + titles, and see which formats match and how they score. + +#### Entity Testing + +Entity testing lets users validate quality profiles before syncing to Arr +instances. The workflow: + +1. Add test entities (movies or TV series) via TMDB search +2. Attach release titles to each entity—either manually or by importing from a + connected Arr instance +3. Select a quality profile to see how each release would score +4. Expand releases to see parsed metadata and which custom formats matched + +**Architecture:** + +- **Lazy evaluation** — Release parsing and CF evaluation happen on-demand when + an entity row is expanded, not on page load. This keeps the page fast even + with many entities/releases. +- **Parser service** — Release titles are parsed by the C# parser microservice + to extract metadata (resolution, source, languages, release group, etc.). + Pattern matching uses .NET-compatible regex for accuracy with Arr behavior. +- **Caching** — Both parsed results and pattern match results are cached in + SQLite. Cache keys include parser version (for parse cache) or pattern hash + (for match cache) to auto-invalidate when things change. + +**API Endpoints:** + +- `POST /api/v1/entity-testing/evaluate` — Parses releases and evaluates them + against all custom formats. Returns which CFs matched each release. +- `GET /api/v1/arr/library` — Fetches movie/series library from an Arr instance + for release import. +- `GET /api/v1/arr/releases` — Triggers interactive search on an Arr instance + and returns grouped/deduplicated releases. + +See `docs/todo/1.automatic-entity-release-add.md` for detailed API research and +the release import implementation plan. + +**Key Files:** + +- `src/routes/quality-profiles/entity-testing/` — Page and components +- `src/routes/api/v1/entity-testing/evaluate/+server.ts` — Evaluation endpoint +- `src/routes/api/v1/arr/library/+server.ts` — Library fetch endpoint +- `src/routes/api/v1/arr/releases/+server.ts` — Release search endpoint +- `src/lib/server/pcd/queries/customFormats/evaluator.ts` — CF evaluation logic +- `src/lib/server/utils/arr/parser/client.ts` — Parser client with caching + #### Routes, Not Modals Prefer routes over modals. Modals should only be used for things requiring diff --git a/docs/api/v1/openapi.yaml b/docs/api/v1/openapi.yaml index 52d3685..7a0196d 100644 --- a/docs/api/v1/openapi.yaml +++ b/docs/api/v1/openapi.yaml @@ -14,18 +14,58 @@ servers: tags: - name: System description: System health and status endpoints + - name: Entity Testing + description: Release parsing and custom format evaluation + - name: Arr + description: Arr instance library and release endpoints paths: /health: $ref: './paths/system.yaml#/health' /openapi.json: $ref: './paths/system.yaml#/openapi' + /entity-testing/evaluate: + $ref: './paths/entity-testing.yaml#/evaluate' + /arr/library: + $ref: './paths/arr.yaml#/library' + /arr/releases: + $ref: './paths/arr.yaml#/releases' components: schemas: + # Common ComponentStatus: $ref: './schemas/common.yaml#/ComponentStatus' HealthStatus: $ref: './schemas/common.yaml#/HealthStatus' + # Health HealthResponse: $ref: './schemas/health.yaml#/HealthResponse' + # Entity Testing + MediaType: + $ref: './schemas/entity-testing.yaml#/MediaType' + ParsedInfo: + $ref: './schemas/entity-testing.yaml#/ParsedInfo' + ReleaseInput: + $ref: './schemas/entity-testing.yaml#/ReleaseInput' + ReleaseEvaluation: + $ref: './schemas/entity-testing.yaml#/ReleaseEvaluation' + EvaluateRequest: + $ref: './schemas/entity-testing.yaml#/EvaluateRequest' + EvaluateResponse: + $ref: './schemas/entity-testing.yaml#/EvaluateResponse' + # Arr + ArrType: + $ref: './schemas/arr.yaml#/ArrType' + LibraryMovieItem: + $ref: './schemas/arr.yaml#/LibraryMovieItem' + LibrarySeriesItem: + $ref: './schemas/arr.yaml#/LibrarySeriesItem' + LibraryResponse: + $ref: './schemas/arr.yaml#/LibraryResponse' + GroupedRelease: + $ref: './schemas/arr.yaml#/GroupedRelease' + ReleasesResponse: + $ref: './schemas/arr.yaml#/ReleasesResponse' + ErrorResponse: + $ref: './schemas/arr.yaml#/ErrorResponse' diff --git a/docs/api/v1/paths/arr.yaml b/docs/api/v1/paths/arr.yaml new file mode 100644 index 0000000..3f53ea7 --- /dev/null +++ b/docs/api/v1/paths/arr.yaml @@ -0,0 +1,103 @@ +library: + get: + operationId: getLibrary + summary: Get Arr instance library + description: | + Fetches the movie or series library from an Arr instance. + + Returns a simplified list suitable for selection/matching. + - For Radarr: Returns movies with id, title, year, and tmdbId + - For Sonarr: Returns series with id, title, year, tvdbId, and available seasons + tags: + - Arr + parameters: + - name: instanceId + in: query + required: true + schema: + type: integer + description: Arr instance ID + responses: + '200': + description: Library items + content: + application/json: + schema: + $ref: '../schemas/arr.yaml#/LibraryResponse' + '400': + description: Invalid or missing instanceId + content: + application/json: + schema: + $ref: '../schemas/arr.yaml#/ErrorResponse' + '404': + description: Instance not found + content: + application/json: + schema: + $ref: '../schemas/arr.yaml#/ErrorResponse' + '500': + description: Failed to fetch library + content: + application/json: + schema: + $ref: '../schemas/arr.yaml#/ErrorResponse' + +releases: + get: + operationId: getReleases + summary: Search for releases + description: | + Triggers an interactive search on an Arr instance and returns grouped/deduplicated results. + + For Radarr: Searches for releases for the specified movie. + For Sonarr: Searches for season pack releases for the specified series and season. + + Results are grouped by title, combining information from multiple indexers. + tags: + - Arr + parameters: + - name: instanceId + in: query + required: true + schema: + type: integer + description: Arr instance ID + - name: itemId + in: query + required: true + schema: + type: integer + description: Movie ID (Radarr) or Series ID (Sonarr) + - name: season + in: query + required: false + schema: + type: integer + default: 1 + description: Season number for Sonarr searches (defaults to 1) + responses: + '200': + description: Release search results + content: + application/json: + schema: + $ref: '../schemas/arr.yaml#/ReleasesResponse' + '400': + description: Invalid or missing parameters + content: + application/json: + schema: + $ref: '../schemas/arr.yaml#/ErrorResponse' + '404': + description: Instance not found + content: + application/json: + schema: + $ref: '../schemas/arr.yaml#/ErrorResponse' + '500': + description: Failed to fetch releases + content: + application/json: + schema: + $ref: '../schemas/arr.yaml#/ErrorResponse' diff --git a/docs/api/v1/paths/entity-testing.yaml b/docs/api/v1/paths/entity-testing.yaml new file mode 100644 index 0000000..4e85969 --- /dev/null +++ b/docs/api/v1/paths/entity-testing.yaml @@ -0,0 +1,33 @@ +evaluate: + post: + operationId: evaluateReleases + summary: Evaluate releases against custom formats + description: | + Parses release titles and evaluates them against all custom formats in the specified database. + + This endpoint: + - Parses each release title to extract metadata (resolution, source, languages, etc.) + - Matches regex patterns using .NET-compatible regex via the parser service + - Evaluates each release against all custom formats in the database + - Returns which custom formats match each release + + Results are cached for performance - repeated requests with the same titles will be faster. + tags: + - Entity Testing + requestBody: + required: true + content: + application/json: + schema: + $ref: '../schemas/entity-testing.yaml#/EvaluateRequest' + responses: + '200': + description: Evaluation results + content: + application/json: + schema: + $ref: '../schemas/entity-testing.yaml#/EvaluateResponse' + '400': + description: Invalid request (missing databaseId or releases) + '500': + description: Database cache not available diff --git a/docs/api/v1/schemas/arr.yaml b/docs/api/v1/schemas/arr.yaml new file mode 100644 index 0000000..ee2ceb6 --- /dev/null +++ b/docs/api/v1/schemas/arr.yaml @@ -0,0 +1,150 @@ +ArrType: + type: string + enum: + - radarr + - sonarr + description: Type of Arr instance + +# Library endpoint types +LibraryMovieItem: + type: object + required: + - id + - title + - year + - tmdbId + properties: + id: + type: integer + description: Radarr movie ID + title: + type: string + description: Movie title + year: + type: integer + description: Release year + tmdbId: + type: integer + description: TMDB ID + +LibrarySeriesItem: + type: object + required: + - id + - title + - year + - tvdbId + - seasons + properties: + id: + type: integer + description: Sonarr series ID + title: + type: string + description: Series title + year: + type: integer + description: First air year + tvdbId: + type: integer + description: TVDB ID + seasons: + type: array + items: + type: integer + description: Available season numbers (excludes specials) + +LibraryRadarrResponse: + type: object + required: + - type + - items + properties: + type: + type: string + enum: + - radarr + items: + type: array + items: + $ref: '#/LibraryMovieItem' + +LibrarySonarrResponse: + type: object + required: + - type + - items + properties: + type: + type: string + enum: + - sonarr + items: + type: array + items: + $ref: '#/LibrarySeriesItem' + +LibraryResponse: + oneOf: + - $ref: '#/LibraryRadarrResponse' + - $ref: '#/LibrarySonarrResponse' + description: Library response varies by instance type + +# Releases endpoint types +GroupedRelease: + type: object + required: + - title + - size + - languages + - indexers + - flags + properties: + title: + type: string + description: Release title + size: + type: integer + description: Release size in bytes + languages: + type: array + items: + type: string + description: Languages detected in the release + indexers: + type: array + items: + type: string + description: Indexers where this release was found + flags: + type: array + items: + type: string + description: Release flags (e.g., freeleech, internal) + +ReleasesResponse: + type: object + required: + - type + - rawCount + - releases + properties: + type: + $ref: '#/ArrType' + rawCount: + type: integer + description: Total number of raw releases before grouping + releases: + type: array + items: + $ref: '#/GroupedRelease' + description: Grouped and deduplicated releases + +ErrorResponse: + type: object + required: + - error + properties: + error: + type: string + description: Error message diff --git a/docs/api/v1/schemas/entity-testing.yaml b/docs/api/v1/schemas/entity-testing.yaml new file mode 100644 index 0000000..efd7a2c --- /dev/null +++ b/docs/api/v1/schemas/entity-testing.yaml @@ -0,0 +1,114 @@ +MediaType: + type: string + enum: + - movie + - series + description: Type of media + +ParsedInfo: + type: object + required: + - source + - resolution + - modifier + - languages + - year + properties: + source: + type: string + description: Detected source (e.g., bluray, webdl, webrip) + resolution: + type: string + description: Detected resolution (e.g., 1080p, 2160p) + modifier: + type: string + description: Quality modifier (e.g., remux, none) + languages: + type: array + items: + type: string + description: Detected languages + releaseGroup: + type: string + nullable: true + description: Detected release group + year: + type: integer + description: Detected year + edition: + type: string + nullable: true + description: Detected edition (e.g., Director's Cut) + releaseType: + type: string + nullable: true + description: Release type for series (single_episode, season_pack, etc.) + +ReleaseInput: + type: object + required: + - id + - title + - type + properties: + id: + type: integer + description: Release ID + title: + type: string + description: Release title to parse and evaluate + type: + $ref: '#/MediaType' + +ReleaseEvaluation: + type: object + required: + - releaseId + - title + - cfMatches + properties: + releaseId: + type: integer + description: Release ID + title: + type: string + description: Release title + parsed: + $ref: '#/ParsedInfo' + nullable: true + description: Parsed release info (null if parsing failed) + cfMatches: + type: object + additionalProperties: + type: boolean + description: Map of custom format ID to whether it matches + +EvaluateRequest: + type: object + required: + - databaseId + - releases + properties: + databaseId: + type: integer + description: Database ID to use for custom format evaluation + releases: + type: array + items: + $ref: '#/ReleaseInput' + description: Releases to evaluate + +EvaluateResponse: + type: object + required: + - parserAvailable + - evaluations + properties: + parserAvailable: + type: boolean + description: Whether the parser service is available + evaluations: + type: array + items: + $ref: '#/ReleaseEvaluation' + description: Evaluation results for each release diff --git a/docs/todo/1.automatic-entity-release-add.md b/docs/todo/1.automatic-entity-release-add.md new file mode 100644 index 0000000..8f3c9dc --- /dev/null +++ b/docs/todo/1.automatic-entity-release-add.md @@ -0,0 +1,454 @@ +# Automatic Entity Release Import + +**Status: Complete** + +## Summary + +Implemented a feature to bulk-import releases from connected Arr instances into +the PCD for entity testing. Key components: + +- **API endpoints**: `/api/v1/arr/library` and `/api/v1/arr/releases` +- **Client methods**: `RadarrClient.getReleases()`, `SonarrClient.getSeries()`, + `SonarrClient.getReleases()`, `SonarrClient.getSeasonPackReleases()` +- **Utilities**: Flag normalization, indexer name sanitization, title similarity + (Dice coefficient), release grouping/deduplication + (`src/lib/server/utils/arr/releaseImport.ts`) +- **UI**: `ImportReleasesModal.svelte` - two-step flow (library selection → + release selection with search/sort/bulk-select) +- **Entry point**: Import button in entity table row actions + +--- + +## Overview + +Adding entity test releases 1 by 1 is slow. This feature allows users to conduct +an interactive search directly from Profilarr to connected Arrs, find releases +for an entity, and automatically add them to the PCD. + +## User Flow + +1. User clicks "add test release" on an entity +2. Choose between manual entry OR import from Arr +3. For import: select an Arr instance (Radarr for movies, Sonarr for TV) +4. Search/select the matching title in that Arr's library +5. Profilarr triggers interactive search and fetches results +6. Results are deduplicated/grouped by similar releases +7. User reviews and can edit before confirming +8. Releases are bulk-written to the PCD + +## UI Considerations + +- Entry point options: tabs in modal, separate buttons, or action button in + entity table? +- Need a review stage where users can see raw vs transformed results + +--- + +## API Research + +### Radarr - Release Endpoint + +**Endpoint:** `GET /api/v3/release?movieId={id}` + +This triggers an interactive search across all configured indexers and returns +results. It does NOT download anything - just returns available releases. + +Note: This is different from `POST /api/v3/command` with `MoviesSearch` which +triggers a background search that may auto-grab releases. + +**Example Response:** + +```json +{ + "guid": "PassThePopcorn-1345649", + "title": "Beetlejuice.Beetlejuice.2024.Hybrid.1080p.BluRay.DDP7.1.x264-ZoroSenpai", + "size": 13880140407, + "indexer": "PassThePopcorn (Prowlarr)", + "indexerId": 18, + "languages": [{ "id": 1, "name": "English" }], + "indexerFlags": ["G_Halfleech", "G_Internal"], + "quality": { + "quality": { + "id": 7, + "name": "Bluray-1080p", + "source": "bluray", + "resolution": 1080, + "modifier": "none" + } + }, + "customFormats": [ + { "id": 1474, "name": "1080p" }, + { "id": 1424, "name": "1080p Bluray" } + ], + "customFormatScore": 225600, + "releaseGroup": "ZoroSenpai", + "seeders": 204, + "leechers": 0, + "protocol": "torrent", + "age": 426, + "approved": false, + "rejected": true, + "rejections": ["Existing file on disk has equal or higher Custom Format score"] +} +``` + +**Fields we need for test releases:** + +| API Field | Maps To | Notes | +| -------------- | ------------------ | ---------------------------------- | +| `title` | `title` | Full release name | +| `size` | `size_bytes` | Already in bytes | +| `indexer` | `indexers[]` | Needs sanitization (remove suffix) | +| `languages` | `languages[]` | Extract `.name` from each object | +| `indexerFlags` | `flags[]` | Already an array of strings | + +### Sonarr - Release Endpoint + +**Endpoint:** `GET /api/v3/release?seriesId={id}&seasonNumber={season}` + +Both `seriesId` AND `seasonNumber` are required to get filtered results. Without +`seasonNumber`, the endpoint returns RSS feed releases from all series. + +For season packs, filter results by `fullSeason: true`. + +**Getting seasons from series:** + +Use `GET /api/v3/series/{id}` to get season info: + +```json +{ + "seasons": [ + { + "seasonNumber": 1, + "monitored": true, + "statistics": { + "episodeFileCount": 22, + "episodeCount": 22, + "totalEpisodeCount": 22, + "percentOfEpisodes": 100 + } + }, + { + "seasonNumber": 2, + "monitored": true, + "statistics": { + "nextAiring": "2026-02-27T01:00:00Z", + "episodeCount": 10, + "totalEpisodeCount": 14, + "percentOfEpisodes": 100 + } + } + ] +} +``` + +**Filter for finished seasons only** (don't search ongoing seasons): + +```typescript +const finishedSeasons = series.seasons.filter( + (s) => s.statistics.episodeCount === s.statistics.totalEpisodeCount +); +``` + +**Example Response:** + +```json +{ + "guid": "BTN-2167645", + "title": "Georgie.and.Mandys.First.Marriage.S01.1080p.AMZN.WEB-DL.DDP5.1.H.264-NTb", + "size": 28025938746, + "indexer": "BroadcasTheNet (Prowlarr)", + "indexerId": 5, + "languages": [{ "id": 1, "name": "English" }], + "indexerFlags": 9, + "fullSeason": true, + "seasonNumber": 1, + "seriesTitle": "Georgie & Mandy's First Marriage", + "episodeNumbers": [], + "quality": { + "quality": { + "id": 3, + "name": "WEBDL-1080p", + "source": "web", + "resolution": 1080 + } + }, + "customFormats": [{ "id": 808, "name": "2160p WEB-DL" }], + "customFormatScore": 468100, + "seeders": 258, + "leechers": 4, + "protocol": "torrent" +} +``` + +**Key differences from Radarr:** + +| Field | Radarr | Sonarr | +| -------------- | -------------------------------- | ----------------------------- | +| `indexerFlags` | String array `["G_Internal"]` | Integer bitmask `9` | +| Query params | `movieId` only | `seriesId` + `seasonNumber` | +| Extra fields | - | `fullSeason`, `seasonNumber` | + +### Indexer Flags + +Radarr returns flags as string array, Sonarr as integer bitmask. We need to +normalize both to a common format. + +**Radarr flags (string array):** +``` +["G_Freeleech", "G_Internal"] -> ["freeleech", "internal"] +``` + +**Sonarr bitmask (from `src/lib/server/sync/mappings.ts`):** +``` +freeleech: 1 (0b00000001) +halfleech: 2 (0b00000010) +double_upload: 4 (0b00000100) +internal: 8 (0b00001000) +scene: 16 (0b00010000) +freeleech_75: 32 (0b00100000) +freeleech_25: 64 (0b01000000) +nuked: 128 (0b10000000) +``` + +**Examples:** +- `9` = freeleech (1) + internal (8) +- `17` = freeleech (1) + scene (16) +- `2` = halfleech + +**Decoding function:** + +```typescript +import { INDEXER_FLAGS } from '$lib/server/sync/mappings.ts'; + +function decodeSonarrFlags(bitmask: number): string[] { + const flags: string[] = []; + const sonarrFlags = INDEXER_FLAGS.sonarr; + + for (const [name, value] of Object.entries(sonarrFlags)) { + if (bitmask & value) { + flags.push(name); + } + } + + return flags; +} + +function normalizeRadarrFlags(flags: string[]): string[] { + // Remove "G_" prefix and lowercase + return flags.map((f) => f.replace(/^G_/i, '').toLowerCase()); +} +``` + +--- + +## TypeScript Types + +```typescript +// Radarr release response +interface RadarrRelease { + guid: string; + title: string; + size: number; + indexer: string; + indexerId: number; + languages: Array<{ id: number; name: string }>; + indexerFlags: string[]; // String array like ["G_Internal", "G_Freeleech"] + quality: { + quality: { + id: number; + name: string; + source: string; + resolution: number; + modifier: string; + }; + }; + customFormats: Array<{ id: number; name: string }>; + customFormatScore: number; + releaseGroup: string | null; + seeders: number | null; + leechers: number | null; + protocol: 'torrent' | 'usenet'; + age: number; + approved: boolean; + rejected: boolean; + rejections: string[]; +} + +// Sonarr release response +interface SonarrRelease { + guid: string; + title: string; + size: number; + indexer: string; + indexerId: number; + languages: Array<{ id: number; name: string }>; + indexerFlags: number; // Integer bitmask + fullSeason: boolean; + seasonNumber: number; + seriesTitle: string; + episodeNumbers: number[]; + quality: { + quality: { + id: number; + name: string; + source: string; + resolution: number; + }; + }; + customFormats: Array<{ id: number; name: string }>; + customFormatScore: number; + releaseGroup: string | null; + seeders: number | null; + leechers: number | null; + protocol: 'torrent' | 'usenet'; + age: number; + approved: boolean; + rejected: boolean; + rejections: string[]; +} + +// Transformed for grouping/deduplication +interface GroupedRelease { + title: string; // Canonical title (from first occurrence) + size: number; // Average size of grouped releases + indexers: string[]; // All indexers that have this release + languages: string[]; // Union of all languages + flags: string[]; // Union of all flags + occurrences: number; // How many raw releases were grouped +} + +// Final shape for PCD test_releases table +interface TestReleaseInput { + entityId: number; + title: string; + size_bytes: number | null; + languages: string[]; + indexers: string[]; + flags: string[]; +} +``` + +--- + +## Data Transformation + +### Indexer Name Sanitization + +Indexer names from Prowlarr include a suffix like `(Prowlarr)` that we should +strip: + +``` +"PassThePopcorn (Prowlarr)" -> "PassThePopcorn" +"HDBits (Prowlarr)" -> "HDBits" +"BeyondHD (Prowlarr)" -> "BeyondHD" +``` + +**Implementation:** + +```typescript +function sanitizeIndexerName(name: string): string { + return name.replace(/\s*\(Prowlarr\)$/i, '').trim(); +} +``` + +### Release Deduplication Strategy + +Same release often appears from multiple indexers with slight variations: + +**Example from real data:** + +``` +Title: Beetlejuice.Beetlejuice.2024.Hybrid.1080p.BluRay.DDP7.1.x264-ZoroSenpai + - PassThePopcorn: 13,880,140,407 bytes, flags: [] + - BeyondHD: 13,880,140,407 bytes, flags: [] + - HDBits: 13,880,140,407 bytes, flags: [G_Halfleech, G_Internal] + - Blutopia: 13,880,140,800 bytes, flags: [] (393 byte difference!) +``` + +**Grouping criteria:** + +1. **Title similarity > 90%** - Handle minor formatting differences (dots vs + spaces) +2. **Size within ±5%** - Same release should have nearly identical size + +**Grouping algorithm:** + +```typescript +function groupReleases(releases: ArrRelease[]): GroupedRelease[] { + const groups: GroupedRelease[] = []; + + for (const release of releases) { + const match = groups.find( + (g) => + titleSimilarity(g.title, release.title) > 0.9 && + Math.abs(g.size - release.size) / g.size < 0.05 + ); + + if (match) { + // Add to existing group + match.indexers.push(sanitizeIndexerName(release.indexer)); + match.languages = union(match.languages, release.languages.map((l) => l.name)); + match.flags = union(match.flags, release.indexerFlags); + match.occurrences++; + } else { + // Create new group + groups.push({ + title: release.title, + size: release.size, + indexers: [sanitizeIndexerName(release.indexer)], + languages: release.languages.map((l) => l.name), + flags: [...release.indexerFlags], + occurrences: 1, + }); + } + } + + return groups; +} +``` + +**Title similarity options:** + +- Levenshtein distance normalized +- Dice coefficient +- Or simpler: normalize both titles (lowercase, replace separators) and compare + +--- + +## Implementation Checklist + +- [x] Add Radarr client method: `getReleases(movieId: number)` +- [x] Add Sonarr client method: `getSeries(seriesId: number)` (for season info) +- [x] Add Sonarr client method: `getReleases(seriesId: number, seasonNumber: number)` +- [x] Add helper to get finished seasons from series data +- [x] Add flag normalization utilities (Radarr string array, Sonarr bitmask) +- [x] Create release transformation utilities (sanitize indexer names, group, dedupe) +- [x] Add API endpoint for fetching/transforming releases +- [x] Update ReleaseModal with import option +- [x] Create import UI flow (Arr selection, title search, results review) +- [x] Add bulk release creation to PCD writer +- [x] Testing with real data + +--- + +## Open Questions + +1. ~~Should we show both raw and grouped results in the review UI?~~ **Resolved: + Show only grouped results. The `occurrences` count indicates how many were + merged.** +2. ~~How to handle releases that don't group well (unique from single indexer)?~~ + **Resolved: They appear as-is with `occurrences: 1`.** +3. ~~Should we store which indexers a release came from for reference?~~ + **Resolved: Yes, stored in `indexers[]` array.** +4. ~~Sonarr: do we search by series or by specific episode/season?~~ **Resolved: + Search by series + season number. Filter by `fullSeason: true` for season + packs.** + +--- + +## Future Considerations + +- **Refactor modal**: `ImportReleasesModal.svelte` is large (~640 lines). Could + split into separate components (e.g., `LibraryStep.svelte`, + `ReleasesStep.svelte`, `SeasonSelector.svelte`). diff --git a/src/lib/api/v1.d.ts b/src/lib/api/v1.d.ts index 4fcaeff..b79df29 100644 --- a/src/lib/api/v1.d.ts +++ b/src/lib/api/v1.d.ts @@ -49,6 +49,83 @@ export interface paths { patch?: never; trace?: never; }; + "/entity-testing/evaluate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Evaluate releases against custom formats + * @description Parses release titles and evaluates them against all custom formats in the specified database. + * + * This endpoint: + * - Parses each release title to extract metadata (resolution, source, languages, etc.) + * - Matches regex patterns using .NET-compatible regex via the parser service + * - Evaluates each release against all custom formats in the database + * - Returns which custom formats match each release + * + * Results are cached for performance - repeated requests with the same titles will be faster. + */ + post: operations["evaluateReleases"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/arr/library": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Arr instance library + * @description Fetches the movie or series library from an Arr instance. + * + * Returns a simplified list suitable for selection/matching. + * - For Radarr: Returns movies with id, title, year, and tmdbId + * - For Sonarr: Returns series with id, title, year, tvdbId, and available seasons + */ + get: operations["getLibrary"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/arr/releases": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Search for releases + * @description Triggers an interactive search on an Arr instance and returns grouped/deduplicated results. + * + * For Radarr: Searches for releases for the specified movie. + * For Sonarr: Searches for season pack releases for the specified series and season. + * + * Results are grouped by title, combining information from multiple indexers. + */ + get: operations["getReleases"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -82,6 +159,112 @@ export interface components { logs: components["schemas"]["LogsHealth"]; }; }; + /** + * @description Type of media + * @enum {string} + */ + MediaType: "movie" | "series"; + ParsedInfo: { + /** @description Detected source (e.g., bluray, webdl, webrip) */ + source: string; + /** @description Detected resolution (e.g., 1080p, 2160p) */ + resolution: string; + /** @description Quality modifier (e.g., remux, none) */ + modifier: string; + /** @description Detected languages */ + languages: string[]; + /** @description Detected release group */ + releaseGroup?: string | null; + /** @description Detected year */ + year: number; + /** @description Detected edition (e.g., Director's Cut) */ + edition?: string | null; + /** @description Release type for series (single_episode, season_pack, etc.) */ + releaseType?: string | null; + }; + ReleaseInput: { + /** @description Release ID */ + id: number; + /** @description Release title to parse and evaluate */ + title: string; + type: components["schemas"]["MediaType"]; + }; + ReleaseEvaluation: { + /** @description Release ID */ + releaseId: number; + /** @description Release title */ + title: string; + /** @description Parsed release info (null if parsing failed) */ + parsed?: components["schemas"]["ParsedInfo"]; + /** @description Map of custom format ID to whether it matches */ + cfMatches: { + [key: string]: boolean; + }; + }; + EvaluateRequest: { + /** @description Database ID to use for custom format evaluation */ + databaseId: number; + /** @description Releases to evaluate */ + releases: components["schemas"]["ReleaseInput"][]; + }; + EvaluateResponse: { + /** @description Whether the parser service is available */ + parserAvailable: boolean; + /** @description Evaluation results for each release */ + evaluations: components["schemas"]["ReleaseEvaluation"][]; + }; + /** + * @description Type of Arr instance + * @enum {string} + */ + ArrType: "radarr" | "sonarr"; + LibraryMovieItem: { + /** @description Radarr movie ID */ + id: number; + /** @description Movie title */ + title: string; + /** @description Release year */ + year: number; + /** @description TMDB ID */ + tmdbId: number; + }; + LibrarySeriesItem: { + /** @description Sonarr series ID */ + id: number; + /** @description Series title */ + title: string; + /** @description First air year */ + year: number; + /** @description TVDB ID */ + tvdbId: number; + /** @description Available season numbers (excludes specials) */ + seasons: number[]; + }; + /** @description Library response varies by instance type */ + LibraryResponse: components["schemas"]["LibraryRadarrResponse"] | components["schemas"]["LibrarySonarrResponse"]; + GroupedRelease: { + /** @description Release title */ + title: string; + /** @description Release size in bytes */ + size: number; + /** @description Languages detected in the release */ + languages: string[]; + /** @description Indexers where this release was found */ + indexers: string[]; + /** @description Release flags (e.g., freeleech, internal) */ + flags: string[]; + }; + ReleasesResponse: { + type: components["schemas"]["ArrType"]; + /** @description Total number of raw releases before grouping */ + rawCount: number; + /** @description Grouped and deduplicated releases */ + releases: components["schemas"]["GroupedRelease"][]; + }; + ErrorResponse: { + /** @description Error message */ + error: string; + }; DatabaseHealth: { status: components["schemas"]["ComponentStatus"]; /** @description Database query response time in milliseconds */ @@ -148,6 +331,16 @@ export interface components { /** @description Additional status information */ message?: string; }; + LibraryRadarrResponse: { + /** @enum {string} */ + type: "radarr"; + items: components["schemas"]["LibraryMovieItem"][]; + }; + LibrarySonarrResponse: { + /** @enum {string} */ + type: "sonarr"; + items: components["schemas"]["LibrarySeriesItem"][]; + }; }; responses: never; parameters: never; @@ -197,4 +390,146 @@ export interface operations { }; }; }; + evaluateReleases: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["EvaluateRequest"]; + }; + }; + responses: { + /** @description Evaluation results */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EvaluateResponse"]; + }; + }; + /** @description Invalid request (missing databaseId or releases) */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Database cache not available */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getLibrary: { + parameters: { + query: { + /** @description Arr instance ID */ + instanceId: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Library items */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LibraryResponse"]; + }; + }; + /** @description Invalid or missing instanceId */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Instance not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Failed to fetch library */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getReleases: { + parameters: { + query: { + /** @description Arr instance ID */ + instanceId: number; + /** @description Movie ID (Radarr) or Series ID (Sonarr) */ + itemId: number; + /** @description Season number for Sonarr searches (defaults to 1) */ + season?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Release search results */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReleasesResponse"]; + }; + }; + /** @description Invalid or missing parameters */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Instance not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Failed to fetch releases */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; } diff --git a/src/lib/client/ui/actions/ActionButton.svelte b/src/lib/client/ui/actions/ActionButton.svelte index 559c627..26d70b0 100644 --- a/src/lib/client/ui/actions/ActionButton.svelte +++ b/src/lib/client/ui/actions/ActionButton.svelte @@ -7,6 +7,7 @@ export let square: boolean = true; // Fixed size square button export let hasDropdown: boolean = false; export let dropdownPosition: 'left' | 'right' | 'middle' = 'left'; + export let disabled: boolean = false; let isHovered = false; let leaveTimer: ReturnType | null = null; @@ -34,9 +35,12 @@ role="group" > diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index 28dbf90..1155854 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -24,6 +24,7 @@ import { migration as migration019 } from './migrations/019_default_log_level_de import { migration as migration020 } from './migrations/020_create_tmdb_settings.ts'; import { migration as migration021 } from './migrations/021_create_parsed_release_cache.ts'; import { migration as migration022 } from './migrations/022_add_next_run_at.ts'; +import { migration as migration023 } from './migrations/023_create_pattern_match_cache.ts'; export interface Migration { version: number; @@ -260,7 +261,8 @@ export function loadMigrations(): Migration[] { migration019, migration020, migration021, - migration022 + migration022, + migration023 ]; // Sort by version number diff --git a/src/lib/server/db/migrations/023_create_pattern_match_cache.ts b/src/lib/server/db/migrations/023_create_pattern_match_cache.ts new file mode 100644 index 0000000..637bbc3 --- /dev/null +++ b/src/lib/server/db/migrations/023_create_pattern_match_cache.ts @@ -0,0 +1,29 @@ +import type { Migration } from '../migrations.ts'; + +export const migration: Migration = { + version: 23, + name: 'create_pattern_match_cache', + up: ` + -- Cache for pattern match results + -- Stores regex match results to avoid redundant computation + -- Key is title, invalidated when patterns change (via patterns_hash) + CREATE TABLE pattern_match_cache ( + title TEXT NOT NULL, -- Release title being matched + patterns_hash TEXT NOT NULL, -- Hash of all patterns (for invalidation) + match_results TEXT NOT NULL, -- JSON object: { pattern: boolean } + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (title, patterns_hash) + ); + + -- Index for cleanup queries by hash (delete old pattern sets) + CREATE INDEX idx_pattern_match_cache_hash ON pattern_match_cache(patterns_hash); + + -- Index for potential cleanup queries by age + CREATE INDEX idx_pattern_match_cache_created_at ON pattern_match_cache(created_at); + `, + down: ` + DROP INDEX IF EXISTS idx_pattern_match_cache_created_at; + DROP INDEX IF EXISTS idx_pattern_match_cache_hash; + DROP TABLE IF EXISTS pattern_match_cache; + ` +}; diff --git a/src/lib/server/db/queries/patternMatchCache.ts b/src/lib/server/db/queries/patternMatchCache.ts new file mode 100644 index 0000000..4c60058 --- /dev/null +++ b/src/lib/server/db/queries/patternMatchCache.ts @@ -0,0 +1,118 @@ +import { db } from '../db.ts'; + +/** + * Types for pattern_match_cache table + */ +export interface PatternMatchCache { + title: string; + patterns_hash: string; + match_results: string; + created_at: string; +} + +/** + * All queries for pattern_match_cache table + */ +export const patternMatchCacheQueries = { + /** + * Get cached match results for a title with specific patterns hash + */ + get(title: string, patternsHash: string): PatternMatchCache | undefined { + return db.queryFirst( + 'SELECT * FROM pattern_match_cache WHERE title = ? AND patterns_hash = ?', + title, + patternsHash + ); + }, + + /** + * Get cached match results for multiple titles with specific patterns hash + * Returns a map of title -> match_results JSON + */ + getBatch(titles: string[], patternsHash: string): Map { + if (titles.length === 0) return new Map(); + + const placeholders = titles.map(() => '?').join(','); + const results = db.query( + `SELECT title, match_results FROM pattern_match_cache WHERE patterns_hash = ? AND title IN (${placeholders})`, + patternsHash, + ...titles + ); + + const map = new Map(); + for (const row of results) { + map.set(row.title, row.match_results); + } + return map; + }, + + /** + * Store match results in cache (insert or replace) + */ + set(title: string, patternsHash: string, matchResults: string): void { + db.execute( + 'INSERT OR REPLACE INTO pattern_match_cache (title, patterns_hash, match_results) VALUES (?, ?, ?)', + title, + patternsHash, + matchResults + ); + }, + + /** + * Store multiple match results in cache (batch insert) + */ + setBatch(entries: Array<{ title: string; matchResults: string }>, patternsHash: string): void { + if (entries.length === 0) return; + + // Use a transaction for batch insert + const stmt = db.prepare( + 'INSERT OR REPLACE INTO pattern_match_cache (title, patterns_hash, match_results) VALUES (?, ?, ?)' + ); + + try { + for (const entry of entries) { + stmt.run(entry.title, patternsHash, entry.matchResults); + } + } finally { + stmt.finalize(); + } + }, + + /** + * Delete all entries for old pattern hashes + * Call this periodically to clean up stale cache entries + */ + deleteOldHashes(currentHash: string): number { + return db.execute( + 'DELETE FROM pattern_match_cache WHERE patterns_hash != ?', + currentHash + ); + }, + + /** + * Clear all cached entries + */ + clear(): number { + return db.execute('DELETE FROM pattern_match_cache'); + }, + + /** + * Get cache stats + */ + getStats(): { total: number; byHash: Record } { + const total = db.queryFirst<{ count: number }>( + 'SELECT COUNT(*) as count FROM pattern_match_cache' + )?.count ?? 0; + + const hashCounts = db.query<{ patterns_hash: string; count: number }>( + 'SELECT patterns_hash, COUNT(*) as count FROM pattern_match_cache GROUP BY patterns_hash' + ); + + const byHash: Record = {}; + for (const row of hashCounts) { + byHash[row.patterns_hash] = row.count; + } + + return { total, byHash }; + } +}; diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index 26b2839..536491e 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -1,7 +1,7 @@ -- Profilarr Database Schema -- This file documents the current database schema after all migrations -- DO NOT execute this file directly - use migrations instead --- Last updated: 2025-12-29 +-- Last updated: 2026-01-16 -- ============================================================================== -- TABLE: migrations @@ -44,7 +44,7 @@ CREATE TABLE arr_instances ( -- ============================================================================== -- TABLE: log_settings -- Purpose: Store configurable logging settings (singleton pattern with id=1) --- Migration: 003_create_log_settings.ts, 006_simplify_log_settings.ts +-- Migration: 003_create_log_settings.ts, 006_simplify_log_settings.ts, 019_default_log_level_debug.ts -- ============================================================================== CREATE TABLE log_settings ( @@ -53,8 +53,8 @@ CREATE TABLE log_settings ( -- Retention retention_days INTEGER NOT NULL DEFAULT 30, - -- Log Level - min_level TEXT NOT NULL DEFAULT 'INFO' CHECK (min_level IN ('DEBUG', 'INFO', 'WARN', 'ERROR')), + -- Log Level (default changed to DEBUG in migration 019) + min_level TEXT NOT NULL DEFAULT 'DEBUG' CHECK (min_level IN ('DEBUG', 'INFO', 'WARN', 'ERROR')), -- Enable/Disable enabled INTEGER NOT NULL DEFAULT 1, @@ -304,6 +304,7 @@ CREATE TABLE arr_sync_quality_profiles_config ( trigger TEXT NOT NULL DEFAULT 'none', -- 'none', 'manual', 'on_pull', 'on_change', 'schedule' cron TEXT, -- Cron expression for schedule trigger should_sync INTEGER NOT NULL DEFAULT 0, -- Flag for pending sync (Migration 016) + next_run_at TEXT, -- Next scheduled run timestamp (Migration 022) FOREIGN KEY (instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE ); @@ -332,6 +333,7 @@ CREATE TABLE arr_sync_delay_profiles_config ( trigger TEXT NOT NULL DEFAULT 'none', -- 'none', 'manual', 'on_pull', 'on_change', 'schedule' cron TEXT, -- Cron expression for schedule trigger should_sync INTEGER NOT NULL DEFAULT 0, -- Flag for pending sync (Migration 016) + next_run_at TEXT, -- Next scheduled run timestamp (Migration 022) FOREIGN KEY (instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE ); @@ -349,6 +351,7 @@ CREATE TABLE arr_sync_media_management ( trigger TEXT NOT NULL DEFAULT 'none', -- 'none', 'manual', 'on_pull', 'on_change', 'schedule' cron TEXT, -- Cron expression for schedule trigger should_sync INTEGER NOT NULL DEFAULT 0, -- Flag for pending sync (Migration 016) + next_run_at TEXT, -- Next scheduled run timestamp (Migration 022) FOREIGN KEY (instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE ); @@ -395,3 +398,66 @@ CREATE TABLE regex101_cache ( response TEXT NOT NULL, -- Full JSON response with test results fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP ); + +-- ============================================================================== +-- TABLE: app_info +-- Purpose: Store application metadata (singleton pattern with id=1) +-- Migration: 018_create_app_info.ts +-- ============================================================================== + +CREATE TABLE app_info ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version TEXT NOT NULL, -- Application version (e.g., "2.0.0") + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- ============================================================================== +-- TABLE: tmdb_settings +-- Purpose: Store TMDB API configuration (singleton pattern with id=1) +-- Migration: 020_create_tmdb_settings.ts +-- ============================================================================== + +CREATE TABLE tmdb_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + + -- TMDB Configuration + api_key TEXT NOT NULL DEFAULT '', -- TMDB API key for authentication + + -- Metadata + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- ============================================================================== +-- TABLE: parsed_release_cache +-- Purpose: Cache parsed release titles from parser microservice +-- Migration: 021_create_parsed_release_cache.ts +-- ============================================================================== + +CREATE TABLE parsed_release_cache ( + cache_key TEXT PRIMARY KEY, -- "{title}:{type}" e.g. "Movie.2024.1080p.WEB-DL:movie" + parser_version TEXT NOT NULL, -- Parser version when cached (for invalidation) + parsed_result TEXT NOT NULL, -- Full JSON ParseResult from parser + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_parsed_release_cache_version ON parsed_release_cache(parser_version); +CREATE INDEX idx_parsed_release_cache_created_at ON parsed_release_cache(created_at); + +-- ============================================================================== +-- TABLE: pattern_match_cache +-- Purpose: Cache regex pattern match results to avoid redundant computation +-- Migration: 023_create_pattern_match_cache.ts +-- ============================================================================== + +CREATE TABLE pattern_match_cache ( + title TEXT NOT NULL, -- Release title being matched + patterns_hash TEXT NOT NULL, -- Hash of all patterns (for invalidation) + match_results TEXT NOT NULL, -- JSON object: { pattern: boolean } + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (title, patterns_hash) +); + +CREATE INDEX idx_pattern_match_cache_hash ON pattern_match_cache(patterns_hash); +CREATE INDEX idx_pattern_match_cache_created_at ON pattern_match_cache(created_at); diff --git a/src/lib/server/pcd/queries/entityTests/createReleases.ts b/src/lib/server/pcd/queries/entityTests/createReleases.ts new file mode 100644 index 0000000..5352ebf --- /dev/null +++ b/src/lib/server/pcd/queries/entityTests/createReleases.ts @@ -0,0 +1,101 @@ +/** + * Bulk create test releases operation + */ + +import type { PCDCache } from '../../cache.ts'; +import { writeOperation, type OperationLayer } from '../../writer.ts'; + +export interface CreateTestReleasesInput { + entityId: number; + title: string; + size_bytes: number | null; + languages: string[]; + indexers: string[]; + flags: string[]; +} + +export interface CreateTestReleasesOptions { + databaseId: number; + cache: PCDCache; + layer: OperationLayer; + inputs: CreateTestReleasesInput[]; +} + +/** + * Bulk create test releases by writing operations to the specified layer + * Skips releases that already exist (by title within the same entity) + */ +export async function createReleases(options: CreateTestReleasesOptions) { + const { databaseId, cache, layer, inputs } = options; + const db = cache.kb; + + if (inputs.length === 0) { + return { + success: true, + added: 0, + skipped: 0 + }; + } + + // Get the entity ID (all inputs should have the same entityId) + const entityId = inputs[0].entityId; + + // Check for existing releases for this entity + const existingReleases = await db + .selectFrom('test_releases') + .select(['title']) + .where('test_entity_id', '=', entityId) + .execute(); + + const existingTitles = new Set(existingReleases.map((r) => r.title)); + + // Filter out duplicates + const newInputs = inputs.filter((input) => !existingTitles.has(input.title)); + + const skippedCount = inputs.length - newInputs.length; + + // If all releases already exist, return early + if (newInputs.length === 0) { + return { + success: true, + added: 0, + skipped: skippedCount + }; + } + + const queries = []; + + for (const input of newInputs) { + const insertRelease = db + .insertInto('test_releases') + .values({ + test_entity_id: input.entityId, + title: input.title, + size_bytes: input.size_bytes, + languages: JSON.stringify(input.languages), + indexers: JSON.stringify(input.indexers), + flags: JSON.stringify(input.flags) + }) + .compile(); + + queries.push(insertRelease); + } + + const result = await writeOperation({ + databaseId, + layer, + description: `import-test-releases`, + queries, + metadata: { + operation: 'create', + entity: 'test_release', + name: `${newInputs.length} releases` + } + }); + + return { + ...result, + added: newInputs.length, + skipped: skippedCount + }; +} diff --git a/src/lib/server/pcd/queries/entityTests/index.ts b/src/lib/server/pcd/queries/entityTests/index.ts index 8ed6d1e..3510d5f 100644 --- a/src/lib/server/pcd/queries/entityTests/index.ts +++ b/src/lib/server/pcd/queries/entityTests/index.ts @@ -5,6 +5,7 @@ // Export types export type { CreateTestEntityInput, CreateTestEntitiesOptions } from './create.ts'; export type { CreateTestReleaseInput, CreateTestReleaseOptions } from './createRelease.ts'; +export type { CreateTestReleasesInput, CreateTestReleasesOptions } from './createReleases.ts'; export type { UpdateTestReleaseInput, UpdateTestReleaseOptions } from './updateRelease.ts'; export type { DeleteTestReleaseOptions } from './deleteRelease.ts'; @@ -17,5 +18,6 @@ export { remove } from './delete.ts'; // Export release mutation functions export { createRelease } from './createRelease.ts'; +export { createReleases } from './createReleases.ts'; export { updateRelease } from './updateRelease.ts'; export { deleteRelease } from './deleteRelease.ts'; diff --git a/src/lib/server/utils/arr/clients/radarr.ts b/src/lib/server/utils/arr/clients/radarr.ts index ee8815b..ca4ccd9 100644 --- a/src/lib/server/utils/arr/clients/radarr.ts +++ b/src/lib/server/utils/arr/clients/radarr.ts @@ -8,7 +8,8 @@ import type { CustomFormatRef, QualityProfileFormatItem, RadarrTag, - RadarrCommand + RadarrCommand, + RadarrRelease } from '../types.ts'; /** @@ -142,6 +143,15 @@ export class RadarrClient extends BaseArrClient { }); } + /** + * Get releases for a movie (interactive search) + * Queries all configured indexers and returns available releases + * Note: This can take several seconds as it searches indexers in real-time + */ + getReleases(movieId: number): Promise { + return this.get(`/api/${this.apiVersion}/release?movieId=${movieId}`); + } + // ========================================================================= // Tag Methods // ========================================================================= diff --git a/src/lib/server/utils/arr/clients/sonarr.ts b/src/lib/server/utils/arr/clients/sonarr.ts index 620a164..70d199c 100644 --- a/src/lib/server/utils/arr/clients/sonarr.ts +++ b/src/lib/server/utils/arr/clients/sonarr.ts @@ -1,9 +1,55 @@ import { BaseArrClient } from '../base.ts'; +import type { SonarrSeries, SonarrRelease } from '../types.ts'; /** * Sonarr API client * Extends BaseArrClient with Sonarr-specific API methods */ export class SonarrClient extends BaseArrClient { - // Specific API methods will be implemented here as needed + // ========================================================================= + // Series Methods + // ========================================================================= + + /** + * Get all series + */ + getAllSeries(): Promise { + return this.get(`/api/${this.apiVersion}/series`); + } + + /** + * Get a specific series by ID + * Includes season information with statistics + */ + getSeries(seriesId: number): Promise { + return this.get(`/api/${this.apiVersion}/series/${seriesId}`); + } + + // ========================================================================= + // Search Methods + // ========================================================================= + + /** + * Get releases for a series/season (interactive search) + * Queries all configured indexers and returns available releases + * Note: This can take several seconds as it searches indexers in real-time + * + * @param seriesId - The series ID + * @param seasonNumber - The season number to search + * @returns Array of releases from indexers + */ + getReleases(seriesId: number, seasonNumber: number): Promise { + return this.get( + `/api/${this.apiVersion}/release?seriesId=${seriesId}&seasonNumber=${seasonNumber}` + ); + } + + /** + * Get only season pack releases (fullSeason: true) + * Filters out individual episode releases + */ + async getSeasonPackReleases(seriesId: number, seasonNumber: number): Promise { + const releases = await this.getReleases(seriesId, seasonNumber); + return releases.filter((r) => r.fullSeason); + } } diff --git a/src/lib/server/utils/arr/parser/client.ts b/src/lib/server/utils/arr/parser/client.ts index bfd43b6..fd65c8b 100644 --- a/src/lib/server/utils/arr/parser/client.ts +++ b/src/lib/server/utils/arr/parser/client.ts @@ -6,6 +6,7 @@ import { config } from '$config'; import { logger } from '$logger/logger.ts'; import { parsedReleaseCacheQueries } from '$db/queries/parsedReleaseCache.ts'; +import { patternMatchCacheQueries } from '$db/queries/patternMatchCache.ts'; import { QualitySource, QualityModifier, @@ -356,3 +357,139 @@ export async function matchPatterns( return null; } } + +/** + * Compute a hash of patterns for cache invalidation + * Uses Web Crypto API (built into Deno) + */ +async function hashPatterns(patterns: string[]): Promise { + const sorted = [...patterns].sort(); + const data = new TextEncoder().encode(sorted.join('\n')); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, 16); +} + +/** + * Fetch pattern matches from parser service (no caching) + */ +async function fetchPatternMatches( + texts: string[], + patterns: string[] +): Promise> | null> { + try { + const res = await fetch(`${config.parserUrl}/match/batch`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ texts, patterns }) + }); + + if (!res.ok) { + await logger.warn('Batch pattern match request failed', { + source: 'ParserClient', + meta: { status: res.status } + }); + return null; + } + + const data: { results: Record> } = await res.json(); + + const result = new Map>(); + for (const [text, patternResults] of Object.entries(data.results)) { + result.set(text, new Map(Object.entries(patternResults))); + } + return result; + } catch (err) { + await logger.warn('Failed to connect to parser for batch pattern matching', { + source: 'ParserClient', + meta: { error: err instanceof Error ? err.message : 'Unknown error' } + }); + return null; + } +} + +/** + * Match multiple texts against patterns in a single request with caching + * Results are cached keyed by title + patterns hash + * Cache automatically invalidates when patterns change + * + * @param texts - Array of texts to match (e.g., release titles) + * @param patterns - Array of regex patterns to test + * @returns Map of text -> (pattern -> matched), or null if parser unavailable + */ +export async function matchPatternsBatch( + texts: string[], + patterns: string[] +): Promise> | null> { + if (texts.length === 0 || patterns.length === 0) { + return new Map(); + } + + // Compute hash of patterns for cache key + const patternsHash = await hashPatterns(patterns); + + // Check cache for existing results + const cachedResults = patternMatchCacheQueries.getBatch(texts, patternsHash); + const results = new Map>(); + const uncachedTexts: string[] = []; + + for (const text of texts) { + const cached = cachedResults.get(text); + if (cached) { + // Parse cached JSON back to Map + const parsed = JSON.parse(cached) as Record; + results.set(text, new Map(Object.entries(parsed))); + } else { + uncachedTexts.push(text); + } + } + + const cacheHits = texts.length - uncachedTexts.length; + + // If all cached, return immediately + if (uncachedTexts.length === 0) { + await logger.debug(`Pattern match: ${texts.length} cache hits, 0 computed`, { + source: 'PatternMatchCache', + meta: { total: texts.length, cacheHits, patternsHash } + }); + return results; + } + + // Fetch uncached results from parser + const fetchedResults = await fetchPatternMatches(uncachedTexts, patterns); + if (!fetchedResults) { + // Parser unavailable - return partial results if any + if (cacheHits > 0) { + await logger.debug(`Pattern match: ${cacheHits} cache hits, parser unavailable for ${uncachedTexts.length}`, { + source: 'PatternMatchCache', + meta: { total: texts.length, cacheHits, uncached: uncachedTexts.length } + }); + return results; + } + return null; + } + + // Store new results in cache and add to return map + const toCache: Array<{ title: string; matchResults: string }> = []; + for (const [text, patternMatches] of fetchedResults) { + results.set(text, patternMatches); + // Convert Map to object for JSON storage + const obj: Record = {}; + for (const [pattern, matched] of patternMatches) { + obj[pattern] = matched; + } + toCache.push({ title: text, matchResults: JSON.stringify(obj) }); + } + + // Batch insert into cache + if (toCache.length > 0) { + patternMatchCacheQueries.setBatch(toCache, patternsHash); + } + + await logger.debug(`Pattern match: ${cacheHits} cache hits, ${uncachedTexts.length} computed`, { + source: 'PatternMatchCache', + meta: { total: texts.length, cacheHits, computed: uncachedTexts.length, patternsHash } + }); + + return results; +} diff --git a/src/lib/server/utils/arr/parser/index.ts b/src/lib/server/utils/arr/parser/index.ts index 720eb30..e7d22cc 100644 --- a/src/lib/server/utils/arr/parser/index.ts +++ b/src/lib/server/utils/arr/parser/index.ts @@ -13,5 +13,6 @@ export { parseWithCache, parseWithCacheBatch, cleanupOldCacheEntries, - matchPatterns + matchPatterns, + matchPatternsBatch } from './client.ts'; diff --git a/src/lib/server/utils/arr/releaseImport.ts b/src/lib/server/utils/arr/releaseImport.ts new file mode 100644 index 0000000..3e68a02 --- /dev/null +++ b/src/lib/server/utils/arr/releaseImport.ts @@ -0,0 +1,272 @@ +/** + * Release Import Utilities + * Helpers for importing releases from Arr interactive search into PCD test releases + */ + +import { INDEXER_FLAGS } from '$lib/server/sync/mappings.ts'; +import type { SonarrSeries, SonarrSeason, RadarrRelease, SonarrRelease } from './types.ts'; + +// ============================================================================= +// Season Helpers +// ============================================================================= + +/** + * Get finished seasons from a series (where all episodes have aired) + * A season is considered finished when episodeCount === totalEpisodeCount + */ +export function getFinishedSeasons(series: SonarrSeries): SonarrSeason[] { + return series.seasons.filter( + (s) => s.statistics.episodeCount === s.statistics.totalEpisodeCount + ); +} + +/** + * Check if a specific season is finished + */ +export function isSeasonFinished(season: SonarrSeason): boolean { + return season.statistics.episodeCount === season.statistics.totalEpisodeCount; +} + +// ============================================================================= +// Flag Normalization +// ============================================================================= + +/** + * Normalize Radarr indexer flags (string array with G_ prefix) + * Example: ["G_Freeleech", "G_Internal"] -> ["freeleech", "internal"] + */ +export function normalizeRadarrFlags(flags: string[]): string[] { + return flags.map((f) => f.replace(/^G_/i, '').toLowerCase().replace(/_/g, ' ')); +} + +/** + * Decode Sonarr indexer flags bitmask to string array + * Example: 9 -> ["freeleech", "internal"] + */ +export function decodeSonarrFlags(bitmask: number): string[] { + const flags: string[] = []; + const sonarrFlags = INDEXER_FLAGS.sonarr; + + for (const [name, value] of Object.entries(sonarrFlags)) { + if (bitmask & value) { + // Convert snake_case to readable format + flags.push(name.replace(/_/g, ' ')); + } + } + + return flags; +} + +// ============================================================================= +// Indexer Name Sanitization +// ============================================================================= + +/** + * Sanitize indexer name by removing common suffixes + * Example: "PassThePopcorn (Prowlarr)" -> "PassThePopcorn" + */ +export function sanitizeIndexerName(name: string): string { + return name + .replace(/\s*\(Prowlarr\)$/i, '') + .replace(/\s*\(Jackett\)$/i, '') + .trim(); +} + +// ============================================================================= +// Title Normalization & Similarity +// ============================================================================= + +/** + * Normalize a release title for comparison + * - Lowercase + * - Replace dots and underscores with spaces + * - Collapse multiple spaces + * - Trim + */ +export function normalizeTitle(title: string): string { + return title + .toLowerCase() + .replace(/[._]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Calculate similarity between two titles using Dice coefficient + * Returns a value between 0 and 1 (1 = identical) + */ +export function titleSimilarity(title1: string, title2: string): number { + const norm1 = normalizeTitle(title1); + const norm2 = normalizeTitle(title2); + + if (norm1 === norm2) return 1; + + // Create bigrams + const bigrams1 = getBigrams(norm1); + const bigrams2 = getBigrams(norm2); + + // Calculate intersection + const intersection = new Set([...bigrams1].filter((b) => bigrams2.has(b))); + + // Dice coefficient: 2 * |intersection| / (|set1| + |set2|) + return (2 * intersection.size) / (bigrams1.size + bigrams2.size); +} + +/** + * Get bigrams (2-character sequences) from a string + */ +function getBigrams(str: string): Set { + const bigrams = new Set(); + for (let i = 0; i < str.length - 1; i++) { + bigrams.add(str.slice(i, i + 2)); + } + return bigrams; +} + +// ============================================================================= +// Release Grouping/Deduplication +// ============================================================================= + +/** + * Grouped release after deduplication + */ +export interface GroupedRelease { + title: string; + size: number; + indexers: string[]; + languages: string[]; + flags: string[]; + occurrences: number; +} + +/** + * Union helper for arrays (returns unique values) + */ +function union(arr1: T[], arr2: T[]): T[] { + return [...new Set([...arr1, ...arr2])]; +} + +/** + * Group Radarr releases by similarity + * Releases are grouped if title similarity > threshold AND size within tolerance + * + * @param releases - Raw releases from Radarr API + * @param titleThreshold - Minimum title similarity (0-1, default 0.9) + * @param sizeTolerance - Maximum size difference as fraction (default 0.05 = 5%) + */ +export function groupRadarrReleases( + releases: RadarrRelease[], + titleThreshold = 0.9, + sizeTolerance = 0.05 +): GroupedRelease[] { + const groups: GroupedRelease[] = []; + + for (const release of releases) { + const match = groups.find((g) => { + const similarity = titleSimilarity(g.title, release.title); + const sizeDiff = Math.abs(g.size - release.size) / Math.max(g.size, 1); + return similarity > titleThreshold && sizeDiff < sizeTolerance; + }); + + const languages = release.languages.map((l) => l.name); + const flags = normalizeRadarrFlags(release.indexerFlags); + const indexer = sanitizeIndexerName(release.indexer); + + if (match) { + match.indexers = union(match.indexers, [indexer]); + match.languages = union(match.languages, languages); + match.flags = union(match.flags, flags); + match.occurrences++; + } else { + groups.push({ + title: release.title, + size: release.size, + indexers: [indexer], + languages, + flags, + occurrences: 1 + }); + } + } + + return groups; +} + +/** + * Group Sonarr releases by similarity + * Same as Radarr but handles the integer bitmask for flags + * + * @param releases - Raw releases from Sonarr API + * @param titleThreshold - Minimum title similarity (0-1, default 0.9) + * @param sizeTolerance - Maximum size difference as fraction (default 0.05 = 5%) + */ +export function groupSonarrReleases( + releases: SonarrRelease[], + titleThreshold = 0.9, + sizeTolerance = 0.05 +): GroupedRelease[] { + const groups: GroupedRelease[] = []; + + for (const release of releases) { + const match = groups.find((g) => { + const similarity = titleSimilarity(g.title, release.title); + const sizeDiff = Math.abs(g.size - release.size) / Math.max(g.size, 1); + return similarity > titleThreshold && sizeDiff < sizeTolerance; + }); + + const languages = release.languages.map((l) => l.name); + const flags = decodeSonarrFlags(release.indexerFlags); + const indexer = sanitizeIndexerName(release.indexer); + + if (match) { + match.indexers = union(match.indexers, [indexer]); + match.languages = union(match.languages, languages); + match.flags = union(match.flags, flags); + match.occurrences++; + } else { + groups.push({ + title: release.title, + size: release.size, + indexers: [indexer], + languages, + flags, + occurrences: 1 + }); + } + } + + return groups; +} + +// ============================================================================= +// Test Release Conversion +// ============================================================================= + +/** + * Shape for PCD test release creation + */ +export interface TestReleaseInput { + entityId: number; + title: string; + size_bytes: number | null; + languages: string[]; + indexers: string[]; + flags: string[]; +} + +/** + * Convert grouped releases to test release inputs + */ +export function groupedReleasesToTestInputs( + groupedReleases: GroupedRelease[], + entityId: number +): TestReleaseInput[] { + return groupedReleases.map((r) => ({ + entityId, + title: r.title, + size_bytes: r.size, + languages: r.languages, + indexers: r.indexers, + flags: r.flags + })); +} diff --git a/src/lib/server/utils/arr/types.ts b/src/lib/server/utils/arr/types.ts index 1aa20f3..7412877 100644 --- a/src/lib/server/utils/arr/types.ts +++ b/src/lib/server/utils/arr/types.ts @@ -156,6 +156,167 @@ export interface RadarrCommand { }; } +// ============================================================================= +// Release Types (Interactive Search) +// ============================================================================= + +/** + * Release from /api/v3/release (Radarr) + * Returned by interactive search endpoint + */ +export interface RadarrRelease { + guid: string; + title: string; + size: number; + indexer: string; + indexerId: number; + languages: Array<{ id: number; name: string }>; + indexerFlags: string[]; // String array like ["G_Internal", "G_Freeleech"] + quality: { + quality: { + id: number; + name: string; + source: string; + resolution: number; + modifier: string; + }; + revision?: { + version: number; + real: number; + isRepack: boolean; + }; + }; + customFormats: Array<{ id: number; name: string }>; + customFormatScore: number; + releaseGroup: string | null; + seeders: number | null; + leechers: number | null; + protocol: 'torrent' | 'usenet' | 'unknown'; + age: number; + ageHours: number; + ageMinutes: number; + approved: boolean; + temporarilyRejected: boolean; + rejected: boolean; + rejections: string[]; + publishDate: string; + downloadUrl: string | null; + infoUrl: string | null; + magnetUrl: string | null; + infoHash: string | null; +} + +// ============================================================================= +// Sonarr Types +// ============================================================================= + +/** + * Season statistics from /api/v3/series + */ +export interface SonarrSeasonStatistics { + previousAiring?: string; + nextAiring?: string; + episodeFileCount: number; + episodeCount: number; + totalEpisodeCount: number; + sizeOnDisk: number; + releaseGroups: string[]; + percentOfEpisodes: number; +} + +/** + * Season from /api/v3/series + */ +export interface SonarrSeason { + seasonNumber: number; + monitored: boolean; + statistics: SonarrSeasonStatistics; +} + +/** + * Series from /api/v3/series + */ +export interface SonarrSeries { + id: number; + title: string; + sortTitle?: string; + tvdbId?: number; + imdbId?: string; + overview?: string; + path?: string; + qualityProfileId: number; + seasonFolder?: boolean; + monitored: boolean; + status?: string; + year?: number; + seasons: SonarrSeason[]; + images?: Array<{ coverType: string; url: string; remoteUrl?: string }>; + genres?: string[]; + tags?: number[]; + added?: string; + statistics?: { + seasonCount: number; + episodeFileCount: number; + episodeCount: number; + totalEpisodeCount: number; + sizeOnDisk: number; + percentOfEpisodes: number; + }; +} + +/** + * Release from /api/v3/release (Sonarr) + * Returned by interactive search endpoint + */ +export interface SonarrRelease { + guid: string; + title: string; + size: number; + indexer: string; + indexerId: number; + languages: Array<{ id: number; name: string }>; + indexerFlags: number; // Integer bitmask + fullSeason: boolean; + seasonNumber: number; + seriesTitle: string; + episodeNumbers: number[]; + absoluteEpisodeNumbers: number[]; + mappedSeasonNumber: number | null; + mappedEpisodeNumbers: number[] | null; + mappedSeriesId: number | null; + quality: { + quality: { + id: number; + name: string; + source: string; + resolution: number; + }; + revision?: { + version: number; + real: number; + isRepack: boolean; + }; + }; + customFormats: Array<{ id: number; name: string }>; + customFormatScore: number; + releaseGroup: string | null; + seeders: number | null; + leechers: number | null; + protocol: 'torrent' | 'usenet' | 'unknown'; + age: number; + ageHours: number; + ageMinutes: number; + approved: boolean; + temporarilyRejected: boolean; + rejected: boolean; + rejections: string[]; + publishDate: string; + downloadUrl: string | null; + infoUrl: string | null; + magnetUrl: string | null; + infoHash: string | null; +} + // ============================================================================= // Library View Types (computed/joined data) // ============================================================================= diff --git a/src/routes/api/tmdb/search/+server.ts b/src/routes/api/tmdb/search/+server.ts index b4eb74a..4ee8a57 100644 --- a/src/routes/api/tmdb/search/+server.ts +++ b/src/routes/api/tmdb/search/+server.ts @@ -31,7 +31,8 @@ export const GET: RequestHandler = async ({ url }) => { overview: m.overview, posterPath: m.poster_path, releaseDate: m.release_date, - voteAverage: m.vote_average + voteAverage: m.vote_average, + popularity: m.popularity })), totalPages: result.total_pages, totalResults: result.total_results, @@ -47,7 +48,8 @@ export const GET: RequestHandler = async ({ url }) => { overview: t.overview, posterPath: t.poster_path, releaseDate: t.first_air_date, - voteAverage: t.vote_average + voteAverage: t.vote_average, + popularity: t.popularity })), totalPages: result.total_pages, totalResults: result.total_results, @@ -68,7 +70,8 @@ export const GET: RequestHandler = async ({ url }) => { overview: m.overview, posterPath: m.poster_path, releaseDate: m.release_date, - voteAverage: m.vote_average + voteAverage: m.vote_average, + popularity: m.popularity })), ...tvShows.results.map((t) => ({ id: t.id, @@ -77,9 +80,10 @@ export const GET: RequestHandler = async ({ url }) => { overview: t.overview, posterPath: t.poster_path, releaseDate: t.first_air_date, - voteAverage: t.vote_average + voteAverage: t.vote_average, + popularity: t.popularity })) - ].sort((a, b) => b.voteAverage - a.voteAverage); + ].sort((a, b) => b.popularity - a.popularity); return json({ results: combined, diff --git a/src/routes/api/v1/arr/library/+server.ts b/src/routes/api/v1/arr/library/+server.ts new file mode 100644 index 0000000..daf63a7 --- /dev/null +++ b/src/routes/api/v1/arr/library/+server.ts @@ -0,0 +1,78 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { arrInstancesQueries } from '$db/queries/arrInstances.ts'; +import { RadarrClient } from '$utils/arr/clients/radarr.ts'; +import { SonarrClient } from '$utils/arr/clients/sonarr.ts'; + +/** + * GET /api/v1/arr/library + * + * Get movies or series from an Arr instance's library. + * Returns a simplified list for selection/matching. + * + * Query params: + * - instanceId: Arr instance ID (required) + */ +export const GET: RequestHandler = async ({ url }) => { + const instanceId = url.searchParams.get('instanceId'); + + if (!instanceId) { + return json({ error: 'instanceId is required' }, { status: 400 }); + } + + const instanceIdNum = parseInt(instanceId, 10); + if (isNaN(instanceIdNum)) { + return json({ error: 'Invalid instanceId' }, { status: 400 }); + } + + // Get the Arr instance + const instance = arrInstancesQueries.getById(instanceIdNum); + if (!instance) { + return json({ error: 'Instance not found' }, { status: 404 }); + } + + try { + if (instance.type === 'radarr') { + const client = new RadarrClient(instance.url, instance.api_key); + try { + const movies = await client.getMovies(); + return json({ + type: 'radarr', + items: movies.map((m) => ({ + id: m.id, + title: m.title, + year: m.year, + tmdbId: m.tmdbId + })) + }); + } finally { + client.close(); + } + } else if (instance.type === 'sonarr') { + const client = new SonarrClient(instance.url, instance.api_key); + try { + const series = await client.getAllSeries(); + return json({ + type: 'sonarr', + items: series.map((s) => ({ + id: s.id, + title: s.title, + year: s.year, + tvdbId: s.tvdbId, + seasons: s.seasons + .map((season) => season.seasonNumber) + .filter((n) => n > 0) // Exclude "specials" (season 0) + .sort((a, b) => a - b) + })) + }); + } finally { + client.close(); + } + } else { + return json({ error: `Unsupported instance type: ${instance.type}` }, { status: 400 }); + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to fetch library'; + return json({ error: message }, { status: 500 }); + } +}; diff --git a/src/routes/api/v1/arr/releases/+server.ts b/src/routes/api/v1/arr/releases/+server.ts new file mode 100644 index 0000000..076d8db --- /dev/null +++ b/src/routes/api/v1/arr/releases/+server.ts @@ -0,0 +1,87 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { arrInstancesQueries } from '$db/queries/arrInstances.ts'; +import { RadarrClient } from '$utils/arr/clients/radarr.ts'; +import { SonarrClient } from '$utils/arr/clients/sonarr.ts'; +import { groupRadarrReleases, groupSonarrReleases } from '$utils/arr/releaseImport.ts'; + +/** + * GET /api/v1/arr/releases + * + * Search for releases from an Arr instance. + * Triggers an interactive search and returns grouped/deduplicated results. + * + * Query params: + * - instanceId: Arr instance ID (required) + * - itemId: Movie ID (Radarr) or Series ID (Sonarr) (required) + * - season: Season number for Sonarr (optional, defaults to 1) + * + * Note: For Sonarr, this searches the specified season and filters for season packs only. + */ +export const GET: RequestHandler = async ({ url }) => { + const instanceId = url.searchParams.get('instanceId'); + const itemId = url.searchParams.get('itemId'); + const season = url.searchParams.get('season'); + + if (!instanceId) { + return json({ error: 'instanceId is required' }, { status: 400 }); + } + + if (!itemId) { + return json({ error: 'itemId is required' }, { status: 400 }); + } + + const instanceIdNum = parseInt(instanceId, 10); + const itemIdNum = parseInt(itemId, 10); + + if (isNaN(instanceIdNum)) { + return json({ error: 'Invalid instanceId' }, { status: 400 }); + } + + if (isNaN(itemIdNum)) { + return json({ error: 'Invalid itemId' }, { status: 400 }); + } + + // Get the Arr instance + const instance = arrInstancesQueries.getById(instanceIdNum); + if (!instance) { + return json({ error: 'Instance not found' }, { status: 404 }); + } + + try { + if (instance.type === 'radarr') { + const client = new RadarrClient(instance.url, instance.api_key); + try { + const releases = await client.getReleases(itemIdNum); + const grouped = groupRadarrReleases(releases); + return json({ + type: 'radarr', + rawCount: releases.length, + releases: grouped + }); + } finally { + client.close(); + } + } else if (instance.type === 'sonarr') { + const client = new SonarrClient(instance.url, instance.api_key); + try { + // Search specified season (default to 1) and filter for season packs + const seasonNum = season ? parseInt(season, 10) : 1; + const releases = await client.getSeasonPackReleases(itemIdNum, isNaN(seasonNum) ? 1 : seasonNum); + const grouped = groupSonarrReleases(releases); + return json({ + type: 'sonarr', + rawCount: releases.length, + releases: grouped + }); + } finally { + client.close(); + } + } else { + return json({ error: `Unsupported instance type: ${instance.type}` }, { status: 400 }); + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to fetch releases'; + return json({ error: message }, { status: 500 }); + } +}; diff --git a/src/routes/api/entity-testing/evaluate/+server.ts b/src/routes/api/v1/entity-testing/evaluate/+server.ts similarity index 68% rename from src/routes/api/entity-testing/evaluate/+server.ts rename to src/routes/api/v1/entity-testing/evaluate/+server.ts index 3f0f94e..4d5fe80 100644 --- a/src/routes/api/entity-testing/evaluate/+server.ts +++ b/src/routes/api/v1/entity-testing/evaluate/+server.ts @@ -6,30 +6,19 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { pcdManager } from '$pcd/pcd.ts'; -import { parseWithCacheBatch, isParserHealthy } from '$lib/server/utils/arr/parser/index.ts'; -import type { ParseResult, MediaType } from '$lib/server/utils/arr/parser/types.ts'; +import { parseWithCacheBatch, isParserHealthy, matchPatternsBatch } from '$lib/server/utils/arr/parser/index.ts'; import { getAllConditionsForEvaluation } from '$pcd/queries/customFormats/allConditions.ts'; -import { evaluateCustomFormat, getParsedInfo, type ParsedInfo } from '$pcd/queries/customFormats/evaluator.ts'; +import { evaluateCustomFormat, getParsedInfo, extractAllPatterns } from '$pcd/queries/customFormats/evaluator.ts'; +import type { components } from '$api/v1.d.ts'; -export interface ReleaseEvaluation { - releaseId: number; - title: string; - parsed: ParsedInfo | null; - /** Map of custom format ID to whether it matches */ - cfMatches: Record; -} - -export interface EvaluateResponse { - parserAvailable: boolean; - evaluations: ReleaseEvaluation[]; -} +type EvaluateRequest = components['schemas']['EvaluateRequest']; +type EvaluateResponse = components['schemas']['EvaluateResponse']; +type ReleaseEvaluation = components['schemas']['ReleaseEvaluation']; +type MediaType = components['schemas']['MediaType']; export const POST: RequestHandler = async ({ request }) => { - const body = await request.json(); - const { databaseId, releases } = body as { - databaseId: number; - releases: Array<{ id: number; title: string; type: MediaType }>; - }; + const body: EvaluateRequest = await request.json(); + const { databaseId, releases } = body; if (!databaseId) { throw error(400, 'Missing databaseId'); @@ -47,7 +36,6 @@ export const POST: RequestHandler = async ({ request }) => { evaluations: releases.map((r) => ({ releaseId: r.id, title: r.title, - parsed: null, cfMatches: {} })) } satisfies EvaluateResponse); @@ -66,6 +54,11 @@ export const POST: RequestHandler = async ({ request }) => { // Get all custom formats with conditions const customFormats = await getAllConditionsForEvaluation(cache); + // Extract all unique patterns and match them against all release titles (with caching) + const allPatterns = extractAllPatterns(customFormats); + const releaseTitles = releases.map((r) => r.title); + const patternMatchResults = await matchPatternsBatch(releaseTitles, allPatterns); + // Evaluate each release against all custom formats const evaluations: ReleaseEvaluation[] = releases.map((release) => { const cacheKey = `${release.title}:${release.type}`; @@ -75,11 +68,13 @@ export const POST: RequestHandler = async ({ request }) => { return { releaseId: release.id, title: release.title, - parsed: null, cfMatches: {} }; } + // Get pattern matches for this release title + const patternMatches = patternMatchResults?.get(release.title); + // Evaluate against all custom formats const cfMatches: Record = {}; for (const cf of customFormats) { @@ -89,7 +84,7 @@ export const POST: RequestHandler = async ({ request }) => { continue; } - const result = evaluateCustomFormat(cf.conditions, parsed, release.title); + const result = evaluateCustomFormat(cf.conditions, parsed, release.title, patternMatches); cfMatches[cf.id] = result.matches; } diff --git a/src/routes/quality-profiles/entity-testing/[databaseId]/+page.server.ts b/src/routes/quality-profiles/entity-testing/[databaseId]/+page.server.ts index 5602667..1d17464 100644 --- a/src/routes/quality-profiles/entity-testing/[databaseId]/+page.server.ts +++ b/src/routes/quality-profiles/entity-testing/[databaseId]/+page.server.ts @@ -3,14 +3,14 @@ import type { ServerLoad, Actions } from '@sveltejs/kit'; import { pcdManager } from '$pcd/pcd.ts'; import { canWriteToBase } from '$pcd/writer.ts'; import { tmdbSettingsQueries } from '$db/queries/tmdbSettings.ts'; +import { arrInstancesQueries } from '$db/queries/arrInstances.ts'; import * as entityTestQueries from '$pcd/queries/entityTests/index.ts'; import * as qualityProfileQueries from '$pcd/queries/qualityProfiles/index.ts'; -import { isParserHealthy, parseWithCacheBatch, matchPatterns } from '$lib/server/utils/arr/parser/index.ts'; -import { getAllConditionsForEvaluation } from '$pcd/queries/customFormats/allConditions.ts'; -import { evaluateCustomFormat, getParsedInfo, extractAllPatterns } from '$pcd/queries/customFormats/evaluator.ts'; -import type { MediaType } from '$lib/server/utils/arr/parser/types.ts'; +import { isParserHealthy } from '$lib/server/utils/arr/parser/index.ts'; +import { logger } from '$logger/logger.ts'; export const load: ServerLoad = async ({ params }) => { + const loadStart = performance.now(); const { databaseId } = params; // Validate params exist @@ -40,97 +40,35 @@ export const load: ServerLoad = async ({ params }) => { throw error(500, 'Database cache not available'); } + let t = performance.now(); const testEntities = await entityTestQueries.list(cache); + await logger.debug(`entityTestQueries.list: ${(performance.now() - t).toFixed(0)}ms`, { source: 'EntityTesting' }); + + t = performance.now(); const qualityProfiles = await qualityProfileQueries.select(cache); + await logger.debug(`qualityProfileQueries.select: ${(performance.now() - t).toFixed(0)}ms`, { source: 'EntityTesting' }); + + t = performance.now(); const cfScoresData = await qualityProfileQueries.allCfScores(cache); + await logger.debug(`qualityProfileQueries.allCfScores: ${(performance.now() - t).toFixed(0)}ms`, { source: 'EntityTesting' }); // Check if TMDB API key is configured const tmdbSettings = tmdbSettingsQueries.get(); const tmdbConfigured = !!tmdbSettings?.api_key; // Check parser availability + t = performance.now(); const parserAvailable = await isParserHealthy(); + await logger.debug(`isParserHealthy: ${(performance.now() - t).toFixed(0)}ms`, { source: 'EntityTesting' }); - // Evaluate all releases against all custom formats - type ReleaseEvaluation = { - releaseId: number; - parsed: ReturnType | null; - cfMatches: Record; - }; - const evaluations: Record = {}; + // Get enabled Arr instances for release import + const arrInstances = arrInstancesQueries.getEnabled().map((instance) => ({ + id: instance.id, + name: instance.name, + type: instance.type as 'radarr' | 'sonarr' + })); - if (parserAvailable && testEntities.length > 0) { - // Collect all releases with their entity type - const allReleases: Array<{ id: number; title: string; type: MediaType }> = []; - for (const entity of testEntities) { - for (const release of entity.releases) { - allReleases.push({ - id: release.id, - title: release.title, - type: entity.type - }); - } - } - - if (allReleases.length > 0) { - // Parse all releases (uses cache) - const parseResults = await parseWithCacheBatch( - allReleases.map((r) => ({ title: r.title, type: r.type })) - ); - - // Get all custom formats with conditions - const customFormats = await getAllConditionsForEvaluation(cache); - - // Extract all unique patterns for batch matching - const allPatterns = extractAllPatterns(customFormats); - - // Pre-compute pattern matches for each release title using .NET regex - const patternMatchesByRelease = new Map>(); - if (allPatterns.length > 0) { - for (const release of allReleases) { - const matches = await matchPatterns(release.title, allPatterns); - if (matches) { - patternMatchesByRelease.set(release.id, matches); - } - } - } - - // Evaluate each release - for (const release of allReleases) { - const cacheKey = `${release.title}:${release.type}`; - const parsed = parseResults.get(cacheKey); - - if (!parsed) { - evaluations[release.id] = { - releaseId: release.id, - parsed: null, - cfMatches: {} - }; - continue; - } - - // Get pre-computed pattern matches for this release - const patternMatches = patternMatchesByRelease.get(release.id); - - // Evaluate against all custom formats - const cfMatches: Record = {}; - for (const cf of customFormats) { - if (cf.conditions.length === 0) { - cfMatches[cf.id] = false; - continue; - } - const result = evaluateCustomFormat(cf.conditions, parsed, release.title, patternMatches); - cfMatches[cf.id] = result.matches; - } - - evaluations[release.id] = { - releaseId: release.id, - parsed: getParsedInfo(parsed), - cfMatches - }; - } - } - } + await logger.debug(`Total load time: ${(performance.now() - loadStart).toFixed(0)}ms`, { source: 'EntityTesting' }); return { databases, @@ -140,7 +78,7 @@ export const load: ServerLoad = async ({ params }) => { testEntities, qualityProfiles, cfScoresData, - evaluations, + arrInstances, canWriteToBase: canWriteToBase(currentDatabaseId) }; }; @@ -411,5 +349,67 @@ export const actions: Actions = { } return { success: true }; + }, + + importReleases: async ({ request, params }) => { + const { databaseId } = params; + + if (!databaseId) { + return fail(400, { error: 'Missing database ID' }); + } + + const currentDatabaseId = parseInt(databaseId, 10); + if (isNaN(currentDatabaseId)) { + return fail(400, { error: 'Invalid database ID' }); + } + + const cache = pcdManager.getCache(currentDatabaseId); + if (!cache) { + return fail(500, { error: 'Database cache not available' }); + } + + const formData = await request.formData(); + const releasesJson = formData.get('releases') as string; + const layer = (formData.get('layer') as 'user' | 'base') || 'user'; + + if (layer === 'base' && !canWriteToBase(currentDatabaseId)) { + return fail(403, { error: 'Cannot write to base layer for this database' }); + } + + let releases: Array<{ + entityId: number; + title: string; + size_bytes: number | null; + languages: string[]; + indexers: string[]; + flags: string[]; + }>; + + try { + releases = JSON.parse(releasesJson || '[]'); + } catch { + return fail(400, { error: 'Invalid releases format' }); + } + + if (releases.length === 0) { + return fail(400, { error: 'No releases to import' }); + } + + const result = await entityTestQueries.createReleases({ + databaseId: currentDatabaseId, + cache, + layer, + inputs: releases + }); + + if (!result.success) { + return fail(500, { error: result.error || 'Failed to import releases' }); + } + + return { + success: true, + added: result.added, + skipped: result.skipped + }; } }; diff --git a/src/routes/quality-profiles/entity-testing/[databaseId]/+page.svelte b/src/routes/quality-profiles/entity-testing/[databaseId]/+page.svelte index c9345c1..666b7cd 100644 --- a/src/routes/quality-profiles/entity-testing/[databaseId]/+page.svelte +++ b/src/routes/quality-profiles/entity-testing/[databaseId]/+page.svelte @@ -12,14 +12,35 @@ import DropdownItem from '$ui/dropdown/DropdownItem.svelte'; import AddEntityModal from './components/AddEntityModal.svelte'; import ReleaseModal from './components/ReleaseModal.svelte'; + import ImportReleasesModal from './components/ImportReleasesModal.svelte'; import EntityTable from './components/EntityTable.svelte'; import { createDataPageStore } from '$lib/client/stores/dataPage'; import { alertStore } from '$lib/client/alerts/store'; import type { PageData } from './$types'; import type { TestEntity, TestRelease } from './components/types'; + import type { components } from '$api/v1.d.ts'; + + type EvaluateResponse = components['schemas']['EvaluateResponse']; + type ReleaseEvaluation = components['schemas']['ReleaseEvaluation']; + type MediaType = components['schemas']['MediaType']; export let data: PageData; + // Local state for evaluations (fetched lazily on expand) + // Keyed by releaseId for quick lookup + let evaluations: Record = {}; + let loadingEntityIds = new Set(); + let fetchedEntityIds = new Set(); + let expandedRows = new Set(); + + // Reset state when database changes + $: if (data.currentDatabase) { + evaluations = {}; + loadingEntityIds = new Set(); + fetchedEntityIds = new Set(); + expandedRows = new Set(); + } + // Show warning if parser is unavailable onMount(() => { if (!data.parserAvailable) { @@ -50,12 +71,69 @@ // Quality profile selection let selectedProfileId: number | null = null; + // Fetch evaluations for an entity's releases + async function fetchEvaluations(entity: TestEntity) { + if (fetchedEntityIds.has(entity.id) || loadingEntityIds.has(entity.id)) { + return; // Already fetched or in progress + } + + if (entity.releases.length === 0) { + fetchedEntityIds.add(entity.id); + fetchedEntityIds = fetchedEntityIds; + return; + } + + loadingEntityIds.add(entity.id); + loadingEntityIds = loadingEntityIds; + + try { + const response = await fetch('/api/v1/entity-testing/evaluate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + databaseId: data.currentDatabase.id, + releases: entity.releases.map(r => ({ + id: r.id, + title: r.title, + type: entity.type + })) + }) + }); + + if (!response.ok) { + throw new Error('Failed to fetch evaluations'); + } + + const result: EvaluateResponse = await response.json(); + + // Merge evaluations into local state + for (const evaluation of result.evaluations) { + evaluations[evaluation.releaseId] = evaluation; + } + evaluations = evaluations; // Trigger reactivity + + fetchedEntityIds.add(entity.id); + fetchedEntityIds = fetchedEntityIds; + } catch (err) { + console.error('Failed to fetch evaluations:', err); + alertStore.add('error', 'Failed to evaluate releases'); + } finally { + loadingEntityIds.delete(entity.id); + loadingEntityIds = loadingEntityIds; + } + } + + // Handle entity expansion + function handleExpand(e: CustomEvent<{ entity: TestEntity }>) { + fetchEvaluations(e.detail.entity); + } + // Calculate score for a release based on selected profile // Reactive so it updates when selectedProfileId changes $: calculateScore = (releaseId: number, entityType: 'movie' | 'series'): number | null => { if (!selectedProfileId) return null; - const evaluation = data.evaluations[releaseId]; + const evaluation = evaluations[releaseId]; if (!evaluation || !evaluation.cfMatches) return null; const profileScores = data.cfScoresData.profiles.find((p) => p.profileId === selectedProfileId); @@ -103,6 +181,10 @@ let releaseToDelete: TestRelease | null = null; let deleteReleaseFormRef: HTMLFormElement | null = null; + // Import releases modal state + let showImportModal = false; + let importEntity: TestEntity | null = null; + // Layer selection for delete operations let deleteLayer: 'user' | 'base' = 'user'; let deleteReleaseLayer: 'user' | 'base' = 'user'; @@ -260,6 +342,12 @@ releaseToDelete = null; deleteReleaseFormRef = null; } + + // Import releases handler + function handleImportReleases(e: CustomEvent<{ entity: TestEntity }>) { + importEntity = e.detail.entity; + showImportModal = true; + } @@ -338,14 +426,18 @@ {:else} @@ -382,7 +474,7 @@ - + {/if} + + diff --git a/src/routes/quality-profiles/entity-testing/[databaseId]/components/AddEntityModal.svelte b/src/routes/quality-profiles/entity-testing/[databaseId]/components/AddEntityModal.svelte index de575e7..55ad1f9 100644 --- a/src/routes/quality-profiles/entity-testing/[databaseId]/components/AddEntityModal.svelte +++ b/src/routes/quality-profiles/entity-testing/[databaseId]/components/AddEntityModal.svelte @@ -1,7 +1,7 @@ + + +
+ + {#if entity} +
+ {#if entity.poster_path} + {entity.title} + {:else} +
+ {#if entity.type === 'movie'} + + {:else} + + {/if} +
+ {/if} +
+

{entity.title}

+

+ {entity.type === 'movie' ? 'Movie' : 'TV Series'} + {#if entity.year} + • {entity.year} + {/if} +

+
+
+ {/if} + + {#if step === 1} + + {#if filteredInstances.length === 0} +
+

+ No {entity?.type === 'movie' ? 'Radarr' : 'Sonarr'} instances configured. + + Configure in Settings + +

+
+ {:else} + + + + {#if selectedInstance} + {selectedInstance.name} + {/if} + + + {#each filteredInstances as instance} + selectInstance(instance.id)} + /> + {/each} + + + + + + + {#if selectedInstanceId} + {#if loadingLibrary} +
+ +
+ {:else if libraryItems.length === 0} +
+

No items found in library.

+
+ {:else} + + {#if potentialMatches.length > 0} +
+

Suggested Match

+
+
+ {#each potentialMatches as item} + + {/each} +
+
+
+ {/if} + + +
+ {#if potentialMatches.length > 0} +

All Items

+ {/if} +
+
+ {#each filteredLibrary as item} + + {/each} +
+
+
+ {/if} + {:else} +
+

Select an instance to load library.

+
+ {/if} + {/if} + {:else} + + {#if entity?.type === 'series' && selectedSeason === null} + +
+

Select a season to search for releases:

+
+ {#each selectedItem?.seasons || [] as season} + + {/each} +
+
+ {:else if loadingReleases} +
+ +

Searching indexers{entity?.type === 'series' ? ` for season ${selectedSeason}` : ''}...

+
+ {:else} + + {#if entity?.type === 'series' && selectedItem?.seasons} +
+ {#each selectedItem.seasons as season} + + {/each} +
+ {/if} + + + + + + + setSortField('title')} + /> + setSortField('size')} + /> + setSortField('indexers')} + /> + + + + + + + + + + + + + {#if releases.length === 0} +
+

No releases found{entity?.type === 'series' ? ` for season ${selectedSeason}` : ''}.

+
+ {:else} +
+
+ {#each filteredReleases as release} + + {/each} +
+
+ {/if} + {/if} + {/if} + + + +
+
+ +{#if canWriteToBase} + (showSaveTargetModal = false)} + /> +{/if} diff --git a/src/routes/quality-profiles/entity-testing/[databaseId]/components/ReleaseTable.svelte b/src/routes/quality-profiles/entity-testing/[databaseId]/components/ReleaseTable.svelte index 183d254..8eccc35 100644 --- a/src/routes/quality-profiles/entity-testing/[databaseId]/components/ReleaseTable.svelte +++ b/src/routes/quality-profiles/entity-testing/[databaseId]/components/ReleaseTable.svelte @@ -3,10 +3,14 @@ import { Plus, Trash2, Pencil, HardDrive, Tag, Users, Bookmark, Earth, Layers } from 'lucide-svelte'; import { createEventDispatcher } from 'svelte'; import ExpandableTable from '$ui/table/ExpandableTable.svelte'; + import TableActionButton from '$ui/table/TableActionButton.svelte'; import Badge from '$ui/badge/Badge.svelte'; import { alertStore } from '$lib/client/alerts/store'; import type { Column } from '$ui/table/types'; - import type { TestRelease, ReleaseEvaluation, ProfileCfScores, CustomFormatInfo } from './types'; + import type { TestRelease, ProfileCfScores, CustomFormatInfo } from './types'; + import type { components } from '$api/v1.d.ts'; + + type ReleaseEvaluation = components['schemas']['ReleaseEvaluation']; export let entityId: number; export let entityType: 'movie' | 'series'; @@ -161,14 +165,13 @@ {@const releaseFormId = `delete-release-form-${release.id}`}
- + variant="accent" + size="sm" + on:click={() => dispatch('edit', { entityId, release })} + />
- + />
diff --git a/src/routes/quality-profiles/entity-testing/[databaseId]/components/types.ts b/src/routes/quality-profiles/entity-testing/[databaseId]/components/types.ts index 3377b25..9517122 100644 --- a/src/routes/quality-profiles/entity-testing/[databaseId]/components/types.ts +++ b/src/routes/quality-profiles/entity-testing/[databaseId]/components/types.ts @@ -17,26 +17,8 @@ export interface TestEntity { releases: TestRelease[]; } -/** Parsed info from parser service */ -export interface ParsedInfo { - source: string; - resolution: string; - modifier: string; - languages: string[]; - releaseGroup: string | null; - year: number; - edition: string | null; - releaseType: string | null; -} - -/** Evaluation result for a single release */ -export interface ReleaseEvaluation { - releaseId: number; - title: string; - parsed: ParsedInfo | null; - /** Map of custom format ID to whether it matches */ - cfMatches: Record; -} +// Note: ParsedInfo, ReleaseEvaluation, and EvaluateResponse types are now +// generated from OpenAPI spec - see $api/v1.d.ts /** CF score for a specific arr type */ export interface CfScore { diff --git a/src/routes/settings/logs/+page.svelte b/src/routes/settings/logs/+page.svelte index a6cca5f..765c0de 100644 --- a/src/routes/settings/logs/+page.svelte +++ b/src/routes/settings/logs/+page.svelte @@ -4,6 +4,7 @@ import Modal from '$ui/modal/Modal.svelte'; import JsonView from '$ui/meta/JsonView.svelte'; import Table from '$ui/table/Table.svelte'; + import TableActionButton from '$ui/table/TableActionButton.svelte'; import type { Column } from '$ui/table/types'; import LogsActionsBar from './components/LogsActionsBar.svelte'; import { createSearchStore } from '$lib/client/stores/search'; @@ -194,23 +195,17 @@
- + on:click={() => copyLog(row)} + /> {#if row.meta} - + on:click={() => viewMeta(row.meta)} + /> {/if}
diff --git a/src/services/parser/Program.cs b/src/services/parser/Program.cs index bb37a51..3da4842 100644 --- a/src/services/parser/Program.cs +++ b/src/services/parser/Program.cs @@ -139,6 +139,65 @@ app.MapPost("/match", (MatchRequest request) => return Results.Ok(new MatchResponse { Results = results }); }); +app.MapPost("/match/batch", (BatchMatchRequest request) => +{ + if (request.Texts == null || request.Texts.Count == 0) + { + return Results.BadRequest(new { error = "At least one text is required" }); + } + + if (request.Patterns == null || request.Patterns.Count == 0) + { + return Results.BadRequest(new { error = "At least one pattern is required" }); + } + + // Pre-compile all regexes once + var compiledPatterns = new Dictionary(); + foreach (var pattern in request.Patterns) + { + try + { + compiledPatterns[pattern] = new System.Text.RegularExpressions.Regex( + pattern, + System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled, + TimeSpan.FromMilliseconds(100) + ); + } + catch (System.ArgumentException) + { + compiledPatterns[pattern] = null; // Invalid pattern + } + } + + // Process texts in parallel for better performance + var results = new System.Collections.Concurrent.ConcurrentDictionary>(); + + System.Threading.Tasks.Parallel.ForEach(request.Texts, text => + { + var textResults = new Dictionary(); + foreach (var (pattern, regex) in compiledPatterns) + { + if (regex == null) + { + textResults[pattern] = false; + continue; + } + + try + { + textResults[pattern] = regex.IsMatch(text); + } + catch (System.Text.RegularExpressions.RegexMatchTimeoutException) + { + textResults[pattern] = false; + } + } + results[text] = textResults; + }); + + return Results.Ok(new BatchMatchResponse { Results = results.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) }); +}); + app.Run(); public record ParseRequest(string Title, string? Type); @@ -191,3 +250,10 @@ public record MatchResponse { public Dictionary Results { get; init; } = new(); } + +public record BatchMatchRequest(List Texts, List Patterns); + +public record BatchMatchResponse +{ + public Dictionary> Results { get; init; } = new(); +}