home · contact · privacy
Prepare sync infrastructure. master
authorChristian Heller <c.heller@plomlompom.de>
Thu, 21 Nov 2024 06:27:07 +0000 (07:27 +0100)
committerChristian Heller <c.heller@plomlompom.de>
Thu, 21 Nov 2024 06:27:07 +0000 (07:27 +0100)
templates/playlist.tmpl
ytplom.py
ytplom/misc.py

index aee1deebec8e0ae72e413ca587627b2ec7cb8add..70aaa43b9abf5760b895c47f3f30e908239ba5a6 100644 (file)
@@ -44,6 +44,7 @@ td.history { width: 50%; }
 <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 %}">
 <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 %}">
+<input type="submit" name="reload" value="reload">
 </form>
 </td></tr>
 {% for prev_title, next_title in tuples %}
 </form>
 </td></tr>
 {% for prev_title, next_title in tuples %}
index 9a204f62efc550b0685e818e4d5e14d16e5a7bb5..a3f4c79efa91d3a0366c3216264ba107d7799a80 100755 (executable)
--- a/ytplom.py
+++ b/ytplom.py
@@ -1,15 +1,14 @@
 #!/usr/bin/env python3
 """Minimalistic download-focused YouTube interface."""
 from threading import Thread
 #!/usr/bin/env python3
 """Minimalistic download-focused YouTube interface."""
 from threading import Thread
-from ytplom.misc import DownloadsDb, HTTP_PORT, Player, Server, TaskHandler
+from ytplom.misc import DownloadsDb, HTTP_PORT, Server, TaskHandler
 
 
 if __name__ == '__main__':
     downloads_db = DownloadsDb()
     downloads_db.clean_unfinished()
     Thread(target=downloads_db.download_loop, daemon=False).start()
 
 
 if __name__ == '__main__':
     downloads_db = DownloadsDb()
     downloads_db.clean_unfinished()
     Thread(target=downloads_db.download_loop, daemon=False).start()
-    server = Server(Player(), downloads_db, ('localhost', HTTP_PORT),
-                    TaskHandler)
+    server = Server(downloads_db, ('0.0.0.0', HTTP_PORT), TaskHandler)
     print(f'running at port {HTTP_PORT}')
     try:
         server.serve_forever()
     print(f'running at port {HTTP_PORT}')
     try:
         server.serve_forever()
index 639acdf792b1976f900610d84a225e38e5b09848..5f6908591dc0a7bb1ca95f822c0441ea89536213 100644 (file)
@@ -132,11 +132,12 @@ def _ensure_expected_dirs(expected_dirs: list[PathStr]) -> None:
 class DatabaseConnection:
     """Wrapped sqlite3.Connection."""
 
 class DatabaseConnection:
     """Wrapped sqlite3.Connection."""
 
-    def __init__(self) -> None:
-        if not path_exists(PATH_DB):
-            with sql_connect(PATH_DB) as conn:
+    def __init__(self, path: PathStr = PATH_DB) -> None:
+        self._path = path
+        if not path_exists(self._path):
+            with sql_connect(self._path) as conn:
                 conn.executescript(SCRIPT_INIT_DB)
                 conn.executescript(SCRIPT_INIT_DB)
-        self._conn = sql_connect(PATH_DB)
+        self._conn = sql_connect(self._path)
 
     def exec(self, sql: SqlText, inputs: tuple[Any, ...] = tuple()) -> Cursor:
         """Wrapper around sqlite3.Connection.execute."""
 
     def exec(self, sql: SqlText, inputs: tuple[Any, ...] = tuple()) -> Cursor:
         """Wrapper around sqlite3.Connection.execute."""
@@ -153,6 +154,14 @@ class DbData:
     _table_name: str
     _cols: tuple[str, ...]
 
     _table_name: str
     _cols: tuple[str, ...]
 
+    def __eq__(self, other: Any) -> bool:
+        if not isinstance(other, self.__class__):
+            return False
+        for attr_name in self._cols:
+            if getattr(self, attr_name) != getattr(other, attr_name):
+                return False
+        return True
+
     @classmethod
     def _from_table_row(cls, row: Row) -> Self:
         kwargs = {}
     @classmethod
     def _from_table_row(cls, row: Row) -> Self:
         kwargs = {}
@@ -284,11 +293,7 @@ class VideoFile(DbData):
     def __init__(self, rel_path: PathStr, yt_id: YoutubeId) -> None:
         self.rel_path = rel_path
         self.yt_id = yt_id
     def __init__(self, rel_path: PathStr, yt_id: YoutubeId) -> None:
         self.rel_path = rel_path
         self.yt_id = yt_id
-
-    def remove(self, conn: DatabaseConnection) -> None:
-        """Remove self from database by self.rel_path as identifier."""
-        sql = SqlText(f'DELETE FROM {self._table_name} WHERE rel_path = ?')
-        conn.exec(SqlText(sql), (self.rel_path,))
+        self.missing = False
 
 
 class QuotaLog(DbData):
 
 
 class QuotaLog(DbData):
@@ -330,15 +335,19 @@ class QuotaLog(DbData):
 
 class Player:
     """MPV representation with some additional features."""
 
 class Player:
     """MPV representation with some additional features."""
+    _idx: int
 
     def __init__(self) -> None:
         self.last_update = PlayerUpdateId('')
 
     def __init__(self) -> None:
         self.last_update = PlayerUpdateId('')
+        self._load_filenames()
+        self._mpv: Optional[MPV] = None
+
+    def _load_filenames(self) -> None:
         self._filenames = [PathStr(e.path) for e in scandir(PATH_DOWNLOADS)
                            if isfile(e.path)
                            and splitext(e.path)[1][1:] in LEGAL_EXTENSIONS]
         shuffle(self._filenames)
         self._filenames = [PathStr(e.path) for e in scandir(PATH_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
+        self._idx = 0
 
     @property
     def _mpv_available(self) -> bool:
 
     @property
     def _mpv_available(self) -> bool:
@@ -375,6 +384,12 @@ class Player:
             self._mpv.playlist_append(path)
         self._mpv.playlist_play_index(self._idx)
 
             self._mpv.playlist_append(path)
         self._mpv.playlist_play_index(self._idx)
 
+    @_if_mpv_available
+    def _kill_mpv(self) -> None:
+        assert self._mpv is not None
+        self._mpv.terminate()
+        self._mpv = None
+
     @property
     def current_filename(self) -> Optional[PathStr]:
         """Return what we assume is the name of the currently playing file."""
     @property
     def current_filename(self) -> Optional[PathStr]:
         """Return what we assume is the name of the currently playing file."""
@@ -408,9 +423,7 @@ class Player:
     def toggle_run(self) -> None:
         """Toggle player running."""
         if self._mpv_available:
     def toggle_run(self) -> None:
         """Toggle player running."""
         if self._mpv_available:
-            assert self._mpv is not None
-            self._mpv.terminate()
-            self._mpv = None
+            self._kill_mpv()
         else:
             self._start_mpv()
         self._signal_update()
         else:
             self._start_mpv()
         self._signal_update()
@@ -441,6 +454,13 @@ class Player:
         else:
             self._mpv.playlist_play_index(max_idx)
 
         else:
             self._mpv.playlist_play_index(max_idx)
 
+    def reload(self) -> None:
+        """Close MPV, re-read (and re-shuffle) filenames, then re-start MPV."""
+        self._kill_mpv()
+        self._load_filenames()
+        self._start_mpv()
+        self._signal_update()
+
 
 class DownloadsDb:
     """Collections downloading-related stuff."""
 
 class DownloadsDb:
     """Collections downloading-related stuff."""
@@ -455,10 +475,6 @@ class DownloadsDb:
         files_via_db = VideoFile.get_all(conn)
         old_cwd = getcwd()
         chdir(PATH_DOWNLOADS)
         files_via_db = VideoFile.get_all(conn)
         old_cwd = getcwd()
         chdir(PATH_DOWNLOADS)
-        for file in files_via_db:
-            if not isfile(path_join(file.rel_path)):
-                print(f'SYNC: no file {file.rel_path} found, removing entry.')
-                file.remove(conn)
         paths = [file.rel_path for file in files_via_db]
         for path in [PathStr(e.path) for e in scandir() if isfile(e.path)]:
             if path not in paths:
         paths = [file.rel_path for file in files_via_db]
         for path in [PathStr(e.path) for e in scandir() if isfile(e.path)]:
             if path not in paths:
@@ -466,8 +482,11 @@ class DownloadsDb:
                 file = VideoFile(path, yt_id)
                 print(f'SYNC: new file {path}, saving with YT ID "{yt_id}".')
                 file.save(conn)
                 file = VideoFile(path, yt_id)
                 print(f'SYNC: new file {path}, saving with YT ID "{yt_id}".')
                 file.save(conn)
-        chdir(old_cwd)
         self._files = VideoFile.get_all(conn)
         self._files = VideoFile.get_all(conn)
+        for file in self._files:
+            if not isfile(path_join(file.rel_path)):
+                file.missing = True
+        chdir(old_cwd)
         conn.commit_close()
 
     @staticmethod
         conn.commit_close()
 
     @staticmethod
@@ -479,6 +498,12 @@ class DownloadsDb:
             before_ext = splitext(before_ext)[0]
         return YoutubeId(before_ext.split('[')[-1].split(']')[0])
 
             before_ext = splitext(before_ext)[0]
         return YoutubeId(before_ext.split('[')[-1].split(']')[0])
 
+    @property
+    def missing(self) -> list[PathStr]:
+        """Return relative paths of files known but not in PATH_DOWNLOADS."""
+        self._sync_db()
+        return [f.rel_path for f in self._files if f.missing]
+
     @property
     def ids_to_paths(self) -> DownloadsIndex:
         """Return mapping YoutubeIds:paths of files downloaded to them."""
     @property
     def ids_to_paths(self) -> DownloadsIndex:
         """Return mapping YoutubeIds:paths of files downloaded to them."""
@@ -513,6 +538,7 @@ class DownloadsDb:
             video_id = self._to_download.pop(0)
             with YoutubeDL(YT_DL_PARAMS) as ydl:
                 ydl.download([f'{YOUTUBE_URL_PREFIX}{video_id}'])
             video_id = self._to_download.pop(0)
             with YoutubeDL(YT_DL_PARAMS) as ydl:
                 ydl.download([f'{YOUTUBE_URL_PREFIX}{video_id}'])
+            self._sync_db()
 
     def download_loop(self) -> None:
         """Keep iterating through download queue for new download tasks."""
 
     def download_loop(self) -> None:
         """Keep iterating through download queue for new download tasks."""
@@ -524,13 +550,9 @@ class DownloadsDb:
 class Server(HTTPServer):
     """Extension of HTTPServer providing for Player and DownloadsDb."""
 
 class Server(HTTPServer):
     """Extension of HTTPServer providing for Player and DownloadsDb."""
 
-    def __init__(self,
-                 player: Player,
-                 downloads_db: DownloadsDb,
-                 *args, **kwargs
-                 ) -> None:
+    def __init__(self, downloads_db: DownloadsDb, *args, **kwargs) -> None:
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
-        self.player = player
+        self.player = Player()
         self.downloads = downloads_db
 
 
         self.downloads = downloads_db
 
 
@@ -572,7 +594,9 @@ class TaskHandler(BaseHTTPRequestHandler):
             self.server.player.next()
         elif 'stop' in commands:
             self.server.player.toggle_run()
             self.server.player.next()
         elif 'stop' in commands:
             self.server.player.toggle_run()
-        sleep(0.5)  # avoid reload happening before current_file update
+        elif 'reload' in commands:
+            self.server.player.reload()
+        sleep(0.5)  # avoid redir happening before current_file update
         self._send_http(headers=[('Location', '/')], code=302)
 
     def _post_query(self, query_txt: QueryText) -> None:
         self._send_http(headers=[('Location', '/')], code=302)
 
     def _post_query(self, query_txt: QueryText) -> None:
@@ -637,6 +661,8 @@ class TaskHandler(BaseHTTPRequestHandler):
                 self._send_videos_index()
             elif 'video_about' == page_name:
                 self._send_video_about(YoutubeId(toks_url[2]))
                 self._send_videos_index()
             elif 'video_about' == page_name:
                 self._send_video_about(YoutubeId(toks_url[2]))
+            elif 'missing.json' == page_name:
+                self._send_missing_json()
             elif 'query' == page_name:
                 self._send_query_page(QueryId(toks_url[2]))
             elif 'queries' == page_name:
             elif 'query' == page_name:
                 self._send_query_page(QueryId(toks_url[2]))
             elif 'queries' == page_name:
@@ -728,6 +754,11 @@ class TaskHandler(BaseHTTPRequestHandler):
         videos.sort(key=lambda t: t[1])
         self._send_rendered_template(NAME_TEMPLATE_VIDEOS, {'videos': videos})
 
         videos.sort(key=lambda t: t[1])
         self._send_rendered_template(NAME_TEMPLATE_VIDEOS, {'videos': videos})
 
+    def _send_missing_json(self) -> None:
+        self._send_http(
+                bytes(json_dumps(self.server.downloads.missing), 'utf8'),
+                headers=[('Content-type', 'application/json')])
+
     def _send_last_playlist_update(self) -> None:
         payload: dict[str, PlayerUpdateId] = {
                 'last_update': self.server.player.last_update}
     def _send_last_playlist_update(self) -> None:
         payload: dict[str, PlayerUpdateId] = {
                 'last_update': self.server.player.last_update}