Support original-language movie requests and fix repair dialog layout
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
@@ -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))
|
||||
@@ -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>
|
||||
|
||||
@@ -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)); }
|
||||
|
||||
@@ -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 title’s 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>I’m 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)}>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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; });
|
||||
Reference in New Issue
Block a user