Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a3a3f5368d | |||
| 5f925be29b | |||
| 8530016d35 | |||
| cae77e9401 |
Generated
+1
-1
@@ -1397,7 +1397,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "furumusic"
|
||||
version = "0.1.4"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "furumusic"
|
||||
version = "0.1.5"
|
||||
version = "0.1.8"
|
||||
edition = "2024"
|
||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ Full OpenID Connect authorization code flow with PKCE:
|
||||
|
||||
Provider metadata is cached for 1 hour and invalidated when OIDC config changes.
|
||||
|
||||
**Group-to-role mapping:** The `oidc_admin_groups` config field lists OIDC group names (comma-separated) that grant the admin role. Groups are extracted from the `groups` claim in the ID token JWT payload.
|
||||
**Group access and role mapping:** The `oidc_user_groups` config field lists OIDC group names (comma-separated) allowed to access the service. When it is set, users outside both `oidc_user_groups` and `oidc_admin_groups` are denied before provisioning/login. The `oidc_admin_groups` config field lists OIDC group names that grant the admin role. Groups are extracted from the `groups` claim in the ID token JWT payload.
|
||||
|
||||
**User provisioning order:**
|
||||
1. Find existing `OidcLink` by issuer+sub → update claims, update role
|
||||
@@ -197,4 +197,5 @@ All prefixed with `FURU_`. Priority: env var > DB override > compiled default.
|
||||
| `FURU_OIDC_CLIENT_SECRET` | OIDC client secret | *(empty)* |
|
||||
| `FURU_OIDC_BUTTON_TEXT` | SSO button label | `Sign in with SSO` |
|
||||
| `FURU_OIDC_ADMIN_GROUPS` | Comma-separated OIDC groups that grant admin | *(empty)* |
|
||||
| `FURU_OIDC_USER_GROUPS` | Comma-separated OIDC groups allowed to access the service. Empty means any authenticated SSO user is allowed. | *(empty)* |
|
||||
| `FURU_SWAGGER_ENABLED` | Serve Swagger UI at `/swagger/` | `false` |
|
||||
|
||||
+13
-1
@@ -129,6 +129,11 @@ fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec<Co
|
||||
config.oidc_admin_groups.clone(),
|
||||
defaults.oidc_admin_groups.clone()
|
||||
),
|
||||
entry!(
|
||||
oidc_user_groups,
|
||||
config.oidc_user_groups.clone(),
|
||||
defaults.oidc_user_groups.clone()
|
||||
),
|
||||
entry!(
|
||||
swagger_enabled,
|
||||
config.swagger_enabled.to_string(),
|
||||
@@ -248,6 +253,8 @@ struct SettingsTemplate {
|
||||
oidc_client_secret_source: &'static str,
|
||||
oidc_admin_groups: String,
|
||||
oidc_admin_groups_source: &'static str,
|
||||
oidc_user_groups: String,
|
||||
oidc_user_groups_source: &'static str,
|
||||
swagger_enabled: bool,
|
||||
swagger_enabled_source: &'static str,
|
||||
agent_enabled: bool,
|
||||
@@ -298,6 +305,8 @@ pub async fn settings_handler(
|
||||
oidc_client_secret_source: sources.oidc_client_secret.code(),
|
||||
oidc_admin_groups: config.oidc_admin_groups,
|
||||
oidc_admin_groups_source: sources.oidc_admin_groups.code(),
|
||||
oidc_user_groups: config.oidc_user_groups,
|
||||
oidc_user_groups_source: sources.oidc_user_groups.code(),
|
||||
swagger_enabled: config.swagger_enabled,
|
||||
swagger_enabled_source: sources.swagger_enabled.code(),
|
||||
agent_enabled: config.agent_enabled,
|
||||
@@ -331,6 +340,7 @@ pub struct OidcSettingsForm {
|
||||
oidc_client_id: Option<String>,
|
||||
oidc_client_secret: Option<String>,
|
||||
oidc_admin_groups: Option<String>,
|
||||
oidc_user_groups: Option<String>,
|
||||
swagger_enabled: Option<String>,
|
||||
agent_enabled: Option<String>,
|
||||
agent_inbox_dir: Option<String>,
|
||||
@@ -378,6 +388,7 @@ pub async fn settings_submit(
|
||||
let oidc_client_id = data.oidc_client_id.unwrap_or_default();
|
||||
let oidc_client_secret = data.oidc_client_secret.unwrap_or_default();
|
||||
let oidc_admin_groups = data.oidc_admin_groups.unwrap_or_default();
|
||||
let oidc_user_groups = data.oidc_user_groups.unwrap_or_default();
|
||||
let agent_inbox_dir = data.agent_inbox_dir.unwrap_or_default();
|
||||
let agent_storage_dir = data.agent_storage_dir.unwrap_or_default();
|
||||
let agent_llm_url = data.agent_llm_url.unwrap_or_default();
|
||||
@@ -386,7 +397,7 @@ pub async fn settings_submit(
|
||||
let agent_confidence_threshold = data.agent_confidence_threshold.unwrap_or_default();
|
||||
let agent_context_limit = data.agent_context_limit.unwrap_or_default();
|
||||
let agent_concurrency = data.agent_concurrency.unwrap_or_default();
|
||||
let fields: [(&str, &str); 17] = [
|
||||
let fields: [(&str, &str); 18] = [
|
||||
("auth_password_enabled", pw_enabled),
|
||||
("auth_sso_enabled", sso_enabled),
|
||||
("oidc_button_text", &oidc_button_text),
|
||||
@@ -394,6 +405,7 @@ pub async fn settings_submit(
|
||||
("oidc_client_id", &oidc_client_id),
|
||||
("oidc_client_secret", &oidc_client_secret),
|
||||
("oidc_admin_groups", &oidc_admin_groups),
|
||||
("oidc_user_groups", &oidc_user_groups),
|
||||
("swagger_enabled", swagger),
|
||||
("agent_enabled", agent_en),
|
||||
("agent_inbox_dir", &agent_inbox_dir),
|
||||
|
||||
@@ -360,6 +360,8 @@ pub async fn save_cover_to_storage(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("UFO"),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("failed to create cover MediaFile: {e}"))?;
|
||||
|
||||
@@ -122,6 +122,7 @@ pub struct ConfigSources {
|
||||
pub auth_sso_enabled: ConfigSource,
|
||||
pub oidc_button_text: ConfigSource,
|
||||
pub oidc_admin_groups: ConfigSource,
|
||||
pub oidc_user_groups: ConfigSource,
|
||||
pub swagger_enabled: ConfigSource,
|
||||
pub agent_enabled: ConfigSource,
|
||||
pub agent_inbox_dir: ConfigSource,
|
||||
@@ -146,6 +147,7 @@ impl Default for ConfigSources {
|
||||
auth_sso_enabled: ConfigSource::Default,
|
||||
oidc_button_text: ConfigSource::Default,
|
||||
oidc_admin_groups: ConfigSource::Default,
|
||||
oidc_user_groups: ConfigSource::Default,
|
||||
swagger_enabled: ConfigSource::Default,
|
||||
agent_enabled: ConfigSource::Default,
|
||||
agent_inbox_dir: ConfigSource::Default,
|
||||
@@ -238,6 +240,8 @@ pub struct AppConfig {
|
||||
pub oidc_button_text: String,
|
||||
/// Comma-separated list of OIDC group names that grant admin role.
|
||||
pub oidc_admin_groups: String,
|
||||
/// Comma-separated list of OIDC group names that are allowed to use the service.
|
||||
pub oidc_user_groups: String,
|
||||
/// Whether the Swagger UI is served at /swagger/.
|
||||
pub swagger_enabled: bool,
|
||||
/// Whether the AI agent background loop is enabled.
|
||||
@@ -272,6 +276,7 @@ impl Default for AppConfig {
|
||||
auth_sso_enabled: false,
|
||||
oidc_button_text: "Sign in with SSO".into(),
|
||||
oidc_admin_groups: String::new(),
|
||||
oidc_user_groups: String::new(),
|
||||
swagger_enabled: false,
|
||||
agent_enabled: false,
|
||||
agent_inbox_dir: String::new(),
|
||||
@@ -297,6 +302,7 @@ impl_env_overrides!(
|
||||
auth_sso_enabled,
|
||||
oidc_button_text,
|
||||
oidc_admin_groups,
|
||||
oidc_user_groups,
|
||||
swagger_enabled,
|
||||
agent_enabled,
|
||||
agent_inbox_dir,
|
||||
@@ -372,6 +378,7 @@ impl AppConfig {
|
||||
apply_db_field!(auth_sso_enabled);
|
||||
apply_db_field!(oidc_button_text);
|
||||
apply_db_field!(oidc_admin_groups);
|
||||
apply_db_field!(oidc_user_groups);
|
||||
apply_db_field!(swagger_enabled);
|
||||
apply_db_field!(agent_enabled);
|
||||
apply_db_field!(agent_inbox_dir);
|
||||
|
||||
@@ -70,6 +70,8 @@ translations! {
|
||||
settings_oidc_issuer_help: "Base URL of the OIDC provider (e.g. https://accounts.google.com)" , "Базовый URL провайдера OIDC (напр. https://accounts.google.com)";
|
||||
settings_oidc_admin_groups: "Admin groups" , "Группы администраторов";
|
||||
settings_oidc_admin_groups_help: "Comma-separated OIDC group names that grant admin role (e.g. /admin,/furumusic-admins)" , "OIDC группы через запятую, дающие роль администратора (напр. /admin,/furumusic-admins)";
|
||||
settings_oidc_user_groups: "User groups" , "Группы пользователей";
|
||||
settings_oidc_user_groups_help: "Comma-separated OIDC group names allowed to access the service. If empty, any authenticated SSO user is allowed." , "OIDC группы через запятую, которым разрешён доступ к сервису. Если пусто, разрешён любой SSO пользователь.";
|
||||
|
||||
// User management
|
||||
nav_users: "Users" , "Пользователи";
|
||||
@@ -97,6 +99,7 @@ translations! {
|
||||
// OIDC login errors
|
||||
login_oidc_error: "SSO login failed. Please try again." , "Ошибка входа через SSO. Попробуйте ещё раз.";
|
||||
login_sso_disabled: "SSO login is not configured." , "Вход через SSO не настроен.";
|
||||
login_access_denied: "Access denied. Contact your administrator." , "Доступ запрещён. Обратитесь к администратору.";
|
||||
|
||||
// Artist management
|
||||
nav_artists: "Artists" , "Артисты";
|
||||
|
||||
@@ -140,7 +140,9 @@ impl Job for InboxDiscoverJob {
|
||||
|
||||
// Parse path hints
|
||||
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
|
||||
let context = serde_json::json!({
|
||||
@@ -156,6 +158,8 @@ impl Job for InboxDiscoverJob {
|
||||
"audio_bitrate": raw_meta.audio_bitrate,
|
||||
"audio_sample_rate": raw_meta.audio_sample_rate,
|
||||
"audio_bit_depth": raw_meta.audio_bit_depth,
|
||||
"uploaded_by_user_id": uploader.user_id,
|
||||
"uploader_name": uploader.name,
|
||||
"path_title": hints.title,
|
||||
"path_artist": hints.artist,
|
||||
"path_album": hints.album,
|
||||
|
||||
@@ -337,7 +337,9 @@ async fn process_folder_batch(
|
||||
|
||||
// Parse path hints
|
||||
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() {
|
||||
context_obj.insert(
|
||||
"audio_bitrate".to_owned(),
|
||||
@@ -351,6 +353,15 @@ async fn process_folder_batch(
|
||||
"audio_bit_depth".to_owned(),
|
||||
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 {
|
||||
@@ -737,6 +748,12 @@ pub async fn finalize_approved(
|
||||
.get("audio_bit_depth")
|
||||
.and_then(|v| v.as_i64())
|
||||
.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 original_filename = source_path
|
||||
@@ -805,6 +822,8 @@ pub async fn finalize_approved(
|
||||
audio_bitrate,
|
||||
audio_sample_rate,
|
||||
audio_bit_depth,
|
||||
uploaded_by_user_id,
|
||||
Some(uploader_name),
|
||||
)
|
||||
.await
|
||||
.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_process;
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,6 +281,7 @@ impl Project for FuruProject {
|
||||
" FURU_OIDC_CLIENT_SECRET OIDC client secret\n",
|
||||
" FURU_OIDC_BUTTON_TEXT SSO button label (default: Sign in with SSO)\n",
|
||||
" FURU_OIDC_ADMIN_GROUPS OIDC groups that grant admin role\n",
|
||||
" FURU_OIDC_USER_GROUPS OIDC groups allowed to access the service\n",
|
||||
"\n",
|
||||
" API:\n",
|
||||
" FURU_SWAGGER_ENABLED Enable Swagger UI at /swagger/ (default: false)\n",
|
||||
|
||||
@@ -36,6 +36,10 @@ pub struct MediaFile {
|
||||
pub audio_sample_rate: Option<i32>,
|
||||
/// Bit depth (16, 24, 32)
|
||||
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>,
|
||||
}
|
||||
|
||||
@@ -607,8 +611,13 @@ impl MediaFile {
|
||||
audio_bitrate: Option<i32>,
|
||||
audio_sample_rate: Option<i32>,
|
||||
audio_bit_depth: Option<i32>,
|
||||
uploaded_by_user_id: Option<i64>,
|
||||
uploader_name: Option<&str>,
|
||||
) -> cot::db::Result<Self> {
|
||||
let now = now_iso();
|
||||
let uploader_name = uploader_name
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.unwrap_or("UFO");
|
||||
let mut mf = Self {
|
||||
id: Auto::auto(),
|
||||
file_type: LimitedString::new(file_type).unwrap(),
|
||||
@@ -621,6 +630,8 @@ impl MediaFile {
|
||||
audio_bitrate,
|
||||
audio_sample_rate,
|
||||
audio_bit_depth,
|
||||
uploaded_by_user_id,
|
||||
uploader_name: LimitedString::new(uploader_name).unwrap(),
|
||||
created_at: now,
|
||||
};
|
||||
mf.insert(db).await?;
|
||||
@@ -1533,6 +1544,40 @@ pub mod db_migrations {
|
||||
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] = &[
|
||||
&M0006CreateMediaFile,
|
||||
&M0007CreateArtist,
|
||||
@@ -1553,5 +1598,6 @@ pub mod db_migrations {
|
||||
&M0022CreateTrackTrgmIndex,
|
||||
&M0028AddModelNameColumns,
|
||||
&M0029AddPlaybackVolume,
|
||||
&M0030AddMediaFileUploader,
|
||||
];
|
||||
}
|
||||
|
||||
+36
-1
@@ -384,10 +384,24 @@ pub async fn oidc_callback_handler(
|
||||
.unwrap_or_default();
|
||||
|
||||
tracing::info!(
|
||||
"OIDC login: sub={sub}, groups={groups:?}, admin_groups={:?}",
|
||||
"OIDC login: sub={sub}, groups={groups:?}, admin_groups={:?}, user_groups={:?}",
|
||||
config.oidc_admin_groups,
|
||||
config.oidc_user_groups,
|
||||
);
|
||||
|
||||
if !is_allowed_by_groups(
|
||||
&groups,
|
||||
&config.oidc_user_groups,
|
||||
&config.oidc_admin_groups,
|
||||
) {
|
||||
tracing::warn!(
|
||||
"OIDC login denied by group allowlist: sub={sub}, groups={groups:?}, user_groups={:?}, admin_groups={:?}",
|
||||
config.oidc_user_groups,
|
||||
config.oidc_admin_groups,
|
||||
);
|
||||
return redirect_login_with_error(i18n.t.login_access_denied);
|
||||
}
|
||||
|
||||
// User provisioning logic.
|
||||
let user = match provision_user(
|
||||
&db,
|
||||
@@ -458,6 +472,27 @@ fn resolve_role(groups: &[String], admin_groups: &str) -> &'static str {
|
||||
auth::Role::User.code()
|
||||
}
|
||||
|
||||
fn parse_group_set(groups: &str) -> std::collections::HashSet<&str> {
|
||||
groups
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn has_any_group(groups: &[String], allowed: &std::collections::HashSet<&str>) -> bool {
|
||||
groups.iter().any(|g| allowed.contains(g.as_str()))
|
||||
}
|
||||
|
||||
fn is_allowed_by_groups(groups: &[String], user_groups: &str, admin_groups: &str) -> bool {
|
||||
let user_set = parse_group_set(user_groups);
|
||||
if user_set.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let admin_set = parse_group_set(admin_groups);
|
||||
has_any_group(groups, &user_set) || has_any_group(groups, &admin_set)
|
||||
}
|
||||
|
||||
async fn provision_user(
|
||||
db: &Database,
|
||||
issuer: &str,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+370
-400
@@ -10,8 +10,6 @@ use cot::router::method::{get, post};
|
||||
use cot::router::{Route, Router};
|
||||
use cot::session::Session;
|
||||
use cot::{App, Body, Template};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::auth;
|
||||
use crate::config::AppConfig;
|
||||
@@ -19,6 +17,16 @@ use crate::i18n::Translations;
|
||||
use crate::scheduler::SchedulerHandle;
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -32,393 +40,6 @@ fn json_error(status: StatusCode, message: &str) -> cot::response::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>,
|
||||
}
|
||||
|
||||
#[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 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>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -607,6 +228,11 @@ async fn artist_detail_handler(
|
||||
.await
|
||||
.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
|
||||
.into_iter()
|
||||
.map(|r| ReleaseCard {
|
||||
@@ -616,6 +242,7 @@ async fn artist_detail_handler(
|
||||
year: r.year,
|
||||
cover_url: cover_url(r.cover_file_id),
|
||||
track_count: r.track_count,
|
||||
uploaders: release_uploaders.remove(&r.id).unwrap_or_default(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -633,6 +260,99 @@ async fn artist_detail_handler(
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
let featured_rows = sqlx::query_as::<_, AppearanceTrackRow>(
|
||||
r#"SELECT DISTINCT t.id,
|
||||
t.title::text AS title,
|
||||
r.id AS release_id,
|
||||
r.title::text AS release_title,
|
||||
t.duration_seconds,
|
||||
t.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
|
||||
JOIN furumusic__track t ON t.id = ta.track_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
|
||||
AND ta.role = 'featuring'
|
||||
AND t.is_hidden = false
|
||||
AND r.is_hidden = false
|
||||
ORDER BY r.title::text, t.title::text"#,
|
||||
)
|
||||
.bind(artist_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
let featured_track_ids: Vec<i64> = featured_rows.iter().map(|t| t.id).collect();
|
||||
let featured_track_artists = if featured_track_ids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
sqlx::query_as::<_, TrackArtistRow>(
|
||||
r#"SELECT ta.track_id, ta.artist_id, a.name::text as artist_name, ta.role::text as role
|
||||
FROM furumusic__track_artist ta
|
||||
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||
WHERE ta.track_id = ANY($1)
|
||||
ORDER BY ta.track_id, ta.position"#,
|
||||
)
|
||||
.bind(&featured_track_ids)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?
|
||||
};
|
||||
|
||||
let mut featured_main_artists: std::collections::HashMap<i64, Vec<ArtistRef>> =
|
||||
std::collections::HashMap::new();
|
||||
let mut featured_feat_artists: std::collections::HashMap<i64, Vec<ArtistRef>> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for ta in &featured_track_artists {
|
||||
let artist_ref = ArtistRef {
|
||||
id: ta.artist_id,
|
||||
name: ta.artist_name.clone(),
|
||||
};
|
||||
if ta.role == "featuring" {
|
||||
featured_feat_artists
|
||||
.entry(ta.track_id)
|
||||
.or_default()
|
||||
.push(artist_ref);
|
||||
} else {
|
||||
featured_main_artists
|
||||
.entry(ta.track_id)
|
||||
.or_default()
|
||||
.push(artist_ref);
|
||||
}
|
||||
}
|
||||
|
||||
let featured_tracks: Vec<ArtistAppearanceTrack> = featured_rows
|
||||
.into_iter()
|
||||
.map(|t| {
|
||||
let tid = t.id;
|
||||
ArtistAppearanceTrack {
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
release_id: t.release_id,
|
||||
release_title: t.release_title,
|
||||
duration_seconds: t.duration_seconds,
|
||||
artists: featured_main_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),
|
||||
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();
|
||||
|
||||
Json(ArtistDetail {
|
||||
id: artist.id,
|
||||
name: artist.name,
|
||||
@@ -640,6 +360,7 @@ async fn artist_detail_handler(
|
||||
total_track_count,
|
||||
total_play_count,
|
||||
releases: release_cards,
|
||||
featured_tracks,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
@@ -690,9 +411,16 @@ async fn release_detail_handler(
|
||||
let tracks = sqlx::query_as::<_, TrackRow>(
|
||||
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||
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
|
||||
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
|
||||
ORDER BY t.disc_number NULLS FIRST, t.track_number NULLS LAST"#,
|
||||
)
|
||||
@@ -758,9 +486,20 @@ async fn release_detail_handler(
|
||||
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
|
||||
cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id),
|
||||
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();
|
||||
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 {
|
||||
id: release.id,
|
||||
@@ -776,6 +515,7 @@ async fn release_detail_handler(
|
||||
})
|
||||
.collect(),
|
||||
tracks: track_items,
|
||||
uploaders,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
@@ -871,10 +611,17 @@ async fn playlist_detail_handler(
|
||||
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
||||
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||
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
|
||||
JOIN furumusic__track t ON t.id = pt.track_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
|
||||
ORDER BY pt.position"#,
|
||||
)
|
||||
@@ -956,6 +703,12 @@ async fn build_track_items(
|
||||
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
|
||||
cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id),
|
||||
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())
|
||||
@@ -969,10 +722,17 @@ async fn likes_playlist_handler(
|
||||
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
||||
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||
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
|
||||
JOIN furumusic__track t ON t.id = ult.track_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
|
||||
ORDER BY ult.created_at DESC"#,
|
||||
)
|
||||
@@ -1426,9 +1186,16 @@ async fn search_handler(
|
||||
let t = sqlx::query_as::<_, SearchTrackRow>(
|
||||
r#"SELECT t.id, t.title::text AS title, t.track_number, t.disc_number,
|
||||
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
|
||||
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 || '%'
|
||||
ORDER BY t.title_sort LIMIT $2"#,
|
||||
)
|
||||
@@ -1488,22 +1255,32 @@ async fn search_handler(
|
||||
.fetch_all(pool);
|
||||
|
||||
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,
|
||||
t.duration_seconds, t.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
|
||||
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
|
||||
FROM furumusic__track WHERE is_hidden = false AND title_sort % $1
|
||||
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
|
||||
FROM furumusic__track WHERE is_hidden = false AND title_sort ILIKE '%' || $1 || '%'
|
||||
) t
|
||||
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
|
||||
LIMIT $2
|
||||
) sub"#,
|
||||
@@ -1568,6 +1345,11 @@ async fn search_handler(
|
||||
})
|
||||
.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
|
||||
.into_iter()
|
||||
.map(|r| ReleaseCard {
|
||||
@@ -1577,6 +1359,7 @@ async fn search_handler(
|
||||
year: r.year,
|
||||
cover_url: cover_url(r.cover_file_id),
|
||||
track_count: r.track_count,
|
||||
uploaders: release_uploaders.remove(&r.id).unwrap_or_default(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -1594,6 +1377,12 @@ async fn search_handler(
|
||||
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
|
||||
cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id),
|
||||
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();
|
||||
@@ -2006,6 +1795,124 @@ async fn liked_ids_handler(
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/player/follows — get followed artists for current user
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn followed_artists_handler(
|
||||
session: Session,
|
||||
db: Database,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
let Some(user) = auth::get_session_user(&session, &db).await else {
|
||||
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||
};
|
||||
|
||||
let rows = sqlx::query_as::<_, ArtistRow>(
|
||||
r#"SELECT a.id, a.name::text as name, a.image_file_id,
|
||||
COALESCE(s.release_count, 0)::bigint AS release_count,
|
||||
COALESCE(s.track_count, 0)::bigint AS track_count
|
||||
FROM furumusic__user_followed_artist ufa
|
||||
JOIN furumusic__artist a ON a.id = ufa.artist_id
|
||||
LEFT JOIN (
|
||||
SELECT ra.artist_id,
|
||||
COUNT(DISTINCT r.id) AS release_count,
|
||||
COUNT(t.id) AS track_count
|
||||
FROM furumusic__release_artist ra
|
||||
JOIN furumusic__release r ON r.id = ra.release_id AND r.is_hidden = false
|
||||
LEFT JOIN furumusic__track t ON t.release_id = r.id AND t.is_hidden = false
|
||||
WHERE ra.position = 0
|
||||
GROUP BY ra.artist_id
|
||||
) s ON s.artist_id = a.id
|
||||
WHERE ufa.user_id = $1 AND a.is_hidden = false
|
||||
ORDER BY ufa.created_at DESC, a.name_sort"#,
|
||||
)
|
||||
.bind(user.id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
let artist_ids = rows.iter().map(|row| row.id).collect();
|
||||
let artists = rows
|
||||
.into_iter()
|
||||
.map(|r| ArtistCard {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
image_url: cover_url(r.image_file_id),
|
||||
release_count: r.release_count,
|
||||
track_count: r.track_count,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Json(FollowedArtists {
|
||||
artist_ids,
|
||||
artists,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/player/follows/toggle/{id} — follow/unfollow artist
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn toggle_follow_artist_handler(
|
||||
session: Session,
|
||||
db: Database,
|
||||
pool: &sqlx::PgPool,
|
||||
path: Path<PathId>,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
let Some(user) = auth::get_session_user(&session, &db).await else {
|
||||
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||
};
|
||||
let artist_id = path.0.id;
|
||||
|
||||
let artist_exists: Option<(i64,)> =
|
||||
sqlx::query_as("SELECT id FROM furumusic__artist WHERE id = $1 AND is_hidden = false")
|
||||
.bind(artist_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
if artist_exists.is_none() {
|
||||
return Ok(json_error(StatusCode::NOT_FOUND, "artist not found"));
|
||||
}
|
||||
|
||||
let existing: Option<(i64,)> = sqlx::query_as(
|
||||
"SELECT id FROM furumusic__user_followed_artist WHERE user_id = $1 AND artist_id = $2",
|
||||
)
|
||||
.bind(user.id)
|
||||
.bind(artist_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
if existing.is_some() {
|
||||
sqlx::query(
|
||||
"DELETE FROM furumusic__user_followed_artist WHERE user_id = $1 AND artist_id = $2",
|
||||
)
|
||||
.bind(user.id)
|
||||
.bind(artist_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
Json(FollowStatus { followed: false }).into_response()
|
||||
} else {
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
sqlx::query(
|
||||
r#"INSERT INTO furumusic__user_followed_artist (user_id, artist_id, created_at)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id, artist_id) DO NOTHING"#,
|
||||
)
|
||||
.bind(user.id)
|
||||
.bind(artist_id)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
Json(FollowStatus { followed: true }).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/player/tracks-by-ids
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2030,9 +1937,16 @@ async fn tracks_by_ids_handler(
|
||||
let tracks = sqlx::query_as::<_, TrackRow>(
|
||||
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||
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
|
||||
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"#,
|
||||
)
|
||||
.bind(&ids)
|
||||
@@ -2097,6 +2011,12 @@ async fn tracks_by_ids_handler(
|
||||
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
|
||||
cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id),
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -2213,8 +2133,7 @@ impl App for PlayerApp {
|
||||
let torrent_service = Arc::clone(&torrent_service);
|
||||
let scheduler_handle = Arc::clone(&scheduler_handle);
|
||||
async move {
|
||||
let Some(_user) = auth::get_session_user(&session, &db).await
|
||||
else {
|
||||
let Some(user) = auth::get_session_user(&session, &db).await else {
|
||||
return Ok(json_error(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"not authenticated",
|
||||
@@ -2231,6 +2150,7 @@ impl App for PlayerApp {
|
||||
&path.0.id,
|
||||
json.0.selected_files,
|
||||
live_config.agent_inbox_dir,
|
||||
user.id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -2595,6 +2515,56 @@ impl App for PlayerApp {
|
||||
}),
|
||||
"player_like_release",
|
||||
),
|
||||
// -- Followed artists --
|
||||
Route::with_handler_and_name(
|
||||
"/follows",
|
||||
get({
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
move |session: Session, db: Database| {
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
async move {
|
||||
let pg_pool = pool
|
||||
.get_or_init(|| async {
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&pool_config.database_url)
|
||||
.await
|
||||
.expect("player pool")
|
||||
})
|
||||
.await;
|
||||
followed_artists_handler(session, db, pg_pool).await
|
||||
}
|
||||
}
|
||||
}),
|
||||
"player_follows",
|
||||
),
|
||||
// -- Follow/unfollow artist --
|
||||
Route::with_handler_and_name(
|
||||
"/follows/toggle/{id}",
|
||||
post({
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
move |session: Session, db: Database, path: Path<PathId>| {
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
async move {
|
||||
let pg_pool = pool
|
||||
.get_or_init(|| async {
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&pool_config.database_url)
|
||||
.await
|
||||
.expect("player pool")
|
||||
})
|
||||
.await;
|
||||
toggle_follow_artist_handler(session, db, pg_pool, path).await
|
||||
}
|
||||
}
|
||||
}),
|
||||
"player_follow_toggle",
|
||||
),
|
||||
// -- Audio stream --
|
||||
Route::with_handler_and_name(
|
||||
"/stream/{track_id}",
|
||||
|
||||
@@ -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,
|
||||
selected_files: Vec<usize>,
|
||||
inbox_dir: String,
|
||||
uploader_user_id: i64,
|
||||
) -> anyhow::Result<TorrentJobDto> {
|
||||
if selected_files.is_empty() {
|
||||
bail!("select at least one file");
|
||||
@@ -371,7 +372,10 @@ impl TorrentService {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
});
|
||||
@@ -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 mut jobs = self.jobs.lock().await;
|
||||
let job = jobs.get_mut(id).context("torrent job not found")?;
|
||||
@@ -414,7 +423,8 @@ impl TorrentService {
|
||||
};
|
||||
|
||||
let destination_root = inbox_dir
|
||||
.join("torrents")
|
||||
.join("user_uploads")
|
||||
.join(uploader_user_id.to_string())
|
||||
.join(sanitize_path_component(&name));
|
||||
tokio::fs::create_dir_all(&destination_root).await?;
|
||||
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
<td><input name="oidc_admin_groups" id="oidc_admin_groups" value="{{ oidc_admin_groups }}" style="width:100%"></td>
|
||||
<td><span class="badge badge-{{ oidc_admin_groups_source }}">{{ oidc_admin_groups_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="oidc_user_groups">{{ t.settings_oidc_user_groups }}</label><br><span style="font-size:.75rem;color:#999;">{{ t.settings_oidc_user_groups_help }}</span></td>
|
||||
<td><input name="oidc_user_groups" id="oidc_user_groups" value="{{ oidc_user_groups }}" style="width:100%"></td>
|
||||
<td><span class="badge badge-{{ oidc_user_groups_source }}">{{ oidc_user_groups_source }}</span></td>
|
||||
</tr>
|
||||
</table>
|
||||
<h2>{{ t.settings_api }}</h2>
|
||||
<table>
|
||||
|
||||
+508
-7
@@ -208,6 +208,84 @@ button.user-stat:hover {
|
||||
|
||||
.sidebar-nav-item svg { width: 20px; height: 20px; flex-shrink: 0; }
|
||||
|
||||
.sidebar-section {
|
||||
padding: 8px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sidebar-section-title {
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-subdued);
|
||||
}
|
||||
|
||||
.following-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.following-artist {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.following-artist:hover,
|
||||
.following-artist.active {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.following-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-elevated);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.following-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.following-avatar svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--text-subdued);
|
||||
}
|
||||
|
||||
.following-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.following-empty {
|
||||
padding: 8px 12px;
|
||||
color: var(--text-subdued);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.playlist-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -435,6 +513,17 @@ button.user-stat:hover {
|
||||
.release-meta .release-type { font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); }
|
||||
.release-meta .release-title { font-size: 36px; font-weight: 900; line-height: 1.2; margin: 4px 0; }
|
||||
.release-meta .release-artists { font-size: 14px; color: var(--text-secondary); }
|
||||
|
||||
.artist-link {
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.artist-link:hover {
|
||||
color: var(--text-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.release-meta .release-year { font-size: 14px; color: var(--text-subdued); margin-top: 4px; }
|
||||
|
||||
/* Track list table */
|
||||
@@ -521,6 +610,47 @@ button.user-stat:hover {
|
||||
.track-action-btn.play-btn:hover { color: var(--accent); }
|
||||
.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-btn {
|
||||
position: absolute;
|
||||
@@ -579,6 +709,49 @@ button.user-stat:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.release-action-btn.followed {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.artist-follow-card-btn {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
transition: opacity 0.2s, transform 0.2s, background 0.15s, color 0.15s;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.35);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.card:hover .artist-follow-card-btn,
|
||||
.search-artist-card:hover .artist-follow-card-btn,
|
||||
.artist-follow-card-btn.followed {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.artist-follow-card-btn.followed {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.artist-follow-card-btn svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
/* Queue Panel */
|
||||
.queue-panel {
|
||||
width: var(--queue-width);
|
||||
@@ -1155,6 +1328,7 @@ button.user-stat:hover {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-artist-img img { width: 100%; height: 100%; object-fit: cover; }
|
||||
@@ -1693,6 +1867,8 @@ button.user-stat:hover {
|
||||
.card-subtitle { font-size: 11px; }
|
||||
.card-play-btn,
|
||||
.card-enqueue-btn,
|
||||
.card-info-btn,
|
||||
.artist-follow-card-btn,
|
||||
.track-actions,
|
||||
.playlist-item-actions,
|
||||
.queue-track-actions,
|
||||
@@ -2135,6 +2311,33 @@ button.user-stat:hover {
|
||||
Artists
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-title">
|
||||
Following
|
||||
<span x-show="$store.follows.artists.length > 0"
|
||||
x-text="'(' + $store.follows.artists.length + ')'"></span>
|
||||
</div>
|
||||
<template x-if="$store.follows.artists.length === 0">
|
||||
<div class="following-empty">No followed artists</div>
|
||||
</template>
|
||||
<div class="following-list" x-show="$store.follows.artists.length > 0" x-cloak>
|
||||
<template x-for="artist in $store.follows.artists" :key="artist.id">
|
||||
<div class="following-artist"
|
||||
:class="{ active: $store.library.currentArtist && $store.library.currentArtist.id === artist.id }"
|
||||
@click="$store.library.openArtist(artist.id)">
|
||||
<div class="following-avatar">
|
||||
<template x-if="artist.image_url">
|
||||
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!artist.image_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||||
</template>
|
||||
</div>
|
||||
<div class="following-name" x-text="artist.name"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="playlist-list">
|
||||
<template x-for="pl in $store.playlists.list" :key="pl.id">
|
||||
<div class="playlist-item-row">
|
||||
@@ -2268,6 +2471,17 @@ button.user-stat:hover {
|
||||
<template x-if="!artist.image_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||||
</template>
|
||||
<button class="artist-follow-card-btn"
|
||||
:class="{ followed: $store.follows.has(artist.id) }"
|
||||
@click.stop="$store.follows.toggle(artist.id)"
|
||||
:title="$store.follows.has(artist.id) ? 'Unfollow artist' : 'Follow artist'">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path x-show="!$store.follows.has(artist.id)" d="M19 8v6M16 11h6"/>
|
||||
<path x-show="$store.follows.has(artist.id)" d="M16 11l2 2 4-5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="search-artist-name" x-text="artist.name"></div>
|
||||
</div>
|
||||
@@ -2289,6 +2503,9 @@ button.user-stat:hover {
|
||||
<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>
|
||||
</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 class="card-title" x-text="release.title"></div>
|
||||
<div class="card-subtitle">
|
||||
@@ -2318,10 +2535,20 @@ button.user-stat:hover {
|
||||
<span class="track-num" x-text="idx + 1"></span>
|
||||
<div class="track-info">
|
||||
<div class="track-title" x-text="track.title"></div>
|
||||
<div class="track-artists-inline" x-text="[...track.artists, ...track.featured_artists.map(a => ({...a, name: 'ft. ' + a.name}))].map(a => a.name).join(', ')"></div>
|
||||
<div class="track-artists-inline">
|
||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||||
<span>
|
||||
<template x-if="artistIdx > 0"><span>, </span></template>
|
||||
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<span></span>
|
||||
<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">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
@@ -2362,6 +2589,17 @@ button.user-stat:hover {
|
||||
<template x-if="!artist.image_url">
|
||||
<span class="placeholder-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg></span>
|
||||
</template>
|
||||
<button class="artist-follow-card-btn"
|
||||
:class="{ followed: $store.follows.has(artist.id) }"
|
||||
@click.stop="$store.follows.toggle(artist.id)"
|
||||
:title="$store.follows.has(artist.id) ? 'Unfollow artist' : 'Follow artist'">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path x-show="!$store.follows.has(artist.id)" d="M19 8v6M16 11h6"/>
|
||||
<path x-show="$store.follows.has(artist.id)" d="M16 11l2 2 4-5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-title" x-text="artist.name"></div>
|
||||
<div class="card-subtitle" x-text="artist.release_count + ' releases · ' + artist.track_count + ' tracks'"></div>
|
||||
@@ -2401,6 +2639,20 @@ button.user-stat:hover {
|
||||
<span>•</span>
|
||||
<span x-text="$store.library.currentArtist.total_play_count + ' plays'"></span>
|
||||
</div>
|
||||
<div class="release-actions">
|
||||
<button class="release-action-btn secondary"
|
||||
:class="{ followed: $store.follows.has($store.library.currentArtist.id) }"
|
||||
@click="$store.follows.toggle($store.library.currentArtist.id)"
|
||||
:title="$store.follows.has($store.library.currentArtist.id) ? 'Unfollow artist' : 'Follow artist'">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path x-show="!$store.follows.has($store.library.currentArtist.id)" d="M19 8v6M16 11h6"/>
|
||||
<path x-show="$store.follows.has($store.library.currentArtist.id)" d="M16 11l2 2 4-5"/>
|
||||
</svg>
|
||||
<span x-text="$store.follows.has($store.library.currentArtist.id) ? 'Following' : 'Follow'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template x-for="group in $store.library.artistReleaseGroups()" :key="group.type">
|
||||
@@ -2416,6 +2668,9 @@ button.user-stat:hover {
|
||||
<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>
|
||||
</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">
|
||||
<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>
|
||||
@@ -2433,6 +2688,62 @@ button.user-stat:hover {
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<template x-if="$store.library.currentArtist.featured_tracks && $store.library.currentArtist.featured_tracks.length > 0">
|
||||
<section class="artist-release-group">
|
||||
<h2 class="artist-release-group-title">Appears on</h2>
|
||||
<div class="track-list-header">
|
||||
<span>#</span>
|
||||
<span>Title</span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span style="text-align:right">Duration</span>
|
||||
</div>
|
||||
<template x-for="(track, idx) in $store.library.currentArtist.featured_tracks" :key="track.id">
|
||||
<div class="track-row"
|
||||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
||||
@dblclick="$store.queue.playRelease($store.library.currentArtist.featured_tracks, idx)">
|
||||
<span class="track-num" x-text="idx + 1"></span>
|
||||
<div class="track-info">
|
||||
<div class="track-title">
|
||||
<span x-text="track.title"></span>
|
||||
<span style="color:var(--text-subdued)"> · </span>
|
||||
<a class="artist-link" @click.stop="$store.library.openRelease(track.release_id)" x-text="track.release_title"></a>
|
||||
</div>
|
||||
<div class="track-artists-inline">
|
||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||||
<span>
|
||||
<template x-if="artistIdx > 0"><span>, </span></template>
|
||||
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<span></span>
|
||||
<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">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="Like">
|
||||
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn" @click.stop="$store.queue.addNextInQueue([track])" title="Play next">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn" @click.stop="$store.queue.addToEnd([track])" 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>
|
||||
</button>
|
||||
<button class="track-action-btn" @click.stop="$store.playlists.showPicker([track.id])" title="Add to playlist">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<span class="track-duration" x-text="formatTime(track.duration_seconds)"></span>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2460,9 +2771,23 @@ button.user-stat:hover {
|
||||
<div class="release-meta">
|
||||
<div class="release-type" x-text="$store.library.currentRelease.release_type"></div>
|
||||
<div class="release-title" x-text="$store.library.currentRelease.title"></div>
|
||||
<div class="release-artists" x-text="$store.library.currentRelease.artists.map(a => a.name).join(', ')"></div>
|
||||
<div class="release-artists">
|
||||
<template x-for="(artist, artistIdx) in $store.library.currentRelease.artists" :key="artist.id">
|
||||
<span>
|
||||
<template x-if="artistIdx > 0"><span>, </span></template>
|
||||
<a class="artist-link" @click="$store.library.openArtist(artist.id)" x-text="artist.name"></a>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="release-year" x-text="$store.library.currentRelease.year || ''"></div>
|
||||
<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)">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||
Play
|
||||
@@ -2499,10 +2824,20 @@ button.user-stat:hover {
|
||||
<span class="track-num" x-text="track.track_number || (idx + 1)"></span>
|
||||
<div class="track-info">
|
||||
<div class="track-title" x-text="track.title"></div>
|
||||
<div class="track-artists-inline" x-text="[...track.artists, ...track.featured_artists.map(a => ({...a, name: 'ft. ' + a.name}))].map(a => a.name).join(', ')"></div>
|
||||
<div class="track-artists-inline">
|
||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||||
<span>
|
||||
<template x-if="artistIdx > 0"><span>, </span></template>
|
||||
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<span></span>
|
||||
<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">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
@@ -2551,10 +2886,20 @@ button.user-stat:hover {
|
||||
<span class="track-num" x-text="idx + 1"></span>
|
||||
<div class="track-info">
|
||||
<div class="track-title" x-text="track.title"></div>
|
||||
<div class="track-artists-inline" x-text="[...track.artists, ...track.featured_artists.map(a => ({...a, name: 'ft. ' + a.name}))].map(a => a.name).join(', ')"></div>
|
||||
<div class="track-artists-inline">
|
||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||||
<span>
|
||||
<template x-if="artistIdx > 0"><span>, </span></template>
|
||||
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<span></span>
|
||||
<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">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
@@ -2614,9 +2959,19 @@ button.user-stat:hover {
|
||||
</div>
|
||||
<div class="queue-track-info">
|
||||
<div class="queue-track-title" x-text="track.title"></div>
|
||||
<div class="queue-track-artist" x-text="track.artists.map(a => a.name).join(', ')"></div>
|
||||
<div class="queue-track-artist">
|
||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||||
<span>
|
||||
<template x-if="artistIdx > 0"><span>, </span></template>
|
||||
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
@@ -2642,7 +2997,14 @@ button.user-stat:hover {
|
||||
</div>
|
||||
<div class="player-track-info">
|
||||
<div class="player-track-title" x-text="$store.player.currentTrack.title"></div>
|
||||
<div class="player-track-artist" x-text="$store.player.currentTrack.artists.map(a => a.name).join(', ')"></div>
|
||||
<div class="player-track-artist">
|
||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks($store.player.currentTrack)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||||
<span>
|
||||
<template x-if="artistIdx > 0"><span>, </span></template>
|
||||
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3308,7 +3670,7 @@ document.addEventListener('alpine:init', () => {
|
||||
Alpine.store('queue', {
|
||||
tracks: [],
|
||||
currentIndex: 0,
|
||||
visible: true,
|
||||
visible: false,
|
||||
_dragIdx: null,
|
||||
|
||||
add(track) {
|
||||
@@ -3523,6 +3885,70 @@ document.addEventListener('alpine:init', () => {
|
||||
}));
|
||||
},
|
||||
|
||||
trackArtistLinks(track) {
|
||||
const main = (track?.artists || []).map(artist => ({
|
||||
id: artist.id,
|
||||
label: artist.name,
|
||||
}));
|
||||
const featured = (track?.featured_artists || []).map(artist => ({
|
||||
id: artist.id,
|
||||
label: 'ft. ' + artist.name,
|
||||
}));
|
||||
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) {
|
||||
this.searchQuery = '';
|
||||
this.searchResults = null;
|
||||
@@ -3701,6 +4127,81 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Artist follows store
|
||||
// -----------------------------------------------------------------------
|
||||
Alpine.store('follows', {
|
||||
_set: new Set(),
|
||||
artists: [],
|
||||
|
||||
init() {
|
||||
this.reload();
|
||||
},
|
||||
|
||||
has(artistId) {
|
||||
return this._set.has(Number(artistId));
|
||||
},
|
||||
|
||||
async reload() {
|
||||
try {
|
||||
const res = await fetch('/api/player/follows');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
this._set = new Set((data.artist_ids || []).map(Number));
|
||||
this.artists = data.artists || [];
|
||||
} catch {}
|
||||
},
|
||||
|
||||
_artistSnapshot(artistId) {
|
||||
const id = Number(artistId);
|
||||
const library = Alpine.store('library');
|
||||
const fromLists = [
|
||||
...(library.artists || []),
|
||||
...((library.searchResults && library.searchResults.artists) || []),
|
||||
].find(artist => Number(artist.id) === id);
|
||||
if (fromLists) return fromLists;
|
||||
if (library.currentArtist && Number(library.currentArtist.id) === id) {
|
||||
return {
|
||||
id,
|
||||
name: library.currentArtist.name,
|
||||
image_url: library.currentArtist.image_url,
|
||||
release_count: (library.currentArtist.releases || []).length,
|
||||
track_count: library.currentArtist.total_track_count || 0,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
async toggle(artistId) {
|
||||
const id = Number(artistId);
|
||||
if (!id) return;
|
||||
|
||||
if (this._set.has(id)) {
|
||||
this._set.delete(id);
|
||||
this.artists = this.artists.filter(artist => Number(artist.id) !== id);
|
||||
} else {
|
||||
this._set.add(id);
|
||||
const snapshot = this._artistSnapshot(id);
|
||||
if (snapshot && !this.artists.some(artist => Number(artist.id) === id)) {
|
||||
this.artists = [snapshot, ...this.artists];
|
||||
}
|
||||
}
|
||||
this._set = new Set(this._set);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/player/follows/toggle/${id}`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.followed) this._set.add(id);
|
||||
else this._set.delete(id);
|
||||
this._set = new Set(this._set);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
await this.reload();
|
||||
},
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Torrent import store
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user