Feature/video validate (#74)

* Videos are marked as validated so they will not be tried again.  All previous bulk methods are combined into one.

* Passing json to fixture factory to avoid writing the files in every test.

* Adding log to explain if refresh is slow.

* Generating video poster if one cannot be found.

* Generating video poster if no poster can be found.

* Videos without a video file (yet to be downloaded) are not validated.

* Video title is extracted from the video file name as a last resort.

* Keeping existing view count because it may be from the channel, which will be more recent.

* Downloads should only be downloaded after they are due, not the same day.

* Setting manager on new downloads.

* Checking if downloading is disabled in loop.

* Adding log when refresh is complete.

* Not generating posters by default.  This is to avoid deleting files.

* Not converting posters unless channel has that enabled.

* Not logging on valid because all posters will be checked.

* Validating videos in upsert.

* Using video name parser in upsert.

* Using validate_video in upsert so that data is only read once from disk.

* Attaching manager in download_manager.get_download.

* Adding 22 to preferred video format for odd 720p resolution.

Signed-off-by: Roland <roland@learningselfreliance.com>
This commit is contained in:
lrnselfreliance 2022-01-14 15:54:33 -07:00 • committed by Roland
parent 6976abcdad
commit 86893ef7f1
No known key found for this signature in database
GPG key ID: 9D6DBD3ACF11AB93
14 changed files with 464 additions and 434 deletions

View file

@ -1,13 +1,12 @@
#! /usr/bin/env python3
import pathlib
from pathlib import Path
from typing import Generator, List, Union
from typing import Generator, Union
import srt
import webvtt
from wrolpi.common import logger, chunks
from wrolpi.db import get_db_session
from wrolpi.common import logger
from wrolpi.media_path import MediaPath
from .models import Video
@ -39,9 +38,9 @@ def get_unique_caption_lines(caption_path: Union[str, Path]) -> Generator:
yield line
def process_captions(video: Video):
def get_video_captions(video: Video):
"""
Parse and insert captions for a video record.
Parse video captions from the video's captions file.
"""
if isinstance(video.caption_path, MediaPath):
caption_path = video.caption_path.path
@ -51,8 +50,7 @@ def process_captions(video: Video):
try:
lines = get_unique_caption_lines(str(caption_path))
block = '\n'.join(lines)
video.caption = block
return
return block
except UnicodeDecodeError:
# Failed to decode the caption file
# TODO handle this error
@ -65,12 +63,3 @@ def process_captions(video: Video):
pass
logger.debug(f'Failed to parse caption file {caption_path}')
async def insert_bulk_captions(video_ids: List[int]):
for video_ids in chunks(video_ids, 10):
with get_db_session(commit=True) as session:
for idx, video_id in enumerate(video_ids):
video = session.query(Video).filter_by(id=video_id).one()
process_captions(video)
logger.debug(f'Inserted {len(video_ids)} captions')

View file

@ -1,4 +1,4 @@
import html
import json
import json
import os
import pathlib
@ -14,7 +14,7 @@ from sqlalchemy.orm import Session
from yt_dlp import YoutubeDL
from wrolpi import before_startup
from wrolpi.common import logger, get_config, iterify, chunks, get_media_directory, \
from wrolpi.common import logger, get_config, iterify, get_media_directory, \
minimize_dict, api_param_limiter
from wrolpi.db import get_db_session, get_db_curs
from wrolpi.errors import UnknownFile, UnknownDirectory, ChannelNameConflict, ChannelURLConflict, \
@ -342,28 +342,12 @@ def generate_video_poster(video_path: Path) -> Path:
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
if not poster_path.exists():
raise Exception(f'Failed to find generated poster! {poster_path}')
return poster_path
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 = video.video_path.path
poster_path = replace_extension(video_path, '.jpg')
if not poster_path.exists():
generate_video_poster(video_path)
video.poster_path = 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
@ -391,42 +375,6 @@ def is_valid_poster(poster_path: Path) -> bool:
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: Video = session.query(Video).filter_by(id=video_id).one()
poster_path: Path = video.poster_path.path
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 as e:
logger.error(f'Failed to convert invalid poster {poster_path} to {new_poster_path}', exc_info=e)
continue
else:
logger.debug(f'Poster was already valid: {new_poster_path}')
# Update the video with the new poster path. Mark it as validated.
video.poster_path = new_poster_path
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.
@ -450,62 +398,6 @@ def get_video_duration(video_path: Path) -> int:
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}')
if not video.video_path:
logger.warning(f'Refusing to get info_json for video without a video file: {video}')
continue
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')
# Trust the info_json title before the video path title.
video.title = info_json.get('title')
if not video.duration:
# As a last resort, get duration from the video file.
video.duration = get_video_duration(video.video_path.path)
if not video.title:
# Use the video path name without the extension.
video.title = video.video_path.path.with_suffix('').name
# Unescape any HTML entities.
video.title = html.unescape(video.title)
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}')
size = video.video_path.path.stat().st_size
video.size = size
logger.info('Done getting video sizes')
def apply_info_json(channel_id: int):
"""
Update view_count for all Videos in a channel using its info_json file. Mark any videos not in the info_json as

View file

@ -1,9 +1,11 @@
import json
import pathlib
import shutil
from uuid import uuid4
import mock
import pytest
from PIL import Image
from modules.videos.channel.lib import spread_channel_downloads
from modules.videos.downloader import VideoDownloader, ChannelDownloader
@ -99,13 +101,38 @@ def video_file(test_directory) -> pathlib.Path:
@pytest.fixture
def video_factory(test_session, test_directory):
def _(channel_id: int = None):
"""
Creates Videos for testing.
"""
def _(channel_id: int = None, with_video_file: bool = False, with_info_json: dict = None,
with_poster_ext: str = None):
title = str(uuid4())
if channel_id:
path = str(test_directory / f'{title}.mp4')
path = test_directory / f'{title}.mp4'
else:
path = str(test_directory / 'videos' / 'NO CHANNEL' / f'{title}.mp4')
video = Video(video_path=path, title=title, channel_id=channel_id, source_id=title)
path = test_directory / 'videos' / 'NO CHANNEL' / f'{title}.mp4'
if with_video_file:
shutil.copy(PROJECT_DIR / 'test/big_buck_bunny_720p_1mb.mp4', path)
info_json_path = None
if with_info_json is True:
# User requests a info json file, but does not provide one.
info_json_path = path.with_suffix('.info.json')
info_json_path.write_text(json.dumps({}))
elif with_info_json:
# Use the provided object as the json.
info_json_path = path.with_suffix('.info.json')
info_json_path.write_text(json.dumps(with_info_json))
poster_path = None
if with_poster_ext:
poster_path = path.with_suffix(f'.{with_poster_ext}')
Image.new('RGB', (25, 25)).save(poster_path)
video = Video(video_path=str(path), title=title, channel_id=channel_id, source_id=title,
info_json_path=info_json_path, poster_path=poster_path)
test_session.add(video)
return video

View file

@ -37,7 +37,7 @@ ChannelIEs = {
}
PREFERRED_VIDEO_EXTENSION = 'mp4'
PREFERRED_VIDEO_FORMAT = 'best[height=720],mp4-480p,mp4-360p,mp4-240p'
PREFERRED_VIDEO_FORMAT = 'best[height=720],22,720p,mp4-480p,mp4-360p,mp4-240p'
def get_no_channel_directory():
@ -171,7 +171,7 @@ class VideoDownloader(Downloader, ABC):
return_code = self.process_runner(url, cmd, out_dir)
if return_code != 0:
raise ValueError(f'youtube-dl exited with {return_code}')
raise ValueError(f'video downloader process exited with {return_code}')
if not video_path.is_file():
raise ValueError(f'Video file could not be found! {video_path}')

View file

@ -1,10 +1,8 @@
import asyncio
import html
import json
import pathlib
import re
from collections import defaultdict
from typing import Tuple
from typing import Tuple, Optional
from uuid import uuid1
from sqlalchemy.orm import Session
@ -12,11 +10,11 @@ from sqlalchemy.orm import Session
from wrolpi.common import logger, chunks, get_config
from wrolpi.common import save_settings_config
from wrolpi.db import get_db_curs, get_db_session, optional_session
from wrolpi.media_path import MediaPath
from wrolpi.vars import PYTEST
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, \
apply_info_json, import_videos_config
from .captions import get_video_captions
from .common import generate_video_paths, remove_duplicate_video_paths, apply_info_json, import_videos_config, \
get_video_duration, is_valid_poster, convert_image, generate_video_poster
from .models import Channel, Video
logger = logger.getChild(__name__)
@ -24,115 +22,6 @@ logger = logger.getChild(__name__)
DEFAULT_DOWNLOAD_FREQUENCY = 60 * 60 * 24 * 7 # weekly
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.info_json_path != ''
AND (v.duration IS NULL OR v.view_count IS NULL OR v.title 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_info_json()')
return True
else:
logger.info('No videos need updating from info_json.')
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):
"""
Find all video files in a channel's directory. Add any videos not in the DB to the DB.
@ -222,6 +111,136 @@ def refresh_no_channel_videos():
logger.info(deleted_status)
def process_video_info_json(video: Video):
"""
Parse the Video's info json file, return the relevant data.
"""
title = duration = view_count = url = None
if info_json := video.get_info_json():
title = info_json.get('fulltitle') or info_json.get('title')
title = html.unescape(title) if title else None
duration = info_json.get('duration')
view_count = info_json.get('view_count')
url = info_json.get('webpage_url') or info_json.get('url')
return title, duration, view_count, url
def validate_videos():
"""
Validate all Videos not yet validated. A Video is validated when we have attempted to find its: title, duration,
view_count, url, caption, size. 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.
This function marks the Video as validated, even if no data can be found so a Video will not be validated multiple
times.
"""
with get_db_curs() as curs:
curs.execute('SELECT id FROM video WHERE video_path IS NOT NULL AND validated IS FALSE')
video_ids = [i['id'] for i in curs.fetchall()]
curs.execute('SELECT id, generate_posters FROM channel')
channel_generate_posters = {i['id']: i['generate_posters'] for i in curs.fetchall()}
logger.info(f'Validating {len(video_ids)} videos.')
for chunk in chunks(video_ids, 20):
with get_db_session(commit=True) as session:
videos = session.query(Video).filter(Video.id.in_(chunk)).all()
for video in videos:
try:
channel_generate_poster = channel_generate_posters.get(video.channel_id)
validate_video(video, channel_generate_poster)
# All data about the Video has been found, we should not attempt to validate it again.
video.validated = True
except Exception as e:
# This video failed to validate, continue validation for the rest of the videos.
logger.warning(f'Failed to validate {video=}', exc_info=e)
def validate_video(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. 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.
"""
if not video.title or not video.duration or not video.view_count or not video.url:
# These properties can be found in the info json.
title, duration, view_count, url = process_video_info_json(video)
video.title = title
video.duration = duration
video.url = url
# View count will probably be overwritten by more recent data when this Video's Channel is
# updated.
video.view_count = video.view_count or view_count
video_path = video.video_path.path if isinstance(video.video_path, MediaPath) else video.video_path
if not video.title or not video.upload_date 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.
_, upload_date, source_id, title = parse_video_file_name(video_path)
video.title = video.title or html.unescape(title)
video.upload_date = video.upload_date or upload_date
video.source_id = video.source_id or source_id
if not video.duration:
# Video duration was not in the info json, use ffprobe.
video.duration = get_video_duration(video_path)
if not video.caption and video.caption_path:
video.caption = get_video_captions(video)
if not video.size:
video.size = video_path.stat().st_size
if not video.poster_path:
# Video poster is not found, lets check near the video file.
for ext in ('.jpg', '.jpeg', '.webp', '.png'):
if (poster_path := video_path.with_suffix(ext)).is_file():
video.poster_path = poster_path
break
if channel_generate_poster:
# Try to convert/generate, but keep the old poster if those fail.
video.poster_path = convert_or_generate_poster(video) or video.poster_path
def convert_or_generate_poster(video: Video) -> Optional[pathlib.Path]:
"""
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.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.path if \
isinstance(video.poster_path, MediaPath) else video.poster_path
new = old.with_suffix('.jpg')
if old != new and new.exists():
# Destination JPEG already exists (it may have the wrong format).
old.unlink()
old = video.poster_path = new
if not is_valid_poster(old):
# Poster is not valid, convert it and place it in the new location.
try:
convert_image(old, new)
old.unlink(missing_ok=True)
logger.info(f'Converted invalid poster {old} to {new}')
return new
except Exception as e:
logger.error(f'Failed to convert invalid poster {old} to {new}', exc_info=e)
return
if not video.poster_path:
# Video poster was not discovered, or converted. Let's generate it.
try:
poster_path = generate_video_poster(video_path)
logger.debug(f'Generated poster for {video}')
return poster_path
except Exception as e:
logger.error(f'Failed to generate poster for {video}', exc_info=e)
def _refresh_videos(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
@ -259,7 +278,9 @@ def _refresh_videos(channel_links: list = None):
# Fill in any missing data for all videos.
if not PYTEST:
import_videos_config()
process_video_meta_data()
validate_videos()
logger.info('Refresh of video files complete')
def get_channels_config(session: Session) -> dict:
@ -364,12 +385,37 @@ async def get_statistics():
return ret
NAME_PARSER = re.compile(r'(.*?)_((?:\d+?)|(?:NA))_(?:(.{11})_)?(.*)\.'
NAME_PARSER = re.compile(r'(.*?)_((?:\d+?)|(?:NA))_(?:(.+?)_)?(.*)\.'
r'(jpg|webp|flv|mp4|part|info\.json|description|webm|..\.srt|..\.vtt)')
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.
Example: {channel_name}_{upload_date}_{source_id}_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 = pathlib.Path(video_path).stem.strip()
return None, None, None, title
def upsert_video(session: Session, video_path: pathlib.Path, channel: Channel = None, idempotency: str = None,
skip_captions=False, id_: str = None) -> Video:
id_: str = None) -> Video:
"""
Insert a video into the DB. Also, find any meta-files near the video file and store them on the video row.
@ -377,66 +423,37 @@ def upsert_video(session: Session, video_path: pathlib.Path, channel: Channel =
"""
if not video_path.is_absolute():
raise ValueError(f'Video path is not absolute: {video_path}')
# This function can update or insert a Video.
video = session.query(Video).filter_by(id=id_).one() if id_ else Video()
# Set the file values, all other things can be found using these files.
poster_path, description_path, caption_path, info_json_path = find_meta_files(video_path)
video.caption_path = caption_path
video.description_path = description_path
video.info_json_path = info_json_path
video.poster_path = poster_path
video.video_path = video_path
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()
if channel and not video_path.is_relative_to(channel.directory.path):
raise ValueError(f'Video path is not within its channel {video_path=} {channel.directory=}')
# 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
video.channel = channel
video.idempotency = idempotency
duration = None
url = None
if info_json_path:
try:
with info_json_path.open('rt') as fh:
json_contents = json.load(fh)
duration = json_contents.get('duration')
url = json_contents.get('webpage_url')
# Trust the info_json title before the video filename.
title = json_contents.get('title', title)
except json.decoder.JSONDecodeError:
logger.warning(f'Failed to load JSON file to get duration: {info_json_path}')
size = video_path.stat().st_size
title = html.unescape(title) if title else None
video_dict = dict(
caption_path=str(caption_path) if caption_path else None,
channel_id=channel.id if channel else None,
description_path=str(description_path) if description_path else None,
duration=duration,
ext=ext,
idempotency=idempotency,
info_json_path=str(info_json_path) if info_json_path else None,
poster_path=str(poster_path) if poster_path else None,
size=size,
source_id=source_id,
title=title,
upload_date=upload_date,
url=url,
video_path=str(video_path),
)
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)
try:
# Fill in any missing data. Generate poster if enabled and necessary.
validate_video(video, channel.generate_posters if channel else False)
# All data about the Video has been found, we should not attempt to validate it again.
video.validated = True
except Exception:
# Could not validate, this could be an issue with a file. This should not prevent the video from being
# inserted.
pass
session.add(video)
session.flush()
if skip_captions is False and caption_path:
# Process captions only when requested
process_captions(video)
return video

View file

@ -7,7 +7,7 @@ from sqlalchemy import Column, Integer, String, Boolean, JSON, Date, ARRAY, Fore
from sqlalchemy.orm import relationship, Session, deferred
from sqlalchemy.orm.collections import InstrumentedList
from wrolpi.common import Base, tsvector, ModelHelper, logger
from wrolpi.common import Base, tsvector, ModelHelper, logger, get_media_directory
from wrolpi.dates import now, TZDateTime
from wrolpi.db import get_db_curs
from wrolpi.downloader import Download
@ -23,6 +23,7 @@ class Video(ModelHelper, Base):
channel_id = Column(Integer, ForeignKey('channel.id'))
channel = relationship('Channel', primaryjoin='Video.channel_id==Channel.id', back_populates='videos')
idempotency = Column(String)
validated = Column(Boolean, default=False)
# File paths
caption_path = Column(MediaPathType)
@ -33,17 +34,16 @@ class Video(ModelHelper, Base):
video_path = Column(MediaPathType)
caption = deferred(Column(String)) # slow to fetch
censored = Column(Boolean, default=False)
duration = Column(Integer)
favorite = Column(TZDateTime)
size = Column(Integer)
source_id = Column(String)
title = Column(String)
upload_date = Column(TZDateTime)
validated_poster = Column(Boolean, default=False)
viewed = Column(TZDateTime)
view_count = Column(Integer)
url = Column(String)
censored = Column(Boolean, default=False)
view_count = Column(Integer)
viewed = Column(TZDateTime)
textsearch = deferred(
Column(tsvector, Computed('''to_tsvector('english'::regconfig,
@ -51,7 +51,9 @@ class Video(ModelHelper, Base):
COALESCE(caption, ''::text)))''')))
def __repr__(self):
video_path = self.video_path.path if self.video_path else None
video_path = self.video_path
if self.video_path and isinstance(self.video_path, MediaPath):
video_path = self.video_path.path.relative_to(get_media_directory())
return f'<Video id={self.id} title={self.title} path={video_path} channel={self.channel_id} ' \
f'source_id={self.source_id}>'
@ -258,8 +260,8 @@ class Channel(ModelHelper, Base):
match_regex = Column(String)
directory: MediaPath = Column(MediaPathType)
skip_download_videos = Column(ARRAY(String))
generate_posters = Column(Boolean)
calculate_duration = Column(Boolean)
generate_posters = Column(Boolean, default=False) # generating posters may delete files, and can be slow.
calculate_duration = Column(Boolean, default=True)
download_frequency = Column(Integer)
source_id = Column(String)
refreshed = Column(Boolean, default=False)

View file

@ -46,11 +46,11 @@ class TestCaption(TestAPI):
video1 = Video(title='scream', caption_path=str(self.vtt_path1))
session.add(video1)
session.commit()
captions.process_captions(video1)
video1.caption = captions.get_video_captions(video1)
video2 = Video(title='bar', caption_path=str(self.vtt_path2))
session.add(video2)
session.commit()
captions.process_captions(video2)
video2.caption = captions.get_video_captions(video2)
session.commit()
session.flush()

View file

@ -1,11 +1,10 @@
import json
import pathlib
import subprocess
import tempfile
from datetime import datetime
from typing import List
from unittest import mock
from unittest.mock import Mock, MagicMock
from unittest.mock import MagicMock
import pytest
from PIL import Image
@ -17,9 +16,9 @@ from wrolpi.dates import local_timezone
from wrolpi.db import get_db_session
from wrolpi.test.common import build_test_directories, wrap_test_db, TestAPI
from wrolpi.vars import PROJECT_DIR
from ..common import get_matching_directories, convert_image, bulk_validate_posters, remove_duplicate_video_paths, \
from ..common import get_matching_directories, convert_image, remove_duplicate_video_paths, \
apply_info_json, get_video_duration, generate_video_poster, replace_extension, is_valid_poster, \
get_bulk_video_info_json, import_videos_config
import_videos_config
from ..lib import save_channels_config
@ -186,79 +185,6 @@ def test_create_db_structure(_structure, paths):
test_func(test_)
@wrap_test_db
@create_channel_structure(
{
'channel1': ['vid1.mp4', 'vid1.jpg'],
'channel2': ['vid2.flv', 'vid2.webp'],
}
)
def test_bulk_replace_invalid_posters(tempdir: pathlib.Path):
"""
Test that when a video has an invalid poster format, we convert it to JPEG.
"""
channel1, channel2 = sorted(tempdir.iterdir())
jpg, mp4 = sorted(channel1.iterdir())
flv, webp = sorted(channel2.iterdir())
Image.new('RGB', (25, 25)).save(jpg)
Image.new('RGB', (25, 25)).save(webp)
with open(jpg, 'rb') as jpg_fh, open(webp, 'rb') as webp_fh:
# Files are different formats.
jpg_fh_contents = jpg_fh.read()
webp_fh_contents = webp_fh.read()
assert jpg_fh_contents != webp_fh_contents
assert Image.open(jpg_fh).format == 'JPEG'
assert Image.open(webp_fh).format == 'WEBP'
with get_db_session() as session:
vid1 = session.query(Video).filter_by(poster_path=f'{tempdir}/channel1/vid1.jpg').one()
assert vid1.validated_poster is False
vid2 = session.query(Video).filter_by(poster_path=f'{tempdir}/channel2/vid2.webp').one()
assert vid2.validated_poster is False
# Convert the WEBP image. convert_image() should only be called once.
mocked_convert_image = Mock(wraps=convert_image)
with mock.patch('modules.videos.common.convert_image', mocked_convert_image):
video_ids = [vid1.id, vid2.id]
bulk_validate_posters(video_ids)
mocked_convert_image.assert_called_once_with(webp, tempdir / 'channel2/vid2.jpg')
with get_db_session() as session:
# Get the video by ID because it's poster is now a JPEG.
vid2 = session.query(Video).filter_by(id=vid2.id).one()
assert str(vid2.poster_path.path).split('/')[-1] == 'vid2.jpg'
assert all('webp' not in str(i.poster_path.path) for i in session.query(Video).all())
assert vid2.validated_poster is True
# Vid1's image was validated, but not converted.
vid1 = session.query(Video).filter_by(id=vid1.id).one()
assert str(vid1.poster_path.path).split('/')[-1] == 'vid1.jpg'
assert vid1.validated_poster is True
# Old webp was removed
assert not webp.is_file()
new_jpg = tempdir / 'channel2/vid2.jpg'
assert new_jpg.is_file()
# chmod 644
assert new_jpg.stat().st_mode == 0o100644
with open(new_jpg, 'rb') as new_jpg_fh:
# The converted image is the same as the other JPEG because both are black 25x25 pixel images.
assert jpg_fh_contents == new_jpg_fh.read()
assert Image.open(new_jpg_fh).format == 'JPEG'
# Calling convert again has no effect.
mocked_convert_image.reset_mock()
with mock.patch('modules.videos.common.convert_image', mocked_convert_image):
video_ids = [vid1.id, vid2.id]
bulk_validate_posters(video_ids)
mocked_convert_image.assert_not_called()
@wrap_test_db
@create_channel_structure(
{
@ -383,34 +309,6 @@ def test_update_censored_videos(test_session, video_factory, simple_channel):
check_censored([(vid1.id, True), (vid2.id, True), (vid3.id, True), (vid4.id, False)])
@pytest.mark.asyncio
async def test_missing_title(test_session, simple_video):
"""
A Video title will be filled out by `get_bulk_video_info_json`.
"""
video_path: pathlib.Path = simple_video.video_path.path
simple_video.duration = 10 # don't update the duration during get_bulk_video_info_json()
info_json_path = video_path.with_suffix('.info.json')
info_json_path.write_text(json.dumps({'title': 'info json title &amp;'}))
simple_video.info_json_path = info_json_path
test_session.commit()
# simple_video does not come with a title.
assert not simple_video.title
# Title is fetched from the info_json.
await get_bulk_video_info_json([simple_video.id])
assert simple_video.title == 'info json title &'
# No info_json means the filename is used.
simple_video.title = None
simple_video.info_json_path = None
test_session.commit()
await get_bulk_video_info_json([simple_video.id])
assert simple_video.title == 'simple_video'
def test_import_favorites(test_session, simple_channel, video_factory):
"""
A favorited Video will be preserved through everything (channel deletion, DB wipe) and can be imported and will be

View file

@ -1,3 +1,6 @@
import json
import pathlib
import shutil
from datetime import datetime
from unittest import mock
@ -6,10 +9,12 @@ import sqlalchemy
from modules.videos.channel import lib
from modules.videos.common import apply_info_json
from modules.videos.lib import validate_videos, parse_video_file_name
from modules.videos.models import Channel, Video
from modules.videos.video.lib import video_search
from wrolpi.dates import local_timezone
from wrolpi.downloader import DownloadFrequency
from wrolpi.vars import PROJECT_DIR
daily, weekly, days30 = DownloadFrequency.daily, DownloadFrequency.weekly, DownloadFrequency.days30
@ -108,3 +113,157 @@ def test_search_censored_videos(test_session, simple_channel):
videos, total = video_search(filters=['censored'], order_by='id')
assert [i['source_id'] for i in videos] == [str(i) for i in range(20)]
assert total == 25
def test_validate_videos(test_session, simple_channel, video_factory):
"""
Videos that aren't validated should have their data filled in while being validated.
"""
simple_channel.generate_posters = True
vid1_json = {'title': 'info&apos;s title'}
vid3_json = {'duration': 100}
vid5_json = {'view_count': 42}
vid6_json = {'webpage_url': 'https://example.com/webpage', 'url': 'https://example.com/url'}
vid1 = video_factory(simple_channel.id, with_video_file=True, with_poster_ext='jpg', with_info_json=vid1_json)
vid2 = video_factory(simple_channel.id, with_video_file=True, with_poster_ext='jpg', with_info_json=True)
vid3 = video_factory(simple_channel.id, with_video_file=True, with_poster_ext='jpg', with_info_json=vid3_json)
vid4 = video_factory(simple_channel.id, with_video_file=True, with_poster_ext='jpg', with_info_json=True)
vid5 = video_factory(simple_channel.id, with_video_file=True, with_poster_ext='jpg', with_info_json=vid5_json)
vid6 = video_factory(simple_channel.id, with_video_file=True, with_poster_ext='jpg', with_info_json=vid6_json)
vid7 = video_factory(simple_channel.id, with_video_file=True, with_poster_ext='jpg')
vid8 = video_factory(simple_channel.id, with_video_file=True, with_poster_ext='webp')
vid9 = video_factory(simple_channel.id, with_video_file=True)
# These videos are missing this data, and a refresh should fill them in.
vid1.title = None
vid2.caption = None
vid2.title = None
vid3.duration = None
vid4.size = None
vid5.view_count = None
vid6.url = None
vid7.poster_path = None
test_session.commit()
# Write the associated files. This data should be processed.
vid2.caption_path = vid2.video_path.path.with_suffix('.en.vtt')
shutil.copy(PROJECT_DIR / 'test/example1.en.vtt', vid2.caption_path)
test_session.commit()
# All Videos are validated, all data is filled out. The info json data is trusted first.
validate_videos()
assert all([i.validated for i in test_session.query(Video)])
assert vid1.title == "info's title"
assert vid2.caption.startswith('okay welcome')
assert 'mp4' not in vid2.title
assert vid3.duration == 100
assert vid4.size == 1055736
assert vid5.view_count == 42
assert vid6.url == 'https://example.com/webpage'
assert str(vid7.poster_path).endswith('.jpg')
assert str(vid8.poster_path).endswith('.jpg')
assert str(vid9.poster_path).endswith('.jpg') # this was generated from the video file.
vid1.title = None
vid3.duration = None
vid5.view_count = None
vid6.url = None
vid9.poster_path = None
test_session.commit()
# Validation does not happen because all are still validated.
validate_videos()
# All videos need to be validated again.
for i in test_session.query(Video):
i.validated = False
# Use backup methods to fetch data.
vid1.info_json_path = None
vid3.info_json_path = None
vid5.info_json_path = None
vid6.info_json_path.path.write_text(json.dumps({'url': 'https://example.com/url'}))
test_session.commit()
validate_videos()
assert all([i.validated for i in test_session.query(Video)])
assert vid1.title != "info's title" # file name is used.
assert vid3.duration == 5 # video file duration is used.
assert vid5.view_count is None
assert vid6.url == 'https://example.com/url' # url is used as backup url.
# Posters are both JPEG format. Webp file is removed.
assert vid7.poster_path.path.is_file()
assert vid8.poster_path.path.is_file()
assert not vid8.poster_path.path.with_suffix('.webp').is_file()
assert str(vid9.poster_path).endswith('.jpg') # the generated file was rediscovered.
def test_validate_video_exception(test_session, simple_channel, video_factory):
"""
Test that even if a Video cannot be validated, the other Videos will still be validated.
"""
vid1 = video_factory(simple_channel.id, with_video_file=True)
vid2 = video_factory(simple_channel.id, with_video_file=True)
# The validator should not even check this Video because it does not have a video file.
vid3 = video_factory(simple_channel.id)
vid3.video_path = None
assert not all([i.validated for i in test_session.query(Video)])
test_session.commit()
with mock.patch('modules.videos.lib.process_video_info_json') as mock_process_video_info_json:
mock_process_video_info_json.side_effect = [
Exception('skip this video'),
(None, None, None, None),
('should not be set to video 3', None, None, None), # This should not be called!
]
validate_videos()
# Only first two videos are validated.
assert mock_process_video_info_json.call_count == 2, mock_process_video_info_json.call_count
test_session.commit()
assert not vid1.validated
assert vid2.validated
assert not vid3.validated
@pytest.mark.parametrize('file_name,expected', [
('channel_20000101_12345678910_ some title.mp4', ('channel', '20000101', '12345678910', 'some title')),
('channel name_NA_12345678910_ some title.mp4', ('channel name', None, '12345678910', 'some title')),
('20000101 foo.mp4', (None, None, None, '20000101 foo')),
('20000101foo.mp4', (None, None, None, '20000101foo')),
('something 20000101 foo.mp4', (None, None, None, 'something 20000101 foo')),
('something_20000101_foo.mp4', ('something', '20000101', None, 'foo')),
('foo .mp4', (None, None, None, 'foo')),
('NA_20000303_vp91w5_Bob&apos;s Pancakes.mp4', (None, '20000303', 'vp91w5', 'Bob&apos;s Pancakes')),
('NA_NA_vp91w5_Bob&apos;s Pancakes.mp4', (None, None, 'vp91w5', 'Bob&apos;s Pancakes')),
])
def test_parse_video_file_name(file_name, expected):
"""
A Video's title can be parsed from the video file.
"""
video_path = pathlib.Path(file_name)
assert parse_video_file_name(video_path) == expected, f'{file_name} != {expected}'
def test_validate_does_not_generate_when_disabled(test_session, channel_factory, video_factory):
"""
A poster should not be generated if the Video's Channel forbids it.
"""
channel1 = channel_factory() # posters should not be generated by default.
channel2 = channel_factory()
channel2.generate_posters = True
vid1 = video_factory(channel1.id, with_video_file=True)
vid2 = video_factory(channel2.id, with_video_file=True)
# webp will not be converted unless generate_posters is enabled.
vid3 = video_factory(channel1.id, with_video_file=True, with_poster_ext='webp')
test_session.commit()
assert not vid1.poster_path
assert not vid2.poster_path
assert str(vid3.poster_path).endswith('webp')
validate_videos()
assert not vid1.poster_path
assert vid2.poster_path
assert str(vid3.poster_path).endswith('webp')