Compare commits

...
5 Commits
Author SHA1 Message Date
Assclaw aed1bf9256 feat: add seasons from request details
Magent CI/CD / verify (push) Successful in 1m51s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-16 17:15:40 +12:00
Assclaw 05a540ecbb fix: keep site banner off login page
Magent CI/CD / verify (push) Successful in 1m50s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 56s
2026-09-16 14:37:00 +12:00
Assclaw d75f36c691 feat: customize site banners and login notices
Magent CI/CD / verify (push) Successful in 1m55s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 49s
2026-09-16 14:22:38 +12:00
Assclaw 3465343a69 feat: route ready-title issues through guided workflow
Magent CI/CD / verify (push) Successful in 1m52s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 53s
2026-09-15 15:46:09 +12:00
Assclaw 4ba1a5763e feat: simplify ready requests and add global search
Magent CI/CD / verify (push) Successful in 1m56s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 48s
2026-09-15 15:15:35 +12:00
24 changed files with 1298 additions and 82 deletions
+20
View File
@@ -1,9 +1,20 @@
import re
from typing import Optional from typing import Optional
from pydantic import AliasChoices, Field from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from .build_info import BUILD_NUMBER, CHANGELOG from .build_info import BUILD_NUMBER, CHANGELOG
_BANNER_COLOR_PATTERN = re.compile(r"^#[0-9a-f]{6}$")
def normalize_banner_color(value: object) -> Optional[str]:
color = str(value or "").strip().lower()
return color if _BANNER_COLOR_PATTERN.fullmatch(color) else None
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="") model_config = SettingsConfigDict(env_prefix="")
app_name: str = "Magent" app_name: str = "Magent"
@@ -108,6 +119,15 @@ class Settings(BaseSettings):
site_banner_tone: str = Field( site_banner_tone: str = Field(
default="info", validation_alias=AliasChoices("SITE_BANNER_TONE") default="info", validation_alias=AliasChoices("SITE_BANNER_TONE")
) )
site_banner_background_color: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_BANNER_BACKGROUND_COLOR")
)
site_banner_border_color: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_BANNER_BORDER_COLOR")
)
site_login_message: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_LOGIN_MESSAGE")
)
site_login_show_jellyfin_login: bool = Field( site_login_show_jellyfin_login: bool = Field(
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_JELLYFIN_LOGIN") default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_JELLYFIN_LOGIN")
) )
+14 -1
View File
@@ -20,7 +20,7 @@ from ..auth import (
normalize_user_auth_provider, normalize_user_auth_provider,
resolve_user_auth_provider, resolve_user_auth_provider,
) )
from ..config import settings as env_settings from ..config import normalize_banner_color, settings as env_settings
from ..network_security import validate_notification_target_url from ..network_security import validate_notification_target_url
from ..db import ( from ..db import (
delete_setting, delete_setting,
@@ -174,6 +174,11 @@ NOTIFICATION_URL_SETTING_KEYS = {
"magent_notify_webhook_url", "magent_notify_webhook_url",
} }
BANNER_COLOR_SETTING_KEYS = {
"site_banner_background_color",
"site_banner_border_color",
}
SETTING_KEYS: List[str] = [ SETTING_KEYS: List[str] = [
"jellystat_base_url", "jellystat_base_url",
"jellystat_api_key", "jellystat_api_key",
@@ -260,6 +265,9 @@ SETTING_KEYS: List[str] = [
"site_banner_enabled", "site_banner_enabled",
"site_banner_message", "site_banner_message",
"site_banner_tone", "site_banner_tone",
"site_banner_background_color",
"site_banner_border_color",
"site_login_message",
"site_login_show_jellyfin_login", "site_login_show_jellyfin_login",
"site_login_show_local_login", "site_login_show_local_login",
"site_login_show_forgot_password", "site_login_show_forgot_password",
@@ -712,6 +720,11 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
value_to_store = value_to_store.lower() value_to_store = value_to_store.lower()
if value_to_store not in {"days", "weeks", "months"}: if value_to_store not in {"days", "weeks", "months"}:
raise HTTPException(status_code=400, detail="Confirmation interval unit must be days, weeks, or months") raise HTTPException(status_code=400, detail="Confirmation interval unit must be days, weeks, or months")
if key in BANNER_COLOR_SETTING_KEYS:
normalized_color = normalize_banner_color(value_to_store)
if not normalized_color:
raise HTTPException(status_code=400, detail=f"{key.replace('_', ' ')} must be a six-digit hex colour such as #ffc857")
value_to_store = normalized_color
if key in URL_SETTING_KEYS and value_to_store: if key in URL_SETTING_KEYS and value_to_store:
try: try:
value_to_store = _normalize_service_url(value_to_store) value_to_store = _normalize_service_url(value_to_store)
+138 -1
View File
@@ -273,8 +273,14 @@ def _user_can_use_search_auto(user: Dict[str, Any]) -> bool:
def _filter_snapshot_for_user(snapshot: Snapshot, user: Dict[str, Any]) -> Snapshot: def _filter_snapshot_for_user(snapshot: Snapshot, user: Dict[str, Any]) -> Snapshot:
if not _user_can_use_search_auto(user): can_add_seasons = _user_can_use_search_auto(user)
if not can_add_seasons:
snapshot.actions = [action for action in snapshot.actions if action.id != "search_auto"] snapshot.actions = [action for action in snapshot.actions if action.id != "search_auto"]
pipeline = snapshot.presentation.get("pipeline")
if isinstance(pipeline, list):
for stage in pipeline:
if isinstance(stage, dict) and stage.get("id") == "library":
stage["canAddSeasons"] = can_add_seasons
if user.get("role") != "admin": if user.get("role") != "admin":
# The standard request view is intentionally collaborative, but service payloads can # The standard request view is intentionally collaborative, but service payloads can
# contain requester identities, internal URLs, download hashes and diagnostic errors. # contain requester identities, internal URLs, download hashes and diagnostic errors.
@@ -2474,6 +2480,137 @@ async def action_search_missing_media(
return {"status": "ok", "message": message, "episode_ids": searched_ids} return {"status": "ok", "message": message, "episode_ids": searched_ids}
@router.post("/{request_id}/actions/add-seasons")
async def action_add_seasons(
request_id: str,
payload: Dict[str, Any],
user: Dict[str, str] = Depends(get_current_user),
) -> Dict[str, Any]:
if not request_id.isdigit():
raise HTTPException(status_code=400, detail="Invalid request id")
if not _user_can_use_search_auto(user):
raise HTTPException(status_code=403, detail="Adding seasons is disabled for this user")
season_numbers = _positive_id_list(
payload.get("season_numbers"), field="season_numbers", maximum=100
)
if not season_numbers:
raise HTTPException(status_code=400, detail="Choose at least one season")
runtime = get_runtime_settings()
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if seerr.configured():
await _ensure_request_access(seerr, int(request_id), user)
snapshot = await build_snapshot(request_id)
if snapshot.request_type != RequestType.tv:
raise HTTPException(status_code=400, detail="Additional seasons are only available for TV requests")
arr_item = snapshot.raw.get("arr", {}).get("item")
if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int):
raise HTTPException(status_code=404, detail="Series not found in Sonarr")
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
if not sonarr.configured():
raise HTTPException(status_code=400, detail="Sonarr is not configured")
series_id = int(arr_item["id"])
label = "Add seasons"
try:
series = await sonarr.get_series(series_id)
if not isinstance(series, dict) or not isinstance(series.get("seasons"), list):
raise HTTPException(status_code=502, detail="Sonarr did not return the series seasons")
known_seasons = {
season.get("seasonNumber")
for season in series["seasons"]
if isinstance(season, dict)
and isinstance(season.get("seasonNumber"), int)
and season.get("seasonNumber") > 0
}
if any(season_number not in known_seasons for season_number in season_numbers):
raise HTTPException(status_code=409, detail="One or more selected seasons are no longer available in Sonarr")
updated_seasons = [
{**season, "monitored": True}
if isinstance(season, dict) and season.get("seasonNumber") in season_numbers
else season
for season in series["seasons"]
]
if series.get("monitored") is not True or updated_seasons != series["seasons"]:
await sonarr.update_series({**series, "monitored": True, "seasons": updated_seasons})
episodes = await sonarr.get_episodes(series_id)
if not isinstance(episodes, list):
raise HTTPException(status_code=502, detail="Sonarr did not return an episode list")
selected_episodes = [
episode for episode in episodes
if isinstance(episode, dict)
and episode.get("seasonNumber") in season_numbers
and isinstance(episode.get("id"), int)
]
episode_ids = [int(episode["id"]) for episode in selected_episodes]
if episode_ids:
await sonarr.monitor_episodes(episode_ids, True)
search_ids = [
int(episode["id"])
for episode in selected_episodes
if _released_episode(episode)
and episode.get("hasFile") is not True
and not (
isinstance(episode.get("episodeFileId"), int)
and episode.get("episodeFileId") > 0
)
]
if search_ids:
await sonarr.search_episodes(search_ids)
verified_series = await sonarr.get_series(series_id)
verified_seasons = verified_series.get("seasons") if isinstance(verified_series, dict) else []
verified_season_map = {
season.get("seasonNumber"): season.get("monitored")
for season in verified_seasons
if isinstance(season, dict) and isinstance(season.get("seasonNumber"), int)
}
if (
not isinstance(verified_series, dict)
or verified_series.get("monitored") is not True
or any(verified_season_map.get(number) is not True for number in season_numbers)
):
raise HTTPException(status_code=502, detail="Sonarr did not enable monitoring for every selected season")
if episode_ids:
verified_episodes = await sonarr.get_episodes(series_id)
if not isinstance(verified_episodes, list) or any(
isinstance(episode, dict)
and episode.get("id") in episode_ids
and episode.get("monitored") is not True
for episode in verified_episodes
):
raise HTTPException(status_code=502, detail="Sonarr did not enable monitoring for every selected episode")
except HTTPException as exc:
detail = f"The seasons could not be added: {exc.detail}"
await asyncio.to_thread(save_action, request_id, "add_seasons", label, "failed", detail)
raise
except Exception as exc:
logger.exception("add seasons failed request_id=%s", request_id)
detail = "Sonarr could not add the selected seasons."
await asyncio.to_thread(save_action, request_id, "add_seasons", label, "failed", detail)
raise HTTPException(status_code=502, detail=detail) from exc
season_label = ", ".join(str(number) for number in season_numbers)
message = f"Season{'s' if len(season_numbers) != 1 else ''} {season_label} added to Sonarr."
if search_ids:
message += f" Searching for {len(search_ids)} released missing episode{'s' if len(search_ids) != 1 else ''}."
elif episode_ids:
message += " All known episodes are already collected or have not aired yet."
else:
message += " New episodes will be monitored when Sonarr discovers them."
await asyncio.to_thread(save_action, request_id, "add_seasons", label, "ok", message)
fresh_snapshot = _filter_snapshot_for_user(await build_snapshot(request_id), user)
return {
"status": "ok",
"message": message,
"season_numbers": season_numbers,
"searched_episode_count": len(search_ids),
"snapshot": fresh_snapshot,
}
@router.post("/{request_id}/actions/repair-subtitles") @router.post("/{request_id}/actions/repair-subtitles")
async def action_repair_subtitles( async def action_repair_subtitles(
request_id: str, request_id: str,
+5
View File
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends
from ..auth import get_current_user from ..auth import get_current_user
from ..build_info import BUILD_NUMBER, CHANGELOG from ..build_info import BUILD_NUMBER, CHANGELOG
from ..config import normalize_banner_color
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
router = APIRouter(prefix="/site", tags=["site"]) router = APIRouter(prefix="/site", tags=["site"])
@@ -15,6 +16,7 @@ _BANNER_TONES = {"info", "warning", "error", "maintenance"}
def _build_site_info(include_changelog: bool) -> Dict[str, Any]: def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
runtime = get_runtime_settings() runtime = get_runtime_settings()
banner_message = (runtime.site_banner_message or "").strip() banner_message = (runtime.site_banner_message or "").strip()
login_message = (runtime.site_login_message or "").strip()
tone = (runtime.site_banner_tone or "info").strip().lower() tone = (runtime.site_banner_tone or "info").strip().lower()
if tone not in _BANNER_TONES: if tone not in _BANNER_TONES:
tone = "info" tone = "info"
@@ -24,8 +26,11 @@ def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
"enabled": bool(runtime.site_banner_enabled and banner_message), "enabled": bool(runtime.site_banner_enabled and banner_message),
"message": banner_message, "message": banner_message,
"tone": tone, "tone": tone,
"backgroundColor": normalize_banner_color(runtime.site_banner_background_color),
"borderColor": normalize_banner_color(runtime.site_banner_border_color),
}, },
"login": { "login": {
"message": login_message,
"showJellyfinLogin": bool(runtime.site_login_show_jellyfin_login), "showJellyfinLogin": bool(runtime.site_login_show_jellyfin_login),
"showLocalLogin": bool(runtime.site_login_show_local_login), "showLocalLogin": bool(runtime.site_login_show_local_login),
"showForgotPassword": bool(runtime.site_login_show_forgot_password), "showForgotPassword": bool(runtime.site_login_show_forgot_password),
+34
View File
@@ -369,6 +369,38 @@ def _episode_availability(episodes: Any) -> Dict[str, Any]:
} }
def _unmonitored_season_options(series: Any, episodes: Any) -> List[Dict[str, int]]:
"""Describe regular Sonarr seasons that can be added to an existing request."""
if not isinstance(series, dict) or not isinstance(series.get("seasons"), list):
return []
episode_rows = [episode for episode in episodes if isinstance(episode, dict)] if isinstance(episodes, list) else []
options: List[Dict[str, int]] = []
for season in series["seasons"]:
if not isinstance(season, dict) or season.get("monitored") is not False:
continue
season_number = season.get("seasonNumber")
if not isinstance(season_number, int) or season_number <= 0:
continue
matching = [episode for episode in episode_rows if episode.get("seasonNumber") == season_number]
statistics = season.get("statistics") if isinstance(season.get("statistics"), dict) else {}
episode_count = statistics.get("totalEpisodeCount")
if not isinstance(episode_count, int):
episode_count = statistics.get("episodeCount")
if not isinstance(episode_count, int):
episode_count = len(matching)
available = statistics.get("episodeFileCount")
if not isinstance(available, int):
available = sum(1 for episode in matching if episode.get("hasFile") is True)
options.append(
{
"seasonNumber": season_number,
"episodeCount": max(0, episode_count),
"available": max(0, available),
}
)
return sorted(options, key=lambda item: item["seasonNumber"])
def _summarize_qbit(torrents: List[Dict[str, Any]]) -> Dict[str, Any]: def _summarize_qbit(torrents: List[Dict[str, Any]]) -> Dict[str, Any]:
if not torrents: if not torrents:
return {"state": "idle", "message": "0 active downloads."} return {"state": "idle", "message": "0 active downloads."}
@@ -939,6 +971,7 @@ def _build_presentation(
"missing": missing, "missing": missing,
"total": total, "total": total,
"seasons": availability.get("seasons") or [], "seasons": availability.get("seasons") or [],
"unmonitoredSeasons": arr_details.get("unmonitoredSeasons") or [],
"missingEpisodes": arr_details.get("missingEpisodes") or {}, "missingEpisodes": arr_details.get("missingEpisodes") or {},
}, },
{ {
@@ -1240,6 +1273,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
"state": await read_search_status(sonarr, RequestType.tv, series_id, episodes) "state": await read_search_status(sonarr, RequestType.tv, series_id, episodes)
} }
arr_details["availability"] = _episode_availability(episodes) arr_details["availability"] = _episode_availability(episodes)
arr_details["unmonitoredSeasons"] = _unmonitored_season_options(arr_item, episodes)
counts = arr_details["availability"] counts = arr_details["availability"]
arr_state = "available" if counts.get("total", 0) > 0 and not counts.get("missing") else "added" arr_state = "available" if counts.get("total", 0) > 0 and not counts.get("missing") else "added"
missing_by_season = _missing_episode_numbers_by_season(episodes) missing_by_season = _missing_episode_numbers_by_season(episodes)
+173 -1
View File
@@ -2,7 +2,7 @@ import os
from types import SimpleNamespace from types import SimpleNamespace
import tempfile import tempfile
import unittest import unittest
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, call, patch
import httpx import httpx
from fastapi import HTTPException from fastapi import HTTPException
@@ -39,6 +39,7 @@ from backend.app.services.snapshot import (
_build_repair_activity, _build_repair_activity,
_episode_availability, _episode_availability,
_torrent_progress, _torrent_progress,
_unmonitored_season_options,
) )
@@ -298,6 +299,9 @@ class SiteInfoTests(unittest.TestCase):
site_banner_enabled=False, site_banner_enabled=False,
site_banner_message="", site_banner_message="",
site_banner_tone="info", site_banner_tone="info",
site_banner_background_color=None,
site_banner_border_color=None,
site_login_message="",
site_login_show_jellyfin_login=True, site_login_show_jellyfin_login=True,
site_login_show_local_login=True, site_login_show_local_login=True,
site_login_show_forgot_password=True, site_login_show_forgot_password=True,
@@ -310,6 +314,49 @@ class SiteInfoTests(unittest.TestCase):
self.assertEqual(info["navigation"], {"showRequests": False}) self.assertEqual(info["navigation"], {"showRequests": False})
def test_site_public_exposes_safe_banner_colours_and_login_message(self) -> None:
runtime = settings.model_copy(update={
"site_banner_enabled": True,
"site_banner_message": "Planned maintenance",
"site_banner_tone": "warning",
"site_banner_background_color": "#123ABC",
"site_banner_border_color": "red",
"site_login_message": "Use your Grizzlyflix account to sign in.",
})
with patch.object(site_router, "get_runtime_settings", return_value=runtime):
info = site_router._build_site_info(False)
self.assertEqual(info["banner"]["backgroundColor"], "#123abc")
self.assertIsNone(info["banner"]["borderColor"])
self.assertEqual(info["login"]["message"], "Use your Grizzlyflix account to sign in.")
class SiteSettingValidationTests(unittest.IsolatedAsyncioTestCase):
async def test_banner_colours_are_normalized_before_saving(self) -> None:
with patch.object(admin_router, "set_setting") as save:
result = await admin_router.update_settings({
"site_banner_background_color": "#A1B2C3",
"site_banner_border_color": "#010203",
})
self.assertEqual(result, {"status": "ok", "updated": 2})
self.assertEqual(
save.call_args_list,
[
call("site_banner_background_color", "#a1b2c3"),
call("site_banner_border_color", "#010203"),
],
)
async def test_banner_colours_reject_unsafe_css_values(self) -> None:
with self.assertRaises(HTTPException) as raised:
await admin_router.update_settings({
"site_banner_border_color": "red; background: url(example)",
})
self.assertEqual(raised.exception.status_code, 400)
class RequestCacheTests(unittest.TestCase): class RequestCacheTests(unittest.TestCase):
def tearDown(self) -> None: def tearDown(self) -> None:
@@ -521,6 +568,34 @@ class RequestPresentationTests(unittest.TestCase):
self.assertEqual(availability["missing"], 1) self.assertEqual(availability["missing"], 1)
self.assertEqual(availability["total"], 2) self.assertEqual(availability["total"], 2)
def test_unmonitored_seasons_are_offered_separately_from_collection_progress(self) -> None:
series = {
"seasons": [
{"seasonNumber": 0, "monitored": False},
{"seasonNumber": 7, "monitored": True},
{
"seasonNumber": 8,
"monitored": False,
"statistics": {"episodeCount": 16, "episodeFileCount": 2},
},
{"seasonNumber": 9, "monitored": False},
]
}
episodes = [
{"seasonNumber": 9, "episodeNumber": 1, "hasFile": True},
{"seasonNumber": 9, "episodeNumber": 2, "hasFile": False},
]
options = _unmonitored_season_options(series, episodes)
self.assertEqual(
options,
[
{"seasonNumber": 8, "episodeCount": 16, "available": 2},
{"seasonNumber": 9, "episodeCount": 2, "available": 1},
],
)
def test_presentation_hides_download_without_download_evidence(self) -> None: def test_presentation_hides_download_without_download_evidence(self) -> None:
snapshot = Snapshot( snapshot = Snapshot(
request_id="3909", request_id="3909",
@@ -1846,6 +1921,103 @@ class MediaReplacementTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase)
sonarr.search_episodes.assert_awaited_once_with([36899]) sonarr.search_episodes.assert_awaited_once_with([36899])
sonarr.search.assert_not_awaited() sonarr.search.assert_not_awaited()
async def test_add_seasons_monitors_series_and_searches_released_missing_episodes(self) -> None:
snapshot = Snapshot(
request_id="3580",
title="Suits",
request_type=RequestType.tv,
state=NormalizedState.available,
raw={"arr": {"item": {"id": 540}}},
)
refreshed = Snapshot(
request_id="3580",
title="Suits",
request_type=RequestType.tv,
state=NormalizedState.importing,
presentation={"pipeline": [{"id": "library", "unmonitoredSeasons": []}]},
)
original_series = {
"id": 540,
"monitored": True,
"qualityProfileId": 7,
"seasons": [
{"seasonNumber": 7, "monitored": True},
{"seasonNumber": 8, "monitored": False},
{"seasonNumber": 9, "monitored": False},
],
}
updated_series = {
**original_series,
"seasons": [
{"seasonNumber": 7, "monitored": True},
{"seasonNumber": 8, "monitored": True},
{"seasonNumber": 9, "monitored": True},
],
}
episodes = [
{
"id": 801,
"seasonNumber": 8,
"episodeNumber": 1,
"monitored": False,
"hasFile": False,
"airDateUtc": "2018-07-18T00:00:00Z",
},
{
"id": 802,
"seasonNumber": 8,
"episodeNumber": 2,
"monitored": False,
"hasFile": True,
"episodeFileId": 88,
},
{
"id": 901,
"seasonNumber": 9,
"episodeNumber": 1,
"monitored": False,
"hasFile": False,
"airDateUtc": "2019-07-17T00:00:00Z",
},
]
verified_episodes = [{**episode, "monitored": True} for episode in episodes]
sonarr = SimpleNamespace(
configured=lambda: True,
get_series=AsyncMock(side_effect=[original_series, updated_series]),
update_series=AsyncMock(return_value=updated_series),
get_episodes=AsyncMock(side_effect=[episodes, verified_episodes]),
monitor_episodes=AsyncMock(return_value={"monitored": True}),
search_episodes=AsyncMock(return_value={"id": 9001}),
)
runtime = SimpleNamespace(
jellyseerr_base_url=None,
jellyseerr_api_key=None,
sonarr_base_url="http://sonarr",
sonarr_api_key="secret",
)
with (
patch.object(requests_router, "get_runtime_settings", return_value=runtime),
patch.object(
requests_router,
"build_snapshot",
new=AsyncMock(side_effect=[snapshot, refreshed]),
),
patch.object(requests_router, "SonarrClient", return_value=sonarr),
patch.object(requests_router, "save_action"),
):
result = await requests_router.action_add_seasons(
"3580",
{"season_numbers": [8, 9]},
{"username": "viewer", "role": "user", "auto_search_enabled": True},
)
self.assertEqual(result["season_numbers"], [8, 9])
self.assertEqual(result["searched_episode_count"], 2)
self.assertTrue(result["snapshot"].presentation["pipeline"][0]["canAddSeasons"])
sonarr.update_series.assert_awaited_once_with(updated_series)
sonarr.monitor_episodes.assert_awaited_once_with([801, 802, 901], True)
sonarr.search_episodes.assert_awaited_once_with([801, 901])
async def test_missing_movie_search_monitors_movie_before_search(self) -> None: async def test_missing_movie_search_monitors_movie_before_search(self) -> None:
snapshot = Snapshot( snapshot = Snapshot(
request_id="3914", request_id="3914",
+1
View File
@@ -36,6 +36,7 @@ button.account-secondary { min-height: 44px; padding: 11px 16px; border: 1px sol
.account-notice { margin: 8px 0 0; padding: 12px 14px; border: 1px solid var(--ops-line); border-radius: 8px; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; } .account-notice { margin: 8px 0 0; padding: 12px 14px; border: 1px solid var(--ops-line); border-radius: 8px; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
.account-notice.is-error { color: #ffb6b6; border-color: #763d44; background: #311e23; } .account-notice.is-error { color: #ffb6b6; border-color: #763d44; background: #311e23; }
.account-notice.is-status { color: #aae0cb; border-color: #365c50; background: #1b2924; } .account-notice.is-status { color: #aae0cb; border-color: #365c50; background: #1b2924; }
.account-login-message { color: #ded8ed; border-color: #514a60; background: #26222d; white-space: pre-line; }
.account-connected { display: flex; align-items: center; gap: 9px; border-top: 1px solid var(--ops-line-soft); margin-top: 32px; padding-top: 20px; color: var(--ops-faint); font-size: 12px; } .account-connected { display: flex; align-items: center; gap: 9px; border-top: 1px solid var(--ops-line-soft); margin-top: 32px; padding-top: 20px; color: var(--ops-faint); font-size: 12px; }
.account-connection-dot { width: 6px; height: 6px; background: #83bda7; border-radius: 50%; flex-shrink: 0; } .account-connection-dot { width: 6px; height: 6px; background: #83bda7; border-radius: 50%; flex-shrink: 0; }
.account-request-summary { display: flex; gap: 40px; align-items: center; padding-bottom: 24px; margin-bottom: 28px; border-bottom: 1px solid var(--ops-line-soft); } .account-request-summary { display: flex; gap: 40px; align-items: center; padding-bottom: 24px; margin-bottom: 28px; border-bottom: 1px solid var(--ops-line-soft); }
+29 -1
View File
@@ -26,6 +26,11 @@ const SELECTS: Record<string, Option[]> = {
requests_data_source: [{ value: 'always_js', label: 'Read directly from Seerr' }, { value: 'prefer_cache', label: 'Use saved requests' }], requests_data_source: [{ value: 'always_js', label: 'Read directly from Seerr' }, { value: 'prefer_cache', label: 'Use saved requests' }],
} }
const COLOR_DEFAULTS: Record<string, string> = {
site_banner_background_color: '#332814',
site_banner_border_color: '#a27b32',
}
export default function SettingField(props: Props) { export default function SettingField(props: Props) {
const { setting, label, value, help, placeholder, onChange } = props const { setting, label, value, help, placeholder, onChange } = props
const id = `setting-${setting.key}` const id = `setting-${setting.key}`
@@ -36,13 +41,15 @@ export default function SettingField(props: Props) {
const zeroAllowed = setting.key === 'log_file_backup_count' const zeroAllowed = setting.key === 'log_file_backup_count'
const minimum = zeroAllowed ? 0 : 1 const minimum = zeroAllowed ? 0 : 1
const maximum = setting.key === 'issue_confirmation_interval_value' ? 365 : setting.key.endsWith('_port') ? 65535 : undefined const maximum = setting.key === 'issue_confirmation_interval_value' ? 365 : setting.key.endsWith('_port') ? 65535 : undefined
const colorDefault = COLOR_DEFAULTS[setting.key]
const pickerValue = /^#[0-9a-f]{6}$/i.test(value) ? value : colorDefault
const aria = { id, name: setting.key, 'aria-describedby': help ? `${id}-help` : undefined } const aria = { id, name: setting.key, 'aria-describedby': help ? `${id}-help` : undefined }
if (props.boolean) { if (props.boolean) {
return ( return (
<div className="setting-field setting-switch"> <div className="setting-field setting-switch">
<div><label htmlFor={id}>{label}</label>{help && <p id={`${id}-help`}>{help}</p>}</div> <div><label htmlFor={id}>{label}</label>{help && <p id={`${id}-help`}>{help}</p>}</div>
<input {...aria} type="checkbox" role="switch" checked={value.toLowerCase() === 'true'} onChange={(event) => onChange(String(event.target.checked))} /> <input {...aria} type="checkbox" role="switch" aria-checked={value.toLowerCase() === 'true'} checked={value.toLowerCase() === 'true'} onChange={(event) => onChange(String(event.target.checked))} />
</div> </div>
) )
} }
@@ -52,6 +59,27 @@ export default function SettingField(props: Props) {
<label htmlFor={id}>{label}{setting.sensitive && setting.isSet && <small>Saved</small>}</label> <label htmlFor={id}>{label}{setting.sensitive && setting.isSet && <small>Saved</small>}</label>
{props.optionsUnavailable ? ( {props.optionsUnavailable ? (
<select {...aria} disabled value={value}><option value={value}>Save the connection, then reload available options</option></select> <select {...aria} disabled value={value}><option value={value}>Save the connection, then reload available options</option></select>
) : colorDefault ? (
<div className="setting-color-control">
<input
id={`${id}-picker`}
type="color"
aria-label={`Choose ${label.toLowerCase()}`}
value={pickerValue}
onChange={(event) => onChange(event.target.value)}
/>
<input
{...aria}
type="text"
value={value}
pattern="#[0-9A-Fa-f]{6}"
placeholder={colorDefault}
autoComplete="off"
spellCheck={false}
onChange={(event) => onChange(event.target.value)}
/>
{value ? <button type="button" className="ghost-button" onClick={() => onChange('')}>Use tone default</button> : null}
</div>
) : selectedOptions ? ( ) : selectedOptions ? (
<select {...aria} value={value} onChange={(event) => onChange(event.target.value)}> <select {...aria} value={value} onChange={(event) => onChange(event.target.value)}>
{!value && <option value="">Choose an option</option>} {!value && <option value="">Choose an option</option>}
+19 -3
View File
@@ -70,6 +70,7 @@ const BOOL_SETTINGS = new Set([
]) ])
const TEXTAREA_SETTINGS = new Set([ const TEXTAREA_SETTINGS = new Set([
'site_banner_message', 'site_banner_message',
'site_login_message',
'site_changelog', 'site_changelog',
'magent_ssl_certificate_pem', 'magent_ssl_certificate_pem',
'magent_ssl_private_key_pem', 'magent_ssl_private_key_pem',
@@ -266,14 +267,21 @@ const SITE_SECTION_GROUPS: Array<{
{ {
key: 'site-banner', key: 'site-banner',
title: 'Site Banner', title: 'Site Banner',
description: 'Control the sitewide banner message, tone, and visibility.', description: 'Control the sitewide banner message, preset tone, surrounding background and border colours, and visibility.',
keys: ['site_banner_enabled', 'site_banner_tone', 'site_banner_message'], keys: [
'site_banner_enabled',
'site_banner_tone',
'site_banner_background_color',
'site_banner_border_color',
'site_banner_message',
],
}, },
{ {
key: 'site-login', key: 'site-login',
title: 'Login Page Behaviour', title: 'Login Page Behaviour',
description: 'Control which sign-in and recovery options are shown on the logged-out login page.', description: 'Control which sign-in and recovery options are shown on the logged-out login page.',
keys: [ keys: [
'site_login_message',
'site_login_show_jellyfin_login', 'site_login_show_jellyfin_login',
'site_login_show_local_login', 'site_login_show_local_login',
'site_login_show_forgot_password', 'site_login_show_forgot_password',
@@ -517,6 +525,7 @@ const SETTING_LABEL_OVERRIDES: Record<string, string> = {
log_level: 'Application log level', log_level: 'Application log level',
log_file: 'Active log file', log_file: 'Active log file',
site_login_show_jellyfin_login: 'Login page: Jellyfin sign-in', site_login_show_jellyfin_login: 'Login page: Jellyfin sign-in',
site_login_message: 'Logged-out login page message',
site_login_show_local_login: 'Login page: local Magent sign-in', site_login_show_local_login: 'Login page: local Magent sign-in',
site_login_show_forgot_password: 'Login page: forgot password', site_login_show_forgot_password: 'Login page: forgot password',
site_login_show_signup_link: 'Login page: invite signup link', site_login_show_signup_link: 'Login page: invite signup link',
@@ -551,6 +560,9 @@ const labelFromKey = (key: string) =>
.replace('site banner enabled', 'Sitewide banner enabled') .replace('site banner enabled', 'Sitewide banner enabled')
.replace('site banner message', 'Sitewide banner message') .replace('site banner message', 'Sitewide banner message')
.replace('site banner tone', 'Sitewide banner tone') .replace('site banner tone', 'Sitewide banner tone')
.replace('site banner background color', 'Banner background colour')
.replace('site banner border color', 'Banner border colour')
.replace('site login message', 'Logged-out login page message')
.replace('site nav show requests', 'Top navigation: New Requests') .replace('site nav show requests', 'Top navigation: New Requests')
.replace('site changelog', 'Changelog text') .replace('site changelog', 'Changelog text')
@@ -1051,7 +1063,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
site_build_number: 'Build number shown in the account menu (auto-set from releases).', site_build_number: 'Build number shown in the account menu (auto-set from releases).',
site_banner_enabled: 'Enable a sitewide banner for announcements.', site_banner_enabled: 'Enable a sitewide banner for announcements.',
site_banner_message: 'Short banner message for maintenance or updates.', site_banner_message: 'Short banner message for maintenance or updates.',
site_banner_tone: 'Visual tone for the banner.', site_banner_tone: 'Preset visual tone used whenever custom colours are blank.',
site_banner_background_color: 'Optional six-digit hex colour behind the banner message. Clear it to use the selected tone.',
site_banner_border_color: 'Optional six-digit hex colour around the banner. Clear it to use the selected tone.',
site_login_message: 'Optional message shown only on the logged-out login page. Leave blank to hide it.',
site_login_show_jellyfin_login: 'Show the Jellyfin login button on the login page.', site_login_show_jellyfin_login: 'Show the Jellyfin login button on the login page.',
site_login_show_local_login: 'Show the local Magent login button on the login page.', site_login_show_local_login: 'Show the local Magent login button on the login page.',
site_login_show_forgot_password: 'Show the forgot-password link on the login page.', site_login_show_forgot_password: 'Show the forgot-password link on the login page.',
@@ -1097,6 +1112,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
radarr_base_url: 'https://radarr.example.com or 10.30.1.81:7878', radarr_base_url: 'https://radarr.example.com or 10.30.1.81:7878',
prowlarr_base_url: 'https://prowlarr.example.com or 10.30.1.81:9696', prowlarr_base_url: 'https://prowlarr.example.com or 10.30.1.81:9696',
qbittorrent_base_url: 'https://qb.example.com or 10.30.1.81:8080', qbittorrent_base_url: 'https://qb.example.com or 10.30.1.81:8080',
site_login_message: 'Sign-in information, an outage notice, or help for users…',
} }
const parseActionError = (err: unknown, fallback: string) => { const parseActionError = (err: unknown, fallback: string) => {
+5
View File
@@ -50,6 +50,9 @@
.config-subsection .setting-field p { margin: 0; color: var(--ops-muted); font: 400 12px/1.5 Inter, sans-serif; text-transform: none; } .config-subsection .setting-field p { margin: 0; color: var(--ops-muted); font: 400 12px/1.5 Inter, sans-serif; text-transform: none; }
.config-subsection .setting-field input:not([type=checkbox]), .config-subsection .setting-field select, .config-subsection .setting-field textarea { margin: 0; width: 100%; min-width: 0; padding: 10px 12px; min-height: 42px; border: 1px solid var(--ops-line); background: var(--ops-bg-2); color: var(--ops-text); font: 400 13px/1.5 Inter, sans-serif; border-radius: 8px; } .config-subsection .setting-field input:not([type=checkbox]), .config-subsection .setting-field select, .config-subsection .setting-field textarea { margin: 0; width: 100%; min-width: 0; padding: 10px 12px; min-height: 42px; border: 1px solid var(--ops-line); background: var(--ops-bg-2); color: var(--ops-text); font: 400 13px/1.5 Inter, sans-serif; border-radius: 8px; }
.config-subsection .setting-field input:focus-visible, .config-subsection .setting-field select:focus-visible, .config-subsection .setting-field textarea:focus-visible { outline: 2px solid var(--ops-primary-2); outline-offset: 2px; } .config-subsection .setting-field input:focus-visible, .config-subsection .setting-field select:focus-visible, .config-subsection .setting-field textarea:focus-visible { outline: 2px solid var(--ops-primary-2); outline-offset: 2px; }
.config-subsection .setting-color-control { display: grid; grid-template-columns: 52px minmax(140px, 1fr) auto; align-items: center; gap: 8px; }
.config-subsection .setting-color-control input[type=color] { width: 52px; min-width: 52px; padding: 4px; cursor: pointer; }
.config-subsection .setting-color-control .ghost-button { min-height: 42px; padding: 9px 12px; white-space: nowrap; }
.config-subsection .setting-field.field-span-full { grid-column: 1 / -1; } .config-subsection .setting-field.field-span-full { grid-column: 1 / -1; }
.config-subsection .setting-switch { flex-direction: row; justify-content: space-between; align-items: center; padding: 12px 0; border-bottom: 1px solid var(--ops-line-soft); gap: 24px; } .config-subsection .setting-switch { flex-direction: row; justify-content: space-between; align-items: center; padding: 12px 0; border-bottom: 1px solid var(--ops-line-soft); gap: 24px; }
.setting-switch > div { display: grid; gap: 6px; } .setting-switch > div { display: grid; gap: 6px; }
@@ -92,6 +95,8 @@
@media (max-width: 680px) { @media (max-width: 680px) {
.admin-form .admin-grid { grid-template-columns: 1fr; } .admin-form .admin-grid { grid-template-columns: 1fr; }
.admin-form .config-subsection { padding: 16px !important; } .admin-form .config-subsection { padding: 16px !important; }
.config-subsection .setting-color-control { grid-template-columns: 52px minmax(0, 1fr); }
.config-subsection .setting-color-control .ghost-button { grid-column: 1 / -1; }
.config-directory-link { flex-wrap: wrap; gap: 12px; padding: 14px; } .config-directory-link { flex-wrap: wrap; gap: 12px; padding: 14px; }
.config-link-copy { flex-basis: calc(100% - 62px); } .config-link-copy { flex-basis: calc(100% - 62px); }
.config-directory-link .config-connection-badge { margin-left: 46px; } .config-directory-link .config-connection-badge { margin-left: 46px; }
+5 -7
View File
@@ -15,7 +15,7 @@ export default function LoginPage() {
const [mode, setMode] = useState<LoginMode>('jellyfin') const [mode, setMode] = useState<LoginMode>('jellyfin')
const [options, setOptions] = useState<LoginOptions>(DEFAULT_OPTIONS) const [options, setOptions] = useState<LoginOptions>(DEFAULT_OPTIONS)
const [optionsReady, setOptionsReady] = useState(false) const [optionsReady, setOptionsReady] = useState(false)
const [banner, setBanner] = useState<{ message: string; tone: string } | null>(null) const [loginMessage, setLoginMessage] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const canSignIn = options.showJellyfinLogin || options.showLocalLogin const canSignIn = options.showJellyfinLogin || options.showLocalLogin
@@ -35,9 +35,7 @@ export default function LoginPage() {
showForgotPassword: data?.login?.showForgotPassword !== false, showForgotPassword: data?.login?.showForgotPassword !== false,
showSignupLink: data?.login?.showSignupLink !== false, showSignupLink: data?.login?.showSignupLink !== false,
}) })
if (data?.banner?.enabled && typeof data.banner.message === 'string' && data.banner.message.trim().toLowerCase() !== 'beta environment') { setLoginMessage(typeof data?.login?.message === 'string' ? data.login.message.trim() : '')
setBanner({ message: data.banner.message, tone: data.banner.tone || 'info' })
}
} catch { } catch {
// Keep the normal sign-in methods available during a settings outage. // Keep the normal sign-in methods available during a settings outage.
} finally { } finally {
@@ -84,11 +82,11 @@ export default function LoginPage() {
<AuthLayout title="Welcome back." description="Sign in to your media workspace." footer={ <AuthLayout title="Welcome back." description="Sign in to your media workspace." footer={
optionsReady && options.showSignupLink && <>Have an invite? <a href="/signup">Create an account <span aria-hidden="true"></span></a></> optionsReady && options.showSignupLink && <>Have an invite? <a href="/signup">Create an account <span aria-hidden="true"></span></a></>
}> }>
{banner && <p className={`account-notice ${['error', 'maintenance'].includes(banner.tone) ? 'is-error' : 'is-status'}`} role="status">{banner.message}</p>} {loginMessage && <p className="account-notice account-login-message" role="status">{loginMessage}</p>}
{optionsReady && options.showJellyfinLogin && options.showLocalLogin && <div className="login-methods" role="group" aria-label="Sign-in account"> {optionsReady && options.showJellyfinLogin && options.showLocalLogin && <fieldset className="login-methods" aria-label="Sign-in account">
<button type="button" aria-pressed={selectedMode === 'jellyfin'} disabled={loading} onClick={() => { setMode('jellyfin'); setError('') }}>Grizzlyflix</button> <button type="button" aria-pressed={selectedMode === 'jellyfin'} disabled={loading} onClick={() => { setMode('jellyfin'); setError('') }}>Grizzlyflix</button>
<button type="button" aria-pressed={selectedMode === 'local'} disabled={loading} onClick={() => { setMode('local'); setError('') }}>Magent</button> <button type="button" aria-pressed={selectedMode === 'local'} disabled={loading} onClick={() => { setMode('local'); setError('') }}>Magent</button>
</div>} </fieldset>}
{!optionsReady ? <p className="account-hint" role="status">Loading sign-in</p> : !canSignIn ? <p className="account-notice is-error" role="alert">Sign-in is currently unavailable. Please contact an administrator.</p> : ( {!optionsReady ? <p className="account-hint" role="status">Loading sign-in</p> : !canSignIn ? <p className="account-notice is-error" role="alert">Sign-in is currently unavailable. Please contact an administrator.</p> : (
<form className="account-form login-form" onSubmit={submit}> <form className="account-form login-form" onSubmit={submit}>
<p className="login-method-help">{selectedMode === 'jellyfin' ? 'Use your Grizzlyflix / Jellyfin account.' : 'Use your Magent account.'}</p> <p className="login-method-help">{selectedMode === 'jellyfin' ? 'Use your Grizzlyflix / Jellyfin account.' : 'Use your Magent account.'}</p>
@@ -132,6 +132,17 @@ export default function NewRequestClient() {
if (!getToken()) router.push('/login') if (!getToken()) router.push('/login')
}, [router]) }, [router])
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const requestedType = params.get('type')
const requestedQuery = params.get('query')?.trim()
if ((requestedType === 'movie' || requestedType === 'tv') && requestedQuery) {
setMediaType(requestedType)
setQuery(requestedQuery)
window.setTimeout(() => searchSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }), 80)
}
}, [])
const selectedTitleId = selected?.tmdbId const selectedTitleId = selected?.tmdbId
useEffect(() => { useEffect(() => {
if (selectedTitleId) configureSectionRef.current?.focus() if (selectedTitleId) configureSectionRef.current?.focus()
+212 -2
View File
@@ -281,12 +281,16 @@ a {
.site-banner { .site-banner {
border-radius: var(--ops-radius); border-radius: var(--ops-radius);
border: 1px solid rgba(255, 208, 130, 0.28); border: 1px solid var(--site-banner-border-color, var(--site-banner-tone-border, rgba(255, 208, 130, 0.28)));
background: rgba(103, 75, 25, 0.38); background: var(--site-banner-background-color, var(--site-banner-tone-background, rgba(103, 75, 25, 0.38)));
color: #ffe4b3; color: #ffe4b3;
font-family: "JetBrains Mono", Consolas, monospace; font-family: "JetBrains Mono", Consolas, monospace;
font-size: 0.82rem; font-size: 0.82rem;
} }
.site-banner--info { --site-banner-tone-background: rgba(46, 92, 153, 0.34); --site-banner-tone-border: rgba(126, 184, 255, 0.38); }
.site-banner--warning { --site-banner-tone-background: rgba(103, 75, 25, 0.38); --site-banner-tone-border: rgba(255, 208, 130, 0.28); }
.site-banner--error { --site-banner-tone-background: rgba(104, 36, 43, 0.4); --site-banner-tone-border: rgba(255, 128, 139, 0.38); }
.site-banner--maintenance { --site-banner-tone-background: rgba(98, 57, 27, 0.42); --site-banner-tone-border: rgba(255, 163, 92, 0.38); }
.card, .card,
.admin-card, .admin-card,
@@ -2024,6 +2028,7 @@ button:disabled,
.request-overview, .request-overview,
.request-repair-activity, .request-repair-activity,
.request-add-seasons,
.request-journey, .request-journey,
.request-advanced { .request-advanced {
border: 1px solid var(--ops-line); border: 1px solid var(--ops-line);
@@ -2199,6 +2204,30 @@ button:disabled,
} }
.request-action-feedback.is-error { color: var(--request-red); background: rgba(255, 86, 113, 0.08); } .request-action-feedback.is-error { color: var(--request-red); background: rgba(255, 86, 113, 0.08); }
.request-add-seasons {
display: grid;
gap: 18px;
padding: 20px;
border-color: rgba(126, 215, 255, 0.32);
background:
radial-gradient(circle at 5% 0%, rgba(14, 165, 233, 0.13), transparent 34%),
rgba(255, 255, 255, 0.018);
}
.request-add-seasons-heading,
.request-add-seasons-submit { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.request-add-seasons-heading > div,
.request-add-seasons-submit > div { display: grid; gap: 5px; }
.request-add-seasons-heading h2 { margin: 0; font-size: clamp(1.25rem, 2.2vw, 1.8rem); }
.request-add-seasons-heading p { max-width: 82ch; margin: 0; color: var(--ops-muted); line-height: 1.5; }
.request-add-seasons-submit {
padding: 15px;
border: 1px solid var(--ops-line-soft);
border-radius: var(--ops-radius-lg);
background: rgba(255, 255, 255, 0.024);
}
.request-add-seasons-submit small { color: var(--ops-muted); line-height: 1.45; }
.request-add-seasons-submit button { flex: 0 0 auto; min-width: 190px; min-height: 44px; }
.request-operation-progress { .request-operation-progress {
grid-column: 1 / -1; grid-column: 1 / -1;
display: grid; display: grid;
@@ -3541,6 +3570,187 @@ textarea:focus {
.config-subsection-nav { top: 62px; } .config-subsection-nav { top: 62px; }
} }
/* Global title search */
.header-nav {
display: flex;
min-width: 0;
align-items: center;
justify-content: center;
gap: 10px;
}
.header-nav .header-actions { flex: 0 0 auto; width: auto; }
.global-search {
position: relative;
z-index: 20;
flex: 0 1 240px;
width: 240px;
min-width: 170px;
}
.global-search form {
position: relative;
display: flex;
align-items: center;
}
.global-search form > svg {
position: absolute;
left: 11px;
width: 17px;
height: 17px;
fill: none;
stroke: var(--ops-muted);
stroke-linecap: round;
stroke-width: 1.8;
pointer-events: none;
}
.global-search input {
width: 100%;
height: 36px;
padding: 7px 34px 7px 36px;
border: 1px solid var(--ops-line);
border-radius: 8px;
background: var(--ops-panel-2);
color: var(--ops-text);
font-size: 0.78rem;
}
.global-search input:focus {
border-color: rgba(126, 215, 255, 0.58);
outline: 2px solid rgba(14, 165, 233, 0.16);
}
.global-search input::placeholder { color: var(--ops-muted); }
.global-search-spinner {
position: absolute;
right: 11px;
width: 14px;
height: 14px;
border: 2px solid var(--ops-line);
border-top-color: var(--ops-primary-2);
border-radius: 50%;
animation: request-operation-spin 0.75s linear infinite;
}
.global-search-results {
position: absolute;
inset: calc(100% + 8px) 0 auto;
display: grid;
max-height: min(440px, calc(100vh - 92px));
overflow-y: auto;
padding: 6px;
border: 1px solid var(--ops-line);
border-radius: 10px;
background: #1c1b1d;
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.46);
}
.global-search-results button {
display: flex;
width: 100%;
min-height: 56px;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 9px 10px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--ops-text);
text-align: left;
}
.global-search-results button:hover,
.global-search-results button:focus-visible { background: var(--ops-panel-3); }
.global-search-results button > span { display: grid; min-width: 0; gap: 2px; }
.global-search-results strong { overflow: hidden; font-size: 0.8rem; text-overflow: ellipsis; white-space: nowrap; }
.global-search-results small { color: var(--ops-muted); font-size: 0.66rem; }
.global-search-results b { flex: 0 0 auto; color: var(--ops-primary-2); font-size: 0.63rem; }
.global-search-results p { margin: 0; padding: 14px 10px; color: var(--ops-muted); font-size: 0.75rem; }
/* The finished request becomes a compact watch-or-report destination. */
.request-next-step.is-ready { padding: 0; }
.request-ready-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.request-ready-actions > section {
display: grid;
align-content: start;
gap: 8px;
min-width: 0;
padding: 20px;
}
.request-ready-actions > section + section { border-left: 1px solid var(--ops-line-soft); }
.request-ready-actions > section > strong { color: var(--ops-text); font-size: 1.12rem; }
.request-ready-actions > section > p { min-height: 44px; margin: 0; color: var(--ops-muted); line-height: 1.5; }
.request-ready-actions .request-watch-button,
.request-problem-button {
width: 100%;
min-width: 0;
min-height: 46px;
margin-top: 5px;
}
.request-problem-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
border: 1px solid rgba(126, 215, 255, 0.36);
border-radius: var(--ops-radius);
background: rgba(79, 70, 229, 0.18);
color: var(--ops-text);
font-weight: 750;
text-decoration: none;
}
.request-problem-button:hover { border-color: rgba(126, 215, 255, 0.7); background: rgba(79, 70, 229, 0.28); }
.request-ready-unavailable { margin-top: 5px; padding: 12px; border: 1px solid var(--ops-line); border-radius: var(--ops-radius); color: var(--ops-muted); font-size: 0.75rem; }
.issue-prefilled-request {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-bottom: 16px;
padding: 14px;
border: 1px solid rgba(126, 215, 255, 0.3);
border-radius: 9px;
background: rgba(14, 165, 233, 0.07);
}
.issue-prefilled-request > div { display: grid; gap: 4px; }
.issue-prefilled-request strong { color: var(--ops-text); font-size: 1rem; }
.issue-prefilled-request small { color: var(--ops-muted); }
.issue-prefilled-request > a { flex: 0 0 auto; text-decoration: none; }
@media (max-width: 1250px) {
.global-search { flex-basis: 190px; width: 190px; }
}
@media (max-width: 980px) {
.header { height: 108px; grid-template-rows: 58px 50px; }
.header-left,
.header-right { grid-row: 1; }
.header-nav {
display: flex;
grid-column: 1 / -1;
grid-row: 2;
width: 100%;
}
.header-nav .header-actions { display: none; }
.global-search { flex: 1 1 auto; width: 100%; max-width: none; }
.global-search-results { max-height: min(420px, calc(100vh - 190px)); }
.page { padding-top: 108px; }
}
@media (max-width: 680px) {
.header { height: 104px; grid-template-rows: 54px 50px; }
.page { padding-top: 104px; }
.global-search input { height: 38px; }
.request-ready-actions { grid-template-columns: 1fr; }
.request-ready-actions > section + section { border-top: 1px solid var(--ops-line-soft); border-left: 0; }
.request-ready-actions > section > p { min-height: 0; }
.request-add-seasons-heading,
.request-add-seasons-submit { align-items: stretch; flex-direction: column; }
.request-add-seasons-heading .request-live-indicator { align-self: flex-start; }
.request-add-seasons .request-season-grid { grid-template-columns: 1fr; }
.request-add-seasons-submit button { width: 100%; }
.issue-prefilled-request { align-items: stretch; flex-direction: column; }
.issue-prefilled-request > a { text-align: center; }
}
/* Guided issue reporting */ /* Guided issue reporting */
.issue-portal-page { .issue-portal-page {
display: grid; display: grid;
+39 -6
View File
@@ -586,13 +586,30 @@ export default function PortalClient({ workspace }: PortalClientProps) {
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
const raw = new URLSearchParams(window.location.search).get('item') const params = new URLSearchParams(window.location.search)
if (!raw) { const raw = params.get('item')
setPreselectedItemId(null) const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN
return
}
const parsed = Number.parseInt(raw, 10)
setPreselectedItemId(Number.isNaN(parsed) || parsed <= 0 ? null : parsed) setPreselectedItemId(Number.isNaN(parsed) || parsed <= 0 ? null : parsed)
const requestId = Number.parseInt(params.get('reportRequest') ?? '', 10)
const title = params.get('title')?.trim()
const type = params.get('type')
const rawYear = Number.parseInt(params.get('year') ?? '', 10)
if (requestId > 0 && title && (type === 'movie' || type === 'tv')) {
const media: DiscoveryResult = {
title,
year: rawYear >= 1870 && rawYear <= 2200 ? rawYear : null,
type,
requestId,
statusLabel: 'Ready to watch',
accessible: true,
}
setIssueSelectedMedia(media)
setIssueMediaTitle(media.title)
setIssueMediaType(type)
setIssueMediaQuery(`${media.title}${media.year ? ` (${media.year})` : ''}`)
setIssueStep('problem')
}
}, []) }, [])
const loadMe = async () => { const loadMe = async () => {
@@ -860,6 +877,14 @@ export default function PortalClient({ workspace }: PortalClientProps) {
} }
const payload = await response.json() as IssueTargetOptions const payload = await response.json() as IssueTargetOptions
if (version !== issueOptionsVersion.current) return if (version !== issueOptionsVersion.current) return
const verifiedMedia: DiscoveryResult = {
...media,
title: payload.title,
type: payload.request_type,
}
setIssueSelectedMedia(verifiedMedia)
setIssueMediaTitle(verifiedMedia.title)
setIssueMediaType(payload.request_type)
setIssueOptions(payload) setIssueOptions(payload)
setIssueOptionsMessage(payload.message ?? null) setIssueOptionsMessage(payload.message ?? null)
const firstSeason = payload.seasons.find((season) => season.season_number > 0) ?? payload.seasons[0] const firstSeason = payload.seasons.find((season) => season.season_number > 0) ?? payload.seasons[0]
@@ -981,12 +1006,14 @@ export default function PortalClient({ workspace }: PortalClientProps) {
setMediaServerError(null) setMediaServerError(null)
setError(null) setError(null)
setStatus(null) setStatus(null)
const shouldLoadSelectedRequest = Boolean(issueSelectedMedia?.requestId && !issueOptions)
setIssueStep(issueSelectedMedia && issueOptions ? 'symptoms' : 'media') setIssueStep(issueSelectedMedia && issueOptions ? 'symptoms' : 'media')
setSelectedSeasonNumbers([]) setSelectedSeasonNumbers([])
setSelectedEpisodeIds([]) setSelectedEpisodeIds([])
if (category === 'playback' || category === 'service_unavailable') { if (category === 'playback' || category === 'service_unavailable') {
void checkMediaServer() void checkMediaServer()
} }
if (shouldLoadSelectedRequest && issueSelectedMedia) void loadIssueOptions(issueSelectedMedia)
} }
const toggleStringChoice = ( const toggleStringChoice = (
@@ -1607,6 +1634,12 @@ export default function PortalClient({ workspace }: PortalClientProps) {
<form className="issue-guided-form" onSubmit={createGuidedIssue}> <form className="issue-guided-form" onSubmit={createGuidedIssue}>
<fieldset className="issue-wizard-fields" disabled={creating}> <fieldset className="issue-wizard-fields" disabled={creating}>
<IssueFlowStep {...stepProps('problem')} title="What is wrong?" summary={selectedIssueDefinition?.label ?? ''}> <IssueFlowStep {...stepProps('problem')} title="What is wrong?" summary={selectedIssueDefinition?.label ?? ''}>
{issueSelectedMedia?.requestId ? (
<div className="issue-prefilled-request">
<div><span className="section-kicker">Reporting a problem with</span><strong>{issueSelectedMedia.title}{issueSelectedMedia.year ? ` (${issueSelectedMedia.year})` : ''}</strong><small>Request #{issueSelectedMedia.requestId} is already selected.</small></div>
<a className="ghost-button" href={`/requests/${issueSelectedMedia.requestId}`}>Back to request</a>
</div>
) : null}
<div className="issue-category-grid"> <div className="issue-category-grid">
{ISSUE_CATEGORIES.map((category) => ( {ISSUE_CATEGORIES.map((category) => (
<button <button
+148 -39
View File
@@ -1,14 +1,15 @@
'use client' 'use client'
import PageHeading from '../../ui/PageHeading'
import RequestLanguage from './RequestLanguage'
import { lockBodyScroll } from '../../lib/scrollLock'
import LatestActivity from './LatestActivity'
import Image from 'next/image' import Image from 'next/image'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth' import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
import { canAccess } from '../../lib/features'
import { lockBodyScroll } from '../../lib/scrollLock'
import PageHeading from '../../ui/PageHeading'
import LatestActivity from './LatestActivity'
import RequestLanguage from './RequestLanguage'
type TimelineHop = { type TimelineHop = {
service: string service: string
@@ -35,6 +36,8 @@ type PipelineStage = {
missing?: number missing?: number
total?: number total?: number
seasons?: Array<{ seasonNumber: number; available: number; missing: number; total: number }> seasons?: Array<{ seasonNumber: number; available: number; missing: number; total: number }>
unmonitoredSeasons?: Array<{ seasonNumber: number; episodeCount: number; available: number }>
canAddSeasons?: boolean
missingEpisodes?: Record<string, number[]> missingEpisodes?: Record<string, number[]>
actionIds?: string[] actionIds?: string[]
visible?: boolean visible?: boolean
@@ -325,6 +328,8 @@ export default function RequestTimelinePage() {
const [historyActions, setHistoryActions] = useState<ActionHistory[]>([]) const [historyActions, setHistoryActions] = useState<ActionHistory[]>([])
const [operationProgress, setOperationProgress] = useState<OperationProgress | null>(null) const [operationProgress, setOperationProgress] = useState<OperationProgress | null>(null)
const [isAdmin, setIsAdmin] = useState(false) const [isAdmin, setIsAdmin] = useState(false)
const [canReportIssues, setCanReportIssues] = useState(false)
const [selectedAdditionalSeasons, setSelectedAdditionalSeasons] = useState<number[]>([])
const awaitingMediaIndex = Boolean( const awaitingMediaIndex = Boolean(
snapshot?.presentation?.pipeline?.some( snapshot?.presentation?.pipeline?.some(
(stage) => stage.id === 'available' && stage.state === 'active' (stage) => stage.id === 'available' && stage.state === 'active'
@@ -386,6 +391,7 @@ export default function RequestTimelinePage() {
const me = await meResponse.json() const me = await meResponse.json()
const viewerIsAdmin = me?.role === 'admin' const viewerIsAdmin = me?.role === 'admin'
setIsAdmin(viewerIsAdmin) setIsAdmin(viewerIsAdmin)
setCanReportIssues(canAccess(me, 'issues'))
if (!snapshotResponse.ok) { if (!snapshotResponse.ok) {
throw new Error(await readApiError(snapshotResponse, 'Unable to load this request.')) throw new Error(await readApiError(snapshotResponse, 'Unable to load this request.'))
} }
@@ -533,10 +539,20 @@ export default function RequestTimelinePage() {
const presentation = snapshot.presentation ?? {} const presentation = snapshot.presentation ?? {}
const pipeline = presentation.pipeline?.length ? presentation.pipeline : fallbackPipeline(snapshot) const pipeline = presentation.pipeline?.length ? presentation.pipeline : fallbackPipeline(snapshot)
const libraryStage = pipeline.find((stage) => stage.id === 'library')
const unmonitoredSeasons = libraryStage?.unmonitoredSeasons ?? []
const availableStage = pipeline.find((stage) => stage.id === 'available') const availableStage = pipeline.find((stage) => stage.id === 'available')
const mediaServerLink = availableStage?.state === 'complete' && availableStage.link const mediaServerLink = availableStage?.state === 'complete' && availableStage.link
? availableStage.link ? availableStage.link
: null : null
const requestComplete = ['COMPLETED', 'AVAILABLE'].includes(snapshot.state) || availableStage?.state === 'complete'
const issueReportParams = new URLSearchParams({
reportRequest: snapshot.request_id,
title: snapshot.title,
type: snapshot.request_type === 'tv' ? 'tv' : 'movie',
})
if (snapshot.year) issueReportParams.set('year', String(snapshot.year))
const issueReportLink = `/portal/issues?${issueReportParams.toString()}`
const statusLabel = presentation.status?.label ?? fallbackStatusLabel(snapshot.state) const statusLabel = presentation.status?.label ?? fallbackStatusLabel(snapshot.state)
const statusMeaning = presentation.status?.meaning ?? snapshot.state_reason ?? 'Magent is checking this request.' const statusMeaning = presentation.status?.meaning ?? snapshot.state_reason ?? 'Magent is checking this request.'
const download = presentation.download const download = presentation.download
@@ -674,6 +690,41 @@ export default function RequestTimelinePage() {
} }
} }
const addSelectedSeasons = async () => {
if (!selectedAdditionalSeasons.length) return
setBusyAction('add_seasons')
setActionError(null)
setActionMessage(null)
try {
const response = await trackedPost(
'Add seasons to this request',
`${getApiBase()}/requests/${snapshot.request_id}/actions/add-seasons`,
{
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ season_numbers: selectedAdditionalSeasons }),
},
)
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (!response.ok) throw new Error(await readApiError(response, 'The selected seasons could not be added.'))
const data = await response.json()
if (!isSnapshotPayload(data?.snapshot)) {
throw new Error('The seasons were added, but Magent did not return an updated request.')
}
setSnapshot(data.snapshot)
setSelectedAdditionalSeasons([])
setActionMessage(data?.message ?? 'The selected seasons were added and will now be monitored.')
} catch (error) {
console.error(error)
setActionError(error instanceof Error ? error.message : 'The selected seasons could not be added.')
} finally {
setBusyAction(null)
}
}
const runAction = async (action: RequestAction, searchOffset = 0) => { const runAction = async (action: RequestAction, searchOffset = 0) => {
if (action.requires_confirmation && !window.confirm(`Run “${action.label}”?`)) return if (action.requires_confirmation && !window.confirm(`Run “${action.label}”?`)) return
const actionPaths: Record<string, string> = { const actionPaths: Record<string, string> = {
@@ -815,40 +866,39 @@ export default function RequestTimelinePage() {
</div> </div>
)} )}
{operationProgress && <LatestActivity operation={operationProgress} besideDownload={downloadVisible} onDismiss={() => setOperationProgress(null)} />} {operationProgress && <LatestActivity operation={operationProgress} besideDownload={downloadVisible} onDismiss={() => setOperationProgress(null)} />}
<div className="request-overview-block request-next-step"> <div className={`request-overview-block request-next-step ${requestComplete ? 'is-ready' : ''}`}>
<div className="request-next-step-main"> {requestComplete ? (
<div className="request-next-step-copy"> <div className="request-ready-actions">
<span className="request-overview-label">Next step</span> <section>
<strong>{nextStep.title}</strong> <span className="request-overview-label">Ready to watch</span>
<p>{nextStep.description}</p> <strong>Watch this now!</strong>
<p>Open {snapshot.title} directly in Grizzlyflix.</p>
{mediaServerLink ? <a className="request-watch-button" href={mediaServerLink} target="_blank" rel="noreferrer">Watch on Grizzlyflix <span aria-hidden="true">&rarr;</span></a> : <span className="request-ready-unavailable">The Grizzlyflix watch link is not configured.</span>}
</section>
<section>
<span className="request-overview-label">Need help?</span>
<strong>Is there a problem with this?</strong>
<p>Let us know what is wrong and we&apos;ll attach the title and request details automatically.</p>
{canReportIssues ? <a className="request-problem-button" href={issueReportLink}>Start issue report <span aria-hidden="true">&rarr;</span></a> : <span className="request-ready-unavailable">Issue reporting is not enabled for your account.</span>}
</section>
</div> </div>
{mediaServerLink && ( ) : <>
<a <div className="request-next-step-main">
className="request-watch-button" <div className="request-next-step-copy">
href={mediaServerLink} <span className="request-overview-label">Next step</span>
target="_blank" <strong>{nextStep.title}</strong>
rel="noreferrer" <p>{nextStep.description}</p>
> </div>
Watch on Grizzlyflix <span aria-hidden="true">&rarr;</span> </div>
</a> <div className="request-action-row">
)} {recommendedActions.map((action) => (
</div> <button key={action.id} type="button" disabled={Boolean(busyAction)} onClick={() => void runAction(action)}>
<div className="request-action-row"> {busyAction === action.id ? 'Working…' : action.label}
{recommendedActions.map((action) => ( </button>
<button key={action.id} type="button" disabled={Boolean(busyAction)} onClick={() => void runAction(action)}> ))}
{busyAction === action.id ? 'Working…' : action.label} <button type="button" className="request-recheck-button" disabled={Boolean(busyAction)} onClick={() => void recheckRequest()} title="Recheck Seerr, the library collector, qBittorrent, and the media server">{busyAction === 'recheck_pipeline' ? 'Rechecking…' : 'Recheck request'}</button>
</button> </div>
))} </>}
<button
type="button"
className="request-recheck-button"
disabled={Boolean(busyAction)}
onClick={() => void recheckRequest()}
title="Recheck Seerr, the library collector, qBittorrent, and the media server"
>
{busyAction === 'recheck_pipeline' ? 'Rechecking…' : 'Recheck request'}
</button>
</div>
</div> </div>
{(actionMessage || actionError) && ( {(actionMessage || actionError) && (
<div className={`request-action-feedback ${actionError ? 'is-error' : 'is-success'}`} role="status"> <div className={`request-action-feedback ${actionError ? 'is-error' : 'is-success'}`} role="status">
@@ -857,6 +907,65 @@ export default function RequestTimelinePage() {
)} )}
</section> </section>
{snapshot.request_type === 'tv' && unmonitoredSeasons.length > 0 && (
<section className="request-add-seasons" aria-labelledby="request-add-seasons-heading">
<div className="request-add-seasons-heading">
<div>
<span className="section-kicker">Collection expansion</span>
<h2 id="request-add-seasons-heading">Add more seasons</h2>
<p>
Sonarr knows about {unmonitoredSeasons.length} additional season{unmonitoredSeasons.length === 1 ? '' : 's'} that {unmonitoredSeasons.length === 1 ? 'is' : 'are'} not currently part of this request.
</p>
</div>
<span className="request-live-indicator"><i />Available to add</span>
</div>
<fieldset className="request-season-picker">
<legend>Choose seasons to monitor and search</legend>
<div className="request-season-actions">
<button type="button" disabled={Boolean(busyAction)} onClick={() => setSelectedAdditionalSeasons(unmonitoredSeasons.map((season) => season.seasonNumber))}>Select all</button>
<button type="button" disabled={Boolean(busyAction) || selectedAdditionalSeasons.length === 0} onClick={() => setSelectedAdditionalSeasons([])}>Clear</button>
</div>
<div className="request-season-grid">
{unmonitoredSeasons.map((season) => {
const selected = selectedAdditionalSeasons.includes(season.seasonNumber)
const episodeLabel = season.episodeCount > 0
? `${season.episodeCount} known episode${season.episodeCount === 1 ? '' : 's'}`
: 'Episodes not announced yet'
return (
<label className={selected ? 'is-selected' : ''} key={season.seasonNumber}>
<input
type="checkbox"
checked={selected}
disabled={Boolean(busyAction) || libraryStage?.canAddSeasons === false}
onChange={() => setSelectedAdditionalSeasons((current) => selected
? current.filter((number) => number !== season.seasonNumber)
: [...current, season.seasonNumber].sort((left, right) => left - right))}
/>
<span>
<strong>Season {season.seasonNumber}</strong>
<small>{episodeLabel}{season.available > 0 ? ` · ${season.available} already collected` : ''}</small>
</span>
</label>
)
})}
</div>
</fieldset>
<div className="request-add-seasons-submit">
<div>
<strong>{selectedAdditionalSeasons.length ? `${selectedAdditionalSeasons.length} season${selectedAdditionalSeasons.length === 1 ? '' : 's'} selected` : 'Choose one or more seasons'}</strong>
<small>Selected seasons will be monitored in Sonarr and released missing episodes will be searched immediately.</small>
</div>
{libraryStage?.canAddSeasons === false ? (
<span className="request-ready-unavailable">Automatic collection searches are not enabled for your account.</span>
) : (
<button type="button" disabled={Boolean(busyAction) || selectedAdditionalSeasons.length === 0} onClick={() => void addSelectedSeasons()}>
{busyAction === 'add_seasons' ? 'Adding seasons…' : 'Add selected seasons'}
</button>
)}
</div>
</section>
)}
{repairActivity?.visible && ( {repairActivity?.visible && (
<section className={`request-repair-activity is-${repairActivity.state ?? 'searching'}`} aria-live="polite"> <section className={`request-repair-activity is-${repairActivity.state ?? 'searching'}`} aria-live="polite">
<div className="request-repair-heading"> <div className="request-repair-heading">
@@ -886,7 +995,7 @@ export default function RequestTimelinePage() {
</section> </section>
)} )}
<section className="request-journey" aria-labelledby="request-journey-heading"> {!requestComplete && <section className="request-journey" aria-labelledby="request-journey-heading">
<div className="request-journey-heading"> <div className="request-journey-heading">
<div> <div>
<span className="section-kicker">Live collection path</span> <span className="section-kicker">Live collection path</span>
@@ -975,7 +1084,7 @@ export default function RequestTimelinePage() {
})} })}
</div> </div>
</section> </section>}
{releasePickerOpen && ( {releasePickerOpen && (
<div className="request-release-modal-layer"> <div className="request-release-modal-layer">
+2 -1
View File
@@ -4,6 +4,7 @@ import { usePathname } from 'next/navigation'
import BrandingLogo from './BrandingLogo' import BrandingLogo from './BrandingLogo'
import HeaderActions from './HeaderActions' import HeaderActions from './HeaderActions'
import HeaderIdentity from './HeaderIdentity' import HeaderIdentity from './HeaderIdentity'
import GlobalSearch from './GlobalSearch'
import SiteStatus from './SiteStatus' import SiteStatus from './SiteStatus'
import UserViewBanner from './UserViewBanner' import UserViewBanner from './UserViewBanner'
import WorkspaceNavigation from './WorkspaceNavigation' import WorkspaceNavigation from './WorkspaceNavigation'
@@ -15,7 +16,7 @@ export default function ApplicationChrome() {
<header className="header"> <header className="header">
<div className="header-left"><a className="brand-link" href="/"><BrandingLogo className="brand-logo brand-logo--header" /><div className="brand-stack"><div className="brand">Magent</div><div className="tagline">GrizzlyFlix media operations</div></div></a></div> <div className="header-left"><a className="brand-link" href="/"><BrandingLogo className="brand-logo brand-logo--header" /><div className="brand-stack"><div className="brand">Magent</div><div className="tagline">GrizzlyFlix media operations</div></div></a></div>
<div className="header-right"><span className="beta-chip" title="Beta environment">Beta</span><HeaderIdentity /></div> <div className="header-right"><span className="beta-chip" title="Beta environment">Beta</span><HeaderIdentity /></div>
<div className="header-nav"><HeaderActions /></div> <div className="header-nav"><GlobalSearch /><HeaderActions /></div>
</header> </header>
<WorkspaceNavigation /> <WorkspaceNavigation />
<UserViewBanner /> <UserViewBanner />
+146
View File
@@ -0,0 +1,146 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useRef, useState } from 'react'
import { authFetch, getApiBase } from '../lib/auth'
import { canAccess } from '../lib/features'
import { useFeatureUser } from './FeatureGate'
type SearchResult = {
title: string
year?: number | null
type: 'movie' | 'tv'
tmdbId: number
requestId?: number | null
statusLabel?: string | null
}
export default function GlobalSearch() {
const router = useRouter()
const { user, ready } = useFeatureUser()
const root = useRef<HTMLDivElement>(null)
const requestVersion = useRef(0)
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[]>([])
const [open, setOpen] = useState(false)
const [searching, setSearching] = useState(false)
const [message, setMessage] = useState<string | null>(null)
const canSearch = canAccess(user, 'new_requests') || canAccess(user, 'issues')
const canOpenRequests = canAccess(user, 'requests')
const canCreateRequests = canAccess(user, 'new_requests')
useEffect(() => {
const close = (event: PointerEvent) => {
if (!root.current?.contains(event.target as Node)) setOpen(false)
}
document.addEventListener('pointerdown', close)
return () => document.removeEventListener('pointerdown', close)
}, [])
useEffect(() => {
const term = query.trim()
requestVersion.current += 1
const version = requestVersion.current
if (term.length < 2 || !canSearch) {
setResults([])
setSearching(false)
setMessage(null)
return
}
setSearching(true)
setMessage(null)
const controller = new AbortController()
const timer = window.setTimeout(async () => {
try {
const params = new URLSearchParams({ query: term })
const response = await authFetch(`${getApiBase()}/requests/search?${params.toString()}`, {
signal: controller.signal,
cache: 'no-store',
})
if (!response.ok) throw new Error('Search is unavailable right now.')
const payload = await response.json()
if (version !== requestVersion.current) return
const mapped = (Array.isArray(payload?.results) ? payload.results : [])
.filter((item: any) => ['movie', 'tv'].includes(item?.type) && Number(item?.tmdbId) > 0)
.slice(0, 7)
.map((item: any): SearchResult => ({
title: String(item?.title || 'Untitled'),
year: typeof item?.year === 'number' ? item.year : null,
type: item.type,
tmdbId: Number(item.tmdbId),
requestId: typeof item?.requestId === 'number' ? item.requestId : null,
statusLabel: typeof item?.statusLabel === 'string' ? item.statusLabel : null,
}))
setResults(mapped)
setMessage(mapped.length ? null : 'No matching titles found.')
} catch (error) {
if (controller.signal.aborted || version !== requestVersion.current) return
console.error(error)
setResults([])
setMessage('Search is unavailable right now.')
} finally {
if (version === requestVersion.current) setSearching(false)
}
}, 280)
return () => {
window.clearTimeout(timer)
controller.abort()
}
}, [query, canSearch])
if (!ready || !user || !canSearch) return null
const openResult = (result: SearchResult) => {
setOpen(false)
setQuery('')
if (result.requestId && canOpenRequests) {
router.push(`/requests/${result.requestId}`)
return
}
if (canCreateRequests) {
const params = new URLSearchParams({ type: result.type, query: result.title })
router.push(`/new-requests?${params.toString()}`)
return
}
router.push('/portal/issues')
}
const showResults = open && query.trim().length >= 2
return (
<div className="global-search" ref={root}>
<search>
<form onSubmit={(event) => {
event.preventDefault()
if (results[0]) openResult(results[0])
}}>
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="m16 16 4 4" /></svg>
<input
type="search"
role="combobox"
value={query}
placeholder="Search titles or requests"
aria-label="Search titles or requests"
aria-autocomplete="list"
aria-expanded={showResults}
aria-controls="global-search-results"
onFocus={() => setOpen(true)}
onKeyDown={(event) => { if (event.key === 'Escape') setOpen(false) }}
onChange={(event) => { setQuery(event.target.value); setOpen(true) }}
/>
{searching && <i className="global-search-spinner" aria-hidden="true" />}
</form>
</search>
{showResults && (
<div className="global-search-results" id="global-search-results" role="listbox">
{results.map((result) => (
<button type="button" role="option" aria-selected="false" key={`${result.type}:${result.tmdbId}`} onClick={() => openResult(result)}>
<span><strong>{result.title}</strong><small>{result.type === 'tv' ? 'TV show' : 'Movie'}{result.year ? ` · ${result.year}` : ''}</small></span>
<b>{result.requestId && canOpenRequests ? result.statusLabel || 'View request' : canCreateRequests ? 'New request' : 'Report issue'}</b>
</button>
))}
{!searching && message && <p role="status">{message}</p>}
</div>
)}
</div>
)
}
+10 -10
View File
@@ -36,16 +36,6 @@ export default function HeaderActions() {
] ]
const commonItems = [ const commonItems = [
{
href: '/insights',
label: 'My Stats',
match: (path: string) => path === '/insights' || path.startsWith('/insights/'),
},
{
href: '/',
label: 'My Requests',
match: (path: string) => path === '/' || path.startsWith('/requests/'),
},
...(showRequestsNav ...(showRequestsNav
? [ ? [
{ {
@@ -55,6 +45,16 @@ export default function HeaderActions() {
}, },
] ]
: []), : []),
{
href: '/',
label: 'My Requests',
match: (path: string) => path === '/' || path.startsWith('/requests/'),
},
{
href: '/insights',
label: 'My Stats',
match: (path: string) => path === '/insights' || path.startsWith('/insights/'),
},
{ {
href: '/portal/issues', href: '/portal/issues',
label: 'Issues', label: 'Issues',
+8 -2
View File
@@ -1,12 +1,14 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState, type CSSProperties } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type BannerInfo = { type BannerInfo = {
enabled: boolean enabled: boolean
message: string message: string
tone?: string tone?: string
backgroundColor?: string | null
borderColor?: string | null
} }
type SiteInfo = { type SiteInfo = {
@@ -52,10 +54,14 @@ export default function SiteStatus() {
const banner = info?.banner const banner = info?.banner
const tone = banner?.tone || 'info' const tone = banner?.tone || 'info'
const bannerStyle = {
'--site-banner-background-color': banner?.backgroundColor || undefined,
'--site-banner-border-color': banner?.borderColor || undefined,
} as CSSProperties
return ( return (
<> <>
{banner?.enabled && banner.message ? ( {banner?.enabled && banner.message ? (
<div className={`site-banner site-banner--${tone}`}>{banner.message}</div> <div className={`site-banner site-banner--${tone}`} style={bannerStyle}>{banner.message}</div>
) : null} ) : null}
</> </>
) )
+2 -2
View File
@@ -15,9 +15,9 @@ type NavigationItem = {
} }
const NAVIGATION: NavigationItem[] = [ const NAVIGATION: NavigationItem[] = [
{ href: '/insights', label: 'My Stats', shortLabel: 'Stats', icon: 'stats', match: (path) => path === '/insights' || path.startsWith('/insights/') },
{ href: '/', label: 'My Requests', shortLabel: 'Requests', icon: 'dashboard', match: (path) => path === '/' || path.startsWith('/requests/') },
{ href: '/new-requests', label: 'New Request', shortLabel: 'New', icon: 'media', match: (path) => path === '/new-requests' }, { href: '/new-requests', label: 'New Request', shortLabel: 'New', icon: 'media', match: (path) => path === '/new-requests' },
{ href: '/', label: 'My Requests', shortLabel: 'Requests', icon: 'dashboard', match: (path) => path === '/' || path.startsWith('/requests/') },
{ href: '/insights', label: 'My Stats', shortLabel: 'Stats', icon: 'stats', match: (path) => path === '/insights' || path.startsWith('/insights/') },
{ href: '/portal/issues', label: 'Issues', shortLabel: 'Issues', icon: 'issues', match: (path) => path.startsWith('/portal/issues') }, { href: '/portal/issues', label: 'Issues', shortLabel: 'Issues', icon: 'issues', match: (path) => path.startsWith('/portal/issues') },
{ href: '/profile/invites', label: 'Invites', shortLabel: 'Invites', icon: 'invites', match: (path) => path.startsWith('/profile/invites') }, { href: '/profile/invites', label: 'Invites', shortLabel: 'Invites', icon: 'invites', match: (path) => path.startsWith('/profile/invites') },
{ href: '/admin', label: 'Configuration', shortLabel: 'Config', icon: 'settings', adminOnly: true, match: (path) => path.startsWith('/admin') || path.startsWith('/users') }, { href: '/admin', label: 'Configuration', shortLabel: 'Config', icon: 'settings', adminOnly: true, match: (path) => path.startsWith('/admin') || path.startsWith('/users') },
+1 -1
View File
@@ -29,7 +29,7 @@ export default function WelcomePage() {
<header><span className="welcome-kicker">GrizzlyFlix + Magent</span><h1>Make yourself at home.</h1><p>Something to watch, or something to sort out?</p></header> <header><span className="welcome-kicker">GrizzlyFlix + Magent</span><h1>Make yourself at home.</h1><p>Something to watch, or something to sort out?</p></header>
{error ? <div role="alert"><p>{error}</p><button type="button" onClick={() => window.location.reload()}>Try again</button> <a href="/login">Back to sign in</a></div> : !ready ? <p role="status">Getting things ready</p> : <div className="welcome-choices"> {error ? <div role="alert"><p>{error}</p><button type="button" onClick={() => window.location.reload()}>Try again</button> <a href="/login">Back to sign in</a></div> : !ready ? <p role="status">Getting things ready</p> : <div className="welcome-choices">
{url ? <a className="welcome-choice" href={url}><span className="welcome-icon" aria-hidden="true"></span><h2>Go to GrizzlyFlix</h2><p>Find your next favourite. Watch movies and TV shows.</p><strong>Lets watch <span aria-hidden="true"></span></strong></a> : <section className="welcome-choice"><span className="welcome-icon" aria-hidden="true"></span><h2>Go to GrizzlyFlix</h2><p>The watch link hasnt been set up yet. Please ask an admin to add the public playback URL.</p></section>} {url ? <a className="welcome-choice" href={url}><span className="welcome-icon" aria-hidden="true"></span><h2>Go to GrizzlyFlix</h2><p>Find your next favourite. Watch movies and TV shows.</p><strong>Lets watch <span aria-hidden="true"></span></strong></a> : <section className="welcome-choice"><span className="welcome-icon" aria-hidden="true"></span><h2>Go to GrizzlyFlix</h2><p>The watch link hasnt been set up yet. Please ask an admin to add the public playback URL.</p></section>}
<a className="welcome-choice" href="/"><span className="welcome-icon" aria-hidden="true"></span><h2>Manage your account</h2><p>Track requests, report a problem, or update your profile.</p><strong>Open My Requests <span aria-hidden="true"></span></strong></a> <a className="welcome-choice" href="/"><span className="welcome-icon" aria-hidden="true"></span><h2>View stats &amp; requests</h2><p>Check your viewing stats, follow your requests, or make a new one.</p><strong>Open Magent <span aria-hidden="true"></span></strong></a>
</div>} </div>}
<footer>First time here? <a href="/how-it-works">Heres how it works</a>.</footer> <footer>First time here? <a href="/how-it-works">Heres how it works</a>.</footer>
</main> </main>
+60 -5
View File
@@ -4,7 +4,7 @@ const assert = require('node:assert/strict')
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright') const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright')
const base = process.env.REVIEW_BASE || 'http://127.0.0.1:3101' const base = process.env.REVIEW_BASE || 'http://127.0.0.1:3101'
const output = process.env.REVIEW_DIR const output = process.env.REVIEW_DIR
const site = { login: { showJellyfinLogin: true, showLocalLogin: true, showForgotPassword: true, showSignupLink: true }, navigation: { showRequests: true }, banner: { enabled: true, message: 'Beta environment', tone: 'warning' } } const site = { login: { message: '', showJellyfinLogin: true, showLocalLogin: true, showForgotPassword: true, showSignupLink: true }, navigation: { showRequests: true }, banner: { enabled: true, message: 'Beta environment', tone: 'warning', backgroundColor: null, borderColor: null } }
;(async () => { ;(async () => {
const browser = await chromium.launch({ headless: true }) const browser = await chromium.launch({ headless: true })
@@ -17,6 +17,18 @@ const site = { login: { showJellyfinLogin: true, showLocalLogin: true, showForgo
let provider = 'jellyfin' let provider = 'jellyfin'
let supported = true let supported = true
let savedEmail = 'member@example.com' let savedEmail = 'member@example.com'
let adminSettings = [
['site_banner_enabled', 'true'],
['site_banner_tone', 'warning'],
['site_banner_background_color', '#24172f'],
['site_banner_border_color', '#d946ef'],
['site_banner_message', 'Maintenance tonight'],
['site_login_message', 'Sign-in help is available from the media team.'],
['site_login_show_jellyfin_login', 'true'],
['site_login_show_local_login', 'true'],
['site_login_show_forgot_password', 'true'],
['site_login_show_signup_link', 'true'],
].map(([key, value]) => ({ key, value, isSet: true, source: 'db', sensitive: false }))
const calls = [] const calls = []
const errors = [] const errors = []
await context.route('**/api/**', async (route) => { await context.route('**/api/**', async (route) => {
@@ -25,7 +37,18 @@ const site = { login: { showJellyfinLogin: true, showLocalLogin: true, showForgo
const method = request.method() const method = request.method()
const reply = (json, status = 200) => route.fulfill({ status, json }) const reply = (json, status = 200) => route.fulfill({ status, json })
if (path === '/api/site/public' || path === '/api/site/info') return reply(options) if (path === '/api/site/public' || path === '/api/site/info') return reply(options)
if (path === '/api/auth/me') return reply({ username: 'Grizzlyflix member', role: 'user' }) if (path === '/api/auth/me') return reply({ username: 'Grizzlyflix member', role: 'admin' })
if (path === '/api/admin/settings') {
if (method === 'PUT') {
const body = request.postDataJSON()
calls.push({ path, method, body: request.postData() })
adminSettings = adminSettings.map((setting) => Object.hasOwn(body, setting.key)
? { ...setting, value: body[setting.key], isSet: Boolean(body[setting.key]) }
: setting)
return reply({ status: 'ok', updated: Object.keys(body).length })
}
return reply({ settings: adminSettings })
}
if (path === '/api/auth/profile') return reply(profileStatus === 200 ? { if (path === '/api/auth/profile') return reply(profileStatus === 200 ? {
user: { username: 'Grizzlyflix member', role: 'user', email: savedEmail, auth_provider: provider, password_provider: provider, password_change_supported: supported }, user: { username: 'Grizzlyflix member', role: 'user', email: savedEmail, auth_provider: provider, password_provider: provider, password_change_supported: supported },
stats: { total: 12, ready: 9, in_progress: 3 }, stats: { total: 12, ready: 9, in_progress: 3 },
@@ -86,20 +109,52 @@ const site = { login: { showJellyfinLogin: true, showLocalLogin: true, showForgo
options.login.showLocalLogin = false options.login.showLocalLogin = false
options.login.showForgotPassword = false options.login.showForgotPassword = false
options.login.showSignupLink = false options.login.showSignupLink = false
options.login.message = 'Sign-in help is available from the media team.'
options.banner.message = 'Maintenance tonight' options.banner.message = 'Maintenance tonight'
options.banner.backgroundColor = '#24172f'
options.banner.borderColor = '#d946ef'
await openLogin() await openLogin()
assert.equal(await page.getByRole('button', { name: 'Sign in', exact: true }).count(), 0) assert.equal(await page.getByRole('button', { name: 'Sign in', exact: true }).count(), 0)
assert.equal(await page.getByRole('link', { name: 'Forgot password?' }).count(), 0) assert.equal(await page.getByRole('link', { name: 'Forgot password?' }).count(), 0)
assert.equal(await page.getByRole('link', { name: /Create an account/ }).count(), 0) assert.equal(await page.getByRole('link', { name: /Create an account/ }).count(), 0)
assert(await page.getByText('Maintenance tonight').isVisible()) assert.equal(await page.getByText('Maintenance tonight').count(), 0)
assert(await page.getByText('Sign-in help is available from the media team.').isVisible())
options = structuredClone(site) options = structuredClone(site)
options.login.showLocalLogin = false options.login.showLocalLogin = false
await openLogin() await openLogin()
loginStatus = 200 loginStatus = 200
await login() await login()
await page.waitForURL(base + '/') await page.waitForURL(base + '/welcome')
assert((await context.cookies()).some((cookie) => cookie.name === 'magent_logged_in' && cookie.value === '1')) assert((await context.cookies()).some((cookie) => cookie.name === 'magent_logged_in' && cookie.value === '1'))
console.log('PASS: both sign-in providers, disabled methods, error states, password visibility, redirect and notices') console.log('PASS: both sign-in providers, disabled methods, error states, password visibility, redirect, login-only message and hidden site banner')
options.banner = { enabled: true, message: 'Custom site banner', tone: 'warning', backgroundColor: '#24172f', borderColor: '#d946ef' }
await page.goto(base + '/admin/site')
const signedInBannerStyle = await page.getByText('Custom site banner', { exact: true }).evaluate((element) => ({
background: getComputedStyle(element).backgroundColor,
border: getComputedStyle(element).borderTopColor,
}))
assert.deepEqual(signedInBannerStyle, { background: 'rgb(36, 23, 47)', border: 'rgb(217, 70, 239)' })
const bannerRegion = page.locator('#config-site-banner')
const backgroundHex = bannerRegion.getByLabel('Banner background colour', { exact: true })
await backgroundHex.waitFor()
await backgroundHex.fill('red')
assert.equal(await backgroundHex.evaluate((element) => element.checkValidity()), false)
await bannerRegion.getByLabel('Choose banner background colour', { exact: true }).fill('#112233')
assert.equal(await backgroundHex.inputValue(), '#112233')
await bannerRegion.getByRole('button', { name: 'Save changes', exact: true }).click()
await bannerRegion.getByText('Settings saved.').waitFor()
const bannerSave = calls.filter((call) => call.path === '/api/admin/settings').at(-1)
assert.equal(JSON.parse(bannerSave.body).site_banner_background_color, '#112233')
const loginRegion = page.locator('#config-site-login')
await loginRegion.getByLabel('Logged-out login page message', { exact: true }).fill('Welcome. Contact support if you cannot sign in.')
await loginRegion.getByRole('button', { name: 'Save changes', exact: true }).click()
await loginRegion.getByText('Settings saved.').waitFor()
const loginSave = calls.filter((call) => call.path === '/api/admin/settings').at(-1)
assert.equal(JSON.parse(loginSave.body).site_login_message, 'Welcome. Contact support if you cannot sign in.')
assert(!Object.hasOwn(JSON.parse(loginSave.body), 'site_banner_message'))
console.log('PASS: Site & sign-in colour picker, native hex validation, login message and region-only saves')
await page.goto(base + '/profile') await page.goto(base + '/profile')
const email = page.getByLabel('Email address', { exact: true }) const email = page.getByLabel('Email address', { exact: true })
+101
View File
@@ -0,0 +1,101 @@
const assert = require('node:assert/strict');
const { chromium } = require(process.env.PLAYWRIGHT_PACKAGE || 'playwright');
const base = process.env.REVIEW_BASE || 'http://localhost:3114';
const snapshot = added => ({
request_id: '3580',
title: 'Suits',
year: 2011,
request_type: 'tv',
state: added ? 'IMPORTING' : 'AVAILABLE',
timeline: [],
actions: [],
presentation: {
status: {
label: added ? 'Partially collected — 32 episodes still missing' : 'Available to watch',
meaning: added
? 'Seasons 8 and 9 are now monitored and collection is in progress.'
: 'The originally requested collection is complete and available on the media server.',
},
nextStep: { title: added ? 'Wait for search results' : 'Ready to watch', description: 'Magent is checking Sonarr.', actionIds: [] },
pipeline: [
{
id: 'library',
label: 'Library collection',
state: added ? 'partial' : 'complete',
summary: added ? 'Seasons 8 and 9 are being collected.' : 'Collection complete — no search needed',
available: 124,
missing: added ? 32 : 0,
total: added ? 156 : 124,
seasons: added
? [
{ seasonNumber: 7, available: 16, missing: 0, total: 16 },
{ seasonNumber: 8, available: 0, missing: 16, total: 16 },
{ seasonNumber: 9, available: 0, missing: 16, total: 16 },
]
: [{ seasonNumber: 7, available: 16, missing: 0, total: 16 }],
unmonitoredSeasons: added
? []
: [
{ seasonNumber: 8, episodeCount: 16, available: 0 },
{ seasonNumber: 9, episodeCount: 16, available: 0 },
],
canAddSeasons: true,
},
{ id: 'available', label: 'Available to watch', state: added ? 'partial' : 'complete', summary: 'Suits is available.', link: 'https://watch.example.test/suits' },
],
},
});
(async () => {
const browser = await chromium.launch();
try {
const calls = [];
const errors = [];
for (const width of [1440, 390]) {
let added = false;
const context = await browser.newContext({ viewport: { width, height: 950 } });
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }]);
await context.route('**/api/**', async route => {
const request = route.request();
const path = new URL(request.url()).pathname;
const reply = json => route.fulfill({ json });
if (path === '/api/auth/me') return reply({ username: 'Viewer', role: 'user', auto_search_enabled: true, features: { requests: true, new_requests: true, stats: true, issues: true } });
if (path === '/api/site/info') return reply({ mediaServerUrl: 'https://watch.example.test/' });
if (path === '/api/requests/3580/snapshot') return reply(snapshot(added));
if (path === '/api/requests/3580/language') return reply({ language: null });
if (path === '/api/requests/3580/actions/add-seasons' && request.method() === 'POST') {
calls.push(request.postDataJSON());
added = true;
return reply({ status: 'ok', message: 'Seasons 8, 9 added to Sonarr. Searching for 32 released missing episodes.', snapshot: snapshot(true) });
}
if (path.startsWith('/api/operations/')) return reply({ id: 'fixture', label: 'Add seasons', status: 'complete', events: [] });
if (path.includes('/events/stream')) return route.fulfill({ contentType: 'text/event-stream', body: ': fixture\n\n' });
if (path.includes('/branding/')) return route.fulfill({ status: 404 });
return reply({ navigation: { showRequests: true }, items: [], total: 0, services: [] });
});
const page = await context.newPage();
page.on('pageerror', error => errors.push(error.message));
await page.goto(base + '/requests/3580');
await page.getByRole('heading', { name: 'Add more seasons' }).waitFor();
await page.getByText('Season 8', { exact: true }).waitFor();
await page.getByText('Season 9', { exact: true }).waitFor();
assert.equal(await page.getByRole('checkbox').count(), 2);
await page.getByRole('button', { name: 'Select all' }).click();
assert(await page.getByText('2 seasons selected', { exact: true }).isVisible());
await page.getByRole('button', { name: 'Add selected seasons' }).click();
await page.getByText('Seasons 8, 9 added to Sonarr. Searching for 32 released missing episodes.', { exact: true }).waitFor();
assert.equal(await page.getByRole('heading', { name: 'Add more seasons' }).count(), 0);
assert(await page.getByRole('heading', { name: 'Where your request is now' }).isVisible());
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
await context.close();
}
assert.deepEqual(calls, [{ season_numbers: [8, 9] }, { season_numbers: [8, 9] }]);
assert.deepEqual(errors, []);
console.log('Passed: completed TV requests show unmonitored seasons and add them through Sonarr on desktop/mobile. APIs intercepted.');
} finally {
await browser.close();
}
})().catch(error => { console.error(error); process.exitCode = 1; });
+115
View File
@@ -0,0 +1,115 @@
const assert = require('node:assert/strict');
const { chromium } = require(process.env.PLAYWRIGHT_PACKAGE || 'playwright');
const base = process.env.REVIEW_BASE || 'http://localhost:3114';
(async () => {
const browser = await chromium.launch();
try {
const context = await browser.newContext();
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }]);
const calls = [];
const errors = [];
await context.route('**/api/**', async route => {
const request = route.request();
const path = new URL(request.url()).pathname;
const reply = json => route.fulfill({ json });
if (path === '/api/auth/me') return reply({ username: 'Viewer', role: 'user', features: { requests: true, new_requests: true, stats: true, issues: true, invites: true } });
if (path === '/api/site/info') return reply({ mediaServerUrl: 'https://watch.example.test/' });
if (path === '/api/requests/search') return reply({ results: [
{ title: 'Drive', type: 'movie', tmdbId: 64690, year: 2011, requestId: 42, statusLabel: 'Ready to watch' },
{ title: 'Drive Away Dolls', type: 'movie', tmdbId: 957304, year: 2024 },
] });
if (path === '/api/requests/42/snapshot') return reply({
request_id: '42', title: 'Drive', year: 2011, request_type: 'movie', state: 'AVAILABLE', timeline: [], actions: [],
presentation: {
status: { label: 'Available to watch', meaning: 'Collection is complete and the title is available on the media server.' },
nextStep: { title: 'Ready to watch', description: 'Open the title when you are ready.', actionIds: [] },
pipeline: [{ id: 'available', label: 'Available to watch', state: 'complete', summary: 'Ready', link: 'https://watch.example.test/movie/drive' }],
},
});
if (path === '/api/requests/42/language') return reply({ language: null });
if (path === '/api/requests/42/issue-options') return reply({
request_id: '42', request_type: 'movie', title: 'Drive', can_act: true,
movie: { selected_label: 'Drive', has_file: true, missing: false, best_fit: true, file_id: 77 },
seasons: [], episodes: [],
});
if (path === '/api/portal/items' && request.method() === 'POST') {
calls.push({ path, body: request.postDataJSON() });
return reply({ item: { id: 73 } });
}
if (path === '/api/requests/42/actions/replace' && request.method() === 'POST') {
calls.push({ path, body: request.postDataJSON() });
return reply({ status: 'ok', message: 'Replacement queued.' });
}
if (path.includes('/events/stream')) return route.fulfill({ contentType: 'text/event-stream', body: ': fixture\n\n' });
if (path.includes('/branding/')) return route.fulfill({ status: 404 });
return reply({ navigation: { showRequests: true }, items: [], total: 0, services: [] });
});
const page = await context.newPage();
page.on('pageerror', error => errors.push(error.message));
const active = () => page.locator('.issue-procedure-step.is-current');
const visibleStep = async title => {
await active().getByRole('heading', { name: title, exact: true }).waitFor();
assert.equal(await page.locator('.issue-procedure-step.is-current').count(), 1);
};
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 950 });
await page.goto(base + '/requests/42');
await page.getByText('Watch this now!', { exact: true }).waitFor();
assert.equal(await page.getByRole('heading', { name: 'Where your request is now' }).count(), 0);
assert.equal(await page.getByRole('link', { name: /Watch on Grizzlyflix/ }).getAttribute('href'), 'https://watch.example.test/movie/drive');
const search = page.getByRole('combobox', { name: 'Search titles or requests' });
assert(await search.isVisible());
await search.fill('Drive');
await page.getByRole('option', { name: /Drive.*Ready to watch/ }).waitFor();
await page.keyboard.press('Escape');
await page.getByRole('link', { name: /Start issue report/ }).click();
await page.waitForURL('**/portal/issues?*');
assert.equal(new URL(page.url()).searchParams.get('reportRequest'), '42');
await visibleStep('What is wrong?');
await active().locator('.issue-prefilled-request').getByText('Drive (2011)', { exact: true }).waitFor();
await active().locator('.issue-prefilled-request').getByText(/Request #42 is already selected/).waitFor();
await active().getByRole('button').filter({ hasText: 'Picture or file is broken' }).click();
await visibleStep('What needs to be corrected?');
await active().getByRole('button', { name: 'Visual artefacts or corruption', exact: true }).click();
await active().getByRole('button', { name: 'Continue', exact: true }).click();
await visibleStep('Review and submit');
await active().getByRole('button', { name: /Submit/ }).click();
await visibleStep('What is wrong?');
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
if (width === 1440) {
const labels = await page.locator('.header-actions a').allTextContents();
assert(labels.indexOf('New Requests') < labels.indexOf('My Requests'));
assert(labels.indexOf('My Requests') < labels.indexOf('My Stats'));
}
}
await page.goto(base + '/welcome');
await page.getByRole('heading', { name: 'View stats & requests' }).waitFor();
await page.getByText('Check your viewing stats, follow your requests, or make a new one.', { exact: true }).waitFor();
const issueCalls = calls.filter(call => call.path === '/api/portal/items');
const replaceCalls = calls.filter(call => call.path === '/api/requests/42/actions/replace');
assert.equal(issueCalls.length, 2);
assert.equal(replaceCalls.length, 2);
assert(issueCalls.every(({ body }) =>
body.kind === 'issue'
&& body.title === 'Replace media: Drive'
&& body.issue_type === 'broken_media'
&& body.external_ref === '/requests/42'
&& body.description.includes('Problem: Picture or file is broken')
&& body.description.includes('What needs correction: Visual artefacts or corruption')
&& body.description.includes('Magent request: #42')));
assert(replaceCalls.every(({ body }) =>
JSON.stringify(body) === JSON.stringify({ issue_id: 73, file_ids: [77], confirmed: true })));
assert.deepEqual(errors, []);
console.log('Passed: completed requests hand off to the guided issue pipeline, create linked issues, start repairs, and preserve global navigation on desktop/mobile. APIs intercepted.');
} finally {
await browser.close();
}
})().catch(error => { console.error(error); process.exitCode = 1; });