feat(main): main
This commit is contained in:
96
AGENTS.md
Normal file
96
AGENTS.md
Normal 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.
|
||||||
|
|
||||||
|
|
||||||
@@ -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
105
api/routers/auth.py
Normal 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
156
api/routers/backups.py
Normal 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}
|
||||||
|
|
||||||
@@ -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
53
api/tests/test_auth.py
Normal 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
82
api/tests/test_backups.py
Normal 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
|
||||||
|
|
||||||
|
|
||||||
12
core/apps.py
12
core/apps.py
@@ -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)
|
||||||
|
|||||||
12
core/management/commands/backup_now.py
Normal file
12
core/management/commands/backup_now.py
Normal 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}"))
|
||||||
|
|
||||||
30
core/management/commands/run_backup_worker.py
Normal file
30
core/management/commands/run_backup_worker.py
Normal 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)
|
||||||
|
|
||||||
21
core/migrations/0006_channel_requires_auth.py
Normal file
21
core/migrations/0006_channel_requires_auth.py
Normal 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.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
27
core/migrations/0007_backupschedule.py
Normal file
27
core/migrations/0007_backupschedule.py
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
@@ -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
0
core/scripts/__init__.py
Normal file
41
core/scripts/make_user.py
Normal file
41
core/scripts/make_user.py
Normal 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
227
core/services/backups.py
Normal 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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
92
core/services/bootstrap.py
Normal file
92
core/services/bootstrap.py
Normal 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)
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
56
frontend/src/AuthContext.jsx
Normal file
56
frontend/src/AuthContext.jsx
Normal 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);
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
84
frontend/src/components/AdminSetup.jsx
Normal file
84
frontend/src/components/AdminSetup.jsx
Normal 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;
|
||||||
117
frontend/src/components/LoginModal.css
Normal file
117
frontend/src/components/LoginModal.css
Normal 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;
|
||||||
|
}
|
||||||
73
frontend/src/components/LoginModal.jsx
Normal file
73
frontend/src/components/LoginModal.jsx
Normal 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;
|
||||||
@@ -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
@@ -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*"]
|
||||||
@@ -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')
|
||||||
|
|
||||||
|
|||||||
68
tests/test_channel_auth.py
Normal file
68
tests/test_channel_auth.py
Normal 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
2
uv.lock
generated
@@ -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" },
|
||||||
|
|||||||
Reference in New Issue
Block a user