import json import pathlib import shutil import sqlite3 import tempfile import threading from datetime import timedelta from http import HTTPStatus import mock import pytest from modules.videos.channel import lib from modules.videos.common import get_videos_directory from modules.videos.conftest import simple_channel from modules.videos.downloader import ChannelDownloader from modules.videos.lib import save_channels_config, get_channels_config from modules.videos.models import Channel, Video from wrolpi.collections.models import Collection from wrolpi.common import get_relative_to_media_directory, walk from wrolpi.conftest import production_like_sessions, write_lock_held_briefly from wrolpi.dates import now from wrolpi.db import get_db_session from wrolpi.downloader import Download, DownloadResult from wrolpi.files.models import Directory from wrolpi.test.common import assert_dict_contains @pytest.mark.asyncio async def test_get_channels(test_directory, channel_factory, async_client): channel_factory() channel_factory() channel_factory() request, response = await async_client.get('/api/videos/channels') assert response.status_code == HTTPStatus.OK assert len(response.json['channels']) == 3 @pytest.mark.asyncio async def test_get_video(async_client, test_session, simple_channel, video_factory): """Test that you get can information about a video. Test that video file can be gotten.""" now_ = now() video1 = video_factory(channel_id=simple_channel.id, title='vid1') video1.file_group.published_datetime = now_ video2 = video_factory(channel_id=simple_channel.id, title='vid2') video2.file_group.published_datetime = now_ + timedelta(seconds=1) test_session.commit() # Test that a 404 is returned when no video exists _, response = await async_client.get('/api/videos/10') assert response.status_code == HTTPStatus.NOT_FOUND, response.json assert response.json == {'code': 'UNKNOWN_VIDEO', 'error': 'Cannot find Video with file_group_id 10', 'message': 'The video could not be found.'} # Get the video info we inserted _, response = await async_client.get(f'/api/videos/{video1.file_group_id}') assert response.status_code == HTTPStatus.OK, response.json assert_dict_contains(response.json['file_group'], {'title': 'vid1'}) # The next video is included. assert response.json['prev'] is None assert_dict_contains(response.json['next'], {'title': 'vid2'}) @pytest.mark.asyncio async def test_channel_no_download_frequency(async_client, test_session, test_directory, simple_channel, test_download_manager): """A channel does not require a download frequency.""" # No downloads are scheduled. assert len(test_download_manager.get_downloads(test_session)) == 0 # Get the Channel request, response = await async_client.get('/api/videos/channels/1') assert response.status_code == HTTPStatus.OK channel = response.json['channel'] assert not channel['downloads'] # Update the Channel with a frequency. new_download = {'urls': [simple_channel.url], 'frequency': 10, 'downloader': ChannelDownloader.name} request, response = await async_client.post('/api/download', json=new_download) assert response.status_code == HTTPStatus.CREATED, response.json # Download is scheduled. Download is related to Channel. assert len(test_download_manager.get_downloads(test_session)) == 1 request, response = await async_client.get('/api/videos/channels/1') channel = response.json['channel'] assert channel['downloads'] downloads = channel['downloads'] assert len(downloads) == 1 and downloads[0]['url'] == simple_channel.url assert test_session.query(Download).one() download_id = channel['downloads'][0]['id'] # Deleting Download does not delete Channel. request, response = await async_client.delete(f'/api/download/{download_id}') assert response.status_code == HTTPStatus.NO_CONTENT, response.json assert not test_download_manager.get_downloads(test_session) assert not test_session.query(Download).all() assert test_session.query(Channel).count() == 1 @pytest.mark.asyncio async def test_video_file_name(test_session, simple_video, async_client): """If a Video has no title, the front-end can use the file name as the title.""" _, resp = await async_client.get(f'/api/videos/{simple_video.file_group_id}') assert resp.status_code == HTTPStatus.OK assert resp.json['file_group']['video']['video_path'] == 'simple_video.mp4' assert resp.json['file_group']['video'].get('stem') == 'simple_video' @pytest.mark.asyncio async def test_channel_conflicts(async_client, test_session, test_directory): channel_directory = tempfile.TemporaryDirectory(dir=test_directory).name pathlib.Path(channel_directory).mkdir() new_channel = dict( directory=channel_directory, name='Example Channel 1', url='https://example.com/channel1', ) async def _post_channel(channel): return await async_client.post('/api/videos/channels', content=json.dumps(channel)) # Create it request, response = await _post_channel(new_channel) assert response.status_code == HTTPStatus.CREATED # Name is an exact match channel_directory2 = tempfile.TemporaryDirectory(dir=test_directory).name pathlib.Path(channel_directory2).mkdir() new_channel = dict( directory=channel_directory2, name='Example Channel 1', ) request, response = await _post_channel(new_channel) assert response.status_code == HTTPStatus.BAD_REQUEST assert response.json == {'cause': {'code': 'CHANNEL_NAME_CONFLICT', 'error': 'Bad Request', 'message': 'The channel name is already taken.'}, 'code': 'VALIDATION_ERROR', 'error': 'Bad Request', 'message': 'Could not validate the contents of the request'} # Directory was already used new_channel = dict( directory=channel_directory, name='name is fine', ) request, response = await _post_channel(new_channel) assert response.status_code == HTTPStatus.BAD_REQUEST assert response.json == {'cause': {'code': 'CHANNEL_DIRECTORY_CONFLICT', 'error': 'Bad Request', 'message': 'The directory is already used by another channel.'}, 'code': 'VALIDATION_ERROR', 'error': 'Bad Request', 'message': 'Could not validate the contents of the request'} # URL is already used channel_directory3 = tempfile.TemporaryDirectory(dir=test_directory).name pathlib.Path(channel_directory3).mkdir() new_channel = dict( directory=channel_directory3, name='name is fine', url='https://example.com/channel1', ) request, response = await _post_channel(new_channel) assert response.status_code == HTTPStatus.BAD_REQUEST assert response.json == {'cause': {'code': 'CHANNEL_URL_CONFLICT', 'error': 'Bad Request', 'message': 'The URL is already used by another channel.'}, 'code': 'VALIDATION_ERROR', 'error': 'Bad Request', 'message': 'Could not validate the contents of the request'} @pytest.mark.asyncio async def test_channel_empty_url_doesnt_conflict(async_client, test_session, test_directory): """Two channels with empty URLs shouldn't conflict""" channel_directory = tempfile.TemporaryDirectory(dir=test_directory).name pathlib.Path(channel_directory).mkdir() new_channel = dict(name='Fooz', directory=channel_directory) request, response = await async_client.post('/api/videos/channels', json=new_channel) assert response.status_code == HTTPStatus.CREATED, response.json location = response.headers['Location'] channel_directory2 = tempfile.TemporaryDirectory(dir=test_directory).name pathlib.Path(channel_directory2).mkdir() new_channel = dict(name='Barz', directory=channel_directory2) request, response = await async_client.post('/api/videos/channels', json=new_channel) assert response.status_code == HTTPStatus.CREATED, response.json assert location != response.headers['Location'] @pytest.mark.asyncio async def test_channel_download_requires_refresh( async_client, test_session, mock_video_extract_info, download_channel, video_download_manager, video_factory, events_fixture, await_switches, await_background_tasks): """A Channel cannot be downloaded until it has been refreshed. Videos already downloaded are not downloaded again.""" vid = video_factory(channel_id=download_channel.id, with_video_file=True, with_poster_ext='jpg') vid.file_group.url = 'https://example.com/1' d = download_channel.downloads[0] d.next_download = None test_session.commit() assert not d.next_download def assert_refreshed(expected: bool): channel: Channel = test_session.query(Channel).one() assert channel.refreshed == expected assert bool(channel.info_json) == expected assert_refreshed(False) test_session.commit() downloaded_urls = [] async def fake_execute(_, prepared, ctx, download=None): # Track all downloaded URLs and short-circuit by returning a DownloadResult. # Per the dispatch contract (wrolpi/downloader.py: signal_download_download), # if execute_download returns a DownloadResult, finalize_download is skipped. downloaded_urls.append(prepared.url) return DownloadResult(success=True) with mock.patch('modules.videos.downloader.VideoDownloader.execute_download', fake_execute): entries = [ dict(id=vid.source_id, view_count=0, webpage_url='https://example.com/1'), # Already downloaded. dict(id='not downloaded', view_count=0, webpage_url='https://example.com/2'), ] mock_video_extract_info.return_value = dict(entries=entries, url=download_channel.url, uploader='some uploader', channel_id='the id', id='the id') await video_download_manager.do_downloads() await video_download_manager.wait_for_all_downloads() await await_switches() # Wait for background tasks including channel refresh which processes the file worker queue await await_background_tasks() # Channel was refreshed before downloading videos. assert_refreshed(True) assert d.next_download # Only the missing video was downloaded. assert downloaded_urls == ['https://example.com/2'] # Should not send user-facing events - only internal FileWorker events are allowed. user_facing_events = ['user_notify_message', 'directory_refresh', 'files_refreshed'] for event_name in user_facing_events: events_fixture.assert_no_event(event_name) @pytest.mark.asyncio async def test_channel_post_directory(test_session, async_client, test_directory): """A Channel can be created with or without an existing directory.""" # Channel can be created with a directory which is not on disk. data = dict(name='foo', directory='foo') request, response = await async_client.post('/api/videos/channels', content=json.dumps(data)) assert response.status_code == HTTPStatus.CREATED directory = test_session.query(Channel).filter_by(id=1).one().directory assert (test_directory / 'foo') == directory assert directory.is_dir() assert directory.is_absolute() @pytest.mark.asyncio async def test_channel_post_survives_concurrent_writer(test_session, async_client, test_directory): """Creating a Channel must not fail because something else wrote in the meantime. Regression test for `database is locked` on `INSERT INTO collection` from POST /api/videos/channels while background workers (config saves, refreshes) were writing. The request session begins *deferred*, `create_channel` reads (the conflict check) before it INSERTs, and SQLite refuses that mid-transaction lock upgrade instantly -- busy_timeout is skipped for upgrades. The handler must begin as a writer so the concurrent writer waits on it (busy_timeout) instead. """ db_file = test_session.get_bind().url.database data = json.dumps(dict(name='Contended Channel', directory='contended')) # Release anything the fixtures left open on the shared session's connection. test_session.commit() def commit_from_another_connection(): # The "background worker": a committed write from a fresh connection. other = sqlite3.connect(db_file, timeout=30, check_same_thread=False) other.isolation_level = None other.execute('PRAGMA busy_timeout=30000') other.execute('BEGIN IMMEDIATE') version, = other.execute('PRAGMA user_version').fetchone() other.execute(f'PRAGMA user_version={version + 1}') other.execute('COMMIT') other.close() writer = threading.Thread(target=commit_from_another_connection, daemon=True) real_check = lib.check_for_channel_conflicts def check_then_let_another_worker_write(*args, **kwargs): # The handler has now read; something else writes before the handler's INSERT. A deferred # handler lets the writer through at once and then fails its own INSERT; a handler that # began IMMEDIATE holds the lock, so the writer blocks here and waits its turn. result = real_check(*args, **kwargs) writer.start() writer.join(timeout=0.5) return result with production_like_sessions(test_session) as maker: with mock.patch('modules.videos.channel.lib.check_for_channel_conflicts', check_then_let_another_worker_write): request, response = await async_client.post('/api/videos/channels', content=data) writer.join(timeout=30) assert response.status_code == HTTPStatus.CREATED, response.body assert not writer.is_alive(), 'the concurrent writer never got its turn' # Committed before the handler returned, so another worker sees it. assert maker().query(Collection).filter_by(name='Contended Channel').one() @pytest.mark.asyncio async def test_channel_by_id(test_session, async_client, simple_channel, simple_video): request, response = await async_client.get(f'/api/videos/channels/{simple_channel.id}') assert response.status_code == HTTPStatus.OK channel = response.json['channel'] # needs_reorganization should be included for UI to display reorganization status assert 'needs_reorganization' in channel # The yt-dlp catalog is large and deferred; it is never loaded for a response. assert 'info_json' not in channel @pytest.mark.asyncio async def test_channel_crud(test_session, async_client, test_directory, test_download_manager): channel_directory = test_directory / 'channel directory' channel_directory.mkdir() new_channel = dict( directory=str(channel_directory), name=' Example Channel 1 ', url='https://example.com/channel1', ) # Channel doesn't exist request, response = await async_client.get('/api/videos/channels/examplechannel1') assert response.status_code == HTTPStatus.NOT_FOUND, f'Channel exists: {response.json}' # Create it request, response = await async_client.post('/api/videos/channels', content=json.dumps(new_channel)) assert response.status_code == HTTPStatus.CREATED, response.content location = response.headers['Location'] request, response = await async_client.get(location) assert response.status_code == HTTPStatus.OK, response.json created = response.json['channel'] assert created assert created['id'] # No frequency was provided. No download exists. assert test_session.query(Download).count() == 0 # Channel name leading/trailing whitespace should be stripped assert created['name'] == 'Example Channel 1' # Channel directory should be relative to the media directory assert not pathlib.Path(created['directory']).is_absolute(), \ f'Channel directory is absolute: {created["directory"]}' # Can't create it again request, response = await async_client.post('/api/videos/channels', content=json.dumps(new_channel)) assert response.status_code == HTTPStatus.BAD_REQUEST async def put_and_fetch(new_channel_): request, response = await async_client.put(location, content=json.dumps(new_channel_)) assert response.status_code == HTTPStatus.NO_CONTENT, response.status_code request, response = await async_client.get(location) assert response.status_code == HTTPStatus.OK channel = response.json['channel'] return channel # Update it new_channel['name'] = 'Example Channel 2' new_channel['directory'] = str(new_channel['directory']) # noqa channel = await put_and_fetch(new_channel) assert channel['id'] == 1 assert channel['name'] == 'Example Channel 2' assert channel['directory'] == channel_directory.name assert channel['url'] == 'https://example.com/channel1' # Can't update channel that doesn't exist request, response = await async_client.put('/api/videos/channels/doesnt_exist', content=json.dumps(new_channel)) assert response.status_code == HTTPStatus.NOT_FOUND # Delete the new channel request, response = await async_client.delete(location) assert response.status_code == HTTPStatus.NO_CONTENT assert test_session.query(Download).count() == 0 # Cant delete it again request, response = await async_client.delete(location) assert response.status_code == HTTPStatus.NOT_FOUND @pytest.mark.asyncio async def test_change_channel_url(async_client, test_session, test_download_manager, download_channel, test_download_manager_config): # Change the Channel's URL. download_channel.update({'url': 'https://example.com/new-url'}) test_session.commit() channel = test_session.query(Channel).one() assert channel.url == 'https://example.com/new-url', "Channel's URL was not changed." @pytest.mark.asyncio async def test_search_videos_channel(async_client, test_session, video_factory, test_directory): from wrolpi.collections import Collection with get_db_session(commit=True) as session: # Create Collections first collection1 = Collection(name='Foo', kind='channel', directory=test_directory / 'foo') collection2 = Collection(name='Bar', kind='channel', directory=test_directory / 'bar') session.add_all([collection1, collection2]) session.flush() # Create Channels linked to Collections channel1 = Channel(collection_id=collection1.id) channel2 = Channel(collection_id=collection2.id) session.add(channel1) session.add(channel2) session.flush() session.refresh(channel1) session.refresh(channel2) # Channels don't have videos yet d = dict(channel_id=channel1.id) request, response = await async_client.post(f'/api/videos/search', content=json.dumps(d)) assert response.status_code == HTTPStatus.OK assert len(response.json['file_groups']) == 0 with get_db_session(commit=True) as session: vid1 = video_factory(title='vid1', channel_id=channel2.id) vid2 = video_factory(title='vid2', channel_id=channel1.id) session.add(vid1) session.add(vid2) # Videos are gotten by their respective channels request, response = await async_client.post(f'/api/videos/search', content=json.dumps(d)) assert response.status_code == HTTPStatus.OK assert len(response.json['file_groups']) == 1 assert response.json['totals']['file_groups'] == 1 assert_dict_contains(response.json['file_groups'][0], dict(primary_path='foo/vid2.mp4', video=dict(channel_id=channel1.id))) d = dict(channel_id=channel2.id) request, response = await async_client.post(f'/api/videos/search', content=json.dumps(d)) assert response.status_code == HTTPStatus.OK assert len(response.json['file_groups']) == 1 assert_dict_contains(response.json['file_groups'][0], dict(primary_path='bar/vid1.mp4', video=dict(channel_id=channel2.id))) @pytest.mark.asyncio async def test_get_channel_videos_pagination(test_session, video_factory, assert_video_search, channel_factory): # Create two channels, one with 50 videos... channel1, channel2 = channel_factory(), channel_factory() for i in range(50): video_factory(channel_id=channel1.id) # The other channel only has one video. video_factory(channel_id=channel2.id) video_factory(title='vid2', channel_id=channel2.id) test_session.commit() # Get first, second, third, and empty pages of videos. tests = [ # (offset, video_count) (0, 20), (20, 20), (40, 10), (50, 0), ] last_ids = [] for offset, video_count in tests: _, response = await assert_video_search(channel_id=channel1.id, order_by='published_datetime', offset=offset, limit=20) assert len(response.json['file_groups']) == video_count, \ f'Returned videos does not match for ({offset}, {video_count})' current_ids = [i['id'] for i in response.json['file_groups']] assert current_ids != last_ids, f'IDs are unchanged current_ids={current_ids}' last_ids = current_ids @pytest.mark.asyncio async def test_channel_download_id(async_client, test_session, tag_factory, simple_channel, test_download_manager, test_downloader): tag = await tag_factory() # A recurring Download can be displayed on a Channel's page. body = dict( urls=['https://example.com/channel1'], tag_names=[tag.name], downloader=test_downloader.name, settings=dict(), collection_id=simple_channel.collection_id, frequency=120, ) request, response = await async_client.post(f'/api/download', json=body) assert response.status_code == HTTPStatus.CREATED download = test_session.query(Download).one() assert download.url == 'https://example.com/channel1' assert download.collection_id == simple_channel.collection_id # A Collection relationship can be removed from a Download. body = dict( urls=['https://example.com/channel1'], tag_names=[tag.name], downloader=test_downloader.name, settings=dict(), collection_id=None, frequency=240, ) request, response = await async_client.put(f'/api/download/{download.id}', json=body) assert response.status_code == HTTPStatus.NO_CONTENT download = test_session.query(Download).one() assert download.collection_id is None assert download.frequency == 240 # A once-Download cannot be associated with a Collection. body = dict( urls=['https://example.com/channel1'], tag_names=[tag.name], downloader=test_downloader.name, collection_id=simple_channel.collection_id, ) request, response = await async_client.put(f'/api/download/{download.id}', json=body) assert response.status_code == HTTPStatus.BAD_REQUEST @pytest.mark.asyncio async def test_tag_channel(async_client, test_session, test_directory, channel_factory, tag_factory, video_factory, test_channels_config, test_download_manager, test_downloader): """A single Tag can be applied to a Channel.""" # Create channel directory in the usual videos directory. videos_directory = get_videos_directory() channel = channel_factory(name='Channel Name', download_frequency=120) channel_directory = channel.directory tag = await tag_factory('Tag Name') v1, v2 = video_factory(title='video1', channel_id=channel.id), video_factory(title='video2', channel_id=channel.id) # Create recurring download which uses the Channel's directory. download = test_download_manager.recurring_download(test_session, 'https://example.com/1', 60, test_downloader.name, destination=str(test_directory / 'videos/Channel Name')) test_session.commit() save_channels_config() # Config stores relative paths for portability assert get_channels_config().channels[0]['directory'] == 'videos/Channel Name' # Channel download downloads into the Channel's directory. assert channel.downloads[0].destination == test_directory / 'videos/Channel Name' # Make extra file in the Channel's directory, it should be moved. (channel_directory / 'extra file.txt').write_text('extra file contents') # Channel directory is in the Videos directory. assert channel.directory == channel_directory # Videos are in the Channel's Directory. assert str(get_relative_to_media_directory(v1.video_path)) == 'videos/Channel Name/video1.mp4' assert str(get_relative_to_media_directory(v2.video_path)) == 'videos/Channel Name/video2.mp4' video_files = [i for i in walk(videos_directory) if i.is_file()] # 2 videos + extra file + optional .ffprobe.json files (1 per video) assert len(video_files) >= 3 # Count non-ffprobe files for comparison (modelers may create .ffprobe.json during post-processing) initial_non_ffprobe_count = len([i for i in video_files if not i.name.endswith('.ffprobe.json')]) # Channel's directory is indexed. assert [i.path for i in test_session.query(Directory)] == [channel.directory, ] body = dict(tag_name=tag.name, directory='videos/Tag Name/Channel Name') request, response = await async_client.post(f'/api/videos/channels/{channel.id}/tag', json=body) assert response.status_code == HTTPStatus.NO_CONTENT channel = test_session.query(Channel).one() assert channel.tag_name == tag.name # Channel was moved to Tag's directory, old directory was removed new_channel_directory = test_directory / 'videos/Tag Name/Channel Name' assert channel.directory == new_channel_directory, 'Channel directory should have been changed.' assert new_channel_directory.is_dir(), 'Channel should have been moved.' assert next(new_channel_directory.iterdir(), None), 'Channel videos should have been moved.' assert not channel_directory.exists(), 'Old Channel directory should have been deleted.' # Channel download goes into the Channel's directory. d1, d2 = test_session.query(Download).all() assert d1.destination == test_directory / 'videos/Tag Name/Channel Name', f'{d1} was not moved' assert d2.destination == test_directory / 'videos/Tag Name/Channel Name', f'{d2} was not moved' # Directory record was replaced. assert [i.path for i in test_session.query(Directory)] == [channel.directory, ] # Videos were moved v1, v2 = test_session.query(Video).order_by(Video.id).all() assert str(get_relative_to_media_directory(v1.video_path)) == 'videos/Tag Name/Channel Name/video1.mp4' assert str(get_relative_to_media_directory(v2.video_path)) == 'videos/Tag Name/Channel Name/video2.mp4' # Extra file was also moved. assert (new_channel_directory / 'extra file.txt').read_text() == 'extra file contents' # No unexpected files were created during the move (modelers may create .ffprobe.json cache files). final_non_ffprobe_count = len([i for i in walk(videos_directory) if i.is_file() and not i.name.endswith('.ffprobe.json')]) assert final_non_ffprobe_count == initial_non_ffprobe_count # Config stores relative paths for portability assert get_channels_config().channels[0]['directory'] == 'videos/Tag Name/Channel Name' # Remove tag, Channel/Videos should be moved back. body = dict(tag_name=None, directory='videos/Channel Name') request, response = await async_client.post(f'/api/videos/channels/{channel.id}/tag', json=body) assert response.status_code == HTTPStatus.NO_CONTENT, response.content.decode() channel = test_session.query(Channel).one() assert channel.tag_name is None assert str(channel.directory) == str(test_directory / 'videos/Channel Name') v1, v2 = test_session.query(Video).order_by(Video.id).all() assert str(get_relative_to_media_directory(v1.video_path)) == 'videos/Channel Name/video1.mp4' assert str(get_relative_to_media_directory(v2.video_path)) == 'videos/Channel Name/video2.mp4' assert not (test_directory / 'videos/Tag Name/Channel Name').exists() # Downloads are moved back. d1, d2 = test_session.query(Download).all() assert d1.destination == test_directory / 'videos/Channel Name', f'{d1} was not moved' assert d2.destination == test_directory / 'videos/Channel Name', f'{d2} was not moved' assert [i.path for i in test_session.query(Directory)] == [channel.directory, ] # Channel can be Tagged, without moving directories. body = dict(tag_name=tag.name) request, response = await async_client.post(f'/api/videos/channels/{channel.id}/tag', json=body) assert response.status_code == HTTPStatus.NO_CONTENT, response.content.decode() channel = test_session.query(Channel).one() assert channel.tag_name == 'Tag Name' assert str(channel.directory) == str(test_directory / 'videos/Channel Name') assert str(get_relative_to_media_directory(v1.video_path)) == 'videos/Channel Name/video1.mp4' assert str(get_relative_to_media_directory(v2.video_path)) == 'videos/Channel Name/video2.mp4' # Downloads were not changed. d1, d2 = test_session.query(Download).all() assert d1.destination == test_directory / 'videos/Channel Name', f'{d1} was not moved' assert d2.destination == test_directory / 'videos/Channel Name', f'{d2} was not moved' assert [i.path for i in test_session.query(Directory)] == [channel.directory, ] @pytest.mark.asyncio async def test_create_channel_with_tag(async_client, test_session, test_directory, tag_factory): """A Channel can be created with a single Tag.""" tag = await tag_factory() assert not (test_directory / 'some/deep/new/directory').exists() body = dict( name='Channel Name', directory='some/deep/new/directory', tag_name=tag.name, ) request, response = await async_client.post('/api/videos/channels', json=body) assert response.status_code == HTTPStatus.CREATED, response.content.decode() channel = test_session.query(Channel).one() assert channel.tag_name == tag.name assert channel.directory == (test_directory / 'some/deep/new/directory') assert (test_directory / 'some/deep/new/directory').is_dir() @pytest.mark.asyncio async def test_move_channel(async_client, test_session, test_directory, channel_factory, tag_factory, video_factory, test_channels_config, test_download_manager, test_downloader): """A Channel can be moved.""" channel = channel_factory(name='Channel Name') vid1 = video_factory(channel_id=channel.id) vid2 = video_factory(channel_id=channel.id) test_session.commit() assert str(channel.directory) == str(test_directory / 'videos/Channel Name') assert str(vid1.video_path).startswith(str(test_directory / 'videos/Channel Name/')) assert str(vid2.video_path).startswith(str(test_directory / 'videos/Channel Name/')) body = dict( name=channel.name, directory=str(test_directory / 'videos/New Directory'), ) request, response = await async_client.put(f'/api/videos/channels/{channel.id}', json=body) assert response.status_code == HTTPStatus.NO_CONTENT, response.content.decode() channel = Channel.find_by_id(test_session, channel.id) assert str(channel.directory) == str(test_directory / 'videos/New Directory') assert str(vid1.video_path).startswith(str(test_directory / 'videos/New Directory/')) assert str(vid2.video_path).startswith(str(test_directory / 'videos/New Directory/')) assert [i.path for i in test_session.query(Directory)] == [test_directory / 'videos/New Directory', ] @pytest.mark.asyncio async def test_move_channel_after_terminal_move(async_client, test_session, test_directory, channel_factory, tag_factory, video_factory, test_channels_config, test_download_manager, test_downloader): """A Channel can be moved in the UI after being moved in the Terminal.""" channel = channel_factory(name='Channel Name') vid1 = video_factory(channel_id=channel.id) test_session.commit() assert str(channel.directory) == str(test_directory / 'videos/Channel Name') assert str(vid1.video_path).startswith(str(test_directory / 'videos/Channel Name/')) # mv 'videos/Channel Name' 'videos/New Directory' shutil.move(channel.directory, test_directory / 'videos/New Directory') body = dict( name=channel.name, directory=str(test_directory / 'videos/New Directory'), ) request, response = await async_client.put(f'/api/videos/channels/{channel.id}', json=body) assert response.status_code == HTTPStatus.NO_CONTENT, response.content.decode() channel = Channel.find_by_id(test_session, channel.id) vid1 = test_session.query(Video).one() assert str(channel.directory) == str(test_directory / 'videos/New Directory') assert str(vid1.video_path).startswith(str(test_directory / 'videos/New Directory/')) @pytest.mark.asyncio async def test_search_tagged_channels(async_client, test_session, channel_factory, tag_factory, video_factory): """Tagged Channels can be searched.""" tag = await tag_factory() test_session.commit() # Can search when empty. body = dict(tag_names=[tag.name]) request, response = await async_client.post('/api/videos/channels/search', json=body) assert response.status_code == HTTPStatus.OK assert len(response.json['channels']) == 0, 'No Channels should be tagged' channel1 = channel_factory(tag_name=tag.name) channel2 = channel_factory() test_session.commit() body = dict(tag_names=[tag.name]) request, response = await async_client.post('/api/videos/channels/search', json=body) assert response.status_code == HTTPStatus.OK assert len(response.json['channels']) == 1, 'Only one Channel is tagged' assert response.json['channels'][0]['id'] == channel1.id @pytest.mark.asyncio async def test_search_channels_by_name(async_client, test_session, channel_factory, tag_factory): """Channels can be searched by their name, alone or with Tags.""" tag = await tag_factory() cooking = channel_factory(name='Cooking Show', tag_name=tag.name) channel_factory(name='Cooking Basics') channel_factory(name='Gardening') test_session.commit() async def search(body): request, response = await async_client.post('/api/videos/channels/search', json=body) assert response.status_code == HTTPStatus.OK return sorted(i['name'] for i in response.json['channels']) assert await search(dict(search_str='cooking')) == ['Cooking Basics', 'Cooking Show'] # The name and the Tags must both match. assert await search(dict(search_str='cooking', tag_names=[tag.name])) == ['Cooking Show'] assert await search(dict(search_str='gardening', tag_names=[tag.name])) == [] # Nothing to search by matches nothing. assert await search(dict(search_str='')) == [] assert await search(dict()) == [] # The Other tab's estimate counts the same Channels. for body in (dict(search_str='cooking'), dict(search_str='cooking', tag_names=[tag.name]), dict(search_str='')): request, response = await async_client.post('/api/search_other_estimates', json=body) assert response.status_code == HTTPStatus.OK assert response.json['others']['channel_count'] == len(await search(body)), body @pytest.mark.asyncio @pytest.mark.parametrize('step', ['update', 'tag', 'delete']) async def test_channel_writes_wait_for_a_concurrent_writer(async_client, test_session, test_directory, channel_factory, tag_factory, step): """Updating, tagging and deleting a Channel read before they write, so each must begin as a writer: SQLite fails a deferred transaction's lock upgrade instantly ("database is locked") if another connection wrote meanwhile.""" channel = channel_factory(name='Lock Channel') tag = await tag_factory() test_session.commit() channel_id, directory = channel.id, str(channel.directory) db_file = test_session.get_bind().url.database with production_like_sessions(test_session) as maker: # Held past any slow work a request does before it writes. with write_lock_held_briefly(db_file, seconds=3): if step == 'update': body = dict(name='Renamed Channel', directory=directory) _, response = await async_client.put(f'/api/videos/channels/{channel_id}', json=body) elif step == 'tag': body = dict(tag_name=tag.name, directory=str(get_relative_to_media_directory(directory))) _, response = await async_client.post(f'/api/videos/channels/{channel_id}/tag', json=body) else: _, response = await async_client.delete(f'/api/videos/channels/{channel_id}') assert response.status_code == HTTPStatus.NO_CONTENT, response.body with maker() as session: channel = session.query(Channel).filter_by(id=channel_id).one_or_none() if step == 'update': assert channel.name == 'Renamed Channel' elif step == 'tag': assert channel.tag_name == tag.name else: assert channel is None