Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7ee65b1
fix: strip queue ID prefix from final library filenames and folders
drondeseries Dec 28, 2025
56f9b6b
fix(health): ensure playback failures trigger immediate repair
drondeseries Dec 29, 2025
b98f8a7
fix(importer): do not mark items as fallback if fallback is not confi…
drondeseries Dec 29, 2025
6a66bd8
Merge pull request #46 from javi11/main
drondeseries Dec 29, 2025
e6857f9
Merge pull request #47 from drondeseries/fix/fallback-not-configured
drondeseries Dec 29, 2025
d13700c
Merge pull request #48 from drondeseries/fix/playback-failure-repair
drondeseries Dec 29, 2025
e3b8002
fix(health): allow periodic re-checks for healthy files
drondeseries Dec 29, 2025
100d331
fix(health): allow periodic re-checks for healthy files
drondeseries Dec 29, 2025
9856fab
fix(health): add 1-hour delay between repair retries
drondeseries Dec 29, 2025
df1a956
fix(api): include DownloadClientBaseURL in SABnzbd config response
drondeseries Dec 29, 2025
2b0617d
fix(arrs): make library_dir optional for sonarr rescan triggering
drondeseries Dec 30, 2025
79b7561
fix(health): persist library sync result and fix frontend display
drondeseries Dec 30, 2025
0bc1ab7
fix: resolve test compilation error and remove unused field
drondeseries Dec 30, 2025
f24d945
fix: remove unused field to satisfy linter
drondeseries Dec 30, 2025
e31fcf6
Merge branch 'fix/library-sync-status' into fix/all-consolidated-fixes
drondeseries Dec 30, 2025
d102b8a
Merge branch 'fix/allow-healthy-recheck' into fix/all-consolidated-fixes
drondeseries Dec 30, 2025
c6d1670
Merge branch 'fix/arr-category-registration' into fix/all-consolidate…
drondeseries Dec 30, 2025
da469d9
Merge branch 'fix/fallback-not-configured' into fix/all-consolidated-…
drondeseries Dec 30, 2025
d8d7921
Merge branch 'fix/playback-failure-repair' into fix/all-consolidated-…
drondeseries Dec 30, 2025
b0bf6f4
Merge branch 'fix/sabnzbd-api-url-missing' into fix/all-consolidated-…
drondeseries Dec 30, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/altmount/cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ func runServe(cmd *cobra.Command, args []string) error {
webdav.RegisterConfigHandlers(ctx, configManager, webdavHandler)
api.RegisterLogLevelHandler(ctx, configManager, debugMode)

healthWorker, librarySyncWorker, err := startHealthWorker(ctx, cfg, repos.HealthRepo, poolManager, configManager, rcloneRCClient, arrsService)
healthWorker, librarySyncWorker, err := startHealthWorker(ctx, cfg, repos.HealthRepo, repos.MainRepo, poolManager, configManager, rcloneRCClient, arrsService)
if err != nil {
logger.Warn("Health worker initialization failed", "err", err)
}
Expand Down
2 changes: 2 additions & 0 deletions cmd/altmount/cmd/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ func startHealthWorker(
ctx context.Context,
cfg *config.Config,
healthRepo *database.HealthRepository,
repo *database.Repository,
poolManager pool.Manager,
configManager *config.Manager,
rcloneClient rclonecli.RcloneRcClient,
Expand Down Expand Up @@ -354,6 +355,7 @@ func startHealthWorker(
librarySyncWorker := health.NewLibrarySyncWorker(
metadataService,
healthRepo,
repo,
configManager.GetConfigGetter(),
configManager,
rcloneClient,
Expand Down
14 changes: 13 additions & 1 deletion frontend/src/pages/HealthPage/components/LibraryScanStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ interface LibrarySyncProgress {
interface LibrarySyncResult {
files_added: number;
files_deleted: number;
metadata_deleted: number;
library_files_deleted: number;
library_dirs_deleted: number;
duration: number;
completed_at: string;
}
Expand Down Expand Up @@ -178,7 +181,16 @@ export function LibraryScanStatus({
<strong>Added:</strong> {status.last_sync_result.files_added}
</span>
<span>
<strong>Deleted:</strong> {status.last_sync_result.files_deleted}
<strong>Files Deleted:</strong> {status.last_sync_result.files_deleted}
</span>
<span>
<strong>Meta Deleted:</strong> {status.last_sync_result.metadata_deleted}
</span>
<span>
<strong>Lib Deleted:</strong> {status.last_sync_result.library_files_deleted}
</span>
<span>
<strong>Dirs Deleted:</strong> {status.last_sync_result.library_dirs_deleted}
</span>
<span>
<strong>Duration:</strong> {(status.last_sync_result.duration / 1e9).toFixed(2)}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ export interface LibrarySyncProgress {
export interface LibrarySyncResult {
files_added: number;
files_deleted: number;
metadata_deleted: number;
duration: number;
completed_at: string;
}
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ export interface LibrarySyncResult {
files_added: number;
files_deleted: number;
metadata_deleted: number;
duration: string;
library_files_deleted: number;
library_dirs_deleted: number;
duration: number;
completed_at: string;
}

Expand Down
26 changes: 14 additions & 12 deletions internal/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,13 @@ type ImportAPIResponse struct {

// SABnzbdAPIResponse sanitizes SABnzbd config for API responses
type SABnzbdAPIResponse struct {
Enabled bool `json:"enabled"`
CompleteDir string `json:"complete_dir"`
Categories []config.SABnzbdCategory `json:"categories"`
FallbackHost string `json:"fallback_host"`
FallbackAPIKey string `json:"fallback_api_key"` // Obfuscated if set
FallbackAPIKeySet bool `json:"fallback_api_key_set"` // Indicates if API key is set
Enabled bool `json:"enabled"`
CompleteDir string `json:"complete_dir"`
DownloadClientBaseURL string `json:"download_client_base_url"`
Categories []config.SABnzbdCategory `json:"categories"`
FallbackHost string `json:"fallback_host"`
FallbackAPIKey string `json:"fallback_api_key"` // Obfuscated if set
FallbackAPIKeySet bool `json:"fallback_api_key_set"` // Indicates if API key is set
}

// Helper functions to create API responses from core config types
Expand Down Expand Up @@ -208,12 +209,13 @@ func ToConfigAPIResponse(cfg *config.Config, apiKey string) *ConfigAPIResponse {
}

sabnzbdResp := SABnzbdAPIResponse{
Enabled: cfg.SABnzbd.Enabled != nil && *cfg.SABnzbd.Enabled,
CompleteDir: cfg.SABnzbd.CompleteDir,
Categories: cfg.SABnzbd.Categories,
FallbackHost: cfg.SABnzbd.FallbackHost,
FallbackAPIKey: fallbackAPIKey,
FallbackAPIKeySet: cfg.SABnzbd.FallbackAPIKey != "",
Enabled: cfg.SABnzbd.Enabled != nil && *cfg.SABnzbd.Enabled,
CompleteDir: cfg.SABnzbd.CompleteDir,
DownloadClientBaseURL: cfg.SABnzbd.DownloadClientBaseURL,
Categories: cfg.SABnzbd.Categories,
FallbackHost: cfg.SABnzbd.FallbackHost,
FallbackAPIKey: fallbackAPIKey,
FallbackAPIKeySet: cfg.SABnzbd.FallbackAPIKey != "",
}

return &ConfigAPIResponse{
Expand Down
2 changes: 0 additions & 2 deletions internal/arrs/scanner/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -445,8 +445,6 @@ func (m *Manager) triggerSonarrRescanByPath(ctx context.Context, client *sonarr.
libraryDir := ""
if cfg.Health.LibraryDir != nil && *cfg.Health.LibraryDir != "" {
libraryDir = *cfg.Health.LibraryDir
} else {
return fmt.Errorf("Health.LibraryDir is not configured")
}

slog.DebugContext(ctx, "Triggering Sonarr rescan/re-download by path",
Expand Down
29 changes: 17 additions & 12 deletions internal/database/health_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,22 @@ func NewHealthRepository(db *sql.DB) *HealthRepository {
func (r *HealthRepository) UpdateFileHealth(ctx context.Context, filePath string, status HealthStatus, errorMessage *string, sourceNzbPath *string, errorDetails *string, noRetry bool) error {
filePath = strings.TrimPrefix(filePath, "/")
query := `
INSERT INTO file_health (file_path, status, last_checked, last_error, source_nzb_path, error_details, retry_count, max_retries, repair_retry_count, created_at, updated_at, scheduled_check_at)
VALUES (?, ?, datetime('now'), ?, ?, ?, CASE WHEN ? THEN 2 ELSE 0 END, 2, 0, datetime('now'), datetime('now'), datetime('now'))
INSERT INTO file_health (file_path, status, last_checked, last_error, source_nzb_path, error_details, retry_count, max_retries, repair_retry_count, created_at, updated_at, scheduled_check_at, priority)
VALUES (?, ?, datetime('now'), ?, ?, ?, CASE WHEN ? THEN 1 ELSE 0 END, 2, 0, datetime('now'), datetime('now'), datetime('now'), CASE WHEN ? THEN 2 ELSE 0 END)
ON CONFLICT(file_path) DO UPDATE SET
status = excluded.status,
last_checked = datetime('now'),
last_error = excluded.last_error,
source_nzb_path = COALESCE(excluded.source_nzb_path, source_nzb_path),
error_details = excluded.error_details,
retry_count = CASE WHEN ? THEN max_retries ELSE retry_count END,
retry_count = CASE WHEN ? THEN max_retries - 1 ELSE retry_count END,
max_retries = excluded.max_retries,
updated_at = datetime('now'),
scheduled_check_at = datetime('now')
scheduled_check_at = datetime('now'),
priority = CASE WHEN ? THEN 2 ELSE priority END
`

_, err := r.db.ExecContext(ctx, query, filePath, status, errorMessage, sourceNzbPath, errorDetails, noRetry, noRetry)
_, err := r.db.ExecContext(ctx, query, filePath, status, errorMessage, sourceNzbPath, errorDetails, noRetry, noRetry, noRetry, noRetry)
if err != nil {
return fmt.Errorf("failed to update file health: %w", err)
}
Expand Down Expand Up @@ -114,7 +115,7 @@ func (r *HealthRepository) GetUnhealthyFiles(ctx context.Context, limit int) ([]
WHERE scheduled_check_at IS NOT NULL
AND scheduled_check_at <= datetime('now')
AND retry_count < max_retries
AND status NOT IN ('repair_triggered', 'corrupted', 'checking', 'healthy')
AND status NOT IN ('repair_triggered', 'corrupted', 'checking')
ORDER BY priority DESC, scheduled_check_at ASC
LIMIT ?
`
Expand Down Expand Up @@ -495,16 +496,19 @@ func (r *HealthRepository) RegisterCorruptedFile(ctx context.Context, filePath s
INSERT INTO file_health (
file_path, library_path, status, last_error, error_details,
retry_count, max_retries, repair_retry_count, max_repair_retries,
created_at, updated_at, scheduled_check_at
created_at, updated_at, scheduled_check_at, last_checked, priority
)
VALUES (?, ?, 'corrupted', ?, ?, 0, 2, 0, 3, datetime('now'), datetime('now'), NULL)
VALUES (?, ?, 'pending', ?, ?, 1, 2, 0, 3, datetime('now'), datetime('now'), datetime('now'), datetime('now'), 2)
ON CONFLICT(file_path) DO UPDATE SET
library_path = COALESCE(excluded.library_path, library_path),
status = 'corrupted',
status = 'pending',
last_error = excluded.last_error,
error_details = excluded.error_details,
scheduled_check_at = NULL,
updated_at = datetime('now')
retry_count = CASE WHEN max_retries > 0 THEN max_retries - 1 ELSE 0 END,
scheduled_check_at = datetime('now'),
last_checked = datetime('now'),
updated_at = datetime('now'),
priority = 2
`

_, err := r.db.ExecContext(ctx, query, filePath, libraryPath, errorMessage, errorMessage)
Expand Down Expand Up @@ -975,7 +979,8 @@ func (r *HealthRepository) UpdateHealthStatusBulk(ctx context.Context, updates [
UPDATE file_health
SET repair_retry_count = repair_retry_count + 1, last_error = ?,
error_details = ?, status = 'repair_triggered',
updated_at = datetime('now'), last_checked = datetime('now')
updated_at = datetime('now'), last_checked = datetime('now'),
scheduled_check_at = datetime('now', '+1 hour')
WHERE file_path = ?
`)
if err != nil {
Expand Down
41 changes: 39 additions & 2 deletions internal/database/health_repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
)

func setupTestDB(t *testing.T) *HealthRepository {
db, err := sql.Open("sqlite3", "file::memory:?cache=shared")
db, err := sql.Open("sqlite3", "file::memory:")
require.NoError(t, err)

_, err = db.Exec(`
Expand All @@ -33,7 +33,7 @@ func setupTestDB(t *testing.T) *HealthRepository {
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
release_date DATETIME,
scheduled_check_at DATETIME,
priority BOOLEAN DEFAULT 0
priority INTEGER DEFAULT 0
);
`)
require.NoError(t, err)
Expand Down Expand Up @@ -95,3 +95,40 @@ func TestGetFilesForRepairNotification_RespectsSchedule(t *testing.T) {
assert.True(t, foundPast, "Past scheduled repair should be picked up")
assert.True(t, foundNull, "Null scheduled repair should be picked up")
}

func TestRegisterCorruptedFile_PlaybackFailureBehavior(t *testing.T) {
repo := setupTestDB(t)
ctx := context.Background()
filePath := "tv/Show/Season 01/Episode 01.mkv"
errorMsg := "no NZB data available for file"

// 1. Simulate RegisterCorruptedFile call (e.g. from streaming failure)
err := repo.RegisterCorruptedFile(ctx, filePath, nil, errorMsg)
require.NoError(t, err)

// 2. Check the file state
fileHealth, err := repo.GetFileHealth(ctx, filePath)
require.NoError(t, err)
require.NotNil(t, fileHealth)

// Assert FIX behavior:
// Status = 'pending'
// Priority = HealthPriorityNext (2)
// RetryCount = MaxRetries - 1 (so it triggers repair on next check)
assert.Equal(t, HealthStatusPending, fileHealth.Status, "Status should be pending to trigger check/repair")
assert.Equal(t, HealthPriorityNext, fileHealth.Priority, "Priority should be high/next")
assert.Equal(t, fileHealth.MaxRetries-1, fileHealth.RetryCount, "RetryCount should equal MaxRetries-1 to trigger immediate repair on next check")

// 3. Verify GetUnhealthyFiles picks it up
unhealthyFiles, err := repo.GetUnhealthyFiles(ctx, 10)
require.NoError(t, err)

found := false
for _, f := range unhealthyFiles {
if f.FilePath == filePath {
found = true
break
}
}
assert.True(t, found, "File should be picked up by GetUnhealthyFiles")
}
10 changes: 10 additions & 0 deletions internal/database/migrations/010_add_system_state.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- +goose Up
-- Table for storing persistent system state and worker metadata
CREATE TABLE IF NOT EXISTS system_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- +goose Down
DROP TABLE IF EXISTS system_state;
36 changes: 36 additions & 0 deletions internal/database/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -890,3 +890,39 @@ func (r *Repository) UpdateQueueItemPriority(ctx context.Context, id int64, prio
}
return nil
}

// System State operations

// SetSystemState saves a key-value pair to the system_state table
func (r *Repository) SetSystemState(ctx context.Context, key string, value string) error {
query := `
INSERT INTO system_state (key, value, updated_at)
VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = datetime('now')
`

_, err := r.db.ExecContext(ctx, query, key, value)
if err != nil {
return fmt.Errorf("failed to set system state: %w", err)
}

return nil
}

// GetSystemState retrieves a value from the system_state table
func (r *Repository) GetSystemState(ctx context.Context, key string) (string, error) {
query := `SELECT value FROM system_state WHERE key = ?`

var value string
err := r.db.QueryRowContext(ctx, query, key).Scan(&value)
if err != nil {
if err == sql.ErrNoRows {
return "", nil
}
return "", fmt.Errorf("failed to get system state: %w", err)
}

return value, nil
}
6 changes: 6 additions & 0 deletions internal/errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,10 @@ var (
message: "import contains no video files",
cause: nil,
}

// ErrFallbackNotConfigured indicates that SABnzbd fallback is not enabled or configured.
ErrFallbackNotConfigured = &NonRetryableError{
message: "SABnzbd fallback not configured",
cause: nil,
}
)
Loading
Loading