🏡 index : quine-global/quintodrome.git

package cmd

import (
	"bufio"
	"context"
	"encoding/gob"
	"errors"
	"fmt"
	"os"
	"strings"

	"github.com/navidrome/navidrome/core/artwork"
	"github.com/navidrome/navidrome/core/playlists"
	"github.com/navidrome/navidrome/db"
	"github.com/navidrome/navidrome/log"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/persistence"
	"github.com/navidrome/navidrome/scanner"
	"github.com/navidrome/navidrome/utils/pl"
	"github.com/spf13/cobra"
)

var (
	fullScan   bool
	subprocess bool
	targets    []string
	targetFile string
)

func init() {
	scanCmd.Flags().BoolVarP(&fullScan, "full", "f", false, "check all subfolders, ignoring timestamps")
	scanCmd.Flags().BoolVarP(&subprocess, "subprocess", "", false, "run as subprocess (internal use)")
	scanCmd.Flags().StringArrayVarP(&targets, "target", "t", []string{}, "list of libraryID:folderPath pairs, can be repeated (e.g., \"-t 1:Music/Rock -t 1:Music/Jazz -t 2:Classical\")")
	scanCmd.Flags().StringVar(&targetFile, "target-file", "", "path to file containing targets (one libraryID:folderPath per line)")
	rootCmd.AddCommand(scanCmd)
}

var scanCmd = &cobra.Command{
	Use:   "scan",
	Short: "Scan music folder",
	Long:  "Scan music folder for updates",
	Run: func(cmd *cobra.Command, args []string) {
		runScanner(cmd.Context())
	},
}

func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) (bool, error) {
	var changesDetected bool
	var scanErrors []error
	for status := range pl.ReadOrDone(ctx, progress) {
		if status.Warning != "" {
			log.Warn(ctx, "Scan warning", "error", status.Warning)
		}
		if status.Error != "" {
			log.Error(ctx, "Scan error", "error", status.Error)
			scanErrors = append(scanErrors, errors.New(status.Error))
		}
		if status.ChangesDetected {
			changesDetected = true
		}
	}

	if fullScan {
		log.Info("Finished full rescan")
	} else {
		log.Info("Finished rescan")
	}
	return changesDetected, errors.Join(scanErrors...)
}

func trackScanAsSubprocess(ctx context.Context, progress <-chan *scanner.ProgressInfo) {
	encoder := gob.NewEncoder(os.Stdout)
	for status := range pl.ReadOrDone(ctx, progress) {
		err := encoder.Encode(status)
		if err != nil {
			log.Error(ctx, "Failed to encode status", err)
		}
	}
}

func runScanner(ctx context.Context) {
	sqlDB := db.Db()
	defer db.Db().Close()
	ds := persistence.New(sqlDB)
	pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds))

	// Parse targets from command line or file
	var scanTargets []model.ScanTarget
	var err error

	if targetFile != "" {
		scanTargets, err = readTargetsFromFile(targetFile)
		if err != nil {
			log.Fatal(ctx, "Failed to read targets from file", err)
		}
		log.Info(ctx, "Scanning specific folders from file", "numTargets", len(scanTargets))
	} else if len(targets) > 0 {
		scanTargets, err = model.ParseTargets(targets)
		if err != nil {
			log.Fatal(ctx, "Failed to parse targets", err)
		}
		log.Info(ctx, "Scanning specific folders", "numTargets", len(scanTargets))
	}

	effectiveFullScan := fullScan
	if !subprocess {
		effectiveFullScan = scanner.EffectiveFullScan(ctx, ds, fullScan, scanTargets)
		if effectiveFullScan {
			if err := db.MarkOptimizePending(ctx); err != nil {
				log.Error(ctx, "Error marking DB analysis pending", err)
			}
		}
	}

	progress, err := scanner.CallScan(ctx, ds, pls, fullScan, scanTargets)
	if err != nil {
		log.Fatal(ctx, "Failed to scan", err)
	}

	// Wait for the scanner to finish
	if subprocess {
		trackScanAsSubprocess(ctx, progress)
	} else {
		changesDetected, scanErr := trackScanInteractively(ctx, progress)
		runPostScanAnalysis(ctx, changesDetected, effectiveFullScan, scanErr)
	}
}

func runPostScanAnalysis(ctx context.Context, changesDetected, effectiveFullScan bool, scanErr error) {
	if changesDetected {
		if err := db.MarkOptimizePending(ctx); err != nil {
			log.Error(ctx, "Error marking DB analysis pending", err)
		}
	}
	if effectiveFullScan && scanErr == nil {
		if err := db.Optimize(ctx); err != nil {
			log.Error(ctx, "Error analyzing DB", err)
		}
	}
}

// readTargetsFromFile reads scan targets from a file, one per line.
// Each line should be in the format "libraryID:folderPath".
// Empty lines and lines starting with # are ignored.
func readTargetsFromFile(filePath string) ([]model.ScanTarget, error) {
	file, err := os.Open(filePath)
	if err != nil {
		return nil, fmt.Errorf("failed to open target file: %w", err)
	}
	defer file.Close()

	var targetStrings []string
	scanner := bufio.NewScanner(file)
	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())
		// Skip empty lines and comments
		if line == "" {
			continue
		}
		targetStrings = append(targetStrings, line)
	}

	if err := scanner.Err(); err != nil {
		return nil, fmt.Errorf("failed to read target file: %w", err)
	}

	return model.ParseTargets(targetStrings)
}