feat(main): main
This commit is contained in:
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)
|
||||
library = models.ForeignKey(Library, on_delete=models.CASCADE)
|
||||
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)
|
||||
slug = models.SlugField(max_length=64, unique=True)
|
||||
channel_number = models.IntegerField(blank=True, null=True)
|
||||
|
||||
@@ -41,10 +41,18 @@ class ScheduleGenerator:
|
||||
Idempotent generation of airings for `target_date`.
|
||||
Returns the number of new Airing rows created.
|
||||
"""
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
template = self._get_template()
|
||||
if not template:
|
||||
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()
|
||||
blocks = template.scheduleblock_set.all().order_by('start_local_time')
|
||||
airings_created = 0
|
||||
@@ -53,10 +61,14 @@ class ScheduleGenerator:
|
||||
if not (block.day_of_week_mask & target_weekday_bit):
|
||||
continue
|
||||
|
||||
start_dt = datetime.combine(target_date, block.start_local_time, tzinfo=timezone.utc)
|
||||
end_dt = datetime.combine(target_date, block.end_local_time, tzinfo=timezone.utc)
|
||||
# Convert local block times to UTC-aware datetimes
|
||||
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:
|
||||
end_dt += timedelta(days=1)
|
||||
|
||||
@@ -81,12 +93,18 @@ class ScheduleGenerator:
|
||||
if latest_prior_airing and latest_prior_airing.ends_at > start_dt:
|
||||
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(
|
||||
template, block, actual_start_dt, end_dt, available_items
|
||||
)
|
||||
|
||||
return airings_created
|
||||
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
@@ -248,13 +266,21 @@ class ScheduleGenerator:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
for original_airing in airings:
|
||||
# 1. Fetch available downloaded items for this block
|
||||
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 block {original_airing.schedule_block.name}")
|
||||
continue
|
||||
# 1. First check if the channel has a dedicated error fallback collection
|
||||
safe_items = []
|
||||
if getattr(self.channel, 'fallback_collection', None):
|
||||
safe_items = list(self.channel.fallback_collection.media_items.exclude(
|
||||
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
|
||||
fallback_item = random.choice(safe_items)
|
||||
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)
|
||||
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 = {
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
@@ -218,6 +233,7 @@ def download_for_airing(media_item: MediaItem) -> Path:
|
||||
# 3. Any pre-muxed webm
|
||||
# 4. Anything pre-muxed (no merger needed)
|
||||
"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
|
||||
|
||||
Reference in New Issue
Block a user