predownload working
This commit is contained in:
parent
8fcdfb7ff7
commit
3d74f9b53b
5 changed files with 160 additions and 79 deletions
44
app/main.py
44
app/main.py
|
|
@ -223,23 +223,41 @@ async def add(request):
|
||||||
raise web.HTTPBadRequest()
|
raise web.HTTPBadRequest()
|
||||||
format = post.get('format')
|
format = post.get('format')
|
||||||
folder = post.get('folder')
|
folder = post.get('folder')
|
||||||
custom_name_prefix = post.get('custom_name_prefix')
|
custom_name_prefix = post.get('custom_name_prefix')
|
||||||
playlist_strict_mode = post.get('playlist_strict_mode')
|
custom_name = post.get('custom_name')
|
||||||
playlist_item_limit = post.get('playlist_item_limit')
|
playlist_strict_mode = post.get('playlist_strict_mode')
|
||||||
auto_start = post.get('auto_start')
|
playlist_item_limit = post.get('playlist_item_limit')
|
||||||
|
auto_start = post.get('auto_start')
|
||||||
if custom_name_prefix is None:
|
|
||||||
custom_name_prefix = ''
|
if custom_name_prefix is None:
|
||||||
if auto_start is None:
|
custom_name_prefix = ''
|
||||||
auto_start = True
|
if custom_name is not None and not isinstance(custom_name, str):
|
||||||
if playlist_strict_mode is None:
|
log.error("Bad request: custom_name must be a string")
|
||||||
playlist_strict_mode = config.DEFAULT_OPTION_PLAYLIST_STRICT_MODE
|
return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'invalid custom name'}))
|
||||||
if playlist_item_limit is None:
|
sanitized_custom_name = ''
|
||||||
|
if isinstance(custom_name, str):
|
||||||
|
sanitized_custom_name = custom_name.strip()
|
||||||
|
if sanitized_custom_name:
|
||||||
|
if any(sep in sanitized_custom_name for sep in ('/', '\\')) or '..' in sanitized_custom_name:
|
||||||
|
log.error("Bad request: custom_name contains invalid characters")
|
||||||
|
return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'invalid custom name'}))
|
||||||
|
dot_index = sanitized_custom_name.rfind('.')
|
||||||
|
if dot_index > 0:
|
||||||
|
sanitized_custom_name = sanitized_custom_name[:dot_index]
|
||||||
|
sanitized_custom_name = sanitized_custom_name.strip()
|
||||||
|
if not sanitized_custom_name:
|
||||||
|
log.error("Bad request: custom_name missing after sanitization")
|
||||||
|
return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'invalid custom name'}))
|
||||||
|
if auto_start is None:
|
||||||
|
auto_start = True
|
||||||
|
if playlist_strict_mode is None:
|
||||||
|
playlist_strict_mode = config.DEFAULT_OPTION_PLAYLIST_STRICT_MODE
|
||||||
|
if playlist_item_limit is None:
|
||||||
playlist_item_limit = config.DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT
|
playlist_item_limit = config.DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT
|
||||||
|
|
||||||
playlist_item_limit = int(playlist_item_limit)
|
playlist_item_limit = int(playlist_item_limit)
|
||||||
|
|
||||||
status = await dqueue.add(url, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start)
|
status = await dqueue.add(url, quality, format, folder, custom_name_prefix, sanitized_custom_name, playlist_strict_mode, playlist_item_limit, auto_start)
|
||||||
return web.Response(text=serializer.encode(status))
|
return web.Response(text=serializer.encode(status))
|
||||||
|
|
||||||
@routes.post(config.URL_PREFIX + 'delete')
|
@routes.post(config.URL_PREFIX + 'delete')
|
||||||
|
|
|
||||||
146
app/ytdl.py
146
app/ytdl.py
|
|
@ -1,9 +1,10 @@
|
||||||
import os
|
import os
|
||||||
import yt_dlp
|
import glob
|
||||||
from collections import OrderedDict
|
import yt_dlp
|
||||||
import shelve
|
from collections import OrderedDict
|
||||||
import time
|
import shelve
|
||||||
import asyncio
|
import time
|
||||||
|
import asyncio
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
@ -33,20 +34,21 @@ class DownloadQueueNotifier:
|
||||||
async def renamed(self, dl):
|
async def renamed(self, dl):
|
||||||
raise NotImplementedError
|
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, custom_name, error, entry, playlist_item_limit):
|
||||||
self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}'
|
self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}'
|
||||||
self.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}'
|
self.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}'
|
||||||
self.url = url
|
self.url = url
|
||||||
self.quality = quality
|
self.quality = quality
|
||||||
self.format = format
|
self.format = format
|
||||||
self.folder = folder
|
self.folder = folder
|
||||||
self.custom_name_prefix = custom_name_prefix
|
self.custom_name_prefix = custom_name_prefix
|
||||||
self.msg = self.percent = self.speed = self.eta = None
|
self.custom_name = custom_name
|
||||||
self.status = "pending"
|
self.msg = self.percent = self.speed = self.eta = None
|
||||||
self.size = None
|
self.status = "pending"
|
||||||
self.timestamp = time.time_ns()
|
self.size = None
|
||||||
self.error = error
|
self.timestamp = time.time_ns()
|
||||||
|
self.error = error
|
||||||
self.entry = entry
|
self.entry = entry
|
||||||
self.playlist_item_limit = playlist_item_limit
|
self.playlist_item_limit = playlist_item_limit
|
||||||
|
|
||||||
|
|
@ -332,16 +334,24 @@ class DownloadQueue:
|
||||||
dldirectory = base_directory
|
dldirectory = base_directory
|
||||||
return dldirectory, None
|
return dldirectory, None
|
||||||
|
|
||||||
async def __add_download(self, dl, auto_start):
|
async def __add_download(self, dl, auto_start):
|
||||||
dldirectory, error_message = self.__calc_download_path(dl.quality, dl.format, dl.folder)
|
dldirectory, error_message = self.__calc_download_path(dl.quality, dl.format, dl.folder)
|
||||||
if error_message is not None:
|
if error_message is not None:
|
||||||
return error_message
|
return error_message
|
||||||
output = self.config.OUTPUT_TEMPLATE if len(dl.custom_name_prefix) == 0 else f'{dl.custom_name_prefix}.{self.config.OUTPUT_TEMPLATE}'
|
custom_name = getattr(dl, 'custom_name', '') or ''
|
||||||
output_chapter = self.config.OUTPUT_TEMPLATE_CHAPTER
|
if custom_name:
|
||||||
entry = getattr(dl, 'entry', None)
|
conflict = self.__check_custom_name_conflict(dldirectory, custom_name)
|
||||||
if entry is not None and 'playlist' in entry and entry['playlist'] is not None:
|
if conflict is not None:
|
||||||
if len(self.config.OUTPUT_TEMPLATE_PLAYLIST):
|
log.info(f'Custom name conflict for download {dl.url} at {dldirectory} name "{custom_name}"')
|
||||||
output = self.config.OUTPUT_TEMPLATE_PLAYLIST
|
return conflict
|
||||||
|
output = f'{custom_name}.%(ext)s'
|
||||||
|
else:
|
||||||
|
output = self.config.OUTPUT_TEMPLATE if len(dl.custom_name_prefix) == 0 else f'{dl.custom_name_prefix}.{self.config.OUTPUT_TEMPLATE}'
|
||||||
|
output_chapter = self.config.OUTPUT_TEMPLATE_CHAPTER
|
||||||
|
entry = getattr(dl, 'entry', None)
|
||||||
|
if entry is not None and 'playlist' in entry and entry['playlist'] is not None:
|
||||||
|
if len(self.config.OUTPUT_TEMPLATE_PLAYLIST):
|
||||||
|
output = self.config.OUTPUT_TEMPLATE_PLAYLIST
|
||||||
for property, value in entry.items():
|
for property, value in entry.items():
|
||||||
if property.startswith("playlist"):
|
if property.startswith("playlist"):
|
||||||
output = output.replace(f"%({property})s", str(value))
|
output = output.replace(f"%({property})s", str(value))
|
||||||
|
|
@ -356,14 +366,24 @@ class DownloadQueue:
|
||||||
asyncio.create_task(self.__start_download(download))
|
asyncio.create_task(self.__start_download(download))
|
||||||
else:
|
else:
|
||||||
self.pending.put(download)
|
self.pending.put(download)
|
||||||
await self.notifier.added(dl)
|
await self.notifier.added(dl)
|
||||||
|
|
||||||
async def __add_entry(self, entry, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start, already):
|
def __check_custom_name_conflict(self, directory, custom_name):
|
||||||
if not entry:
|
base_path = os.path.join(directory, custom_name)
|
||||||
return {'status': 'error', 'msg': "Invalid/empty data was given."}
|
if os.path.exists(base_path):
|
||||||
|
return {'status': 'error', 'msg': 'custom filename already exists'}
|
||||||
error = None
|
pattern = os.path.join(directory, f'{custom_name}.*')
|
||||||
if "live_status" in entry and "release_timestamp" in entry and entry.get("live_status") == "is_upcoming":
|
for candidate in glob.glob(pattern):
|
||||||
|
if os.path.isfile(candidate):
|
||||||
|
return {'status': 'error', 'msg': 'custom filename already exists'}
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def __add_entry(self, entry, quality, format, folder, custom_name_prefix, custom_name, playlist_strict_mode, playlist_item_limit, auto_start, already):
|
||||||
|
if not entry:
|
||||||
|
return {'status': 'error', 'msg': "Invalid/empty data was given."}
|
||||||
|
|
||||||
|
error = None
|
||||||
|
if "live_status" in entry and "release_timestamp" in entry and entry.get("live_status") == "is_upcoming":
|
||||||
dt_ts = datetime.fromtimestamp(entry.get("release_timestamp")).strftime('%Y-%m-%d %H:%M:%S %z')
|
dt_ts = datetime.fromtimestamp(entry.get("release_timestamp")).strftime('%Y-%m-%d %H:%M:%S %z')
|
||||||
error = f"Live stream is scheduled to start at {dt_ts}"
|
error = f"Live stream is scheduled to start at {dt_ts}"
|
||||||
else:
|
else:
|
||||||
|
|
@ -374,13 +394,15 @@ class DownloadQueue:
|
||||||
|
|
||||||
if etype.startswith('url'):
|
if etype.startswith('url'):
|
||||||
log.debug('Processing as an url')
|
log.debug('Processing as an url')
|
||||||
return await self.add(entry['url'], quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start, already)
|
return await self.add(entry['url'], quality, format, folder, custom_name_prefix, custom_name, playlist_strict_mode, playlist_item_limit, auto_start, already)
|
||||||
elif etype == 'playlist':
|
elif etype == 'playlist':
|
||||||
log.debug('Processing as a playlist')
|
if custom_name:
|
||||||
entries = entry['entries']
|
return {'status': 'error', 'msg': 'custom name is only supported for single downloads'}
|
||||||
log.info(f'playlist detected with {len(entries)} entries')
|
log.debug('Processing as a playlist')
|
||||||
playlist_index_digits = len(str(len(entries)))
|
entries = entry['entries']
|
||||||
results = []
|
log.info(f'playlist detected with {len(entries)} entries')
|
||||||
|
playlist_index_digits = len(str(len(entries)))
|
||||||
|
results = []
|
||||||
if playlist_item_limit > 0:
|
if playlist_item_limit > 0:
|
||||||
log.info(f'Playlist item limit is set. Processing only first {playlist_item_limit} entries')
|
log.info(f'Playlist item limit is set. Processing only first {playlist_item_limit} entries')
|
||||||
entries = entries[:playlist_item_limit]
|
entries = entries[:playlist_item_limit]
|
||||||
|
|
@ -395,28 +417,30 @@ class DownloadQueue:
|
||||||
if any(res['status'] == 'error' for res in results):
|
if any(res['status'] == 'error' for res in results):
|
||||||
return {'status': 'error', 'msg': ', '.join(res['msg'] for res in results if res['status'] == 'error' and 'msg' in res)}
|
return {'status': 'error', 'msg': ', '.join(res['msg'] for res in results if res['status'] == 'error' and 'msg' in res)}
|
||||||
return {'status': 'ok'}
|
return {'status': 'ok'}
|
||||||
elif etype == 'video' or (etype.startswith('url') and 'id' in entry and 'title' in entry):
|
elif etype == 'video' or (etype.startswith('url') and 'id' in entry and 'title' in entry):
|
||||||
log.debug('Processing as a video')
|
log.debug('Processing as a video')
|
||||||
key = entry.get('webpage_url') or entry['url']
|
key = entry.get('webpage_url') or entry['url']
|
||||||
if not self.queue.exists(key):
|
if not self.queue.exists(key):
|
||||||
dl = DownloadInfo(entry['id'], entry.get('title') or entry['id'], key, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit)
|
dl = DownloadInfo(entry['id'], entry.get('title') or entry['id'], key, quality, format, folder, custom_name_prefix, custom_name, error, entry, playlist_item_limit)
|
||||||
await self.__add_download(dl, auto_start)
|
result = await self.__add_download(dl, auto_start)
|
||||||
return {'status': 'ok'}
|
if isinstance(result, dict) and result.get('status') == 'error':
|
||||||
return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'}
|
return result
|
||||||
|
return {'status': 'ok'}
|
||||||
async def add(self, url, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start=True, already=None):
|
return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'}
|
||||||
log.info(f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} {playlist_strict_mode=} {playlist_item_limit=} {auto_start=}')
|
|
||||||
already = set() if already is None else already
|
async def add(self, url, quality, format, folder, custom_name_prefix, custom_name, playlist_strict_mode, playlist_item_limit, auto_start=True, already=None):
|
||||||
if url in already:
|
log.info(f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} {custom_name=} {playlist_strict_mode=} {playlist_item_limit=} {auto_start=}')
|
||||||
log.info('recursion detected, skipping')
|
already = set() if already is None else already
|
||||||
return {'status': 'ok'}
|
if url in already:
|
||||||
|
log.info('recursion detected, skipping')
|
||||||
|
return {'status': 'ok'}
|
||||||
else:
|
else:
|
||||||
already.add(url)
|
already.add(url)
|
||||||
try:
|
try:
|
||||||
entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url, playlist_strict_mode)
|
entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url, playlist_strict_mode)
|
||||||
except yt_dlp.utils.YoutubeDLError as exc:
|
except yt_dlp.utils.YoutubeDLError as exc:
|
||||||
return {'status': 'error', 'msg': str(exc)}
|
return {'status': 'error', 'msg': str(exc)}
|
||||||
return await self.__add_entry(entry, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start, already)
|
return await self.__add_entry(entry, quality, format, folder, custom_name_prefix, custom_name, playlist_strict_mode, playlist_item_limit, auto_start, already)
|
||||||
|
|
||||||
async def start_pending(self, ids):
|
async def start_pending(self, ids):
|
||||||
for id in ids:
|
for id in ids:
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,22 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<label for="customName" class="form-label mb-1">Name (no extension) <span class="text-muted">(optional)</span></label>
|
||||||
|
<input type="text"
|
||||||
|
id="customName"
|
||||||
|
name="customName"
|
||||||
|
class="form-control"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="e.g. my-video"
|
||||||
|
[disabled]="addInProgress || downloads.loading"
|
||||||
|
[(ngModel)]="customName"
|
||||||
|
[ngModelOptions]="{standalone: true}">
|
||||||
|
<small class="form-text text-muted">The extension is added automatically; avoid dots or slashes.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Options Row -->
|
<!-- Options Row -->
|
||||||
<div class="row mb-3 g-3">
|
<div class="row mb-3 g-3">
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ export class AppComponent implements AfterViewInit {
|
||||||
format: string;
|
format: string;
|
||||||
folder: string;
|
folder: string;
|
||||||
customNamePrefix: string;
|
customNamePrefix: string;
|
||||||
|
customName: string = '';
|
||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
playlistStrictMode: boolean;
|
playlistStrictMode: boolean;
|
||||||
playlistItemLimit: number;
|
playlistItemLimit: number;
|
||||||
|
|
@ -270,14 +271,31 @@ export class AppComponent implements AfterViewInit {
|
||||||
playlistStrictMode = playlistStrictMode ?? this.playlistStrictMode
|
playlistStrictMode = playlistStrictMode ?? this.playlistStrictMode
|
||||||
playlistItemLimit = playlistItemLimit ?? this.playlistItemLimit
|
playlistItemLimit = playlistItemLimit ?? this.playlistItemLimit
|
||||||
autoStart = autoStart ?? this.autoStart
|
autoStart = autoStart ?? this.autoStart
|
||||||
|
let sanitizedCustomName = '';
|
||||||
|
if (this.customName && this.customName.trim()) {
|
||||||
|
const raw = this.customName.trim();
|
||||||
|
if (/[\\/]/.test(raw) || raw.includes('..')) {
|
||||||
|
alert('Name cannot include path separators or "..".');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sanitizedCustomName = this.getFilenameStem(raw);
|
||||||
|
if (!sanitizedCustomName) {
|
||||||
|
alert('Invalid name provided.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.customName = sanitizedCustomName;
|
||||||
|
} else {
|
||||||
|
this.customName = '';
|
||||||
|
}
|
||||||
|
|
||||||
console.debug('Downloading: url='+url+' quality='+quality+' format='+format+' folder='+folder+' customNamePrefix='+customNamePrefix+' playlistStrictMode='+playlistStrictMode+' playlistItemLimit='+playlistItemLimit+' autoStart='+autoStart);
|
console.debug('Downloading: url='+url+' quality='+quality+' format='+format+' folder='+folder+' customNamePrefix='+customNamePrefix+' customName='+sanitizedCustomName+' playlistStrictMode='+playlistStrictMode+' playlistItemLimit='+playlistItemLimit+' autoStart='+autoStart);
|
||||||
this.addInProgress = true;
|
this.addInProgress = true;
|
||||||
this.downloads.add(url, quality, format, folder, customNamePrefix, playlistStrictMode, playlistItemLimit, autoStart).subscribe((status: Status) => {
|
this.downloads.add(url, quality, format, folder, customNamePrefix, playlistStrictMode, playlistItemLimit, autoStart, sanitizedCustomName).subscribe((status: Status) => {
|
||||||
if (status.status === 'error') {
|
if (status.status === 'error') {
|
||||||
alert(`Error adding URL: ${status.msg}`);
|
alert(`Error adding URL: ${status.msg}`);
|
||||||
} else {
|
} else {
|
||||||
this.addUrl = '';
|
this.addUrl = '';
|
||||||
|
this.customName = '';
|
||||||
}
|
}
|
||||||
this.addInProgress = false;
|
this.addInProgress = false;
|
||||||
});
|
});
|
||||||
|
|
@ -479,7 +497,7 @@ export class AppComponent implements AfterViewInit {
|
||||||
this.batchImportStatus = `Importing URL ${index + 1} of ${urls.length}: ${url}`;
|
this.batchImportStatus = `Importing URL ${index + 1} of ${urls.length}: ${url}`;
|
||||||
// Now pass the selected quality, format, folder, etc. to the add() method
|
// Now pass the selected quality, format, folder, etc. to the add() method
|
||||||
this.downloads.add(url, this.quality, this.format, this.folder, this.customNamePrefix,
|
this.downloads.add(url, this.quality, this.format, this.folder, this.customNamePrefix,
|
||||||
this.playlistStrictMode, this.playlistItemLimit, this.autoStart)
|
this.playlistStrictMode, this.playlistItemLimit, this.autoStart, undefined)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (status: Status) => {
|
next: (status: Status) => {
|
||||||
if (status.status === 'error') {
|
if (status.status === 'error') {
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ export interface Download {
|
||||||
speed: number;
|
speed: number;
|
||||||
eta: number;
|
eta: number;
|
||||||
filename: string;
|
filename: string;
|
||||||
|
custom_name?: string;
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
deleting?: boolean;
|
deleting?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -125,8 +126,12 @@ export class DownloadsService {
|
||||||
return of({status: 'error', msg: msg})
|
return of({status: 'error', msg: msg})
|
||||||
}
|
}
|
||||||
|
|
||||||
public add(url: string, quality: string, format: string, folder: string, customNamePrefix: string, playlistStrictMode: boolean, playlistItemLimit: number, autoStart: boolean) {
|
public add(url: string, quality: string, format: string, folder: string, customNamePrefix: string, playlistStrictMode: boolean, playlistItemLimit: number, autoStart: boolean, customName?: string) {
|
||||||
return this.http.post<Status>('add', {url: url, quality: quality, format: format, folder: folder, custom_name_prefix: customNamePrefix, playlist_strict_mode: playlistStrictMode, playlist_item_limit: playlistItemLimit, auto_start: autoStart}).pipe(
|
const payload: Record<string, any> = {url: url, quality: quality, format: format, folder: folder, custom_name_prefix: customNamePrefix, playlist_strict_mode: playlistStrictMode, playlist_item_limit: playlistItemLimit, auto_start: autoStart};
|
||||||
|
if (customName) {
|
||||||
|
payload.custom_name = customName;
|
||||||
|
}
|
||||||
|
return this.http.post<Status>('add', payload).pipe(
|
||||||
catchError(this.handleHTTPError)
|
catchError(this.handleHTTPError)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue