after downloading working, minimal changes
This commit is contained in:
parent
1b32d49fcf
commit
8fcdfb7ff7
6 changed files with 336 additions and 92 deletions
22
app/main.py
22
app/main.py
|
|
@ -155,6 +155,10 @@ class Notifier(DownloadQueueNotifier):
|
|||
log.info(f"Notifier: Download cleared - {id}")
|
||||
await sio.emit('cleared', serializer.encode(id))
|
||||
|
||||
async def renamed(self, dl):
|
||||
log.info(f"Notifier: Download renamed - {dl.url}")
|
||||
await sio.emit('renamed', serializer.encode(dl))
|
||||
|
||||
dqueue = DownloadQueue(config, Notifier())
|
||||
app.on_startup.append(lambda app: dqueue.initialize())
|
||||
|
||||
|
|
@ -258,6 +262,24 @@ async def start(request):
|
|||
status = await dqueue.start_pending(ids)
|
||||
return web.Response(text=serializer.encode(status))
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'rename')
|
||||
async def rename(request):
|
||||
try:
|
||||
post = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
log.error("Bad request: invalid JSON in rename")
|
||||
return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'invalid json'}))
|
||||
|
||||
id = post.get('id')
|
||||
new_name = post.get('new_name')
|
||||
if not id or not isinstance(new_name, str) or not new_name.strip():
|
||||
log.error("Bad request: missing id or new_name for rename")
|
||||
return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'missing id or new_name'}))
|
||||
|
||||
status = await dqueue.rename(id, new_name)
|
||||
http_status = 200 if status.get('status') == 'ok' else 200
|
||||
return web.Response(text=serializer.encode(status), status=http_status)
|
||||
|
||||
@routes.get(config.URL_PREFIX + 'history')
|
||||
async def history(request):
|
||||
history = { 'done': [], 'queue': [], 'pending': []}
|
||||
|
|
|
|||
64
app/ytdl.py
64
app/ytdl.py
|
|
@ -30,6 +30,9 @@ class DownloadQueueNotifier:
|
|||
async def cleared(self, id):
|
||||
raise NotImplementedError
|
||||
|
||||
async def renamed(self, dl):
|
||||
raise NotImplementedError
|
||||
|
||||
class DownloadInfo:
|
||||
def __init__(self, id, title, url, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit):
|
||||
self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}'
|
||||
|
|
@ -458,6 +461,67 @@ class DownloadQueue:
|
|||
await self.notifier.cleared(id)
|
||||
return {'status': 'ok'}
|
||||
|
||||
async def rename(self, id, new_name):
|
||||
log.info(f"Rename requested for download {id} -> {new_name!r}")
|
||||
if not isinstance(new_name, str):
|
||||
return {'status': 'error', 'msg': 'new_name must be a string'}
|
||||
new_name = new_name.strip()
|
||||
if not id or not new_name:
|
||||
return {'status': 'error', 'msg': 'missing id or new_name'}
|
||||
if any(sep in new_name for sep in ('/', '\\')):
|
||||
return {'status': 'error', 'msg': 'new_name cannot contain path separators'}
|
||||
if '..' in new_name:
|
||||
return {'status': 'error', 'msg': 'invalid name'}
|
||||
if not self.done.exists(id):
|
||||
log.warn(f'requested rename for non-existent download {id}')
|
||||
return {'status': 'error', 'msg': 'download not found'}
|
||||
download = self.done.get(id)
|
||||
info = download.info
|
||||
if info.status != 'finished':
|
||||
return {'status': 'error', 'msg': 'only finished downloads can be renamed'}
|
||||
if not getattr(info, 'filename', None):
|
||||
return {'status': 'error', 'msg': 'original filename unavailable'}
|
||||
|
||||
dldirectory, error_message = self.__calc_download_path(info.quality, info.format, info.folder)
|
||||
if error_message is not None:
|
||||
return error_message
|
||||
|
||||
current_relative = info.filename
|
||||
current_basename = os.path.basename(current_relative)
|
||||
current_dir = os.path.dirname(current_relative)
|
||||
_, ext = os.path.splitext(current_basename)
|
||||
if ext and new_name.lower().endswith(ext.lower()):
|
||||
return {'status': 'error', 'msg': 'do not include the file extension'}
|
||||
target_basename = f"{new_name}{ext}"
|
||||
target_relative = os.path.join(current_dir, target_basename) if current_dir else target_basename
|
||||
current_path = os.path.join(dldirectory, current_relative)
|
||||
target_path = os.path.join(dldirectory, target_relative)
|
||||
|
||||
current_norm = os.path.normcase(os.path.normpath(current_path))
|
||||
target_norm = os.path.normcase(os.path.normpath(target_path))
|
||||
if target_norm == current_norm:
|
||||
log.info(f"Rename skipped for download {id}; target matches current filename")
|
||||
return {'status': 'ok', 'filename': info.filename}
|
||||
|
||||
if os.path.exists(target_path) and target_norm != current_norm:
|
||||
log.info(f"Rename failed for download {id}; target {target_path} already exists")
|
||||
return {'status': 'error', 'msg': 'target filename already exists'}
|
||||
if not os.path.exists(current_path):
|
||||
log.info(f"Rename failed for download {id}; source {current_path} missing")
|
||||
return {'status': 'error', 'msg': 'original file missing'}
|
||||
|
||||
try:
|
||||
os.replace(current_path, target_path)
|
||||
except OSError as exc:
|
||||
log.error(f"Rename failed for download {id}: {exc}")
|
||||
return {'status': 'error', 'msg': f'rename failed: {exc}'}
|
||||
|
||||
info.filename = target_relative
|
||||
self.done.put(download)
|
||||
asyncio.create_task(self.notifier.renamed(info))
|
||||
log.info(f"Rename successful for download {id}: {current_basename} -> {target_basename}")
|
||||
return {'status': 'ok', 'filename': info.filename}
|
||||
|
||||
def get(self):
|
||||
return (list((k, v.info) for k, v in self.queue.items()) +
|
||||
list((k, v.info) for k, v in self.pending.items()),
|
||||
|
|
|
|||
|
|
@ -346,17 +346,49 @@
|
|||
<td>
|
||||
<app-slave-checkbox [id]="download.key" [master]="doneMasterCheckbox" [checkable]="download.value"></app-slave-checkbox>
|
||||
</td>
|
||||
<td>
|
||||
<td [ngClass]="{'rename-container': renameState[download.key]?.editing}">
|
||||
<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-container *ngIf="download.value.status === 'finished' && renameState[download.key]?.editing; else viewDownloadTitle">
|
||||
<div class="rename-inline">
|
||||
<input class="form-control form-control-sm rename-input"
|
||||
[disabled]="renameState[download.key]?.submitting"
|
||||
[(ngModel)]="renameState[download.key].value"
|
||||
[ngModelOptions]="{standalone: true}"
|
||||
(keyup.enter)="submitRename(download.key, download.value)"
|
||||
(keyup.escape)="cancelRename(download.key)"
|
||||
placeholder="New name">
|
||||
<div class="rename-actions">
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-primary"
|
||||
(click)="submitRename(download.key, download.value)"
|
||||
[disabled]="renameState[download.key]?.submitting">
|
||||
Save
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-secondary"
|
||||
(click)="cancelRename(download.key)"
|
||||
[disabled]="renameState[download.key]?.submitting">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #viewDownloadTitle>
|
||||
<span ngbTooltip="{{download.value.msg}} | {{download.value.error}}">
|
||||
<a *ngIf="!!download.value.filename; else noDownloadLink" href="{{buildDownloadLink(download.value)}}" target="_blank">{{ download.value.filename || 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>
|
||||
</ng-template>
|
||||
<div *ngIf="renameState[download.key]?.error" class="text-danger small mt-2">
|
||||
{{ renameState[download.key].error }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span *ngIf="download.value.size">{{ download.value.size | fileSize }}</span>
|
||||
|
|
@ -366,6 +398,7 @@
|
|||
<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 *ngIf="download.value.status == 'finished' && !renameState[download.key]?.editing" type="button" class="btn btn-link" (click)="beginRename(download.key, download.value)"><fa-icon [icon]="faPen"></fa-icon></button>
|
||||
<button type="button" class="btn btn-link" (click)="delDownload('done', download.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button>
|
||||
</div>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -209,3 +209,26 @@ main
|
|||
|
||||
span
|
||||
white-space: nowrap
|
||||
|
||||
.rename-inline
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 0.5rem
|
||||
width: 100%
|
||||
max-width: 100%
|
||||
flex-wrap: nowrap
|
||||
|
||||
.rename-input
|
||||
flex: 1 1 auto
|
||||
min-width: 10rem
|
||||
max-width: 100%
|
||||
|
||||
.rename-actions
|
||||
display: flex
|
||||
gap: 0.5rem
|
||||
flex-shrink: 0
|
||||
|
||||
.rename-container
|
||||
display: flex
|
||||
align-items: center
|
||||
padding: 1.1rem 0.5rem
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Component, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { faTrashAlt, faCheckCircle, faTimesCircle, IconDefinition } from '@fortawesome/free-regular-svg-icons';
|
||||
import { faRedoAlt, faSun, faMoon, faCircleHalfStroke, faCheck, faExternalLinkAlt, faDownload, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faRedoAlt, faSun, faMoon, faCircleHalfStroke, faCheck, faExternalLinkAlt, faDownload, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt, faPen } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faGithub } from '@fortawesome/free-brands-svg-icons';
|
||||
import { CookieService } from 'ngx-cookie-service';
|
||||
import { map, Observable, of, distinctUntilChanged } from 'rxjs';
|
||||
|
|
@ -12,6 +12,13 @@ import { Formats, Format, Quality } from './formats';
|
|||
import { Theme, Themes } from './theme';
|
||||
import {KeyValue} from "@angular/common";
|
||||
|
||||
interface RenameState {
|
||||
editing: boolean;
|
||||
value: string;
|
||||
error?: string;
|
||||
submitting?: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
|
|
@ -50,6 +57,7 @@ export class AppComponent implements AfterViewInit {
|
|||
completedDownloads = 0;
|
||||
failedDownloads = 0;
|
||||
totalSpeed = 0;
|
||||
renameState: Record<string, RenameState> = {};
|
||||
|
||||
@ViewChild('queueMasterCheckbox') queueMasterCheckbox: MasterCheckboxComponent;
|
||||
@ViewChild('queueDelSelected') queueDelSelected: ElementRef;
|
||||
|
|
@ -77,6 +85,7 @@ export class AppComponent implements AfterViewInit {
|
|||
faGithub = faGithub;
|
||||
faClock = faClock;
|
||||
faTachometerAlt = faTachometerAlt;
|
||||
faPen = faPen;
|
||||
|
||||
constructor(public downloads: DownloadsService, private cookieService: CookieService, private http: HttpClient) {
|
||||
this.format = cookieService.get('metube_format') || 'any';
|
||||
|
|
@ -325,6 +334,78 @@ export class AppComponent implements AfterViewInit {
|
|||
});
|
||||
}
|
||||
|
||||
private getFilenameStem(filename: string): string {
|
||||
if (!filename) {
|
||||
return '';
|
||||
}
|
||||
const base = filename.split('/').pop();
|
||||
if (!base) {
|
||||
return '';
|
||||
}
|
||||
const lastDot = base.lastIndexOf('.');
|
||||
return lastDot > 0 ? base.substring(0, lastDot) : base;
|
||||
}
|
||||
|
||||
private getFilenameExtension(filename: string): string {
|
||||
if (!filename) {
|
||||
return '';
|
||||
}
|
||||
const base = filename.split('/').pop();
|
||||
if (!base) {
|
||||
return '';
|
||||
}
|
||||
const lastDot = base.lastIndexOf('.');
|
||||
return lastDot > -1 ? base.substring(lastDot) : '';
|
||||
}
|
||||
|
||||
beginRename(key: string, download: Download) {
|
||||
if (!download.filename || download.status !== 'finished') {
|
||||
return;
|
||||
}
|
||||
this.renameState[key] = {
|
||||
editing: true,
|
||||
value: this.getFilenameStem(download.filename)
|
||||
};
|
||||
}
|
||||
|
||||
cancelRename(key: string) {
|
||||
delete this.renameState[key];
|
||||
}
|
||||
|
||||
submitRename(key: string, download: Download) {
|
||||
const state = this.renameState[key];
|
||||
if (!state || state.submitting) {
|
||||
return;
|
||||
}
|
||||
const trimmed = state.value.trim();
|
||||
if (!trimmed) {
|
||||
state.error = 'Name cannot be empty';
|
||||
return;
|
||||
}
|
||||
if (/[\\/]/.test(trimmed) || trimmed.includes('..')) {
|
||||
state.error = 'Invalid characters in name';
|
||||
return;
|
||||
}
|
||||
const ext = this.getFilenameExtension(download.filename);
|
||||
if (ext && trimmed.toLowerCase().endsWith(ext.toLowerCase())) {
|
||||
state.error = 'Do not include the file extension';
|
||||
return;
|
||||
}
|
||||
state.submitting = true;
|
||||
state.error = undefined;
|
||||
this.downloads.rename(download.url, trimmed).subscribe((status: Status) => {
|
||||
state.submitting = false;
|
||||
if (status.status === 'ok') {
|
||||
if (status.filename) {
|
||||
download.filename = status.filename;
|
||||
}
|
||||
this.cancelRename(key);
|
||||
} else {
|
||||
state.error = status.msg || 'Rename failed';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
buildDownloadLink(download: Download) {
|
||||
let baseDir = this.downloads.configuration["PUBLIC_HOST_URL"];
|
||||
if (download.quality == 'audio' || download.filename.endsWith('.mp3')) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { MeTubeSocket } from './metube-socket';
|
|||
export interface Status {
|
||||
status: string;
|
||||
msg?: string;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export interface Download {
|
||||
|
|
@ -87,6 +88,20 @@ export class DownloadsService {
|
|||
this.done.delete(data);
|
||||
this.doneChanged.next(null);
|
||||
});
|
||||
socket.fromEvent('renamed').subscribe((strdata: string) => {
|
||||
let data: Download = JSON.parse(strdata);
|
||||
const existing = this.done.get(data.url);
|
||||
let merged: Download;
|
||||
if (existing) {
|
||||
merged = { ...existing, ...data };
|
||||
merged.checked = existing.checked;
|
||||
merged.deleting = existing.deleting;
|
||||
} else {
|
||||
merged = data;
|
||||
}
|
||||
this.done.set(data.url, merged);
|
||||
this.doneChanged.next(null);
|
||||
});
|
||||
socket.fromEvent('configuration').subscribe((strdata: string) => {
|
||||
let data = JSON.parse(strdata);
|
||||
console.debug("got configuration:", data);
|
||||
|
|
@ -125,6 +140,12 @@ export class DownloadsService {
|
|||
return this.http.post('delete', {where: where, ids: ids});
|
||||
}
|
||||
|
||||
public rename(id: string, newName: string) {
|
||||
return this.http.post<Status>('rename', { id: id, new_name: newName }).pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
);
|
||||
}
|
||||
|
||||
public startByFilter(where: string, filter: (dl: Download) => boolean) {
|
||||
let ids: string[] = [];
|
||||
this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) });
|
||||
|
|
|
|||
Loading…
Reference in a new issue