wrolpi/modules/videos/lib.py

439 lines
16 KiB
Python

import asyncio
import json
import pathlib
import re
from multiprocessing.queues import Queue
from typing import Tuple
from uuid import uuid1
from sqlalchemy.orm import Session
from wrolpi.common import ProgressReporter, save_settings_config, get_absolute_media_path
from wrolpi.common import logger
from wrolpi.db import get_db_curs, get_db_session
from .captions import insert_bulk_captions, process_captions
from .common import generate_bulk_posters, get_bulk_video_info_json, get_bulk_video_size, \
generate_video_paths, remove_duplicate_video_paths, bulk_validate_posters, \
update_view_count
from .models import Channel, Video
logger = logger.getChild(__name__)
def refresh_channel_video_captions() -> bool:
with get_db_curs() as curs:
query = 'SELECT id FROM video WHERE caption IS NULL AND caption_path IS NOT NULL'
curs.execute(query)
missing_captions = [i for (i,) in curs.fetchall()]
if missing_captions:
coro = insert_bulk_captions(missing_captions)
asyncio.ensure_future(coro)
logger.info('Scheduled insert_bulk_captions()')
return True
else:
logger.info('No missing captions to process.')
return False
def refresh_channel_generate_posters() -> bool:
with get_db_curs() as curs:
query = 'SELECT id FROM video WHERE video_path IS NOT NULL AND poster_path IS NULL'
curs.execute(query)
missing_posters = [i for (i,) in curs.fetchall()]
if missing_posters:
coro = generate_bulk_posters(missing_posters)
asyncio.ensure_future(coro)
logger.info('Scheduled generate_bulk_posters()')
return True
else:
logger.info('No missing posters to generate.')
return False
def convert_invalid_posters() -> bool:
"""
Searches the DB for all videos with an invalid poster type (i.e. webp) and converts them to JPEGs. A video with a
valid poster will be marked as such in it's column "validated_poster".
"""
with get_db_curs() as curs:
query = "SELECT id FROM video WHERE poster_path IS NOT NULL AND validated_poster = FALSE"
curs.execute(query)
invalid_posters = [i for (i,) in curs.fetchall()]
if invalid_posters:
async def _():
return bulk_validate_posters(invalid_posters)
coro = _()
asyncio.ensure_future(coro)
logger.info('Scheduled bulk_replace_invalid_posters()')
return True
else:
logger.info('No invalid posters to replace.')
return False
def refresh_channel_info_json() -> bool:
"""
Fill in Video columns that are extracted from the info_json.
"""
with get_db_curs() as curs:
query = '''
SELECT v.id
FROM video v
WHERE
v.video_path IS NOT NULL
AND v.info_json_path IS NOT NULL
AND (v.duration IS NULL OR v.view_count IS NULL)
'''
curs.execute(query)
missing_duration = [i for (i,) in curs.fetchall()]
if missing_duration:
coro = get_bulk_video_info_json(missing_duration)
asyncio.ensure_future(coro)
logger.info('Scheduled get_bulk_video_duration()')
return True
else:
logger.info('No videos missing duration.')
return False
def refresh_channel_calculate_size() -> bool:
with get_db_curs() as curs:
query = 'SELECT id FROM video WHERE video_path IS NOT NULL AND size IS NULL'
curs.execute(query)
missing_size = [i for (i,) in curs.fetchall()]
if missing_size:
coro = get_bulk_video_size(missing_size)
asyncio.ensure_future(coro)
logger.info('Scheduled get_bulk_video_size()')
return True
else:
logger.info('No videos missing size.')
return False
def process_video_meta_data():
"""
Search for any videos missing meta data, fill in that data.
"""
refresh_channel_video_captions()
refresh_channel_generate_posters()
convert_invalid_posters()
refresh_channel_info_json()
refresh_channel_calculate_size()
def refresh_channel_videos(channel: Channel, reporter: ProgressReporter):
"""
Find all video files in a channel's directory. Add any videos not in the DB to the DB.
"""
# This function is hard to predict, so we will simply progress in chunks :(
reporter.set_progress_total(1, 7)
reporter.send_progress(1, 0, 'Preparing channel.')
# Set the idempotency key so we can remove any videos not touched during this search
with get_db_curs(commit=True) as curs:
curs.execute('UPDATE video SET idempotency=NULL WHERE channel_id=%s', (channel.id,))
reporter.send_progress(1, 1, 'Finding all videos, checking for duplicates.')
idempotency = str(uuid1())
directory = get_absolute_media_path(channel.directory)
# A set of absolute paths that exist in the file system
possible_new_paths = generate_video_paths(directory)
possible_new_paths = remove_duplicate_video_paths(possible_new_paths)
reporter.send_progress(1, 2, 'Matching all videos to the database.')
# Update all videos that match the current video paths
relative_new_paths = [str(i.relative_to(directory)) for i in possible_new_paths]
with get_db_curs(commit=True) as curs:
query = 'UPDATE video SET idempotency = %s WHERE channel_id = %s AND video_path = ANY(%s) RETURNING video_path'
curs.execute(query, (idempotency, channel.id, relative_new_paths))
existing_paths = {i for (i,) in curs.fetchall()}
reporter.send_progress(1, 3)
# Get the paths for any video not yet in the DB
# (paths in DB are relative, but we need to pass an absolute path)
new_videos = {p for p in possible_new_paths if str(p.relative_to(directory)) not in existing_paths}
reporter.send_progress(1, 4, f'Inserting {len(new_videos)} new videos.')
for video_path in new_videos:
with get_db_session(commit=True) as session:
upsert_video(session, pathlib.Path(video_path), channel, idempotency=idempotency)
logger.debug(f'{channel.name}: Added {video_path}')
reporter.send_progress(1, 5, 'Deleting unnecessary video entries.')
with get_db_curs(commit=True) as curs:
curs.execute('DELETE FROM video WHERE channel_id=%s AND idempotency IS NULL RETURNING id', (channel.id,))
deleted_count = len(curs.fetchall())
if deleted_count:
deleted_status = f'Deleted {deleted_count} video records from channel {channel.name}'
logger.info(deleted_status)
logger.info(f'{channel.name}: {len(new_videos)} new videos, {len(existing_paths)} already existed. ')
reporter.send_progress(1, 6, f'Updating video view counts')
update_view_count(channel.id)
reporter.send_progress(1, 7, f'Processed all videos for {channel.name}')
def _refresh_videos(q: Queue, channel_links: list = None):
"""
Find any videos in the channel directories and add them to the DB. Delete DB records of any videos not in the
file system.
Yields status updates to be passed to the UI.
:return:
"""
logger.info('Refreshing video files')
with get_db_session() as session:
reporter = ProgressReporter(q, 2)
reporter.code('refresh-started')
reporter.set_progress_total(0, session.query(Channel).count())
if channel_links:
channels = session.query(Channel).filter(Channel.link.in_(channel_links))
else:
channels = session.query(Channel).all()
channels = list(channels)
if not channels and channel_links:
raise Exception(f'No channels match links(s): {channel_links}')
elif not channels:
raise Exception(f'No channels in DB. Have you created any?')
for idx, channel in enumerate(channels):
reporter.send_progress(0, idx, f'Checking {channel.name} directory for new videos')
refresh_channel_videos(channel, reporter)
# Fill in any missing data for all videos.
process_video_meta_data()
reporter.send_progress(0, len(channels), 'All videos refreshed.')
reporter.code('refresh-complete')
def get_channels_config(session: Session) -> dict:
"""
Create a dictionary that contains all the Channels from the DB.
"""
channels = session.query(Channel).order_by(Channel.link).all()
channels = {i.link: i.config_view() for i in channels}
return dict(channels=channels)
def save_channels_config(session=None):
"""
Pull the Channel information from the DB, save it to the config.
"""
if session:
config = get_channels_config(session)
else:
with get_db_session() as session:
config = get_channels_config(session)
save_settings_config(config)
async def get_statistics():
with get_db_curs() as curs:
curs.execute('''
SELECT
-- total videos
COUNT(id) AS "videos",
-- total videos that are marked as favorite
COUNT(id) FILTER (WHERE favorite IS NOT NULL) AS "favorites",
-- total videos downloaded over the past week/month/year
COUNT(id) FILTER (WHERE upload_date >= current_date - interval '1 week') AS "week",
COUNT(id) FILTER (WHERE upload_date >= current_date - interval '1 month') AS "month",
COUNT(id) FILTER (WHERE upload_date >= current_date - interval '1 year') AS "year",
-- sum of all video lengths in seconds
COALESCE(SUM(duration), 0) AS "sum_duration",
-- sum of all video file sizes
COALESCE(SUM(size), 0)::BIGINT AS "sum_size",
-- largest video
COALESCE(MAX(size), 0) AS "max_size"
FROM
video
WHERE
video_path IS NOT NULL
''')
video_stats = dict(curs.fetchone())
# Get the total videos downloaded every month for the past two years.
curs.execute('''
SELECT
DATE_TRUNC('month', months.a),
COUNT(id)::BIGINT,
SUM(size)::BIGINT AS "size"
FROM
generate_series(
date_trunc('month', current_date) - interval '2 years',
date_trunc('month', current_date) - interval '1 month',
'1 month'::interval) AS months(a),
video
WHERE
video.upload_date >= date_trunc('month', months.a)
AND video.upload_date < date_trunc('month', months.a) + interval '1 month'
AND video.upload_date IS NOT NULL
AND video.video_path IS NOT NULL
GROUP BY
1
ORDER BY
1
''')
monthly_videos = [dict(i) for i in curs.fetchall()]
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(id) AS "channels"
FROM
channel
''')
channel_stats = dict(curs.fetchone())
ret = dict(statistics=dict(
videos=video_stats,
channels=channel_stats,
historical=historical_stats,
))
return ret
async def get_download_history():
"""
Get the info_date for all channels that have been downloaded. Order by the most recent download first.
"""
query = '''
SELECT
link, name, info_date
FROM
channel
WHERE
info_date IS NOT NULL
ORDER BY channel.info_date DESC
'''
with get_db_curs() as curs:
curs.execute(query)
results = [dict(i) for i in curs.fetchall()]
return results
NAME_PARSER = re.compile(r'(.*?)_((?:\d+?)|(?:NA))_(?:(.{11})_)?(.*)\.'
r'(jpg|webp|flv|mp4|part|info\.json|description|webm|..\.srt|..\.vtt)')
def upsert_video(session: Session, video_path: pathlib.Path, channel: Channel, idempotency: str = None,
skip_captions=False,
id_: str = None, info_json: dict = None) -> Video:
"""
Insert a video into the DB. Also, find any meta-files near the video file and store them on the video row.
If id_ is provided, update that entry.
"""
channel_dir = get_absolute_media_path(channel.directory)
poster_path, description_path, caption_path, info_json_path = find_meta_files(video_path, relative_to=channel_dir)
# Video paths should be relative to the channel's directory
if video_path.is_absolute():
video_path = video_path.relative_to(channel_dir)
name_match = NAME_PARSER.match(video_path.name)
_ = upload_date = source_id = title = ext = None
if name_match:
_, upload_date, source_id, title, ext = name_match.groups()
# Make sure the date is a valid date format, if not, leave it blank. Youtube-DL sometimes puts an NA in the date.
# We may even get videos that weren't downloaded by WROLPi.
if not upload_date or not upload_date.isdigit() or len(upload_date) != 8:
logger.debug(f'Could not parse date from filename: {video_path}')
upload_date = None
duration = None
if info_json_path:
path = (channel_dir / info_json_path).absolute()
try:
with open(path) as fh:
json_contents = json.load(fh)
duration = json_contents['duration']
except json.decoder.JSONDecodeError:
logger.warning(f'Failed to load JSON file to get duration: {path}')
video_dict = dict(
channel_id=channel.id,
description_path=str(description_path) if description_path else None,
ext=ext,
poster_path=str(poster_path) if poster_path else None,
source_id=source_id,
title=title,
upload_date=upload_date,
video_path=str(video_path),
caption_path=str(caption_path) if caption_path else None,
idempotency=idempotency,
info_json_path=str(info_json_path) if info_json_path else None,
duration=duration,
)
if id_:
video = session.query(Video).filter_by(id=id_).one()
for key, value in video_dict.items():
setattr(video, key, value)
else:
video = Video(**video_dict)
session.add(video)
session.flush()
if skip_captions is False and caption_path:
# Process captions only when requested
process_captions(video)
return video
def find_meta_files(path: pathlib.Path, relative_to=None) -> Tuple[
pathlib.Path, pathlib.Path, pathlib.Path, pathlib.Path]:
"""
Find all files that share a file's full path, except their extensions. It is assumed that file with the
same name, but different extension is related to that file. A None will be yielded if the meta file doesn't exist.
Example:
>>> foo = pathlib.Path('foo.bar')
>>> find_meta_files(foo)
(pathlib.Path('foo.jpg'), pathlib.Path('foo.description'),
pathlib.Path('foo.en.vtt'), pathlib.Path('foo.info.json'))
"""
suffix = path.suffix
name, suffix, _ = str(path.name).rpartition(suffix)
meta_file_exts = (('.jpg', '.webp', '.png'), ('.description',), ('.en.vtt', '.en.srt'), ('.info.json',))
for meta_exts in meta_file_exts:
for meta_ext in meta_exts:
meta_path = path.with_suffix(meta_ext)
if meta_path.exists():
if relative_to:
yield meta_path.relative_to(relative_to)
break
else:
yield meta_path
break
else:
yield None