package server import ( "bytes" "context" "crypto/md5" "errors" "fmt" "io" "log/slog" "net/http" "os" "path/filepath" "slices" "git.lastassault.de/speatzle/morffix/constants" "github.com/jackc/pgx/v5" ) var videoFileExtensions = []string{".mkv", ".mp4", ".webm", ".flv", ".avi"} func handleScan(w http.ResponseWriter, r *http.Request) { if r.Method == "GET" { scanStatus(w, r) return } id := r.PathValue("id") if id == "" { http.Error(w, "No ID Set", http.StatusBadRequest) return } full := r.FormValue("full") == "on" var name string var path string var enabled bool err := db.QueryRow(r.Context(), "SELECT name, path, enable FROM libraries WHERE id = $1", id).Scan(&name, &path, &enabled) if err != nil { slog.ErrorContext(r.Context(), "Get Library", "err", err) http.Error(w, "Error Get Library: "+err.Error(), http.StatusInternalServerError) return } scanCtx := context.Background() go scan(scanCtx, id, full) message := "Scan Started" buf := bytes.Buffer{} err = templates.ExecuteTemplate(&buf, constants.MESSAGE_TEMPLATE_NAME, message) if err != nil { slog.ErrorContext(r.Context(), "Executing Library Template", "err", err) http.Error(w, "Error Executing Template: "+err.Error(), http.StatusInternalServerError) return } _, err = w.Write(buf.Bytes()) if err != nil { slog.ErrorContext(r.Context(), "Writing http Response", "err", err) } } func scanStatus(w http.ResponseWriter, r *http.Request) { message := "TODO" buf := bytes.Buffer{} err := templates.ExecuteTemplate(&buf, constants.MESSAGE_TEMPLATE_NAME, message) if err != nil { slog.ErrorContext(r.Context(), "Executing Library Template", "err", err) http.Error(w, "Error Executing Template: "+err.Error(), http.StatusInternalServerError) return } _, err = w.Write(buf.Bytes()) if err != nil { slog.ErrorContext(r.Context(), "Writing http Response", "err", err) } } func scan(ctx context.Context, id string, full bool) { slog.InfoContext(ctx, "Starting Scan", "id", id) // TODO Scan settings: // - Auto Queue Healthcheck for Changed Files // - Auto Queue Healthcheck for New Files // - Auto Queue Transcode for New Files // - Auto Queue Transcode for Changed Files (? Instead have library setting to queue transcode for changed files on healthcheck success) // - Auto Queue Health/Transcode for Unkown Status ? (might result in requeue loop) // - Schedule Scans Periodically // - Add File Monitoring for Setting Changed status and triggering tasks var name string var lpath string var enabled bool err := db.QueryRow(ctx, "SELECT name, path, enable FROM libraries WHERE id = $1", id).Scan(&name, &lpath, &enabled) if err != nil { slog.ErrorContext(ctx, "Get Library", "err", err) return } if !enabled { slog.ErrorContext(ctx, "Scan Aborted, Library not Enabled", "id", id) return } dirInfo, err := os.Stat(lpath) if err != nil { slog.ErrorContext(ctx, "Stating Library Path", "id", id, "path", lpath, "err", err) return } if !dirInfo.IsDir() { slog.ErrorContext(ctx, "Library Path is not a Folder", "id", id, "path", lpath) return } tx, err := db.Begin(ctx) if err != nil { slog.ErrorContext(ctx, "Begin Transaction", "err", err) return } defer tx.Rollback(ctx) slog.InfoContext(ctx, "Checking Files...", "id", id, "path", lpath) // Mark all Files as Missing _, err = tx.Exec(ctx, "UPDATE files SET status = $2 where library_id = $1", id, constants.FILE_STATUS_MISSING) if err != nil { slog.ErrorContext(ctx, "Setting Missing Status", "err", err) return } err = filepath.Walk(lpath, func(fullPath string, info os.FileInfo, err error) error { if errors.Is(err, os.ErrPermission) { slog.WarnContext(ctx, "Permission Denied While Scanning File", "path", fullPath) return nil } else if err != nil { return err } if info.IsDir() { // We don't care about folders return nil } if !slices.Contains(videoFileExtensions, filepath.Ext(fullPath)) { slog.InfoContext(ctx, "Skipping non video file", "path", fullPath) return nil } slog.InfoContext(ctx, "Hashing File", "path", fullPath, "size", info.Size()) file, err := os.Open(fullPath) if err != nil { return fmt.Errorf("Opening File: %w", err) } hash := md5.New() if _, err := io.Copy(hash, file); err != nil { return fmt.Errorf("Reading File: %w", err) } newMD5 := hash.Sum(nil) slog.InfoContext(ctx, "File MD5", "path", fullPath, "size", info.Size(), "md5", newMD5) fPath, err := filepath.Rel(lpath, fullPath) if err != nil { return fmt.Errorf("Getting Relative Path: %w", err) } var fileID int var oldMD5 []byte var health constants.FileHealth err = tx.QueryRow(ctx, "SELECT id, md5, health FROM files WHERE library_id = $1 AND path = $2", id, fPath).Scan(&fileID, &oldMD5, &health) if errors.Is(err, pgx.ErrNoRows) { // File Does not Exist Yet slog.InfoContext(ctx, "File is New", "path", fullPath) _, err = tx.Exec(ctx, "INSERT INTO files (library_id, path, size, status, health, md5) VALUES ($1, $2, $3, $4, $5, $6)", id, fPath, info.Size(), constants.FILE_STATUS_EXISTS, constants.FILE_HEALTH_UNKNOWN, newMD5) if err != nil { return fmt.Errorf("Add New File to DB: %w", err) } return nil } else if err != nil { return fmt.Errorf("Getting File: %w", err) } if slices.Compare[[]byte](newMD5, oldMD5) != 0 { // File has changed on disk so reset health health = constants.FILE_HEALTH_UNKNOWN } // File Exists so update Size, status and hash _, err = tx.Exec(ctx, "UPDATE files SET size = $2, status = $3, health = $4, md5 = $5 WHERE id = $1", fileID, info.Size(), constants.FILE_STATUS_EXISTS, health, newMD5) if err != nil { return fmt.Errorf("Updating File in DB: %w", err) } return nil }) if err != nil { slog.ErrorContext(ctx, "Error Walking Library", "err", err) return } err = tx.Commit(ctx) if err != nil { slog.ErrorContext(ctx, "Error Committing Changes", "err", err) return } // TODO, create health and transcode tasks if requested slog.InfoContext(ctx, "Scan Done", "id", id) }