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.
HTTPS
https://zanvex.de/git/mini-video-library.gitinternal/scanner/scanner.go
Zum Verzeichnispackage 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
}