wrolpi/modules/videos/common.py

550 lines
21 KiB
Python

import json
import os
import pathlib
import subprocess
import tempfile
from functools import partial
from pathlib import Path
from typing import Union, Tuple, List, Set, Iterable
import PIL
from PIL import Image
from sqlalchemy.orm import Session
from wrolpi import before_startup
from wrolpi.common import sanitize_link, logger, CONFIG_PATH, get_config, iterify, chunks, get_media_directory, \
get_absolute_media_path, minimize_dict
from wrolpi.db import get_db_session, get_db_curs
from wrolpi.errors import UnknownFile, UnknownDirectory, ChannelNameConflict, ChannelURLConflict, \
ChannelLinkConflict, ChannelDirectoryConflict
from wrolpi.vars import DOCKERIZED, DEFAULT_FILE_PERMISSIONS
from .models import Channel, Video
logger = logger.getChild(__name__)
REQUIRED_OPTIONS = ['name', 'directory']
MINIMUM_CHANNEL_KEYS = {'id', 'name', 'directory', 'url', 'video_count', 'link'}
MINIMUM_INFO_JSON_KEYS = {'description', 'view_count'}
MINIMUM_VIDEO_KEYS = {'id', 'title', 'upload_date', 'duration', 'channel', 'channel_id', 'favorite', 'size',
'poster_path', 'caption_path', 'video_path', 'info_json', 'channel', 'viewed', 'source_id',
'view_count'}
VIDEO_QUERY_LIMIT = 20
VIDEO_QUERY_MAX_LIMIT = 100
# These are the supported video formats. These are in order of their preference.
VIDEO_EXTENSIONS = ('mp4', 'ogg', 'webm', 'flv')
def load_downloader_config() -> dict:
config = get_config()
downloader = config['downloader']
return downloader
def load_channels_config() -> dict:
config = get_config()
channels = config['channels']
return channels
def get_allowed_limit(limit: int) -> int:
"""
Return the video limit int if it was passed, unless it is over the maximum limit. If no limit is provided, return
the default limit.
"""
if limit:
return min(int(limit), VIDEO_QUERY_MAX_LIMIT)
return VIDEO_QUERY_LIMIT
class ConfigError(Exception):
pass
@before_startup
def import_videos_config():
"""Import channel settings to the DB. Existing channels will be updated."""
try:
config = load_channels_config()
with get_db_session(commit=True) as session:
for section in config:
for option in (i for i in REQUIRED_OPTIONS if i not in config[section]):
raise ConfigError(f'Channel "{section}" is required to have "{option}"')
name, directory = config[section]['name'], config[section]['directory']
link = sanitize_link(name)
matches = session.query(Channel).filter(Channel.link == link)
if not matches.count():
# Channel not yet in the DB, add it
channel = Channel(link=link)
else:
channel = matches.one()
# Only name and directory are required
channel.name = name
channel.directory = str(directory)
channel.calculate_duration = config[section].get('calculate_duration')
channel.download_frequency = config[section].get('download_frequency')
channel.generate_posters = config[section].get('generate_posters')
channel.match_regex = config[section].get('match_regex')
channel.skip_download_videos = list(set(config[section].get('skip_download_videos', {})))
channel.url = config[section].get('url')
session.add(channel)
# Set favorite Videos of this Channel.
favorites = config[section].get('favorites', {})
if favorites:
videos = session.query(Video).filter(Video.source_id.in_(favorites.keys()))
for video in videos:
video.favorite = favorites[video.source_id]['favorite']
except Exception:
logger.warning('Failed to load channels config!', exc_info=True)
VALID_VIDEO_KINDS = {'video', 'caption', 'poster', 'description', 'info_json'}
def get_absolute_video_path(video: Video, kind: str = 'video') -> Path:
if kind not in VALID_VIDEO_KINDS:
raise Exception(f'Unknown video path kind {kind}')
directory = get_absolute_media_path(video.channel.directory)
path = getattr(video, f'{kind}_path')
if directory and path:
return directory / path
raise UnknownFile()
get_absolute_video_caption = partial(get_absolute_video_path, kind='caption')
get_absolute_video_poster = partial(get_absolute_video_path, kind='poster')
get_absolute_video_description = partial(get_absolute_video_path, kind='description')
get_absolute_video_info_json = partial(get_absolute_video_path, kind='info_json')
def get_absolute_video_files(video: Video) -> List[Path]:
"""
Get all video files that exist.
"""
getters = [
get_absolute_video_description,
get_absolute_video_caption,
get_absolute_video_poster,
get_absolute_video_info_json,
get_absolute_video_path,
]
def _get():
for getter in getters:
try:
yield getter(video)
except UnknownFile:
pass
return list(_get())
def get_video_info_json(video) -> Union[dict, None]:
"""Get the info_json object from a video's meta-file. Return an empty dict if not possible."""
if not video.channel or not video.channel.directory:
return
try:
path = get_absolute_video_info_json(video)
if path.exists():
with open(str(path), 'rb') as fh:
contents = json.load(fh)
return contents
except UnknownFile:
return
except UnknownDirectory:
return
def any_extensions(filename: str, extensions: Iterable = ()):
"""
Return True only if the file name ends with any of the possible extensions.
Matches lower or upper case of the extension.
"""
return any(filename.lower().endswith(ext) for ext in extensions)
match_video_extensions = partial(any_extensions, extensions=VIDEO_EXTENSIONS)
def generate_video_paths(directory: Union[str, pathlib.Path], relative_to=None) -> Tuple[str, pathlib.Path]:
"""
Generate a list of video paths in the provided directory.
"""
directory = pathlib.Path(directory)
for child in directory.iterdir():
if child.is_file() and match_video_extensions(child.name):
child = child.absolute()
if relative_to:
yield child.relative_to(relative_to)
else:
yield child
elif child.is_dir():
yield from generate_video_paths(child)
@iterify(set)
def remove_duplicate_video_paths(paths: Iterable[Path]) -> Set[Path]:
"""
Remove any duplicate paths from a given list. Duplicate is defined as any file that shares the EXACT same
name as another file, but with a different extension. If a duplicate is found, only yield one of the paths. The
path that will be yielded will be whichever is first in the VIDEO_EXTESIONS tuple.
i.e.
>>> remove_duplicate_video_paths([Path('one.mp4'), Path('two.mp4'), Path('one.ogg')])
{'one.mp4, 'two.mp4'}
"""
new_paths = {}
# Group all paths by their name, but without their extension.
for path in set(paths):
name, _, _ = path.name.rpartition(path.suffix)
try:
new_paths[name].append(path)
except KeyError:
new_paths[name] = [path]
# Yield back the first occurrence of the preferred format for each video.
for name, paths in new_paths.items():
if len(paths) == 1:
# This should be the most common case. Most videos will only have one format.
yield paths[0]
else:
path_strings = [i.name for i in paths]
for ext in VIDEO_EXTENSIONS:
try:
index = path_strings.index(f'{name}.{ext}')
yield paths[index]
break
except ValueError:
# That extension is not in the paths.
pass
else:
# Somehow no format was found, yield back the first one. This is probably caused by an unexpected video
# format in the paths.
yield sorted(paths)[0]
def check_for_channel_conflicts(session: Session, id_=None, url=None, name=None, link=None, directory=None):
"""
Search for any channels that conflict with the provided args, raise a relevant exception if any conflicts are found.
"""
if not any([id_, url, name, link, directory]):
raise Exception('Cannot search for channel with no arguments')
logger.debug(f'Checking for channel conflicts: id={id_} url={url} name={name} link={link} directory={directory}')
# A channel can't conflict with itself
if id_:
base_where = session.query(Channel).filter(Channel.id != id_)
else:
base_where = session.query(Channel)
if url:
conflicts = base_where.filter(Channel.url == url)
if list(conflicts):
raise ChannelURLConflict()
if name:
conflicts = base_where.filter(Channel.name == name)
if list(conflicts):
raise ChannelNameConflict()
if link:
conflicts = base_where.filter(Channel.link == link)
if list(conflicts):
raise ChannelLinkConflict()
if directory:
conflicts = base_where.filter(Channel.directory == directory)
if list(conflicts):
raise ChannelDirectoryConflict()
def verify_config():
"""
Check that the local.yaml config file exists. Check some basic expectations about it's structure.
"""
if not pathlib.Path(CONFIG_PATH).exists():
raise Exception(f'local.yaml does not exist, looked here: {CONFIG_PATH}')
media_directory = get_media_directory()
error = None
if not media_directory.is_absolute():
error = f'Media directory is not absolute! {media_directory} '
elif not media_directory.exists():
error = f'Media directory does not exist! {media_directory} '
if error and DOCKERIZED:
error += 'Have you mounted your media directory in docker-compose.yml?'
elif error:
error += 'Have you updated your local.yaml with the media_directory?'
if error:
raise Exception(error)
def get_matching_directories(path: Union[str, Path]) -> List[str]:
"""
Return a list of directory strings that start with the provided path. If the path is a directory, return it's
subdirectories, if the directory contains no subdirectories, return the directory.
"""
path = str(path)
if os.path.isdir(path):
# The provided path is a directory, return it's subdirectories, or itself if no subdirectories exist
paths = [os.path.join(path, i) for i in os.listdir(path)]
paths = sorted(i for i in paths if os.path.isdir(i))
if len(paths) == 0:
return [path]
return paths
head, tail = os.path.split(path)
paths = os.listdir(head)
paths = [os.path.join(head, i) for i in paths]
pattern = path.lower()
paths = sorted(i for i in paths if os.path.isdir(i) and i.lower().startswith(pattern))
return paths
def replace_extension(path: pathlib.Path, new_ext) -> pathlib.Path:
"""Swap the extension of a file's path.
Example:
>>> foo = pathlib.Path('foo.bar')
>>> replace_extension(foo, 'baz')
'foo.baz'
"""
parent = path.parent
existing_ext = path.suffix
path = str(path)
name, _, _ = path.rpartition(existing_ext)
path = pathlib.Path(str(parent / name) + new_ext)
return path
def generate_video_poster(video_path: Path):
"""
Create a poster (aka thumbnail) next to the provided video_path.
"""
poster_path = replace_extension(video_path, '.jpg')
cmd = ['/usr/bin/ffmpeg', '-n', '-i', str(video_path), '-f', 'mjpeg', '-vframes', '1', '-ss', '00:00:05.000',
str(poster_path)]
try:
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
logger.info(f'Generated poster at {poster_path}')
except subprocess.CalledProcessError as e:
logger.warning(f'FFMPEG poster generation failed with stdout: {e.stdout.decode()}')
logger.warning(f'FFMPEG poster generation failed with stdout: {e.stderr.decode()}')
raise
async def generate_bulk_posters(video_ids: List[int]):
"""
Generate all posters for the provided videos. Update the video object with the new jpg file location. Do not
clobber existing jpg files.
"""
logger.info(f'Generating {len(video_ids)} video posters')
for video_ids in chunks(video_ids, 10):
with get_db_session(commit=True) as session:
videos = session.query(Video).filter(Video.id.in_(video_ids))
for video in videos:
video_path = get_absolute_video_path(video)
poster_path = replace_extension(video_path, '.jpg')
if not poster_path.exists():
generate_video_poster(video_path)
channel_dir = get_absolute_media_path(video.channel.directory)
poster_path = poster_path.relative_to(channel_dir)
video.poster_path = str(poster_path)
logger.info('Done generating video posters')
def convert_image(existing_path: Path, destination_path: Path, ext: str = 'jpeg'):
"""
Convert an image from one format to another. Remove the existing image file. This will safely overwrite an image
if the existing path is the same as the destination path.
"""
with tempfile.NamedTemporaryFile(dir=destination_path.parent, delete=False) as fh:
img = Image.open(existing_path).convert('RGB')
img.save(fh.name, ext)
existing_path.unlink()
os.rename(fh.name, destination_path)
os.chmod(destination_path, DEFAULT_FILE_PERMISSIONS)
def is_valid_poster(poster_path: Path) -> bool:
"""Return True only if poster file exists, and is of a JPEG format."""
if poster_path.is_file():
try:
img = Image.open(poster_path)
return img.format == 'JPEG'
except PIL.UnidentifiedImageError:
logger.error(f'Failed to identify poster: {poster_path}', exc_info=True)
pass
return False
def bulk_validate_posters(video_ids: List[int]):
"""
Replace all posters for the provided videos if a video's poster is not a JPEG format.
"""
logger.info(f'Validating {len(video_ids)} video posters')
for video_ids in chunks(video_ids, 10):
for video_id in video_ids:
with get_db_session(commit=True) as session:
video = session.query(Video).filter_by(id=video_id).one()
channel = video.channel
poster_path: Path = get_absolute_video_poster(video)
new_poster_path = poster_path.with_suffix('.jpg')
if poster_path != new_poster_path and new_poster_path.exists():
# Destination JPEG already exists (it may have the wrong format), lets overwrite it with a valid
# JPEG.
poster_path.unlink()
poster_path = new_poster_path
if not is_valid_poster(poster_path):
# Poster is not valid, convert it and place it in the new location.
try:
convert_image(poster_path, new_poster_path)
logger.info(f'Converted invalid poster {poster_path} to {new_poster_path}')
except Exception:
logger.error(f'Failed to convert invalid poster {poster_path} to {new_poster_path}',
exc_info=True)
else:
logger.debug(f'Poster was already valid: {new_poster_path}')
channel_dir = get_absolute_media_path(channel.directory)
# Update the video with the new poster path. Mark it as validated.
video.poster_path = str(new_poster_path.relative_to(channel_dir))
video.validated_poster = True
logger.info('Done validating video posters.')
def get_video_duration(video_path: Path) -> int:
"""
Get the duration of a video in seconds. Do this using ffprobe.
"""
if not isinstance(video_path, Path):
video_path = Path(video_path)
if not video_path.is_file():
raise FileNotFoundError(f'{video_path} does not exist!')
cmd = ['/usr/bin/ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of',
'default=noprint_wrappers=1:nokey=1', str(video_path)]
try:
proc = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
except subprocess.CalledProcessError as e:
logger.warning(f'FFPROBE failed to get duration with stdout: {e.stdout.decode()}')
logger.warning(f'FFPROBE failed to get duration with stderr: {e.stderr.decode()}')
raise
stdout = proc.stdout.decode()
duration = int(float(stdout.strip()))
return duration
async def get_bulk_video_info_json(video_ids: List[int]):
"""
Get and save the info_json data for each video provided.
"""
logger.info(f'Getting {len(video_ids)} video info_json meta data.')
for video_ids in chunks(video_ids, 10):
with get_db_session(commit=True) as session:
for video_id in video_ids:
video = session.query(Video).filter_by(id=video_id).one()
logger.debug(f'Getting video info_json data: {video}')
try:
info_json = video.get_info_json()
if info_json:
if not video.view_count and info_json.get('view_count'):
video.view_count = info_json['view_count']
if not video.duration:
# Get duration from info_json without reading video file.
video.duration = info_json.get('duration')
elif not video.duration:
# As a last resort, get duration from the video file.
video_path = get_absolute_video_path(video)
video.duration = get_video_duration(video_path)
except Exception:
logger.warning(f'Unable to get meta data of {video}', exc_info=True)
logger.info('Done getting video info_json meta data.')
async def get_bulk_video_size(video_ids: List[int]):
"""
Get and save the size for each video provided.
"""
logger.info(f'Getting {len(video_ids)} video sizes.')
for video_ids in chunks(video_ids, 10):
with get_db_session(commit=True) as session:
for video_id in video_ids:
video = session.query(Video).filter_by(id=video_id).one()
logger.debug(f'Getting video size: {video.id} {video.video_path}')
video_path = get_absolute_video_path(video)
size = video_path.stat().st_size
video.size = size
logger.info('Done getting video sizes')
def update_view_count(channel_id: int):
"""
Update view_count for all Videos in a channel.
"""
with get_db_session() as session:
channel = session.query(Channel).filter_by(id=channel_id).one()
info = channel.info_json
if not info:
logger.info(f'No info_json for channel {channel.name}')
return
view_counts = [{'id': i['id'], 'view_count': i['view_count']} for i in info['entries']]
logger.info(f'Updating {len(view_counts)} view counts for channel {channel.name}')
view_counts_str = json.dumps(view_counts)
with get_db_curs(commit=True) as curs:
stmt = '''
WITH source AS (select * from json_to_recordset(%s::json) as (id text, view_count int))
UPDATE video
SET view_count = s.view_count
FROM source as s
WHERE source_id=s.id AND channel_id=%s
RETURNING video.id AS updated_ids
'''
curs.execute(stmt, (view_counts_str, channel_id))
count = len(curs.fetchall())
logger.debug(f'Updated {count} view counts in DB.')
minimize_channel = partial(minimize_dict, keys=MINIMUM_CHANNEL_KEYS)
minimize_video_info_json = partial(minimize_dict, keys=MINIMUM_INFO_JSON_KEYS)
_minimize_video = partial(minimize_dict, keys=MINIMUM_VIDEO_KEYS)
def minimize_video(video: dict) -> dict:
"""
Return a Video dictionary that contains only the key/values typically used. Minimize the Channel and info_json,
if they are present.
"""
video = _minimize_video(video)
if video.get('channel'):
video['channel'] = minimize_channel(video['channel'])
if video.get('info_json'):
video['info_json'] = minimize_video_info_json(video['info_json'])
return video