diff --git a/backend/playlist/src/index.py b/backend/playlist/src/index.py index 387c213c..584342b3 100644 --- a/backend/playlist/src/index.py +++ b/backend/playlist/src/index.py @@ -201,7 +201,7 @@ class YoutubePlaylist(YouTubeItem): current_idx = all_entries.index(current[0]) if current_idx == 0: - previous_item = False + previous_item = None else: previous_item = all_entries[current_idx - 1] prev_id = previous_item["youtube_id"] @@ -209,7 +209,7 @@ class YoutubePlaylist(YouTubeItem): previous_item["vid_thumb"] = f"{cache_root}/{prev_thumb_path}" if current_idx == len(all_entries) - 1: - next_item = False + next_item = None else: next_item = all_entries[current_idx + 1] next_id = next_item["youtube_id"] diff --git a/backend/video/serializers.py b/backend/video/serializers.py new file mode 100644 index 00000000..85963ba2 --- /dev/null +++ b/backend/video/serializers.py @@ -0,0 +1,198 @@ +"""video serializers""" + +# pylint: disable=abstract-method + +from channel.serializers import ChannelSerializer +from common.serializers import PaginationSerializer +from rest_framework import serializers + + +class PlayerSerializer(serializers.Serializer): + """serialize player""" + + watched = serializers.BooleanField() + duration = serializers.IntegerField() + duration_str = serializers.CharField() + progress = serializers.FloatField(required=False) + position = serializers.FloatField(required=False) + + +class SponsorBlockSegmentSerializer(serializers.Serializer): + """serialize sponsorblock segment""" + + actionType = serializers.CharField() + videoDuration = serializers.FloatField() + segment = serializers.ListField(child=serializers.FloatField()) + votes = serializers.IntegerField() + category = serializers.CharField() + UUID = serializers.CharField() + locked = serializers.IntegerField() + + +class SponsorBlockSerializer(serializers.Serializer): + """serialize sponsorblock""" + + is_enabled = serializers.BooleanField() + last_refresh = serializers.IntegerField() + has_unlocked = serializers.BooleanField(required=False) + segments = SponsorBlockSegmentSerializer(many=True) + + +class StatsSerializer(serializers.Serializer): + """serialize stats""" + + like_count = serializers.IntegerField(required=False) + average_rating = serializers.FloatField(required=False) + view_count = serializers.IntegerField(required=False) + dislike_count = serializers.IntegerField(required=False) + + +class StreamItemSerializer(serializers.Serializer): + """serialize stream item""" + + index = serializers.IntegerField() + codec = serializers.CharField() + bitrate = serializers.IntegerField() + type = serializers.ChoiceField(choices=["video", "audio"]) + width = serializers.IntegerField(required=False) + height = serializers.IntegerField(required=False) + + +class SubtitleItemSerializer(serializers.Serializer): + """serialize subtitle item""" + + ext = serializers.ChoiceField(choices=["json3"]) + name = serializers.CharField() + source = serializers.ChoiceField(choices=["user", "auto"]) + lang = serializers.CharField() + media_url = serializers.CharField() + url = serializers.URLField() + + +class VideoSerializer(serializers.Serializer): + """serialize video item""" + + active = serializers.BooleanField() + category = serializers.ListField(child=serializers.CharField()) + channel = ChannelSerializer() + comment_count = serializers.IntegerField(allow_null=True) + date_downloaded = serializers.IntegerField() + description = serializers.CharField() + media_size = serializers.IntegerField() + media_url = serializers.CharField() + player = PlayerSerializer() + playlist = serializers.ListField( + child=serializers.CharField(), allow_null=True + ) + published = serializers.CharField() + sponsorblock = SponsorBlockSerializer(allow_null=True) + stats = StatsSerializer() + streams = StreamItemSerializer(many=True) + subtitles = SubtitleItemSerializer(many=True) + tags = serializers.ListField(child=serializers.CharField()) + title = serializers.CharField() + vid_last_refresh = serializers.CharField() + vid_thumb_url = serializers.CharField() + vid_type = serializers.ChoiceField(choices=["video", "stream", "short"]) + youtube_id = serializers.CharField() + _index = serializers.CharField(required=False) + _score = serializers.FloatField(required=False) + + +class VideoListSerializer(serializers.Serializer): + """serialize video list""" + + data = VideoSerializer(many=True) + paginate = PaginationSerializer() + + +class VideoListQuerySerializer(serializers.Serializer): + """serialize query for video list""" + + playlist = serializers.CharField(required=False) + channel = serializers.CharField(required=False) + watch = serializers.ChoiceField( + choices=["watched", "unwatched", "continue"], required=False + ) + sort = serializers.ChoiceField( + choices=[ + "published", + "downloaded", + "views", + "likes", + "duration", + "filesize", + ], + required=False, + ) + order = serializers.ChoiceField(choices=["asc", "desc"], required=False) + type = serializers.ChoiceField( + choices=["video", "stream", "short"], required=False + ) + + +class CommentThreadItemSerializer(serializers.Serializer): + """serialize comment thread item""" + + comment_id = serializers.CharField() + comment_text = serializers.CharField() + comment_timestamp = serializers.IntegerField() + comment_time_text = serializers.CharField() + comment_likecount = serializers.IntegerField() + comment_is_favorited = serializers.BooleanField() + comment_author = serializers.CharField() + comment_author_id = serializers.CharField() + comment_author_thumbnail = serializers.URLField() + comment_author_is_uploader = serializers.BooleanField() + comment_parent = serializers.CharField() + + +class CommentItemSerializer(serializers.Serializer): + """serialize comment item""" + + comment_id = serializers.CharField() + comment_text = serializers.CharField() + comment_timestamp = serializers.IntegerField() + comment_time_text = serializers.CharField() + comment_likecount = serializers.IntegerField() + comment_is_favorited = serializers.BooleanField() + comment_author = serializers.CharField() + comment_author_id = serializers.CharField() + comment_author_thumbnail = serializers.URLField() + comment_author_is_uploader = serializers.BooleanField() + comment_parent = serializers.CharField() + comment_replies = CommentThreadItemSerializer(many=True) + + +class PlaylistNavMetaSerializer(serializers.Serializer): + """serialize playlist nav meta""" + + current_idx = serializers.IntegerField() + playlist_id = serializers.CharField() + playlist_name = serializers.CharField() + playlist_channel = serializers.CharField() + + +class PlaylistNavVideoSerializer(serializers.Serializer): + """serialize video item on playlist nav""" + + youtube_id = serializers.CharField() + title = serializers.CharField() + uploader = serializers.CharField() + idx = serializers.IntegerField() + downloaded = serializers.BooleanField() + vid_thumb = serializers.CharField() + + +class PlaylistNavItemSerializer(serializers.Serializer): + """serialize nav on playlist""" + + playlist_meta = PlaylistNavMetaSerializer() + playlist_previous = PlaylistNavVideoSerializer(allow_null=True) + playlist_next = PlaylistNavVideoSerializer(allow_null=True) + + +class VideoProgressUpdateSerializer(serializers.Serializer): + """serialize progress update data""" + + position = serializers.FloatField(default=0) diff --git a/backend/video/src/query_building.py b/backend/video/src/query_building.py index 2a2cd637..78b129e4 100644 --- a/backend/video/src/query_building.py +++ b/backend/video/src/query_building.py @@ -27,20 +27,20 @@ class QueryBuilder: must_list = [] channel = self.request_params.get("channel") if channel: - must_list.append({"match": {"channel.channel_id": channel[0]}}) + must_list.append({"match": {"channel.channel_id": channel}}) playlist = self.request_params.get("playlist") if playlist: - must_list.append({"match": {"playlist.keyword": playlist[0]}}) + must_list.append({"match": {"playlist.keyword": playlist}}) watch = self.request_params.get("watch") if watch: - watch_must_list = self.parse_watch(watch[0]) + watch_must_list = self.parse_watch(watch) must_list.append(watch_must_list) video_type = self.request_params.get("type") if video_type: - type_list_list = self.parse_type(video_type[0]) + type_list_list = self.parse_type(video_type) must_list.append(type_list_list) query = {"bool": {"must": must_list}} @@ -88,14 +88,12 @@ class QueryBuilder: if not sort: return None - sort = sort[0] if not hasattr(SortEnum, sort.upper()): raise ValueError(f"'{sort}' not in SortEnum") sort_field = getattr(SortEnum, sort.upper()).value order = self.request_params.get("order", ["desc"]) - order = order[0] if not hasattr(OrderEnum, order.upper()): raise ValueError(f"'{order}' not in OrderEnum") diff --git a/backend/video/views.py b/backend/video/views.py index 0b3d93bb..88d53003 100644 --- a/backend/video/views.py +++ b/backend/video/views.py @@ -1,11 +1,22 @@ """all API views for video endpoints""" +from common.serializers import ErrorResponseSerializer from common.src.helper import calc_is_watched from common.src.ta_redis import RedisArchivist from common.src.watched import WatchState from common.views_base import AdminWriteOnly, ApiBaseView +from drf_spectacular.utils import OpenApiResponse, extend_schema from playlist.src.index import YoutubePlaylist from rest_framework.response import Response +from video.serializers import ( + CommentItemSerializer, + PlayerSerializer, + PlaylistNavItemSerializer, + VideoListQuerySerializer, + VideoListSerializer, + VideoProgressUpdateSerializer, + VideoSerializer, +) from video.src.index import YoutubeVideo from video.src.query_building import QueryBuilder @@ -24,13 +35,22 @@ class VideoApiListView(ApiBaseView): search_base = "ta_video/_search/" + @extend_schema( + parameters=[VideoListQuerySerializer()], + responses={ + 200: VideoListSerializer(), + 400: OpenApiResponse( + ErrorResponseSerializer(), description="bad request" + ), + }, + ) def get(self, request): - """get request""" - try: - data = QueryBuilder(request.user.id, **request.GET).build_data() - except ValueError as err: - return Response({"error": str(err)}, status=400) + """get video list""" + query_serializer = VideoListQuerySerializer(data=request.query_params) + query_serializer.is_valid(raise_exception=True) + validated_query = query_serializer.validated_data + data = QueryBuilder(request.user.id, **validated_query).build_data() if data == {"query": {"bool": {"must": [None]}}}: # skip empty lookup return Response([]) @@ -38,7 +58,9 @@ class VideoApiListView(ApiBaseView): self.data = data self.get_document_list(request, progress_match=request.user.id) - return Response(self.response) + response_serializer = VideoListSerializer(self.response) + + return Response(response_serializer.data) class VideoApiView(ApiBaseView): @@ -49,25 +71,71 @@ class VideoApiView(ApiBaseView): search_base = "ta_video/_doc/" permission_classes = [AdminWriteOnly] + @extend_schema( + responses={ + 200: VideoSerializer(), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="video not found" + ), + }, + ) def get(self, request, video_id): - # pylint: disable=unused-argument - """get request""" + """get video""" self.get_document(video_id, progress_match=request.user.id) - return Response(self.response, status=self.status_code) + if not self.response: + error = ErrorResponseSerializer({"error": "video not found"}) + return Response(error.data, status=404) + serializer = VideoSerializer(self.response) + return Response(serializer.data) + + @extend_schema( + responses={ + 204: OpenApiResponse(description="video deleted"), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="video not found" + ), + } + ) def delete(self, request, video_id): # pylint: disable=unused-argument - """delete single video""" - message = {"video": video_id} + """delete video""" try: YoutubeVideo(video_id).delete_media_file() - status_code = 200 - message.update({"state": "delete"}) except FileNotFoundError: - status_code = 404 - message.update({"state": "not found"}) + error = ErrorResponseSerializer({"error": "video not found"}) + return Response(error.data, status=404) - return Response(message, status=status_code) + return Response(status=204) + + +class VideoCommentView(ApiBaseView): + """resolves to /api/video//comment/ + handle video comments + GET: return all comments from video with reply threads + """ + + search_base = "ta_comment/_doc/" + + @extend_schema( + responses={ + 200: CommentItemSerializer(), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="video not found" + ), + } + ) + def get(self, request, video_id): + """get video comments""" + # pylint: disable=unused-argument + self.get_document(video_id) + if self.status_code == 404: + error = ErrorResponseSerializer({"error": "video not found"}) + return Response(error.data, status=404) + + serializer = CommentItemSerializer(self.response, many=True) + + return Response(serializer.data) class VideoApiNavView(ApiBaseView): @@ -77,26 +145,39 @@ class VideoApiNavView(ApiBaseView): search_base = "ta_video/_doc/" + @extend_schema( + responses={ + 200: PlaylistNavItemSerializer(), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="video not found" + ), + } + ) def get(self, request, video_id): # pylint: disable=unused-argument - """get request""" + """get video playlist nav""" self.get_document(video_id) - if self.status_code != 200: - return Response(status=self.status_code) + if self.status_code == 404: + error = ErrorResponseSerializer({"error": "video not found"}) + return Response(error.data, status=404) playlist_nav = [] - if not self.response["data"].get("playlist"): + if not self.response.get("playlist"): return Response(playlist_nav) - for playlist_id in self.response["data"]["playlist"]: + for playlist_id in self.response["playlist"]: playlist = YoutubePlaylist(playlist_id) playlist.get_from_es() playlist.build_nav(video_id) if playlist.nav: playlist_nav.append(playlist.nav) - return Response(playlist_nav, status=self.status_code) + response_serializer = PlaylistNavItemSerializer( + playlist_nav, many=True + ) + + return Response(response_serializer.data) class VideoProgressView(ApiBaseView): @@ -111,19 +192,32 @@ class VideoProgressView(ApiBaseView): """redis key""" return f"{user_id}:progress:{video_id}" + @extend_schema( + request=VideoProgressUpdateSerializer(), + responses={ + 200: PlayerSerializer(), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="video not found" + ), + }, + ) def post(self, request, video_id): - """set progress position in redis""" - position = request.data.get("position", 0) + """set video progress position in redis""" + data_serializer = VideoProgressUpdateSerializer(data=request.data) + data_serializer.is_valid(raise_exception=True) + validated_data = data_serializer.validated_data + + self.get_document(video_id) + if self.status_code == 404: + error = ErrorResponseSerializer({"error": "video not found"}) + return Response(error.data, status=404) + + position = validated_data["position"] key = self._get_key(request.user.id, video_id) redis_con = RedisArchivist() - current_progress = redis_con.get_message_dict(key) - - if not current_progress: - self.get_document(video_id) - if self.status_code != 200: - return Response(status=self.status_code) - - current_progress = self.response["data"]["player"] + current_progress = ( + redis_con.get_message_dict(key) or self.response["player"] + ) current_progress.update({"position": position, "youtube_id": video_id}) watched = self._check_watched(request, video_id, current_progress) @@ -134,8 +228,11 @@ class VideoProgressView(ApiBaseView): current_progress.update({"watched": watched}) redis_con.set_message(key, current_progress, expire=expire) + print(current_progress) - return Response(current_progress) + response_serializer = PlayerSerializer(current_progress) + + return Response(response_serializer.data) def _check_watched(self, request, video_id, current_progress) -> bool: """check watched state""" @@ -150,29 +247,17 @@ class VideoProgressView(ApiBaseView): return watched + @extend_schema( + responses={ + 204: OpenApiResponse(description="video progress deleted"), + } + ) def delete(self, request, video_id): """delete progress position""" key = self._get_key(request.user.id, video_id) RedisArchivist().del_message(key) - self.response = {"progress-reset": video_id} - return Response(self.response) - - -class VideoCommentView(ApiBaseView): - """resolves to /api/video//comment/ - handle video comments - GET: return all comments from video with reply threads - """ - - search_base = "ta_comment/_doc/" - - def get(self, request, video_id): - """get video comments""" - # pylint: disable=unused-argument - self.get_document(video_id) - - return Response(self.response, status=200) + return Response(status=204) class VideoSimilarView(ApiBaseView): @@ -182,6 +267,9 @@ class VideoSimilarView(ApiBaseView): search_base = "ta_video/_search/" + @extend_schema( + responses=VideoSerializer(many=True), + ) def get(self, request, video_id): """get similar videos""" self.data = { @@ -196,4 +284,5 @@ class VideoSimilarView(ApiBaseView): }, } self.get_document_list(request, pagination=False) - return Response(self.response, status=200) + serializer = VideoSerializer(self.response["data"], many=True) + return Response(serializer.data)