diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index dee7961..aa79055 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -562,7 +562,6 @@ async def _resolve_request_destination( runtime: Any, seerr: JellyseerrClient, media_type: str, - requested_profile_id: Optional[int] = None, ) -> Dict[str, Any]: if media_type == "tv": collector_name = "Sonarr" @@ -601,16 +600,17 @@ async def _resolve_request_destination( raise HTTPException(status_code=409, detail=f"{collector_name} has no quality profiles available.") profile_ids = {int(item["id"]) for item in profiles} - default_profile_id = _quality_profile_id(server.get("activeProfileId")) + # Magent's administrator default is authoritative for every new request. + # An unset default inherits Seerr's profile; a stale default must be repaired. + default_profile_id = configured_profile_id + if default_profile_id is None: + default_profile_id = _quality_profile_id(server.get("activeProfileId")) if default_profile_id not in profile_ids: - default_profile_id = configured_profile_id if configured_profile_id in profile_ids else profiles[0]["id"] - - selected_profile_id = requested_profile_id if requested_profile_id is not None else default_profile_id - if selected_profile_id not in profile_ids: raise HTTPException( - status_code=400, - detail=f"The selected quality profile is not available in {collector_name}.", + status_code=409, + detail=f"The default quality profile is not available in {collector_name}. Ask an administrator to select a valid default in Admin settings.", ) + selected_profile_id = default_profile_id roots = _normalize_request_roots(root_payload) root_folder = str(server.get("activeDirectory") or "").strip() @@ -3164,10 +3164,6 @@ async def create_request( raise HTTPException(status_code=400, detail="tmdbId must be a positive integer") seasons = _normalize_seasons(payload.get("seasons")) if media_type == "tv" else [] - raw_profile_id = payload.get("profileId") - profile_id = _quality_profile_id(raw_profile_id) if raw_profile_id is not None else None - if raw_profile_id is not None and (profile_id is None or profile_id <= 0): - raise HTTPException(status_code=400, detail="profileId must be a positive integer") raw_is_4k = payload.get("is4k") if raw_is_4k is not None and not isinstance(raw_is_4k, bool): raise HTTPException(status_code=400, detail="is4k must be true or false") @@ -3230,7 +3226,7 @@ async def create_request( detail=f"Season selection is not available for this series: {invalid_seasons}", ) - destination = await _resolve_request_destination(runtime, client, media_type, profile_id) + destination = await _resolve_request_destination(runtime, client, media_type) try: created = await client.create_request( diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index 80f17e8..a64c19e 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -803,7 +803,7 @@ class RequestCreationFlowTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(result, {"ok": True}) self.assertEqual(captured["payload"], {"mediaType": "movie", "mediaId": 209112}) - async def test_request_destination_only_offers_live_sonarr_profiles(self) -> None: + async def test_request_destination_uses_admin_default_before_seerr(self) -> None: runtime = SimpleNamespace( sonarr_base_url="http://sonarr.test", sonarr_api_key="key", @@ -818,7 +818,7 @@ class RequestCreationFlowTests(unittest.IsolatedAsyncioTestCase): "name": "Main Sonarr", "isDefault": True, "is4k": False, - "activeProfileId": 7, + "activeProfileId": 10, "activeDirectory": "/tv", } ] @@ -834,10 +834,10 @@ class RequestCreationFlowTests(unittest.IsolatedAsyncioTestCase): with patch.object(requests_router, "SonarrClient", return_value=sonarr): destination = await requests_router._resolve_request_destination( - runtime, seerr, "tv", requested_profile_id=10 + runtime, seerr, "tv" ) - self.assertEqual(destination["profile_id"], 10) + self.assertEqual(destination["profile_id"], 7) self.assertEqual(destination["default_profile_id"], 7) self.assertEqual(destination["root_folder"], "/tv") self.assertEqual(destination["profiles"], [ @@ -845,11 +845,43 @@ class RequestCreationFlowTests(unittest.IsolatedAsyncioTestCase): {"id": 10, "name": "Optimal"}, ]) + async def test_request_defaults_inherit_seerr_only_when_unset(self) -> None: + for media_type, service in [('movie', 'radarr'), ('tv', 'sonarr')]: + runtime = SimpleNamespace(**{ + service + '_base_url': 'http://collector.test', service + '_api_key': 'key', + service + '_quality_profile_id': None, service + '_root_folder': '/media', + }) + seerr = SimpleNamespace(get_service_settings=AsyncMock(return_value=[{ + 'id': 1, 'isDefault': True, 'activeProfileId': 7, 'activeDirectory': '/media', + }])) + collector = SimpleNamespace(configured=lambda: True, + get_quality_profiles=AsyncMock(return_value=[{'id': 7, 'name': 'HD'}]), + get_root_folders=AsyncMock(return_value=[{'path': '/media'}])) + with patch.object(requests_router, 'RadarrClient' if service == 'radarr' else 'SonarrClient', return_value=collector): + result = await requests_router._resolve_request_destination(runtime, seerr, media_type) + self.assertEqual(result['profile_id'], 7) + seerr.get_service_settings.return_value[0]['activeProfileId'] = 999 + with self.assertRaises(HTTPException): + await requests_router._resolve_request_destination(runtime, seerr, media_type) + + async def test_request_creation_ignores_browser_quality_override(self) -> None: + runtime = SimpleNamespace(jellyseerr_base_url='http://seerr.test', jellyseerr_api_key='key') + seerr = SimpleNamespace(configured=lambda: True, + get_movie=AsyncMock(return_value={'title': 'Movie'}), + create_request=AsyncMock(return_value={'status': 1})) + destination = {'server_id': 1, 'profile_id': 7, 'root_folder': '/movies'} + with patch.object(requests_router, 'get_runtime_settings', return_value=runtime), \ + patch.object(requests_router, 'JellyseerrClient', return_value=seerr), \ + patch.object(requests_router, '_resolve_request_destination', new_callable=AsyncMock, return_value=destination) as resolve: + await requests_router.create_request({'mediaType': 'movie', 'tmdbId': 123, 'profileId': 999}, {'username': 'viewer'}) + resolve.assert_awaited_once_with(runtime, seerr, 'movie') + self.assertEqual(seerr.create_request.await_args.kwargs['profile_id'], 7) + async def test_request_destination_rejects_stale_profile_id(self) -> None: runtime = SimpleNamespace( radarr_base_url="http://radarr.test", radarr_api_key="key", - radarr_quality_profile_id=6, + radarr_quality_profile_id=999, radarr_root_folder="/movies", ) seerr = SimpleNamespace( @@ -875,10 +907,10 @@ class RequestCreationFlowTests(unittest.IsolatedAsyncioTestCase): with patch.object(requests_router, "RadarrClient", return_value=radarr): with self.assertRaises(HTTPException) as context: await requests_router._resolve_request_destination( - runtime, seerr, "movie", requested_profile_id=999 + runtime, seerr, "movie" ) - self.assertEqual(context.exception.status_code, 400) + self.assertEqual(context.exception.status_code, 409) self.assertIn("not available in Radarr", context.exception.detail) diff --git a/frontend/app/admin/SettingsPage.tsx b/frontend/app/admin/SettingsPage.tsx index 83501f8..759ba9c 100644 --- a/frontend/app/admin/SettingsPage.tsx +++ b/frontend/app/admin/SettingsPage.tsx @@ -1021,11 +1021,11 @@ export default function SettingsPage({ section }: SettingsPageProps) { bazarr_base_url: 'Bazarr server URL used for movie and episode subtitle repairs. Scheme is optional.', bazarr_api_key: 'API key used to ask Bazarr for fresh subtitles.', bazarr_default_language: 'Language code Bazarr should search for by default, such as en.', - sonarr_quality_profile_id: 'Quality profile used when adding TV shows.', + sonarr_quality_profile_id: 'Applied automatically to every new TV request. Users do not choose a quality profile in the request pipeline. If no Magent default is configured, requests use Seerr’s default.', sonarr_root_folder: 'Root folder where Sonarr stores TV shows.', radarr_base_url: 'Radarr server URL for movies (FQDN or IP). Scheme is optional.', radarr_api_key: 'API key for Radarr.', - radarr_quality_profile_id: 'Quality profile used when adding movies.', + radarr_quality_profile_id: 'Applied automatically to every new movie request. Users do not choose a quality profile in the request pipeline. If no Magent default is configured, requests use Seerr’s default.', radarr_root_folder: 'Root folder where Radarr stores movies.', prowlarr_base_url: 'Prowlarr server URL for indexer searches (FQDN or IP). Scheme is optional.', diff --git a/frontend/app/new-requests/NewRequestClient.tsx b/frontend/app/new-requests/NewRequestClient.tsx index 20e3d7e..90c5835 100644 --- a/frontend/app/new-requests/NewRequestClient.tsx +++ b/frontend/app/new-requests/NewRequestClient.tsx @@ -114,7 +114,6 @@ export default function NewRequestClient() { const [selected, setSelected] = useState(null) const [options, setOptions] = useState(null) const [loadingOptions, setLoadingOptions] = useState(false) - const [profileId, setProfileId] = useState(null) const [selectedSeasons, setSelectedSeasons] = useState([]) const [submitting, setSubmitting] = useState(false) const [operation, setOperation] = useState(null) @@ -132,7 +131,6 @@ export default function NewRequestClient() { setSearchAttempted(false) setSelected(null) setOptions(null) - setProfileId(null) setSelectedSeasons([]) setOperation(null) setError(null) @@ -194,7 +192,6 @@ export default function NewRequestClient() { const selectResult = async (item: DiscoveryResult) => { setSelected(item) setOptions(null) - setProfileId(null) setSelectedSeasons([]) setOperation(null) setError(null) @@ -234,7 +231,6 @@ export default function NewRequestClient() { return } setOptions(payload) - setProfileId(payload.destination.defaultProfileId) setSelectedSeasons(payload.media.seasons.map((season) => season.seasonNumber)) } catch (caught) { setError(caught instanceof Error ? caught.message : 'Could not load request options.') @@ -254,7 +250,7 @@ export default function NewRequestClient() { } const submitRequest = async () => { - if (!selected || !options || !profileId) return + if (!selected || !options) return if (selected.type === 'tv' && selectedSeasons.length === 0) { setError('Select at least one season.') return @@ -276,7 +272,6 @@ export default function NewRequestClient() { body: JSON.stringify({ mediaType: selected.type, tmdbId: selected.tmdbId, - profileId, seasons: selected.type === 'tv' ? selectedSeasons : undefined, }), }) @@ -480,17 +475,9 @@ export default function NewRequestClient() { )} - -
-
Delivery routeSeerr → {options.destination.collector} → GrizzlyflixOnly settings currently accepted by {options.destination.collector} are available.
-