# 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`).