Add grid view with thumbnail preview of videos

This commit is contained in:
vNawar 2025-08-24 23:40:36 +03:00
parent b3242e1f46
commit 8f23cc8cdc
10 changed files with 463 additions and 820 deletions

View file

@ -4,6 +4,7 @@
import os import os
import sys import sys
import asyncio import asyncio
import subprocess
from pathlib import Path from pathlib import Path
from aiohttp import web from aiohttp import web
from aiohttp.log import access_logger from aiohttp.log import access_logger
@ -264,6 +265,57 @@ async def history(request):
log.info("Sending download history") log.info("Sending download history")
return web.Response(text=serializer.encode(history)) return web.Response(text=serializer.encode(history))
@routes.get(config.URL_PREFIX + 'thumb')
async def thumb(request):
# Query: base=video|audio, file=<filename>, folder=<subdir or empty>, t=<seconds>
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 (youll 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
@sio.event @sio.event
async def connect(sid, environ): async def connect(sid, environ):
log.info(f"Client connected: {sid}") log.info(f"Client connected: {sid}")

View file

@ -7,6 +7,7 @@ import asyncio
import multiprocessing import multiprocessing
import logging import logging
import re import re
import subprocess
import yt_dlp.networking.impersonate import yt_dlp.networking.impersonate
from dl_formats import get_format, get_opts, AUDIO_FORMATS from dl_formats import get_format, get_opts, AUDIO_FORMATS
@ -45,6 +46,7 @@ class DownloadInfo:
self.size = None self.size = None
self.timestamp = time.time_ns() self.timestamp = time.time_ns()
self.error = error self.error = error
self.thumbnail = None
class Download: class Download:
manager = None manager = None
@ -220,6 +222,13 @@ class Download:
os.remove(os.path.join(self.download_dir, tmpfile)) os.remove(os.path.join(self.download_dir, tmpfile))
except Exception as e: except Exception as e:
log.warning(f"Error deleting temporary files: {e}") log.warning(f"Error deleting temporary files: {e}")
def _is_audio_download(self) -> bool:
try:
return (self.info.format in AUDIO_FORMATS)
except Exception:
return False
class PersistentQueue: class PersistentQueue:
def __init__(self, path): def __init__(self, path):

1067
ui/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -28,6 +28,7 @@
"@fortawesome/free-solid-svg-icons": "^6.7.0", "@fortawesome/free-solid-svg-icons": "^6.7.0",
"@ng-bootstrap/ng-bootstrap": "^18.0.0", "@ng-bootstrap/ng-bootstrap": "^18.0.0",
"@ng-select/ng-select": "^14.0.0", "@ng-select/ng-select": "^14.0.0",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.6", "bootstrap": "^5.3.6",
"ngx-cookie-service": "^19.0.0", "ngx-cookie-service": "^19.0.0",
"ngx-socket-io": "~4.8.0", "ngx-socket-io": "~4.8.0",
@ -40,7 +41,7 @@
"@angular/cli": "^19.2.14", "@angular/cli": "^19.2.14",
"@angular/compiler-cli": "^19.2.14", "@angular/compiler-cli": "^19.2.14",
"@types/node": "^22.15.29", "@types/node": "^22.15.29",
"codelyzer": "^6.0.2", "codelyzer": "^0.0.28",
"ts-node": "~10.9.1", "ts-node": "~10.9.1",
"tslint": "~6.1.3", "tslint": "~6.1.3",
"typescript": "~5.8.3" "typescript": "~5.8.3"

11
ui/proxy.conf.json Normal file
View file

@ -0,0 +1,11 @@
{
"/socket.io": { "target": "http://localhost:8081", "ws": true, "secure": false, "changeOrigin": true },
"/add": { "target": "http://localhost:8081", "secure": false },
"/delete": { "target": "http://localhost:8081", "secure": false },
"/start": { "target": "http://localhost:8081", "secure": false },
"/history": { "target": "http://localhost:8081", "secure": false },
"/version": { "target": "http://localhost:8081", "secure": false },
"/robots.txt": { "target": "http://localhost:8081", "secure": false },
"/download": { "target": "http://localhost:8081", "secure": false },
"/thumb": { "target": "http://localhost:8081", "secure": false }
}

View file

@ -321,7 +321,24 @@
</table> </table>
</div> </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')">
<fa-icon [icon]="faGrid" class="me-1"></fa-icon> Grid
</button>
</div>
</div>
<div *ngIf="viewMode==='list'">
<div class="px-2 py-3 border-bottom"> <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 #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 #doneClearCompleted (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle"></fa-icon>&nbsp; Clear completed</button>
@ -352,6 +369,7 @@
<fa-icon *ngIf="download.value.status == 'finished'" [icon]="faCheckCircle" class="text-success"></fa-icon> <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> <fa-icon *ngIf="download.value.status == 'error'" [icon]="faTimesCircle" class="text-danger"></fa-icon>
</div> </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> <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> <ng-template #noDownloadLink>
{{download.value.title}} {{download.value.title}}
@ -377,6 +395,52 @@
</tbody> </tbody>
</table> </table>
</div> </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>
<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 p-0"
(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
p-0">
<fa-icon [icon]="faDownload"></fa-icon>
</a>
<a [href]="download.value.url" target="_blank" class="btn btn-link p-0">
<fa-icon [icon]="faExternalLinkAlt"></fa-icon>
</a>
<button type="button" class="btn btn-link p-0" (click)="delDownload('done', download.key)">
<fa-icon [icon]="faTrashAlt"></fa-icon>
</button>
</div>
</div>
</div>
</div>
</div>
</main><!-- /.container --> </main><!-- /.container -->
<footer class="footer navbar-dark bg-dark py-3 mt-5"> <footer class="footer navbar-dark bg-dark py-3 mt-5">

View file

@ -6,6 +6,31 @@
max-width: 960px max-width: 960px
margin: 4rem auto margin: 4rem auto
.thumb
width: 80px
object-fit: cover
border-radius: 4px
vertical-align: middle
.thumb-grid-img
aspect-ratio: 16/9
object-fit: cover
.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)
.add-url-component .add-url-component
margin: 0.5rem auto margin: 0.5rem auto
@ -31,7 +56,11 @@ button.add-url
background: var(--bs-secondary-bg) background: var(--bs-secondary-bg)
padding: 0.5rem 0 padding: 0.5rem 0
margin-top: 3.5rem margin-top: 3.5rem
display: flex
align-items: center
justify-content: space-between
padding: 0.5rem 0.75rem
.metube-section-header:before .metube-section-header:before
content: "" content: ""
position: absolute position: absolute

View file

@ -12,6 +12,8 @@ import { Formats, Format, Quality } from './formats';
import { Theme, Themes } from './theme'; import { Theme, Themes } from './theme';
import {KeyValue} from "@angular/common"; import {KeyValue} from "@angular/common";
import { faList, faTableCells, faGrip } from '@fortawesome/free-solid-svg-icons';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
templateUrl: './app.component.html', templateUrl: './app.component.html',
@ -43,6 +45,10 @@ export class AppComponent implements AfterViewInit {
ytDlpVersion: string | null = null; ytDlpVersion: string | null = null;
metubeVersion: string | null = null; metubeVersion: string | null = null;
isAdvancedOpen = false; isAdvancedOpen = false;
viewMode: 'list' | 'grid' = 'list';
faList = faList;
faTableCells = faTableCells;
faGrid = faGrip;
// Download metrics // Download metrics
activeDownloads = 0; activeDownloads = 0;
@ -504,6 +510,43 @@ export class AppComponent implements AfterViewInit {
this.isAdvancedOpen = !this.isAdvancedOpen; 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);
}
private updateMetrics() { private updateMetrics() {
this.activeDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'downloading' || d.status === 'preparing').length; 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; this.queuedDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'pending').length;

View file

@ -27,6 +27,7 @@ export interface Download {
filename: string; filename: string;
checked?: boolean; checked?: boolean;
deleting?: boolean; deleting?: boolean;
thumbnail?: string;
} }
@Injectable({ @Injectable({

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB