home · contact · privacy
Add MPV playlisting.
authorChristian Heller <c.heller@plomlompom.de>
Tue, 12 Nov 2024 16:33:00 +0000 (17:33 +0100)
committerChristian Heller <c.heller@plomlompom.de>
Tue, 12 Nov 2024 16:33:00 +0000 (17:33 +0100)
templates/index.tmpl [deleted file]
templates/playlist.tmpl [new file with mode: 0644]
templates/queries.tmpl [new file with mode: 0644]
templates/results.tmpl
templates/videos.tmpl
ytplom.py

diff --git a/templates/index.tmpl b/templates/index.tmpl
deleted file mode 100644 (file)
index b34319d..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-<html>
-<meta charset="UTF-8">
-<body>
-<p>queries · <a href="/videos">videos</a></p>
-<p>quota: {{quota_count}}/100000</p>
-<form action="" method="POST" />
-<input name="query" />
-</form>
-<table>
-<tr>
-<th>retrieved at</th>
-<th>DLs</th>
-<th>query</th>
-</tr>
-{% for query in queries %}
-<tr>
-<td>{{query.retrieved_at[:19]}}</td>
-<td style="text-align: right;">{{query.downloads}}</td>
-<td><a href="/query/{{query.id}}">{{query.text}}</a></td>
-</tr>
-{% endfor %}
-</table>
-</body>
-</html>
diff --git a/templates/playlist.tmpl b/templates/playlist.tmpl
new file mode 100644 (file)
index 0000000..b533d3c
--- /dev/null
@@ -0,0 +1,59 @@
+<html>
+<head>
+<meta charset="UTF-8">
+<script>
+const RELOAD_INTERVAL_S = 10;
+const PATH_LAST_UPDATE = '/_last_playlist_update.json';
+const MSG_SERVER_DOWN = 'Server seems to be unavailable.';
+const MSG_ERR_UNKNOWN = 'Unknown error checking ' + PATH_LAST_UPDATE;
+const last_update = '{{last_update}}';
+async function keep_updated() {
+  try {
+    const response = await fetch(PATH_LAST_UPDATE);
+    const data = await response.json();
+    if (data.last_update != last_update) {
+      location.reload();
+    }
+  } catch(error) {
+      const status = document.getElementById('status');
+      if (error instanceof TypeError && !error.response) {
+        status.innerText = MSG_SERVER_DOWN;
+      } else {
+        status.innerText = MSG_ERR_UNKNOWN;
+      } 
+  }
+  setTimeout(keep_updated, RELOAD_INTERVAL_S * 1000); 
+}
+window.onload = keep_updated;
+</script>
+<style>
+body { background-color: #aaaaaa; }
+table { width: 100%; }
+#status { text-align: center; font-weight: bold; }
+td.history { width: 50%; }
+</style>
+</head>
+<body>
+<p>playlist · <a href="/videos">videos</a> · <a href="/queries">queries</a></p>
+<table>
+<tr><td id="status" colspan=2>
+{% if running %}{% if pause %}PAUSED{% else %}PLAYING{% endif %}{% else %}STOPPED{% endif %}:<br />
+{{ current_title }}<br />
+<form action="/playlist" method="POST">
+<input type="submit" name="pause" value="{% if paused %}resume{% else %}pause{% endif %}">
+<input type="submit" name="prev" value="prev">
+<input type="submit" name="next" value="next">
+<input type="submit" name="stop" value="{% if running %}stop{% else %}start{% endif %}">
+</form>
+</td></tr>
+{% for prev_title, next_title in tuples %}
+<tr><td class="history">
+{{ prev_title }}
+</td><td class="history">
+{{ next_title }}
+</td></tr>
+{% endfor %}
+</table>
+</body>
+</html>
+
diff --git a/templates/queries.tmpl b/templates/queries.tmpl
new file mode 100644 (file)
index 0000000..0d888b0
--- /dev/null
@@ -0,0 +1,24 @@
+<html>
+<meta charset="UTF-8">
+<body>
+<p><a href="/playlist">playlist</a> · <a href="/videos">videos</a> · queries</p>
+<p>quota: {{quota_count}}/100000</p>
+<form action="/queries" method="POST" />
+<input name="query" />
+</form>
+<table>
+<tr>
+<th>retrieved at</th>
+<th>DLs</th>
+<th>query</th>
+</tr>
+{% for query in queries %}
+<tr>
+<td>{{query.retrieved_at[:19]}}</td>
+<td style="text-align: right;">{{query.downloads}}</td>
+<td><a href="/query/{{query.id}}">{{query.text}}</a></td>
+</tr>
+{% endfor %}
+</table>
+</body>
+</html>
index 6b7ac2bdf9a3235abbd108e89eba679065a97fa9..8b88362a106a43835d9e296843549a39ad4ef751 100644 (file)
@@ -1,7 +1,7 @@
 <html>
 <meta charset="UTF-8">
 <body>
-<p><a href="/">queries</a> · <a href="/videos">videos</a></p>
+<p><a href="/playlist">playlist</a> · <a href="/videos">videos</a> · <a href="/queries">queries</a></p>
 <p>query: {{query_text}}</p>
 <table>
 {% for video in videos %}
index 757e566deed3694365dc3ec8e415ff4a3a29fe61..daba8ed2953f75f484cc1ecf5407cec526396e07 100644 (file)
@@ -1,7 +1,7 @@
 <html>
 <meta charset="UTF-8">
 <body>
-<p><a href="/">queries</a> · videos</p>
+<p><a href="/playlist">playlist</a> · videos · <a href="/queries">queries</a></p>
 <p>downloaded videos:</p>
 <ul>
 {% for video_id, path in videos %}
index 0a95119171184b4cc34f88e5b17f133d46b525f9..7c5e756862f81b336ba5c2a406ecc76c6d092c48 100755 (executable)
--- a/ytplom.py
+++ b/ytplom.py
@@ -1,11 +1,12 @@
 #!/usr/bin/env python3
 """Minimalistic download-focused YouTube interface."""
-from typing import TypeAlias, Optional, NewType
+from typing import TypeAlias, Optional, NewType, Callable
 from os import environ, makedirs, scandir, remove as os_remove
 from os.path import (isdir, isfile, exists as path_exists, join as path_join,
                      splitext, basename)
-from time import sleep
-from json import load as json_load, dump as json_dump
+from random import shuffle
+from time import time, sleep
+from json import load as json_load, dump as json_dump, dumps as json_dumps
 from datetime import datetime, timedelta
 from threading import Thread
 from http.server import HTTPServer, BaseHTTPRequestHandler
@@ -13,6 +14,7 @@ from urllib.parse import urlparse, parse_qs
 from urllib.request import urlretrieve
 from hashlib import md5
 from jinja2 import Template
+from mpv import MPV  # type: ignore
 from yt_dlp import YoutubeDL  # type: ignore
 import googleapiclient.discovery  # type: ignore
 
@@ -23,6 +25,7 @@ PathStr = NewType('PathStr', str)
 QueryId = NewType('QueryId', str)
 QueryText = NewType('QueryText', str)
 AmountDownloads = NewType('AmountDownloads', int)
+PlayerUpdateId = NewType('PlayerUpdateId', str)
 Result: TypeAlias = dict[str, str]
 Header: TypeAlias = tuple[str, str]
 VideoData: TypeAlias = dict[str, str | bool]
@@ -30,10 +33,12 @@ QueryData: TypeAlias = dict[str, QueryId | QueryText | DatetimeStr
                             | AmountDownloads | list[Result]]
 QuotaLog: TypeAlias = dict[DatetimeStr, QuotaCost]
 DownloadsDB = dict[VideoId, PathStr]
-TemplateContext = dict[str, PathStr | VideoId | QuotaCost | QueryData
+TemplateContext = dict[str, None | bool | PlayerUpdateId | PathStr | VideoId
+                       | QuotaCost | QueryData
                        | VideoData | list[QueryData] | list[VideoData]
                        | list[tuple[VideoId, PathStr]]
-                       | list[tuple[QueryId, QueryText]]]
+                       | list[tuple[QueryId, QueryText]]
+                       | list[tuple[PathStr, PathStr]]]
 
 API_KEY = environ.get('GOOGLE_API_KEY')
 HTTP_PORT = 8083
@@ -48,6 +53,7 @@ NAME_TEMPLATE_INDEX = PathStr('index.tmpl')
 NAME_TEMPLATE_RESULTS = PathStr('results.tmpl')
 NAME_TEMPLATE_VIDEOS = PathStr('videos.tmpl')
 NAME_TEMPLATE_VIDEO_ABOUT = PathStr('video_about.tmpl')
+NAME_TEMPLATE_PLAYLIST = PathStr('playlist.tmpl')
 
 PATH_DIR_TEMP = PathStr(path_join(PATH_DIR_DOWNLOADS, NAME_DIR_TEMP))
 EXPECTED_DIRS = [PATH_DIR_DOWNLOADS, PATH_DIR_TEMP, PATH_DIR_THUMBNAILS,
@@ -65,9 +71,133 @@ YT_DL_PARAMS = {'paths': {'home': PATH_DIR_DOWNLOADS,
 QUOTA_COST_YOUTUBE_SEARCH = QuotaCost(100)
 QUOTA_COST_YOUTUBE_DETAILS = QuotaCost(1)
 
+LEGAL_EXTENSIONS = {'webm', 'mp4', 'mkv'}
+
 to_download: list[VideoId] = []
 
 
+class Player:
+    """MPV representation with some additional features."""
+
+    def __init__(self) -> None:
+        self.last_update = PlayerUpdateId('')
+        self._filenames = [PathStr(e.path) for e in scandir(PATH_DIR_DOWNLOADS)
+                           if isfile(e.path)
+                           and splitext(e.path)[1][1:] in LEGAL_EXTENSIONS]
+        shuffle(self._filenames)
+        self._idx: int = 0
+        self._mpv: Optional[MPV] = None
+
+    @property
+    def _mpv_available(self) -> bool:
+        return bool(self._mpv and not self._mpv.core_shutdown)
+
+    @staticmethod
+    def _if_mpv_available(f) -> Callable:
+        def wrapper(self):
+            return f(self) if self._mpv else None
+        return wrapper
+
+    def _signal_update(self) -> None:
+        self.last_update = PlayerUpdateId(f'{self._idx}:{time()}')
+
+    def _start_mpv(self) -> None:
+        self._mpv = MPV(input_default_bindings=True,
+                        input_vo_keyboard=True,
+                        config=True)
+        self._mpv.observe_property('pause', lambda a, b: self._signal_update())
+
+        @self._mpv.event_callback('start-file')
+        def on_start_file(_) -> None:
+            assert self._mpv is not None
+            self._mpv.pause = False
+            self._idx = self._mpv.playlist_pos
+            self._signal_update()
+
+        @self._mpv.event_callback('shutdown')
+        def on_shutdown(_) -> None:
+            self._mpv = None
+            self._signal_update()
+
+        for path in self._filenames:
+            self._mpv.playlist_append(path)
+        self._mpv.playlist_play_index(self._idx)
+
+    @property
+    def current_filename(self) -> Optional[PathStr]:
+        """Return what we assume is the name of the currently playing file."""
+        if not self._filenames:
+            return None
+        return PathStr(basename(self._filenames[self._idx]))
+
+    @property
+    def prev_files(self) -> list[PathStr]:
+        """List 'past' files of playlist."""
+        return list(reversed(self._filenames[:self._idx]))
+
+    @property
+    def next_files(self) -> list[PathStr]:
+        """List 'coming' files of playlist."""
+        return self._filenames[self._idx + 1:]
+
+    @property
+    def is_running(self) -> bool:
+        """Return if player is running/available."""
+        return self._mpv_available
+
+    @property
+    def is_paused(self) -> bool:
+        """Return if player is paused."""
+        if self._mpv_available:
+            assert self._mpv is not None
+            return self._mpv.pause
+        return False
+
+    def toggle_run(self) -> None:
+        """Toggle player running."""
+        if self._mpv_available:
+            assert self._mpv is not None
+            self._mpv.terminate()
+            self._mpv = None
+        else:
+            self._start_mpv()
+        self._signal_update()
+
+    @_if_mpv_available
+    def toggle_pause(self) -> None:
+        """Toggle player pausing."""
+        assert self._mpv is not None
+        self._mpv.pause = not self._mpv.pause
+        self._signal_update()
+
+    @_if_mpv_available
+    def prev(self) -> None:
+        """Move player to previous item in playlist."""
+        assert self._mpv is not None
+        if self._mpv.playlist_pos > 0:
+            self._mpv.playlist_prev()
+        else:
+            self._mpv.playlist_play_index(0)
+
+    @_if_mpv_available
+    def next(self) -> None:
+        """Move player to next item in playlist."""
+        assert self._mpv is not None
+        max_idx: int = len(self._mpv.playlist_filenames) - 1
+        if self._mpv.playlist_pos < len(self._mpv.playlist_filenames) - 1:
+            self._mpv.playlist_next()
+        else:
+            self._mpv.playlist_play_index(max_idx)
+
+
+class PlayerServer(HTTPServer):
+    """Extension of HTTPServer providing for .player inclusion."""
+
+    def __init__(self, *args, **kwargs) -> None:
+        super().__init__(*args, **kwargs)
+        self.player = Player()
+
+
 def ensure_expected_dirs_and_files() -> None:
     """Ensure existance of all dirs and files we need for proper operation."""
     for dir_name in EXPECTED_DIRS:
@@ -96,8 +226,8 @@ def clean_unfinished_downloads() -> None:
 
 
 def run_server() -> None:
-    """Run HTTPServer on TaskHandler, handle KeyboardInterrupt as exit."""
-    server = HTTPServer(('localhost', HTTP_PORT), TaskHandler)
+    """Run PlayerServer on TaskHandler, handle KeyboardInterrupt as exit."""
+    server = PlayerServer(('localhost', HTTP_PORT), TaskHandler)
     print(f'running at port {HTTP_PORT}')
     try:
         server.serve_forever()
@@ -113,10 +243,10 @@ def read_quota_log() -> QuotaLog:
         log = json_load(f)
     ret = {}
     now = datetime.now()
-    for time, amount in log.items():
-        then = datetime.strptime(time, TIMESTAMP_FMT)
+    for timestamp, amount in log.items():
+        then = datetime.strptime(timestamp, TIMESTAMP_FMT)
         if then >= now - timedelta(days=1):
-            ret[time] = amount
+            ret[timestamp] = amount
     return ret
 
 
@@ -142,6 +272,7 @@ def download_thread() -> None:
 
 class TaskHandler(BaseHTTPRequestHandler):
     """Handler for GET and POST requests to our server."""
+    server: PlayerServer
 
     def _send_http(self,
                    content: bytes = b'',
@@ -157,7 +288,31 @@ class TaskHandler(BaseHTTPRequestHandler):
             self.wfile.write(content)
 
     def do_POST(self) -> None:  # pylint:disable=invalid-name
-        """Send requests to YouTube API and cache them."""
+        """Map POST requests to handlers for various paths."""
+        url = urlparse(self.path)
+        toks_url: list[str] = url.path.split('/')
+        page_name = toks_url[1]
+        body_length = int(self.headers['content-length'])
+        postvars = parse_qs(self.rfile.read(body_length).decode())
+        if 'playlist' == page_name:
+            self._post_player_command(list(postvars.keys()))
+        elif 'queries' == page_name:
+            self._post_query(QueryText(postvars['query'][0]))
+
+    def _post_player_command(self, commands: list[str]) -> None:
+        # print("DEBUG commands", commands)
+        if 'pause' in commands:
+            self.server.player.toggle_pause()
+        elif 'prev' in commands:
+            self.server.player.prev()
+        elif 'next' in commands:
+            self.server.player.next()
+        elif 'stop' in commands:
+            self.server.player.toggle_run()
+        sleep(0.5)  # avoid reload happening before current_file update
+        self._send_http(headers=[('Location', '/')], code=302)
+
+    def _post_query(self, query_txt: QueryText) -> None:
 
         def collect_results(now: DatetimeStr,
                             query_txt: QueryText
@@ -196,9 +351,6 @@ class TaskHandler(BaseHTTPRequestHandler):
                 results_item['definition'] = content_details['definition']
             return results
 
-        body_length = int(self.headers['content-length'])
-        postvars = parse_qs(self.rfile.read(body_length).decode())
-        query_txt = QueryText(postvars['query'][0])
         now = DatetimeStr(datetime.now().strftime(TIMESTAMP_FMT))
         results = collect_results(now, query_txt)
         md5sum = md5(str(query_txt).encode()).hexdigest()
@@ -224,8 +376,12 @@ class TaskHandler(BaseHTTPRequestHandler):
             self._send_video_about(VideoId(toks_url[2]))
         elif 'query' == page_name:
             self._send_query_page(QueryId(toks_url[2]))
-        else:  # e.g. for /
+        elif 'queries' == page_name:
             self._send_queries_index_and_search()
+        elif '_last_playlist_update.json' == page_name:
+            self._send_last_playlist_update()
+        else:  # e.g. for /
+            self._send_playlist()
 
     def _send_rendered_template(self,
                                 tmpl_name: PathStr,
@@ -362,6 +518,33 @@ class TaskHandler(BaseHTTPRequestHandler):
         videos.sort(key=lambda t: t[1])
         self._send_rendered_template(NAME_TEMPLATE_VIDEOS, {'videos': videos})
 
+    def _send_last_playlist_update(self) -> None:
+        payload: dict[str, str] = {
+                'last_update': self.server.player.last_update}
+        self._send_http(bytes(json_dumps(payload), 'utf8'),
+                        headers=[('Content-type', 'application/json')])
+
+    def _send_playlist(self) -> None:
+        tuples: list[tuple[PathStr, PathStr]] = []
+        i: int = 0
+        while True:
+            prev, next_ = PathStr(''), PathStr('')
+            if len(self.server.player.prev_files) > i:
+                prev = PathStr(basename(self.server.player.prev_files[i]))
+            if len(self.server.player.next_files) > i:
+                next_ = PathStr(basename(self.server.player.next_files[i]))
+            if not prev + next_:
+                break
+            tuples += [(prev, next_)]
+            i += 1
+        self._send_rendered_template(
+                NAME_TEMPLATE_PLAYLIST,
+                {'last_update': self.server.player.last_update,
+                 'running': self.server.player.is_running,
+                 'paused': self.server.player.is_paused,
+                 'current_title': self.server.player.current_filename,
+                 'tuples': tuples})
+
 
 if __name__ == '__main__':
     ensure_expected_dirs_and_files()