From cf735b4278cfcdb85be85a038302a8950c707cce Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 12 Mar 2026 22:06:29 +0300 Subject: [PATCH 01/30] chore: enforce `rustc_hash` over std hashers Signed-off-by: NotAShelf Change-Id: I228093b5da57d6fa3a6249e06de2f5776a6a6964 --- .clippy.toml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.clippy.toml b/.clippy.toml index 0a3de0a..f2cb4f3 100644 --- a/.clippy.toml +++ b/.clippy.toml @@ -1,3 +1,9 @@ +avoid-breaking-exported-api = false +cognitive-complexity-threshold = 30 +too-many-arguments-threshold = 12 +upper-case-acronyms-aggressive = true +check-inconsistent-struct-field-initializers = true + await-holding-invalid-types = [ "generational_box::GenerationalRef", { path = "generational_box::GenerationalRef", reason = "Reads should not be held over an await point. This will cause any writes to fail while the await is pending since the read borrow is still active." }, @@ -13,3 +19,9 @@ disallowed-methods = [ { path = "once_cell::unsync::Lazy::new", reason = "use `std::cell::LazyCell` instead, unless you need into_value" }, { path = "once_cell::sync::Lazy::new", reason = "use `std::sync::LazyLock` instead, unless you need into_value" }, ] + + +disallowed-types = [ + { path = "std::collections::HashMap", reason = "Use `rustc_hash::FxHashMap` instead, which is typically faster." }, + { path = "std::collections::HashSet", reason = "Use `rustc_hash::FxHashSet` instead, which is typically faster." }, +] -- 2.43.0 From 0e79ba0518a47d0ea9af11bd5e79a798a7caff53 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 19 Mar 2026 22:34:16 +0300 Subject: [PATCH 02/30] meta: ignore Nix build results properly Signed-off-by: NotAShelf Change-Id: Iccbf2928b43e8b519d84884e801e4f206a6a6964 --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index fd19e1e..9f12b81 100644 --- a/.gitignore +++ b/.gitignore @@ -3,9 +3,12 @@ target/ **/*.wasm # Nix -.direnv/ +/.direnv/ +/result* # Runtime artifacts *.db* + +# Test configuration test.toml -- 2.43.0 From c6efd3661f6d13e4a7237802cb983763a69d185f Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 19 Mar 2026 22:34:30 +0300 Subject: [PATCH 03/30] treewide: replace std hashers with rustc_hash alternatives; fix clippy Signed-off-by: NotAShelf Change-Id: I766c36cb53d3d7f9e85b91a67c4131a66a6a6964 --- Cargo.lock | Bin 241244 -> 242493 bytes Cargo.toml | 54 +--------------- crates/pinakes-core/Cargo.toml | 1 + crates/pinakes-core/src/config.rs | 31 ++++----- crates/pinakes-core/src/import.rs | 2 +- crates/pinakes-core/src/integrity.rs | 23 +++---- crates/pinakes-core/src/jobs.rs | 23 +++---- crates/pinakes-core/src/links.rs | 2 +- .../pinakes-core/src/media_type/registry.rs | 11 ++-- crates/pinakes-core/src/metadata/document.rs | 2 +- crates/pinakes-core/src/metadata/mod.rs | 6 +- crates/pinakes-core/src/model.rs | 9 +-- crates/pinakes-core/src/plugin/mod.rs | 14 ++-- crates/pinakes-core/src/plugin/pipeline.rs | 28 ++++---- crates/pinakes-core/src/plugin/registry.rs | 12 ++-- crates/pinakes-core/src/plugin/rpc.rs | 7 +- crates/pinakes-core/src/plugin/runtime.rs | 6 +- crates/pinakes-core/src/storage/mod.rs | 3 +- crates/pinakes-core/src/storage/postgres.rs | 58 ++++++++--------- crates/pinakes-core/src/storage/sqlite.rs | 28 ++++---- crates/pinakes-core/src/transcode.rs | 8 +-- crates/pinakes-core/src/upload.rs | 4 +- crates/pinakes-core/src/users.rs | 5 +- crates/pinakes-core/tests/common/mod.rs | 9 +-- crates/pinakes-core/tests/integration.rs | 15 ++--- crates/pinakes-plugin-api/Cargo.toml | 1 + crates/pinakes-plugin-api/src/lib.rs | 22 +++---- crates/pinakes-plugin-api/src/manifest.rs | 15 +++-- crates/pinakes-plugin-api/src/ui_schema.rs | 61 +++++++++--------- crates/pinakes-plugin-api/src/validation.rs | 6 +- crates/pinakes-plugin-api/src/wasm.rs | 9 ++- crates/pinakes-plugin-api/tests/api.rs | 31 ++++----- .../pinakes-plugin-api/tests/integration.rs | 6 +- crates/pinakes-server/Cargo.toml | 1 + crates/pinakes-server/src/dto/media.rs | 8 +-- crates/pinakes-server/src/routes/books.rs | 3 +- crates/pinakes-server/src/routes/media.rs | 3 +- crates/pinakes-server/src/routes/photos.rs | 8 +-- crates/pinakes-server/src/routes/plugins.rs | 5 +- .../src/routes/saved_searches.rs | 17 ++--- crates/pinakes-tui/Cargo.toml | 1 + crates/pinakes-tui/src/app.rs | 7 +- crates/pinakes-tui/src/client.rs | 5 +- crates/pinakes-ui/Cargo.toml | 1 + crates/pinakes-ui/src/client.rs | 9 ++- .../pinakes-ui/src/components/graph_view.rs | 5 +- crates/pinakes-ui/src/components/import.rs | 15 ++--- .../src/components/markdown_viewer.rs | 4 ++ crates/pinakes-ui/src/plugin_ui/actions.rs | 44 +++++++------ crates/pinakes-ui/src/plugin_ui/data.rs | 36 +++++------ crates/pinakes-ui/src/plugin_ui/registry.rs | 25 ++++--- crates/pinakes-ui/src/plugin_ui/renderer.rs | 17 +++-- crates/pinakes-ui/src/plugin_ui/widget.rs | 11 ++-- 53 files changed, 343 insertions(+), 394 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 495a23a0bf577b1201657ba6aebd5ecc095ba980..8a387d61e721744155ed827df064e7d311ae2df4 100644 GIT binary patch delta 2580 zcmX|@TZ~;*9mccvp_l1Qr$p?Twv@CMFj3m+x?jA^Oi_?TX*wgNgxJ<~H7%XiGPVRS z)godrCbZqrWr75sv=C}LjPZ!XyU_<@qQ<0xMHz$mK)e(q4;IkxOfNYPXW!P||NZ^H z@Au#5&JF+I<>52WFRptZBzKHkEb>!^(h*R=HJk@}%Go9pME8F=8`E&1X$+^~4V1=S%4ntWj_L}!c>`{FHk)Poab^1akBp;g3BXP-?nMlwmcODXdriwvGA z7K^q?dBHV{&P$rIu(k5)^D#Pi|e)DYzFG{k2YiN=G#`+zrTlU8>iV=$qcj; z0XLT^((F_=LK$J5=h8))yfaZm9+aepQfgcZni&>KYT5f%&;9GRNA6s*o{n-h%H}@% z_$>ohc7A?x=&APAbCcs%`CzGVQV0<#FB!eaa08`2C`t?55IJWec?hMg$)Px>yjBoM zGg|#=GSdEe^|Jc8?c~1Y8#XRmzu}XI_UBD|Kc3s!b=USb-bvc-V$>i;Y5mbYva`N; z54pOX|IgF}M}MMoXk1K?UGtKPP29PnG+Yv$(6C_8v`}NS7G5QrL=-X?t|brc!L!|Z z>_{_MPwgWsHc$I_?}3?x3ij^bvuWo389Y^vvV|l24t#9ke`e2g+8y)EAzLRekTrFF zA9-v2;oY4jb>dItx(*h$#~$o9oVRS%U^AGjQdh?fkRvtgENVkL#Lgva+s`WNJKWVT`vn2H_YD zIlDj$_7m*LGfO;qn~if!DQAmTCD3ScW-?l1eQD?Cx=Sy;@TPXxUF(-z+UdW@#U}_G zX>fhpDO<9lkULVV6E99`#gHwPl@=@;4kz2f(OK_9OwxGGb-%~m_VzDq?)_n!+_b#i z^5t#gK?N*!qXaq2TDofOeLePk;klu(aWvH`1ZMyC5 zzYKrt#1mxkCA;-boF=c64qVXw=HcslCuhmm*Vli41lj6S3F|@*LKF*DD4#_Zo*BoP zRbW)K8O@|paEPZI!ogxRF$pGuY7g#MRbTlX`D-5pkpNLFQp%%ISd=}FS_O+9$t03u zNhxs4CQvLTW%vdUIMc@wnw|&dZyE??0xC?WJl9(8QC_W43_zV=x5Hgq8y-6 z93D%-YvTi05TS4cUnrHZhcY_w;BpES;0e8hXUTmV+7CZ5T3>#h@OJBo;d;#*YzIXz=7jCHW2=tRe z2G|Mwb0DP7MbXSzwpJsvS!<*;ieiLeRH9-Ruo)#S1ul%#)!9u=xA&PFn|F;3T%yy4 zjrHJsv$}rtRC85(|MkPYmtynwMZ{gymqivJr=xw(iwqtx8d(AK`rOW#0tWwRC$0;j zm?Q-a!NgEXa-z=eY#{JGou&1$XUPq{KkjL6TD$ZL2VN+|k2wkCF=b#xbU776xdB@- zK+rdY(j=~ROhr2MBY9pTxXg1YMg<=0(fwrPt9O&Dx^eg6eDBPj1N*Pg=Fle!So1)$ zcJXxTZ@1?c`3ZOpGZrKh2!)~;WN?O1tUxl14pA^ELj>=oL%6g-GB9|(f`_1l23og{ z)L%RV71un}9Nlmk>-G54I5c~*d9FiDdw%8QxWJq+80<2dAgt<_98(5lipBd;%uHkf zXmHxgLb;Sh7wv$ch#1JwpYJ_(syRDY|M4nV6*OWDTVnQNeg_@BOo~d2Atx<97pMTc z6w^XFbUbo61fKv=a4vX=s?Vx=`k7|DeR|!R-iq%xb4TiT_cl{wB|8hIvdkUkX@sFN z0ALLD3=_c$M}2~`7h;G|IxQ25WLhebbg5){XXJ!mapLx&w*Y>9OYu>0nfvYelPl}3 ze`~I;uRKXs^nT_#%kH`?Snt=zJD(oxCvLoOWo$4^7wdszg>49uR7lpqY%&yy5<)Bj zVp4@^h$NzQT2cjmwr~4hxBhduSz2FvtaJ0i9Zl8|ZnqgCiI6l0F#AT;{a_czX@pHP z2O&Z8EJEQx8HTJV)OyM>J+}AOKHeD_s;}+pOw|uO)j80p(K>&IOpV39TG43X48C~0 wR4kXo`a?on3EJYz20-q2!pi71rg+BFa&-7Q;i8@VV7Kl%)frd>k#;WoKXRKDu>b%7 delta 2066 zcmYL~U5H&*7021=WVH{1ptP<#h5B+I_PJ-B zwg2n?|E(KuE!?`baOKb2x5-sn#dh^Uv2j>s6VXAj${MYe)5VhyCZ&*7g_JYusGW2a zWK9q;7$gix(N$gB5k1=5Z_dw6FYI_!v~#}~UmELTNo$9?$`CNw=((t|9+fT?WQn%a z$klR*PE}7z2JMa4Ir{{r9r>A9nm)br*?W8OgN?EEwkn;2Qa%+0Qe#A$QIWJo8nBK) zp?9_#Z=%E)bfrMK)@Y@Y?e595E$fL#n%?D2R|GZ{o);4&(EAa-NLbMPkZZ(INW}IR@@j4eN){1;U4x(*2zg3LynXp zcaF7YRymL@1ZZMZzJjxS&e|Ge!Af!5rqdLQBmm@_F1>h8z(HZ#l~=kGtz&}YNJ#}VRTU!$KT4B zXDzpfUKM|B8=n%BHR+skJVONK*&Sd*bo^>6J{NBzYHggC&KLADaB`BDJR4uD)k-UD zx0l6Y`|%(?J^br6alC8SUg;)l+(L+$Su&okDcq-X-l6jvy+Yv3IhXk2#Bs~)Dd(sp z%f8it4Tk;2?ThUrtD_ym+i!@kthC=A@8h`Uifsx1YPd+=VPd+g5sy@z(qKfd|( z53;^~x!XO+_0i`Z86NxT=sWZMJG&=q!8)I)Xy&M~#2r_T_>89nF(sCP4;VYiam=fs z5>SFy5y^3m@O?bC^S>M&+p1Q{B<*})T0yYdC#Ssy<)pQ|LaZd7B0A}{1Rzu#evXWz z%s+}3L%aDCaca2y>}Y9MTe#4jTvJt};@t#RqW7F&D-&}uMyQ~24bf(1M40V49~}Eg zSDh7k&qUIod%<)&du_C>O|OprIAXQ?myb=>kXIBY7iRm8mnW*kH`9^q%nk)1^8s%r z-Z0X|#E?=Y&#=PO=Vb!@)mw}0&~&tO`_=?`fDG2zXZE-I-w>0r=H2N_DwS7AveI)* s7#qknBin^j%qXg%Kq6L%c&qc&m>TFxNy@e(e-cYu<>x Config { @@ -1549,7 +1551,7 @@ mod tests { // HashMap lookup. This avoids unsafe std::env::set_var and is // thread-safe for parallel test execution. fn test_lookup<'a>( - vars: &'a std::collections::HashMap<&str, &str>, + vars: &'a FxHashMap<&str, &str>, ) -> impl Fn(&str) -> crate::error::Result + 'a { move |name| { vars @@ -1565,24 +1567,21 @@ mod tests { #[test] fn test_expand_env_var_simple() { - let vars = - std::collections::HashMap::from([("TEST_VAR_SIMPLE", "test_value")]); + let vars = FxHashMap::from([("TEST_VAR_SIMPLE", "test_value")]); let result = expand_env_vars("$TEST_VAR_SIMPLE", test_lookup(&vars)); assert_eq!(result.unwrap(), "test_value"); } #[test] fn test_expand_env_var_braces() { - let vars = - std::collections::HashMap::from([("TEST_VAR_BRACES", "test_value")]); + let vars = FxHashMap::from([("TEST_VAR_BRACES", "test_value")]); let result = expand_env_vars("${TEST_VAR_BRACES}", test_lookup(&vars)); assert_eq!(result.unwrap(), "test_value"); } #[test] fn test_expand_env_var_embedded() { - let vars = - std::collections::HashMap::from([("TEST_VAR_EMBEDDED", "value")]); + let vars = FxHashMap::from([("TEST_VAR_EMBEDDED", "value")]); let result = expand_env_vars("prefix_${TEST_VAR_EMBEDDED}_suffix", test_lookup(&vars)); assert_eq!(result.unwrap(), "prefix_value_suffix"); @@ -1590,15 +1589,14 @@ mod tests { #[test] fn test_expand_env_var_multiple() { - let vars = - std::collections::HashMap::from([("VAR1", "value1"), ("VAR2", "value2")]); + let vars = FxHashMap::from([("VAR1", "value1"), ("VAR2", "value2")]); let result = expand_env_vars("${VAR1}_${VAR2}", test_lookup(&vars)); assert_eq!(result.unwrap(), "value1_value2"); } #[test] fn test_expand_env_var_missing() { - let vars = std::collections::HashMap::new(); + let vars = FxHashMap::default(); let result = expand_env_vars("${NONEXISTENT_VAR}", test_lookup(&vars)); assert!(result.is_err()); assert!( @@ -1611,7 +1609,7 @@ mod tests { #[test] fn test_expand_env_var_empty_name() { - let vars = std::collections::HashMap::new(); + let vars = FxHashMap::default(); let result = expand_env_vars("${}", test_lookup(&vars)); assert!(result.is_err()); assert!( @@ -1624,31 +1622,28 @@ mod tests { #[test] fn test_expand_env_var_escaped() { - let vars = std::collections::HashMap::new(); + let vars = FxHashMap::default(); let result = expand_env_vars("\\$NOT_A_VAR", test_lookup(&vars)); assert_eq!(result.unwrap(), "$NOT_A_VAR"); } #[test] fn test_expand_env_var_no_vars() { - let vars = std::collections::HashMap::new(); + let vars = FxHashMap::default(); let result = expand_env_vars("plain_text", test_lookup(&vars)); assert_eq!(result.unwrap(), "plain_text"); } #[test] fn test_expand_env_var_underscore() { - let vars = std::collections::HashMap::from([("TEST_VAR_NAME", "value")]); + let vars = FxHashMap::from([("TEST_VAR_NAME", "value")]); let result = expand_env_vars("$TEST_VAR_NAME", test_lookup(&vars)); assert_eq!(result.unwrap(), "value"); } #[test] fn test_expand_env_var_mixed_syntax() { - let vars = std::collections::HashMap::from([ - ("VAR1_MIXED", "v1"), - ("VAR2_MIXED", "v2"), - ]); + let vars = FxHashMap::from([("VAR1_MIXED", "v1"), ("VAR2_MIXED", "v2")]); let result = expand_env_vars("$VAR1_MIXED and ${VAR2_MIXED}", test_lookup(&vars)); assert_eq!(result.unwrap(), "v1 and v2"); diff --git a/crates/pinakes-core/src/import.rs b/crates/pinakes-core/src/import.rs index 27046e2..7bae8a3 100644 --- a/crates/pinakes-core/src/import.rs +++ b/crates/pinakes-core/src/import.rs @@ -254,7 +254,7 @@ pub async fn import_file_with_options( duration_secs: extracted.duration_secs, description: extracted.description, thumbnail_path: thumb_path, - custom_fields: std::collections::HashMap::new(), + custom_fields: rustc_hash::FxHashMap::default(), file_mtime: current_mtime, // Photo-specific metadata from extraction diff --git a/crates/pinakes-core/src/integrity.rs b/crates/pinakes-core/src/integrity.rs index ff4bf9b..b5d1daf 100644 --- a/crates/pinakes-core/src/integrity.rs +++ b/crates/pinakes-core/src/integrity.rs @@ -1,8 +1,6 @@ -use std::{ - collections::{HashMap, HashSet}, - path::{Path, PathBuf}, -}; +use std::path::{Path, PathBuf}; +use rustc_hash::{FxHashMap, FxHashSet}; use serde::{Deserialize, Serialize}; use tracing::{info, warn}; @@ -96,8 +94,8 @@ pub async fn detect_orphans( let mut orphaned_ids = Vec::new(); // Build hash index: ContentHash -> Vec<(MediaId, PathBuf)> - let mut hash_index: HashMap> = - HashMap::new(); + let mut hash_index: FxHashMap> = + FxHashMap::default(); for (id, path, hash) in &media_paths { hash_index .entry(hash.clone()) @@ -138,12 +136,12 @@ pub async fn detect_orphans( fn detect_moved_files( orphaned_ids: &[MediaId], media_paths: &[(MediaId, PathBuf, ContentHash)], - hash_index: &HashMap>, + hash_index: &FxHashMap>, ) -> Vec<(MediaId, PathBuf, PathBuf)> { let mut moved = Vec::new(); // Build lookup map for orphaned items: MediaId -> (PathBuf, ContentHash) - let orphaned_map: HashMap = media_paths + let orphaned_map: FxHashMap = media_paths .iter() .filter(|(id, ..)| orphaned_ids.contains(id)) .map(|(id, path, hash)| (*id, (path.clone(), hash.clone()))) @@ -184,7 +182,7 @@ async fn detect_untracked_files( } // Build set of tracked paths for fast lookup - let tracked_paths: HashSet = media_paths + let tracked_paths: FxHashSet = media_paths .iter() .map(|(_, path, _)| path.clone()) .collect(); @@ -198,7 +196,7 @@ async fn detect_untracked_files( ]; // Walk filesystem for each root in parallel (limit concurrency to 4) - let mut filesystem_paths = HashSet::new(); + let mut filesystem_paths = FxHashSet::default(); let mut tasks = tokio::task::JoinSet::new(); for root in roots { @@ -322,8 +320,7 @@ pub async fn verify_integrity( let paths_to_check: Vec<(MediaId, PathBuf, ContentHash)> = if let Some(ids) = media_ids { - let id_set: std::collections::HashSet = - ids.iter().copied().collect(); + let id_set: FxHashSet = ids.iter().copied().collect(); all_paths .into_iter() .filter(|(id, ..)| id_set.contains(id)) @@ -383,7 +380,7 @@ pub async fn cleanup_orphaned_thumbnails( thumbnail_dir: &Path, ) -> Result { let media_paths = storage.list_media_paths().await?; - let known_ids: std::collections::HashSet = media_paths + let known_ids: FxHashSet = media_paths .iter() .map(|(id, ..)| id.0.to_string()) .collect(); diff --git a/crates/pinakes-core/src/jobs.rs b/crates/pinakes-core/src/jobs.rs index f9487f8..d4bc106 100644 --- a/crates/pinakes-core/src/jobs.rs +++ b/crates/pinakes-core/src/jobs.rs @@ -1,6 +1,7 @@ -use std::{collections::HashMap, path::PathBuf, sync::Arc}; +use std::{path::PathBuf, sync::Arc}; use chrono::{DateTime, Utc}; +use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use serde_json::Value; use tokio::sync::{RwLock, mpsc}; @@ -71,8 +72,8 @@ struct WorkerItem { } pub struct JobQueue { - jobs: Arc>>, - cancellations: Arc>>, + jobs: Arc>>, + cancellations: Arc>>, tx: mpsc::Sender, } @@ -94,7 +95,7 @@ impl JobQueue { Uuid, JobKind, CancellationToken, - Arc>>, + Arc>>, ) -> tokio::task::JoinHandle<()> + Send + Sync @@ -102,10 +103,10 @@ impl JobQueue { { let (tx, rx) = mpsc::channel::(256); let rx = Arc::new(tokio::sync::Mutex::new(rx)); - let jobs: Arc>> = - Arc::new(RwLock::new(HashMap::new())); - let cancellations: Arc>> = - Arc::new(RwLock::new(HashMap::new())); + let jobs: Arc>> = + Arc::new(RwLock::new(FxHashMap::default())); + let cancellations: Arc>> = + Arc::new(RwLock::new(FxHashMap::default())); let executor = Arc::new(executor); @@ -261,7 +262,7 @@ impl JobQueue { /// Update a job's progress. Called by executors. pub async fn update_progress( - jobs: &Arc>>, + jobs: &Arc>>, id: Uuid, progress: f32, message: String, @@ -275,7 +276,7 @@ impl JobQueue { /// Mark a job as completed. pub async fn complete( - jobs: &Arc>>, + jobs: &Arc>>, id: Uuid, result: Value, ) { @@ -288,7 +289,7 @@ impl JobQueue { /// Mark a job as failed. pub async fn fail( - jobs: &Arc>>, + jobs: &Arc>>, id: Uuid, error: String, ) { diff --git a/crates/pinakes-core/src/links.rs b/crates/pinakes-core/src/links.rs index 851521b..4673739 100644 --- a/crates/pinakes-core/src/links.rs +++ b/crates/pinakes-core/src/links.rs @@ -352,7 +352,7 @@ pub fn resolve_link_candidates( } // 4. Remove duplicates while preserving order - let mut seen = std::collections::HashSet::new(); + let mut seen = rustc_hash::FxHashSet::default(); candidates.retain(|p| seen.insert(p.clone())); candidates diff --git a/crates/pinakes-core/src/media_type/registry.rs b/crates/pinakes-core/src/media_type/registry.rs index 569a3ab..871f12c 100644 --- a/crates/pinakes-core/src/media_type/registry.rs +++ b/crates/pinakes-core/src/media_type/registry.rs @@ -1,8 +1,7 @@ //! Media type registry for managing both built-in and custom media types -use std::collections::HashMap; - use anyhow::{Result, anyhow}; +use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use super::MediaCategory; @@ -33,10 +32,10 @@ pub struct MediaTypeDescriptor { #[derive(Debug, Clone)] pub struct MediaTypeRegistry { /// Map of media type ID to descriptor - types: HashMap, + types: FxHashMap, /// Map of extension to media type ID - extension_map: HashMap, + extension_map: FxHashMap, } impl MediaTypeRegistry { @@ -44,8 +43,8 @@ impl MediaTypeRegistry { #[must_use] pub fn new() -> Self { Self { - types: HashMap::new(), - extension_map: HashMap::new(), + types: FxHashMap::default(), + extension_map: FxHashMap::default(), } } diff --git a/crates/pinakes-core/src/metadata/document.rs b/crates/pinakes-core/src/metadata/document.rs index 4994020..395e18b 100644 --- a/crates/pinakes-core/src/metadata/document.rs +++ b/crates/pinakes-core/src/metadata/document.rs @@ -190,7 +190,7 @@ fn extract_epub(path: &Path) -> Result { book_meta.authors = authors; // Extract ISBNs from identifiers - let mut identifiers = std::collections::HashMap::new(); + let mut identifiers = rustc_hash::FxHashMap::default(); for item in &doc.metadata { if item.property == "identifier" || item.property == "dc:identifier" { // Try to get scheme from refinements diff --git a/crates/pinakes-core/src/metadata/mod.rs b/crates/pinakes-core/src/metadata/mod.rs index 0ea4da3..b4e91e5 100644 --- a/crates/pinakes-core/src/metadata/mod.rs +++ b/crates/pinakes-core/src/metadata/mod.rs @@ -4,7 +4,9 @@ pub mod image; pub mod markdown; pub mod video; -use std::{collections::HashMap, path::Path}; +use std::path::Path; + +use rustc_hash::FxHashMap; use crate::{error::Result, media_type::MediaType, model::BookMetadata}; @@ -17,7 +19,7 @@ pub struct ExtractedMetadata { pub year: Option, pub duration_secs: Option, pub description: Option, - pub extra: HashMap, + pub extra: FxHashMap, pub book_metadata: Option, // Photo-specific metadata diff --git a/crates/pinakes-core/src/model.rs b/crates/pinakes-core/src/model.rs index 19d6e8e..f37e7a1 100644 --- a/crates/pinakes-core/src/model.rs +++ b/crates/pinakes-core/src/model.rs @@ -1,6 +1,7 @@ -use std::{collections::HashMap, fmt, path::PathBuf}; +use std::{fmt, path::PathBuf}; use chrono::{DateTime, Utc}; +use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -129,7 +130,7 @@ pub struct MediaItem { pub duration_secs: Option, pub description: Option, pub thumbnail_path: Option, - pub custom_fields: HashMap, + pub custom_fields: FxHashMap, /// File modification time (Unix timestamp in seconds), used for incremental /// scanning pub file_mtime: Option, @@ -434,7 +435,7 @@ pub struct BookMetadata { pub series_index: Option, pub format: Option, pub authors: Vec, - pub identifiers: HashMap>, + pub identifiers: FxHashMap>, pub created_at: DateTime, pub updated_at: DateTime, } @@ -454,7 +455,7 @@ impl Default for BookMetadata { series_index: None, format: None, authors: Vec::new(), - identifiers: HashMap::new(), + identifiers: FxHashMap::default(), created_at: now, updated_at: now, } diff --git a/crates/pinakes-core/src/plugin/mod.rs b/crates/pinakes-core/src/plugin/mod.rs index e43e930..f8ae6cc 100644 --- a/crates/pinakes-core/src/plugin/mod.rs +++ b/crates/pinakes-core/src/plugin/mod.rs @@ -186,17 +186,19 @@ impl PluginManager { fn resolve_load_order( manifests: &[pinakes_plugin_api::PluginManifest], ) -> Vec { - use std::collections::{HashMap, HashSet, VecDeque}; + use std::collections::VecDeque; + + use rustc_hash::{FxHashMap, FxHashSet}; // Index manifests by name for O(1) lookup - let by_name: HashMap<&str, usize> = manifests + let by_name: FxHashMap<&str, usize> = manifests .iter() .enumerate() .map(|(i, m)| (m.plugin.name.as_str(), i)) .collect(); // Check for missing dependencies and warn early - let known: HashSet<&str> = by_name.keys().copied().collect(); + let known: FxHashSet<&str> = by_name.keys().copied().collect(); for manifest in manifests { for dep in &manifest.plugin.dependencies { if !known.contains(dep.as_str()) { @@ -250,7 +252,7 @@ impl PluginManager { // Anything not in `result` is part of a cycle or has a missing dep if result.len() < manifests.len() { - let loaded: HashSet<&str> = + let loaded: FxHashSet<&str> = result.iter().map(|m| m.plugin.name.as_str()).collect(); for manifest in manifests { if !loaded.contains(manifest.plugin.name.as_str()) { @@ -669,9 +671,9 @@ impl PluginManager { /// none declare theme extensions. pub async fn list_ui_theme_extensions( &self, - ) -> std::collections::HashMap { + ) -> rustc_hash::FxHashMap { let registry = self.registry.read().await; - let mut merged = std::collections::HashMap::new(); + let mut merged = rustc_hash::FxHashMap::default(); for plugin in registry.list_all() { if !plugin.enabled { continue; diff --git a/crates/pinakes-core/src/plugin/pipeline.rs b/crates/pinakes-core/src/plugin/pipeline.rs index 8add7d1..f4301a5 100644 --- a/crates/pinakes-core/src/plugin/pipeline.rs +++ b/crates/pinakes-core/src/plugin/pipeline.rs @@ -13,12 +13,12 @@ //! priority 100. A circuit breaker disables plugins after consecutive failures. use std::{ - collections::HashMap, path::{Path, PathBuf}, sync::Arc, time::{Duration, Instant}, }; +use rustc_hash::FxHashMap; use tokio::sync::RwLock; use tracing::{debug, info, warn}; @@ -75,22 +75,22 @@ struct CachedCapabilities { /// Keyed by `(kind, plugin_id)` -> list of supported type strings. /// Separate entries for each kind avoid collisions when a plugin /// implements both `metadata_extractor` and `thumbnail_generator`. - supported_types: HashMap<(String, String), Vec>, + supported_types: FxHashMap<(String, String), Vec>, /// `plugin_id` -> list of interested event type strings - interested_events: HashMap>, + interested_events: FxHashMap>, /// `plugin_id` -> list of media type definitions (for `MediaTypeProvider`) - media_type_definitions: HashMap>, + media_type_definitions: FxHashMap>, /// `plugin_id` -> list of theme definitions (for `ThemeProvider`) - theme_definitions: HashMap>, + theme_definitions: FxHashMap>, } impl CachedCapabilities { fn new() -> Self { Self { - supported_types: HashMap::new(), - interested_events: HashMap::new(), - media_type_definitions: HashMap::new(), - theme_definitions: HashMap::new(), + supported_types: FxHashMap::default(), + interested_events: FxHashMap::default(), + media_type_definitions: FxHashMap::default(), + theme_definitions: FxHashMap::default(), } } } @@ -101,7 +101,7 @@ pub struct PluginPipeline { manager: Arc, timeouts: PluginTimeoutConfig, max_consecutive_failures: u32, - health: RwLock>, + health: RwLock>, capabilities: RwLock, } @@ -117,7 +117,7 @@ impl PluginPipeline { manager, timeouts, max_consecutive_failures, - health: RwLock::new(HashMap::new()), + health: RwLock::new(FxHashMap::default()), capabilities: RwLock::new(CachedCapabilities::new()), } } @@ -826,7 +826,7 @@ impl PluginPipeline { } // Deduplicate by ID, keeping the highest-scoring entry - let mut seen: HashMap = HashMap::new(); + let mut seen: FxHashMap = FxHashMap::default(); let mut deduped: Vec = Vec::new(); for item in all_results { if let Some(&idx) = seen.get(&item.id) { @@ -1363,7 +1363,7 @@ mod tests { year: Some(2024), duration_secs: None, description: None, - extra: HashMap::new(), + extra: FxHashMap::default(), }; merge_metadata(&mut base, &resp); @@ -1379,7 +1379,7 @@ mod tests { let mut base = ExtractedMetadata::default(); base.extra.insert("key1".to_string(), "val1".to_string()); - let mut extra = HashMap::new(); + let mut extra = FxHashMap::default(); extra.insert("key2".to_string(), "val2".to_string()); extra.insert("key1".to_string(), "overwritten".to_string()); diff --git a/crates/pinakes-core/src/plugin/registry.rs b/crates/pinakes-core/src/plugin/registry.rs index a773164..ce13d86 100644 --- a/crates/pinakes-core/src/plugin/registry.rs +++ b/crates/pinakes-core/src/plugin/registry.rs @@ -1,9 +1,10 @@ //! Plugin registry for managing loaded plugins -use std::{collections::HashMap, path::PathBuf}; +use std::path::PathBuf; use anyhow::{Result, anyhow}; use pinakes_plugin_api::{PluginManifest, PluginMetadata}; +use rustc_hash::FxHashMap; use super::runtime::WasmPlugin; @@ -21,7 +22,7 @@ pub struct RegisteredPlugin { /// Plugin registry maintains the state of all loaded plugins pub struct PluginRegistry { /// Map of plugin ID to registered plugin - plugins: HashMap, + plugins: FxHashMap, } impl PluginRegistry { @@ -29,7 +30,7 @@ impl PluginRegistry { #[must_use] pub fn new() -> Self { Self { - plugins: HashMap::new(), + plugins: FxHashMap::default(), } } @@ -156,9 +157,8 @@ impl Default for PluginRegistry { #[cfg(test)] mod tests { - use std::collections::HashMap; - use pinakes_plugin_api::{Capabilities, manifest::ManifestCapabilities}; + use rustc_hash::FxHashMap; use super::*; @@ -181,7 +181,7 @@ mod tests { priority: 0, }, capabilities: ManifestCapabilities::default(), - config: HashMap::new(), + config: FxHashMap::default(), ui: Default::default(), }; diff --git a/crates/pinakes-core/src/plugin/rpc.rs b/crates/pinakes-core/src/plugin/rpc.rs index 40d4d13..e875d11 100644 --- a/crates/pinakes-core/src/plugin/rpc.rs +++ b/crates/pinakes-core/src/plugin/rpc.rs @@ -4,8 +4,9 @@ //! Requests are serialized to JSON, passed to the plugin, and responses //! are deserialized from JSON written by the plugin via `host_set_result`. -use std::{collections::HashMap, path::PathBuf}; +use std::path::PathBuf; +use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; /// Request to check if a plugin can handle a file @@ -55,7 +56,7 @@ pub struct ExtractMetadataResponse { #[serde(default)] pub description: Option, #[serde(default)] - pub extra: HashMap, + pub extra: FxHashMap, } /// Request to generate a thumbnail @@ -140,7 +141,7 @@ pub struct PluginThemeDefinition { #[derive(Debug, Clone, Deserialize)] pub struct LoadThemeResponse { pub css: Option, - pub colors: HashMap, + pub colors: FxHashMap, } #[cfg(test)] diff --git a/crates/pinakes-core/src/plugin/runtime.rs b/crates/pinakes-core/src/plugin/runtime.rs index 14fe010..6a363e7 100644 --- a/crates/pinakes-core/src/plugin/runtime.rs +++ b/crates/pinakes-core/src/plugin/runtime.rs @@ -272,7 +272,7 @@ impl Default for WasmPlugin { context: PluginContext { data_dir: std::env::temp_dir(), cache_dir: std::env::temp_dir(), - config: std::collections::HashMap::new(), + config: Default::default(), capabilities: Default::default(), }, } @@ -774,8 +774,6 @@ impl HostFunctions { #[cfg(test)] mod tests { - use std::collections::HashMap; - use pinakes_plugin_api::PluginContext; use super::*; @@ -795,7 +793,7 @@ mod tests { let context = PluginContext { data_dir: "/tmp/data".into(), cache_dir: "/tmp/cache".into(), - config: HashMap::new(), + config: Default::default(), capabilities, }; diff --git a/crates/pinakes-core/src/storage/mod.rs b/crates/pinakes-core/src/storage/mod.rs index 6557ee1..606dc61 100644 --- a/crates/pinakes-core/src/storage/mod.rs +++ b/crates/pinakes-core/src/storage/mod.rs @@ -5,6 +5,7 @@ pub mod sqlite; use std::{path::PathBuf, sync::Arc}; use chrono::{DateTime, Utc}; +use rustc_hash::FxHashMap; use uuid::Uuid; use crate::{ @@ -221,7 +222,7 @@ pub trait StorageBackend: Send + Sync + 'static { async fn get_custom_fields( &self, media_id: MediaId, - ) -> Result>; + ) -> Result>; /// Delete a custom field from a media item by name. async fn delete_custom_field( diff --git a/crates/pinakes-core/src/storage/postgres.rs b/crates/pinakes-core/src/storage/postgres.rs index 76d84cd..ea962aa 100644 --- a/crates/pinakes-core/src/storage/postgres.rs +++ b/crates/pinakes-core/src/storage/postgres.rs @@ -1,9 +1,10 @@ -use std::{collections::HashMap, path::PathBuf}; +use std::path::PathBuf; use chrono::Utc; use deadpool_postgres::{Config as PoolConfig, Pool, Runtime}; use native_tls::TlsConnector; use postgres_native_tls::MakeTlsConnector; +use rustc_hash::FxHashMap; use tokio_postgres::{NoTls, Row, types::ToSql}; use uuid::Uuid; @@ -215,7 +216,7 @@ fn row_to_media_item(row: &Row) -> Result { thumbnail_path: row .get::<_, Option>("thumbnail_path") .map(PathBuf::from), - custom_fields: HashMap::new(), + custom_fields: FxHashMap::default(), file_mtime: row.get("file_mtime"), // Photo-specific fields @@ -922,8 +923,8 @@ impl StorageBackend for PostgresBackend { ) .await?; - let mut cf_map: HashMap> = - HashMap::new(); + let mut cf_map: FxHashMap> = + FxHashMap::default(); for row in &cf_rows { let mid: Uuid = row.get("media_id"); let name: String = row.get("field_name"); @@ -1596,8 +1597,8 @@ impl StorageBackend for PostgresBackend { ) .await?; - let mut cf_map: HashMap> = - HashMap::new(); + let mut cf_map: FxHashMap> = + FxHashMap::default(); for row in &cf_rows { let mid: Uuid = row.get("media_id"); let name: String = row.get("field_name"); @@ -1759,8 +1760,8 @@ impl StorageBackend for PostgresBackend { ) .await?; - let mut cf_map: HashMap> = - HashMap::new(); + let mut cf_map: FxHashMap> = + FxHashMap::default(); for row in &cf_rows { let mid: Uuid = row.get("media_id"); let name: String = row.get("field_name"); @@ -1894,7 +1895,7 @@ impl StorageBackend for PostgresBackend { async fn get_custom_fields( &self, media_id: MediaId, - ) -> Result> { + ) -> Result> { let client = self .pool .get() @@ -1909,7 +1910,7 @@ impl StorageBackend for PostgresBackend { ) .await?; - let mut map = HashMap::new(); + let mut map = FxHashMap::default(); for row in &rows { let name: String = row.get("field_name"); let ft_str: String = row.get("field_type"); @@ -1988,8 +1989,8 @@ impl StorageBackend for PostgresBackend { ) .await?; - let mut cf_map: HashMap> = - HashMap::new(); + let mut cf_map: FxHashMap> = + FxHashMap::default(); for row in &cf_rows { let mid: Uuid = row.get("media_id"); let name: String = row.get("field_name"); @@ -2066,8 +2067,8 @@ impl StorageBackend for PostgresBackend { ) .await?; - let mut cf_map: HashMap> = - HashMap::new(); + let mut cf_map: FxHashMap> = + FxHashMap::default(); for row in &cf_rows { let mid: Uuid = row.get("media_id"); let name: String = row.get("field_name"); @@ -2089,8 +2090,8 @@ impl StorageBackend for PostgresBackend { // Compare each pair and build groups let mut groups: Vec> = Vec::new(); - let mut grouped_indices: std::collections::HashSet = - std::collections::HashSet::new(); + let mut grouped_indices: rustc_hash::FxHashSet = + rustc_hash::FxHashSet::default(); for i in 0..items.len() { if grouped_indices.contains(&i) { @@ -2952,8 +2953,8 @@ impl StorageBackend for PostgresBackend { &[&ids], ) .await?; - let mut cf_map: HashMap> = - HashMap::new(); + let mut cf_map: FxHashMap> = + FxHashMap::default(); for row in &cf_rows { let mid: Uuid = row.get("media_id"); let name: String = row.get("field_name"); @@ -3365,8 +3366,8 @@ impl StorageBackend for PostgresBackend { &[&ids], ) .await?; - let mut cf_map: HashMap> = - HashMap::new(); + let mut cf_map: FxHashMap> = + FxHashMap::default(); for row in &cf_rows { let mid: Uuid = row.get("media_id"); let name: String = row.get("field_name"); @@ -3553,8 +3554,8 @@ impl StorageBackend for PostgresBackend { &[&ids], ) .await?; - let mut cf_map: HashMap> = - HashMap::new(); + let mut cf_map: FxHashMap> = + FxHashMap::default(); for row in &cf_rows { let mid: Uuid = row.get("media_id"); let name: String = row.get("field_name"); @@ -3623,8 +3624,8 @@ impl StorageBackend for PostgresBackend { &[&ids], ) .await?; - let mut cf_map: HashMap> = - HashMap::new(); + let mut cf_map: FxHashMap> = + FxHashMap::default(); for row in &cf_rows { let mid: Uuid = row.get("media_id"); let name: String = row.get("field_name"); @@ -4448,8 +4449,7 @@ impl StorageBackend for PostgresBackend { ) .await?; - let mut identifiers: std::collections::HashMap> = - std::collections::HashMap::new(); + let mut identifiers: FxHashMap> = FxHashMap::default(); for r in id_rows { let id_type: String = r.get(0); let value: String = r.get(1); @@ -7031,11 +7031,11 @@ impl StorageBackend for PostgresBackend { let depth = depth.min(5); // Limit depth let mut nodes = Vec::new(); let mut edges = Vec::new(); - let node_ids: std::collections::HashSet = + let node_ids: rustc_hash::FxHashSet = if let Some(center) = center_id { // BFS to find connected nodes within depth let mut frontier = vec![center.0.to_string()]; - let mut visited = std::collections::HashSet::new(); + let mut visited = rustc_hash::FxHashSet::default(); visited.insert(center.0.to_string()); for _ in 0..depth { @@ -7099,7 +7099,7 @@ impl StorageBackend for PostgresBackend { .await .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut collected = std::collections::HashSet::new(); + let mut collected = rustc_hash::FxHashSet::default(); for row in rows { let id: String = row.get(0); collected.insert(id); diff --git a/crates/pinakes-core/src/storage/sqlite.rs b/crates/pinakes-core/src/storage/sqlite.rs index 847256a..46ce813 100644 --- a/crates/pinakes-core/src/storage/sqlite.rs +++ b/crates/pinakes-core/src/storage/sqlite.rs @@ -1,11 +1,11 @@ use std::{ - collections::HashMap, path::{Path, PathBuf}, sync::{Arc, Mutex}, }; use chrono::{DateTime, NaiveDateTime, Utc}; use rusqlite::{Connection, Row, params}; +use rustc_hash::FxHashMap; use uuid::Uuid; use crate::{ @@ -142,7 +142,7 @@ fn row_to_media_item(row: &Row) -> rusqlite::Result { thumbnail_path: row .get::<_, Option>("thumbnail_path")? .map(PathBuf::from), - custom_fields: HashMap::new(), // loaded separately + custom_fields: FxHashMap::default(), // loaded separately // file_mtime may not be present in all queries, so handle gracefully file_mtime: row.get::<_, Option>("file_mtime").unwrap_or(None), @@ -358,7 +358,7 @@ fn load_user_profile_sync( fn load_custom_fields_sync( db: &Connection, media_id: MediaId, -) -> rusqlite::Result> { +) -> rusqlite::Result> { let mut stmt = db.prepare( "SELECT field_name, field_type, field_value FROM custom_fields WHERE \ media_id = ?1", @@ -372,7 +372,7 @@ fn load_custom_fields_sync( value, })) })?; - let mut map = HashMap::new(); + let mut map = FxHashMap::default(); for r in rows { let (name, field) = r?; map.insert(name, field); @@ -409,8 +409,8 @@ fn load_custom_fields_batch( Ok((mid_str, name, ft_str, value)) })?; - let mut fields_map: HashMap> = - HashMap::new(); + let mut fields_map: FxHashMap> = + FxHashMap::default(); for r in rows { let (mid_str, name, ft_str, value) = r?; fields_map @@ -1762,7 +1762,7 @@ impl StorageBackend for SqliteBackend { async fn get_custom_fields( &self, media_id: MediaId, - ) -> Result> { + ) -> Result> { let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { let map = { @@ -1783,7 +1783,7 @@ impl StorageBackend for SqliteBackend { })) })?; - let mut map = HashMap::new(); + let mut map = FxHashMap::default(); for r in rows { let (name, field) = r?; map.insert(name, field); @@ -2093,8 +2093,8 @@ impl StorageBackend for SqliteBackend { // Compare each pair and build groups let mut groups: Vec> = Vec::new(); - let mut grouped_indices: std::collections::HashSet = - std::collections::HashSet::new(); + let mut grouped_indices: rustc_hash::FxHashSet = + rustc_hash::FxHashSet::default(); for i in 0..items.len() { if grouped_indices.contains(&i) { @@ -5265,8 +5265,8 @@ impl StorageBackend for SqliteBackend { "SELECT identifier_type, identifier_value FROM book_identifiers WHERE media_id = ?1", )?; - let mut identifiers: std::collections::HashMap> = - std::collections::HashMap::new(); + let mut identifiers: FxHashMap> = + FxHashMap::default(); for row in stmt.query_map([&media_id_str], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) })? { @@ -8336,13 +8336,13 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| PinakesError::Database(format!("connection mutex poisoned: {e}")))?; let mut nodes = Vec::new(); let mut edges = Vec::new(); - let mut node_ids = std::collections::HashSet::new(); + let mut node_ids = rustc_hash::FxHashSet::default(); // Get nodes - either all markdown files or those connected to center if let Some(center_id) = center_id_str { // BFS to find connected nodes within depth let mut frontier = vec![center_id.clone()]; - let mut visited = std::collections::HashSet::new(); + let mut visited = rustc_hash::FxHashSet::default(); visited.insert(center_id); for _ in 0..depth { diff --git a/crates/pinakes-core/src/transcode.rs b/crates/pinakes-core/src/transcode.rs index 12b2b62..416c1a6 100644 --- a/crates/pinakes-core/src/transcode.rs +++ b/crates/pinakes-core/src/transcode.rs @@ -1,12 +1,12 @@ //! Transcoding service for media files using `FFmpeg`. use std::{ - collections::HashMap, path::{Path, PathBuf}, sync::Arc, }; use chrono::{DateTime, Utc}; +use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use tokio::sync::{RwLock, Semaphore}; use uuid::Uuid; @@ -94,7 +94,7 @@ impl TranscodeStatus { /// Service managing transcoding sessions and `FFmpeg` invocations. pub struct TranscodeService { pub config: TranscodingConfig, - pub sessions: Arc>>, + pub sessions: Arc>>, semaphore: Arc, } @@ -103,7 +103,7 @@ impl TranscodeService { pub fn new(config: TranscodingConfig) -> Self { let max_concurrent = config.max_concurrent.max(1); Self { - sessions: Arc::new(RwLock::new(HashMap::new())), + sessions: Arc::new(RwLock::new(FxHashMap::default())), semaphore: Arc::new(Semaphore::new(max_concurrent)), config, } @@ -481,7 +481,7 @@ fn get_ffmpeg_args( /// Run `FFmpeg` as a child process, parsing progress from stdout. async fn run_ffmpeg( args: &[String], - sessions: &Arc>>, + sessions: &Arc>>, session_id: Uuid, duration_secs: Option, cancel: Arc, diff --git a/crates/pinakes-core/src/upload.rs b/crates/pinakes-core/src/upload.rs index 806c9bd..837b34d 100644 --- a/crates/pinakes-core/src/upload.rs +++ b/crates/pinakes-core/src/upload.rs @@ -3,7 +3,7 @@ //! Handles file uploads, metadata extraction, and `MediaItem` creation //! for files stored in managed content-addressable storage. -use std::{collections::HashMap, path::Path}; +use std::path::Path; use chrono::Utc; use tokio::io::AsyncRead; @@ -85,7 +85,7 @@ pub async fn process_upload( duration_secs: extracted.as_ref().and_then(|m| m.duration_secs), description: extracted.as_ref().and_then(|m| m.description.clone()), thumbnail_path: None, - custom_fields: HashMap::new(), + custom_fields: rustc_hash::FxHashMap::default(), file_mtime: None, date_taken: extracted.as_ref().and_then(|m| m.date_taken), latitude: extracted.as_ref().and_then(|m| m.latitude), diff --git a/crates/pinakes-core/src/users.rs b/crates/pinakes-core/src/users.rs index 27773eb..030bd46 100644 --- a/crates/pinakes-core/src/users.rs +++ b/crates/pinakes-core/src/users.rs @@ -1,8 +1,7 @@ //! User management and authentication -use std::collections::HashMap; - use chrono::{DateTime, Utc}; +use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -78,7 +77,7 @@ pub struct UserPreferences { pub auto_play: bool, /// Custom preferences (extensible) - pub custom: HashMap, + pub custom: FxHashMap, } /// Library access permission diff --git a/crates/pinakes-core/tests/common/mod.rs b/crates/pinakes-core/tests/common/mod.rs index 030c106..d3db13d 100644 --- a/crates/pinakes-core/tests/common/mod.rs +++ b/crates/pinakes-core/tests/common/mod.rs @@ -3,13 +3,14 @@ // the test suite #![allow(dead_code)] -use std::{collections::HashMap, path::PathBuf, sync::Arc}; +use std::{path::PathBuf, sync::Arc}; use pinakes_core::{ media_type::{BuiltinMediaType, MediaType}, model::{ContentHash, MediaId, MediaItem, StorageMode}, storage::{DynStorageBackend, StorageBackend, sqlite::SqliteBackend}, }; +use rustc_hash::FxHashMap; use tempfile::TempDir; use uuid::Uuid; @@ -46,7 +47,7 @@ pub fn make_test_media(hash: &str) -> MediaItem { duration_secs: Some(120.0), description: None, thumbnail_path: None, - custom_fields: HashMap::new(), + custom_fields: FxHashMap::default(), file_mtime: None, date_taken: None, latitude: None, @@ -83,7 +84,7 @@ pub fn create_test_media_item(path: PathBuf, hash: &str) -> MediaItem { duration_secs: None, description: None, thumbnail_path: None, - custom_fields: HashMap::new(), + custom_fields: FxHashMap::default(), file_mtime: None, date_taken: None, latitude: None, @@ -121,7 +122,7 @@ pub fn make_test_markdown_item(id: MediaId) -> MediaItem { duration_secs: None, description: Some("Test markdown note".to_string()), thumbnail_path: None, - custom_fields: HashMap::new(), + custom_fields: FxHashMap::default(), file_mtime: None, date_taken: None, latitude: None, diff --git a/crates/pinakes-core/tests/integration.rs b/crates/pinakes-core/tests/integration.rs index 927d012..9033f9c 100644 --- a/crates/pinakes-core/tests/integration.rs +++ b/crates/pinakes-core/tests/integration.rs @@ -1,6 +1,5 @@ -use std::collections::HashMap; - use pinakes_core::{model::*, storage::StorageBackend}; +use rustc_hash::FxHashMap; mod common; use common::{make_test_media, setup}; @@ -28,7 +27,7 @@ async fn test_media_crud() { duration_secs: None, description: Some("A test file".to_string()), thumbnail_path: None, - custom_fields: HashMap::new(), + custom_fields: FxHashMap::default(), file_mtime: None, date_taken: None, latitude: None, @@ -120,7 +119,7 @@ async fn test_tags() { duration_secs: Some(180.0), description: None, thumbnail_path: None, - custom_fields: HashMap::new(), + custom_fields: FxHashMap::default(), file_mtime: None, date_taken: None, latitude: None, @@ -191,7 +190,7 @@ async fn test_collections() { duration_secs: None, description: None, thumbnail_path: None, - custom_fields: HashMap::new(), + custom_fields: FxHashMap::default(), file_mtime: None, date_taken: None, latitude: None, @@ -252,7 +251,7 @@ async fn test_custom_fields() { duration_secs: None, description: None, thumbnail_path: None, - custom_fields: HashMap::new(), + custom_fields: FxHashMap::default(), file_mtime: None, date_taken: None, latitude: None, @@ -334,7 +333,7 @@ async fn test_search() { duration_secs: None, description: None, thumbnail_path: None, - custom_fields: HashMap::new(), + custom_fields: FxHashMap::default(), file_mtime: None, date_taken: None, latitude: None, @@ -479,7 +478,7 @@ async fn test_library_statistics_with_data() { duration_secs: Some(120.0), description: None, thumbnail_path: None, - custom_fields: HashMap::new(), + custom_fields: FxHashMap::default(), file_mtime: None, date_taken: None, latitude: None, diff --git a/crates/pinakes-plugin-api/Cargo.toml b/crates/pinakes-plugin-api/Cargo.toml index bfc8dd4..51a6686 100644 --- a/crates/pinakes-plugin-api/Cargo.toml +++ b/crates/pinakes-plugin-api/Cargo.toml @@ -19,6 +19,7 @@ toml = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } mime_guess = { workspace = true } +rustc-hash = { workspace = true } # WASM bridge types wit-bindgen = { workspace = true, optional = true } diff --git a/crates/pinakes-plugin-api/src/lib.rs b/crates/pinakes-plugin-api/src/lib.rs index 5440669..d21c284 100644 --- a/crates/pinakes-plugin-api/src/lib.rs +++ b/crates/pinakes-plugin-api/src/lib.rs @@ -4,12 +4,10 @@ //! Plugins can extend Pinakes by implementing one or more of the provided //! traits. -use std::{ - collections::HashMap, - path::{Path, PathBuf}, -}; +use std::path::{Path, PathBuf}; use async_trait::async_trait; +use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -74,7 +72,7 @@ pub struct PluginContext { pub cache_dir: PathBuf, /// Plugin configuration from manifest - pub config: HashMap, + pub config: FxHashMap, /// Capabilities granted to the plugin pub capabilities: Capabilities, @@ -160,7 +158,7 @@ pub struct PluginMetadata { pub struct HealthStatus { pub healthy: bool, pub message: Option, - pub metrics: HashMap, + pub metrics: FxHashMap, } /// Trait for plugins that provide custom media type support @@ -227,7 +225,7 @@ pub struct ExtractedMetadata { pub bitrate_kbps: Option, /// Custom metadata fields specific to this file type - pub custom_fields: HashMap, + pub custom_fields: FxHashMap, /// Tags extracted from the file pub tags: Vec, @@ -301,14 +299,14 @@ pub struct SearchIndexItem { pub content: Option, pub tags: Vec, pub media_type: String, - pub metadata: HashMap, + pub metadata: FxHashMap, } /// Search query #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SearchQuery { pub query_text: String, - pub filters: HashMap, + pub filters: FxHashMap, pub limit: usize, pub offset: usize, } @@ -360,7 +358,7 @@ pub enum EventType { pub struct Event { pub event_type: EventType, pub timestamp: String, - pub data: HashMap, + pub data: FxHashMap, } /// Trait for plugins that provide UI themes @@ -387,7 +385,7 @@ pub struct ThemeDefinition { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Theme { pub id: String, - pub colors: HashMap, - pub fonts: HashMap, + pub colors: FxHashMap, + pub fonts: FxHashMap, pub custom_css: Option, } diff --git a/crates/pinakes-plugin-api/src/manifest.rs b/crates/pinakes-plugin-api/src/manifest.rs index a7229c0..20547a1 100644 --- a/crates/pinakes-plugin-api/src/manifest.rs +++ b/crates/pinakes-plugin-api/src/manifest.rs @@ -1,7 +1,8 @@ //! Plugin manifest parsing and validation -use std::{collections::HashMap, path::Path}; +use std::path::Path; +use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -23,7 +24,7 @@ pub struct PluginManifest { pub capabilities: ManifestCapabilities, #[serde(default)] - pub config: HashMap, + pub config: FxHashMap, /// UI pages provided by this plugin #[serde(default)] @@ -49,8 +50,8 @@ pub struct UiSection { /// CSS custom property overrides provided by this plugin. /// Keys are property names (e.g. `--accent-color`), values are CSS values. /// The host applies these to `document.documentElement` on startup. - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub theme_extensions: HashMap, + #[serde(default, skip_serializing_if = "FxHashMap::is_empty")] + pub theme_extensions: FxHashMap, } impl UiSection { @@ -709,7 +710,7 @@ gap = 16 "/api/v1/media".to_string(), "/api/plugins/my-plugin/data".to_string(), ], - theme_extensions: HashMap::new(), + theme_extensions: FxHashMap::default(), }; assert!(section.validate().is_ok()); } @@ -720,7 +721,7 @@ gap = 16 pages: vec![], widgets: vec![], required_endpoints: vec!["/not-api/something".to_string()], - theme_extensions: HashMap::new(), + theme_extensions: FxHashMap::default(), }; assert!(section.validate().is_err()); } @@ -731,7 +732,7 @@ gap = 16 pages: vec![], widgets: vec![], required_endpoints: vec!["/api/ok".to_string(), "no-slash".to_string()], - theme_extensions: HashMap::new(), + theme_extensions: FxHashMap::default(), }; let err = section.validate().unwrap_err(); assert!( diff --git a/crates/pinakes-plugin-api/src/ui_schema.rs b/crates/pinakes-plugin-api/src/ui_schema.rs index 6ce15bd..27576ab 100644 --- a/crates/pinakes-plugin-api/src/ui_schema.rs +++ b/crates/pinakes-plugin-api/src/ui_schema.rs @@ -49,8 +49,7 @@ //! Array indices use the same notation: `"items.0.title"`. //! ``` -use std::collections::HashMap; - +use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -133,12 +132,12 @@ pub struct UiPage { pub root_element: UiElement, /// Named data sources available to this page - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub data_sources: HashMap, + #[serde(default, skip_serializing_if = "FxHashMap::is_empty")] + pub data_sources: FxHashMap, /// Named actions available to this page (referenced by `ActionRef::Name`) - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub actions: HashMap, + #[serde(default, skip_serializing_if = "FxHashMap::is_empty")] + pub actions: FxHashMap, } impl UiPage { @@ -204,8 +203,8 @@ impl UiPage { /// Validates that there are no cycles in Transform data source dependencies fn validate_no_cycles(&self) -> SchemaResult<()> { - let mut visited = std::collections::HashSet::new(); - let mut stack = std::collections::HashSet::new(); + let mut visited = rustc_hash::FxHashSet::default(); + let mut stack = rustc_hash::FxHashSet::default(); for name in self.data_sources.keys() { Self::dfs_check_cycles(self, name, &mut visited, &mut stack)?; @@ -218,8 +217,8 @@ impl UiPage { fn dfs_check_cycles( &self, name: &str, - visited: &mut std::collections::HashSet, - stack: &mut std::collections::HashSet, + visited: &mut rustc_hash::FxHashSet, + stack: &mut rustc_hash::FxHashSet, ) -> SchemaResult<()> { if stack.contains(name) { return Err(SchemaError::ValidationError(format!( @@ -1451,8 +1450,8 @@ pub struct ActionDefinition { pub path: String, /// Action parameters (merged with form data on submit) - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub params: HashMap, + #[serde(default, skip_serializing_if = "FxHashMap::is_empty")] + pub params: FxHashMap, /// Success message #[serde(skip_serializing_if = "Option::is_none")] @@ -1509,7 +1508,7 @@ impl Default for ActionDefinition { Self { method: default_http_method(), path: String::new(), - params: HashMap::new(), + params: FxHashMap::default(), success_message: None, error_message: None, navigate_to: None, @@ -1543,8 +1542,8 @@ pub enum DataSource { path: String, /// Query parameters - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - params: HashMap, + #[serde(default, skip_serializing_if = "FxHashMap::is_empty")] + params: FxHashMap, /// Polling interval in seconds (0 = no polling) #[serde(default)] @@ -1839,7 +1838,7 @@ mod tests { let valid = DataSource::Endpoint { method: HttpMethod::Get, path: "/api/test".to_string(), - params: HashMap::new(), + params: FxHashMap::default(), poll_interval: 0, transform: None, }; @@ -1848,7 +1847,7 @@ mod tests { let invalid = DataSource::Endpoint { method: HttpMethod::Get, path: "api/test".to_string(), - params: HashMap::new(), + params: FxHashMap::default(), poll_interval: 0, transform: None, }; @@ -1898,8 +1897,8 @@ mod tests { page_size: 0, row_actions: vec![], }, - data_sources: HashMap::new(), - actions: HashMap::new(), + data_sources: FxHashMap::default(), + actions: FxHashMap::default(), }; let refs = page.referenced_data_sources(); @@ -1918,8 +1917,8 @@ mod tests { columns: 13, gap: 16, }, - data_sources: HashMap::new(), - actions: HashMap::new(), + data_sources: FxHashMap::default(), + actions: FxHashMap::default(), }; assert!(page.validate().is_err()); @@ -1937,8 +1936,8 @@ mod tests { content: TextContent::Static("Title".to_string()), id: None, }, - data_sources: HashMap::new(), - actions: HashMap::new(), + data_sources: FxHashMap::default(), + actions: FxHashMap::default(), }; assert!(page.validate().is_err()); @@ -2005,7 +2004,7 @@ mod tests { let bad = DataSource::Endpoint { method: HttpMethod::Get, path: "/not-api/something".to_string(), - params: HashMap::new(), + params: FxHashMap::default(), poll_interval: 0, transform: None, }; @@ -2017,7 +2016,7 @@ mod tests { let bad = DataSource::Endpoint { method: HttpMethod::Get, path: "/api/v1/../admin".to_string(), - params: HashMap::new(), + params: FxHashMap::default(), poll_interval: 0, transform: None, }; @@ -2078,7 +2077,7 @@ mod tests { #[test] fn test_link_validation_rejects_unsafe_href() { - use std::collections::HashMap as HM; + use rustc_hash::FxHashMap as HM; let page = UiPage { id: "p".to_string(), title: "P".to_string(), @@ -2089,15 +2088,15 @@ mod tests { href: "javascript:alert(1)".to_string(), external: false, }, - data_sources: HM::new(), - actions: HM::new(), + data_sources: HM::default(), + actions: HM::default(), }; assert!(page.validate().is_err()); } #[test] fn test_reserved_route_rejected() { - use std::collections::HashMap as HM; + use rustc_hash::FxHashMap as HM; let page = UiPage { id: "search-page".to_string(), title: "Search".to_string(), @@ -2108,8 +2107,8 @@ mod tests { gap: 0, padding: None, }, - data_sources: HM::new(), - actions: HM::new(), + data_sources: HM::default(), + actions: HM::default(), }; let err = page.validate().unwrap_err(); assert!( diff --git a/crates/pinakes-plugin-api/src/validation.rs b/crates/pinakes-plugin-api/src/validation.rs index 7b717fc..83053f4 100644 --- a/crates/pinakes-plugin-api/src/validation.rs +++ b/crates/pinakes-plugin-api/src/validation.rs @@ -343,7 +343,7 @@ impl SchemaValidator { #[cfg(test)] mod tests { - use std::collections::HashMap; + use rustc_hash::FxHashMap; use super::*; use crate::UiElement; @@ -359,8 +359,8 @@ mod tests { gap: 0, padding: None, }, - data_sources: HashMap::new(), - actions: HashMap::new(), + data_sources: FxHashMap::default(), + actions: FxHashMap::default(), } } diff --git a/crates/pinakes-plugin-api/src/wasm.rs b/crates/pinakes-plugin-api/src/wasm.rs index 166785f..07f62c8 100644 --- a/crates/pinakes-plugin-api/src/wasm.rs +++ b/crates/pinakes-plugin-api/src/wasm.rs @@ -1,7 +1,6 @@ //! WASM bridge types and helpers for plugin communication -use std::collections::HashMap; - +use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; /// Memory allocation info for passing data between host and plugin @@ -93,7 +92,7 @@ pub struct LogMessage { pub level: LogLevel, pub target: String, pub message: String, - pub fields: HashMap, + pub fields: FxHashMap, } /// HTTP request parameters @@ -101,7 +100,7 @@ pub struct LogMessage { pub struct HttpRequest { pub method: String, pub url: String, - pub headers: HashMap, + pub headers: FxHashMap, pub body: Option>, } @@ -109,7 +108,7 @@ pub struct HttpRequest { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HttpResponse { pub status: u16, - pub headers: HashMap, + pub headers: FxHashMap, pub body: Vec, } diff --git a/crates/pinakes-plugin-api/tests/api.rs b/crates/pinakes-plugin-api/tests/api.rs index a082f5c..78297cb 100644 --- a/crates/pinakes-plugin-api/tests/api.rs +++ b/crates/pinakes-plugin-api/tests/api.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, path::PathBuf}; +use std::path::PathBuf; use async_trait::async_trait; use pinakes_plugin_api::{ @@ -25,6 +25,7 @@ use pinakes_plugin_api::{ ThumbnailOptions, wasm::{HttpRequest, HttpResponse, LogLevel, LogMessage}, }; +use rustc_hash::FxHashMap; struct TestPlugin { initialized: bool, @@ -41,7 +42,7 @@ impl TestPlugin { health_status: HealthStatus { healthy: true, message: Some("OK".to_string()), - metrics: HashMap::new(), + metrics: FxHashMap::default(), }, metadata: PluginMetadata { id: "test-plugin".to_string(), @@ -82,7 +83,7 @@ async fn test_plugin_context_creation() { let context = PluginContext { data_dir: PathBuf::from("/data/test-plugin"), cache_dir: PathBuf::from("/cache/test-plugin"), - config: HashMap::from([ + config: FxHashMap::from([ ("enabled".to_string(), serde_json::json!(true)), ("max_items".to_string(), serde_json::json!(100)), ]), @@ -119,7 +120,7 @@ async fn test_plugin_context_fields() { let context = PluginContext { data_dir: PathBuf::from("/custom/data"), cache_dir: PathBuf::from("/custom/cache"), - config: HashMap::new(), + config: FxHashMap::default(), capabilities: Capabilities::default(), }; @@ -137,7 +138,7 @@ async fn test_plugin_lifecycle() { let context = PluginContext { data_dir: PathBuf::from("/data"), cache_dir: PathBuf::from("/cache"), - config: HashMap::new(), + config: FxHashMap::default(), capabilities: Capabilities::default(), }; plugin.initialize(context).await.unwrap(); @@ -164,7 +165,7 @@ async fn test_extracted_metadata_structure() { file_size_bytes: Some(1_500_000), codec: Some("h264".to_string()), bitrate_kbps: Some(5000), - custom_fields: HashMap::from([ + custom_fields: FxHashMap::from([ ("color_space".to_string(), serde_json::json!("sRGB")), ("orientation".to_string(), serde_json::json!(90)), ]), @@ -182,7 +183,7 @@ async fn test_extracted_metadata_structure() { async fn test_search_query_serialization() { let query = SearchQuery { query_text: "nature landscape".to_string(), - filters: HashMap::from([ + filters: FxHashMap::from([ ("type".to_string(), serde_json::json!("image")), ("year".to_string(), serde_json::json!(2023)), ]), @@ -329,7 +330,7 @@ async fn test_event_serialization() { let event = Event { event_type: EventType::MediaImported, timestamp: "2024-01-15T10:00:00Z".to_string(), - data: HashMap::from([ + data: FxHashMap::from([ ("path".to_string(), serde_json::json!("/media/test.jpg")), ("size".to_string(), serde_json::json!(1024)), ]), @@ -347,7 +348,7 @@ async fn test_http_request_serialization() { let request = HttpRequest { method: "GET".to_string(), url: "https://api.example.com/data".to_string(), - headers: HashMap::from([ + headers: FxHashMap::from([ ("Authorization".to_string(), "Bearer token".to_string()), ("Content-Type".to_string(), "application/json".to_string()), ]), @@ -366,7 +367,7 @@ async fn test_http_request_serialization() { async fn test_http_response_serialization() { let response = HttpResponse { status: 200, - headers: HashMap::from([( + headers: FxHashMap::from([( "Content-Type".to_string(), "application/json".to_string(), )]), @@ -386,7 +387,7 @@ async fn test_log_message_serialization() { level: LogLevel::Info, target: "plugin::metadata".to_string(), message: "Metadata extraction complete".to_string(), - fields: HashMap::from([ + fields: FxHashMap::from([ ("file_count".to_string(), "42".to_string()), ("duration_ms".to_string(), "150".to_string()), ]), @@ -453,7 +454,7 @@ async fn test_search_index_item_serialization() { "photos".to_string(), ], media_type: "image/jpeg".to_string(), - metadata: HashMap::from([ + metadata: FxHashMap::from([ ("camera".to_string(), serde_json::json!("Canon EOS R5")), ("location".to_string(), serde_json::json!("Beach")), ]), @@ -474,7 +475,7 @@ async fn test_health_status_variants() { let healthy = HealthStatus { healthy: true, message: Some("All systems operational".to_string()), - metrics: HashMap::from([ + metrics: FxHashMap::from([ ("items_processed".to_string(), 1000.0), ("avg_process_time_ms".to_string(), 45.5), ]), @@ -484,7 +485,7 @@ async fn test_health_status_variants() { let unhealthy = HealthStatus { healthy: false, message: Some("Database connection failed".to_string()), - metrics: HashMap::new(), + metrics: FxHashMap::default(), }; assert!(!unhealthy.healthy); assert_eq!( @@ -571,7 +572,7 @@ async fn test_extracted_metadata_default() { async fn test_search_query_structure() { let query = SearchQuery { query_text: "test query".to_string(), - filters: HashMap::new(), + filters: FxHashMap::default(), limit: 10, offset: 0, }; diff --git a/crates/pinakes-plugin-api/tests/integration.rs b/crates/pinakes-plugin-api/tests/integration.rs index a6d92fe..51bad5a 100644 --- a/crates/pinakes-plugin-api/tests/integration.rs +++ b/crates/pinakes-plugin-api/tests/integration.rs @@ -3,8 +3,6 @@ //! Renderer-level behaviour (e.g., Dioxus components) is out of scope here; //! that requires a Dioxus runtime and belongs in pinakes-ui tests. -use std::collections::HashMap; - use pinakes_plugin_api::{ DataSource, HttpMethod, @@ -26,8 +24,8 @@ fn make_page(id: &str, route: &str) -> UiPage { gap: 0, padding: None, }, - data_sources: HashMap::new(), - actions: HashMap::new(), + data_sources: Default::default(), + actions: Default::default(), } } diff --git a/crates/pinakes-server/Cargo.toml b/crates/pinakes-server/Cargo.toml index e853715..d6f42a2 100644 --- a/crates/pinakes-server/Cargo.toml +++ b/crates/pinakes-server/Cargo.toml @@ -31,6 +31,7 @@ blake3 = { workspace = true } rand = { workspace = true } percent-encoding = { workspace = true } http = { workspace = true } +rustc-hash = { workspace = true } [lints] workspace = true diff --git a/crates/pinakes-server/src/dto/media.rs b/crates/pinakes-server/src/dto/media.rs index 4c16a17..ffed427 100644 --- a/crates/pinakes-server/src/dto/media.rs +++ b/crates/pinakes-server/src/dto/media.rs @@ -1,9 +1,7 @@ -use std::{ - collections::HashMap, - path::{Path, PathBuf}, -}; +use std::path::{Path, PathBuf}; use chrono::{DateTime, Utc}; +use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -52,7 +50,7 @@ pub struct MediaResponse { pub duration_secs: Option, pub description: Option, pub has_thumbnail: bool, - pub custom_fields: HashMap, + pub custom_fields: FxHashMap, // Photo-specific metadata pub date_taken: Option>, diff --git a/crates/pinakes-server/src/routes/books.rs b/crates/pinakes-server/src/routes/books.rs index b8a1758..9c83b64 100644 --- a/crates/pinakes-server/src/routes/books.rs +++ b/crates/pinakes-server/src/routes/books.rs @@ -17,6 +17,7 @@ use pinakes_core::{ ReadingStatus, }, }; +use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -41,7 +42,7 @@ pub struct BookMetadataResponse { pub series_index: Option, pub format: Option, pub authors: Vec, - pub identifiers: std::collections::HashMap>, + pub identifiers: FxHashMap>, } impl From for BookMetadataResponse { diff --git a/crates/pinakes-server/src/routes/media.rs b/crates/pinakes-server/src/routes/media.rs index fd26f23..057aa31 100644 --- a/crates/pinakes-server/src/routes/media.rs +++ b/crates/pinakes-server/src/routes/media.rs @@ -3,6 +3,7 @@ use axum::{ extract::{Path, Query, State}, }; use pinakes_core::{model::MediaId, storage::DynStorageBackend}; +use rustc_hash::FxHashMap; use uuid::Uuid; use crate::{ @@ -1249,7 +1250,7 @@ pub async fn empty_trash( pub async fn permanent_delete_media( State(state): State, Path(id): Path, - Query(params): Query>, + Query(params): Query>, ) -> Result, ApiError> { let media_id = MediaId(id); let permanent = params.get("permanent").is_some_and(|v| v == "true"); diff --git a/crates/pinakes-server/src/routes/photos.rs b/crates/pinakes-server/src/routes/photos.rs index c36b463..318c9d0 100644 --- a/crates/pinakes-server/src/routes/photos.rs +++ b/crates/pinakes-server/src/routes/photos.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use axum::{ Json, Router, @@ -91,8 +89,10 @@ pub async fn get_timeline( .collect(); // Group by the requested period - let mut groups: HashMap> = - HashMap::new(); + let mut groups: rustc_hash::FxHashMap< + String, + Vec, + > = rustc_hash::FxHashMap::default(); for photo in photos { if let Some(date_taken) = photo.date_taken { diff --git a/crates/pinakes-server/src/routes/plugins.rs b/crates/pinakes-server/src/routes/plugins.rs index 6748282..e3b13a0 100644 --- a/crates/pinakes-server/src/routes/plugins.rs +++ b/crates/pinakes-server/src/routes/plugins.rs @@ -1,10 +1,11 @@ -use std::{collections::HashMap, sync::Arc}; +use std::sync::Arc; use axum::{ Json, extract::{Path, State}, }; use pinakes_core::plugin::PluginManager; +use rustc_hash::FxHashMap; use crate::{ dto::{ @@ -194,7 +195,7 @@ pub async fn emit_plugin_event( /// List merged CSS custom property overrides from all enabled plugins pub async fn list_plugin_ui_theme_extensions( State(state): State, -) -> Result>, ApiError> { +) -> Result>, ApiError> { let plugin_manager = require_plugin_manager(&state)?; Ok(Json(plugin_manager.list_ui_theme_extensions().await)) } diff --git a/crates/pinakes-server/src/routes/saved_searches.rs b/crates/pinakes-server/src/routes/saved_searches.rs index ed103ab..2439240 100644 --- a/crates/pinakes-server/src/routes/saved_searches.rs +++ b/crates/pinakes-server/src/routes/saved_searches.rs @@ -51,14 +51,15 @@ pub async fn create_saved_search( )); } if let Some(ref sort) = req.sort_order - && !VALID_SORT_ORDERS.contains(&sort.as_str()) { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation(format!( - "sort_order must be one of: {}", - VALID_SORT_ORDERS.join(", ") - )), - )); - } + && !VALID_SORT_ORDERS.contains(&sort.as_str()) + { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation(format!( + "sort_order must be one of: {}", + VALID_SORT_ORDERS.join(", ") + )), + )); + } let id = uuid::Uuid::now_v7(); state .storage diff --git a/crates/pinakes-tui/Cargo.toml b/crates/pinakes-tui/Cargo.toml index 5aa9e9e..ed0e788 100644 --- a/crates/pinakes-tui/Cargo.toml +++ b/crates/pinakes-tui/Cargo.toml @@ -18,6 +18,7 @@ tracing-subscriber = { workspace = true } reqwest = { workspace = true } ratatui = { workspace = true } crossterm = { workspace = true } +rustc-hash = { workspace = true } [lints] workspace = true diff --git a/crates/pinakes-tui/src/app.rs b/crates/pinakes-tui/src/app.rs index 1642b6d..7fd166f 100644 --- a/crates/pinakes-tui/src/app.rs +++ b/crates/pinakes-tui/src/app.rs @@ -1,4 +1,4 @@ -use std::{collections::HashSet, time::Duration}; +use std::time::Duration; use anyhow::Result; use crossterm::{ @@ -6,6 +6,7 @@ use crossterm::{ terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{Terminal, backend::CrosstermBackend}; +use rustc_hash::FxHashSet; use crate::{ client::{ @@ -74,7 +75,7 @@ pub struct AppState { pub total_media_count: u64, pub server_url: String, // Multi-select support - pub selected_items: HashSet, + pub selected_items: FxHashSet, pub selection_mode: bool, pub pending_batch_delete: bool, // Duplicates view @@ -178,7 +179,7 @@ impl AppState { total_media_count: 0, server_url: server_url.to_string(), // Multi-select - selected_items: HashSet::new(), + selected_items: FxHashSet::default(), selection_mode: false, pending_batch_delete: false, } diff --git a/crates/pinakes-tui/src/client.rs b/crates/pinakes-tui/src/client.rs index ad13f08..3a1de56 100644 --- a/crates/pinakes-tui/src/client.rs +++ b/crates/pinakes-tui/src/client.rs @@ -1,7 +1,6 @@ -use std::collections::HashMap; - use anyhow::Result; use reqwest::Client; +use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; #[derive(Clone)] @@ -28,7 +27,7 @@ pub struct MediaResponse { pub description: Option, #[serde(default)] pub has_thumbnail: bool, - pub custom_fields: HashMap, + pub custom_fields: FxHashMap, pub created_at: String, pub updated_at: String, } diff --git a/crates/pinakes-ui/Cargo.toml b/crates/pinakes-ui/Cargo.toml index f77b273..6c52e77 100644 --- a/crates/pinakes-ui/Cargo.toml +++ b/crates/pinakes-ui/Cargo.toml @@ -28,6 +28,7 @@ gloo-timers = { workspace = true } rand = { workspace = true } urlencoding = { workspace = true } pinakes-plugin-api = { workspace = true } +rustc-hash = { workspace = true } [lints] workspace = true diff --git a/crates/pinakes-ui/src/client.rs b/crates/pinakes-ui/src/client.rs index e411750..a82283a 100644 --- a/crates/pinakes-ui/src/client.rs +++ b/crates/pinakes-ui/src/client.rs @@ -1,7 +1,6 @@ -use std::collections::HashMap; - use anyhow::Result; use reqwest::{Client, header}; +use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; /// Payload for import events: (path, tag_ids, new_tags, collection_id) @@ -66,7 +65,7 @@ pub struct MediaResponse { pub description: Option, #[serde(default)] pub has_thumbnail: bool, - pub custom_fields: HashMap, + pub custom_fields: FxHashMap, pub created_at: String, pub updated_at: String, #[serde(default)] @@ -395,7 +394,7 @@ pub struct BookMetadataResponse { pub format: Option, pub authors: Vec, #[serde(default)] - pub identifiers: HashMap>, + pub identifiers: FxHashMap>, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] @@ -1680,7 +1679,7 @@ impl ApiClient { /// Returns a map of CSS property names to values. pub async fn get_plugin_ui_theme_extensions( &self, - ) -> Result> { + ) -> Result> { Ok( self .client diff --git a/crates/pinakes-ui/src/components/graph_view.rs b/crates/pinakes-ui/src/components/graph_view.rs index 123f56b..d769cdd 100644 --- a/crates/pinakes-ui/src/components/graph_view.rs +++ b/crates/pinakes-ui/src/components/graph_view.rs @@ -1,9 +1,8 @@ //! Graph visualization component for markdown note connections. //! //! Renders a force-directed graph showing connections between notes. -use std::collections::HashMap; - use dioxus::prelude::*; +use rustc_hash::FxHashMap; use crate::client::{ ApiClient, @@ -298,7 +297,7 @@ fn ForceDirectedGraph( // Create id to position map let nodes_read = physics_nodes.read(); - let id_to_pos: HashMap<&str, (f64, f64)> = nodes_read + let id_to_pos: FxHashMap<&str, (f64, f64)> = nodes_read .iter() .map(|n| (n.id.as_str(), (n.x, n.y))) .collect(); diff --git a/crates/pinakes-ui/src/components/import.rs b/crates/pinakes-ui/src/components/import.rs index 9dbd8ee..123f6bd 100644 --- a/crates/pinakes-ui/src/components/import.rs +++ b/crates/pinakes-ui/src/components/import.rs @@ -1,6 +1,5 @@ -use std::collections::HashSet; - use dioxus::prelude::*; +use rustc_hash::FxHashSet; use super::utils::{format_size, type_badge_class}; use crate::client::{ @@ -50,7 +49,7 @@ pub fn Import( let mut filter_max_size = use_signal(|| 0u64); // 0 means no limit // File selection state - let mut selected_file_paths = use_signal(HashSet::::new); + let mut selected_file_paths = use_signal(FxHashSet::::default); let current_mode = *import_mode.read(); @@ -475,7 +474,7 @@ pub fn Import( button { class: "btn btn-sm btn-ghost", onclick: move |_| { - selected_file_paths.set(HashSet::new()); + selected_file_paths.set(FxHashSet::default()); }, "Deselect All" } @@ -496,12 +495,12 @@ pub fn Import( let filtered_paths = filtered_paths.clone(); move |_| { if all_filtered_selected { - let filtered_set: HashSet = filtered_paths + let filtered_set: FxHashSet = filtered_paths .iter() .cloned() .collect(); let sel = selected_file_paths.read().clone(); - let new_sel: HashSet = sel + let new_sel: FxHashSet = sel .difference(&filtered_set) .cloned() .collect(); @@ -599,7 +598,7 @@ pub fn Import( let new_tags = parse_new_tags(&new_tags_input.read()); let col_id = selected_collection.read().clone(); on_import_batch.call((paths, tag_ids, new_tags, col_id)); - selected_file_paths.set(HashSet::new()); + selected_file_paths.set(FxHashSet::default()); selected_tags.set(Vec::new()); new_tags_input.set(String::new()); selected_collection.set(None); @@ -644,7 +643,7 @@ pub fn Import( selected_tags.set(Vec::new()); new_tags_input.set(String::new()); selected_collection.set(None); - selected_file_paths.set(HashSet::new()); + selected_file_paths.set(FxHashSet::default()); } } }, diff --git a/crates/pinakes-ui/src/components/markdown_viewer.rs b/crates/pinakes-ui/src/components/markdown_viewer.rs index 35f20f2..d883bf4 100644 --- a/crates/pinakes-ui/src/components/markdown_viewer.rs +++ b/crates/pinakes-ui/src/components/markdown_viewer.rs @@ -316,6 +316,10 @@ fn escape_html_attr(text: &str) -> String { /// Sanitize HTML using ammonia with a safe allowlist. /// This prevents XSS attacks by removing dangerous elements and attributes. +#[expect( + clippy::disallowed_types, + reason = "ammonia::Builder requires std HashSet" +)] fn sanitize_html(html: &str) -> String { use std::collections::HashSet; diff --git a/crates/pinakes-ui/src/plugin_ui/actions.rs b/crates/pinakes-ui/src/plugin_ui/actions.rs index 1c6f553..1a2b31d 100644 --- a/crates/pinakes-ui/src/plugin_ui/actions.rs +++ b/crates/pinakes-ui/src/plugin_ui/actions.rs @@ -3,8 +3,6 @@ //! This module provides the action execution system that handles //! user interactions with plugin UI elements. -use std::collections::HashMap; - use pinakes_plugin_api::{ ActionDefinition, ActionRef, @@ -12,6 +10,7 @@ use pinakes_plugin_api::{ SpecialAction, UiElement, }; +use rustc_hash::FxHashMap; use super::data::to_reqwest_method; use crate::client::ApiClient; @@ -48,7 +47,7 @@ pub enum ActionResult { pub async fn execute_action( client: &ApiClient, action_ref: &ActionRef, - page_actions: &HashMap, + page_actions: &FxHashMap, form_data: Option<&serde_json::Value>, ) -> Result { match action_ref { @@ -224,9 +223,10 @@ mod tests { async fn test_named_action_unknown_returns_none() { let client = crate::client::ApiClient::default(); let action_ref = ActionRef::Name("my-action".to_string()); - let result = execute_action(&client, &action_ref, &HashMap::new(), None) - .await - .unwrap(); + let result = + execute_action(&client, &action_ref, &FxHashMap::default(), None) + .await + .unwrap(); assert!(matches!(result, ActionResult::None)); } @@ -235,11 +235,11 @@ mod tests { use pinakes_plugin_api::ActionDefinition; let client = crate::client::ApiClient::default(); - let mut page_actions = HashMap::new(); + let mut page_actions = FxHashMap::default(); page_actions.insert("do-thing".to_string(), ActionDefinition { method: pinakes_plugin_api::HttpMethod::Post, path: "/api/v1/nonexistent-endpoint".to_string(), - params: HashMap::new(), + params: FxHashMap::default(), success_message: None, error_message: None, navigate_to: None, @@ -267,9 +267,10 @@ mod tests { let client = crate::client::ApiClient::default(); let action_ref = ActionRef::Special(SpecialAction::Refresh); - let result = execute_action(&client, &action_ref, &HashMap::new(), None) - .await - .unwrap(); + let result = + execute_action(&client, &action_ref, &FxHashMap::default(), None) + .await + .unwrap(); assert!(matches!(result, ActionResult::Refresh)); } @@ -281,9 +282,10 @@ mod tests { let action_ref = ActionRef::Special(SpecialAction::Navigate { to: "/dashboard".to_string(), }); - let result = execute_action(&client, &action_ref, &HashMap::new(), None) - .await - .unwrap(); + let result = + execute_action(&client, &action_ref, &FxHashMap::default(), None) + .await + .unwrap(); assert!( matches!(result, ActionResult::Navigate(ref p) if p == "/dashboard") ); @@ -299,9 +301,10 @@ mod tests { key: "count".to_string(), value: expr.clone(), }); - let result = execute_action(&client, &action_ref, &HashMap::new(), None) - .await - .unwrap(); + let result = + execute_action(&client, &action_ref, &FxHashMap::default(), None) + .await + .unwrap(); match result { ActionResult::UpdateState { key, value_expr } => { assert_eq!(key, "count"); @@ -317,9 +320,10 @@ mod tests { let client = crate::client::ApiClient::default(); let action_ref = ActionRef::Special(SpecialAction::CloseModal); - let result = execute_action(&client, &action_ref, &HashMap::new(), None) - .await - .unwrap(); + let result = + execute_action(&client, &action_ref, &FxHashMap::default(), None) + .await + .unwrap(); assert!(matches!(result, ActionResult::CloseModal)); } } diff --git a/crates/pinakes-ui/src/plugin_ui/data.rs b/crates/pinakes-ui/src/plugin_ui/data.rs index 2244fe6..c037169 100644 --- a/crates/pinakes-ui/src/plugin_ui/data.rs +++ b/crates/pinakes-ui/src/plugin_ui/data.rs @@ -2,14 +2,12 @@ //! //! Provides data fetching and caching for plugin data sources. -use std::{ - collections::{HashMap, HashSet}, - time::Duration, -}; +use std::time::Duration; use dioxus::prelude::*; use dioxus_core::Task; use pinakes_plugin_api::{DataSource, Expression, HttpMethod}; +use rustc_hash::{FxHashMap, FxHashSet}; use super::expr::{evaluate_expression, value_to_display_string}; use crate::client::ApiClient; @@ -17,9 +15,9 @@ use crate::client::ApiClient; /// Cached data for a plugin page #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct PluginPageData { - data: HashMap, - loading: HashSet, - errors: HashMap, + data: FxHashMap, + loading: FxHashSet, + errors: FxHashMap, } impl PluginPageData { @@ -105,7 +103,7 @@ async fn fetch_endpoint( client: &ApiClient, path: &str, method: HttpMethod, - params: &HashMap, + params: &FxHashMap, ctx: &serde_json::Value, allowed_endpoints: &[String], ) -> Result { @@ -174,9 +172,9 @@ async fn fetch_endpoint( /// Returns an error if any data source fails to fetch pub async fn fetch_page_data( client: &ApiClient, - data_sources: &HashMap, + data_sources: &FxHashMap, allowed_endpoints: &[String], -) -> Result, String> { +) -> Result, String> { // Group non-Transform sources into dedup groups. // // For Endpoint sources, two entries are in the same group when they share @@ -300,7 +298,7 @@ pub async fn fetch_page_data( }) .collect(); - let mut results: HashMap = HashMap::new(); + let mut results: FxHashMap = FxHashMap::default(); for group_result in futures::future::join_all(futs).await { for (name, value) in group_result? { results.insert(name, value); @@ -375,7 +373,7 @@ pub async fn fetch_page_data( /// immediate re-fetch outside of the polling interval. pub fn use_plugin_data( client: Signal, - data_sources: HashMap, + data_sources: FxHashMap, refresh: Signal, allowed_endpoints: Vec, ) -> Signal { @@ -564,7 +562,7 @@ mod tests { use crate::client::ApiClient; let client = ApiClient::default(); - let mut sources = HashMap::new(); + let mut sources = FxHashMap::default(); sources.insert("nums".to_string(), DataSource::Static { value: serde_json::json!([1, 2, 3]), }); @@ -586,7 +584,7 @@ mod tests { use crate::client::ApiClient; let client = ApiClient::default(); - let mut sources = HashMap::new(); + let mut sources = FxHashMap::default(); // The Transform expression accesses "raw" from the context sources.insert("derived".to_string(), DataSource::Transform { source_name: "raw".to_string(), @@ -611,7 +609,7 @@ mod tests { use crate::client::ApiClient; let client = ApiClient::default(); - let mut sources = HashMap::new(); + let mut sources = FxHashMap::default(); sources.insert("raw".to_string(), DataSource::Static { value: serde_json::json!(42), }); @@ -634,7 +632,7 @@ mod tests { use crate::client::ApiClient; let client = ApiClient::default(); - let mut sources = HashMap::new(); + let mut sources = FxHashMap::default(); // Two Static sources with the same payload; dedup is for Endpoint sources, // but both names must appear in the output regardless. sources.insert("a".to_string(), DataSource::Static { @@ -662,7 +660,7 @@ mod tests { use crate::client::ApiClient; let client = ApiClient::default(); - let mut sources = HashMap::new(); + let mut sources = FxHashMap::default(); // Two endpoints with identical (path, method, params=empty) but different // transforms. Both should produce the same error when the path is blocked. sources.insert("x".to_string(), DataSource::Endpoint { @@ -707,7 +705,7 @@ mod tests { use crate::client::ApiClient; let client = ApiClient::default(); - let mut sources = HashMap::new(); + let mut sources = FxHashMap::default(); sources.insert("raw_data".to_string(), DataSource::Static { value: serde_json::json!({"count": 42, "name": "test"}), }); @@ -741,7 +739,7 @@ mod tests { use crate::client::ApiClient; let client = ApiClient::default(); - let mut sources = HashMap::new(); + let mut sources = FxHashMap::default(); sources.insert("items".to_string(), DataSource::Endpoint { path: "/api/v1/media".to_string(), method: HttpMethod::Get, diff --git a/crates/pinakes-ui/src/plugin_ui/registry.rs b/crates/pinakes-ui/src/plugin_ui/registry.rs index 8fde3d0..1a0f1fa 100644 --- a/crates/pinakes-ui/src/plugin_ui/registry.rs +++ b/crates/pinakes-ui/src/plugin_ui/registry.rs @@ -16,10 +16,9 @@ //! } //! ``` -use std::collections::HashMap; - use dioxus::prelude::*; use pinakes_plugin_api::{UiPage, UiWidget}; +use rustc_hash::FxHashMap; use crate::client::ApiClient; @@ -43,11 +42,11 @@ pub struct PluginRegistry { /// API client for fetching pages from server client: ApiClient, /// Cached pages: (`plugin_id`, `page_id`) -> `PluginPage` - pages: HashMap<(String, String), PluginPage>, + pages: FxHashMap<(String, String), PluginPage>, /// Cached widgets: (`plugin_id`, `widget_id`) -> `UiWidget` widgets: Vec<(String, UiWidget)>, /// Merged CSS custom property overrides from all enabled plugins - theme_vars: HashMap, + theme_vars: FxHashMap, } impl PluginRegistry { @@ -55,14 +54,14 @@ impl PluginRegistry { pub fn new(client: ApiClient) -> Self { Self { client, - pages: HashMap::new(), + pages: FxHashMap::default(), widgets: Vec::new(), - theme_vars: HashMap::new(), + theme_vars: FxHashMap::default(), } } /// Get merged CSS custom property overrides from all loaded plugins. - pub fn theme_vars(&self) -> &HashMap { + pub fn theme_vars(&self) -> &FxHashMap { &self.theme_vars } @@ -230,8 +229,8 @@ mod tests { gap: 16, padding: None, }, - data_sources: HashMap::new(), - actions: HashMap::new(), + data_sources: FxHashMap::default(), + actions: FxHashMap::default(), } } @@ -491,8 +490,8 @@ mod tests { gap: 16, padding: None, }, - data_sources: HashMap::new(), - actions: HashMap::new(), + data_sources: FxHashMap::default(), + actions: FxHashMap::default(), }; registry.register_page("test-plugin".to_string(), invalid_page, vec![]); @@ -517,8 +516,8 @@ mod tests { gap: 0, padding: None, }, - data_sources: HashMap::new(), - actions: HashMap::new(), + data_sources: FxHashMap::default(), + actions: FxHashMap::default(), }; registry.register_page("p".to_string(), invalid_page, vec![]); assert_eq!(registry.all_pages().len(), 0); diff --git a/crates/pinakes-ui/src/plugin_ui/renderer.rs b/crates/pinakes-ui/src/plugin_ui/renderer.rs index fa62f65..9951ec4 100644 --- a/crates/pinakes-ui/src/plugin_ui/renderer.rs +++ b/crates/pinakes-ui/src/plugin_ui/renderer.rs @@ -4,8 +4,6 @@ //! elements. Data-driven elements resolve their data from a [`PluginPageData`] //! context that is populated by the `use_plugin_data` hook. -use std::collections::HashMap; - use dioxus::prelude::*; use pinakes_plugin_api::{ ActionDefinition, @@ -23,6 +21,7 @@ use pinakes_plugin_api::{ UiElement, UiPage, }; +use rustc_hash::{FxHashMap, FxHashSet}; use super::{ actions::execute_action, @@ -49,13 +48,13 @@ pub struct RenderContext { pub navigate: Signal>, pub refresh: Signal, pub modal: Signal>, - pub local_state: Signal>, + pub local_state: Signal>, } /// Build the expression evaluation context from page data and local state. fn build_ctx( data: &PluginPageData, - local_state: &HashMap, + local_state: &FxHashMap, ) -> serde_json::Value { let mut base = data.as_json(); if let serde_json::Value::Object(ref mut obj) = base { @@ -101,7 +100,7 @@ pub fn PluginViewRenderer(props: PluginViewProps) -> Element { let mut navigate = use_signal(|| None::); let refresh = use_signal(|| 0u32); let mut modal = use_signal(|| None::); - let local_state = use_signal(HashMap::::new); + let local_state = use_signal(FxHashMap::::default); let ctx = RenderContext { client: props.client, feedback, @@ -169,7 +168,7 @@ struct PluginTabsProps { tabs: Vec, default_tab: usize, data: PluginPageData, - actions: HashMap, + actions: FxHashMap, ctx: RenderContext, } @@ -232,7 +231,7 @@ struct PluginDataTableProps { page_size: usize, row_actions: Vec, data: PluginPageData, - actions: HashMap, + actions: FxHashMap, ctx: RenderContext, } @@ -472,7 +471,7 @@ fn PluginDataTable(props: PluginDataTableProps) -> Element { pub fn render_element( element: &UiElement, data: &PluginPageData, - actions: &HashMap, + actions: &FxHashMap, ctx: RenderContext, ) -> Element { match element { @@ -1188,7 +1187,7 @@ fn render_chart_data( Some(serde_json::Value::Array(arr)) if !arr.is_empty() => { if arr.first().map(|v| v.is_object()).unwrap_or(false) { // Object rows: collect unique keys preserving insertion order - let mut seen = std::collections::HashSet::new(); + let mut seen = FxHashSet::default(); let cols: Vec = arr .iter() .filter_map(|r| r.as_object()) diff --git a/crates/pinakes-ui/src/plugin_ui/widget.rs b/crates/pinakes-ui/src/plugin_ui/widget.rs index 362367c..d0c3431 100644 --- a/crates/pinakes-ui/src/plugin_ui/widget.rs +++ b/crates/pinakes-ui/src/plugin_ui/widget.rs @@ -4,10 +4,9 @@ //! predefined locations. Unlike full pages, widgets have no data sources of //! their own and render with empty data context. -use std::collections::HashMap; - use dioxus::prelude::*; use pinakes_plugin_api::{ActionDefinition, UiWidget, widget_location}; +use rustc_hash::FxHashMap; use super::{ data::PluginPageData, @@ -120,7 +119,7 @@ pub fn WidgetViewRenderer(props: WidgetViewRendererProps) -> Element { let navigate = use_signal(|| None::); let refresh = use_signal(|| 0u32); let modal = use_signal(|| None::); - let local_state = use_signal(HashMap::::new); + let local_state = use_signal(FxHashMap::::default); let ctx = RenderContext { client: props.client, feedback, @@ -129,7 +128,7 @@ pub fn WidgetViewRenderer(props: WidgetViewRendererProps) -> Element { modal, local_state, }; - let empty_actions: HashMap = HashMap::new(); + let empty_actions: FxHashMap = FxHashMap::default(); rsx! { div { class: "plugin-widget", @@ -142,6 +141,8 @@ pub fn WidgetViewRenderer(props: WidgetViewRendererProps) -> Element { #[cfg(test)] mod tests { + use rustc_hash::FxHashSet; + use super::*; #[test] @@ -159,7 +160,7 @@ mod tests { WidgetLocation::SettingsSection, ]; let strings: Vec<&str> = locations.iter().map(|l| l.as_str()).collect(); - let unique: std::collections::HashSet<_> = strings.iter().collect(); + let unique: FxHashSet<_> = strings.iter().collect(); assert_eq!( strings.len(), unique.len(), -- 2.43.0 From 8023dc606b02df8d2b60817719fb57bfa98b2baf Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 19 Mar 2026 22:37:51 +0300 Subject: [PATCH 04/30] migrations/postgres: add missing sequence counter for sqlite parity Signed-off-by: NotAShelf Change-Id: Iaf993250bff02b3d02aece62876b5ee56a6a6964 --- migrations/postgres/V16__sync_system.sql | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/migrations/postgres/V16__sync_system.sql b/migrations/postgres/V16__sync_system.sql index 5a54b7a..8a87823 100644 --- a/migrations/postgres/V16__sync_system.sql +++ b/migrations/postgres/V16__sync_system.sql @@ -39,6 +39,13 @@ CREATE INDEX idx_sync_log_sequence ON sync_log(sequence); CREATE INDEX idx_sync_log_path ON sync_log(path); CREATE INDEX idx_sync_log_timestamp ON sync_log(timestamp); +-- Sequence counter for sync log +CREATE TABLE sync_sequence ( + id INTEGER PRIMARY KEY CHECK (id = 1), + current_value BIGINT NOT NULL DEFAULT 0 +); +INSERT INTO sync_sequence (id, current_value) VALUES (1, 0); + -- Device sync state - tracks sync status per device per file CREATE TABLE device_sync_state ( device_id TEXT NOT NULL REFERENCES sync_devices(id) ON DELETE CASCADE, -- 2.43.0 From e15dad208e185726255d71d66dd77548f2b7b728 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 19 Mar 2026 23:57:17 +0300 Subject: [PATCH 05/30] pinakes-core: clarify backup support for postgresql Signed-off-by: NotAShelf Change-Id: I7f7d5dcb1d973c8615aacbfc0a5a44576a6a6964 --- crates/pinakes-core/src/storage/mod.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/pinakes-core/src/storage/mod.rs b/crates/pinakes-core/src/storage/mod.rs index 606dc61..5e5cdcd 100644 --- a/crates/pinakes-core/src/storage/mod.rs +++ b/crates/pinakes-core/src/storage/mod.rs @@ -1171,11 +1171,15 @@ pub trait StorageBackend: Send + Sync + 'static { async fn count_unresolved_links(&self) -> Result; /// Create a backup of the database to the specified path. - /// Default implementation returns unsupported; `SQLite` overrides with - /// VACUUM INTO. + /// + /// Only supported for SQLite (uses VACUUM INTO). PostgreSQL + /// deployments should use `pg_dump` directly; this method returns + /// `PinakesError::InvalidOperation` for unsupported backends. async fn backup(&self, _dest: &std::path::Path) -> Result<()> { Err(crate::error::PinakesError::InvalidOperation( - "backup not supported for this storage backend".to_string(), + "backup not supported for this storage backend; use pg_dump for \ + PostgreSQL" + .to_string(), )) } } -- 2.43.0 From 5b817e0b3e7a7c5fc5976fc21055b2dab60ca59c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 19 Mar 2026 23:57:48 +0300 Subject: [PATCH 06/30] pinakes-core: fix hasher usage in tests Signed-off-by: NotAShelf Change-Id: Ied03277d450e39299470667ef479c3526a6a6964 --- crates/pinakes-core/src/config.rs | 24 +++++++++++++++++------ crates/pinakes-core/src/plugin/runtime.rs | 3 ++- crates/pinakes-core/src/storage/mod.rs | 2 +- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/crates/pinakes-core/src/config.rs b/crates/pinakes-core/src/config.rs index 0725308..ecc8ae8 100644 --- a/crates/pinakes-core/src/config.rs +++ b/crates/pinakes-core/src/config.rs @@ -1567,21 +1567,27 @@ mod tests { #[test] fn test_expand_env_var_simple() { - let vars = FxHashMap::from([("TEST_VAR_SIMPLE", "test_value")]); + let vars = [("TEST_VAR_SIMPLE", "test_value")] + .into_iter() + .collect::>(); let result = expand_env_vars("$TEST_VAR_SIMPLE", test_lookup(&vars)); assert_eq!(result.unwrap(), "test_value"); } #[test] fn test_expand_env_var_braces() { - let vars = FxHashMap::from([("TEST_VAR_BRACES", "test_value")]); + let vars = [("TEST_VAR_BRACES", "test_value")] + .into_iter() + .collect::>(); let result = expand_env_vars("${TEST_VAR_BRACES}", test_lookup(&vars)); assert_eq!(result.unwrap(), "test_value"); } #[test] fn test_expand_env_var_embedded() { - let vars = FxHashMap::from([("TEST_VAR_EMBEDDED", "value")]); + let vars = [("TEST_VAR_EMBEDDED", "value")] + .into_iter() + .collect::>(); let result = expand_env_vars("prefix_${TEST_VAR_EMBEDDED}_suffix", test_lookup(&vars)); assert_eq!(result.unwrap(), "prefix_value_suffix"); @@ -1589,7 +1595,9 @@ mod tests { #[test] fn test_expand_env_var_multiple() { - let vars = FxHashMap::from([("VAR1", "value1"), ("VAR2", "value2")]); + let vars = [("VAR1", "value1"), ("VAR2", "value2")] + .into_iter() + .collect::>(); let result = expand_env_vars("${VAR1}_${VAR2}", test_lookup(&vars)); assert_eq!(result.unwrap(), "value1_value2"); } @@ -1636,14 +1644,18 @@ mod tests { #[test] fn test_expand_env_var_underscore() { - let vars = FxHashMap::from([("TEST_VAR_NAME", "value")]); + let vars = [("TEST_VAR_NAME", "value")] + .into_iter() + .collect::>(); let result = expand_env_vars("$TEST_VAR_NAME", test_lookup(&vars)); assert_eq!(result.unwrap(), "value"); } #[test] fn test_expand_env_var_mixed_syntax() { - let vars = FxHashMap::from([("VAR1_MIXED", "v1"), ("VAR2_MIXED", "v2")]); + let vars = [("VAR1_MIXED", "v1"), ("VAR2_MIXED", "v2")] + .into_iter() + .collect::>(); let result = expand_env_vars("$VAR1_MIXED and ${VAR2_MIXED}", test_lookup(&vars)); assert_eq!(result.unwrap(), "v1 and v2"); diff --git a/crates/pinakes-core/src/plugin/runtime.rs b/crates/pinakes-core/src/plugin/runtime.rs index 6a363e7..b550b05 100644 --- a/crates/pinakes-core/src/plugin/runtime.rs +++ b/crates/pinakes-core/src/plugin/runtime.rs @@ -775,6 +775,7 @@ impl HostFunctions { #[cfg(test)] mod tests { use pinakes_plugin_api::PluginContext; + use rustc_hash::FxHashMap; use super::*; @@ -836,7 +837,7 @@ mod tests { let mut context = PluginContext { data_dir: "/tmp/data".into(), cache_dir: "/tmp/cache".into(), - config: HashMap::new(), + config: FxHashMap::default(), capabilities: Default::default(), }; diff --git a/crates/pinakes-core/src/storage/mod.rs b/crates/pinakes-core/src/storage/mod.rs index 5e5cdcd..e1d93bc 100644 --- a/crates/pinakes-core/src/storage/mod.rs +++ b/crates/pinakes-core/src/storage/mod.rs @@ -1172,7 +1172,7 @@ pub trait StorageBackend: Send + Sync + 'static { /// Create a backup of the database to the specified path. /// - /// Only supported for SQLite (uses VACUUM INTO). PostgreSQL + /// Only supported for `SQLite` (uses VACUUM INTO). `PostgreSQL` /// deployments should use `pg_dump` directly; this method returns /// `PinakesError::InvalidOperation` for unsupported backends. async fn backup(&self, _dest: &std::path::Path) -> Result<()> { -- 2.43.0 From 6b8444f19c2bdfaaa12c0a093af5b345100cf4ed Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 20 Mar 2026 00:27:46 +0300 Subject: [PATCH 07/30] pinakes-plugin-api: fix hasher usage in tests Signed-off-by: NotAShelf Change-Id: I8ee475aef2d1f81cf6af6f5e247f5e386a6a6964 --- crates/pinakes-plugin-api/tests/api.rs | 55 ++++++++++++++++---------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/crates/pinakes-plugin-api/tests/api.rs b/crates/pinakes-plugin-api/tests/api.rs index 78297cb..0bf44b3 100644 --- a/crates/pinakes-plugin-api/tests/api.rs +++ b/crates/pinakes-plugin-api/tests/api.rs @@ -83,10 +83,12 @@ async fn test_plugin_context_creation() { let context = PluginContext { data_dir: PathBuf::from("/data/test-plugin"), cache_dir: PathBuf::from("/cache/test-plugin"), - config: FxHashMap::from([ + config: [ ("enabled".to_string(), serde_json::json!(true)), ("max_items".to_string(), serde_json::json!(100)), - ]), + ] + .into_iter() + .collect(), capabilities: Capabilities { filesystem: FilesystemCapability { read: vec![PathBuf::from("/data")], @@ -165,10 +167,12 @@ async fn test_extracted_metadata_structure() { file_size_bytes: Some(1_500_000), codec: Some("h264".to_string()), bitrate_kbps: Some(5000), - custom_fields: FxHashMap::from([ + custom_fields: [ ("color_space".to_string(), serde_json::json!("sRGB")), ("orientation".to_string(), serde_json::json!(90)), - ]), + ] + .into_iter() + .collect(), tags: vec!["test".to_string(), "document".to_string()], }; @@ -183,10 +187,12 @@ async fn test_extracted_metadata_structure() { async fn test_search_query_serialization() { let query = SearchQuery { query_text: "nature landscape".to_string(), - filters: FxHashMap::from([ + filters: [ ("type".to_string(), serde_json::json!("image")), ("year".to_string(), serde_json::json!(2023)), - ]), + ] + .into_iter() + .collect(), limit: 50, offset: 0, }; @@ -330,10 +336,12 @@ async fn test_event_serialization() { let event = Event { event_type: EventType::MediaImported, timestamp: "2024-01-15T10:00:00Z".to_string(), - data: FxHashMap::from([ + data: [ ("path".to_string(), serde_json::json!("/media/test.jpg")), ("size".to_string(), serde_json::json!(1024)), - ]), + ] + .into_iter() + .collect(), }; let serialized = serde_json::to_string(&event).unwrap(); @@ -348,10 +356,12 @@ async fn test_http_request_serialization() { let request = HttpRequest { method: "GET".to_string(), url: "https://api.example.com/data".to_string(), - headers: FxHashMap::from([ + headers: [ ("Authorization".to_string(), "Bearer token".to_string()), ("Content-Type".to_string(), "application/json".to_string()), - ]), + ] + .into_iter() + .collect(), body: None, }; @@ -367,10 +377,9 @@ async fn test_http_request_serialization() { async fn test_http_response_serialization() { let response = HttpResponse { status: 200, - headers: FxHashMap::from([( - "Content-Type".to_string(), - "application/json".to_string(), - )]), + headers: [("Content-Type".to_string(), "application/json".to_string())] + .into_iter() + .collect(), body: b"{\"success\": true}".to_vec(), }; @@ -387,10 +396,12 @@ async fn test_log_message_serialization() { level: LogLevel::Info, target: "plugin::metadata".to_string(), message: "Metadata extraction complete".to_string(), - fields: FxHashMap::from([ + fields: [ ("file_count".to_string(), "42".to_string()), ("duration_ms".to_string(), "150".to_string()), - ]), + ] + .into_iter() + .collect(), }; let serialized = serde_json::to_string(&message).unwrap(); @@ -454,10 +465,12 @@ async fn test_search_index_item_serialization() { "photos".to_string(), ], media_type: "image/jpeg".to_string(), - metadata: FxHashMap::from([ + metadata: [ ("camera".to_string(), serde_json::json!("Canon EOS R5")), ("location".to_string(), serde_json::json!("Beach")), - ]), + ] + .into_iter() + .collect(), }; let serialized = serde_json::to_string(&item).unwrap(); @@ -475,10 +488,12 @@ async fn test_health_status_variants() { let healthy = HealthStatus { healthy: true, message: Some("All systems operational".to_string()), - metrics: FxHashMap::from([ + metrics: [ ("items_processed".to_string(), 1000.0), ("avg_process_time_ms".to_string(), 45.5), - ]), + ] + .into_iter() + .collect(), }; assert!(healthy.healthy); -- 2.43.0 From 2f43279dd70526e5175ba0d4f5be1923d4bb0dd1 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 20 Mar 2026 00:31:36 +0300 Subject: [PATCH 08/30] pinakes-server: consolidate helpers for the tests Signed-off-by: NotAShelf Change-Id: Ifbc07ced09014391bc264a36be27dc8c6a6a6964 --- crates/pinakes-server/tests/api.rs | 306 +------------------- crates/pinakes-server/tests/common/mod.rs | 323 ++++++++++++++++++++++ crates/pinakes-server/tests/plugin.rs | 127 ++------- 3 files changed, 343 insertions(+), 413 deletions(-) create mode 100644 crates/pinakes-server/tests/common/mod.rs diff --git a/crates/pinakes-server/tests/api.rs b/crates/pinakes-server/tests/api.rs index 7a66859..e519f21 100644 --- a/crates/pinakes-server/tests/api.rs +++ b/crates/pinakes-server/tests/api.rs @@ -1,314 +1,12 @@ -use std::{net::SocketAddr, sync::Arc}; - +mod common; use axum::{ body::Body, - extract::ConnectInfo, http::{Request, StatusCode}, }; +use common::*; use http_body_util::BodyExt; -use pinakes_core::{ - cache::CacheLayer, - config::{ - AccountsConfig, - AnalyticsConfig, - CloudConfig, - Config, - DirectoryConfig, - EnrichmentConfig, - JobsConfig, - ManagedStorageConfig, - PhotoConfig, - PluginsConfig, - RateLimitConfig, - ScanningConfig, - ServerConfig, - SharingConfig, - SqliteConfig, - StorageBackendType, - StorageConfig, - SyncConfig, - ThumbnailConfig, - TlsConfig, - TranscodingConfig, - TrashConfig, - UiConfig, - UserAccount, - UserRole, - WebhookConfig, - }, - jobs::JobQueue, - storage::{StorageBackend, sqlite::SqliteBackend}, -}; -use tokio::sync::RwLock; use tower::ServiceExt; -/// Fake socket address for tests (governor needs `ConnectInfo`) -fn test_addr() -> ConnectInfo { - ConnectInfo("127.0.0.1:9999".parse().unwrap()) -} - -/// Build a GET request with `ConnectInfo` for rate limiter compatibility -fn get(uri: &str) -> Request { - let mut req = Request::builder().uri(uri).body(Body::empty()).unwrap(); - req.extensions_mut().insert(test_addr()); - req -} - -/// Build a POST request with `ConnectInfo` -fn post_json(uri: &str, body: &str) -> Request { - let mut req = Request::builder() - .method("POST") - .uri(uri) - .header("content-type", "application/json") - .body(Body::from(body.to_string())) - .unwrap(); - req.extensions_mut().insert(test_addr()); - req -} - -/// Build a GET request with Bearer auth -fn get_authed(uri: &str, token: &str) -> Request { - let mut req = Request::builder() - .uri(uri) - .header("authorization", format!("Bearer {token}")) - .body(Body::empty()) - .unwrap(); - req.extensions_mut().insert(test_addr()); - req -} - -/// Build a POST JSON request with Bearer auth -fn post_json_authed(uri: &str, body: &str, token: &str) -> Request { - let mut req = Request::builder() - .method("POST") - .uri(uri) - .header("content-type", "application/json") - .header("authorization", format!("Bearer {token}")) - .body(Body::from(body.to_string())) - .unwrap(); - req.extensions_mut().insert(test_addr()); - req -} - -/// Build a DELETE request with Bearer auth -fn delete_authed(uri: &str, token: &str) -> Request { - let mut req = Request::builder() - .method("DELETE") - .uri(uri) - .header("authorization", format!("Bearer {token}")) - .body(Body::empty()) - .unwrap(); - req.extensions_mut().insert(test_addr()); - req -} - -/// Build a PATCH JSON request with Bearer auth -fn patch_json_authed(uri: &str, body: &str, token: &str) -> Request { - let mut req = Request::builder() - .method("PATCH") - .uri(uri) - .header("content-type", "application/json") - .header("authorization", format!("Bearer {token}")) - .body(Body::from(body.to_string())) - .unwrap(); - req.extensions_mut().insert(test_addr()); - req -} - -fn default_config() -> Config { - Config { - storage: StorageConfig { - backend: StorageBackendType::Sqlite, - sqlite: Some(SqliteConfig { - path: ":memory:".into(), - }), - postgres: None, - }, - directories: DirectoryConfig { roots: vec![] }, - scanning: ScanningConfig { - watch: false, - poll_interval_secs: 300, - ignore_patterns: vec![], - import_concurrency: 8, - }, - server: ServerConfig { - host: "127.0.0.1".to_string(), - port: 3000, - api_key: None, - tls: TlsConfig::default(), - authentication_disabled: true, - cors_enabled: false, - cors_origins: vec![], - }, - rate_limits: RateLimitConfig::default(), - ui: UiConfig::default(), - accounts: AccountsConfig::default(), - jobs: JobsConfig::default(), - thumbnails: ThumbnailConfig::default(), - webhooks: Vec::::new(), - scheduled_tasks: vec![], - plugins: PluginsConfig::default(), - transcoding: TranscodingConfig::default(), - enrichment: EnrichmentConfig::default(), - cloud: CloudConfig::default(), - analytics: AnalyticsConfig::default(), - photos: PhotoConfig::default(), - managed_storage: ManagedStorageConfig::default(), - sync: SyncConfig::default(), - sharing: SharingConfig::default(), - trash: TrashConfig::default(), - } -} - -async fn setup_app() -> axum::Router { - let backend = SqliteBackend::in_memory().expect("in-memory SQLite"); - backend.run_migrations().await.expect("migrations"); - let storage = Arc::new(backend) as pinakes_core::storage::DynStorageBackend; - - let config = default_config(); - - let job_queue = - JobQueue::new(1, 0, |_id, _kind, _cancel, _jobs| tokio::spawn(async {})); - let config = Arc::new(RwLock::new(config)); - let scheduler = pinakes_core::scheduler::TaskScheduler::new( - job_queue.clone(), - tokio_util::sync::CancellationToken::new(), - config.clone(), - None, - ); - - let state = pinakes_server::state::AppState { - storage, - config, - config_path: None, - scan_progress: pinakes_core::scan::ScanProgress::new(), - job_queue, - cache: Arc::new(CacheLayer::new(60)), - scheduler: Arc::new(scheduler), - plugin_manager: None, - plugin_pipeline: None, - transcode_service: None, - managed_storage: None, - chunked_upload_manager: None, - session_semaphore: Arc::new(tokio::sync::Semaphore::new(64)), - webhook_dispatcher: None, - }; - - pinakes_server::app::create_router(state, &RateLimitConfig::default()) -} - -/// Hash a password for test user accounts -fn hash_password(password: &str) -> String { - pinakes_core::users::auth::hash_password(password).unwrap() -} - -/// Set up an app with accounts enabled and three pre-seeded users. -/// Returns (Router, `admin_token`, `editor_token`, `viewer_token`). -async fn setup_app_with_auth() -> (axum::Router, String, String, String) { - let backend = SqliteBackend::in_memory().expect("in-memory SQLite"); - backend.run_migrations().await.expect("migrations"); - let storage = Arc::new(backend) as pinakes_core::storage::DynStorageBackend; - - // Create users in database so resolve_user_id works - let users_to_create = vec![ - ("admin", "adminpass", UserRole::Admin), - ("editor", "editorpass", UserRole::Editor), - ("viewer", "viewerpass", UserRole::Viewer), - ]; - for (username, password, role) in &users_to_create { - let password_hash = hash_password(password); - storage - .create_user(username, &password_hash, *role, None) - .await - .expect("create user"); - } - - let mut config = default_config(); - config.server.authentication_disabled = false; // Enable authentication for these tests - config.accounts.enabled = true; - config.accounts.users = vec![ - UserAccount { - username: "admin".to_string(), - password_hash: hash_password("adminpass"), - role: UserRole::Admin, - }, - UserAccount { - username: "editor".to_string(), - password_hash: hash_password("editorpass"), - role: UserRole::Editor, - }, - UserAccount { - username: "viewer".to_string(), - password_hash: hash_password("viewerpass"), - role: UserRole::Viewer, - }, - ]; - - let job_queue = - JobQueue::new(1, 0, |_id, _kind, _cancel, _jobs| tokio::spawn(async {})); - let config = Arc::new(RwLock::new(config)); - let scheduler = pinakes_core::scheduler::TaskScheduler::new( - job_queue.clone(), - tokio_util::sync::CancellationToken::new(), - config.clone(), - None, - ); - - let state = pinakes_server::state::AppState { - storage, - config, - config_path: None, - scan_progress: pinakes_core::scan::ScanProgress::new(), - job_queue, - cache: Arc::new(CacheLayer::new(60)), - scheduler: Arc::new(scheduler), - plugin_manager: None, - plugin_pipeline: None, - transcode_service: None, - managed_storage: None, - chunked_upload_manager: None, - session_semaphore: Arc::new(tokio::sync::Semaphore::new(64)), - webhook_dispatcher: None, - }; - - let app = - pinakes_server::app::create_router(state, &RateLimitConfig::default()); - - // Login each user to get tokens - let admin_token = login_user(app.clone(), "admin", "adminpass").await; - let editor_token = login_user(app.clone(), "editor", "editorpass").await; - let viewer_token = login_user(app.clone(), "viewer", "viewerpass").await; - - (app, admin_token, editor_token, viewer_token) -} - -async fn login_user( - app: axum::Router, - username: &str, - password: &str, -) -> String { - let body = format!(r#"{{"username":"{username}","password":"{password}"}}"#); - let response = app - .oneshot(post_json("/api/v1/auth/login", &body)) - .await - .unwrap(); - assert_eq!( - response.status(), - StatusCode::OK, - "login failed for user {username}" - ); - let body = response.into_body().collect().await.unwrap().to_bytes(); - let result: serde_json::Value = serde_json::from_slice(&body).unwrap(); - result["token"].as_str().unwrap().to_string() -} - -async fn response_body( - response: axum::response::Response, -) -> serde_json::Value { - let body = response.into_body().collect().await.unwrap().to_bytes(); - serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null) -} - #[tokio::test] async fn test_list_media_empty() { let app = setup_app().await; diff --git a/crates/pinakes-server/tests/common/mod.rs b/crates/pinakes-server/tests/common/mod.rs new file mode 100644 index 0000000..d4d38c9 --- /dev/null +++ b/crates/pinakes-server/tests/common/mod.rs @@ -0,0 +1,323 @@ +use std::{net::SocketAddr, sync::Arc}; + +use axum::{ + body::Body, + extract::ConnectInfo, + http::{Request, StatusCode}, +}; +use http_body_util::BodyExt; +use pinakes_core::{ + cache::CacheLayer, + config::{ + AccountsConfig, + AnalyticsConfig, + CloudConfig, + Config, + DirectoryConfig, + EnrichmentConfig, + JobsConfig, + ManagedStorageConfig, + PhotoConfig, + PluginsConfig, + RateLimitConfig, + ScanningConfig, + ServerConfig, + SharingConfig, + SqliteConfig, + StorageBackendType, + StorageConfig, + SyncConfig, + ThumbnailConfig, + TlsConfig, + TranscodingConfig, + TrashConfig, + UiConfig, + UserAccount, + UserRole, + WebhookConfig, + }, + jobs::JobQueue, + storage::{StorageBackend, sqlite::SqliteBackend}, +}; +use tokio::sync::RwLock; +use tower::ServiceExt; + +/// Fake socket address for tests (governor needs +/// `ConnectInfo`) +pub fn test_addr() -> ConnectInfo { + ConnectInfo("127.0.0.1:9999".parse().unwrap()) +} + +/// Build a GET request with `ConnectInfo` for rate limiter +/// compatibility +pub fn get(uri: &str) -> Request { + let mut req = Request::builder().uri(uri).body(Body::empty()).unwrap(); + req.extensions_mut().insert(test_addr()); + req +} + +/// Build a POST request with `ConnectInfo` +pub fn post_json(uri: &str, body: &str) -> Request { + let mut req = Request::builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + req.extensions_mut().insert(test_addr()); + req +} + +/// Build a GET request with Bearer auth +pub fn get_authed(uri: &str, token: &str) -> Request { + let mut req = Request::builder() + .uri(uri) + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(); + req.extensions_mut().insert(test_addr()); + req +} + +/// Build a POST JSON request with Bearer auth +pub fn post_json_authed(uri: &str, body: &str, token: &str) -> Request { + let mut req = Request::builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {token}")) + .body(Body::from(body.to_string())) + .unwrap(); + req.extensions_mut().insert(test_addr()); + req +} + +/// Build a PUT JSON request with Bearer auth +pub fn put_json_authed(uri: &str, body: &str, token: &str) -> Request { + let mut req = Request::builder() + .method("PUT") + .uri(uri) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {token}")) + .body(Body::from(body.to_string())) + .unwrap(); + req.extensions_mut().insert(test_addr()); + req +} + +/// Build a DELETE request with Bearer auth +pub fn delete_authed(uri: &str, token: &str) -> Request { + let mut req = Request::builder() + .method("DELETE") + .uri(uri) + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(); + req.extensions_mut().insert(test_addr()); + req +} + +/// Build a PATCH JSON request with Bearer auth +pub fn patch_json_authed(uri: &str, body: &str, token: &str) -> Request { + let mut req = Request::builder() + .method("PATCH") + .uri(uri) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {token}")) + .body(Body::from(body.to_string())) + .unwrap(); + req.extensions_mut().insert(test_addr()); + req +} + +pub fn default_config() -> Config { + Config { + storage: StorageConfig { + backend: StorageBackendType::Sqlite, + sqlite: Some(SqliteConfig { + path: ":memory:".into(), + }), + postgres: None, + }, + directories: DirectoryConfig { roots: vec![] }, + scanning: ScanningConfig { + watch: false, + poll_interval_secs: 300, + ignore_patterns: vec![], + import_concurrency: 8, + }, + server: ServerConfig { + host: "127.0.0.1".to_string(), + port: 3000, + api_key: None, + tls: TlsConfig::default(), + authentication_disabled: true, + cors_enabled: false, + cors_origins: vec![], + }, + rate_limits: RateLimitConfig::default(), + ui: UiConfig::default(), + accounts: AccountsConfig::default(), + jobs: JobsConfig::default(), + thumbnails: ThumbnailConfig::default(), + webhooks: Vec::::new(), + scheduled_tasks: vec![], + plugins: PluginsConfig::default(), + transcoding: TranscodingConfig::default(), + enrichment: EnrichmentConfig::default(), + cloud: CloudConfig::default(), + analytics: AnalyticsConfig::default(), + photos: PhotoConfig::default(), + managed_storage: ManagedStorageConfig::default(), + sync: SyncConfig::default(), + sharing: SharingConfig::default(), + trash: TrashConfig::default(), + } +} + +pub async fn setup_app() -> axum::Router { + let backend = SqliteBackend::in_memory().expect("in-memory SQLite"); + backend.run_migrations().await.expect("migrations"); + let storage = Arc::new(backend) as pinakes_core::storage::DynStorageBackend; + + let config = default_config(); + + let job_queue = + JobQueue::new(1, 0, |_id, _kind, _cancel, _jobs| tokio::spawn(async {})); + let config = Arc::new(RwLock::new(config)); + let scheduler = pinakes_core::scheduler::TaskScheduler::new( + job_queue.clone(), + tokio_util::sync::CancellationToken::new(), + config.clone(), + None, + ); + + let state = pinakes_server::state::AppState { + storage, + config, + config_path: None, + scan_progress: pinakes_core::scan::ScanProgress::new(), + job_queue, + cache: Arc::new(CacheLayer::new(60)), + scheduler: Arc::new(scheduler), + plugin_manager: None, + plugin_pipeline: None, + transcode_service: None, + managed_storage: None, + chunked_upload_manager: None, + session_semaphore: Arc::new(tokio::sync::Semaphore::new(64)), + webhook_dispatcher: None, + }; + + pinakes_server::app::create_router(state, &RateLimitConfig::default()) +} + +/// Hash a password for test user accounts +pub fn hash_password(password: &str) -> String { + pinakes_core::users::auth::hash_password(password).unwrap() +} + +/// Set up an app with accounts enabled and three pre-seeded users. +/// Returns (Router, `admin_token`, `editor_token`, `viewer_token`). +pub async fn setup_app_with_auth() -> (axum::Router, String, String, String) { + let backend = SqliteBackend::in_memory().expect("in-memory SQLite"); + backend.run_migrations().await.expect("migrations"); + let storage = Arc::new(backend) as pinakes_core::storage::DynStorageBackend; + + let users_to_create = vec![ + ("admin", "adminpass", UserRole::Admin), + ("editor", "editorpass", UserRole::Editor), + ("viewer", "viewerpass", UserRole::Viewer), + ]; + for (username, password, role) in &users_to_create { + let password_hash = hash_password(password); + storage + .create_user(username, &password_hash, *role, None) + .await + .expect("create user"); + } + + let mut config = default_config(); + config.server.authentication_disabled = false; + config.accounts.enabled = true; + config.accounts.users = vec![ + UserAccount { + username: "admin".to_string(), + password_hash: hash_password("adminpass"), + role: UserRole::Admin, + }, + UserAccount { + username: "editor".to_string(), + password_hash: hash_password("editorpass"), + role: UserRole::Editor, + }, + UserAccount { + username: "viewer".to_string(), + password_hash: hash_password("viewerpass"), + role: UserRole::Viewer, + }, + ]; + + let job_queue = + JobQueue::new(1, 0, |_id, _kind, _cancel, _jobs| tokio::spawn(async {})); + let config = Arc::new(RwLock::new(config)); + let scheduler = pinakes_core::scheduler::TaskScheduler::new( + job_queue.clone(), + tokio_util::sync::CancellationToken::new(), + config.clone(), + None, + ); + + let state = pinakes_server::state::AppState { + storage, + config, + config_path: None, + scan_progress: pinakes_core::scan::ScanProgress::new(), + job_queue, + cache: Arc::new(CacheLayer::new(60)), + scheduler: Arc::new(scheduler), + plugin_manager: None, + plugin_pipeline: None, + transcode_service: None, + managed_storage: None, + chunked_upload_manager: None, + session_semaphore: Arc::new(tokio::sync::Semaphore::new(64)), + webhook_dispatcher: None, + }; + + let app = + pinakes_server::app::create_router(state, &RateLimitConfig::default()); + + let admin_token = login_user(app.clone(), "admin", "adminpass").await; + let editor_token = login_user(app.clone(), "editor", "editorpass").await; + let viewer_token = login_user(app.clone(), "viewer", "viewerpass").await; + + (app, admin_token, editor_token, viewer_token) +} + +pub async fn login_user( + app: axum::Router, + username: &str, + password: &str, +) -> String { + let body = format!(r#"{{"username":"{username}","password":"{password}"}}"#); + let response = app + .oneshot(post_json("/api/v1/auth/login", &body)) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::OK, + "login failed for user {username}" + ); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let result: serde_json::Value = serde_json::from_slice(&body).unwrap(); + result["token"].as_str().unwrap().to_string() +} + +pub async fn response_body( + response: axum::response::Response, +) -> serde_json::Value { + let body = response.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null) +} diff --git a/crates/pinakes-server/tests/plugin.rs b/crates/pinakes-server/tests/plugin.rs index 0dcb250..889c1a5 100644 --- a/crates/pinakes-server/tests/plugin.rs +++ b/crates/pinakes-server/tests/plugin.rs @@ -1,66 +1,26 @@ -use std::{net::SocketAddr, sync::Arc}; +mod common; +use std::sync::Arc; -use axum::{ - body::Body, - extract::ConnectInfo, - http::{Request, StatusCode}, -}; +use axum::{body::Body, http::StatusCode}; +use common::*; use http_body_util::BodyExt; -use pinakes_core::{ - cache::CacheLayer, - config::{ - AccountsConfig, - AnalyticsConfig, - CloudConfig, - Config, - DirectoryConfig, - EnrichmentConfig, - JobsConfig, - ManagedStorageConfig, - PhotoConfig, - PluginsConfig, - RateLimitConfig, - ScanningConfig, - ServerConfig, - SharingConfig, - SqliteConfig, - StorageBackendType, - StorageConfig, - SyncConfig, - ThumbnailConfig, - TlsConfig, - TranscodingConfig, - TrashConfig, - UiConfig, - WebhookConfig, - }, - jobs::JobQueue, - plugin::PluginManager, - storage::{StorageBackend, sqlite::SqliteBackend}, -}; -use tokio::sync::RwLock; +use pinakes_core::{config::PluginsConfig, plugin::PluginManager}; use tower::ServiceExt; -/// Fake socket address for tests (governor needs `ConnectInfo`) -fn test_addr() -> ConnectInfo { - ConnectInfo("127.0.0.1:9999".parse().unwrap()) -} - -/// Build a GET request with `ConnectInfo` for rate limiter compatibility -fn get(uri: &str) -> Request { - let mut req = Request::builder().uri(uri).body(Body::empty()).unwrap(); - req.extensions_mut().insert(test_addr()); - req -} - async fn setup_app_with_plugins() -> (axum::Router, Arc, tempfile::TempDir) { + use pinakes_core::{ + cache::CacheLayer, + config::RateLimitConfig, + jobs::JobQueue, + storage::{StorageBackend, sqlite::SqliteBackend}, + }; + use tokio::sync::RwLock; + let backend = SqliteBackend::in_memory().expect("in-memory SQLite"); backend.run_migrations().await.expect("migrations"); let storage = Arc::new(backend) as pinakes_core::storage::DynStorageBackend; - // Create temp directories for plugin manager (automatically cleaned up when - // TempDir drops) let temp_dir = tempfile::TempDir::new().expect("create temp dir"); let data_dir = temp_dir.path().join("data"); let cache_dir = temp_dir.path().join("cache"); @@ -87,48 +47,8 @@ async fn setup_app_with_plugins() .expect("create plugin manager"); let plugin_manager = Arc::new(plugin_manager); - let config = Config { - storage: StorageConfig { - backend: StorageBackendType::Sqlite, - sqlite: Some(SqliteConfig { - path: ":memory:".into(), - }), - postgres: None, - }, - directories: DirectoryConfig { roots: vec![] }, - scanning: ScanningConfig { - watch: false, - poll_interval_secs: 300, - ignore_patterns: vec![], - import_concurrency: 8, - }, - server: ServerConfig { - host: "127.0.0.1".to_string(), - port: 3000, - api_key: None, - tls: TlsConfig::default(), - authentication_disabled: true, - cors_enabled: false, - cors_origins: vec![], - }, - rate_limits: RateLimitConfig::default(), - ui: UiConfig::default(), - accounts: AccountsConfig::default(), - jobs: JobsConfig::default(), - thumbnails: ThumbnailConfig::default(), - webhooks: Vec::::new(), - scheduled_tasks: vec![], - plugins: plugin_config, - transcoding: TranscodingConfig::default(), - enrichment: EnrichmentConfig::default(), - cloud: CloudConfig::default(), - analytics: AnalyticsConfig::default(), - photos: PhotoConfig::default(), - managed_storage: ManagedStorageConfig::default(), - sync: SyncConfig::default(), - sharing: SharingConfig::default(), - trash: TrashConfig::default(), - }; + let mut config = default_config(); + config.plugins = plugin_config; let job_queue = JobQueue::new(1, 0, |_id, _kind, _cancel, _jobs| tokio::spawn(async {})); @@ -178,11 +98,9 @@ async fn test_list_plugins_empty() { async fn test_plugin_manager_exists() { let (app, _pm, _tmp) = setup_app_with_plugins().await; - // Verify plugin manager is accessible let plugins = _pm.list_plugins().await; assert_eq!(plugins.len(), 0); - // Verify API endpoint works let response = app.oneshot(get("/api/v1/plugins")).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); } @@ -203,14 +121,9 @@ async fn test_plugin_not_found() { async fn test_plugin_enable_disable() { let (app, pm, _tmp) = setup_app_with_plugins().await; - // Verify plugin manager is initialized assert!(pm.list_plugins().await.is_empty()); - // For this test, we would need to actually load a plugin first - // Since we don't have a real WASM plugin loaded, we'll just verify - // the endpoints exist and return appropriate errors - - let mut req = Request::builder() + let mut req = axum::http::Request::builder() .method("POST") .uri("/api/v1/plugins/test-plugin/enable") .body(Body::empty()) @@ -219,11 +132,9 @@ async fn test_plugin_enable_disable() { let response = app.clone().oneshot(req).await.unwrap(); - // Should be NOT_FOUND since plugin doesn't exist assert_eq!(response.status(), StatusCode::NOT_FOUND); - // Test disable endpoint - let mut req = Request::builder() + let mut req = axum::http::Request::builder() .method("POST") .uri("/api/v1/plugins/test-plugin/disable") .body(Body::empty()) @@ -232,7 +143,6 @@ async fn test_plugin_enable_disable() { let response = app.oneshot(req).await.unwrap(); - // Should also be NOT_FOUND assert_eq!(response.status(), StatusCode::NOT_FOUND); } @@ -240,7 +150,7 @@ async fn test_plugin_enable_disable() { async fn test_plugin_uninstall_not_found() { let (app, _pm, _tmp) = setup_app_with_plugins().await; - let mut req = Request::builder() + let mut req = axum::http::Request::builder() .method("DELETE") .uri("/api/v1/plugins/nonexistent") .body(Body::empty()) @@ -249,7 +159,6 @@ async fn test_plugin_uninstall_not_found() { let response = app.oneshot(req).await.unwrap(); - // Expect 400 or 404 when plugin doesn't exist assert!( response.status() == StatusCode::BAD_REQUEST || response.status() == StatusCode::NOT_FOUND -- 2.43.0 From 1ee225201a0744c8edd5cced788dd7bc7fe9e891 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 20 Mar 2026 00:36:28 +0300 Subject: [PATCH 09/30] pinakes-plugin-api: suppress `struct_field_names` lint; minor doc tweaks Signed-off-by: NotAShelf Change-Id: I90f1cc46303564a61bdefe76d21045066a6a6964 --- crates/pinakes-plugin-api/src/lib.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/pinakes-plugin-api/src/lib.rs b/crates/pinakes-plugin-api/src/lib.rs index d21c284..23cdab1 100644 --- a/crates/pinakes-plugin-api/src/lib.rs +++ b/crates/pinakes-plugin-api/src/lib.rs @@ -1,7 +1,7 @@ //! Pinakes Plugin API //! -//! This crate defines the stable plugin interface for Pinakes. -//! Plugins can extend Pinakes by implementing one or more of the provided +//! Defines the "stable" plugin interface for Pinakes. Using this interface, +//! plugins can extend Pinakes by implementing one or more of the provided //! traits. use std::path::{Path, PathBuf}; @@ -23,6 +23,7 @@ pub use ui_schema::*; pub use wasm::host_functions; /// Plugin API version - plugins must match this version +/// FIXME: handle breaking changes for the API after stabilizing pub const PLUGIN_API_VERSION: &str = "1.0"; /// Result type for plugin operations @@ -355,6 +356,7 @@ pub enum EventType { /// Event data #[derive(Debug, Clone, Serialize, Deserialize)] +#[expect(clippy::struct_field_names)] pub struct Event { pub event_type: EventType, pub timestamp: String, -- 2.43.0 From 60b6aa1fe800f9a5e181238f0ebbc4dc8e79105d Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 20 Mar 2026 12:42:16 +0300 Subject: [PATCH 10/30] pinakes-plugin-api: suppress `enum_variant_names` lint Signed-off-by: NotAShelf Change-Id: I01367dea28dd7b47cf765b6f33782a5e6a6a6964 --- crates/pinakes-plugin-api/src/manifest.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/pinakes-plugin-api/src/manifest.rs b/crates/pinakes-plugin-api/src/manifest.rs index 20547a1..a8af310 100644 --- a/crates/pinakes-plugin-api/src/manifest.rs +++ b/crates/pinakes-plugin-api/src/manifest.rs @@ -195,6 +195,7 @@ pub struct ManifestFilesystemCapability { } #[derive(Debug, Error)] +#[expect(clippy::enum_variant_names)] pub enum ManifestError { #[error("Failed to read manifest file: {0}")] IoError(#[from] std::io::Error), -- 2.43.0 From ee5df288bcc5bf24032ec318e8c12624194a46c5 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 20 Mar 2026 12:43:43 +0300 Subject: [PATCH 11/30] pinakes-server: expand test coverage for server features Signed-off-by: NotAShelf Change-Id: Ia09d2d3ad7f6613e21d20321e0877bc16a6a6964 --- crates/pinakes-server/tests/api.rs | 30 ++++- crates/pinakes-server/tests/books.rs | 158 +++++++++++++++++++++++ crates/pinakes-server/tests/media_ops.rs | 145 +++++++++++++++++++++ crates/pinakes-server/tests/notes.rs | 125 ++++++++++++++++++ crates/pinakes-server/tests/plugin.rs | 110 +++++++++++++++- crates/pinakes-server/tests/shares.rs | 142 ++++++++++++++++++++ crates/pinakes-server/tests/sync.rs | 137 ++++++++++++++++++++ crates/pinakes-tui/src/app.rs | 16 +-- 8 files changed, 853 insertions(+), 10 deletions(-) create mode 100644 crates/pinakes-server/tests/books.rs create mode 100644 crates/pinakes-server/tests/media_ops.rs create mode 100644 crates/pinakes-server/tests/notes.rs create mode 100644 crates/pinakes-server/tests/shares.rs create mode 100644 crates/pinakes-server/tests/sync.rs diff --git a/crates/pinakes-server/tests/api.rs b/crates/pinakes-server/tests/api.rs index e519f21..37c4e65 100644 --- a/crates/pinakes-server/tests/api.rs +++ b/crates/pinakes-server/tests/api.rs @@ -3,7 +3,19 @@ use axum::{ body::Body, http::{Request, StatusCode}, }; -use common::*; +use common::{ + delete_authed, + get, + get_authed, + patch_json_authed, + post_json, + post_json_authed, + put_json_authed, + response_body, + setup_app, + setup_app_with_auth, + test_addr, +}; use http_body_util::BodyExt; use tower::ServiceExt; @@ -708,3 +720,19 @@ async fn test_share_link_expired() { || response.status() == StatusCode::INTERNAL_SERVER_ERROR ); } + +#[tokio::test] +async fn test_update_sync_device_requires_editor() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let fake_id = uuid::Uuid::now_v7(); + let response = app + .clone() + .oneshot(put_json_authed( + &format!("/api/v1/sync/devices/{fake_id}"), + r#"{"name":"renamed"}"#, + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} diff --git a/crates/pinakes-server/tests/books.rs b/crates/pinakes-server/tests/books.rs new file mode 100644 index 0000000..dbd0bce --- /dev/null +++ b/crates/pinakes-server/tests/books.rs @@ -0,0 +1,158 @@ +mod common; + +use axum::http::StatusCode; +use common::{ + delete_authed, + get, + get_authed, + patch_json_authed, + post_json_authed, + put_json_authed, + response_body, + setup_app, + setup_app_with_auth, +}; +use tower::ServiceExt; + +#[tokio::test] +async fn list_books_empty() { + let app = setup_app().await; + let resp = app.oneshot(get("/api/v1/books")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = response_body(resp).await; + let items = body.as_array().expect("array response"); + assert!(items.is_empty()); +} + +#[tokio::test] +async fn get_book_metadata_not_found() { + let app = setup_app().await; + let fake_id = uuid::Uuid::now_v7(); + let resp = app + .oneshot(get(&format!("/api/v1/books/{fake_id}/metadata"))) + .await + .unwrap(); + assert!( + resp.status() == StatusCode::NOT_FOUND + || resp.status() == StatusCode::INTERNAL_SERVER_ERROR + ); +} + +#[tokio::test] +async fn list_books_with_filters() { + let app = setup_app().await; + let resp = app + .oneshot(get("/api/v1/books?author=Tolkien&limit=10")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} + +#[tokio::test] +async fn list_series_empty() { + let app = setup_app().await; + let resp = app.oneshot(get("/api/v1/books/series")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} + +#[tokio::test] +async fn list_authors_empty() { + let app = setup_app().await; + let resp = app + .oneshot(get("/api/v1/books/authors?offset=0&limit=50")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} + +#[tokio::test] +async fn reading_progress_nonexistent_book() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let fake_id = uuid::Uuid::now_v7(); + let resp = app + .clone() + .oneshot(get_authed( + &format!("/api/v1/books/{fake_id}/progress"), + &viewer, + )) + .await + .unwrap(); + // Nonexistent book; expect NOT_FOUND or empty response + assert!( + resp.status() == StatusCode::NOT_FOUND || resp.status() == StatusCode::OK + ); +} + +#[tokio::test] +async fn update_reading_progress_nonexistent_book() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let fake_id = uuid::Uuid::now_v7(); + let resp = app + .clone() + .oneshot(put_json_authed( + &format!("/api/v1/books/{fake_id}/progress"), + r#"{"current_page":42}"#, + &viewer, + )) + .await + .unwrap(); + // Nonexistent book; expect NOT_FOUND or error + assert!( + resp.status() == StatusCode::NOT_FOUND + || resp.status() == StatusCode::INTERNAL_SERVER_ERROR + ); +} + +#[tokio::test] +async fn reading_list_empty() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let resp = app + .clone() + .oneshot(get_authed("/api/v1/books/reading-list", &viewer)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} + +#[tokio::test] +async fn import_media_requires_editor() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let resp = app + .clone() + .oneshot(post_json_authed( + "/api/v1/media/import", + r#"{"path":"/tmp/test.txt"}"#, + &viewer, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn update_media_requires_editor() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let fake_id = uuid::Uuid::now_v7(); + let resp = app + .clone() + .oneshot(patch_json_authed( + &format!("/api/v1/media/{fake_id}"), + r#"{"title":"new"}"#, + &viewer, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn delete_media_requires_editor() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let fake_id = uuid::Uuid::now_v7(); + let resp = app + .clone() + .oneshot(delete_authed(&format!("/api/v1/media/{fake_id}"), &viewer)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} diff --git a/crates/pinakes-server/tests/media_ops.rs b/crates/pinakes-server/tests/media_ops.rs new file mode 100644 index 0000000..a884c7c --- /dev/null +++ b/crates/pinakes-server/tests/media_ops.rs @@ -0,0 +1,145 @@ +mod common; + +use axum::http::StatusCode; +use common::{ + delete_authed, + get, + get_authed, + patch_json_authed, + post_json_authed, + put_json_authed, + response_body, + setup_app, + setup_app_with_auth, +}; +use tower::ServiceExt; + +#[tokio::test] +async fn media_count_empty() { + let app = setup_app().await; + let resp = app.oneshot(get("/api/v1/media/count")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = response_body(resp).await; + assert_eq!(body["count"], 0); +} + +#[tokio::test] +async fn batch_delete_empty_ids() { + let (app, admin, ..) = setup_app_with_auth().await; + let resp = app + .clone() + .oneshot(post_json_authed( + "/api/v1/media/batch/delete", + r#"{"ids":[]}"#, + &admin, + )) + .await + .unwrap(); + // Empty ids should be rejected (validation requires 1+ items) + assert!( + resp.status() == StatusCode::BAD_REQUEST + || resp.status() == StatusCode::UNPROCESSABLE_ENTITY + ); +} + +#[tokio::test] +async fn batch_delete_requires_editor() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let fake_id = uuid::Uuid::now_v7(); + let body = format!(r#"{{"ids":["{fake_id}"]}}"#); + let resp = app + .clone() + .oneshot(post_json_authed( + "/api/v1/media/batch/delete", + &body, + &viewer, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn list_trash_empty() { + let (app, _, editor, _) = setup_app_with_auth().await; + let resp = app + .clone() + .oneshot(get_authed("/api/v1/trash?offset=0&limit=50", &editor)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = response_body(resp).await; + assert_eq!(body["total_count"], 0); + let items = body["items"].as_array().expect("items array"); + assert!(items.is_empty()); +} + +#[tokio::test] +async fn batch_tag_requires_editor() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let resp = app + .clone() + .oneshot(post_json_authed( + "/api/v1/media/batch/tag", + r#"{"media_ids":[],"tag_ids":[]}"#, + &viewer, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn list_trash_requires_editor() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let resp = app + .clone() + .oneshot(get_authed("/api/v1/trash", &viewer)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn rename_media_requires_editor() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let fake_id = uuid::Uuid::now_v7(); + let resp = app + .clone() + .oneshot(patch_json_authed( + &format!("/api/v1/media/{fake_id}/rename"), + r#"{"new_name":"renamed.txt"}"#, + &viewer, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn permanent_delete_requires_editor() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let fake_id = uuid::Uuid::now_v7(); + let resp = app + .clone() + .oneshot(delete_authed(&format!("/api/v1/media/{fake_id}"), &viewer)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn update_sync_device_requires_editor() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let fake_id = uuid::Uuid::now_v7(); + let resp = app + .clone() + .oneshot(put_json_authed( + &format!("/api/v1/sync/devices/{fake_id}"), + r#"{"name":"renamed"}"#, + &viewer, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} diff --git a/crates/pinakes-server/tests/notes.rs b/crates/pinakes-server/tests/notes.rs new file mode 100644 index 0000000..0dc246e --- /dev/null +++ b/crates/pinakes-server/tests/notes.rs @@ -0,0 +1,125 @@ +mod common; + +use axum::http::StatusCode; +use common::{ + delete_authed, + get, + get_authed, + patch_json_authed, + post_json_authed, + put_json_authed, + response_body, + setup_app, + setup_app_with_auth, +}; +use tower::ServiceExt; + +#[tokio::test] +async fn backlinks_for_nonexistent_media() { + let app = setup_app().await; + let fake_id = uuid::Uuid::now_v7(); + let resp = app + .oneshot(get(&format!("/api/v1/media/{fake_id}/backlinks"))) + .await + .unwrap(); + // Should return OK with empty list, or NOT_FOUND + assert!( + resp.status() == StatusCode::OK || resp.status() == StatusCode::NOT_FOUND + ); +} + +#[tokio::test] +async fn outgoing_links_for_nonexistent_media() { + let app = setup_app().await; + let fake_id = uuid::Uuid::now_v7(); + let resp = app + .oneshot(get(&format!("/api/v1/media/{fake_id}/outgoing-links"))) + .await + .unwrap(); + assert!( + resp.status() == StatusCode::OK || resp.status() == StatusCode::NOT_FOUND + ); +} + +#[tokio::test] +async fn notes_graph_empty() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let resp = app + .clone() + .oneshot(get_authed("/api/v1/notes/graph", &viewer)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = response_body(resp).await; + assert!(body.is_object() || body.is_array()); +} + +#[tokio::test] +async fn unresolved_count_zero() { + let app = setup_app().await; + let resp = app + .oneshot(get("/api/v1/notes/unresolved-count")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} + +#[tokio::test] +async fn reindex_links_requires_editor() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let fake_id = uuid::Uuid::now_v7(); + let resp = app + .clone() + .oneshot(post_json_authed( + &format!("/api/v1/media/{fake_id}/reindex-links"), + "{}", + &viewer, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn update_media_requires_editor() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let fake_id = uuid::Uuid::now_v7(); + let resp = app + .clone() + .oneshot(patch_json_authed( + &format!("/api/v1/media/{fake_id}"), + r#"{"title":"new title"}"#, + &viewer, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn delete_media_requires_editor() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let fake_id = uuid::Uuid::now_v7(); + let resp = app + .clone() + .oneshot(delete_authed(&format!("/api/v1/media/{fake_id}"), &viewer)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn update_sync_device_requires_editor() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let fake_id = uuid::Uuid::now_v7(); + let resp = app + .clone() + .oneshot(put_json_authed( + &format!("/api/v1/sync/devices/{fake_id}"), + r#"{"name":"renamed"}"#, + &viewer, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} diff --git a/crates/pinakes-server/tests/plugin.rs b/crates/pinakes-server/tests/plugin.rs index 889c1a5..287fef7 100644 --- a/crates/pinakes-server/tests/plugin.rs +++ b/crates/pinakes-server/tests/plugin.rs @@ -2,7 +2,20 @@ mod common; use std::sync::Arc; use axum::{body::Body, http::StatusCode}; -use common::*; +use common::{ + default_config, + delete_authed, + get, + get_authed, + patch_json_authed, + post_json, + post_json_authed, + put_json_authed, + response_body, + setup_app, + setup_app_with_auth, + test_addr, +}; use http_body_util::BodyExt; use pinakes_core::{config::PluginsConfig, plugin::PluginManager}; use tower::ServiceExt; @@ -164,3 +177,98 @@ async fn test_plugin_uninstall_not_found() { || response.status() == StatusCode::NOT_FOUND ); } + +// RBAC tests using common helpers with auth setup + +#[tokio::test] +async fn media_list_unauthenticated() { + let app = setup_app().await; + let resp = app.oneshot(get("/api/v1/media")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = response_body(resp).await; + assert!(body.is_array()); +} + +#[tokio::test] +async fn media_list_authenticated() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let resp = app + .clone() + .oneshot(get_authed("/api/v1/media", &viewer)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} + +#[tokio::test] +async fn import_unauthenticated_rejected() { + let (app, ..) = setup_app_with_auth().await; + let resp = app + .clone() + .oneshot(post_json( + "/api/v1/media/import", + r#"{"path":"/tmp/test.txt"}"#, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn import_viewer_forbidden() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let resp = app + .clone() + .oneshot(post_json_authed( + "/api/v1/media/import", + r#"{"path":"/tmp/test.txt"}"#, + &viewer, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn update_media_viewer_forbidden() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let fake_id = uuid::Uuid::now_v7(); + let resp = app + .clone() + .oneshot(patch_json_authed( + &format!("/api/v1/media/{fake_id}"), + r#"{"title":"new"}"#, + &viewer, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn delete_media_viewer_forbidden() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let fake_id = uuid::Uuid::now_v7(); + let resp = app + .clone() + .oneshot(delete_authed(&format!("/api/v1/media/{fake_id}"), &viewer)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn update_sync_device_viewer_forbidden() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let fake_id = uuid::Uuid::now_v7(); + let resp = app + .clone() + .oneshot(put_json_authed( + &format!("/api/v1/sync/devices/{fake_id}"), + r#"{"name":"renamed"}"#, + &viewer, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} diff --git a/crates/pinakes-server/tests/shares.rs b/crates/pinakes-server/tests/shares.rs new file mode 100644 index 0000000..ea7c234 --- /dev/null +++ b/crates/pinakes-server/tests/shares.rs @@ -0,0 +1,142 @@ +mod common; + +use axum::http::StatusCode; +use common::{ + delete_authed, + get, + get_authed, + patch_json_authed, + post_json, + post_json_authed, + put_json_authed, + response_body, + setup_app, + setup_app_with_auth, +}; +use tower::ServiceExt; + +#[tokio::test] +async fn list_outgoing_shares_empty() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let resp = app + .clone() + .oneshot(get_authed("/api/v1/shares/outgoing", &viewer)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = response_body(resp).await; + let shares = body.as_array().expect("array response"); + assert!(shares.is_empty()); +} + +#[tokio::test] +async fn list_incoming_shares_empty() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let resp = app + .clone() + .oneshot(get_authed("/api/v1/shares/incoming", &viewer)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} + +#[tokio::test] +async fn share_notifications_empty() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let resp = app + .clone() + .oneshot(get_authed("/api/v1/notifications/shares", &viewer)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} + +#[tokio::test] +async fn batch_delete_shares_requires_auth() { + let (app, ..) = setup_app_with_auth().await; + let resp = app + .clone() + .oneshot(post_json("/api/v1/shares/batch/delete", r#"{"ids":[]}"#)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn batch_delete_shares_requires_editor() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let resp = app + .clone() + .oneshot(post_json_authed( + "/api/v1/shares/batch/delete", + r#"{"ids":[]}"#, + &viewer, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn create_share_requires_editor() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let fake_id = uuid::Uuid::now_v7(); + let body = format!(r#"{{"media_id":"{fake_id}","share_type":"link"}}"#); + let resp = app + .clone() + .oneshot(post_json_authed("/api/v1/shares", &body, &viewer)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn update_share_requires_editor() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let fake_id = uuid::Uuid::now_v7(); + let resp = app + .clone() + .oneshot(patch_json_authed( + &format!("/api/v1/shares/{fake_id}"), + r#"{"permissions":["read"]}"#, + &viewer, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn delete_share_requires_editor() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let fake_id = uuid::Uuid::now_v7(); + let resp = app + .clone() + .oneshot(delete_authed(&format!("/api/v1/shares/{fake_id}"), &viewer)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn update_sync_device_requires_editor() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let fake_id = uuid::Uuid::now_v7(); + let resp = app + .clone() + .oneshot(put_json_authed( + &format!("/api/v1/sync/devices/{fake_id}"), + r#"{"name":"renamed"}"#, + &viewer, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn media_list_no_auth() { + let app = setup_app().await; + let resp = app.oneshot(get("/api/v1/media")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} diff --git a/crates/pinakes-server/tests/sync.rs b/crates/pinakes-server/tests/sync.rs new file mode 100644 index 0000000..55fd585 --- /dev/null +++ b/crates/pinakes-server/tests/sync.rs @@ -0,0 +1,137 @@ +mod common; + +use axum::http::StatusCode; +use common::{ + delete_authed, + get, + get_authed, + patch_json_authed, + post_json, + post_json_authed, + put_json_authed, + response_body, + setup_app, + setup_app_with_auth, +}; +use tower::ServiceExt; + +#[tokio::test] +async fn list_sync_devices_empty() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let resp = app + .clone() + .oneshot(get_authed("/api/v1/sync/devices", &viewer)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = response_body(resp).await; + let devices = body.as_array().expect("array response"); + assert!(devices.is_empty()); +} + +#[tokio::test] +async fn get_changes_sync_disabled() { + // Default config has sync.enabled = false; endpoint should reject + let (app, _, _, viewer) = setup_app_with_auth().await; + let resp = app + .clone() + .oneshot(get_authed("/api/v1/sync/changes", &viewer)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn list_conflicts_requires_device_token() { + // list_conflicts requires X-Device-Token header; omitting it returns 400 + let (app, _, _, viewer) = setup_app_with_auth().await; + let resp = app + .clone() + .oneshot(get_authed("/api/v1/sync/conflicts", &viewer)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn register_device_requires_auth() { + let (app, ..) = setup_app_with_auth().await; + let resp = app + .clone() + .oneshot(post_json( + "/api/v1/sync/devices", + r#"{"name":"test","device_type":"desktop","client_version":"0.3.0"}"#, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn register_device_requires_editor() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let resp = app + .clone() + .oneshot(post_json_authed( + "/api/v1/sync/devices", + r#"{"name":"test","device_type":"desktop","client_version":"0.3.0"}"#, + &viewer, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn update_device_requires_editor() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let fake_id = uuid::Uuid::now_v7(); + let resp = app + .clone() + .oneshot(put_json_authed( + &format!("/api/v1/sync/devices/{fake_id}"), + r#"{"name":"renamed"}"#, + &viewer, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn delete_device_requires_editor() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let fake_id = uuid::Uuid::now_v7(); + let resp = app + .clone() + .oneshot(delete_authed( + &format!("/api/v1/sync/devices/{fake_id}"), + &viewer, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn update_media_requires_editor() { + let (app, _, _, viewer) = setup_app_with_auth().await; + let fake_id = uuid::Uuid::now_v7(); + let resp = app + .clone() + .oneshot(patch_json_authed( + &format!("/api/v1/media/{fake_id}"), + r#"{"title":"new"}"#, + &viewer, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn media_list_no_auth() { + let app = setup_app().await; + let resp = app.oneshot(get("/api/v1/media")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} diff --git a/crates/pinakes-tui/src/app.rs b/crates/pinakes-tui/src/app.rs index 7fd166f..d1ee90c 100644 --- a/crates/pinakes-tui/src/app.rs +++ b/crates/pinakes-tui/src/app.rs @@ -146,6 +146,14 @@ impl AppState { import_input: String::new(), status_message: None, should_quit: false, + page_offset: 0, + page_size: 50, + total_media_count: 0, + server_url: server_url.to_string(), + // Multi-select + selected_items: FxHashSet::default(), + selection_mode: false, + pending_batch_delete: false, duplicate_groups: Vec::new(), duplicates_selected: None, database_stats: None, @@ -174,14 +182,6 @@ impl AppState { reading_progress: None, page_input: String::new(), entering_page: false, - page_offset: 0, - page_size: 50, - total_media_count: 0, - server_url: server_url.to_string(), - // Multi-select - selected_items: FxHashSet::default(), - selection_mode: false, - pending_batch_delete: false, } } } -- 2.43.0 From 2daa1e4395288c4a9eb02589086121569e6888ff Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 20 Mar 2026 12:44:16 +0300 Subject: [PATCH 12/30] pinakes-core: add error variants for external tool calls and subtitle ops Signed-off-by: NotAShelf Change-Id: I9c9f4a7de065e176e16b108411c3d44b6a6a6964 --- crates/pinakes-core/src/error.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/pinakes-core/src/error.rs b/crates/pinakes-core/src/error.rs index c987dc6..7e43726 100644 --- a/crates/pinakes-core/src/error.rs +++ b/crates/pinakes-core/src/error.rs @@ -111,6 +111,15 @@ pub enum PinakesError { #[error("serialization error: {0}")] Serialization(String), + + #[error("external tool `{tool}` failed: {stderr}")] + ExternalTool { tool: String, stderr: String }, + + #[error("subtitle track {index} not found in media")] + SubtitleTrackNotFound { index: u32 }, + + #[error("invalid language code: {0}")] + InvalidLanguageCode(String), } impl From for PinakesError { -- 2.43.0 From 5e0f404fc7236d882dece4017a92c094ba2c8c2b Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Mar 2026 02:14:36 +0300 Subject: [PATCH 13/30] pinakes-core: initial subtitle management Signed-off-by: NotAShelf Change-Id: Id2f9b87b1cc903462539ab8ea47099696a6a6964 --- crates/pinakes-core/src/subtitles.rs | 315 ++++++++++++++++++++++++++- 1 file changed, 313 insertions(+), 2 deletions(-) diff --git a/crates/pinakes-core/src/subtitles.rs b/crates/pinakes-core/src/subtitles.rs index 5927899..51ae763 100644 --- a/crates/pinakes-core/src/subtitles.rs +++ b/crates/pinakes-core/src/subtitles.rs @@ -1,6 +1,6 @@ //! Subtitle management for video media items. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -17,7 +17,7 @@ pub struct Subtitle { pub format: SubtitleFormat, pub file_path: Option, pub is_embedded: bool, - pub track_index: Option, + pub track_index: Option, pub offset_ms: i64, pub created_at: DateTime, } @@ -33,6 +33,23 @@ pub enum SubtitleFormat { Pgs, } +impl SubtitleFormat { + /// Returns the MIME type for this subtitle format. + pub const fn mime_type(self) -> &'static str { + match self { + Self::Srt => "application/x-subrip", + Self::Vtt => "text/vtt", + Self::Ass | Self::Ssa => "text/plain; charset=utf-8", + Self::Pgs => "application/octet-stream", + } + } + + /// Returns true if this format is binary (not UTF-8 text). + pub const fn is_binary(self) -> bool { + matches!(self, Self::Pgs) + } +} + impl std::fmt::Display for SubtitleFormat { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let s = match self { @@ -60,3 +77,297 @@ impl std::str::FromStr for SubtitleFormat { } } } + +use crate::error::{PinakesError, Result}; + +/// Information about a subtitle track embedded in a media container. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubtitleTrackInfo { + /// Zero-based index among subtitle streams, as reported by ffprobe. + pub index: u32, + /// BCP 47 language code extracted from stream tags, if present. + pub language: Option, + /// Subtitle format derived from the codec name. + pub format: SubtitleFormat, + /// Human-readable title from stream tags, if present. + pub title: Option, +} + +/// Detects the subtitle format from a file extension. +/// +/// Returns `None` if the extension is unrecognised or absent. +pub fn detect_format(path: &Path) -> Option { + match path.extension()?.to_str()?.to_lowercase().as_str() { + "srt" => Some(SubtitleFormat::Srt), + "vtt" => Some(SubtitleFormat::Vtt), + "ass" => Some(SubtitleFormat::Ass), + "ssa" => Some(SubtitleFormat::Ssa), + "pgs" | "sup" => Some(SubtitleFormat::Pgs), + _ => None, + } +} + +/// Validates a BCP 47 language code. +/// +/// Accepts a primary tag of 2-3 letters followed by zero or more +/// hyphen-separated subtags of 2-8 alphanumeric characters each. +/// Examples: `en`, `en-US`, `zh-Hant`, `zh-Hant-TW`. +pub fn validate_language_code(lang: &str) -> bool { + static RE: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + #[expect(clippy::expect_used)] + regex::Regex::new(r"^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$") + .expect("valid regex pattern") + }); + RE.is_match(lang) +} + +/// Lists subtitle tracks embedded in a media file using ffprobe. +/// +/// Returns an empty vec if the file has no subtitle streams. +/// +/// # Errors +/// +/// Returns `PinakesError::ExternalTool` if ffprobe is not available or +/// produces an error exit code. +pub async fn list_embedded_tracks( + media_path: &Path, +) -> Result> { + let output = tokio::process::Command::new("ffprobe") + .args([ + "-v", + "quiet", + "-print_format", + "json", + "-show_streams", + "-select_streams", + "s", + ]) + .arg(media_path) + .output() + .await + .map_err(|e| { + PinakesError::ExternalTool { + tool: "ffprobe".into(), + stderr: e.to_string(), + } + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + return Err(PinakesError::ExternalTool { + tool: "ffprobe".into(), + stderr, + }); + } + + let json: serde_json::Value = serde_json::from_slice(&output.stdout) + .map_err(|e| { + PinakesError::ExternalTool { + tool: "ffprobe".into(), + stderr: format!("failed to parse output: {e}"), + } + })?; + + let streams = match json.get("streams").and_then(|s| s.as_array()) { + Some(s) => s, + None => return Ok(vec![]), + }; + + let mut tracks = Vec::new(); + for (idx, stream) in streams.iter().enumerate() { + let codec_name = stream + .get("codec_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let format = match codec_name { + "subrip" => SubtitleFormat::Srt, + "webvtt" => SubtitleFormat::Vtt, + "ass" | "ssa" => SubtitleFormat::Ass, + "hdmv_pgs_subtitle" | "pgssub" => SubtitleFormat::Pgs, + _ => continue, // skip unknown codec + }; + + let tags = stream.get("tags"); + let language = tags + .and_then(|t| t.get("language")) + .and_then(|v| v.as_str()) + .map(str::to_owned); + let title = tags + .and_then(|t| t.get("title")) + .and_then(|v| v.as_str()) + .map(str::to_owned); + + tracks.push(SubtitleTrackInfo { + index: idx as u32, + language, + format, + title, + }); + } + + Ok(tracks) +} + +/// Extracts an embedded subtitle track from a media file using ffmpeg. +/// +/// The caller must ensure the output directory exists before calling this +/// function. The output format is determined by the file extension of +/// `output_path`. +/// +/// # Errors +/// +/// Returns `PinakesError::ExternalTool` if ffmpeg is not available or exits +/// with a non-zero status. +pub async fn extract_embedded_track( + media_path: &Path, + track_index: u32, + output_path: &Path, +) -> Result<()> { + let output = tokio::process::Command::new("ffmpeg") + .args(["-v", "quiet", "-i"]) + .arg(media_path) + .args(["-map", &format!("0:s:{track_index}"), "-y"]) + .arg(output_path) + .output() + .await + .map_err(|e| { + PinakesError::ExternalTool { + tool: "ffmpeg".into(), + stderr: e.to_string(), + } + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + return Err(PinakesError::ExternalTool { + tool: "ffmpeg".into(), + stderr, + }); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::{SubtitleFormat, detect_format, validate_language_code}; + + #[test] + fn test_detect_format_srt() { + assert_eq!( + detect_format(Path::new("track.srt")), + Some(SubtitleFormat::Srt) + ); + } + + #[test] + fn test_detect_format_vtt() { + assert_eq!( + detect_format(Path::new("track.vtt")), + Some(SubtitleFormat::Vtt) + ); + } + + #[test] + fn test_detect_format_ass() { + assert_eq!( + detect_format(Path::new("track.ass")), + Some(SubtitleFormat::Ass) + ); + } + + #[test] + fn test_detect_format_ssa() { + assert_eq!( + detect_format(Path::new("track.ssa")), + Some(SubtitleFormat::Ssa) + ); + } + + #[test] + fn test_detect_format_pgs() { + assert_eq!( + detect_format(Path::new("track.pgs")), + Some(SubtitleFormat::Pgs) + ); + } + + #[test] + fn test_detect_format_sup() { + assert_eq!( + detect_format(Path::new("track.sup")), + Some(SubtitleFormat::Pgs) + ); + } + + #[test] + fn test_detect_format_unknown() { + assert_eq!(detect_format(Path::new("track.xyz")), None); + } + + #[test] + fn test_detect_format_no_extension() { + assert_eq!(detect_format(Path::new("track")), None); + } + + #[test] + fn test_detect_format_case_insensitive() { + assert_eq!( + detect_format(Path::new("track.SRT")), + Some(SubtitleFormat::Srt) + ); + assert_eq!( + detect_format(Path::new("track.VTT")), + Some(SubtitleFormat::Vtt) + ); + } + + #[test] + fn test_validate_language_code_simple() { + assert!(validate_language_code("en")); + } + + #[test] + fn test_validate_language_code_with_region() { + assert!(validate_language_code("en-US")); + } + + #[test] + fn test_validate_language_code_script() { + assert!(validate_language_code("zh-Hant")); + } + + #[test] + fn test_validate_language_code_full() { + assert!(validate_language_code("zh-Hant-TW")); + } + + #[test] + fn test_validate_language_code_empty() { + assert!(!validate_language_code("")); + } + + #[test] + fn test_validate_language_code_primary_too_long() { + assert!(!validate_language_code("toolong-tag-over-3-chars")); + } + + #[test] + fn test_validate_language_code_underscore_separator() { + assert!(!validate_language_code("en_US")); + } + + #[test] + fn test_validate_language_code_subtag_too_short() { + assert!(!validate_language_code("en-a")); + } + + #[test] + fn test_validate_language_code_three_letter_primary() { + assert!(validate_language_code("eng")); + } +} -- 2.43.0 From aa68d742c9bec6d648c82ea7f532801e6106b25c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Mar 2026 02:15:31 +0300 Subject: [PATCH 14/30] pinakes-core: fix minor clippy warnings; add toggle for Swagger UI generation Signed-off-by: NotAShelf Change-Id: Ie33a5d17b774289023e3855789d3adc86a6a6964 --- crates/pinakes-core/Cargo.toml | 3 +++ crates/pinakes-core/src/config.rs | 5 +++++ crates/pinakes-core/src/storage/postgres.rs | 4 ++-- crates/pinakes-core/src/storage/sqlite.rs | 6 ++---- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/pinakes-core/Cargo.toml b/crates/pinakes-core/Cargo.toml index 4304012..c4ba043 100644 --- a/crates/pinakes-core/Cargo.toml +++ b/crates/pinakes-core/Cargo.toml @@ -49,6 +49,9 @@ pinakes-plugin-api.workspace = true wasmtime.workspace = true ed25519-dalek.workspace = true +[features] +ffmpeg-tests = [] + [lints] workspace = true diff --git a/crates/pinakes-core/src/config.rs b/crates/pinakes-core/src/config.rs index ecc8ae8..325ff30 100644 --- a/crates/pinakes-core/src/config.rs +++ b/crates/pinakes-core/src/config.rs @@ -1126,6 +1126,10 @@ pub struct ServerConfig { /// TLS/HTTPS configuration #[serde(default)] pub tls: TlsConfig, + /// Enable the Swagger UI at /api/docs. + /// Defaults to true. Set to false to disable in production if desired. + #[serde(default = "default_true")] + pub swagger_ui: bool, } /// TLS/HTTPS configuration for secure connections @@ -1470,6 +1474,7 @@ impl Default for Config { cors_enabled: false, cors_origins: vec![], tls: TlsConfig::default(), + swagger_ui: true, }, ui: UiConfig::default(), accounts: AccountsConfig::default(), diff --git a/crates/pinakes-core/src/storage/postgres.rs b/crates/pinakes-core/src/storage/postgres.rs index ea962aa..dbeb0e2 100644 --- a/crates/pinakes-core/src/storage/postgres.rs +++ b/crates/pinakes-core/src/storage/postgres.rs @@ -3729,7 +3729,7 @@ impl StorageBackend for PostgresBackend { )) }) }) - .transpose()?; + .transpose()?; // u32 fits in i32 for any valid track index, error is a safeguard let offset_ms = i32::try_from(subtitle.offset_ms).map_err(|_| { PinakesError::InvalidOperation(format!( "subtitle offset_ms {} exceeds i32 range", @@ -3791,7 +3791,7 @@ impl StorageBackend for PostgresBackend { is_embedded: row.get("is_embedded"), track_index: row .get::<_, Option>("track_index") - .map(|i| usize::try_from(i).unwrap_or(0)), + .map(|i| u32::try_from(i).unwrap_or(0)), offset_ms: i64::from(row.get::<_, i32>("offset_ms")), created_at: row.get("created_at"), } diff --git a/crates/pinakes-core/src/storage/sqlite.rs b/crates/pinakes-core/src/storage/sqlite.rs index 46ce813..ecc1b00 100644 --- a/crates/pinakes-core/src/storage/sqlite.rs +++ b/crates/pinakes-core/src/storage/sqlite.rs @@ -4297,9 +4297,7 @@ impl StorageBackend for SqliteBackend { .as_ref() .map(|p| p.to_string_lossy().to_string()); let is_embedded = subtitle.is_embedded; - let track_index = subtitle - .track_index - .map(|i| i64::try_from(i).unwrap_or(i64::MAX)); + let track_index = subtitle.track_index.map(i64::from); let offset_ms = subtitle.offset_ms; let now = subtitle.created_at.to_rfc3339(); let fut = tokio::task::spawn_blocking(move || { @@ -4365,7 +4363,7 @@ impl StorageBackend for SqliteBackend { is_embedded: row.get::<_, i32>(5)? != 0, track_index: row .get::<_, Option>(6)? - .map(|i| usize::try_from(i).unwrap_or(0)), + .and_then(|i| u32::try_from(i).ok()), offset_ms: row.get(7)?, created_at: parse_datetime(&created_str), }) -- 2.43.0 From 67b832270575de599bc5792503224eec69693b58 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Mar 2026 02:16:07 +0300 Subject: [PATCH 15/30] pinakes-server: add utoipa annotations; manage embedded subtitle data Signed-off-by: NotAShelf Change-Id: I30d4b23f09113628dea245404b0a31bd6a6a6964 --- crates/pinakes-server/src/dto/subtitles.rs | 39 +++++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/crates/pinakes-server/src/dto/subtitles.rs b/crates/pinakes-server/src/dto/subtitles.rs index a3203ef..1b79e8a 100644 --- a/crates/pinakes-server/src/dto/subtitles.rs +++ b/crates/pinakes-server/src/dto/subtitles.rs @@ -1,14 +1,14 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct SubtitleResponse { pub id: String, pub media_id: String, pub language: Option, pub format: String, pub is_embedded: bool, - pub track_index: Option, + pub track_index: Option, pub offset_ms: i64, pub created_at: DateTime, } @@ -28,17 +28,46 @@ impl From for SubtitleResponse { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct AddSubtitleRequest { pub language: Option, pub format: String, pub file_path: Option, pub is_embedded: Option, - pub track_index: Option, + pub track_index: Option, pub offset_ms: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct UpdateSubtitleOffsetRequest { pub offset_ms: i64, } + +/// Information about an embedded subtitle track available for extraction. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct SubtitleTrackInfoResponse { + pub index: u32, + pub language: Option, + pub format: String, + pub title: Option, +} + +impl From + for SubtitleTrackInfoResponse +{ + fn from(t: pinakes_core::subtitles::SubtitleTrackInfo) -> Self { + Self { + index: t.index, + language: t.language, + format: t.format.to_string(), + title: t.title, + } + } +} + +/// Response for listing subtitles on a media item. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct SubtitleListResponse { + pub subtitles: Vec, + pub available_tracks: Vec, +} -- 2.43.0 From 9d58927cb467ab699bf2e62d46cc357e27ece84f Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Mar 2026 02:17:55 +0300 Subject: [PATCH 16/30] pinakes-server: add utoipa annotations to all routes; fix tests Signed-off-by: NotAShelf Change-Id: I28cf5b7b7cff8e90e123d624d97cf9656a6a6964 --- crates/pinakes-server/Cargo.toml | 3 + crates/pinakes-server/src/api_doc.rs | 486 ++++++++++++++++++ crates/pinakes-server/src/app.rs | 19 +- crates/pinakes-server/src/dto/analytics.rs | 5 +- crates/pinakes-server/src/dto/audit.rs | 2 +- crates/pinakes-server/src/dto/batch.rs | 10 +- crates/pinakes-server/src/dto/collections.rs | 6 +- crates/pinakes-server/src/dto/config.rs | 14 +- crates/pinakes-server/src/dto/enrichment.rs | 3 +- crates/pinakes-server/src/dto/media.rs | 61 ++- crates/pinakes-server/src/dto/playlists.rs | 10 +- crates/pinakes-server/src/dto/plugins.rs | 15 +- crates/pinakes-server/src/dto/scan.rs | 9 +- crates/pinakes-server/src/dto/search.rs | 8 +- crates/pinakes-server/src/dto/sharing.rs | 20 +- crates/pinakes-server/src/dto/social.rs | 14 +- crates/pinakes-server/src/dto/statistics.rs | 8 +- crates/pinakes-server/src/dto/sync.rs | 34 +- crates/pinakes-server/src/dto/tags.rs | 6 +- crates/pinakes-server/src/dto/transcode.rs | 4 +- crates/pinakes-server/src/dto/users.rs | 19 +- crates/pinakes-server/src/error.rs | 19 + crates/pinakes-server/src/lib.rs | 1 + crates/pinakes-server/src/routes/analytics.rs | 74 +++ crates/pinakes-server/src/routes/audit.rs | 15 + crates/pinakes-server/src/routes/auth.rs | 123 ++++- crates/pinakes-server/src/routes/backup.rs | 12 + crates/pinakes-server/src/routes/books.rs | 135 ++++- .../pinakes-server/src/routes/collections.rs | 97 ++++ crates/pinakes-server/src/routes/config.rs | 76 +++ crates/pinakes-server/src/routes/database.rs | 36 ++ .../pinakes-server/src/routes/duplicates.rs | 11 + .../pinakes-server/src/routes/enrichment.rs | 41 ++ crates/pinakes-server/src/routes/export.rs | 28 +- crates/pinakes-server/src/routes/health.rs | 45 +- crates/pinakes-server/src/routes/integrity.rs | 69 ++- crates/pinakes-server/src/routes/jobs.rs | 37 ++ crates/pinakes-server/src/routes/media.rs | 409 +++++++++++++++ crates/pinakes-server/src/routes/notes.rs | 96 +++- crates/pinakes-server/src/routes/photos.rs | 39 +- crates/pinakes-server/src/routes/playlists.rs | 135 +++++ crates/pinakes-server/src/routes/plugins.rs | 117 +++++ .../src/routes/saved_searches.rs | 41 +- crates/pinakes-server/src/routes/scan.rs | 23 + .../src/routes/scheduled_tasks.rs | 37 ++ crates/pinakes-server/src/routes/search.rs | 31 ++ crates/pinakes-server/src/routes/shares.rs | 156 ++++++ crates/pinakes-server/src/routes/social.rs | 114 ++++ .../pinakes-server/src/routes/statistics.rs | 11 + crates/pinakes-server/src/routes/streaming.rs | 75 +++ crates/pinakes-server/src/routes/subtitles.rs | 281 ++++++++-- crates/pinakes-server/src/routes/sync.rs | 217 ++++++++ crates/pinakes-server/src/routes/tags.rs | 97 ++++ crates/pinakes-server/src/routes/transcode.rs | 48 ++ crates/pinakes-server/src/routes/upload.rs | 49 ++ crates/pinakes-server/src/routes/users.rs | 114 ++++ crates/pinakes-server/src/routes/webhooks.rs | 24 +- crates/pinakes-server/tests/books.rs | 18 +- crates/pinakes-server/tests/common/mod.rs | 1 + crates/pinakes-server/tests/notes.rs | 27 +- 60 files changed, 3493 insertions(+), 242 deletions(-) create mode 100644 crates/pinakes-server/src/api_doc.rs diff --git a/crates/pinakes-server/Cargo.toml b/crates/pinakes-server/Cargo.toml index d6f42a2..d96001c 100644 --- a/crates/pinakes-server/Cargo.toml +++ b/crates/pinakes-server/Cargo.toml @@ -32,6 +32,9 @@ rand = { workspace = true } percent-encoding = { workspace = true } http = { workspace = true } rustc-hash = { workspace = true } +utoipa = { workspace = true } +utoipa-axum = { workspace = true } +utoipa-swagger-ui = { workspace = true } [lints] workspace = true diff --git a/crates/pinakes-server/src/api_doc.rs b/crates/pinakes-server/src/api_doc.rs new file mode 100644 index 0000000..c4a8e4d --- /dev/null +++ b/crates/pinakes-server/src/api_doc.rs @@ -0,0 +1,486 @@ +use utoipa::OpenApi; + +/// Central OpenAPI document registry. +/// Handler functions and schemas are added here as route modules are annotated. +#[derive(OpenApi)] +#[openapi( + info( + title = "Pinakes API", + version = env!("CARGO_PKG_VERSION"), + description = "Media cataloging and library management API" + ), + paths( + // analytics + crate::routes::analytics::get_most_viewed, + crate::routes::analytics::get_recently_viewed, + crate::routes::analytics::record_event, + crate::routes::analytics::get_watch_progress, + crate::routes::analytics::update_watch_progress, + // audit + crate::routes::audit::list_audit, + // auth + crate::routes::auth::login, + crate::routes::auth::logout, + crate::routes::auth::me, + crate::routes::auth::refresh, + crate::routes::auth::revoke_all_sessions, + crate::routes::auth::list_active_sessions, + // backup + crate::routes::backup::create_backup, + // books + crate::routes::books::get_book_metadata, + crate::routes::books::list_books, + crate::routes::books::list_series, + crate::routes::books::get_series_books, + crate::routes::books::list_authors, + crate::routes::books::get_author_books, + crate::routes::books::get_reading_progress, + crate::routes::books::update_reading_progress, + crate::routes::books::get_reading_list, + // collections + crate::routes::collections::create_collection, + crate::routes::collections::list_collections, + crate::routes::collections::get_collection, + crate::routes::collections::delete_collection, + crate::routes::collections::add_member, + crate::routes::collections::remove_member, + crate::routes::collections::get_members, + // config + crate::routes::config::get_config, + crate::routes::config::get_ui_config, + crate::routes::config::update_ui_config, + crate::routes::config::update_scanning_config, + crate::routes::config::add_root, + crate::routes::config::remove_root, + // database + crate::routes::database::database_stats, + crate::routes::database::vacuum_database, + crate::routes::database::clear_database, + // duplicates + crate::routes::duplicates::list_duplicates, + // enrichment + crate::routes::enrichment::trigger_enrichment, + crate::routes::enrichment::get_external_metadata, + crate::routes::enrichment::batch_enrich, + // export + crate::routes::export::trigger_export, + crate::routes::export::trigger_export_with_options, + // health + crate::routes::health::health, + crate::routes::health::liveness, + crate::routes::health::readiness, + crate::routes::health::health_detailed, + // integrity + crate::routes::integrity::trigger_orphan_detection, + crate::routes::integrity::trigger_verify_integrity, + crate::routes::integrity::trigger_cleanup_thumbnails, + crate::routes::integrity::generate_all_thumbnails, + crate::routes::integrity::resolve_orphans, + // jobs + crate::routes::jobs::list_jobs, + crate::routes::jobs::get_job, + crate::routes::jobs::cancel_job, + // media + crate::routes::media::import_media, + crate::routes::media::list_media, + crate::routes::media::get_media, + crate::routes::media::update_media, + crate::routes::media::delete_media, + crate::routes::media::open_media, + crate::routes::media::import_with_options, + crate::routes::media::batch_import, + crate::routes::media::import_directory_endpoint, + crate::routes::media::preview_directory, + crate::routes::media::set_custom_field, + crate::routes::media::delete_custom_field, + crate::routes::media::batch_tag, + crate::routes::media::delete_all_media, + crate::routes::media::batch_delete, + crate::routes::media::batch_add_to_collection, + crate::routes::media::batch_update, + crate::routes::media::get_thumbnail, + crate::routes::media::get_media_count, + crate::routes::media::rename_media, + crate::routes::media::move_media_endpoint, + crate::routes::media::batch_move_media, + crate::routes::media::soft_delete_media, + crate::routes::media::restore_media, + crate::routes::media::list_trash, + crate::routes::media::trash_info, + crate::routes::media::empty_trash, + crate::routes::media::permanent_delete_media, + crate::routes::media::stream_media, + // notes + crate::routes::notes::get_backlinks, + crate::routes::notes::get_outgoing_links, + crate::routes::notes::get_graph, + crate::routes::notes::reindex_links, + crate::routes::notes::resolve_links, + crate::routes::notes::get_unresolved_count, + // photos + crate::routes::photos::get_timeline, + crate::routes::photos::get_map_photos, + // playlists + crate::routes::playlists::create_playlist, + crate::routes::playlists::list_playlists, + crate::routes::playlists::get_playlist, + crate::routes::playlists::update_playlist, + crate::routes::playlists::delete_playlist, + crate::routes::playlists::add_item, + crate::routes::playlists::remove_item, + crate::routes::playlists::list_items, + crate::routes::playlists::reorder_item, + crate::routes::playlists::shuffle_playlist, + // plugins + crate::routes::plugins::list_plugins, + crate::routes::plugins::get_plugin, + crate::routes::plugins::install_plugin, + crate::routes::plugins::uninstall_plugin, + crate::routes::plugins::toggle_plugin, + crate::routes::plugins::list_plugin_ui_pages, + crate::routes::plugins::list_plugin_ui_widgets, + crate::routes::plugins::emit_plugin_event, + crate::routes::plugins::list_plugin_ui_theme_extensions, + crate::routes::plugins::reload_plugin, + // saved_searches + crate::routes::saved_searches::create_saved_search, + crate::routes::saved_searches::list_saved_searches, + crate::routes::saved_searches::delete_saved_search, + // scan + crate::routes::scan::trigger_scan, + crate::routes::scan::scan_status, + // scheduled_tasks + crate::routes::scheduled_tasks::list_scheduled_tasks, + crate::routes::scheduled_tasks::toggle_scheduled_task, + crate::routes::scheduled_tasks::run_scheduled_task_now, + // search + crate::routes::search::search, + crate::routes::search::search_post, + // shares + crate::routes::shares::create_share, + crate::routes::shares::list_outgoing, + crate::routes::shares::list_incoming, + crate::routes::shares::get_share, + crate::routes::shares::update_share, + crate::routes::shares::delete_share, + crate::routes::shares::batch_delete, + crate::routes::shares::access_shared, + crate::routes::shares::get_activity, + crate::routes::shares::get_notifications, + crate::routes::shares::mark_notification_read, + crate::routes::shares::mark_all_read, + // social + crate::routes::social::rate_media, + crate::routes::social::get_media_ratings, + crate::routes::social::add_comment, + crate::routes::social::get_media_comments, + crate::routes::social::add_favorite, + crate::routes::social::remove_favorite, + crate::routes::social::list_favorites, + crate::routes::social::create_share_link, + crate::routes::social::access_shared_media, + // statistics + crate::routes::statistics::library_statistics, + // streaming + crate::routes::streaming::hls_master_playlist, + crate::routes::streaming::hls_variant_playlist, + crate::routes::streaming::hls_segment, + crate::routes::streaming::dash_manifest, + crate::routes::streaming::dash_segment, + // subtitles + crate::routes::subtitles::list_subtitles, + crate::routes::subtitles::add_subtitle, + crate::routes::subtitles::delete_subtitle, + crate::routes::subtitles::get_subtitle_content, + crate::routes::subtitles::update_offset, + // sync + crate::routes::sync::register_device, + crate::routes::sync::list_devices, + crate::routes::sync::get_device, + crate::routes::sync::update_device, + crate::routes::sync::delete_device, + crate::routes::sync::regenerate_token, + crate::routes::sync::get_changes, + crate::routes::sync::report_changes, + crate::routes::sync::acknowledge_changes, + crate::routes::sync::list_conflicts, + crate::routes::sync::resolve_conflict, + crate::routes::sync::create_upload, + crate::routes::sync::upload_chunk, + crate::routes::sync::get_upload_status, + crate::routes::sync::complete_upload, + crate::routes::sync::cancel_upload, + crate::routes::sync::download_file, + // tags + crate::routes::tags::create_tag, + crate::routes::tags::list_tags, + crate::routes::tags::get_tag, + crate::routes::tags::delete_tag, + crate::routes::tags::tag_media, + crate::routes::tags::untag_media, + crate::routes::tags::get_media_tags, + // transcode + crate::routes::transcode::start_transcode, + crate::routes::transcode::get_session, + crate::routes::transcode::list_sessions, + crate::routes::transcode::cancel_session, + // upload + crate::routes::upload::upload_file, + crate::routes::upload::download_file, + crate::routes::upload::move_to_managed, + crate::routes::upload::managed_stats, + // users + crate::routes::users::list_users, + crate::routes::users::create_user, + crate::routes::users::get_user, + crate::routes::users::update_user, + crate::routes::users::delete_user, + crate::routes::users::get_user_libraries, + crate::routes::users::grant_library_access, + crate::routes::users::revoke_library_access, + // webhooks + crate::routes::webhooks::list_webhooks, + crate::routes::webhooks::test_webhook, + ), + components( + schemas( + // analytics DTOs + crate::dto::UsageEventResponse, + crate::dto::RecordUsageEventRequest, + // audit DTOs + crate::dto::AuditEntryResponse, + // auth local types + crate::routes::auth::SessionListResponse, + crate::routes::auth::SessionInfo, + // batch DTOs + crate::dto::BatchTagRequest, + crate::dto::BatchCollectionRequest, + crate::dto::BatchDeleteRequest, + crate::dto::BatchUpdateRequest, + crate::dto::BatchOperationResponse, + // books local types + crate::routes::books::BookMetadataResponse, + crate::routes::books::AuthorResponse, + crate::routes::books::ReadingProgressResponse, + crate::routes::books::UpdateProgressRequest, + crate::routes::books::SeriesSummary, + crate::routes::books::AuthorSummary, + // collections DTOs + crate::dto::CollectionResponse, + crate::dto::CreateCollectionRequest, + crate::dto::AddMemberRequest, + // config DTOs + crate::dto::ConfigResponse, + crate::dto::ScanningConfigResponse, + crate::dto::ServerConfigResponse, + crate::dto::UpdateScanningRequest, + crate::dto::RootDirRequest, + crate::dto::UiConfigResponse, + crate::dto::UpdateUiConfigRequest, + // database DTOs + crate::dto::DatabaseStatsResponse, + // duplicate DTOs + crate::dto::DuplicateGroupResponse, + // enrichment DTOs + crate::dto::ExternalMetadataResponse, + // export local types + crate::routes::export::ExportRequest, + // health local types + crate::routes::health::HealthResponse, + crate::routes::health::DatabaseHealth, + crate::routes::health::FilesystemHealth, + crate::routes::health::CacheHealth, + crate::routes::health::DetailedHealthResponse, + crate::routes::health::JobsHealth, + // integrity local types + crate::routes::integrity::OrphanResolveRequest, + crate::routes::integrity::VerifyIntegrityRequest, + crate::routes::integrity::GenerateThumbnailsRequest, + // media DTOs + crate::dto::MediaResponse, + crate::dto::CustomFieldResponse, + crate::dto::ImportRequest, + crate::dto::ImportWithOptionsRequest, + crate::dto::DirectoryImportRequest, + crate::dto::DirectoryPreviewResponse, + crate::dto::UpdateMediaRequest, + crate::dto::MoveMediaRequest, + crate::dto::RenameMediaRequest, + crate::dto::BatchMoveRequest, + crate::dto::BatchImportRequest, + crate::dto::SetCustomFieldRequest, + crate::dto::MediaCountResponse, + crate::dto::TrashInfoResponse, + crate::dto::ImportResponse, + crate::dto::TrashResponse, + crate::dto::EmptyTrashResponse, + crate::dto::BatchImportResponse, + crate::dto::BatchImportItemResult, + crate::dto::DirectoryPreviewFile, + crate::dto::UpdateMediaFullRequest, + crate::dto::OpenRequest, + crate::dto::WatchProgressRequest, + crate::dto::WatchProgressResponse, + // notes local types + crate::routes::notes::BacklinksResponse, + crate::routes::notes::BacklinkItem, + crate::routes::notes::OutgoingLinksResponse, + crate::routes::notes::OutgoingLinkItem, + crate::routes::notes::GraphResponse, + crate::routes::notes::GraphNodeResponse, + crate::routes::notes::GraphEdgeResponse, + crate::routes::notes::ReindexResponse, + crate::routes::notes::ResolveLinksResponse, + crate::routes::notes::UnresolvedLinksResponse, + // photos local types + crate::routes::photos::TimelineGroup, + crate::routes::photos::MapMarker, + // playlists DTOs + crate::dto::PlaylistResponse, + crate::dto::CreatePlaylistRequest, + crate::dto::UpdatePlaylistRequest, + crate::dto::PlaylistItemRequest, + crate::dto::ReorderPlaylistRequest, + // plugins DTOs + crate::dto::PluginResponse, + crate::dto::InstallPluginRequest, + crate::dto::TogglePluginRequest, + crate::dto::PluginUiPageEntry, + crate::dto::PluginUiWidgetEntry, + crate::dto::PluginEventRequest, + // saved_searches local types + crate::routes::saved_searches::CreateSavedSearchRequest, + crate::routes::saved_searches::SavedSearchResponse, + // scan DTOs + crate::dto::ScanRequest, + crate::dto::ScanResponse, + crate::dto::ScanJobResponse, + crate::dto::ScanStatusResponse, + // search DTOs + crate::dto::SearchParams, + crate::dto::SearchResponse, + crate::dto::SearchRequestBody, + crate::dto::PaginationParams, + // sharing DTOs + crate::dto::CreateShareRequest, + crate::dto::UpdateShareRequest, + crate::dto::ShareResponse, + crate::dto::SharePermissionsRequest, + crate::dto::BatchDeleteSharesRequest, + crate::dto::AccessSharedRequest, + crate::dto::SharedContentResponse, + crate::dto::ShareActivityResponse, + crate::dto::ShareNotificationResponse, + // social DTOs + crate::dto::RatingResponse, + crate::dto::CreateRatingRequest, + crate::dto::CommentResponse, + crate::dto::CreateCommentRequest, + crate::dto::FavoriteRequest, + crate::dto::CreateShareLinkRequest, + crate::dto::ShareLinkResponse, + // statistics DTOs + crate::dto::LibraryStatisticsResponse, + crate::dto::TypeCountResponse, + crate::dto::ScheduledTaskResponse, + // subtitles DTOs + crate::dto::SubtitleResponse, + crate::dto::AddSubtitleRequest, + crate::dto::UpdateSubtitleOffsetRequest, + crate::dto::SubtitleListResponse, + crate::dto::SubtitleTrackInfoResponse, + // sync DTOs + crate::dto::RegisterDeviceRequest, + crate::dto::DeviceResponse, + crate::dto::DeviceRegistrationResponse, + crate::dto::UpdateDeviceRequest, + crate::dto::GetChangesParams, + crate::dto::SyncChangeResponse, + crate::dto::ChangesResponse, + crate::dto::ReportChangesRequest, + crate::dto::ReportChangesResponse, + crate::dto::AcknowledgeChangesRequest, + crate::dto::ConflictResponse, + crate::dto::ResolveConflictRequest, + crate::dto::CreateUploadSessionRequest, + crate::dto::UploadSessionResponse, + crate::dto::ChunkUploadedResponse, + crate::dto::MostViewedResponse, + // tags DTOs + crate::dto::TagResponse, + crate::dto::CreateTagRequest, + crate::dto::TagMediaRequest, + // transcode DTOs + crate::dto::TranscodeSessionResponse, + crate::dto::CreateTranscodeRequest, + // upload DTOs + crate::dto::UploadResponse, + crate::dto::ManagedStorageStatsResponse, + // users DTOs + crate::dto::UserResponse, + crate::dto::UserLibraryResponse, + crate::dto::GrantLibraryAccessRequest, + crate::dto::RevokeLibraryAccessRequest, + // webhooks local types + crate::routes::webhooks::WebhookInfo, + ) + ), + tags( + (name = "analytics", description = "Usage analytics and viewing history"), + (name = "audit", description = "Audit log entries"), + (name = "auth", description = "Authentication and session management"), + (name = "backup", description = "Database backup"), + (name = "books", description = "Book metadata, series, authors, and reading progress"), + (name = "collections", description = "Media collections"), + (name = "config", description = "Server configuration"), + (name = "database", description = "Database administration"), + (name = "duplicates", description = "Duplicate media detection"), + (name = "enrichment", description = "External metadata enrichment"), + (name = "export", description = "Media library export"), + (name = "health", description = "Server health checks"), + (name = "integrity", description = "Library integrity checks and repairs"), + (name = "jobs", description = "Background job management"), + (name = "media", description = "Media item management"), + (name = "notes", description = "Markdown notes link graph"), + (name = "photos", description = "Photo timeline and map view"), + (name = "playlists", description = "Media playlists"), + (name = "plugins", description = "Plugin management"), + (name = "saved_searches", description = "Saved search queries"), + (name = "scan", description = "Directory scanning"), + (name = "scheduled_tasks", description = "Scheduled background tasks"), + (name = "search", description = "Full-text media search"), + (name = "shares", description = "Media sharing and notifications"), + (name = "social", description = "Ratings, comments, favorites, and share links"), + (name = "statistics", description = "Library statistics"), + (name = "streaming", description = "HLS and DASH adaptive streaming"), + (name = "subtitles", description = "Media subtitle management"), + (name = "sync", description = "Multi-device library synchronization"), + (name = "tags", description = "Media tag management"), + (name = "transcode", description = "Video transcoding sessions"), + (name = "upload", description = "File upload and managed storage"), + (name = "users", description = "User and library access management"), + (name = "webhooks", description = "Webhook configuration"), + ), + security( + ("bearer_auth" = []) + ), + modifiers(&SecurityAddon) +)] +pub struct ApiDoc; + +struct SecurityAddon; + +impl utoipa::Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + if let Some(components) = openapi.components.as_mut() { + components.add_security_scheme( + "bearer_auth", + utoipa::openapi::security::SecurityScheme::Http( + utoipa::openapi::security::Http::new( + utoipa::openapi::security::HttpAuthScheme::Bearer, + ), + ), + ); + } + } +} diff --git a/crates/pinakes-server/src/app.rs b/crates/pinakes-server/src/app.rs index f32431c..ed859d0 100644 --- a/crates/pinakes-server/src/app.rs +++ b/crates/pinakes-server/src/app.rs @@ -14,8 +14,10 @@ use tower_http::{ set_header::SetResponseHeaderLayer, trace::TraceLayer, }; +use utoipa::OpenApi as _; +use utoipa_swagger_ui::SwaggerUi; -use crate::{auth, routes, state::AppState}; +use crate::{api_doc::ApiDoc, auth, routes, state::AppState}; /// Create the router with optional TLS configuration for HSTS headers pub fn create_router( @@ -51,6 +53,11 @@ pub fn create_router_with_tls( rate_limits: &pinakes_core::config::RateLimitConfig, tls_config: Option<&pinakes_core::config::TlsConfig>, ) -> Router { + let swagger_ui_enabled = state + .config + .try_read() + .map_or(false, |cfg| cfg.server.swagger_ui); + let global_governor = build_governor( rate_limits.global_per_second, rate_limits.global_burst_size, @@ -605,7 +612,7 @@ pub fn create_router_with_tls( HeaderValue::from_static("default-src 'none'; frame-ancestors 'none'"), )); - let router = Router::new() + let base_router = Router::new() .nest("/api/v1", full_api) .layer(DefaultBodyLimit::max(10 * 1024 * 1024)) .layer(GovernorLayer::new(global_governor)) @@ -613,6 +620,14 @@ pub fn create_router_with_tls( .layer(cors) .layer(security_headers); + let router = if swagger_ui_enabled { + base_router.merge( + SwaggerUi::new("/api/docs").url("/api/openapi.json", ApiDoc::openapi()), + ) + } else { + base_router + }; + // Add HSTS header when TLS is enabled if let Some(tls) = tls_config { if tls.enabled && tls.hsts_enabled { diff --git a/crates/pinakes-server/src/dto/analytics.rs b/crates/pinakes-server/src/dto/analytics.rs index c68289d..456d8fe 100644 --- a/crates/pinakes-server/src/dto/analytics.rs +++ b/crates/pinakes-server/src/dto/analytics.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct UsageEventResponse { pub id: String, pub media_id: Option, @@ -25,10 +25,11 @@ impl From for UsageEventResponse { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct RecordUsageEventRequest { pub media_id: Option, pub event_type: String, pub duration_secs: Option, + #[schema(value_type = Option)] pub context: Option, } diff --git a/crates/pinakes-server/src/dto/audit.rs b/crates/pinakes-server/src/dto/audit.rs index 7a71fd0..d0df363 100644 --- a/crates/pinakes-server/src/dto/audit.rs +++ b/crates/pinakes-server/src/dto/audit.rs @@ -1,7 +1,7 @@ use chrono::{DateTime, Utc}; use serde::Serialize; -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct AuditEntryResponse { pub id: String, pub media_id: Option, diff --git a/crates/pinakes-server/src/dto/batch.rs b/crates/pinakes-server/src/dto/batch.rs index 0389762..a56596c 100644 --- a/crates/pinakes-server/src/dto/batch.rs +++ b/crates/pinakes-server/src/dto/batch.rs @@ -1,24 +1,24 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct BatchTagRequest { pub media_ids: Vec, pub tag_ids: Vec, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct BatchCollectionRequest { pub media_ids: Vec, pub collection_id: Uuid, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct BatchDeleteRequest { pub media_ids: Vec, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct BatchUpdateRequest { pub media_ids: Vec, pub title: Option, @@ -29,7 +29,7 @@ pub struct BatchUpdateRequest { pub description: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct BatchOperationResponse { pub processed: usize, pub errors: Vec, diff --git a/crates/pinakes-server/src/dto/collections.rs b/crates/pinakes-server/src/dto/collections.rs index 04adcdb..14b8a86 100644 --- a/crates/pinakes-server/src/dto/collections.rs +++ b/crates/pinakes-server/src/dto/collections.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct CollectionResponse { pub id: String, pub name: String, @@ -13,7 +13,7 @@ pub struct CollectionResponse { pub updated_at: DateTime, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct CreateCollectionRequest { pub name: String, pub kind: String, @@ -21,7 +21,7 @@ pub struct CreateCollectionRequest { pub filter_query: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct AddMemberRequest { pub media_id: Uuid, pub position: Option, diff --git a/crates/pinakes-server/src/dto/config.rs b/crates/pinakes-server/src/dto/config.rs index 024c683..6252c53 100644 --- a/crates/pinakes-server/src/dto/config.rs +++ b/crates/pinakes-server/src/dto/config.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ConfigResponse { pub backend: String, pub database_path: Option, @@ -12,33 +12,33 @@ pub struct ConfigResponse { pub config_writable: bool, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ScanningConfigResponse { pub watch: bool, pub poll_interval_secs: u64, pub ignore_patterns: Vec, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ServerConfigResponse { pub host: String, pub port: u16, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct UpdateScanningRequest { pub watch: Option, pub poll_interval_secs: Option, pub ignore_patterns: Option>, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct RootDirRequest { pub path: String, } // UI Config -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)] pub struct UiConfigResponse { pub theme: String, pub default_view: String, @@ -49,7 +49,7 @@ pub struct UiConfigResponse { pub sidebar_collapsed: bool, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct UpdateUiConfigRequest { pub theme: Option, pub default_view: Option, diff --git a/crates/pinakes-server/src/dto/enrichment.rs b/crates/pinakes-server/src/dto/enrichment.rs index c1ec7b0..4e144de 100644 --- a/crates/pinakes-server/src/dto/enrichment.rs +++ b/crates/pinakes-server/src/dto/enrichment.rs @@ -1,12 +1,13 @@ use chrono::{DateTime, Utc}; use serde::Serialize; -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ExternalMetadataResponse { pub id: String, pub media_id: String, pub source: String, pub external_id: Option, + #[schema(value_type = Object)] pub metadata: serde_json::Value, pub confidence: f64, pub last_updated: DateTime, diff --git a/crates/pinakes-server/src/dto/media.rs b/crates/pinakes-server/src/dto/media.rs index ffed427..8e269ab 100644 --- a/crates/pinakes-server/src/dto/media.rs +++ b/crates/pinakes-server/src/dto/media.rs @@ -34,7 +34,7 @@ pub fn relativize_path(full_path: &Path, roots: &[PathBuf]) -> String { full_path.to_string_lossy().into_owned() } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct MediaResponse { pub id: String, pub path: String, @@ -50,6 +50,7 @@ pub struct MediaResponse { pub duration_secs: Option, pub description: Option, pub has_thumbnail: bool, + #[schema(value_type = Object)] pub custom_fields: FxHashMap, // Photo-specific metadata @@ -67,24 +68,25 @@ pub struct MediaResponse { pub links_extracted_at: Option>, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct CustomFieldResponse { pub field_type: String, pub value: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct ImportRequest { + #[schema(value_type = String)] pub path: PathBuf, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ImportResponse { pub media_id: String, pub was_duplicate: bool, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct UpdateMediaRequest { pub title: Option, pub artist: Option, @@ -95,56 +97,60 @@ pub struct UpdateMediaRequest { } // File Management -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct RenameMediaRequest { pub new_name: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct MoveMediaRequest { + #[schema(value_type = String)] pub destination: PathBuf, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct BatchMoveRequest { pub media_ids: Vec, + #[schema(value_type = String)] pub destination: PathBuf, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct TrashResponse { pub items: Vec, pub total_count: u64, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct TrashInfoResponse { pub count: u64, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct EmptyTrashResponse { pub deleted_count: u64, } // Enhanced Import -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct ImportWithOptionsRequest { + #[schema(value_type = String)] pub path: PathBuf, pub tag_ids: Option>, pub new_tags: Option>, pub collection_id: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct BatchImportRequest { + #[schema(value_type = Vec)] pub paths: Vec, pub tag_ids: Option>, pub new_tags: Option>, pub collection_id: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct BatchImportResponse { pub results: Vec, pub total: usize, @@ -153,7 +159,7 @@ pub struct BatchImportResponse { pub errors: usize, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct BatchImportItemResult { pub path: String, pub media_id: Option, @@ -161,22 +167,23 @@ pub struct BatchImportItemResult { pub error: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct DirectoryImportRequest { + #[schema(value_type = String)] pub path: PathBuf, pub tag_ids: Option>, pub new_tags: Option>, pub collection_id: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct DirectoryPreviewResponse { pub files: Vec, pub total_count: usize, pub total_size: u64, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct DirectoryPreviewFile { pub path: String, pub file_name: String, @@ -185,7 +192,7 @@ pub struct DirectoryPreviewFile { } // Custom Fields -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct SetCustomFieldRequest { pub name: String, pub field_type: String, @@ -193,7 +200,7 @@ pub struct SetCustomFieldRequest { } // Media update extended -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct UpdateMediaFullRequest { pub title: Option, pub artist: Option, @@ -204,26 +211,26 @@ pub struct UpdateMediaFullRequest { } // Search with sort -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct MediaCountResponse { pub count: u64, } // Duplicates -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct DuplicateGroupResponse { pub content_hash: String, pub items: Vec, } // Open -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct OpenRequest { pub media_id: Uuid, } // Upload -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct UploadResponse { pub media_id: String, pub content_hash: String, @@ -242,7 +249,7 @@ impl From for UploadResponse { } } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ManagedStorageStatsResponse { pub total_blobs: u64, pub total_size_bytes: u64, @@ -368,12 +375,12 @@ mod tests { } // Watch progress -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct WatchProgressRequest { pub progress_secs: f64, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct WatchProgressResponse { pub progress_secs: f64, } diff --git a/crates/pinakes-server/src/dto/playlists.rs b/crates/pinakes-server/src/dto/playlists.rs index af387b1..c054df5 100644 --- a/crates/pinakes-server/src/dto/playlists.rs +++ b/crates/pinakes-server/src/dto/playlists.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct PlaylistResponse { pub id: String, pub owner_id: String, @@ -31,7 +31,7 @@ impl From for PlaylistResponse { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct CreatePlaylistRequest { pub name: String, pub description: Option, @@ -40,20 +40,20 @@ pub struct CreatePlaylistRequest { pub filter_query: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct UpdatePlaylistRequest { pub name: Option, pub description: Option, pub is_public: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct PlaylistItemRequest { pub media_id: Uuid, pub position: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct ReorderPlaylistRequest { pub media_id: Uuid, pub new_position: i32, diff --git a/crates/pinakes-server/src/dto/plugins.rs b/crates/pinakes-server/src/dto/plugins.rs index a80a1f2..661111a 100644 --- a/crates/pinakes-server/src/dto/plugins.rs +++ b/crates/pinakes-server/src/dto/plugins.rs @@ -1,7 +1,7 @@ use pinakes_plugin_api::{UiPage, UiWidget}; use serde::{Deserialize, Serialize}; -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct PluginResponse { pub id: String, pub name: String, @@ -12,22 +12,23 @@ pub struct PluginResponse { pub enabled: bool, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct InstallPluginRequest { pub source: String, // URL or file path } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct TogglePluginRequest { pub enabled: bool, } /// A single plugin UI page entry in the list response -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct PluginUiPageEntry { /// Plugin ID that provides this page pub plugin_id: String, /// Full page definition + #[schema(value_type = Object)] pub page: UiPage, /// Endpoint paths this plugin is allowed to fetch (empty means no /// restriction) @@ -35,19 +36,21 @@ pub struct PluginUiPageEntry { } /// A single plugin UI widget entry in the list response -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct PluginUiWidgetEntry { /// Plugin ID that provides this widget pub plugin_id: String, /// Full widget definition + #[schema(value_type = Object)] pub widget: UiWidget, } /// Request body for emitting a plugin event -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct PluginEventRequest { pub event: String, #[serde(default)] + #[schema(value_type = Object)] pub payload: serde_json::Value, } diff --git a/crates/pinakes-server/src/dto/scan.rs b/crates/pinakes-server/src/dto/scan.rs index 86c4805..c546e0b 100644 --- a/crates/pinakes-server/src/dto/scan.rs +++ b/crates/pinakes-server/src/dto/scan.rs @@ -2,24 +2,25 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct ScanRequest { + #[schema(value_type = Option)] pub path: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ScanResponse { pub files_found: usize, pub files_processed: usize, pub errors: Vec, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ScanJobResponse { pub job_id: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ScanStatusResponse { pub scanning: bool, pub files_found: usize, diff --git a/crates/pinakes-server/src/dto/search.rs b/crates/pinakes-server/src/dto/search.rs index dfe2576..9421bef 100644 --- a/crates/pinakes-server/src/dto/search.rs +++ b/crates/pinakes-server/src/dto/search.rs @@ -9,7 +9,7 @@ pub const MAX_OFFSET: u64 = 10_000_000; /// Maximum page size accepted from most listing endpoints. pub const MAX_LIMIT: u64 = 1000; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct SearchParams { pub q: String, pub sort: Option, @@ -28,14 +28,14 @@ impl SearchParams { } } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct SearchResponse { pub items: Vec, pub total_count: u64, } // Search (POST body) -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct SearchRequestBody { pub q: String, pub sort: Option, @@ -55,7 +55,7 @@ impl SearchRequestBody { } // Pagination -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct PaginationParams { pub offset: Option, pub limit: Option, diff --git a/crates/pinakes-server/src/dto/sharing.rs b/crates/pinakes-server/src/dto/sharing.rs index 60f60ab..4757e26 100644 --- a/crates/pinakes-server/src/dto/sharing.rs +++ b/crates/pinakes-server/src/dto/sharing.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct CreateShareRequest { pub target_type: String, pub target_id: String, @@ -16,7 +16,7 @@ pub struct CreateShareRequest { pub inherit_to_children: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct SharePermissionsRequest { pub can_view: Option, pub can_download: Option, @@ -26,7 +26,7 @@ pub struct SharePermissionsRequest { pub can_add: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ShareResponse { pub id: String, pub target_type: String, @@ -46,7 +46,7 @@ pub struct ShareResponse { pub updated_at: DateTime, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct SharePermissionsResponse { pub can_view: bool, pub can_download: bool, @@ -125,7 +125,7 @@ impl From for ShareResponse { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct UpdateShareRequest { pub permissions: Option, pub note: Option, @@ -133,7 +133,7 @@ pub struct UpdateShareRequest { pub inherit_to_children: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ShareActivityResponse { pub id: String, pub share_id: String, @@ -158,7 +158,7 @@ impl From for ShareActivityResponse { } } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ShareNotificationResponse { pub id: String, pub share_id: String, @@ -181,12 +181,12 @@ impl From } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct BatchDeleteSharesRequest { pub share_ids: Vec, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct AccessSharedRequest { pub password: Option, } @@ -194,7 +194,7 @@ pub struct AccessSharedRequest { /// Response for accessing shared content. /// Single-media shares return the media object directly (backwards compatible). /// Collection/Tag/SavedSearch shares return a list of items. -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] #[serde(untagged)] pub enum SharedContentResponse { Single(super::MediaResponse), diff --git a/crates/pinakes-server/src/dto/social.rs b/crates/pinakes-server/src/dto/social.rs index 43f192f..d52d85d 100644 --- a/crates/pinakes-server/src/dto/social.rs +++ b/crates/pinakes-server/src/dto/social.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct RatingResponse { pub id: String, pub user_id: String, @@ -25,13 +25,13 @@ impl From for RatingResponse { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct CreateRatingRequest { pub stars: u8, pub review_text: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct CommentResponse { pub id: String, pub user_id: String, @@ -54,25 +54,25 @@ impl From for CommentResponse { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct CreateCommentRequest { pub text: String, pub parent_id: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct FavoriteRequest { pub media_id: Uuid, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct CreateShareLinkRequest { pub media_id: Uuid, pub password: Option, pub expires_in_hours: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ShareLinkResponse { pub id: String, pub media_id: String, diff --git a/crates/pinakes-server/src/dto/statistics.rs b/crates/pinakes-server/src/dto/statistics.rs index b5d573e..a430409 100644 --- a/crates/pinakes-server/src/dto/statistics.rs +++ b/crates/pinakes-server/src/dto/statistics.rs @@ -1,7 +1,7 @@ use serde::Serialize; // Library Statistics -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct LibraryStatisticsResponse { pub total_media: u64, pub total_size_bytes: u64, @@ -17,7 +17,7 @@ pub struct LibraryStatisticsResponse { pub total_duplicates: u64, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct TypeCountResponse { pub name: String, pub count: u64, @@ -61,7 +61,7 @@ impl From } // Database management -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct DatabaseStatsResponse { pub media_count: u64, pub tag_count: u64, @@ -72,7 +72,7 @@ pub struct DatabaseStatsResponse { } // Scheduled Tasks -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ScheduledTaskResponse { pub id: String, pub name: String, diff --git a/crates/pinakes-server/src/dto/sync.rs b/crates/pinakes-server/src/dto/sync.rs index 95993ed..34b2056 100644 --- a/crates/pinakes-server/src/dto/sync.rs +++ b/crates/pinakes-server/src/dto/sync.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use super::media::MediaResponse; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct RegisterDeviceRequest { pub name: String, pub device_type: String, @@ -11,7 +11,7 @@ pub struct RegisterDeviceRequest { pub os_info: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct DeviceResponse { pub id: String, pub name: String, @@ -42,25 +42,25 @@ impl From for DeviceResponse { } } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct DeviceRegistrationResponse { pub device: DeviceResponse, pub device_token: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct UpdateDeviceRequest { pub name: Option, pub enabled: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct GetChangesParams { pub cursor: Option, pub limit: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct SyncChangeResponse { pub id: String, pub sequence: i64, @@ -87,14 +87,14 @@ impl From for SyncChangeResponse { } } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ChangesResponse { pub changes: Vec, pub cursor: i64, pub has_more: bool, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct ClientChangeReport { pub path: String, pub change_type: String, @@ -103,19 +103,19 @@ pub struct ClientChangeReport { pub local_mtime: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct ReportChangesRequest { pub changes: Vec, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ReportChangesResponse { pub accepted: Vec, pub conflicts: Vec, pub upload_required: Vec, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ConflictResponse { pub id: String, pub path: String, @@ -136,12 +136,12 @@ impl From for ConflictResponse { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct ResolveConflictRequest { pub resolution: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct CreateUploadSessionRequest { pub target_path: String, pub expected_hash: String, @@ -149,7 +149,7 @@ pub struct CreateUploadSessionRequest { pub chunk_size: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct UploadSessionResponse { pub id: String, pub target_path: String, @@ -178,19 +178,19 @@ impl From for UploadSessionResponse { } } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ChunkUploadedResponse { pub chunk_index: u64, pub received: bool, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct AcknowledgeChangesRequest { pub cursor: i64, } // Most viewed (uses MediaResponse) -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct MostViewedResponse { pub media: MediaResponse, pub view_count: u64, diff --git a/crates/pinakes-server/src/dto/tags.rs b/crates/pinakes-server/src/dto/tags.rs index 20f1bec..032d437 100644 --- a/crates/pinakes-server/src/dto/tags.rs +++ b/crates/pinakes-server/src/dto/tags.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct TagResponse { pub id: String, pub name: String, @@ -10,13 +10,13 @@ pub struct TagResponse { pub created_at: DateTime, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct CreateTagRequest { pub name: String, pub parent_id: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct TagMediaRequest { pub tag_id: Uuid, } diff --git a/crates/pinakes-server/src/dto/transcode.rs b/crates/pinakes-server/src/dto/transcode.rs index 4a828a2..6b3debf 100644 --- a/crates/pinakes-server/src/dto/transcode.rs +++ b/crates/pinakes-server/src/dto/transcode.rs @@ -1,7 +1,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct TranscodeSessionResponse { pub id: String, pub media_id: String, @@ -28,7 +28,7 @@ impl From } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct CreateTranscodeRequest { pub profile: String, } diff --git a/crates/pinakes-server/src/dto/users.rs b/crates/pinakes-server/src/dto/users.rs index f657dab..d0567c8 100644 --- a/crates/pinakes-server/src/dto/users.rs +++ b/crates/pinakes-server/src/dto/users.rs @@ -2,27 +2,27 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; // Auth -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct LoginRequest { pub username: String, pub password: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct LoginResponse { pub token: String, pub username: String, pub role: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct UserInfoResponse { pub username: String, pub role: String, } // Users -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct UserResponse { pub id: String, pub username: String, @@ -32,14 +32,14 @@ pub struct UserResponse { pub updated_at: DateTime, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct UserProfileResponse { pub avatar_path: Option, pub bio: Option, pub preferences: UserPreferencesResponse, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct UserPreferencesResponse { pub theme: Option, pub language: Option, @@ -47,7 +47,7 @@ pub struct UserPreferencesResponse { pub auto_play: bool, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct UserLibraryResponse { pub user_id: String, pub root_path: String, @@ -55,13 +55,14 @@ pub struct UserLibraryResponse { pub granted_at: DateTime, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct GrantLibraryAccessRequest { pub root_path: String, + #[schema(value_type = String)] pub permission: pinakes_core::users::LibraryPermission, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct RevokeLibraryAccessRequest { pub root_path: String, } diff --git a/crates/pinakes-server/src/error.rs b/crates/pinakes-server/src/error.rs index 8a4f345..c18592d 100644 --- a/crates/pinakes-server/src/error.rs +++ b/crates/pinakes-server/src/error.rs @@ -44,6 +44,25 @@ impl IntoResponse for ApiError { PinakesError::InvalidOperation(msg) => { (StatusCode::BAD_REQUEST, msg.clone()) }, + PinakesError::InvalidLanguageCode(code) => { + ( + StatusCode::BAD_REQUEST, + format!("invalid language code: {code}"), + ) + }, + PinakesError::SubtitleTrackNotFound { index } => { + ( + StatusCode::NOT_FOUND, + format!("subtitle track {index} not found in media"), + ) + }, + PinakesError::ExternalTool { tool, .. } => { + tracing::error!(tool = %tool, error = %self.0, "external tool failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("external tool `{tool}` failed"), + ) + }, PinakesError::Authentication(msg) => { (StatusCode::UNAUTHORIZED, msg.clone()) }, diff --git a/crates/pinakes-server/src/lib.rs b/crates/pinakes-server/src/lib.rs index 6f386df..4299f9a 100644 --- a/crates/pinakes-server/src/lib.rs +++ b/crates/pinakes-server/src/lib.rs @@ -1,3 +1,4 @@ +pub mod api_doc; pub mod app; pub mod auth; pub mod dto; diff --git a/crates/pinakes-server/src/routes/analytics.rs b/crates/pinakes-server/src/routes/analytics.rs index 1698061..fda8fd9 100644 --- a/crates/pinakes-server/src/routes/analytics.rs +++ b/crates/pinakes-server/src/routes/analytics.rs @@ -24,6 +24,21 @@ use crate::{ const MAX_LIMIT: u64 = 100; +#[utoipa::path( + get, + path = "/api/v1/analytics/most-viewed", + tag = "analytics", + params( + ("limit" = Option, Query, description = "Maximum number of results"), + ("offset" = Option, Query, description = "Pagination offset"), + ), + responses( + (status = 200, description = "Most viewed media", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_most_viewed( State(state): State, Query(params): Query, @@ -44,6 +59,21 @@ pub async fn get_most_viewed( )) } +#[utoipa::path( + get, + path = "/api/v1/analytics/recently-viewed", + tag = "analytics", + params( + ("limit" = Option, Query, description = "Maximum number of results"), + ("offset" = Option, Query, description = "Pagination offset"), + ), + responses( + (status = 200, description = "Recently viewed media", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_recently_viewed( State(state): State, Extension(username): Extension, @@ -61,6 +91,18 @@ pub async fn get_recently_viewed( )) } +#[utoipa::path( + post, + path = "/api/v1/analytics/events", + tag = "analytics", + request_body = RecordUsageEventRequest, + responses( + (status = 200, description = "Event recorded"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn record_event( State(state): State, Extension(username): Extension, @@ -84,6 +126,21 @@ pub async fn record_event( Ok(Json(serde_json::json!({"recorded": true}))) } +#[utoipa::path( + get, + path = "/api/v1/media/{id}/progress", + tag = "analytics", + params( + ("id" = Uuid, Path, description = "Media item ID"), + ), + responses( + (status = 200, description = "Watch progress", body = WatchProgressResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_watch_progress( State(state): State, Extension(username): Extension, @@ -100,6 +157,23 @@ pub async fn get_watch_progress( })) } +#[utoipa::path( + put, + path = "/api/v1/media/{id}/progress", + tag = "analytics", + params( + ("id" = Uuid, Path, description = "Media item ID"), + ), + request_body = WatchProgressRequest, + responses( + (status = 200, description = "Progress updated"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn update_watch_progress( State(state): State, Extension(username): Extension, diff --git a/crates/pinakes-server/src/routes/audit.rs b/crates/pinakes-server/src/routes/audit.rs index 7a32067..80ccd10 100644 --- a/crates/pinakes-server/src/routes/audit.rs +++ b/crates/pinakes-server/src/routes/audit.rs @@ -9,6 +9,21 @@ use crate::{ state::AppState, }; +#[utoipa::path( + get, + path = "/api/v1/audit", + tag = "audit", + params( + ("offset" = Option, Query, description = "Pagination offset"), + ("limit" = Option, Query, description = "Page size"), + ), + responses( + (status = 200, description = "Audit log entries", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_audit( State(state): State, Query(params): Query, diff --git a/crates/pinakes-server/src/routes/auth.rs b/crates/pinakes-server/src/routes/auth.rs index 3b4672e..a4561f5 100644 --- a/crates/pinakes-server/src/routes/auth.rs +++ b/crates/pinakes-server/src/routes/auth.rs @@ -17,6 +17,19 @@ const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,\ p=1$VGltaW5nU2FmZUR1bW15$c2ltdWxhdGVkX2hhc2hfZm9yX3RpbWluZ19zYWZldHk"; +#[utoipa::path( + post, + path = "/api/v1/auth/login", + tag = "auth", + request_body = LoginRequest, + responses( + (status = 200, description = "Login successful", body = LoginResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Invalid credentials"), + (status = 500, description = "Internal server error"), + ), + security() +)] pub async fn login( State(state): State, Json(req): Json, @@ -82,6 +95,7 @@ pub async fn login( let user = user.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; // Generate session token using unbiased uniform distribution + #[expect(clippy::expect_used)] let token: String = { use rand::seq::IndexedRandom; const CHARSET: &[u8] = @@ -134,39 +148,64 @@ pub async fn login( })) } +#[utoipa::path( + post, + path = "/api/v1/auth/logout", + tag = "auth", + responses( + (status = 200, description = "Logged out"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn logout( State(state): State, headers: HeaderMap, ) -> StatusCode { - if let Some(token) = extract_bearer_token(&headers) { - // Get username before deleting session - let username = match state.storage.get_session(token).await { - Ok(Some(session)) => Some(session.username), - _ => None, - }; + let Some(token) = extract_bearer_token(&headers) else { + return StatusCode::UNAUTHORIZED; + }; - // Delete session from database - if let Err(e) = state.storage.delete_session(token).await { - tracing::error!(error = %e, "failed to delete session from database"); - return StatusCode::INTERNAL_SERVER_ERROR; - } + // Get username before deleting session + let username = match state.storage.get_session(token).await { + Ok(Some(session)) => Some(session.username), + _ => None, + }; - // Record logout in audit log - if let Some(user) = username - && let Err(e) = pinakes_core::audit::record_action( - &state.storage, - None, - pinakes_core::model::AuditAction::Logout, - Some(format!("username: {user}")), - ) - .await - { - tracing::warn!(error = %e, "failed to record logout audit"); - } + // Delete session from database + if let Err(e) = state.storage.delete_session(token).await { + tracing::error!(error = %e, "failed to delete session from database"); + return StatusCode::INTERNAL_SERVER_ERROR; } + + // Record logout in audit log + if let Some(user) = username + && let Err(e) = pinakes_core::audit::record_action( + &state.storage, + None, + pinakes_core::model::AuditAction::Logout, + Some(format!("username: {user}")), + ) + .await + { + tracing::warn!(error = %e, "failed to record logout audit"); + } + StatusCode::OK } +#[utoipa::path( + get, + path = "/api/v1/auth/me", + tag = "auth", + responses( + (status = 200, description = "Current user info", body = UserInfoResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn me( State(state): State, headers: HeaderMap, @@ -204,6 +243,17 @@ fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> { /// Refresh the current session, extending its expiry by the configured /// duration. +#[utoipa::path( + post, + path = "/api/v1/auth/refresh", + tag = "auth", + responses( + (status = 200, description = "Session refreshed"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn refresh( State(state): State, headers: HeaderMap, @@ -232,6 +282,17 @@ pub async fn refresh( } /// Revoke all sessions for the current user +#[utoipa::path( + post, + path = "/api/v1/auth/revoke-all", + tag = "auth", + responses( + (status = 200, description = "All sessions revoked"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn revoke_all_sessions( State(state): State, headers: HeaderMap, @@ -280,12 +341,12 @@ pub async fn revoke_all_sessions( } /// List all active sessions (admin only) -#[derive(serde::Serialize)] +#[derive(serde::Serialize, utoipa::ToSchema)] pub struct SessionListResponse { pub sessions: Vec, } -#[derive(serde::Serialize)] +#[derive(serde::Serialize, utoipa::ToSchema)] pub struct SessionInfo { pub username: String, pub role: String, @@ -294,6 +355,18 @@ pub struct SessionInfo { pub expires_at: String, } +#[utoipa::path( + get, + path = "/api/v1/auth/sessions", + tag = "auth", + responses( + (status = 200, description = "Active sessions", body = SessionListResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_active_sessions( State(state): State, ) -> Result, StatusCode> { diff --git a/crates/pinakes-server/src/routes/backup.rs b/crates/pinakes-server/src/routes/backup.rs index 4af2b18..d80b31f 100644 --- a/crates/pinakes-server/src/routes/backup.rs +++ b/crates/pinakes-server/src/routes/backup.rs @@ -11,6 +11,18 @@ use crate::{error::ApiError, state::AppState}; /// /// For `SQLite`: creates a backup via VACUUM INTO and returns the file. /// For `PostgreSQL`: returns unsupported error (use `pg_dump` instead). +#[utoipa::path( + post, + path = "/api/v1/admin/backup", + tag = "backup", + responses( + (status = 200, description = "Backup file download"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn create_backup( State(state): State, ) -> Result { diff --git a/crates/pinakes-server/src/routes/books.rs b/crates/pinakes-server/src/routes/books.rs index 9c83b64..9993492 100644 --- a/crates/pinakes-server/src/routes/books.rs +++ b/crates/pinakes-server/src/routes/books.rs @@ -29,7 +29,7 @@ use crate::{ }; /// Book metadata response DTO -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct BookMetadataResponse { pub media_id: Uuid, pub isbn: Option, @@ -42,6 +42,7 @@ pub struct BookMetadataResponse { pub series_index: Option, pub format: Option, pub authors: Vec, + #[schema(value_type = Object)] pub identifiers: FxHashMap>, } @@ -69,7 +70,7 @@ impl From for BookMetadataResponse { } /// Author response DTO -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct AuthorResponse { pub name: String, pub role: String, @@ -89,7 +90,7 @@ impl From for AuthorResponse { } /// Reading progress response DTO -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct ReadingProgressResponse { pub media_id: Uuid, pub user_id: Uuid, @@ -113,7 +114,7 @@ impl From for ReadingProgressResponse { } /// Update reading progress request -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct UpdateProgressRequest { pub current_page: i32, } @@ -141,20 +142,32 @@ const fn default_limit() -> u64 { } /// Series summary DTO -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct SeriesSummary { pub name: String, pub book_count: u64, } /// Author summary DTO -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct AuthorSummary { pub name: String, pub book_count: u64, } /// Get book metadata by media ID +#[utoipa::path( + get, + path = "/api/v1/books/{id}/metadata", + tag = "books", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Book metadata", body = BookMetadataResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_book_metadata( State(state): State, Path(media_id): Path, @@ -173,6 +186,26 @@ pub async fn get_book_metadata( } /// List all books with optional search filters +#[utoipa::path( + get, + path = "/api/v1/books", + tag = "books", + params( + ("isbn" = Option, Query, description = "Filter by ISBN"), + ("author" = Option, Query, description = "Filter by author"), + ("series" = Option, Query, description = "Filter by series"), + ("publisher" = Option, Query, description = "Filter by publisher"), + ("language" = Option, Query, description = "Filter by language"), + ("offset" = Option, Query, description = "Pagination offset"), + ("limit" = Option, Query, description = "Pagination limit"), + ), + responses( + (status = 200, description = "List of books", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_books( State(state): State, Query(query): Query, @@ -204,6 +237,16 @@ pub async fn list_books( } /// List all series with book counts +#[utoipa::path( + get, + path = "/api/v1/books/series", + tag = "books", + responses( + (status = 200, description = "List of series with counts", body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_series( State(state): State, ) -> Result { @@ -222,6 +265,17 @@ pub async fn list_series( } /// Get books in a specific series +#[utoipa::path( + get, + path = "/api/v1/books/series/{name}", + tag = "books", + params(("name" = String, Path, description = "Series name")), + responses( + (status = 200, description = "Books in series", body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_series_books( State(state): State, Path(series_name): Path, @@ -236,6 +290,20 @@ pub async fn get_series_books( } /// List all authors with book counts +#[utoipa::path( + get, + path = "/api/v1/books/authors", + tag = "books", + params( + ("offset" = Option, Query, description = "Pagination offset"), + ("limit" = Option, Query, description = "Pagination limit"), + ), + responses( + (status = 200, description = "Authors with book counts", body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_authors( State(state): State, Query(pagination): Query, @@ -255,6 +323,21 @@ pub async fn list_authors( } /// Get books by a specific author +#[utoipa::path( + get, + path = "/api/v1/books/authors/{name}/books", + tag = "books", + params( + ("name" = String, Path, description = "Author name"), + ("offset" = Option, Query, description = "Pagination offset"), + ("limit" = Option, Query, description = "Pagination limit"), + ), + responses( + (status = 200, description = "Books by author", body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_author_books( State(state): State, Path(author_name): Path, @@ -274,6 +357,18 @@ pub async fn get_author_books( } /// Get reading progress for a book +#[utoipa::path( + get, + path = "/api/v1/books/{id}/progress", + tag = "books", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Reading progress", body = ReadingProgressResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_reading_progress( State(state): State, Extension(username): Extension, @@ -294,6 +389,19 @@ pub async fn get_reading_progress( } /// Update reading progress for a book +#[utoipa::path( + put, + path = "/api/v1/books/{id}/progress", + tag = "books", + params(("id" = Uuid, Path, description = "Media item ID")), + request_body = UpdateProgressRequest, + responses( + (status = 204, description = "Progress updated"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn update_reading_progress( State(state): State, Extension(username): Extension, @@ -306,6 +414,10 @@ pub async fn update_reading_progress( let user_id = resolve_user_id(&state.storage, &username).await?; let media_id = MediaId(media_id); + // Verify the media item exists before writing progress; a FK violation from + // the storage layer would otherwise surface as a 500 rather than 404. + state.storage.get_media(media_id).await?; + state .storage .update_reading_progress(user_id.0, media_id, req.current_page) @@ -315,6 +427,17 @@ pub async fn update_reading_progress( } /// Get user's reading list +#[utoipa::path( + get, + path = "/api/v1/books/reading-list", + tag = "books", + params(("status" = Option, Query, description = "Filter by reading status. Valid values: to_read, reading, completed, abandoned")), + responses( + (status = 200, description = "Reading list", body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_reading_list( State(state): State, Extension(username): Extension, diff --git a/crates/pinakes-server/src/routes/collections.rs b/crates/pinakes-server/src/routes/collections.rs index c746fa8..a1df04d 100644 --- a/crates/pinakes-server/src/routes/collections.rs +++ b/crates/pinakes-server/src/routes/collections.rs @@ -16,6 +16,20 @@ use crate::{ state::AppState, }; +#[utoipa::path( + post, + path = "/api/v1/collections", + tag = "collections", + request_body = CreateCollectionRequest, + responses( + (status = 200, description = "Collection created", body = CollectionResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn create_collection( State(state): State, Json(req): Json, @@ -60,6 +74,17 @@ pub async fn create_collection( Ok(Json(CollectionResponse::from(col))) } +#[utoipa::path( + get, + path = "/api/v1/collections", + tag = "collections", + responses( + (status = 200, description = "List of collections", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_collections( State(state): State, ) -> Result>, ApiError> { @@ -69,6 +94,19 @@ pub async fn list_collections( )) } +#[utoipa::path( + get, + path = "/api/v1/collections/{id}", + tag = "collections", + params(("id" = Uuid, Path, description = "Collection ID")), + responses( + (status = 200, description = "Collection", body = CollectionResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_collection( State(state): State, Path(id): Path, @@ -77,6 +115,20 @@ pub async fn get_collection( Ok(Json(CollectionResponse::from(col))) } +#[utoipa::path( + delete, + path = "/api/v1/collections/{id}", + tag = "collections", + params(("id" = Uuid, Path, description = "Collection ID")), + responses( + (status = 200, description = "Collection deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn delete_collection( State(state): State, Path(id): Path, @@ -91,6 +143,21 @@ pub async fn delete_collection( Ok(Json(serde_json::json!({"deleted": true}))) } +#[utoipa::path( + post, + path = "/api/v1/collections/{id}/members", + tag = "collections", + params(("id" = Uuid, Path, description = "Collection ID")), + request_body = AddMemberRequest, + responses( + (status = 200, description = "Member added"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn add_member( State(state): State, Path(collection_id): Path, @@ -106,6 +173,23 @@ pub async fn add_member( Ok(Json(serde_json::json!({"added": true}))) } +#[utoipa::path( + delete, + path = "/api/v1/collections/{id}/members/{media_id}", + tag = "collections", + params( + ("id" = Uuid, Path, description = "Collection ID"), + ("media_id" = Uuid, Path, description = "Media item ID"), + ), + responses( + (status = 200, description = "Member removed"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn remove_member( State(state): State, Path((collection_id, media_id)): Path<(Uuid, Uuid)>, @@ -119,6 +203,19 @@ pub async fn remove_member( Ok(Json(serde_json::json!({"removed": true}))) } +#[utoipa::path( + get, + path = "/api/v1/collections/{id}/members", + tag = "collections", + params(("id" = Uuid, Path, description = "Collection ID")), + responses( + (status = 200, description = "Collection members", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_members( State(state): State, Path(collection_id): Path, diff --git a/crates/pinakes-server/src/routes/config.rs b/crates/pinakes-server/src/routes/config.rs index 2311178..7a76f83 100644 --- a/crates/pinakes-server/src/routes/config.rs +++ b/crates/pinakes-server/src/routes/config.rs @@ -14,6 +14,18 @@ use crate::{ state::AppState, }; +#[utoipa::path( + get, + path = "/api/v1/config", + tag = "config", + responses( + (status = 200, description = "Current server configuration", body = ConfigResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_config( State(state): State, ) -> Result, ApiError> { @@ -63,6 +75,17 @@ pub async fn get_config( })) } +#[utoipa::path( + get, + path = "/api/v1/config/ui", + tag = "config", + responses( + (status = 200, description = "UI configuration", body = UiConfigResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_ui_config( State(state): State, ) -> Result, ApiError> { @@ -70,6 +93,19 @@ pub async fn get_ui_config( Ok(Json(UiConfigResponse::from(&config.ui))) } +#[utoipa::path( + patch, + path = "/api/v1/config/ui", + tag = "config", + request_body = UpdateUiConfigRequest, + responses( + (status = 200, description = "Updated UI configuration", body = UiConfigResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn update_ui_config( State(state): State, Json(req): Json, @@ -104,6 +140,19 @@ pub async fn update_ui_config( Ok(Json(UiConfigResponse::from(&config.ui))) } +#[utoipa::path( + patch, + path = "/api/v1/config/scanning", + tag = "config", + request_body = UpdateScanningRequest, + responses( + (status = 200, description = "Updated configuration", body = ConfigResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn update_scanning_config( State(state): State, Json(req): Json, @@ -169,6 +218,20 @@ pub async fn update_scanning_config( })) } +#[utoipa::path( + post, + path = "/api/v1/config/roots", + tag = "config", + request_body = RootDirRequest, + responses( + (status = 200, description = "Updated configuration", body = ConfigResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn add_root( State(state): State, Json(req): Json, @@ -196,6 +259,19 @@ pub async fn add_root( get_config(State(state)).await } +#[utoipa::path( + delete, + path = "/api/v1/config/roots", + tag = "config", + request_body = RootDirRequest, + responses( + (status = 200, description = "Updated configuration", body = ConfigResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn remove_root( State(state): State, Json(req): Json, diff --git a/crates/pinakes-server/src/routes/database.rs b/crates/pinakes-server/src/routes/database.rs index 4c71cde..e88fcb8 100644 --- a/crates/pinakes-server/src/routes/database.rs +++ b/crates/pinakes-server/src/routes/database.rs @@ -2,6 +2,18 @@ use axum::{Json, extract::State}; use crate::{dto::DatabaseStatsResponse, error::ApiError, state::AppState}; +#[utoipa::path( + get, + path = "/api/v1/admin/database/stats", + tag = "database", + responses( + (status = 200, description = "Database statistics", body = DatabaseStatsResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn database_stats( State(state): State, ) -> Result, ApiError> { @@ -16,6 +28,18 @@ pub async fn database_stats( })) } +#[utoipa::path( + post, + path = "/api/v1/admin/database/vacuum", + tag = "database", + responses( + (status = 200, description = "Database vacuumed"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn vacuum_database( State(state): State, ) -> Result, ApiError> { @@ -23,6 +47,18 @@ pub async fn vacuum_database( Ok(Json(serde_json::json!({"status": "ok"}))) } +#[utoipa::path( + post, + path = "/api/v1/admin/database/clear", + tag = "database", + responses( + (status = 200, description = "Database cleared"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn clear_database( State(state): State, ) -> Result, ApiError> { diff --git a/crates/pinakes-server/src/routes/duplicates.rs b/crates/pinakes-server/src/routes/duplicates.rs index 075b3cc..6150979 100644 --- a/crates/pinakes-server/src/routes/duplicates.rs +++ b/crates/pinakes-server/src/routes/duplicates.rs @@ -6,6 +6,17 @@ use crate::{ state::AppState, }; +#[utoipa::path( + get, + path = "/api/v1/media/duplicates", + tag = "duplicates", + responses( + (status = 200, description = "Duplicate groups", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_duplicates( State(state): State, ) -> Result>, ApiError> { diff --git a/crates/pinakes-server/src/routes/enrichment.rs b/crates/pinakes-server/src/routes/enrichment.rs index 5b93b3f..1060cc3 100644 --- a/crates/pinakes-server/src/routes/enrichment.rs +++ b/crates/pinakes-server/src/routes/enrichment.rs @@ -11,6 +11,20 @@ use crate::{ state::AppState, }; +#[utoipa::path( + post, + path = "/api/v1/media/{id}/enrich", + tag = "enrichment", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Enrichment job submitted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn trigger_enrichment( State(state): State, Path(id): Path, @@ -25,6 +39,19 @@ pub async fn trigger_enrichment( Ok(Json(serde_json::json!({"job_id": job_id.to_string()}))) } +#[utoipa::path( + get, + path = "/api/v1/media/{id}/metadata/external", + tag = "enrichment", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "External metadata", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_external_metadata( State(state): State, Path(id): Path, @@ -38,6 +65,20 @@ pub async fn get_external_metadata( )) } +#[utoipa::path( + post, + path = "/api/v1/media/enrich/batch", + tag = "enrichment", + request_body = BatchDeleteRequest, + responses( + (status = 200, description = "Enrichment job submitted"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn batch_enrich( State(state): State, Json(req): Json, // Reuse: has media_ids field diff --git a/crates/pinakes-server/src/routes/export.rs b/crates/pinakes-server/src/routes/export.rs index 7b98b04..8251272 100644 --- a/crates/pinakes-server/src/routes/export.rs +++ b/crates/pinakes-server/src/routes/export.rs @@ -5,12 +5,25 @@ use serde::Deserialize; use crate::{error::ApiError, state::AppState}; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct ExportRequest { pub format: String, + #[schema(value_type = String)] pub destination: PathBuf, } +#[utoipa::path( + post, + path = "/api/v1/export", + tag = "export", + responses( + (status = 200, description = "Export job submitted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn trigger_export( State(state): State, ) -> Result, ApiError> { @@ -25,6 +38,19 @@ pub async fn trigger_export( Ok(Json(serde_json::json!({ "job_id": job_id.to_string() }))) } +#[utoipa::path( + post, + path = "/api/v1/export/options", + tag = "export", + request_body = ExportRequest, + responses( + (status = 200, description = "Export job submitted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn trigger_export_with_options( State(state): State, Json(req): Json, diff --git a/crates/pinakes-server/src/routes/health.rs b/crates/pinakes-server/src/routes/health.rs index cffabb3..7d30c27 100644 --- a/crates/pinakes-server/src/routes/health.rs +++ b/crates/pinakes-server/src/routes/health.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use crate::state::AppState; /// Basic health check response -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct HealthResponse { pub status: String, pub version: String, @@ -18,7 +18,7 @@ pub struct HealthResponse { pub cache: Option, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct DatabaseHealth { pub status: String, pub latency_ms: u64, @@ -26,14 +26,14 @@ pub struct DatabaseHealth { pub media_count: Option, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct FilesystemHealth { pub status: String, pub roots_configured: usize, pub roots_accessible: usize, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct CacheHealth { pub hit_rate: f64, pub total_entries: u64, @@ -43,6 +43,14 @@ pub struct CacheHealth { } /// Comprehensive health check - includes database, filesystem, and cache status +#[utoipa::path( + get, + path = "/api/v1/health", + tag = "health", + responses( + (status = 200, description = "Health status", body = HealthResponse), + ) +)] pub async fn health(State(state): State) -> Json { let mut response = HealthResponse { status: "ok".to_string(), @@ -106,6 +114,14 @@ pub async fn health(State(state): State) -> Json { /// Liveness probe - just checks if the server is running /// Returns 200 OK if the server process is alive +#[utoipa::path( + get, + path = "/api/v1/health/live", + tag = "health", + responses( + (status = 200, description = "Server is alive"), + ) +)] pub async fn liveness() -> impl IntoResponse { ( StatusCode::OK, @@ -117,6 +133,15 @@ pub async fn liveness() -> impl IntoResponse { /// Readiness probe - checks if the server can serve requests /// Returns 200 OK if database is accessible +#[utoipa::path( + get, + path = "/api/v1/health/ready", + tag = "health", + responses( + (status = 200, description = "Server is ready"), + (status = 503, description = "Server not ready"), + ) +)] pub async fn readiness(State(state): State) -> impl IntoResponse { // Check database connectivity let db_start = Instant::now(); @@ -144,7 +169,7 @@ pub async fn readiness(State(state): State) -> impl IntoResponse { } /// Detailed health check for monitoring dashboards -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct DetailedHealthResponse { pub status: String, pub version: String, @@ -155,12 +180,20 @@ pub struct DetailedHealthResponse { pub jobs: JobsHealth, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct JobsHealth { pub pending: usize, pub running: usize, } +#[utoipa::path( + get, + path = "/api/v1/health/detailed", + tag = "health", + responses( + (status = 200, description = "Detailed health status", body = DetailedHealthResponse), + ) +)] pub async fn health_detailed( State(state): State, ) -> Json { diff --git a/crates/pinakes-server/src/routes/integrity.rs b/crates/pinakes-server/src/routes/integrity.rs index d6a84ea..f688e79 100644 --- a/crates/pinakes-server/src/routes/integrity.rs +++ b/crates/pinakes-server/src/routes/integrity.rs @@ -3,12 +3,24 @@ use serde::Deserialize; use crate::{error::ApiError, state::AppState}; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct OrphanResolveRequest { pub action: String, pub ids: Vec, } +#[utoipa::path( + post, + path = "/api/v1/admin/integrity/orphans/detect", + tag = "integrity", + responses( + (status = 200, description = "Orphan detection job submitted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn trigger_orphan_detection( State(state): State, ) -> Result, ApiError> { @@ -17,6 +29,19 @@ pub async fn trigger_orphan_detection( Ok(Json(serde_json::json!({ "job_id": job_id.to_string() }))) } +#[utoipa::path( + post, + path = "/api/v1/admin/integrity/verify", + tag = "integrity", + request_body = VerifyIntegrityRequest, + responses( + (status = 200, description = "Integrity verification job submitted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn trigger_verify_integrity( State(state): State, Json(req): Json, @@ -31,11 +56,23 @@ pub async fn trigger_verify_integrity( Ok(Json(serde_json::json!({ "job_id": job_id.to_string() }))) } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct VerifyIntegrityRequest { pub media_ids: Vec, } +#[utoipa::path( + post, + path = "/api/v1/admin/integrity/thumbnails/cleanup", + tag = "integrity", + responses( + (status = 200, description = "Thumbnail cleanup job submitted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn trigger_cleanup_thumbnails( State(state): State, ) -> Result, ApiError> { @@ -44,7 +81,7 @@ pub async fn trigger_cleanup_thumbnails( Ok(Json(serde_json::json!({ "job_id": job_id.to_string() }))) } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct GenerateThumbnailsRequest { /// When true, only generate thumbnails for items that don't have one yet. /// When false (default), regenerate all thumbnails. @@ -52,6 +89,19 @@ pub struct GenerateThumbnailsRequest { pub only_missing: bool, } +#[utoipa::path( + post, + path = "/api/v1/admin/integrity/thumbnails/generate", + tag = "integrity", + request_body = GenerateThumbnailsRequest, + responses( + (status = 200, description = "Thumbnail generation job submitted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn generate_all_thumbnails( State(state): State, body: Option>, @@ -77,6 +127,19 @@ pub async fn generate_all_thumbnails( }))) } +#[utoipa::path( + post, + path = "/api/v1/admin/integrity/orphans/resolve", + tag = "integrity", + request_body = OrphanResolveRequest, + responses( + (status = 200, description = "Orphans resolved"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn resolve_orphans( State(state): State, Json(req): Json, diff --git a/crates/pinakes-server/src/routes/jobs.rs b/crates/pinakes-server/src/routes/jobs.rs index 6016c05..c7319cb 100644 --- a/crates/pinakes-server/src/routes/jobs.rs +++ b/crates/pinakes-server/src/routes/jobs.rs @@ -6,10 +6,34 @@ use pinakes_core::jobs::Job; use crate::{error::ApiError, state::AppState}; +#[utoipa::path( + get, + path = "/api/v1/jobs", + tag = "jobs", + responses( + (status = 200, description = "List of jobs"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_jobs(State(state): State) -> Json> { Json(state.job_queue.list().await) } +#[utoipa::path( + get, + path = "/api/v1/jobs/{id}", + tag = "jobs", + params(("id" = uuid::Uuid, Path, description = "Job ID")), + responses( + (status = 200, description = "Job details"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_job( State(state): State, Path(id): Path, @@ -20,6 +44,19 @@ pub async fn get_job( }) } +#[utoipa::path( + post, + path = "/api/v1/jobs/{id}/cancel", + tag = "jobs", + params(("id" = uuid::Uuid, Path, description = "Job ID")), + responses( + (status = 200, description = "Job cancelled"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn cancel_job( State(state): State, Path(id): Path, diff --git a/crates/pinakes-server/src/routes/media.rs b/crates/pinakes-server/src/routes/media.rs index 057aa31..6aa3ec5 100644 --- a/crates/pinakes-server/src/routes/media.rs +++ b/crates/pinakes-server/src/routes/media.rs @@ -99,6 +99,20 @@ async fn apply_import_post_processing( } } +#[utoipa::path( + post, + path = "/api/v1/media/import", + tag = "media", + request_body = ImportRequest, + responses( + (status = 200, description = "Media imported", body = ImportResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn import_media( State(state): State, Json(req): Json, @@ -126,6 +140,22 @@ pub async fn import_media( })) } +#[utoipa::path( + get, + path = "/api/v1/media", + tag = "media", + params( + ("offset" = Option, Query, description = "Pagination offset"), + ("limit" = Option, Query, description = "Page size"), + ("sort" = Option, Query, description = "Sort field"), + ), + responses( + (status = 200, description = "List of media items", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_media( State(state): State, Query(params): Query, @@ -141,6 +171,19 @@ pub async fn list_media( )) } +#[utoipa::path( + get, + path = "/api/v1/media/{id}", + tag = "media", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Media item", body = MediaResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_media( State(state): State, Path(id): Path, @@ -172,6 +215,22 @@ fn validate_optional_text( Ok(()) } +#[utoipa::path( + patch, + path = "/api/v1/media/{id}", + tag = "media", + params(("id" = Uuid, Path, description = "Media item ID")), + request_body = UpdateMediaRequest, + responses( + (status = 200, description = "Updated media item", body = MediaResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn update_media( State(state): State, Path(id): Path, @@ -229,6 +288,20 @@ pub async fn update_media( Ok(Json(MediaResponse::new(item, &roots))) } +#[utoipa::path( + delete, + path = "/api/v1/media/{id}", + tag = "media", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Media deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn delete_media( State(state): State, Path(id): Path, @@ -267,6 +340,19 @@ pub async fn delete_media( Ok(Json(serde_json::json!({"deleted": true}))) } +#[utoipa::path( + post, + path = "/api/v1/media/{id}/open", + tag = "media", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Media opened"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn open_media( State(state): State, Path(id): Path, @@ -284,6 +370,20 @@ pub async fn open_media( Ok(Json(serde_json::json!({"opened": true}))) } +#[utoipa::path( + get, + path = "/api/v1/media/{id}/stream", + tag = "media", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Media stream"), + (status = 206, description = "Partial content"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn stream_media( State(state): State, Path(id): Path, @@ -395,6 +495,20 @@ fn parse_range(header: &str, total_size: u64) -> Option<(u64, u64)> { } } +#[utoipa::path( + post, + path = "/api/v1/media/import/options", + tag = "media", + request_body = ImportWithOptionsRequest, + responses( + (status = 200, description = "Media imported", body = ImportResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn import_with_options( State(state): State, Json(req): Json, @@ -429,6 +543,20 @@ pub async fn import_with_options( })) } +#[utoipa::path( + post, + path = "/api/v1/media/import/batch", + tag = "media", + request_body = BatchImportRequest, + responses( + (status = 200, description = "Batch import results", body = BatchImportResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn batch_import( State(state): State, Json(req): Json, @@ -503,6 +631,20 @@ pub async fn batch_import( })) } +#[utoipa::path( + post, + path = "/api/v1/media/import/directory", + tag = "media", + request_body = DirectoryImportRequest, + responses( + (status = 200, description = "Directory import results", body = BatchImportResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn import_directory_endpoint( State(state): State, Json(req): Json, @@ -571,6 +713,19 @@ pub async fn import_directory_endpoint( })) } +#[utoipa::path( + post, + path = "/api/v1/media/import/preview", + tag = "media", + responses( + (status = 200, description = "Directory preview", body = DirectoryPreviewResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn preview_directory( State(state): State, Json(req): Json, @@ -672,6 +827,22 @@ pub async fn preview_directory( })) } +#[utoipa::path( + put, + path = "/api/v1/media/{id}/custom-fields", + tag = "media", + params(("id" = Uuid, Path, description = "Media item ID")), + request_body = SetCustomFieldRequest, + responses( + (status = 200, description = "Custom field set"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn set_custom_field( State(state): State, Path(id): Path, @@ -709,6 +880,23 @@ pub async fn set_custom_field( Ok(Json(serde_json::json!({"set": true}))) } +#[utoipa::path( + delete, + path = "/api/v1/media/{id}/custom-fields/{name}", + tag = "media", + params( + ("id" = Uuid, Path, description = "Media item ID"), + ("name" = String, Path, description = "Custom field name"), + ), + responses( + (status = 200, description = "Custom field deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn delete_custom_field( State(state): State, Path((id, name)): Path<(Uuid, String)>, @@ -720,6 +908,20 @@ pub async fn delete_custom_field( Ok(Json(serde_json::json!({"deleted": true}))) } +#[utoipa::path( + post, + path = "/api/v1/media/batch/tag", + tag = "media", + request_body = BatchTagRequest, + responses( + (status = 200, description = "Batch tag result", body = BatchOperationResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn batch_tag( State(state): State, Json(req): Json, @@ -754,6 +956,18 @@ pub async fn batch_tag( } } +#[utoipa::path( + delete, + path = "/api/v1/media", + tag = "media", + responses( + (status = 200, description = "All media deleted", body = BatchOperationResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn delete_all_media( State(state): State, ) -> Result, ApiError> { @@ -785,6 +999,20 @@ pub async fn delete_all_media( } } +#[utoipa::path( + post, + path = "/api/v1/media/batch/delete", + tag = "media", + request_body = BatchDeleteRequest, + responses( + (status = 200, description = "Batch delete result", body = BatchOperationResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn batch_delete( State(state): State, Json(req): Json, @@ -829,6 +1057,20 @@ pub async fn batch_delete( } } +#[utoipa::path( + post, + path = "/api/v1/media/batch/collection", + tag = "media", + request_body = BatchCollectionRequest, + responses( + (status = 200, description = "Batch collection result", body = BatchOperationResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn batch_add_to_collection( State(state): State, Json(req): Json, @@ -859,6 +1101,20 @@ pub async fn batch_add_to_collection( Ok(Json(BatchOperationResponse { processed, errors })) } +#[utoipa::path( + post, + path = "/api/v1/media/batch/update", + tag = "media", + request_body = BatchUpdateRequest, + responses( + (status = 200, description = "Batch update result", body = BatchOperationResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn batch_update( State(state): State, Json(req): Json, @@ -901,6 +1157,19 @@ pub async fn batch_update( } } +#[utoipa::path( + get, + path = "/api/v1/media/{id}/thumbnail", + tag = "media", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Thumbnail image"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_thumbnail( State(state): State, Path(id): Path, @@ -934,6 +1203,17 @@ pub async fn get_thumbnail( }) } +#[utoipa::path( + get, + path = "/api/v1/media/count", + tag = "media", + responses( + (status = 200, description = "Media count", body = MediaCountResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_media_count( State(state): State, ) -> Result, ApiError> { @@ -941,6 +1221,22 @@ pub async fn get_media_count( Ok(Json(MediaCountResponse { count })) } +#[utoipa::path( + post, + path = "/api/v1/media/{id}/rename", + tag = "media", + params(("id" = Uuid, Path, description = "Media item ID")), + request_body = RenameMediaRequest, + responses( + (status = 200, description = "Renamed media item", body = MediaResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn rename_media( State(state): State, Path(id): Path, @@ -993,6 +1289,22 @@ pub async fn rename_media( Ok(Json(MediaResponse::new(item, &roots))) } +#[utoipa::path( + post, + path = "/api/v1/media/{id}/move", + tag = "media", + params(("id" = Uuid, Path, description = "Media item ID")), + request_body = MoveMediaRequest, + responses( + (status = 200, description = "Moved media item", body = MediaResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn move_media_endpoint( State(state): State, Path(id): Path, @@ -1042,6 +1354,20 @@ pub async fn move_media_endpoint( Ok(Json(MediaResponse::new(item, &roots))) } +#[utoipa::path( + post, + path = "/api/v1/media/batch/move", + tag = "media", + request_body = BatchMoveRequest, + responses( + (status = 200, description = "Batch move result", body = BatchOperationResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn batch_move_media( State(state): State, Json(req): Json, @@ -1111,6 +1437,20 @@ pub async fn batch_move_media( } } +#[utoipa::path( + delete, + path = "/api/v1/media/{id}/trash", + tag = "media", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Media moved to trash"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn soft_delete_media( State(state): State, Path(id): Path, @@ -1157,6 +1497,20 @@ pub async fn soft_delete_media( Ok(Json(serde_json::json!({"deleted": true, "trashed": true}))) } +#[utoipa::path( + post, + path = "/api/v1/media/{id}/restore", + tag = "media", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Media restored", body = MediaResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn restore_media( State(state): State, Path(id): Path, @@ -1204,6 +1558,21 @@ pub async fn restore_media( Ok(Json(MediaResponse::new(item, &roots))) } +#[utoipa::path( + get, + path = "/api/v1/media/trash", + tag = "media", + params( + ("offset" = Option, Query, description = "Pagination offset"), + ("limit" = Option, Query, description = "Page size"), + ), + responses( + (status = 200, description = "Trashed media items", body = TrashResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_trash( State(state): State, Query(params): Query, @@ -1223,6 +1592,17 @@ pub async fn list_trash( })) } +#[utoipa::path( + get, + path = "/api/v1/media/trash/info", + tag = "media", + responses( + (status = 200, description = "Trash info", body = TrashInfoResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn trash_info( State(state): State, ) -> Result, ApiError> { @@ -1230,6 +1610,18 @@ pub async fn trash_info( Ok(Json(TrashInfoResponse { count })) } +#[utoipa::path( + delete, + path = "/api/v1/media/trash", + tag = "media", + responses( + (status = 200, description = "Trash emptied", body = EmptyTrashResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn empty_trash( State(state): State, ) -> Result, ApiError> { @@ -1247,6 +1639,23 @@ pub async fn empty_trash( Ok(Json(EmptyTrashResponse { deleted_count })) } +#[utoipa::path( + delete, + path = "/api/v1/media/{id}/permanent", + tag = "media", + params( + ("id" = Uuid, Path, description = "Media item ID"), + ("permanent" = Option, Query, description = "Set to 'true' for permanent deletion"), + ), + responses( + (status = 200, description = "Media deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn permanent_delete_media( State(state): State, Path(id): Path, diff --git a/crates/pinakes-server/src/routes/notes.rs b/crates/pinakes-server/src/routes/notes.rs index 0fca5a3..6fe3a37 100644 --- a/crates/pinakes-server/src/routes/notes.rs +++ b/crates/pinakes-server/src/routes/notes.rs @@ -26,14 +26,14 @@ use uuid::Uuid; use crate::{error::ApiError, state::AppState}; /// Response for backlinks query -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct BacklinksResponse { pub backlinks: Vec, pub count: usize, } /// Individual backlink item -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct BacklinkItem { pub link_id: Uuid, pub source_id: Uuid, @@ -61,14 +61,14 @@ impl From for BacklinkItem { } /// Response for outgoing links query -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct OutgoingLinksResponse { pub links: Vec, pub count: usize, } /// Individual outgoing link item -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct OutgoingLinkItem { pub id: Uuid, pub target_path: String, @@ -94,7 +94,7 @@ impl From for OutgoingLinkItem { } /// Response for graph visualization -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct GraphResponse { pub nodes: Vec, pub edges: Vec, @@ -103,7 +103,7 @@ pub struct GraphResponse { } /// Graph node for visualization -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct GraphNodeResponse { pub id: String, pub label: String, @@ -127,7 +127,7 @@ impl From for GraphNodeResponse { } /// Graph edge for visualization -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct GraphEdgeResponse { pub source: String, pub target: String, @@ -180,20 +180,20 @@ const fn default_depth() -> u32 { } /// Response for reindex operation -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ReindexResponse { pub message: String, pub links_extracted: usize, } /// Response for link resolution -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ResolveLinksResponse { pub resolved_count: u64, } /// Response for unresolved links count -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct UnresolvedLinksResponse { pub count: u64, } @@ -201,6 +201,19 @@ pub struct UnresolvedLinksResponse { /// Get backlinks (incoming links) to a media item. /// /// GET /api/v1/media/{id}/backlinks +#[utoipa::path( + get, + path = "/api/v1/media/{id}/backlinks", + tag = "notes", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Backlinks", body = BacklinksResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_backlinks( State(state): State, Path(id): Path, @@ -221,6 +234,19 @@ pub async fn get_backlinks( /// Get outgoing links from a media item. /// /// GET /api/v1/media/{id}/outgoing-links +#[utoipa::path( + get, + path = "/api/v1/media/{id}/outgoing-links", + tag = "notes", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Outgoing links", body = OutgoingLinksResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_outgoing_links( State(state): State, Path(id): Path, @@ -241,6 +267,21 @@ pub async fn get_outgoing_links( /// Get graph data for visualization. /// /// GET /api/v1/notes/graph?center={uuid}&depth={n} +#[utoipa::path( + get, + path = "/api/v1/notes/graph", + tag = "notes", + params( + ("center" = Option, Query, description = "Center node ID"), + ("depth" = Option, Query, description = "Traversal depth (max 5, default 2)"), + ), + responses( + (status = 200, description = "Graph data", body = GraphResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_graph( State(state): State, Query(params): Query, @@ -256,6 +297,19 @@ pub async fn get_graph( /// Re-extract links from a media item. /// /// POST /api/v1/media/{id}/reindex-links +#[utoipa::path( + post, + path = "/api/v1/media/{id}/reindex-links", + tag = "notes", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Links reindexed", body = ReindexResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn reindex_links( State(state): State, Path(id): Path, @@ -304,6 +358,17 @@ pub async fn reindex_links( /// Resolve all unresolved links in the database. /// /// POST /api/v1/notes/resolve-links +#[utoipa::path( + post, + path = "/api/v1/notes/resolve-links", + tag = "notes", + responses( + (status = 200, description = "Links resolved", body = ResolveLinksResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn resolve_links( State(state): State, ) -> Result, ApiError> { @@ -315,6 +380,17 @@ pub async fn resolve_links( /// Get count of unresolved links. /// /// GET /api/v1/notes/unresolved-count +#[utoipa::path( + get, + path = "/api/v1/notes/unresolved-count", + tag = "notes", + responses( + (status = 200, description = "Unresolved link count", body = UnresolvedLinksResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_unresolved_count( State(state): State, ) -> Result, ApiError> { diff --git a/crates/pinakes-server/src/routes/photos.rs b/crates/pinakes-server/src/routes/photos.rs index 318c9d0..7320427 100644 --- a/crates/pinakes-server/src/routes/photos.rs +++ b/crates/pinakes-server/src/routes/photos.rs @@ -36,7 +36,7 @@ const fn default_timeline_limit() -> u64 { } /// Timeline group response -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct TimelineGroup { pub date: String, pub count: usize, @@ -54,7 +54,7 @@ pub struct MapQuery { } /// Map marker response -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct MapMarker { pub id: String, pub latitude: f64, @@ -63,6 +63,23 @@ pub struct MapMarker { pub date_taken: Option>, } +#[utoipa::path( + get, + path = "/api/v1/photos/timeline", + tag = "photos", + params( + ("group_by" = Option, Query, description = "Grouping: day, month, year"), + ("year" = Option, Query, description = "Filter by year"), + ("month" = Option, Query, description = "Filter by month"), + ("limit" = Option, Query, description = "Max items (default 10000)"), + ), + responses( + (status = 200, description = "Photo timeline groups", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] /// Get timeline of photos grouped by date pub async fn get_timeline( State(state): State, @@ -147,6 +164,24 @@ pub async fn get_timeline( Ok(Json(timeline)) } +#[utoipa::path( + get, + path = "/api/v1/photos/map", + tag = "photos", + params( + ("lat1" = f64, Query, description = "Bounding box latitude 1"), + ("lon1" = f64, Query, description = "Bounding box longitude 1"), + ("lat2" = f64, Query, description = "Bounding box latitude 2"), + ("lon2" = f64, Query, description = "Bounding box longitude 2"), + ), + responses( + (status = 200, description = "Map markers", body = Vec), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] /// Get photos in a bounding box for map view pub async fn get_map_photos( State(state): State, diff --git a/crates/pinakes-server/src/routes/playlists.rs b/crates/pinakes-server/src/routes/playlists.rs index f341458..420897e 100644 --- a/crates/pinakes-server/src/routes/playlists.rs +++ b/crates/pinakes-server/src/routes/playlists.rs @@ -51,6 +51,19 @@ async fn check_playlist_access( Ok(playlist) } +#[utoipa::path( + post, + path = "/api/v1/playlists", + tag = "playlists", + request_body = CreatePlaylistRequest, + responses( + (status = 200, description = "Playlist created", body = PlaylistResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn create_playlist( State(state): State, Extension(username): Extension, @@ -78,6 +91,17 @@ pub async fn create_playlist( Ok(Json(PlaylistResponse::from(playlist))) } +#[utoipa::path( + get, + path = "/api/v1/playlists", + tag = "playlists", + responses( + (status = 200, description = "List of playlists", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_playlists( State(state): State, Extension(username): Extension, @@ -93,6 +117,19 @@ pub async fn list_playlists( Ok(Json(visible)) } +#[utoipa::path( + get, + path = "/api/v1/playlists/{id}", + tag = "playlists", + params(("id" = Uuid, Path, description = "Playlist ID")), + responses( + (status = 200, description = "Playlist details", body = PlaylistResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_playlist( State(state): State, Extension(username): Extension, @@ -104,6 +141,21 @@ pub async fn get_playlist( Ok(Json(PlaylistResponse::from(playlist))) } +#[utoipa::path( + patch, + path = "/api/v1/playlists/{id}", + tag = "playlists", + params(("id" = Uuid, Path, description = "Playlist ID")), + request_body = UpdatePlaylistRequest, + responses( + (status = 200, description = "Playlist updated", body = PlaylistResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn update_playlist( State(state): State, Extension(username): Extension, @@ -133,6 +185,19 @@ pub async fn update_playlist( Ok(Json(PlaylistResponse::from(playlist))) } +#[utoipa::path( + delete, + path = "/api/v1/playlists/{id}", + tag = "playlists", + params(("id" = Uuid, Path, description = "Playlist ID")), + responses( + (status = 200, description = "Playlist deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn delete_playlist( State(state): State, Extension(username): Extension, @@ -144,6 +209,20 @@ pub async fn delete_playlist( Ok(Json(serde_json::json!({"deleted": true}))) } +#[utoipa::path( + post, + path = "/api/v1/playlists/{id}/items", + tag = "playlists", + params(("id" = Uuid, Path, description = "Playlist ID")), + request_body = PlaylistItemRequest, + responses( + (status = 200, description = "Item added"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn add_item( State(state): State, Extension(username): Extension, @@ -165,6 +244,22 @@ pub async fn add_item( Ok(Json(serde_json::json!({"added": true}))) } +#[utoipa::path( + delete, + path = "/api/v1/playlists/{id}/items/{media_id}", + tag = "playlists", + params( + ("id" = Uuid, Path, description = "Playlist ID"), + ("media_id" = Uuid, Path, description = "Media item ID"), + ), + responses( + (status = 200, description = "Item removed"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn remove_item( State(state): State, Extension(username): Extension, @@ -179,6 +274,19 @@ pub async fn remove_item( Ok(Json(serde_json::json!({"removed": true}))) } +#[utoipa::path( + get, + path = "/api/v1/playlists/{id}/items", + tag = "playlists", + params(("id" = Uuid, Path, description = "Playlist ID")), + responses( + (status = 200, description = "Playlist items", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_items( State(state): State, Extension(username): Extension, @@ -196,6 +304,20 @@ pub async fn list_items( )) } +#[utoipa::path( + patch, + path = "/api/v1/playlists/{id}/items/reorder", + tag = "playlists", + params(("id" = Uuid, Path, description = "Playlist ID")), + request_body = ReorderPlaylistRequest, + responses( + (status = 200, description = "Item reordered"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn reorder_item( State(state): State, Extension(username): Extension, @@ -211,6 +333,19 @@ pub async fn reorder_item( Ok(Json(serde_json::json!({"reordered": true}))) } +#[utoipa::path( + post, + path = "/api/v1/playlists/{id}/shuffle", + tag = "playlists", + params(("id" = Uuid, Path, description = "Playlist ID")), + responses( + (status = 200, description = "Shuffled playlist items", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn shuffle_playlist( State(state): State, Extension(username): Extension, diff --git a/crates/pinakes-server/src/routes/plugins.rs b/crates/pinakes-server/src/routes/plugins.rs index e3b13a0..e2b45b0 100644 --- a/crates/pinakes-server/src/routes/plugins.rs +++ b/crates/pinakes-server/src/routes/plugins.rs @@ -31,6 +31,17 @@ fn require_plugin_manager( } /// List all installed plugins +#[utoipa::path( + get, + path = "/api/v1/plugins", + tag = "plugins", + responses( + (status = 200, description = "List of plugins", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_plugins( State(state): State, ) -> Result>, ApiError> { @@ -46,6 +57,18 @@ pub async fn list_plugins( } /// Get a specific plugin by ID +#[utoipa::path( + get, + path = "/api/v1/plugins/{id}", + tag = "plugins", + params(("id" = String, Path, description = "Plugin ID")), + responses( + (status = 200, description = "Plugin details", body = PluginResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_plugin( State(state): State, Path(id): Path, @@ -63,6 +86,19 @@ pub async fn get_plugin( } /// Install a plugin from URL or file path +#[utoipa::path( + post, + path = "/api/v1/plugins", + tag = "plugins", + request_body = InstallPluginRequest, + responses( + (status = 200, description = "Plugin installed", body = PluginResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = [])) +)] pub async fn install_plugin( State(state): State, Json(req): Json, @@ -91,6 +127,19 @@ pub async fn install_plugin( } /// Uninstall a plugin +#[utoipa::path( + delete, + path = "/api/v1/plugins/{id}", + tag = "plugins", + params(("id" = String, Path, description = "Plugin ID")), + responses( + (status = 200, description = "Plugin uninstalled"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn uninstall_plugin( State(state): State, Path(id): Path, @@ -107,6 +156,20 @@ pub async fn uninstall_plugin( } /// Enable or disable a plugin +#[utoipa::path( + patch, + path = "/api/v1/plugins/{id}/toggle", + tag = "plugins", + params(("id" = String, Path, description = "Plugin ID")), + request_body = TogglePluginRequest, + responses( + (status = 200, description = "Plugin toggled"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn toggle_plugin( State(state): State, Path(id): Path, @@ -146,6 +209,16 @@ pub async fn toggle_plugin( } /// List all UI pages provided by loaded plugins +#[utoipa::path( + get, + path = "/api/v1/plugins/ui/pages", + tag = "plugins", + responses( + (status = 200, description = "Plugin UI pages", body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_plugin_ui_pages( State(state): State, ) -> Result>, ApiError> { @@ -166,6 +239,16 @@ pub async fn list_plugin_ui_pages( } /// List all UI widgets provided by loaded plugins +#[utoipa::path( + get, + path = "/api/v1/plugins/ui/widgets", + tag = "plugins", + responses( + (status = 200, description = "Plugin UI widgets", body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_plugin_ui_widgets( State(state): State, ) -> Result>, ApiError> { @@ -181,6 +264,17 @@ pub async fn list_plugin_ui_widgets( /// Receive a plugin event emitted from the UI and dispatch it to interested /// server-side event-handler plugins via the pipeline. +#[utoipa::path( + post, + path = "/api/v1/plugins/events", + tag = "plugins", + request_body = PluginEventRequest, + responses( + (status = 200, description = "Event received"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn emit_plugin_event( State(state): State, Json(req): Json, @@ -193,6 +287,16 @@ pub async fn emit_plugin_event( } /// List merged CSS custom property overrides from all enabled plugins +#[utoipa::path( + get, + path = "/api/v1/plugins/ui/theme", + tag = "plugins", + responses( + (status = 200, description = "Plugin UI theme extensions"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_plugin_ui_theme_extensions( State(state): State, ) -> Result>, ApiError> { @@ -201,6 +305,19 @@ pub async fn list_plugin_ui_theme_extensions( } /// Reload a plugin (for development) +#[utoipa::path( + post, + path = "/api/v1/plugins/{id}/reload", + tag = "plugins", + params(("id" = String, Path, description = "Plugin ID")), + responses( + (status = 200, description = "Plugin reloaded"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn reload_plugin( State(state): State, Path(id): Path, diff --git a/crates/pinakes-server/src/routes/saved_searches.rs b/crates/pinakes-server/src/routes/saved_searches.rs index 2439240..11bb4f0 100644 --- a/crates/pinakes-server/src/routes/saved_searches.rs +++ b/crates/pinakes-server/src/routes/saved_searches.rs @@ -6,14 +6,14 @@ use serde::{Deserialize, Serialize}; use crate::{error::ApiError, state::AppState}; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, utoipa::ToSchema)] pub struct CreateSavedSearchRequest { pub name: String, pub query: String, pub sort_order: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct SavedSearchResponse { pub id: String, pub name: String, @@ -31,6 +31,19 @@ const VALID_SORT_ORDERS: &[&str] = &[ "size_desc", ]; +#[utoipa::path( + post, + path = "/api/v1/searches", + tag = "saved_searches", + request_body = CreateSavedSearchRequest, + responses( + (status = 200, description = "Search saved", body = SavedSearchResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn create_saved_search( State(state): State, Json(req): Json, @@ -76,6 +89,17 @@ pub async fn create_saved_search( })) } +#[utoipa::path( + get, + path = "/api/v1/searches", + tag = "saved_searches", + responses( + (status = 200, description = "List of saved searches", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_saved_searches( State(state): State, ) -> Result>, ApiError> { @@ -100,6 +124,19 @@ pub async fn list_saved_searches( )) } +#[utoipa::path( + delete, + path = "/api/v1/searches/{id}", + tag = "saved_searches", + params(("id" = uuid::Uuid, Path, description = "Saved search ID")), + responses( + (status = 200, description = "Saved search deleted"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn delete_saved_search( State(state): State, Path(id): Path, diff --git a/crates/pinakes-server/src/routes/scan.rs b/crates/pinakes-server/src/routes/scan.rs index b9aef1b..f78b089 100644 --- a/crates/pinakes-server/src/routes/scan.rs +++ b/crates/pinakes-server/src/routes/scan.rs @@ -7,6 +7,19 @@ use crate::{ }; /// Trigger a scan as a background job. Returns the job ID immediately. +#[utoipa::path( + post, + path = "/api/v1/scan", + tag = "scan", + request_body = ScanRequest, + responses( + (status = 200, description = "Scan job submitted", body = ScanJobResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn trigger_scan( State(state): State, Json(req): Json, @@ -18,6 +31,16 @@ pub async fn trigger_scan( })) } +#[utoipa::path( + get, + path = "/api/v1/scan/status", + tag = "scan", + responses( + (status = 200, description = "Scan status", body = ScanStatusResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn scan_status( State(state): State, ) -> Json { diff --git a/crates/pinakes-server/src/routes/scheduled_tasks.rs b/crates/pinakes-server/src/routes/scheduled_tasks.rs index 4d50f76..270c4ab 100644 --- a/crates/pinakes-server/src/routes/scheduled_tasks.rs +++ b/crates/pinakes-server/src/routes/scheduled_tasks.rs @@ -5,6 +5,17 @@ use axum::{ use crate::{dto::ScheduledTaskResponse, error::ApiError, state::AppState}; +#[utoipa::path( + get, + path = "/api/v1/scheduled-tasks", + tag = "scheduled_tasks", + responses( + (status = 200, description = "List of scheduled tasks", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_scheduled_tasks( State(state): State, ) -> Result>, ApiError> { @@ -26,6 +37,19 @@ pub async fn list_scheduled_tasks( Ok(Json(responses)) } +#[utoipa::path( + post, + path = "/api/v1/scheduled-tasks/{id}/toggle", + tag = "scheduled_tasks", + params(("id" = String, Path, description = "Task ID")), + responses( + (status = 200, description = "Task toggled"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn toggle_scheduled_task( State(state): State, Path(id): Path, @@ -45,6 +69,19 @@ pub async fn toggle_scheduled_task( } } +#[utoipa::path( + post, + path = "/api/v1/scheduled-tasks/{id}/run", + tag = "scheduled_tasks", + params(("id" = String, Path, description = "Task ID")), + responses( + (status = 200, description = "Task triggered"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn run_scheduled_task_now( State(state): State, Path(id): Path, diff --git a/crates/pinakes-server/src/routes/search.rs b/crates/pinakes-server/src/routes/search.rs index eacec6e..bebb04b 100644 --- a/crates/pinakes-server/src/routes/search.rs +++ b/crates/pinakes-server/src/routes/search.rs @@ -22,6 +22,24 @@ fn resolve_sort(sort: Option<&str>) -> SortOrder { } } +#[utoipa::path( + get, + path = "/api/v1/search", + tag = "search", + params( + ("q" = String, Query, description = "Search query"), + ("sort" = Option, Query, description = "Sort order"), + ("offset" = Option, Query, description = "Pagination offset"), + ("limit" = Option, Query, description = "Pagination limit"), + ), + responses( + (status = 200, description = "Search results", body = SearchResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn search( State(state): State, Query(params): Query, @@ -56,6 +74,19 @@ pub async fn search( })) } +#[utoipa::path( + post, + path = "/api/v1/search", + tag = "search", + request_body = SearchRequestBody, + responses( + (status = 200, description = "Search results", body = SearchResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn search_post( State(state): State, Json(body): Json, diff --git a/crates/pinakes-server/src/routes/shares.rs b/crates/pinakes-server/src/routes/shares.rs index 39d00d9..965b79e 100644 --- a/crates/pinakes-server/src/routes/shares.rs +++ b/crates/pinakes-server/src/routes/shares.rs @@ -48,6 +48,19 @@ use crate::{ /// Create a new share /// POST /api/shares +#[utoipa::path( + post, + path = "/api/v1/shares", + tag = "shares", + request_body = CreateShareRequest, + responses( + (status = 200, description = "Share created", body = ShareResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn create_share( State(state): State, Extension(username): Extension, @@ -201,6 +214,20 @@ pub async fn create_share( /// List outgoing shares (shares I created) /// GET /api/shares/outgoing +#[utoipa::path( + get, + path = "/api/v1/shares/outgoing", + tag = "shares", + params( + ("offset" = Option, Query, description = "Pagination offset"), + ("limit" = Option, Query, description = "Pagination limit"), + ), + responses( + (status = 200, description = "Outgoing shares", body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_outgoing( State(state): State, Extension(username): Extension, @@ -220,6 +247,20 @@ pub async fn list_outgoing( /// List incoming shares (shares shared with me) /// GET /api/shares/incoming +#[utoipa::path( + get, + path = "/api/v1/shares/incoming", + tag = "shares", + params( + ("offset" = Option, Query, description = "Pagination offset"), + ("limit" = Option, Query, description = "Pagination limit"), + ), + responses( + (status = 200, description = "Incoming shares", body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_incoming( State(state): State, Extension(username): Extension, @@ -239,6 +280,19 @@ pub async fn list_incoming( /// Get share details /// GET /api/shares/{id} +#[utoipa::path( + get, + path = "/api/v1/shares/{id}", + tag = "shares", + params(("id" = Uuid, Path, description = "Share ID")), + responses( + (status = 200, description = "Share details", body = ShareResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_share( State(state): State, Extension(username): Extension, @@ -269,6 +323,20 @@ pub async fn get_share( /// Update a share /// PATCH /api/shares/{id} +#[utoipa::path( + patch, + path = "/api/v1/shares/{id}", + tag = "shares", + params(("id" = Uuid, Path, description = "Share ID")), + request_body = UpdateShareRequest, + responses( + (status = 200, description = "Share updated", body = ShareResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn update_share( State(state): State, Extension(username): Extension, @@ -349,6 +417,19 @@ pub async fn update_share( /// Delete (revoke) a share /// DELETE /api/shares/{id} +#[utoipa::path( + delete, + path = "/api/v1/shares/{id}", + tag = "shares", + params(("id" = Uuid, Path, description = "Share ID")), + responses( + (status = 204, description = "Share deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn delete_share( State(state): State, Extension(username): Extension, @@ -393,6 +474,19 @@ pub async fn delete_share( /// Batch delete shares /// POST /api/shares/batch/delete +#[utoipa::path( + post, + path = "/api/v1/shares/batch/delete", + tag = "shares", + request_body = BatchDeleteSharesRequest, + responses( + (status = 200, description = "Shares deleted"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = [])) +)] pub async fn batch_delete( State(state): State, Extension(username): Extension, @@ -432,6 +526,20 @@ pub async fn batch_delete( /// Access a public shared resource /// GET /api/shared/{token} +#[utoipa::path( + get, + path = "/api/v1/shared/{token}", + tag = "shares", + params( + ("token" = String, Path, description = "Share token"), + ("password" = Option, Query, description = "Share password if required"), + ), + responses( + (status = 200, description = "Shared content", body = SharedContentResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ) +)] pub async fn access_shared( State(state): State, Path(token): Path, @@ -599,6 +707,23 @@ pub async fn access_shared( /// Get share activity log /// GET /api/shares/{id}/activity +#[utoipa::path( + get, + path = "/api/v1/shares/{id}/activity", + tag = "shares", + params( + ("id" = Uuid, Path, description = "Share ID"), + ("offset" = Option, Query, description = "Pagination offset"), + ("limit" = Option, Query, description = "Pagination limit"), + ), + responses( + (status = 200, description = "Share activity", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_activity( State(state): State, Extension(username): Extension, @@ -632,6 +757,16 @@ pub async fn get_activity( /// Get unread share notifications /// GET /api/notifications/shares +#[utoipa::path( + get, + path = "/api/v1/notifications/shares", + tag = "shares", + responses( + (status = 200, description = "Unread notifications", body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_notifications( State(state): State, Extension(username): Extension, @@ -650,6 +785,17 @@ pub async fn get_notifications( /// Mark a notification as read /// POST /api/notifications/shares/{id}/read +#[utoipa::path( + post, + path = "/api/v1/notifications/shares/{id}/read", + tag = "shares", + params(("id" = Uuid, Path, description = "Notification ID")), + responses( + (status = 200, description = "Notification marked as read"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn mark_notification_read( State(state): State, Extension(username): Extension, @@ -667,6 +813,16 @@ pub async fn mark_notification_read( /// Mark all notifications as read /// POST /api/notifications/shares/read-all +#[utoipa::path( + post, + path = "/api/v1/notifications/shares/read-all", + tag = "shares", + responses( + (status = 200, description = "All notifications marked as read"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn mark_all_read( State(state): State, Extension(username): Extension, diff --git a/crates/pinakes-server/src/routes/social.rs b/crates/pinakes-server/src/routes/social.rs index 116146b..b378026 100644 --- a/crates/pinakes-server/src/routes/social.rs +++ b/crates/pinakes-server/src/routes/social.rs @@ -27,6 +27,20 @@ pub struct ShareLinkQuery { pub password: Option, } +#[utoipa::path( + post, + path = "/api/v1/media/{id}/rate", + tag = "social", + params(("id" = Uuid, Path, description = "Media item ID")), + request_body = CreateRatingRequest, + responses( + (status = 200, description = "Rating saved", body = RatingResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn rate_media( State(state): State, Extension(username): Extension, @@ -59,6 +73,18 @@ pub async fn rate_media( Ok(Json(RatingResponse::from(rating))) } +#[utoipa::path( + get, + path = "/api/v1/media/{id}/ratings", + tag = "social", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Media ratings", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_media_ratings( State(state): State, Path(id): Path, @@ -69,6 +95,20 @@ pub async fn get_media_ratings( )) } +#[utoipa::path( + post, + path = "/api/v1/media/{id}/comments", + tag = "social", + params(("id" = Uuid, Path, description = "Media item ID")), + request_body = CreateCommentRequest, + responses( + (status = 200, description = "Comment added", body = CommentResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn add_comment( State(state): State, Extension(username): Extension, @@ -91,6 +131,18 @@ pub async fn add_comment( Ok(Json(CommentResponse::from(comment))) } +#[utoipa::path( + get, + path = "/api/v1/media/{id}/comments", + tag = "social", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Media comments", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_media_comments( State(state): State, Path(id): Path, @@ -101,6 +153,18 @@ pub async fn get_media_comments( )) } +#[utoipa::path( + post, + path = "/api/v1/favorites", + tag = "social", + request_body = FavoriteRequest, + responses( + (status = 200, description = "Added to favorites"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn add_favorite( State(state): State, Extension(username): Extension, @@ -114,6 +178,18 @@ pub async fn add_favorite( Ok(Json(serde_json::json!({"added": true}))) } +#[utoipa::path( + delete, + path = "/api/v1/favorites/{media_id}", + tag = "social", + params(("media_id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Removed from favorites"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn remove_favorite( State(state): State, Extension(username): Extension, @@ -127,6 +203,17 @@ pub async fn remove_favorite( Ok(Json(serde_json::json!({"removed": true}))) } +#[utoipa::path( + get, + path = "/api/v1/favorites", + tag = "social", + responses( + (status = 200, description = "User favorites", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_favorites( State(state): State, Extension(username): Extension, @@ -145,6 +232,19 @@ pub async fn list_favorites( )) } +#[utoipa::path( + post, + path = "/api/v1/media/share", + tag = "social", + request_body = CreateShareLinkRequest, + responses( + (status = 200, description = "Share link created", body = ShareLinkResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn create_share_link( State(state): State, Extension(username): Extension, @@ -191,6 +291,20 @@ pub async fn create_share_link( Ok(Json(ShareLinkResponse::from(link))) } +#[utoipa::path( + get, + path = "/api/v1/shared/media/{token}", + tag = "social", + params( + ("token" = String, Path, description = "Share token"), + ("password" = Option, Query, description = "Share password"), + ), + responses( + (status = 200, description = "Shared media", body = MediaResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ) +)] pub async fn access_shared_media( State(state): State, Path(token): Path, diff --git a/crates/pinakes-server/src/routes/statistics.rs b/crates/pinakes-server/src/routes/statistics.rs index 24dc7b9..47d1a3b 100644 --- a/crates/pinakes-server/src/routes/statistics.rs +++ b/crates/pinakes-server/src/routes/statistics.rs @@ -2,6 +2,17 @@ use axum::{Json, extract::State}; use crate::{dto::LibraryStatisticsResponse, error::ApiError, state::AppState}; +#[utoipa::path( + get, + path = "/api/v1/statistics", + tag = "statistics", + responses( + (status = 200, description = "Library statistics", body = LibraryStatisticsResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn library_statistics( State(state): State, ) -> Result, ApiError> { diff --git a/crates/pinakes-server/src/routes/streaming.rs b/crates/pinakes-server/src/routes/streaming.rs index 92ae897..622b5aa 100644 --- a/crates/pinakes-server/src/routes/streaming.rs +++ b/crates/pinakes-server/src/routes/streaming.rs @@ -49,6 +49,18 @@ fn escape_xml(s: &str) -> String { .replace('\'', "'") } +#[utoipa::path( + get, + path = "/api/v1/media/{id}/stream/hls/master.m3u8", + tag = "streaming", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "HLS master playlist"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn hls_master_playlist( State(state): State, Path(id): Path, @@ -75,6 +87,22 @@ pub async fn hls_master_playlist( build_response("application/vnd.apple.mpegurl", playlist) } +#[utoipa::path( + get, + path = "/api/v1/media/{id}/stream/hls/{profile}/playlist.m3u8", + tag = "streaming", + params( + ("id" = Uuid, Path, description = "Media item ID"), + ("profile" = String, Path, description = "Transcode profile name"), + ), + responses( + (status = 200, description = "HLS variant playlist"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn hls_variant_playlist( State(state): State, Path((id, profile)): Path<(Uuid, String)>, @@ -112,6 +140,23 @@ pub async fn hls_variant_playlist( build_response("application/vnd.apple.mpegurl", playlist) } +#[utoipa::path( + get, + path = "/api/v1/media/{id}/stream/hls/{profile}/{segment}", + tag = "streaming", + params( + ("id" = Uuid, Path, description = "Media item ID"), + ("profile" = String, Path, description = "Transcode profile name"), + ("segment" = String, Path, description = "Segment filename"), + ), + responses( + (status = 200, description = "HLS segment data"), + (status = 202, description = "Segment not yet available"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn hls_segment( State(state): State, Path((id, profile, segment)): Path<(Uuid, String, String)>, @@ -167,6 +212,19 @@ pub async fn hls_segment( )) } +#[utoipa::path( + get, + path = "/api/v1/media/{id}/stream/dash/manifest.mpd", + tag = "streaming", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "DASH manifest"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn dash_manifest( State(state): State, Path(id): Path, @@ -216,6 +274,23 @@ pub async fn dash_manifest( build_response("application/dash+xml", mpd) } +#[utoipa::path( + get, + path = "/api/v1/media/{id}/stream/dash/{profile}/{segment}", + tag = "streaming", + params( + ("id" = Uuid, Path, description = "Media item ID"), + ("profile" = String, Path, description = "Transcode profile name"), + ("segment" = String, Path, description = "Segment filename"), + ), + responses( + (status = 200, description = "DASH segment data"), + (status = 202, description = "Segment not yet available"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn dash_segment( State(state): State, Path((id, profile, segment)): Path<(Uuid, String, String)>, diff --git a/crates/pinakes-server/src/routes/subtitles.rs b/crates/pinakes-server/src/routes/subtitles.rs index b8be6ca..2f71311 100644 --- a/crates/pinakes-server/src/routes/subtitles.rs +++ b/crates/pinakes-server/src/routes/subtitles.rs @@ -4,62 +4,185 @@ use axum::{ }; use pinakes_core::{ model::MediaId, - subtitles::{Subtitle, SubtitleFormat}, + subtitles::{ + Subtitle, + detect_format, + extract_embedded_track, + list_embedded_tracks, + validate_language_code, + }, }; use uuid::Uuid; use crate::{ - dto::{AddSubtitleRequest, SubtitleResponse, UpdateSubtitleOffsetRequest}, + dto::{ + AddSubtitleRequest, + SubtitleListResponse, + SubtitleResponse, + SubtitleTrackInfoResponse, + UpdateSubtitleOffsetRequest, + }, error::ApiError, state::AppState, }; +#[utoipa::path( + get, + path = "/api/v1/media/{id}/subtitles", + tag = "subtitles", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Subtitles and available embedded tracks", body = SubtitleListResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_subtitles( State(state): State, Path(id): Path, -) -> Result>, ApiError> { +) -> Result, ApiError> { + let item = state.storage.get_media(MediaId(id)).await?; let subtitles = state.storage.get_media_subtitles(MediaId(id)).await?; - Ok(Json( - subtitles.into_iter().map(SubtitleResponse::from).collect(), - )) + + let available_tracks = + list_embedded_tracks(&item.path).await.unwrap_or_default(); + + Ok(Json(SubtitleListResponse { + subtitles: subtitles + .into_iter() + .map(SubtitleResponse::from) + .collect(), + available_tracks: available_tracks + .into_iter() + .map(SubtitleTrackInfoResponse::from) + .collect(), + })) } +#[utoipa::path( + post, + path = "/api/v1/media/{id}/subtitles", + tag = "subtitles", + params(("id" = Uuid, Path, description = "Media item ID")), + request_body = AddSubtitleRequest, + responses( + (status = 200, description = "Subtitle added", body = SubtitleResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn add_subtitle( State(state): State, Path(id): Path, Json(req): Json, ) -> Result, ApiError> { - let format: SubtitleFormat = req.format.parse().map_err(|e: String| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation(e)) - })?; + // Validate language code if provided. + if let Some(ref lang) = req.language { + if !validate_language_code(lang) { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidLanguageCode(lang.clone()), + )); + } + } + let is_embedded = req.is_embedded.unwrap_or(false); - if !is_embedded && req.file_path.is_none() { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "file_path is required for non-embedded subtitles".into(), - ), - )); - } - if is_embedded && req.track_index.is_none() { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( + + let (file_path, resolved_format) = if is_embedded { + // Embedded subtitle: validate track_index and extract via ffmpeg. + let track_index = req.track_index.ok_or_else(|| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( "track_index is required for embedded subtitles".into(), - ), - )); - } - if req - .language - .as_ref() - .is_some_and(|l| l.is_empty() || l.len() > 64) - { - return Err(ApiError::bad_request("language must be 1-64 bytes")); - } + )) + })?; + + let item = state.storage.get_media(MediaId(id)).await?; + let tracks = list_embedded_tracks(&item.path).await?; + + let track = + tracks + .iter() + .find(|t| t.index == track_index) + .ok_or(ApiError( + pinakes_core::error::PinakesError::SubtitleTrackNotFound { + index: track_index, + }, + ))?; + + // Use the format detected from the embedded track metadata as + // authoritative. + let embedded_format = track.format; + let ext = embedded_format.to_string(); + let output_dir = pinakes_core::config::Config::default_data_dir() + .join("subtitles") + .join(id.to_string()); + let output_path = output_dir.join(format!("{track_index}.{ext}")); + + tokio::fs::create_dir_all(&output_dir).await.map_err(|e| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + format!("failed to create subtitle output dir: {e}"), + )) + })?; + + extract_embedded_track(&item.path, track_index, &output_path).await?; + + (Some(output_path), embedded_format) + } else { + // External subtitle file: validate path then detect format from content. + let path_str = req.file_path.ok_or_else(|| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + "file_path is required for non-embedded subtitles".into(), + )) + })?; + + let path = std::path::PathBuf::from(&path_str); + + use std::path::Component; + if !path.is_absolute() + || path.components().any(|c| c == Component::ParentDir) + { + return Err(ApiError::bad_request( + "file_path must be an absolute path within a configured root", + )); + } + let roots = state.config.read().await.directories.roots.clone(); + if !roots.iter().any(|root| path.starts_with(root)) { + return Err(ApiError::bad_request( + "file_path must be an absolute path within a configured root", + )); + } + + let exists = tokio::fs::try_exists(&path).await.map_err(|e| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + format!("failed to check subtitle file: {e}"), + )) + })?; + + if !exists { + return Err(ApiError(pinakes_core::error::PinakesError::FileNotFound( + path, + ))); + } + + // Detect the actual format from the file extension; use it as authoritative + // rather than trusting the client-supplied format field. + let detected_format = detect_format(&path).ok_or_else(|| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + format!("unrecognised subtitle format for: {}", path.display()), + )) + })?; + + (Some(path), detected_format) + }; + let subtitle = Subtitle { id: Uuid::now_v7(), media_id: MediaId(id), language: req.language, - format, - file_path: req.file_path.map(std::path::PathBuf::from), + format: resolved_format, + file_path, is_embedded, track_index: req.track_index, offset_ms: req.offset_ms.unwrap_or(0), @@ -69,6 +192,18 @@ pub async fn add_subtitle( Ok(Json(SubtitleResponse::from(subtitle))) } +#[utoipa::path( + delete, + path = "/api/v1/subtitles/{id}", + tag = "subtitles", + params(("id" = Uuid, Path, description = "Subtitle ID")), + responses( + (status = 200, description = "Subtitle deleted"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn delete_subtitle( State(state): State, Path(id): Path, @@ -77,6 +212,21 @@ pub async fn delete_subtitle( Ok(Json(serde_json::json!({"deleted": true}))) } +#[utoipa::path( + get, + path = "/api/v1/media/{media_id}/subtitles/{subtitle_id}/content", + tag = "subtitles", + params( + ("media_id" = Uuid, Path, description = "Media item ID"), + ("subtitle_id" = Uuid, Path, description = "Subtitle ID"), + ), + responses( + (status = 200, description = "Subtitle content"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_subtitle_content( State(state): State, Path((media_id, subtitle_id)): Path<(Uuid, Uuid)>, @@ -91,40 +241,65 @@ pub async fn get_subtitle_content( ))) })?; - if let Some(ref path) = subtitle.file_path { - let content = tokio::fs::read_to_string(path).await.map_err(|e| { + let path = subtitle.file_path.ok_or_else(|| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + "subtitle has no associated file to serve".into(), + )) + })?; + + let fmt = subtitle.format; + let content_type = fmt.mime_type(); + let body = if fmt.is_binary() { + let bytes = tokio::fs::read(&path).await.map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { ApiError(pinakes_core::error::PinakesError::FileNotFound( path.clone(), )) } else { ApiError(pinakes_core::error::PinakesError::InvalidOperation( - format!("failed to read subtitle file {}: {}", path.display(), e), + format!("failed to read subtitle file {}: {e}", path.display()), )) } })?; - let content_type = match subtitle.format { - SubtitleFormat::Vtt => "text/vtt", - SubtitleFormat::Srt => "application/x-subrip", - _ => "text/plain", - }; - axum::response::Response::builder() - .header("Content-Type", content_type) - .body(axum::body::Body::from(content)) - .map_err(|e| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - format!("failed to build response: {e}"), - )) - }) + axum::body::Body::from(bytes) } else { - Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "subtitle is embedded, no file to serve".into(), - ), - )) - } + let text = tokio::fs::read_to_string(&path).await.map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + ApiError(pinakes_core::error::PinakesError::FileNotFound( + path.clone(), + )) + } else { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + format!("failed to read subtitle file {}: {e}", path.display()), + )) + } + })?; + axum::body::Body::from(text) + }; + + axum::response::Response::builder() + .header("Content-Type", content_type) + .body(body) + .map_err(|e| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + format!("failed to build response: {e}"), + )) + }) } +#[utoipa::path( + patch, + path = "/api/v1/subtitles/{id}/offset", + tag = "subtitles", + params(("id" = Uuid, Path, description = "Subtitle ID")), + request_body = UpdateSubtitleOffsetRequest, + responses( + (status = 200, description = "Offset updated"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn update_offset( State(state): State, Path(id): Path, diff --git a/crates/pinakes-server/src/routes/sync.rs b/crates/pinakes-server/src/routes/sync.rs index e2ef48d..debc4cd 100644 --- a/crates/pinakes-server/src/routes/sync.rs +++ b/crates/pinakes-server/src/routes/sync.rs @@ -57,6 +57,19 @@ const DEFAULT_CHANGES_LIMIT: u64 = 100; /// Register a new sync device /// POST /api/sync/devices +#[utoipa::path( + post, + path = "/api/v1/sync/devices", + tag = "sync", + request_body = RegisterDeviceRequest, + responses( + (status = 200, description = "Device registered", body = DeviceRegistrationResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn register_device( State(state): State, Extension(username): Extension, @@ -111,6 +124,16 @@ pub async fn register_device( /// List user's sync devices /// GET /api/sync/devices +#[utoipa::path( + get, + path = "/api/v1/sync/devices", + tag = "sync", + responses( + (status = 200, description = "List of devices", body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_devices( State(state): State, Extension(username): Extension, @@ -127,6 +150,19 @@ pub async fn list_devices( /// Get device details /// GET /api/sync/devices/{id} +#[utoipa::path( + get, + path = "/api/v1/sync/devices/{id}", + tag = "sync", + params(("id" = Uuid, Path, description = "Device ID")), + responses( + (status = 200, description = "Device details", body = DeviceResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_device( State(state): State, Extension(username): Extension, @@ -149,6 +185,20 @@ pub async fn get_device( /// Update a device /// PUT /api/sync/devices/{id} +#[utoipa::path( + put, + path = "/api/v1/sync/devices/{id}", + tag = "sync", + params(("id" = Uuid, Path, description = "Device ID")), + request_body = UpdateDeviceRequest, + responses( + (status = 200, description = "Device updated", body = DeviceResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn update_device( State(state): State, Extension(username): Extension, @@ -185,6 +235,19 @@ pub async fn update_device( /// Delete a device /// DELETE /api/sync/devices/{id} +#[utoipa::path( + delete, + path = "/api/v1/sync/devices/{id}", + tag = "sync", + params(("id" = Uuid, Path, description = "Device ID")), + responses( + (status = 204, description = "Device deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn delete_device( State(state): State, Extension(username): Extension, @@ -213,6 +276,19 @@ pub async fn delete_device( /// Regenerate device token /// POST /api/sync/devices/{id}/token +#[utoipa::path( + post, + path = "/api/v1/sync/devices/{id}/token", + tag = "sync", + params(("id" = Uuid, Path, description = "Device ID")), + responses( + (status = 200, description = "Token regenerated", body = DeviceRegistrationResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn regenerate_token( State(state): State, Extension(username): Extension, @@ -253,6 +329,21 @@ pub async fn regenerate_token( /// Get changes since cursor /// GET /api/sync/changes +#[utoipa::path( + get, + path = "/api/v1/sync/changes", + tag = "sync", + params( + ("cursor" = Option, Query, description = "Sync cursor"), + ("limit" = Option, Query, description = "Max changes (max 1000)"), + ), + responses( + (status = 200, description = "Changes since cursor", body = ChangesResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_changes( State(state): State, Query(params): Query, @@ -290,6 +381,18 @@ pub async fn get_changes( /// Report local changes from client /// POST /api/sync/report +#[utoipa::path( + post, + path = "/api/v1/sync/report", + tag = "sync", + request_body = ReportChangesRequest, + responses( + (status = 200, description = "Changes processed", body = ReportChangesResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn report_changes( State(state): State, Extension(_username): Extension, @@ -392,6 +495,18 @@ pub async fn report_changes( /// Acknowledge processed changes /// POST /api/sync/ack +#[utoipa::path( + post, + path = "/api/v1/sync/ack", + tag = "sync", + request_body = AcknowledgeChangesRequest, + responses( + (status = 200, description = "Changes acknowledged"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn acknowledge_changes( State(state): State, Extension(_username): Extension, @@ -422,6 +537,16 @@ pub async fn acknowledge_changes( /// List unresolved conflicts /// GET /api/sync/conflicts +#[utoipa::path( + get, + path = "/api/v1/sync/conflicts", + tag = "sync", + responses( + (status = 200, description = "Unresolved conflicts", body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_conflicts( State(state): State, Extension(_username): Extension, @@ -451,6 +576,19 @@ pub async fn list_conflicts( /// Resolve a sync conflict /// POST /api/sync/conflicts/{id}/resolve +#[utoipa::path( + post, + path = "/api/v1/sync/conflicts/{id}/resolve", + tag = "sync", + params(("id" = Uuid, Path, description = "Conflict ID")), + request_body = ResolveConflictRequest, + responses( + (status = 200, description = "Conflict resolved"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn resolve_conflict( State(state): State, Extension(_username): Extension, @@ -477,6 +615,18 @@ pub async fn resolve_conflict( /// Create an upload session for chunked upload /// POST /api/sync/upload +#[utoipa::path( + post, + path = "/api/v1/sync/upload", + tag = "sync", + request_body = CreateUploadSessionRequest, + responses( + (status = 200, description = "Upload session created", body = UploadSessionResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn create_upload( State(state): State, Extension(_username): Extension, @@ -541,6 +691,23 @@ pub async fn create_upload( /// Upload a chunk /// PUT /api/sync/upload/{id}/chunks/{index} +#[utoipa::path( + put, + path = "/api/v1/sync/upload/{id}/chunks/{index}", + tag = "sync", + params( + ("id" = Uuid, Path, description = "Upload session ID"), + ("index" = u64, Path, description = "Chunk index"), + ), + request_body(content = Vec, description = "Chunk binary data", content_type = "application/octet-stream"), + responses( + (status = 200, description = "Chunk received", body = ChunkUploadedResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn upload_chunk( State(state): State, Path((session_id, chunk_index)): Path<(Uuid, u64)>, @@ -590,6 +757,18 @@ pub async fn upload_chunk( /// Get upload session status /// GET /api/sync/upload/{id} +#[utoipa::path( + get, + path = "/api/v1/sync/upload/{id}", + tag = "sync", + params(("id" = Uuid, Path, description = "Upload session ID")), + responses( + (status = 200, description = "Upload session status", body = UploadSessionResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_upload_status( State(state): State, Path(id): Path, @@ -603,6 +782,19 @@ pub async fn get_upload_status( /// Complete an upload session /// POST /api/sync/upload/{id}/complete +#[utoipa::path( + post, + path = "/api/v1/sync/upload/{id}/complete", + tag = "sync", + params(("id" = Uuid, Path, description = "Upload session ID")), + responses( + (status = 200, description = "Upload completed"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn complete_upload( State(state): State, Path(id): Path, @@ -759,6 +951,18 @@ pub async fn complete_upload( /// Cancel an upload session /// DELETE /api/sync/upload/{id} +#[utoipa::path( + delete, + path = "/api/v1/sync/upload/{id}", + tag = "sync", + params(("id" = Uuid, Path, description = "Upload session ID")), + responses( + (status = 204, description = "Upload cancelled"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn cancel_upload( State(state): State, Path(id): Path, @@ -789,6 +993,19 @@ pub async fn cancel_upload( /// Download a file for sync (supports Range header) /// GET /api/sync/download/{*path} +#[utoipa::path( + get, + path = "/api/v1/sync/download/{path}", + tag = "sync", + params(("path" = String, Path, description = "File path")), + responses( + (status = 200, description = "File content"), + (status = 206, description = "Partial content"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn download_file( State(state): State, Path(path): Path, diff --git a/crates/pinakes-server/src/routes/tags.rs b/crates/pinakes-server/src/routes/tags.rs index 3c12ec2..506f855 100644 --- a/crates/pinakes-server/src/routes/tags.rs +++ b/crates/pinakes-server/src/routes/tags.rs @@ -11,6 +11,20 @@ use crate::{ state::AppState, }; +#[utoipa::path( + post, + path = "/api/v1/tags", + tag = "tags", + request_body = CreateTagRequest, + responses( + (status = 200, description = "Tag created", body = TagResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn create_tag( State(state): State, Json(req): Json, @@ -28,6 +42,17 @@ pub async fn create_tag( Ok(Json(TagResponse::from(tag))) } +#[utoipa::path( + get, + path = "/api/v1/tags", + tag = "tags", + responses( + (status = 200, description = "List of tags", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_tags( State(state): State, ) -> Result>, ApiError> { @@ -35,6 +60,19 @@ pub async fn list_tags( Ok(Json(tags.into_iter().map(TagResponse::from).collect())) } +#[utoipa::path( + get, + path = "/api/v1/tags/{id}", + tag = "tags", + params(("id" = Uuid, Path, description = "Tag ID")), + responses( + (status = 200, description = "Tag", body = TagResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_tag( State(state): State, Path(id): Path, @@ -43,6 +81,20 @@ pub async fn get_tag( Ok(Json(TagResponse::from(tag))) } +#[utoipa::path( + delete, + path = "/api/v1/tags/{id}", + tag = "tags", + params(("id" = Uuid, Path, description = "Tag ID")), + responses( + (status = 200, description = "Tag deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn delete_tag( State(state): State, Path(id): Path, @@ -51,6 +103,21 @@ pub async fn delete_tag( Ok(Json(serde_json::json!({"deleted": true}))) } +#[utoipa::path( + post, + path = "/api/v1/media/{media_id}/tags", + tag = "tags", + params(("media_id" = Uuid, Path, description = "Media item ID")), + request_body = TagMediaRequest, + responses( + (status = 200, description = "Tag applied"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn tag_media( State(state): State, Path(media_id): Path, @@ -70,6 +137,23 @@ pub async fn tag_media( Ok(Json(serde_json::json!({"tagged": true}))) } +#[utoipa::path( + delete, + path = "/api/v1/media/{media_id}/tags/{tag_id}", + tag = "tags", + params( + ("media_id" = Uuid, Path, description = "Media item ID"), + ("tag_id" = Uuid, Path, description = "Tag ID"), + ), + responses( + (status = 200, description = "Tag removed"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn untag_media( State(state): State, Path((media_id, tag_id)): Path<(Uuid, Uuid)>, @@ -88,6 +172,19 @@ pub async fn untag_media( Ok(Json(serde_json::json!({"untagged": true}))) } +#[utoipa::path( + get, + path = "/api/v1/media/{media_id}/tags", + tag = "tags", + params(("media_id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "Media tags", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_media_tags( State(state): State, Path(media_id): Path, diff --git a/crates/pinakes-server/src/routes/transcode.rs b/crates/pinakes-server/src/routes/transcode.rs index c57becb..81a8b5c 100644 --- a/crates/pinakes-server/src/routes/transcode.rs +++ b/crates/pinakes-server/src/routes/transcode.rs @@ -11,6 +11,20 @@ use crate::{ state::AppState, }; +#[utoipa::path( + post, + path = "/api/v1/media/{id}/transcode", + tag = "transcode", + params(("id" = Uuid, Path, description = "Media item ID")), + request_body = CreateTranscodeRequest, + responses( + (status = 200, description = "Transcode job submitted"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn start_transcode( State(state): State, Path(id): Path, @@ -29,6 +43,18 @@ pub async fn start_transcode( Ok(Json(serde_json::json!({"job_id": job_id.to_string()}))) } +#[utoipa::path( + get, + path = "/api/v1/transcode/{id}", + tag = "transcode", + params(("id" = Uuid, Path, description = "Transcode session ID")), + responses( + (status = 200, description = "Transcode session details", body = TranscodeSessionResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_session( State(state): State, Path(id): Path, @@ -37,6 +63,16 @@ pub async fn get_session( Ok(Json(TranscodeSessionResponse::from(session))) } +#[utoipa::path( + get, + path = "/api/v1/transcode", + tag = "transcode", + responses( + (status = 200, description = "List of transcode sessions", body = Vec), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_sessions( State(state): State, Query(params): Query, @@ -51,6 +87,18 @@ pub async fn list_sessions( )) } +#[utoipa::path( + delete, + path = "/api/v1/transcode/{id}", + tag = "transcode", + params(("id" = Uuid, Path, description = "Transcode session ID")), + responses( + (status = 200, description = "Transcode session cancelled"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn cancel_session( State(state): State, Path(id): Path, diff --git a/crates/pinakes-server/src/routes/upload.rs b/crates/pinakes-server/src/routes/upload.rs index 947757f..cba6451 100644 --- a/crates/pinakes-server/src/routes/upload.rs +++ b/crates/pinakes-server/src/routes/upload.rs @@ -32,6 +32,18 @@ fn sanitize_content_disposition(filename: &str) -> String { /// Upload a file to managed storage /// POST /api/upload +#[utoipa::path( + post, + path = "/api/v1/upload", + tag = "upload", + responses( + (status = 200, description = "File uploaded", body = UploadResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn upload_file( State(state): State, mut multipart: Multipart, @@ -85,6 +97,19 @@ pub async fn upload_file( /// Download a managed file /// GET /api/media/{id}/download +#[utoipa::path( + get, + path = "/api/v1/media/{id}/download", + tag = "upload", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 200, description = "File content"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn download_file( State(state): State, Path(id): Path, @@ -154,6 +179,19 @@ pub async fn download_file( /// Migrate an external file to managed storage /// POST /api/media/{id}/move-to-managed +#[utoipa::path( + post, + path = "/api/v1/media/{id}/move-to-managed", + tag = "upload", + params(("id" = Uuid, Path, description = "Media item ID")), + responses( + (status = 204, description = "File migrated"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn move_to_managed( State(state): State, Path(id): Path, @@ -177,6 +215,17 @@ pub async fn move_to_managed( /// Get managed storage statistics /// GET /api/managed/stats +#[utoipa::path( + get, + path = "/api/v1/managed/stats", + tag = "upload", + responses( + (status = 200, description = "Managed storage statistics", body = ManagedStorageStatsResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn managed_stats( State(state): State, ) -> ApiResult> { diff --git a/crates/pinakes-server/src/routes/users.rs b/crates/pinakes-server/src/routes/users.rs index e97e8a5..f88e466 100644 --- a/crates/pinakes-server/src/routes/users.rs +++ b/crates/pinakes-server/src/routes/users.rs @@ -16,6 +16,17 @@ use crate::{ }; /// List all users (admin only) +#[utoipa::path( + get, + path = "/api/v1/admin/users", + tag = "users", + responses( + (status = 200, description = "List of users", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_users( State(state): State, ) -> Result>, ApiError> { @@ -24,6 +35,24 @@ pub async fn list_users( } /// Create a new user (admin only) +#[utoipa::path( + post, + path = "/api/v1/admin/users", + tag = "users", + request_body( + content = inline(serde_json::Value), + description = "username, password, role, and optional profile fields", + content_type = "application/json" + ), + responses( + (status = 200, description = "User created", body = UserResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] pub async fn create_user( State(state): State, Json(req): Json, @@ -74,6 +103,19 @@ pub async fn create_user( } /// Get a specific user by ID +#[utoipa::path( + get, + path = "/api/v1/admin/users/{id}", + tag = "users", + params(("id" = String, Path, description = "User ID")), + responses( + (status = 200, description = "User details", body = UserResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_user( State(state): State, Path(id): Path, @@ -90,6 +132,25 @@ pub async fn get_user( } /// Update a user +#[utoipa::path( + patch, + path = "/api/v1/admin/users/{id}", + tag = "users", + params(("id" = String, Path, description = "User ID")), + request_body( + content = inline(serde_json::Value), + description = "Optional password, role, or profile fields to update", + content_type = "application/json" + ), + responses( + (status = 200, description = "User updated", body = UserResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn update_user( State(state): State, Path(id): Path, @@ -125,6 +186,19 @@ pub async fn update_user( } /// Delete a user (admin only) +#[utoipa::path( + delete, + path = "/api/v1/admin/users/{id}", + tag = "users", + params(("id" = String, Path, description = "User ID")), + responses( + (status = 200, description = "User deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = [])) +)] pub async fn delete_user( State(state): State, Path(id): Path, @@ -141,6 +215,18 @@ pub async fn delete_user( } /// Get user's accessible libraries +#[utoipa::path( + get, + path = "/api/v1/admin/users/{id}/libraries", + tag = "users", + params(("id" = String, Path, description = "User ID")), + responses( + (status = 200, description = "User libraries", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = [])) +)] pub async fn get_user_libraries( State(state): State, Path(id): Path, @@ -177,6 +263,20 @@ fn validate_root_path(path: &str) -> Result<(), ApiError> { } /// Grant library access to a user (admin only) +#[utoipa::path( + post, + path = "/api/v1/admin/users/{id}/libraries", + tag = "users", + params(("id" = String, Path, description = "User ID")), + request_body = GrantLibraryAccessRequest, + responses( + (status = 200, description = "Access granted"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = [])) +)] pub async fn grant_library_access( State(state): State, Path(id): Path, @@ -202,6 +302,20 @@ pub async fn grant_library_access( /// /// Uses a JSON body instead of a path parameter because `root_path` may contain /// slashes that conflict with URL routing. +#[utoipa::path( + delete, + path = "/api/v1/admin/users/{id}/libraries", + tag = "users", + params(("id" = String, Path, description = "User ID")), + request_body = RevokeLibraryAccessRequest, + responses( + (status = 200, description = "Access revoked"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = [])) +)] pub async fn revoke_library_access( State(state): State, Path(id): Path, diff --git a/crates/pinakes-server/src/routes/webhooks.rs b/crates/pinakes-server/src/routes/webhooks.rs index b2c5ca5..ca53d70 100644 --- a/crates/pinakes-server/src/routes/webhooks.rs +++ b/crates/pinakes-server/src/routes/webhooks.rs @@ -3,12 +3,23 @@ use serde::Serialize; use crate::{error::ApiError, state::AppState}; -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct WebhookInfo { pub url: String, pub events: Vec, } +#[utoipa::path( + get, + path = "/api/v1/webhooks", + tag = "webhooks", + responses( + (status = 200, description = "List of configured webhooks", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = [])) +)] pub async fn list_webhooks( State(state): State, ) -> Result>, ApiError> { @@ -26,6 +37,17 @@ pub async fn list_webhooks( Ok(Json(hooks)) } +#[utoipa::path( + post, + path = "/api/v1/webhooks/test", + tag = "webhooks", + responses( + (status = 200, description = "Test webhook sent"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = [])) +)] pub async fn test_webhook( State(state): State, ) -> Result, ApiError> { diff --git a/crates/pinakes-server/tests/books.rs b/crates/pinakes-server/tests/books.rs index dbd0bce..5b7efdd 100644 --- a/crates/pinakes-server/tests/books.rs +++ b/crates/pinakes-server/tests/books.rs @@ -32,10 +32,7 @@ async fn get_book_metadata_not_found() { .oneshot(get(&format!("/api/v1/books/{fake_id}/metadata"))) .await .unwrap(); - assert!( - resp.status() == StatusCode::NOT_FOUND - || resp.status() == StatusCode::INTERNAL_SERVER_ERROR - ); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[tokio::test] @@ -77,10 +74,8 @@ async fn reading_progress_nonexistent_book() { )) .await .unwrap(); - // Nonexistent book; expect NOT_FOUND or empty response - assert!( - resp.status() == StatusCode::NOT_FOUND || resp.status() == StatusCode::OK - ); + // Nonexistent book always returns 404. + assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[tokio::test] @@ -96,11 +91,8 @@ async fn update_reading_progress_nonexistent_book() { )) .await .unwrap(); - // Nonexistent book; expect NOT_FOUND or error - assert!( - resp.status() == StatusCode::NOT_FOUND - || resp.status() == StatusCode::INTERNAL_SERVER_ERROR - ); + // Nonexistent book: handler verifies existence first, so always 404. + assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[tokio::test] diff --git a/crates/pinakes-server/tests/common/mod.rs b/crates/pinakes-server/tests/common/mod.rs index d4d38c9..6d8ba18 100644 --- a/crates/pinakes-server/tests/common/mod.rs +++ b/crates/pinakes-server/tests/common/mod.rs @@ -154,6 +154,7 @@ pub fn default_config() -> Config { authentication_disabled: true, cors_enabled: false, cors_origins: vec![], + swagger_ui: false, }, rate_limits: RateLimitConfig::default(), ui: UiConfig::default(), diff --git a/crates/pinakes-server/tests/notes.rs b/crates/pinakes-server/tests/notes.rs index 0dc246e..2dddd1e 100644 --- a/crates/pinakes-server/tests/notes.rs +++ b/crates/pinakes-server/tests/notes.rs @@ -51,7 +51,26 @@ async fn notes_graph_empty() { .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = response_body(resp).await; - assert!(body.is_object() || body.is_array()); + // Fresh database: graph must be empty. + if let Some(arr) = body.as_array() { + assert!(arr.is_empty(), "graph should be empty, got {arr:?}"); + } else if let Some(obj) = body.as_object() { + // Accept an object if the schema uses {nodes:[], edges:[]} style. + let nodes_empty = obj + .get("nodes") + .and_then(|v| v.as_array()) + .map_or(true, |a| a.is_empty()); + let edges_empty = obj + .get("edges") + .and_then(|v| v.as_array()) + .map_or(true, |a| a.is_empty()); + assert!( + nodes_empty && edges_empty, + "graph should be empty, got {obj:?}" + ); + } else { + panic!("expected array or object, got {body}"); + } } #[tokio::test] @@ -62,6 +81,12 @@ async fn unresolved_count_zero() { .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); + let body = response_body(resp).await; + // Fresh database has no unresolved links. + let count = body["count"] + .as_u64() + .expect("response should have a numeric 'count' field"); + assert_eq!(count, 0, "expected zero unresolved links in fresh database"); } #[tokio::test] -- 2.43.0 From 934691c0f90ebdbbf6cae2fd7bf809f0f55d382e Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Mar 2026 02:18:48 +0300 Subject: [PATCH 17/30] docs: auto-generate API route documentation Signed-off-by: NotAShelf Change-Id: Id0d1f9769b7ccdbf83d5fa78adef62e46a6a6964 --- Cargo.lock | Bin 242493 -> 244600 bytes Cargo.toml | 5 +- docs/api/analytics.md | 117 + docs/api/audit.md | 27 + docs/api/auth.md | 103 + docs/api/backup.md | 27 + docs/api/books.md | 208 + docs/api/collections.md | 157 + docs/api/config.md | 120 + docs/api/database.md | 51 + docs/api/duplicates.md | 20 + docs/api/enrichment.md | 71 + docs/api/export.md | 42 + docs/api/health.md | 63 + docs/api/integrity.md | 99 + docs/api/jobs.md | 62 + docs/api/media.md | 640 ++ docs/api/notes.md | 142 + docs/api/openapi.json | 12810 ++++++++++++++++++++++++++++++++++ docs/api/photos.md | 57 + docs/api/playlists.md | 229 + docs/api/plugins.md | 209 + docs/api/saved_searches.md | 62 + docs/api/scan.md | 42 + docs/api/scheduled_tasks.md | 62 + docs/api/search.md | 51 + docs/api/shares.md | 282 + docs/api/social.md | 196 + docs/api/statistics.md | 20 + docs/api/streaming.md | 115 + docs/api/subtitles.md | 120 + docs/api/sync.md | 412 ++ docs/api/tags.md | 157 + docs/api/transcode.md | 86 + docs/api/upload.md | 89 + docs/api/users.md | 207 + docs/api/webhooks.md | 34 + xtask/Cargo.toml | 17 + xtask/src/docs.rs | 215 + xtask/src/main.rs | 19 + 40 files changed, 17444 insertions(+), 1 deletion(-) create mode 100644 docs/api/analytics.md create mode 100644 docs/api/audit.md create mode 100644 docs/api/auth.md create mode 100644 docs/api/backup.md create mode 100644 docs/api/books.md create mode 100644 docs/api/collections.md create mode 100644 docs/api/config.md create mode 100644 docs/api/database.md create mode 100644 docs/api/duplicates.md create mode 100644 docs/api/enrichment.md create mode 100644 docs/api/export.md create mode 100644 docs/api/health.md create mode 100644 docs/api/integrity.md create mode 100644 docs/api/jobs.md create mode 100644 docs/api/media.md create mode 100644 docs/api/notes.md create mode 100644 docs/api/openapi.json create mode 100644 docs/api/photos.md create mode 100644 docs/api/playlists.md create mode 100644 docs/api/plugins.md create mode 100644 docs/api/saved_searches.md create mode 100644 docs/api/scan.md create mode 100644 docs/api/scheduled_tasks.md create mode 100644 docs/api/search.md create mode 100644 docs/api/shares.md create mode 100644 docs/api/social.md create mode 100644 docs/api/statistics.md create mode 100644 docs/api/streaming.md create mode 100644 docs/api/subtitles.md create mode 100644 docs/api/sync.md create mode 100644 docs/api/tags.md create mode 100644 docs/api/transcode.md create mode 100644 docs/api/upload.md create mode 100644 docs/api/users.md create mode 100644 docs/api/webhooks.md create mode 100644 xtask/Cargo.toml create mode 100644 xtask/src/docs.rs create mode 100644 xtask/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 8a387d61e721744155ed827df064e7d311ae2df4..5e977d83e95e4354b111abfdd27f1587439e5edd 100644 GIT binary patch delta 1040 zcmZvbOOI4V6vxvw10)beTbhOtFimS@F*K?7V*wpEE>R+`h~lYJr-ly9(COQagt*Xz zwGpmKB}Oxeal_7q$*f%P1&E)(rMh$25KT<<(mi2@$FAz!ll&j|SHJwe^7oUKFYdN} zW~*#xFdG%oW^b3T{%sxD`hc;|uXF~q9x3$? z-l9o0y1CIi-|PFD|8aT9wGawpP{x3AE_s!?r7SWBZK8+hF*VaDr1!#dCtR{NWrHqw zpJZvh!Mf$AtF7hULSislAK#dpn#`){&TKXv-Kfet7ulJUyTke7ONK5a;DJlh=+ILL zPM82uc}+I)j6tB3LOGMI4_avpB)H4krRJ+btlM{w)!UCwy#Atk{rKCXW%VNKpW1aW zxAlrnOvFT)N3xk*wi%RCv5`?2bZ|0Arz1C6NCg~{qm0p0PTqMksA7F@e|NdM26CRb zeEkXAxAo{__SO$a7JqPWW>cU3>%$8&Bg%lDqZFu`n!S{ccn~tmC~Y;wAA+qh7P5PKutrc~O$u`~M@LiM|?6UTQn z*J$Ipe&=zAHo1_vPeC?#LDp!d<&hu^h2r@RYnl#V!{->Qig>L=1x%API{{d86Mce=Y delta 47 zcmV+~0MP&V_71(~4uFIKv;r#vmlg#A9+!^;0tdH&0s?$nw;y2wC8D>!2Lt$@wCM diff --git a/Cargo.toml b/Cargo.toml index 1d5f56d..8d9ea1e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/*"] +members = ["crates/*", "xtask"] exclude = ["crates/pinakes-core/tests/fixtures/test-plugin"] resolver = "3" @@ -92,6 +92,9 @@ http = "1.4.0" wasmtime = { version = "42.0.1", features = ["component-model"] } wit-bindgen = "0.53.1" tempfile = "3.26.0" +utoipa = { version = "5.4.0", features = ["axum_extras", "uuid", "chrono"] } +utoipa-axum = { version = "0.2.0" } +utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] } # See: # diff --git a/docs/api/analytics.md b/docs/api/analytics.md new file mode 100644 index 0000000..f706d2f --- /dev/null +++ b/docs/api/analytics.md @@ -0,0 +1,117 @@ +# Analytics + +Usage analytics and viewing history + +## Endpoints + +### POST /api/v1/analytics/events + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Event recorded | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### GET /api/v1/analytics/most-viewed + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `limit` | query | No | Maximum number of results | +| `offset` | query | No | Pagination offset | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Most viewed media | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### GET /api/v1/analytics/recently-viewed + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `limit` | query | No | Maximum number of results | +| `offset` | query | No | Pagination offset | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Recently viewed media | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### GET /api/v1/media/{id}/progress + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Watch progress | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | + +--- + +### PUT /api/v1/media/{id}/progress + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Progress updated | +| 400 | Bad request | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | + +--- + diff --git a/docs/api/audit.md b/docs/api/audit.md new file mode 100644 index 0000000..adcf493 --- /dev/null +++ b/docs/api/audit.md @@ -0,0 +1,27 @@ +# Audit + +Audit log entries + +## Endpoints + +### GET /api/v1/audit + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Page size | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Audit log entries | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + diff --git a/docs/api/auth.md b/docs/api/auth.md new file mode 100644 index 0000000..f62c099 --- /dev/null +++ b/docs/api/auth.md @@ -0,0 +1,103 @@ +# Auth + +Authentication and session management + +## Endpoints + +### POST /api/v1/auth/login + +**Authentication:** Not required + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Login successful | +| 400 | Bad request | +| 401 | Invalid credentials | +| 500 | Internal server error | + +--- + +### POST /api/v1/auth/logout + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Logged out | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### GET /api/v1/auth/me + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Current user info | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### POST /api/v1/auth/refresh + +Refresh the current session, extending its expiry by the configured +duration. + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Session refreshed | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### POST /api/v1/auth/revoke-all + +Revoke all sessions for the current user + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | All sessions revoked | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### GET /api/v1/auth/sessions + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Active sessions | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + diff --git a/docs/api/backup.md b/docs/api/backup.md new file mode 100644 index 0000000..e7c7884 --- /dev/null +++ b/docs/api/backup.md @@ -0,0 +1,27 @@ +# Backup + +Database backup + +## Endpoints + +### POST /api/v1/admin/backup + +Create a database backup and return it as a downloadable file. +POST /api/v1/admin/backup + +For `SQLite`: creates a backup via VACUUM INTO and returns the file. +For `PostgreSQL`: returns unsupported error (use `pg_dump` instead). + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Backup file download | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + diff --git a/docs/api/books.md b/docs/api/books.md new file mode 100644 index 0000000..6af55ad --- /dev/null +++ b/docs/api/books.md @@ -0,0 +1,208 @@ +# Books + +Book metadata, series, authors, and reading progress + +## Endpoints + +### GET /api/v1/books + +List all books with optional search filters + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `isbn` | query | No | Filter by ISBN | +| `author` | query | No | Filter by author | +| `series` | query | No | Filter by series | +| `publisher` | query | No | Filter by publisher | +| `language` | query | No | Filter by language | +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Pagination limit | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | List of books | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### GET /api/v1/books/authors + +List all authors with book counts + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Pagination limit | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Authors with book counts | +| 401 | Unauthorized | + +--- + +### GET /api/v1/books/authors/{name}/books + +Get books by a specific author + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `name` | path | Yes | Author name | +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Pagination limit | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Books by author | +| 401 | Unauthorized | + +--- + +### GET /api/v1/books/reading-list + +Get user's reading list + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `status` | query | No | Filter by reading status | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Reading list | +| 401 | Unauthorized | + +--- + +### GET /api/v1/books/series + +List all series with book counts + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | List of series with counts | +| 401 | Unauthorized | + +--- + +### GET /api/v1/books/series/{name} + +Get books in a specific series + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `name` | path | Yes | Series name | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Books in series | +| 401 | Unauthorized | + +--- + +### GET /api/v1/books/{id}/metadata + +Get book metadata by media ID + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Book metadata | +| 401 | Unauthorized | +| 404 | Not found | + +--- + +### GET /api/v1/books/{id}/progress + +Get reading progress for a book + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Reading progress | +| 401 | Unauthorized | +| 404 | Not found | + +--- + +### PUT /api/v1/books/{id}/progress + +Update reading progress for a book + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 204 | Progress updated | +| 400 | Bad request | +| 401 | Unauthorized | + +--- + diff --git a/docs/api/collections.md b/docs/api/collections.md new file mode 100644 index 0000000..f86b5e0 --- /dev/null +++ b/docs/api/collections.md @@ -0,0 +1,157 @@ +# Collections + +Media collections + +## Endpoints + +### GET /api/v1/collections + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | List of collections | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### POST /api/v1/collections + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Collection created | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + +### GET /api/v1/collections/{id} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Collection ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Collection | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | + +--- + +### DELETE /api/v1/collections/{id} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Collection ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Collection deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | + +--- + +### GET /api/v1/collections/{id}/members + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Collection ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Collection members | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | + +--- + +### POST /api/v1/collections/{id}/members + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Collection ID | + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Member added | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | + +--- + +### DELETE /api/v1/collections/{id}/members/{media_id} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Collection ID | +| `media_id` | path | Yes | Media item ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Member removed | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | + +--- + diff --git a/docs/api/config.md b/docs/api/config.md new file mode 100644 index 0000000..f88299f --- /dev/null +++ b/docs/api/config.md @@ -0,0 +1,120 @@ +# Config + +Server configuration + +## Endpoints + +### GET /api/v1/config + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Current server configuration | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + +### POST /api/v1/config/roots + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Updated configuration | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + +### DELETE /api/v1/config/roots + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Updated configuration | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + +### PATCH /api/v1/config/scanning + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Updated configuration | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + +### GET /api/v1/config/ui + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | UI configuration | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### PATCH /api/v1/config/ui + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Updated UI configuration | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + diff --git a/docs/api/database.md b/docs/api/database.md new file mode 100644 index 0000000..373df31 --- /dev/null +++ b/docs/api/database.md @@ -0,0 +1,51 @@ +# Database + +Database administration + +## Endpoints + +### POST /api/v1/admin/database/clear + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Database cleared | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + +### GET /api/v1/admin/database/stats + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Database statistics | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + +### POST /api/v1/admin/database/vacuum + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Database vacuumed | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + diff --git a/docs/api/duplicates.md b/docs/api/duplicates.md new file mode 100644 index 0000000..b1005b7 --- /dev/null +++ b/docs/api/duplicates.md @@ -0,0 +1,20 @@ +# Duplicates + +Duplicate media detection + +## Endpoints + +### GET /api/v1/media/duplicates + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Duplicate groups | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + diff --git a/docs/api/enrichment.md b/docs/api/enrichment.md new file mode 100644 index 0000000..5012641 --- /dev/null +++ b/docs/api/enrichment.md @@ -0,0 +1,71 @@ +# Enrichment + +External metadata enrichment + +## Endpoints + +### POST /api/v1/media/enrich/batch + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Enrichment job submitted | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + +### POST /api/v1/media/{id}/enrich + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Enrichment job submitted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | + +--- + +### GET /api/v1/media/{id}/metadata/external + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | External metadata | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | + +--- + diff --git a/docs/api/export.md b/docs/api/export.md new file mode 100644 index 0000000..3410a9f --- /dev/null +++ b/docs/api/export.md @@ -0,0 +1,42 @@ +# Export + +Media library export + +## Endpoints + +### POST /api/v1/export + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Export job submitted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + +### POST /api/v1/export/options + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Export job submitted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + diff --git a/docs/api/health.md b/docs/api/health.md new file mode 100644 index 0000000..44f6d4a --- /dev/null +++ b/docs/api/health.md @@ -0,0 +1,63 @@ +# Health + +Server health checks + +## Endpoints + +### GET /api/v1/health + +Comprehensive health check - includes database, filesystem, and cache status + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Health status | + +--- + +### GET /api/v1/health/detailed + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Detailed health status | + +--- + +### GET /api/v1/health/live + +Liveness probe - just checks if the server is running +Returns 200 OK if the server process is alive + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Server is alive | + +--- + +### GET /api/v1/health/ready + +Readiness probe - checks if the server can serve requests +Returns 200 OK if database is accessible + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Server is ready | +| 503 | Server not ready | + +--- + diff --git a/docs/api/integrity.md b/docs/api/integrity.md new file mode 100644 index 0000000..fd22c23 --- /dev/null +++ b/docs/api/integrity.md @@ -0,0 +1,99 @@ +# Integrity + +Library integrity checks and repairs + +## Endpoints + +### POST /api/v1/admin/integrity/orphans/detect + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Orphan detection job submitted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + +### POST /api/v1/admin/integrity/orphans/resolve + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Orphans resolved | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + +### POST /api/v1/admin/integrity/thumbnails/cleanup + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Thumbnail cleanup job submitted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + +### POST /api/v1/admin/integrity/thumbnails/generate + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Thumbnail generation job submitted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + +### POST /api/v1/admin/integrity/verify + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Integrity verification job submitted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + diff --git a/docs/api/jobs.md b/docs/api/jobs.md new file mode 100644 index 0000000..a46b7fd --- /dev/null +++ b/docs/api/jobs.md @@ -0,0 +1,62 @@ +# Jobs + +Background job management + +## Endpoints + +### GET /api/v1/jobs + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | List of jobs | +| 401 | Unauthorized | +| 403 | Forbidden | + +--- + +### GET /api/v1/jobs/{id} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Job ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Job details | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | + +--- + +### POST /api/v1/jobs/{id}/cancel + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Job ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Job cancelled | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | + +--- + diff --git a/docs/api/media.md b/docs/api/media.md new file mode 100644 index 0000000..b612729 --- /dev/null +++ b/docs/api/media.md @@ -0,0 +1,640 @@ +# Media + +Media item management + +## Endpoints + +### GET /api/v1/media + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Page size | +| `sort` | query | No | Sort field | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | List of media items | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### DELETE /api/v1/media + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | All media deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + +### POST /api/v1/media/batch/collection + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Batch collection result | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + +### POST /api/v1/media/batch/delete + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Batch delete result | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + +### POST /api/v1/media/batch/move + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Batch move result | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + +### POST /api/v1/media/batch/tag + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Batch tag result | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + +### POST /api/v1/media/batch/update + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Batch update result | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + +### GET /api/v1/media/count + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Media count | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### POST /api/v1/media/import + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Media imported | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + +### POST /api/v1/media/import/batch + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Batch import results | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + +### POST /api/v1/media/import/directory + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Directory import results | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + +### POST /api/v1/media/import/options + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Media imported | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + +### POST /api/v1/media/import/preview + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Directory preview | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + +### GET /api/v1/media/trash + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Page size | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Trashed media items | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### DELETE /api/v1/media/trash + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Trash emptied | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + +### GET /api/v1/media/trash/info + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Trash info | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### GET /api/v1/media/{id} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Media item | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | + +--- + +### PATCH /api/v1/media/{id} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Updated media item | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | + +--- + +### DELETE /api/v1/media/{id} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Media deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | + +--- + +### PUT /api/v1/media/{id}/custom-fields + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Custom field set | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | + +--- + +### DELETE /api/v1/media/{id}/custom-fields/{name} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | +| `name` | path | Yes | Custom field name | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Custom field deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | + +--- + +### POST /api/v1/media/{id}/move + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Moved media item | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | + +--- + +### POST /api/v1/media/{id}/open + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Media opened | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | + +--- + +### DELETE /api/v1/media/{id}/permanent + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | +| `permanent` | query | No | Set to 'true' for permanent deletion | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Media deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | + +--- + +### POST /api/v1/media/{id}/rename + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Renamed media item | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | + +--- + +### POST /api/v1/media/{id}/restore + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Media restored | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | + +--- + +### GET /api/v1/media/{id}/stream + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Media stream | +| 206 | Partial content | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | + +--- + +### GET /api/v1/media/{id}/thumbnail + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Thumbnail image | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | + +--- + +### DELETE /api/v1/media/{id}/trash + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Media moved to trash | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | + +--- + diff --git a/docs/api/notes.md b/docs/api/notes.md new file mode 100644 index 0000000..330c8f3 --- /dev/null +++ b/docs/api/notes.md @@ -0,0 +1,142 @@ +# Notes + +Markdown notes link graph + +## Endpoints + +### GET /api/v1/media/{id}/backlinks + +Get backlinks (incoming links) to a media item. + +GET /api/v1/media/{id}/backlinks + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Backlinks | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | + +--- + +### GET /api/v1/media/{id}/outgoing-links + +Get outgoing links from a media item. + +GET /api/v1/media/{id}/outgoing-links + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Outgoing links | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | + +--- + +### POST /api/v1/media/{id}/reindex-links + +Re-extract links from a media item. + +POST /api/v1/media/{id}/reindex-links + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Links reindexed | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | + +--- + +### GET /api/v1/notes/graph + +Get graph data for visualization. + +GET /api/v1/notes/graph?center={uuid}&depth={n} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `center` | query | No | Center node ID | +| `depth` | query | No | Traversal depth (max 5, default 2) | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Graph data | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### POST /api/v1/notes/resolve-links + +Resolve all unresolved links in the database. + +POST /api/v1/notes/resolve-links + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Links resolved | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### GET /api/v1/notes/unresolved-count + +Get count of unresolved links. + +GET /api/v1/notes/unresolved-count + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Unresolved link count | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + diff --git a/docs/api/openapi.json b/docs/api/openapi.json new file mode 100644 index 0000000..3f86923 --- /dev/null +++ b/docs/api/openapi.json @@ -0,0 +1,12810 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Pinakes API", + "description": "Media cataloging and library management API", + "license": { + "name": "EUPL-1.2", + "identifier": "EUPL-1.2" + }, + "version": "0.3.0-dev" + }, + "paths": { + "/api/v1/admin/backup": { + "post": { + "tags": [ + "backup" + ], + "summary": "Create a database backup and return it as a downloadable file.\nPOST /api/v1/admin/backup", + "description": "For `SQLite`: creates a backup via VACUUM INTO and returns the file.\nFor `PostgreSQL`: returns unsupported error (use `pg_dump` instead).", + "operationId": "create_backup", + "responses": { + "200": { + "description": "Backup file download" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/admin/database/clear": { + "post": { + "tags": [ + "database" + ], + "operationId": "clear_database", + "responses": { + "200": { + "description": "Database cleared" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/admin/database/stats": { + "get": { + "tags": [ + "database" + ], + "operationId": "database_stats", + "responses": { + "200": { + "description": "Database statistics", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DatabaseStatsResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/admin/database/vacuum": { + "post": { + "tags": [ + "database" + ], + "operationId": "vacuum_database", + "responses": { + "200": { + "description": "Database vacuumed" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/admin/integrity/orphans/detect": { + "post": { + "tags": [ + "integrity" + ], + "operationId": "trigger_orphan_detection", + "responses": { + "200": { + "description": "Orphan detection job submitted" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/admin/integrity/orphans/resolve": { + "post": { + "tags": [ + "integrity" + ], + "operationId": "resolve_orphans", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrphanResolveRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Orphans resolved" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/admin/integrity/thumbnails/cleanup": { + "post": { + "tags": [ + "integrity" + ], + "operationId": "trigger_cleanup_thumbnails", + "responses": { + "200": { + "description": "Thumbnail cleanup job submitted" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/admin/integrity/thumbnails/generate": { + "post": { + "tags": [ + "integrity" + ], + "operationId": "generate_all_thumbnails", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerateThumbnailsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Thumbnail generation job submitted" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/admin/integrity/verify": { + "post": { + "tags": [ + "integrity" + ], + "operationId": "trigger_verify_integrity", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyIntegrityRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Integrity verification job submitted" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/admin/users": { + "get": { + "tags": [ + "users" + ], + "summary": "List all users (admin only)", + "operationId": "list_users", + "responses": { + "200": { + "description": "List of users", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "post": { + "tags": [ + "users" + ], + "summary": "Create a new user (admin only)", + "operationId": "create_user", + "requestBody": { + "description": "username, password, role, and optional profile fields", + "content": { + "application/json": { + "schema": {} + } + }, + "required": true + }, + "responses": { + "200": { + "description": "User created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/admin/users/{id}": { + "get": { + "tags": [ + "users" + ], + "summary": "Get a specific user by ID", + "operationId": "get_user", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "User details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "delete": { + "tags": [ + "users" + ], + "summary": "Delete a user (admin only)", + "operationId": "delete_user", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "User deleted" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "patch": { + "tags": [ + "users" + ], + "summary": "Update a user", + "operationId": "update_user", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Optional password, role, or profile fields to update", + "content": { + "application/json": { + "schema": {} + } + }, + "required": true + }, + "responses": { + "200": { + "description": "User updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/admin/users/{id}/libraries": { + "get": { + "tags": [ + "users" + ], + "summary": "Get user's accessible libraries", + "operationId": "get_user_libraries", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "User libraries", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserLibraryResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "post": { + "tags": [ + "users" + ], + "summary": "Grant library access to a user (admin only)", + "operationId": "grant_library_access", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GrantLibraryAccessRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Access granted" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "delete": { + "tags": [ + "users" + ], + "summary": "Revoke library access from a user (admin only)", + "description": "Uses a JSON body instead of a path parameter because `root_path` may contain\nslashes that conflict with URL routing.", + "operationId": "revoke_library_access", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RevokeLibraryAccessRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Access revoked" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/analytics/events": { + "post": { + "tags": [ + "analytics" + ], + "operationId": "record_event", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecordUsageEventRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Event recorded" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/analytics/most-viewed": { + "get": { + "tags": [ + "analytics" + ], + "operationId": "get_most_viewed", + "parameters": [ + { + "name": "limit", + "in": "query", + "description": "Maximum number of results", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "offset", + "in": "query", + "description": "Pagination offset", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Most viewed media", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MostViewedResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/analytics/recently-viewed": { + "get": { + "tags": [ + "analytics" + ], + "operationId": "get_recently_viewed", + "parameters": [ + { + "name": "limit", + "in": "query", + "description": "Maximum number of results", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "offset", + "in": "query", + "description": "Pagination offset", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Recently viewed media", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/audit": { + "get": { + "tags": [ + "audit" + ], + "operationId": "list_audit", + "parameters": [ + { + "name": "offset", + "in": "query", + "description": "Pagination offset", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "limit", + "in": "query", + "description": "Page size", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Audit log entries", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuditEntryResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/auth/login": { + "post": { + "tags": [ + "auth" + ], + "operationId": "login", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Login successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Invalid credentials" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [] + } + }, + "/api/v1/auth/logout": { + "post": { + "tags": [ + "auth" + ], + "operationId": "logout", + "responses": { + "200": { + "description": "Logged out" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/auth/me": { + "get": { + "tags": [ + "auth" + ], + "operationId": "me", + "responses": { + "200": { + "description": "Current user info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserInfoResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/auth/refresh": { + "post": { + "tags": [ + "auth" + ], + "summary": "Refresh the current session, extending its expiry by the configured\nduration.", + "operationId": "refresh", + "responses": { + "200": { + "description": "Session refreshed" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/auth/revoke-all": { + "post": { + "tags": [ + "auth" + ], + "summary": "Revoke all sessions for the current user", + "operationId": "revoke_all_sessions", + "responses": { + "200": { + "description": "All sessions revoked" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/auth/sessions": { + "get": { + "tags": [ + "auth" + ], + "operationId": "list_active_sessions", + "responses": { + "200": { + "description": "Active sessions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionListResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/books": { + "get": { + "tags": [ + "books" + ], + "summary": "List all books with optional search filters", + "operationId": "list_books", + "parameters": [ + { + "name": "isbn", + "in": "query", + "description": "Filter by ISBN", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "author", + "in": "query", + "description": "Filter by author", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "series", + "in": "query", + "description": "Filter by series", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "publisher", + "in": "query", + "description": "Filter by publisher", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "language", + "in": "query", + "description": "Filter by language", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "offset", + "in": "query", + "description": "Pagination offset", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "limit", + "in": "query", + "description": "Pagination limit", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "List of books", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/books/authors": { + "get": { + "tags": [ + "books" + ], + "summary": "List all authors with book counts", + "operationId": "list_authors", + "parameters": [ + { + "name": "offset", + "in": "query", + "description": "Pagination offset", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "limit", + "in": "query", + "description": "Pagination limit", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Authors with book counts", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuthorSummary" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/books/authors/{name}/books": { + "get": { + "tags": [ + "books" + ], + "summary": "Get books by a specific author", + "operationId": "get_author_books", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Author name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "offset", + "in": "query", + "description": "Pagination offset", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "limit", + "in": "query", + "description": "Pagination limit", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Books by author", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/books/reading-list": { + "get": { + "tags": [ + "books" + ], + "summary": "Get user's reading list", + "operationId": "get_reading_list", + "parameters": [ + { + "name": "status", + "in": "query", + "description": "Filter by reading status", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Reading list", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/books/series": { + "get": { + "tags": [ + "books" + ], + "summary": "List all series with book counts", + "operationId": "list_series", + "responses": { + "200": { + "description": "List of series with counts", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesSummary" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/books/series/{name}": { + "get": { + "tags": [ + "books" + ], + "summary": "Get books in a specific series", + "operationId": "get_series_books", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Series name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Books in series", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/books/{id}/metadata": { + "get": { + "tags": [ + "books" + ], + "summary": "Get book metadata by media ID", + "operationId": "get_book_metadata", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Book metadata", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookMetadataResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/books/{id}/progress": { + "get": { + "tags": [ + "books" + ], + "summary": "Get reading progress for a book", + "operationId": "get_reading_progress", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Reading progress", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadingProgressResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "put": { + "tags": [ + "books" + ], + "summary": "Update reading progress for a book", + "operationId": "update_reading_progress", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateProgressRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Progress updated" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/collections": { + "get": { + "tags": [ + "collections" + ], + "operationId": "list_collections", + "responses": { + "200": { + "description": "List of collections", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CollectionResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "post": { + "tags": [ + "collections" + ], + "operationId": "create_collection", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCollectionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Collection created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/collections/{id}": { + "get": { + "tags": [ + "collections" + ], + "operationId": "get_collection", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Collection ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Collection", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "delete": { + "tags": [ + "collections" + ], + "operationId": "delete_collection", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Collection ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Collection deleted" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/collections/{id}/members": { + "get": { + "tags": [ + "collections" + ], + "operationId": "get_members", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Collection ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Collection members", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "post": { + "tags": [ + "collections" + ], + "operationId": "add_member", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Collection ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddMemberRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Member added" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/collections/{id}/members/{media_id}": { + "delete": { + "tags": [ + "collections" + ], + "operationId": "remove_member", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Collection ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "media_id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Member removed" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/config": { + "get": { + "tags": [ + "config" + ], + "operationId": "get_config", + "responses": { + "200": { + "description": "Current server configuration", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/config/roots": { + "post": { + "tags": [ + "config" + ], + "operationId": "add_root", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RootDirRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Updated configuration", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "delete": { + "tags": [ + "config" + ], + "operationId": "remove_root", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RootDirRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Updated configuration", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/config/scanning": { + "patch": { + "tags": [ + "config" + ], + "operationId": "update_scanning_config", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateScanningRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Updated configuration", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/config/ui": { + "get": { + "tags": [ + "config" + ], + "operationId": "get_ui_config", + "responses": { + "200": { + "description": "UI configuration", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UiConfigResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "patch": { + "tags": [ + "config" + ], + "operationId": "update_ui_config", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUiConfigRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Updated UI configuration", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UiConfigResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/export": { + "post": { + "tags": [ + "export" + ], + "operationId": "trigger_export", + "responses": { + "200": { + "description": "Export job submitted" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/export/options": { + "post": { + "tags": [ + "export" + ], + "operationId": "trigger_export_with_options", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExportRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Export job submitted" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/favorites": { + "get": { + "tags": [ + "social" + ], + "operationId": "list_favorites", + "responses": { + "200": { + "description": "User favorites", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "post": { + "tags": [ + "social" + ], + "operationId": "add_favorite", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FavoriteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Added to favorites" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/favorites/{media_id}": { + "delete": { + "tags": [ + "social" + ], + "operationId": "remove_favorite", + "parameters": [ + { + "name": "media_id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Removed from favorites" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/health": { + "get": { + "tags": [ + "health" + ], + "summary": "Comprehensive health check - includes database, filesystem, and cache status", + "operationId": "health", + "responses": { + "200": { + "description": "Health status", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + } + } + } + } + }, + "/api/v1/health/detailed": { + "get": { + "tags": [ + "health" + ], + "operationId": "health_detailed", + "responses": { + "200": { + "description": "Detailed health status", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DetailedHealthResponse" + } + } + } + } + } + } + }, + "/api/v1/health/live": { + "get": { + "tags": [ + "health" + ], + "summary": "Liveness probe - just checks if the server is running\nReturns 200 OK if the server process is alive", + "operationId": "liveness", + "responses": { + "200": { + "description": "Server is alive" + } + } + } + }, + "/api/v1/health/ready": { + "get": { + "tags": [ + "health" + ], + "summary": "Readiness probe - checks if the server can serve requests\nReturns 200 OK if database is accessible", + "operationId": "readiness", + "responses": { + "200": { + "description": "Server is ready" + }, + "503": { + "description": "Server not ready" + } + } + } + }, + "/api/v1/jobs": { + "get": { + "tags": [ + "jobs" + ], + "operationId": "list_jobs", + "responses": { + "200": { + "description": "List of jobs" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/jobs/{id}": { + "get": { + "tags": [ + "jobs" + ], + "operationId": "get_job", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Job ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Job details" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/jobs/{id}/cancel": { + "post": { + "tags": [ + "jobs" + ], + "operationId": "cancel_job", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Job ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Job cancelled" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/managed/stats": { + "get": { + "tags": [ + "upload" + ], + "summary": "Get managed storage statistics\nGET /api/managed/stats", + "operationId": "managed_stats", + "responses": { + "200": { + "description": "Managed storage statistics", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagedStorageStatsResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media": { + "get": { + "tags": [ + "media" + ], + "operationId": "list_media", + "parameters": [ + { + "name": "offset", + "in": "query", + "description": "Pagination offset", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "limit", + "in": "query", + "description": "Page size", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "sort", + "in": "query", + "description": "Sort field", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of media items", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "delete": { + "tags": [ + "media" + ], + "operationId": "delete_all_media", + "responses": { + "200": { + "description": "All media deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchOperationResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/batch/collection": { + "post": { + "tags": [ + "media" + ], + "operationId": "batch_add_to_collection", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchCollectionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Batch collection result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchOperationResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/batch/delete": { + "post": { + "tags": [ + "media" + ], + "operationId": "batch_delete", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchDeleteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Batch delete result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchOperationResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/batch/move": { + "post": { + "tags": [ + "media" + ], + "operationId": "batch_move_media", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchMoveRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Batch move result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchOperationResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/batch/tag": { + "post": { + "tags": [ + "media" + ], + "operationId": "batch_tag", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchTagRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Batch tag result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchOperationResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/batch/update": { + "post": { + "tags": [ + "media" + ], + "operationId": "batch_update", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Batch update result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchOperationResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/count": { + "get": { + "tags": [ + "media" + ], + "operationId": "get_media_count", + "responses": { + "200": { + "description": "Media count", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaCountResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/duplicates": { + "get": { + "tags": [ + "duplicates" + ], + "operationId": "list_duplicates", + "responses": { + "200": { + "description": "Duplicate groups", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DuplicateGroupResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/enrich/batch": { + "post": { + "tags": [ + "enrichment" + ], + "operationId": "batch_enrich", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchDeleteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Enrichment job submitted" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/import": { + "post": { + "tags": [ + "media" + ], + "operationId": "import_media", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Media imported", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/import/batch": { + "post": { + "tags": [ + "media" + ], + "operationId": "batch_import", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchImportRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Batch import results", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchImportResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/import/directory": { + "post": { + "tags": [ + "media" + ], + "operationId": "import_directory_endpoint", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DirectoryImportRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Directory import results", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchImportResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/import/options": { + "post": { + "tags": [ + "media" + ], + "operationId": "import_with_options", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportWithOptionsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Media imported", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/import/preview": { + "post": { + "tags": [ + "media" + ], + "operationId": "preview_directory", + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Directory preview", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DirectoryPreviewResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/share": { + "post": { + "tags": [ + "social" + ], + "operationId": "create_share_link", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateShareLinkRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Share link created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShareLinkResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/trash": { + "get": { + "tags": [ + "media" + ], + "operationId": "list_trash", + "parameters": [ + { + "name": "offset", + "in": "query", + "description": "Pagination offset", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "limit", + "in": "query", + "description": "Page size", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Trashed media items", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TrashResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "delete": { + "tags": [ + "media" + ], + "operationId": "empty_trash", + "responses": { + "200": { + "description": "Trash emptied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmptyTrashResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/trash/info": { + "get": { + "tags": [ + "media" + ], + "operationId": "trash_info", + "responses": { + "200": { + "description": "Trash info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TrashInfoResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}": { + "get": { + "tags": [ + "media" + ], + "operationId": "get_media", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Media item", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "delete": { + "tags": [ + "media" + ], + "operationId": "delete_media", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Media deleted" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "patch": { + "tags": [ + "media" + ], + "operationId": "update_media", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateMediaRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Updated media item", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}/backlinks": { + "get": { + "tags": [ + "notes" + ], + "summary": "Get backlinks (incoming links) to a media item.", + "description": "GET /api/v1/media/{id}/backlinks", + "operationId": "get_backlinks", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Backlinks", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BacklinksResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}/comments": { + "get": { + "tags": [ + "social" + ], + "operationId": "get_media_comments", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Media comments", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CommentResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "post": { + "tags": [ + "social" + ], + "operationId": "add_comment", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCommentRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Comment added", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}/custom-fields": { + "put": { + "tags": [ + "media" + ], + "operationId": "set_custom_field", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetCustomFieldRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Custom field set" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}/custom-fields/{name}": { + "delete": { + "tags": [ + "media" + ], + "operationId": "delete_custom_field", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "name", + "in": "path", + "description": "Custom field name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Custom field deleted" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}/download": { + "get": { + "tags": [ + "upload" + ], + "summary": "Download a managed file\nGET /api/media/{id}/download", + "operationId": "download_file", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "File content" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}/enrich": { + "post": { + "tags": [ + "enrichment" + ], + "operationId": "trigger_enrichment", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Enrichment job submitted" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}/metadata/external": { + "get": { + "tags": [ + "enrichment" + ], + "operationId": "get_external_metadata", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "External metadata", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExternalMetadataResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}/move": { + "post": { + "tags": [ + "media" + ], + "operationId": "move_media_endpoint", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MoveMediaRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Moved media item", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}/move-to-managed": { + "post": { + "tags": [ + "upload" + ], + "summary": "Migrate an external file to managed storage\nPOST /api/media/{id}/move-to-managed", + "operationId": "move_to_managed", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "File migrated" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}/open": { + "post": { + "tags": [ + "media" + ], + "operationId": "open_media", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Media opened" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}/outgoing-links": { + "get": { + "tags": [ + "notes" + ], + "summary": "Get outgoing links from a media item.", + "description": "GET /api/v1/media/{id}/outgoing-links", + "operationId": "get_outgoing_links", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Outgoing links", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OutgoingLinksResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}/permanent": { + "delete": { + "tags": [ + "media" + ], + "operationId": "permanent_delete_media", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "permanent", + "in": "query", + "description": "Set to 'true' for permanent deletion", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Media deleted" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}/progress": { + "get": { + "tags": [ + "analytics" + ], + "operationId": "get_watch_progress", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Watch progress", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WatchProgressResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "put": { + "tags": [ + "analytics" + ], + "operationId": "update_watch_progress", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WatchProgressRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Progress updated" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}/rate": { + "post": { + "tags": [ + "social" + ], + "operationId": "rate_media", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRatingRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Rating saved", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RatingResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}/ratings": { + "get": { + "tags": [ + "social" + ], + "operationId": "get_media_ratings", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Media ratings", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RatingResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}/reindex-links": { + "post": { + "tags": [ + "notes" + ], + "summary": "Re-extract links from a media item.", + "description": "POST /api/v1/media/{id}/reindex-links", + "operationId": "reindex_links", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Links reindexed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReindexResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}/rename": { + "post": { + "tags": [ + "media" + ], + "operationId": "rename_media", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RenameMediaRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Renamed media item", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}/restore": { + "post": { + "tags": [ + "media" + ], + "operationId": "restore_media", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Media restored", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}/stream": { + "get": { + "tags": [ + "media" + ], + "operationId": "stream_media", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Media stream" + }, + "206": { + "description": "Partial content" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}/stream/dash/manifest.mpd": { + "get": { + "tags": [ + "streaming" + ], + "operationId": "dash_manifest", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "DASH manifest" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}/stream/dash/{profile}/{segment}": { + "get": { + "tags": [ + "streaming" + ], + "operationId": "dash_segment", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "profile", + "in": "path", + "description": "Transcode profile name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "segment", + "in": "path", + "description": "Segment filename", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "DASH segment data" + }, + "202": { + "description": "Segment not yet available" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}/stream/hls/master.m3u8": { + "get": { + "tags": [ + "streaming" + ], + "operationId": "hls_master_playlist", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "HLS master playlist" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}/stream/hls/{profile}/playlist.m3u8": { + "get": { + "tags": [ + "streaming" + ], + "operationId": "hls_variant_playlist", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "profile", + "in": "path", + "description": "Transcode profile name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "HLS variant playlist" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}/stream/hls/{profile}/{segment}": { + "get": { + "tags": [ + "streaming" + ], + "operationId": "hls_segment", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "profile", + "in": "path", + "description": "Transcode profile name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "segment", + "in": "path", + "description": "Segment filename", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "HLS segment data" + }, + "202": { + "description": "Segment not yet available" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}/subtitles": { + "get": { + "tags": [ + "subtitles" + ], + "operationId": "list_subtitles", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Subtitles", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SubtitleResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "post": { + "tags": [ + "subtitles" + ], + "operationId": "add_subtitle", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddSubtitleRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Subtitle added", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubtitleResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}/thumbnail": { + "get": { + "tags": [ + "media" + ], + "operationId": "get_thumbnail", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Thumbnail image" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}/transcode": { + "post": { + "tags": [ + "transcode" + ], + "operationId": "start_transcode", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTranscodeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Transcode job submitted" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{id}/trash": { + "delete": { + "tags": [ + "media" + ], + "operationId": "soft_delete_media", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Media moved to trash" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{media_id}/subtitles/{subtitle_id}/content": { + "get": { + "tags": [ + "subtitles" + ], + "operationId": "get_subtitle_content", + "parameters": [ + { + "name": "media_id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "subtitle_id", + "in": "path", + "description": "Subtitle ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Subtitle content" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{media_id}/tags": { + "get": { + "tags": [ + "tags" + ], + "operationId": "get_media_tags", + "parameters": [ + { + "name": "media_id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Media tags", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TagResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "post": { + "tags": [ + "tags" + ], + "operationId": "tag_media", + "parameters": [ + { + "name": "media_id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagMediaRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Tag applied" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/media/{media_id}/tags/{tag_id}": { + "delete": { + "tags": [ + "tags" + ], + "operationId": "untag_media", + "parameters": [ + { + "name": "media_id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "tag_id", + "in": "path", + "description": "Tag ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Tag removed" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/notes/graph": { + "get": { + "tags": [ + "notes" + ], + "summary": "Get graph data for visualization.", + "description": "GET /api/v1/notes/graph?center={uuid}&depth={n}", + "operationId": "get_graph", + "parameters": [ + { + "name": "center", + "in": "query", + "description": "Center node ID", + "required": false, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "depth", + "in": "query", + "description": "Traversal depth (max 5, default 2)", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Graph data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GraphResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/notes/resolve-links": { + "post": { + "tags": [ + "notes" + ], + "summary": "Resolve all unresolved links in the database.", + "description": "POST /api/v1/notes/resolve-links", + "operationId": "resolve_links", + "responses": { + "200": { + "description": "Links resolved", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResolveLinksResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/notes/unresolved-count": { + "get": { + "tags": [ + "notes" + ], + "summary": "Get count of unresolved links.", + "description": "GET /api/v1/notes/unresolved-count", + "operationId": "get_unresolved_count", + "responses": { + "200": { + "description": "Unresolved link count", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnresolvedLinksResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/notifications/shares": { + "get": { + "tags": [ + "shares" + ], + "summary": "Get unread share notifications\nGET /api/notifications/shares", + "operationId": "get_notifications", + "responses": { + "200": { + "description": "Unread notifications", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShareNotificationResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/notifications/shares/read-all": { + "post": { + "tags": [ + "shares" + ], + "summary": "Mark all notifications as read\nPOST /api/notifications/shares/read-all", + "operationId": "mark_all_read", + "responses": { + "200": { + "description": "All notifications marked as read" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/notifications/shares/{id}/read": { + "post": { + "tags": [ + "shares" + ], + "summary": "Mark a notification as read\nPOST /api/notifications/shares/{id}/read", + "operationId": "mark_notification_read", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Notification ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Notification marked as read" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/photos/map": { + "get": { + "tags": [ + "photos" + ], + "summary": "Get photos in a bounding box for map view", + "operationId": "get_map_photos", + "parameters": [ + { + "name": "lat1", + "in": "query", + "description": "Bounding box latitude 1", + "required": true, + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "lon1", + "in": "query", + "description": "Bounding box longitude 1", + "required": true, + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "lat2", + "in": "query", + "description": "Bounding box latitude 2", + "required": true, + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "lon2", + "in": "query", + "description": "Bounding box longitude 2", + "required": true, + "schema": { + "type": "number", + "format": "double" + } + } + ], + "responses": { + "200": { + "description": "Map markers", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MapMarker" + } + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/photos/timeline": { + "get": { + "tags": [ + "photos" + ], + "summary": "Get timeline of photos grouped by date", + "operationId": "get_timeline", + "parameters": [ + { + "name": "group_by", + "in": "query", + "description": "Grouping: day, month, year", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "year", + "in": "query", + "description": "Filter by year", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "month", + "in": "query", + "description": "Filter by month", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + { + "name": "limit", + "in": "query", + "description": "Max items (default 10000)", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Photo timeline groups", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TimelineGroup" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/playlists": { + "get": { + "tags": [ + "playlists" + ], + "operationId": "list_playlists", + "responses": { + "200": { + "description": "List of playlists", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlaylistResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "post": { + "tags": [ + "playlists" + ], + "operationId": "create_playlist", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePlaylistRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Playlist created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlaylistResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/playlists/{id}": { + "get": { + "tags": [ + "playlists" + ], + "operationId": "get_playlist", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Playlist ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Playlist details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlaylistResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "delete": { + "tags": [ + "playlists" + ], + "operationId": "delete_playlist", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Playlist ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Playlist deleted" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "patch": { + "tags": [ + "playlists" + ], + "operationId": "update_playlist", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Playlist ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePlaylistRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Playlist updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlaylistResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/playlists/{id}/items": { + "get": { + "tags": [ + "playlists" + ], + "operationId": "list_items", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Playlist ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Playlist items", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "post": { + "tags": [ + "playlists" + ], + "operationId": "add_item", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Playlist ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlaylistItemRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Item added" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/playlists/{id}/items/reorder": { + "patch": { + "tags": [ + "playlists" + ], + "operationId": "reorder_item", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Playlist ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReorderPlaylistRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Item reordered" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/playlists/{id}/items/{media_id}": { + "delete": { + "tags": [ + "playlists" + ], + "operationId": "remove_item", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Playlist ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "media_id", + "in": "path", + "description": "Media item ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Item removed" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/playlists/{id}/shuffle": { + "post": { + "tags": [ + "playlists" + ], + "operationId": "shuffle_playlist", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Playlist ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Shuffled playlist items", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/plugins": { + "get": { + "tags": [ + "plugins" + ], + "summary": "List all installed plugins", + "operationId": "list_plugins", + "responses": { + "200": { + "description": "List of plugins", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PluginResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "post": { + "tags": [ + "plugins" + ], + "summary": "Install a plugin from URL or file path", + "operationId": "install_plugin", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstallPluginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Plugin installed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/plugins/events": { + "post": { + "tags": [ + "plugins" + ], + "summary": "Receive a plugin event emitted from the UI and dispatch it to interested\nserver-side event-handler plugins via the pipeline.", + "operationId": "emit_plugin_event", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginEventRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Event received" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/plugins/ui/pages": { + "get": { + "tags": [ + "plugins" + ], + "summary": "List all UI pages provided by loaded plugins", + "operationId": "list_plugin_ui_pages", + "responses": { + "200": { + "description": "Plugin UI pages", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PluginUiPageEntry" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/plugins/ui/theme": { + "get": { + "tags": [ + "plugins" + ], + "summary": "List merged CSS custom property overrides from all enabled plugins", + "operationId": "list_plugin_ui_theme_extensions", + "responses": { + "200": { + "description": "Plugin UI theme extensions" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/plugins/ui/widgets": { + "get": { + "tags": [ + "plugins" + ], + "summary": "List all UI widgets provided by loaded plugins", + "operationId": "list_plugin_ui_widgets", + "responses": { + "200": { + "description": "Plugin UI widgets", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PluginUiWidgetEntry" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/plugins/{id}": { + "get": { + "tags": [ + "plugins" + ], + "summary": "Get a specific plugin by ID", + "operationId": "get_plugin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Plugin ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Plugin details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "delete": { + "tags": [ + "plugins" + ], + "summary": "Uninstall a plugin", + "operationId": "uninstall_plugin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Plugin ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Plugin uninstalled" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/plugins/{id}/reload": { + "post": { + "tags": [ + "plugins" + ], + "summary": "Reload a plugin (for development)", + "operationId": "reload_plugin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Plugin ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Plugin reloaded" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/plugins/{id}/toggle": { + "patch": { + "tags": [ + "plugins" + ], + "summary": "Enable or disable a plugin", + "operationId": "toggle_plugin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Plugin ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TogglePluginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Plugin toggled" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/scan": { + "post": { + "tags": [ + "scan" + ], + "summary": "Trigger a scan as a background job. Returns the job ID immediately.", + "operationId": "trigger_scan", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScanRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Scan job submitted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScanJobResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/scan/status": { + "get": { + "tags": [ + "scan" + ], + "operationId": "scan_status", + "responses": { + "200": { + "description": "Scan status", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScanStatusResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/scheduled-tasks": { + "get": { + "tags": [ + "scheduled_tasks" + ], + "operationId": "list_scheduled_tasks", + "responses": { + "200": { + "description": "List of scheduled tasks", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScheduledTaskResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/scheduled-tasks/{id}/run": { + "post": { + "tags": [ + "scheduled_tasks" + ], + "operationId": "run_scheduled_task_now", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Task ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Task triggered" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/scheduled-tasks/{id}/toggle": { + "post": { + "tags": [ + "scheduled_tasks" + ], + "operationId": "toggle_scheduled_task", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Task ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Task toggled" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/search": { + "get": { + "tags": [ + "search" + ], + "operationId": "search", + "parameters": [ + { + "name": "q", + "in": "query", + "description": "Search query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "sort", + "in": "query", + "description": "Sort order", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "offset", + "in": "query", + "description": "Pagination offset", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "limit", + "in": "query", + "description": "Pagination limit", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Search results", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "post": { + "tags": [ + "search" + ], + "operationId": "search_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Search results", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/searches": { + "get": { + "tags": [ + "saved_searches" + ], + "operationId": "list_saved_searches", + "responses": { + "200": { + "description": "List of saved searches", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SavedSearchResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "post": { + "tags": [ + "saved_searches" + ], + "operationId": "create_saved_search", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSavedSearchRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Search saved", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SavedSearchResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/searches/{id}": { + "delete": { + "tags": [ + "saved_searches" + ], + "operationId": "delete_saved_search", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Saved search ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Saved search deleted" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/shared/media/{token}": { + "get": { + "tags": [ + "social" + ], + "operationId": "access_shared_media", + "parameters": [ + { + "name": "token", + "in": "path", + "description": "Share token", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "password", + "in": "query", + "description": "Share password", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Shared media", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + } + } + } + }, + "/api/v1/shared/{token}": { + "get": { + "tags": [ + "shares" + ], + "summary": "Access a public shared resource\nGET /api/shared/{token}", + "operationId": "access_shared", + "parameters": [ + { + "name": "token", + "in": "path", + "description": "Share token", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "password", + "in": "query", + "description": "Share password if required", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Shared content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharedContentResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + } + } + } + }, + "/api/v1/shares": { + "post": { + "tags": [ + "shares" + ], + "summary": "Create a new share\nPOST /api/shares", + "operationId": "create_share", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateShareRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Share created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShareResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/shares/batch/delete": { + "post": { + "tags": [ + "shares" + ], + "summary": "Batch delete shares\nPOST /api/shares/batch/delete", + "operationId": "batch_delete", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchDeleteSharesRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Shares deleted" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/shares/incoming": { + "get": { + "tags": [ + "shares" + ], + "summary": "List incoming shares (shares shared with me)\nGET /api/shares/incoming", + "operationId": "list_incoming", + "parameters": [ + { + "name": "offset", + "in": "query", + "description": "Pagination offset", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "limit", + "in": "query", + "description": "Pagination limit", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Incoming shares", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShareResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/shares/outgoing": { + "get": { + "tags": [ + "shares" + ], + "summary": "List outgoing shares (shares I created)\nGET /api/shares/outgoing", + "operationId": "list_outgoing", + "parameters": [ + { + "name": "offset", + "in": "query", + "description": "Pagination offset", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "limit", + "in": "query", + "description": "Pagination limit", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Outgoing shares", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShareResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/shares/{id}": { + "get": { + "tags": [ + "shares" + ], + "summary": "Get share details\nGET /api/shares/{id}", + "operationId": "get_share", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Share ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Share details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShareResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "delete": { + "tags": [ + "shares" + ], + "summary": "Delete (revoke) a share\nDELETE /api/shares/{id}", + "operationId": "delete_share", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Share ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Share deleted" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "patch": { + "tags": [ + "shares" + ], + "summary": "Update a share\nPATCH /api/shares/{id}", + "operationId": "update_share", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Share ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateShareRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Share updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShareResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/shares/{id}/activity": { + "get": { + "tags": [ + "shares" + ], + "summary": "Get share activity log\nGET /api/shares/{id}/activity", + "operationId": "get_activity", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Share ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "offset", + "in": "query", + "description": "Pagination offset", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "limit", + "in": "query", + "description": "Pagination limit", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Share activity", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShareActivityResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/statistics": { + "get": { + "tags": [ + "statistics" + ], + "operationId": "library_statistics", + "responses": { + "200": { + "description": "Library statistics", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LibraryStatisticsResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/subtitles/{id}": { + "delete": { + "tags": [ + "subtitles" + ], + "operationId": "delete_subtitle", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Subtitle ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Subtitle deleted" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/subtitles/{id}/offset": { + "patch": { + "tags": [ + "subtitles" + ], + "operationId": "update_offset", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Subtitle ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSubtitleOffsetRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Offset updated" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/sync/ack": { + "post": { + "tags": [ + "sync" + ], + "summary": "Acknowledge processed changes\nPOST /api/sync/ack", + "operationId": "acknowledge_changes", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AcknowledgeChangesRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Changes acknowledged" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/sync/changes": { + "get": { + "tags": [ + "sync" + ], + "summary": "Get changes since cursor\nGET /api/sync/changes", + "operationId": "get_changes", + "parameters": [ + { + "name": "cursor", + "in": "query", + "description": "Sync cursor", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "limit", + "in": "query", + "description": "Max changes (max 1000)", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Changes since cursor", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangesResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/sync/conflicts": { + "get": { + "tags": [ + "sync" + ], + "summary": "List unresolved conflicts\nGET /api/sync/conflicts", + "operationId": "list_conflicts", + "responses": { + "200": { + "description": "Unresolved conflicts", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConflictResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/sync/conflicts/{id}/resolve": { + "post": { + "tags": [ + "sync" + ], + "summary": "Resolve a sync conflict\nPOST /api/sync/conflicts/{id}/resolve", + "operationId": "resolve_conflict", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Conflict ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResolveConflictRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Conflict resolved" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/sync/devices": { + "get": { + "tags": [ + "sync" + ], + "summary": "List user's sync devices\nGET /api/sync/devices", + "operationId": "list_devices", + "responses": { + "200": { + "description": "List of devices", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DeviceResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "post": { + "tags": [ + "sync" + ], + "summary": "Register a new sync device\nPOST /api/sync/devices", + "operationId": "register_device", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterDeviceRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Device registered", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceRegistrationResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/sync/devices/{id}": { + "get": { + "tags": [ + "sync" + ], + "summary": "Get device details\nGET /api/sync/devices/{id}", + "operationId": "get_device", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Device ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Device details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "put": { + "tags": [ + "sync" + ], + "summary": "Update a device\nPUT /api/sync/devices/{id}", + "operationId": "update_device", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Device ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateDeviceRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Device updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "delete": { + "tags": [ + "sync" + ], + "summary": "Delete a device\nDELETE /api/sync/devices/{id}", + "operationId": "delete_device", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Device ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Device deleted" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/sync/devices/{id}/token": { + "post": { + "tags": [ + "sync" + ], + "summary": "Regenerate device token\nPOST /api/sync/devices/{id}/token", + "operationId": "regenerate_token", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Device ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Token regenerated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceRegistrationResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/sync/download/{path}": { + "get": { + "tags": [ + "sync" + ], + "summary": "Download a file for sync (supports Range header)\nGET /api/sync/download/{*path}", + "operationId": "download_file", + "parameters": [ + { + "name": "path", + "in": "path", + "description": "File path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "File content" + }, + "206": { + "description": "Partial content" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/sync/report": { + "post": { + "tags": [ + "sync" + ], + "summary": "Report local changes from client\nPOST /api/sync/report", + "operationId": "report_changes", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportChangesRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Changes processed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportChangesResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/sync/upload": { + "post": { + "tags": [ + "sync" + ], + "summary": "Create an upload session for chunked upload\nPOST /api/sync/upload", + "operationId": "create_upload", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUploadSessionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Upload session created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadSessionResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/sync/upload/{id}": { + "get": { + "tags": [ + "sync" + ], + "summary": "Get upload session status\nGET /api/sync/upload/{id}", + "operationId": "get_upload_status", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Upload session ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Upload session status", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadSessionResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "delete": { + "tags": [ + "sync" + ], + "summary": "Cancel an upload session\nDELETE /api/sync/upload/{id}", + "operationId": "cancel_upload", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Upload session ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Upload cancelled" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/sync/upload/{id}/chunks/{index}": { + "put": { + "tags": [ + "sync" + ], + "summary": "Upload a chunk\nPUT /api/sync/upload/{id}/chunks/{index}", + "operationId": "upload_chunk", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Upload session ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "index", + "in": "path", + "description": "Chunk index", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + ], + "requestBody": { + "description": "Chunk binary data", + "content": { + "application/octet-stream": { + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Chunk received", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChunkUploadedResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/sync/upload/{id}/complete": { + "post": { + "tags": [ + "sync" + ], + "summary": "Complete an upload session\nPOST /api/sync/upload/{id}/complete", + "operationId": "complete_upload", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Upload session ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Upload completed" + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/tags": { + "get": { + "tags": [ + "tags" + ], + "operationId": "list_tags", + "responses": { + "200": { + "description": "List of tags", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TagResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "post": { + "tags": [ + "tags" + ], + "operationId": "create_tag", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTagRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Tag created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/tags/{id}": { + "get": { + "tags": [ + "tags" + ], + "operationId": "get_tag", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Tag ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Tag", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "delete": { + "tags": [ + "tags" + ], + "operationId": "delete_tag", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Tag ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Tag deleted" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/transcode": { + "get": { + "tags": [ + "transcode" + ], + "operationId": "list_sessions", + "responses": { + "200": { + "description": "List of transcode sessions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TranscodeSessionResponse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/transcode/{id}": { + "get": { + "tags": [ + "transcode" + ], + "operationId": "get_session", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Transcode session ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Transcode session details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TranscodeSessionResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "delete": { + "tags": [ + "transcode" + ], + "operationId": "cancel_session", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Transcode session ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Transcode session cancelled" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/upload": { + "post": { + "tags": [ + "upload" + ], + "summary": "Upload a file to managed storage\nPOST /api/upload", + "operationId": "upload_file", + "responses": { + "200": { + "description": "File uploaded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/webhooks": { + "get": { + "tags": [ + "webhooks" + ], + "operationId": "list_webhooks", + "responses": { + "200": { + "description": "List of configured webhooks", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebhookInfo" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/webhooks/test": { + "post": { + "tags": [ + "webhooks" + ], + "operationId": "test_webhook", + "responses": { + "200": { + "description": "Test webhook sent" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + } + }, + "components": { + "schemas": { + "AccessSharedRequest": { + "type": "object", + "properties": { + "password": { + "type": [ + "string", + "null" + ] + } + } + }, + "AcknowledgeChangesRequest": { + "type": "object", + "required": [ + "cursor" + ], + "properties": { + "cursor": { + "type": "integer", + "format": "int64" + } + } + }, + "AddMemberRequest": { + "type": "object", + "required": [ + "media_id" + ], + "properties": { + "media_id": { + "type": "string", + "format": "uuid" + }, + "position": { + "type": [ + "integer", + "null" + ], + "format": "int32" + } + } + }, + "AddSubtitleRequest": { + "type": "object", + "required": [ + "format" + ], + "properties": { + "file_path": { + "type": [ + "string", + "null" + ] + }, + "format": { + "type": "string" + }, + "is_embedded": { + "type": [ + "boolean", + "null" + ] + }, + "language": { + "type": [ + "string", + "null" + ] + }, + "offset_ms": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "track_index": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + } + }, + "AuditEntryResponse": { + "type": "object", + "required": [ + "id", + "action", + "timestamp" + ], + "properties": { + "action": { + "type": "string" + }, + "details": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "media_id": { + "type": [ + "string", + "null" + ] + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "AuthorResponse": { + "type": "object", + "description": "Author response DTO", + "required": [ + "name", + "role", + "position" + ], + "properties": { + "file_as": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "position": { + "type": "integer", + "format": "int32" + }, + "role": { + "type": "string" + } + } + }, + "AuthorSummary": { + "type": "object", + "description": "Author summary DTO", + "required": [ + "name", + "book_count" + ], + "properties": { + "book_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "name": { + "type": "string" + } + } + }, + "BacklinkItem": { + "type": "object", + "description": "Individual backlink item", + "required": [ + "link_id", + "source_id", + "source_path", + "link_type" + ], + "properties": { + "context": { + "type": [ + "string", + "null" + ] + }, + "line_number": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "link_id": { + "type": "string", + "format": "uuid" + }, + "link_text": { + "type": [ + "string", + "null" + ] + }, + "link_type": { + "type": "string" + }, + "source_id": { + "type": "string", + "format": "uuid" + }, + "source_path": { + "type": "string" + }, + "source_title": { + "type": [ + "string", + "null" + ] + } + } + }, + "BacklinksResponse": { + "type": "object", + "description": "Response for backlinks query", + "required": [ + "backlinks", + "count" + ], + "properties": { + "backlinks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BacklinkItem" + } + }, + "count": { + "type": "integer", + "minimum": 0 + } + } + }, + "BatchCollectionRequest": { + "type": "object", + "required": [ + "media_ids", + "collection_id" + ], + "properties": { + "collection_id": { + "type": "string", + "format": "uuid" + }, + "media_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + }, + "BatchDeleteRequest": { + "type": "object", + "required": [ + "media_ids" + ], + "properties": { + "media_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + }, + "BatchDeleteSharesRequest": { + "type": "object", + "required": [ + "share_ids" + ], + "properties": { + "share_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + }, + "BatchImportItemResult": { + "type": "object", + "required": [ + "path", + "was_duplicate" + ], + "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, + "media_id": { + "type": [ + "string", + "null" + ] + }, + "path": { + "type": "string" + }, + "was_duplicate": { + "type": "boolean" + } + } + }, + "BatchImportRequest": { + "type": "object", + "required": [ + "paths" + ], + "properties": { + "collection_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "new_tags": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "paths": { + "type": "array", + "items": { + "type": "string" + } + }, + "tag_ids": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "format": "uuid" + } + } + } + }, + "BatchImportResponse": { + "type": "object", + "required": [ + "results", + "total", + "imported", + "duplicates", + "errors" + ], + "properties": { + "duplicates": { + "type": "integer", + "minimum": 0 + }, + "errors": { + "type": "integer", + "minimum": 0 + }, + "imported": { + "type": "integer", + "minimum": 0 + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BatchImportItemResult" + } + }, + "total": { + "type": "integer", + "minimum": 0 + } + } + }, + "BatchMoveRequest": { + "type": "object", + "required": [ + "media_ids", + "destination" + ], + "properties": { + "destination": { + "type": "string" + }, + "media_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + }, + "BatchOperationResponse": { + "type": "object", + "required": [ + "processed", + "errors" + ], + "properties": { + "errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "processed": { + "type": "integer", + "minimum": 0 + } + } + }, + "BatchTagRequest": { + "type": "object", + "required": [ + "media_ids", + "tag_ids" + ], + "properties": { + "media_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "tag_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + }, + "BatchUpdateRequest": { + "type": "object", + "required": [ + "media_ids" + ], + "properties": { + "album": { + "type": [ + "string", + "null" + ] + }, + "artist": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "genre": { + "type": [ + "string", + "null" + ] + }, + "media_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "year": { + "type": [ + "integer", + "null" + ], + "format": "int32" + } + } + }, + "BookMetadataResponse": { + "type": "object", + "description": "Book metadata response DTO", + "required": [ + "media_id", + "authors", + "identifiers" + ], + "properties": { + "authors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuthorResponse" + } + }, + "format": { + "type": [ + "string", + "null" + ] + }, + "identifiers": { + "type": "object" + }, + "isbn": { + "type": [ + "string", + "null" + ] + }, + "isbn13": { + "type": [ + "string", + "null" + ] + }, + "language": { + "type": [ + "string", + "null" + ] + }, + "media_id": { + "type": "string", + "format": "uuid" + }, + "page_count": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "publication_date": { + "type": [ + "string", + "null" + ] + }, + "publisher": { + "type": [ + "string", + "null" + ] + }, + "series_index": { + "type": [ + "number", + "null" + ], + "format": "double" + }, + "series_name": { + "type": [ + "string", + "null" + ] + } + } + }, + "CacheHealth": { + "type": "object", + "required": [ + "hit_rate", + "total_entries", + "responses_size", + "queries_size", + "media_size" + ], + "properties": { + "hit_rate": { + "type": "number", + "format": "double" + }, + "media_size": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "queries_size": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "responses_size": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "total_entries": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "ChangesResponse": { + "type": "object", + "required": [ + "changes", + "cursor", + "has_more" + ], + "properties": { + "changes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SyncChangeResponse" + } + }, + "cursor": { + "type": "integer", + "format": "int64" + }, + "has_more": { + "type": "boolean" + } + } + }, + "ChunkUploadedResponse": { + "type": "object", + "required": [ + "chunk_index", + "received" + ], + "properties": { + "chunk_index": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "received": { + "type": "boolean" + } + } + }, + "ClientChangeReport": { + "type": "object", + "required": [ + "path", + "change_type" + ], + "properties": { + "change_type": { + "type": "string" + }, + "content_hash": { + "type": [ + "string", + "null" + ] + }, + "file_size": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "local_mtime": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "path": { + "type": "string" + } + } + }, + "CollectionResponse": { + "type": "object", + "required": [ + "id", + "name", + "kind", + "created_at", + "updated_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "filter_query": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "name": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "CommentResponse": { + "type": "object", + "required": [ + "id", + "user_id", + "media_id", + "text", + "created_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string" + }, + "media_id": { + "type": "string" + }, + "parent_comment_id": { + "type": [ + "string", + "null" + ] + }, + "text": { + "type": "string" + }, + "user_id": { + "type": "string" + } + } + }, + "ConfigResponse": { + "type": "object", + "required": [ + "backend", + "roots", + "scanning", + "server", + "ui", + "config_writable" + ], + "properties": { + "backend": { + "type": "string" + }, + "config_path": { + "type": [ + "string", + "null" + ] + }, + "config_writable": { + "type": "boolean" + }, + "database_path": { + "type": [ + "string", + "null" + ] + }, + "roots": { + "type": "array", + "items": { + "type": "string" + } + }, + "scanning": { + "$ref": "#/components/schemas/ScanningConfigResponse" + }, + "server": { + "$ref": "#/components/schemas/ServerConfigResponse" + }, + "ui": { + "$ref": "#/components/schemas/UiConfigResponse" + } + } + }, + "ConflictResponse": { + "type": "object", + "required": [ + "id", + "path", + "local_hash", + "server_hash", + "detected_at" + ], + "properties": { + "detected_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string" + }, + "local_hash": { + "type": "string" + }, + "path": { + "type": "string" + }, + "server_hash": { + "type": "string" + } + } + }, + "CreateCollectionRequest": { + "type": "object", + "required": [ + "name", + "kind" + ], + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "filter_query": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "CreateCommentRequest": { + "type": "object", + "required": [ + "text" + ], + "properties": { + "parent_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "text": { + "type": "string" + } + } + }, + "CreatePlaylistRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "filter_query": { + "type": [ + "string", + "null" + ] + }, + "is_public": { + "type": [ + "boolean", + "null" + ] + }, + "is_smart": { + "type": [ + "boolean", + "null" + ] + }, + "name": { + "type": "string" + } + } + }, + "CreateRatingRequest": { + "type": "object", + "required": [ + "stars" + ], + "properties": { + "review_text": { + "type": [ + "string", + "null" + ] + }, + "stars": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "CreateSavedSearchRequest": { + "type": "object", + "required": [ + "name", + "query" + ], + "properties": { + "name": { + "type": "string" + }, + "query": { + "type": "string" + }, + "sort_order": { + "type": [ + "string", + "null" + ] + } + } + }, + "CreateShareLinkRequest": { + "type": "object", + "required": [ + "media_id" + ], + "properties": { + "expires_in_hours": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "media_id": { + "type": "string", + "format": "uuid" + }, + "password": { + "type": [ + "string", + "null" + ] + } + } + }, + "CreateShareRequest": { + "type": "object", + "required": [ + "target_type", + "target_id", + "recipient_type" + ], + "properties": { + "expires_in_hours": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "inherit_to_children": { + "type": [ + "boolean", + "null" + ] + }, + "note": { + "type": [ + "string", + "null" + ] + }, + "password": { + "type": [ + "string", + "null" + ] + }, + "permissions": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/SharePermissionsRequest" + } + ] + }, + "recipient_group_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "recipient_type": { + "type": "string" + }, + "recipient_user_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "target_id": { + "type": "string" + }, + "target_type": { + "type": "string" + } + } + }, + "CreateTagRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "parent_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + }, + "CreateTranscodeRequest": { + "type": "object", + "required": [ + "profile" + ], + "properties": { + "profile": { + "type": "string" + } + } + }, + "CreateUploadSessionRequest": { + "type": "object", + "required": [ + "target_path", + "expected_hash", + "expected_size" + ], + "properties": { + "chunk_size": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "expected_hash": { + "type": "string" + }, + "expected_size": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "target_path": { + "type": "string" + } + } + }, + "CustomFieldResponse": { + "type": "object", + "required": [ + "field_type", + "value" + ], + "properties": { + "field_type": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "DatabaseHealth": { + "type": "object", + "required": [ + "status", + "latency_ms" + ], + "properties": { + "latency_ms": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "media_count": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "status": { + "type": "string" + } + } + }, + "DatabaseStatsResponse": { + "type": "object", + "required": [ + "media_count", + "tag_count", + "collection_count", + "audit_count", + "database_size_bytes", + "backend_name" + ], + "properties": { + "audit_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "backend_name": { + "type": "string" + }, + "collection_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "database_size_bytes": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "media_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "tag_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "DetailedHealthResponse": { + "type": "object", + "description": "Detailed health check for monitoring dashboards", + "required": [ + "status", + "version", + "uptime_seconds", + "database", + "filesystem", + "cache", + "jobs" + ], + "properties": { + "cache": { + "$ref": "#/components/schemas/CacheHealth" + }, + "database": { + "$ref": "#/components/schemas/DatabaseHealth" + }, + "filesystem": { + "$ref": "#/components/schemas/FilesystemHealth" + }, + "jobs": { + "$ref": "#/components/schemas/JobsHealth" + }, + "status": { + "type": "string" + }, + "uptime_seconds": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "version": { + "type": "string" + } + } + }, + "DeviceRegistrationResponse": { + "type": "object", + "required": [ + "device", + "device_token" + ], + "properties": { + "device": { + "$ref": "#/components/schemas/DeviceResponse" + }, + "device_token": { + "type": "string" + } + } + }, + "DeviceResponse": { + "type": "object", + "required": [ + "id", + "name", + "device_type", + "client_version", + "last_seen_at", + "enabled", + "created_at" + ], + "properties": { + "client_version": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "device_type": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "last_seen_at": { + "type": "string", + "format": "date-time" + }, + "last_sync_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "name": { + "type": "string" + }, + "os_info": { + "type": [ + "string", + "null" + ] + }, + "sync_cursor": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + } + }, + "DirectoryImportRequest": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "collection_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "new_tags": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "path": { + "type": "string" + }, + "tag_ids": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "format": "uuid" + } + } + } + }, + "DirectoryPreviewFile": { + "type": "object", + "required": [ + "path", + "file_name", + "media_type", + "file_size" + ], + "properties": { + "file_name": { + "type": "string" + }, + "file_size": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "media_type": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "DirectoryPreviewResponse": { + "type": "object", + "required": [ + "files", + "total_count", + "total_size" + ], + "properties": { + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DirectoryPreviewFile" + } + }, + "total_count": { + "type": "integer", + "minimum": 0 + }, + "total_size": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "DuplicateGroupResponse": { + "type": "object", + "required": [ + "content_hash", + "items" + ], + "properties": { + "content_hash": { + "type": "string" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaResponse" + } + } + } + }, + "EmptyTrashResponse": { + "type": "object", + "required": [ + "deleted_count" + ], + "properties": { + "deleted_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "ExportRequest": { + "type": "object", + "required": [ + "format", + "destination" + ], + "properties": { + "destination": { + "type": "string" + }, + "format": { + "type": "string" + } + } + }, + "ExternalMetadataResponse": { + "type": "object", + "required": [ + "id", + "media_id", + "source", + "metadata", + "confidence", + "last_updated" + ], + "properties": { + "confidence": { + "type": "number", + "format": "double" + }, + "external_id": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "last_updated": { + "type": "string", + "format": "date-time" + }, + "media_id": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "source": { + "type": "string" + } + } + }, + "FavoriteRequest": { + "type": "object", + "required": [ + "media_id" + ], + "properties": { + "media_id": { + "type": "string", + "format": "uuid" + } + } + }, + "FilesystemHealth": { + "type": "object", + "required": [ + "status", + "roots_configured", + "roots_accessible" + ], + "properties": { + "roots_accessible": { + "type": "integer", + "minimum": 0 + }, + "roots_configured": { + "type": "integer", + "minimum": 0 + }, + "status": { + "type": "string" + } + } + }, + "GenerateThumbnailsRequest": { + "type": "object", + "properties": { + "only_missing": { + "type": "boolean", + "description": "When true, only generate thumbnails for items that don't have one yet.\nWhen false (default), regenerate all thumbnails." + } + } + }, + "GetChangesParams": { + "type": "object", + "properties": { + "cursor": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + } + } + }, + "GrantLibraryAccessRequest": { + "type": "object", + "required": [ + "root_path", + "permission" + ], + "properties": { + "permission": { + "type": "string" + }, + "root_path": { + "type": "string" + } + } + }, + "GraphEdgeResponse": { + "type": "object", + "description": "Graph edge for visualization", + "required": [ + "source", + "target", + "link_type" + ], + "properties": { + "link_type": { + "type": "string" + }, + "source": { + "type": "string" + }, + "target": { + "type": "string" + } + } + }, + "GraphNodeResponse": { + "type": "object", + "description": "Graph node for visualization", + "required": [ + "id", + "label", + "media_type", + "link_count", + "backlink_count" + ], + "properties": { + "backlink_count": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "link_count": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "media_type": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + } + } + }, + "GraphResponse": { + "type": "object", + "description": "Response for graph visualization", + "required": [ + "nodes", + "edges", + "node_count", + "edge_count" + ], + "properties": { + "edge_count": { + "type": "integer", + "minimum": 0 + }, + "edges": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GraphEdgeResponse" + } + }, + "node_count": { + "type": "integer", + "minimum": 0 + }, + "nodes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GraphNodeResponse" + } + } + } + }, + "HealthResponse": { + "type": "object", + "description": "Basic health check response", + "required": [ + "status", + "version" + ], + "properties": { + "cache": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/CacheHealth" + } + ] + }, + "database": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DatabaseHealth" + } + ] + }, + "filesystem": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/FilesystemHealth" + } + ] + }, + "status": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "ImportRequest": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string" + } + } + }, + "ImportResponse": { + "type": "object", + "required": [ + "media_id", + "was_duplicate" + ], + "properties": { + "media_id": { + "type": "string" + }, + "was_duplicate": { + "type": "boolean" + } + } + }, + "ImportWithOptionsRequest": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "collection_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "new_tags": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "path": { + "type": "string" + }, + "tag_ids": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "format": "uuid" + } + } + } + }, + "InstallPluginRequest": { + "type": "object", + "required": [ + "source" + ], + "properties": { + "source": { + "type": "string" + } + } + }, + "JobsHealth": { + "type": "object", + "required": [ + "pending", + "running" + ], + "properties": { + "pending": { + "type": "integer", + "minimum": 0 + }, + "running": { + "type": "integer", + "minimum": 0 + } + } + }, + "LibraryStatisticsResponse": { + "type": "object", + "required": [ + "total_media", + "total_size_bytes", + "avg_file_size_bytes", + "media_by_type", + "storage_by_type", + "top_tags", + "top_collections", + "total_tags", + "total_collections", + "total_duplicates" + ], + "properties": { + "avg_file_size_bytes": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "media_by_type": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TypeCountResponse" + } + }, + "newest_item": { + "type": [ + "string", + "null" + ] + }, + "oldest_item": { + "type": [ + "string", + "null" + ] + }, + "storage_by_type": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TypeCountResponse" + } + }, + "top_collections": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TypeCountResponse" + } + }, + "top_tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TypeCountResponse" + } + }, + "total_collections": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "total_duplicates": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "total_media": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "total_size_bytes": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "total_tags": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "LoginRequest": { + "type": "object", + "required": [ + "username", + "password" + ], + "properties": { + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "LoginResponse": { + "type": "object", + "required": [ + "token", + "username", + "role" + ], + "properties": { + "role": { + "type": "string" + }, + "token": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "ManagedStorageStatsResponse": { + "type": "object", + "required": [ + "total_blobs", + "total_size_bytes", + "orphaned_blobs", + "deduplication_ratio" + ], + "properties": { + "deduplication_ratio": { + "type": "number", + "format": "double" + }, + "orphaned_blobs": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "total_blobs": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "total_size_bytes": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "MapMarker": { + "type": "object", + "description": "Map marker response", + "required": [ + "id", + "latitude", + "longitude" + ], + "properties": { + "date_taken": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "string" + }, + "latitude": { + "type": "number", + "format": "double" + }, + "longitude": { + "type": "number", + "format": "double" + }, + "thumbnail_url": { + "type": [ + "string", + "null" + ] + } + } + }, + "MediaCountResponse": { + "type": "object", + "required": [ + "count" + ], + "properties": { + "count": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "MediaResponse": { + "type": "object", + "required": [ + "id", + "path", + "file_name", + "media_type", + "content_hash", + "file_size", + "has_thumbnail", + "custom_fields", + "created_at", + "updated_at" + ], + "properties": { + "album": { + "type": [ + "string", + "null" + ] + }, + "artist": { + "type": [ + "string", + "null" + ] + }, + "camera_make": { + "type": [ + "string", + "null" + ] + }, + "camera_model": { + "type": [ + "string", + "null" + ] + }, + "content_hash": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "custom_fields": { + "type": "object" + }, + "date_taken": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "duration_secs": { + "type": [ + "number", + "null" + ], + "format": "double" + }, + "file_name": { + "type": "string" + }, + "file_size": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "genre": { + "type": [ + "string", + "null" + ] + }, + "has_thumbnail": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "latitude": { + "type": [ + "number", + "null" + ], + "format": "double" + }, + "links_extracted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "longitude": { + "type": [ + "number", + "null" + ], + "format": "double" + }, + "media_type": { + "type": "string" + }, + "path": { + "type": "string" + }, + "rating": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "year": { + "type": [ + "integer", + "null" + ], + "format": "int32" + } + } + }, + "MostViewedResponse": { + "type": "object", + "required": [ + "media", + "view_count" + ], + "properties": { + "media": { + "$ref": "#/components/schemas/MediaResponse" + }, + "view_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "MoveMediaRequest": { + "type": "object", + "required": [ + "destination" + ], + "properties": { + "destination": { + "type": "string" + } + } + }, + "OpenRequest": { + "type": "object", + "required": [ + "media_id" + ], + "properties": { + "media_id": { + "type": "string", + "format": "uuid" + } + } + }, + "OrphanResolveRequest": { + "type": "object", + "required": [ + "action", + "ids" + ], + "properties": { + "action": { + "type": "string" + }, + "ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + }, + "OutgoingLinkItem": { + "type": "object", + "description": "Individual outgoing link item", + "required": [ + "id", + "target_path", + "link_type", + "is_resolved" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "is_resolved": { + "type": "boolean" + }, + "line_number": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "link_text": { + "type": [ + "string", + "null" + ] + }, + "link_type": { + "type": "string" + }, + "target_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "target_path": { + "type": "string" + } + } + }, + "OutgoingLinksResponse": { + "type": "object", + "description": "Response for outgoing links query", + "required": [ + "links", + "count" + ], + "properties": { + "count": { + "type": "integer", + "minimum": 0 + }, + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OutgoingLinkItem" + } + } + } + }, + "PaginationParams": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "offset": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "sort": { + "type": [ + "string", + "null" + ] + } + } + }, + "PlaylistItemRequest": { + "type": "object", + "required": [ + "media_id" + ], + "properties": { + "media_id": { + "type": "string", + "format": "uuid" + }, + "position": { + "type": [ + "integer", + "null" + ], + "format": "int32" + } + } + }, + "PlaylistResponse": { + "type": "object", + "required": [ + "id", + "owner_id", + "name", + "is_public", + "is_smart", + "created_at", + "updated_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "filter_query": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "is_public": { + "type": "boolean" + }, + "is_smart": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "owner_id": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "PluginEventRequest": { + "type": "object", + "description": "Request body for emitting a plugin event", + "required": [ + "event" + ], + "properties": { + "event": { + "type": "string" + }, + "payload": { + "type": "object" + } + } + }, + "PluginResponse": { + "type": "object", + "required": [ + "id", + "name", + "version", + "author", + "description", + "api_version", + "enabled" + ], + "properties": { + "api_version": { + "type": "string" + }, + "author": { + "type": "string" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "PluginUiPageEntry": { + "type": "object", + "description": "A single plugin UI page entry in the list response", + "required": [ + "plugin_id", + "page", + "allowed_endpoints" + ], + "properties": { + "allowed_endpoints": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Endpoint paths this plugin is allowed to fetch (empty means no\nrestriction)" + }, + "page": { + "type": "object", + "description": "Full page definition" + }, + "plugin_id": { + "type": "string", + "description": "Plugin ID that provides this page" + } + } + }, + "PluginUiWidgetEntry": { + "type": "object", + "description": "A single plugin UI widget entry in the list response", + "required": [ + "plugin_id", + "widget" + ], + "properties": { + "plugin_id": { + "type": "string", + "description": "Plugin ID that provides this widget" + }, + "widget": { + "type": "object", + "description": "Full widget definition" + } + } + }, + "RatingResponse": { + "type": "object", + "required": [ + "id", + "user_id", + "media_id", + "stars", + "created_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string" + }, + "media_id": { + "type": "string" + }, + "review_text": { + "type": [ + "string", + "null" + ] + }, + "stars": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "user_id": { + "type": "string" + } + } + }, + "ReadingProgressResponse": { + "type": "object", + "description": "Reading progress response DTO", + "required": [ + "media_id", + "user_id", + "current_page", + "progress_percent", + "last_read_at" + ], + "properties": { + "current_page": { + "type": "integer", + "format": "int32" + }, + "last_read_at": { + "type": "string" + }, + "media_id": { + "type": "string", + "format": "uuid" + }, + "progress_percent": { + "type": "number", + "format": "double" + }, + "total_pages": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "user_id": { + "type": "string", + "format": "uuid" + } + } + }, + "RecordUsageEventRequest": { + "type": "object", + "required": [ + "event_type" + ], + "properties": { + "context": { + "type": [ + "object", + "null" + ] + }, + "duration_secs": { + "type": [ + "number", + "null" + ], + "format": "double" + }, + "event_type": { + "type": "string" + }, + "media_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + }, + "RegisterDeviceRequest": { + "type": "object", + "required": [ + "name", + "device_type", + "client_version" + ], + "properties": { + "client_version": { + "type": "string" + }, + "device_type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "os_info": { + "type": [ + "string", + "null" + ] + } + } + }, + "ReindexResponse": { + "type": "object", + "description": "Response for reindex operation", + "required": [ + "message", + "links_extracted" + ], + "properties": { + "links_extracted": { + "type": "integer", + "minimum": 0 + }, + "message": { + "type": "string" + } + } + }, + "RenameMediaRequest": { + "type": "object", + "required": [ + "new_name" + ], + "properties": { + "new_name": { + "type": "string" + } + } + }, + "ReorderPlaylistRequest": { + "type": "object", + "required": [ + "media_id", + "new_position" + ], + "properties": { + "media_id": { + "type": "string", + "format": "uuid" + }, + "new_position": { + "type": "integer", + "format": "int32" + } + } + }, + "ReportChangesRequest": { + "type": "object", + "required": [ + "changes" + ], + "properties": { + "changes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ClientChangeReport" + } + } + } + }, + "ReportChangesResponse": { + "type": "object", + "required": [ + "accepted", + "conflicts", + "upload_required" + ], + "properties": { + "accepted": { + "type": "array", + "items": { + "type": "string" + } + }, + "conflicts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConflictResponse" + } + }, + "upload_required": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "ResolveConflictRequest": { + "type": "object", + "required": [ + "resolution" + ], + "properties": { + "resolution": { + "type": "string" + } + } + }, + "ResolveLinksResponse": { + "type": "object", + "description": "Response for link resolution", + "required": [ + "resolved_count" + ], + "properties": { + "resolved_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "RevokeLibraryAccessRequest": { + "type": "object", + "required": [ + "root_path" + ], + "properties": { + "root_path": { + "type": "string" + } + } + }, + "RootDirRequest": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string" + } + } + }, + "SavedSearchResponse": { + "type": "object", + "required": [ + "id", + "name", + "query", + "created_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "query": { + "type": "string" + }, + "sort_order": { + "type": [ + "string", + "null" + ] + } + } + }, + "ScanJobResponse": { + "type": "object", + "required": [ + "job_id" + ], + "properties": { + "job_id": { + "type": "string" + } + } + }, + "ScanRequest": { + "type": "object", + "properties": { + "path": { + "type": [ + "string", + "null" + ] + } + } + }, + "ScanResponse": { + "type": "object", + "required": [ + "files_found", + "files_processed", + "errors" + ], + "properties": { + "errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "files_found": { + "type": "integer", + "minimum": 0 + }, + "files_processed": { + "type": "integer", + "minimum": 0 + } + } + }, + "ScanStatusResponse": { + "type": "object", + "required": [ + "scanning", + "files_found", + "files_processed", + "error_count", + "errors" + ], + "properties": { + "error_count": { + "type": "integer", + "minimum": 0 + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "files_found": { + "type": "integer", + "minimum": 0 + }, + "files_processed": { + "type": "integer", + "minimum": 0 + }, + "scanning": { + "type": "boolean" + } + } + }, + "ScanningConfigResponse": { + "type": "object", + "required": [ + "watch", + "poll_interval_secs", + "ignore_patterns" + ], + "properties": { + "ignore_patterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "poll_interval_secs": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "watch": { + "type": "boolean" + } + } + }, + "ScheduledTaskResponse": { + "type": "object", + "required": [ + "id", + "name", + "schedule", + "enabled" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "last_run": { + "type": [ + "string", + "null" + ] + }, + "last_status": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "next_run": { + "type": [ + "string", + "null" + ] + }, + "schedule": { + "type": "string" + } + } + }, + "SearchParams": { + "type": "object", + "required": [ + "q" + ], + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "offset": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "q": { + "type": "string" + }, + "sort": { + "type": [ + "string", + "null" + ] + } + } + }, + "SearchRequestBody": { + "type": "object", + "required": [ + "q" + ], + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "offset": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "q": { + "type": "string" + }, + "sort": { + "type": [ + "string", + "null" + ] + } + } + }, + "SearchResponse": { + "type": "object", + "required": [ + "items", + "total_count" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaResponse" + } + }, + "total_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "SeriesSummary": { + "type": "object", + "description": "Series summary DTO", + "required": [ + "name", + "book_count" + ], + "properties": { + "book_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "name": { + "type": "string" + } + } + }, + "ServerConfigResponse": { + "type": "object", + "required": [ + "host", + "port" + ], + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "SessionInfo": { + "type": "object", + "required": [ + "username", + "role", + "created_at", + "last_accessed", + "expires_at" + ], + "properties": { + "created_at": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "last_accessed": { + "type": "string" + }, + "role": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "SessionListResponse": { + "type": "object", + "description": "List all active sessions (admin only)", + "required": [ + "sessions" + ], + "properties": { + "sessions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionInfo" + } + } + } + }, + "SetCustomFieldRequest": { + "type": "object", + "required": [ + "name", + "field_type", + "value" + ], + "properties": { + "field_type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "ShareActivityResponse": { + "type": "object", + "required": [ + "id", + "share_id", + "action", + "timestamp" + ], + "properties": { + "action": { + "type": "string" + }, + "actor_id": { + "type": [ + "string", + "null" + ] + }, + "actor_ip": { + "type": [ + "string", + "null" + ] + }, + "details": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "share_id": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ShareLinkResponse": { + "type": "object", + "required": [ + "id", + "media_id", + "token", + "view_count", + "created_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "string" + }, + "media_id": { + "type": "string" + }, + "token": { + "type": "string" + }, + "view_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "ShareNotificationResponse": { + "type": "object", + "required": [ + "id", + "share_id", + "notification_type", + "is_read", + "created_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string" + }, + "is_read": { + "type": "boolean" + }, + "notification_type": { + "type": "string" + }, + "share_id": { + "type": "string" + } + } + }, + "SharePermissionsRequest": { + "type": "object", + "properties": { + "can_add": { + "type": [ + "boolean", + "null" + ] + }, + "can_delete": { + "type": [ + "boolean", + "null" + ] + }, + "can_download": { + "type": [ + "boolean", + "null" + ] + }, + "can_edit": { + "type": [ + "boolean", + "null" + ] + }, + "can_reshare": { + "type": [ + "boolean", + "null" + ] + }, + "can_view": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "SharePermissionsResponse": { + "type": "object", + "required": [ + "can_view", + "can_download", + "can_edit", + "can_delete", + "can_reshare", + "can_add" + ], + "properties": { + "can_add": { + "type": "boolean" + }, + "can_delete": { + "type": "boolean" + }, + "can_download": { + "type": "boolean" + }, + "can_edit": { + "type": "boolean" + }, + "can_reshare": { + "type": "boolean" + }, + "can_view": { + "type": "boolean" + } + } + }, + "ShareResponse": { + "type": "object", + "required": [ + "id", + "target_type", + "target_id", + "owner_id", + "recipient_type", + "permissions", + "access_count", + "inherit_to_children", + "created_at", + "updated_at" + ], + "properties": { + "access_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "string" + }, + "inherit_to_children": { + "type": "boolean" + }, + "last_accessed": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "note": { + "type": [ + "string", + "null" + ] + }, + "owner_id": { + "type": "string" + }, + "permissions": { + "$ref": "#/components/schemas/SharePermissionsResponse" + }, + "public_token": { + "type": [ + "string", + "null" + ] + }, + "recipient_group_id": { + "type": [ + "string", + "null" + ] + }, + "recipient_type": { + "type": "string" + }, + "recipient_user_id": { + "type": [ + "string", + "null" + ] + }, + "target_id": { + "type": "string" + }, + "target_type": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "SharedContentResponse": { + "oneOf": [ + { + "$ref": "#/components/schemas/MediaResponse" + }, + { + "type": "object", + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaResponse" + } + } + } + } + ], + "description": "Response for accessing shared content.\nSingle-media shares return the media object directly (backwards compatible).\nCollection/Tag/SavedSearch shares return a list of items." + }, + "SubtitleResponse": { + "type": "object", + "required": [ + "id", + "media_id", + "format", + "is_embedded", + "offset_ms", + "created_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "format": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_embedded": { + "type": "boolean" + }, + "language": { + "type": [ + "string", + "null" + ] + }, + "media_id": { + "type": "string" + }, + "offset_ms": { + "type": "integer", + "format": "int64" + }, + "track_index": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + } + }, + "SyncChangeResponse": { + "type": "object", + "required": [ + "id", + "sequence", + "change_type", + "path", + "timestamp" + ], + "properties": { + "change_type": { + "type": "string" + }, + "content_hash": { + "type": [ + "string", + "null" + ] + }, + "file_size": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "id": { + "type": "string" + }, + "media_id": { + "type": [ + "string", + "null" + ] + }, + "path": { + "type": "string" + }, + "sequence": { + "type": "integer", + "format": "int64" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "TagMediaRequest": { + "type": "object", + "required": [ + "tag_id" + ], + "properties": { + "tag_id": { + "type": "string", + "format": "uuid" + } + } + }, + "TagResponse": { + "type": "object", + "required": [ + "id", + "name", + "created_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "parent_id": { + "type": [ + "string", + "null" + ] + } + } + }, + "TimelineGroup": { + "type": "object", + "description": "Timeline group response", + "required": [ + "date", + "count", + "items" + ], + "properties": { + "count": { + "type": "integer", + "minimum": 0 + }, + "cover_id": { + "type": [ + "string", + "null" + ] + }, + "date": { + "type": "string" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaResponse" + } + } + } + }, + "TogglePluginRequest": { + "type": "object", + "required": [ + "enabled" + ], + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "TranscodeSessionResponse": { + "type": "object", + "required": [ + "id", + "media_id", + "profile", + "status", + "progress", + "created_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "string" + }, + "media_id": { + "type": "string" + }, + "profile": { + "type": "string" + }, + "progress": { + "type": "number", + "format": "float" + }, + "status": { + "type": "string" + } + } + }, + "TrashInfoResponse": { + "type": "object", + "required": [ + "count" + ], + "properties": { + "count": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "TrashResponse": { + "type": "object", + "required": [ + "items", + "total_count" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaResponse" + } + }, + "total_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "TypeCountResponse": { + "type": "object", + "required": [ + "name", + "count" + ], + "properties": { + "count": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "name": { + "type": "string" + } + } + }, + "UiConfigResponse": { + "type": "object", + "required": [ + "theme", + "default_view", + "default_page_size", + "default_view_mode", + "auto_play_media", + "show_thumbnails", + "sidebar_collapsed" + ], + "properties": { + "auto_play_media": { + "type": "boolean" + }, + "default_page_size": { + "type": "integer", + "minimum": 0 + }, + "default_view": { + "type": "string" + }, + "default_view_mode": { + "type": "string" + }, + "show_thumbnails": { + "type": "boolean" + }, + "sidebar_collapsed": { + "type": "boolean" + }, + "theme": { + "type": "string" + } + } + }, + "UnresolvedLinksResponse": { + "type": "object", + "description": "Response for unresolved links count", + "required": [ + "count" + ], + "properties": { + "count": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "UpdateDeviceRequest": { + "type": "object", + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + } + } + }, + "UpdateMediaFullRequest": { + "type": "object", + "properties": { + "album": { + "type": [ + "string", + "null" + ] + }, + "artist": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "genre": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "year": { + "type": [ + "integer", + "null" + ], + "format": "int32" + } + } + }, + "UpdateMediaRequest": { + "type": "object", + "properties": { + "album": { + "type": [ + "string", + "null" + ] + }, + "artist": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "genre": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "year": { + "type": [ + "integer", + "null" + ], + "format": "int32" + } + } + }, + "UpdatePlaylistRequest": { + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "is_public": { + "type": [ + "boolean", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + } + } + }, + "UpdateProgressRequest": { + "type": "object", + "description": "Update reading progress request", + "required": [ + "current_page" + ], + "properties": { + "current_page": { + "type": "integer", + "format": "int32" + } + } + }, + "UpdateScanningRequest": { + "type": "object", + "properties": { + "ignore_patterns": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "poll_interval_secs": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "watch": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "UpdateShareRequest": { + "type": "object", + "properties": { + "expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "inherit_to_children": { + "type": [ + "boolean", + "null" + ] + }, + "note": { + "type": [ + "string", + "null" + ] + }, + "permissions": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/SharePermissionsRequest" + } + ] + } + } + }, + "UpdateSubtitleOffsetRequest": { + "type": "object", + "required": [ + "offset_ms" + ], + "properties": { + "offset_ms": { + "type": "integer", + "format": "int64" + } + } + }, + "UpdateUiConfigRequest": { + "type": "object", + "properties": { + "auto_play_media": { + "type": [ + "boolean", + "null" + ] + }, + "default_page_size": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "default_view": { + "type": [ + "string", + "null" + ] + }, + "default_view_mode": { + "type": [ + "string", + "null" + ] + }, + "show_thumbnails": { + "type": [ + "boolean", + "null" + ] + }, + "sidebar_collapsed": { + "type": [ + "boolean", + "null" + ] + }, + "theme": { + "type": [ + "string", + "null" + ] + } + } + }, + "UploadResponse": { + "type": "object", + "required": [ + "media_id", + "content_hash", + "was_duplicate", + "file_size" + ], + "properties": { + "content_hash": { + "type": "string" + }, + "file_size": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "media_id": { + "type": "string" + }, + "was_duplicate": { + "type": "boolean" + } + } + }, + "UploadSessionResponse": { + "type": "object", + "required": [ + "id", + "target_path", + "expected_hash", + "expected_size", + "chunk_size", + "chunk_count", + "status", + "created_at", + "expires_at" + ], + "properties": { + "chunk_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "chunk_size": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "expected_hash": { + "type": "string" + }, + "expected_size": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string" + }, + "status": { + "type": "string" + }, + "target_path": { + "type": "string" + } + } + }, + "UsageEventResponse": { + "type": "object", + "required": [ + "id", + "event_type", + "timestamp" + ], + "properties": { + "duration_secs": { + "type": [ + "number", + "null" + ], + "format": "double" + }, + "event_type": { + "type": "string" + }, + "id": { + "type": "string" + }, + "media_id": { + "type": [ + "string", + "null" + ] + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": [ + "string", + "null" + ] + } + } + }, + "UserInfoResponse": { + "type": "object", + "required": [ + "username", + "role" + ], + "properties": { + "role": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "UserLibraryResponse": { + "type": "object", + "required": [ + "user_id", + "root_path", + "permission", + "granted_at" + ], + "properties": { + "granted_at": { + "type": "string", + "format": "date-time" + }, + "permission": { + "type": "string" + }, + "root_path": { + "type": "string" + }, + "user_id": { + "type": "string" + } + } + }, + "UserPreferencesResponse": { + "type": "object", + "required": [ + "auto_play" + ], + "properties": { + "auto_play": { + "type": "boolean" + }, + "default_video_quality": { + "type": [ + "string", + "null" + ] + }, + "language": { + "type": [ + "string", + "null" + ] + }, + "theme": { + "type": [ + "string", + "null" + ] + } + } + }, + "UserProfileResponse": { + "type": "object", + "required": [ + "preferences" + ], + "properties": { + "avatar_path": { + "type": [ + "string", + "null" + ] + }, + "bio": { + "type": [ + "string", + "null" + ] + }, + "preferences": { + "$ref": "#/components/schemas/UserPreferencesResponse" + } + } + }, + "UserResponse": { + "type": "object", + "required": [ + "id", + "username", + "role", + "profile", + "created_at", + "updated_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string" + }, + "profile": { + "$ref": "#/components/schemas/UserProfileResponse" + }, + "role": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "username": { + "type": "string" + } + } + }, + "VerifyIntegrityRequest": { + "type": "object", + "required": [ + "media_ids" + ], + "properties": { + "media_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + }, + "WatchProgressRequest": { + "type": "object", + "required": [ + "progress_secs" + ], + "properties": { + "progress_secs": { + "type": "number", + "format": "double" + } + } + }, + "WatchProgressResponse": { + "type": "object", + "required": [ + "progress_secs" + ], + "properties": { + "progress_secs": { + "type": "number", + "format": "double" + } + } + }, + "WebhookInfo": { + "type": "object", + "required": [ + "url", + "events" + ], + "properties": { + "events": { + "type": "array", + "items": { + "type": "string" + } + }, + "url": { + "type": "string" + } + } + } + }, + "securitySchemes": { + "bearer_auth": { + "type": "http", + "scheme": "bearer" + } + } + }, + "security": [ + { + "bearer_auth": [] + } + ], + "tags": [ + { + "name": "analytics", + "description": "Usage analytics and viewing history" + }, + { + "name": "audit", + "description": "Audit log entries" + }, + { + "name": "auth", + "description": "Authentication and session management" + }, + { + "name": "backup", + "description": "Database backup" + }, + { + "name": "books", + "description": "Book metadata, series, authors, and reading progress" + }, + { + "name": "collections", + "description": "Media collections" + }, + { + "name": "config", + "description": "Server configuration" + }, + { + "name": "database", + "description": "Database administration" + }, + { + "name": "duplicates", + "description": "Duplicate media detection" + }, + { + "name": "enrichment", + "description": "External metadata enrichment" + }, + { + "name": "export", + "description": "Media library export" + }, + { + "name": "health", + "description": "Server health checks" + }, + { + "name": "integrity", + "description": "Library integrity checks and repairs" + }, + { + "name": "jobs", + "description": "Background job management" + }, + { + "name": "media", + "description": "Media item management" + }, + { + "name": "notes", + "description": "Markdown notes link graph" + }, + { + "name": "photos", + "description": "Photo timeline and map view" + }, + { + "name": "playlists", + "description": "Media playlists" + }, + { + "name": "plugins", + "description": "Plugin management" + }, + { + "name": "saved_searches", + "description": "Saved search queries" + }, + { + "name": "scan", + "description": "Directory scanning" + }, + { + "name": "scheduled_tasks", + "description": "Scheduled background tasks" + }, + { + "name": "search", + "description": "Full-text media search" + }, + { + "name": "shares", + "description": "Media sharing and notifications" + }, + { + "name": "social", + "description": "Ratings, comments, favorites, and share links" + }, + { + "name": "statistics", + "description": "Library statistics" + }, + { + "name": "streaming", + "description": "HLS and DASH adaptive streaming" + }, + { + "name": "subtitles", + "description": "Media subtitle management" + }, + { + "name": "sync", + "description": "Multi-device library synchronization" + }, + { + "name": "tags", + "description": "Media tag management" + }, + { + "name": "transcode", + "description": "Video transcoding sessions" + }, + { + "name": "upload", + "description": "File upload and managed storage" + }, + { + "name": "users", + "description": "User and library access management" + }, + { + "name": "webhooks", + "description": "Webhook configuration" + } + ] +} \ No newline at end of file diff --git a/docs/api/photos.md b/docs/api/photos.md new file mode 100644 index 0000000..5afdba3 --- /dev/null +++ b/docs/api/photos.md @@ -0,0 +1,57 @@ +# Photos + +Photo timeline and map view + +## Endpoints + +### GET /api/v1/photos/map + +Get photos in a bounding box for map view + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `lat1` | query | Yes | Bounding box latitude 1 | +| `lon1` | query | Yes | Bounding box longitude 1 | +| `lat2` | query | Yes | Bounding box latitude 2 | +| `lon2` | query | Yes | Bounding box longitude 2 | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Map markers | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### GET /api/v1/photos/timeline + +Get timeline of photos grouped by date + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `group_by` | query | No | Grouping: day, month, year | +| `year` | query | No | Filter by year | +| `month` | query | No | Filter by month | +| `limit` | query | No | Max items (default 10000) | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Photo timeline groups | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + diff --git a/docs/api/playlists.md b/docs/api/playlists.md new file mode 100644 index 0000000..2f97cde --- /dev/null +++ b/docs/api/playlists.md @@ -0,0 +1,229 @@ +# Playlists + +Media playlists + +## Endpoints + +### GET /api/v1/playlists + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | List of playlists | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### POST /api/v1/playlists + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Playlist created | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### GET /api/v1/playlists/{id} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Playlist ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Playlist details | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | + +--- + +### PATCH /api/v1/playlists/{id} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Playlist ID | + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Playlist updated | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | + +--- + +### DELETE /api/v1/playlists/{id} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Playlist ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Playlist deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | + +--- + +### GET /api/v1/playlists/{id}/items + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Playlist ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Playlist items | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | + +--- + +### POST /api/v1/playlists/{id}/items + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Playlist ID | + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Item added | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | + +--- + +### PATCH /api/v1/playlists/{id}/items/reorder + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Playlist ID | + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Item reordered | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | + +--- + +### DELETE /api/v1/playlists/{id}/items/{media_id} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Playlist ID | +| `media_id` | path | Yes | Media item ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Item removed | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | + +--- + +### POST /api/v1/playlists/{id}/shuffle + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Playlist ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Shuffled playlist items | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | + +--- + diff --git a/docs/api/plugins.md b/docs/api/plugins.md new file mode 100644 index 0000000..eaab41e --- /dev/null +++ b/docs/api/plugins.md @@ -0,0 +1,209 @@ +# Plugins + +Plugin management + +## Endpoints + +### GET /api/v1/plugins + +List all installed plugins + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | List of plugins | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### POST /api/v1/plugins + +Install a plugin from URL or file path + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Plugin installed | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | + +--- + +### POST /api/v1/plugins/events + +Receive a plugin event emitted from the UI and dispatch it to interested +server-side event-handler plugins via the pipeline. + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Event received | +| 401 | Unauthorized | + +--- + +### GET /api/v1/plugins/ui/pages + +List all UI pages provided by loaded plugins + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Plugin UI pages | +| 401 | Unauthorized | + +--- + +### GET /api/v1/plugins/ui/theme + +List merged CSS custom property overrides from all enabled plugins + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Plugin UI theme extensions | +| 401 | Unauthorized | + +--- + +### GET /api/v1/plugins/ui/widgets + +List all UI widgets provided by loaded plugins + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Plugin UI widgets | +| 401 | Unauthorized | + +--- + +### GET /api/v1/plugins/{id} + +Get a specific plugin by ID + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Plugin ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Plugin details | +| 401 | Unauthorized | +| 404 | Not found | + +--- + +### DELETE /api/v1/plugins/{id} + +Uninstall a plugin + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Plugin ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Plugin uninstalled | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | + +--- + +### POST /api/v1/plugins/{id}/reload + +Reload a plugin (for development) + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Plugin ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Plugin reloaded | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | + +--- + +### PATCH /api/v1/plugins/{id}/toggle + +Enable or disable a plugin + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Plugin ID | + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Plugin toggled | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | + +--- + diff --git a/docs/api/saved_searches.md b/docs/api/saved_searches.md new file mode 100644 index 0000000..12e374d --- /dev/null +++ b/docs/api/saved_searches.md @@ -0,0 +1,62 @@ +# Saved_searches + +Saved search queries + +## Endpoints + +### GET /api/v1/searches + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | List of saved searches | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### POST /api/v1/searches + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Search saved | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### DELETE /api/v1/searches/{id} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Saved search ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Saved search deleted | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | + +--- + diff --git a/docs/api/scan.md b/docs/api/scan.md new file mode 100644 index 0000000..9c2af4b --- /dev/null +++ b/docs/api/scan.md @@ -0,0 +1,42 @@ +# Scan + +Directory scanning + +## Endpoints + +### POST /api/v1/scan + +Trigger a scan as a background job. Returns the job ID immediately. + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Scan job submitted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + +### GET /api/v1/scan/status + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Scan status | +| 401 | Unauthorized | + +--- + diff --git a/docs/api/scheduled_tasks.md b/docs/api/scheduled_tasks.md new file mode 100644 index 0000000..2367493 --- /dev/null +++ b/docs/api/scheduled_tasks.md @@ -0,0 +1,62 @@ +# Scheduled_tasks + +Scheduled background tasks + +## Endpoints + +### GET /api/v1/scheduled-tasks + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | List of scheduled tasks | +| 401 | Unauthorized | +| 403 | Forbidden | + +--- + +### POST /api/v1/scheduled-tasks/{id}/run + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Task ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Task triggered | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | + +--- + +### POST /api/v1/scheduled-tasks/{id}/toggle + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Task ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Task toggled | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | + +--- + diff --git a/docs/api/search.md b/docs/api/search.md new file mode 100644 index 0000000..102d2fb --- /dev/null +++ b/docs/api/search.md @@ -0,0 +1,51 @@ +# Search + +Full-text media search + +## Endpoints + +### GET /api/v1/search + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `q` | query | Yes | Search query | +| `sort` | query | No | Sort order | +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Pagination limit | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Search results | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### POST /api/v1/search + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Search results | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + diff --git a/docs/api/shares.md b/docs/api/shares.md new file mode 100644 index 0000000..9702f41 --- /dev/null +++ b/docs/api/shares.md @@ -0,0 +1,282 @@ +# Shares + +Media sharing and notifications + +## Endpoints + +### GET /api/v1/notifications/shares + +Get unread share notifications +GET /api/notifications/shares + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Unread notifications | +| 401 | Unauthorized | + +--- + +### POST /api/v1/notifications/shares/read-all + +Mark all notifications as read +POST /api/notifications/shares/read-all + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | All notifications marked as read | +| 401 | Unauthorized | + +--- + +### POST /api/v1/notifications/shares/{id}/read + +Mark a notification as read +POST /api/notifications/shares/{id}/read + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Notification ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Notification marked as read | +| 401 | Unauthorized | + +--- + +### GET /api/v1/shared/{token} + +Access a public shared resource +GET /api/shared/{token} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `token` | path | Yes | Share token | +| `password` | query | No | Share password if required | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Shared content | +| 401 | Unauthorized | +| 404 | Not found | + +--- + +### POST /api/v1/shares + +Create a new share +POST /api/shares + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Share created | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### POST /api/v1/shares/batch/delete + +Batch delete shares +POST /api/shares/batch/delete + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Shares deleted | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | + +--- + +### GET /api/v1/shares/incoming + +List incoming shares (shares shared with me) +GET /api/shares/incoming + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Pagination limit | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Incoming shares | +| 401 | Unauthorized | + +--- + +### GET /api/v1/shares/outgoing + +List outgoing shares (shares I created) +GET /api/shares/outgoing + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Pagination limit | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Outgoing shares | +| 401 | Unauthorized | + +--- + +### GET /api/v1/shares/{id} + +Get share details +GET /api/shares/{id} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Share ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Share details | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | + +--- + +### PATCH /api/v1/shares/{id} + +Update a share +PATCH /api/shares/{id} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Share ID | + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Share updated | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | + +--- + +### DELETE /api/v1/shares/{id} + +Delete (revoke) a share +DELETE /api/shares/{id} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Share ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 204 | Share deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | + +--- + +### GET /api/v1/shares/{id}/activity + +Get share activity log +GET /api/shares/{id}/activity + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Share ID | +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Pagination limit | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Share activity | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | + +--- + diff --git a/docs/api/social.md b/docs/api/social.md new file mode 100644 index 0000000..e706183 --- /dev/null +++ b/docs/api/social.md @@ -0,0 +1,196 @@ +# Social + +Ratings, comments, favorites, and share links + +## Endpoints + +### GET /api/v1/favorites + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | User favorites | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### POST /api/v1/favorites + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Added to favorites | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### DELETE /api/v1/favorites/{media_id} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `media_id` | path | Yes | Media item ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Removed from favorites | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### POST /api/v1/media/share + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Share link created | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### GET /api/v1/media/{id}/comments + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Media comments | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### POST /api/v1/media/{id}/comments + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Comment added | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### POST /api/v1/media/{id}/rate + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Rating saved | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### GET /api/v1/media/{id}/ratings + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Media ratings | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### GET /api/v1/shared/media/{token} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `token` | path | Yes | Share token | +| `password` | query | No | Share password | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Shared media | +| 401 | Unauthorized | +| 404 | Not found | + +--- + diff --git a/docs/api/statistics.md b/docs/api/statistics.md new file mode 100644 index 0000000..270ad62 --- /dev/null +++ b/docs/api/statistics.md @@ -0,0 +1,20 @@ +# Statistics + +Library statistics + +## Endpoints + +### GET /api/v1/statistics + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Library statistics | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + diff --git a/docs/api/streaming.md b/docs/api/streaming.md new file mode 100644 index 0000000..11a3352 --- /dev/null +++ b/docs/api/streaming.md @@ -0,0 +1,115 @@ +# Streaming + +HLS and DASH adaptive streaming + +## Endpoints + +### GET /api/v1/media/{id}/stream/dash/manifest.mpd + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | DASH manifest | +| 400 | Bad request | +| 401 | Unauthorized | +| 404 | Not found | + +--- + +### GET /api/v1/media/{id}/stream/dash/{profile}/{segment} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | +| `profile` | path | Yes | Transcode profile name | +| `segment` | path | Yes | Segment filename | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | DASH segment data | +| 202 | Segment not yet available | +| 400 | Bad request | +| 401 | Unauthorized | + +--- + +### GET /api/v1/media/{id}/stream/hls/master.m3u8 + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | HLS master playlist | +| 401 | Unauthorized | +| 404 | Not found | + +--- + +### GET /api/v1/media/{id}/stream/hls/{profile}/playlist.m3u8 + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | +| `profile` | path | Yes | Transcode profile name | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | HLS variant playlist | +| 400 | Bad request | +| 401 | Unauthorized | +| 404 | Not found | + +--- + +### GET /api/v1/media/{id}/stream/hls/{profile}/{segment} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | +| `profile` | path | Yes | Transcode profile name | +| `segment` | path | Yes | Segment filename | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | HLS segment data | +| 202 | Segment not yet available | +| 400 | Bad request | +| 401 | Unauthorized | + +--- + diff --git a/docs/api/subtitles.md b/docs/api/subtitles.md new file mode 100644 index 0000000..ce36e05 --- /dev/null +++ b/docs/api/subtitles.md @@ -0,0 +1,120 @@ +# Subtitles + +Media subtitle management + +## Endpoints + +### GET /api/v1/media/{id}/subtitles + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Subtitles | +| 401 | Unauthorized | +| 404 | Not found | + +--- + +### POST /api/v1/media/{id}/subtitles + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Subtitle added | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### GET /api/v1/media/{media_id}/subtitles/{subtitle_id}/content + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `media_id` | path | Yes | Media item ID | +| `subtitle_id` | path | Yes | Subtitle ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Subtitle content | +| 401 | Unauthorized | +| 404 | Not found | + +--- + +### DELETE /api/v1/subtitles/{id} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Subtitle ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Subtitle deleted | +| 401 | Unauthorized | +| 404 | Not found | + +--- + +### PATCH /api/v1/subtitles/{id}/offset + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Subtitle ID | + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Offset updated | +| 401 | Unauthorized | +| 404 | Not found | + +--- + diff --git a/docs/api/sync.md b/docs/api/sync.md new file mode 100644 index 0000000..165d4c0 --- /dev/null +++ b/docs/api/sync.md @@ -0,0 +1,412 @@ +# Sync + +Multi-device library synchronization + +## Endpoints + +### POST /api/v1/sync/ack + +Acknowledge processed changes +POST /api/sync/ack + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Changes acknowledged | +| 400 | Bad request | +| 401 | Unauthorized | + +--- + +### GET /api/v1/sync/changes + +Get changes since cursor +GET /api/sync/changes + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `cursor` | query | No | Sync cursor | +| `limit` | query | No | Max changes (max 1000) | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Changes since cursor | +| 400 | Bad request | +| 401 | Unauthorized | + +--- + +### GET /api/v1/sync/conflicts + +List unresolved conflicts +GET /api/sync/conflicts + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Unresolved conflicts | +| 401 | Unauthorized | + +--- + +### POST /api/v1/sync/conflicts/{id}/resolve + +Resolve a sync conflict +POST /api/sync/conflicts/{id}/resolve + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Conflict ID | + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Conflict resolved | +| 400 | Bad request | +| 401 | Unauthorized | + +--- + +### GET /api/v1/sync/devices + +List user's sync devices +GET /api/sync/devices + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | List of devices | +| 401 | Unauthorized | + +--- + +### POST /api/v1/sync/devices + +Register a new sync device +POST /api/sync/devices + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Device registered | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### GET /api/v1/sync/devices/{id} + +Get device details +GET /api/sync/devices/{id} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Device ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Device details | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | + +--- + +### PUT /api/v1/sync/devices/{id} + +Update a device +PUT /api/sync/devices/{id} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Device ID | + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Device updated | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | + +--- + +### DELETE /api/v1/sync/devices/{id} + +Delete a device +DELETE /api/sync/devices/{id} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Device ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 204 | Device deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | + +--- + +### POST /api/v1/sync/devices/{id}/token + +Regenerate device token +POST /api/sync/devices/{id}/token + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Device ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Token regenerated | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | + +--- + +### GET /api/v1/sync/download/{path} + +Download a file for sync (supports Range header) +GET /api/sync/download/{*path} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `path` | path | Yes | File path | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | File content | +| 206 | Partial content | +| 401 | Unauthorized | +| 404 | Not found | + +--- + +### POST /api/v1/sync/report + +Report local changes from client +POST /api/sync/report + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Changes processed | +| 400 | Bad request | +| 401 | Unauthorized | + +--- + +### POST /api/v1/sync/upload + +Create an upload session for chunked upload +POST /api/sync/upload + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Upload session created | +| 400 | Bad request | +| 401 | Unauthorized | + +--- + +### GET /api/v1/sync/upload/{id} + +Get upload session status +GET /api/sync/upload/{id} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Upload session ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Upload session status | +| 401 | Unauthorized | +| 404 | Not found | + +--- + +### DELETE /api/v1/sync/upload/{id} + +Cancel an upload session +DELETE /api/sync/upload/{id} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Upload session ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 204 | Upload cancelled | +| 401 | Unauthorized | +| 404 | Not found | + +--- + +### PUT /api/v1/sync/upload/{id}/chunks/{index} + +Upload a chunk +PUT /api/sync/upload/{id}/chunks/{index} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Upload session ID | +| `index` | path | Yes | Chunk index | + +#### Request Body + +Chunk binary data +`Content-Type: application/octet-stream` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Chunk received | +| 400 | Bad request | +| 401 | Unauthorized | +| 404 | Not found | + +--- + +### POST /api/v1/sync/upload/{id}/complete + +Complete an upload session +POST /api/sync/upload/{id}/complete + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Upload session ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Upload completed | +| 400 | Bad request | +| 401 | Unauthorized | +| 404 | Not found | + +--- + diff --git a/docs/api/tags.md b/docs/api/tags.md new file mode 100644 index 0000000..a9a71c0 --- /dev/null +++ b/docs/api/tags.md @@ -0,0 +1,157 @@ +# Tags + +Media tag management + +## Endpoints + +### GET /api/v1/media/{media_id}/tags + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `media_id` | path | Yes | Media item ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Media tags | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | + +--- + +### POST /api/v1/media/{media_id}/tags + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `media_id` | path | Yes | Media item ID | + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Tag applied | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | + +--- + +### DELETE /api/v1/media/{media_id}/tags/{tag_id} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `media_id` | path | Yes | Media item ID | +| `tag_id` | path | Yes | Tag ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Tag removed | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | + +--- + +### GET /api/v1/tags + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | List of tags | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### POST /api/v1/tags + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Tag created | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + +### GET /api/v1/tags/{id} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Tag ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Tag | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | + +--- + +### DELETE /api/v1/tags/{id} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Tag ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Tag deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | + +--- + diff --git a/docs/api/transcode.md b/docs/api/transcode.md new file mode 100644 index 0000000..126135e --- /dev/null +++ b/docs/api/transcode.md @@ -0,0 +1,86 @@ +# Transcode + +Video transcoding sessions + +## Endpoints + +### POST /api/v1/media/{id}/transcode + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Transcode job submitted | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### GET /api/v1/transcode + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | List of transcode sessions | +| 401 | Unauthorized | + +--- + +### GET /api/v1/transcode/{id} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Transcode session ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Transcode session details | +| 401 | Unauthorized | +| 404 | Not found | + +--- + +### DELETE /api/v1/transcode/{id} + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Transcode session ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Transcode session cancelled | +| 401 | Unauthorized | +| 404 | Not found | + +--- + diff --git a/docs/api/upload.md b/docs/api/upload.md new file mode 100644 index 0000000..da8a61b --- /dev/null +++ b/docs/api/upload.md @@ -0,0 +1,89 @@ +# Upload + +File upload and managed storage + +## Endpoints + +### GET /api/v1/managed/stats + +Get managed storage statistics +GET /api/managed/stats + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Managed storage statistics | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### GET /api/v1/media/{id}/download + +Download a managed file +GET /api/media/{id}/download + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | File content | +| 400 | Bad request | +| 401 | Unauthorized | +| 404 | Not found | + +--- + +### POST /api/v1/media/{id}/move-to-managed + +Migrate an external file to managed storage +POST /api/media/{id}/move-to-managed + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 204 | File migrated | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + +### POST /api/v1/upload + +Upload a file to managed storage +POST /api/upload + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | File uploaded | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | + +--- + diff --git a/docs/api/users.md b/docs/api/users.md new file mode 100644 index 0000000..0cd7087 --- /dev/null +++ b/docs/api/users.md @@ -0,0 +1,207 @@ +# Users + +User and library access management + +## Endpoints + +### GET /api/v1/admin/users + +List all users (admin only) + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | List of users | +| 401 | Unauthorized | +| 403 | Forbidden | + +--- + +### POST /api/v1/admin/users + +Create a new user (admin only) + +**Authentication:** Required (Bearer JWT) + +#### Request Body + +username, password, role, and optional profile fields +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | User created | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | + +--- + +### GET /api/v1/admin/users/{id} + +Get a specific user by ID + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | User ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | User details | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | + +--- + +### PATCH /api/v1/admin/users/{id} + +Update a user + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | User ID | + +#### Request Body + +Optional password, role, or profile fields to update +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | User updated | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | + +--- + +### DELETE /api/v1/admin/users/{id} + +Delete a user (admin only) + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | User ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | User deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | + +--- + +### GET /api/v1/admin/users/{id}/libraries + +Get user's accessible libraries + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | User ID | + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | User libraries | +| 401 | Unauthorized | +| 403 | Forbidden | + +--- + +### POST /api/v1/admin/users/{id}/libraries + +Grant library access to a user (admin only) + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | User ID | + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Access granted | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | + +--- + +### DELETE /api/v1/admin/users/{id}/libraries + +Revoke library access from a user (admin only) + +Uses a JSON body instead of a path parameter because `root_path` may contain +slashes that conflict with URL routing. + +**Authentication:** Required (Bearer JWT) + +#### Parameters + +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | User ID | + +#### Request Body + +`Content-Type: application/json` + +See `docs/api/openapi.json` for the full schema. + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Access revoked | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | + +--- + diff --git a/docs/api/webhooks.md b/docs/api/webhooks.md new file mode 100644 index 0000000..9005323 --- /dev/null +++ b/docs/api/webhooks.md @@ -0,0 +1,34 @@ +# Webhooks + +Webhook configuration + +## Endpoints + +### GET /api/v1/webhooks + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | List of configured webhooks | +| 401 | Unauthorized | +| 403 | Forbidden | + +--- + +### POST /api/v1/webhooks/test + +**Authentication:** Required (Bearer JWT) + +#### Responses + +| Status | Description | +|--------|-------------| +| 200 | Test webhook sent | +| 401 | Unauthorized | +| 403 | Forbidden | + +--- + diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..58559dc --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "xtask" +version = "0.1.0" +edition = "2024" +publish = false + +[[bin]] +name = "xtask" +path = "src/main.rs" + +[dependencies] +pinakes-server = { workspace = true } +utoipa = { workspace = true } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/xtask/src/docs.rs b/xtask/src/docs.rs new file mode 100644 index 0000000..c8d84ee --- /dev/null +++ b/xtask/src/docs.rs @@ -0,0 +1,215 @@ +use std::{collections::BTreeMap, fmt::Write as _}; + +use pinakes_server::api_doc::ApiDoc; +use utoipa::{ + OpenApi, + openapi::{RefOr, Required, path::ParameterIn}, +}; + +#[expect( + clippy::expect_used, + clippy::print_stdout, + reason = "Panics are acceptable here." +)] +pub fn run() { + let api = ApiDoc::openapi(); + + let out_dir = std::path::Path::new("docs/api"); + std::fs::create_dir_all(out_dir).expect("create docs/api dir"); + + let json = serde_json::to_string_pretty(&api).expect("serialize openapi"); + std::fs::write(out_dir.join("openapi.json"), &json) + .expect("write openapi.json"); + println!("Written docs/api/openapi.json"); + + // Collect all operations grouped by tag. + let mut tag_ops: BTreeMap< + String, + Vec<(String, String, &utoipa::openapi::path::Operation)>, + > = BTreeMap::new(); + + for (path, item) in &api.paths.paths { + let method_ops: &[(&str, Option<&utoipa::openapi::path::Operation>)] = &[ + ("GET", item.get.as_ref()), + ("POST", item.post.as_ref()), + ("PUT", item.put.as_ref()), + ("PATCH", item.patch.as_ref()), + ("DELETE", item.delete.as_ref()), + ]; + + for (method, maybe_op) in method_ops { + let Some(op) = maybe_op else { continue }; + + let tags = op.tags.as_deref().unwrap_or(&[]); + if tags.is_empty() { + tag_ops.entry("_untagged".to_owned()).or_default().push(( + (*method).to_owned(), + path.clone(), + op, + )); + } else { + for tag in tags { + tag_ops.entry(tag.clone()).or_default().push(( + (*method).to_owned(), + path.clone(), + op, + )); + } + } + } + } + + // Build a lookup from tag name to description. + let tag_descriptions: BTreeMap = api + .tags + .as_deref() + .unwrap_or(&[]) + .iter() + .map(|t| { + let desc = t.description.as_deref().unwrap_or("").to_owned(); + (t.name.clone(), desc) + }) + .collect(); + + let mut files_written = 0usize; + + for (tag_name, ops) in &tag_ops { + let description = tag_descriptions.get(tag_name).map_or("", String::as_str); + + let mut md = String::new(); + + write!(md, "# {}\n\n", title_case(tag_name)).expect("write to String"); + if !description.is_empty() { + write!(md, "{description}\n\n").expect("write to String"); + } + md.push_str("## Endpoints\n\n"); + + for (method, path, op) in ops { + write_operation(&mut md, method, path, op); + } + + let file_name = format!("{}.md", tag_name.replace('/', "_")); + let dest = out_dir.join(&file_name); + std::fs::write(&dest, &md).expect("write markdown file"); + println!("Written docs/api/{file_name}"); + files_written += 1; + } + + println!( + "Done: wrote docs/api/openapi.json and {files_written} markdown files." + ); +} + +fn title_case(s: &str) -> String { + let mut chars = s.chars(); + chars.next().map_or_else(String::new, |c| { + c.to_uppercase().collect::() + chars.as_str() + }) +} + +#[expect( + clippy::expect_used, + reason = "write! on String is infallible, but clippy still warns on expect()" +)] +fn write_operation( + md: &mut String, + method: &str, + path: &str, + op: &utoipa::openapi::path::Operation, +) { + let summary = op.summary.as_deref().unwrap_or(""); + let description = op.description.as_deref().unwrap_or(""); + + write!(md, "### {method} {path}\n\n").expect("write to String"); + + if !summary.is_empty() { + md.push_str(summary); + md.push('\n'); + if !description.is_empty() { + md.push('\n'); + md.push_str(description); + md.push('\n'); + } + md.push('\n'); + } else if !description.is_empty() { + write!(md, "{description}\n\n").expect("write to String"); + } + + // Authentication + let needs_auth = op.security.as_ref().is_none_or(|s| !s.is_empty()); + if needs_auth { + md.push_str("**Authentication:** Required (Bearer JWT)\n\n"); + } else { + md.push_str("**Authentication:** Not required\n\n"); + } + + // Parameters + if let Some(params) = &op.parameters { + if !params.is_empty() { + md.push_str("#### Parameters\n\n"); + md.push_str("| Name | In | Required | Description |\n"); + md.push_str("|------|----|----------|-------------|\n"); + for p in params { + let location = param_in_str(&p.parameter_in); + let required = match p.required { + Required::True => "Yes", + Required::False => "No", + }; + let desc = p + .description + .as_deref() + .unwrap_or("") + .replace('|', "\\|") + .replace('\n', " "); + writeln!( + md, + "| `{}` | {} | {} | {} |", + p.name, location, required, desc + ) + .expect("write to String"); + } + md.push('\n'); + } + } + + // Request body + if let Some(rb) = &op.request_body { + md.push_str("#### Request Body\n\n"); + if let Some(desc) = &rb.description { + writeln!(md, "{desc}").expect("write to String"); + } + for content_type in rb.content.keys() { + write!(md, "`Content-Type: {content_type}`\n\n") + .expect("write to String"); + md.push_str("See `docs/api/openapi.json` for the full schema.\n\n"); + } + } + + // Responses + let responses = &op.responses; + if !responses.responses.is_empty() { + md.push_str("#### Responses\n\n"); + md.push_str("| Status | Description |\n"); + md.push_str("|--------|-------------|\n"); + for (status, resp) in &responses.responses { + let raw = match resp { + RefOr::T(r) => r.description.as_str(), + RefOr::Ref(_) => "See schema", + }; + let desc = raw.replace('|', "\\|").replace('\n', " "); + writeln!(md, "| {status} | {desc} |").expect("write to String"); + } + md.push('\n'); + } + + md.push_str("---\n\n"); +} + +const fn param_in_str(pin: &ParameterIn) -> &'static str { + match pin { + ParameterIn::Path => "path", + ParameterIn::Query => "query", + ParameterIn::Header => "header", + ParameterIn::Cookie => "cookie", + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..cd395c8 --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,19 @@ +mod docs; + +#[expect(clippy::print_stderr)] +fn main() { + let args: Vec = std::env::args().collect(); + match args.get(1).map(String::as_str) { + Some("docs") => docs::run(), + Some(cmd) => { + eprintln!("Unknown command: {cmd}"); + std::process::exit(1); + }, + None => { + eprintln!("Usage: cargo xtask "); + eprintln!("Commands:"); + eprintln!(" docs Generate API documentation"); + std::process::exit(1); + }, + } +} -- 2.43.0 From 0dda2aec8f9bf6577bc9f724456ed539aaa19010 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Mar 2026 02:19:37 +0300 Subject: [PATCH 18/30] chore: add `cargo xtask` alias Signed-off-by: NotAShelf Change-Id: Iaf5e1365e825e88a6cde49a50624c7736a6a6964 --- .cargo/config.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.cargo/config.toml b/.cargo/config.toml index a91a31b..7d835ba 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,3 +1,6 @@ +[alias] +xtask = "run --manifest-path xtask/Cargo.toml --" + [unstable] build-std = ["std", "panic_abort", "core", "alloc"] -- 2.43.0 From bb69f2fa370ffea5296840b3b42179120cddc62b Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Mar 2026 02:19:55 +0300 Subject: [PATCH 19/30] pinakes-tui: cover more API routes in the TUI crate Signed-off-by: NotAShelf Change-Id: Id14b6f82d3b9f3c27bee9c214a1bdedc6a6a6964 --- crates/pinakes-tui/src/app.rs | 1040 ++++++++++++++++++++++-- crates/pinakes-tui/src/client.rs | 363 ++++++++- crates/pinakes-tui/src/event.rs | 17 + crates/pinakes-tui/src/input.rs | 110 ++- crates/pinakes-tui/src/ui/admin.rs | 174 ++++ crates/pinakes-tui/src/ui/detail.rs | 107 +++ crates/pinakes-tui/src/ui/mod.rs | 19 +- crates/pinakes-tui/src/ui/playlists.rs | 117 +++ 8 files changed, 1873 insertions(+), 74 deletions(-) create mode 100644 crates/pinakes-tui/src/ui/admin.rs create mode 100644 crates/pinakes-tui/src/ui/playlists.rs diff --git a/crates/pinakes-tui/src/app.rs b/crates/pinakes-tui/src/app.rs index d1ee90c..45d0761 100644 --- a/crates/pinakes-tui/src/app.rs +++ b/crates/pinakes-tui/src/app.rs @@ -13,7 +13,14 @@ use crate::{ ApiClient, AuditEntryResponse, BookMetadataResponse, + CommentResponse, + DeviceResponse, + PlaylistResponse, ReadingProgressResponse, + SubtitleEntry, + TranscodeSessionResponse, + UserResponse, + WebhookInfo, }, event::{ApiResult, AppEvent, EventHandler}, input::{self, Action}, @@ -37,6 +44,8 @@ pub enum View { Statistics, Tasks, Books, + Playlists, + Admin, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -114,6 +123,37 @@ pub struct AppState { // Reading progress input (page number) pub page_input: String, pub entering_page: bool, + // Playlists view + pub playlists: Vec, + pub playlists_selected: usize, + pub playlist_items: Vec, + pub playlist_items_selected: usize, + pub viewing_playlist_items: bool, + pub playlist_name_input: String, + pub creating_playlist: bool, + // Social / detail extras + pub media_comments: Vec, + pub media_rating: Option, + pub is_favorite: bool, + pub comment_input: String, + pub entering_comment: bool, + pub entering_rating: bool, + pub rating_input: String, + pub subtitles: Vec, + pub showing_subtitles: bool, + pub showing_transcodes: bool, + pub transcode_profile_input: String, + pub entering_transcode: bool, + // Admin view + pub admin_tab: usize, + pub users_list: Vec, + pub users_selected: usize, + pub sync_devices: Vec, + pub sync_devices_selected: usize, + pub webhooks: Vec, + pub webhooks_selected: usize, + pub transcodes: Vec, + pub transcodes_selected: usize, } #[derive(Clone)] @@ -182,6 +222,34 @@ impl AppState { reading_progress: None, page_input: String::new(), entering_page: false, + playlists: Vec::new(), + playlists_selected: 0, + playlist_items: Vec::new(), + playlist_items_selected: 0, + viewing_playlist_items: false, + playlist_name_input: String::new(), + creating_playlist: false, + media_comments: Vec::new(), + media_rating: None, + is_favorite: false, + comment_input: String::new(), + entering_comment: false, + entering_rating: false, + rating_input: String::new(), + subtitles: Vec::new(), + showing_subtitles: false, + showing_transcodes: false, + transcode_profile_input: String::new(), + entering_transcode: false, + admin_tab: 0, + users_list: Vec::new(), + users_selected: 0, + sync_devices: Vec::new(), + sync_devices_selected: 0, + webhooks: Vec::new(), + webhooks_selected: 0, + transcodes: Vec::new(), + transcodes_selected: 0, } } } @@ -221,8 +289,215 @@ pub async fn run(server_url: &str, api_key: Option<&str>) -> Result<()> { if let Some(event) = events.next().await { match event { AppEvent::Key(key) => { + // Intercept input when entering a comment + if state.entering_comment { + use crossterm::event::KeyCode; + match key.code { + KeyCode::Char(c) => { + state.comment_input.push(c); + state.status_message = + Some(format!("Comment: {}", state.comment_input)); + }, + KeyCode::Backspace => { + state.comment_input.pop(); + state.status_message = if state.comment_input.is_empty() { + Some("Enter comment (Enter to submit, Esc to cancel)".into()) + } else { + Some(format!("Comment: {}", state.comment_input)) + }; + }, + KeyCode::Enter => { + state.entering_comment = false; + let text = state.comment_input.clone(); + state.comment_input.clear(); + if !text.is_empty() + && let Some(ref media) = state.selected_media + { + let media_id = media.id.clone(); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.add_comment(&media_id, &text).await { + Ok(_) => { + if let Ok(comments) = + client.list_comments(&media_id).await + { + let _ = tx.send(AppEvent::ApiResult( + ApiResult::CommentsLoaded(comments), + )); + } + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Comment: {e}"), + ))); + }, + } + }); + } + }, + KeyCode::Esc => { + state.entering_comment = false; + state.comment_input.clear(); + state.status_message = None; + }, + _ => {}, + } + // Intercept input when entering a rating + } else if state.entering_rating { + use crossterm::event::KeyCode; + match key.code { + KeyCode::Char(c) if c.is_ascii_digit() => { + state.rating_input.clear(); + state.rating_input.push(c); + state.status_message = + Some(format!("Rating: {c} stars (Enter to confirm)")); + }, + KeyCode::Enter => { + state.entering_rating = false; + if let Ok(stars) = state.rating_input.parse::() { + if (1u8..=5).contains(&stars) + && let Some(ref media) = state.selected_media + { + let media_id = media.id.clone(); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.rate_media(&media_id, stars).await { + Ok(()) => { + let _ = tx.send(AppEvent::ApiResult( + ApiResult::RatingSet(stars), + )); + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult( + ApiResult::Error(format!("Rate: {e}")), + )); + }, + } + }); + } else { + state.status_message = + Some("Rating must be between 1 and 5".into()); + } + } + state.rating_input.clear(); + }, + KeyCode::Esc => { + state.entering_rating = false; + state.rating_input.clear(); + state.status_message = None; + }, + _ => {}, + } + // Intercept input when entering transcode profile + } else if state.entering_transcode { + use crossterm::event::KeyCode; + match key.code { + KeyCode::Char(c) => { + state.transcode_profile_input.push(c); + state.status_message = + Some(format!("Profile: {}", state.transcode_profile_input)); + }, + KeyCode::Backspace => { + state.transcode_profile_input.pop(); + state.status_message = + if state.transcode_profile_input.is_empty() { + Some( + "Enter transcode profile (Enter to start, Esc to cancel)" + .into(), + ) + } else { + Some(format!("Profile: {}", state.transcode_profile_input)) + }; + }, + KeyCode::Enter => { + state.entering_transcode = false; + let profile = state.transcode_profile_input.clone(); + state.transcode_profile_input.clear(); + if !profile.is_empty() + && let Some(ref media) = state.selected_media + { + let media_id = media.id.clone(); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.start_transcode(&media_id, &profile).await { + Ok(_) => { + let _ = tx.send(AppEvent::ApiResult( + ApiResult::TranscodeStarted, + )); + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Transcode: {e}"), + ))); + }, + } + }); + } + }, + KeyCode::Esc => { + state.entering_transcode = false; + state.transcode_profile_input.clear(); + state.status_message = None; + }, + _ => {}, + } + // Intercept input when creating a playlist + } else if state.creating_playlist { + use crossterm::event::KeyCode; + match key.code { + KeyCode::Char(c) => { + state.playlist_name_input.push(c); + state.status_message = + Some(format!("Playlist name: {}", state.playlist_name_input)); + }, + KeyCode::Backspace => { + state.playlist_name_input.pop(); + state.status_message = if state.playlist_name_input.is_empty() { + Some( + "Enter playlist name (Enter to create, Esc to cancel)" + .into(), + ) + } else { + Some(format!("Playlist name: {}", state.playlist_name_input)) + }; + }, + KeyCode::Enter => { + state.creating_playlist = false; + let name = state.playlist_name_input.clone(); + state.playlist_name_input.clear(); + if !name.is_empty() { + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.create_playlist(&name, None).await { + Ok(_) => { + if let Ok(playlists) = client.list_playlists().await { + let _ = tx.send(AppEvent::ApiResult( + ApiResult::PlaylistsLoaded(playlists), + )); + } + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Create playlist: {e}"), + ))); + }, + } + }); + } + }, + KeyCode::Esc => { + state.creating_playlist = false; + state.playlist_name_input.clear(); + state.status_message = None; + }, + _ => {}, + } // Intercept input when entering reading progress page number - if state.entering_page { + } else if state.entering_page { use crossterm::event::KeyCode; match key.code { KeyCode::Char(c) if c.is_ascii_digit() => { @@ -425,6 +700,69 @@ fn handle_api_result(state: &mut AppState, result: ApiResult) { ApiResult::ReadingProgressUpdated => { state.status_message = Some("Reading progress updated".into()); }, + ApiResult::PlaylistsLoaded(playlists) => { + state.playlists = playlists; + if !state.playlists.is_empty() + && state.playlists_selected >= state.playlists.len() + { + state.playlists_selected = 0; + } + state.status_message = None; + }, + ApiResult::PlaylistItemsLoaded(items) => { + state.playlist_items = items; + state.playlist_items_selected = 0; + state.viewing_playlist_items = true; + state.status_message = None; + }, + ApiResult::CommentsLoaded(comments) => { + state.media_comments = comments; + state.status_message = None; + }, + ApiResult::RatingSet(stars) => { + state.media_rating = Some(stars); + state.status_message = Some("Rating saved".into()); + }, + ApiResult::FavoriteToggled => { + state.is_favorite = !state.is_favorite; + if state.is_favorite { + state.status_message = Some("Added to favorites".into()); + } else { + state.status_message = Some("Removed from favorites".into()); + } + }, + ApiResult::SubtitlesLoaded(resp) => { + state.subtitles = resp.subtitles; + state.showing_subtitles = true; + state.status_message = None; + }, + ApiResult::EnrichmentTriggered => { + state.status_message = Some("Enrichment started".into()); + }, + ApiResult::TranscodeStarted => { + state.status_message = Some("Transcode started".into()); + }, + ApiResult::UsersLoaded(users) => { + state.users_list = users; + state.users_selected = 0; + state.status_message = None; + }, + ApiResult::SyncDevicesLoaded(devices) => { + state.sync_devices = devices; + state.sync_devices_selected = 0; + state.status_message = None; + }, + ApiResult::WebhooksLoaded(webhooks) => { + state.webhooks = webhooks; + state.webhooks_selected = 0; + state.status_message = None; + }, + ApiResult::TranscodesLoaded(transcodes) => { + state.transcodes = transcodes; + state.transcodes_selected = 0; + state.showing_transcodes = true; + state.status_message = None; + }, ApiResult::Error(msg) => { state.status_message = Some(format!("Error: {msg}")); }, @@ -440,70 +778,93 @@ async fn handle_action( match action { Action::Quit => state.should_quit = true, Action::NavigateDown => { - let len = match state.current_view { - View::Search => state.search_results.len(), - View::Tags => state.tags.len(), - View::Collections => state.collections.len(), - View::Audit => state.audit_log.len(), - View::Books => { - match state.books_sub_view { - BooksSubView::List => state.books_list.len(), - BooksSubView::Series => state.books_series.len(), - BooksSubView::Authors => state.books_authors.len(), + if state.current_view == View::Playlists { + if state.viewing_playlist_items { + let len = state.playlist_items.len(); + if len > 0 { + state.playlist_items_selected = + (state.playlist_items_selected + 1).min(len - 1); } - }, - _ => state.media_list.len(), - }; - if len > 0 { - let idx = match state.current_view { - View::Search => &mut state.search_selected, - View::Tags => &mut state.tag_selected, - View::Collections => &mut state.collection_selected, - View::Audit => &mut state.audit_selected, - View::Books => &mut state.books_selected, - _ => &mut state.selected_index, + } else { + let len = state.playlists.len(); + if len > 0 { + state.playlists_selected = + (state.playlists_selected + 1).min(len - 1); + } + } + } else if state.current_view == View::Admin { + match state.admin_tab { + 0 => { + let len = state.users_list.len(); + if len > 0 { + state.users_selected = (state.users_selected + 1).min(len - 1); + } + }, + 1 => { + let len = state.sync_devices.len(); + if len > 0 { + state.sync_devices_selected = + (state.sync_devices_selected + 1).min(len - 1); + } + }, + _ => { + let len = state.webhooks.len(); + if len > 0 { + state.webhooks_selected = + (state.webhooks_selected + 1).min(len - 1); + } + }, + } + } else { + let len = match state.current_view { + View::Search => state.search_results.len(), + View::Tags => state.tags.len(), + View::Collections => state.collections.len(), + View::Audit => state.audit_log.len(), + View::Books => { + match state.books_sub_view { + BooksSubView::List => state.books_list.len(), + BooksSubView::Series => state.books_series.len(), + BooksSubView::Authors => state.books_authors.len(), + } + }, + _ => state.media_list.len(), }; - *idx = Some(idx.map_or(0, |i| (i + 1).min(len - 1))); + if len > 0 { + let idx = match state.current_view { + View::Search => &mut state.search_selected, + View::Tags => &mut state.tag_selected, + View::Collections => &mut state.collection_selected, + View::Audit => &mut state.audit_selected, + View::Books => &mut state.books_selected, + _ => &mut state.selected_index, + }; + *idx = Some(idx.map_or(0, |i| (i + 1).min(len - 1))); + } } }, Action::NavigateUp => { - let idx = match state.current_view { - View::Search => &mut state.search_selected, - View::Tags => &mut state.tag_selected, - View::Collections => &mut state.collection_selected, - View::Audit => &mut state.audit_selected, - View::Books => &mut state.books_selected, - _ => &mut state.selected_index, - }; - *idx = Some(idx.map_or(0, |i| i.saturating_sub(1))); - }, - Action::GoTop => { - let idx = match state.current_view { - View::Search => &mut state.search_selected, - View::Tags => &mut state.tag_selected, - View::Collections => &mut state.collection_selected, - View::Audit => &mut state.audit_selected, - View::Books => &mut state.books_selected, - _ => &mut state.selected_index, - }; - *idx = Some(0); - }, - Action::GoBottom => { - let len = match state.current_view { - View::Search => state.search_results.len(), - View::Tags => state.tags.len(), - View::Collections => state.collections.len(), - View::Audit => state.audit_log.len(), - View::Books => { - match state.books_sub_view { - BooksSubView::List => state.books_list.len(), - BooksSubView::Series => state.books_series.len(), - BooksSubView::Authors => state.books_authors.len(), - } - }, - _ => state.media_list.len(), - }; - if len > 0 { + if state.current_view == View::Playlists { + if state.viewing_playlist_items { + state.playlist_items_selected = + state.playlist_items_selected.saturating_sub(1); + } else { + state.playlists_selected = state.playlists_selected.saturating_sub(1); + } + } else if state.current_view == View::Admin { + match state.admin_tab { + 0 => { + state.users_selected = state.users_selected.saturating_sub(1); + }, + 1 => { + state.sync_devices_selected = + state.sync_devices_selected.saturating_sub(1); + }, + _ => { + state.webhooks_selected = state.webhooks_selected.saturating_sub(1); + }, + } + } else { let idx = match state.current_view { View::Search => &mut state.search_selected, View::Tags => &mut state.tag_selected, @@ -512,10 +873,121 @@ async fn handle_action( View::Books => &mut state.books_selected, _ => &mut state.selected_index, }; - *idx = Some(len - 1); + *idx = Some(idx.map_or(0, |i| i.saturating_sub(1))); + } + }, + Action::GoTop => { + if state.current_view == View::Playlists { + if state.viewing_playlist_items { + state.playlist_items_selected = 0; + } else { + state.playlists_selected = 0; + } + } else if state.current_view == View::Admin { + match state.admin_tab { + 0 => state.users_selected = 0, + 1 => state.sync_devices_selected = 0, + _ => state.webhooks_selected = 0, + } + } else { + let idx = match state.current_view { + View::Search => &mut state.search_selected, + View::Tags => &mut state.tag_selected, + View::Collections => &mut state.collection_selected, + View::Audit => &mut state.audit_selected, + View::Books => &mut state.books_selected, + _ => &mut state.selected_index, + }; + *idx = Some(0); + } + }, + Action::GoBottom => { + if state.current_view == View::Playlists { + if state.viewing_playlist_items { + let len = state.playlist_items.len(); + if len > 0 { + state.playlist_items_selected = len - 1; + } + } else { + let len = state.playlists.len(); + if len > 0 { + state.playlists_selected = len - 1; + } + } + } else if state.current_view == View::Admin { + match state.admin_tab { + 0 => { + let len = state.users_list.len(); + if len > 0 { + state.users_selected = len - 1; + } + }, + 1 => { + let len = state.sync_devices.len(); + if len > 0 { + state.sync_devices_selected = len - 1; + } + }, + _ => { + let len = state.webhooks.len(); + if len > 0 { + state.webhooks_selected = len - 1; + } + }, + } + } else { + let len = match state.current_view { + View::Search => state.search_results.len(), + View::Tags => state.tags.len(), + View::Collections => state.collections.len(), + View::Audit => state.audit_log.len(), + View::Books => { + match state.books_sub_view { + BooksSubView::List => state.books_list.len(), + BooksSubView::Series => state.books_series.len(), + BooksSubView::Authors => state.books_authors.len(), + } + }, + _ => state.media_list.len(), + }; + if len > 0 { + let idx = match state.current_view { + View::Search => &mut state.search_selected, + View::Tags => &mut state.tag_selected, + View::Collections => &mut state.collection_selected, + View::Audit => &mut state.audit_selected, + View::Books => &mut state.books_selected, + _ => &mut state.selected_index, + }; + *idx = Some(len - 1); + } } }, Action::Select => { + if state.current_view == View::Playlists && !state.input_mode { + // Open items for the selected playlist inline (no recursion) + if let Some(pl) = state.playlists.get(state.playlists_selected).cloned() + { + state.status_message = Some("Loading playlist items...".into()); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.get_playlist_items(&pl.id).await { + Ok(items) => { + let _ = tx.send(AppEvent::ApiResult( + ApiResult::PlaylistItemsLoaded(items), + )); + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Playlist items: {e}"), + ))); + }, + } + }); + } + return; + } if state.input_mode { state.input_mode = false; match state.current_view { @@ -625,6 +1097,24 @@ async fn handle_action( client.get_book_metadata(&full_media.id).await.ok(); state.reading_progress = client.get_reading_progress(&full_media.id).await.ok(); + // Load comments and subtitles asynchronously + let media_id = full_media.id.clone(); + let client_clone = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + if let Ok(comments) = client_clone.list_comments(&media_id).await + { + let _ = tx.send(AppEvent::ApiResult( + ApiResult::CommentsLoaded(comments), + )); + } + }); + state.media_comments.clear(); + state.media_rating = None; + state.is_favorite = false; + state.showing_subtitles = false; + state.showing_transcodes = false; + state.subtitles.clear(); state.selected_media = Some(full_media); if let Some(tags) = media_tags { state.tags = tags; @@ -635,6 +1125,12 @@ async fn handle_action( } else { state.book_metadata = None; state.reading_progress = None; + state.media_comments.clear(); + state.media_rating = None; + state.is_favorite = false; + state.showing_subtitles = false; + state.showing_transcodes = false; + state.subtitles.clear(); state.selected_media = Some(media); } state.current_view = View::Detail; @@ -644,6 +1140,11 @@ async fn handle_action( Action::Back => { if state.input_mode { state.input_mode = false; + } else if state.current_view == View::Playlists + && state.viewing_playlist_items + { + state.viewing_playlist_items = false; + state.playlist_items.clear(); } else { state.current_view = View::Library; state.status_message = None; @@ -1079,6 +1580,28 @@ async fn handle_action( tx.send(AppEvent::ApiResult(ApiResult::BookAuthors(authors))); } }, + View::Playlists => { + if let Ok(playlists) = client.list_playlists().await { + let _ = tx.send(AppEvent::ApiResult(ApiResult::PlaylistsLoaded( + playlists, + ))); + } + }, + View::Admin => { + if let Ok(users) = client.list_users().await { + let _ = + tx.send(AppEvent::ApiResult(ApiResult::UsersLoaded(users))); + } + if let Ok(devices) = client.list_sync_devices().await { + let _ = tx.send(AppEvent::ApiResult( + ApiResult::SyncDevicesLoaded(devices), + )); + } + if let Ok(webhooks) = client.list_webhooks().await { + let _ = tx + .send(AppEvent::ApiResult(ApiResult::WebhooksLoaded(webhooks))); + } + }, View::Search | View::MetadataEdit | View::Queue => { // No generic refresh for these views }, @@ -1113,7 +1636,9 @@ async fn handle_action( View::Books => View::Queue, View::Queue => View::Statistics, View::Statistics => View::Tasks, - View::Tasks + View::Tasks => View::Playlists, + View::Playlists => View::Admin, + View::Admin | View::Detail | View::Import | View::Settings @@ -1143,7 +1668,7 @@ async fn handle_action( } } else { state.current_view = match state.current_view { - View::Library => View::Tasks, + View::Library => View::Admin, View::Search | View::Detail | View::Import @@ -1158,6 +1683,8 @@ async fn handle_action( View::Queue => View::Books, View::Statistics => View::Queue, View::Tasks => View::Statistics, + View::Playlists => View::Tasks, + View::Admin => View::Playlists, }; } }, @@ -1629,6 +2156,393 @@ async fn handle_action( Some("Select a tag first (use t to view tags)".into()); } }, + Action::PlaylistsView => { + state.current_view = View::Playlists; + state.viewing_playlist_items = false; + state.status_message = Some("Loading playlists...".into()); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.list_playlists().await { + Ok(playlists) => { + let _ = tx + .send(AppEvent::ApiResult(ApiResult::PlaylistsLoaded(playlists))); + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Playlists: {e}" + )))); + }, + } + }); + }, + Action::CreatePlaylist => { + if state.current_view == View::Playlists { + state.creating_playlist = true; + state.playlist_name_input.clear(); + state.status_message = + Some("Enter playlist name (Enter to create, Esc to cancel)".into()); + } + }, + Action::DeletePlaylist => { + if state.current_view == View::Playlists + && !state.viewing_playlist_items + && let Some(pl) = state.playlists.get(state.playlists_selected).cloned() + { + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.delete_playlist(&pl.id).await { + Ok(()) => { + if let Ok(playlists) = client.list_playlists().await { + let _ = tx.send(AppEvent::ApiResult( + ApiResult::PlaylistsLoaded(playlists), + )); + } + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Delete playlist: {e}" + )))); + }, + } + }); + } + }, + Action::RemoveFromPlaylist => { + if state.current_view == View::Playlists + && state.viewing_playlist_items + && let Some(pl) = state.playlists.get(state.playlists_selected).cloned() + && let Some(item) = state + .playlist_items + .get(state.playlist_items_selected) + .cloned() + { + let client = client.clone(); + let tx = event_sender.clone(); + let pl_id = pl.id; + tokio::spawn(async move { + match client.remove_from_playlist(&pl_id, &item.id).await { + Ok(()) => { + if let Ok(items) = client.get_playlist_items(&pl_id).await { + let _ = tx.send(AppEvent::ApiResult( + ApiResult::PlaylistItemsLoaded(items), + )); + } + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Remove from playlist: {e}" + )))); + }, + } + }); + } + }, + Action::ShufflePlaylist => { + if state.current_view == View::Playlists + && let Some(pl) = state.playlists.get(state.playlists_selected).cloned() + { + let client = client.clone(); + let tx = event_sender.clone(); + let pl_id = pl.id; + tokio::spawn(async move { + match client.shuffle_playlist(&pl_id).await { + Ok(()) => { + if let Ok(items) = client.get_playlist_items(&pl_id).await { + let _ = tx.send(AppEvent::ApiResult( + ApiResult::PlaylistItemsLoaded(items), + )); + } + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Shuffle playlist: {e}" + )))); + }, + } + }); + } + }, + Action::ToggleFavorite => { + if state.current_view == View::Detail + && let Some(ref media) = state.selected_media + { + let media_id = media.id.clone(); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.toggle_favorite(&media_id).await { + Ok(()) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::FavoriteToggled)); + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Favorite: {e}" + )))); + }, + } + }); + } + }, + Action::RateMedia => { + if state.current_view == View::Detail && state.selected_media.is_some() { + state.entering_rating = true; + state.rating_input.clear(); + state.status_message = + Some("Enter rating 1-5 (Enter to confirm, Esc to cancel)".into()); + } + }, + Action::AddComment => { + if state.current_view == View::Detail && state.selected_media.is_some() { + state.entering_comment = true; + state.comment_input.clear(); + state.status_message = + Some("Enter comment (Enter to submit, Esc to cancel)".into()); + } + }, + Action::EnrichMedia => { + if state.current_view == View::Detail + && let Some(ref media) = state.selected_media + { + let media_id = media.id.clone(); + state.status_message = Some("Enriching...".into()); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.enrich_media(&media_id).await { + Ok(()) => { + let _ = + tx.send(AppEvent::ApiResult(ApiResult::EnrichmentTriggered)); + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Enrich: {e}" + )))); + }, + } + }); + } + }, + Action::ToggleSubtitles => { + if state.current_view == View::Detail { + if state.showing_subtitles { + state.showing_subtitles = false; + } else if let Some(ref media) = state.selected_media { + let media_id = media.id.clone(); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.list_subtitles(&media_id).await { + Ok(resp) => { + let _ = tx + .send(AppEvent::ApiResult(ApiResult::SubtitlesLoaded(resp))); + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Subtitles: {e}"), + ))); + }, + } + }); + } + } + }, + Action::ToggleTranscodes => { + if state.current_view == View::Detail { + if state.showing_transcodes { + state.showing_transcodes = false; + } else { + state.entering_transcode = true; + state.transcode_profile_input.clear(); + state.status_message = Some( + "Enter transcode profile (Enter to start, Esc to cancel)".into(), + ); + } + } + }, + Action::CancelTranscode => { + if state.current_view == View::Detail + && state.showing_transcodes + && let Some(tc) = + state.transcodes.get(state.transcodes_selected).cloned() + { + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.cancel_transcode(&tc.id).await { + Ok(()) => { + if let Ok(transcodes) = client.list_transcodes().await { + let _ = tx.send(AppEvent::ApiResult( + ApiResult::TranscodesLoaded(transcodes), + )); + } + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Cancel transcode: {e}" + )))); + }, + } + }); + } + }, + Action::AdminView => { + state.current_view = View::Admin; + state.admin_tab = 0; + state.status_message = Some("Loading admin data...".into()); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + if let Ok(users) = client.list_users().await { + let _ = tx.send(AppEvent::ApiResult(ApiResult::UsersLoaded(users))); + } + if let Ok(devices) = client.list_sync_devices().await { + let _ = + tx.send(AppEvent::ApiResult(ApiResult::SyncDevicesLoaded(devices))); + } + if let Ok(webhooks) = client.list_webhooks().await { + let _ = + tx.send(AppEvent::ApiResult(ApiResult::WebhooksLoaded(webhooks))); + } + }); + }, + Action::AdminTabNext => { + if state.current_view == View::Admin { + state.admin_tab = (state.admin_tab + 1) % 3; + } + }, + Action::AdminTabPrev => { + if state.current_view == View::Admin { + state.admin_tab = if state.admin_tab == 0 { + 2 + } else { + state.admin_tab - 1 + }; + } + }, + Action::DeleteUser => { + if state.current_view == View::Admin + && state.admin_tab == 0 + && let Some(user) = state.users_list.get(state.users_selected).cloned() + { + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.delete_user(&user.id).await { + Ok(()) => { + if let Ok(users) = client.list_users().await { + let _ = + tx.send(AppEvent::ApiResult(ApiResult::UsersLoaded(users))); + } + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Delete user: {e}" + )))); + }, + } + }); + } + }, + Action::DeleteDevice => { + if state.current_view == View::Admin + && state.admin_tab == 1 + && let Some(device) = + state.sync_devices.get(state.sync_devices_selected).cloned() + { + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.delete_sync_device(&device.id).await { + Ok(()) => { + if let Ok(devices) = client.list_sync_devices().await { + let _ = tx.send(AppEvent::ApiResult( + ApiResult::SyncDevicesLoaded(devices), + )); + } + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Delete device: {e}" + )))); + }, + } + }); + } + }, + Action::TestWebhook => { + if state.current_view == View::Admin + && state.admin_tab == 2 + && let Some(wh) = state.webhooks.get(state.webhooks_selected).cloned() + { + let client = client.clone(); + let tx = event_sender.clone(); + state.status_message = Some(format!("Testing webhook: {}", wh.url)); + tokio::spawn(async move { + match client.test_webhook(&wh.id).await { + Ok(()) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error( + "Webhook test sent".into(), + ))); + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Webhook test: {e}" + )))); + }, + } + }); + } + }, + Action::AddToPlaylist => { + if (state.current_view == View::Library + || state.current_view == View::Search) + && !state.playlists.is_empty() + { + let media = if state.current_view == View::Library { + state + .selected_index + .and_then(|i| state.media_list.get(i)) + .cloned() + } else { + state + .search_selected + .and_then(|i| state.search_results.get(i)) + .cloned() + }; + if let Some(item) = media { + if let Some(pl) = + state.playlists.get(state.playlists_selected).cloned() + { + let client = client.clone(); + let tx = event_sender.clone(); + let pl_id = pl.id.clone(); + let media_id = item.id; + state.status_message = + Some(format!("Adding to playlist \"{}\"...", pl.name)); + tokio::spawn(async move { + match client.add_to_playlist(&pl_id, &media_id).await { + Ok(()) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Added to playlist \"{name}\"", name = pl.name), + ))); + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Add to playlist: {e}"), + ))); + }, + } + }); + } else { + state.status_message = Some( + "No playlist selected; open Playlists view (p) first".into(), + ); + } + } + } + }, Action::NavigateLeft | Action::NavigateRight | Action::None => {}, } } diff --git a/crates/pinakes-tui/src/client.rs b/crates/pinakes-tui/src/client.rs index 3a1de56..59cd8cd 100644 --- a/crates/pinakes-tui/src/client.rs +++ b/crates/pinakes-tui/src/client.rs @@ -186,6 +186,62 @@ pub struct AuthorSummary { pub count: u64, } +#[derive(Debug, Clone, Deserialize)] +pub struct PlaylistResponse { + pub id: String, + pub name: String, + pub description: Option, + pub created_at: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CommentResponse { + pub text: String, + pub created_at: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct TranscodeSessionResponse { + pub id: String, + pub profile: String, + pub status: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SubtitleEntry { + pub language: Option, + pub format: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SubtitleListResponse { + pub subtitles: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DeviceResponse { + pub id: String, + pub name: String, + pub device_type: Option, + pub last_seen: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct WebhookInfo { + #[serde(default)] + pub id: String, + pub url: String, + pub events: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct UserResponse { + pub id: String, + pub username: String, + pub role: String, + pub created_at: String, +} + impl ApiClient { pub fn new(base_url: &str, api_key: Option<&str>) -> Self { let client = api_key.map_or_else(Client::new, |key| { @@ -198,7 +254,13 @@ impl ApiClient { Client::builder() .default_headers(headers) .build() - .unwrap_or_default() + .unwrap_or_else(|e| { + tracing::warn!( + "failed to build authenticated HTTP client: {e}; falling back to \ + unauthenticated client" + ); + Client::new() + }) }); Self { client, @@ -627,4 +689,303 @@ impl ApiClient { .error_for_status()?; Ok(()) } + + pub async fn list_playlists(&self) -> Result> { + let resp = self + .client + .get(self.url("/playlists")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn create_playlist( + &self, + name: &str, + description: Option<&str>, + ) -> Result { + let mut body = serde_json::json!({"name": name}); + if let Some(desc) = description { + body["description"] = serde_json::Value::String(desc.to_string()); + } + let resp = self + .client + .post(self.url("/playlists")) + .json(&body) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn delete_playlist(&self, id: &str) -> Result<()> { + self + .client + .delete(self.url(&format!("/playlists/{id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn get_playlist_items( + &self, + id: &str, + ) -> Result> { + let resp = self + .client + .get(self.url(&format!("/playlists/{id}/items"))) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn remove_from_playlist( + &self, + playlist_id: &str, + media_id: &str, + ) -> Result<()> { + self + .client + .delete(self.url(&format!("/playlists/{playlist_id}/items/{media_id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn shuffle_playlist(&self, id: &str) -> Result<()> { + self + .client + .post(self.url(&format!("/playlists/{id}/shuffle"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn rate_media(&self, media_id: &str, stars: u8) -> Result<()> { + self + .client + .post(self.url(&format!("/media/{media_id}/ratings"))) + .json(&serde_json::json!({"stars": stars})) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn add_comment( + &self, + media_id: &str, + text: &str, + ) -> Result { + let resp = self + .client + .post(self.url(&format!("/media/{media_id}/comments"))) + .json(&serde_json::json!({"text": text})) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn list_comments( + &self, + media_id: &str, + ) -> Result> { + let resp = self + .client + .get(self.url(&format!("/media/{media_id}/comments"))) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn toggle_favorite(&self, media_id: &str) -> Result<()> { + // Try POST to add; if it fails with conflict, DELETE to remove + let post_resp = self + .client + .post(self.url("/favorites")) + .json(&serde_json::json!({"media_id": media_id})) + .send() + .await?; + if post_resp.status() == reqwest::StatusCode::CONFLICT + || post_resp.status() == reqwest::StatusCode::UNPROCESSABLE_ENTITY + { + // Already a favorite: remove it + self + .client + .delete(self.url(&format!("/favorites/{media_id}"))) + .send() + .await? + .error_for_status()?; + } else { + post_resp.error_for_status()?; + } + Ok(()) + } + + pub async fn enrich_media(&self, media_id: &str) -> Result<()> { + self + .client + .post(self.url(&format!("/media/{media_id}/enrich"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn start_transcode( + &self, + media_id: &str, + profile: &str, + ) -> Result { + let resp = self + .client + .post(self.url(&format!("/media/{media_id}/transcode"))) + .json(&serde_json::json!({"profile": profile})) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn list_transcodes(&self) -> Result> { + let resp = self + .client + .get(self.url("/transcode")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn cancel_transcode(&self, id: &str) -> Result<()> { + self + .client + .delete(self.url(&format!("/transcode/{id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn list_subtitles( + &self, + media_id: &str, + ) -> Result { + let resp = self + .client + .get(self.url(&format!("/media/{media_id}/subtitles"))) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn list_sync_devices(&self) -> Result> { + let resp = self + .client + .get(self.url("/sync/devices")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn delete_sync_device(&self, id: &str) -> Result<()> { + self + .client + .delete(self.url(&format!("/sync/devices/{id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn list_webhooks(&self) -> Result> { + let resp = self + .client + .get(self.url("/webhooks")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn test_webhook(&self, id: &str) -> Result<()> { + self + .client + .post(self.url("/webhooks/test")) + .json(&serde_json::json!({"id": id})) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn list_users(&self) -> Result> { + let resp = self + .client + .get(self.url("/users")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn delete_user(&self, id: &str) -> Result<()> { + self + .client + .delete(self.url(&format!("/users/{id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn add_to_playlist( + &self, + playlist_id: &str, + media_id: &str, + ) -> Result<()> { + let body = serde_json::json!({"media_id": media_id}); + let resp = self + .client + .post(self.url(&format!("/playlists/{playlist_id}/items"))) + .json(&body) + .send() + .await?; + if resp.status().is_success() { + Ok(()) + } else { + anyhow::bail!("add to playlist failed: {}", resp.status()) + } + } } diff --git a/crates/pinakes-tui/src/event.rs b/crates/pinakes-tui/src/event.rs index 394bc51..2af2483 100644 --- a/crates/pinakes-tui/src/event.rs +++ b/crates/pinakes-tui/src/event.rs @@ -28,6 +28,23 @@ pub enum ApiResult { BookAuthors(Vec), MediaUpdated, ReadingProgressUpdated, + // Playlists + PlaylistsLoaded(Vec), + PlaylistItemsLoaded(Vec), + // Social + CommentsLoaded(Vec), + RatingSet(u8), + FavoriteToggled, + // Subtitles + SubtitlesLoaded(crate::client::SubtitleListResponse), + // Enrichment / transcode + EnrichmentTriggered, + TranscodeStarted, + // Admin + UsersLoaded(Vec), + SyncDevicesLoaded(Vec), + WebhooksLoaded(Vec), + TranscodesLoaded(Vec), Error(String), } diff --git a/crates/pinakes-tui/src/input.rs b/crates/pinakes-tui/src/input.rs index d7b9d7c..a70c08a 100644 --- a/crates/pinakes-tui/src/input.rs +++ b/crates/pinakes-tui/src/input.rs @@ -53,6 +53,29 @@ pub enum Action { ConfirmBatchDelete, BatchTag, UpdateReadingProgress, + // Playlists + PlaylistsView, + CreatePlaylist, + DeletePlaylist, + RemoveFromPlaylist, + ShufflePlaylist, + AddToPlaylist, + // Social / detail + ToggleFavorite, + RateMedia, + AddComment, + EnrichMedia, + ToggleSubtitles, + // Transcode + ToggleTranscodes, + CancelTranscode, + // Admin + AdminView, + AdminTabNext, + AdminTabPrev, + DeleteUser, + DeleteDevice, + TestWebhook, None, } @@ -98,6 +121,8 @@ pub fn handle_key( (KeyCode::Char('d'), _) => { match current_view { View::Tags | View::Collections => Action::DeleteSelected, + View::Playlists => Action::DeletePlaylist, + View::Admin => Action::DeleteUser, _ => Action::Delete, } }, @@ -111,16 +136,22 @@ pub fn handle_key( (KeyCode::Char('p'), _) => { match current_view { View::Detail => Action::UpdateReadingProgress, - _ => Action::None, + _ => Action::PlaylistsView, } }, (KeyCode::Char('t'), _) => { match current_view { View::Tasks => Action::Toggle, + View::Detail => Action::ToggleTranscodes, _ => Action::TagView, } }, - (KeyCode::Char('c'), _) => Action::CollectionView, + (KeyCode::Char('c'), _) => { + match current_view { + View::Detail => Action::AddComment, + _ => Action::CollectionView, + } + }, // Multi-select: Ctrl+A for SelectAll (must come before plain 'a') (KeyCode::Char('a'), KeyModifiers::CONTROL) => { match current_view { @@ -130,15 +161,22 @@ pub fn handle_key( }, (KeyCode::Char('a'), _) => Action::AuditView, (KeyCode::Char('b'), _) => Action::BooksView, - (KeyCode::Char('S'), _) => Action::SettingsView, + (KeyCode::Char('S'), _) => { + match current_view { + View::Playlists => Action::ShufflePlaylist, + _ => Action::SettingsView, + } + }, (KeyCode::Char('B'), _) => Action::DatabaseView, (KeyCode::Char('Q'), _) => Action::QueueView, (KeyCode::Char('X'), _) => Action::StatisticsView, + (KeyCode::Char('A'), _) => Action::AdminView, // Use plain D/T for views in non-library contexts, keep for batch ops in // library/search (KeyCode::Char('D'), _) => { match current_view { View::Library | View::Search => Action::BatchDelete, + View::Admin => Action::DeleteDevice, _ => Action::DuplicatesView, } }, @@ -157,9 +195,24 @@ pub fn handle_key( }, (KeyCode::Char('s'), _) => Action::ScanTrigger, (KeyCode::Char('r'), _) => Action::Refresh, - (KeyCode::Char('n'), _) => Action::CreateTag, - (KeyCode::Char('+'), _) => Action::TagMedia, - (KeyCode::Char('-'), _) => Action::UntagMedia, + (KeyCode::Char('n'), _) => { + match current_view { + View::Playlists => Action::CreatePlaylist, + _ => Action::CreateTag, + } + }, + (KeyCode::Char('+'), _) => { + match current_view { + View::Library | View::Search => Action::AddToPlaylist, + _ => Action::TagMedia, + } + }, + (KeyCode::Char('-'), _) => { + match current_view { + View::Playlists => Action::RemoveFromPlaylist, + _ => Action::UntagMedia, + } + }, (KeyCode::Char('v'), _) => { match current_view { View::Database => Action::Vacuum, @@ -169,11 +222,52 @@ pub fn handle_key( (KeyCode::Char('x'), _) => { match current_view { View::Tasks => Action::RunNow, + View::Detail => Action::CancelTranscode, _ => Action::None, } }, - (KeyCode::Tab, _) => Action::NextTab, - (KeyCode::BackTab, _) => Action::PrevTab, + (KeyCode::Char('f'), _) => { + match current_view { + View::Detail => Action::ToggleFavorite, + _ => Action::None, + } + }, + (KeyCode::Char('R'), _) => { + match current_view { + View::Detail => Action::RateMedia, + _ => Action::None, + } + }, + (KeyCode::Char('E'), _) => { + match current_view { + View::Detail => Action::EnrichMedia, + _ => Action::None, + } + }, + (KeyCode::Char('U'), _) => { + match current_view { + View::Detail => Action::ToggleSubtitles, + _ => Action::None, + } + }, + (KeyCode::Char('w'), _) => { + match current_view { + View::Admin => Action::TestWebhook, + _ => Action::None, + } + }, + (KeyCode::Tab, _) => { + match current_view { + View::Admin => Action::AdminTabNext, + _ => Action::NextTab, + } + }, + (KeyCode::BackTab, _) => { + match current_view { + View::Admin => Action::AdminTabPrev, + _ => Action::PrevTab, + } + }, (KeyCode::PageUp, _) => Action::PageUp, (KeyCode::PageDown, _) => Action::PageDown, // Multi-select keys diff --git a/crates/pinakes-tui/src/ui/admin.rs b/crates/pinakes-tui/src/ui/admin.rs new file mode 100644 index 0000000..ef07c18 --- /dev/null +++ b/crates/pinakes-tui/src/ui/admin.rs @@ -0,0 +1,174 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Row, Table, Tabs}, +}; + +use super::format_date; +use crate::app::AppState; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0)]) + .split(area); + + render_tab_bar(f, state, chunks[0]); + + match state.admin_tab { + 0 => render_users(f, state, chunks[1]), + 1 => render_devices(f, state, chunks[1]), + _ => render_webhooks(f, state, chunks[1]), + } +} + +fn render_tab_bar(f: &mut Frame, state: &AppState, area: Rect) { + let titles: Vec = vec!["Users", "Sync Devices", "Webhooks"] + .into_iter() + .map(|t| Line::from(Span::styled(t, Style::default().fg(Color::White)))) + .collect(); + + let tabs = Tabs::new(titles) + .block(Block::default().borders(Borders::ALL).title(" Admin ")) + .select(state.admin_tab) + .style(Style::default().fg(Color::Gray)) + .highlight_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ); + + f.render_widget(tabs, area); +} + +fn render_users(f: &mut Frame, state: &AppState, area: Rect) { + let header = Row::new(vec!["Username", "Role", "Created"]).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + + let rows: Vec = state + .users_list + .iter() + .enumerate() + .map(|(i, user)| { + let style = if i == state.users_selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + let role_color = match user.role.as_str() { + "admin" => Color::Red, + "editor" => Color::Yellow, + _ => Color::White, + }; + Style::default().fg(role_color) + }; + Row::new(vec![ + user.username.clone(), + user.role.clone(), + format_date(&user.created_at).to_string(), + ]) + .style(style) + }) + .collect(); + + let title = format!(" Users ({}) ", state.users_list.len()); + + let table = Table::new(rows, [ + Constraint::Percentage(40), + Constraint::Percentage(20), + Constraint::Percentage(40), + ]) + .header(header) + .block(Block::default().borders(Borders::ALL).title(title)); + + f.render_widget(table, area); +} + +fn render_devices(f: &mut Frame, state: &AppState, area: Rect) { + let header = Row::new(vec!["Name", "Type", "Last Seen"]).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + + let rows: Vec = state + .sync_devices + .iter() + .enumerate() + .map(|(i, dev)| { + let style = if i == state.sync_devices_selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default() + }; + Row::new(vec![ + dev.name.clone(), + dev.device_type.clone().unwrap_or_else(|| "-".into()), + dev + .last_seen + .as_deref() + .map_or("-", format_date) + .to_string(), + ]) + .style(style) + }) + .collect(); + + let title = format!(" Sync Devices ({}) ", state.sync_devices.len()); + + let table = Table::new(rows, [ + Constraint::Percentage(40), + Constraint::Percentage(20), + Constraint::Percentage(40), + ]) + .header(header) + .block(Block::default().borders(Borders::ALL).title(title)); + + f.render_widget(table, area); +} + +fn render_webhooks(f: &mut Frame, state: &AppState, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0)]) + .split(area); + + let header = Row::new(vec!["URL", "Events"]).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + + let rows: Vec = state + .webhooks + .iter() + .enumerate() + .map(|(i, wh)| { + let style = if i == state.webhooks_selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default() + }; + let events = if wh.events.is_empty() { + "-".to_string() + } else { + wh.events.join(", ") + }; + Row::new(vec![wh.url.clone(), events]).style(style) + }) + .collect(); + + let title = format!(" Webhooks ({}) ", state.webhooks.len()); + + let table = Table::new(rows, [ + Constraint::Percentage(50), + Constraint::Percentage(50), + ]) + .header(header) + .block(Block::default().borders(Borders::ALL).title(title)); + + f.render_widget(table, chunks[0]); +} diff --git a/crates/pinakes-tui/src/ui/detail.rs b/crates/pinakes-tui/src/ui/detail.rs index 9788ca8..400bdbd 100644 --- a/crates/pinakes-tui/src/ui/detail.rs +++ b/crates/pinakes-tui/src/ui/detail.rs @@ -252,6 +252,113 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) { } } + // Social section: rating, favorite, comments + { + let has_social = state.media_rating.is_some() + || state.is_favorite + || !state.media_comments.is_empty(); + if has_social { + lines.push(Line::default()); + lines.push(Line::from(Span::styled( + "--- Social ---", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))); + } + if state.is_favorite { + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Favorite"), label_style), + Span::styled("Yes", Style::default().fg(Color::Yellow)), + ])); + } + if let Some(stars) = state.media_rating { + let stars_str = "*".repeat(stars as usize); + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Rating"), label_style), + Span::styled(format!("{stars_str} ({stars}/5)"), value_style), + ])); + } + if !state.media_comments.is_empty() { + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled( + format!("Comments ({})", state.media_comments.len()), + label_style, + ), + ])); + for comment in state.media_comments.iter().take(5) { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("[{}] {}", format_date(&comment.created_at), comment.text), + dim_style, + ), + ])); + } + if state.media_comments.len() > 5 { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("... and {} more", state.media_comments.len() - 5), + dim_style, + ), + ])); + } + } + } + + // Subtitles section + if state.showing_subtitles { + lines.push(Line::default()); + lines.push(Line::from(Span::styled( + "--- Subtitles ---", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))); + if state.subtitles.is_empty() { + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled("No subtitles found", dim_style), + ])); + } else { + for sub in &state.subtitles { + let lang = sub.language.as_deref().unwrap_or("?"); + let fmt = sub.format.as_deref().unwrap_or("?"); + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(format!("[{lang}] {fmt}"), value_style), + ])); + } + } + } + + // Transcodes section + if state.showing_transcodes && !state.transcodes.is_empty() { + lines.push(Line::default()); + lines.push(Line::from(Span::styled( + "--- Transcodes ---", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))); + for tc in &state.transcodes { + let status_color = match tc.status.as_str() { + "done" | "completed" => Color::Green, + "failed" | "error" => Color::Red, + _ => Color::Yellow, + }; + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(format!("[{}] ", tc.profile), label_style), + Span::styled(&tc.status, Style::default().fg(status_color)), + ])); + } + } + // Reading progress section if let Some(ref progress) = state.reading_progress { lines.push(Line::default()); diff --git a/crates/pinakes-tui/src/ui/mod.rs b/crates/pinakes-tui/src/ui/mod.rs index 01b3f18..b3cf733 100644 --- a/crates/pinakes-tui/src/ui/mod.rs +++ b/crates/pinakes-tui/src/ui/mod.rs @@ -1,3 +1,4 @@ +pub mod admin; pub mod audit; pub mod books; pub mod collections; @@ -7,6 +8,7 @@ pub mod duplicates; pub mod import; pub mod library; pub mod metadata_edit; +pub mod playlists; pub mod queue; pub mod search; pub mod settings; @@ -109,6 +111,8 @@ pub fn render(f: &mut Frame, state: &AppState) { View::Statistics => statistics::render(f, state, chunks[1]), View::Tasks => tasks::render(f, state, chunks[1]), View::Books => books::render(f, state, chunks[1]), + View::Playlists => playlists::render(f, state, chunks[1]), + View::Admin => admin::render(f, state, chunks[1]), } render_status_bar(f, state, chunks[2]); @@ -125,6 +129,8 @@ fn render_tabs(f: &mut Frame, state: &AppState, area: Rect) { "Queue", "Stats", "Tasks", + "Playlists", + "Admin", ] .into_iter() .map(|t| Line::from(Span::styled(t, Style::default().fg(Color::White)))) @@ -144,6 +150,8 @@ fn render_tabs(f: &mut Frame, state: &AppState, area: Rect) { View::Queue => 6, View::Statistics => 7, View::Tasks => 8, + View::Playlists => 9, + View::Admin => 10, }; let tabs = Tabs::new(titles) @@ -177,10 +185,17 @@ fn render_status_bar(f: &mut Frame, state: &AppState, area: Rect) { .to_string() }, View::Detail => { - " q:Quit Esc:Back o:Open e:Edit p:Page +:Tag -:Untag \ - r:Refresh ?:Help" + " q:Quit Esc:Back o:Open e:Edit p:Page +:Tag -:Untag f:Fav \ + R:Rate c:Comment E:Enrich U:Subtitles t:Transcode r:Refresh" .to_string() }, + View::Playlists => { + " q:Quit j/k:Nav n:New d:Delete Enter:Items S:Shuffle Esc:Back" + .to_string() + }, + View::Admin => " q:Quit j/k:Nav Tab:Switch tab d:Del user/device \ + w:Test webhook r:Refresh Esc:Back" + .to_string(), View::Import => { " Enter:Import Esc:Cancel s:Scan libraries ?:Help".to_string() }, diff --git a/crates/pinakes-tui/src/ui/playlists.rs b/crates/pinakes-tui/src/ui/playlists.rs new file mode 100644 index 0000000..105d5f8 --- /dev/null +++ b/crates/pinakes-tui/src/ui/playlists.rs @@ -0,0 +1,117 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Row, Table}, +}; + +use super::format_date; +use crate::app::AppState; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + if state.viewing_playlist_items { + render_items(f, state, area); + } else { + render_list(f, state, area); + } +} + +fn render_list(f: &mut Frame, state: &AppState, area: Rect) { + let header = Row::new(vec!["Name", "Description", "Created"]).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + + let rows: Vec = state + .playlists + .iter() + .enumerate() + .map(|(i, pl)| { + let style = if i == state.playlists_selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default() + }; + Row::new(vec![ + pl.name.clone(), + pl.description.clone().unwrap_or_else(|| "-".into()), + format_date(&pl.created_at).to_string(), + ]) + .style(style) + }) + .collect(); + + let title = format!(" Playlists ({}) ", state.playlists.len()); + + let table = Table::new(rows, [ + Constraint::Percentage(40), + Constraint::Percentage(40), + Constraint::Percentage(20), + ]) + .header(header) + .block(Block::default().borders(Borders::ALL).title(title)); + + f.render_widget(table, area); +} + +fn render_items(f: &mut Frame, state: &AppState, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(2), Constraint::Min(0)]) + .split(area); + + let pl_name = state + .playlists + .get(state.playlists_selected) + .map_or("Playlist", |p| p.name.as_str()); + + let hint = Paragraph::new(Line::from(vec![ + Span::styled( + format!(" {pl_name} "), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::styled("Esc:Back d:Remove", Style::default().fg(Color::DarkGray)), + ])); + f.render_widget(hint, chunks[0]); + + let header = Row::new(vec!["File", "Type", "Title"]).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + + let rows: Vec = state + .playlist_items + .iter() + .enumerate() + .map(|(i, item)| { + let style = if i == state.playlist_items_selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default() + }; + Row::new(vec![ + item.file_name.clone(), + item.media_type.clone(), + item.title.clone().unwrap_or_else(|| "-".into()), + ]) + .style(style) + }) + .collect(); + + let title = format!(" Items ({}) ", state.playlist_items.len()); + + let table = Table::new(rows, [ + Constraint::Percentage(40), + Constraint::Percentage(20), + Constraint::Percentage(40), + ]) + .header(header) + .block(Block::default().borders(Borders::ALL).title(title)); + + f.render_widget(table, chunks[1]); +} -- 2.43.0 From 0feb51d7b4cb58ab36463d38172245f3376e0534 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Mar 2026 02:23:30 +0300 Subject: [PATCH 20/30] pinakes-ui: add playlists; expand detail/settings/player components Signed-off-by: NotAShelf Change-Id: Ifb9c9da6fec0a9152b54ccf48705088e6a6a6964 --- crates/pinakes-ui/src/components/detail.rs | 550 ++++++++++++++++++ .../pinakes-ui/src/components/media_player.rs | 88 ++- crates/pinakes-ui/src/components/mod.rs | 1 + crates/pinakes-ui/src/components/playlists.rs | 411 +++++++++++++ crates/pinakes-ui/src/components/settings.rs | 467 ++++++++++++++- 5 files changed, 1510 insertions(+), 7 deletions(-) create mode 100644 crates/pinakes-ui/src/components/playlists.rs diff --git a/crates/pinakes-ui/src/components/detail.rs b/crates/pinakes-ui/src/components/detail.rs index 3bd7003..2e7474f 100644 --- a/crates/pinakes-ui/src/components/detail.rs +++ b/crates/pinakes-ui/src/components/detail.rs @@ -11,10 +11,14 @@ use super::{ use crate::client::{ ApiClient, BookMetadataResponse, + CommentResponse, MediaResponse, MediaUpdateEvent, + RatingResponse, ReadingProgressResponse, + SubtitleListResponse, TagResponse, + TranscodeSessionResponse, }; #[component] @@ -48,6 +52,8 @@ pub fn Detail( #[props(default)] on_update_reading_progress: Option< EventHandler<(String, i32)>, >, + #[props(default)] subtitle_data: Option, + #[props(default)] transcode_sessions: Option>, ) -> Element { let mut editing = use_signal(|| false); let mut show_image_viewer = use_signal(|| false); @@ -66,6 +72,36 @@ pub fn Detail( let mut confirm_delete = use_signal(|| false); + // Enrichment state + let mut enriching = use_signal(|| false); + let mut enrich_done = use_signal(|| false); + let mut enrich_error: Signal> = use_signal(|| None); + let mut show_ext_meta = use_signal(|| false); + let mut ext_meta: Signal> = use_signal(|| None); + + // Subtitle state + let mut subtitle_list: Signal> = + use_signal(|| subtitle_data.clone()); + let mut subtitle_error: Signal> = use_signal(|| None); + + // Transcode state + let mut transcode_list: Signal> = + use_signal(|| transcode_sessions.clone().unwrap_or_default()); + let mut transcode_profile = use_signal(String::new); + // Counter tracks how many start-transcode API calls are in-flight so that + // each profile's button state is independent of others. + let mut transcode_running = use_signal(|| 0u32); + let mut transcode_error: Signal> = use_signal(|| None); + + // Social state + let mut ratings: Signal> = use_signal(Vec::new); + let mut comments: Signal> = use_signal(Vec::new); + let mut is_favorite = use_signal(|| false); + let mut social_loaded = use_signal(|| false); + let mut new_comment_text = use_signal(String::new); + let mut selected_stars = use_signal(|| 0u8); + let mut social_error: Signal> = use_signal(|| None); + let id = media.id.clone(); let title = media.title.clone().unwrap_or_default(); let artist = media.artist.clone().unwrap_or_default(); @@ -971,6 +1007,520 @@ pub fn Detail( } } + // Social section: ratings, comments, favorites + { + let social_id = id.clone(); + let client_social = client.clone(); + rsx! { + div { class: "card mb-16", + div { class: "card-header", + h4 { class: "card-title", "Social" } + button { + class: "btn btn-ghost btn-sm", + onclick: { + let sid = social_id.clone(); + let cs = client_social.clone(); + move |_| { + let sid = sid.clone(); + let cs = cs.clone(); + spawn(async move { + let mut errors: Vec = Vec::new(); + match cs.get_ratings(&sid).await { + Ok(r) => ratings.set(r), + Err(e) => errors.push(format!("ratings: {e}")), + } + match cs.list_comments(&sid).await { + Ok(c) => comments.set(c), + Err(e) => errors.push(format!("comments: {e}")), + } + match cs.list_favorites().await { + Ok(favs) => is_favorite.set(favs.iter().any(|m| m.id == sid)), + Err(e) => errors.push(format!("favorites: {e}")), + } + social_loaded.set(true); + if errors.is_empty() { + social_error.set(None); + } else { + social_error.set(Some(errors.join("; "))); + } + }); + } + }, + "Load" + } + } + if *social_loaded.read() { + div { class: "card-body", + if let Some(ref err) = *social_error.read() { + div { class: "error-banner mb-8", "{err}" } + } + // Favorites + div { class: "form-row mb-8", + if *is_favorite.read() { + button { + class: "btn btn-secondary btn-sm", + onclick: { + let sid = social_id.clone(); + let cs = client_social.clone(); + move |_| { + let sid = sid.clone(); + let cs = cs.clone(); + spawn(async move { + match cs.remove_favorite(&sid).await { + Ok(_) => is_favorite.set(false), + Err(e) => social_error.set(Some(format!("Failed: {e}"))), + } + }); + } + }, + "\u{2665} Unfavorite" + } + } else { + button { + class: "btn btn-primary btn-sm", + onclick: { + let sid = social_id.clone(); + let cs = client_social.clone(); + move |_| { + let sid = sid.clone(); + let cs = cs.clone(); + spawn(async move { + match cs.add_favorite(&sid).await { + Ok(_) => is_favorite.set(true), + Err(e) => social_error.set(Some(format!("Failed: {e}"))), + } + }); + } + }, + "\u{2661} Favorite" + } + } + } + // Rating + div { class: "form-row mb-8", + span { class: "detail-label", "Rate: " } + for star in 1u8..=5u8 { + { + let cs = client_social.clone(); + let sid = social_id.clone(); + rsx! { + button { + key: "{star}", + class: if *selected_stars.read() >= star { "btn btn-sm star-btn active" } else { "btn btn-sm star-btn" }, + onclick: move |_| { + let cs = cs.clone(); + let sid = sid.clone(); + selected_stars.set(star); + spawn(async move { + if let Err(e) = cs.rate_media(&sid, star).await { + social_error.set(Some(format!("Rating failed: {e}"))); + } else if let Ok(r) = cs.get_ratings(&sid).await { + ratings.set(r); + } + }); + }, + "\u{2605}" + } + } + } + } + span { class: "text-muted", "({ratings.read().len()} ratings)" } + } + // Comments + div { class: "mb-8", + h5 { "Comments" } + if comments.read().is_empty() { + p { class: "text-muted text-sm", "No comments." } + } else { + div { class: "comments-list", + for comment in comments.read().iter() { + div { class: "comment-item", key: "{comment.id}", + span { class: "comment-text", "{comment.text}" } + if let Some(ref t) = comment.created_at { + span { class: "text-muted text-sm", " - {t}" } + } + } + } + } + } + div { class: "form-row mt-8", + input { + r#type: "text", + placeholder: "Add a comment...", + value: "{new_comment_text}", + oninput: move |e| new_comment_text.set(e.value()), + } + button { + class: "btn btn-primary btn-sm", + onclick: { + let sid = social_id.clone(); + let cs = client_social.clone(); + move |_| { + let text = new_comment_text.read().clone(); + if text.is_empty() { + return; + } + let sid = sid.clone(); + let cs = cs.clone(); + spawn(async move { + match cs.add_comment(&sid, &text).await { + Ok(c) => { + comments.write().push(c); + new_comment_text.set(String::new()); + } + Err(e) => social_error.set(Some(format!("Comment failed: {e}"))), + } + }); + } + }, + "Post" + } + } + } + } + } + } + } + } + + // Enrichment section (all media types) + { + let enrich_id = id.clone(); + let client_enrich = client.clone(); + rsx! { + div { class: "card mb-16", + div { class: "card-header", + h4 { class: "card-title", "Metadata Enrichment" } + } + div { class: "card-body", + if let Some(ref err) = *enrich_error.read() { + div { class: "error-banner mb-8", "{err}" } + } + div { class: "form-row", + button { + class: "btn btn-primary btn-sm", + disabled: *enriching.read(), + onclick: { + let eid = enrich_id.clone(); + let ce = client_enrich.clone(); + move |_| { + let eid = eid.clone(); + let ce = ce.clone(); + spawn(async move { + enriching.set(true); + enrich_done.set(false); + enrich_error.set(None); + match ce.enrich_media(&eid).await { + Ok(_) => enrich_done.set(true), + Err(e) => enrich_error.set(Some(format!("Enrichment failed: {e}"))), + } + enriching.set(false); + }); + } + }, + if *enriching.read() { "Enriching..." } else { "Enrich Metadata" } + } + if *enrich_done.read() { + span { class: "badge badge-success", "Enrichment complete" } + } + button { + class: "btn btn-ghost btn-sm", + onclick: { + let eid = enrich_id.clone(); + let ce = client_enrich.clone(); + move |_| { + let eid = eid.clone(); + let ce = ce.clone(); + show_ext_meta.toggle(); + if *show_ext_meta.read() && ext_meta.read().is_none() { + spawn(async move { + match ce.get_external_metadata(&eid).await { + Ok(v) => ext_meta.set(Some(v)), + Err(e) => enrich_error.set(Some(format!("Metadata fetch failed: {e}"))), + } + }); + } + } + }, + if *show_ext_meta.read() { "Hide External Metadata" } else { "Show External Metadata" } + } + } + if *show_ext_meta.read() { + if let Some(ref meta) = *ext_meta.read() { + pre { class: "mono text-sm", "{meta}" } + } else { + p { class: "text-muted", "Loading external metadata..." } + } + } + } + } + } + } + + // Subtitles section (video and audio only) + if category == "video" || category == "audio" { + { + let sub_id = id.clone(); + let client_sub = client.clone(); + rsx! { + div { class: "card mb-16", + div { class: "card-header", + h4 { class: "card-title", "Subtitles" } + button { + class: "btn btn-ghost btn-sm", + onclick: { + let sid = sub_id.clone(); + let cs = client_sub.clone(); + move |_| { + let sid = sid.clone(); + let cs = cs.clone(); + spawn(async move { + match cs.list_subtitles_for_media(&sid).await { + Ok(data) => { + subtitle_error.set(None); + subtitle_list.set(Some(data)); + } + Err(e) => subtitle_error.set(Some(format!("Failed to load subtitles: {e}"))), + } + }); + } + }, + "Refresh" + } + } + if let Some(ref err) = *subtitle_error.read() { + div { class: "error-banner mb-8", "{err}" } + } + if let Some(ref subs) = *subtitle_list.read() { + if !subs.available_tracks.is_empty() { + div { class: "mb-8", + h5 { "Embedded Tracks" } + table { class: "data-table", + thead { + tr { + th { "Index" } + th { "Language" } + th { "Format" } + th { "Title" } + } + } + tbody { + for track in subs.available_tracks.iter() { + tr { key: "{track.index}", + td { "{track.index}" } + td { "{track.language.clone().unwrap_or_default()}" } + td { "{track.format}" } + td { "{track.title.clone().unwrap_or_default()}" } + } + } + } + } + } + } + if subs.subtitles.is_empty() { + p { class: "text-muted", "No external subtitles." } + } else { + table { class: "data-table", + thead { + tr { + th { "Language" } + th { "Format" } + th { "Embedded" } + th { "Offset (ms)" } + th { "" } + } + } + tbody { + for sub in subs.subtitles.iter() { + { + let sub_entry_id = sub.id.clone(); + let cs2 = client_sub.clone(); + let sid2 = sub_id.clone(); + rsx! { + tr { key: "{sub_entry_id}", + td { "{sub.language.clone().unwrap_or_default()}" } + td { "{sub.format}" } + td { if sub.is_embedded { "Yes" } else { "No" } } + td { "{sub.offset_ms}" } + td { + button { + class: "btn btn-danger btn-sm", + onclick: move |_| { + let eid = sub_entry_id.clone(); + let cs2 = cs2.clone(); + let sid2 = sid2.clone(); + spawn(async move { + match cs2.delete_subtitle(&eid).await { + Ok(()) => { + match cs2.list_subtitles_for_media(&sid2).await { + Ok(data) => { + subtitle_error.set(None); + subtitle_list.set(Some(data)); + } + Err(e) => subtitle_error.set(Some(format!("Failed to refresh subtitles: {e}"))), + } + } + Err(e) => subtitle_error.set(Some(format!("Failed to delete subtitle: {e}"))), + } + }); + }, + "Delete" + } + } + } + } + } + } + } + } + } + } else { + p { class: "text-muted", + "Click Refresh to load subtitle information." + } + } + } + } + } + } + + // Transcode section (video and audio only) + if category == "video" || category == "audio" { + { + let tc_id = id.clone(); + let client_tc = client.clone(); + rsx! { + div { class: "card mb-16", + div { class: "card-header", + h4 { class: "card-title", "Transcoding" } + button { + class: "btn btn-ghost btn-sm", + onclick: { + let cs = client_tc.clone(); + let tid = tc_id.clone(); + move |_| { + let cs = cs.clone(); + let tid = tid.clone(); + spawn(async move { + if let Ok(all) = cs.list_transcodes().await { + let filtered: Vec<_> = all + .into_iter() + .filter(|s| s.media_id == tid) + .collect(); + transcode_list.set(filtered); + } + }); + } + }, + "Refresh" + } + } + if let Some(ref err) = *transcode_error.read() { + div { class: "error-banner mb-8", "{err}" } + } + div { class: "form-row mb-8", + input { + r#type: "text", + placeholder: "Profile (e.g. web-720p)...", + value: "{transcode_profile}", + oninput: move |e| transcode_profile.set(e.value()), + } + button { + class: "btn btn-primary btn-sm", + disabled: *transcode_running.read() > 0, + onclick: { + let tid = tc_id.clone(); + let ct = client_tc.clone(); + move |_| { + let profile = transcode_profile.read().clone(); + if profile.is_empty() { + return; + } + let tid = tid.clone(); + let ct = ct.clone(); + spawn(async move { + *transcode_running.write() += 1; + transcode_error.set(None); + match ct.start_transcode(&tid, &profile).await { + Ok(session) => { + transcode_list.write().push(session); + transcode_profile.set(String::new()); + } + Err(e) => transcode_error.set(Some(format!("Transcode failed: {e}"))), + } + let prev = *transcode_running.read(); + *transcode_running.write() = prev.saturating_sub(1); + }); + } + }, + if *transcode_running.read() > 0 { "Starting..." } else { "Start Transcode" } + } + } + if transcode_list.read().is_empty() { + p { class: "text-muted", "No transcode sessions for this item." } + } else { + table { class: "data-table", + thead { + tr { + th { "Profile" } + th { "Status" } + th { "Progress" } + th { "" } + } + } + tbody { + for session in transcode_list.read().clone() { + { + let sess_id = session.id.clone(); + let ct2 = client_tc.clone(); + let is_active = session.status == "running" || session.status == "pending"; + let progress = session + .progress + .map(|p| format!("{:.0}%", p)) + .unwrap_or_default(); + rsx! { + tr { key: "{sess_id}", + td { "{session.profile}" } + td { + span { + class: if is_active { "badge badge-warning" } else { "badge badge-neutral" }, + "{session.status}" + } + } + td { "{progress}" } + td { + if is_active { + button { + class: "btn btn-danger btn-sm", + onclick: move |_| { + let sid = sess_id.clone(); + let ct2 = ct2.clone(); + spawn(async move { + match ct2.cancel_transcode(&sid).await { + Ok(()) => { + transcode_error.set(None); + transcode_list.write().retain(|s| s.id != sid); + } + Err(e) => transcode_error.set(Some(format!("Cancel failed: {e}"))), + } + }); + }, + "Cancel" + } + } + } + } + } + } + } + } + } + } + } + } + } + } + // Image viewer overlay if *show_image_viewer.read() { ImageViewer { diff --git a/crates/pinakes-ui/src/components/media_player.rs b/crates/pinakes-ui/src/components/media_player.rs index 73d0e01..5c84fca 100644 --- a/crates/pinakes-ui/src/components/media_player.rs +++ b/crates/pinakes-ui/src/components/media_player.rs @@ -1,4 +1,7 @@ -use dioxus::{document::eval, prelude::*}; +use dioxus::{ + document::{Script, eval}, + prelude::*, +}; use super::utils::format_duration; @@ -123,6 +126,43 @@ impl PlayQueue { } } +/// Generate JavaScript to initialize hls.js for an HLS stream URL. +/// +/// Destroys any previously created instance stored at `window.__hlsInstances` +/// keyed by element ID before creating a new one, preventing memory leaks and +/// multiple instances competing for the same video element across re-renders. +fn hls_init_script(video_id: &str, hls_url: &str) -> String { + // JSON-encode both values so embedded quotes/backslashes cannot break out of + // the JS string. + let encoded_url = + serde_json::to_string(hls_url).unwrap_or_else(|_| "\"\"".to_string()); + let encoded_id = + serde_json::to_string(video_id).unwrap_or_else(|_| "\"\"".to_string()); + format!( + r#" + (function() {{ + window.__hlsInstances = window.__hlsInstances || {{}}; + var existing = window.__hlsInstances[{encoded_id}]; + if (existing) {{ + existing.destroy(); + window.__hlsInstances[{encoded_id}] = null; + }} + if (typeof Hls !== 'undefined' && Hls.isSupported()) {{ + var hls = new Hls(); + hls.loadSource({encoded_url}); + hls.attachMedia(document.getElementById({encoded_id})); + window.__hlsInstances[{encoded_id}] = hls; + }} else {{ + var video = document.getElementById({encoded_id}); + if (video && video.canPlayType('application/vnd.apple.mpegurl')) {{ + video.src = {encoded_url}; + }} + }} + }})(); + "# + ) +} + #[component] pub fn MediaPlayer( src: String, @@ -200,6 +240,35 @@ pub fn MediaPlayer( }); }); + // HLS initialization for .m3u8 streams. + // use_effect must be called unconditionally to maintain stable hook ordering. + let is_hls = src.ends_with(".m3u8"); + let hls_src = src.clone(); + use_effect(move || { + if !hls_src.ends_with(".m3u8") { + return; + } + let js = hls_init_script("pinakes-player", &hls_src); + spawn(async move { + // Poll until hls.js is loaded rather than using a fixed delay, so we + // initialize as soon as the script is ready without timing out on slow + // connections. Max wait: 25 * 100ms = 2.5s. + const MAX_POLLS: u32 = 25; + for _ in 0..MAX_POLLS { + if let Ok(val) = eval("typeof Hls !== 'undefined'").await { + if val == serde_json::Value::Bool(true) { + let _ = eval(&js).await; + return; + } + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + tracing::warn!( + "hls.js did not load within 2.5s; HLS stream will not play" + ); + }); + }); + // Autoplay on mount if autoplay { let src_auto = src.clone(); @@ -367,24 +436,31 @@ pub fn MediaPlayer( let mute_icon = if is_muted { "\u{1f507}" } else { "\u{1f50a}" }; rsx! { + // Load hls.js for HLS stream support when needed + if is_hls { + // Pin to a specific release so unexpected upstream changes cannot + // break playback. Update this when intentionally upgrading hls.js. + Script { src: "https://cdn.jsdelivr.net/npm/hls.js@1.5.15/dist/hls.min.js" } + } + div { class: if is_video { "media-player media-player-video" } else { "media-player media-player-audio" }, tabindex: "0", onkeydown: on_keydown, - // Hidden native element + // Hidden native element; for HLS streams skip the src attr (hls.js attaches it) if is_video { video { id: "pinakes-player", - src: "{src}", - style: if is_video { "width: 100%; display: block;" } else { "display: none;" }, + class: "player-native-video", + src: if is_hls { String::new() } else { src.clone() }, preload: "metadata", } } else { audio { id: "pinakes-player", - src: "{src}", - style: "display: none;", + class: "player-native-audio", + src: if is_hls { String::new() } else { src.clone() }, preload: "metadata", } } diff --git a/crates/pinakes-ui/src/components/mod.rs b/crates/pinakes-ui/src/components/mod.rs index 605a528..57b5730 100644 --- a/crates/pinakes-ui/src/components/mod.rs +++ b/crates/pinakes-ui/src/components/mod.rs @@ -16,6 +16,7 @@ pub mod markdown_viewer; pub mod media_player; pub mod pagination; pub mod pdf_viewer; +pub mod playlists; pub mod search; pub mod settings; pub mod statistics; diff --git a/crates/pinakes-ui/src/components/playlists.rs b/crates/pinakes-ui/src/components/playlists.rs new file mode 100644 index 0000000..c821d6d --- /dev/null +++ b/crates/pinakes-ui/src/components/playlists.rs @@ -0,0 +1,411 @@ +use dioxus::prelude::*; + +use super::utils::{format_size, type_badge_class}; +use crate::client::{ApiClient, MediaResponse, PlaylistResponse}; + +#[component] +pub fn Playlists(client: Signal) -> Element { + let mut playlists: Signal> = use_signal(Vec::new); + let mut items: Signal> = use_signal(Vec::new); + let mut selected_id: Signal> = use_signal(|| None); + let mut loading = use_signal(|| false); + let mut error: Signal> = use_signal(|| None); + + let mut new_name = use_signal(String::new); + let mut new_desc = use_signal(String::new); + let mut confirm_delete: Signal> = use_signal(|| None); + let mut renaming_id: Signal> = use_signal(|| None); + let mut rename_input = use_signal(String::new); + let mut add_media_id = use_signal(String::new); + + // Load playlists on mount + use_effect(move || { + spawn(async move { + loading.set(true); + match client.read().list_playlists().await { + Ok(list) => playlists.set(list), + Err(e) => error.set(Some(format!("Failed to load playlists: {e}"))), + } + loading.set(false); + }); + }); + + let load_items = move |pid: String| { + spawn(async move { + match client.read().get_playlist_items(&pid).await { + Ok(list) => items.set(list), + Err(e) => error.set(Some(format!("Failed to load items: {e}"))), + } + }); + }; + + let on_create = move |_| { + let name = new_name.read().clone(); + if name.is_empty() { + return; + } + let desc = { + let d = new_desc.read().clone(); + if d.is_empty() { None } else { Some(d) } + }; + spawn(async move { + match client.read().create_playlist(&name, desc.as_deref()).await { + Ok(pl) => { + playlists.write().push(pl); + new_name.set(String::new()); + new_desc.set(String::new()); + }, + Err(e) => error.set(Some(format!("Failed to create playlist: {e}"))), + } + }); + }; + + let on_shuffle = move |pid: String| { + spawn(async move { + match client.read().shuffle_playlist(&pid).await { + Ok(_) => { + // Reload items if this is the selected playlist + if selected_id.read().as_deref() == Some(&pid) { + match client.read().get_playlist_items(&pid).await { + Ok(list) => items.set(list), + Err(e) => error.set(Some(format!("Failed to reload: {e}"))), + } + } + }, + Err(e) => error.set(Some(format!("Shuffle failed: {e}"))), + } + }); + }; + + // Detail view: show items for selected playlist + if let Some(ref pid) = selected_id.read().clone() { + let pl_name = playlists + .read() + .iter() + .find(|p| &p.id == pid) + .map(|p| p.name.clone()) + .unwrap_or_else(|| pid.clone()); + + let pid_for_shuffle = pid.clone(); + let pid_for_back = pid.clone(); + + return rsx! { + div { class: "form-row mb-16", + button { + class: "btn btn-ghost", + onclick: move |_| { + let _ = &pid_for_back; + selected_id.set(None); + items.set(Vec::new()); + }, + "\u{2190} Back to Playlists" + } + } + + h3 { class: "mb-16", "{pl_name}" } + + if let Some(ref err) = *error.read() { + div { class: "error-banner", "{err}" } + } + + div { class: "form-row mb-16", + button { + class: "btn btn-secondary btn-sm", + onclick: move |_| on_shuffle(pid_for_shuffle.clone()), + "\u{1f500} Shuffle" + } + } + + div { class: "form-row mb-16", + input { + r#type: "text", + placeholder: "Media ID to add...", + value: "{add_media_id}", + oninput: move |e| add_media_id.set(e.value()), + } + button { + class: "btn btn-primary btn-sm", + onclick: { + let pid = pid.clone(); + move |_| { + let mid = add_media_id.read().clone(); + if mid.is_empty() { + return; + } + let pid = pid.clone(); + spawn(async move { + match client.read().add_to_playlist(&pid, &mid).await { + Ok(_) => { + add_media_id.set(String::new()); + match client.read().get_playlist_items(&pid).await { + Ok(list) => items.set(list), + Err(e) => error.set(Some(format!("Reload failed: {e}"))), + } + } + Err(e) => error.set(Some(format!("Add failed: {e}"))), + } + }); + } + }, + "Add Media" + } + } + + if *loading.read() { + div { class: "loading-overlay", + div { class: "spinner" } + "Loading..." + } + } else if items.read().is_empty() { + div { class: "empty-state", + p { class: "empty-subtitle", "No items in this playlist." } + } + } else { + table { class: "data-table", + thead { + tr { + th { "Name" } + th { "Type" } + th { "Artist" } + th { "Size" } + th { "" } + } + } + tbody { + for item in items.read().clone() { + { + let pid_rm = pid.clone(); + let mid = item.id.clone(); + let artist = item.artist.clone().unwrap_or_default(); + let size = format_size(item.file_size); + let badge_class = type_badge_class(&item.media_type); + rsx! { + tr { key: "{mid}", + td { "{item.file_name}" } + td { + span { class: "type-badge {badge_class}", "{item.media_type}" } + } + td { "{artist}" } + td { "{size}" } + td { + button { + class: "btn btn-danger btn-sm", + onclick: move |_| { + let pid_rm = pid_rm.clone(); + let mid = mid.clone(); + spawn(async move { + match client.read().remove_from_playlist(&pid_rm, &mid).await { + Ok(_) => { + match client.read().get_playlist_items(&pid_rm).await { + Ok(list) => items.set(list), + Err(e) => error.set(Some(format!("Reload failed: {e}"))), + } + } + Err(e) => error.set(Some(format!("Remove failed: {e}"))), + } + }); + }, + "Remove" + } + } + } + } + } + } + } + } + } + }; + } + + // List view + rsx! { + div { class: "card", + div { class: "card-header", + h3 { class: "card-title", "Playlists" } + } + + if let Some(ref err) = *error.read() { + div { class: "error-banner mb-16", "{err}" } + } + + div { class: "form-row mb-16", + input { + r#type: "text", + placeholder: "Playlist name...", + value: "{new_name}", + oninput: move |e| new_name.set(e.value()), + } + input { + r#type: "text", + placeholder: "Description (optional)...", + value: "{new_desc}", + oninput: move |e| new_desc.set(e.value()), + } + button { class: "btn btn-primary", onclick: on_create, "Create" } + } + + if *loading.read() { + div { class: "loading-overlay", + div { class: "spinner" } + "Loading..." + } + } else if playlists.read().is_empty() { + div { class: "empty-state", + p { class: "empty-subtitle", "No playlists yet. Create one above." } + } + } else { + table { class: "data-table", + thead { + tr { + th { "Name" } + th { "Description" } + th { "Items" } + th { "" } + th { "" } + th { "" } + } + } + tbody { + for pl in playlists.read().clone() { + { + let pl_id = pl.id.clone(); + let pl_id_open = pl.id.clone(); + let pl_id_rename = pl.id.clone(); + let desc = pl.description.clone().unwrap_or_default(); + let count = pl.item_count.map(|c| c.to_string()).unwrap_or_default(); + let is_confirming = confirm_delete + .read() + .as_ref() + .map(|id| id == &pl.id) + .unwrap_or(false); + let is_renaming = renaming_id + .read() + .as_ref() + .map(|id| id == &pl.id) + .unwrap_or(false); + rsx! { + tr { key: "{pl_id}", + td { + if is_renaming { + div { class: "form-row", + input { + r#type: "text", + value: "{rename_input}", + oninput: move |e| rename_input.set(e.value()), + } + button { + class: "btn btn-primary btn-sm", + onclick: { + let rid = pl_id_rename.clone(); + move |_| { + let rid = rid.clone(); + let new_name_val = rename_input.read().clone(); + if new_name_val.is_empty() { + return; + } + spawn(async move { + match client.read().update_playlist(&rid, &new_name_val).await { + Ok(updated) => { + let mut list = playlists.write(); + if let Some(p) = list.iter_mut().find(|p| p.id == rid) { + *p = updated; + } + renaming_id.set(None); + } + Err(e) => error.set(Some(format!("Rename failed: {e}"))), + } + }); + } + }, + "Save" + } + button { + class: "btn btn-ghost btn-sm", + onclick: move |_| renaming_id.set(None), + "Cancel" + } + } + } else { + "{pl.name}" + } + } + td { "{desc}" } + td { "{count}" } + td { + button { + class: "btn btn-sm btn-secondary", + onclick: move |_| { + let pid = pl_id_open.clone(); + selected_id.set(Some(pid.clone())); + load_items(pid); + }, + "Open" + } + } + td { + if !is_renaming { + button { + class: "btn btn-sm btn-ghost", + onclick: { + let rid = pl_id.clone(); + let rname = pl.name.clone(); + move |_| { + rename_input.set(rname.clone()); + renaming_id.set(Some(rid.clone())); + } + }, + "Rename" + } + } + } + td { + if is_confirming { + button { + class: "btn btn-danger btn-sm", + onclick: { + let id = pl_id.clone(); + move |_| { + let id = id.clone(); + spawn(async move { + match client.read().delete_playlist(&id).await { + Ok(_) => { + playlists.write().retain(|p| p.id != id); + confirm_delete.set(None); + } + Err(e) => { + error.set(Some(format!("Delete failed: {e}"))); + confirm_delete.set(None); + } + } + }); + } + }, + "Confirm" + } + button { + class: "btn btn-ghost btn-sm", + onclick: move |_| confirm_delete.set(None), + "Cancel" + } + } else { + button { + class: "btn btn-danger btn-sm", + onclick: { + let id = pl_id.clone(); + move |_| confirm_delete.set(Some(id.clone())) + }, + "Delete" + } + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/crates/pinakes-ui/src/components/settings.rs b/crates/pinakes-ui/src/components/settings.rs index 6666270..c780141 100644 --- a/crates/pinakes-ui/src/components/settings.rs +++ b/crates/pinakes-ui/src/components/settings.rs @@ -1,6 +1,6 @@ use dioxus::prelude::*; -use crate::client::ConfigResponse; +use crate::client::{ApiClient, ConfigResponse}; #[component] pub fn Settings( @@ -13,6 +13,8 @@ pub fn Settings( #[props(default)] on_update_ui_config: Option< EventHandler, >, + #[props(default)] client: Option, + #[props(default)] current_user_role: Option, ) -> Element { let mut new_root = use_signal(String::new); let mut editing_poll = use_signal(|| false); @@ -21,13 +23,475 @@ pub fn Settings( let mut editing_patterns = use_signal(|| false); let mut patterns_input = use_signal(String::new); + // Tab state: "general", "users", "sync", "webhooks" + let mut active_tab = use_signal(|| "general".to_string()); + + // Users tab state + let mut users_list = use_signal(Vec::::new); + let mut users_loaded = use_signal(|| false); + let mut users_loading = use_signal(|| false); + let mut users_error: Signal> = use_signal(|| None); + let mut new_username = use_signal(String::new); + let mut new_password = use_signal(String::new); + let mut new_role = use_signal(|| "viewer".to_string()); + let mut confirm_delete_user: Signal> = use_signal(|| None); + + // Sync devices tab state + let mut devices_list = use_signal(Vec::::new); + let mut devices_loaded = use_signal(|| false); + let mut devices_loading = use_signal(|| false); + let mut devices_error: Signal> = use_signal(|| None); + let mut confirm_delete_device: Signal> = use_signal(|| None); + + // Webhooks tab state + let mut webhooks_list = + use_signal(Vec::::new); + let mut webhooks_loaded = use_signal(|| false); + let mut webhooks_loading = use_signal(|| false); + let mut webhooks_error: Signal> = use_signal(|| None); + let writable = config.config_writable; let watch_enabled = config.scanning.watch; let host_port = format!("{}:{}", config.server.host, config.server.port); let db_path = config.database_path.clone().unwrap_or_default(); let root_count = config.roots.len(); + let is_admin = current_user_role + .as_deref() + .map(|r| r == "admin") + .unwrap_or(false); + rsx! { + // Tab bar + div { class: "tab-bar mb-16", + button { + class: if *active_tab.read() == "general" { "tab-btn active" } else { "tab-btn" }, + onclick: move |_| active_tab.set("general".to_string()), + "General" + } + if is_admin { + button { + class: if *active_tab.read() == "users" { "tab-btn active" } else { "tab-btn" }, + onclick: { + let c = client.clone(); + move |_| { + active_tab.set("users".to_string()); + if !*users_loaded.read() { + if let Some(ref api) = c { + let api = api.clone(); + spawn(async move { + users_loading.set(true); + match api.list_users().await { + Ok(list) => { users_list.set(list); users_loaded.set(true); } + Err(e) => users_error.set(Some(format!("Failed to load users: {e}"))), + } + users_loading.set(false); + }); + } + } + } + }, + "Users" + } + } + if is_admin { + button { + class: if *active_tab.read() == "sync" { "tab-btn active" } else { "tab-btn" }, + onclick: { + let c = client.clone(); + move |_| { + active_tab.set("sync".to_string()); + if !*devices_loaded.read() && !*devices_loading.read() { + if let Some(ref api) = c { + let api = api.clone(); + devices_loading.set(true); + spawn(async move { + match api.list_sync_devices().await { + Ok(list) => { devices_list.set(list); devices_loaded.set(true); } + Err(e) => devices_error.set(Some(format!("Failed to load devices: {e}"))), + } + devices_loading.set(false); + }); + } + } + } + }, + "Sync Devices" + } + } + if is_admin { + button { + class: if *active_tab.read() == "webhooks" { "tab-btn active" } else { "tab-btn" }, + onclick: { + let c = client.clone(); + move |_| { + active_tab.set("webhooks".to_string()); + if !*webhooks_loaded.read() && !*webhooks_loading.read() { + if let Some(ref api) = c { + let api = api.clone(); + webhooks_loading.set(true); + spawn(async move { + match api.list_webhooks().await { + Ok(list) => { webhooks_list.set(list); webhooks_loaded.set(true); } + Err(e) => webhooks_error.set(Some(format!("Failed to load webhooks: {e}"))), + } + webhooks_loading.set(false); + }); + } + } + } + }, + "Webhooks" + } + } + } + + if *active_tab.read() == "users" { + div { class: "settings-layout", + if let Some(ref err) = *users_error.read() { + div { class: "error-banner mb-16", "{err}" } + } + if is_admin { + div { class: "settings-card", + div { class: "settings-card-header", + h3 { class: "settings-card-title", "Create User" } + } + div { class: "settings-card-body", + div { class: "form-row mb-8", + input { + r#type: "text", + placeholder: "Username...", + value: "{new_username}", + oninput: move |e| new_username.set(e.value()), + } + input { + r#type: "password", + placeholder: "Password...", + value: "{new_password}", + oninput: move |e| new_password.set(e.value()), + } + select { + value: "{new_role}", + onchange: move |e| new_role.set(e.value()), + option { value: "viewer", "Viewer" } + option { value: "editor", "Editor" } + option { value: "admin", "Admin" } + } + button { + class: "btn btn-primary btn-sm", + onclick: { + let c = client.clone(); + move |_| { + let username = new_username.read().clone(); + let password = new_password.read().clone(); + let role = new_role.read().clone(); + if username.is_empty() || password.is_empty() { + users_error.set(Some("Username and password are required.".to_string())); + return; + } + users_error.set(None); + if let Some(ref api) = c { + let api = api.clone(); + spawn(async move { + match api.create_user(&username, &password, &role).await { + Ok(u) => { + users_list.write().push(u); + new_username.set(String::new()); + new_password.set(String::new()); + } + Err(e) => users_error.set(Some(format!("Create failed: {e}"))), + } + }); + } + } + }, + "Create" + } + } + } + } + } + div { class: "settings-card", + div { class: "settings-card-header", + h3 { class: "settings-card-title", "Users" } + } + div { class: "settings-card-body", + if *users_loading.read() { + div { class: "spinner" } + } else if users_list.read().is_empty() { + p { class: "text-muted", "No users." } + } else { + table { class: "data-table", + thead { + tr { + th { "Username" } + th { "Role" } + th { "Created" } + if is_admin { + th { "Change Role" } + th { "" } + } + } + } + tbody { + for user in users_list.read().clone() { + { + let uid = user.id.clone(); + let created = user.created_at.clone().unwrap_or_default(); + let current_role = user.role.clone(); + let is_confirming = confirm_delete_user + .read() + .as_ref() + .map(|id| id == &user.id) + .unwrap_or(false); + rsx! { + tr { key: "{uid}", + td { "{user.username}" } + td { + span { class: "role-badge role-{user.role}", "{user.role}" } + } + td { "{created}" } + if is_admin { + td { + select { + value: "{current_role}", + onchange: { + let id = uid.clone(); + let c = client.clone(); + move |e: Event| { + let new_role = e.value(); + let id = id.clone(); + if let Some(ref api) = c { + let api = api.clone(); + spawn(async move { + match api.update_user(&id, &new_role).await { + Ok(updated) => { + users_error.set(None); + let mut list = users_list.write(); + if let Some(u) = list.iter_mut().find(|u| u.id == id) { + *u = updated; + } + } + Err(e) => users_error.set(Some(format!("Failed to update role: {e}"))), + } + }); + } + } + }, + option { value: "viewer", "Viewer" } + option { value: "editor", "Editor" } + option { value: "admin", "Admin" } + } + } + td { + if is_confirming { + button { + class: "btn btn-danger btn-sm", + onclick: { + let id = uid.clone(); + let c = client.clone(); + move |_| { + let id = id.clone(); + if let Some(ref api) = c { + let api = api.clone(); + spawn(async move { + if api.delete_user(&id).await.is_ok() { + users_list.write().retain(|u| u.id != id); + confirm_delete_user.set(None); + } + }); + } + } + }, + "Confirm" + } + button { + class: "btn btn-ghost btn-sm", + onclick: move |_| confirm_delete_user.set(None), + "Cancel" + } + } else { + button { + class: "btn btn-danger btn-sm", + onclick: { + let id = uid.clone(); + move |_| confirm_delete_user.set(Some(id.clone())) + }, + "Delete" + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } else if *active_tab.read() == "sync" { + div { class: "settings-layout", + if let Some(ref err) = *devices_error.read() { + div { class: "error-banner mb-16", "{err}" } + } + div { class: "settings-card", + div { class: "settings-card-header", + h3 { class: "settings-card-title", "Sync Devices" } + } + div { class: "settings-card-body", + if *devices_loading.read() { + div { class: "spinner" } + } else if devices_list.read().is_empty() { + p { class: "text-muted", "No sync devices registered." } + } else { + table { class: "data-table", + thead { + tr { + th { "Name" } + th { "Type" } + th { "Last Seen" } + th { "" } + } + } + tbody { + for device in devices_list.read().clone() { + { + let did = device.id.clone(); + let last_seen = device.last_seen.clone().unwrap_or_default(); + let is_confirming = confirm_delete_device + .read() + .as_ref() + .map(|id| id == &device.id) + .unwrap_or(false); + rsx! { + tr { key: "{did}", + td { "{device.name}" } + td { "{device.device_type}" } + td { "{last_seen}" } + td { + if is_confirming { + button { + class: "btn btn-danger btn-sm", + onclick: { + let id = did.clone(); + let c = client.clone(); + move |_| { + let id = id.clone(); + if let Some(ref api) = c { + let api = api.clone(); + spawn(async move { + match api.delete_sync_device(&id).await { + Ok(()) => { + devices_error.set(None); + devices_list.write().retain(|d| d.id != id); + confirm_delete_device.set(None); + } + Err(e) => { + devices_error.set(Some(format!("Failed to delete device: {e}"))); + confirm_delete_device.set(None); + } + } + }); + } + } + }, + "Confirm" + } + button { + class: "btn btn-ghost btn-sm", + onclick: move |_| confirm_delete_device.set(None), + "Cancel" + } + } else { + button { + class: "btn btn-danger btn-sm", + onclick: { + let id = did.clone(); + move |_| confirm_delete_device.set(Some(id.clone())) + }, + "Delete" + } + } + } + } + } + } + } + } + } + } + } + } + } + } else if *active_tab.read() == "webhooks" { + div { class: "settings-layout", + if let Some(ref err) = *webhooks_error.read() { + div { class: "error-banner mb-16", "{err}" } + } + div { class: "settings-card", + div { class: "settings-card-header", + h3 { class: "settings-card-title", "Webhooks" } + } + div { class: "settings-card-body", + if *webhooks_loading.read() { + div { class: "spinner" } + } else if webhooks_list.read().is_empty() { + p { class: "text-muted", "No webhooks configured." } + } else { + table { class: "data-table", + thead { + tr { + th { "URL" } + th { "Events" } + th { "" } + } + } + tbody { + for wh in webhooks_list.read().clone() { + { + let wid = wh.id.clone(); + let events = wh.events.join(", "); + rsx! { + tr { key: "{wid}", + td { class: "mono", "{wh.url}" } + td { "{events}" } + td { + button { + class: "btn btn-secondary btn-sm", + onclick: { + let id = wid.clone(); + let c = client.clone(); + move |_| { + let id = id.clone(); + if let Some(ref api) = c { + let api = api.clone(); + spawn(async move { + match api.test_webhook(&id).await { + Ok(_) => webhooks_error.set(None), + Err(e) => webhooks_error.set(Some(format!("Test failed: {e}"))), + } + }); + } + } + }, + "Test" + } + } + } + } + } + } + } + } + } + } + } + } + } else { + // General tab (original content) div { class: "settings-layout", // Configuration source @@ -567,5 +1031,6 @@ pub fn Settings( } } } + } // end else (general tab) } } -- 2.43.0 From 67019cad4c47135e3e5e3c92706eb67c238eebf0 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Mar 2026 13:23:31 +0300 Subject: [PATCH 21/30] pinakes-ui: add rustdoc to ApiClient types and methods Signed-off-by: NotAShelf Change-Id: I4b25ba66e695a870a753bdc6276c113d6a6a6964 --- crates/pinakes-ui/src/client.rs | 686 ++++++++++++++++++++++++++++++-- 1 file changed, 645 insertions(+), 41 deletions(-) diff --git a/crates/pinakes-ui/src/client.rs b/crates/pinakes-ui/src/client.rs index a82283a..356faec 100644 --- a/crates/pinakes-ui/src/client.rs +++ b/crates/pinakes-ui/src/client.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; /// Payload for import events: (path, tag_ids, new_tags, collection_id) pub type ImportEvent = (String, Vec, Vec, Option); -/// Payload for media update events +/// Fields that can be updated on a media item. #[derive(Debug, Clone, PartialEq)] pub struct MediaUpdateEvent { pub id: String, @@ -18,6 +18,7 @@ pub struct MediaUpdateEvent { pub description: Option, } +/// HTTP client for the Pinakes server API. pub struct ApiClient { client: Client, base_url: String, @@ -46,8 +47,7 @@ impl PartialEq for ApiClient { } } -// Response types - +/// A media item returned by the API. #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct MediaResponse { pub id: String, @@ -72,18 +72,21 @@ pub struct MediaResponse { pub links_extracted_at: Option, } +/// A single custom metadata field on a media item. #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct CustomFieldResponse { pub field_type: String, pub value: String, } +/// Result of a single file import. #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct ImportResponse { pub media_id: String, pub was_duplicate: bool, } +/// Summary of a batch import operation. #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct BatchImportResponse { pub results: Vec, @@ -93,6 +96,7 @@ pub struct BatchImportResponse { pub errors: usize, } +/// Per-file result within a batch import. #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct BatchImportItemResult { pub path: String, @@ -101,6 +105,7 @@ pub struct BatchImportItemResult { pub error: Option, } +/// Preview of files in a directory before import. #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct DirectoryPreviewResponse { pub files: Vec, @@ -108,6 +113,7 @@ pub struct DirectoryPreviewResponse { pub total_size: u64, } +/// A single file entry in a directory preview. #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct DirectoryPreviewFile { pub path: String, @@ -116,12 +122,14 @@ pub struct DirectoryPreviewFile { pub file_size: u64, } +/// A group of media items sharing the same content hash. #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct DuplicateGroupResponse { pub content_hash: String, pub items: Vec, } +/// A tag returned by the API. #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct TagResponse { pub id: String, @@ -130,6 +138,7 @@ pub struct TagResponse { pub created_at: String, } +/// A collection returned by the API. #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct CollectionResponse { pub id: String, @@ -141,12 +150,14 @@ pub struct CollectionResponse { pub updated_at: String, } +/// Paginated search results. #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct SearchResponse { pub items: Vec, pub total_count: u64, } +/// A single audit log entry. #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct AuditEntryResponse { pub id: String, @@ -156,6 +167,7 @@ pub struct AuditEntryResponse { pub timestamp: String, } +/// Full server configuration. #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct ConfigResponse { pub backend: String, @@ -169,6 +181,7 @@ pub struct ConfigResponse { pub config_writable: bool, } +/// UI-specific configuration. #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Default)] pub struct UiConfigResponse { #[serde(default = "default_theme")] @@ -203,6 +216,7 @@ fn default_true() -> bool { true } +/// Response returned after a successful login. #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct LoginResponse { pub token: String, @@ -210,12 +224,14 @@ pub struct LoginResponse { pub role: String, } +/// Basic identity information for the authenticated user. #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct UserInfoResponse { pub username: String, pub role: String, } +/// Scanning configuration section. #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct ScanningConfigResponse { pub watch: bool, @@ -223,12 +239,14 @@ pub struct ScanningConfigResponse { pub ignore_patterns: Vec, } +/// Server bind address configuration. #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct ServerConfigResponse { pub host: String, pub port: u16, } +/// Summary of a completed scan. #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct ScanResponse { pub files_found: usize, @@ -236,6 +254,7 @@ pub struct ScanResponse { pub errors: Vec, } +/// Live status of an ongoing or recent scan. #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct ScanStatusResponse { pub scanning: bool, @@ -245,12 +264,14 @@ pub struct ScanStatusResponse { pub errors: Vec, } +/// Result of a batch operation on media items. #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct BatchOperationResponse { pub processed: usize, pub errors: Vec, } +/// Aggregate library statistics. #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct LibraryStatisticsResponse { pub total_media: u64, @@ -267,12 +288,14 @@ pub struct LibraryStatisticsResponse { pub total_duplicates: u64, } +/// A named count entry used in statistics breakdowns. #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct TypeCountResponse { pub name: String, pub count: u64, } +/// A scheduled background task. #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct ScheduledTaskResponse { pub id: String, @@ -284,6 +307,7 @@ pub struct ScheduledTaskResponse { pub last_status: Option, } +/// Low-level database statistics. #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct DatabaseStatsResponse { pub media_count: u64, @@ -294,14 +318,14 @@ pub struct DatabaseStatsResponse { pub backend_name: String, } -// Markdown notes/links response types - +/// Incoming backlinks for a markdown note. #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct BacklinksResponse { pub backlinks: Vec, pub count: usize, } +/// A single incoming link to a note. #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct BacklinkItem { pub link_id: String, @@ -314,12 +338,14 @@ pub struct BacklinkItem { pub link_type: String, } +/// Outgoing links from a markdown note. #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct OutgoingLinksResponse { pub links: Vec, pub count: usize, } +/// A single outgoing link from a note. #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct OutgoingLinkItem { pub id: String, @@ -331,6 +357,7 @@ pub struct OutgoingLinkItem { pub is_resolved: bool, } +/// Note graph data for visualization. #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct GraphResponse { pub nodes: Vec, @@ -339,6 +366,7 @@ pub struct GraphResponse { pub edge_count: usize, } +/// A node in the note link graph. #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct GraphNodeResponse { pub id: String, @@ -349,6 +377,7 @@ pub struct GraphNodeResponse { pub backlink_count: u32, } +/// A directed edge in the note link graph. #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct GraphEdgeResponse { pub source: String, @@ -356,12 +385,14 @@ pub struct GraphEdgeResponse { pub link_type: String, } +/// Result of a link re-extraction operation. #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct ReindexLinksResponse { pub message: String, pub links_extracted: usize, } +/// A saved search query. #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct SavedSearchResponse { pub id: String, @@ -371,6 +402,7 @@ pub struct SavedSearchResponse { pub created_at: chrono::DateTime, } +/// Request body for creating a saved search. #[derive(Debug, Clone, Serialize)] pub struct CreateSavedSearchRequest { pub name: String, @@ -378,8 +410,7 @@ pub struct CreateSavedSearchRequest { pub sort_order: Option, } -// Book management response types - +/// Book-specific metadata for a media item. #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct BookMetadataResponse { pub media_id: String, @@ -397,6 +428,7 @@ pub struct BookMetadataResponse { pub identifiers: FxHashMap>, } +/// An author associated with a book. #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct BookAuthorResponse { pub name: String, @@ -405,6 +437,7 @@ pub struct BookAuthorResponse { pub position: i32, } +/// Reading progress for a book. #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct ReadingProgressResponse { pub media_id: String, @@ -415,19 +448,111 @@ pub struct ReadingProgressResponse { pub last_read_at: String, } +/// A book series with its total book count. #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct SeriesSummary { pub name: String, pub book_count: u64, } +/// An author with their total book count. #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct AuthorSummary { pub name: String, pub book_count: u64, } +/// A playlist. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct PlaylistResponse { + pub id: String, + pub name: String, + pub description: Option, + pub item_count: Option, +} + +/// An active or completed transcode session. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct TranscodeSessionResponse { + pub id: String, + pub media_id: String, + pub profile: String, + pub status: String, + pub progress: Option, +} + +/// A subtitle file or stream attached to a media item. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct SubtitleEntry { + pub id: String, + pub language: Option, + pub format: String, + pub is_embedded: bool, + pub offset_ms: i64, +} + +/// A subtitle track discovered in a media container. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct SubtitleTrackInfo { + pub index: u32, + pub language: Option, + pub format: String, + pub title: Option, +} + +/// All subtitle information for a media item. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct SubtitleListResponse { + pub subtitles: Vec, + pub available_tracks: Vec, +} + +/// A star rating on a media item. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct RatingResponse { + pub media_id: String, + pub stars: u8, + pub user_id: Option, +} + +/// A user comment on a media item. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct CommentResponse { + pub id: String, + pub media_id: String, + pub text: String, + pub created_at: Option, +} + +/// A registered sync device. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct DeviceResponse { + pub id: String, + pub name: String, + pub device_type: String, + pub last_seen: Option, +} + +/// A registered webhook endpoint. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct WebhookResponse { + pub id: String, + pub url: String, + pub events: Vec, +} + +/// A user account. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct UserResponse { + pub id: String, + pub username: String, + pub role: String, + pub created_at: Option, +} + impl ApiClient { + /// Create a new client targeting `base_url`, optionally authenticating with + /// a bearer token. pub fn new(base_url: &str, api_key: Option<&str>) -> Self { let mut headers = header::HeaderMap::new(); if let Some(key) = api_key @@ -445,6 +570,7 @@ impl ApiClient { } } + /// Return the base URL without a trailing slash. pub fn base_url(&self) -> &str { &self.base_url } @@ -453,6 +579,7 @@ impl ApiClient { format!("{}/api/v1{}", self.base_url, path) } + /// Return `true` if the server responds to a health check within 3 seconds. pub async fn health_check(&self) -> bool { match self .client @@ -466,8 +593,7 @@ impl ApiClient { } } - // Media - + /// List media items with optional sorting. pub async fn list_media( &self, offset: u64, @@ -492,6 +618,7 @@ impl ApiClient { ) } + /// Fetch a single media item by ID. pub async fn get_media(&self, id: &str) -> Result { Ok( self @@ -505,6 +632,7 @@ impl ApiClient { ) } + /// Apply metadata updates to a media item. pub async fn update_media( &self, event: &MediaUpdateEvent, @@ -542,6 +670,7 @@ impl ApiClient { ) } + /// Delete a media item by ID. pub async fn delete_media(&self, id: &str) -> Result<()> { self .client @@ -552,6 +681,7 @@ impl ApiClient { Ok(()) } + /// Ask the server to open a media item in the system default application. pub async fn open_media(&self, id: &str) -> Result<()> { self .client @@ -562,14 +692,17 @@ impl ApiClient { Ok(()) } + /// Return the streaming URL for a media item. pub fn stream_url(&self, id: &str) -> String { self.url(&format!("/media/{id}/stream")) } + /// Return the thumbnail URL for a media item. pub fn thumbnail_url(&self, id: &str) -> String { self.url(&format!("/media/{id}/thumbnail")) } + /// Return the total number of media items in the library. pub async fn get_media_count(&self) -> Result { #[derive(Deserialize)] struct CountResp { @@ -586,8 +719,7 @@ impl ApiClient { Ok(resp.count) } - // Import - + /// Import a single file into the library. pub async fn import_file(&self, path: &str) -> Result { Ok( self @@ -602,6 +734,7 @@ impl ApiClient { ) } + /// Import a file with tags and an optional collection assignment. pub async fn import_with_options( &self, path: &str, @@ -632,6 +765,8 @@ impl ApiClient { ) } + /// Import all files in a directory, optionally assigning tags and a + /// collection. pub async fn import_directory( &self, path: &str, @@ -662,6 +797,7 @@ impl ApiClient { ) } + /// List files in a directory without importing them. pub async fn preview_directory( &self, path: &str, @@ -680,8 +816,7 @@ impl ApiClient { ) } - // Search - + /// Run a full-text search query. pub async fn search( &self, query: &str, @@ -710,8 +845,7 @@ impl ApiClient { ) } - // Tags - + /// List all tags. pub async fn list_tags(&self) -> Result> { Ok( self @@ -725,6 +859,7 @@ impl ApiClient { ) } + /// Create a tag, optionally nested under a parent. pub async fn create_tag( &self, name: &str, @@ -747,6 +882,7 @@ impl ApiClient { ) } + /// Delete a tag by ID. pub async fn delete_tag(&self, id: &str) -> Result<()> { self .client @@ -757,6 +893,7 @@ impl ApiClient { Ok(()) } + /// Apply a tag to a media item. pub async fn tag_media(&self, media_id: &str, tag_id: &str) -> Result<()> { self .client @@ -768,6 +905,7 @@ impl ApiClient { Ok(()) } + /// Remove a tag from a media item. pub async fn untag_media(&self, media_id: &str, tag_id: &str) -> Result<()> { self .client @@ -778,6 +916,7 @@ impl ApiClient { Ok(()) } + /// List all tags applied to a media item. pub async fn get_media_tags( &self, media_id: &str, @@ -794,8 +933,7 @@ impl ApiClient { ) } - // Custom fields - + /// Set a custom metadata field on a media item. pub async fn set_custom_field( &self, media_id: &str, @@ -812,6 +950,7 @@ impl ApiClient { Ok(()) } + /// Delete a custom metadata field from a media item. pub async fn delete_custom_field( &self, media_id: &str, @@ -826,8 +965,7 @@ impl ApiClient { Ok(()) } - // Collections - + /// List all collections. pub async fn list_collections(&self) -> Result> { Ok( self @@ -841,6 +979,7 @@ impl ApiClient { ) } + /// Create a collection. pub async fn create_collection( &self, name: &str, @@ -868,6 +1007,7 @@ impl ApiClient { ) } + /// Delete a collection by ID. pub async fn delete_collection(&self, id: &str) -> Result<()> { self .client @@ -878,6 +1018,7 @@ impl ApiClient { Ok(()) } + /// List media items that are members of a collection. pub async fn get_collection_members( &self, id: &str, @@ -894,6 +1035,7 @@ impl ApiClient { ) } + /// Add a media item to a collection at the given position. pub async fn add_to_collection( &self, collection_id: &str, @@ -910,6 +1052,7 @@ impl ApiClient { Ok(()) } + /// Remove a media item from a collection. pub async fn remove_from_collection( &self, collection_id: &str, @@ -926,8 +1069,7 @@ impl ApiClient { Ok(()) } - // Batch operations - + /// Apply tags to multiple media items in one request. pub async fn batch_tag( &self, media_ids: &[String], @@ -946,6 +1088,7 @@ impl ApiClient { ) } + /// Delete multiple media items in one request. pub async fn batch_delete( &self, media_ids: &[String], @@ -963,6 +1106,7 @@ impl ApiClient { ) } + /// Delete every media item in the library. pub async fn delete_all_media(&self) -> Result { Ok( self @@ -976,6 +1120,7 @@ impl ApiClient { ) } + /// Add multiple media items to a collection in one request. pub async fn batch_add_to_collection( &self, media_ids: &[String], @@ -992,8 +1137,7 @@ impl ApiClient { .await?) } - // Audit - + /// List audit log entries. pub async fn list_audit( &self, offset: u64, @@ -1012,8 +1156,7 @@ impl ApiClient { ) } - // Scan - + /// Trigger a full library scan. pub async fn trigger_scan(&self) -> Result> { Ok( self @@ -1028,6 +1171,7 @@ impl ApiClient { ) } + /// Return the current scan status. pub async fn scan_status(&self) -> Result { Ok( self @@ -1041,8 +1185,7 @@ impl ApiClient { ) } - // Config - + /// Fetch the full server configuration. pub async fn get_config(&self) -> Result { Ok( self @@ -1056,6 +1199,7 @@ impl ApiClient { ) } + /// Update scanning settings. pub async fn update_scanning( &self, watch: Option, @@ -1085,6 +1229,7 @@ impl ApiClient { ) } + /// Add a root directory to the library. pub async fn add_root(&self, path: &str) -> Result { Ok( self @@ -1099,6 +1244,7 @@ impl ApiClient { ) } + /// Remove a root directory from the library. pub async fn remove_root(&self, path: &str) -> Result { Ok( self @@ -1113,8 +1259,7 @@ impl ApiClient { ) } - // Database management - + /// Fetch database statistics. pub async fn database_stats(&self) -> Result { Ok( self @@ -1128,6 +1273,7 @@ impl ApiClient { ) } + /// Run a VACUUM on the database. pub async fn vacuum_database(&self) -> Result<()> { self .client @@ -1139,6 +1285,7 @@ impl ApiClient { Ok(()) } + /// Clear all data from the database. pub async fn clear_database(&self) -> Result<()> { self .client @@ -1164,8 +1311,7 @@ impl ApiClient { Ok(()) } - // Books - + /// Fetch book-specific metadata for a media item. pub async fn get_book_metadata( &self, media_id: &str, @@ -1182,6 +1328,7 @@ impl ApiClient { ) } + /// List books, optionally filtered by author or series. pub async fn list_books( &self, offset: u64, @@ -1208,6 +1355,7 @@ impl ApiClient { ) } + /// List all series with their book counts. pub async fn list_series(&self) -> Result> { Ok( self @@ -1221,6 +1369,7 @@ impl ApiClient { ) } + /// List books in a series ordered by series index. pub async fn get_series_books( &self, series_name: &str, @@ -1240,6 +1389,7 @@ impl ApiClient { ) } + /// List all authors with their book counts. pub async fn list_authors(&self) -> Result> { Ok( self @@ -1253,6 +1403,7 @@ impl ApiClient { ) } + /// List books by a specific author. pub async fn get_author_books( &self, author_name: &str, @@ -1272,6 +1423,7 @@ impl ApiClient { ) } + /// Fetch reading progress for a book. pub async fn get_reading_progress( &self, media_id: &str, @@ -1288,6 +1440,7 @@ impl ApiClient { ) } + /// Update the current page for a book. pub async fn update_reading_progress( &self, media_id: &str, @@ -1303,6 +1456,7 @@ impl ApiClient { Ok(()) } + /// Fetch the user's reading list, optionally filtered by status. pub async fn get_reading_list( &self, status: Option<&str>, @@ -1323,8 +1477,7 @@ impl ApiClient { ) } - // Duplicates - + /// List all duplicate groups. pub async fn list_duplicates(&self) -> Result> { Ok( self @@ -1338,8 +1491,7 @@ impl ApiClient { ) } - // UI config - + /// Persist UI configuration updates. pub async fn update_ui_config( &self, updates: serde_json::Value, @@ -1357,8 +1509,7 @@ impl ApiClient { ) } - // Auth - + /// Authenticate with the server and obtain a JWT token. pub async fn login( &self, username: &str, @@ -1377,6 +1528,7 @@ impl ApiClient { ) } + /// Invalidate the current session on the server. pub async fn logout(&self) -> Result<()> { self .client @@ -1387,6 +1539,7 @@ impl ApiClient { Ok(()) } + /// Return identity information for the authenticated user. pub async fn get_current_user(&self) -> Result { Ok( self @@ -1400,6 +1553,7 @@ impl ApiClient { ) } + /// Fetch aggregate library statistics. pub async fn library_statistics(&self) -> Result { Ok( self @@ -1413,6 +1567,7 @@ impl ApiClient { ) } + /// List all scheduled background tasks. pub async fn list_scheduled_tasks( &self, ) -> Result> { @@ -1428,6 +1583,7 @@ impl ApiClient { ) } + /// Toggle a scheduled task on or off. pub async fn toggle_scheduled_task( &self, id: &str, @@ -1444,6 +1600,7 @@ impl ApiClient { ) } + /// Execute a scheduled task immediately. pub async fn run_scheduled_task_now( &self, id: &str, @@ -1460,8 +1617,7 @@ impl ApiClient { ) } - // Saved searches - + /// List all saved searches. pub async fn list_saved_searches(&self) -> Result> { Ok( self @@ -1475,6 +1631,7 @@ impl ApiClient { ) } + /// Create a new saved search. pub async fn create_saved_search( &self, name: &str, @@ -1499,6 +1656,7 @@ impl ApiClient { ) } + /// Delete a saved search by ID. pub async fn delete_saved_search(&self, id: &str) -> Result<()> { self .client @@ -1509,8 +1667,6 @@ impl ApiClient { Ok(()) } - // Markdown notes/links - /// Get backlinks (incoming links) to a media item. pub async fn get_backlinks(&self, id: &str) -> Result { Ok( @@ -1603,6 +1759,7 @@ impl ApiClient { Ok(resp.count) } + /// Replace the bearer token used for subsequent requests. pub fn set_token(&mut self, token: &str) { let mut headers = header::HeaderMap::new(); if let Ok(val) = header::HeaderValue::from_str(&format!("Bearer {token}")) { @@ -1713,6 +1870,453 @@ impl ApiClient { Ok(()) } + /// List all playlists. + pub async fn list_playlists(&self) -> Result> { + Ok( + self + .client + .get(self.url("/playlists")) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + /// Create a new playlist. + pub async fn create_playlist( + &self, + name: &str, + description: Option<&str>, + ) -> Result { + let mut body = serde_json::json!({ "name": name }); + if let Some(desc) = description { + body["description"] = serde_json::Value::String(desc.to_string()); + } + Ok( + self + .client + .post(self.url("/playlists")) + .json(&body) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + /// Update a playlist's name. + pub async fn update_playlist( + &self, + id: &str, + name: &str, + ) -> Result { + Ok( + self + .client + .patch(self.url(&format!("/playlists/{id}"))) + .json(&serde_json::json!({ "name": name })) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + /// Delete a playlist. + pub async fn delete_playlist(&self, id: &str) -> Result<()> { + self + .client + .delete(self.url(&format!("/playlists/{id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + /// Get items in a playlist. + pub async fn get_playlist_items( + &self, + id: &str, + ) -> Result> { + Ok( + self + .client + .get(self.url(&format!("/playlists/{id}/items"))) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + /// Add a media item to a playlist. + pub async fn add_to_playlist( + &self, + playlist_id: &str, + media_id: &str, + ) -> Result<()> { + self + .client + .post(self.url(&format!("/playlists/{playlist_id}/items"))) + .json(&serde_json::json!({ "media_id": media_id })) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + /// Remove a media item from a playlist. + pub async fn remove_from_playlist( + &self, + playlist_id: &str, + media_id: &str, + ) -> Result<()> { + self + .client + .delete(self.url(&format!("/playlists/{playlist_id}/items/{media_id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + /// Shuffle a playlist. + pub async fn shuffle_playlist(&self, id: &str) -> Result<()> { + self + .client + .post(self.url(&format!("/playlists/{id}/shuffle"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + /// Trigger metadata enrichment for a media item. + pub async fn enrich_media(&self, media_id: &str) -> Result<()> { + self + .client + .post(self.url(&format!("/media/{media_id}/enrich"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + /// Get external metadata for a media item. + pub async fn get_external_metadata( + &self, + media_id: &str, + ) -> Result { + Ok( + self + .client + .get(self.url(&format!("/media/{media_id}/external-metadata"))) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + /// Start a transcode job for a media item. + pub async fn start_transcode( + &self, + media_id: &str, + profile: &str, + ) -> Result { + Ok( + self + .client + .post(self.url(&format!("/media/{media_id}/transcode"))) + .json(&serde_json::json!({ "profile": profile })) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + /// List all transcode sessions. + pub async fn list_transcodes(&self) -> Result> { + Ok( + self + .client + .get(self.url("/transcode")) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + /// Cancel a transcode session. + pub async fn cancel_transcode(&self, id: &str) -> Result<()> { + self + .client + .delete(self.url(&format!("/transcode/{id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + /// List subtitles for a media item. + pub async fn list_subtitles_for_media( + &self, + media_id: &str, + ) -> Result { + Ok( + self + .client + .get(self.url(&format!("/media/{media_id}/subtitles"))) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + /// Delete a subtitle entry. + pub async fn delete_subtitle(&self, id: &str) -> Result<()> { + self + .client + .delete(self.url(&format!("/subtitles/{id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + /// Rate a media item. + pub async fn rate_media(&self, media_id: &str, stars: u8) -> Result<()> { + self + .client + .post(self.url(&format!("/media/{media_id}/ratings"))) + .json(&serde_json::json!({ "stars": stars })) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + /// Get ratings for a media item. + pub async fn get_ratings( + &self, + media_id: &str, + ) -> Result> { + Ok( + self + .client + .get(self.url(&format!("/media/{media_id}/ratings"))) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + /// Add a comment to a media item. + pub async fn add_comment( + &self, + media_id: &str, + text: &str, + ) -> Result { + Ok( + self + .client + .post(self.url(&format!("/media/{media_id}/comments"))) + .json(&serde_json::json!({ "text": text })) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + /// List comments for a media item. + pub async fn list_comments( + &self, + media_id: &str, + ) -> Result> { + Ok( + self + .client + .get(self.url(&format!("/media/{media_id}/comments"))) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + /// Add a media item to favorites. + pub async fn add_favorite(&self, media_id: &str) -> Result<()> { + self + .client + .post(self.url("/favorites")) + .json(&serde_json::json!({ "media_id": media_id })) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + /// Remove a media item from favorites. + pub async fn remove_favorite(&self, media_id: &str) -> Result<()> { + self + .client + .delete(self.url(&format!("/favorites/{media_id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + /// List favorite media items. + pub async fn list_favorites(&self) -> Result> { + Ok( + self + .client + .get(self.url("/favorites")) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + /// List sync devices. + pub async fn list_sync_devices(&self) -> Result> { + Ok( + self + .client + .get(self.url("/sync/devices")) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + /// Delete a sync device. + pub async fn delete_sync_device(&self, id: &str) -> Result<()> { + self + .client + .delete(self.url(&format!("/sync/devices/{id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + /// List webhooks. + pub async fn list_webhooks(&self) -> Result> { + Ok( + self + .client + .get(self.url("/webhooks")) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + /// Test a webhook. + pub async fn test_webhook(&self, id: &str) -> Result<()> { + self + .client + .post(self.url("/webhooks/test")) + .json(&serde_json::json!({ "id": id })) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + /// List all users. + pub async fn list_users(&self) -> Result> { + Ok( + self + .client + .get(self.url("/users")) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + /// Create a new user. + pub async fn create_user( + &self, + username: &str, + password: &str, + role: &str, + ) -> Result { + Ok( + self + .client + .post(self.url("/users")) + .json(&serde_json::json!({ + "username": username, + "password": password, + "role": role, + })) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + /// Update a user's role. + pub async fn update_user( + &self, + id: &str, + role: &str, + ) -> Result { + Ok( + self + .client + .patch(self.url(&format!("/users/{id}"))) + .json(&serde_json::json!({ "role": role })) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + /// Delete a user. + pub async fn delete_user(&self, id: &str) -> Result<()> { + self + .client + .delete(self.url(&format!("/users/{id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + /// Make a raw HTTP request to an API path. /// /// The `path` is appended to the base URL without any prefix. -- 2.43.0 From 39488720428756e44fb00189876bae86cd6a5dc2 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Mar 2026 13:32:41 +0300 Subject: [PATCH 22/30] pinakes-ui: playlists view and settings updates Signed-off-by: NotAShelf Change-Id: I7f39eca04360e78cd76c7cb43c2ad2776a6a6964 --- crates/pinakes-ui/src/app.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/pinakes-ui/src/app.rs b/crates/pinakes-ui/src/app.rs index 43699d2..5f59a29 100644 --- a/crates/pinakes-ui/src/app.rs +++ b/crates/pinakes-ui/src/app.rs @@ -22,6 +22,7 @@ use dioxus_free_icons::{ FaDownload, FaGear, FaLayerGroup, + FaList, FaMagnifyingGlass, FaTags, }, @@ -53,6 +54,7 @@ use crate::{ import, library, media_player::PlayQueue, + playlists, search, settings, statistics, @@ -77,6 +79,7 @@ enum View { Detail, Tags, Collections, + Playlists, Books, Audit, Import, @@ -100,6 +103,7 @@ impl View { Self::Detail => "Detail", Self::Tags => "Tags", Self::Collections => "Collections", + Self::Playlists => "Playlists", Self::Books => "Books", Self::Audit => "Audit Log", Self::Import => "Import", @@ -128,6 +132,7 @@ fn parse_plugin_route(route: &str) -> View { ["settings"] => View::Settings, ["tags"] => View::Tags, ["collections"] => View::Collections, + ["playlists"] => View::Playlists, ["books"] => View::Books, ["audit"] => View::Audit, ["import"] => View::Import, @@ -682,6 +687,14 @@ pub fn App() -> Element { span { class: "nav-item-text", "Collections" } span { class: "nav-badge", "{collections_list.read().len()}" } } + button { + class: if *current_view.read() == View::Playlists { "nav-item active" } else { "nav-item" }, + onclick: move |_| current_view.set(View::Playlists), + span { class: "nav-icon", + NavIcon { icon: FaList } + } + span { class: "nav-item-text", "Playlists" } + } button { class: if *current_view.read() == View::Books { "nav-item active" } else { "nav-item" }, onclick: { @@ -1851,6 +1864,9 @@ pub fn App() -> Element { }, } }, + View::Playlists => rsx! { + playlists::Playlists { client } + }, View::Audit => { let page_size = *audit_page_size.read(); let total = *audit_total_count.read(); @@ -2658,6 +2674,8 @@ pub fn App() -> Element { } settings::Settings { config: cfg.clone(), + client: Some(client.read().clone()), + current_user_role: current_user.read().as_ref().map(|u| u.role.clone()), on_add_root: { let client = client.read().clone(); move |path: String| { -- 2.43.0 From 76a48250e9b1895fa7b510f8fc69c14ca844232c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Mar 2026 13:32:50 +0300 Subject: [PATCH 23/30] pinakes-ui: update styles for media widgets Signed-off-by: NotAShelf Change-Id: Ia380cb749d3aafc15ffc242e43eefa106a6a6964 --- crates/pinakes-ui/assets/css/main.css | 2 +- crates/pinakes-ui/assets/styles/_base.scss | 8 ++ .../pinakes-ui/assets/styles/_components.scss | 124 ++++++++++++++++++ 3 files changed, 133 insertions(+), 1 deletion(-) diff --git a/crates/pinakes-ui/assets/css/main.css b/crates/pinakes-ui/assets/css/main.css index 746e00c..d25d383 100644 --- a/crates/pinakes-ui/assets/css/main.css +++ b/crates/pinakes-ui/assets/css/main.css @@ -1 +1 @@ -@media (prefers-reduced-motion: reduce){*,*::before,*::after{animation-duration:.01ms !important;animation-iteration-count:1 !important;transition-duration:.01ms !important}}*{margin:0;padding:0;box-sizing:border-box;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.06) rgba(0,0,0,0)}*::-webkit-scrollbar{width:5px;height:5px}*::-webkit-scrollbar-track{background:rgba(0,0,0,0)}*::-webkit-scrollbar-thumb{background:rgba(255,255,255,.06);border-radius:3px}*::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.14)}:root{--bg-0: #111118;--bg-1: #18181f;--bg-2: #1f1f28;--bg-3: #26263a;--border-subtle: rgba(255,255,255,.06);--border: rgba(255,255,255,.09);--border-strong: rgba(255,255,255,.14);--text-0: #dcdce4;--text-1: #a0a0b8;--text-2: #6c6c84;--accent: #7c7ef5;--accent-dim: rgba(124,126,245,.15);--accent-text: #9698f7;--success: #3ec97a;--error: #e45858;--warning: #d4a037;--radius-sm: 3px;--radius: 5px;--radius-md: 7px;--shadow-sm: 0 1px 3px rgba(0,0,0,.3);--shadow: 0 2px 8px rgba(0,0,0,.35);--shadow-lg: 0 4px 20px rgba(0,0,0,.45)}body{font-family:"Inter",-apple-system,"Segoe UI",system-ui,sans-serif;background:var(--bg-0);color:var(--text-0);font-size:13px;line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden}:focus-visible:focus-visible{outline:2px solid #7c7ef5;outline-offset:2px}::selection{background:rgba(124,126,245,.15);color:#9698f7}a{color:#9698f7;text-decoration:none}a:hover{text-decoration:underline}code{padding:1px 5px;border-radius:3px;background:#111118;color:#9698f7;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px}ul{list-style:none;padding:0}ul li{padding:3px 0;font-size:12px;color:#a0a0b8}.text-muted{color:#a0a0b8}.text-sm{font-size:11px}.mono{font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px}.flex-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px}.flex-between{display:flex;justify-content:space-between;align-items:center}.mb-16{margin-bottom:16px}.mb-8{margin-bottom:12px}@keyframes fade-in{from{opacity:0}to{opacity:1}}@keyframes slide-up{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}@keyframes pulse{0%, 100%{opacity:1}50%{opacity:.3}}@keyframes spin{to{transform:rotate(360deg)}}@keyframes skeleton-pulse{0%{opacity:.6}50%{opacity:.3}100%{opacity:.6}}@keyframes indeterminate{0%{transform:translateX(-100%)}100%{transform:translateX(400%)}}.app{display:flex;flex-direction:row;justify-content:flex-start;align-items:stretch;height:100vh;overflow:hidden}.sidebar{width:220px;min-width:220px;max-width:220px;background:#18181f;border-right:1px solid rgba(255,255,255,.09);display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;flex-shrink:0;user-select:none;overflow-y:auto;overflow-x:hidden;z-index:10;transition:width .15s,min-width .15s,max-width .15s}.sidebar.collapsed{width:48px;min-width:48px;max-width:48px}.sidebar.collapsed .nav-label,.sidebar.collapsed .sidebar-header .logo,.sidebar.collapsed .sidebar-header .version,.sidebar.collapsed .nav-badge,.sidebar.collapsed .nav-item-text,.sidebar.collapsed .sidebar-footer .status-text,.sidebar.collapsed .user-name,.sidebar.collapsed .role-badge,.sidebar.collapsed .user-info .btn,.sidebar.collapsed .sidebar-import-header span,.sidebar.collapsed .sidebar-import-file{display:none}.sidebar.collapsed .nav-item{justify-content:center;padding:8px;border-left:none;border-radius:3px}.sidebar.collapsed .nav-item.active{border-left:none}.sidebar.collapsed .nav-icon{width:auto;margin:0}.sidebar.collapsed .sidebar-header{padding:12px 8px;justify-content:center}.sidebar.collapsed .nav-section{padding:0 4px}.sidebar.collapsed .sidebar-footer{padding:8px}.sidebar.collapsed .sidebar-footer .user-info{justify-content:center;padding:4px}.sidebar.collapsed .sidebar-import-progress{padding:6px}.sidebar-header{padding:16px 16px 20px;display:flex;flex-direction:row;justify-content:flex-start;align-items:baseline;gap:8px}.sidebar-header .logo{font-size:15px;font-weight:700;letter-spacing:-.4px;color:#dcdce4}.sidebar-header .version{font-size:10px;color:#6c6c84}.sidebar-toggle{background:rgba(0,0,0,0);border:none;color:#6c6c84;padding:8px;font-size:18px;width:100%;text-align:center}.sidebar-toggle:hover{color:#dcdce4}.sidebar-spacer{flex:1}.sidebar-footer{padding:12px;border-top:1px solid rgba(255,255,255,.06);overflow:visible;min-width:0}.nav-section{padding:0 8px;margin-bottom:2px}.nav-label{padding:8px 8px 4px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#6c6c84}.nav-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:6px 8px;border-radius:3px;cursor:pointer;color:#a0a0b8;font-size:13px;font-weight:450;transition:color .1s,background .1s;border:none;background:none;width:100%;text-align:left;border-left:2px solid rgba(0,0,0,0);margin-left:0}.nav-item:hover{color:#dcdce4;background:rgba(255,255,255,.03)}.nav-item.active{color:#9698f7;border-left-color:#7c7ef5;background:rgba(124,126,245,.15)}.nav-item-text{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.sidebar:not(.collapsed) .nav-item-text{overflow:visible}.nav-icon{width:18px;text-align:center;font-size:14px;opacity:.7}.nav-badge{margin-left:auto;font-size:10px;font-weight:600;color:#6c6c84;background:#26263a;padding:1px 6px;border-radius:12px;min-width:20px;text-align:center;font-variant-numeric:tabular-nums}.status-indicator{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:6px;font-size:11px;font-weight:500;min-width:0;overflow:visible}.sidebar:not(.collapsed) .status-indicator{justify-content:flex-start}.status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}.status-dot.connected{background:#3ec97a}.status-dot.disconnected{background:#e45858}.status-dot.checking{background:#d4a037;animation:pulse 1.5s infinite}.status-text{color:#6c6c84;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.sidebar:not(.collapsed) .status-text{overflow:visible}.main{flex:1;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;overflow:hidden;min-width:0}.header{height:48px;min-height:48px;border-bottom:1px solid rgba(255,255,255,.06);display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:0 20px;background:#18181f}.page-title{font-size:14px;font-weight:600;color:#dcdce4}.header-spacer{flex:1}.content{flex:1;overflow-y:auto;padding:20px;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.06) rgba(0,0,0,0)}.sidebar-import-progress{padding:10px 12px;background:#1f1f28;border-top:1px solid rgba(255,255,255,.06);font-size:11px}.sidebar-import-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;margin-bottom:4px;color:#a0a0b8}.sidebar-import-file{color:#6c6c84;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}.sidebar-import-progress .progress-bar{height:3px}.user-info{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:12px;overflow:hidden;min-width:0}.user-name{font-weight:500;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:90px;flex-shrink:1}.role-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase}.role-badge.role-admin{background:rgba(139,92,246,.1);color:#9d8be0}.role-badge.role-editor{background:rgba(34,160,80,.1);color:#5cb97a}.role-badge.role-viewer{background:rgba(59,120,200,.1);color:#6ca0d4}.btn{padding:5px 12px;border-radius:3px;border:none;cursor:pointer;font-size:12px;font-weight:500;transition:all .1s;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;line-height:1.5}.btn-primary{background:#7c7ef5;color:#fff}.btn-primary:hover{background:#8b8df7}.btn-secondary{background:#26263a;color:#dcdce4;border:1px solid rgba(255,255,255,.09)}.btn-secondary:hover{border-color:rgba(255,255,255,.14);background:rgba(255,255,255,.06)}.btn-danger{background:rgba(0,0,0,0);color:#e45858;border:1px solid rgba(228,88,88,.25)}.btn-danger:hover{background:rgba(228,88,88,.08)}.btn-ghost{background:rgba(0,0,0,0);border:none;color:#a0a0b8;padding:5px 8px}.btn-ghost:hover{color:#dcdce4;background:rgba(255,255,255,.04)}.btn-sm{padding:3px 8px;font-size:11px}.btn-icon{padding:4px;border-radius:3px;background:rgba(0,0,0,0);border:none;color:#6c6c84;cursor:pointer;transition:color .1s;font-size:13px}.btn-icon:hover{color:#dcdce4}.btn:disabled,.btn[disabled]{opacity:.4;cursor:not-allowed;pointer-events:none}.btn.btn-disabled-hint:disabled{opacity:.6;border-style:dashed;pointer-events:auto;cursor:help}.card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px}.card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.card-title{font-size:14px;font-weight:600}.data-table{width:100%;border-collapse:collapse;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden}.data-table thead th{padding:8px 14px;text-align:left;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#6c6c84;border-bottom:1px solid rgba(255,255,255,.09);background:#26263a}.data-table tbody td{padding:8px 14px;font-size:13px;border-bottom:1px solid rgba(255,255,255,.06);max-width:300px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.data-table tbody tr{cursor:pointer;transition:background .08s}.data-table tbody tr:hover{background:rgba(255,255,255,.02)}.data-table tbody tr.row-selected{background:rgba(99,102,241,.12)}.data-table tbody tr:last-child td{border-bottom:none}.sortable-header{cursor:pointer;user-select:none;transition:color .1s}.sortable-header:hover{color:#9698f7}input[type=text],textarea,select{padding:6px 10px;border-radius:3px;border:1px solid rgba(255,255,255,.09);background:#111118;color:#dcdce4;font-size:13px;outline:none;transition:border-color .15s;font-family:inherit}input[type=text]::placeholder,textarea::placeholder,select::placeholder{color:#6c6c84}input[type=text]:focus,textarea:focus,select:focus{border-color:#7c7ef5}input[type=text][type=number],textarea[type=number],select[type=number]{width:80px;padding:6px 8px;-moz-appearance:textfield}input[type=text][type=number]::-webkit-outer-spin-button,input[type=text][type=number]::-webkit-inner-spin-button,textarea[type=number]::-webkit-outer-spin-button,textarea[type=number]::-webkit-inner-spin-button,select[type=number]::-webkit-outer-spin-button,select[type=number]::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}textarea{min-height:64px;resize:vertical}select{appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%236c6c84' d='M5 7L1 3h8z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 8px center;padding-right:26px;min-width:100px}.form-group{margin-bottom:12px}.form-label{display:block;font-size:11px;font-weight:600;color:#a0a0b8;margin-bottom:4px;text-transform:uppercase;letter-spacing:.03em}.form-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:flex-end;gap:8px}.form-row input[type=text]{flex:1}.form-label-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:4px}.form-label-row .form-label{margin-bottom:0}input[type=checkbox]{appearance:none;-webkit-appearance:none;width:16px;height:16px;border:1px solid rgba(255,255,255,.14);border-radius:3px;background:#1f1f28;cursor:pointer;position:relative;flex-shrink:0;transition:all .15s ease}input[type=checkbox]:hover{border-color:#7c7ef5;background:#26263a}input[type=checkbox]:checked{background:#7c7ef5;border-color:#7c7ef5}input[type=checkbox]:checked::after{content:"";position:absolute;left:5px;top:2px;width:4px;height:8px;border:solid #111118;border-width:0 2px 2px 0;transform:rotate(45deg)}input[type=checkbox]:focus-visible:focus-visible{outline:2px solid #7c7ef5;outline-offset:2px}.checkbox-label{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#a0a0b8;user-select:none}.checkbox-label:hover{color:#dcdce4}.checkbox-label input[type=checkbox]{margin:0}.toggle{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#dcdce4}.toggle.disabled{opacity:.4;cursor:not-allowed}.toggle-track{width:32px;height:18px;border-radius:9px;background:#26263a;border:1px solid rgba(255,255,255,.09);position:relative;transition:background .15s;flex-shrink:0}.toggle-track.active{background:#7c7ef5;border-color:#7c7ef5}.toggle-track.active .toggle-thumb{transform:translateX(14px)}.toggle-thumb{width:14px;height:14px;border-radius:50%;background:#dcdce4;position:absolute;top:1px;left:1px;transition:transform .15s}.filter-bar{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:12px;padding:12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:5px;margin-bottom:12px}.filter-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.filter-label{font-size:11px;font-weight:500;color:#6c6c84;text-transform:uppercase;letter-spacing:.5px;margin-right:4px}.filter-chip{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:14px;cursor:pointer;font-size:11px;color:#a0a0b8;transition:all .15s ease;user-select:none}.filter-chip:hover{background:#26263a;border-color:rgba(255,255,255,.14);color:#dcdce4}.filter-chip.active{background:rgba(124,126,245,.15);border-color:#7c7ef5;color:#9698f7}.filter-chip input[type=checkbox]{width:12px;height:12px;margin:0}.filter-chip input[type=checkbox]:checked::after{left:3px;top:1px;width:3px;height:6px}.filter-group{display:flex;align-items:center;gap:6px}.filter-group label{display:flex;align-items:center;gap:3px;cursor:pointer;color:#a0a0b8;font-size:11px;white-space:nowrap}.filter-group label:hover{color:#dcdce4}.filter-separator{width:1px;height:20px;background:rgba(255,255,255,.09);flex-shrink:0}.view-toggle{display:flex;border:1px solid rgba(255,255,255,.09);border-radius:3px;overflow:hidden}.view-btn{padding:4px 10px;background:#1f1f28;border:none;color:#6c6c84;cursor:pointer;font-size:18px;line-height:1;transition:background .1s,color .1s}.view-btn:first-child{border-right:1px solid rgba(255,255,255,.09)}.view-btn:hover{color:#dcdce4;background:#26263a}.view-btn.active{background:rgba(124,126,245,.15);color:#9698f7}.breadcrumb{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px;padding:10px 16px;font-size:.85rem;color:#6c6c84}.breadcrumb-sep{color:#6c6c84;opacity:.5}.breadcrumb-link{color:#9698f7;text-decoration:none;cursor:pointer}.breadcrumb-link:hover{text-decoration:underline}.breadcrumb-current{color:#dcdce4;font-weight:500}.progress-bar{width:100%;height:8px;background:#26263a;border-radius:4px;overflow:hidden;margin-bottom:6px}.progress-fill{height:100%;background:#7c7ef5;border-radius:4px;transition:width .3s ease}.progress-fill.indeterminate{width:30%;animation:indeterminate 1.5s ease-in-out infinite}.loading-overlay{display:flex;align-items:center;justify-content:center;padding:48px 16px;color:#6c6c84;font-size:13px;gap:10px}.spinner{width:18px;height:18px;border:2px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.spinner-small{width:14px;height:14px;border:2px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.spinner-tiny{width:10px;height:10px;border:1.5px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:100;animation:fade-in .1s ease-out}.modal{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:20px;min-width:360px;max-width:480px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.modal.wide{max-width:600px;max-height:70vh;overflow-y:auto}.modal-title{font-size:15px;font-weight:600;margin-bottom:6px}.modal-body{font-size:12px;color:#a0a0b8;margin-bottom:16px;line-height:1.5}.modal-actions{display:flex;flex-direction:row;justify-content:flex-end;align-items:center;gap:6px}.tooltip-trigger{display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border-radius:50%;background:#26263a;color:#6c6c84;font-size:9px;font-weight:700;cursor:help;position:relative;flex-shrink:0;margin-left:4px}.tooltip-trigger:hover{background:rgba(124,126,245,.15);color:#9698f7}.tooltip-trigger:hover .tooltip-text{display:block}.tooltip-text{display:none;position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);padding:6px 10px;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#dcdce4;font-size:11px;font-weight:400;line-height:1.4;white-space:normal;width:220px;text-transform:none;letter-spacing:normal;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:100;pointer-events:none}.media-player{position:relative;background:#111118;border-radius:5px;overflow:hidden}.media-player:focus{outline:none}.media-player-audio .player-artwork{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:8px;padding:24px 16px 8px}.player-artwork img{max-width:200px;max-height:200px;border-radius:5px;object-fit:cover}.player-artwork-placeholder{width:120px;height:120px;display:flex;align-items:center;justify-content:center;background:#1f1f28;border-radius:5px;font-size:48px;opacity:.3}.player-title{font-size:13px;font-weight:500;color:#dcdce4;text-align:center}.player-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 14px;background:#1f1f28}.media-player-video .player-controls{position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.7);opacity:0;transition:opacity .2s}.media-player-video:hover .player-controls{opacity:1}.play-btn,.mute-btn,.fullscreen-btn{background:none;border:none;color:#dcdce4;cursor:pointer;font-size:18px;padding:4px;line-height:1;transition:color .1s}.play-btn:hover,.mute-btn:hover,.fullscreen-btn:hover{color:#9698f7}.player-time{font-size:11px;color:#6c6c84;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;min-width:36px;text-align:center;user-select:none}.seek-bar{flex:1;-webkit-appearance:none;appearance:none;height:4px;border-radius:4px;background:#26263a;outline:none;cursor:pointer}.seek-bar::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:12px;height:12px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none}.seek-bar::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none}.volume-slider{width:70px;-webkit-appearance:none;appearance:none;height:4px;border-radius:4px;background:#26263a;outline:none;cursor:pointer}.volume-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:10px;height:10px;border-radius:50%;background:#a0a0b8;cursor:pointer;border:none}.volume-slider::-moz-range-thumb{width:10px;height:10px;border-radius:50%;background:#a0a0b8;cursor:pointer;border:none}.image-viewer-overlay{position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:150;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;animation:fade-in .15s ease-out}.image-viewer-overlay:focus{outline:none}.image-viewer-toolbar{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;background:rgba(0,0,0,.5);border-bottom:1px solid rgba(255,255,255,.08);z-index:2;user-select:none}.image-viewer-toolbar-left,.image-viewer-toolbar-center,.image-viewer-toolbar-right{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px}.iv-btn{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);color:#dcdce4;border-radius:3px;padding:4px 10px;font-size:12px;cursor:pointer;transition:background .1s}.iv-btn:hover{background:rgba(255,255,255,.12)}.iv-btn.iv-close{color:#e45858;font-weight:600}.iv-zoom-label{font-size:11px;color:#a0a0b8;min-width:40px;text-align:center;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace}.image-viewer-canvas{flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative}.image-viewer-canvas img{max-width:100%;max-height:100%;object-fit:contain;user-select:none;-webkit-user-drag:none}.pdf-viewer{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;height:100%;min-height:500px;background:#111118;border-radius:5px;overflow:hidden}.pdf-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:10px 12px;background:#18181f;border-bottom:1px solid rgba(255,255,255,.09)}.pdf-toolbar-group{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px}.pdf-toolbar-btn{display:flex;align-items:center;justify-content:center;width:28px;height:28px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#a0a0b8;font-size:14px;cursor:pointer;transition:all .15s}.pdf-toolbar-btn:hover:not(:disabled){background:#26263a;color:#dcdce4}.pdf-toolbar-btn:disabled{opacity:.4;cursor:not-allowed}.pdf-zoom-label{min-width:45px;text-align:center;font-size:12px;color:#a0a0b8}.pdf-container{flex:1;position:relative;overflow:hidden;background:#1f1f28}.pdf-object{width:100%;height:100%;border:none}.pdf-loading,.pdf-error{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:12px;background:#18181f;color:#a0a0b8}.pdf-error{padding:12px;text-align:center}.pdf-fallback{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:16px;padding:48px 12px;text-align:center;color:#6c6c84}.markdown-viewer{padding:16px;text-align:left;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:12px}.markdown-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:8px;background:#1f1f28;border-radius:5px;border:1px solid rgba(255,255,255,.09)}.toolbar-btn{padding:6px 12px;border:1px solid rgba(255,255,255,.09);border-radius:3px;background:#18181f;color:#a0a0b8;font-size:13px;font-weight:500;cursor:pointer;transition:all .15s}.toolbar-btn:hover{background:#26263a;border-color:rgba(255,255,255,.14)}.toolbar-btn.active{background:#7c7ef5;color:#fff;border-color:#7c7ef5}.markdown-source{max-width:100%;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px;overflow-x:auto;font-family:"Menlo","Monaco","Courier New",monospace;font-size:13px;line-height:1.7;color:#dcdce4;white-space:pre-wrap;word-wrap:break-word}.markdown-source code{font-family:inherit;background:none;padding:0;border:none}.markdown-content{max-width:800px;color:#dcdce4;line-height:1.7;font-size:14px;text-align:left}.markdown-content h1{font-size:1.8em;font-weight:700;margin:1em 0 .5em;border-bottom:1px solid rgba(255,255,255,.06);padding-bottom:.3em}.markdown-content h2{font-size:1.5em;font-weight:600;margin:.8em 0 .4em;border-bottom:1px solid rgba(255,255,255,.06);padding-bottom:.2em}.markdown-content h3{font-size:1.25em;font-weight:600;margin:.6em 0 .3em}.markdown-content h4{font-size:1.1em;font-weight:600;margin:.5em 0 .25em}.markdown-content h5,.markdown-content h6{font-size:1em;font-weight:600;margin:.4em 0 .2em;color:#a0a0b8}.markdown-content p{margin:0 0 1em}.markdown-content a{color:#7c7ef5;text-decoration:none}.markdown-content a:hover{text-decoration:underline}.markdown-content pre{background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;padding:12px 16px;overflow-x:auto;margin:0 0 1em;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;line-height:1.5}.markdown-content code{background:#26263a;padding:1px 5px;border-radius:3px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:.9em}.markdown-content pre code{background:none;padding:0}.markdown-content blockquote{border-left:3px solid #7c7ef5;padding:4px 16px;margin:0 0 1em;color:#a0a0b8;background:rgba(124,126,245,.04)}.markdown-content table{width:100%;border-collapse:collapse;margin:0 0 1em}.markdown-content th,.markdown-content td{padding:6px 12px;border:1px solid rgba(255,255,255,.09);font-size:13px}.markdown-content th{background:#26263a;font-weight:600;text-align:left}.markdown-content tr:nth-child(even){background:#1f1f28}.markdown-content ul,.markdown-content ol{margin:0 0 1em;padding-left:16px}.markdown-content ul{list-style:disc}.markdown-content ol{list-style:decimal}.markdown-content li{padding:2px 0;font-size:14px;color:#dcdce4}.markdown-content hr{border:none;border-top:1px solid rgba(255,255,255,.09);margin:1.5em 0}.markdown-content img{max-width:100%;border-radius:5px}.markdown-content .footnote-definition{font-size:.85em;color:#a0a0b8;margin-top:.5em;padding-left:1.5em}.markdown-content .footnote-definition sup{color:#7c7ef5;margin-right:4px}.markdown-content sup a{color:#7c7ef5;text-decoration:none;font-size:.8em}.wikilink{color:#9698f7;text-decoration:none;border-bottom:1px dashed #7c7ef5;cursor:pointer;transition:border-color .1s,color .1s}.wikilink:hover{color:#7c7ef5;border-bottom-style:solid}.wikilink-embed{display:inline-block;padding:2px 8px;background:rgba(139,92,246,.08);border:1px dashed rgba(139,92,246,.3);border-radius:3px;color:#9d8be0;font-size:12px;cursor:default}.media-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:12px}.media-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden;cursor:pointer;transition:border-color .12s,box-shadow .12s;position:relative}.media-card:hover{border-color:rgba(255,255,255,.14);box-shadow:0 1px 3px rgba(0,0,0,.3)}.media-card.selected{border-color:#7c7ef5;box-shadow:0 0 0 1px #7c7ef5}.card-checkbox{position:absolute;top:6px;left:6px;z-index:2;opacity:0;transition:opacity .1s}.card-checkbox input[type=checkbox]{width:16px;height:16px;cursor:pointer;filter:drop-shadow(0 1px 2px rgba(0,0,0,.5))}.media-card:hover .card-checkbox,.media-card.selected .card-checkbox{opacity:1}.card-thumbnail{width:100%;aspect-ratio:1;background:#111118;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative}.card-thumbnail img,.card-thumbnail .card-thumb-img{width:100%;height:100%;object-fit:cover;position:absolute;top:0;left:0;z-index:1}.card-type-icon{font-size:32px;opacity:.4;display:flex;align-items:center;justify-content:center;width:100%;height:100%;position:absolute;top:0;left:0;z-index:0}.card-info{padding:8px 10px}.card-name{font-size:12px;font-weight:500;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}.card-title,.card-artist{font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.3}.card-meta{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:10px}.card-size{color:#6c6c84;font-size:10px}.table-thumb-cell{width:36px;padding:4px 6px !important;position:relative}.table-thumb{width:28px;height:28px;object-fit:cover;border-radius:3px;display:block}.table-thumb-overlay{position:absolute;top:4px;left:6px;z-index:1}.table-type-icon{display:flex;align-items:center;justify-content:center;width:28px;height:28px;font-size:14px;opacity:.5;border-radius:3px;background:#111118;z-index:0}.type-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.type-badge.type-audio{background:rgba(139,92,246,.1);color:#9d8be0}.type-badge.type-video{background:rgba(200,72,130,.1);color:#d07eaa}.type-badge.type-image{background:rgba(34,160,80,.1);color:#5cb97a}.type-badge.type-document{background:rgba(59,120,200,.1);color:#6ca0d4}.type-badge.type-text{background:rgba(200,160,36,.1);color:#c4a840}.type-badge.type-other{background:rgba(128,128,160,.08);color:#6c6c84}.tag-list{display:flex;flex-wrap:wrap;gap:4px}.tag-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 10px;background:rgba(124,126,245,.15);color:#9698f7;border-radius:12px;font-size:11px;font-weight:500}.tag-badge.selected{background:#7c7ef5;color:#fff;cursor:pointer}.tag-badge:not(.selected){cursor:pointer}.tag-badge .tag-remove{cursor:pointer;opacity:.4;font-size:13px;line-height:1;transition:opacity .1s}.tag-badge .tag-remove:hover{opacity:1}.tag-group{margin-bottom:6px}.tag-children{margin-left:16px;margin-top:4px;display:flex;flex-wrap:wrap;gap:4px}.tag-confirm-delete{display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#a0a0b8}.tag-confirm-yes{cursor:pointer;color:#e45858;font-weight:600}.tag-confirm-yes:hover{text-decoration:underline}.tag-confirm-no{cursor:pointer;color:#6c6c84;font-weight:500}.tag-confirm-no:hover{text-decoration:underline}.detail-actions{display:flex;gap:6px;margin-bottom:16px}.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.detail-field{padding:10px 12px;background:#111118;border-radius:3px;border:1px solid rgba(255,255,255,.06)}.detail-field.full-width{grid-column:1/-1}.detail-field input[type=text],.detail-field textarea,.detail-field select{width:100%;margin-top:4px}.detail-field textarea{min-height:64px;resize:vertical}.detail-label{font-size:10px;font-weight:600;color:#6c6c84;text-transform:uppercase;letter-spacing:.04em;margin-bottom:2px}.detail-value{font-size:13px;color:#dcdce4;word-break:break-all}.detail-value.mono{font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px;color:#a0a0b8}.detail-preview{margin-bottom:16px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:5px;overflow:hidden;text-align:center}.detail-preview:has(.markdown-viewer){max-height:none;overflow-y:auto;text-align:left}.detail-preview:not(:has(.markdown-viewer)){max-height:450px}.detail-preview img{max-width:100%;max-height:400px;object-fit:contain;display:block;margin:0 auto}.detail-preview audio{width:100%;padding:16px}.detail-preview video{max-width:100%;max-height:400px;display:block;margin:0 auto}.detail-no-preview{padding:16px 16px;text-align:center;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px}.frontmatter-card{max-width:800px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:12px 16px;margin-bottom:16px}.frontmatter-fields{display:grid;grid-template-columns:auto 1fr;gap:4px 12px;margin:0}.frontmatter-fields dt{font-weight:600;font-size:12px;color:#a0a0b8;text-transform:capitalize}.frontmatter-fields dd{font-size:13px;color:#dcdce4;margin:0}.empty-state{text-align:center;padding:48px 12px;color:#6c6c84}.empty-state .empty-icon{font-size:32px;margin-bottom:12px;opacity:.3}.empty-title{font-size:15px;font-weight:600;color:#a0a0b8;margin-bottom:4px}.empty-subtitle{font-size:12px;max-width:320px;margin:0 auto;line-height:1.5}.toast-container{position:fixed;bottom:16px;right:16px;z-index:300;display:flex;flex-direction:column-reverse;gap:6px;align-items:flex-end}.toast-container .toast{position:static;transform:none}.toast{position:fixed;bottom:16px;right:16px;padding:10px 16px;border-radius:5px;background:#26263a;border:1px solid rgba(255,255,255,.09);color:#dcdce4;font-size:12px;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:300;animation:slide-up .15s ease-out;max-width:420px}.toast.success{border-left:3px solid #3ec97a}.toast.error{border-left:3px solid #e45858}.offline-banner,.error-banner{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:3px;padding:10px 12px;margin-bottom:12px;font-size:12px;color:#d47070;display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px}.offline-banner .offline-icon,.offline-banner .error-icon,.error-banner .offline-icon,.error-banner .error-icon{font-size:14px;flex-shrink:0}.error-banner{padding:10px 14px}.readonly-banner{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 12px;background:rgba(212,160,55,.06);border:1px solid rgba(212,160,55,.15);border-radius:3px;margin-bottom:16px;font-size:12px;color:#d4a037}.batch-actions{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:8px 10px;background:rgba(124,126,245,.15);border:1px solid rgba(124,126,245,.2);border-radius:3px;margin-bottom:12px;font-size:12px;font-weight:500;color:#9698f7}.select-all-banner{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:8px;padding:10px 16px;background:rgba(99,102,241,.08);border-radius:6px;margin-bottom:8px;font-size:.85rem;color:#a0a0b8}.select-all-banner button{background:none;border:none;color:#7c7ef5;cursor:pointer;font-weight:600;text-decoration:underline;font-size:.85rem;padding:0}.select-all-banner button:hover{color:#dcdce4}.import-status-panel{background:#1f1f28;border:1px solid #7c7ef5;border-radius:5px;padding:12px 16px;margin-bottom:16px}.import-status-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:8px;font-size:13px;color:#dcdce4}.import-current-file{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:6px;font-size:12px;overflow:hidden}.import-file-label{color:#6c6c84;flex-shrink:0}.import-file-name{color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:monospace;font-size:11px}.import-queue-indicator{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:8px;font-size:11px}.import-queue-badge{display:flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 6px;background:rgba(124,126,245,.15);color:#9698f7;border-radius:9px;font-weight:600;font-size:10px}.import-queue-text{color:#6c6c84}.import-tabs{display:flex;gap:0;margin-bottom:16px;border-bottom:1px solid rgba(255,255,255,.09)}.import-tab{padding:10px 16px;background:none;border:none;border-bottom:2px solid rgba(0,0,0,0);color:#6c6c84;font-size:12px;font-weight:500;cursor:pointer;transition:color .1s,border-color .1s}.import-tab:hover{color:#dcdce4}.import-tab.active{color:#9698f7;border-bottom-color:#7c7ef5}.queue-panel{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;border-left:1px solid rgba(255,255,255,.09);background:#18181f;min-width:280px;max-width:320px}.queue-header{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid rgba(255,255,255,.06)}.queue-header h3{margin:0;font-size:.9rem;color:#dcdce4}.queue-controls{display:flex;gap:2px}.queue-list{overflow-y:auto;flex:1}.queue-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;padding:8px 16px;cursor:pointer;border-bottom:1px solid rgba(255,255,255,.06);transition:background .15s}.queue-item:hover{background:#1f1f28}.queue-item:hover .queue-item-remove{opacity:1}.queue-item-active{background:rgba(124,126,245,.15);border-left:3px solid #7c7ef5}.queue-item-info{flex:1;min-width:0}.queue-item-title{display:block;font-size:.85rem;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.queue-item-artist{display:block;font-size:.75rem;color:#6c6c84}.queue-item-remove{opacity:0;transition:opacity .15s}.queue-empty{padding:16px 16px;text-align:center;color:#6c6c84;font-size:.85rem}.statistics-page{padding:20px}.stats-overview,.stats-grid{display:grid;grid-template-columns:repeat(3, 1fr);gap:16px;margin-bottom:24px}@media (max-width: 768px){.stats-overview,.stats-grid{grid-template-columns:1fr}}.stat-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:20px;display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:16px}.stat-card.stat-primary{border-left:3px solid #7c7ef5}.stat-card.stat-success{border-left:3px solid #3ec97a}.stat-card.stat-info{border-left:3px solid #6ca0d4}.stat-card.stat-warning{border-left:3px solid #d4a037}.stat-card.stat-purple{border-left:3px solid #9d8be0}.stat-card.stat-danger{border-left:3px solid #e45858}.stat-icon{flex-shrink:0;color:#6c6c84}.stat-content{flex:1}.stat-value{font-size:28px;font-weight:700;color:#dcdce4;line-height:1.2;font-variant-numeric:tabular-nums}.stat-label{font-size:12px;color:#6c6c84;margin-top:4px;font-weight:500}.stats-section{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px;margin-bottom:20px}.section-title{font-size:16px;font-weight:600;color:#dcdce4;margin-bottom:20px}.section-title.small{font-size:14px;margin-bottom:12px;padding-bottom:6px;border-bottom:1px solid rgba(255,255,255,.06)}.chart-bars{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:16px}.bar-item{display:grid;grid-template-columns:120px 1fr 80px;align-items:center;gap:16px}.bar-label{font-size:13px;font-weight:500;color:#a0a0b8;text-align:right}.bar-track{height:28px;background:#26263a;border-radius:3px;overflow:hidden;position:relative}.bar-fill{height:100%;transition:width .6s cubic-bezier(.4, 0, .2, 1);border-radius:3px}.bar-fill.bar-primary{background:linear-gradient(90deg, #7c7ef5 0%, #7c7ef3 100%)}.bar-fill.bar-success{background:linear-gradient(90deg, #3ec97a 0%, #66bb6a 100%)}.bar-value{font-size:13px;font-weight:600;color:#a0a0b8;text-align:right;font-variant-numeric:tabular-nums}.settings-section{margin-bottom:16px}.settings-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:20px;margin-bottom:16px}.settings-card.danger-card{border:1px solid rgba(228,88,88,.25)}.settings-card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid rgba(255,255,255,.06)}.settings-card-title{font-size:14px;font-weight:600}.settings-card-body{padding-top:2px}.settings-field{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.06)}.settings-field:last-child{border-bottom:none}.settings-field select{min-width:120px}.config-path{font-size:11px;color:#6c6c84;margin-bottom:12px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;padding:6px 10px;background:#111118;border-radius:3px;border:1px solid rgba(255,255,255,.06)}.config-status{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600}.config-status.writable{background:rgba(62,201,122,.1);color:#3ec97a}.config-status.readonly{background:rgba(228,88,88,.1);color:#e45858}.root-list{list-style:none}.root-item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:3px;margin-bottom:4px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;color:#a0a0b8}.info-row{display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid rgba(255,255,255,.06);font-size:13px}.info-row:last-child{border-bottom:none}.info-label{color:#a0a0b8;font-weight:500}.info-value{color:#dcdce4}.tasks-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(400px, 1fr));gap:16px;padding:12px}.task-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden;transition:all .2s}.task-card:hover{border-color:rgba(255,255,255,.14);box-shadow:0 4px 12px rgba(0,0,0,.08);transform:translateY(-2px)}.task-card-enabled{border-left:3px solid #3ec97a}.task-card-disabled{border-left:3px solid #4a4a5e;opacity:.7}.task-card-header{display:flex;justify-content:space-between;align-items:center;align-items:flex-start;padding:16px;border-bottom:1px solid rgba(255,255,255,.06)}.task-header-left{flex:1;min-width:0}.task-name{font-size:16px;font-weight:600;color:#dcdce4;margin-bottom:2px}.task-schedule{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:12px;color:#6c6c84;font-family:"Menlo","Monaco","Courier New",monospace}.schedule-icon{font-size:14px}.task-status-badge{flex-shrink:0}.status-badge{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:2px 10px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}.status-badge.status-enabled{background:rgba(76,175,80,.12);color:#3ec97a}.status-badge.status-enabled .status-dot{animation:pulse 1.5s infinite}.status-badge.status-disabled{background:#26263a;color:#6c6c84}.status-badge .status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;background:currentColor}.task-info-grid{display:grid;grid-template-columns:repeat(auto-fit, minmax(120px, 1fr));gap:12px;padding:16px}.task-info-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:flex-start;gap:10px}.task-info-icon{font-size:18px;color:#6c6c84;flex-shrink:0}.task-info-content{flex:1;min-width:0}.task-info-label{font-size:10px;color:#6c6c84;font-weight:600;text-transform:uppercase;letter-spacing:.03em;margin-bottom:2px}.task-info-value{font-size:12px;color:#a0a0b8;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.task-card-actions{display:flex;gap:8px;padding:10px 16px;background:#18181f;border-top:1px solid rgba(255,255,255,.06)}.task-card-actions button{flex:1}.db-actions{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:16px;padding:10px}.db-action-row{display:flex;flex-direction:row;justify-content:space-between;align-items:center;gap:16px;padding:10px;border-radius:6px;background:rgba(0,0,0,.06)}.db-action-info{flex:1}.db-action-info h4{font-size:.95rem;font-weight:600;color:#dcdce4;margin-bottom:2px}.db-action-confirm{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;flex-shrink:0}.library-toolbar{display:flex;justify-content:space-between;align-items:center;padding:8px 0;margin-bottom:12px;gap:12px;flex-wrap:wrap}.toolbar-left{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:10px}.toolbar-right{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:10px}.sort-control select,.page-size-control select{padding:4px 24px 4px 8px;font-size:11px;background:#1f1f28}.page-size-control{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px}.library-stats{display:flex;justify-content:space-between;align-items:center;padding:2px 0 6px 0;font-size:11px}.type-filter-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:4px 0;margin-bottom:6px;flex-wrap:wrap}.pagination{display:flex;align-items:center;justify-content:center;gap:4px;margin-top:16px;padding:8px 0}.page-btn{min-width:28px;text-align:center;font-variant-numeric:tabular-nums}.page-ellipsis{color:#6c6c84;padding:0 4px;font-size:12px;user-select:none}.audit-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:12px}.filter-select{padding:4px 24px 4px 8px;font-size:11px;background:#1f1f28}.action-danger{background:rgba(228,88,88,.1);color:#d47070}.action-updated{background:rgba(59,120,200,.1);color:#6ca0d4}.action-collection{background:rgba(34,160,80,.1);color:#5cb97a}.action-collection-remove{background:rgba(212,160,55,.1);color:#c4a840}.action-opened{background:rgba(139,92,246,.1);color:#9d8be0}.action-scanned{background:rgba(128,128,160,.08);color:#6c6c84}.clickable{cursor:pointer;color:#9698f7}.clickable:hover{text-decoration:underline}.clickable-row{cursor:pointer}.clickable-row:hover{background:rgba(255,255,255,.03)}.duplicates-view{padding:0}.duplicates-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.duplicates-header h3{margin:0}.duplicates-summary{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px}.duplicate-group{border:1px solid rgba(255,255,255,.09);border-radius:5px;margin-bottom:8px;overflow:hidden}.duplicate-group-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;width:100%;padding:10px 14px;background:#1f1f28;border:none;cursor:pointer;text-align:left;color:#dcdce4;font-size:13px}.duplicate-group-header:hover{background:#26263a}.expand-icon{font-size:10px;width:14px;flex-shrink:0}.group-name{font-weight:600;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.group-badge{background:#7c7ef5;color:#fff;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;flex-shrink:0}.group-size{flex-shrink:0;font-size:12px}.group-hash{font-size:11px;flex-shrink:0}.duplicate-items{border-top:1px solid rgba(255,255,255,.09)}.duplicate-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.06)}.duplicate-item:last-child{border-bottom:none}.duplicate-item-keep{background:rgba(76,175,80,.06)}.dup-thumb{width:48px;height:48px;flex-shrink:0;border-radius:3px;overflow:hidden}.dup-thumb-img{width:100%;height:100%;object-fit:cover}.dup-thumb-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#26263a;font-size:20px;color:#6c6c84}.dup-info{flex:1;min-width:0}.dup-filename{font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dup-path{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dup-meta{font-size:12px;margin-top:2px}.dup-actions{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;flex-shrink:0}.keep-badge{background:rgba(76,175,80,.12);color:#4caf50;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:600}.saved-searches-list{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:4px;max-height:300px;overflow-y:auto}.saved-search-item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:#18181f;border-radius:3px;cursor:pointer;transition:background .15s ease}.saved-search-item:hover{background:#1f1f28}.saved-search-info{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:2px;flex:1;min-width:0}.saved-search-name{font-weight:500;color:#dcdce4}.saved-search-query{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.backlinks-panel,.outgoing-links-panel{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;margin-top:16px;overflow:hidden}.backlinks-header,.outgoing-links-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 14px;background:#26263a;cursor:pointer;user-select:none;transition:background .1s}.backlinks-header:hover,.outgoing-links-header:hover{background:rgba(255,255,255,.04)}.backlinks-toggle,.outgoing-links-toggle{font-size:10px;color:#6c6c84;width:12px;text-align:center}.backlinks-title,.outgoing-links-title{font-size:12px;font-weight:600;color:#dcdce4;flex:1}.backlinks-count,.outgoing-links-count{font-size:11px;color:#6c6c84}.backlinks-reindex-btn{display:flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;margin-left:auto;background:rgba(0,0,0,0);border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#6c6c84;font-size:12px;cursor:pointer;transition:background .1s,color .1s,border-color .1s}.backlinks-reindex-btn:hover:not(:disabled){background:#1f1f28;color:#dcdce4;border-color:rgba(255,255,255,.14)}.backlinks-reindex-btn:disabled{opacity:.5;cursor:not-allowed}.backlinks-content,.outgoing-links-content{padding:12px;border-top:1px solid rgba(255,255,255,.06)}.backlinks-loading,.outgoing-links-loading{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:12px;color:#6c6c84;font-size:12px}.backlinks-error,.outgoing-links-error{padding:8px 12px;background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:3px;font-size:12px;color:#e45858}.backlinks-empty,.outgoing-links-empty{padding:16px;text-align:center;color:#6c6c84;font-size:12px;font-style:italic}.backlinks-list,.outgoing-links-list{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:6px}.backlink-item,.outgoing-link-item{padding:10px 12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:3px;cursor:pointer;transition:background .1s,border-color .1s}.backlink-item:hover,.outgoing-link-item:hover{background:#18181f;border-color:rgba(255,255,255,.09)}.backlink-item.unresolved,.outgoing-link-item.unresolved{opacity:.7;border-style:dashed}.backlink-source,.outgoing-link-target{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:2px}.backlink-title,.outgoing-link-text{font-size:13px;font-weight:500;color:#dcdce4;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.backlink-type-badge,.outgoing-link-type-badge{display:inline-block;padding:1px 6px;border-radius:12px;font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}.backlink-type-badge.backlink-type-wikilink,.backlink-type-badge.link-type-wikilink,.outgoing-link-type-badge.backlink-type-wikilink,.outgoing-link-type-badge.link-type-wikilink{background:rgba(124,126,245,.15);color:#9698f7}.backlink-type-badge.backlink-type-embed,.backlink-type-badge.link-type-embed,.outgoing-link-type-badge.backlink-type-embed,.outgoing-link-type-badge.link-type-embed{background:rgba(139,92,246,.1);color:#9d8be0}.backlink-type-badge.backlink-type-markdown_link,.backlink-type-badge.link-type-markdown_link,.outgoing-link-type-badge.backlink-type-markdown_link,.outgoing-link-type-badge.link-type-markdown_link{background:rgba(59,120,200,.1);color:#6ca0d4}.backlink-context{font-size:11px;color:#6c6c84;line-height:1.4;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.backlink-line{color:#a0a0b8;font-weight:500}.unresolved-badge{padding:1px 6px;border-radius:12px;font-size:9px;font-weight:600;background:rgba(212,160,55,.1);color:#d4a037}.outgoing-links-unresolved-badge{margin-left:8px;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:500;background:rgba(212,160,55,.12);color:#d4a037}.outgoing-links-global-unresolved{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;margin-top:12px;padding:10px 12px;background:rgba(212,160,55,.06);border:1px solid rgba(212,160,55,.15);border-radius:3px;font-size:11px;color:#6c6c84}.outgoing-links-global-unresolved .unresolved-icon{color:#d4a037}.backlinks-message{padding:8px 10px;margin-bottom:10px;border-radius:3px;font-size:11px}.backlinks-message.success{background:rgba(62,201,122,.08);border:1px solid rgba(62,201,122,.2);color:#3ec97a}.backlinks-message.error{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);color:#e45858}.graph-view{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;height:100%;background:#18181f;border-radius:5px;overflow:hidden}.graph-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:16px;padding:12px 16px;background:#1f1f28;border-bottom:1px solid rgba(255,255,255,.09)}.graph-title{font-size:14px;font-weight:600;color:#dcdce4}.graph-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;font-size:12px;color:#a0a0b8}.graph-controls select{padding:4px 20px 4px 8px;font-size:11px;background:#26263a}.graph-stats{margin-left:auto;font-size:11px;color:#6c6c84}.graph-container{flex:1;position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden;background:#111118}.graph-loading,.graph-error,.graph-empty{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px;padding:48px;color:#6c6c84;font-size:13px;text-align:center}.graph-svg{max-width:100%;max-height:100%;cursor:grab}.graph-svg-container{position:relative;width:100%;height:100%}.graph-zoom-controls{position:absolute;top:16px;left:16px;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;z-index:5}.zoom-btn{width:36px;height:36px;border-radius:6px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);color:#dcdce4;font-size:18px;font-weight:bold;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:all .15s;box-shadow:0 1px 3px rgba(0,0,0,.3)}.zoom-btn:hover{background:#26263a;border-color:rgba(255,255,255,.14);transform:scale(1.05)}.zoom-btn:active{transform:scale(.95)}.graph-edges line{stroke:rgba(255,255,255,.14);stroke-width:1;opacity:.6}.graph-edges line.edge-type-wikilink{stroke:#7c7ef5}.graph-edges line.edge-type-embed{stroke:#9d8be0;stroke-dasharray:4 2}.graph-nodes .graph-node{cursor:pointer}.graph-nodes .graph-node circle{fill:#4caf50;stroke:#388e3c;stroke-width:2;transition:fill .15s,stroke .15s}.graph-nodes .graph-node:hover circle{fill:#66bb6a}.graph-nodes .graph-node.selected circle{fill:#7c7ef5;stroke:#5456d6}.graph-nodes .graph-node text{fill:#a0a0b8;font-size:11px;pointer-events:none;text-anchor:middle;dominant-baseline:central;transform:translateY(16px)}.node-details-panel{position:absolute;top:16px;right:16px;width:280px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:10}.node-details-header{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.06)}.node-details-header h3{font-size:13px;font-weight:600;color:#dcdce4;margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.node-details-header .close-btn{background:none;border:none;color:#6c6c84;cursor:pointer;font-size:14px;padding:2px 6px;line-height:1}.node-details-header .close-btn:hover{color:#dcdce4}.node-details-content{padding:14px}.node-details-content .node-title{font-size:12px;color:#a0a0b8;margin-bottom:12px}.node-stats{display:flex;gap:16px;margin-bottom:12px}.node-stats .stat{font-size:12px;color:#6c6c84}.node-stats .stat strong{color:#dcdce4}.physics-controls-panel{position:absolute;top:16px;right:16px;width:300px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;box-shadow:0 2px 8px rgba(0,0,0,.35);padding:16px;z-index:10}.physics-controls-panel h4{font-size:13px;font-weight:600;color:#dcdce4;margin:0 0 16px 0;padding-bottom:8px;border-bottom:1px solid rgba(255,255,255,.06)}.physics-controls-panel .btn{width:100%;margin-top:8px}.control-group{margin-bottom:14px}.control-group label{display:block;font-size:11px;font-weight:500;color:#a0a0b8;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}.control-group input[type=range]{width:100%;height:4px;border-radius:4px;background:#26263a;outline:none;-webkit-appearance:none}.control-group input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;border-radius:50%;background:#7c7ef5;cursor:pointer;transition:transform .1s}.control-group input[type=range]::-webkit-slider-thumb:hover{transform:scale(1.15)}.control-group input[type=range]::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none;transition:transform .1s}.control-group input[type=range]::-moz-range-thumb:hover{transform:scale(1.15)}.control-value{display:inline-block;margin-top:2px;font-size:11px;color:#6c6c84;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace}.theme-light{--bg-0: #f5f5f7;--bg-1: #eeeef0;--bg-2: #fff;--bg-3: #e8e8ec;--border-subtle: rgba(0,0,0,.06);--border: rgba(0,0,0,.1);--border-strong: rgba(0,0,0,.16);--text-0: #1a1a2e;--text-1: #555570;--text-2: #8888a0;--accent: #6366f1;--accent-dim: rgba(99,102,241,.1);--accent-text: #4f52e8;--shadow-sm: 0 1px 3px rgba(0,0,0,.08);--shadow: 0 2px 8px rgba(0,0,0,.1);--shadow-lg: 0 4px 20px rgba(0,0,0,.12)}.theme-light ::-webkit-scrollbar-thumb{background:rgba(0,0,0,.12)}.theme-light ::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.08)}.theme-light ::-webkit-scrollbar-track{background:rgba(0,0,0,.06)}.theme-light .graph-nodes .graph-node text{fill:#1a1a2e}.theme-light .graph-edges line{stroke:rgba(0,0,0,.12)}.theme-light .pdf-container{background:#e8e8ec}.skeleton-pulse{animation:skeleton-pulse 1.5s ease-in-out infinite;background:#26263a;border-radius:4px}.skeleton-card{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;padding:8px}.skeleton-thumb{width:100%;aspect-ratio:1;border-radius:6px}.skeleton-text{height:14px;width:80%}.skeleton-text-short{width:50%}.skeleton-row{display:flex;gap:12px;padding:10px 16px;align-items:center}.skeleton-cell{height:14px;flex:1;border-radius:4px}.skeleton-cell-icon{width:32px;height:32px;flex:none;border-radius:4px}.skeleton-cell-wide{flex:3}.loading-overlay{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px;background:rgba(0,0,0,.3);z-index:100;border-radius:8px}.loading-spinner{width:32px;height:32px;border:3px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .8s linear infinite}.loading-message{color:#a0a0b8;font-size:.9rem}.login-container{display:flex;align-items:center;justify-content:center;height:100vh;background:#111118}.login-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:24px;width:360px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.login-title{font-size:20px;font-weight:700;color:#dcdce4;text-align:center;margin-bottom:2px}.login-subtitle{font-size:13px;color:#6c6c84;text-align:center;margin-bottom:20px}.login-error{background:rgba(228,88,88,.08);border:1px solid rgba(228,88,88,.2);border-radius:3px;padding:8px 12px;margin-bottom:12px;font-size:12px;color:#e45858}.login-form input[type=text],.login-form input[type=password]{width:100%}.login-btn{width:100%;padding:8px 16px;font-size:13px;margin-top:2px}.pagination{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:2px;margin-top:16px;padding:8px 0}.page-btn{min-width:28px;text-align:center;font-variant-numeric:tabular-nums}.page-ellipsis{color:#6c6c84;padding:0 4px;font-size:12px;user-select:none}.help-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:200;animation:fade-in .1s ease-out}.help-dialog{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:16px;min-width:300px;max-width:400px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.help-dialog h3{font-size:16px;font-weight:600;margin-bottom:16px}.help-shortcuts{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;margin-bottom:16px}.shortcut-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px}.shortcut-row kbd{display:inline-block;padding:2px 8px;background:#111118;border:1px solid rgba(255,255,255,.09);border-radius:3px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px;color:#dcdce4;min-width:32px;text-align:center}.shortcut-row span{font-size:13px;color:#a0a0b8}.help-close{display:block;width:100%;padding:6px 12px;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#dcdce4;font-size:12px;cursor:pointer;text-align:center}.help-close:hover{background:rgba(255,255,255,.06)}.plugin-page{padding:16px 24px;max-width:100%;overflow-x:hidden}.plugin-page-title{font-size:14px;font-weight:600;color:#dcdce4;margin:0 0 16px}.plugin-container{display:flex;flex-direction:column;gap:var(--plugin-gap, 0px);padding:var(--plugin-padding, 0)}.plugin-grid{display:grid;grid-template-columns:repeat(var(--plugin-columns, 1), 1fr);gap:var(--plugin-gap, 0px)}.plugin-flex{display:flex;gap:var(--plugin-gap, 0px)}.plugin-flex[data-direction=row]{flex-direction:row}.plugin-flex[data-direction=column]{flex-direction:column}.plugin-flex[data-justify=flex-start]{justify-content:flex-start}.plugin-flex[data-justify=flex-end]{justify-content:flex-end}.plugin-flex[data-justify=center]{justify-content:center}.plugin-flex[data-justify=space-between]{justify-content:space-between}.plugin-flex[data-justify=space-around]{justify-content:space-around}.plugin-flex[data-justify=space-evenly]{justify-content:space-evenly}.plugin-flex[data-align=flex-start]{align-items:flex-start}.plugin-flex[data-align=flex-end]{align-items:flex-end}.plugin-flex[data-align=center]{align-items:center}.plugin-flex[data-align=stretch]{align-items:stretch}.plugin-flex[data-align=baseline]{align-items:baseline}.plugin-flex[data-wrap=wrap]{flex-wrap:wrap}.plugin-flex[data-wrap=nowrap]{flex-wrap:nowrap}.plugin-split{display:flex}.plugin-split-sidebar{width:var(--plugin-sidebar-width, 200px);flex-shrink:0}.plugin-split-main{flex:1;min-width:0}.plugin-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;overflow:hidden}.plugin-card-header{padding:12px 16px;font-size:12px;font-weight:600;color:#dcdce4;border-bottom:1px solid rgba(255,255,255,.09);background:#26263a}.plugin-card-content{padding:16px}.plugin-card-footer{padding:12px 16px;border-top:1px solid rgba(255,255,255,.09);background:#18181f}.plugin-heading{color:#dcdce4;margin:0;line-height:1.2}.plugin-heading.level-1{font-size:28px;font-weight:700}.plugin-heading.level-2{font-size:18px;font-weight:600}.plugin-heading.level-3{font-size:16px;font-weight:600}.plugin-heading.level-4{font-size:14px;font-weight:500}.plugin-heading.level-5{font-size:13px;font-weight:500}.plugin-heading.level-6{font-size:12px;font-weight:500}.plugin-text{margin:0;font-size:12px;color:#dcdce4;line-height:1.4}.plugin-text.text-secondary{color:#a0a0b8}.plugin-text.text-error{color:#d47070}.plugin-text.text-success{color:#3ec97a}.plugin-text.text-warning{color:#d4a037}.plugin-text.text-bold{font-weight:600}.plugin-text.text-italic{font-style:italic}.plugin-text.text-small{font-size:10px}.plugin-text.text-large{font-size:15px}.plugin-code{background:#18181f;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px 24px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;color:#dcdce4;overflow-x:auto;white-space:pre}.plugin-code code{font-family:inherit;font-size:inherit;color:inherit}.plugin-tabs{display:flex;flex-direction:column}.plugin-tab-list{display:flex;gap:2px;border-bottom:1px solid rgba(255,255,255,.09);margin-bottom:16px}.plugin-tab{padding:8px 20px;font-size:12px;font-weight:500;color:#a0a0b8;background:rgba(0,0,0,0);border:none;border-bottom:2px solid rgba(0,0,0,0);cursor:pointer;transition:color .1s,border-color .1s}.plugin-tab:hover{color:#dcdce4}.plugin-tab.active{color:#9698f7;border-bottom-color:#7c7ef5}.plugin-tab .tab-icon{margin-right:4px}.plugin-tab-panel:not(.active){display:none}.plugin-description-list-wrapper{width:100%}.plugin-description-list{display:grid;grid-template-columns:max-content 1fr;gap:4px 16px;margin:0;padding:0}.plugin-description-list dt{font-size:10px;font-weight:500;color:#a0a0b8;text-transform:uppercase;letter-spacing:.5px;padding:6px 0;white-space:nowrap}.plugin-description-list dd{font-size:12px;color:#dcdce4;padding:6px 0;margin:0;word-break:break-word}.plugin-description-list.horizontal{display:flex;flex-wrap:wrap;gap:16px 24px;display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr))}.plugin-description-list.horizontal dt{width:auto;padding:0}.plugin-description-list.horizontal dd{width:auto;padding:0}.plugin-description-list.horizontal dt,.plugin-description-list.horizontal dd{display:inline}.plugin-description-list.horizontal dt{font-size:9px;text-transform:uppercase;letter-spacing:.5px;color:#6c6c84;margin-bottom:2px}.plugin-description-list.horizontal dd{font-size:13px;font-weight:600;color:#dcdce4}.plugin-data-table-wrapper{overflow-x:auto}.plugin-data-table{width:100%;border-collapse:collapse;font-size:12px}.plugin-data-table thead tr{border-bottom:1px solid rgba(255,255,255,.14)}.plugin-data-table thead th{padding:8px 12px;text-align:left;font-size:10px;font-weight:600;color:#a0a0b8;text-transform:uppercase;letter-spacing:.5px;white-space:nowrap}.plugin-data-table tbody tr{border-bottom:1px solid rgba(255,255,255,.06);transition:background .08s}.plugin-data-table tbody tr:hover{background:rgba(255,255,255,.03)}.plugin-data-table tbody tr:last-child{border-bottom:none}.plugin-data-table tbody td{padding:8px 12px;color:#dcdce4;vertical-align:middle}.plugin-col-constrained{width:var(--plugin-col-width)}.table-filter{margin-bottom:12px}.table-filter input{width:240px;padding:6px 12px;background:#18181f;border:1px solid rgba(255,255,255,.09);border-radius:5px;color:#dcdce4;font-size:12px}.table-filter input::placeholder{color:#6c6c84}.table-filter input:focus{outline:none;border-color:#7c7ef5}.table-pagination{display:flex;align-items:center;gap:12px;padding:8px 0;font-size:12px;color:#a0a0b8}.row-actions{white-space:nowrap;width:1%}.row-actions .plugin-button{padding:4px 8px;font-size:10px;margin-right:4px}.plugin-media-grid{display:grid;grid-template-columns:repeat(var(--plugin-columns, 2), 1fr);gap:var(--plugin-gap, 8px)}.media-grid-item{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;overflow:hidden;display:flex;flex-direction:column}.media-grid-img{width:100%;aspect-ratio:16/9;object-fit:cover;display:block}.media-grid-no-img{width:100%;aspect-ratio:16/9;background:#26263a;display:flex;align-items:center;justify-content:center;font-size:10px;color:#6c6c84}.media-grid-caption{padding:8px 12px;font-size:10px;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.plugin-list{list-style:none;margin:0;padding:0}.plugin-list-item{padding:8px 0}.plugin-list-divider{border:none;border-top:1px solid rgba(255,255,255,.06);margin:0}.plugin-list-empty{padding:16px;text-align:center;color:#6c6c84;font-size:12px}.plugin-button{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border:1px solid rgba(255,255,255,.09);border-radius:5px;font-size:12px;font-weight:500;cursor:pointer;transition:background .08s,border-color .08s,color .08s;background:#1f1f28;color:#dcdce4}.plugin-button:disabled{opacity:.45;cursor:not-allowed}.plugin-button.btn-primary{background:#7c7ef5;border-color:#7c7ef5;color:#fff}.plugin-button.btn-primary:hover:not(:disabled){background:#8b8df7}.plugin-button.btn-secondary{background:#26263a;border-color:rgba(255,255,255,.14);color:#dcdce4}.plugin-button.btn-secondary:hover:not(:disabled){background:rgba(255,255,255,.04)}.plugin-button.btn-tertiary{background:rgba(0,0,0,0);border-color:rgba(0,0,0,0);color:#9698f7}.plugin-button.btn-tertiary:hover:not(:disabled){background:rgba(124,126,245,.15)}.plugin-button.btn-danger{background:rgba(0,0,0,0);border-color:rgba(228,88,88,.2);color:#d47070}.plugin-button.btn-danger:hover:not(:disabled){background:rgba(228,88,88,.06)}.plugin-button.btn-success{background:rgba(0,0,0,0);border-color:rgba(62,201,122,.2);color:#3ec97a}.plugin-button.btn-success:hover:not(:disabled){background:rgba(62,201,122,.08)}.plugin-button.btn-ghost{background:rgba(0,0,0,0);border-color:rgba(0,0,0,0);color:#a0a0b8}.plugin-button.btn-ghost:hover:not(:disabled){background:rgba(255,255,255,.04)}.plugin-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:50%;font-size:9px;font-weight:600;letter-spacing:.5px;text-transform:uppercase}.plugin-badge.badge-default,.plugin-badge.badge-neutral{background:rgba(255,255,255,.04);color:#a0a0b8}.plugin-badge.badge-primary{background:rgba(124,126,245,.15);color:#9698f7}.plugin-badge.badge-secondary{background:rgba(255,255,255,.03);color:#dcdce4}.plugin-badge.badge-success{background:rgba(62,201,122,.08);color:#3ec97a}.plugin-badge.badge-warning{background:rgba(212,160,55,.06);color:#d4a037}.plugin-badge.badge-error{background:rgba(228,88,88,.06);color:#d47070}.plugin-badge.badge-info{background:rgba(99,102,241,.08);color:#9698f7}.plugin-form{display:flex;flex-direction:column;gap:16px}.form-field{display:flex;flex-direction:column;gap:6px}.form-field label{font-size:12px;font-weight:500;color:#dcdce4}.form-field input,.form-field textarea,.form-field select{padding:8px 12px;background:#18181f;border:1px solid rgba(255,255,255,.09);border-radius:5px;color:#dcdce4;font-size:12px;font-family:inherit}.form-field input::placeholder,.form-field textarea::placeholder,.form-field select::placeholder{color:#6c6c84}.form-field input:focus,.form-field textarea:focus,.form-field select:focus{outline:none;border-color:#7c7ef5;box-shadow:0 0 0 2px rgba(124,126,245,.15)}.form-field textarea{min-height:80px;resize:vertical}.form-field select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23a0a0b8' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center;padding-right:32px}.form-help{margin:0;font-size:10px;color:#6c6c84}.form-actions{display:flex;gap:12px;padding-top:8px}.required{color:#e45858}.plugin-link{color:#9698f7;text-decoration:none}.plugin-link:hover{text-decoration:underline}.plugin-link-blocked{color:#6c6c84;text-decoration:line-through;cursor:not-allowed}.plugin-progress{background:#18181f;border:1px solid rgba(255,255,255,.09);border-radius:5px;height:8px;overflow:hidden;display:flex;align-items:center;gap:8px}.plugin-progress-bar{height:100%;background:#7c7ef5;border-radius:4px;transition:width .3s ease;width:var(--plugin-progress, 0%)}.plugin-progress-label{font-size:10px;color:#a0a0b8;white-space:nowrap;flex-shrink:0}.plugin-chart{overflow:auto;height:var(--plugin-chart-height, 200px)}.plugin-chart .chart-title{font-size:13px;font-weight:600;color:#dcdce4;margin-bottom:8px}.plugin-chart .chart-x-label,.plugin-chart .chart-y-label{font-size:10px;color:#6c6c84;margin-bottom:4px}.plugin-chart .chart-data-table{overflow-x:auto}.plugin-chart .chart-no-data{padding:24px;text-align:center;color:#6c6c84;font-size:12px}.plugin-loading{padding:16px;color:#a0a0b8;font-size:12px;font-style:italic}.plugin-error{padding:12px 16px;background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:5px;color:#d47070;font-size:12px}.plugin-feedback{position:sticky;bottom:16px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:12px 16px;border-radius:7px;font-size:12px;z-index:300;box-shadow:0 4px 20px rgba(0,0,0,.45)}.plugin-feedback.success{background:rgba(62,201,122,.08);border:1px solid rgba(62,201,122,.2);color:#3ec97a}.plugin-feedback.error{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);color:#d47070}.plugin-feedback-dismiss{background:rgba(0,0,0,0);border:none;color:inherit;font-size:14px;cursor:pointer;line-height:1;padding:0;opacity:.7}.plugin-feedback-dismiss:hover{opacity:1}.plugin-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.65);display:flex;align-items:center;justify-content:center;z-index:100}.plugin-modal{position:relative;background:#1f1f28;border:1px solid rgba(255,255,255,.14);border-radius:12px;padding:32px;min-width:380px;max-width:640px;max-height:80vh;overflow-y:auto;box-shadow:0 4px 20px rgba(0,0,0,.45);z-index:200}.plugin-modal-close{position:absolute;top:16px;right:16px;background:rgba(0,0,0,0);border:none;color:#a0a0b8;font-size:14px;cursor:pointer;line-height:1;padding:4px;border-radius:5px}.plugin-modal-close:hover{background:rgba(255,255,255,.04);color:#dcdce4} \ No newline at end of file +@media (prefers-reduced-motion: reduce){*,*::before,*::after{animation-duration:.01ms !important;animation-iteration-count:1 !important;transition-duration:.01ms !important}}*{margin:0;padding:0;box-sizing:border-box;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.06) rgba(0,0,0,0)}*::-webkit-scrollbar{width:5px;height:5px}*::-webkit-scrollbar-track{background:rgba(0,0,0,0)}*::-webkit-scrollbar-thumb{background:rgba(255,255,255,.06);border-radius:3px}*::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.14)}:root{--bg-0: #111118;--bg-1: #18181f;--bg-2: #1f1f28;--bg-3: #26263a;--border-subtle: rgba(255,255,255,.06);--border: rgba(255,255,255,.09);--border-strong: rgba(255,255,255,.14);--text-0: #dcdce4;--text-1: #a0a0b8;--text-2: #6c6c84;--accent: #7c7ef5;--accent-dim: rgba(124,126,245,.15);--accent-text: #9698f7;--success: #3ec97a;--error: #e45858;--warning: #d4a037;--radius-sm: 3px;--radius: 5px;--radius-md: 7px;--shadow-sm: 0 1px 3px rgba(0,0,0,.3);--shadow: 0 2px 8px rgba(0,0,0,.35);--shadow-lg: 0 4px 20px rgba(0,0,0,.45)}body{font-family:"Inter",-apple-system,"Segoe UI",system-ui,sans-serif;background:var(--bg-0);color:var(--text-0);font-size:13px;line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden}:focus-visible:focus-visible{outline:2px solid #7c7ef5;outline-offset:2px}::selection{background:rgba(124,126,245,.15);color:#9698f7}a{color:#9698f7;text-decoration:none}a:hover{text-decoration:underline}code{padding:1px 5px;border-radius:3px;background:#111118;color:#9698f7;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px}ul{list-style:none;padding:0}ul li{padding:3px 0;font-size:12px;color:#a0a0b8}.text-muted{color:#a0a0b8}.text-sm{font-size:11px}.mono{font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px}.flex-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px}.flex-between{display:flex;justify-content:space-between;align-items:center}.mb-16{margin-bottom:16px}.mb-8{margin-bottom:12px}.mt-16{margin-top:16px}.mt-8{margin-top:12px}@keyframes fade-in{from{opacity:0}to{opacity:1}}@keyframes slide-up{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}@keyframes pulse{0%, 100%{opacity:1}50%{opacity:.3}}@keyframes spin{to{transform:rotate(360deg)}}@keyframes skeleton-pulse{0%{opacity:.6}50%{opacity:.3}100%{opacity:.6}}@keyframes indeterminate{0%{transform:translateX(-100%)}100%{transform:translateX(400%)}}.app{display:flex;flex-direction:row;justify-content:flex-start;align-items:stretch;height:100vh;overflow:hidden}.sidebar{width:220px;min-width:220px;max-width:220px;background:#18181f;border-right:1px solid rgba(255,255,255,.09);display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;flex-shrink:0;user-select:none;overflow-y:auto;overflow-x:hidden;z-index:10;transition:width .15s,min-width .15s,max-width .15s}.sidebar.collapsed{width:48px;min-width:48px;max-width:48px}.sidebar.collapsed .nav-label,.sidebar.collapsed .sidebar-header .logo,.sidebar.collapsed .sidebar-header .version,.sidebar.collapsed .nav-badge,.sidebar.collapsed .nav-item-text,.sidebar.collapsed .sidebar-footer .status-text,.sidebar.collapsed .user-name,.sidebar.collapsed .role-badge,.sidebar.collapsed .user-info .btn,.sidebar.collapsed .sidebar-import-header span,.sidebar.collapsed .sidebar-import-file{display:none}.sidebar.collapsed .nav-item{justify-content:center;padding:8px;border-left:none;border-radius:3px}.sidebar.collapsed .nav-item.active{border-left:none}.sidebar.collapsed .nav-icon{width:auto;margin:0}.sidebar.collapsed .sidebar-header{padding:12px 8px;justify-content:center}.sidebar.collapsed .nav-section{padding:0 4px}.sidebar.collapsed .sidebar-footer{padding:8px}.sidebar.collapsed .sidebar-footer .user-info{justify-content:center;padding:4px}.sidebar.collapsed .sidebar-import-progress{padding:6px}.sidebar-header{padding:16px 16px 20px;display:flex;flex-direction:row;justify-content:flex-start;align-items:baseline;gap:8px}.sidebar-header .logo{font-size:15px;font-weight:700;letter-spacing:-.4px;color:#dcdce4}.sidebar-header .version{font-size:10px;color:#6c6c84}.sidebar-toggle{background:rgba(0,0,0,0);border:none;color:#6c6c84;padding:8px;font-size:18px;width:100%;text-align:center}.sidebar-toggle:hover{color:#dcdce4}.sidebar-spacer{flex:1}.sidebar-footer{padding:12px;border-top:1px solid rgba(255,255,255,.06);overflow:visible;min-width:0}.nav-section{padding:0 8px;margin-bottom:2px}.nav-label{padding:8px 8px 4px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#6c6c84}.nav-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:6px 8px;border-radius:3px;cursor:pointer;color:#a0a0b8;font-size:13px;font-weight:450;transition:color .1s,background .1s;border:none;background:none;width:100%;text-align:left;border-left:2px solid rgba(0,0,0,0);margin-left:0}.nav-item:hover{color:#dcdce4;background:rgba(255,255,255,.03)}.nav-item.active{color:#9698f7;border-left-color:#7c7ef5;background:rgba(124,126,245,.15)}.nav-item-text{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.sidebar:not(.collapsed) .nav-item-text{overflow:visible}.nav-icon{width:18px;text-align:center;font-size:14px;opacity:.7}.nav-badge{margin-left:auto;font-size:10px;font-weight:600;color:#6c6c84;background:#26263a;padding:1px 6px;border-radius:12px;min-width:20px;text-align:center;font-variant-numeric:tabular-nums}.status-indicator{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:6px;font-size:11px;font-weight:500;min-width:0;overflow:visible}.sidebar:not(.collapsed) .status-indicator{justify-content:flex-start}.status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}.status-dot.connected{background:#3ec97a}.status-dot.disconnected{background:#e45858}.status-dot.checking{background:#d4a037;animation:pulse 1.5s infinite}.status-text{color:#6c6c84;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.sidebar:not(.collapsed) .status-text{overflow:visible}.main{flex:1;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;overflow:hidden;min-width:0}.header{height:48px;min-height:48px;border-bottom:1px solid rgba(255,255,255,.06);display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:0 20px;background:#18181f}.page-title{font-size:14px;font-weight:600;color:#dcdce4}.header-spacer{flex:1}.content{flex:1;overflow-y:auto;padding:20px;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.06) rgba(0,0,0,0)}.sidebar-import-progress{padding:10px 12px;background:#1f1f28;border-top:1px solid rgba(255,255,255,.06);font-size:11px}.sidebar-import-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;margin-bottom:4px;color:#a0a0b8}.sidebar-import-file{color:#6c6c84;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}.sidebar-import-progress .progress-bar{height:3px}.user-info{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:12px;overflow:hidden;min-width:0}.user-name{font-weight:500;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:90px;flex-shrink:1}.role-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase}.role-badge.role-admin{background:rgba(139,92,246,.1);color:#9d8be0}.role-badge.role-editor{background:rgba(34,160,80,.1);color:#5cb97a}.role-badge.role-viewer{background:rgba(59,120,200,.1);color:#6ca0d4}.btn{padding:5px 12px;border-radius:3px;border:none;cursor:pointer;font-size:12px;font-weight:500;transition:all .1s;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;line-height:1.5}.btn-primary{background:#7c7ef5;color:#fff}.btn-primary:hover{background:#8b8df7}.btn-secondary{background:#26263a;color:#dcdce4;border:1px solid rgba(255,255,255,.09)}.btn-secondary:hover{border-color:rgba(255,255,255,.14);background:rgba(255,255,255,.06)}.btn-danger{background:rgba(0,0,0,0);color:#e45858;border:1px solid rgba(228,88,88,.25)}.btn-danger:hover{background:rgba(228,88,88,.08)}.btn-ghost{background:rgba(0,0,0,0);border:none;color:#a0a0b8;padding:5px 8px}.btn-ghost:hover{color:#dcdce4;background:rgba(255,255,255,.04)}.btn-sm{padding:3px 8px;font-size:11px}.btn-icon{padding:4px;border-radius:3px;background:rgba(0,0,0,0);border:none;color:#6c6c84;cursor:pointer;transition:color .1s;font-size:13px}.btn-icon:hover{color:#dcdce4}.btn:disabled,.btn[disabled]{opacity:.4;cursor:not-allowed;pointer-events:none}.btn.btn-disabled-hint:disabled{opacity:.6;border-style:dashed;pointer-events:auto;cursor:help}.card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px}.card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.card-title{font-size:14px;font-weight:600}.card-body{padding-top:8px}.data-table{width:100%;border-collapse:collapse;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden}.data-table thead th{padding:8px 14px;text-align:left;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#6c6c84;border-bottom:1px solid rgba(255,255,255,.09);background:#26263a}.data-table tbody td{padding:8px 14px;font-size:13px;border-bottom:1px solid rgba(255,255,255,.06);max-width:300px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.data-table tbody tr{cursor:pointer;transition:background .08s}.data-table tbody tr:hover{background:rgba(255,255,255,.02)}.data-table tbody tr.row-selected{background:rgba(99,102,241,.12)}.data-table tbody tr:last-child td{border-bottom:none}.sortable-header{cursor:pointer;user-select:none;transition:color .1s}.sortable-header:hover{color:#9698f7}input[type=text],textarea,select{padding:6px 10px;border-radius:3px;border:1px solid rgba(255,255,255,.09);background:#111118;color:#dcdce4;font-size:13px;outline:none;transition:border-color .15s;font-family:inherit}input[type=text]::placeholder,textarea::placeholder,select::placeholder{color:#6c6c84}input[type=text]:focus,textarea:focus,select:focus{border-color:#7c7ef5}input[type=text][type=number],textarea[type=number],select[type=number]{width:80px;padding:6px 8px;-moz-appearance:textfield}input[type=text][type=number]::-webkit-outer-spin-button,input[type=text][type=number]::-webkit-inner-spin-button,textarea[type=number]::-webkit-outer-spin-button,textarea[type=number]::-webkit-inner-spin-button,select[type=number]::-webkit-outer-spin-button,select[type=number]::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}textarea{min-height:64px;resize:vertical}select{appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%236c6c84' d='M5 7L1 3h8z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 8px center;padding-right:26px;min-width:100px}.form-group{margin-bottom:12px}.form-label{display:block;font-size:11px;font-weight:600;color:#a0a0b8;margin-bottom:4px;text-transform:uppercase;letter-spacing:.03em}.form-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:flex-end;gap:8px}.form-row input[type=text]{flex:1}.form-label-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:4px}.form-label-row .form-label{margin-bottom:0}.badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:50%;font-size:9px;font-weight:600;letter-spacing:.5px;text-transform:uppercase}.badge-neutral{background:rgba(255,255,255,.04);color:#a0a0b8}.badge-success{background:rgba(62,201,122,.08);color:#3ec97a}.badge-warning{background:rgba(212,160,55,.06);color:#d4a037}.badge-danger{background:rgba(228,88,88,.06);color:#d47070}.tab-bar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px;border-bottom:1px solid rgba(255,255,255,.09);margin-bottom:12px}.tab-btn{background:rgba(0,0,0,0);border:none;padding:6px 8px;font-size:13px;color:#a0a0b8;border-bottom:2px solid rgba(0,0,0,0);margin-bottom:-1px;cursor:pointer;transition:color .1s,border-color .1s}.tab-btn:hover{color:#dcdce4}.tab-btn.active{color:#9698f7;border-bottom-color:#7c7ef5}.input-sm{padding:6px 10px;border-radius:3px;border:1px solid rgba(255,255,255,.09);background:#111118;color:#dcdce4;font-size:13px;outline:none;transition:border-color .15s;font-family:inherit;padding:3px 8px;font-size:12px}.input-sm::placeholder{color:#6c6c84}.input-sm:focus{border-color:#7c7ef5}.input-suffix{display:flex;flex-direction:row;justify-content:flex-start;align-items:stretch}.input-suffix input{border-radius:3px 0 0 3px;border-right:none;flex:1}.input-suffix .btn{border-radius:0 3px 3px 0}.field-error{color:#d47070;font-size:11px;margin-top:2px}.comments-list{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px}.comment-item{padding:8px;background:#18181f;border-radius:5px;border:1px solid rgba(255,255,255,.06)}.comment-text{font-size:13px;color:#dcdce4;line-height:1.5;margin-top:4px}input[type=checkbox]{appearance:none;-webkit-appearance:none;width:16px;height:16px;border:1px solid rgba(255,255,255,.14);border-radius:3px;background:#1f1f28;cursor:pointer;position:relative;flex-shrink:0;transition:all .15s ease}input[type=checkbox]:hover{border-color:#7c7ef5;background:#26263a}input[type=checkbox]:checked{background:#7c7ef5;border-color:#7c7ef5}input[type=checkbox]:checked::after{content:"";position:absolute;left:5px;top:2px;width:4px;height:8px;border:solid #111118;border-width:0 2px 2px 0;transform:rotate(45deg)}input[type=checkbox]:focus-visible:focus-visible{outline:2px solid #7c7ef5;outline-offset:2px}.checkbox-label{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#a0a0b8;user-select:none}.checkbox-label:hover{color:#dcdce4}.checkbox-label input[type=checkbox]{margin:0}.toggle{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#dcdce4}.toggle.disabled{opacity:.4;cursor:not-allowed}.toggle-track{width:32px;height:18px;border-radius:9px;background:#26263a;border:1px solid rgba(255,255,255,.09);position:relative;transition:background .15s;flex-shrink:0}.toggle-track.active{background:#7c7ef5;border-color:#7c7ef5}.toggle-track.active .toggle-thumb{transform:translateX(14px)}.toggle-thumb{width:14px;height:14px;border-radius:50%;background:#dcdce4;position:absolute;top:1px;left:1px;transition:transform .15s}.filter-bar{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:12px;padding:12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:5px;margin-bottom:12px}.filter-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.filter-label{font-size:11px;font-weight:500;color:#6c6c84;text-transform:uppercase;letter-spacing:.5px;margin-right:4px}.filter-chip{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:14px;cursor:pointer;font-size:11px;color:#a0a0b8;transition:all .15s ease;user-select:none}.filter-chip:hover{background:#26263a;border-color:rgba(255,255,255,.14);color:#dcdce4}.filter-chip.active{background:rgba(124,126,245,.15);border-color:#7c7ef5;color:#9698f7}.filter-chip input[type=checkbox]{width:12px;height:12px;margin:0}.filter-chip input[type=checkbox]:checked::after{left:3px;top:1px;width:3px;height:6px}.filter-group{display:flex;align-items:center;gap:6px}.filter-group label{display:flex;align-items:center;gap:3px;cursor:pointer;color:#a0a0b8;font-size:11px;white-space:nowrap}.filter-group label:hover{color:#dcdce4}.filter-separator{width:1px;height:20px;background:rgba(255,255,255,.09);flex-shrink:0}.view-toggle{display:flex;border:1px solid rgba(255,255,255,.09);border-radius:3px;overflow:hidden}.view-btn{padding:4px 10px;background:#1f1f28;border:none;color:#6c6c84;cursor:pointer;font-size:18px;line-height:1;transition:background .1s,color .1s}.view-btn:first-child{border-right:1px solid rgba(255,255,255,.09)}.view-btn:hover{color:#dcdce4;background:#26263a}.view-btn.active{background:rgba(124,126,245,.15);color:#9698f7}.breadcrumb{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px;padding:10px 16px;font-size:.85rem;color:#6c6c84}.breadcrumb-sep{color:#6c6c84;opacity:.5}.breadcrumb-link{color:#9698f7;text-decoration:none;cursor:pointer}.breadcrumb-link:hover{text-decoration:underline}.breadcrumb-current{color:#dcdce4;font-weight:500}.progress-bar{width:100%;height:8px;background:#26263a;border-radius:4px;overflow:hidden;margin-bottom:6px}.progress-fill{height:100%;background:#7c7ef5;border-radius:4px;transition:width .3s ease}.progress-fill.indeterminate{width:30%;animation:indeterminate 1.5s ease-in-out infinite}.loading-overlay{display:flex;align-items:center;justify-content:center;padding:48px 16px;color:#6c6c84;font-size:13px;gap:10px}.spinner{width:18px;height:18px;border:2px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.spinner-small{width:14px;height:14px;border:2px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.spinner-tiny{width:10px;height:10px;border:1.5px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:100;animation:fade-in .1s ease-out}.modal{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:20px;min-width:360px;max-width:480px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.modal.wide{max-width:600px;max-height:70vh;overflow-y:auto}.modal-title{font-size:15px;font-weight:600;margin-bottom:6px}.modal-body{font-size:12px;color:#a0a0b8;margin-bottom:16px;line-height:1.5}.modal-actions{display:flex;flex-direction:row;justify-content:flex-end;align-items:center;gap:6px}.tooltip-trigger{display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border-radius:50%;background:#26263a;color:#6c6c84;font-size:9px;font-weight:700;cursor:help;position:relative;flex-shrink:0;margin-left:4px}.tooltip-trigger:hover{background:rgba(124,126,245,.15);color:#9698f7}.tooltip-trigger:hover .tooltip-text{display:block}.tooltip-text{display:none;position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);padding:6px 10px;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#dcdce4;font-size:11px;font-weight:400;line-height:1.4;white-space:normal;width:220px;text-transform:none;letter-spacing:normal;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:100;pointer-events:none}.media-player{position:relative;background:#111118;border-radius:5px;overflow:hidden}.media-player:focus{outline:none}.media-player-audio .player-artwork{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:8px;padding:24px 16px 8px}.player-native-video{width:100%;display:block}.player-native-audio{display:none}.player-artwork img{max-width:200px;max-height:200px;border-radius:5px;object-fit:cover}.player-artwork-placeholder{width:120px;height:120px;display:flex;align-items:center;justify-content:center;background:#1f1f28;border-radius:5px;font-size:48px;opacity:.3}.player-title{font-size:13px;font-weight:500;color:#dcdce4;text-align:center}.player-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 14px;background:#1f1f28}.media-player-video .player-controls{position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.7);opacity:0;transition:opacity .2s}.media-player-video:hover .player-controls{opacity:1}.play-btn,.mute-btn,.fullscreen-btn{background:none;border:none;color:#dcdce4;cursor:pointer;font-size:18px;padding:4px;line-height:1;transition:color .1s}.play-btn:hover,.mute-btn:hover,.fullscreen-btn:hover{color:#9698f7}.player-time{font-size:11px;color:#6c6c84;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;min-width:36px;text-align:center;user-select:none}.seek-bar{flex:1;-webkit-appearance:none;appearance:none;height:4px;border-radius:4px;background:#26263a;outline:none;cursor:pointer}.seek-bar::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:12px;height:12px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none}.seek-bar::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none}.volume-slider{width:70px;-webkit-appearance:none;appearance:none;height:4px;border-radius:4px;background:#26263a;outline:none;cursor:pointer}.volume-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:10px;height:10px;border-radius:50%;background:#a0a0b8;cursor:pointer;border:none}.volume-slider::-moz-range-thumb{width:10px;height:10px;border-radius:50%;background:#a0a0b8;cursor:pointer;border:none}.image-viewer-overlay{position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:150;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;animation:fade-in .15s ease-out}.image-viewer-overlay:focus{outline:none}.image-viewer-toolbar{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;background:rgba(0,0,0,.5);border-bottom:1px solid rgba(255,255,255,.08);z-index:2;user-select:none}.image-viewer-toolbar-left,.image-viewer-toolbar-center,.image-viewer-toolbar-right{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px}.iv-btn{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);color:#dcdce4;border-radius:3px;padding:4px 10px;font-size:12px;cursor:pointer;transition:background .1s}.iv-btn:hover{background:rgba(255,255,255,.12)}.iv-btn.iv-close{color:#e45858;font-weight:600}.iv-zoom-label{font-size:11px;color:#a0a0b8;min-width:40px;text-align:center;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace}.image-viewer-canvas{flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative}.image-viewer-canvas img{max-width:100%;max-height:100%;object-fit:contain;user-select:none;-webkit-user-drag:none}.pdf-viewer{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;height:100%;min-height:500px;background:#111118;border-radius:5px;overflow:hidden}.pdf-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:10px 12px;background:#18181f;border-bottom:1px solid rgba(255,255,255,.09)}.pdf-toolbar-group{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px}.pdf-toolbar-btn{display:flex;align-items:center;justify-content:center;width:28px;height:28px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#a0a0b8;font-size:14px;cursor:pointer;transition:all .15s}.pdf-toolbar-btn:hover:not(:disabled){background:#26263a;color:#dcdce4}.pdf-toolbar-btn:disabled{opacity:.4;cursor:not-allowed}.pdf-zoom-label{min-width:45px;text-align:center;font-size:12px;color:#a0a0b8}.pdf-container{flex:1;position:relative;overflow:hidden;background:#1f1f28}.pdf-object{width:100%;height:100%;border:none}.pdf-loading,.pdf-error{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:12px;background:#18181f;color:#a0a0b8}.pdf-error{padding:12px;text-align:center}.pdf-fallback{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:16px;padding:48px 12px;text-align:center;color:#6c6c84}.markdown-viewer{padding:16px;text-align:left;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:12px}.markdown-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:8px;background:#1f1f28;border-radius:5px;border:1px solid rgba(255,255,255,.09)}.toolbar-btn{padding:6px 12px;border:1px solid rgba(255,255,255,.09);border-radius:3px;background:#18181f;color:#a0a0b8;font-size:13px;font-weight:500;cursor:pointer;transition:all .15s}.toolbar-btn:hover{background:#26263a;border-color:rgba(255,255,255,.14)}.toolbar-btn.active{background:#7c7ef5;color:#fff;border-color:#7c7ef5}.markdown-source{max-width:100%;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px;overflow-x:auto;font-family:"Menlo","Monaco","Courier New",monospace;font-size:13px;line-height:1.7;color:#dcdce4;white-space:pre-wrap;word-wrap:break-word}.markdown-source code{font-family:inherit;background:none;padding:0;border:none}.markdown-content{max-width:800px;color:#dcdce4;line-height:1.7;font-size:14px;text-align:left}.markdown-content h1{font-size:1.8em;font-weight:700;margin:1em 0 .5em;border-bottom:1px solid rgba(255,255,255,.06);padding-bottom:.3em}.markdown-content h2{font-size:1.5em;font-weight:600;margin:.8em 0 .4em;border-bottom:1px solid rgba(255,255,255,.06);padding-bottom:.2em}.markdown-content h3{font-size:1.25em;font-weight:600;margin:.6em 0 .3em}.markdown-content h4{font-size:1.1em;font-weight:600;margin:.5em 0 .25em}.markdown-content h5,.markdown-content h6{font-size:1em;font-weight:600;margin:.4em 0 .2em;color:#a0a0b8}.markdown-content p{margin:0 0 1em}.markdown-content a{color:#7c7ef5;text-decoration:none}.markdown-content a:hover{text-decoration:underline}.markdown-content pre{background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;padding:12px 16px;overflow-x:auto;margin:0 0 1em;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;line-height:1.5}.markdown-content code{background:#26263a;padding:1px 5px;border-radius:3px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:.9em}.markdown-content pre code{background:none;padding:0}.markdown-content blockquote{border-left:3px solid #7c7ef5;padding:4px 16px;margin:0 0 1em;color:#a0a0b8;background:rgba(124,126,245,.04)}.markdown-content table{width:100%;border-collapse:collapse;margin:0 0 1em}.markdown-content th,.markdown-content td{padding:6px 12px;border:1px solid rgba(255,255,255,.09);font-size:13px}.markdown-content th{background:#26263a;font-weight:600;text-align:left}.markdown-content tr:nth-child(even){background:#1f1f28}.markdown-content ul,.markdown-content ol{margin:0 0 1em;padding-left:16px}.markdown-content ul{list-style:disc}.markdown-content ol{list-style:decimal}.markdown-content li{padding:2px 0;font-size:14px;color:#dcdce4}.markdown-content hr{border:none;border-top:1px solid rgba(255,255,255,.09);margin:1.5em 0}.markdown-content img{max-width:100%;border-radius:5px}.markdown-content .footnote-definition{font-size:.85em;color:#a0a0b8;margin-top:.5em;padding-left:1.5em}.markdown-content .footnote-definition sup{color:#7c7ef5;margin-right:4px}.markdown-content sup a{color:#7c7ef5;text-decoration:none;font-size:.8em}.wikilink{color:#9698f7;text-decoration:none;border-bottom:1px dashed #7c7ef5;cursor:pointer;transition:border-color .1s,color .1s}.wikilink:hover{color:#7c7ef5;border-bottom-style:solid}.wikilink-embed{display:inline-block;padding:2px 8px;background:rgba(139,92,246,.08);border:1px dashed rgba(139,92,246,.3);border-radius:3px;color:#9d8be0;font-size:12px;cursor:default}.media-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:12px}.media-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden;cursor:pointer;transition:border-color .12s,box-shadow .12s;position:relative}.media-card:hover{border-color:rgba(255,255,255,.14);box-shadow:0 1px 3px rgba(0,0,0,.3)}.media-card.selected{border-color:#7c7ef5;box-shadow:0 0 0 1px #7c7ef5}.card-checkbox{position:absolute;top:6px;left:6px;z-index:2;opacity:0;transition:opacity .1s}.card-checkbox input[type=checkbox]{width:16px;height:16px;cursor:pointer;filter:drop-shadow(0 1px 2px rgba(0,0,0,.5))}.media-card:hover .card-checkbox,.media-card.selected .card-checkbox{opacity:1}.card-thumbnail{width:100%;aspect-ratio:1;background:#111118;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative}.card-thumbnail img,.card-thumbnail .card-thumb-img{width:100%;height:100%;object-fit:cover;position:absolute;top:0;left:0;z-index:1}.card-type-icon{font-size:32px;opacity:.4;display:flex;align-items:center;justify-content:center;width:100%;height:100%;position:absolute;top:0;left:0;z-index:0}.card-info{padding:8px 10px}.card-name{font-size:12px;font-weight:500;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}.card-title,.card-artist{font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.3}.card-meta{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:10px}.card-size{color:#6c6c84;font-size:10px}.table-thumb-cell{width:36px;padding:4px 6px !important;position:relative}.table-thumb{width:28px;height:28px;object-fit:cover;border-radius:3px;display:block}.table-thumb-overlay{position:absolute;top:4px;left:6px;z-index:1}.table-type-icon{display:flex;align-items:center;justify-content:center;width:28px;height:28px;font-size:14px;opacity:.5;border-radius:3px;background:#111118;z-index:0}.type-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.type-badge.type-audio{background:rgba(139,92,246,.1);color:#9d8be0}.type-badge.type-video{background:rgba(200,72,130,.1);color:#d07eaa}.type-badge.type-image{background:rgba(34,160,80,.1);color:#5cb97a}.type-badge.type-document{background:rgba(59,120,200,.1);color:#6ca0d4}.type-badge.type-text{background:rgba(200,160,36,.1);color:#c4a840}.type-badge.type-other{background:rgba(128,128,160,.08);color:#6c6c84}.tag-list{display:flex;flex-wrap:wrap;gap:4px}.tag-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 10px;background:rgba(124,126,245,.15);color:#9698f7;border-radius:12px;font-size:11px;font-weight:500}.tag-badge.selected{background:#7c7ef5;color:#fff;cursor:pointer}.tag-badge:not(.selected){cursor:pointer}.tag-badge .tag-remove{cursor:pointer;opacity:.4;font-size:13px;line-height:1;transition:opacity .1s}.tag-badge .tag-remove:hover{opacity:1}.tag-group{margin-bottom:6px}.tag-children{margin-left:16px;margin-top:4px;display:flex;flex-wrap:wrap;gap:4px}.tag-confirm-delete{display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#a0a0b8}.tag-confirm-yes{cursor:pointer;color:#e45858;font-weight:600}.tag-confirm-yes:hover{text-decoration:underline}.tag-confirm-no{cursor:pointer;color:#6c6c84;font-weight:500}.tag-confirm-no:hover{text-decoration:underline}.detail-actions{display:flex;gap:6px;margin-bottom:16px}.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.detail-field{padding:10px 12px;background:#111118;border-radius:3px;border:1px solid rgba(255,255,255,.06)}.detail-field.full-width{grid-column:1/-1}.detail-field input[type=text],.detail-field textarea,.detail-field select{width:100%;margin-top:4px}.detail-field textarea{min-height:64px;resize:vertical}.detail-label{font-size:10px;font-weight:600;color:#6c6c84;text-transform:uppercase;letter-spacing:.04em;margin-bottom:2px}.detail-value{font-size:13px;color:#dcdce4;word-break:break-all}.detail-value.mono{font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px;color:#a0a0b8}.detail-preview{margin-bottom:16px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:5px;overflow:hidden;text-align:center}.detail-preview:has(.markdown-viewer){max-height:none;overflow-y:auto;text-align:left}.detail-preview:not(:has(.markdown-viewer)){max-height:450px}.detail-preview img{max-width:100%;max-height:400px;object-fit:contain;display:block;margin:0 auto}.detail-preview audio{width:100%;padding:16px}.detail-preview video{max-width:100%;max-height:400px;display:block;margin:0 auto}.detail-no-preview{padding:16px 16px;text-align:center;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px}.frontmatter-card{max-width:800px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:12px 16px;margin-bottom:16px}.frontmatter-fields{display:grid;grid-template-columns:auto 1fr;gap:4px 12px;margin:0}.frontmatter-fields dt{font-weight:600;font-size:12px;color:#a0a0b8;text-transform:capitalize}.frontmatter-fields dd{font-size:13px;color:#dcdce4;margin:0}.empty-state{text-align:center;padding:48px 12px;color:#6c6c84}.empty-state .empty-icon{font-size:32px;margin-bottom:12px;opacity:.3}.empty-title{font-size:15px;font-weight:600;color:#a0a0b8;margin-bottom:4px}.empty-subtitle{font-size:12px;max-width:320px;margin:0 auto;line-height:1.5}.toast-container{position:fixed;bottom:16px;right:16px;z-index:300;display:flex;flex-direction:column-reverse;gap:6px;align-items:flex-end}.toast-container .toast{position:static;transform:none}.toast{position:fixed;bottom:16px;right:16px;padding:10px 16px;border-radius:5px;background:#26263a;border:1px solid rgba(255,255,255,.09);color:#dcdce4;font-size:12px;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:300;animation:slide-up .15s ease-out;max-width:420px}.toast.success{border-left:3px solid #3ec97a}.toast.error{border-left:3px solid #e45858}.offline-banner,.error-banner{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:3px;padding:10px 12px;margin-bottom:12px;font-size:12px;color:#d47070;display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px}.offline-banner .offline-icon,.offline-banner .error-icon,.error-banner .offline-icon,.error-banner .error-icon{font-size:14px;flex-shrink:0}.error-banner{padding:10px 14px}.readonly-banner{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 12px;background:rgba(212,160,55,.06);border:1px solid rgba(212,160,55,.15);border-radius:3px;margin-bottom:16px;font-size:12px;color:#d4a037}.batch-actions{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:8px 10px;background:rgba(124,126,245,.15);border:1px solid rgba(124,126,245,.2);border-radius:3px;margin-bottom:12px;font-size:12px;font-weight:500;color:#9698f7}.select-all-banner{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:8px;padding:10px 16px;background:rgba(99,102,241,.08);border-radius:6px;margin-bottom:8px;font-size:.85rem;color:#a0a0b8}.select-all-banner button{background:none;border:none;color:#7c7ef5;cursor:pointer;font-weight:600;text-decoration:underline;font-size:.85rem;padding:0}.select-all-banner button:hover{color:#dcdce4}.import-status-panel{background:#1f1f28;border:1px solid #7c7ef5;border-radius:5px;padding:12px 16px;margin-bottom:16px}.import-status-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:8px;font-size:13px;color:#dcdce4}.import-current-file{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:6px;font-size:12px;overflow:hidden}.import-file-label{color:#6c6c84;flex-shrink:0}.import-file-name{color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:monospace;font-size:11px}.import-queue-indicator{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:8px;font-size:11px}.import-queue-badge{display:flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 6px;background:rgba(124,126,245,.15);color:#9698f7;border-radius:9px;font-weight:600;font-size:10px}.import-queue-text{color:#6c6c84}.import-tabs{display:flex;gap:0;margin-bottom:16px;border-bottom:1px solid rgba(255,255,255,.09)}.import-tab{padding:10px 16px;background:none;border:none;border-bottom:2px solid rgba(0,0,0,0);color:#6c6c84;font-size:12px;font-weight:500;cursor:pointer;transition:color .1s,border-color .1s}.import-tab:hover{color:#dcdce4}.import-tab.active{color:#9698f7;border-bottom-color:#7c7ef5}.queue-panel{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;border-left:1px solid rgba(255,255,255,.09);background:#18181f;min-width:280px;max-width:320px}.queue-header{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid rgba(255,255,255,.06)}.queue-header h3{margin:0;font-size:.9rem;color:#dcdce4}.queue-controls{display:flex;gap:2px}.queue-list{overflow-y:auto;flex:1}.queue-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;padding:8px 16px;cursor:pointer;border-bottom:1px solid rgba(255,255,255,.06);transition:background .15s}.queue-item:hover{background:#1f1f28}.queue-item:hover .queue-item-remove{opacity:1}.queue-item-active{background:rgba(124,126,245,.15);border-left:3px solid #7c7ef5}.queue-item-info{flex:1;min-width:0}.queue-item-title{display:block;font-size:.85rem;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.queue-item-artist{display:block;font-size:.75rem;color:#6c6c84}.queue-item-remove{opacity:0;transition:opacity .15s}.queue-empty{padding:16px 16px;text-align:center;color:#6c6c84;font-size:.85rem}.statistics-page{padding:20px}.stats-overview,.stats-grid{display:grid;grid-template-columns:repeat(3, 1fr);gap:16px;margin-bottom:24px}@media (max-width: 768px){.stats-overview,.stats-grid{grid-template-columns:1fr}}.stat-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:20px;display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:16px}.stat-card.stat-primary{border-left:3px solid #7c7ef5}.stat-card.stat-success{border-left:3px solid #3ec97a}.stat-card.stat-info{border-left:3px solid #6ca0d4}.stat-card.stat-warning{border-left:3px solid #d4a037}.stat-card.stat-purple{border-left:3px solid #9d8be0}.stat-card.stat-danger{border-left:3px solid #e45858}.stat-icon{flex-shrink:0;color:#6c6c84}.stat-content{flex:1}.stat-value{font-size:28px;font-weight:700;color:#dcdce4;line-height:1.2;font-variant-numeric:tabular-nums}.stat-label{font-size:12px;color:#6c6c84;margin-top:4px;font-weight:500}.stats-section{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px;margin-bottom:20px}.section-title{font-size:16px;font-weight:600;color:#dcdce4;margin-bottom:20px}.section-title.small{font-size:14px;margin-bottom:12px;padding-bottom:6px;border-bottom:1px solid rgba(255,255,255,.06)}.chart-bars{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:16px}.bar-item{display:grid;grid-template-columns:120px 1fr 80px;align-items:center;gap:16px}.bar-label{font-size:13px;font-weight:500;color:#a0a0b8;text-align:right}.bar-track{height:28px;background:#26263a;border-radius:3px;overflow:hidden;position:relative}.bar-fill{height:100%;transition:width .6s cubic-bezier(.4, 0, .2, 1);border-radius:3px}.bar-fill.bar-primary{background:linear-gradient(90deg, #7c7ef5 0%, #7c7ef3 100%)}.bar-fill.bar-success{background:linear-gradient(90deg, #3ec97a 0%, #66bb6a 100%)}.bar-value{font-size:13px;font-weight:600;color:#a0a0b8;text-align:right;font-variant-numeric:tabular-nums}.settings-section{margin-bottom:16px}.settings-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:20px;margin-bottom:16px}.settings-card.danger-card{border:1px solid rgba(228,88,88,.25)}.settings-card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid rgba(255,255,255,.06)}.settings-card-title{font-size:14px;font-weight:600}.settings-card-body{padding-top:2px}.settings-field{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.06)}.settings-field:last-child{border-bottom:none}.settings-field select{min-width:120px}.config-path{font-size:11px;color:#6c6c84;margin-bottom:12px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;padding:6px 10px;background:#111118;border-radius:3px;border:1px solid rgba(255,255,255,.06)}.config-status{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600}.config-status.writable{background:rgba(62,201,122,.1);color:#3ec97a}.config-status.readonly{background:rgba(228,88,88,.1);color:#e45858}.root-list{list-style:none}.root-item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:3px;margin-bottom:4px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;color:#a0a0b8}.info-row{display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid rgba(255,255,255,.06);font-size:13px}.info-row:last-child{border-bottom:none}.info-label{color:#a0a0b8;font-weight:500}.info-value{color:#dcdce4}.tasks-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(400px, 1fr));gap:16px;padding:12px}.task-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden;transition:all .2s}.task-card:hover{border-color:rgba(255,255,255,.14);box-shadow:0 4px 12px rgba(0,0,0,.08);transform:translateY(-2px)}.task-card-enabled{border-left:3px solid #3ec97a}.task-card-disabled{border-left:3px solid #4a4a5e;opacity:.7}.task-card-header{display:flex;justify-content:space-between;align-items:center;align-items:flex-start;padding:16px;border-bottom:1px solid rgba(255,255,255,.06)}.task-header-left{flex:1;min-width:0}.task-name{font-size:16px;font-weight:600;color:#dcdce4;margin-bottom:2px}.task-schedule{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:12px;color:#6c6c84;font-family:"Menlo","Monaco","Courier New",monospace}.schedule-icon{font-size:14px}.task-status-badge{flex-shrink:0}.status-badge{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:2px 10px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}.status-badge.status-enabled{background:rgba(76,175,80,.12);color:#3ec97a}.status-badge.status-enabled .status-dot{animation:pulse 1.5s infinite}.status-badge.status-disabled{background:#26263a;color:#6c6c84}.status-badge .status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;background:currentColor}.task-info-grid{display:grid;grid-template-columns:repeat(auto-fit, minmax(120px, 1fr));gap:12px;padding:16px}.task-info-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:flex-start;gap:10px}.task-info-icon{font-size:18px;color:#6c6c84;flex-shrink:0}.task-info-content{flex:1;min-width:0}.task-info-label{font-size:10px;color:#6c6c84;font-weight:600;text-transform:uppercase;letter-spacing:.03em;margin-bottom:2px}.task-info-value{font-size:12px;color:#a0a0b8;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.task-card-actions{display:flex;gap:8px;padding:10px 16px;background:#18181f;border-top:1px solid rgba(255,255,255,.06)}.task-card-actions button{flex:1}.db-actions{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:16px;padding:10px}.db-action-row{display:flex;flex-direction:row;justify-content:space-between;align-items:center;gap:16px;padding:10px;border-radius:6px;background:rgba(0,0,0,.06)}.db-action-info{flex:1}.db-action-info h4{font-size:.95rem;font-weight:600;color:#dcdce4;margin-bottom:2px}.db-action-confirm{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;flex-shrink:0}.library-toolbar{display:flex;justify-content:space-between;align-items:center;padding:8px 0;margin-bottom:12px;gap:12px;flex-wrap:wrap}.toolbar-left{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:10px}.toolbar-right{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:10px}.sort-control select,.page-size-control select{padding:4px 24px 4px 8px;font-size:11px;background:#1f1f28}.page-size-control{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px}.library-stats{display:flex;justify-content:space-between;align-items:center;padding:2px 0 6px 0;font-size:11px}.type-filter-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:4px 0;margin-bottom:6px;flex-wrap:wrap}.pagination{display:flex;align-items:center;justify-content:center;gap:4px;margin-top:16px;padding:8px 0}.page-btn{min-width:28px;text-align:center;font-variant-numeric:tabular-nums}.page-ellipsis{color:#6c6c84;padding:0 4px;font-size:12px;user-select:none}.audit-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:12px}.filter-select{padding:4px 24px 4px 8px;font-size:11px;background:#1f1f28}.action-danger{background:rgba(228,88,88,.1);color:#d47070}.action-updated{background:rgba(59,120,200,.1);color:#6ca0d4}.action-collection{background:rgba(34,160,80,.1);color:#5cb97a}.action-collection-remove{background:rgba(212,160,55,.1);color:#c4a840}.action-opened{background:rgba(139,92,246,.1);color:#9d8be0}.action-scanned{background:rgba(128,128,160,.08);color:#6c6c84}.clickable{cursor:pointer;color:#9698f7}.clickable:hover{text-decoration:underline}.clickable-row{cursor:pointer}.clickable-row:hover{background:rgba(255,255,255,.03)}.duplicates-view{padding:0}.duplicates-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.duplicates-header h3{margin:0}.duplicates-summary{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px}.duplicate-group{border:1px solid rgba(255,255,255,.09);border-radius:5px;margin-bottom:8px;overflow:hidden}.duplicate-group-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;width:100%;padding:10px 14px;background:#1f1f28;border:none;cursor:pointer;text-align:left;color:#dcdce4;font-size:13px}.duplicate-group-header:hover{background:#26263a}.expand-icon{font-size:10px;width:14px;flex-shrink:0}.group-name{font-weight:600;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.group-badge{background:#7c7ef5;color:#fff;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;flex-shrink:0}.group-size{flex-shrink:0;font-size:12px}.group-hash{font-size:11px;flex-shrink:0}.duplicate-items{border-top:1px solid rgba(255,255,255,.09)}.duplicate-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.06)}.duplicate-item:last-child{border-bottom:none}.duplicate-item-keep{background:rgba(76,175,80,.06)}.dup-thumb{width:48px;height:48px;flex-shrink:0;border-radius:3px;overflow:hidden}.dup-thumb-img{width:100%;height:100%;object-fit:cover}.dup-thumb-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#26263a;font-size:20px;color:#6c6c84}.dup-info{flex:1;min-width:0}.dup-filename{font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dup-path{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dup-meta{font-size:12px;margin-top:2px}.dup-actions{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;flex-shrink:0}.keep-badge{background:rgba(76,175,80,.12);color:#4caf50;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:600}.saved-searches-list{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:4px;max-height:300px;overflow-y:auto}.saved-search-item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:#18181f;border-radius:3px;cursor:pointer;transition:background .15s ease}.saved-search-item:hover{background:#1f1f28}.saved-search-info{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:2px;flex:1;min-width:0}.saved-search-name{font-weight:500;color:#dcdce4}.saved-search-query{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.backlinks-panel,.outgoing-links-panel{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;margin-top:16px;overflow:hidden}.backlinks-header,.outgoing-links-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 14px;background:#26263a;cursor:pointer;user-select:none;transition:background .1s}.backlinks-header:hover,.outgoing-links-header:hover{background:rgba(255,255,255,.04)}.backlinks-toggle,.outgoing-links-toggle{font-size:10px;color:#6c6c84;width:12px;text-align:center}.backlinks-title,.outgoing-links-title{font-size:12px;font-weight:600;color:#dcdce4;flex:1}.backlinks-count,.outgoing-links-count{font-size:11px;color:#6c6c84}.backlinks-reindex-btn{display:flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;margin-left:auto;background:rgba(0,0,0,0);border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#6c6c84;font-size:12px;cursor:pointer;transition:background .1s,color .1s,border-color .1s}.backlinks-reindex-btn:hover:not(:disabled){background:#1f1f28;color:#dcdce4;border-color:rgba(255,255,255,.14)}.backlinks-reindex-btn:disabled{opacity:.5;cursor:not-allowed}.backlinks-content,.outgoing-links-content{padding:12px;border-top:1px solid rgba(255,255,255,.06)}.backlinks-loading,.outgoing-links-loading{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:12px;color:#6c6c84;font-size:12px}.backlinks-error,.outgoing-links-error{padding:8px 12px;background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:3px;font-size:12px;color:#e45858}.backlinks-empty,.outgoing-links-empty{padding:16px;text-align:center;color:#6c6c84;font-size:12px;font-style:italic}.backlinks-list,.outgoing-links-list{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:6px}.backlink-item,.outgoing-link-item{padding:10px 12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:3px;cursor:pointer;transition:background .1s,border-color .1s}.backlink-item:hover,.outgoing-link-item:hover{background:#18181f;border-color:rgba(255,255,255,.09)}.backlink-item.unresolved,.outgoing-link-item.unresolved{opacity:.7;border-style:dashed}.backlink-source,.outgoing-link-target{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:2px}.backlink-title,.outgoing-link-text{font-size:13px;font-weight:500;color:#dcdce4;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.backlink-type-badge,.outgoing-link-type-badge{display:inline-block;padding:1px 6px;border-radius:12px;font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}.backlink-type-badge.backlink-type-wikilink,.backlink-type-badge.link-type-wikilink,.outgoing-link-type-badge.backlink-type-wikilink,.outgoing-link-type-badge.link-type-wikilink{background:rgba(124,126,245,.15);color:#9698f7}.backlink-type-badge.backlink-type-embed,.backlink-type-badge.link-type-embed,.outgoing-link-type-badge.backlink-type-embed,.outgoing-link-type-badge.link-type-embed{background:rgba(139,92,246,.1);color:#9d8be0}.backlink-type-badge.backlink-type-markdown_link,.backlink-type-badge.link-type-markdown_link,.outgoing-link-type-badge.backlink-type-markdown_link,.outgoing-link-type-badge.link-type-markdown_link{background:rgba(59,120,200,.1);color:#6ca0d4}.backlink-context{font-size:11px;color:#6c6c84;line-height:1.4;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.backlink-line{color:#a0a0b8;font-weight:500}.unresolved-badge{padding:1px 6px;border-radius:12px;font-size:9px;font-weight:600;background:rgba(212,160,55,.1);color:#d4a037}.outgoing-links-unresolved-badge{margin-left:8px;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:500;background:rgba(212,160,55,.12);color:#d4a037}.outgoing-links-global-unresolved{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;margin-top:12px;padding:10px 12px;background:rgba(212,160,55,.06);border:1px solid rgba(212,160,55,.15);border-radius:3px;font-size:11px;color:#6c6c84}.outgoing-links-global-unresolved .unresolved-icon{color:#d4a037}.backlinks-message{padding:8px 10px;margin-bottom:10px;border-radius:3px;font-size:11px}.backlinks-message.success{background:rgba(62,201,122,.08);border:1px solid rgba(62,201,122,.2);color:#3ec97a}.backlinks-message.error{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);color:#e45858}.graph-view{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;height:100%;background:#18181f;border-radius:5px;overflow:hidden}.graph-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:16px;padding:12px 16px;background:#1f1f28;border-bottom:1px solid rgba(255,255,255,.09)}.graph-title{font-size:14px;font-weight:600;color:#dcdce4}.graph-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;font-size:12px;color:#a0a0b8}.graph-controls select{padding:4px 20px 4px 8px;font-size:11px;background:#26263a}.graph-stats{margin-left:auto;font-size:11px;color:#6c6c84}.graph-container{flex:1;position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden;background:#111118}.graph-loading,.graph-error,.graph-empty{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px;padding:48px;color:#6c6c84;font-size:13px;text-align:center}.graph-svg{max-width:100%;max-height:100%;cursor:grab}.graph-svg-container{position:relative;width:100%;height:100%}.graph-zoom-controls{position:absolute;top:16px;left:16px;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;z-index:5}.zoom-btn{width:36px;height:36px;border-radius:6px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);color:#dcdce4;font-size:18px;font-weight:bold;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:all .15s;box-shadow:0 1px 3px rgba(0,0,0,.3)}.zoom-btn:hover{background:#26263a;border-color:rgba(255,255,255,.14);transform:scale(1.05)}.zoom-btn:active{transform:scale(.95)}.graph-edges line{stroke:rgba(255,255,255,.14);stroke-width:1;opacity:.6}.graph-edges line.edge-type-wikilink{stroke:#7c7ef5}.graph-edges line.edge-type-embed{stroke:#9d8be0;stroke-dasharray:4 2}.graph-nodes .graph-node{cursor:pointer}.graph-nodes .graph-node circle{fill:#4caf50;stroke:#388e3c;stroke-width:2;transition:fill .15s,stroke .15s}.graph-nodes .graph-node:hover circle{fill:#66bb6a}.graph-nodes .graph-node.selected circle{fill:#7c7ef5;stroke:#5456d6}.graph-nodes .graph-node text{fill:#a0a0b8;font-size:11px;pointer-events:none;text-anchor:middle;dominant-baseline:central;transform:translateY(16px)}.node-details-panel{position:absolute;top:16px;right:16px;width:280px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:10}.node-details-header{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.06)}.node-details-header h3{font-size:13px;font-weight:600;color:#dcdce4;margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.node-details-header .close-btn{background:none;border:none;color:#6c6c84;cursor:pointer;font-size:14px;padding:2px 6px;line-height:1}.node-details-header .close-btn:hover{color:#dcdce4}.node-details-content{padding:14px}.node-details-content .node-title{font-size:12px;color:#a0a0b8;margin-bottom:12px}.node-stats{display:flex;gap:16px;margin-bottom:12px}.node-stats .stat{font-size:12px;color:#6c6c84}.node-stats .stat strong{color:#dcdce4}.physics-controls-panel{position:absolute;top:16px;right:16px;width:300px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;box-shadow:0 2px 8px rgba(0,0,0,.35);padding:16px;z-index:10}.physics-controls-panel h4{font-size:13px;font-weight:600;color:#dcdce4;margin:0 0 16px 0;padding-bottom:8px;border-bottom:1px solid rgba(255,255,255,.06)}.physics-controls-panel .btn{width:100%;margin-top:8px}.control-group{margin-bottom:14px}.control-group label{display:block;font-size:11px;font-weight:500;color:#a0a0b8;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}.control-group input[type=range]{width:100%;height:4px;border-radius:4px;background:#26263a;outline:none;-webkit-appearance:none}.control-group input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;border-radius:50%;background:#7c7ef5;cursor:pointer;transition:transform .1s}.control-group input[type=range]::-webkit-slider-thumb:hover{transform:scale(1.15)}.control-group input[type=range]::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none;transition:transform .1s}.control-group input[type=range]::-moz-range-thumb:hover{transform:scale(1.15)}.control-value{display:inline-block;margin-top:2px;font-size:11px;color:#6c6c84;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace}.theme-light{--bg-0: #f5f5f7;--bg-1: #eeeef0;--bg-2: #fff;--bg-3: #e8e8ec;--border-subtle: rgba(0,0,0,.06);--border: rgba(0,0,0,.1);--border-strong: rgba(0,0,0,.16);--text-0: #1a1a2e;--text-1: #555570;--text-2: #8888a0;--accent: #6366f1;--accent-dim: rgba(99,102,241,.1);--accent-text: #4f52e8;--shadow-sm: 0 1px 3px rgba(0,0,0,.08);--shadow: 0 2px 8px rgba(0,0,0,.1);--shadow-lg: 0 4px 20px rgba(0,0,0,.12)}.theme-light ::-webkit-scrollbar-thumb{background:rgba(0,0,0,.12)}.theme-light ::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.08)}.theme-light ::-webkit-scrollbar-track{background:rgba(0,0,0,.06)}.theme-light .graph-nodes .graph-node text{fill:#1a1a2e}.theme-light .graph-edges line{stroke:rgba(0,0,0,.12)}.theme-light .pdf-container{background:#e8e8ec}.skeleton-pulse{animation:skeleton-pulse 1.5s ease-in-out infinite;background:#26263a;border-radius:4px}.skeleton-card{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;padding:8px}.skeleton-thumb{width:100%;aspect-ratio:1;border-radius:6px}.skeleton-text{height:14px;width:80%}.skeleton-text-short{width:50%}.skeleton-row{display:flex;gap:12px;padding:10px 16px;align-items:center}.skeleton-cell{height:14px;flex:1;border-radius:4px}.skeleton-cell-icon{width:32px;height:32px;flex:none;border-radius:4px}.skeleton-cell-wide{flex:3}.loading-overlay{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px;background:rgba(0,0,0,.3);z-index:100;border-radius:8px}.loading-spinner{width:32px;height:32px;border:3px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .8s linear infinite}.loading-message{color:#a0a0b8;font-size:.9rem}.login-container{display:flex;align-items:center;justify-content:center;height:100vh;background:#111118}.login-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:24px;width:360px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.login-title{font-size:20px;font-weight:700;color:#dcdce4;text-align:center;margin-bottom:2px}.login-subtitle{font-size:13px;color:#6c6c84;text-align:center;margin-bottom:20px}.login-error{background:rgba(228,88,88,.08);border:1px solid rgba(228,88,88,.2);border-radius:3px;padding:8px 12px;margin-bottom:12px;font-size:12px;color:#e45858}.login-form input[type=text],.login-form input[type=password]{width:100%}.login-btn{width:100%;padding:8px 16px;font-size:13px;margin-top:2px}.pagination{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:2px;margin-top:16px;padding:8px 0}.page-btn{min-width:28px;text-align:center;font-variant-numeric:tabular-nums}.page-ellipsis{color:#6c6c84;padding:0 4px;font-size:12px;user-select:none}.help-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:200;animation:fade-in .1s ease-out}.help-dialog{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:16px;min-width:300px;max-width:400px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.help-dialog h3{font-size:16px;font-weight:600;margin-bottom:16px}.help-shortcuts{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;margin-bottom:16px}.shortcut-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px}.shortcut-row kbd{display:inline-block;padding:2px 8px;background:#111118;border:1px solid rgba(255,255,255,.09);border-radius:3px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px;color:#dcdce4;min-width:32px;text-align:center}.shortcut-row span{font-size:13px;color:#a0a0b8}.help-close{display:block;width:100%;padding:6px 12px;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#dcdce4;font-size:12px;cursor:pointer;text-align:center}.help-close:hover{background:rgba(255,255,255,.06)}.plugin-page{padding:16px 24px;max-width:100%;overflow-x:hidden}.plugin-page-title{font-size:14px;font-weight:600;color:#dcdce4;margin:0 0 16px}.plugin-container{display:flex;flex-direction:column;gap:var(--plugin-gap, 0px);padding:var(--plugin-padding, 0)}.plugin-grid{display:grid;grid-template-columns:repeat(var(--plugin-columns, 1), 1fr);gap:var(--plugin-gap, 0px)}.plugin-flex{display:flex;gap:var(--plugin-gap, 0px)}.plugin-flex[data-direction=row]{flex-direction:row}.plugin-flex[data-direction=column]{flex-direction:column}.plugin-flex[data-justify=flex-start]{justify-content:flex-start}.plugin-flex[data-justify=flex-end]{justify-content:flex-end}.plugin-flex[data-justify=center]{justify-content:center}.plugin-flex[data-justify=space-between]{justify-content:space-between}.plugin-flex[data-justify=space-around]{justify-content:space-around}.plugin-flex[data-justify=space-evenly]{justify-content:space-evenly}.plugin-flex[data-align=flex-start]{align-items:flex-start}.plugin-flex[data-align=flex-end]{align-items:flex-end}.plugin-flex[data-align=center]{align-items:center}.plugin-flex[data-align=stretch]{align-items:stretch}.plugin-flex[data-align=baseline]{align-items:baseline}.plugin-flex[data-wrap=wrap]{flex-wrap:wrap}.plugin-flex[data-wrap=nowrap]{flex-wrap:nowrap}.plugin-split{display:flex}.plugin-split-sidebar{width:var(--plugin-sidebar-width, 200px);flex-shrink:0}.plugin-split-main{flex:1;min-width:0}.plugin-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;overflow:hidden}.plugin-card-header{padding:12px 16px;font-size:12px;font-weight:600;color:#dcdce4;border-bottom:1px solid rgba(255,255,255,.09);background:#26263a}.plugin-card-content{padding:16px}.plugin-card-footer{padding:12px 16px;border-top:1px solid rgba(255,255,255,.09);background:#18181f}.plugin-heading{color:#dcdce4;margin:0;line-height:1.2}.plugin-heading.level-1{font-size:28px;font-weight:700}.plugin-heading.level-2{font-size:18px;font-weight:600}.plugin-heading.level-3{font-size:16px;font-weight:600}.plugin-heading.level-4{font-size:14px;font-weight:500}.plugin-heading.level-5{font-size:13px;font-weight:500}.plugin-heading.level-6{font-size:12px;font-weight:500}.plugin-text{margin:0;font-size:12px;color:#dcdce4;line-height:1.4}.plugin-text.text-secondary{color:#a0a0b8}.plugin-text.text-error{color:#d47070}.plugin-text.text-success{color:#3ec97a}.plugin-text.text-warning{color:#d4a037}.plugin-text.text-bold{font-weight:600}.plugin-text.text-italic{font-style:italic}.plugin-text.text-small{font-size:10px}.plugin-text.text-large{font-size:15px}.plugin-code{background:#18181f;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px 24px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;color:#dcdce4;overflow-x:auto;white-space:pre}.plugin-code code{font-family:inherit;font-size:inherit;color:inherit}.plugin-tabs{display:flex;flex-direction:column}.plugin-tab-list{display:flex;gap:2px;border-bottom:1px solid rgba(255,255,255,.09);margin-bottom:16px}.plugin-tab{padding:8px 20px;font-size:12px;font-weight:500;color:#a0a0b8;background:rgba(0,0,0,0);border:none;border-bottom:2px solid rgba(0,0,0,0);cursor:pointer;transition:color .1s,border-color .1s}.plugin-tab:hover{color:#dcdce4}.plugin-tab.active{color:#9698f7;border-bottom-color:#7c7ef5}.plugin-tab .tab-icon{margin-right:4px}.plugin-tab-panel:not(.active){display:none}.plugin-description-list-wrapper{width:100%}.plugin-description-list{display:grid;grid-template-columns:max-content 1fr;gap:4px 16px;margin:0;padding:0}.plugin-description-list dt{font-size:10px;font-weight:500;color:#a0a0b8;text-transform:uppercase;letter-spacing:.5px;padding:6px 0;white-space:nowrap}.plugin-description-list dd{font-size:12px;color:#dcdce4;padding:6px 0;margin:0;word-break:break-word}.plugin-description-list.horizontal{display:flex;flex-wrap:wrap;gap:16px 24px;display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr))}.plugin-description-list.horizontal dt{width:auto;padding:0}.plugin-description-list.horizontal dd{width:auto;padding:0}.plugin-description-list.horizontal dt,.plugin-description-list.horizontal dd{display:inline}.plugin-description-list.horizontal dt{font-size:9px;text-transform:uppercase;letter-spacing:.5px;color:#6c6c84;margin-bottom:2px}.plugin-description-list.horizontal dd{font-size:13px;font-weight:600;color:#dcdce4}.plugin-data-table-wrapper{overflow-x:auto}.plugin-data-table{width:100%;border-collapse:collapse;font-size:12px}.plugin-data-table thead tr{border-bottom:1px solid rgba(255,255,255,.14)}.plugin-data-table thead th{padding:8px 12px;text-align:left;font-size:10px;font-weight:600;color:#a0a0b8;text-transform:uppercase;letter-spacing:.5px;white-space:nowrap}.plugin-data-table tbody tr{border-bottom:1px solid rgba(255,255,255,.06);transition:background .08s}.plugin-data-table tbody tr:hover{background:rgba(255,255,255,.03)}.plugin-data-table tbody tr:last-child{border-bottom:none}.plugin-data-table tbody td{padding:8px 12px;color:#dcdce4;vertical-align:middle}.plugin-col-constrained{width:var(--plugin-col-width)}.table-filter{margin-bottom:12px}.table-filter input{width:240px;padding:6px 12px;background:#18181f;border:1px solid rgba(255,255,255,.09);border-radius:5px;color:#dcdce4;font-size:12px}.table-filter input::placeholder{color:#6c6c84}.table-filter input:focus{outline:none;border-color:#7c7ef5}.table-pagination{display:flex;align-items:center;gap:12px;padding:8px 0;font-size:12px;color:#a0a0b8}.row-actions{white-space:nowrap;width:1%}.row-actions .plugin-button{padding:4px 8px;font-size:10px;margin-right:4px}.plugin-media-grid{display:grid;grid-template-columns:repeat(var(--plugin-columns, 2), 1fr);gap:var(--plugin-gap, 8px)}.media-grid-item{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;overflow:hidden;display:flex;flex-direction:column}.media-grid-img{width:100%;aspect-ratio:16/9;object-fit:cover;display:block}.media-grid-no-img{width:100%;aspect-ratio:16/9;background:#26263a;display:flex;align-items:center;justify-content:center;font-size:10px;color:#6c6c84}.media-grid-caption{padding:8px 12px;font-size:10px;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.plugin-list{list-style:none;margin:0;padding:0}.plugin-list-item{padding:8px 0}.plugin-list-divider{border:none;border-top:1px solid rgba(255,255,255,.06);margin:0}.plugin-list-empty{padding:16px;text-align:center;color:#6c6c84;font-size:12px}.plugin-button{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border:1px solid rgba(255,255,255,.09);border-radius:5px;font-size:12px;font-weight:500;cursor:pointer;transition:background .08s,border-color .08s,color .08s;background:#1f1f28;color:#dcdce4}.plugin-button:disabled{opacity:.45;cursor:not-allowed}.plugin-button.btn-primary{background:#7c7ef5;border-color:#7c7ef5;color:#fff}.plugin-button.btn-primary:hover:not(:disabled){background:#8b8df7}.plugin-button.btn-secondary{background:#26263a;border-color:rgba(255,255,255,.14);color:#dcdce4}.plugin-button.btn-secondary:hover:not(:disabled){background:rgba(255,255,255,.04)}.plugin-button.btn-tertiary{background:rgba(0,0,0,0);border-color:rgba(0,0,0,0);color:#9698f7}.plugin-button.btn-tertiary:hover:not(:disabled){background:rgba(124,126,245,.15)}.plugin-button.btn-danger{background:rgba(0,0,0,0);border-color:rgba(228,88,88,.2);color:#d47070}.plugin-button.btn-danger:hover:not(:disabled){background:rgba(228,88,88,.06)}.plugin-button.btn-success{background:rgba(0,0,0,0);border-color:rgba(62,201,122,.2);color:#3ec97a}.plugin-button.btn-success:hover:not(:disabled){background:rgba(62,201,122,.08)}.plugin-button.btn-ghost{background:rgba(0,0,0,0);border-color:rgba(0,0,0,0);color:#a0a0b8}.plugin-button.btn-ghost:hover:not(:disabled){background:rgba(255,255,255,.04)}.plugin-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:50%;font-size:9px;font-weight:600;letter-spacing:.5px;text-transform:uppercase}.plugin-badge.badge-default,.plugin-badge.badge-neutral{background:rgba(255,255,255,.04);color:#a0a0b8}.plugin-badge.badge-primary{background:rgba(124,126,245,.15);color:#9698f7}.plugin-badge.badge-secondary{background:rgba(255,255,255,.03);color:#dcdce4}.plugin-badge.badge-success{background:rgba(62,201,122,.08);color:#3ec97a}.plugin-badge.badge-warning{background:rgba(212,160,55,.06);color:#d4a037}.plugin-badge.badge-error{background:rgba(228,88,88,.06);color:#d47070}.plugin-badge.badge-info{background:rgba(99,102,241,.08);color:#9698f7}.plugin-form{display:flex;flex-direction:column;gap:16px}.form-field{display:flex;flex-direction:column;gap:6px}.form-field label{font-size:12px;font-weight:500;color:#dcdce4}.form-field input,.form-field textarea,.form-field select{padding:8px 12px;background:#18181f;border:1px solid rgba(255,255,255,.09);border-radius:5px;color:#dcdce4;font-size:12px;font-family:inherit}.form-field input::placeholder,.form-field textarea::placeholder,.form-field select::placeholder{color:#6c6c84}.form-field input:focus,.form-field textarea:focus,.form-field select:focus{outline:none;border-color:#7c7ef5;box-shadow:0 0 0 2px rgba(124,126,245,.15)}.form-field textarea{min-height:80px;resize:vertical}.form-field select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23a0a0b8' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center;padding-right:32px}.form-help{margin:0;font-size:10px;color:#6c6c84}.form-actions{display:flex;gap:12px;padding-top:8px}.required{color:#e45858}.plugin-link{color:#9698f7;text-decoration:none}.plugin-link:hover{text-decoration:underline}.plugin-link-blocked{color:#6c6c84;text-decoration:line-through;cursor:not-allowed}.plugin-progress{background:#18181f;border:1px solid rgba(255,255,255,.09);border-radius:5px;height:8px;overflow:hidden;display:flex;align-items:center;gap:8px}.plugin-progress-bar{height:100%;background:#7c7ef5;border-radius:4px;transition:width .3s ease;width:var(--plugin-progress, 0%)}.plugin-progress-label{font-size:10px;color:#a0a0b8;white-space:nowrap;flex-shrink:0}.plugin-chart{overflow:auto;height:var(--plugin-chart-height, 200px)}.plugin-chart .chart-title{font-size:13px;font-weight:600;color:#dcdce4;margin-bottom:8px}.plugin-chart .chart-x-label,.plugin-chart .chart-y-label{font-size:10px;color:#6c6c84;margin-bottom:4px}.plugin-chart .chart-data-table{overflow-x:auto}.plugin-chart .chart-no-data{padding:24px;text-align:center;color:#6c6c84;font-size:12px}.plugin-loading{padding:16px;color:#a0a0b8;font-size:12px;font-style:italic}.plugin-error{padding:12px 16px;background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:5px;color:#d47070;font-size:12px}.plugin-feedback{position:sticky;bottom:16px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:12px 16px;border-radius:7px;font-size:12px;z-index:300;box-shadow:0 4px 20px rgba(0,0,0,.45)}.plugin-feedback.success{background:rgba(62,201,122,.08);border:1px solid rgba(62,201,122,.2);color:#3ec97a}.plugin-feedback.error{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);color:#d47070}.plugin-feedback-dismiss{background:rgba(0,0,0,0);border:none;color:inherit;font-size:14px;cursor:pointer;line-height:1;padding:0;opacity:.7}.plugin-feedback-dismiss:hover{opacity:1}.plugin-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.65);display:flex;align-items:center;justify-content:center;z-index:100}.plugin-modal{position:relative;background:#1f1f28;border:1px solid rgba(255,255,255,.14);border-radius:12px;padding:32px;min-width:380px;max-width:640px;max-height:80vh;overflow-y:auto;box-shadow:0 4px 20px rgba(0,0,0,.45);z-index:200}.plugin-modal-close{position:absolute;top:16px;right:16px;background:rgba(0,0,0,0);border:none;color:#a0a0b8;font-size:14px;cursor:pointer;line-height:1;padding:4px;border-radius:5px}.plugin-modal-close:hover{background:rgba(255,255,255,.04);color:#dcdce4} \ No newline at end of file diff --git a/crates/pinakes-ui/assets/styles/_base.scss b/crates/pinakes-ui/assets/styles/_base.scss index c8c2cbe..4a41490 100644 --- a/crates/pinakes-ui/assets/styles/_base.scss +++ b/crates/pinakes-ui/assets/styles/_base.scss @@ -144,6 +144,14 @@ ul { margin-bottom: $space-6; } +.mt-16 { + margin-top: $space-8; +} + +.mt-8 { + margin-top: $space-6; +} + // Animations @keyframes fade-in { from { diff --git a/crates/pinakes-ui/assets/styles/_components.scss b/crates/pinakes-ui/assets/styles/_components.scss index 4d1bedb..ddff3e7 100644 --- a/crates/pinakes-ui/assets/styles/_components.scss +++ b/crates/pinakes-ui/assets/styles/_components.scss @@ -82,6 +82,10 @@ font-size: $font-size-xl; font-weight: $font-weight-semibold; } + + &-body { + padding-top: $space-4; + } } // Tables @@ -201,6 +205,117 @@ select { } } +// Badges + +.badge { + display: inline-flex; + align-items: center; + padding: $space-1 $space-4; + border-radius: $radius-full; + font-size: $font-size-xs; + font-weight: $font-weight-semibold; + letter-spacing: $letter-spacing-uppercase; + text-transform: uppercase; + + &-neutral { + background: $overlay-medium; + color: $text-1; + } + + &-success { + background: $success-bg; + color: $success; + } + + &-warning { + background: $warning-bg; + color: $warning; + } + + &-danger { + background: $error-bg; + color: $error-text; + } +} + +// Tabs + +.tab-bar { + @include flex(row, flex-start, center, $space-2); + border-bottom: 1px solid $border; + margin-bottom: $space-6; +} + +.tab-btn { + @include button-ghost; + padding: $space-3 $space-4; + font-size: $font-size-lg; + color: $text-1; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + cursor: pointer; + transition: color $transition-base, border-color $transition-base; + + &:hover { + color: $text-0; + } + + &.active { + color: $accent-text; + border-bottom-color: $accent; + } +} + +// Input variants + +.input-sm { + @include input-base; + padding: 3px $space-4; + font-size: $font-size-md; +} + +.input-suffix { + @include flex(row, flex-start, stretch); + + input { + border-radius: $radius-sm 0 0 $radius-sm; + border-right: none; + flex: 1; + } + + .btn { + border-radius: 0 $radius-sm $radius-sm 0; + } +} + +// Validation + +.field-error { + color: $error-text; + font-size: $font-size-base; + margin-top: $space-1; +} + +// Comments / social + +.comments-list { + @include flex(column, flex-start, stretch, $space-4); +} + +.comment-item { + padding: $space-4; + background: $bg-1; + border-radius: $radius; + border: 1px solid $border-subtle; +} + +.comment-text { + font-size: $font-size-lg; + color: $text-0; + line-height: $line-height-relaxed; + margin-top: $space-2; +} + // Checkbox input[type='checkbox'] { @@ -591,6 +706,15 @@ input[type='checkbox'] { } } +.player-native-video { + width: 100%; + display: block; +} + +.player-native-audio { + display: none; +} + .player-artwork { img { max-width: 200px; -- 2.43.0 From b1ddb32ff0150e5333ec0685540b22b7edfa73ee Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Mar 2026 13:33:29 +0300 Subject: [PATCH 24/30] pinakes-server: fix subtitle list response and registration Signed-off-by: NotAShelf Change-Id: I22c7237877862acbf931ce4c662bd2816a6a6964 --- crates/pinakes-core/src/subtitles.rs | 3 +++ crates/pinakes-server/src/api_doc.rs | 2 +- crates/pinakes-server/src/app.rs | 2 +- crates/pinakes-server/src/routes/subtitles.rs | 5 ++--- crates/pinakes-server/tests/notes.rs | 4 ++-- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/pinakes-core/src/subtitles.rs b/crates/pinakes-core/src/subtitles.rs index 51ae763..76cc8b5 100644 --- a/crates/pinakes-core/src/subtitles.rs +++ b/crates/pinakes-core/src/subtitles.rs @@ -35,6 +35,7 @@ pub enum SubtitleFormat { impl SubtitleFormat { /// Returns the MIME type for this subtitle format. + #[must_use] pub const fn mime_type(self) -> &'static str { match self { Self::Srt => "application/x-subrip", @@ -45,6 +46,7 @@ impl SubtitleFormat { } /// Returns true if this format is binary (not UTF-8 text). + #[must_use] pub const fn is_binary(self) -> bool { matches!(self, Self::Pgs) } @@ -96,6 +98,7 @@ pub struct SubtitleTrackInfo { /// Detects the subtitle format from a file extension. /// /// Returns `None` if the extension is unrecognised or absent. +#[must_use] pub fn detect_format(path: &Path) -> Option { match path.extension()?.to_str()?.to_lowercase().as_str() { "srt" => Some(SubtitleFormat::Srt), diff --git a/crates/pinakes-server/src/api_doc.rs b/crates/pinakes-server/src/api_doc.rs index c4a8e4d..279b637 100644 --- a/crates/pinakes-server/src/api_doc.rs +++ b/crates/pinakes-server/src/api_doc.rs @@ -1,6 +1,6 @@ use utoipa::OpenApi; -/// Central OpenAPI document registry. +/// Central `OpenAPI` document registry. /// Handler functions and schemas are added here as route modules are annotated. #[derive(OpenApi)] #[openapi( diff --git a/crates/pinakes-server/src/app.rs b/crates/pinakes-server/src/app.rs index ed859d0..3d2f531 100644 --- a/crates/pinakes-server/src/app.rs +++ b/crates/pinakes-server/src/app.rs @@ -56,7 +56,7 @@ pub fn create_router_with_tls( let swagger_ui_enabled = state .config .try_read() - .map_or(false, |cfg| cfg.server.swagger_ui); + .is_ok_and(|cfg| cfg.server.swagger_ui); let global_governor = build_governor( rate_limits.global_per_second, diff --git a/crates/pinakes-server/src/routes/subtitles.rs b/crates/pinakes-server/src/routes/subtitles.rs index 2f71311..8cb97ca 100644 --- a/crates/pinakes-server/src/routes/subtitles.rs +++ b/crates/pinakes-server/src/routes/subtitles.rs @@ -80,13 +80,12 @@ pub async fn add_subtitle( Json(req): Json, ) -> Result, ApiError> { // Validate language code if provided. - if let Some(ref lang) = req.language { - if !validate_language_code(lang) { + if let Some(ref lang) = req.language + && !validate_language_code(lang) { return Err(ApiError( pinakes_core::error::PinakesError::InvalidLanguageCode(lang.clone()), )); } - } let is_embedded = req.is_embedded.unwrap_or(false); diff --git a/crates/pinakes-server/tests/notes.rs b/crates/pinakes-server/tests/notes.rs index 2dddd1e..2e1582d 100644 --- a/crates/pinakes-server/tests/notes.rs +++ b/crates/pinakes-server/tests/notes.rs @@ -59,11 +59,11 @@ async fn notes_graph_empty() { let nodes_empty = obj .get("nodes") .and_then(|v| v.as_array()) - .map_or(true, |a| a.is_empty()); + .is_none_or(std::vec::Vec::is_empty); let edges_empty = obj .get("edges") .and_then(|v| v.as_array()) - .map_or(true, |a| a.is_empty()); + .is_none_or(std::vec::Vec::is_empty); assert!( nodes_empty && edges_empty, "graph should be empty, got {obj:?}" -- 2.43.0 From bac79a2c08742ce1e757da6b9d8447f51f978d77 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Mar 2026 15:18:38 +0300 Subject: [PATCH 25/30] pinakes-server: add more integration tests Signed-off-by: NotAShelf Change-Id: I7c6c8eaad569404c7a13cfa8114d84516a6a6964 --- Cargo.lock | Bin 244600 -> 244619 bytes crates/pinakes-server/Cargo.toml | 3 +- crates/pinakes-server/tests/e2e.rs | 65 +++++++ crates/pinakes-server/tests/enrichment.rs | 143 ++++++++++++++ crates/pinakes-server/tests/users.rs | 217 ++++++++++++++++++++++ crates/pinakes-server/tests/webhooks.rs | 90 +++++++++ 6 files changed, 517 insertions(+), 1 deletion(-) create mode 100644 crates/pinakes-server/tests/e2e.rs create mode 100644 crates/pinakes-server/tests/enrichment.rs create mode 100644 crates/pinakes-server/tests/users.rs create mode 100644 crates/pinakes-server/tests/webhooks.rs diff --git a/Cargo.lock b/Cargo.lock index 5e977d83e95e4354b111abfdd27f1587439e5edd..622df0c87350a9397623c2a8a04a75291c84fd96 100644 GIT binary patch delta 43 zcmV+`0M!5Z_702p4uFIKv;tWHm+1lmL6>;`0Wp`K&H@RS#r^>shui@Ix7+~(oVlXC B5`6#w delta 28 kcmeDF&iCURUqcJy7N$tX>0ACY>b1{eWZFK9k@@3J0J|6rJ^%m! diff --git a/crates/pinakes-server/Cargo.toml b/crates/pinakes-server/Cargo.toml index d96001c..14e329e 100644 --- a/crates/pinakes-server/Cargo.toml +++ b/crates/pinakes-server/Cargo.toml @@ -41,4 +41,5 @@ workspace = true [dev-dependencies] http-body-util = "0.1.3" -tempfile = "3.25.0" +reqwest = { workspace = true } +tempfile = { workspace = true } diff --git a/crates/pinakes-server/tests/e2e.rs b/crates/pinakes-server/tests/e2e.rs new file mode 100644 index 0000000..96d8aeb --- /dev/null +++ b/crates/pinakes-server/tests/e2e.rs @@ -0,0 +1,65 @@ +/// End-to-end tests that bind a real TCP listener and exercise the HTTP layer. +/// +/// These tests differ from the router-level `oneshot` tests in that they verify +/// the full Axum `serve` path: `TcpListener` binding, HTTP framing, and response +/// serialization. Each test spins up a server on an ephemeral port, issues a +/// real HTTP request via reqwest, then shuts down. +mod common; + +use std::net::SocketAddr; + +use tokio::net::TcpListener; + +/// Bind a listener on an ephemeral port, spawn the server in the background, +/// and return the bound address as a string. +/// +/// Uses `into_make_service_with_connect_info` so that the governor rate +/// limiter can extract `ConnectInfo` from real TCP connections. +async fn bind_and_serve() -> String { + let app = common::setup_app().await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .unwrap(); + }); + format!("http://{addr}") +} + +#[tokio::test] +async fn health_endpoint_responds_over_real_tcp() { + let base = bind_and_serve().await; + let resp = reqwest::get(format!("{base}/api/v1/health")) + .await + .expect("health request failed"); + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.expect("body not JSON"); + assert!( + body["status"].is_string(), + "expected health response to contain 'status' field" + ); +} + +#[tokio::test] +async fn media_list_responds_over_real_tcp() { + // setup_app has authentication_disabled=true; verifies the router serves + // real TCP traffic, not just in-process oneshot requests. + let base = bind_and_serve().await; + let resp = reqwest::get(format!("{base}/api/v1/media")) + .await + .expect("media list request failed"); + assert_eq!(resp.status(), 200); +} + +#[tokio::test] +async fn unknown_route_returns_404_over_real_tcp() { + let base = bind_and_serve().await; + let resp = reqwest::get(format!("{base}/api/v1/nonexistent-route")) + .await + .expect("request failed"); + assert_eq!(resp.status(), 404); +} diff --git a/crates/pinakes-server/tests/enrichment.rs b/crates/pinakes-server/tests/enrichment.rs new file mode 100644 index 0000000..9f93951 --- /dev/null +++ b/crates/pinakes-server/tests/enrichment.rs @@ -0,0 +1,143 @@ +mod common; + +use axum::http::StatusCode; +use common::{ + get, + get_authed, + post_json_authed, + setup_app, + setup_app_with_auth, +}; +use tower::ServiceExt; + +// GET /api/v1/media/{id}/metadata/external (viewer) + +#[tokio::test] +async fn get_external_metadata_requires_auth() { + let (app, ..) = setup_app_with_auth().await; + let response = app + .oneshot(get( + "/api/v1/media/00000000-0000-0000-0000-000000000000/external-metadata", + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn get_external_metadata_viewer_ok() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(get_authed( + "/api/v1/media/00000000-0000-0000-0000-000000000000/external-metadata", + &viewer_token, + )) + .await + .unwrap(); + // Media does not exist; 200 with empty array or 404 are both valid + assert!( + response.status() == StatusCode::OK + || response.status() == StatusCode::NOT_FOUND + ); +} + +// POST /api/v1/media/{id}/enrich (editor) + +#[tokio::test] +async fn trigger_enrichment_requires_editor() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(post_json_authed( + "/api/v1/media/00000000-0000-0000-0000-000000000000/enrich", + "{}", + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn trigger_enrichment_editor_accepted() { + let (app, _, editor_token, _) = setup_app_with_auth().await; + let response = app + .oneshot(post_json_authed( + "/api/v1/media/00000000-0000-0000-0000-000000000000/enrich", + "{}", + &editor_token, + )) + .await + .unwrap(); + // Route is accessible to editors; media not found returns 404, job queued + // returns 200 + assert!( + response.status() == StatusCode::OK + || response.status() == StatusCode::NOT_FOUND + ); +} + +// POST /api/v1/jobs/enrich (editor, batch) + +#[tokio::test] +async fn batch_enrich_requires_editor() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(post_json_authed( + "/api/v1/jobs/enrich", + r#"{"media_ids":["00000000-0000-0000-0000-000000000000"]}"#, + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn batch_enrich_empty_ids_rejected() { + let (app, _, editor_token, _) = setup_app_with_auth().await; + let response = app + .oneshot(post_json_authed( + "/api/v1/jobs/enrich", + r#"{"media_ids":[]}"#, + &editor_token, + )) + .await + .unwrap(); + // Validation requires 1-1000 ids + assert!( + response.status() == StatusCode::BAD_REQUEST + || response.status() == StatusCode::UNPROCESSABLE_ENTITY + ); +} + +#[tokio::test] +async fn batch_enrich_editor_accepted() { + let (app, _, editor_token, _) = setup_app_with_auth().await; + let response = app + .oneshot(post_json_authed( + "/api/v1/jobs/enrich", + r#"{"media_ids":["00000000-0000-0000-0000-000000000000"]}"#, + &editor_token, + )) + .await + .unwrap(); + // Job is queued and a job_id is returned + assert_eq!(response.status(), StatusCode::OK); +} + +// No-auth coverage (exercises setup_app and get) + +#[tokio::test] +async fn get_external_metadata_auth_disabled() { + let app = setup_app().await; + let response = app + .oneshot(get( + "/api/v1/media/00000000-0000-0000-0000-000000000000/external-metadata", + )) + .await + .unwrap(); + assert!( + response.status() == StatusCode::OK + || response.status() == StatusCode::NOT_FOUND + ); +} diff --git a/crates/pinakes-server/tests/users.rs b/crates/pinakes-server/tests/users.rs new file mode 100644 index 0000000..3f99f13 --- /dev/null +++ b/crates/pinakes-server/tests/users.rs @@ -0,0 +1,217 @@ +mod common; + +use axum::http::StatusCode; +use common::{ + delete_authed, + get, + get_authed, + patch_json_authed, + post_json_authed, + response_body, + setup_app, + setup_app_with_auth, +}; +use tower::ServiceExt; + +// GET /api/v1/users (admin) + +#[tokio::test] +async fn list_users_requires_admin() { + let (app, _, editor_token, _) = setup_app_with_auth().await; + let response = app + .oneshot(get_authed("/api/v1/users", &editor_token)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn list_users_viewer_forbidden() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(get_authed("/api/v1/users", &viewer_token)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn list_users_admin_ok() { + let (app, admin_token, ..) = setup_app_with_auth().await; + let response = app + .oneshot(get_authed("/api/v1/users", &admin_token)) + .await + .unwrap(); + let status = response.status(); + let body = response_body(response).await; + assert_eq!(status, StatusCode::OK); + let users = body.as_array().expect("users is array"); + // setup_app_with_auth seeds three users + assert_eq!(users.len(), 3); +} + +// POST /api/v1/users (admin) + +#[tokio::test] +async fn create_user_requires_admin() { + let (app, _, editor_token, _) = setup_app_with_auth().await; + let response = app + .oneshot(post_json_authed( + "/api/v1/users", + r#"{"username":"newuser","password":"password123","role":"viewer"}"#, + &editor_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn create_user_admin_ok() { + let (app, admin_token, ..) = setup_app_with_auth().await; + let response = app + .oneshot(post_json_authed( + "/api/v1/users", + r#"{"username":"newuser","password":"password123","role":"viewer"}"#, + &admin_token, + )) + .await + .unwrap(); + let status = response.status(); + let body = response_body(response).await; + assert!( + status == StatusCode::OK || status == StatusCode::CREATED, + "unexpected status: {status}" + ); + assert!(body["id"].is_string(), "expected id field, got: {body}"); + assert_eq!(body["username"].as_str().unwrap(), "newuser"); +} + +#[tokio::test] +async fn create_user_duplicate_username() { + let (app, admin_token, ..) = setup_app_with_auth().await; + // "admin" already exists + let response = app + .oneshot(post_json_authed( + "/api/v1/users", + r#"{"username":"admin","password":"password123","role":"viewer"}"#, + &admin_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CONFLICT); +} + +#[tokio::test] +async fn create_user_password_too_short() { + let (app, admin_token, ..) = setup_app_with_auth().await; + let response = app + .oneshot(post_json_authed( + "/api/v1/users", + r#"{"username":"shortpass","password":"short","role":"viewer"}"#, + &admin_token, + )) + .await + .unwrap(); + // Password minimum is 8 chars; should be rejected + assert!( + response.status() == StatusCode::BAD_REQUEST + || response.status() == StatusCode::UNPROCESSABLE_ENTITY + ); +} + +// GET /api/v1/users/{id} (admin) + +#[tokio::test] +async fn get_user_requires_admin() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(get_authed( + "/api/v1/users/00000000-0000-0000-0000-000000000000", + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn get_user_not_found() { + let (app, admin_token, ..) = setup_app_with_auth().await; + let response = app + .oneshot(get_authed( + "/api/v1/users/00000000-0000-0000-0000-000000000000", + &admin_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +// PATCH /api/v1/users/{id} (admin) + +#[tokio::test] +async fn update_user_requires_admin() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(patch_json_authed( + "/api/v1/users/00000000-0000-0000-0000-000000000000", + r#"{"role":"editor"}"#, + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +// DELETE /api/v1/users/{id} (admin) + +#[tokio::test] +async fn delete_user_requires_admin() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(delete_authed( + "/api/v1/users/00000000-0000-0000-0000-000000000000", + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn delete_user_not_found() { + let (app, admin_token, ..) = setup_app_with_auth().await; + let response = app + .oneshot(delete_authed( + "/api/v1/users/00000000-0000-0000-0000-000000000000", + &admin_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +// GET /api/v1/users/{id}/libraries (admin) + +#[tokio::test] +async fn get_user_libraries_requires_admin() { + let (app, _, editor_token, _) = setup_app_with_auth().await; + let response = app + .oneshot(get_authed( + "/api/v1/users/00000000-0000-0000-0000-000000000000/libraries", + &editor_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +// No-auth coverage (exercises setup_app and get helpers) + +#[tokio::test] +async fn media_list_no_auth_users_file() { + let app = setup_app().await; + let response = app.oneshot(get("/api/v1/media")).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); +} diff --git a/crates/pinakes-server/tests/webhooks.rs b/crates/pinakes-server/tests/webhooks.rs new file mode 100644 index 0000000..7171e1b --- /dev/null +++ b/crates/pinakes-server/tests/webhooks.rs @@ -0,0 +1,90 @@ +mod common; + +use axum::http::StatusCode; +use common::{ + get, + get_authed, + post_json_authed, + response_body, + setup_app, + setup_app_with_auth, +}; +use tower::ServiceExt; + +// GET /api/v1/webhooks (viewer) + +#[tokio::test] +async fn list_webhooks_requires_auth() { + let (app, ..) = setup_app_with_auth().await; + let response = app.oneshot(get("/api/v1/webhooks")).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn list_webhooks_viewer_ok() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(get_authed("/api/v1/webhooks", &viewer_token)) + .await + .unwrap(); + let status = response.status(); + let body = response_body(response).await; + assert_eq!(status, StatusCode::OK); + // No webhooks configured in test config: empty array + assert!(body.is_array(), "expected array, got: {body}"); + assert_eq!(body.as_array().unwrap().len(), 0); +} + +#[tokio::test] +async fn list_webhooks_no_auth_disabled_ok() { + // Auth disabled (setup_app): viewer-level route still accessible + let app = setup_app().await; + let response = app.oneshot(get("/api/v1/webhooks")).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); +} + +// POST /api/v1/webhooks/test (editor) + +#[tokio::test] +async fn test_webhook_requires_editor() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(post_json_authed( + "/api/v1/webhooks/test", + "{}", + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn test_webhook_no_dispatcher_returns_ok() { + // No webhook dispatcher in test setup; route should return 200 with + // "no webhooks configured" message rather than erroring. + let (app, _, editor_token, _) = setup_app_with_auth().await; + let response = app + .oneshot(post_json_authed( + "/api/v1/webhooks/test", + "{}", + &editor_token, + )) + .await + .unwrap(); + // Either OK or the route returns a structured response about no webhooks + assert!( + response.status() == StatusCode::OK + || response.status() == StatusCode::BAD_REQUEST + ); +} + +#[tokio::test] +async fn test_webhook_requires_auth() { + let (app, ..) = setup_app_with_auth().await; + let response = app + .oneshot(post_json_authed("/api/v1/webhooks/test", "{}", "badtoken")) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} -- 2.43.0 From c1a1f4a6009ac22ac7c8fe2296aae66960805345 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Mar 2026 15:19:28 +0300 Subject: [PATCH 26/30] pinakes-ui: use system player for HLS streams Signed-off-by: NotAShelf Change-Id: I0255e79e25cde100a063476de2c8fe0d6a6a6964 --- .../pinakes-ui/src/components/media_player.rs | 125 +++++++----------- 1 file changed, 48 insertions(+), 77 deletions(-) diff --git a/crates/pinakes-ui/src/components/media_player.rs b/crates/pinakes-ui/src/components/media_player.rs index 5c84fca..7bc76d5 100644 --- a/crates/pinakes-ui/src/components/media_player.rs +++ b/crates/pinakes-ui/src/components/media_player.rs @@ -1,7 +1,4 @@ -use dioxus::{ - document::{Script, eval}, - prelude::*, -}; +use dioxus::{document::eval, prelude::*}; use super::utils::format_duration; @@ -126,41 +123,30 @@ impl PlayQueue { } } -/// Generate JavaScript to initialize hls.js for an HLS stream URL. +/// Open a URL in the system's default media player. /// -/// Destroys any previously created instance stored at `window.__hlsInstances` -/// keyed by element ID before creating a new one, preventing memory leaks and -/// multiple instances competing for the same video element across re-renders. -fn hls_init_script(video_id: &str, hls_url: &str) -> String { - // JSON-encode both values so embedded quotes/backslashes cannot break out of - // the JS string. - let encoded_url = - serde_json::to_string(hls_url).unwrap_or_else(|_| "\"\"".to_string()); - let encoded_id = - serde_json::to_string(video_id).unwrap_or_else(|_| "\"\"".to_string()); - format!( - r#" - (function() {{ - window.__hlsInstances = window.__hlsInstances || {{}}; - var existing = window.__hlsInstances[{encoded_id}]; - if (existing) {{ - existing.destroy(); - window.__hlsInstances[{encoded_id}] = null; - }} - if (typeof Hls !== 'undefined' && Hls.isSupported()) {{ - var hls = new Hls(); - hls.loadSource({encoded_url}); - hls.attachMedia(document.getElementById({encoded_id})); - window.__hlsInstances[{encoded_id}] = hls; - }} else {{ - var video = document.getElementById({encoded_id}); - if (video && video.canPlayType('application/vnd.apple.mpegurl')) {{ - video.src = {encoded_url}; - }} - }} - }})(); - "# - ) +/// Uses `xdg-open` on Linux, `open` on macOS, and `cmd /c start` on Windows. +/// The call is fire-and-forget; failures are logged as warnings. +fn open_in_system_player(url: &str) { + #[cfg(target_os = "linux")] + let result = std::process::Command::new("xdg-open").arg(url).spawn(); + #[cfg(target_os = "macos")] + let result = std::process::Command::new("open").arg(url).spawn(); + #[cfg(target_os = "windows")] + let result = std::process::Command::new("cmd") + .args(["/c", "start", "", url]) + .spawn(); + #[cfg(not(any( + target_os = "linux", + target_os = "macos", + target_os = "windows" + )))] + let result: std::io::Result = + Err(std::io::Error::other("unsupported platform")); + + if let Err(e) = result { + tracing::warn!("failed to open system player: {e}"); + } } #[component] @@ -179,6 +165,10 @@ pub fn MediaPlayer( let mut muted = use_signal(|| false); let is_video = media_type == "video"; + // HLS adaptive streams (.m3u8) cannot be decoded natively by webkit2gtk on + // Linux. Detect them and show an external-player button instead of attempting + // in-process playback via a JavaScript library. + let is_hls = src.ends_with(".m3u8") || src.contains("/stream/hls/"); let is_playing = *playing.read(); let cur_time = *current_time.read(); let dur = *duration.read(); @@ -240,35 +230,6 @@ pub fn MediaPlayer( }); }); - // HLS initialization for .m3u8 streams. - // use_effect must be called unconditionally to maintain stable hook ordering. - let is_hls = src.ends_with(".m3u8"); - let hls_src = src.clone(); - use_effect(move || { - if !hls_src.ends_with(".m3u8") { - return; - } - let js = hls_init_script("pinakes-player", &hls_src); - spawn(async move { - // Poll until hls.js is loaded rather than using a fixed delay, so we - // initialize as soon as the script is ready without timing out on slow - // connections. Max wait: 25 * 100ms = 2.5s. - const MAX_POLLS: u32 = 25; - for _ in 0..MAX_POLLS { - if let Ok(val) = eval("typeof Hls !== 'undefined'").await { - if val == serde_json::Value::Bool(true) { - let _ = eval(&js).await; - return; - } - } - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - } - tracing::warn!( - "hls.js did not load within 2.5s; HLS stream will not play" - ); - }); - }); - // Autoplay on mount if autoplay { let src_auto = src.clone(); @@ -435,32 +396,41 @@ pub fn MediaPlayer( let play_icon = if is_playing { "\u{23f8}" } else { "\u{25b6}" }; let mute_icon = if is_muted { "\u{1f507}" } else { "\u{1f50a}" }; - rsx! { - // Load hls.js for HLS stream support when needed - if is_hls { - // Pin to a specific release so unexpected upstream changes cannot - // break playback. Update this when intentionally upgrading hls.js. - Script { src: "https://cdn.jsdelivr.net/npm/hls.js@1.5.15/dist/hls.min.js" } - } + let open_src = src.clone(); + rsx! { div { class: if is_video { "media-player media-player-video" } else { "media-player media-player-audio" }, tabindex: "0", onkeydown: on_keydown, - // Hidden native element; for HLS streams skip the src attr (hls.js attaches it) + // HLS adaptive streams cannot be decoded natively in the desktop WebView. + // Show a prompt to open in the system's default media player instead. + if is_hls { + div { class: "player-hls-notice", + p { class: "text-muted", + "Adaptive (HLS) streams require an external media player." + } + button { + class: "btn btn-primary", + onclick: move |_| open_in_system_player(&open_src), + "Open in system player" + } + } + } else { + if is_video { video { id: "pinakes-player", class: "player-native-video", - src: if is_hls { String::new() } else { src.clone() }, + src: src.clone(), preload: "metadata", } } else { audio { id: "pinakes-player", class: "player-native-audio", - src: if is_hls { String::new() } else { src.clone() }, + src: src.clone(), preload: "metadata", } } @@ -521,6 +491,7 @@ pub fn MediaPlayer( } } } + } // end else (non-HLS) } } } -- 2.43.0 From 7ed66f1d3f3e89d1a8044142258031e550f4c498 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Mar 2026 15:21:03 +0300 Subject: [PATCH 27/30] various: autofix Clippy warnings Signed-off-by: NotAShelf Change-Id: Ia355e5626b5db7760c8dbb571cb552c46a6a6964 --- xtask/src/docs.rs | 50 +++++++++++++++++++++++------------------------ 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/xtask/src/docs.rs b/xtask/src/docs.rs index c8d84ee..5f9872c 100644 --- a/xtask/src/docs.rs +++ b/xtask/src/docs.rs @@ -144,32 +144,32 @@ fn write_operation( } // Parameters - if let Some(params) = &op.parameters { - if !params.is_empty() { - md.push_str("#### Parameters\n\n"); - md.push_str("| Name | In | Required | Description |\n"); - md.push_str("|------|----|----------|-------------|\n"); - for p in params { - let location = param_in_str(&p.parameter_in); - let required = match p.required { - Required::True => "Yes", - Required::False => "No", - }; - let desc = p - .description - .as_deref() - .unwrap_or("") - .replace('|', "\\|") - .replace('\n', " "); - writeln!( - md, - "| `{}` | {} | {} | {} |", - p.name, location, required, desc - ) - .expect("write to String"); - } - md.push('\n'); + if let Some(params) = &op.parameters + && !params.is_empty() + { + md.push_str("#### Parameters\n\n"); + md.push_str("| Name | In | Required | Description |\n"); + md.push_str("|------|----|----------|-------------|\n"); + for p in params { + let location = param_in_str(&p.parameter_in); + let required = match p.required { + Required::True => "Yes", + Required::False => "No", + }; + let desc = p + .description + .as_deref() + .unwrap_or("") + .replace('|', "\\|") + .replace('\n', " "); + writeln!( + md, + "| `{}` | {} | {} | {} |", + p.name, location, required, desc + ) + .expect("write to String"); } + md.push('\n'); } // Request body -- 2.43.0 From d26f23782825f41dd6b2b3470e824db1bd80c99c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Mar 2026 15:21:19 +0300 Subject: [PATCH 28/30] meta: configure gitattributes; don't diff churn Signed-off-by: NotAShelf Change-Id: I9d22892a5f90a95c29fa8979512b13646a6a6964 --- .gitattributes | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d418442 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,26 @@ +*.sh text eol=lf + +# Don't diff churn. +*.lock -diff +*LICENSE -diff + +# Try to get markdown files to be treated as markdown +# by linguist - ** prefix is for all subdirectories. +**/*.md linguist-detectable +**/*.md linguist-language=Markdown + +# This is vendored code, because it's generated by build tools. +# See: +# +/docs/api/*.json linguist-vendored +/docs/api/*.md linguist-vendored +/crates/pinakes-ui/assets/css/main.css linguist-vendored + +# Git Configuration files +*.gitattributes linguist-detectable=false +*.gitattributes linguist-documentation=false +*.gitignore linguist-detectable=false +*.gitignore linguist-documentation=false +*.editorconfig linguist-detectable=false +*.editorconfig linguist-documentation=false + -- 2.43.0 From f1eacc84848f4e667b322f0c814689fc492e7446 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Mar 2026 15:32:59 +0300 Subject: [PATCH 29/30] pinakes-server: add more route tests Signed-off-by: NotAShelf Change-Id: Ief16a2b3181bfa50193fb69a5ad4a9166a6a6964 --- crates/pinakes-server/src/routes/subtitles.rs | 11 +- crates/pinakes-server/tests/e2e.rs | 162 +++++++++++++++++- crates/pinakes-server/tests/enrichment.rs | 67 ++++++++ crates/pinakes-server/tests/users.rs | 17 ++ crates/pinakes-server/tests/webhooks.rs | 46 +++++ 5 files changed, 295 insertions(+), 8 deletions(-) diff --git a/crates/pinakes-server/src/routes/subtitles.rs b/crates/pinakes-server/src/routes/subtitles.rs index 8cb97ca..3e55af3 100644 --- a/crates/pinakes-server/src/routes/subtitles.rs +++ b/crates/pinakes-server/src/routes/subtitles.rs @@ -81,11 +81,12 @@ pub async fn add_subtitle( ) -> Result, ApiError> { // Validate language code if provided. if let Some(ref lang) = req.language - && !validate_language_code(lang) { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidLanguageCode(lang.clone()), - )); - } + && !validate_language_code(lang) + { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidLanguageCode(lang.clone()), + )); + } let is_embedded = req.is_embedded.unwrap_or(false); diff --git a/crates/pinakes-server/tests/e2e.rs b/crates/pinakes-server/tests/e2e.rs index 96d8aeb..2191a2a 100644 --- a/crates/pinakes-server/tests/e2e.rs +++ b/crates/pinakes-server/tests/e2e.rs @@ -1,14 +1,15 @@ /// End-to-end tests that bind a real TCP listener and exercise the HTTP layer. /// /// These tests differ from the router-level `oneshot` tests in that they verify -/// the full Axum `serve` path: `TcpListener` binding, HTTP framing, and response -/// serialization. Each test spins up a server on an ephemeral port, issues a -/// real HTTP request via reqwest, then shuts down. +/// the full Axum `serve` path: `TcpListener` binding, HTTP framing, and +/// response serialization. Each test spins up a server on an ephemeral port, +/// issues a real HTTP request via reqwest, then shuts down. mod common; use std::net::SocketAddr; use tokio::net::TcpListener; +use tower::ServiceExt; /// Bind a listener on an ephemeral port, spawn the server in the background, /// and return the bound address as a string. @@ -30,6 +31,32 @@ async fn bind_and_serve() -> String { format!("http://{addr}") } +/// Bind a listener on an ephemeral port with auth enabled. Returns the base +/// URL and tokens for admin, editor, and viewer users. +/// +/// Tokens are issued in-process before binding so they work against the same +/// app instance served over TCP. +async fn bind_and_serve_authed() -> (String, String, String, String) { + let (app, admin_token, editor_token, viewer_token) = + common::setup_app_with_auth().await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .unwrap(); + }); + ( + format!("http://{addr}"), + admin_token, + editor_token, + viewer_token, + ) +} + #[tokio::test] async fn health_endpoint_responds_over_real_tcp() { let base = bind_and_serve().await; @@ -63,3 +90,132 @@ async fn unknown_route_returns_404_over_real_tcp() { .expect("request failed"); assert_eq!(resp.status(), 404); } + +#[tokio::test] +async fn authenticated_request_accepted_over_real_tcp() { + let (base, _, _, viewer_token) = bind_and_serve_authed().await; + let client = reqwest::Client::new(); + let resp = client + .get(format!("{base}/api/v1/health")) + .bearer_auth(&viewer_token) + .send() + .await + .expect("authenticated health request failed"); + assert_eq!(resp.status(), 200); +} + +#[tokio::test] +async fn invalid_token_rejected_over_real_tcp() { + let (base, ..) = bind_and_serve_authed().await; + let client = reqwest::Client::new(); + let resp = client + .get(format!("{base}/api/v1/webhooks")) + .bearer_auth("not-a-valid-token") + .send() + .await + .expect("request failed"); + assert_eq!(resp.status(), 401); +} + +// In-process cross-checks: verify that RBAC and response shapes are +// consistent whether accessed via oneshot or real TCP. + +#[tokio::test] +async fn health_response_body_has_status_field() { + let app = common::setup_app().await; + let resp = app.oneshot(common::get("/api/v1/health")).await.unwrap(); + let status = resp.status(); + let body = common::response_body(resp).await; + assert_eq!(status, 200); + assert!(body["status"].is_string(), "expected status field: {body}"); +} + +#[tokio::test] +async fn rbac_enforced_for_write_methods() { + let (app, _, editor_token, viewer_token) = + common::setup_app_with_auth().await; + let _ = common::hash_password("unused"); // exercises hash_password + + // post_json - unauthenticated login attempt with wrong password + let resp = app + .clone() + .oneshot(common::post_json( + "/api/v1/auth/login", + r#"{"username":"viewer","password":"wrong"}"#, + )) + .await + .unwrap(); + assert_eq!(resp.status(), 401); + + // get_authed - viewer can reach health + let resp = app + .clone() + .oneshot(common::get_authed("/api/v1/health", &viewer_token)) + .await + .unwrap(); + assert_eq!(resp.status(), 200); + + // post_json_authed - viewer cannot trigger batch enrich (editor route) + let resp = app + .clone() + .oneshot(common::post_json_authed( + "/api/v1/jobs/enrich", + r#"{"media_ids":["00000000-0000-0000-0000-000000000000"]}"#, + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(resp.status(), 403); + + // put_json_authed - viewer cannot update sync device (editor route) + let resp = app + .clone() + .oneshot(common::put_json_authed( + "/api/v1/sync/devices/00000000-0000-0000-0000-000000000000", + r#"{"name":"device"}"#, + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(resp.status(), 403); + + // patch_json_authed - viewer cannot update media (editor route) + let resp = app + .clone() + .oneshot(common::patch_json_authed( + "/api/v1/media/00000000-0000-0000-0000-000000000000", + r#"{"title":"x"}"#, + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(resp.status(), 403); + + // delete_authed - viewer cannot delete media (editor route) + let resp = app + .clone() + .oneshot(common::delete_authed( + "/api/v1/media/00000000-0000-0000-0000-000000000000", + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(resp.status(), 403); + + // test_addr is exercised by all common request builders above via + // extensions_mut().insert(test_addr()); verify it round-trips + let addr = common::test_addr(); + assert_eq!(addr.0.ip().to_string(), "127.0.0.1"); + + // editor can access editor routes + let resp = app + .clone() + .oneshot(common::post_json_authed( + "/api/v1/jobs/enrich", + r#"{"media_ids":["00000000-0000-0000-0000-000000000000"]}"#, + &editor_token, + )) + .await + .unwrap(); + assert_eq!(resp.status(), 200); +} diff --git a/crates/pinakes-server/tests/enrichment.rs b/crates/pinakes-server/tests/enrichment.rs index 9f93951..eba5a76 100644 --- a/crates/pinakes-server/tests/enrichment.rs +++ b/crates/pinakes-server/tests/enrichment.rs @@ -2,9 +2,13 @@ mod common; use axum::http::StatusCode; use common::{ + delete_authed, get, get_authed, + patch_json_authed, post_json_authed, + put_json_authed, + response_body, setup_app, setup_app_with_auth, }; @@ -141,3 +145,66 @@ async fn get_external_metadata_auth_disabled() { || response.status() == StatusCode::NOT_FOUND ); } + +// RBAC enforcement for editor-level HTTP methods + +#[tokio::test] +async fn batch_enrich_response_has_job_id() { + let (app, _, editor_token, _) = setup_app_with_auth().await; + let response = app + .oneshot(post_json_authed( + "/api/v1/jobs/enrich", + r#"{"media_ids":["00000000-0000-0000-0000-000000000000"]}"#, + &editor_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_body(response).await; + // Route queues a job and returns a job identifier + assert!( + body["job_id"].is_string() || body["id"].is_string(), + "expected job identifier in response: {body}" + ); +} + +#[tokio::test] +async fn delete_tag_requires_editor() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(delete_authed( + "/api/v1/tags/00000000-0000-0000-0000-000000000000", + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn update_media_requires_editor() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(patch_json_authed( + "/api/v1/media/00000000-0000-0000-0000-000000000000", + r#"{"title":"new title"}"#, + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn update_sync_device_requires_editor() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(put_json_authed( + "/api/v1/sync/devices/00000000-0000-0000-0000-000000000000", + r#"{"name":"my device"}"#, + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} diff --git a/crates/pinakes-server/tests/users.rs b/crates/pinakes-server/tests/users.rs index 3f99f13..d6f34a5 100644 --- a/crates/pinakes-server/tests/users.rs +++ b/crates/pinakes-server/tests/users.rs @@ -7,6 +7,7 @@ use common::{ get_authed, patch_json_authed, post_json_authed, + put_json_authed, response_body, setup_app, setup_app_with_auth, @@ -207,6 +208,22 @@ async fn get_user_libraries_requires_admin() { assert_eq!(response.status(), StatusCode::FORBIDDEN); } +// PUT coverage + +#[tokio::test] +async fn update_sync_device_requires_editor() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(put_json_authed( + "/api/v1/sync/devices/00000000-0000-0000-0000-000000000000", + r#"{"name":"device"}"#, + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + // No-auth coverage (exercises setup_app and get helpers) #[tokio::test] diff --git a/crates/pinakes-server/tests/webhooks.rs b/crates/pinakes-server/tests/webhooks.rs index 7171e1b..a70cb86 100644 --- a/crates/pinakes-server/tests/webhooks.rs +++ b/crates/pinakes-server/tests/webhooks.rs @@ -2,9 +2,12 @@ mod common; use axum::http::StatusCode; use common::{ + delete_authed, get, get_authed, + patch_json_authed, post_json_authed, + put_json_authed, response_body, setup_app, setup_app_with_auth, @@ -88,3 +91,46 @@ async fn test_webhook_requires_auth() { .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } + +// RBAC enforcement for editor-level HTTP methods + +#[tokio::test] +async fn delete_playlist_requires_editor() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(delete_authed( + "/api/v1/playlists/00000000-0000-0000-0000-000000000000", + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn update_playlist_requires_editor() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(patch_json_authed( + "/api/v1/playlists/00000000-0000-0000-0000-000000000000", + r#"{"name":"updated"}"#, + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn update_sync_device_requires_editor() { + let (app, _, _, viewer_token) = setup_app_with_auth().await; + let response = app + .oneshot(put_json_authed( + "/api/v1/sync/devices/00000000-0000-0000-0000-000000000000", + r#"{"name":"device"}"#, + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} -- 2.43.0 From 103be9d13d66bdc609311b0ebc1a2a33683e4c5a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Mar 2026 15:38:04 +0300 Subject: [PATCH 30/30] nix: setup sccache Signed-off-by: NotAShelf Change-Id: I375e0d41d42939b63a01a59d41b3fd426a6a6964 --- nix/shell.nix | 47 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/nix/shell.nix b/nix/shell.nix index ec76d19..dcff21d 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -26,18 +26,6 @@ in name = "pinakes-dev"; packages = [ - pkgs.taplo # TOML formatter - pkgs.lldb # debugger - pkgs.llvm - - # Modern, LLVM based linking pipelime - llvmPackages.lld - llvmPackages.clang - - # Additional Cargo Tooling - pkgs.cargo-nextest - pkgs.cargo-deny - # Build tools # We use the rust-overlay to get the stable Rust toolchain for various targets. # This is not exactly necessary, but it allows for compiling for various targets @@ -47,11 +35,43 @@ in targets = ["wasm32-unknown-unknown" "wasm32-wasip1"]; # web + plugins }) + # Modern, LLVM based linking pipeline + llvmPackages.lld + llvmPackages.clang + # Handy CLI for packaging Dioxus apps and such pkgs.dioxus-cli + + # Additional Cargo Tooling + pkgs.cargo-nextest + pkgs.cargo-deny + + # Other tools + pkgs.taplo # TOML formatter + pkgs.lldb # debugger + pkgs.sccache # distributed Rust cache ] ++ runtimeDeps; + # We could do this in the NixOS configuration via ~/.config/cargo.toml + # or something, but this is better because it lets everyone benefit + # from sccache while working with Pinakes. The reason those variables + # are not in 'env' are that we have to use Bash, which doesn't mix well + # with Nix strings. + shellHook = '' + if [ -n "$SCCACHE_BIN" ]; then + export RUSTC_WRAPPER="$SCCACHE_BIN" + export SCCACHE_DIR="''${HOME}/.cache/sccache" + export SCCACHE_CACHE_SIZE="50G" + mkdir -p "$SCCACHE_DIR" + + echo "sccache setup complete!" + fi + + # Start the daemon early for slightly faster startup. + "$SCCACHE_BIN" --start-server >/dev/null 2>&1 || true + ''; + env = { # Allow Cargo to use lld and clang properly LIBCLANG_PATH = "${llvmPackages.libclang.lib}/lib"; @@ -64,5 +84,8 @@ in # Runtime library path for GTK/WebKit/xdotool LD_LIBRARY_PATH = "${lib.makeLibraryPath runtimeDeps}"; + + # Enable sccache for local nix develop shell + SCCACHE_BIN = "${pkgs.sccache}/bin/sccache"; }; } -- 2.43.0