Added youtube import. Reworked Download Manager
Build and Publish / Build and Publish Docker Image (push) Successful in 3m52s

This commit is contained in:
Aleksandr Bogomiakov
2026-08-14 01:21:51 +01:00
parent 2b254f417f
commit 943191a0ff
16 changed files with 4167 additions and 79 deletions
+273 -28
View File
@@ -85,7 +85,7 @@
</div>
</template>
<!-- Torrent Import Modal -->
<!-- Download Manager Modal -->
<template x-if="$store.torrents.modal">
<div class="modal-overlay" @click.self="$store.torrents.close()">
<div class="modal-box torrent-modal">
@@ -107,25 +107,43 @@
</button>
<div class="torrent-client-status">
<span class="torrent-status-pill"
x-show="$store.torrents.sourceTab === 'torrents'"
:class="{ active: $store.torrents.activeCount() > 0 }"
x-text="$store.torrents.clientSummary()"></span>
<span class="torrent-status-pill"
x-show="$store.torrents.sourceTab === 'youtube'"
:class="{ active: $store.torrents.youtubeActiveCount() > 0 }"
x-text="$store.torrents.youtubeSummary()"></span>
<span class="torrent-status-pill torrent-agent-pill"
:class="{ active: $store.torrents.agentBusy() }">
<span class="torrent-agent-dot"></span>
<span x-text="$store.torrents.agentSummary()"></span>
</span>
<span class="torrent-status-pill"
x-show="$store.torrents.sourceTab === 'torrents'"
x-text="$store.torrents.sessions.length + ' ' + T.saved"></span>
<span class="torrent-status-pill"
x-show="$store.torrents.sourceTab === 'youtube'"
x-text="$store.torrents.youtubeJobs.length + ' ' + T.saved"></span>
<span class="torrent-status-pill"
x-show="$store.torrents.sourceTab === 'files'"
x-text="$store.torrents.localUploadHistory.length + ' ' + T.saved"></span>
</div>
</div>
<div class="torrent-tabs">
<div class="torrent-tabs download-source-tabs">
<button class="torrent-tab-btn"
:class="{ active: $store.torrents.activeTab === 'import' }"
@click="$store.torrents.showImportTab()">{{ t.player_import }}</button>
:class="{ active: $store.torrents.sourceTab === 'youtube' }"
@click="$store.torrents.showSourceTab('youtube')">{{ t.player_youtube }}</button>
<button class="torrent-tab-btn"
:class="{ active: $store.torrents.activeTab === 'uploads' }"
@click="$store.torrents.showUploadsTab()">
:class="{ active: $store.torrents.sourceTab === 'torrents' }"
@click="$store.torrents.showSourceTab('torrents')">{{ t.player_torrents }}</button>
<button class="torrent-tab-btn"
:class="{ active: $store.torrents.sourceTab === 'files' }"
@click="$store.torrents.showSourceTab('files')">{{ t.player_files }}</button>
<button class="torrent-tab-btn"
:class="{ active: $store.torrents.sourceTab === 'uploads' }"
@click="$store.torrents.showSourceTab('uploads')">
<span>{{ t.player_my_uploads }}</span>
<span class="torrent-tab-count"
x-show="$store.torrents.uploadPendingTotal + $store.torrents.uploadQueuedTotal > 0"
@@ -133,7 +151,173 @@
</button>
</div>
<template x-if="$store.torrents.activeTab === 'import'">
<div class="youtube-manager-panel" x-show="$store.torrents.sourceTab === 'youtube'">
<form class="youtube-download-form" @submit.prevent="$store.torrents.previewYoutubeUrl()">
<label for="youtube-download-url">{{ t.player_youtube_url }}</label>
<div class="youtube-download-form-row">
<input id="youtube-download-url"
type="text"
inputmode="url"
autocomplete="url"
x-model="$store.torrents.youtubeUrl"
@input="$store.torrents.clearYoutubePreview()"
placeholder="https://www.youtube.com/watch?v=...">
<button type="submit"
class="modal-btn modal-btn-primary"
:disabled="$store.torrents.youtubePreviewLoading || $store.torrents.youtubeSubmitting || !$store.torrents.youtubeUrl.trim()">
<span x-text="$store.torrents.youtubePreviewLoading ? T.youtubeParsing : T.youtubeParse"></span>
</button>
</div>
<p class="youtube-download-hint">{{ t.player_youtube_url_hint }}</p>
</form>
<template x-if="$store.torrents.youtubePreview">
<section class="youtube-preview-card">
<div class="youtube-preview-head">
<div>
<h4>{{ t.player_youtube_preview_title }}</h4>
<p>
<strong x-text="$store.torrents.youtubePreview.title"></strong>
<span> · </span>
<span x-text="$store.torrents.youtubePreview.items.length + ' ' + T.youtubeItems"></span>
</p>
</div>
<div class="youtube-preview-controls">
<button type="button" class="modal-btn modal-btn-ghost"
@click="$store.torrents.selectAllYoutubePreview()">{{ t.player_youtube_select_all }}</button>
<button type="button" class="modal-btn modal-btn-ghost"
@click="$store.torrents.clearYoutubePreviewSelection()">{{ t.player_youtube_clear_selection }}</button>
</div>
</div>
<div class="youtube-preview-list">
<template x-for="item in $store.torrents.youtubePreview.items" :key="item.source_id">
<label class="youtube-preview-row"
:class="{ selected: $store.torrents.youtubePreviewIsSelected(item.source_id) }">
<input type="checkbox"
:checked="$store.torrents.youtubePreviewIsSelected(item.source_id)"
@change="$store.torrents.toggleYoutubePreviewItem(item.source_id)">
<span class="youtube-item-index" x-text="String(item.playlist_index).padStart(2, '0')"></span>
<span class="youtube-preview-item-title" x-text="item.title"></span>
</label>
</template>
</div>
<div class="youtube-preview-footer">
<span x-text="$store.torrents.youtubePreviewSelectedCount() + ' ' + T.youtubeSelectedCount"></span>
<div>
<button type="button" class="modal-btn modal-btn-ghost"
@click="$store.torrents.clearYoutubePreview()">{{ t.player_cancel }}</button>
<button type="button" class="modal-btn modal-btn-primary"
@click="$store.torrents.startYoutubeDownload()"
:disabled="$store.torrents.youtubeSubmitting || $store.torrents.youtubePreviewSelectedCount() === 0">
{{ t.player_youtube_start_import }}
</button>
</div>
</div>
</section>
</template>
<div class="youtube-download-list-head">
<span>{{ t.player_youtube_downloads }}</span>
<button class="modal-btn modal-btn-ghost"
@click="$store.torrents.loadYoutubeJobs()"
:disabled="$store.torrents.youtubeLoading">{{ t.player_refresh }}</button>
</div>
<div class="youtube-download-list">
<template x-if="!$store.torrents.youtubeLoading && $store.torrents.youtubeJobs.length === 0">
<div class="empty-state youtube-empty-state">
<p>{{ t.player_no_youtube_downloads }}</p>
</div>
</template>
<template x-for="job in $store.torrents.youtubeJobs" :key="job.id">
<article class="youtube-job-card">
<div class="youtube-job-head">
<div class="youtube-job-heading">
<div class="youtube-job-title" x-text="job.title"></div>
<div class="youtube-job-meta" x-text="$store.torrents.youtubeJobMeta(job)"></div>
</div>
<span class="torrent-status-badge"
:class="$store.torrents.youtubeStatusClass(job.status)"
x-text="$store.torrents.youtubeStatusLabel(job.status)"></span>
</div>
<div class="youtube-job-progress">
<div class="torrent-session-progress">
<div class="torrent-session-progress-bar"
:style="'width:' + $store.torrents.youtubeJobProgress(job) + '%'">
</div>
</div>
<span x-text="$store.torrents.youtubeJobProgressText(job)"></span>
</div>
<p class="youtube-job-error" x-show="job.error" x-text="job.error"></p>
<div class="youtube-item-list">
<template x-for="item in job.items" :key="item.id">
<div class="youtube-item-row" :class="{ failed: $store.torrents.youtubeIsError(item.status) }">
<div class="youtube-item-head">
<span class="youtube-item-index" x-text="String(item.playlist_index).padStart(2, '0')"></span>
<div class="youtube-item-main">
<div class="youtube-item-title" x-text="item.title"></div>
<div class="youtube-item-meta" x-text="$store.torrents.youtubeItemMeta(item)"></div>
</div>
<span class="torrent-status-badge"
:class="$store.torrents.youtubeStatusClass(item.status)"
x-text="$store.torrents.youtubeStatusLabel(item.status)"></span>
</div>
<div class="youtube-item-download-progress" x-show="item.status === 'downloading'">
<div class="torrent-session-progress">
<div class="torrent-session-progress-bar"
:style="'width:' + Number(item.progress_percent || 0) + '%'">
</div>
</div>
<span x-text="$store.torrents.youtubeDownloadMeta(item)"></span>
</div>
<div class="youtube-step-list" aria-label="{{ t.player_download_steps }}">
<span class="youtube-step" :class="$store.torrents.youtubeStepClass(item, 1)">
<i></i><b>{{ t.player_downloading }}</b>
</span>
<span class="youtube-step" :class="$store.torrents.youtubeStepClass(item, 2)">
<i></i><b>FFmpeg</b>
</span>
<span class="youtube-step" :class="$store.torrents.youtubeStepClass(item, 3)">
<i></i><b>{{ t.player_ai_prefix }}</b>
</span>
<span class="youtube-step" :class="$store.torrents.youtubeStepClass(item, 4)">
<i></i><b x-text="$store.torrents.youtubeFinalStepLabel(item)"></b>
</span>
</div>
<p class="youtube-item-error"
x-show="item.error"
x-text="item.error"></p>
</div>
</template>
</div>
<div class="youtube-job-actions">
<button class="modal-btn modal-btn-ghost"
x-show="job.review_items > 0 || job.items.some(item => item.status === 'ai_failed')"
@click="$store.torrents.openYoutubeReviews()">{{ t.player_my_uploads }}</button>
<button class="modal-btn modal-btn-pause"
x-show="$store.torrents.youtubeJobCancellable(job.status)"
:disabled="$store.torrents.youtubeCancellingIds.has(job.id)"
@click="$store.torrents.cancelYoutubeJob(job.id)">{{ t.player_youtube_stop }}</button>
<button class="modal-btn modal-btn-pause"
x-show="job.status !== 'cancelled' && (job.items.some(item => item.status === 'failed') || (job.status === 'failed' && job.total_items === 0))"
@click="$store.torrents.retryYoutubeJob(job.id)">{{ t.player_retry_failed }}</button>
<button class="modal-btn modal-btn-danger"
x-show="$store.torrents.youtubeJobTerminal(job.status)"
@click="$store.torrents.removeYoutubeJob(job.id)">{{ t.player_remove_from_history }}</button>
</div>
</article>
</template>
</div>
</div>
<div class="download-torrent-panel" x-show="$store.torrents.sourceTab === 'torrents'">
<div class="torrent-manager-layout">
<aside class="torrent-manager-sidebar">
<div class="torrent-manager-title">
@@ -173,7 +357,7 @@
@click="$store.torrents.addNew()"
:disabled="$store.torrents.loading">
<span class="torrent-session-add-icon">+</span>
<span>{{ t.player_upload }}</span>
<span>{{ t.player_add_torrent }}</span>
</button>
</div>
</aside>
@@ -188,12 +372,6 @@
<template x-if="$store.torrents.isImporting()">
<div class="torrent-import-panel">
<div class="torrent-modal-grid">
<div>
<label for="local-file-input">{{ t.player_local_files }}</label>
<input id="local-file-input" type="file" multiple accept="audio/*,.mp3,.flac,.wav,.m4a,.ogg,.opus,.aac"
@change="$store.torrents.setLocalFiles($event.target.files)">
<div class="torrent-upload-summary" x-text="$store.torrents.localUploadSummary()"></div>
</div>
<div>
<label for="torrent-magnet-input">{{ t.player_magnet_link }}</label>
<input id="torrent-magnet-input" type="text"
@@ -206,17 +384,6 @@
@change="$store.torrents.file = $event.target.files[0] || null">
</div>
</div>
<div class="torrent-upload-progress"
x-show="$store.torrents.uploadProgress > 0 || ($store.torrents.localFiles.length > 0 && $store.torrents.loading)">
<div class="torrent-progress-head">
<span x-text="$store.torrents.uploadProgress >= 100 ? T.uploadComplete : T.uploadingFiles"></span>
<span x-text="$store.torrents.uploadProgressText"></span>
</div>
<div class="torrent-progress-track">
<div class="torrent-progress-bar"
:style="'width:' + $store.torrents.uploadProgress + '%'"></div>
</div>
</div>
<div class="torrent-actions">
<button class="modal-btn modal-btn-primary" @click="$store.torrents.preview()" :disabled="$store.torrents.loading">
{{ t.player_upload_content }}
@@ -282,7 +449,7 @@
<button class="modal-btn modal-btn-danger"
@click="$store.torrents.removeSession($store.torrents.previewData.id)"
:disabled="$store.torrents.loading">
{{ t.player_delete }}
{{ t.player_remove_from_history }}
</button>
</div>
</div>
@@ -341,9 +508,87 @@
</template>
</section>
</div>
</template>
</div>
<template x-if="$store.torrents.activeTab === 'uploads'">
<section class="file-upload-panel" x-show="$store.torrents.sourceTab === 'files'">
<input id="direct-audio-file-input"
class="file-upload-input"
type="file"
multiple
accept="audio/*,.mp3,.flac,.wav,.m4a,.ogg,.opus,.aac"
@change="$store.torrents.setLocalFiles($event.target.files); $event.target.value = ''">
<label class="file-drop-zone"
for="direct-audio-file-input"
:class="{ dragging: $store.torrents.localFilesDragging }"
@dragenter.prevent="$store.torrents.localFilesDragging = true"
@dragover.prevent="$store.torrents.localFilesDragging = true"
@dragleave.prevent="$store.torrents.leaveLocalFileDrop($event)"
@drop.prevent="$store.torrents.dropLocalFiles($event)">
<span class="file-drop-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M12 16V4"/><polyline points="7 9 12 4 17 9"/>
<path d="M5 14v4a2 2 0 002 2h10a2 2 0 002-2v-4"/>
</svg>
</span>
<strong>{{ t.player_drop_audio_title }}</strong>
<span>{{ t.player_drop_audio_hint }}</span>
<small>{{ t.player_drop_audio_formats }}</small>
</label>
<div class="file-upload-selection" x-show="$store.torrents.localFiles.length > 0">
<span x-text="$store.torrents.localUploadSummary()"></span>
<div>
<button type="button" class="modal-btn modal-btn-ghost"
@click="$store.torrents.clearLocalFiles()"
:disabled="$store.torrents.localFilesUploading">{{ t.player_cancel }}</button>
<button type="button" class="modal-btn modal-btn-primary"
@click="$store.torrents.uploadLocalFiles()"
:disabled="$store.torrents.localFilesUploading">{{ t.player_upload_selected_files }}</button>
</div>
</div>
<div class="torrent-upload-progress"
x-show="$store.torrents.localFilesUploading || $store.torrents.uploadProgress > 0">
<div class="torrent-progress-head">
<span x-text="$store.torrents.uploadProgress >= 100 ? T.uploadComplete : T.uploadingFiles"></span>
<span x-text="$store.torrents.uploadProgressText"></span>
</div>
<div class="torrent-progress-track">
<div class="torrent-progress-bar"
:style="'width:' + $store.torrents.uploadProgress + '%'"></div>
</div>
</div>
<div class="youtube-download-list-head file-upload-history-head">
<span>{{ t.player_upload_history }}</span>
<button class="modal-btn modal-btn-ghost"
@click="$store.torrents.loadLocalUploadHistory()"
:disabled="$store.torrents.localUploadHistoryLoading">{{ t.player_refresh }}</button>
</div>
<div class="file-upload-history">
<template x-if="!$store.torrents.localUploadHistoryLoading && $store.torrents.localUploadHistory.length === 0">
<div class="empty-state youtube-empty-state"><p>{{ t.player_no_file_uploads }}</p></div>
</template>
<template x-for="item in $store.torrents.localUploadHistory" :key="item.id">
<article class="file-upload-history-row">
<div class="file-upload-history-main">
<div class="file-upload-history-title" x-text="item.filename"></div>
<div class="file-upload-history-meta"
x-text="$store.torrents.bytes(item.size_bytes) + ' · ' + $store.torrents.formatUploadDate(item.created_at)"></div>
<p class="youtube-item-error" x-show="item.error" x-text="item.error"></p>
</div>
<span class="torrent-status-badge"
:class="$store.torrents.youtubeStatusClass(item.status)"
x-text="$store.torrents.youtubeStatusLabel(item.status)"></span>
<button class="modal-btn modal-btn-danger"
@click="$store.torrents.removeLocalUploadHistory(item.id)">{{ t.player_remove_from_history }}</button>
</article>
</template>
</div>
</section>
<template x-if="$store.torrents.sourceTab === 'uploads'">
<section class="upload-manager-panel">
<div class="upload-manager-head">
<div>
+454 -22
View File
@@ -136,6 +136,42 @@ const T = {
openTorrentFailed: "{{ t.player_open_torrent_failed }}",
deleteTorrentFailed: "{{ t.player_delete_torrent_failed }}",
loadAiQueueFailed: "{{ t.player_load_ai_queue_failed }}",
fileUploadLoadFailed: "{{ t.player_file_upload_load_failed }}",
removeFileUploadConfirm: "{{ t.player_remove_file_upload_confirm }}",
fileUploadHistoryRemoved: "{{ t.player_file_upload_history_removed }}",
fileUploadHistoryRemoveFailed: "{{ t.player_file_upload_history_remove_failed }}",
noSupportedAudioFiles: "{{ t.player_no_supported_audio_files }}",
youtubeQueued: "{{ t.player_youtube_queued }}",
youtubeResolving: "{{ t.player_youtube_resolving }}",
youtubePostprocessing: "{{ t.player_youtube_postprocessing }}",
youtubeAwaitingAi: "{{ t.player_youtube_awaiting_ai }}",
youtubeAiProcessing: "{{ t.player_youtube_ai_processing }}",
youtubeNeedsReview: "{{ t.player_youtube_needs_review }}",
youtubeCompleteWithErrors: "{{ t.player_youtube_complete_with_errors }}",
youtubeSkipped: "{{ t.player_youtube_skipped }}",
youtubeStarting: "{{ t.player_youtube_starting }}",
youtubeStarted: "{{ t.player_youtube_started }}",
youtubeLoadFailed: "{{ t.player_youtube_load_failed }}",
youtubeStartFailed: "{{ t.player_youtube_start_failed }}",
youtubeRetryFailed: "{{ t.player_youtube_retry_failed }}",
youtubeDeleteFailed: "{{ t.player_youtube_delete_failed }}",
youtubeDeleteConfirm: "{{ t.player_youtube_delete_confirm }}",
youtubeParse: "{{ t.player_youtube_parse }}",
youtubeParsing: "{{ t.player_youtube_parsing }}",
youtubePreviewFailed: "{{ t.player_youtube_preview_failed }}",
youtubeSelectAll: "{{ t.player_youtube_select_all }}",
youtubeClearSelection: "{{ t.player_youtube_clear_selection }}",
youtubeSelectedCount: "{{ t.player_youtube_selected_count }}",
youtubeCancelled: "{{ t.player_youtube_cancelled }}",
youtubeStopConfirm: "{{ t.player_youtube_stop_confirm }}",
youtubeStopping: "{{ t.player_youtube_stopping }}",
youtubeStopped: "{{ t.player_youtube_stopped }}",
youtubeStopFailed: "{{ t.player_youtube_stop_failed }}",
youtubeVideo: "{{ t.player_youtube_video }}",
youtubePlaylist: "{{ t.player_youtube_playlist }}",
youtubeItems: "{{ t.player_youtube_items }}",
youtubeErrors: "{{ t.player_youtube_errors }}",
chapters: "{{ t.player_chapters }}",
deletePlaylistConfirm: "{{ t.player_delete_playlist_confirm }}",
albums: "{{ t.player_albums }}",
eps: "{{ t.player_eps }}",
@@ -4568,8 +4604,21 @@ document.addEventListener('alpine:init', () => {
// -----------------------------------------------------------------------
Alpine.store('torrents', {
modal: false,
sourceTab: 'youtube',
youtubeUrl: '',
youtubePreview: null,
youtubePreviewSelected: new Set(),
youtubePreviewLoading: false,
youtubeJobs: [],
youtubeLoading: false,
youtubeSubmitting: false,
youtubeCancellingIds: new Set(),
file: null,
localFiles: [],
localFilesDragging: false,
localFilesUploading: false,
localUploadHistory: [],
localUploadHistoryLoading: false,
magnet: '',
sessions: [],
loadingSessions: false,
@@ -4590,7 +4639,6 @@ document.addEventListener('alpine:init', () => {
loadingAgentStatus: false,
uploadProgress: 0,
uploadProgressText: '',
activeTab: 'import',
uploadTracks: [],
uploadReleases: [],
uploadPending: [],
@@ -4626,9 +4674,12 @@ document.addEventListener('alpine:init', () => {
this.modal = true;
this.message = '';
this.error = false;
this.loadSessions();
if (this.sourceTab === 'youtube') this.loadYoutubeJobs();
else if (this.sourceTab === 'uploads') this.loadUploads();
else if (this.sourceTab === 'files') this.loadLocalUploadHistory();
else this.loadSessions();
if (this.sourceTab !== 'uploads') this.loadUploads({ silent: true });
this.loadAgentStatus();
if (this.activeTab === 'uploads') this.loadUploads();
this._startRefresh();
},
@@ -4648,16 +4699,324 @@ document.addEventListener('alpine:init', () => {
return this.workspaceMode === 'new';
},
showImportTab() {
this.activeTab = 'import';
showSourceTab(tab) {
this.sourceTab = ['youtube', 'torrents', 'files', 'uploads'].includes(tab) ? tab : 'youtube';
this._setMessage('');
if (this.sourceTab === 'youtube') this.loadYoutubeJobs();
else if (this.sourceTab === 'uploads') {
this._stopPoll();
this.loadUploads();
}
else if (this.sourceTab === 'files') {
this._stopPoll();
this.loadLocalUploadHistory();
}
else this.loadSessions();
},
showUploadsTab() {
this.activeTab = 'uploads';
this._stopPoll();
this._setMessage('');
this.loadUploads();
async loadYoutubeJobs({ silent = false } = {}) {
if (!silent) this.youtubeLoading = true;
try {
const res = await fetch('/api/player/youtube');
const data = await res.json().catch(() => null);
if (!res.ok) throw new Error(data?.error || T.youtubeLoadFailed);
this.youtubeJobs = Array.isArray(data) ? data : [];
} catch (err) {
if (!silent) this._setMessage(err.message || T.youtubeLoadFailed, true);
} finally {
if (!silent) this.youtubeLoading = false;
}
},
clearYoutubePreview() {
this.youtubePreview = null;
this.youtubePreviewSelected = new Set();
},
async previewYoutubeUrl() {
const url = String(this.youtubeUrl || '').trim();
if (!url || this.youtubePreviewLoading || this.youtubeSubmitting) return;
this.youtubePreviewLoading = true;
this.clearYoutubePreview();
this._setMessage(T.youtubeParsing);
try {
const res = await fetch('/api/player/youtube/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
const data = await res.json().catch(() => null);
if (!res.ok) throw new Error(data?.error || T.youtubePreviewFailed);
const items = Array.isArray(data?.items) ? data.items : [];
if (!items.length) throw new Error(T.youtubePreviewFailed);
if (String(this.youtubeUrl || '').trim() !== url) return;
this.youtubePreview = { ...data, items };
this.youtubePreviewSelected = new Set(
items.filter(item => item.selected_by_default).map(item => item.source_id)
);
this._setMessage('');
} catch (err) {
this._setMessage(err.message || T.youtubePreviewFailed, true);
} finally {
this.youtubePreviewLoading = false;
}
},
youtubePreviewIsSelected(sourceId) {
return this.youtubePreviewSelected.has(sourceId);
},
toggleYoutubePreviewItem(sourceId) {
const selected = new Set(this.youtubePreviewSelected);
if (selected.has(sourceId)) selected.delete(sourceId);
else selected.add(sourceId);
this.youtubePreviewSelected = selected;
},
selectAllYoutubePreview() {
const items = Array.isArray(this.youtubePreview?.items) ? this.youtubePreview.items : [];
this.youtubePreviewSelected = new Set(items.map(item => item.source_id));
},
clearYoutubePreviewSelection() {
this.youtubePreviewSelected = new Set();
},
youtubePreviewSelectedCount() {
return this.youtubePreviewSelected.size;
},
async startYoutubeDownload() {
const preview = this.youtubePreview;
const selectedSourceIds = Array.from(this.youtubePreviewSelected);
if (!preview || !selectedSourceIds.length || this.youtubeSubmitting) return;
this.youtubeSubmitting = true;
this._setMessage(T.youtubeStarting);
try {
const res = await fetch('/api/player/youtube/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: preview.source_url,
selected_source_ids: selectedSourceIds,
}),
});
const data = await res.json().catch(() => null);
if (!res.ok) throw new Error(data?.error || T.youtubeStartFailed);
this.youtubeJobs = [data, ...this.youtubeJobs.filter(job => job.id !== data.id)];
this.youtubeUrl = '';
this.clearYoutubePreview();
this._setMessage(T.youtubeStarted);
} catch (err) {
this._setMessage(err.message || T.youtubeStartFailed, true);
} finally {
this.youtubeSubmitting = false;
}
},
async cancelYoutubeJob(id) {
if (this.youtubeCancellingIds.has(id) || !confirm(T.youtubeStopConfirm)) return;
const cancelling = new Set(this.youtubeCancellingIds);
cancelling.add(id);
this.youtubeCancellingIds = cancelling;
this._setMessage(T.youtubeStopping);
try {
const res = await fetch(`/api/player/youtube/${encodeURIComponent(id)}/cancel`, {
method: 'POST',
});
const data = await res.json().catch(() => null);
if (!res.ok) throw new Error(data?.error || T.youtubeStopFailed);
this.youtubeJobs = this.youtubeJobs.map(job => job.id === id ? data : job);
this._setMessage(T.youtubeStopped);
} catch (err) {
this._setMessage(err.message || T.youtubeStopFailed, true);
} finally {
const remaining = new Set(this.youtubeCancellingIds);
remaining.delete(id);
this.youtubeCancellingIds = remaining;
}
},
async retryYoutubeJob(id) {
try {
const res = await fetch(`/api/player/youtube/${encodeURIComponent(id)}/retry`, {
method: 'POST',
});
const data = await res.json().catch(() => null);
if (!res.ok) throw new Error(data?.error || T.youtubeRetryFailed);
this.youtubeJobs = this.youtubeJobs.map(job => job.id === id ? data : job);
this._setMessage(T.youtubeStarted);
} catch (err) {
this._setMessage(err.message || T.youtubeRetryFailed, true);
}
},
async removeYoutubeJob(id) {
if (!confirm(T.youtubeDeleteConfirm)) return;
try {
const res = await fetch(`/api/player/youtube/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
const data = await res.json().catch(() => null);
if (!res.ok) throw new Error(data?.error || T.youtubeDeleteFailed);
this.youtubeJobs = this.youtubeJobs.filter(job => job.id !== id);
this._setMessage('');
} catch (err) {
this._setMessage(err.message || T.youtubeDeleteFailed, true);
}
},
openYoutubeReviews() {
this.showSourceTab('uploads');
},
youtubeActiveCount() {
return this.youtubeJobs.filter(job => !this.youtubeJobTerminal(job.status)).length;
},
youtubeSummary() {
const active = this.youtubeActiveCount();
return active > 0 ? active + ' ' + T.active : T.clientIdle;
},
youtubeStatusLabel(status) {
const labels = {
queued: T.youtubeQueued,
resolving: T.youtubeResolving,
downloading: T.downloading,
postprocessing: T.youtubePostprocessing,
awaiting_ai: T.youtubeAwaitingAi,
ai_processing: T.youtubeAiProcessing,
needs_review: T.youtubeNeedsReview,
complete: T.completed,
complete_with_errors: T.youtubeCompleteWithErrors,
failed: T.failed,
ai_failed: T.failed,
skipped: T.youtubeSkipped,
cancelled: T.youtubeCancelled,
uploading: T.uploadingFiles,
};
return labels[String(status || '').toLowerCase()] || status || T.unknown;
},
youtubeStatusClass(status) {
const classes = {
queued: 'status-preview',
resolving: 'status-resolving',
downloading: 'status-downloading',
postprocessing: 'status-moving',
awaiting_ai: 'status-preview',
ai_processing: 'status-moving',
needs_review: 'status-paused',
complete: 'status-completed',
complete_with_errors: 'status-failed',
failed: 'status-failed',
ai_failed: 'status-failed',
skipped: 'status-completed',
cancelled: 'status-paused',
uploading: 'status-downloading',
};
return classes[String(status || '').toLowerCase()] || 'status-preview';
},
youtubeIsError(status) {
return ['failed', 'ai_failed'].includes(String(status || '').toLowerCase());
},
youtubeJobTerminal(status) {
return ['complete', 'complete_with_errors', 'failed', 'needs_review', 'cancelled'].includes(String(status || '').toLowerCase());
},
youtubeItemTerminal(status) {
return ['complete', 'skipped', 'needs_review', 'failed', 'ai_failed', 'cancelled'].includes(String(status || '').toLowerCase());
},
youtubeJobCancellable(status) {
return ['queued', 'resolving', 'downloading', 'postprocessing'].includes(String(status || '').toLowerCase());
},
youtubeJobMeta(job) {
const kind = job.source_kind === 'playlist' ? T.youtubePlaylist : T.youtubeVideo;
const parts = [kind];
if (Number(job.total_items || 0) > 0) parts.push(Number(job.total_items) + ' ' + T.youtubeItems);
if (Number(job.failed_items || 0) > 0) parts.push(Number(job.failed_items) + ' ' + T.youtubeErrors);
return parts.join(' · ');
},
youtubeItemMeta(item) {
const parts = [];
if (Number(item.chapter_count || 0) > 0) parts.push(Number(item.chapter_count) + ' ' + T.chapters);
if (Number(item.audio_file_count || 0) > 0) parts.push(Number(item.audio_file_count) + ' ' + T.trackWord);
if (Number(item.total_bytes || 0) > 0) parts.push(this.bytes(item.total_bytes));
return parts.join(' · ');
},
youtubeDownloadMeta(item) {
const downloaded = this.bytes(item.downloaded_bytes || 0);
const total = Number(item.total_bytes || 0) > 0 ? ' / ' + this.bytes(item.total_bytes) : '';
const speed = Number(item.speed_bytes_per_sec || 0) > 0 ? ' · ' + this.bytes(item.speed_bytes_per_sec) + '/s' : '';
const eta = Number(item.eta_seconds || 0) > 0 ? ' · ' + T.eta + ' ' + formatTime(item.eta_seconds) : '';
return downloaded + total + speed + eta;
},
youtubeItemProgress(item) {
const status = String(item?.status || 'queued').toLowerCase();
if (status === 'cancelled') {
const downloaded = Number(item.progress_percent || 0);
return downloaded > 0 ? Math.max(5, Math.min(55, 5 + downloaded * 0.5)) : 0;
}
if (this.youtubeItemTerminal(status)) return 100;
if (status === 'downloading') return 5 + Math.max(0, Math.min(100, Number(item.progress_percent || 0))) * 0.5;
if (status === 'postprocessing') return 62;
if (status === 'awaiting_ai') return 75;
if (status === 'ai_processing') return 88;
return 2;
},
youtubeJobProgress(job) {
const items = Array.isArray(job?.items) ? job.items : [];
if (!items.length) return job?.status === 'failed' ? 100 : 2;
return Math.round(items.reduce((sum, item) => sum + this.youtubeItemProgress(item), 0) / items.length);
},
youtubeJobProgressText(job) {
const items = Array.isArray(job?.items) ? job.items : [];
if (!items.length) return this.youtubeStatusLabel(job?.status);
const done = items.filter(item => this.youtubeItemTerminal(item.status) && item.status !== 'cancelled').length;
return done + ' ' + T.ofWord + ' ' + items.length;
},
youtubeStage(item) {
const status = String(item?.status || 'queued').toLowerCase();
if (status === 'cancelled') {
if (Number(item?.progress_percent || 0) >= 99) return 2;
return Number(item?.progress_percent || 0) > 0 ? 1 : 0;
}
if (status === 'downloading') return 1;
if (status === 'postprocessing') return 2;
if (['awaiting_ai', 'ai_processing', 'ai_failed'].includes(status)) return 3;
if (status === 'failed') return Number(item?.progress_percent || 0) >= 99 ? 2 : 1;
if (this.youtubeItemTerminal(status)) return 4;
return 0;
},
youtubeStepClass(item, step) {
const status = String(item?.status || '').toLowerCase();
const stage = this.youtubeStage(item);
if (status === 'cancelled') {
if (step < stage) return 'done';
if (step === stage) return 'cancelled';
return '';
}
if (this.youtubeIsError(status) && step === stage) return 'failed';
if (['complete', 'skipped'].includes(status)) return 'done';
if (step < stage) return 'done';
if (step === stage) return 'active';
return '';
},
youtubeFinalStepLabel(item) {
return this.youtubeStatusLabel(item?.status === 'complete' ? 'complete' : item?.status);
},
addNew() {
@@ -5329,9 +5688,15 @@ document.addEventListener('alpine:init', () => {
this._stopRefresh();
this._refreshTimer = setInterval(() => {
if (!this.modal) return;
if (this.activeTab === 'uploads') {
if (this.sourceTab === 'youtube') {
this.loadYoutubeJobs({ silent: true });
}
else if (this.sourceTab === 'uploads') {
this.loadUploads({ silent: true });
}
else if (this.sourceTab === 'files') {
this.loadLocalUploadHistory({ silent: true });
}
else this.loadSessions();
this.loadAgentStatus();
}, 5000);
@@ -5469,7 +5834,36 @@ document.addEventListener('alpine:init', () => {
},
setLocalFiles(files) {
this.localFiles = Array.from(files || []);
const supported = new Set(['mp3', 'flac', 'wav', 'm4a', 'ogg', 'opus', 'aac']);
const selected = Array.from(files || []).filter(file => {
const extension = String(file.name || '').split('.').pop().toLowerCase();
return String(file.type || '').startsWith('audio/') || supported.has(extension);
});
this.localFiles = selected;
this.localFilesDragging = false;
this.uploadProgress = 0;
this.uploadProgressText = '';
if (!selected.length && Array.from(files || []).length) {
this._setMessage(T.noSupportedAudioFiles, true);
} else {
this._setMessage('');
}
},
clearLocalFiles() {
if (this.localFilesUploading) return;
this.localFiles = [];
this.uploadProgress = 0;
this.uploadProgressText = '';
},
leaveLocalFileDrop(event) {
if (!event.currentTarget.contains(event.relatedTarget)) this.localFilesDragging = false;
},
dropLocalFiles(event) {
this.localFilesDragging = false;
this.setLocalFiles(event?.dataTransfer?.files || []);
},
localUploadBytes() {
@@ -5488,6 +5882,7 @@ document.addEventListener('alpine:init', () => {
xhr.open('POST', '/api/player/uploads/local');
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
xhr.setRequestHeader('X-Furumusic-Filename', encodeURIComponent(file.name || 'upload.mp3'));
xhr.setRequestHeader('X-Furumusic-Upload-Id', crypto.randomUUID());
xhr.upload.onprogress = event => {
if (!event.lengthComputable || totalBytes <= 0) return;
const loaded = loadedBefore + event.loaded;
@@ -5506,38 +5901,75 @@ document.addEventListener('alpine:init', () => {
},
async uploadLocalFiles() {
if (this.loading || this.localFiles.length === 0) return;
this.loading = true;
if (this.localFilesUploading || this.localFiles.length === 0) return;
this.localFilesUploading = true;
this.uploadProgress = 0;
this.uploadProgressText = '0.0%';
this._setMessage(T.uploadingFiles);
const totalBytes = this.localUploadBytes();
let loadedBefore = 0;
try {
for (const file of this.localFiles) {
await this.uploadLocalFile(file, loadedBefore, totalBytes);
for (const file of [...this.localFiles]) {
const data = await this.uploadLocalFile(file, loadedBefore, totalBytes);
if (data?.upload) {
this.localUploadHistory = [
data.upload,
...this.localUploadHistory.filter(item => item.id !== data.upload.id),
];
}
loadedBefore += Number(file.size || 0);
this.localFiles = this.localFiles.filter(item => item !== file);
this.uploadProgress = totalBytes > 0 ? Math.min(100, loadedBefore / totalBytes * 100) : 100;
this.uploadProgressText = this.uploadProgress.toFixed(1) + '%';
}
this.localFiles = [];
this.uploadProgress = 100;
this.uploadProgressText = '100.0%';
this._setMessage(T.uploadComplete);
await this.loadAgentStatus();
await this.loadLocalUploadHistory({ silent: true });
} catch (err) {
this._setMessage(err.message || String(err), true);
} finally {
this.loading = false;
this.localFilesUploading = false;
}
},
async loadLocalUploadHistory({ silent = false } = {}) {
if (!silent) this.localUploadHistoryLoading = true;
try {
const res = await fetch('/api/player/uploads/local/history');
const data = await res.json().catch(() => null);
if (!res.ok) throw new Error(data?.error || T.fileUploadLoadFailed);
this.localUploadHistory = Array.isArray(data) ? data : [];
} catch (err) {
if (!silent) this._setMessage(err.message || T.fileUploadLoadFailed, true);
} finally {
if (!silent) this.localUploadHistoryLoading = false;
}
},
async removeLocalUploadHistory(id) {
if (!confirm(T.removeFileUploadConfirm)) return;
try {
const res = await fetch(`/api/player/uploads/local/history/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
const data = await res.json().catch(() => null);
if (!res.ok) throw new Error(data?.error || T.fileUploadHistoryRemoveFailed);
this.localUploadHistory = this.localUploadHistory.filter(item => item.id !== id);
this._setMessage(T.fileUploadHistoryRemoved);
} catch (err) {
this._setMessage(err.message || T.fileUploadHistoryRemoveFailed, true);
}
},
formatUploadDate(value) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? String(value || '') : date.toLocaleString();
},
async preview() {
if (this.loading) return;
if (this.localFiles.length > 0) {
await this.uploadLocalFiles();
return;
}
const magnet = this.magnet.trim();
if (!this.file && !magnet) {
this._setMessage(T.chooseTorrent, true);
+539
View File
@@ -3415,6 +3415,487 @@ button.user-stat:hover {
font-size: 11px;
}
.download-source-tabs {
padding-bottom: 10px;
border-bottom: 1px solid var(--border-color);
}
.download-source-tabs .torrent-tab-btn {
min-width: 112px;
justify-content: center;
}
.download-torrent-panel,
.youtube-manager-panel {
min-height: 0;
flex: 1 1 auto;
}
.file-upload-panel {
min-height: 0;
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 12px;
overflow: hidden;
}
.file-upload-input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
clip-path: inset(50%);
white-space: nowrap;
}
.file-drop-zone {
min-height: 176px;
flex: 0 0 auto;
display: flex !important;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
margin: 0 !important;
padding: 24px;
border: 2px dashed var(--border-color);
border-radius: 12px;
background: var(--bg-primary);
color: var(--text-subdued);
text-align: center;
cursor: pointer;
transition: border-color 150ms ease, background 150ms ease, transform 150ms ease;
}
.file-drop-zone:hover,
.file-drop-zone.dragging {
border-color: var(--accent);
background: rgba(29,185,84,0.08);
}
.file-drop-zone.dragging {
transform: scale(0.995);
}
.file-drop-zone strong {
color: var(--text-primary);
font-size: 15px;
}
.file-drop-zone span {
font-size: 12px;
}
.file-drop-zone small {
margin-top: 2px;
color: var(--text-muted);
font-size: 10px;
}
.file-drop-icon {
width: 42px;
height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 4px;
border-radius: 50%;
background: rgba(29,185,84,0.14);
color: #7ee29e;
}
.file-drop-icon svg {
width: 23px;
height: 23px;
}
.file-upload-selection {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-secondary);
font-size: 12px;
font-weight: 700;
}
.file-upload-selection > div {
display: flex;
gap: 7px;
}
.file-upload-history-head {
margin-top: 2px;
}
.file-upload-history {
min-height: 0;
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
padding-right: 4px;
}
.file-upload-history-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 10px;
align-items: center;
padding: 11px 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
}
.file-upload-history-main {
min-width: 0;
}
.file-upload-history-title {
overflow: hidden;
color: var(--text-primary);
font-size: 12px;
font-weight: 750;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-upload-history-meta {
margin-top: 3px;
color: var(--text-subdued);
font-size: 10px;
}
.download-torrent-panel {
display: flex;
flex-direction: column;
}
.youtube-manager-panel {
display: flex;
flex-direction: column;
gap: 12px;
overflow: hidden;
}
.youtube-download-form {
flex: 0 0 auto;
padding: 14px;
border: 1px solid var(--border-color);
border-radius: 9px;
background: var(--bg-primary);
}
.youtube-download-form-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
}
.torrent-modal .youtube-download-form-row input[type="text"] {
margin: 0;
}
.youtube-download-hint {
margin: 8px 0 0;
color: var(--text-subdued);
font-size: 11px;
line-height: 1.45;
}
.youtube-preview-card {
min-height: 0;
flex: 0 1 360px;
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
border: 1px solid rgba(29,185,84,0.35);
border-radius: 9px;
background: var(--bg-primary);
}
.youtube-preview-head,
.youtube-preview-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.youtube-preview-head h4 {
margin: 0 0 4px;
color: var(--text-primary);
font-size: 13px;
}
.youtube-preview-head p {
margin: 0;
color: var(--text-subdued);
font-size: 11px;
}
.youtube-preview-head strong {
color: var(--text-secondary);
}
.youtube-preview-controls,
.youtube-preview-footer > div {
display: flex;
align-items: center;
gap: 7px;
flex: 0 0 auto;
}
.youtube-preview-list {
min-height: 72px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 7px;
background: var(--bg-secondary);
}
.youtube-preview-row {
display: grid;
grid-template-columns: auto 30px minmax(0, 1fr);
gap: 8px;
align-items: center;
min-height: 38px;
padding: 6px 10px;
border-bottom: 1px solid var(--border-color);
color: var(--text-secondary);
cursor: pointer;
}
.youtube-preview-row:last-child {
border-bottom: 0;
}
.youtube-preview-row:hover,
.youtube-preview-row.selected {
background: var(--bg-hover);
color: var(--text-primary);
}
.youtube-preview-row input[type="checkbox"] {
width: 15px;
height: 15px;
margin: 0;
accent-color: #1db954;
}
.youtube-preview-item-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
font-weight: 650;
}
.youtube-preview-footer {
color: var(--text-subdued);
font-size: 11px;
font-weight: 700;
}
.youtube-download-list-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex: 0 0 auto;
color: var(--text-secondary);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.youtube-download-list {
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: auto;
padding-right: 4px;
}
.youtube-empty-state {
min-height: 180px;
border: 1px dashed var(--border-color);
border-radius: 9px;
}
.youtube-job-card {
flex: 0 0 auto;
padding: 13px;
border: 1px solid var(--border-color);
border-radius: 9px;
background: var(--bg-primary);
}
.youtube-job-head,
.youtube-item-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 10px;
}
.youtube-job-heading,
.youtube-item-main {
min-width: 0;
}
.youtube-job-title,
.youtube-item-title {
overflow: hidden;
color: var(--text-primary);
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.youtube-job-title { font-size: 14px; }
.youtube-item-title { font-size: 12px; }
.youtube-job-meta,
.youtube-item-meta {
margin-top: 3px;
color: var(--text-subdued);
font-size: 11px;
line-height: 1.35;
}
.youtube-job-progress {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
margin-top: 9px;
color: var(--text-subdued);
font-size: 11px;
}
.youtube-job-progress .torrent-session-progress,
.youtube-item-download-progress .torrent-session-progress {
margin-top: 0;
}
.youtube-job-error,
.youtube-item-error {
margin: 8px 0 0;
color: #ffb9b9;
font-size: 11px;
line-height: 1.4;
overflow-wrap: anywhere;
}
.youtube-item-list {
display: flex;
flex-direction: column;
gap: 7px;
margin-top: 11px;
}
.youtube-item-row {
padding: 10px;
border: 1px solid rgba(255,255,255,0.07);
border-radius: 7px;
background: var(--bg-elevated);
}
.youtube-item-row.failed {
border-color: rgba(229,96,96,0.3);
}
.youtube-item-head {
grid-template-columns: 28px minmax(0, 1fr) auto;
align-items: center;
}
.youtube-item-index {
color: var(--text-muted);
font-size: 10px;
font-weight: 900;
text-align: center;
}
.youtube-item-download-progress {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
margin-top: 8px;
color: var(--text-subdued);
font-size: 10px;
}
.youtube-step-list {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 4px;
margin-top: 9px;
}
.youtube-step {
min-width: 0;
display: flex;
align-items: center;
gap: 5px;
color: var(--text-muted);
font-size: 9px;
text-transform: uppercase;
}
.youtube-step i {
width: 7px;
height: 7px;
flex: 0 0 7px;
border-radius: 50%;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.youtube-step b {
min-width: 0;
overflow: hidden;
font: inherit;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.youtube-step.done { color: #9ff0b9; }
.youtube-step.done i { border-color: var(--accent); background: var(--accent); }
.youtube-step.active { color: #ffd78a; }
.youtube-step.active i {
border-color: #f0b84d;
background: #f0b84d;
box-shadow: 0 0 0 3px rgba(240,184,77,0.13);
}
.youtube-step.failed { color: #ffb9b9; }
.youtube-step.failed i { border-color: #e56060; background: #e56060; }
.youtube-step.cancelled { color: #ffd78a; }
.youtube-step.cancelled i { border-color: #f0b84d; background: #f0b84d; }
.youtube-job-actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 7px;
margin-top: 10px;
}
.torrent-manager-layout {
display: grid;
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
@@ -6016,6 +6497,64 @@ button.user-stat:hover {
padding-bottom: 2px;
}
.youtube-manager-panel {
overflow-y: auto;
}
.file-upload-panel {
overflow-y: auto;
}
.file-upload-history {
overflow: visible;
}
.file-upload-history-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.file-upload-history-row .modal-btn-danger {
grid-column: 1 / -1;
justify-self: start;
}
.file-upload-selection {
align-items: flex-start;
flex-direction: column;
}
.youtube-download-form-row {
grid-template-columns: 1fr;
}
.youtube-download-form-row .modal-btn {
justify-self: start;
}
.youtube-preview-card {
flex-basis: auto;
max-height: 330px;
}
.youtube-preview-head,
.youtube-preview-footer {
align-items: flex-start;
flex-direction: column;
}
.youtube-preview-controls {
flex-wrap: wrap;
}
.youtube-download-list {
overflow: visible;
}
.youtube-step-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
row-gap: 7px;
}
.upload-manager-panel {
overflow-y: auto;
}