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

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/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:

{
  "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:

  1. Title similarity > 90% - Handle minor formatting differences (dots vs spaces)
  2. 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

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