Ver código fonte

Implement simple playlist mechanism

niels 4 anos atrás
pai
commit
ac20f0f909
9 arquivos alterados com 228 adições e 117 exclusões
  1. 33 23
      chube.py
  2. 6 0
      chube_enums.py
  3. 0 54
      chube_search.py
  4. 7 3
      chube_ws.py
  5. 71 0
      chube_youtube.py
  6. 33 2
      static/css/main.css
  7. 5 0
      static/js/enums.js
  8. 64 34
      static/js/main.js
  9. 9 1
      static/player.html

+ 33 - 23
chube.py

@@ -4,7 +4,7 @@ from typing import Optional, Iterator, Dict, List
 import sys
 from itertools import cycle
 
-import chube_search
+import chube_youtube
 from channel import Channel, Subscriber
 from chube_enums import *
 from chube_ws import Resolver, Message, start_server, make_message
@@ -130,7 +130,7 @@ async def request_state_processor(ws, data, path):
     await ws.send(make_message(Message.STATE, {
         "list": room.chueue.as_list(),
         "playing": room.playback.get_song(),
-        "state": room.playback.get_state().name
+        "state": room.playback.get_state().value
     }))
 
 
@@ -139,25 +139,39 @@ async def request_list_operation_processor(ws, data, path):
     chueue = room.chueue
     op = data["op"]
     message = None
-    if op == QueueOp.ADD.name:
-        code = data["code"]
-        song_id = chueue.add(code)
+    if op == QueueOp.ADD.value:
+        kind = data["kind"]
+        if kind == YoutubeResourceType.VIDEO.value:
+            code = data["code"]
+            song_id = chueue.add(code)
+            message = make_message(Message.LIST_OPERATION, {"op": QueueOp.ADD.value, "items": [{"code": code, "id": song_id}]})
+        elif kind == YoutubeResourceType.PLAYLIST.value:
+            code = data["code"]
+            playlist_items = await chube_youtube.get_all_playlist_items(code)
+            response_items = []
+            with room.chueue:
+                for item in playlist_items["items"]:
+                    code = item["snippet"]["resourceId"]["videoId"]
+                    song_id = chueue.add(code)
+                    response_items.append({"code": code, "id": song_id, "snippet": item["snippet"]})
+            message = make_message(Message.LIST_OPERATION, {"op": QueueOp.ADD.value, "items": response_items})
         with room.playback.lock:
             if room.playback.get_state() == PlayerState.LIST_END:
-                room.playback.set_state(PlayerState.PLAYING)
-                room.playback.set_song(chueue.pop())
-        message = make_message(Message.LIST_OPERATION, {"op": QueueOp.ADD.name, "code": code, "id": song_id})
-    elif op == QueueOp.DEL.name:
+                playing = chueue.pop()
+                if playing is not None:
+                    room.playback.set_state(PlayerState.PLAYING)
+                    room.playback.set_song(playing)
+    elif op == QueueOp.DEL.value:
         song_id = data["id"]
         chueue.remove(song_id)
-        message = make_message(Message.LIST_OPERATION, {"op": QueueOp.DEL.name, "id": song_id})
-    elif op == QueueOp.MOVE.name:
+        message = make_message(Message.LIST_OPERATION, {"op": QueueOp.DEL.value, "items": [{"id": song_id}]})
+    elif op == QueueOp.MOVE.value:
         song_id = data["id"]
         displacement = data["displacement"]
         actual_displacement = chueue.move(song_id, displacement)
         if actual_displacement != 0:
             message = make_message(Message.LIST_OPERATION,
-                                   {"op": QueueOp.MOVE.name, "id": song_id, "displacement": actual_displacement})
+                                   {"op": QueueOp.MOVE.value, "items": [{"id": song_id, "displacement": actual_displacement}]})
 
     if message is not None:
         await room.channel.send(message)
@@ -167,7 +181,7 @@ async def media_action_processor(ws, data, path):
     room = rooms[path]
     action = data["action"]
     send_next = False
-    if action == "NEXT":
+    if action == MediaAction.NEXT.value:
         current_id = data["current_id"]
         with room.playback.lock, room.chueue:
             old_song_id = room.playback.get_song_id()
@@ -181,25 +195,25 @@ async def media_action_processor(ws, data, path):
         if send_next:
             await room.channel.send(make_message(
                 Message.MEDIA_ACTION,
-                {"action": MediaAction.NEXT.name, "ended_id": old_song_id, "current_id": new_song_id}))
+                {"action": MediaAction.NEXT.value, "ended_id": old_song_id, "current_id": new_song_id}))
 
-    if action == "PLAY" or send_next:
+    if action == MediaAction.PLAY.value or send_next:
         send_play = False
         with room.playback.lock:
             if room.playback.get_state() == PlayerState.PAUSED:
                 send_play = True
                 room.playback.set_state(PlayerState.PLAYING)
         if send_play:
-            await room.channel.send(make_message(Message.MEDIA_ACTION, {"action": MediaAction.PLAY.name}))
+            await room.channel.send(make_message(Message.MEDIA_ACTION, {"action": MediaAction.PLAY.value}))
 
-    if action == "PAUSE":
+    if action == MediaAction.PAUSE.value:
         send_pause = False
         with room.playback.lock:
             if room.playback.get_state() == PlayerState.PLAYING:
                 send_pause = True
                 room.playback.set_state(PlayerState.PAUSED)
         if send_pause:
-            await room.channel.send(make_message(Message.MEDIA_ACTION, {"action": MediaAction.PAUSE.name}))
+            await room.channel.send(make_message(Message.MEDIA_ACTION, {"action": MediaAction.PAUSE.value}))
 
 
 async def obtain_control_processor(ws, data, path):
@@ -238,10 +252,6 @@ async def song_end_processor(ws, data, path):
                 make_message(Message.SONG_END, {"ended_id": old_song_id, "current_id": new_song_id}))
 
 
-async def playback_processor(ws, data, path):
-    room = rooms[path]
-
-
 async def player_enabled_processor(ws, data, path):
     room = rooms[path]
     room.channel.subscribers[ws].player_enabled = data["enabled"]
@@ -299,7 +309,7 @@ def make_resolver():
     resolver.register(Message.RELEASE_CONTROL, release_control_processor)
     resolver.register(Message.SONG_END, song_end_processor)
 
-    search_resolver = chube_search.make_resolver()
+    search_resolver = chube_youtube.make_resolver()
 
     resolver.add_all(search_resolver)
 

+ 6 - 0
chube_enums.py

@@ -36,3 +36,9 @@ class PlayerState(Enum):
     PLAYING = "PLAYING"
     PAUSED = "PAUSED"
     LIST_END = "LIST_END"
+
+
+class YoutubeResourceType(Enum):
+    VIDEO = "youtube#video"
+    PLAYLIST = "youtube#playlist"
+    CHANNEL = "youtube#channel"

+ 0 - 54
chube_search.py

@@ -1,54 +0,0 @@
-import asyncio
-import os
-
-import aiohttp
-
-from chube_enums import Message
-from chube_ws import Resolver, make_message
-
-API_KEY = os.environ.get('GOOGLE_SEARCH_API_KEY')
-SEARCH_URL = "https://www.googleapis.com/youtube/v3/search"
-SEARCH_ID_URL = "https://www.googleapis.com/youtube/v3/videos"
-
-BASE_QUERY_SEARCH = [('part', 'snippet'), ('type', 'video'), ('videoEmbeddable', 'true'), ('safeSearch', 'none'),
-                     ('key', API_KEY)]
-
-BASE_QUERY_ID_SEARCH = [('part', 'snippet'), ('key', API_KEY)]
-
-
-async def search_processor(ws, data, path):
-    async with aiohttp.ClientSession() as session:
-        async with session.get(SEARCH_URL, params=BASE_QUERY_SEARCH + [('q', data['q'])]) as response:
-            json_data = await response.json()
-            await ws.send(make_message(Message.SEARCH, json_data))
-
-
-async def search_id_processor(ws, data, path):
-    ids = data["id"]
-    if not isinstance(ids, list):
-        ids = {ids}
-    else:
-        ids = set(ids)
-    async with aiohttp.ClientSession() as session:
-        async with session.get(SEARCH_ID_URL, params=BASE_QUERY_ID_SEARCH + [('id', ",".join(ids))]) as response:
-            json_data = await response.json()
-            await ws.send(make_message(Message.SEARCH_ID, json_data))
-
-
-def make_resolver():
-    resolver = Resolver()
-    resolver.register(Message.SEARCH, search_processor)
-    resolver.register(Message.SEARCH_ID, search_id_processor)
-    return resolver
-
-
-if __name__ == "__main__":
-    async def foo():
-        async with aiohttp.ClientSession() as session:
-            async with session.get(SEARCH_URL, params=BASE_QUERY_SEARCH + [('q', "She")]) as response:
-                json_data = await response.json()
-                print(json_data)
-
-
-    loop = asyncio.get_event_loop()
-    loop.run_until_complete(foo())

+ 7 - 3
chube_ws.py

@@ -14,10 +14,10 @@ class Resolver:
     _registerDict: dict = {}
 
     def register(self, message: Message, handler):
-        self._registerDict[message.name] = handler
+        self._registerDict[message.value] = handler
 
     def unregister(self, message):
-        return self._registerDict.pop(message.name)
+        return self._registerDict.pop(message.value)
 
     def resolve(self, data):
         message = json.loads(data)
@@ -64,7 +64,11 @@ class Resolver:
 
 
 def make_message(message_type, body=None):
-    return json.dumps({"__message": message_type.name, "__body": body})
+    return json.dumps({"__message": message_type.value, "__body": body})
+
+
+def make_message_from_json_string(message_type, raw_body: str):
+    return "{{\"__message\": \"{}\", \"__body\": {}}}".format(message_type.value, raw_body)
 
 
 def start_server(resolver: Resolver, on_new_connection, on_connection_close):

+ 71 - 0
chube_youtube.py

@@ -0,0 +1,71 @@
+import asyncio
+import os
+
+import aiohttp
+
+from chube_enums import Message
+from chube_ws import Resolver, make_message, make_message_from_json_string
+
+API_KEY = os.environ.get('GOOGLE_SEARCH_API_KEY')
+
+SEARCH_URL = "https://www.googleapis.com/youtube/v3/search"
+SEARCH_PARAM = [('part', 'snippet'), ('type', 'video,playlist'), ('safeSearch', 'none'),
+                ('key', API_KEY)]
+
+ID_SEARCH_URL = "https://www.googleapis.com/youtube/v3/videos"
+ID_SEARCH_PARAM = [('part', 'snippet'), ('key', API_KEY)]
+
+GET_PLAYLIST_URL = "https://www.googleapis.com/youtube/v3/playlists"
+GET_PLAYLIST_COUNT_PARAM = [('part', 'contentDetails'), ('key', API_KEY)]
+
+GET_PLAYLIST_ITEMS_URL = "https://www.googleapis.com/youtube/v3/playlistItems"
+GET_PLAYLIST_ITEMS_PARAM = [('part', 'snippet'),('maxResults', '1'), ('key', API_KEY)]
+
+
+async def search_processor(ws, data, path):
+    async with aiohttp.ClientSession() as session:
+        async with session.get(SEARCH_URL, params=SEARCH_PARAM + [('q', data['q'])]) as response:
+            data = await response.content.read(-1)
+            await ws.send(make_message_from_json_string(Message.SEARCH, data.decode(response.get_encoding())))
+
+
+async def search_id_processor(ws, data, path):
+    ids = data["id"]
+    if not isinstance(ids, list):
+        ids = {ids}
+    else:
+        ids = set(ids)
+    async with aiohttp.ClientSession() as session:
+        async with session.get(ID_SEARCH_URL, params=ID_SEARCH_PARAM + [('id', ",".join(ids))]) as response:
+            data = await response.content.read(-1)
+            await ws.send(make_message_from_json_string(Message.SEARCH_ID, data.decode(response.get_encoding())))
+
+
+async def get_all_playlist_items(playlistId):
+    async with aiohttp.ClientSession() as session:
+        async with session.get(GET_PLAYLIST_URL, params=GET_PLAYLIST_COUNT_PARAM + [('id', playlistId)]) as count_response:
+            json_data = await count_response.json()
+            item_count = json_data['items'][0]['contentDetails']['itemCount']
+            items_params = GET_PLAYLIST_ITEMS_PARAM + [('playlistId', playlistId), ('maxResults', item_count)]
+            async with session.get(GET_PLAYLIST_ITEMS_URL, params=items_params) as items_response:
+                return await items_response.json()
+
+    
+
+def make_resolver():
+    resolver = Resolver()
+    resolver.register(Message.SEARCH, search_processor)
+    resolver.register(Message.SEARCH_ID, search_id_processor)
+    return resolver
+
+
+if __name__ == "__main__":
+    async def foo():
+        async with aiohttp.ClientSession() as session:
+            async with session.get(SEARCH_URL, params=SEARCH_PARAM + [('q', "She")]) as response:
+                json_data = await response.json()
+                print(json_data)
+
+
+    loop = asyncio.get_event_loop()
+    loop.run_until_complete(foo())

+ 33 - 2
static/css/main.css

@@ -17,7 +17,9 @@ body {
     font-family: "Open Sans", sans-serif;
 }
 
-input, select, textarea, button{font-family:inherit;}
+input, select, textarea, button {
+    font-family: inherit;
+}
 
 #logo {
     position: absolute;
@@ -60,9 +62,38 @@ input, select, textarea, button{font-family:inherit;}
     text-overflow: ellipsis;
 }
 
+.thumbnailContainer {
+    position: relative;
+    width: 120px;
+    height: 90px;
+}
+
+.thumbnailImage {
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    right: 0;
+    left: 0
+}
+
+.thumbnailPlaylistOverlay {
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    right: 0;
+    width: 50px;
+    background: rgba(0,0,0, 0.8);
+    text-align: center;
+}
+
+.thumbnailPlaylistOverlay .fa {
+    color: #eeeeee;
+    padding-top: 38px;
+}
+
 .absoluteCenter {
     position: absolute;
-    top:0;
+    top: 0;
     bottom: 0;
     left: 0;
     right: 0;

+ 5 - 0
static/js/enums.js

@@ -31,3 +31,8 @@ export const PlayerState = {
     LIST_END: "LIST_END",
 }
 
+export const YoutubeResourceType = {
+    VIDEO: "youtube#video",
+    PLAYLIST: "youtube#playlist",
+    CHANNEL: "youtube#channel",
+}

+ 64 - 34
static/js/main.js

@@ -1,5 +1,5 @@
 import { makeMessage, Resolver } from './websocketResolver.js';
-import { ListOperationTypes, MessageTypes, PlayerState, MediaAction } from "./enums.js";
+import { ListOperationTypes, MediaAction, MessageTypes, PlayerState, YoutubeResourceType } from "./enums.js";
 
 window.onYouTubeIframeAPIReady = onYouTubeIframeAPIReady
 
@@ -207,8 +207,7 @@ function makeQueueLine(code, id, before_id) {
     newQueueLine.hidden = false;
     newQueueLine.setAttribute("data-id", id)
     if (codeInfoMap.has(code)) {
-        const { id, snippet } = codeInfoMap.get(code)
-        const { thumbnails, title, channelTitle, publishTime, description } = snippet
+        const { thumbnails, title, channelTitle, publishTime, description } = codeInfoMap.get(code)
 
         const thumbnail = newQueueLine.getElementsByClassName("videoListCardThumbnail")[0]
         const img = document.createElement('img')
@@ -273,7 +272,7 @@ function onSearch(event) {
     event.preventDefault();
     const q = event.target[0].value
     if (q !== "") {
-        socket.send(makeMessage(MessageTypes.SEARCH, {q}))
+        socket.send(makeMessage(MessageTypes.SEARCH, { q }))
     }
 }
 
@@ -313,7 +312,7 @@ function onPlayerStart(event) {
         case PlayerState.PAUSED:
             if (videoPlaying !== null) {
                 loadVideo(videoPlaying);
-            }  else {
+            } else {
                 console.error(`Invalid state: state=${state}; videoPlaying=${videoPlaying}`)
                 return
             }
@@ -321,13 +320,13 @@ function onPlayerStart(event) {
         case PlayerState.PLAYING:
             if (videoPlaying !== null) {
                 playVideo(videoPlaying);
-            }  else {
+            } else {
                 console.error(`Invalid state: state=${state}; videoPlaying=${videoPlaying}`)
                 return
             }
             break;
     }
-    socket.send(makeMessage(MessageTypes.PLAYER_ENABLED, {enabled: true}))
+    socket.send(makeMessage(MessageTypes.PLAYER_ENABLED, { enabled: true }))
 }
 
 function onPlayerClose(event) {
@@ -338,7 +337,7 @@ function onPlayerClose(event) {
     if (player !== null) {
         player.pauseVideo();
     }
-    socket.send(makeMessage(MessageTypes.PLAYER_ENABLED, {enabled: false}))
+    socket.send(makeMessage(MessageTypes.PLAYER_ENABLED, { enabled: false }))
 }
 
 function hidePlayerPlaceholder(event) {
@@ -355,12 +354,12 @@ function showPlayerPlaceholder(event) {
 
 function onPlayButton(event) {
     event.preventDefault();
-    socket.send(makeMessage(MessageTypes.MEDIA_ACTION, {action: MediaAction.PLAY}))
+    socket.send(makeMessage(MessageTypes.MEDIA_ACTION, { action: MediaAction.PLAY }))
 }
 
 function onPauseButton(event) {
     event.preventDefault();
-    socket.send(makeMessage(MessageTypes.MEDIA_ACTION, {action: MediaAction.PAUSE}))
+    socket.send(makeMessage(MessageTypes.MEDIA_ACTION, { action: MediaAction.PAUSE }))
 
 }
 
@@ -380,14 +379,14 @@ function stateProcessor(ws, data) {
 
     const codes = []
     for (const song of list) {
-        const {code, id} = song
+        const { code, id } = song
         addVideo(code, id)
         if (!(codes.includes(code))) {
             codes.push(code)
         }
     }
 
-    socket.send(makeMessage(MessageTypes.SEARCH_ID, {id: codes}))
+    socket.send(makeMessage(MessageTypes.SEARCH_ID, { id: codes }))
 
     if (videoPlaying !== null) {
         if (state === PlayerState.PLAYING) {
@@ -403,23 +402,33 @@ function stateProcessor(ws, data) {
 }
 
 function listOperationProcessor(ws, data) {
-    const { op, id } = data;
+    const { op, items } = data;
     if (op === ListOperationTypes.ADD) {
-        const { code } = data;
-        addVideo(code, id);
-        if (!codeInfoMap.has(code)) {
-            socket.send(makeMessage(MessageTypes.SEARCH_ID, {id: code}))
+        const noCodeInfo = []
+        for (const { code, id, snippet } of items){
+            if (snippet !== undefined) {
+                codeInfoMap.set(code, snippet)
+            } else if (!codeInfoMap.has(code)) {
+                noCodeInfo.push(code)
+            }
+            addVideo(code, id);
+        }
+        if (noCodeInfo.length > 0) {
+            socket.send(makeMessage(MessageTypes.SEARCH_ID, {id: noCodeInfo.join(',')}))
         }
     } else if (op === ListOperationTypes.DEL) {
-        delVideo(id);
+        for (const {id} of items) {
+            delVideo(id);
+        }
     } else if (op === ListOperationTypes.MOVE) {
-        const { displacement } = data
-        moveVideo(id, displacement)
+        for (const { id, displacement } of items) {
+            moveVideo(id, displacement)
+        }
     }
 }
 
 function mediaActionProcessor(ws, data) {
-    const {action, ended_id, current_id} = data;
+    const { action, ended_id, current_id } = data;
     if (action === MediaAction.PLAY && state === PlayerState.PAUSED) {
         state = PlayerState.PLAYING
         player.playVideo();
@@ -471,29 +480,52 @@ searchResultTemplate.id = ""
 
 function makeSearchResult(item) {
     const { id, snippet } = item
-    const { videoId } = id
+    const { kind, videoId, channelId, playlistId } = id
     const { thumbnails, title, channelTitle, publishTime, description } = snippet
 
+    const code = kind === YoutubeResourceType.CHANNEL ? channelId :
+        kind === YoutubeResourceType.PLAYLIST ? playlistId :
+            kind === YoutubeResourceType.VIDEO ? videoId :
+                console.error(`Unknown kind ${kind}`)
+
+    if (!code) return;
+
     const searchResult = searchResultTemplate.cloneNode(true)
-    searchResult.setAttribute('data-youtubeID', videoId)
+    searchResult.setAttribute('data-youtubeID', code)
+
+
+
     function onClickHandler() {
         socket.send(makeMessage(MessageTypes.LIST_OPERATION, {
             op: ListOperationTypes.ADD,
-            code: videoId
+            kind,
+            code
         }));
-        codeInfoMap.set(videoId, item)
+        if (kind === YoutubeResourceType.VIDEO) {
+            codeInfoMap.set(videoId, snippet)
+        }
     }
+
     searchResult.addEventListener("click", onClickHandler)
     searchResult.addEventListener("keydown", onClickHandler)
 
-    const thumbnail = searchResult.getElementsByClassName("searchResultThumbnail")[0]
+    const thumbnailContainer = searchResult.getElementsByClassName("searchResultThumbnail")[0]
+    const thumbnailImage = thumbnailContainer.getElementsByClassName("thumbnailImage")[0]
     const img = document.createElement('img')
-    thumbnail.appendChild(img)
-    img.setAttribute('src', thumbnails.default.url)
-    img.setAttribute('width', thumbnails.default.width)
-    img.setAttribute('height', thumbnails.default.height)
+    thumbnailImage.appendChild(img)
+
+    const {url, width, height} = (thumbnails ? thumbnails["default"] : {url: "/img/no_thumbnail.png", width: 120, height: 90})
+
+    img.setAttribute('src', url)
+    img.setAttribute('width', width)
+    img.setAttribute('height', height)
     img.setAttribute('alt', "")
 
+    if (kind !== YoutubeResourceType.PLAYLIST) {
+        const playlistOverlay = thumbnailContainer.getElementsByClassName("thumbnailPlaylistOverlay")[0]
+        playlistOverlay.parentElement.removeChild(playlistOverlay);
+    }
+
     searchResult.getElementsByClassName("searchResultTitle")[0].innerText = title
     searchResult.getElementsByClassName("searchResultChannel")[0].innerText = channelTitle
     searchResult.getElementsByClassName("searchResultDescription")[0].innerText = description
@@ -512,10 +544,8 @@ function searchResultProcessor(_, data) {
 
 function searchIdResultProcessor(_, data) {
     const { items } = data;
-    for (const item of items) {
-        console.log(item)
-        const code = item.id;
-        codeInfoMap.set(code, item);
+    for (const { id: code, snippet } of items) {
+        codeInfoMap.set(code, snippet);
         const lines = queueElement.querySelectorAll(`[data-youtubeID='${code}`)
         for (const line of lines) {
             if (line !== null) {

+ 9 - 1
static/player.html

@@ -49,7 +49,15 @@
                 <div id="searchResultList" class="list-group col">
                     <div id="searchResultTemplate" class="searchResult list-group-item list-group-item-action" hidden>
                         <div class="row">
-                            <div class="searchResultThumbnail col-3"></div>
+                            <div class="searchResultThumbnail col-3">
+                                <div class="thumbnailContainer">
+                                    <div class="thumbnailImage" >
+                                    </div>
+                                    <div class="thumbnailPlaylistOverlay">
+                                        <i class="fa fa-bars"></i>
+                                    </div>
+                                </div>
+                            </div>
                             <div class="col-9 searchResultTextContainer">
                                 <span class="h4 searchResultTitle">She</span><br/>
                                 <span class="searchResultChannel pr-3">Dodie - Topic</span>