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
+1 -2
View File
@@ -28,8 +28,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
cache-dependency-path: frontend/package-lock.json
# Gitea cache restore/save stalls here; npm ci takes about 15 seconds.
- name: Install frontend dependencies
working-directory: frontend
+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))
+15
View File
@@ -0,0 +1,15 @@
# Original-language requests
New Requests displays a language notice when Seerr reports a known, non-English original language. This is title metadata, not proof that a particular release lacks an English audio track or includes subtitles. Unknown and English original languages do not produce the notice.
Users can leave the normal request settings or explicitly accept original-language audio. The choice resets when changing titles and is verified against fresh Seerr metadata during submission; browser-supplied profile IDs remain ignored.
For movies, consent creates or reuses a `Magent Original …` Radarr quality profile. It copies the current default's quality ordering, allowed qualities, cutoff, upgrade rules and custom-format scores, changing only the language to Original. The existing default is never edited. The copy is selected for this new Seerr request only. Magent's subsequent Search and auto-download action preserves a verified copy instead of resetting it to English. Copies are content-addressed so later default changes do not silently change earlier requests.
TV requests show the same notice and retain their configured Sonarr profile. The inspected Sonarr configuration has no language custom formats. This feature does not bypass custom-format rejection, indexer restrictions, availability or permissions; it does not guarantee that a download is available. Existing requests are not silently modified by selecting an already-requested search result.
The first opted-in movie request creates a profile in Radarr. Failed request submission can leave an unused copy, which is reused on retry. Do not rename/edit managed copies if they should retain Magent's recognition during subsequent searches.
Radarr's API represents Original as language ID -2: [language source](https://github.com/Radarr/Radarr/blob/develop/src/NzbDrone.Core/Languages/Language.cs). Profile fields are defined in its [quality profile resource](https://github.com/Radarr/Radarr/blob/develop/src/Radarr.Api.V3/Profiles/Quality/QualityProfileResource.cs).
Validation: backend consent/profile isolation tests and `scripts/review_request_language_ui.cjs` with intercepted APIs; no live requests or downloads are created by these tests.
@@ -55,7 +55,7 @@ export default function DuplicateAccountRepair({ row, onClose, onSaved }: { row:
{preview.accounts.map((account) => <option key={account.id} value={account.id}>{account.username} Magent {account.id}{account.id === preview.recommended_id ? ' (recommended)' : ''}</option>)}
</select></label>
<p>The recommended row already owns the Jellyfin link, or is the oldest row when neither owns it.</p>
<div className="identity-mapping">{preview.accounts.map((account) => <div key={account.id}><strong>Magent {account.id}{account.id === preview.keep_id ? ' · Keep' : ' · Consolidate'}</strong><p>{account.username}</p><p>{account.email || 'No email'} · Profile {account.profile_id ?? 'None'}</p><p>Last login: {account.last_login_at ? new Date(account.last_login_at).toLocaleString() : 'Never'}</p></div>)}</div>
<div className="identity-mapping identity-duplicate-accounts">{preview.accounts.map((account) => <div key={account.id}><strong>Magent {account.id}{account.id === preview.keep_id ? ' · Keep' : ' · Consolidate'}</strong><p>{account.username}</p><p>{account.email || 'No email'} · Profile {account.profile_id ?? 'None'}</p><p>Last login: {account.last_login_at ? new Date(account.last_login_at).toLocaleString() : 'Never'}</p></div>)}</div>
<h3>Resulting account</h3>
<p><strong>{preview.proposed.username}</strong> · Magent {preview.keep_id} · Seerr {preview.proposed.seerr_user_id ?? 'Not verified'}</p>
<p>Jellyfin / Jellystat: <code>{preview.proposed.jellyfin_user_id ?? 'Not verified'}</code></p>
+3 -1
View File
@@ -1,6 +1,6 @@
.identity-review { display: grid; gap: 20px; min-width: 0; }
.identity-resolution-entry { display: grid; justify-items: start; gap: 12px; margin-top: 16px; }
.identity-resolve-dialog { width: min(900px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; color: var(--ops-text); background: var(--ops-panel, #1b1b1d); border: 1px solid var(--ops-line); border-radius: 16px; }
.identity-resolve-dialog { position: fixed; inset: 0; margin: auto; overflow: auto; overscroll-behavior: contain; width: min(900px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; color: var(--ops-text); background: var(--ops-panel, #1b1b1d); border: 1px solid var(--ops-line); border-radius: 16px; }
.identity-resolve-dialog::backdrop { background: #000b; backdrop-filter: blur(4px); }
.identity-resolve-content { display: grid; gap: 20px; padding: 24px; min-width: 0; }
.identity-resolve-content > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
@@ -76,3 +76,5 @@
.identity-import-option > span { display: flex; align-items: flex-start; gap: 10px; line-height: 1.6; }
.identity-import-option input[type=checkbox] { flex: 0 0 20px; margin: 3px 0 0; }
.identity-duplicate-accounts { grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr)); }
+6
View File
@@ -7597,3 +7597,9 @@ textarea {
justify-content: flex-start;
}
}
.request-language-notice { display: grid; gap: 12px; padding: 20px; border: 1px solid #a88445; border-radius: 12px; background: #a8844512; }
.request-language-notice h3, .request-language-notice p { margin: 0; }
.request-language-notice p, .request-language-notice small { line-height: 1.6; }
.request-language-notice label { display: flex; align-items: flex-start; gap: 10px; }
.request-language-notice input[type=checkbox] { flex: 0 0 20px; width: 20px; height: 20px; margin-top: 2px; }
@@ -28,6 +28,7 @@ type RequestOptions = {
episodeCount: number
airDate?: string | null
}>
originalLanguage?: { code: string } | null
existingRequestId?: number | null
}
destination: {
@@ -115,6 +116,7 @@ export default function NewRequestClient() {
const [options, setOptions] = useState<RequestOptions | null>(null)
const [loadingOptions, setLoadingOptions] = useState(false)
const [selectedSeasons, setSelectedSeasons] = useState<number[]>([])
const [acceptOriginalLanguage, setAcceptOriginalLanguage] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [operation, setOperation] = useState<OperationProgress | null>(null)
const [error, setError] = useState<string | null>(null)
@@ -132,6 +134,7 @@ export default function NewRequestClient() {
const changeTitle = () => {
setSelected(null)
setOptions(null)
setAcceptOriginalLanguage(false)
setSelectedSeasons([])
setOperation(null)
setError(null)
@@ -146,6 +149,7 @@ export default function NewRequestClient() {
setSearchAttempted(false)
setSelected(null)
setOptions(null)
setAcceptOriginalLanguage(false)
setSelectedSeasons([])
setOperation(null)
setError(null)
@@ -165,6 +169,7 @@ export default function NewRequestClient() {
setSearchAttempted(true)
setSelected(null)
setOptions(null)
setAcceptOriginalLanguage(false)
setOperation(null)
setError(null)
setSuccess(null)
@@ -207,6 +212,7 @@ export default function NewRequestClient() {
const selectResult = async (item: DiscoveryResult) => {
setSelected(item)
setOptions(null)
setAcceptOriginalLanguage(false)
setSelectedSeasons([])
setOperation(null)
setError(null)
@@ -285,6 +291,7 @@ export default function NewRequestClient() {
body: JSON.stringify({
mediaType: selected.type,
tmdbId: selected.tmdbId,
acceptOriginalLanguage,
seasons: selected.type === 'tv' ? selectedSeasons : undefined,
}),
})
@@ -489,6 +496,12 @@ export default function NewRequestClient() {
</fieldset>
)}
{options.media.originalLanguage && <div className="request-language-notice">
<h3>Check the audio language</h3>
<p>This titles original language is <strong>{new Intl.DisplayNames(['en'], { type: 'language' }).of(options.media.originalLanguage.code) || options.media.originalLanguage.code}</strong>. An English audio track may not be available. Title metadata does not confirm the audio or subtitles in a download.</p>
<label><input type="checkbox" checked={acceptOriginalLanguage} onChange={(event) => setAcceptOriginalLanguage(event.target.checked)} disabled={submitting} /><span>Im happy to watch in the original language.</span></label>
<small>{acceptOriginalLanguage ? (selected.type === 'movie' ? 'Search for original-language audio using the same quality requirements.' : 'Continue with your selected seasons and the configured TV quality requirements.') : 'Leave this unchecked to keep the standard request settings. An English-only profile may leave this title waiting for a suitable release.'}</small>
</div>}
<div className="request-submit-bar">
<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 || (selected.type === 'tv' && selectedSeasons.length === 0)}>
+6
View File
@@ -34,6 +34,12 @@ const base = process.env.REVIEW_BASE || 'http://localhost:3114';
assert.equal(await dialog.getByLabel('Magent account to keep').inputValue(), '36');
assert(await dialog.getByRole('button', { name: 'Confirm duplicate repair' }).isDisabled());
assert(await dialog.evaluate(el => el.scrollWidth <= el.clientWidth), 'No horizontal overflow');
const bounds = await dialog.boundingBox();
assert(Math.abs(bounds.x + bounds.width / 2 - width / 2) < 2, 'Dialog centered horizontally');
assert(bounds.y >= 19 && bounds.y + bounds.height <= 981, 'Dialog stays inside viewport');
await dialog.getByRole('checkbox').scrollIntoViewIfNeeded();
await dialog.getByRole('checkbox').check();
assert(await dialog.getByRole('button', { name: 'Confirm duplicate repair' }).isEnabled());
await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' });
assert(await trigger.evaluate(el => el === document.activeElement));
}
+48
View File
@@ -0,0 +1,48 @@
const assert = require('node:assert/strict');
const { chromium } = require(process.env.PLAYWRIGHT_PACKAGE || 'playwright');
const base = process.env.REVIEW_BASE || 'http://localhost:3114';
(async () => {
const browser = await chromium.launch();
try {
const context = await browser.newContext();
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }]);
const writes = [], errors = [];
await context.route('**/api/**', async route => {
const path = new URL(route.request().url()).pathname;
const media = { title: "Pan's Labyrinth", type: 'movie', tmdbId: 1417, year: 2006, seasons: [], originalLanguage: { code: 'es' } };
if (path === '/api/auth/me') return route.fulfill({ json: { username: 'Admin', role: 'admin' } });
if (path.startsWith('/api/operations/')) return route.fulfill({ json: { status: 'complete', events: [] } });
if (path === '/api/requests/search') return route.fulfill({ json: { results: [media] } });
if (path === '/api/requests/request-options') return route.fulfill({ json: { media, destination: { collector: 'Radarr', serverName: 'Movies', defaultProfileId: 6, profiles: [] } } });
if (path === '/api/requests/create') { writes.push(route.request().postDataJSON()); return route.fulfill({ json: { requestId: 12 } }); }
return route.fulfill({ json: {} });
});
const page = await context.newPage();
page.on('pageerror', e => errors.push(e.message));
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 900 });
await page.goto(base + '/new-requests');
await page.getByRole('button', { name: /Film.*Movie/ }).click();
await page.getByLabel('Title', { exact: true }).fill('Pans Labyrinth');
await page.getByRole('button', { name: 'Search Seerr' }).click();
await page.getByRole('button', { name: /Pan's Labyrinth/ }).click();
await page.getByRole('heading', { name: 'Check the audio language' }).waitFor();
assert(await page.locator('.request-language-notice').getByText('Spanish', { exact: true }).isVisible());
const consent = page.getByRole('checkbox');
assert(!await consent.isChecked());
await consent.check();
await page.getByRole('button', { name: 'Change title', exact: true }).click();
await page.getByRole('button', { name: /Pan's Labyrinth/ }).click();
await page.getByRole('heading', { name: 'Check the audio language' }).waitFor();
assert(!await consent.isChecked(), 'Consent resets when changing titles');
await consent.check();
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
await page.getByRole('button', { name: 'Request movie', exact: true }).click();
await page.getByText('Request #12 has been accepted by Seerr.', { exact: true }).waitFor();
}
assert.equal(writes.length, 2);
assert(writes.every(w => w.acceptOriginalLanguage === true && w.tmdbId === 1417 && !w.profileId));
assert.deepEqual(errors, []);
console.log('Passed: desktop/mobile warning, Spanish metadata, explicit consent, reset on title change and request payload. All APIs intercepted.');
} finally { await browser.close(); }
})().catch(e => { console.error(e); process.exitCode = 1; });