From d26682fffae428204ee0986a11b2591c70079a9f Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:52:35 +0000 Subject: [PATCH 1/9] wip: rescue in-flight MariaDB swap from crashed agent Preservation commit. A previous agent performed most of P1's database work and crashed before committing any of it; 94 files were sitting unstaged on this branch with zero commits ahead of main. Nothing here is reviewed. Committed as-is, unsplit, so the work survives. State at time of rescue, measured against origin/main (81c865bf): migrations 18 files deleted, replaced by 001_baseline.sql (907 lines, 60 tables, MariaDB 11.4 dialect) sqlx feature postgres -> mysql PgPool refs 153 -> 0 $n placeholders 1420 -> 1 ON CONFLICT 77 -> 0 RETURNING 78 -> 8 crate rename stackarr-postgres -> stackarr-mariadb Two things need a decision before any of this becomes a PR: 1. The rename dropped the embedded-database subsystem. stackarr-postgres was 1,216 LOC of Postgres provisioning (download binaries, initdb, supervise a child process, in external/managed/embedded modes). stackarr-mariadb is 98 LOC; config.rs, lifecycle.rs and provision.rs were deleted rather than ported. Whether StackArr still ships a self-provisioning database is a product decision, not a task detail. 2. This spans T21-T25 in one commit. It must be split per item before review, since the repository requires one item per branch and PR. Refs #59, #60, #61, #62, #64 --- Cargo.lock | 29 +- Cargo.toml | 9 +- crates/stackarr-core/Cargo.toml | 1 + crates/stackarr-core/src/config.rs | 26 +- crates/stackarr-core/src/dav_db.rs | 100 +- crates/stackarr-core/src/db.rs | 365 +++---- crates/stackarr-core/src/models/download.rs | 1 + .../src/models/import_candidate.rs | 62 +- crates/stackarr-core/src/models/media.rs | 4 + crates/stackarr-core/src/models/rss.rs | 1 + crates/stackarr-core/src/test_helpers.rs | 91 +- crates/stackarr-import/src/lib.rs | 129 ++- crates/stackarr-import/src/recycle_bin.rs | 28 +- crates/stackarr-import/src/tmdb_match.rs | 7 +- crates/stackarr-import/src/upgrade.rs | 20 +- crates/stackarr-mariadb/Cargo.toml | 13 + .../pg-binaries/.gitkeep | 0 crates/stackarr-mariadb/src/error.rs | 13 + crates/stackarr-mariadb/src/lib.rs | 85 ++ crates/stackarr-media/src/import_lists.rs | 46 +- crates/stackarr-media/src/lib.rs | 240 ++--- crates/stackarr-migrate/src/lib.rs | 2 +- crates/stackarr-migrate/src/writer.rs | 186 ++-- crates/stackarr-notify/src/lib.rs | 2 +- crates/stackarr-plex/src/scanner.rs | 22 +- crates/stackarr-plex/src/sync.rs | 54 +- crates/stackarr-postgres/Cargo.toml | 30 - crates/stackarr-postgres/src/config.rs | 71 -- crates/stackarr-postgres/src/error.rs | 86 -- crates/stackarr-postgres/src/lib.rs | 44 - crates/stackarr-postgres/src/lifecycle.rs | 574 ----------- crates/stackarr-postgres/src/provision.rs | 441 --------- crates/stackarr-quality/src/lib.rs | 58 +- crates/stackarr-scheduler/src/auto_search.rs | 133 +-- crates/stackarr-scheduler/src/health.rs | 42 +- crates/stackarr-scheduler/src/lib.rs | 92 +- crates/stackarr-scheduler/src/rss.rs | 28 +- crates/stackarr-stream/src/session.rs | 17 +- crates/stackarr-web/src/dav_manager.rs | 6 +- crates/stackarr-web/src/routes/auth.rs | 8 +- crates/stackarr-web/src/routes/backup.rs | 41 +- crates/stackarr-web/src/routes/blocklist.rs | 38 +- crates/stackarr-web/src/routes/bootstrap.rs | 17 +- crates/stackarr-web/src/routes/calendar.rs | 4 +- crates/stackarr-web/src/routes/dav.rs | 6 +- crates/stackarr-web/src/routes/discover.rs | 183 ++-- .../src/routes/downloadclients.rs | 68 +- crates/stackarr-web/src/routes/episodes.rs | 33 +- crates/stackarr-web/src/routes/general.rs | 42 +- crates/stackarr-web/src/routes/history.rs | 4 +- .../src/routes/import_candidates.rs | 49 +- crates/stackarr-web/src/routes/indexers.rs | 141 +-- .../stackarr-web/src/routes/manual_import.rs | 20 +- .../src/routes/medialibraryfolders.rs | 30 +- .../src/routes/mediamanagement.rs | 8 +- crates/stackarr-web/src/routes/movies.rs | 41 +- crates/stackarr-web/src/routes/naming.rs | 20 +- .../src/routes/notification_providers.rs | 127 +-- .../stackarr-web/src/routes/notifications.rs | 2 +- crates/stackarr-web/src/routes/plex.rs | 163 ++-- crates/stackarr-web/src/routes/queue.rs | 2 +- crates/stackarr-web/src/routes/releases.rs | 85 +- crates/stackarr-web/src/routes/requests.rs | 4 +- crates/stackarr-web/src/routes/rss.rs | 156 +-- crates/stackarr-web/src/routes/series.rs | 76 +- crates/stackarr-web/src/routes/stream.rs | 14 +- crates/stackarr-web/src/routes/stremio.rs | 40 +- crates/stackarr-web/src/routes/system.rs | 244 ++--- crates/stackarr-web/src/routes/tags.rs | 60 +- crates/stackarr-web/src/routes/torrent.rs | 8 +- crates/stackarr-web/src/routes/usenet.rs | 117 ++- crates/stackarr-web/src/routes/wanted.rs | 48 +- crates/stackarr-web/src/routes/watchlist.rs | 8 +- crates/stackarr-web/src/state.rs | 4 +- docs/MARIADB-PLACEHOLDER-AUDIT.json | 62 +- migrations/001_baseline.sql | 907 ++++++++++++++++++ migrations/001_initial.sql | 388 -------- migrations/002_streaming.sql | 19 - migrations/003_health_check.sql | 13 - migrations/004_remote_access.sql | 11 - migrations/005_quality_profile_media_type.sql | 2 - migrations/006_users.sql | 148 --- migrations/007_language_queue.sql | 10 - migrations/008_system_activities.sql | 16 - migrations/009_plex_verify_tls.sql | 3 - migrations/010_media_management.sql | 18 - migrations/011_plex_deep_integration.sql | 19 - migrations/012_rss.sql | 43 - migrations/013_queue_output_path.sql | 4 - migrations/014_custom_format_fields.sql | 2 - migrations/015_normalize_quality_integers.sql | 34 - migrations/016_nzbdav.sql | 99 -- migrations/017_performance_indexes.sql | 6 - migrations/018_import_candidates.sql | 64 -- scripts/convert_sql_placeholders.py | 43 +- scripts/swap_sqlx_backend.py | 35 + src/main.rs | 64 +- 97 files changed, 3120 insertions(+), 3959 deletions(-) create mode 100644 crates/stackarr-mariadb/Cargo.toml rename crates/{stackarr-postgres => stackarr-mariadb}/pg-binaries/.gitkeep (100%) create mode 100644 crates/stackarr-mariadb/src/error.rs create mode 100644 crates/stackarr-mariadb/src/lib.rs delete mode 100644 crates/stackarr-postgres/Cargo.toml delete mode 100644 crates/stackarr-postgres/src/config.rs delete mode 100644 crates/stackarr-postgres/src/error.rs delete mode 100644 crates/stackarr-postgres/src/lib.rs delete mode 100644 crates/stackarr-postgres/src/lifecycle.rs delete mode 100644 crates/stackarr-postgres/src/provision.rs create mode 100644 migrations/001_baseline.sql delete mode 100644 migrations/001_initial.sql delete mode 100644 migrations/002_streaming.sql delete mode 100644 migrations/003_health_check.sql delete mode 100644 migrations/004_remote_access.sql delete mode 100644 migrations/005_quality_profile_media_type.sql delete mode 100644 migrations/006_users.sql delete mode 100644 migrations/007_language_queue.sql delete mode 100644 migrations/008_system_activities.sql delete mode 100644 migrations/009_plex_verify_tls.sql delete mode 100644 migrations/010_media_management.sql delete mode 100644 migrations/011_plex_deep_integration.sql delete mode 100644 migrations/012_rss.sql delete mode 100644 migrations/013_queue_output_path.sql delete mode 100644 migrations/014_custom_format_fields.sql delete mode 100644 migrations/015_normalize_quality_integers.sql delete mode 100644 migrations/016_nzbdav.sql delete mode 100644 migrations/017_performance_indexes.sql delete mode 100644 migrations/018_import_candidates.sql create mode 100644 scripts/swap_sqlx_backend.py diff --git a/Cargo.lock b/Cargo.lock index 61433b54..8685b017 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4740,7 +4740,6 @@ dependencies = [ "stackarr-indexer", "stackarr-metadata", "stackarr-migrate", - "stackarr-postgres", "stackarr-scheduler", "stackarr-stream", "stackarr-web", @@ -4811,6 +4810,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sqlx", + "stackarr-mariadb", "thiserror 2.0.19", "tokio", "toml 1.1.4+spec-1.1.0", @@ -4888,6 +4888,15 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "stackarr-mariadb" +version = "0.1.0" +dependencies = [ + "sqlx", + "thiserror 2.0.19", + "tracing", +] + [[package]] name = "stackarr-media" version = "0.1.0" @@ -4993,24 +5002,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "stackarr-postgres" -version = "0.1.0" -dependencies = [ - "anyhow", - "chrono", - "libc", - "reqwest 0.13.4", - "rust-embed", - "serde", - "serde_json", - "stackarr-core", - "tempfile", - "thiserror 2.0.19", - "tokio", - "tracing", -] - [[package]] name = "stackarr-quality" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 85067f7f..c77ee43b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ members = [ "crates/stackarr-cardigann", "crates/stackarr-cardigann-parity", "crates/stackarr-stream", - "crates/stackarr-postgres", + "crates/stackarr-mariadb", ] [workspace.package] @@ -50,7 +50,7 @@ tower-http = { version = "0.6", features = ["cors", "trace", "fs", "set-header", http = "1" # Database -sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio", "postgres", "chrono", "json", "uuid", "derive", "macros", "migrate"] } +sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio", "mysql", "chrono", "json", "uuid", "derive", "macros", "migrate"] } rusqlite = { version = "0.32", features = ["bundled"] } # HTTP client @@ -193,7 +193,7 @@ stackarr-migrate = { path = "crates/stackarr-migrate" } stackarr-plex = { path = "crates/stackarr-plex" } stackarr-cardigann = { path = "crates/stackarr-cardigann" } stackarr-stream = { path = "crates/stackarr-stream" } -stackarr-postgres = { path = "crates/stackarr-postgres" } +stackarr-mariadb = { path = "crates/stackarr-mariadb" } # SwarmForge 0.1.0, published by rustTorrent. Cargo aliases preserve the # existing librtbit API names while the canonical crates.io package names make @@ -236,8 +236,6 @@ license.workspace = true [features] default = ["ui"] ui = [] -managed-postgres = ["dep:stackarr-postgres"] -embed-postgres = ["stackarr-postgres/embed", "managed-postgres"] embed-ui = ["stackarr-web/embed-ui"] [dependencies] @@ -269,7 +267,6 @@ network-interface = { workspace = true } librtbit-upnp = { workspace = true } serde = { workspace = true } rustls = { workspace = true } -stackarr-postgres = { workspace = true, optional = true } [dev-dependencies] # Select the otherwise optional twelfth SwarmForge crate so the lockfile and diff --git a/crates/stackarr-core/Cargo.toml b/crates/stackarr-core/Cargo.toml index 130252c1..b4690b6a 100644 --- a/crates/stackarr-core/Cargo.toml +++ b/crates/stackarr-core/Cargo.toml @@ -12,6 +12,7 @@ default = [] testing = [] [dependencies] +stackarr-mariadb = { workspace = true } sqlx = { workspace = true } nzbdav-core = { workspace = true } async-trait = { workspace = true } diff --git a/crates/stackarr-core/src/config.rs b/crates/stackarr-core/src/config.rs index c6e2301c..74203e7f 100644 --- a/crates/stackarr-core/src/config.rs +++ b/crates/stackarr-core/src/config.rs @@ -40,18 +40,9 @@ pub struct GeneralConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DatabaseConfig { - /// Database mode: "external" (default), "managed", or "embedded". - #[serde(default = "default_db_mode")] - pub mode: String, pub url: String, #[serde(default = "default_max_connections")] pub max_connections: u32, - /// Where managed PostgreSQL stores its data. Defaults to `{data_dir}/postgres`. - #[serde(default)] - pub data_dir: Option, - /// Port for managed PostgreSQL (default 5433, avoids conflict with system PG). - #[serde(default = "default_pg_port")] - pub port: u16, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -417,12 +408,6 @@ fn default_log_level() -> String { fn default_max_connections() -> u32 { 20 } -fn default_db_mode() -> String { - "external".to_string() -} -fn default_pg_port() -> u16 { - 5433 -} fn default_auth_method() -> String { "forms".to_string() } @@ -570,11 +555,8 @@ impl Default for GeneralConfig { impl Default for DatabaseConfig { fn default() -> Self { Self { - mode: default_db_mode(), - url: "postgresql://stackarr:stackarr@localhost:5432/stackarr".to_string(), + url: "mysql://stackarr:stackarr@localhost:3306/stackarr".to_string(), max_connections: default_max_connections(), - data_dir: None, - port: default_pg_port(), } } } @@ -609,11 +591,9 @@ impl AppConfig { /// Validate config values and emit warnings for common misconfigurations. /// Returns an error only for values that will definitely break at runtime. pub fn validate(&self) -> crate::Result<()> { - // Database URL is required for external mode - if self.database.mode == "external" && self.database.url.is_empty() { + if self.database.url.is_empty() { return Err(crate::Error::Config( - "database.url is required when database.mode = \"external\". \ - Set it in your config file or pass --database-url." + "database.url is required. Set it in your config file or pass --database-url." .to_string(), )); } diff --git a/crates/stackarr-core/src/dav_db.rs b/crates/stackarr-core/src/dav_db.rs index b4831a30..f11d98db 100644 --- a/crates/stackarr-core/src/dav_db.rs +++ b/crates/stackarr-core/src/dav_db.rs @@ -1,19 +1,19 @@ -//! `PostgresDavDatabase` — implements nzbdav-core's `DavDatabase` trait for PostgreSQL. +//! `MariaDbDavDatabase` — implements nzbdav-core's `DavDatabase` trait for MariaDB. use chrono::{DateTime, NaiveDateTime, Utc}; -use sqlx::PgPool; +use sqlx::MySqlPool; use uuid::Uuid; use nzbdav_core::database::DavDatabase; use nzbdav_core::error::{DavError, Result}; use nzbdav_core::models::{DavItem, DownloadStatus, HistoryItem, ItemSubType, ItemType, QueueItem}; -pub struct PostgresDavDatabase { - pool: PgPool, +pub struct MariaDbDavDatabase { + pool: MySqlPool, } -impl PostgresDavDatabase { - pub fn new(pool: PgPool) -> Self { +impl MariaDbDavDatabase { + pub fn new(pool: MySqlPool) -> Self { Self { pool } } @@ -147,7 +147,7 @@ impl TryFrom for HistoryItem { } #[async_trait::async_trait] -impl DavDatabase for PostgresDavDatabase { +impl DavDatabase for MariaDbDavDatabase { // ── DavItem ──────────────────────────────────────────────────────── async fn insert_dav_item(&self, item: &DavItem) -> Result<()> { @@ -155,11 +155,10 @@ impl DavDatabase for PostgresDavDatabase { "INSERT INTO dav_items (id, id_prefix, created_at, parent_id, name, file_size, \ item_type, sub_type, path, release_date, last_health_check, next_health_check, \ history_item_id, file_blob_id, nzb_blob_id) \ - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) \ - ON CONFLICT (id) DO UPDATE SET \ - name = EXCLUDED.name, path = EXCLUDED.path, parent_id = EXCLUDED.parent_id, \ - file_size = EXCLUDED.file_size, file_blob_id = EXCLUDED.file_blob_id, \ - nzb_blob_id = EXCLUDED.nzb_blob_id", + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) \ + ON DUPLICATE KEY UPDATE name = VALUES(name), path = VALUES(path), \ + parent_id = VALUES(parent_id), file_size = VALUES(file_size), \ + file_blob_id = VALUES(file_blob_id), nzb_blob_id = VALUES(nzb_blob_id)", ) .bind(item.id) .bind(&item.id_prefix) @@ -186,7 +185,7 @@ impl DavDatabase for PostgresDavDatabase { } async fn get_dav_item_by_id(&self, id: Uuid) -> Result> { - let row: Option = sqlx::query_as("SELECT * FROM dav_items WHERE id = $1") + let row: Option = sqlx::query_as("SELECT * FROM dav_items WHERE id = ?") .bind(id) .fetch_optional(&self.pool) .await @@ -195,7 +194,7 @@ impl DavDatabase for PostgresDavDatabase { } async fn get_dav_item_by_path(&self, path: &str) -> Result> { - let row: Option = sqlx::query_as("SELECT * FROM dav_items WHERE path = $1") + let row: Option = sqlx::query_as("SELECT * FROM dav_items WHERE path = ?") .bind(path) .fetch_optional(&self.pool) .await @@ -204,7 +203,7 @@ impl DavDatabase for PostgresDavDatabase { } async fn get_dav_children(&self, parent_id: Uuid) -> Result> { - let rows: Vec = sqlx::query_as("SELECT * FROM dav_items WHERE parent_id = $1") + let rows: Vec = sqlx::query_as("SELECT * FROM dav_items WHERE parent_id = ?") .bind(parent_id) .fetch_all(&self.pool) .await @@ -216,7 +215,7 @@ impl DavDatabase for PostgresDavDatabase { let rows: Vec = sqlx::query_as( "SELECT c.* FROM dav_items c \ INNER JOIN dav_items p ON c.parent_id = p.id \ - WHERE p.path = $1", + WHERE p.path = ?", ) .bind(parent_path) .fetch_all(&self.pool) @@ -226,7 +225,7 @@ impl DavDatabase for PostgresDavDatabase { } async fn delete_dav_item(&self, id: Uuid) -> Result<()> { - sqlx::query("DELETE FROM dav_items WHERE id = $1") + sqlx::query("DELETE FROM dav_items WHERE id = ?") .bind(id) .execute(&self.pool) .await @@ -235,7 +234,7 @@ impl DavDatabase for PostgresDavDatabase { } async fn delete_dav_items_by_history(&self, history_item_id: Uuid) -> Result<()> { - sqlx::query("DELETE FROM dav_items WHERE history_item_id = $1") + sqlx::query("DELETE FROM dav_items WHERE history_item_id = ?") .bind(history_item_id) .execute(&self.pool) .await @@ -250,7 +249,7 @@ impl DavDatabase for PostgresDavDatabase { new_path: &str, new_parent_id: Uuid, ) -> Result<()> { - sqlx::query("UPDATE dav_items SET name = $1, path = $2, parent_id = $3 WHERE id = $4") + sqlx::query("UPDATE dav_items SET name = ?, path = ?, parent_id = ? WHERE id = ?") .bind(new_name) .bind(new_path) .bind(new_parent_id) @@ -268,7 +267,7 @@ impl DavDatabase for PostgresDavDatabase { next: DateTime, ) -> Result<()> { let result = sqlx::query( - "UPDATE dav_items SET last_health_check = $1, next_health_check = $2 WHERE id = $3", + "UPDATE dav_items SET last_health_check = ?, next_health_check = ? WHERE id = ?", ) .bind(last) .bind(next) @@ -285,7 +284,7 @@ impl DavDatabase for PostgresDavDatabase { // ── Blobs ────────────────────────────────────────────────────────── async fn get_file_blob(&self, id: Uuid) -> Result> { - let row: Option<(Vec,)> = sqlx::query_as("SELECT data FROM dav_blobs WHERE id = $1") + let row: Option<(Vec,)> = sqlx::query_as("SELECT data FROM dav_blobs WHERE id = ?") .bind(id) .fetch_optional(&self.pool) .await @@ -296,8 +295,8 @@ impl DavDatabase for PostgresDavDatabase { async fn put_file_blob(&self, id: Uuid, data: &[u8]) -> Result<()> { sqlx::query( - "INSERT INTO dav_blobs (id, data) VALUES ($1, $2) \ - ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data", + "INSERT INTO dav_blobs (id, data) VALUES (?, ?) \ + ON DUPLICATE KEY UPDATE data = VALUES(data)", ) .bind(id) .bind(data) @@ -308,20 +307,19 @@ impl DavDatabase for PostgresDavDatabase { } async fn get_nzb_blob(&self, id: Uuid) -> Result> { - let row: Option<(Vec,)> = - sqlx::query_as("SELECT data FROM dav_nzb_blobs WHERE id = $1") - .bind(id) - .fetch_optional(&self.pool) - .await - .map_err(db_err)?; + let row: Option<(Vec,)> = sqlx::query_as("SELECT data FROM dav_nzb_blobs WHERE id = ?") + .bind(id) + .fetch_optional(&self.pool) + .await + .map_err(db_err)?; row.map(|r| r.0) .ok_or_else(|| DavError::BlobNotFound(id.to_string())) } async fn put_nzb_blob(&self, id: Uuid, data: &[u8]) -> Result<()> { sqlx::query( - "INSERT INTO dav_nzb_blobs (id, data) VALUES ($1, $2) \ - ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data", + "INSERT INTO dav_nzb_blobs (id, data) VALUES (?, ?) \ + ON DUPLICATE KEY UPDATE data = VALUES(data)", ) .bind(id) .bind(data) @@ -332,7 +330,7 @@ impl DavDatabase for PostgresDavDatabase { } async fn delete_nzb_blob(&self, id: Uuid) -> Result<()> { - sqlx::query("DELETE FROM dav_nzb_blobs WHERE id = $1") + sqlx::query("DELETE FROM dav_nzb_blobs WHERE id = ?") .bind(id) .execute(&self.pool) .await @@ -362,16 +360,20 @@ impl DavDatabase for PostgresDavDatabase { .await .map_err(db_err)? } else { - sqlx::query_as( + let mut query = sqlx::QueryBuilder::new( "SELECT * FROM dav_queue_items \ - WHERE (pause_until IS NULL OR pause_until <= NOW()) \ - AND id != ALL($1) \ - ORDER BY priority DESC, created_at ASC LIMIT 1", - ) - .bind(exclude_ids) - .fetch_optional(&self.pool) - .await - .map_err(db_err)? + WHERE (pause_until IS NULL OR pause_until <= NOW()) AND id NOT IN (", + ); + let mut separated = query.separated(", "); + for id in exclude_ids { + separated.push_bind(id); + } + separated.push_unseparated(") ORDER BY priority DESC, created_at ASC LIMIT 1"); + query + .build_query_as() + .fetch_optional(&self.pool) + .await + .map_err(db_err)? }; Ok(row.map(QueueItem::from)) } @@ -380,7 +382,7 @@ impl DavDatabase for PostgresDavDatabase { sqlx::query( "INSERT INTO dav_queue_items (id, created_at, file_name, job_name, nzb_file_size, \ total_segment_bytes, category, priority, post_processing, pause_until) \ - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)", + VALUES (?,?,?,?,?,?,?,?,?,?)", ) .bind(item.id) .bind(DateTime::::from_naive_utc_and_offset( @@ -405,7 +407,7 @@ impl DavDatabase for PostgresDavDatabase { } async fn delete_queue_item(&self, id: Uuid) -> Result<()> { - sqlx::query("DELETE FROM dav_queue_items WHERE id = $1") + sqlx::query("DELETE FROM dav_queue_items WHERE id = ?") .bind(id) .execute(&self.pool) .await @@ -418,7 +420,7 @@ impl DavDatabase for PostgresDavDatabase { id: Uuid, pause_until: Option, ) -> Result<()> { - sqlx::query("UPDATE dav_queue_items SET pause_until = $1 WHERE id = $2") + sqlx::query("UPDATE dav_queue_items SET pause_until = ? WHERE id = ?") .bind(pause_until.map(|dt| DateTime::::from_naive_utc_and_offset(dt, Utc))) .bind(id) .execute(&self.pool) @@ -442,7 +444,7 @@ impl DavDatabase for PostgresDavDatabase { "INSERT INTO dav_history_items (id, created_at, file_name, job_name, category, \ download_status, total_segment_bytes, download_time_seconds, fail_message, \ download_dir_id, nzb_blob_id) \ - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)", + VALUES (?,?,?,?,?,?,?,?,?,?,?)", ) .bind(item.id) .bind(DateTime::::from_naive_utc_and_offset( @@ -466,7 +468,7 @@ impl DavDatabase for PostgresDavDatabase { async fn list_history_items(&self, offset: i64, limit: i64) -> Result> { let rows: Vec = sqlx::query_as( - "SELECT * FROM dav_history_items ORDER BY created_at DESC LIMIT $1 OFFSET $2", + "SELECT * FROM dav_history_items ORDER BY created_at DESC LIMIT ? OFFSET ?", ) .bind(limit) .bind(offset) @@ -477,7 +479,7 @@ impl DavDatabase for PostgresDavDatabase { } async fn delete_history_item(&self, id: Uuid) -> Result<()> { - sqlx::query("DELETE FROM dav_history_items WHERE id = $1") + sqlx::query("DELETE FROM dav_history_items WHERE id = ?") .bind(id) .execute(&self.pool) .await @@ -513,8 +515,8 @@ impl DavDatabase for PostgresDavDatabase { async fn set_config_item(&self, key: &str, value: &str) -> Result<()> { sqlx::query( - "INSERT INTO dav_config (key, value) VALUES ($1, $2) \ - ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value", + "INSERT INTO dav_config (`key`, value) VALUES (?, ?) \ + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(key) .bind(value) diff --git a/crates/stackarr-core/src/db.rs b/crates/stackarr-core/src/db.rs index d08d57c4..d7c3845d 100644 --- a/crates/stackarr-core/src/db.rs +++ b/crates/stackarr-core/src/db.rs @@ -1,9 +1,6 @@ -use std::time::Duration; - use chrono::{DateTime, Utc}; use serde::Serialize; -use sqlx::PgPool; -use sqlx::postgres::PgPoolOptions; +use sqlx::MySqlPool; use uuid::Uuid; use crate::config::{DatabaseConfig, EnabledModules}; @@ -14,27 +11,23 @@ use crate::models::user::{ #[derive(Clone)] pub struct Database { - pool: PgPool, + pool: MySqlPool, } impl Database { pub async fn connect(config: &DatabaseConfig) -> crate::Result { - let pool = PgPoolOptions::new() - .max_connections(config.max_connections) - .idle_timeout(Duration::from_secs(300)) - .max_lifetime(Duration::from_secs(1800)) - .acquire_timeout(Duration::from_secs(10)) - .connect(&config.url) - .await?; + let pool = stackarr_mariadb::connect(&config.url, config.max_connections) + .await + .map_err(|error| crate::Error::Config(error.to_string()))?; Ok(Self { pool }) } - pub fn pool(&self) -> &PgPool { + pub fn pool(&self) -> &MySqlPool { &self.pool } /// Create a Database wrapper from an existing pool. - pub fn from_pool(pool: PgPool) -> Self { + pub fn from_pool(pool: MySqlPool) -> Self { Self { pool } } @@ -103,8 +96,8 @@ impl Database { for (name, enabled) in module_list { sqlx::query( - "INSERT INTO enabled_modules (module, enabled) VALUES ($1, $2) - ON CONFLICT (module) DO UPDATE SET enabled = $2", + "INSERT INTO enabled_modules (module, enabled) VALUES (?, ?) + ON DUPLICATE KEY UPDATE enabled = VALUES(enabled)", ) .bind(name) .bind(enabled) @@ -133,7 +126,7 @@ impl Database { } None => { let new_id = Uuid::new_v4(); - sqlx::query("INSERT INTO app_config (key, value) VALUES ('server_id', $1)") + sqlx::query("INSERT INTO app_config (key, value) VALUES ('server_id', ?)") .bind(serde_json::Value::String(new_id.to_string())) .execute(&self.pool) .await?; @@ -145,12 +138,12 @@ impl Database { // ── Remote clients ────────────────────────────────────────────────── pub async fn create_remote_client(&self, client_token: Uuid) -> crate::Result { - let row: (i32,) = - sqlx::query_as("INSERT INTO remote_clients (client_token) VALUES ($1) RETURNING id") - .bind(client_token) - .fetch_one(&self.pool) - .await?; - Ok(row.0) + let result = sqlx::query("INSERT INTO remote_clients (client_token) VALUES (?)") + .bind(client_token) + .execute(&self.pool) + .await?; + i32::try_from(result.last_insert_id()) + .map_err(|_| crate::Error::Config("remote client id exceeds i32".into())) } pub async fn set_remote_client_name( @@ -159,8 +152,8 @@ impl Database { name: &str, ) -> crate::Result { let result = sqlx::query( - "UPDATE remote_clients SET client_name = $1, last_seen = NOW() \ - WHERE client_token = $2 AND revoked = false", + "UPDATE remote_clients SET client_name = ?, last_seen = NOW() \ + WHERE client_token = ? AND revoked = false", ) .bind(name) .bind(client_token) @@ -171,7 +164,7 @@ impl Database { pub async fn validate_remote_client(&self, client_token: Uuid) -> crate::Result { let row: Option<(bool,)> = - sqlx::query_as("SELECT revoked FROM remote_clients WHERE client_token = $1") + sqlx::query_as("SELECT revoked FROM remote_clients WHERE client_token = ?") .bind(client_token) .fetch_optional(&self.pool) .await?; @@ -182,7 +175,7 @@ impl Database { } pub async fn touch_remote_client(&self, client_token: Uuid) -> crate::Result<()> { - sqlx::query("UPDATE remote_clients SET last_seen = NOW() WHERE client_token = $1") + sqlx::query("UPDATE remote_clients SET last_seen = NOW() WHERE client_token = ?") .bind(client_token) .execute(&self.pool) .await?; @@ -200,7 +193,7 @@ impl Database { } pub async fn revoke_remote_client(&self, id: i32) -> crate::Result { - let result = sqlx::query("UPDATE remote_clients SET revoked = true WHERE id = $1") + let result = sqlx::query("UPDATE remote_clients SET revoked = true WHERE id = ?") .bind(id) .execute(&self.pool) .await?; @@ -208,7 +201,7 @@ impl Database { } pub async fn delete_remote_client(&self, id: i32) -> crate::Result { - let result = sqlx::query("DELETE FROM remote_clients WHERE id = $1") + let result = sqlx::query("DELETE FROM remote_clients WHERE id = ?") .bind(id) .execute(&self.pool) .await?; @@ -224,21 +217,23 @@ impl Database { password_hash: &str, role: &str, ) -> crate::Result { - let user = sqlx::query_as::<_, User>( + let result = sqlx::query( "INSERT INTO users (username, display_name, password_hash, role) \ - VALUES ($1, $2, $3, $4) RETURNING *", + VALUES (?, ?, ?, ?)", ) .bind(username) .bind(display_name) .bind(password_hash) .bind(role) - .fetch_one(&self.pool) + .execute(&self.pool) .await?; - Ok(user) + self.get_user_by_id(result.last_insert_id() as i64) + .await? + .ok_or_else(|| crate::Error::Config("created user was not found".into())) } pub async fn get_user_by_id(&self, id: i64) -> crate::Result> { - let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") + let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?") .bind(id) .fetch_optional(&self.pool) .await?; @@ -251,7 +246,7 @@ impl Database { // still resolve to the stored `exampleuser` row without the login / basic // auth middleware needing its own normalization step. let user = - sqlx::query_as::<_, User>("SELECT * FROM users WHERE LOWER(username) = LOWER($1)") + sqlx::query_as::<_, User>("SELECT * FROM users WHERE LOWER(username) = LOWER(?)") .bind(username) .fetch_optional(&self.pool) .await?; @@ -273,23 +268,23 @@ impl Database { enabled: bool, avatar_url: Option<&str>, ) -> crate::Result> { - let user = sqlx::query_as::<_, User>( - "UPDATE users SET display_name = $1, role = $2, enabled = $3, avatar_url = $4, \ - updated_at = NOW() WHERE id = $5 RETURNING *", + sqlx::query( + "UPDATE users SET display_name = ?, role = ?, enabled = ?, avatar_url = ?, \ + updated_at = NOW() WHERE id = ?", ) .bind(display_name) .bind(role) .bind(enabled) .bind(avatar_url) .bind(id) - .fetch_optional(&self.pool) + .execute(&self.pool) .await?; - Ok(user) + self.get_user_by_id(id).await } pub async fn update_user_password(&self, id: i64, password_hash: &str) -> crate::Result { let result = - sqlx::query("UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2") + sqlx::query("UPDATE users SET password_hash = ?, updated_at = NOW() WHERE id = ?") .bind(password_hash) .bind(id) .execute(&self.pool) @@ -298,7 +293,7 @@ impl Database { } pub async fn delete_user(&self, id: i64) -> crate::Result { - let result = sqlx::query("DELETE FROM users WHERE id = $1") + let result = sqlx::query("DELETE FROM users WHERE id = ?") .bind(id) .execute(&self.pool) .await?; @@ -322,19 +317,22 @@ impl Database { ip_address: Option<&str>, expires_at: DateTime, ) -> crate::Result { - let session = sqlx::query_as::<_, UserSession>( + let result = sqlx::query( "INSERT INTO user_sessions (user_id, token_hash, user_agent, ip_address, expires_at) \ - VALUES ($1, $2, $3, $4::INET, $5) RETURNING id, user_id, token_hash, user_agent, \ - ip_address::TEXT, created_at, expires_at, last_active", + VALUES (?, ?, ?, ?, ?)", ) .bind(user_id) .bind(token_hash) .bind(user_agent) .bind(ip_address) .bind(expires_at) - .fetch_one(&self.pool) + .execute(&self.pool) .await?; - Ok(session) + sqlx::query_as::<_, UserSession>("SELECT * FROM user_sessions WHERE id = ?") + .bind(result.last_insert_id() as i64) + .fetch_one(&self.pool) + .await + .map_err(Into::into) } /// Validate a session token hash and return the associated user if valid. @@ -343,7 +341,7 @@ impl Database { let user = sqlx::query_as::<_, User>( "SELECT u.* FROM users u \ INNER JOIN user_sessions s ON s.user_id = u.id \ - WHERE s.token_hash = $1 AND s.expires_at > NOW() AND u.enabled = true", + WHERE s.token_hash = ? AND s.expires_at > NOW() AND u.enabled = true", ) .bind(token_hash) .fetch_optional(&self.pool) @@ -352,7 +350,7 @@ impl Database { } pub async fn touch_session(&self, token_hash: &str) -> crate::Result<()> { - sqlx::query("UPDATE user_sessions SET last_active = NOW() WHERE token_hash = $1") + sqlx::query("UPDATE user_sessions SET last_active = NOW() WHERE token_hash = ?") .bind(token_hash) .execute(&self.pool) .await?; @@ -360,7 +358,7 @@ impl Database { } pub async fn delete_session(&self, token_hash: &str) -> crate::Result { - let result = sqlx::query("DELETE FROM user_sessions WHERE token_hash = $1") + let result = sqlx::query("DELETE FROM user_sessions WHERE token_hash = ?") .bind(token_hash) .execute(&self.pool) .await?; @@ -368,7 +366,7 @@ impl Database { } pub async fn delete_all_sessions(&self, user_id: i64) -> crate::Result { - let result = sqlx::query("DELETE FROM user_sessions WHERE user_id = $1") + let result = sqlx::query("DELETE FROM user_sessions WHERE user_id = ?") .bind(user_id) .execute(&self.pool) .await?; @@ -379,7 +377,7 @@ impl Database { let sessions = sqlx::query_as::<_, UserSession>( "SELECT id, user_id, token_hash, user_agent, ip_address::TEXT, \ created_at, expires_at, last_active \ - FROM user_sessions WHERE user_id = $1 ORDER BY last_active DESC", + FROM user_sessions WHERE user_id = ? ORDER BY last_active DESC", ) .bind(user_id) .fetch_all(&self.pool) @@ -403,17 +401,21 @@ impl Database { device_name: Option<&str>, device_type: Option<&str>, ) -> crate::Result { - let device = sqlx::query_as::<_, UserDevice>( + let result = sqlx::query( "INSERT INTO user_devices (user_id, device_token, device_name, device_type) \ - VALUES ($1, $2, $3, $4) RETURNING *", + VALUES (?, ?, ?, ?)", ) .bind(user_id) .bind(device_token) .bind(device_name) .bind(device_type) - .fetch_one(&self.pool) + .execute(&self.pool) .await?; - Ok(device) + sqlx::query_as::<_, UserDevice>("SELECT * FROM user_devices WHERE id = ?") + .bind(result.last_insert_id() as i64) + .fetch_one(&self.pool) + .await + .map_err(Into::into) } /// Validate a device token and return the associated user if valid. @@ -421,7 +423,7 @@ impl Database { let user = sqlx::query_as::<_, User>( "SELECT u.* FROM users u \ INNER JOIN user_devices d ON d.user_id = u.id \ - WHERE d.device_token = $1 AND d.revoked = false AND u.enabled = true", + WHERE d.device_token = ? AND d.revoked = false AND u.enabled = true", ) .bind(device_token) .fetch_optional(&self.pool) @@ -430,7 +432,7 @@ impl Database { } pub async fn touch_user_device(&self, device_token: Uuid) -> crate::Result<()> { - sqlx::query("UPDATE user_devices SET last_seen = NOW() WHERE device_token = $1") + sqlx::query("UPDATE user_devices SET last_seen = NOW() WHERE device_token = ?") .bind(device_token) .execute(&self.pool) .await?; @@ -439,7 +441,7 @@ impl Database { pub async fn list_user_devices(&self, user_id: i64) -> crate::Result> { let devices = sqlx::query_as::<_, UserDevice>( - "SELECT * FROM user_devices WHERE user_id = $1 ORDER BY created_at DESC", + "SELECT * FROM user_devices WHERE user_id = ? ORDER BY created_at DESC", ) .bind(user_id) .fetch_all(&self.pool) @@ -448,7 +450,7 @@ impl Database { } pub async fn revoke_user_device(&self, id: i32) -> crate::Result { - let result = sqlx::query("UPDATE user_devices SET revoked = true WHERE id = $1") + let result = sqlx::query("UPDATE user_devices SET revoked = true WHERE id = ?") .bind(id) .execute(&self.pool) .await?; @@ -456,7 +458,7 @@ impl Database { } pub async fn delete_user_device(&self, id: i32) -> crate::Result { - let result = sqlx::query("DELETE FROM user_devices WHERE id = $1") + let result = sqlx::query("DELETE FROM user_devices WHERE id = ?") .bind(id) .execute(&self.pool) .await?; @@ -468,7 +470,7 @@ impl Database { device_token: Uuid, user_id: i64, ) -> crate::Result { - let result = sqlx::query("UPDATE user_devices SET user_id = $1 WHERE device_token = $2") + let result = sqlx::query("UPDATE user_devices SET user_id = ? WHERE device_token = ?") .bind(user_id) .bind(device_token) .execute(&self.pool) @@ -485,23 +487,27 @@ impl Database { role: &str, expires_at: Option>, ) -> crate::Result { - let invite = sqlx::query_as::<_, Invite>( + sqlx::query( "INSERT INTO invites (code, created_by, role, expires_at) \ - VALUES ($1, $2, $3, $4) RETURNING *", + VALUES (?, ?, ?, ?)", ) .bind(code) .bind(created_by) .bind(role) .bind(expires_at) - .fetch_one(&self.pool) + .execute(&self.pool) .await?; - Ok(invite) + sqlx::query_as::<_, Invite>("SELECT * FROM invites WHERE code = ?") + .bind(code) + .fetch_one(&self.pool) + .await + .map_err(Into::into) } /// Validate an invite code. Returns the invite if it's unclaimed and not expired. pub async fn validate_invite(&self, code: &str) -> crate::Result> { let invite = sqlx::query_as::<_, Invite>( - "SELECT * FROM invites WHERE code = $1 AND claimed_by IS NULL \ + "SELECT * FROM invites WHERE code = ? AND claimed_by IS NULL \ AND (expires_at IS NULL OR expires_at > NOW())", ) .bind(code) @@ -511,13 +517,12 @@ impl Database { } pub async fn claim_invite(&self, code: &str, user_id: i64) -> crate::Result { - let result = sqlx::query( - "UPDATE invites SET claimed_by = $1 WHERE code = $2 AND claimed_by IS NULL", - ) - .bind(user_id) - .bind(code) - .execute(&self.pool) - .await?; + let result = + sqlx::query("UPDATE invites SET claimed_by = ? WHERE code = ? AND claimed_by IS NULL") + .bind(user_id) + .bind(code) + .execute(&self.pool) + .await?; Ok(result.rows_affected() > 0) } @@ -529,7 +534,7 @@ impl Database { } pub async fn delete_invite(&self, id: i32) -> crate::Result { - let result = sqlx::query("DELETE FROM invites WHERE id = $1") + let result = sqlx::query("DELETE FROM invites WHERE id = ?") .bind(id) .execute(&self.pool) .await?; @@ -552,13 +557,12 @@ impl Database { duration_secs: f32, completed: bool, ) -> crate::Result { - let row = sqlx::query_as::<_, WatchProgress>( + sqlx::query( "INSERT INTO watch_progress (user_id, media_file_id, media_type, media_id, episode_id, \ position_secs, duration_secs, completed, updated_at) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) \ - ON CONFLICT (user_id, media_file_id) DO UPDATE SET \ - position_secs = $6, duration_secs = $7, completed = $8, updated_at = NOW() \ - RETURNING *", + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW()) \ + ON DUPLICATE KEY UPDATE position_secs = VALUES(position_secs), \ + duration_secs = VALUES(duration_secs), completed = VALUES(completed), updated_at = NOW()", ) .bind(user_id) .bind(media_file_id) @@ -568,9 +572,11 @@ impl Database { .bind(position_secs) .bind(duration_secs) .bind(completed) - .fetch_one(&self.pool) + .execute(&self.pool) .await?; - Ok(row) + self.get_watch_progress(user_id, media_file_id) + .await? + .ok_or_else(|| crate::Error::Config("saved watch progress was not found".into())) } pub async fn get_watch_progress( @@ -579,7 +585,7 @@ impl Database { media_file_id: i64, ) -> crate::Result> { let row = sqlx::query_as::<_, WatchProgress>( - "SELECT * FROM watch_progress WHERE user_id = $1 AND media_file_id = $2", + "SELECT * FROM watch_progress WHERE user_id = ? AND media_file_id = ?", ) .bind(user_id) .bind(media_file_id) @@ -595,8 +601,8 @@ impl Database { ) -> crate::Result> { let rows = sqlx::query_as::<_, WatchProgress>( "SELECT * FROM watch_progress \ - WHERE user_id = $1 AND completed = false AND position_secs > 0 \ - ORDER BY updated_at DESC LIMIT $2", + WHERE user_id = ? AND completed = false AND position_secs > 0 \ + ORDER BY updated_at DESC LIMIT ?", ) .bind(user_id) .bind(limit) @@ -612,7 +618,7 @@ impl Database { ) -> crate::Result> { let rows = sqlx::query_as::<_, WatchProgress>( "SELECT * FROM watch_progress \ - WHERE user_id = $1 AND media_type = 'series' AND media_id = $2 \ + WHERE user_id = ? AND media_type = 'series' AND media_id = ? \ ORDER BY updated_at DESC", ) .bind(user_id) @@ -629,7 +635,7 @@ impl Database { ) -> crate::Result> { let row = sqlx::query_as::<_, WatchProgress>( "SELECT * FROM watch_progress \ - WHERE user_id = $1 AND media_type = 'movie' AND media_id = $2 \ + WHERE user_id = ? AND media_type = 'movie' AND media_id = ? \ ORDER BY updated_at DESC LIMIT 1", ) .bind(user_id) @@ -645,7 +651,7 @@ impl Database { media_file_id: i64, ) -> crate::Result { let result = - sqlx::query("DELETE FROM watch_progress WHERE user_id = $1 AND media_file_id = $2") + sqlx::query("DELETE FROM watch_progress WHERE user_id = ? AND media_file_id = ?") .bind(user_id) .bind(media_file_id) .execute(&self.pool) @@ -656,7 +662,7 @@ impl Database { pub async fn mark_series_watched(&self, user_id: i64, series_id: i64) -> crate::Result { let result = sqlx::query( "UPDATE watch_progress SET completed = true, updated_at = NOW() \ - WHERE user_id = $1 AND media_type = 'series' AND media_id = $2", + WHERE user_id = ? AND media_type = 'series' AND media_id = ?", ) .bind(user_id) .bind(series_id) @@ -681,7 +687,7 @@ impl Database { LEFT JOIN ( \ SELECT m.id AS movie_id, m.movie_file_id FROM movies m WHERE m.movie_file_id IS NOT NULL \ ) mf_movie ON mf_movie.movie_file_id = mf.id \ - WHERE mf.id = $1 \ + WHERE mf.id = ? \ LIMIT 1", ) .bind(media_file_id) @@ -712,8 +718,8 @@ impl Database { LEFT JOIN series s ON wp.media_type = 'series' AND s.id = wp.media_id \ LEFT JOIN movies m ON wp.media_type = 'movie' AND m.id = wp.media_id \ LEFT JOIN episodes e ON e.id = wp.episode_id \ - WHERE wp.user_id = $1 AND wp.completed = false AND wp.position_secs > 0 \ - ORDER BY wp.updated_at DESC LIMIT $2", + WHERE wp.user_id = ? AND wp.completed = false AND wp.position_secs > 0 \ + ORDER BY wp.updated_at DESC LIMIT ?", ) .bind(user_id) .bind(limit) @@ -761,9 +767,9 @@ impl Database { poster_url: Option<&str>, overview: Option<&str>, ) -> crate::Result { - let row = sqlx::query_as::<_, MediaRequest>( + let result = sqlx::query( "INSERT INTO media_requests (user_id, media_type, tmdb_id, title, year, poster_url, overview) \ - VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *", + VALUES (?, ?, ?, ?, ?, ?, ?)", ) .bind(user_id) .bind(media_type) @@ -772,13 +778,15 @@ impl Database { .bind(year) .bind(poster_url) .bind(overview) - .fetch_one(&self.pool) + .execute(&self.pool) .await?; - Ok(row) + self.get_media_request(result.last_insert_id() as i64) + .await? + .ok_or_else(|| crate::Error::Config("created media request was not found".into())) } pub async fn get_media_request(&self, id: i64) -> crate::Result> { - let row = sqlx::query_as::<_, MediaRequest>("SELECT * FROM media_requests WHERE id = $1") + let row = sqlx::query_as::<_, MediaRequest>("SELECT * FROM media_requests WHERE id = ?") .bind(id) .fetch_optional(&self.pool) .await?; @@ -793,7 +801,7 @@ impl Database { let rows = match (status, user_id) { (Some(s), Some(uid)) => { sqlx::query_as::<_, MediaRequest>( - "SELECT * FROM media_requests WHERE status = $1 AND user_id = $2 ORDER BY created_at DESC", + "SELECT * FROM media_requests WHERE status = ? AND user_id = ? ORDER BY created_at DESC", ) .bind(s) .bind(uid) @@ -802,7 +810,7 @@ impl Database { } (Some(s), None) => { sqlx::query_as::<_, MediaRequest>( - "SELECT * FROM media_requests WHERE status = $1 ORDER BY created_at DESC", + "SELECT * FROM media_requests WHERE status = ? ORDER BY created_at DESC", ) .bind(s) .fetch_all(&self.pool) @@ -810,7 +818,7 @@ impl Database { } (None, Some(uid)) => { sqlx::query_as::<_, MediaRequest>( - "SELECT * FROM media_requests WHERE user_id = $1 ORDER BY created_at DESC", + "SELECT * FROM media_requests WHERE user_id = ? ORDER BY created_at DESC", ) .bind(uid) .fetch_all(&self.pool) @@ -835,8 +843,8 @@ impl Database { admin_note: Option<&str>, ) -> crate::Result { let result = sqlx::query( - "UPDATE media_requests SET status = $1, approved_by = $2, admin_note = $3, \ - updated_at = NOW() WHERE id = $4", + "UPDATE media_requests SET status = ?, approved_by = ?, admin_note = ?, \ + updated_at = NOW() WHERE id = ?", ) .bind(status) .bind(approved_by) @@ -848,7 +856,7 @@ impl Database { } pub async fn delete_media_request(&self, id: i64) -> crate::Result { - let result = sqlx::query("DELETE FROM media_requests WHERE id = $1") + let result = sqlx::query("DELETE FROM media_requests WHERE id = ?") .bind(id) .execute(&self.pool) .await?; @@ -861,7 +869,7 @@ impl Database { media_type: &str, ) -> crate::Result> { let row = sqlx::query_as::<_, MediaRequest>( - "SELECT * FROM media_requests WHERE tmdb_id = $1 AND media_type = $2", + "SELECT * FROM media_requests WHERE tmdb_id = ? AND media_type = ?", ) .bind(tmdb_id) .bind(media_type) @@ -877,7 +885,7 @@ impl Database { ) -> crate::Result { let result = sqlx::query( "UPDATE media_requests SET status = 'available', updated_at = NOW() \ - WHERE tmdb_id = $1 AND media_type = $2 AND status != 'available'", + WHERE tmdb_id = ? AND media_type = ? AND status != 'available'", ) .bind(tmdb_id) .bind(media_type) @@ -903,19 +911,26 @@ impl Database { media_id: i64, tmdb_id: i64, ) -> crate::Result { - let row = sqlx::query_as::<_, UserWatchlistItem>( + sqlx::query( "INSERT INTO user_watchlist (user_id, media_type, media_id, tmdb_id) \ - VALUES ($1, $2, $3, $4) \ - ON CONFLICT (user_id, media_type, media_id) DO UPDATE SET added_at = NOW() \ - RETURNING *", + VALUES (?, ?, ?, ?) \ + ON DUPLICATE KEY UPDATE tmdb_id = VALUES(tmdb_id), added_at = NOW()", ) .bind(user_id) .bind(media_type) .bind(media_id) .bind(tmdb_id) - .fetch_one(&self.pool) + .execute(&self.pool) .await?; - Ok(row) + sqlx::query_as::<_, UserWatchlistItem>( + "SELECT * FROM user_watchlist WHERE user_id = ? AND media_type = ? AND media_id = ?", + ) + .bind(user_id) + .bind(media_type) + .bind(media_id) + .fetch_one(&self.pool) + .await + .map_err(Into::into) } pub async fn remove_from_watchlist( @@ -925,7 +940,7 @@ impl Database { media_id: i64, ) -> crate::Result { let result = sqlx::query( - "DELETE FROM user_watchlist WHERE user_id = $1 AND media_type = $2 AND media_id = $3", + "DELETE FROM user_watchlist WHERE user_id = ? AND media_type = ? AND media_id = ?", ) .bind(user_id) .bind(media_type) @@ -943,7 +958,7 @@ impl Database { let rows = match media_type { Some(mt) => { sqlx::query_as::<_, UserWatchlistItem>( - "SELECT * FROM user_watchlist WHERE user_id = $1 AND media_type = $2 \ + "SELECT * FROM user_watchlist WHERE user_id = ? AND media_type = ? \ ORDER BY added_at DESC", ) .bind(user_id) @@ -953,7 +968,7 @@ impl Database { } None => { sqlx::query_as::<_, UserWatchlistItem>( - "SELECT * FROM user_watchlist WHERE user_id = $1 ORDER BY added_at DESC", + "SELECT * FROM user_watchlist WHERE user_id = ? ORDER BY added_at DESC", ) .bind(user_id) .fetch_all(&self.pool) @@ -971,7 +986,7 @@ impl Database { ) -> crate::Result { let row: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM user_watchlist \ - WHERE user_id = $1 AND media_type = $2 AND media_id = $3", + WHERE user_id = ? AND media_type = ? AND media_id = ?", ) .bind(user_id) .bind(media_type) @@ -990,20 +1005,20 @@ impl Database { media_id: i64, rating: i16, ) -> crate::Result { - let row = sqlx::query_as::<_, UserRating>( + sqlx::query( "INSERT INTO user_ratings (user_id, media_type, media_id, rating) \ - VALUES ($1, $2, $3, $4) \ - ON CONFLICT (user_id, media_type, media_id) DO UPDATE SET \ - rating = $4, updated_at = NOW() \ - RETURNING *", + VALUES (?, ?, ?, ?) \ + ON DUPLICATE KEY UPDATE rating = VALUES(rating), updated_at = NOW()", ) .bind(user_id) .bind(media_type) .bind(media_id) .bind(rating) - .fetch_one(&self.pool) + .execute(&self.pool) .await?; - Ok(row) + self.get_rating(user_id, media_type, media_id) + .await? + .ok_or_else(|| crate::Error::Config("saved rating was not found".into())) } pub async fn get_rating( @@ -1013,7 +1028,7 @@ impl Database { media_id: i64, ) -> crate::Result> { let row = sqlx::query_as::<_, UserRating>( - "SELECT * FROM user_ratings WHERE user_id = $1 AND media_type = $2 AND media_id = $3", + "SELECT * FROM user_ratings WHERE user_id = ? AND media_type = ? AND media_id = ?", ) .bind(user_id) .bind(media_type) @@ -1030,7 +1045,7 @@ impl Database { media_id: i64, ) -> crate::Result { let result = sqlx::query( - "DELETE FROM user_ratings WHERE user_id = $1 AND media_type = $2 AND media_id = $3", + "DELETE FROM user_ratings WHERE user_id = ? AND media_type = ? AND media_id = ?", ) .bind(user_id) .bind(media_type) @@ -1048,7 +1063,7 @@ impl Database { let rows = match media_type { Some(mt) => { sqlx::query_as::<_, UserRating>( - "SELECT * FROM user_ratings WHERE user_id = $1 AND media_type = $2 \ + "SELECT * FROM user_ratings WHERE user_id = ? AND media_type = ? \ ORDER BY updated_at DESC", ) .bind(user_id) @@ -1058,7 +1073,7 @@ impl Database { } None => { sqlx::query_as::<_, UserRating>( - "SELECT * FROM user_ratings WHERE user_id = $1 ORDER BY updated_at DESC", + "SELECT * FROM user_ratings WHERE user_id = ? ORDER BY updated_at DESC", ) .bind(user_id) .fetch_all(&self.pool) @@ -1074,8 +1089,8 @@ impl Database { media_id: i64, ) -> crate::Result<(f64, i64)> { let row: (Option, i64) = sqlx::query_as( - "SELECT AVG(rating::DOUBLE PRECISION), COUNT(*) FROM user_ratings \ - WHERE media_type = $1 AND media_id = $2", + "SELECT AVG(CAST(rating AS DOUBLE)), COUNT(*) FROM user_ratings \ + WHERE media_type = ? AND media_id = ?", ) .bind(media_type) .bind(media_id) @@ -1094,18 +1109,22 @@ impl Database { body: Option<&str>, data: Option, ) -> crate::Result { - let row = sqlx::query_as::<_, UserNotification>( + let result = sqlx::query( "INSERT INTO user_notifications (user_id, notification_type, title, body, data) \ - VALUES ($1, $2, $3, $4, $5) RETURNING *", + VALUES (?, ?, ?, ?, ?)", ) .bind(user_id) .bind(notification_type) .bind(title) .bind(body) .bind(data) - .fetch_one(&self.pool) + .execute(&self.pool) .await?; - Ok(row) + sqlx::query_as::<_, UserNotification>("SELECT * FROM user_notifications WHERE id = ?") + .bind(result.last_insert_id() as i64) + .fetch_one(&self.pool) + .await + .map_err(Into::into) } pub async fn create_notification_for_all_users( @@ -1117,7 +1136,7 @@ impl Database { ) -> crate::Result { let result = sqlx::query( "INSERT INTO user_notifications (user_id, notification_type, title, body, data) \ - SELECT id, $1, $2, $3, $4 FROM users WHERE enabled = true", + SELECT id, ?, ?, ?, ? FROM users WHERE enabled = true", ) .bind(notification_type) .bind(title) @@ -1138,8 +1157,8 @@ impl Database { let rows = if unread_only { sqlx::query_as::<_, UserNotification>( "SELECT * FROM user_notifications \ - WHERE user_id = $1 AND read = false \ - ORDER BY created_at DESC LIMIT $2 OFFSET $3", + WHERE user_id = ? AND read = false \ + ORDER BY created_at DESC LIMIT ? OFFSET ?", ) .bind(user_id) .bind(limit) @@ -1149,8 +1168,8 @@ impl Database { } else { sqlx::query_as::<_, UserNotification>( "SELECT * FROM user_notifications \ - WHERE user_id = $1 \ - ORDER BY created_at DESC LIMIT $2 OFFSET $3", + WHERE user_id = ? \ + ORDER BY created_at DESC LIMIT ? OFFSET ?", ) .bind(user_id) .bind(limit) @@ -1163,7 +1182,7 @@ impl Database { pub async fn unread_notification_count(&self, user_id: i64) -> crate::Result { let row: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM user_notifications WHERE user_id = $1 AND read = false", + "SELECT COUNT(*) FROM user_notifications WHERE user_id = ? AND read = false", ) .bind(user_id) .fetch_one(&self.pool) @@ -1173,7 +1192,7 @@ impl Database { pub async fn mark_notification_read(&self, id: i64, user_id: i64) -> crate::Result { let result = - sqlx::query("UPDATE user_notifications SET read = true WHERE id = $1 AND user_id = $2") + sqlx::query("UPDATE user_notifications SET read = true WHERE id = ? AND user_id = ?") .bind(id) .bind(user_id) .execute(&self.pool) @@ -1183,7 +1202,7 @@ impl Database { pub async fn mark_all_notifications_read(&self, user_id: i64) -> crate::Result { let result = sqlx::query( - "UPDATE user_notifications SET read = true WHERE user_id = $1 AND read = false", + "UPDATE user_notifications SET read = true WHERE user_id = ? AND read = false", ) .bind(user_id) .execute(&self.pool) @@ -1193,7 +1212,7 @@ impl Database { pub async fn delete_old_notifications(&self, days: i32) -> crate::Result { let result = sqlx::query( - "DELETE FROM user_notifications WHERE created_at < NOW() - make_interval(days => $1)", + "DELETE FROM user_notifications WHERE created_at < NOW() - make_interval(days => ?)", ) .bind(days) .execute(&self.pool) @@ -1211,21 +1230,24 @@ impl Database { auth: &str, user_agent: Option<&str>, ) -> crate::Result { - let row = sqlx::query_as::<_, PushSubscription>( + sqlx::query( "INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, user_agent) \ - VALUES ($1, $2, $3, $4, $5) \ - ON CONFLICT (endpoint) DO UPDATE SET \ - user_id = $1, p256dh = $3, auth = $4, user_agent = $5 \ - RETURNING *", + VALUES (?, ?, ?, ?, ?) \ + ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), p256dh = VALUES(p256dh), \ + auth = VALUES(auth), user_agent = VALUES(user_agent)", ) .bind(user_id) .bind(endpoint) .bind(p256dh) .bind(auth) .bind(user_agent) - .fetch_one(&self.pool) + .execute(&self.pool) .await?; - Ok(row) + sqlx::query_as::<_, PushSubscription>("SELECT * FROM push_subscriptions WHERE endpoint = ?") + .bind(endpoint) + .fetch_one(&self.pool) + .await + .map_err(Into::into) } pub async fn get_push_subscriptions( @@ -1233,7 +1255,7 @@ impl Database { user_id: i64, ) -> crate::Result> { let rows = sqlx::query_as::<_, PushSubscription>( - "SELECT * FROM push_subscriptions WHERE user_id = $1 ORDER BY created_at DESC", + "SELECT * FROM push_subscriptions WHERE user_id = ? ORDER BY created_at DESC", ) .bind(user_id) .fetch_all(&self.pool) @@ -1247,7 +1269,7 @@ impl Database { user_id: i64, ) -> crate::Result { let result = - sqlx::query("DELETE FROM push_subscriptions WHERE endpoint = $1 AND user_id = $2") + sqlx::query("DELETE FROM push_subscriptions WHERE endpoint = ? AND user_id = ?") .bind(endpoint) .bind(user_id) .execute(&self.pool) @@ -1263,16 +1285,20 @@ impl Database { title: &str, detail: Option<&str>, ) -> crate::Result { - let row = sqlx::query_as::<_, SystemActivity>( + let result = sqlx::query( "INSERT INTO system_activities (activity_type, title, detail) \ - VALUES ($1, $2, $3) RETURNING *", + VALUES (?, ?, ?)", ) .bind(activity_type) .bind(title) .bind(detail) - .fetch_one(&self.pool) + .execute(&self.pool) .await?; - Ok(row) + sqlx::query_as::<_, SystemActivity>("SELECT * FROM system_activities WHERE id = ?") + .bind(result.last_insert_id() as i64) + .fetch_one(&self.pool) + .await + .map_err(Into::into) } pub async fn update_activity_progress( @@ -1283,12 +1309,12 @@ impl Database { ) -> crate::Result { let result = sqlx::query( "UPDATE system_activities \ - SET detail = COALESCE($2, detail), progress = COALESCE($3, progress), updated_at = NOW() \ - WHERE id = $1", + SET detail = COALESCE(?, detail), progress = COALESCE(?, progress), updated_at = NOW() \ + WHERE id = ?", ) - .bind(id) .bind(detail) .bind(progress) + .bind(id) .execute(&self.pool) .await?; Ok(result.rows_affected() > 0) @@ -1304,15 +1330,15 @@ impl Database { ) -> crate::Result { let res = sqlx::query( "UPDATE system_activities \ - SET status = $2, detail = COALESCE($3, detail), result = $4, error = $5, \ + SET status = ?, detail = COALESCE(?, detail), result = ?, error = ?, \ completed_at = NOW(), updated_at = NOW() \ - WHERE id = $1", + WHERE id = ?", ) - .bind(id) .bind(status) .bind(detail) .bind(result) .bind(error) + .bind(id) .execute(&self.pool) .await?; Ok(res.rows_affected() > 0) @@ -1340,7 +1366,7 @@ impl Database { sqlx::query_as::<_, SystemActivity>( "SELECT * FROM system_activities \ ORDER BY CASE WHEN status = 'running' THEN 0 ELSE 1 END, started_at DESC \ - LIMIT $1", + LIMIT ?", ) .bind(limit) .fetch_all(&self.pool) @@ -1348,7 +1374,7 @@ impl Database { } else { sqlx::query_as::<_, SystemActivity>( "SELECT * FROM system_activities WHERE status = 'running' \ - ORDER BY started_at DESC LIMIT $1", + ORDER BY started_at DESC LIMIT ?", ) .bind(limit) .fetch_all(&self.pool) @@ -1375,8 +1401,8 @@ impl Database { "UPDATE system_activities \ SET status = 'failed', error = 'stale: timed out', \ completed_at = NOW(), updated_at = NOW() \ - WHERE activity_type = $1 AND status = 'running' \ - AND started_at < NOW() - INTERVAL '30 minutes'", + WHERE activity_type = ? AND status = 'running' \ + AND started_at < NOW() - INTERVAL 30 MINUTE", ) .bind(activity_type) .execute(&self.pool) @@ -1385,7 +1411,7 @@ impl Database { let row = sqlx::query_as::<_, SystemActivity>( "SELECT * FROM system_activities \ - WHERE activity_type = $1 AND status = 'running' \ + WHERE activity_type = ? AND status = 'running' \ ORDER BY started_at DESC LIMIT 1", ) .bind(activity_type) @@ -1396,7 +1422,7 @@ impl Database { pub async fn delete_old_activities(&self, days: i32) -> crate::Result { let result = sqlx::query( - "DELETE FROM system_activities WHERE started_at < NOW() - make_interval(days => $1)", + "DELETE FROM system_activities WHERE started_at < NOW() - make_interval(days => ?)", ) .bind(days) .execute(&self.pool) @@ -1407,10 +1433,9 @@ impl Database { /// Migrate existing remote_clients to user_devices for a given user. pub async fn migrate_remote_clients_to_user_devices(&self, user_id: i64) -> crate::Result { let result = sqlx::query( - "INSERT INTO user_devices (user_id, device_token, device_name, created_at, last_seen, revoked) \ - SELECT $1, client_token, client_name, created_at, last_seen, revoked \ - FROM remote_clients \ - ON CONFLICT (device_token) DO NOTHING", + "INSERT IGNORE INTO user_devices (user_id, device_token, device_name, created_at, last_seen, revoked) \ + SELECT ?, client_token, client_name, created_at, last_seen, revoked \ + FROM remote_clients", ) .bind(user_id) .execute(&self.pool) diff --git a/crates/stackarr-core/src/models/download.rs b/crates/stackarr-core/src/models/download.rs index 23304859..cf43a07f 100644 --- a/crates/stackarr-core/src/models/download.rs +++ b/crates/stackarr-core/src/models/download.rs @@ -61,6 +61,7 @@ pub struct IndexerConfig { pub base_url: String, pub api_key: Option, pub protocol: DownloadProtocol, + #[sqlx(json(nullable))] pub categories: Option>, pub enabled: bool, pub priority: i32, diff --git a/crates/stackarr-core/src/models/import_candidate.rs b/crates/stackarr-core/src/models/import_candidate.rs index 7fde94d5..e827226a 100644 --- a/crates/stackarr-core/src/models/import_candidate.rs +++ b/crates/stackarr-core/src/models/import_candidate.rs @@ -15,6 +15,7 @@ pub struct ImportCandidate { pub parsed_title: Option, pub parsed_year: Option, pub parsed_season: Option, + #[sqlx(json(nullable))] pub parsed_episodes: Option>, pub suggested_tmdb_id: Option, pub suggested_title: Option, @@ -56,20 +57,19 @@ impl ImportCandidate { /// `(discovered_path) WHERE status = 'pending'` to dedupe across scans. /// Returns `Ok(None)` if a pending row already exists for that path. pub async fn insert_pending( - pool: &sqlx::PgPool, + pool: &sqlx::MySqlPool, new: &NewImportCandidate, ) -> Result, sqlx::Error> { - let episodes: Option> = new.parsed_episodes.clone(); - let row: Option = sqlx::query_as( + let episodes = new.parsed_episodes.as_ref().map(sqlx::types::Json); + let result = sqlx::query( r#" INSERT INTO import_candidates ( media_library_folder_id, media_type, match_kind, discovered_path, file_count, total_size, parsed_title, parsed_year, parsed_season, parsed_episodes, data ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - ON CONFLICT (discovered_path) WHERE status = 'pending' DO NOTHING - RETURNING * + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id) "#, ) .bind(new.media_library_folder_id) @@ -83,13 +83,13 @@ impl ImportCandidate { .bind(new.parsed_season) .bind(episodes) .bind(&new.data) - .fetch_optional(pool) + .execute(pool) .await?; - Ok(row) + Self::get(pool, result.last_insert_id() as i64).await } pub async fn list_pending( - pool: &sqlx::PgPool, + pool: &sqlx::MySqlPool, media_type: Option<&str>, limit: i64, ) -> Result, sqlx::Error> { @@ -97,9 +97,9 @@ impl ImportCandidate { Some(mt) => { sqlx::query_as::<_, Self>( "SELECT * FROM import_candidates - WHERE status = 'pending' AND media_type = $1 + WHERE status = 'pending' AND media_type = ? ORDER BY confidence DESC, discovered_at DESC - LIMIT $2", + LIMIT ?", ) .bind(mt) .bind(limit) @@ -111,7 +111,7 @@ impl ImportCandidate { "SELECT * FROM import_candidates WHERE status = 'pending' ORDER BY confidence DESC, discovered_at DESC - LIMIT $1", + LIMIT ?", ) .bind(limit) .fetch_all(pool) @@ -120,8 +120,8 @@ impl ImportCandidate { } } - pub async fn get(pool: &sqlx::PgPool, id: i64) -> Result, sqlx::Error> { - sqlx::query_as::<_, Self>("SELECT * FROM import_candidates WHERE id = $1") + pub async fn get(pool: &sqlx::MySqlPool, id: i64) -> Result, sqlx::Error> { + sqlx::query_as::<_, Self>("SELECT * FROM import_candidates WHERE id = ?") .bind(id) .fetch_optional(pool) .await @@ -129,7 +129,7 @@ impl ImportCandidate { #[allow(clippy::too_many_arguments)] pub async fn update_suggestion( - pool: &sqlx::PgPool, + pool: &sqlx::MySqlPool, id: i64, tmdb_id: Option, title: Option<&str>, @@ -140,47 +140,47 @@ impl ImportCandidate { ) -> Result<(), sqlx::Error> { sqlx::query( "UPDATE import_candidates - SET suggested_tmdb_id = $2, suggested_title = $3, suggested_year = $4, - suggested_poster = $5, suggested_overview = $6, confidence = $7 - WHERE id = $1", + SET suggested_tmdb_id = ?, suggested_title = ?, suggested_year = ?, + suggested_poster = ?, suggested_overview = ?, confidence = ? + WHERE id = ?", ) - .bind(id) .bind(tmdb_id) .bind(title) .bind(year) .bind(poster) .bind(overview) .bind(confidence) + .bind(id) .execute(pool) .await?; Ok(()) } pub async fn mark_accepted( - pool: &sqlx::PgPool, + pool: &sqlx::MySqlPool, id: i64, target_series_id: Option, target_movie_id: Option, ) -> Result<(), sqlx::Error> { sqlx::query( "UPDATE import_candidates - SET status = 'accepted', target_series_id = $2, target_movie_id = $3, + SET status = 'accepted', target_series_id = ?, target_movie_id = ?, resolved_at = NOW() - WHERE id = $1", + WHERE id = ?", ) - .bind(id) .bind(target_series_id) .bind(target_movie_id) + .bind(id) .execute(pool) .await?; Ok(()) } - pub async fn mark_rejected(pool: &sqlx::PgPool, id: i64) -> Result<(), sqlx::Error> { + pub async fn mark_rejected(pool: &sqlx::MySqlPool, id: i64) -> Result<(), sqlx::Error> { sqlx::query( "UPDATE import_candidates SET status = 'rejected', resolved_at = NOW() - WHERE id = $1", + WHERE id = ?", ) .bind(id) .execute(pool) @@ -188,14 +188,18 @@ impl ImportCandidate { Ok(()) } - pub async fn mark_failed(pool: &sqlx::PgPool, id: i64, error: &str) -> Result<(), sqlx::Error> { + pub async fn mark_failed( + pool: &sqlx::MySqlPool, + id: i64, + error: &str, + ) -> Result<(), sqlx::Error> { sqlx::query( "UPDATE import_candidates - SET status = 'failed', error = $2, resolved_at = NOW() - WHERE id = $1", + SET status = 'failed', error = ?, resolved_at = NOW() + WHERE id = ?", ) - .bind(id) .bind(error) + .bind(id) .execute(pool) .await?; Ok(()) diff --git a/crates/stackarr-core/src/models/media.rs b/crates/stackarr-core/src/models/media.rs index 2e989325..bd2f1d2d 100644 --- a/crates/stackarr-core/src/models/media.rs +++ b/crates/stackarr-core/src/models/media.rs @@ -78,7 +78,9 @@ pub struct Series { pub tvmaze_id: Option, pub mal_id: Option, pub images: Option, + #[sqlx(json(nullable))] pub genres: Option>, + #[sqlx(json(nullable))] pub tags: Option>, pub added_at: DateTime, pub last_info_sync: Option>, @@ -144,7 +146,9 @@ pub struct Movie { pub physical_release: Option, pub digital_release: Option, pub images: Option, + #[sqlx(json(nullable))] pub genres: Option>, + #[sqlx(json(nullable))] pub tags: Option>, pub collection_tmdb_id: Option, pub added_at: DateTime, diff --git a/crates/stackarr-core/src/models/rss.rs b/crates/stackarr-core/src/models/rss.rs index beed9d02..7594adff 100644 --- a/crates/stackarr-core/src/models/rss.rs +++ b/crates/stackarr-core/src/models/rss.rs @@ -42,6 +42,7 @@ pub struct RssItem { pub struct RssRule { pub id: i64, pub name: String, + #[sqlx(json)] pub feed_ids: Vec, pub category: Option, pub priority: i32, diff --git a/crates/stackarr-core/src/test_helpers.rs b/crates/stackarr-core/src/test_helpers.rs index e8bc2ac2..d5bec9da 100644 --- a/crates/stackarr-core/src/test_helpers.rs +++ b/crates/stackarr-core/src/test_helpers.rs @@ -1,13 +1,13 @@ //! Test utilities for database-backed integration tests. //! -//! Requires a running PostgreSQL instance (e.g. `docker compose -f docker/docker-compose.dev.yml up -d`). +//! Requires a running MariaDB instance (e.g. `docker compose -f docker/docker-compose.dev.yml up -d`). //! Set `TEST_DATABASE_URL` to override the default connection string. -use sqlx::postgres::PgPoolOptions; -use sqlx::{Executor, PgPool}; +use sqlx::mysql::MySqlPoolOptions; +use sqlx::{Executor, MySqlPool}; -/// Default Postgres URL matching docker-compose.dev.yml. -const DEFAULT_TEST_URL: &str = "postgresql://stackarr:stackarr@localhost:5433/postgres"; +/// Default MariaDB URL matching docker-compose.dev.yml. +const DEFAULT_TEST_URL: &str = "mysql://stackarr:stackarr@127.0.0.1:3306/mysql"; fn base_url() -> String { std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| DEFAULT_TEST_URL.to_string()) @@ -15,7 +15,7 @@ fn base_url() -> String { /// A guard that owns a test database and drops it when the guard goes out of scope. pub struct TestDb { - pub pool: PgPool, + pub pool: MySqlPool, pub name: String, base_url: String, } @@ -28,14 +28,19 @@ impl TestDb { let name = format!("stackarr_test_{}", uuid::Uuid::new_v4().simple()); // Connect to the maintenance database to create a new test database. - let admin_pool = PgPoolOptions::new() + let admin_pool = MySqlPoolOptions::new() .max_connections(2) .connect(&base) .await - .expect("failed to connect to admin postgres — is docker compose running?"); + .expect("failed to connect to MariaDB admin database — is docker compose running?"); admin_pool - .execute(format!("CREATE DATABASE \"{name}\"").as_str()) + .execute( + format!( + "CREATE DATABASE `{name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ) + .as_str(), + ) .await .expect("failed to create test database"); @@ -48,7 +53,7 @@ impl TestDb { panic!("TEST_DATABASE_URL must include a database name component"); }; - let pool = PgPoolOptions::new() + let pool = MySqlPoolOptions::new() .max_connections(5) .connect(&test_url) .await @@ -75,9 +80,13 @@ impl TestDb { let base = self.base_url.clone(); let name = self.name.clone(); self.pool.close().await; - if let Ok(admin) = PgPoolOptions::new().max_connections(2).connect(&base).await { + if let Ok(admin) = MySqlPoolOptions::new() + .max_connections(2) + .connect(&base) + .await + { let _ = admin - .execute(format!("DROP DATABASE IF EXISTS \"{name}\" WITH (FORCE)").as_str()) + .execute(format!("DROP DATABASE IF EXISTS `{name}`").as_str()) .await; admin.close().await; } @@ -89,41 +98,39 @@ impl TestDb { // --------------------------------------------------------------------------- /// Insert a minimal quality profile and return its id. -pub async fn seed_quality_profile(pool: &PgPool) -> i32 { - let row: (i32,) = sqlx::query_as( +pub async fn seed_quality_profile(pool: &MySqlPool) -> i32 { + let result = sqlx::query( "INSERT INTO quality_profiles (name, cutoff, upgrade_allowed, min_format_score, cutoff_format_score, items) - VALUES ('Test Profile', 6, true, 0, 0, '[]'::jsonb) RETURNING id", + VALUES ('Test Profile', 6, true, 0, 0, JSON_ARRAY())", ) - .fetch_one(pool) + .execute(pool) .await .expect("seed quality profile"); - row.0 + result.last_insert_id() as i32 } /// Insert a media library folder and return its id. -pub async fn seed_media_library_folder(pool: &PgPool, path: &str, media_type: &str) -> i32 { - let row: (i32,) = sqlx::query_as( - "INSERT INTO media_library_folders (path, media_type) VALUES ($1, $2) RETURNING id", - ) - .bind(path) - .bind(media_type) - .fetch_one(pool) - .await - .expect("seed media library folder"); - row.0 +pub async fn seed_media_library_folder(pool: &MySqlPool, path: &str, media_type: &str) -> i32 { + let result = sqlx::query("INSERT INTO media_library_folders (path, media_type) VALUES (?, ?)") + .bind(path) + .bind(media_type) + .execute(pool) + .await + .expect("seed media library folder"); + result.last_insert_id() as i32 } /// Insert a test series and return its id. pub async fn seed_series( - pool: &PgPool, + pool: &MySqlPool, title: &str, profile_id: i32, media_library_folder_id: i32, ) -> i64 { let clean = title.to_lowercase().replace(' ', ""); - let row: (i64,) = sqlx::query_as( + let result = sqlx::query( "INSERT INTO series (title, clean_title, sort_title, path, quality_profile_id, media_library_folder_id, monitored) - VALUES ($1, $2, $3, $4, $5, $6, true) RETURNING id", + VALUES (?, ?, ?, ?, ?, ?, true)", ) .bind(title) .bind(&clean) @@ -131,23 +138,23 @@ pub async fn seed_series( .bind(format!("/tv/{title}")) .bind(profile_id) .bind(media_library_folder_id) - .fetch_one(pool) + .execute(pool) .await .expect("seed series"); - row.0 + result.last_insert_id() as i64 } /// Insert a test movie and return its id. pub async fn seed_movie( - pool: &PgPool, + pool: &MySqlPool, title: &str, profile_id: i32, media_library_folder_id: i32, ) -> i64 { let clean = title.to_lowercase().replace(' ', ""); - let row: (i64,) = sqlx::query_as( + let result = sqlx::query( "INSERT INTO movies (title, clean_title, sort_title, path, quality_profile_id, media_library_folder_id, monitored, minimum_availability) - VALUES ($1, $2, $3, $4, $5, $6, true, 'released') RETURNING id", + VALUES (?, ?, ?, ?, ?, ?, true, 'released')", ) .bind(title) .bind(&clean) @@ -155,24 +162,24 @@ pub async fn seed_movie( .bind(format!("/movies/{title}")) .bind(profile_id) .bind(media_library_folder_id) - .fetch_one(pool) + .execute(pool) .await .expect("seed movie"); - row.0 + result.last_insert_id() as i64 } /// Insert a test episode and return its id. -pub async fn seed_episode(pool: &PgPool, series_id: i64, season: i32, episode: i32) -> i64 { - let row: (i64,) = sqlx::query_as( +pub async fn seed_episode(pool: &MySqlPool, series_id: i64, season: i32, episode: i32) -> i64 { + let result = sqlx::query( "INSERT INTO episodes (series_id, season_number, episode_number, title, monitored) - VALUES ($1, $2, $3, $4, true) RETURNING id", + VALUES (?, ?, ?, ?, true)", ) .bind(series_id) .bind(season) .bind(episode) .bind(format!("Episode {episode}")) - .fetch_one(pool) + .execute(pool) .await .expect("seed episode"); - row.0 + result.last_insert_id() as i64 } diff --git a/crates/stackarr-import/src/lib.rs b/crates/stackarr-import/src/lib.rs index a8a844f7..5519d60a 100644 --- a/crates/stackarr-import/src/lib.rs +++ b/crates/stackarr-import/src/lib.rs @@ -10,7 +10,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use serde::Serialize; -use sqlx::PgPool; +use sqlx::MySqlPool; use naming::{ build_episode_filename, build_movie_filename, build_season_folder, sanitize_filename, @@ -83,7 +83,7 @@ pub struct DiskScanResult { /// Context needed to process a completed download. pub struct ImportContext { - pub pool: PgPool, + pub pool: MySqlPool, /// Download ID from the queue record. pub download_id: String, /// Path where the download client placed the files. @@ -109,12 +109,12 @@ struct NamingConfig { colon_replacement: String, } -async fn load_naming_config(pool: &PgPool, media_type: &str) -> Result { +async fn load_naming_config(pool: &MySqlPool, media_type: &str) -> Result { #[allow(clippy::type_complexity)] let row: Option<(bool, Option, Option, Option, String)> = sqlx::query_as( "SELECT rename_files, standard_format, season_folder_format, movie_format, colon_replacement \ - FROM naming_config WHERE media_type = $1", + FROM naming_config WHERE media_type = ?", ) .bind(media_type) .fetch_optional(pool) @@ -298,7 +298,7 @@ async fn import_series_file( // Load series from DB let series_row: Option<(i64, String, String)> = - sqlx::query_as("SELECT id, title, path FROM series WHERE id = $1") + sqlx::query_as("SELECT id, title, path FROM series WHERE id = ?") .bind(ctx.media_id) .fetch_optional(pool) .await?; @@ -316,17 +316,15 @@ async fn import_series_file( // If the queue has a specific episode_id, load that episode let episode_row: Option<(i64, i32, i32, Option)> = if let Some(ep_id) = ctx.episode_id { - sqlx::query_as( - "SELECT id, season_number, episode_number, title FROM episodes WHERE id = $1", - ) - .bind(ep_id) - .fetch_optional(pool) - .await? + sqlx::query_as("SELECT id, season_number, episode_number, title FROM episodes WHERE id = ?") + .bind(ep_id) + .fetch_optional(pool) + .await? } else if let (Some(s), Some(&e)) = (season, episodes.first()) { // Fall back to matching by season/episode from parsed name sqlx::query_as( "SELECT id, season_number, episode_number, title FROM episodes \ - WHERE series_id = $1 AND season_number = $2 AND episode_number = $3", + WHERE series_id = ? AND season_number = ? AND episode_number = ?", ) .bind(series_id) .bind(s) @@ -402,15 +400,15 @@ async fn import_series_file( .await?; // Clean up old DB records - sqlx::query("DELETE FROM episode_files WHERE media_file_id = $1") + sqlx::query("DELETE FROM episode_files WHERE media_file_id = ?") .bind(existing_file_id) .execute(pool) .await?; - sqlx::query("UPDATE episodes SET episode_file_id = NULL WHERE episode_file_id = $1") + sqlx::query("UPDATE episodes SET episode_file_id = NULL WHERE episode_file_id = ?") .bind(existing_file_id) .execute(pool) .await?; - sqlx::query("DELETE FROM media_files WHERE id = $1") + sqlx::query("DELETE FROM media_files WHERE id = ?") .bind(existing_file_id) .execute(pool) .await?; @@ -424,7 +422,7 @@ async fn import_series_file( }); sqlx::query( "INSERT INTO history (media_type, media_id, episode_id, event_type, quality, source_title, data) \ - VALUES ('series', $1, $2, 'file_deleted', $3, $4, $5)", + VALUES ('series', ?, ?, 'file_deleted', ?, ?, ?)", ) .bind(series_id) .bind(episode_id) @@ -552,10 +550,9 @@ async fn import_series_file( .as_ref() .and_then(|mi| serde_json::to_value(mi).ok()); - let media_file_row: (i64,) = sqlx::query_as( + let media_file_result = sqlx::query( "INSERT INTO media_files (media_type, relative_path, size, quality, languages, scene_name, release_group, release_hash, edition, media_info) \ - VALUES ('series', $1, $2, $3, $4, $5, $6, $7, $8, $9) \ - RETURNING id", + VALUES ('series', ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&relative_path) .bind(size) @@ -566,22 +563,20 @@ async fn import_series_file( .bind(&parsed.release_hash) .bind(&parsed.edition) .bind(&media_info_json) - .fetch_one(pool) + .execute(pool) .await?; - let media_file_id = media_file_row.0; + let media_file_id = media_file_result.last_insert_id() as i64; // Link episode to media file - sqlx::query( - "INSERT INTO episode_files (episode_id, media_file_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", - ) - .bind(episode_id) - .bind(media_file_id) - .execute(pool) - .await?; + sqlx::query("INSERT IGNORE INTO episode_files (episode_id, media_file_id) VALUES (?, ?)") + .bind(episode_id) + .bind(media_file_id) + .execute(pool) + .await?; // Update the episode's file pointer - sqlx::query("UPDATE episodes SET episode_file_id = $1 WHERE id = $2") + sqlx::query("UPDATE episodes SET episode_file_id = ? WHERE id = ?") .bind(media_file_id) .bind(episode_id) .execute(pool) @@ -592,7 +587,7 @@ async fn import_series_file( for &ep_num in episodes.iter().skip(1) { if let Some(s) = season { let extra_ep: Option<(i64,)> = sqlx::query_as( - "SELECT id FROM episodes WHERE series_id = $1 AND season_number = $2 AND episode_number = $3", + "SELECT id FROM episodes WHERE series_id = ? AND season_number = ? AND episode_number = ?", ) .bind(series_id) .bind(s) @@ -602,7 +597,7 @@ async fn import_series_file( if let Some((extra_id,)) = extra_ep { sqlx::query( - "INSERT INTO episode_files (episode_id, media_file_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", + "INSERT IGNORE INTO episode_files (episode_id, media_file_id) VALUES (?, ?)", ) .bind(extra_id) .bind(media_file_id) @@ -610,7 +605,7 @@ async fn import_series_file( .await?; sqlx::query( - "UPDATE episodes SET episode_file_id = $1 WHERE id = $2 AND episode_file_id IS NULL", + "UPDATE episodes SET episode_file_id = ? WHERE id = ? AND episode_file_id IS NULL", ) .bind(media_file_id) .bind(extra_id) @@ -624,7 +619,7 @@ async fn import_series_file( // Insert history record sqlx::query( "INSERT INTO history (media_type, media_id, episode_id, event_type, quality, languages, source_title, download_id) \ - VALUES ('series', $1, $2, 'imported', $3, $4, $5, $6)", + VALUES ('series', ?, ?, 'imported', ?, ?, ?, ?)", ) .bind(series_id) .bind(episode_id) @@ -670,7 +665,7 @@ async fn import_movie_file( // Load movie from DB let movie_row: Option<(i64, String, String, Option)> = - sqlx::query_as("SELECT id, title, path, year FROM movies WHERE id = $1") + sqlx::query_as("SELECT id, title, path, year FROM movies WHERE id = ?") .bind(ctx.media_id) .fetch_optional(pool) .await?; @@ -733,11 +728,11 @@ async fn import_movie_file( .await?; // Clean up old DB records - sqlx::query("UPDATE movies SET movie_file_id = NULL WHERE movie_file_id = $1") + sqlx::query("UPDATE movies SET movie_file_id = NULL WHERE movie_file_id = ?") .bind(existing_file_id) .execute(pool) .await?; - sqlx::query("DELETE FROM media_files WHERE id = $1") + sqlx::query("DELETE FROM media_files WHERE id = ?") .bind(existing_file_id) .execute(pool) .await?; @@ -751,7 +746,7 @@ async fn import_movie_file( }); sqlx::query( "INSERT INTO history (media_type, media_id, episode_id, event_type, quality, source_title, data) \ - VALUES ('movie', $1, NULL, 'file_deleted', $2, $3, $4)", + VALUES ('movie', ?, NULL, 'file_deleted', ?, ?, ?)", ) .bind(movie_id) .bind(&existing_quality) @@ -854,10 +849,9 @@ async fn import_movie_file( .as_ref() .and_then(|mi| serde_json::to_value(mi).ok()); - let media_file_row: (i64,) = sqlx::query_as( + let media_file_result = sqlx::query( "INSERT INTO media_files (media_type, relative_path, size, quality, languages, scene_name, release_group, release_hash, edition, media_info) \ - VALUES ('movie', $1, $2, $3, $4, $5, $6, $7, $8, $9) \ - RETURNING id", + VALUES ('movie', ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&relative_path) .bind(size) @@ -868,13 +862,13 @@ async fn import_movie_file( .bind(&parsed.release_hash) .bind(&parsed.edition) .bind(&media_info_json) - .fetch_one(pool) + .execute(pool) .await?; - let media_file_id = media_file_row.0; + let media_file_id = media_file_result.last_insert_id() as i64; // Link to movie - sqlx::query("UPDATE movies SET movie_file_id = $1 WHERE id = $2") + sqlx::query("UPDATE movies SET movie_file_id = ? WHERE id = ?") .bind(media_file_id) .bind(movie_id) .execute(pool) @@ -883,7 +877,7 @@ async fn import_movie_file( // Insert history record sqlx::query( "INSERT INTO history (media_type, media_id, event_type, quality, languages, source_title, download_id) \ - VALUES ('movie', $1, 'imported', $2, $3, $4, $5)", + VALUES ('movie', ?, 'imported', ?, ?, ?, ?)", ) .bind(movie_id) .bind(&quality_json) @@ -1002,11 +996,11 @@ fn scan_folder_for_media(folder: &Path) -> Result> { #[derive(Clone)] pub struct ImportService { #[allow(dead_code)] - pool: PgPool, + pool: MySqlPool, } impl ImportService { - pub fn new(pool: PgPool) -> Self { + pub fn new(pool: MySqlPool) -> Self { Self { pool } } @@ -1062,7 +1056,7 @@ impl ImportService { /// `import_candidates` rows are written. Prefer [`disk_scan_in_folder`] for /// scheduler/API calls that do know the folder id. pub async fn disk_scan( - pool: &PgPool, + pool: &MySqlPool, root_path: &Path, media_type: &str, ) -> Result { @@ -1073,7 +1067,7 @@ pub async fn disk_scan( /// `import_candidates` tied to `media_library_folder_id`. This is the path /// the scheduler and the manual-scan API should use. pub async fn disk_scan_in_folder( - pool: &PgPool, + pool: &MySqlPool, media_library_folder_id: Option, root_path: &Path, media_type: &str, @@ -1116,7 +1110,7 @@ struct UnmatchedSeriesGroup { /// Scan for series: expects `{root}/{Series Name}/Season XX/file.mkv` async fn scan_series( - pool: &PgPool, + pool: &MySqlPool, media_library_folder_id: Option, root_path: &Path, ) -> Result { @@ -1266,10 +1260,9 @@ async fn scan_series( let languages_json = serde_json::to_value(&parsed.languages)?; // Insert media_file record - let media_file_row: (i64,) = sqlx::query_as( + let media_file_result = sqlx::query( "INSERT INTO media_files (media_type, relative_path, size, quality, languages, scene_name, release_group, release_hash, edition) - VALUES ('series', $1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id", + VALUES ('series', ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&relative_path_str) .bind(size) @@ -1279,10 +1272,10 @@ async fn scan_series( .bind(&parsed.release_group) .bind(&parsed.release_hash) .bind(&parsed.edition) - .fetch_one(pool) + .execute(pool) .await?; - let media_file_id = media_file_row.0; + let media_file_id = media_file_result.last_insert_id() as i64; // Try to match to specific episode let season = parsed.episode_info.season_number; @@ -1292,7 +1285,7 @@ async fn scan_series( for &ep_num in episodes { // Find the episode let episode_row: Option<(i64,)> = sqlx::query_as( - "SELECT id FROM episodes WHERE series_id = $1 AND season_number = $2 AND episode_number = $3", + "SELECT id FROM episodes WHERE series_id = ? AND season_number = ? AND episode_number = ?", ) .bind(series_id) .bind(season_num) @@ -1303,7 +1296,7 @@ async fn scan_series( if let Some((episode_id,)) = episode_row { // Link episode to media file via episode_files join table sqlx::query( - "INSERT INTO episode_files (episode_id, media_file_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", + "INSERT IGNORE INTO episode_files (episode_id, media_file_id) VALUES (?, ?)", ) .bind(episode_id) .bind(media_file_id) @@ -1312,7 +1305,7 @@ async fn scan_series( // Also update the episode's episode_file_id pointer sqlx::query( - "UPDATE episodes SET episode_file_id = $1 WHERE id = $2 AND episode_file_id IS NULL", + "UPDATE episodes SET episode_file_id = ? WHERE id = ? AND episode_file_id IS NULL", ) .bind(media_file_id) .bind(episode_id) @@ -1379,7 +1372,7 @@ async fn scan_series( /// Scan for movies: expects `{root}/{Movie Name (Year)}/file.mkv` async fn scan_movies( - pool: &PgPool, + pool: &MySqlPool, media_library_folder_id: Option, root_path: &Path, ) -> Result { @@ -1523,10 +1516,9 @@ async fn scan_movies( let languages_json = serde_json::to_value(&parsed.languages)?; // Insert media_file record - let media_file_row: (i64,) = sqlx::query_as( + let media_file_result = sqlx::query( "INSERT INTO media_files (media_type, relative_path, size, quality, languages, scene_name, release_group, release_hash, edition) - VALUES ('movie', $1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id", + VALUES ('movie', ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&relative_path_str) .bind(size) @@ -1536,13 +1528,12 @@ async fn scan_movies( .bind(&parsed.release_group) .bind(&parsed.release_hash) .bind(&parsed.edition) - .fetch_one(pool) + .execute(pool) .await?; - - let media_file_id = media_file_row.0; + let media_file_id = media_file_result.last_insert_id() as i64; // Link to movie - sqlx::query("UPDATE movies SET movie_file_id = $1 WHERE id = $2 AND movie_file_id IS NULL") + sqlx::query("UPDATE movies SET movie_file_id = ? WHERE id = ? AND movie_file_id IS NULL") .bind(media_file_id) .bind(movie_id) .execute(pool) @@ -1668,16 +1659,16 @@ mod tests { } // ── Tests for disk_scan media_type routing ─────────────────────────── - // disk_scan requires a real PgPool + data, so we test the media_type + // disk_scan requires a real MySqlPool + data, so we test the media_type // dispatch logic via the public function on a non-existent path to // confirm "tv" is accepted before the path-existence check. mod disk_scan_media_type { use super::*; - use sqlx::postgres::PgPoolOptions; + use sqlx::mysql::MySqlPoolOptions; - fn dummy_pool() -> PgPool { - PgPoolOptions::new() + fn dummy_pool() -> MySqlPool { + MySqlPoolOptions::new() .max_connections(1) .connect_lazy("postgresql://fake:fake@localhost:5432/fake") .expect("lazy pool") diff --git a/crates/stackarr-import/src/recycle_bin.rs b/crates/stackarr-import/src/recycle_bin.rs index 70ca952a..e38a33cb 100644 --- a/crates/stackarr-import/src/recycle_bin.rs +++ b/crates/stackarr-import/src/recycle_bin.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use anyhow::Result; use chrono::{DateTime, Utc}; use serde::Serialize; -use sqlx::{FromRow, PgPool}; +use sqlx::{FromRow, MySqlPool}; // ── Types ─────────────────────────────────────────────────────────────────── @@ -22,7 +22,7 @@ pub struct RecycleBinEntry { // ── Config helpers ────────────────────────────────────────────────────────── -async fn get_recycle_bin_path(pool: &PgPool) -> Result { +async fn get_recycle_bin_path(pool: &MySqlPool) -> Result { let row: Option<(serde_json::Value,)> = sqlx::query_as("SELECT value FROM app_config WHERE key = 'recycle_bin_path'") .fetch_optional(pool) @@ -33,7 +33,7 @@ async fn get_recycle_bin_path(pool: &PgPool) -> Result { Ok(path) } -async fn get_cleanup_days(pool: &PgPool) -> Result { +async fn get_cleanup_days(pool: &MySqlPool) -> Result { let row: Option<(serde_json::Value,)> = sqlx::query_as("SELECT value FROM app_config WHERE key = 'recycle_bin_cleanup_days'") .fetch_optional(pool) @@ -51,7 +51,7 @@ async fn get_cleanup_days(pool: &PgPool) -> Result { /// Returns the recycle path if the file was moved, or `None` if the recycle bin /// is disabled (the file is permanently deleted instead). pub async fn recycle_file( - pool: &PgPool, + pool: &MySqlPool, file_path: &Path, media_file_id: i64, media_type: &str, @@ -91,7 +91,7 @@ pub async fn recycle_file( sqlx::query( "INSERT INTO recycle_bin (original_path, recycle_path, media_file_id, media_type, media_id, size) \ - VALUES ($1, $2, $3, $4, $5, $6)", + VALUES (?, ?, ?, ?, ?, ?)", ) .bind(&original) .bind(&recycled) @@ -113,7 +113,7 @@ pub async fn recycle_file( /// Permanently delete all recycle bin entries older than the configured /// `recycle_bin_cleanup_days`. Returns the number of files cleaned up. -pub async fn cleanup_expired_from_config(pool: PgPool) -> Result { +pub async fn cleanup_expired_from_config(pool: MySqlPool) -> Result { let days = get_cleanup_days(&pool).await?; if days == 0 { return Ok(0); // 0 = keep forever @@ -122,9 +122,9 @@ pub async fn cleanup_expired_from_config(pool: PgPool) -> Result { } /// Permanently delete all recycle bin entries older than `days`. -pub async fn cleanup_expired(pool: &PgPool, days: i32) -> Result { +pub async fn cleanup_expired(pool: &MySqlPool, days: i32) -> Result { let entries: Vec<(i64, String)> = sqlx::query_as( - "SELECT id, recycle_path FROM recycle_bin WHERE recycled_at < NOW() - make_interval(days => $1)", + "SELECT id, recycle_path FROM recycle_bin WHERE recycled_at < NOW() - make_interval(days => ?)", ) .bind(days) .fetch_all(pool) @@ -141,7 +141,7 @@ pub async fn cleanup_expired(pool: &PgPool, days: i32) -> Result { tracing::warn!(path = %path, error = %e, "failed to delete expired recycle bin file"); continue; } - sqlx::query("DELETE FROM recycle_bin WHERE id = $1") + sqlx::query("DELETE FROM recycle_bin WHERE id = ?") .bind(id) .execute(pool) .await?; @@ -152,7 +152,7 @@ pub async fn cleanup_expired(pool: &PgPool, days: i32) -> Result { } /// List all entries currently in the recycle bin. -pub async fn list_entries(pool: &PgPool) -> Result> { +pub async fn list_entries(pool: &MySqlPool) -> Result> { let entries = sqlx::query_as::<_, RecycleBinEntry>("SELECT * FROM recycle_bin ORDER BY recycled_at DESC") .fetch_all(pool) @@ -161,9 +161,9 @@ pub async fn list_entries(pool: &PgPool) -> Result> { } /// Permanently delete a specific recycle bin entry by ID. -pub async fn delete_entry(pool: &PgPool, id: i64) -> Result<()> { +pub async fn delete_entry(pool: &MySqlPool, id: i64) -> Result<()> { let row: Option<(String,)> = - sqlx::query_as("SELECT recycle_path FROM recycle_bin WHERE id = $1") + sqlx::query_as("SELECT recycle_path FROM recycle_bin WHERE id = ?") .bind(id) .fetch_optional(pool) .await?; @@ -173,7 +173,7 @@ pub async fn delete_entry(pool: &PgPool, id: i64) -> Result<()> { if tokio::fs::metadata(p).await.is_ok() { tokio::fs::remove_file(p).await?; } - sqlx::query("DELETE FROM recycle_bin WHERE id = $1") + sqlx::query("DELETE FROM recycle_bin WHERE id = ?") .bind(id) .execute(pool) .await?; @@ -183,7 +183,7 @@ pub async fn delete_entry(pool: &PgPool, id: i64) -> Result<()> { } /// Empty the entire recycle bin. Returns the number of entries removed. -pub async fn empty_bin(pool: &PgPool) -> Result { +pub async fn empty_bin(pool: &MySqlPool) -> Result { let entries: Vec<(i64, String)> = sqlx::query_as("SELECT id, recycle_path FROM recycle_bin") .fetch_all(pool) .await?; diff --git a/crates/stackarr-import/src/tmdb_match.rs b/crates/stackarr-import/src/tmdb_match.rs index 6b8da9f4..8e217548 100644 --- a/crates/stackarr-import/src/tmdb_match.rs +++ b/crates/stackarr-import/src/tmdb_match.rs @@ -11,7 +11,7 @@ //! Results with a confidence below [`MIN_CONFIDENCE`] are treated as "no //! useful suggestion" and return `None`. -use sqlx::PgPool; +use sqlx::MySqlPool; use stackarr_core::models::ImportCandidate; use stackarr_metadata::TmdbClient; @@ -117,7 +117,10 @@ pub async fn suggest_movie( /// Skips candidates that don't have a parsed_title (there's nothing to /// search with). Honours [`MIN_CONFIDENCE`] — low-confidence results are /// simply left untouched and will retry on the next pass. -pub async fn refresh_pending_suggestions(pool: &PgPool, tmdb: &TmdbClient) -> anyhow::Result { +pub async fn refresh_pending_suggestions( + pool: &MySqlPool, + tmdb: &TmdbClient, +) -> anyhow::Result { let rows: Vec<(i64, String, Option, Option)> = sqlx::query_as( "SELECT id, media_type, parsed_title, parsed_year FROM import_candidates diff --git a/crates/stackarr-import/src/upgrade.rs b/crates/stackarr-import/src/upgrade.rs index 4094c8db..f07c59cc 100644 --- a/crates/stackarr-import/src/upgrade.rs +++ b/crates/stackarr-import/src/upgrade.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use anyhow::Result; -use sqlx::PgPool; +use sqlx::MySqlPool; use stackarr_core::models::quality::QualityProfile; use stackarr_quality::{is_quality_allowed, parser_quality_to_num, quality_name}; @@ -27,7 +27,7 @@ pub enum UpgradeCheckResult { /// Check whether importing a file with `new_quality_num` for the given media /// is an upgrade over the existing file (if any). pub async fn check_upgrade( - pool: &PgPool, + pool: &MySqlPool, media_type: &str, media_id: i64, episode_id: Option, @@ -103,7 +103,7 @@ pub async fn check_upgrade( /// Returns (media_file_id, relative_path, quality_json, series_path) for an /// episode's existing file, or None if no file is linked. async fn lookup_series_file( - pool: &PgPool, + pool: &MySqlPool, episode_id: Option, ) -> Result> { let ep_id = match episode_id { @@ -116,7 +116,7 @@ async fn lookup_series_file( FROM episodes e \ JOIN media_files mf ON e.episode_file_id = mf.id \ JOIN series s ON e.series_id = s.id \ - WHERE e.id = $1 AND e.episode_file_id IS NOT NULL", + WHERE e.id = ? AND e.episode_file_id IS NOT NULL", ) .bind(ep_id) .fetch_optional(pool) @@ -128,14 +128,14 @@ async fn lookup_series_file( /// Returns (media_file_id, relative_path, quality_json, movie_path) for a /// movie's existing file, or None if no file is linked. async fn lookup_movie_file( - pool: &PgPool, + pool: &MySqlPool, movie_id: i64, ) -> Result> { let row: Option<(i64, String, serde_json::Value, String)> = sqlx::query_as( "SELECT mf.id, mf.relative_path, mf.quality, m.path \ FROM movies m \ JOIN media_files mf ON m.movie_file_id = mf.id \ - WHERE m.id = $1 AND m.movie_file_id IS NOT NULL", + WHERE m.id = ? AND m.movie_file_id IS NOT NULL", ) .bind(movie_id) .fetch_optional(pool) @@ -145,19 +145,19 @@ async fn lookup_movie_file( } async fn load_quality_profile( - pool: &PgPool, + pool: &MySqlPool, media_type: &str, media_id: i64, ) -> Result { let profile_id: (i32,) = match media_type { "series" | "tv" => { - sqlx::query_as("SELECT quality_profile_id FROM series WHERE id = $1") + sqlx::query_as("SELECT quality_profile_id FROM series WHERE id = ?") .bind(media_id) .fetch_one(pool) .await? } "movie" => { - sqlx::query_as("SELECT quality_profile_id FROM movies WHERE id = $1") + sqlx::query_as("SELECT quality_profile_id FROM movies WHERE id = ?") .bind(media_id) .fetch_one(pool) .await? @@ -166,7 +166,7 @@ async fn load_quality_profile( }; let profile = - sqlx::query_as::<_, QualityProfile>("SELECT * FROM quality_profiles WHERE id = $1") + sqlx::query_as::<_, QualityProfile>("SELECT * FROM quality_profiles WHERE id = ?") .bind(profile_id.0) .fetch_one(pool) .await?; diff --git a/crates/stackarr-mariadb/Cargo.toml b/crates/stackarr-mariadb/Cargo.toml new file mode 100644 index 00000000..f66f6e9c --- /dev/null +++ b/crates/stackarr-mariadb/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "stackarr-mariadb" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +sqlx = { workspace = true } +tracing = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/stackarr-postgres/pg-binaries/.gitkeep b/crates/stackarr-mariadb/pg-binaries/.gitkeep similarity index 100% rename from crates/stackarr-postgres/pg-binaries/.gitkeep rename to crates/stackarr-mariadb/pg-binaries/.gitkeep diff --git a/crates/stackarr-mariadb/src/error.rs b/crates/stackarr-mariadb/src/error.rs new file mode 100644 index 00000000..fa19cea8 --- /dev/null +++ b/crates/stackarr-mariadb/src/error.rs @@ -0,0 +1,13 @@ +use thiserror::Error; + +pub type MariaDbResult = Result; + +#[derive(Debug, Error)] +pub enum MariaDbError { + #[error("failed to connect to MariaDB: {0}")] + Connect(#[source] sqlx::Error), + #[error("failed to inspect MariaDB server: {0}")] + Inspect(#[source] sqlx::Error), + #[error("unsupported database server `{0}`; StackArr requires MariaDB 11.4 LTS")] + UnsupportedServer(String), +} diff --git a/crates/stackarr-mariadb/src/lib.rs b/crates/stackarr-mariadb/src/lib.rs new file mode 100644 index 00000000..fcdc9579 --- /dev/null +++ b/crates/stackarr-mariadb/src/lib.rs @@ -0,0 +1,85 @@ +//! MariaDB connection policy for StackArr. +//! +//! The database server is deliberately a separate process. Standard deployments +//! provide it externally; the standalone image supervises MariaDB with s6. + +mod error; + +use std::time::Duration; + +pub use error::{MariaDbError, MariaDbResult}; +use sqlx::mysql::MySqlPoolOptions; +use sqlx::{MySqlPool, Row}; + +pub const REQUIRED_MAJOR: u32 = 11; +pub const REQUIRED_MINOR: u32 = 4; + +/// Connect using the session invariants required by the baseline schema, then +/// verify that the server is the pinned MariaDB 11.4 LTS line. +pub async fn connect(url: &str, max_connections: u32) -> MariaDbResult { + let pool = MySqlPoolOptions::new() + .max_connections(max_connections) + .idle_timeout(Duration::from_secs(300)) + .max_lifetime(Duration::from_secs(1800)) + .acquire_timeout(Duration::from_secs(10)) + .after_connect(|connection, _metadata| { + Box::pin(async move { + sqlx::query("SET SESSION time_zone = '+00:00'") + .execute(&mut *connection) + .await?; + Ok(()) + }) + }) + .connect(url) + .await + .map_err(MariaDbError::Connect)?; + + validate_server(&pool).await?; + Ok(pool) +} + +pub async fn validate_server(pool: &MySqlPool) -> MariaDbResult { + let row = sqlx::query("SELECT VERSION() AS version") + .fetch_one(pool) + .await + .map_err(MariaDbError::Inspect)?; + let version: String = row.try_get("version").map_err(MariaDbError::Inspect)?; + + match parse_mariadb_version(&version) { + Some((REQUIRED_MAJOR, minor)) if minor == REQUIRED_MINOR => { + tracing::info!(%version, "connected to supported MariaDB server"); + Ok(version) + } + _ => Err(MariaDbError::UnsupportedServer(version)), + } +} + +fn parse_mariadb_version(version: &str) -> Option<(u32, u32)> { + if !version.to_ascii_lowercase().contains("mariadb") { + return None; + } + let numeric = version + .split('-') + .find(|part| part.chars().next().is_some_and(|c| c.is_ascii_digit()))?; + let mut parts = numeric.split('.'); + Some((parts.next()?.parse().ok()?, parts.next()?.parse().ok()?)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_pinned_mariadb_version_shape() { + assert_eq!( + parse_mariadb_version("11.4.8-MariaDB-ubu2404"), + Some((11, 4)) + ); + } + + #[test] + fn rejects_mysql_and_unparseable_versions() { + assert_eq!(parse_mariadb_version("8.0.42 MySQL Community Server"), None); + assert_eq!(parse_mariadb_version("MariaDB development build"), None); + } +} diff --git a/crates/stackarr-media/src/import_lists.rs b/crates/stackarr-media/src/import_lists.rs index fdc84599..fbdbc791 100644 --- a/crates/stackarr-media/src/import_lists.rs +++ b/crates/stackarr-media/src/import_lists.rs @@ -1,6 +1,6 @@ use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; -use sqlx::PgPool; +use sqlx::MySqlPool; use stackarr_metadata::TmdbClient; @@ -88,11 +88,11 @@ struct FetchedItem { #[derive(Clone)] pub struct ImportListService { - pool: PgPool, + pool: MySqlPool, } impl ImportListService { - pub fn new(pool: PgPool) -> Self { + pub fn new(pool: MySqlPool) -> Self { Self { pool } } @@ -104,7 +104,7 @@ impl ImportListService { } pub async fn get(&self, id: i64) -> Result { - let row = sqlx::query_as::<_, ImportList>("SELECT * FROM import_lists WHERE id = $1") + let row = sqlx::query_as::<_, ImportList>("SELECT * FROM import_lists WHERE id = ?") .bind(id) .fetch_one(&self.pool) .await?; @@ -112,10 +112,9 @@ impl ImportListService { } pub async fn create(&self, input: CreateImportListInput) -> Result { - let row = sqlx::query_as::<_, ImportList>( + let result = sqlx::query( "INSERT INTO import_lists (name, list_type, media_type, config, quality_profile_id, media_library_folder_id, monitored, enabled, poll_interval_secs) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING *", + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&input.name) .bind(&input.list_type) @@ -126,9 +125,9 @@ impl ImportListService { .bind(input.monitored) .bind(input.enabled) .bind(input.poll_interval_secs) - .fetch_one(&self.pool) + .execute(&self.pool) .await?; - Ok(row) + self.get(result.last_insert_id() as i64).await } pub async fn update(&self, id: i64, input: UpdateImportListInput) -> Result { @@ -149,13 +148,12 @@ impl ImportListService { .poll_interval_secs .unwrap_or(existing.poll_interval_secs); - let row = sqlx::query_as::<_, ImportList>( + sqlx::query( "UPDATE import_lists - SET name = $1, list_type = $2, media_type = $3, config = $4, - quality_profile_id = $5, media_library_folder_id = $6, monitored = $7, - enabled = $8, poll_interval_secs = $9 - WHERE id = $10 - RETURNING *", + SET name = ?, list_type = ?, media_type = ?, config = ?, + quality_profile_id = ?, media_library_folder_id = ?, monitored = ?, + enabled = ?, poll_interval_secs = ? + WHERE id = ?", ) .bind(&name) .bind(&list_type) @@ -167,13 +165,13 @@ impl ImportListService { .bind(enabled) .bind(poll_interval_secs) .bind(id) - .fetch_one(&self.pool) + .execute(&self.pool) .await?; - Ok(row) + self.get(id).await } pub async fn delete(&self, id: i64) -> Result<()> { - sqlx::query("DELETE FROM import_lists WHERE id = $1") + sqlx::query("DELETE FROM import_lists WHERE id = ?") .bind(id) .execute(&self.pool) .await?; @@ -206,7 +204,7 @@ impl ImportListService { // Resolve media library folder path for building media paths let media_library_folder_path = if let Some(rf_id) = list.media_library_folder_id { let row: Option<(String,)> = - sqlx::query_as("SELECT path FROM media_library_folders WHERE id = $1") + sqlx::query_as("SELECT path FROM media_library_folders WHERE id = ?") .bind(rf_id) .fetch_optional(&self.pool) .await?; @@ -222,7 +220,7 @@ impl ImportListService { "movie" => { // Check if already exists by tmdb_id let exists: Option<(i64,)> = - sqlx::query_as("SELECT id FROM movies WHERE tmdb_id = $1") + sqlx::query_as("SELECT id FROM movies WHERE tmdb_id = ?") .bind(item.tmdb_id) .fetch_optional(&self.pool) .await?; @@ -244,10 +242,11 @@ impl ImportListService { match sqlx::query( "INSERT INTO movies (title, clean_title, sort_title, path, quality_profile_id, monitored, tmdb_id, year, overview) - VALUES ($1, $2, $2, $3, $4, $5, $6, $7, $8)", + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&item.title) .bind(&clean) + .bind(&clean) .bind(&path) .bind(quality_profile_id) .bind(list.monitored) @@ -267,7 +266,7 @@ impl ImportListService { "series" => { // Check if already exists by tmdb_id let exists: Option<(i64,)> = - sqlx::query_as("SELECT id FROM series WHERE tmdb_id = $1") + sqlx::query_as("SELECT id FROM series WHERE tmdb_id = ?") .bind(item.tmdb_id) .fetch_optional(&self.pool) .await?; @@ -285,10 +284,11 @@ impl ImportListService { match sqlx::query( "INSERT INTO series (title, clean_title, sort_title, path, quality_profile_id, monitored, tmdb_id, overview) - VALUES ($1, $2, $2, $3, $4, $5, $6, $7)", + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&item.title) .bind(&clean) + .bind(&clean) .bind(&path) .bind(quality_profile_id) .bind(list.monitored) diff --git a/crates/stackarr-media/src/lib.rs b/crates/stackarr-media/src/lib.rs index c9602e33..ae7f5ae8 100644 --- a/crates/stackarr-media/src/lib.rs +++ b/crates/stackarr-media/src/lib.rs @@ -3,7 +3,7 @@ pub mod import_lists; use anyhow::Result; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use sqlx::PgPool; +use sqlx::MySqlPool; use stackarr_core::models::{Episode, Movie, Series, SeriesStatus}; @@ -100,11 +100,11 @@ fn default_true() -> bool { #[derive(Clone)] pub struct SeriesService { - pool: PgPool, + pool: MySqlPool, } impl SeriesService { - pub fn new(pool: PgPool) -> Self { + pub fn new(pool: MySqlPool) -> Self { Self { pool } } @@ -128,7 +128,7 @@ impl SeriesService { let rows = match limit { Some(lim) => { sqlx::query_as::<_, Series>( - "SELECT * FROM series ORDER BY sort_title LIMIT $1 OFFSET $2", + "SELECT * FROM series ORDER BY sort_title LIMIT ? OFFSET ?", ) .bind(lim) .bind(offset.unwrap_or(0)) @@ -145,7 +145,7 @@ impl SeriesService { } pub async fn get(&self, id: i64) -> Result { - let row = sqlx::query_as::<_, Series>("SELECT * FROM series WHERE id = $1") + let row = sqlx::query_as::<_, Series>("SELECT * FROM series WHERE id = ?") .bind(id) .fetch_one(&self.pool) .await?; @@ -155,10 +155,9 @@ impl SeriesService { pub async fn create(&self, input: CreateSeriesInput) -> Result { let clean = stackarr_parser::clean_title(&input.title); let sort = clean.clone(); - let row = sqlx::query_as::<_, Series>( + let result = sqlx::query( "INSERT INTO series (title, clean_title, sort_title, path, quality_profile_id, monitored, tvdb_id, tmdb_id, imdb_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING *", + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&input.title) .bind(&clean) @@ -169,8 +168,9 @@ impl SeriesService { .bind(input.tvdb_id) .bind(input.tmdb_id) .bind(&input.imdb_id) - .fetch_one(&self.pool) + .execute(&self.pool) .await?; + let row = self.get(result.last_insert_id() as i64).await?; tracing::info!(id = row.id, title = %row.title, path = %row.path, "series created"); Ok(row) } @@ -191,36 +191,39 @@ impl SeriesService { move_media_directory(&existing.path, &new_path).await?; // Rewrite episode file paths that start with the old path sqlx::query( - "UPDATE episode_files SET path = $1 || substring(path from length($2)+1) - WHERE series_id = $3 AND path LIKE $2 || '%'", + "UPDATE episode_files SET path = CONCAT(?, SUBSTRING(path, CHAR_LENGTH(?) + 1)) + WHERE series_id = ? AND path LIKE CONCAT(?, '%')", ) .bind(&new_path) .bind(&existing.path) .bind(id) + .bind(&existing.path) .execute(&self.pool) .await?; tracing::info!(id, old = %existing.path, new = %new_path, "series directory moved"); } - let row = sqlx::query_as::<_, Series>( - "UPDATE series SET title = $1, clean_title = $2, sort_title = $2, path = $3, quality_profile_id = $4, monitored = $5 - WHERE id = $6 RETURNING *", + sqlx::query( + "UPDATE series SET title = ?, clean_title = ?, sort_title = ?, path = ?, quality_profile_id = ?, monitored = ? + WHERE id = ?", ) .bind(&title) .bind(&clean) + .bind(&clean) .bind(&new_path) .bind(qp) .bind(monitored) .bind(id) - .fetch_one(&self.pool) + .execute(&self.pool) .await?; + let row = self.get(id).await?; tracing::debug!(id, title = %row.title, monitored, "series updated"); Ok(row) } pub async fn delete(&self, id: i64) -> Result<()> { tracing::info!(id, "deleting series"); - sqlx::query("DELETE FROM series WHERE id = $1") + sqlx::query("DELETE FROM series WHERE id = ?") .bind(id) .execute(&self.pool) .await?; @@ -232,11 +235,11 @@ impl SeriesService { #[derive(Clone)] pub struct MovieService { - pool: PgPool, + pool: MySqlPool, } impl MovieService { - pub fn new(pool: PgPool) -> Self { + pub fn new(pool: MySqlPool) -> Self { Self { pool } } @@ -260,7 +263,7 @@ impl MovieService { let rows = match limit { Some(lim) => { sqlx::query_as::<_, Movie>( - "SELECT * FROM movies ORDER BY sort_title LIMIT $1 OFFSET $2", + "SELECT * FROM movies ORDER BY sort_title LIMIT ? OFFSET ?", ) .bind(lim) .bind(offset.unwrap_or(0)) @@ -277,7 +280,7 @@ impl MovieService { } pub async fn get(&self, id: i64) -> Result { - let row = sqlx::query_as::<_, Movie>("SELECT * FROM movies WHERE id = $1") + let row = sqlx::query_as::<_, Movie>("SELECT * FROM movies WHERE id = ?") .bind(id) .fetch_one(&self.pool) .await?; @@ -287,10 +290,9 @@ impl MovieService { pub async fn create(&self, input: CreateMovieInput) -> Result { let clean = stackarr_parser::clean_title(&input.title); let sort = clean.clone(); - let row = sqlx::query_as::<_, Movie>( + let result = sqlx::query( "INSERT INTO movies (title, clean_title, sort_title, path, quality_profile_id, monitored, tmdb_id, imdb_id, year) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING *", + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&input.title) .bind(&clean) @@ -301,8 +303,9 @@ impl MovieService { .bind(input.tmdb_id) .bind(&input.imdb_id) .bind(input.year) - .fetch_one(&self.pool) + .execute(&self.pool) .await?; + let row = self.get(result.last_insert_id() as i64).await?; tracing::info!(id = row.id, title = %row.title, year = row.year, "movie created"); Ok(row) } @@ -320,36 +323,39 @@ impl MovieService { if input.move_files && new_path != existing.path { move_media_directory(&existing.path, &new_path).await?; sqlx::query( - "UPDATE movie_files SET path = $1 || substring(path from length($2)+1) - WHERE movie_id = $3 AND path LIKE $2 || '%'", + "UPDATE movie_files SET path = CONCAT(?, SUBSTRING(path, CHAR_LENGTH(?) + 1)) + WHERE movie_id = ? AND path LIKE CONCAT(?, '%')", ) .bind(&new_path) .bind(&existing.path) .bind(id) + .bind(&existing.path) .execute(&self.pool) .await?; tracing::info!(id, old = %existing.path, new = %new_path, "movie directory moved"); } - let row = sqlx::query_as::<_, Movie>( - "UPDATE movies SET title = $1, clean_title = $2, sort_title = $2, path = $3, quality_profile_id = $4, monitored = $5 - WHERE id = $6 RETURNING *", + sqlx::query( + "UPDATE movies SET title = ?, clean_title = ?, sort_title = ?, path = ?, quality_profile_id = ?, monitored = ? + WHERE id = ?", ) .bind(&title) .bind(&clean) + .bind(&clean) .bind(&new_path) .bind(qp) .bind(monitored) .bind(id) - .fetch_one(&self.pool) + .execute(&self.pool) .await?; + let row = self.get(id).await?; tracing::debug!(id, title = %row.title, monitored, "movie updated"); Ok(row) } pub async fn delete(&self, id: i64) -> Result<()> { tracing::info!(id, "deleting movie"); - sqlx::query("DELETE FROM movies WHERE id = $1") + sqlx::query("DELETE FROM movies WHERE id = ?") .bind(id) .execute(&self.pool) .await?; @@ -361,17 +367,17 @@ impl MovieService { #[derive(Clone)] pub struct EpisodeService { - pool: PgPool, + pool: MySqlPool, } impl EpisodeService { - pub fn new(pool: PgPool) -> Self { + pub fn new(pool: MySqlPool) -> Self { Self { pool } } pub async fn list_by_series(&self, series_id: i64) -> Result> { let rows = sqlx::query_as::<_, Episode>( - "SELECT * FROM episodes WHERE series_id = $1 ORDER BY season_number, episode_number", + "SELECT * FROM episodes WHERE series_id = ? ORDER BY season_number, episode_number", ) .bind(series_id) .fetch_all(&self.pool) @@ -380,7 +386,7 @@ impl EpisodeService { } pub async fn get(&self, id: i64) -> Result { - let row = sqlx::query_as::<_, Episode>("SELECT * FROM episodes WHERE id = $1") + let row = sqlx::query_as::<_, Episode>("SELECT * FROM episodes WHERE id = ?") .bind(id) .fetch_one(&self.pool) .await?; @@ -388,17 +394,18 @@ impl EpisodeService { } pub async fn create(&self, input: CreateEpisodeInput) -> Result { - let row = sqlx::query_as::<_, Episode>( + let result = sqlx::query( "INSERT INTO episodes (series_id, season_number, episode_number, title, monitored) - VALUES ($1, $2, $3, $4, $5) RETURNING *", + VALUES (?, ?, ?, ?, ?)", ) .bind(input.series_id) .bind(input.season_number) .bind(input.episode_number) .bind(&input.title) .bind(input.monitored) - .fetch_one(&self.pool) + .execute(&self.pool) .await?; + let row = self.get(result.last_insert_id() as i64).await?; tracing::debug!( id = row.id, series_id = input.series_id, @@ -411,7 +418,7 @@ impl EpisodeService { pub async fn set_monitored(&self, id: i64, monitored: bool) -> Result<()> { tracing::debug!(id, monitored, "episode monitored changed"); - sqlx::query("UPDATE episodes SET monitored = $1 WHERE id = $2") + sqlx::query("UPDATE episodes SET monitored = ? WHERE id = ?") .bind(monitored) .bind(id) .execute(&self.pool) @@ -421,14 +428,12 @@ impl EpisodeService { /// Update episode monitored status and return the updated episode. pub async fn update_monitored(&self, id: i64, monitored: bool) -> Result { - let row = sqlx::query_as::<_, Episode>( - "UPDATE episodes SET monitored = $1 WHERE id = $2 RETURNING *", - ) - .bind(monitored) - .bind(id) - .fetch_one(&self.pool) - .await?; - Ok(row) + sqlx::query("UPDATE episodes SET monitored = ? WHERE id = ?") + .bind(monitored) + .bind(id) + .execute(&self.pool) + .await?; + self.get(id).await } /// Bulk update monitored status for all episodes in a season, and sync the seasons table. @@ -444,21 +449,18 @@ impl EpisodeService { monitored, "season monitored changed" ); - sqlx::query( - "UPDATE episodes SET monitored = $1 WHERE series_id = $2 AND season_number = $3", - ) - .bind(monitored) - .bind(series_id) - .bind(season_number) - .execute(&self.pool) - .await?; + sqlx::query("UPDATE episodes SET monitored = ? WHERE series_id = ? AND season_number = ?") + .bind(monitored) + .bind(series_id) + .bind(season_number) + .execute(&self.pool) + .await?; // Upsert the seasons table to keep it in sync sqlx::query( "INSERT INTO seasons (series_id, season_number, monitored) - VALUES ($1, $2, $3) - ON CONFLICT (series_id, season_number) - DO UPDATE SET monitored = $3", + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE monitored = VALUES(monitored)", ) .bind(series_id) .bind(season_number) @@ -480,7 +482,7 @@ impl EpisodeService { MonitorStrategy::All => { // Monitor all non-special episodes sqlx::query( - "UPDATE episodes SET monitored = (season_number > 0) WHERE series_id = $1", + "UPDATE episodes SET monitored = (season_number > 0) WHERE series_id = ?", ) .bind(series_id) .execute(&self.pool) @@ -490,7 +492,7 @@ impl EpisodeService { // Find the latest (highest) season number (excluding specials) let latest: Option<(i32,)> = sqlx::query_as( "SELECT MAX(season_number) FROM episodes - WHERE series_id = $1 AND season_number > 0", + WHERE series_id = ? AND season_number > 0", ) .bind(series_id) .fetch_optional(&self.pool) @@ -498,11 +500,11 @@ impl EpisodeService { if let Some((max_season,)) = latest { sqlx::query( - "UPDATE episodes SET monitored = (season_number = $2) - WHERE series_id = $1 AND season_number > 0", + "UPDATE episodes SET monitored = (season_number = ?) + WHERE series_id = ? AND season_number > 0", ) - .bind(series_id) .bind(max_season) + .bind(series_id) .execute(&self.pool) .await?; } @@ -511,7 +513,7 @@ impl EpisodeService { // Monitor only season 1 sqlx::query( "UPDATE episodes SET monitored = (season_number = 1) - WHERE series_id = $1 AND season_number > 0", + WHERE series_id = ? AND season_number > 0", ) .bind(series_id) .execute(&self.pool) @@ -521,7 +523,7 @@ impl EpisodeService { // Monitor only unaired episodes (air_date_utc is NULL or in the future) sqlx::query( "UPDATE episodes SET monitored = (air_date_utc IS NULL OR air_date_utc > NOW()) - WHERE series_id = $1 AND season_number > 0", + WHERE series_id = ? AND season_number > 0", ) .bind(series_id) .execute(&self.pool) @@ -529,7 +531,7 @@ impl EpisodeService { } MonitorStrategy::None => { // Unmonitor everything - sqlx::query("UPDATE episodes SET monitored = false WHERE series_id = $1") + sqlx::query("UPDATE episodes SET monitored = false WHERE series_id = ?") .bind(series_id) .execute(&self.pool) .await?; @@ -539,11 +541,10 @@ impl EpisodeService { // Sync the seasons table: a season is monitored if any of its episodes are monitored sqlx::query( "INSERT INTO seasons (series_id, season_number, monitored) - SELECT series_id, season_number, bool_or(monitored) - FROM episodes WHERE series_id = $1 + SELECT series_id, season_number, MAX(monitored) + FROM episodes WHERE series_id = ? GROUP BY series_id, season_number - ON CONFLICT (series_id, season_number) - DO UPDATE SET monitored = EXCLUDED.monitored", + ON DUPLICATE KEY UPDATE monitored = VALUES(monitored)", ) .bind(series_id) .execute(&self.pool) @@ -557,11 +558,14 @@ impl EpisodeService { if episode_ids.is_empty() { return Ok(()); } - sqlx::query("UPDATE episodes SET monitored = $1 WHERE id = ANY($2)") - .bind(monitored) - .bind(episode_ids) - .execute(&self.pool) - .await?; + let mut query = sqlx::QueryBuilder::new("UPDATE episodes SET monitored = "); + query.push_bind(monitored).push(" WHERE id IN ("); + let mut ids = query.separated(", "); + for id in episode_ids { + ids.push_bind(id); + } + ids.push_unseparated(")"); + query.build().execute(&self.pool).await?; Ok(()) } } @@ -584,11 +588,11 @@ pub struct CalendarEntry { #[derive(Clone)] pub struct CalendarService { - pool: PgPool, + pool: MySqlPool, } impl CalendarService { - pub fn new(pool: PgPool) -> Self { + pub fn new(pool: MySqlPool) -> Self { Self { pool } } @@ -601,7 +605,7 @@ impl CalendarService { e.monitored FROM episodes e JOIN series s ON e.series_id = s.id - WHERE e.air_date_utc BETWEEN $1::timestamptz AND $2::timestamptz + WHERE e.air_date_utc BETWEEN ? AND ? AND s.monitored = true ORDER BY e.air_date_utc", ) @@ -639,11 +643,11 @@ pub struct WantedRecord { #[derive(Clone)] pub struct WantedService { - pool: PgPool, + pool: MySqlPool, } impl WantedService { - pub fn new(pool: PgPool) -> Self { + pub fn new(pool: MySqlPool) -> Self { Self { pool } } @@ -675,23 +679,23 @@ impl WantedService { // Fetch combined missing records using a UNION query let rows = sqlx::query_as::<_, (i64, String, i64, String, Option, i32, Option, bool)>( "SELECT * FROM ( - SELECT e.id, 'series'::text AS media_type, e.series_id AS media_id, - s.title, CONCAT('S', LPAD(e.season_number::text, 2, '0'), 'E', LPAD(e.episode_number::text, 2, '0')) AS episode_info, - s.quality_profile_id, e.air_date_utc::text AS air_date, e.monitored + SELECT e.id, 'series' AS media_type, e.series_id AS media_id, + s.title, CONCAT('S', LPAD(e.season_number, 2, '0'), 'E', LPAD(e.episode_number, 2, '0')) AS episode_info, + s.quality_profile_id, CAST(e.air_date_utc AS CHAR) AS air_date, e.monitored FROM episodes e JOIN series s ON e.series_id = s.id WHERE e.monitored = true AND s.monitored = true AND e.episode_file_id IS NULL AND e.air_date_utc < NOW() UNION ALL - SELECT m.id, 'movie'::text AS media_type, m.id AS media_id, + SELECT m.id, 'movie' AS media_type, m.id AS media_id, m.title, NULL AS episode_info, - m.quality_profile_id, m.physical_release::text AS air_date, m.monitored + m.quality_profile_id, CAST(m.physical_release AS CHAR) AS air_date, m.monitored FROM movies m WHERE m.monitored = true AND m.movie_file_id IS NULL ) combined - ORDER BY air_date DESC NULLS LAST - LIMIT $1 OFFSET $2", + ORDER BY air_date IS NULL, air_date DESC + LIMIT ? OFFSET ?", ) .bind(page_size) .bind(offset) @@ -746,7 +750,7 @@ impl WantedService { JOIN quality_profiles qp ON s.quality_profile_id = qp.id WHERE e.monitored = true AND s.monitored = true AND e.episode_file_id IS NOT NULL - AND COALESCE((mf.quality->>'quality')::int, 0) < qp.cutoff", + AND COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(mf.quality, '$.quality')) AS SIGNED), 0) < qp.cutoff", ) .fetch_one(&self.pool) .await?; @@ -758,7 +762,7 @@ impl WantedService { JOIN quality_profiles qp ON m.quality_profile_id = qp.id WHERE m.monitored = true AND m.movie_file_id IS NOT NULL - AND COALESCE((mf.quality->>'quality')::int, 0) < qp.cutoff", + AND COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(mf.quality, '$.quality')) AS SIGNED), 0) < qp.cutoff", ) .fetch_one(&self.pool) .await?; @@ -767,29 +771,29 @@ impl WantedService { let rows = sqlx::query_as::<_, (i64, String, i64, String, Option, i32, Option, bool)>( "SELECT * FROM ( - SELECT e.id, 'series'::text AS media_type, e.series_id AS media_id, - s.title, CONCAT('S', LPAD(e.season_number::text, 2, '0'), 'E', LPAD(e.episode_number::text, 2, '0')) AS episode_info, - s.quality_profile_id, e.air_date_utc::text AS air_date, e.monitored + SELECT e.id, 'series' AS media_type, e.series_id AS media_id, + s.title, CONCAT('S', LPAD(e.season_number, 2, '0'), 'E', LPAD(e.episode_number, 2, '0')) AS episode_info, + s.quality_profile_id, CAST(e.air_date_utc AS CHAR) AS air_date, e.monitored FROM episodes e JOIN series s ON e.series_id = s.id JOIN media_files mf ON e.episode_file_id = mf.id JOIN quality_profiles qp ON s.quality_profile_id = qp.id WHERE e.monitored = true AND s.monitored = true AND e.episode_file_id IS NOT NULL - AND COALESCE((mf.quality->>'quality')::int, 0) < qp.cutoff + AND COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(mf.quality, '$.quality')) AS SIGNED), 0) < qp.cutoff UNION ALL - SELECT m.id, 'movie'::text AS media_type, m.id AS media_id, + SELECT m.id, 'movie' AS media_type, m.id AS media_id, m.title, NULL AS episode_info, - m.quality_profile_id, m.physical_release::text AS air_date, m.monitored + m.quality_profile_id, CAST(m.physical_release AS CHAR) AS air_date, m.monitored FROM movies m JOIN media_files mf ON m.movie_file_id = mf.id JOIN quality_profiles qp ON m.quality_profile_id = qp.id WHERE m.monitored = true AND m.movie_file_id IS NOT NULL - AND COALESCE((mf.quality->>'quality')::int, 0) < qp.cutoff + AND COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(mf.quality, '$.quality')) AS SIGNED), 0) < qp.cutoff ) combined - ORDER BY air_date DESC NULLS LAST - LIMIT $1 OFFSET $2", + ORDER BY air_date IS NULL, air_date DESC + LIMIT ? OFFSET ?", ) .bind(page_size) .bind(offset) @@ -836,11 +840,11 @@ impl WantedService { #[derive(Clone)] pub struct MetadataRefreshService { - pool: PgPool, + pool: MySqlPool, } impl MetadataRefreshService { - pub fn new(pool: PgPool) -> Self { + pub fn new(pool: MySqlPool) -> Self { Self { pool } } @@ -849,7 +853,7 @@ impl MetadataRefreshService { let rows: Vec<(i64,)> = sqlx::query_as( "SELECT id FROM series WHERE last_info_sync IS NULL - OR last_info_sync < NOW() - INTERVAL '12 hours'", + OR last_info_sync < NOW() - INTERVAL 12 HOUR", ) .fetch_all(&self.pool) .await?; @@ -861,7 +865,7 @@ impl MetadataRefreshService { let rows: Vec<(i64,)> = sqlx::query_as( "SELECT id FROM movies WHERE last_info_sync IS NULL - OR last_info_sync < NOW() - INTERVAL '12 hours'", + OR last_info_sync < NOW() - INTERVAL 12 HOUR", ) .fetch_all(&self.pool) .await?; @@ -870,7 +874,7 @@ impl MetadataRefreshService { /// Update last_info_sync timestamp for a series. pub async fn mark_series_synced(&self, id: i64) -> Result<()> { - sqlx::query("UPDATE series SET last_info_sync = NOW() WHERE id = $1") + sqlx::query("UPDATE series SET last_info_sync = NOW() WHERE id = ?") .bind(id) .execute(&self.pool) .await?; @@ -879,7 +883,7 @@ impl MetadataRefreshService { /// Update last_info_sync timestamp for a movie. pub async fn mark_movie_synced(&self, id: i64) -> Result<()> { - sqlx::query("UPDATE movies SET last_info_sync = NOW() WHERE id = $1") + sqlx::query("UPDATE movies SET last_info_sync = NOW() WHERE id = ?") .bind(id) .execute(&self.pool) .await?; @@ -918,20 +922,20 @@ impl MetadataRefreshService { sqlx::query( "UPDATE series - SET overview = COALESCE($1, overview), - network = COALESCE($2, network), - runtime = COALESCE($3, runtime), - images = COALESCE($4, images), - genres = COALESCE($5, genres), - status = $6, + SET overview = COALESCE(?, overview), + network = COALESCE(?, network), + runtime = COALESCE(?, runtime), + images = COALESCE(?, images), + genres = COALESCE(?, genres), + status = ?, last_info_sync = NOW() - WHERE id = $7", + WHERE id = ?", ) .bind(overview) .bind(network) .bind(runtime) .bind(images) - .bind(genres) + .bind(genres.map(sqlx::types::Json)) .bind(series_status) .bind(id) .execute(&self.pool) @@ -950,17 +954,17 @@ impl MetadataRefreshService { ) -> Result<()> { sqlx::query( "UPDATE movies - SET overview = COALESCE($1, overview), - studio = COALESCE($2, studio), - images = COALESCE($3, images), - genres = COALESCE($4, genres), + SET overview = COALESCE(?, overview), + studio = COALESCE(?, studio), + images = COALESCE(?, images), + genres = COALESCE(?, genres), last_info_sync = NOW() - WHERE id = $5", + WHERE id = ?", ) .bind(overview) .bind(studio) .bind(images) - .bind(genres) + .bind(genres.map(sqlx::types::Json)) .bind(id) .execute(&self.pool) .await?; diff --git a/crates/stackarr-migrate/src/lib.rs b/crates/stackarr-migrate/src/lib.rs index cc056148..bb83ed8c 100644 --- a/crates/stackarr-migrate/src/lib.rs +++ b/crates/stackarr-migrate/src/lib.rs @@ -31,7 +31,7 @@ fn remap_path(path: &mut String, mappings: &[PathMapping]) { /// from the old *arr container mounts to StackArr's mounts. /// When `dry_run` is true, all data is read and merged but nothing is written to Postgres. pub async fn run_migration( - pool: &sqlx::PgPool, + pool: &sqlx::MySqlPool, sonarr_db: Option<&std::path::Path>, radarr_db: Option<&std::path::Path>, prowlarr_db: Option<&std::path::Path>, diff --git a/crates/stackarr-migrate/src/writer.rs b/crates/stackarr-migrate/src/writer.rs index 7460f2d0..e11c75c6 100644 --- a/crates/stackarr-migrate/src/writer.rs +++ b/crates/stackarr-migrate/src/writer.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; use serde_json::Value as JsonValue; -use sqlx::PgPool; +use sqlx::MySqlPool; use tracing::{debug, info, warn}; use crate::prowlarr::{ @@ -1408,11 +1408,11 @@ pub fn build_migration_data( // --------------------------------------------------------------------------- pub struct MigrationWriter { - pool: PgPool, + pool: MySqlPool, } impl MigrationWriter { - pub fn new(pool: PgPool) -> Self { + pub fn new(pool: MySqlPool) -> Self { Self { pool } } @@ -1585,21 +1585,21 @@ impl MigrationWriter { async fn write_tags( &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tx: &mut sqlx::Transaction<'_, sqlx::MySql>, tags: &[String], ) -> Result> { let mut map = HashMap::new(); for label in tags { - let row: (i32,) = sqlx::query_as( - "INSERT INTO tags (label) VALUES ($1) - ON CONFLICT (label) DO UPDATE SET label = tags.label - RETURNING id", + let result = sqlx::query( + "INSERT INTO tags (label) VALUES (?) + ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)", ) .bind(label) - .fetch_one(&mut **tx) + .execute(&mut **tx) .await .with_context(|| format!("insert tag '{label}'"))?; - map.insert(label.to_lowercase(), row.0 as i64); + let id = i64::try_from(result.last_insert_id()).context("tag id exceeds i64")?; + map.insert(label.to_lowercase(), id); } Ok(map) } @@ -1608,15 +1608,14 @@ impl MigrationWriter { async fn write_quality_profiles( &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tx: &mut sqlx::Transaction<'_, sqlx::MySql>, profiles: &[QualityProfileInsert], ) -> Result> { let mut map = HashMap::new(); for p in profiles { - let row: (i32,) = sqlx::query_as( + let result = sqlx::query( "INSERT INTO quality_profiles (name, cutoff, upgrade_allowed, min_format_score, cutoff_format_score, min_upgrade_format_score, items, media_type, language) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING id", + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&p.name) .bind(p.cutoff) @@ -1627,10 +1626,12 @@ impl MigrationWriter { .bind(&p.items) .bind(&p.media_type) .bind(p.language) - .fetch_one(&mut **tx) + .execute(&mut **tx) .await .with_context(|| format!("insert quality profile '{}'", p.name))?; - map.insert(p.old_id, row.0 as i64); + let id = + i64::try_from(result.last_insert_id()).context("quality profile id exceeds i64")?; + map.insert(p.old_id, id); } Ok(map) } @@ -1639,23 +1640,24 @@ impl MigrationWriter { async fn write_custom_formats( &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tx: &mut sqlx::Transaction<'_, sqlx::MySql>, formats: &[CustomFormatInsert], ) -> Result> { let mut map = HashMap::new(); for (idx, cf) in formats.iter().enumerate() { - let row: (i32,) = sqlx::query_as( + let result = sqlx::query( "INSERT INTO custom_formats (name, specifications, include_custom_format_when_renaming) - VALUES ($1, $2, $3) - RETURNING id", + VALUES (?, ?, ?)", ) .bind(&cf.name) .bind(&cf.specifications) .bind(cf.include_when_renaming) - .fetch_one(&mut **tx) + .execute(&mut **tx) .await .with_context(|| format!("insert custom format '{}'", cf.name))?; - map.insert(idx, row.0 as i64); + let id = + i64::try_from(result.last_insert_id()).context("custom format id exceeds i64")?; + map.insert(idx, id); } Ok(map) } @@ -1664,10 +1666,10 @@ impl MigrationWriter { /// Write custom_format_scores rows linking quality profiles to custom formats. /// Profile format_scores contain (cf_insert_idx, score) — we remap both - /// the profile old_id and cf_insert_idx to their new Postgres IDs. + /// the profile old_id and cf_insert_idx to their new MariaDB IDs. async fn write_format_scores( &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tx: &mut sqlx::Transaction<'_, sqlx::MySql>, profiles: &[QualityProfileInsert], profile_id_map: &HashMap, cf_id_map: &HashMap, @@ -1683,8 +1685,8 @@ impl MigrationWriter { }; sqlx::query( "INSERT INTO custom_format_scores (profile_id, format_id, score) - VALUES ($1, $2, $3) - ON CONFLICT (profile_id, format_id) DO UPDATE SET score = $3", + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE score = VALUES(score)", ) .bind(new_profile_id as i32) .bind(new_cf_id as i32) @@ -1704,23 +1706,24 @@ impl MigrationWriter { async fn write_media_library_folders( &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tx: &mut sqlx::Transaction<'_, sqlx::MySql>, folders: &[MediaLibraryFolderInsert], ) -> Result> { let mut map = HashMap::new(); for f in folders { - let row: (i32,) = sqlx::query_as( + let result = sqlx::query( "INSERT INTO media_library_folders (path, media_type) - VALUES ($1, $2) - ON CONFLICT (path) DO UPDATE SET media_type = media_library_folders.media_type - RETURNING id", + VALUES (?, ?) + ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)", ) .bind(&f.path) .bind(&f.media_type) - .fetch_one(&mut **tx) + .execute(&mut **tx) .await .with_context(|| format!("insert media library folder '{}'", f.path))?; - map.insert(f.path.clone(), row.0 as i64); + let id = i64::try_from(result.last_insert_id()) + .context("media library folder id exceeds i64")?; + map.insert(f.path.clone(), id); } Ok(map) } @@ -1729,21 +1732,21 @@ impl MigrationWriter { async fn write_naming_config( &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tx: &mut sqlx::Transaction<'_, sqlx::MySql>, nc: &NamingConfigInsert, ) -> Result<()> { sqlx::query( "INSERT INTO naming_config (media_type, rename_files, standard_format, daily_format, anime_format, season_folder_format, movie_format, movie_folder_format, colon_replacement) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - ON CONFLICT (media_type) DO UPDATE SET - rename_files = $2, - standard_format = $3, - daily_format = $4, - anime_format = $5, - season_folder_format = $6, - movie_format = $7, - movie_folder_format = $8, - colon_replacement = $9", + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + rename_files = VALUES(rename_files), + standard_format = VALUES(standard_format), + daily_format = VALUES(daily_format), + anime_format = VALUES(anime_format), + season_folder_format = VALUES(season_folder_format), + movie_format = VALUES(movie_format), + movie_folder_format = VALUES(movie_folder_format), + colon_replacement = VALUES(colon_replacement)", ) .bind(&nc.media_type) .bind(nc.rename_files) @@ -1764,21 +1767,21 @@ impl MigrationWriter { async fn write_indexers( &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tx: &mut sqlx::Transaction<'_, sqlx::MySql>, indexers: &[IndexerInsert], ) -> Result { let mut count = 0; for idx in indexers { sqlx::query( "INSERT INTO indexers (name, indexer_type, base_url, api_key, protocol, categories, enabled, priority, supports_search, supports_rss, config) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&idx.name) .bind(&idx.indexer_type) .bind(&idx.base_url) .bind(&idx.api_key) .bind(&idx.protocol) - .bind(&idx.categories) + .bind(idx.categories.as_ref().map(sqlx::types::Json)) .bind(idx.enabled) .bind(idx.priority) .bind(idx.supports_search) @@ -1796,14 +1799,14 @@ impl MigrationWriter { async fn write_download_clients( &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tx: &mut sqlx::Transaction<'_, sqlx::MySql>, clients: &[DownloadClientInsert], ) -> Result { let mut count = 0; for dc in clients { sqlx::query( "INSERT INTO download_clients (name, client_type, protocol, config, enabled, priority) - VALUES ($1, $2, $3, $4, $5, $6)", + VALUES (?, ?, ?, ?, ?, ?)", ) .bind(&dc.name) .bind(&dc.client_type) @@ -1824,7 +1827,7 @@ impl MigrationWriter { #[allow(clippy::too_many_arguments)] async fn write_series( &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tx: &mut sqlx::Transaction<'_, sqlx::MySql>, series: &[SeriesInsert], profile_id_map: &HashMap, _profile_name_map: &HashMap, @@ -1857,14 +1860,13 @@ impl MigrationWriter { .collect() }); - let row: (i64,) = sqlx::query_as( + let result = sqlx::query( "INSERT INTO series (title, clean_title, sort_title, overview, status, series_type, network, air_time, first_aired, year, runtime, path, media_library_folder_id, quality_profile_id, season_folder, monitored, use_scene_numbering, tvdb_id, imdb_id, tmdb_id, tvmaze_id, images, genres, tags, added_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, - $16, $17, $18, $19, $20, $21, $22, $23, $24, $25) - RETURNING id", + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&s.title) .bind(&s.clean_title) @@ -1888,14 +1890,15 @@ impl MigrationWriter { .bind(s.tmdb_id) .bind(s.tvmaze_id) .bind(&s.images) - .bind::>(None) // genres - Sonarr doesn't store them on Series - .bind(mapped_tags.as_deref()) + .bind(Option::>::None) // Sonarr has no series genres + .bind(mapped_tags.as_deref().map(sqlx::types::Json)) .bind(s.added_at) - .fetch_one(&mut **tx) + .execute(&mut **tx) .await .with_context(|| format!("insert series '{}'", s.title))?; - map.insert(s.old_id, row.0); + let id = i64::try_from(result.last_insert_id()).context("series id exceeds i64")?; + map.insert(s.old_id, id); } Ok(map) } @@ -1904,7 +1907,7 @@ impl MigrationWriter { async fn write_seasons( &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tx: &mut sqlx::Transaction<'_, sqlx::MySql>, series: &[SeriesInsert], series_id_map: &HashMap, ) -> Result<()> { @@ -1916,9 +1919,8 @@ impl MigrationWriter { let seasons = parse_seasons_json(json); for season in seasons { let result = sqlx::query( - "INSERT INTO seasons (series_id, season_number, monitored) - VALUES ($1, $2, $3) - ON CONFLICT (series_id, season_number) DO NOTHING", + "INSERT IGNORE INTO seasons (series_id, season_number, monitored) + VALUES (?, ?, ?)", ) .bind(new_series_id) .bind(season.season_number) @@ -1942,18 +1944,17 @@ impl MigrationWriter { async fn write_media_files( &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tx: &mut sqlx::Transaction<'_, sqlx::MySql>, files: &[MediaFileInsert], ) -> Result<(HashMap, HashMap)> { let mut series_map = HashMap::new(); let mut movie_map = HashMap::new(); for f in files { - let row: (i64,) = sqlx::query_as( + let result = sqlx::query( "INSERT INTO media_files (media_type, relative_path, size, date_added, quality, languages, scene_name, release_group, release_hash, edition, media_info, indexer_flags) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) - RETURNING id", + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&f.media_type) .bind(&f.relative_path) @@ -1967,16 +1968,18 @@ impl MigrationWriter { .bind(&f.edition) .bind(&f.media_info) .bind(f.indexer_flags) - .fetch_one(&mut **tx) + .execute(&mut **tx) .await .with_context(|| format!("insert media file '{}'", f.relative_path))?; + let id = i64::try_from(result.last_insert_id()).context("media file id exceeds i64")?; + match f.media_type.as_str() { "series" => { - series_map.insert(f.old_id, row.0); + series_map.insert(f.old_id, id); } "movie" => { - movie_map.insert(f.old_id, row.0); + movie_map.insert(f.old_id, id); } _ => {} } @@ -1989,7 +1992,7 @@ impl MigrationWriter { async fn write_episodes( &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tx: &mut sqlx::Transaction<'_, sqlx::MySql>, episodes: &[EpisodeInsert], series_id_map: &HashMap, file_id_map: &HashMap, @@ -2009,14 +2012,13 @@ impl MigrationWriter { .old_episode_file_id .and_then(|old_id| file_id_map.get(&old_id).copied()); - let result = sqlx::query_as::<_, (i64,)>( + let result = sqlx::query( "INSERT INTO episodes (series_id, season_number, episode_number, absolute_number, scene_season_number, scene_episode_number, scene_absolute_number, title, overview, air_date, air_date_utc, runtime, monitored, episode_file_id, last_search_time) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) - ON CONFLICT (series_id, season_number, episode_number) DO NOTHING - RETURNING id", + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)", ) .bind(new_series_id) .bind(ep.season_number) @@ -2033,7 +2035,7 @@ impl MigrationWriter { .bind(ep.monitored) .bind(episode_file_id) .bind(ep.last_search_time) - .fetch_optional(&mut **tx) + .execute(&mut **tx) .await .with_context(|| { format!( @@ -2042,14 +2044,16 @@ impl MigrationWriter { ) })?; - if let Some((new_id,)) = result { + let new_id = + i64::try_from(result.last_insert_id()).context("episode id exceeds i64")?; + if new_id != 0 { map.insert(ep.old_id, new_id); // Also insert into episode_files join table if there is a file if let Some(new_file_id) = episode_file_id { let _ = sqlx::query( - "INSERT INTO episode_files (episode_id, media_file_id) - VALUES ($1, $2) ON CONFLICT DO NOTHING", + "INSERT IGNORE INTO episode_files (episode_id, media_file_id) + VALUES (?, ?)", ) .bind(new_id) .bind(new_file_id) @@ -2067,7 +2071,7 @@ impl MigrationWriter { #[allow(clippy::too_many_arguments)] async fn write_movies( &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tx: &mut sqlx::Transaction<'_, sqlx::MySql>, movies: &[MovieInsert], profile_id_map: &HashMap, profile_name_map: &HashMap, @@ -2108,14 +2112,13 @@ impl MigrationWriter { .collect() }); - let row: (i64,) = sqlx::query_as( + let result = sqlx::query( "INSERT INTO movies (title, clean_title, sort_title, overview, year, studio, path, media_library_folder_id, quality_profile_id, monitored, minimum_availability, movie_file_id, tmdb_id, imdb_id, in_cinemas, physical_release, digital_release, images, genres, tags, collection_tmdb_id, added_at, original_language) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, - $15, $16, $17, $18, $19, $20, $21, $22, $23) - RETURNING id", + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&m.title) .bind(&m.clean_title) @@ -2135,16 +2138,17 @@ impl MigrationWriter { .bind(m.physical_release) .bind(m.digital_release) .bind(&m.images) - .bind(m.genres.as_deref()) - .bind(mapped_tags.as_deref()) + .bind(m.genres.as_deref().map(sqlx::types::Json)) + .bind(mapped_tags.as_deref().map(sqlx::types::Json)) .bind(m.collection_tmdb_id) .bind(m.added_at) .bind(m.original_language) - .fetch_one(&mut **tx) + .execute(&mut **tx) .await .with_context(|| format!("insert movie '{}'", m.title))?; - map.insert(m.old_id, row.0); + let id = i64::try_from(result.last_insert_id()).context("movie id exceeds i64")?; + map.insert(m.old_id, id); } Ok(map) @@ -2155,7 +2159,7 @@ impl MigrationWriter { #[allow(dead_code)] async fn write_history( &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tx: &mut sqlx::Transaction<'_, sqlx::MySql>, history: &[HistoryInsert], series_id_map: &HashMap, movie_id_map: &HashMap, @@ -2182,7 +2186,7 @@ impl MigrationWriter { sqlx::query( "INSERT INTO history (media_type, media_id, episode_id, event_type, quality, languages, source_title, download_id, data, occurred_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&h.media_type) .bind(media_id) @@ -2213,7 +2217,7 @@ impl MigrationWriter { async fn write_blocklist( &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tx: &mut sqlx::Transaction<'_, sqlx::MySql>, blocklist: &[BlocklistInsert], series_id_map: &HashMap, movie_id_map: &HashMap, @@ -2234,7 +2238,7 @@ impl MigrationWriter { sqlx::query( "INSERT INTO blocklist (media_type, media_id, source_title, quality, languages, info_hash, added_at) - VALUES ($1, $2, $3, $4, $5, $6, $7)", + VALUES (?, ?, ?, ?, ?, ?, ?)", ) .bind(&b.media_type) .bind(media_id) diff --git a/crates/stackarr-notify/src/lib.rs b/crates/stackarr-notify/src/lib.rs index 41d6952b..d90431f1 100644 --- a/crates/stackarr-notify/src/lib.rs +++ b/crates/stackarr-notify/src/lib.rs @@ -472,7 +472,7 @@ pub fn build_provider_from_config( /// /// This is the main entry point for sending notifications throughout the app. /// Errors from individual providers are logged but never propagated. -pub async fn dispatch_event(pool: &sqlx::PgPool, event: &NotificationEvent) { +pub async fn dispatch_event(pool: &sqlx::MySqlPool, event: &NotificationEvent) { let rows: Vec = match sqlx::query_as::<_, NotificationProviderRow>( "SELECT id, name, provider_type, config, on_grab, on_import, on_upgrade, \ on_health_issue, on_failure, enabled \ diff --git a/crates/stackarr-plex/src/scanner.rs b/crates/stackarr-plex/src/scanner.rs index adccb5f0..e700bc46 100644 --- a/crates/stackarr-plex/src/scanner.rs +++ b/crates/stackarr-plex/src/scanner.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use anyhow::Result; -use sqlx::PgPool; +use sqlx::MySqlPool; use stackarr_metadata::TmdbClient; use crate::api::PlexApi; @@ -12,19 +12,19 @@ const PAGE_SIZE: i64 = 50; /// Scans Plex libraries and updates local media availability. pub struct PlexScanner { - pool: PgPool, + pool: MySqlPool, tmdb_client: Option>, } impl PlexScanner { - pub fn new(pool: PgPool) -> Self { + pub fn new(pool: MySqlPool) -> Self { Self { pool, tmdb_client: None, } } - pub fn with_tmdb_client(pool: PgPool, tmdb_client: Option>) -> Self { + pub fn with_tmdb_client(pool: MySqlPool, tmdb_client: Option>) -> Self { Self { pool, tmdb_client } } @@ -83,7 +83,7 @@ impl PlexScanner { } // Update last_scan timestamp - let _ = sqlx::query("UPDATE plex_libraries SET last_scan = NOW() WHERE id = $1") + let _ = sqlx::query("UPDATE plex_libraries SET last_scan = NOW() WHERE id = ?") .bind(lib.id) .execute(&self.pool) .await; @@ -164,7 +164,7 @@ impl PlexScanner { } // Update last_scan - let _ = sqlx::query("UPDATE plex_libraries SET last_scan = NOW() WHERE id = $1") + let _ = sqlx::query("UPDATE plex_libraries SET last_scan = NOW() WHERE id = ?") .bind(lib.id) .execute(&self.pool) .await; @@ -294,8 +294,8 @@ impl PlexScanner { }; let query = format!( - "UPDATE movies SET {rk_col} = $1, media_added_at = COALESCE($2, media_added_at) \ - WHERE tmdb_id = $3" + "UPDATE movies SET {rk_col} = ?, media_added_at = COALESCE(?, media_added_at) \ + WHERE tmdb_id = ?" ); let result = sqlx::query(&query) .bind(rk_val) @@ -318,8 +318,8 @@ impl PlexScanner { }; let query = format!( - "UPDATE series SET {rk_col} = $1, media_added_at = COALESCE($2, media_added_at) \ - WHERE tmdb_id = $3" + "UPDATE series SET {rk_col} = ?, media_added_at = COALESCE(?, media_added_at) \ + WHERE tmdb_id = ?" ); let result = sqlx::query(&query) .bind(rk_val) @@ -353,7 +353,7 @@ impl PlexScanner { async fn load_enabled_libraries(&self, server_id: i32) -> Result> { let libs = sqlx::query_as::<_, PlexLibrary>( "SELECT id, plex_server_id, section_id, name, enabled, library_type, last_scan \ - FROM plex_libraries WHERE plex_server_id = $1 AND enabled = true ORDER BY id", + FROM plex_libraries WHERE plex_server_id = ? AND enabled = true ORDER BY id", ) .bind(server_id) .fetch_all(&self.pool) diff --git a/crates/stackarr-plex/src/sync.rs b/crates/stackarr-plex/src/sync.rs index 29383ed2..e4a4d273 100644 --- a/crates/stackarr-plex/src/sync.rs +++ b/crates/stackarr-plex/src/sync.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use sqlx::PgPool; +use sqlx::MySqlPool; use crate::api::{PlexApi, PlexTvApi}; use crate::guid; @@ -10,11 +10,11 @@ use crate::types::*; /// Verifies that media marked as available in Plex still exists. /// Runs every 24 hours to detect items removed from Plex libraries. pub struct AvailabilitySync { - pool: PgPool, + pool: MySqlPool, } impl AvailabilitySync { - pub fn new(pool: PgPool) -> Self { + pub fn new(pool: MySqlPool) -> Self { Self { pool } } @@ -47,7 +47,7 @@ impl AvailabilitySync { // Check standard quality if !rk.is_empty() && !self.item_exists_in_plex(&api, rk, false).await { tracing::info!(movie_id, rating_key = %rk, "movie no longer in Plex, clearing"); - let _ = sqlx::query("UPDATE movies SET plex_rating_key = NULL WHERE id = $1") + let _ = sqlx::query("UPDATE movies SET plex_rating_key = NULL WHERE id = ?") .bind(movie_id) .execute(&self.pool) .await; @@ -58,11 +58,10 @@ impl AvailabilitySync { if let Some(rk4) = rk_4k && !self.item_exists_in_plex(&api, rk4, true).await { - let _ = - sqlx::query("UPDATE movies SET plex_rating_key_4k = NULL WHERE id = $1") - .bind(movie_id) - .execute(&self.pool) - .await; + let _ = sqlx::query("UPDATE movies SET plex_rating_key_4k = NULL WHERE id = ?") + .bind(movie_id) + .execute(&self.pool) + .await; report.removed += 1; } } @@ -80,7 +79,7 @@ impl AvailabilitySync { if !rk.is_empty() && !self.item_exists_in_plex(&api, rk, false).await { tracing::info!(series_id, rating_key = %rk, "series no longer in Plex, clearing"); - let _ = sqlx::query("UPDATE series SET plex_rating_key = NULL WHERE id = $1") + let _ = sqlx::query("UPDATE series SET plex_rating_key = NULL WHERE id = ?") .bind(series_id) .execute(&self.pool) .await; @@ -90,11 +89,10 @@ impl AvailabilitySync { if let Some(rk4) = rk_4k && !self.item_exists_in_plex(&api, rk4, true).await { - let _ = - sqlx::query("UPDATE series SET plex_rating_key_4k = NULL WHERE id = $1") - .bind(series_id) - .execute(&self.pool) - .await; + let _ = sqlx::query("UPDATE series SET plex_rating_key_4k = NULL WHERE id = ?") + .bind(series_id) + .execute(&self.pool) + .await; report.removed += 1; } } @@ -135,11 +133,11 @@ pub struct AvailabilitySyncReport { /// Syncs Plex watchlists and optionally auto-adds items to the library. pub struct WatchlistSync { - pool: PgPool, + pool: MySqlPool, } impl WatchlistSync { - pub fn new(pool: PgPool) -> Self { + pub fn new(pool: MySqlPool) -> Self { Self { pool } } @@ -230,9 +228,8 @@ impl WatchlistSync { }; let result = sqlx::query( - "INSERT INTO watchlist (tmdb_id, media_type, plex_rating_key) \ - VALUES ($1, $2, $3) \ - ON CONFLICT (tmdb_id, media_type) DO NOTHING", + "INSERT IGNORE INTO watchlist (tmdb_id, media_type, plex_rating_key) \ + VALUES (?, ?, ?)", ) .bind(tmdb_id) .bind(media_type_normalized) @@ -263,14 +260,14 @@ impl WatchlistSync { // Skip if already in library let in_library = match media_type { "movie" => sqlx::query_scalar::<_, bool>( - "SELECT EXISTS(SELECT 1 FROM movies WHERE tmdb_id = $1)", + "SELECT EXISTS(SELECT 1 FROM movies WHERE tmdb_id = ?)", ) .bind(tmdb_id) .fetch_one(&self.pool) .await .unwrap_or(false), "tv" => sqlx::query_scalar::<_, bool>( - "SELECT EXISTS(SELECT 1 FROM series WHERE tmdb_id = $1)", + "SELECT EXISTS(SELECT 1 FROM series WHERE tmdb_id = ?)", ) .bind(tmdb_id) .fetch_one(&self.pool) @@ -285,7 +282,7 @@ impl WatchlistSync { // Skip if request already exists let request_exists: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM media_requests WHERE tmdb_id = $1 AND media_type = $2)", + "SELECT EXISTS(SELECT 1 FROM media_requests WHERE tmdb_id = ? AND media_type = ?)", ) .bind(tmdb_id) .bind(media_type) @@ -300,9 +297,8 @@ impl WatchlistSync { // Create request (auto-approved, system user) let title = format!("TMDB #{tmdb_id}"); let result = sqlx::query( - "INSERT INTO media_requests (user_id, media_type, tmdb_id, title, status) \ - VALUES (1, $1, $2, $3, 'approved') \ - ON CONFLICT (tmdb_id, media_type) DO NOTHING", + "INSERT IGNORE INTO media_requests (user_id, media_type, tmdb_id, title, status) \ + VALUES (1, ?, ?, ?, 'approved')", ) .bind(media_type) .bind(tmdb_id) @@ -315,7 +311,7 @@ impl WatchlistSync { { // Mark as auto-requested in watchlist let _ = sqlx::query( - "UPDATE watchlist SET auto_requested = true WHERE tmdb_id = $1 AND media_type = $2", + "UPDATE watchlist SET auto_requested = true WHERE tmdb_id = ? AND media_type = ?", ) .bind(tmdb_id) .bind(media_type) @@ -352,11 +348,11 @@ pub struct WatchlistSyncReport { /// Periodically pings plex.tv to keep auth tokens from expiring. pub struct TokenRefresh { - pool: PgPool, + pool: MySqlPool, } impl TokenRefresh { - pub fn new(pool: PgPool) -> Self { + pub fn new(pool: MySqlPool) -> Self { Self { pool } } diff --git a/crates/stackarr-postgres/Cargo.toml b/crates/stackarr-postgres/Cargo.toml deleted file mode 100644 index 567b04fe..00000000 --- a/crates/stackarr-postgres/Cargo.toml +++ /dev/null @@ -1,30 +0,0 @@ -[package] -name = "stackarr-postgres" -version.workspace = true -edition.workspace = true -license.workspace = true - -[lints] -workspace = true - -[features] -default = [] -embed = ["rust-embed"] - -[dependencies] -stackarr-core = { workspace = true } -tokio = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -chrono = { workspace = true } -tracing = { workspace = true } -thiserror = { workspace = true } -anyhow = { workspace = true } -reqwest = { workspace = true } -rust-embed = { version = "8", optional = true } - -[target.'cfg(all(unix, not(target_os = "linux")))'.dependencies] -libc = { workspace = true } - -[dev-dependencies] -tempfile = { workspace = true } diff --git a/crates/stackarr-postgres/src/config.rs b/crates/stackarr-postgres/src/config.rs deleted file mode 100644 index 41acd589..00000000 --- a/crates/stackarr-postgres/src/config.rs +++ /dev/null @@ -1,71 +0,0 @@ -use std::path::PathBuf; - -/// Resolved paths to PostgreSQL binaries and directories. -#[derive(Debug, Clone)] -pub struct PgPaths { - /// Directory containing pg_ctl, initdb, postgres, pg_isready, etc. - pub bin_dir: PathBuf, - /// PostgreSQL shared libraries directory. - pub lib_dir: PathBuf, - /// PostgreSQL share directory (timezone data, etc.). - pub share_dir: PathBuf, -} - -/// PostgreSQL database mode. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PgMode { - /// User provides their own PostgreSQL instance and connection string. - External, - /// StackArr downloads and manages a PostgreSQL instance. - Managed, - /// PostgreSQL binaries are embedded in the binary (requires `embed` feature). - Embedded, -} - -impl PgMode { - pub fn parse(s: &str) -> Option { - match s { - "external" => Some(Self::External), - "managed" => Some(Self::Managed), - "embedded" => Some(Self::Embedded), - _ => None, - } - } -} - -/// Version metadata written to `{data_dir}/postgres/version.json`. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct PgVersionInfo { - pub pg_major: u32, - pub pg_version: String, - pub provisioned_at: chrono::DateTime, - pub source: String, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_pg_mode_from_str() { - assert_eq!(PgMode::parse("external"), Some(PgMode::External)); - assert_eq!(PgMode::parse("managed"), Some(PgMode::Managed)); - assert_eq!(PgMode::parse("embedded"), Some(PgMode::Embedded)); - assert_eq!(PgMode::parse("invalid"), None); - } - - #[test] - fn test_pg_version_info_roundtrip() { - let info = PgVersionInfo { - pg_major: 17, - pg_version: "17.4".to_string(), - provisioned_at: chrono::Utc::now(), - source: "managed".to_string(), - }; - let json = serde_json::to_string(&info).unwrap(); - let parsed: PgVersionInfo = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.pg_major, 17); - assert_eq!(parsed.pg_version, "17.4"); - assert_eq!(parsed.source, "managed"); - } -} diff --git a/crates/stackarr-postgres/src/error.rs b/crates/stackarr-postgres/src/error.rs deleted file mode 100644 index 1934c769..00000000 --- a/crates/stackarr-postgres/src/error.rs +++ /dev/null @@ -1,86 +0,0 @@ -/// PostgreSQL management errors. -#[derive(Debug, thiserror::Error)] -pub enum PostgresError { - #[error("postgres provisioning error: {0}")] - Provision(String), - #[error("initdb failed: {0}")] - InitDb(String), - #[error("postgres failed to start: {0}")] - Start(String), - #[error("postgres health check failed after {0}s")] - HealthTimeout(u64), - #[error("postgres shutdown failed: {0}")] - Shutdown(String), - #[error("postgres version mismatch: {0}")] - VersionMismatch(String), - #[error("io error: {0}")] - Io(#[from] std::io::Error), -} - -pub type PostgresResult = Result; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_error_display_provision() { - let err = PostgresError::Provision("download failed".to_string()); - assert_eq!( - err.to_string(), - "postgres provisioning error: download failed" - ); - } - - #[test] - fn test_error_display_initdb() { - let err = PostgresError::InitDb("locale error".to_string()); - assert_eq!(err.to_string(), "initdb failed: locale error"); - } - - #[test] - fn test_error_display_start() { - let err = PostgresError::Start("port in use".to_string()); - assert_eq!(err.to_string(), "postgres failed to start: port in use"); - } - - #[test] - fn test_error_display_health_timeout() { - let err = PostgresError::HealthTimeout(30); - assert_eq!(err.to_string(), "postgres health check failed after 30s"); - } - - #[test] - fn test_error_display_shutdown() { - let err = PostgresError::Shutdown("pg_ctl failed".to_string()); - assert_eq!(err.to_string(), "postgres shutdown failed: pg_ctl failed"); - } - - #[test] - fn test_error_display_version_mismatch() { - let err = PostgresError::VersionMismatch("data is v16, binary is v17".to_string()); - assert_eq!( - err.to_string(), - "postgres version mismatch: data is v16, binary is v17" - ); - } - - #[test] - fn test_error_from_io() { - let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing"); - let err: PostgresError = io_err.into(); - match err { - PostgresError::Io(_) => {} - other => panic!("expected Io variant, got: {other:?}"), - } - } - - #[test] - fn test_postgres_result_type() { - let ok: PostgresResult = Ok(42); - assert_eq!(ok.unwrap(), 42); - - let err: PostgresResult = Err(PostgresError::Provision("x".into())); - assert!(err.is_err()); - } -} diff --git a/crates/stackarr-postgres/src/lib.rs b/crates/stackarr-postgres/src/lib.rs deleted file mode 100644 index 557e9d0b..00000000 --- a/crates/stackarr-postgres/src/lib.rs +++ /dev/null @@ -1,44 +0,0 @@ -//! Managed PostgreSQL for StackArr. -//! -//! This crate provides automatic provisioning and lifecycle management of a -//! PostgreSQL instance. It supports three modes: -//! -//! - **External**: User provides their own PostgreSQL (existing behavior). -//! - **Managed**: PostgreSQL binaries are downloaded on first run and managed -//! as a child process. -//! - **Embedded**: PostgreSQL binaries are baked into the binary at compile time -//! (requires the `embed` feature flag). - -pub mod config; -pub mod error; -pub mod lifecycle; -pub mod provision; - -pub use config::{PgMode, PgPaths}; -pub use error::PostgresError; -pub use lifecycle::PostgresManager; -pub use provision::ensure_postgres; - -use std::path::Path; - -use error::PostgresResult; - -/// Provision PostgreSQL binaries and start a managed instance. -/// -/// This is the main entry point for managed/embedded modes. It: -/// 1. Ensures PostgreSQL binaries are available (download or extract). -/// 2. Initializes the data directory if needed. -/// 3. Starts PostgreSQL as a child process. -/// 4. Returns the manager handle and connection URL. -/// -/// The caller must call `manager.stop()` before exiting, or the `Drop` impl -/// will attempt a best-effort synchronous shutdown. -pub async fn start_managed_postgres( - data_dir: &Path, - port: u16, -) -> PostgresResult<(PostgresManager, String)> { - let paths = ensure_postgres(data_dir).await?; - let mut manager = PostgresManager::new(paths, data_dir, port); - let url = manager.start().await?; - Ok((manager, url)) -} diff --git a/crates/stackarr-postgres/src/lifecycle.rs b/crates/stackarr-postgres/src/lifecycle.rs deleted file mode 100644 index 89820d83..00000000 --- a/crates/stackarr-postgres/src/lifecycle.rs +++ /dev/null @@ -1,574 +0,0 @@ -use std::path::{Path, PathBuf}; - -use crate::config::PgPaths; -use crate::error::{PostgresError, PostgresResult}; - -/// Manages a PostgreSQL instance as a child process. -/// -/// Handles the full lifecycle: initdb, configuration, startup, health checking, -/// and graceful shutdown. The postgres process runs as a direct child (not a daemon) -/// so Rust owns its lifetime. -pub struct PostgresManager { - paths: PgPaths, - pgdata: PathBuf, - port: u16, - pg_user: String, - pg_database: String, - child: Option, -} - -impl PostgresManager { - /// Create a new manager. Does not start PostgreSQL yet. - pub fn new(paths: PgPaths, data_dir: &Path, port: u16) -> Self { - Self { - paths, - pgdata: data_dir.join("postgres").join("data"), - port, - pg_user: "stackarr".to_string(), - pg_database: "stackarr".to_string(), - child: None, - } - } - - /// Full startup sequence: crash recovery → initdb → configure → start → health check → create db. - /// Returns the connection URL on success. - pub async fn start(&mut self) -> PostgresResult { - // 1. Handle stale postmaster.pid (crash recovery) - self.recover_from_crash().await?; - - // 2. Check version compatibility - self.check_version().await?; - - // 3. Initialize data directory if needed (first run) - let first_run = !self.pgdata.join("PG_VERSION").exists(); - if first_run { - self.init_db().await?; - } - - // 4. Write configuration files - self.write_config().await?; - - // 5. Start postgres as a child process - self.spawn_postgres().await?; - - // 6. Wait for postgres to accept connections - self.wait_for_ready().await?; - - // 7. Create database if first run - if first_run { - self.create_database().await?; - } - - let url = self.connection_url(); - tracing::info!(%url, port = self.port, "managed PostgreSQL is ready"); - Ok(url) - } - - /// Graceful shutdown via pg_ctl stop. - pub async fn stop(&mut self) -> PostgresResult<()> { - if self.child.is_none() { - return Ok(()); - } - - tracing::info!("stopping managed PostgreSQL"); - - let status = tokio::process::Command::new(self.pg_ctl_path()) - .args(["stop", "-D"]) - .arg(&self.pgdata) - .args(["-m", "fast", "-w", "-t", "30"]) - .envs(self.pg_env()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::piped()) - .status() - .await - .map_err(|e| PostgresError::Shutdown(format!("failed to run pg_ctl stop: {e}")))?; - - // Wait for child process to exit - if let Some(ref mut child) = self.child { - let _ = child.wait().await; - } - self.child = None; - - if status.success() { - tracing::info!("managed PostgreSQL stopped"); - Ok(()) - } else { - Err(PostgresError::Shutdown(format!( - "pg_ctl stop exited with code {}", - status.code().unwrap_or(-1) - ))) - } - } - - /// Check if PostgreSQL is accepting connections. - pub async fn health_check(&self) -> bool { - let result = tokio::process::Command::new(self.bin_path("pg_isready")) - .args(["-h", "127.0.0.1", "-p", &self.port.to_string()]) - .envs(self.pg_env()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .await; - - matches!(result, Ok(status) if status.success()) - } - - /// Returns the connection URL for this managed instance. - pub fn connection_url(&self) -> String { - format!( - "postgresql://{}@127.0.0.1:{}/{}", - self.pg_user, self.port, self.pg_database - ) - } - - // --- Private methods --- - - /// Detect and clean up stale postmaster.pid from unclean shutdown. - async fn recover_from_crash(&self) -> PostgresResult<()> { - let pid_file = self.pgdata.join("postmaster.pid"); - if !pid_file.exists() { - return Ok(()); - } - - let content = tokio::fs::read_to_string(&pid_file) - .await - .unwrap_or_default(); - - let pid: Option = content - .lines() - .next() - .and_then(|line| line.trim().parse().ok()); - - let process_alive = if let Some(pid) = pid { - is_process_alive(pid) - } else { - false - }; - - if process_alive { - // Postgres is actually running — try a clean stop - tracing::warn!(pid = ?pid, "found running PostgreSQL from previous session, stopping it"); - let _ = tokio::process::Command::new(self.pg_ctl_path()) - .args(["stop", "-D"]) - .arg(&self.pgdata) - .args(["-m", "fast", "-w", "-t", "10"]) - .envs(self.pg_env()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .await; - } else { - // Stale pid file — remove it - tracing::warn!("removing stale postmaster.pid (previous unclean shutdown)"); - let _ = tokio::fs::remove_file(&pid_file).await; - } - - Ok(()) - } - - /// Check that provisioned PG version matches the data directory version. - async fn check_version(&self) -> PostgresResult<()> { - let version_file = self - .pgdata - .parent() - .unwrap_or(&self.pgdata) - .join("version.json"); - let pg_version_file = self.pgdata.join("PG_VERSION"); - - // No data directory yet — nothing to check - if !pg_version_file.exists() { - return Ok(()); - } - - // Read the PG_VERSION from data directory (contains just the major version, e.g. "17") - let data_major: u32 = tokio::fs::read_to_string(&pg_version_file) - .await - .unwrap_or_default() - .trim() - .parse() - .unwrap_or(0); - - // Read the provisioned version - let provisioned_major = if version_file.exists() { - let content = tokio::fs::read_to_string(&version_file) - .await - .unwrap_or_default(); - serde_json::from_str::(&content) - .map(|v| v.pg_major) - .unwrap_or(0) - } else { - // No version.json but binaries exist — detect from binary - detect_binary_major(&self.paths.bin_dir).await - }; - - if data_major != 0 && provisioned_major != 0 && data_major != provisioned_major { - return Err(PostgresError::VersionMismatch(format!( - "data directory is PostgreSQL {data_major} but provisioned binaries are \ - PostgreSQL {provisioned_major}. Run pg_upgrade or re-provision with the \ - matching version." - ))); - } - - Ok(()) - } - - /// Initialize the PostgreSQL data directory. - async fn init_db(&self) -> PostgresResult<()> { - tracing::info!(pgdata = %self.pgdata.display(), "initializing PostgreSQL data directory"); - - tokio::fs::create_dir_all(&self.pgdata) - .await - .map_err(|e| PostgresError::InitDb(format!("failed to create PGDATA: {e}")))?; - - let output = tokio::process::Command::new(self.bin_path("initdb")) - .args(["-D"]) - .arg(&self.pgdata) - .args([ - "-U", - &self.pg_user, - "--auth=trust", - "--encoding=UTF-8", - "--locale=C", - "--no-instructions", - ]) - .envs(self.pg_env()) - .output() - .await - .map_err(|e| PostgresError::InitDb(format!("failed to run initdb: {e}")))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(PostgresError::InitDb(format!("initdb failed: {stderr}"))); - } - - tracing::info!("PostgreSQL data directory initialized"); - Ok(()) - } - - /// Write postgresql.conf and pg_hba.conf tuned for embedded use. - async fn write_config(&self) -> PostgresResult<()> { - // postgresql.conf — tuned for single-user embedded use - let postgresql_conf = format!( - r#"# StackArr managed PostgreSQL configuration -# DO NOT EDIT — regenerated on each startup - -listen_addresses = '127.0.0.1' -port = {port} -max_connections = 50 - -# Memory (conservative — shared with StackArr process) -shared_buffers = 128MB -work_mem = 4MB -maintenance_work_mem = 64MB -effective_cache_size = 256MB - -# WAL (minimal — no replication needed) -wal_level = minimal -max_wal_senders = 0 -max_wal_size = 256MB - -# Reliability -fsync = on -synchronous_commit = on - -# Logging (stderr only — captured by StackArr) -log_destination = 'stderr' -logging_collector = off -log_min_messages = warning -log_min_error_statement = error - -# Performance -random_page_cost = 1.1 -effective_io_concurrency = 200 -"#, - port = self.port, - ); - - // pg_hba.conf — trust auth for localhost only - let pg_hba_conf = "\ -# StackArr managed PostgreSQL HBA configuration -# Trust authentication for localhost only — no external access -local all all trust -host all all 127.0.0.1/32 trust -host all all ::1/128 trust -"; - - tokio::fs::write(self.pgdata.join("postgresql.conf"), postgresql_conf) - .await - .map_err(|e| PostgresError::Start(format!("failed to write postgresql.conf: {e}")))?; - - tokio::fs::write(self.pgdata.join("pg_hba.conf"), pg_hba_conf) - .await - .map_err(|e| PostgresError::Start(format!("failed to write pg_hba.conf: {e}")))?; - - Ok(()) - } - - /// Spawn the postgres process as a direct child. - async fn spawn_postgres(&mut self) -> PostgresResult<()> { - tracing::info!(port = self.port, "starting PostgreSQL"); - - let child = tokio::process::Command::new(self.bin_path("postgres")) - .arg("-D") - .arg(&self.pgdata) - .envs(self.pg_env()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::piped()) - .spawn() - .map_err(|e| PostgresError::Start(format!("failed to spawn postgres: {e}")))?; - - self.child = Some(child); - Ok(()) - } - - /// Wait for PostgreSQL to accept connections, with exponential backoff. - async fn wait_for_ready(&self) -> PostgresResult<()> { - let max_wait = std::time::Duration::from_secs(30); - let start = std::time::Instant::now(); - let mut interval = std::time::Duration::from_millis(100); - - while start.elapsed() < max_wait { - if self.health_check().await { - return Ok(()); - } - - // Check if child process has exited (crashed) - // We can't await on the child here without &mut, so just check health - tokio::time::sleep(interval).await; - interval = std::cmp::min(interval * 2, std::time::Duration::from_secs(2)); - } - - Err(PostgresError::HealthTimeout(max_wait.as_secs())) - } - - /// Create the stackarr database (first run only). - async fn create_database(&self) -> PostgresResult<()> { - tracing::info!(database = %self.pg_database, "creating database"); - - // Use psql to create the database (connecting as the superuser created by initdb) - let output = tokio::process::Command::new(self.bin_path("psql")) - .args([ - "-h", - "127.0.0.1", - "-p", - &self.port.to_string(), - "-U", - &self.pg_user, - "-d", - "postgres", - "-c", - &format!("CREATE DATABASE {} ENCODING 'UTF8';", self.pg_database), - ]) - .envs(self.pg_env()) - .output() - .await - .map_err(|e| PostgresError::Start(format!("failed to run psql: {e}")))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - // Ignore "already exists" — this is fine - if !stderr.contains("already exists") { - return Err(PostgresError::Start(format!( - "failed to create database: {stderr}" - ))); - } - } - - Ok(()) - } - - /// Build the path to a PostgreSQL binary. - fn bin_path(&self, name: &str) -> PathBuf { - let bin_name = if cfg!(target_os = "windows") { - format!("{name}.exe") - } else { - name.to_string() - }; - self.paths.bin_dir.join(bin_name) - } - - /// Path to pg_ctl binary. - fn pg_ctl_path(&self) -> PathBuf { - self.bin_path("pg_ctl") - } - - /// Environment variables needed for portable PostgreSQL. - fn pg_env(&self) -> Vec<(String, String)> { - let mut env = vec![( - "PGDATA".to_string(), - self.pgdata.to_string_lossy().to_string(), - )]; - - // LD_LIBRARY_PATH for Linux portable builds - #[cfg(target_os = "linux")] - { - env.push(( - "LD_LIBRARY_PATH".to_string(), - self.paths.lib_dir.to_string_lossy().to_string(), - )); - } - - // DYLD_LIBRARY_PATH for macOS - #[cfg(target_os = "macos")] - { - env.push(( - "DYLD_LIBRARY_PATH".to_string(), - self.paths.lib_dir.to_string_lossy().to_string(), - )); - } - - env - } -} - -/// Drop safety: best-effort synchronous shutdown if the manager is dropped -/// without calling stop() first. -impl Drop for PostgresManager { - fn drop(&mut self) { - if self.child.is_some() { - tracing::warn!( - "PostgresManager dropped without stop() — attempting synchronous shutdown" - ); - let pg_ctl = self.pg_ctl_path(); - let pgdata = self.pgdata.clone(); - let lib_dir = self.paths.lib_dir.clone(); - - let mut cmd = std::process::Command::new(pg_ctl); - cmd.args(["stop", "-D"]).arg(&pgdata).args(["-m", "fast"]); - - #[cfg(target_os = "linux")] - { - cmd.env("LD_LIBRARY_PATH", &lib_dir); - } - #[cfg(target_os = "macos")] - { - cmd.env("DYLD_LIBRARY_PATH", &lib_dir); - } - - cmd.env("PGDATA", &pgdata); - - let _ = cmd.status(); - } - } -} - -/// Check if a process with the given PID is alive. -/// Uses /proc on Linux, kill(0) on other Unix, tasklist on Windows. -fn is_process_alive(pid: u32) -> bool { - #[cfg(target_os = "linux")] - { - std::path::Path::new(&format!("/proc/{pid}")).exists() - } - #[cfg(all(unix, not(target_os = "linux")))] - { - // kill(pid, 0) checks existence without sending a signal. - // Safety: signal 0 has no side effects. - unsafe { libc::kill(pid as libc::pid_t, 0) == 0 } - } - #[cfg(windows)] - { - // Use tasklist to check if PID exists (no extra crate dependency) - std::process::Command::new("tasklist") - .args(["/FI", &format!("PID eq {pid}"), "/NH"]) - .output() - .map(|o| String::from_utf8_lossy(&o.stdout).contains(&pid.to_string())) - .unwrap_or(false) - } - #[cfg(not(any(unix, windows)))] - { - let _ = pid; - false - } -} - -/// Detect the PostgreSQL major version from the postgres binary. -async fn detect_binary_major(bin_dir: &Path) -> u32 { - let postgres = if cfg!(target_os = "windows") { - bin_dir.join("postgres.exe") - } else { - bin_dir.join("postgres") - }; - - let output = tokio::process::Command::new(&postgres) - .arg("--version") - .output() - .await; - - match output { - Ok(out) if out.status.success() => { - let version_line = String::from_utf8_lossy(&out.stdout); - // e.g. "postgres (PostgreSQL) 17.4" — extract "17" - version_line - .split_whitespace() - .last() - .and_then(|v| v.split('.').next()) - .and_then(|v| v.parse().ok()) - .unwrap_or(0) - } - _ => 0, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::path::PathBuf; - - fn test_paths() -> PgPaths { - PgPaths { - bin_dir: PathBuf::from("/tmp/test-pg/bin"), - lib_dir: PathBuf::from("/tmp/test-pg/lib"), - share_dir: PathBuf::from("/tmp/test-pg/share"), - } - } - - #[test] - fn test_connection_url() { - let mgr = PostgresManager::new(test_paths(), Path::new("/tmp/test-data"), 5433); - assert_eq!( - mgr.connection_url(), - "postgresql://stackarr@127.0.0.1:5433/stackarr" - ); - } - - #[test] - fn test_connection_url_custom_port() { - let mgr = PostgresManager::new(test_paths(), Path::new("/tmp/test-data"), 5500); - assert_eq!( - mgr.connection_url(), - "postgresql://stackarr@127.0.0.1:5500/stackarr" - ); - } - - #[test] - fn test_bin_path() { - let mgr = PostgresManager::new(test_paths(), Path::new("/tmp/test-data"), 5433); - let path = mgr.bin_path("pg_ctl"); - if cfg!(target_os = "windows") { - assert!(path.to_string_lossy().ends_with("pg_ctl.exe")); - } else { - assert!(path.to_string_lossy().ends_with("pg_ctl")); - } - } - - #[test] - fn test_pg_env_includes_pgdata() { - let mgr = PostgresManager::new(test_paths(), Path::new("/tmp/test-data"), 5433); - let env = mgr.pg_env(); - assert!(env.iter().any(|(k, _)| k == "PGDATA")); - } - - #[cfg(unix)] - #[test] - fn test_is_process_alive_self() { - // Our own process should be alive - let pid = std::process::id(); - assert!(is_process_alive(pid)); - } - - #[test] - fn test_is_process_alive_nonexistent() { - // PID 999999999 should not exist - assert!(!is_process_alive(999_999_999)); - } -} diff --git a/crates/stackarr-postgres/src/provision.rs b/crates/stackarr-postgres/src/provision.rs deleted file mode 100644 index 5aa12e0b..00000000 --- a/crates/stackarr-postgres/src/provision.rs +++ /dev/null @@ -1,441 +0,0 @@ -use std::path::{Path, PathBuf}; - -use crate::config::{PgPaths, PgVersionInfo}; -use crate::error::{PostgresError, PostgresResult}; - -/// PostgreSQL major version to provision. -const PG_MAJOR: u32 = 17; - -/// Ensure PostgreSQL binaries are available, downloading if necessary. -/// -/// Strategy: -/// 1. Check well-known system paths (package manager installs). -/// 2. Check `{data_dir}/postgres/bin/` for previously provisioned binaries. -/// 3. Download portable PostgreSQL build to `{data_dir}/postgres/`. -/// 4. (If `embed` feature) Extract from embedded archive instead of downloading. -pub async fn ensure_postgres(data_dir: &Path) -> PostgresResult { - // Step 1: Check well-known system paths - for base in well_known_paths() { - let paths = PgPaths { - bin_dir: base.join("bin"), - lib_dir: base.join("lib"), - share_dir: base.join("share"), - }; - if is_pg_executable(&paths.bin_dir).await { - tracing::info!(path = %base.display(), "found system PostgreSQL"); - return Ok(paths); - } - } - - // Step 2: Check data_dir/postgres/ - let pg_dir = data_dir.join("postgres"); - let local_paths = PgPaths { - bin_dir: pg_dir.join("bin"), - lib_dir: pg_dir.join("lib"), - share_dir: pg_dir.join("share"), - }; - - if is_pg_executable(&local_paths.bin_dir).await { - tracing::info!(path = %pg_dir.display(), "using previously provisioned PostgreSQL"); - return Ok(local_paths); - } - - // Step 3/4: Provision (download or extract embedded) - tracing::info!("PostgreSQL not found — provisioning"); - tokio::fs::create_dir_all(&pg_dir).await.map_err(|e| { - PostgresError::Provision(format!("failed to create {}: {e}", pg_dir.display())) - })?; - - #[cfg(feature = "embed")] - { - extract_embedded(&pg_dir).await?; - } - #[cfg(not(feature = "embed"))] - { - let url = download_url()?; - download_and_extract(url, &pg_dir).await?; - } - - // Verify the provisioned binaries work - if !is_pg_executable(&local_paths.bin_dir).await { - return Err(PostgresError::Provision( - "provisioned PostgreSQL binaries are not executable".into(), - )); - } - - // Write version metadata - let version_str = detect_pg_version(&local_paths.bin_dir).await; - let version_info = PgVersionInfo { - pg_major: PG_MAJOR, - pg_version: version_str, - provisioned_at: chrono::Utc::now(), - #[cfg(feature = "embed")] - source: "embedded".to_string(), - #[cfg(not(feature = "embed"))] - source: "managed".to_string(), - }; - let version_json = serde_json::to_string_pretty(&version_info) - .map_err(|e| PostgresError::Provision(format!("failed to serialize version info: {e}")))?; - tokio::fs::write(pg_dir.join("version.json"), version_json) - .await - .map_err(|e| PostgresError::Provision(format!("failed to write version.json: {e}")))?; - - tracing::info!(path = %pg_dir.display(), "PostgreSQL provisioned successfully"); - Ok(local_paths) -} - -/// Check if a pg_isready binary exists and is executable in the given bin directory. -async fn is_pg_executable(bin_dir: &Path) -> bool { - let pg_isready = bin_dir.join(pg_binary_name("pg_isready")); - if !pg_isready.exists() { - return false; - } - - let result = tokio::time::timeout( - std::time::Duration::from_secs(5), - tokio::process::Command::new(&pg_isready) - .arg("--version") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status(), - ) - .await; - - matches!(result, Ok(Ok(status)) if status.success()) -} - -/// Detect the PostgreSQL version from the postgres binary. -async fn detect_pg_version(bin_dir: &Path) -> String { - let postgres = bin_dir.join(pg_binary_name("postgres")); - let output = tokio::process::Command::new(&postgres) - .arg("--version") - .output() - .await; - - match output { - Ok(out) if out.status.success() => { - let version_line = String::from_utf8_lossy(&out.stdout); - // e.g. "postgres (PostgreSQL) 17.4" - version_line - .split_whitespace() - .last() - .unwrap_or("unknown") - .to_string() - } - _ => "unknown".to_string(), - } -} - -/// Platform-specific binary name (appends .exe on Windows). -fn pg_binary_name(name: &str) -> String { - if cfg!(target_os = "windows") { - format!("{name}.exe") - } else { - name.to_string() - } -} - -/// Well-known system paths where PostgreSQL might be installed. -fn well_known_paths() -> Vec { - let mut paths = Vec::new(); - - #[cfg(target_os = "linux")] - { - // Common Linux package manager locations - paths.push(PathBuf::from("/usr/lib/postgresql/17")); - paths.push(PathBuf::from("/usr/lib/postgresql/16")); - paths.push(PathBuf::from("/usr/pgsql-17")); // RHEL/CentOS - paths.push(PathBuf::from("/usr/pgsql-16")); - } - - #[cfg(target_os = "macos")] - { - // Homebrew (ARM and Intel) - paths.push(PathBuf::from("/opt/homebrew/opt/postgresql@17")); - paths.push(PathBuf::from("/usr/local/opt/postgresql@17")); - paths.push(PathBuf::from("/opt/homebrew/opt/postgresql@16")); - paths.push(PathBuf::from("/usr/local/opt/postgresql@16")); - // Postgres.app - paths.push(PathBuf::from( - "/Applications/Postgres.app/Contents/Versions/17", - )); - paths.push(PathBuf::from( - "/Applications/Postgres.app/Contents/Versions/16", - )); - } - - #[cfg(target_os = "windows")] - { - paths.push(PathBuf::from("C:\\Program Files\\PostgreSQL\\17")); - paths.push(PathBuf::from("C:\\Program Files\\PostgreSQL\\16")); - } - - paths -} - -/// Platform-specific download URL for portable PostgreSQL builds. -/// Uses EDB (EnterpriseDB) portable builds which are relocatable. -#[cfg(any(test, not(feature = "embed")))] -fn download_url() -> PostgresResult<&'static str> { - #[cfg(all(target_os = "linux", target_arch = "x86_64"))] - { - Ok("https://get.enterprisedb.com/postgresql/postgresql-17.4-1-linux-x64-binaries.tar.gz") - } - - #[cfg(all(target_os = "linux", target_arch = "aarch64"))] - { - Ok("https://get.enterprisedb.com/postgresql/postgresql-17.4-1-linux-arm64-binaries.tar.gz") - } - - #[cfg(all(target_os = "macos", target_arch = "x86_64"))] - { - Ok("https://get.enterprisedb.com/postgresql/postgresql-17.4-1-osx-x64-binaries.tar.gz") - } - - #[cfg(all(target_os = "macos", target_arch = "aarch64"))] - { - Ok("https://get.enterprisedb.com/postgresql/postgresql-17.4-1-osx-arm64-binaries.tar.gz") - } - - #[cfg(all(target_os = "windows", target_arch = "x86_64"))] - { - Ok("https://get.enterprisedb.com/postgresql/postgresql-17.4-1-windows-x64-binaries.zip") - } - - #[cfg(not(any( - all(target_os = "linux", target_arch = "x86_64"), - all(target_os = "linux", target_arch = "aarch64"), - all(target_os = "macos", target_arch = "x86_64"), - all(target_os = "macos", target_arch = "aarch64"), - all(target_os = "windows", target_arch = "x86_64"), - )))] - { - Err(PostgresError::Provision( - "automatic PostgreSQL download is not supported on this platform — \ - please install PostgreSQL manually and use database.mode = \"external\"" - .into(), - )) - } -} - -/// Download the archive from `url` and extract PostgreSQL binaries into `target_dir`. -#[cfg(not(feature = "embed"))] -async fn download_and_extract(url: &str, target_dir: &Path) -> PostgresResult<()> { - let archive_path = target_dir.join("pg-download.tmp"); - - // Stream download to temp file - tracing::info!(%url, "downloading PostgreSQL"); - let response = reqwest::get(url) - .await - .map_err(|e| PostgresError::Provision(format!("download failed: {e}")))?; - - if !response.status().is_success() { - return Err(PostgresError::Provision(format!( - "download returned HTTP {}", - response.status() - ))); - } - - let bytes = response - .bytes() - .await - .map_err(|e| PostgresError::Provision(format!("failed to read download: {e}")))?; - - let size_mb = bytes.len() / (1024 * 1024); - tracing::info!(size_mb, "download complete, extracting"); - - tokio::fs::write(&archive_path, &bytes) - .await - .map_err(|e| PostgresError::Provision(format!("failed to write archive: {e}")))?; - - // Extract platform-specific - if cfg!(target_os = "windows") { - extract_zip(&archive_path, target_dir).await?; - } else { - extract_tar_gz(&archive_path, target_dir).await?; - } - - // Clean up archive - let _ = tokio::fs::remove_file(&archive_path).await; - - Ok(()) -} - -/// Extract a tar.gz archive, pulling out the pgsql/ directory contents to target_dir. -/// EDB portable builds have structure: pgsql/bin/, pgsql/lib/, pgsql/share/, etc. -async fn extract_tar_gz(archive: &Path, target_dir: &Path) -> PostgresResult<()> { - let archive_str = archive.to_string_lossy(); - - // EDB portable builds extract to pgsql/ — strip that prefix - let status = tokio::process::Command::new("tar") - .args(["xzf", &archive_str]) - .arg("--strip-components=1") - .arg("-C") - .arg(target_dir) - .status() - .await - .map_err(|e| PostgresError::Provision(format!("failed to run tar: {e}")))?; - - if !status.success() { - return Err(PostgresError::Provision(format!( - "tar extraction failed with exit code: {}", - status.code().unwrap_or(-1) - ))); - } - - // Ensure execute permissions on key binaries - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let bin_dir = target_dir.join("bin"); - if let Ok(mut entries) = tokio::fs::read_dir(&bin_dir).await { - while let Ok(Some(entry)) = entries.next_entry().await { - let path = entry.path(); - if path.is_file() { - let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)); - } - } - } - } - - Ok(()) -} - -/// Extract a zip archive on Windows. -async fn extract_zip(archive: &Path, target_dir: &Path) -> PostgresResult<()> { - let extract_tmp = target_dir.join("_extract_tmp"); - - let status = tokio::process::Command::new("powershell") - .args([ - "-NoProfile", - "-Command", - &format!( - "Expand-Archive -Path '{}' -DestinationPath '{}' -Force", - archive.to_string_lossy(), - extract_tmp.to_string_lossy() - ), - ]) - .status() - .await - .map_err(|e| PostgresError::Provision(format!("failed to run powershell: {e}")))?; - - if !status.success() { - return Err(PostgresError::Provision("zip extraction failed".into())); - } - - // EDB zips extract to pgsql/ — move contents up - let pgsql_dir = extract_tmp.join("pgsql"); - let source = if pgsql_dir.exists() { - pgsql_dir - } else { - extract_tmp.clone() - }; - - // Move bin/, lib/, share/ to target_dir - for dir_name in ["bin", "lib", "share", "include"] { - let src = source.join(dir_name); - let dst = target_dir.join(dir_name); - if src.exists() { - if dst.exists() { - let _ = tokio::fs::remove_dir_all(&dst).await; - } - tokio::fs::rename(&src, &dst) - .await - .or_else(|_| { - // Cross-device move: fall back to copy - std::fs::rename(&src, &dst) - }) - .map_err(|e| PostgresError::Provision(format!("failed to move {dir_name}: {e}")))?; - } - } - - // Clean up extraction temp - let _ = tokio::fs::remove_dir_all(&extract_tmp).await; - - Ok(()) -} - -/// Extract PostgreSQL from embedded archive (requires `embed` feature). -#[cfg(feature = "embed")] -async fn extract_embedded(target_dir: &Path) -> PostgresResult<()> { - use rust_embed::Embed; - - #[derive(Embed)] - #[folder = "pg-binaries/"] - struct PgBinaries; - - // Find the embedded archive file - let archive_name = PgBinaries::iter() - .find(|name| name.ends_with(".tar.gz") || name.ends_with(".zip")) - .ok_or_else(|| { - PostgresError::Provision( - "no embedded PostgreSQL archive found — rebuild with pg-binaries/ populated".into(), - ) - })?; - - let archive_data = PgBinaries::get(&archive_name) - .ok_or_else(|| PostgresError::Provision("failed to read embedded archive".into()))?; - - let archive_path = target_dir.join("pg-embedded.tmp"); - tokio::fs::write(&archive_path, &archive_data.data) - .await - .map_err(|e| PostgresError::Provision(format!("failed to write embedded archive: {e}")))?; - - if cfg!(target_os = "windows") { - extract_zip(&archive_path, target_dir).await?; - } else { - extract_tar_gz(&archive_path, target_dir).await?; - } - - let _ = tokio::fs::remove_file(&archive_path).await; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_pg_binary_name_unix() { - if cfg!(unix) { - assert_eq!(pg_binary_name("pg_isready"), "pg_isready"); - assert_eq!(pg_binary_name("postgres"), "postgres"); - } - } - - #[test] - fn test_pg_binary_name_windows() { - if cfg!(target_os = "windows") { - assert_eq!(pg_binary_name("pg_isready"), "pg_isready.exe"); - assert_eq!(pg_binary_name("postgres"), "postgres.exe"); - } - } - - #[test] - fn test_well_known_paths_not_empty() { - let paths = well_known_paths(); - // Should have at least some paths on any supported platform - if cfg!(any( - target_os = "linux", - target_os = "macos", - target_os = "windows", - )) { - assert!(!paths.is_empty()); - } - } - - #[test] - fn test_download_url_returns_ok() { - if cfg!(any( - all(target_os = "linux", target_arch = "x86_64"), - all(target_os = "linux", target_arch = "aarch64"), - all(target_os = "macos", target_arch = "x86_64"), - all(target_os = "macos", target_arch = "aarch64"), - all(target_os = "windows", target_arch = "x86_64"), - )) { - let url = download_url().unwrap(); - assert!(url.starts_with("https://")); - assert!(url.contains("postgresql")); - } - } -} diff --git a/crates/stackarr-quality/src/lib.rs b/crates/stackarr-quality/src/lib.rs index d68f383b..7ffdf674 100644 --- a/crates/stackarr-quality/src/lib.rs +++ b/crates/stackarr-quality/src/lib.rs @@ -2,7 +2,7 @@ pub mod custom_formats; use anyhow::Result; use serde::{Deserialize, Serialize}; -use sqlx::PgPool; +use sqlx::MySqlPool; use stackarr_core::models::{CustomFormat, DownloadProtocol, QualityProfile, ReleaseInfo}; @@ -10,7 +10,7 @@ use stackarr_core::models::{CustomFormat, DownloadProtocol, QualityProfile, Rele #[derive(Clone)] pub struct QualityProfileService { - pool: PgPool, + pool: MySqlPool, } #[derive(Debug, Clone, Deserialize)] @@ -91,7 +91,7 @@ pub struct QualityProfileResponse { #[derive(Clone)] pub struct CustomFormatService { - pool: PgPool, + pool: MySqlPool, } #[derive(Debug, Clone, Deserialize)] @@ -112,7 +112,7 @@ pub struct UpdateCustomFormatInput { } impl CustomFormatService { - pub fn new(pool: PgPool) -> Self { + pub fn new(pool: MySqlPool) -> Self { Self { pool } } @@ -124,7 +124,7 @@ impl CustomFormatService { } pub async fn get(&self, id: i64) -> Result { - let row = sqlx::query_as::<_, CustomFormat>("SELECT * FROM custom_formats WHERE id = $1") + let row = sqlx::query_as::<_, CustomFormat>("SELECT * FROM custom_formats WHERE id = ?") .bind(id) .fetch_one(&self.pool) .await?; @@ -132,15 +132,16 @@ impl CustomFormatService { } pub async fn create(&self, input: CreateCustomFormatInput) -> Result { - let row = sqlx::query_as::<_, CustomFormat>( + let result = sqlx::query( "INSERT INTO custom_formats (name, specifications, include_custom_format_when_renaming) - VALUES ($1, $2, $3) RETURNING *", + VALUES (?, ?, ?)", ) .bind(&input.name) .bind(&input.specifications) .bind(input.include_custom_format_when_renaming) - .fetch_one(&self.pool) + .execute(&self.pool) .await?; + let row = self.get(result.last_insert_id() as i64).await?; tracing::info!(id = row.id, name = %row.name, "custom format created"); Ok(row) } @@ -153,23 +154,24 @@ impl CustomFormatService { .include_custom_format_when_renaming .unwrap_or(existing.include_custom_format_when_renaming); - let row = sqlx::query_as::<_, CustomFormat>( - "UPDATE custom_formats SET name=$1, specifications=$2, include_custom_format_when_renaming=$3 - WHERE id=$4 RETURNING *", + sqlx::query( + "UPDATE custom_formats SET name=?, specifications=?, include_custom_format_when_renaming=? + WHERE id=?", ) .bind(&name) .bind(&specs) .bind(rename) .bind(id) - .fetch_one(&self.pool) + .execute(&self.pool) .await?; + let row = self.get(id).await?; tracing::debug!(id, name = %row.name, "custom format updated"); Ok(row) } pub async fn delete(&self, id: i64) -> Result<()> { tracing::info!(id, "deleting custom format"); - sqlx::query("DELETE FROM custom_formats WHERE id = $1") + sqlx::query("DELETE FROM custom_formats WHERE id = ?") .bind(id) .execute(&self.pool) .await?; @@ -178,7 +180,7 @@ impl CustomFormatService { } impl QualityProfileService { - pub fn new(pool: PgPool) -> Self { + pub fn new(pool: MySqlPool) -> Self { Self { pool } } @@ -233,7 +235,7 @@ impl QualityProfileService { pub async fn get(&self, id: i64) -> Result { let mut profile = - sqlx::query_as::<_, QualityProfile>("SELECT * FROM quality_profiles WHERE id = $1") + sqlx::query_as::<_, QualityProfile>("SELECT * FROM quality_profiles WHERE id = ?") .bind(id) .fetch_one(&self.pool) .await?; @@ -249,7 +251,7 @@ impl QualityProfileService { sqlx::query_as::<_, (i32, String, i32)>( "SELECT cf.id, cf.name, COALESCE(cfs.score, 0) as score FROM custom_formats cf - LEFT JOIN custom_format_scores cfs ON cfs.format_id = cf.id AND cfs.profile_id = $1 + LEFT JOIN custom_format_scores cfs ON cfs.format_id = cf.id AND cfs.profile_id = ? ORDER BY cf.name", ) .bind(profile_id) @@ -270,14 +272,14 @@ impl QualityProfileService { profile_id: i32, items: &[ProfileFormatItemInput], ) -> Result<()> { - sqlx::query("DELETE FROM custom_format_scores WHERE profile_id = $1") + sqlx::query("DELETE FROM custom_format_scores WHERE profile_id = ?") .bind(profile_id) .execute(&self.pool) .await?; for item in items { if item.score != 0 { sqlx::query( - "INSERT INTO custom_format_scores (profile_id, format_id, score) VALUES ($1, $2, $3)", + "INSERT INTO custom_format_scores (profile_id, format_id, score) VALUES (?, ?, ?)", ) .bind(profile_id) .bind(item.format) @@ -290,9 +292,9 @@ impl QualityProfileService { } pub async fn create(&self, input: CreateProfileInput) -> Result { - let mut profile = sqlx::query_as::<_, QualityProfile>( + let result = sqlx::query( "INSERT INTO quality_profiles (name, cutoff, upgrade_allowed, min_format_score, cutoff_format_score, items, media_type, language, min_upgrade_format_score) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *", + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&input.name) .bind(input.cutoff) @@ -303,8 +305,9 @@ impl QualityProfileService { .bind(&input.media_type) .bind(input.language) .bind(input.min_upgrade_format_score) - .fetch_one(&self.pool) + .execute(&self.pool) .await?; + let mut profile = self.get_raw(result.last_insert_id() as i64).await?; profile.normalize_items(); tracing::info!(id = profile.id, name = %profile.name, "quality profile created"); @@ -342,9 +345,9 @@ impl QualityProfileService { .min_upgrade_format_score .unwrap_or(existing.min_upgrade_format_score); - let mut profile = sqlx::query_as::<_, QualityProfile>( - "UPDATE quality_profiles SET name=$1, cutoff=$2, upgrade_allowed=$3, min_format_score=$4, cutoff_format_score=$5, items=$6, media_type=$7, language=$8, min_upgrade_format_score=$9 - WHERE id=$10 RETURNING *", + sqlx::query( + "UPDATE quality_profiles SET name=?, cutoff=?, upgrade_allowed=?, min_format_score=?, cutoff_format_score=?, items=?, media_type=?, language=?, min_upgrade_format_score=? + WHERE id=?", ) .bind(&name) .bind(cutoff) @@ -356,8 +359,9 @@ impl QualityProfileService { .bind(language) .bind(min_upgrade_fs) .bind(id) - .fetch_one(&self.pool) + .execute(&self.pool) .await?; + let mut profile = self.get_raw(id).await?; profile.normalize_items(); tracing::debug!(id, name = %profile.name, "quality profile updated"); @@ -374,7 +378,7 @@ impl QualityProfileService { /// Internal: get raw profile without format items (for update merging). async fn get_raw(&self, id: i64) -> Result { let mut row = - sqlx::query_as::<_, QualityProfile>("SELECT * FROM quality_profiles WHERE id = $1") + sqlx::query_as::<_, QualityProfile>("SELECT * FROM quality_profiles WHERE id = ?") .bind(id) .fetch_one(&self.pool) .await?; @@ -384,7 +388,7 @@ impl QualityProfileService { pub async fn delete(&self, id: i64) -> Result<()> { tracing::info!(id, "deleting quality profile"); - sqlx::query("DELETE FROM quality_profiles WHERE id = $1") + sqlx::query("DELETE FROM quality_profiles WHERE id = ?") .bind(id) .execute(&self.pool) .await?; diff --git a/crates/stackarr-scheduler/src/auto_search.rs b/crates/stackarr-scheduler/src/auto_search.rs index edf06649..9c660289 100644 --- a/crates/stackarr-scheduler/src/auto_search.rs +++ b/crates/stackarr-scheduler/src/auto_search.rs @@ -8,7 +8,7 @@ use std::collections::HashSet; use std::sync::Arc; use anyhow::Result; -use sqlx::PgPool; +use sqlx::MySqlPool; use tokio::sync::RwLock; use stackarr_core::models::{DownloadProtocol, QualityProfile, ReleaseInfo}; @@ -46,7 +46,7 @@ pub struct AutoSearchStats { /// releases were found, or `Err` on failure. #[allow(clippy::too_many_arguments)] pub async fn search_and_grab( - pool: &PgPool, + pool: &MySqlPool, indexer_manager: &Arc>, download_manager: &Arc>, query_term: &str, @@ -73,7 +73,7 @@ pub async fn search_and_grab( // scheduler sync will either progress it or move it to history on its own. let already_queued: Option<(i64,)> = if is_movie { sqlx::query_as( - "SELECT id FROM queue WHERE media_type = 'movie' AND media_id = $1 \ + "SELECT id FROM queue WHERE media_type = 'movie' AND media_id = ? \ AND status != 'completed' LIMIT 1", ) .bind(media_id) @@ -81,7 +81,7 @@ pub async fn search_and_grab( .await? } else if let Some(eid) = episode_id { sqlx::query_as( - "SELECT id FROM queue WHERE media_type = 'series' AND episode_id = $1 \ + "SELECT id FROM queue WHERE media_type = 'series' AND episode_id = ? \ AND status != 'completed' LIMIT 1", ) .bind(eid) @@ -105,7 +105,7 @@ pub async fn search_and_grab( // Load quality profile for the media let profile: QualityProfile = if is_movie { sqlx::query_as::<_, QualityProfile>( - "SELECT qp.* FROM movies m JOIN quality_profiles qp ON m.quality_profile_id = qp.id WHERE m.id = $1", + "SELECT qp.* FROM movies m JOIN quality_profiles qp ON m.quality_profile_id = qp.id WHERE m.id = ?", ) .bind(media_id) .fetch_optional(pool) @@ -113,7 +113,7 @@ pub async fn search_and_grab( } else { let sid = series_id.unwrap_or(media_id); sqlx::query_as::<_, QualityProfile>( - "SELECT qp.* FROM series s JOIN quality_profiles qp ON s.quality_profile_id = qp.id WHERE s.id = $1", + "SELECT qp.* FROM series s JOIN quality_profiles qp ON s.quality_profile_id = qp.id WHERE s.id = ?", ) .bind(sid) .fetch_optional(pool) @@ -271,7 +271,7 @@ pub async fn search_and_grab( let original_language = if is_movie { if let Some(mid) = movie_id { sqlx::query_scalar::<_, Option>( - "SELECT original_language FROM movies WHERE id = $1", + "SELECT original_language FROM movies WHERE id = ?", ) .bind(mid) .fetch_optional(pool) @@ -309,7 +309,7 @@ pub async fn search_and_grab( /// pre-fetched releases rather than querying indexers. #[allow(clippy::too_many_arguments)] pub async fn evaluate_and_grab( - pool: &PgPool, + pool: &MySqlPool, download_manager: &Arc>, indexer_manager: Option<&Arc>>, profile: &QualityProfile, @@ -323,31 +323,48 @@ pub async fn evaluate_and_grab( ) -> Result> { // Check queue/history/blocklist let guids: Vec = releases.iter().map(|r| r.guid.clone()).collect(); - let queued_guids: HashSet = - sqlx::query_scalar("SELECT download_id FROM queue WHERE download_id = ANY($1)") - .bind(&guids) - .fetch_all(pool) - .await? - .into_iter() - .collect(); + let mut queued = + sqlx::QueryBuilder::new("SELECT download_id FROM queue WHERE download_id IN ("); + let mut queued_ids = queued.separated(", "); + for guid in &guids { + queued_ids.push_bind(guid); + } + queued_ids.push_unseparated(")"); + let queued_guids: HashSet = queued + .build_query_scalar() + .fetch_all(pool) + .await? + .into_iter() + .collect(); - let history_guids: HashSet = sqlx::query_scalar( - "SELECT download_id FROM history WHERE download_id = ANY($1) AND event_type = 'grabbed'", - ) - .bind(&guids) - .fetch_all(pool) - .await? - .into_iter() - .collect(); + let mut history = + sqlx::QueryBuilder::new("SELECT download_id FROM history WHERE download_id IN ("); + let mut history_ids = history.separated(", "); + for guid in &guids { + history_ids.push_bind(guid); + } + history_ids.push_unseparated(") AND event_type = 'grabbed'"); + let history_guids: HashSet = history + .build_query_scalar() + .fetch_all(pool) + .await? + .into_iter() + .collect(); let release_titles: Vec = releases.iter().map(|r| r.title.clone()).collect(); - let blocklisted_titles: HashSet = - sqlx::query_scalar("SELECT source_title FROM blocklist WHERE source_title = ANY($1)") - .bind(&release_titles) - .fetch_all(pool) - .await? - .into_iter() - .collect(); + let mut blocklist = + sqlx::QueryBuilder::new("SELECT source_title FROM blocklist WHERE source_title IN ("); + let mut titles = blocklist.separated(", "); + for title in &release_titles { + titles.push_bind(title); + } + titles.push_unseparated(")"); + let blocklisted_titles: HashSet = blocklist + .build_query_scalar() + .fetch_all(pool) + .await? + .into_iter() + .collect(); // Load custom formats let cf_formats: Vec = @@ -366,7 +383,7 @@ pub async fn evaluate_and_grab( .collect(); let cf_scores: Vec<(i64, i32)> = sqlx::query_as::<_, (i32, i32)>( - "SELECT format_id, score FROM custom_format_scores WHERE profile_id = $1", + "SELECT format_id, score FROM custom_format_scores WHERE profile_id = ?", ) .bind(profile.id) .fetch_all(pool) @@ -516,7 +533,7 @@ pub async fn evaluate_and_grab( let _ = sqlx::query( "INSERT INTO queue (media_type, media_id, episode_id, title, quality, size, status, download_id, download_client_id, indexer_id, protocol) - VALUES ($1, $2, $3, $4, '{}'::jsonb, $5, 'queued', $6, $7, $8, $9)", + VALUES (?, ?, ?, ?, JSON_OBJECT(), ?, 'queued', ?, ?, ?, ?)", ) .bind(media_type_str) .bind(media_id) @@ -533,7 +550,7 @@ pub async fn evaluate_and_grab( // Insert history entry let _ = sqlx::query( "INSERT INTO history (media_type, media_id, episode_id, event_type, quality, source_title, download_id, indexer_id, download_client) - VALUES ($1, $2, $3, 'grabbed', '{}'::jsonb, $4, $5, $6, $7)", + VALUES (?, ?, ?, 'grabbed', JSON_OBJECT(), ?, ?, ?, ?)", ) .bind(media_type_str) .bind(media_id) @@ -576,7 +593,7 @@ struct MissingMovie { /// Run one cycle of automatic search for all missing monitored media. pub async fn auto_search_missing( - pool: &PgPool, + pool: &MySqlPool, indexer_manager: &Arc>, download_manager: &Arc>, cancel_token: Option<&tokio_util::sync::CancellationToken>, @@ -594,7 +611,7 @@ pub async fn auto_search_missing( AND e.episode_file_id IS NULL AND e.season_number > 0 AND (e.air_date IS NULL OR e.air_date <= CURRENT_DATE) - ORDER BY e.air_date DESC NULLS LAST + ORDER BY e.air_date IS NULL, e.air_date DESC LIMIT 100", ) .fetch_all(pool) @@ -738,9 +755,9 @@ pub async fn auto_search_missing( // ── Internal helpers ───────────────────────────────────────────────────────── #[cfg(test)] -async fn load_quality_profile(pool: &PgPool, id: i32) -> Result { +async fn load_quality_profile(pool: &MySqlPool, id: i32) -> Result { let profile = - sqlx::query_as::<_, QualityProfile>("SELECT * FROM quality_profiles WHERE id = $1") + sqlx::query_as::<_, QualityProfile>("SELECT * FROM quality_profiles WHERE id = ?") .bind(id) .fetch_one(pool) .await?; @@ -779,7 +796,7 @@ fn indexer_to_core(r: stackarr_indexer::ReleaseInfo) -> ReleaseInfo { #[allow(clippy::too_many_arguments)] pub async fn lookup_existing_quality_and_cf( - pool: &PgPool, + pool: &MySqlPool, cf_engine: &CustomFormatEngine, cf_formats: &[CustomFormatDef], cf_scores: &[(i64, i32)], @@ -795,7 +812,7 @@ pub async fn lookup_existing_quality_and_cf( "SELECT mf.quality, mf.scene_name FROM movies m JOIN media_files mf ON mf.id = m.movie_file_id - WHERE m.id = $1 AND m.movie_file_id IS NOT NULL", + WHERE m.id = ? AND m.movie_file_id IS NOT NULL", ) .bind(mid) .fetch_optional(pool) @@ -821,7 +838,7 @@ pub async fn lookup_existing_quality_and_cf( "SELECT mf.quality, mf.scene_name FROM episodes e JOIN media_files mf ON mf.id = e.episode_file_id - WHERE e.id = $1 AND e.episode_file_id IS NOT NULL", + WHERE e.id = ? AND e.episode_file_id IS NOT NULL", ) .bind(eid) .fetch_optional(pool) @@ -843,7 +860,7 @@ pub async fn lookup_existing_quality_and_cf( "SELECT mf.quality, mf.scene_name FROM episodes e JOIN media_files mf ON mf.id = e.episode_file_id - WHERE e.series_id = $1 AND e.episode_file_id IS NOT NULL + WHERE e.series_id = ? AND e.episode_file_id IS NOT NULL ORDER BY mf.id DESC LIMIT 1", ) .bind(sid) @@ -898,7 +915,7 @@ fn parse_existing_file_context( } pub async fn lookup_queued_quality( - pool: &PgPool, + pool: &MySqlPool, is_movie: bool, series_id: Option, movie_id: Option, @@ -910,7 +927,7 @@ pub async fn lookup_queued_quality( let quality_json: Option = if !is_movie { if let Some(eid) = episode_id { sqlx::query_scalar( - "SELECT quality FROM queue WHERE media_type = $1 AND media_id = $2 AND episode_id = $3 ORDER BY id DESC LIMIT 1", + "SELECT quality FROM queue WHERE media_type = ? AND media_id = ? AND episode_id = ? ORDER BY id DESC LIMIT 1", ) .bind(media_type) .bind(media_id) @@ -921,7 +938,7 @@ pub async fn lookup_queued_quality( .flatten() } else { sqlx::query_scalar( - "SELECT quality FROM queue WHERE media_type = $1 AND media_id = $2 ORDER BY id DESC LIMIT 1", + "SELECT quality FROM queue WHERE media_type = ? AND media_id = ? ORDER BY id DESC LIMIT 1", ) .bind(media_type) .bind(media_id) @@ -932,7 +949,7 @@ pub async fn lookup_queued_quality( } } else { sqlx::query_scalar( - "SELECT quality FROM queue WHERE media_type = $1 AND media_id = $2 ORDER BY id DESC LIMIT 1", + "SELECT quality FROM queue WHERE media_type = ? AND media_id = ? ORDER BY id DESC LIMIT 1", ) .bind(media_type) .bind(media_id) @@ -1064,18 +1081,18 @@ mod tests { } } - async fn seed_profile_with_quality(pool: &PgPool, allowed_quality: i32) -> i32 { + async fn seed_profile_with_quality(pool: &MySqlPool, allowed_quality: i32) -> i32 { let items = serde_json::json!([{"quality": allowed_quality, "allowed": true}]); - let row: (i32,) = sqlx::query_as( + let result = sqlx::query( "INSERT INTO quality_profiles (name, cutoff, upgrade_allowed, min_format_score, cutoff_format_score, items) - VALUES ('Test Profile', $1, true, 0, 0, $2) RETURNING id", + VALUES ('Test Profile', ?, true, 0, 0, ?)", ) .bind(allowed_quality) .bind(items) - .fetch_one(pool) + .execute(pool) .await .expect("seed quality profile"); - row.0 + i32::try_from(result.last_insert_id()).expect("quality profile id fits in i32") } fn dm_with_usenet() -> Arc> { @@ -1249,7 +1266,7 @@ mod tests { // Seed indexer and download_client rows to satisfy FK constraints sqlx::query("INSERT INTO indexers (id, name, indexer_type, base_url, protocol, priority) VALUES (1, 'Test', 'Newznab', 'http://localhost', 'usenet', 25)") .execute(&db.pool).await.unwrap(); - sqlx::query("INSERT INTO download_clients (id, name, client_type, protocol, config) VALUES (1, 'MockSab', 'SABnzbd', 'usenet', '{}'::jsonb)") + sqlx::query("INSERT INTO download_clients (id, name, client_type, protocol, config) VALUES (1, 'MockSab', 'SABnzbd', 'usenet', JSON_OBJECT())") .execute(&db.pool).await.unwrap(); let release = make_release("Test.Show.S01E01.1080p.WEB-DL.x264-GROUP"); @@ -1272,7 +1289,7 @@ mod tests { // Verify queue entry was created let queue_count: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM queue WHERE media_type = 'series' AND media_id = $1", + "SELECT COUNT(*) FROM queue WHERE media_type = 'series' AND media_id = ?", ) .bind(series_id) .fetch_one(&db.pool) @@ -1282,7 +1299,7 @@ mod tests { // Verify history entry was created let history_count: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM history WHERE media_type = 'series' AND media_id = $1 AND event_type = 'grabbed'", + "SELECT COUNT(*) FROM history WHERE media_type = 'series' AND media_id = ? AND event_type = 'grabbed'", ) .bind(series_id) .fetch_one(&db.pool) @@ -1304,7 +1321,7 @@ mod tests { let blocked_title = "Show.S01E01.1080p.WEB-DL.x264-BLOCKED"; // Insert blocklist entry - sqlx::query("INSERT INTO blocklist (source_title, media_type, media_id, quality) VALUES ($1, 'series', 1, '{}'::jsonb)") + sqlx::query("INSERT INTO blocklist (source_title, media_type, media_id, quality) VALUES (?, 'series', 1, JSON_OBJECT())") .bind(blocked_title) .execute(&db.pool) .await @@ -1340,15 +1357,15 @@ mod tests { {"quality": 7, "allowed": true}, {"quality": 11, "allowed": true} ]); - let row: (i32,) = sqlx::query_as( + let result = sqlx::query( "INSERT INTO quality_profiles (name, cutoff, upgrade_allowed, min_format_score, cutoff_format_score, items) - VALUES ('Multi Profile', 11, true, 0, 0, $1) RETURNING id", + VALUES ('Multi Profile', 11, true, 0, 0, ?)", ) .bind(items) - .fetch_one(&db.pool) + .execute(&db.pool) .await .unwrap(); - let profile_id = row.0; + let profile_id = i32::try_from(result.last_insert_id()).unwrap(); let profile = load_quality_profile(&db.pool, profile_id).await.unwrap(); let dm = dm_with_usenet(); diff --git a/crates/stackarr-scheduler/src/health.rs b/crates/stackarr-scheduler/src/health.rs index 072d5ea8..e98dc537 100644 --- a/crates/stackarr-scheduler/src/health.rs +++ b/crates/stackarr-scheduler/src/health.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use std::time::Duration; use anyhow::Result; -use sqlx::PgPool; +use sqlx::MySqlPool; use tokio::sync::RwLock; use stackarr_download::DownloadClientManager; @@ -36,7 +36,7 @@ struct IndexerRow { /// Run a single pass of health checks against all download clients and indexers. pub async fn health_check_task( - pool: PgPool, + pool: MySqlPool, download_manager: Arc>, indexer_manager: Arc>, ) -> Result<()> { @@ -46,7 +46,7 @@ pub async fn health_check_task( } async fn check_download_clients( - pool: &PgPool, + pool: &MySqlPool, download_manager: &Arc>, ) { let rows: Vec = match sqlx::query_as( @@ -104,7 +104,7 @@ async fn check_download_clients( SET enabled = true, auto_disabled = false, \ health_status = 'healthy', consecutive_failures = 0, \ last_health_check = NOW() \ - WHERE id = $1", + WHERE id = ?", ) .bind(row.id) .execute(pool) @@ -113,7 +113,7 @@ async fn check_download_clients( let _ = sqlx::query( "UPDATE download_clients \ SET health_status = 'healthy', last_health_check = NOW() \ - WHERE id = $1", + WHERE id = ?", ) .bind(row.id) .execute(pool) @@ -131,7 +131,7 @@ async fn check_download_clients( } async fn handle_dl_failure( - pool: &PgPool, + pool: &MySqlPool, download_manager: &Arc>, row: &DownloadClientRow, error_msg: &str, @@ -151,8 +151,8 @@ async fn handle_dl_failure( "UPDATE download_clients \ SET enabled = false, auto_disabled = true, \ health_status = 'auto_disabled', \ - consecutive_failures = $1, last_health_check = NOW() \ - WHERE id = $2", + consecutive_failures = ?, last_health_check = NOW() \ + WHERE id = ?", ) .bind(new_failures) .bind(row.id) @@ -167,8 +167,8 @@ async fn handle_dl_failure( let _ = sqlx::query( "UPDATE download_clients \ SET health_status = 'unhealthy', \ - consecutive_failures = $1, last_health_check = NOW() \ - WHERE id = $2", + consecutive_failures = ?, last_health_check = NOW() \ + WHERE id = ?", ) .bind(new_failures) .bind(row.id) @@ -178,13 +178,13 @@ async fn handle_dl_failure( } async fn try_rebuild_client( - pool: &PgPool, + pool: &MySqlPool, download_manager: &Arc>, row: &DownloadClientRow, ) { // Try to rebuild the client from DB config let config_row: Option<(serde_json::Value,)> = - sqlx::query_as("SELECT config FROM download_clients WHERE id = $1") + sqlx::query_as("SELECT config FROM download_clients WHERE id = ?") .bind(row.id) .fetch_optional(pool) .await @@ -207,7 +207,7 @@ async fn try_rebuild_client( SET enabled = true, auto_disabled = false, \ health_status = 'healthy', consecutive_failures = 0, \ last_health_check = NOW() \ - WHERE id = $1", + WHERE id = ?", ) .bind(row.id) .execute(pool) @@ -231,7 +231,7 @@ async fn try_rebuild_client( } } -async fn check_indexers(pool: &PgPool, indexer_manager: &Arc>) { +async fn check_indexers(pool: &MySqlPool, indexer_manager: &Arc>) { let rows: Vec = match sqlx::query_as( "SELECT id::BIGINT, name, enabled, auto_disabled, consecutive_failures FROM indexers", ) @@ -283,7 +283,7 @@ async fn check_indexers(pool: &PgPool, indexer_manager: &Arc>, row: &IndexerRow, error_msg: &str, @@ -331,8 +331,8 @@ async fn handle_indexer_failure( "UPDATE indexers \ SET enabled = false, auto_disabled = true, \ health_status = 'auto_disabled', \ - consecutive_failures = $1, last_health_check = NOW() \ - WHERE id = $2", + consecutive_failures = ?, last_health_check = NOW() \ + WHERE id = ?", ) .bind(new_failures) .bind(row.id) @@ -347,8 +347,8 @@ async fn handle_indexer_failure( let _ = sqlx::query( "UPDATE indexers \ SET health_status = 'unhealthy', \ - consecutive_failures = $1, last_health_check = NOW() \ - WHERE id = $2", + consecutive_failures = ?, last_health_check = NOW() \ + WHERE id = ?", ) .bind(new_failures) .bind(row.id) diff --git a/crates/stackarr-scheduler/src/lib.rs b/crates/stackarr-scheduler/src/lib.rs index aae11c0a..f949e111 100644 --- a/crates/stackarr-scheduler/src/lib.rs +++ b/crates/stackarr-scheduler/src/lib.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use std::time::Duration; use anyhow::Result; -use sqlx::PgPool; +use sqlx::MySqlPool; use tokio::sync::RwLock; use tokio::time::interval; @@ -32,7 +32,7 @@ pub struct ArchiveCleanupConfig { /// Background scheduler that spawns periodic tasks. pub struct Scheduler { - pool: PgPool, + pool: MySqlPool, rss_interval: Duration, download_sync_interval: Duration, importer_interval: Duration, @@ -56,7 +56,7 @@ pub struct Scheduler { impl Scheduler { /// Create a scheduler with default intervals. - pub fn new(pool: PgPool) -> Self { + pub fn new(pool: MySqlPool) -> Self { Self { pool, rss_interval: Duration::from_secs(15 * 60), // 15 min @@ -82,7 +82,7 @@ impl Scheduler { /// Create a scheduler with custom intervals. pub fn with_intervals( - pool: PgPool, + pool: MySqlPool, rss_secs: u64, import_secs: u64, refresh_secs: u64, @@ -1083,7 +1083,7 @@ impl SchedulerHandle { // ── Module check ──────────────────────────────────────────────────────────── -async fn get_enabled_modules(pool: &PgPool) -> Vec { +async fn get_enabled_modules(pool: &MySqlPool) -> Vec { sqlx::query_scalar::<_, String>("SELECT module FROM enabled_modules WHERE enabled = true") .fetch_all(pool) .await @@ -1095,7 +1095,7 @@ async fn get_enabled_modules(pool: &PgPool) -> Vec { /// Polls all registered download clients, updates item statuses, /// persists output paths, and handles stale/orphaned downloads. async fn download_sync_task( - pool: PgPool, + pool: MySqlPool, download_manager: Option>>, nzb_archive: Option<(std::path::PathBuf, std::path::PathBuf)>, ) -> Result<()> { @@ -1188,9 +1188,9 @@ async fn download_sync_task( }; sqlx::query( - "UPDATE queue SET status = $1, output_path = COALESCE($2, output_path), \ - error_message = COALESCE($3, error_message), stale_count = 0 \ - WHERE id = $4", + "UPDATE queue SET status = ?, output_path = COALESCE(?, output_path), \ + error_message = COALESCE(?, error_message), stale_count = 0 \ + WHERE id = ?", ) .bind(new_status) .bind(&output_path_str) @@ -1242,7 +1242,7 @@ async fn download_sync_task( // Status unchanged — still persist output_path and reset stale if needed if output_path_str.is_some() || *stale_count > 0 { sqlx::query( - "UPDATE queue SET output_path = COALESCE($1, output_path), stale_count = 0 WHERE id = $2", + "UPDATE queue SET output_path = COALESCE(?, output_path), stale_count = 0 WHERE id = ?", ) .bind(&output_path_str) .bind(queue_id) @@ -1263,7 +1263,7 @@ async fn download_sync_task( // keep nzb-web workers from making socket reads. if tokio::fs::metadata(path_str).await.is_ok() { sqlx::query( - "UPDATE queue SET status = 'completed', stale_count = 0 WHERE id = $1", + "UPDATE queue SET status = 'completed', stale_count = 0 WHERE id = ?", ) .bind(queue_id) .execute(&pool) @@ -1289,7 +1289,7 @@ async fn download_sync_task( let new_stale = stale_count + 1; if new_stale >= 2 { // Item gone from client for 2+ cycles — remove from queue - sqlx::query("DELETE FROM queue WHERE id = $1") + sqlx::query("DELETE FROM queue WHERE id = ?") .bind(queue_id) .execute(&pool) .await?; @@ -1314,7 +1314,7 @@ async fn download_sync_task( ) .await; } else { - sqlx::query("UPDATE queue SET stale_count = $1 WHERE id = $2") + sqlx::query("UPDATE queue SET stale_count = ? WHERE id = ?") .bind(new_stale) .bind(queue_id) .execute(&pool) @@ -1329,7 +1329,7 @@ async fn download_sync_task( // Purge old failed queue items (older than 1 hour) to prevent table bloat let purged = sqlx::query( - "DELETE FROM queue WHERE status = 'failed' AND added_at < NOW() - INTERVAL '1 hour'", + "DELETE FROM queue WHERE status = 'failed' AND added_at < NOW() - INTERVAL 1 HOUR", ) .execute(&pool) .await?; @@ -1347,7 +1347,7 @@ async fn download_sync_task( /// Independent importer job — picks up completed downloads from the queue /// table and runs the import pipeline for each one. Runs on its own timer /// (every 30 seconds) so imports are never blocked by download client sync. -async fn importer_task(pool: PgPool, ffprobe_path: Option) -> Result<()> { +async fn importer_task(pool: MySqlPool, ffprobe_path: Option) -> Result<()> { #[allow(clippy::type_complexity)] let completed: Vec<( i64, @@ -1448,17 +1448,16 @@ async fn importer_task(pool: PgPool, ffprobe_path: Option) -> Result<()> // Mark as importing so the UI shows progress and Phase B won't re-pick // this item on the next scheduler tick - sqlx::query("UPDATE queue SET status = 'importing' WHERE id = $1") + sqlx::query("UPDATE queue SET status = 'importing' WHERE id = ?") .bind(queue_id) .execute(&pool) .await?; // Create an "import_started" activity record in history (first attempt only) let activity_id: Option<(i64,)> = if *stale_count == 0 { - sqlx::query_as( + let insert = sqlx::query( "INSERT INTO history (media_type, media_id, episode_id, event_type, quality, languages, source_title, download_id, indexer_id, data) \ - VALUES ($1, $2, $3, 'import_started', $4, $5, $6, $7, $8, '{}'::jsonb) \ - RETURNING id", + VALUES (?, ?, ?, 'import_started', ?, ?, ?, ?, ?, JSON_OBJECT())", ) .bind(media_type) .bind(media_id) @@ -1468,8 +1467,9 @@ async fn importer_task(pool: PgPool, ffprobe_path: Option) -> Result<()> .bind(title) .bind(download_id) .bind(indexer_id) - .fetch_optional(&pool) - .await? + .execute(&pool) + .await?; + Some((insert.last_insert_id() as i64,)) } else { None }; @@ -1504,7 +1504,7 @@ async fn importer_task(pool: PgPool, ffprobe_path: Option) -> Result<()> // Resolve download client name for the history record let client_name: Option = if let Some(cid) = client_id { - sqlx::query_scalar("SELECT name FROM download_clients WHERE id = $1") + sqlx::query_scalar("SELECT name FROM download_clients WHERE id = ?") .bind(cid) .fetch_optional(&pool) .await @@ -1521,7 +1521,7 @@ async fn importer_task(pool: PgPool, ffprobe_path: Option) -> Result<()> "skipped_files": import_result.skipped_files.len(), }); let _ = sqlx::query( - "UPDATE history SET event_type = 'imported', data = $1 WHERE id = $2", + "UPDATE history SET event_type = 'imported', data = ? WHERE id = ?", ) .bind(&import_data) .bind(aid) @@ -1532,7 +1532,7 @@ async fn importer_task(pool: PgPool, ffprobe_path: Option) -> Result<()> // Insert a completed import record into history (includes import log lines) if let Err(e) = sqlx::query( "INSERT INTO history (media_type, media_id, episode_id, event_type, quality, languages, source_title, download_id, indexer_id, download_client, data) \ - VALUES ($1, $2, $3, 'download_imported', $4, $5, $6, $7, $8, $9, $10::jsonb)", + VALUES (?, ?, ?, 'download_imported', ?, ?, ?, ?, ?, ?, ?)", ) .bind(media_type) .bind(media_id) @@ -1555,7 +1555,7 @@ async fn importer_task(pool: PgPool, ffprobe_path: Option) -> Result<()> } // Remove from queue now that it's in history - sqlx::query("DELETE FROM queue WHERE id = $1") + sqlx::query("DELETE FROM queue WHERE id = ?") .bind(queue_id) .execute(&pool) .await?; @@ -1584,7 +1584,7 @@ async fn importer_task(pool: PgPool, ffprobe_path: Option) -> Result<()> "import failed after max retries, marking as failed" ); sqlx::query( - "UPDATE queue SET status = 'failed', error_message = $1, stale_count = $2 WHERE id = $3", + "UPDATE queue SET status = 'failed', error_message = ?, stale_count = ? WHERE id = ?", ) .bind(&error_msg) .bind(new_count) @@ -1595,7 +1595,7 @@ async fn importer_task(pool: PgPool, ffprobe_path: Option) -> Result<()> // Update the import_started activity record to reflect failure if let Some((aid,)) = activity_id { let _ = sqlx::query( - "UPDATE history SET event_type = 'download_failed', data = $1 WHERE id = $2", + "UPDATE history SET event_type = 'download_failed', data = ? WHERE id = ?", ) .bind(serde_json::json!({ "error": &error_msg })) .bind(aid) @@ -1634,7 +1634,7 @@ async fn importer_task(pool: PgPool, ffprobe_path: Option) -> Result<()> "import completed with errors, reverting to completed for retry" ); sqlx::query( - "UPDATE queue SET status = 'completed', error_message = $1, stale_count = $2 WHERE id = $3", + "UPDATE queue SET status = 'completed', error_message = ?, stale_count = ? WHERE id = ?", ) .bind(&error_msg) .bind(new_count) @@ -1652,7 +1652,7 @@ async fn importer_task(pool: PgPool, ffprobe_path: Option) -> Result<()> "import failed" ); - sqlx::query("UPDATE queue SET status = 'failed', error_message = $1 WHERE id = $2") + sqlx::query("UPDATE queue SET status = 'failed', error_message = ? WHERE id = ?") .bind(e.to_string()) .bind(queue_id) .execute(&pool) @@ -1661,7 +1661,7 @@ async fn importer_task(pool: PgPool, ffprobe_path: Option) -> Result<()> // Update the import_started activity record to reflect failure if let Some((aid,)) = activity_id { let _ = sqlx::query( - "UPDATE history SET event_type = 'download_failed', data = $1 WHERE id = $2", + "UPDATE history SET event_type = 'download_failed', data = ? WHERE id = ?", ) .bind(serde_json::json!({ "error": e.to_string() })) .bind(aid) @@ -1700,7 +1700,7 @@ async fn importer_task(pool: PgPool, ffprobe_path: Option) -> Result<()> /// For embedded clients (client_id=None), look up the usenet/torrent complete dir /// from the `app_config` table so we don't depend on in-memory engine state. async fn resolve_output_path_from_config( - pool: &PgPool, + pool: &MySqlPool, client_id: Option, title: &str, ) -> Option { @@ -1708,7 +1708,7 @@ async fn resolve_output_path_from_config( Some(cid) => { // External download client — look up its config let client_row: Option<(serde_json::Value,)> = sqlx::query_as( - "SELECT config FROM download_clients WHERE id = $1 AND enabled = true", + "SELECT config FROM download_clients WHERE id = ? AND enabled = true", ) .bind(cid) .fetch_optional(pool) @@ -1743,7 +1743,7 @@ async fn resolve_output_path_from_config( /// Record a download failure: add to blocklist and create a download_failed history event. #[allow(clippy::too_many_arguments)] async fn record_download_failure( - pool: &PgPool, + pool: &MySqlPool, media_type: &str, media_id: i64, episode_id: Option, @@ -1755,7 +1755,7 @@ async fn record_download_failure( // Add to blocklist so auto-search doesn't re-grab if let Err(e) = sqlx::query( "INSERT INTO blocklist (media_type, media_id, source_title, quality, message, indexer_id) \ - VALUES ($1, $2, $3, '{}'::jsonb, $4, $5)", + VALUES (?, ?, ?, JSON_OBJECT(), ?, ?)", ) .bind(media_type) .bind(media_id) @@ -1771,7 +1771,7 @@ async fn record_download_failure( // Create download_failed history event if let Err(e) = sqlx::query( "INSERT INTO history (media_type, media_id, episode_id, event_type, quality, source_title, download_id, indexer_id, data) \ - VALUES ($1, $2, $3, 'download_failed', '{}'::jsonb, $4, $5, $6, $7::jsonb)", + VALUES (?, ?, ?, 'download_failed', JSON_OBJECT(), ?, ?, ?, ?)", ) .bind(media_type) .bind(media_id) @@ -1805,7 +1805,10 @@ async fn record_download_failure( // ── Real metadata refresh task ────────────────────────────────────────────── -async fn metadata_refresh_task(pool: PgPool, tmdb_client: Option>) -> Result<()> { +async fn metadata_refresh_task( + pool: MySqlPool, + tmdb_client: Option>, +) -> Result<()> { let refresh_svc = stackarr_media::MetadataRefreshService::new(pool.clone()); // 1. Find stale series @@ -1903,7 +1906,10 @@ async fn metadata_refresh_task(pool: PgPool, tmdb_client: Option // ── Import list sync task ─────────────────────────────────────────────────── -async fn import_list_sync_task(pool: PgPool, tmdb_client: Option>) -> Result<()> { +async fn import_list_sync_task( + pool: MySqlPool, + tmdb_client: Option>, +) -> Result<()> { let Some(ref tmdb) = tmdb_client else { tracing::debug!("import list sync: no TMDB client available, skipping"); return Ok(()); @@ -1936,7 +1942,7 @@ async fn import_list_sync_task(pool: PgPool, tmdb_client: Option // ── Scheduled disk scan task ──────────────────────────────────────────────── -async fn scheduled_disk_scan(pool: PgPool) -> Result<()> { +async fn scheduled_disk_scan(pool: MySqlPool) -> Result<()> { let db = stackarr_core::Database::from_pool(pool.clone()); let folders: Vec<(i32, String, String)> = @@ -2141,7 +2147,7 @@ async fn cleanup_dir_keep_newest(dir: &std::path::Path, keep: usize) -> Result Result { +async fn dav_cleanup(pool: &MySqlPool) -> Result { // Load retention from dav_config (default 24 hours) let retention_hours: i64 = sqlx::query_scalar::<_, String>( "SELECT value FROM dav_config WHERE key = 'retention_hours'", @@ -2159,7 +2165,7 @@ async fn dav_cleanup(pool: &PgPool) -> Result { "DELETE FROM dav_items \ WHERE sub_type NOT IN (102, 103, 104, 105, 106) \ AND sub_type != 204 \ - AND created_at < $1", + AND created_at < ?", ) .bind(cutoff) .execute(pool) @@ -2196,11 +2202,11 @@ async fn dav_cleanup(pool: &PgPool) -> Result { #[cfg(test)] mod tests { use super::*; - use sqlx::postgres::PgPoolOptions; + use sqlx::mysql::MySqlPoolOptions; - fn dummy_pool() -> PgPool { + fn dummy_pool() -> MySqlPool { // connect_lazy requires a tokio context, so tests must be #[tokio::test] - PgPoolOptions::new() + MySqlPoolOptions::new() .max_connections(1) .connect_lazy("postgresql://fake:fake@localhost:5432/fake") .expect("lazy pool") diff --git a/crates/stackarr-scheduler/src/rss.rs b/crates/stackarr-scheduler/src/rss.rs index 98cc8940..cfe87d7e 100644 --- a/crates/stackarr-scheduler/src/rss.rs +++ b/crates/stackarr-scheduler/src/rss.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use anyhow::{Context, Result}; -use sqlx::PgPool; +use sqlx::MySqlPool; use tokio::sync::RwLock; use stackarr_core::models::{DownloadProtocol, RssFeed, RssItem, RssRule}; @@ -15,7 +15,7 @@ pub struct CheckStats { /// Run one RSS sync cycle: check all enabled feeds. pub async fn rss_sync( - pool: &PgPool, + pool: &MySqlPool, download_manager: &Arc>, ) -> Result<()> { let feeds: Vec = sqlx::query_as( @@ -73,7 +73,7 @@ pub async fn rss_sync( /// Check a single feed — used by both the scheduler and the manual check endpoint. pub async fn check_single_feed( - pool: &PgPool, + pool: &MySqlPool, feed: &RssFeed, download_manager: &Arc>, ) -> Result { @@ -85,7 +85,7 @@ pub async fn check_single_feed( async fn check_single_feed_inner( client: &reqwest::Client, - pool: &PgPool, + pool: &MySqlPool, feed: &RssFeed, download_manager: &Arc>, ) -> Result { @@ -140,14 +140,12 @@ async fn check_single_feed_inner( }); } - // 4. Batch insert (ON CONFLICT DO NOTHING for dedup) + // 4. Batch insert with duplicate-key suppression. let mut new_ids: Vec = Vec::new(); for item in &pending_items { - let result = sqlx::query_scalar::<_, String>( - "INSERT INTO rss_items (id, feed_id, title, url, published_at, first_seen_at, category, size_bytes) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT (id) DO NOTHING - RETURNING id", + let result = sqlx::query( + "INSERT IGNORE INTO rss_items (id, feed_id, title, url, published_at, first_seen_at, category, size_bytes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&item.id) .bind(item.feed_id) @@ -157,11 +155,11 @@ async fn check_single_feed_inner( .bind(item.first_seen_at) .bind(&item.category) .bind(item.size_bytes) - .fetch_optional(pool) + .execute(pool) .await?; - if let Some(id) = result { - new_ids.push(id); + if result.rows_affected() == 1 { + new_ids.push(item.id.clone()); } } @@ -174,7 +172,7 @@ async fn check_single_feed_inner( // Load rules that apply to this feed let rules: Vec = sqlx::query_as( "SELECT id, name, feed_ids, category, priority, match_regex, enabled, created_at - FROM rss_rules WHERE enabled = true AND $1 = ANY(feed_ids)", + FROM rss_rules WHERE enabled = true AND JSON_CONTAINS(feed_ids, JSON_ARRAY(?))", ) .bind(feed.id) .fetch_all(pool) @@ -250,7 +248,7 @@ async fn check_single_feed_inner( ); let _ = sqlx::query( - "UPDATE rss_items SET downloaded = true, downloaded_at = NOW(), category = COALESCE($1, category) WHERE id = $2", + "UPDATE rss_items SET downloaded = true, downloaded_at = NOW(), category = COALESCE(?, category) WHERE id = ?", ) .bind(&category) .bind(&item.id) diff --git a/crates/stackarr-stream/src/session.rs b/crates/stackarr-stream/src/session.rs index 5dc4fce2..5ebd6763 100644 --- a/crates/stackarr-stream/src/session.rs +++ b/crates/stackarr-stream/src/session.rs @@ -4,7 +4,7 @@ use std::time::Duration; use chrono::{DateTime, Utc}; use dashmap::DashMap; -use sqlx::PgPool; +use sqlx::MySqlPool; use uuid::Uuid; use stackarr_core::config::{HwAccelConfig, StreamingConfig}; @@ -147,11 +147,11 @@ pub struct SessionManager { sessions: DashMap, config: StreamingConfig, detected_accel: DetectedAccel, - pool: PgPool, + pool: MySqlPool, } impl SessionManager { - pub fn new(config: StreamingConfig, detected_accel: DetectedAccel, pool: PgPool) -> Self { + pub fn new(config: StreamingConfig, detected_accel: DetectedAccel, pool: MySqlPool) -> Self { Self { sessions: DashMap::new(), config, @@ -186,11 +186,12 @@ impl SessionManager { // Record in DB sqlx::query( "INSERT INTO streaming_sessions (id, media_file_id, session_type, status, started_at, last_activity) - VALUES ($1, $2, 'direct', 'active', $3, $3)", + VALUES (?, ?, 'direct', 'active', ?, ?)", ) .bind(id) .bind(media_file_id) .bind(now) + .bind(now) .execute(&self.pool) .await?; @@ -330,11 +331,12 @@ impl SessionManager { // Record in DB sqlx::query( "INSERT INTO streaming_sessions (id, media_file_id, session_type, status, started_at, last_activity, transcode_dir) - VALUES ($1, $2, 'transcode', 'active', $3, $3, $4)", + VALUES (?, ?, 'transcode', 'active', ?, ?, ?)", ) .bind(session_id) .bind(media_file_id) .bind(now) + .bind(now) .bind(session_dir.to_string_lossy().as_ref()) .execute(&self.pool) .await?; @@ -449,11 +451,12 @@ impl SessionManager { // Record in DB sqlx::query( "INSERT INTO streaming_sessions (id, media_file_id, session_type, status, started_at, last_activity, transcode_dir) - VALUES ($1, $2, 'transcode', 'active', $3, $3, $4)", + VALUES (?, ?, 'transcode', 'active', ?, ?, ?)", ) .bind(session_id) .bind(media_file_id) .bind(now) + .bind(now) .bind(session_dir.to_string_lossy().as_ref()) .execute(&self.pool) .await?; @@ -561,7 +564,7 @@ impl SessionManager { } // Update DB - let _ = sqlx::query("UPDATE streaming_sessions SET status = 'completed' WHERE id = $1") + let _ = sqlx::query("UPDATE streaming_sessions SET status = 'completed' WHERE id = ?") .bind(session_id) .execute(&self.pool) .await; diff --git a/crates/stackarr-web/src/dav_manager.rs b/crates/stackarr-web/src/dav_manager.rs index 1bc03e35..119eaef1 100644 --- a/crates/stackarr-web/src/dav_manager.rs +++ b/crates/stackarr-web/src/dav_manager.rs @@ -10,7 +10,7 @@ use nzbdav_dav::DatabaseStore; use nzbdav_pipeline::queue_item_processor::QueueItemProcessor; use nzbdav_stream::UsenetArticleProvider; use nzbdav_stream::nzb_nntp::ConnectionPool; -use sqlx::PgPool; +use sqlx::MySqlPool; /// Holds the initialized DAV streaming components. pub struct DavManager { @@ -20,7 +20,7 @@ pub struct DavManager { pub store: Arc, /// Pipeline processor for inline NZB processing. pub processor: Arc, - /// Database implementation (PostgresDavDatabase). + /// Database implementation (`MariaDbDavDatabase`). pub db: Arc, } @@ -31,7 +31,7 @@ const DEFAULT_DAV_CONNECTIONS: u16 = 10; /// /// These are separate from the embedded usenet engine's pools to prevent /// streaming from starving downloads and vice versa. -pub async fn build_dav_pools(pool: &PgPool) -> Vec> { +pub async fn build_dav_pools(pool: &MySqlPool) -> Vec> { // Load usenet server configs from download_clients table let rows: Vec<(i32, serde_json::Value, bool)> = sqlx::query_as( "SELECT id, config, enabled FROM download_clients \ diff --git a/crates/stackarr-web/src/routes/auth.rs b/crates/stackarr-web/src/routes/auth.rs index 78675f6f..c94ee92c 100644 --- a/crates/stackarr-web/src/routes/auth.rs +++ b/crates/stackarr-web/src/routes/auth.rs @@ -541,8 +541,8 @@ async fn setup( // Generate and store API key let api_key = stackarr_core::auth::generate_session_token(); if let Err(e) = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('api_key', $1) \ - ON CONFLICT (key) DO UPDATE SET value = $1", + "INSERT INTO app_config (key, value) VALUES ('api_key', ?) \ + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(serde_json::json!(&api_key)) .execute(state.db.pool()) @@ -557,8 +557,8 @@ async fn setup( && !name.trim().is_empty() { let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('instance_name', $1) \ - ON CONFLICT (key) DO UPDATE SET value = $1", + "INSERT INTO app_config (key, value) VALUES ('instance_name', ?) \ + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(serde_json::json!(name.trim())) .execute(state.db.pool()) diff --git a/crates/stackarr-web/src/routes/backup.rs b/crates/stackarr-web/src/routes/backup.rs index c042f7a6..07f34370 100644 --- a/crates/stackarr-web/src/routes/backup.rs +++ b/crates/stackarr-web/src/routes/backup.rs @@ -243,9 +243,8 @@ async fn import_restore( .and_then(|v| v.as_str()); match sqlx::query( - "INSERT INTO quality_profiles (name, cutoff, upgrade_allowed, min_format_score, cutoff_format_score, items, media_type) - VALUES ($1, $2, $3, $4, $5, $6, $7) - ON CONFLICT DO NOTHING" + "INSERT IGNORE INTO quality_profiles (name, cutoff, upgrade_allowed, min_format_score, cutoff_format_score, items, media_type) + VALUES (?, ?, ?, ?, ?, ?, ?)" ) .bind(name) .bind(cutoff) @@ -275,7 +274,7 @@ async fn import_restore( if label.is_empty() { continue; } - match sqlx::query("INSERT INTO tags (label) VALUES ($1) ON CONFLICT (label) DO NOTHING") + match sqlx::query("INSERT IGNORE INTO tags (label) VALUES (?)") .bind(label) .execute(pool) .await @@ -305,7 +304,7 @@ async fn import_restore( continue; } match sqlx::query( - "INSERT INTO media_library_folders (path, media_type) VALUES ($1, $2) ON CONFLICT (path) DO NOTHING" + "INSERT IGNORE INTO media_library_folders (path, media_type) VALUES (?, ?)", ) .bind(path) .bind(media_type) @@ -358,10 +357,12 @@ async fn import_restore( match sqlx::query( "INSERT INTO naming_config (media_type, rename_files, standard_format, daily_format, anime_format, season_folder_format, movie_format, movie_folder_format) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT (media_type) DO UPDATE SET - rename_files = $2, standard_format = $3, daily_format = $4, - anime_format = $5, season_folder_format = $6, movie_format = $7, movie_folder_format = $8" + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + rename_files = VALUES(rename_files), standard_format = VALUES(standard_format), + daily_format = VALUES(daily_format), anime_format = VALUES(anime_format), + season_folder_format = VALUES(season_folder_format), movie_format = VALUES(movie_format), + movie_folder_format = VALUES(movie_folder_format)" ) .bind(media_type) .bind(rename_files) @@ -407,9 +408,8 @@ async fn import_restore( let config = json_field(idx, "config", "config"); match sqlx::query( - "INSERT INTO indexers (name, indexer_type, base_url, api_key, protocol, categories, enabled, priority, supports_search, supports_rss, config) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - ON CONFLICT DO NOTHING" + "INSERT IGNORE INTO indexers (name, indexer_type, base_url, api_key, protocol, categories, enabled, priority, supports_search, supports_rss, config) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ) .bind(name) .bind(indexer_type) @@ -450,9 +450,8 @@ async fn import_restore( let priority = i64_field(c, "priority", "priority", 1) as i32; match sqlx::query( - "INSERT INTO download_clients (name, client_type, protocol, config, enabled, priority) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT DO NOTHING" + "INSERT IGNORE INTO download_clients (name, client_type, protocol, config, enabled, priority) + VALUES (?, ?, ?, ?, ?, ?)" ) .bind(name) .bind(client_type) @@ -491,9 +490,8 @@ async fn import_restore( let enabled = bool_field(p, "enabled", "enabled", true); match sqlx::query( - "INSERT INTO notification_providers (name, provider_type, config, on_grab, on_import, on_upgrade, on_health_issue, on_failure, enabled) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - ON CONFLICT DO NOTHING" + "INSERT IGNORE INTO notification_providers (name, provider_type, config, on_grab, on_import, on_upgrade, on_health_issue, on_failure, enabled) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" ) .bind(name) .bind(provider_type) @@ -532,9 +530,8 @@ async fn import_restore( let enabled = bool_field(l, "enabled", "enabled", true); match sqlx::query( - "INSERT INTO import_lists (name, list_type, media_type, config, monitored, enabled) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT DO NOTHING", + "INSERT IGNORE INTO import_lists (name, list_type, media_type, config, monitored, enabled) + VALUES (?, ?, ?, ?, ?, ?)", ) .bind(name) .bind(list_type) @@ -565,7 +562,7 @@ async fn import_restore( continue; } match sqlx::query( - "INSERT INTO enabled_modules (module, enabled) VALUES ($1, $2) ON CONFLICT (module) DO UPDATE SET enabled = $2" + "INSERT INTO enabled_modules (module, enabled) VALUES (?, ?) ON DUPLICATE KEY UPDATE enabled = VALUES(enabled)" ) .bind(module) .bind(enabled) diff --git a/crates/stackarr-web/src/routes/blocklist.rs b/crates/stackarr-web/src/routes/blocklist.rs index be8fd9a0..9c32728f 100644 --- a/crates/stackarr-web/src/routes/blocklist.rs +++ b/crates/stackarr-web/src/routes/blocklist.rs @@ -88,7 +88,7 @@ async fn list_blocklist( match sqlx::query_as::<_, BlocklistEntry>( "SELECT id, media_type, media_id, source_title, quality, languages, indexer_id, info_hash, message, added_at - FROM blocklist ORDER BY added_at DESC LIMIT $1 OFFSET $2", + FROM blocklist ORDER BY added_at DESC LIMIT ? OFFSET ?", ) .bind(page_size) .bind(offset) @@ -121,10 +121,9 @@ async fn add_blocklist_entry( ) -> impl IntoResponse { let pool = state.db.pool(); - match sqlx::query_as::<_, BlocklistEntry>( + match sqlx::query( "INSERT INTO blocklist (media_type, media_id, source_title, quality, languages, indexer_id, info_hash, message) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id, media_type, media_id, source_title, quality, languages, indexer_id, info_hash, message, added_at", + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&body.media_type) .bind(body.media_id) @@ -134,10 +133,22 @@ async fn add_blocklist_entry( .bind(body.indexer_id) .bind(&body.info_hash) .bind(&body.message) - .fetch_one(pool) + .execute(pool) .await { - Ok(entry) => (StatusCode::CREATED, Json(json!(entry))).into_response(), + Ok(result) => match sqlx::query_as::<_, BlocklistEntry>( + "SELECT id, media_type, media_id, source_title, quality, languages, indexer_id, info_hash, message, added_at FROM blocklist WHERE id = ?", + ) + .bind(result.last_insert_id() as i64) + .fetch_one(pool) + .await + { + Ok(entry) => (StatusCode::CREATED, Json(json!(entry))).into_response(), + Err(e) => { + tracing::error!(error = %e, "blocklist: failed to load created entry"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + }, Err(e) => { tracing::error!(error = %e, "blocklist: failed to add entry"); ( @@ -157,7 +168,7 @@ async fn delete_blocklist_entry( ) -> impl IntoResponse { let pool = state.db.pool(); - match sqlx::query("DELETE FROM blocklist WHERE id = $1") + match sqlx::query("DELETE FROM blocklist WHERE id = ?") .bind(id) .execute(pool) .await @@ -190,11 +201,14 @@ async fn bulk_delete_blocklist( } let pool = state.db.pool(); - match sqlx::query("DELETE FROM blocklist WHERE id = ANY($1)") - .bind(&body.ids) - .execute(pool) - .await - { + let mut query = sqlx::QueryBuilder::new("DELETE FROM blocklist WHERE id IN ("); + let mut ids = query.separated(", "); + for id in &body.ids { + ids.push_bind(id); + } + ids.push_unseparated(")"); + + match query.build().execute(pool).await { Ok(r) => Json(json!({"deleted": r.rows_affected()})).into_response(), Err(e) => { tracing::error!(error = %e, "blocklist: failed to bulk delete"); diff --git a/crates/stackarr-web/src/routes/bootstrap.rs b/crates/stackarr-web/src/routes/bootstrap.rs index a9d53278..3cec34bf 100644 --- a/crates/stackarr-web/src/routes/bootstrap.rs +++ b/crates/stackarr-web/src/routes/bootstrap.rs @@ -121,7 +121,7 @@ async fn register_name( // Mark as registered in app_config let _ = sqlx::query( "INSERT INTO app_config (key, value) VALUES ('bootstrap_name_registered', '\"true\"') - ON CONFLICT (key) DO UPDATE SET value = '\"true\"'", + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .execute(state.db.pool()) .await; @@ -183,7 +183,7 @@ async fn recover_name( // Mark as registered in app_config let _ = sqlx::query( "INSERT INTO app_config (key, value) VALUES ('bootstrap_name_registered', '\"true\"') - ON CONFLICT (key) DO UPDATE SET value = '\"true\"'", + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .execute(state.db.pool()) .await; @@ -402,12 +402,11 @@ async fn firstboot_recover( None => { // ensure_server_id should have run, but fallback just in case let id = uuid::Uuid::new_v4(); - let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('server_id', $1) ON CONFLICT DO NOTHING", - ) - .bind(json!(id.to_string())) - .execute(state.db.pool()) - .await; + let _ = + sqlx::query("INSERT IGNORE INTO app_config (key, value) VALUES ('server_id', ?)") + .bind(json!(id.to_string())) + .execute(state.db.pool()) + .await; id } }; @@ -445,7 +444,7 @@ async fn firstboot_recover( // Mark as registered let _ = sqlx::query( "INSERT INTO app_config (key, value) VALUES ('bootstrap_name_registered', '\"true\"') - ON CONFLICT (key) DO UPDATE SET value = '\"true\"'", + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .execute(state.db.pool()) .await; diff --git a/crates/stackarr-web/src/routes/calendar.rs b/crates/stackarr-web/src/routes/calendar.rs index 2b1056f8..d84adac4 100644 --- a/crates/stackarr-web/src/routes/calendar.rs +++ b/crates/stackarr-web/src/routes/calendar.rs @@ -68,8 +68,8 @@ async fn get_calendar( e.monitored, s.images FROM episodes e JOIN series s ON e.series_id = s.id - WHERE e.air_date_utc >= $1::timestamptz - AND e.air_date_utc <= $2::timestamptz + WHERE e.air_date_utc >= ? + AND e.air_date_utc <= ? AND s.monitored = true ORDER BY e.air_date_utc", ) diff --git a/crates/stackarr-web/src/routes/dav.rs b/crates/stackarr-web/src/routes/dav.rs index 6c0ba69f..09dbfb84 100644 --- a/crates/stackarr-web/src/routes/dav.rs +++ b/crates/stackarr-web/src/routes/dav.rs @@ -337,7 +337,7 @@ async fn get_status(State(state): State>) -> Response { let queue_count = dav.db.count_queue_items().await.unwrap_or(0); let history_count = dav.db.count_history_items().await.unwrap_or(0); - // Count content items via helper on the concrete PostgresDavDatabase. + // Count content items via helper on the concrete MariaDbDavDatabase. // TODO: Uplift `count_content_items` to the `DavDatabase` trait in nzbdav-core // so this can go through the trait object instead of the app-level pool. let items_count = count_content_items(state.db.pool()).await.unwrap_or(0); @@ -357,8 +357,8 @@ async fn get_status(State(state): State>) -> Response { /// Count DAV content items, excluding root/system directory sub-types. /// // TODO: Uplift to `DavDatabase` trait in nzbdav-core so callers can use the -// trait object directly instead of requiring the raw PgPool. -async fn count_content_items(pool: &sqlx::PgPool) -> Result { +// trait object directly instead of requiring the raw MySqlPool. +async fn count_content_items(pool: &sqlx::MySqlPool) -> Result { let (count,): (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM dav_items WHERE sub_type NOT IN (102, 103, 104, 105, 106)", ) diff --git a/crates/stackarr-web/src/routes/discover.rs b/crates/stackarr-web/src/routes/discover.rs index b4f9e782..060bba2f 100644 --- a/crates/stackarr-web/src/routes/discover.rs +++ b/crates/stackarr-web/src/routes/discover.rs @@ -388,6 +388,19 @@ async fn get_movies_by_keyword( // ── Discover Sliders CRUD ─────────────────────────────────────────────────── +async fn load_slider( + pool: &sqlx::MySqlPool, + slider_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, DiscoverSlider>( + "SELECT id, slider_type, display_order, is_built_in, enabled, title, custom_data, created_at, updated_at \ + FROM discover_sliders WHERE id = ?", + ) + .bind(slider_id) + .fetch_optional(pool) + .await +} + async fn list_sliders(State(state): State>) -> impl IntoResponse { let pool = state.db.pool(); match sqlx::query_as::<_, DiscoverSlider>( @@ -425,19 +438,25 @@ async fn create_slider( .and_then(|v| v.as_str().map(|s| s.to_string())) .unwrap_or_default(); - match sqlx::query_as::<_, DiscoverSlider>( + let created = async { + let result = sqlx::query( "INSERT INTO discover_sliders (slider_type, display_order, is_built_in, enabled, title, custom_data) \ - VALUES ($1, $2, false, $3, $4, $5) \ - RETURNING id, slider_type, display_order, is_built_in, enabled, title, custom_data, created_at, updated_at", - ) - .bind(&slider_type) - .bind(next_order) - .bind(input.enabled.unwrap_or(true)) - .bind(&input.title) - .bind(&input.custom_data) - .fetch_one(pool) - .await - { + VALUES (?, ?, false, ?, ?, ?)", + ) + .bind(&slider_type) + .bind(next_order) + .bind(input.enabled.unwrap_or(true)) + .bind(&input.title) + .bind(&input.custom_data) + .execute(pool) + .await?; + load_slider(pool, result.last_insert_id() as i64) + .await? + .ok_or(sqlx::Error::RowNotFound) + } + .await; + + match created { Ok(slider) => (StatusCode::CREATED, Json(slider)).into_response(), Err(e) => { tracing::error!(error = %e, "failed to create discover slider"); @@ -454,13 +473,7 @@ async fn update_slider( let pool = state.db.pool(); // Build dynamic update - let existing = sqlx::query_as::<_, DiscoverSlider>( - "SELECT id, slider_type, display_order, is_built_in, enabled, title, custom_data, created_at, updated_at \ - FROM discover_sliders WHERE id = $1", - ) - .bind(slider_id) - .fetch_optional(pool) - .await; + let existing = load_slider(pool, slider_id).await; let existing = match existing { Ok(Some(s)) => s, @@ -479,18 +492,24 @@ async fn update_slider( existing.custom_data }; - match sqlx::query_as::<_, DiscoverSlider>( - "UPDATE discover_sliders SET title = $1, enabled = $2, custom_data = $3, updated_at = NOW() \ - WHERE id = $4 \ - RETURNING id, slider_type, display_order, is_built_in, enabled, title, custom_data, created_at, updated_at", - ) - .bind(&new_title) - .bind(new_enabled) - .bind(&new_custom_data) - .bind(slider_id) - .fetch_one(pool) - .await - { + let updated = async { + sqlx::query( + "UPDATE discover_sliders SET title = ?, enabled = ?, custom_data = ?, updated_at = NOW() \ + WHERE id = ?", + ) + .bind(&new_title) + .bind(new_enabled) + .bind(&new_custom_data) + .bind(slider_id) + .execute(pool) + .await?; + load_slider(pool, slider_id) + .await? + .ok_or(sqlx::Error::RowNotFound) + } + .await; + + match updated { Ok(slider) => Json(slider).into_response(), Err(e) => { tracing::error!(error = %e, slider_id, "failed to update discover slider"); @@ -507,7 +526,7 @@ async fn delete_slider( // Don't allow deleting built-in sliders let is_built_in: Option<(bool,)> = - sqlx::query_as("SELECT is_built_in FROM discover_sliders WHERE id = $1") + sqlx::query_as("SELECT is_built_in FROM discover_sliders WHERE id = ?") .bind(slider_id) .fetch_optional(pool) .await @@ -526,7 +545,7 @@ async fn delete_slider( _ => {} } - match sqlx::query("DELETE FROM discover_sliders WHERE id = $1") + match sqlx::query("DELETE FROM discover_sliders WHERE id = ?") .bind(slider_id) .execute(pool) .await @@ -548,7 +567,7 @@ async fn reorder_sliders( for (idx, slider_id) in input.slider_ids.iter().enumerate() { let order = (idx + 1) as i32; if let Err(e) = sqlx::query( - "UPDATE discover_sliders SET display_order = $1, updated_at = NOW() WHERE id = $2", + "UPDATE discover_sliders SET display_order = ?, updated_at = NOW() WHERE id = ?", ) .bind(order) .bind(slider_id) @@ -590,8 +609,8 @@ async fn reset_sliders(State(state): State>) -> impl IntoResponse for (slider_type, order) in defaults { let _ = sqlx::query( - "UPDATE discover_sliders SET display_order = $1, enabled = true, updated_at = NOW() \ - WHERE slider_type = $2 AND is_built_in = true", + "UPDATE discover_sliders SET display_order = ?, enabled = true, updated_at = NOW() \ + WHERE slider_type = ? AND is_built_in = true", ) .bind(order) .bind(slider_type) @@ -632,24 +651,44 @@ async fn discover_search( let tmdb_ids: Vec = results.results.iter().map(|r| r.id).collect(); - let library_ids: HashSet = - sqlx::query_scalar::<_, i64>("SELECT tmdb_id FROM series WHERE tmdb_id = ANY($1)") - .bind(&tmdb_ids) + let library_ids: HashSet = if tmdb_ids.is_empty() { + HashSet::new() + } else { + let mut query = + sqlx::QueryBuilder::new("SELECT tmdb_id FROM series WHERE tmdb_id IN ("); + let mut ids = query.separated(", "); + for id in &tmdb_ids { + ids.push_bind(id); + } + ids.push_unseparated(")"); + query + .build_query_scalar::() .fetch_all(pool) .await .unwrap_or_default() .into_iter() - .collect(); + .collect() + }; - let request_statuses: HashMap = sqlx::query_as::<_, (i64, String)>( - "SELECT tmdb_id, status FROM media_requests WHERE tmdb_id = ANY($1) AND media_type = 'series'", - ) - .bind(&tmdb_ids) - .fetch_all(pool) - .await - .unwrap_or_default() - .into_iter() - .collect(); + let request_statuses: HashMap = if tmdb_ids.is_empty() { + HashMap::new() + } else { + let mut query = sqlx::QueryBuilder::new( + "SELECT tmdb_id, status FROM media_requests WHERE tmdb_id IN (", + ); + let mut ids = query.separated(", "); + for id in &tmdb_ids { + ids.push_bind(id); + } + ids.push_unseparated(") AND media_type = 'series'"); + query + .build_query_as::<(i64, String)>() + .fetch_all(pool) + .await + .unwrap_or_default() + .into_iter() + .collect() + }; let enriched: Vec<_> = results .results @@ -685,24 +724,44 @@ async fn discover_search( let tmdb_ids: Vec = results.results.iter().map(|r| r.id).collect(); - let library_ids: HashSet = - sqlx::query_scalar::<_, i64>("SELECT tmdb_id FROM movies WHERE tmdb_id = ANY($1)") - .bind(&tmdb_ids) + let library_ids: HashSet = if tmdb_ids.is_empty() { + HashSet::new() + } else { + let mut query = + sqlx::QueryBuilder::new("SELECT tmdb_id FROM movies WHERE tmdb_id IN ("); + let mut ids = query.separated(", "); + for id in &tmdb_ids { + ids.push_bind(id); + } + ids.push_unseparated(")"); + query + .build_query_scalar::() .fetch_all(pool) .await .unwrap_or_default() .into_iter() - .collect(); + .collect() + }; - let request_statuses: HashMap = sqlx::query_as::<_, (i64, String)>( - "SELECT tmdb_id, status FROM media_requests WHERE tmdb_id = ANY($1) AND media_type = 'movie'", - ) - .bind(&tmdb_ids) - .fetch_all(pool) - .await - .unwrap_or_default() - .into_iter() - .collect(); + let request_statuses: HashMap = if tmdb_ids.is_empty() { + HashMap::new() + } else { + let mut query = sqlx::QueryBuilder::new( + "SELECT tmdb_id, status FROM media_requests WHERE tmdb_id IN (", + ); + let mut ids = query.separated(", "); + for id in &tmdb_ids { + ids.push_bind(id); + } + ids.push_unseparated(") AND media_type = 'movie'"); + query + .build_query_as::<(i64, String)>() + .fetch_all(pool) + .await + .unwrap_or_default() + .into_iter() + .collect() + }; let enriched: Vec<_> = results .results diff --git a/crates/stackarr-web/src/routes/downloadclients.rs b/crates/stackarr-web/src/routes/downloadclients.rs index 20b2fe56..69e73a0f 100644 --- a/crates/stackarr-web/src/routes/downloadclients.rs +++ b/crates/stackarr-web/src/routes/downloadclients.rs @@ -49,8 +49,8 @@ pub struct UpdateDownloadClientRequest { } /// Read an embedded engine's priority from app_config, defaulting to 0. -async fn embedded_priority(pool: &sqlx::PgPool, key: &str) -> i32 { - sqlx::query_scalar::<_, serde_json::Value>("SELECT value FROM app_config WHERE key = $1") +async fn embedded_priority(pool: &sqlx::MySqlPool, key: &str) -> i32 { + sqlx::query_scalar::<_, serde_json::Value>("SELECT value FROM app_config WHERE key = ?") .bind(key) .fetch_optional(pool) .await @@ -178,10 +178,9 @@ pub async fn create_download_client( let enabled = body.enabled.unwrap_or(true); let priority = body.priority.unwrap_or(1); - match sqlx::query_as::<_, DownloadClientResponse>( + match sqlx::query( "INSERT INTO download_clients (name, client_type, protocol, config, enabled, priority) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING id, name, client_type, protocol, config, enabled, priority", + VALUES (?, ?, ?, ?, ?, ?)", ) .bind(body.name.trim()) .bind(&body.client_type) @@ -189,10 +188,23 @@ pub async fn create_download_client( .bind(&body.config) .bind(enabled) .bind(priority) - .fetch_one(pool) + .execute(pool) .await { - Ok(client) => { + Ok(result) => { + let client = match sqlx::query_as::<_, DownloadClientResponse>( + "SELECT id, name, client_type, protocol, config, enabled, priority FROM download_clients WHERE id = ?", + ) + .bind(result.last_insert_id() as i32) + .fetch_one(pool) + .await + { + Ok(client) => client, + Err(e) => { + tracing::error!(error = %e, "failed to load created download client"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; let mut value = serde_json::to_value(&client).unwrap_or_default(); redact_sensitive_fields(&mut value); (StatusCode::CREATED, Json(value)).into_response() @@ -234,8 +246,8 @@ pub async fn update_download_client( }; if let Some(priority) = body.priority && let Err(e) = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ($1, $2) - ON CONFLICT (key) DO UPDATE SET value = $2", + "INSERT INTO app_config (key, value) VALUES (?, ?) + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(key) .bind(json!(priority)) @@ -277,16 +289,15 @@ pub async fn update_download_client( .into_response(); } - match sqlx::query_as::<_, DownloadClientResponse>( + let update = sqlx::query( "UPDATE download_clients SET - name = COALESCE($1, name), - client_type = COALESCE($2, client_type), - protocol = COALESCE($3, protocol), - config = COALESCE($4, config), - enabled = COALESCE($5, enabled), - priority = COALESCE($6, priority) - WHERE id = $7 - RETURNING id, name, client_type, protocol, config, enabled, priority", + name = COALESCE(?, name), + client_type = COALESCE(?, client_type), + protocol = COALESCE(?, protocol), + config = COALESCE(?, config), + enabled = COALESCE(?, enabled), + priority = COALESCE(?, priority) + WHERE id = ?", ) .bind(body.name.as_deref().map(str::trim)) .bind(&body.client_type) @@ -295,9 +306,16 @@ pub async fn update_download_client( .bind(body.enabled) .bind(body.priority) .bind(id as i32) - .fetch_optional(pool) - .await - { + .execute(pool) + .await; + match update { + Ok(_) => match sqlx::query_as::<_, DownloadClientResponse>( + "SELECT id, name, client_type, protocol, config, enabled, priority FROM download_clients WHERE id = ?", + ) + .bind(id as i32) + .fetch_optional(pool) + .await + { Ok(Some(client)) => { let mut value = serde_json::to_value(&client).unwrap_or_default(); redact_sensitive_fields(&mut value); @@ -315,6 +333,10 @@ pub async fn update_download_client( Json(json!({"error": "internal server error"})), ) .into_response() + }}, + Err(e) => { + tracing::error!(error = %e, "failed to update download client"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() } } } @@ -335,7 +357,7 @@ pub async fn delete_download_client( ) -> impl IntoResponse { let pool = state.db.pool(); - match sqlx::query("DELETE FROM download_clients WHERE id = $1") + match sqlx::query("DELETE FROM download_clients WHERE id = ?") .bind(id as i32) .execute(pool) .await @@ -380,7 +402,7 @@ pub async fn test_download_client( // Load client config from DB let row: Option<(String, serde_json::Value)> = - match sqlx::query_as("SELECT client_type, config FROM download_clients WHERE id = $1") + match sqlx::query_as("SELECT client_type, config FROM download_clients WHERE id = ?") .bind(id as i32) .fetch_optional(pool) .await diff --git a/crates/stackarr-web/src/routes/episodes.rs b/crates/stackarr-web/src/routes/episodes.rs index 00efcfee..80fda8fc 100644 --- a/crates/stackarr-web/src/routes/episodes.rs +++ b/crates/stackarr-web/src/routes/episodes.rs @@ -83,12 +83,18 @@ fn enrich_episode(ep: EpisodeRow, files: &HashMap) -> EpisodeRes } } -async fn fetch_media_files(pool: &sqlx::PgPool, file_ids: &[i64]) -> HashMap { +async fn fetch_media_files(pool: &sqlx::MySqlPool, file_ids: &[i64]) -> HashMap { if file_ids.is_empty() { return HashMap::new(); } - sqlx::query_as::<_, MediaFile>("SELECT * FROM media_files WHERE id = ANY($1)") - .bind(file_ids) + let mut query = sqlx::QueryBuilder::new("SELECT * FROM media_files WHERE id IN ("); + let mut ids = query.separated(", "); + for id in file_ids { + ids.push_bind(id); + } + ids.push_unseparated(")"); + query + .build_query_as::() .fetch_all(pool) .await .unwrap_or_default() @@ -110,7 +116,7 @@ async fn list_episodes_for_series( title, overview, air_date, air_date_utc, runtime, monitored, episode_file_id, last_search_time FROM episodes - WHERE series_id = $1 + WHERE series_id = ? ORDER BY season_number, episode_number", ) .bind(series_id) @@ -148,7 +154,7 @@ async fn get_episode(State(state): State>, Path(id): Path) -> title, overview, air_date, air_date_utc, runtime, monitored, episode_file_id, last_search_time FROM episodes - WHERE id = $1", + WHERE id = ?", ) .bind(id) .fetch_optional(pool) @@ -191,7 +197,7 @@ async fn update_episode( let pool = state.db.pool(); if let Some(monitored) = body.monitored { - let result = sqlx::query("UPDATE episodes SET monitored = $1 WHERE id = $2") + let result = sqlx::query("UPDATE episodes SET monitored = ? WHERE id = ?") .bind(monitored) .bind(id) .execute(pool) @@ -224,7 +230,7 @@ async fn update_episode( title, overview, air_date, air_date_utc, runtime, monitored, episode_file_id, last_search_time FROM episodes - WHERE id = $1", + WHERE id = ?", ) .bind(id) .fetch_optional(pool) @@ -274,11 +280,14 @@ async fn bulk_monitor( .into_response(); } - let result = sqlx::query("UPDATE episodes SET monitored = $1 WHERE id = ANY($2)") - .bind(body.monitored) - .bind(&body.episode_ids) - .execute(pool) - .await; + let mut query = sqlx::QueryBuilder::new("UPDATE episodes SET monitored = "); + query.push_bind(body.monitored).push(" WHERE id IN ("); + let mut ids = query.separated(", "); + for id in &body.episode_ids { + ids.push_bind(id); + } + ids.push_unseparated(")"); + let result = query.build().execute(pool).await; match result { Ok(r) => Json(json!({ diff --git a/crates/stackarr-web/src/routes/general.rs b/crates/stackarr-web/src/routes/general.rs index 06a5977b..ca371336 100644 --- a/crates/stackarr-web/src/routes/general.rs +++ b/crates/stackarr-web/src/routes/general.rs @@ -62,8 +62,8 @@ async fn put_general( if let Some(name) = &body.instance_name { let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('instance_name', $1::jsonb) - ON CONFLICT (key) DO UPDATE SET value = $1::jsonb", + "INSERT INTO app_config (key, value) VALUES ('instance_name', ?) + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(serde_json::json!(name)) .execute(pool) @@ -72,8 +72,8 @@ async fn put_general( if let Some(method) = &body.auth_method { let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('auth_method', $1::jsonb) - ON CONFLICT (key) DO UPDATE SET value = $1::jsonb", + "INSERT INTO app_config (key, value) VALUES ('auth_method', ?) + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(serde_json::json!(method)) .execute(pool) @@ -83,8 +83,8 @@ async fn put_general( if let Some(strategy) = &body.grab_strategy { let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('grab_strategy', $1::jsonb) - ON CONFLICT (key) DO UPDATE SET value = $1::jsonb", + "INSERT INTO app_config (key, value) VALUES ('grab_strategy', ?) + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(serde_json::json!(strategy)) .execute(pool) @@ -189,8 +189,8 @@ async fn put_bootstrap_config( if let Some(enabled) = body.enabled { let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('bootstrap_enabled', $1::jsonb) - ON CONFLICT (key) DO UPDATE SET value = $1::jsonb", + "INSERT INTO app_config (key, value) VALUES ('bootstrap_enabled', ?) + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(serde_json::json!(enabled)) .execute(pool) @@ -199,8 +199,8 @@ async fn put_bootstrap_config( if let Some(url) = &body.url { let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('bootstrap_url', $1::jsonb) - ON CONFLICT (key) DO UPDATE SET value = $1::jsonb", + "INSERT INTO app_config (key, value) VALUES ('bootstrap_url', ?) + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(serde_json::json!(url)) .execute(pool) @@ -209,8 +209,8 @@ async fn put_bootstrap_config( if let Some(token) = &body.token { let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('bootstrap_token', $1::jsonb) - ON CONFLICT (key) DO UPDATE SET value = $1::jsonb", + "INSERT INTO app_config (key, value) VALUES ('bootstrap_token', ?) + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(serde_json::json!(token)) .execute(pool) @@ -219,8 +219,8 @@ async fn put_bootstrap_config( if let Some(port) = body.advertise_port { let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('bootstrap_advertise_port', $1::jsonb) - ON CONFLICT (key) DO UPDATE SET value = $1::jsonb", + "INSERT INTO app_config (key, value) VALUES ('bootstrap_advertise_port', ?) + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(serde_json::json!(port)) .execute(pool) @@ -229,8 +229,8 @@ async fn put_bootstrap_config( if let Some(upnp) = body.upnp_enabled { let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('bootstrap_upnp_enabled', $1::jsonb) - ON CONFLICT (key) DO UPDATE SET value = $1::jsonb", + "INSERT INTO app_config (key, value) VALUES ('bootstrap_upnp_enabled', ?) + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(serde_json::json!(upnp)) .execute(pool) @@ -239,8 +239,8 @@ async fn put_bootstrap_config( if let Some(name) = &body.discovery_name { let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('discovery_name', $1::jsonb) - ON CONFLICT (key) DO UPDATE SET value = $1::jsonb", + "INSERT INTO app_config (key, value) VALUES ('discovery_name', ?) + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(serde_json::json!(name)) .execute(pool) @@ -413,13 +413,13 @@ async fn put_storage_config( let pool = state.db.pool(); async fn set_json( - pool: &sqlx::PgPool, + pool: &sqlx::MySqlPool, key: &str, value: serde_json::Value, ) -> Result<(), sqlx::Error> { sqlx::query( - "INSERT INTO app_config (key, value) VALUES ($1, $2::jsonb) - ON CONFLICT (key) DO UPDATE SET value = $2::jsonb", + "INSERT INTO app_config (key, value) VALUES (?, ?) + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(key) .bind(value) diff --git a/crates/stackarr-web/src/routes/history.rs b/crates/stackarr-web/src/routes/history.rs index eb0b03aa..67a3d061 100644 --- a/crates/stackarr-web/src/routes/history.rs +++ b/crates/stackarr-web/src/routes/history.rs @@ -123,7 +123,7 @@ async fn list_history( let (events_result, indexers_result) = tokio::join!( sqlx::query_as::<_, HistoryEvent>( - "SELECT * FROM history ORDER BY occurred_at DESC LIMIT $1 OFFSET $2", + "SELECT * FROM history ORDER BY occurred_at DESC LIMIT ? OFFSET ?", ) .bind(params.page_size) .bind(offset) @@ -166,7 +166,7 @@ async fn recent_events( let (events_result, indexers_result) = tokio::join!( sqlx::query_as::<_, HistoryEvent>( - "SELECT * FROM history ORDER BY occurred_at DESC LIMIT $1", + "SELECT * FROM history ORDER BY occurred_at DESC LIMIT ?", ) .bind(limit) .fetch_all(pool), diff --git a/crates/stackarr-web/src/routes/import_candidates.rs b/crates/stackarr-web/src/routes/import_candidates.rs index 64df8a4a..298105c2 100644 --- a/crates/stackarr-web/src/routes/import_candidates.rs +++ b/crates/stackarr-web/src/routes/import_candidates.rs @@ -127,7 +127,7 @@ pub async fn accept( // Look up folder (for media_type + root path needed by the rescan). let folder: Option<(String, String)> = - match sqlx::query_as("SELECT path, media_type FROM media_library_folders WHERE id = $1") + match sqlx::query_as("SELECT path, media_type FROM media_library_folders WHERE id = ?") .bind(folder_id) .fetch_optional(pool) .await @@ -231,7 +231,7 @@ struct AcceptOutcome { } async fn accept_series( - pool: &sqlx::PgPool, + pool: &sqlx::MySqlPool, candidate: &ImportCandidate, tmdb_id: i64, folder_id: i32, @@ -247,24 +247,24 @@ async fn accept_series( let clean = stackarr_parser::clean_title(&title); // Insert minimal series row pointing at the on-disk folder. - let row: (i64,) = sqlx::query_as( + let result = sqlx::query( "INSERT INTO series ( title, clean_title, sort_title, path, quality_profile_id, monitored, media_library_folder_id, tmdb_id - ) VALUES ($1, $2, $2, $3, $4, $5, $6, $7) - RETURNING id", + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&title) .bind(&clean) + .bind(&clean) .bind(&candidate.discovered_path) .bind(quality_profile_id) .bind(monitored) .bind(folder_id) .bind(tmdb_id) - .fetch_one(pool) + .execute(pool) .await .map_err(|e| format!("failed to insert series: {e}"))?; - let series_id = row.0; + let series_id = result.last_insert_id() as i64; // Inline TMDB enrichment — populate metadata + episodes so the rescan // can actually match season/episode from filenames. @@ -300,16 +300,16 @@ async fn accept_series( let tvdb_id = detail.external_ids.as_ref().and_then(|e| e.tvdb_id); let imdb_id = detail.external_ids.as_ref().and_then(|e| e.imdb_id.clone()); let _ = sqlx::query( - "UPDATE series SET overview = $1, status = $2::text::series_status, network = $3, - images = $4, genres = $5, year = $6, runtime = $7, tvdb_id = COALESCE($8, tvdb_id), - imdb_id = COALESCE($9, imdb_id), last_info_sync = NOW() - WHERE id = $10", + "UPDATE series SET overview = ?, status = ?, network = ?, + images = ?, genres = ?, year = ?, runtime = ?, tvdb_id = COALESCE(?, tvdb_id), + imdb_id = COALESCE(?, imdb_id), last_info_sync = NOW() + WHERE id = ?", ) .bind(&detail.overview) .bind(status_str) .bind(network) .bind(&images_json) - .bind(&genres) + .bind(sqlx::types::Json(&genres)) .bind(year) .bind(runtime) .bind(tvdb_id) @@ -324,11 +324,10 @@ async fn accept_series( if let Ok(season) = client.get_season(tmdb_id, season_num).await { for ep in &season.episodes { let _ = sqlx::query( - "INSERT INTO episodes ( + "INSERT IGNORE INTO episodes ( series_id, season_number, episode_number, title, overview, air_date, runtime, monitored - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT (series_id, season_number, episode_number) DO NOTHING", + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(series_id) .bind(ep.season_number) @@ -352,7 +351,7 @@ async fn accept_series( } async fn accept_movie( - pool: &sqlx::PgPool, + pool: &sqlx::MySqlPool, candidate: &ImportCandidate, tmdb_id: i64, folder_id: i32, @@ -377,25 +376,25 @@ async fn accept_movie( let year = candidate.suggested_year.or(candidate.parsed_year); - let row: (i64,) = sqlx::query_as( + let result = sqlx::query( "INSERT INTO movies ( title, clean_title, sort_title, path, quality_profile_id, monitored, media_library_folder_id, tmdb_id, year - ) VALUES ($1, $2, $2, $3, $4, $5, $6, $7, $8) - RETURNING id", + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&title) .bind(&clean) + .bind(&clean) .bind(&movie_path) .bind(quality_profile_id) .bind(monitored) .bind(folder_id) .bind(tmdb_id) .bind(year) - .fetch_one(pool) + .execute(pool) .await .map_err(|e| format!("failed to insert movie: {e}"))?; - let movie_id = row.0; + let movie_id = result.last_insert_id() as i64; // Inline TMDB enrichment. if let Some(client) = tmdb_client.as_ref() @@ -413,13 +412,13 @@ async fn accept_movie( let imdb_id = detail.imdb_id.clone(); let runtime = detail.runtime; let _ = sqlx::query( - "UPDATE movies SET overview = $1, images = $2, genres = $3, - runtime = $4, imdb_id = COALESCE($5, imdb_id), last_info_sync = NOW() - WHERE id = $6", + "UPDATE movies SET overview = ?, images = ?, genres = ?, + runtime = ?, imdb_id = COALESCE(?, imdb_id), last_info_sync = NOW() + WHERE id = ?", ) .bind(&detail.overview) .bind(&images_json) - .bind(&genres) + .bind(sqlx::types::Json(&genres)) .bind(runtime) .bind(&imdb_id) .bind(movie_id) diff --git a/crates/stackarr-web/src/routes/indexers.rs b/crates/stackarr-web/src/routes/indexers.rs index 3c4be114..d7c9146f 100644 --- a/crates/stackarr-web/src/routes/indexers.rs +++ b/crates/stackarr-web/src/routes/indexers.rs @@ -21,6 +21,7 @@ struct IndexerResponse { base_url: String, api_key: Option, protocol: String, + #[sqlx(json(nullable))] categories: Option>, enabled: bool, priority: i32, @@ -66,6 +67,20 @@ struct UpdateIndexerRequest { // CRUD endpoints (existing) // --------------------------------------------------------------------------- +async fn load_indexer( + pool: &sqlx::MySqlPool, + id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, IndexerResponse>( + "SELECT id, name, indexer_type, base_url, api_key, protocol, categories, + enabled, priority, supports_search, supports_rss, config, last_rss_sync + FROM indexers WHERE id = ?", + ) + .bind(id) + .fetch_optional(pool) + .await +} + async fn list_indexers(State(state): State>) -> impl IntoResponse { let pool = state.db.pool(); @@ -152,27 +167,32 @@ async fn create_indexer( let supports_search = body.supports_search.unwrap_or(true); let supports_rss = body.supports_rss.unwrap_or(true); - match sqlx::query_as::<_, IndexerResponse>( - "INSERT INTO indexers (name, indexer_type, base_url, api_key, protocol, categories, + let created = async { + let result = sqlx::query( + "INSERT INTO indexers (name, indexer_type, base_url, api_key, protocol, categories, enabled, priority, supports_search, supports_rss, config) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - RETURNING id, name, indexer_type, base_url, api_key, protocol, categories, - enabled, priority, supports_search, supports_rss, config, last_rss_sync", - ) - .bind(body.name.trim()) - .bind(&body.indexer_type) - .bind(body.base_url.trim()) - .bind(&body.api_key) - .bind(&body.protocol) - .bind(&body.categories) - .bind(enabled) - .bind(priority) - .bind(supports_search) - .bind(supports_rss) - .bind(&body.config) - .fetch_one(pool) - .await - { + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(body.name.trim()) + .bind(&body.indexer_type) + .bind(body.base_url.trim()) + .bind(&body.api_key) + .bind(&body.protocol) + .bind(body.categories.as_ref().map(sqlx::types::Json)) + .bind(enabled) + .bind(priority) + .bind(supports_search) + .bind(supports_rss) + .bind(&body.config) + .execute(pool) + .await?; + load_indexer(pool, result.last_insert_id() as i64) + .await? + .ok_or(sqlx::Error::RowNotFound) + } + .await; + + match created { Ok(indexer) => { // Register the new indexer in the manager for immediate search register_indexer_in_manager(&state, &indexer).await; @@ -203,8 +223,8 @@ async fn update_indexer( if let Some(priority) = body.priority { let val = serde_json::Value::Number(serde_json::Number::from(priority)); let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('indexarr_priority', $1) - ON CONFLICT (key) DO UPDATE SET value = $1", + "INSERT INTO app_config (key, value) VALUES ('indexarr_priority', ?) + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(&val) .execute(pool) @@ -214,38 +234,41 @@ async fn update_indexer( .into_response(); } - match sqlx::query_as::<_, IndexerResponse>( - "UPDATE indexers SET - name = COALESCE($1, name), - indexer_type = COALESCE($2, indexer_type), - base_url = COALESCE($3, base_url), - api_key = COALESCE($4, api_key), - protocol = COALESCE($5, protocol), - categories = COALESCE($6, categories), - enabled = COALESCE($7, enabled), - priority = COALESCE($8, priority), - supports_search = COALESCE($9, supports_search), - supports_rss = COALESCE($10, supports_rss), - config = COALESCE($11, config) - WHERE id = $12 - RETURNING id, name, indexer_type, base_url, api_key, protocol, categories, - enabled, priority, supports_search, supports_rss, config, last_rss_sync", - ) - .bind(body.name.as_deref().map(str::trim)) - .bind(&body.indexer_type) - .bind(body.base_url.as_deref().map(str::trim)) - .bind(&body.api_key) - .bind(&body.protocol) - .bind(&body.categories) - .bind(body.enabled) - .bind(body.priority) - .bind(body.supports_search) - .bind(body.supports_rss) - .bind(&body.config) - .bind(id as i32) - .fetch_optional(pool) - .await - { + let updated = async { + sqlx::query( + "UPDATE indexers SET + name = COALESCE(?, name), + indexer_type = COALESCE(?, indexer_type), + base_url = COALESCE(?, base_url), + api_key = COALESCE(?, api_key), + protocol = COALESCE(?, protocol), + categories = COALESCE(?, categories), + enabled = COALESCE(?, enabled), + priority = COALESCE(?, priority), + supports_search = COALESCE(?, supports_search), + supports_rss = COALESCE(?, supports_rss), + config = COALESCE(?, config) + WHERE id = ?", + ) + .bind(body.name.as_deref().map(str::trim)) + .bind(&body.indexer_type) + .bind(body.base_url.as_deref().map(str::trim)) + .bind(&body.api_key) + .bind(&body.protocol) + .bind(body.categories.as_ref().map(sqlx::types::Json)) + .bind(body.enabled) + .bind(body.priority) + .bind(body.supports_search) + .bind(body.supports_rss) + .bind(&body.config) + .bind(id) + .execute(pool) + .await?; + load_indexer(pool, id).await + } + .await; + + match updated { Ok(Some(indexer)) => { // Update the indexer in the manager let mut mgr = state.indexer_manager.write().await; @@ -287,7 +310,7 @@ async fn delete_indexer( let pool = state.db.pool(); - match sqlx::query("DELETE FROM indexers WHERE id = $1") + match sqlx::query("DELETE FROM indexers WHERE id = ?") .bind(id as i32) .execute(pool) .await @@ -332,7 +355,7 @@ async fn test_indexer( String, Option, )> = match sqlx::query_as( - "SELECT indexer_type, base_url, api_key, protocol, config FROM indexers WHERE id = $1", + "SELECT indexer_type, base_url, api_key, protocol, config FROM indexers WHERE id = ?", ) .bind(id as i32) .fetch_optional(pool) @@ -363,7 +386,7 @@ async fn test_indexer( if indexer_type.eq_ignore_ascii_case("cardigann") { // Load config from DB to build the Cardigann indexer let config_json: Option = - sqlx::query_scalar("SELECT config FROM indexers WHERE id = $1") + sqlx::query_scalar("SELECT config FROM indexers WHERE id = ?") .bind(id as i32) .fetch_optional(pool) .await @@ -494,7 +517,7 @@ async fn test_indexer( // If URL was auto-corrected, update the DB if url_changed { - let _ = sqlx::query("UPDATE indexers SET base_url = $1 WHERE id = $2") + let _ = sqlx::query("UPDATE indexers SET base_url = ? WHERE id = ?") .bind(candidate_url) .bind(id as i32) .execute(state.db.pool()) @@ -503,7 +526,7 @@ async fn test_indexer( if let Ok(Some(updated_row)) = sqlx::query_as::<_, IndexerResponse>( "SELECT id, name, indexer_type, base_url, api_key, protocol, categories, enabled, priority, supports_search, supports_rss, config, last_rss_sync - FROM indexers WHERE id = $1", + FROM indexers WHERE id = ?", ) .bind(id as i32) .fetch_optional(state.db.pool()) diff --git a/crates/stackarr-web/src/routes/manual_import.rs b/crates/stackarr-web/src/routes/manual_import.rs index 7861ffec..74c1de01 100644 --- a/crates/stackarr-web/src/routes/manual_import.rs +++ b/crates/stackarr-web/src/routes/manual_import.rs @@ -304,13 +304,13 @@ async fn run_manual_import( // Verify target exists let exists: Option<(i64,)> = match media_type { "series" => { - sqlx::query_as("SELECT id FROM series WHERE id = $1") + sqlx::query_as("SELECT id FROM series WHERE id = ?") .bind(media_id) .fetch_optional(pool) .await? } "movie" => { - sqlx::query_as("SELECT id FROM movies WHERE id = $1") + sqlx::query_as("SELECT id FROM movies WHERE id = ?") .bind(media_id) .fetch_optional(pool) .await? @@ -365,13 +365,14 @@ async fn analyze_path(state: &Arc, raw_path: &str) -> AnalyzeResponse } else { sqlx::query_as( "SELECT id, title, year, images FROM series \ - WHERE clean_title ILIKE '%' || $1 || '%' \ + WHERE clean_title LIKE CONCAT('%', ?, '%') \ ORDER BY \ - CASE WHEN clean_title = $1 THEN 0 ELSE 1 END, \ + CASE WHEN clean_title = ? THEN 0 ELSE 1 END, \ title \ LIMIT 10", ) .bind(&clean) + .bind(&clean) .fetch_all(pool) .await .unwrap_or_default() @@ -394,14 +395,15 @@ async fn analyze_path(state: &Arc, raw_path: &str) -> AnalyzeResponse } else { sqlx::query_as( "SELECT id, title, year, images FROM movies \ - WHERE clean_title ILIKE '%' || $1 || '%' \ + WHERE clean_title LIKE CONCAT('%', ?, '%') \ ORDER BY \ - CASE WHEN clean_title = $1 THEN 0 ELSE 1 END, \ - CASE WHEN year = $2 THEN 0 ELSE 1 END, \ + CASE WHEN clean_title = ? THEN 0 ELSE 1 END, \ + CASE WHEN year = ? THEN 0 ELSE 1 END, \ title \ LIMIT 10", ) .bind(&clean) + .bind(&clean) .bind(parsed.year) .fetch_all(pool) .await @@ -459,7 +461,7 @@ async fn analyze_path(state: &Arc, raw_path: &str) -> AnalyzeResponse #[allow(clippy::too_many_arguments)] async fn build_auto_match( - pool: &sqlx::PgPool, + pool: &sqlx::MySqlPool, clean_title: &str, parsed_year: Option, suggested_media_type: &str, @@ -482,7 +484,7 @@ async fn build_auto_match( (Some(s), Some(e)) => { let row: Option<(i64, Option)> = sqlx::query_as( "SELECT id, title FROM episodes \ - WHERE series_id = $1 AND season_number = $2 AND episode_number = $3", + WHERE series_id = ? AND season_number = ? AND episode_number = ?", ) .bind(id) .bind(s) diff --git a/crates/stackarr-web/src/routes/medialibraryfolders.rs b/crates/stackarr-web/src/routes/medialibraryfolders.rs index d28e966a..b5b2a595 100644 --- a/crates/stackarr-web/src/routes/medialibraryfolders.rs +++ b/crates/stackarr-web/src/routes/medialibraryfolders.rs @@ -149,18 +149,30 @@ async fn create_media_library_folder( .await .unwrap_or((None, None)); - match sqlx::query_as::<_, MediaLibraryFolderRow>( + match sqlx::query( "INSERT INTO media_library_folders (path, media_type, free_space, last_checked) - VALUES ($1, $2, $3, NOW()) - RETURNING id, path, media_type, free_space", + VALUES (?, ?, ?, NOW())", ) .bind(&canonical_str) .bind(media_type) .bind(free_space) - .fetch_one(pool) + .execute(pool) .await { - Ok(row) => { + Ok(result) => { + let row = match sqlx::query_as::<_, MediaLibraryFolderRow>( + "SELECT id, path, media_type, free_space FROM media_library_folders WHERE id = ?", + ) + .bind(result.last_insert_id() as i64) + .fetch_one(pool) + .await + { + Ok(row) => row, + Err(e) => { + tracing::error!(error = %e, "failed to load created media library folder"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; // Trigger a background disk scan for the new folder let scan_path = row.path.clone(); let scan_type = row.media_type.clone(); @@ -236,25 +248,25 @@ async fn delete_media_library_folder( // Unlink any series, movies, and import lists referencing this folder let _ = sqlx::query( - "UPDATE series SET media_library_folder_id = NULL WHERE media_library_folder_id = $1", + "UPDATE series SET media_library_folder_id = NULL WHERE media_library_folder_id = ?", ) .bind(id_i32) .execute(pool) .await; let _ = sqlx::query( - "UPDATE movies SET media_library_folder_id = NULL WHERE media_library_folder_id = $1", + "UPDATE movies SET media_library_folder_id = NULL WHERE media_library_folder_id = ?", ) .bind(id_i32) .execute(pool) .await; let _ = sqlx::query( - "UPDATE import_lists SET media_library_folder_id = NULL WHERE media_library_folder_id = $1", + "UPDATE import_lists SET media_library_folder_id = NULL WHERE media_library_folder_id = ?", ) .bind(id_i32) .execute(pool) .await; - match sqlx::query("DELETE FROM media_library_folders WHERE id = $1") + match sqlx::query("DELETE FROM media_library_folders WHERE id = ?") .bind(id_i32) .execute(pool) .await diff --git a/crates/stackarr-web/src/routes/mediamanagement.rs b/crates/stackarr-web/src/routes/mediamanagement.rs index cd96d426..ed2fab13 100644 --- a/crates/stackarr-web/src/routes/mediamanagement.rs +++ b/crates/stackarr-web/src/routes/mediamanagement.rs @@ -67,8 +67,8 @@ async fn put_config( if let Some(path) = &body.recycle_bin_path { let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('recycle_bin_path', $1::jsonb) \ - ON CONFLICT (key) DO UPDATE SET value = $1::jsonb", + "INSERT INTO app_config (key, value) VALUES ('recycle_bin_path', ?) \ + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(serde_json::json!(path)) .execute(pool) @@ -77,8 +77,8 @@ async fn put_config( if let Some(days) = body.recycle_bin_cleanup_days { let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('recycle_bin_cleanup_days', $1::jsonb) \ - ON CONFLICT (key) DO UPDATE SET value = $1::jsonb", + "INSERT INTO app_config (key, value) VALUES ('recycle_bin_cleanup_days', ?) \ + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(serde_json::json!(days)) .execute(pool) diff --git a/crates/stackarr-web/src/routes/movies.rs b/crates/stackarr-web/src/routes/movies.rs index 0ebeb2fa..0ff4da34 100644 --- a/crates/stackarr-web/src/routes/movies.rs +++ b/crates/stackarr-web/src/routes/movies.rs @@ -52,16 +52,19 @@ fn enrich_movie(movie: Movie, files: &HashMap) -> MovieResponse } async fn fetch_media_files( - pool: &sqlx::PgPool, + pool: &sqlx::MySqlPool, file_ids: &[i64], ) -> Result, sqlx::Error> { if file_ids.is_empty() { return Ok(HashMap::new()); } - let rows = sqlx::query_as::<_, MediaFile>("SELECT * FROM media_files WHERE id = ANY($1)") - .bind(file_ids) - .fetch_all(pool) - .await?; + let mut query = sqlx::QueryBuilder::new("SELECT * FROM media_files WHERE id IN ("); + let mut ids = query.separated(", "); + for id in file_ids { + ids.push_bind(id); + } + ids.push_unseparated(")"); + let rows = query.build_query_as::().fetch_all(pool).await?; Ok(rows.into_iter().map(|f| (f.id, f)).collect()) } @@ -400,12 +403,14 @@ pub async fn bulk_update_movies( let mut updated: u64 = 0; if let Some(qp) = body.quality_profile_id { - match sqlx::query("UPDATE movies SET quality_profile_id = $1 WHERE id = ANY($2)") - .bind(qp) - .bind(&body.movie_ids) - .execute(pool) - .await - { + let mut query = sqlx::QueryBuilder::new("UPDATE movies SET quality_profile_id = "); + query.push_bind(qp).push(" WHERE id IN ("); + let mut ids = query.separated(", "); + for id in &body.movie_ids { + ids.push_bind(id); + } + ids.push_unseparated(")"); + match query.build().execute(pool).await { Ok(r) => updated = r.rows_affected(), Err(e) => { tracing::error!(error = %e, "failed to bulk update movies quality_profile_id"); @@ -419,12 +424,14 @@ pub async fn bulk_update_movies( } if let Some(monitored) = body.monitored { - match sqlx::query("UPDATE movies SET monitored = $1 WHERE id = ANY($2)") - .bind(monitored) - .bind(&body.movie_ids) - .execute(pool) - .await - { + let mut query = sqlx::QueryBuilder::new("UPDATE movies SET monitored = "); + query.push_bind(monitored).push(" WHERE id IN ("); + let mut ids = query.separated(", "); + for id in &body.movie_ids { + ids.push_bind(id); + } + ids.push_unseparated(")"); + match query.build().execute(pool).await { Ok(r) => updated = r.rows_affected(), Err(e) => { tracing::error!(error = %e, "failed to bulk update movies monitored"); diff --git a/crates/stackarr-web/src/routes/naming.rs b/crates/stackarr-web/src/routes/naming.rs index 80528f2e..e3c7e69d 100644 --- a/crates/stackarr-web/src/routes/naming.rs +++ b/crates/stackarr-web/src/routes/naming.rs @@ -95,12 +95,12 @@ async fn update_naming_config( if let Some(series) = &body.series && let Err(e) = sqlx::query( "UPDATE naming_config SET - rename_files = COALESCE($1, rename_files), - standard_format = COALESCE($2, standard_format), - daily_format = COALESCE($3, daily_format), - anime_format = COALESCE($4, anime_format), - season_folder_format = COALESCE($5, season_folder_format), - colon_replacement = COALESCE($6, colon_replacement) + rename_files = COALESCE(?, rename_files), + standard_format = COALESCE(?, standard_format), + daily_format = COALESCE(?, daily_format), + anime_format = COALESCE(?, anime_format), + season_folder_format = COALESCE(?, season_folder_format), + colon_replacement = COALESCE(?, colon_replacement) WHERE media_type = 'series'", ) .bind(series.rename_files) @@ -123,10 +123,10 @@ async fn update_naming_config( if let Some(movie) = &body.movie && let Err(e) = sqlx::query( "UPDATE naming_config SET - rename_files = COALESCE($1, rename_files), - movie_format = COALESCE($2, movie_format), - movie_folder_format = COALESCE($3, movie_folder_format), - colon_replacement = COALESCE($4, colon_replacement) + rename_files = COALESCE(?, rename_files), + movie_format = COALESCE(?, movie_format), + movie_folder_format = COALESCE(?, movie_folder_format), + colon_replacement = COALESCE(?, colon_replacement) WHERE media_type = 'movie'", ) .bind(movie.rename_files) diff --git a/crates/stackarr-web/src/routes/notification_providers.rs b/crates/stackarr-web/src/routes/notification_providers.rs index 78f27085..c2c03d03 100644 --- a/crates/stackarr-web/src/routes/notification_providers.rs +++ b/crates/stackarr-web/src/routes/notification_providers.rs @@ -65,6 +65,20 @@ pub struct TestProviderRequest { const VALID_PROVIDER_TYPES: &[&str] = &["webhook", "discord", "telegram", "slack", "email"]; +async fn load_provider( + pool: &sqlx::MySqlPool, + id: i32, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, NotificationProviderResponse>( + "SELECT id, name, provider_type, config, on_grab, on_import, on_upgrade, \ + on_health_issue, on_failure, enabled \ + FROM notification_providers WHERE id = ?", + ) + .bind(id) + .fetch_optional(pool) + .await +} + // ── Handlers ───────────────────────────────────────────────────────────────── /// List all notification providers. @@ -129,15 +143,7 @@ pub async fn get_provider( ) -> impl IntoResponse { let pool = state.db.pool(); - match sqlx::query_as::<_, NotificationProviderResponse>( - "SELECT id, name, provider_type, config, on_grab, on_import, on_upgrade, \ - on_health_issue, on_failure, enabled \ - FROM notification_providers WHERE id = $1", - ) - .bind(id) - .fetch_optional(pool) - .await - { + match load_provider(pool, id).await { Ok(Some(provider)) => { let mut value = serde_json::to_value(&provider).unwrap_or_default(); redact_sensitive_fields(&mut value); @@ -206,24 +212,33 @@ pub async fn create_provider( let on_failure = body.on_failure.unwrap_or(true); let enabled = body.enabled.unwrap_or(true); - match sqlx::query_as::<_, NotificationProviderResponse>( + let created = async { + let result = sqlx::query( "INSERT INTO notification_providers \ (name, provider_type, config, on_grab, on_import, on_upgrade, on_health_issue, on_failure, enabled) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \ - RETURNING id, name, provider_type, config, on_grab, on_import, on_upgrade, on_health_issue, on_failure, enabled", - ) - .bind(body.name.trim()) - .bind(&body.provider_type) - .bind(&body.config) - .bind(on_grab) - .bind(on_import) - .bind(on_upgrade) - .bind(on_health_issue) - .bind(on_failure) - .bind(enabled) - .fetch_one(pool) - .await - { + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(body.name.trim()) + .bind(&body.provider_type) + .bind(&body.config) + .bind(on_grab) + .bind(on_import) + .bind(on_upgrade) + .bind(on_health_issue) + .bind(on_failure) + .bind(enabled) + .execute(pool) + .await?; + let id = i32::try_from(result.last_insert_id()).map_err(|error| { + sqlx::Error::Protocol(format!("notification provider id overflow: {error}")) + })?; + load_provider(pool, id) + .await? + .ok_or(sqlx::Error::RowNotFound) + } + .await; + + match created { Ok(provider) => { let mut value = serde_json::to_value(&provider).unwrap_or_default(); redact_sensitive_fields(&mut value); @@ -273,33 +288,37 @@ pub async fn update_provider( let pool = state.db.pool(); - match sqlx::query_as::<_, NotificationProviderResponse>( - "UPDATE notification_providers SET \ - name = COALESCE($1, name), \ - provider_type = COALESCE($2, provider_type), \ - config = COALESCE($3, config), \ - on_grab = COALESCE($4, on_grab), \ - on_import = COALESCE($5, on_import), \ - on_upgrade = COALESCE($6, on_upgrade), \ - on_health_issue = COALESCE($7, on_health_issue), \ - on_failure = COALESCE($8, on_failure), \ - enabled = COALESCE($9, enabled) \ - WHERE id = $10 \ - RETURNING id, name, provider_type, config, on_grab, on_import, on_upgrade, on_health_issue, on_failure, enabled", - ) - .bind(body.name.as_deref().map(str::trim)) - .bind(&body.provider_type) - .bind(&body.config) - .bind(body.on_grab) - .bind(body.on_import) - .bind(body.on_upgrade) - .bind(body.on_health_issue) - .bind(body.on_failure) - .bind(body.enabled) - .bind(id) - .fetch_optional(pool) - .await - { + let updated = async { + sqlx::query( + "UPDATE notification_providers SET \ + name = COALESCE(?, name), \ + provider_type = COALESCE(?, provider_type), \ + config = COALESCE(?, config), \ + on_grab = COALESCE(?, on_grab), \ + on_import = COALESCE(?, on_import), \ + on_upgrade = COALESCE(?, on_upgrade), \ + on_health_issue = COALESCE(?, on_health_issue), \ + on_failure = COALESCE(?, on_failure), \ + enabled = COALESCE(?, enabled) \ + WHERE id = ?", + ) + .bind(body.name.as_deref().map(str::trim)) + .bind(&body.provider_type) + .bind(&body.config) + .bind(body.on_grab) + .bind(body.on_import) + .bind(body.on_upgrade) + .bind(body.on_health_issue) + .bind(body.on_failure) + .bind(body.enabled) + .bind(id) + .execute(pool) + .await?; + load_provider(pool, id).await + } + .await; + + match updated { Ok(Some(provider)) => { let mut value = serde_json::to_value(&provider).unwrap_or_default(); redact_sensitive_fields(&mut value); @@ -341,7 +360,7 @@ pub async fn delete_provider( ) -> impl IntoResponse { let pool = state.db.pool(); - match sqlx::query("DELETE FROM notification_providers WHERE id = $1") + match sqlx::query("DELETE FROM notification_providers WHERE id = ?") .bind(id) .execute(pool) .await @@ -389,7 +408,7 @@ pub async fn test_saved_provider( let pool = state.db.pool(); let row: Option<(String, serde_json::Value)> = match sqlx::query_as( - "SELECT provider_type, config FROM notification_providers WHERE id = $1", + "SELECT provider_type, config FROM notification_providers WHERE id = ?", ) .bind(id) .fetch_optional(pool) diff --git a/crates/stackarr-web/src/routes/notifications.rs b/crates/stackarr-web/src/routes/notifications.rs index 86ed4bc9..5df6cd6e 100644 --- a/crates/stackarr-web/src/routes/notifications.rs +++ b/crates/stackarr-web/src/routes/notifications.rs @@ -199,7 +199,7 @@ async fn clear_notifications( State(state): State>, ) -> impl IntoResponse { let pool = state.db.pool(); - match sqlx::query("DELETE FROM user_notifications WHERE user_id = $1") + match sqlx::query("DELETE FROM user_notifications WHERE user_id = ?") .bind(auth.0.user_id) .execute(pool) .await diff --git a/crates/stackarr-web/src/routes/plex.rs b/crates/stackarr-web/src/routes/plex.rs index 6dd1d254..12caa2ff 100644 --- a/crates/stackarr-web/src/routes/plex.rs +++ b/crates/stackarr-web/src/routes/plex.rs @@ -16,6 +16,32 @@ use crate::middleware::redact_sensitive_fields; // ── Plex Server CRUD ─────────────────────────────────────────────────────── +async fn load_server( + pool: &sqlx::MySqlPool, + server_id: i32, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, PlexServer>( + "SELECT id, name, machine_id, ip, port, use_ssl, verify_tls, auth_token, web_app_url, webhook_secret, created_at, updated_at \ + FROM plex_servers WHERE id = ?", + ) + .bind(server_id) + .fetch_optional(pool) + .await +} + +async fn load_library( + pool: &sqlx::MySqlPool, + library_id: i32, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, PlexLibrary>( + "SELECT id, plex_server_id, section_id, name, enabled, library_type, last_scan \ + FROM plex_libraries WHERE id = ?", + ) + .bind(library_id) + .fetch_optional(pool) + .await +} + async fn list_servers(State(state): State>) -> impl IntoResponse { let pool = state.db.pool(); match sqlx::query_as::<_, PlexServer>( @@ -63,23 +89,31 @@ async fn create_server( let webhook_secret = uuid::Uuid::new_v4().to_string(); - match sqlx::query_as::<_, PlexServer>( + let created = async { + let result = sqlx::query( "INSERT INTO plex_servers (name, machine_id, ip, port, use_ssl, verify_tls, auth_token, web_app_url, webhook_secret) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \ - RETURNING id, name, machine_id, ip, port, use_ssl, verify_tls, auth_token, web_app_url, webhook_secret, created_at, updated_at", - ) - .bind(&name) - .bind(&machine_id) - .bind(&input.ip) - .bind(port) - .bind(use_ssl) - .bind(verify_tls) - .bind(&input.auth_token) - .bind(&input.web_app_url) - .bind(&webhook_secret) - .fetch_one(pool) - .await - { + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&name) + .bind(&machine_id) + .bind(&input.ip) + .bind(port) + .bind(use_ssl) + .bind(verify_tls) + .bind(&input.auth_token) + .bind(&input.web_app_url) + .bind(&webhook_secret) + .execute(pool) + .await?; + let id = i32::try_from(result.last_insert_id()) + .map_err(|error| sqlx::Error::Protocol(format!("Plex server id overflow: {error}")))?; + load_server(pool, id) + .await? + .ok_or(sqlx::Error::RowNotFound) + } + .await; + + match created { Ok(server) => { let mut value = serde_json::to_value(&server).unwrap_or_default(); redact_sensitive_fields(&mut value); @@ -99,13 +133,7 @@ async fn update_server( ) -> impl IntoResponse { let pool = state.db.pool(); - let existing = sqlx::query_as::<_, PlexServer>( - "SELECT id, name, machine_id, ip, port, use_ssl, verify_tls, auth_token, web_app_url, webhook_secret, created_at, updated_at \ - FROM plex_servers WHERE id = $1", - ) - .bind(server_id) - .fetch_optional(pool) - .await; + let existing = load_server(pool, server_id).await; let existing = match existing { Ok(Some(s)) => s, @@ -124,22 +152,28 @@ async fn update_server( let auth_token = input.auth_token.or(existing.auth_token); let web_app_url = input.web_app_url.or(existing.web_app_url); - match sqlx::query_as::<_, PlexServer>( - "UPDATE plex_servers SET name = $1, ip = $2, port = $3, use_ssl = $4, verify_tls = $5, auth_token = $6, \ - web_app_url = $7, updated_at = NOW() WHERE id = $8 \ - RETURNING id, name, machine_id, ip, port, use_ssl, verify_tls, auth_token, web_app_url, webhook_secret, created_at, updated_at", - ) - .bind(&name) - .bind(&ip) - .bind(port) - .bind(use_ssl) - .bind(verify_tls) - .bind(&auth_token) - .bind(&web_app_url) - .bind(server_id) - .fetch_one(pool) - .await - { + let updated = async { + sqlx::query( + "UPDATE plex_servers SET name = ?, ip = ?, port = ?, use_ssl = ?, verify_tls = ?, auth_token = ?, \ + web_app_url = ?, updated_at = NOW() WHERE id = ?", + ) + .bind(&name) + .bind(&ip) + .bind(port) + .bind(use_ssl) + .bind(verify_tls) + .bind(&auth_token) + .bind(&web_app_url) + .bind(server_id) + .execute(pool) + .await?; + load_server(pool, server_id) + .await? + .ok_or(sqlx::Error::RowNotFound) + } + .await; + + match updated { Ok(server) => { let mut value = serde_json::to_value(&server).unwrap_or_default(); redact_sensitive_fields(&mut value); @@ -157,7 +191,7 @@ async fn delete_server( Path(server_id): Path, ) -> impl IntoResponse { let pool = state.db.pool(); - match sqlx::query("DELETE FROM plex_servers WHERE id = $1") + match sqlx::query("DELETE FROM plex_servers WHERE id = ?") .bind(server_id) .execute(pool) .await @@ -180,14 +214,7 @@ async fn sync_libraries( ) -> impl IntoResponse { let pool = state.db.pool(); - let server = match sqlx::query_as::<_, PlexServer>( - "SELECT id, name, machine_id, ip, port, use_ssl, verify_tls, auth_token, web_app_url, webhook_secret, created_at, updated_at \ - FROM plex_servers WHERE id = $1", - ) - .bind(server_id) - .fetch_optional(pool) - .await - { + let server = match load_server(pool, server_id).await { Ok(Some(s)) => s, Ok(None) => return StatusCode::NOT_FOUND.into_response(), Err(e) => { @@ -226,8 +253,8 @@ async fn sync_libraries( let _ = sqlx::query( "INSERT INTO plex_libraries (plex_server_id, section_id, name, library_type) \ - VALUES ($1, $2, $3, $4) \ - ON CONFLICT (plex_server_id, section_id) DO UPDATE SET name = $3", + VALUES (?, ?, ?, ?) \ + ON DUPLICATE KEY UPDATE name = VALUES(name), library_type = VALUES(library_type)", ) .bind(server_id) .bind(§ion.key) @@ -240,7 +267,7 @@ async fn sync_libraries( // Return the updated list match sqlx::query_as::<_, PlexLibrary>( "SELECT id, plex_server_id, section_id, name, enabled, library_type, last_scan \ - FROM plex_libraries WHERE plex_server_id = $1 ORDER BY id", + FROM plex_libraries WHERE plex_server_id = ? ORDER BY id", ) .bind(server_id) .fetch_all(pool) @@ -260,15 +287,17 @@ async fn update_library( Json(input): Json, ) -> impl IntoResponse { let pool = state.db.pool(); - match sqlx::query_as::<_, PlexLibrary>( - "UPDATE plex_libraries SET enabled = $1 WHERE id = $2 \ - RETURNING id, plex_server_id, section_id, name, enabled, library_type, last_scan", - ) - .bind(input.enabled) - .bind(library_id) - .fetch_optional(pool) - .await - { + let updated = async { + sqlx::query("UPDATE plex_libraries SET enabled = ? WHERE id = ?") + .bind(input.enabled) + .bind(library_id) + .execute(pool) + .await?; + load_library(pool, library_id).await + } + .await; + + match updated { Ok(Some(lib)) => Json(lib).into_response(), Ok(None) => StatusCode::NOT_FOUND.into_response(), Err(e) => { @@ -462,8 +491,8 @@ async fn update_watchlist_config( let pool = state.db.pool(); let result = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('plex_watchlist_auto_request', $1) \ - ON CONFLICT (key) DO UPDATE SET value = $1", + "INSERT INTO app_config (key, value) VALUES ('plex_watchlist_auto_request', ?) \ + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(&body) .execute(pool) @@ -491,7 +520,7 @@ async fn receive_webhook( // Validate secret matches a configured server let server_id: Option = - sqlx::query_scalar("SELECT id FROM plex_servers WHERE webhook_secret = $1") + sqlx::query_scalar("SELECT id FROM plex_servers WHERE webhook_secret = ?") .bind(&secret) .fetch_optional(pool) .await @@ -540,7 +569,7 @@ async fn receive_webhook( // Insert event let _ = sqlx::query( "INSERT INTO plex_events (event_type, plex_server_id, user_name, title, rating_key, metadata, received_at) \ - VALUES ($1, $2, $3, $4, $5, $6, NOW())", + VALUES (?, ?, ?, ?, ?, ?, NOW())", ) .bind(&payload.event) .bind(server_id) @@ -581,7 +610,7 @@ async fn list_events( let events = if let Some(ref event_type) = query.event_type { sqlx::query_as::<_, PlexEvent>( "SELECT id, event_type, plex_server_id, user_name, title, rating_key, metadata, thumb_url, received_at \ - FROM plex_events WHERE event_type = $1 ORDER BY received_at DESC LIMIT $2", + FROM plex_events WHERE event_type = ? ORDER BY received_at DESC LIMIT ?", ) .bind(event_type) .bind(limit) @@ -590,7 +619,7 @@ async fn list_events( } else { sqlx::query_as::<_, PlexEvent>( "SELECT id, event_type, plex_server_id, user_name, title, rating_key, metadata, thumb_url, received_at \ - FROM plex_events ORDER BY received_at DESC LIMIT $1", + FROM plex_events ORDER BY received_at DESC LIMIT ?", ) .bind(limit) .fetch_all(pool) @@ -625,7 +654,7 @@ async fn get_webhook_url( ) -> impl IntoResponse { let pool = state.db.pool(); let secret: Option = - sqlx::query_scalar("SELECT webhook_secret FROM plex_servers WHERE id = $1") + sqlx::query_scalar("SELECT webhook_secret FROM plex_servers WHERE id = ?") .bind(server_id) .fetch_optional(pool) .await diff --git a/crates/stackarr-web/src/routes/queue.rs b/crates/stackarr-web/src/routes/queue.rs index 67f96beb..a38410cd 100644 --- a/crates/stackarr-web/src/routes/queue.rs +++ b/crates/stackarr-web/src/routes/queue.rs @@ -183,7 +183,7 @@ pub async fn delete_queue_item( State(state): State>, Path(id): Path, ) -> impl IntoResponse { - let result = sqlx::query("DELETE FROM queue WHERE id = $1") + let result = sqlx::query("DELETE FROM queue WHERE id = ?") .bind(id) .execute(state.db.pool()) .await; diff --git a/crates/stackarr-web/src/routes/releases.rs b/crates/stackarr-web/src/routes/releases.rs index 8fe05cd7..9985cb11 100644 --- a/crates/stackarr-web/src/routes/releases.rs +++ b/crates/stackarr-web/src/routes/releases.rs @@ -84,7 +84,7 @@ async fn search_releases( // Load quality profile: explicit id > media's profile > first available let pool = state.db.pool(); let profile: QualityProfile = if let Some(id) = query.quality_profile_id { - match sqlx::query_as::<_, QualityProfile>("SELECT * FROM quality_profiles WHERE id = $1") + match sqlx::query_as::<_, QualityProfile>("SELECT * FROM quality_profiles WHERE id = ?") .bind(id as i32) .fetch_optional(pool) .await @@ -111,7 +111,7 @@ async fn search_releases( // uses the same profile as automatic search. let media_profile = if let Some(sid) = query.series_id { sqlx::query_as::<_, QualityProfile>( - "SELECT qp.* FROM series s JOIN quality_profiles qp ON s.quality_profile_id = qp.id WHERE s.id = $1", + "SELECT qp.* FROM series s JOIN quality_profiles qp ON s.quality_profile_id = qp.id WHERE s.id = ?", ) .bind(sid) .fetch_optional(pool) @@ -120,7 +120,7 @@ async fn search_releases( .flatten() } else if let Some(mid) = query.movie_id { sqlx::query_as::<_, QualityProfile>( - "SELECT qp.* FROM movies m JOIN quality_profiles qp ON m.quality_profile_id = qp.id WHERE m.id = $1", + "SELECT qp.* FROM movies m JOIN quality_profiles qp ON m.quality_profile_id = qp.id WHERE m.id = ?", ) .bind(mid) .fetch_optional(pool) @@ -182,7 +182,7 @@ async fn search_releases( let indexer_results = if is_movie { let (tmdb_id, imdb_id) = if let Some(mid) = query.movie_id { sqlx::query_as::<_, (Option, Option)>( - "SELECT tmdb_id, imdb_id FROM movies WHERE id = $1", + "SELECT tmdb_id, imdb_id FROM movies WHERE id = ?", ) .bind(mid) .fetch_optional(pool) @@ -203,7 +203,7 @@ async fn search_releases( } else { let (tvdb_id, season, episode) = if let Some(sid) = query.series_id { let tvdb = - sqlx::query_scalar::<_, Option>("SELECT tvdb_id FROM series WHERE id = $1") + sqlx::query_scalar::<_, Option>("SELECT tvdb_id FROM series WHERE id = ?") .bind(sid) .fetch_optional(pool) .await @@ -212,7 +212,7 @@ async fn search_releases( .flatten(); let (s, e) = if let Some(eid) = query.episode_id { sqlx::query_as::<_, (i32, i32)>( - "SELECT season_number, episode_number FROM episodes WHERE id = $1", + "SELECT season_number, episode_number FROM episodes WHERE id = ?", ) .bind(eid) .fetch_optional(pool) @@ -253,35 +253,64 @@ async fn search_releases( // Check which guids are already in queue or history let guids: Vec = releases.iter().map(|r| r.guid.clone()).collect(); - let queued_guids: std::collections::HashSet = - sqlx::query_scalar("SELECT download_id FROM queue WHERE download_id = ANY($1)") - .bind(&guids) + let queued_guids: std::collections::HashSet = if guids.is_empty() { + std::collections::HashSet::new() + } else { + let mut query = + sqlx::QueryBuilder::new("SELECT download_id FROM queue WHERE download_id IN ("); + let mut ids = query.separated(", "); + for guid in &guids { + ids.push_bind(guid); + } + ids.push_unseparated(")"); + query + .build_query_scalar::() .fetch_all(pool) .await .unwrap_or_default() .into_iter() - .collect(); + .collect() + }; - let history_guids: std::collections::HashSet = sqlx::query_scalar( - "SELECT download_id FROM history WHERE download_id = ANY($1) AND event_type = 'grabbed'", - ) - .bind(&guids) - .fetch_all(pool) - .await - .unwrap_or_default() - .into_iter() - .collect(); + let history_guids: std::collections::HashSet = if guids.is_empty() { + std::collections::HashSet::new() + } else { + let mut query = + sqlx::QueryBuilder::new("SELECT download_id FROM history WHERE download_id IN ("); + let mut ids = query.separated(", "); + for guid in &guids { + ids.push_bind(guid); + } + ids.push_unseparated(") AND event_type = 'grabbed'"); + query + .build_query_scalar::() + .fetch_all(pool) + .await + .unwrap_or_default() + .into_iter() + .collect() + }; // Check which release titles are blocklisted let release_titles: Vec = releases.iter().map(|r| r.title.clone()).collect(); - let blocklisted_titles: std::collections::HashSet = - sqlx::query_scalar("SELECT source_title FROM blocklist WHERE source_title = ANY($1)") - .bind(&release_titles) + let blocklisted_titles: std::collections::HashSet = if release_titles.is_empty() { + std::collections::HashSet::new() + } else { + let mut query = + sqlx::QueryBuilder::new("SELECT source_title FROM blocklist WHERE source_title IN ("); + let mut titles = query.separated(", "); + for title in &release_titles { + titles.push_bind(title); + } + titles.push_unseparated(")"); + query + .build_query_scalar::() .fetch_all(pool) .await .unwrap_or_default() .into_iter() - .collect(); + .collect() + }; // Load custom formats and profile scores for CF scoring let cf_formats: Vec = @@ -301,7 +330,7 @@ async fn search_releases( .collect(); let cf_scores: Vec<(i64, i32)> = sqlx::query_as::<_, (i32, i32)>( - "SELECT format_id, score FROM custom_format_scores WHERE profile_id = $1", + "SELECT format_id, score FROM custom_format_scores WHERE profile_id = ?", ) .bind(profile.id) .fetch_all(pool) @@ -344,7 +373,7 @@ async fn search_releases( let original_language = if is_movie { if let Some(mid) = query.movie_id { sqlx::query_scalar::<_, Option>( - "SELECT original_language FROM movies WHERE id = $1", + "SELECT original_language FROM movies WHERE id = ?", ) .bind(mid) .fetch_optional(pool) @@ -532,7 +561,7 @@ async fn grab_release( if let Err(e) = sqlx::query( "INSERT INTO queue (media_type, media_id, episode_id, title, quality, size, status, download_id, download_client_id, indexer_id, protocol) - VALUES ($1, $2, $3, $4, '{}'::jsonb, $5, 'queued', $6, $7, $8, $9)", + VALUES (?, ?, ?, ?, JSON_OBJECT(), ?, 'queued', ?, ?, ?, ?)", ) .bind(media_type) .bind(media_id) @@ -552,7 +581,7 @@ async fn grab_release( // Record in history if let Err(e) = sqlx::query( "INSERT INTO history (media_type, media_id, event_type, quality, source_title, download_id, indexer_id, download_client) - VALUES ($1, $2, 'grabbed', '{}'::jsonb, $3, $4, $5, $6)", + VALUES (?, ?, 'grabbed', JSON_OBJECT(), ?, ?, ?, ?)", ) .bind(media_type) .bind(media_id) @@ -567,7 +596,7 @@ async fn grab_release( } // Dispatch grab notification - let indexer_name = sqlx::query_scalar::<_, String>("SELECT name FROM indexers WHERE id = $1") + let indexer_name = sqlx::query_scalar::<_, String>("SELECT name FROM indexers WHERE id = ?") .bind(body.indexer_id as i32) .fetch_optional(pool) .await diff --git a/crates/stackarr-web/src/routes/requests.rs b/crates/stackarr-web/src/routes/requests.rs index 7fee25a4..dd887f38 100644 --- a/crates/stackarr-web/src/routes/requests.rs +++ b/crates/stackarr-web/src/routes/requests.rs @@ -56,7 +56,7 @@ async fn create_request( // Check if already in library (series by tmdb_id or movies by tmdb_id) let pool = state.db.pool(); let in_library = if body.media_type == "series" { - let row: Option<(i64,)> = sqlx::query_as("SELECT id FROM series WHERE tmdb_id = $1") + let row: Option<(i64,)> = sqlx::query_as("SELECT id FROM series WHERE tmdb_id = ?") .bind(body.tmdb_id) .fetch_optional(pool) .await @@ -64,7 +64,7 @@ async fn create_request( .flatten(); row.is_some() } else { - let row: Option<(i64,)> = sqlx::query_as("SELECT id FROM movies WHERE tmdb_id = $1") + let row: Option<(i64,)> = sqlx::query_as("SELECT id FROM movies WHERE tmdb_id = ?") .bind(body.tmdb_id) .fetch_optional(pool) .await diff --git a/crates/stackarr-web/src/routes/rss.rs b/crates/stackarr-web/src/routes/rss.rs index c1c268f8..68ab462f 100644 --- a/crates/stackarr-web/src/routes/rss.rs +++ b/crates/stackarr-web/src/routes/rss.rs @@ -17,6 +17,26 @@ use crate::AppState; // ── Feed handlers ─────────���──────────────────────────────────────────────── +async fn load_feed(pool: &sqlx::MySqlPool, id: i64) -> Result, sqlx::Error> { + sqlx::query_as( + "SELECT id, name, url, protocol, poll_interval_secs, category, filter_regex, + enabled, auto_download, created_at, updated_at FROM rss_feeds WHERE id = ?", + ) + .bind(id) + .fetch_optional(pool) + .await +} + +async fn load_rule(pool: &sqlx::MySqlPool, id: i64) -> Result, sqlx::Error> { + sqlx::query_as( + "SELECT id, name, feed_ids, category, priority, match_regex, enabled, created_at + FROM rss_rules WHERE id = ?", + ) + .bind(id) + .fetch_optional(pool) + .await +} + async fn list_feeds(State(state): State>) -> impl IntoResponse { let pool = state.db.pool(); @@ -65,11 +85,9 @@ async fn create_feed( let auto_download = body.auto_download.unwrap_or(false); let poll_interval = body.poll_interval_secs.unwrap_or(900); - match sqlx::query_as::<_, RssFeed>( + match sqlx::query( "INSERT INTO rss_feeds (name, url, protocol, poll_interval_secs, category, filter_regex, enabled, auto_download) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id, name, url, protocol, poll_interval_secs, category, filter_regex, - enabled, auto_download, created_at, updated_at", + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(body.name.trim()) .bind(body.url.trim()) @@ -79,10 +97,13 @@ async fn create_feed( .bind(&body.filter_regex) .bind(enabled) .bind(auto_download) - .fetch_one(pool) + .execute(pool) .await { - Ok(feed) => (StatusCode::CREATED, Json(serde_json::to_value(&feed).unwrap_or_default())).into_response(), + Ok(result) => match load_feed(pool, result.last_insert_id() as i64).await { + Ok(Some(feed)) => (StatusCode::CREATED, Json(serde_json::to_value(&feed).unwrap_or_default())).into_response(), + Ok(None) | Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }, Err(e) => { tracing::error!(error = %e, "failed to create RSS feed"); if e.to_string().contains("duplicate key") { @@ -101,20 +122,18 @@ async fn update_feed( ) -> impl IntoResponse { let pool = state.db.pool(); - match sqlx::query_as::<_, RssFeed>( + let update = sqlx::query( "UPDATE rss_feeds SET - name = COALESCE($1, name), - url = COALESCE($2, url), - protocol = COALESCE($3, protocol), - poll_interval_secs = COALESCE($4, poll_interval_secs), - category = COALESCE($5, category), - filter_regex = COALESCE($6, filter_regex), - enabled = COALESCE($7, enabled), - auto_download = COALESCE($8, auto_download), + name = COALESCE(?, name), + url = COALESCE(?, url), + protocol = COALESCE(?, protocol), + poll_interval_secs = COALESCE(?, poll_interval_secs), + category = COALESCE(?, category), + filter_regex = COALESCE(?, filter_regex), + enabled = COALESCE(?, enabled), + auto_download = COALESCE(?, auto_download), updated_at = NOW() - WHERE id = $9 - RETURNING id, name, url, protocol, poll_interval_secs, category, filter_regex, - enabled, auto_download, created_at, updated_at", + WHERE id = ?", ) .bind(body.name.as_deref().map(str::trim)) .bind(body.url.as_deref().map(str::trim)) @@ -125,15 +144,18 @@ async fn update_feed( .bind(body.enabled) .bind(body.auto_download) .bind(id) - .fetch_optional(pool) - .await - { - Ok(Some(feed)) => Json(serde_json::to_value(&feed).unwrap_or_default()).into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "feed not found"})), - ) - .into_response(), + .execute(pool) + .await; + match update { + Ok(_) => match load_feed(pool, id).await { + Ok(Some(feed)) => Json(serde_json::to_value(&feed).unwrap_or_default()).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(json!({"error": "feed not found"})), + ) + .into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }, Err(e) => { tracing::error!(error = %e, "failed to update RSS feed"); ( @@ -148,7 +170,7 @@ async fn update_feed( async fn delete_feed(State(state): State>, Path(id): Path) -> impl IntoResponse { let pool = state.db.pool(); - match sqlx::query("DELETE FROM rss_feeds WHERE id = $1") + match sqlx::query("DELETE FROM rss_feeds WHERE id = ?") .bind(id) .execute(pool) .await @@ -182,7 +204,7 @@ async fn check_feed(State(state): State>, Path(id): Path) -> let feed = match sqlx::query_as::<_, RssFeed>( "SELECT id, name, url, protocol, poll_interval_secs, category, filter_regex, enabled, auto_download, created_at, updated_at - FROM rss_feeds WHERE id = $1", + FROM rss_feeds WHERE id = ?", ) .bind(id) .fetch_optional(pool) @@ -245,8 +267,8 @@ async fn list_items( sqlx::query_as::<_, RssItem>( "SELECT id, feed_id, title, url, published_at, first_seen_at, downloaded, downloaded_at, category, size_bytes - FROM rss_items WHERE feed_id = $1 - ORDER BY first_seen_at DESC LIMIT $2", + FROM rss_items WHERE feed_id = ? + ORDER BY first_seen_at DESC LIMIT ?", ) .bind(feed_id) .bind(limit) @@ -256,7 +278,7 @@ async fn list_items( sqlx::query_as::<_, RssItem>( "SELECT id, feed_id, title, url, published_at, first_seen_at, downloaded, downloaded_at, category, size_bytes - FROM rss_items ORDER BY first_seen_at DESC LIMIT $1", + FROM rss_items ORDER BY first_seen_at DESC LIMIT ?", ) .bind(limit) .fetch_all(pool) @@ -286,7 +308,7 @@ async fn download_item( let item = match sqlx::query_as::<_, RssItem>( "SELECT id, feed_id, title, url, published_at, first_seen_at, downloaded, downloaded_at, category, size_bytes - FROM rss_items WHERE id = $1", + FROM rss_items WHERE id = ?", ) .bind(&id) .fetch_optional(pool) @@ -325,7 +347,7 @@ async fn download_item( let feed = match sqlx::query_as::<_, RssFeed>( "SELECT id, name, url, protocol, poll_interval_secs, category, filter_regex, enabled, auto_download, created_at, updated_at - FROM rss_feeds WHERE id = $1", + FROM rss_feeds WHERE id = ?", ) .bind(item.feed_id) .fetch_optional(pool) @@ -386,7 +408,7 @@ async fn download_item( // Mark as downloaded let _ = sqlx::query( - "UPDATE rss_items SET downloaded = true, downloaded_at = NOW(), category = COALESCE($1, category) WHERE id = $2", + "UPDATE rss_items SET downloaded = true, downloaded_at = NOW(), category = COALESCE(?, category) WHERE id = ?", ) .bind(&category) .bind(&id) @@ -468,25 +490,27 @@ async fn create_rule( let priority = body.priority.unwrap_or(1); let enabled = body.enabled.unwrap_or(true); - match sqlx::query_as::<_, RssRule>( + match sqlx::query( "INSERT INTO rss_rules (name, feed_ids, category, priority, match_regex, enabled) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING id, name, feed_ids, category, priority, match_regex, enabled, created_at", + VALUES (?, ?, ?, ?, ?, ?)", ) .bind(body.name.trim()) - .bind(&body.feed_ids) + .bind(sqlx::types::Json(&body.feed_ids)) .bind(&body.category) .bind(priority) .bind(&body.match_regex) .bind(enabled) - .fetch_one(pool) + .execute(pool) .await { - Ok(rule) => ( - StatusCode::CREATED, - Json(serde_json::to_value(&rule).unwrap_or_default()), - ) - .into_response(), + Ok(result) => match load_rule(pool, result.last_insert_id() as i64).await { + Ok(Some(rule)) => ( + StatusCode::CREATED, + Json(serde_json::to_value(&rule).unwrap_or_default()), + ) + .into_response(), + _ => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }, Err(e) => { tracing::error!(error = %e, "failed to create RSS rule"); ( @@ -516,33 +540,35 @@ async fn update_rule( .into_response(); } - match sqlx::query_as::<_, RssRule>( + let update = sqlx::query( "UPDATE rss_rules SET - name = COALESCE($1, name), - feed_ids = COALESCE($2, feed_ids), - category = COALESCE($3, category), - priority = COALESCE($4, priority), - match_regex = COALESCE($5, match_regex), - enabled = COALESCE($6, enabled) - WHERE id = $7 - RETURNING id, name, feed_ids, category, priority, match_regex, enabled, created_at", + name = COALESCE(?, name), + feed_ids = COALESCE(?, feed_ids), + category = COALESCE(?, category), + priority = COALESCE(?, priority), + match_regex = COALESCE(?, match_regex), + enabled = COALESCE(?, enabled) + WHERE id = ?", ) .bind(body.name.as_deref().map(str::trim)) - .bind(&body.feed_ids) + .bind(body.feed_ids.as_ref().map(sqlx::types::Json)) .bind(&body.category) .bind(body.priority) .bind(&body.match_regex) .bind(body.enabled) .bind(id) - .fetch_optional(pool) - .await - { - Ok(Some(rule)) => Json(serde_json::to_value(&rule).unwrap_or_default()).into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "rule not found"})), - ) - .into_response(), + .execute(pool) + .await; + match update { + Ok(_) => match load_rule(pool, id).await { + Ok(Some(rule)) => Json(serde_json::to_value(&rule).unwrap_or_default()).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(json!({"error": "rule not found"})), + ) + .into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }, Err(e) => { tracing::error!(error = %e, "failed to update RSS rule"); ( @@ -557,7 +583,7 @@ async fn update_rule( async fn delete_rule(State(state): State>, Path(id): Path) -> impl IntoResponse { let pool = state.db.pool(); - match sqlx::query("DELETE FROM rss_rules WHERE id = $1") + match sqlx::query("DELETE FROM rss_rules WHERE id = ?") .bind(id) .execute(pool) .await diff --git a/crates/stackarr-web/src/routes/series.rs b/crates/stackarr-web/src/routes/series.rs index 1cbbfbb8..26e51282 100644 --- a/crates/stackarr-web/src/routes/series.rs +++ b/crates/stackarr-web/src/routes/series.rs @@ -61,7 +61,7 @@ fn enrich_series(series: Series, counts: &HashMap) -> Series } async fn fetch_episode_counts( - pool: &sqlx::PgPool, + pool: &sqlx::MySqlPool, ) -> Result, sqlx::Error> { let rows = sqlx::query_as::<_, EpisodeCounts>( "SELECT series_id, @@ -79,7 +79,7 @@ async fn fetch_episode_counts( } async fn fetch_episode_counts_for_series( - pool: &sqlx::PgPool, + pool: &sqlx::MySqlPool, series_id: i64, ) -> Result, sqlx::Error> { let row = sqlx::query_as::<_, EpisodeCounts>( @@ -89,7 +89,7 @@ async fn fetch_episode_counts_for_series( COUNT(*) as total_episode_count, COUNT(DISTINCT season_number) FILTER (WHERE season_number > 0) as season_count FROM episodes - WHERE series_id = $1 + WHERE series_id = ? GROUP BY series_id", ) .bind(series_id) @@ -298,23 +298,23 @@ pub async fn create_series( // Update series with full metadata let _ = sqlx::query( - "UPDATE series SET overview = $1, status = $2::text::series_status, network = $3, - images = $4, genres = $5, year = $6, runtime = $7, tvdb_id = COALESCE($8, tvdb_id), - imdb_id = COALESCE($9, imdb_id), last_info_sync = NOW() - WHERE id = $10", - ) - .bind(&detail.overview) - .bind(status_str) - .bind(network) - .bind(&images_json) - .bind(&genres) - .bind(year) - .bind(runtime) - .bind(tvdb_id) - .bind(&imdb_id) - .bind(series.id) - .execute(pool) - .await; + "UPDATE series SET overview = ?, status = ?, network = ?, + images = ?, genres = ?, year = ?, runtime = ?, tvdb_id = COALESCE(?, tvdb_id), + imdb_id = COALESCE(?, imdb_id), last_info_sync = NOW() + WHERE id = ?", + ) + .bind(&detail.overview) + .bind(status_str) + .bind(network) + .bind(&images_json) + .bind(sqlx::types::Json(&genres)) + .bind(year) + .bind(runtime) + .bind(tvdb_id) + .bind(&imdb_id) + .bind(series.id) + .execute(pool) + .await; // Fetch all seasons and insert episodes let num_seasons = detail.number_of_seasons.unwrap_or(0); @@ -322,9 +322,9 @@ pub async fn create_series( if let Ok(season) = client.get_season(tmdb_id, season_num).await { for ep in &season.episodes { let _ = sqlx::query( - "INSERT INTO episodes (series_id, season_number, episode_number, title, overview, air_date, runtime, monitored) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT (series_id, season_number, episode_number) DO NOTHING", + "INSERT IGNORE INTO episodes (series_id, season_number, episode_number, title, overview, air_date, runtime, monitored) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ", ) .bind(series.id) .bind(ep.season_number) @@ -419,7 +419,7 @@ pub async fn delete_series( // ── TMDB helpers ──────────────────────────────────────────────────────────── /// Resolve TMDB API key from env or database. -async fn resolve_tmdb_api_key(pool: &sqlx::PgPool) -> Option { +async fn resolve_tmdb_api_key(pool: &sqlx::MySqlPool) -> Option { if let Ok(key) = std::env::var("STACKARR_TMDB_API_KEY") && !key.is_empty() { @@ -551,12 +551,14 @@ pub async fn bulk_update_series( let mut updated: u64 = 0; if let Some(qp) = body.quality_profile_id { - match sqlx::query("UPDATE series SET quality_profile_id = $1 WHERE id = ANY($2)") - .bind(qp) - .bind(&body.series_ids) - .execute(pool) - .await - { + let mut query = sqlx::QueryBuilder::new("UPDATE series SET quality_profile_id = "); + query.push_bind(qp).push(" WHERE id IN ("); + let mut ids = query.separated(", "); + for id in &body.series_ids { + ids.push_bind(id); + } + ids.push_unseparated(")"); + match query.build().execute(pool).await { Ok(r) => updated = r.rows_affected(), Err(e) => { tracing::error!(error = %e, "failed to bulk update series quality_profile_id"); @@ -570,12 +572,14 @@ pub async fn bulk_update_series( } if let Some(monitored) = body.monitored { - match sqlx::query("UPDATE series SET monitored = $1 WHERE id = ANY($2)") - .bind(monitored) - .bind(&body.series_ids) - .execute(pool) - .await - { + let mut query = sqlx::QueryBuilder::new("UPDATE series SET monitored = "); + query.push_bind(monitored).push(" WHERE id IN ("); + let mut ids = query.separated(", "); + for id in &body.series_ids { + ids.push_bind(id); + } + ids.push_unseparated(")"); + match query.build().execute(pool).await { Ok(r) => updated = r.rows_affected(), Err(e) => { tracing::error!(error = %e, "failed to bulk update series monitored"); diff --git a/crates/stackarr-web/src/routes/stream.rs b/crates/stackarr-web/src/routes/stream.rs index c60cf8eb..b265b98b 100644 --- a/crates/stackarr-web/src/routes/stream.rs +++ b/crates/stackarr-web/src/routes/stream.rs @@ -29,7 +29,7 @@ fn streaming_not_enabled() -> impl IntoResponse { /// Apply path mappings from `app_config` key `path_maps` (JSON array of `[from, to]` pairs). /// Falls back to the media_library_folders table for prefix remapping. -async fn apply_path_maps(pool: &sqlx::PgPool, path: PathBuf) -> PathBuf { +async fn apply_path_maps(pool: &sqlx::MySqlPool, path: PathBuf) -> PathBuf { // Try app_config path_maps first (explicit overrides) if let Ok(Some(maps)) = sqlx::query_scalar::<_, serde_json::Value>( "SELECT value FROM app_config WHERE key = 'path_maps'", @@ -57,7 +57,7 @@ async fn apply_path_maps(pool: &sqlx::PgPool, path: PathBuf) -> PathBuf { /// Resolve the full filesystem path for a media file by joining the /// parent entity's directory path with the file's relative path. async fn resolve_media_path( - pool: &sqlx::PgPool, + pool: &sqlx::MySqlPool, media_file_id: i64, ) -> Result { // Try movie first (simpler join) @@ -65,7 +65,7 @@ async fn resolve_media_path( "SELECT m.path, mf.relative_path FROM media_files mf JOIN movies m ON m.movie_file_id = mf.id - WHERE mf.id = $1 AND mf.media_type = 'movie'", + WHERE mf.id = ? AND mf.media_type = 'movie'", ) .bind(media_file_id) .fetch_optional(pool) @@ -93,7 +93,7 @@ async fn resolve_media_path( JOIN episode_files ef ON ef.media_file_id = mf.id JOIN episodes e ON ef.episode_id = e.id JOIN series s ON e.series_id = s.id - WHERE mf.id = $1 AND mf.media_type = 'series' + WHERE mf.id = ? AND mf.media_type = 'series' LIMIT 1", ) .bind(media_file_id) @@ -122,7 +122,7 @@ async fn stream_info( ) -> impl IntoResponse { // Check for cached media_info in DB first (works even without streaming enabled) let cached: Option<(Option,)> = - sqlx::query_as("SELECT media_info FROM media_files WHERE id = $1") + sqlx::query_as("SELECT media_info FROM media_files WHERE id = ?") .bind(media_file_id) .fetch_optional(state.db.pool()) .await @@ -165,7 +165,7 @@ async fn stream_info( Ok(info) => { // Cache the result in DB if let Ok(info_json) = serde_json::to_value(&info) { - let _ = sqlx::query("UPDATE media_files SET media_info = $1 WHERE id = $2") + let _ = sqlx::query("UPDATE media_files SET media_info = ? WHERE id = ?") .bind(&info_json) .bind(media_file_id) .execute(state.db.pool()) @@ -679,7 +679,7 @@ async fn quality_tiers( ) -> impl IntoResponse { // Get source resolution from cached media_info let cached: Option<(Option,)> = - sqlx::query_as("SELECT media_info FROM media_files WHERE id = $1") + sqlx::query_as("SELECT media_info FROM media_files WHERE id = ?") .bind(media_file_id) .fetch_optional(state.db.pool()) .await diff --git a/crates/stackarr-web/src/routes/stremio.rs b/crates/stackarr-web/src/routes/stremio.rs index a5aeacbc..c84b7d9e 100644 --- a/crates/stackarr-web/src/routes/stremio.rs +++ b/crates/stackarr-web/src/routes/stremio.rs @@ -135,7 +135,7 @@ fn addon_disabled() -> impl IntoResponse { ) } -async fn is_addon_enabled(pool: &sqlx::PgPool) -> bool { +async fn is_addon_enabled(pool: &sqlx::MySqlPool) -> bool { sqlx::query_scalar::<_, bool>( "SELECT enabled FROM enabled_modules WHERE module = 'stremio_addon'", ) @@ -159,7 +159,7 @@ fn image_url(images: &Option, cover_type: &str) -> Option String { +async fn base_url(pool: &sqlx::MySqlPool, config: &stackarr_core::config::AppConfig) -> String { if let Ok(Some(val)) = sqlx::query_scalar::<_, serde_json::Value>( "SELECT value FROM app_config WHERE key = 'stremio_base_url'", ) @@ -247,7 +247,7 @@ async fn catalog( String, Option, Option, - Option>, + Option>>, Option, )> = sqlx::query_as( "SELECT m.imdb_id, m.title, m.overview, m.year, m.genres, m.movie_file_id @@ -269,7 +269,7 @@ async fn catalog( poster: None, // Stremio fetches posters from cinemeta description: overview, year: year.map(|y| y.to_string()), - genres, + genres: genres.map(|value| value.0), }) }) .collect() @@ -281,7 +281,7 @@ async fn catalog( String, Option, Option, - Option>, + Option>>, )> = sqlx::query_as( "SELECT s.imdb_id, s.title, s.overview, s.year, s.genres FROM series s @@ -302,7 +302,7 @@ async fn catalog( poster: None, description: overview, year: year.map(|y| y.to_string()), - genres, + genres: genres.map(|value| value.0), }) }) .collect() @@ -336,12 +336,12 @@ async fn meta( String, Option, Option, - Option>, + Option>>, Option, Option, )> = sqlx::query_as( - "SELECT m.title, m.overview, m.year, m.genres, m.images, NULL::int - FROM movies m WHERE m.imdb_id = $1", + "SELECT m.title, m.overview, m.year, m.genres, m.images, CAST(NULL AS SIGNED) + FROM movies m WHERE m.imdb_id = ?", ) .bind(imdb_id) .fetch_optional(pool) @@ -358,7 +358,7 @@ async fn meta( background: image_url(&images, "fanart"), description: overview, year: year.map(|y| y.to_string()), - genres, + genres: genres.map(|value| value.0), runtime: None, videos: Vec::new(), }, @@ -376,12 +376,12 @@ async fn meta( String, Option, Option, - Option>, + Option>>, Option, Option, )> = sqlx::query_as( "SELECT s.id, s.title, s.overview, s.year, s.genres, s.images, s.runtime - FROM series s WHERE s.imdb_id = $1", + FROM series s WHERE s.imdb_id = ?", ) .bind(imdb_id) .fetch_optional(pool) @@ -401,7 +401,7 @@ async fn meta( )> = sqlx::query_as( "SELECT e.season_number, e.episode_number, e.title, e.overview, e.air_date FROM episodes e - WHERE e.series_id = $1 AND e.episode_file_id IS NOT NULL + WHERE e.series_id = ? AND e.episode_file_id IS NOT NULL ORDER BY e.season_number, e.episode_number", ) .bind(series_id) @@ -433,7 +433,7 @@ async fn meta( background: image_url(&images, "fanart"), description: overview, year: year.map(|y| y.to_string()), - genres, + genres: genres.map(|value| value.0), runtime: runtime.map(|r| format!("{r} min")), videos, }, @@ -481,12 +481,12 @@ async fn stream( } /// Resolve streams for a movie by IMDB ID. -async fn resolve_movie_streams(pool: &sqlx::PgPool, imdb_id: &str, host: &str) -> Vec { +async fn resolve_movie_streams(pool: &sqlx::MySqlPool, imdb_id: &str, host: &str) -> Vec { let row: Option<(i64, i64, String, serde_json::Value)> = sqlx::query_as( "SELECT mf.id, mf.size, mf.relative_path, mf.quality FROM movies m JOIN media_files mf ON m.movie_file_id = mf.id - WHERE m.imdb_id = $1 AND m.movie_file_id IS NOT NULL", + WHERE m.imdb_id = ? AND m.movie_file_id IS NOT NULL", ) .bind(imdb_id) .fetch_optional(pool) @@ -502,7 +502,7 @@ async fn resolve_movie_streams(pool: &sqlx::PgPool, imdb_id: &str, host: &str) - } /// Resolve streams for a series episode by "imdb_id:season:episode". -async fn resolve_series_streams(pool: &sqlx::PgPool, raw_id: &str, host: &str) -> Vec { +async fn resolve_series_streams(pool: &sqlx::MySqlPool, raw_id: &str, host: &str) -> Vec { let parts: Vec<&str> = raw_id.splitn(3, ':').collect(); if parts.len() != 3 { return Vec::new(); @@ -523,9 +523,9 @@ async fn resolve_series_streams(pool: &sqlx::PgPool, raw_id: &str, host: &str) - JOIN episodes e ON e.series_id = s.id JOIN episode_files ef ON ef.episode_id = e.id JOIN media_files mf ON ef.media_file_id = mf.id - WHERE s.imdb_id = $1 - AND e.season_number = $2 - AND e.episode_number = $3 + WHERE s.imdb_id = ? + AND e.season_number = ? + AND e.episode_number = ? AND e.episode_file_id IS NOT NULL LIMIT 1", ) diff --git a/crates/stackarr-web/src/routes/system.rs b/crates/stackarr-web/src/routes/system.rs index 6572da76..9b150541 100644 --- a/crates/stackarr-web/src/routes/system.rs +++ b/crates/stackarr-web/src/routes/system.rs @@ -288,8 +288,8 @@ async fn init_setup( for (module, enabled) in &module_entries { if let Err(e) = sqlx::query( - "INSERT INTO enabled_modules (module, enabled) VALUES ($1, $2) - ON CONFLICT (module) DO UPDATE SET enabled = $2", + "INSERT INTO enabled_modules (module, enabled) VALUES (?, ?) + ON DUPLICATE KEY UPDATE enabled = VALUES(enabled)", ) .bind(module) .bind(enabled) @@ -316,24 +316,24 @@ async fn init_setup( if let Some(folders) = &body.media_library_folders { let user_paths: Vec<&str> = folders.iter().map(|f| f.path.as_str()).collect(); - // Delete unreferenced folders not in the user's list - if let Err(e) = sqlx::query( - "DELETE FROM media_library_folders - WHERE path != ALL($1) + // Delete unreferenced folders not in the user's list. + let mut cleanup = + sqlx::QueryBuilder::new("DELETE FROM media_library_folders WHERE path NOT IN ("); + let mut paths = cleanup.separated(", "); + for path in &user_paths { + paths.push_bind(path); + } + paths.push_unseparated(") AND id NOT IN (SELECT DISTINCT media_library_folder_id FROM series WHERE media_library_folder_id IS NOT NULL) - AND id NOT IN (SELECT DISTINCT media_library_folder_id FROM movies WHERE media_library_folder_id IS NOT NULL)", - ) - .bind(&user_paths) - .execute(pool) - .await - { + AND id NOT IN (SELECT DISTINCT media_library_folder_id FROM movies WHERE media_library_folder_id IS NOT NULL)"); + if let Err(e) = cleanup.build().execute(pool).await { tracing::warn!(error = %e, "failed to clean up migration-imported folders"); } for folder in folders { if let Err(e) = sqlx::query( - "INSERT INTO media_library_folders (path, media_type) VALUES ($1, $2) - ON CONFLICT (path) DO UPDATE SET media_type = $2", + "INSERT INTO media_library_folders (path, media_type) VALUES (?, ?) + ON DUPLICATE KEY UPDATE media_type = VALUES(media_type)", ) .bind(&folder.path) .bind(&folder.media_type) @@ -383,25 +383,26 @@ async fn init_setup( let is_tv = matches!(scope, "tv" | "series" | "all"); let is_movie = matches!(scope, "movie" | "all"); - // Remap media_library_folders paths. - // Use ON CONFLICT to handle cases where the target path already exists - // (e.g. user already specified the correct path in their folder list). + // Remap folders that do not collide with an existing target path. let folder_query = if is_tv && is_movie { - "UPDATE media_library_folders SET path = $2 || substring(path from length($1) + 1) - WHERE path LIKE $1 || '%' - AND NOT EXISTS (SELECT 1 FROM media_library_folders mf2 WHERE mf2.path = $2 || substring(media_library_folders.path from length($1) + 1))" + "UPDATE media_library_folders SET path = CONCAT(?, SUBSTRING(path, CHAR_LENGTH(?) + 1)) + WHERE path LIKE CONCAT(?, '%') + AND NOT EXISTS (SELECT 1 FROM media_library_folders mf2 WHERE mf2.path = CONCAT(?, SUBSTRING(media_library_folders.path, CHAR_LENGTH(?) + 1)))" } else if is_tv { - "UPDATE media_library_folders SET path = $2 || substring(path from length($1) + 1) - WHERE path LIKE $1 || '%' AND media_type IN ('tv', 'series') - AND NOT EXISTS (SELECT 1 FROM media_library_folders mf2 WHERE mf2.path = $2 || substring(media_library_folders.path from length($1) + 1))" + "UPDATE media_library_folders SET path = CONCAT(?, SUBSTRING(path, CHAR_LENGTH(?) + 1)) + WHERE path LIKE CONCAT(?, '%') AND media_type IN ('tv', 'series') + AND NOT EXISTS (SELECT 1 FROM media_library_folders mf2 WHERE mf2.path = CONCAT(?, SUBSTRING(media_library_folders.path, CHAR_LENGTH(?) + 1)))" } else { - "UPDATE media_library_folders SET path = $2 || substring(path from length($1) + 1) - WHERE path LIKE $1 || '%' AND media_type = 'movie' - AND NOT EXISTS (SELECT 1 FROM media_library_folders mf2 WHERE mf2.path = $2 || substring(media_library_folders.path from length($1) + 1))" + "UPDATE media_library_folders SET path = CONCAT(?, SUBSTRING(path, CHAR_LENGTH(?) + 1)) + WHERE path LIKE CONCAT(?, '%') AND media_type = 'movie' + AND NOT EXISTS (SELECT 1 FROM media_library_folders mf2 WHERE mf2.path = CONCAT(?, SUBSTRING(media_library_folders.path, CHAR_LENGTH(?) + 1)))" }; if let Err(e) = sqlx::query(folder_query) + .bind(&m.to) + .bind(&m.from) .bind(&m.from) .bind(&m.to) + .bind(&m.from) .execute(pool) .await { @@ -412,31 +413,33 @@ async fn init_setup( { // Reassign series.media_library_folder_id let _ = sqlx::query( - "UPDATE series SET media_library_folder_id = new_f.id - FROM media_library_folders old_f, media_library_folders new_f - WHERE series.media_library_folder_id = old_f.id - AND old_f.path LIKE $1 || '%' - AND new_f.path = $2 || substring(old_f.path from length($1) + 1)", + "UPDATE series s + JOIN media_library_folders old_f ON s.media_library_folder_id = old_f.id + JOIN media_library_folders new_f ON new_f.path = CONCAT(?, SUBSTRING(old_f.path, CHAR_LENGTH(?) + 1)) + SET s.media_library_folder_id = new_f.id + WHERE old_f.path LIKE CONCAT(?, '%')", ) - .bind(&m.from) .bind(&m.to) + .bind(&m.from) + .bind(&m.from) .execute(pool) .await; // Reassign movies.media_library_folder_id let _ = sqlx::query( - "UPDATE movies SET media_library_folder_id = new_f.id - FROM media_library_folders old_f, media_library_folders new_f - WHERE movies.media_library_folder_id = old_f.id - AND old_f.path LIKE $1 || '%' - AND new_f.path = $2 || substring(old_f.path from length($1) + 1)", + "UPDATE movies m + JOIN media_library_folders old_f ON m.media_library_folder_id = old_f.id + JOIN media_library_folders new_f ON new_f.path = CONCAT(?, SUBSTRING(old_f.path, CHAR_LENGTH(?) + 1)) + SET m.media_library_folder_id = new_f.id + WHERE old_f.path LIKE CONCAT(?, '%')", ) - .bind(&m.from) .bind(&m.to) + .bind(&m.from) + .bind(&m.from) .execute(pool) .await; // Now delete old folders (should be unreferenced after reassignment) let _ = sqlx::query( - "DELETE FROM media_library_folders WHERE path LIKE $1 || '%' + "DELETE FROM media_library_folders WHERE path LIKE CONCAT(?, '%') AND id NOT IN (SELECT DISTINCT media_library_folder_id FROM series WHERE media_library_folder_id IS NOT NULL) AND id NOT IN (SELECT DISTINCT media_library_folder_id FROM movies WHERE media_library_folder_id IS NOT NULL)", ) @@ -448,11 +451,12 @@ async fn init_setup( // Update series paths (only if scope includes TV) if is_tv && let Err(e) = sqlx::query( - "UPDATE series SET path = $2 || substring(path from length($1) + 1) - WHERE path LIKE $1 || '%'", + "UPDATE series SET path = CONCAT(?, SUBSTRING(path, CHAR_LENGTH(?) + 1)) + WHERE path LIKE CONCAT(?, '%')", ) - .bind(&m.from) .bind(&m.to) + .bind(&m.from) + .bind(&m.from) .execute(pool) .await { @@ -462,11 +466,12 @@ async fn init_setup( // Update movie paths (only if scope includes movies) if is_movie && let Err(e) = sqlx::query( - "UPDATE movies SET path = $2 || substring(path from length($1) + 1) - WHERE path LIKE $1 || '%'", + "UPDATE movies SET path = CONCAT(?, SUBSTRING(path, CHAR_LENGTH(?) + 1)) + WHERE path LIKE CONCAT(?, '%')", ) - .bind(&m.from) .bind(&m.to) + .bind(&m.from) + .bind(&m.from) .execute(pool) .await { @@ -481,8 +486,8 @@ async fn init_setup( .collect::>() .into(); if let Err(e) = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('path_maps', $1) - ON CONFLICT (key) DO UPDATE SET value = $1", + "INSERT INTO app_config (key, value) VALUES ('path_maps', ?) + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(&maps_json) .execute(pool) @@ -509,7 +514,7 @@ async fn init_setup( path, "removing orphaned media library folder (path does not exist)" ); - let _ = sqlx::query("DELETE FROM media_library_folders WHERE id = $1") + let _ = sqlx::query("DELETE FROM media_library_folders WHERE id = ?") .bind(id) .execute(pool) .await; @@ -521,8 +526,8 @@ async fn init_setup( if let Some(name) = &body.instance_name { let name_json = serde_json::Value::String(name.clone()); if let Err(e) = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('instance_name', $1) - ON CONFLICT (key) DO UPDATE SET value = $1", + "INSERT INTO app_config (key, value) VALUES ('instance_name', ?) + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(&name_json) .execute(pool) @@ -545,8 +550,8 @@ async fn init_setup( ] { let val_json = serde_json::Value::String(val.clone()); if let Err(e) = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ($1, $2) - ON CONFLICT (key) DO UPDATE SET value = $2", + "INSERT INTO app_config (key, value) VALUES (?, ?) + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(key) .bind(&val_json) @@ -567,8 +572,8 @@ async fn init_setup( let api_key = uuid::Uuid::new_v4().to_string(); let api_key_json = serde_json::Value::String(api_key.clone()); if let Err(e) = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('api_key', $1) - ON CONFLICT (key) DO UPDATE SET value = $1", + "INSERT INTO app_config (key, value) VALUES ('api_key', ?) + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(&api_key_json) .execute(pool) @@ -589,7 +594,7 @@ async fn init_setup( if streaming_enabled && !body.modules.remote_access.unwrap_or(false) { let _ = sqlx::query( "INSERT INTO enabled_modules (module, enabled) VALUES ('remote_access', true) - ON CONFLICT (module) DO UPDATE SET enabled = true", + ON DUPLICATE KEY UPDATE enabled = true", ) .execute(pool) .await; @@ -606,8 +611,8 @@ async fn init_setup( // Store hash so bootstrap can verify it later let hash_json = serde_json::Value::String(hex_hash); let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('recovery_key_hash', $1) - ON CONFLICT (key) DO UPDATE SET value = $1", + "INSERT INTO app_config (key, value) VALUES ('recovery_key_hash', ?) + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(&hash_json) .execute(pool) @@ -915,7 +920,7 @@ async fn post_command( // If a specific series_id is given, scan just that series' path if let Some(series_id) = body.series_id { let series_row: Option<(String,)> = - match sqlx::query_as("SELECT path FROM series WHERE id = $1") + match sqlx::query_as("SELECT path FROM series WHERE id = ?") .bind(series_id) .fetch_optional(pool) .await @@ -1169,7 +1174,7 @@ async fn post_command( "RefreshSeries" => { if let Some(series_id) = body.series_id { // Mark a specific series as needing refresh by updating last_info_sync - match sqlx::query("UPDATE series SET last_info_sync = NOW() WHERE id = $1") + match sqlx::query("UPDATE series SET last_info_sync = NOW() WHERE id = ?") .bind(series_id) .execute(pool) .await @@ -1236,7 +1241,7 @@ async fn post_command( } "RefreshMovie" => { if let Some(movie_id) = body.movie_id { - match sqlx::query("UPDATE movies SET last_info_sync = NOW() WHERE id = $1") + match sqlx::query("UPDATE movies SET last_info_sync = NOW() WHERE id = ?") .bind(movie_id) .execute(pool) .await @@ -1355,30 +1360,32 @@ async fn post_command( }; // Look up episode + series info - let episodes: Vec<(i64, i64, String, i32, i32, Option)> = match sqlx::query_as( + let mut query = sqlx::QueryBuilder::new( "SELECT e.id, e.series_id, s.title, e.season_number, e.episode_number, s.tvdb_id \ - FROM episodes e JOIN series s ON e.series_id = s.id \ - WHERE e.id = ANY($1)", - ) - .bind(&episode_ids) - .fetch_all(pool) - .await - { - Ok(rows) => rows, - Err(e) => { - tracing::error!(error = %e, "failed to query episodes for search"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!(CommandResponse { - name: body.name, - status: "error".to_string(), - result: None, - error: Some("failed to query episodes".to_string()), - })), - ) - .into_response(); - } - }; + FROM episodes e JOIN series s ON e.series_id = s.id WHERE e.id IN (", + ); + let mut ids = query.separated(", "); + for id in &episode_ids { + ids.push_bind(id); + } + ids.push_unseparated(")"); + let episodes: Vec<(i64, i64, String, i32, i32, Option)> = + match query.build_query_as().fetch_all(pool).await { + Ok(rows) => rows, + Err(e) => { + tracing::error!(error = %e, "failed to query episodes for search"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!(CommandResponse { + name: body.name, + status: "error".to_string(), + result: None, + error: Some("failed to query episodes".to_string()), + })), + ) + .into_response(); + } + }; if episodes.is_empty() { return ( @@ -1519,28 +1526,31 @@ async fn post_command( }, }; - let movies: Vec<(i64, String, Option, Option)> = match sqlx::query_as( - "SELECT id, title, tmdb_id, imdb_id FROM movies WHERE id = ANY($1)", - ) - .bind(&movie_ids) - .fetch_all(pool) - .await - { - Ok(rows) => rows, - Err(e) => { - tracing::error!(error = %e, "failed to query movies for search"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!(CommandResponse { - name: body.name, - status: "error".to_string(), - result: None, - error: Some("failed to query movies".to_string()), - })), - ) - .into_response(); - } - }; + let mut query = sqlx::QueryBuilder::new( + "SELECT id, title, tmdb_id, imdb_id FROM movies WHERE id IN (", + ); + let mut ids = query.separated(", "); + for id in &movie_ids { + ids.push_bind(id); + } + ids.push_unseparated(")"); + let movies: Vec<(i64, String, Option, Option)> = + match query.build_query_as().fetch_all(pool).await { + Ok(rows) => rows, + Err(e) => { + tracing::error!(error = %e, "failed to query movies for search"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!(CommandResponse { + name: body.name, + status: "error".to_string(), + result: None, + error: Some("failed to query movies".to_string()), + })), + ) + .into_response(); + } + }; if movies.is_empty() { return ( @@ -1720,7 +1730,7 @@ async fn post_command( AND e.episode_file_id IS NULL \ AND e.season_number > 0 \ AND (e.air_date IS NULL OR e.air_date <= CURRENT_DATE) \ - ORDER BY e.air_date DESC NULLS LAST", + ORDER BY e.air_date IS NULL, e.air_date DESC", ) .fetch_all(pool) .await @@ -1967,8 +1977,8 @@ async fn post_command( JOIN quality_profiles qp ON s.quality_profile_id = qp.id \ WHERE e.monitored = true AND s.monitored = true \ AND e.episode_file_id IS NOT NULL \ - AND (mf.quality->>'quality')::int < qp.cutoff \ - ORDER BY e.air_date DESC NULLS LAST", + AND CAST(JSON_UNQUOTE(JSON_EXTRACT(mf.quality, '$.quality')) AS SIGNED) < qp.cutoff \ + ORDER BY e.air_date IS NULL, e.air_date DESC", ) .fetch_all(pool) .await @@ -1981,7 +1991,7 @@ async fn post_command( JOIN media_files mf ON m.movie_file_id = mf.id \ JOIN quality_profiles qp ON m.quality_profile_id = qp.id \ WHERE m.monitored = true AND m.movie_file_id IS NOT NULL \ - AND (mf.quality->>'quality')::int < qp.cutoff \ + AND CAST(JSON_UNQUOTE(JSON_EXTRACT(mf.quality, '$.quality')) AS SIGNED) < qp.cutoff \ ORDER BY m.title", ) .fetch_all(pool) @@ -2192,7 +2202,7 @@ async fn post_command( // Fetch series title for activity display let series_title: String = - sqlx::query_scalar("SELECT title FROM series WHERE id = $1") + sqlx::query_scalar("SELECT title FROM series WHERE id = ?") .bind(series_id) .fetch_optional(pool) .await @@ -2221,7 +2231,7 @@ async fn post_command( let episodes: Vec<(i64, i64, String, i32, i32, Option)> = sqlx::query_as( "SELECT e.id, e.series_id, s.title, e.season_number, e.episode_number, s.tvdb_id \ FROM episodes e JOIN series s ON e.series_id = s.id \ - WHERE e.series_id = $1 \ + WHERE e.series_id = ? \ AND e.monitored = true AND s.monitored = true \ AND e.episode_file_id IS NULL \ AND e.season_number > 0 \ @@ -2379,7 +2389,7 @@ async fn post_command( // Fetch series title for activity display let series_title: String = - sqlx::query_scalar("SELECT title FROM series WHERE id = $1") + sqlx::query_scalar("SELECT title FROM series WHERE id = ?") .bind(series_id) .fetch_optional(pool) .await @@ -2404,10 +2414,10 @@ async fn post_command( JOIN series s ON e.series_id = s.id \ JOIN media_files mf ON e.episode_file_id = mf.id \ JOIN quality_profiles qp ON s.quality_profile_id = qp.id \ - WHERE e.series_id = $1 \ + WHERE e.series_id = ? \ AND e.monitored = true AND s.monitored = true \ AND e.episode_file_id IS NOT NULL \ - AND (mf.quality->>'quality')::int < qp.cutoff \ + AND CAST(JSON_UNQUOTE(JSON_EXTRACT(mf.quality, '$.quality')) AS SIGNED) < qp.cutoff \ ORDER BY e.season_number, e.episode_number", ) .bind(series_id) @@ -2649,8 +2659,8 @@ async fn put_modules( for (module, value) in &module_entries { let Some(enabled) = value else { continue }; if let Err(e) = sqlx::query( - "INSERT INTO enabled_modules (module, enabled) VALUES ($1, $2) - ON CONFLICT (module) DO UPDATE SET enabled = $2", + "INSERT INTO enabled_modules (module, enabled) VALUES (?, ?) + ON DUPLICATE KEY UPDATE enabled = VALUES(enabled)", ) .bind(module) .bind(enabled) @@ -2672,7 +2682,7 @@ async fn put_modules( if body.streaming == Some(true) { let _ = sqlx::query( "INSERT INTO enabled_modules (module, enabled) VALUES ('remote_access', true) - ON CONFLICT (module) DO UPDATE SET enabled = true", + ON DUPLICATE KEY UPDATE enabled = true", ) .execute(pool) .await; diff --git a/crates/stackarr-web/src/routes/tags.rs b/crates/stackarr-web/src/routes/tags.rs index 462e618a..53522486 100644 --- a/crates/stackarr-web/src/routes/tags.rs +++ b/crates/stackarr-web/src/routes/tags.rs @@ -84,14 +84,24 @@ pub async fn create_tag( .into_response(); } - match sqlx::query_as::<_, TagResponse>( - "INSERT INTO tags (label) VALUES ($1) RETURNING id, label", - ) - .bind(body.label.trim()) - .fetch_one(pool) - .await + match sqlx::query("INSERT INTO tags (label) VALUES (?)") + .bind(body.label.trim()) + .execute(pool) + .await { - Ok(tag) => (StatusCode::CREATED, Json(json!(tag))).into_response(), + Ok(result) => { + match sqlx::query_as::<_, TagResponse>("SELECT id, label FROM tags WHERE id = ?") + .bind(result.last_insert_id() as i32) + .fetch_one(pool) + .await + { + Ok(tag) => (StatusCode::CREATED, Json(json!(tag))).into_response(), + Err(e) => { + tracing::error!(error = %e, "failed to load created tag"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } + } Err(e) => { let msg = e.to_string(); if msg.contains("duplicate key") || msg.contains("unique") { @@ -141,20 +151,28 @@ pub async fn update_tag( .into_response(); } - match sqlx::query_as::<_, TagResponse>( - "UPDATE tags SET label = $1 WHERE id = $2 RETURNING id, label", - ) - .bind(body.label.trim()) - .bind(id as i32) - .fetch_optional(pool) - .await + match sqlx::query("UPDATE tags SET label = ? WHERE id = ?") + .bind(body.label.trim()) + .bind(id as i32) + .execute(pool) + .await { - Ok(Some(tag)) => Json(json!(tag)).into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "tag not found"})), - ) - .into_response(), + Ok(_) => match sqlx::query_as::<_, TagResponse>("SELECT id, label FROM tags WHERE id = ?") + .bind(id as i32) + .fetch_one(pool) + .await + { + Ok(tag) => Json(json!(tag)).into_response(), + Err(sqlx::Error::RowNotFound) => ( + StatusCode::NOT_FOUND, + Json(json!({"error": "tag not found"})), + ) + .into_response(), + Err(e) => { + tracing::error!(error = %e, "failed to load updated tag"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + }, Err(e) => { let msg = e.to_string(); if msg.contains("duplicate key") || msg.contains("unique") { @@ -193,7 +211,7 @@ pub async fn delete_tag( ) -> impl IntoResponse { let pool = state.db.pool(); - match sqlx::query("DELETE FROM tags WHERE id = $1") + match sqlx::query("DELETE FROM tags WHERE id = ?") .bind(id as i32) .execute(pool) .await diff --git a/crates/stackarr-web/src/routes/torrent.rs b/crates/stackarr-web/src/routes/torrent.rs index d7fe3c92..e96dc112 100644 --- a/crates/stackarr-web/src/routes/torrent.rs +++ b/crates/stackarr-web/src/routes/torrent.rs @@ -396,8 +396,8 @@ async fn torrent_settings_update( let _ = tokio::fs::create_dir_all(folder).await; api.api_set_output_folder(folder.clone()); let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('torrent_download_dir', $1::jsonb) \ - ON CONFLICT (key) DO UPDATE SET value = $1::jsonb", + "INSERT INTO app_config (key, value) VALUES ('torrent_download_dir', ?) \ + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(serde_json::json!(folder)) .execute(state.db.pool()) @@ -409,8 +409,8 @@ async fn torrent_settings_update( let _ = tokio::fs::create_dir_all(f).await; } let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('torrent_complete_dir', $1::jsonb) \ - ON CONFLICT (key) DO UPDATE SET value = $1::jsonb", + "INSERT INTO app_config (key, value) VALUES ('torrent_complete_dir', ?) \ + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(serde_json::json!(folder)) .execute(state.db.pool()) diff --git a/crates/stackarr-web/src/routes/usenet.rs b/crates/stackarr-web/src/routes/usenet.rs index d4978ed2..12bd32b4 100644 --- a/crates/stackarr-web/src/routes/usenet.rs +++ b/crates/stackarr-web/src/routes/usenet.rs @@ -98,6 +98,20 @@ struct DownloadClientRow { // Helpers // --------------------------------------------------------------------------- +async fn load_usenet_server( + pool: &sqlx::MySqlPool, + id: i32, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, DownloadClientRow>( + "SELECT id, name, client_type, protocol, config, enabled, priority + FROM download_clients + WHERE id = ? AND client_type = 'embedded_usenet'", + ) + .bind(id) + .fetch_optional(pool) + .await +} + fn engine_not_initialized() -> impl IntoResponse { ( StatusCode::SERVICE_UNAVAILABLE, @@ -914,18 +928,27 @@ async fn usenet_servers_add( let priority = body.priority.unwrap_or(0); let pool = state.db.pool(); - let row = match sqlx::query_as::<_, DownloadClientRow>( - "INSERT INTO download_clients (name, client_type, protocol, config, enabled, priority) - VALUES ($1, 'embedded_usenet', 'usenet', $2, $3, $4) - RETURNING id, name, client_type, protocol, config, enabled, priority", - ) - .bind(&display_name) - .bind(&config_json) - .bind(enabled) - .bind(priority) - .fetch_one(pool) - .await - { + let created = async { + let result = sqlx::query( + "INSERT INTO download_clients (name, client_type, protocol, config, enabled, priority) + VALUES (?, 'embedded_usenet', 'usenet', ?, ?, ?)", + ) + .bind(&display_name) + .bind(&config_json) + .bind(enabled) + .bind(priority) + .execute(pool) + .await?; + let id = i32::try_from(result.last_insert_id()).map_err(|error| { + sqlx::Error::Protocol(format!("download client id overflow: {error}")) + })?; + load_usenet_server(pool, id) + .await? + .ok_or(sqlx::Error::RowNotFound) + } + .await; + + let row = match created { Ok(row) => row, Err(e) => { error!("Failed to insert usenet server: {e}"); @@ -958,15 +981,7 @@ async fn usenet_servers_update( let pool = state.db.pool(); // Fetch existing row - let existing = match sqlx::query_as::<_, DownloadClientRow>( - "SELECT id, name, client_type, protocol, config, enabled, priority - FROM download_clients - WHERE id = $1 AND client_type = 'embedded_usenet'", - ) - .bind(id) - .fetch_optional(pool) - .await - { + let existing = match load_usenet_server(pool, id).await { Ok(Some(row)) => row, Ok(None) => { return ( @@ -1017,20 +1032,26 @@ async fn usenet_servers_update( let enabled = body.enabled.unwrap_or(existing.enabled); let priority = body.priority.unwrap_or(existing.priority); - let row = match sqlx::query_as::<_, DownloadClientRow>( - "UPDATE download_clients - SET name = $1, config = $2, enabled = $3, priority = $4 - WHERE id = $5 - RETURNING id, name, client_type, protocol, config, enabled, priority", - ) - .bind(&display_name) - .bind(&config_json) - .bind(enabled) - .bind(priority) - .bind(id) - .fetch_one(pool) - .await - { + let updated = async { + sqlx::query( + "UPDATE download_clients + SET name = ?, config = ?, enabled = ?, priority = ? + WHERE id = ? AND client_type = 'embedded_usenet'", + ) + .bind(&display_name) + .bind(&config_json) + .bind(enabled) + .bind(priority) + .bind(id) + .execute(pool) + .await?; + load_usenet_server(pool, id) + .await? + .ok_or(sqlx::Error::RowNotFound) + } + .await; + + let row = match updated { Ok(row) => row, Err(e) => { error!("Failed to update usenet server {id}: {e}"); @@ -1060,7 +1081,7 @@ async fn usenet_servers_delete( let result = match sqlx::query( "DELETE FROM download_clients - WHERE id = $1 AND client_type = 'embedded_usenet'", + WHERE id = ? AND client_type = 'embedded_usenet'", ) .bind(id) .execute(pool) @@ -1155,7 +1176,7 @@ async fn usenet_servers_test( let row = match sqlx::query_as::<_, DownloadClientRow>( "SELECT id, name, client_type, protocol, config, enabled, priority FROM download_clients - WHERE id = $1 AND client_type = 'embedded_usenet'", + WHERE id = ? AND client_type = 'embedded_usenet'", ) .bind(id) .fetch_optional(pool) @@ -1324,8 +1345,8 @@ async fn usenet_settings_update( qm.set_max_active_downloads(max); // Persist to DB so it survives restarts let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('usenet_max_active_downloads', $1::jsonb) \ - ON CONFLICT (key) DO UPDATE SET value = $1::jsonb", + "INSERT INTO app_config (key, value) VALUES ('usenet_max_active_downloads', ?) \ + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(serde_json::json!(max)) .execute(state.db.pool()) @@ -1342,8 +1363,8 @@ async fn usenet_settings_update( let _ = tokio::fs::create_dir_all(&path).await; qm.set_incomplete_dir(path); let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('usenet_incomplete_dir', $1::jsonb) \ - ON CONFLICT (key) DO UPDATE SET value = $1::jsonb", + "INSERT INTO app_config (key, value) VALUES ('usenet_incomplete_dir', ?) \ + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(serde_json::json!(dir)) .execute(state.db.pool()) @@ -1354,8 +1375,8 @@ async fn usenet_settings_update( let _ = tokio::fs::create_dir_all(&path).await; qm.set_complete_dir(path); let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('usenet_complete_dir', $1::jsonb) \ - ON CONFLICT (key) DO UPDATE SET value = $1::jsonb", + "INSERT INTO app_config (key, value) VALUES ('usenet_complete_dir', ?) \ + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(serde_json::json!(dir)) .execute(state.db.pool()) @@ -1489,7 +1510,7 @@ async fn import_sabnzbd_apply( let result = sqlx::query( "INSERT INTO download_clients (name, client_type, protocol, config, enabled, priority) - VALUES ($1, 'embedded_usenet', $2, $3, $4, $5)", + VALUES (?, 'embedded_usenet', ?, ?, ?, ?)", ) .bind(&imported.name) .bind(protocol) @@ -1508,8 +1529,8 @@ async fn import_sabnzbd_apply( if !preview.categories.is_empty() { let cats_json = serde_json::to_value(&preview.categories).unwrap_or_default(); let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('usenet_categories', $1) - ON CONFLICT (key) DO UPDATE SET value = $1", + "INSERT INTO app_config (key, value) VALUES ('usenet_categories', ?) + ON DUPLICATE KEY UPDATE value = VALUES(value)", ) .bind(&cats_json) .execute(pool) @@ -1531,9 +1552,9 @@ async fn import_sabnzbd_apply( /// Query the stackarr `history` table for `download_imported` records matching /// the given download_id and return the import log lines stored in their `data` /// field. Returns an empty vec if nothing is found or data is absent. -async fn import_log_lines_for_download(pool: &sqlx::PgPool, download_id: &str) -> Vec { +async fn import_log_lines_for_download(pool: &sqlx::MySqlPool, download_id: &str) -> Vec { let rows: Vec<(Option,)> = match sqlx::query_as( - "SELECT data FROM history WHERE download_id = $1 AND event_type = 'download_imported' \ + "SELECT data FROM history WHERE download_id = ? AND event_type = 'download_imported' \ ORDER BY occurred_at DESC LIMIT 1", ) .bind(download_id) diff --git a/crates/stackarr-web/src/routes/wanted.rs b/crates/stackarr-web/src/routes/wanted.rs index e2ce09e8..89b2a5bf 100644 --- a/crates/stackarr-web/src/routes/wanted.rs +++ b/crates/stackarr-web/src/routes/wanted.rs @@ -131,8 +131,8 @@ async fn get_missing( s.title, e.season_number, e.episode_number, e.title as episode_title, qp.name as quality_profile, - e.air_date::text as air_date, e.monitored, - NULL::text as current_quality, NULL::text as cutoff_quality + CAST(e.air_date AS CHAR) as air_date, e.monitored, + CAST(NULL AS CHAR) as current_quality, CAST(NULL AS CHAR) as cutoff_quality FROM episodes e JOIN series s ON e.series_id = s.id LEFT JOIN quality_profiles qp ON s.quality_profile_id = qp.id @@ -140,8 +140,8 @@ async fn get_missing( AND e.episode_file_id IS NULL AND e.season_number > 0 AND (e.air_date IS NULL OR e.air_date <= CURRENT_DATE) - ORDER BY e.air_date DESC NULLS LAST - LIMIT $1 OFFSET $2", + ORDER BY e.air_date IS NULL, e.air_date DESC + LIMIT ? OFFSET ?", ) .bind(ep_limit) .bind(ep_offset) @@ -162,16 +162,16 @@ async fn get_missing( if movie_limit > 0 { match sqlx::query_as::<_, WantedRecord>( "SELECT m.id, 'movie' as media_type, m.id as media_id, - m.title, NULL::int as season_number, NULL::int as episode_number, - NULL::text as episode_title, + m.title, CAST(NULL AS SIGNED) as season_number, CAST(NULL AS SIGNED) as episode_number, + CAST(NULL AS CHAR) as episode_title, qp.name as quality_profile, - NULL::text as air_date, m.monitored, - NULL::text as current_quality, NULL::text as cutoff_quality + CAST(NULL AS CHAR) as air_date, m.monitored, + CAST(NULL AS CHAR) as current_quality, CAST(NULL AS CHAR) as cutoff_quality FROM movies m LEFT JOIN quality_profiles qp ON m.quality_profile_id = qp.id WHERE m.monitored = true AND m.movie_file_id IS NULL ORDER BY m.title - LIMIT $1 OFFSET $2", + LIMIT ? OFFSET ?", ) .bind(movie_limit) .bind(movie_offset) @@ -217,7 +217,7 @@ async fn get_cutoff( JOIN quality_profiles qp ON s.quality_profile_id = qp.id WHERE e.monitored = true AND s.monitored = true AND e.episode_file_id IS NOT NULL - AND (mf.quality->>'quality')::int < qp.cutoff", + AND CAST(JSON_UNQUOTE(JSON_EXTRACT(mf.quality, '$.quality')) AS SIGNED) < qp.cutoff", ) .fetch_one(pool) .await @@ -238,7 +238,7 @@ async fn get_cutoff( JOIN media_files mf ON m.movie_file_id = mf.id JOIN quality_profiles qp ON m.quality_profile_id = qp.id WHERE m.monitored = true AND m.movie_file_id IS NOT NULL - AND (mf.quality->>'quality')::int < qp.cutoff", + AND CAST(JSON_UNQUOTE(JSON_EXTRACT(mf.quality, '$.quality')) AS SIGNED) < qp.cutoff", ) .fetch_one(pool) .await @@ -266,18 +266,18 @@ async fn get_cutoff( s.title, e.season_number, e.episode_number, e.title as episode_title, qp.name as quality_profile, - e.air_date::text as air_date, e.monitored, - (mf.quality->>'quality') as current_quality, - qp.cutoff::text as cutoff_quality + CAST(e.air_date AS CHAR) as air_date, e.monitored, + JSON_UNQUOTE(JSON_EXTRACT(mf.quality, '$.quality')) as current_quality, + CAST(qp.cutoff AS CHAR) as cutoff_quality FROM episodes e JOIN series s ON e.series_id = s.id JOIN media_files mf ON e.episode_file_id = mf.id JOIN quality_profiles qp ON s.quality_profile_id = qp.id WHERE e.monitored = true AND s.monitored = true AND e.episode_file_id IS NOT NULL - AND (mf.quality->>'quality')::int < qp.cutoff - ORDER BY e.air_date DESC NULLS LAST - LIMIT $1 OFFSET $2", + AND CAST(JSON_UNQUOTE(JSON_EXTRACT(mf.quality, '$.quality')) AS SIGNED) < qp.cutoff + ORDER BY e.air_date IS NULL, e.air_date DESC + LIMIT ? OFFSET ?", ) .bind(ep_limit) .bind(ep_offset) @@ -298,19 +298,19 @@ async fn get_cutoff( if movie_limit > 0 { match sqlx::query_as::<_, WantedRecord>( "SELECT m.id, 'movie' as media_type, m.id as media_id, - m.title, NULL::int as season_number, NULL::int as episode_number, - NULL::text as episode_title, + m.title, CAST(NULL AS SIGNED) as season_number, CAST(NULL AS SIGNED) as episode_number, + CAST(NULL AS CHAR) as episode_title, qp.name as quality_profile, - NULL::text as air_date, m.monitored, - (mf.quality->>'quality') as current_quality, - qp.cutoff::text as cutoff_quality + CAST(NULL AS CHAR) as air_date, m.monitored, + JSON_UNQUOTE(JSON_EXTRACT(mf.quality, '$.quality')) as current_quality, + CAST(qp.cutoff AS CHAR) as cutoff_quality FROM movies m JOIN media_files mf ON m.movie_file_id = mf.id JOIN quality_profiles qp ON m.quality_profile_id = qp.id WHERE m.monitored = true AND m.movie_file_id IS NOT NULL - AND (mf.quality->>'quality')::int < qp.cutoff + AND CAST(JSON_UNQUOTE(JSON_EXTRACT(mf.quality, '$.quality')) AS SIGNED) < qp.cutoff ORDER BY m.title - LIMIT $1 OFFSET $2", + LIMIT ? OFFSET ?", ) .bind(movie_limit) .bind(movie_offset) diff --git a/crates/stackarr-web/src/routes/watchlist.rs b/crates/stackarr-web/src/routes/watchlist.rs index 705fab78..88c64d92 100644 --- a/crates/stackarr-web/src/routes/watchlist.rs +++ b/crates/stackarr-web/src/routes/watchlist.rs @@ -63,7 +63,7 @@ async fn list_watchlist( let (title, poster_url, year) = match item.media_type.as_str() { "series" => { let row: Option<(String, Option, Option)> = - sqlx::query_as("SELECT title, images, year FROM series WHERE id = $1") + sqlx::query_as("SELECT title, images, year FROM series WHERE id = ?") .bind(item.media_id) .fetch_optional(pool) .await @@ -75,7 +75,7 @@ async fn list_watchlist( } "movie" => { let row: Option<(String, Option, Option)> = - sqlx::query_as("SELECT title, images, year FROM movies WHERE id = $1") + sqlx::query_as("SELECT title, images, year FROM movies WHERE id = ?") .bind(item.media_id) .fetch_optional(pool) .await @@ -121,12 +121,12 @@ async fn add_to_watchlist( // Look up tmdb_id from the media table let pool = state.db.pool(); let tmdb_id: Option> = match media_type.as_str() { - "series" => sqlx::query_scalar("SELECT tmdb_id FROM series WHERE id = $1") + "series" => sqlx::query_scalar("SELECT tmdb_id FROM series WHERE id = ?") .bind(media_id) .fetch_optional(pool) .await .unwrap_or(None), - "movie" => sqlx::query_scalar("SELECT tmdb_id FROM movies WHERE id = $1") + "movie" => sqlx::query_scalar("SELECT tmdb_id FROM movies WHERE id = ?") .bind(media_id) .fetch_optional(pool) .await diff --git a/crates/stackarr-web/src/state.rs b/crates/stackarr-web/src/state.rs index a05a778d..49849f63 100644 --- a/crates/stackarr-web/src/state.rs +++ b/crates/stackarr-web/src/state.rs @@ -90,7 +90,7 @@ impl AppState { /// Load a directory path from the `app_config` DB table. async fn load_dir_setting(&self, key: &str) -> Option { - sqlx::query_scalar::<_, serde_json::Value>("SELECT value FROM app_config WHERE key = $1") + sqlx::query_scalar::<_, serde_json::Value>("SELECT value FROM app_config WHERE key = ?") .bind(key) .fetch_optional(self.db.pool()) .await @@ -339,7 +339,7 @@ impl AppState { let provider = Arc::new(nzbdav_stream::UsenetArticleProvider::new(dav_pools)); let dav_db: Arc = Arc::new( - stackarr_core::dav_db::PostgresDavDatabase::new(self.db.pool().clone()), + stackarr_core::dav_db::MariaDbDavDatabase::new(self.db.pool().clone()), ); // Seed root DAV filesystem items diff --git a/docs/MARIADB-PLACEHOLDER-AUDIT.json b/docs/MARIADB-PLACEHOLDER-AUDIT.json index 7202865c..d6b071a6 100644 --- a/docs/MARIADB-PLACEHOLDER-AUDIT.json +++ b/docs/MARIADB-PLACEHOLDER-AUDIT.json @@ -1,61 +1,5 @@ { - "unsafe_queries": [ - "crates/stackarr-core/src/db.rs:106: [1, 2, 2] (expected [1, 2, 3])", - "crates/stackarr-core/src/db.rs:556: [1, 2, 3, 4, 5, 6, 7, 8, 6, 7, 8] (expected [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])", - "crates/stackarr-core/src/db.rs:994: [1, 2, 3, 4, 4] (expected [1, 2, 3, 4, 5])", - "crates/stackarr-core/src/db.rs:1215: [1, 2, 3, 4, 5, 1, 3, 4, 5] (expected [1, 2, 3, 4, 5, 6, 7, 8, 9])", - "crates/stackarr-core/src/db.rs:1285: [2, 3, 1] (expected [1, 2, 3])", - "crates/stackarr-core/src/db.rs:1306: [2, 3, 4, 5, 1] (expected [1, 2, 3, 4, 5])", - "crates/stackarr-core/src/models/import_candidate.rs:142: [2, 3, 4, 5, 6, 7, 1] (expected [1, 2, 3, 4, 5, 6, 7])", - "crates/stackarr-core/src/models/import_candidate.rs:166: [2, 3, 1] (expected [1, 2, 3])", - "crates/stackarr-core/src/models/import_candidate.rs:193: [2, 1] (expected [1, 2])", - "crates/stackarr-media/src/import_lists.rs:246: [1, 2, 2, 3, 4, 5, 6, 7, 8] (expected [1, 2, 3, 4, 5, 6, 7, 8, 9])", - "crates/stackarr-media/src/import_lists.rs:287: [1, 2, 2, 3, 4, 5, 6, 7] (expected [1, 2, 3, 4, 5, 6, 7, 8])", - "crates/stackarr-media/src/lib.rs:194: [1, 2, 3, 2] (expected [1, 2, 3, 4])", - "crates/stackarr-media/src/lib.rs:206: [1, 2, 2, 3, 4, 5, 6] (expected [1, 2, 3, 4, 5, 6, 7])", - "crates/stackarr-media/src/lib.rs:323: [1, 2, 3, 2] (expected [1, 2, 3, 4])", - "crates/stackarr-media/src/lib.rs:335: [1, 2, 2, 3, 4, 5, 6] (expected [1, 2, 3, 4, 5, 6, 7])", - "crates/stackarr-media/src/lib.rs:458: [1, 2, 3, 3] (expected [1, 2, 3, 4])", - "crates/stackarr-media/src/lib.rs:501: [2, 1] (expected [1, 2])", - "crates/stackarr-migrate/src/writer.rs:1685: [1, 2, 3, 3] (expected [1, 2, 3, 4])", - "crates/stackarr-migrate/src/writer.rs:1736: [1, 2, 3, 4, 5, 6, 7, 8, 9, 2, 3, 4, 5, 6, 7, 8, 9] (expected [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17])", - "crates/stackarr-stream/src/session.rs:188: [1, 2, 3, 3] (expected [1, 2, 3, 4])", - "crates/stackarr-stream/src/session.rs:332: [1, 2, 3, 3, 4] (expected [1, 2, 3, 4, 5])", - "crates/stackarr-stream/src/session.rs:451: [1, 2, 3, 3, 4] (expected [1, 2, 3, 4, 5])", - "crates/stackarr-web/src/routes/auth.rs:544: [1, 1] (expected [1, 2])", - "crates/stackarr-web/src/routes/auth.rs:560: [1, 1] (expected [1, 2])", - "crates/stackarr-web/src/routes/backup.rs:360: [1, 2, 3, 4, 5, 6, 7, 8, 2, 3, 4, 5, 6, 7, 8] (expected [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])", - "crates/stackarr-web/src/routes/backup.rs:568: [1, 2, 2] (expected [1, 2, 3])", - "crates/stackarr-web/src/routes/downloadclients.rs:237: [1, 2, 2] (expected [1, 2, 3])", - "crates/stackarr-web/src/routes/import_candidates.rs:251: [1, 2, 2, 3, 4, 5, 6, 7] (expected [1, 2, 3, 4, 5, 6, 7, 8])", - "crates/stackarr-web/src/routes/import_candidates.rs:381: [1, 2, 2, 3, 4, 5, 6, 7, 8] (expected [1, 2, 3, 4, 5, 6, 7, 8, 9])", - "crates/stackarr-web/src/routes/indexers.rs:206: [1, 1] (expected [1, 2])", - "crates/stackarr-web/src/routes/manual_import.rs:367: [1, 1] (expected [1, 2])", - "crates/stackarr-web/src/routes/manual_import.rs:396: [1, 1, 2] (expected [1, 2, 3])", - "crates/stackarr-web/src/routes/mediamanagement.rs:70: [1, 1] (expected [1, 2])", - "crates/stackarr-web/src/routes/mediamanagement.rs:80: [1, 1] (expected [1, 2])", - "crates/stackarr-web/src/routes/plex.rs:228: [1, 2, 3, 4, 3] (expected [1, 2, 3, 4, 5])", - "crates/stackarr-web/src/routes/plex.rs:465: [1, 1] (expected [1, 2])", - "crates/stackarr-web/src/routes/system.rs:291: [1, 2, 2] (expected [1, 2, 3])", - "crates/stackarr-web/src/routes/system.rs:335: [1, 2, 2] (expected [1, 2, 3])", - "crates/stackarr-web/src/routes/system.rs:390: [2, 1, 1, 2, 1] (expected [1, 2, 3, 4, 5])", - "crates/stackarr-web/src/routes/system.rs:394: [2, 1, 1, 2, 1] (expected [1, 2, 3, 4, 5])", - "crates/stackarr-web/src/routes/system.rs:398: [2, 1, 1, 2, 1] (expected [1, 2, 3, 4, 5])", - "crates/stackarr-web/src/routes/system.rs:415: [1, 2, 1] (expected [1, 2, 3])", - "crates/stackarr-web/src/routes/system.rs:427: [1, 2, 1] (expected [1, 2, 3])", - "crates/stackarr-web/src/routes/system.rs:451: [2, 1, 1] (expected [1, 2, 3])", - "crates/stackarr-web/src/routes/system.rs:465: [2, 1, 1] (expected [1, 2, 3])", - "crates/stackarr-web/src/routes/system.rs:484: [1, 1] (expected [1, 2])", - "crates/stackarr-web/src/routes/system.rs:524: [1, 1] (expected [1, 2])", - "crates/stackarr-web/src/routes/system.rs:548: [1, 2, 2] (expected [1, 2, 3])", - "crates/stackarr-web/src/routes/system.rs:570: [1, 1] (expected [1, 2])", - "crates/stackarr-web/src/routes/system.rs:609: [1, 1] (expected [1, 2])", - "crates/stackarr-web/src/routes/system.rs:2652: [1, 2, 2] (expected [1, 2, 3])", - "crates/stackarr-web/src/routes/torrent.rs:399: [1, 1] (expected [1, 2])", - "crates/stackarr-web/src/routes/torrent.rs:412: [1, 1] (expected [1, 2])", - "crates/stackarr-web/src/routes/usenet.rs:1327: [1, 1] (expected [1, 2])", - "crates/stackarr-web/src/routes/usenet.rs:1345: [1, 1] (expected [1, 2])", - "crates/stackarr-web/src/routes/usenet.rs:1357: [1, 1] (expected [1, 2])", - "crates/stackarr-web/src/routes/usenet.rs:1511: [1, 1] (expected [1, 2])" - ] + "placeholder_count": 1, + "unsafe_query_count": 0, + "unsafe_queries": [] } diff --git a/migrations/001_baseline.sql b/migrations/001_baseline.sql new file mode 100644 index 00000000..ea8c2e51 --- /dev/null +++ b/migrations/001_baseline.sql @@ -0,0 +1,907 @@ +-- SPDX-License-Identifier: GPL-3.0-only +-- StackArr fresh-deploy baseline for MariaDB 11.4 LTS. +-- Datetimes are UTC DATETIME(6); JSON replaces PostgreSQL arrays and JSONB. + +CREATE TABLE app_config ( + `key` VARCHAR(191) PRIMARY KEY, + value JSON NOT NULL +) ENGINE=InnoDB; + +CREATE TABLE enabled_modules ( + id INT AUTO_INCREMENT PRIMARY KEY, + module VARCHAR(191) NOT NULL UNIQUE, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + config JSON +) ENGINE=InnoDB; + +CREATE TABLE media_library_folders ( + id INT AUTO_INCREMENT PRIMARY KEY, + path VARCHAR(2048) NOT NULL, + media_type VARCHAR(32) NOT NULL, + free_space BIGINT, + last_checked DATETIME(6), + UNIQUE KEY uq_media_library_folder_path (path(768)) +) ENGINE=InnoDB; + +CREATE TABLE tags ( + id INT AUTO_INCREMENT PRIMARY KEY, + label VARCHAR(191) NOT NULL UNIQUE +) ENGINE=InnoDB; + +CREATE TABLE quality_profiles ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + cutoff INT NOT NULL, + upgrade_allowed BOOLEAN NOT NULL DEFAULT TRUE, + min_format_score INT NOT NULL DEFAULT 0, + cutoff_format_score INT NOT NULL DEFAULT 0, + min_upgrade_format_score INT NOT NULL DEFAULT 1, + items JSON NOT NULL, + media_type VARCHAR(32), + language INT NOT NULL DEFAULT -1 +) ENGINE=InnoDB; + +CREATE TABLE custom_formats ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + specifications JSON NOT NULL, + include_custom_format_when_renaming BOOLEAN NOT NULL DEFAULT FALSE +) ENGINE=InnoDB; + +CREATE TABLE custom_format_scores ( + profile_id INT NOT NULL, + format_id INT NOT NULL, + score INT NOT NULL, + PRIMARY KEY (profile_id, format_id), + CONSTRAINT fk_custom_format_scores_profile FOREIGN KEY (profile_id) REFERENCES quality_profiles(id) ON DELETE CASCADE, + CONSTRAINT fk_custom_format_scores_format FOREIGN KEY (format_id) REFERENCES custom_formats(id) ON DELETE CASCADE +) ENGINE=InnoDB; + +-- Generic identity shared by all present and future media-type adapters. +CREATE TABLE media_entities ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + media_type VARCHAR(32) NOT NULL, + source_key VARCHAR(255) NOT NULL, + title VARCHAR(1024) NOT NULL, + sort_title VARCHAR(1024) NOT NULL, + year INT, + monitored BOOLEAN NOT NULL DEFAULT TRUE, + library_folder_id INT, + quality_profile_id INT, + external_ids JSON NOT NULL, + metadata JSON NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + UNIQUE KEY uq_media_entity_source (media_type, source_key), + KEY idx_media_entities_type_title (media_type, title(191)), + CONSTRAINT fk_media_entities_folder FOREIGN KEY (library_folder_id) REFERENCES media_library_folders(id) ON DELETE SET NULL, + CONSTRAINT fk_media_entities_profile FOREIGN KEY (quality_profile_id) REFERENCES quality_profiles(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE TABLE series ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + entity_id BIGINT UNIQUE, + title VARCHAR(1024) NOT NULL, + clean_title VARCHAR(1024) NOT NULL, + sort_title VARCHAR(1024) NOT NULL, + overview LONGTEXT, + status VARCHAR(32) NOT NULL DEFAULT 'continuing', + series_type VARCHAR(32) NOT NULL DEFAULT 'standard', + network VARCHAR(255), + air_time TIME, + first_aired DATE, + year INT, + runtime INT, + path VARCHAR(2048) NOT NULL, + media_library_folder_id INT, + quality_profile_id INT, + season_folder BOOLEAN NOT NULL DEFAULT TRUE, + monitored BOOLEAN NOT NULL DEFAULT TRUE, + use_scene_numbering BOOLEAN NOT NULL DEFAULT FALSE, + tvdb_id BIGINT, + imdb_id VARCHAR(32), + tmdb_id BIGINT, + tvmaze_id BIGINT, + mal_id BIGINT, + images JSON, + genres JSON, + tags JSON, + added_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + last_info_sync DATETIME(6), + plex_rating_key VARCHAR(255), + plex_rating_key_4k VARCHAR(255), + media_added_at DATETIME(6), + KEY idx_series_tvdb (tvdb_id), + KEY idx_series_tmdb (tmdb_id), + KEY idx_series_imdb (imdb_id), + KEY idx_series_clean_title (clean_title(191)), + CONSTRAINT fk_series_entity FOREIGN KEY (entity_id) REFERENCES media_entities(id) ON DELETE SET NULL, + CONSTRAINT fk_series_folder FOREIGN KEY (media_library_folder_id) REFERENCES media_library_folders(id) ON DELETE SET NULL, + CONSTRAINT fk_series_profile FOREIGN KEY (quality_profile_id) REFERENCES quality_profiles(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE TABLE seasons ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + series_id BIGINT NOT NULL, + season_number INT NOT NULL, + monitored BOOLEAN NOT NULL DEFAULT TRUE, + UNIQUE KEY uq_season (series_id, season_number), + CONSTRAINT fk_seasons_series FOREIGN KEY (series_id) REFERENCES series(id) ON DELETE CASCADE +) ENGINE=InnoDB; + +CREATE TABLE media_files ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + media_type VARCHAR(32) NOT NULL, + relative_path VARCHAR(2048) NOT NULL, + size BIGINT NOT NULL, + date_added DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + quality JSON NOT NULL, + languages JSON NOT NULL, + scene_name VARCHAR(1024), + release_group VARCHAR(255), + release_hash VARCHAR(255), + edition VARCHAR(255), + media_info JSON, + indexer_flags INT NOT NULL DEFAULT 0, + KEY idx_media_files_media_type (media_type) +) ENGINE=InnoDB; + +CREATE TABLE episodes ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + series_id BIGINT NOT NULL, + season_number INT NOT NULL, + episode_number INT NOT NULL, + absolute_number INT, + scene_season_number INT, + scene_episode_number INT, + scene_absolute_number INT, + title VARCHAR(1024), + overview LONGTEXT, + air_date DATE, + air_date_utc DATETIME(6), + runtime INT, + monitored BOOLEAN NOT NULL DEFAULT TRUE, + episode_file_id BIGINT, + last_search_time DATETIME(6), + UNIQUE KEY uq_episode (series_id, season_number, episode_number), + KEY idx_episodes_air_date (air_date_utc), + CONSTRAINT fk_episodes_series FOREIGN KEY (series_id) REFERENCES series(id) ON DELETE CASCADE, + CONSTRAINT fk_episodes_file FOREIGN KEY (episode_file_id) REFERENCES media_files(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE TABLE episode_files ( + episode_id BIGINT NOT NULL, + media_file_id BIGINT NOT NULL, + PRIMARY KEY (episode_id, media_file_id), + KEY idx_episode_files_media_file (media_file_id), + CONSTRAINT fk_episode_files_episode FOREIGN KEY (episode_id) REFERENCES episodes(id) ON DELETE CASCADE, + CONSTRAINT fk_episode_files_media FOREIGN KEY (media_file_id) REFERENCES media_files(id) ON DELETE CASCADE +) ENGINE=InnoDB; + +CREATE TABLE movies ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + entity_id BIGINT UNIQUE, + title VARCHAR(1024) NOT NULL, + clean_title VARCHAR(1024) NOT NULL, + sort_title VARCHAR(1024) NOT NULL, + overview LONGTEXT, + year INT, + studio VARCHAR(255), + path VARCHAR(2048) NOT NULL, + media_library_folder_id INT, + quality_profile_id INT, + monitored BOOLEAN NOT NULL DEFAULT TRUE, + minimum_availability VARCHAR(32) NOT NULL DEFAULT 'released', + movie_file_id BIGINT, + tmdb_id BIGINT, + imdb_id VARCHAR(32), + in_cinemas DATE, + physical_release DATE, + digital_release DATE, + images JSON, + genres JSON, + tags JSON, + collection_tmdb_id BIGINT, + added_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + last_info_sync DATETIME(6), + plex_rating_key VARCHAR(255), + plex_rating_key_4k VARCHAR(255), + media_added_at DATETIME(6), + original_language INT, + KEY idx_movies_tmdb (tmdb_id), + KEY idx_movies_imdb (imdb_id), + KEY idx_movies_clean_title (clean_title(191)), + KEY idx_movies_movie_file (movie_file_id), + CONSTRAINT fk_movies_entity FOREIGN KEY (entity_id) REFERENCES media_entities(id) ON DELETE SET NULL, + CONSTRAINT fk_movies_folder FOREIGN KEY (media_library_folder_id) REFERENCES media_library_folders(id) ON DELETE SET NULL, + CONSTRAINT fk_movies_profile FOREIGN KEY (quality_profile_id) REFERENCES quality_profiles(id) ON DELETE SET NULL, + CONSTRAINT fk_movies_file FOREIGN KEY (movie_file_id) REFERENCES media_files(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE TABLE alternative_titles ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + media_type VARCHAR(32) NOT NULL, + media_id BIGINT NOT NULL, + title VARCHAR(1024) NOT NULL, + clean_title VARCHAR(1024) NOT NULL, + scene_name BOOLEAN NOT NULL DEFAULT FALSE, + KEY idx_alt_titles_clean (clean_title(191)) +) ENGINE=InnoDB; + +CREATE TABLE indexers ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + indexer_type VARCHAR(64) NOT NULL, + base_url VARCHAR(2048) NOT NULL, + api_key TEXT, + protocol VARCHAR(32) NOT NULL, + categories JSON, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + priority INT NOT NULL DEFAULT 25, + supports_search BOOLEAN NOT NULL DEFAULT TRUE, + supports_rss BOOLEAN NOT NULL DEFAULT TRUE, + config JSON, + last_rss_sync DATETIME(6), + last_health_check DATETIME(6), + health_status VARCHAR(32) NOT NULL DEFAULT 'unknown', + consecutive_failures INT NOT NULL DEFAULT 0, + auto_disabled BOOLEAN NOT NULL DEFAULT FALSE +) ENGINE=InnoDB; + +CREATE TABLE download_clients ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + client_type VARCHAR(64) NOT NULL, + protocol VARCHAR(32) NOT NULL, + config JSON NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + priority INT NOT NULL DEFAULT 1, + last_health_check DATETIME(6), + health_status VARCHAR(32) NOT NULL DEFAULT 'unknown', + consecutive_failures INT NOT NULL DEFAULT 0, + auto_disabled BOOLEAN NOT NULL DEFAULT FALSE +) ENGINE=InnoDB; + +CREATE TABLE queue ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + media_type VARCHAR(32) NOT NULL, + media_id BIGINT NOT NULL, + episode_id BIGINT, + title VARCHAR(2048) NOT NULL, + quality JSON NOT NULL, + languages JSON, + size BIGINT, + status VARCHAR(64) NOT NULL, + download_id VARCHAR(255) NOT NULL, + download_client_id INT, + indexer_id INT, + protocol VARCHAR(32) NOT NULL, + error_message LONGTEXT, + output_path VARCHAR(2048), + stale_count INT NOT NULL DEFAULT 0, + added_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + KEY idx_queue_download_id (download_id), + KEY idx_queue_media (media_type, media_id), + CONSTRAINT fk_queue_client FOREIGN KEY (download_client_id) REFERENCES download_clients(id) ON DELETE SET NULL, + CONSTRAINT fk_queue_indexer FOREIGN KEY (indexer_id) REFERENCES indexers(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE TABLE history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + media_type VARCHAR(32) NOT NULL, + media_id BIGINT NOT NULL, + episode_id BIGINT, + event_type VARCHAR(64) NOT NULL, + quality JSON NOT NULL, + languages JSON, + source_title VARCHAR(2048) NOT NULL, + download_id VARCHAR(255), + indexer_id INT, + download_client VARCHAR(255), + data JSON, + occurred_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + KEY idx_history_media (media_type, media_id), + KEY idx_history_occurred (occurred_at DESC), + KEY idx_history_download_id (download_id) +) ENGINE=InnoDB; + +CREATE TABLE blocklist ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + media_type VARCHAR(32) NOT NULL, + media_id BIGINT NOT NULL, + source_title VARCHAR(2048) NOT NULL, + quality JSON NOT NULL, + languages JSON, + indexer_id INT, + info_hash VARCHAR(255), + message LONGTEXT, + added_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + KEY idx_blocklist_media (media_type, media_id), + KEY idx_blocklist_hash (info_hash) +) ENGINE=InnoDB; + +CREATE TABLE naming_config ( + id INT AUTO_INCREMENT PRIMARY KEY, + media_type VARCHAR(32) NOT NULL UNIQUE, + rename_files BOOLEAN NOT NULL DEFAULT TRUE, + standard_format TEXT, + daily_format TEXT, + anime_format TEXT, + season_folder_format TEXT, + movie_format TEXT, + movie_folder_format TEXT, + colon_replacement VARCHAR(32) NOT NULL DEFAULT 'smart' +) ENGINE=InnoDB; + +CREATE TABLE notification_providers ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + provider_type VARCHAR(64) NOT NULL, + config JSON NOT NULL, + on_grab BOOLEAN NOT NULL DEFAULT FALSE, + on_import BOOLEAN NOT NULL DEFAULT FALSE, + on_upgrade BOOLEAN NOT NULL DEFAULT FALSE, + on_health_issue BOOLEAN NOT NULL DEFAULT FALSE, + on_failure BOOLEAN NOT NULL DEFAULT FALSE, + enabled BOOLEAN NOT NULL DEFAULT TRUE +) ENGINE=InnoDB; + +CREATE TABLE import_lists ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + list_type VARCHAR(64) NOT NULL, + media_type VARCHAR(32) NOT NULL, + config JSON NOT NULL, + quality_profile_id INT, + media_library_folder_id INT, + monitored BOOLEAN NOT NULL DEFAULT TRUE, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + poll_interval_secs INT NOT NULL DEFAULT 3600, + CONSTRAINT fk_import_lists_profile FOREIGN KEY (quality_profile_id) REFERENCES quality_profiles(id) ON DELETE SET NULL, + CONSTRAINT fk_import_lists_folder FOREIGN KEY (media_library_folder_id) REFERENCES media_library_folders(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE TABLE discover_sliders ( + id INT AUTO_INCREMENT PRIMARY KEY, + slider_type VARCHAR(64) NOT NULL, + display_order INT NOT NULL DEFAULT 0, + is_built_in BOOLEAN NOT NULL DEFAULT FALSE, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + title VARCHAR(255), + custom_data JSON, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + KEY idx_discover_sliders_order (display_order) +) ENGINE=InnoDB; + +CREATE TABLE plex_servers ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL DEFAULT 'Plex', + machine_id VARCHAR(255), + ip VARCHAR(255) NOT NULL, + port INT NOT NULL DEFAULT 32400, + use_ssl BOOLEAN NOT NULL DEFAULT FALSE, + verify_tls BOOLEAN NOT NULL DEFAULT FALSE, + auth_token TEXT, + web_app_url VARCHAR(2048), + webhook_secret VARCHAR(255), + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) +) ENGINE=InnoDB; + +CREATE TABLE plex_libraries ( + id INT AUTO_INCREMENT PRIMARY KEY, + plex_server_id INT NOT NULL, + section_id VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + library_type VARCHAR(64) NOT NULL, + last_scan DATETIME(6), + UNIQUE KEY uq_plex_library (plex_server_id, section_id), + CONSTRAINT fk_plex_libraries_server FOREIGN KEY (plex_server_id) REFERENCES plex_servers(id) ON DELETE CASCADE +) ENGINE=InnoDB; + +CREATE TABLE plex_events ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + event_type VARCHAR(128) NOT NULL, + plex_server_id INT, + user_name VARCHAR(255), + title VARCHAR(1024), + rating_key VARCHAR(255), + metadata JSON, + thumb_url VARCHAR(2048), + received_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + KEY idx_plex_events_type (event_type), + KEY idx_plex_events_received (received_at DESC), + CONSTRAINT fk_plex_events_server FOREIGN KEY (plex_server_id) REFERENCES plex_servers(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE TABLE watchlist ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tmdb_id BIGINT NOT NULL, + media_type VARCHAR(32) NOT NULL, + plex_rating_key VARCHAR(255), + auto_requested BOOLEAN NOT NULL DEFAULT FALSE, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + UNIQUE KEY uq_watchlist_media (tmdb_id, media_type), + KEY idx_watchlist_tmdb (tmdb_id) +) ENGINE=InnoDB; + +CREATE TABLE streaming_sessions ( + id CHAR(36) PRIMARY KEY, + media_file_id BIGINT NOT NULL, + session_type VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'active', + started_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + last_activity DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + transcode_progress FLOAT, + video_codec VARCHAR(64), + audio_codec VARCHAR(64), + resolution VARCHAR(64), + bitrate BIGINT, + client_info TEXT, + transcode_dir VARCHAR(2048), + user_id BIGINT, + KEY idx_streaming_sessions_media (media_file_id), + KEY idx_streaming_sessions_status (status), + CONSTRAINT fk_streaming_sessions_media FOREIGN KEY (media_file_id) REFERENCES media_files(id) ON DELETE CASCADE +) ENGINE=InnoDB; + +CREATE TABLE remote_clients ( + id INT AUTO_INCREMENT PRIMARY KEY, + client_token CHAR(36) NOT NULL UNIQUE, + client_name VARCHAR(255), + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + last_seen DATETIME(6), + revoked BOOLEAN NOT NULL DEFAULT FALSE +) ENGINE=InnoDB; + +CREATE TABLE users ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(191) NOT NULL UNIQUE, + display_name VARCHAR(255) NOT NULL, + password_hash TEXT NOT NULL, + role VARCHAR(32) NOT NULL DEFAULT 'user', + avatar_url VARCHAR(2048), + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) +) ENGINE=InnoDB; + +ALTER TABLE streaming_sessions + ADD CONSTRAINT fk_streaming_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL; + +CREATE TABLE user_sessions ( + id CHAR(36) PRIMARY KEY, + user_id BIGINT NOT NULL, + token_hash VARCHAR(255) NOT NULL UNIQUE, + user_agent TEXT, + ip_address VARCHAR(45), + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + expires_at DATETIME(6) NOT NULL, + last_active DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + KEY idx_user_sessions_user (user_id), + KEY idx_user_sessions_expires (expires_at), + CONSTRAINT fk_user_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB; + +CREATE TABLE user_devices ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + device_token CHAR(36) NOT NULL UNIQUE, + device_name VARCHAR(255), + device_type VARCHAR(64), + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + last_seen DATETIME(6), + revoked BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT fk_user_devices_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB; + +CREATE TABLE invites ( + id INT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(191) NOT NULL UNIQUE, + created_by BIGINT NOT NULL, + claimed_by BIGINT, + role VARCHAR(32) NOT NULL DEFAULT 'user', + expires_at DATETIME(6), + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + CONSTRAINT fk_invites_creator FOREIGN KEY (created_by) REFERENCES users(id), + CONSTRAINT fk_invites_claimant FOREIGN KEY (claimed_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE TABLE watch_progress ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + media_file_id BIGINT NOT NULL, + media_type VARCHAR(32) NOT NULL, + media_id BIGINT NOT NULL, + episode_id BIGINT, + position_secs FLOAT NOT NULL DEFAULT 0, + duration_secs FLOAT NOT NULL DEFAULT 0, + completed BOOLEAN NOT NULL DEFAULT FALSE, + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + UNIQUE KEY uq_watch_progress_file (user_id, media_file_id), + KEY idx_watch_progress_user (user_id, updated_at DESC), + KEY idx_watch_progress_continue (user_id, completed, updated_at DESC), + KEY idx_watch_progress_media (media_type, media_id), + CONSTRAINT fk_watch_progress_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_watch_progress_file FOREIGN KEY (media_file_id) REFERENCES media_files(id) ON DELETE CASCADE +) ENGINE=InnoDB; + +CREATE TABLE media_requests ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + media_type VARCHAR(32) NOT NULL, + tmdb_id BIGINT NOT NULL, + title VARCHAR(1024) NOT NULL, + year INT, + poster_url VARCHAR(2048), + overview LONGTEXT, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + admin_note LONGTEXT, + approved_by BIGINT, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + UNIQUE KEY uq_media_request (tmdb_id, media_type), + KEY idx_media_requests_user (user_id), + KEY idx_media_requests_status (status), + CONSTRAINT fk_media_requests_user FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT fk_media_requests_approver FOREIGN KEY (approved_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE TABLE user_watchlist ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + media_type VARCHAR(32) NOT NULL, + media_id BIGINT NOT NULL, + tmdb_id BIGINT NOT NULL, + added_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + UNIQUE KEY uq_user_watchlist (user_id, media_type, media_id), + KEY idx_user_watchlist_user (user_id, added_at DESC), + CONSTRAINT fk_user_watchlist_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB; + +CREATE TABLE user_ratings ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + media_type VARCHAR(32) NOT NULL, + media_id BIGINT NOT NULL, + rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 10), + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + UNIQUE KEY uq_user_rating (user_id, media_type, media_id), + KEY idx_user_ratings_media (media_type, media_id), + CONSTRAINT fk_user_ratings_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB; + +CREATE TABLE user_notifications ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + notification_type VARCHAR(64) NOT NULL, + title VARCHAR(1024) NOT NULL, + body LONGTEXT, + data JSON, + `read` BOOLEAN NOT NULL DEFAULT FALSE, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + KEY idx_user_notifications_user (user_id, `read`, created_at DESC), + KEY idx_user_notifications_created (created_at), + CONSTRAINT fk_user_notifications_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB; + +CREATE TABLE push_subscriptions ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + endpoint VARCHAR(2048) NOT NULL, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + user_agent TEXT, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + UNIQUE KEY uq_push_subscription_endpoint (endpoint(768)), + CONSTRAINT fk_push_subscriptions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB; + +CREATE TABLE recycle_bin ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + original_path VARCHAR(2048) NOT NULL, + recycle_path VARCHAR(2048) NOT NULL, + media_file_id BIGINT, + media_type VARCHAR(32) NOT NULL, + media_id BIGINT NOT NULL, + size BIGINT NOT NULL DEFAULT 0, + recycled_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + KEY idx_recycle_bin_recycled_at (recycled_at) +) ENGINE=InnoDB; + +CREATE TABLE system_activities ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + activity_type VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'running', + title VARCHAR(1024) NOT NULL, + detail LONGTEXT, + progress JSON, + result JSON, + error LONGTEXT, + started_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + completed_at DATETIME(6), + KEY idx_system_activities_status (status, started_at DESC), + KEY idx_system_activities_recent (started_at DESC) +) ENGINE=InnoDB; + +CREATE TABLE rss_feeds ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + url VARCHAR(2048) NOT NULL, + protocol VARCHAR(32) NOT NULL, + poll_interval_secs INT NOT NULL DEFAULT 900, + category VARCHAR(255), + filter_regex TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + auto_download BOOLEAN NOT NULL DEFAULT FALSE, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) +) ENGINE=InnoDB; + +CREATE TABLE rss_items ( + id VARCHAR(768) PRIMARY KEY, + feed_id BIGINT NOT NULL, + title VARCHAR(2048) NOT NULL, + url VARCHAR(2048), + published_at DATETIME(6), + first_seen_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + downloaded BOOLEAN NOT NULL DEFAULT FALSE, + downloaded_at DATETIME(6), + category VARCHAR(255), + size_bytes BIGINT DEFAULT 0, + KEY idx_rss_items_feed_id (feed_id), + KEY idx_rss_items_first_seen (first_seen_at DESC), + CONSTRAINT fk_rss_items_feed FOREIGN KEY (feed_id) REFERENCES rss_feeds(id) ON DELETE CASCADE +) ENGINE=InnoDB; + +CREATE TABLE rss_rules ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + feed_ids JSON NOT NULL, + category VARCHAR(255), + priority INT NOT NULL DEFAULT 1, + match_regex TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) +) ENGINE=InnoDB; + +CREATE TABLE dav_items ( + id CHAR(36) PRIMARY KEY, + id_prefix VARCHAR(64) NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + parent_id CHAR(36), + name VARCHAR(1024) NOT NULL, + file_size BIGINT, + item_type INT NOT NULL, + sub_type INT NOT NULL, + path VARCHAR(2048) NOT NULL, + release_date DATETIME(6), + last_health_check DATETIME(6), + next_health_check DATETIME(6), + history_item_id CHAR(36), + file_blob_id CHAR(36), + nzb_blob_id CHAR(36), + UNIQUE KEY uq_dav_item_name (parent_id, name(191)), + KEY idx_dav_items_prefix (id_prefix, item_type), + KEY idx_dav_items_type_created (item_type, created_at), + KEY idx_dav_items_sub_type (sub_type, created_at), + KEY idx_dav_items_history (history_item_id, item_type), + KEY idx_dav_items_nzb_blob (nzb_blob_id), + KEY idx_dav_items_path (path(768)), + CONSTRAINT fk_dav_items_parent FOREIGN KEY (parent_id) REFERENCES dav_items(id) ON DELETE CASCADE +) ENGINE=InnoDB; + +CREATE TABLE dav_blobs (id CHAR(36) PRIMARY KEY, data LONGBLOB NOT NULL) ENGINE=InnoDB; +CREATE TABLE dav_nzb_blobs (id CHAR(36) PRIMARY KEY, data LONGBLOB NOT NULL) ENGINE=InnoDB; + +CREATE TABLE dav_queue_items ( + id CHAR(36) PRIMARY KEY, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + file_name VARCHAR(2048) NOT NULL, + job_name VARCHAR(1024) NOT NULL, + nzb_file_size BIGINT NOT NULL DEFAULT 0, + total_segment_bytes BIGINT NOT NULL DEFAULT 0, + category VARCHAR(255) NOT NULL DEFAULT '', + priority INT NOT NULL DEFAULT 0, + post_processing INT NOT NULL DEFAULT -1, + pause_until DATETIME(6), + KEY idx_dav_queue_priority (priority DESC, created_at ASC) +) ENGINE=InnoDB; + +CREATE TABLE dav_history_items ( + id CHAR(36) PRIMARY KEY, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + file_name VARCHAR(2048) NOT NULL, + job_name VARCHAR(1024) NOT NULL, + category VARCHAR(255) NOT NULL DEFAULT '', + download_status INT NOT NULL, + total_segment_bytes BIGINT NOT NULL DEFAULT 0, + download_time_seconds INT NOT NULL DEFAULT 0, + fail_message LONGTEXT, + download_dir_id CHAR(36), + nzb_blob_id CHAR(36), + KEY idx_dav_history_created (created_at) +) ENGINE=InnoDB; + +CREATE TABLE dav_health_checks ( + id CHAR(36) PRIMARY KEY, + dav_item_id CHAR(36) NOT NULL, + path VARCHAR(2048) NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + result INT NOT NULL DEFAULT 0, + repair_status INT NOT NULL DEFAULT 0, + message LONGTEXT NOT NULL, + CONSTRAINT fk_dav_health_item FOREIGN KEY (dav_item_id) REFERENCES dav_items(id) ON DELETE CASCADE +) ENGINE=InnoDB; + +CREATE TABLE dav_config (`key` VARCHAR(191) PRIMARY KEY, value TEXT NOT NULL) ENGINE=InnoDB; + +CREATE TABLE import_candidates ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + media_library_folder_id INT, + media_type VARCHAR(32) NOT NULL, + match_kind VARCHAR(32) NOT NULL, + discovered_path VARCHAR(2048) NOT NULL, + file_count INT NOT NULL DEFAULT 1, + total_size BIGINT NOT NULL DEFAULT 0, + parsed_title VARCHAR(1024), + parsed_year INT, + parsed_season INT, + parsed_episodes JSON, + suggested_tmdb_id INT, + suggested_title VARCHAR(1024), + suggested_year INT, + suggested_poster VARCHAR(2048), + suggested_overview LONGTEXT, + confidence FLOAT NOT NULL DEFAULT 0.0, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + target_series_id BIGINT, + target_movie_id BIGINT, + error LONGTEXT, + data JSON NOT NULL, + discovered_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + resolved_at DATETIME(6), + pending_path_hash BINARY(32) GENERATED ALWAYS AS ( + IF(status = 'pending', UNHEX(SHA2(discovered_path, 256)), NULL) + ) STORED, + UNIQUE KEY uq_import_candidates_pending_path (pending_path_hash), + KEY idx_import_candidates_status (status), + KEY idx_import_candidates_media_type (media_type), + KEY idx_import_candidates_discovered_path (discovered_path(768)), + CONSTRAINT fk_import_candidates_folder FOREIGN KEY (media_library_folder_id) REFERENCES media_library_folders(id) ON DELETE CASCADE, + CONSTRAINT fk_import_candidates_series FOREIGN KEY (target_series_id) REFERENCES series(id) ON DELETE SET NULL, + CONSTRAINT fk_import_candidates_movie FOREIGN KEY (target_movie_id) REFERENCES movies(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +-- P5: upstream profile subscriptions, immutable snapshots, local overrides, provenance. +CREATE TABLE profile_sources ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + source_type VARCHAR(32) NOT NULL, + name VARCHAR(255) NOT NULL, + repository_url VARCHAR(2048) NOT NULL, + reference_name VARCHAR(255) NOT NULL DEFAULT 'main', + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + UNIQUE KEY uq_profile_source (repository_url(512), reference_name) +) ENGINE=InnoDB; + +CREATE TABLE profile_subscriptions ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + source_id BIGINT NOT NULL, + upstream_key VARCHAR(512) NOT NULL, + media_type VARCHAR(32) NOT NULL, + local_profile_id INT, + current_revision VARCHAR(255), + base_document JSON NOT NULL, + merged_document JSON NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + UNIQUE KEY uq_profile_subscription (source_id, upstream_key, media_type), + CONSTRAINT fk_profile_subscriptions_source FOREIGN KEY (source_id) REFERENCES profile_sources(id) ON DELETE CASCADE, + CONSTRAINT fk_profile_subscriptions_profile FOREIGN KEY (local_profile_id) REFERENCES quality_profiles(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE TABLE profile_snapshots ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + subscription_id BIGINT NOT NULL, + revision VARCHAR(255) NOT NULL, + document JSON NOT NULL, + changelog LONGTEXT, + fetched_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + UNIQUE KEY uq_profile_snapshot (subscription_id, revision), + CONSTRAINT fk_profile_snapshots_subscription FOREIGN KEY (subscription_id) REFERENCES profile_subscriptions(id) ON DELETE CASCADE +) ENGINE=InnoDB; + +CREATE TABLE profile_overrides ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + subscription_id BIGINT NOT NULL, + json_pointer VARCHAR(1024) NOT NULL, + base_value JSON, + local_value JSON, + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + UNIQUE KEY uq_profile_override (subscription_id, json_pointer(512)), + CONSTRAINT fk_profile_overrides_subscription FOREIGN KEY (subscription_id) REFERENCES profile_subscriptions(id) ON DELETE CASCADE +) ENGINE=InnoDB; + +CREATE TABLE custom_format_provenance ( + custom_format_id INT PRIMARY KEY, + subscription_id BIGINT, + upstream_key VARCHAR(512), + upstream_revision VARCHAR(255), + upstream_score INT, + local_score INT, + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + CONSTRAINT fk_custom_format_provenance_format FOREIGN KEY (custom_format_id) REFERENCES custom_formats(id) ON DELETE CASCADE, + CONSTRAINT fk_custom_format_provenance_subscription FOREIGN KEY (subscription_id) REFERENCES profile_subscriptions(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +-- P6: replayable decision outcomes and ordered per-spec explanations. +CREATE TABLE decision_records ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + media_type VARCHAR(32) NOT NULL, + media_id BIGINT, + release_guid VARCHAR(768) NOT NULL, + release_title VARCHAR(2048) NOT NULL, + accepted BOOLEAN NOT NULL, + total_score INT NOT NULL, + input JSON NOT NULL, + outcome JSON NOT NULL, + evaluated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + KEY idx_decision_records_media (media_type, media_id, evaluated_at DESC), + KEY idx_decision_records_guid (release_guid) +) ENGINE=InnoDB; + +CREATE TABLE decision_steps ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + decision_id BIGINT NOT NULL, + ordinal INT NOT NULL, + specification VARCHAR(255) NOT NULL, + accepted BOOLEAN NOT NULL, + score_delta INT NOT NULL DEFAULT 0, + reason LONGTEXT NOT NULL, + details JSON, + UNIQUE KEY uq_decision_step (decision_id, ordinal), + CONSTRAINT fk_decision_steps_record FOREIGN KEY (decision_id) REFERENCES decision_records(id) ON DELETE CASCADE +) ENGINE=InnoDB; + +INSERT INTO quality_profiles + (name, cutoff, upgrade_allowed, min_format_score, cutoff_format_score, items) +VALUES + ('Any', 20, TRUE, 0, 0, '[{"quality":1,"allowed":true},{"quality":2,"allowed":true},{"quality":3,"allowed":true},{"quality":4,"allowed":true},{"quality":5,"allowed":true},{"quality":6,"allowed":true},{"quality":7,"allowed":true},{"quality":8,"allowed":true},{"quality":9,"allowed":true},{"quality":10,"allowed":true},{"quality":11,"allowed":true},{"quality":12,"allowed":true},{"quality":13,"allowed":true},{"quality":14,"allowed":true},{"quality":15,"allowed":true},{"quality":16,"allowed":true},{"quality":17,"allowed":true},{"quality":18,"allowed":true},{"quality":19,"allowed":true}]'), + ('HD-1080p', 13, TRUE, 0, 0, '[{"quality":10,"allowed":true},{"quality":11,"allowed":true},{"quality":12,"allowed":true},{"quality":13,"allowed":true},{"quality":14,"allowed":true}]'), + ('Ultra-HD', 18, TRUE, 0, 0, '[{"quality":15,"allowed":true},{"quality":16,"allowed":true},{"quality":17,"allowed":true},{"quality":18,"allowed":true},{"quality":19,"allowed":true}]'); + +INSERT INTO naming_config + (media_type, standard_format, daily_format, anime_format, season_folder_format, colon_replacement) +VALUES + ('series', '{Series Title} - S{season:00}E{episode:00} - {Episode Title} [{Quality Title}]', '{Series Title} - {Air-Date} - {Episode Title} [{Quality Title}]', '{Series Title} - S{season:00}E{episode:00} - {Absolute Episode} - {Episode Title} [{Quality Title}]', 'Season {season:00}', 'smart'); + +INSERT INTO naming_config + (media_type, movie_format, movie_folder_format, colon_replacement) +VALUES + ('movie', '{Movie Title} ({Release Year}) [{Quality Title}]', '{Movie Title} ({Release Year})', 'smart'); + +INSERT INTO discover_sliders (slider_type, display_order, is_built_in, enabled, title) +VALUES + ('trending', 1, TRUE, TRUE, 'Trending'), + ('popular_movies', 2, TRUE, TRUE, 'Popular Movies'), + ('popular_tv', 3, TRUE, TRUE, 'Popular TV Shows'), + ('upcoming_movies', 4, TRUE, TRUE, 'Upcoming Movies'), + ('upcoming_tv', 5, TRUE, TRUE, 'Upcoming TV Shows'), + ('recently_added', 6, TRUE, TRUE, 'Recently Added'), + ('movie_genres', 7, TRUE, TRUE, 'Movie Genres'), + ('tv_genres', 8, TRUE, TRUE, 'TV Genres'); + +INSERT IGNORE INTO app_config (`key`, value) +VALUES ('recycle_bin_path', '""'), ('recycle_bin_cleanup_days', '7'); + +INSERT IGNORE INTO dav_items (id, id_prefix, name, item_type, sub_type, path) +VALUES + ('00000000-0000-0000-0000-000000000001', '0000', 'dav', 1, 102, '/'), + ('00000000-0000-0000-0000-000000000002', '0000', 'content', 1, 104, '/content'), + ('00000000-0000-0000-0000-000000000003', '0000', 'nzbs', 1, 103, '/nzbs'); diff --git a/migrations/001_initial.sql b/migrations/001_initial.sql deleted file mode 100644 index aed64fd7..00000000 --- a/migrations/001_initial.sql +++ /dev/null @@ -1,388 +0,0 @@ --- StackArr schema - --- Core config -CREATE TABLE app_config ( - key TEXT PRIMARY KEY, - value JSONB NOT NULL -); - -CREATE TABLE enabled_modules ( - id SERIAL PRIMARY KEY, - module TEXT UNIQUE NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT false, - config JSONB -); - -CREATE TABLE media_library_folders ( - id SERIAL PRIMARY KEY, - path TEXT NOT NULL UNIQUE, - media_type TEXT NOT NULL, - free_space BIGINT, - last_checked TIMESTAMPTZ -); - -CREATE TABLE tags ( - id SERIAL PRIMARY KEY, - label TEXT NOT NULL UNIQUE -); - --- Quality system -CREATE TABLE quality_profiles ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - cutoff INTEGER NOT NULL, - upgrade_allowed BOOLEAN NOT NULL DEFAULT true, - min_format_score INTEGER NOT NULL DEFAULT 0, - cutoff_format_score INTEGER NOT NULL DEFAULT 0, - items JSONB NOT NULL -); - -CREATE TABLE custom_formats ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - specifications JSONB NOT NULL -); - -CREATE TABLE custom_format_scores ( - profile_id INTEGER REFERENCES quality_profiles(id) ON DELETE CASCADE, - format_id INTEGER REFERENCES custom_formats(id) ON DELETE CASCADE, - score INTEGER NOT NULL, - PRIMARY KEY (profile_id, format_id) -); - --- TV Series -CREATE TABLE series ( - id BIGSERIAL PRIMARY KEY, - title TEXT NOT NULL, - clean_title TEXT NOT NULL, - sort_title TEXT NOT NULL, - overview TEXT, - status TEXT NOT NULL DEFAULT 'continuing', - series_type TEXT NOT NULL DEFAULT 'standard', - network TEXT, - air_time TIME, - first_aired DATE, - year INTEGER, - runtime INTEGER, - path TEXT NOT NULL, - media_library_folder_id INTEGER REFERENCES media_library_folders(id), - quality_profile_id INTEGER REFERENCES quality_profiles(id), - season_folder BOOLEAN NOT NULL DEFAULT true, - monitored BOOLEAN NOT NULL DEFAULT true, - use_scene_numbering BOOLEAN NOT NULL DEFAULT false, - tvdb_id BIGINT, - imdb_id TEXT, - tmdb_id BIGINT, - tvmaze_id BIGINT, - mal_id BIGINT, - images JSONB, - genres TEXT[], - tags INTEGER[], - added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - last_info_sync TIMESTAMPTZ, - plex_rating_key TEXT, - plex_rating_key_4k TEXT, - media_added_at TIMESTAMPTZ -); -CREATE INDEX idx_series_tvdb ON series(tvdb_id); -CREATE INDEX idx_series_tmdb ON series(tmdb_id); -CREATE INDEX idx_series_imdb ON series(imdb_id); -CREATE INDEX idx_series_clean_title ON series(clean_title); - -CREATE TABLE seasons ( - id BIGSERIAL PRIMARY KEY, - series_id BIGINT NOT NULL REFERENCES series(id) ON DELETE CASCADE, - season_number INTEGER NOT NULL, - monitored BOOLEAN NOT NULL DEFAULT true, - UNIQUE(series_id, season_number) -); - --- Media files (shared TV + movies) -CREATE TABLE media_files ( - id BIGSERIAL PRIMARY KEY, - media_type TEXT NOT NULL, - relative_path TEXT NOT NULL, - size BIGINT NOT NULL, - date_added TIMESTAMPTZ NOT NULL DEFAULT NOW(), - quality JSONB NOT NULL, - languages JSONB NOT NULL DEFAULT '[]', - scene_name TEXT, - release_group TEXT, - release_hash TEXT, - edition TEXT, - media_info JSONB, - indexer_flags INTEGER NOT NULL DEFAULT 0 -); - -CREATE TABLE episodes ( - id BIGSERIAL PRIMARY KEY, - series_id BIGINT NOT NULL REFERENCES series(id) ON DELETE CASCADE, - season_number INTEGER NOT NULL, - episode_number INTEGER NOT NULL, - absolute_number INTEGER, - scene_season_number INTEGER, - scene_episode_number INTEGER, - scene_absolute_number INTEGER, - title TEXT, - overview TEXT, - air_date DATE, - air_date_utc TIMESTAMPTZ, - runtime INTEGER, - monitored BOOLEAN NOT NULL DEFAULT true, - episode_file_id BIGINT REFERENCES media_files(id) ON DELETE SET NULL, - last_search_time TIMESTAMPTZ, - UNIQUE(series_id, season_number, episode_number) -); -CREATE INDEX idx_episodes_air_date ON episodes(air_date_utc); - --- Episode-to-file join (multi-episode files) -CREATE TABLE episode_files ( - episode_id BIGINT NOT NULL REFERENCES episodes(id) ON DELETE CASCADE, - media_file_id BIGINT NOT NULL REFERENCES media_files(id) ON DELETE CASCADE, - PRIMARY KEY (episode_id, media_file_id) -); - --- Movies -CREATE TABLE movies ( - id BIGSERIAL PRIMARY KEY, - title TEXT NOT NULL, - clean_title TEXT NOT NULL, - sort_title TEXT NOT NULL, - overview TEXT, - year INTEGER, - studio TEXT, - path TEXT NOT NULL, - media_library_folder_id INTEGER REFERENCES media_library_folders(id), - quality_profile_id INTEGER REFERENCES quality_profiles(id), - monitored BOOLEAN NOT NULL DEFAULT true, - minimum_availability TEXT NOT NULL DEFAULT 'released', - movie_file_id BIGINT REFERENCES media_files(id) ON DELETE SET NULL, - tmdb_id BIGINT, - imdb_id TEXT, - in_cinemas DATE, - physical_release DATE, - digital_release DATE, - images JSONB, - genres TEXT[], - tags INTEGER[], - collection_tmdb_id BIGINT, - added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - last_info_sync TIMESTAMPTZ, - plex_rating_key TEXT, - plex_rating_key_4k TEXT, - media_added_at TIMESTAMPTZ -); -CREATE INDEX idx_movies_tmdb ON movies(tmdb_id); -CREATE INDEX idx_movies_imdb ON movies(imdb_id); -CREATE INDEX idx_movies_clean_title ON movies(clean_title); - --- Alternative titles -CREATE TABLE alternative_titles ( - id BIGSERIAL PRIMARY KEY, - media_type TEXT NOT NULL, - media_id BIGINT NOT NULL, - title TEXT NOT NULL, - clean_title TEXT NOT NULL, - scene_name BOOLEAN NOT NULL DEFAULT false -); -CREATE INDEX idx_alt_titles_clean ON alternative_titles(clean_title); - --- Indexers -CREATE TABLE indexers ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - indexer_type TEXT NOT NULL, - base_url TEXT NOT NULL, - api_key TEXT, - protocol TEXT NOT NULL, - categories INTEGER[], - enabled BOOLEAN NOT NULL DEFAULT true, - priority INTEGER NOT NULL DEFAULT 25, - supports_search BOOLEAN NOT NULL DEFAULT true, - supports_rss BOOLEAN NOT NULL DEFAULT true, - config JSONB, - last_rss_sync TIMESTAMPTZ -); - --- Download clients -CREATE TABLE download_clients ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - client_type TEXT NOT NULL, - protocol TEXT NOT NULL, - config JSONB NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT true, - priority INTEGER NOT NULL DEFAULT 1 -); - --- Queue (tracked in-progress downloads) -CREATE TABLE queue ( - id BIGSERIAL PRIMARY KEY, - media_type TEXT NOT NULL, - media_id BIGINT NOT NULL, - episode_id BIGINT, - title TEXT NOT NULL, - quality JSONB NOT NULL, - languages JSONB, - size BIGINT, - status TEXT NOT NULL, - download_id TEXT NOT NULL, - download_client_id INTEGER REFERENCES download_clients(id), - indexer_id INTEGER REFERENCES indexers(id), - protocol TEXT NOT NULL, - error_message TEXT, - added_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE INDEX idx_queue_download_id ON queue(download_id); - --- History -CREATE TABLE history ( - id BIGSERIAL PRIMARY KEY, - media_type TEXT NOT NULL, - media_id BIGINT NOT NULL, - episode_id BIGINT, - event_type TEXT NOT NULL, - quality JSONB NOT NULL, - languages JSONB, - source_title TEXT NOT NULL, - download_id TEXT, - indexer_id INTEGER, - download_client TEXT, - data JSONB, - occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE INDEX idx_history_media ON history(media_type, media_id); -CREATE INDEX idx_history_occurred ON history(occurred_at DESC); -CREATE INDEX idx_history_download_id ON history(download_id); - --- Blocklist -CREATE TABLE blocklist ( - id BIGSERIAL PRIMARY KEY, - media_type TEXT NOT NULL, - media_id BIGINT NOT NULL, - source_title TEXT NOT NULL, - quality JSONB NOT NULL, - languages JSONB, - indexer_id INTEGER, - info_hash TEXT, - message TEXT, - added_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE INDEX idx_blocklist_media ON blocklist(media_type, media_id); -CREATE INDEX idx_blocklist_hash ON blocklist(info_hash); - --- Naming config -CREATE TABLE naming_config ( - id SERIAL PRIMARY KEY, - media_type TEXT NOT NULL UNIQUE, - rename_files BOOLEAN NOT NULL DEFAULT true, - standard_format TEXT, - daily_format TEXT, - anime_format TEXT, - season_folder_format TEXT, - movie_format TEXT, - movie_folder_format TEXT, - colon_replacement TEXT NOT NULL DEFAULT 'smart' -); - --- Notification providers -CREATE TABLE notification_providers ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - provider_type TEXT NOT NULL, - config JSONB NOT NULL, - on_grab BOOLEAN NOT NULL DEFAULT false, - on_import BOOLEAN NOT NULL DEFAULT false, - on_upgrade BOOLEAN NOT NULL DEFAULT false, - on_health_issue BOOLEAN NOT NULL DEFAULT false, - on_failure BOOLEAN NOT NULL DEFAULT false, - enabled BOOLEAN NOT NULL DEFAULT true -); - --- Import lists -CREATE TABLE import_lists ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - list_type TEXT NOT NULL, - media_type TEXT NOT NULL, - config JSONB NOT NULL, - quality_profile_id INTEGER REFERENCES quality_profiles(id), - media_library_folder_id INTEGER REFERENCES media_library_folders(id), - monitored BOOLEAN NOT NULL DEFAULT true, - enabled BOOLEAN NOT NULL DEFAULT true, - poll_interval_secs INTEGER NOT NULL DEFAULT 3600 -); - --- Discover sliders -CREATE TABLE discover_sliders ( - id SERIAL PRIMARY KEY, - slider_type TEXT NOT NULL, - display_order INTEGER NOT NULL DEFAULT 0, - is_built_in BOOLEAN NOT NULL DEFAULT false, - enabled BOOLEAN NOT NULL DEFAULT true, - title TEXT, - custom_data JSONB, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE INDEX idx_discover_sliders_order ON discover_sliders(display_order); - --- Plex server connections -CREATE TABLE plex_servers ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL DEFAULT 'Plex', - machine_id TEXT, - ip TEXT NOT NULL, - port INTEGER NOT NULL DEFAULT 32400, - use_ssl BOOLEAN NOT NULL DEFAULT false, - auth_token TEXT, - web_app_url TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE TABLE plex_libraries ( - id SERIAL PRIMARY KEY, - plex_server_id INTEGER NOT NULL REFERENCES plex_servers(id) ON DELETE CASCADE, - section_id TEXT NOT NULL, - name TEXT NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT true, - library_type TEXT NOT NULL, - last_scan TIMESTAMPTZ, - UNIQUE(plex_server_id, section_id) -); - -CREATE TABLE watchlist ( - id BIGSERIAL PRIMARY KEY, - tmdb_id BIGINT NOT NULL, - media_type TEXT NOT NULL, - plex_rating_key TEXT, - auto_requested BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(tmdb_id, media_type) -); -CREATE INDEX idx_watchlist_tmdb ON watchlist(tmdb_id); - --- Seed default quality profiles -INSERT INTO quality_profiles (name, cutoff, upgrade_allowed, min_format_score, cutoff_format_score, items) VALUES -('Any', 20, true, 0, 0, '[{"quality": 1, "allowed": true}, {"quality": 2, "allowed": true}, {"quality": 3, "allowed": true}, {"quality": 4, "allowed": true}, {"quality": 5, "allowed": true}, {"quality": 6, "allowed": true}, {"quality": 7, "allowed": true}, {"quality": 8, "allowed": true}, {"quality": 9, "allowed": true}, {"quality": 10, "allowed": true}, {"quality": 11, "allowed": true}, {"quality": 12, "allowed": true}, {"quality": 13, "allowed": true}, {"quality": 14, "allowed": true}, {"quality": 15, "allowed": true}, {"quality": 16, "allowed": true}, {"quality": 17, "allowed": true}, {"quality": 18, "allowed": true}, {"quality": 19, "allowed": true}]'), -('HD-1080p', 13, true, 0, 0, '[{"quality": 10, "allowed": true}, {"quality": 11, "allowed": true}, {"quality": 12, "allowed": true}, {"quality": 13, "allowed": true}, {"quality": 14, "allowed": true}]'), -('Ultra-HD', 18, true, 0, 0, '[{"quality": 15, "allowed": true}, {"quality": 16, "allowed": true}, {"quality": 17, "allowed": true}, {"quality": 18, "allowed": true}, {"quality": 19, "allowed": true}]'); - --- Seed default naming config -INSERT INTO naming_config (media_type, standard_format, daily_format, anime_format, season_folder_format, colon_replacement) VALUES -('series', '{Series Title} - S{season:00}E{episode:00} - {Episode Title} [{Quality Title}]', '{Series Title} - {Air-Date} - {Episode Title} [{Quality Title}]', '{Series Title} - S{season:00}E{episode:00} - {Absolute Episode} - {Episode Title} [{Quality Title}]', 'Season {season:00}', 'smart'); - -INSERT INTO naming_config (media_type, movie_format, movie_folder_format, colon_replacement) VALUES -('movie', '{Movie Title} ({Release Year}) [{Quality Title}]', '{Movie Title} ({Release Year})', 'smart'); - --- Seed default discover sliders -INSERT INTO discover_sliders (slider_type, display_order, is_built_in, enabled, title) VALUES -('trending', 1, true, true, 'Trending'), -('popular_movies', 2, true, true, 'Popular Movies'), -('popular_tv', 3, true, true, 'Popular TV Shows'), -('upcoming_movies', 4, true, true, 'Upcoming Movies'), -('upcoming_tv', 5, true, true, 'Upcoming TV Shows'), -('recently_added', 6, true, true, 'Recently Added'), -('movie_genres', 7, true, true, 'Movie Genres'), -('tv_genres', 8, true, true, 'TV Genres'); diff --git a/migrations/002_streaming.sql b/migrations/002_streaming.sql deleted file mode 100644 index b5073f56..00000000 --- a/migrations/002_streaming.sql +++ /dev/null @@ -1,19 +0,0 @@ --- Streaming session tracking -CREATE TABLE IF NOT EXISTS streaming_sessions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - media_file_id BIGINT NOT NULL REFERENCES media_files(id) ON DELETE CASCADE, - session_type TEXT NOT NULL, -- 'direct' or 'transcode' - status TEXT NOT NULL DEFAULT 'active', -- 'active', 'paused', 'completed', 'error' - started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - last_activity TIMESTAMPTZ NOT NULL DEFAULT NOW(), - transcode_progress REAL, -- 0.0 to 1.0 - video_codec TEXT, - audio_codec TEXT, - resolution TEXT, - bitrate BIGINT, - client_info TEXT, -- user-agent or similar - transcode_dir TEXT -- path to temp HLS segments -); - -CREATE INDEX IF NOT EXISTS idx_streaming_sessions_media ON streaming_sessions(media_file_id); -CREATE INDEX IF NOT EXISTS idx_streaming_sessions_status ON streaming_sessions(status); diff --git a/migrations/003_health_check.sql b/migrations/003_health_check.sql deleted file mode 100644 index e774bbb8..00000000 --- a/migrations/003_health_check.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Health check tracking for download clients -ALTER TABLE download_clients - ADD COLUMN last_health_check TIMESTAMPTZ, - ADD COLUMN health_status TEXT NOT NULL DEFAULT 'unknown', - ADD COLUMN consecutive_failures INTEGER NOT NULL DEFAULT 0, - ADD COLUMN auto_disabled BOOLEAN NOT NULL DEFAULT false; - --- Health check tracking for indexers -ALTER TABLE indexers - ADD COLUMN last_health_check TIMESTAMPTZ, - ADD COLUMN health_status TEXT NOT NULL DEFAULT 'unknown', - ADD COLUMN consecutive_failures INTEGER NOT NULL DEFAULT 0, - ADD COLUMN auto_disabled BOOLEAN NOT NULL DEFAULT false; diff --git a/migrations/004_remote_access.sql b/migrations/004_remote_access.sql deleted file mode 100644 index 113c167d..00000000 --- a/migrations/004_remote_access.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Remote client authorization for bootstrap-discovered clients -CREATE TABLE IF NOT EXISTS remote_clients ( - id SERIAL PRIMARY KEY, - client_token UUID NOT NULL UNIQUE, - client_name TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - last_seen TIMESTAMPTZ, - revoked BOOLEAN NOT NULL DEFAULT false -); - -CREATE INDEX IF NOT EXISTS idx_remote_clients_token ON remote_clients(client_token); diff --git a/migrations/005_quality_profile_media_type.sql b/migrations/005_quality_profile_media_type.sql deleted file mode 100644 index 1051da83..00000000 --- a/migrations/005_quality_profile_media_type.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Add media_type to quality profiles (series, movie, or NULL for any/both) -ALTER TABLE quality_profiles ADD COLUMN media_type TEXT; diff --git a/migrations/006_users.sql b/migrations/006_users.sql deleted file mode 100644 index 2e90c6fe..00000000 --- a/migrations/006_users.sql +++ /dev/null @@ -1,148 +0,0 @@ --- Phase 1: User system, sessions, devices, invites --- Phase 2-5: Watch progress, media requests, watchlist, ratings, notifications, push - --- USERS -CREATE TABLE users ( - id BIGSERIAL PRIMARY KEY, - username TEXT NOT NULL UNIQUE, - display_name TEXT NOT NULL, - password_hash TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'user', - avatar_url TEXT, - enabled BOOLEAN NOT NULL DEFAULT true, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- SESSIONS (web login sessions) -CREATE TABLE user_sessions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - token_hash TEXT NOT NULL UNIQUE, - user_agent TEXT, - ip_address INET, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - expires_at TIMESTAMPTZ NOT NULL, - last_active TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE INDEX idx_user_sessions_user ON user_sessions(user_id); -CREATE INDEX idx_user_sessions_expires ON user_sessions(expires_at); - --- USER DEVICES (replaces remote_clients) -CREATE TABLE user_devices ( - id SERIAL PRIMARY KEY, - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - device_token UUID NOT NULL UNIQUE, - device_name TEXT, - device_type TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - last_seen TIMESTAMPTZ, - revoked BOOLEAN NOT NULL DEFAULT false -); -CREATE INDEX idx_user_devices_token ON user_devices(device_token); -CREATE INDEX idx_user_devices_user ON user_devices(user_id); - --- INVITES -CREATE TABLE invites ( - id SERIAL PRIMARY KEY, - code TEXT NOT NULL UNIQUE, - created_by BIGINT NOT NULL REFERENCES users(id), - claimed_by BIGINT REFERENCES users(id), - role TEXT NOT NULL DEFAULT 'user', - expires_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE INDEX idx_invites_code ON invites(code); - --- Watch progress (Phase 2) -CREATE TABLE watch_progress ( - id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - media_file_id BIGINT NOT NULL REFERENCES media_files(id) ON DELETE CASCADE, - media_type TEXT NOT NULL, - media_id BIGINT NOT NULL, - episode_id BIGINT, - position_secs REAL NOT NULL DEFAULT 0, - duration_secs REAL NOT NULL DEFAULT 0, - completed BOOLEAN NOT NULL DEFAULT false, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(user_id, media_file_id) -); -CREATE INDEX idx_watch_progress_user ON watch_progress(user_id, updated_at DESC); -CREATE INDEX idx_watch_progress_continue ON watch_progress(user_id, completed, updated_at DESC); -CREATE INDEX idx_watch_progress_media ON watch_progress(media_type, media_id); - --- Media requests (Phase 3) -CREATE TABLE media_requests ( - id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL REFERENCES users(id), - media_type TEXT NOT NULL, - tmdb_id BIGINT NOT NULL, - title TEXT NOT NULL, - year INTEGER, - poster_url TEXT, - overview TEXT, - status TEXT NOT NULL DEFAULT 'pending', - admin_note TEXT, - approved_by BIGINT REFERENCES users(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(tmdb_id, media_type) -); -CREATE INDEX idx_media_requests_user ON media_requests(user_id); -CREATE INDEX idx_media_requests_status ON media_requests(status); - --- User watchlist (Phase 4) -CREATE TABLE user_watchlist ( - id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - media_type TEXT NOT NULL, - media_id BIGINT NOT NULL, - tmdb_id BIGINT NOT NULL, - added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(user_id, media_type, media_id) -); -CREATE INDEX idx_user_watchlist_user ON user_watchlist(user_id, added_at DESC); - --- User ratings (Phase 4) -CREATE TABLE user_ratings ( - id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - media_type TEXT NOT NULL, - media_id BIGINT NOT NULL, - rating SMALLINT NOT NULL CHECK (rating >= 1 AND rating <= 10), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(user_id, media_type, media_id) -); -CREATE INDEX idx_user_ratings_user ON user_ratings(user_id); -CREATE INDEX idx_user_ratings_media ON user_ratings(media_type, media_id); - --- User notifications (Phase 5) -CREATE TABLE user_notifications ( - id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - notification_type TEXT NOT NULL, - title TEXT NOT NULL, - body TEXT, - data JSONB, - read BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE INDEX idx_user_notifications_user ON user_notifications(user_id, read, created_at DESC); -CREATE INDEX idx_user_notifications_created ON user_notifications(created_at); - --- Push subscriptions (Phase 5) -CREATE TABLE push_subscriptions ( - id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - endpoint TEXT NOT NULL UNIQUE, - p256dh TEXT NOT NULL, - auth TEXT NOT NULL, - user_agent TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE INDEX idx_push_subscriptions_user ON push_subscriptions(user_id); - --- Link streaming sessions to users -ALTER TABLE streaming_sessions ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES users(id); diff --git a/migrations/007_language_queue.sql b/migrations/007_language_queue.sql deleted file mode 100644 index e58b2eb9..00000000 --- a/migrations/007_language_queue.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Add language preference to quality profiles (Radarr v6 compatibility). --- -1 = any language (default), -2 = original language, positive = specific Radarr language ID. -ALTER TABLE quality_profiles ADD COLUMN language INTEGER NOT NULL DEFAULT -1; - --- Add original language to movies for resolving "Original" language profiles. --- Stores Radarr language ID (1=English, 2=French, 3=Spanish, etc.). -ALTER TABLE movies ADD COLUMN original_language INTEGER; - --- Add index on queue for media-item-based conflict checking (replaces guid-only lookups). -CREATE INDEX idx_queue_media ON queue(media_type, media_id); diff --git a/migrations/008_system_activities.sql b/migrations/008_system_activities.sql deleted file mode 100644 index 6dbaceee..00000000 --- a/migrations/008_system_activities.sql +++ /dev/null @@ -1,16 +0,0 @@ --- System-wide activity tracking (disk scans, imports, transcodes, etc.) -CREATE TABLE system_activities ( - id BIGSERIAL PRIMARY KEY, - activity_type TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'running', - title TEXT NOT NULL, - detail TEXT, - progress JSONB, - result JSONB, - error TEXT, - started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - completed_at TIMESTAMPTZ -); -CREATE INDEX idx_system_activities_status ON system_activities(status, started_at DESC); -CREATE INDEX idx_system_activities_recent ON system_activities(started_at DESC); diff --git a/migrations/009_plex_verify_tls.sql b/migrations/009_plex_verify_tls.sql deleted file mode 100644 index e4477214..00000000 --- a/migrations/009_plex_verify_tls.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Add per-server TLS verification toggle for Plex connections. --- Default false for backward compat (existing servers commonly use self-signed certs). -ALTER TABLE plex_servers ADD COLUMN verify_tls BOOLEAN NOT NULL DEFAULT false; diff --git a/migrations/010_media_management.sql b/migrations/010_media_management.sql deleted file mode 100644 index 372ccc6c..00000000 --- a/migrations/010_media_management.sql +++ /dev/null @@ -1,18 +0,0 @@ --- Track files moved to the recycle bin for scheduled cleanup -CREATE TABLE recycle_bin ( - id BIGSERIAL PRIMARY KEY, - original_path TEXT NOT NULL, - recycle_path TEXT NOT NULL, - media_file_id BIGINT, - media_type TEXT NOT NULL, - media_id BIGINT NOT NULL, - size BIGINT NOT NULL DEFAULT 0, - recycled_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE INDEX idx_recycle_bin_recycled_at ON recycle_bin(recycled_at); - --- Seed default media management config -INSERT INTO app_config (key, value) VALUES - ('recycle_bin_path', '""'::jsonb), - ('recycle_bin_cleanup_days', '7'::jsonb) -ON CONFLICT (key) DO NOTHING; diff --git a/migrations/011_plex_deep_integration.sql b/migrations/011_plex_deep_integration.sql deleted file mode 100644 index 3d7643ff..00000000 --- a/migrations/011_plex_deep_integration.sql +++ /dev/null @@ -1,19 +0,0 @@ --- Plex deep integration: events, webhook secrets, unified streaming - --- Plex webhook event history -CREATE TABLE plex_events ( - id BIGSERIAL PRIMARY KEY, - event_type TEXT NOT NULL, - plex_server_id INTEGER REFERENCES plex_servers(id) ON DELETE SET NULL, - user_name TEXT, - title TEXT, - rating_key TEXT, - metadata JSONB, - thumb_url TEXT, - received_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE INDEX idx_plex_events_type ON plex_events(event_type); -CREATE INDEX idx_plex_events_received ON plex_events(received_at DESC); - --- Webhook secret per Plex server (used in webhook URL path for validation) -ALTER TABLE plex_servers ADD COLUMN IF NOT EXISTS webhook_secret TEXT; diff --git a/migrations/012_rss.sql b/migrations/012_rss.sql deleted file mode 100644 index 95981250..00000000 --- a/migrations/012_rss.sql +++ /dev/null @@ -1,43 +0,0 @@ --- RSS feed subscriptions -CREATE TABLE rss_feeds ( - id BIGSERIAL PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - url TEXT NOT NULL, - protocol TEXT NOT NULL, -- 'usenet' or 'torrent' - poll_interval_secs INTEGER NOT NULL DEFAULT 900, - category TEXT, - filter_regex TEXT, - enabled BOOLEAN NOT NULL DEFAULT true, - auto_download BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- RSS feed items (discovered entries, deduped by feed entry GUID) -CREATE TABLE rss_items ( - id TEXT PRIMARY KEY, -- feed entry GUID - feed_id BIGINT NOT NULL REFERENCES rss_feeds(id) ON DELETE CASCADE, - title TEXT NOT NULL, - url TEXT, -- download URL (.nzb / .torrent / magnet) - published_at TIMESTAMPTZ, - first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - downloaded BOOLEAN NOT NULL DEFAULT false, - downloaded_at TIMESTAMPTZ, - category TEXT, - size_bytes BIGINT DEFAULT 0 -); - -CREATE INDEX idx_rss_items_feed_id ON rss_items(feed_id); -CREATE INDEX idx_rss_items_first_seen ON rss_items(first_seen_at DESC); - --- RSS download rules (auto-grab matching items) -CREATE TABLE rss_rules ( - id BIGSERIAL PRIMARY KEY, - name TEXT NOT NULL, - feed_ids BIGINT[] NOT NULL, - category TEXT, - priority INTEGER NOT NULL DEFAULT 1, - match_regex TEXT NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT true, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); diff --git a/migrations/013_queue_output_path.sql b/migrations/013_queue_output_path.sql deleted file mode 100644 index 662be756..00000000 --- a/migrations/013_queue_output_path.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Add output_path to store the resolved download output directory from the client. --- Add stale_count to track how many consecutive polling cycles the item is missing from the client. -ALTER TABLE queue ADD COLUMN output_path TEXT; -ALTER TABLE queue ADD COLUMN stale_count INTEGER NOT NULL DEFAULT 0; diff --git a/migrations/014_custom_format_fields.sql b/migrations/014_custom_format_fields.sql deleted file mode 100644 index dd6d9b51..00000000 --- a/migrations/014_custom_format_fields.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE custom_formats ADD COLUMN include_custom_format_when_renaming BOOLEAN NOT NULL DEFAULT false; -ALTER TABLE quality_profiles ADD COLUMN min_upgrade_format_score INTEGER NOT NULL DEFAULT 1; diff --git a/migrations/015_normalize_quality_integers.sql b/migrations/015_normalize_quality_integers.sql deleted file mode 100644 index cad26493..00000000 --- a/migrations/015_normalize_quality_integers.sql +++ /dev/null @@ -1,34 +0,0 @@ --- Normalize media_files.quality to use integer IDs instead of string enum names. --- String values like "WEBDL1080p" are converted to their integer equivalents (e.g. 11). --- Rows already using integer IDs are left untouched. - -UPDATE media_files -SET quality = jsonb_set( - quality, - '{quality}', - CASE quality->>'quality' - WHEN 'Unknown' THEN '0'::jsonb - WHEN 'SDTV' THEN '1'::jsonb - WHEN 'DVD' THEN '2'::jsonb - WHEN 'DVDRip' THEN '2'::jsonb - WHEN 'WEBDL480p' THEN '3'::jsonb - WHEN 'WEBRip480p' THEN '4'::jsonb - WHEN 'HDTV720p' THEN '6'::jsonb - WHEN 'WEBDL720p' THEN '7'::jsonb - WHEN 'WEBRip720p' THEN '8'::jsonb - WHEN 'Bluray720p' THEN '9'::jsonb - WHEN 'HDTV1080p' THEN '10'::jsonb - WHEN 'WEBDL1080p' THEN '11'::jsonb - WHEN 'WEBRip1080p' THEN '12'::jsonb - WHEN 'Bluray1080p' THEN '13'::jsonb - WHEN 'Remux1080p' THEN '14'::jsonb - WHEN 'HDTV2160p' THEN '15'::jsonb - WHEN 'WEBDL2160p' THEN '16'::jsonb - WHEN 'WEBRip2160p' THEN '17'::jsonb - WHEN 'Bluray2160p' THEN '18'::jsonb - WHEN 'Remux2160p' THEN '19'::jsonb - WHEN 'Raw' THEN '20'::jsonb - ELSE '0'::jsonb - END -) -WHERE jsonb_typeof(quality->'quality') = 'string'; diff --git a/migrations/016_nzbdav.sql b/migrations/016_nzbdav.sql deleted file mode 100644 index cc61dbdd..00000000 --- a/migrations/016_nzbdav.sql +++ /dev/null @@ -1,99 +0,0 @@ --- NZBDav virtual filesystem for live Usenet streaming --- Module: dav_streaming - --- Virtual filesystem nodes -CREATE TABLE dav_items ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - id_prefix TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - parent_id UUID REFERENCES dav_items(id) ON DELETE CASCADE, - name TEXT NOT NULL, - file_size BIGINT, - item_type INT NOT NULL, -- 1=Directory, 2=UsenetFile - sub_type INT NOT NULL, -- 101-106=dir types, 201-204=file types - path TEXT NOT NULL, - release_date TIMESTAMPTZ, - last_health_check TIMESTAMPTZ, - next_health_check TIMESTAMPTZ, - history_item_id UUID, - file_blob_id UUID, - nzb_blob_id UUID, - UNIQUE(parent_id, name) -); - -CREATE INDEX idx_dav_items_prefix ON dav_items(id_prefix, item_type); -CREATE INDEX idx_dav_items_type_created ON dav_items(item_type, created_at); -CREATE INDEX idx_dav_items_sub_type ON dav_items(sub_type, created_at); -CREATE INDEX idx_dav_items_history ON dav_items(history_item_id, item_type); -CREATE INDEX idx_dav_items_nzb_blob ON dav_items(nzb_blob_id); -CREATE INDEX idx_dav_items_path ON dav_items(path); - --- File metadata blobs (DavMultipartFile / DavNzbFile serialized as bincode) -CREATE TABLE dav_blobs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - data BYTEA NOT NULL -); - --- Raw NZB XML blobs -CREATE TABLE dav_nzb_blobs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - data BYTEA NOT NULL -); - --- NZB processing queue (for batch operations) -CREATE TABLE dav_queue_items ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - file_name TEXT NOT NULL, - job_name TEXT NOT NULL, - nzb_file_size BIGINT NOT NULL DEFAULT 0, - total_segment_bytes BIGINT NOT NULL DEFAULT 0, - category TEXT NOT NULL DEFAULT '', - priority INT NOT NULL DEFAULT 0, - post_processing INT NOT NULL DEFAULT -1, - pause_until TIMESTAMPTZ -); - -CREATE INDEX idx_dav_queue_priority ON dav_queue_items(priority DESC, created_at ASC); - --- Processing history -CREATE TABLE dav_history_items ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - file_name TEXT NOT NULL, - job_name TEXT NOT NULL, - category TEXT NOT NULL DEFAULT '', - download_status INT NOT NULL, -- 1=Completed, 2=Failed - total_segment_bytes BIGINT NOT NULL DEFAULT 0, - download_time_seconds INT NOT NULL DEFAULT 0, - fail_message TEXT, - download_dir_id UUID, - nzb_blob_id UUID -); - -CREATE INDEX idx_dav_history_created ON dav_history_items(created_at); - --- Health checks -CREATE TABLE dav_health_checks ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - dav_item_id UUID NOT NULL REFERENCES dav_items(id) ON DELETE CASCADE, - path TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - result INT NOT NULL DEFAULT 0, - repair_status INT NOT NULL DEFAULT 0, - message TEXT NOT NULL DEFAULT '' -); - --- Module-specific config (separate from app_config) -CREATE TABLE dav_config ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); - --- Seed root virtual directories (idempotent — matches nzbdav_core::seed::seed_root_items) -INSERT INTO dav_items (id, id_prefix, name, item_type, sub_type, path) -VALUES - ('00000000-0000-0000-0000-000000000001', '0000', 'dav', 1, 102, '/'), - ('00000000-0000-0000-0000-000000000002', '0000', 'content', 1, 104, '/content'), - ('00000000-0000-0000-0000-000000000003', '0000', 'nzbs', 1, 103, '/nzbs') -ON CONFLICT (id) DO NOTHING; diff --git a/migrations/017_performance_indexes.sql b/migrations/017_performance_indexes.sql deleted file mode 100644 index 9d1c8342..00000000 --- a/migrations/017_performance_indexes.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Performance indexes for stream path resolution and file lookups - -CREATE INDEX IF NOT EXISTS idx_episode_files_media_file ON episode_files(media_file_id); -CREATE INDEX IF NOT EXISTS idx_episode_files_episode ON episode_files(episode_id); -CREATE INDEX IF NOT EXISTS idx_movies_movie_file ON movies(movie_file_id); -CREATE INDEX IF NOT EXISTS idx_media_files_media_type ON media_files(media_type); diff --git a/migrations/018_import_candidates.sql b/migrations/018_import_candidates.sql deleted file mode 100644 index c9db634e..00000000 --- a/migrations/018_import_candidates.sql +++ /dev/null @@ -1,64 +0,0 @@ --- Discovered media files/groups that the disk scanner could not match to an --- existing series/movie in the DB. The scanner writes candidate rows here --- instead of dropping unmatched files silently, so the user can review, --- accept, or reject them via the Import UI. --- --- A single candidate can cover a whole series (many files grouped by parsed --- title+year) or a single movie file, depending on the scanner's confidence --- that the grouping is correct. -CREATE TABLE import_candidates ( - id BIGSERIAL PRIMARY KEY, - media_library_folder_id INTEGER REFERENCES media_library_folders(id) ON DELETE CASCADE, - media_type TEXT NOT NULL, -- 'series' | 'movie' - -- Kind of grouping this row represents. 'series' = entire show folder, - -- 'season' = one season of a show, 'episode' = a single episode file, - -- 'movie' = a single movie file. - match_kind TEXT NOT NULL, - -- Path the candidate was derived from. For series/season kinds this is - -- the folder; for episode/movie kinds this is the file. - discovered_path TEXT NOT NULL, - file_count INTEGER NOT NULL DEFAULT 1, - total_size BIGINT NOT NULL DEFAULT 0, - - -- Parsed metadata (from stackarr-parser on filenames / folder names). - parsed_title TEXT, - parsed_year INTEGER, - parsed_season INTEGER, - parsed_episodes INTEGER[], - - -- TMDB suggestion (populated by the match pass). - suggested_tmdb_id INTEGER, - suggested_title TEXT, - suggested_year INTEGER, - suggested_poster TEXT, - suggested_overview TEXT, - confidence REAL NOT NULL DEFAULT 0.0, - - -- Review state. - -- 'pending' — awaiting user review - -- 'accepted' — user accepted; resulting series_id/movie_id set on target_* - -- 'rejected' — user explicitly rejected - -- 'ignored' — skipped (e.g. bulk-ignore low confidence) - -- 'failed' — accept action raised an error (see `error`) - status TEXT NOT NULL DEFAULT 'pending', - target_series_id BIGINT REFERENCES series(id) ON DELETE SET NULL, - target_movie_id BIGINT REFERENCES movies(id) ON DELETE SET NULL, - error TEXT, - - -- Raw parsed metadata + per-file breakdown for the UI to display. - data JSONB NOT NULL DEFAULT '{}'::jsonb, - - discovered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - resolved_at TIMESTAMPTZ -); - -CREATE INDEX idx_import_candidates_status ON import_candidates(status); -CREATE INDEX idx_import_candidates_media_type ON import_candidates(media_type); -CREATE INDEX idx_import_candidates_discovered_path ON import_candidates(discovered_path); - --- Prevent duplicate pending rows for the same discovered path. When the --- scheduler re-runs disk_scan every 12h we only want a new row per path --- when the previous one has been resolved (accepted/rejected/ignored/failed). -CREATE UNIQUE INDEX idx_import_candidates_pending_path - ON import_candidates(discovered_path) - WHERE status = 'pending'; diff --git a/scripts/convert_sql_placeholders.py b/scripts/convert_sql_placeholders.py index 9024b281..40fed9c4 100644 --- a/scripts/convert_sql_placeholders.py +++ b/scripts/convert_sql_placeholders.py @@ -42,10 +42,13 @@ def issues(path: pathlib.Path, text: str) -> list[str]: return found -def convert(text: str) -> str: +def convert(text: str, *, safe_only: bool = False) -> str: def replace_literal(match: re.Match[str]) -> str: value = match.group(0) - if SQL_WORD.search(value): + numbers = [int(number) for number in PLACEHOLDER.findall(value)] + if SQL_WORD.search(value) and ( + not safe_only or numbers == list(range(1, len(numbers) + 1)) + ): return PLACEHOLDER.sub("?", value) return value @@ -55,6 +58,11 @@ def replace_literal(match: re.Match[str]) -> str: def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--write", action="store_true", help="rewrite safe SQL literals") + parser.add_argument( + "--write-safe", + action="store_true", + help="rewrite monotonic literals and leave unsafe literals for hand review", + ) parser.add_argument( "--report", type=pathlib.Path, @@ -70,9 +78,27 @@ def main() -> None: placeholder_count += len(PLACEHOLDER.findall(text)) flagged.extend(issues(path, text)) - if flagged: - if args.report: - args.report.write_text(json.dumps({"unsafe_queries": flagged}, indent=2) + "\n") + if args.report: + args.report.write_text( + json.dumps( + { + "placeholder_count": placeholder_count, + "unsafe_query_count": len(flagged), + "unsafe_queries": flagged, + }, + indent=2, + ) + + "\n" + ) + + if args.write_safe: + for path in sources: + text = path.read_text() + updated = convert(text, safe_only=True) + if updated != text: + path.write_text(updated) + + if flagged and not args.write_safe: print("unsafe PostgreSQL placeholders require hand review:", file=sys.stderr) print("\n".join(flagged), file=sys.stderr) raise SystemExit(1) @@ -84,8 +110,11 @@ def main() -> None: if updated != text: path.write_text(updated) - action = "converted" if args.write else "audited" - print(f"{action} {placeholder_count} PostgreSQL placeholders across {len(sources)} Rust files") + action = "converted safe literals from" if args.write_safe else "converted" if args.write else "audited" + print( + f"{action} {placeholder_count} PostgreSQL placeholders across {len(sources)} Rust files; " + f"{len(flagged)} queries require hand review" + ) if __name__ == "__main__": diff --git a/scripts/swap_sqlx_backend.py b/scripts/swap_sqlx_backend.py new file mode 100644 index 00000000..47cee251 --- /dev/null +++ b/scripts/swap_sqlx_backend.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Mechanically rename sqlx PostgreSQL driver types to the MariaDB/MySQL driver.""" + +from __future__ import annotations + +import pathlib + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +REPLACEMENTS = ( + ("sqlx::postgres::PgPoolOptions", "sqlx::mysql::MySqlPoolOptions"), + ("sqlx::Postgres", "sqlx::MySql"), + ("sqlx::PgPool", "sqlx::MySqlPool"), + ("postgres::PgPoolOptions", "mysql::MySqlPoolOptions"), + ("PgPoolOptions", "MySqlPoolOptions"), + ("PgPool", "MySqlPool"), +) + + +def main() -> None: + changed = 0 + sources = [*(ROOT / "crates").rglob("*.rs"), *(ROOT / "src").rglob("*.rs")] + for path in sorted(sources): + text = path.read_text() + updated = text + for old, new in REPLACEMENTS: + updated = updated.replace(old, new) + if updated != text: + path.write_text(updated) + changed += 1 + print(f"updated sqlx backend types in {changed} Rust files") + + +if __name__ == "__main__": + main() diff --git a/src/main.rs b/src/main.rs index fd0dea9a..873c8829 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,14 +41,6 @@ struct Cli { #[arg(long, env = "STACKARR_DATABASE_URL")] database_url: Option, - /// Database mode: "external", "managed", or "embedded" - #[arg(long, env = "STACKARR_DATABASE_MODE")] - database_mode: Option, - - /// Port for managed PostgreSQL (default 5433) - #[arg(long, env = "STACKARR_DATABASE_PORT")] - database_port: Option, - /// Log level (trace, debug, info, warn, error) #[arg(long, env = "STACKARR_LOG_LEVEL", default_value = "info")] log_level: String, @@ -80,8 +72,8 @@ enum Commands { } /// Load a directory path from the `app_config` DB table. -async fn load_dir_setting(pool: &sqlx::PgPool, key: &str) -> Option { - sqlx::query_scalar::<_, serde_json::Value>("SELECT value FROM app_config WHERE key = $1") +async fn load_dir_setting(pool: &sqlx::MySqlPool, key: &str) -> Option { + sqlx::query_scalar::<_, serde_json::Value>("SELECT value FROM app_config WHERE key = ?") .bind(key) .fetch_optional(pool) .await @@ -139,12 +131,6 @@ async fn main() -> Result<()> { if let Some(ref db_url) = cli.database_url { config.database.url = db_url.clone(); } - if let Some(ref db_mode) = cli.database_mode { - config.database.mode = db_mode.clone(); - } - if let Some(db_port) = cli.database_port { - config.database.port = db_port; - } if let Some(ref bind) = cli.bind { config.general.bind_addr = bind.clone(); } @@ -157,41 +143,6 @@ async fn main() -> Result<()> { .validate() .context("configuration validation failed")?; - // 4c. Start managed PostgreSQL if configured - #[cfg(feature = "managed-postgres")] - let _pg_manager = { - match config.database.mode.as_str() { - "managed" | "embedded" => { - let pg_data_dir = config - .database - .data_dir - .clone() - .unwrap_or_else(|| config.general.data_dir.clone()); - let pg_port = config.database.port; - tracing::info!(mode = %config.database.mode, port = pg_port, "starting managed PostgreSQL"); - let (manager, url) = - stackarr_postgres::start_managed_postgres(&pg_data_dir, pg_port) - .await - .context("failed to start managed PostgreSQL")?; - config.database.url = url; - Some(manager) - } - _ => None, - } - }; - #[cfg(not(feature = "managed-postgres"))] - let _pg_manager: Option<()> = { - if config.database.mode != "external" { - tracing::warn!( - mode = %config.database.mode, - "database.mode is set to '{}' but managed-postgres feature is not enabled — \ - falling back to external mode. Build with --features managed-postgres to enable.", - config.database.mode, - ); - } - None - }; - // 5. Connect to database let db = Database::connect(&config.database) .await @@ -806,7 +757,7 @@ async fn main() -> Result<()> { let mut name = None; for key in &["discovery_name", "instance_name"] { if let Ok(Some(val)) = sqlx::query_scalar::<_, serde_json::Value>( - "SELECT value FROM app_config WHERE key = $1", + "SELECT value FROM app_config WHERE key = ?", ) .bind(key) .fetch_optional(db.pool()) @@ -997,15 +948,6 @@ async fn main() -> Result<()> { }; stackarr_web::run_with_tls(&listen_addr, state, tls_cfg).await?; - // Shut down managed PostgreSQL - #[cfg(feature = "managed-postgres")] - if let Some(mut pg) = _pg_manager { - tracing::info!("stopping managed PostgreSQL"); - if let Err(e) = pg.stop().await { - tracing::error!(error = %e, "failed to stop managed PostgreSQL cleanly"); - } - } - tracing::info!("StackArr shut down cleanly"); Ok(()) } From e18a5315bb744ab728a03ef02b54815830889857 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:54:27 +0000 Subject: [PATCH 2/9] fix: point the workspace-member list at stackarr-mariadb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rescued swap renamed crates/stackarr-postgres to crates/stackarr-mariadb in Cargo.toml but left AGENTS.md naming the old crate, so the doc-drift guard landed by T29 failed: $ python3 scripts/check_workspace_docs.py workspace documentation drift: Cargo.toml has [... 'crates/stackarr-mariadb'], AGENTS.md has [... 'crates/stackarr-postgres'] exit=1 Now green: "workspace documentation verified: 17 members". Also drops the sentence announcing the rename as future work, since it has happened. This is the guard doing exactly the job §P0.3 gave it. Refs #60 --- AGENTS.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 51045073..c2a32926 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,11 +36,10 @@ The following list is checked against `Cargo.toml` in CI. - `crates/stackarr-cardigann` - `crates/stackarr-cardigann-parity` - `crates/stackarr-stream` -- `crates/stackarr-postgres` +- `crates/stackarr-mariadb` -The final entry is renamed to `stackarr-mariadb` during P1. Update this list in -the same commit as any workspace-member change. +Update this list in the same commit as any workspace-member change. The torrent engine is consumed from crates.io through the `swarmforge` package family and historical `librtbit` dependency aliases. The Usenet engine is the From cfe276fabf0ae8b4e1c1b592d173a84de973d4f4 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:06:03 +0000 Subject: [PATCH 3/9] feat: finish the MariaDB swap in the runtime, CI and docs layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rescued swap converted Rust code but left every surface around it on PostgreSQL, so the binary spoke the sqlx mysql driver while the deployment stack still started postgres:17 and handed it a postgresql:// URL. P1's exit criterion — a fresh docker run reaching /health from an empty database — could not have passed. Runtime and deployment: docker/docker-compose.yml postgres:17 -> mariadb:11.4, mysql:// URL docker/docker-compose.dev.yml likewise; now publishes 3306, matching the TEST_DATABASE_URL default the tests already assumed docker/docker-compose.prod.yml likewise; volume path and the required env var change (see below) docker/docker-compose.test.yml likewise docker/run-tests.sh pg_isready -> healthcheck.sh tests/e2e/*.yml, *.toml five stacks and five configs docker/mariadb-init/ new: grants the dev account server-wide CREATE/DROP, which TestDb needs to make and drop a database per test CI (T19, the MariaDB-service part): - mariadb:11.4 service container on the test job, with a health gate - TEST_DATABASE_URL pointing at it - a second test step running the #[ignore]d tests. Those 31 tests are the only ones that touch a real server, so without this the service container would have proved nothing. Source and config: - 34 stale #[ignore = "requires running postgres"] reasons - two connect_lazy() URLs still using the postgresql:// scheme - config.example.toml and stackarr.toml still advertising port 5432 - no "postgres" left in any .rs file Docs: - docs/DATABASE.md rewritten for MariaDB and the single baseline. It had described 18 migrations that no longer exist and a PgPool API. - docs/UNIFIED-ARR-PLAN.md: corrected two statements that said the swap was concentrated in stackarr-postgres and amounted to a rename. That crate held zero PgPool and zero sqlx references. Deployment-affecting, needs a human decision before deploy: - prod requires STACKARR_MARIADB_PASSWORD, not STACKARR_POSTGRES_PASSWORD - the prod data volume moves to .../stackarr/mariadbdata - docker/Dockerfile.standalone builds --features managed-postgres, which the rename deleted. Left in place and flagged in-file rather than rewritten or removed, because whether StackArr still ships a self-provisioning database is a product decision. Nothing in CI builds it. - the indexarr sidecar keeps its own postgres service; D8 (#29) owns whether it merges in, so this change does not decide it. Gates, locally, with no server present: cargo fmt --all -- --check pass cargo clippy --workspace --all-features -Dwarn pass cargo test --workspace --all-features 1010 passed, 0 failed, 31 ignored scripts/check_workspace_docs.py pass scripts/check_dependency_sources.py pass The 31 ignored tests have still never executed against MariaDB — no docker daemon on this host. The new CI step is the first thing that will run them. Refs #57, #58, #59, #60, #61, #62, #63, #64 --- .github/workflows/ci.yml | 23 + config.example.toml | 4 +- crates/stackarr-core/src/config.rs | 2 +- crates/stackarr-core/src/db.rs | 6 +- crates/stackarr-import/src/lib.rs | 2 +- crates/stackarr-media/src/lib.rs | 18 +- crates/stackarr-migrate/src/lib.rs | 8 +- crates/stackarr-migrate/src/writer.rs | 4 +- .../stackarr-migrate/tests/import_fixtures.rs | 12 +- crates/stackarr-scheduler/src/auto_search.rs | 14 +- crates/stackarr-scheduler/src/lib.rs | 2 +- crates/stackarr-web/src/lib.rs | 16 +- docker/Dockerfile.standalone | 15 + docker/config-test.toml | 2 +- docker/docker-compose.dev.yml | 23 +- docker/docker-compose.prod.yml | 23 +- docker/docker-compose.test.yml | 52 +- docker/docker-compose.yml | 23 +- docker/mariadb-init/01-test-grants.sql | 14 + docker/run-tests.sh | 20 +- docs/DATABASE.md | 980 +++--------------- docs/UNIFIED-ARR-PLAN.md | 4 +- stackarr.toml | 2 +- tests/e2e/config-existing.toml | 2 +- tests/e2e/config-fresh.toml | 2 +- tests/e2e/config-import.toml | 2 +- tests/e2e/config-ngms-test.toml | 2 +- tests/e2e/config-quality-parity.toml | 2 +- tests/e2e/docker-compose.existing.yml | 23 +- tests/e2e/docker-compose.fresh.yml | 23 +- tests/e2e/docker-compose.import.yml | 23 +- tests/e2e/docker-compose.ngms-test.yml | 19 +- tests/e2e/docker-compose.quality-parity.yml | 21 +- 33 files changed, 378 insertions(+), 1010 deletions(-) create mode 100644 docker/mariadb-init/01-test-grants.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a49ebab3..15dd9845 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,23 @@ jobs: test: runs-on: ubuntu-latest + services: + mariadb: + image: mariadb:11.4 + env: + MARIADB_ROOT_PASSWORD: stackarr + ports: + - 3306:3306 + options: >- + --health-cmd="healthcheck.sh --connect --innodb_initialized" + --health-interval=5s + --health-timeout=5s + --health-retries=20 + env: + # The TestDb harness creates and drops a database per test, so this + # account needs server-wide CREATE/DROP. root is scoped to the throwaway + # service container and never leaves the job. + TEST_DATABASE_URL: mysql://root:stackarr@127.0.0.1:3306/mysql steps: - uses: actions/checkout@v7 - uses: Swatinem/rust-cache@v2 @@ -50,6 +67,12 @@ jobs: npm --prefix client ci --no-audit --no-fund npm --prefix client run build - run: cargo test --workspace --all-features --locked + # The database-backed tests are #[ignore]d so they can be skipped on a + # machine with no server. They are the only thing that proves the + # MariaDB swap against a real server, so CI must opt into them + # explicitly now that the service container above exists. + - name: Database-backed tests + run: cargo test --workspace --all-features --locked -- --ignored build: runs-on: ubuntu-latest diff --git a/config.example.toml b/config.example.toml index 21a775a0..200187c9 100644 --- a/config.example.toml +++ b/config.example.toml @@ -15,8 +15,8 @@ data_dir = "/config" log_level = "info" [database] -# PostgreSQL connection string -url = "postgresql://stackarr:stackarr@localhost:5432/stackarr" +# MariaDB connection string +url = "mysql://stackarr:stackarr@localhost:3306/stackarr" # Maximum number of connections in the pool max_connections = 20 diff --git a/crates/stackarr-core/src/config.rs b/crates/stackarr-core/src/config.rs index 74203e7f..312a2c21 100644 --- a/crates/stackarr-core/src/config.rs +++ b/crates/stackarr-core/src/config.rs @@ -702,7 +702,7 @@ mod tests { port = 9090 [database] -url = "postgresql://test:test@localhost:5432/test" +url = "mysql://test:test@localhost:3306/test" [auth] method = "none" diff --git a/crates/stackarr-core/src/db.rs b/crates/stackarr-core/src/db.rs index d7c3845d..4f6e56ae 100644 --- a/crates/stackarr-core/src/db.rs +++ b/crates/stackarr-core/src/db.rs @@ -1500,7 +1500,7 @@ mod tests { use crate::test_helpers::TestDb; #[tokio::test] - #[ignore = "requires running postgres"] + #[ignore = "requires running mariadb"] async fn test_connect_and_migrate() { let db = TestDb::new().await; // If we get here, connect + migrations succeeded @@ -1513,7 +1513,7 @@ mod tests { } #[tokio::test] - #[ignore = "requires running postgres"] + #[ignore = "requires running mariadb"] async fn test_is_first_boot_true() { let db = TestDb::new().await; let database = Database { @@ -1525,7 +1525,7 @@ mod tests { } #[tokio::test] - #[ignore = "requires running postgres"] + #[ignore = "requires running mariadb"] async fn test_enabled_modules_round_trip() { let db = TestDb::new().await; let database = Database { diff --git a/crates/stackarr-import/src/lib.rs b/crates/stackarr-import/src/lib.rs index 5519d60a..64b5e90c 100644 --- a/crates/stackarr-import/src/lib.rs +++ b/crates/stackarr-import/src/lib.rs @@ -1670,7 +1670,7 @@ mod tests { fn dummy_pool() -> MySqlPool { MySqlPoolOptions::new() .max_connections(1) - .connect_lazy("postgresql://fake:fake@localhost:5432/fake") + .connect_lazy("mysql://fake:fake@localhost:3306/fake") .expect("lazy pool") } diff --git a/crates/stackarr-media/src/lib.rs b/crates/stackarr-media/src/lib.rs index ae7f5ae8..177bb9f2 100644 --- a/crates/stackarr-media/src/lib.rs +++ b/crates/stackarr-media/src/lib.rs @@ -1206,7 +1206,7 @@ mod tests { // ── SeriesService ─────────────────────────────────────────────────── #[tokio::test] - #[ignore = "requires running postgres"] + #[ignore = "requires running mariadb"] async fn test_series_create_and_get() { let db = TestDb::new().await; let profile_id = seed_quality_profile(&db.pool).await; @@ -1236,7 +1236,7 @@ mod tests { } #[tokio::test] - #[ignore = "requires running postgres"] + #[ignore = "requires running mariadb"] async fn test_series_list() { let db = TestDb::new().await; let profile_id = seed_quality_profile(&db.pool).await; @@ -1253,7 +1253,7 @@ mod tests { } #[tokio::test] - #[ignore = "requires running postgres"] + #[ignore = "requires running mariadb"] async fn test_series_update_partial() { let db = TestDb::new().await; let profile_id = seed_quality_profile(&db.pool).await; @@ -1293,7 +1293,7 @@ mod tests { } #[tokio::test] - #[ignore = "requires running postgres"] + #[ignore = "requires running mariadb"] async fn test_series_delete() { let db = TestDb::new().await; let profile_id = seed_quality_profile(&db.pool).await; @@ -1322,7 +1322,7 @@ mod tests { // ── MovieService ──────────────────────────────────────────────────── #[tokio::test] - #[ignore = "requires running postgres"] + #[ignore = "requires running mariadb"] async fn test_movie_create_and_get() { let db = TestDb::new().await; let profile_id = seed_quality_profile(&db.pool).await; @@ -1350,7 +1350,7 @@ mod tests { } #[tokio::test] - #[ignore = "requires running postgres"] + #[ignore = "requires running mariadb"] async fn test_movie_delete() { let db = TestDb::new().await; let profile_id = seed_quality_profile(&db.pool).await; @@ -1379,7 +1379,7 @@ mod tests { // ── EpisodeService ────────────────────────────────────────────────── #[tokio::test] - #[ignore = "requires running postgres"] + #[ignore = "requires running mariadb"] async fn test_episode_create_and_list() { let db = TestDb::new().await; let profile_id = seed_quality_profile(&db.pool).await; @@ -1397,7 +1397,7 @@ mod tests { } #[tokio::test] - #[ignore = "requires running postgres"] + #[ignore = "requires running mariadb"] async fn test_episode_set_monitored() { let db = TestDb::new().await; let profile_id = seed_quality_profile(&db.pool).await; @@ -1425,7 +1425,7 @@ mod tests { } #[tokio::test] - #[ignore = "requires running postgres"] + #[ignore = "requires running mariadb"] async fn test_episode_bulk_monitored() { let db = TestDb::new().await; let profile_id = seed_quality_profile(&db.pool).await; diff --git a/crates/stackarr-migrate/src/lib.rs b/crates/stackarr-migrate/src/lib.rs index bb83ed8c..05653907 100644 --- a/crates/stackarr-migrate/src/lib.rs +++ b/crates/stackarr-migrate/src/lib.rs @@ -24,12 +24,12 @@ fn remap_path(path: &mut String, mappings: &[PathMapping]) { } } -/// Run a complete migration from *arr SQLite databases to StackArr Postgres. +/// Run a complete migration from *arr SQLite databases to StackArr MariaDB. /// /// Provide `None` for any database you don't want to import. /// `path_mappings` remaps imported paths (root folders, series/movie directories) /// from the old *arr container mounts to StackArr's mounts. -/// When `dry_run` is true, all data is read and merged but nothing is written to Postgres. +/// When `dry_run` is true, all data is read and merged but nothing is written to MariaDB. pub async fn run_migration( pool: &sqlx::MySqlPool, sonarr_db: Option<&std::path::Path>, @@ -125,7 +125,7 @@ pub async fn run_migration( // 4. Dry-run: count everything and return report without writing if dry_run { - info!("dry run mode -- no data will be written to Postgres"); + info!("dry run mode -- no data will be written to MariaDB"); return Ok(MigrationReport { series_imported: data.series.len(), movies_imported: data.movies.len(), @@ -143,7 +143,7 @@ pub async fn run_migration( }); } - // 5. Write to Postgres + // 5. Write to MariaDB let writer = MigrationWriter::new(pool.clone()); let mut report = writer.write_all(data).await?; report.warnings.extend(merge_warnings); diff --git a/crates/stackarr-migrate/src/writer.rs b/crates/stackarr-migrate/src/writer.rs index e11c75c6..7d784407 100644 --- a/crates/stackarr-migrate/src/writer.rs +++ b/crates/stackarr-migrate/src/writer.rs @@ -253,7 +253,7 @@ fn normalize_cf_specifications(raw: &JsonValue) -> JsonValue { } // --------------------------------------------------------------------------- -// Insert structs – what we write to Postgres +// Insert structs – what we write to MariaDB // --------------------------------------------------------------------------- #[derive(Debug, Clone)] @@ -464,7 +464,7 @@ pub struct MigrationData { pub tags: Vec, /// Maps old source tag IDs (Sonarr/Radarr) to their label (lowercase). /// Used during write to re-map old integer tag IDs on series/movies to - /// the new PostgreSQL tag IDs via label lookup. + /// the new MariaDB tag IDs via label lookup. pub old_tag_id_to_label: HashMap, pub naming_series: Option, pub naming_movie: Option, diff --git a/crates/stackarr-migrate/tests/import_fixtures.rs b/crates/stackarr-migrate/tests/import_fixtures.rs index 4e202951..4651ed27 100644 --- a/crates/stackarr-migrate/tests/import_fixtures.rs +++ b/crates/stackarr-migrate/tests/import_fixtures.rs @@ -1,7 +1,7 @@ //! Integration test: import real Sonarr, Radarr, Prowlarr backups. //! //! Requires: -//! - A running Postgres on localhost:5433 (docker compose -f docker/docker-compose.dev.yml up -d) +//! - A running MariaDB on localhost:3306 (docker compose -f docker/docker-compose.dev.yml up -d) //! - Fixture files in test-fixtures/ at the repo root //! //! Run with: cargo test -p stackarr-migrate --test import_fixtures -- --ignored --nocapture @@ -22,7 +22,7 @@ fn has_fixtures() -> bool { } #[tokio::test] -#[ignore = "requires running postgres and test-fixtures"] +#[ignore = "requires running mariadb and test-fixtures"] async fn test_import_all_fixtures() { if !has_fixtures() { eprintln!( @@ -56,7 +56,7 @@ async fn test_import_all_fixtures() { ); assert!(report.indexers_imported > 0, "should import indexers"); - // Verify data landed in Postgres + // Verify data landed in MariaDB let series_count: (i64,) = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM series") .fetch_one(&db.pool) .await @@ -92,7 +92,7 @@ async fn test_import_all_fixtures() { } #[tokio::test] -#[ignore = "requires running postgres and test-fixtures"] +#[ignore = "requires running mariadb and test-fixtures"] async fn test_import_sonarr_only() { if !has_fixtures() { eprintln!("SKIP: test-fixtures/sonarr.db not found"); @@ -122,7 +122,7 @@ async fn test_import_sonarr_only() { } #[tokio::test] -#[ignore = "requires running postgres and test-fixtures"] +#[ignore = "requires running mariadb and test-fixtures"] async fn test_import_radarr_only() { if !has_fixtures() { eprintln!("SKIP: test-fixtures/radarr.db not found"); @@ -151,7 +151,7 @@ async fn test_import_radarr_only() { } #[tokio::test] -#[ignore = "requires running postgres and test-fixtures"] +#[ignore = "requires running mariadb and test-fixtures"] async fn test_dry_run() { if !has_fixtures() { eprintln!("SKIP: test-fixtures not found"); diff --git a/crates/stackarr-scheduler/src/auto_search.rs b/crates/stackarr-scheduler/src/auto_search.rs index 9c660289..2decd61a 100644 --- a/crates/stackarr-scheduler/src/auto_search.rs +++ b/crates/stackarr-scheduler/src/auto_search.rs @@ -1108,7 +1108,7 @@ mod tests { // ── Tests ──────────────────────────────────────────────────────────── #[tokio::test] - #[ignore = "requires running postgres"] + #[ignore = "requires running mariadb"] async fn test_evaluate_no_releases_returns_none() { let db = TestDb::new().await; let profile_id = seed_profile_with_quality(&db.pool, 16).await; // WEBDL-2160p @@ -1137,7 +1137,7 @@ mod tests { } #[tokio::test] - #[ignore = "requires running postgres"] + #[ignore = "requires running mariadb"] async fn test_evaluate_all_rejected_returns_none() { let db = TestDb::new().await; // Profile only allows quality 6 (HDTV-720p), but release is 2160p (quality 16) @@ -1170,7 +1170,7 @@ mod tests { } #[tokio::test] - #[ignore = "requires running postgres"] + #[ignore = "requires running mariadb"] async fn test_evaluate_picks_best_and_grabs() { let db = TestDb::new().await; // Allow WEBDL-1080p (quality 3) @@ -1212,7 +1212,7 @@ mod tests { } #[tokio::test] - #[ignore = "requires running postgres"] + #[ignore = "requires running mariadb"] async fn test_evaluate_no_download_client_returns_err() { let db = TestDb::new().await; let profile_id = seed_profile_with_quality(&db.pool, 11).await; @@ -1248,7 +1248,7 @@ mod tests { } #[tokio::test] - #[ignore = "requires running postgres"] + #[ignore = "requires running mariadb"] async fn test_evaluate_inserts_queue_and_history() { let db = TestDb::new().await; let profile_id = seed_profile_with_quality(&db.pool, 11).await; @@ -1311,7 +1311,7 @@ mod tests { } #[tokio::test] - #[ignore = "requires running postgres"] + #[ignore = "requires running mariadb"] async fn test_evaluate_blocklisted_release_skipped() { let db = TestDb::new().await; let profile_id = seed_profile_with_quality(&db.pool, 11).await; @@ -1349,7 +1349,7 @@ mod tests { } #[tokio::test] - #[ignore = "requires running postgres"] + #[ignore = "requires running mariadb"] async fn test_evaluate_picks_best_when_multiple_approved() { let db = TestDb::new().await; // Allow both WEBDL-720p (quality 7) and WEBDL-1080p (quality 11) diff --git a/crates/stackarr-scheduler/src/lib.rs b/crates/stackarr-scheduler/src/lib.rs index f949e111..9fe13b1f 100644 --- a/crates/stackarr-scheduler/src/lib.rs +++ b/crates/stackarr-scheduler/src/lib.rs @@ -2208,7 +2208,7 @@ mod tests { // connect_lazy requires a tokio context, so tests must be #[tokio::test] MySqlPoolOptions::new() .max_connections(1) - .connect_lazy("postgresql://fake:fake@localhost:5432/fake") + .connect_lazy("mysql://fake:fake@localhost:3306/fake") .expect("lazy pool") } diff --git a/crates/stackarr-web/src/lib.rs b/crates/stackarr-web/src/lib.rs index e28dfc82..f5890ae6 100644 --- a/crates/stackarr-web/src/lib.rs +++ b/crates/stackarr-web/src/lib.rs @@ -414,7 +414,7 @@ mod tests { } #[tokio::test] - #[ignore = "requires running postgres"] + #[ignore = "requires running mariadb"] async fn test_health_check() { let (state, db) = test_state().await; let app = build_router(state); @@ -434,7 +434,7 @@ mod tests { } #[tokio::test] - #[ignore = "requires running postgres"] + #[ignore = "requires running mariadb"] async fn test_system_health() { let (state, db) = test_state().await; let app = build_router(state); @@ -456,7 +456,7 @@ mod tests { } #[tokio::test] - #[ignore = "requires running postgres"] + #[ignore = "requires running mariadb"] async fn test_list_series_empty() { let (state, db) = test_state().await; let app = build_router(state); @@ -479,7 +479,7 @@ mod tests { } #[tokio::test] - #[ignore = "requires running postgres"] + #[ignore = "requires running mariadb"] async fn test_create_series() { let (state, db) = test_state().await; let profile_id = seed_quality_profile(&db.pool).await; @@ -512,7 +512,7 @@ mod tests { } #[tokio::test] - #[ignore = "requires running postgres"] + #[ignore = "requires running mariadb"] async fn test_list_tags_empty() { let (state, db) = test_state().await; let app = build_router(state); @@ -534,7 +534,7 @@ mod tests { } #[tokio::test] - #[ignore = "requires running postgres"] + #[ignore = "requires running mariadb"] async fn test_list_queue_empty() { let (state, db) = test_state().await; let app = build_router(state); @@ -556,7 +556,7 @@ mod tests { } #[tokio::test] - #[ignore = "requires running postgres"] + #[ignore = "requires running mariadb"] async fn test_list_quality_profiles_empty() { let (state, db) = test_state().await; let app = build_router(state); @@ -576,7 +576,7 @@ mod tests { } #[tokio::test] - #[ignore = "requires running postgres"] + #[ignore = "requires running mariadb"] async fn test_list_media_library_folders_empty() { let (state, db) = test_state().await; let app = build_router(state); diff --git a/docker/Dockerfile.standalone b/docker/Dockerfile.standalone index 888383ac..16059cef 100644 --- a/docker/Dockerfile.standalone +++ b/docker/Dockerfile.standalone @@ -1,3 +1,18 @@ +# BROKEN — DO NOT USE. Left in place deliberately; see below. +# +# This image built the `managed-postgres` feature, which provisioned and +# supervised a PostgreSQL server inside the container. The MariaDB swap +# deleted that subsystem (crates/stackarr-postgres' config/lifecycle/provision +# modules) rather than porting it, so the feature named on the cargo lines +# below no longer exists and this build fails immediately. +# +# Whether StackArr still ships a self-provisioning single-container database +# is a product decision, not a build detail, so this file is not being +# rewritten or deleted until that decision is made. MariaDB has no drop-in +# equivalent of the embedded-Postgres approach used here. +# +# Nothing in CI builds this file (ci.yml builds docker/Dockerfile only). +# # Standalone StackArr — single container with managed PostgreSQL # No external database needed; PG is managed as a child process. # diff --git a/docker/config-test.toml b/docker/config-test.toml index 11f22b3b..2801fab1 100644 --- a/docker/config-test.toml +++ b/docker/config-test.toml @@ -9,7 +9,7 @@ data_dir = "/config" log_level = "debug" [database] -url = "postgresql://stackarr:stackarr@postgres:5432/stackarr" +url = "mysql://stackarr:stackarr@mariadb:3306/stackarr" max_connections = 10 [auth] diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 695868d7..b666615d 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -1,14 +1,21 @@ services: - postgres: - image: postgres:17-alpine + mariadb: + image: mariadb:11.4 ports: - - "5433:5432" + - "3306:3306" volumes: - - pgdata:/var/lib/postgresql/data + - mariadbdata:/var/lib/mysql + - ./mariadb-init:/docker-entrypoint-initdb.d:ro environment: - - POSTGRES_USER=stackarr - - POSTGRES_PASSWORD=stackarr - - POSTGRES_DB=stackarr + - MARIADB_ROOT_PASSWORD=stackarr + - MARIADB_USER=stackarr + - MARIADB_PASSWORD=stackarr + - MARIADB_DATABASE=stackarr + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 5s + timeout: 5s + retries: 20 volumes: - pgdata: + mariadbdata: diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 84743746..eca5b8e7 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -13,7 +13,7 @@ services: environment: - PUID=1000 - PGID=1000 - - STACKARR_DATABASE_URL=postgresql://stackarr:${STACKARR_POSTGRES_PASSWORD:?set STACKARR_POSTGRES_PASSWORD}@postgres:5432/stackarr + - STACKARR_DATABASE_URL=mysql://stackarr:${STACKARR_MARIADB_PASSWORD:?set STACKARR_MARIADB_PASSWORD}@mariadb:3306/stackarr - STACKARR_LOG_LEVEL=info - STACKARR_BIND=0.0.0.0 - STACKARR_PORT=9111 @@ -53,7 +53,7 @@ services: - "44" # video - "992" # render depends_on: - postgres: + mariadb: condition: service_healthy healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:9111/health"] @@ -73,19 +73,20 @@ services: limits: memory: 4096m - postgres: - image: postgres:17-alpine - container_name: stackarr-postgres + mariadb: + image: mariadb:11.4 + container_name: stackarr-mariadb volumes: - - /mnt/2tnvme/docker/volumes/stackarr/pgdata:/var/lib/postgresql/data + - /mnt/2tnvme/docker/volumes/stackarr/mariadbdata:/var/lib/mysql environment: - - POSTGRES_USER=stackarr - - POSTGRES_PASSWORD=${STACKARR_POSTGRES_PASSWORD:?set STACKARR_POSTGRES_PASSWORD} - - POSTGRES_DB=stackarr + - MARIADB_ROOT_PASSWORD=${STACKARR_MARIADB_PASSWORD:?set STACKARR_MARIADB_PASSWORD} + - MARIADB_USER=stackarr + - MARIADB_PASSWORD=${STACKARR_MARIADB_PASSWORD:?set STACKARR_MARIADB_PASSWORD} + - MARIADB_DATABASE=stackarr - TZ=Australia/Sydney healthcheck: - test: ["CMD-SHELL", "pg_isready -U stackarr"] + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 5s timeout: 5s - retries: 5 + retries: 20 restart: unless-stopped diff --git a/docker/docker-compose.test.yml b/docker/docker-compose.test.yml index e6a19d56..36a1ee74 100644 --- a/docker/docker-compose.test.yml +++ b/docker/docker-compose.test.yml @@ -18,22 +18,23 @@ services: # ── Database ────────────────────────────────────────────── - postgres: - image: postgres:17-alpine - container_name: stackarr-test-pg + mariadb: + image: mariadb:11.4 + container_name: stackarr-test-mariadb tmpfs: - - /var/lib/postgresql/data:rw,noexec,nosuid,size=512m + - /var/lib/mysql:rw,noexec,nosuid,size=512m environment: - POSTGRES_USER: stackarr - POSTGRES_PASSWORD: stackarr - POSTGRES_DB: stackarr + MARIADB_ROOT_PASSWORD: stackarr + MARIADB_USER: stackarr + MARIADB_PASSWORD: stackarr + MARIADB_DATABASE: stackarr healthcheck: - test: ["CMD-SHELL", "pg_isready -U stackarr"] + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 3s timeout: 3s - retries: 10 + retries: 20 ports: - - "5434:5432" + - "3307:3306" # ── Unit tests (no DB required) ─────────────────────────── test-unit: @@ -43,14 +44,14 @@ services: command: cargo test --workspace --lib depends_on: [] - # ── Integration tests (needs Postgres) ──────────────────── + # ── Integration tests (needs MariaDB) ───────────────────── # Runs only #[ignore] tests in stackarr-* crates (excludes vendored torrent/usenet) test-integration: image: stackarr-test-runner:latest container_name: stackarr-test-integration working_dir: /build environment: - TEST_DATABASE_URL: postgresql://stackarr:stackarr@postgres:5432/postgres + TEST_DATABASE_URL: mysql://root:stackarr@mariadb:3306/mysql command: > cargo test -p stackarr-core @@ -59,7 +60,7 @@ services: -p stackarr-migrate -- --ignored depends_on: - postgres: + mariadb: condition: service_healthy # ── StackArr instance (for E2E tests) ───────────────────── @@ -67,7 +68,7 @@ services: image: stackarr-test-app:latest container_name: stackarr-test-app environment: - STACKARR_DATABASE_URL: postgresql://stackarr:stackarr@postgres:5432/stackarr + STACKARR_DATABASE_URL: mysql://stackarr:stackarr@mariadb:3306/stackarr STACKARR_INDEXARR_ENABLED: "true" STACKARR_LOG_LEVEL: debug volumes: @@ -79,7 +80,7 @@ services: - media-tv:/media/TV - media-movies:/media/Movies depends_on: - postgres: + mariadb: condition: service_healthy healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:9111/health"] @@ -90,6 +91,23 @@ services: ports: - "9211:9111" + # Indexarr is a separate product with its own Postgres backend. It is NOT part + # of the StackArr MariaDB swap; D8 (#29) decides whether it merges in. + indexarr-postgres: + image: postgres:17-alpine + container_name: stackarr-test-indexarr-pg + tmpfs: + - /var/lib/postgresql/data:rw,noexec,nosuid,size=256m + environment: + POSTGRES_USER: stackarr + POSTGRES_PASSWORD: stackarr + POSTGRES_DB: indexarr + healthcheck: + test: ["CMD-SHELL", "pg_isready -U stackarr"] + interval: 3s + timeout: 3s + retries: 10 + # ── Indexarr sidecar (indexarr-rs) ───────────────────────── indexarr: image: ghcr.io/thedancingdeveloper-org/indexarr-rs:dev @@ -99,10 +117,10 @@ services: environment: INDEXARR_WORKERS: http_server,sync INDEXARR_DB_BACKEND: postgresql - INDEXARR_DB_URL: postgresql://stackarr:stackarr@postgres:5432/indexarr + INDEXARR_DB_URL: postgresql://stackarr:stackarr@indexarr-postgres:5432/indexarr TZ: Australia/Sydney depends_on: - postgres: + indexarr-postgres: condition: service_healthy restart: "no" ports: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 8e6edba7..05f1d0b7 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -14,34 +14,35 @@ services: devices: - /dev/dri:/dev/dri # Intel QSV / VAAPI hardware transcoding environment: - - STACKARR_DATABASE_URL=postgresql://${POSTGRES_USER:-stackarr}:${POSTGRES_PASSWORD:-stackarr}@postgres:5432/${POSTGRES_DB:-stackarr} + - STACKARR_DATABASE_URL=mysql://${MARIADB_USER:-stackarr}:${MARIADB_PASSWORD:-stackarr}@mariadb:3306/${MARIADB_DATABASE:-stackarr} - STACKARR_LOG_LEVEL=info - STACKARR_BIND=0.0.0.0 - STACKARR_PORT=9111 - STACKARR_TMDB_API_KEY=${STACKARR_TMDB_API_KEY:-} - STACKARR_INDEXARR_ENABLED=${STACKARR_INDEXARR_ENABLED:-false} depends_on: - postgres: + mariadb: condition: service_healthy restart: unless-stopped - postgres: - image: postgres:17-alpine + mariadb: + image: mariadb:11.4 volumes: - - pgdata:/var/lib/postgresql/data + - mariadbdata:/var/lib/mysql environment: - - POSTGRES_USER=${POSTGRES_USER:-stackarr} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-stackarr} - - POSTGRES_DB=${POSTGRES_DB:-stackarr} + - MARIADB_ROOT_PASSWORD=${MARIADB_ROOT_PASSWORD:-stackarr} + - MARIADB_USER=${MARIADB_USER:-stackarr} + - MARIADB_PASSWORD=${MARIADB_PASSWORD:-stackarr} + - MARIADB_DATABASE=${MARIADB_DATABASE:-stackarr} healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-stackarr}"] + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 5s timeout: 5s - retries: 5 + retries: 20 restart: unless-stopped volumes: stackarr-config: stackarr-transcode: - pgdata: + mariadbdata: diff --git a/docker/mariadb-init/01-test-grants.sql b/docker/mariadb-init/01-test-grants.sql new file mode 100644 index 00000000..c4f4c1b0 --- /dev/null +++ b/docker/mariadb-init/01-test-grants.sql @@ -0,0 +1,14 @@ +-- SPDX-License-Identifier: GPL-3.0-only +-- Development and integration-test grants. +-- +-- stackarr-core's TestDb harness creates a randomly named database per test +-- (`stackarr_test_`) and drops it afterwards, so the account named in +-- TEST_DATABASE_URL needs CREATE/DROP on databases it does not own yet. The +-- MARIADB_USER created by the image is only scoped to MARIADB_DATABASE, which +-- is not enough. +-- +-- This file is mounted into /docker-entrypoint-initdb.d and therefore only +-- runs for the local development stack. Do not reuse it for production. + +GRANT ALL PRIVILEGES ON *.* TO 'stackarr'@'%'; +FLUSH PRIVILEGES; diff --git a/docker/run-tests.sh b/docker/run-tests.sh index 5aaa46fb..6b520e08 100755 --- a/docker/run-tests.sh +++ b/docker/run-tests.sh @@ -95,20 +95,20 @@ cmd_unit() { cmd_integration() { header "Integration Tests" - log "Starting Postgres..." - $COMPOSE up -d postgres - log "Waiting for Postgres health..." + log "Starting MariaDB..." + $COMPOSE up -d mariadb + log "Waiting for MariaDB health..." local attempt=0 while [ $attempt -lt 30 ]; do - if $COMPOSE exec postgres pg_isready -U stackarr >/dev/null 2>&1; then - ok "Postgres ready" + if $COMPOSE exec mariadb healthcheck.sh --connect --innodb_initialized >/dev/null 2>&1; then + ok "MariaDB ready" break fi sleep 1 attempt=$((attempt + 1)) done if [ $attempt -ge 30 ]; then - fail "Postgres not ready after 30s" + fail "MariaDB not ready after 30s" return 1 fi @@ -123,8 +123,8 @@ cmd_e2e_mocked() { cmd_e2e_live() { header "E2E Tests (Live)" - log "Starting full stack (Postgres + StackArr + Indexarr)..." - $COMPOSE up -d postgres indexarr stackarr + log "Starting full stack (MariaDB + StackArr + Indexarr)..." + $COMPOSE up -d mariadb indexarr-postgres indexarr stackarr log "Waiting for StackArr to be healthy..." local attempt=0 @@ -169,7 +169,7 @@ cmd_all() { # Unit tests (no services needed) cmd_unit - # Integration tests (needs Postgres) + # Integration tests (needs MariaDB) cmd_integration # E2E tests (needs full stack) @@ -218,7 +218,7 @@ case "${1:-all}" in echo " all — Run all tests + teardown (default)" echo " build — Build Docker images only" echo " unit — Run unit tests" - echo " integration — Run integration tests (with Postgres)" + echo " integration — Run integration tests (with MariaDB)" echo " e2e — Run all E2E tests (mocked + live)" echo " e2e-mocked — Run mocked E2E tests only" echo " e2e-live — Run live E2E tests only" diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 633e0fcf..51088f64 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -1,16 +1,21 @@ # Database -PostgreSQL 17 is required. SQLite is only used for reading *arr migration databases (rusqlite in `stackarr-migrate`). +MariaDB 11.4 LTS is required, reached through the `sqlx` MySQL driver. SQLite is +used only for *reading* arr migration databases (`rusqlite` in +`stackarr-migrate`). + +StackArr deploys fresh. There is no upgrade path from the pre-MariaDB schema and +none is planned, which is what makes the single baseline below possible. ## Connection ```rust // stackarr-core/src/db.rs -pub struct Database { pool: PgPool } +pub struct Database { pool: MySqlPool } impl Database { pub async fn connect(config: &DatabaseConfig) -> Result - pub fn pool(&self) -> &PgPool + pub fn pool(&self) -> &MySqlPool pub async fn run_migrations(&self) -> Result<()> pub async fn is_first_boot(&self) -> Result pub async fn load_enabled_modules(&self) -> Result @@ -18,859 +23,138 @@ impl Database { } ``` -Connection string format: `postgresql://user:pass@host:port/dbname` -Default max connections: 20 (configurable in `[database]` section). - -## Schema Overview - -14 migration files in `migrations/`: - -| Migration | Description | -|-----------|-------------| -| `001_initial.sql` | All core tables, seeded data | -| `002_streaming.sql` | `streaming_sessions` table | -| `003_health_check.sql` | Health check fields on `indexers` and `download_clients` | -| `004_remote_access.sql` | `remote_clients` table | -| `005_quality_profile_media_type.sql` | Add `media_type` column to `quality_profiles` | -| `006_users.sql` | User system: users, sessions, devices, invites, watch progress, requests, watchlist, ratings, notifications, push subscriptions; links `streaming_sessions` to users | -| `007_language_queue.sql` | Add `language` to `quality_profiles`, `original_language` to `movies`, queue media index | -| `008_system_activities.sql` | `system_activities` table for background task tracking | -| `009_plex_verify_tls.sql` | Add `verify_tls` to `plex_servers` | -| `010_media_management.sql` | `recycle_bin` table, seed media management config | -| `011_plex_deep_integration.sql` | Plex events, webhook secrets, unified streaming | -| `012_rss.sql` | RSS feed subscriptions | -| `013_queue_output_path.sql` | Add `output_path` and `stale_count` to `queue` | -| `014_custom_format_fields.sql` | Add `include_custom_format_when_renaming` to `custom_formats`, `min_upgrade_format_score` to `quality_profiles` | - -### Table Groups - -#### Configuration -| Table | Purpose | -|-------|---------| -| `app_config` | Key-value config store (key TEXT PK, value JSONB) | -| `enabled_modules` | Module on/off flags (module_name TEXT PK, enabled BOOL, config JSONB) | -| `naming_config` | File naming patterns per media type | - -#### Media Libraries -| Table | Purpose | -|-------|---------| -| `media_library_folders` | Root directories for TV/Movies (path, media_type, free_space) | -| `tags` | User-defined tags for categorization | - -#### Quality -| Table | Purpose | -|-------|---------| -| `quality_profiles` | Named profiles with cutoff, upgrade settings, items (JSONB), media_type, language, min_upgrade_format_score | -| `custom_formats` | Custom format rules (specifications JSONB, include_custom_format_when_renaming) | -| `custom_format_scores` | Profile <> format junction with score | - -#### TV Series -| Table | Purpose | -|-------|---------| -| `series` | Series metadata (title, external IDs, path, status, images, genres, tags) | -| `seasons` | Season records with monitored flag | -| `episodes` | Episode details (numbering, air dates, file reference) | -| `episode_files` | Episode <> media_file junction (multi-episode support) | - -#### Movies -| Table | Purpose | -|-------|---------| -| `movies` | Movie metadata (title, external IDs, path, availability, dates, original_language) | - -#### Shared -| Table | Purpose | -|-------|---------| -| `media_files` | File records (path, size, quality JSONB, languages JSONB, scene info) | -| `alternative_titles` | Alt titles for matching (clean_title, scene_name flag) | - -#### Downloads -| Table | Purpose | -|-------|---------| -| `indexers` | Indexer configs (type, URL, API key, categories, protocol) | -| `download_clients` | Download client configs (type, protocol, config JSONB) | -| `queue` | In-progress downloads (status, download_id, error tracking) | -| `history` | Event log (grabbed/imported/failed/renamed/ignored) | -| `blocklist` | Rejected releases | - -#### Integrations -| Table | Purpose | -|-------|---------| -| `notification_providers` | Notification configs (type, events, config JSONB) | -| `import_lists` | External list sources (type, media_type, config JSONB) | -| `discover_sliders` | Homepage content sections | -| `plex_servers` | Plex server connections (with verify_tls toggle) | -| `plex_libraries` | Plex library mappings | -| `watchlist` | Plex watchlist items | - -#### Streaming (migration 002) -| Table | Purpose | -|-------|---------| -| `streaming_sessions` | Active/completed streaming sessions (type, status, transcode progress, codecs, user_id) | - -#### Remote Access (migration 004) -| Table | Purpose | -|-------|---------| -| `remote_clients` | Bootstrap-paired remote client tokens and metadata | - -#### Users & Authentication (migration 006) -| Table | Purpose | -|-------|---------| -| `users` | User accounts (username, password_hash, role, avatar, enabled) | -| `user_sessions` | Web login sessions with expiry and activity tracking | -| `user_devices` | Per-user device registrations (replaces `remote_clients` for user-scoped access) | -| `invites` | Invite codes for user registration (created_by, claimed_by, role, expiry) | - -#### User Engagement (migration 006) -| Table | Purpose | -|-------|---------| -| `watch_progress` | Per-user playback position tracking (continue watching) | -| `media_requests` | User-submitted media requests with approval workflow | -| `user_watchlist` | Per-user watchlist (distinct from Plex `watchlist`) | -| `user_ratings` | User ratings (1-10 scale) per media item | -| `user_notifications` | In-app notification inbox per user | -| `push_subscriptions` | Web Push API subscriptions per user | - -#### System (migration 008) -| Table | Purpose | -|-------|---------| -| `system_activities` | Background task tracking (disk scans, imports, transcodes) | - -#### Media Management (migration 010) -| Table | Purpose | -|-------|---------| -| `recycle_bin` | Files moved to recycle bin pending scheduled cleanup | - -### Health Check Fields (migration 003) - -Added to `indexers` and `download_clients`: - -| Column | Type | Purpose | -|--------|------|---------| -| `last_health_check` | TIMESTAMPTZ | When last health check ran | -| `health_status` | TEXT | Current health status | -| `consecutive_failures` | INTEGER | Failure count for auto-disable | -| `auto_disabled` | BOOLEAN | Whether auto-disabled due to failures | - -### Streaming Sessions (migration 002, updated in 006) - -| Column | Type | Purpose | -|--------|------|---------| -| `id` | UUID PK | Session identifier | -| `media_file_id` | BIGINT FK | Media file being streamed | -| `session_type` | TEXT | `'direct'` or `'transcode'` | -| `status` | TEXT | `'active'`, `'paused'`, `'completed'`, `'error'` | -| `started_at` | TIMESTAMPTZ | Session start time | -| `last_activity` | TIMESTAMPTZ | Last activity timestamp | -| `transcode_progress` | REAL | 0.0 to 1.0 | -| `video_codec` | TEXT | Video codec used | -| `audio_codec` | TEXT | Audio codec used | -| `resolution` | TEXT | Output resolution | -| `bitrate` | BIGINT | Output bitrate | -| `client_info` | TEXT | Client identifier | -| `transcode_dir` | TEXT | Transcode output directory | -| `user_id` | BIGINT FK | User who owns the session (added in migration 006) | - -### Remote Clients (migration 004) - -| Column | Type | Purpose | -|--------|------|---------| -| `id` | SERIAL PK | Auto-increment ID | -| `client_token` | UUID UNIQUE | Authentication token | -| `client_name` | TEXT | Human-readable client name | -| `created_at` | TIMESTAMPTZ | When client was registered | -| `last_seen` | TIMESTAMPTZ | Last API access | -| `revoked` | BOOLEAN | Whether access has been revoked | - -### Quality Profile Updates (migrations 005, 007, 014) - -Added to `quality_profiles`: - -| Column | Type | Purpose | -|--------|------|---------| -| `media_type` | TEXT | Scopes profile to `'series'`, `'movie'`, or NULL for any | -| `language` | INTEGER NOT NULL DEFAULT -1 | Language preference: -1 = any, -2 = original, positive = specific Radarr language ID | -| `min_upgrade_format_score` | INTEGER NOT NULL DEFAULT 1 | Minimum custom format score improvement required for an upgrade to be considered | - -### Movie Updates (migration 007) - -Added to `movies`: - -| Column | Type | Purpose | -|--------|------|---------| -| `original_language` | INTEGER | Radarr language ID (1=English, 2=French, 3=Spanish, etc.) for resolving "Original" language profiles | - -### Queue Index (migration 007) - -Added index `idx_queue_media` on `queue(media_type, media_id)` for media-item-based conflict checking. - -### Plex Server Updates (migration 009) - -Added to `plex_servers`: - -| Column | Type | Purpose | -|--------|------|---------| -| `verify_tls` | BOOLEAN NOT NULL DEFAULT false | Per-server TLS certificate verification toggle (false for backward compat with self-signed certs) | - -### Users (migration 006) - -| Column | Type | Purpose | -|--------|------|---------| -| `id` | BIGSERIAL PK | User identifier | -| `username` | TEXT NOT NULL UNIQUE | Login name | -| `display_name` | TEXT NOT NULL | Display name | -| `password_hash` | TEXT NOT NULL | Argon2 password hash | -| `role` | TEXT NOT NULL DEFAULT 'user' | Role: `'admin'`, `'user'` | -| `avatar_url` | TEXT | Optional avatar URL | -| `enabled` | BOOLEAN NOT NULL DEFAULT true | Whether account is active | -| `created_at` | TIMESTAMPTZ | Account creation time | -| `updated_at` | TIMESTAMPTZ | Last profile update time | - -### User Sessions (migration 006) - -| Column | Type | Purpose | -|--------|------|---------| -| `id` | UUID PK | Session identifier (auto-generated) | -| `user_id` | BIGINT FK | Owning user (CASCADE delete) | -| `token_hash` | TEXT NOT NULL UNIQUE | Hashed session token | -| `user_agent` | TEXT | Browser/client user agent | -| `ip_address` | INET | Client IP address | -| `created_at` | TIMESTAMPTZ | Session creation time | -| `expires_at` | TIMESTAMPTZ | Session expiry time | -| `last_active` | TIMESTAMPTZ | Last request time | - -Indexes: `idx_user_sessions_user(user_id)`, `idx_user_sessions_expires(expires_at)`. - -### User Devices (migration 006) - -| Column | Type | Purpose | -|--------|------|---------| -| `id` | SERIAL PK | Auto-increment ID | -| `user_id` | BIGINT FK | Owning user (CASCADE delete) | -| `device_token` | UUID NOT NULL UNIQUE | Authentication token | -| `device_name` | TEXT | Human-readable device name | -| `device_type` | TEXT | Device type (e.g., "android", "ios") | -| `created_at` | TIMESTAMPTZ | Registration time | -| `last_seen` | TIMESTAMPTZ | Last API access | -| `revoked` | BOOLEAN NOT NULL DEFAULT false | Whether device access is revoked | - -Indexes: `idx_user_devices_token(device_token)`, `idx_user_devices_user(user_id)`. - -### Invites (migration 006) - -| Column | Type | Purpose | -|--------|------|---------| -| `id` | SERIAL PK | Auto-increment ID | -| `code` | TEXT NOT NULL UNIQUE | Invite code string | -| `created_by` | BIGINT FK | Admin user who created the invite | -| `claimed_by` | BIGINT FK | User who redeemed the invite (NULL if unclaimed) | -| `role` | TEXT NOT NULL DEFAULT 'user' | Role assigned on claim | -| `expires_at` | TIMESTAMPTZ | Optional expiry time | -| `created_at` | TIMESTAMPTZ | Creation time | - -Index: `idx_invites_code(code)`. - -### Watch Progress (migration 006) - -| Column | Type | Purpose | -|--------|------|---------| -| `id` | BIGSERIAL PK | Progress record ID | -| `user_id` | BIGINT FK | Owning user (CASCADE delete) | -| `media_file_id` | BIGINT FK | Media file being watched (CASCADE delete) | -| `media_type` | TEXT NOT NULL | `'series'` or `'movie'` | -| `media_id` | BIGINT NOT NULL | ID of series or movie | -| `episode_id` | BIGINT | Episode ID (NULL for movies) | -| `position_secs` | REAL NOT NULL DEFAULT 0 | Current playback position in seconds | -| `duration_secs` | REAL NOT NULL DEFAULT 0 | Total duration in seconds | -| `completed` | BOOLEAN NOT NULL DEFAULT false | Whether playback is complete | -| `updated_at` | TIMESTAMPTZ | Last progress update | - -Unique constraint: `(user_id, media_file_id)`. -Indexes: `idx_watch_progress_user(user_id, updated_at DESC)`, `idx_watch_progress_continue(user_id, completed, updated_at DESC)`, `idx_watch_progress_media(media_type, media_id)`. - -### Media Requests (migration 006) - -| Column | Type | Purpose | -|--------|------|---------| -| `id` | BIGSERIAL PK | Request ID | -| `user_id` | BIGINT FK | Requesting user | -| `media_type` | TEXT NOT NULL | `'series'` or `'movie'` | -| `tmdb_id` | BIGINT NOT NULL | TMDB ID of requested media | -| `title` | TEXT NOT NULL | Requested media title | -| `year` | INTEGER | Release year | -| `poster_url` | TEXT | Poster image URL | -| `overview` | TEXT | Media description | -| `status` | TEXT NOT NULL DEFAULT 'pending' | `'pending'`, `'approved'`, `'denied'`, `'available'` | -| `admin_note` | TEXT | Admin response note | -| `approved_by` | BIGINT FK | Admin who approved/denied | -| `created_at` | TIMESTAMPTZ | Request creation time | -| `updated_at` | TIMESTAMPTZ | Last status update | - -Unique constraint: `(tmdb_id, media_type)`. -Indexes: `idx_media_requests_user(user_id)`, `idx_media_requests_status(status)`. - -### User Watchlist (migration 006) - -| Column | Type | Purpose | -|--------|------|---------| -| `id` | BIGSERIAL PK | Watchlist entry ID | -| `user_id` | BIGINT FK | Owning user (CASCADE delete) | -| `media_type` | TEXT NOT NULL | `'series'` or `'movie'` | -| `media_id` | BIGINT NOT NULL | ID of series or movie | -| `tmdb_id` | BIGINT NOT NULL | TMDB ID | -| `added_at` | TIMESTAMPTZ | When added to watchlist | - -Unique constraint: `(user_id, media_type, media_id)`. -Index: `idx_user_watchlist_user(user_id, added_at DESC)`. - -### User Ratings (migration 006) - -| Column | Type | Purpose | -|--------|------|---------| -| `id` | BIGSERIAL PK | Rating ID | -| `user_id` | BIGINT FK | Owning user (CASCADE delete) | -| `media_type` | TEXT NOT NULL | `'series'` or `'movie'` | -| `media_id` | BIGINT NOT NULL | ID of series or movie | -| `rating` | SMALLINT NOT NULL | Rating value (CHECK: 1-10) | -| `created_at` | TIMESTAMPTZ | When rated | -| `updated_at` | TIMESTAMPTZ | Last rating change | - -Unique constraint: `(user_id, media_type, media_id)`. -Indexes: `idx_user_ratings_user(user_id)`, `idx_user_ratings_media(media_type, media_id)`. - -### User Notifications (migration 006) - -| Column | Type | Purpose | -|--------|------|---------| -| `id` | BIGSERIAL PK | Notification ID | -| `user_id` | BIGINT FK | Target user (CASCADE delete) | -| `notification_type` | TEXT NOT NULL | Event type (e.g., `'media_available'`, `'request_approved'`) | -| `title` | TEXT NOT NULL | Notification title | -| `body` | TEXT | Notification body text | -| `data` | JSONB | Structured payload (media IDs, links, etc.) | -| `read` | BOOLEAN NOT NULL DEFAULT false | Read/unread state | -| `created_at` | TIMESTAMPTZ | When notification was created | - -Indexes: `idx_user_notifications_user(user_id, read, created_at DESC)`, `idx_user_notifications_created(created_at)`. - -### Push Subscriptions (migration 006) - -| Column | Type | Purpose | -|--------|------|---------| -| `id` | BIGSERIAL PK | Subscription ID | -| `user_id` | BIGINT FK | Owning user (CASCADE delete) | -| `endpoint` | TEXT NOT NULL UNIQUE | Web Push endpoint URL | -| `p256dh` | TEXT NOT NULL | ECDH public key | -| `auth` | TEXT NOT NULL | Auth secret | -| `user_agent` | TEXT | Client user agent | -| `created_at` | TIMESTAMPTZ | When subscription was registered | - -Index: `idx_push_subscriptions_user(user_id)`. - -### System Activities (migration 008) - -| Column | Type | Purpose | -|--------|------|---------| -| `id` | BIGSERIAL PK | Activity ID | -| `activity_type` | TEXT NOT NULL | Activity kind (e.g., `'disk_scan'`, `'import'`, `'transcode'`) | -| `status` | TEXT NOT NULL DEFAULT 'running' | `'running'`, `'completed'`, `'failed'` | -| `title` | TEXT NOT NULL | Human-readable activity title | -| `detail` | TEXT | Additional detail text | -| `progress` | JSONB | Structured progress data (percentComplete, currentItem, etc.) | -| `result` | JSONB | Structured result data on completion | -| `error` | TEXT | Error message on failure | -| `started_at` | TIMESTAMPTZ | Activity start time | -| `updated_at` | TIMESTAMPTZ | Last progress update time | -| `completed_at` | TIMESTAMPTZ | Completion time (NULL while running) | - -Indexes: `idx_system_activities_status(status, started_at DESC)`, `idx_system_activities_recent(started_at DESC)`. - -### Recycle Bin (migration 010) - -| Column | Type | Purpose | -|--------|------|---------| -| `id` | BIGSERIAL PK | Entry ID | -| `original_path` | TEXT NOT NULL | Original file path before recycling | -| `recycle_path` | TEXT NOT NULL | Path in recycle bin directory | -| `media_file_id` | BIGINT | Former media_files ID (NULL if record deleted) | -| `media_type` | TEXT NOT NULL | `'series'` or `'movie'` | -| `media_id` | BIGINT NOT NULL | ID of associated series or movie | -| `size` | BIGINT NOT NULL DEFAULT 0 | File size in bytes | -| `recycled_at` | TIMESTAMPTZ | When file was moved to recycle bin | - -Index: `idx_recycle_bin_recycled_at(recycled_at)`. - -### App Config Seeded Data (migration 010) - -Media management defaults added to `app_config`: - -| Key | Default Value | Purpose | -|-----|---------------|---------| -| `recycle_bin_path` | `""` (empty string) | Directory path for recycled files (empty = disabled) | -| `recycle_bin_cleanup_days` | `7` | Days before recycled files are permanently deleted | - -### Custom Format Fields (migration 014) - -Added to `custom_formats`: - -| Column | Type | Purpose | -|--------|------|---------| -| `include_custom_format_when_renaming` | BOOLEAN NOT NULL DEFAULT false | Whether to include this custom format's name in the renamed file path | - -Added to `quality_profiles`: - -| Column | Type | Purpose | -|--------|------|---------| -| `min_upgrade_format_score` | INTEGER NOT NULL DEFAULT 1 | Minimum custom format score improvement required for an upgrade to be considered | - -## Bootstrap SQLite Database - -The standalone `stackarr-bootstrap` binary uses a separate SQLite database (not PostgreSQL) for its own persistence. This is the only component that writes to SQLite. - -### server_names - -| Column | Type | Purpose | -|--------|------|---------| -| `name` | TEXT PK | Human-readable server name | -| `server_id` | TEXT | UUID of the registered StackArr server | -| `recovery_hash` | TEXT | Hash of the BIP39 12-word recovery phrase | -| `local_ip` | TEXT | Server's local/LAN IP | -| `public_ip` | TEXT | Server's public IP | -| `port` | INTEGER | Server's advertised port | -| `registered_at` | TEXT | ISO 8601 timestamp | - -### pending_claims - -| Column | Type | Purpose | -|--------|------|---------| -| `code` | TEXT PK | 8-character claim code | -| `server_id` | TEXT | UUID of the server that created the claim | -| `claim_type` | TEXT | Type of claim (e.g., `"invite"`) | -| `invite_code` | TEXT | The invite code for account registration (matches `code` for unified claims) | -| `local_ip` | TEXT | Server's local IP | -| `public_ip` | TEXT | Server's public IP | -| `port` | INTEGER | Server's port | -| `created_at` | TEXT | ISO 8601 timestamp | - ---- - -## Model Structs - -All models are defined in `crates/stackarr-core/src/models/user.rs` and derive `FromRow`, `Serialize`, `Deserialize`. - -### User - -```rust -pub struct User { - pub id: i64, - pub username: String, - pub display_name: String, - #[serde(skip_serializing)] - pub password_hash: String, - pub role: String, - pub avatar_url: Option, - pub enabled: bool, - pub created_at: DateTime, - pub updated_at: DateTime, -} -``` - -### UserSession - -```rust -pub struct UserSession { - pub id: Uuid, - pub user_id: i64, - pub token_hash: String, - pub user_agent: Option, - pub ip_address: Option, - pub created_at: DateTime, - pub expires_at: DateTime, - pub last_active: DateTime, -} -``` - -### UserDevice - -```rust -pub struct UserDevice { - pub id: i32, - pub user_id: i64, - pub device_token: Uuid, - pub device_name: Option, - pub device_type: Option, - pub created_at: DateTime, - pub last_seen: Option>, - pub revoked: bool, -} -``` - -### Invite - -```rust -pub struct Invite { - pub id: i32, - pub code: String, - pub created_by: i64, - pub claimed_by: Option, - pub role: String, - pub expires_at: Option>, - pub created_at: DateTime, -} -``` - -### WatchProgress - -```rust -pub struct WatchProgress { - pub id: i64, - pub user_id: i64, - pub media_file_id: i64, - pub media_type: String, - pub media_id: i64, - pub episode_id: Option, - pub position_secs: f32, - pub duration_secs: f32, - pub completed: bool, - pub updated_at: DateTime, -} -``` - -### MediaRequest - -```rust -pub struct MediaRequest { - pub id: i64, - pub user_id: i64, - pub media_type: String, - pub tmdb_id: i64, - pub title: String, - pub year: Option, - pub poster_url: Option, - pub overview: Option, - pub status: String, - pub admin_note: Option, - pub approved_by: Option, - pub created_at: DateTime, - pub updated_at: DateTime, -} -``` - -### UserWatchlistItem - -```rust -pub struct UserWatchlistItem { - pub id: i64, - pub user_id: i64, - pub media_type: String, - pub media_id: i64, - pub tmdb_id: i64, - pub added_at: DateTime, -} -``` - -### UserRating - -```rust -pub struct UserRating { - pub id: i64, - pub user_id: i64, - pub media_type: String, - pub media_id: i64, - pub rating: i16, - pub created_at: DateTime, - pub updated_at: DateTime, -} -``` - -### UserNotification - -```rust -pub struct UserNotification { - pub id: i64, - pub user_id: i64, - pub notification_type: String, - pub title: String, - pub body: Option, - pub data: Option, - pub read: bool, - pub created_at: DateTime, -} -``` - -### PushSubscription +Connection string format: `mysql://user:pass@host:port/dbname` +Default max connections: 20 (configurable in the `[database]` section). + +Bring a server up locally with: + +```bash +docker compose -f docker/docker-compose.dev.yml up -d +``` + +That publishes MariaDB on `127.0.0.1:3306` with user/password `stackarr`, which +is what `TEST_DATABASE_URL` defaults to. + +## Schema + +One file: `migrations/001_baseline.sql` — 60 tables, 907 lines. The 18 +incremental migrations that preceded it were collapsed into it and deleted. + +Do not add a `002_`. Until the first tagged release, schema changes are edits to +the baseline; after it, the migration chain restarts from the shipped schema. + +### Table groups + +| Group | Tables | +|---|---| +| Configuration | `app_config`, `enabled_modules`, `naming_config`, `media_library_folders`, `tags` | +| Generic media core | `media_entities`, `media_files` | +| TV adapter | `series`, `seasons`, `episodes`, `episode_files` | +| Film adapter | `movies`, `alternative_titles` | +| Quality and formats | `quality_profiles`, `custom_formats`, `custom_format_scores` | +| Indexers and downloads | `indexers`, `download_clients`, `queue`, `history`, `blocklist` | +| Import | `import_candidates`, `recycle_bin` | +| Integrations | `notification_providers`, `import_lists`, `plex_servers`, `plex_libraries`, `plex_events`, `discover_sliders` | +| Streaming | `streaming_sessions`, `watchlist`, `remote_clients` | +| Users | `users`, `user_sessions`, `user_devices`, `invites`, `watch_progress`, `media_requests`, `user_watchlist`, `user_ratings`, `user_notifications`, `push_subscriptions` | +| RSS | `rss_feeds`, `rss_items`, `rss_rules` | +| WebDAV | `dav_items`, `dav_blobs`, `dav_nzb_blobs`, `dav_queue_items`, `dav_history_items`, `dav_health_checks`, `dav_config` | +| System | `system_activities` | +| **P5 — profiles** | `profile_sources`, `profile_subscriptions`, `profile_snapshots`, `profile_overrides`, `custom_format_provenance` | +| **P6 — decisions** | `decision_records`, `decision_steps` | + +### Why the last two groups exist now + +The baseline was designed, not translated. Three structures were brought +forward because adding them later costs a migration chain and a backfill, and +adding them now costs nothing: + +- **`media_entities` / `media_files`** — the media-type-generic core from §5 of + `UNIFIED-ARR-PLAN.md`. `series` and `movies` are adapters keyed off a shared + identity (`UNIQUE (media_type, source_key)`), rather than two parallel + hierarchies. Music and books become rows with a new `media_type`, not forks. +- **P5 profile tables** — `profile_subscriptions` keeps `base_document` beside + the live profile so an upstream TRaSH update can be **three-way merged** + against local edits instead of clobbering them. `profile_snapshots` are + immutable; `profile_overrides` and `custom_format_provenance` record what was + changed locally and where it came from. +- **P6 decision tables** — `decision_records` stores the full input and outcome + of a grab decision, `decision_steps` the ordered per-specification breakdown. + This is what makes "why didn't it grab this?" answerable and replayable. + +None of these have code behind them yet. They are schema-only until P5 and P6. + +## MariaDB conventions + +These are the rules the baseline follows. Match them in any new table. + +| Concern | Rule | +|---|---| +| Engine | `ENGINE=InnoDB` on every table | +| Charset | `utf8mb4` / `utf8mb4_unicode_ci` | +| Timestamps | `DATETIME(6)`, UTC, `DEFAULT CURRENT_TIMESTAMP(6)` | +| Identity | `AUTO_INCREMENT`, `INT` for config-scale tables and `BIGINT` for row-growth tables | +| Structured columns | `JSON` — replaces both PostgreSQL `jsonb` and PostgreSQL arrays | +| Booleans | `BOOLEAN` (MariaDB stores as `TINYINT(1)`) | +| Placeholders | `?` — MySQL protocol has no `$n` | +| Indexes | declared inline as `KEY` / `UNIQUE KEY`, not as trailing `CREATE INDEX` | +| Long strings in keys | prefix length, e.g. `path(768)`, since InnoDB caps index entries at 3072 bytes | + +### Dialect differences that changed query code + +| PostgreSQL | MariaDB | +|---|---| +| `$1, $2, …` | `?, ?, …` | +| `INSERT … RETURNING id` | `INSERT …` then `LAST_INSERT_ID()` | +| `INSERT … ON CONFLICT (k) DO UPDATE` | `INSERT … ON DUPLICATE KEY UPDATE` | +| `INSERT … ON CONFLICT DO NOTHING` | `INSERT IGNORE` | +| `jsonb` | `JSON` | +| `TEXT[]` | `JSON` array | +| `PgPool` | `MySqlPool` | + +The workspace uses **no** `sqlx::query!` compile-time macros, so none of this is +checked against a live server at build time. The database-backed tests are the +only thing that verifies it. -```rust -pub struct PushSubscription { - pub id: i64, - pub user_id: i64, - pub endpoint: String, - pub p256dh: String, - pub auth: String, - pub user_agent: Option, - pub created_at: DateTime, -} -``` - -### SystemActivity - -```rust -pub struct SystemActivity { - pub id: i64, - pub activity_type: String, - pub status: String, - pub title: String, - pub detail: Option, - pub progress: Option, - pub result: Option, - pub error: Option, - pub started_at: DateTime, - pub updated_at: DateTime, - pub completed_at: Option>, -} -``` - -### RecycleBinEntry - -Defined in `crates/stackarr-import/src/recycle_bin.rs`: - -```rust -pub struct RecycleBinEntry { - pub id: i64, - pub original_path: String, - pub recycle_path: String, - pub media_file_id: Option, - pub media_type: String, - pub media_id: i64, - pub size: i64, - pub recycled_at: DateTime, -} -``` - ---- - -## Key Column Patterns - -### JSONB Columns - -Used for flexible/nested data that doesn't need individual column indexing: - -- `quality` -- `{"quality": {"id": 11, "name": "WEBDL-1080p"}, "revision": {"version": 1, "real": 0, "isRepack": false}}` -- `languages` -- `[{"id": 1, "name": "English"}]` -- `images` -- `[{"coverType": "poster", "url": "https://..."}]` -- `config` -- provider/client-specific configuration -- `items` -- quality profile items (ordered list of allowed qualities) -- `custom_data` -- discover slider configuration -- `data` -- user notification structured payload -- `progress` -- system activity progress data -- `result` -- system activity result data - -### Array Columns - -- `genres TEXT[]` -- genre strings -- `tags INT[]` -- tag IDs -- `categories INT[]` -- indexer category IDs - -### External IDs - -Series and movies store multiple external identifiers: - -| Column | Source | -|--------|--------| -| `tvdb_id` | TheTVDB | -| `tmdb_id` | TMDB | -| `imdb_id` | IMDb (string, e.g., "tt0903747") | -| `tvmaze_id` | TVMaze | -| `mal_id` | MyAnimeList | -| `plex_rating_key` | Plex | -| `plex_rating_key_4k` | Plex (4K library) | - -### Timestamps - -All timestamps are `TIMESTAMPTZ` (stored as UTC): -- `added_at` -- when the record was created (DEFAULT NOW()) -- `created_at` -- when the record was created (DEFAULT NOW()) -- `updated_at` -- when the record was last modified -- `last_info_sync` -- when metadata was last refreshed -- `last_search_time` -- when a search was last performed -- `occurred_at` -- when a history event happened -- `last_rss_sync` -- when an indexer RSS feed was last polled -- `expires_at` -- session/invite expiry time -- `last_active` -- last session activity -- `last_seen` -- last device API access -- `recycled_at` -- when a file was moved to recycle bin -- `started_at` / `completed_at` -- activity lifecycle timestamps - -## Seeded Data - -The initial migration seeds: - -**3 Quality Profiles:** -1. "Any" -- all 19 quality levels enabled -2. "HD-1080p" -- HDTV/WEBDL/WEBRip/Bluray 1080p -3. "Ultra-HD" -- 2160p variants + Remux - -**Naming Configs:** -- Series: standard, daily, anime formats + season folder -- Movie: standard format + folder format - -**8 Discover Sliders:** -- Trending, Popular Movies, Popular TV, Upcoming Movies, Upcoming TV, Recently Added, Movie Genres, TV Genres - -**Media Management Config (migration 010):** -- `recycle_bin_path` = `""` (disabled by default) -- `recycle_bin_cleanup_days` = `7` - -## Query Patterns - -### Direct SQL with sqlx - -```rust -// Typed query with FromRow -let series = sqlx::query_as::<_, Series>("SELECT * FROM series WHERE id = $1") - .bind(id) - .fetch_one(pool) - .await?; - -// Insert returning ID -let row = sqlx::query_scalar::<_, i64>( - "INSERT INTO series (title, clean_title, path, ...) VALUES ($1, $2, $3, ...) RETURNING id" -) - .bind(&input.title) - .bind(&clean) - .bind(&input.path) - .fetch_one(pool) - .await?; - -// Update -sqlx::query("UPDATE series SET title = $1, monitored = $2 WHERE id = $3") - .bind(&input.title) - .bind(input.monitored) - .bind(id) - .execute(pool) - .await?; - -// Delete -sqlx::query("DELETE FROM series WHERE id = $1") - .bind(id) - .execute(pool) - .await?; -``` - -### Pagination Pattern - -```rust -let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM history") - .fetch_one(pool).await?; - -let records = sqlx::query_as::<_, HistoryEvent>( - "SELECT * FROM history ORDER BY occurred_at DESC LIMIT $1 OFFSET $2" -) - .bind(page_size) - .bind((page - 1) * page_size) - .fetch_all(pool).await?; -``` +## Testing -### JSONB Queries +`stackarr-core`'s `TestDb` harness creates a randomly named database per test +(`stackarr_test_`), runs the baseline into it, and drops it afterwards. +The account in `TEST_DATABASE_URL` therefore needs server-wide `CREATE`/`DROP`, +not just rights on one schema — `docker/mariadb-init/01-test-grants.sql` grants +that for the dev stack, and CI uses the service container's root account. -```rust -// Insert JSONB -sqlx::query("INSERT INTO media_files (quality, languages, ...) VALUES ($1::jsonb, $2::jsonb, ...)") - .bind(serde_json::to_value(&quality)?) - .bind(serde_json::to_value(&languages)?) - .execute(pool).await?; -``` +Database-backed tests are marked `#[ignore]` so the suite still runs on a +machine with no server: -### User Authentication - -```rust -// Look up user by username for login -let user = sqlx::query_as::<_, User>( - "SELECT * FROM users WHERE username = $1 AND enabled = true" -) - .bind(&username) - .fetch_optional(pool) - .await?; - -// Create session after password verification -let session_id = sqlx::query_scalar::<_, Uuid>( - "INSERT INTO user_sessions (user_id, token_hash, user_agent, ip_address, expires_at) - VALUES ($1, $2, $3, $4::inet, $5) RETURNING id" -) - .bind(user.id) - .bind(&token_hash) - .bind(&user_agent) - .bind(&ip_address) - .bind(expires_at) - .fetch_one(pool) - .await?; - -// Validate session token (middleware) -let session = sqlx::query_as::<_, UserSession>( - "SELECT * FROM user_sessions WHERE token_hash = $1 AND expires_at > NOW()" -) - .bind(&token_hash) - .fetch_optional(pool) - .await?; +```bash +cargo test --workspace --all-features # 1,010 tests, no server needed +cargo test --workspace --all-features -- --ignored # 31 tests, needs MariaDB ``` -### Watch Progress +CI runs both; the second is the only gate that proves the swap against a real +server. -```rust -// Upsert playback position (ON CONFLICT on unique(user_id, media_file_id)) -sqlx::query( - "INSERT INTO watch_progress (user_id, media_file_id, media_type, media_id, episode_id, position_secs, duration_secs, completed) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT (user_id, media_file_id) DO UPDATE SET - position_secs = EXCLUDED.position_secs, - duration_secs = EXCLUDED.duration_secs, - completed = EXCLUDED.completed, - updated_at = NOW()" -) - .bind(user_id) - .bind(media_file_id) - .bind(&media_type) - .bind(media_id) - .bind(episode_id) - .bind(position_secs) - .bind(duration_secs) - .bind(completed) - .execute(pool) - .await?; - -// Continue watching: incomplete items ordered by recent activity -let items = sqlx::query_as::<_, WatchProgress>( - "SELECT * FROM watch_progress - WHERE user_id = $1 AND completed = false - ORDER BY updated_at DESC LIMIT $2" -) - .bind(user_id) - .bind(limit) - .fetch_all(pool) - .await?; -``` - -## Adding a New Migration +## Index parity with the pre-MariaDB schema -1. Create `migrations/NNN_description.sql` (e.g., `011_add_feature.sql`) -2. Write SQL -- sqlx runs migrations in filename order -3. Add corresponding model structs to `stackarr-core/src/models.rs` -4. Derive `FromRow`, `Serialize`, `Deserialize` on new structs -5. Migrations run automatically on startup via `Database::run_migrations()` +The 18 old migrations declared 60 named indexes; the baseline declares 55 by +name. The eight names that disappeared are all still covered, so the difference +is naming and idiom rather than lost coverage: -## Testing +| Old index | Covered in the baseline by | +|---|---| +| `idx_invites_code` | `code … UNIQUE` | +| `idx_remote_clients_token` | `client_token … UNIQUE` | +| `idx_user_devices_token` | `device_token … UNIQUE` | +| `idx_user_devices_user` | FK `fk_user_devices_user` (InnoDB indexes every FK) | +| `idx_push_subscriptions_user` | FK `fk_push_subscriptions_user` | +| `idx_user_ratings_user` | leftmost prefix of `uq_user_rating (user_id, …)` | +| `idx_episode_files_episode` | leftmost prefix of `PRIMARY KEY (episode_id, media_file_id)` | +| `idx_import_candidates_pending_path` | renamed to `uq_import_candidates_pending_path` | -Integration tests use `stackarr_core::test_helpers::TestDb` (behind `testing` feature flag): -- Creates a temporary database -- Runs all migrations -- Provides a `PgPool` for tests -- Drops the database on `Drop` +## Seeded data -```rust -#[tokio::test] -#[ignore] // Requires running Postgres -async fn test_something() { - let db = TestDb::new("postgresql://stackarr:stackarr@localhost:5433/stackarr").await; - let pool = db.pool(); - // ... test with pool -} -``` +The baseline seeds the rows the application assumes exist on first boot: default +quality profiles and definitions, `naming_config` rows for TV and film, the +built-in `discover_sliders`, recycle-bin config keys, and the three root +`dav_items`. First boot is detected by `is_first_boot()`, not by a seed marker. diff --git a/docs/UNIFIED-ARR-PLAN.md b/docs/UNIFIED-ARR-PLAN.md index 54095e63..b415968a 100644 --- a/docs/UNIFIED-ARR-PLAN.md +++ b/docs/UNIFIED-ARR-PLAN.md @@ -328,7 +328,7 @@ So P1 does not merely translate the schema — it designs the *target* schema on | `RETURNING` clauses | **78** | **The single largest cost.** MySQL has no `RETURNING`. | | `ON CONFLICT` | **77** | → `ON DUPLICATE KEY UPDATE` / `INSERT IGNORE` | | `jsonb` references in Rust | **56** | → `JSON` | -| `PgPool` / `Postgres` type refs | **173** | Mechanical → `MySqlPool`. Much of this is concentrated in the dedicated `stackarr-postgres` crate (1,216 LOC), which is a real advantage — it becomes `stackarr-mariadb`. | +| `PgPool` / `Postgres` type refs | **153** | Mechanical → `MySqlPool`. **Correction (2026-08-02):** the count was 173 before `crates/torrent/` was deleted, and the claim that these are concentrated in `stackarr-postgres` was wrong — that crate contained **zero** `PgPool` and `sqlx::` references. It was an embedded-Postgres *server provisioner*, not a query layer. The refs are spread across ten crates: scheduler 35, import 29, core 27, web 21, media 16, plex 11, quality 5, migrate 4, stream 3, notify 1. See the note under §7 P1. | | `BIGSERIAL` / `SERIAL` in schema | 20 / 16 | → `BIGINT AUTO_INCREMENT` / `INT AUTO_INCREMENT` | | `JSONB` in schema | 27 | → `JSON` (MySQL 8 stores JSON binary; no `jsonb` keyword) | | `gen_random_uuid()` | 2 | → `UUID()` (MySQL 8) or generate app-side (preferred — portable) | @@ -662,7 +662,7 @@ No new features. Shrink and correct the foundation. | ~~Delete `crates/usenet/`~~ | **Already done.** All seven `nzb-*` crates pinned from crates.io (§2.4). No action. | — | | **Delete all migrations** | 18 files → one `001_baseline.sql`. Fresh deploy, no upgrade path to preserve. | — | | **Design the target schema** | Not a translation. Bake in the media-type-generic model (§5), P5 profile-provenance tables, P6 decision records — while it is still free. | — | -| **Postgres → MariaDB** | 1,420 placeholders, 78 `RETURNING`, 77 `ON CONFLICT`, 173 `PgPool` refs, 56 `jsonb`. Zero `query!` macros. `stackarr-postgres` becomes `stackarr-mariadb`. See §4.2. | ~1,800 touched | +| **Postgres → MariaDB** | 1,420 placeholders, 78 `RETURNING`, 77 `ON CONFLICT`, 153 `PgPool` refs, 56 `jsonb`. Zero `query!` macros. `stackarr-postgres` becomes `stackarr-mariadb` — but this is a **rewrite, not a rename**: that crate is 1,216 LOC of embedded-Postgres provisioning (download binaries, `initdb`, supervise a child process) with no query code in it, and MariaDB has no drop-in equivalent. Whether StackArr still ships a self-provisioning database is an open product decision. See §4.2. | ~1,800 touched | | Relicense | MIT → GPL-3.0 across workspace + headers | — | | Correct `CLAUDE.md` | Three false claims (§2.5) + the database change | — | | Toolchain | Add `rust-toolchain.toml`; unpin CI from 1.88 | — | diff --git a/stackarr.toml b/stackarr.toml index 56cde6aa..bc12f669 100644 --- a/stackarr.toml +++ b/stackarr.toml @@ -6,7 +6,7 @@ data_dir = "/config" log_level = "info" [database] -url = "postgresql://stackarr:stackarr@localhost:5432/stackarr" +url = "mysql://stackarr:stackarr@localhost:3306/stackarr" max_connections = 20 [auth] diff --git a/tests/e2e/config-existing.toml b/tests/e2e/config-existing.toml index 0a25dc66..06fe576e 100644 --- a/tests/e2e/config-existing.toml +++ b/tests/e2e/config-existing.toml @@ -9,7 +9,7 @@ data_dir = "/config" log_level = "debug" [database] -url = "postgresql://stackarr:stackarr@postgres:5432/stackarr" +url = "mysql://stackarr:stackarr@mariadb:3306/stackarr" max_connections = 10 [auth] diff --git a/tests/e2e/config-fresh.toml b/tests/e2e/config-fresh.toml index d25e59e1..4f83efa9 100644 --- a/tests/e2e/config-fresh.toml +++ b/tests/e2e/config-fresh.toml @@ -9,7 +9,7 @@ data_dir = "/config" log_level = "debug" [database] -url = "postgresql://stackarr:stackarr@postgres:5432/stackarr" +url = "mysql://stackarr:stackarr@mariadb:3306/stackarr" max_connections = 10 [auth] diff --git a/tests/e2e/config-import.toml b/tests/e2e/config-import.toml index 8ff1cac5..dea4ba1c 100644 --- a/tests/e2e/config-import.toml +++ b/tests/e2e/config-import.toml @@ -8,7 +8,7 @@ data_dir = "/config" log_level = "debug" [database] -url = "postgresql://stackarr:stackarr@postgres:5432/stackarr" +url = "mysql://stackarr:stackarr@mariadb:3306/stackarr" max_connections = 10 [auth] diff --git a/tests/e2e/config-ngms-test.toml b/tests/e2e/config-ngms-test.toml index 0b5c3c70..860b1935 100644 --- a/tests/e2e/config-ngms-test.toml +++ b/tests/e2e/config-ngms-test.toml @@ -9,7 +9,7 @@ data_dir = "/config" log_level = "debug" [database] -url = "postgresql://stackarr:stackarr@postgres:5432/stackarr" +url = "mysql://stackarr:stackarr@mariadb:3306/stackarr" max_connections = 10 [auth] diff --git a/tests/e2e/config-quality-parity.toml b/tests/e2e/config-quality-parity.toml index 84be6ca8..619e382e 100644 --- a/tests/e2e/config-quality-parity.toml +++ b/tests/e2e/config-quality-parity.toml @@ -8,7 +8,7 @@ data_dir = "/config" log_level = "debug" [database] -url = "postgresql://stackarr:stackarr@postgres:5432/stackarr" +url = "mysql://stackarr:stackarr@mariadb:3306/stackarr" max_connections = 10 [auth] diff --git a/tests/e2e/docker-compose.existing.yml b/tests/e2e/docker-compose.existing.yml index 664fb5d1..23b3defd 100644 --- a/tests/e2e/docker-compose.existing.yml +++ b/tests/e2e/docker-compose.existing.yml @@ -1,5 +1,5 @@ # Stack 2 — Existing/Upgrade Test -# Ports: stackarr=9212, postgres=5435, indexarr=8280 +# Ports: stackarr=9212, mariadb=3308, indexarr=8280 # Data volumes persist between runs (NOT nuked on restart). services: @@ -31,7 +31,7 @@ services: - /mnt/data2/TV1:/media/TV1:ro - /mnt/data1/movies2:/media/Movies2:ro depends_on: - postgres: + mariadb: condition: service_healthy healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:9111/health"] @@ -41,20 +41,21 @@ services: start_period: 20s restart: unless-stopped - postgres: - image: postgres:17-alpine + mariadb: + image: mariadb:11.4 container_name: stackarr-test-existing-pg ports: - - "5435:5432" + - "3308:3306" volumes: - - existing-pgdata:/var/lib/postgresql/data + - existing-mariadbdata:/var/lib/mysql environment: - - POSTGRES_USER=stackarr - - POSTGRES_PASSWORD=stackarr - - POSTGRES_DB=stackarr + - MARIADB_ROOT_PASSWORD=stackarr + - MARIADB_USER=stackarr + - MARIADB_PASSWORD=stackarr + - MARIADB_DATABASE=stackarr - TZ=Australia/Sydney healthcheck: - test: ["CMD-SHELL", "pg_isready -U stackarr"] + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 5s timeout: 5s retries: 5 @@ -75,7 +76,7 @@ services: volumes: existing-stackarr-config: - existing-pgdata: + existing-mariadbdata: existing-dl-torrent-incomplete: existing-dl-torrent-complete: existing-dl-usenet-incomplete: diff --git a/tests/e2e/docker-compose.fresh.yml b/tests/e2e/docker-compose.fresh.yml index 78737035..aaf7de78 100644 --- a/tests/e2e/docker-compose.fresh.yml +++ b/tests/e2e/docker-compose.fresh.yml @@ -1,5 +1,5 @@ # Stack 1 — Fresh Install Test -# Ports: stackarr=9211, postgres=5434, indexarr=8180 +# Ports: stackarr=9211, mariadb=3309, indexarr=8180 # All data volumes are ephemeral (nuked each run). services: @@ -31,7 +31,7 @@ services: - /mnt/data2/TV1:/media/TV1:ro - /mnt/data1/movies2:/media/Movies2:ro depends_on: - postgres: + mariadb: condition: service_healthy healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:9111/health"] @@ -41,20 +41,21 @@ services: start_period: 20s restart: unless-stopped - postgres: - image: postgres:17-alpine + mariadb: + image: mariadb:11.4 container_name: stackarr-test-fresh-pg ports: - - "5434:5432" + - "3309:3306" volumes: - - fresh-pgdata:/var/lib/postgresql/data + - fresh-mariadbdata:/var/lib/mysql environment: - - POSTGRES_USER=stackarr - - POSTGRES_PASSWORD=stackarr - - POSTGRES_DB=stackarr + - MARIADB_ROOT_PASSWORD=stackarr + - MARIADB_USER=stackarr + - MARIADB_PASSWORD=stackarr + - MARIADB_DATABASE=stackarr - TZ=Australia/Sydney healthcheck: - test: ["CMD-SHELL", "pg_isready -U stackarr"] + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 5s timeout: 5s retries: 5 @@ -75,7 +76,7 @@ services: volumes: fresh-stackarr-config: - fresh-pgdata: + fresh-mariadbdata: fresh-dl-torrent-incomplete: fresh-dl-torrent-complete: fresh-dl-usenet-incomplete: diff --git a/tests/e2e/docker-compose.import.yml b/tests/e2e/docker-compose.import.yml index 1c1064d1..92e24d7b 100644 --- a/tests/e2e/docker-compose.import.yml +++ b/tests/e2e/docker-compose.import.yml @@ -1,5 +1,5 @@ # Stack 3 — Import Test -# Ports: stackarr=9213, postgres=5436 +# Ports: stackarr=9213, mariadb=3310 # Tests Sonarr/Radarr/Prowlarr DB import + SABnzbd config import. # All data volumes are ephemeral (nuked each run). @@ -32,7 +32,7 @@ services: - /mnt/data2/TV1:/media/TV1:ro - /mnt/data1/movies2:/media/Movies2:ro depends_on: - postgres: + mariadb: condition: service_healthy healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:9111/health"] @@ -42,20 +42,21 @@ services: start_period: 20s restart: unless-stopped - postgres: - image: postgres:17-alpine + mariadb: + image: mariadb:11.4 container_name: stackarr-test-import-pg ports: - - "5436:5432" + - "3310:3306" volumes: - - import-pgdata:/var/lib/postgresql/data + - import-mariadbdata:/var/lib/mysql environment: - - POSTGRES_USER=stackarr - - POSTGRES_PASSWORD=stackarr - - POSTGRES_DB=stackarr + - MARIADB_ROOT_PASSWORD=stackarr + - MARIADB_USER=stackarr + - MARIADB_PASSWORD=stackarr + - MARIADB_DATABASE=stackarr - TZ=Australia/Sydney healthcheck: - test: ["CMD-SHELL", "pg_isready -U stackarr"] + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 5s timeout: 5s retries: 5 @@ -63,7 +64,7 @@ services: volumes: import-stackarr-config: - import-pgdata: + import-mariadbdata: import-dl-torrent-incomplete: import-dl-torrent-complete: import-dl-usenet-incomplete: diff --git a/tests/e2e/docker-compose.ngms-test.yml b/tests/e2e/docker-compose.ngms-test.yml index 2d658083..c867fedd 100644 --- a/tests/e2e/docker-compose.ngms-test.yml +++ b/tests/e2e/docker-compose.ngms-test.yml @@ -1,6 +1,6 @@ # NGMS GUI Test Environment # Deployed to Node B at /mnt/2tnvme/docker/volumes/ngms_test -# Ports: stackarr=9311, postgres=5435, indexarr=8182 +# Ports: stackarr=9311, mariadb=3312, indexarr=8182 # Persistent — not nuked between runs (unlike fresh/import stacks) services: @@ -25,7 +25,7 @@ services: - /mnt/data2/TV1:/media/TV1:ro - /mnt/data1/movies2:/media/Movies2:ro depends_on: - postgres: + mariadb: condition: service_healthy healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:9111/health"] @@ -35,20 +35,21 @@ services: start_period: 20s restart: unless-stopped - postgres: - image: postgres:17-alpine + mariadb: + image: mariadb:11.4 container_name: ngms-test-pg ports: - "5435:5432" volumes: - - ./pgdata:/var/lib/postgresql/data + - ./mariadbdata:/var/lib/mysql environment: - - POSTGRES_USER=stackarr - - POSTGRES_PASSWORD=stackarr - - POSTGRES_DB=stackarr + - MARIADB_ROOT_PASSWORD=stackarr + - MARIADB_USER=stackarr + - MARIADB_PASSWORD=stackarr + - MARIADB_DATABASE=stackarr - TZ=Australia/Sydney healthcheck: - test: ["CMD-SHELL", "pg_isready -U stackarr"] + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 5s timeout: 5s retries: 5 diff --git a/tests/e2e/docker-compose.quality-parity.yml b/tests/e2e/docker-compose.quality-parity.yml index 0708dac6..62791e7c 100644 --- a/tests/e2e/docker-compose.quality-parity.yml +++ b/tests/e2e/docker-compose.quality-parity.yml @@ -1,5 +1,5 @@ # Stack 4 — Quality Parity Test -# Ports: stackarr=9214, postgres=5437 +# Ports: stackarr=9214, mariadb=3311 # Compares release scoring between StackArr and live Sonarr/Radarr. # All data volumes are ephemeral (nuked each run). @@ -29,24 +29,25 @@ services: - ${MEDIA_BASE:-/tmp/stackarr-quality-test}/TV:/media/TV - ${MEDIA_BASE:-/tmp/stackarr-quality-test}/Movies:/media/Movies depends_on: - postgres: + mariadb: condition: service_healthy restart: unless-stopped - postgres: - image: postgres:17-alpine + mariadb: + image: mariadb:11.4 container_name: stackarr-test-quality-pg ports: - "5437:5432" volumes: - - quality-pgdata:/var/lib/postgresql/data + - quality-mariadbdata:/var/lib/mysql environment: - - POSTGRES_USER=stackarr - - POSTGRES_PASSWORD=stackarr - - POSTGRES_DB=stackarr + - MARIADB_ROOT_PASSWORD=stackarr + - MARIADB_USER=stackarr + - MARIADB_PASSWORD=stackarr + - MARIADB_DATABASE=stackarr - TZ=Australia/Sydney healthcheck: - test: ["CMD-SHELL", "pg_isready -U stackarr"] + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 5s timeout: 5s retries: 5 @@ -54,7 +55,7 @@ services: volumes: quality-stackarr-config: - quality-pgdata: + quality-mariadbdata: quality-dl-torrent-incomplete: quality-dl-torrent-complete: quality-dl-usenet-incomplete: From 44887922d9b8dd47c405bd12140eb1500263d785 Mon Sep 17 00:00:00 2001 From: sprooty Date: Mon, 3 Aug 2026 03:04:32 +0000 Subject: [PATCH 4/9] =?UTF-8?q?ci:=20T19=20partial=20=E2=80=94=20conforman?= =?UTF-8?q?ce=20gate=20and=20multi-arch=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commits the working-tree change that was sitting unstaged, plus the handoff issue register. NOT complete T19. Still missing, and raised as #105: coverage ratcheting (the referenced coverage-watchdog is a monitoring web application, not a Rust coverage-ratchet command, so a tool decision and a recorded baseline are needed first), and musl, which is not representable as a Docker platform. Committed rather than left in the working tree because that is how work gets destroyed -- this checkout already lost most of a day's rescue to being uncommitted once. Deliberately NOT pushed: the register makes pushing conditional on T20 (#58) acceptance. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 25 +++++-- docs/2026-08-03-handoff-issues.md | 110 ++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 6 deletions(-) create mode 100644 docs/2026-08-03-handoff-issues.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15dd9845..6503f8ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ permissions: jobs: lint: - runs-on: ubuntu-latest + runs-on: [self-hosted, node-b, linux, x64] steps: - uses: actions/checkout@v7 - uses: Swatinem/rust-cache@v2 @@ -36,7 +36,7 @@ jobs: - run: cargo clippy --workspace --all-features --locked -- -D warnings test: - runs-on: ubuntu-latest + runs-on: [self-hosted, node-b, linux, x64] services: mariadb: image: mariadb:11.4 @@ -75,7 +75,7 @@ jobs: run: cargo test --workspace --all-features --locked -- --ignored build: - runs-on: ubuntu-latest + runs-on: [self-hosted, node-b, linux, x64] steps: - uses: actions/checkout@v7 - uses: Swatinem/rust-cache@v2 @@ -91,7 +91,7 @@ jobs: - run: cargo build --workspace --all-features --locked ui: - runs-on: ubuntu-latest + runs-on: [self-hosted, node-b, linux, x64] strategy: matrix: project: [ui, client] @@ -108,7 +108,7 @@ jobs: run: npm run build ui-e2e: - runs-on: ubuntu-latest + runs-on: [self-hosted, node-b, linux, x64] steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 @@ -123,9 +123,20 @@ jobs: - working-directory: ui run: npm run test:e2e + conformance: + runs-on: [self-hosted, node-b, linux, x64] + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.95.0" + - uses: Swatinem/rust-cache@v2 + - name: Run compatibility/conformance gate + run: just conformance + container: if: github.event_name == 'push' - needs: [lint, test, build, ui, ui-e2e] + needs: [lint, test, build, ui, ui-e2e, conformance] runs-on: [self-hosted, node-b, linux, x64, publish, docker] permissions: contents: read @@ -133,6 +144,7 @@ jobs: steps: - uses: actions/checkout@v7 - uses: docker/setup-buildx-action@v4 + - uses: docker/setup-qemu-action@v4 - uses: docker/login-action@v4 if: github.event_name == 'push' with: @@ -144,6 +156,7 @@ jobs: context: . file: docker/Dockerfile push: ${{ github.event_name == 'push' }} + platforms: linux/amd64,linux/arm64 tags: | ghcr.io/thedancingdeveloper-org/ngms:latest ghcr.io/thedancingdeveloper-org/ngms:sha-${{ github.sha }} diff --git a/docs/2026-08-03-handoff-issues.md b/docs/2026-08-03-handoff-issues.md new file mode 100644 index 00000000..707233bd --- /dev/null +++ b/docs/2026-08-03-handoff-issues.md @@ -0,0 +1,110 @@ +# NGMS handoff and AI harness issue register + +**Date:** 2026-08-03 +**Status:** AI execution paused for review. +**Scope:** `docs/2026-08-02-ngms-unified-arr-handover.md`, the `feat/mariadb-baseline` +checkout, and the connected AIDevEnv/agent-harness runtime. + +This is an evidence register, not a claim that the items below are resolved. +No credentials or token values are included. + +## NGMS handoff issues + +### T20 schema approval is still a gate — blocking + +Issue T20 (#58) remains open. The MariaDB baseline and the dependent T21–T26 +work are present in the rescue branch, but the handoff explicitly requires +human acceptance of the target schema. The queue is therefore paused rather +than treating the generated baseline as approved. + +### The rescue branch bundles unrelated handoff items — high + +The local branch contains three rescue commits spanning multiple task IDs +(including T21–T25 and the runtime/CI/docs layers). This makes review, rollback, +and per-issue acceptance ambiguous. The changes should be split into reviewable +commits or PRs after T20 is accepted. + +### T22 removed the embedded database provisioner without a replacement decision — high + +The former `stackarr-postgres` crate was an embedded PostgreSQL provisioner, +not merely a query layer. It was reduced to a `stackarr-mariadb` stub while +`docker/Dockerfile.standalone` still references the removed `managed-postgres` +feature. This silently changes the self-provisioning product behavior. D8 +(#29) must decide how MariaDB is delivered before standalone packaging is +considered complete. + +### T19 CI requirements are incomplete — high + +The handoff requested conformance, a coverage ratchet, MariaDB-backed tests, +and multi-architecture output. The branch originally supplied only the MariaDB +service and ignored-test invocation. The current working-tree edit adds a +conformance gate and `linux/amd64,linux/arm64` Docker build, but coverage +ratcheting is still absent and musl is not represented by a Docker platform. +The referenced `coverage-watchdog` is a monitoring web application, not an +existing Rust coverage-ratchet command. A separate coverage-tool decision and +baseline are required. + +The organization runner policy requires explicit self-hosted labels; all NGMS +jobs in the current working tree now use those labels. The handoff text that +asks for `ubuntu-latest` is superseded by that workspace policy. + +### Database tests are not locally verified against MariaDB — medium + +The Rust workspace gates passed locally, but the 31 ignored database tests were +not run against a live MariaDB because this environment has no Docker daemon or +MariaDB service. CI must provide and successfully exercise the service before +the database migration can be called verified. + +## Harness and AIDevEnv issues + +### Project start can report running with zero workers — high + +In `agent-harness/src/agent_harness/api.py`, `POST /api/projects/{id}/start` +sets queue control to `RUNNING` when `app.state.fleet` is `None`. The returned +summary then reports zero workers. This can look like successful execution +while no worker can claim work. Start should fail clearly, or require a live +fleet, instead of creating a false-running state. A regression test is needed. + +### Harness preflight does not prove an executable configuration — high + +The NGMS queue contained 49 pending items but no usable model/reviewer route or +GitHub write credential in this runtime. Project registration/start does not +preflight the executor, reviewer, helper, repository write access, or required +checks. A queue can therefore be resumed into a nonproductive or misleading +state. Add an explicit preflight/readiness gate before admission or worker +claiming. + +### Feature AIDevEnv has no credential broker mounted — high + +The running `aidevenv-feat` stack is configured with +`AIDEVENV_AGENT_AUTH_REQUIRED=0` and `AIDEVENV_AUTO_AGENT_AUTH=0`; no +`aidevenv-agent-auth` executable or Infisical machine identity is present in +the container. The shared MyDevEnv2 helper source exists, but its required +runtime identity and `infisical` CLI are not available here. GitHub issue/PR +writes consequently cannot be authenticated from this session. + +### Broker status is not a readiness check — medium + +The AIDevEnv status surface reports the configured helper name and whether +auto-auth is enabled, but not whether the helper executable exists, whether +Infisical identity variables are present, or whether `check` succeeds. It can +therefore advertise a configured broker while the session is credential-free. + +### Harness test dependency mismatch — medium + +The agent-harness project declares `httpx>=0.27` for development tests. In this +runtime, the installed Starlette TestClient requires the separately named +`httpx2` package, so `tests/test_projects.py` fails during fixture setup before +endpoint behavior is tested. The supported Python/dependency matrix should be +pinned and tested in CI. + +## Current execution state + +- The AIDevEnv queue is **paused** with 49 pending, 0 running, 0 done, and 0 + failed items. +- The NGMS checkout has an uncommitted `.github/workflows/ci.yml` change for + the partial T19 work; it has not been pushed. +- No secret values were printed, committed, or written to this document. +- Resume only after T20 approval and a verified helper-backed GitHub write + path are available. + From 41a8d46aecef3afe5a2bb7c68c9d67ea2a652450 Mon Sep 17 00:00:00 2001 From: sprooty Date: Mon, 3 Aug 2026 04:21:38 +0000 Subject: [PATCH 5/9] docs: link tracked harness defect and auth status --- docs/2026-08-03-handoff-issues.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/2026-08-03-handoff-issues.md b/docs/2026-08-03-handoff-issues.md index 707233bd..2156c294 100644 --- a/docs/2026-08-03-handoff-issues.md +++ b/docs/2026-08-03-handoff-issues.md @@ -65,6 +65,8 @@ summary then reports zero workers. This can look like successful execution while no worker can claim work. Start should fail clearly, or require a live fleet, instead of creating a false-running state. A regression test is needed. +**Tracked:** [agent-harness #85](https://github.com/TheDancingDeveloper-org/agent-harness/issues/85). + ### Harness preflight does not prove an executable configuration — high The NGMS queue contained 49 pending items but no usable model/reviewer route or @@ -83,6 +85,12 @@ the container. The shared MyDevEnv2 helper source exists, but its required runtime identity and `infisical` CLI are not available here. GitHub issue/PR writes consequently cannot be authenticated from this session. +**Status update:** resolved in the running environment on 2026-08-03. The +broker is now installed with a machine identity and its `check` command proves +Infisical authentication plus destination GitHub-admin access. This item remains +here as the reason the earlier execution attempt was paused; it is not an open +defect to file. + ### Broker status is not a readiness check — medium The AIDevEnv status surface reports the configured helper name and whether @@ -90,6 +98,10 @@ auto-auth is enabled, but not whether the helper executable exists, whether Infisical identity variables are present, or whether `check` succeeds. It can therefore advertise a configured broker while the session is credential-free. +**Status update:** already fixed in the current `aidevenv` `feat` branch. Its +status model now distinguishes helper presence, identity presence, configured, +verified, ready, and a safe failure reason. No duplicate issue was filed. + ### Harness test dependency mismatch — medium The agent-harness project declares `httpx>=0.27` for development tests. In this @@ -104,7 +116,10 @@ pinned and tested in CI. failed items. - The NGMS checkout has an uncommitted `.github/workflows/ci.yml` change for the partial T19 work; it has not been pushed. +- `just` is not installed in this AIDevEnv container, so the new CI + `just conformance` command could not be run locally. The current placeholder + recipe is inspectable, but CI or a provisioned development image must execute + it before it is claimed as validated. - No secret values were printed, committed, or written to this document. - Resume only after T20 approval and a verified helper-backed GitHub write path are available. - From 18b07ea46b42dad23cf1f3e93caaeba35710b451 Mon Sep 17 00:00:00 2001 From: sprooty Date: Mon, 3 Aug 2026 04:51:06 +0000 Subject: [PATCH 6/9] docs: record harness execution findings --- docs/2026-08-03-handoff-issues.md | 42 +++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/docs/2026-08-03-handoff-issues.md b/docs/2026-08-03-handoff-issues.md index 2156c294..96123d13 100644 --- a/docs/2026-08-03-handoff-issues.md +++ b/docs/2026-08-03-handoff-issues.md @@ -1,7 +1,7 @@ # NGMS handoff and AI harness issue register **Date:** 2026-08-03 -**Status:** AI execution paused for review. +**Status:** AI execution paused after worker-lifecycle smoke test. **Scope:** `docs/2026-08-02-ngms-unified-arr-handover.md`, the `feat/mariadb-baseline` checkout, and the connected AIDevEnv/agent-harness runtime. @@ -110,16 +110,44 @@ runtime, the installed Starlette TestClient requires the separately named endpoint behavior is tested. The supported Python/dependency matrix should be pinned and tested in CI. +### Worker exit leaves a live session and claim — critical + +The first real Fleet smoke test claimed T19, then the worker process exited +while the AIDevEnv session remained live and the queue row stayed claimed. The +row was re-queued through the queue API and the project stopped to prevent +repeated unattended attempts. + +**Tracked:** [agent-harness #87](https://github.com/TheDancingDeveloper-org/agent-harness/issues/87). + +### Work-list item identifiers serialize as null — high + +`GET /api/work?project_id=default` returned `id: null` for every row even +though the same rows can be addressed by their canonical IDs through +`GET /api/work/{item_id}`. + +**Tracked:** [agent-harness #88](https://github.com/TheDancingDeveloper-org/agent-harness/issues/88). + +### No supported operator transition for human-decision rows — high + +The queue has a `blocked` state, but the API exposes no authenticated operator +action to mark a decision item blocked with a reason. D8 and D9 were marked +blocked through the queue abstraction as data cleanup so an implementation +worker cannot answer them. + +**Tracked:** [agent-harness #89](https://github.com/TheDancingDeveloper-org/agent-harness/issues/89). + ## Current execution state -- The AIDevEnv queue is **paused** with 49 pending, 0 running, 0 done, and 0 - failed items. -- The NGMS checkout has an uncommitted `.github/workflows/ci.yml` change for - the partial T19 work; it has not been pushed. +- The AIDevEnv queue is **stopped** with 40 pending, 9 blocked, 0 running, 0 + done, and 0 failed items. D8/D9 and the seven epic rows are blocked with + explicit cleanup reasons; T19 was re-queued after the worker exit. +- The NGMS checkout is clean on `feat/mariadb-baseline`; the partial T19 + changes are committed and pushed. - `just` is not installed in this AIDevEnv container, so the new CI `just conformance` command could not be run locally. The current placeholder recipe is inspectable, but CI or a provisioned development image must execute it before it is claimed as validated. - No secret values were printed, committed, or written to this document. -- Resume only after T20 approval and a verified helper-backed GitHub write - path are available. +- Resume only after the worker-lifecycle defect is fixed upstream (or a + verified replacement executor is deployed), and after T20 approval before + schema-dependent work runs. From 604030cfc1ee1f30fa9d052f857fb184b5bcbe72 Mon Sep 17 00:00:00 2001 From: sprooty Date: Mon, 3 Aug 2026 05:00:47 +0000 Subject: [PATCH 7/9] docs: record second harness smoke test --- docs/2026-08-03-handoff-issues.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/2026-08-03-handoff-issues.md b/docs/2026-08-03-handoff-issues.md index 96123d13..cc0f437d 100644 --- a/docs/2026-08-03-handoff-issues.md +++ b/docs/2026-08-03-handoff-issues.md @@ -112,10 +112,11 @@ pinned and tested in CI. ### Worker exit leaves a live session and claim — critical -The first real Fleet smoke test claimed T19, then the worker process exited -while the AIDevEnv session remained live and the queue row stayed claimed. The -row was re-queued through the queue API and the project stopped to prevent -repeated unattended attempts. +Fleet smoke tests claimed T19 and later T20, then the temporary worker/session +process was stopped while the queue row remained claimed. Both rows required +administrative re-queueing; the project was stopped to prevent repeated +unattended attempts. The T20 run also confirms that the queue has no policy +for recognizing a human-gate item before claiming it. **Tracked:** [agent-harness #87](https://github.com/TheDancingDeveloper-org/agent-harness/issues/87). @@ -140,7 +141,8 @@ worker cannot answer them. - The AIDevEnv queue is **stopped** with 40 pending, 9 blocked, 0 running, 0 done, and 0 failed items. D8/D9 and the seven epic rows are blocked with - explicit cleanup reasons; T19 was re-queued after the worker exit. + explicit cleanup reasons; T19 and T20 were re-queued after smoke-test + worker/session termination. - The NGMS checkout is clean on `feat/mariadb-baseline`; the partial T19 changes are committed and pushed. - `just` is not installed in this AIDevEnv container, so the new CI From 69fa3c973f59636f1cf0237173bca17a55c83e99 Mon Sep 17 00:00:00 2001 From: sprooty Date: Mon, 3 Aug 2026 05:06:48 +0000 Subject: [PATCH 8/9] docs: record cli worker orphan reproduction --- docs/2026-08-03-handoff-issues.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/2026-08-03-handoff-issues.md b/docs/2026-08-03-handoff-issues.md index cc0f437d..47816664 100644 --- a/docs/2026-08-03-handoff-issues.md +++ b/docs/2026-08-03-handoff-issues.md @@ -113,7 +113,9 @@ pinned and tested in CI. ### Worker exit leaves a live session and claim — critical Fleet smoke tests claimed T19 and later T20, then the temporary worker/session -process was stopped while the queue row remained claimed. Both rows required +process was stopped while the queue row remained claimed. A supported +one-shot `agent-harness run --limit 1` invocation claimed T27, exited its +parent, and left the Codex session and claim alive as well. Each row required administrative re-queueing; the project was stopped to prevent repeated unattended attempts. The T20 run also confirms that the queue has no policy for recognizing a human-gate item before claiming it. From 503e7c972492ee50fec7d28fe69c9033531c4800 Mon Sep 17 00:00:00 2001 From: sprooty Date: Mon, 3 Aug 2026 05:09:29 +0000 Subject: [PATCH 9/9] docs: record persisted role map defect --- docs/2026-08-03-handoff-issues.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/2026-08-03-handoff-issues.md b/docs/2026-08-03-handoff-issues.md index 47816664..f569dc3f 100644 --- a/docs/2026-08-03-handoff-issues.md +++ b/docs/2026-08-03-handoff-issues.md @@ -139,6 +139,14 @@ worker cannot answer them. **Tracked:** [agent-harness #89](https://github.com/TheDancingDeveloper-org/agent-harness/issues/89). +### Persisted partial role map overrides complete CLI configuration — high + +A direct, non-session `agent-harness run` supplied planner, implementer, and +reviewer routes, but the persisted database role map contained only +`reviewer`. The CLI announced that the stored map was in force, claimed T28, +then failed with `no route for role 'planner'`. T28 was re-queued and the +project stopped. This is tracked in [agent-harness #90](https://github.com/TheDancingDeveloper-org/agent-harness/issues/90). + ## Current execution state - The AIDevEnv queue is **stopped** with 40 pending, 9 blocked, 0 running, 0