From aa188897edf69577b9d13f45c444aabb1660f896 Mon Sep 17 00:00:00 2001 From: Tuomas Salo Date: Sun, 5 Jul 2026 22:31:11 +0300 Subject: [PATCH 1/3] add a simple web interface for yle-dl --- .dockerignore | 8 + .gitignore | 7 + Dockerfile | 19 +- README.md | 39 ++++ app/entrypoint.sh | 11 + app/templates/base.html | 132 +++++++++++ app/templates/error.html | 5 + app/templates/index.html | 22 ++ app/templates/jobs.html | 115 ++++++++++ app/templates/status.html | 115 ++++++++++ app/yle-dl-web.py | 460 ++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 12 + docs/nginx-example.conf | 34 +++ 13 files changed, 977 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 app/entrypoint.sh create mode 100644 app/templates/base.html create mode 100644 app/templates/error.html create mode 100644 app/templates/index.html create mode 100644 app/templates/jobs.html create mode 100644 app/templates/status.html create mode 100644 app/yle-dl-web.py create mode 100644 docker-compose.yml create mode 100644 docs/nginx-example.conf diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..948f4bb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +# Python bytecode — the only thing that could leak into the image via +# `COPY app/`, since app/ may contain a __pycache__. +__pycache__/ +*.py[cod] + +# Keep the build context small / out of the daemon. +.git +downloads/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..16aaa4b --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# Downloaded media and job metadata +downloads/ + +# Python +__pycache__/ +*.py[cod] +*$py.class diff --git a/Dockerfile b/Dockerfile index ac94191..36e0783 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,8 +43,23 @@ RUN --mount=type=cache,target=/root/.cache/pipx \ pipx install yle-dl; \ fi +# Install web interface dependencies +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install --no-cache-dir bottle ansi2html paste + +# Copy web application +COPY app/ /app/ +RUN chmod +x /app/entrypoint.sh && \ + mkdir -p /var/lib/yle-dl-web/jobs + # Set working directory WORKDIR /out -# Set entrypoint -ENTRYPOINT ["yle-dl"] +# Environment variables for web interface (disabled by default) +ENV WORKERS=3 + +# Expose web port (only used if ENABLE_WEB_UI=1) +EXPOSE 8080 + +# Set entrypoint (defaults to CLI, web UI if ENABLE_WEB_UI=1) +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/README.md b/README.md index 4bf191a..8bdbf82 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ Normally the script has a lot of dependencies that you do not want pollute your system. This docker container has them all, and you can use yle-dl script without hassle. +This image also includes an optional web interface for browser-based downloads. + ## Usage Just execute this one-liner: @@ -21,3 +23,40 @@ docker run --rm -ti -u=$(id -u):$(id -g) -v "$(pwd)":/out taskinen/yle-dl YLE-UR ``` Then you have the downloaded file in your current working directory. + +## Web Interface + +The image includes an optional lightweight web server for browser-based downloads. +Enable it with `ENABLE_WEB_UI=1`: + +```sh +docker run -p 8080:8080 -v "$(pwd)/downloads":/out -e ENABLE_WEB_UI=1 taskinen/yle-dl +``` + +or: + +```sh +docker compose up -d +``` + +Then open http://localhost:8080 in your browser. + +Features: +- Simple web form for submitting downloads +- Real-time progress streaming +- Background downloads (continues even if browser is closed) +- Supports nginx reverse proxy + +### Nginx Reverse Proxy Setup + +See `docs/nginx-example.conf` for configuration. Key requirement: +- Disable buffering for Server-Sent Events (SSE) + +Example URL: `https://example.com/yle-dl/?url=YLE-URL` + +### Environment Variables + +- `ENABLE_WEB_UI`: Set to `1` or `true` to enable web interface (default: disabled) +- `WORKERS`: Max concurrent downloads (default: 3) + +Job history is automatically cleaned up after 7 days. diff --git a/app/entrypoint.sh b/app/entrypoint.sh new file mode 100644 index 0000000..ce45b61 --- /dev/null +++ b/app/entrypoint.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -e + +# Check if web UI is enabled +if [ "$ENABLE_WEB_UI" = "1" ] || [ "$ENABLE_WEB_UI" = "true" ]; then + # Start web server (it creates its job dir and prints its own banner) + exec python3 /app/yle-dl-web.py +else + # Run yle-dl CLI (default behavior) + exec yle-dl "$@" +fi diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..f010c70 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,132 @@ + + + + + + {{get('title', 'yle-dl Web Interface')}} + + + +
+ {{!base}} +
+ + diff --git a/app/templates/error.html b/app/templates/error.html new file mode 100644 index 0000000..58eaa1c --- /dev/null +++ b/app/templates/error.html @@ -0,0 +1,5 @@ +% rebase('base.html', title='Job Not Found') + +

Job not found

+

Job ID: {{job_id}}

+

Go back

diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..4a76382 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,22 @@ +% rebase('base.html') + +

yle-dl Web Interface

+ +
+
+ + +
+ +
+ +
+

Usage:

+

Enter YLE Areena URL to start downloading. You can also use:

+

?url=YOUR_YLE_URL

+

Downloaded files will be saved on the server.

+
+ +View all jobs → diff --git a/app/templates/jobs.html b/app/templates/jobs.html new file mode 100644 index 0000000..442a4cb --- /dev/null +++ b/app/templates/jobs.html @@ -0,0 +1,115 @@ +% rebase('base.html', title='All jobs - yle-dl Web Interface') + +

Active downloads

+ +
Loading…
+ +← Start new download + + + + diff --git a/app/templates/status.html b/app/templates/status.html new file mode 100644 index 0000000..69a6b7a --- /dev/null +++ b/app/templates/status.html @@ -0,0 +1,115 @@ +% rebase('base.html', title=f'Download Status - {job_id}') + +

Download Status

+ +
+ Status: {{status}} + +
+ +
+ +← Start new download + ·  +View all jobs → + + diff --git a/app/yle-dl-web.py b/app/yle-dl-web.py new file mode 100644 index 0000000..7773274 --- /dev/null +++ b/app/yle-dl-web.py @@ -0,0 +1,460 @@ +#!/usr/bin/env python3 +""" +Lightweight web interface for yle-dl +Streams yle-dl output to browser in real-time via SSE +""" + +import os +import io +import json +import uuid +import subprocess +import threading +import time +from collections import deque +from datetime import datetime, timezone, timedelta +from pathlib import Path +from queue import Queue, Empty +from concurrent.futures import ThreadPoolExecutor +from bottle import Bottle, request, response, template, redirect, TEMPLATE_PATH +from ansi2html import Ansi2HTMLConverter + +# Look for templates next to this script, regardless of the working directory. +TEMPLATE_PATH.insert(0, str(Path(__file__).parent / 'templates')) + +# Configuration +WORKERS = int(os.environ.get('WORKERS', 3)) +JOB_RETENTION_DAYS = 7 +MAX_OUTPUT_LINES = 1000 # bounded scrollback per job +# Terminal states a job can no longer leave; shared by cancel and stream logic. +FINAL_STATES = frozenset({'completed', 'failed', 'cancelled'}) +OUTPUT_DIR = Path('/out') +# Job metadata is kept outside OUTPUT_DIR so it never pollutes the user's +# mounted download volume. Overridable via JOBS_DIR. +JOBS_DIR = Path(os.environ.get('JOBS_DIR', '/var/lib/yle-dl-web/jobs')) + +# Initialize +app = Bottle() +JOBS_DIR.mkdir(parents=True, exist_ok=True) + +# ANSI to HTML converter +ansi_conv = Ansi2HTMLConverter(inline=True, scheme='xterm') + + +class JobManager: + """Thread-safe job management""" + + def __init__(self): + self.jobs = {} + self.lock = threading.Lock() + self.executor = ThreadPoolExecutor(max_workers=WORKERS) + self.subscribers = {} # job_id -> list of queues for SSE + self.processes = {} # job_id -> running Popen (for cancellation) + self.cancelled = set() # job_ids that were cancelled by the user + + def create_job(self, url): + """Create a new download job""" + job_id = str(uuid.uuid4()) + job = { + 'job_id': job_id, + 'url': url, + 'status': 'queued', + 'output_file': None, + 'error': None, + 'start_time': datetime.now(timezone.utc).isoformat(), + 'end_time': None, + # deque(maxlen=...) bounds scrollback automatically in O(1) — no + # manual length check or list re-slicing on every append. + 'output_lines': deque(maxlen=MAX_OUTPUT_LINES), + 'progress_line': None # transient live line from '\r' redraws + } + + with self.lock: + self.jobs[job_id] = job + self.subscribers[job_id] = [] + + self._persist_job(job_id) + + # Submit to worker pool + self.executor.submit(self._worker, job_id) + + return job_id + + def get_job(self, job_id): + """Get job by ID""" + with self.lock: + return self.jobs.get(job_id) + + def update_job(self, job_id, **kwargs): + """Update job fields""" + with self.lock: + if job_id in self.jobs: + self.jobs[job_id].update(kwargs) + self._persist_job(job_id) + + def _publish(self, job_id, event): + """Push an event to all subscribers of a job. Caller must hold the lock. + + create_job always seeds self.subscribers[job_id], and the key is never + removed, so a plain lookup is safe without an existence guard. + """ + for queue in self.subscribers[job_id]: + queue.put(event) + + def append_output(self, job_id, line): + """Append output line and notify subscribers""" + with self.lock: + if job_id in self.jobs: + # deque(maxlen) drops the oldest line automatically. + self.jobs[job_id]['output_lines'].append(line) + self._publish(job_id, {'type': 'line', 'line': line}) + + def update_progress(self, job_id, line): + """Update the transient 'live' line (a '\\r' progress redraw). + + Unlike append_output, this is not persisted to history: it represents + the single terminal line that yle-dl keeps overwriting. Passing None + clears it. Subscribers replace their current live line in place. + """ + with self.lock: + if job_id in self.jobs: + self.jobs[job_id]['progress_line'] = line + self._publish(job_id, {'type': 'progress', 'line': line}) + + def notify_status(self, job_id, status): + """Notify subscribers of status change""" + with self.lock: + if job_id in self.jobs: + self._publish(job_id, {'type': 'status', 'status': status}) + + def subscribe(self, job_id): + """Subscribe to job updates (returns a queue). + + The current history is seeded as the first event under the same lock + that append_output/update_progress hold when pushing. This makes + "get the backlog" and "start receiving live events" atomic: a client + can neither miss a line emitted between the two nor receive one twice. + """ + queue = Queue() + with self.lock: + job = self.jobs.get(job_id) + if job is not None: + queue.put({ + 'type': 'snapshot', + 'lines': list(job['output_lines']), + 'progress': job['progress_line'], + }) + self.subscribers[job_id].append(queue) + return queue + + def unsubscribe(self, job_id, queue): + """Unsubscribe from job updates""" + with self.lock: + if job_id in self.subscribers and queue in self.subscribers[job_id]: + self.subscribers[job_id].remove(queue) + + def cancel_job(self, job_id): + """Cancel a queued or running job. Returns True if it was cancellable.""" + with self.lock: + job = self.jobs.get(job_id) + if not job or job['status'] in FINAL_STATES: + return False + # Mark as cancelled so the worker knows this was intentional. + self.cancelled.add(job_id) + process = self.processes.get(job_id) + + # Terminate the process outside the lock; the worker thread will + # observe the non-zero exit and mark the job as cancelled. + if process and process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + else: + # Job was still queued (no process yet): mark it cancelled now. + self.update_job( + job_id, + status='cancelled', + end_time=datetime.now(timezone.utc).isoformat() + ) + self.notify_status(job_id, 'cancelled') + return True + + @staticmethod + def _job_json(job): + """Return a JSON-serializable view of a job (output_lines is a deque).""" + return {**job, 'output_lines': list(job['output_lines'])} + + def list_jobs(self): + """List all jobs (JSON-safe)""" + with self.lock: + return [self._job_json(job) for job in self.jobs.values()] + + def cleanup_old_jobs(self): + """Remove job files older than JOB_RETENTION_DAYS""" + try: + cutoff = datetime.now(timezone.utc) - timedelta(days=JOB_RETENTION_DAYS) + for job_file in JOBS_DIR.glob('*.json'): + try: + # Check file modification time + mtime = datetime.fromtimestamp(job_file.stat().st_mtime, tz=timezone.utc) + if mtime < cutoff: + job_file.unlink() + except Exception as e: + print(f'Error cleaning up {job_file}: {e}') + except Exception as e: + print(f'Error during job cleanup: {e}') + + def _persist_job(self, job_id): + """Save job to disk""" + job = self.jobs.get(job_id) + if job: + job_file = JOBS_DIR / f'{job_id}.json' + with open(job_file, 'w') as f: + json.dump(self._job_json(job), f, indent=2) + + def _worker(self, job_id): + """Worker thread that runs yle-dl""" + job = self.get_job(job_id) + if not job: + return + + self.update_job(job_id, status='downloading') + self.notify_status(job_id, 'downloading') + + try: + # Run yle-dl + # Pass '--' so the URL is never interpreted as yle-dl option, + # even if validation is ever relaxed. + process = subprocess.Popen( + ['yle-dl', '--', job['url']], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + cwd=str(OUTPUT_DIR) + ) + + # Register the process so it can be cancelled. If a cancel arrived + # while the job was still queued, terminate immediately. + with self.lock: + if job_id in self.cancelled: + process.terminate() + self.processes[job_id] = process + + # Wrap stdout with newline='' so lone '\r' is preserved instead of + # being translated to '\n' by universal-newline mode. yle-dl/ffmpeg + # redraw progress on one terminal line with '\r'; we mirror that so + # the web view shows a single updating line, not one line per tick. + stream = io.TextIOWrapper( + process.stdout, encoding='utf-8', errors='replace', newline='' + ) + + # Model the terminal's active line. yle-dl redraws progress with + # '\r' (cursor to column 0, overwrite) and only advances to a new + # line on '\n'. So '\r'-terminated chunks update a transient "live" + # line that is never persisted; '\n' commits the line to history. + # The wrapper yields one chunk per terminator, so a committing chunk + # already holds the full overwriting text — never concatenate it + # onto the previous progress text. + current_line = '' # content shown on the live (unfinished) line + progress_active = False # a '\r' redraw is currently displayed + for chunk in stream: + if chunk.endswith('\r'): + # Progress redraw: update the live line in place. + current_line = chunk[:-1] + progress_active = True + self.update_progress(job_id, self._convert_ansi(current_line)) + else: + # Committed by '\n' (or the final unterminated EOF chunk). + # Non-empty text overwrites the live line; a bare newline + # commits whatever the live line already held. + # A bare newline (empty text) commits whatever the live + # line already held; otherwise this chunk's text is the line. + text = chunk.rstrip('\n') + committed = text if text else current_line + if committed.strip(): + self.append_output(job_id, self._convert_ansi(committed)) + if progress_active: + # The live line has been finalized; clear the transient. + self.update_progress(job_id, None) + progress_active = False + current_line = '' + + # Commit any progress text left on the live line at EOF, then clear + # the transient so the frontend doesn't show it twice. + if current_line.strip(): + self.append_output(job_id, self._convert_ansi(current_line)) + if progress_active: + self.update_progress(job_id, None) + + process.wait() + + was_cancelled = job_id in self.cancelled + + # Check result + if was_cancelled: + self.update_job( + job_id, + status='cancelled', + end_time=datetime.now(timezone.utc).isoformat() + ) + self.notify_status(job_id, 'cancelled') + elif process.returncode == 0: + self.update_job( + job_id, + status='completed', + end_time=datetime.now(timezone.utc).isoformat() + ) + self.notify_status(job_id, 'completed') + else: + self.update_job( + job_id, + status='failed', + error=f'yle-dl exited with code {process.returncode}', + end_time=datetime.now(timezone.utc).isoformat() + ) + self.notify_status(job_id, 'failed') + + except Exception as e: + self.update_job( + job_id, + status='failed', + error=str(e), + end_time=datetime.now(timezone.utc).isoformat() + ) + self.notify_status(job_id, 'failed') + finally: + with self.lock: + self.processes.pop(job_id, None) + + def _convert_ansi(self, line): + """Convert ANSI codes to HTML""" + try: + return ansi_conv.convert(line, full=False) + except Exception: + # Fallback to plain text if conversion fails + return line + + +# Global job manager +job_manager = JobManager() + + +# Routes + +@app.route('/') +def index(): + """Main page""" + url_param = request.query.get('url') + + if url_param: + url_param = url_param.strip() + if not url_param.startswith('https://'): + response.status = 400 + return template('error.html', + job_id='Invalid URL. Must start with https://') + # Create job and redirect to status page (relative URL) + job_id = job_manager.create_job(url_param) + return redirect(f'status?job_id={job_id}') + + return template('index.html') + + +@app.route('/status') +def status_page(): + """Status page for a single job, or the all-jobs overview if no job_id.""" + job_id = request.query.get('job_id') + + if not job_id: + # Overview of all jobs (client renders times in the browser timezone). + return template('jobs.html') + + job = job_manager.get_job(job_id) + + if not job: + return template('error.html', job_id=job_id) + + return template('status.html', job_id=job_id, status=job['status']) + + +@app.route('/stream/') +def stream(job_id): + """SSE stream for job updates""" + job = job_manager.get_job(job_id) + if not job: + response.status = 404 + return {'error': 'Job not found'} + + # Set SSE headers + response.content_type = 'text/event-stream' + response.set_header('Cache-Control', 'no-cache') + response.set_header('X-Accel-Buffering', 'no') + + # Subscribe to job updates + queue = job_manager.subscribe(job_id) + + def generate(): + try: + while True: + # Block until the worker pushes an event, so output reaches the + # browser with no polling delay. The timeout just lets us send a + # keepalive and re-check whether the job has finished. + try: + event = queue.get(timeout=15) + yield f'data: {json.dumps(event)}\n\n' + continue + except Empty: + pass + + # No events for a while: keepalive and check for completion. + # Only stop once the queue is empty so the final status event + # is never dropped. + yield ': keepalive\n\n' + current_job = job_manager.get_job(job_id) + if (current_job + and current_job['status'] in FINAL_STATES + and queue.empty()): + break + finally: + job_manager.unsubscribe(job_id, queue) + + return generate() + + +@app.route('/api/job//cancel', method='POST') +def cancel_job_api(job_id): + """Cancel a running or queued job""" + job = job_manager.get_job(job_id) + if not job: + response.status = 404 + return {'error': 'Job not found'} + if job_manager.cancel_job(job_id): + return {'status': 'cancelled'} + response.status = 409 + return {'error': 'Job is already finished'} + + +@app.route('/api/jobs') +def list_jobs_api(): + """List all jobs""" + return {'jobs': job_manager.list_jobs()} + + +def cleanup_worker(): + """Background thread for periodic job cleanup""" + while True: + time.sleep(86400) # Run once per day + job_manager.cleanup_old_jobs() + + +if __name__ == '__main__': + print('Starting yle-dl Web Interface on port 8080') + print(f'Workers: {WORKERS}') + print(f'Job retention: {JOB_RETENTION_DAYS} days') + print(f'Output directory: {OUTPUT_DIR}') + + # Start cleanup worker + cleanup_thread = threading.Thread(target=cleanup_worker, daemon=True) + cleanup_thread.start() + + app.run(host='0.0.0.0', port=8080, server='paste') diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8144b3c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + yle-dl-web: + build: . + container_name: yle-dl-web + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - ./downloads:/out + environment: + - ENABLE_WEB_UI=1 + - WORKERS=3 diff --git a/docs/nginx-example.conf b/docs/nginx-example.conf new file mode 100644 index 0000000..8ca9a6e --- /dev/null +++ b/docs/nginx-example.conf @@ -0,0 +1,34 @@ +# Nginx example configuration for yle-dl-web. +# Add this to your nginx server block. + +location /yle-dl/ { + proxy_pass http://localhost:8080/; + + # SSE support + proxy_buffering off; + proxy_cache off; + proxy_set_header Connection ''; + proxy_http_version 1.1; + chunked_transfer_encoding on; + proxy_set_header X-Accel-Buffering no; +} + +# Full server block with SSL: +# +# server { +# listen 443 ssl http2; +# server_name example.com; +# +# ssl_certificate /etc/nginx/ssl/cert.pem; +# ssl_certificate_key /etc/nginx/ssl/key.pem; +# +# location /yle-dl/ { +# proxy_pass http://localhost:8080/; +# proxy_buffering off; +# proxy_cache off; +# proxy_set_header Connection ''; +# proxy_http_version 1.1; +# chunked_transfer_encoding on; +# proxy_set_header X-Accel-Buffering no; +# } +# } From ce480211a31ca8f550afee7ab12a664de7348d9a Mon Sep 17 00:00:00 2001 From: Tuomas Salo Date: Mon, 6 Jul 2026 23:17:19 +0300 Subject: [PATCH 2/3] "scroll to bottom" button --- app/templates/status.html | 59 +++++++++++++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/app/templates/status.html b/app/templates/status.html index 69a6b7a..18a1b81 100644 --- a/app/templates/status.html +++ b/app/templates/status.html @@ -1,5 +1,26 @@ % rebase('base.html', title=f'Download Status - {job_id}') + +

Download Status

@@ -7,7 +28,10 @@

Download Status

-
+
+
+ +
← Start new download  ·  @@ -19,6 +43,7 @@

Download Status

const statusDiv = document.getElementById('status'); const statusText = document.getElementById('status-text'); const cancelBtn = document.getElementById('cancel-btn'); + const scrollBtn = document.getElementById('scroll-bottom-btn'); const FINAL_STATES = ['completed', 'failed', 'cancelled']; @@ -31,6 +56,28 @@

Download Status

outputDiv.scrollTop = outputDiv.scrollHeight; } + // Whether the view should follow new output. Driven by the user's scroll + // position, NOT recomputed per event: measuring geometry at each append is + // fragile under a fast burst — a read taken mid-reflow, or during a + // progress-line add/remove, can report the wrong distance and silently + // drop the "stick" even though the user never scrolled up. Instead we + // update the flag from real 'scroll' events (including the ones our own + // scrollToBottom() fires), so it stays correct no matter how fast output + // arrives and only turns off when the user actually scrolls away. + let stickToBottom = true; + function updateScrollButton() { + scrollBtn.hidden = stickToBottom; + } + outputDiv.addEventListener('scroll', function() { + stickToBottom = isAtBottom(); + updateScrollButton(); + }); + scrollBtn.addEventListener('click', function() { + // Fires a 'scroll' event, which flips stickToBottom back on and hides + // the button via the listener above. + scrollToBottom(); + }); + // The transient "live" line, mirroring a terminal line that yle-dl keeps // overwriting with '\r'. It's a single element kept at the bottom of the // output; committed lines are inserted before it, and it's removed/updated @@ -38,27 +85,27 @@

Download Status

let progressEl = null; function setProgress(html) { if (html === null) { - // Progress finished: drop the live line. + // Progress finished: drop the live line. Removing it shrinks the + // content, so re-pin if we were following. if (progressEl) { progressEl.remove(); progressEl = null; } + if (stickToBottom) scrollToBottom(); return; } - const stick = isAtBottom(); if (!progressEl) { progressEl = document.createElement('div'); progressEl.className = 'output-line'; outputDiv.appendChild(progressEl); } progressEl.innerHTML = html; - if (stick) scrollToBottom(); + if (stickToBottom) scrollToBottom(); } function appendLine(html) { - const stick = isAtBottom(); const lineDiv = document.createElement('div'); lineDiv.className = 'output-line'; lineDiv.innerHTML = html; // Keep committed lines above the live progress line, if present. outputDiv.insertBefore(lineDiv, progressEl); - if (stick) scrollToBottom(); + if (stickToBottom) scrollToBottom(); } function updateCancelButton(status) { From f173559b513ad5ab1e5f771b213cf77c487dbd39 Mon Sep 17 00:00:00 2001 From: Tuomas Salo Date: Mon, 6 Jul 2026 23:50:21 +0300 Subject: [PATCH 3/3] show more compact progress indicator by using a PTY in cli invocation --- app/yle-dl-web.py | 115 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 85 insertions(+), 30 deletions(-) diff --git a/app/yle-dl-web.py b/app/yle-dl-web.py index 7773274..e6f6513 100644 --- a/app/yle-dl-web.py +++ b/app/yle-dl-web.py @@ -6,8 +6,13 @@ import os import io +import pty +import errno +import fcntl import json import uuid +import struct +import termios import subprocess import threading import time @@ -223,16 +228,44 @@ def _worker(self, job_id): self.update_job(job_id, status='downloading') self.notify_status(job_id, 'downloading') + master_fd = None + stream = None try: - # Run yle-dl + # Run yle-dl attached to a pseudo-terminal. yle-dl shells out to + # wget for direct file downloads, and wget checks isatty(stdout): + # on a terminal it draws a single-line progress bar redrawn with + # '\r'; on a plain pipe it falls back to verbose multi-line "dot" + # output. Giving the child a PTY makes it emit the same clean + # single-line progress the CLI shows, which the '\r'/'\n' loop + # below already knows how to render. (ffmpeg, used for streams, + # writes '\r' progress either way.) + master_fd, slave_fd = pty.openpty() + + # Disable ONLCR on the slave: without this the tty layer rewrites + # the child's '\n' into '\r\n', which would leave a stray '\r' on + # every committed line. With it off the byte stream matches the CLI + # exactly (bare '\r' for redraws, bare '\n' for committed lines). + attrs = termios.tcgetattr(slave_fd) + attrs[1] &= ~termios.ONLCR # oflag + termios.tcsetattr(slave_fd, termios.TCSANOW, attrs) + + # Give wget a roomy width so its progress bar isn't cramped. + fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, + struct.pack('HHHH', 24, 120, 0, 0)) + # Pass '--' so the URL is never interpreted as yle-dl option, # even if validation is ever relaxed. process = subprocess.Popen( ['yle-dl', '--', job['url']], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - cwd=str(OUTPUT_DIR) + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + cwd=str(OUTPUT_DIR), + close_fds=True ) + # The child holds its own copy of the slave; close ours so that + # reads on the master see EOF once the child exits. + os.close(slave_fd) # Register the process so it can be cancelled. If a cancel arrived # while the job was still queued, terminate immediately. @@ -241,12 +274,13 @@ def _worker(self, job_id): process.terminate() self.processes[job_id] = process - # Wrap stdout with newline='' so lone '\r' is preserved instead of - # being translated to '\n' by universal-newline mode. yle-dl/ffmpeg - # redraw progress on one terminal line with '\r'; we mirror that so - # the web view shows a single updating line, not one line per tick. + # Read the master end. newline='' preserves lone '\r' instead of + # letting universal-newline mode translate it to '\n'. yle-dl/wget/ + # ffmpeg redraw progress on one terminal line with '\r'; we mirror + # that so the web view shows a single updating line, not one per tick. stream = io.TextIOWrapper( - process.stdout, encoding='utf-8', errors='replace', newline='' + os.fdopen(master_fd, 'rb', buffering=0), + encoding='utf-8', errors='replace', newline='' ) # Model the terminal's active line. yle-dl redraws progress with @@ -258,27 +292,38 @@ def _worker(self, job_id): # onto the previous progress text. current_line = '' # content shown on the live (unfinished) line progress_active = False # a '\r' redraw is currently displayed - for chunk in stream: - if chunk.endswith('\r'): - # Progress redraw: update the live line in place. - current_line = chunk[:-1] - progress_active = True - self.update_progress(job_id, self._convert_ansi(current_line)) - else: - # Committed by '\n' (or the final unterminated EOF chunk). - # Non-empty text overwrites the live line; a bare newline - # commits whatever the live line already held. - # A bare newline (empty text) commits whatever the live - # line already held; otherwise this chunk's text is the line. - text = chunk.rstrip('\n') - committed = text if text else current_line - if committed.strip(): - self.append_output(job_id, self._convert_ansi(committed)) - if progress_active: - # The live line has been finalized; clear the transient. - self.update_progress(job_id, None) - progress_active = False - current_line = '' + try: + for chunk in stream: + if chunk.endswith('\r'): + # Progress redraw: update the live line in place. + current_line = chunk[:-1] + progress_active = True + self.update_progress(job_id, self._convert_ansi(current_line)) + else: + # Committed by '\n' (or the final unterminated EOF chunk). + # Non-empty text overwrites the live line; a bare newline + # commits whatever the live line already held. + # A bare newline (empty text) commits whatever the live + # line already held; otherwise this chunk's text is the line. + # Strip a trailing '\r' too: over a PTY, wget ends its + # final progress redraw with '\r' right before the '\n', + # yielding a '\r\n' chunk — the '\r' is cursor control, + # never content. + text = chunk.rstrip('\r\n') + committed = text if text else current_line + if committed.strip(): + self.append_output(job_id, self._convert_ansi(committed)) + if progress_active: + # The live line has been finalized; clear the transient. + self.update_progress(job_id, None) + progress_active = False + current_line = '' + except OSError as e: + # Reading a PTY master after the child has closed the slave + # raises EIO on Linux instead of returning EOF. Treat it as a + # clean end of stream; re-raise anything else. + if e.errno != errno.EIO: + raise # Commit any progress text left on the live line at EOF, then clear # the transient so the frontend doesn't show it twice. @@ -324,6 +369,16 @@ def _worker(self, job_id): ) self.notify_status(job_id, 'failed') finally: + # Close the PTY master. If the TextIOWrapper was created it owns + # the fd; otherwise close the raw fd directly. Guard against a + # double close (e.g. spawn failed before the wrapper existed). + try: + if stream is not None: + stream.close() + elif master_fd is not None: + os.close(master_fd) + except OSError: + pass with self.lock: self.processes.pop(job_id, None)