Add grid view with thumbnail preview of videos
This commit is contained in:
parent
b3242e1f46
commit
8f23cc8cdc
10 changed files with 463 additions and 820 deletions
52
app/main.py
52
app/main.py
|
|
@ -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 (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
|
||||||
|
|
||||||
@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}")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -221,6 +223,13 @@ class Download:
|
||||||
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):
|
||||||
pdir = os.path.dirname(path)
|
pdir = os.path.dirname(path)
|
||||||
|
|
|
||||||
1067
ui/package-lock.json
generated
1067
ui/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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
11
ui/proxy.conf.json
Normal 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 }
|
||||||
|
}
|
||||||
|
|
@ -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> 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> 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> 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> 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">
|
||||||
|
|
|
||||||
|
|
@ -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,6 +56,10 @@ 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: ""
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export interface Download {
|
||||||
filename: string;
|
filename: string;
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
deleting?: boolean;
|
deleting?: boolean;
|
||||||
|
thumbnail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
|
|
|
||||||
BIN
ui/src/assets/audio-placeholder.png
Normal file
BIN
ui/src/assets/audio-placeholder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.1 KiB |
Loading…
Reference in a new issue