wrolpi/modules/videos/test/test_common.py

279 lines
10 KiB
Python

import pathlib
import subprocess
import tempfile
from unittest import mock
from unittest.mock import Mock
import pytest
from PIL import Image
from modules.videos.models import Channel, Video
from modules.videos.test.common import create_channel_structure
from wrolpi.common import get_absolute_media_path
from wrolpi.db import get_db_session
from wrolpi.test.common import ExtendedTestCase, build_test_directories, wrap_test_db
from wrolpi.vars import PROJECT_DIR
from ..common import get_matching_directories, convert_image, bulk_validate_posters, remove_duplicate_video_paths, \
update_view_count, get_video_duration
class TestCommon(ExtendedTestCase):
def test_get_absolute_media_path(self):
wrolpi = get_absolute_media_path('videos/wrolpi')
assert str(wrolpi).endswith('wrolpi')
def test_matching_directories(self):
structure = [
'foo/qux/',
'Bar/',
'baz/baz'
'barr',
'bazz',
]
with build_test_directories(structure) as temp_dir:
temp_dir = pathlib.Path(temp_dir)
# No directories have c
matches = get_matching_directories(temp_dir / 'c')
assert matches == []
# Get all directories starting with f
matches = get_matching_directories(temp_dir / 'f')
assert matches == [str(temp_dir / 'foo')]
# Get all directories starting with b, ignore case
matches = get_matching_directories(temp_dir / 'b')
assert matches == [str(temp_dir / 'Bar'), str(temp_dir / 'baz')]
# baz matches, but it has no subdirectories
matches = get_matching_directories(temp_dir / 'baz')
assert matches == [str(temp_dir / 'baz')]
# foo is an exact match, return subdirectories
matches = get_matching_directories(temp_dir / 'foo')
assert matches == [str(temp_dir / 'foo/qux')]
def test_get_video_duration(self):
"""
Video duration can be retrieved from the video file.
"""
video_path = PROJECT_DIR / 'test/videos/wrolpi/big_buck_bunny_720p_1mb.mp4'
self.assertEqual(get_video_duration(video_path), 5)
video_path = PROJECT_DIR / 'test/videos/wrolpi/does not exist.mp4'
self.assertRaises(FileNotFoundError, get_video_duration, video_path)
video_path = str(PROJECT_DIR / 'test/videos/wrolpi/does not exist.mp4')
self.assertRaises(FileNotFoundError, get_video_duration, video_path)
with tempfile.NamedTemporaryFile() as fh:
self.assertRaises(subprocess.CalledProcessError, get_video_duration, fh.name)
@pytest.mark.parametrize(
'paths,expected',
(
(['1.mp4', ], ['1.mp4', ]),
(['1.mp4', '2.mp4'], ['1.mp4', '2.mp4']),
(['1.mp4', '1.ogg'], ['1.mp4']),
(['1.bad_ext', '1.other_ext'], ['1.bad_ext']),
)
)
def test_remove_duplicate_video_paths(paths, expected):
result = remove_duplicate_video_paths(map(pathlib.Path, paths))
assert sorted(result) == sorted(map(pathlib.Path, expected))
def test_convert_image():
"""
An image's format can be changed. This tests that convert_image() converts from a WEBP format, to a JPEG format.
"""
foo = Image.new('RGB', (25, 25), color='grey')
with tempfile.TemporaryDirectory() as tempdir:
tempdir = pathlib.Path(tempdir)
# Save the new image to "foo.webp".
existing_path = tempdir / 'foo.webp'
foo.save(existing_path)
assert Image.open(existing_path).format == 'WEBP'
destination_path = tempdir / 'foo.jpg'
assert not destination_path.is_file()
# Convert the WEBP to a JPEG. The WEBP image should be removed.
convert_image(existing_path, destination_path)
assert not existing_path.is_file()
assert destination_path.is_file()
assert Image.open(destination_path).format == 'JPEG'
@pytest.mark.parametrize(
'_structure,paths',
(
(
{'channel1': ['vid1.mp4']},
[
'channel1/vid1.mp4',
],
),
(
{'channel1': ['vid1.mp4'], 'channel2': ['vid1.mp4']},
[
'channel1/vid1.mp4',
'channel2/vid1.mp4',
],
),
(
{'channel1': ['vid1.mp4'], 'channel2': ['vid1.mp4', 'vid2.mp4', 'vid2.en.vtt']},
[
'channel1/vid1.mp4',
'channel2/vid1.mp4',
'channel2/vid2.mp4',
'channel2/vid2.en.vtt',
],
),
)
)
def test_create_db_structure(_structure, paths):
@create_channel_structure(_structure)
def test_func(tempdir):
assert isinstance(tempdir, pathlib.Path)
for path in paths:
path = (tempdir / path)
assert path.exists()
assert path.is_file()
with get_db_session() as session:
for channel_name in _structure:
channel = session.query(Channel).filter_by(name=channel_name).one()
assert (tempdir / channel_name).is_dir()
assert channel
assert channel.directory == tempdir / channel_name
assert len(channel.videos) == len([i for i in _structure[channel_name] if i.endswith('mp4')])
test_func()
@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='vid1.jpg').one()
assert vid1.validated_poster is False
vid2 = session.query(Video).filter_by(poster_path='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) == 'vid2.jpg'
assert all('webp' not in str(i.poster_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) == '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(
{
'channel1': ['vid1.mp4', 'vid1.jpg'],
'channel2': ['vid2.mp4'],
'channel3': ['vid3.mp4', 'vid4.mp4'],
}
)
def test_update_view_count(tempdir: pathlib.Path):
def check_view_counts(view_counts):
with get_db_session(commit=True) as session:
for source_id, view_count in view_counts.items():
vid = session.query(Video).filter_by(source_id=source_id).one()
assert vid.view_count == view_count
with get_db_session(commit=True) as session:
channel1, channel2, channel3 = session.query(Channel).order_by(Channel.id).all()
channel1.info_json = {'entries': [{'id': 'vid1.mp4', 'view_count': 10}]}
channel2.info_json = {'entries': [{'id': 'vid2.mp4', 'view_count': 11}, {'id': 'bad_id', 'view_count': 12}]}
channel3.info_json = {'entries': [{'id': 'vid3.mp4', 'view_count': 13}, {'id': 'vid4.mp4', 'view_count': 14}]}
# Use the video_path as a unique source_id
for v in session.query(Video).all():
v.source_id = str(v.video_path)
# Check all videos are empty.
check_view_counts({'vid1.mp4': None, 'vid2.mp4': None, 'vid3.mp4': None, 'vid4.mp4': None})
# Channel 1 is updated, the other channels are left alone.
update_view_count(channel1.id)
check_view_counts({'vid1.mp4': 10, 'vid2.mp4': None, 'vid3.mp4': None, 'vid4.mp4': None})
# Channel 2 is updated, the other channels are left alone. The 'bad_id' video is ignored.
update_view_count(channel2.id)
check_view_counts({'vid1.mp4': 10, 'vid2.mp4': 11, 'vid3.mp4': None, 'vid4.mp4': None})
# All videos are updated.
update_view_count(channel3.id)
check_view_counts({'vid1.mp4': 10, 'vid2.mp4': 11, 'vid3.mp4': 13, 'vid4.mp4': 14})
# An outdated view count will be overwritten.
with get_db_session(commit=True) as session:
vid = session.query(Video).filter_by(id=1).one()
vid.view_count = 8
check_view_counts({'vid1.mp4': 8, 'vid2.mp4': 11, 'vid3.mp4': 13, 'vid4.mp4': 14})
update_view_count(channel1.id)
check_view_counts({'vid1.mp4': 10, 'vid2.mp4': 11, 'vid3.mp4': 13, 'vid4.mp4': 14})