Reconcile verified account IDs and make language repairs observable
This commit is contained in:
@@ -26,7 +26,7 @@ class RadarrClient(ApiClient):
|
|||||||
return await self.get("/api/v3/qualityprofile")
|
return await self.get("/api/v3/qualityprofile")
|
||||||
|
|
||||||
async def get_queue(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
async def get_queue(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get("/api/v3/queue", params={"movieId": movie_id})
|
return await self.get("/api/v3/queue", params={"movieIds": movie_id, "pageSize": 1000})
|
||||||
|
|
||||||
async def search_releases(self, movie_id: int) -> Optional[Any]:
|
async def search_releases(self, movie_id: int) -> Optional[Any]:
|
||||||
return await self.get(
|
return await self.get(
|
||||||
|
|||||||
@@ -1078,6 +1078,10 @@ def create_user_if_missing(
|
|||||||
if any(str(row[0]).strip().casefold() == username.casefold()
|
if any(str(row[0]).strip().casefold() == username.casefold()
|
||||||
for row in conn.execute("SELECT username FROM users")):
|
for row in conn.execute("SELECT username FROM users")):
|
||||||
return False
|
return False
|
||||||
|
if jellyseerr_user_id is not None and conn.execute(
|
||||||
|
"SELECT 1 FROM users WHERE jellyseerr_user_id=?", (jellyseerr_user_id,)
|
||||||
|
).fetchone():
|
||||||
|
return False
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR IGNORE INTO users (
|
INSERT OR IGNORE INTO users (
|
||||||
|
|||||||
@@ -880,28 +880,10 @@ async def jellyseerr_users_sync() -> Dict[str, Any]:
|
|||||||
if not jellyseerr_users:
|
if not jellyseerr_users:
|
||||||
return {"status": "ok", "matched": 0, "skipped": 0, "total": 0}
|
return {"status": "ok", "matched": 0, "skipped": 0, "total": 0}
|
||||||
|
|
||||||
candidate_to_id = build_jellyseerr_candidate_map(jellyseerr_users)
|
from ..services.jellyfin_sync import sync_jellyfin_users
|
||||||
|
imported = await sync_jellyfin_users()
|
||||||
|
return {"status": "ok", "matched": len(jellyseerr_users), "skipped": 0, "imported": imported, "total": len(jellyseerr_users)}
|
||||||
|
|
||||||
updated = 0
|
|
||||||
skipped = 0
|
|
||||||
users = get_all_users()
|
|
||||||
for user in users:
|
|
||||||
if user.get("jellyseerr_user_id") is not None:
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
username = user.get("username") or ""
|
|
||||||
matched_id = match_jellyseerr_user_id(username, candidate_to_id)
|
|
||||||
matched_seerr_user = find_matching_jellyseerr_user(username, jellyseerr_users)
|
|
||||||
matched_email = extract_jellyseerr_user_email(matched_seerr_user)
|
|
||||||
if matched_id is not None:
|
|
||||||
set_user_jellyseerr_id(username, matched_id)
|
|
||||||
if matched_email:
|
|
||||||
set_user_email(username, matched_email)
|
|
||||||
updated += 1
|
|
||||||
else:
|
|
||||||
skipped += 1
|
|
||||||
|
|
||||||
return {"status": "ok", "matched": updated, "skipped": skipped, "total": len(users)}
|
|
||||||
|
|
||||||
def _pick_jellyseerr_username(user: Dict[str, Any]) -> Optional[str]:
|
def _pick_jellyseerr_username(user: Dict[str, Any]) -> Optional[str]:
|
||||||
for key in ("email", "username", "displayName", "name"):
|
for key in ("email", "username", "displayName", "name"):
|
||||||
@@ -922,33 +904,9 @@ async def jellyseerr_users_resync() -> Dict[str, Any]:
|
|||||||
if not jellyseerr_users:
|
if not jellyseerr_users:
|
||||||
return {"status": "ok", "imported": 0, "cleared": 0}
|
return {"status": "ok", "imported": 0, "cleared": 0}
|
||||||
|
|
||||||
cleared = delete_non_admin_users()
|
from ..services.jellyfin_sync import sync_jellyfin_users
|
||||||
imported = 0
|
imported = await sync_jellyfin_users()
|
||||||
for user in jellyseerr_users:
|
return {"status": "ok", "imported": imported, "cleared": 0}
|
||||||
user_id = user.get("id") or user.get("userId") or user.get("Id")
|
|
||||||
try:
|
|
||||||
user_id = int(user_id)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
username = _pick_jellyseerr_username(user)
|
|
||||||
if not username:
|
|
||||||
continue
|
|
||||||
email = extract_jellyseerr_user_email(user)
|
|
||||||
created = create_user_if_missing(
|
|
||||||
username,
|
|
||||||
"jellyseerr-user",
|
|
||||||
role="user",
|
|
||||||
email=email,
|
|
||||||
auth_provider="jellyseerr",
|
|
||||||
jellyseerr_user_id=user_id,
|
|
||||||
)
|
|
||||||
if created:
|
|
||||||
imported += 1
|
|
||||||
else:
|
|
||||||
set_user_jellyseerr_id(username, user_id)
|
|
||||||
if email:
|
|
||||||
set_user_email(username, email)
|
|
||||||
return {"status": "ok", "imported": imported, "cleared": cleared}
|
|
||||||
|
|
||||||
@router.post("/requests/sync")
|
@router.post("/requests/sync")
|
||||||
async def requests_sync() -> Dict[str, Any]:
|
async def requests_sync() -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -733,6 +733,13 @@ async def jellyfin_login(
|
|||||||
if not isinstance(auth_response, dict) or not auth_response.get("User"):
|
if not isinstance(auth_response, dict) or not auth_response.get("User"):
|
||||||
_record_login_failure(request, username)
|
_record_login_failure(request, username)
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
|
||||||
|
from ..services.jellyfin_identity import user_for_identity
|
||||||
|
identity_owner = user_for_identity(auth_response['User'].get('Id'), runtime.jellyfin_base_url)
|
||||||
|
if identity_owner:
|
||||||
|
preferred_match = identity_owner
|
||||||
|
user = identity_owner
|
||||||
|
canonical_username = identity_owner['username']
|
||||||
|
_assert_user_can_login(user)
|
||||||
if not preferred_match:
|
if not preferred_match:
|
||||||
create_user_if_missing(
|
create_user_if_missing(
|
||||||
canonical_username,
|
canonical_username,
|
||||||
@@ -815,8 +822,13 @@ async def jellyseerr_login(
|
|||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Seerr credentials")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Seerr credentials")
|
||||||
jellyseerr_user_id = _extract_jellyseerr_user_id(auth_response)
|
jellyseerr_user_id = _extract_jellyseerr_user_id(auth_response)
|
||||||
jellyseerr_email = _extract_jellyseerr_response_email(auth_response)
|
jellyseerr_email = _extract_jellyseerr_response_email(auth_response)
|
||||||
|
id_matches = [row for row in get_all_users() if jellyseerr_user_id is not None and row.get('jellyseerr_user_id') == jellyseerr_user_id]
|
||||||
|
if len(id_matches) > 1:
|
||||||
|
raise HTTPException(409, 'Multiple Magent accounts claim this Seerr identity. Ask an administrator to repair the links.')
|
||||||
ci_matches = get_users_by_username_ci(form_data.username)
|
ci_matches = get_users_by_username_ci(form_data.username)
|
||||||
preferred_match = _pick_preferred_ci_user_match(ci_matches, form_data.username)
|
preferred_match = id_matches[0] if id_matches else _pick_preferred_ci_user_match(ci_matches, form_data.username)
|
||||||
|
if preferred_match and preferred_match.get('jellyseerr_user_id') not in (None, jellyseerr_user_id):
|
||||||
|
raise HTTPException(409, 'The account name and authenticated identity disagree. Ask an administrator to repair the links.')
|
||||||
canonical_username = str(preferred_match.get("username") or form_data.username) if preferred_match else form_data.username
|
canonical_username = str(preferred_match.get("username") or form_data.username) if preferred_match else form_data.username
|
||||||
if not preferred_match:
|
if not preferred_match:
|
||||||
create_user_if_missing(
|
create_user_if_missing(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from ..services.request_language import language_info, original_profile, is_original_profile
|
from ..services.request_language import language_info, original_profile, is_original_profile, apply_original_to_movie, movie_search_outcome, series_search_outcome
|
||||||
from ..feature_guards import require_request_access
|
from ..feature_guards import require_request_access
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -3180,6 +3180,13 @@ async def create_request(
|
|||||||
if not isinstance(details, dict):
|
if not isinstance(details, dict):
|
||||||
raise HTTPException(status_code=502, detail="Invalid response from Seerr media lookup")
|
raise HTTPException(status_code=502, detail="Invalid response from Seerr media lookup")
|
||||||
|
|
||||||
|
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.")
|
||||||
|
|
||||||
media_info = details.get("mediaInfo") if isinstance(details.get("mediaInfo"), dict) else {}
|
media_info = details.get("mediaInfo") if isinstance(details.get("mediaInfo"), dict) else {}
|
||||||
requests_list = media_info.get("requests")
|
requests_list = media_info.get("requests")
|
||||||
existing_request: Optional[Dict[str, Any]] = None
|
existing_request: Optional[Dict[str, Any]] = None
|
||||||
@@ -3195,6 +3202,8 @@ async def create_request(
|
|||||||
year = int(date_value[:4])
|
year = int(date_value[:4])
|
||||||
|
|
||||||
if isinstance(existing_request, dict):
|
if isinstance(existing_request, dict):
|
||||||
|
if accept_original:
|
||||||
|
raise HTTPException(409, 'This title is already requested. Open its request and choose Use original audio & search to update the existing movie.')
|
||||||
existing_request_id = _quality_profile_id(existing_request.get("id"))
|
existing_request_id = _quality_profile_id(existing_request.get("id"))
|
||||||
existing_status = existing_request.get("status")
|
existing_status = existing_request.get("status")
|
||||||
if existing_request_id is not None:
|
if existing_request_id is not None:
|
||||||
@@ -3229,16 +3238,12 @@ 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}",
|
||||||
)
|
)
|
||||||
|
|
||||||
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)
|
destination = await _resolve_request_destination(runtime, client, media_type)
|
||||||
if accept_original and media_type == "movie":
|
if accept_original and media_type == "movie":
|
||||||
destination["profile_id"] = await original_profile(
|
destination["profile_id"] = await original_profile(
|
||||||
RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key), destination["profile_id"])
|
RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key), destination["profile_id"])
|
||||||
|
# Seerr does not update an already-existing Radarr movie's profile on request creation.
|
||||||
|
await apply_original_to_movie(RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key), tmdb_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
created = await client.create_request(
|
created = await client.create_request(
|
||||||
@@ -3299,6 +3304,57 @@ async def ai_triage(request_id: str, user: Dict[str, str] = Depends(get_current_
|
|||||||
return triage_snapshot(snapshot)
|
return triage_snapshot(snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
async def _request_language_context(request_id, user):
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||||
|
await _ensure_request_access(seerr, int(request_id), user)
|
||||||
|
request = await seerr.get_request(request_id)
|
||||||
|
if not isinstance(request, dict) or request.get('type') != 'movie':
|
||||||
|
return runtime, None, None
|
||||||
|
tmdb_id = (request.get('media') or {}).get('tmdbId')
|
||||||
|
if not isinstance(tmdb_id, int):
|
||||||
|
raise HTTPException(502, 'Seerr did not return the movie identity.')
|
||||||
|
details = await seerr.get_movie(tmdb_id)
|
||||||
|
return runtime, tmdb_id, language_info(details or {})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{request_id}/language")
|
||||||
|
async def request_language(request_id: str, user: dict = Depends(get_current_user)):
|
||||||
|
runtime, tmdb_id, language = await _request_language_context(request_id, user)
|
||||||
|
if not language:
|
||||||
|
return {'language': None}
|
||||||
|
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
||||||
|
movies = await radarr.get_movie_by_tmdb_id(tmdb_id)
|
||||||
|
movie = next((m for m in (movies or []) if m.get('tmdbId') == tmdb_id), None)
|
||||||
|
profiles = await radarr.get_quality_profiles() if movie else []
|
||||||
|
profile = next((p for p in profiles if p['id'] == movie['qualityProfileId']), {}) if movie else {}
|
||||||
|
return {'language': language, 'originalEnabled': is_original_profile(profile),
|
||||||
|
'canChange': bool(movie) and _user_can_use_search_auto(user),
|
||||||
|
'profileLanguage': (profile.get('language') or {}).get('name')}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{request_id}/actions/language")
|
||||||
|
async def accept_request_language(request_id: str, payload: dict, user: dict = Depends(get_current_user)):
|
||||||
|
if not _user_can_use_search_auto(user):
|
||||||
|
raise HTTPException(403, 'Search and download changes are disabled for this account.')
|
||||||
|
if payload.get('acceptOriginalLanguage') is not True:
|
||||||
|
raise HTTPException(400, 'Explicitly accept original-language audio before continuing.')
|
||||||
|
runtime, tmdb_id, language = await _request_language_context(request_id, user)
|
||||||
|
if not language:
|
||||||
|
raise HTTPException(409, 'This request has no verified non-English original language.')
|
||||||
|
if payload.get('languageCode') != language['code']:
|
||||||
|
raise HTTPException(409, 'The language metadata changed. Reload the request and review it again.')
|
||||||
|
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
||||||
|
profile_id = await apply_original_to_movie(radarr, tmdb_id)
|
||||||
|
if profile_id is None:
|
||||||
|
raise HTTPException(409, 'The movie is not in Radarr yet. Recheck the pipeline first.')
|
||||||
|
await asyncio.to_thread(save_action, request_id, 'original_language', 'Accept original-language audio',
|
||||||
|
'ok', f"Original-language audio accepted ({language['code']}); Radarr profile {profile_id} verified.")
|
||||||
|
result = await action_search_auto(request_id, user)
|
||||||
|
result['message'] = 'Original-language audio enabled. ' + result['message']
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{request_id}/actions/search")
|
@router.post("/{request_id}/actions/search")
|
||||||
async def action_search(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
|
async def action_search(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
@@ -3377,6 +3433,9 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr
|
|||||||
raise HTTPException(status_code=502, detail=detail) from exc
|
raise HTTPException(status_code=502, detail=detail) from exc
|
||||||
|
|
||||||
releases = _filter_arr_release_results(results)
|
releases = _filter_arr_release_results(results)
|
||||||
|
rejection_reasons = sorted({str(reason) for result in results for reason in (result.get('rejections') or [])})[:8]
|
||||||
|
result_message = (f"{collector} approved {len(releases)} releases against its assigned quality profile."
|
||||||
|
if releases else f"No approved releases were found. " + (' '.join(rejection_reasons) if rejection_reasons else 'The indexers returned no suitable results. Try again later or review the audio language.'))
|
||||||
|
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
save_action,
|
save_action,
|
||||||
@@ -3390,10 +3449,9 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr
|
|||||||
"status": "ok",
|
"status": "ok",
|
||||||
"collector": collector,
|
"collector": collector,
|
||||||
"qualityFiltered": True,
|
"qualityFiltered": True,
|
||||||
"message": (
|
"message": result_message,
|
||||||
f"{collector} approved {len(releases)} release{'s' if len(releases) != 1 else ''} "
|
"outcome": "matches" if releases else "attention",
|
||||||
"against its assigned quality profile."
|
"rejectionReasons": rejection_reasons,
|
||||||
),
|
|
||||||
"releases": releases,
|
"releases": releases,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3444,13 +3502,14 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
|
|||||||
responses.append(
|
responses.append(
|
||||||
{"season": season_number, "episodeCount": len(episode_ids), "response": response}
|
{"season": season_number, "episodeCount": len(episode_ids), "response": response}
|
||||||
)
|
)
|
||||||
message = "Search sent to Sonarr."
|
outcome = await series_search_outcome(client, int(arr_item["id"]), [item['response'] for item in responses])
|
||||||
|
message = outcome['message']
|
||||||
if profile_message:
|
if profile_message:
|
||||||
message = f"{profile_message} {message}"
|
message = f"{profile_message} {message}"
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
save_action, request_id, "search_auto", "Search and auto-download", "ok", message
|
save_action, request_id, "search_auto", "Search and auto-download", "ok", message
|
||||||
)
|
)
|
||||||
return {"status": "ok", "message": message, "searched": responses}
|
return {"status": outcome["status"], "message": message, "searched": responses}
|
||||||
if snapshot.request_type.value == "movie":
|
if snapshot.request_type.value == "movie":
|
||||||
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
||||||
if not client.configured():
|
if not client.configured():
|
||||||
@@ -3472,13 +3531,14 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
|
|||||||
await client.update_movie(movie)
|
await client.update_movie(movie)
|
||||||
profile_message = f"Radarr quality profile updated to {target_profile_id} before search."
|
profile_message = f"Radarr quality profile updated to {target_profile_id} before search."
|
||||||
response = await client.search(int(arr_item["id"]))
|
response = await client.search(int(arr_item["id"]))
|
||||||
message = "Search sent to Radarr."
|
outcome = await movie_search_outcome(client, int(arr_item["id"]), response)
|
||||||
|
message = outcome['message']
|
||||||
if profile_message:
|
if profile_message:
|
||||||
message = f"{profile_message} {message}"
|
message = f"{profile_message} {message}"
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
save_action, request_id, "search_auto", "Search and auto-download", "ok", message
|
save_action, request_id, "search_auto", "Search and auto-download", "ok", message
|
||||||
)
|
)
|
||||||
return {"status": "ok", "message": message, "response": response}
|
return {"status": outcome["status"], "message": message, "response": response}
|
||||||
|
|
||||||
raise HTTPException(status_code=400, detail="Unknown request type")
|
raise HTTPException(status_code=400, detail="Unknown request type")
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Reviewed consolidation of same-name Jellyfin accounts, entirely within Magent."""
|
"""Reviewed consolidation of accounts sharing a verified Jellyfin ID, entirely within Magent."""
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
@@ -30,14 +30,19 @@ def account_state(conn, ids):
|
|||||||
('email_recap_subscriptions', 'user_id'), ('newsletter_subscriptions', 'user_id')]}
|
('email_recap_subscriptions', 'user_id'), ('newsletter_subscriptions', 'user_id')]}
|
||||||
|
|
||||||
|
|
||||||
|
def identity_group(report, target):
|
||||||
|
identity = target['candidate_jellyfin_id']
|
||||||
|
return [row for row in report['rows'] if identity and row['candidate_jellyfin_id'] == identity]
|
||||||
|
|
||||||
|
|
||||||
def build_preview(report, local, runtime, state, user_id, keep_id=None):
|
def build_preview(report, local, runtime, state, user_id, keep_id=None):
|
||||||
target = next((row for row in report['rows'] if row['user']['id'] == user_id), None)
|
target = next((row for row in report['rows'] if row['user']['id'] == user_id), None)
|
||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(404, 'This Magent account no longer exists. Run the check again.')
|
raise HTTPException(404, 'This Magent account no longer exists. Run the check again.')
|
||||||
group = [row for row in report['rows'] if review.name_key(row['user']['username']) == review.name_key(target['user']['username'])]
|
group = identity_group(report, target)
|
||||||
ids = {row['user']['id'] for row in group}
|
ids = {row['user']['id'] for row in group}
|
||||||
if len(ids) < 2:
|
if len(ids) < 2:
|
||||||
raise HTTPException(409, 'No same-name duplicate group remains. Run the account check again.')
|
raise HTTPException(409, 'No duplicate identity group remains. Run the account check again.')
|
||||||
jf_id = target['candidate_jellyfin_id']
|
jf_id = target['candidate_jellyfin_id']
|
||||||
source = source_key(runtime.jellyfin_base_url)
|
source = source_key(runtime.jellyfin_base_url)
|
||||||
owned = {link['local_user_id'] for link in local['links'] if link['source'] == source and review.normalized_id(link['jellyfin_user_id']) == jf_id}
|
owned = {link['local_user_id'] for link in local['links'] if link['source'] == source and review.normalized_id(link['jellyfin_user_id']) == jf_id}
|
||||||
@@ -50,14 +55,14 @@ def build_preview(report, local, runtime, state, user_id, keep_id=None):
|
|||||||
problems.append('Restore all three media-service connections before consolidating accounts.')
|
problems.append('Restore all three media-service connections before consolidating accounts.')
|
||||||
if not target['jellyfin'] or target['jellystat']['state'] != 'matched' or len(target['seerr']) != 1:
|
if not target['jellyfin'] or target['jellystat']['state'] != 'matched' or len(target['seerr']) != 1:
|
||||||
problems.append('One Jellyfin identity and one Seerr account must be verified against Jellystat.')
|
problems.append('One Jellyfin identity and one Seerr account must be verified against Jellystat.')
|
||||||
if target['jellyfin'] and review.name_key(target['jellyfin']['name']) != review.name_key(target['user']['username']):
|
|
||||||
problems.append('The current Jellyfin name does not match this duplicate group.')
|
|
||||||
if len([account for account in report['jellyfin_users'] if review.name_key(account['name']) == review.name_key(target['user']['username'])]) != 1:
|
|
||||||
problems.append('The name must identify exactly one current Jellyfin account.')
|
|
||||||
seerr_id = target['seerr'][0]['id'] if len(target['seerr']) == 1 else None
|
seerr_id = target['seerr'][0]['id'] if len(target['seerr']) == 1 else None
|
||||||
for row in group:
|
for row in group:
|
||||||
if row['user']['role'] != 'user' or row['user']['auth_provider'] != 'jellyfin':
|
if row['basis'] not in {'confirmed_id', 'stored_jellyfin_id', 'stored_seerr_id'}:
|
||||||
problems.append('Only non-admin Jellyfin sign-in accounts can use duplicate consolidation.')
|
problems.append('Every account needs a stored Jellyfin or Seerr ID; names alone cannot authorize consolidation.')
|
||||||
|
if any('different accounts' in issue or 'multiple distinct Jellyfin' in issue for issue in row['issues']):
|
||||||
|
problems.append('A name and stored identity disagree. Resolve that mapping before consolidation.')
|
||||||
|
if row['user']['role'] != 'user' or row['user']['auth_provider'] not in {'jellyfin', 'jellyseerr'}:
|
||||||
|
problems.append('Only non-admin Jellyfin or Seerr accounts can use duplicate consolidation.')
|
||||||
if not jf_id or row['candidate_jellyfin_id'] != jf_id or row['user']['jellyseerr_user_id'] not in (None, seerr_id):
|
if not jf_id or row['candidate_jellyfin_id'] != jf_id or row['user']['jellyseerr_user_id'] not in (None, seerr_id):
|
||||||
problems.append('These rows do not all resolve to the same Jellyfin and Seerr identity.')
|
problems.append('These rows do not all resolve to the same Jellyfin and Seerr identity.')
|
||||||
for link in local['links']:
|
for link in local['links']:
|
||||||
@@ -76,7 +81,7 @@ def build_preview(report, local, runtime, state, user_id, keep_id=None):
|
|||||||
problems.append('Another confirmation owns this identity.')
|
problems.append('Another confirmation owns this identity.')
|
||||||
if any(row['user']['id'] not in ids and (row['candidate_jellyfin_id'] == jf_id or
|
if any(row['user']['id'] not in ids and (row['candidate_jellyfin_id'] == jf_id or
|
||||||
(seerr_id is not None and row['user']['jellyseerr_user_id'] == seerr_id)) for row in report['rows']):
|
(seerr_id is not None and row['user']['jellyseerr_user_id'] == seerr_id)) for row in report['rows']):
|
||||||
problems.append('An account outside this same-name group also claims the identity.')
|
problems.append('An account outside this identity group also claims the identity.')
|
||||||
accounts = [account for account in state['users'] if account['id'] in ids]
|
accounts = [account for account in state['users'] if account['id'] in ids]
|
||||||
kept = next(account for account in accounts if account['id'] == keep_id)
|
kept = next(account for account in accounts if account['id'] == keep_id)
|
||||||
overrides = {(entry['user_id'], entry['feature']): bool(entry['enabled']) for entry in state['user_feature_permissions']}
|
overrides = {(entry['user_id'], entry['feature']): bool(entry['enabled']) for entry in state['user_feature_permissions']}
|
||||||
@@ -105,7 +110,8 @@ async def prepare(user_id, keep_id=None):
|
|||||||
target = next((row for row in local['users'] if row['id'] == user_id), None)
|
target = next((row for row in local['users'] if row['id'] == user_id), None)
|
||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(404, 'Account not found.')
|
raise HTTPException(404, 'Account not found.')
|
||||||
ids = sorted(row['id'] for row in local['users'] if review.name_key(row['username']) == review.name_key(target['username']))
|
report_target = next(row for row in report['rows'] if row['user']['id'] == user_id)
|
||||||
|
ids = sorted(row['user']['id'] for row in identity_group(report, report_target))
|
||||||
with closing(db._connect()) as conn:
|
with closing(db._connect()) as conn:
|
||||||
conn.execute('BEGIN')
|
conn.execute('BEGIN')
|
||||||
if review.digest(review.snapshot(conn)) != review.digest(local):
|
if review.digest(review.snapshot(conn)) != review.digest(local):
|
||||||
@@ -146,7 +152,7 @@ def consolidate(preview, report, local, runtime, state, admin):
|
|||||||
for name in old_values:
|
for name in old_values:
|
||||||
new_value = review.name_key(values['username']) if column == 'requested_by_norm' else values['username']
|
new_value = review.name_key(values['username']) if column == 'requested_by_norm' else values['username']
|
||||||
conn.execute(f'UPDATE {table} SET {column}=? WHERE {column}=? COLLATE BINARY', (new_value, name))
|
conn.execute(f'UPDATE {table} SET {column}=? WHERE {column}=? COLLATE BINARY', (new_value, name))
|
||||||
activity = [dict(row) for row in conn.execute('SELECT * FROM user_activity') if review.name_key(row['username']) == review.name_key(values['username'])]
|
activity = [dict(row) for row in conn.execute('SELECT * FROM user_activity') if row['username'] in names]
|
||||||
for entry in activity:
|
for entry in activity:
|
||||||
conn.execute('DELETE FROM user_activity WHERE id=?', (entry['id'],))
|
conn.execute('DELETE FROM user_activity WHERE id=?', (entry['id'],))
|
||||||
for entry in activity:
|
for entry in activity:
|
||||||
@@ -165,7 +171,7 @@ def consolidate(preview, report, local, runtime, state, admin):
|
|||||||
conn.execute('DELETE FROM user_identity_confirmations WHERE local_user_id=?', (identity,))
|
conn.execute('DELETE FROM user_identity_confirmations WHERE local_user_id=?', (identity,))
|
||||||
conn.execute('DELETE FROM users WHERE id=?', (identity,))
|
conn.execute('DELETE FROM users WHERE id=?', (identity,))
|
||||||
last_login = max((account['last_login_at'] for account in state['users'] if account['last_login_at']), default=None)
|
last_login = max((account['last_login_at'] for account in state['users'] if account['last_login_at']), default=None)
|
||||||
conn.execute('''UPDATE users SET username=?,jellyseerr_user_id=?,is_blocked=?,auto_search_enabled=?,
|
conn.execute('''UPDATE users SET auth_provider='jellyfin',username=?,jellyseerr_user_id=?,is_blocked=?,auto_search_enabled=?,
|
||||||
invite_management_enabled=?,expires_at=?,last_login_at=? WHERE id=?''',
|
invite_management_enabled=?,expires_at=?,last_login_at=? WHERE id=?''',
|
||||||
(values['username'], values['seerr_user_id'], values['is_blocked'], values['auto_search_enabled'],
|
(values['username'], values['seerr_user_id'], values['is_blocked'], values['auto_search_enabled'],
|
||||||
values['features']['invites'], values['expires_at'], last_login, keep))
|
values['features']['invites'], values['expires_at'], last_login, keep))
|
||||||
|
|||||||
@@ -36,3 +36,16 @@ def link_user(username: str, jellyfin_user_id: str, base_url: str | None) -> Non
|
|||||||
"INSERT OR IGNORE INTO jellyfin_user_links (source, local_user_id, jellyfin_user_id) VALUES (?, ?, ?)",
|
"INSERT OR IGNORE INTO jellyfin_user_links (source, local_user_id, jellyfin_user_id) VALUES (?, ?, ?)",
|
||||||
(source_key(base_url), user["id"], str(jellyfin_user_id)),
|
(source_key(base_url), user["id"], str(jellyfin_user_id)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def user_for_identity(jellyfin_user_id: str, base_url: str | None):
|
||||||
|
"""Resolve a verified upstream login to its existing local account."""
|
||||||
|
if not jellyfin_user_id or not base_url:
|
||||||
|
return None
|
||||||
|
with closing(db._connect()) as conn:
|
||||||
|
rows = conn.execute("SELECT local_user_id FROM jellyfin_user_links WHERE source=? AND lower(replace(jellyfin_user_id,'-',''))=?",
|
||||||
|
(source_key(base_url), str(jellyfin_user_id).replace('-', '').lower())).fetchall()
|
||||||
|
if len(rows) > 1:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(409, 'Multiple accounts claim this Jellyfin ID. Ask an administrator to repair the links.')
|
||||||
|
return db.get_user_by_id(rows[0][0]) if rows else None
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from collections import Counter
|
||||||
|
from contextlib import closing
|
||||||
|
from .. import db
|
||||||
|
from .jellyfin_identity import source_key
|
||||||
|
from .identity_review import normalized_id, name_key
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
@@ -6,18 +11,14 @@ from ..clients.jellyfin import JellyfinClient
|
|||||||
from ..db import (
|
from ..db import (
|
||||||
create_user_if_missing,
|
create_user_if_missing,
|
||||||
get_user_by_username,
|
get_user_by_username,
|
||||||
set_user_email,
|
|
||||||
set_user_auth_provider,
|
set_user_auth_provider,
|
||||||
set_user_jellyseerr_id,
|
set_user_jellyseerr_id,
|
||||||
)
|
)
|
||||||
from ..runtime import get_runtime_settings
|
from ..runtime import get_runtime_settings
|
||||||
from .jellyfin_identity import link_user
|
from .jellyfin_identity import link_user
|
||||||
from .user_cache import (
|
from .user_cache import (
|
||||||
build_jellyseerr_candidate_map,
|
|
||||||
extract_jellyseerr_user_email,
|
extract_jellyseerr_user_email,
|
||||||
find_matching_jellyseerr_user,
|
|
||||||
get_cached_jellyseerr_users,
|
get_cached_jellyseerr_users,
|
||||||
match_jellyseerr_user_id,
|
|
||||||
save_jellyfin_users_cache,
|
save_jellyfin_users_cache,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,43 +37,52 @@ async def sync_jellyfin_users() -> int:
|
|||||||
# Jellyfin is the canonical source for local user objects; Seerr IDs are
|
# Jellyfin is the canonical source for local user objects; Seerr IDs are
|
||||||
# matched as enrichment when possible.
|
# matched as enrichment when possible.
|
||||||
jellyseerr_users = get_cached_jellyseerr_users()
|
jellyseerr_users = get_cached_jellyseerr_users()
|
||||||
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
|
|
||||||
imported = 0
|
imported = 0
|
||||||
|
name_counts = Counter(name_key(row.get('Name')) for row in users if isinstance(row, dict))
|
||||||
|
with closing(db._connect()) as conn:
|
||||||
|
links = [dict(zip(('local_id', 'jf_id'), row)) for row in conn.execute(
|
||||||
|
'SELECT local_user_id,jellyfin_user_id FROM jellyfin_user_links WHERE source=?', (source_key(runtime.jellyfin_base_url),))]
|
||||||
for user in users:
|
for user in users:
|
||||||
if not isinstance(user, dict):
|
if not isinstance(user, dict):
|
||||||
continue
|
continue
|
||||||
name = user.get("Name")
|
name, jf_id = user.get('Name'), normalized_id(user.get('Id'))
|
||||||
if not name:
|
if not name or not jf_id or name_counts[name_key(name)] != 1:
|
||||||
continue
|
continue
|
||||||
matched_id = match_jellyseerr_user_id(name, candidate_map) if candidate_map else None
|
matches = [row for row in (jellyseerr_users or []) if normalized_id(row.get('jellyfinUserId')) == jf_id]
|
||||||
matched_seerr_user = find_matching_jellyseerr_user(name, jellyseerr_users or [])
|
if len(matches) > 1:
|
||||||
matched_email = extract_jellyseerr_user_email(matched_seerr_user)
|
continue
|
||||||
created = create_user_if_missing(
|
matched = matches[0] if matches else None
|
||||||
name,
|
matched_id = matched.get('id') if matched else None
|
||||||
"jellyfin-user",
|
owners = [row['local_id'] for row in links if normalized_id(row['jf_id']) == jf_id]
|
||||||
role="user",
|
if len(owners) > 1:
|
||||||
email=matched_email,
|
continue
|
||||||
auth_provider="jellyfin",
|
existing = db.get_user_by_id(owners[0]) if owners else None
|
||||||
jellyseerr_user_id=matched_id,
|
if not existing and matched_id is not None:
|
||||||
)
|
candidates = [row for row in db.get_all_users() if row.get('jellyseerr_user_id') == matched_id]
|
||||||
if created:
|
if len(candidates) > 1:
|
||||||
imported += 1
|
continue
|
||||||
else:
|
existing = candidates[0] if candidates else None
|
||||||
|
if not existing:
|
||||||
existing = get_user_by_username(name)
|
existing = get_user_by_username(name)
|
||||||
if (
|
if existing:
|
||||||
existing
|
existing_links = [normalized_id(row['jf_id']) for row in links if row['local_id'] == existing['id']]
|
||||||
and str(existing.get("role") or "user").strip().lower() != "admin"
|
if existing_links and any(value != jf_id for value in existing_links):
|
||||||
and str(existing.get("auth_provider") or "local").strip().lower() != "jellyfin"
|
continue
|
||||||
):
|
if existing.get('role') == 'admin' or existing.get('auth_provider') == 'local':
|
||||||
set_user_auth_provider(name, "jellyfin")
|
continue
|
||||||
if matched_id is not None:
|
canonical = existing['username']
|
||||||
set_user_jellyseerr_id(name, matched_id)
|
# Never overwrite a stored Seerr identity on name evidence.
|
||||||
if matched_email:
|
if existing.get('jellyseerr_user_id') not in (None, matched_id):
|
||||||
set_user_email(name, matched_email)
|
continue
|
||||||
if user.get("Id"):
|
set_user_auth_provider(canonical, 'jellyfin')
|
||||||
local_user = get_user_by_username(name)
|
else:
|
||||||
if local_user and local_user.get("auth_provider") == "jellyfin":
|
canonical = name
|
||||||
link_user(name, str(user["Id"]), runtime.jellyfin_base_url)
|
if create_user_if_missing(canonical, 'jellyfin-user', auth_provider='jellyfin',
|
||||||
|
jellyseerr_user_id=matched_id, email=extract_jellyseerr_user_email(matched)):
|
||||||
|
imported += 1
|
||||||
|
if matched_id is not None:
|
||||||
|
set_user_jellyseerr_id(canonical, matched_id)
|
||||||
|
link_user(canonical, jf_id, runtime.jellyfin_base_url)
|
||||||
return imported
|
return imported
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -58,3 +58,72 @@ async def original_profile(client, default_id):
|
|||||||
if not isinstance(result, dict) or not isinstance(result.get("id"), int):
|
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.")
|
raise HTTPException(502, "Radarr could not prepare the original-language profile. Try again.")
|
||||||
return result["id"]
|
return result["id"]
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_original_to_movie(client, tmdb_id):
|
||||||
|
movies = await client.get_movie_by_tmdb_id(tmdb_id)
|
||||||
|
if not isinstance(movies, list):
|
||||||
|
raise HTTPException(502, "Radarr did not return the movie list.")
|
||||||
|
matches = [movie for movie in movies if movie.get('tmdbId') == tmdb_id]
|
||||||
|
if not matches:
|
||||||
|
return None
|
||||||
|
if len(matches) != 1:
|
||||||
|
raise HTTPException(409, "Radarr returned multiple movies for this identity.")
|
||||||
|
movie = matches[0]
|
||||||
|
profile_id = await original_profile(client, movie['qualityProfileId'])
|
||||||
|
if movie['qualityProfileId'] != profile_id:
|
||||||
|
movie['qualityProfileId'] = profile_id
|
||||||
|
await client.update_movie(movie)
|
||||||
|
verified = await client.get_movie(movie['id'])
|
||||||
|
if not verified or verified.get('qualityProfileId') != profile_id:
|
||||||
|
raise HTTPException(502, "Radarr did not save the original-language choice. Try again before searching.")
|
||||||
|
return profile_id
|
||||||
|
|
||||||
|
|
||||||
|
async def movie_search_outcome(client, movie_id, command, attempts=12, delay=2):
|
||||||
|
command_id = command.get('id') if isinstance(command, dict) else None
|
||||||
|
if not isinstance(command_id, int):
|
||||||
|
return {'status': 'searching', 'message': 'Search submitted; download confirmation is not available yet. Recheck the request shortly.'}
|
||||||
|
for attempt in range(attempts):
|
||||||
|
state = await client.get(f'/api/v3/command/{command_id}')
|
||||||
|
status = str((state or {}).get('status', '')).lower()
|
||||||
|
queue = await client.get_queue(movie_id)
|
||||||
|
records = queue.get('records', []) if isinstance(queue, dict) else queue or []
|
||||||
|
matching = [item for item in records if item.get('movieId') == movie_id]
|
||||||
|
if any(item.get('trackedDownloadStatus') in {'warning', 'error'} for item in matching):
|
||||||
|
return {'status': 'attention', 'message': 'Radarr found a download, but it reports a download or import problem. Open the pipeline details to review it.'}
|
||||||
|
if matching:
|
||||||
|
return {'status': 'downloading', 'message': 'Radarr has a download queued for this movie. The pipeline will track its progress.'}
|
||||||
|
if status in {'failed', 'aborted', 'cancelled'}:
|
||||||
|
return {'status': 'attention', 'message': 'Radarr could not complete the search. Check service health or try Search and choose a download.'}
|
||||||
|
if status == 'completed':
|
||||||
|
movie = await client.get_movie(movie_id)
|
||||||
|
if (movie or {}).get('hasFile'):
|
||||||
|
return {'status': 'complete', 'message': 'Radarr already has the movie file. Recheck the request for Jellyfin availability.'}
|
||||||
|
return {'status': 'attention', 'message': 'Search finished, but no download appeared in Radarr. Use Search and choose a download to see matching releases and rejection reasons. For a foreign-language title, review the audio choice above.'}
|
||||||
|
if attempt + 1 < attempts:
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
return {'status': 'searching', 'message': 'Radarr is still searching. No download is confirmed yet; the pipeline will keep checking. You can close this window.'}
|
||||||
|
|
||||||
|
|
||||||
|
async def series_search_outcome(client, series_id, commands, attempts=12, delay=2):
|
||||||
|
ids = [item.get('id') for item in commands if isinstance(item, dict) and isinstance(item.get('id'), int)]
|
||||||
|
if not ids:
|
||||||
|
return {'status': 'searching', 'message': 'Search submitted to Sonarr; no download is confirmed yet. Recheck the pipeline shortly.'}
|
||||||
|
for attempt in range(attempts):
|
||||||
|
states = await asyncio.gather(*(client.get(f'/api/v3/command/{identity}') for identity in ids))
|
||||||
|
queue = await client.get_queue(series_id)
|
||||||
|
records = queue.get('records', []) if isinstance(queue, dict) else queue or []
|
||||||
|
matching = [item for item in records if item.get('seriesId') == series_id]
|
||||||
|
if any(item.get('trackedDownloadStatus') in {'warning', 'error'} for item in matching):
|
||||||
|
return {'status': 'attention', 'message': 'Sonarr has a download with a reported problem. Review the pipeline details.'}
|
||||||
|
if matching:
|
||||||
|
return {'status': 'downloading', 'message': 'Sonarr has downloads queued for this show. The pipeline will track their progress.'}
|
||||||
|
statuses = {str((state or {}).get('status', '')).lower() for state in states}
|
||||||
|
if statuses & {'failed', 'aborted', 'cancelled'}:
|
||||||
|
return {'status': 'attention', 'message': 'A Sonarr search failed. Check the service or try Search and choose a download.'}
|
||||||
|
if statuses == {'completed'}:
|
||||||
|
return {'status': 'attention', 'message': 'Sonarr finished searching, but no download is visible yet. Use Search and choose a download to review available releases and rejection reasons.'}
|
||||||
|
if attempt + 1 < attempts:
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
return {'status': 'searching', 'message': 'Sonarr is still searching. No download is confirmed yet; you can close this window and follow the pipeline.'}
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ class DuplicateAccountTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase)
|
|||||||
with db._connect() as conn:
|
with db._connect() as conn:
|
||||||
conn.execute('UPDATE users SET jellyseerr_user_id=42 WHERE id=?', (self.extra,))
|
conn.execute('UPDATE users SET jellyseerr_user_id=42 WHERE id=?', (self.extra,))
|
||||||
db.create_user('Other', 'Password-123456!', auth_provider='jellyfin', jellyseerr_user_id=42)
|
db.create_user('Other', 'Password-123456!', auth_provider='jellyfin', jellyseerr_user_id=42)
|
||||||
|
self.jf['users'].append({'id': 'd' * 32, 'name': 'Other'})
|
||||||
self.assertFalse((await duplicates.repair_duplicates(self.keep))['can_confirm'])
|
self.assertFalse((await duplicates.repair_duplicates(self.keep))['can_confirm'])
|
||||||
|
|
||||||
async def test_transaction_rolls_back_archive_and_history_on_failure(self):
|
async def test_transaction_rolls_back_archive_and_history_on_failure(self):
|
||||||
@@ -166,3 +167,16 @@ class DuplicateAccountTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase)
|
|||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
for path in ('check', 'confirm'):
|
for path in ('check', 'confirm'):
|
||||||
self.assertEqual(client.post('/admin/identities/duplicates/' + path, json={'user_id': self.keep}).status_code, 403)
|
self.assertEqual(client.post('/admin/identities/duplicates/' + path, json={'user_id': self.keep}).status_code, 403)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_email_alias_consolidates_by_verified_id_and_preserves_activity(self):
|
||||||
|
with db._connect() as conn:
|
||||||
|
conn.execute("UPDATE users SET username='old@example.test',auth_provider='jellyseerr' WHERE id=?", (self.extra,))
|
||||||
|
db.upsert_user_activity('old@example.test', '127.0.0.1', 'browser')
|
||||||
|
preview = await duplicates.repair_duplicates(self.keep)
|
||||||
|
self.assertTrue(preview['can_confirm'], preview['issues'])
|
||||||
|
await duplicates.repair_duplicates(self.keep, self.keep, preview['revision'], {'username': 'admin'})
|
||||||
|
self.assertIsNone(db.get_user_by_id(self.extra))
|
||||||
|
with db._connect() as conn:
|
||||||
|
self.assertEqual(conn.execute('SELECT username FROM user_activity').fetchone()[0], 'Viewer')
|
||||||
|
self.assertFalse(db.create_user_if_missing('new-alias@example.test', 'unused', auth_provider='jellyseerr', jellyseerr_user_id=42))
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from backend.app import db
|
||||||
|
from backend.app.services import jellyfin_sync
|
||||||
|
from backend.app.services.jellyfin_identity import link_user, user_for_identity
|
||||||
|
from backend.app.routers import admin
|
||||||
|
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||||
|
|
||||||
|
|
||||||
|
class IdentitySyncTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_sync_reuses_id_when_names_differ_and_preserves_settings(self):
|
||||||
|
db.create_user('old@example.test', 'Password-123456!', auth_provider='jellyseerr', jellyseerr_user_id=42,
|
||||||
|
auto_search_enabled=False, email='kept@example.test')
|
||||||
|
original = db.get_user_by_username('old@example.test')
|
||||||
|
runtime = SimpleNamespace(jellyfin_base_url='http://jf', jellyfin_api_key='test')
|
||||||
|
jf = SimpleNamespace(configured=lambda: True, get_users=AsyncMock(return_value=[{'Id': 'a' * 32, 'Name': 'NewName'}]))
|
||||||
|
with patch.object(jellyfin_sync, 'get_runtime_settings', return_value=runtime), \
|
||||||
|
patch.object(jellyfin_sync, 'JellyfinClient', return_value=jf), \
|
||||||
|
patch.object(jellyfin_sync, 'get_cached_jellyseerr_users', return_value=[{'id': 42, 'jellyfinUserId': 'a' * 32, 'email': 'upstream@example.test'}]), \
|
||||||
|
patch.object(jellyfin_sync, 'save_jellyfin_users_cache'):
|
||||||
|
self.assertEqual(await jellyfin_sync.sync_jellyfin_users(), 0)
|
||||||
|
self.assertEqual(await jellyfin_sync.sync_jellyfin_users(), 0)
|
||||||
|
kept = user_for_identity('a' * 32, 'http://jf')
|
||||||
|
self.assertEqual(kept['id'], original['id'])
|
||||||
|
self.assertFalse(kept['auto_search_enabled'])
|
||||||
|
self.assertEqual(kept['email'], 'kept@example.test')
|
||||||
|
self.assertIsNone(db.get_user_by_username('NewName'))
|
||||||
|
self.assertEqual(kept['auth_provider'], 'jellyfin')
|
||||||
|
|
||||||
|
async def test_resync_no_longer_deletes_accounts(self):
|
||||||
|
db.create_user('Keep', 'Password-123456!')
|
||||||
|
runtime = SimpleNamespace(jellyseerr_base_url='http://seer', jellyseerr_api_key='test')
|
||||||
|
with patch.object(admin, 'get_runtime_settings', return_value=runtime), \
|
||||||
|
patch.object(admin, '_fetch_all_jellyseerr_users', new=AsyncMock(return_value=[{'id': 42}])), \
|
||||||
|
patch.object(jellyfin_sync, 'sync_jellyfin_users', new=AsyncMock(return_value=0)), \
|
||||||
|
patch.object(admin, 'delete_non_admin_users') as delete:
|
||||||
|
result = await admin.jellyseerr_users_resync()
|
||||||
|
self.assertEqual(result['cleared'], 0)
|
||||||
|
delete.assert_not_called()
|
||||||
|
self.assertIsNotNone(db.get_user_by_username('Keep'))
|
||||||
|
|
||||||
|
def test_jellyfin_lookup_is_scoped_to_server(self):
|
||||||
|
db.create_user('Viewer', 'Password-123456!', auth_provider='jellyfin')
|
||||||
|
link_user('Viewer', 'a' * 32, 'http://jf')
|
||||||
|
self.assertIsNotNone(user_for_identity('a' * 32, 'http://jf'))
|
||||||
|
self.assertIsNone(user_for_identity('a' * 32, 'http://other-server'))
|
||||||
@@ -4,7 +4,7 @@ from types import SimpleNamespace
|
|||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from backend.app.services.request_language import language_info, original_profile, is_original_profile
|
from backend.app.services.request_language import language_info, original_profile, is_original_profile, apply_original_to_movie, movie_search_outcome
|
||||||
from backend.app.routers import requests
|
from backend.app.routers import requests
|
||||||
|
|
||||||
|
|
||||||
@@ -54,6 +54,7 @@ class RequestLanguageTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
with patch.object(requests, 'get_runtime_settings', return_value=runtime), \
|
with patch.object(requests, 'get_runtime_settings', return_value=runtime), \
|
||||||
patch.object(requests, 'JellyseerrClient', return_value=seerr), \
|
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, '_resolve_request_destination', new=AsyncMock(return_value={'server_id': 1, 'profile_id': 6, 'root_folder': '/media'})), \
|
||||||
|
patch.object(requests, 'apply_original_to_movie', new=AsyncMock(return_value=None)), \
|
||||||
patch.object(requests, 'original_profile', new=AsyncMock(return_value=20)) as clone:
|
patch.object(requests, 'original_profile', new=AsyncMock(return_value=20)) as clone:
|
||||||
payload = {'mediaType': media_type, 'tmdbId': 1417, 'acceptOriginalLanguage': consent, 'seasons': [1]}
|
payload = {'mediaType': media_type, 'tmdbId': 1417, 'acceptOriginalLanguage': consent, 'seasons': [1]}
|
||||||
if expected is None:
|
if expected is None:
|
||||||
@@ -65,3 +66,54 @@ class RequestLanguageTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
await requests.create_request(payload, {'username': 'viewer'})
|
await requests.create_request(payload, {'username': 'viewer'})
|
||||||
self.assertEqual(seerr.create_request.await_args.kwargs['profile_id'], expected)
|
self.assertEqual(seerr.create_request.await_args.kwargs['profile_id'], expected)
|
||||||
self.assertEqual(clone.await_count, int(expected == 20))
|
self.assertEqual(clone.await_count, int(expected == 20))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_existing_radarr_movie_is_updated_and_read_back(self):
|
||||||
|
movie = {'id': 6940, 'tmdbId': 613, 'qualityProfileId': 9, 'monitored': True}
|
||||||
|
client = SimpleNamespace(get_movie_by_tmdb_id=AsyncMock(return_value=[movie]),
|
||||||
|
update_movie=AsyncMock(), get_movie=AsyncMock(return_value={**movie, 'qualityProfileId': 20}))
|
||||||
|
with patch('backend.app.services.request_language.original_profile', new=AsyncMock(return_value=20)):
|
||||||
|
self.assertEqual(await apply_original_to_movie(client, 613), 20)
|
||||||
|
self.assertEqual(client.update_movie.await_args.args[0]['qualityProfileId'], 20)
|
||||||
|
self.assertTrue(client.update_movie.await_args.args[0]['monitored'])
|
||||||
|
|
||||||
|
async def test_failed_profile_verification_does_not_claim_success(self):
|
||||||
|
client = SimpleNamespace(get_movie_by_tmdb_id=AsyncMock(return_value=[{'id': 6940, 'tmdbId': 613, 'qualityProfileId': 9}]),
|
||||||
|
update_movie=AsyncMock(), get_movie=AsyncMock(return_value={'qualityProfileId': 9}))
|
||||||
|
with patch('backend.app.services.request_language.original_profile', new=AsyncMock(return_value=20)):
|
||||||
|
with self.assertRaises(HTTPException):
|
||||||
|
await apply_original_to_movie(client, 613)
|
||||||
|
|
||||||
|
async def test_search_reports_real_outcomes(self):
|
||||||
|
for command_status, queue, expected in [('completed', [], 'attention'), ('failed', [], 'attention'),
|
||||||
|
('started', [], 'searching'), ('completed', [{'movieId': 6940}], 'downloading')]:
|
||||||
|
client = SimpleNamespace(get=AsyncMock(return_value={'status': command_status}),
|
||||||
|
get_queue=AsyncMock(return_value={'records': queue}), get_movie=AsyncMock(return_value={'hasFile': False}))
|
||||||
|
result = await movie_search_outcome(client, 6940, {'id': 1}, attempts=1, delay=0)
|
||||||
|
self.assertEqual(result['status'], expected)
|
||||||
|
|
||||||
|
async def test_language_endpoint_checks_consent_identity_and_access(self):
|
||||||
|
for payload in ({}, {'acceptOriginalLanguage': 'true'}, {'acceptOriginalLanguage': True, 'languageCode': 'es'}):
|
||||||
|
with patch.object(requests, '_request_language_context', new=AsyncMock(return_value=(SimpleNamespace(), 613, {'code': 'de'}))), \
|
||||||
|
patch.object(requests, 'apply_original_to_movie', new=AsyncMock()) as apply:
|
||||||
|
with self.assertRaises(HTTPException):
|
||||||
|
await requests.accept_request_language('3976', payload, {'role': 'admin'})
|
||||||
|
apply.assert_not_awaited()
|
||||||
|
with self.assertRaises(HTTPException):
|
||||||
|
await requests.accept_request_language('3976', {'acceptOriginalLanguage': True}, {'role': 'user', 'auto_search_enabled': False})
|
||||||
|
|
||||||
|
|
||||||
|
async def test_radarr_queue_filters_before_pagination(self):
|
||||||
|
from backend.app.clients.radarr import RadarrClient
|
||||||
|
client = RadarrClient('http://radarr.test', 'test')
|
||||||
|
with patch.object(client, 'get', new=AsyncMock(return_value={'records': []})) as get:
|
||||||
|
await client.get_queue(6940)
|
||||||
|
get.assert_awaited_once_with('/api/v3/queue', params={'movieIds': 6940, 'pageSize': 1000})
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tv_search_distinguishes_no_download_and_queue(self):
|
||||||
|
from backend.app.services.request_language import series_search_outcome
|
||||||
|
client = SimpleNamespace(get=AsyncMock(return_value={'status': 'completed'}), get_queue=AsyncMock(return_value={'records': []}))
|
||||||
|
self.assertEqual((await series_search_outcome(client, 50, [{'id': 1}], attempts=1))['status'], 'attention')
|
||||||
|
client.get_queue.return_value = {'records': [{'seriesId': 50}]}
|
||||||
|
self.assertEqual((await series_search_outcome(client, 50, [{'id': 1}], attempts=1))['status'], 'downloading')
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Duplicate account repair
|
# Duplicate account repair
|
||||||
|
|
||||||
Open **Configuration → User management → Account links & repairs**, run **Check all user IDs**, then choose **Repair duplicate accounts** on a same-name conflict. An individual user's management overlay also links to this view with their username prefilled.
|
Open **Configuration → User management → Account links & repairs**, run **Check all user IDs**, then choose **Repair duplicate accounts** on a shared-ID conflict. An individual user's management overlay also links to this view with their username prefilled.
|
||||||
|
|
||||||
The preview recommends the Magent row that already owns the Jellyfin link, or the oldest row if none does. Administrators can select a different row from the group. Confirmation requires an explicit acknowledgement that the rows belong to the same person.
|
The preview recommends the Magent row that already owns the Jellyfin link, or the oldest row if none does. Administrators can select a different row from the group. Confirmation requires an explicit acknowledgement that the rows belong to the same person.
|
||||||
|
|
||||||
Eligibility requires a single current Jellyfin account for the normalized name, one Seerr account mapped to that Jellyfin ID, and the same ID verified in Jellystat. Every member must be a non-admin Jellyfin sign-in account resolving to that identity. Different stored IDs, other servers, orphaned reservations, ownership outside the group, and unavailable services block repair. Similar names alone are insufficient.
|
Eligibility requires a single current Jellyfin ID, one Seerr account mapped to that Jellyfin ID, and the same ID verified in Jellystat. Every member must be a non-admin Jellyfin or Seerr sign-in account resolving to that identity. Different stored IDs, other servers, orphaned reservations, ownership outside the group, and unavailable services block repair. Similar names alone are insufficient.
|
||||||
|
|
||||||
The transaction:
|
The transaction:
|
||||||
|
|
||||||
@@ -15,8 +15,10 @@ The transaction:
|
|||||||
- Retains email delivery history, cancels outstanding deliveries from retired rows, and does not inherit their subscriptions. The retained account's own subscriptions remain subject to the normal identity and access checks. Sending emails block repair until they finish.
|
- Retains email delivery history, cancels outstanding deliveries from retired rows, and does not inherit their subscriptions. The retained account's own subscriptions remain subject to the normal identity and access checks. Sending emails block repair until they finish.
|
||||||
- Invalidates existing password-reset links, removes the extra active Magent rows, and confirms the retained account's verified service links. Affected users may need to sign in again.
|
- Invalidates existing password-reset links, removes the extra active Magent rows, and confirms the retained account's verified service links. Affected users may need to sign in again.
|
||||||
|
|
||||||
Jellyfin, Seerr and Jellystat accounts, media and upstream history are not modified. There is no automatic bulk merge or self-service undo. The archive supports administrative investigation; unrelated or renamed identities require separate review.
|
Jellyfin, Seerr and Jellystat accounts, media and upstream history are not modified. There is no unattended bulk merge or self-service undo. An explicitly authorized operator can use `scripts/reconcile_verified_accounts.py --apply --output <new-private-directory>`; it takes a SQLite backup and archives each repair. Without `--apply` it previews only. The archive supports administrative investigation; conflicting identities require separate review.
|
||||||
|
|
||||||
Both preview and confirmation recheck live service mappings. A transaction rechecks local identity state, permissions, subscriptions and connection settings before writing. Stale previews fail with HTTP 409. Account creation/import checks normalized usernames under a SQLite write lock to prevent concurrent case/whitespace duplicates from recurring.
|
Both preview and confirmation recheck live service mappings. A transaction rechecks local identity state, permissions, subscriptions and connection settings before writing. Stale previews fail with HTTP 409. Account creation/import checks normalized usernames under a SQLite write lock to prevent concurrent case/whitespace duplicates from recurring.
|
||||||
|
|
||||||
Validation: temporary-database tests cover history, permissions, consent, rollback, concurrent creation, stale previews and ownership conflicts. `scripts/review_duplicate_accounts_ui.cjs` checks desktop/mobile UI and confirmation using intercepted API fixtures only.
|
Validation: temporary-database tests cover history, permissions, consent, rollback, concurrent creation, stale previews and ownership conflicts. `scripts/review_duplicate_accounts_ui.cjs` checks desktop/mobile UI and confirmation using intercepted API fixtures only.
|
||||||
|
|
||||||
|
Seerr sync/resync now reconciles against Jellyfin IDs without deleting the directory. Daily imports and verified login reuse linked accounts; account creation also guards against duplicate Seerr IDs.
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ Users can leave the normal request settings or explicitly accept original-langua
|
|||||||
|
|
||||||
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.
|
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.
|
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 show a prominent audio panel above the pipeline. **Use <language> audio & search** explicitly updates and reads back the existing Radarr movie profile before searching. Seerr only permits editing pending requests, so an approved request retains its historical Seerr profile field; the live Radarr profile is authoritative for collection.
|
||||||
|
|
||||||
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.
|
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).
|
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.
|
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.
|
||||||
|
|
||||||
|
Manual actions automatically open their progress dialog. The final response distinguishes a queued download, a completed search with no observed download, a failed search and a search still running. Interactive searches expose rejection reasons. Radarr queue reads use the supported `movieIds` filter before pagination, preventing unrelated first-page records from hiding the actual download.
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export default function IdentityReviewPanel() {
|
|||||||
<div><dt>Jellystat user ID</dt><dd><code>{row.jellystat.id ?? 'Not verified'}</code><span>{statsLabels[row.jellystat.state] ?? row.jellystat.state}</span></dd></div>
|
<div><dt>Jellystat user ID</dt><dd><code>{row.jellystat.id ?? 'Not verified'}</code><span>{statsLabels[row.jellystat.state] ?? row.jellystat.state}</span></dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
{row.issues.length > 0 && <ul className="identity-issues">{row.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul>}
|
{row.issues.length > 0 && <ul className="identity-issues">{row.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul>}
|
||||||
{(row.state === 'unlinked' || row.state === 'conflict') && <div className="identity-resolution-entry"><p className="identity-meta">Compare the correct Jellyfin identity with the stored links and review the smallest safe repair.</p><button type="button" className="ghost-button" disabled={saving || report.services.jellyfin !== 'available'} onClick={() => setResolving(row)}>Review repair</button>{row.issues.some((issue) => issue.includes("share this username")) && <button type="button" className="ghost-button" disabled={saving} onClick={() => setDuplicates(row)}>Repair duplicate accounts</button>}</div>}
|
{(row.state === 'unlinked' || row.state === 'conflict') && <div className="identity-resolution-entry"><p className="identity-meta">Compare the correct Jellyfin identity with the stored links and review the smallest safe repair.</p><button type="button" className="ghost-button" disabled={saving || report.services.jellyfin !== 'available'} onClick={() => setResolving(row)}>Review repair</button>{row.issues.some((issue) => issue.includes("resolve to this Jellyfin ID") || issue.includes("share this username")) && <button type="button" className="ghost-button" disabled={saving} onClick={() => setDuplicates(row)}>Repair duplicate accounts</button>}</div>}
|
||||||
{row.state === 'unavailable' && <p className="identity-meta">A required service could not be checked. Check its connection and run this again.</p>}
|
{row.state === 'unavailable' && <p className="identity-meta">A required service could not be checked. Check its connection and run this again.</p>}
|
||||||
{row.confirmed_at && <p className="identity-meta">Last confirmed {new Date(row.confirmed_at).toLocaleString()}</p>}
|
{row.confirmed_at && <p className="identity-meta">Last confirmed {new Date(row.confirmed_at).toLocaleString()}</p>}
|
||||||
</article>)}
|
</article>)}
|
||||||
|
|||||||
@@ -7602,4 +7602,4 @@ textarea {
|
|||||||
.request-language-notice h3, .request-language-notice p { margin: 0; }
|
.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 p, .request-language-notice small { line-height: 1.6; }
|
||||||
.request-language-notice label { display: flex; align-items: flex-start; gap: 10px; }
|
.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; }
|
.request-language-notice input[type=checkbox], .request-language-notice input[type=radio] { flex: 0 0 20px; width: 20px; height: 20px; margin-top: 2px; }
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
let locks = 0
|
||||||
|
let previous = ''
|
||||||
|
|
||||||
|
export function lockBodyScroll() {
|
||||||
|
if (locks++ === 0) {
|
||||||
|
previous = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
}
|
||||||
|
let released = false
|
||||||
|
return () => {
|
||||||
|
if (released) return
|
||||||
|
released = true
|
||||||
|
if (--locks === 0) document.body.style.overflow = previous
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,7 +116,7 @@ export default function NewRequestClient() {
|
|||||||
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 [selectedSeasons, setSelectedSeasons] = useState<number[]>([])
|
const [selectedSeasons, setSelectedSeasons] = useState<number[]>([])
|
||||||
const [acceptOriginalLanguage, setAcceptOriginalLanguage] = useState(false)
|
const [acceptOriginalLanguage, setAcceptOriginalLanguage] = useState<boolean | null>(null)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [operation, setOperation] = useState<OperationProgress | null>(null)
|
const [operation, setOperation] = useState<OperationProgress | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -134,7 +134,7 @@ export default function NewRequestClient() {
|
|||||||
const changeTitle = () => {
|
const changeTitle = () => {
|
||||||
setSelected(null)
|
setSelected(null)
|
||||||
setOptions(null)
|
setOptions(null)
|
||||||
setAcceptOriginalLanguage(false)
|
setAcceptOriginalLanguage(null)
|
||||||
setSelectedSeasons([])
|
setSelectedSeasons([])
|
||||||
setOperation(null)
|
setOperation(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -149,7 +149,7 @@ export default function NewRequestClient() {
|
|||||||
setSearchAttempted(false)
|
setSearchAttempted(false)
|
||||||
setSelected(null)
|
setSelected(null)
|
||||||
setOptions(null)
|
setOptions(null)
|
||||||
setAcceptOriginalLanguage(false)
|
setAcceptOriginalLanguage(null)
|
||||||
setSelectedSeasons([])
|
setSelectedSeasons([])
|
||||||
setOperation(null)
|
setOperation(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -169,7 +169,7 @@ export default function NewRequestClient() {
|
|||||||
setSearchAttempted(true)
|
setSearchAttempted(true)
|
||||||
setSelected(null)
|
setSelected(null)
|
||||||
setOptions(null)
|
setOptions(null)
|
||||||
setAcceptOriginalLanguage(false)
|
setAcceptOriginalLanguage(null)
|
||||||
setOperation(null)
|
setOperation(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
setSuccess(null)
|
setSuccess(null)
|
||||||
@@ -212,7 +212,7 @@ export default function NewRequestClient() {
|
|||||||
const selectResult = async (item: DiscoveryResult) => {
|
const selectResult = async (item: DiscoveryResult) => {
|
||||||
setSelected(item)
|
setSelected(item)
|
||||||
setOptions(null)
|
setOptions(null)
|
||||||
setAcceptOriginalLanguage(false)
|
setAcceptOriginalLanguage(null)
|
||||||
setSelectedSeasons([])
|
setSelectedSeasons([])
|
||||||
setOperation(null)
|
setOperation(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -270,6 +270,7 @@ export default function NewRequestClient() {
|
|||||||
|
|
||||||
const submitRequest = async () => {
|
const submitRequest = async () => {
|
||||||
if (!selected || !options) return
|
if (!selected || !options) return
|
||||||
|
if (options.media.originalLanguage && acceptOriginalLanguage === null) { setError('Choose an audio language option before requesting.'); 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
|
||||||
@@ -291,7 +292,7 @@ export default function NewRequestClient() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
mediaType: selected.type,
|
mediaType: selected.type,
|
||||||
tmdbId: selected.tmdbId,
|
tmdbId: selected.tmdbId,
|
||||||
acceptOriginalLanguage,
|
acceptOriginalLanguage: acceptOriginalLanguage === true,
|
||||||
seasons: selected.type === 'tv' ? selectedSeasons : undefined,
|
seasons: selected.type === 'tv' ? selectedSeasons : undefined,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -497,14 +498,15 @@ export default function NewRequestClient() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{options.media.originalLanguage && <div className="request-language-notice">
|
{options.media.originalLanguage && <div className="request-language-notice">
|
||||||
<h3>Check the audio language</h3>
|
<h3>Choose your 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>
|
<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>
|
<label><input type="radio" name="request-audio" checked={acceptOriginalLanguage === true} onChange={() => setAcceptOriginalLanguage(true)} disabled={submitting} /><span>Original {new Intl.DisplayNames(['en'], { type: 'language' }).of(options.media.originalLanguage.code)} audio — 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>
|
<label><input type="radio" name="request-audio" checked={acceptOriginalLanguage === false} onChange={() => setAcceptOriginalLanguage(false)} disabled={submitting} /><span>Keep standard audio requirements. This title may remain waiting for an English release.</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.') : 'Choose an option to continue. An English-only profile may leave this title waiting for a suitable release.'}</small>
|
||||||
</div>}
|
</div>}
|
||||||
<div className="request-submit-bar">
|
<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>
|
<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)}>
|
<button type="button" onClick={() => void submitRequest()} disabled={submitting || (Boolean(options.media.originalLanguage) && acceptOriginalLanguage === null) || (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>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import './latest-activity.css'
|
import './latest-activity.css'
|
||||||
|
import { lockBodyScroll } from '../../lib/scrollLock'
|
||||||
|
|
||||||
type Event = { id: string; service: string; state: string; message: string; started_at?: string; finished_at?: string }
|
type Event = { id: string; service: string; state: string; message: string; started_at?: string; finished_at?: string }
|
||||||
type Operation = { id: string; label: string; status: string; events: Event[] }
|
type Operation = { id: string; label: string; status: string; events: Event[] }
|
||||||
@@ -11,21 +12,21 @@ export default function LatestActivity({ operation, besideDownload, onDismiss }:
|
|||||||
}) {
|
}) {
|
||||||
const dialog = useRef<HTMLDialogElement>(null)
|
const dialog = useRef<HTMLDialogElement>(null)
|
||||||
const trigger = useRef<HTMLButtonElement>(null)
|
const trigger = useRef<HTMLButtonElement>(null)
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(true)
|
||||||
|
useEffect(() => { setOpen(true) }, [operation.id])
|
||||||
const latest = [...operation.events].sort((a, b) =>
|
const latest = [...operation.events].sort((a, b) =>
|
||||||
(a.finished_at ?? a.started_at ?? '').localeCompare(b.finished_at ?? b.started_at ?? '')
|
(a.finished_at ?? a.started_at ?? '').localeCompare(b.finished_at ?? b.started_at ?? '')
|
||||||
).at(-1)
|
).at(-1)
|
||||||
const status = operation.status === 'running' ? 'Working' : operation.status === 'complete' ? 'Finished' : 'Needs attention'
|
const status = operation.status === 'running' ? 'Working' : operation.status === 'complete' ? 'Finished' : operation.status === 'searching' ? 'Search still running' : 'Needs attention'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
const element = dialog.current
|
const element = dialog.current
|
||||||
element?.showModal()
|
element?.showModal()
|
||||||
const previous = document.body.style.overflow
|
const unlock = lockBodyScroll()
|
||||||
document.body.style.overflow = 'hidden'
|
|
||||||
return () => {
|
return () => {
|
||||||
element?.close()
|
element?.close()
|
||||||
document.body.style.overflow = previous
|
unlock()
|
||||||
trigger.current?.focus()
|
trigger.current?.focus()
|
||||||
}
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
@@ -37,13 +38,14 @@ export default function LatestActivity({ operation, besideDownload, onDismiss }:
|
|||||||
<span className="latest-activity-message" role="status">{latest?.message || 'Getting ready to check your request…'}</span>
|
<span className="latest-activity-message" role="status">{latest?.message || 'Getting ready to check your request…'}</span>
|
||||||
<span className="latest-activity-more">View all activity ({operation.events.length}) <span aria-hidden="true">↗</span></span>
|
<span className="latest-activity-more">View all activity ({operation.events.length}) <span aria-hidden="true">↗</span></span>
|
||||||
</button>
|
</button>
|
||||||
<dialog ref={dialog} className="activity-dialog" aria-labelledby="activity-dialog-title" onCancel={() => setOpen(false)} onClose={() => setOpen(false)} onClick={(event) => { if (event.target === event.currentTarget) setOpen(false) }}>
|
<dialog ref={dialog} className="activity-dialog" aria-labelledby="activity-dialog-title" onCancel={() => setOpen(false)} onClose={() => setOpen(false)}>
|
||||||
<div className="activity-dialog-content">
|
<div className="activity-dialog-content">
|
||||||
<header>
|
<header>
|
||||||
<div><span className="request-overview-label">Activity details</span><h2 id="activity-dialog-title">{operation.label}</h2><small>{status} · {operation.events.length} updates</small></div>
|
<div><span className="request-overview-label">Activity details</span><h2 id="activity-dialog-title">{operation.label}</h2><small>{status} · {operation.events.length} updates</small></div>
|
||||||
<button type="button" onClick={() => setOpen(false)} autoFocus>Close</button>
|
<button type="button" onClick={() => setOpen(false)}>Close</button>
|
||||||
</header>
|
</header>
|
||||||
<ol className="activity-dialog-events" aria-label="All activity, oldest first">
|
{operation.status === 'running' && <p role="status" className="activity-working">Working on your request. This can take a minute while the media services search.</p>}
|
||||||
|
<ol aria-live="polite" className="activity-dialog-events" aria-label="All activity, oldest first">
|
||||||
{operation.events.map((event) => <li key={event.id} className={`is-${event.state}`}>
|
{operation.events.map((event) => <li key={event.id} className={`is-${event.state}`}>
|
||||||
<span className="activity-event-state">{event.state === 'active' ? 'Working' : event.state === 'error' ? 'Needs attention' : 'Done'}</span>
|
<span className="activity-event-state">{event.state === 'active' ? 'Working' : event.state === 'error' ? 'Needs attention' : 'Done'}</span>
|
||||||
<div><strong>{event.service}</strong><p>{event.message}</p></div>
|
<div><strong>{event.service}</strong><p>{event.message}</p></div>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { authFetch, getApiBase } from '../../lib/auth'
|
||||||
|
|
||||||
|
type AudioChoice = { language: { code: string } | null; originalEnabled?: boolean; canChange?: boolean; profileLanguage?: string }
|
||||||
|
|
||||||
|
export default function RequestLanguage({ requestId, disabled, onApply }: {
|
||||||
|
requestId: string; disabled: boolean; onApply: (code: string) => Promise<void>
|
||||||
|
}) {
|
||||||
|
const [choice, setChoice] = useState<AudioChoice | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [revision, setRevision] = useState(0)
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
void authFetch(`${getApiBase()}/requests/${requestId}/language`, { signal: controller.signal })
|
||||||
|
.then(async response => { if (!response.ok) throw new Error('Could not check the audio settings. Reload the request to try again.'); return response.json() })
|
||||||
|
.then(data => { if (!controller.signal.aborted) setChoice(data) })
|
||||||
|
.catch(e => { if (!controller.signal.aborted) setError(e.message) })
|
||||||
|
return () => controller.abort()
|
||||||
|
}, [requestId, revision])
|
||||||
|
if (error && !choice) return <p role="alert">{error}</p>
|
||||||
|
if (!choice?.language) return null
|
||||||
|
const code = choice.language.code
|
||||||
|
const name = new Intl.DisplayNames(['en'], { type: 'language' }).of(code) || code
|
||||||
|
return <section className="request-language-notice" aria-label="Audio language">
|
||||||
|
<h2>{name} audio {choice.originalEnabled ? 'enabled' : 'may need your approval'}</h2>
|
||||||
|
<p>This movie was originally made in {name}. An English dub may not exist. {choice.originalEnabled ? 'Radarr is set to accept its original audio.' : `The current audio requirement is ${choice.profileLanguage || 'set by the library'}. This can leave the request waiting even when an original-language release exists.`}</p>
|
||||||
|
<p>Accepting original audio keeps the quality requirements. Subtitles and individual release audio tracks are not guaranteed by title metadata.</p>
|
||||||
|
{choice.canChange && !choice.originalEnabled && <button type="button" disabled={disabled} onClick={async () => {
|
||||||
|
setError(null)
|
||||||
|
try { await onApply(code); setRevision(v => v + 1) } catch (e) { setError(e instanceof Error ? e.message : 'The audio choice could not be saved.') }
|
||||||
|
}}>Use {name} audio & search</button>}
|
||||||
|
{error && <p role="alert">{error}</p>}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import PageHeading from '../../ui/PageHeading'
|
import PageHeading from '../../ui/PageHeading'
|
||||||
|
import RequestLanguage from './RequestLanguage'
|
||||||
|
import { lockBodyScroll } from '../../lib/scrollLock'
|
||||||
import LatestActivity from './LatestActivity'
|
import LatestActivity from './LatestActivity'
|
||||||
|
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
@@ -339,14 +341,13 @@ export default function RequestTimelinePage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!releasePickerOpen) return
|
if (!releasePickerOpen) return
|
||||||
const previousOverflow = document.body.style.overflow
|
const unlock = lockBodyScroll()
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') closeReleasePicker()
|
if (event.key === 'Escape') closeReleasePicker()
|
||||||
}
|
}
|
||||||
document.body.style.overflow = 'hidden'
|
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = previousOverflow
|
unlock()
|
||||||
window.removeEventListener('keydown', handleKeyDown)
|
window.removeEventListener('keydown', handleKeyDown)
|
||||||
}
|
}
|
||||||
}, [releasePickerOpen, busyAction])
|
}, [releasePickerOpen, busyAction])
|
||||||
@@ -588,14 +589,16 @@ export default function RequestTimelinePage() {
|
|||||||
const timer = window.setInterval(() => void refreshProgress(), 650)
|
const timer = window.setInterval(() => void refreshProgress(), 650)
|
||||||
try {
|
try {
|
||||||
const response = await request
|
const response = await request
|
||||||
const finalProgress = await refreshProgress()
|
await refreshProgress()
|
||||||
if (!finalProgress || finalProgress.status === 'running') {
|
let result: any = null
|
||||||
setOperationProgress((current) => current?.id === operationId ? {
|
try { result = await response.clone().json() } catch { /* Non-JSON error is handled below. */ }
|
||||||
...current, status: response.ok ? 'complete' : 'error',
|
const needsAttention = !response.ok || result?.status === 'attention' || result?.outcome === 'attention'
|
||||||
events: [...current.events, { id: 'result', service: 'Magent', state: response.ok ? 'complete' : 'error',
|
const finalState = needsAttention ? 'error' : result?.status === 'searching' ? 'searching' : 'complete'
|
||||||
message: response.ok ? 'This action has finished. Check the request status for what happens next.' : 'This action could not be completed. Check the message beside the request controls.' }],
|
setOperationProgress((current) => current?.id === operationId ? {
|
||||||
} : current)
|
...current, status: finalState,
|
||||||
}
|
events: [...current.events.map(event => event.state === 'active' ? { ...event, state: 'complete' as const } : event), { id: 'result', service: 'Magent', state: needsAttention ? 'error' : 'complete',
|
||||||
|
message: result?.message || (typeof result?.detail === 'string' ? result.detail : response.ok ? 'Action completed. The pipeline will update as the media services report progress.' : 'The action failed. Recheck the request before trying again.') }],
|
||||||
|
} : current)
|
||||||
return response
|
return response
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setOperationProgress((current) => current?.id === operationId ? {
|
setOperationProgress((current) => current?.id === operationId ? {
|
||||||
@@ -660,7 +663,7 @@ export default function RequestTimelinePage() {
|
|||||||
setReleaseOptions([])
|
setReleaseOptions([])
|
||||||
setReleaseCollector(snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr')
|
setReleaseCollector(snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr')
|
||||||
setReleaseSearchMessage(null)
|
setReleaseSearchMessage(null)
|
||||||
setReleasePickerOpen(true)
|
setReleasePickerOpen(false)
|
||||||
}
|
}
|
||||||
setBusyAction(action.id)
|
setBusyAction(action.id)
|
||||||
setActionError(null)
|
setActionError(null)
|
||||||
@@ -680,6 +683,7 @@ export default function RequestTimelinePage() {
|
|||||||
if (action.id === 'search_releases') {
|
if (action.id === 'search_releases') {
|
||||||
const releases = Array.isArray(data.releases) ? data.releases : []
|
const releases = Array.isArray(data.releases) ? data.releases : []
|
||||||
setReleaseOptions(releases)
|
setReleaseOptions(releases)
|
||||||
|
setReleasePickerOpen(true)
|
||||||
setReleaseCollector(data?.collector ?? (snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr'))
|
setReleaseCollector(data?.collector ?? (snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr'))
|
||||||
setReleaseSearchMessage(
|
setReleaseSearchMessage(
|
||||||
data?.message ??
|
data?.message ??
|
||||||
@@ -744,6 +748,16 @@ export default function RequestTimelinePage() {
|
|||||||
leading={resolvedPoster && <Image className="request-poster" src={resolvedPoster} alt={`${snapshot.title} poster`} width={60} height={90} sizes="60px" unoptimized />}
|
leading={resolvedPoster && <Image className="request-poster" src={resolvedPoster} alt={`${snapshot.title} poster`} width={60} height={90} sizes="60px" unoptimized />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<RequestLanguage requestId={snapshot.request_id} disabled={Boolean(busyAction)} onApply={async (code) => {
|
||||||
|
setBusyAction('language')
|
||||||
|
try {
|
||||||
|
const response = await trackedPost('Use original audio and search', `${getApiBase()}/requests/${snapshot.request_id}/actions/language`, {
|
||||||
|
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ acceptOriginalLanguage: true, languageCode: code }),
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error(await readApiError(response, 'The audio choice could not be saved.'))
|
||||||
|
} finally { setBusyAction(null) }
|
||||||
|
}} />
|
||||||
|
|
||||||
<section className="request-overview" aria-labelledby="request-status-heading">
|
<section className="request-overview" aria-labelledby="request-status-heading">
|
||||||
<div className="request-overview-block request-overview-status">
|
<div className="request-overview-block request-overview-status">
|
||||||
<span className="request-overview-label" id="request-status-heading">Status</span>
|
<span className="request-overview-label" id="request-status-heading">Status</span>
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export default function UsersPage() {
|
|||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setJellyseerrSyncStatus(
|
setJellyseerrSyncStatus(
|
||||||
`Matched ${data?.matched ?? 0} users. Skipped ${data?.skipped ?? 0}.`
|
`Checked ${data?.total ?? 0} Seerr records against Jellyfin IDs. Added ${data?.imported ?? 0} users; existing settings retained.`
|
||||||
)
|
)
|
||||||
await loadUsers()
|
await loadUsers()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -187,10 +187,6 @@ export default function UsersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resyncJellyseerrUsers = async () => {
|
const resyncJellyseerrUsers = async () => {
|
||||||
const confirmed = window.confirm(
|
|
||||||
'Rebuild the Magent directory from Seerr? This deletes all existing non-admin Magent accounts and creates accounts from Seerr. Account settings and saved identity links may be lost. Admin accounts are kept. Continue?'
|
|
||||||
)
|
|
||||||
if (!confirmed) return
|
|
||||||
setJellyseerrSyncStatus(null)
|
setJellyseerrSyncStatus(null)
|
||||||
setJellyseerrResyncBusy(true)
|
setJellyseerrResyncBusy(true)
|
||||||
try {
|
try {
|
||||||
@@ -204,7 +200,7 @@ export default function UsersPage() {
|
|||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setJellyseerrSyncStatus(
|
setJellyseerrSyncStatus(
|
||||||
`Re-imported ${data?.imported ?? 0} users. Cleared ${data?.cleared ?? 0}.`
|
`Reconciled service identities. Added ${data?.imported ?? 0} new users; existing accounts and settings were retained.`
|
||||||
)
|
)
|
||||||
await loadUsers()
|
await loadUsers()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -358,7 +354,7 @@ export default function UsersPage() {
|
|||||||
</section>
|
</section>
|
||||||
<section className="user-management-panel"><h3>Seerr sync</h3><p>Connect existing Magent accounts to their Seerr request accounts.</p>
|
<section className="user-management-panel"><h3>Seerr sync</h3><p>Connect existing Magent accounts to their Seerr request accounts.</p>
|
||||||
<div className="user-management-action"><button type="button" onClick={() => void syncJellyseerrUsers()} disabled={controlsBusy} aria-describedby="sync-help">{jellyseerrSyncBusy ? 'Matching accounts…' : 'Match unlinked Seerr accounts'}</button><p id="sync-help">Match users without a saved Seerr ID by their account name, then copy the matching Seerr ID and available email into Magent. Already-linked users are skipped.</p></div>
|
<div className="user-management-action"><button type="button" onClick={() => void syncJellyseerrUsers()} disabled={controlsBusy} aria-describedby="sync-help">{jellyseerrSyncBusy ? 'Matching accounts…' : 'Match unlinked Seerr accounts'}</button><p id="sync-help">Match users without a saved Seerr ID by their account name, then copy the matching Seerr ID and available email into Magent. Already-linked users are skipped.</p></div>
|
||||||
<details className="user-management-advanced"><summary>Advanced: rebuild from Seerr</summary><p id="resync-help">Deletes all non-admin Magent accounts, then imports accounts from Seerr. Account settings and saved identity links may be lost. Admin accounts are kept. Use this only when you intend to replace the directory.</p><button type="button" className="ghost-button" onClick={() => void resyncJellyseerrUsers()} disabled={controlsBusy} aria-describedby="resync-help">{jellyseerrResyncBusy ? 'Rebuilding directory…' : 'Rebuild directory from Seerr'}</button></details>
|
<details className="user-management-advanced"><summary>Reconcile service identities</summary><p id="resync-help">Refreshes Jellyfin and Seerr accounts using their shared Jellyfin ID. Preserves account settings and history. Duplicate or conflicting links stay available for reviewed repair.</p><button type="button" className="ghost-button" onClick={() => void resyncJellyseerrUsers()} disabled={controlsBusy} aria-describedby="resync-help">{jellyseerrResyncBusy ? 'Reconciling identities…' : 'Reconcile Jellyfin and Seerr'}</button></details>
|
||||||
</section>
|
</section>
|
||||||
<section className="user-management-panel"><h3>Automatic search & download</h3><p>Allow users to trigger automatic searches and downloads for their requests.</p><span className="user-management-count">{autoSearchEnabledCount} of {nonAdminUsers.length} non-admin users enabled</span><p id="auto-search-help">Applies to every existing non-admin account, including accounts outside your search results. Use an individual user's page to change just their access.</p><div className="user-management-buttons"><button type="button" onClick={() => void bulkUpdateAutoSearch(true)} disabled={controlsBusy || !nonAdminUsers.length || autoSearchEnabledCount === nonAdminUsers.length} aria-describedby="auto-search-help">Enable for all non-admin users</button><button type="button" className="ghost-button" onClick={() => void bulkUpdateAutoSearch(false)} disabled={controlsBusy || !autoSearchEnabledCount} aria-describedby="auto-search-help">Disable for all non-admin users</button></div></section>
|
<section className="user-management-panel"><h3>Automatic search & download</h3><p>Allow users to trigger automatic searches and downloads for their requests.</p><span className="user-management-count">{autoSearchEnabledCount} of {nonAdminUsers.length} non-admin users enabled</span><p id="auto-search-help">Applies to every existing non-admin account, including accounts outside your search results. Use an individual user's page to change just their access.</p><div className="user-management-buttons"><button type="button" onClick={() => void bulkUpdateAutoSearch(true)} disabled={controlsBusy || !nonAdminUsers.length || autoSearchEnabledCount === nonAdminUsers.length} aria-describedby="auto-search-help">Enable for all non-admin users</button><button type="button" className="ghost-button" onClick={() => void bulkUpdateAutoSearch(false)} disabled={controlsBusy || !autoSearchEnabledCount} aria-describedby="auto-search-help">Disable for all non-admin users</button></div></section>
|
||||||
<FeatureControls onSaved={() => void loadUsers()} />
|
<FeatureControls onSaved={() => void loadUsers()} />
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Audited operator repair. Preview by default; --apply requires explicit authorization."""
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
from contextlib import closing
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.services import duplicate_accounts as duplicates
|
||||||
|
from app.services import identity_review as review
|
||||||
|
|
||||||
|
|
||||||
|
async def reconcile(apply, output):
|
||||||
|
os.umask(0o077)
|
||||||
|
output.mkdir(parents=True, exist_ok=False, mode=0o700)
|
||||||
|
if apply:
|
||||||
|
with closing(db._connect()) as source, closing(sqlite3.connect(output / 'before.sqlite')) as target:
|
||||||
|
source.backup(target)
|
||||||
|
changes, blocked, seen = [], [], set()
|
||||||
|
actor = {'username': 'maintenance:authorized-identity-reconciliation'}
|
||||||
|
report, local, runtime = await review.review_identities()
|
||||||
|
initial = report['counts']
|
||||||
|
while True:
|
||||||
|
target = next((row for row in report['rows'] if row['candidate_jellyfin_id']
|
||||||
|
and row['candidate_jellyfin_id'] not in seen
|
||||||
|
and len(duplicates.identity_group(report, row)) > 1), None)
|
||||||
|
if not target:
|
||||||
|
break
|
||||||
|
identity = target['candidate_jellyfin_id']
|
||||||
|
seen.add(identity)
|
||||||
|
ids = [row['user']['id'] for row in duplicates.identity_group(report, target)]
|
||||||
|
with closing(db._connect()) as conn:
|
||||||
|
state = duplicates.account_state(conn, ids)
|
||||||
|
preview = duplicates.build_preview(report, local, runtime, state, target['user']['id'])
|
||||||
|
if preview['can_confirm']:
|
||||||
|
result = duplicates.consolidate(preview, report, local, runtime, state, actor) if apply else {
|
||||||
|
'kept_user_id': preview['keep_id'], 'consolidated': len(ids) - 1}
|
||||||
|
changes.append(result)
|
||||||
|
if apply:
|
||||||
|
report, local, runtime = await review.review_identities()
|
||||||
|
else:
|
||||||
|
blocked.append({'ids': ids, 'jellyfin_id': identity, 'issues': preview['issues']})
|
||||||
|
(output / 'progress.json').write_text(json.dumps({'changes': changes, 'blocked': blocked}))
|
||||||
|
if len(seen) % 20 == 0:
|
||||||
|
print('Reviewed groups:', len(seen), 'consolidated rows:', sum(r['consolidated'] for r in changes), flush=True)
|
||||||
|
if apply:
|
||||||
|
ready = [row['user']['id'] for row in report['rows'] if row['can_confirm']
|
||||||
|
and row['basis'] in {'confirmed_id', 'stored_jellyfin_id', 'stored_seerr_id'}]
|
||||||
|
if ready:
|
||||||
|
review.save_confirmations(report, local, runtime, ready, actor)
|
||||||
|
report, _, _ = await review.review_identities()
|
||||||
|
summary = {'applied': apply, 'before': initial, 'after': report['counts'],
|
||||||
|
'consolidated_rows': sum(r['consolidated'] for r in changes), 'groups': len(changes), 'blocked': blocked}
|
||||||
|
(output / 'result.json').write_text(json.dumps(summary, indent=2))
|
||||||
|
print(json.dumps(summary), flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--apply', action='store_true')
|
||||||
|
parser.add_argument('--output', type=Path, required=True, help='New private backup/report directory')
|
||||||
|
args = parser.parse_args()
|
||||||
|
asyncio.run(reconcile(args.apply, args.output))
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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 }]);
|
||||||
|
let finish, original = false, outcome = 'attention'; const writes = [], errors = [];
|
||||||
|
await context.route('**/api/**', async route => {
|
||||||
|
const req = route.request(), path = new URL(req.url()).pathname;
|
||||||
|
const reply = json => route.fulfill({ json });
|
||||||
|
if (path === '/api/auth/me') return reply({ username: 'Admin', role: 'admin' });
|
||||||
|
if (path.endsWith('/snapshot')) return reply({ request_id: '3976', title: 'Downfall', request_type: 'movie', state: 'ADDED_TO_ARR', timeline: [], actions: [{ id: 'search_auto', label: 'Search and auto-download', requires_confirmation: false }], presentation: { status: { label: 'Waiting', meaning: 'Waiting for a release.' }, nextStep: { title: 'Search', description: 'Search for a download.', actionIds: ['search_auto'] }, pipeline: [] } });
|
||||||
|
if (path.endsWith('/language') && req.method() === 'GET') return reply({ language: { code: 'de' }, originalEnabled: original, canChange: true, profileLanguage: original ? 'Original' : 'English' });
|
||||||
|
if (path.includes('/operations/')) return reply({ id: path.split('/').pop(), label: 'Working on request', status: 'running', events: [{ id: 'search', service: 'Radarr', state: 'active', message: 'Searching indexers for Downfall…' }] });
|
||||||
|
if (path.endsWith('/actions/search_auto') || path.endsWith('/actions/language')) {
|
||||||
|
writes.push({ path, payload: req.postData() ? req.postDataJSON() : null });
|
||||||
|
await new Promise(resolve => { finish = resolve; });
|
||||||
|
if (path.endsWith('/language')) original = true;
|
||||||
|
return reply({ status: outcome, message: outcome === 'attention' ? 'Search finished, but no download appeared. Review matching releases and rejection reasons.' : 'Radarr has a download queued for this movie.' });
|
||||||
|
}
|
||||||
|
if (path.includes('/events/stream')) return route.fulfill({ contentType: 'text/event-stream', body: ': fixture\n\n' });
|
||||||
|
return reply({});
|
||||||
|
});
|
||||||
|
const page = await context.newPage(); page.on('pageerror', e => errors.push(e.message));
|
||||||
|
for (const width of [1440, 390]) {
|
||||||
|
original = false; outcome = 'attention';
|
||||||
|
await page.setViewportSize({ width, height: 900 }); await page.goto(base + '/requests/3976');
|
||||||
|
await page.getByRole('heading', { name: 'German audio may need your approval' }).waitFor();
|
||||||
|
await page.getByRole('button', { name: 'Search and auto-download', exact: true }).first().click();
|
||||||
|
let dialog = page.getByRole('dialog'); await dialog.waitFor();
|
||||||
|
assert(await dialog.getByText(/Working on your request/).isVisible(), 'Progress opens automatically');
|
||||||
|
await dialog.getByText('Searching indexers for Downfall…', { exact: true }).waitFor(); finish();
|
||||||
|
await dialog.getByText('Search finished, but no download appeared. Review matching releases and rejection reasons.', { exact: true }).waitFor();
|
||||||
|
await dialog.getByRole('button', { name: 'Dismiss activity' }).click();
|
||||||
|
outcome = 'downloading';
|
||||||
|
await page.getByRole('button', { name: 'Use German audio & search', exact: true }).click();
|
||||||
|
dialog = page.getByRole('dialog'); await dialog.waitFor();
|
||||||
|
await dialog.getByText('Searching indexers for Downfall…', { exact: true }).waitFor(); finish();
|
||||||
|
await dialog.getByText('Radarr has a download queued for this movie.', { exact: true }).waitFor();
|
||||||
|
if (process.env.REVIEW_DIR) await page.screenshot({ path: `${process.env.REVIEW_DIR}/downfall-progress-${width}.png`, animations: 'disabled' });
|
||||||
|
await dialog.getByRole('button', { name: 'Dismiss activity' }).click();
|
||||||
|
await page.getByRole('heading', { name: 'German audio enabled' }).waitFor();
|
||||||
|
assert.notEqual(await page.evaluate(() => document.body.style.overflow), 'hidden');
|
||||||
|
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
||||||
|
}
|
||||||
|
assert(writes.filter(w => w.path.endsWith('/language')).every(w => w.payload.acceptOriginalLanguage === true && w.payload.languageCode === 'de'));
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log('Passed: desktop/mobile automatic repair overlay, live events, no-download outcome, prominent German audio choice, saved state, queue confirmation and scroll restoration. All APIs intercepted.');
|
||||||
|
} finally { await browser.close(); }
|
||||||
|
})().catch(e => { console.error(e); process.exitCode = 1; });
|
||||||
@@ -26,14 +26,15 @@ const base = process.env.REVIEW_BASE || 'http://localhost:3114';
|
|||||||
await page.getByLabel('Title', { exact: true }).fill('Pans Labyrinth');
|
await page.getByLabel('Title', { exact: true }).fill('Pans Labyrinth');
|
||||||
await page.getByRole('button', { name: 'Search Seerr' }).click();
|
await page.getByRole('button', { name: 'Search Seerr' }).click();
|
||||||
await page.getByRole('button', { name: /Pan's Labyrinth/ }).click();
|
await page.getByRole('button', { name: /Pan's Labyrinth/ }).click();
|
||||||
await page.getByRole('heading', { name: 'Check the audio language' }).waitFor();
|
await page.getByRole('heading', { name: 'Choose your audio language' }).waitFor();
|
||||||
assert(await page.locator('.request-language-notice').getByText('Spanish', { exact: true }).isVisible());
|
assert(await page.locator('.request-language-notice').getByText('Spanish', { exact: true }).isVisible());
|
||||||
const consent = page.getByRole('checkbox');
|
const consent = page.getByRole('radio', { name: /Original Spanish audio/ });
|
||||||
|
assert(await page.getByRole('button', { name: 'Request movie', exact: true }).isDisabled());
|
||||||
assert(!await consent.isChecked());
|
assert(!await consent.isChecked());
|
||||||
await consent.check();
|
await consent.check();
|
||||||
await page.getByRole('button', { name: 'Change title', exact: true }).click();
|
await page.getByRole('button', { name: 'Change title', exact: true }).click();
|
||||||
await page.getByRole('button', { name: /Pan's Labyrinth/ }).click();
|
await page.getByRole('button', { name: /Pan's Labyrinth/ }).click();
|
||||||
await page.getByRole('heading', { name: 'Check the audio language' }).waitFor();
|
await page.getByRole('heading', { name: 'Choose your audio language' }).waitFor();
|
||||||
assert(!await consent.isChecked(), 'Consent resets when changing titles');
|
assert(!await consent.isChecked(), 'Consent resets when changing titles');
|
||||||
await consent.check();
|
await consent.check();
|
||||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
||||||
|
|||||||
Reference in New Issue
Block a user