Skip to content

Commit

Permalink
✨ Scan history and basic override configs (#583)
Browse files Browse the repository at this point in the history
* wip: start roughing out granular scan concept

* wip: api endpoint + section

* change name, write ideas down

* wip: save place, idek what I was doing

* wip: scan history

* got distracted, adjust table component

* create wide switch component

* wip: refactor options to use enums

* wip: build out scanner logic

* wip: operation handling

* realized I need to redo some of this

this only works as a 1 scan with 1 strategy, when really we probably want a check list of things you can do

* super messy but works minimally

* wip: build out ui

* add locale for scan history sections

* strip ansi from live logs

* add migration

* fix clippy lint

* fix tests

* fix generated type for scan options

* improve coverage
  • Loading branch information
aaronleopold authored Feb 9, 2025
1 parent cf17ff6 commit c980ed8
Show file tree
Hide file tree
Showing 86 changed files with 2,604 additions and 479 deletions.
12 changes: 12 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ async-stream = "0.3.5"
base64 = "0.22.1"
bcrypt = "0.15.1"
derive_builder = "0.20.0"
derivative = "2.2.0"
chrono = { version = "0.4.38", features = ["serde"] }
futures = "0.3.30"
futures-util = "0.3.30"
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/middleware/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ impl RequestContext {
///
/// Note: It is important that this middlware is placed _after_ any middleware/handlers which access the
/// request extensions, as the user is inserted into the request extensions dynamically here.
#[tracing::instrument(skip(ctx, req, next))]
#[tracing::instrument(skip_all)]
pub async fn auth_middleware(
State(ctx): State<AppState>,
HostExtractor(host_details): HostExtractor,
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/routers/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ mod tests {
NamedType,
};

use stump_core::config::StumpConfig;
use stump_core::{config::StumpConfig, filesystem::scanner::LibraryScanRecord};

use crate::{
config::jwt::CreatedToken,
Expand Down Expand Up @@ -213,6 +213,8 @@ mod tests {
file.write_all(
format!("{}\n\n", ts_export::<PatchLibraryThumbnail>()?).as_bytes(),
)?;
file.write_all(format!("{}\n\n", ts_export::<LibraryScanRecord>()?).as_bytes())?;
file.write_all(format!("{}\n\n", ts_export::<LastScanDetails>()?).as_bytes())?;

file.write_all(
format!("{}\n\n", ts_export::<CreateOrUpdateSmartList>()?).as_bytes(),
Expand Down
30 changes: 29 additions & 1 deletion apps/server/src/routers/api/v1/job.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ pub(crate) fn mount(app_state: AppState) -> Router<AppState> {
.nest(
"/:id",
Router::new()
.route("/", delete(delete_job_by_id))
.route("/", get(get_job_by_id).delete(delete_job_by_id))
.route("/cancel", delete(cancel_job_by_id)),
)
.route(
Expand Down Expand Up @@ -162,6 +162,34 @@ async fn delete_jobs(State(ctx): State<AppState>) -> APIResult<()> {
Ok(())
}

#[utoipa::path(
get,
path = "/api/v1/jobs/:id",
tag = "job",
responses(
(status = 200, description = "Successfully fetched job report", body = PersistedJob),
(status = 401, description = "No user is logged in (unauthorized)."),
(status = 403, description = "User does not have permission to access this resource."),
(status = 404, description = "Job not found"),
(status = 500, description = "Internal server error."),
)
)]
async fn get_job_by_id(
State(ctx): State<AppState>,
Path(job_id): Path<String>,
) -> APIResult<Json<PersistedJob>> {
let job = ctx
.db
.job()
.find_unique(job::id::equals(job_id))
.with(job::logs::fetch(vec![]))
.exec()
.await?
.ok_or(APIError::NotFound("Job not found".to_string()))?;

Ok(Json(PersistedJob::from(job)))
}

#[utoipa::path(
delete,
path = "/api/v1/jobs/:id",
Expand Down
120 changes: 117 additions & 3 deletions apps/server/src/routers/api/v1/library.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use axum::{
routing::{get, post, put},
Extension, Json, Router,
};
use chrono::Duration;
use chrono::{DateTime, Duration, FixedOffset};
use prisma_client_rust::{chrono::Utc, not, or, raw, Direction, PrismaValue};
use serde::{Deserialize, Serialize};
use serde_qs::axum::QsQuery;
Expand All @@ -20,6 +20,7 @@ use stump_core::{
db::{
entity::{
macros::{
library_idents_select, library_scan_details,
library_series_ids_media_ids_include, library_tags_select,
library_thumbnails_deletion_include, series_or_library_thumbnail,
},
Expand All @@ -37,11 +38,11 @@ use stump_core::{
GenerateThumbnailOptions, ImageFormat, ImageProcessorOptions,
ThumbnailGenerationJob, ThumbnailGenerationJobParams,
},
scanner::{LibraryScanJob, ScanOptions},
scanner::{LastLibraryScan, LibraryScanJob, LibraryScanRecord, ScanOptions},
ContentType,
},
prisma::{
last_library_visit, library, library_config,
last_library_visit, library, library_config, library_scan_record,
media::{self, OrderByParam as MediaOrderByParam},
series::{self, OrderByParam as SeriesOrderByParam},
tag, user,
Expand Down Expand Up @@ -90,6 +91,11 @@ pub(crate) fn mount(app_state: AppState) -> Router<AppState> {
"/excluded-users",
get(get_library_excluded_users).post(update_library_excluded_users),
)
.route("/last-scan", get(get_library_last_scan))
.route(
"/scan-history",
get(get_library_scan_history).delete(delete_library_scan_history),
)
.route("/scan", post(scan_library))
.route("/clean", put(clean_library))
.route("/series", get(get_library_series))
Expand Down Expand Up @@ -991,6 +997,111 @@ async fn update_library_excluded_users(
Ok(Json(Library::from(updated_library)))
}

#[derive(Debug, Deserialize, Serialize, ToSchema, Type)]
pub struct LastScanDetails {
last_scanned_at: Option<DateTime<FixedOffset>>,
last_scan: Option<LastLibraryScan>,
}

#[utoipa::path(
get,
path = "/api/v1/libraries/:id/last-scan",
tag = "library",
params(
("id" = String, Path, description = "The library ID")
),
responses(
(status = 200, description = "Successfully fetched library last scan details", body = LastScanDetails),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Library not found"),
(status = 500, description = "Internal server error")
)
)]
/// Get the last scan details for a library by id, if the current user has access to it.
/// This includes the last scanned at timestamp and the last custom scan details.
async fn get_library_last_scan(
Path(id): Path<String>,
State(ctx): State<AppState>,
Extension(req): Extension<RequestContext>,
) -> APIResult<Json<LastScanDetails>> {
let client = &ctx.db;

let record = client
.library()
.find_first(vec![
library::id::equals(id.clone()),
library_not_hidden_from_user_filter(req.user()),
])
.with(
library::scan_history::fetch(vec![])
.order_by(library_scan_record::timestamp::order(Direction::Desc)),
)
.select(library_scan_details::select())
.exec()
.await?
.ok_or(APIError::NotFound("Library not found".to_string()))?;

let last_scan = record
.scan_history
.first()
.cloned()
.map(LastLibraryScan::try_from)
.transpose()?;
let last_scanned_at = record.last_scanned_at;

Ok(Json(LastScanDetails {
last_scanned_at,
last_scan,
}))
}

async fn get_library_scan_history(
Path(id): Path<String>,
State(ctx): State<AppState>,
Extension(req): Extension<RequestContext>,
) -> APIResult<Json<Vec<LibraryScanRecord>>> {
let client = &ctx.db;

let records = client
.library_scan_record()
.find_many(vec![library_scan_record::library::is(vec![
library::id::equals(id.clone()),
library_not_hidden_from_user_filter(req.user()),
])])
.order_by(library_scan_record::timestamp::order(Direction::Desc))
.exec()
.await?;

let scan_history = records
.into_iter()
.map(LibraryScanRecord::try_from)
.collect::<Result<_, _>>()?;

Ok(Json(scan_history))
}

async fn delete_library_scan_history(
Path(id): Path<String>,
State(ctx): State<AppState>,
Extension(req): Extension<RequestContext>,
) -> APIResult<Json<()>> {
req.enforce_permissions(&[UserPermission::ManageLibrary])?;

let client = &ctx.db;

let affected_rows = client
.library_scan_record()
.delete_many(vec![library_scan_record::library::is(vec![
library::id::equals(id.clone()),
library_not_hidden_from_user_filter(req.user()),
])])
.exec()
.await?;
tracing::debug!(affected_rows, "Deleted library scan history records");

Ok(Json(()))
}

#[utoipa::path(
post,
path = "/api/v1/libraries/:id/scan",
Expand All @@ -1004,6 +1115,7 @@ async fn update_library_excluded_users(
)]
/// Queue a ScannerJob to scan the library by id. The job, when started, is
/// executed in a separate thread.
#[tracing::instrument(skip(ctx, req))]
async fn scan_library(
Path(id): Path<String>,
State(ctx): State<AppState>,
Expand All @@ -1019,6 +1131,7 @@ async fn scan_library(
library::id::equals(id.clone()),
library_not_hidden_from_user_filter(&user),
])
.select(library_idents_select::select())
.exec()
.await?
.ok_or(APIError::NotFound(format!(
Expand All @@ -1032,6 +1145,7 @@ async fn scan_library(
"Failed to enqueue library scan job".to_string(),
)
})?;
tracing::debug!("Enqueued library scan job");

Ok(())
}
Expand Down
1 change: 1 addition & 0 deletions core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ async-trait = { workspace = true }
cuid = "1.3.2"
data-encoding = "2.5.0"
derive_builder = { workspace = true }
derivative = { workspace = true }
dirs = "5.0.1"
email = { path = "../crates/email" }
epub = { git = "https://github.com/stumpapp/epub-rs", rev = "38e091abe96875952556ab7dec195022d0230e14" }
Expand Down
16 changes: 16 additions & 0 deletions core/prisma/migrations/20250208194841_scan_history/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- AlterTable
ALTER TABLE "libraries" ADD COLUMN "last_scanned_at" DATETIME;

-- CreateTable
CREATE TABLE "library_scan_records" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"options" BLOB,
"timestamp" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"library_id" TEXT NOT NULL,
"job_id" TEXT,
CONSTRAINT "library_scan_records_library_id_fkey" FOREIGN KEY ("library_id") REFERENCES "libraries" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "library_scan_records_job_id_fkey" FOREIGN KEY ("job_id") REFERENCES "jobs" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);

-- CreateIndex
CREATE UNIQUE INDEX "library_scan_records_job_id_key" ON "library_scan_records"("job_id");
37 changes: 28 additions & 9 deletions core/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,15 @@ model APIKey {
}

model Library {
id String @id @default(uuid())
name String @unique
description String?
path String @unique
status String @default("READY") // UNKNOWN, READY, UNSUPPORTED, ERROR, MISSING
updated_at DateTime @updatedAt
created_at DateTime @default(now())
emoji String?
id String @id @default(uuid())
name String @unique
description String?
path String @unique
status String @default("READY") // UNKNOWN, READY, UNSUPPORTED, ERROR, MISSING
last_scanned_at DateTime?
updated_at DateTime @updatedAt
created_at DateTime @default(now())
emoji String?
series Series[]
Expand All @@ -122,9 +123,10 @@ model Library {
tags Tag[]
hidden_from_users User[]
job_schedule_config JobScheduleConfig? @relation(fields: [job_schedule_config_id], references: [id])
job_schedule_config JobScheduleConfig? @relation(fields: [job_schedule_config_id], references: [id])
job_schedule_config_id String?
user_visits LastLibraryVisit[]
scan_history LibraryScanRecord[]
@@map("libraries")
}
Expand Down Expand Up @@ -163,6 +165,21 @@ model LastLibraryVisit {
@@map("last_library_visits")
}

model LibraryScanRecord {
id Int @id @default(autoincrement())
options Bytes?
timestamp DateTime @default(now())
library_id String
library Library @relation(fields: [library_id], references: [id], onDelete: Cascade)
job_id String? @unique
job Job? @relation(fields: [job_id], references: [id], onDelete: Cascade)
@@map("library_scan_records")
}

model Series {
id String @id @default(uuid())
name String
Expand Down Expand Up @@ -729,6 +746,8 @@ model Job {
logs Log[]
library_scan_record LibraryScanRecord?
@@map("jobs")
}

Expand Down
Loading

0 comments on commit c980ed8

Please sign in to comment.