feat(main): commit
This commit is contained in:
@@ -6,8 +6,10 @@ from api.routers.library import router as library_router
|
||||
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
|
||||
|
||||
api.add_router("/library/", library_router)
|
||||
api.add_router("/channel/", channel_router)
|
||||
api.add_router("/schedule/", schedule_router)
|
||||
api.add_router("/user/", user_router)
|
||||
api.add_router("/sources/", sources_router)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from ninja import Router, Schema
|
||||
from typing import List, Optional
|
||||
from core.models import Channel, AppUser, Library
|
||||
from core.models import Channel, AppUser, Library, ChannelSourceRule, MediaSource, Airing
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
router = Router(tags=["channel"])
|
||||
|
||||
@@ -23,6 +25,66 @@ class ChannelCreateSchema(Schema):
|
||||
library_id: int
|
||||
owner_user_id: int # Mock Auth User
|
||||
|
||||
class ChannelUpdateSchema(Schema):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
channel_number: Optional[int] = None
|
||||
scheduling_mode: Optional[str] = None
|
||||
visibility: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class ChannelSourceRuleSchema(Schema):
|
||||
id: int
|
||||
source_id: int
|
||||
source_name: str
|
||||
rule_mode: str
|
||||
weight: float
|
||||
|
||||
class ChannelSourceAssignSchema(Schema):
|
||||
source_id: int
|
||||
rule_mode: str = 'allow' # allow | prefer | avoid | block
|
||||
weight: float = 1.0
|
||||
|
||||
class AiringSchema(Schema):
|
||||
id: int
|
||||
media_item_title: str
|
||||
media_item_path: Optional[str] = None
|
||||
starts_at: datetime
|
||||
ends_at: datetime
|
||||
slot_kind: str
|
||||
status: str
|
||||
|
||||
@staticmethod
|
||||
def from_airing(airing) -> 'AiringSchema':
|
||||
media_path = None
|
||||
if airing.media_item:
|
||||
raw_path = airing.media_item.cached_file_path or airing.media_item.file_path
|
||||
if raw_path:
|
||||
if raw_path.startswith("http://") or raw_path.startswith("https://"):
|
||||
media_path = raw_path
|
||||
else:
|
||||
from django.conf import settings
|
||||
import os
|
||||
try:
|
||||
rel_path = os.path.relpath(raw_path, settings.MEDIA_ROOT)
|
||||
if not rel_path.startswith("..") and not os.path.isabs(rel_path):
|
||||
base = settings.MEDIA_URL.rstrip('/')
|
||||
media_path = f"{base}/{rel_path}"
|
||||
else:
|
||||
media_path = raw_path
|
||||
except ValueError:
|
||||
media_path = raw_path
|
||||
|
||||
return AiringSchema(
|
||||
id=airing.id,
|
||||
media_item_title=airing.media_item.title if airing.media_item else 'Unknown',
|
||||
media_item_path=media_path,
|
||||
starts_at=airing.starts_at,
|
||||
ends_at=airing.ends_at,
|
||||
slot_kind=airing.slot_kind,
|
||||
status=airing.status,
|
||||
)
|
||||
|
||||
@router.get("/", response=List[ChannelSchema])
|
||||
def list_channels(request):
|
||||
return Channel.objects.all()
|
||||
@@ -45,3 +107,96 @@ def create_channel(request, payload: ChannelCreateSchema):
|
||||
description=payload.description
|
||||
)
|
||||
return 201, channel
|
||||
|
||||
@router.patch("/{channel_id}", response=ChannelSchema)
|
||||
def update_channel(request, channel_id: int, payload: ChannelUpdateSchema):
|
||||
channel = get_object_or_404(Channel, id=channel_id)
|
||||
for attr, value in payload.dict(exclude_unset=True).items():
|
||||
setattr(channel, attr, value)
|
||||
channel.save()
|
||||
return channel
|
||||
|
||||
@router.delete("/{channel_id}", response={204: None})
|
||||
def delete_channel(request, channel_id: int):
|
||||
channel = get_object_or_404(Channel, id=channel_id)
|
||||
channel.delete()
|
||||
return 204, None
|
||||
|
||||
@router.get("/{channel_id}/sources", response=List[ChannelSourceRuleSchema])
|
||||
def list_channel_sources(request, channel_id: int):
|
||||
channel = get_object_or_404(Channel, id=channel_id)
|
||||
rules = ChannelSourceRule.objects.filter(channel=channel, media_source__isnull=False).select_related('media_source')
|
||||
return [
|
||||
ChannelSourceRuleSchema(
|
||||
id=r.id,
|
||||
source_id=r.media_source.id,
|
||||
source_name=r.media_source.name,
|
||||
rule_mode=r.rule_mode,
|
||||
weight=float(r.weight),
|
||||
)
|
||||
for r in rules
|
||||
]
|
||||
|
||||
@router.post("/{channel_id}/sources", response={201: ChannelSourceRuleSchema})
|
||||
def assign_source_to_channel(request, channel_id: int, payload: ChannelSourceAssignSchema):
|
||||
channel = get_object_or_404(Channel, id=channel_id)
|
||||
source = get_object_or_404(MediaSource, id=payload.source_id)
|
||||
rule = ChannelSourceRule.objects.create(
|
||||
channel=channel,
|
||||
media_source=source,
|
||||
rule_mode=payload.rule_mode,
|
||||
weight=payload.weight,
|
||||
)
|
||||
return 201, ChannelSourceRuleSchema(
|
||||
id=rule.id,
|
||||
source_id=source.id,
|
||||
source_name=source.name,
|
||||
rule_mode=rule.rule_mode,
|
||||
weight=float(rule.weight),
|
||||
)
|
||||
|
||||
@router.delete("/{channel_id}/sources/{rule_id}", response={204: None})
|
||||
def remove_source_from_channel(request, channel_id: int, rule_id: int):
|
||||
rule = get_object_or_404(ChannelSourceRule, id=rule_id, channel_id=channel_id)
|
||||
rule.delete()
|
||||
return 204, None
|
||||
|
||||
@router.get("/{channel_id}/now", response=Optional[AiringSchema])
|
||||
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)
|
||||
# Using a 1-second buffer to handle boundary conditions smoothly
|
||||
now = timezone.now()
|
||||
airing = (
|
||||
Airing.objects
|
||||
.filter(channel=channel, starts_at__lte=now, ends_at__gt=now)
|
||||
.select_related('media_item')
|
||||
.first()
|
||||
)
|
||||
if airing is None:
|
||||
return None
|
||||
return AiringSchema.from_airing(airing)
|
||||
|
||||
@router.get("/{channel_id}/airings", response=List[AiringSchema])
|
||||
def channel_airings(request, channel_id: int, hours: int = 4):
|
||||
"""
|
||||
Return Airings for this channel that overlap with the window:
|
||||
[now - 2 hours, now + {hours} hours]
|
||||
"""
|
||||
channel = get_object_or_404(Channel, id=channel_id)
|
||||
now = timezone.now()
|
||||
window_start = now - timedelta(hours=2) # Look back 2h for context
|
||||
window_end = now + timedelta(hours=hours)
|
||||
|
||||
# Logic for overlap: starts_at < window_end AND ends_at > window_start
|
||||
airings = (
|
||||
Airing.objects
|
||||
.filter(
|
||||
channel=channel,
|
||||
starts_at__lt=window_end,
|
||||
ends_at__gt=window_start
|
||||
)
|
||||
.select_related('media_item')
|
||||
.order_by('starts_at')
|
||||
)
|
||||
return [AiringSchema.from_airing(a) for a in airings]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from ninja import Router, Schema
|
||||
from typing import List, Optional
|
||||
from core.models import ScheduleTemplate, Channel
|
||||
from core.models import ScheduleTemplate, Channel, ScheduleBlock
|
||||
from django.shortcuts import get_object_or_404
|
||||
from datetime import date
|
||||
|
||||
@@ -49,6 +49,19 @@ def create_schedule_template(request, payload: ScheduleTemplateCreateSchema):
|
||||
priority=payload.priority,
|
||||
is_active=payload.is_active
|
||||
)
|
||||
|
||||
# Create a default 24/7 programming block automatically to avoid
|
||||
# complex block management in the UI
|
||||
from datetime import time
|
||||
ScheduleBlock.objects.create(
|
||||
schedule_template=template,
|
||||
name="Default 24/7 Block",
|
||||
block_type=ScheduleBlock.BlockType.PROGRAMMING,
|
||||
start_local_time=time(0, 0, 0),
|
||||
end_local_time=time(23, 59, 59),
|
||||
day_of_week_mask=127,
|
||||
)
|
||||
|
||||
return 201, template
|
||||
|
||||
class GenerateScheduleSchema(Schema):
|
||||
@@ -63,3 +76,19 @@ def generate_schedule(request, channel_id: int, payload: GenerateScheduleSchema)
|
||||
generator = ScheduleGenerator(channel=channel)
|
||||
airings_created = generator.generate_for_date(payload.target_date)
|
||||
return {"status": "success", "airings_created": airings_created}
|
||||
|
||||
@router.delete("/template/{template_id}", response={204: None})
|
||||
def delete_schedule_template(request, template_id: int):
|
||||
template = get_object_or_404(ScheduleTemplate, id=template_id)
|
||||
template.delete()
|
||||
return 204, None
|
||||
|
||||
@router.post("/generate-today/{channel_id}")
|
||||
def generate_schedule_today(request, channel_id: int):
|
||||
"""Convenience endpoint: generates today's schedule for a channel."""
|
||||
from datetime import date
|
||||
from core.services.scheduler import ScheduleGenerator
|
||||
channel = get_object_or_404(Channel, id=channel_id)
|
||||
generator = ScheduleGenerator(channel=channel)
|
||||
airings_created = generator.generate_for_date(date.today())
|
||||
return {"status": "success", "airings_created": airings_created}
|
||||
|
||||
178
api/routers/sources.py
Normal file
178
api/routers/sources.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
API router for MediaSource management (with YouTube support).
|
||||
|
||||
Endpoints:
|
||||
GET /api/sources/ – list all sources
|
||||
POST /api/sources/ – create a new source
|
||||
POST /api/sources/cache-upcoming – download videos for upcoming airings
|
||||
GET /api/sources/download-status – snapshot of all YouTube item cache state
|
||||
GET /api/sources/{id} – retrieve one source
|
||||
DELETE /api/sources/{id} – delete a source
|
||||
POST /api/sources/{id}/sync – trigger yt-dlp metadata sync
|
||||
POST /api/sources/{id}/download – download a specific media item by ID
|
||||
|
||||
IMPORTANT: literal paths (/cache-upcoming, /download-status) MUST be declared
|
||||
before parameterised paths (/{source_id}) so Django Ninja's URL dispatcher
|
||||
matches them first.
|
||||
"""
|
||||
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
from ninja import Router
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.models import Library, MediaSource, MediaItem
|
||||
from core.services.youtube import sync_source, YOUTUBE_SOURCE_TYPES
|
||||
from core.services.cache import run_cache, get_download_status as _get_download_status
|
||||
|
||||
router = Router(tags=["sources"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class MediaSourceIn(BaseModel):
|
||||
library_id: int
|
||||
name: str
|
||||
source_type: str # one of MediaSource.SourceType string values
|
||||
uri: str
|
||||
is_active: bool = True
|
||||
scan_interval_minutes: Optional[int] = None
|
||||
|
||||
class MediaSourceOut(BaseModel):
|
||||
id: int
|
||||
library_id: int
|
||||
name: str
|
||||
source_type: str
|
||||
uri: str
|
||||
is_active: bool
|
||||
scan_interval_minutes: Optional[int]
|
||||
last_scanned_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class SyncResult(BaseModel):
|
||||
created: int
|
||||
updated: int
|
||||
skipped: int
|
||||
max_videos: Optional[int] = None
|
||||
|
||||
class CacheRunResult(BaseModel):
|
||||
pruned: int
|
||||
downloaded: int
|
||||
already_cached: int
|
||||
failed: int
|
||||
items: List[dict]
|
||||
|
||||
class DownloadStatusResult(BaseModel):
|
||||
total: int
|
||||
cached: int
|
||||
items: List[dict]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Collection endpoints (no path parameters — must come FIRST)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/", response=list[MediaSourceOut])
|
||||
def list_sources(request):
|
||||
"""List all media sources across all libraries."""
|
||||
return list(MediaSource.objects.select_related("library").all())
|
||||
|
||||
|
||||
@router.post("/", response={201: MediaSourceOut})
|
||||
def create_source(request, payload: MediaSourceIn):
|
||||
"""Register a new media source (including YouTube channel/playlist URLs)."""
|
||||
library = get_object_or_404(Library, id=payload.library_id)
|
||||
source = MediaSource.objects.create(
|
||||
library=library,
|
||||
name=payload.name,
|
||||
source_type=payload.source_type,
|
||||
uri=payload.uri,
|
||||
is_active=payload.is_active,
|
||||
scan_interval_minutes=payload.scan_interval_minutes,
|
||||
)
|
||||
return 201, source
|
||||
|
||||
|
||||
@router.post("/cache-upcoming", response=CacheRunResult)
|
||||
def trigger_cache_upcoming(request, hours: int = 24, prune_only: bool = False):
|
||||
"""
|
||||
Download YouTube videos for airings scheduled within the next `hours` hours.
|
||||
Equivalent to running: python manage.py cache_upcoming --hours N
|
||||
|
||||
Query params:
|
||||
hours – scan window in hours (default: 24)
|
||||
prune_only – if true, only delete stale cache files; skip downloads
|
||||
"""
|
||||
result = run_cache(hours=hours, prune_only=prune_only)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/download-status", response=DownloadStatusResult)
|
||||
def download_status(request):
|
||||
"""
|
||||
Return a snapshot of all YouTube-backed MediaItems with their local
|
||||
cache status (downloaded vs not downloaded).
|
||||
"""
|
||||
return _get_download_status()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single-item endpoints (path parameters — AFTER all literal paths)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/{source_id}", response=MediaSourceOut)
|
||||
def get_source(request, source_id: int):
|
||||
"""Retrieve a single media source by ID."""
|
||||
return get_object_or_404(MediaSource, id=source_id)
|
||||
|
||||
|
||||
@router.delete("/{source_id}", response={204: None})
|
||||
def delete_source(request, source_id: int):
|
||||
"""Delete a media source and all its associated media items."""
|
||||
source = get_object_or_404(MediaSource, id=source_id)
|
||||
source.delete()
|
||||
return 204, None
|
||||
|
||||
|
||||
@router.post("/{source_id}/sync", response=SyncResult)
|
||||
def trigger_sync(request, source_id: int, max_videos: Optional[int] = None):
|
||||
"""
|
||||
Trigger a yt-dlp metadata sync for a YouTube source.
|
||||
Phase 1 only — video METADATA is fetched and upserted as MediaItem rows.
|
||||
No video files are downloaded here.
|
||||
|
||||
Query params:
|
||||
max_videos – override the default cap (channel default: 50, playlist: 200)
|
||||
"""
|
||||
source = get_object_or_404(MediaSource, id=source_id)
|
||||
if source.source_type not in YOUTUBE_SOURCE_TYPES:
|
||||
from ninja.errors import HttpError
|
||||
raise HttpError(400, "Source is not a YouTube source type.")
|
||||
result = sync_source(source, max_videos=max_videos)
|
||||
return {**result, "max_videos": max_videos}
|
||||
|
||||
|
||||
@router.post("/{item_id}/download", response=dict)
|
||||
def download_item(request, item_id: int):
|
||||
"""
|
||||
Immediately download a single YouTube MediaItem to local cache.
|
||||
The item must already exist (synced) as a MediaItem in the database.
|
||||
"""
|
||||
from core.services.youtube import download_for_airing
|
||||
item = get_object_or_404(MediaItem, id=item_id)
|
||||
if not item.youtube_video_id:
|
||||
from ninja.errors import HttpError
|
||||
raise HttpError(400, "MediaItem is not a YouTube video.")
|
||||
try:
|
||||
path = download_for_airing(item)
|
||||
return {"status": "downloaded", "path": str(path)}
|
||||
except Exception as exc:
|
||||
from ninja.errors import HttpError
|
||||
raise HttpError(500, f"Download failed: {exc}")
|
||||
@@ -50,3 +50,9 @@ def update_user(request, user_id: int, payload: UserUpdateSchema):
|
||||
setattr(user, attr, value)
|
||||
user.save()
|
||||
return user
|
||||
|
||||
@router.delete("/{user_id}", response={204: None})
|
||||
def delete_user(request, user_id: int):
|
||||
user = get_object_or_404(AppUser, id=user_id)
|
||||
user.delete()
|
||||
return 204, None
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import pytest
|
||||
from django.test import Client
|
||||
from core.models import AppUser, Library, Channel
|
||||
from core.models import AppUser, Library, Channel, MediaSource, MediaItem, Airing
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
import uuid
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
@@ -63,5 +66,68 @@ def test_create_channel(client, user, library):
|
||||
assert data["slug"] == "new-api-ch"
|
||||
|
||||
# Verify it hit the DB
|
||||
assert Channel.objects.count() == 1
|
||||
assert Channel.objects.get(id=data["id"]).name == "New API Channel"
|
||||
|
||||
@pytest.fixture
|
||||
def media_source(db, library):
|
||||
return MediaSource.objects.create(
|
||||
library=library,
|
||||
name="Test Source",
|
||||
source_type="local_directory",
|
||||
uri="/mock/test"
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def media_item_youtube(db, media_source):
|
||||
return MediaItem.objects.create(
|
||||
media_source=media_source,
|
||||
title="YT Test Video",
|
||||
item_kind="video",
|
||||
runtime_seconds=600,
|
||||
file_path="https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_channel_now_playing_uncached_media_path(client, channel, media_item_youtube):
|
||||
now = timezone.now()
|
||||
Airing.objects.create(
|
||||
channel=channel,
|
||||
media_item=media_item_youtube,
|
||||
starts_at=now - timedelta(minutes=5),
|
||||
ends_at=now + timedelta(minutes=5),
|
||||
slot_kind="program",
|
||||
status="playing",
|
||||
generation_batch_uuid=uuid.uuid4()
|
||||
)
|
||||
|
||||
response = client.get(f"/api/channel/{channel.id}/now")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["media_item_title"] == "YT Test Video"
|
||||
# Should use the raw file_path since there is no cached_file_path
|
||||
assert data["media_item_path"] == "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_channel_now_playing_cached_media_path(client, channel, media_item_youtube):
|
||||
from django.conf import settings
|
||||
import os
|
||||
media_item_youtube.cached_file_path = os.path.join(settings.MEDIA_ROOT, "dQw4w9WgXcQ.mp4")
|
||||
media_item_youtube.save()
|
||||
|
||||
now = timezone.now()
|
||||
Airing.objects.create(
|
||||
channel=channel,
|
||||
media_item=media_item_youtube,
|
||||
starts_at=now - timedelta(minutes=5),
|
||||
ends_at=now + timedelta(minutes=5),
|
||||
slot_kind="program",
|
||||
status="playing",
|
||||
generation_batch_uuid=uuid.uuid4()
|
||||
)
|
||||
|
||||
response = client.get(f"/api/channel/{channel.id}/now")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["media_item_title"] == "YT Test Video"
|
||||
# Should resolve the cached_file_path to a web-accessible MEDIA_URL
|
||||
assert data["media_item_path"] == "/dQw4w9WgXcQ.mp4"
|
||||
|
||||
292
api/tests/test_sources.py
Normal file
292
api/tests/test_sources.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""
|
||||
Tests for the /api/sources/ router.
|
||||
|
||||
Covers:
|
||||
- Listing sources
|
||||
- Creating local and YouTube-type sources
|
||||
- Syncing a YouTube source (mocked yt-dlp call)
|
||||
- Deleting a source
|
||||
- Attempting to sync a non-YouTube source (should 400)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from django.test import Client
|
||||
from core.models import AppUser, Library, MediaSource, MediaItem
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return Client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(db):
|
||||
return AppUser.objects.create_user(
|
||||
username="srcuser", email="src@pytv.local", password="password"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def library(db, user):
|
||||
return Library.objects.create(
|
||||
owner_user=user,
|
||||
name="Source Test Library",
|
||||
visibility="public",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def local_source(db, library):
|
||||
return MediaSource.objects.create(
|
||||
library=library,
|
||||
name="Local Movies",
|
||||
source_type=MediaSource.SourceType.LOCAL_DIRECTORY,
|
||||
uri="/mnt/movies",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def youtube_source(db, library):
|
||||
return MediaSource.objects.create(
|
||||
library=library,
|
||||
name="ABC News",
|
||||
source_type=MediaSource.SourceType.YOUTUBE_CHANNEL,
|
||||
uri="https://www.youtube.com/@ABCNews",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Listing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_sources_empty(client):
|
||||
"""Listing when no sources exist returns an empty array."""
|
||||
response = client.get("/api/sources/")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_sources_returns_all(client, local_source, youtube_source):
|
||||
response = client.get("/api/sources/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 2
|
||||
names = {s["name"] for s in data}
|
||||
assert names == {"Local Movies", "ABC News"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Creation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_local_source(client, library):
|
||||
payload = {
|
||||
"library_id": library.id,
|
||||
"name": "My Movies",
|
||||
"source_type": "local_directory",
|
||||
"uri": "/mnt/media/movies",
|
||||
"is_active": True,
|
||||
}
|
||||
response = client.post(
|
||||
"/api/sources/", data=payload, content_type="application/json"
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["name"] == "My Movies"
|
||||
assert data["source_type"] == "local_directory"
|
||||
assert MediaSource.objects.filter(id=data["id"]).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_youtube_channel_source(client, library):
|
||||
"""Registering a YouTube channel URL as a media source."""
|
||||
payload = {
|
||||
"library_id": library.id,
|
||||
"name": "ABC News",
|
||||
"source_type": "youtube_channel",
|
||||
"uri": "https://www.youtube.com/@ABCNews",
|
||||
"is_active": True,
|
||||
}
|
||||
response = client.post(
|
||||
"/api/sources/", data=payload, content_type="application/json"
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["source_type"] == "youtube_channel"
|
||||
assert "@ABCNews" in data["uri"]
|
||||
|
||||
# Verify it's in the DB with correct type
|
||||
src = MediaSource.objects.get(id=data["id"])
|
||||
assert src.source_type == MediaSource.SourceType.YOUTUBE_CHANNEL
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_youtube_playlist_source(client, library):
|
||||
"""Registering a YouTube playlist URL as a media source."""
|
||||
payload = {
|
||||
"library_id": library.id,
|
||||
"name": "Tech Talks",
|
||||
"source_type": "youtube_playlist",
|
||||
"uri": "https://www.youtube.com/playlist?list=PLFgquLnL59akA2PflFpeQG9L01VFg90wS",
|
||||
"is_active": True,
|
||||
}
|
||||
response = client.post(
|
||||
"/api/sources/", data=payload, content_type="application/json"
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["source_type"] == "youtube_playlist"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_source_invalid_library(client):
|
||||
"""Creating a source with a non-existent library should 404."""
|
||||
payload = {
|
||||
"library_id": 99999,
|
||||
"name": "Orphan",
|
||||
"source_type": "local_directory",
|
||||
"uri": "/nope",
|
||||
}
|
||||
response = client.post(
|
||||
"/api/sources/", data=payload, content_type="application/json"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Retrieval
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_source(client, youtube_source):
|
||||
response = client.get(f"/api/sources/{youtube_source.id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == youtube_source.id
|
||||
assert data["source_type"] == "youtube_channel"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_source_not_found(client):
|
||||
response = client.get("/api/sources/99999")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deletion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_source(client, local_source):
|
||||
source_id = local_source.id
|
||||
response = client.delete(f"/api/sources/{source_id}")
|
||||
assert response.status_code == 204
|
||||
assert not MediaSource.objects.filter(id=source_id).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_source_not_found(client):
|
||||
response = client.delete("/api/sources/99999")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync (mocked yt-dlp)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MOCK_YT_ENTRIES = [
|
||||
{
|
||||
"id": "abc123",
|
||||
"title": "Breaking News: Test Story",
|
||||
"duration": 180,
|
||||
"thumbnail": "https://i.ytimg.com/vi/abc123/hqdefault.jpg",
|
||||
"description": "A breaking news segment.",
|
||||
"upload_date": "20240315",
|
||||
"url": "https://www.youtube.com/watch?v=abc123",
|
||||
"uploader": "ABC News",
|
||||
},
|
||||
{
|
||||
"id": "def456",
|
||||
"title": "Weather Report: Sunny with a chance of clouds",
|
||||
"duration": 90,
|
||||
"thumbnail": "https://i.ytimg.com/vi/def456/hqdefault.jpg",
|
||||
"description": "Evening weather.",
|
||||
"upload_date": "20240316",
|
||||
"url": "https://www.youtube.com/watch?v=def456",
|
||||
"uploader": "ABC News",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sync_youtube_channel_creates_media_items(client, youtube_source):
|
||||
"""
|
||||
Syncing a YouTube channel should call yt-dlp (mocked) and upsert
|
||||
MediaItem rows for each discovered video.
|
||||
"""
|
||||
with patch("core.services.youtube._extract_playlist_info", return_value=MOCK_YT_ENTRIES):
|
||||
response = client.post(f"/api/sources/{youtube_source.id}/sync")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Two new videos → created=2
|
||||
assert data["created"] == 2
|
||||
assert data["updated"] == 0
|
||||
assert data["skipped"] == 0
|
||||
|
||||
# MediaItems should now exist in the DB
|
||||
items = MediaItem.objects.filter(media_source=youtube_source)
|
||||
assert items.count() == 2
|
||||
|
||||
titles = {i.title for i in items}
|
||||
assert "Breaking News: Test Story" in titles
|
||||
assert "Weather Report: Sunny with a chance of clouds" in titles
|
||||
|
||||
# Verify youtube_video_id is populated
|
||||
item = items.get(youtube_video_id="abc123")
|
||||
assert item.runtime_seconds == 180
|
||||
assert item.release_year == 2024
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sync_youtube_channel_updates_existing(client, youtube_source):
|
||||
"""Re-syncing the same source updates existing rows rather than duplicating."""
|
||||
with patch("core.services.youtube._extract_playlist_info", return_value=MOCK_YT_ENTRIES):
|
||||
client.post(f"/api/sources/{youtube_source.id}/sync")
|
||||
|
||||
# Second sync — same entries, should update not create
|
||||
with patch(
|
||||
"core.services.youtube._extract_playlist_info",
|
||||
return_value=[{**MOCK_YT_ENTRIES[0], "title": "Updated Title"}],
|
||||
):
|
||||
response = client.post(f"/api/sources/{youtube_source.id}/sync")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["created"] == 0
|
||||
assert data["updated"] == 1
|
||||
|
||||
item = MediaItem.objects.get(youtube_video_id="abc123")
|
||||
assert item.title == "Updated Title"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sync_non_youtube_source_returns_400(client, local_source):
|
||||
"""Syncing a non-YouTube source type should return HTTP 400."""
|
||||
response = client.post(f"/api/sources/{local_source.id}/sync")
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sync_source_not_found(client):
|
||||
response = client.post("/api/sources/99999/sync")
|
||||
assert response.status_code == 404
|
||||
Reference in New Issue
Block a user