home · contact · privacy
Add "downloads" page to observe and manipulate downloads queue.
authorChristian Heller <c.heller@plomlompom.de>
Fri, 14 Nov 2025 08:15:57 +0000 (09:15 +0100)
committerChristian Heller <c.heller@plomlompom.de>
Fri, 14 Nov 2025 08:15:57 +0000 (09:15 +0100)
src/templates/_base.tmpl
src/templates/downloads.tmpl [new file with mode: 0644]
src/templates/yt_result.tmpl
src/ytplom/http.py
src/ytplom/misc.py

index 8764a5ea5b9721ebebaad219cdb0197c4614ab71..1845b4a13b99f384ce851e7f080656ae112caa11 100644 (file)
@@ -163,6 +163,7 @@ td, th { vertical-align: top; text-align: left; margin: 0; padding: 0; }
 · {{ macros.link_if("files" != selected, page_names.files) }}
 · {{ macros.link_if("tags" != selected, page_names.tags) }}
 · {{ macros.link_if("yt_queries" != selected, page_names.yt_queries, "queries") }}
+· {{ macros.link_if("downloads" != selected, page_names.downloads) }}
 <hr />
 <div id="player_controls"></div>
 <hr />
diff --git a/src/templates/downloads.tmpl b/src/templates/downloads.tmpl
new file mode 100644 (file)
index 0000000..2ed03f8
--- /dev/null
@@ -0,0 +1,125 @@
+{% extends '_base.tmpl' %}
+
+
+{% block script %}
+events_params += 'downloads=1';
+event_handlers.downloads = function(data) {
+    let table = document.getElementById('downloaded_rows');
+    table.innerHTML = '';
+    for (let i = 0; i < data.downloaded.length; i++) {
+        const download_ = data.downloaded[i];
+        const tr = add_child_to(
+            tag='tr',
+            parent=table);
+        add_a_to(
+            parent=add_child_to(
+                tag='td',
+                parent=tr),
+            text=download_.path,
+            href="/{{page_names.file}}/" + download_.digest);
+        add_a_to(
+            parent=add_child_to(
+                tag='td',
+                parent=tr),
+            text=download_.title,
+            href="/{{page_names.yt_result}}/" + download_.yt_id);
+    }
+
+    add_child_to(
+        tag='td',
+        parent=add_child_to(
+            tag='tr',
+            parent=table),
+        attrs={
+            textContent: 'DOWNLOADING'});
+
+    if (data.downloading) {
+        const tr = add_child_to(
+            tag='tr',
+            parent=table);
+        // col 1
+        const td_entry_control = add_child_to(
+            tag='td',
+            parent=tr);
+        add_child_to(
+            tag='button',
+            parent=td_entry_control,
+            attrs={
+                textContent: 'x',
+                onclick: function() {
+                    wrapped_post(
+                        target="{{page_names.downloads_json}}",
+                        body={
+                            command: ['abort']}); }
+           })
+        // col 2
+        add_child_to(
+            tag='td',
+            parent=tr,
+            attrs={
+                textContent: data.downloading.status});
+        // col 3
+        add_a_to(
+            parent=add_child_to(
+                tag='td',
+                parent=tr),
+            text=data.downloading.title,
+            href="/{{page_names.yt_result}}/" + data.downloading.yt_id);
+    }
+
+    for (let i = 0; i < data.to_download.length; i++) {
+        const download_ = data.to_download[i];
+        if (download_.status == 'downloaded') {
+            continue; 
+        } 
+        const tr = add_child_to(
+            tag='tr',
+            parent=table);
+        // col 1
+        add_child_to(
+            tag='button',
+            parent=tr,
+            attrs={
+                textContent: 'x',
+                onclick: function() {
+                    wrapped_post(
+                        target="{{page_names.downloads_json}}",
+                        body={
+                            command: [`unqueue_${i}`]}); }
+           })
+        // col 2
+        const td_entry_control = add_child_to(
+            tag='td',
+            parent=tr);
+        for (const [symbol, prefix] of [['^', 'up'],
+                                        ['v', 'down']]) {
+            add_child_to(
+                tag='button',
+                parent=td_entry_control,
+                attrs={
+                    textContent: symbol,
+                    onclick: function() {
+                        wrapped_post(
+                            target="{{page_names.downloads_json}}",
+                            body={
+                                command: [`${prefix}_${i}`]}); }
+               })
+        }
+        // col 3
+        add_a_to(
+            parent=add_child_to(
+                tag='td',
+                parent=tr),
+            text=download_.title,
+            href="/{{page_names.yt_result}}/" + download_.yt_id);
+    }
+}
+{% endblock %}
+
+
+{% block body %}
+<table id="downloaded_rows">
+</table>
+<table id="to_download_rows">
+</table>
+{% endblock %}
index 2aa3737a130ca6bc593d2ad310a506ba18525ed3..fe68a43c710e56684c2734f2ba210a5a7a60d093 100644 (file)
@@ -7,12 +7,19 @@ event_handlers.download = function(data) {
     const td = document.getElementById("status");
     td.innerHTML = "";
     if ("absent" == data.status) {
-        add_a_to(td, "download?", "/{{page_names.download}}/{{video_data.id_}}");
+        add_a_to(
+            parent=td,
+            text="download?",
+            href="/{{page_names.download}}/{{video_data.id_}}");
     } else if ("present" == data.status) {
-        add_a_to(td, data.path, "/{{page_names.file}}/" + data.digest);
+        add_a_to(
+            parent=td,
+            text=data.path,
+            href="/{{page_names.file}}/" + data.digest);
     } else {
-        add_text_to(td, `${data.status}`);
-    }
+        add_text_to(
+            parent=td,
+            text=`${data.status}`); }
 }
 {% endblock %}
 
index c7fe804839ff0678bd1dbec2817714a7a003a1c0..2eb793059fb81741cf5cf0b53446e537762d037e 100644 (file)
@@ -26,6 +26,7 @@ _THUMBNAIL_URL_SUFFIX = '/default.jpg'
 
 # template paths
 _PATH_TEMPLATES = PATH_APP_DATA.joinpath('templates')
+_NAME_TEMPLATE_DOWNLOADS = Path('downloads.tmpl')
 _NAME_TEMPLATE_FILE_DATA = Path('file_data.tmpl')
 _NAME_TEMPLATE_FILES = Path('files.tmpl')
 _NAME_TEMPLATE_PLAYLIST = Path('playlist.tmpl')
@@ -37,6 +38,8 @@ _NAME_TEMPLATE_YT_RESULTS = Path('yt_results.tmpl')
 # page names
 PAGE_NAMES: dict[str, str] = {
     'download': 'dl',
+    'downloads': 'downloads',
+    'downloads_json': 'downloads.json',
     'events': 'events',
     'file': 'file',
     'file_kill': 'file_kill',
@@ -108,7 +111,9 @@ class _TaskHandler(PlomHttpHandler):
 
     def do_POST(self) -> None:  # pylint:disable=invalid-name
         """Map POST requests to handlers for various paths."""
-        if self.pagename == PAGE_NAMES['file_json']:
+        if self.pagename == PAGE_NAMES['downloads_json']:
+            self._receive_downloads()
+        elif self.pagename == PAGE_NAMES['file_json']:
             self._update_file()
         elif self.pagename == PAGE_NAMES['file_kill']:
             self._kill_file()
@@ -119,6 +124,10 @@ class _TaskHandler(PlomHttpHandler):
         elif self.pagename == PAGE_NAMES['yt_queries']:
             self._receive_yt_query()
 
+    def _receive_downloads(self) -> None:
+        self.server.downloads.q.put(self.postvars.first_for('command'))
+        self.send_http(b'OK')
+
     def _kill_file(self) -> None:
         if not self.server.config.allow_file_edit:
             self.send_http(b'no way', code=403)
@@ -197,6 +206,8 @@ class _TaskHandler(PlomHttpHandler):
         try:
             if self.pagename == PAGE_NAMES['download']:
                 self._send_or_download_video()
+            elif self.pagename == PAGE_NAMES['downloads']:
+                self._send_downloads()
             elif self.pagename == PAGE_NAMES['events']:
                 self._send_events()
             elif self.pagename == PAGE_NAMES['file']:
@@ -253,12 +264,15 @@ class _TaskHandler(PlomHttpHandler):
                            .joinpath(PAGE_NAMES['yt_result'])
                            .joinpath(video_id))
 
+    def _send_downloads(self) -> None:
+        self._send_rendered_template(_NAME_TEMPLATE_DOWNLOADS, {})
+
     def _send_events(self) -> None:
         self.send_http(headers=[(_HEADER_CONTENT_TYPE, 'text/event-stream'),
                                 ('Cache-Control', 'no-cache'),
                                 ('Connection', 'keep-alive')])
         selected: Optional[VideoFile] = None
-        last_updates = {'player': '', 'download': ''}
+        last_updates = {'player': '', 'download': '', 'downloads': ''}
         payload: dict[str, Any] = {}
         time_last_write = 0.0
         subscriptions = ['player'] + list(self.params.as_dict.keys())
@@ -319,6 +333,12 @@ class _TaskHandler(PlomHttpHandler):
                         last_updates['download'] = update['time']
                         payload['download'] = {k: update[k] for k in update
                                                if k != 'time'}
+            if 'downloads' in subscriptions:
+                if last_updates['downloads'] < self.server.downloads.timestamp:
+                    last_updates['downloads'] = self.server.downloads.timestamp
+                    with DbConn() as conn:
+                        payload['downloads']\
+                            = self.server.downloads.overview(conn)
             if not payload:
                 sleep(_EVENTS_UPDATE_INTERVAL_S)
 
index 760d0b485dea1f9d1994caa0836d3a389efce2fe..33a4a9b60827b2c4eb8ffff539b6f0de4e1cd6a1 100644 (file)
@@ -837,6 +837,7 @@ class DownloadsManager:
     """Manages downloading and downloads access."""
 
     def __init__(self) -> None:
+        self._inherited: list[YoutubeId] = []
         self._downloaded: list[YoutubeId] = []
         self._downloading: Optional[YoutubeId] = None
         self._to_download: list[YoutubeId] = []
@@ -861,16 +862,22 @@ class DownloadsManager:
                 with DbConn() as conn:
                     file.save(conn)
                     conn.commit()
-            self._downloaded += [yt_id]
+            self._inherited += [yt_id]
+            self._update_timestamp(yt_id)
+        self._timestamp: DatetimeStr = _now_string()
         chdir(old_cwd)
 
+    @property
+    def timestamp(self) -> DatetimeStr:
+        'When most recent change has occured in downloads management.'
+        return max(self._timestamp, *self._timestamps.values())
+
     def last_update_for(self, conn: DbConn, yt_id: YoutubeId
                         ) -> dict[str, str]:
         'For yt_id construct update with timestamp, status, optional fields.'
         update = {
             'time': self._timestamps.get(yt_id, '2000-01-01'),
-            ## 'status': ('present' if yt_id in self._downloaded + self._inherited
-            'status': ('present' if yt_id in self._downloaded
+            'status': ('present' if yt_id in self._downloaded + self._inherited
                        else ('queued' if yt_id in self._to_download
                              else (self._status if yt_id == self._downloading
                                    else 'absent')))}
@@ -883,6 +890,43 @@ class DownloadsManager:
     def _update_timestamp(self, yt_id: YoutubeId) -> None:
         self._timestamps[yt_id] = _now_string()
 
+    def overview(
+            self, conn: DbConn
+            ) -> dict[str, list[dict[str, str]] | Optional[dict[str, str]]]:
+        'What has been, what will be, and what currently is being downloaded.'
+        downloaded: list[dict[str, str]] = []
+        yt_id: Optional[YoutubeId]
+        for yt_id in self._downloaded:
+            try:
+                file = VideoFile.get_by_yt_id(conn, yt_id)
+            except NotFoundException:
+                continue
+            downloaded += [{'yt_id': yt_id,
+                            'path': str(file.rel_path),
+                            'digest': file.digest.b64}]  # + title?
+        to_download: list[dict[str, str]] = []
+        for idx, yt_id in enumerate(self._to_download):
+            try:
+                yt_video = YoutubeVideo.get_one(conn, yt_id)
+            except NotFoundException:
+                continue
+            to_download += [{'yt_id': yt_id,
+                             'title': yt_video.title,
+                             'idx': str(idx)}]
+        downloading: Optional[dict[str, str]] = None
+        if (yt_id := self._downloading):
+            try:
+                yt_video = YoutubeVideo.get_one(conn, yt_id)
+            except NotFoundException:
+                pass
+            else:
+                downloading = {'yt_id': yt_id,
+                               'title': yt_video.title,
+                               'status': self._status}
+        return {'downloaded': downloaded,
+                'downloading': downloading,
+                'to_download': to_download}
+
     @staticmethod
     def _id_from_filename(path: Path) -> YoutubeId:
         return YoutubeId(path.stem.split('[')[-1].split(']')[0])
@@ -900,9 +944,26 @@ class DownloadsManager:
         if not self._downloading:
             self.q.put('download_next')
 
+    def _unqueue_download(self, idx: int) -> YoutubeId:
+        yt_id = self._to_download[idx]
+        self._to_download.remove(yt_id)
+        return yt_id
+
     def _forget_file(self, yt_id: YoutubeId) -> None:
-        if yt_id in self._downloaded:
-            self._downloaded.remove(yt_id)
+        for attr_name in ('_downloaded', '_inherited'):
+            coll = getattr(self, attr_name)
+            if yt_id in coll:
+                # NB: for some strange remove .remove on target
+                # collection will not satisfy assert below …
+                setattr(self, attr_name, [id_ for id_ in coll if id_ != yt_id])
+        assert yt_id not in self._downloaded + self._inherited
+
+    def _abort_downloading(self) -> YoutubeId:
+        target = self._downloading
+        self._downloading = None
+        assert target is not None
+        self.q.put('download_next')
+        return target
 
     def _savefile(self, arg: str) -> YoutubeId:
         filename = Path(arg)
@@ -918,6 +979,13 @@ class DownloadsManager:
         self._downloading = None
         return yt_id
 
+    def _move_in_queue(self, start_idx: int, upwards: bool) -> None:
+        i0, i1 = start_idx, start_idx + (-1 if upwards else 1)
+        if i1 < 0 or i1 >= len(self._to_download):
+            return
+        self._to_download[i0], self._to_download[i1] = (self._to_download[i1],
+                                                        self._to_download[i0])
+
     def _download_next(self) -> None:
         if not self._to_download:
             return
@@ -926,9 +994,14 @@ class DownloadsManager:
         self._downloading = yt_id = self._to_download.pop(0)
         filename: Optional[str] = None
 
+        class AbortedException(Exception):
+            'To signal abortion of download has been requested.'
+
         def hook(d) -> None:
             nonlocal downloaded_before
             nonlocal filename
+            if self._downloading != yt_id:
+                raise AbortedException()
             if d['status'] in {'downloading', 'finished'}:
                 downloaded_i = d[TOK_LOADED]
                 downloaded_mb = (downloaded_i + downloaded_before) / MEGA
@@ -953,6 +1026,8 @@ class DownloadsManager:
             try:
                 self._status = 'extracting download info'
                 info_dirty = ydl.extract_info(url, download=False)
+                if self._downloading != yt_id:
+                    raise AbortedException()
                 info = ydl.sanitize_info(info_dirty)
                 key_formats = 'requested_formats'
                 if key_formats not in info:
@@ -962,9 +1037,10 @@ class DownloadsManager:
                     if TOK_FS_AP in f:
                         sizes[f[TOK_FO_ID]] = [True, f[TOK_FS_AP]]
                 ydl.download(url)
-            except YoutubeDLError as e:
+            except (YoutubeDLError, AbortedException) as e:
                 self._update_timestamp(yt_id)
-                self._status = 'ERROR'
+                if isinstance(e, YoutubeDLError):
+                    self._status = 'ERROR'
                 raise e
         self.q.put(f'savefile_{filename}')
         self.q.put('download_next')
@@ -973,19 +1049,30 @@ class DownloadsManager:
         'Collect, enact commands sent through .q.'
         def loop() -> None:
             while True:
+                self._timestamp = _now_string()
                 command = self.q.get()
                 if command == 'download_next':
                     Thread(target=self._download_next, daemon=False).start()
                     continue
-                command, arg = command.split('_', maxsplit=1)
-                if command == 'savefile':
-                    yt_id = self._savefile(arg)
+                if command == 'abort':
+                    yt_id = self._abort_downloading()
                 else:
-                    yt_id = arg
-                    if command == 'forget':
-                        self._forget_file(yt_id)
-                    elif command == 'queue':
-                        self._queue_download(yt_id)
+                    command, arg = command.split('_', maxsplit=1)
+                    if command == 'savefile':
+                        yt_id = self._savefile(arg)
+                    elif command in {'unqueue', 'up', 'down'}:
+                        idx = int(arg)
+                        if command == 'unqueue':
+                            yt_id = self._unqueue_download(idx)
+                        else:
+                            self._move_in_queue(idx, upwards=command == 'up')
+                            continue
+                    else:
+                        yt_id = arg
+                        if command == 'queue':
+                            self._queue_download(yt_id)
+                        elif command == 'forget':
+                            self._forget_file(yt_id)
                 self._update_timestamp(yt_id)
 
         Thread(target=loop, daemon=False).start()