Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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/
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Downloaded media and job metadata
downloads/

# Python
__pycache__/
*.py[cod]
*$py.class
19 changes: 17 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
11 changes: 11 additions & 0 deletions app/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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
132 changes: 132 additions & 0 deletions app/templates/base.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{get('title', 'yle-dl Web Interface')}}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #1a1a1a;
color: #e0e0e0;
padding: 20px;
}
.container {
max-width: 1000px;
margin: 0 auto;
}
h1 {
margin-bottom: 24px;
color: #fff;
font-size: 28px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
color: #b0b0b0;
}
input[type="url"] {
width: 100%;
padding: 12px;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
color: #e0e0e0;
font-size: 14px;
}
input[type="url"]:focus {
outline: none;
border-color: #0066cc;
}
button {
padding: 12px 24px;
background: #0066cc;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
}
button:hover {
background: #0052a3;
}
.info {
margin-top: 30px;
padding: 15px;
background: #2a2a2a;
border-radius: 4px;
font-size: 14px;
color: #b0b0b0;
}
.info code {
background: #3a3a3a;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
.status {
padding: 12px 20px;
border-radius: 4px;
margin-bottom: 20px;
font-weight: bold;
}
.status.queued { background: #444; color: #fff; }
.status.downloading { background: #0066cc; color: #fff; }
.status.completed { background: #00aa00; color: #fff; }
.status.failed { background: #cc0000; color: #fff; }
.status.cancelled { background: #886600; color: #fff; }
.status {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.cancel-btn {
padding: 6px 14px;
background: rgba(0, 0, 0, 0.3);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 4px;
font-size: 13px;
cursor: pointer;
}
.cancel-btn:hover { background: rgba(0, 0, 0, 0.5); }
.output-container {
background: #000;
border: 1px solid #333;
border-radius: 4px;
padding: 15px;
height: 600px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.4;
}
.output-line {
white-space: pre-wrap;
word-wrap: break-word;
}
.back-link, a {
color: #0066cc;
text-decoration: none;
}
.back-link:hover, a:hover {
text-decoration: underline;
}
.back-link {
display: inline-block;
margin-top: 20px;
}
.error { color: #ff4444; }
</style>
</head>
<body>
<div class="container">
{{!base}}
</div>
</body>
</html>
5 changes: 5 additions & 0 deletions app/templates/error.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
% rebase('base.html', title='Job Not Found')

<h1 class="error">Job not found</h1>
<p>Job ID: {{job_id}}</p>
<p><a href="./">Go back</a></p>
22 changes: 22 additions & 0 deletions app/templates/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
% rebase('base.html')

<h1>yle-dl Web Interface</h1>

<form method="GET" action="">
<div class="form-group">
<label for="url">YLE Areena URL:</label>
<input type="url" id="url" name="url"
placeholder="e.g. https://areena.yle.fi/..."
required>
</div>
<button type="submit">Start Download</button>
</form>

<div class="info">
<p><strong>Usage:</strong></p>
<p>Enter YLE Areena URL to start downloading. You can also use:</p>
<p><code>?url=YOUR_YLE_URL</code></p>
<p>Downloaded files will be saved on the server.</p>
</div>

<a href="status" class="back-link">View all jobs →</a>
115 changes: 115 additions & 0 deletions app/templates/jobs.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
% rebase('base.html', title='All jobs - yle-dl Web Interface')

<h1>Active downloads</h1>

<div id="jobs">Loading…</div>

<a href="./" class="back-link">← Start new download</a>

<style>
.job-list { list-style: none; }
.job-item {
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
padding: 12px 16px;
margin-bottom: 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.job-main { min-width: 0; }
.job-url {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 60ch;
}
.job-meta { color: #b0b0b0; font-size: 13px; margin-top: 4px; }
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-size: 12px;
font-weight: bold;
}
.badge.queued { background: #444; color: #fff; }
.badge.downloading { background: #0066cc; color: #fff; }
.empty { color: #b0b0b0; }
</style>

<script>
const jobsDiv = document.getElementById('jobs');
// Only jobs that are still running or waiting to run.
const ACTIVE_STATES = ['queued', 'downloading'];

function fmtStart(iso) {
// Render the UTC ISO timestamp in the browser's local timezone.
return new Date(iso).toLocaleString();
}

function elapsedSeconds(iso) {
// max(0, ...) guards against clock skew between browser and server.
return Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000));
}

function render(jobs) {
const active = jobs
.filter(j => ACTIVE_STATES.includes(j.status))
.sort((a, b) => new Date(a.start_time) - new Date(b.start_time));

if (!active.length) {
jobsDiv.innerHTML = '<p class="empty">No active downloads.</p>';
return;
}

const ul = document.createElement('ul');
ul.className = 'job-list';
active.forEach(job => {
const li = document.createElement('li');
li.className = 'job-item';

const main = document.createElement('div');
main.className = 'job-main';

const link = document.createElement('a');
link.className = 'job-url';
link.href = 'status?job_id=' + encodeURIComponent(job.job_id);
link.textContent = job.url;
main.appendChild(link);

// Build with DOM APIs (not innerHTML) so job fields are always
// treated as text, never parsed as HTML — safe even if the set of
// possible status values grows or becomes less trusted later.
const meta = document.createElement('div');
meta.className = 'job-meta';

const badge = document.createElement('span');
badge.className = 'badge ' + job.status;
badge.textContent = job.status;
meta.appendChild(badge);
meta.appendChild(document.createTextNode(
' Started: ' + fmtStart(job.start_time) +
' · Running: ' + elapsedSeconds(job.start_time) + 's'
));
main.appendChild(meta);

li.appendChild(main);
ul.appendChild(li);
});

jobsDiv.replaceChildren(ul);
}

// Load the list once. Elapsed times reflect the moment the page loaded;
// reload the page for an up-to-date view.
fetch('api/jobs')
.then(r => r.json())
.then(data => render(data.jobs || []))
.catch(err => {
console.error('Failed to load jobs:', err);
jobsDiv.innerHTML = '<p class="empty">Could not load jobs.</p>';
});
</script>
Loading