Updated ui and backend

Added Sequential, limited and concurrent downloading and import export buttons
This commit is contained in:
rdiaz738 2025-03-01 17:43:48 -08:00
parent 11cb4a1d28
commit 8d70ed9d36
7 changed files with 282 additions and 29 deletions

View file

@ -43,7 +43,7 @@ class Config:
'KEYFILE': '',
'BASE_DIR': '',
'DEFAULT_THEME': 'auto',
'DOWNLOAD_MODE': 'concurrent', # Can be 'sequential', 'concurrent', or 'limited'
'DOWNLOAD_MODE': 'limited', # Can be 'sequential', 'concurrent', or 'limited'
'MAX_CONCURRENT_DOWNLOADS': 3, # Used if DOWNLOAD_MODE is 'limited'
}

View file

@ -122,14 +122,20 @@ class Download:
def cancel(self):
log.info(f"Cancelling download: {self.info.title}")
if self.running():
self.proc.kill()
try:
self.proc.kill()
except Exception as e:
log.error(f"Error killing process for {self.info.title}: {e}")
self.canceled = True
if self.status_queue is not None:
self.status_queue.put(None)
def close(self):
log.info(f"Closing download process for: {self.info.title}")
if self.started():
self.proc.close()
self.status_queue.put(None)
if self.status_queue is not None:
self.status_queue.put(None)
def running(self):
try:
@ -146,12 +152,14 @@ class Download:
if status is None:
log.info(f"Status update finished for: {self.info.title}")
return
if self.canceled:
log.info(f"Download {self.info.title} is canceled; stopping status updates.")
return
self.tmpfilename = status.get('tmpfilename')
if 'filename' in status:
fileName = status.get('filename')
self.info.filename = os.path.relpath(fileName, self.download_dir)
self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None
if self.info.format == 'thumbnail':
self.info.filename = re.sub(r'\.webm$', '.jpg', self.info.filename)
self.info.status = status['status']
@ -199,9 +207,10 @@ class PersistentQueue:
shelf[key] = value.info
def delete(self, key):
del self.dict[key]
with shelve.open(self.path, 'w') as shelf:
shelf.pop(key)
if key in self.dict:
del self.dict[key]
with shelve.open(self.path, 'w') as shelf:
shelf.pop(key, None)
def next(self):
k, v = next(iter(self.dict.items()))
@ -219,8 +228,10 @@ class DownloadQueue:
self.pending = PersistentQueue(self.config.STATE_DIR + '/pending')
self.active_downloads = set()
self.semaphore = None
if self.config.DOWNLOAD_MODE == 'limited':
# For sequential mode, use an asyncio lock to ensure one-at-a-time execution.
if self.config.DOWNLOAD_MODE == 'sequential':
self.seq_lock = asyncio.Lock()
elif self.config.DOWNLOAD_MODE == 'limited':
self.semaphore = asyncio.Semaphore(self.config.MAX_CONCURRENT_DOWNLOADS)
self.done.load()
@ -234,18 +245,19 @@ class DownloadQueue:
asyncio.create_task(self.__import_queue())
async def __start_download(self, download):
if download.canceled:
log.info(f"Download {download.info.title} was canceled, skipping start.")
return
if self.config.DOWNLOAD_MODE == 'sequential':
await self.__sequential_download(download)
async with self.seq_lock:
log.info("Starting sequential download.")
await download.start(self.notifier)
self._post_download_cleanup(download)
elif self.config.DOWNLOAD_MODE == 'limited' and self.semaphore is not None:
await self.__limited_concurrent_download(download)
else: # concurrent without limit
else:
await self.__concurrent_download(download)
async def __sequential_download(self, download):
log.info("Starting sequential download.")
await download.start(self.notifier)
self._post_download_cleanup(download)
async def __concurrent_download(self, download):
log.info("Starting concurrent download without limits.")
asyncio.create_task(self._run_download(download))
@ -256,6 +268,9 @@ class DownloadQueue:
await self._run_download(download)
async def _run_download(self, download):
if download.canceled:
log.info(f"Download {download.info.title} is canceled; skipping start.")
return
await download.start(self.notifier)
self._post_download_cleanup(download)
@ -332,7 +347,7 @@ class DownloadQueue:
log.info(f'Playlist item limit is set. Processing only first {playlist_item_limit} entries')
entries = entries[:playlist_item_limit]
for index, etr in enumerate(entries, start=1):
etr["_type"] = "video" # Prevents video to be treated as url and lose below properties during processing
etr["_type"] = "video"
etr["playlist"] = entry["id"]
etr["playlist_index"] = '{{0:0{0:d}d}}'.format(playlist_index_digits).format(index)
for property in ("id", "title", "uploader", "uploader_id"):
@ -342,10 +357,11 @@ 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:
elif etype == 'video' or (etype.startswith('url') and 'id' in entry and 'title' in entry):
log.debug('Processing as a video')
if not self.queue.exists(entry['id']):
dl = DownloadInfo(entry['id'], entry.get('title') or entry['id'], entry.get('webpage_url') or entry['url'], quality, format, folder, custom_name_prefix, error)
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)
dldirectory, error_message = self.__calc_download_path(quality, format, folder)
if error_message is not None:
return error_message
@ -354,21 +370,20 @@ class DownloadQueue:
if '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))
ytdl_options = dict(self.config.YTDL_OPTIONS)
if playlist_item_limit > 0:
log.info(f'playlist limit is set. Processing only first {playlist_item_limit} entries')
ytdl_options['playlistend'] = playlist_item_limit
if auto_start is True:
download = Download(dldirectory, self.config.TEMP_DIR, output, output_chapter, quality, format, ytdl_options, dl)
self.queue.put(download)
asyncio.create_task(self.__start_download(download))
if self.config.DOWNLOAD_MODE == 'sequential':
asyncio.create_task(self.__start_download(download))
else:
asyncio.create_task(self.__start_download(download))
else:
self.pending.put(Download(dldirectory, self.config.TEMP_DIR, output, output_chapter, quality, format, ytdl_options, dl))
await self.notifier.added(dl)

View file

@ -1,10 +1,10 @@
version: "3"
services:
metube:
image: ghcr.io/alexta69/metube
container_name: metube
image: notataco/metube:latest
container_name: metube-notataco
restart: unless-stopped
ports:
- "8081:8081"
volumes:
- c:/Users/Roger/Downloads:/downloads
- C:/Users/ilike/Downloads:/downloads

View file

@ -119,6 +119,49 @@
</div>
</div>
</form>
<div class="d-flex justify-content-end align-items-center my-3">
<!-- Batch Import Button (opens modal) -->
<button class="btn btn-secondary me-2" (click)="openBatchImportModal()">
Batch Import
</button>
<!-- Batch Export All -->
<button class="btn btn-secondary me-2" (click)="exportBatchUrls('all')">
Batch Export All
</button>
<!-- Batch Copy All -->
<button class="btn btn-secondary me-2" (click)="copyBatchUrls('all')">
Batch Copy All
</button>
</div>
<!-- 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">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Batch Import URLs</h5>
<button type="button" class="btn-close" aria-label="Close" (click)="closeBatchImportModal()"></button>
</div>
<div class="modal-body">
<textarea [(ngModel)]="batchImportText" class="form-control" rows="6"
placeholder="Paste one video URL per line"></textarea>
<div class="mt-2">
<small *ngIf="batchImportStatus">{{ batchImportStatus }}</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger me-auto" *ngIf="importInProgress" (click)="cancelBatchImport()">
Cancel Import
</button>
<button type="button" class="btn btn-secondary" (click)="closeBatchImportModal()">Close</button>
<button type="button" class="btn btn-primary" (click)="startBatchImport()" [disabled]="importInProgress">
Import URLs
</button>
</div>
</div>
</div>
</div>
<div *ngIf="downloads.loading" class="alert alert-info" role="alert">
Connecting to server...

View file

@ -64,4 +64,42 @@ td
.download-progressbar
width: 12rem
margin-left: auto
margin-left: auto
.batch-panel
margin-top: 15px
border: 1px solid #ccc
border-radius: 8px
padding: 15px
background-color: #fff
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1)
.batch-panel-header
border-bottom: 1px solid #eee
padding-bottom: 8px
margin-bottom: 15px
h4
font-size: 1.5rem
margin: 0
.batch-panel-body
textarea.form-control
resize: vertical
.batch-status
font-size: 0.9rem
color: #555
.d-flex.my-3
margin-top: 1rem
margin-bottom: 1rem
.modal.fade.show
background-color: rgba(0, 0, 0, 0.5)
.modal-header
border-bottom: 1px solid #eee
.modal-body
textarea.form-control
resize: vertical

View file

@ -30,6 +30,12 @@ export class AppComponent implements AfterViewInit {
themes: Theme[] = Themes;
activeTheme: Theme;
customDirs$: Observable<string[]>;
showBatchPanel: boolean = false;
batchImportModalOpen = false;
batchImportText = '';
batchImportStatus = '';
importInProgress = false;
cancelImportFlag = false;
@ViewChild('queueMasterCheckbox') queueMasterCheckbox: MasterCheckboxComponent;
@ViewChild('queueDelSelected') queueDelSelected: ElementRef;
@ -293,4 +299,133 @@ export class AppComponent implements AfterViewInit {
event.preventDefault();
}
}
// Toggle inline batch panel (if you want to use an inline panel for export; not used for import modal)
toggleBatchPanel(): void {
this.showBatchPanel = !this.showBatchPanel;
}
// Open the Batch Import modal
openBatchImportModal(): void {
this.batchImportModalOpen = true;
this.batchImportText = '';
this.batchImportStatus = '';
this.importInProgress = false;
this.cancelImportFlag = false;
}
// Close the Batch Import modal
closeBatchImportModal(): void {
this.batchImportModalOpen = false;
}
// Start importing URLs from the batch modal textarea
startBatchImport(): void {
const urls = this.batchImportText
.split(/\r?\n/)
.map(url => url.trim())
.filter(url => url.length > 0);
if (urls.length === 0) {
alert('No valid URLs found.');
return;
}
this.importInProgress = true;
this.cancelImportFlag = false;
this.batchImportStatus = `Starting to import ${urls.length} URLs...`;
let index = 0;
const delayBetween = 1000;
const processNext = () => {
if (this.cancelImportFlag) {
this.batchImportStatus = `Import cancelled after ${index} of ${urls.length} URLs.`;
this.importInProgress = false;
return;
}
if (index >= urls.length) {
this.batchImportStatus = `Finished importing ${urls.length} URLs.`;
this.importInProgress = false;
return;
}
const url = urls[index];
this.batchImportStatus = `Importing URL ${index + 1} of ${urls.length}: ${url}`;
this.downloads.addDownloadByUrl(url)
.then(() => {
index++;
setTimeout(processNext, delayBetween);
})
.catch(err => {
console.error(`Error importing URL ${url}:`, err);
index++;
setTimeout(processNext, delayBetween);
});
};
processNext();
}
// Cancel the batch import process
cancelBatchImport(): void {
if (this.importInProgress) {
this.cancelImportFlag = true;
this.batchImportStatus += ' Cancelling...';
}
}
// Export URLs based on filter: 'pending', 'completed', 'failed', or 'all'
exportBatchUrls(filter: 'pending' | 'completed' | 'failed' | 'all'): void {
let urls: string[];
if (filter === 'pending') {
urls = Array.from(this.downloads.queue.values()).map(dl => dl.url);
} else if (filter === 'completed') {
// Only finished downloads in the "done" Map
urls = Array.from(this.downloads.done.values()).filter(dl => dl.status === 'finished').map(dl => dl.url);
} else if (filter === 'failed') {
// Only error downloads from the "done" Map
urls = Array.from(this.downloads.done.values()).filter(dl => dl.status === 'error').map(dl => dl.url);
} else {
// All: pending + both finished and error in done
urls = [
...Array.from(this.downloads.queue.values()).map(dl => dl.url),
...Array.from(this.downloads.done.values()).map(dl => dl.url)
];
}
if (!urls.length) {
alert('No URLs found for the selected filter.');
return;
}
const content = urls.join('\n');
const blob = new Blob([content], { type: 'text/plain' });
const downloadUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = 'metube_urls.txt';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(downloadUrl);
}
// Copy URLs to clipboard based on filter: 'pending', 'completed', 'failed', or 'all'
copyBatchUrls(filter: 'pending' | 'completed' | 'failed' | 'all'): void {
let urls: string[];
if (filter === 'pending') {
urls = Array.from(this.downloads.queue.values()).map(dl => dl.url);
} else if (filter === 'completed') {
urls = Array.from(this.downloads.done.values()).filter(dl => dl.status === 'finished').map(dl => dl.url);
} else if (filter === 'failed') {
urls = Array.from(this.downloads.done.values()).filter(dl => dl.status === 'error').map(dl => dl.url);
} else {
urls = [
...Array.from(this.downloads.queue.values()).map(dl => dl.url),
...Array.from(this.downloads.done.values()).map(dl => dl.url)
];
}
if (!urls.length) {
alert('No URLs found for the selected filter.');
return;
}
const content = urls.join('\n');
navigator.clipboard.writeText(content)
.then(() => alert('URLs copied to clipboard.'))
.catch(() => alert('Failed to copy URLs.'));
}
}

View file

@ -129,4 +129,26 @@ export class DownloadsService {
this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) });
return this.delById(where, ids);
}
public addDownloadByUrl(url: string): Promise<any> {
const defaultQuality = 'best';
const defaultFormat = 'mp4';
const defaultFolder = '';
const defaultCustomNamePrefix = '';
const defaultPlaylistStrictMode = false;
const defaultPlaylistItemLimit = 0;
const defaultAutoStart = true;
return new Promise((resolve, reject) => {
this.add(url, defaultQuality, defaultFormat, defaultFolder, defaultCustomNamePrefix, defaultPlaylistStrictMode, defaultPlaylistItemLimit, defaultAutoStart)
.subscribe(
response => resolve(response),
error => reject(error)
);
});
}
public exportQueueUrls(): string[] {
return Array.from(this.queue.values()).map(download => download.url);
}
}