feat(main): main

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

View File

@@ -7,6 +7,8 @@ from api.routers.channel import router as channel_router
from api.routers.schedule import router as schedule_router
from api.routers.user import router as user_router
from api.routers.sources import router as sources_router
from api.routers.auth import router as auth_router
from api.routers.backups import router as backups_router
api.add_router("/library/", library_router)
api.add_router("/collections/", collections_router)
@@ -14,3 +16,6 @@ api.add_router("/channel/", channel_router)
api.add_router("/schedule/", schedule_router)
api.add_router("/user/", user_router)
api.add_router("/sources/", sources_router)
api.add_router("/auth/", auth_router)
api.add_router("/backups/", backups_router)

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

@@ -0,0 +1,105 @@
from ninja import Router, Schema
from django.contrib.auth import authenticate, login, logout
from django.http import JsonResponse
from django.conf import settings
from ninja.errors import HttpError
from pydantic import BaseModel
from core.models import AppUser
router = Router(tags=["auth"])
class LoginSchema(Schema):
username: str
password: str
class UserSchema(Schema):
id: int
username: str
email: str
is_staff: bool
is_superuser: bool
class SetupSchema(Schema):
username: str
password: str
email: str
@router.get("/has-users")
def check_has_users(request):
"""Returns True if any users exist in the database."""
return {"has_users": AppUser.objects.exists()}
@router.post("/setup")
def setup_admin(request, payload: SetupSchema):
"""Allows creating a superuser if and only if the database is entirely empty."""
if AppUser.objects.exists():
raise HttpError(403, "Database already has users. Cannot run initial setup.")
user = AppUser.objects.create_superuser(
username=payload.username,
email=payload.email,
password=payload.password
)
# Bootstrap a default library now that the first user exists.
from core.services.bootstrap import ensure_default_library
ensure_default_library()
# Log them in automatically
user = authenticate(request, username=payload.username, password=payload.password)
login(request, user)
request.session.set_expiry(settings.SESSION_COOKIE_AGE)
from django.middleware.csrf import get_token
csrf_token = get_token(request)
return {
"success": True,
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"is_staff": user.is_staff,
"is_superuser": user.is_superuser
},
"csrf_token": csrf_token
}
@router.post("/login")
def auth_login(request, payload: LoginSchema):
# Passwords in django are already PBKDF2 hashed and verified through `authenticate`
user = authenticate(request, username=payload.username, password=payload.password)
if user is not None:
login(request, user)
request.session.set_expiry(settings.SESSION_COOKIE_AGE)
# We also need to get the CSRF token to be placed in the cookie so the frontend can send it back natively.
from django.middleware.csrf import get_token
csrf_token = get_token(request)
return {
"success": True,
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"is_staff": user.is_staff,
"is_superuser": user.is_superuser
},
"csrf_token": csrf_token
}
else:
raise HttpError(401, "Invalid username or password")
@router.post("/logout")
def auth_logout(request):
logout(request)
resp = JsonResponse({"success": True})
resp.delete_cookie(settings.SESSION_COOKIE_NAME, path='/')
return resp
@router.get("/me", response=UserSchema)
def auth_me(request):
if request.user.is_authenticated:
return request.user
else:
raise HttpError(401, "Not authenticated")

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

@@ -0,0 +1,156 @@
from datetime import datetime
from django.http import FileResponse
from ninja import File, Router, Schema
from ninja.errors import HttpError
from ninja.files import UploadedFile
from core.models import BackupSchedule
from core.services.backups import (
compute_next_run,
create_backup_now,
get_backup_file,
import_backup_content,
is_backup_due,
list_backups,
)
router = Router(tags=["backups"])
class BackupScheduleSchema(Schema):
enabled: bool
frequency: str
minute: int
hour: int
day_of_week: int
day_of_month: int
retention_count: int
last_run_at: datetime | None = None
next_run_at: datetime | None = None
class BackupScheduleUpdateSchema(Schema):
enabled: bool
frequency: str
minute: int = 0
hour: int = 2
day_of_week: int = 0
day_of_month: int = 1
retention_count: int = 14
class BackupFileSchema(Schema):
filename: str
size_bytes: int
created_at: str
def _require_admin(request):
if not request.user.is_authenticated:
raise HttpError(401, "Authentication required.")
if not request.user.is_staff:
raise HttpError(403, "Admin permissions required.")
@router.get("/settings", response=BackupScheduleSchema)
def get_backup_schedule(request):
_require_admin(request)
schedule = BackupSchedule.get_solo()
return {
"enabled": schedule.enabled,
"frequency": schedule.frequency,
"minute": schedule.minute,
"hour": schedule.hour,
"day_of_week": schedule.day_of_week,
"day_of_month": schedule.day_of_month,
"retention_count": schedule.retention_count,
"last_run_at": schedule.last_run_at,
"next_run_at": compute_next_run(schedule),
}
@router.put("/settings", response=BackupScheduleSchema)
def update_backup_schedule(request, payload: BackupScheduleUpdateSchema):
_require_admin(request)
if payload.frequency not in BackupSchedule.Frequency.values:
raise HttpError(400, "Invalid frequency")
if not 0 <= payload.minute <= 59:
raise HttpError(400, "minute must be 0..59")
if not 0 <= payload.hour <= 23:
raise HttpError(400, "hour must be 0..23")
if not 0 <= payload.day_of_week <= 6:
raise HttpError(400, "day_of_week must be 0..6")
if not 1 <= payload.day_of_month <= 28:
raise HttpError(400, "day_of_month must be 1..28")
if payload.retention_count < 1:
raise HttpError(400, "retention_count must be >= 1")
schedule = BackupSchedule.get_solo()
for attr, value in payload.dict().items():
setattr(schedule, attr, value)
schedule.save()
return {
"enabled": schedule.enabled,
"frequency": schedule.frequency,
"minute": schedule.minute,
"hour": schedule.hour,
"day_of_week": schedule.day_of_week,
"day_of_month": schedule.day_of_month,
"retention_count": schedule.retention_count,
"last_run_at": schedule.last_run_at,
"next_run_at": compute_next_run(schedule),
}
@router.post("/run")
def run_backup_now(request):
_require_admin(request)
path = create_backup_now()
return {"status": "ok", "filename": path.name}
@router.get("/", response=list[BackupFileSchema])
def get_backups(request):
_require_admin(request)
return [x.__dict__ for x in list_backups()]
@router.get("/{filename}/download")
def download_backup(request, filename: str):
_require_admin(request)
try:
target = get_backup_file(filename)
except FileNotFoundError:
raise HttpError(404, "Backup not found")
response = FileResponse(open(target, "rb"), content_type="application/json")
response["Content-Disposition"] = f'attachment; filename="{target.name}"'
return response
@router.post("/import")
def import_backup(request, file: UploadedFile = File(...), mode: str = "append"):
_require_admin(request)
if mode not in {"append", "override"}:
raise HttpError(400, "mode must be append or override")
content = file.read().decode("utf-8")
try:
result = import_backup_content(content, mode=mode)
except ValueError as exc:
raise HttpError(400, str(exc))
return {"status": "ok", **result}
@router.post("/run-if-due")
def run_if_due(request):
_require_admin(request)
schedule = BackupSchedule.get_solo()
if not is_backup_due(schedule):
return {"status": "skipped"}
path = create_backup_now()
return {"status": "ok", "filename": path.name}

View File

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

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

@@ -0,0 +1,53 @@
import json
import pytest
from core.models import AppUser
@pytest.mark.django_db
def test_login_sets_bounded_session_expiry(client, settings):
settings.SESSION_COOKIE_AGE = 3600
AppUser.objects.create_user(
username="auth_user",
email="auth@example.com",
password="secret-pass-123",
)
response = client.post(
"/api/auth/login",
data=json.dumps({"username": "auth_user", "password": "secret-pass-123"}),
content_type="application/json",
)
assert response.status_code == 200
assert response.json()["success"] is True
# Session should be persisted, but bounded to SESSION_COOKIE_AGE.
assert 0 < client.session.get_expiry_age() <= settings.SESSION_COOKIE_AGE
@pytest.mark.django_db
def test_logout_forces_session_logout(client):
AppUser.objects.create_user(
username="logout_user",
email="logout@example.com",
password="secret-pass-123",
)
login_response = client.post(
"/api/auth/login",
data=json.dumps({"username": "logout_user", "password": "secret-pass-123"}),
content_type="application/json",
)
assert login_response.status_code == 200
assert client.get("/api/auth/me").status_code == 200
logout_response = client.post("/api/auth/logout")
assert logout_response.status_code == 200
# Session is no longer authenticated after explicit logout.
assert client.get("/api/auth/me").status_code == 401

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

@@ -0,0 +1,82 @@
import json
from pathlib import Path
import pytest
from core.models import AppUser, BackupSchedule, Library, MediaSource, MediaItem
@pytest.fixture
def admin_user(db):
return AppUser.objects.create_superuser("backup_admin", "backup@example.com", "secret-pass-123")
@pytest.mark.django_db
def test_backup_schedule_requires_admin(client):
response = client.get("/api/backups/settings")
assert response.status_code == 401
@pytest.mark.django_db
def test_backup_schedule_get_and_update(client, admin_user):
client.login(username="backup_admin", password="secret-pass-123")
response = client.get("/api/backups/settings")
assert response.status_code == 200
payload = {
"enabled": True,
"frequency": "weekly",
"minute": 30,
"hour": 3,
"day_of_week": 6,
"day_of_month": 1,
"retention_count": 5,
}
update = client.put(
"/api/backups/settings",
data=json.dumps(payload),
content_type="application/json",
)
assert update.status_code == 200
schedule = BackupSchedule.get_solo()
assert schedule.enabled is True
assert schedule.frequency == "weekly"
assert schedule.retention_count == 5
@pytest.mark.django_db
def test_backup_now_writes_json_and_strips_cached_paths(client, admin_user, settings, tmp_path):
settings.BACKUP_ROOT = str(tmp_path)
client.login(username="backup_admin", password="secret-pass-123")
library = Library.objects.create(owner_user=admin_user, name="Backup Lib")
source = MediaSource.objects.create(
library=library,
name="YT",
source_type="youtube_playlist",
uri="https://example.com/list",
)
MediaItem.objects.create(
media_source=source,
title="Cached Item",
item_kind="movie",
runtime_seconds=60,
file_path="https://youtube.com/watch?v=abc",
cached_file_path="/tmp/pytv_cache/abc.mp4",
)
resp = client.post("/api/backups/run")
assert resp.status_code == 200
filename = resp.json()["filename"]
backup_path = Path(settings.BACKUP_ROOT) / filename
assert backup_path.exists()
payload = json.loads(backup_path.read_text(encoding="utf-8"))
media_entries = [x for x in payload["items"] if x["model"] == "core.mediaitem"]
assert media_entries
assert media_entries[0]["fields"]["cached_file_path"] is None