feat(main): main

This commit is contained in:
2026-03-20 15:00:24 -04:00
parent af3076342a
commit c9718c5483
30 changed files with 2513 additions and 559 deletions

96
AGENTS.md Normal file
View File

@@ -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 <id> --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.

View File

@@ -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.schedule import router as schedule_router
from api.routers.user import router as user_router from api.routers.user import router as user_router
from api.routers.sources import router as sources_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("/library/", library_router)
api.add_router("/collections/", collections_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("/schedule/", schedule_router)
api.add_router("/user/", user_router) api.add_router("/user/", user_router)
api.add_router("/sources/", sources_router) api.add_router("/sources/", sources_router)
api.add_router("/auth/", auth_router)
api.add_router("/backups/", backups_router)

105
api/routers/auth.py Normal file
View File

@@ -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")

156
api/routers/backups.py Normal file
View File

@@ -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}

View File

@@ -17,6 +17,7 @@ class ChannelSchema(Schema):
library_id: int library_id: int
owner_user_id: int owner_user_id: int
fallback_collection_id: Optional[int] = None fallback_collection_id: Optional[int] = None
requires_auth: bool
class ChannelCreateSchema(Schema): class ChannelCreateSchema(Schema):
name: str name: str
@@ -26,6 +27,7 @@ class ChannelCreateSchema(Schema):
library_id: int library_id: int
owner_user_id: int # Mock Auth User owner_user_id: int # Mock Auth User
fallback_collection_id: Optional[int] = None fallback_collection_id: Optional[int] = None
requires_auth: bool = False
class ChannelUpdateSchema(Schema): class ChannelUpdateSchema(Schema):
name: Optional[str] = None name: Optional[str] = None
@@ -35,6 +37,7 @@ class ChannelUpdateSchema(Schema):
visibility: Optional[str] = None visibility: Optional[str] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
fallback_collection_id: Optional[int] = None fallback_collection_id: Optional[int] = None
requires_auth: Optional[bool] = None
class ChannelSourceRuleSchema(Schema): class ChannelSourceRuleSchema(Schema):
id: int id: int
@@ -135,6 +138,10 @@ class ChannelStatusSchema(Schema):
@router.get("/{channel_id}/status", response=ChannelStatusSchema) @router.get("/{channel_id}/status", response=ChannelStatusSchema)
def get_channel_status(request, channel_id: int): def get_channel_status(request, channel_id: int):
channel = get_object_or_404(Channel, id=channel_id) 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() now = timezone.now()
window_end = now + timedelta(hours=24) window_end = now + timedelta(hours=24)
@@ -196,7 +203,8 @@ def create_channel(request, payload: ChannelCreateSchema):
slug=payload.slug, slug=payload.slug,
channel_number=payload.channel_number, channel_number=payload.channel_number,
description=payload.description, 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 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): def channel_now_playing(request, channel_id: int):
"""Return the Airing currently on-air for this channel, or null.""" """Return the Airing currently on-air for this channel, or null."""
channel = get_object_or_404(Channel, id=channel_id) 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 # Using a 1-second buffer to handle boundary conditions smoothly
now = timezone.now() now = timezone.now()
airing = ( airing = (
@@ -279,6 +291,10 @@ def channel_airings(request, channel_id: int, hours: int = 4):
[now - 2 hours, now + {hours} hours] [now - 2 hours, now + {hours} hours]
""" """
channel = get_object_or_404(Channel, id=channel_id) 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() now = timezone.now()
window_start = now - timedelta(hours=2) # Look back 2h for context window_start = now - timedelta(hours=2) # Look back 2h for context
window_end = now + timedelta(hours=hours) window_end = now + timedelta(hours=hours)

53
api/tests/test_auth.py Normal file
View File

@@ -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

82
api/tests/test_backups.py Normal file
View File

@@ -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

View File

@@ -3,3 +3,15 @@ from django.apps import AppConfig
class CoreConfig(AppConfig): class CoreConfig(AppConfig):
name = "core" 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)

View File

@@ -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}"))

View File

@@ -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)

View File

@@ -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.",
),
),
]

View File

@@ -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)),
],
),
]

View File

@@ -231,6 +231,7 @@ class Channel(models.Model):
scheduling_mode = models.CharField(max_length=24, choices=SchedulingMode.choices, default=SchedulingMode.TEMPLATE_DRIVEN) scheduling_mode = models.CharField(max_length=24, choices=SchedulingMode.choices, default=SchedulingMode.TEMPLATE_DRIVEN)
is_active = models.BooleanField(default=True) 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) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@@ -514,3 +515,27 @@ class MediaResumePoint(models.Model):
constraints = [ constraints = [
models.UniqueConstraint(fields=['user', 'media_item'], name='unique_media_resume_point') 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

0
core/scripts/__init__.py Normal file
View File

41
core/scripts/make_user.py Normal file
View File

@@ -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()

227
core/services/backups.py Normal file
View File

@@ -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)

View File

@@ -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)

View File

@@ -19,6 +19,7 @@ services:
- .:/app - .:/app
- ./mock:/mock - ./mock:/mock
- ./cache:/tmp/pytv_cache - ./cache:/tmp/pytv_cache
- ./backups:/Backups
ports: ports:
- "8000:8000" - "8000:8000"
environment: environment:
@@ -35,6 +36,20 @@ services:
- .:/app - .:/app
- ./mock:/mock - ./mock:/mock
- ./cache:/tmp/pytv_cache - ./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: environment:
- DEBUG=True - DEBUG=True
- SECRET_KEY=django-insecure-development-key-replace-in-production - SECRET_KEY=django-insecure-development-key-replace-in-production

View File

@@ -2,31 +2,82 @@ import React, { useState } from 'react';
import ChannelTuner from './components/ChannelTuner'; import ChannelTuner from './components/ChannelTuner';
import Guide from './components/Guide'; import Guide from './components/Guide';
import Settings from './components/Settings'; 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 [showGuide, setShowGuide] = useState(false);
const [showSettings, setShowSettings] = 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(() => { React.useEffect(() => {
const handleKey = (e) => { const handleKey = (e) => {
// 'S' key opens settings (when nothing else is open) if (loading) return;
if (e.key === 's' && !showGuide && !showSettings) { const key = (e.key || '').toLowerCase();
setShowSettings(true);
// 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); setShowGuide(false);
setShowSettings(false); setShowSettings(false);
} }
}; };
window.addEventListener('keydown', handleKey); window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey); return () => window.removeEventListener('keydown', handleKey);
}, [showGuide, showSettings]); }, [showGuide, showSettings, user, loading]);
return ( return (
<> <>
{/* ChannelTuner always stays mounted to preserve buffering */} {/* ChannelTuner always stays mounted to preserve buffering */}
<ChannelTuner onOpenGuide={() => setShowGuide(!showGuide)} /> <ChannelTuner
onOpenGuide={() => {
// If Settings is open, Back/Escape should only close Settings.
if (showSettings) {
setShowSettings(false);
return;
}
setShowGuide(prev => !prev);
}}
/>
{showGuide && ( {showGuide && (
<Guide <Guide
@@ -41,14 +92,40 @@ function App() {
{!showGuide && !showSettings && ( {!showGuide && !showSettings && (
<button <button
className="settings-gear-btn" className="settings-gear-btn"
onClick={() => setShowSettings(true)} onClick={() => {
title="Settings (S)" if (loading) return;
if (!user) setNeedsLogin(true);
else setShowSettings(true);
}}
title={loading ? 'Checking session...' : 'Settings (S)'}
disabled={loading}
> >
</button> </button>
)} )}
{!loading && needsLogin && hasUsers && (
<LoginModal
onSuccess={() => {
setNeedsLogin(false);
setShowSettings(true); // auto-open settings after login
}}
onClose={() => setNeedsLogin(false)}
/>
)}
{!hasUsers && <AdminSetup />}
</> </>
); );
} }
function App() {
return (
<AuthProvider>
<MainApp />
</AuthProvider>
);
}
export default App; export default App;

View File

@@ -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 (
<AuthContext.Provider value={{ user, loading, hasUsers, setHasUsers, login, logout }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => useContext(AuthContext);

View File

@@ -3,6 +3,17 @@ import axios from 'axios';
const apiClient = axios.create({ const apiClient = axios.create({
baseURL: '/api', baseURL: '/api',
headers: { 'Content-Type': 'application/json' }, 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 ────────────────────────────────────────────────────────────── // ── 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 updateChannel = async (id, payload) => (await apiClient.patch(`/channel/${id}`, payload)).data;
export const deleteChannel = async (id) => { await apiClient.delete(`/channel/${id}`); }; 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 // Channel program data
export const fetchChannelNow = async (channelId) => export const fetchChannelNow = async (channelId) =>
(await apiClient.get(`/channel/${channelId}/now`)).data; (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 createUser = async (payload) => (await apiClient.post('/user/', payload)).data;
export const updateUser = async (id, payload) => (await apiClient.patch(`/user/${id}`, 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}`); }; 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;
};

View File

@@ -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 (
<div className="login-modal-overlay">
<div className="login-modal">
<h2>Welcome to PYTV</h2>
<p className="login-description">No users exist in the database. Please create an administrator account to begin.</p>
{error && <div className="login-error">{error}</div>}
<form onSubmit={handleSubmit} className="login-form">
<label>
Admin Username
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
required
autoFocus
/>
</label>
<label>
Email
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
/>
</label>
<div className="login-actions">
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? 'Creating...' : 'Create Admin Account'}
</button>
</div>
</form>
</div>
</div>
);
};
export default AdminSetup;

View File

@@ -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;
}

View File

@@ -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 (
<div className="login-modal-overlay">
<div className="login-modal">
<h2>Sign In Required</h2>
<p className="login-description">This channel requires authentication to stream.</p>
{error && <div className="login-error">{error}</div>}
<form onSubmit={handleSubmit} className="login-form">
<label>
Username
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
required
autoFocus
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
/>
</label>
<div className="login-actions">
{onClose && (
<button type="button" onClick={onClose} className="btn-cancel">
Cancel
</button>
)}
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? 'Signing In...' : 'Sign In'}
</button>
</div>
</form>
</div>
</div>
);
};
export default LoginModal;

View File

@@ -9,23 +9,27 @@ import {
fetchLibraries, fetchCollections, fetchLibraries, fetchCollections,
fetchDownloadStatus, triggerCacheUpcoming, downloadItem, fetchDownloadProgress, fetchDownloadStatus, triggerCacheUpcoming, downloadItem, fetchDownloadProgress,
fetchChannelStatus, triggerChannelDownload, fetchChannelStatus, triggerChannelDownload,
fetchBackupSchedule, updateBackupSchedule, runBackupNow,
fetchBackups, downloadBackup, importBackup,
} from '../api'; } from '../api';
import { useAuth } from '../AuthContext';
// ─── Constants ──────────────────────────────────────────────────────────── // ─── Constants ────────────────────────────────────────────────────────────
const TABS = [ const TABS = [
{ id: 'channels', label: '📺 Channels' }, { id: 'channels', label: 'Channels' },
{ id: 'sources', label: '📡 Sources' }, { id: 'sources', label: 'Sources' },
{ id: 'downloads', label: 'Downloads' }, { id: 'downloads', label: 'Downloads' },
{ id: 'schedule', label: '📅 Scheduling' }, { id: 'schedule', label: 'Schedule' },
{ id: 'users', label: '👤 Users' }, { id: 'backups', label: 'Backups' },
{ id: 'users', label: 'Users' },
]; ];
const SOURCE_TYPE_OPTIONS = [ const SOURCE_TYPE_OPTIONS = [
{ value: 'youtube_channel', label: 'YouTube Channel' }, { value: 'youtube_channel', label: 'YouTube Channel' },
{ value: 'youtube_playlist', label: 'YouTube Playlist' }, { value: 'youtube_playlist', label: 'YouTube Playlist' },
{ value: 'local_directory', label: '📁 Local Directory' }, { value: 'local_directory', label: 'Local Directory' },
{ value: 'stream', label: '📡 Live Stream' }, { value: 'stream', label: 'Live Stream' },
]; ];
const RULE_MODE_OPTIONS = [ const RULE_MODE_OPTIONS = [
@@ -167,7 +171,7 @@ function ChannelsTab() {
const [channelSources, setChannelSources] = useState({}); // { channelId: [rules] } const [channelSources, setChannelSources] = useState({}); // { channelId: [rules] }
const [channelStatuses, setChannelStatuses] = useState({}); // { channelId: statusData } const [channelStatuses, setChannelStatuses] = useState({}); // { channelId: statusData }
const [showForm, setShowForm] = useState(false); 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 [assignForm, setAssignForm] = useState({ source_id: '', rule_mode: 'allow', weight: 1.0, schedule_block_label: '' });
const [syncingId, setSyncingId] = useState(null); const [syncingId, setSyncingId] = useState(null);
const [downloadingId, setDownloadingId] = useState(null); const [downloadingId, setDownloadingId] = useState(null);
@@ -217,10 +221,11 @@ function ChannelsTab() {
channel_number: form.channel_number ? parseInt(form.channel_number) : undefined, channel_number: form.channel_number ? parseInt(form.channel_number) : undefined,
library_id: parseInt(form.library_id), library_id: parseInt(form.library_id),
owner_user_id: parseInt(form.owner_user_id), owner_user_id: parseInt(form.owner_user_id),
requires_auth: form.requires_auth,
}); });
setChannels(c => [...c, ch]); setChannels(c => [...c, ch]);
setShowForm(false); 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.`); ok(`Channel "${ch.name}" created.`);
} catch { err('Failed to create channel. Check slug is unique.'); } } catch { err('Failed to create channel. Check slug is unique.'); }
}; };
@@ -286,6 +291,14 @@ function ChannelsTab() {
} catch { err('Failed to update fallback collection.'); } } 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 ( return (
<div className="tab-content"> <div className="tab-content">
<Feedback fb={feedback} clear={() => setFeedback(null)} /> <Feedback fb={feedback} clear={() => setFeedback(null)} />
@@ -317,7 +330,14 @@ function ChannelsTab() {
</select> </select>
</label> </label>
</div> </div>
<label>Description<input value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} /></label> <div className="form-row">
<label>Description<input value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} /></label>
<label className="checkbox-label" style={{ alignSelf: 'flex-end', marginBottom: '0.75rem' }}>
<input type="checkbox" checked={form.requires_auth} onChange={e => setForm(f => ({ ...f, requires_auth: e.target.checked }))} />
Require Sign-In
</label>
</div>
<button type="submit" className="btn-accent">Create Channel</button> <button type="submit" className="btn-accent">Create Channel</button>
</form> </form>
)} )}
@@ -337,6 +357,7 @@ function ChannelsTab() {
<strong>{ch.name}</strong> <strong>{ch.name}</strong>
<span className="row-sub">{ch.slug} · {ch.scheduling_mode}</span> <span className="row-sub">{ch.slug} · {ch.scheduling_mode}</span>
<span className="row-badges"> <span className="row-badges">
{ch.requires_auth && <span className="badge badge-error">🔒 Private</span>}
{!hasRules && isExpanded === false && channelSources[ch.id] !== undefined && ( {!hasRules && isExpanded === false && channelSources[ch.id] !== undefined && (
<span className="badge badge-warn" style={{ marginLeft: '0.2rem' }}> Fallback Library Mode</span> <span className="badge badge-warn" style={{ marginLeft: '0.2rem' }}> Fallback Library Mode</span>
)} )}
@@ -370,29 +391,29 @@ function ChannelsTab() {
{/* ─── Channel Status ──────────────────────────────────── */} {/* ─── Channel Status ──────────────────────────────────── */}
{channelStatuses[ch.id] && ( {channelStatuses[ch.id] && (
<div style={{ marginBottom: '1.25rem', padding: '0.75rem', background: 'rgba(59, 130, 246, 0.1)', border: '1px solid rgba(59, 130, 246, 0.3)', borderRadius: '6px' }}> <div className="expand-card is-info">
<div style={{ fontWeight: 600, marginBottom: '0.4rem', color: '#60a5fa' }}>Schedule Status (Next 24 Hours)</div> <div className="expand-card-title is-info">Schedule Status Next 24 Hours</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem', fontSize: '0.9rem' }}> <div className="expand-card-grid">
<span><strong>Total Upcoming:</strong> {channelStatuses[ch.id].total_upcoming_airings}</span> <span><strong>Total Upcoming:</strong> {channelStatuses[ch.id].total_upcoming_airings}</span>
<span><strong>Cached:</strong> {channelStatuses[ch.id].total_cached_airings} ({Math.round(channelStatuses[ch.id].percent_cached)}%)</span> <span><strong>Cached:</strong> {channelStatuses[ch.id].total_cached_airings} ({Math.round(channelStatuses[ch.id].percent_cached)}%)</span>
</div> </div>
{channelStatuses[ch.id].missing_items?.length > 0 && ( {channelStatuses[ch.id].missing_items?.length > 0 && (
<div style={{ marginTop: '0.75rem', fontSize: '0.8rem', opacity: 0.8 }}> <p>
<strong>Missing Downloads:</strong> {channelStatuses[ch.id].missing_items.slice(0, 3).map(i => `[${i.source_name}] ${i.title}`).join(', ')} <strong>Missing:</strong>{' '}
{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` : ''} {channelStatuses[ch.id].missing_items.length > 3 ? ` +${channelStatuses[ch.id].missing_items.length - 3} more` : ''}
</div> </p>
)} )}
</div> </div>
)} )}
{/* ─── Fallback block selector ───────────────────────── */} {/* ─── Fallback block selector ───────────────────────── */}
<div style={{ marginBottom: '1.25rem', padding: '0.75rem', background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)', borderRadius: '6px' }}> <div className="expand-card is-warn">
<label style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', fontSize: '0.9rem' }}> <div className="expand-card-title is-warn">Error Fallback Collection</div>
<span style={{ whiteSpace: 'nowrap', fontWeight: 600 }}> Error Fallback Collection</span> <label>
<select <select
value={ch.fallback_collection_id ?? ''} value={ch.fallback_collection_id ?? ''}
onChange={e => handleSetFallback(ch, e.target.value)} onChange={e => handleSetFallback(ch, e.target.value)}
style={{ flex: 1 }}
title="When scheduled programming cannot play, items from this collection will air instead." title="When scheduled programming cannot play, items from this collection will air instead."
> >
<option value=""> None (use block sources) </option> <option value=""> None (use block sources) </option>
@@ -402,9 +423,20 @@ function ChannelsTab() {
} }
</select> </select>
</label> </label>
<p style={{ margin: '0.4rem 0 0 0', fontSize: '0.78rem', opacity: 0.65 }}> <p>Items from this collection air when a scheduled video cannot be played. If none is set, the scheduler picks a safe video from the block's normal sources.</p>
Items in this collection will air when a scheduled video cannot be played (missing file). If none is set, the scheduler picks a safe video from the block's normal sources. </div>
</p>
{/* ─── Security Settings ────────────────────────────── */}
<div className="expand-card">
<label className="checkbox-label">
<input
type="checkbox"
checked={ch.requires_auth}
onChange={() => handleToggleAuth(ch)}
/>
Require Sign-In to Stream
</label>
<p>If enabled, viewers must be logged in to watch or see the schedule for this channel.</p>
</div> </div>
<h4 className="expand-section-title">Assigned Sources</h4> <h4 className="expand-section-title">Assigned Sources</h4>
@@ -426,9 +458,8 @@ function ChannelsTab() {
))} ))}
{/* Assign form */} {/* Assign form */}
<div className="assign-form" style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '1rem' }}> <div className="assign-form">
<div className="assign-form-row">
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<select <select
value={assignForm.source_id} value={assignForm.source_id}
onChange={e => setAssignForm(f => ({ ...f, source_id: e.target.value }))} onChange={e => setAssignForm(f => ({ ...f, source_id: e.target.value }))}
@@ -437,23 +468,21 @@ function ChannelsTab() {
<option value="">— Select source —</option> <option value="">— Select source —</option>
{sources.map(s => <option key={s.id} value={s.id}>{s.name} ({s.source_type})</option>)} {sources.map(s => <option key={s.id} value={s.id}>{s.name} ({s.source_type})</option>)}
</select> </select>
<button <button
className="btn-accent" className="btn-accent"
onClick={() => { onClick={() => {
if (!assignForm.source_id) { err('Select a source first.'); return; } if (!assignForm.source_id) { err('Select a source first.'); return; }
setAssignForm(f => ({ ...f, rule_mode: 'prefer', weight: 10.0 })); setAssignForm(f => ({ ...f, rule_mode: 'prefer', weight: 10.0 }));
// We wait for re-render state update before submit
setTimeout(() => handleAssign(ch.id), 0); setTimeout(() => handleAssign(ch.id), 0);
}} }}
title="Quick add as heavily preferred source to ensure it dominates schedule" title="Add as heavily preferred source to dominate scheduling"
> >
Set as Primary Source Set as Primary
</button> </button>
</div> </div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', opacity: 0.8, fontSize: '0.9rem' }}> <div className="assign-form-hint">
<span>Or custom rule:</span> <span>Custom rule:</span>
<select <select
value={assignForm.rule_mode} value={assignForm.rule_mode}
onChange={e => setAssignForm(f => ({ ...f, rule_mode: e.target.value }))} onChange={e => setAssignForm(f => ({ ...f, rule_mode: e.target.value }))}
@@ -464,16 +493,16 @@ function ChannelsTab() {
type="number" min="0.1" max="10" step="0.1" type="number" min="0.1" max="10" step="0.1"
value={assignForm.weight} value={assignForm.weight}
onChange={e => setAssignForm(f => ({ ...f, weight: e.target.value }))} onChange={e => setAssignForm(f => ({ ...f, weight: e.target.value }))}
style={{ width: 60 }} style={{ width: 56 }}
title="Weight (higher = more airings)" title="Weight (higher = more airings)"
/> />
<select <select
value={assignForm.schedule_block_label} value={assignForm.schedule_block_label}
onChange={e => setAssignForm(f => ({ ...f, schedule_block_label: e.target.value }))} onChange={e => setAssignForm(f => ({ ...f, schedule_block_label: e.target.value }))}
style={{ flex: 1 }} style={{ flex: 1 }}
title="If set, this source will ONLY play during blocks with this exact name. Leaving it empty applies to all blocks." title="Restrict to a named block, or leave empty for all blocks"
> >
<option value="">— Any Time (Default) —</option> <option value="">— Any block —</option>
{Array.from(new Set( {Array.from(new Set(
templates.filter(t => t.channel_id === ch.id) templates.filter(t => t.channel_id === ch.id)
.flatMap(t => (templateBlocks[t.id] || []).map(b => b.name)) .flatMap(t => (templateBlocks[t.id] || []).map(b => b.name))
@@ -481,7 +510,7 @@ function ChannelsTab() {
<option key={name} value={name}>{name}</option> <option key={name} value={name}>{name}</option>
))} ))}
</select> </select>
<button className="btn-sync sm" onClick={() => handleAssign(ch.id)}>+ Add Rule</button> <button className="btn-sync sm" onClick={() => handleAssign(ch.id)}>+ Add</button>
</div> </div>
</div> </div>
@@ -582,6 +611,13 @@ function SourcesTab() {
}; };
const isYT = (src) => src.source_type.startsWith('youtube'); const isYT = (src) => src.source_type.startsWith('youtube');
const sourceAvatar = (src) => {
if (src.source_type === 'youtube_channel') return 'YTC';
if (src.source_type === 'youtube_playlist') return 'YTP';
if (src.source_type === 'local_directory') return 'DIR';
if (src.source_type === 'stream') return 'STR';
return 'SRC';
};
return ( return (
<div className="tab-content"> <div className="tab-content">
@@ -663,7 +699,7 @@ function SourcesTab() {
const synced = src.last_scanned_at; const synced = src.last_scanned_at;
return ( return (
<div key={src.id} className="settings-row"> <div key={src.id} className="settings-row">
<div className="row-avatar">{isYT(src) ? '▶' : '📁'}</div> <div className={`row-avatar${isYT(src) ? ' is-accent' : ''}`}>{sourceAvatar(src)}</div>
<div className="row-info"> <div className="row-info">
<strong>{src.name}</strong> <strong>{src.name}</strong>
<span className="row-sub">{src.uri}</span> <span className="row-sub">{src.uri}</span>
@@ -842,11 +878,11 @@ function DownloadsTab() {
{!loading && visibleItems.length === 0 && ( {!loading && visibleItems.length === 0 && (
<EmptyState text="No YouTube videos found. Sync a source first." /> <EmptyState text="No YouTube videos found. Sync a source first." />
)} )}
{visibleItems.map(item => ( {visibleItems.map(item => (
<div key={item.id} className={`settings-row download-item-row ${item.cached ? 'is-cached' : ''}`}> <div key={item.id} className={`settings-row download-item-row ${item.cached ? 'is-cached' : ''}`}>
<div className="row-avatar" style={{ fontSize: '0.8rem', background: item.cached ? 'rgba(34,197,94,0.12)' : 'rgba(255,255,255,0.05)', borderColor: item.cached ? 'rgba(34,197,94,0.3)' : 'var(--pytv-glass-border)', color: item.cached ? '#86efac' : 'var(--pytv-text-dim)' }}> <div className={`row-avatar${item.cached ? ' is-accent' : ''}`}>
{item.cached ? '✓' : ''} {item.cached ? '✓' : 'YT'}
</div> </div>
<div className="row-info"> <div className="row-info">
<strong>{item.title}</strong> <strong>{item.title}</strong>
<span className="row-sub">{item.source_name} · {item.runtime_seconds}s</span> <span className="row-sub">{item.source_name} · {item.runtime_seconds}s</span>
@@ -949,7 +985,7 @@ function SchedulingTab() {
let total = 0; let total = 0;
for (const id of chIds) { for (const id of chIds) {
try { const r = await generateScheduleToday(id); total += r.airings_created; } try { const r = await generateScheduleToday(id); total += r.airings_created; }
catch {} catch (e) { console.warn('Failed to generate schedule for channel', id, e); }
} }
ok(`Generated today's schedule: ${total} total airings created.`); ok(`Generated today's schedule: ${total} total airings created.`);
}; };
@@ -962,8 +998,8 @@ function SchedulingTab() {
<div className="settings-section-title"> <div className="settings-section-title">
<h3>Schedule Templates</h3> <h3>Schedule Templates</h3>
<div style={{ display: 'flex', gap: '0.5rem' }}> <div className="row-actions">
<button className="btn-sync" onClick={handleGenerateAll}> Generate All Today</button> <button className="btn-sync" onClick={handleGenerateAll}>Generate All Today</button>
<button className="btn-accent" onClick={() => setShowForm(f => !f)}> <button className="btn-accent" onClick={() => setShowForm(f => !f)}>
{showForm ? '— Cancel' : '+ New Template'} {showForm ? '— Cancel' : '+ New Template'}
</button> </button>
@@ -1009,7 +1045,7 @@ function SchedulingTab() {
return ( return (
<div key={t.id} className={`settings-row-expandable ${isExpanded ? 'expanded' : ''}`}> <div key={t.id} className={`settings-row-expandable ${isExpanded ? 'expanded' : ''}`}>
<div className="settings-row" onClick={() => toggleExpand(t)}> <div className="settings-row" onClick={() => toggleExpand(t)}>
<div className="row-avatar" style={{ fontSize: '1.2rem' }}>📄</div> <div className="row-avatar">TPL</div>
<div className="row-info"> <div className="row-info">
<strong>{t.name}</strong> <strong>{t.name}</strong>
<span className="row-sub">{channelName(t.channel_id)} · {t.timezone_name}</span> <span className="row-sub">{channelName(t.channel_id)} · {t.timezone_name}</span>
@@ -1028,34 +1064,34 @@ function SchedulingTab() {
</div> </div>
{isExpanded && ( {isExpanded && (
<div className="channel-expand-panel block-editor" style={{ background: 'rgba(0,0,0,0.1)', borderTop: 'none', padding: '1rem', borderBottomLeftRadius: '6px', borderBottomRightRadius: '6px' }}> <div className="channel-expand-panel block-editor">
<h4 style={{ margin: '0 0 1rem 0', opacity: 0.9 }}>Schedule Blocks</h4> <h4 className="expand-section-title">Schedule Blocks</h4>
{blocks.length === 0 && ( {blocks.length === 0 && (
<div style={{ fontSize: '0.9rem', opacity: 0.7, marginBottom: '1rem' }}> <p style={{ fontSize: '0.82rem', color: 'var(--pytv-text-dim)', marginBottom: '0.5rem' }}>
No blocks defined. By default, PYTV acts as if there is a single 24/7 block. If you define blocks here, you must completely cover the 24 hours of a day to avoid dead air. No blocks defined. By default PYTV uses a single 24/7 block. Define blocks here to create specific programming windows.
</div> </p>
)} )}
{blocks.length > 0 && ( {blocks.length > 0 && (
<div style={{ position: 'relative', width: '100%', height: '32px', background: '#2f3640', borderRadius: '4px', marginBottom: '1.5rem', overflow: 'hidden', border: '1px solid var(--pytv-glass-border)' }}> <div className="block-timeline">
{/* Timeline tick marks */}
{[0, 6, 12, 18].map(h => ( {[0, 6, 12, 18].map(h => (
<div key={h} style={{ position: 'absolute', left: `${(h/24)*100}%`, height: '100%', borderLeft: '1px dashed rgba(255,255,255,0.2)', pointerEvents: 'none', paddingLeft: '2px', fontSize: '0.65rem', color: 'rgba(255,255,255,0.4)', paddingTop: '2px' }}> <div key={h} className="block-timeline-tick" style={{ left: `${(h / 24) * 100}%` }}>
{h}:00 {h}:00
</div> </div>
))} ))}
{blocks.map(b => { {blocks.map(b => {
const startPct = timeToPct(b.start_local_time); const startPct = timeToPct(b.start_local_time);
let endPct = timeToPct(b.end_local_time); let endPct = timeToPct(b.end_local_time);
if (endPct <= startPct) endPct = 100; // spills past midnight visually if (endPct <= startPct) endPct = 100;
const width = endPct - startPct; const width = endPct - startPct;
const color = b.block_type === 'OFF_AIR' ? 'rgba(248, 113, 113, 0.8)' : 'rgba(96, 165, 250, 0.8)'; // red vs blue const cls = b.block_type === 'OFF_AIR' ? 'is-off-air' : 'is-programming';
return ( return (
<div <div
key={`vis-${b.id}`} key={`vis-${b.id}`}
style={{ position: 'absolute', left: `${startPct}%`, width: `${width}%`, height: '100%', background: color, borderRight: '1px solid rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.75rem', color: '#fff', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', padding: '0 4px' }} className={`block-timeline-segment ${cls}`}
title={`${b.name} (${b.start_local_time.slice(0,5)} - ${b.end_local_time.slice(0,5)}) Rating: ${b.target_content_rating || 'Any'}`} style={{ left: `${startPct}%`, width: `${width}%` }}
title={`${b.name} (${b.start_local_time.slice(0,5)}${b.end_local_time.slice(0,5)})`}
> >
{width > 8 ? b.name : ''} {width > 8 ? b.name : ''}
</div> </div>
@@ -1064,13 +1100,13 @@ function SchedulingTab() {
</div> </div>
)} )}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1.5rem' }}> <div className="block-list">
{blocks.map(b => ( {blocks.map(b => (
<div key={b.id} style={{ display: 'flex', gap: '0.5rem', background: '#353b48', padding: '0.5rem', borderRadius: '4px', alignItems: 'center', fontSize: '0.9rem' }}> <div key={b.id} className="block-row">
<strong style={{ minWidth: 100 }}>{b.name}</strong> <span className="block-row-name">{b.name}</span>
<span style={{ fontFamily: 'monospace', opacity: 0.8 }}>{b.start_local_time.slice(0,5)} - {b.end_local_time.slice(0,5)}</span> <span className="block-row-time">{b.start_local_time.slice(0,5)} {b.end_local_time.slice(0,5)}</span>
<span className={`badge ${b.block_type === 'OFF_AIR' ? 'badge-warn' : 'badge-ok'}`}>{b.block_type}</span> <span className={`badge ${b.block_type === 'OFF_AIR' ? 'badge-warn' : 'badge-ok'}`}>{b.block_type}</span>
{b.target_content_rating && <span className="badge badge-type">Rating Tier: {b.target_content_rating}</span>} {b.target_content_rating && <span className="badge badge-type">Rating {b.target_content_rating}</span>}
<div style={{ flex: 1 }} /> <div style={{ flex: 1 }} />
<IconBtn icon="✕" kind="danger" onClick={async () => { <IconBtn icon="✕" kind="danger" onClick={async () => {
try { try {
@@ -1083,9 +1119,9 @@ function SchedulingTab() {
))} ))}
</div> </div>
<form className="assign-form" style={{ background: '#2f3640' }} onSubmit={async (e) => { <form className="block-add-form" onSubmit={async (e) => {
e.preventDefault(); e.preventDefault();
if (!blockForm.name || !blockForm.start_local_time || !blockForm.end_local_time) { err('Fill req fields'); return; } if (!blockForm.name || !blockForm.start_local_time || !blockForm.end_local_time) { err('Fill required fields'); return; }
try { try {
const nb = await createTemplateBlock({ const nb = await createTemplateBlock({
schedule_template_id: t.id, schedule_template_id: t.id,
@@ -1100,19 +1136,19 @@ function SchedulingTab() {
ok('Block created.'); ok('Block created.');
} catch { err('Failed to create block'); } } catch { err('Failed to create block'); }
}}> }}>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}> <div className="block-add-row">
<input placeholder="Block Name (e.g. Morning News)" required style={{ flex: 1 }} value={blockForm.name} onChange={e => setBlockForm(f => ({...f, name: e.target.value}))} /> <input placeholder="Block name" required style={{ flex: 1 }} value={blockForm.name} onChange={e => setBlockForm(f => ({...f, name: e.target.value}))} />
<select value={blockForm.block_type} onChange={e => setBlockForm(f => ({...f, block_type: e.target.value}))}> <select value={blockForm.block_type} onChange={e => setBlockForm(f => ({...f, block_type: e.target.value}))}>
<option value="PROGRAM">Programming</option> <option value="PROGRAM">Programming</option>
<option value="OFF_AIR">Off Air / Dead Time</option> <option value="OFF_AIR">Off Air</option>
</select> </select>
<input type="time" required value={blockForm.start_local_time} onChange={e => setBlockForm(f => ({...f, start_local_time: e.target.value}))} />
<span style={{ opacity: 0.5 }}>to</span>
<input type="time" required value={blockForm.end_local_time} onChange={e => setBlockForm(f => ({...f, end_local_time: e.target.value}))} />
</div> </div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginTop: '0.5rem' }}> <div className="block-add-row">
<input type="time" required value={blockForm.start_local_time} onChange={e => setBlockForm(f => ({...f, start_local_time: e.target.value}))} />
<span className="block-time-sep">to</span>
<input type="time" required value={blockForm.end_local_time} onChange={e => setBlockForm(f => ({...f, end_local_time: e.target.value}))} />
<select value={blockForm.target_content_rating} onChange={e => setBlockForm(f => ({...f, target_content_rating: e.target.value}))}> <select value={blockForm.target_content_rating} onChange={e => setBlockForm(f => ({...f, target_content_rating: e.target.value}))}>
<option value="">Any content rating</option> <option value="">Any rating</option>
<option value="1">TV-Y</option> <option value="1">TV-Y</option>
<option value="2">TV-Y7</option> <option value="2">TV-Y7</option>
<option value="3">TV-G</option> <option value="3">TV-G</option>
@@ -1134,10 +1170,217 @@ function SchedulingTab() {
); );
} }
function BackupsTab() {
const [schedule, setSchedule] = useState(null);
const [files, setFiles] = useState([]);
const [importMode, setImportMode] = useState('append');
const [importFile, setImportFile] = useState(null);
const [busy, setBusy] = useState(false);
const [feedback, setFeedback, ok, err] = useFeedback();
const load = useCallback(async () => {
try {
const [sched, backups] = await Promise.all([fetchBackupSchedule(), fetchBackups()]);
setSchedule(sched);
setFiles(backups);
} catch {
err('Failed to load backup settings.');
}
}, [err]);
useEffect(() => { load(); }, [load]);
const saveSchedule = async () => {
if (!schedule) return;
setBusy(true);
try {
const updated = await updateBackupSchedule(schedule);
setSchedule(updated);
ok('Backup schedule saved.');
} catch {
err('Failed to save backup schedule.');
} finally {
setBusy(false);
}
};
const handleBackupNow = async () => {
setBusy(true);
try {
const result = await runBackupNow();
ok(`Backup created: ${result.filename}`);
await load();
} catch {
err('Backup failed.');
} finally {
setBusy(false);
}
};
const handleDownload = async (filename) => {
try {
const blob = await downloadBackup(filename);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
} catch {
err('Failed to download backup.');
}
};
const handleImport = async () => {
if (!importFile) {
err('Choose a backup JSON file first.');
return;
}
setBusy(true);
try {
const result = await importBackup(importFile, importMode);
ok(`Import complete (${result.mode}): ${result.created} created, ${result.updated} updated.`);
await load();
} catch {
err('Import failed.');
} finally {
setBusy(false);
}
};
return (
<div className="tab-content">
<Feedback fb={feedback} clear={() => setFeedback(null)} />
{!schedule && <EmptyState text="Loading backup settings..." />}
{schedule && (
<>
<div className="settings-section-title"><h3>Backup Schedule</h3></div>
<div className="settings-form">
<label className="checkbox-label">
<input
type="checkbox"
checked={schedule.enabled}
onChange={e => setSchedule(s => ({ ...s, enabled: e.target.checked }))}
/>
Enable automatic backups
</label>
<div className="form-row">
<label>Frequency
<select value={schedule.frequency} onChange={e => setSchedule(s => ({ ...s, frequency: e.target.value }))}>
<option value="hourly">Hourly</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
</label>
<label>Time (24h)
<input
type="time"
value={`${String(schedule.hour).padStart(2, '0')}:${String(schedule.minute).padStart(2, '0')}`}
onChange={e => {
const [h, m] = e.target.value.split(':').map(Number);
setSchedule(s => ({ ...s, hour: h, minute: m }));
}}
/>
</label>
</div>
{schedule.frequency === 'weekly' && (
<label>Day of Week
<select value={schedule.day_of_week} onChange={e => setSchedule(s => ({ ...s, day_of_week: parseInt(e.target.value) }))}>
<option value={0}>Monday</option>
<option value={1}>Tuesday</option>
<option value={2}>Wednesday</option>
<option value={3}>Thursday</option>
<option value={4}>Friday</option>
<option value={5}>Saturday</option>
<option value={6}>Sunday</option>
</select>
</label>
)}
{schedule.frequency === 'monthly' && (
<label>Day of Month
<input
type="number"
min={1}
max={28}
value={schedule.day_of_month}
onChange={e => setSchedule(s => ({ ...s, day_of_month: parseInt(e.target.value || '1') }))}
/>
</label>
)}
<label>Retention (number of backup files to keep)
<input
type="number"
min={1}
value={schedule.retention_count}
onChange={e => setSchedule(s => ({ ...s, retention_count: parseInt(e.target.value || '1') }))}
/>
</label>
<div className="form-actions">
<button className="btn-accent" onClick={saveSchedule} disabled={busy}>Save Schedule</button>
<button className="btn-sync" onClick={handleBackupNow} disabled={busy}>Backup Now</button>
</div>
<p className="settings-empty" style={{ marginTop: '0.4rem' }}>
Backups are always stored on server path <code>/Backups</code>.
</p>
</div>
<div className="settings-section-title"><h3>Import Backup</h3></div>
<div className="settings-form">
<label>Backup File (JSON)
<input type="file" accept="application/json,.json" onChange={e => setImportFile(e.target.files?.[0] || null)} />
</label>
<label>Import Mode
<select value={importMode} onChange={e => setImportMode(e.target.value)}>
<option value="append">Append (merge into existing DB)</option>
<option value="override">Override (replace existing DB)</option>
</select>
</label>
<button className="btn-accent" onClick={handleImport} disabled={busy || !importFile}>Import Backup</button>
</div>
<div className="settings-section-title"><h3>Available Backups</h3></div>
<div className="settings-row-list">
{files.length === 0 && <EmptyState text="No backups found." />}
{files.map(file => (
<div key={file.filename} className="settings-row">
<div className="row-avatar">BK</div>
<div className="row-info">
<strong>{file.filename}</strong>
<span className="row-sub">{Math.round(file.size_bytes / 1024)} KB · {new Date(file.created_at).toLocaleString()}</span>
</div>
<div className="row-actions">
<button className="btn-sync sm" onClick={() => handleDownload(file.filename)}>Download</button>
</div>
</div>
))}
</div>
</>
)}
</div>
);
}
// ─── Root Settings Component ────────────────────────────────────────────── // ─── Root Settings Component ──────────────────────────────────────────────
export default function Settings({ onClose }) { export default function Settings({ onClose }) {
const [activeTab, setActiveTab] = useState('channels'); const [activeTab, setActiveTab] = useState('channels');
const { user, logout } = useAuth();
const [loggingOut, setLoggingOut] = useState(false);
const handleLogout = async () => {
setLoggingOut(true);
try {
await logout();
onClose();
} finally {
setLoggingOut(false);
}
};
return ( return (
<div className="settings-overlay"> <div className="settings-overlay">
@@ -1146,6 +1389,11 @@ export default function Settings({ onClose }) {
<div className="settings-header"> <div className="settings-header">
<span className="settings-logo">PYTV</span> <span className="settings-logo">PYTV</span>
<h2>Settings</h2> <h2>Settings</h2>
{user && (
<button className="btn-accent" onClick={handleLogout} disabled={loggingOut}>
{loggingOut ? 'Logging out...' : 'Logout'}
</button>
)}
<button className="settings-close-btn" onClick={onClose}>✕ Close</button> <button className="settings-close-btn" onClick={onClose}>✕ Close</button>
</div> </div>
@@ -1168,6 +1416,7 @@ export default function Settings({ onClose }) {
{activeTab === 'sources' && <SourcesTab />} {activeTab === 'sources' && <SourcesTab />}
{activeTab === 'downloads' && <DownloadsTab />} {activeTab === 'downloads' && <DownloadsTab />}
{activeTab === 'schedule' && <SchedulingTab />} {activeTab === 'schedule' && <SchedulingTab />}
{activeTab === 'backups' && <BackupsTab />}
{activeTab === 'users' && <UsersTab />} {activeTab === 'users' && <UsersTab />}
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -17,3 +17,13 @@ dependencies = [
"pytest-sugar>=1.1.1", "pytest-sugar>=1.1.1",
"yt-dlp>=2026.3.3", "yt-dlp>=2026.3.3",
] ]
[project.scripts]
make_user = "core.scripts.make_user:main"
[tool.uv]
package = true
[tool.setuptools.packages.find]
include = ["api*", "core*", "pytv*"]
exclude = ["frontend*", "node_modules*", "assets*", "mock*", "cache*", "tests*"]

View File

@@ -37,6 +37,8 @@ DEBUG = env('DEBUG')
ALLOWED_HOSTS = ['localhost', '127.0.0.1', '0.0.0.0'] ALLOWED_HOSTS = ['localhost', '127.0.0.1', '0.0.0.0']
CORS_ALLOW_ALL_ORIGINS = True # Allows Vite dev server to connect CORS_ALLOW_ALL_ORIGINS = True # Allows Vite dev server to connect
CORS_ALLOW_CREDENTIALS = True
CSRF_TRUSTED_ORIGINS = ['http://localhost:5173', 'http://127.0.0.1:5173']
# Application definition # Application definition
@@ -123,6 +125,11 @@ USE_TZ = True
AUTH_USER_MODEL = 'core.AppUser' AUTH_USER_MODEL = 'core.AppUser'
# Session lifetime: keep login persistent for a bounded time window.
SESSION_COOKIE_AGE = env.int('SESSION_COOKIE_AGE', default=60 * 60 * 24 * 7) # 7 days
SESSION_EXPIRE_AT_BROWSER_CLOSE = env.bool('SESSION_EXPIRE_AT_BROWSER_CLOSE', default=False)
SESSION_SAVE_EVERY_REQUEST = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/6.0/howto/static-files/ # https://docs.djangoproject.com/en/6.0/howto/static-files/
@@ -131,3 +138,7 @@ STATIC_URL = "static/"
# YouTube video cache directory (used by core.services.youtube and cache_upcoming command) # YouTube video cache directory (used by core.services.youtube and cache_upcoming command)
MEDIA_ROOT = env('MEDIA_ROOT', default='/tmp/pytv_cache') MEDIA_ROOT = env('MEDIA_ROOT', default='/tmp/pytv_cache')
# Backups are always written to a fixed in-container location.
BACKUP_ROOT = env('BACKUP_ROOT', default='/Backups')

View File

@@ -0,0 +1,68 @@
import pytest
from core.models import AppUser, Channel, Library
from django.urls import reverse
from datetime import datetime, timedelta
from django.utils import timezone
@pytest.mark.django_db
class TestChannelAuthEnforcement:
def setup_method(self):
# Create user and library
self.user = AppUser.objects.create_user(
username="testuser",
password="password123",
email="test@example.com"
)
self.library = Library.objects.create(
owner_user=self.user,
name="Test Library"
)
# Create a channel that requires auth
self.protected_channel = Channel.objects.create(
owner_user=self.user,
library=self.library,
name="Protected Channel",
slug="protected-channel",
requires_auth=True
)
# Create a channel that does not require auth
self.public_channel = Channel.objects.create(
owner_user=self.user,
library=self.library,
name="Public Channel",
slug="public-channel",
requires_auth=False
)
def test_unauthenticated_access_to_protected_channel_now(self, client):
response = client.get(f"/api/channel/{self.protected_channel.id}/now")
assert response.status_code == 401
def test_authenticated_access_to_protected_channel_now(self, client):
client.login(username="testuser", password="password123")
response = client.get(f"/api/channel/{self.protected_channel.id}/now")
assert response.status_code == 200
def test_unauthenticated_access_to_public_channel_now(self, client):
response = client.get(f"/api/channel/{self.public_channel.id}/now")
assert response.status_code == 200
def test_unauthenticated_access_to_protected_channel_airings(self, client):
response = client.get(f"/api/channel/{self.protected_channel.id}/airings")
assert response.status_code == 401
def test_authenticated_access_to_protected_channel_airings(self, client):
client.login(username="testuser", password="password123")
response = client.get(f"/api/channel/{self.protected_channel.id}/airings")
assert response.status_code == 200
def test_unauthenticated_access_to_protected_channel_status(self, client):
response = client.get(f"/api/channel/{self.protected_channel.id}/status")
assert response.status_code == 401
def test_authenticated_access_to_protected_channel_status(self, client):
client.login(username="testuser", password="password123")
response = client.get(f"/api/channel/{self.protected_channel.id}/status")
assert response.status_code == 200

2
uv.lock generated
View File

@@ -333,7 +333,7 @@ wheels = [
[[package]] [[package]]
name = "pytv" name = "pytv"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "django" }, { name = "django" },
{ name = "django-cors-headers" }, { name = "django-cors-headers" },