Git Repository

Mini Video Library

Mini Video Library scannt lokale Medienordner, verwaltet Metadaten und Vorschaubilder und streamt kompatible Videos direkt per HTTP an den Browser.

Projektseite ↗
HTTPShttps://zanvex.de/git/mini-video-library.git

internal/scanner/scanner.go

Zum Verzeichnis
package scanner

import (
	"context"
	"crypto/sha256"
	"database/sql"
	"encoding/hex"
	"encoding/json"
	"errors"
	"io"
	"log/slog"
	"minivideolib/internal/config"
	"minivideolib/internal/filenameparser"
	"minivideolib/internal/metadata"
	"minivideolib/internal/thumbnail"
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"sync"
	"sync/atomic"
	"time"
)

type Scanner struct {
	db       *sql.DB
	cfg      config.Config
	thumbs   *thumbnail.Generator
	log      *slog.Logger
	running  atomic.Bool
	mu       sync.RWMutex
	statusMu sync.RWMutex
	status   ScanStatus
	cancel   context.CancelFunc
}

type ScanStatus struct {
	Running    bool    `json:"running"`
	Phase      string  `json:"phase"`
	Discovered int64   `json:"discovered"`
	Processed  int64   `json:"processed"`
	Updated    int64   `json:"updated"`
	Errors     int64   `json:"errors"`
	Removed    int64   `json:"removed"`
	Percent    float64 `json:"percent"`
	Elapsed    int64   `json:"elapsed_seconds"`
	ETA        int64   `json:"eta_seconds"`
	StartedAt  int64   `json:"started_at"`
}

func New(db *sql.DB, c config.Config, t *thumbnail.Generator, l *slog.Logger) *Scanner {
	return &Scanner{db: db, cfg: c, thumbs: t, log: l}
}
func (s *Scanner) Running() bool { return s.running.Load() }
func (s *Scanner) Status() ScanStatus {
	s.statusMu.RLock()
	st := s.status
	s.statusMu.RUnlock()
	if st.StartedAt > 0 {
		st.Elapsed = time.Now().Unix() - st.StartedAt
	}
	if st.Discovered > 0 && st.Phase != "discovering" {
		st.Percent = float64(st.Processed) * 100 / float64(st.Discovered)
	}
	if st.Processed > 0 && st.Discovered > st.Processed && st.Phase != "discovering" && st.Elapsed > 0 {
		st.ETA = int64(float64(st.Discovered-st.Processed) / (float64(st.Processed) / float64(st.Elapsed)))
	}
	return st
}
func (s *Scanner) updateStatus(fn func(*ScanStatus)) {
	s.statusMu.Lock()
	fn(&s.status)
	s.statusMu.Unlock()
}
func (s *Scanner) SetBinaries(ffmpeg, ffprobe string) {
	s.mu.Lock()
	s.cfg.FFmpegPath, s.cfg.FFprobePath = ffmpeg, ffprobe
	s.mu.Unlock()
	s.thumbs.SetBinary(ffmpeg)
}
func (s *Scanner) SetConfig(c config.Config) {
	s.mu.Lock()
	s.cfg = c.Clone()
	s.mu.Unlock()
	s.thumbs.SetBinary(c.FFmpegPath)
}
func (s *Scanner) Config() config.Config { s.mu.RLock(); defer s.mu.RUnlock(); return s.cfg.Clone() }
func (s *Scanner) Cancel() bool {
	s.statusMu.Lock()
	defer s.statusMu.Unlock()
	if s.cancel == nil {
		return false
	}
	s.cancel()
	return true
}
func (s *Scanner) RegenerateThumbnail(ctx context.Context, id int64, path string, duration float64) (string, error) {
	thumb, e := s.thumbs.Regenerate(ctx, id, path, duration)
	if e == nil {
		_, e = s.db.ExecContext(ctx, "UPDATE videos SET thumbnail=? WHERE id=?", thumb, id)
	}
	return thumb, e
}
func (s *Scanner) ForceRescan(ctx context.Context, thumbnails bool) error {
	if s.Running() {
		return errors.New("scan already running")
	}
	if thumbnails {
		rows, e := s.db.QueryContext(ctx, "SELECT thumbnail FROM videos WHERE thumbnail<>''")
		if e != nil {
			return e
		}
		for rows.Next() {
			var p string
			rows.Scan(&p)
			_ = os.Remove(p)
		}
		rows.Close()
	}
	_, e := s.db.ExecContext(ctx, "UPDATE videos SET modified_at=0")
	return e
}

var extensions = map[string]bool{".mkv": true, ".mp4": true, ".avi": true, ".mov": true, ".webm": true, ".ts": true, ".m2ts": true}

type job struct {
	path string
	info os.FileInfo
}

func (s *Scanner) Run(ctx context.Context) error {
	if !s.running.CompareAndSwap(false, true) {
		return errors.New("scan already running")
	}
	ctx, cancel := context.WithCancel(ctx)
	s.statusMu.Lock()
	s.cancel = cancel
	s.statusMu.Unlock()
	defer func() { cancel(); s.statusMu.Lock(); s.cancel = nil; s.statusMu.Unlock(); s.running.Store(false) }()
	s.mu.RLock()
	cfg := s.cfg.Clone()
	s.mu.RUnlock()
	s.updateStatus(func(st *ScanStatus) {
		*st = ScanStatus{Running: true, Phase: "discovering", StartedAt: time.Now().Unix()}
	})
	defer s.updateStatus(func(st *ScanStatus) {
		st.Running = false
		if ctx.Err() != nil {
			st.Phase = "cancelled"
		} else {
			st.Phase = "complete"
			st.Percent = 100
		}
	})
	if len(cfg.Libraries) == 0 {
		s.log.Info("scan skipped: no library configured")
		return nil
	}
	start := time.Now()
	seen := map[string]bool{}
	jobs := make(chan job, 128)
	var wg sync.WaitGroup
	workers := cfg.ScanWorkers
	if workers < 1 {
		workers = runtime.NumCPU()
	}
	if workers > 16 {
		workers = 16
	}
	var changed atomic.Int64
	for range workers {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := range jobs {
				ok, e := s.process(ctx, j)
				if e != nil {
					s.log.Error("scan file", "path", j.path, "error", e)
					s.updateStatus(func(st *ScanStatus) { st.Errors++ })
				} else if ok {
					changed.Add(1)
					s.updateStatus(func(st *ScanStatus) { st.Updated++ })
				}
				s.updateStatus(func(st *ScanStatus) { st.Processed++ })
			}
		}()
	}
	successfulRoots := map[string]bool{}
	for _, root := range cfg.Libraries {
		root = filepath.Clean(root)
		rootOK := true
		e := filepath.Walk(root, func(path string, info os.FileInfo, e error) error {
			if e != nil {
				s.log.Warn("walk", "path", path, "error", e)
				rootOK = false
				return nil
			}
			if ctx.Err() != nil {
				return ctx.Err()
			}
			if info.Mode().IsRegular() && extensions[strings.ToLower(filepath.Ext(path))] {
				abs, _ := filepath.Abs(path)
				seen[abs] = true
				s.updateStatus(func(st *ScanStatus) { st.Discovered++ })
				jobs <- job{abs, info}
			}
			return nil
		})
		if e != nil {
			s.log.Error("scan library", "path", root, "error", e)
			rootOK = false
		}
		if rootOK {
			if abs, e := filepath.Abs(root); e == nil {
				successfulRoots[abs] = true
			}
		}
	}
	s.updateStatus(func(st *ScanStatus) { st.Phase = "processing" })
	close(jobs)
	wg.Wait()
	if ctx.Err() != nil {
		return ctx.Err()
	}
	rows, e := s.db.QueryContext(ctx, "SELECT id,path,thumbnail FROM videos")
	if e != nil {
		return e
	}
	type stale struct {
		id          int64
		path, thumb string
	}
	var remove []stale
	for rows.Next() {
		var x stale
		rows.Scan(&x.id, &x.path, &x.thumb)
		if !seen[x.path] && belongsToSuccessfulRoot(x.path, successfulRoots) {
			remove = append(remove, x)
		}
	}
	rows.Close()
	for _, x := range remove {
		s.db.ExecContext(ctx, "DELETE FROM videos WHERE id=?", x.id)
		if x.thumb != "" {
			os.Remove(x.thumb)
		}
	}
	s.updateStatus(func(st *ScanStatus) { st.Removed = int64(len(remove)) })
	s.log.Info("scan complete", "files", len(seen), "updated", changed.Load(), "removed", len(remove), "elapsed", time.Since(start))
	return nil
}
func (s *Scanner) process(ctx context.Context, j job) (bool, error) {
	var failedSize, failedMod, failedAt int64
	if e := s.db.QueryRowContext(ctx, "SELECT size,modified_at,failed_at FROM scan_errors WHERE path=? AND ignored=0", j.path).Scan(&failedSize, &failedMod, &failedAt); e == nil && failedSize == j.info.Size() && failedMod == j.info.ModTime().Unix() && time.Since(time.Unix(failedAt, 0)) < 6*time.Hour {
		return false, nil
	}
	s.mu.RLock()
	ffprobe := s.cfg.FFprobePath
	s.mu.RUnlock()
	var id int64
	var size, mod int64
	e := s.db.QueryRowContext(ctx, "SELECT id,size,modified_at FROM videos WHERE path=?", j.path).Scan(&id, &size, &mod)
	if e == nil && size == j.info.Size() && mod == j.info.ModTime().Unix() {
		parsed := filenameparser.Parse(j.path)
		_, _ = s.db.ExecContext(ctx, "UPDATE videos SET parsed_series=?,parsed_season=?,parsed_episode=?,release_group=?,detected_quality=? WHERE id=?", parsed.Series, parsed.Season, parsed.Episode, parsed.ReleaseGroup, parsed.Quality, id)
		return false, nil
	}
	if e != nil && !errors.Is(e, sql.ErrNoRows) {
		return false, e
	}
	p, e := metadata.Probe(ctx, ffprobe, j.path)
	if e != nil {
		_, _ = s.db.ExecContext(context.Background(), `INSERT INTO scan_errors(path,error,failed_at,size,modified_at,ignored) VALUES(?,?,?,?,?,0) ON CONFLICT(path) DO UPDATE SET error=excluded.error,failed_at=excluded.failed_at,size=excluded.size,modified_at=excluded.modified_at,ignored=0`, j.path, e.Error(), time.Now().Unix(), j.info.Size(), j.info.ModTime().Unix())
		return false, e
	}
	_, _ = s.db.ExecContext(ctx, "DELETE FROM scan_errors WHERE path=?", j.path)
	h, e := quickHash(j.path, j.info.Size())
	if e != nil {
		return false, e
	}
	audio, _ := json.Marshal(p.Audio)
	subs, _ := json.Marshal(p.Subtitles)
	chap, _ := json.Marshal(p.Chapters)
	title := strings.TrimSuffix(filepath.Base(j.path), filepath.Ext(j.path))
	parsed := filenameparser.Parse(j.path)
	now := time.Now().Unix()
	_, e = s.db.ExecContext(ctx, `INSERT INTO videos(path,filename,title,size,hash,modified_at,duration,video_codec,width,height,fps,bitrate,container,hdr,audio_json,subtitle_json,chapters_json,added_at,parsed_series,parsed_season,parsed_episode,release_group,detected_quality) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(path) DO UPDATE SET filename=excluded.filename,size=excluded.size,hash=excluded.hash,modified_at=excluded.modified_at,duration=excluded.duration,video_codec=excluded.video_codec,width=excluded.width,height=excluded.height,fps=excluded.fps,bitrate=excluded.bitrate,container=excluded.container,hdr=excluded.hdr,audio_json=excluded.audio_json,subtitle_json=excluded.subtitle_json,chapters_json=excluded.chapters_json,parsed_series=excluded.parsed_series,parsed_season=excluded.parsed_season,parsed_episode=excluded.parsed_episode,release_group=excluded.release_group,detected_quality=excluded.detected_quality`, j.path, filepath.Base(j.path), title, j.info.Size(), h, j.info.ModTime().Unix(), p.Duration, p.VideoCodec, p.Width, p.Height, p.FPS, p.Bitrate, p.Container, p.HDR, string(audio), string(subs), string(chap), now, parsed.Series, parsed.Season, parsed.Episode, parsed.ReleaseGroup, parsed.Quality)
	if e != nil {
		return false, e
	}
	e = s.db.QueryRowContext(ctx, "SELECT id FROM videos WHERE path=?", j.path).Scan(&id)
	if e != nil {
		return false, e
	}
	thumb, e := s.thumbs.Generate(ctx, id, j.path, p.Duration)
	if e != nil {
		s.log.Warn("thumbnail", "path", j.path, "error", e)
	} else {
		_, e = s.db.ExecContext(ctx, "UPDATE videos SET thumbnail=? WHERE id=?", thumb, id)
	}
	return true, e
}
func belongsToSuccessfulRoot(path string, roots map[string]bool) bool {
	for root := range roots {
		rel, e := filepath.Rel(root, path)
		if e == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
			return true
		}
	}
	return false
}
func quickHash(path string, size int64) (string, error) {
	f, e := os.Open(path)
	if e != nil {
		return "", e
	}
	defer f.Close()
	h := sha256.New()
	io.CopyN(h, f, 1024*1024)
	if size > 1024*1024 {
		f.Seek(-1024*1024, io.SeekEnd)
		io.Copy(h, f)
	}
	return hex.EncodeToString(h.Sum(nil)), nil
}