This commit is contained in:
@@ -406,6 +406,13 @@ translations! {
|
|||||||
player_youtube_select_all: "Select all" , "Отметить все";
|
player_youtube_select_all: "Select all" , "Отметить все";
|
||||||
player_youtube_clear_selection: "Clear selection" , "Снять все";
|
player_youtube_clear_selection: "Clear selection" , "Снять все";
|
||||||
player_youtube_selected_count: "selected" , "выбрано";
|
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_youtube_start_import: "Start import" , "Начать импорт";
|
||||||
player_start_download: "Start download" , "Начать загрузку";
|
player_start_download: "Start download" , "Начать загрузку";
|
||||||
player_retry_failed: "Retry failed" , "Повторить ошибки";
|
player_retry_failed: "Retry failed" , "Повторить ошибки";
|
||||||
@@ -533,6 +540,7 @@ translations! {
|
|||||||
player_download_selected: "Download selected" , "Скачать выбранное";
|
player_download_selected: "Download selected" , "Скачать выбранное";
|
||||||
player_pause_download: "Pause download" , "Поставить на паузу";
|
player_pause_download: "Pause download" , "Поставить на паузу";
|
||||||
player_expand_all: "Expand all" , "Развернуть всё";
|
player_expand_all: "Expand all" , "Развернуть всё";
|
||||||
|
player_expand: "Expand" , "Развернуть";
|
||||||
player_collapse: "Collapse" , "Свернуть";
|
player_collapse: "Collapse" , "Свернуть";
|
||||||
player_selected: "selected" , "выбрано";
|
player_selected: "selected" , "выбрано";
|
||||||
player_preview: "Preview" , "Предпросмотр";
|
player_preview: "Preview" , "Предпросмотр";
|
||||||
|
|||||||
@@ -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!(
|
tracing::info!(
|
||||||
track_id = track.id_val(),
|
track_id = track.id_val(),
|
||||||
artist = artist_name,
|
artist = artist_name,
|
||||||
|
|||||||
+274
-6
@@ -244,6 +244,8 @@ pub struct YouTubePreviewRequest {
|
|||||||
pub struct YouTubeStartRequest {
|
pub struct YouTubeStartRequest {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub selected_source_ids: Vec<String>,
|
pub selected_source_ids: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub target_playlist_id: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -289,6 +291,7 @@ pub struct YouTubeJobDto {
|
|||||||
pub source_url: String,
|
pub source_url: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub source_kind: String,
|
pub source_kind: String,
|
||||||
|
pub target_playlist_id: Option<i64>,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub total_items: i32,
|
pub total_items: i32,
|
||||||
pub completed_items: i32,
|
pub completed_items: i32,
|
||||||
@@ -308,6 +311,7 @@ struct YouTubeJobRow {
|
|||||||
source_url: String,
|
source_url: String,
|
||||||
title: String,
|
title: String,
|
||||||
source_kind: String,
|
source_kind: String,
|
||||||
|
target_playlist_id: Option<i64>,
|
||||||
status: String,
|
status: String,
|
||||||
total_items: i32,
|
total_items: i32,
|
||||||
completed_items: i32,
|
completed_items: i32,
|
||||||
@@ -373,6 +377,7 @@ impl YouTubeJobRow {
|
|||||||
source_url: self.source_url.clone(),
|
source_url: self.source_url.clone(),
|
||||||
title: self.title.clone(),
|
title: self.title.clone(),
|
||||||
source_kind: self.source_kind.clone(),
|
source_kind: self.source_kind.clone(),
|
||||||
|
target_playlist_id: self.target_playlist_id,
|
||||||
status: self.status.clone(),
|
status: self.status.clone(),
|
||||||
total_items: self.total_items,
|
total_items: self.total_items,
|
||||||
completed_items: self.completed_items,
|
completed_items: self.completed_items,
|
||||||
@@ -561,19 +566,37 @@ impl YouTubeService {
|
|||||||
.collect();
|
.collect();
|
||||||
let already_imported = already_imported_source_ids(pool, user_id, &source_ids).await?;
|
let already_imported = already_imported_source_ids(pool, user_id, &source_ids).await?;
|
||||||
let mut transaction = pool.begin().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<i64> = 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(
|
sqlx::query(
|
||||||
r#"INSERT INTO furumusic__youtube_download
|
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,
|
total_items, completed_items, failed_items, review_items,
|
||||||
error, created_at, updated_at, completed_at)
|
error, created_at, updated_at, completed_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, 'queued', $6, 0, 0, 0,
|
VALUES ($1, $2, $3, $4, $5, $6, 'queued', $7, 0, 0, 0,
|
||||||
NULL, $7, $7, NULL)"#,
|
NULL, $8, $8, NULL)"#,
|
||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.bind(&url)
|
.bind(&url)
|
||||||
.bind(&resolved.title)
|
.bind(&resolved.title)
|
||||||
.bind(&resolved.kind)
|
.bind(&resolved.kind)
|
||||||
|
.bind(request.target_playlist_id)
|
||||||
.bind(i32::try_from(selected_items.len()).unwrap_or(i32::MAX))
|
.bind(i32::try_from(selected_items.len()).unwrap_or(i32::MAX))
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
.execute(&mut *transaction)
|
.execute(&mut *transaction)
|
||||||
@@ -614,6 +637,17 @@ impl YouTubeService {
|
|||||||
}
|
}
|
||||||
transaction.commit().await?;
|
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(
|
self.spawn_job(
|
||||||
pool.clone(),
|
pool.clone(),
|
||||||
id.clone(),
|
id.clone(),
|
||||||
@@ -880,7 +914,8 @@ impl YouTubeService {
|
|||||||
}
|
}
|
||||||
let inbox_root = validate_inbox_dir(inbox_dir)?;
|
let inbox_root = validate_inbox_dir(inbox_dir)?;
|
||||||
let job: YouTubeJobRow = sqlx::query_as(
|
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,
|
total_items, completed_items, failed_items, review_items,
|
||||||
error, created_at, updated_at, completed_at
|
error, created_at, updated_at, completed_at
|
||||||
FROM furumusic__youtube_download WHERE id = $1"#,
|
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 {
|
for job_id in touched_jobs {
|
||||||
refresh_parent(pool, &job_id).await?;
|
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(())
|
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<i64> = 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<bool> {
|
||||||
|
let mut transaction = pool.begin().await?;
|
||||||
|
let playlist: Option<i64> = 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<()> {
|
async fn refresh_parent(pool: &PgPool, job_id: &str) -> anyhow::Result<()> {
|
||||||
let parent_status: Option<String> =
|
let parent_status: Option<String> =
|
||||||
sqlx::query_scalar("SELECT status::text FROM furumusic__youtube_download WHERE id = $1")
|
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<Y
|
|||||||
|
|
||||||
async fn load_job_row(pool: &PgPool, user_id: i64, id: &str) -> anyhow::Result<YouTubeJobRow> {
|
async fn load_job_row(pool: &PgPool, user_id: i64, id: &str) -> anyhow::Result<YouTubeJobRow> {
|
||||||
sqlx::query_as(
|
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,
|
total_items, completed_items, failed_items, review_items,
|
||||||
error, created_at, updated_at, completed_at
|
error, created_at, updated_at, completed_at
|
||||||
FROM furumusic__youtube_download WHERE id = $1 AND user_id = $2"#,
|
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()];
|
&[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)]
|
#[cfg(test)]
|
||||||
@@ -2433,6 +2683,24 @@ mod tests {
|
|||||||
assert!(validate_youtube_url("https://youtu.be/abc").is_ok());
|
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]
|
#[test]
|
||||||
fn rejects_non_youtube_and_lookalike_hosts() {
|
fn rejects_non_youtube_and_lookalike_hosts() {
|
||||||
assert!(validate_youtube_url("https://example.com/video").is_err());
|
assert!(validate_youtube_url("https://example.com/video").is_err());
|
||||||
|
|||||||
@@ -88,7 +88,8 @@
|
|||||||
<!-- Download Manager Modal -->
|
<!-- Download Manager Modal -->
|
||||||
<template x-if="$store.torrents.modal">
|
<template x-if="$store.torrents.modal">
|
||||||
<div class="modal-overlay" @click.self="$store.torrents.close()">
|
<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 class="torrent-modal-head">
|
||||||
<div>
|
<div>
|
||||||
<h3>{{ t.player_torrent_manager }}</h3>
|
<h3>{{ t.player_torrent_manager }}</h3>
|
||||||
@@ -205,6 +206,29 @@
|
|||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</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">
|
<div class="youtube-preview-footer">
|
||||||
<span x-text="$store.torrents.youtubePreviewSelectedCount() + ' ' + T.youtubeSelectedCount"></span>
|
<span x-text="$store.torrents.youtubePreviewSelectedCount() + ' ' + T.youtubeSelectedCount"></span>
|
||||||
<div>
|
<div>
|
||||||
@@ -212,7 +236,7 @@
|
|||||||
@click="$store.torrents.clearYoutubePreview()">{{ t.player_cancel }}</button>
|
@click="$store.torrents.clearYoutubePreview()">{{ t.player_cancel }}</button>
|
||||||
<button type="button" class="modal-btn modal-btn-primary"
|
<button type="button" class="modal-btn modal-btn-primary"
|
||||||
@click="$store.torrents.startYoutubeDownload()"
|
@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 }}
|
{{ t.player_youtube_start_import }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -235,16 +259,29 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template x-for="job in $store.torrents.youtubeJobs" :key="job.id">
|
<template x-for="job in $store.torrents.youtubeJobs" :key="job.id">
|
||||||
<article class="youtube-job-card">
|
<article class="youtube-job-card"
|
||||||
<div class="youtube-job-head">
|
: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-heading">
|
||||||
<div class="youtube-job-title" x-text="job.title"></div>
|
<div class="youtube-job-title" x-text="job.title"></div>
|
||||||
<div class="youtube-job-meta" x-text="$store.torrents.youtubeJobMeta(job)"></div>
|
<div class="youtube-job-meta" x-text="$store.torrents.youtubeJobMeta(job)"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="youtube-job-compact-progress"
|
||||||
|
x-text="$store.torrents.youtubeJobProgress(job) + '%'">
|
||||||
|
</span>
|
||||||
<span class="torrent-status-badge"
|
<span class="torrent-status-badge"
|
||||||
:class="$store.torrents.youtubeStatusClass(job.status)"
|
:class="$store.torrents.youtubeStatusClass(job.status)"
|
||||||
x-text="$store.torrents.youtubeStatusLabel(job.status)"></span>
|
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="youtube-job-progress">
|
||||||
<div class="torrent-session-progress">
|
<div class="torrent-session-progress">
|
||||||
@@ -316,6 +353,7 @@
|
|||||||
x-show="$store.torrents.youtubeJobTerminal(job.status)"
|
x-show="$store.torrents.youtubeJobTerminal(job.status)"
|
||||||
@click="$store.torrents.removeYoutubeJob(job.id)">{{ t.player_remove_from_history }}</button>
|
@click="$store.torrents.removeYoutubeJob(job.id)">{{ t.player_remove_from_history }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ const T = {
|
|||||||
youtubeSelectAll: "{{ t.player_youtube_select_all }}",
|
youtubeSelectAll: "{{ t.player_youtube_select_all }}",
|
||||||
youtubeClearSelection: "{{ t.player_youtube_clear_selection }}",
|
youtubeClearSelection: "{{ t.player_youtube_clear_selection }}",
|
||||||
youtubeSelectedCount: "{{ t.player_youtube_selected_count }}",
|
youtubeSelectedCount: "{{ t.player_youtube_selected_count }}",
|
||||||
|
youtubePlaylistCreateFailed: "{{ t.player_youtube_playlist_create_failed }}",
|
||||||
|
youtubeAddedTo: "{{ t.player_youtube_added_to }}",
|
||||||
youtubeCancelled: "{{ t.player_youtube_cancelled }}",
|
youtubeCancelled: "{{ t.player_youtube_cancelled }}",
|
||||||
youtubeStopConfirm: "{{ t.player_youtube_stop_confirm }}",
|
youtubeStopConfirm: "{{ t.player_youtube_stop_confirm }}",
|
||||||
youtubeStopping: "{{ t.player_youtube_stopping }}",
|
youtubeStopping: "{{ t.player_youtube_stopping }}",
|
||||||
@@ -181,6 +183,8 @@ const T = {
|
|||||||
liveReleases: "{{ t.player_live_releases }}",
|
liveReleases: "{{ t.player_live_releases }}",
|
||||||
soundtracks: "{{ t.player_soundtracks }}",
|
soundtracks: "{{ t.player_soundtracks }}",
|
||||||
likesPlaylist: "{{ t.player_likes_playlist }}",
|
likesPlaylist: "{{ t.player_likes_playlist }}",
|
||||||
|
expand: "{{ t.player_expand }}",
|
||||||
|
collapse: "{{ t.player_collapse }}",
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatTime(seconds) {
|
function formatTime(seconds) {
|
||||||
@@ -4611,8 +4615,13 @@ document.addEventListener('alpine:init', () => {
|
|||||||
youtubeUrl: '',
|
youtubeUrl: '',
|
||||||
youtubePreview: null,
|
youtubePreview: null,
|
||||||
youtubePreviewSelected: new Set(),
|
youtubePreviewSelected: new Set(),
|
||||||
|
youtubePlaylistChoice: '',
|
||||||
|
youtubeNewPlaylistTitle: '',
|
||||||
|
youtubePlaylistSyncKey: '',
|
||||||
youtubePreviewLoading: false,
|
youtubePreviewLoading: false,
|
||||||
youtubeJobs: [],
|
youtubeJobs: [],
|
||||||
|
youtubeExpandedJobId: null,
|
||||||
|
youtubeJobsInitialized: false,
|
||||||
youtubeLoading: false,
|
youtubeLoading: false,
|
||||||
youtubeSubmitting: false,
|
youtubeSubmitting: false,
|
||||||
youtubeCancellingIds: new Set(),
|
youtubeCancellingIds: new Set(),
|
||||||
@@ -4728,6 +4737,31 @@ document.addEventListener('alpine:init', () => {
|
|||||||
const data = await res.json().catch(() => null);
|
const data = await res.json().catch(() => null);
|
||||||
if (!res.ok) throw new Error(data?.error || T.youtubeLoadFailed);
|
if (!res.ok) throw new Error(data?.error || T.youtubeLoadFailed);
|
||||||
this.youtubeJobs = Array.isArray(data) ? data : [];
|
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) {
|
} catch (err) {
|
||||||
if (!silent) this._setMessage(err.message || T.youtubeLoadFailed, true);
|
if (!silent) this._setMessage(err.message || T.youtubeLoadFailed, true);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -4738,6 +4772,8 @@ document.addEventListener('alpine:init', () => {
|
|||||||
clearYoutubePreview() {
|
clearYoutubePreview() {
|
||||||
this.youtubePreview = null;
|
this.youtubePreview = null;
|
||||||
this.youtubePreviewSelected = new Set();
|
this.youtubePreviewSelected = new Set();
|
||||||
|
this.youtubePlaylistChoice = '';
|
||||||
|
this.youtubeNewPlaylistTitle = '';
|
||||||
},
|
},
|
||||||
|
|
||||||
async previewYoutubeUrl() {
|
async previewYoutubeUrl() {
|
||||||
@@ -4761,6 +4797,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.youtubePreviewSelected = new Set(
|
this.youtubePreviewSelected = new Set(
|
||||||
items.filter(item => item.selected_by_default).map(item => item.source_id)
|
items.filter(item => item.selected_by_default).map(item => item.source_id)
|
||||||
);
|
);
|
||||||
|
this.youtubeExpandedJobId = null;
|
||||||
this._setMessage('');
|
this._setMessage('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._setMessage(err.message || T.youtubePreviewFailed, true);
|
this._setMessage(err.message || T.youtubePreviewFailed, true);
|
||||||
@@ -4793,24 +4830,90 @@ document.addEventListener('alpine:init', () => {
|
|||||||
return this.youtubePreviewSelected.size;
|
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() {
|
async startYoutubeDownload() {
|
||||||
const preview = this.youtubePreview;
|
const preview = this.youtubePreview;
|
||||||
const selectedSourceIds = Array.from(this.youtubePreviewSelected);
|
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.youtubeSubmitting = true;
|
||||||
this._setMessage(T.youtubeStarting);
|
this._setMessage(T.youtubeStarting);
|
||||||
try {
|
try {
|
||||||
|
const targetPlaylistId = await this.resolveYoutubeTargetPlaylist();
|
||||||
const res = await fetch('/api/player/youtube/start', {
|
const res = await fetch('/api/player/youtube/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
url: preview.source_url,
|
url: preview.source_url,
|
||||||
selected_source_ids: selectedSourceIds,
|
selected_source_ids: selectedSourceIds,
|
||||||
|
target_playlist_id: targetPlaylistId,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => null);
|
const data = await res.json().catch(() => null);
|
||||||
if (!res.ok) throw new Error(data?.error || T.youtubeStartFailed);
|
if (!res.ok) throw new Error(data?.error || T.youtubeStartFailed);
|
||||||
this.youtubeJobs = [data, ...this.youtubeJobs.filter(job => job.id !== data.id)];
|
this.youtubeJobs = [data, ...this.youtubeJobs.filter(job => job.id !== data.id)];
|
||||||
|
this.youtubeExpandedJobId = data.id;
|
||||||
this.youtubeUrl = '';
|
this.youtubeUrl = '';
|
||||||
this.clearYoutubePreview();
|
this.clearYoutubePreview();
|
||||||
this._setMessage(T.youtubeStarted);
|
this._setMessage(T.youtubeStarted);
|
||||||
@@ -4942,11 +5045,21 @@ document.addEventListener('alpine:init', () => {
|
|||||||
return ['queued', 'resolving', 'downloading', 'postprocessing'].includes(String(status || '').toLowerCase());
|
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) {
|
youtubeJobMeta(job) {
|
||||||
const kind = job.source_kind === 'playlist' ? T.youtubePlaylist : T.youtubeVideo;
|
const kind = job.source_kind === 'playlist' ? T.youtubePlaylist : T.youtubeVideo;
|
||||||
const parts = [kind];
|
const parts = [kind];
|
||||||
if (Number(job.total_items || 0) > 0) parts.push(Number(job.total_items) + ' ' + T.youtubeItems);
|
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);
|
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(' · ');
|
return parts.join(' · ');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -3291,6 +3291,11 @@ button.user-stat:hover {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.torrent-modal.youtube-mode {
|
||||||
|
width: min(1440px, calc(100vw - 32px));
|
||||||
|
max-width: 1440px;
|
||||||
|
}
|
||||||
|
|
||||||
.torrent-modal-head {
|
.torrent-modal-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -3613,8 +3618,8 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.youtube-preview-card {
|
.youtube-preview-card {
|
||||||
min-height: 0;
|
min-height: 370px;
|
||||||
flex: 0 1 360px;
|
flex: 1 1 520px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -3626,6 +3631,7 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.youtube-preview-head,
|
.youtube-preview-head,
|
||||||
.youtube-preview-footer {
|
.youtube-preview-footer {
|
||||||
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -3657,13 +3663,55 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.youtube-preview-list {
|
.youtube-preview-list {
|
||||||
min-height: 72px;
|
min-height: 190px;
|
||||||
|
flex: 1 1 260px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
background: var(--bg-secondary);
|
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 {
|
.youtube-preview-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 30px minmax(0, 1fr);
|
grid-template-columns: auto 30px minmax(0, 1fr);
|
||||||
@@ -3722,6 +3770,7 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.youtube-download-list {
|
.youtube-download-list {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
flex: 1 1 180px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -3737,10 +3786,63 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.youtube-job-card {
|
.youtube-job-card {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 13px;
|
padding: 0;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
background: var(--bg-primary);
|
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,
|
.youtube-job-head,
|
||||||
@@ -3781,7 +3883,7 @@ button.user-stat:hover {
|
|||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: 9px;
|
margin-top: 0;
|
||||||
color: var(--text-subdued);
|
color: var(--text-subdued);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
@@ -6462,6 +6564,11 @@ button.user-stat:hover {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.torrent-modal.youtube-mode {
|
||||||
|
width: 100vw;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
.torrent-modal-head {
|
.torrent-modal-head {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
@@ -6533,7 +6640,8 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.youtube-preview-card {
|
.youtube-preview-card {
|
||||||
flex-basis: auto;
|
flex-basis: auto;
|
||||||
max-height: 330px;
|
min-height: 420px;
|
||||||
|
max-height: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.youtube-preview-head,
|
.youtube-preview-head,
|
||||||
@@ -6546,6 +6654,32 @@ button.user-stat:hover {
|
|||||||
flex-wrap: wrap;
|
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 {
|
.youtube-download-list {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user