Compare commits
5
Commits
dd51332f3c
...
aed1bf9256
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aed1bf9256 | ||
|
|
05a540ecbb | ||
|
|
d75f36c691 | ||
|
|
3465343a69 | ||
|
|
4ba1a5763e |
@@ -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")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">→</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'll attach the title and request details automatically.</p>
|
||||||
|
{canReportIssues ? <a className="request-problem-button" href={issueReportLink}>Start issue report <span aria-hidden="true">→</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">→</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">
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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') },
|
||||||
|
|||||||
@@ -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>Let’s 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 hasn’t 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>Let’s 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 hasn’t 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 & 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">Here’s how it works</a>.</footer>
|
<footer>First time here? <a href="/how-it-works">Here’s how it works</a>.</footer>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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; });
|
||||||
@@ -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; });
|
||||||
Reference in New Issue
Block a user