Added user attribution
This commit is contained in:
Generated
+1
-1
@@ -1397,7 +1397,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.1.6"
|
version = "0.1.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
@@ -360,6 +360,8 @@ pub async fn save_cover_to_storage(
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
|
Some("UFO"),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("failed to create cover MediaFile: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("failed to create cover MediaFile: {e}"))?;
|
||||||
|
|||||||
@@ -140,7 +140,9 @@ impl Job for InboxDiscoverJob {
|
|||||||
|
|
||||||
// Parse path hints
|
// Parse path hints
|
||||||
let relative = file_path.strip_prefix(inbox).unwrap_or(file_path);
|
let relative = file_path.strip_prefix(inbox).unwrap_or(file_path);
|
||||||
let hints = crate::agent::path_hints::parse(relative);
|
let uploader = crate::jobs::uploader_from_relative_path(&ctx.pool, relative).await;
|
||||||
|
let hinted_relative = crate::jobs::strip_user_upload_prefix(relative);
|
||||||
|
let hints = crate::agent::path_hints::parse(&hinted_relative);
|
||||||
|
|
||||||
// Build context JSON
|
// Build context JSON
|
||||||
let context = serde_json::json!({
|
let context = serde_json::json!({
|
||||||
@@ -156,6 +158,8 @@ impl Job for InboxDiscoverJob {
|
|||||||
"audio_bitrate": raw_meta.audio_bitrate,
|
"audio_bitrate": raw_meta.audio_bitrate,
|
||||||
"audio_sample_rate": raw_meta.audio_sample_rate,
|
"audio_sample_rate": raw_meta.audio_sample_rate,
|
||||||
"audio_bit_depth": raw_meta.audio_bit_depth,
|
"audio_bit_depth": raw_meta.audio_bit_depth,
|
||||||
|
"uploaded_by_user_id": uploader.user_id,
|
||||||
|
"uploader_name": uploader.name,
|
||||||
"path_title": hints.title,
|
"path_title": hints.title,
|
||||||
"path_artist": hints.artist,
|
"path_artist": hints.artist,
|
||||||
"path_album": hints.album,
|
"path_album": hints.album,
|
||||||
|
|||||||
@@ -337,7 +337,9 @@ async fn process_folder_batch(
|
|||||||
|
|
||||||
// Parse path hints
|
// Parse path hints
|
||||||
let relative = file_path.strip_prefix(inbox_path).unwrap_or(file_path);
|
let relative = file_path.strip_prefix(inbox_path).unwrap_or(file_path);
|
||||||
let hints = crate::agent::path_hints::parse(relative);
|
let uploader = crate::jobs::uploader_from_relative_path(pool, relative).await;
|
||||||
|
let hinted_relative = crate::jobs::strip_user_upload_prefix(relative);
|
||||||
|
let hints = crate::agent::path_hints::parse(&hinted_relative);
|
||||||
if let Some(context_obj) = context.as_object_mut() {
|
if let Some(context_obj) = context.as_object_mut() {
|
||||||
context_obj.insert(
|
context_obj.insert(
|
||||||
"audio_bitrate".to_owned(),
|
"audio_bitrate".to_owned(),
|
||||||
@@ -351,6 +353,15 @@ async fn process_folder_batch(
|
|||||||
"audio_bit_depth".to_owned(),
|
"audio_bit_depth".to_owned(),
|
||||||
serde_json::json!(raw_meta.audio_bit_depth),
|
serde_json::json!(raw_meta.audio_bit_depth),
|
||||||
);
|
);
|
||||||
|
if !context_obj.contains_key("uploaded_by_user_id") {
|
||||||
|
context_obj.insert(
|
||||||
|
"uploaded_by_user_id".to_owned(),
|
||||||
|
serde_json::json!(uploader.user_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !context_obj.contains_key("uploader_name") {
|
||||||
|
context_obj.insert("uploader_name".to_owned(), serde_json::json!(uploader.name));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
prepared.push(PreparedFile {
|
prepared.push(PreparedFile {
|
||||||
@@ -737,6 +748,12 @@ pub async fn finalize_approved(
|
|||||||
.get("audio_bit_depth")
|
.get("audio_bit_depth")
|
||||||
.and_then(|v| v.as_i64())
|
.and_then(|v| v.as_i64())
|
||||||
.and_then(|v| i32::try_from(v).ok());
|
.and_then(|v| i32::try_from(v).ok());
|
||||||
|
let uploaded_by_user_id = context.get("uploaded_by_user_id").and_then(|v| v.as_i64());
|
||||||
|
let uploader_name = context
|
||||||
|
.get("uploader_name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or("UFO");
|
||||||
|
|
||||||
let source_path = Path::new(input_path_str);
|
let source_path = Path::new(input_path_str);
|
||||||
let original_filename = source_path
|
let original_filename = source_path
|
||||||
@@ -805,6 +822,8 @@ pub async fn finalize_approved(
|
|||||||
audio_bitrate,
|
audio_bitrate,
|
||||||
audio_sample_rate,
|
audio_sample_rate,
|
||||||
audio_bit_depth,
|
audio_bit_depth,
|
||||||
|
uploaded_by_user_id,
|
||||||
|
Some(uploader_name),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("failed to create media file: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("failed to create media file: {e}"))?;
|
||||||
|
|||||||
@@ -4,3 +4,73 @@ pub mod cover_backfill;
|
|||||||
pub mod inbox_discover;
|
pub mod inbox_discover;
|
||||||
pub mod inbox_process;
|
pub mod inbox_process;
|
||||||
pub mod metadata_backfill;
|
pub mod metadata_backfill;
|
||||||
|
|
||||||
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct UploaderAttribution {
|
||||||
|
pub user_id: Option<i64>,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UploaderAttribution {
|
||||||
|
pub fn unknown() -> Self {
|
||||||
|
Self {
|
||||||
|
user_id: None,
|
||||||
|
name: "UFO".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn strip_user_upload_prefix(relative_path: &Path) -> PathBuf {
|
||||||
|
let components: Vec<_> = relative_path.components().collect();
|
||||||
|
if components.len() >= 3
|
||||||
|
&& matches!(components[0], Component::Normal(value) if value == "user_uploads")
|
||||||
|
{
|
||||||
|
components[2..].iter().collect()
|
||||||
|
} else {
|
||||||
|
relative_path.to_path_buf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn uploader_from_relative_path(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
relative_path: &Path,
|
||||||
|
) -> UploaderAttribution {
|
||||||
|
let components: Vec<_> = relative_path.components().collect();
|
||||||
|
let Some(Component::Normal(root)) = components.first() else {
|
||||||
|
return UploaderAttribution::unknown();
|
||||||
|
};
|
||||||
|
if *root != "user_uploads" {
|
||||||
|
return UploaderAttribution::unknown();
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(Component::Normal(user_id_os)) = components.get(1) else {
|
||||||
|
return UploaderAttribution::unknown();
|
||||||
|
};
|
||||||
|
let Some(user_id_str) = user_id_os.to_str() else {
|
||||||
|
return UploaderAttribution::unknown();
|
||||||
|
};
|
||||||
|
let Ok(user_id) = user_id_str.parse::<i64>() else {
|
||||||
|
return UploaderAttribution::unknown();
|
||||||
|
};
|
||||||
|
|
||||||
|
let name: Option<String> = sqlx::query_scalar(
|
||||||
|
r#"SELECT COALESCE(NULLIF(display_name, ''), username)::text
|
||||||
|
FROM furumusic__user
|
||||||
|
WHERE id = $1 AND is_active = true"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
match name {
|
||||||
|
Some(name) if !name.trim().is_empty() => UploaderAttribution {
|
||||||
|
user_id: Some(user_id),
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
_ => UploaderAttribution::unknown(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ pub struct MediaFile {
|
|||||||
pub audio_sample_rate: Option<i32>,
|
pub audio_sample_rate: Option<i32>,
|
||||||
/// Bit depth (16, 24, 32)
|
/// Bit depth (16, 24, 32)
|
||||||
pub audio_bit_depth: Option<i32>,
|
pub audio_bit_depth: Option<i32>,
|
||||||
|
/// FK -> user who imported/uploaded the source, NULL when unknown.
|
||||||
|
pub uploaded_by_user_id: Option<i64>,
|
||||||
|
/// Stable display label for the uploader. Unknown uploads are stored as "UFO".
|
||||||
|
pub uploader_name: LimitedString<255>,
|
||||||
pub created_at: LimitedString<32>,
|
pub created_at: LimitedString<32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,8 +611,13 @@ impl MediaFile {
|
|||||||
audio_bitrate: Option<i32>,
|
audio_bitrate: Option<i32>,
|
||||||
audio_sample_rate: Option<i32>,
|
audio_sample_rate: Option<i32>,
|
||||||
audio_bit_depth: Option<i32>,
|
audio_bit_depth: Option<i32>,
|
||||||
|
uploaded_by_user_id: Option<i64>,
|
||||||
|
uploader_name: Option<&str>,
|
||||||
) -> cot::db::Result<Self> {
|
) -> cot::db::Result<Self> {
|
||||||
let now = now_iso();
|
let now = now_iso();
|
||||||
|
let uploader_name = uploader_name
|
||||||
|
.filter(|name| !name.trim().is_empty())
|
||||||
|
.unwrap_or("UFO");
|
||||||
let mut mf = Self {
|
let mut mf = Self {
|
||||||
id: Auto::auto(),
|
id: Auto::auto(),
|
||||||
file_type: LimitedString::new(file_type).unwrap(),
|
file_type: LimitedString::new(file_type).unwrap(),
|
||||||
@@ -621,6 +630,8 @@ impl MediaFile {
|
|||||||
audio_bitrate,
|
audio_bitrate,
|
||||||
audio_sample_rate,
|
audio_sample_rate,
|
||||||
audio_bit_depth,
|
audio_bit_depth,
|
||||||
|
uploaded_by_user_id,
|
||||||
|
uploader_name: LimitedString::new(uploader_name).unwrap(),
|
||||||
created_at: now,
|
created_at: now,
|
||||||
};
|
};
|
||||||
mf.insert(db).await?;
|
mf.insert(db).await?;
|
||||||
@@ -1533,6 +1544,40 @@ pub mod db_migrations {
|
|||||||
const OPERATIONS: &'static [Operation] = &[Operation::custom(add_playback_volume).build()];
|
const OPERATIONS: &'static [Operation] = &[Operation::custom(add_playback_volume).build()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- M0030: add uploader attribution to media_file ------------------------
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn add_media_file_uploader(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw("ALTER TABLE furumusic__media_file ADD COLUMN uploaded_by_user_id BIGINT DEFAULT NULL")
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw("ALTER TABLE furumusic__media_file ADD COLUMN uploader_name VARCHAR(255) NOT NULL DEFAULT 'UFO'")
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw("CREATE INDEX IF NOT EXISTS idx_media_file_uploaded_by_user ON furumusic__media_file (uploaded_by_user_id)")
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw("CREATE INDEX IF NOT EXISTS idx_media_file_uploader_name ON furumusic__media_file (uploader_name)")
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0030AddMediaFileUploader;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0030AddMediaFileUploader {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0030_add_media_file_uploader";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0029_add_playback_volume",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(add_media_file_uploader).build()];
|
||||||
|
}
|
||||||
|
|
||||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||||
&M0006CreateMediaFile,
|
&M0006CreateMediaFile,
|
||||||
&M0007CreateArtist,
|
&M0007CreateArtist,
|
||||||
@@ -1553,5 +1598,6 @@ pub mod db_migrations {
|
|||||||
&M0022CreateTrackTrgmIndex,
|
&M0022CreateTrackTrgmIndex,
|
||||||
&M0028AddModelNameColumns,
|
&M0028AddModelNameColumns,
|
||||||
&M0029AddPlaybackVolume,
|
&M0029AddPlaybackVolume,
|
||||||
|
&M0030AddMediaFileUploader,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct ArtistCard {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) name: String,
|
||||||
|
pub(super) image_url: Option<String>,
|
||||||
|
pub(super) release_count: i64,
|
||||||
|
pub(super) track_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct Paginated<T: Serialize> {
|
||||||
|
pub(super) items: Vec<T>,
|
||||||
|
pub(super) total: i64,
|
||||||
|
pub(super) page: i32,
|
||||||
|
pub(super) per_page: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct ReleaseCard {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) title: String,
|
||||||
|
pub(super) release_type: String,
|
||||||
|
pub(super) year: Option<i32>,
|
||||||
|
pub(super) cover_url: Option<String>,
|
||||||
|
pub(super) track_count: i64,
|
||||||
|
pub(super) uploaders: Vec<UploaderSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct ArtistDetail {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) name: String,
|
||||||
|
pub(super) image_url: Option<String>,
|
||||||
|
pub(super) total_track_count: i64,
|
||||||
|
pub(super) total_play_count: i64,
|
||||||
|
pub(super) releases: Vec<ReleaseCard>,
|
||||||
|
pub(super) featured_tracks: Vec<ArtistAppearanceTrack>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct ArtistRef {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct TrackItem {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) title: String,
|
||||||
|
pub(super) track_number: Option<i32>,
|
||||||
|
pub(super) disc_number: Option<i32>,
|
||||||
|
pub(super) duration_seconds: f64,
|
||||||
|
pub(super) artists: Vec<ArtistRef>,
|
||||||
|
pub(super) featured_artists: Vec<ArtistRef>,
|
||||||
|
pub(super) cover_url: Option<String>,
|
||||||
|
pub(super) stream_url: String,
|
||||||
|
pub(super) uploader_name: String,
|
||||||
|
pub(super) audio_format: Option<String>,
|
||||||
|
pub(super) audio_bitrate: Option<i32>,
|
||||||
|
pub(super) audio_sample_rate: Option<i32>,
|
||||||
|
pub(super) audio_bit_depth: Option<i32>,
|
||||||
|
pub(super) file_size_bytes: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct ArtistAppearanceTrack {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) title: String,
|
||||||
|
pub(super) release_id: i64,
|
||||||
|
pub(super) release_title: String,
|
||||||
|
pub(super) duration_seconds: f64,
|
||||||
|
pub(super) artists: Vec<ArtistRef>,
|
||||||
|
pub(super) featured_artists: Vec<ArtistRef>,
|
||||||
|
pub(super) cover_url: Option<String>,
|
||||||
|
pub(super) stream_url: String,
|
||||||
|
pub(super) uploader_name: String,
|
||||||
|
pub(super) audio_format: Option<String>,
|
||||||
|
pub(super) audio_bitrate: Option<i32>,
|
||||||
|
pub(super) audio_sample_rate: Option<i32>,
|
||||||
|
pub(super) audio_bit_depth: Option<i32>,
|
||||||
|
pub(super) file_size_bytes: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct ReleaseDetail {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) title: String,
|
||||||
|
pub(super) release_type: String,
|
||||||
|
pub(super) year: Option<i32>,
|
||||||
|
pub(super) cover_url: Option<String>,
|
||||||
|
pub(super) artists: Vec<ArtistRef>,
|
||||||
|
pub(super) tracks: Vec<TrackItem>,
|
||||||
|
pub(super) uploaders: Vec<UploaderSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct UploaderSummary {
|
||||||
|
pub(super) name: String,
|
||||||
|
pub(super) track_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct PlaylistCard {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) title: String,
|
||||||
|
pub(super) track_count: i64,
|
||||||
|
pub(super) is_own: bool,
|
||||||
|
pub(super) kind: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub(super) struct PlaybackStateDto {
|
||||||
|
pub(super) current_track_id: Option<i64>,
|
||||||
|
pub(super) position_ms: i32,
|
||||||
|
pub(super) queue: Vec<i64>,
|
||||||
|
pub(super) queue_position: i32,
|
||||||
|
pub(super) shuffle: bool,
|
||||||
|
pub(super) repeat_mode: String,
|
||||||
|
pub(super) volume: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct PlaylistDetail {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) title: String,
|
||||||
|
pub(super) description: Option<String>,
|
||||||
|
pub(super) is_own: bool,
|
||||||
|
pub(super) kind: String,
|
||||||
|
pub(super) tracks: Vec<TrackItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct SearchResults {
|
||||||
|
pub(super) artists: Vec<ArtistCard>,
|
||||||
|
pub(super) releases: Vec<ReleaseCard>,
|
||||||
|
pub(super) tracks: Vec<TrackItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct UserStats {
|
||||||
|
pub(super) liked_tracks: i64,
|
||||||
|
pub(super) playlists: i64,
|
||||||
|
pub(super) plays: i64,
|
||||||
|
pub(super) listened_minutes: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct UserProfile {
|
||||||
|
pub(super) name: String,
|
||||||
|
pub(super) role: String,
|
||||||
|
pub(super) stats: UserStats,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct PlayHistoryItem {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) track_id: i64,
|
||||||
|
pub(super) track_title: String,
|
||||||
|
pub(super) release_title: Option<String>,
|
||||||
|
pub(super) played_at: String,
|
||||||
|
pub(super) duration_listened: Option<i32>,
|
||||||
|
pub(super) completed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct PlayHistoryPage {
|
||||||
|
pub(super) items: Vec<PlayHistoryItem>,
|
||||||
|
pub(super) total: i64,
|
||||||
|
pub(super) page: i32,
|
||||||
|
pub(super) per_page: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct LikeStatus {
|
||||||
|
pub(super) liked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct LikedIds {
|
||||||
|
pub(super) track_ids: Vec<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct FollowStatus {
|
||||||
|
pub(super) followed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct FollowedArtists {
|
||||||
|
pub(super) artist_ids: Vec<i64>,
|
||||||
|
pub(super) artists: Vec<ArtistCard>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
use crate::player::dto::UploaderSummary;
|
||||||
|
use crate::player::rows::ReleaseUploaderRow;
|
||||||
|
|
||||||
|
pub(super) fn cover_url(file_id: Option<i64>) -> Option<String> {
|
||||||
|
file_id.map(|id| format!("/api/player/cover/{id}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn track_cover_url(
|
||||||
|
track_cover: Option<i64>,
|
||||||
|
release_cover: Option<i64>,
|
||||||
|
) -> Option<String> {
|
||||||
|
cover_url(track_cover.or(release_cover))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn load_release_uploaders(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
release_ids: &[i64],
|
||||||
|
) -> Result<std::collections::HashMap<i64, Vec<UploaderSummary>>, sqlx::Error> {
|
||||||
|
if release_ids.is_empty() {
|
||||||
|
return Ok(std::collections::HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, ReleaseUploaderRow>(
|
||||||
|
r#"SELECT t.release_id,
|
||||||
|
COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name,
|
||||||
|
COUNT(*)::bigint AS track_count
|
||||||
|
FROM furumusic__track t
|
||||||
|
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
||||||
|
WHERE t.release_id = ANY($1) AND t.is_hidden = false
|
||||||
|
GROUP BY t.release_id, COALESCE(mf.uploader_name, 'UFO')
|
||||||
|
ORDER BY t.release_id, track_count DESC, uploader_name"#,
|
||||||
|
)
|
||||||
|
.bind(release_ids)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut map: std::collections::HashMap<i64, Vec<UploaderSummary>> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
for row in rows {
|
||||||
|
map.entry(row.release_id)
|
||||||
|
.or_default()
|
||||||
|
.push(UploaderSummary {
|
||||||
|
name: row.uploader_name,
|
||||||
|
track_count: row.track_count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(map)
|
||||||
|
}
|
||||||
+122
-437
@@ -10,8 +10,6 @@ use cot::router::method::{get, post};
|
|||||||
use cot::router::{Route, Router};
|
use cot::router::{Route, Router};
|
||||||
use cot::session::Session;
|
use cot::session::Session;
|
||||||
use cot::{App, Body, Template};
|
use cot::{App, Body, Template};
|
||||||
use schemars::JsonSchema;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::auth;
|
use crate::auth;
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
@@ -19,6 +17,16 @@ use crate::i18n::Translations;
|
|||||||
use crate::scheduler::SchedulerHandle;
|
use crate::scheduler::SchedulerHandle;
|
||||||
use crate::torrents::{TorrentPreviewRequest, TorrentService, TorrentStartRequest};
|
use crate::torrents::{TorrentPreviewRequest, TorrentService, TorrentStartRequest};
|
||||||
|
|
||||||
|
mod dto;
|
||||||
|
mod helpers;
|
||||||
|
mod queries;
|
||||||
|
mod rows;
|
||||||
|
|
||||||
|
use dto::*;
|
||||||
|
use helpers::{cover_url, load_release_uploaders, track_cover_url};
|
||||||
|
use queries::*;
|
||||||
|
use rows::*;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// JSON error helper
|
// JSON error helper
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -32,429 +40,6 @@ fn json_error(status: StatusCode, message: &str) -> cot::response::Response {
|
|||||||
.expect("valid response")
|
.expect("valid response")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// DTO structs
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
|
||||||
struct ArtistCard {
|
|
||||||
id: i64,
|
|
||||||
name: String,
|
|
||||||
image_url: Option<String>,
|
|
||||||
release_count: i64,
|
|
||||||
track_count: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
|
||||||
struct Paginated<T: Serialize> {
|
|
||||||
items: Vec<T>,
|
|
||||||
total: i64,
|
|
||||||
page: i32,
|
|
||||||
per_page: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
|
||||||
struct ReleaseCard {
|
|
||||||
id: i64,
|
|
||||||
title: String,
|
|
||||||
release_type: String,
|
|
||||||
year: Option<i32>,
|
|
||||||
cover_url: Option<String>,
|
|
||||||
track_count: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
|
||||||
struct ArtistDetail {
|
|
||||||
id: i64,
|
|
||||||
name: String,
|
|
||||||
image_url: Option<String>,
|
|
||||||
total_track_count: i64,
|
|
||||||
total_play_count: i64,
|
|
||||||
releases: Vec<ReleaseCard>,
|
|
||||||
featured_tracks: Vec<ArtistAppearanceTrack>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
|
||||||
struct ArtistRef {
|
|
||||||
id: i64,
|
|
||||||
name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
|
||||||
struct TrackItem {
|
|
||||||
id: i64,
|
|
||||||
title: String,
|
|
||||||
track_number: Option<i32>,
|
|
||||||
disc_number: Option<i32>,
|
|
||||||
duration_seconds: f64,
|
|
||||||
artists: Vec<ArtistRef>,
|
|
||||||
featured_artists: Vec<ArtistRef>,
|
|
||||||
cover_url: Option<String>,
|
|
||||||
stream_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
|
||||||
struct ArtistAppearanceTrack {
|
|
||||||
id: i64,
|
|
||||||
title: String,
|
|
||||||
release_id: i64,
|
|
||||||
release_title: String,
|
|
||||||
duration_seconds: f64,
|
|
||||||
artists: Vec<ArtistRef>,
|
|
||||||
featured_artists: Vec<ArtistRef>,
|
|
||||||
cover_url: Option<String>,
|
|
||||||
stream_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
|
||||||
struct ReleaseDetail {
|
|
||||||
id: i64,
|
|
||||||
title: String,
|
|
||||||
release_type: String,
|
|
||||||
year: Option<i32>,
|
|
||||||
cover_url: Option<String>,
|
|
||||||
artists: Vec<ArtistRef>,
|
|
||||||
tracks: Vec<TrackItem>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
|
||||||
struct PlaylistCard {
|
|
||||||
id: i64,
|
|
||||||
title: String,
|
|
||||||
track_count: i64,
|
|
||||||
is_own: bool,
|
|
||||||
kind: String, // "user" or "likes"
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
|
||||||
struct PlaybackStateDto {
|
|
||||||
current_track_id: Option<i64>,
|
|
||||||
position_ms: i32,
|
|
||||||
queue: Vec<i64>,
|
|
||||||
queue_position: i32,
|
|
||||||
shuffle: bool,
|
|
||||||
repeat_mode: String,
|
|
||||||
volume: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
|
||||||
struct PlaylistDetail {
|
|
||||||
id: i64,
|
|
||||||
title: String,
|
|
||||||
description: Option<String>,
|
|
||||||
is_own: bool,
|
|
||||||
kind: String,
|
|
||||||
tracks: Vec<TrackItem>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
|
||||||
struct SearchResults {
|
|
||||||
artists: Vec<ArtistCard>,
|
|
||||||
releases: Vec<ReleaseCard>,
|
|
||||||
tracks: Vec<TrackItem>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
|
||||||
struct UserStats {
|
|
||||||
liked_tracks: i64,
|
|
||||||
playlists: i64,
|
|
||||||
plays: i64,
|
|
||||||
listened_minutes: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
|
||||||
struct UserProfile {
|
|
||||||
name: String,
|
|
||||||
role: String,
|
|
||||||
stats: UserStats,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
|
||||||
struct PlayHistoryItem {
|
|
||||||
id: i64,
|
|
||||||
track_id: i64,
|
|
||||||
track_title: String,
|
|
||||||
release_title: Option<String>,
|
|
||||||
played_at: String,
|
|
||||||
duration_listened: Option<i32>,
|
|
||||||
completed: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
|
||||||
struct PlayHistoryPage {
|
|
||||||
items: Vec<PlayHistoryItem>,
|
|
||||||
total: i64,
|
|
||||||
page: i32,
|
|
||||||
per_page: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct HistoryEntry {
|
|
||||||
track_id: i64,
|
|
||||||
duration_listened: Option<i32>,
|
|
||||||
completed: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct HistoryQuery {
|
|
||||||
page: Option<i32>,
|
|
||||||
limit: Option<i32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct TracksByIdsRequest {
|
|
||||||
ids: Vec<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct CreatePlaylistRequest {
|
|
||||||
title: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct UpdatePlaylistRequest {
|
|
||||||
title: Option<String>,
|
|
||||||
description: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct AddTracksRequest {
|
|
||||||
track_ids: Vec<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct RemoveTrackRequest {
|
|
||||||
track_id: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
|
||||||
struct LikeStatus {
|
|
||||||
liked: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
|
||||||
struct LikedIds {
|
|
||||||
track_ids: Vec<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
|
||||||
struct FollowStatus {
|
|
||||||
followed: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
|
||||||
struct FollowedArtists {
|
|
||||||
artist_ids: Vec<i64>,
|
|
||||||
artists: Vec<ArtistCard>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Query helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct PaginationQuery {
|
|
||||||
page: Option<i32>,
|
|
||||||
limit: Option<i32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct PathId {
|
|
||||||
id: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct PathStringId {
|
|
||||||
id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct SearchQuery {
|
|
||||||
q: String,
|
|
||||||
limit: Option<i32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct PathTrackId {
|
|
||||||
track_id: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct PathMediaFileId {
|
|
||||||
media_file_id: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// sqlx row types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct ArtistRow {
|
|
||||||
id: i64,
|
|
||||||
name: String,
|
|
||||||
image_file_id: Option<i64>,
|
|
||||||
release_count: i64,
|
|
||||||
track_count: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct CountRow {
|
|
||||||
count: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct ReleaseRow {
|
|
||||||
id: i64,
|
|
||||||
title: String,
|
|
||||||
release_type: String,
|
|
||||||
year: Option<i32>,
|
|
||||||
cover_file_id: Option<i64>,
|
|
||||||
track_count: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct ArtistBriefRow {
|
|
||||||
id: i64,
|
|
||||||
name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct TrackRow {
|
|
||||||
id: i64,
|
|
||||||
title: String,
|
|
||||||
track_number: Option<i32>,
|
|
||||||
disc_number: Option<i32>,
|
|
||||||
duration_seconds: f64,
|
|
||||||
cover_file_id: Option<i64>,
|
|
||||||
release_cover_file_id: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct TrackArtistRow {
|
|
||||||
track_id: i64,
|
|
||||||
artist_id: i64,
|
|
||||||
artist_name: String,
|
|
||||||
role: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct MediaFileRow {
|
|
||||||
file_path: String,
|
|
||||||
mime_type: String,
|
|
||||||
file_size_bytes: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct PlaybackStateRow {
|
|
||||||
current_track_id: Option<i64>,
|
|
||||||
position_ms: i32,
|
|
||||||
queue_json: String,
|
|
||||||
queue_position: i32,
|
|
||||||
shuffle: bool,
|
|
||||||
repeat_mode: String,
|
|
||||||
volume: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct PlaylistRow {
|
|
||||||
id: i64,
|
|
||||||
title: String,
|
|
||||||
track_count: i64,
|
|
||||||
is_own: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct PlaylistInfoRow {
|
|
||||||
id: i64,
|
|
||||||
title: String,
|
|
||||||
description: Option<String>,
|
|
||||||
owner_id: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct PlaylistTrackRow {
|
|
||||||
id: i64,
|
|
||||||
title: String,
|
|
||||||
track_number: Option<i32>,
|
|
||||||
disc_number: Option<i32>,
|
|
||||||
duration_seconds: f64,
|
|
||||||
cover_file_id: Option<i64>,
|
|
||||||
release_cover_file_id: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct AppearanceTrackRow {
|
|
||||||
id: i64,
|
|
||||||
title: String,
|
|
||||||
release_id: i64,
|
|
||||||
release_title: String,
|
|
||||||
duration_seconds: f64,
|
|
||||||
cover_file_id: Option<i64>,
|
|
||||||
release_cover_file_id: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct SearchArtistRow {
|
|
||||||
id: i64,
|
|
||||||
name: String,
|
|
||||||
image_file_id: Option<i64>,
|
|
||||||
release_count: i64,
|
|
||||||
track_count: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct SearchReleaseRow {
|
|
||||||
id: i64,
|
|
||||||
title: String,
|
|
||||||
release_type: String,
|
|
||||||
year: Option<i32>,
|
|
||||||
cover_file_id: Option<i64>,
|
|
||||||
track_count: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct SearchTrackRow {
|
|
||||||
id: i64,
|
|
||||||
title: String,
|
|
||||||
track_number: Option<i32>,
|
|
||||||
disc_number: Option<i32>,
|
|
||||||
duration_seconds: f64,
|
|
||||||
cover_file_id: Option<i64>,
|
|
||||||
release_cover_file_id: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct PlayHistoryRow {
|
|
||||||
id: i64,
|
|
||||||
track_id: i64,
|
|
||||||
track_title: String,
|
|
||||||
release_title: Option<String>,
|
|
||||||
played_at: String,
|
|
||||||
duration_listened: Option<i32>,
|
|
||||||
completed: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct ReleaseInfoRow {
|
|
||||||
id: i64,
|
|
||||||
title: String,
|
|
||||||
release_type: String,
|
|
||||||
year: Option<i32>,
|
|
||||||
cover_file_id: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
fn cover_url(file_id: Option<i64>) -> Option<String> {
|
|
||||||
file_id.map(|id| format!("/api/player/cover/{id}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn track_cover_url(track_cover: Option<i64>, release_cover: Option<i64>) -> Option<String> {
|
|
||||||
cover_url(track_cover.or(release_cover))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// SPA shell
|
// SPA shell
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -643,6 +228,11 @@ async fn artist_detail_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let release_ids: Vec<i64> = releases.iter().map(|r| r.id).collect();
|
||||||
|
let mut release_uploaders = load_release_uploaders(pool, &release_ids)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
let release_cards: Vec<ReleaseCard> = releases
|
let release_cards: Vec<ReleaseCard> = releases
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| ReleaseCard {
|
.map(|r| ReleaseCard {
|
||||||
@@ -652,6 +242,7 @@ async fn artist_detail_handler(
|
|||||||
year: r.year,
|
year: r.year,
|
||||||
cover_url: cover_url(r.cover_file_id),
|
cover_url: cover_url(r.cover_file_id),
|
||||||
track_count: r.track_count,
|
track_count: r.track_count,
|
||||||
|
uploaders: release_uploaders.remove(&r.id).unwrap_or_default(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -676,10 +267,17 @@ async fn artist_detail_handler(
|
|||||||
r.title::text AS release_title,
|
r.title::text AS release_title,
|
||||||
t.duration_seconds,
|
t.duration_seconds,
|
||||||
t.cover_file_id,
|
t.cover_file_id,
|
||||||
r.cover_file_id AS release_cover_file_id
|
r.cover_file_id AS release_cover_file_id,
|
||||||
|
COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name,
|
||||||
|
mf.audio_format,
|
||||||
|
mf.audio_bitrate,
|
||||||
|
mf.audio_sample_rate,
|
||||||
|
mf.audio_bit_depth,
|
||||||
|
mf.file_size_bytes
|
||||||
FROM furumusic__track_artist ta
|
FROM furumusic__track_artist ta
|
||||||
JOIN furumusic__track t ON t.id = ta.track_id
|
JOIN furumusic__track t ON t.id = ta.track_id
|
||||||
JOIN furumusic__release r ON r.id = t.release_id
|
JOIN furumusic__release r ON r.id = t.release_id
|
||||||
|
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
||||||
WHERE ta.artist_id = $1
|
WHERE ta.artist_id = $1
|
||||||
AND ta.role = 'featuring'
|
AND ta.role = 'featuring'
|
||||||
AND t.is_hidden = false
|
AND t.is_hidden = false
|
||||||
@@ -745,6 +343,12 @@ async fn artist_detail_handler(
|
|||||||
featured_artists: featured_feat_artists.remove(&tid).unwrap_or_default(),
|
featured_artists: featured_feat_artists.remove(&tid).unwrap_or_default(),
|
||||||
cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id),
|
cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id),
|
||||||
stream_url: format!("/api/player/stream/{tid}"),
|
stream_url: format!("/api/player/stream/{tid}"),
|
||||||
|
uploader_name: t.uploader_name,
|
||||||
|
audio_format: t.audio_format,
|
||||||
|
audio_bitrate: t.audio_bitrate,
|
||||||
|
audio_sample_rate: t.audio_sample_rate,
|
||||||
|
audio_bit_depth: t.audio_bit_depth,
|
||||||
|
file_size_bytes: t.file_size_bytes,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -807,9 +411,16 @@ async fn release_detail_handler(
|
|||||||
let tracks = sqlx::query_as::<_, TrackRow>(
|
let tracks = sqlx::query_as::<_, TrackRow>(
|
||||||
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||||
t.duration_seconds, t.cover_file_id,
|
t.duration_seconds, t.cover_file_id,
|
||||||
r.cover_file_id as release_cover_file_id
|
r.cover_file_id as release_cover_file_id,
|
||||||
|
COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name,
|
||||||
|
mf.audio_format,
|
||||||
|
mf.audio_bitrate,
|
||||||
|
mf.audio_sample_rate,
|
||||||
|
mf.audio_bit_depth,
|
||||||
|
mf.file_size_bytes
|
||||||
FROM furumusic__track t
|
FROM furumusic__track t
|
||||||
JOIN furumusic__release r ON r.id = t.release_id
|
JOIN furumusic__release r ON r.id = t.release_id
|
||||||
|
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
||||||
WHERE t.release_id = $1 AND t.is_hidden = false
|
WHERE t.release_id = $1 AND t.is_hidden = false
|
||||||
ORDER BY t.disc_number NULLS FIRST, t.track_number NULLS LAST"#,
|
ORDER BY t.disc_number NULLS FIRST, t.track_number NULLS LAST"#,
|
||||||
)
|
)
|
||||||
@@ -875,9 +486,20 @@ async fn release_detail_handler(
|
|||||||
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
|
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
|
||||||
cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id),
|
cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id),
|
||||||
stream_url: format!("/api/player/stream/{tid}"),
|
stream_url: format!("/api/player/stream/{tid}"),
|
||||||
|
uploader_name: t.uploader_name,
|
||||||
|
audio_format: t.audio_format,
|
||||||
|
audio_bitrate: t.audio_bitrate,
|
||||||
|
audio_sample_rate: t.audio_sample_rate,
|
||||||
|
audio_bit_depth: t.audio_bit_depth,
|
||||||
|
file_size_bytes: t.file_size_bytes,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
let uploaders = load_release_uploaders(pool, &[release.id])
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?
|
||||||
|
.remove(&release.id)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
Json(ReleaseDetail {
|
Json(ReleaseDetail {
|
||||||
id: release.id,
|
id: release.id,
|
||||||
@@ -893,6 +515,7 @@ async fn release_detail_handler(
|
|||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
tracks: track_items,
|
tracks: track_items,
|
||||||
|
uploaders,
|
||||||
})
|
})
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
@@ -988,10 +611,17 @@ async fn playlist_detail_handler(
|
|||||||
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
||||||
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||||
t.duration_seconds, t.cover_file_id,
|
t.duration_seconds, t.cover_file_id,
|
||||||
r.cover_file_id as release_cover_file_id
|
r.cover_file_id as release_cover_file_id,
|
||||||
|
COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name,
|
||||||
|
mf.audio_format,
|
||||||
|
mf.audio_bitrate,
|
||||||
|
mf.audio_sample_rate,
|
||||||
|
mf.audio_bit_depth,
|
||||||
|
mf.file_size_bytes
|
||||||
FROM furumusic__playlist_track pt
|
FROM furumusic__playlist_track pt
|
||||||
JOIN furumusic__track t ON t.id = pt.track_id
|
JOIN furumusic__track t ON t.id = pt.track_id
|
||||||
JOIN furumusic__release r ON r.id = t.release_id
|
JOIN furumusic__release r ON r.id = t.release_id
|
||||||
|
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
||||||
WHERE pt.playlist_id = $1 AND t.is_hidden = false
|
WHERE pt.playlist_id = $1 AND t.is_hidden = false
|
||||||
ORDER BY pt.position"#,
|
ORDER BY pt.position"#,
|
||||||
)
|
)
|
||||||
@@ -1073,6 +703,12 @@ async fn build_track_items(
|
|||||||
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
|
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
|
||||||
cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id),
|
cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id),
|
||||||
stream_url: format!("/api/player/stream/{tid}"),
|
stream_url: format!("/api/player/stream/{tid}"),
|
||||||
|
uploader_name: t.uploader_name,
|
||||||
|
audio_format: t.audio_format,
|
||||||
|
audio_bitrate: t.audio_bitrate,
|
||||||
|
audio_sample_rate: t.audio_sample_rate,
|
||||||
|
audio_bit_depth: t.audio_bit_depth,
|
||||||
|
file_size_bytes: t.file_size_bytes,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
@@ -1086,10 +722,17 @@ async fn likes_playlist_handler(
|
|||||||
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
||||||
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||||
t.duration_seconds, t.cover_file_id,
|
t.duration_seconds, t.cover_file_id,
|
||||||
r.cover_file_id as release_cover_file_id
|
r.cover_file_id as release_cover_file_id,
|
||||||
|
COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name,
|
||||||
|
mf.audio_format,
|
||||||
|
mf.audio_bitrate,
|
||||||
|
mf.audio_sample_rate,
|
||||||
|
mf.audio_bit_depth,
|
||||||
|
mf.file_size_bytes
|
||||||
FROM furumusic__user_liked_track ult
|
FROM furumusic__user_liked_track ult
|
||||||
JOIN furumusic__track t ON t.id = ult.track_id
|
JOIN furumusic__track t ON t.id = ult.track_id
|
||||||
JOIN furumusic__release r ON r.id = t.release_id
|
JOIN furumusic__release r ON r.id = t.release_id
|
||||||
|
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
||||||
WHERE ult.user_id = $1 AND t.is_hidden = false
|
WHERE ult.user_id = $1 AND t.is_hidden = false
|
||||||
ORDER BY ult.created_at DESC"#,
|
ORDER BY ult.created_at DESC"#,
|
||||||
)
|
)
|
||||||
@@ -1543,9 +1186,16 @@ async fn search_handler(
|
|||||||
let t = sqlx::query_as::<_, SearchTrackRow>(
|
let t = sqlx::query_as::<_, SearchTrackRow>(
|
||||||
r#"SELECT t.id, t.title::text AS title, t.track_number, t.disc_number,
|
r#"SELECT t.id, t.title::text AS title, t.track_number, t.disc_number,
|
||||||
t.duration_seconds, t.cover_file_id,
|
t.duration_seconds, t.cover_file_id,
|
||||||
rel.cover_file_id AS release_cover_file_id
|
rel.cover_file_id AS release_cover_file_id,
|
||||||
|
COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name,
|
||||||
|
mf.audio_format,
|
||||||
|
mf.audio_bitrate,
|
||||||
|
mf.audio_sample_rate,
|
||||||
|
mf.audio_bit_depth,
|
||||||
|
mf.file_size_bytes
|
||||||
FROM furumusic__track t
|
FROM furumusic__track t
|
||||||
JOIN furumusic__release rel ON rel.id = t.release_id
|
JOIN furumusic__release rel ON rel.id = t.release_id
|
||||||
|
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
||||||
WHERE t.is_hidden = false AND t.title_sort ILIKE '%' || $1 || '%'
|
WHERE t.is_hidden = false AND t.title_sort ILIKE '%' || $1 || '%'
|
||||||
ORDER BY t.title_sort LIMIT $2"#,
|
ORDER BY t.title_sort LIMIT $2"#,
|
||||||
)
|
)
|
||||||
@@ -1605,22 +1255,32 @@ async fn search_handler(
|
|||||||
.fetch_all(pool);
|
.fetch_all(pool);
|
||||||
|
|
||||||
let t = sqlx::query_as::<_, SearchTrackRow>(
|
let t = sqlx::query_as::<_, SearchTrackRow>(
|
||||||
r#"SELECT id, title, track_number, disc_number, duration_seconds, cover_file_id, release_cover_file_id FROM (
|
r#"SELECT id, title, track_number, disc_number, duration_seconds, cover_file_id,
|
||||||
|
release_cover_file_id, uploader_name, audio_format, audio_bitrate,
|
||||||
|
audio_sample_rate, audio_bit_depth, file_size_bytes FROM (
|
||||||
SELECT t.id, t.title::text AS title, t.track_number, t.disc_number,
|
SELECT t.id, t.title::text AS title, t.track_number, t.disc_number,
|
||||||
t.duration_seconds, t.cover_file_id,
|
t.duration_seconds, t.cover_file_id,
|
||||||
rel.cover_file_id AS release_cover_file_id,
|
rel.cover_file_id AS release_cover_file_id,
|
||||||
|
COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name,
|
||||||
|
mf.audio_format,
|
||||||
|
mf.audio_bitrate,
|
||||||
|
mf.audio_sample_rate,
|
||||||
|
mf.audio_bit_depth,
|
||||||
|
mf.file_size_bytes,
|
||||||
MAX(sim) AS similarity
|
MAX(sim) AS similarity
|
||||||
FROM (
|
FROM (
|
||||||
SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id,
|
SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id, audio_file_id,
|
||||||
similarity(title_sort, $1) AS sim
|
similarity(title_sort, $1) AS sim
|
||||||
FROM furumusic__track WHERE is_hidden = false AND title_sort % $1
|
FROM furumusic__track WHERE is_hidden = false AND title_sort % $1
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id,
|
SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id, audio_file_id,
|
||||||
0.01::real AS sim
|
0.01::real AS sim
|
||||||
FROM furumusic__track WHERE is_hidden = false AND title_sort ILIKE '%' || $1 || '%'
|
FROM furumusic__track WHERE is_hidden = false AND title_sort ILIKE '%' || $1 || '%'
|
||||||
) t
|
) t
|
||||||
JOIN furumusic__release rel ON rel.id = t.release_id
|
JOIN furumusic__release rel ON rel.id = t.release_id
|
||||||
GROUP BY t.id, t.title, t.track_number, t.disc_number, t.duration_seconds, t.cover_file_id, rel.cover_file_id
|
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
||||||
|
GROUP BY t.id, t.title, t.track_number, t.disc_number, t.duration_seconds, t.cover_file_id, rel.cover_file_id,
|
||||||
|
mf.uploader_name, mf.audio_format, mf.audio_bitrate, mf.audio_sample_rate, mf.audio_bit_depth, mf.file_size_bytes
|
||||||
ORDER BY similarity DESC
|
ORDER BY similarity DESC
|
||||||
LIMIT $2
|
LIMIT $2
|
||||||
) sub"#,
|
) sub"#,
|
||||||
@@ -1685,6 +1345,11 @@ async fn search_handler(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let release_ids: Vec<i64> = release_rows.iter().map(|r| r.id).collect();
|
||||||
|
let mut release_uploaders = load_release_uploaders(pool, &release_ids)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
let releases: Vec<ReleaseCard> = release_rows
|
let releases: Vec<ReleaseCard> = release_rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| ReleaseCard {
|
.map(|r| ReleaseCard {
|
||||||
@@ -1694,6 +1359,7 @@ async fn search_handler(
|
|||||||
year: r.year,
|
year: r.year,
|
||||||
cover_url: cover_url(r.cover_file_id),
|
cover_url: cover_url(r.cover_file_id),
|
||||||
track_count: r.track_count,
|
track_count: r.track_count,
|
||||||
|
uploaders: release_uploaders.remove(&r.id).unwrap_or_default(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -1711,6 +1377,12 @@ async fn search_handler(
|
|||||||
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
|
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
|
||||||
cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id),
|
cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id),
|
||||||
stream_url: format!("/api/player/stream/{tid}"),
|
stream_url: format!("/api/player/stream/{tid}"),
|
||||||
|
uploader_name: t.uploader_name,
|
||||||
|
audio_format: t.audio_format,
|
||||||
|
audio_bitrate: t.audio_bitrate,
|
||||||
|
audio_sample_rate: t.audio_sample_rate,
|
||||||
|
audio_bit_depth: t.audio_bit_depth,
|
||||||
|
file_size_bytes: t.file_size_bytes,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -2265,9 +1937,16 @@ async fn tracks_by_ids_handler(
|
|||||||
let tracks = sqlx::query_as::<_, TrackRow>(
|
let tracks = sqlx::query_as::<_, TrackRow>(
|
||||||
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||||
t.duration_seconds, t.cover_file_id,
|
t.duration_seconds, t.cover_file_id,
|
||||||
r.cover_file_id as release_cover_file_id
|
r.cover_file_id as release_cover_file_id,
|
||||||
|
COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name,
|
||||||
|
mf.audio_format,
|
||||||
|
mf.audio_bitrate,
|
||||||
|
mf.audio_sample_rate,
|
||||||
|
mf.audio_bit_depth,
|
||||||
|
mf.file_size_bytes
|
||||||
FROM furumusic__track t
|
FROM furumusic__track t
|
||||||
JOIN furumusic__release r ON r.id = t.release_id
|
JOIN furumusic__release r ON r.id = t.release_id
|
||||||
|
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
||||||
WHERE t.id = ANY($1) AND t.is_hidden = false"#,
|
WHERE t.id = ANY($1) AND t.is_hidden = false"#,
|
||||||
)
|
)
|
||||||
.bind(&ids)
|
.bind(&ids)
|
||||||
@@ -2332,6 +2011,12 @@ async fn tracks_by_ids_handler(
|
|||||||
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
|
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
|
||||||
cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id),
|
cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id),
|
||||||
stream_url: format!("/api/player/stream/{tid}"),
|
stream_url: format!("/api/player/stream/{tid}"),
|
||||||
|
uploader_name: t.uploader_name,
|
||||||
|
audio_format: t.audio_format,
|
||||||
|
audio_bitrate: t.audio_bitrate,
|
||||||
|
audio_sample_rate: t.audio_sample_rate,
|
||||||
|
audio_bit_depth: t.audio_bit_depth,
|
||||||
|
file_size_bytes: t.file_size_bytes,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2448,8 +2133,7 @@ impl App for PlayerApp {
|
|||||||
let torrent_service = Arc::clone(&torrent_service);
|
let torrent_service = Arc::clone(&torrent_service);
|
||||||
let scheduler_handle = Arc::clone(&scheduler_handle);
|
let scheduler_handle = Arc::clone(&scheduler_handle);
|
||||||
async move {
|
async move {
|
||||||
let Some(_user) = auth::get_session_user(&session, &db).await
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
||||||
else {
|
|
||||||
return Ok(json_error(
|
return Ok(json_error(
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
"not authenticated",
|
"not authenticated",
|
||||||
@@ -2466,6 +2150,7 @@ impl App for PlayerApp {
|
|||||||
&path.0.id,
|
&path.0.id,
|
||||||
json.0.selected_files,
|
json.0.selected_files,
|
||||||
live_config.agent_inbox_dir,
|
live_config.agent_inbox_dir,
|
||||||
|
user.id,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct HistoryEntry {
|
||||||
|
pub(super) track_id: i64,
|
||||||
|
pub(super) duration_listened: Option<i32>,
|
||||||
|
pub(super) completed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct HistoryQuery {
|
||||||
|
pub(super) page: Option<i32>,
|
||||||
|
pub(super) limit: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct TracksByIdsRequest {
|
||||||
|
pub(super) ids: Vec<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct CreatePlaylistRequest {
|
||||||
|
pub(super) title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct UpdatePlaylistRequest {
|
||||||
|
pub(super) title: Option<String>,
|
||||||
|
pub(super) description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct AddTracksRequest {
|
||||||
|
pub(super) track_ids: Vec<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct RemoveTrackRequest {
|
||||||
|
pub(super) track_id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct PaginationQuery {
|
||||||
|
pub(super) page: Option<i32>,
|
||||||
|
pub(super) limit: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct PathId {
|
||||||
|
pub(super) id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct PathStringId {
|
||||||
|
pub(super) id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct SearchQuery {
|
||||||
|
pub(super) q: String,
|
||||||
|
pub(super) limit: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct PathTrackId {
|
||||||
|
pub(super) track_id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct PathMediaFileId {
|
||||||
|
pub(super) media_file_id: i64,
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct ArtistRow {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) name: String,
|
||||||
|
pub(super) image_file_id: Option<i64>,
|
||||||
|
pub(super) release_count: i64,
|
||||||
|
pub(super) track_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct CountRow {
|
||||||
|
pub(super) count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct ReleaseRow {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) title: String,
|
||||||
|
pub(super) release_type: String,
|
||||||
|
pub(super) year: Option<i32>,
|
||||||
|
pub(super) cover_file_id: Option<i64>,
|
||||||
|
pub(super) track_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct ArtistBriefRow {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct TrackRow {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) title: String,
|
||||||
|
pub(super) track_number: Option<i32>,
|
||||||
|
pub(super) disc_number: Option<i32>,
|
||||||
|
pub(super) duration_seconds: f64,
|
||||||
|
pub(super) cover_file_id: Option<i64>,
|
||||||
|
pub(super) release_cover_file_id: Option<i64>,
|
||||||
|
pub(super) uploader_name: String,
|
||||||
|
pub(super) audio_format: Option<String>,
|
||||||
|
pub(super) audio_bitrate: Option<i32>,
|
||||||
|
pub(super) audio_sample_rate: Option<i32>,
|
||||||
|
pub(super) audio_bit_depth: Option<i32>,
|
||||||
|
pub(super) file_size_bytes: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct TrackArtistRow {
|
||||||
|
pub(super) track_id: i64,
|
||||||
|
pub(super) artist_id: i64,
|
||||||
|
pub(super) artist_name: String,
|
||||||
|
pub(super) role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct MediaFileRow {
|
||||||
|
pub(super) file_path: String,
|
||||||
|
pub(super) mime_type: String,
|
||||||
|
pub(super) file_size_bytes: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct PlaybackStateRow {
|
||||||
|
pub(super) current_track_id: Option<i64>,
|
||||||
|
pub(super) position_ms: i32,
|
||||||
|
pub(super) queue_json: String,
|
||||||
|
pub(super) queue_position: i32,
|
||||||
|
pub(super) shuffle: bool,
|
||||||
|
pub(super) repeat_mode: String,
|
||||||
|
pub(super) volume: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct PlaylistRow {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) title: String,
|
||||||
|
pub(super) track_count: i64,
|
||||||
|
pub(super) is_own: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct PlaylistInfoRow {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) title: String,
|
||||||
|
pub(super) description: Option<String>,
|
||||||
|
pub(super) owner_id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct PlaylistTrackRow {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) title: String,
|
||||||
|
pub(super) track_number: Option<i32>,
|
||||||
|
pub(super) disc_number: Option<i32>,
|
||||||
|
pub(super) duration_seconds: f64,
|
||||||
|
pub(super) cover_file_id: Option<i64>,
|
||||||
|
pub(super) release_cover_file_id: Option<i64>,
|
||||||
|
pub(super) uploader_name: String,
|
||||||
|
pub(super) audio_format: Option<String>,
|
||||||
|
pub(super) audio_bitrate: Option<i32>,
|
||||||
|
pub(super) audio_sample_rate: Option<i32>,
|
||||||
|
pub(super) audio_bit_depth: Option<i32>,
|
||||||
|
pub(super) file_size_bytes: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct AppearanceTrackRow {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) title: String,
|
||||||
|
pub(super) release_id: i64,
|
||||||
|
pub(super) release_title: String,
|
||||||
|
pub(super) duration_seconds: f64,
|
||||||
|
pub(super) cover_file_id: Option<i64>,
|
||||||
|
pub(super) release_cover_file_id: Option<i64>,
|
||||||
|
pub(super) uploader_name: String,
|
||||||
|
pub(super) audio_format: Option<String>,
|
||||||
|
pub(super) audio_bitrate: Option<i32>,
|
||||||
|
pub(super) audio_sample_rate: Option<i32>,
|
||||||
|
pub(super) audio_bit_depth: Option<i32>,
|
||||||
|
pub(super) file_size_bytes: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct SearchArtistRow {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) name: String,
|
||||||
|
pub(super) image_file_id: Option<i64>,
|
||||||
|
pub(super) release_count: i64,
|
||||||
|
pub(super) track_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct SearchReleaseRow {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) title: String,
|
||||||
|
pub(super) release_type: String,
|
||||||
|
pub(super) year: Option<i32>,
|
||||||
|
pub(super) cover_file_id: Option<i64>,
|
||||||
|
pub(super) track_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct SearchTrackRow {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) title: String,
|
||||||
|
pub(super) track_number: Option<i32>,
|
||||||
|
pub(super) disc_number: Option<i32>,
|
||||||
|
pub(super) duration_seconds: f64,
|
||||||
|
pub(super) cover_file_id: Option<i64>,
|
||||||
|
pub(super) release_cover_file_id: Option<i64>,
|
||||||
|
pub(super) uploader_name: String,
|
||||||
|
pub(super) audio_format: Option<String>,
|
||||||
|
pub(super) audio_bitrate: Option<i32>,
|
||||||
|
pub(super) audio_sample_rate: Option<i32>,
|
||||||
|
pub(super) audio_bit_depth: Option<i32>,
|
||||||
|
pub(super) file_size_bytes: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct ReleaseUploaderRow {
|
||||||
|
pub(super) release_id: i64,
|
||||||
|
pub(super) uploader_name: String,
|
||||||
|
pub(super) track_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct PlayHistoryRow {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) track_id: i64,
|
||||||
|
pub(super) track_title: String,
|
||||||
|
pub(super) release_title: Option<String>,
|
||||||
|
pub(super) played_at: String,
|
||||||
|
pub(super) duration_listened: Option<i32>,
|
||||||
|
pub(super) completed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(super) struct ReleaseInfoRow {
|
||||||
|
pub(super) id: i64,
|
||||||
|
pub(super) title: String,
|
||||||
|
pub(super) release_type: String,
|
||||||
|
pub(super) year: Option<i32>,
|
||||||
|
pub(super) cover_file_id: Option<i64>,
|
||||||
|
}
|
||||||
+13
-3
@@ -316,6 +316,7 @@ impl TorrentService {
|
|||||||
id: &str,
|
id: &str,
|
||||||
selected_files: Vec<usize>,
|
selected_files: Vec<usize>,
|
||||||
inbox_dir: String,
|
inbox_dir: String,
|
||||||
|
uploader_user_id: i64,
|
||||||
) -> anyhow::Result<TorrentJobDto> {
|
) -> anyhow::Result<TorrentJobDto> {
|
||||||
if selected_files.is_empty() {
|
if selected_files.is_empty() {
|
||||||
bail!("select at least one file");
|
bail!("select at least one file");
|
||||||
@@ -371,7 +372,10 @@ impl TorrentService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
service.stop_torrent(&handle).await;
|
service.stop_torrent(&handle).await;
|
||||||
if let Err(err) = service.finalize_completed(&id, &inbox_dir).await {
|
if let Err(err) = service
|
||||||
|
.finalize_completed(&id, &inbox_dir, uploader_user_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
service.fail_job(&id, err.to_string()).await;
|
service.fail_job(&id, err.to_string()).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -400,7 +404,12 @@ impl TorrentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn finalize_completed(&self, id: &str, inbox_dir: &Path) -> anyhow::Result<()> {
|
async fn finalize_completed(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
inbox_dir: &Path,
|
||||||
|
uploader_user_id: i64,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
let (name, files, selected_files, output_dir) = {
|
let (name, files, selected_files, output_dir) = {
|
||||||
let mut jobs = self.jobs.lock().await;
|
let mut jobs = self.jobs.lock().await;
|
||||||
let job = jobs.get_mut(id).context("torrent job not found")?;
|
let job = jobs.get_mut(id).context("torrent job not found")?;
|
||||||
@@ -414,7 +423,8 @@ impl TorrentService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let destination_root = inbox_dir
|
let destination_root = inbox_dir
|
||||||
.join("torrents")
|
.join("user_uploads")
|
||||||
|
.join(uploader_user_id.to_string())
|
||||||
.join(sanitize_path_component(&name));
|
.join(sanitize_path_component(&name));
|
||||||
tokio::fs::create_dir_all(&destination_root).await?;
|
tokio::fs::create_dir_all(&destination_root).await?;
|
||||||
|
|
||||||
|
|||||||
@@ -610,6 +610,47 @@ button.user-stat:hover {
|
|||||||
.track-action-btn.play-btn:hover { color: var(--accent); }
|
.track-action-btn.play-btn:hover { color: var(--accent); }
|
||||||
.track-action-btn svg { width: 16px; height: 16px; }
|
.track-action-btn svg { width: 16px; height: 16px; }
|
||||||
|
|
||||||
|
.info-btn {
|
||||||
|
color: var(--text-subdued);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-info-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: rgba(18,18,18,0.78);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: help;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s, background 0.15s;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover .card-info-btn,
|
||||||
|
.search-release-card:hover .card-info-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-info-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-info-btn svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Card enqueue button (next to play button on release cards) */
|
/* Card enqueue button (next to play button on release cards) */
|
||||||
.card-enqueue-btn {
|
.card-enqueue-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -1826,6 +1867,7 @@ button.user-stat:hover {
|
|||||||
.card-subtitle { font-size: 11px; }
|
.card-subtitle { font-size: 11px; }
|
||||||
.card-play-btn,
|
.card-play-btn,
|
||||||
.card-enqueue-btn,
|
.card-enqueue-btn,
|
||||||
|
.card-info-btn,
|
||||||
.artist-follow-card-btn,
|
.artist-follow-card-btn,
|
||||||
.track-actions,
|
.track-actions,
|
||||||
.playlist-item-actions,
|
.playlist-item-actions,
|
||||||
@@ -2461,6 +2503,9 @@ button.user-stat:hover {
|
|||||||
<template x-if="!release.cover_url">
|
<template x-if="!release.cover_url">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
||||||
</template>
|
</template>
|
||||||
|
<button class="card-info-btn" @click.stop :title="$store.library.releaseInfo(release)" aria-label="Release info">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-title" x-text="release.title"></div>
|
<div class="card-title" x-text="release.title"></div>
|
||||||
<div class="card-subtitle">
|
<div class="card-subtitle">
|
||||||
@@ -2501,6 +2546,9 @@ button.user-stat:hover {
|
|||||||
</div>
|
</div>
|
||||||
<span></span>
|
<span></span>
|
||||||
<div class="track-actions">
|
<div class="track-actions">
|
||||||
|
<button class="track-action-btn info-btn" @click.stop :title="$store.library.trackInfo(track)" aria-label="Track info">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||||
|
</button>
|
||||||
<button class="track-action-btn play-btn" @click.stop="$store.library.playSearchTrack(idx)" title="Play">
|
<button class="track-action-btn play-btn" @click.stop="$store.library.playSearchTrack(idx)" title="Play">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -2620,6 +2668,9 @@ button.user-stat:hover {
|
|||||||
<template x-if="!release.cover_url">
|
<template x-if="!release.cover_url">
|
||||||
<span class="placeholder-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg></span>
|
<span class="placeholder-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg></span>
|
||||||
</template>
|
</template>
|
||||||
|
<button class="card-info-btn" @click.stop :title="$store.library.releaseInfo(release)" aria-label="Release info">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||||
|
</button>
|
||||||
<button class="card-enqueue-btn" @click.stop="$store.library.enqueueRelease(release.id)" title="Add to queue">
|
<button class="card-enqueue-btn" @click.stop="$store.library.enqueueRelease(release.id)" title="Add to queue">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -2669,6 +2720,9 @@ button.user-stat:hover {
|
|||||||
</div>
|
</div>
|
||||||
<span></span>
|
<span></span>
|
||||||
<div class="track-actions">
|
<div class="track-actions">
|
||||||
|
<button class="track-action-btn info-btn" @click.stop :title="$store.library.trackInfo(track)" aria-label="Track info">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||||
|
</button>
|
||||||
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentArtist.featured_tracks, idx)" title="Play">
|
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentArtist.featured_tracks, idx)" title="Play">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -2727,6 +2781,13 @@ button.user-stat:hover {
|
|||||||
</div>
|
</div>
|
||||||
<div class="release-year" x-text="$store.library.currentRelease.year || ''"></div>
|
<div class="release-year" x-text="$store.library.currentRelease.year || ''"></div>
|
||||||
<div class="release-actions">
|
<div class="release-actions">
|
||||||
|
<button class="release-action-btn secondary"
|
||||||
|
@click.stop
|
||||||
|
:title="$store.library.releaseInfo($store.library.currentRelease)"
|
||||||
|
aria-label="Release info">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||||
|
Info
|
||||||
|
</button>
|
||||||
<button class="release-action-btn primary" @click="$store.queue.playRelease($store.library.currentRelease.tracks, 0)">
|
<button class="release-action-btn primary" @click="$store.queue.playRelease($store.library.currentRelease.tracks, 0)">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||||
Play
|
Play
|
||||||
@@ -2774,6 +2835,9 @@ button.user-stat:hover {
|
|||||||
</div>
|
</div>
|
||||||
<span></span>
|
<span></span>
|
||||||
<div class="track-actions">
|
<div class="track-actions">
|
||||||
|
<button class="track-action-btn info-btn" @click.stop :title="$store.library.trackInfo(track)" aria-label="Track info">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||||
|
</button>
|
||||||
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentRelease.tracks, idx)" title="Play">
|
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentRelease.tracks, idx)" title="Play">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -2833,6 +2897,9 @@ button.user-stat:hover {
|
|||||||
</div>
|
</div>
|
||||||
<span></span>
|
<span></span>
|
||||||
<div class="track-actions">
|
<div class="track-actions">
|
||||||
|
<button class="track-action-btn info-btn" @click.stop :title="$store.library.trackInfo(track)" aria-label="Track info">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||||
|
</button>
|
||||||
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentPlaylist.tracks, idx)" title="Play">
|
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentPlaylist.tracks, idx)" title="Play">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -2902,6 +2969,9 @@ button.user-stat:hover {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="queue-track-actions">
|
<div class="queue-track-actions">
|
||||||
|
<button class="queue-track-remove info-btn" @click.stop :title="$store.library.trackInfo(track)" aria-label="Track info">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||||
|
</button>
|
||||||
<button class="queue-track-remove" @click.stop="$store.queue.remove(idx)" title="Remove">
|
<button class="queue-track-remove" @click.stop="$store.queue.remove(idx)" title="Remove">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -3827,6 +3897,58 @@ document.addEventListener('alpine:init', () => {
|
|||||||
return [...main, ...featured];
|
return [...main, ...featured];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
bytes(value) {
|
||||||
|
if (!value) return 'unknown size';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let size = Number(value);
|
||||||
|
let idx = 0;
|
||||||
|
while (size >= 1024 && idx < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
return (idx === 0 ? size.toFixed(0) : size.toFixed(1)) + ' ' + units[idx];
|
||||||
|
},
|
||||||
|
|
||||||
|
uploadersInfo(uploaders) {
|
||||||
|
const rows = uploaders || [];
|
||||||
|
if (!rows.length) return 'UFO';
|
||||||
|
return rows
|
||||||
|
.map(row => `${row.name || 'UFO'} (${row.track_count} track${row.track_count === 1 ? '' : 's'})`)
|
||||||
|
.join(', ');
|
||||||
|
},
|
||||||
|
|
||||||
|
releaseInfo(release) {
|
||||||
|
if (!release) return '';
|
||||||
|
const lines = [
|
||||||
|
release.title || 'Unknown release',
|
||||||
|
`Type: ${release.release_type || 'unknown'}`,
|
||||||
|
`Year: ${release.year || 'unknown'}`,
|
||||||
|
`Tracks: ${release.track_count || release.tracks?.length || 0}`,
|
||||||
|
`Uploaders: ${this.uploadersInfo(release.uploaders || [])}`,
|
||||||
|
];
|
||||||
|
return lines.join('\n');
|
||||||
|
},
|
||||||
|
|
||||||
|
trackInfo(track) {
|
||||||
|
if (!track) return '';
|
||||||
|
const artists = this.trackArtistLinks(track).map(artist => artist.label).join(', ') || 'unknown';
|
||||||
|
const audio = [
|
||||||
|
track.audio_format || null,
|
||||||
|
track.audio_bitrate ? `${track.audio_bitrate} kbps` : null,
|
||||||
|
track.audio_sample_rate ? `${track.audio_sample_rate} Hz` : null,
|
||||||
|
track.audio_bit_depth ? `${track.audio_bit_depth}-bit` : null,
|
||||||
|
].filter(Boolean).join(' · ') || 'unknown audio details';
|
||||||
|
const lines = [
|
||||||
|
track.title || 'Unknown track',
|
||||||
|
`Artists: ${artists}`,
|
||||||
|
`Duration: ${formatTime(track.duration_seconds)}`,
|
||||||
|
`Audio: ${audio}`,
|
||||||
|
`Size: ${this.bytes(track.file_size_bytes)}`,
|
||||||
|
`Uploader: ${track.uploader_name || 'UFO'}`,
|
||||||
|
];
|
||||||
|
return lines.join('\n');
|
||||||
|
},
|
||||||
|
|
||||||
async openRelease(id) {
|
async openRelease(id) {
|
||||||
this.searchQuery = '';
|
this.searchQuery = '';
|
||||||
this.searchResults = null;
|
this.searchResults = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user