This commit is contained in:
@@ -88,7 +88,8 @@
|
||||
<!-- Download Manager Modal -->
|
||||
<template x-if="$store.torrents.modal">
|
||||
<div class="modal-overlay" @click.self="$store.torrents.close()">
|
||||
<div class="modal-box torrent-modal">
|
||||
<div class="modal-box torrent-modal"
|
||||
:class="{ 'youtube-mode': $store.torrents.sourceTab === 'youtube' }">
|
||||
<div class="torrent-modal-head">
|
||||
<div>
|
||||
<h3>{{ t.player_torrent_manager }}</h3>
|
||||
@@ -205,6 +206,29 @@
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
<div class="youtube-preview-destination">
|
||||
<label for="youtube-target-playlist">{{ t.player_youtube_destination }}</label>
|
||||
<div class="youtube-preview-destination-fields"
|
||||
:class="{ creating: $store.torrents.youtubePlaylistChoice === '__new__' }">
|
||||
<select id="youtube-target-playlist"
|
||||
x-model="$store.torrents.youtubePlaylistChoice"
|
||||
@change="$store.torrents.youtubePlaylistChoiceChanged()">
|
||||
<option value="">{{ t.player_youtube_no_destination }}</option>
|
||||
<template x-for="playlist in $store.torrents.youtubeOwnedPlaylists()" :key="playlist.id">
|
||||
<option :value="String(playlist.id)" x-text="playlist.title"></option>
|
||||
</template>
|
||||
<option value="__new__">{{ t.player_youtube_create_playlist }}</option>
|
||||
</select>
|
||||
<template x-if="$store.torrents.youtubePlaylistChoice === '__new__'">
|
||||
<input type="text"
|
||||
maxlength="255"
|
||||
autocomplete="off"
|
||||
x-model="$store.torrents.youtubeNewPlaylistTitle"
|
||||
placeholder="{{ t.player_youtube_new_playlist_name }}">
|
||||
</template>
|
||||
</div>
|
||||
<p>{{ t.player_youtube_destination_hint }}</p>
|
||||
</div>
|
||||
<div class="youtube-preview-footer">
|
||||
<span x-text="$store.torrents.youtubePreviewSelectedCount() + ' ' + T.youtubeSelectedCount"></span>
|
||||
<div>
|
||||
@@ -212,7 +236,7 @@
|
||||
@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">
|
||||
:disabled="$store.torrents.youtubeSubmitting || $store.torrents.youtubePreviewSelectedCount() === 0 || !$store.torrents.youtubeDestinationValid()">
|
||||
{{ t.player_youtube_start_import }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -235,16 +259,29 @@
|
||||
</template>
|
||||
|
||||
<template x-for="job in $store.torrents.youtubeJobs" :key="job.id">
|
||||
<article class="youtube-job-card">
|
||||
<div class="youtube-job-head">
|
||||
<article class="youtube-job-card"
|
||||
:class="{ collapsed: !$store.torrents.youtubeJobExpanded(job.id) }">
|
||||
<button type="button"
|
||||
class="youtube-job-summary"
|
||||
:aria-expanded="$store.torrents.youtubeJobExpanded(job.id)"
|
||||
:title="$store.torrents.youtubeJobExpanded(job.id) ? T.collapse : T.expand"
|
||||
@click="$store.torrents.toggleYoutubeJob(job.id)">
|
||||
<span class="youtube-job-chevron" aria-hidden="true"></span>
|
||||
<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="youtube-job-compact-progress"
|
||||
x-text="$store.torrents.youtubeJobProgress(job) + '%'">
|
||||
</span>
|
||||
<span class="torrent-status-badge"
|
||||
:class="$store.torrents.youtubeStatusClass(job.status)"
|
||||
x-text="$store.torrents.youtubeStatusLabel(job.status)"></span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="youtube-job-details"
|
||||
x-show="$store.torrents.youtubeJobExpanded(job.id)"
|
||||
x-cloak>
|
||||
|
||||
<div class="youtube-job-progress">
|
||||
<div class="torrent-session-progress">
|
||||
@@ -316,6 +353,7 @@
|
||||
x-show="$store.torrents.youtubeJobTerminal(job.status)"
|
||||
@click="$store.torrents.removeYoutubeJob(job.id)">{{ t.player_remove_from_history }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -162,6 +162,8 @@ const T = {
|
||||
youtubeSelectAll: "{{ t.player_youtube_select_all }}",
|
||||
youtubeClearSelection: "{{ t.player_youtube_clear_selection }}",
|
||||
youtubeSelectedCount: "{{ t.player_youtube_selected_count }}",
|
||||
youtubePlaylistCreateFailed: "{{ t.player_youtube_playlist_create_failed }}",
|
||||
youtubeAddedTo: "{{ t.player_youtube_added_to }}",
|
||||
youtubeCancelled: "{{ t.player_youtube_cancelled }}",
|
||||
youtubeStopConfirm: "{{ t.player_youtube_stop_confirm }}",
|
||||
youtubeStopping: "{{ t.player_youtube_stopping }}",
|
||||
@@ -181,6 +183,8 @@ const T = {
|
||||
liveReleases: "{{ t.player_live_releases }}",
|
||||
soundtracks: "{{ t.player_soundtracks }}",
|
||||
likesPlaylist: "{{ t.player_likes_playlist }}",
|
||||
expand: "{{ t.player_expand }}",
|
||||
collapse: "{{ t.player_collapse }}",
|
||||
};
|
||||
|
||||
function formatTime(seconds) {
|
||||
@@ -4611,8 +4615,13 @@ document.addEventListener('alpine:init', () => {
|
||||
youtubeUrl: '',
|
||||
youtubePreview: null,
|
||||
youtubePreviewSelected: new Set(),
|
||||
youtubePlaylistChoice: '',
|
||||
youtubeNewPlaylistTitle: '',
|
||||
youtubePlaylistSyncKey: '',
|
||||
youtubePreviewLoading: false,
|
||||
youtubeJobs: [],
|
||||
youtubeExpandedJobId: null,
|
||||
youtubeJobsInitialized: false,
|
||||
youtubeLoading: false,
|
||||
youtubeSubmitting: false,
|
||||
youtubeCancellingIds: new Set(),
|
||||
@@ -4728,6 +4737,31 @@ document.addEventListener('alpine:init', () => {
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) throw new Error(data?.error || T.youtubeLoadFailed);
|
||||
this.youtubeJobs = Array.isArray(data) ? data : [];
|
||||
if (
|
||||
this.youtubeExpandedJobId !== null
|
||||
&& !this.youtubeJobs.some(job => job.id === this.youtubeExpandedJobId)
|
||||
) {
|
||||
this.youtubeExpandedJobId = null;
|
||||
}
|
||||
if (!this.youtubeJobsInitialized) {
|
||||
const activeJob = this.youtubeJobs.find(job => !this.youtubeJobTerminal(job.status));
|
||||
this.youtubeExpandedJobId = this.youtubePreview ? null : (activeJob?.id || null);
|
||||
this.youtubeJobsInitialized = true;
|
||||
}
|
||||
const playlistSyncKey = this.youtubeJobs
|
||||
.filter(job => Number(job.target_playlist_id || 0) > 0)
|
||||
.map(job => [
|
||||
job.id,
|
||||
job.status,
|
||||
Number(job.completed_items || 0),
|
||||
Number(job.failed_items || 0),
|
||||
Number(job.review_items || 0),
|
||||
].join(':'))
|
||||
.join('|');
|
||||
if (playlistSyncKey && playlistSyncKey !== this.youtubePlaylistSyncKey) {
|
||||
this.youtubePlaylistSyncKey = playlistSyncKey;
|
||||
Alpine.store('playlists')?.reload?.();
|
||||
}
|
||||
} catch (err) {
|
||||
if (!silent) this._setMessage(err.message || T.youtubeLoadFailed, true);
|
||||
} finally {
|
||||
@@ -4738,6 +4772,8 @@ document.addEventListener('alpine:init', () => {
|
||||
clearYoutubePreview() {
|
||||
this.youtubePreview = null;
|
||||
this.youtubePreviewSelected = new Set();
|
||||
this.youtubePlaylistChoice = '';
|
||||
this.youtubeNewPlaylistTitle = '';
|
||||
},
|
||||
|
||||
async previewYoutubeUrl() {
|
||||
@@ -4761,6 +4797,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.youtubePreviewSelected = new Set(
|
||||
items.filter(item => item.selected_by_default).map(item => item.source_id)
|
||||
);
|
||||
this.youtubeExpandedJobId = null;
|
||||
this._setMessage('');
|
||||
} catch (err) {
|
||||
this._setMessage(err.message || T.youtubePreviewFailed, true);
|
||||
@@ -4793,24 +4830,90 @@ document.addEventListener('alpine:init', () => {
|
||||
return this.youtubePreviewSelected.size;
|
||||
},
|
||||
|
||||
youtubeOwnedPlaylists() {
|
||||
return (Alpine.store('playlists')?.list || []).filter(playlist => (
|
||||
playlist.kind === 'user' && playlist.is_own && Number(playlist.id) > 0
|
||||
));
|
||||
},
|
||||
|
||||
youtubePlaylistChoiceChanged() {
|
||||
if (
|
||||
this.youtubePlaylistChoice === '__new__'
|
||||
&& !String(this.youtubeNewPlaylistTitle || '').trim()
|
||||
) {
|
||||
this.youtubeNewPlaylistTitle = String(this.youtubePreview?.title || '').slice(0, 255);
|
||||
}
|
||||
},
|
||||
|
||||
youtubeDestinationValid() {
|
||||
return this.youtubePlaylistChoice !== '__new__'
|
||||
|| String(this.youtubeNewPlaylistTitle || '').trim().length > 0;
|
||||
},
|
||||
|
||||
youtubePlaylistTitle(playlistId) {
|
||||
const wanted = Number(playlistId || 0);
|
||||
return this.youtubeOwnedPlaylists().find(playlist => Number(playlist.id) === wanted)?.title || '';
|
||||
},
|
||||
|
||||
async resolveYoutubeTargetPlaylist() {
|
||||
if (this.youtubePlaylistChoice !== '__new__') {
|
||||
const playlistId = Number(this.youtubePlaylistChoice || 0);
|
||||
return playlistId > 0 ? playlistId : null;
|
||||
}
|
||||
|
||||
const title = String(this.youtubeNewPlaylistTitle || '').trim();
|
||||
if (!title) throw new Error(T.youtubePlaylistCreateFailed);
|
||||
const res = await fetch('/api/player/playlists', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
const playlist = await res.json().catch(() => null);
|
||||
if (!res.ok || Number(playlist?.id || 0) <= 0) {
|
||||
throw new Error(playlist?.error || T.youtubePlaylistCreateFailed);
|
||||
}
|
||||
|
||||
// Switch to the created playlist before starting the job. If the
|
||||
// YouTube request fails, retrying will reuse it instead of creating
|
||||
// another playlist with the same name.
|
||||
this.youtubePlaylistChoice = String(playlist.id);
|
||||
const playlists = Alpine.store('playlists');
|
||||
if (playlists) {
|
||||
playlists.list = [
|
||||
...(playlists.list || []).filter(item => Number(item.id) !== Number(playlist.id)),
|
||||
playlist,
|
||||
];
|
||||
await playlists.reload();
|
||||
}
|
||||
return Number(playlist.id);
|
||||
},
|
||||
|
||||
async startYoutubeDownload() {
|
||||
const preview = this.youtubePreview;
|
||||
const selectedSourceIds = Array.from(this.youtubePreviewSelected);
|
||||
if (!preview || !selectedSourceIds.length || this.youtubeSubmitting) return;
|
||||
if (
|
||||
!preview
|
||||
|| !selectedSourceIds.length
|
||||
|| !this.youtubeDestinationValid()
|
||||
|| this.youtubeSubmitting
|
||||
) return;
|
||||
this.youtubeSubmitting = true;
|
||||
this._setMessage(T.youtubeStarting);
|
||||
try {
|
||||
const targetPlaylistId = await this.resolveYoutubeTargetPlaylist();
|
||||
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,
|
||||
target_playlist_id: targetPlaylistId,
|
||||
}),
|
||||
});
|
||||
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.youtubeExpandedJobId = data.id;
|
||||
this.youtubeUrl = '';
|
||||
this.clearYoutubePreview();
|
||||
this._setMessage(T.youtubeStarted);
|
||||
@@ -4942,11 +5045,21 @@ document.addEventListener('alpine:init', () => {
|
||||
return ['queued', 'resolving', 'downloading', 'postprocessing'].includes(String(status || '').toLowerCase());
|
||||
},
|
||||
|
||||
youtubeJobExpanded(jobId) {
|
||||
return this.youtubeExpandedJobId === jobId;
|
||||
},
|
||||
|
||||
toggleYoutubeJob(jobId) {
|
||||
this.youtubeExpandedJobId = this.youtubeExpandedJobId === jobId ? null : jobId;
|
||||
},
|
||||
|
||||
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);
|
||||
const playlistTitle = this.youtubePlaylistTitle(job.target_playlist_id);
|
||||
if (playlistTitle) parts.push(T.youtubeAddedTo + ' ' + playlistTitle);
|
||||
return parts.join(' · ');
|
||||
},
|
||||
|
||||
|
||||
@@ -3291,6 +3291,11 @@ button.user-stat:hover {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.torrent-modal.youtube-mode {
|
||||
width: min(1440px, calc(100vw - 32px));
|
||||
max-width: 1440px;
|
||||
}
|
||||
|
||||
.torrent-modal-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -3613,8 +3618,8 @@ button.user-stat:hover {
|
||||
}
|
||||
|
||||
.youtube-preview-card {
|
||||
min-height: 0;
|
||||
flex: 0 1 360px;
|
||||
min-height: 370px;
|
||||
flex: 1 1 520px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
@@ -3626,6 +3631,7 @@ button.user-stat:hover {
|
||||
|
||||
.youtube-preview-head,
|
||||
.youtube-preview-footer {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -3657,13 +3663,55 @@ button.user-stat:hover {
|
||||
}
|
||||
|
||||
.youtube-preview-list {
|
||||
min-height: 72px;
|
||||
min-height: 190px;
|
||||
flex: 1 1 260px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 7px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.youtube-preview-destination {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
|
||||
grid-template-rows: auto auto;
|
||||
column-gap: 14px;
|
||||
row-gap: 4px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 7px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.youtube-preview-destination > label {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.youtube-preview-destination-fields {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / 3;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.youtube-preview-destination-fields.creating {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.youtube-preview-destination p {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
margin: 0;
|
||||
color: var(--text-subdued);
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.youtube-preview-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 30px minmax(0, 1fr);
|
||||
@@ -3722,6 +3770,7 @@ button.user-stat:hover {
|
||||
|
||||
.youtube-download-list {
|
||||
min-height: 0;
|
||||
flex: 1 1 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
@@ -3737,10 +3786,63 @@ button.user-stat:hover {
|
||||
|
||||
.youtube-job-card {
|
||||
flex: 0 0 auto;
|
||||
padding: 13px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 9px;
|
||||
background: var(--bg-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.youtube-job-card.collapsed {
|
||||
border-color: rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.youtube-job-summary {
|
||||
width: 100%;
|
||||
min-height: 50px;
|
||||
display: grid;
|
||||
grid-template-columns: 16px minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 13px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.youtube-job-summary:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.youtube-job-chevron {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-right: 2px solid var(--text-subdued);
|
||||
border-bottom: 2px solid var(--text-subdued);
|
||||
transform: rotate(45deg) translate(-2px, -2px);
|
||||
transition: transform 140ms ease;
|
||||
}
|
||||
|
||||
.youtube-job-card.collapsed .youtube-job-chevron {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.youtube-job-card.collapsed .youtube-job-meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.youtube-job-compact-progress {
|
||||
color: var(--text-subdued);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.youtube-job-details {
|
||||
padding: 0 13px 13px;
|
||||
}
|
||||
|
||||
.youtube-job-head,
|
||||
@@ -3781,7 +3883,7 @@ button.user-stat:hover {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 9px;
|
||||
margin-top: 0;
|
||||
color: var(--text-subdued);
|
||||
font-size: 11px;
|
||||
}
|
||||
@@ -6462,6 +6564,11 @@ button.user-stat:hover {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.torrent-modal.youtube-mode {
|
||||
width: 100vw;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.torrent-modal-head {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
@@ -6533,7 +6640,8 @@ button.user-stat:hover {
|
||||
|
||||
.youtube-preview-card {
|
||||
flex-basis: auto;
|
||||
max-height: 330px;
|
||||
min-height: 420px;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.youtube-preview-head,
|
||||
@@ -6546,6 +6654,32 @@ button.user-stat:hover {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.youtube-preview-destination-fields {
|
||||
grid-column: auto;
|
||||
grid-row: auto;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.youtube-preview-destination {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.youtube-preview-destination > label {
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.youtube-preview-destination p {
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.youtube-job-summary {
|
||||
grid-template-columns: 16px minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.youtube-job-summary .youtube-job-compact-progress {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.youtube-download-list {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user