wrolpi/modules/videos/lib.py
lrnselfreliance 79526afebe
A malformed config entry is skipped and counted instead of failing the import (#875)
* A malformed config entry is skipped and counted instead of failing the import

An entry that is not a mapping (or whose key is not text, or a list that is not a list) made the whole file fail to
import in playlists.yaml, repos.yaml, download_manager.yaml and tags.yaml (tag_files/tag_zims), and a non-text name
did in domains.yaml.  Each is now skipped and counted in import_skipped, so the config is not saved over it, and the
rest of the file imports.  A Channel whose `downloads` is not a list keeps its Downloads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* An unreadable entry keeps no row and does not make a section present

The key of an entry too malformed to name a row is now `Unreadable(position)`.  It is still counted in
import_skipped, but `present` ignores it, so a section whose entries are all unreadable removes nothing (as an empty
section), instead of deleting every row.  A playlist whose `items` is not a list keeps its items, and a Channel
Download whose url is not text is counted (its Channel keeps its Downloads) instead of failing the import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:28:34 -06:00

1484 lines
64 KiB
Python

import contextlib
import dataclasses
import html
import json
import pathlib
import re
from dataclasses import dataclass, field
from datetime import datetime
from typing import Dict, Generator, Type, List, Set
from typing import Tuple, Optional
import pytz
from sqlalchemy.orm import Session, joinedload
from yt_dlp import YoutubeDL
from modules.videos.models import Video
from wrolpi import dates, flags
from wrolpi.captions import extract_captions
from wrolpi.common import ConfigFile, extract_domain, logger, \
escape_file_name, get_media_directory, background_task, Base, get_wrolpi_config, trim_file_name
from wrolpi.collections import Collection
from wrolpi.collections.models import validate_collection_directory
from wrolpi.config_import import ConfigSection, Unreadable, chunks, media_relative
from wrolpi.config_version import register_ownership
from wrolpi.dates import Seconds, from_timestamp
from wrolpi.db import get_db_curs, get_db_session, session_curs
from wrolpi.downloader import Download, download_manager, save_downloads_config
from wrolpi.errors import UnknownDirectory
from wrolpi.events import Events
from wrolpi.files.lib import split_path_stem_and_suffix
from wrolpi.switches import register_switch_handler, ActivateSwitchMethod
from wrolpi.tags import Tag, unknown_tag_names
from wrolpi.vars import YTDLP_CACHE_DIR, PYTEST
from .common import is_valid_poster, convert_image, \
generate_video_poster, ConfigError, \
extract_video_duration, ffprobe_json_sync, read_ffprobe_json_file, write_ffprobe_json_file
from .cookies import cookies_unlocked, cookies_for_download
from .errors import UnknownChannel
from .models import Channel
logger = logger.getChild(__name__)
DEFAULT_DOWNLOAD_FREQUENCY = Seconds.week
REQUIRED_OPTIONS = ['name', 'directory']
@dataclasses.dataclass
class VideoInfoJSON:
channel_source_id: str | None = None
channel_url: str | None = None
duration: int | None = None
epoch: int | None = None
timestamp: datetime | None = None
title: str | None = None
upload_date: datetime | None = None
url: str | None = None
view_count: int | None = None
def extract_video_info_json(video: Video) -> VideoInfoJSON:
"""
Parse the Video's info json file, return the relevant data.
"""
video_info_json = VideoInfoJSON()
if info_json := video.get_info_json():
# A title the user set (Edit > Edit) is kept apart from yt-dlp's keys, and wins.
wrolpi_section = info_json.get('wrolpi') or dict()
title = wrolpi_section.get('custom_title') or info_json.get('fulltitle') or info_json.get('title')
video_info_json.title = html.unescape(title) if title else None
upload_date = dates.strpdate(i) if (i := info_json.get('upload_date')) else None
if upload_date:
upload_date = upload_date.astimezone(pytz.UTC)
timestamp = None
try:
timestamp = dates.from_timestamp(info_json['timestamp']) if info_json.get('timestamp') else None
except Exception:
pass
video_info_json.channel_source_id = info_json.get('channel_id') or info_json.get('uploader_id') or None
video_info_json.channel_url = info_json.get('channel_url') or info_json.get('uploader_url') or None
video_info_json.duration = int(i) if (i := info_json.get('duration')) else None
video_info_json.epoch = int(i) if (i := info_json.get('epoch')) else None
video_info_json.timestamp = timestamp
video_info_json.upload_date = upload_date
video_info_json.url = info_json.get('webpage_url') or info_json.get('url') or None
video_info_json.view_count = int(i) if (i := info_json.get('view_count')) else None
return video_info_json
EXTRACT_SUBTITLES = False
def validate_video(session: Session, video: Video, channel_generate_poster: bool):
"""
Validate a single video.
A Video is validated when we have attempted to find its: title, duration, view_count, url, caption, size, and
channel.
A Video is also valid when it has a JPEG poster, if any. If no poster can be
found, it will be generated from the video file.
"""
json_published_datetime = None
if video.info_json_path:
# These properties can be found in the info json.
video_info_json = extract_video_info_json(video)
video.file_group.title = video_info_json.title
video.file_group.length = video_info_json.duration
video.file_group.url = video_info_json.url
json_published_datetime = video.file_group.published_datetime = (
video_info_json.timestamp # The exact second the video was published.
or video_info_json.upload_date # The day the video was published.
or video.file_group.published_datetime
)
video.file_group.download_datetime = from_timestamp(video_info_json.epoch) if video_info_json.epoch else None
# View count will probably be overwritten by more recent data when this Video's Channel is
# updated.
video.view_count = video.view_count or video_info_json.view_count
if video_info_json.channel_source_id or video_info_json.channel_url:
from modules.videos.channel.lib import get_channel
try:
if channel := get_channel(
session,
source_id=video_info_json.channel_source_id,
url=video_info_json.channel_url,
directory=video.video_path.parent,
return_dict=False,
):
video.channel_id = channel.id
except UnknownChannel:
pass
video_path = video.video_path
if not video_path:
logger.error(f'{video} does not have video_path!')
if video_path and (not video.file_group.title or not video.file_group.published_datetime or not video.source_id):
# Video is missing things that can be extracted from the video file name.
# These are the least trusted, so anything already on the video should be trusted.
_, published_date, source_id, title = parse_video_file_name(video_path)
published_date = published_date or video.file_group.published_datetime
if published_date and isinstance(published_date, str):
published_date = dates.strpdate(published_date)
if not published_date.tzinfo:
published_date = published_date.astimezone(pytz.UTC)
video.file_group.title = video.file_group.title or html.unescape(title)
# Trust info json upload_date before file name datetime.
video.file_group.published_datetime = json_published_datetime or published_date
video.source_id = video.source_id or source_id
is_audio = video.file_group.mimetype and video.file_group.mimetype.startswith('audio/')
if channel_generate_poster and not is_audio:
# Try to convert/generate, but keep the old poster if those fail.
new_poster_path, duration = convert_or_generate_poster(video) or video.poster_path
if new_poster_path:
# Poster was created/updated.
video.file_group.append_files(new_poster_path)
if duration:
video.file_group.length = duration
if video_path and not video.ffprobe_json:
# Extract ffprobe data if not already present (e.g., from .ffprobe.json cache file)
ffprobe_path = video.ffprobe_json_path
# Try to read from cache file first
if ffprobe_path and ffprobe_path.is_file():
video.ffprobe_json = read_ffprobe_json_file(ffprobe_path)
# If still no data, run ffprobe
if not video.ffprobe_json:
try:
video.ffprobe_json = ffprobe_json_sync(video_path)
# Write cache file if enabled
if ffprobe_path and get_wrolpi_config().save_ffprobe_json:
try:
write_ffprobe_json_file(ffprobe_path, video.ffprobe_json)
video.file_group.append_files(ffprobe_path)
except IOError as e:
logger.warning(f'Failed to write ffprobe json cache file {ffprobe_path}', exc_info=e)
except Exception as e:
logger.warning(f'Failed to extract ffprobe data for {video}', exc_info=e)
# Extract duration from ffprobe data
if video_path and not video.file_group.length:
if video.ffprobe_json:
if duration := video.ffprobe_json['format'].get('duration'):
video.file_group.length = float(duration)
elif (video_streams := video.get_streams_by_codec_type('video')) and 'duration' in video_streams[0]:
video.file_group.length = float(video_streams[0]['duration'])
else:
# Fallback if ffprobe extraction failed
video.file_group.length = extract_video_duration(video_path)
if video_path and not video.caption_paths and video.file_group.d_text and not EXTRACT_SUBTITLES:
# Caption file was deleted, clear out old captions.
video.file_group.d_text = None
elif video_path and not video.file_group.d_text and EXTRACT_SUBTITLES:
# Captions were not found, extract them from the video.
video.file_group.d_text = extract_captions(video_path)
elif video_path and video.caption_paths and not video.file_group.d_text:
video.file_group.d_text = video.get_caption_text()
def convert_or_generate_poster(video: Video) -> Tuple[Optional[pathlib.Path], Optional[int]]:
"""
If a Video has a poster, but the poster is invalid, convert it. If a Video has no poster, generate one from the
video file.
Returns None if the poster was not converted, and not generated.
"""
video_path = video.video_path
# Modification/generation of poster is enabled for this channel.
if video.poster_path:
# Check that the poster is a more universally supported JPEG.
old: pathlib.Path = video.poster_path
new = old.with_suffix('.jpg')
if old != new and new.exists():
# Destination JPEG already exists (it may have the wrong format).
video.file_group.unlink_tag_directory_links(old)
old.unlink()
old = new
if not is_valid_poster(old):
# Poster is not valid, convert it and place it in the new location. Conversion removes `old`, so its
# Tags Directory links are found now and removed only once the conversion succeeds.
old_links = video.file_group.get_tag_directory_links(old) if old != new else []
try:
convert_image(old, new)
if not new.is_file() or not new.stat().st_size:
raise FileNotFoundError(f'Failed to convert poster: {new}')
for link in old_links:
link.unlink(missing_ok=True)
logger.info(f'Converted invalid poster {repr(str(old))} to {repr(str(new))}')
return new, None
except Exception as e:
logger.error(f'Failed to convert invalid poster {old} to {new}', exc_info=e)
return None, None
if not video.poster_path:
# Video poster was not discovered, or converted. Let's generate it.
try:
poster_path, duration = generate_video_poster(video_path)
logger.debug(f'Generated poster for {video}')
return poster_path, duration
except Exception as e:
logger.error(f'Failed to generate poster for {video}', exc_info=e)
return None, None
@dataclass
class ChannelDictValidator:
name: str
directory: str
download_frequency: int = 604800 # Default to weekly
url: str = None
def _channel_key(data: dict) -> str:
"""A Channel's key: its directory, relative to the media directory."""
if not isinstance(data, dict) or not data.get('directory'):
raise ValueError('A Channel requires a directory')
return media_relative(validate_collection_directory(data['directory']))
# The key of a channel Collection without a Channel or a directory.
ORPHAN_KEY = '\0orphan:'
# Missing from an entry: the Channel keeps the database's value.
CHANNEL_KEPT_IF_MISSING = ('source_id', 'generate_posters', 'calculate_duration', 'download_missing_data')
CHANNEL_COMPARED = ('name', 'url', 'tag_name', 'file_format') + CHANNEL_KEPT_IF_MISSING
def _channel_row(name, url, source_id, tag_name, file_format, generate_posters, calculate_duration,
download_missing_data) -> dict:
return dict(name=name, url=url or None, source_id=source_id or None, tag_name=tag_name or None,
file_format=file_format or None, generate_posters=generate_posters,
calculate_duration=calculate_duration, download_missing_data=download_missing_data)
class ChannelsSection(ConfigSection):
"""channels.yaml's Channels (a Collection with kind 'channel' and its Channel), keyed by directory. A channel
Collection without a Channel is in the database's rows too: listed, it gets its Channel; not listed, it is
deleted."""
name = 'channels'
def file_rows(self, config: dict) -> Tuple[Dict[str, dict], Set[str]]:
rows, invalid = dict(), set()
for position, data in enumerate(config.get('channels') or []):
try:
key = _channel_key(data)
except Exception as e:
channel_import_logger.error(f'Skipping invalid Channel in config: {data!r}', exc_info=e)
# Counted as skipped (see `Unreadable`). The entry's name keeps its Channel (see `prepare`).
invalid.add(Unreadable(position))
continue
if key in rows or key in invalid:
raise RuntimeError('Refusing to import channels config because it contains duplicate directories!')
if not data.get('name'):
channel_import_logger.error(f'Channel {key!r} is required to have "name", skipping')
invalid.add(key)
continue
rows[key] = _channel_row(data['name'], data.get('url'), data.get('source_id'), data.get('tag_name'),
data.get('file_format'), data.get('generate_posters'),
data.get('calculate_duration'), data.get('download_missing_data'))
return rows, invalid
def db_rows(self, session: Session) -> Dict[str, dict]:
query = session.query(Collection.directory, Collection.name, Channel.url, Channel.source_id, Tag.name,
Collection.file_format, Channel.generate_posters, Channel.calculate_duration,
Channel.download_missing_data, Collection.id, Channel.id) \
.select_from(Collection) \
.outerjoin(Channel, Channel.collection_id == Collection.id) \
.outerjoin(Tag, Tag.id == Collection.tag_id) \
.filter(Collection.kind == 'channel')
rows = dict()
for directory, *values, collection_id, channel_id in query:
if directory:
key = media_relative(directory)
elif channel_id is None:
# An orphan without a directory: no entry can list it (`ORPHAN_KEY`).
key = f'{ORPHAN_KEY}{collection_id}'
else:
continue
rows[key] = row = _channel_row(*values)
if channel_id is None:
row['orphan'] = True
return rows
def same(self, file_row: dict, db_row: dict) -> bool:
if db_row.get('orphan') or file_row.get('directory'):
return False
return all(file_row[i] == db_row[i] for i in CHANNEL_COMPARED
if not (i in CHANNEL_KEPT_IF_MISSING and file_row[i] is None))
def upsert(self, session: Session, rows: Dict[str, dict]):
media_directory = get_media_directory()
collections = {media_relative(i.directory): i for i in session.query(Collection).filter(
Collection.kind == 'channel', Collection.directory.in_([str(media_directory / i) for i in rows]))}
channels = {i.collection_id: i for i in session.query(Channel).filter(
Channel.collection_id.in_([i.id for i in collections.values()]))}
tags = {i.name: i for i in
session.query(Tag).filter(Tag.name.in_(list({i['tag_name'] for i in rows.values()} - {None})))}
for key, row in rows.items():
if not (collection := collections.get(key)):
collection = Collection(kind='channel')
session.add(collection)
# A moved Channel's new directory (see `ChannelsConfig.prepare`).
collection.directory = media_directory / row.get('directory', key)
collection.name = row['name']
collection.tag = tags.get(row['tag_name'])
collection.file_format = row['file_format']
if not (channel := channels.get(collection.id) if collection.id else None):
channel = Channel(collection=collection)
session.add(channel)
channel.url = row['url']
for name in CHANNEL_KEPT_IF_MISSING:
if row[name] is not None:
setattr(channel, name, row[name])
session.flush()
def delete(self, session: Session, keys: Set[str]):
media_directory = get_media_directory()
orphan_ids = [int(i.removeprefix(ORPHAN_KEY)) for i in keys if i.startswith(ORPHAN_KEY)]
collections = session.query(Collection).filter(
Collection.kind == 'channel',
Collection.directory.in_([str(media_directory / i) for i in keys if not i.startswith(ORPHAN_KEY)])
| Collection.id.in_(orphan_ids)).all()
channels = {i.collection_id: i for i in session.query(Channel).filter(
Channel.collection_id.in_([i.id for i in collections]))}
for collection in collections:
if channel := channels.get(collection.id):
logger.warning(f'Deleting {channel} because it is not in the config.')
channel.delete_with_videos(commit=False)
else:
logger.warning(f'Deleting orphaned Collection {collection.name} (id={collection.id})')
session.delete(collection)
session.flush()
class ChannelDownloadsSection(ConfigSection):
"""The Downloads listed under each Channel, keyed by (Channel directory, URL). An existing Download keeps its own
frequency (download_manager.yaml's); the file's is used to create one."""
name = 'downloads'
def file_rows(self, config: dict) -> Tuple[Dict[tuple, dict], Set[tuple]]:
rows, invalid = dict(), set()
for data in config.get('channels') or []:
try:
key = _channel_key(data)
except Exception:
continue
downloads = data.get('downloads') or []
if not isinstance(downloads, list):
channel_import_logger.error(f'Skipping the Downloads of Channel {key!r}, they are not a list')
# Counted as skipped; the Channel keeps its Downloads (see `ChannelsConfig.prepare`).
invalid.add((key, '\0downloads'))
continue
for position, download in enumerate(downloads):
if isinstance(download, dict) and download.get('url') and isinstance(download['url'], str):
rows[(key, download['url'])] = dict(frequency=download.get('frequency'))
else:
channel_import_logger.error(f'Skipping invalid Download of Channel {key!r}: {download!r}')
# Counted as skipped; the Channel keeps its Downloads (see `ChannelsConfig.prepare`).
invalid.add((key, download if isinstance(download, str) else f'\0{position}'))
return rows, invalid
def present(self, config: dict, file_rows: dict, invalid_keys: set) -> bool:
# A Channel listed with an empty list of Downloads has none (see `ChannelsConfig.prepare` for entries without
# a list).
return bool(config.get('channels'))
def db_rows(self, session: Session) -> Dict[tuple, dict]:
query = session.query(Collection.directory, Download.url, Download.frequency) \
.join(Collection, Collection.id == Download.collection_id) \
.filter(Collection.kind == 'channel', Collection.directory.isnot(None))
return {(media_relative(directory), url): dict(frequency=frequency) for directory, url, frequency in query}
def same(self, file_row: dict, db_row: dict) -> bool:
return True
def upsert(self, session: Session, rows: Dict[tuple, dict]):
media_directory = get_media_directory()
channels = {media_relative(i.directory): i for i in session.query(Channel).join(Collection).filter(
Collection.directory.in_([str(media_directory / i) for i, _ in rows]))}
for (key, url), row in rows.items():
if not (channel := channels.get(key)):
channel_import_logger.warning(f'Cannot find Channel {key!r} for Download {url}')
continue
if existing := Download.get_by_url(session, url):
existing.collection_id = channel.collection_id
elif row['frequency']:
channel.get_or_create_download(session, url, row['frequency'], reset_attempts=True, commit=False)
else:
logger.error(f'Refusing to create Download for Channel without frequency: {url}')
session.flush()
def delete(self, session: Session, keys: Set[tuple]):
media_directory = get_media_directory()
query = session.query(Download, Collection.directory) \
.join(Collection, Collection.id == Download.collection_id) \
.filter(Collection.kind == 'channel', Download.url.in_([url for _, url in keys]))
for download, directory in query.all():
if (media_relative(directory), download.url) in keys:
logger.warning(f'Deleting {download} because it is no longer in its Channel')
download.delete(add_to_skip_list=False, commit=False)
@dataclass
class ChannelsConfigValidator:
version: int = None
channels: list[ChannelDictValidator] = dataclasses.field(default_factory=list)
class ChannelsConfig(ConfigFile):
file_name = 'channels.yaml'
sections = (ChannelsSection(), ChannelDownloadsSection())
default_config = dict(
channels=[dict(
name='WROLPi',
url='https://www.youtube.com/channel/UC4t8bw1besFTyjW7ZBCOIrw/videos',
directory='videos/wrolpi',
download_frequency=604800,
)],
version=0,
)
validator = ChannelsConfigValidator
@property
def channels(self) -> dict:
return self._config['channels']
@channels.setter
def channels(self, value: dict):
self.update({'channels': value})
def import_config(self, file: pathlib.Path = None, send_events=False):
self._config_outdated = False
super().import_config(file, send_events)
def prepare(self, diffs: dict):
channels, downloads = diffs['channels'], diffs['downloads']
db_rows = self.import_db_rows['channels']
# A Tag that no longer exists (it was removed) is dropped, and the config is saved without it.
rows = [*channels.added.values(), *channels.changed.values()]
unknown = unknown_tag_names({i['tag_name'] for i in rows if i['tag_name']})
for row in rows:
if row['tag_name'] in unknown:
channel_import_logger.warning(f'Removing unknown Tag {row["tag_name"]!r} from a Channel')
row['tag_name'] = None
self._config_outdated = True
# A Channel whose directory changed (its name did not) is moved, not deleted and re-created.
removed_by_name = dict()
for key in channels.removed:
removed_by_name.setdefault(db_rows[key]['name'], []).append(key)
moves = dict()
for key, row in list(channels.added.items()):
if len(old := removed_by_name.get(row['name']) or []) == 1:
channels.removed.discard(old[0])
del channels.added[key]
channels.changed[old[0]] = dict(row, directory=key)
moves[old[0]] = key
# An invalid entry keeps its Channel (found by the entry's name when its directory is invalid), and the
# Channel's Downloads; so does an entry without a list of Downloads (an old config), or with an unreadable
# Download.
entries = [i for i in self._config.get('channels') or [] if isinstance(i, dict)]
invalid_names = {i.get('name') for i in entries if i.get('name')} - {i['name'] for i in self._channel_rows()}
for key in [i for i in channels.removed if db_rows[i]['name'] in invalid_names]:
channels.removed.discard(key)
kept = set(db_rows) - set(self._channel_rows_by_key()) - channels.removed - set(moves)
kept |= {key for key, data in self._channel_entries() if 'downloads' not in data}
kept |= {directory for directory, _ in ChannelDownloadsSection().file_rows(self._config)[1]}
# A moved Channel's Downloads are deleted after the move, under its new directory; one its new entry lists
# is the same Download.
for directory, url in [i for i in downloads.removed if i[0] in moves]:
downloads.removed.discard((directory, url))
if (moves[directory], url) in downloads.added:
del downloads.added[(moves[directory], url)]
else:
downloads.removed.add((moves[directory], url))
downloads.removed = {i for i in downloads.removed if i[0] not in kept}
# A new Download needs a frequency.
if missing := {url for (_, url), row in downloads.added.items() if not row['frequency']}:
with get_db_session() as session:
missing -= {i for i, in session.query(Download.url).filter(Download.url.in_(list(missing)))}
for key in [i for i in downloads.added if i[1] in missing]:
channel_import_logger.error(f'Refusing to create Download for Channel without frequency: {key[1]}')
del downloads.added[key]
self.import_skipped += 1
def _channel_entries(self) -> List[Tuple[str, dict]]:
"""The valid entries of the file, keyed by directory."""
entries = list()
for data in self._config.get('channels') or []:
try:
entries.append((_channel_key(data), data))
except Exception:
pass
return entries
def _channel_rows_by_key(self) -> dict:
return ChannelsSection().file_rows(self._config)[0]
def _channel_rows(self) -> list:
return list(self._channel_rows_by_key().values())
def after_import(self, diffs: dict):
"""Claim the Videos of new and changed Channels, find their missing source ids, and link Downloads."""
changed = {row.get('directory', key) for key, row in
{**diffs['channels'].added, **diffs['channels'].changed}.items()}
if changed:
media_directory = get_media_directory()
with get_db_session() as session:
updated = session.query(Channel.id, Channel.url, Channel.source_id, Channel.download_missing_data) \
.join(Collection, Collection.id == Channel.collection_id) \
.filter(Collection.directory.in_([str(media_directory / i) for i in changed])).all()
from modules.videos import claim_videos_for_channels
claim_videos_for_channels([i.id for i in updated])
# A fetched source id changes channels.yaml, which cannot be saved while it has skipped entries.
for id_, url, source_id, download_missing_data in [] if self.import_skipped else updated:
if not source_id and url and download_missing_data and flags.have_internet.is_set() \
and download_manager.can_download:
channel_import_logger.info(f'Fetching channel source id for Channel id={id_}')
background_task(fetch_channel_source_id(id_))
if self.import_skipped:
# Linking changes channels.yaml, which cannot be saved while it has skipped entries: the database would be
# newer than the file, and the next startup would save over them.
channel_import_logger.warning('Not linking Downloads to Channels while channels.yaml has skipped entries')
else:
with get_db_session() as session:
links = channel_download_links(session)
if links:
with get_db_session(commit=True) as session:
_link_downloads(session, links)
self._config_outdated = True
if self._config_outdated:
self.background_dump.activate_switch()
if diffs['downloads']:
# The import's Downloads changed download_manager.yaml; saved after the import's write commits.
save_downloads_config.activate_switch()
def dump_config(self, file: pathlib.Path = None, send_events=False, overwrite=False):
"""Dump all channels from database to config file."""
from wrolpi.collections import Collection
channel_import_logger.info('Dumping channels to config')
with get_db_session() as session:
version = self.db_version_for_dump(session)
# Get all channels, ordered by collection name
channels = session.query(Channel).join(Collection).order_by(Collection.name).all()
# Use config_view to export each channel
channels_data = [channel.config_view() for channel in channels]
self._config['channels'] = channels_data
self._config['version'] = version
channel_import_logger.info(f'Dumping {len(channels_data)} channels to config')
self.save(file, send_events, overwrite)
def preview_backup_import(self, backup_date: str, mode: str) -> dict:
backup_file = self._get_backup_file(backup_date)
backup_data = self.read_config_file(backup_file)
current_data = self.read_config_file() if self.get_file().is_file() else dict(channels=[], version=0)
backup_channels = backup_data.get('channels', [])
current_channels = current_data.get('channels', [])
current_dirs = {ch.get('directory') for ch in current_channels if ch.get('directory')}
backup_dirs = {ch.get('directory') for ch in backup_channels if ch.get('directory')}
add = []
remove = []
unchanged = 0
for ch in backup_channels:
d = ch.get('directory')
if d and d not in current_dirs:
add.append(dict(type='channel', name=ch.get('name', ''), directory=d))
elif d:
unchanged += 1
if mode == 'overwrite':
backup_dir_to_name = {ch.get('directory'): ch.get('name', '') for ch in current_channels}
for d in sorted(current_dirs - backup_dirs):
remove.append(dict(type='channel', name=backup_dir_to_name.get(d, ''), directory=d))
return dict(mode=mode, add=add, remove=remove, unchanged=unchanged)
def import_backup(self, backup_date: str, mode: str, send_events: bool = False):
self._preserve_current_config()
import shutil
backup_file = self._get_backup_file(backup_date)
config_file = self.get_file()
if mode == 'overwrite':
config_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(backup_file, config_file)
elif mode == 'merge':
backup_data = self.read_config_file(backup_file)
current_data = self.read_config_file() if config_file.is_file() else dict(channels=[], version=0)
current_dirs = {ch.get('directory') for ch in current_data.get('channels', []) if ch.get('directory')}
merged_channels = list(current_data.get('channels', []))
for ch in backup_data.get('channels', []):
if ch.get('directory') and ch['directory'] not in current_dirs:
merged_channels.append(ch)
merged_data = dict(
channels=merged_channels,
version=current_data.get('version', 0) + 1,
)
config_file.parent.mkdir(parents=True, exist_ok=True)
self.write_config_data(merged_data, config_file)
# Importing a backup is the user's choice: apply it whatever the versions.
self.import_forced(send_events=send_events)
CHANNELS_CONFIG: ChannelsConfig = ChannelsConfig()
# Only the columns channels.yaml saves (not a refresh's counts, info_json, ...); its Collection saves the rest.
register_ownership(ChannelsConfig.file_name, Channel,
columns={'url', 'source_id', 'generate_posters', 'calculate_duration', 'download_missing_data',
'collection_id'})
TEST_CHANNELS_CONFIG: ChannelsConfig = None
def get_channels_config() -> ChannelsConfig:
global TEST_CHANNELS_CONFIG
if PYTEST and not TEST_CHANNELS_CONFIG:
logger.warning('Test did not initialize the channels config')
return
if TEST_CHANNELS_CONFIG:
return TEST_CHANNELS_CONFIG
global CHANNELS_CONFIG
return CHANNELS_CONFIG
@contextlib.contextmanager
def set_test_channels_config():
global TEST_CHANNELS_CONFIG
TEST_CHANNELS_CONFIG = ChannelsConfig()
TEST_CHANNELS_CONFIG.initialize()
yield TEST_CHANNELS_CONFIG
TEST_CHANNELS_CONFIG = None
@dataclass
class VideoDownloaderConfigYtDlpOptionsValidator:
continue_dl: bool
file_name_format: str
nooverwrites: bool
quiet: bool
merge_output_format: str
writeautomaticsub: bool
writeinfojson: bool
writesubtitles: bool
writethumbnail: bool
youtube_include_dash_manifest: bool
def __post_init__(self):
from modules.videos.downloader import preview_filename
try:
preview_filename(self.file_name_format)
except Exception as e:
raise ValueError(f'file_name_format is invalid: {str(e)}')
@dataclass
class VideoDownloaderConfigValidator:
video_resolutions: List[str] = field(default_factory=lambda: ['1080p', '720p', '480p', 'maximum'])
version: int = None
yt_dlp_options: VideoDownloaderConfigYtDlpOptionsValidator = field(default_factory=dict)
yt_dlp_extra_args: str = ''
download_missing_video_comments: bool = False
user_agent: str = ''
sleep_requests: float = 0.75
audio_only: bool = False
audio_format: str = 'mp3'
video_codecs: List[str] = field(default_factory=list)
audio_codecs: List[str] = field(default_factory=list)
transcode: bool = False
strict_codecs: bool = False
def __post_init__(self):
allowed_fields = {i.name for i in dataclasses.fields(VideoDownloaderConfigYtDlpOptionsValidator)}
yt_dlp_options = {k: v for k, v in dict(self.yt_dlp_options).items() if k in allowed_fields}
VideoDownloaderConfigYtDlpOptionsValidator(**yt_dlp_options)
self.yt_dlp_options = yt_dlp_options
class VideoDownloaderConfig(ConfigFile):
file_name = 'videos_downloader.yaml'
default_config = dict(
video_resolutions=['1080p', '720p', '480p', 'maximum'],
version=0,
yt_dlp_options=dict(
continue_dl=True,
file_name_format='%(uploader)s_%(upload_date)s_%(id)s_%(title)s.%(ext)s',
merge_output_format='mp4',
nooverwrites=True,
quiet=False,
writeautomaticsub=True,
writeinfojson=True,
writesubtitles=True,
writethumbnail=True,
youtube_include_dash_manifest=False,
),
yt_dlp_extra_args='',
download_missing_video_comments=False,
user_agent='',
sleep_requests=0.75,
audio_only=False,
audio_format='mp3',
video_codecs=[],
audio_codecs=[],
transcode=False,
strict_codecs=False,
)
validator = VideoDownloaderConfigValidator
@property
def continue_dl(self) -> bool:
return self._config['yt_dlp_options']['continue_dl']
@property
def video_resolutions(self) -> List[str]:
return self._config['video_resolutions']
@video_resolutions.setter
def video_resolutions(self, value: List[str]):
self.update({'video_resolutions': value})
@property
def file_name_format(self) -> str:
return self._config['yt_dlp_options']['file_name_format']
@property
def nooverwrites(self) -> bool:
return self._config['yt_dlp_options']['nooverwrites']
@property
def merge_output_format(self) -> str:
return self._config['yt_dlp_options']['merge_output_format']
@property
def video_format(self) -> str:
"""Alias for merge_output_format, matching the name used in Download.settings."""
return self.merge_output_format
@property
def quiet(self) -> bool:
return self._config['yt_dlp_options']['quiet']
@property
def writeautomaticsub(self) -> bool:
return self._config['yt_dlp_options']['writeautomaticsub']
@property
def writeinfojson(self) -> bool:
return self._config['yt_dlp_options']['writeinfojson']
@property
def writesubtitles(self) -> bool:
return self._config['yt_dlp_options']['writesubtitles']
@property
def writethumbnail(self) -> bool:
return self._config['yt_dlp_options']['writethumbnail']
@property
def youtube_include_dash_manifest(self) -> bool:
return self._config['yt_dlp_options']['youtube_include_dash_manifest']
@property
def yt_dlp_options(self) -> dict:
return self._config['yt_dlp_options']
@yt_dlp_options.setter
def yt_dlp_options(self, value: dict):
self.update({'yt_dlp_options': value})
@property
def yt_dlp_extra_args(self) -> str:
return self._config['yt_dlp_extra_args']
@yt_dlp_extra_args.setter
def yt_dlp_extra_args(self, value: str):
self.update({**self._config, 'yt_dlp_extra_args': value})
@property
def download_missing_video_comments(self) -> bool:
return self._config['download_missing_video_comments']
@download_missing_video_comments.setter
def download_missing_video_comments(self, value: bool):
self.update({**self._config, 'download_missing_video_comments': value})
@property
def user_agent(self) -> str:
return self._config.get('user_agent', '')
@user_agent.setter
def user_agent(self, value: str):
self.update({**self._config, 'user_agent': value})
@property
def sleep_requests(self) -> float:
try:
return float(self._config.get('sleep_requests', 0.75))
except (TypeError, ValueError):
return 0.75
@sleep_requests.setter
def sleep_requests(self, value: float):
self.update({**self._config, 'sleep_requests': value})
@property
def audio_only(self) -> bool:
return self._config.get('audio_only', False)
@audio_only.setter
def audio_only(self, value: bool):
self.update({**self._config, 'audio_only': value})
@property
def audio_format(self) -> str:
return self._config.get('audio_format', 'mp3')
@audio_format.setter
def audio_format(self, value: str):
self.update({**self._config, 'audio_format': value})
@property
def video_codecs(self) -> List[str]:
return self._config.get('video_codecs', [])
@video_codecs.setter
def video_codecs(self, value: List[str]):
self.update({**self._config, 'video_codecs': value})
@property
def audio_codecs(self) -> List[str]:
return self._config.get('audio_codecs', [])
@audio_codecs.setter
def audio_codecs(self, value: List[str]):
self.update({**self._config, 'audio_codecs': value})
@property
def transcode(self) -> bool:
return self._config.get('transcode', False)
@transcode.setter
def transcode(self, value: bool):
self.update({**self._config, 'transcode': value})
@property
def strict_codecs(self) -> bool:
return self._config.get('strict_codecs', False)
@strict_codecs.setter
def strict_codecs(self, value: bool):
self.update({**self._config, 'strict_codecs': value})
VIDEOS_DOWNLOADER_CONFIG: VideoDownloaderConfig = VideoDownloaderConfig()
TEST_VIDEOS_DOWNLOADER_CONFIG: VideoDownloaderConfig = None
def get_videos_downloader_config() -> VideoDownloaderConfig:
global TEST_VIDEOS_DOWNLOADER_CONFIG
if isinstance(TEST_VIDEOS_DOWNLOADER_CONFIG, VideoDownloaderConfig):
return TEST_VIDEOS_DOWNLOADER_CONFIG
global VIDEOS_DOWNLOADER_CONFIG
return VIDEOS_DOWNLOADER_CONFIG
def set_test_downloader_config(enabled: bool):
global TEST_VIDEOS_DOWNLOADER_CONFIG
TEST_VIDEOS_DOWNLOADER_CONFIG = VideoDownloaderConfig() if enabled else None
# Names of settings that can be overridden at channel/RSS or video level.
# Actual default values come from VideoDownloaderConfig.
INHERITABLE_VIDEO_SETTINGS = {
'video_resolutions', 'video_format', 'writesubtitles', 'writeautomaticsub',
'writethumbnail', 'writeinfojson', 'yt_dlp_extra_args', 'sleep_requests',
'user_agent', 'continue_dl', 'nooverwrites', 'audio_only', 'audio_format',
'video_codecs', 'audio_codecs', 'transcode', 'strict_codecs',
}
# Subset of inheritable settings allowed at channel/RSS level.
# continue_dl and nooverwrites are global+video only.
CHANNEL_INHERITABLE_SETTINGS = INHERITABLE_VIDEO_SETTINGS - {'continue_dl', 'nooverwrites'}
def _extract_global_inheritable(config: VideoDownloaderConfig) -> dict:
"""Read current global config values for all inheritable settings, flattened."""
return {
'video_resolutions': config.video_resolutions,
'video_format': config.merge_output_format,
'writesubtitles': config.writesubtitles,
'writeautomaticsub': config.writeautomaticsub,
'writethumbnail': config.writethumbnail,
'writeinfojson': config.writeinfojson,
'yt_dlp_extra_args': config.yt_dlp_extra_args,
'sleep_requests': config.sleep_requests,
'user_agent': config.user_agent,
'continue_dl': config.continue_dl,
'nooverwrites': config.nooverwrites,
'audio_only': config.audio_only,
'audio_format': config.audio_format,
'video_codecs': config.video_codecs,
'audio_codecs': config.audio_codecs,
'transcode': config.transcode,
'strict_codecs': config.strict_codecs,
}
def get_effective_video_settings(download_settings: dict = None) -> dict:
"""Merge global defaults with download-level overrides.
At download time: effective = {**global, **download_settings}
Only inheritable keys present in download_settings override the global value.
"""
config = get_videos_downloader_config()
effective = _extract_global_inheritable(config)
if download_settings:
for key in INHERITABLE_VIDEO_SETTINGS:
if key in download_settings:
effective[key] = download_settings[key]
# Safety net for already-persisted downloads that may contain string values
# for numeric settings (from before the DownloadSettings coercion fix).
# This is the single chokepoint so downstream code can assume correct types.
if effective.get('sleep_requests') not in (None, ''):
try:
effective['sleep_requests'] = float(effective['sleep_requests'])
except (TypeError, ValueError):
# Remove corrupt value so the global config default (already populated
# earlier via _extract_global_inheritable) takes effect.
del effective['sleep_requests']
return effective
def get_yt_dlp_http_headers() -> dict:
"""Get HTTP headers for yt-dlp, including user-agent if configured.
This should be used when cookies are being used to ensure the user-agent
matches the browser that exported the cookies."""
config = get_videos_downloader_config()
if config.user_agent:
return {'User-Agent': config.user_agent}
return {}
def get_yt_dlp_sleep_opts() -> dict:
"""Get sleep options for yt-dlp to avoid rate limiting.
This adds a delay between HTTP requests during metadata extraction,
helping avoid bot detection. Does not affect video file download speed."""
config = get_videos_downloader_config()
try:
if float(config.sleep_requests) > 0:
return {'sleep_requests': config.sleep_requests}
except (TypeError, ValueError):
pass
return {}
def get_channels_config_from_db(session: Session) -> dict:
"""Create a dictionary that contains all the Channels from the DB."""
from wrolpi.collections import Collection
channels = session.query(Channel).join(Collection).order_by(Collection.directory).all()
channels = sorted((i.config_view() for i in channels), key=lambda i: i['directory'] if i['directory'] else '')
return dict(channels=channels)
@register_switch_handler('save_channels_config')
def save_channels_config():
"""Get the Channel information from the DB, save it to the config."""
with get_db_session() as session:
config = get_channels_config_from_db(session)
channels_config = get_channels_config()
if PYTEST and not channels_config:
logger.warning('Refusing to save channels config because test did not initialize a test config!')
return
if not config.get('channels') and channels_config.channels:
logger.warning('Refusing to save empty channels config because channels exist in the config file!')
return
channels_config.update(config)
logger.info('save_channels_config completed')
save_channels_config: ActivateSwitchMethod
channel_import_logger = logger.getChild('channel_import')
async def fetch_channel_source_id(channel_id: int):
# If we can download from a channel, we must have its source_id.
try:
with get_db_session() as session:
channel = Channel.find_by_id(session, channel_id)
source_id, url = channel.source_id, channel.url
if source_id:
return
# Fetched outside any transaction: this asks the remote site.
source_id = get_channel_source_id(url)
if not source_id:
channel_import_logger.warning(f'Unable to fetch source_id for {url}')
return
with get_db_session(commit=True) as session:
Channel.find_by_id(session, channel_id).source_id = source_id
# Dumped with the database's version (see wrolpi.config_version).
if config := get_channels_config():
config.background_dump.activate_switch()
except Exception as e:
logger.error(f'Failed to get Channel source id of id={channel_id}', exc_info=e)
def import_channels_config():
"""Import channel settings to the DB. Existing channels will be updated."""
if PYTEST and not get_channels_config():
logger.warning('Skipping import_channels_config for this test')
return
channel_import_logger.info('Importing channels config')
get_channels_config().import_config()
channel_import_logger.info('Importing channels config completed')
def channel_download_links(session: Session) -> Dict[int, int]:
"""The recurring Downloads without a Collection which belong to a Channel: {Download id: Collection id}.
A Download belongs to the Channel whose directory is its destination, whose URL (or RSS feed) is its URL, or whose
source id is the channel id of its info json."""
channels = session.query(Channel).options(joinedload(Channel.collection)).all()
by_directory = {str(i.directory): i.collection_id for i in channels if i.directory}
by_url = {i.url: i.collection_id for i in channels if i.url}
for channel in channels:
if rss_url := channel.get_rss_url():
by_url.setdefault(rss_url, channel.collection_id)
by_source_id = {i.source_id: i.collection_id for i in channels if i.source_id}
links = dict()
downloads = session.query(Download.id, Download.url, Download.destination, Download.settings, Download.info_json) \
.filter(Download.frequency.isnot(None), Download.collection_id.is_(None))
for id_, url, destination, settings, info_json in downloads:
destination = str(destination) if destination else (settings or dict()).get('destination')
collection_id = (by_directory.get(destination) if destination else None) or by_url.get(url)
if not collection_id:
info_json = info_json or dict()
if source_id := info_json.get('channel_id') or info_json.get('id'):
collection_id = by_source_id.get(source_id)
if collection_id:
links[id_] = collection_id
return links
def _link_downloads(session: Session, links: Dict[int, int]):
for ids in chunks(list(links), 500):
for download in session.query(Download).filter(Download.id.in_(ids)):
download.collection_id = links[download.id]
session.flush()
def link_channel_and_downloads(session: Session):
"""Associate any Download related to a Channel via its Collection (see `channel_download_links`)."""
if links := channel_download_links(session):
_link_downloads(session, links)
session.commit()
YDL = YoutubeDL(dict(cachedir=YTDLP_CACHE_DIR))
ydl_logger = YDL.params['logger'] = logger.getChild('youtube-dl')
YDL.add_default_info_extractors()
def get_channel_source_id(url: str) -> str:
"""Get the source ID for a channel URL."""
if cookies_unlocked():
logger.info(f'Using encrypted cookies for channel source id: {url}')
with cookies_for_download() as cookies_path:
ydl_opts = dict(
cachedir=YTDLP_CACHE_DIR,
cookiefile=str(cookies_path),
**get_yt_dlp_sleep_opts(),
)
http_headers = get_yt_dlp_http_headers()
if http_headers:
ydl_opts['http_headers'] = http_headers
ydl = YoutubeDL(ydl_opts)
ydl.params['logger'] = logger.getChild('youtube-dl')
ydl.add_default_info_extractors()
channel_info = ydl.extract_info(url, download=False, process=False)
return channel_info.get('channel_id') or channel_info['uploader_id']
logger.info(f'No cookies available for channel source id: {url}')
ydl_opts = dict(
cachedir=YTDLP_CACHE_DIR,
**get_yt_dlp_sleep_opts(),
)
ydl = YoutubeDL(ydl_opts)
ydl.params['logger'] = logger.getChild('youtube-dl')
ydl.add_default_info_extractors()
channel_info = ydl.extract_info(url, download=False, process=False)
return channel_info.get('channel_id') or channel_info['uploader_id']
async def get_statistics(session: Session):
with session_curs(session) as curs:
curs.execute('''
SELECT
-- total videos
COUNT(v.id) AS "videos",
-- total videos downloaded over the past week/month/year
COUNT(v.id) FILTER (WHERE published_datetime >= datetime('now', '-7 days')) AS "week",
COUNT(v.id) FILTER (WHERE published_datetime >= datetime('now', '-1 month')) AS "month",
COUNT(v.id) FILTER (WHERE published_datetime >= datetime('now', '-1 year')) AS "year",
-- sum of all video lengths in seconds
COALESCE(SUM(fg.length), 0) AS "sum_duration",
-- sum of all video file sizes
COALESCE(SUM(fg.size), 0) AS "sum_size",
-- largest video
COALESCE(MAX(fg.size), 0) AS "max_size",
-- Videos may or may not have comments.
COUNT(v.id) FILTER ( WHERE v.have_comments = TRUE ) AS "have_comments",
COUNT(v.id) FILTER ( WHERE v.have_comments = FALSE AND v.comments_failed = FALSE
and fg.censored = false and
fg.url is not null) AS "missing_comments",
COUNT(v.id) FILTER ( WHERE v.comments_failed = TRUE ) AS "failed_comments",
COUNT(v.id) FILTER ( WHERE fg.censored = TRUE ) as "censored_videos"
FROM video v
LEFT JOIN file_group fg on v.file_group_id = fg.id
''')
video_stats = dict(curs.fetchone())
# Get the total videos downloaded every month for the past two years.
# (Not parameterized, so `%` needs no escaping.) Like the old Postgres query, months
# without any videos are absent from the results.
curs.execute('''
SELECT strftime('%Y-%m-01 00:00:00', fg.published_datetime) AS "month",
COUNT(video.id) AS "count",
SUM(fg.size) AS "size"
FROM video
LEFT JOIN file_group fg on video.file_group_id = fg.id
WHERE fg.published_datetime IS NOT NULL
AND fg.published_datetime >= datetime('now', 'start of month', '-24 months')
AND fg.published_datetime < datetime('now', 'start of month')
GROUP BY 1
ORDER BY 1
''')
monthly_videos = [dict(i) for i in curs.fetchall()]
for monthly_video in monthly_videos:
# The frontend expects a datetime, not the TEXT that SQLite stores.
monthly_video['month'] = datetime.fromisoformat(monthly_video['month'])
historical_stats = dict(monthly_videos=monthly_videos)
historical_stats['average_count'] = (sum(i['count'] for i in monthly_videos) // len(monthly_videos)) \
if monthly_videos else 0
historical_stats['average_size'] = (sum(i['size'] for i in monthly_videos) // len(monthly_videos)) \
if monthly_videos else 0
curs.execute('''
SELECT COUNT(c.id) AS "channels",
COUNT(c.id) FILTER ( WHERE col.tag_id IS NOT NULL ) AS "tagged_channels"
FROM channel c
LEFT JOIN collection col on col.id = c.collection_id
LEFT JOIN tag t on t.id = col.tag_id
''')
channel_stats = dict(curs.fetchone())
ret = dict(statistics=dict(
videos=video_stats,
channels=channel_stats,
historical=historical_stats,
))
return ret
NAME_PARSER = re.compile(
r'(?:(.*?)_)?((?:\d+?)|(?:NA))_(?:(.{5,25})_)?(.*)'
# The file suffix. Every media suffix a Video FileGroup can lead with belongs here:
# audio is Channel content too, and a suffix missing from this list leaves the file
# without a published date, sorted to the end of its Channel.
r'\.(jpg|webp|flv|mp4|m4v|mkv|webm|mp3|flac|opus|ogg|m4a|wav|aac|wma'
r'|part|info\.json|description|..\.srt|..\.vtt)',
re.IGNORECASE)
def parse_video_file_name(video_path: pathlib.Path) -> \
Tuple[Optional[str], Optional[str], Optional[str], Optional[str]]:
"""
A Video's file name can have data in it, this attempts to extract what may be there.
{published_date} is assumed to always be %Y%m%d
Examples:
NA_{published_date}_{source_id}_{title}{ext}
{channel_name}_NA_{source_id}_{title}{ext}
{channel_name}_{published_date}_{source_id}_{title}{ext} # Typical name format from WROLPi.
{published_date}_{title}{ext}
{title}{ext}
"""
video_str = str(video_path)
if match := NAME_PARSER.match(video_str):
channel, date, source_id, title, _ = match.groups()
channel = None if channel == 'NA' else channel
date = None if date == 'NA' else date
title = title.strip()
if date == 'NA':
return channel, None, source_id, title
if date is None or (len(date) == 8 and date.isdigit()):
return channel, date, source_id, title
# Return the stem as a last resort
title, _ = split_path_stem_and_suffix(video_path)
return None, None, None, title.strip()
def get_video_metadata(video) -> dict:
"""Extract video metadata with fallback chain.
Fallback strategy:
1. Primary: video.channel.name for uploader (when video belongs to a channel)
2. Secondary: info_json + database fields (video.source_id, file_group.published_datetime)
3. Tertiary: parse_video_file_name() - ALL-OR-NOTHING, only if it provides channel AND date AND source_id
Returns:
dict with keys: upload_date (datetime|None), uploader (str), source_id (str), title (str)
"""
file_group = video.file_group
info_json = video.get_info_json() or {}
# Primary sources
upload_date = file_group.published_datetime
if not upload_date and info_json.get('upload_date'):
upload_date = dates.strpdate(info_json['upload_date'])
# Prioritize WROLPi channel name when video belongs to a channel
# This ensures reorganized files use the user's channel name, not the YouTube uploader name
if video.channel and video.channel.name:
uploader = video.channel.name
else:
uploader = info_json.get('uploader') or info_json.get('channel') or ''
source_id = video.source_id or info_json.get('id') or ''
title = file_group.title or info_json.get('title') or info_json.get('fulltitle') or 'untitled'
# Fallback: Parse filename - ALL-OR-NOTHING
# Only use if it provides ALL three fields: channel, date, source_id
if file_group.primary_path and not (upload_date and source_id and uploader):
parsed_channel, parsed_date, parsed_source_id, _ = parse_video_file_name(file_group.primary_path)
if parsed_channel and parsed_date and parsed_source_id:
if not upload_date:
upload_date = dates.strpdate(parsed_date)
if not source_id:
source_id = parsed_source_id
if not uploader:
uploader = parsed_channel
return {
'upload_date': upload_date,
'uploader': uploader,
'source_id': source_id,
'title': title,
}
def find_orphaned_video_files(directory: pathlib.Path) -> Generator[pathlib.Path, None, None]:
"""Finds all files which should be associated with a video file, but a video file does not match their stem.
Example:
video1.mp4, video1.info.json # Video file with info json.
video2.info.json # Orphaned video json.
"""
with get_db_curs() as curs:
if not directory.is_dir():
raise UnknownDirectory()
directory_str = str(directory).rstrip('/')
# Query returns directory and files - files may contain relative or absolute paths
# Use indexed directory column for efficient lookup
# (Not parameterized, so `%` needs no escaping.)
curs.execute(f'''
SELECT directory, files
FROM file_group
WHERE
mimetype NOT LIKE 'video%'
AND (directory = '{directory_str}' OR directory LIKE '{directory_str}/%')
''')
for row in curs.fetchall():
fg_directory = pathlib.Path(row[0]) if row[0] else None
files = row[1]
if isinstance(files, str):
# Raw cursor reads return the JSON column as TEXT.
files = json.loads(files)
for file_info in files:
path = pathlib.Path(file_info['path'])
if not path.is_absolute() and fg_directory:
# Resolve relative path using directory
path = fg_directory / path
yield path
def format_videos_destination(channel_name: str = None, channel_tag: str = None, channel_url: str = None) \
-> pathlib.Path:
"""Return the directory where Videos should be downloaded according to the WROLPi Config.
Note: When you have a Collection object, prefer using Collection.format_destination()
or Collection.get_or_set_directory() instead, as they provide unified behavior
across both channel and domain collections.
@warning: Directory may or may not exist."""
videos_destination = get_wrolpi_config().videos_destination
channel_domain = ''
if channel_url:
try:
channel_domain = extract_domain(channel_url)
except Exception as e:
logger.error(f'Failed to extract domain from Channel URL: {channel_url}', exc_info=e)
if PYTEST:
raise
if channel_name and not isinstance(channel_name, str):
raise RuntimeError('channel name must be string')
if channel_tag and not isinstance(channel_tag, str):
raise RuntimeError('channel tag must be string')
name = escape_file_name(channel_name) if channel_name else ''
variables = dict(
channel_name=name,
channel_tag=channel_tag or '',
channel_domain=channel_domain,
)
try:
videos_destination = videos_destination % variables
except KeyError as e:
msg = f'Cannot download to the "videos_destination" from Settings: {videos_destination}'
raise FileNotFoundError(msg) from e
videos_destination = get_media_directory() / videos_destination.lstrip('/')
return videos_destination
def format_video_filename(
video: Video,
file_name_format: str = None,
) -> str:
"""Single source of truth for video filenames.
Formats a video filename using the configured file_name_format template.
Used by both the video downloader (initial download) and collection reorganization.
Args:
video: Video model with metadata (upload_date, uploader, title, source_id)
file_name_format: Optional override, defaults to config value
Returns:
Formatted filename (may include subdirectory path like "2025/01/video.mp4")
Example:
With format '%(upload_year)s/%(upload_month)s/%(uploader)s_%(upload_date)s_%(id)s_%(title)s.%(ext)s':
Returns '2025/01/WROLPi_20250115_abc123_My Video Title.mp4'
"""
from modules.videos.downloader import convert_wrolpi_filename_format
config = get_videos_downloader_config()
template = file_name_format or config.file_name_format
# Get metadata with fallback chain
metadata = get_video_metadata(video)
upload_date = metadata['upload_date']
uploader = metadata['uploader']
source_id = metadata['source_id']
title = metadata['title']
# Get video file extension
video_path = video.video_path
ext = video_path.suffix.lstrip('.') if video_path else config.merge_output_format or 'mp4'
variables = dict(
uploader=escape_file_name(uploader) if uploader else '',
channel=escape_file_name(uploader) if uploader else '', # Alias
upload_date=upload_date.strftime('%Y%m%d') if upload_date else '',
id=source_id,
title=escape_file_name(title) if title else 'untitled',
ext=ext,
# WROLPi-specific variables for subdirectory organization
upload_year=str(upload_date.year) if upload_date else '',
upload_month=f'{upload_date.month:02d}' if upload_date else '',
)
# Convert WROLPi-specific variables to yt-dlp syntax then apply
# (This handles %(upload_year)s -> %(upload_date>%Y)s conversion)
converted_template = convert_wrolpi_filename_format(template)
# For the converted variables (like %(upload_date>%Y)s), we need to add them too
variables['upload_date>%Y'] = str(upload_date.year) if upload_date else ''
variables['upload_date>%m'] = f'{upload_date.month:02d}' if upload_date else ''
try:
result = converted_template % variables
# Trim the filename portion if it's too long (may include subdirectory paths)
if '/' in result:
# Has subdirectories - only trim the filename part
parts = result.rsplit('/', 1)
subdir = parts[0]
filename = trim_file_name(parts[1])
return f'{subdir}/{filename}'
else:
return trim_file_name(result)
except KeyError as e:
logger.error(f'Invalid variable in video file_name_format: {e}')
# Fallback to a simple format
fallback_title = escape_file_name(title) if title else 'untitled'
return trim_file_name(f'{fallback_title}.{ext}')