docs: update comprehensive API documentation and project guides
This commit updates all documentation files throughout the project: - Updated IMPLEMENTATION_PLAN.md with new implementation details - Updated PROJECT_GUIDELINES.md with coding standards and practices - Updated README.md with current project information - Updated SCREENSHOT_AUTOMATION.md with new automation details - Added TEST_DATA.md with test fixtures data - Updated cover_image_serving_plan.md with static URL patterns Documentation API updates: - Updated API reference documentation for all endpoints including: - Authentication (login, logout, register, refresh_token) - Book matching (auto_link, bulk_link, link_book, search) - Collections (CRUD operations, shelf mappings, auto-assign rules) - Conflicts (bulk operations, resolve/dismiss) - Devices (registration, approval, shelf management) - Highlights (create, update, delete, get) - Kobo sync (bookmark, markup, initialization, sync) - KOReader sync (library, metadata, bookmarks, progress) - Libraries (CRUD, folders, media items, stats) - Media items (bulk operations, CRUD) - Notes (CRUD operations) - OPDS (acquisition, feeds, publication) - Progress (reading progress tracking) - Queue (device queue management) - Ratings (star ratings) - Scanner (watch mode, scan operations) - Sync protocols (Kobo, KOReader) - Users (profile, password, admin operations) - WebSocket protocols - Updated user guides (admin, dashboard, settings, sync) - Updated device setup guides (Kobo, KOReader) - Updated developer guides (testing, contributing, operations) - Updated scripts/README.md
This commit is contained in:
+107
-66
@@ -13,6 +13,7 @@ This document provides precise, line-by-line steps to implement the scanner fixe
|
||||
**File:** `internal/services/media_scanner.go`
|
||||
|
||||
**Current code (around line 348-360):**
|
||||
|
||||
```go
|
||||
func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool, error) {
|
||||
// Get file info
|
||||
@@ -27,6 +28,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
||||
```
|
||||
|
||||
**Add AFTER line 357 (after getting file info, before existingItem check):**
|
||||
|
||||
```go
|
||||
// Get file modification time for created_at
|
||||
fileModTime := info.ModTime()
|
||||
@@ -37,6 +39,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
||||
**File:** `internal/database/queries/queries.sql`
|
||||
|
||||
**Current code (line 131-133):**
|
||||
|
||||
```sql
|
||||
-- name: CreateMediaItem :one
|
||||
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id)
|
||||
@@ -45,6 +48,7 @@ RETURNING *;
|
||||
```
|
||||
|
||||
**Change TO:**
|
||||
|
||||
```sql
|
||||
-- name: CreateMediaItem :one
|
||||
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at)
|
||||
@@ -55,6 +59,7 @@ RETURNING *;
|
||||
### Step 1.3: Regenerate Go code from SQL OR manually update queries.sql.go
|
||||
|
||||
**Option A - Run SQL generation (recommended):**
|
||||
|
||||
```bash
|
||||
cd internal/database && go generate ./...
|
||||
```
|
||||
@@ -66,6 +71,7 @@ cd internal/database && go generate ./...
|
||||
**Find `CreateMediaItemParams` struct (around line 557):**
|
||||
|
||||
**Add to struct (after AddedByAdminID):**
|
||||
|
||||
```go
|
||||
CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"`
|
||||
```
|
||||
@@ -73,6 +79,7 @@ CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"`
|
||||
**Find `CreateMediaItem` function (around line 588):**
|
||||
|
||||
**Add to the query parameters (after AddedByAdminID in the VALUES):**
|
||||
|
||||
```go
|
||||
arg.CreatedAt,
|
||||
```
|
||||
@@ -84,6 +91,7 @@ arg.CreatedAt,
|
||||
**Find the CreateMediaItem call** - around line 512.
|
||||
|
||||
**Current code (line 512-532):**
|
||||
|
||||
```go
|
||||
createdItem, err := s.db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: libraryID,
|
||||
@@ -93,11 +101,13 @@ createdItem, err := s.db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
```
|
||||
|
||||
**Add to the params (after AddedByAdminID):**
|
||||
|
||||
```go
|
||||
CreatedAt: pgtype.Timestamp{Time: fileModTime, Valid: true},
|
||||
```
|
||||
|
||||
**Note:** You'll need to import `"github.com/jackc/pgx/v5/pgtype"` if not already present.
|
||||
|
||||
```go
|
||||
CreatedAt: pgtype.Timestamp{Time: fileModTime, Valid: true},
|
||||
```
|
||||
@@ -115,6 +125,7 @@ createdItem, err := s.db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
**File:** `internal/services/media_scanner.go`
|
||||
|
||||
**Current code (around lines 365-372):**
|
||||
|
||||
```go
|
||||
if s.forceRescan {
|
||||
fmt.Printf("Force rescan enabled, re-processing existing media item: %s\n", path)
|
||||
@@ -129,6 +140,7 @@ if s.forceRescan {
|
||||
### Step 2.2: Replace DELETE+INSERT with UPDATE
|
||||
|
||||
**Replace lines 365-372 with:**
|
||||
|
||||
```go
|
||||
if s.forceRescan {
|
||||
fmt.Printf("Force rescan enabled, updating existing media item: %s\n", path)
|
||||
@@ -155,6 +167,7 @@ if s.forceRescan {
|
||||
**Goal:** Prevent cross-library interference - scanning one library shouldn't affect another library's entries.
|
||||
|
||||
**Important Context:** The `libraryID` is already available in `StartWatchModeForLibrary` at `scanner.go:385`:
|
||||
|
||||
```go
|
||||
h.StartWatchModeForLibrary(ctx, library.ID, library.CreatedByAdminID)
|
||||
```
|
||||
@@ -168,6 +181,7 @@ The function receives `libraryID` but doesn't store it in the scanner. We need t
|
||||
**Find the SetAdminID function (around line 104):**
|
||||
|
||||
**Add AFTER SetAdminID:**
|
||||
|
||||
```go
|
||||
func (s *MediaScanner) SetLibraryID(libraryID pgtype.UUID) {
|
||||
s.defaultLibraryID = libraryID
|
||||
@@ -179,12 +193,14 @@ func (s *MediaScanner) SetLibraryID(libraryID pgtype.UUID) {
|
||||
**File:** `internal/handlers/scanner.go`
|
||||
|
||||
**Current code (around line 268):**
|
||||
|
||||
```go
|
||||
scanner.SetAdminID(adminID)
|
||||
scanner.WatchChanges(h.watchModeCtx)
|
||||
```
|
||||
|
||||
**Add AFTER line 268:**
|
||||
|
||||
```go
|
||||
scanner.SetLibraryID(libraryID)
|
||||
```
|
||||
@@ -194,12 +210,14 @@ scanner.SetLibraryID(libraryID)
|
||||
**File:** `internal/database/queries/queries.sql`
|
||||
|
||||
**Current code (line 302-303):**
|
||||
|
||||
```sql
|
||||
-- name: GetMediaItemByFilePath :one
|
||||
SELECT * FROM media_items WHERE file_path = $1;
|
||||
```
|
||||
|
||||
**Change TO:**
|
||||
|
||||
```sql
|
||||
-- name: GetMediaItemByFilePath :one
|
||||
SELECT * FROM media_items WHERE file_path = $1 AND library_id = $2;
|
||||
@@ -210,10 +228,12 @@ SELECT * FROM media_items WHERE file_path = $1 AND library_id = $2;
|
||||
**File:** `internal/database/queries.sql.go`
|
||||
|
||||
Find `GetMediaItemByFilePath` function and update:
|
||||
|
||||
1. Add `LibraryID pgtype.UUID` parameter to the function and params struct
|
||||
2. Add the parameter to the query call
|
||||
|
||||
**OR run SQL generation:**
|
||||
|
||||
```bash
|
||||
cd internal/database && go generate ./...
|
||||
```
|
||||
@@ -223,6 +243,7 @@ cd internal/database && go generate ./...
|
||||
**File:** `internal/services/media_scanner.go`
|
||||
|
||||
**Current code (lines 1395-1397):**
|
||||
|
||||
```go
|
||||
func (s *MediaScanner) getMediaItemByFilePath(ctx context.Context, filePath string) (database.MediaItems, error) {
|
||||
return s.db.GetMediaItemByFilePath(ctx, filePath)
|
||||
@@ -230,6 +251,7 @@ func (s *MediaScanner) getMediaItemByFilePath(ctx context.Context, filePath stri
|
||||
```
|
||||
|
||||
**Change TO:**
|
||||
|
||||
```go
|
||||
func (s *MediaScanner) getMediaItemByFilePath(ctx context.Context, filePath string) (database.MediaItems, error) {
|
||||
return s.db.GetMediaItemByFilePath(ctx, filePath, s.defaultLibraryID)
|
||||
@@ -241,6 +263,7 @@ func (s *MediaScanner) getMediaItemByFilePath(ctx context.Context, filePath stri
|
||||
**File:** `internal/services/media_scanner.go`
|
||||
|
||||
Update all places that call `getMediaItemByFilePath` to pass the libraryID:
|
||||
|
||||
- Line 361: In `processMediaFile` - already has access to libraryID via folder lookup
|
||||
|
||||
**Note:** The `processMediaFile` function already determines libraryID from the folder path (lines 487-501). Use that libraryID instead of `s.defaultLibraryID` for better accuracy.
|
||||
@@ -288,12 +311,12 @@ func NewScannerLogger() *ScannerLogger {
|
||||
// ensureLogFiles creates/opens log files for today
|
||||
func (l *ScannerLogger) ensureLogFiles() error {
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
|
||||
// Check if we need to rotate (new day)
|
||||
if l.currentDate == today && l.deletesFile != nil {
|
||||
return nil // Already have today's files open
|
||||
}
|
||||
|
||||
|
||||
// Close existing files
|
||||
if l.deletesFile != nil {
|
||||
l.deletesFile.Close()
|
||||
@@ -301,41 +324,41 @@ func (l *ScannerLogger) ensureLogFiles() error {
|
||||
if l.errorsFile != nil {
|
||||
l.errorsFile.Close()
|
||||
}
|
||||
|
||||
|
||||
// Create log directory if it doesn't exist
|
||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create log directory: %v", err)
|
||||
}
|
||||
|
||||
|
||||
// Open new files for today
|
||||
deletesPath := filepath.Join(logDir, fmt.Sprintf("scanner-deletes-%s.log", today))
|
||||
errorsPath := filepath.Join(logDir, fmt.Sprintf("scanner-errors-%s.log", today))
|
||||
|
||||
|
||||
deletesFile, err := os.OpenFile(deletesPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open deletes log file: %v", err)
|
||||
}
|
||||
|
||||
|
||||
errorsFile, err := os.OpenFile(errorsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
deletesFile.Close()
|
||||
return fmt.Errorf("failed to open errors log file: %v", err)
|
||||
}
|
||||
|
||||
|
||||
l.deletesFile = deletesFile
|
||||
l.errorsFile = errorsFile
|
||||
l.currentDate = today
|
||||
|
||||
|
||||
// Clean up old log files
|
||||
l.cleanupOldLogs()
|
||||
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupOldLogs removes log files older than maxLogAgeDays
|
||||
func (l *ScannerLogger) cleanupOldLogs() {
|
||||
cutoff := time.Now().AddDate(0, 0, -maxLogAgeDays)
|
||||
|
||||
|
||||
filepath.Walk(logDir, func(path string, info os.FileInfo) error {
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -381,9 +404,11 @@ func (l *ScannerLogger) Close() {
|
||||
```
|
||||
|
||||
**Add to MediaScanner struct:**
|
||||
|
||||
- Add `logger *ScannerLogger` field to track logger instance
|
||||
|
||||
**Update NewMediaScanner function:**
|
||||
|
||||
- Initialize logger: `logger: NewScannerLogger()`
|
||||
|
||||
### Step 4.1: Ensure libraryID is available in scanner
|
||||
@@ -397,6 +422,7 @@ func (l *ScannerLogger) Close() {
|
||||
**Find:** `WatchChanges` function (around line 1427).
|
||||
|
||||
**Find the event handling section (around lines 1449-1455):**
|
||||
|
||||
```go
|
||||
// Handle file modifications and creations
|
||||
if (event.Has(fsnotify.Create) || event.Has(fsnotify.Write)) && s.isScannableFile(event.Name) {
|
||||
@@ -408,12 +434,13 @@ if (event.Has(fsnotify.Create) || event.Has(fsnotify.Write)) && s.isScannableFil
|
||||
```
|
||||
|
||||
**Add AFTER that block (before line 1457):**
|
||||
|
||||
```go
|
||||
// Handle file deletions
|
||||
if event.Has(fsnotify.Remove) && s.isScannableFile(event.Name) {
|
||||
// Use file logger for persistence
|
||||
s.logger.LogDelete(fmt.Sprintf("[WATCH-DELETE] File removed from filesystem: %s", event.Name))
|
||||
|
||||
|
||||
// Determine libraryID for this file
|
||||
var libraryID pgtype.UUID
|
||||
for _, folder := range s.folders {
|
||||
@@ -431,20 +458,20 @@ if event.Has(fsnotify.Remove) && s.isScannableFile(event.Name) {
|
||||
s.logger.LogError(msg)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Look up media item BEFORE deleting - log for safety
|
||||
existingItem, err := s.db.GetMediaItemByFilePath(ctx, event.Name, libraryID)
|
||||
if err == nil {
|
||||
msg := fmt.Sprintf("[WATCH-DELETE] Found media item to delete: ID=%s, Title=%s, Path=%s",
|
||||
msg := fmt.Sprintf("[WATCH-DELETE] Found media item to delete: ID=%s, Title=%s, Path=%s",
|
||||
existingItem.ID, existingItem.Title.String, existingItem.FilePath.String)
|
||||
s.logger.LogDelete(msg)
|
||||
|
||||
|
||||
if err := s.db.DeleteMediaItem(ctx, existingItem.ID); err != nil {
|
||||
errMsg := fmt.Sprintf("[WATCH-DELETE] ERROR: failed to delete media item %s: %v", existingItem.ID, err)
|
||||
s.logger.LogDelete(errMsg)
|
||||
s.logger.LogError(errMsg)
|
||||
} else {
|
||||
s.logger.LogDelete(fmt.Sprintf("[WATCH-DELETE] SUCCESS: deleted media item '%s' (was at %s)",
|
||||
s.logger.LogDelete(fmt.Sprintf("[WATCH-DELETE] SUCCESS: deleted media item '%s' (was at %s)",
|
||||
existingItem.Title.String, existingItem.FilePath.String))
|
||||
}
|
||||
} else if err != pgx.ErrNoRows {
|
||||
@@ -468,6 +495,7 @@ if event.Has(fsnotify.Remove) && s.isScannableFile(event.Name) {
|
||||
**Find:** End of `ScanFolders` function (after line 242).
|
||||
|
||||
**Current code (around line 242-248):**
|
||||
|
||||
```go
|
||||
fmt.Printf("Scan completed: %d total files scanned, %d media files found, %d new items, %d errors\n",
|
||||
processedFiles, mediaFiles, s.newItems, s.errors)
|
||||
@@ -480,6 +508,7 @@ return nil
|
||||
```
|
||||
|
||||
**Add BEFORE `return nil`:**
|
||||
|
||||
```go
|
||||
// Clean up: Find media items in DB that no longer exist on filesystem
|
||||
for _, folder := range s.folders {
|
||||
@@ -512,14 +541,14 @@ for _, folder := range s.folders {
|
||||
for _, item := range dbItems {
|
||||
filePath := item.FilePath.String
|
||||
if filePath != "" && !scannedPaths[filePath] {
|
||||
msg := fmt.Sprintf("[RESCAN-CLEANUP] Orphaned media item found: ID=%s, Title=%s, Path=%s",
|
||||
msg := fmt.Sprintf("[RESCAN-CLEANUP] Orphaned media item found: ID=%s, Title=%s, Path=%s",
|
||||
item.ID, item.Title.String, filePath)
|
||||
s.logger.LogDelete(msg)
|
||||
|
||||
delMsg := fmt.Sprintf("[RESCAN-CLEANUP] Deleting orphaned item '%s' (file no longer exists at %s)",
|
||||
|
||||
delMsg := fmt.Sprintf("[RESCAN-CLEANUP] Deleting orphaned item '%s' (file no longer exists at %s)",
|
||||
item.Title.String, filePath)
|
||||
s.logger.LogDelete(delMsg)
|
||||
|
||||
|
||||
if err := s.db.DeleteMediaItem(ctx, item.ID); err != nil {
|
||||
errMsg := fmt.Sprintf("[RESCAN-CLEANUP] ERROR: failed to delete orphaned item %s: %v", item.Title.String, err)
|
||||
s.logger.LogDelete(errMsg)
|
||||
@@ -533,6 +562,7 @@ for _, folder := range s.folders {
|
||||
```
|
||||
|
||||
**Log Files Location:** `/app/logs/`
|
||||
|
||||
- `scanner-deletes-YYYY-MM-DD.log` - All deletion events (watch mode + rescan)
|
||||
- `scanner-errors-YYYY-MM-DD.log` - All error events
|
||||
- Rotation: Daily, keeps 7 days of history
|
||||
@@ -543,11 +573,13 @@ for _, folder := range s.folders {
|
||||
## Verification Steps After Implementation
|
||||
|
||||
1. **Compile the code:**
|
||||
|
||||
```bash
|
||||
go build ./...
|
||||
```
|
||||
|
||||
2. **Run tests:**
|
||||
|
||||
```bash
|
||||
go test ./... -v
|
||||
```
|
||||
@@ -571,14 +603,16 @@ for _, folder := range s.folders {
|
||||
## Safety Checks (IMPORTANT)
|
||||
|
||||
### Prevent deleting ALL books:
|
||||
|
||||
- The cleanup logic MUST check `scannedPaths[filePath]` - this ensures we only delete items whose paths were NOT found during the filesystem scan
|
||||
- The key condition is: `if filePath != "" && !scannedPaths[filePath]` - meaning "if this file was NOT found in our scan, delete it"
|
||||
- This is correct because:
|
||||
1. We scan the filesystem → get all current file paths
|
||||
2. We query DB → get all stored file paths
|
||||
2. We query DB → get all stored file paths
|
||||
3. We compare → only delete if DB path is NOT in filesystem paths
|
||||
|
||||
### Before running against production:
|
||||
|
||||
- Test with a small subset of books first
|
||||
- Verify the delete queries target specific library_id (not all libraries)
|
||||
- Check logs show only expected deletions
|
||||
@@ -587,20 +621,20 @@ for _, folder := range s.folders {
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| Fix | File | Changes |
|
||||
|-----|------|---------|
|
||||
| 1a | `internal/database/queries/queries.sql` | Add created_at to INSERT columns |
|
||||
| 1b | `internal/database/queries.sql.go` | Add CreatedAt to CreateMediaItemParams struct + query |
|
||||
| 1c | `internal/services/media_scanner.go` | Get file.ModTime() + pass to CreateMediaItem |
|
||||
| 2 | `internal/services/media_scanner.go` | Change force rescan from DELETE+INSERT to UPDATE |
|
||||
| 3a | `internal/services/media_scanner.go` | Add SetLibraryID() method |
|
||||
| 3b | `internal/handlers/scanner.go` | Call scanner.SetLibraryID(libraryID) |
|
||||
| 3c | `internal/database/queries/queries.sql` | Add library_id to WHERE clause |
|
||||
| 3d | `internal/database/queries.sql.go` | Update GetMediaItemByFilePath params |
|
||||
| 3e | `internal/services/media_scanner.go` | Pass libraryID to getMediaItemByFilePath |
|
||||
| 4.0 | `internal/services/scanner_logger.go` | **NEW FILE** - File logging infrastructure |
|
||||
| 4a | `internal/services/media_scanner.go` | Add fsnotify.Remove handler in WatchChanges + use logger |
|
||||
| 4b | `internal/services/media_scanner.go` | Add cleanup loop in ScanFolders + use logger |
|
||||
| Fix | File | Changes |
|
||||
| --- | --------------------------------------- | -------------------------------------------------------- |
|
||||
| 1a | `internal/database/queries/queries.sql` | Add created_at to INSERT columns |
|
||||
| 1b | `internal/database/queries.sql.go` | Add CreatedAt to CreateMediaItemParams struct + query |
|
||||
| 1c | `internal/services/media_scanner.go` | Get file.ModTime() + pass to CreateMediaItem |
|
||||
| 2 | `internal/services/media_scanner.go` | Change force rescan from DELETE+INSERT to UPDATE |
|
||||
| 3a | `internal/services/media_scanner.go` | Add SetLibraryID() method |
|
||||
| 3b | `internal/handlers/scanner.go` | Call scanner.SetLibraryID(libraryID) |
|
||||
| 3c | `internal/database/queries/queries.sql` | Add library_id to WHERE clause |
|
||||
| 3d | `internal/database/queries.sql.go` | Update GetMediaItemByFilePath params |
|
||||
| 3e | `internal/services/media_scanner.go` | Pass libraryID to getMediaItemByFilePath |
|
||||
| 4.0 | `internal/services/scanner_logger.go` | **NEW FILE** - File logging infrastructure |
|
||||
| 4a | `internal/services/media_scanner.go` | Add fsnotify.Remove handler in WatchChanges + use logger |
|
||||
| 4b | `internal/services/media_scanner.go` | Add cleanup loop in ScanFolders + use logger |
|
||||
|
||||
### Docker Compose Volume Mount
|
||||
|
||||
@@ -618,6 +652,7 @@ volumes:
|
||||
After editing `queries.sql`, you MUST regenerate the Go code:
|
||||
|
||||
**Option A - Run SQL code generation (recommended):**
|
||||
|
||||
```bash
|
||||
cd internal/database && go generate ./...
|
||||
```
|
||||
@@ -625,10 +660,12 @@ cd internal/database && go generate ./...
|
||||
**Option B - Manual update (if Option A fails):**
|
||||
|
||||
If `go generate` fails or is not available, manually update `queries.sql.go`:
|
||||
|
||||
1. Add `LibraryID pgtype.UUID` parameter to `GetMediaItemByFilePathParams` struct
|
||||
2. Add parameter to the query function call
|
||||
|
||||
For Fix 1, manually add `CreatedAt` to:
|
||||
|
||||
- `CreateMediaItemParams` struct
|
||||
- The query VALUES
|
||||
|
||||
@@ -648,11 +685,13 @@ For Fix 1, manually add `CreatedAt` to:
|
||||
### Existing Tests Analysis
|
||||
|
||||
**Current scanner integration tests** (`cmd/server/tests/scanner_integration_test.go`):
|
||||
|
||||
- Uses `/app/uploads` as test folder
|
||||
- Tests scan, progress tracking, watch mode start/stop
|
||||
- **After Fix 3:** Tests can safely use `/app/uploads` because GetMediaItemByFilePath now filters by library_id - test library's entries are isolated from user's library
|
||||
|
||||
**Current unit tests** (`internal/services/*_test.go`):
|
||||
|
||||
- `media_scanner_epub_cover_test.go` - Tests cover extraction
|
||||
- `media_scanner_hash_test.go` - Tests hash calculation
|
||||
- `media_scanner_library_type_test.go` - Tests library type detection
|
||||
@@ -672,20 +711,20 @@ func TestProcessMediaFile_UsesFileMtime(t *testing.T) {
|
||||
// Create test file with specific modification time
|
||||
testFile := createTestEpub(t, "test-book.epub")
|
||||
defer os.Remove(testFile)
|
||||
|
||||
|
||||
// Set specific mtime
|
||||
pastTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
os.Chtimes(testFile, pastTime, pastTime)
|
||||
|
||||
|
||||
// Process file
|
||||
scanner := NewMediaScanner(db)
|
||||
scanner.SetFolders([]string{filepath.Dir(testFile)})
|
||||
scanner.SetLibraryID(testLibraryID)
|
||||
|
||||
|
||||
wasNew, err := scanner.ProcessMediaFile(ctx, testFile)
|
||||
require.NoError(t, err)
|
||||
require.True(t, wasNew)
|
||||
|
||||
|
||||
// Verify created_at matches file mtime, not scan time
|
||||
item, err := db.GetMediaItemByFilePath(ctx, testFile, testLibraryID)
|
||||
require.NoError(t, err)
|
||||
@@ -697,24 +736,24 @@ func TestForceRescan_PreservesCreatedAt(t *testing.T) {
|
||||
// Create and process file
|
||||
testFile := createTestEpub(t, "test-book.epub")
|
||||
defer os.Remove(testFile)
|
||||
|
||||
|
||||
scanner := NewMediaScanner(db)
|
||||
scanner.SetFolders([]string{filepath.Dir(testFile)})
|
||||
scanner.SetLibraryID(testLibraryID)
|
||||
|
||||
|
||||
_, _ = scanner.ProcessMediaFile(ctx, testFile)
|
||||
|
||||
|
||||
// Get original created_at
|
||||
item, _ := db.GetMediaItemByFilePath(ctx, testFile, testLibraryID)
|
||||
originalCreatedAt := item.CreatedAt.Time
|
||||
|
||||
|
||||
// Wait a moment to ensure time difference
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
|
||||
// Force rescan
|
||||
scanner.SetForce(true)
|
||||
_, _ = scanner.ProcessMediaFile(ctx, testFile)
|
||||
|
||||
|
||||
// Verify created_at is preserved
|
||||
item, _ = db.GetMediaItemByFilePath(ctx, testFile, testLibraryID)
|
||||
assert.Equal(t, originalCreatedAt, item.CreatedAt.Time)
|
||||
@@ -731,21 +770,21 @@ func TestGetMediaItemByFilePath_FiltersByLibrary(t *testing.T) {
|
||||
// Same file path in two different libraries
|
||||
testFile := createTestEpub(t, "shared-book.epub")
|
||||
defer os.Remove(testFile)
|
||||
|
||||
|
||||
// Add to library A
|
||||
scannerA := NewMediaScanner(db)
|
||||
scannerA.SetLibraryID(libraryAID)
|
||||
_, _ = scannerA.ProcessMediaFile(ctx, testFile)
|
||||
|
||||
|
||||
// Add same path to library B (simulating shared folder scenario)
|
||||
scannerB := NewMediaScanner(db)
|
||||
scannerB.SetLibraryID(libraryBID)
|
||||
_, _ = scannerB.ProcessMediaFile(ctx, testFile)
|
||||
|
||||
|
||||
// Verify each library has its own entry
|
||||
itemA, errA := db.GetMediaItemByFilePath(ctx, testFile, libraryAID)
|
||||
itemB, errB := db.GetMediaItemByFilePath(ctx, testFile, libraryBID)
|
||||
|
||||
|
||||
require.NoError(t, errA)
|
||||
require.NoError(t, errB)
|
||||
assert.Equal(t, libraryAID, itemA.LibraryID)
|
||||
@@ -765,30 +804,30 @@ func TestGetMediaItemByFilePath_FiltersByLibrary(t *testing.T) {
|
||||
func TestScan_DeletesOrphanedBooks(t *testing.T) {
|
||||
// Create test library pointing to /app/uploads (same as existing tests)
|
||||
testFolder := "/app/uploads"
|
||||
|
||||
|
||||
// Create test library with test folder
|
||||
libraryID := createTestLibrary(t, s.setup.Token, "Orphan Test Library", testFolder)
|
||||
|
||||
|
||||
// Add a test file
|
||||
testFile := createTestEpubFile(t, testFolder, "test-orphan-book.epub")
|
||||
defer os.Remove(testFile) // Cleanup after test
|
||||
|
||||
|
||||
// Add a test file
|
||||
testFile := createTestEpubFile(t, testFolder, "test-book.epub")
|
||||
|
||||
|
||||
// Initial scan
|
||||
scanLibrary(t, s.setup.Server.URL, libraryID, s.setup.Token)
|
||||
|
||||
|
||||
// Verify book exists
|
||||
items, _ := s.setup.DB.ListMediaItemsByLibrary(ctx, libraryID)
|
||||
require.Len(t, items, 1)
|
||||
|
||||
|
||||
// Delete file from filesystem (simulating user deletion)
|
||||
os.Remove(testFile)
|
||||
|
||||
|
||||
// Rescan
|
||||
scanLibrary(t, s.setup.Server.URL, libraryID, s.setup.Token)
|
||||
|
||||
|
||||
// Verify book was deleted from DB
|
||||
items, _ = s.setup.DB.ListMediaItemsByLibrary(ctx, libraryID)
|
||||
assert.Len(t, items, 0, "Orphaned book should be removed from database")
|
||||
@@ -804,33 +843,33 @@ func TestScan_DeletesOrphanedBooks(t *testing.T) {
|
||||
func TestWatchMode_DeletesRemovedFile(t *testing.T) {
|
||||
// Use /app/uploads - Fix 3 handles isolation
|
||||
testFolder := "/app/uploads"
|
||||
|
||||
|
||||
// Create test library
|
||||
libraryID := createTestLibrary(t, s.setup.Token, "Watch Delete Test", testFolder)
|
||||
|
||||
|
||||
// Add test file
|
||||
testFile := createTestEpubFile(t, testFolder, "watch-test.epub")
|
||||
|
||||
|
||||
// Start watch mode
|
||||
startWatchMode(t, s.setup.Server.URL, libraryID, s.setup.Token)
|
||||
|
||||
|
||||
// Wait for initial scan
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
|
||||
// Verify book exists
|
||||
items, _ := s.setup.DB.ListMediaItemsByLibrary(ctx, libraryID)
|
||||
require.Len(t, items, 1)
|
||||
|
||||
|
||||
// Delete file
|
||||
os.Remove(testFile)
|
||||
|
||||
|
||||
// Wait for watch mode to detect
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
|
||||
// Verify book was deleted
|
||||
items, _ = s.setup.DB.ListMediaItemsByLibrary(ctx, libraryID)
|
||||
assert.Len(t, items, 0, "Book should be deleted when file removed from filesystem")
|
||||
|
||||
|
||||
// Stop watch mode
|
||||
stopWatchMode(t, s.setup.Server.URL, libraryID, s.setup.Token)
|
||||
}
|
||||
@@ -845,17 +884,19 @@ func TestWatchMode_DeletesRemovedFile(t *testing.T) {
|
||||
### Documentation Updates
|
||||
|
||||
If API behavior changes, update:
|
||||
|
||||
- `docs/developer/api/scanner.md` - For any endpoint changes
|
||||
- `docs/user/` - If user-facing behavior changes
|
||||
|
||||
### Running Tests
|
||||
|
||||
After implementation, run:
|
||||
|
||||
```bash
|
||||
# Unit tests
|
||||
go test ./internal/services/... -v -run "TestProcessMediaFile|TestGetMediaItemByFilePath|TestForceRescan"
|
||||
|
||||
# Integration tests
|
||||
# Integration tests
|
||||
go test ./cmd/server/tests/... -v -run "Scanner"
|
||||
|
||||
# All tests
|
||||
|
||||
Reference in New Issue
Block a user