home · contact · privacy
Live-update download status on /yt_result pages. master
authorChristian Heller <c.heller@plomlompom.de>
Sun, 23 Feb 2025 12:10:33 +0000 (13:10 +0100)
committerChristian Heller <c.heller@plomlompom.de>
Sun, 23 Feb 2025 12:10:33 +0000 (13:10 +0100)
src/templates/yt_result.tmpl
src/ytplom/http.py
src/ytplom/misc.py

index 5216bc14d49adf7590ba3a4c236cd73a7ddec625..123b8c2c68acdb54799847f31887529898e05de2 100644 (file)
@@ -1,6 +1,24 @@
 {% extends '_base.tmpl' %}
 
 
+{% block script %}
+events_params += 'download={{video_data.id_}}';
+event_handlers.download = function(data) {
+    const td = document.getElementById("status");
+    td.innerHTML = "";
+    if ("absent" == data.status) {
+        a = new_child_to("a", td, "download?");
+        a.href = "/{{page_names.download}}/{{video_data.id_}}";
+    } else if ("present" == data.status) {
+        a = new_child_to("a", td, data.path);
+        a.href = "/{{page_names.file}}/" + data.digest;
+    } else {
+        td.appendChild(document.createTextNode(`${data.status}`));
+    }
+}
+{% endblock %}
+
+
 {% block body %}
 <table>
 <tr><th>title:</th><td>{{video_data.title}}</td></tr>
@@ -9,7 +27,7 @@
 <tr><th>duration:</th><td>{{video_data.duration}}</td></tr>
 <tr><th>definition:</th><td>{{video_data.definition}}</td></tr>
 <tr><th>YouTube ID:</th><td>{{video_data.id_}}</td></tr>
-<tr><th>download:</th><td>{% if is_temp %}working on it{% elif file_path %}<a href="/{{page_names.file}}/{{file_digest}}">{{file_path}}</a>{% else %}<a href="/{{page_names.download}}/{{video_data.id_}}">please do</a>{% endif %}</td></tr>
+<tr><th>download:</th><td id="status"></td></tr>
 <tr>
 <th>linked queries:</th>
 <td>
index 7f446dda716feb7ef500dda59bc4a27024bd98e9..957cdab711b435a1978716a052a9bfeab6b60769 100644 (file)
@@ -126,7 +126,7 @@ class _TaskHandler(PlomHttpHandler):
             file.set_flags({FILE_FLAGS[FlagName('delete')]})
             file.save(conn)
             conn.commit()
-        file.ensure_unlinked_if_deleted()
+            file.ensure_unlinked_if_deleted(conn)
         self._redirect(Path(self.postvars.first_for('redir_target')))
 
     def _update_file(self) -> None:
@@ -255,9 +255,12 @@ class _TaskHandler(PlomHttpHandler):
                                 ('Cache-Control', 'no-cache'),
                                 ('Connection', 'keep-alive')])
         selected: Optional[VideoFile] = None
-        last_sent = ''
+        last_updates = {'player': '', 'download': ''}
         payload: dict[str, Any] = {}
         time_last_write = 0.0
+        subscriptions = ['player'] + list(self.params.as_dict.keys())
+        if 'download' in subscriptions:
+            download_id = self.params.first_for('download')
         while True:
             if not payload and time_last_write < time() - _PING_INTERVAL_S:
                 payload['ping'] = {}
@@ -277,22 +280,24 @@ class _TaskHandler(PlomHttpHandler):
                 with DbConn() as conn:
                     selected = VideoFile.get_one(
                             conn, self.server.player.current_digest)
-            if last_sent < self.server.player.last_update:
-                last_sent = self.server.player.last_update
-                title, digest, tags = '', '', []
-                if selected:
-                    title = str(selected.rel_path)
-                    digest = selected.digest.b64
-                    tags = selected.tags_showable.as_str_list
-                payload['player'] = {
-                        'is_running': self.server.player.is_running,
-                        'is_playing': self.server.player.is_playing,
-                        'can_play': self.server.player.can_play,
-                        'title_tags': tags,
-                        'title_digest': digest,
-                        'title': title
-                }
-                if self.params.has_key('playlist'):
+            if last_updates['player'] < self.server.player.last_update:
+                if 'player' in subscriptions or 'playlist' in subscriptions:
+                    last_updates['player'] = self.server.player.last_update
+                if 'player' in subscriptions:
+                    title, digest, tags = '', '', []
+                    if selected:
+                        title = str(selected.rel_path)
+                        digest = selected.digest.b64
+                        tags = selected.tags_showable.as_str_list
+                    payload['player'] = {
+                            'is_running': self.server.player.is_running,
+                            'is_playing': self.server.player.is_playing,
+                            'can_play': self.server.player.can_play,
+                            'title_tags': tags,
+                            'title_digest': digest,
+                            'title': title
+                    }
+                if 'playlist' in subscriptions:
                     payload['playlist'] = {
                         'idx': self.server.player.idx,
                         'playlist_files': [
@@ -300,7 +305,14 @@ class _TaskHandler(PlomHttpHandler):
                              'digest': f.digest.b64}
                             for f in self.server.player.playlist]
                     }
-            else:
+            if 'download' in subscriptions:
+                with DbConn() as conn:
+                    last_update = self.server.downloads.last_update_for(
+                            conn, YoutubeId(download_id))
+                if last_updates['download'] < last_update['time']:
+                    last_updates['download'] = last_update['time']
+                    payload['download'] = last_update
+            if not payload:
                 sleep(_EVENTS_UPDATE_INTERVAL_S)
 
     def _send_file_data(self) -> None:
index 1d4ade8839e1b5c8641eb62598127e890924cd0f..5df2502701fc2492e832134ae7b5106d23f5601a 100644 (file)
@@ -392,16 +392,12 @@ class VideoFile(DbData):
         """Return if 'delete' flag set."""
         return self.is_flag_set(FlagName('delete'))
 
-    def ensure_not_deleted(self) -> None:
-        """If 'delete' flag set, raise appropriate NotFoundException."""
-        if self.deleted:
-            raise NotFoundException('not showing entry marked as deleted')
-
     @classmethod
     def get_one(cls, conn: DbConn, id_: str | Hash) -> Self:
         """Extend super by .test_deletion."""
         file = super().get_one(conn, id_)
-        file.ensure_not_deleted()  # pylint: disable=no-member
+        if file.deleted:  # pylint: disable=no-member
+            raise NotFoundException('not showing entry marked as deleted')
         # NB: mypy recognizes file as VideoFile without below assert and
         # if-isinstance-else, yet less type-smart pylint only does due to the
         # latter (also the reason for the disable=no-member above; but wouldn't
@@ -423,14 +419,14 @@ class VideoFile(DbData):
 
     @classmethod
     def get_by_yt_id(cls, conn: DbConn, yt_id: YoutubeId) -> Self:
-        """Return VideoFile of .yt_id."""
-        row = conn.exec(f'SELECT * FROM {cls._table_name} WHERE yt_id =',
-                        (yt_id,)).fetchone()
-        if not row:
-            raise NotFoundException(f'no entry for file to Youtube ID {yt_id}')
-        file = cls._from_table_row(row)
-        file.ensure_not_deleted()
-        return file
+        """Return (non-deleted) VideoFile of .yt_id."""
+        rows = conn.exec(f'SELECT * FROM {cls._table_name} WHERE yt_id =',
+                         (yt_id,)).fetchall()
+        for file in [cls._from_table_row(row) for row in rows]:
+            if not file.deleted:
+                return file
+        raise NotFoundException(
+                f'no undeleted entry for file to Youtube ID {yt_id}')
 
     @classmethod
     def get_filtered(cls,
@@ -529,8 +525,10 @@ class VideoFile(DbData):
         """Return if flag of flag_name is set in flags field."""
         return bool(self.flags & FILE_FLAGS[flag_name])
 
-    def ensure_unlinked_if_deleted(self) -> None:
-        """If 'delete' flag set, ensure no actual file in filesystem."""
+    def ensure_unlinked_if_deleted(self, conn: DbConn) -> None:
+        """If 'delete' flag set and no undeleted owner, ensure unlinked."""
+        if self.full_path in [f.full_path for f in self.get_all(conn)]:
+            return
         if self.is_flag_set(FlagName('delete')) and self.present:
             self.unlink_locally()
 
@@ -784,10 +782,12 @@ class Player:
 
 class DownloadsManager:
     """Manages downloading and downloads access."""
+    _last_updates: dict[YoutubeId, dict[str, str]]
 
     def __init__(self) -> None:
         self._to_download: list[YoutubeId] = []
         ensure_expected_dirs([PATH_DOWNLOADS, PATH_TEMP])
+        self._last_updates = {}
         self._sync_db()
 
     def _sync_db(self):
@@ -795,20 +795,56 @@ class DownloadsManager:
             known_paths = [file.rel_path for file in VideoFile.get_all(conn)]
             old_cwd = Path.cwd()
             chdir(PATH_DOWNLOADS)
-            for path in [p for p in Path('.').iterdir()
-                         if p.is_file() and p not in known_paths]:
+            for path in [p for p in Path('.').iterdir() if p.is_file()]:
                 yt_id = self._id_from_filename(path)
-                print(f'SYNC: new file {path}, saving to YT ID "{yt_id}".')
-                file = VideoFile(digest=None,
-                                 rel_path=path,
-                                 yt_id=yt_id,
-                                 tags_str=VideoFile.tags_default.joined)
-                file.save(conn)
+                if path not in known_paths:
+                    print(f'SYNC: new file {path}, saving to YT ID "{yt_id}".')
+                    file = VideoFile(digest=None,
+                                     rel_path=path,
+                                     yt_id=yt_id,
+                                     tags_str=VideoFile.tags_default.joined)
+                    file.save(conn)
+                    conn.commit()
+                if (yt_id not in self._last_updates
+                        or 'present' != self._last_updates[yt_id]['status']):
+                    self._update_status(
+                            yt_id,
+                            'present',
+                            str(path),
+                            VideoFile.get_by_yt_id(conn, yt_id).digest.b64)
             for file in VideoFile.get_deleteds(conn):
-                file.ensure_unlinked_if_deleted()
+                file.ensure_unlinked_if_deleted(conn)
             self._files = VideoFile.get_all(conn)
             chdir(old_cwd)
-            conn.commit()
+
+    def last_update_for(self,
+                        conn: DbConn,
+                        yt_id: YoutubeId
+                        ) -> dict[str, str]:
+        """Retrieve ._last_updates[yt_id] but reset to 'absent' if needed."""
+        if yt_id in self._last_updates:
+            if self._last_updates[yt_id]['status'] != 'present':
+                return self._last_updates[yt_id]
+            try:
+                file = VideoFile.get_by_yt_id(conn, yt_id)
+                if not file.present:
+                    self._update_status(yt_id, 'absent')
+            except NotFoundException:
+                self._update_status(yt_id, 'absent')
+        else:
+            self._update_status(yt_id, 'absent')
+        return self._last_updates[yt_id]
+
+    def _update_status(self,
+                       yt_id: YoutubeId,
+                       status: str,
+                       path: str = '',
+                       digest: str = ''
+                       ) -> None:
+        self._last_updates[yt_id] = {'status': status, 'time': _now_string()}
+        for k, v in [('path', path), ('digest', digest)]:
+            if k:
+                self._last_updates[yt_id] |= {k: v}
 
     @staticmethod
     def _id_from_filename(path: Path) -> YoutubeId:
@@ -836,11 +872,13 @@ class DownloadsManager:
                               + [f.full_path for f in self._files]))
         if video_id not in pre_existing:
             self._to_download += [video_id]
+            self._update_status(video_id, 'queued')
 
     def _download_next(self) -> None:
         if self._to_download:
             video_id = self._to_download.pop(0)
             with YoutubeDL(YT_DL_PARAMS) as ydl:
+                self._update_status(video_id, 'downloading')
                 ydl.download([f'{YOUTUBE_URL_PREFIX}{video_id}'])
             self._sync_db()