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()
|
||||
format = post.get('format')
|
||||
folder = post.get('folder')
|
||||
custom_name_prefix = post.get('custom_name_prefix')
|
||||
playlist_strict_mode = post.get('playlist_strict_mode')
|
||||
playlist_item_limit = post.get('playlist_item_limit')
|
||||
auto_start = post.get('auto_start')
|
||||
|
||||
if custom_name_prefix is None:
|
||||
custom_name_prefix = ''
|
||||
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:
|
||||
custom_name_prefix = post.get('custom_name_prefix')
|
||||
custom_name = post.get('custom_name')
|
||||
playlist_strict_mode = post.get('playlist_strict_mode')
|
||||
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 is not None and not isinstance(custom_name, str):
|
||||
log.error("Bad request: custom_name must be a string")
|
||||
return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'invalid custom name'}))
|
||||
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 = 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))
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'delete')
|
||||
|
|
|
|||
146
app/ytdl.py
146
app/ytdl.py
|
|
@ -1,9 +1,10 @@
|
|||
import os
|
||||
import yt_dlp
|
||||
from collections import OrderedDict
|
||||
import shelve
|
||||
import time
|
||||
import asyncio
|
||||
import os
|
||||
import glob
|
||||
import yt_dlp
|
||||
from collections import OrderedDict
|
||||
import shelve
|
||||
import time
|
||||
import asyncio
|
||||
import multiprocessing
|
||||
import logging
|
||||
import re
|
||||
|
|
@ -33,20 +34,21 @@ class DownloadQueueNotifier:
|
|||
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}'
|
||||
self.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}'
|
||||
self.url = url
|
||||
self.quality = quality
|
||||
self.format = format
|
||||
self.folder = folder
|
||||
self.custom_name_prefix = custom_name_prefix
|
||||
self.msg = self.percent = self.speed = self.eta = None
|
||||
self.status = "pending"
|
||||
self.size = None
|
||||
self.timestamp = time.time_ns()
|
||||
self.error = error
|
||||
class DownloadInfo:
|
||||
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.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}'
|
||||
self.url = url
|
||||
self.quality = quality
|
||||
self.format = format
|
||||
self.folder = folder
|
||||
self.custom_name_prefix = custom_name_prefix
|
||||
self.custom_name = custom_name
|
||||
self.msg = self.percent = self.speed = self.eta = None
|
||||
self.status = "pending"
|
||||
self.size = None
|
||||
self.timestamp = time.time_ns()
|
||||
self.error = error
|
||||
self.entry = entry
|
||||
self.playlist_item_limit = playlist_item_limit
|
||||
|
||||
|
|
@ -332,16 +334,24 @@ class DownloadQueue:
|
|||
dldirectory = base_directory
|
||||
return dldirectory, None
|
||||
|
||||
async def __add_download(self, dl, auto_start):
|
||||
dldirectory, error_message = self.__calc_download_path(dl.quality, dl.format, dl.folder)
|
||||
if error_message is not None:
|
||||
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}'
|
||||
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
|
||||
async def __add_download(self, dl, auto_start):
|
||||
dldirectory, error_message = self.__calc_download_path(dl.quality, dl.format, dl.folder)
|
||||
if error_message is not None:
|
||||
return error_message
|
||||
custom_name = getattr(dl, 'custom_name', '') or ''
|
||||
if custom_name:
|
||||
conflict = self.__check_custom_name_conflict(dldirectory, custom_name)
|
||||
if conflict is not None:
|
||||
log.info(f'Custom name conflict for download {dl.url} at {dldirectory} name "{custom_name}"')
|
||||
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():
|
||||
if property.startswith("playlist"):
|
||||
output = output.replace(f"%({property})s", str(value))
|
||||
|
|
@ -356,14 +366,24 @@ class DownloadQueue:
|
|||
asyncio.create_task(self.__start_download(download))
|
||||
else:
|
||||
self.pending.put(download)
|
||||
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):
|
||||
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":
|
||||
await self.notifier.added(dl)
|
||||
|
||||
def __check_custom_name_conflict(self, directory, custom_name):
|
||||
base_path = os.path.join(directory, custom_name)
|
||||
if os.path.exists(base_path):
|
||||
return {'status': 'error', 'msg': 'custom filename already exists'}
|
||||
pattern = os.path.join(directory, f'{custom_name}.*')
|
||||
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')
|
||||
error = f"Live stream is scheduled to start at {dt_ts}"
|
||||
else:
|
||||
|
|
@ -374,13 +394,15 @@ class DownloadQueue:
|
|||
|
||||
if etype.startswith('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)
|
||||
elif etype == 'playlist':
|
||||
log.debug('Processing as a playlist')
|
||||
entries = entry['entries']
|
||||
log.info(f'playlist detected with {len(entries)} entries')
|
||||
playlist_index_digits = len(str(len(entries)))
|
||||
results = []
|
||||
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':
|
||||
if custom_name:
|
||||
return {'status': 'error', 'msg': 'custom name is only supported for single downloads'}
|
||||
log.debug('Processing as a playlist')
|
||||
entries = entry['entries']
|
||||
log.info(f'playlist detected with {len(entries)} entries')
|
||||
playlist_index_digits = len(str(len(entries)))
|
||||
results = []
|
||||
if playlist_item_limit > 0:
|
||||
log.info(f'Playlist item limit is set. Processing only first {playlist_item_limit} entries')
|
||||
entries = entries[:playlist_item_limit]
|
||||
|
|
@ -395,28 +417,30 @@ class DownloadQueue:
|
|||
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': 'ok'}
|
||||
elif etype == 'video' or (etype.startswith('url') and 'id' in entry and 'title' in entry):
|
||||
log.debug('Processing as a video')
|
||||
key = entry.get('webpage_url') or entry['url']
|
||||
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)
|
||||
await self.__add_download(dl, auto_start)
|
||||
return {'status': 'ok'}
|
||||
return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'}
|
||||
|
||||
async def add(self, url, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start=True, already=None):
|
||||
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
|
||||
if url in already:
|
||||
log.info('recursion detected, skipping')
|
||||
return {'status': 'ok'}
|
||||
elif etype == 'video' or (etype.startswith('url') and 'id' in entry and 'title' in entry):
|
||||
log.debug('Processing as a video')
|
||||
key = entry.get('webpage_url') or entry['url']
|
||||
if not self.queue.exists(key):
|
||||
dl = DownloadInfo(entry['id'], entry.get('title') or entry['id'], key, quality, format, folder, custom_name_prefix, custom_name, error, entry, playlist_item_limit)
|
||||
result = await self.__add_download(dl, auto_start)
|
||||
if isinstance(result, dict) and result.get('status') == 'error':
|
||||
return result
|
||||
return {'status': 'ok'}
|
||||
return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'}
|
||||
|
||||
async def add(self, url, quality, format, folder, custom_name_prefix, custom_name, playlist_strict_mode, playlist_item_limit, auto_start=True, already=None):
|
||||
log.info(f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} {custom_name=} {playlist_strict_mode=} {playlist_item_limit=} {auto_start=}')
|
||||
already = set() if already is None else already
|
||||
if url in already:
|
||||
log.info('recursion detected, skipping')
|
||||
return {'status': 'ok'}
|
||||
else:
|
||||
already.add(url)
|
||||
try:
|
||||
entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url, playlist_strict_mode)
|
||||
except yt_dlp.utils.YoutubeDLError as 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):
|
||||
for id in ids:
|
||||
|
|
|
|||
|
|
@ -91,6 +91,22 @@
|
|||
</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 -->
|
||||
<div class="row mb-3 g-3">
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export class AppComponent implements AfterViewInit {
|
|||
format: string;
|
||||
folder: string;
|
||||
customNamePrefix: string;
|
||||
customName: string = '';
|
||||
autoStart: boolean;
|
||||
playlistStrictMode: boolean;
|
||||
playlistItemLimit: number;
|
||||
|
|
@ -270,14 +271,31 @@ export class AppComponent implements AfterViewInit {
|
|||
playlistStrictMode = playlistStrictMode ?? this.playlistStrictMode
|
||||
playlistItemLimit = playlistItemLimit ?? this.playlistItemLimit
|
||||
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.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') {
|
||||
alert(`Error adding URL: ${status.msg}`);
|
||||
} else {
|
||||
this.addUrl = '';
|
||||
this.customName = '';
|
||||
}
|
||||
this.addInProgress = false;
|
||||
});
|
||||
|
|
@ -479,7 +497,7 @@ export class AppComponent implements AfterViewInit {
|
|||
this.batchImportStatus = `Importing URL ${index + 1} of ${urls.length}: ${url}`;
|
||||
// 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.playlistStrictMode, this.playlistItemLimit, this.autoStart)
|
||||
this.playlistStrictMode, this.playlistItemLimit, this.autoStart, undefined)
|
||||
.subscribe({
|
||||
next: (status: Status) => {
|
||||
if (status.status === 'error') {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export interface Download {
|
|||
speed: number;
|
||||
eta: number;
|
||||
filename: string;
|
||||
custom_name?: string;
|
||||
checked?: boolean;
|
||||
deleting?: boolean;
|
||||
}
|
||||
|
|
@ -125,8 +126,12 @@ export class DownloadsService {
|
|||
return of({status: 'error', msg: msg})
|
||||
}
|
||||
|
||||
public add(url: string, quality: string, format: string, folder: string, customNamePrefix: string, playlistStrictMode: boolean, playlistItemLimit: number, autoStart: boolean) {
|
||||
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(
|
||||
public add(url: string, quality: string, format: string, folder: string, customNamePrefix: string, playlistStrictMode: boolean, playlistItemLimit: number, autoStart: boolean, customName?: string) {
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue