Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cae77e9401 | |||
| 709f319bc5 | |||
| bf0a2a553c | |||
| 3fc9b16e2c | |||
| 29f6d04d12 | |||
| c34485b521 | |||
| bc9f9605d8 | |||
| 2f0ed2ee09 |
Generated
+1030
-14
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.1.1"
|
version = "0.1.6"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||||
|
|
||||||
@@ -26,3 +26,4 @@ tokio-cron-scheduler = "0.15"
|
|||||||
croner = "3"
|
croner = "3"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
uuid = "1"
|
uuid = "1"
|
||||||
|
librqbit = { version = "8.1.1", features = ["disable-upload"] }
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Built with Rust ([cot](https://cot.rs) framework).
|
|||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export FURU_DATABASE_URL=postgres://user:pass@localhost/furumusic
|
export FURU_DATABASE_URL=postgresql://user:pass@localhost/furumusic
|
||||||
cargo run
|
cargo run
|
||||||
# Open http://localhost:8000/admin/setup to create the first admin account
|
# Open http://localhost:8000/admin/setup to create the first admin account
|
||||||
```
|
```
|
||||||
@@ -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.
|
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:**
|
**User provisioning order:**
|
||||||
1. Find existing `OidcLink` by issuer+sub → update claims, update role
|
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_CLIENT_SECRET` | OIDC client secret | *(empty)* |
|
||||||
| `FURU_OIDC_BUTTON_TEXT` | SSO button label | `Sign in with SSO` |
|
| `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_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` |
|
| `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(),
|
config.oidc_admin_groups.clone(),
|
||||||
defaults.oidc_admin_groups.clone()
|
defaults.oidc_admin_groups.clone()
|
||||||
),
|
),
|
||||||
|
entry!(
|
||||||
|
oidc_user_groups,
|
||||||
|
config.oidc_user_groups.clone(),
|
||||||
|
defaults.oidc_user_groups.clone()
|
||||||
|
),
|
||||||
entry!(
|
entry!(
|
||||||
swagger_enabled,
|
swagger_enabled,
|
||||||
config.swagger_enabled.to_string(),
|
config.swagger_enabled.to_string(),
|
||||||
@@ -248,6 +253,8 @@ struct SettingsTemplate {
|
|||||||
oidc_client_secret_source: &'static str,
|
oidc_client_secret_source: &'static str,
|
||||||
oidc_admin_groups: String,
|
oidc_admin_groups: String,
|
||||||
oidc_admin_groups_source: &'static str,
|
oidc_admin_groups_source: &'static str,
|
||||||
|
oidc_user_groups: String,
|
||||||
|
oidc_user_groups_source: &'static str,
|
||||||
swagger_enabled: bool,
|
swagger_enabled: bool,
|
||||||
swagger_enabled_source: &'static str,
|
swagger_enabled_source: &'static str,
|
||||||
agent_enabled: bool,
|
agent_enabled: bool,
|
||||||
@@ -298,6 +305,8 @@ pub async fn settings_handler(
|
|||||||
oidc_client_secret_source: sources.oidc_client_secret.code(),
|
oidc_client_secret_source: sources.oidc_client_secret.code(),
|
||||||
oidc_admin_groups: config.oidc_admin_groups,
|
oidc_admin_groups: config.oidc_admin_groups,
|
||||||
oidc_admin_groups_source: sources.oidc_admin_groups.code(),
|
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: config.swagger_enabled,
|
||||||
swagger_enabled_source: sources.swagger_enabled.code(),
|
swagger_enabled_source: sources.swagger_enabled.code(),
|
||||||
agent_enabled: config.agent_enabled,
|
agent_enabled: config.agent_enabled,
|
||||||
@@ -331,6 +340,7 @@ pub struct OidcSettingsForm {
|
|||||||
oidc_client_id: Option<String>,
|
oidc_client_id: Option<String>,
|
||||||
oidc_client_secret: Option<String>,
|
oidc_client_secret: Option<String>,
|
||||||
oidc_admin_groups: Option<String>,
|
oidc_admin_groups: Option<String>,
|
||||||
|
oidc_user_groups: Option<String>,
|
||||||
swagger_enabled: Option<String>,
|
swagger_enabled: Option<String>,
|
||||||
agent_enabled: Option<String>,
|
agent_enabled: Option<String>,
|
||||||
agent_inbox_dir: 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_id = data.oidc_client_id.unwrap_or_default();
|
||||||
let oidc_client_secret = data.oidc_client_secret.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_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_inbox_dir = data.agent_inbox_dir.unwrap_or_default();
|
||||||
let agent_storage_dir = data.agent_storage_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();
|
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_confidence_threshold = data.agent_confidence_threshold.unwrap_or_default();
|
||||||
let agent_context_limit = data.agent_context_limit.unwrap_or_default();
|
let agent_context_limit = data.agent_context_limit.unwrap_or_default();
|
||||||
let agent_concurrency = data.agent_concurrency.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_password_enabled", pw_enabled),
|
||||||
("auth_sso_enabled", sso_enabled),
|
("auth_sso_enabled", sso_enabled),
|
||||||
("oidc_button_text", &oidc_button_text),
|
("oidc_button_text", &oidc_button_text),
|
||||||
@@ -394,6 +405,7 @@ pub async fn settings_submit(
|
|||||||
("oidc_client_id", &oidc_client_id),
|
("oidc_client_id", &oidc_client_id),
|
||||||
("oidc_client_secret", &oidc_client_secret),
|
("oidc_client_secret", &oidc_client_secret),
|
||||||
("oidc_admin_groups", &oidc_admin_groups),
|
("oidc_admin_groups", &oidc_admin_groups),
|
||||||
|
("oidc_user_groups", &oidc_user_groups),
|
||||||
("swagger_enabled", swagger),
|
("swagger_enabled", swagger),
|
||||||
("agent_enabled", agent_en),
|
("agent_enabled", agent_en),
|
||||||
("agent_inbox_dir", &agent_inbox_dir),
|
("agent_inbox_dir", &agent_inbox_dir),
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ pub struct ConfigSources {
|
|||||||
pub auth_sso_enabled: ConfigSource,
|
pub auth_sso_enabled: ConfigSource,
|
||||||
pub oidc_button_text: ConfigSource,
|
pub oidc_button_text: ConfigSource,
|
||||||
pub oidc_admin_groups: ConfigSource,
|
pub oidc_admin_groups: ConfigSource,
|
||||||
|
pub oidc_user_groups: ConfigSource,
|
||||||
pub swagger_enabled: ConfigSource,
|
pub swagger_enabled: ConfigSource,
|
||||||
pub agent_enabled: ConfigSource,
|
pub agent_enabled: ConfigSource,
|
||||||
pub agent_inbox_dir: ConfigSource,
|
pub agent_inbox_dir: ConfigSource,
|
||||||
@@ -146,6 +147,7 @@ impl Default for ConfigSources {
|
|||||||
auth_sso_enabled: ConfigSource::Default,
|
auth_sso_enabled: ConfigSource::Default,
|
||||||
oidc_button_text: ConfigSource::Default,
|
oidc_button_text: ConfigSource::Default,
|
||||||
oidc_admin_groups: ConfigSource::Default,
|
oidc_admin_groups: ConfigSource::Default,
|
||||||
|
oidc_user_groups: ConfigSource::Default,
|
||||||
swagger_enabled: ConfigSource::Default,
|
swagger_enabled: ConfigSource::Default,
|
||||||
agent_enabled: ConfigSource::Default,
|
agent_enabled: ConfigSource::Default,
|
||||||
agent_inbox_dir: ConfigSource::Default,
|
agent_inbox_dir: ConfigSource::Default,
|
||||||
@@ -238,6 +240,8 @@ pub struct AppConfig {
|
|||||||
pub oidc_button_text: String,
|
pub oidc_button_text: String,
|
||||||
/// Comma-separated list of OIDC group names that grant admin role.
|
/// Comma-separated list of OIDC group names that grant admin role.
|
||||||
pub oidc_admin_groups: String,
|
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/.
|
/// Whether the Swagger UI is served at /swagger/.
|
||||||
pub swagger_enabled: bool,
|
pub swagger_enabled: bool,
|
||||||
/// Whether the AI agent background loop is enabled.
|
/// Whether the AI agent background loop is enabled.
|
||||||
@@ -272,6 +276,7 @@ impl Default for AppConfig {
|
|||||||
auth_sso_enabled: false,
|
auth_sso_enabled: false,
|
||||||
oidc_button_text: "Sign in with SSO".into(),
|
oidc_button_text: "Sign in with SSO".into(),
|
||||||
oidc_admin_groups: String::new(),
|
oidc_admin_groups: String::new(),
|
||||||
|
oidc_user_groups: String::new(),
|
||||||
swagger_enabled: false,
|
swagger_enabled: false,
|
||||||
agent_enabled: false,
|
agent_enabled: false,
|
||||||
agent_inbox_dir: String::new(),
|
agent_inbox_dir: String::new(),
|
||||||
@@ -297,6 +302,7 @@ impl_env_overrides!(
|
|||||||
auth_sso_enabled,
|
auth_sso_enabled,
|
||||||
oidc_button_text,
|
oidc_button_text,
|
||||||
oidc_admin_groups,
|
oidc_admin_groups,
|
||||||
|
oidc_user_groups,
|
||||||
swagger_enabled,
|
swagger_enabled,
|
||||||
agent_enabled,
|
agent_enabled,
|
||||||
agent_inbox_dir,
|
agent_inbox_dir,
|
||||||
@@ -372,6 +378,7 @@ impl AppConfig {
|
|||||||
apply_db_field!(auth_sso_enabled);
|
apply_db_field!(auth_sso_enabled);
|
||||||
apply_db_field!(oidc_button_text);
|
apply_db_field!(oidc_button_text);
|
||||||
apply_db_field!(oidc_admin_groups);
|
apply_db_field!(oidc_admin_groups);
|
||||||
|
apply_db_field!(oidc_user_groups);
|
||||||
apply_db_field!(swagger_enabled);
|
apply_db_field!(swagger_enabled);
|
||||||
apply_db_field!(agent_enabled);
|
apply_db_field!(agent_enabled);
|
||||||
apply_db_field!(agent_inbox_dir);
|
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_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: "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_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
|
// User management
|
||||||
nav_users: "Users" , "Пользователи";
|
nav_users: "Users" , "Пользователи";
|
||||||
@@ -97,6 +99,7 @@ translations! {
|
|||||||
// OIDC login errors
|
// OIDC login errors
|
||||||
login_oidc_error: "SSO login failed. Please try again." , "Ошибка входа через SSO. Попробуйте ещё раз.";
|
login_oidc_error: "SSO login failed. Please try again." , "Ошибка входа через SSO. Попробуйте ещё раз.";
|
||||||
login_sso_disabled: "SSO login is not configured." , "Вход через SSO не настроен.";
|
login_sso_disabled: "SSO login is not configured." , "Вход через SSO не настроен.";
|
||||||
|
login_access_denied: "Access denied. Contact your administrator." , "Доступ запрещён. Обратитесь к администратору.";
|
||||||
|
|
||||||
// Artist management
|
// Artist management
|
||||||
nav_artists: "Artists" , "Артисты";
|
nav_artists: "Artists" , "Артисты";
|
||||||
|
|||||||
+6
-1
@@ -9,6 +9,7 @@ mod music;
|
|||||||
mod oidc;
|
mod oidc;
|
||||||
mod player;
|
mod player;
|
||||||
mod scheduler;
|
mod scheduler;
|
||||||
|
mod torrents;
|
||||||
mod user;
|
mod user;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -280,6 +281,7 @@ impl Project for FuruProject {
|
|||||||
" FURU_OIDC_CLIENT_SECRET OIDC client secret\n",
|
" FURU_OIDC_CLIENT_SECRET OIDC client secret\n",
|
||||||
" FURU_OIDC_BUTTON_TEXT SSO button label (default: Sign in with SSO)\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_ADMIN_GROUPS OIDC groups that grant admin role\n",
|
||||||
|
" FURU_OIDC_USER_GROUPS OIDC groups allowed to access the service\n",
|
||||||
"\n",
|
"\n",
|
||||||
" API:\n",
|
" API:\n",
|
||||||
" FURU_SWAGGER_ENABLED Enable Swagger UI at /swagger/ (default: false)\n",
|
" FURU_SWAGGER_ENABLED Enable Swagger UI at /swagger/ (default: false)\n",
|
||||||
@@ -370,7 +372,10 @@ impl Project for FuruProject {
|
|||||||
);
|
);
|
||||||
apps.register_with_views(api::ApiApp, "/api");
|
apps.register_with_views(api::ApiApp, "/api");
|
||||||
apps.register_with_views(
|
apps.register_with_views(
|
||||||
player::PlayerApp::new(Arc::clone(&self.app_config)),
|
player::PlayerApp::new(
|
||||||
|
Arc::clone(&self.app_config),
|
||||||
|
Arc::clone(&self.scheduler_handle),
|
||||||
|
),
|
||||||
"/api/player",
|
"/api/player",
|
||||||
);
|
);
|
||||||
if self.app_config.swagger_enabled {
|
if self.app_config.swagger_enabled {
|
||||||
|
|||||||
+36
-1
@@ -384,10 +384,24 @@ pub async fn oidc_callback_handler(
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
tracing::info!(
|
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_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.
|
// User provisioning logic.
|
||||||
let user = match provision_user(
|
let user = match provision_user(
|
||||||
&db,
|
&db,
|
||||||
@@ -458,6 +472,27 @@ fn resolve_role(groups: &[String], admin_groups: &str) -> &'static str {
|
|||||||
auth::Role::User.code()
|
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(
|
async fn provision_user(
|
||||||
db: &Database,
|
db: &Database,
|
||||||
issuer: &str,
|
issuer: &str,
|
||||||
|
|||||||
+488
-10
@@ -16,6 +16,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
use crate::auth;
|
use crate::auth;
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
use crate::i18n::Translations;
|
use crate::i18n::Translations;
|
||||||
|
use crate::scheduler::SchedulerHandle;
|
||||||
|
use crate::torrents::{TorrentPreviewRequest, TorrentService, TorrentStartRequest};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// JSON error helper
|
// JSON error helper
|
||||||
@@ -40,6 +42,7 @@ struct ArtistCard {
|
|||||||
name: String,
|
name: String,
|
||||||
image_url: Option<String>,
|
image_url: Option<String>,
|
||||||
release_count: i64,
|
release_count: i64,
|
||||||
|
track_count: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
@@ -68,6 +71,7 @@ struct ArtistDetail {
|
|||||||
total_track_count: i64,
|
total_track_count: i64,
|
||||||
total_play_count: i64,
|
total_play_count: i64,
|
||||||
releases: Vec<ReleaseCard>,
|
releases: Vec<ReleaseCard>,
|
||||||
|
featured_tracks: Vec<ArtistAppearanceTrack>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
@@ -89,6 +93,19 @@ struct TrackItem {
|
|||||||
stream_url: String,
|
stream_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
struct ArtistAppearanceTrack {
|
||||||
|
id: i64,
|
||||||
|
title: String,
|
||||||
|
release_id: i64,
|
||||||
|
release_title: String,
|
||||||
|
duration_seconds: f64,
|
||||||
|
artists: Vec<ArtistRef>,
|
||||||
|
featured_artists: Vec<ArtistRef>,
|
||||||
|
cover_url: Option<String>,
|
||||||
|
stream_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
struct ReleaseDetail {
|
struct ReleaseDetail {
|
||||||
id: i64,
|
id: i64,
|
||||||
@@ -137,6 +154,40 @@ struct SearchResults {
|
|||||||
tracks: Vec<TrackItem>,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct HistoryEntry {
|
struct HistoryEntry {
|
||||||
track_id: i64,
|
track_id: i64,
|
||||||
@@ -144,6 +195,12 @@ struct HistoryEntry {
|
|||||||
completed: bool,
|
completed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct HistoryQuery {
|
||||||
|
page: Option<i32>,
|
||||||
|
limit: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct TracksByIdsRequest {
|
struct TracksByIdsRequest {
|
||||||
ids: Vec<i64>,
|
ids: Vec<i64>,
|
||||||
@@ -195,6 +252,11 @@ struct PathId {
|
|||||||
id: i64,
|
id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct PathStringId {
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct SearchQuery {
|
struct SearchQuery {
|
||||||
q: String,
|
q: String,
|
||||||
@@ -221,6 +283,7 @@ struct ArtistRow {
|
|||||||
name: String,
|
name: String,
|
||||||
image_file_id: Option<i64>,
|
image_file_id: Option<i64>,
|
||||||
release_count: i64,
|
release_count: i64,
|
||||||
|
track_count: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
@@ -308,12 +371,24 @@ struct PlaylistTrackRow {
|
|||||||
release_cover_file_id: Option<i64>,
|
release_cover_file_id: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct AppearanceTrackRow {
|
||||||
|
id: i64,
|
||||||
|
title: String,
|
||||||
|
release_id: i64,
|
||||||
|
release_title: String,
|
||||||
|
duration_seconds: f64,
|
||||||
|
cover_file_id: Option<i64>,
|
||||||
|
release_cover_file_id: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct SearchArtistRow {
|
struct SearchArtistRow {
|
||||||
id: i64,
|
id: i64,
|
||||||
name: String,
|
name: String,
|
||||||
image_file_id: Option<i64>,
|
image_file_id: Option<i64>,
|
||||||
release_count: i64,
|
release_count: i64,
|
||||||
|
track_count: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
@@ -337,6 +412,17 @@ struct SearchTrackRow {
|
|||||||
release_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)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct ReleaseInfoRow {
|
struct ReleaseInfoRow {
|
||||||
id: i64,
|
id: i64,
|
||||||
@@ -368,6 +454,61 @@ pub struct PlayerPageTemplate {
|
|||||||
pub t: &'static Translations,
|
pub t: &'static Translations,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/player/me
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn me_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 liked_tracks: (i64,) =
|
||||||
|
sqlx::query_as("SELECT COUNT(*) FROM furumusic__user_liked_track WHERE user_id = $1")
|
||||||
|
.bind(user.id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let playlists: (i64,) =
|
||||||
|
sqlx::query_as("SELECT COUNT(*) FROM furumusic__playlist WHERE owner_id = $1")
|
||||||
|
.bind(user.id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let plays: (i64,) =
|
||||||
|
sqlx::query_as("SELECT COUNT(*) FROM furumusic__play_history WHERE user_id = $1")
|
||||||
|
.bind(user.id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let listened_seconds: Option<i64> = sqlx::query_scalar(
|
||||||
|
"SELECT COALESCE(SUM(duration_listened), 0) FROM furumusic__play_history WHERE user_id = $1",
|
||||||
|
)
|
||||||
|
.bind(user.id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Json(UserProfile {
|
||||||
|
name: user.name,
|
||||||
|
role: user.role.code().to_string(),
|
||||||
|
stats: UserStats {
|
||||||
|
liked_tracks: liked_tracks.0,
|
||||||
|
playlists: playlists.0,
|
||||||
|
plays: plays.0,
|
||||||
|
listened_minutes: listened_seconds.unwrap_or(0) / 60,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /api/player/artists?page=N&limit=N
|
// GET /api/player/artists?page=N&limit=N
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -387,7 +528,11 @@ async fn artists_handler(
|
|||||||
let offset = (page - 1) as i64 * per_page as i64;
|
let offset = (page - 1) as i64 * per_page as i64;
|
||||||
|
|
||||||
let total_row = sqlx::query_as::<_, CountRow>(
|
let total_row = sqlx::query_as::<_, CountRow>(
|
||||||
"SELECT COUNT(*) as count FROM furumusic__artist WHERE is_hidden = false",
|
r#"SELECT COUNT(DISTINCT a.id) AS count
|
||||||
|
FROM furumusic__artist a
|
||||||
|
JOIN furumusic__release_artist ra ON ra.artist_id = a.id
|
||||||
|
JOIN furumusic__release r ON r.id = ra.release_id
|
||||||
|
WHERE a.is_hidden = false AND r.is_hidden = false AND ra.position = 0"#,
|
||||||
)
|
)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await
|
.await
|
||||||
@@ -395,12 +540,21 @@ async fn artists_handler(
|
|||||||
|
|
||||||
let rows = sqlx::query_as::<_, ArtistRow>(
|
let rows = sqlx::query_as::<_, ArtistRow>(
|
||||||
r#"SELECT a.id, a.name::text as name, a.image_file_id,
|
r#"SELECT a.id, a.name::text as name, a.image_file_id,
|
||||||
COALESCE((SELECT COUNT(*) FROM furumusic__release_artist ra
|
s.release_count,
|
||||||
JOIN furumusic__release r ON r.id = ra.release_id AND r.is_hidden = false
|
s.track_count
|
||||||
WHERE ra.artist_id = a.id), 0) as release_count
|
|
||||||
FROM furumusic__artist a
|
FROM furumusic__artist a
|
||||||
|
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 a.is_hidden = false
|
WHERE a.is_hidden = false
|
||||||
ORDER BY a.name_sort
|
ORDER BY s.release_count DESC, s.track_count DESC, a.name_sort
|
||||||
LIMIT $1 OFFSET $2"#,
|
LIMIT $1 OFFSET $2"#,
|
||||||
)
|
)
|
||||||
.bind(per_page as i64)
|
.bind(per_page as i64)
|
||||||
@@ -416,6 +570,7 @@ async fn artists_handler(
|
|||||||
name: r.name,
|
name: r.name,
|
||||||
image_url: cover_url(r.image_file_id),
|
image_url: cover_url(r.image_file_id),
|
||||||
release_count: r.release_count,
|
release_count: r.release_count,
|
||||||
|
track_count: r.track_count,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -503,6 +658,86 @@ async fn artist_detail_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let 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
|
||||||
|
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
|
||||||
|
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}"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
Json(ArtistDetail {
|
Json(ArtistDetail {
|
||||||
id: artist.id,
|
id: artist.id,
|
||||||
name: artist.name,
|
name: artist.name,
|
||||||
@@ -510,6 +745,7 @@ async fn artist_detail_handler(
|
|||||||
total_track_count,
|
total_track_count,
|
||||||
total_play_count,
|
total_play_count,
|
||||||
releases: release_cards,
|
releases: release_cards,
|
||||||
|
featured_tracks,
|
||||||
})
|
})
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
@@ -1145,6 +1381,69 @@ async fn put_state_handler(
|
|||||||
// POST /api/player/history
|
// POST /api/player/history
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn history_list_handler(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
query: cot::request::extractors::UrlQuery<HistoryQuery>,
|
||||||
|
) -> 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 page = query.0.page.unwrap_or(1).max(1);
|
||||||
|
let per_page = query.0.limit.unwrap_or(20).clamp(1, 100);
|
||||||
|
let offset = (page - 1) as i64 * per_page as i64;
|
||||||
|
|
||||||
|
let total: i64 =
|
||||||
|
sqlx::query_scalar("SELECT COUNT(*) FROM furumusic__play_history WHERE user_id = $1")
|
||||||
|
.bind(user.id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, PlayHistoryRow>(
|
||||||
|
r#"SELECT ph.id,
|
||||||
|
ph.track_id,
|
||||||
|
t.title::text AS track_title,
|
||||||
|
r.title::text AS release_title,
|
||||||
|
ph.played_at::text AS played_at,
|
||||||
|
ph.duration_listened,
|
||||||
|
ph.completed
|
||||||
|
FROM furumusic__play_history ph
|
||||||
|
JOIN furumusic__track t ON t.id = ph.track_id
|
||||||
|
LEFT JOIN furumusic__release r ON r.id = t.release_id
|
||||||
|
WHERE ph.user_id = $1
|
||||||
|
ORDER BY ph.played_at DESC, ph.id DESC
|
||||||
|
LIMIT $2 OFFSET $3"#,
|
||||||
|
)
|
||||||
|
.bind(user.id)
|
||||||
|
.bind(per_page as i64)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Json(PlayHistoryPage {
|
||||||
|
items: rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| PlayHistoryItem {
|
||||||
|
id: row.id,
|
||||||
|
track_id: row.track_id,
|
||||||
|
track_title: row.track_title,
|
||||||
|
release_title: row.release_title,
|
||||||
|
played_at: row.played_at,
|
||||||
|
duration_listened: row.duration_listened,
|
||||||
|
completed: row.completed,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
per_page,
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
async fn history_handler(
|
async fn history_handler(
|
||||||
session: Session,
|
session: Session,
|
||||||
db: Database,
|
db: Database,
|
||||||
@@ -1205,7 +1504,11 @@ async fn search_handler(
|
|||||||
r#"SELECT a.id, a.name::text AS name, a.image_file_id,
|
r#"SELECT a.id, a.name::text AS name, a.image_file_id,
|
||||||
COALESCE((SELECT COUNT(*) FROM furumusic__release_artist ra
|
COALESCE((SELECT COUNT(*) FROM furumusic__release_artist ra
|
||||||
JOIN furumusic__release r ON r.id = ra.release_id AND r.is_hidden = false
|
JOIN furumusic__release r ON r.id = ra.release_id AND r.is_hidden = false
|
||||||
WHERE ra.artist_id = a.id), 0) AS release_count
|
WHERE ra.artist_id = a.id), 0) AS release_count,
|
||||||
|
COALESCE((SELECT COUNT(*) FROM furumusic__release_artist ra
|
||||||
|
JOIN furumusic__release r ON r.id = ra.release_id AND r.is_hidden = false
|
||||||
|
JOIN furumusic__track t ON t.release_id = r.id AND t.is_hidden = false
|
||||||
|
WHERE ra.artist_id = a.id), 0) AS track_count
|
||||||
FROM furumusic__artist a
|
FROM furumusic__artist a
|
||||||
WHERE a.is_hidden = false AND a.name_sort ILIKE '%' || $1 || '%'
|
WHERE a.is_hidden = false AND a.name_sort ILIKE '%' || $1 || '%'
|
||||||
ORDER BY a.name_sort LIMIT $2"#,
|
ORDER BY a.name_sort LIMIT $2"#,
|
||||||
@@ -1242,11 +1545,15 @@ async fn search_handler(
|
|||||||
tokio::try_join!(a, r, t).map_err(|e| cot::Error::internal(e.to_string()))?
|
tokio::try_join!(a, r, t).map_err(|e| cot::Error::internal(e.to_string()))?
|
||||||
} else {
|
} else {
|
||||||
let a = sqlx::query_as::<_, SearchArtistRow>(
|
let a = sqlx::query_as::<_, SearchArtistRow>(
|
||||||
r#"SELECT id, name, image_file_id, release_count FROM (
|
r#"SELECT id, name, image_file_id, release_count, track_count FROM (
|
||||||
SELECT a.id, a.name::text AS name, a.image_file_id,
|
SELECT a.id, a.name::text AS name, a.image_file_id,
|
||||||
COALESCE((SELECT COUNT(*) FROM furumusic__release_artist ra
|
COALESCE((SELECT COUNT(*) FROM furumusic__release_artist ra
|
||||||
JOIN furumusic__release r ON r.id = ra.release_id AND r.is_hidden = false
|
JOIN furumusic__release r ON r.id = ra.release_id AND r.is_hidden = false
|
||||||
WHERE ra.artist_id = a.id), 0) AS release_count,
|
WHERE ra.artist_id = a.id), 0) AS release_count,
|
||||||
|
COALESCE((SELECT COUNT(*) FROM furumusic__release_artist ra
|
||||||
|
JOIN furumusic__release r ON r.id = ra.release_id AND r.is_hidden = false
|
||||||
|
JOIN furumusic__track t ON t.release_id = r.id AND t.is_hidden = false
|
||||||
|
WHERE ra.artist_id = a.id), 0) AS track_count,
|
||||||
MAX(sim) AS similarity
|
MAX(sim) AS similarity
|
||||||
FROM (
|
FROM (
|
||||||
SELECT id, name, image_file_id, name_sort, similarity(name_sort, $1) AS sim
|
SELECT id, name, image_file_id, name_sort, similarity(name_sort, $1) AS sim
|
||||||
@@ -1363,6 +1670,7 @@ async fn search_handler(
|
|||||||
name: r.name,
|
name: r.name,
|
||||||
image_url: cover_url(r.image_file_id),
|
image_url: cover_url(r.image_file_id),
|
||||||
release_count: r.release_count,
|
release_count: r.release_count,
|
||||||
|
track_count: r.track_count,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -1911,11 +2219,18 @@ async fn tracks_by_ids_handler(
|
|||||||
|
|
||||||
pub struct PlayerApp {
|
pub struct PlayerApp {
|
||||||
config: Arc<AppConfig>,
|
config: Arc<AppConfig>,
|
||||||
|
scheduler_handle: Arc<tokio::sync::OnceCell<Arc<SchedulerHandle>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlayerApp {
|
impl PlayerApp {
|
||||||
pub fn new(config: Arc<AppConfig>) -> Self {
|
pub fn new(
|
||||||
Self { config }
|
config: Arc<AppConfig>,
|
||||||
|
scheduler_handle: Arc<tokio::sync::OnceCell<Arc<SchedulerHandle>>>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
scheduler_handle,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1927,8 +2242,149 @@ impl App for PlayerApp {
|
|||||||
fn router(&self) -> Router {
|
fn router(&self) -> Router {
|
||||||
let pool_config = Arc::clone(&self.config);
|
let pool_config = Arc::clone(&self.config);
|
||||||
let pool: Arc<tokio::sync::OnceCell<sqlx::PgPool>> = Arc::new(tokio::sync::OnceCell::new());
|
let pool: Arc<tokio::sync::OnceCell<sqlx::PgPool>> = Arc::new(tokio::sync::OnceCell::new());
|
||||||
|
let torrent_service: Arc<tokio::sync::OnceCell<Arc<TorrentService>>> =
|
||||||
|
Arc::new(tokio::sync::OnceCell::new());
|
||||||
|
|
||||||
Router::with_urls([
|
Router::with_urls([
|
||||||
|
// -- Current user profile --
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/me",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
get(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;
|
||||||
|
me_handler(session, db, pg_pool).await
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
"player_me",
|
||||||
|
),
|
||||||
|
// -- Torrent import widget --
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/torrents/preview",
|
||||||
|
{
|
||||||
|
let torrent_service = Arc::clone(&torrent_service);
|
||||||
|
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
||||||
|
post(
|
||||||
|
move |session: Session, db: Database, json: Json<TorrentPreviewRequest>| {
|
||||||
|
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 {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"not authenticated",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let service = torrent_service
|
||||||
|
.get_or_init(|| async {
|
||||||
|
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match service.preview(json.0).await {
|
||||||
|
Ok(preview) => Json(preview).into_response(),
|
||||||
|
Err(err) => {
|
||||||
|
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"player_torrent_preview",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/torrents/{id}/start",
|
||||||
|
{
|
||||||
|
let torrent_service = Arc::clone(&torrent_service);
|
||||||
|
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
||||||
|
post(
|
||||||
|
move |session: Session,
|
||||||
|
db: Database,
|
||||||
|
path: Path<PathStringId>,
|
||||||
|
json: Json<TorrentStartRequest>| {
|
||||||
|
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 {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"not authenticated",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
let service = torrent_service
|
||||||
|
.get_or_init(|| async {
|
||||||
|
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match service
|
||||||
|
.start(
|
||||||
|
&path.0.id,
|
||||||
|
json.0.selected_files,
|
||||||
|
live_config.agent_inbox_dir,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(job) => Json(job).into_response(),
|
||||||
|
Err(err) => {
|
||||||
|
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"player_torrent_start",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/torrents/{id}/status",
|
||||||
|
{
|
||||||
|
let torrent_service = Arc::clone(&torrent_service);
|
||||||
|
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
||||||
|
get(
|
||||||
|
move |session: Session, db: Database, path: Path<PathStringId>| {
|
||||||
|
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 {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"not authenticated",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let service = torrent_service
|
||||||
|
.get_or_init(|| async {
|
||||||
|
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match service.status(&path.0.id).await {
|
||||||
|
Ok(job) => Json(job).into_response(),
|
||||||
|
Err(err) => {
|
||||||
|
Ok(json_error(StatusCode::NOT_FOUND, &err.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"player_torrent_status",
|
||||||
|
),
|
||||||
// -- Artists (paginated) --
|
// -- Artists (paginated) --
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/artists",
|
"/artists",
|
||||||
@@ -2375,7 +2831,29 @@ impl App for PlayerApp {
|
|||||||
// -- Play history --
|
// -- Play history --
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/history",
|
"/history",
|
||||||
cot::router::method::post({
|
get({
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
move |session: Session,
|
||||||
|
db: Database,
|
||||||
|
query: cot::request::extractors::UrlQuery<HistoryQuery>| {
|
||||||
|
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;
|
||||||
|
history_list_handler(session, db, pg_pool, query).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.post({
|
||||||
let pool = Arc::clone(&pool);
|
let pool = Arc::clone(&pool);
|
||||||
let pool_config = Arc::clone(&pool_config);
|
let pool_config = Arc::clone(&pool_config);
|
||||||
move |session: Session, db: Database, json: Json<HistoryEntry>| {
|
move |session: Session, db: Database, json: Json<HistoryEntry>| {
|
||||||
|
|||||||
+537
@@ -0,0 +1,537 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context, bail};
|
||||||
|
use base64::Engine;
|
||||||
|
use librqbit::{
|
||||||
|
AddTorrent, AddTorrentOptions, AddTorrentResponse, ManagedTorrent, Session, SessionOptions,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::sync::{Mutex, OnceCell};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::scheduler::SchedulerHandle;
|
||||||
|
|
||||||
|
const METADATA_TIMEOUT: Duration = Duration::from_secs(90);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct TorrentFileDto {
|
||||||
|
pub index: usize,
|
||||||
|
pub name: String,
|
||||||
|
pub components: Vec<String>,
|
||||||
|
pub length: u64,
|
||||||
|
pub selected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct TorrentPreviewDto {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub info_hash: String,
|
||||||
|
pub total_size: u64,
|
||||||
|
pub files: Vec<TorrentFileDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct TorrentJobDto {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub info_hash: String,
|
||||||
|
pub status: String,
|
||||||
|
pub total_size: u64,
|
||||||
|
pub selected_size: u64,
|
||||||
|
pub downloaded_bytes: u64,
|
||||||
|
pub progress_percent: f64,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TorrentPreviewKind {
|
||||||
|
Magnet,
|
||||||
|
TorrentFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct TorrentPreviewRequest {
|
||||||
|
pub kind: TorrentPreviewKind,
|
||||||
|
pub magnet: Option<String>,
|
||||||
|
pub torrent_base64: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct TorrentStartRequest {
|
||||||
|
pub selected_files: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum TorrentJobStatus {
|
||||||
|
Preview,
|
||||||
|
Downloading,
|
||||||
|
Moving,
|
||||||
|
Complete,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TorrentJobStatus {
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Preview => "preview",
|
||||||
|
Self::Downloading => "downloading",
|
||||||
|
Self::Moving => "moving",
|
||||||
|
Self::Complete => "complete",
|
||||||
|
Self::Failed => "failed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TorrentJob {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
info_hash: String,
|
||||||
|
torrent_bytes: Vec<u8>,
|
||||||
|
files: Vec<TorrentFileDto>,
|
||||||
|
status: TorrentJobStatus,
|
||||||
|
output_dir: PathBuf,
|
||||||
|
selected_files: Vec<usize>,
|
||||||
|
handle: Option<Arc<ManagedTorrent>>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TorrentJob {
|
||||||
|
fn total_size(&self) -> u64 {
|
||||||
|
self.files.iter().map(|f| f.length).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected_size(&self) -> u64 {
|
||||||
|
if self.selected_files.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
self.files
|
||||||
|
.iter()
|
||||||
|
.filter(|f| self.selected_files.contains(&f.index))
|
||||||
|
.map(|f| f.length)
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dto(&self) -> TorrentJobDto {
|
||||||
|
let stats = self.handle.as_ref().map(|h| h.stats());
|
||||||
|
let downloaded_bytes = stats.as_ref().map(|s| s.progress_bytes).unwrap_or(0);
|
||||||
|
let total_bytes = stats
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.total_bytes)
|
||||||
|
.filter(|v| *v > 0)
|
||||||
|
.unwrap_or_else(|| self.selected_size());
|
||||||
|
let progress_percent = if total_bytes == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
downloaded_bytes as f64 / total_bytes as f64 * 100.0
|
||||||
|
};
|
||||||
|
|
||||||
|
Self::dto_from_parts(
|
||||||
|
&self.id,
|
||||||
|
&self.name,
|
||||||
|
&self.info_hash,
|
||||||
|
self.status,
|
||||||
|
self.total_size(),
|
||||||
|
self.selected_size(),
|
||||||
|
downloaded_bytes,
|
||||||
|
progress_percent,
|
||||||
|
self.error.clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn dto_from_parts(
|
||||||
|
id: &str,
|
||||||
|
name: &str,
|
||||||
|
info_hash: &str,
|
||||||
|
status: TorrentJobStatus,
|
||||||
|
total_size: u64,
|
||||||
|
selected_size: u64,
|
||||||
|
downloaded_bytes: u64,
|
||||||
|
progress_percent: f64,
|
||||||
|
error: Option<String>,
|
||||||
|
) -> TorrentJobDto {
|
||||||
|
TorrentJobDto {
|
||||||
|
id: id.to_string(),
|
||||||
|
name: name.to_string(),
|
||||||
|
info_hash: info_hash.to_string(),
|
||||||
|
status: status.as_str().to_string(),
|
||||||
|
total_size,
|
||||||
|
selected_size,
|
||||||
|
downloaded_bytes,
|
||||||
|
progress_percent: progress_percent.clamp(0.0, 100.0),
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TorrentService {
|
||||||
|
temp_root: PathBuf,
|
||||||
|
session: OnceCell<Arc<Session>>,
|
||||||
|
jobs: Mutex<HashMap<String, TorrentJob>>,
|
||||||
|
scheduler_handle: Arc<OnceCell<Arc<SchedulerHandle>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TorrentService {
|
||||||
|
pub fn new(scheduler_handle: Arc<OnceCell<Arc<SchedulerHandle>>>) -> Self {
|
||||||
|
Self {
|
||||||
|
temp_root: std::env::temp_dir().join("furumusic").join("torrents"),
|
||||||
|
session: OnceCell::new(),
|
||||||
|
jobs: Mutex::new(HashMap::new()),
|
||||||
|
scheduler_handle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn session(&self) -> anyhow::Result<Arc<Session>> {
|
||||||
|
let temp_root = self.temp_root.clone();
|
||||||
|
self.session
|
||||||
|
.get_or_try_init(|| async move {
|
||||||
|
tokio::fs::create_dir_all(&temp_root).await?;
|
||||||
|
Session::new_with_opts(
|
||||||
|
temp_root,
|
||||||
|
SessionOptions {
|
||||||
|
disable_upload: true,
|
||||||
|
enable_upnp_port_forwarding: false,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn preview(
|
||||||
|
&self,
|
||||||
|
request: TorrentPreviewRequest,
|
||||||
|
) -> anyhow::Result<TorrentPreviewDto> {
|
||||||
|
let session = self.session().await?;
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
let output_dir = self.temp_root.join(&id).join("download");
|
||||||
|
tokio::fs::create_dir_all(&output_dir).await?;
|
||||||
|
|
||||||
|
let add = match request.kind {
|
||||||
|
TorrentPreviewKind::Magnet => {
|
||||||
|
let magnet = request
|
||||||
|
.magnet
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.context("magnet link is empty")?;
|
||||||
|
AddTorrent::from_url(magnet.to_string())
|
||||||
|
}
|
||||||
|
TorrentPreviewKind::TorrentFile => {
|
||||||
|
let encoded = request
|
||||||
|
.torrent_base64
|
||||||
|
.as_deref()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.context("torrent file is empty")?;
|
||||||
|
let bytes = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(encoded)
|
||||||
|
.context("invalid torrent file encoding")?;
|
||||||
|
AddTorrent::from_bytes(bytes)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = tokio::time::timeout(
|
||||||
|
METADATA_TIMEOUT,
|
||||||
|
session.add_torrent(
|
||||||
|
add,
|
||||||
|
Some(AddTorrentOptions {
|
||||||
|
list_only: true,
|
||||||
|
output_folder: Some(output_dir.to_string_lossy().to_string()),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.context("timed out while resolving torrent metadata")??;
|
||||||
|
|
||||||
|
let AddTorrentResponse::ListOnly(list) = response else {
|
||||||
|
bail!("torrent was unexpectedly added instead of previewed");
|
||||||
|
};
|
||||||
|
|
||||||
|
let name = list
|
||||||
|
.info
|
||||||
|
.name
|
||||||
|
.as_ref()
|
||||||
|
.map(|b| String::from_utf8_lossy(b.as_ref()).to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| list.info_hash.as_string());
|
||||||
|
|
||||||
|
let mut files = Vec::new();
|
||||||
|
for (index, details) in list.info.iter_file_details()?.enumerate() {
|
||||||
|
let name = details
|
||||||
|
.filename
|
||||||
|
.to_string()
|
||||||
|
.unwrap_or_else(|_| "<invalid filename>".to_string());
|
||||||
|
let selected = is_audio_path(&name);
|
||||||
|
files.push(TorrentFileDto {
|
||||||
|
index,
|
||||||
|
name,
|
||||||
|
components: details.filename.to_vec().unwrap_or_default(),
|
||||||
|
length: details.len,
|
||||||
|
selected,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_size = files.iter().map(|f| f.length).sum();
|
||||||
|
let dto = TorrentPreviewDto {
|
||||||
|
id: id.clone(),
|
||||||
|
name: name.clone(),
|
||||||
|
info_hash: list.info_hash.as_string(),
|
||||||
|
total_size,
|
||||||
|
files: files.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let job = TorrentJob {
|
||||||
|
id: id.clone(),
|
||||||
|
name,
|
||||||
|
info_hash: dto.info_hash.clone(),
|
||||||
|
torrent_bytes: list.torrent_bytes.to_vec(),
|
||||||
|
files,
|
||||||
|
status: TorrentJobStatus::Preview,
|
||||||
|
output_dir,
|
||||||
|
selected_files: Vec::new(),
|
||||||
|
handle: None,
|
||||||
|
error: None,
|
||||||
|
};
|
||||||
|
self.jobs.lock().await.insert(id, job);
|
||||||
|
|
||||||
|
Ok(dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn status(&self, id: &str) -> anyhow::Result<TorrentJobDto> {
|
||||||
|
let jobs = self.jobs.lock().await;
|
||||||
|
let job = jobs.get(id).context("torrent job not found")?;
|
||||||
|
Ok(job.dto())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
id: &str,
|
||||||
|
selected_files: Vec<usize>,
|
||||||
|
inbox_dir: String,
|
||||||
|
) -> anyhow::Result<TorrentJobDto> {
|
||||||
|
if selected_files.is_empty() {
|
||||||
|
bail!("select at least one file");
|
||||||
|
}
|
||||||
|
if inbox_dir.trim().is_empty() {
|
||||||
|
bail!("agent_inbox_dir is not configured");
|
||||||
|
}
|
||||||
|
let inbox_dir = validate_inbox_dir(&inbox_dir)?;
|
||||||
|
|
||||||
|
let (torrent_bytes, output_dir) = {
|
||||||
|
let mut jobs = self.jobs.lock().await;
|
||||||
|
let job = jobs.get_mut(id).context("torrent job not found")?;
|
||||||
|
if job.status != TorrentJobStatus::Preview && job.status != TorrentJobStatus::Failed {
|
||||||
|
bail!("torrent job is already started");
|
||||||
|
}
|
||||||
|
validate_selection(&job.files, &selected_files)?;
|
||||||
|
job.status = TorrentJobStatus::Downloading;
|
||||||
|
job.selected_files = selected_files.clone();
|
||||||
|
job.error = None;
|
||||||
|
(job.torrent_bytes.clone(), job.output_dir.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
let session = self.session().await?;
|
||||||
|
let response = session
|
||||||
|
.add_torrent(
|
||||||
|
AddTorrent::from_bytes(torrent_bytes),
|
||||||
|
Some(AddTorrentOptions {
|
||||||
|
only_files: Some(selected_files),
|
||||||
|
output_folder: Some(output_dir.to_string_lossy().to_string()),
|
||||||
|
overwrite: true,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let handle = response
|
||||||
|
.into_handle()
|
||||||
|
.context("torrent did not return a download handle")?;
|
||||||
|
|
||||||
|
let dto = {
|
||||||
|
let mut jobs = self.jobs.lock().await;
|
||||||
|
let job = jobs.get_mut(id).context("torrent job not found")?;
|
||||||
|
job.handle = Some(handle.clone());
|
||||||
|
job.dto()
|
||||||
|
};
|
||||||
|
|
||||||
|
let service = Arc::clone(self);
|
||||||
|
let id = id.to_string();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(err) = handle.wait_until_completed().await {
|
||||||
|
service.stop_torrent(&handle).await;
|
||||||
|
service.fail_job(&id, err.to_string()).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
service.stop_torrent(&handle).await;
|
||||||
|
if let Err(err) = service.finalize_completed(&id, &inbox_dir).await {
|
||||||
|
service.fail_job(&id, err.to_string()).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fail_job(&self, id: &str, error: String) {
|
||||||
|
let mut jobs = self.jobs.lock().await;
|
||||||
|
if let Some(job) = jobs.get_mut(id) {
|
||||||
|
job.status = TorrentJobStatus::Failed;
|
||||||
|
job.error = Some(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop_torrent(&self, handle: &Arc<ManagedTorrent>) {
|
||||||
|
match self.session().await {
|
||||||
|
Ok(session) => {
|
||||||
|
if let Err(err) = session.delete(handle.id().into(), false).await {
|
||||||
|
tracing::warn!("failed to stop completed torrent: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!("failed to access torrent session for shutdown: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn finalize_completed(&self, id: &str, inbox_dir: &Path) -> 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")?;
|
||||||
|
job.status = TorrentJobStatus::Moving;
|
||||||
|
(
|
||||||
|
job.name.clone(),
|
||||||
|
job.files.clone(),
|
||||||
|
job.selected_files.clone(),
|
||||||
|
job.output_dir.clone(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let destination_root = inbox_dir
|
||||||
|
.join("torrents")
|
||||||
|
.join(sanitize_path_component(&name));
|
||||||
|
tokio::fs::create_dir_all(&destination_root).await?;
|
||||||
|
|
||||||
|
for file in files.iter().filter(|f| selected_files.contains(&f.index)) {
|
||||||
|
let source = safe_join(&output_dir, &file.components)?;
|
||||||
|
if !tokio::fs::try_exists(&source).await? {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let destination = safe_join(&destination_root, &file.components)?;
|
||||||
|
if let Some(parent) = destination.parent() {
|
||||||
|
tokio::fs::create_dir_all(parent).await?;
|
||||||
|
}
|
||||||
|
move_file(&source, &destination).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let job_root = self.temp_root.join(id);
|
||||||
|
let _ = tokio::fs::remove_dir_all(job_root).await;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut jobs = self.jobs.lock().await;
|
||||||
|
let job = jobs.get_mut(id).context("torrent job not found")?;
|
||||||
|
job.status = TorrentJobStatus::Complete;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(handle) = self.scheduler_handle.get() {
|
||||||
|
let handle = Arc::clone(handle);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(err) = handle.trigger_job_now("inbox_discover").await {
|
||||||
|
tracing::warn!("failed to trigger inbox_discover after torrent: {err}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_selection(files: &[TorrentFileDto], selected_files: &[usize]) -> anyhow::Result<()> {
|
||||||
|
for index in selected_files {
|
||||||
|
if !files.iter().any(|file| file.index == *index) {
|
||||||
|
bail!("selected file index {index} is not in this torrent");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_inbox_dir(inbox_dir: &str) -> anyhow::Result<PathBuf> {
|
||||||
|
let trimmed = inbox_dir.trim();
|
||||||
|
let path = PathBuf::from(trimmed);
|
||||||
|
if !path.is_absolute() {
|
||||||
|
bail!(
|
||||||
|
"agent_inbox_dir must be an absolute path for this host, got `{}`",
|
||||||
|
trimmed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_audio_path(path: &str) -> bool {
|
||||||
|
let Some(ext) = Path::new(path).extension().and_then(|e| e.to_str()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
matches!(
|
||||||
|
ext.to_ascii_lowercase().as_str(),
|
||||||
|
"mp3"
|
||||||
|
| "flac"
|
||||||
|
| "ogg"
|
||||||
|
| "opus"
|
||||||
|
| "aac"
|
||||||
|
| "m4a"
|
||||||
|
| "wav"
|
||||||
|
| "ape"
|
||||||
|
| "wv"
|
||||||
|
| "wma"
|
||||||
|
| "tta"
|
||||||
|
| "aiff"
|
||||||
|
| "aif"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_path_component(value: &str) -> String {
|
||||||
|
let sanitized: String = value
|
||||||
|
.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||||
|
c if c.is_control() => '_',
|
||||||
|
c => c,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let trimmed = sanitized.trim().trim_matches('.').trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
"torrent".to_string()
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_join(root: &Path, components: &[String]) -> anyhow::Result<PathBuf> {
|
||||||
|
let mut path = root.to_path_buf();
|
||||||
|
for component in components {
|
||||||
|
let sanitized = sanitize_path_component(component);
|
||||||
|
if sanitized == "." || sanitized == ".." {
|
||||||
|
bail!("unsafe torrent path component");
|
||||||
|
}
|
||||||
|
path.push(sanitized);
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn move_file(source: &Path, destination: &Path) -> anyhow::Result<()> {
|
||||||
|
match tokio::fs::rename(source, destination).await {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::CrossesDevices => {
|
||||||
|
tokio::fs::copy(source, destination).await?;
|
||||||
|
tokio::fs::remove_file(source).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(err) => Err(err.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,11 @@
|
|||||||
<td><input name="oidc_admin_groups" id="oidc_admin_groups" value="{{ oidc_admin_groups }}" style="width:100%"></td>
|
<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>
|
<td><span class="badge badge-{{ oidc_admin_groups_source }}">{{ oidc_admin_groups_source }}</span></td>
|
||||||
</tr>
|
</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>
|
</table>
|
||||||
<h2>{{ t.settings_api }}</h2>
|
<h2>{{ t.settings_api }}</h2>
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
+1764
-29
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user