From c9718c548345e954406af38a0628c2afa8286376 Mon Sep 17 00:00:00 2001 From: Emily Boudreaux Date: Fri, 20 Mar 2026 15:00:24 -0400 Subject: [PATCH] feat(main): main --- AGENTS.md | 96 ++ api/api.py | 5 + api/routers/auth.py | 105 ++ api/routers/backups.py | 156 +++ api/routers/channel.py | 18 +- api/tests/test_auth.py | 53 + api/tests/test_backups.py | 82 ++ core/apps.py | 12 + core/management/commands/backup_now.py | 12 + core/management/commands/run_backup_worker.py | 30 + core/migrations/0006_channel_requires_auth.py | 21 + core/migrations/0007_backupschedule.py | 27 + core/models.py | 25 + core/scripts/__init__.py | 0 core/scripts/make_user.py | 41 + core/services/backups.py | 227 ++++ core/services/bootstrap.py | 92 ++ docker-compose.yml | 15 + frontend/src/App.jsx | 97 +- frontend/src/AuthContext.jsx | 56 + frontend/src/api.js | 42 + frontend/src/components/AdminSetup.jsx | 84 ++ frontend/src/components/LoginModal.css | 117 ++ frontend/src/components/LoginModal.jsx | 73 ++ frontend/src/components/Settings.jsx | 409 +++++-- frontend/src/index.css | 1086 ++++++++++------- pyproject.toml | 10 + pytv/settings.py | 11 + tests/test_channel_auth.py | 68 ++ uv.lock | 2 +- 30 files changed, 2513 insertions(+), 559 deletions(-) create mode 100644 AGENTS.md create mode 100644 api/routers/auth.py create mode 100644 api/routers/backups.py create mode 100644 api/tests/test_auth.py create mode 100644 api/tests/test_backups.py create mode 100644 core/management/commands/backup_now.py create mode 100644 core/management/commands/run_backup_worker.py create mode 100644 core/migrations/0006_channel_requires_auth.py create mode 100644 core/migrations/0007_backupschedule.py create mode 100644 core/scripts/__init__.py create mode 100644 core/scripts/make_user.py create mode 100644 core/services/backups.py create mode 100644 core/services/bootstrap.py create mode 100644 frontend/src/AuthContext.jsx create mode 100644 frontend/src/components/AdminSetup.jsx create mode 100644 frontend/src/components/LoginModal.css create mode 100644 frontend/src/components/LoginModal.jsx create mode 100644 tests/test_channel_auth.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..41a905c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,96 @@ +# AGENTS.md + +## What this project is +- PYTV is a Django + Django-Ninja backend (`core/`, `api/`, `pytv/`) with a React/Vite frontend (`frontend/`) for a "cable TV" style experience. +- Core domain model is in `core/models.py`: `Channel`, `ScheduleTemplate`/`ScheduleBlock`, `Airing`, `MediaSource`, `MediaItem`. +- API entrypoint is `api/api.py`; all HTTP API routes are mounted under `/api/` in `pytv/urls.py`. + +## Architecture and data flow to understand first +- Schedule generation: `api/routers/schedule.py` -> `core/services/scheduler.py` (`ScheduleGenerator.generate_for_date`) -> creates `Airing` rows. +- Playback signaling: `api/routers/channel.py` (`/channel/{id}/now`, `/airings`) computes `exact_playback_offset_seconds` for frontend sync. +- Media caching pipeline for YouTube sources: + - metadata sync only: `core/services/youtube.py::sync_source` + - just-in-time download: `core/services/cache.py::run_cache` + `youtube.download_for_airing` + - emergency replacement (<1h before air with no cache): `ScheduleGenerator.replace_undownloaded_airings`. +- Local media serving in dev uses range-aware endpoint `api/views.py::serve_video_with_range` mounted at `/media/*` when `DEBUG=True`. + +## Backend conventions (project-specific) +- API style: Django Ninja `Schema`/Pydantic payloads in routers under `api/routers/`. +- Auth pattern: session auth + CSRF cookie (`api/routers/auth.py`); frontend sends `withCredentials` and `X-CSRFToken` (`frontend/src/api.js`). +- Session persistence is settings-driven; use `SESSION_COOKIE_AGE`/`SESSION_EXPIRE_AT_BROWSER_CLOSE` in `pytv/settings.py` (do not hardcode lifetimes in routers/frontend). +- `Channel.requires_auth` is actively enforced in channel endpoints (`/now`, `/airings`, `/status`). +- Source-rule behavior is business-critical: `allow/prefer/avoid/block` weighting in `core/services/scheduler.py` (not just CRUD metadata). +- In `api/routers/sources.py`, literal routes (e.g. `/cache-upcoming`) must stay before parameterized routes (explicitly documented in file). + +## Frontend/backend integration points +- Frontend API client is centralized in `frontend/src/api.js` (do not scatter fetch logic). +- Dev proxy is configured in `frontend/vite.config.js` for both `/api` and `/media` to `http://localhost:8000`. +- Channel playback UX depends on `frontend/src/components/ChannelTuner.jsx` triple-buffer behavior and backend-provided offset. + +## Developer workflows (discovered commands) +```bash +# Local backend (from repo root) +python manage.py migrate +python manage.py runserver 0.0.0.0:8000 +python manage.py seed +python manage.py cache_upcoming --hours 24 +python manage.py run_cache_worker --interval 600 --hours 24 +python manage.py backup_now +python manage.py run_backup_worker --interval 60 +python manage.py state --channel --test-generate + +# Tests (pytest configured via pytest.ini) +pytest + +# Frontend (from frontend/) +npm install +npm run dev +npm run build +npm run lint + +# Docker stack (repo root) +docker compose up --build + +# Docker migrations (ALWAYS run inside the web container) +docker compose exec web python manage.py migrate + +# Local DB reset (admin/dev only, destructive) +docker compose exec web python manage.py flush --no-input +docker compose exec web python manage.py migrate + +# Optional: reseed mock data after reset +docker compose exec web python manage.py seed + +# Optional: run tests in the same containerized environment +docker compose exec web pytest +``` + +## Agent execution safety rails +- In this repo, assume the user controls long-running processes. Do not start/stop `docker compose up`, Django dev server, or Vite dev server unless explicitly asked. +- Prefer read-only investigation first (`read_file`, targeted tests) before broad edits. +- Keep business logic in `core/services/`; routers should remain thin orchestration/API layers. +- Preserve public API shapes used by frontend (`frontend/src/api.js`) unless the task explicitly includes coordinated frontend updates. +- Do not reorder literal and parameterized source routes in `api/routers/sources.py` in a way that changes URL matching behavior. +- Treat `cached_file_path` and `/media/...` URL behavior as compatibility-sensitive; verify signaling paths when changing cache/playback logic. +- Backups are compatibility-sensitive and stored at fixed path `/Backups`; do not add user-configurable backup destination paths. + +## Containerized dev/test expectations +- The user runs Docker and dev servers; agents should not spin them up by default. +- If a schema change is introduced, run migrations via container exec, not host Python: + - `docker compose exec web python manage.py makemigrations` + - `docker compose exec web python manage.py migrate` +- Prefer running validation commands inside `web` for parity with runtime dependencies (yt-dlp/node/ffprobe path assumptions). +- When sharing run steps in PR notes, provide container commands first, then local equivalents as secondary options. + +## Testing map and where to extend +- API router tests: `api/tests/test_*.py`. +- End-to-end domain behavior tests: `tests/test_channel_actions.py`, `tests/test_source_rules.py`, `tests/test_playback_sync.py`, `tests/test_channel_auth.py`. +- If changing scheduler/cache/youtube logic, update both API-level tests and service-behavior tests. + +## Practical guardrails for AI agents +- Prefer editing service-layer logic in `core/services/` over embedding business rules in routers. +- Preserve idempotent schedule generation semantics (`generate_for_date` clears/rebuilds block window airings). +- Keep `MediaItem.cached_file_path` semantics intact: frontend playback and cache status depend on it. +- Validate cross-layer changes by exercising both API endpoints and frontend assumptions around `/media/...` URLs. + + diff --git a/api/api.py b/api/api.py index 1dbdb20..6905766 100644 --- a/api/api.py +++ b/api/api.py @@ -7,6 +7,8 @@ from api.routers.channel import router as channel_router from api.routers.schedule import router as schedule_router from api.routers.user import router as user_router from api.routers.sources import router as sources_router +from api.routers.auth import router as auth_router +from api.routers.backups import router as backups_router api.add_router("/library/", library_router) api.add_router("/collections/", collections_router) @@ -14,3 +16,6 @@ api.add_router("/channel/", channel_router) api.add_router("/schedule/", schedule_router) api.add_router("/user/", user_router) api.add_router("/sources/", sources_router) +api.add_router("/auth/", auth_router) +api.add_router("/backups/", backups_router) + diff --git a/api/routers/auth.py b/api/routers/auth.py new file mode 100644 index 0000000..7262eff --- /dev/null +++ b/api/routers/auth.py @@ -0,0 +1,105 @@ +from ninja import Router, Schema +from django.contrib.auth import authenticate, login, logout +from django.http import JsonResponse +from django.conf import settings +from ninja.errors import HttpError +from pydantic import BaseModel +from core.models import AppUser + +router = Router(tags=["auth"]) + +class LoginSchema(Schema): + username: str + password: str + +class UserSchema(Schema): + id: int + username: str + email: str + is_staff: bool + is_superuser: bool + +class SetupSchema(Schema): + username: str + password: str + email: str + +@router.get("/has-users") +def check_has_users(request): + """Returns True if any users exist in the database.""" + return {"has_users": AppUser.objects.exists()} + +@router.post("/setup") +def setup_admin(request, payload: SetupSchema): + """Allows creating a superuser if and only if the database is entirely empty.""" + if AppUser.objects.exists(): + raise HttpError(403, "Database already has users. Cannot run initial setup.") + + user = AppUser.objects.create_superuser( + username=payload.username, + email=payload.email, + password=payload.password + ) + + # Bootstrap a default library now that the first user exists. + from core.services.bootstrap import ensure_default_library + ensure_default_library() + + # Log them in automatically + user = authenticate(request, username=payload.username, password=payload.password) + login(request, user) + request.session.set_expiry(settings.SESSION_COOKIE_AGE) + + from django.middleware.csrf import get_token + csrf_token = get_token(request) + + return { + "success": True, + "user": { + "id": user.id, + "username": user.username, + "email": user.email, + "is_staff": user.is_staff, + "is_superuser": user.is_superuser + }, + "csrf_token": csrf_token + } + +@router.post("/login") +def auth_login(request, payload: LoginSchema): + # Passwords in django are already PBKDF2 hashed and verified through `authenticate` + user = authenticate(request, username=payload.username, password=payload.password) + + if user is not None: + login(request, user) + request.session.set_expiry(settings.SESSION_COOKIE_AGE) + # We also need to get the CSRF token to be placed in the cookie so the frontend can send it back natively. + from django.middleware.csrf import get_token + csrf_token = get_token(request) + return { + "success": True, + "user": { + "id": user.id, + "username": user.username, + "email": user.email, + "is_staff": user.is_staff, + "is_superuser": user.is_superuser + }, + "csrf_token": csrf_token + } + else: + raise HttpError(401, "Invalid username or password") + +@router.post("/logout") +def auth_logout(request): + logout(request) + resp = JsonResponse({"success": True}) + resp.delete_cookie(settings.SESSION_COOKIE_NAME, path='/') + return resp + +@router.get("/me", response=UserSchema) +def auth_me(request): + if request.user.is_authenticated: + return request.user + else: + raise HttpError(401, "Not authenticated") diff --git a/api/routers/backups.py b/api/routers/backups.py new file mode 100644 index 0000000..de4056e --- /dev/null +++ b/api/routers/backups.py @@ -0,0 +1,156 @@ +from datetime import datetime + +from django.http import FileResponse +from ninja import File, Router, Schema +from ninja.errors import HttpError +from ninja.files import UploadedFile + +from core.models import BackupSchedule +from core.services.backups import ( + compute_next_run, + create_backup_now, + get_backup_file, + import_backup_content, + is_backup_due, + list_backups, +) + +router = Router(tags=["backups"]) + + +class BackupScheduleSchema(Schema): + enabled: bool + frequency: str + minute: int + hour: int + day_of_week: int + day_of_month: int + retention_count: int + last_run_at: datetime | None = None + next_run_at: datetime | None = None + + +class BackupScheduleUpdateSchema(Schema): + enabled: bool + frequency: str + minute: int = 0 + hour: int = 2 + day_of_week: int = 0 + day_of_month: int = 1 + retention_count: int = 14 + + +class BackupFileSchema(Schema): + filename: str + size_bytes: int + created_at: str + + +def _require_admin(request): + if not request.user.is_authenticated: + raise HttpError(401, "Authentication required.") + if not request.user.is_staff: + raise HttpError(403, "Admin permissions required.") + + +@router.get("/settings", response=BackupScheduleSchema) +def get_backup_schedule(request): + _require_admin(request) + schedule = BackupSchedule.get_solo() + return { + "enabled": schedule.enabled, + "frequency": schedule.frequency, + "minute": schedule.minute, + "hour": schedule.hour, + "day_of_week": schedule.day_of_week, + "day_of_month": schedule.day_of_month, + "retention_count": schedule.retention_count, + "last_run_at": schedule.last_run_at, + "next_run_at": compute_next_run(schedule), + } + + +@router.put("/settings", response=BackupScheduleSchema) +def update_backup_schedule(request, payload: BackupScheduleUpdateSchema): + _require_admin(request) + if payload.frequency not in BackupSchedule.Frequency.values: + raise HttpError(400, "Invalid frequency") + if not 0 <= payload.minute <= 59: + raise HttpError(400, "minute must be 0..59") + if not 0 <= payload.hour <= 23: + raise HttpError(400, "hour must be 0..23") + if not 0 <= payload.day_of_week <= 6: + raise HttpError(400, "day_of_week must be 0..6") + if not 1 <= payload.day_of_month <= 28: + raise HttpError(400, "day_of_month must be 1..28") + if payload.retention_count < 1: + raise HttpError(400, "retention_count must be >= 1") + + schedule = BackupSchedule.get_solo() + for attr, value in payload.dict().items(): + setattr(schedule, attr, value) + schedule.save() + + return { + "enabled": schedule.enabled, + "frequency": schedule.frequency, + "minute": schedule.minute, + "hour": schedule.hour, + "day_of_week": schedule.day_of_week, + "day_of_month": schedule.day_of_month, + "retention_count": schedule.retention_count, + "last_run_at": schedule.last_run_at, + "next_run_at": compute_next_run(schedule), + } + + +@router.post("/run") +def run_backup_now(request): + _require_admin(request) + path = create_backup_now() + return {"status": "ok", "filename": path.name} + + +@router.get("/", response=list[BackupFileSchema]) +def get_backups(request): + _require_admin(request) + return [x.__dict__ for x in list_backups()] + + +@router.get("/{filename}/download") +def download_backup(request, filename: str): + _require_admin(request) + try: + target = get_backup_file(filename) + except FileNotFoundError: + raise HttpError(404, "Backup not found") + + response = FileResponse(open(target, "rb"), content_type="application/json") + response["Content-Disposition"] = f'attachment; filename="{target.name}"' + return response + + +@router.post("/import") +def import_backup(request, file: UploadedFile = File(...), mode: str = "append"): + _require_admin(request) + if mode not in {"append", "override"}: + raise HttpError(400, "mode must be append or override") + + content = file.read().decode("utf-8") + try: + result = import_backup_content(content, mode=mode) + except ValueError as exc: + raise HttpError(400, str(exc)) + + return {"status": "ok", **result} + + +@router.post("/run-if-due") +def run_if_due(request): + _require_admin(request) + schedule = BackupSchedule.get_solo() + if not is_backup_due(schedule): + return {"status": "skipped"} + path = create_backup_now() + return {"status": "ok", "filename": path.name} + diff --git a/api/routers/channel.py b/api/routers/channel.py index cd4580d..18fd650 100644 --- a/api/routers/channel.py +++ b/api/routers/channel.py @@ -17,6 +17,7 @@ class ChannelSchema(Schema): library_id: int owner_user_id: int fallback_collection_id: Optional[int] = None + requires_auth: bool class ChannelCreateSchema(Schema): name: str @@ -26,6 +27,7 @@ class ChannelCreateSchema(Schema): library_id: int owner_user_id: int # Mock Auth User fallback_collection_id: Optional[int] = None + requires_auth: bool = False class ChannelUpdateSchema(Schema): name: Optional[str] = None @@ -35,6 +37,7 @@ class ChannelUpdateSchema(Schema): visibility: Optional[str] = None is_active: Optional[bool] = None fallback_collection_id: Optional[int] = None + requires_auth: Optional[bool] = None class ChannelSourceRuleSchema(Schema): id: int @@ -135,6 +138,10 @@ class ChannelStatusSchema(Schema): @router.get("/{channel_id}/status", response=ChannelStatusSchema) def get_channel_status(request, channel_id: int): channel = get_object_or_404(Channel, id=channel_id) + if channel.requires_auth and not request.user.is_authenticated: + from ninja.errors import HttpError + raise HttpError(401, "Authentication required for this channel.") + now = timezone.now() window_end = now + timedelta(hours=24) @@ -196,7 +203,8 @@ def create_channel(request, payload: ChannelCreateSchema): slug=payload.slug, channel_number=payload.channel_number, description=payload.description, - fallback_collection_id=payload.fallback_collection_id + fallback_collection_id=payload.fallback_collection_id, + requires_auth=payload.requires_auth ) return 201, channel @@ -260,6 +268,10 @@ def remove_source_from_channel(request, channel_id: int, rule_id: int): def channel_now_playing(request, channel_id: int): """Return the Airing currently on-air for this channel, or null.""" channel = get_object_or_404(Channel, id=channel_id) + if channel.requires_auth and not request.user.is_authenticated: + from ninja.errors import HttpError + raise HttpError(401, "Authentication required for this channel.") + # Using a 1-second buffer to handle boundary conditions smoothly now = timezone.now() airing = ( @@ -279,6 +291,10 @@ def channel_airings(request, channel_id: int, hours: int = 4): [now - 2 hours, now + {hours} hours] """ channel = get_object_or_404(Channel, id=channel_id) + if channel.requires_auth and not request.user.is_authenticated: + from ninja.errors import HttpError + raise HttpError(401, "Authentication required for this channel.") + now = timezone.now() window_start = now - timedelta(hours=2) # Look back 2h for context window_end = now + timedelta(hours=hours) diff --git a/api/tests/test_auth.py b/api/tests/test_auth.py new file mode 100644 index 0000000..54c16e5 --- /dev/null +++ b/api/tests/test_auth.py @@ -0,0 +1,53 @@ +import json + +import pytest + +from core.models import AppUser + + +@pytest.mark.django_db +def test_login_sets_bounded_session_expiry(client, settings): + settings.SESSION_COOKIE_AGE = 3600 + + AppUser.objects.create_user( + username="auth_user", + email="auth@example.com", + password="secret-pass-123", + ) + + response = client.post( + "/api/auth/login", + data=json.dumps({"username": "auth_user", "password": "secret-pass-123"}), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response.json()["success"] is True + + # Session should be persisted, but bounded to SESSION_COOKIE_AGE. + assert 0 < client.session.get_expiry_age() <= settings.SESSION_COOKIE_AGE + + +@pytest.mark.django_db +def test_logout_forces_session_logout(client): + AppUser.objects.create_user( + username="logout_user", + email="logout@example.com", + password="secret-pass-123", + ) + + login_response = client.post( + "/api/auth/login", + data=json.dumps({"username": "logout_user", "password": "secret-pass-123"}), + content_type="application/json", + ) + assert login_response.status_code == 200 + + assert client.get("/api/auth/me").status_code == 200 + + logout_response = client.post("/api/auth/logout") + assert logout_response.status_code == 200 + + # Session is no longer authenticated after explicit logout. + assert client.get("/api/auth/me").status_code == 401 + diff --git a/api/tests/test_backups.py b/api/tests/test_backups.py new file mode 100644 index 0000000..0be61e6 --- /dev/null +++ b/api/tests/test_backups.py @@ -0,0 +1,82 @@ +import json +from pathlib import Path + +import pytest + +from core.models import AppUser, BackupSchedule, Library, MediaSource, MediaItem + + +@pytest.fixture +def admin_user(db): + return AppUser.objects.create_superuser("backup_admin", "backup@example.com", "secret-pass-123") + + +@pytest.mark.django_db +def test_backup_schedule_requires_admin(client): + response = client.get("/api/backups/settings") + assert response.status_code == 401 + + +@pytest.mark.django_db +def test_backup_schedule_get_and_update(client, admin_user): + client.login(username="backup_admin", password="secret-pass-123") + + response = client.get("/api/backups/settings") + assert response.status_code == 200 + + payload = { + "enabled": True, + "frequency": "weekly", + "minute": 30, + "hour": 3, + "day_of_week": 6, + "day_of_month": 1, + "retention_count": 5, + } + update = client.put( + "/api/backups/settings", + data=json.dumps(payload), + content_type="application/json", + ) + assert update.status_code == 200 + + schedule = BackupSchedule.get_solo() + assert schedule.enabled is True + assert schedule.frequency == "weekly" + assert schedule.retention_count == 5 + + +@pytest.mark.django_db +def test_backup_now_writes_json_and_strips_cached_paths(client, admin_user, settings, tmp_path): + settings.BACKUP_ROOT = str(tmp_path) + client.login(username="backup_admin", password="secret-pass-123") + + library = Library.objects.create(owner_user=admin_user, name="Backup Lib") + source = MediaSource.objects.create( + library=library, + name="YT", + source_type="youtube_playlist", + uri="https://example.com/list", + ) + MediaItem.objects.create( + media_source=source, + title="Cached Item", + item_kind="movie", + runtime_seconds=60, + file_path="https://youtube.com/watch?v=abc", + cached_file_path="/tmp/pytv_cache/abc.mp4", + ) + + resp = client.post("/api/backups/run") + assert resp.status_code == 200 + filename = resp.json()["filename"] + + backup_path = Path(settings.BACKUP_ROOT) / filename + assert backup_path.exists() + + payload = json.loads(backup_path.read_text(encoding="utf-8")) + media_entries = [x for x in payload["items"] if x["model"] == "core.mediaitem"] + assert media_entries + assert media_entries[0]["fields"]["cached_file_path"] is None + + diff --git a/core/apps.py b/core/apps.py index 5ef1d60..9fcaea8 100644 --- a/core/apps.py +++ b/core/apps.py @@ -3,3 +3,15 @@ from django.apps import AppConfig class CoreConfig(AppConfig): name = "core" + + def ready(self): + from django.db.models.signals import post_migrate + from django.core.signals import request_started + from core.services.bootstrap import _on_post_migrate, _on_first_request + + # After every `manage.py migrate` run. + post_migrate.connect(_on_post_migrate, sender=self) + + # On every server start, run the check lazily on the first incoming + # request so the DB is guaranteed to be ready (avoids RuntimeWarning). + request_started.connect(_on_first_request) diff --git a/core/management/commands/backup_now.py b/core/management/commands/backup_now.py new file mode 100644 index 0000000..80db177 --- /dev/null +++ b/core/management/commands/backup_now.py @@ -0,0 +1,12 @@ +from django.core.management.base import BaseCommand + +from core.services.backups import create_backup_now + + +class Command(BaseCommand): + help = "Create a backup JSON under /Backups immediately." + + def handle(self, *args, **options): + path = create_backup_now() + self.stdout.write(self.style.SUCCESS(f"Backup created: {path}")) + diff --git a/core/management/commands/run_backup_worker.py b/core/management/commands/run_backup_worker.py new file mode 100644 index 0000000..347c702 --- /dev/null +++ b/core/management/commands/run_backup_worker.py @@ -0,0 +1,30 @@ +import time + +from django.core.management.base import BaseCommand + +from core.models import BackupSchedule +from core.services.backups import create_backup_now, is_backup_due + + +class Command(BaseCommand): + help = "Continuously check backup schedule and run backups when due." + + def add_arguments(self, parser): + parser.add_argument( + "--interval", + type=int, + default=60, + help="Polling interval in seconds (default: 60).", + ) + + def handle(self, *args, **options): + interval = options["interval"] + self.stdout.write(self.style.SUCCESS(f"Starting backup worker (interval={interval}s)")) + + while True: + schedule = BackupSchedule.get_solo() + if schedule.enabled and is_backup_due(schedule): + path = create_backup_now() + self.stdout.write(self.style.SUCCESS(f"Backup created: {path.name}")) + time.sleep(interval) + diff --git a/core/migrations/0006_channel_requires_auth.py b/core/migrations/0006_channel_requires_auth.py new file mode 100644 index 0000000..cad9944 --- /dev/null +++ b/core/migrations/0006_channel_requires_auth.py @@ -0,0 +1,21 @@ +# Generated by Django 6.0.3 on 2026-03-10 12:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0005_mediasource_max_age_days_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="channel", + name="requires_auth", + field=models.BooleanField( + default=False, + help_text="If True, only signed-in users can stream or fetch schedules for this channel.", + ), + ), + ] diff --git a/core/migrations/0007_backupschedule.py b/core/migrations/0007_backupschedule.py new file mode 100644 index 0000000..78f3d69 --- /dev/null +++ b/core/migrations/0007_backupschedule.py @@ -0,0 +1,27 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0006_channel_requires_auth'), + ] + + operations = [ + migrations.CreateModel( + name='BackupSchedule', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('enabled', models.BooleanField(default=False)), + ('frequency', models.CharField(choices=[('hourly', 'Hourly'), ('daily', 'Daily'), ('weekly', 'Weekly'), ('monthly', 'Monthly')], default='daily', max_length=16)), + ('minute', models.PositiveSmallIntegerField(default=0)), + ('hour', models.PositiveSmallIntegerField(default=2)), + ('day_of_week', models.PositiveSmallIntegerField(default=0)), + ('day_of_month', models.PositiveSmallIntegerField(default=1)), + ('retention_count', models.PositiveIntegerField(default=14)), + ('last_run_at', models.DateTimeField(blank=True, null=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + ), + ] + diff --git a/core/models.py b/core/models.py index a529c04..223c326 100644 --- a/core/models.py +++ b/core/models.py @@ -231,6 +231,7 @@ class Channel(models.Model): scheduling_mode = models.CharField(max_length=24, choices=SchedulingMode.choices, default=SchedulingMode.TEMPLATE_DRIVEN) is_active = models.BooleanField(default=True) + requires_auth = models.BooleanField(default=False, help_text="If True, only signed-in users can stream or fetch schedules for this channel.") created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -514,3 +515,27 @@ class MediaResumePoint(models.Model): constraints = [ models.UniqueConstraint(fields=['user', 'media_item'], name='unique_media_resume_point') ] + + +class BackupSchedule(models.Model): + class Frequency(models.TextChoices): + HOURLY = 'hourly', 'Hourly' + DAILY = 'daily', 'Daily' + WEEKLY = 'weekly', 'Weekly' + MONTHLY = 'monthly', 'Monthly' + + enabled = models.BooleanField(default=False) + frequency = models.CharField(max_length=16, choices=Frequency.choices, default=Frequency.DAILY) + minute = models.PositiveSmallIntegerField(default=0) + hour = models.PositiveSmallIntegerField(default=2) + day_of_week = models.PositiveSmallIntegerField(default=0) # 0=Mon + day_of_month = models.PositiveSmallIntegerField(default=1) + retention_count = models.PositiveIntegerField(default=14) + last_run_at = models.DateTimeField(blank=True, null=True) + updated_at = models.DateTimeField(auto_now=True) + + @classmethod + def get_solo(cls): + obj, _ = cls.objects.get_or_create(id=1) + return obj + diff --git a/core/scripts/__init__.py b/core/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/scripts/make_user.py b/core/scripts/make_user.py new file mode 100644 index 0000000..530ad34 --- /dev/null +++ b/core/scripts/make_user.py @@ -0,0 +1,41 @@ +import os +import sys +import django +from getpass import getpass + +def main(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pytv.settings") + django.setup() + + from core.models import AppUser + + print("--- Create PYTV User ---") + try: + username = input("Username: ").strip() + if not username: + print("Username is required!") + sys.exit(1) + + email = input("Email: ").strip() + password = getpass("Password: ") + + is_admin_str = input("Is Admin? (y/N): ").strip().lower() + is_admin = is_admin_str in ['y', 'yes'] + + if AppUser.objects.filter(username=username).exists(): + print(f"User '{username}' already exists!") + sys.exit(1) + + if is_admin: + AppUser.objects.create_superuser(username=username, email=email, password=password) + print(f"Superuser '{username}' created successfully!") + else: + AppUser.objects.create_user(username=username, email=email, password=password) + print(f"User '{username}' created successfully!") + + except KeyboardInterrupt: + print("\nOperation cancelled.") + sys.exit(0) + +if __name__ == "__main__": + main() diff --git a/core/services/backups.py b/core/services/backups.py new file mode 100644 index 0000000..2463407 --- /dev/null +++ b/core/services/backups.py @@ -0,0 +1,227 @@ +import json +import decimal +import uuid +from dataclasses import dataclass +from datetime import datetime, date, time, timedelta, timezone as dt_timezone +from pathlib import Path + +from django.apps import apps +from django.conf import settings +from django.core.management import call_command +from django.core import serializers +from django.db import connection +from django.utils import timezone + +from core.models import BackupSchedule, MediaItem + + +class _BackupJSONEncoder(json.JSONEncoder): + """Encode types that Django's Python serializer produces but stdlib json cannot handle.""" + + def default(self, obj): + if isinstance(obj, (datetime, date, time)): + return obj.isoformat() + if isinstance(obj, decimal.Decimal): + return float(obj) + if isinstance(obj, uuid.UUID): + return str(obj) + return super().default(obj) + + +@dataclass +class BackupFileInfo: + filename: str + size_bytes: int + created_at: str + + +def get_backup_root() -> Path: + # Backups are always written under a fixed directory. + root = Path(getattr(settings, "BACKUP_ROOT", "/Backups")) + root.mkdir(parents=True, exist_ok=True) + return root + + +def _export_payload() -> dict: + items = [] + for model in apps.get_models(): + if model._meta.label_lower == "sessions.session": + continue + queryset = model.objects.all().order_by("pk") + serialized = serializers.serialize("python", queryset) + + # Strip cache-path metadata so backups stay DB-only and file-agnostic. + if model is MediaItem: + for item in serialized: + item["fields"]["cached_file_path"] = None + item["fields"]["cache_expires_at"] = None + + items.extend(serialized) + + return { + "version": 1, + "created_at": timezone.now().isoformat(), + "items": items, + } + + +def create_backup_now() -> Path: + root = get_backup_root() + stamp = timezone.localtime().strftime("%Y%m%d_%H%M%S") + filename = f"pytv_backup_{stamp}.json" + path = root / filename + + payload = _export_payload() + path.write_text(json.dumps(payload, indent=2, cls=_BackupJSONEncoder), encoding="utf-8") + + schedule = BackupSchedule.get_solo() + schedule.last_run_at = timezone.now() + schedule.save(update_fields=["last_run_at"]) + apply_retention(schedule.retention_count) + return path + + +def list_backups() -> list[BackupFileInfo]: + root = get_backup_root() + files = sorted(root.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True) + return [ + BackupFileInfo( + filename=f.name, + size_bytes=f.stat().st_size, + created_at=datetime.fromtimestamp(f.stat().st_mtime, tz=dt_timezone.utc).isoformat(), + ) + for f in files + ] + + +def apply_retention(retention_count: int): + if retention_count <= 0: + return + files = list_backups() + to_delete = files[retention_count:] + root = get_backup_root() + for item in to_delete: + target = root / item.filename + if target.exists(): + target.unlink() + + +def get_backup_file(filename: str) -> Path: + root = get_backup_root().resolve() + target = (root / filename).resolve() + if not str(target).startswith(str(root)): + raise FileNotFoundError("Invalid backup path") + if not target.exists() or target.suffix.lower() != ".json": + raise FileNotFoundError("Backup not found") + return target + + +def import_backup_content(content: str, mode: str = "append") -> dict: + if mode not in {"append", "override"}: + raise ValueError("mode must be 'append' or 'override'") + + payload = json.loads(content) + items = payload.get("items") if isinstance(payload, dict) else None + if items is None: + raise ValueError("Invalid backup payload") + + if mode == "override": + # Flush DB tables first (DB-only restore; does not touch media cache files). + call_command("flush", verbosity=0, interactive=False) + + created = 0 + updated = 0 + + serialized = json.dumps(items) + with connection.constraint_checks_disabled(): + for deserialized in serializers.deserialize("json", serialized): + obj = deserialized.object + model = obj.__class__ + existing = model.objects.filter(pk=obj.pk).first() + if existing is None: + deserialized.save() + created += 1 + continue + + if mode == "append": + for field in model._meta.local_fields: + if field.primary_key: + continue + setattr(existing, field.name, getattr(obj, field.name)) + existing.save() + for m2m_field, values in deserialized.m2m_data.items(): + getattr(existing, m2m_field).set(values) + updated += 1 + else: + deserialized.save() + updated += 1 + + return {"created": created, "updated": updated, "mode": mode} + + +def compute_next_run(schedule: BackupSchedule, now=None): + now = now or timezone.now() + + if not schedule.enabled: + return None + + if schedule.frequency == BackupSchedule.Frequency.HOURLY: + candidate = now.replace(second=0, microsecond=0, minute=schedule.minute) + if candidate <= now: + candidate += timedelta(hours=1) + return candidate + + candidate = now.replace(second=0, microsecond=0, minute=schedule.minute, hour=schedule.hour) + + if schedule.frequency == BackupSchedule.Frequency.DAILY: + if candidate <= now: + candidate += timedelta(days=1) + return candidate + + if schedule.frequency == BackupSchedule.Frequency.WEEKLY: + target = schedule.day_of_week + delta = (target - candidate.weekday()) % 7 + candidate += timedelta(days=delta) + if candidate <= now: + candidate += timedelta(days=7) + return candidate + + # monthly + day = max(1, min(schedule.day_of_month, 28)) + candidate = candidate.replace(day=day) + if candidate <= now: + month = candidate.month + 1 + year = candidate.year + if month > 12: + month = 1 + year += 1 + candidate = candidate.replace(year=year, month=month, day=day) + return candidate + + +def is_backup_due(schedule: BackupSchedule, now=None) -> bool: + now = now or timezone.now() + if not schedule.enabled: + return False + + if schedule.last_run_at is None: + return True + + if schedule.frequency == BackupSchedule.Frequency.HOURLY: + next_due = schedule.last_run_at + timedelta(hours=1) + elif schedule.frequency == BackupSchedule.Frequency.DAILY: + next_due = schedule.last_run_at + timedelta(days=1) + elif schedule.frequency == BackupSchedule.Frequency.WEEKLY: + next_due = schedule.last_run_at + timedelta(days=7) + else: + next_due = schedule.last_run_at + timedelta(days=28) + + next_target = now.replace(second=0, microsecond=0) + if schedule.frequency != BackupSchedule.Frequency.HOURLY: + next_target = next_target.replace(hour=schedule.hour) + next_target = next_target.replace(minute=schedule.minute) + + return now >= max(next_due, next_target) + + + diff --git a/core/services/bootstrap.py b/core/services/bootstrap.py new file mode 100644 index 0000000..827fbce --- /dev/null +++ b/core/services/bootstrap.py @@ -0,0 +1,92 @@ +""" +Bootstrap service — idempotent startup initialisation. + +Ensures a default media library exists as soon as the first user is available. +Called from three places: + 1. AppConfig.ready() – every server start (DB already populated) + 2. post_migrate signal – after `manage.py migrate` runs + 3. auth.setup_admin endpoint – immediately after the first user is created +""" +import logging + +logger = logging.getLogger(__name__) + +DEFAULT_LIBRARY_NAME = "Default Library" + +# One-shot flag: after the first request triggers the bootstrap, disconnect +# the signal so we don't repeat the DB check on every subsequent request. +_startup_bootstrap_done = False + + +def ensure_default_library(): + """ + Create a default media library if none exists yet. + + Idempotent — safe to call multiple times; does nothing when a library + already exists. Returns the newly-created Library, or None if no action + was taken (either a library already exists, or no users exist yet to be + the owner). + """ + from core.models import Library, AppUser + + if Library.objects.exists(): + return None # Already bootstrapped + + # Need an owner — prefer the first superuser, fall back to any user. + owner = ( + AppUser.objects.filter(is_superuser=True).order_by("date_joined").first() + or AppUser.objects.order_by("date_joined").first() + ) + if owner is None: + # No users yet — setup_admin will call us again once the first user exists. + logger.debug("ensure_default_library: no users yet, skipping.") + return None + + library = Library.objects.create( + owner_user=owner, + name=DEFAULT_LIBRARY_NAME, + visibility="public", + description=( + "Default media library. " + "Add media sources here to start building your channels." + ), + ) + logger.info( + "Bootstrap: created default library '%s' (id=%d) owned by '%s'.", + library.name, + library.id, + owner.username, + ) + return library + + +# --------------------------------------------------------------------------- +# Signal handlers — wired up in CoreConfig.ready() +# --------------------------------------------------------------------------- + +def _on_post_migrate(sender, **kwargs): + """Run bootstrap after every successful migration.""" + try: + ensure_default_library() + except Exception as exc: # pragma: no cover + logger.warning("ensure_default_library failed in post_migrate: %s", exc) + + +def _on_first_request(sender, **kwargs): + """ + One-shot: run bootstrap on the very first HTTP request after server start. + Disconnects itself immediately so subsequent requests pay zero overhead. + """ + global _startup_bootstrap_done + if _startup_bootstrap_done: + return + _startup_bootstrap_done = True + + from django.core.signals import request_started + request_started.disconnect(_on_first_request) + + try: + ensure_default_library() + except Exception as exc: # pragma: no cover + logger.warning("ensure_default_library failed on first request: %s", exc) + diff --git a/docker-compose.yml b/docker-compose.yml index 50bfd13..a0aca5b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,7 @@ services: - .:/app - ./mock:/mock - ./cache:/tmp/pytv_cache + - ./backups:/Backups ports: - "8000:8000" environment: @@ -35,6 +36,20 @@ services: - .:/app - ./mock:/mock - ./cache:/tmp/pytv_cache + - ./backups:/Backups + environment: + - DEBUG=True + - SECRET_KEY=django-insecure-development-key-replace-in-production + - DATABASE_URL=postgres://pytv_user:pytv_password@db:5432/pytv_db + depends_on: + - db + + backup_worker: + build: . + command: python manage.py run_backup_worker --interval 60 + volumes: + - .:/app + - ./backups:/Backups environment: - DEBUG=True - SECRET_KEY=django-insecure-development-key-replace-in-production diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d58c22a..f7ad5a6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,31 +2,82 @@ import React, { useState } from 'react'; import ChannelTuner from './components/ChannelTuner'; import Guide from './components/Guide'; import Settings from './components/Settings'; +import LoginModal from './components/LoginModal'; +import AdminSetup from './components/AdminSetup'; +import { AuthProvider, useAuth } from './AuthContext'; +import axios from 'axios'; -function App() { +function MainApp() { + const { user, hasUsers, loading } = useAuth(); const [showGuide, setShowGuide] = useState(false); const [showSettings, setShowSettings] = useState(false); + const [needsLogin, setNeedsLogin] = useState(false); + + React.useEffect(() => { + // Intercept generic axios response to catch 401 globally if not handled locally + const interceptor = axios.interceptors.response.use( + response => response, + error => { + if (error.response?.status === 401) { + setNeedsLogin(true); + } + return Promise.reject(error); + } + ); + return () => axios.interceptors.response.eject(interceptor); + }, []); React.useEffect(() => { const handleKey = (e) => { - // 'S' key opens settings (when nothing else is open) - if (e.key === 's' && !showGuide && !showSettings) { - setShowSettings(true); + if (loading) return; + const key = (e.key || '').toLowerCase(); + + // Never intercept keys while the user is typing in a form field. + const tag = document.activeElement?.tagName; + const isTyping = ['INPUT', 'TEXTAREA', 'SELECT'].includes(tag) + || document.activeElement?.isContentEditable; + + // If Settings is open, Esc/Backspace should only close Settings — + // but only when the user is NOT typing in a field inside the modal. + if (showSettings && ['escape', 'backspace'].includes(key) && !isTyping) { + e.preventDefault(); + setShowSettings(false); + return; } - // Escape/Backspace closes whatever is open - if (['Escape', 'Backspace'].includes(e.key)) { + + // 'S' key opens settings (when nothing else is open and not typing) + if (key === 's' && !showGuide && !showSettings && !isTyping) { + e.preventDefault(); + if (!user) { + setNeedsLogin(true); + } else { + setShowSettings(true); + } + } + // Escape/Backspace closes whatever is open (not while typing) + if (['escape', 'backspace'].includes(key) && !isTyping) { + e.preventDefault(); setShowGuide(false); setShowSettings(false); } }; window.addEventListener('keydown', handleKey); return () => window.removeEventListener('keydown', handleKey); - }, [showGuide, showSettings]); + }, [showGuide, showSettings, user, loading]); return ( <> {/* ChannelTuner always stays mounted to preserve buffering */} - setShowGuide(!showGuide)} /> + { + // If Settings is open, Back/Escape should only close Settings. + if (showSettings) { + setShowSettings(false); + return; + } + setShowGuide(prev => !prev); + }} + /> {showGuide && ( setShowSettings(true)} - title="Settings (S)" + onClick={() => { + if (loading) return; + if (!user) setNeedsLogin(true); + else setShowSettings(true); + }} + title={loading ? 'Checking session...' : 'Settings (S)'} + disabled={loading} > ⚙ )} + + + {!loading && needsLogin && hasUsers && ( + { + setNeedsLogin(false); + setShowSettings(true); // auto-open settings after login + }} + onClose={() => setNeedsLogin(false)} + /> + )} + + {!hasUsers && } ); } +function App() { + return ( + + + + ); +} + export default App; diff --git a/frontend/src/AuthContext.jsx b/frontend/src/AuthContext.jsx new file mode 100644 index 0000000..27a0d35 --- /dev/null +++ b/frontend/src/AuthContext.jsx @@ -0,0 +1,56 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; +import { fetchCurrentUser, loginUser, logoutUser, checkHasUsers } from './api'; + +const AuthContext = createContext(null); + +export const AuthProvider = ({ children }) => { + const [user, setUser] = useState(null); + const [hasUsers, setHasUsers] = useState(true); // default true to avoid flashing setup screen + const [loading, setLoading] = useState(true); + + // Checks on initial load if we have an active session cookie and if the database is empty + useEffect(() => { + Promise.all([ + fetchCurrentUser().catch(err => { + if (err.response?.status !== 401) console.error("Auth check error:", err); + return null; + }), + checkHasUsers().catch(err => { + console.error("Failed to check has-users status", err); + return { has_users: true }; + }) + ]).then(([userData, hasUsersData]) => { + setUser(userData); + setHasUsers(hasUsersData.has_users); + }).finally(() => { + setLoading(false); + }); + }, []); + + const login = async (username, password) => { + const data = await loginUser(username, password); + if (data.success && data.user) { + setUser(data.user); + return data.user; + } + throw new Error("Invalid response from server"); + }; + + const logout = async () => { + try { + await logoutUser(); + } catch (e) { + console.error("Logout error", e); + } finally { + setUser(null); + } + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => useContext(AuthContext); diff --git a/frontend/src/api.js b/frontend/src/api.js index f3a7700..5d4ea82 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -3,6 +3,17 @@ import axios from 'axios'; const apiClient = axios.create({ baseURL: '/api', headers: { 'Content-Type': 'application/json' }, + withCredentials: true // send session cookies securely +}); + +// CSRF wrapper for POST/PUT/PATCH/DELETE +apiClient.interceptors.request.use(config => { + // Try to find the csrftoken cookie Django sets + const csrfCookie = document.cookie.split('; ').find(row => row.startsWith('csrftoken=')); + if (csrfCookie) { + config.headers['X-CSRFToken'] = csrfCookie.split('=')[1]; + } + return config; }); // ── Channels ────────────────────────────────────────────────────────────── @@ -11,6 +22,13 @@ export const createChannel = async (payload) => (await apiClient.post('/channel/ export const updateChannel = async (id, payload) => (await apiClient.patch(`/channel/${id}`, payload)).data; export const deleteChannel = async (id) => { await apiClient.delete(`/channel/${id}`); }; +// ── Authentication ────────────────────────────────────────────────────────── +export const checkHasUsers = async () => (await apiClient.get('/auth/has-users')).data; +export const setupAdmin = async (payload) => (await apiClient.post('/auth/setup', payload)).data; +export const loginUser = async (username, password) => (await apiClient.post('/auth/login', { username, password })).data; +export const logoutUser = async () => (await apiClient.post('/auth/logout')).data; +export const fetchCurrentUser = async () => (await apiClient.get('/auth/me')).data; + // Channel program data export const fetchChannelNow = async (channelId) => (await apiClient.get(`/channel/${channelId}/now`)).data; @@ -77,3 +95,27 @@ export const fetchUsers = async () => (await apiClient.get('/user/')).data; export const createUser = async (payload) => (await apiClient.post('/user/', payload)).data; export const updateUser = async (id, payload) => (await apiClient.patch(`/user/${id}`, payload)).data; export const deleteUser = async (id) => { await apiClient.delete(`/user/${id}`); }; + +// -- Backups ---------------------------------------------------------------- +export const fetchBackupSchedule = async () => (await apiClient.get('/backups/settings')).data; +export const updateBackupSchedule = async (payload) => (await apiClient.put('/backups/settings', payload)).data; +export const runBackupNow = async () => (await apiClient.post('/backups/run')).data; +export const fetchBackups = async () => (await apiClient.get('/backups/')).data; + +export const downloadBackup = async (filename) => { + const response = await apiClient.get(`/backups/${encodeURIComponent(filename)}/download`, { + responseType: 'blob', + }); + return response.data; +}; + +export const importBackup = async (file, mode = 'append') => { + const form = new FormData(); + form.append('file', file); + return ( + await apiClient.post(`/backups/import?mode=${mode}`, form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + ).data; +}; + diff --git a/frontend/src/components/AdminSetup.jsx b/frontend/src/components/AdminSetup.jsx new file mode 100644 index 0000000..b6833b6 --- /dev/null +++ b/frontend/src/components/AdminSetup.jsx @@ -0,0 +1,84 @@ +import React, { useState } from 'react'; +import { setupAdmin, loginUser } from '../api'; +import { useAuth } from '../AuthContext'; +import './LoginModal.css'; + +const AdminSetup = () => { + const { setHasUsers, login } = useAuth(); + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(null); + setLoading(true); + + try { + await setupAdmin({ username, email, password }); + + // Auto-login after setup success (the backend already logs the session in, + // but fetchCurrentUser wasn't called. Setup router returns the user.) + // Alternatively, we can force a manual login to populate the frontend context. + await login(username, password); + + setHasUsers(true); + } catch (err) { + setError(err.response?.data?.detail || "Setup failed. The database might not be empty."); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Welcome to PYTV

+

No users exist in the database. Please create an administrator account to begin.

+ + {error &&
{error}
} + +
+ + + + +
+ +
+
+
+
+ ); +}; + +export default AdminSetup; diff --git a/frontend/src/components/LoginModal.css b/frontend/src/components/LoginModal.css new file mode 100644 index 0000000..785cfc6 --- /dev/null +++ b/frontend/src/components/LoginModal.css @@ -0,0 +1,117 @@ +.login-modal-overlay { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0, 0, 0, 0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + backdrop-filter: blur(4px); +} + +.login-modal { + background: #1e1e24; + padding: 2rem; + border-radius: 12px; + width: 100%; + max-width: 400px; + box-shadow: 0 10px 40px rgba(0,0,0,0.5); + border: 1px solid rgba(255,255,255,0.1); + color: #fff; +} + +.login-modal h2 { + margin-top: 0; + margin-bottom: 0.5rem; + font-size: 1.5rem; + text-align: center; +} + +.login-description { + color: #a0a0b0; + font-size: 0.95rem; + text-align: center; + margin-bottom: 1.5rem; +} + +.login-error { + background: rgba(255, 60, 60, 0.1); + color: #ff6b6b; + border: 1px solid rgba(255, 60, 60, 0.3); + padding: 0.75rem; + border-radius: 6px; + margin-bottom: 1.5rem; + font-size: 0.9rem; + text-align: center; +} + +.login-form { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.login-form label { + display: flex; + flex-direction: column; + gap: 0.5rem; + font-size: 0.9rem; + font-weight: 500; + color: #e0e0e0; +} + +.login-form input { + padding: 0.75rem; + border-radius: 6px; + border: 1px solid #333; + background: #111; + color: #fff; + font-size: 1rem; + transition: border-color 0.2s; +} + +.login-form input:focus { + outline: none; + border-color: #4a90e2; +} + +.login-actions { + display: flex; + justify-content: flex-end; + gap: 1rem; + margin-top: 1rem; +} + +.login-actions button { + padding: 0.75rem 1.5rem; + border-radius: 6px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + border: none; + transition: all 0.2s; +} + +.btn-cancel { + background: transparent; + color: #a0a0b0; +} + +.btn-cancel:hover { + background: rgba(255,255,255,0.05); + color: #fff; +} + +.btn-primary { + background: #4a90e2; + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: #357abd; +} + +.btn-primary:disabled { + opacity: 0.7; + cursor: not-allowed; +} diff --git a/frontend/src/components/LoginModal.jsx b/frontend/src/components/LoginModal.jsx new file mode 100644 index 0000000..ccd37be --- /dev/null +++ b/frontend/src/components/LoginModal.jsx @@ -0,0 +1,73 @@ +import React, { useState } from 'react'; +import { useAuth } from '../AuthContext'; +import './LoginModal.css'; + +const LoginModal = ({ onClose, onSuccess }) => { + const { login } = useAuth(); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(null); + setLoading(true); + + try { + await login(username, password); + onSuccess?.(); + onClose?.(); + } catch (err) { + setError(err.response?.data?.detail || "Invalid credentials or network error"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Sign In Required

+

This channel requires authentication to stream.

+ + {error &&
{error}
} + +
+ + + +
+ {onClose && ( + + )} + +
+
+
+
+ ); +}; + +export default LoginModal; diff --git a/frontend/src/components/Settings.jsx b/frontend/src/components/Settings.jsx index a065168..c484586 100644 --- a/frontend/src/components/Settings.jsx +++ b/frontend/src/components/Settings.jsx @@ -9,23 +9,27 @@ import { fetchLibraries, fetchCollections, fetchDownloadStatus, triggerCacheUpcoming, downloadItem, fetchDownloadProgress, fetchChannelStatus, triggerChannelDownload, + fetchBackupSchedule, updateBackupSchedule, runBackupNow, + fetchBackups, downloadBackup, importBackup, } from '../api'; +import { useAuth } from '../AuthContext'; // ─── Constants ──────────────────────────────────────────────────────────── const TABS = [ - { id: 'channels', label: '📺 Channels' }, - { id: 'sources', label: '📡 Sources' }, - { id: 'downloads', label: '⬇ Downloads' }, - { id: 'schedule', label: '📅 Scheduling' }, - { id: 'users', label: '👤 Users' }, + { id: 'channels', label: 'Channels' }, + { id: 'sources', label: 'Sources' }, + { id: 'downloads', label: 'Downloads' }, + { id: 'schedule', label: 'Schedule' }, + { id: 'backups', label: 'Backups' }, + { id: 'users', label: 'Users' }, ]; const SOURCE_TYPE_OPTIONS = [ - { value: 'youtube_channel', label: '▶ YouTube Channel' }, - { value: 'youtube_playlist', label: '▶ YouTube Playlist' }, - { value: 'local_directory', label: '📁 Local Directory' }, - { value: 'stream', label: '📡 Live Stream' }, + { value: 'youtube_channel', label: 'YouTube Channel' }, + { value: 'youtube_playlist', label: 'YouTube Playlist' }, + { value: 'local_directory', label: 'Local Directory' }, + { value: 'stream', label: 'Live Stream' }, ]; const RULE_MODE_OPTIONS = [ @@ -167,7 +171,7 @@ function ChannelsTab() { const [channelSources, setChannelSources] = useState({}); // { channelId: [rules] } const [channelStatuses, setChannelStatuses] = useState({}); // { channelId: statusData } const [showForm, setShowForm] = useState(false); - const [form, setForm] = useState({ name: '', slug: '', channel_number: '', description: '', library_id: '', owner_user_id: '' }); + const [form, setForm] = useState({ name: '', slug: '', channel_number: '', description: '', library_id: '', owner_user_id: '', requires_auth: false }); const [assignForm, setAssignForm] = useState({ source_id: '', rule_mode: 'allow', weight: 1.0, schedule_block_label: '' }); const [syncingId, setSyncingId] = useState(null); const [downloadingId, setDownloadingId] = useState(null); @@ -217,10 +221,11 @@ function ChannelsTab() { channel_number: form.channel_number ? parseInt(form.channel_number) : undefined, library_id: parseInt(form.library_id), owner_user_id: parseInt(form.owner_user_id), + requires_auth: form.requires_auth, }); setChannels(c => [...c, ch]); setShowForm(false); - setForm({ name: '', slug: '', channel_number: '', description: '', library_id: '', owner_user_id: '' }); + setForm({ name: '', slug: '', channel_number: '', description: '', library_id: '', owner_user_id: '', requires_auth: false }); ok(`Channel "${ch.name}" created.`); } catch { err('Failed to create channel. Check slug is unique.'); } }; @@ -286,6 +291,14 @@ function ChannelsTab() { } catch { err('Failed to update fallback collection.'); } }; + const handleToggleAuth = async (ch) => { + try { + const updated = await updateChannel(ch.id, { requires_auth: !ch.requires_auth }); + setChannels(cs => cs.map(c => c.id === updated.id ? updated : c)); + ok(updated.requires_auth ? 'Authentication enabled.' : 'Authentication disabled.'); + } catch { err('Failed to update auth requirement.'); } + }; + return (
setFeedback(null)} /> @@ -317,7 +330,14 @@ function ChannelsTab() {
- +
+ + +
+ )} @@ -337,6 +357,7 @@ function ChannelsTab() { {ch.name} {ch.slug} · {ch.scheduling_mode} + {ch.requires_auth && 🔒 Private} {!hasRules && isExpanded === false && channelSources[ch.id] !== undefined && ( ⚠️ Fallback Library Mode )} @@ -370,29 +391,29 @@ function ChannelsTab() { {/* ─── Channel Status ──────────────────────────────────── */} {channelStatuses[ch.id] && ( -
-
Schedule Status (Next 24 Hours)
-
+
+
Schedule Status — Next 24 Hours
+
Total Upcoming: {channelStatuses[ch.id].total_upcoming_airings} Cached: {channelStatuses[ch.id].total_cached_airings} ({Math.round(channelStatuses[ch.id].percent_cached)}%)
{channelStatuses[ch.id].missing_items?.length > 0 && ( -
- Missing Downloads: {channelStatuses[ch.id].missing_items.slice(0, 3).map(i => `[${i.source_name}] ${i.title}`).join(', ')} +

+ Missing:{' '} + {channelStatuses[ch.id].missing_items.slice(0, 3).map(i => i.title).join(', ')} {channelStatuses[ch.id].missing_items.length > 3 ? ` +${channelStatuses[ch.id].missing_items.length - 3} more` : ''} -

+

)}
)} {/* ─── Fallback block selector ───────────────────────── */} -
-