Sync from /srv/compose/unified-media-manager
This commit is contained in:
87
internal/worker/cleanup.go
Normal file
87
internal/worker/cleanup.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TopherMayor/unified-media-manager/internal/config"
|
||||
"github.com/TopherMayor/unified-media-manager/internal/db"
|
||||
)
|
||||
|
||||
type CleanupWorker struct {
|
||||
database *db.DB
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewCleanupWorker(database *db.DB, cfg *config.Config) *CleanupWorker {
|
||||
return &CleanupWorker{
|
||||
database: database,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *CleanupWorker) Name() string {
|
||||
return "cleanup"
|
||||
}
|
||||
|
||||
func (w *CleanupWorker) CronExpr() string {
|
||||
return w.cfg.WorkerCleanupInterval
|
||||
}
|
||||
|
||||
func (w *CleanupWorker) Run(ctx context.Context) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
partitionsDropped := 0
|
||||
|
||||
rows, err := w.database.Pool.Query(ctx,
|
||||
`SELECT inhrelid::regclass::text FROM pg_inherits
|
||||
JOIN pg_class ON (inhrelid = oid)
|
||||
WHERE inhparent = 'download_history'::regclass`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("query download_history partitions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
cutoff := time.Now().AddDate(0, 0, -90)
|
||||
for rows.Next() {
|
||||
var partName string
|
||||
if err := rows.Scan(&partName); err != nil {
|
||||
slog.Error("failed to scan partition name", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
partLower := strings.ToLower(partName)
|
||||
if strings.HasPrefix(partLower, "download_history_") {
|
||||
dateStr := strings.TrimPrefix(partLower, "download_history_")
|
||||
partTime, err := time.Parse("2006_01_02", dateStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if partTime.Before(cutoff) {
|
||||
_, err := w.database.Pool.Exec(ctx, fmt.Sprintf("DROP TABLE IF EXISTS %s", partName))
|
||||
if err != nil {
|
||||
slog.Error("failed to drop partition", "partition", partName, "error", err)
|
||||
continue
|
||||
}
|
||||
partitionsDropped++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tag, err := w.database.Pool.Exec(ctx,
|
||||
"DELETE FROM task_executions WHERE started_at < NOW() - INTERVAL '7 days'")
|
||||
if err != nil {
|
||||
slog.Error("failed to clean old task executions", "error", err)
|
||||
}
|
||||
executionsCleaned := tag.RowsAffected()
|
||||
|
||||
slog.Info("cleanup completed",
|
||||
"partitions_dropped", partitionsDropped,
|
||||
"executions_cleaned", executionsCleaned)
|
||||
|
||||
return nil
|
||||
}
|
||||
69
internal/worker/disk.go
Normal file
69
internal/worker/disk.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"syscall"
|
||||
|
||||
"github.com/TopherMayor/unified-media-manager/internal/config"
|
||||
"github.com/TopherMayor/unified-media-manager/internal/db"
|
||||
)
|
||||
|
||||
type DiskUsageWorker struct {
|
||||
database *db.DB
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewDiskUsageWorker(database *db.DB, cfg *config.Config) *DiskUsageWorker {
|
||||
return &DiskUsageWorker{
|
||||
database: database,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *DiskUsageWorker) Name() string {
|
||||
return "disk_usage"
|
||||
}
|
||||
|
||||
func (w *DiskUsageWorker) CronExpr() string {
|
||||
return w.cfg.WorkerDiskUsageInterval
|
||||
}
|
||||
|
||||
func (w *DiskUsageWorker) Run(ctx context.Context) error {
|
||||
rows, err := w.database.Pool.Query(ctx, "SELECT id, path FROM root_folders")
|
||||
if err != nil {
|
||||
return fmt.Errorf("query root folders: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
updated := 0
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var path string
|
||||
if err := rows.Scan(&id, &path); err != nil {
|
||||
slog.Error("failed to scan root folder", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &stat); err != nil {
|
||||
slog.Error("failed to stat filesystem", "path", path, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
freeSpace := int64(stat.Bavail) * int64(stat.Bsize)
|
||||
|
||||
_, err := w.database.Pool.Exec(ctx,
|
||||
"UPDATE root_folders SET free_space = $1 WHERE id = $2",
|
||||
freeSpace, id)
|
||||
if err != nil {
|
||||
slog.Error("failed to update free space", "id", id, "error", err)
|
||||
continue
|
||||
}
|
||||
updated++
|
||||
}
|
||||
|
||||
slog.Info("disk usage updated", "folders_updated", updated)
|
||||
return nil
|
||||
}
|
||||
69
internal/worker/health.go
Normal file
69
internal/worker/health.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/TopherMayor/unified-media-manager/internal/config"
|
||||
"github.com/TopherMayor/unified-media-manager/internal/service"
|
||||
)
|
||||
|
||||
type HealthChecker struct {
|
||||
indexerSvc *service.IndexerService
|
||||
dcSvc *service.DownloadClientService
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewHealthChecker(indexerSvc *service.IndexerService, dcSvc *service.DownloadClientService, cfg *config.Config) *HealthChecker {
|
||||
return &HealthChecker{
|
||||
indexerSvc: indexerSvc,
|
||||
dcSvc: dcSvc,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *HealthChecker) Name() string {
|
||||
return "health_check"
|
||||
}
|
||||
|
||||
func (w *HealthChecker) CronExpr() string {
|
||||
return w.cfg.WorkerHealthCheckInterval
|
||||
}
|
||||
|
||||
func (w *HealthChecker) Run(ctx context.Context) error {
|
||||
healthy := 0
|
||||
unhealthy := 0
|
||||
|
||||
indexers, err := w.indexerSvc.List(ctx)
|
||||
if err != nil {
|
||||
slog.Error("failed to list indexers", "error", err)
|
||||
} else {
|
||||
for _, idx := range indexers {
|
||||
_, testErr := w.indexerSvc.Test(ctx, idx.ID)
|
||||
if testErr != nil {
|
||||
unhealthy++
|
||||
slog.Warn("indexer health check failed", "id", idx.ID, "name", idx.Name, "error", testErr)
|
||||
} else {
|
||||
healthy++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clients, err := w.dcSvc.List(ctx)
|
||||
if err != nil {
|
||||
slog.Error("failed to list download clients", "error", err)
|
||||
} else {
|
||||
for _, dc := range clients {
|
||||
_, testErr := w.dcSvc.Test(ctx, dc.ID)
|
||||
if testErr != nil {
|
||||
unhealthy++
|
||||
slog.Warn("download client health check failed", "id", dc.ID, "name", dc.Name, "error", testErr)
|
||||
} else {
|
||||
healthy++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("health check completed", "healthy", healthy, "unhealthy", unhealthy)
|
||||
return nil
|
||||
}
|
||||
182
internal/worker/library_scanner.go
Normal file
182
internal/worker/library_scanner.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TopherMayor/unified-media-manager/internal/config"
|
||||
"github.com/TopherMayor/unified-media-manager/internal/db"
|
||||
"github.com/TopherMayor/unified-media-manager/internal/service"
|
||||
)
|
||||
|
||||
var scannerMediaExts = map[string]bool{
|
||||
".mkv": true,
|
||||
".mp4": true,
|
||||
".avi": true,
|
||||
".wmv": true,
|
||||
".flv": true,
|
||||
".webm": true,
|
||||
".mp3": true,
|
||||
".flac": true,
|
||||
".m4a": true,
|
||||
".m4b": true,
|
||||
".ogg": true,
|
||||
".opus": true,
|
||||
".epub": true,
|
||||
".pdf": true,
|
||||
".mobi": true,
|
||||
".azw3": true,
|
||||
}
|
||||
|
||||
type LibraryScanner struct {
|
||||
database *db.DB
|
||||
matcherSvc *service.MatcherService
|
||||
mediaSvc *service.MediaService
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewLibraryScanner(database *db.DB, matcherSvc *service.MatcherService, mediaSvc *service.MediaService, cfg *config.Config) *LibraryScanner {
|
||||
return &LibraryScanner{
|
||||
database: database,
|
||||
matcherSvc: matcherSvc,
|
||||
mediaSvc: mediaSvc,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *LibraryScanner) Name() string {
|
||||
return "library_scanner"
|
||||
}
|
||||
|
||||
func (w *LibraryScanner) CronExpr() string {
|
||||
return w.cfg.WorkerLibraryScanInterval
|
||||
}
|
||||
|
||||
func (w *LibraryScanner) Run(ctx context.Context) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
scanned := 0
|
||||
tracked := 0
|
||||
matched := 0
|
||||
unmatched := 0
|
||||
|
||||
rows, err := w.database.Pool.Query(ctx, "SELECT id, path, media_type FROM root_folders")
|
||||
if err != nil {
|
||||
return fmt.Errorf("query root folders: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type rootFolder struct {
|
||||
id int64
|
||||
path string
|
||||
mediaType string
|
||||
}
|
||||
var roots []rootFolder
|
||||
for rows.Next() {
|
||||
var r rootFolder
|
||||
if err := rows.Scan(&r.id, &r.path, &r.mediaType); err != nil {
|
||||
slog.Error("failed to scan root folder", "error", err)
|
||||
continue
|
||||
}
|
||||
roots = append(roots, r)
|
||||
}
|
||||
|
||||
for _, root := range roots {
|
||||
cleanRoot := filepath.Clean(root.path)
|
||||
|
||||
err := filepath.WalkDir(root.path, func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(filepath.Clean(path), cleanRoot) {
|
||||
return nil
|
||||
}
|
||||
|
||||
ext := filepath.Ext(path)
|
||||
if !scannerMediaExts[ext] {
|
||||
return nil
|
||||
}
|
||||
|
||||
scanned++
|
||||
|
||||
var exists bool
|
||||
checkErr := w.database.Pool.QueryRow(ctx,
|
||||
"SELECT EXISTS(SELECT 1 FROM media_files WHERE path = $1 AND deleted_at IS NULL)",
|
||||
path).Scan(&exists)
|
||||
if checkErr != nil {
|
||||
slog.Error("failed to check tracked file", "path", path, "error", checkErr)
|
||||
return nil
|
||||
}
|
||||
if exists {
|
||||
tracked++
|
||||
return nil
|
||||
}
|
||||
|
||||
dirName := filepath.Base(filepath.Dir(path))
|
||||
fileName := filepath.Base(path)
|
||||
|
||||
searchName := dirName
|
||||
if searchName == filepath.Base(root.path) {
|
||||
searchName = fileName
|
||||
}
|
||||
|
||||
matchResult, matchErr := w.matcherSvc.Match(ctx, searchName, root.mediaType)
|
||||
if matchErr != nil {
|
||||
slog.Error("failed to match file", "path", path, "error", matchErr)
|
||||
unmatched++
|
||||
return nil
|
||||
}
|
||||
|
||||
if matchResult.Confidence == "none" {
|
||||
unmatched++
|
||||
return nil
|
||||
}
|
||||
|
||||
fileInfo, statErr := os.Stat(path)
|
||||
if statErr != nil {
|
||||
slog.Error("failed to stat file", "path", path, "error", statErr)
|
||||
unmatched++
|
||||
return nil
|
||||
}
|
||||
|
||||
_, insertErr := w.database.Pool.Exec(ctx,
|
||||
`INSERT INTO media_files (media_id, media_type, path, file_name, file_size, quality, is_hardlinked)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, false)`,
|
||||
matchResult.MediaID, matchResult.MediaType, path, filepath.Base(path),
|
||||
fileInfo.Size(), json.RawMessage("{}"))
|
||||
if insertErr != nil {
|
||||
slog.Error("failed to insert media file", "path", path, "error", insertErr)
|
||||
unmatched++
|
||||
return nil
|
||||
}
|
||||
|
||||
_, updateErr := w.database.Pool.Exec(ctx,
|
||||
"UPDATE media SET status = 'available' WHERE id = $1 AND status = 'unavailable'",
|
||||
matchResult.MediaID)
|
||||
if updateErr != nil {
|
||||
slog.Error("failed to update media status", "media_id", matchResult.MediaID, "error", updateErr)
|
||||
}
|
||||
|
||||
matched++
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("library scan walk error", "root", root.path, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("library scan completed", "scanned", scanned, "tracked", tracked, "matched", matched, "unmatched", unmatched)
|
||||
return nil
|
||||
}
|
||||
39
internal/worker/metadata_refresh.go
Normal file
39
internal/worker/metadata_refresh.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/TopherMayor/unified-media-manager/internal/config"
|
||||
"github.com/TopherMayor/unified-media-manager/internal/service"
|
||||
)
|
||||
|
||||
type MetadataRefreshWorker struct {
|
||||
metadataSvc *service.MetadataService
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewMetadataRefreshWorker(metadataSvc *service.MetadataService, cfg *config.Config) *MetadataRefreshWorker {
|
||||
return &MetadataRefreshWorker{
|
||||
metadataSvc: metadataSvc,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *MetadataRefreshWorker) Name() string {
|
||||
return "metadata_refresh"
|
||||
}
|
||||
|
||||
func (w *MetadataRefreshWorker) CronExpr() string {
|
||||
return w.cfg.WorkerMetadataInterval
|
||||
}
|
||||
|
||||
func (w *MetadataRefreshWorker) Run(ctx context.Context) error {
|
||||
if err := w.metadataSvc.RefreshAllMetadata(ctx); err != nil {
|
||||
slog.Error("metadata refresh failed", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Info("metadata refresh completed")
|
||||
return nil
|
||||
}
|
||||
131
internal/worker/queue.go
Normal file
131
internal/worker/queue.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/TopherMayor/unified-media-manager/internal/config"
|
||||
"github.com/TopherMayor/unified-media-manager/internal/db"
|
||||
"github.com/TopherMayor/unified-media-manager/internal/service"
|
||||
)
|
||||
|
||||
type QueueProcessor struct {
|
||||
database *db.DB
|
||||
importSvc *service.ImportService
|
||||
dcSvc *service.DownloadClientService
|
||||
cfg *config.Config
|
||||
activitySvc *service.ActivityService
|
||||
}
|
||||
|
||||
func NewQueueProcessor(database *db.DB, importSvc *service.ImportService, dcSvc *service.DownloadClientService, cfg *config.Config, activitySvc *service.ActivityService) *QueueProcessor {
|
||||
return &QueueProcessor{
|
||||
database: database,
|
||||
importSvc: importSvc,
|
||||
dcSvc: dcSvc,
|
||||
cfg: cfg,
|
||||
activitySvc: activitySvc,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *QueueProcessor) Name() string {
|
||||
return "queue_processor"
|
||||
}
|
||||
|
||||
func (w *QueueProcessor) CronExpr() string {
|
||||
return w.cfg.WorkerQueueInterval
|
||||
}
|
||||
|
||||
func (w *QueueProcessor) Run(ctx context.Context) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
progressUpdated := 0
|
||||
errors := 0
|
||||
|
||||
nzbClients, err := w.dcSvc.GetAllEnabled(ctx, "nzb")
|
||||
if err != nil {
|
||||
slog.Error("failed to get nzb clients", "error", err)
|
||||
}
|
||||
torrentClients, err := w.dcSvc.GetAllEnabled(ctx, "torrent")
|
||||
if err != nil {
|
||||
slog.Error("failed to get torrent clients", "error", err)
|
||||
}
|
||||
|
||||
allClients := append(nzbClients, torrentClients...)
|
||||
|
||||
for _, client := range allClients {
|
||||
rows, err := w.database.Pool.Query(ctx,
|
||||
`SELECT id, download_id FROM download_queue
|
||||
WHERE status = 'downloading' AND download_client = $1 AND download_id IS NOT NULL AND download_id != ''`,
|
||||
client.Config.Name)
|
||||
if err != nil {
|
||||
slog.Error("failed to query active downloads", "client", client.Config.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
type queueItem struct {
|
||||
id int64
|
||||
downloadID string
|
||||
}
|
||||
var items []queueItem
|
||||
for rows.Next() {
|
||||
var item queueItem
|
||||
if err := rows.Scan(&item.id, &item.downloadID); err != nil {
|
||||
slog.Error("failed to scan queue item", "error", err)
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, item := range items {
|
||||
progress, err := client.Client.GetProgress(ctx, item.downloadID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get download progress", "id", item.downloadID, "error", err)
|
||||
errors++
|
||||
continue
|
||||
}
|
||||
|
||||
// Detect failed downloads
|
||||
if progress.Status == "failed" || progress.Status == "error" {
|
||||
if w.activitySvc != nil {
|
||||
w.activitySvc.LogAsync(service.LogEntry{
|
||||
EventType: "download_failed",
|
||||
Title: fmt.Sprintf("Download failed: %s", item.downloadID),
|
||||
Data: []byte(fmt.Sprintf(`{"queue_id":%d,"download_id":"%s"}`, item.id, item.downloadID)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_, err = w.database.Pool.Exec(ctx,
|
||||
"UPDATE download_queue SET progress = $1, updated_at = NOW() WHERE id = $2",
|
||||
progress.Progress, item.id)
|
||||
if err != nil {
|
||||
slog.Error("failed to update progress", "id", item.id, "error", err)
|
||||
errors++
|
||||
continue
|
||||
}
|
||||
progressUpdated++
|
||||
}
|
||||
}
|
||||
|
||||
report, err := w.importSvc.ProcessCompleted(ctx)
|
||||
if err != nil {
|
||||
slog.Error("failed to process completed downloads", "error", err)
|
||||
errors++
|
||||
}
|
||||
|
||||
imported := 0
|
||||
if report != nil {
|
||||
imported = report.Imported
|
||||
}
|
||||
|
||||
slog.Info("queue processor completed",
|
||||
"progress_updated", progressUpdated,
|
||||
"completed_imported", imported,
|
||||
"errors", errors)
|
||||
|
||||
return nil
|
||||
}
|
||||
197
internal/worker/rss_sync.go
Normal file
197
internal/worker/rss_sync.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/TopherMayor/unified-media-manager/internal/config"
|
||||
"github.com/TopherMayor/unified-media-manager/internal/db"
|
||||
"github.com/TopherMayor/unified-media-manager/internal/service"
|
||||
)
|
||||
|
||||
type RSSSyncWorker struct {
|
||||
database *db.DB
|
||||
mediaSvc *service.MediaService
|
||||
searchSvc *service.SearchService
|
||||
dcSvc *service.DownloadClientService
|
||||
qualitySvc *service.QualityService
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewRSSSyncWorker(database *db.DB, mediaSvc *service.MediaService, searchSvc *service.SearchService, dcSvc *service.DownloadClientService, qualitySvc *service.QualityService, cfg *config.Config) *RSSSyncWorker {
|
||||
return &RSSSyncWorker{
|
||||
database: database,
|
||||
mediaSvc: mediaSvc,
|
||||
searchSvc: searchSvc,
|
||||
dcSvc: dcSvc,
|
||||
qualitySvc: qualitySvc,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *RSSSyncWorker) Name() string {
|
||||
return "rss_sync"
|
||||
}
|
||||
|
||||
func (w *RSSSyncWorker) CronExpr() string {
|
||||
return w.cfg.WorkerRSSSyncInterval
|
||||
}
|
||||
|
||||
func (w *RSSSyncWorker) Run(ctx context.Context) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
searched := 0
|
||||
grabs := 0
|
||||
errors := 0
|
||||
|
||||
seen := make(map[int64]bool)
|
||||
|
||||
missing, _, err := w.mediaSvc.SearchMissing(ctx, service.MediaFilters{PageSize: 100})
|
||||
if err != nil {
|
||||
slog.Error("failed to search missing media", "error", err)
|
||||
errors++
|
||||
}
|
||||
for i := range missing {
|
||||
seen[missing[i].ID] = true
|
||||
}
|
||||
|
||||
upgrades, _, err := w.mediaSvc.SearchUpgrades(ctx, service.MediaFilters{PageSize: 100})
|
||||
if err != nil {
|
||||
slog.Error("failed to search upgrade media", "error", err)
|
||||
errors++
|
||||
}
|
||||
for i := range upgrades {
|
||||
seen[upgrades[i].ID] = true
|
||||
}
|
||||
|
||||
var allItems []service.Media
|
||||
for _, m := range missing {
|
||||
allItems = append(allItems, m)
|
||||
}
|
||||
for _, m := range upgrades {
|
||||
if !seen[m.ID] {
|
||||
allItems = append(allItems, m)
|
||||
seen[m.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range allItems {
|
||||
var hasPending bool
|
||||
err := w.database.Pool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM download_queue WHERE media_id = $1 AND status IN ('pending', 'downloading'))`,
|
||||
item.ID).Scan(&hasPending)
|
||||
if err != nil {
|
||||
slog.Error("failed to check pending queue", "media_id", item.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
if hasPending {
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = w.database.Pool.Exec(ctx,
|
||||
"UPDATE media SET last_search_at = NOW() WHERE id = $1", item.ID)
|
||||
if err != nil {
|
||||
slog.Error("failed to update last_search_at", "media_id", item.ID, "error", err)
|
||||
}
|
||||
|
||||
results, err := w.searchSvc.Search(ctx, service.SearchRequest{
|
||||
Query: item.Title,
|
||||
MediaType: item.MediaType,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("failed to search indexers", "media_id", item.ID, "title", item.Title, "error", err)
|
||||
errors++
|
||||
searched++
|
||||
continue
|
||||
}
|
||||
searched++
|
||||
|
||||
if len(results) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var filtered []service.SearchResult
|
||||
for _, r := range results {
|
||||
var blocked bool
|
||||
err := w.database.Pool.QueryRow(ctx,
|
||||
"SELECT EXISTS(SELECT 1 FROM blocklist WHERE release_title = $1)", r.Title).Scan(&blocked)
|
||||
if err != nil {
|
||||
slog.Error("failed to check blocklist", "error", err)
|
||||
continue
|
||||
}
|
||||
if blocked {
|
||||
continue
|
||||
}
|
||||
|
||||
if item.QualityProfileID != nil {
|
||||
profile, err := w.qualitySvc.GetByID(ctx, *item.QualityProfileID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get quality profile", "profile_id", *item.QualityProfileID, "error", err)
|
||||
filtered = append(filtered, r)
|
||||
continue
|
||||
}
|
||||
|
||||
if r.QualityTier != nil {
|
||||
allowed := false
|
||||
for _, name := range profile.AllowedQualities {
|
||||
if name == r.QualityTier.Name {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
best := filtered[0]
|
||||
for _, r := range filtered[1:] {
|
||||
bestRank := 0
|
||||
if best.QualityTier != nil {
|
||||
bestRank = best.QualityTier.Rank
|
||||
}
|
||||
rank := 0
|
||||
if r.QualityTier != nil {
|
||||
rank = r.QualityTier.Rank
|
||||
}
|
||||
if rank > bestRank || (rank == bestRank && r.Size > best.Size) {
|
||||
best = r
|
||||
}
|
||||
}
|
||||
|
||||
_, err = w.searchSvc.Grab(ctx, service.GrabRequest{
|
||||
DownloadURL: best.DownloadURL,
|
||||
Title: best.Title,
|
||||
MediaType: item.MediaType,
|
||||
Quality: best.Quality,
|
||||
IndexerName: best.IndexerName,
|
||||
MediaID: item.ID,
|
||||
}, w.dcSvc)
|
||||
if err != nil {
|
||||
slog.Error("failed to auto-grab release", "media_id", item.ID, "release", best.Title, "error", err)
|
||||
errors++
|
||||
continue
|
||||
}
|
||||
|
||||
status := "searching"
|
||||
if err := w.mediaSvc.Update(ctx, item.ID, item.MediaType, service.UpdateMediaRequest{Status: &status}); err != nil {
|
||||
slog.Error("failed to update media status", "media_id", item.ID, "error", err)
|
||||
}
|
||||
|
||||
slog.Info("auto-grabbed release", "media", item.Title, "release", best.Title, "quality", best.QualityTier.Name)
|
||||
grabs++
|
||||
}
|
||||
|
||||
slog.Info("rss sync completed", "searched", searched, "grabs", grabs, "errors", errors)
|
||||
return nil
|
||||
}
|
||||
236
internal/worker/scheduler.go
Normal file
236
internal/worker/scheduler.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/TopherMayor/unified-media-manager/internal/db"
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
type Worker interface {
|
||||
Name() string
|
||||
CronExpr() string
|
||||
Run(ctx context.Context) error
|
||||
}
|
||||
|
||||
type Scheduler struct {
|
||||
cron *cron.Cron
|
||||
database *db.DB
|
||||
workers map[string]Worker
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func NewScheduler(database *db.DB) *Scheduler {
|
||||
return &Scheduler{
|
||||
cron: cron.New(cron.WithSeconds()),
|
||||
database: database,
|
||||
workers: make(map[string]Worker),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) Register(w Worker) {
|
||||
s.workers[w.Name()] = w
|
||||
|
||||
_, err := s.database.Pool.Exec(context.Background(),
|
||||
`INSERT INTO scheduled_tasks (name, cron_expr, enabled)
|
||||
VALUES ($1, $2, true)
|
||||
ON CONFLICT (name) DO UPDATE SET cron_expr = EXCLUDED.cron_expr`,
|
||||
w.Name(), w.CronExpr())
|
||||
if err != nil {
|
||||
slog.Error("failed to seed scheduled task", "worker", w.Name(), "error", err)
|
||||
}
|
||||
|
||||
wrapper := s.runWithLogging(w)
|
||||
_, err = s.cron.AddFunc(w.CronExpr(), wrapper)
|
||||
if err != nil {
|
||||
slog.Error("failed to schedule worker", "worker", w.Name(), "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) runWithLogging(w Worker) func() {
|
||||
return func() {
|
||||
// Check if task is enabled before running
|
||||
var enabled bool
|
||||
err := s.database.Pool.QueryRow(context.Background(),
|
||||
"SELECT enabled FROM scheduled_tasks WHERE name = $1", w.Name()).Scan(&enabled)
|
||||
if err != nil {
|
||||
slog.Error("failed to check task enabled status", "worker", w.Name(), "error", err)
|
||||
return
|
||||
}
|
||||
if !enabled {
|
||||
slog.Debug("skipping disabled task", "worker", w.Name())
|
||||
return
|
||||
}
|
||||
|
||||
var taskID int
|
||||
err = s.database.Pool.QueryRow(context.Background(),
|
||||
"SELECT id FROM scheduled_tasks WHERE name = $1", w.Name()).Scan(&taskID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get task id", "worker", w.Name(), "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
var execID int64
|
||||
err = s.database.Pool.QueryRow(context.Background(),
|
||||
"INSERT INTO task_executions (task_id, status, started_at) VALUES ($1, 'running', NOW()) RETURNING id",
|
||||
taskID).Scan(&execID)
|
||||
if err != nil {
|
||||
slog.Error("failed to create execution record", "worker", w.Name(), "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
runErr := w.Run(s.ctx)
|
||||
duration := time.Since(start)
|
||||
|
||||
if runErr != nil {
|
||||
slog.Error("worker execution failed", "worker", w.Name(), "error", runErr, "duration_ms", duration.Milliseconds())
|
||||
_, _ = s.database.Pool.Exec(context.Background(),
|
||||
"UPDATE task_executions SET status = 'failed', ended_at = NOW(), duration_ms = $1, error = $2 WHERE id = $3",
|
||||
duration.Milliseconds(), runErr.Error(), execID)
|
||||
} else {
|
||||
slog.Info("worker execution completed", "worker", w.Name(), "duration_ms", duration.Milliseconds())
|
||||
_, _ = s.database.Pool.Exec(context.Background(),
|
||||
"UPDATE task_executions SET status = 'success', ended_at = NOW(), duration_ms = $1 WHERE id = $2",
|
||||
duration.Milliseconds(), execID)
|
||||
}
|
||||
|
||||
schedule, parseErr := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow).Parse(w.CronExpr())
|
||||
var nextRunAt time.Time
|
||||
if parseErr == nil {
|
||||
nextRunAt = schedule.Next(time.Now())
|
||||
}
|
||||
|
||||
_, _ = s.database.Pool.Exec(context.Background(),
|
||||
"UPDATE scheduled_tasks SET last_run_at = NOW(), next_run_at = $1 WHERE id = $2",
|
||||
nextRunAt, taskID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) TriggerWorker(name string) error {
|
||||
w, ok := s.workers[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("worker not found: %s", name)
|
||||
}
|
||||
|
||||
go s.runWithLogging(w)()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) GetWorkers() []ScheduledTaskInfo {
|
||||
rows, err := s.database.Pool.Query(context.Background(),
|
||||
"SELECT id, name, cron_expr, enabled, last_run_at, next_run_at FROM scheduled_tasks ORDER BY name")
|
||||
if err != nil {
|
||||
slog.Error("failed to query scheduled tasks", "error", err)
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tasks []ScheduledTaskInfo
|
||||
for rows.Next() {
|
||||
var t ScheduledTaskInfo
|
||||
var lastRunAt, nextRunAt *time.Time
|
||||
if err := rows.Scan(&t.ID, &t.Name, &t.CronExpr, &t.Enabled, &lastRunAt, &nextRunAt); err != nil {
|
||||
slog.Error("failed to scan scheduled task", "error", err)
|
||||
continue
|
||||
}
|
||||
if lastRunAt != nil {
|
||||
t.LastRunAt = lastRunAt
|
||||
}
|
||||
if nextRunAt != nil {
|
||||
t.NextRunAt = nextRunAt
|
||||
}
|
||||
tasks = append(tasks, t)
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
func (s *Scheduler) GetHistory(ctx context.Context, name string, page, pageSize int) ([]TaskExecution, int, error) {
|
||||
var total int
|
||||
err := s.database.Pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM task_executions te JOIN scheduled_tasks st ON te.task_id = st.id WHERE st.name = $1`,
|
||||
name).Scan(&total)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("count task executions: %w", err)
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
rows, err := s.database.Pool.Query(ctx,
|
||||
`SELECT te.id, te.status, te.started_at, te.ended_at, te.duration_ms, te.result, te.error
|
||||
FROM task_executions te JOIN scheduled_tasks st ON te.task_id = st.id
|
||||
WHERE st.name = $1 ORDER BY te.started_at DESC LIMIT $2 OFFSET $3`,
|
||||
name, pageSize, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("query task executions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var executions []TaskExecution
|
||||
for rows.Next() {
|
||||
var e TaskExecution
|
||||
var result []byte
|
||||
var execError *string
|
||||
if err := rows.Scan(&e.ID, &e.Status, &e.StartedAt, &e.EndedAt, &e.DurationMS, &result, &execError); err != nil {
|
||||
slog.Error("failed to scan task execution", "error", err)
|
||||
continue
|
||||
}
|
||||
if result != nil {
|
||||
e.Result = json.RawMessage(result)
|
||||
}
|
||||
if execError != nil {
|
||||
e.Error = *execError
|
||||
}
|
||||
executions = append(executions, e)
|
||||
}
|
||||
return executions, total, nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) SetEnabled(name string, enabled bool) error {
|
||||
tag, err := s.database.Pool.Exec(context.Background(),
|
||||
"UPDATE scheduled_tasks SET enabled = $1 WHERE name = $2", enabled, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update scheduled task enabled: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("scheduled task not found: %s", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) Start(ctx context.Context) {
|
||||
s.ctx, s.cancel = context.WithCancel(ctx)
|
||||
s.cron.Start()
|
||||
slog.Info("worker scheduler started")
|
||||
}
|
||||
|
||||
func (s *Scheduler) Stop() {
|
||||
s.cron.Stop()
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
}
|
||||
slog.Info("worker scheduler stopped")
|
||||
}
|
||||
|
||||
type ScheduledTaskInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CronExpr string `json:"cron_expr"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastRunAt *time.Time `json:"last_run_at,omitempty"`
|
||||
NextRunAt *time.Time `json:"next_run_at,omitempty"`
|
||||
}
|
||||
|
||||
type TaskExecution struct {
|
||||
ID int64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
EndedAt *time.Time `json:"ended_at,omitempty"`
|
||||
DurationMS *int64 `json:"duration_ms,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
112
internal/worker/subtitle_search.go
Normal file
112
internal/worker/subtitle_search.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/TopherMayor/unified-media-manager/internal/config"
|
||||
"github.com/TopherMayor/unified-media-manager/internal/db"
|
||||
"github.com/TopherMayor/unified-media-manager/internal/service"
|
||||
)
|
||||
|
||||
type SubtitleSearchWorker struct {
|
||||
database *db.DB
|
||||
subtitleSvc *service.SubtitleService
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewSubtitleSearchWorker(database *db.DB, subtitleSvc *service.SubtitleService, cfg *config.Config) *SubtitleSearchWorker {
|
||||
return &SubtitleSearchWorker{
|
||||
database: database,
|
||||
subtitleSvc: subtitleSvc,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *SubtitleSearchWorker) Name() string {
|
||||
return "subtitle_search"
|
||||
}
|
||||
|
||||
func (w *SubtitleSearchWorker) CronExpr() string {
|
||||
return w.cfg.WorkerSubtitleInterval
|
||||
}
|
||||
|
||||
func (w *SubtitleSearchWorker) Run(ctx context.Context) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
searched := 0
|
||||
downloaded := 0
|
||||
errors := 0
|
||||
|
||||
rows, err := w.database.Pool.Query(ctx,
|
||||
`SELECT m.id, m.media_type, m.title, mf.path
|
||||
FROM media m
|
||||
JOIN media_files mf ON m.id = mf.media_id AND mf.deleted_at IS NULL
|
||||
WHERE m.deleted_at IS NULL AND m.status = 'available'
|
||||
ORDER BY m.id`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type mediaFile struct {
|
||||
mediaID int64
|
||||
mediaType string
|
||||
title string
|
||||
path string
|
||||
}
|
||||
|
||||
var files []mediaFile
|
||||
for rows.Next() {
|
||||
var f mediaFile
|
||||
if err := rows.Scan(&f.mediaID, &f.mediaType, &f.title, &f.path); err != nil {
|
||||
slog.Error("failed to scan media file", "error", err)
|
||||
continue
|
||||
}
|
||||
files = append(files, f)
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
ext := filepath.Ext(f.path)
|
||||
basePath := f.path[:len(f.path)-len(ext)]
|
||||
srtPath := basePath + ".eng.srt"
|
||||
sdhPath := basePath + ".eng.sdh.srt"
|
||||
|
||||
if _, err := os.Stat(srtPath); err == nil {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(sdhPath); err == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
results, err := w.subtitleSvc.Search(ctx, f.title, service.SubtitleSearchOptions{
|
||||
LanguageCodes: []string{"eng"},
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("failed to search subtitles", "media_id", f.mediaID, "error", err)
|
||||
errors++
|
||||
continue
|
||||
}
|
||||
searched++
|
||||
|
||||
if len(results) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
baseName := filepath.Base(basePath)
|
||||
_, err = w.subtitleSvc.Download(ctx, results[0].ID, filepath.Dir(f.path), baseName, results[0].LanguageCode, results[0].HI, results[0].Forced)
|
||||
if err != nil {
|
||||
slog.Error("failed to download subtitle", "media_id", f.mediaID, "error", err)
|
||||
errors++
|
||||
continue
|
||||
}
|
||||
downloaded++
|
||||
}
|
||||
|
||||
slog.Info("subtitle search completed", "searched", searched, "downloaded", downloaded, "errors", errors)
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user