Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 5 additions & 15 deletions .punused-ignore
Original file line number Diff line number Diff line change
Expand Up @@ -104,21 +104,11 @@ function NewFromHTTP is used in test only


# ---------------------------------------------------------------------------
# The Plex stream-type enum, after internal/streams adopted plexapi.Stream.
# Structural: production stopped naming the enum, and the names exist for the
# construction sites.
#
# streams.Stream now embeds plexapi.Stream, which promotes the two predicates
# this package used to declare byte-identically (IsAudio, IsSubtitle) — and
# those two were the last production readers of the local enum. Production
# reads a stream's kind only through the promoted methods now, so the three
# names below are named by tests alone: 145 construction sites in
# internal/streams' own tests, 67 in internal/tracksync's, plus
# streams_test.go's `const streamTypeVideo StreamType = 1`. They stay as
# aliases onto plexapi's declarations because the alternative is rewriting
# those 212 sites onto the library's spelling to say the same thing — easing a
# move to a new home is exactly what the rulebook (§11) licenses an alias for,
# and StreamTypeAudio is the app's own vocabulary at every one of them.
# The Plex stream-type enum. Structural: production reads a stream's kind only
# through the IsAudio and IsSubtitle that plexapi.Stream promotes into
# streams.Stream, so these names exist for the 212 construction sites in the
# internal/streams and internal/tracksync tests. They stay as aliases onto
# plexapi's declarations because they are the app's own vocabulary there.
class StreamType is used in test only
constant StreamTypeAudio is used in test only
constant StreamTypeSubtitle is used in test only
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# check=error=true
FROM golang:1.27.0-alpine@sha256:7d5cbf6833f7331dafd25a2e8b9673477f559759ff8ed4ca8efabe6795ad08db AS builder
# GOTOOLCHAIN=auto: a Renovate dep bump requiring a newer Go downloads that toolchain
# instead of failing the build (org convention, go.md/ci-cd.md); still reproducible
# because go.mod pins the toolchain version. `local` would hard-fail such a build.
# instead of failing the build; still reproducible because go.mod pins the
# toolchain version. `local` would hard-fail such a build.
ENV GOTOOLCHAIN=auto

WORKDIR /src
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ services:
image: ghcr.io/cplieger/plex-language-sync:latest
container_name: plex-language-sync
restart: unless-stopped
stop_grace_period: 20s # time to save what it learned on stop, Docker's 10s default is too short
stop_grace_period: 20s # time to save what it learned on stop. Docker's 10s default is too short
# Run "sudo mkdir -p /opt/appdata/plex-language-sync && sudo chown 1000:1000 /opt/appdata/plex-language-sync"
# before the first start, or nothing it learns is saved. If .env sets PUID and PGID, use those numbers.
user: "${PUID:-1000}:${PGID:-1000}"
Expand Down Expand Up @@ -72,7 +72,7 @@ Settings are environment variables. The app reads them once at start, so restart

| Variable | Description | Default |
| --- | --- | --- |
| `PLEX_URL` | Address of your Plex server, with scheme and port, such as `http://192.0.2.10:32400` | required |
| `PLEX_URL` | Address of your Plex server, with scheme and port, such as `http://192.0.2.10:32400`. `PLEX_URL_FILE` reads it from a file instead | required |
| `PLEX_TOKEN` | Plex token of the server's owner. `PLEX_TOKEN_FILE` reads it from a file instead | required |
| `UPDATE_LEVEL` | `show` changes the whole show, `season` only the current season | `show` |
| `UPDATE_STRATEGY` | `all` changes every episode in scope, `next` only the episodes after the one played | `all` |
Expand Down
2 changes: 1 addition & 1 deletion compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ services:
image: ghcr.io/cplieger/plex-language-sync:latest
container_name: plex-language-sync
restart: unless-stopped
stop_grace_period: 20s # time to save what it learned on stop, Docker's 10s default is too short
stop_grace_period: 20s # time to save what it learned on stop. Docker's 10s default is too short
# Run "sudo mkdir -p /opt/appdata/plex-language-sync && sudo chown 1000:1000 /opt/appdata/plex-language-sync"
# before the first start, or nothing it learns is saved. If .env sets PUID and PGID, use those numbers.
user: "${PUID:-1000}:${PGID:-1000}"
Expand Down
15 changes: 7 additions & 8 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,14 +215,13 @@ func splitTrim(s string) []string {
}

// loadSchedulerInterval parses DEEP_SCAN_INTERVAL and reports the daily
// deep-analysis cadence and whether the scheduler runs at all. The value
// is a Go duration ("24h", "12h"), matching the fleet docker-*-scheduler
// convention. The sentinels "off" and "disabled" (case-insensitive) or a
// zero duration ("0", "0s") disable the scheduler entirely: the app then
// runs WebSocket-only (the daily pass is a safety net over the real-time
// listener, and there is no external trigger). Unset defaults to
// defaultSchedulerInterval, enabled. Any other parse failure falls back
// to the default (enabled) with a warning rather than refusing to start.
// deep-analysis cadence and whether the scheduler runs at all. The value is a
// Go duration ("24h", "12h"), like the cplieger docker-*-scheduler images. The
// sentinels "off" and "disabled" (case-insensitive) or a zero duration ("0",
// "0s") disable the scheduler: the app then runs WebSocket-only, since the daily
// pass is a safety net over the real-time listener. Unset defaults to
// defaultSchedulerInterval, enabled. Any other parse failure falls back to the
// default (enabled) with a warning rather than refusing to start.
func loadSchedulerInterval() (interval time.Duration, enabled bool) {
interval = defaultSchedulerInterval
enabled = true
Expand Down
4 changes: 2 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ Every setting is an environment variable, set in `.env` or in the `environment:`

A misspelled value for `UPDATE_LEVEL`, `UPDATE_STRATEGY`, `SUBTITLE_MATCH_TIER`, `DEEP_SCAN_INTERVAL` or `LOG_LEVEL` logs a warning and falls back to the default, so the app still starts. A negative `DEEP_SCAN_INTERVAL` is treated the same way. Set `DEEP_SCAN_INTERVAL` to `off`, `disabled` or a zero duration such as `0` or `0s` to turn the deep scan off. `LOG_LEVEL` ignores case, accepts `warning` for `warn`, and accepts an offset such as `info+2`. A missing or blank `PLEX_URL` or `PLEX_TOKEN` stops the start with an error that names the variable.

## Keeping the token in a file
## Keeping the token and address in files

Set `PLEX_TOKEN_FILE` to the path of a file inside the container, instead of `PLEX_TOKEN`, to read the token from [a Docker secret](https://github.com/cplieger/docs/blob/main/docs/hardening.md#secrets-in-files). One trailing line ending is removed, and so is any space around the token.
Set `PLEX_URL_FILE` and `PLEX_TOKEN_FILE` to the path of a file inside the container, instead of `PLEX_URL` and `PLEX_TOKEN`, to read each value from [a Docker secret](https://github.com/cplieger/docs/blob/main/docs/hardening.md#secrets-in-files). One trailing line ending is removed, and so is any space around the value.

## Leaving shows and libraries out

Expand Down
51 changes: 10 additions & 41 deletions internal/deepscan/deepscan.go
Original file line number Diff line number Diff line change
@@ -1,42 +1,11 @@
// Package deepscan owns the periodic deep-analysis tick and its
// sub-workers (recent-history replay + recently-added sweep). Named for what
// it does rather than "scheduler": that name belongs to the first-party
// scheduler library this package consumes, and the collision forced a
// schedlib alias on the library at every use.
//
// Responsibilities:
// - Schedule a periodic deep-analysis run on a fixed Go-duration
// interval (default 24h), matching the fleet docker-*-scheduler
// convention: one pass at startup (when the last run is older than
// one interval) plus a time.Ticker every interval thereafter. The
// pass is a safety net over the real-time WebSocket listener, so a
// drifting wall-clock start hour is immaterial; using an interval
// rather than an absolute HH:MM boundary means the app reads no
// local wall-clock time (no TZ / time/tzdata dependency).
// - Fan out per-item work across a bounded worker pool
// with a circuit breaker that aborts the
// pass after a threshold of consecutive per-item failures.
// - Persist the last-run record in a scheduler.Stamp file on the
// persistent volume so a cold restart does not double-run the
// analysis.
//
// Stable contracts preserved (keep these exact: Loki alerts grep the log
// strings):
// - WARN slog keys ("scheduler: aborting history processing after
// consecutive failures", "scheduler: failed to fetch history",
// "scheduler: failed to fetch sections", "scheduler: deep analysis
// already in progress, skipping") byte-for-byte identical.
// - INFO slog keys ("scheduler enabled", "scheduled deep analysis
// starting", "running initial deep analysis", "deep analysis
// completed", "scheduler: processing recently added episode",
// "scheduler stopped") identical.
//
// Consumer note: every collaborator is an interface THIS package declares (see
// deps.go) — plexReader, EpisodeReader, runLedger, skipChecker and Syncer, each
// naming only the methods the pass calls. Nothing is imported from a shared
// contract package, and Syncer in particular is declared here rather than
// imported so deepscan needs no dependency on internal/tracksync. In practice
// main.go wires the concrete *tracksync.Syncer through.
// Package deepscan owns the periodic deep-analysis pass (recent-history replay
// plus recently-added sweep) over a bounded worker pool whose circuit breaker
// aborts after consecutive per-item failures. It runs on a Go-duration interval
// (default 24h), so it reads no wall-clock time, and a scheduler.Stamp file
// keeps a cold restart from double-running. alerts/logql.yaml matches "deep
// analysis completed" and docs/monitoring.md names "scheduled deep analysis
// starting": keep both exact. Every collaborator is an interface declared in
// deps.go, so deepscan imports no internal/tracksync.
package deepscan

import (
Expand Down Expand Up @@ -182,8 +151,8 @@ func (s *Scheduler) Run(ctx context.Context) {
s.deepAnalysis(ctx)
}

// Fixed-interval scheduling via scheduler.RunLoop (the fleet
// docker-*-scheduler convention). FireOnStart is false: the conditional
// Fixed-interval scheduling via scheduler.RunLoop, like the cplieger
// docker-*-scheduler images. FireOnStart is false: the conditional
// startup pass above already handled the immediate run (RunLoop's
// unconditional FireOnStart would ignore the last-run stamp and double-run
// on a recent restart), and FirstDelay phases the first tick from the
Expand Down
2 changes: 1 addition & 1 deletion main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1284,7 +1284,7 @@ func TestResolveStallCounter_pausedEventsNeverJoinTheAbsentPairs(t *testing.T) {
func TestResolveStallCounter_anEmptyClientIsNotASecondClient(t *testing.T) {
// A notification carrying no clientIdentifier is one recognisable
// condition, not a distinct client per event. Counting each as new
// would make a run of them look like a fleet-wide failure.
// would make a run of them look like a failure across every client.
c := &resolveStallCounter{}
for range resolveStallThreshold * 2 {
if v := c.miss(playingBy("", "100"), false); v.stalled {
Expand Down
Loading