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
61 changes: 56 additions & 5 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,19 @@
import sys
import asyncio
from pathlib import Path
from aiohttp import web
from aiohttp.log import access_logger
from aiohttp import web # type: ignore
from aiohttp.log import access_logger # pyright: ignore[reportMissingImports]
import ssl
import socket
import socketio
import socketio # type: ignore
import logging
import json
import pathlib
import re
from watchfiles import DefaultFilter, Change, awatch
from watchfiles import DefaultFilter, Change, awatch # type: ignore

from ytdl import DownloadQueueNotifier, DownloadQueue
from yt_dlp.version import __version__ as yt_dlp_version
from yt_dlp.version import __version__ as yt_dlp_version # type: ignore

log = logging.getLogger('main')

Expand Down Expand Up @@ -345,6 +345,57 @@ def index_redirect_root(request):
def index_redirect_dir(request):
return web.HTTPFound(config.URL_PREFIX)

@routes.get(config.URL_PREFIX + 'thumb')
async def thumb(request):
# Query: base=video|audio, file=<filename>, folder=<subdir or empty>, t=<seconds>
Comment on lines +348 to +350
Copy link

Copilot AI Sep 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thumbnail endpoint lacks input validation for the 't' parameter. Malicious values could be passed to ffmpeg. Consider validating that 't' is a valid number within reasonable bounds.

Copilot uses AI. Check for mistakes.
base = request.query.get('base', 'video')
file = request.query.get('file') or ''
folder = request.query.get('folder', '').strip('/')
t = request.query.get('t', '1')

# Choose base dir
base_dir = config.AUDIO_DOWNLOAD_DIR if base == 'audio' else config.DOWNLOAD_DIR

# Build absolute path and secure it
# file is a filename relative to the download folder for that item
# folder (if any) is relative to base_dir
rel_parts = [p for p in [folder, file] if p]
abs_path = os.path.realpath(os.path.join(base_dir, *rel_parts))
real_base = os.path.realpath(base_dir)
if not abs_path.startswith(real_base):
raise web.HTTPBadRequest(text='Invalid path')
if not os.path.exists(abs_path):
raise web.HTTPNotFound()

# For audio, return 404 so the img error handler hides it (you’ll show a placeholder instead)
audio_exts = ('.mp3', '.m4a', '.opus', '.wav', '.flac')
if abs_path.lower().endswith(audio_exts):
raise web.HTTPNotFound()

# Run ffmpeg to stdout (single frame, scaled to width 320)
cmd = [
'ffmpeg', '-hide_banner', '-loglevel', 'error',
'-ss', t, '-i', abs_path,
'-frames:v', '1',
'-vf', 'scale=320:-1',
'-f', 'mjpeg', 'pipe:1',
]
try:
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
except FileNotFoundError:
raise web.HTTPServiceUnavailable(text='ffmpeg not found')

data, err = await proc.communicate()
if proc.returncode != 0 or not data:
raise web.HTTPNotFound(text='Could not generate thumbnail')

resp = web.Response(body=data, content_type='image/jpeg')
# Optional caching
resp.headers['Cache-Control'] = 'public, max-age=86400'
return resp

routes.static(config.URL_PREFIX + 'download/', config.DOWNLOAD_DIR, show_index=config.DOWNLOAD_DIRS_INDEXABLE)
routes.static(config.URL_PREFIX + 'audio_download/', config.AUDIO_DOWNLOAD_DIR, show_index=config.DOWNLOAD_DIRS_INDEXABLE)
routes.static(config.URL_PREFIX, os.path.join(config.BASE_DIR, 'ui/dist/metube/browser'))
Expand Down
112 changes: 73 additions & 39 deletions ui/src/app/app.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -321,57 +321,91 @@ <h5 class="modal-title">Batch Import URLs</h5>
</table>
</div>

<div class="metube-section-header">Completed</div>
<div class="metube-section-header">
<span>Completed</span>
<div class="btn-group metube-view-toggle" role="group" aria-label="View">
<button type="button" class="btn" [ngClass]="viewMode==='list' ? 'btn-primary' : 'btn-outline-secondary'" (click)="setView ? setView('list') : (viewMode='list')">
<fa-icon [icon]="faList" class="me-1"></fa-icon> List
</button>
<button type="button" class="btn" [ngClass]="viewMode==='grid' ? 'btn-primary' : 'btn-outline-secondary'" (click)="setView ? setView('grid') : (viewMode='grid')">
Comment on lines +327 to +330
Copy link

Copilot AI Sep 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The conditional check 'setView ?' is unnecessary since setView is always defined in the component. Simplify to just call setView('list') and setView('grid') directly.

Suggested change
<button type="button" class="btn" [ngClass]="viewMode==='list' ? 'btn-primary' : 'btn-outline-secondary'" (click)="setView ? setView('list') : (viewMode='list')">
<fa-icon [icon]="faList" class="me-1"></fa-icon> List
</button>
<button type="button" class="btn" [ngClass]="viewMode==='grid' ? 'btn-primary' : 'btn-outline-secondary'" (click)="setView ? setView('grid') : (viewMode='grid')">
<button type="button" class="btn" [ngClass]="viewMode==='list' ? 'btn-primary' : 'btn-outline-secondary'" (click)="setView('list')">
<fa-icon [icon]="faList" class="me-1"></fa-icon> List
</button>
<button type="button" class="btn" [ngClass]="viewMode==='grid' ? 'btn-primary' : 'btn-outline-secondary'" (click)="setView('grid')">

Copilot uses AI. Check for mistakes.
Comment on lines +327 to +330
Copy link

Copilot AI Sep 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The conditional check 'setView ?' is unnecessary since setView is always defined in the component. Simplify to just call setView('list') and setView('grid') directly.

Suggested change
<button type="button" class="btn" [ngClass]="viewMode==='list' ? 'btn-primary' : 'btn-outline-secondary'" (click)="setView ? setView('list') : (viewMode='list')">
<fa-icon [icon]="faList" class="me-1"></fa-icon> List
</button>
<button type="button" class="btn" [ngClass]="viewMode==='grid' ? 'btn-primary' : 'btn-outline-secondary'" (click)="setView ? setView('grid') : (viewMode='grid')">
<button type="button" class="btn" [ngClass]="viewMode==='list' ? 'btn-primary' : 'btn-outline-secondary'" (click)="setView('list')">
<fa-icon [icon]="faList" class="me-1"></fa-icon> List
</button>
<button type="button" class="btn" [ngClass]="viewMode==='grid' ? 'btn-primary' : 'btn-outline-secondary'" (click)="setView('grid')">

Copilot uses AI. Check for mistakes.
<fa-icon [icon]="faGrid" class="me-1"></fa-icon> Grid
</button>
</div>
</div>
<div class="px-2 py-3 border-bottom">
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt"></fa-icon>&nbsp; Clear selected</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearCompleted (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle"></fa-icon>&nbsp; Clear completed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearFailed (click)="clearFailedDownloads()"><fa-icon [icon]="faTimesCircle"></fa-icon>&nbsp; Clear failed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneRetryFailed (click)="retryFailedDownloads()"><fa-icon [icon]="faRedoAlt"></fa-icon>&nbsp; Retry failed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDownloadSelected (click)="downloadSelectedFiles()"><fa-icon [icon]="faDownload"></fa-icon>&nbsp; Download Selected</button>
</div>
<div class="overflow-auto">
<table class="table">
<thead>
<tr>
<th scope="col" style="width: 1rem;">
<app-master-checkbox #doneMasterCheckbox [id]="'done'" [list]="downloads.done" (changed)="doneSelectionChanged($event)"></app-master-checkbox>
</th>
<th scope="col">Video</th>
<th scope="col">File Size</th>
<th scope="col" style="width: 8rem;"></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let download of downloads.done | keyvalue: asIsOrder; trackBy: identifyDownloadRow" [class.disabled]='download.value.deleting'>
<td>
<app-slave-checkbox [id]="download.key" [master]="doneMasterCheckbox" [checkable]="download.value"></app-slave-checkbox>
</td>
<td>
<div style="display: inline-block; width: 1.5rem;">
<fa-icon *ngIf="download.value.status == 'finished'" [icon]="faCheckCircle" class="text-success"></fa-icon>
<fa-icon *ngIf="download.value.status == 'error'" [icon]="faTimesCircle" class="text-danger"></fa-icon>
<div *ngIf="viewMode==='list'">
<div class="overflow-auto">
<table class="table">
<thead>
<tr>
<th scope="col" style="width: 1rem;">
<app-master-checkbox #doneMasterCheckbox [id]="'done'" [list]="downloads.done" (changed)="doneSelectionChanged($event)"></app-master-checkbox>
</th>
<th scope="col">Video</th>
<th scope="col">File Size</th>
<th scope="col" style="width: 8rem;"></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let download of downloads.done | keyvalue: asIsOrder; trackBy: identifyDownloadRow" [class.disabled]='download.value.deleting'>
<td>
<app-slave-checkbox [id]="download.key" [master]="doneMasterCheckbox" [checkable]="download.value"></app-slave-checkbox>
</td>
<td>
<div style="display: inline-block; width: 1.5rem;">
<fa-icon *ngIf="download.value.status == 'finished'" [icon]="faCheckCircle" class="text-success"></fa-icon>
<fa-icon *ngIf="download.value.status == 'error'" [icon]="faTimesCircle" class="text-danger"></fa-icon>
</div>
<span ngbTooltip="{{download.value.msg}} | {{download.value.error}}"><a *ngIf="!!download.value.filename; else noDownloadLink" href="{{buildDownloadLink(download.value)}}" target="_blank">{{ download.value.title }}</a></span>
<ng-template #noDownloadLink>
{{download.value.title}}
<span *ngIf="download.value.msg"><br>{{download.value.msg}}</span>
<span *ngIf="download.value.error"><br>Error: {{download.value.error}}</span>
</ng-template>
</td>
<td>
<span *ngIf="download.value.size">{{ download.value.size | fileSize }}</span>
</td>
<td>
<div class="d-flex">
<button *ngIf="download.value.status == 'error'" type="button" class="btn btn-link" (click)="retryDownload(download.key, download.value)"><fa-icon [icon]="faRedoAlt"></fa-icon></button>
<a *ngIf="download.value.filename" href="{{buildDownloadLink(download.value)}}" download class="btn btn-link"><fa-icon [icon]="faDownload"></fa-icon></a>
<a href="{{download.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt"></fa-icon></a>
<button type="button" class="btn btn-link" (click)="delDownload('done', download.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div *ngIf="viewMode==='grid'" class="py-3">
<div class="row row-cols-2 row-cols-md-4 row-cols-lg-5 g-3">
<div class="col" *ngFor="let download of downloads.done | keyvalue: asIsOrder; trackBy: identifyDownloadRow">
<div class="card h-100">
<img *ngIf="getThumbnailUrl(download.value) as thumb" [src]="thumb" alt="" class="card-img-top thumb-grid-img" loading="lazy" (error)="onImgError($event)"/>
<div class="card-body">
<div class="text-truncate" [title]="download.value.title">
<a *ngIf="!!download.value.filename; else noLink" [href]="buildDownloadLink(download.value)" target="_blank">{{ download.value.title }}</a>
<ng-template #noLink>{{ download.value.title }}</ng-template>
</div>
<span ngbTooltip="{{download.value.msg}} | {{download.value.error}}"><a *ngIf="!!download.value.filename; else noDownloadLink" href="{{buildDownloadLink(download.value)}}" target="_blank">{{ download.value.title }}</a></span>
<ng-template #noDownloadLink>
{{download.value.title}}
<span *ngIf="download.value.msg"><br>{{download.value.msg}}</span>
<span *ngIf="download.value.error"><br>Error: {{download.value.error}}</span>
</ng-template>
</td>
<td>
<span *ngIf="download.value.size">{{ download.value.size | fileSize }}</span>
</td>
<td>
<div class="d-flex">
<small class="text-body-secondary" *ngIf="download.value.size">{{ download.value.size | fileSize }}</small>
</div>
<div class="card-footer d-flex justify-content-end gap-2">
<button *ngIf="download.value.status == 'error'" type="button" class="btn btn-link" (click)="retryDownload(download.key, download.value)"><fa-icon [icon]="faRedoAlt"></fa-icon></button>
<a *ngIf="download.value.filename" href="{{buildDownloadLink(download.value)}}" download class="btn btn-link"><fa-icon [icon]="faDownload"></fa-icon></a>
<a href="{{download.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt"></fa-icon></a>
<button type="button" class="btn btn-link" (click)="delDownload('done', download.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</main><!-- /.container -->

Expand Down
30 changes: 29 additions & 1 deletion ui/src/app/app.component.sass
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,11 @@ button.add-url
font-weight: 300
position: relative
background: var(--bs-secondary-bg)
padding: 0.5rem 0
padding: 0.5rem 0.75rem
margin-top: 3.5rem
display: flex
align-items: center
justify-content: space-between

.metube-section-header:before
content: ""
Expand All @@ -42,6 +45,31 @@ button.add-url
border-left: 9999px solid var(--bs-secondary-bg)
box-shadow: 9999px 0 0 var(--bs-secondary-bg)

.metube-view-toggle .btn
min-width: 96px
font-weight: 500

.metube-view-toggle .btn-outline-secondary
background-color: var(--bs-body-bg)
border-color: var(--bs-border-color)
color: var(--bs-body-color)

.metube-view-toggle .btn-outline-secondary:hover
background-color: var(--bs-secondary-bg)

.metube-view-toggle .btn-primary
box-shadow: 0 .25rem .5rem rgba(0,0,0,.08)

.thumb
width: 80px
object-fit: cover
border-radius: 4px
vertical-align: middle

.thumb-grid-img
aspect-ratio: 16/9
object-fit: cover

button:hover
text-decoration: none

Expand Down
42 changes: 42 additions & 0 deletions ui/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { MasterCheckboxComponent } from './master-checkbox.component';
import { Formats, Format, Quality } from './formats';
import { Theme, Themes } from './theme';
import {KeyValue} from "@angular/common";
import { faList, faTableCells, faGrip } from '@fortawesome/free-solid-svg-icons';

@Component({
selector: 'app-root',
Expand Down Expand Up @@ -43,6 +44,10 @@ export class AppComponent implements AfterViewInit {
ytDlpVersion: string | null = null;
metubeVersion: string | null = null;
isAdvancedOpen = false;
viewMode: 'list' | 'grid' = 'list';
faList = faList;
faTableCells = faTableCells;
faGrid = faGrip;

// Download metrics
activeDownloads = 0;
Expand Down Expand Up @@ -504,6 +509,43 @@ export class AppComponent implements AfterViewInit {
this.isAdvancedOpen = !this.isAdvancedOpen;
}

getThumbnailUrl(dl: Download): string | null {
if (this.isAudioDownload(dl)) {
// show your local placeholder for audio
return 'assets/audio-placeholder.png';
}
// Build query: base (video/audio), folder (if any), file (the filename)
const params = new URLSearchParams();
// Decide base by the same logic you use for buildDownloadLink
const isAudio = dl.quality === 'audio' ||
(dl.format && ['mp3','m4a','opus','wav','flac'].includes(dl.format));
params.set('base', isAudio ? 'audio' : 'video');
if (dl.folder) params.set('folder', dl.folder);
if (dl.filename) params.set('file', dl.filename);
params.set('t', '1'); // second to seek; tweak if you like
return `thumb?${params.toString()}`;
}

onImgError(ev: Event) {
const img = ev.target as HTMLImageElement | null;
if (img) img.style.display = 'none';
}

isAudioDownload(dl: Download): boolean {
const audioExts = ['.mp3','.m4a','.opus','.wav','.flac'];
if (dl.quality === 'audio') return true;
if (dl.format && ['mp3','m4a','opus','wav','flac'].includes(dl.format)) return true;
if (dl.filename) {
const lower = dl.filename.toLowerCase();
if (audioExts.some(ext => lower.endsWith(ext))) return true;
}
return false;
}

setView(mode: 'list' | 'grid') {
this.viewMode = mode; localStorage.setItem('completedView', mode);
}
Comment on lines +545 to +547
Copy link

Copilot AI Sep 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The setView method should retrieve the saved view mode from localStorage on component initialization to persist user preference across sessions.

Copilot uses AI. Check for mistakes.

private updateMetrics() {
this.activeDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'downloading' || d.status === 'preparing').length;
this.queuedDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'pending').length;
Expand Down
1 change: 1 addition & 0 deletions ui/src/app/downloads.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface Download {
filename: string;
checked?: boolean;
deleting?: boolean;
thumbnail?: string;
}

@Injectable({
Expand Down
Binary file added ui/src/assets/audio-placeholder.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.