13 KiB
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/libraryand/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
- User clicks "add test release" on an entity
- Choose between manual entry OR import from Arr
- For import: select an Arr instance (Radarr for movies, Sonarr for TV)
- Search/select the matching title in that Arr's library
- Profilarr triggers interactive search and fetches results
- Results are deduplicated/grouped by similar releases
- User reviews and can edit before confirming
- 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:
{
"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:
{
"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):
const finishedSeasons = series.seasons.filter(
(s) => s.statistics.episodeCount === s.statistics.totalEpisodeCount
);
Example Response:
{
"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:
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
// 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:
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:
- Title similarity > 90% - Handle minor formatting differences (dots vs spaces)
- Size within ±5% - Same release should have nearly identical size
Grouping algorithm:
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
- Add Radarr client method:
getReleases(movieId: number) - Add Sonarr client method:
getSeries(seriesId: number)(for season info) - Add Sonarr client method:
getReleases(seriesId: number, seasonNumber: number) - Add helper to get finished seasons from series data
- Add flag normalization utilities (Radarr string array, Sonarr bitmask)
- Create release transformation utilities (sanitize indexer names, group, dedupe)
- Add API endpoint for fetching/transforming releases
- Update ReleaseModal with import option
- Create import UI flow (Arr selection, title search, results review)
- Add bulk release creation to PCD writer
- Testing with real data
Open Questions
Should we show both raw and grouped results in the review UI?Resolved: Show only grouped results. Theoccurrencescount indicates how many were merged.How to handle releases that don't group well (unique from single indexer)?Resolved: They appear as-is withoccurrences: 1.Should we store which indexers a release came from for reference?Resolved: Yes, stored inindexers[]array.Sonarr: do we search by series or by specific episode/season?Resolved: Search by series + season number. Filter byfullSeason: truefor season packs.
Future Considerations
- Refactor modal:
ImportReleasesModal.svelteis large (~640 lines). Could split into separate components (e.g.,LibraryStep.svelte,ReleasesStep.svelte,SeasonSelector.svelte).