Make scan settings default without Unraid env and editable per node in admin.

Legacy 6h configs migrate to watch + nightly 03:30; master pushes schedule via HELLO_ACK/CONFIG_UPDATE.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-25 04:27:12 +02:00
parent f113a1ad8c
commit 9c3fd94f03
10 changed files with 276 additions and 48 deletions

View file

@ -17,6 +17,8 @@ interface Node {
freeStorage: string; freeStorage: string;
activeStreams: number; activeStreams: number;
libraryFileCount: number; libraryFileCount: number;
fullScanInterval: string;
fullScanAt: string;
revoked: boolean; revoked: boolean;
} }
@ -37,6 +39,8 @@ export default function NodesPage() {
const [editName, setEditName] = useState(""); const [editName, setEditName] = useState("");
const [editLocation, setEditLocation] = useState(""); const [editLocation, setEditLocation] = useState("");
const [editPublicUrl, setEditPublicUrl] = useState(""); const [editPublicUrl, setEditPublicUrl] = useState("");
const [editScanInterval, setEditScanInterval] = useState("off");
const [editScanAt, setEditScanAt] = useState("03:30");
function loadNodes() { function loadNodes() {
fetch("/api/v1/admin/nodes", { credentials: "include" }) fetch("/api/v1/admin/nodes", { credentials: "include" })
@ -131,7 +135,7 @@ export default function NodesPage() {
async function saveEdit() { async function saveEdit() {
if (!editId) return; if (!editId) return;
await fetch(`/api/v1/admin/nodes/${editId}`, { const res = await fetch(`/api/v1/admin/nodes/${editId}`, {
method: "PATCH", method: "PATCH",
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -139,8 +143,15 @@ export default function NodesPage() {
name: editName, name: editName,
location: editLocation, location: editLocation,
publicStreamUrl: editPublicUrl.trim().replace(/\/$/, ""), publicStreamUrl: editPublicUrl.trim().replace(/\/$/, ""),
fullScanInterval: editScanInterval.trim() || "off",
fullScanAt: editScanAt.trim() || "03:30",
}), }),
}); });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
alert(data.error?.message ?? "Opslaan mislukt");
return;
}
setEditId(null); setEditId(null);
loadNodes(); loadNodes();
} }
@ -226,8 +237,27 @@ export default function NodesPage() {
onChange={(e) => setEditPublicUrl(e.target.value)} onChange={(e) => setEditPublicUrl(e.target.value)}
placeholder="https://node1.example.com" placeholder="https://node1.example.com"
/> />
</div>
<div className="form-group">
<label>Nachtelijke full scan (HH:MM of off)</label>
<input
value={editScanAt}
onChange={(e) => setEditScanAt(e.target.value)}
placeholder="03:30"
/>
<p style={{ color: "#a1a1aa", fontSize: "0.8rem", marginTop: 6 }}> <p style={{ color: "#a1a1aa", fontSize: "0.8rem", marginTop: 6 }}>
Zet dit ook in de node-container als MEDIA_NODE_PUBLIC_URL, anders overschrijft de volgende HELLO dit weer. Catch-up scan in de tijdzone van de node (Unraid TZ). Standaard 03:30.
</p>
</div>
<div className="form-group">
<label>Extra interval-scan (off / 6h / 24h)</label>
<input
value={editScanInterval}
onChange={(e) => setEditScanInterval(e.target.value)}
placeholder="off"
/>
<p style={{ color: "#a1a1aa", fontSize: "0.8rem", marginTop: 6 }}>
Laat op <code>off</code>: toevoegen/verwijderen gaat via realtime watch. Admin-Rescan blijft beschikbaar.
</p> </p>
</div> </div>
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8 }}>
@ -271,6 +301,8 @@ export default function NodesPage() {
setEditName(node.name); setEditName(node.name);
setEditLocation(node.location ?? ""); setEditLocation(node.location ?? "");
setEditPublicUrl(node.publicStreamUrl); setEditPublicUrl(node.publicStreamUrl);
setEditScanInterval(node.fullScanInterval ?? "off");
setEditScanAt(node.fullScanAt ?? "03:30");
}} }}
> >
Edit Edit

View file

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "nodes" ADD COLUMN "full_scan_interval" TEXT NOT NULL DEFAULT 'off';
ALTER TABLE "nodes" ADD COLUMN "full_scan_at" TEXT NOT NULL DEFAULT '03:30';

View file

@ -99,6 +99,10 @@ model Node {
currentBandwidth BigInt @default(0) @map("current_bandwidth") currentBandwidth BigInt @default(0) @map("current_bandwidth")
libraryFileCount Int @default(0) @map("library_file_count") libraryFileCount Int @default(0) @map("library_file_count")
lastRevision Int @default(0) @map("last_revision") lastRevision Int @default(0) @map("last_revision")
/** Node scanner: "off" or duration like "24h" / "6h" */
fullScanInterval String @default("off") @map("full_scan_interval")
/** Nightly catch-up time HH:MM in node local TZ */
fullScanAt String @default("03:30") @map("full_scan_at")
revoked Boolean @default(false) revoked Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")

View file

@ -146,6 +146,8 @@ export async function registerNodeRoutes(app: FastifyInstance) {
location?: string; location?: string;
locationType?: "LOCAL" | "REMOTE"; locationType?: "LOCAL" | "REMOTE";
publicStreamUrl?: string; publicStreamUrl?: string;
fullScanInterval?: string;
fullScanAt?: string;
}; };
let locationId: string | undefined | null = undefined; let locationId: string | undefined | null = undefined;
@ -162,6 +164,23 @@ export async function registerNodeRoutes(app: FastifyInstance) {
} }
} }
if (body.fullScanInterval !== undefined) {
const v = body.fullScanInterval.trim().toLowerCase();
if (v !== "off" && !/^\d+[smhd]$/.test(v)) {
throw new AppError(
"INVALID_REQUEST",
"fullScanInterval must be 'off' or a duration like 6h / 24h",
400
);
}
}
if (body.fullScanAt !== undefined) {
const v = body.fullScanAt.trim();
if (v.toLowerCase() !== "off" && !/^\d{1,2}:\d{2}$/.test(v)) {
throw new AppError("INVALID_REQUEST", "fullScanAt must be HH:MM or 'off'", 400);
}
}
const node = await prisma.node.update({ const node = await prisma.node.update({
where: { id }, where: { id },
data: { data: {
@ -169,10 +188,22 @@ export async function registerNodeRoutes(app: FastifyInstance) {
...(locationId !== undefined ? { locationId } : {}), ...(locationId !== undefined ? { locationId } : {}),
...(body.locationType ? { locationType: body.locationType } : {}), ...(body.locationType ? { locationType: body.locationType } : {}),
...(body.publicStreamUrl ? { publicStreamUrl: body.publicStreamUrl } : {}), ...(body.publicStreamUrl ? { publicStreamUrl: body.publicStreamUrl } : {}),
...(body.fullScanInterval !== undefined
? { fullScanInterval: body.fullScanInterval.trim().toLowerCase() }
: {}),
...(body.fullScanAt !== undefined ? { fullScanAt: body.fullScanAt.trim() } : {}),
}, },
include: { location: true }, include: { location: true },
}); });
if (body.fullScanInterval !== undefined || body.fullScanAt !== undefined) {
const { nodeConnectionManager } = await import("../websocket/manager");
nodeConnectionManager.sendConfigUpdate(id, {
fullScanInterval: node.fullScanInterval,
fullScanAt: node.fullScanAt,
});
}
return { node: serializeNode(node) }; return { node: serializeNode(node) };
}); });
@ -270,6 +301,8 @@ function serializeNode(n: {
activeStreams: number; activeStreams: number;
currentBandwidth: bigint; currentBandwidth: bigint;
libraryFileCount: number; libraryFileCount: number;
fullScanInterval?: string;
fullScanAt?: string;
revoked: boolean; revoked: boolean;
}) { }) {
return { return {
@ -288,6 +321,8 @@ function serializeNode(n: {
activeStreams: n.activeStreams, activeStreams: n.activeStreams,
currentBandwidth: n.currentBandwidth.toString(), currentBandwidth: n.currentBandwidth.toString(),
libraryFileCount: n.libraryFileCount, libraryFileCount: n.libraryFileCount,
fullScanInterval: n.fullScanInterval ?? "off",
fullScanAt: n.fullScanAt ?? "03:30",
revoked: n.revoked, revoked: n.revoked,
}; };
} }

View file

@ -148,7 +148,9 @@ class NodeConnectionManager {
accepted: false, accepted: false,
reason: "Invalid credentials", reason: "Invalid credentials",
heartbeatIntervalSeconds: 30, heartbeatIntervalSeconds: 30,
fullScanIntervalSeconds: 21600, fullScanIntervalSeconds: 0,
fullScanInterval: "off",
fullScanAt: "03:30",
}) })
); );
socket.close(4003, "Authentication failed"); socket.close(4003, "Authentication failed");
@ -184,12 +186,25 @@ class NodeConnectionManager {
console.log(`Node connected: ${payload.nodeId} (${payload.hostname})`); console.log(`Node connected: ${payload.nodeId} (${payload.hostname})`);
const node = await prisma.node.findUnique({ where: { id: payload.nodeId } });
const fullScanInterval = node?.fullScanInterval || "off";
const fullScanAt = node?.fullScanAt || "03:30";
const legacySeconds =
fullScanInterval === "off"
? 0
: (() => {
const m = fullScanInterval.match(/^(\d+)h$/i);
return m ? parseInt(m[1], 10) * 3600 : 0;
})();
this.send( this.send(
socket, socket,
createMessage("HELLO_ACK", { createMessage("HELLO_ACK", {
accepted: true, accepted: true,
heartbeatIntervalSeconds: this.config?.NODE_HEARTBEAT_INTERVAL_SECONDS ?? 30, heartbeatIntervalSeconds: this.config?.NODE_HEARTBEAT_INTERVAL_SECONDS ?? 30,
fullScanIntervalSeconds: 21600, fullScanIntervalSeconds: legacySeconds,
fullScanInterval,
fullScanAt,
}) })
); );
} }
@ -326,6 +341,13 @@ class NodeConnectionManager {
return this.sendToNode(nodeId, "RESCAN", { full }); return this.sendToNode(nodeId, "RESCAN", { full });
} }
sendConfigUpdate(
nodeId: string,
payload: { fullScanInterval?: string; fullScanAt?: string }
): boolean {
return this.sendToNode(nodeId, "CONFIG_UPDATE", payload);
}
createPlaybackSession(nodeId: string, payload: CreatePlaybackSessionPayload): boolean { createPlaybackSession(nodeId: string, payload: CreatePlaybackSessionPayload): boolean {
return this.sendToNode(nodeId, "CREATE_PLAYBACK_SESSION", payload); return this.sendToNode(nodeId, "CREATE_PLAYBACK_SESSION", payload);
} }

View file

@ -124,7 +124,7 @@ func runService() {
} }
}) })
ctrlClient = control.NewClient(cfg, creds, store, sc, streamer, version) ctrlClient = control.NewClient(cfg, creds, store, sc, streamer, version, configPath)
streamer.SetSessionEndHandler(func(sessionID, reason string) { streamer.SetSessionEndHandler(func(sessionID, reason string) {
ctrlClient.NotifySessionEnded(sessionID, reason) ctrlClient.NotifySessionEnded(sessionID, reason)
}) })

View file

@ -2,6 +2,7 @@ package config
import ( import (
"fmt" "fmt"
"log"
"os" "os"
"strconv" "strconv"
"strings" "strings"
@ -71,10 +72,11 @@ func Load(path string) (*Config, error) {
if envErr != nil { if envErr != nil {
return nil, fmt.Errorf("read config: %w (and env incomplete: %v)", err, envErr) return nil, fmt.Errorf("read config: %w (and env incomplete: %v)", err, envErr)
} }
cfg = normalizeScannerDefaults(applyEnvOverrides(cfg))
if saveErr := Save(path, cfg); saveErr != nil { if saveErr := Save(path, cfg); saveErr != nil {
return applyDefaults(applyEnvOverrides(cfg)), nil return cfg, nil
} }
return applyDefaults(applyEnvOverrides(cfg)), nil return cfg, nil
} }
return nil, fmt.Errorf("read config: %w", err) return nil, fmt.Errorf("read config: %w", err)
} }
@ -82,7 +84,22 @@ func Load(path string) (*Config, error) {
if err := yaml.Unmarshal(data, &cfg); err != nil { if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err) return nil, fmt.Errorf("parse config: %w", err)
} }
return applyDefaults(applyEnvOverrides(&cfg)), nil beforeInterval := cfg.Scanner.FullScanInterval
beforeAt := cfg.Scanner.FullScanAt
cfgPtr := normalizeScannerDefaults(applyEnvOverrides(&cfg))
// Persist migration away from legacy 6h interval when nightly schedule is the new default.
if beforeInterval != cfgPtr.Scanner.FullScanInterval || beforeAt != cfgPtr.Scanner.FullScanAt {
if err := Save(path, cfgPtr); err != nil {
log.Printf("Could not persist scanner defaults: %v", err)
} else {
log.Printf(
"Scanner config updated: interval=%s at=%s",
cfgPtr.Scanner.FullScanInterval,
cfgPtr.Scanner.FullScanAt,
)
}
}
return cfgPtr, nil
} }
// FromEnv builds config from MEDIA_NODE_* environment variables (Docker/Unraid). // FromEnv builds config from MEDIA_NODE_* environment variables (Docker/Unraid).
@ -125,14 +142,27 @@ func applyEnvOverrides(cfg *Config) *Config {
return cfg return cfg
} }
func applyDefaults(cfg *Config) *Config { // normalizeScannerDefaults migrates legacy 6h-only configs to watch + nightly 03:30.
func normalizeScannerDefaults(cfg *Config) *Config {
if cfg.Stream.Listen == "" { if cfg.Stream.Listen == "" {
cfg.Stream.Listen = DefaultListen cfg.Stream.Listen = DefaultListen
} }
if cfg.Scanner.FullScanInterval == "" { interval := strings.TrimSpace(strings.ToLower(cfg.Scanner.FullScanInterval))
at := strings.TrimSpace(cfg.Scanner.FullScanAt)
// Empty or legacy default "6h" without an explicit nightly time → new policy.
if interval == "" || interval == "6h" {
if at == "" {
cfg.Scanner.FullScanInterval = "off"
cfg.Scanner.FullScanAt = "03:30"
} else if interval == "6h" && at != "" {
// Explicit nightly already set (e.g. via admin) — drop redundant 6h.
cfg.Scanner.FullScanInterval = "off"
} else {
cfg.Scanner.FullScanInterval = "off" cfg.Scanner.FullScanInterval = "off"
} }
if cfg.Scanner.FullScanAt == "" { }
if strings.TrimSpace(cfg.Scanner.FullScanAt) == "" {
cfg.Scanner.FullScanAt = "03:30" cfg.Scanner.FullScanAt = "03:30"
} }
if cfg.Security.SessionIdleTimeout == "" { if cfg.Security.SessionIdleTimeout == "" {

View file

@ -36,6 +36,7 @@ type syncAck struct {
type Client struct { type Client struct {
cfg *config.Config cfg *config.Config
configPath string
creds *config.Credentials creds *config.Credentials
store *database.Store store *database.Store
scanner *scanner.Scanner scanner *scanner.Scanner
@ -58,9 +59,11 @@ func NewClient(
sc *scanner.Scanner, sc *scanner.Scanner,
streamer *streaming.Server, streamer *streaming.Server,
version string, version string,
configPath string,
) *Client { ) *Client {
return &Client{ return &Client{
cfg: cfg, cfg: cfg,
configPath: configPath,
creds: creds, creds: creds,
store: store, store: store,
scanner: sc, scanner: sc,
@ -254,6 +257,9 @@ func (c *Client) handleMessage(msgType string, payload json.RawMessage) {
var ack struct { var ack struct {
Accepted bool `json:"accepted"` Accepted bool `json:"accepted"`
Reason string `json:"reason"` Reason string `json:"reason"`
FullScanInterval string `json:"fullScanInterval"`
FullScanAt string `json:"fullScanAt"`
FullScanIntervalSeconds int `json:"fullScanIntervalSeconds"`
} }
_ = json.Unmarshal(payload, &ack) _ = json.Unmarshal(payload, &ack)
if !ack.Accepted { if !ack.Accepted {
@ -261,6 +267,7 @@ func (c *Client) handleMessage(msgType string, payload json.RawMessage) {
return return
} }
log.Println("Connected to Master") log.Println("Connected to Master")
c.applyScannerSettings(ack.FullScanInterval, ack.FullScanAt, ack.FullScanIntervalSeconds)
// Must run async so readLoop can receive FULL_LIBRARY_SYNC_ACK // Must run async so readLoop can receive FULL_LIBRARY_SYNC_ACK
go c.sendFullSync() go c.sendFullSync()
case "FULL_LIBRARY_SYNC_ACK": case "FULL_LIBRARY_SYNC_ACK":
@ -334,12 +341,41 @@ func (c *Client) handleMessage(msgType string, payload json.RawMessage) {
case "FULL_LIBRARY_SYNC": case "FULL_LIBRARY_SYNC":
go c.sendFullSync() go c.sendFullSync()
case "CONFIG_UPDATE": case "CONFIG_UPDATE":
log.Println("CONFIG_UPDATE received (apply on next restart)") var p struct {
FullScanInterval string `json:"fullScanInterval"`
FullScanAt string `json:"fullScanAt"`
}
if json.Unmarshal(payload, &p) == nil {
c.applyScannerSettings(p.FullScanInterval, p.FullScanAt, 0)
}
case "ERROR": case "ERROR":
log.Printf("Master error: %s", string(payload)) log.Printf("Master error: %s", string(payload))
} }
} }
func (c *Client) applyScannerSettings(interval, at string, legacySeconds int) {
if interval == "" && at == "" && legacySeconds > 0 {
interval = fmt.Sprintf("%ds", legacySeconds)
}
if interval == "" && at == "" {
return
}
if interval == "" {
interval = c.cfg.Scanner.FullScanInterval
}
if at == "" {
at = c.cfg.Scanner.FullScanAt
}
c.scanner.UpdateSchedule(interval, at)
c.cfg.Scanner.FullScanInterval = interval
c.cfg.Scanner.FullScanAt = at
if c.configPath != "" {
if err := config.Save(c.configPath, c.cfg); err != nil {
log.Printf("Failed to save scanner settings: %v", err)
}
}
}
func (c *Client) SendLibraryEvents(events []scanner.LibraryEvent) { func (c *Client) SendLibraryEvents(events []scanner.LibraryEvent) {
if len(events) == 0 { if len(events) == 0 {
return return

View file

@ -47,10 +47,17 @@ type Scanner struct {
mu sync.Mutex mu sync.Mutex
scanning bool scanning bool
status string status string
resetSchedule chan struct{}
} }
func New(cfg *config.Config, store *database.Store, onEvents EventHandler) *Scanner { func New(cfg *config.Config, store *database.Store, onEvents EventHandler) *Scanner {
return &Scanner{cfg: cfg, store: store, onEvents: onEvents, status: "idle"} return &Scanner{
cfg: cfg,
store: store,
onEvents: onEvents,
status: "idle",
resetSchedule: make(chan struct{}, 2),
}
} }
func (s *Scanner) Status() string { func (s *Scanner) Status() string {
@ -60,43 +67,92 @@ func (s *Scanner) Status() string {
} }
func (s *Scanner) Start() { func (s *Scanner) Start() {
// One inventory pass at boot, then realtime watch + nightly catch-up. // One inventory pass at boot, then realtime watch + scheduled catch-up.
go s.runStartupScan() go s.runStartupScan()
go s.runNightlyFullScan() go s.runNightlyFullScan()
go s.runIntervalFullScan() go s.runIntervalFullScan()
go s.watchFilesystem() go s.watchFilesystem()
} }
// UpdateSchedule applies admin/master scan settings and restarts timers.
func (s *Scanner) UpdateSchedule(interval, at string) {
s.mu.Lock()
if interval != "" {
s.cfg.Scanner.FullScanInterval = interval
}
if at != "" {
s.cfg.Scanner.FullScanAt = at
}
s.mu.Unlock()
select {
case s.resetSchedule <- struct{}{}:
default:
}
select {
case s.resetSchedule <- struct{}{}:
default:
}
log.Printf("Scanner schedule updated: interval=%s at=%s", s.cfg.Scanner.FullScanInterval, s.cfg.Scanner.FullScanAt)
}
func (s *Scanner) runStartupScan() { func (s *Scanner) runStartupScan() {
log.Println("Startup library inventory scan...") log.Println("Startup library inventory scan...")
s.FullScan() s.FullScan()
} }
func (s *Scanner) runNightlyFullScan() { func (s *Scanner) runNightlyFullScan() {
hour, minute, ok := s.cfg.FullScanClock()
if !ok {
log.Println("Nightly full scan disabled")
return
}
for { for {
s.mu.Lock()
hour, minute, ok := s.cfg.FullScanClock()
s.mu.Unlock()
if !ok {
select {
case <-s.resetSchedule:
continue
}
}
next := nextLocalClock(time.Now(), hour, minute) next := nextLocalClock(time.Now(), hour, minute)
log.Printf("Next nightly full scan at %s", next.Format(time.RFC3339)) log.Printf("Next nightly full scan at %s", next.Format(time.RFC3339))
timer := time.NewTimer(time.Until(next)) timer := time.NewTimer(time.Until(next))
<-timer.C select {
case <-timer.C:
log.Println("Nightly catch-up full scan starting...") log.Println("Nightly catch-up full scan starting...")
s.FullScan() s.FullScan()
case <-s.resetSchedule:
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
}
} }
} }
func (s *Scanner) runIntervalFullScan() { func (s *Scanner) runIntervalFullScan() {
for {
s.mu.Lock()
d := s.cfg.FullScanDuration() d := s.cfg.FullScanDuration()
s.mu.Unlock()
if d <= 0 { if d <= 0 {
return select {
case <-s.resetSchedule:
continue
}
} }
log.Printf("Interval full scan every %s", d) log.Printf("Interval full scan every %s", d)
ticker := time.NewTicker(d) timer := time.NewTimer(d)
for range ticker.C { select {
case <-timer.C:
s.FullScan() s.FullScan()
case <-s.resetSchedule:
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
}
} }
} }

View file

@ -46,7 +46,12 @@ export interface HelloAckPayload {
accepted: boolean; accepted: boolean;
reason?: string; reason?: string;
heartbeatIntervalSeconds: number; heartbeatIntervalSeconds: number;
/** @deprecated prefer fullScanInterval + fullScanAt */
fullScanIntervalSeconds: number; fullScanIntervalSeconds: number;
/** "off" or Go duration e.g. "6h", "24h" */
fullScanInterval: string;
/** Nightly catch-up HH:MM, or "off" */
fullScanAt: string;
} }
export interface HeartbeatPayload extends NodeHeartbeat {} export interface HeartbeatPayload extends NodeHeartbeat {}
@ -76,6 +81,11 @@ export interface RescanPayload {
full: boolean; full: boolean;
} }
export interface ConfigUpdatePayload {
fullScanInterval?: string;
fullScanAt?: string;
}
export interface PlaybackSessionEndedPayload { export interface PlaybackSessionEndedPayload {
sessionId: string; sessionId: string;
reason: string; reason: string;