Merge c4ad4e1970 into c6d487e48a
This commit is contained in:
commit
8a0a9a9460
6 changed files with 201 additions and 45 deletions
61
app/main.py
61
app/main.py
|
|
@ -5,19 +5,19 @@ import os
|
|||
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')
|
||||
|
||||
|
|
@ -353,6 +353,57 @@ if config.URL_PREFIX != '/':
|
|||
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>
|
||||
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'))
|
||||
|
|
|
|||
|
|
@ -321,7 +321,17 @@
|
|||
</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')">
|
||||
<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> 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>
|
||||
|
|
@ -329,49 +339,73 @@
|
|||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneRetryFailed (click)="retryFailedDownloads()"><fa-icon [icon]="faRedoAlt"></fa-icon> 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> 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 -->
|
||||
|
||||
|
|
|
|||
|
|
@ -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: ""
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export interface Download {
|
|||
filename: string;
|
||||
checked?: boolean;
|
||||
deleting?: boolean;
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
@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