Compare commits

..

11 Commits

Author SHA1 Message Date
ab a3a3f5368d Added user attribution
Build and Publish / Build and Publish Docker Image (push) Successful in 2m47s
2026-05-25 23:06:34 +03:00
ab 5f925be29b Added user attribution 2026-05-25 23:04:58 +03:00
ab 8530016d35 Reworked Artist page
Build and Publish / Build and Publish Docker Image (push) Successful in 2m47s
2026-05-25 17:41:00 +03:00
Ultradesu cae77e9401 Added OIDC users group filter
Build and Publish / Build and Publish Docker Image (push) Successful in 2m41s
2026-05-25 16:26:45 +03:00
Ultradesu 709f319bc5 Fixed UI
Build and Publish / Build and Publish Docker Image (push) Successful in 2m42s
2026-05-25 15:57:10 +03:00
Ultradesu bf0a2a553c Added torrent client and upload feature. reworked mobile UI.
Build and Publish / Build and Publish Docker Image (push) Successful in 2m47s
2026-05-25 15:40:07 +03:00
Ultradesu 3fc9b16e2c Reworked main page content 2026-05-25 14:59:15 +03:00
Ultradesu 29f6d04d12 Reworked main page content 2026-05-25 14:59:01 +03:00
Ultradesu c34485b521 Reworked main page content
Build and Publish / Build and Publish Docker Image (push) Successful in 2m33s
2026-05-25 14:42:25 +03:00
Ultradesu bc9f9605d8 Reworked main page content 2026-05-25 14:30:33 +03:00
Ultradesu 2f0ed2ee09 readme fix 2026-05-25 13:53:29 +03:00
21 changed files with 5133 additions and 400 deletions
Generated
+1030 -14
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "furumusic"
version = "0.1.1"
version = "0.1.8"
edition = "2024"
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"
async-trait = "0.1"
uuid = "1"
librqbit = { version = "8.1.1", features = ["disable-upload"] }
+3 -2
View File
@@ -7,7 +7,7 @@ Built with Rust ([cot](https://cot.rs) framework).
## Quick start
```bash
export FURU_DATABASE_URL=postgres://user:pass@localhost/furumusic
export FURU_DATABASE_URL=postgresql://user:pass@localhost/furumusic
cargo run
# 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.
**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
View File
@@ -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),
+2
View File
@@ -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}"))?;
+7
View File
@@ -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);
+3
View File
@@ -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" , "Артисты";
+5 -1
View File
@@ -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,
+20 -1
View File
@@ -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}"))?;
+70
View File
@@ -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(),
}
}
+6 -1
View File
@@ -9,6 +9,7 @@ mod music;
mod oidc;
mod player;
mod scheduler;
mod torrents;
mod user;
use std::sync::Arc;
@@ -280,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",
@@ -370,7 +372,10 @@ impl Project for FuruProject {
);
apps.register_with_views(api::ApiApp, "/api");
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",
);
if self.app_config.swagger_enabled {
+46
View File
@@ -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
View File
@@ -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,
+195
View File
@@ -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>,
}
+48
View File
@@ -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)
}
+691 -349
View File
File diff suppressed because it is too large Load Diff
+72
View File
@@ -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,
}
+185
View File
@@ -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>,
}
+547
View File
@@ -0,0 +1,547 @@
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,
uploader_user_id: i64,
) -> 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, uploader_user_id)
.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,
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")?;
job.status = TorrentJobStatus::Moving;
(
job.name.clone(),
job.files.clone(),
job.selected_files.clone(),
job.output_dir.clone(),
)
};
let destination_root = inbox_dir
.join("user_uploads")
.join(uploader_user_id.to_string())
.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()),
}
}
+5
View File
@@ -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>
+2147 -29
View File
File diff suppressed because it is too large Load Diff