Skip to content

Commit ae44ca8

Browse files
dannyvfilmsclaude
andcommitted
Support multiple Radarr/Sonarr instances (fixes #954)
- Rename RadarrAccount/SonarrAccount to RadarrInstance/SonarrInstance, changing the OneToOne singleton to a ForeignKey so a user can connect more than one server per service (e.g. a 4K instance and an anime one). - Scope CollectionSourceState, Celery Beat scheduling, and the Radarr/Sonarr importers to a specific instance instead of the user, so two instances no longer clobber each other's sync schedule or quality/ownership metadata. - Redo the Import Sources modal to list connected instances as compact rows with sync/disconnect icon buttons, plus an always-visible "connect another instance" form. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 8f330a0 commit ae44ca8

12 files changed

Lines changed: 859 additions & 255 deletions

src/integrations/imports/radarr.py

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
decrypt_or_raise,
1717
find_item_across_buckets,
1818
)
19-
from integrations.models import RadarrAccount
19+
from integrations.models import RadarrInstance
2020
from integrations.source_sync import upsert_collection_source_state
2121

2222
logger = logging.getLogger(__name__)
@@ -57,34 +57,39 @@ def movies(self):
5757
return self._request("/api/v3/movie")
5858

5959

60-
def importer(identifier, user, mode):
60+
def importer(identifier, user, mode, instance_id=None):
6161
"""Import Radarr collection ownership."""
62-
return RadarrImporter(user).import_data()
62+
instance = (
63+
RadarrInstance.objects.get(pk=instance_id, user=user) if instance_id else None
64+
)
65+
return RadarrImporter(user, instance=instance).import_data()
6366

6467

6568
class RadarrImporter:
6669
"""Import collection data from Radarr."""
6770

68-
def __init__(self, user):
69-
"""Store the extra keyword arguments this form needs."""
71+
def __init__(self, user, instance=None):
72+
"""Bind the importer to a user with a connected Radarr instance."""
7073
self.user = user
71-
try:
72-
self.account = user.radarr_account
73-
except RadarrAccount.DoesNotExist as error:
74+
if instance is not None:
75+
self.instance = instance
76+
else:
77+
self.instance = user.radarr_instances.first()
78+
if self.instance is None:
7479
msg = "Connect Radarr before importing"
75-
raise MediaImportError(msg) from error
80+
raise MediaImportError(msg)
7681

7782
try:
78-
api_key = decrypt_or_raise(self.account.api_key)
83+
api_key = decrypt_or_raise(self.instance.api_key)
7984
except MediaImportError as error:
80-
self.account.connection_broken = True
81-
self.account.last_error_message = str(error)
82-
self.account.save(
85+
self.instance.connection_broken = True
86+
self.instance.last_error_message = str(error)
87+
self.instance.save(
8388
update_fields=["connection_broken", "last_error_message", "updated_at"],
8489
)
8590
raise
8691

87-
self.client = RadarrClient(self.account.base_url, api_key)
92+
self.client = RadarrClient(self.instance.base_url, api_key)
8893
self.warnings = []
8994

9095
def import_data(self):
@@ -94,9 +99,9 @@ def import_data(self):
9499
try:
95100
movies = self.client.movies()
96101
except MediaImportError as error:
97-
self.account.connection_broken = True
98-
self.account.last_error_message = str(error)
99-
self.account.save(
102+
self.instance.connection_broken = True
103+
self.instance.last_error_message = str(error)
104+
self.instance.save(
100105
update_fields=["connection_broken", "last_error_message", "updated_at"]
101106
)
102107
raise
@@ -120,16 +125,17 @@ def import_data(self):
120125
user=self.user,
121126
item=item,
122127
source="radarr",
128+
source_instance_id=self.instance.pk,
123129
quality_label=quality_label,
124130
source_updated_at=updated_at,
125131
)
126132
imported_counts[item.media_type] += 1
127133
imported_counts["updated"] += 1
128134

129-
self.account.last_sync_at = timezone.now()
130-
self.account.connection_broken = False
131-
self.account.last_error_message = ""
132-
self.account.save(
135+
self.instance.last_sync_at = timezone.now()
136+
self.instance.connection_broken = False
137+
self.instance.last_error_message = ""
138+
self.instance.save(
133139
update_fields=[
134140
"last_sync_at",
135141
"connection_broken",

src/integrations/imports/sonarr.py

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
find_item_across_buckets,
1717
retry_on_lock,
1818
)
19-
from integrations.models import SonarrAccount
19+
from integrations.models import SonarrInstance
2020
from integrations.source_sync import (
2121
remove_collection_source_state,
2222
upsert_collection_source_state,
@@ -67,34 +67,39 @@ def episodes(self, series_id):
6767
return self._request("/api/v3/episode", params={"seriesId": series_id})
6868

6969

70-
def importer(identifier, user, mode):
70+
def importer(identifier, user, mode, instance_id=None):
7171
"""Import Sonarr collection ownership."""
72-
return SonarrImporter(user).import_data()
72+
instance = (
73+
SonarrInstance.objects.get(pk=instance_id, user=user) if instance_id else None
74+
)
75+
return SonarrImporter(user, instance=instance).import_data()
7376

7477

7578
class SonarrImporter:
7679
"""Import collection data from Sonarr."""
7780

78-
def __init__(self, user):
79-
"""Bind the importer to a user with a connected Sonarr account."""
81+
def __init__(self, user, instance=None):
82+
"""Bind the importer to a user with a connected Sonarr instance."""
8083
self.user = user
81-
try:
82-
self.account = user.sonarr_account
83-
except SonarrAccount.DoesNotExist as error:
84+
if instance is not None:
85+
self.instance = instance
86+
else:
87+
self.instance = user.sonarr_instances.first()
88+
if self.instance is None:
8489
msg = "Connect Sonarr before importing"
85-
raise MediaImportError(msg) from error
90+
raise MediaImportError(msg)
8691

8792
try:
88-
api_key = decrypt_or_raise(self.account.api_key)
93+
api_key = decrypt_or_raise(self.instance.api_key)
8994
except MediaImportError as error:
90-
self.account.connection_broken = True
91-
self.account.last_error_message = str(error)
92-
self.account.save(
95+
self.instance.connection_broken = True
96+
self.instance.last_error_message = str(error)
97+
self.instance.save(
9398
update_fields=["connection_broken", "last_error_message", "updated_at"],
9499
)
95100
raise
96101

97-
self.client = SonarrClient(self.account.base_url, api_key)
102+
self.client = SonarrClient(self.instance.base_url, api_key)
98103
self.warnings = []
99104

100105
def import_data(self):
@@ -104,9 +109,9 @@ def import_data(self):
104109
try:
105110
series_rows = self.client.series()
106111
except MediaImportError as error:
107-
self.account.connection_broken = True
108-
self.account.last_error_message = str(error)
109-
self.account.save(
112+
self.instance.connection_broken = True
113+
self.instance.last_error_message = str(error)
114+
self.instance.save(
110115
update_fields=["connection_broken", "last_error_message", "updated_at"],
111116
)
112117
raise
@@ -132,10 +137,10 @@ def import_data(self):
132137
imported_counts[item.media_type] += 1
133138
imported_counts["updated"] += 1
134139

135-
self.account.last_sync_at = timezone.now()
136-
self.account.connection_broken = False
137-
self.account.last_error_message = ""
138-
self.account.save(
140+
self.instance.last_sync_at = timezone.now()
141+
self.instance.connection_broken = False
142+
self.instance.last_error_message = ""
143+
self.instance.save(
139144
update_fields=[
140145
"last_sync_at",
141146
"connection_broken",
@@ -233,6 +238,7 @@ def _sync_series_episode_collection(self, show_item, row):
233238
user=self.user,
234239
item=show_item,
235240
source="sonarr",
241+
source_instance_id=self.instance.pk,
236242
quality_label="",
237243
source_updated_at=self._parse_source_timestamp(row),
238244
)
@@ -260,6 +266,7 @@ def _sync_series_episode_collection(self, show_item, row):
260266
user=self.user,
261267
item=episode_item,
262268
source="sonarr",
269+
source_instance_id=self.instance.pk,
263270
quality_label=self._extract_episode_quality_label(episode_row),
264271
source_updated_at=self._parse_source_timestamp(episode_row),
265272
)
@@ -456,7 +463,11 @@ def _prune_series_collection_state(self, show_item, *, keep_episode_item_ids=Non
456463
media_type=MediaTypes.EPISODE.value,
457464
)
458465
.exclude(id__in=keep_episode_item_ids)
459-
.filter(source_states__user=self.user, source_states__source="sonarr")
466+
.filter(
467+
source_states__user=self.user,
468+
source_states__source="sonarr",
469+
source_states__source_instance_id=self.instance.pk,
470+
)
460471
.values_list("id", flat=True)
461472
.distinct()
462473
)
@@ -466,6 +477,7 @@ def _prune_series_collection_state(self, show_item, *, keep_episode_item_ids=Non
466477
user=self.user,
467478
item=episode_item,
468479
source="sonarr",
480+
source_instance_id=self.instance.pk,
469481
)
470482

471483
if keep_episode_item_ids:
@@ -475,13 +487,15 @@ def _prune_series_collection_state(self, show_item, *, keep_episode_item_ids=Non
475487
user=self.user,
476488
item=show_item,
477489
source="sonarr",
490+
source_instance_id=self.instance.pk,
478491
)
479492

480493
def _retire_legacy_series_collection_entry(self, show_item):
481494
"""Delete the legacy show-level Sonarr row once episode rows exist."""
482495
if not show_item.source_states.filter(
483496
user=self.user,
484497
source="sonarr",
498+
source_instance_id=self.instance.pk,
485499
).exists():
486500
return
487501

0 commit comments

Comments
 (0)