Use admin quality defaults throughout the request pipeline
This commit is contained in:
@@ -562,7 +562,6 @@ async def _resolve_request_destination(
|
|||||||
runtime: Any,
|
runtime: Any,
|
||||||
seerr: JellyseerrClient,
|
seerr: JellyseerrClient,
|
||||||
media_type: str,
|
media_type: str,
|
||||||
requested_profile_id: Optional[int] = None,
|
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
if media_type == "tv":
|
if media_type == "tv":
|
||||||
collector_name = "Sonarr"
|
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.")
|
raise HTTPException(status_code=409, detail=f"{collector_name} has no quality profiles available.")
|
||||||
profile_ids = {int(item["id"]) for item in profiles}
|
profile_ids = {int(item["id"]) for item in profiles}
|
||||||
|
|
||||||
|
# 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"))
|
default_profile_id = _quality_profile_id(server.get("activeProfileId"))
|
||||||
if default_profile_id not in profile_ids:
|
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(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=409,
|
||||||
detail=f"The selected quality profile is not available in {collector_name}.",
|
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)
|
roots = _normalize_request_roots(root_payload)
|
||||||
root_folder = str(server.get("activeDirectory") or "").strip()
|
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")
|
raise HTTPException(status_code=400, detail="tmdbId must be a positive integer")
|
||||||
|
|
||||||
seasons = _normalize_seasons(payload.get("seasons")) if media_type == "tv" else []
|
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")
|
raw_is_4k = payload.get("is4k")
|
||||||
if raw_is_4k is not None and not isinstance(raw_is_4k, bool):
|
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")
|
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}",
|
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:
|
try:
|
||||||
created = await client.create_request(
|
created = await client.create_request(
|
||||||
|
|||||||
@@ -803,7 +803,7 @@ class RequestCreationFlowTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(result, {"ok": True})
|
self.assertEqual(result, {"ok": True})
|
||||||
self.assertEqual(captured["payload"], {"mediaType": "movie", "mediaId": 209112})
|
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(
|
runtime = SimpleNamespace(
|
||||||
sonarr_base_url="http://sonarr.test",
|
sonarr_base_url="http://sonarr.test",
|
||||||
sonarr_api_key="key",
|
sonarr_api_key="key",
|
||||||
@@ -818,7 +818,7 @@ class RequestCreationFlowTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
"name": "Main Sonarr",
|
"name": "Main Sonarr",
|
||||||
"isDefault": True,
|
"isDefault": True,
|
||||||
"is4k": False,
|
"is4k": False,
|
||||||
"activeProfileId": 7,
|
"activeProfileId": 10,
|
||||||
"activeDirectory": "/tv",
|
"activeDirectory": "/tv",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -834,10 +834,10 @@ class RequestCreationFlowTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
with patch.object(requests_router, "SonarrClient", return_value=sonarr):
|
with patch.object(requests_router, "SonarrClient", return_value=sonarr):
|
||||||
destination = await requests_router._resolve_request_destination(
|
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["default_profile_id"], 7)
|
||||||
self.assertEqual(destination["root_folder"], "/tv")
|
self.assertEqual(destination["root_folder"], "/tv")
|
||||||
self.assertEqual(destination["profiles"], [
|
self.assertEqual(destination["profiles"], [
|
||||||
@@ -845,11 +845,43 @@ class RequestCreationFlowTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
{"id": 10, "name": "Optimal"},
|
{"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:
|
async def test_request_destination_rejects_stale_profile_id(self) -> None:
|
||||||
runtime = SimpleNamespace(
|
runtime = SimpleNamespace(
|
||||||
radarr_base_url="http://radarr.test",
|
radarr_base_url="http://radarr.test",
|
||||||
radarr_api_key="key",
|
radarr_api_key="key",
|
||||||
radarr_quality_profile_id=6,
|
radarr_quality_profile_id=999,
|
||||||
radarr_root_folder="/movies",
|
radarr_root_folder="/movies",
|
||||||
)
|
)
|
||||||
seerr = SimpleNamespace(
|
seerr = SimpleNamespace(
|
||||||
@@ -875,10 +907,10 @@ class RequestCreationFlowTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
with patch.object(requests_router, "RadarrClient", return_value=radarr):
|
with patch.object(requests_router, "RadarrClient", return_value=radarr):
|
||||||
with self.assertRaises(HTTPException) as context:
|
with self.assertRaises(HTTPException) as context:
|
||||||
await requests_router._resolve_request_destination(
|
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)
|
self.assertIn("not available in Radarr", context.exception.detail)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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_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_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.',
|
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.',
|
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_base_url: 'Radarr server URL for movies (FQDN or IP). Scheme is optional.',
|
||||||
radarr_api_key: 'API key for Radarr.',
|
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.',
|
radarr_root_folder: 'Root folder where Radarr stores movies.',
|
||||||
prowlarr_base_url:
|
prowlarr_base_url:
|
||||||
'Prowlarr server URL for indexer searches (FQDN or IP). Scheme is optional.',
|
'Prowlarr server URL for indexer searches (FQDN or IP). Scheme is optional.',
|
||||||
|
|||||||
@@ -114,7 +114,6 @@ export default function NewRequestClient() {
|
|||||||
const [selected, setSelected] = useState<DiscoveryResult | null>(null)
|
const [selected, setSelected] = useState<DiscoveryResult | null>(null)
|
||||||
const [options, setOptions] = useState<RequestOptions | null>(null)
|
const [options, setOptions] = useState<RequestOptions | null>(null)
|
||||||
const [loadingOptions, setLoadingOptions] = useState(false)
|
const [loadingOptions, setLoadingOptions] = useState(false)
|
||||||
const [profileId, setProfileId] = useState<number | null>(null)
|
|
||||||
const [selectedSeasons, setSelectedSeasons] = useState<number[]>([])
|
const [selectedSeasons, setSelectedSeasons] = useState<number[]>([])
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [operation, setOperation] = useState<OperationProgress | null>(null)
|
const [operation, setOperation] = useState<OperationProgress | null>(null)
|
||||||
@@ -132,7 +131,6 @@ export default function NewRequestClient() {
|
|||||||
setSearchAttempted(false)
|
setSearchAttempted(false)
|
||||||
setSelected(null)
|
setSelected(null)
|
||||||
setOptions(null)
|
setOptions(null)
|
||||||
setProfileId(null)
|
|
||||||
setSelectedSeasons([])
|
setSelectedSeasons([])
|
||||||
setOperation(null)
|
setOperation(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -194,7 +192,6 @@ export default function NewRequestClient() {
|
|||||||
const selectResult = async (item: DiscoveryResult) => {
|
const selectResult = async (item: DiscoveryResult) => {
|
||||||
setSelected(item)
|
setSelected(item)
|
||||||
setOptions(null)
|
setOptions(null)
|
||||||
setProfileId(null)
|
|
||||||
setSelectedSeasons([])
|
setSelectedSeasons([])
|
||||||
setOperation(null)
|
setOperation(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -234,7 +231,6 @@ export default function NewRequestClient() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setOptions(payload)
|
setOptions(payload)
|
||||||
setProfileId(payload.destination.defaultProfileId)
|
|
||||||
setSelectedSeasons(payload.media.seasons.map((season) => season.seasonNumber))
|
setSelectedSeasons(payload.media.seasons.map((season) => season.seasonNumber))
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
setError(caught instanceof Error ? caught.message : 'Could not load request options.')
|
setError(caught instanceof Error ? caught.message : 'Could not load request options.')
|
||||||
@@ -254,7 +250,7 @@ export default function NewRequestClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const submitRequest = async () => {
|
const submitRequest = async () => {
|
||||||
if (!selected || !options || !profileId) return
|
if (!selected || !options) return
|
||||||
if (selected.type === 'tv' && selectedSeasons.length === 0) {
|
if (selected.type === 'tv' && selectedSeasons.length === 0) {
|
||||||
setError('Select at least one season.')
|
setError('Select at least one season.')
|
||||||
return
|
return
|
||||||
@@ -276,7 +272,6 @@ export default function NewRequestClient() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
mediaType: selected.type,
|
mediaType: selected.type,
|
||||||
tmdbId: selected.tmdbId,
|
tmdbId: selected.tmdbId,
|
||||||
profileId,
|
|
||||||
seasons: selected.type === 'tv' ? selectedSeasons : undefined,
|
seasons: selected.type === 'tv' ? selectedSeasons : undefined,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -480,17 +475,9 @@ export default function NewRequestClient() {
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<label className="request-profile-field">
|
|
||||||
<span>Quality profile</span>
|
|
||||||
<select value={profileId ?? ''} onChange={(event) => setProfileId(Number(event.target.value))}>
|
|
||||||
{options.destination.profiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
|
|
||||||
</select>
|
|
||||||
<small>Live options from {options.destination.collector}. Seerr will use {options.destination.serverName}.</small>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="request-submit-bar">
|
<div className="request-submit-bar">
|
||||||
<div><span>Delivery route</span><strong>Seerr → {options.destination.collector} → Grizzlyflix</strong><small>Only settings currently accepted by {options.destination.collector} are available.</small></div>
|
<div><span>Delivery route</span><strong>Seerr → {options.destination.collector} → Grizzlyflix</strong><small>Your request uses the default quality set by your administrator.</small></div>
|
||||||
<button type="button" onClick={() => void submitRequest()} disabled={submitting || !profileId || (selected.type === 'tv' && selectedSeasons.length === 0)}>
|
<button type="button" onClick={() => void submitRequest()} disabled={submitting || (selected.type === 'tv' && selectedSeasons.length === 0)}>
|
||||||
{submitting ? 'Sending request…' : `Request ${selected.type === 'tv' ? 'show' : 'movie'}`}
|
{submitting ? 'Sending request…' : `Request ${selected.type === 'tv' ? 'show' : 'movie'}`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user