diff --git a/modules/videos/api.py b/modules/videos/api.py index fa64bb55..79c71aa2 100644 --- a/modules/videos/api.py +++ b/modules/videos/api.py @@ -103,9 +103,9 @@ async def refresh_videos(channel_links: list = None): @validate(FavoriteRequest) @openapi.response(HTTPStatus.OK, FavoriteResponse) @openapi.response(HTTPStatus.BAD_REQUEST, JSONErrorResponse) -async def favorite(_: Request, data: dict): - _favorite = video_lib.set_video_favorite(data['video_id'], data['favorite']) - ret = {'video_id': data['video_id'], 'favorite': _favorite} +async def favorite(_: Request, body: FavoriteRequest): + _favorite = video_lib.set_video_favorite(body.video_id, body.favorite) + ret = {'video_id': body.video_id, 'favorite': _favorite} return json_response(ret, HTTPStatus.OK) diff --git a/modules/videos/channel/api.py b/modules/videos/channel/api.py index 84fca5d5..51065a2b 100644 --- a/modules/videos/channel/api.py +++ b/modules/videos/channel/api.py @@ -12,7 +12,7 @@ from wrolpi.root_api import json_response from wrolpi.schema import JSONErrorResponse from .lib import get_minimal_channels, delete_channel, update_channel, get_channel, create_channel from ..schema import ChannelsResponse, ChannelResponse, ChannelPostRequest, \ - ChannelPostResponse, ChannelPutRequest, SuccessResponse + ChannelPostResponse, ChannelPutRequest channel_bp = Blueprint('Channel', url_prefix='/api/videos/channels') @@ -27,7 +27,7 @@ async def get_channels(_: Request): return json_response({'channels': channels}) -@channel_bp.route('/', methods=['GET', 'OPTIONS']) +@channel_bp.get('/') @openapi.description('Get a Channel') @openapi.response(HTTPStatus.OK, ChannelResponse) def channel_get(_: Request, link: str): @@ -42,14 +42,14 @@ def channel_get(_: Request, link: str): @openapi.response(HTTPStatus.BAD_REQUEST, JSONErrorResponse) @validate(ChannelPostRequest) @wrol_mode_check -def channel_post(_: Request, data: dict): +def channel_post(_: Request, body: ChannelPostRequest): try: # Channel directory is relative to the media directory. Channel directory may not be in "videos" directory! - data['directory'] = get_media_directory() / data['directory'] + body.directory = get_media_directory() / body.directory except UnknownDirectory: - if data['mkdir']: - make_media_directory(data['directory']) - data['directory'] = get_relative_to_media_directory(data['directory']) + if body.mkdir: + make_media_directory(body.directory) + body.directory = get_relative_to_media_directory(body.directory) else: raise # Channel directory is relative to the media directory. Channel directory may not be in "videos" directory! @@ -57,16 +57,7 @@ def channel_post(_: Request, data: dict): if data.get('mkdir') is True: make_media_directory(data['directory']) - if download_frequency := data.get('download_frequency'): - try: - download_frequency = int(download_frequency) - except ValueError: - if download_frequency in ('null', 'None'): - download_frequency = None - - data['download_frequency'] = download_frequency - - channel = create_channel(data) + channel = create_channel(body.__dict__) # TODO don't use the dataclass as a dict. # Refresh the videos asynchronously from ..api import refresh_videos @@ -78,14 +69,13 @@ def channel_post(_: Request, data: dict): @channel_bp.put('/') -@channel_bp.patch('/') @openapi.description('Update a Channel') @openapi.response(HTTPStatus.NO_CONTENT) @openapi.response(HTTPStatus.BAD_REQUEST, JSONErrorResponse) @validate(ChannelPutRequest) @wrol_mode_check -def channel_update(_: Request, link: str, data: dict): - channel = update_channel(data, link) +def channel_update(_: Request, link: str, body: ChannelPutRequest): + channel = update_channel(body.__dict__, link) return response.raw('', HTTPStatus.NO_CONTENT, headers={'Location': f'/api/videos/channels/{channel.link}'}) @@ -93,7 +83,6 @@ def channel_update(_: Request, link: str, data: dict): @channel_bp.delete('/') @openapi.description('Delete a Channel') @openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) -@validate(SuccessResponse) @wrol_mode_check def channel_delete(_: Request, link: str): delete_channel(link) diff --git a/modules/videos/channel/lib.py b/modules/videos/channel/lib.py index 2bf3059c..85f165d4 100644 --- a/modules/videos/channel/lib.py +++ b/modules/videos/channel/lib.py @@ -139,25 +139,18 @@ def update_channel(data: dict, link: str) -> Channel: if 'directory' in data: data['directory'] = str(data['directory']) - if 'download_frequency' in data: - try: - data['download_frequency'] = int(data['download_frequency']) - except ValueError: - if data['download_frequency'] in ('null', 'None', ''): - data['download_frequency'] = None - else: - raise APIError(f'Invalid download frequency {data["download_frequency"]}') - if data.get('match_regex') in ('None', 'null'): data['match_regex'] = None + data['link'] = data.get('link') or link + # Verify that the URL/Name/Link aren't taken check_for_channel_conflicts( session, id_=channel.id, url=data.get('url'), name=data.get('name'), - link=data.get('link'), + link=data['link'], directory=data.get('directory'), ) diff --git a/modules/videos/channel/test/test_api.py b/modules/videos/channel/test/test_api.py index 6e30d309..53d2c6c0 100644 --- a/modules/videos/channel/test/test_api.py +++ b/modules/videos/channel/test/test_api.py @@ -60,7 +60,7 @@ class TestVideoAPI(TestAPI): # Create it request, response = api_app.test_client.post('/api/videos/channels', content=json.dumps(new_channel)) - assert response.status_code == HTTPStatus.CREATED + assert response.status_code == HTTPStatus.CREATED, response.json location = response.headers['Location'] request, response = api_app.test_client.get(location) created = response.json['channel'] @@ -94,20 +94,6 @@ class TestVideoAPI(TestAPI): 'url': 'https://example.com/channel1', }) - # Patch it - patch = {'name': 'new name'} - request, response = api_app.test_client.patch(location, content=json.dumps(patch)) - assert response.status_code == HTTPStatus.NO_CONTENT, response.status_code - request, response = api_app.test_client.get(location) - assert response.status_code == HTTPStatus.OK - self.assertDictContains(response.json['channel'], { - 'id': 1, - 'name': 'new name', - 'directory': channel_directory_name, - 'match_regex': 'asdf', - 'url': 'https://example.com/channel1', - }) - # Can't update channel that doesn't exist request, response = api_app.test_client.put('/api/videos/channels/doesnt_exist', content=json.dumps(new_channel)) @@ -361,9 +347,7 @@ class TestVideoAPI(TestAPI): @wrap_test_db def test_channel_no_download_frequency(self): - """ - A channel does not require a download frequency. - """ + """A channel does not require a download frequency.""" channel_directory = tempfile.TemporaryDirectory(dir=self.tmp_dir.name).name os.mkdir(channel_directory) diff --git a/modules/videos/schema.py b/modules/videos/schema.py index 350d765f..ec1036a9 100644 --- a/modules/videos/schema.py +++ b/modules/videos/schema.py @@ -2,30 +2,33 @@ from dataclasses import dataclass, field from datetime import date, datetime from typing import List, Optional +from modules.videos.lib import DEFAULT_DOWNLOAD_FREQUENCY +from modules.videos.video.lib import DEFAULT_VIDEO_ORDER, VIDEO_QUERY_LIMIT + @dataclass class ChannelPostRequest: - url: str name: str - match_regex: str directory: str - mkdir: bool - generate_posters: bool - calculate_duration: bool - download_frequency: str + calculate_duration: Optional[bool] = None + download_frequency: Optional[int] = DEFAULT_DOWNLOAD_FREQUENCY + generate_posters: Optional[bool] = None + match_regex: Optional[str] = None + mkdir: Optional[bool] = None + url: Optional[str] = None @dataclass class ChannelPutRequest: - url: str - name: str - match_regex: str - link: str - directory: str - generate_posters: bool - calculate_duration: bool - mkdir: bool - download_frequency: str + calculate_duration: Optional[bool] = None + directory: Optional[str] = None + download_frequency: Optional[int] = DEFAULT_DOWNLOAD_FREQUENCY + generate_posters: Optional[bool] = None + link: Optional[str] = None + match_regex: Optional[str] = None + mkdir: Optional[bool] = None + name: Optional[str] = None + url: Optional[str] = None @dataclass @@ -33,11 +36,6 @@ class RefreshRequest: channel_name: str -@dataclass -class SuccessResponse: - success: str - - @dataclass class ChannelModel: id: int @@ -129,11 +127,11 @@ class VideoResponse: @dataclass class VideoSearchRequest: - search_str: str - filters: Optional[List[str]] = field(default_factory=list) + search_str: Optional[str] = None + filters: List[str] = field(default_factory=list) offset: Optional[int] = None - limit: Optional[int] = None - order_by: Optional[str] = None + limit: Optional[int] = VIDEO_QUERY_LIMIT + order_by: Optional[str] = DEFAULT_VIDEO_ORDER channel_link: Optional[str] = None diff --git a/modules/videos/video/api.py b/modules/videos/video/api.py index 77227560..1616ffb1 100644 --- a/modules/videos/video/api.py +++ b/modules/videos/video/api.py @@ -8,11 +8,11 @@ from sanic_ext.extensions.openapi import openapi from wrolpi.common import logger, wrol_mode_check, run_after, get_media_directory, \ get_relative_to_media_directory from wrolpi.db import get_db_session -from wrolpi.errors import ValidationError, InvalidOrderBy +from wrolpi.errors import InvalidOrderBy from wrolpi.root_api import json_response from wrolpi.schema import JSONErrorResponse -from .lib import get_video, VIDEO_ORDERS, DEFAULT_VIDEO_ORDER, video_search, get_video_for_app -from ..common import get_matching_directories, video_limiter +from .lib import get_video, VIDEO_ORDERS, video_search, get_video_for_app +from ..common import get_matching_directories from ..lib import save_channels_config from ..schema import VideoResponse, VideoSearchRequest, VideoSearchResponse, \ DirectoriesResponse, DirectoriesRequest @@ -36,21 +36,18 @@ def video_get(_: Request, video_id: int): @openapi.response(HTTPStatus.OK, VideoSearchResponse) @openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) @validate(VideoSearchRequest) -async def search(_: Request, data: dict): - try: - search_str = data.get('search_str') - channel_link = data.get('channel_link') - order_by = data.get('order_by', DEFAULT_VIDEO_ORDER) - offset = int(data.get('offset', 0)) - limit = video_limiter(data.get('limit')) - filters = data.get('filters', None) - except Exception as e: - raise ValidationError('Unable to validate search queries') from e - - if order_by not in VIDEO_ORDERS: +async def search(_: Request, body: VideoSearchRequest): + if body.order_by not in VIDEO_ORDERS: raise InvalidOrderBy('Invalid order by') - videos, videos_total = video_search(search_str, offset, limit, channel_link, order_by, filters) + videos, videos_total = video_search( + body.search_str, + body.offset, + body.limit, + body.channel_link, + body.order_by, + body.filters, + ) ret = {'videos': list(videos), 'totals': {'videos': videos_total}} return json_response(ret) diff --git a/modules/videos/video/test/test_api.py b/modules/videos/video/test/test_api.py index 0db43ab7..a30a1a92 100644 --- a/modules/videos/video/test/test_api.py +++ b/modules/videos/video/test/test_api.py @@ -91,8 +91,6 @@ class TestVideoFunctions(TestAPI): self.assertError(resp, HTTPStatus.FORBIDDEN, API_ERRORS[WROLModeEnabled]['code']) _, resp = api_app.test_client.put('/api/videos/channels/channel1', content=channel) self.assertError(resp, HTTPStatus.FORBIDDEN, API_ERRORS[WROLModeEnabled]['code']) - _, resp = api_app.test_client.patch('/api/videos/channels/channel1', content=channel) - self.assertError(resp, HTTPStatus.FORBIDDEN, API_ERRORS[WROLModeEnabled]['code']) _, resp = api_app.test_client.delete('/api/videos/channels/channel1') self.assertError(resp, HTTPStatus.FORBIDDEN, API_ERRORS[WROLModeEnabled]['code']) diff --git a/requirements.txt b/requirements.txt index cddd3ec1..3c854436 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ pytest==6.2.5 pytz==2021.3 PyYAML==5.3.1 requests==2.27.1 -sanic-ext==21.12.1 +sanic-ext==22.1.2 sanic-testing==0.8.2 sanic==21.12.1 selenium==4.1.0 diff --git a/wrolpi/common.py b/wrolpi/common.py index 20b82646..19134a0a 100644 --- a/wrolpi/common.py +++ b/wrolpi/common.py @@ -458,7 +458,7 @@ def get_absolute_media_path(path: str) -> Path: return path -def get_relative_to_media_directory(path: str) -> Path: +def get_relative_to_media_directory(path: Union[str, Path]) -> Path: """ Get the path for a file/directory relative to the config media directory. @@ -482,7 +482,7 @@ def minimize_dict(d: dict, keys: Iterable) -> Optional[dict]: return {k: d[k] for k in set(keys) & d.keys()} -def make_media_directory(path: str): +def make_media_directory(path: Union[str, Path]): """ Make a directory relative within the media directory. """ diff --git a/wrolpi/root_api.py b/wrolpi/root_api.py index d2c6f5ca..4fa81931 100644 --- a/wrolpi/root_api.py +++ b/wrolpi/root_api.py @@ -18,7 +18,7 @@ from wrolpi.common import set_sanic_url_parts, logger, get_config, wrol_mode_ena Base, get_media_directory, wrol_mode_check from wrolpi.dates import set_timezone from wrolpi.downloader import download_manager -from wrolpi.errors import WROLModeEnabled, InvalidTimezone +from wrolpi.errors import WROLModeEnabled, InvalidTimezone, API_ERRORS, APIError, ValidationError from wrolpi.media_path import MediaPath from wrolpi.schema import RegexRequest, RegexResponse, SettingsRequest, SettingsResponse, DownloadRequest, EchoResponse from wrolpi.vars import DOCKERIZED @@ -250,3 +250,18 @@ def json_response(*a, **kwargs) -> HTTPResponse: """ resp = response.json(*a, **kwargs, cls=CustomJSONEncoder, dumps=json.dumps) return resp + + +def json_error_handler(request, exception: Exception): + error = API_ERRORS[type(exception)] + if isinstance(exception, ValidationError): + body = dict(error='Could not validate the contents of the request', code=error['code']) + else: + body = dict(message=str(exception), api_error=error['message'], code=error['code']) + if cause := exception.__cause__: + cause = API_ERRORS[type(cause)] + body['cause'] = dict(error=cause['message'], code=cause['code']) + return json_response(body, error['status']) + + +api_app.error_handler.add(APIError, json_error_handler)