after downloading working, minimal changes
This commit is contained in:
parent
1b32d49fcf
commit
8fcdfb7ff7
6 changed files with 336 additions and 92 deletions
48
app/main.py
48
app/main.py
|
|
@ -151,12 +151,16 @@ class Notifier(DownloadQueueNotifier):
|
|||
log.info(f"Notifier: Download canceled - {id}")
|
||||
await sio.emit('canceled', serializer.encode(id))
|
||||
|
||||
async def cleared(self, id):
|
||||
log.info(f"Notifier: Download cleared - {id}")
|
||||
await sio.emit('cleared', serializer.encode(id))
|
||||
|
||||
dqueue = DownloadQueue(config, Notifier())
|
||||
app.on_startup.append(lambda app: dqueue.initialize())
|
||||
async def cleared(self, id):
|
||||
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())
|
||||
|
||||
class FileOpsFilter(DefaultFilter):
|
||||
def __call__(self, change_type: int, path: str) -> bool:
|
||||
|
|
@ -251,13 +255,31 @@ async def delete(request):
|
|||
return web.Response(text=serializer.encode(status))
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'start')
|
||||
async def start(request):
|
||||
post = await request.json()
|
||||
ids = post.get('ids')
|
||||
log.info(f"Received request to start pending downloads for ids: {ids}")
|
||||
status = await dqueue.start_pending(ids)
|
||||
return web.Response(text=serializer.encode(status))
|
||||
|
||||
async def start(request):
|
||||
post = await request.json()
|
||||
ids = post.get('ids')
|
||||
log.info(f"Received request to start pending downloads for ids: {ids}")
|
||||
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': []}
|
||||
|
|
|
|||
98
app/ytdl.py
98
app/ytdl.py
|
|
@ -24,11 +24,14 @@ class DownloadQueueNotifier:
|
|||
async def completed(self, dl):
|
||||
raise NotImplementedError
|
||||
|
||||
async def canceled(self, id):
|
||||
raise NotImplementedError
|
||||
|
||||
async def cleared(self, id):
|
||||
raise NotImplementedError
|
||||
async def canceled(self, id):
|
||||
raise NotImplementedError
|
||||
|
||||
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):
|
||||
|
|
@ -442,11 +445,11 @@ class DownloadQueue:
|
|||
await self.notifier.canceled(id)
|
||||
return {'status': 'ok'}
|
||||
|
||||
async def clear(self, ids):
|
||||
for id in ids:
|
||||
if not self.done.exists(id):
|
||||
log.warn(f'requested delete for non-existent download {id}')
|
||||
continue
|
||||
async def clear(self, ids):
|
||||
for id in ids:
|
||||
if not self.done.exists(id):
|
||||
log.warn(f'requested delete for non-existent download {id}')
|
||||
continue
|
||||
if self.config.DELETE_FILE_ON_TRASHCAN:
|
||||
dl = self.done.get(id)
|
||||
try:
|
||||
|
|
@ -455,10 +458,71 @@ class DownloadQueue:
|
|||
except Exception as e:
|
||||
log.warn(f'deleting file for download {id} failed with error message {e!r}')
|
||||
self.done.delete(id)
|
||||
await self.notifier.cleared(id)
|
||||
return {'status': 'ok'}
|
||||
|
||||
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()),
|
||||
list((k, v.info) for k, v in self.done.items()))
|
||||
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()),
|
||||
list((k, v.info) for k, v in self.done.items()))
|
||||
|
|
|
|||
|
|
@ -73,17 +73,17 @@
|
|||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<div class="input-group input-group-lg shadow-sm">
|
||||
<input type="text"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
class="form-control form-control-lg"
|
||||
placeholder="Enter video or playlist URL"
|
||||
name="addUrl"
|
||||
[(ngModel)]="addUrl"
|
||||
<input type="text"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
class="form-control form-control-lg"
|
||||
placeholder="Enter video or playlist URL"
|
||||
name="addUrl"
|
||||
[(ngModel)]="addUrl"
|
||||
[disabled]="addInProgress || downloads.loading">
|
||||
<button class="btn btn-primary btn-lg px-4"
|
||||
type="submit"
|
||||
(click)="addDownload()"
|
||||
<button class="btn btn-primary btn-lg px-4"
|
||||
type="submit"
|
||||
(click)="addDownload()"
|
||||
[disabled]="addInProgress || downloads.loading">
|
||||
<span class="spinner-border spinner-border-sm" role="status" id="add-spinner" *ngIf="addInProgress"></span>
|
||||
{{ addInProgress ? "Adding..." : "Download" }}
|
||||
|
|
@ -97,10 +97,10 @@
|
|||
<div class="col-md-4">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Quality</span>
|
||||
<select class="form-select"
|
||||
name="quality"
|
||||
[(ngModel)]="quality"
|
||||
(change)="qualityChanged()"
|
||||
<select class="form-select"
|
||||
name="quality"
|
||||
[(ngModel)]="quality"
|
||||
(change)="qualityChanged()"
|
||||
[disabled]="addInProgress || downloads.loading">
|
||||
<option *ngFor="let q of qualities" [ngValue]="q.id">{{ q.text }}</option>
|
||||
</select>
|
||||
|
|
@ -109,18 +109,18 @@
|
|||
<div class="col-md-4">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Format</span>
|
||||
<select class="form-select"
|
||||
name="format"
|
||||
[(ngModel)]="format"
|
||||
(change)="formatChanged()"
|
||||
<select class="form-select"
|
||||
name="format"
|
||||
[(ngModel)]="format"
|
||||
(change)="formatChanged()"
|
||||
[disabled]="addInProgress || downloads.loading">
|
||||
<option *ngFor="let f of formats" [ngValue]="f.id">{{ f.text }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button type="button"
|
||||
class="btn btn-outline-secondary w-100 h-100"
|
||||
<button type="button"
|
||||
class="btn btn-outline-secondary w-100 h-100"
|
||||
(click)="toggleAdvanced()">
|
||||
Advanced Options
|
||||
</button>
|
||||
|
|
@ -137,10 +137,10 @@
|
|||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Auto Start</span>
|
||||
<select class="form-select"
|
||||
name="autoStart"
|
||||
[(ngModel)]="autoStart"
|
||||
(change)="autoStartChanged()"
|
||||
<select class="form-select"
|
||||
name="autoStart"
|
||||
[(ngModel)]="autoStart"
|
||||
(change)="autoStartChanged()"
|
||||
[disabled]="addInProgress || downloads.loading"
|
||||
ngbTooltip="Automatically start downloads when added">
|
||||
<option [ngValue]="true">Yes</option>
|
||||
|
|
@ -151,12 +151,12 @@
|
|||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Download Folder</span>
|
||||
<ng-select [items]="customDirs$ | async"
|
||||
placeholder="Default"
|
||||
[addTag]="allowCustomDir.bind(this)"
|
||||
addTagText="Create directory"
|
||||
bindLabel="folder"
|
||||
[(ngModel)]="folder"
|
||||
<ng-select [items]="customDirs$ | async"
|
||||
placeholder="Default"
|
||||
[addTag]="allowCustomDir.bind(this)"
|
||||
addTagText="Create directory"
|
||||
bindLabel="folder"
|
||||
[(ngModel)]="folder"
|
||||
[disabled]="addInProgress || downloads.loading"
|
||||
[virtualScroll]="true"
|
||||
[clearable]="true"
|
||||
|
|
@ -170,11 +170,11 @@
|
|||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Custom Name Prefix</span>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
placeholder="Default"
|
||||
name="customNamePrefix"
|
||||
[(ngModel)]="customNamePrefix"
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
placeholder="Default"
|
||||
name="customNamePrefix"
|
||||
[(ngModel)]="customNamePrefix"
|
||||
[disabled]="addInProgress || downloads.loading"
|
||||
ngbTooltip="Add a prefix to downloaded filenames">
|
||||
</div>
|
||||
|
|
@ -182,24 +182,24 @@
|
|||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Items Limit</span>
|
||||
<input type="number"
|
||||
min="0"
|
||||
class="form-control"
|
||||
placeholder="Default"
|
||||
name="playlistItemLimit"
|
||||
(keydown)="isNumber($event)"
|
||||
[(ngModel)]="playlistItemLimit"
|
||||
<input type="number"
|
||||
min="0"
|
||||
class="form-control"
|
||||
placeholder="Default"
|
||||
name="playlistItemLimit"
|
||||
(keydown)="isNumber($event)"
|
||||
[(ngModel)]="playlistItemLimit"
|
||||
[disabled]="addInProgress || downloads.loading"
|
||||
ngbTooltip="Maximum number of items to download from a playlist (0 = no limit)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
name="playlistStrictMode"
|
||||
[(ngModel)]="playlistStrictMode"
|
||||
<input class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
name="playlistStrictMode"
|
||||
[(ngModel)]="playlistStrictMode"
|
||||
[disabled]="addInProgress || downloads.loading"
|
||||
ngbTooltip="Only download playlists when URL explicitly points to a playlist">
|
||||
<label class="form-check-label">Strict Playlist Mode</label>
|
||||
|
|
@ -213,24 +213,24 @@
|
|||
<hr class="my-3">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<button type="button"
|
||||
class="btn btn-secondary w-100"
|
||||
<button type="button"
|
||||
class="btn btn-secondary w-100"
|
||||
(click)="openBatchImportModal()">
|
||||
<fa-icon [icon]="faFileImport" class="me-2"></fa-icon>
|
||||
Import URLs
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button type="button"
|
||||
class="btn btn-secondary w-100"
|
||||
<button type="button"
|
||||
class="btn btn-secondary w-100"
|
||||
(click)="exportBatchUrls('all')">
|
||||
<fa-icon [icon]="faFileExport" class="me-2"></fa-icon>
|
||||
Export URLs
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button type="button"
|
||||
class="btn btn-secondary w-100"
|
||||
<button type="button"
|
||||
class="btn btn-secondary w-100"
|
||||
(click)="copyBatchUrls('all')">
|
||||
<fa-icon [icon]="faCopy" class="me-2"></fa-icon>
|
||||
Copy URLs
|
||||
|
|
@ -245,7 +245,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
<!-- Batch Import Modal -->
|
||||
<div class="modal fade" tabindex="-1" role="dialog" [ngClass]="{'show': batchImportModalOpen}" [ngStyle]="{'display': batchImportModalOpen ? 'block' : 'none'}">
|
||||
<div class="modal-dialog" role="document">
|
||||
|
|
@ -273,7 +273,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div *ngIf="downloads.loading" class="alert alert-info" role="alert">
|
||||
Connecting to server...
|
||||
|
|
@ -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