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}")
|
log.info(f"Notifier: Download canceled - {id}")
|
||||||
await sio.emit('canceled', serializer.encode(id))
|
await sio.emit('canceled', serializer.encode(id))
|
||||||
|
|
||||||
async def cleared(self, id):
|
async def cleared(self, id):
|
||||||
log.info(f"Notifier: Download cleared - {id}")
|
log.info(f"Notifier: Download cleared - {id}")
|
||||||
await sio.emit('cleared', serializer.encode(id))
|
await sio.emit('cleared', serializer.encode(id))
|
||||||
|
|
||||||
dqueue = DownloadQueue(config, Notifier())
|
async def renamed(self, dl):
|
||||||
app.on_startup.append(lambda app: dqueue.initialize())
|
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):
|
class FileOpsFilter(DefaultFilter):
|
||||||
def __call__(self, change_type: int, path: str) -> bool:
|
def __call__(self, change_type: int, path: str) -> bool:
|
||||||
|
|
@ -251,13 +255,31 @@ async def delete(request):
|
||||||
return web.Response(text=serializer.encode(status))
|
return web.Response(text=serializer.encode(status))
|
||||||
|
|
||||||
@routes.post(config.URL_PREFIX + 'start')
|
@routes.post(config.URL_PREFIX + 'start')
|
||||||
async def start(request):
|
async def start(request):
|
||||||
post = await request.json()
|
post = await request.json()
|
||||||
ids = post.get('ids')
|
ids = post.get('ids')
|
||||||
log.info(f"Received request to start pending downloads for ids: {ids}")
|
log.info(f"Received request to start pending downloads for ids: {ids}")
|
||||||
status = await dqueue.start_pending(ids)
|
status = await dqueue.start_pending(ids)
|
||||||
return web.Response(text=serializer.encode(status))
|
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')
|
@routes.get(config.URL_PREFIX + 'history')
|
||||||
async def history(request):
|
async def history(request):
|
||||||
history = { 'done': [], 'queue': [], 'pending': []}
|
history = { 'done': [], 'queue': [], 'pending': []}
|
||||||
|
|
|
||||||
98
app/ytdl.py
98
app/ytdl.py
|
|
@ -24,11 +24,14 @@ class DownloadQueueNotifier:
|
||||||
async def completed(self, dl):
|
async def completed(self, dl):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
async def canceled(self, id):
|
async def canceled(self, id):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
async def cleared(self, id):
|
async def cleared(self, id):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def renamed(self, dl):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
class DownloadInfo:
|
class DownloadInfo:
|
||||||
def __init__(self, id, title, url, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit):
|
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)
|
await self.notifier.canceled(id)
|
||||||
return {'status': 'ok'}
|
return {'status': 'ok'}
|
||||||
|
|
||||||
async def clear(self, ids):
|
async def clear(self, ids):
|
||||||
for id in ids:
|
for id in ids:
|
||||||
if not self.done.exists(id):
|
if not self.done.exists(id):
|
||||||
log.warn(f'requested delete for non-existent download {id}')
|
log.warn(f'requested delete for non-existent download {id}')
|
||||||
continue
|
continue
|
||||||
if self.config.DELETE_FILE_ON_TRASHCAN:
|
if self.config.DELETE_FILE_ON_TRASHCAN:
|
||||||
dl = self.done.get(id)
|
dl = self.done.get(id)
|
||||||
try:
|
try:
|
||||||
|
|
@ -455,10 +458,71 @@ class DownloadQueue:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warn(f'deleting file for download {id} failed with error message {e!r}')
|
log.warn(f'deleting file for download {id} failed with error message {e!r}')
|
||||||
self.done.delete(id)
|
self.done.delete(id)
|
||||||
await self.notifier.cleared(id)
|
await self.notifier.cleared(id)
|
||||||
return {'status': 'ok'}
|
return {'status': 'ok'}
|
||||||
|
|
||||||
def get(self):
|
async def rename(self, id, new_name):
|
||||||
return (list((k, v.info) for k, v in self.queue.items()) +
|
log.info(f"Rename requested for download {id} -> {new_name!r}")
|
||||||
list((k, v.info) for k, v in self.pending.items()),
|
if not isinstance(new_name, str):
|
||||||
list((k, v.info) for k, v in self.done.items()))
|
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="row mb-4">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="input-group input-group-lg shadow-sm">
|
<div class="input-group input-group-lg shadow-sm">
|
||||||
<input type="text"
|
<input type="text"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
class="form-control form-control-lg"
|
class="form-control form-control-lg"
|
||||||
placeholder="Enter video or playlist URL"
|
placeholder="Enter video or playlist URL"
|
||||||
name="addUrl"
|
name="addUrl"
|
||||||
[(ngModel)]="addUrl"
|
[(ngModel)]="addUrl"
|
||||||
[disabled]="addInProgress || downloads.loading">
|
[disabled]="addInProgress || downloads.loading">
|
||||||
<button class="btn btn-primary btn-lg px-4"
|
<button class="btn btn-primary btn-lg px-4"
|
||||||
type="submit"
|
type="submit"
|
||||||
(click)="addDownload()"
|
(click)="addDownload()"
|
||||||
[disabled]="addInProgress || downloads.loading">
|
[disabled]="addInProgress || downloads.loading">
|
||||||
<span class="spinner-border spinner-border-sm" role="status" id="add-spinner" *ngIf="addInProgress"></span>
|
<span class="spinner-border spinner-border-sm" role="status" id="add-spinner" *ngIf="addInProgress"></span>
|
||||||
{{ addInProgress ? "Adding..." : "Download" }}
|
{{ addInProgress ? "Adding..." : "Download" }}
|
||||||
|
|
@ -97,10 +97,10 @@
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Quality</span>
|
<span class="input-group-text">Quality</span>
|
||||||
<select class="form-select"
|
<select class="form-select"
|
||||||
name="quality"
|
name="quality"
|
||||||
[(ngModel)]="quality"
|
[(ngModel)]="quality"
|
||||||
(change)="qualityChanged()"
|
(change)="qualityChanged()"
|
||||||
[disabled]="addInProgress || downloads.loading">
|
[disabled]="addInProgress || downloads.loading">
|
||||||
<option *ngFor="let q of qualities" [ngValue]="q.id">{{ q.text }}</option>
|
<option *ngFor="let q of qualities" [ngValue]="q.id">{{ q.text }}</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -109,18 +109,18 @@
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Format</span>
|
<span class="input-group-text">Format</span>
|
||||||
<select class="form-select"
|
<select class="form-select"
|
||||||
name="format"
|
name="format"
|
||||||
[(ngModel)]="format"
|
[(ngModel)]="format"
|
||||||
(change)="formatChanged()"
|
(change)="formatChanged()"
|
||||||
[disabled]="addInProgress || downloads.loading">
|
[disabled]="addInProgress || downloads.loading">
|
||||||
<option *ngFor="let f of formats" [ngValue]="f.id">{{ f.text }}</option>
|
<option *ngFor="let f of formats" [ngValue]="f.id">{{ f.text }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="btn btn-outline-secondary w-100 h-100"
|
class="btn btn-outline-secondary w-100 h-100"
|
||||||
(click)="toggleAdvanced()">
|
(click)="toggleAdvanced()">
|
||||||
Advanced Options
|
Advanced Options
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -137,10 +137,10 @@
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Auto Start</span>
|
<span class="input-group-text">Auto Start</span>
|
||||||
<select class="form-select"
|
<select class="form-select"
|
||||||
name="autoStart"
|
name="autoStart"
|
||||||
[(ngModel)]="autoStart"
|
[(ngModel)]="autoStart"
|
||||||
(change)="autoStartChanged()"
|
(change)="autoStartChanged()"
|
||||||
[disabled]="addInProgress || downloads.loading"
|
[disabled]="addInProgress || downloads.loading"
|
||||||
ngbTooltip="Automatically start downloads when added">
|
ngbTooltip="Automatically start downloads when added">
|
||||||
<option [ngValue]="true">Yes</option>
|
<option [ngValue]="true">Yes</option>
|
||||||
|
|
@ -151,12 +151,12 @@
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Download Folder</span>
|
<span class="input-group-text">Download Folder</span>
|
||||||
<ng-select [items]="customDirs$ | async"
|
<ng-select [items]="customDirs$ | async"
|
||||||
placeholder="Default"
|
placeholder="Default"
|
||||||
[addTag]="allowCustomDir.bind(this)"
|
[addTag]="allowCustomDir.bind(this)"
|
||||||
addTagText="Create directory"
|
addTagText="Create directory"
|
||||||
bindLabel="folder"
|
bindLabel="folder"
|
||||||
[(ngModel)]="folder"
|
[(ngModel)]="folder"
|
||||||
[disabled]="addInProgress || downloads.loading"
|
[disabled]="addInProgress || downloads.loading"
|
||||||
[virtualScroll]="true"
|
[virtualScroll]="true"
|
||||||
[clearable]="true"
|
[clearable]="true"
|
||||||
|
|
@ -170,11 +170,11 @@
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Custom Name Prefix</span>
|
<span class="input-group-text">Custom Name Prefix</span>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="Default"
|
placeholder="Default"
|
||||||
name="customNamePrefix"
|
name="customNamePrefix"
|
||||||
[(ngModel)]="customNamePrefix"
|
[(ngModel)]="customNamePrefix"
|
||||||
[disabled]="addInProgress || downloads.loading"
|
[disabled]="addInProgress || downloads.loading"
|
||||||
ngbTooltip="Add a prefix to downloaded filenames">
|
ngbTooltip="Add a prefix to downloaded filenames">
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -182,24 +182,24 @@
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Items Limit</span>
|
<span class="input-group-text">Items Limit</span>
|
||||||
<input type="number"
|
<input type="number"
|
||||||
min="0"
|
min="0"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="Default"
|
placeholder="Default"
|
||||||
name="playlistItemLimit"
|
name="playlistItemLimit"
|
||||||
(keydown)="isNumber($event)"
|
(keydown)="isNumber($event)"
|
||||||
[(ngModel)]="playlistItemLimit"
|
[(ngModel)]="playlistItemLimit"
|
||||||
[disabled]="addInProgress || downloads.loading"
|
[disabled]="addInProgress || downloads.loading"
|
||||||
ngbTooltip="Maximum number of items to download from a playlist (0 = no limit)">
|
ngbTooltip="Maximum number of items to download from a playlist (0 = no limit)">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
<input class="form-check-input"
|
<input class="form-check-input"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
role="switch"
|
role="switch"
|
||||||
name="playlistStrictMode"
|
name="playlistStrictMode"
|
||||||
[(ngModel)]="playlistStrictMode"
|
[(ngModel)]="playlistStrictMode"
|
||||||
[disabled]="addInProgress || downloads.loading"
|
[disabled]="addInProgress || downloads.loading"
|
||||||
ngbTooltip="Only download playlists when URL explicitly points to a playlist">
|
ngbTooltip="Only download playlists when URL explicitly points to a playlist">
|
||||||
<label class="form-check-label">Strict Playlist Mode</label>
|
<label class="form-check-label">Strict Playlist Mode</label>
|
||||||
|
|
@ -213,24 +213,24 @@
|
||||||
<hr class="my-3">
|
<hr class="my-3">
|
||||||
<div class="row g-2">
|
<div class="row g-2">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="btn btn-secondary w-100"
|
class="btn btn-secondary w-100"
|
||||||
(click)="openBatchImportModal()">
|
(click)="openBatchImportModal()">
|
||||||
<fa-icon [icon]="faFileImport" class="me-2"></fa-icon>
|
<fa-icon [icon]="faFileImport" class="me-2"></fa-icon>
|
||||||
Import URLs
|
Import URLs
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="btn btn-secondary w-100"
|
class="btn btn-secondary w-100"
|
||||||
(click)="exportBatchUrls('all')">
|
(click)="exportBatchUrls('all')">
|
||||||
<fa-icon [icon]="faFileExport" class="me-2"></fa-icon>
|
<fa-icon [icon]="faFileExport" class="me-2"></fa-icon>
|
||||||
Export URLs
|
Export URLs
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="btn btn-secondary w-100"
|
class="btn btn-secondary w-100"
|
||||||
(click)="copyBatchUrls('all')">
|
(click)="copyBatchUrls('all')">
|
||||||
<fa-icon [icon]="faCopy" class="me-2"></fa-icon>
|
<fa-icon [icon]="faCopy" class="me-2"></fa-icon>
|
||||||
Copy URLs
|
Copy URLs
|
||||||
|
|
@ -245,7 +245,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Batch Import Modal -->
|
<!-- Batch Import Modal -->
|
||||||
<div class="modal fade" tabindex="-1" role="dialog" [ngClass]="{'show': batchImportModalOpen}" [ngStyle]="{'display': batchImportModalOpen ? 'block' : 'none'}">
|
<div class="modal fade" tabindex="-1" role="dialog" [ngClass]="{'show': batchImportModalOpen}" [ngStyle]="{'display': batchImportModalOpen ? 'block' : 'none'}">
|
||||||
<div class="modal-dialog" role="document">
|
<div class="modal-dialog" role="document">
|
||||||
|
|
@ -273,7 +273,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div *ngIf="downloads.loading" class="alert alert-info" role="alert">
|
<div *ngIf="downloads.loading" class="alert alert-info" role="alert">
|
||||||
Connecting to server...
|
Connecting to server...
|
||||||
|
|
@ -346,17 +346,49 @@
|
||||||
<td>
|
<td>
|
||||||
<app-slave-checkbox [id]="download.key" [master]="doneMasterCheckbox" [checkable]="download.value"></app-slave-checkbox>
|
<app-slave-checkbox [id]="download.key" [master]="doneMasterCheckbox" [checkable]="download.value"></app-slave-checkbox>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td [ngClass]="{'rename-container': renameState[download.key]?.editing}">
|
||||||
<div style="display: inline-block; width: 1.5rem;">
|
<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 == '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>
|
<ng-container *ngIf="download.value.status === 'finished' && renameState[download.key]?.editing; else viewDownloadTitle">
|
||||||
<ng-template #noDownloadLink>
|
<div class="rename-inline">
|
||||||
{{download.value.title}}
|
<input class="form-control form-control-sm rename-input"
|
||||||
<span *ngIf="download.value.msg"><br>{{download.value.msg}}</span>
|
[disabled]="renameState[download.key]?.submitting"
|
||||||
<span *ngIf="download.value.error"><br>Error: {{download.value.error}}</span>
|
[(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>
|
</ng-template>
|
||||||
|
<div *ngIf="renameState[download.key]?.error" class="text-danger small mt-2">
|
||||||
|
{{ renameState[download.key].error }}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span *ngIf="download.value.size">{{ download.value.size | fileSize }}</span>
|
<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>
|
<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 *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>
|
<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>
|
<button type="button" class="btn btn-link" (click)="delDownload('done', download.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -209,3 +209,26 @@ main
|
||||||
|
|
||||||
span
|
span
|
||||||
white-space: nowrap
|
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 { Component, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { faTrashAlt, faCheckCircle, faTimesCircle, IconDefinition } from '@fortawesome/free-regular-svg-icons';
|
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 { faGithub } from '@fortawesome/free-brands-svg-icons';
|
||||||
import { CookieService } from 'ngx-cookie-service';
|
import { CookieService } from 'ngx-cookie-service';
|
||||||
import { map, Observable, of, distinctUntilChanged } from 'rxjs';
|
import { map, Observable, of, distinctUntilChanged } from 'rxjs';
|
||||||
|
|
@ -12,6 +12,13 @@ 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";
|
||||||
|
|
||||||
|
interface RenameState {
|
||||||
|
editing: boolean;
|
||||||
|
value: string;
|
||||||
|
error?: string;
|
||||||
|
submitting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
templateUrl: './app.component.html',
|
templateUrl: './app.component.html',
|
||||||
|
|
@ -50,6 +57,7 @@ export class AppComponent implements AfterViewInit {
|
||||||
completedDownloads = 0;
|
completedDownloads = 0;
|
||||||
failedDownloads = 0;
|
failedDownloads = 0;
|
||||||
totalSpeed = 0;
|
totalSpeed = 0;
|
||||||
|
renameState: Record<string, RenameState> = {};
|
||||||
|
|
||||||
@ViewChild('queueMasterCheckbox') queueMasterCheckbox: MasterCheckboxComponent;
|
@ViewChild('queueMasterCheckbox') queueMasterCheckbox: MasterCheckboxComponent;
|
||||||
@ViewChild('queueDelSelected') queueDelSelected: ElementRef;
|
@ViewChild('queueDelSelected') queueDelSelected: ElementRef;
|
||||||
|
|
@ -77,6 +85,7 @@ export class AppComponent implements AfterViewInit {
|
||||||
faGithub = faGithub;
|
faGithub = faGithub;
|
||||||
faClock = faClock;
|
faClock = faClock;
|
||||||
faTachometerAlt = faTachometerAlt;
|
faTachometerAlt = faTachometerAlt;
|
||||||
|
faPen = faPen;
|
||||||
|
|
||||||
constructor(public downloads: DownloadsService, private cookieService: CookieService, private http: HttpClient) {
|
constructor(public downloads: DownloadsService, private cookieService: CookieService, private http: HttpClient) {
|
||||||
this.format = cookieService.get('metube_format') || 'any';
|
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) {
|
buildDownloadLink(download: Download) {
|
||||||
let baseDir = this.downloads.configuration["PUBLIC_HOST_URL"];
|
let baseDir = this.downloads.configuration["PUBLIC_HOST_URL"];
|
||||||
if (download.quality == 'audio' || download.filename.endsWith('.mp3')) {
|
if (download.quality == 'audio' || download.filename.endsWith('.mp3')) {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { MeTubeSocket } from './metube-socket';
|
||||||
export interface Status {
|
export interface Status {
|
||||||
status: string;
|
status: string;
|
||||||
msg?: string;
|
msg?: string;
|
||||||
|
filename?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Download {
|
export interface Download {
|
||||||
|
|
@ -87,6 +88,20 @@ export class DownloadsService {
|
||||||
this.done.delete(data);
|
this.done.delete(data);
|
||||||
this.doneChanged.next(null);
|
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) => {
|
socket.fromEvent('configuration').subscribe((strdata: string) => {
|
||||||
let data = JSON.parse(strdata);
|
let data = JSON.parse(strdata);
|
||||||
console.debug("got configuration:", data);
|
console.debug("got configuration:", data);
|
||||||
|
|
@ -125,6 +140,12 @@ export class DownloadsService {
|
||||||
return this.http.post('delete', {where: where, ids: ids});
|
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) {
|
public startByFilter(where: string, filter: (dl: Download) => boolean) {
|
||||||
let ids: string[] = [];
|
let ids: string[] = [];
|
||||||
this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) });
|
this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) });
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue