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:
parent
f113a1ad8c
commit
9c3fd94f03
10 changed files with 276 additions and 48 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 == "" {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue