feat(main): main

This commit is contained in:
2026-03-10 08:39:28 -04:00
parent b1a93161c0
commit af3076342a
18 changed files with 826 additions and 38 deletions

View File

@@ -58,13 +58,26 @@ class AiringSchema(Schema):
ends_at: datetime
slot_kind: str
status: str
exact_playback_offset_seconds: float = 0.0
@staticmethod
def from_airing(airing) -> 'AiringSchema':
media_path = None
exact_offset = 0.0
# Calculate exactly how far into the video we should be right now
now = timezone.now()
# if the airing hasn't started yet, offset is 0
if now >= airing.starts_at:
exact_offset = (now - airing.starts_at).total_seconds()
if airing.media_item:
item = airing.media_item
# If the item has a known runtime, and we are looping it, modulo the offset
if item.runtime_seconds and item.runtime_seconds > 0:
exact_offset = exact_offset % item.runtime_seconds
# 1. Determine if this item is from a YouTube source
is_youtube = False
if item.media_source and item.media_source.source_type in ['youtube', 'youtube_channel', 'youtube_playlist']:
@@ -102,6 +115,7 @@ class AiringSchema(Schema):
ends_at=airing.ends_at,
slot_kind=airing.slot_kind,
status=airing.status,
exact_playback_offset_seconds=max(0.0, exact_offset)
)
@router.get("/", response=List[ChannelSchema])
@@ -112,7 +126,61 @@ def list_channels(request):
)
class ChannelStatusSchema(Schema):
total_upcoming_airings: int
total_cached_airings: int
percent_cached: float
missing_items: List[dict]
@router.get("/{channel_id}/status", response=ChannelStatusSchema)
def get_channel_status(request, channel_id: int):
channel = get_object_or_404(Channel, id=channel_id)
now = timezone.now()
window_end = now + timedelta(hours=24)
airings = Airing.objects.filter(
channel=channel,
ends_at__gt=now,
starts_at__lte=window_end
).select_related('media_item')
total = 0
cached = 0
missing = []
for a in airings:
total += 1
item = a.media_item
if item and item.cached_file_path:
# We don't do path.exists() here to keep it fast, but we could.
cached += 1
elif item:
missing.append({
"id": item.id,
"title": item.title,
"starts_at": a.starts_at.isoformat()
})
pct = (cached / total * 100.0) if total > 0 else 100.0
return {
"total_upcoming_airings": total,
"total_cached_airings": cached,
"percent_cached": pct,
"missing_items": missing
}
@router.post("/{channel_id}/download")
def trigger_channel_download(request, channel_id: int):
get_object_or_404(Channel, id=channel_id)
from core.services.cache import run_cache
# Run cache explicitly for this channel for the next 24 hours
result = run_cache(hours=24, prune_only=False, channel_id=channel_id)
return result
@router.get("/{channel_id}", response=ChannelSchema)
def get_channel(request, channel_id: int):
return get_object_or_404(Channel, id=channel_id)

View File

@@ -41,6 +41,10 @@ class MediaSourceIn(BaseModel):
uri: str
is_active: bool = True
scan_interval_minutes: Optional[int] = None
min_video_length_seconds: Optional[int] = None
max_video_length_seconds: Optional[int] = None
min_repeat_gap_hours: Optional[int] = None
max_age_days: Optional[int] = None
class MediaSourceOut(BaseModel):
id: int
@@ -50,6 +54,10 @@ class MediaSourceOut(BaseModel):
uri: str
is_active: bool
scan_interval_minutes: Optional[int]
min_video_length_seconds: Optional[int]
max_video_length_seconds: Optional[int]
min_repeat_gap_hours: Optional[int]
max_age_days: Optional[int]
last_scanned_at: Optional[datetime]
created_at: datetime
@@ -140,6 +148,23 @@ def delete_source(request, source_id: int):
source.delete()
return 204, None
@router.put("/{source_id}", response=MediaSourceOut)
def update_source(request, source_id: int, payload: MediaSourceIn):
"""Update an existing media source."""
source = get_object_or_404(MediaSource, id=source_id)
source.name = payload.name
source.source_type = payload.source_type
source.uri = payload.uri
source.library_id = payload.library_id
source.is_active = payload.is_active
source.scan_interval_minutes = payload.scan_interval_minutes
source.min_video_length_seconds = payload.min_video_length_seconds
source.max_video_length_seconds = payload.max_video_length_seconds
source.min_repeat_gap_hours = payload.min_repeat_gap_hours
source.max_age_days = payload.max_age_days
source.save()
return source
@router.post("/{source_id}/sync", response=SyncResult)
def trigger_sync(request, source_id: int, max_videos: Optional[int] = None):

View File

@@ -1,3 +1,71 @@
from django.shortcuts import render
import os
import re
from django.conf import settings
from django.http import StreamingHttpResponse, Http404, HttpResponseNotModified, FileResponse
from django.views.static import was_modified_since
from wsgiref.util import FileWrapper
# Create your views here.
def serve_video_with_range(request, path):
"""
Serve a media file with HTTP Range support. Required for HTML5 video
seeking in Chrome/Safari using the Django development server.
"""
clean_path = path.lstrip('/')
full_path = os.path.normpath(os.path.join(settings.MEDIA_ROOT, clean_path))
# Security check to prevent directory traversal
if not full_path.startswith(os.path.normpath(settings.MEDIA_ROOT)):
raise Http404("Invalid path")
if not os.path.exists(full_path):
raise Http404(f"File {path} not found")
statobj = os.stat(full_path)
size = statobj.st_size
# Very simple content type mapping for videos
content_type = "video/mp4"
if full_path.endswith('.webm'): content_type = "video/webm"
elif full_path.endswith('.mkv'): content_type = "video/x-matroska"
elif full_path.endswith('.svg'): content_type = "image/svg+xml"
elif full_path.endswith('.png'): content_type = "image/png"
elif full_path.endswith('.jpg') or full_path.endswith('.jpeg'): content_type = "image/jpeg"
range_header = request.META.get('HTTP_RANGE', '').strip()
if range_header.startswith('bytes='):
range_match = re.match(r'bytes=(\d+)-(\d*)', range_header)
if range_match:
first_byte, last_byte = range_match.groups()
first_byte = int(first_byte)
last_byte = int(last_byte) if last_byte else size - 1
if last_byte >= size:
last_byte = size - 1
length = last_byte - first_byte + 1
def file_iterator(file_path, offset=0, bytes_to_read=None):
with open(file_path, 'rb') as f:
f.seek(offset)
remaining = bytes_to_read
while remaining > 0:
chunk_size = min(8192, remaining)
data = f.read(chunk_size)
if not data:
break
yield data
remaining -= len(data)
resp = StreamingHttpResponse(
file_iterator(full_path, offset=first_byte, bytes_to_read=length),
status=206,
content_type=content_type
)
resp['Content-Range'] = f'bytes {first_byte}-{last_byte}/{size}'
resp['Content-Length'] = str(length)
resp['Accept-Ranges'] = 'bytes'
return resp
# Fallback to standard 200 FileResponse if no range
resp = FileResponse(open(full_path, 'rb'), content_type=content_type)
resp['Content-Length'] = str(size)
resp['Accept-Ranges'] = 'bytes'
return resp