diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index b44a20c..4ae531e 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -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 diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index b14b5d7..c55327c 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -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: diff --git a/backend/app/services/request_language.py b/backend/app/services/request_language.py new file mode 100644 index 0000000..b2923fc --- /dev/null +++ b/backend/app/services/request_language.py @@ -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"] diff --git a/backend/tests/test_request_language.py b/backend/tests/test_request_language.py new file mode 100644 index 0000000..ded6ba0 --- /dev/null +++ b/backend/tests/test_request_language.py @@ -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)) diff --git a/docs/original-language-requests.md b/docs/original-language-requests.md new file mode 100644 index 0000000..5b0d627 --- /dev/null +++ b/docs/original-language-requests.md @@ -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. diff --git a/frontend/app/admin/identities/DuplicateAccountRepair.tsx b/frontend/app/admin/identities/DuplicateAccountRepair.tsx index af7a2c2..017fe38 100644 --- a/frontend/app/admin/identities/DuplicateAccountRepair.tsx +++ b/frontend/app/admin/identities/DuplicateAccountRepair.tsx @@ -55,7 +55,7 @@ export default function DuplicateAccountRepair({ row, onClose, onSaved }: { row: {preview.accounts.map((account) => )}

The recommended row already owns the Jellyfin link, or is the oldest row when neither owns it.

-
{preview.accounts.map((account) =>
Magent {account.id}{account.id === preview.keep_id ? ' · Keep' : ' · Consolidate'}

{account.username}

{account.email || 'No email'} · Profile {account.profile_id ?? 'None'}

Last login: {account.last_login_at ? new Date(account.last_login_at).toLocaleString() : 'Never'}

)}
+
{preview.accounts.map((account) =>
Magent {account.id}{account.id === preview.keep_id ? ' · Keep' : ' · Consolidate'}

{account.username}

{account.email || 'No email'} · Profile {account.profile_id ?? 'None'}

Last login: {account.last_login_at ? new Date(account.last_login_at).toLocaleString() : 'Never'}

)}

Resulting account

{preview.proposed.username} · Magent {preview.keep_id} · Seerr {preview.proposed.seerr_user_id ?? 'Not verified'}

Jellyfin / Jellystat: {preview.proposed.jellyfin_user_id ?? 'Not verified'}

diff --git a/frontend/app/admin/identities/identities.css b/frontend/app/admin/identities/identities.css index 02bf052..51c5fd6 100644 --- a/frontend/app/admin/identities/identities.css +++ b/frontend/app/admin/identities/identities.css @@ -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)); } diff --git a/frontend/app/globals.css b/frontend/app/globals.css index bc421ec..de0d969 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -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; } diff --git a/frontend/app/new-requests/NewRequestClient.tsx b/frontend/app/new-requests/NewRequestClient.tsx index 83ff815..2eafcc7 100644 --- a/frontend/app/new-requests/NewRequestClient.tsx +++ b/frontend/app/new-requests/NewRequestClient.tsx @@ -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(null) const [loadingOptions, setLoadingOptions] = useState(false) const [selectedSeasons, setSelectedSeasons] = useState([]) + const [acceptOriginalLanguage, setAcceptOriginalLanguage] = useState(false) const [submitting, setSubmitting] = useState(false) const [operation, setOperation] = useState(null) const [error, setError] = useState(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() { )} + {options.media.originalLanguage &&
+

Check the audio language

+

This title’s original language is {new Intl.DisplayNames(['en'], { type: 'language' }).of(options.media.originalLanguage.code) || options.media.originalLanguage.code}. An English audio track may not be available. Title metadata does not confirm the audio or subtitles in a download.

+ + {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.'} +
}
Delivery routeSeerr → {options.destination.collector} → GrizzlyflixYour request uses the default quality set by your administrator.