Files
profilarr/docs/todo/1.automatic-entity-release-add.md

455 lines
13 KiB
Markdown

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