Sync from /srv/compose/unified-media-manager

This commit is contained in:
Christopher Mayor
2026-04-24 10:45:19 -07:00
commit 7dbd00e537
132 changed files with 25394 additions and 0 deletions

427
internal/service/import.go Normal file
View File

@@ -0,0 +1,427 @@
package service
import (
"context"
"encoding/json"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"github.com/TopherMayor/unified-media-manager/internal/db"
"github.com/TopherMayor/unified-media-manager/internal/download"
)
type ImportResult struct {
MediaID int64 `json:"media_id"`
MediaType string `json:"media_type"`
SourcePath string `json:"source_path"`
DestPath string `json:"dest_path"`
FileSize int64 `json:"file_size"`
Quality string `json:"quality"`
Status string `json:"status"`
}
type ImportReport struct {
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Errors int `json:"errors"`
Results []ImportResult `json:"results"`
}
type ImportService struct {
db *db.DB
downloadClientSvc *DownloadClientService
namingSvc *NamingService
matcherSvc *MatcherService
mediaSvc *MediaService
parser *ReleaseParser
downloadDir string
subtitleSvc *SubtitleService
activitySvc *ActivityService
}
func NewImportService(database *db.DB, dcSvc *DownloadClientService, nSvc *NamingService, mSvc *MatcherService, mediaSvc *MediaService, downloadDir string, subtitleSvc *SubtitleService, activitySvc *ActivityService) *ImportService {
return &ImportService{
db: database,
downloadClientSvc: dcSvc,
namingSvc: nSvc,
matcherSvc: mSvc,
mediaSvc: mediaSvc,
parser: NewReleaseParser(),
downloadDir: downloadDir,
subtitleSvc: subtitleSvc,
activitySvc: activitySvc,
}
}
var mediaExts = 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,
}
func (s *ImportService) ProcessCompleted(ctx context.Context) (*ImportReport, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
report := &ImportReport{}
nzbClients, err := s.downloadClientSvc.GetAllEnabled(ctx, "nzb")
if err != nil {
slog.Error("failed to get nzb clients", "error", err)
}
torrentClients, err := s.downloadClientSvc.GetAllEnabled(ctx, "torrent")
if err != nil {
slog.Error("failed to get torrent clients", "error", err)
}
allClients := append(nzbClients, torrentClients...)
for _, client := range allClients {
completed, err := client.Client.GetCompleted(ctx)
if err != nil {
slog.Error("failed to get completed downloads", "error", err, "client", client.Config.Name)
continue
}
for _, dl := range completed {
s.processDownload(ctx, dl, client, report)
}
}
return report, nil
}
func (s *ImportService) processDownload(ctx context.Context, dl download.CompletedDownload, client DownloadClientWithInfo, report *ImportReport) {
var exists bool
err := s.db.Pool.QueryRow(ctx,
"SELECT EXISTS(SELECT 1 FROM media_files WHERE original_path = $1 AND deleted_at IS NULL)",
dl.OutputPath).Scan(&exists)
if err != nil {
slog.Error("failed to check existing import", "error", err, "path", dl.OutputPath)
report.Errors++
return
}
if exists {
report.Skipped++
return
}
files, err := s.findMediaFiles(dl.Name)
if err != nil {
slog.Error("failed to find media files", "error", err, "download", dl.Name)
report.Errors++
return
}
if len(files) == 0 {
slog.Warn("no media files found for download", "download", dl.Name)
report.Skipped++
return
}
for _, filePath := range files {
s.processFile(ctx, filePath, dl, client, report)
}
}
func (s *ImportService) processFile(ctx context.Context, sourcePath string, dl download.CompletedDownload, client DownloadClientWithInfo, report *ImportReport) {
fileCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
releaseInfo := s.parser.Parse(filepath.Base(sourcePath))
mediaType := "movie"
if _, _, hasSE := parseSeasonEpisode(filepath.Base(sourcePath)); hasSE {
mediaType = "series"
}
match, err := s.matcherSvc.Match(fileCtx, dl.Name, mediaType)
if err != nil {
slog.Error("failed to match release to media", "error", err, "release", dl.Name)
report.Errors++
return
}
if match.Confidence == "none" {
slog.Warn("no media match for release", "release", dl.Name, "path", sourcePath)
report.Skipped++
return
}
result, err := s.importFile(fileCtx, sourcePath, match, releaseInfo, dl, client)
if err != nil {
slog.Error("failed to import file", "error", err, "source", sourcePath)
report.Errors++
return
}
report.Imported++
report.Results = append(report.Results, *result)
}
func (s *ImportService) importFile(ctx context.Context, sourcePath string, match *MatchResult, releaseInfo ReleaseInfo, completed download.CompletedDownload, client DownloadClientWithInfo) (*ImportResult, error) {
status := "importing"
err := s.mediaSvc.Update(ctx, match.MediaID, match.MediaType, UpdateMediaRequest{
Status: &status,
})
if err != nil {
return nil, fmt.Errorf("update media status to importing: %w", err)
}
qualityTier := s.parser.MatchQuality(releaseInfo)
qualityJSON, _ := json.Marshal(qualityTier)
year := 0
if match.Year != nil {
year = *match.Year
}
season := 0
if match.Season != nil {
season = *match.Season
}
episode := 0
if match.Episode != nil {
episode = *match.Episode
}
namingData := NamingData{
Title: match.Title,
Year: year,
Season: season,
Episode: episode,
Quality: qualityTier.Name,
Ext: ExtractExt(filepath.Base(sourcePath)),
ReleaseGroup: releaseInfo.ReleaseGroup,
Resolution: releaseInfo.Resolution,
Source: releaseInfo.Source,
Codec: releaseInfo.VideoCodec,
}
relativePath, err := s.namingSvc.Render(ctx, match.MediaType, namingData)
if err != nil {
s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed")
s.logImportError(match.MediaID, match.MediaType, fmt.Sprintf("Naming template failed for media %d: %v", match.MediaID, err))
return nil, fmt.Errorf("render naming template: %w", err)
}
targetPath := filepath.Join(match.RootFolder, relativePath)
if !strings.HasPrefix(filepath.Clean(targetPath), filepath.Clean(match.RootFolder)) {
s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed")
return nil, fmt.Errorf("path traversal detected: target path escapes root folder")
}
targetDir := filepath.Dir(targetPath)
if err := os.MkdirAll(targetDir, 0755); err != nil {
s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed")
return nil, fmt.Errorf("create target directory: %w", err)
}
if err := os.Link(sourcePath, targetPath); err != nil {
s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed")
s.logImportError(match.MediaID, match.MediaType, fmt.Sprintf("Hardlink failed for media %d: %v", match.MediaID, err))
return nil, fmt.Errorf("hardlink file: %w", err)
}
srcInfo, err := os.Stat(sourcePath)
if err != nil {
s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed")
return nil, fmt.Errorf("stat source file: %w", err)
}
dstInfo, err := os.Stat(targetPath)
if err != nil {
s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed")
return nil, fmt.Errorf("stat target file: %w", err)
}
if !os.SameFile(srcInfo, dstInfo) {
s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed")
return nil, fmt.Errorf("hardlink verification failed: files are not the same inode")
}
fileSize := dstInfo.Size()
if s.subtitleSvc != nil {
extractCtx, extractCancel := context.WithTimeout(ctx, 30*time.Second)
baseName := s.buildImportSubtitleBaseName(match, releaseInfo)
extracted, err := s.subtitleSvc.ExtractSubtitles(extractCtx, targetPath, filepath.Dir(targetPath), baseName)
if err != nil {
slog.Error("failed to extract subtitles", "error", err, "path", targetPath)
}
if len(extracted) > 0 {
slog.Info("extracted subtitles", "count", len(extracted), "media_id", match.MediaID)
}
extractCancel()
}
_, err = s.db.Pool.Exec(ctx,
`INSERT INTO media_files (media_id, media_type, path, original_path, file_name, file_size, quality, codec, resolution, source, is_hardlinked)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
match.MediaID, match.MediaType, targetPath, sourcePath, filepath.Base(targetPath),
fileSize, qualityJSON, ptrStr(releaseInfo.VideoCodec), ptrStr(releaseInfo.Resolution),
ptrStr(releaseInfo.Source), true)
if err != nil {
s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed")
return nil, fmt.Errorf("insert media file record: %w", err)
}
availableStatus := "available"
err = s.mediaSvc.Update(ctx, match.MediaID, match.MediaType, UpdateMediaRequest{
Status: &availableStatus,
CurrentQuality: qualityJSON,
})
if err != nil {
slog.Error("failed to update media status to available", "error", err, "media_id", match.MediaID)
}
if _, err := s.db.Pool.Exec(ctx, `UPDATE media SET has_files = true WHERE id = $1`, match.MediaID); err != nil {
slog.Error("failed to update has_files", "error", err, "media_id", match.MediaID)
}
// Log successful import activity
if s.activitySvc != nil {
s.activitySvc.LogAsync(LogEntry{
EventType: "import",
MediaID: &match.MediaID,
MediaType: &match.MediaType,
Title: fmt.Sprintf("Imported %s", filepath.Base(sourcePath)),
Data: json.RawMessage(fmt.Sprintf(`{"source":"%s","dest":"%s","quality":"%s","size":%d}`,
sourcePath, targetPath, qualityTier.Name, fileSize)),
})
}
_, err = s.db.Pool.Exec(ctx,
`UPDATE download_queue SET status = 'imported', completed_at = NOW()
WHERE media_id = $1 AND release_title = $2 AND status IN ('downloading', 'pending')`,
match.MediaID, completed.Name)
if err != nil {
slog.Error("failed to update download queue", "error", err, "media_id", match.MediaID)
}
if err := client.Client.Remove(ctx, completed.ID); err != nil {
slog.Warn("failed to remove download client entry", "error", err, "id", completed.ID)
}
return &ImportResult{
MediaID: match.MediaID,
MediaType: match.MediaType,
SourcePath: sourcePath,
DestPath: targetPath,
FileSize: fileSize,
Quality: qualityTier.Name,
Status: "imported",
}, nil
}
func (s *ImportService) rollbackStatus(ctx context.Context, mediaID int64, mediaType string, status string) {
if err := s.mediaSvc.Update(ctx, mediaID, mediaType, UpdateMediaRequest{Status: &status}); err != nil {
slog.Error("failed to rollback media status", "error", err, "media_id", mediaID)
}
}
func (s *ImportService) findMediaFiles(downloadName string) ([]string, error) {
downloadPath := filepath.Join(s.downloadDir, downloadName)
cleanBase := filepath.Clean(s.downloadDir)
info, err := os.Stat(downloadPath)
if err != nil {
entries, err := os.ReadDir(s.downloadDir)
if err != nil {
return nil, fmt.Errorf("read download directory: %w", err)
}
for _, entry := range entries {
candidate := filepath.Join(s.downloadDir, entry.Name())
if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(downloadName)) {
if entry.IsDir() {
return s.walkMediaDir(candidate, cleanBase)
}
if mediaExts[filepath.Ext(entry.Name())] {
return []string{candidate}, nil
}
}
}
return nil, nil
}
if !strings.HasPrefix(filepath.Clean(downloadPath), cleanBase) {
return nil, fmt.Errorf("path traversal detected: download path escapes download dir")
}
if info.IsDir() {
return s.walkMediaDir(downloadPath, cleanBase)
}
if mediaExts[filepath.Ext(downloadPath)] {
return []string{downloadPath}, nil
}
return nil, nil
}
func (s *ImportService) walkMediaDir(dir string, cleanBase string) ([]string, error) {
var files []string
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !strings.HasPrefix(filepath.Clean(path), cleanBase) {
return fmt.Errorf("path traversal detected: walked path escapes download dir")
}
if d.IsDir() {
return nil
}
if mediaExts[filepath.Ext(path)] {
files = append(files, path)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("walk download directory: %w", err)
}
return files, nil
}
func (s *ImportService) buildImportSubtitleBaseName(match *MatchResult, info ReleaseInfo) string {
parts := []string{sanitize(match.Title)}
if match.Year != nil {
parts = append(parts, fmt.Sprintf("%d", *match.Year))
}
if match.Season != nil && match.Episode != nil {
parts = append(parts, fmt.Sprintf("S%02dE%02d", *match.Season, *match.Episode))
}
return strings.Join(parts, ".")
}
func ptrStr(s string) *string {
if s == "" {
return nil
}
return &s
}
func (s *ImportService) logImportError(mediaID int64, mediaType string, msg string) {
if s.activitySvc != nil {
s.activitySvc.LogAsync(LogEntry{
EventType: "error",
Title: msg,
MediaID: &mediaID,
MediaType: &mediaType,
})
}
}