mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-26 12:52:00 +01:00
455 lines
13 KiB
Markdown
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`).
|