Support original-language movie requests and fix repair dialog layout
Magent CI/CD / verify (push) Successful in 1m54s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped

This commit is contained in:
2026-09-11 18:05:18 +12:00
parent 52c85daae3
commit 38169b881e
11 changed files with 238 additions and 5 deletions
+18 -1
View File
@@ -1,3 +1,4 @@
from ..services.request_language import language_info, original_profile, is_original_profile
from ..feature_guards import require_request_access
from typing import Any, Dict, List, Optional, Tuple
import asyncio
@@ -3127,6 +3128,7 @@ async def request_options(
"backdropPath": details.get("backdropPath") or details.get("backdrop_path"),
"seasons": seasons,
"existingRequestId": existing_request_id,
"originalLanguage": language_info(details),
},
"destination": {
"collector": destination["collector"],
@@ -3227,7 +3229,16 @@ async def create_request(
detail=f"Season selection is not available for this series: {invalid_seasons}",
)
language = language_info(details)
accept_original = payload.get("acceptOriginalLanguage", False)
if not isinstance(accept_original, bool):
raise HTTPException(400, "The language choice must be true or false.")
if accept_original and not language:
raise HTTPException(409, "The original language could not be verified. Reload this title.")
destination = await _resolve_request_destination(runtime, client, media_type)
if accept_original and media_type == "movie":
destination["profile_id"] = await original_profile(
RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key), destination["profile_id"])
try:
created = await client.create_request(
@@ -3262,7 +3273,8 @@ async def create_request(
"request_created",
"Create request",
"ok",
f"{media_type} request created from discovery by {user.get('username')}.",
f"{media_type} request created from discovery by {user.get('username')}."
+ (f" Original-language audio accepted ({language['code']})." if accept_original else ""),
)
return {
@@ -3445,6 +3457,11 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
raise HTTPException(status_code=400, detail="Radarr not configured")
target_profile_id = _quality_profile_id(runtime.radarr_quality_profile_id)
current_profile_id = _quality_profile_id(arr_item.get("qualityProfileId"))
if current_profile_id and current_profile_id != target_profile_id:
profiles = await client.get_quality_profiles()
current = next((p for p in profiles if p.get("id") == current_profile_id), {})
if is_original_profile(current):
target_profile_id = current_profile_id
profile_message = None
movie_id = _quality_profile_id(arr_item.get("id"))
if target_profile_id and movie_id and current_profile_id != target_profile_id:
+60
View File
@@ -0,0 +1,60 @@
"""Explicit original-language requests without changing shared quality defaults."""
import asyncio
import copy
import hashlib
import json
import re
import httpx
from fastapi import HTTPException
_profile_lock = asyncio.Lock()
_prefix = "Magent Original "
def language_info(details):
code = str(details.get("originalLanguage") or details.get("original_language") or "").lower()
if not re.fullmatch(r"[a-z]{2}", code) or code in {"en", "xx", "zz"}:
return None
return {"code": code}
def profile_body(profile):
return {key: copy.deepcopy(value) for key, value in profile.items() if key not in {"id", "name"}}
def profile_name(body):
return _prefix + hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()[:16]
def is_original_profile(profile):
return ((profile.get("language") or {}).get("id") == -2
and profile.get("name") == profile_name(profile_body(profile)))
async def original_profile(client, default_id):
# Reuse immutable copies; never edit a profile already used by other titles.
async with _profile_lock:
try:
profiles = await client.get_quality_profiles()
except httpx.HTTPError as exc:
raise HTTPException(502, "Radarr could not load the language profile. Try again.") from exc
if not isinstance(profiles, list):
raise HTTPException(502, "Radarr returned invalid quality profiles.")
default = next((p for p in profiles if p.get("id") == default_id), None)
if not default:
raise HTTPException(409, "The default quality profile changed. Reload the request.")
body = profile_body(default)
body["language"] = {"id": -2, "name": "Original"}
name = profile_name(body)
match = next((p for p in profiles if p.get("name") == name and profile_body(p) == body), None)
if match:
return match["id"]
try:
result = await client.post("/api/v3/qualityprofile", payload={**body, "name": name})
except httpx.HTTPError as exc:
raise HTTPException(502, "Radarr could not prepare the original-language profile. Try again.") from exc
if not isinstance(result, dict) or not isinstance(result.get("id"), int):
raise HTTPException(502, "Radarr could not prepare the original-language profile. Try again.")
return result["id"]
+67
View File
@@ -0,0 +1,67 @@
import copy
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from backend.app.services.request_language import language_info, original_profile, is_original_profile
from backend.app.routers import requests
class RequestLanguageTests(unittest.IsolatedAsyncioTestCase):
def test_metadata_is_not_audio_evidence(self):
for code in ('en', '', 'xx', 'invalid'):
self.assertIsNone(language_info({'originalLanguage': code}))
self.assertEqual(language_info({'original_language': 'es'}), {'code': 'es'})
async def test_copy_preserves_quality_and_reuses_verified_profile(self):
default = {'id': 6, 'name': 'HD', 'language': {'id': 1, 'name': 'English'},
'items': [{'quality': {'id': 7}, 'allowed': True}], 'minFormatScore': 50,
'formatItems': [{'format': 10, 'score': -1000}], 'upgradeAllowed': True}
original = copy.deepcopy(default)
client = SimpleNamespace(get_quality_profiles=AsyncMock(return_value=[default]), post=AsyncMock(return_value={'id': 20}))
self.assertEqual(await original_profile(client, 6), 20)
payload = client.post.await_args.kwargs['payload']
self.assertEqual(payload['language']['id'], -2)
self.assertEqual(payload['items'], default['items'])
self.assertEqual(payload['formatItems'], default['formatItems'])
self.assertEqual(payload['minFormatScore'], 50)
self.assertEqual(default, original)
self.assertTrue(is_original_profile(payload))
client.get_quality_profiles.return_value.append({**payload, 'id': 20})
self.assertEqual(await original_profile(client, 6), 20)
self.assertEqual(client.post.await_count, 1)
payload['minFormatScore'] = 0
self.assertFalse(is_original_profile(payload))
async def test_missing_default_never_creates_profile(self):
client = SimpleNamespace(get_quality_profiles=AsyncMock(return_value=[]), post=AsyncMock())
with self.assertRaises(HTTPException):
await original_profile(client, 6)
client.post.assert_not_awaited()
async def test_only_explicit_verified_movie_consent_changes_destination(self):
runtime = SimpleNamespace(jellyseerr_base_url='http://seerr', jellyseerr_api_key='key',
radarr_base_url='http://radarr', radarr_api_key='key')
seerr = SimpleNamespace(configured=lambda: True, get_movie=AsyncMock(), get_tv=AsyncMock(),
create_request=AsyncMock(return_value={'status': 1}))
for code, consent, media_type, expected in [('es', True, 'movie', 20), ('es', False, 'movie', 6),
('en', True, 'movie', None), ('es', 'yes', 'movie', None),
('ja', True, 'tv', 6)]:
details = {'title': 'Title', 'originalLanguage': code, 'seasons': [{'seasonNumber': 1}]}
seerr.get_movie.return_value = seerr.get_tv.return_value = details
seerr.create_request.reset_mock()
with patch.object(requests, 'get_runtime_settings', return_value=runtime), \
patch.object(requests, 'JellyseerrClient', return_value=seerr), \
patch.object(requests, '_resolve_request_destination', new=AsyncMock(return_value={'server_id': 1, 'profile_id': 6, 'root_folder': '/media'})), \
patch.object(requests, 'original_profile', new=AsyncMock(return_value=20)) as clone:
payload = {'mediaType': media_type, 'tmdbId': 1417, 'acceptOriginalLanguage': consent, 'seasons': [1]}
if expected is None:
with self.assertRaises(HTTPException):
await requests.create_request(payload, {'username': 'viewer'})
seerr.create_request.assert_not_awaited()
clone.assert_not_awaited()
else:
await requests.create_request(payload, {'username': 'viewer'})
self.assertEqual(seerr.create_request.await_args.kwargs['profile_id'], expected)
self.assertEqual(clone.await_count, int(expected == 20))