diff --git a/src/i18n/phrases.rs b/src/i18n/phrases.rs index 72ceafe..fe27d99 100644 --- a/src/i18n/phrases.rs +++ b/src/i18n/phrases.rs @@ -406,6 +406,13 @@ translations! { player_youtube_select_all: "Select all" , "Отметить все"; player_youtube_clear_selection: "Clear selection" , "Снять все"; player_youtube_selected_count: "selected" , "выбрано"; + player_youtube_destination: "Add imported tracks to playlist" , "Добавить импортированные треки в плейлист"; + player_youtube_no_destination: "Do not add to a playlist" , "Не добавлять в плейлист"; + player_youtube_create_playlist: "Create a new playlist" , "Создать новый плейлист"; + player_youtube_new_playlist_name: "New playlist name" , "Название нового плейлиста"; + player_youtube_destination_hint: "Every track created from the selected videos, including chapters and previously imported videos, will be added automatically." , "Все треки из выбранных видео, включая главы и уже импортированные видео, будут добавлены автоматически."; + player_youtube_playlist_create_failed: "Could not create playlist" , "Не удалось создать плейлист"; + player_youtube_added_to: "added to" , "добавление в"; player_youtube_start_import: "Start import" , "Начать импорт"; player_start_download: "Start download" , "Начать загрузку"; player_retry_failed: "Retry failed" , "Повторить ошибки"; @@ -533,6 +540,7 @@ translations! { player_download_selected: "Download selected" , "Скачать выбранное"; player_pause_download: "Pause download" , "Поставить на паузу"; player_expand_all: "Expand all" , "Развернуть всё"; + player_expand: "Expand" , "Развернуть"; player_collapse: "Collapse" , "Свернуть"; player_selected: "selected" , "выбрано"; player_preview: "Preview" , "Предпросмотр"; diff --git a/src/jobs/inbox_process.rs b/src/jobs/inbox_process.rs index b6aa6ad..517a534 100644 --- a/src/jobs/inbox_process.rs +++ b/src/jobs/inbox_process.rs @@ -1196,6 +1196,17 @@ pub async fn finalize_approved( } } + if let Err(error) = + crate::youtube::sync_target_playlists_for_imported_media(pool, media_file.id_val()).await + { + tracing::warn!( + track_id = track.id_val(), + media_file_id = media_file.id_val(), + %error, + "could not add an imported YouTube track to its target playlist; it will be retried" + ); + } + tracing::info!( track_id = track.id_val(), artist = artist_name, diff --git a/src/youtube.rs b/src/youtube.rs index bd0fc29..264f5e0 100644 --- a/src/youtube.rs +++ b/src/youtube.rs @@ -244,6 +244,8 @@ pub struct YouTubePreviewRequest { pub struct YouTubeStartRequest { pub url: String, pub selected_source_ids: Vec, + #[serde(default)] + pub target_playlist_id: Option, } #[derive(Debug, Clone, Serialize)] @@ -289,6 +291,7 @@ pub struct YouTubeJobDto { pub source_url: String, pub title: String, pub source_kind: String, + pub target_playlist_id: Option, pub status: String, pub total_items: i32, pub completed_items: i32, @@ -308,6 +311,7 @@ struct YouTubeJobRow { source_url: String, title: String, source_kind: String, + target_playlist_id: Option, status: String, total_items: i32, completed_items: i32, @@ -373,6 +377,7 @@ impl YouTubeJobRow { source_url: self.source_url.clone(), title: self.title.clone(), source_kind: self.source_kind.clone(), + target_playlist_id: self.target_playlist_id, status: self.status.clone(), total_items: self.total_items, completed_items: self.completed_items, @@ -561,19 +566,37 @@ impl YouTubeService { .collect(); let already_imported = already_imported_source_ids(pool, user_id, &source_ids).await?; let mut transaction = pool.begin().await?; + if let Some(playlist_id) = request.target_playlist_id { + if playlist_id <= 0 { + bail!("selected playlist is invalid"); + } + let owned_playlist: Option = sqlx::query_scalar( + r#"SELECT id FROM furumusic__playlist + WHERE id = $1 AND owner_id = $2 + FOR SHARE"#, + ) + .bind(playlist_id) + .bind(user_id) + .fetch_optional(&mut *transaction) + .await?; + if owned_playlist.is_none() { + bail!("selected playlist does not exist or is not yours"); + } + } sqlx::query( r#"INSERT INTO furumusic__youtube_download - (id, user_id, source_url, title, source_kind, status, + (id, user_id, source_url, title, source_kind, target_playlist_id, status, total_items, completed_items, failed_items, review_items, error, created_at, updated_at, completed_at) - VALUES ($1, $2, $3, $4, $5, 'queued', $6, 0, 0, 0, - NULL, $7, $7, NULL)"#, + VALUES ($1, $2, $3, $4, $5, $6, 'queued', $7, 0, 0, 0, + NULL, $8, $8, NULL)"#, ) .bind(&id) .bind(user_id) .bind(&url) .bind(&resolved.title) .bind(&resolved.kind) + .bind(request.target_playlist_id) .bind(i32::try_from(selected_items.len()).unwrap_or(i32::MAX)) .bind(&now) .execute(&mut *transaction) @@ -614,6 +637,17 @@ impl YouTubeService { } transaction.commit().await?; + if request.target_playlist_id.is_some() + && let Err(error) = sync_target_playlist_tracks_for_user(pool, user_id).await + { + tracing::warn!( + job_id = %id, + user_id, + %error, + "could not immediately add previously imported YouTube tracks to the target playlist; it will be retried" + ); + } + self.spawn_job( pool.clone(), id.clone(), @@ -880,7 +914,8 @@ impl YouTubeService { } let inbox_root = validate_inbox_dir(inbox_dir)?; let job: YouTubeJobRow = sqlx::query_as( - r#"SELECT id, user_id, source_url, title, source_kind, status, + r#"SELECT id, user_id, source_url, title, source_kind, + target_playlist_id, status, total_items, completed_items, failed_items, review_items, error, created_at, updated_at, completed_at FROM furumusic__youtube_download WHERE id = $1"#, @@ -1963,9 +1998,166 @@ async fn sync_ai_statuses(pool: &PgPool, user_id: i64) -> anyhow::Result<()> { for job_id in touched_jobs { refresh_parent(pool, &job_id).await?; } + if let Err(error) = sync_target_playlist_tracks_for_user(pool, user_id).await { + tracing::warn!( + user_id, + %error, + "could not synchronize imported YouTube tracks with their target playlists" + ); + } Ok(()) } +/// Reconcile target playlists after an imported media file becomes a track. +/// +/// This is deliberately idempotent. It also matches by YouTube source id, so a +/// newly-created job can add tracks that were imported by an older job and were +/// therefore marked as `skipped` in the new one. +pub(crate) async fn sync_target_playlists_for_imported_media( + pool: &PgPool, + media_file_id: i64, +) -> anyhow::Result<()> { + let user_ids: Vec = sqlx::query_scalar( + r#"SELECT DISTINCT job.user_id + FROM furumusic__youtube_import_media imported + JOIN furumusic__youtube_download_item item ON item.id = imported.item_id + JOIN furumusic__youtube_download job ON job.id = item.job_id + WHERE imported.media_file_id = $1"#, + ) + .bind(media_file_id) + .fetch_all(pool) + .await?; + for user_id in user_ids { + sync_target_playlist_tracks_for_user(pool, user_id).await?; + } + Ok(()) +} + +async fn sync_target_playlist_tracks_for_user(pool: &PgPool, user_id: i64) -> anyhow::Result<()> { + let candidates: Vec<(i64, i64, i64, i32, i32)> = sqlx::query_as( + r#"SELECT target_job.target_playlist_id AS playlist_id, + target_job.user_id, + track.id AS track_id, + MIN(target_item.playlist_index) AS playlist_index, + COALESCE(track.track_number, 2147483647) AS track_number + FROM furumusic__youtube_download target_job + JOIN furumusic__youtube_download_item target_item + ON target_item.job_id = target_job.id + JOIN furumusic__youtube_download source_job + ON source_job.user_id = target_job.user_id + JOIN furumusic__youtube_download_item source_item + ON source_item.job_id = source_job.id + AND source_item.source_id = target_item.source_id + JOIN furumusic__youtube_import_media imported + ON imported.item_id = source_item.id + JOIN furumusic__track track + ON track.audio_file_id = imported.media_file_id + JOIN furumusic__playlist playlist + ON playlist.id = target_job.target_playlist_id + AND playlist.owner_id = target_job.user_id + WHERE target_job.user_id = $1 + AND target_job.target_playlist_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM furumusic__playlist_track existing + WHERE existing.playlist_id = target_job.target_playlist_id + AND existing.track_id = track.id + ) + GROUP BY target_job.target_playlist_id, target_job.user_id, + track.id, track.track_number + ORDER BY playlist_id, playlist_index, track_number, track_id"#, + ) + .bind(user_id) + .fetch_all(pool) + .await?; + + for (playlist_id, owner_id, track_id, _, _) in candidates { + append_track_to_target_playlist(pool, owner_id, playlist_id, track_id).await?; + } + Ok(()) +} + +async fn append_track_to_target_playlist( + pool: &PgPool, + user_id: i64, + playlist_id: i64, + track_id: i64, +) -> anyhow::Result { + let mut transaction = pool.begin().await?; + let playlist: Option = sqlx::query_scalar( + r#"SELECT id FROM furumusic__playlist + WHERE id = $1 AND owner_id = $2 + FOR UPDATE"#, + ) + .bind(playlist_id) + .bind(user_id) + .fetch_optional(&mut *transaction) + .await?; + if playlist.is_none() { + transaction.rollback().await?; + return Ok(false); + } + + let already_present: bool = sqlx::query_scalar( + r#"SELECT EXISTS ( + SELECT 1 FROM furumusic__playlist_track + WHERE playlist_id = $1 AND track_id = $2 + )"#, + ) + .bind(playlist_id) + .bind(track_id) + .fetch_one(&mut *transaction) + .await?; + if already_present { + transaction.commit().await?; + return Ok(false); + } + + let position: i64 = sqlx::query_scalar( + r#"SELECT COALESCE(MAX(position), -1)::bigint + 1 + FROM furumusic__playlist_track WHERE playlist_id = $1"#, + ) + .bind(playlist_id) + .fetch_one(&mut *transaction) + .await?; + let position = i32::try_from(position).context("target playlist has too many tracks")?; + let now = now_string(); + sqlx::query( + r#"INSERT INTO furumusic__playlist_track + (playlist_id, track_id, position, added_at, added_by_user_id) + VALUES ($1, $2, $3, $4, $5)"#, + ) + .bind(playlist_id) + .bind(track_id) + .bind(position) + .bind(&now) + .bind(user_id) + .execute(&mut *transaction) + .await?; + sqlx::query("UPDATE furumusic__playlist SET updated_at = $1 WHERE id = $2") + .bind(&now) + .bind(playlist_id) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + + if let Err(error) = crate::federation::devices::record_playlist_tracks_added( + pool, + user_id, + playlist_id, + &[track_id], + ) + .await + { + tracing::warn!( + playlist_id, + track_id, + %error, + "federation operation for an automatic YouTube playlist addition was not recorded" + ); + } + Ok(true) +} + async fn refresh_parent(pool: &PgPool, job_id: &str) -> anyhow::Result<()> { let parent_status: Option = sqlx::query_scalar("SELECT status::text FROM furumusic__youtube_download WHERE id = $1") @@ -2111,7 +2303,8 @@ async fn load_job_dto(pool: &PgPool, user_id: i64, id: &str) -> anyhow::Result anyhow::Result { sqlx::query_as( - r#"SELECT id, user_id, source_url, title, source_kind, status, + r#"SELECT id, user_id, source_url, title, source_kind, + target_playlist_id, status, total_items, completed_items, failed_items, review_items, error, created_at, updated_at, completed_at FROM furumusic__youtube_download WHERE id = $1 AND user_id = $2"#, @@ -2406,7 +2599,64 @@ pub mod db_migrations { &[Operation::custom(create_youtube_cookie_files).build()]; } - pub const MIGRATIONS: &[&SyncDynMigration] = &[&M0048CreateYoutubeCookieFiles]; + #[cot::db::migrations::migration_op] + async fn add_youtube_target_playlist( + ctx: migrations::MigrationContext<'_>, + ) -> cot::db::Result<()> { + ctx.db + .raw( + "ALTER TABLE furumusic__youtube_download + ADD COLUMN IF NOT EXISTS target_playlist_id BIGINT", + ) + .await?; + ctx.db + .raw( + "DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_youtube_download_target_playlist' + AND conrelid = 'furumusic__youtube_download'::regclass + ) THEN + ALTER TABLE furumusic__youtube_download + ADD CONSTRAINT fk_youtube_download_target_playlist + FOREIGN KEY (target_playlist_id) + REFERENCES furumusic__playlist(id) + ON DELETE SET NULL; + END IF; + END + $$", + ) + .await?; + ctx.db + .raw( + "CREATE INDEX IF NOT EXISTS idx_youtube_download_target_playlist + ON furumusic__youtube_download (target_playlist_id) + WHERE target_playlist_id IS NOT NULL", + ) + .await?; + Ok(()) + } + + #[derive(Debug, Copy, Clone)] + pub struct M0049AddYoutubeTargetPlaylist; + + impl migrations::Migration for M0049AddYoutubeTargetPlaylist { + const APP_NAME: &'static str = "furumusic"; + const MIGRATION_NAME: &'static str = "m_0049_add_youtube_target_playlist"; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( + "furumusic", + "m_0048_create_youtube_cookie_files", + )]; + const OPERATIONS: &'static [Operation] = + &[Operation::custom(add_youtube_target_playlist).build()]; + } + + pub const MIGRATIONS: &[&SyncDynMigration] = &[ + &M0048CreateYoutubeCookieFiles, + &M0049AddYoutubeTargetPlaylist, + ]; } #[cfg(test)] @@ -2433,6 +2683,24 @@ mod tests { assert!(validate_youtube_url("https://youtu.be/abc").is_ok()); } + #[test] + fn youtube_start_request_accepts_an_optional_target_playlist() { + let without_playlist: YouTubeStartRequest = serde_json::from_value(serde_json::json!({ + "url": "https://youtu.be/abc", + "selected_source_ids": ["abc"] + })) + .unwrap(); + assert_eq!(without_playlist.target_playlist_id, None); + + let with_playlist: YouTubeStartRequest = serde_json::from_value(serde_json::json!({ + "url": "https://youtu.be/abc", + "selected_source_ids": ["abc"], + "target_playlist_id": 42 + })) + .unwrap(); + assert_eq!(with_playlist.target_playlist_id, Some(42)); + } + #[test] fn rejects_non_youtube_and_lookalike_hosts() { assert!(validate_youtube_url("https://example.com/video").is_err()); diff --git a/templates/player/modals.html b/templates/player/modals.html index 7af6620..2073d65 100644 --- a/templates/player/modals.html +++ b/templates/player/modals.html @@ -88,7 +88,8 @@ +
+ +
+ + +
+

{{ t.player_youtube_destination_hint }}

+
diff --git a/templates/player/scripts.html b/templates/player/scripts.html index 60f4fa3..e40c39d 100644 --- a/templates/player/scripts.html +++ b/templates/player/scripts.html @@ -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(' · '); }, diff --git a/templates/player/styles.html b/templates/player/styles.html index 60da857..6370d13 100644 --- a/templates/player/styles.html +++ b/templates/player/styles.html @@ -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; }