feat(main): main
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -8,3 +8,7 @@ wheels/
|
|||||||
|
|
||||||
# Virtual environments
|
# Virtual environments
|
||||||
.venv
|
.venv
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
mock/
|
||||||
|
cache/
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
FROM python:3.12-slim-bookworm
|
FROM python:3.12-slim-bookworm
|
||||||
|
|
||||||
# System dependencies for psycopg/postgres and general utilities
|
# System dependencies for psycopg/postgres and general utilities
|
||||||
|
# System dependencies for psycopg/postgres, nodejs, and general utilities
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
gcc \
|
gcc \
|
||||||
curl \
|
curl \
|
||||||
|
gnupg \
|
||||||
|
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||||
|
&& apt-get install -y nodejs \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install uv globally
|
# Install uv globally
|
||||||
@@ -19,6 +23,9 @@ COPY . /app
|
|||||||
# --system ensures it installs into the global python environment rather than venv
|
# --system ensures it installs into the global python environment rather than venv
|
||||||
RUN uv pip install --system django psycopg django-environ gunicorn django-ninja django-cors-headers yt-dlp
|
RUN uv pip install --system django psycopg django-environ gunicorn django-ninja django-cors-headers yt-dlp
|
||||||
|
|
||||||
|
# Install npm dependencies
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
# Expose Django default port
|
# Expose Django default port
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ from ninja import NinjaAPI
|
|||||||
|
|
||||||
api = NinjaAPI(title="PYTV API")
|
api = NinjaAPI(title="PYTV API")
|
||||||
|
|
||||||
from api.routers.library import router as library_router
|
from api.routers.library import router as library_router, collections_router
|
||||||
from api.routers.channel import router as channel_router
|
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
|
||||||
|
|
||||||
api.add_router("/library/", library_router)
|
api.add_router("/library/", library_router)
|
||||||
|
api.add_router("/collections/", collections_router)
|
||||||
api.add_router("/channel/", channel_router)
|
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)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class ChannelSchema(Schema):
|
|||||||
scheduling_mode: str
|
scheduling_mode: str
|
||||||
library_id: int
|
library_id: int
|
||||||
owner_user_id: int
|
owner_user_id: int
|
||||||
|
fallback_collection_id: Optional[int] = None
|
||||||
|
|
||||||
class ChannelCreateSchema(Schema):
|
class ChannelCreateSchema(Schema):
|
||||||
name: str
|
name: str
|
||||||
@@ -24,6 +25,7 @@ class ChannelCreateSchema(Schema):
|
|||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
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
|
||||||
|
|
||||||
class ChannelUpdateSchema(Schema):
|
class ChannelUpdateSchema(Schema):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
@@ -32,6 +34,7 @@ class ChannelUpdateSchema(Schema):
|
|||||||
scheduling_mode: Optional[str] = None
|
scheduling_mode: Optional[str] = None
|
||||||
visibility: Optional[str] = None
|
visibility: Optional[str] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
|
fallback_collection_id: Optional[int] = None
|
||||||
|
|
||||||
class ChannelSourceRuleSchema(Schema):
|
class ChannelSourceRuleSchema(Schema):
|
||||||
id: int
|
id: int
|
||||||
@@ -103,7 +106,11 @@ class AiringSchema(Schema):
|
|||||||
|
|
||||||
@router.get("/", response=List[ChannelSchema])
|
@router.get("/", response=List[ChannelSchema])
|
||||||
def list_channels(request):
|
def list_channels(request):
|
||||||
return Channel.objects.all()
|
from django.db.models import F
|
||||||
|
return Channel.objects.order_by(
|
||||||
|
F('channel_number').asc(nulls_last=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{channel_id}", response=ChannelSchema)
|
@router.get("/{channel_id}", response=ChannelSchema)
|
||||||
def get_channel(request, channel_id: int):
|
def get_channel(request, channel_id: int):
|
||||||
@@ -120,7 +127,8 @@ def create_channel(request, payload: ChannelCreateSchema):
|
|||||||
name=payload.name,
|
name=payload.name,
|
||||||
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
|
||||||
)
|
)
|
||||||
return 201, channel
|
return 201, channel
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from ninja import Router, Schema
|
from ninja import Router, Schema
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from core.models import Library, AppUser
|
from core.models import Library, AppUser, MediaCollection
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
router = Router(tags=["library"])
|
router = Router(tags=["library"])
|
||||||
|
collections_router = Router(tags=["collections"])
|
||||||
|
|
||||||
class LibrarySchema(Schema):
|
class LibrarySchema(Schema):
|
||||||
id: int
|
id: int
|
||||||
@@ -18,6 +19,12 @@ class LibraryCreateSchema(Schema):
|
|||||||
visibility: Optional[str] = 'private'
|
visibility: Optional[str] = 'private'
|
||||||
owner_user_id: int # In a real app with auth, this would come from request.user
|
owner_user_id: int # In a real app with auth, this would come from request.user
|
||||||
|
|
||||||
|
class MediaCollectionSchema(Schema):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
library_id: int
|
||||||
|
collection_type: str
|
||||||
|
|
||||||
@router.get("/", response=List[LibrarySchema])
|
@router.get("/", response=List[LibrarySchema])
|
||||||
def list_libraries(request):
|
def list_libraries(request):
|
||||||
return Library.objects.all()
|
return Library.objects.all()
|
||||||
@@ -36,3 +43,8 @@ def create_library(request, payload: LibraryCreateSchema):
|
|||||||
visibility=payload.visibility
|
visibility=payload.visibility
|
||||||
)
|
)
|
||||||
return 201, library
|
return 201, library
|
||||||
|
|
||||||
|
@collections_router.get("/", response=List[MediaCollectionSchema])
|
||||||
|
def list_collections(request):
|
||||||
|
"""List all MediaCollections across all libraries."""
|
||||||
|
return MediaCollection.objects.select_related('library').all()
|
||||||
|
|||||||
@@ -176,3 +176,21 @@ def download_item(request, item_id: int):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
from ninja.errors import HttpError
|
from ninja.errors import HttpError
|
||||||
raise HttpError(500, f"Download failed: {exc}")
|
raise HttpError(500, f"Download failed: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{item_id}/progress", response=dict)
|
||||||
|
def download_progress(request, item_id: int):
|
||||||
|
"""
|
||||||
|
Return the current download progress string (e.g. '45.1%') from the yt-dlp hook.
|
||||||
|
"""
|
||||||
|
from django.core.cache import cache
|
||||||
|
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.")
|
||||||
|
|
||||||
|
if item.cached_file_path:
|
||||||
|
return {"status": "finished", "progress": "100%"}
|
||||||
|
|
||||||
|
pct = cache.get(f"yt_progress_{item.youtube_video_id}")
|
||||||
|
return {"status": "downloading" if pct else "unknown", "progress": pct or "0%"}
|
||||||
|
|||||||
1
assets/TestCard.svg
Normal file
1
assets/TestCard.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="672" height="504"><path fill="#131313" d="M0 0h672v504H0z"/><path fill="silver" d="M0 0h96v336H0z"/><path fill="#c0c000" d="M96 0v336h96V0z"/><path fill="#00c0c0" d="M192 0v336h96V0z"/><path fill="#00c000" d="M288 0v336h96V0z"/><path fill="#c000c0" d="M384 0v336h96V0z"/><path fill="#c00000" d="M480 0v336h96V0z"/><path fill="#0000c0" d="M576 0v336h96V0zM0 336h96v42H0z"/><path fill="#c000c0" d="M192 336h96v42H192z"/><path fill="#00c0c0" d="M384 336h96v42H384z"/><path fill="silver" d="M576 336h96v42H576z"/><path fill="#00214c" d="M0 378h120v126H0z"/><path fill="#fff" d="M120 378h120v126H120z"/><path fill="#32006a" d="M240 378h120v126H240z"/><path fill="#090909" d="M480 378h32v126H480z"/><path fill="#1d1d1d" d="M544 378h32v126H544z"/></svg>
|
||||||
|
After Width: | Height: | Size: 793 B |
25
core/migrations/0004_channel_fallback_collection.py
Normal file
25
core/migrations/0004_channel_fallback_collection.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 6.0.3 on 2026-03-09 12:36
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("core", "0003_channelsourcerule_schedule_block_label_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="channel",
|
||||||
|
name="fallback_collection",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="fallback_for_channels",
|
||||||
|
to="core.mediacollection",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -203,6 +203,7 @@ class Channel(models.Model):
|
|||||||
owner_user = models.ForeignKey(AppUser, on_delete=models.CASCADE)
|
owner_user = models.ForeignKey(AppUser, on_delete=models.CASCADE)
|
||||||
library = models.ForeignKey(Library, on_delete=models.CASCADE)
|
library = models.ForeignKey(Library, on_delete=models.CASCADE)
|
||||||
default_genre = models.ForeignKey(Genre, on_delete=models.SET_NULL, blank=True, null=True)
|
default_genre = models.ForeignKey(Genre, on_delete=models.SET_NULL, blank=True, null=True)
|
||||||
|
fallback_collection = models.ForeignKey('MediaCollection', on_delete=models.SET_NULL, blank=True, null=True, related_name='fallback_for_channels')
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
slug = models.SlugField(max_length=64, unique=True)
|
slug = models.SlugField(max_length=64, unique=True)
|
||||||
channel_number = models.IntegerField(blank=True, null=True)
|
channel_number = models.IntegerField(blank=True, null=True)
|
||||||
|
|||||||
@@ -41,10 +41,18 @@ class ScheduleGenerator:
|
|||||||
Idempotent generation of airings for `target_date`.
|
Idempotent generation of airings for `target_date`.
|
||||||
Returns the number of new Airing rows created.
|
Returns the number of new Airing rows created.
|
||||||
"""
|
"""
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
template = self._get_template()
|
template = self._get_template()
|
||||||
if not template:
|
if not template:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
# Resolve the template's local timezone (fall back to UTC)
|
||||||
|
try:
|
||||||
|
local_tz = ZoneInfo(template.timezone_name or 'UTC')
|
||||||
|
except (ZoneInfoNotFoundError, Exception):
|
||||||
|
local_tz = ZoneInfo('UTC')
|
||||||
|
|
||||||
target_weekday_bit = 1 << target_date.weekday()
|
target_weekday_bit = 1 << target_date.weekday()
|
||||||
blocks = template.scheduleblock_set.all().order_by('start_local_time')
|
blocks = template.scheduleblock_set.all().order_by('start_local_time')
|
||||||
airings_created = 0
|
airings_created = 0
|
||||||
@@ -53,10 +61,14 @@ class ScheduleGenerator:
|
|||||||
if not (block.day_of_week_mask & target_weekday_bit):
|
if not (block.day_of_week_mask & target_weekday_bit):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
start_dt = datetime.combine(target_date, block.start_local_time, tzinfo=timezone.utc)
|
# Convert local block times to UTC-aware datetimes
|
||||||
end_dt = datetime.combine(target_date, block.end_local_time, tzinfo=timezone.utc)
|
start_local = datetime.combine(target_date, block.start_local_time, tzinfo=local_tz)
|
||||||
|
end_local = datetime.combine(target_date, block.end_local_time, tzinfo=local_tz)
|
||||||
|
|
||||||
# Midnight-wrap support (e.g. 23:00–02:00)
|
start_dt = start_local.astimezone(timezone.utc)
|
||||||
|
end_dt = end_local.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
# Midnight-wrap support (e.g. 23:00–02:00 local)
|
||||||
if end_dt <= start_dt:
|
if end_dt <= start_dt:
|
||||||
end_dt += timedelta(days=1)
|
end_dt += timedelta(days=1)
|
||||||
|
|
||||||
@@ -81,12 +93,18 @@ class ScheduleGenerator:
|
|||||||
if latest_prior_airing and latest_prior_airing.ends_at > start_dt:
|
if latest_prior_airing and latest_prior_airing.ends_at > start_dt:
|
||||||
actual_start_dt = latest_prior_airing.ends_at
|
actual_start_dt = latest_prior_airing.ends_at
|
||||||
|
|
||||||
|
# If the prior block ran all the way through this block's window, skip
|
||||||
|
if actual_start_dt >= end_dt:
|
||||||
|
continue
|
||||||
|
|
||||||
airings_created += self._fill_block(
|
airings_created += self._fill_block(
|
||||||
template, block, actual_start_dt, end_dt, available_items
|
template, block, actual_start_dt, end_dt, available_items
|
||||||
)
|
)
|
||||||
|
|
||||||
return airings_created
|
return airings_created
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -248,13 +266,21 @@ class ScheduleGenerator:
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
for original_airing in airings:
|
for original_airing in airings:
|
||||||
# 1. Fetch available downloaded items for this block
|
# 1. First check if the channel has a dedicated error fallback collection
|
||||||
safe_items = self._get_weighted_items(original_airing.schedule_block, require_downloaded=True)
|
safe_items = []
|
||||||
if not safe_items:
|
if getattr(self.channel, 'fallback_collection', None):
|
||||||
logger.error(f"Cannot replace airing {original_airing.id}: No downloaded items available for block {original_airing.schedule_block.name}")
|
safe_items = list(self.channel.fallback_collection.media_items.exclude(
|
||||||
continue
|
cached_file_path__isnull=True,
|
||||||
|
media_source__source_type__in=['youtube', 'youtube_channel', 'youtube_playlist']
|
||||||
|
))
|
||||||
|
|
||||||
|
# 2. If no fallback collection or it yielded no valid items, try block sources
|
||||||
|
if not safe_items:
|
||||||
|
safe_items = self._get_weighted_items(original_airing.schedule_block, require_downloaded=True)
|
||||||
|
|
||||||
|
if not safe_items:
|
||||||
|
logger.error(f"Cannot replace airing {original_airing.id}: No downloaded items available for fallback or block {original_airing.schedule_block.name}")
|
||||||
|
continue
|
||||||
# 2. Pick a random valid fallback item
|
# 2. Pick a random valid fallback item
|
||||||
fallback_item = random.choice(safe_items)
|
fallback_item = random.choice(safe_items)
|
||||||
old_duration = original_airing.ends_at - original_airing.starts_at
|
old_duration = original_airing.ends_at - original_airing.starts_at
|
||||||
|
|||||||
@@ -207,6 +207,21 @@ def download_for_airing(media_item: MediaItem) -> Path:
|
|||||||
logger.info("cache hit: %s already at %s", video_id, existing)
|
logger.info("cache hit: %s already at %s", video_id, existing)
|
||||||
return existing
|
return existing
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
def progress_hook(d):
|
||||||
|
if d['status'] == 'downloading':
|
||||||
|
# Note: _percent_str includes ANSI escape codes sometimes, but yt_dlp usually cleans it if not a tty
|
||||||
|
pct = d.get('_percent_str', '').strip()
|
||||||
|
# Clean ANSI just in case
|
||||||
|
import re
|
||||||
|
pct_clean = re.sub(r'\x1b\[[0-9;]*m', '', pct).strip()
|
||||||
|
if pct_clean:
|
||||||
|
# Store the string "xx.x%" into Django cache. Expire after 5 mins so it cleans itself up.
|
||||||
|
cache.set(f"yt_progress_{video_id}", pct_clean, timeout=300)
|
||||||
|
elif d['status'] == 'finished':
|
||||||
|
cache.set(f"yt_progress_{video_id}", "100%", timeout=300)
|
||||||
|
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
"quiet": True,
|
"quiet": True,
|
||||||
"no_warnings": True,
|
"no_warnings": True,
|
||||||
@@ -218,6 +233,7 @@ def download_for_airing(media_item: MediaItem) -> Path:
|
|||||||
# 3. Any pre-muxed webm
|
# 3. Any pre-muxed webm
|
||||||
# 4. Anything pre-muxed (no merger needed)
|
# 4. Anything pre-muxed (no merger needed)
|
||||||
"format": "best[ext=mp4][height<=1080]/best[ext=mp4]/best[ext=webm]/best",
|
"format": "best[ext=mp4][height<=1080]/best[ext=mp4]/best[ext=webm]/best",
|
||||||
|
"progress_hooks": [progress_hook],
|
||||||
}
|
}
|
||||||
|
|
||||||
url = media_item.file_path # URL stored here by sync_source
|
url = media_item.file_path # URL stored here by sync_source
|
||||||
|
|||||||
BIN
db.sqlite3
BIN
db.sqlite3
Binary file not shown.
@@ -17,6 +17,8 @@ services:
|
|||||||
command: python manage.py runserver 0.0.0.0:8000
|
command: python manage.py runserver 0.0.0.0:8000
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
|
- ./mock:/mock
|
||||||
|
- ./cache:/tmp/pytv_cache
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
environment:
|
environment:
|
||||||
@@ -26,5 +28,19 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
|
||||||
|
cache_worker:
|
||||||
|
build: .
|
||||||
|
command: python manage.py run_cache_worker
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- ./mock:/mock
|
||||||
|
- ./cache:/tmp/pytv_cache
|
||||||
|
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
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
1
frontend/public/testcard.svg
Normal file
1
frontend/public/testcard.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="672" height="504"><path fill="#131313" d="M0 0h672v504H0z"/><path fill="silver" d="M0 0h96v336H0z"/><path fill="#c0c000" d="M96 0v336h96V0z"/><path fill="#00c0c0" d="M192 0v336h96V0z"/><path fill="#00c000" d="M288 0v336h96V0z"/><path fill="#c000c0" d="M384 0v336h96V0z"/><path fill="#c00000" d="M480 0v336h96V0z"/><path fill="#0000c0" d="M576 0v336h96V0zM0 336h96v42H0z"/><path fill="#c000c0" d="M192 336h96v42H192z"/><path fill="#00c0c0" d="M384 336h96v42H384z"/><path fill="silver" d="M576 336h96v42H576z"/><path fill="#00214c" d="M0 378h120v126H0z"/><path fill="#fff" d="M120 378h120v126H120z"/><path fill="#32006a" d="M240 378h120v126H240z"/><path fill="#090909" d="M480 378h32v126H480z"/><path fill="#1d1d1d" d="M544 378h32v126H544z"/></svg>
|
||||||
|
After Width: | Height: | Size: 793 B |
@@ -59,9 +59,11 @@ export const fetchDownloadStatus = async () => (await apiClient.get('/sources/do
|
|||||||
export const triggerCacheUpcoming = async (hours = 24, pruneOnly = false) =>
|
export const triggerCacheUpcoming = async (hours = 24, pruneOnly = false) =>
|
||||||
(await apiClient.post(`/sources/cache-upcoming?hours=${hours}&prune_only=${pruneOnly}`)).data;
|
(await apiClient.post(`/sources/cache-upcoming?hours=${hours}&prune_only=${pruneOnly}`)).data;
|
||||||
export const downloadItem = async (itemId) => (await apiClient.post(`/sources/${itemId}/download`)).data;
|
export const downloadItem = async (itemId) => (await apiClient.post(`/sources/${itemId}/download`)).data;
|
||||||
|
export const fetchDownloadProgress = async (itemId) => (await apiClient.get(`/sources/${itemId}/progress`)).data;
|
||||||
|
|
||||||
// ── Libraries ─────────────────────────────────────────────────────────────
|
// ── Libraries ─────────────────────────────────────────────────────────────
|
||||||
export const fetchLibraries = async () => (await apiClient.get('/library/')).data;
|
export const fetchLibraries = async () => (await apiClient.get('/library/')).data;
|
||||||
|
export const fetchCollections = async () => (await apiClient.get('/collections/')).data;
|
||||||
|
|
||||||
// ── Users ─────────────────────────────────────────────────────────────────
|
// ── Users ─────────────────────────────────────────────────────────────────
|
||||||
export const fetchUsers = async () => (await apiClient.get('/user/')).data;
|
export const fetchUsers = async () => (await apiClient.get('/user/')).data;
|
||||||
|
|||||||
@@ -2,11 +2,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|||||||
import { useRemoteControl } from '../hooks/useRemoteControl';
|
import { useRemoteControl } from '../hooks/useRemoteControl';
|
||||||
import { fetchChannels, fetchChannelNow } from '../api';
|
import { fetchChannels, fetchChannelNow } from '../api';
|
||||||
|
|
||||||
const FALLBACK_VIDEOS = [
|
const TEST_CARD_SRC = '/testcard.svg';
|
||||||
'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
|
|
||||||
'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/TearsOfSteel.mp4',
|
|
||||||
'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4'
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ChannelTuner({ onOpenGuide }) {
|
export default function ChannelTuner({ onOpenGuide }) {
|
||||||
const [channels, setChannels] = useState([]);
|
const [channels, setChannels] = useState([]);
|
||||||
@@ -17,7 +13,6 @@ export default function ChannelTuner({ onOpenGuide }) {
|
|||||||
const [nowPlaying, setNowPlaying] = useState({}); // { channelId: airingData }
|
const [nowPlaying, setNowPlaying] = useState({}); // { channelId: airingData }
|
||||||
const osdTimerRef = useRef(null);
|
const osdTimerRef = useRef(null);
|
||||||
|
|
||||||
// The 3 buffer indices
|
|
||||||
const getPrevIndex = (index) => (index - 1 + channels.length) % channels.length;
|
const getPrevIndex = (index) => (index - 1 + channels.length) % channels.length;
|
||||||
const getNextIndex = (index) => (index + 1) % channels.length;
|
const getNextIndex = (index) => (index + 1) % channels.length;
|
||||||
|
|
||||||
@@ -30,107 +25,114 @@ export default function ChannelTuner({ onOpenGuide }) {
|
|||||||
osdTimerRef.current = setTimeout(() => setShowOSD(false), 5000);
|
osdTimerRef.current = setTimeout(() => setShowOSD(false), 5000);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const wrapChannelUp = () => {
|
const wrapChannelUp = () => { setCurrentIndex(getNextIndex); triggerOSD(); };
|
||||||
setCurrentIndex(getNextIndex);
|
const wrapChannelDown = () => { setCurrentIndex(getPrevIndex); triggerOSD(); };
|
||||||
triggerOSD();
|
|
||||||
};
|
|
||||||
|
|
||||||
const wrapChannelDown = () => {
|
|
||||||
setCurrentIndex(getPrevIndex);
|
|
||||||
triggerOSD();
|
|
||||||
};
|
|
||||||
|
|
||||||
useRemoteControl({
|
useRemoteControl({
|
||||||
onChannelUp: wrapChannelUp,
|
onChannelUp: wrapChannelUp,
|
||||||
onChannelDown: wrapChannelDown,
|
onChannelDown: wrapChannelDown,
|
||||||
onSelect: triggerOSD,
|
onSelect: triggerOSD,
|
||||||
onBack: onOpenGuide
|
onBack: onOpenGuide,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Debug Info Toggle
|
// Debug toggle (i key)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e) => {
|
||||||
// Ignore if user is typing in an input
|
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
|
||||||
if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') return;
|
if (e.key === 'i' || e.key === 'I') setShowDebug(prev => !prev);
|
||||||
if (e.key === 'i' || e.key === 'I') {
|
|
||||||
setShowDebug(prev => !prev);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Fetch channels from Django API
|
// Fetch channels — always sorted by channel_number (API guarantees this; sort defensively here too)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchChannels().then(data => {
|
fetchChannels()
|
||||||
const mapped = data.map((ch, idx) => ({
|
.then(data => {
|
||||||
...ch,
|
const sorted = [...data].sort((a, b) => {
|
||||||
fallbackFile: FALLBACK_VIDEOS[idx % FALLBACK_VIDEOS.length]
|
if (a.channel_number == null) return 1;
|
||||||
}));
|
if (b.channel_number == null) return -1;
|
||||||
if (mapped.length === 0) {
|
return a.channel_number - b.channel_number;
|
||||||
mapped.push({ id: 99, channel_number: '99', name: 'Default Feed', fallbackFile: FALLBACK_VIDEOS[0] });
|
});
|
||||||
}
|
setChannels(sorted.length > 0
|
||||||
setChannels(mapped);
|
? sorted
|
||||||
setLoading(false);
|
: [{ id: 99, channel_number: 99, name: 'Default Feed' }]
|
||||||
}).catch(err => {
|
);
|
||||||
console.error(err);
|
setLoading(false);
|
||||||
setChannels([{ id: 99, channel_number: '99', name: 'Offline', fallbackFile: FALLBACK_VIDEOS[0] }]);
|
})
|
||||||
setLoading(false);
|
.catch(err => {
|
||||||
});
|
console.error(err);
|
||||||
|
setChannels([{ id: 99, channel_number: 99, name: 'Offline' }]);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Fetch "Now Playing" metadata for the current channel group whenever currentIndex changes
|
// Fetch "Now Playing" for active triple-buffer
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (channels.length === 0) return;
|
if (channels.length === 0) return;
|
||||||
|
[currentIndex, prevIndex, nextIndex].forEach(idx => {
|
||||||
const activeIndices = [currentIndex, prevIndex, nextIndex];
|
|
||||||
activeIndices.forEach(idx => {
|
|
||||||
const chan = channels[idx];
|
const chan = channels[idx];
|
||||||
fetchChannelNow(chan.id).then(data => {
|
if (!chan) return;
|
||||||
setNowPlaying(prev => ({ ...prev, [chan.id]: data }));
|
fetchChannelNow(chan.id)
|
||||||
}).catch(() => {
|
.then(data => setNowPlaying(prev => ({ ...prev, [chan.id]: data })))
|
||||||
setNowPlaying(prev => ({ ...prev, [chan.id]: null }));
|
.catch(() => setNowPlaying(prev => ({ ...prev, [chan.id]: null })));
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}, [currentIndex, channels, prevIndex, nextIndex]);
|
}, [currentIndex, channels, prevIndex, nextIndex]);
|
||||||
|
|
||||||
// Initial OSD hide
|
// Initial OSD
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading) triggerOSD();
|
if (!loading) triggerOSD();
|
||||||
return () => clearTimeout(osdTimerRef.current);
|
return () => clearTimeout(osdTimerRef.current);
|
||||||
}, [loading, triggerOSD]);
|
}, [loading, triggerOSD]);
|
||||||
|
|
||||||
if (loading || channels.length === 0) {
|
if (loading || channels.length === 0) {
|
||||||
return <div style={{position: 'absolute', top: '50%', left: '50%', color: 'white'}}>Connecting to PYTV Backend...</div>;
|
return (
|
||||||
|
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', background: '#000', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<img src={TEST_CARD_SRC} alt="Test Card" style={{ maxHeight: '100%', maxWidth: '100%', objectFit: 'contain' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentChan = channels[currentIndex];
|
const currentChan = channels[currentIndex];
|
||||||
const airing = nowPlaying[currentChan.id];
|
const airing = nowPlaying[currentChan.id];
|
||||||
|
|
||||||
|
/** Build the video source for a channel, or null if nothing playable. */
|
||||||
|
function getVideoSrc(chan) {
|
||||||
|
const currentAiring = nowPlaying[chan.id];
|
||||||
|
if (!currentAiring || !currentAiring.media_item_path) return null;
|
||||||
|
const path = currentAiring.media_item_path;
|
||||||
|
if (path.startsWith('http')) return path;
|
||||||
|
const clean = path.replace(/^\/?(?:media)?\/*/,'');
|
||||||
|
return `/media/${clean}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tuner-container">
|
<div className="tuner-container">
|
||||||
{channels.map((chan, index) => {
|
{channels.map((chan, index) => {
|
||||||
const isCurrent = index === currentIndex;
|
const isCurrent = index === currentIndex;
|
||||||
const isPrev = index === prevIndex;
|
const isPrev = index === prevIndex;
|
||||||
const isNext = index === nextIndex;
|
const isNext = index === nextIndex;
|
||||||
|
|
||||||
if (!isCurrent && !isPrev && !isNext) return null;
|
if (!isCurrent && !isPrev && !isNext) return null;
|
||||||
|
|
||||||
let stateClass = 'buffering';
|
const stateClass = isCurrent ? 'playing' : 'buffering';
|
||||||
if (isCurrent) stateClass = 'playing';
|
const videoSrc = getVideoSrc(chan);
|
||||||
|
|
||||||
// Use the current airing's media item file if available, else fallback
|
// If no video available, show the test card image for this slot
|
||||||
const currentAiring = nowPlaying[chan.id];
|
if (!videoSrc) {
|
||||||
let videoSrc = chan.fallbackFile;
|
return (
|
||||||
if (currentAiring && currentAiring.media_item_path) {
|
<div
|
||||||
if (currentAiring.media_item_path.startsWith('http')) {
|
key={chan.id}
|
||||||
videoSrc = currentAiring.media_item_path;
|
className={`tuner-video ${stateClass}`}
|
||||||
} else {
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#000' }}
|
||||||
// Django serves cached media at root, Vite proxies /media to root
|
>
|
||||||
// Remove leading slashes or /media/ to avoid double slashes like /media//mock
|
<img
|
||||||
const cleanPath = currentAiring.media_item_path.replace(/^\/?(media)?\/*/, '');
|
src={TEST_CARD_SRC}
|
||||||
videoSrc = `/media/${cleanPath}`;
|
alt="No Signal"
|
||||||
}
|
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -138,33 +140,30 @@ export default function ChannelTuner({ onOpenGuide }) {
|
|||||||
key={chan.id}
|
key={chan.id}
|
||||||
src={videoSrc}
|
src={videoSrc}
|
||||||
className={`tuner-video ${stateClass}`}
|
className={`tuner-video ${stateClass}`}
|
||||||
autoPlay={true}
|
autoPlay
|
||||||
muted={!isCurrent}
|
muted={!isCurrent}
|
||||||
loop
|
loop
|
||||||
playsInline
|
playsInline
|
||||||
onLoadedMetadata={(e) => {
|
onLoadedMetadata={(e) => {
|
||||||
const video = e.target;
|
const video = e.target;
|
||||||
if (currentAiring && currentAiring.starts_at) {
|
const currentAiring = nowPlaying[chan.id];
|
||||||
const startTime = new Date(currentAiring.starts_at).getTime();
|
if (currentAiring?.starts_at) {
|
||||||
const nowTime = Date.now();
|
const offsetSeconds = (Date.now() - new Date(currentAiring.starts_at).getTime()) / 1000;
|
||||||
|
if (offsetSeconds > 0 && video.duration > 0) {
|
||||||
if (nowTime > startTime) {
|
video.currentTime = offsetSeconds % video.duration;
|
||||||
const offsetSeconds = (nowTime - startTime) / 1000;
|
|
||||||
// If the video is shorter than the offset (e.g. repeating a short clip),
|
|
||||||
// modulo the offset by duration to emulate a continuous loop.
|
|
||||||
if (video.duration && video.duration > 0) {
|
|
||||||
video.currentTime = offsetSeconds % video.duration;
|
|
||||||
} else {
|
|
||||||
video.currentTime = offsetSeconds;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
if (e.target.src !== chan.fallbackFile) {
|
// Replace video with test card on error
|
||||||
console.warn(`Video failed to load: ${e.target.src}, falling back.`);
|
console.warn(`Video failed to load: ${e.target.src} — showing test card`);
|
||||||
e.target.src = chan.fallbackFile;
|
const parent = e.target.parentNode;
|
||||||
}
|
if (!parent) return;
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = TEST_CARD_SRC;
|
||||||
|
img.alt = 'No Signal';
|
||||||
|
img.style.cssText = 'width:100%;height:100%;object-fit:contain;position:absolute;top:0;left:0;background:#000';
|
||||||
|
e.target.replaceWith(img);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -177,21 +176,15 @@ export default function ChannelTuner({ onOpenGuide }) {
|
|||||||
<div className="debug-grid">
|
<div className="debug-grid">
|
||||||
<span className="debug-label">Channel:</span>
|
<span className="debug-label">Channel:</span>
|
||||||
<span className="debug-value">{currentChan.channel_number} - {currentChan.name} (ID: {currentChan.id})</span>
|
<span className="debug-value">{currentChan.channel_number} - {currentChan.name} (ID: {currentChan.id})</span>
|
||||||
|
|
||||||
<span className="debug-label">Airing ID:</span>
|
<span className="debug-label">Airing ID:</span>
|
||||||
<span className="debug-value">{airing ? airing.id : 'N/A'}</span>
|
<span className="debug-value">{airing ? airing.id : 'N/A'}</span>
|
||||||
|
|
||||||
<span className="debug-label">Media Item:</span>
|
<span className="debug-label">Media Item:</span>
|
||||||
<span className="debug-value">{airing && airing.media_item_title ? airing.media_item_title : 'N/A'}</span>
|
<span className="debug-value">{airing?.media_item_title ?? 'N/A'}</span>
|
||||||
|
|
||||||
<span className="debug-label">Stream URL:</span>
|
<span className="debug-label">Stream URL:</span>
|
||||||
<span className="debug-value debug-url">{(() => {
|
<span className="debug-value debug-url">{getVideoSrc(currentChan) ?? '(test card — no video)'}</span>
|
||||||
if (airing && airing.media_item_path) {
|
|
||||||
if (airing.media_item_path.startsWith('http')) return airing.media_item_path;
|
|
||||||
return `/media/${airing.media_item_path.replace(/^\/?(media)?\/*/, '')}`;
|
|
||||||
}
|
|
||||||
return currentChan.fallbackFile;
|
|
||||||
})()}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -200,7 +193,7 @@ export default function ChannelTuner({ onOpenGuide }) {
|
|||||||
<div className={`osd-overlay ${showOSD ? '' : 'hidden'}`}>
|
<div className={`osd-overlay ${showOSD ? '' : 'hidden'}`}>
|
||||||
<div className="osd-top">
|
<div className="osd-top">
|
||||||
<div className="osd-channel-bug">
|
<div className="osd-channel-bug">
|
||||||
<span className="ch-num">{currentChan.channel_number}</span>
|
<span className="ch-num">{currentChan.channel_number}</span>
|
||||||
{currentChan.name}
|
{currentChan.name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -208,20 +201,20 @@ export default function ChannelTuner({ onOpenGuide }) {
|
|||||||
<div className="osd-info-box glass-panel">
|
<div className="osd-info-box glass-panel">
|
||||||
<div className="osd-meta">
|
<div className="osd-meta">
|
||||||
<span className="osd-badge">
|
<span className="osd-badge">
|
||||||
{airing ? airing.slot_kind.toUpperCase() : 'LIVE'}
|
{airing ? airing.slot_kind?.toUpperCase() : 'NO SIGNAL'}
|
||||||
</span>
|
</span>
|
||||||
{airing && (
|
{airing && (
|
||||||
<span>
|
<span>
|
||||||
{new Date(airing.starts_at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})} -
|
{new Date(airing.starts_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} –{' '}
|
||||||
{new Date(airing.ends_at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}
|
{new Date(airing.ends_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="osd-title">
|
<h1 className="osd-title">
|
||||||
{airing ? airing.media_item_title : 'Loading Program...'}
|
{airing ? airing.media_item_title : '— No Programming —'}
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ color: 'var(--pytv-text-dim)' }}>
|
<p style={{ color: 'var(--pytv-text-dim)' }}>
|
||||||
Watching {currentChan.name}. Press Escape to load the EPG Guide.
|
Watching {currentChan.name}. Press Escape for the EPG Guide.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useRemoteControl } from '../hooks/useRemoteControl';
|
import { useRemoteControl } from '../hooks/useRemoteControl';
|
||||||
import { fetchChannels, fetchChannelAirings } from '../api';
|
import { fetchChannels, fetchChannelAirings } from '../api';
|
||||||
|
|
||||||
// Hours to show in the EPG and width configs
|
|
||||||
const EPG_HOURS = 4;
|
const EPG_HOURS = 4;
|
||||||
const HOUR_WIDTH_PX = 360;
|
const HOUR_WIDTH_PX = 360;
|
||||||
const PX_PER_MS = HOUR_WIDTH_PX / (60 * 60 * 1000);
|
const PX_PER_MS = HOUR_WIDTH_PX / (60 * 60 * 1000);
|
||||||
|
|
||||||
function fmtTime(iso) {
|
function fmtTime(iso) {
|
||||||
@@ -12,26 +11,88 @@ function fmtTime(iso) {
|
|||||||
return new Date(iso).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
return new Date(iso).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtDuration(starts_at, ends_at) {
|
||||||
|
if (!starts_at || !ends_at) return '';
|
||||||
|
const mins = Math.round((new Date(ends_at) - new Date(starts_at)) / 60000);
|
||||||
|
if (mins < 60) return `${mins}m`;
|
||||||
|
const h = Math.floor(mins / 60), m = mins % 60;
|
||||||
|
return m ? `${h}h ${m}m` : `${h}h`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Guide({ onClose, onSelectChannel }) {
|
export default function Guide({ onClose, onSelectChannel }) {
|
||||||
const [channels, setChannels] = useState([]);
|
const [channels, setChannels] = useState([]);
|
||||||
const [airings, setAirings] = useState({}); // { channelId: [airing, ...] }
|
const [airings, setAirings] = useState({});
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedRow, setSelectedRow] = useState(0);
|
||||||
|
const [selectedProgram, setSelectedProgram] = useState(0);
|
||||||
const [now, setNow] = useState(Date.now());
|
const [now, setNow] = useState(Date.now());
|
||||||
const scrollRef = useRef(null);
|
const scrollRef = useRef(null);
|
||||||
|
const programRefs = useRef({}); // { `${rowIdx}-${progIdx}`: el }
|
||||||
|
const rowsScrollRef = useRef(null);
|
||||||
|
|
||||||
// Time anchor: align to previous 30-min boundary for clean axis
|
|
||||||
const anchorTime = useRef(new Date(Math.floor(Date.now() / 1800000) * 1800000).getTime());
|
const anchorTime = useRef(new Date(Math.floor(Date.now() / 1800000) * 1800000).getTime());
|
||||||
|
|
||||||
|
// Build filtered + sorted airing list for a channel
|
||||||
|
const getAiringsForRow = useCallback((chanId) => {
|
||||||
|
const list = airings[chanId] || [];
|
||||||
|
return list
|
||||||
|
.filter(a => new Date(a.ends_at).getTime() > anchorTime.current)
|
||||||
|
.sort((a, b) => new Date(a.starts_at) - new Date(b.starts_at));
|
||||||
|
}, [airings]);
|
||||||
|
|
||||||
|
// Clamp selectedProgram when row changes
|
||||||
|
useEffect(() => {
|
||||||
|
const ch = channels[selectedRow];
|
||||||
|
if (!ch) return;
|
||||||
|
const rowAirings = getAiringsForRow(ch.id);
|
||||||
|
setSelectedProgram(prev => Math.min(prev, Math.max(0, rowAirings.length - 1)));
|
||||||
|
}, [selectedRow, channels, getAiringsForRow]);
|
||||||
|
|
||||||
|
// Auto-scroll focused program into view (timeline scroll)
|
||||||
|
useEffect(() => {
|
||||||
|
const key = `${selectedRow}-${selectedProgram}`;
|
||||||
|
const el = programRefs.current[key];
|
||||||
|
if (el && scrollRef.current) {
|
||||||
|
const containerRect = scrollRef.current.getBoundingClientRect();
|
||||||
|
const elRect = el.getBoundingClientRect();
|
||||||
|
const relLeft = elRect.left - containerRect.left + scrollRef.current.scrollLeft;
|
||||||
|
const targetScroll = relLeft - containerRect.width / 4;
|
||||||
|
scrollRef.current.scrollTo({ left: Math.max(0, targetScroll), behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [selectedRow, selectedProgram]);
|
||||||
|
|
||||||
|
// Auto-scroll focused row into view (rows column scroll)
|
||||||
|
useEffect(() => {
|
||||||
|
if (rowsScrollRef.current) {
|
||||||
|
const rowHeight = 80;
|
||||||
|
const visibleHeight = rowsScrollRef.current.clientHeight;
|
||||||
|
const rowTop = selectedRow * rowHeight;
|
||||||
|
const rowBottom = rowTop + rowHeight;
|
||||||
|
const currentScroll = rowsScrollRef.current.scrollTop;
|
||||||
|
if (rowTop < currentScroll) {
|
||||||
|
rowsScrollRef.current.scrollTop = rowTop;
|
||||||
|
} else if (rowBottom > currentScroll + visibleHeight) {
|
||||||
|
rowsScrollRef.current.scrollTop = rowBottom - visibleHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedRow]);
|
||||||
|
|
||||||
useRemoteControl({
|
useRemoteControl({
|
||||||
onUp: () => setSelectedIndex(prev => (prev - 1 + channels.length) % channels.length),
|
onUp: () => setSelectedRow(prev => Math.max(0, prev - 1)),
|
||||||
onDown: () => setSelectedIndex(prev => (prev + 1) % channels.length),
|
onDown: () => setSelectedRow(prev => Math.min(channels.length - 1, prev + 1)),
|
||||||
onSelect: () => channels[selectedIndex] && onSelectChannel(channels[selectedIndex].id),
|
onLeft: () => setSelectedProgram(prev => Math.max(0, prev - 1)),
|
||||||
onBack: onClose,
|
onRight: () => {
|
||||||
|
const ch = channels[selectedRow];
|
||||||
|
if (!ch) return;
|
||||||
|
const rowAirings = getAiringsForRow(ch.id);
|
||||||
|
setSelectedProgram(prev => Math.min(rowAirings.length - 1, prev + 1));
|
||||||
|
},
|
||||||
|
onSelect: () => channels[selectedRow] && onSelectChannel(channels[selectedRow].id),
|
||||||
|
onBack: onClose,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clock tick
|
// Clock tick
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => setNow(Date.now()), 15000); // 15s refresh
|
const timer = setInterval(() => setNow(Date.now()), 15000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -39,13 +100,17 @@ export default function Guide({ onClose, onSelectChannel }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchChannels()
|
fetchChannels()
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (!data || data.length === 0) {
|
const sorted = [...data].sort((a, b) => {
|
||||||
|
if (a.channel_number == null) return 1;
|
||||||
|
if (b.channel_number == null) return -1;
|
||||||
|
return a.channel_number - b.channel_number;
|
||||||
|
});
|
||||||
|
if (sorted.length === 0) {
|
||||||
setChannels([{ id: 99, channel_number: 99, name: 'No Channels Found' }]);
|
setChannels([{ id: 99, channel_number: 99, name: 'No Channels Found' }]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setChannels(data);
|
setChannels(sorted);
|
||||||
|
|
||||||
// Fetch overlapping data for timeline
|
|
||||||
Promise.allSettled(
|
Promise.allSettled(
|
||||||
data.map(ch =>
|
data.map(ch =>
|
||||||
fetchChannelAirings(ch.id, EPG_HOURS)
|
fetchChannelAirings(ch.id, EPG_HOURS)
|
||||||
@@ -55,61 +120,77 @@ export default function Guide({ onClose, onSelectChannel }) {
|
|||||||
).then(results => {
|
).then(results => {
|
||||||
const map = {};
|
const map = {};
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
if (r.status === 'fulfilled') {
|
if (r.status === 'fulfilled') map[r.value.channelId] = r.value.list;
|
||||||
map[r.value.channelId] = r.value.list;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setAirings(map);
|
setAirings(map);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch(err => {
|
||||||
console.error("Guide fetch error:", err);
|
console.error('Guide fetch error:', err);
|
||||||
setChannels([{ id: 99, channel_number: 99, name: 'Network Error' }]);
|
setChannels([{ id: 99, channel_number: 99, name: 'Network Error' }]);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Auto-scroll the timeline to keep the playhead visible but leave
|
// Initial scroll to now
|
||||||
// ~0.5 hours of padding on the left
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollRef.current) {
|
if (scrollRef.current) {
|
||||||
const msOffset = now - anchorTime.current;
|
const msOffset = now - anchorTime.current;
|
||||||
const pxOffset = msOffset * PX_PER_MS;
|
const pxOffset = msOffset * PX_PER_MS;
|
||||||
// scroll left to (current time - 30 mins)
|
scrollRef.current.scrollLeft = Math.max(0, pxOffset - HOUR_WIDTH_PX / 2);
|
||||||
const targetScroll = Math.max(0, pxOffset - (HOUR_WIDTH_PX / 2));
|
|
||||||
scrollRef.current.scrollLeft = targetScroll;
|
|
||||||
}
|
}
|
||||||
}, [now, airings]);
|
}, [airings]); // only run once airings load
|
||||||
|
|
||||||
const currentTimeStr = new Date(now).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
const currentTimeStr = new Date(now).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
const playheadPx = (now - anchorTime.current) * PX_PER_MS;
|
const playheadPx = (now - anchorTime.current) * PX_PER_MS;
|
||||||
|
const containerWidthPx = EPG_HOURS * HOUR_WIDTH_PX + HOUR_WIDTH_PX;
|
||||||
|
|
||||||
// Generate grid time slots (half-hour chunks)
|
|
||||||
const timeSlots = Array.from({ length: EPG_HOURS * 2 + 2 }).map((_, i) => {
|
const timeSlots = Array.from({ length: EPG_HOURS * 2 + 2 }).map((_, i) => {
|
||||||
const ts = new Date(anchorTime.current + (i * 30 * 60 * 1000));
|
const ts = new Date(anchorTime.current + i * 30 * 60 * 1000);
|
||||||
return {
|
return {
|
||||||
label: ts.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }),
|
label: ts.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }),
|
||||||
left: i * (HOUR_WIDTH_PX / 2)
|
left: i * (HOUR_WIDTH_PX / 2),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Background pattern width
|
// Focused program info for the detail panel
|
||||||
const containerWidthPx = (EPG_HOURS * HOUR_WIDTH_PX) + HOUR_WIDTH_PX;
|
const focusedChannel = channels[selectedRow];
|
||||||
|
const focusedAirings = focusedChannel ? getAiringsForRow(focusedChannel.id) : [];
|
||||||
|
const focusedAiring = focusedAirings[selectedProgram] || null;
|
||||||
|
|
||||||
|
const nowStr = new Date(now).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
const isLive = focusedAiring
|
||||||
|
? now >= new Date(focusedAiring.starts_at).getTime() && now < new Date(focusedAiring.ends_at).getTime()
|
||||||
|
: false;
|
||||||
|
|
||||||
|
// Progress % for live program
|
||||||
|
const liveProgress = isLive && focusedAiring
|
||||||
|
? Math.min(100, Math.round(
|
||||||
|
(now - new Date(focusedAiring.starts_at).getTime()) /
|
||||||
|
(new Date(focusedAiring.ends_at).getTime() - new Date(focusedAiring.starts_at).getTime()) * 100
|
||||||
|
))
|
||||||
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="guide-container open">
|
<div className="guide-container open" style={{ paddingBottom: 0 }}>
|
||||||
|
{/* Header */}
|
||||||
<div className="guide-header">
|
<div className="guide-header">
|
||||||
<h1>PYTV Guide</h1>
|
<h1>PYTV Guide</h1>
|
||||||
<div className="guide-clock">{currentTimeStr}</div>
|
<div className="guide-clock">{currentTimeStr}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="epg-wrapper">
|
{/* EPG grid — takes available space between header and detail panel */}
|
||||||
|
<div className="epg-wrapper" style={{ flex: 1, minHeight: 0 }}>
|
||||||
{/* Left fixed column for Channels */}
|
|
||||||
<div className="epg-channels-col">
|
{/* Left sticky channel column */}
|
||||||
<div className="epg-corner"></div>
|
<div className="epg-channels-col" style={{ overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
<div className="epg-grid-rows">
|
<div className="epg-corner" />
|
||||||
|
<div ref={rowsScrollRef} className="epg-grid-rows" style={{ overflowY: 'hidden', flex: 1 }}>
|
||||||
{channels.map((chan, idx) => (
|
{channels.map((chan, idx) => (
|
||||||
<div key={chan.id} className={`epg-row ${idx === selectedIndex ? 'active' : ''}`}>
|
<div
|
||||||
|
key={chan.id}
|
||||||
|
className={`epg-row ${idx === selectedRow ? 'active' : ''}`}
|
||||||
|
onClick={() => { setSelectedRow(idx); setSelectedProgram(0); }}
|
||||||
|
>
|
||||||
<div className="epg-ch-num">{chan.channel_number}</div>
|
<div className="epg-ch-num">{chan.channel_number}</div>
|
||||||
<div className="epg-ch-name">{chan.name}</div>
|
<div className="epg-ch-name">{chan.name}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,13 +198,13 @@ export default function Guide({ onClose, onSelectChannel }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable Timeline */}
|
{/* Scrollable timeline */}
|
||||||
<div className="epg-timeline-scroll" ref={scrollRef}>
|
<div className="epg-timeline-scroll" ref={scrollRef}>
|
||||||
<div
|
<div
|
||||||
className="epg-timeline-container"
|
className="epg-timeline-container"
|
||||||
style={{ width: `${containerWidthPx}px`, backgroundSize: `${HOUR_WIDTH_PX / 2}px 100%` }}
|
style={{ width: `${containerWidthPx}px`, backgroundSize: `${HOUR_WIDTH_PX / 2}px 100%` }}
|
||||||
>
|
>
|
||||||
{/* Time Axis Row */}
|
{/* Time axis */}
|
||||||
<div className="epg-time-axis">
|
<div className="epg-time-axis">
|
||||||
{timeSlots.map((slot, i) => (
|
{timeSlots.map((slot, i) => (
|
||||||
<div key={i} className="epg-time-slot" style={{ left: `${slot.left}px` }}>
|
<div key={i} className="epg-time-slot" style={{ left: `${slot.left}px` }}>
|
||||||
@@ -132,32 +213,31 @@ export default function Guide({ onClose, onSelectChannel }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Live Playhead Line */}
|
{/* Live playhead */}
|
||||||
{playheadPx > 0 && playheadPx < containerWidthPx && (
|
{playheadPx > 0 && playheadPx < containerWidthPx && (
|
||||||
<div className="epg-playhead" style={{ left: `${playheadPx}px` }} />
|
<div className="epg-playhead" style={{ left: `${playheadPx}px` }} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Grid Rows for Airings */}
|
{/* Program rows */}
|
||||||
<div className="epg-grid-rows">
|
<div className="epg-grid-rows">
|
||||||
{channels.map((chan, idx) => {
|
{channels.map((chan, rowIdx) => {
|
||||||
const chanAirings = airings[chan.id];
|
const rowAirings = getAiringsForRow(chan.id);
|
||||||
const isLoading = !chanAirings && chan.id !== 99;
|
const isLoading = !airings[chan.id] && chan.id !== 99;
|
||||||
|
const isActiveRow = rowIdx === selectedRow;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={chan.id} className={`epg-row ${idx === selectedIndex ? 'active' : ''}`}>
|
<div
|
||||||
{isLoading && <div className="epg-loading">Loading...</div>}
|
key={chan.id}
|
||||||
{!isLoading && chanAirings?.length === 0 && (
|
className={`epg-row ${isActiveRow ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
{isLoading && <div className="epg-loading">Loading…</div>}
|
||||||
|
{!isLoading && rowAirings.length === 0 && (
|
||||||
<div className="epg-empty">No scheduled programs</div>
|
<div className="epg-empty">No scheduled programs</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{chanAirings && chanAirings.map(a => {
|
{rowAirings.map((a, progIdx) => {
|
||||||
const sTs = new Date(a.starts_at).getTime();
|
const sTs = new Date(a.starts_at).getTime();
|
||||||
const eTs = new Date(a.ends_at).getTime();
|
const eTs = new Date(a.ends_at).getTime();
|
||||||
|
|
||||||
// Filter anything that ended before our timeline anchor
|
|
||||||
if (eTs <= anchorTime.current) return null;
|
|
||||||
|
|
||||||
// Calculate block dimensions
|
|
||||||
const startPx = Math.max(0, (sTs - anchorTime.current) * PX_PER_MS);
|
const startPx = Math.max(0, (sTs - anchorTime.current) * PX_PER_MS);
|
||||||
const rawEndPx = (eTs - anchorTime.current) * PX_PER_MS;
|
const rawEndPx = (eTs - anchorTime.current) * PX_PER_MS;
|
||||||
const endPx = Math.min(containerWidthPx, rawEndPx);
|
const endPx = Math.min(containerWidthPx, rawEndPx);
|
||||||
@@ -165,17 +245,31 @@ export default function Guide({ onClose, onSelectChannel }) {
|
|||||||
|
|
||||||
let stateClass = 'future';
|
let stateClass = 'future';
|
||||||
if (now >= sTs && now < eTs) stateClass = 'current';
|
if (now >= sTs && now < eTs) stateClass = 'current';
|
||||||
if (now >= eTs) stateClass = 'past';
|
else if (now >= eTs) stateClass = 'past';
|
||||||
|
|
||||||
|
const isFocused = isActiveRow && progIdx === selectedProgram;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={a.id}
|
key={a.id}
|
||||||
className={`epg-program ${stateClass}`}
|
ref={el => {
|
||||||
|
const key = `${rowIdx}-${progIdx}`;
|
||||||
|
if (el) programRefs.current[key] = el;
|
||||||
|
}}
|
||||||
|
className={`epg-program ${stateClass}${isFocused ? ' epg-program-focused' : ''}`}
|
||||||
style={{ left: `${startPx}px`, width: `${widthPx}px` }}
|
style={{ left: `${startPx}px`, width: `${widthPx}px` }}
|
||||||
onClick={() => onSelectChannel(chan.id)}
|
onClick={() => {
|
||||||
|
setSelectedRow(rowIdx);
|
||||||
|
setSelectedProgram(progIdx);
|
||||||
|
onSelectChannel(chan.id);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setSelectedRow(rowIdx);
|
||||||
|
setSelectedProgram(progIdx);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="epg-program-title">{a.media_item_title}</div>
|
<div className="epg-program-title">{a.media_item_title}</div>
|
||||||
<div className="epg-program-time">{fmtTime(a.starts_at)} - {fmtTime(a.ends_at)}</div>
|
<div className="epg-program-time">{fmtTime(a.starts_at)} – {fmtTime(a.ends_at)}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -185,11 +279,54 @@ export default function Guide({ onClose, onSelectChannel }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ padding: '0 3rem', flexShrink: 0, marginTop: '2rem', color: 'var(--pytv-text-dim)', textAlign: 'center' }}>
|
{/* ── Detail panel ─────────────────────────────────────────────────── */}
|
||||||
Press <span style={{ color: '#fff' }}>Enter</span> to tune · <span style={{ color: '#fff' }}>↑↓</span> to navigate channels · <span style={{ color: '#fff' }}>Escape</span> to close
|
<div className="epg-detail-panel">
|
||||||
|
{focusedAiring ? (
|
||||||
|
<>
|
||||||
|
<div className="epg-detail-left">
|
||||||
|
{isLive && <span className="epg-live-badge">● LIVE</span>}
|
||||||
|
<div className="epg-detail-title">{focusedAiring.media_item_title || '—'}</div>
|
||||||
|
<div className="epg-detail-meta">
|
||||||
|
<span>{fmtTime(focusedAiring.starts_at)} – {fmtTime(focusedAiring.ends_at)}</span>
|
||||||
|
<span className="epg-detail-dot">·</span>
|
||||||
|
<span>{fmtDuration(focusedAiring.starts_at, focusedAiring.ends_at)}</span>
|
||||||
|
{focusedChannel && (
|
||||||
|
<>
|
||||||
|
<span className="epg-detail-dot">·</span>
|
||||||
|
<span>CH {focusedChannel.channel_number} · {focusedChannel.name}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isLive && (
|
||||||
|
<div className="epg-progress-bar">
|
||||||
|
<div className="epg-progress-fill" style={{ width: `${liveProgress}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="epg-detail-right">
|
||||||
|
<div className="epg-detail-hint">
|
||||||
|
<kbd>↑↓</kbd> Channel <kbd>←→</kbd> Program <kbd>Enter</kbd> Watch <kbd>Esc</kbd> Close
|
||||||
|
</div>
|
||||||
|
{isLive && (
|
||||||
|
<button className="btn-accent" style={{ fontSize: '0.85rem', padding: '0.4rem 1rem' }}
|
||||||
|
onClick={() => onSelectChannel(focusedChannel.id)}>
|
||||||
|
▶ Watch Now
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : focusedChannel ? (
|
||||||
|
<div className="epg-detail-left" style={{ opacity: 0.5 }}>
|
||||||
|
<div className="epg-detail-title">{focusedChannel.name}</div>
|
||||||
|
<div className="epg-detail-meta">No programs scheduled — <kbd>↑↓</kbd> to navigate channels</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="epg-detail-left" style={{ opacity: 0.4 }}>
|
||||||
|
<div className="epg-detail-meta"><kbd>↑↓</kbd> Channel <kbd>←→</kbd> Program <kbd>Enter</kbd> Watch</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import {
|
|||||||
fetchTemplates, createTemplate, deleteTemplate, generateScheduleToday,
|
fetchTemplates, createTemplate, deleteTemplate, generateScheduleToday,
|
||||||
fetchTemplateBlocks, createTemplateBlock, deleteTemplateBlock,
|
fetchTemplateBlocks, createTemplateBlock, deleteTemplateBlock,
|
||||||
fetchSources, createSource, syncSource, deleteSource,
|
fetchSources, createSource, syncSource, deleteSource,
|
||||||
fetchLibraries,
|
fetchLibraries, fetchCollections,
|
||||||
fetchDownloadStatus, triggerCacheUpcoming, downloadItem,
|
fetchDownloadStatus, triggerCacheUpcoming, downloadItem, fetchDownloadProgress,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
|
|
||||||
// ─── Constants ────────────────────────────────────────────────────────────
|
// ─── Constants ────────────────────────────────────────────────────────────
|
||||||
@@ -57,6 +57,12 @@ function EmptyState({ text }) {
|
|||||||
return <p className="settings-empty">{text}</p>;
|
return <p className="settings-empty">{text}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function timeToPct(timeStr) {
|
||||||
|
if (!timeStr) return 0;
|
||||||
|
const [h, m] = timeStr.split(':').map(Number);
|
||||||
|
return ((h * 60 + m) / 1440) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
function IconBtn({ icon, label, kind = 'default', onClick, disabled, title }) {
|
function IconBtn({ icon, label, kind = 'default', onClick, disabled, title }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -151,8 +157,11 @@ function UsersTab() {
|
|||||||
function ChannelsTab() {
|
function ChannelsTab() {
|
||||||
const [channels, setChannels] = useState([]);
|
const [channels, setChannels] = useState([]);
|
||||||
const [sources, setSources] = useState([]);
|
const [sources, setSources] = useState([]);
|
||||||
|
const [collections, setCollections] = useState([]);
|
||||||
const [libraries, setLibraries] = useState([]);
|
const [libraries, setLibraries] = useState([]);
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
|
const [templates, setTemplates] = useState([]);
|
||||||
|
const [templateBlocks, setTemplateBlocks] = useState({}); // { templateId: [blocks] }
|
||||||
const [expandedId, setExpandedId] = useState(null);
|
const [expandedId, setExpandedId] = useState(null);
|
||||||
const [channelSources, setChannelSources] = useState({}); // { channelId: [rules] }
|
const [channelSources, setChannelSources] = useState({}); // { channelId: [rules] }
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
@@ -162,8 +171,17 @@ function ChannelsTab() {
|
|||||||
const [feedback, setFeedback, ok, err] = useFeedback();
|
const [feedback, setFeedback, ok, err] = useFeedback();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([fetchChannels(), fetchSources(), fetchLibraries(), fetchUsers()])
|
Promise.all([fetchChannels(), fetchSources(), fetchLibraries(), fetchUsers(), fetchCollections(), fetchTemplates()])
|
||||||
.then(([c, s, l, u]) => { setChannels(c); setSources(s); setLibraries(l); setUsers(u); })
|
.then(([c, s, l, u, col, tmpl]) => {
|
||||||
|
setChannels(c); setSources(s); setLibraries(l); setUsers(u); setCollections(col);
|
||||||
|
setTemplates(tmpl);
|
||||||
|
// Pre-load blocks for each template
|
||||||
|
tmpl.forEach(t => {
|
||||||
|
fetchTemplateBlocks(t.id).then(blocks => {
|
||||||
|
setTemplateBlocks(prev => ({ ...prev, [t.id]: blocks }));
|
||||||
|
}).catch(() => {});
|
||||||
|
});
|
||||||
|
})
|
||||||
.catch(() => err('Failed to load channels'));
|
.catch(() => err('Failed to load channels'));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -236,6 +254,14 @@ function ChannelsTab() {
|
|||||||
finally { setSyncingId(null); }
|
finally { setSyncingId(null); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSetFallback = async (ch, collectionId) => {
|
||||||
|
try {
|
||||||
|
const updated = await updateChannel(ch.id, { fallback_collection_id: collectionId ? parseInt(collectionId) : null });
|
||||||
|
setChannels(cs => cs.map(c => c.id === updated.id ? updated : c));
|
||||||
|
ok(collectionId ? 'Fallback collection set.' : 'Fallback collection cleared.');
|
||||||
|
} catch { err('Failed to update fallback collection.'); }
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tab-content">
|
<div className="tab-content">
|
||||||
<Feedback fb={feedback} clear={() => setFeedback(null)} />
|
<Feedback fb={feedback} clear={() => setFeedback(null)} />
|
||||||
@@ -309,6 +335,29 @@ function ChannelsTab() {
|
|||||||
{/* Expanded: source assignment panel */}
|
{/* Expanded: source assignment panel */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="channel-expand-panel">
|
<div className="channel-expand-panel">
|
||||||
|
|
||||||
|
{/* ─── 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' }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', fontSize: '0.9rem' }}>
|
||||||
|
<span style={{ whiteSpace: 'nowrap', fontWeight: 600 }}>⛔ Error Fallback Collection</span>
|
||||||
|
<select
|
||||||
|
value={ch.fallback_collection_id ?? ''}
|
||||||
|
onChange={e => handleSetFallback(ch, e.target.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
title="When scheduled programming cannot play, items from this collection will air instead."
|
||||||
|
>
|
||||||
|
<option value="">— None (use block sources) —</option>
|
||||||
|
{collections
|
||||||
|
.filter(col => col.library_id === ch.library_id)
|
||||||
|
.map(col => <option key={col.id} value={col.id}>{col.name} ({col.collection_type})</option>)
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p style={{ margin: '0.4rem 0 0 0', fontSize: '0.78rem', opacity: 0.65 }}>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h4 className="expand-section-title">Assigned Sources</h4>
|
<h4 className="expand-section-title">Assigned Sources</h4>
|
||||||
|
|
||||||
{!hasRules && (
|
{!hasRules && (
|
||||||
@@ -369,13 +418,20 @@ function ChannelsTab() {
|
|||||||
style={{ width: 60 }}
|
style={{ width: 60 }}
|
||||||
title="Weight (higher = more airings)"
|
title="Weight (higher = more airings)"
|
||||||
/>
|
/>
|
||||||
<input
|
<select
|
||||||
placeholder="Target Block Label (Optional)"
|
|
||||||
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"
|
title="If set, this source will ONLY play during blocks with this exact name. Leaving it empty applies to all blocks."
|
||||||
/>
|
>
|
||||||
|
<option value="">— Any Time (Default) —</option>
|
||||||
|
{Array.from(new Set(
|
||||||
|
templates.filter(t => t.channel_id === ch.id)
|
||||||
|
.flatMap(t => (templateBlocks[t.id] || []).map(b => b.name))
|
||||||
|
)).map(name => (
|
||||||
|
<option key={name} value={name}>{name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
<button className="btn-sync sm" onClick={() => handleAssign(ch.id)}>+ Add Rule</button>
|
<button className="btn-sync sm" onClick={() => handleAssign(ch.id)}>+ Add Rule</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -398,7 +454,7 @@ function SourcesTab() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [syncingId, setSyncingId] = useState(null);
|
const [syncingId, setSyncingId] = useState(null);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [form, setForm] = useState({ name: '', source_type: 'youtube_playlist', uri: '', library_id: '', max_videos: 50 });
|
const [form, setForm] = useState({ name: '', source_type: 'youtube_playlist', uri: '', library_id: '', max_videos: 50, scan_interval_minutes: 60 });
|
||||||
const [feedback, setFeedback, ok, err] = useFeedback();
|
const [feedback, setFeedback, ok, err] = useFeedback();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -411,10 +467,14 @@ function SourcesTab() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!form.library_id) { err('Please select a library.'); return; }
|
if (!form.library_id) { err('Please select a library.'); return; }
|
||||||
try {
|
try {
|
||||||
const src = await createSource({ ...form, library_id: parseInt(form.library_id) });
|
const src = await createSource({
|
||||||
|
...form,
|
||||||
|
library_id: parseInt(form.library_id),
|
||||||
|
scan_interval_minutes: parseInt(form.scan_interval_minutes) || null
|
||||||
|
});
|
||||||
setSources(s => [...s, src]);
|
setSources(s => [...s, src]);
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setForm({ name: '', source_type: 'youtube_playlist', uri: '', library_id: '', max_videos: 50 });
|
setForm({ name: '', source_type: 'youtube_playlist', uri: '', library_id: '', max_videos: 50, scan_interval_minutes: 60 });
|
||||||
ok(`Source "${src.name}" registered. Hit Sync to import videos.`);
|
ok(`Source "${src.name}" registered. Hit Sync to import videos.`);
|
||||||
} catch { err('Failed to create source.'); }
|
} catch { err('Failed to create source.'); }
|
||||||
};
|
};
|
||||||
@@ -482,6 +542,11 @@ function SourcesTab() {
|
|||||||
onChange={e => setForm(f => ({ ...f, max_videos: e.target.value }))} />
|
onChange={e => setForm(f => ({ ...f, max_videos: e.target.value }))} />
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
<label>Scan Interval (mins)
|
||||||
|
<input type="number" min="15" max="1440" value={form.scan_interval_minutes}
|
||||||
|
onChange={e => setForm(f => ({ ...f, scan_interval_minutes: e.target.value }))}
|
||||||
|
title="How often background workers should fetch new metadata updates" />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" className="btn-accent">Register Source</button>
|
<button type="submit" className="btn-accent">Register Source</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -537,6 +602,7 @@ function DownloadsTab() {
|
|||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
const [runResult, setRunResult] = useState(null);
|
const [runResult, setRunResult] = useState(null);
|
||||||
const [downloadingId, setDownloadingId] = useState(null);
|
const [downloadingId, setDownloadingId] = useState(null);
|
||||||
|
const [downloadProgress, setDownloadProgress] = useState(null); // '45.1%'
|
||||||
const [filterSource, setFilterSource] = useState('all');
|
const [filterSource, setFilterSource] = useState('all');
|
||||||
const [feedback, setFeedback, ok, err] = useFeedback();
|
const [feedback, setFeedback, ok, err] = useFeedback();
|
||||||
|
|
||||||
@@ -565,6 +631,15 @@ function DownloadsTab() {
|
|||||||
|
|
||||||
const handleDownloadOne = async (item) => {
|
const handleDownloadOne = async (item) => {
|
||||||
setDownloadingId(item.id);
|
setDownloadingId(item.id);
|
||||||
|
setDownloadProgress('0%');
|
||||||
|
let pollInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetchDownloadProgress(item.id);
|
||||||
|
if (res.progress) setDownloadProgress(res.progress);
|
||||||
|
if (res.status === 'finished') clearInterval(pollInterval);
|
||||||
|
} catch (e) { console.warn('Progress poll err', e); }
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadItem(item.id);
|
await downloadItem(item.id);
|
||||||
ok(`Downloaded "${item.title.slice(0, 50)}".`);
|
ok(`Downloaded "${item.title.slice(0, 50)}".`);
|
||||||
@@ -575,7 +650,11 @@ function DownloadsTab() {
|
|||||||
}));
|
}));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
err(`Download failed: ${e?.response?.data?.detail || e.message}`);
|
err(`Download failed: ${e?.response?.data?.detail || e.message}`);
|
||||||
} finally { setDownloadingId(null); }
|
} finally {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
setDownloadingId(null);
|
||||||
|
setDownloadProgress(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const sources = status ? [...new Set(status.items.map(i => i.source_name))] : [];
|
const sources = status ? [...new Set(status.items.map(i => i.source_name))] : [];
|
||||||
@@ -676,13 +755,17 @@ function DownloadsTab() {
|
|||||||
<button
|
<button
|
||||||
className="btn-sync"
|
className="btn-sync"
|
||||||
onClick={() => handleDownloadOne(item)}
|
onClick={() => handleDownloadOne(item)}
|
||||||
disabled={downloadingId === item.id}
|
disabled={downloadingId !== null}
|
||||||
title="Download this video now"
|
title="Download this video now"
|
||||||
>
|
>
|
||||||
{downloadingId === item.id ? '…' : '⬇'}
|
{downloadingId === item.id ? `Downloading ${downloadProgress || '...'}` : '⬇ Download'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Inject a progress bar right under the item if it's currently downloading */}
|
||||||
|
{downloadingId === item.id && downloadProgress && (
|
||||||
|
<div style={{ position: 'absolute', bottom: 0, left: 0, height: '3px', background: 'var(--pytv-accent)', width: downloadProgress, transition: 'width 0.3s ease' }} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -846,6 +929,33 @@ function SchedulingTab() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{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)' }}>
|
||||||
|
{/* Timeline tick marks */}
|
||||||
|
{[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' }}>
|
||||||
|
{h}:00
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{blocks.map(b => {
|
||||||
|
const startPct = timeToPct(b.start_local_time);
|
||||||
|
let endPct = timeToPct(b.end_local_time);
|
||||||
|
if (endPct <= startPct) endPct = 100; // spills past midnight visually
|
||||||
|
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
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
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' }}
|
||||||
|
title={`${b.name} (${b.start_local_time.slice(0,5)} - ${b.end_local_time.slice(0,5)}) Rating: ${b.target_content_rating || 'Any'}`}
|
||||||
|
>
|
||||||
|
{width > 8 ? b.name : ''}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1.5rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1.5rem' }}>
|
||||||
{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} style={{ display: 'flex', gap: '0.5rem', background: '#353b48', padding: '0.5rem', borderRadius: '4px', alignItems: 'center', fontSize: '0.9rem' }}>
|
||||||
|
|||||||
@@ -472,6 +472,114 @@ body {
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keyboard/remote cursor focus ring on a program cell */
|
||||||
|
.epg-program-focused {
|
||||||
|
outline: 2px solid var(--pytv-accent) !important;
|
||||||
|
outline-offset: -2px;
|
||||||
|
background: rgba(0, 212, 255, 0.20) !important;
|
||||||
|
box-shadow: 0 0 18px var(--pytv-accent-glow);
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
.epg-program-focused .epg-program-title {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Detail Panel ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.epg-detail-panel {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 1.25rem 3rem;
|
||||||
|
background: rgba(10, 10, 20, 0.95);
|
||||||
|
border-top: 1px solid var(--pytv-glass-border);
|
||||||
|
min-height: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.epg-detail-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.epg-detail-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.epg-live-badge {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
color: var(--pytv-accent);
|
||||||
|
text-shadow: 0 0 8px var(--pytv-accent-glow);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.epg-detail-title {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: var(--pytv-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.epg-detail-meta {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--pytv-text-dim);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.epg-detail-dot {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Live progress bar under detail title */
|
||||||
|
.epg-progress-bar {
|
||||||
|
height: 3px;
|
||||||
|
background: rgba(255,255,255,0.12);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
width: min(400px, 100%);
|
||||||
|
}
|
||||||
|
.epg-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--pytv-accent);
|
||||||
|
box-shadow: 0 0 6px var(--pytv-accent-glow);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 1s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keyboard hints */
|
||||||
|
.epg-detail-hint {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: var(--pytv-text-dim);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.epg-detail-hint kbd {
|
||||||
|
display: inline-block;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border: 1px solid rgba(255,255,255,0.18);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.05em 0.4em;
|
||||||
|
font-family: var(--font-osd);
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--pytv-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Animations */
|
/* Animations */
|
||||||
@keyframes slideUp {
|
@keyframes slideUp {
|
||||||
from {
|
from {
|
||||||
|
|||||||
22
package-lock.json
generated
Normal file
22
package-lock.json
generated
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "pytv",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "pytv",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"ffprobe-static": "^3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ffprobe-static": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ffprobe-static/-/ffprobe-static-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-Dvpa9uhVMOYivhHKWLGDoa512J751qN1WZAIO+Xw4L/mrUSPxS4DApzSUDbCFE/LUq2+xYnznEahTd63AqBSpA==",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
package.json
Normal file
19
package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "pytv",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Simple app to replicate cable experience. Note this app has been developed with the heavy assistence of Generative AI as an experiment for myself. Therefore **it should not be considered secure**",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git@git.algebrist.com:tboudreaux/PYTV.git"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"ffprobe-static": "^3.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user