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
12 changes: 12 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ jobs:
# runner needs QEMU for the non-native architecture and a buildx builder.
# Logging in with the built-in token is what lets the image land on ghcr.io
# under the repo's own namespace, with no extra registry secret to manage.
#
# A package published here is PRIVATE, whatever the repository's visibility
# is, and the first release after this step was added produced an image
# nobody could pull: an anonymous request answered 403. Making it public is
# a one-time change made by hand on the package itself --
# https://github.com/orgs/TheIntroDB/packages/container/plex-sync/settings
# -- because the workflow token cannot make it. Measured against the
# package as published: this token reads the visibility fine, and a PATCH
# of it answers 404. Visibility belongs to the package rather than to a
# version, so doing it once covers every release after; check it after the
# first publish, and after any change to the image name below, which would
# create a new package.
- name: Set up QEMU
uses: docker/setup-qemu-action@v3

Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ docker run -d --restart=unless-stopped \
--name plex-sync \
-e PLEX_URL=http://plex:32400 \
-e PLEX_TOKEN=xxxxxxxxxxxx \
-e PLEX_SYNC_DEVICE_NAME="plex-sync (media-nas)" \
-v "/mnt/cache/appdata/plex/Library/Application Support/Plex Media Server:/plex:ro" \
-v "/mnt/cache/appdata/plex-sync:/state" \
ghcr.io/theintrodb/plex-sync:latest schedule --yes
Expand All @@ -174,6 +175,11 @@ The image is published to the GitHub Container Registry as
`ghcr.io/theintrodb/plex-sync`, tagged with each release version and with
`latest`.

`PLEX_SYNC_DEVICE_NAME` is optional and is what Plex shows in its device list,
so the name you recognise is the one in the notification. The state volume has
to persist either way: it holds the identity Plex knows this container by, and a
container that loses it registers as a new device again.

The Plex database must be mounted at its real, non-FUSE path. On Unraid that
means the `/mnt/cache/...` path, never `/mnt/user/...`: SQLite locking through
the shfs layer is not reliable and a write can corrupt the database. The tool
Expand Down Expand Up @@ -359,6 +365,8 @@ Lookup order: `$PLEX_SYNC_CONFIG`, `./plex-sync.toml`,
| `plex.token` | | Plex token, for the HTTP API |
| `plex.database` | | path to `com.plexapp.plugins.library.db` |
| `plex.config_dir` | | Plex application-support directory, if the database path is not given |
| `plex.device_name` | `plex-sync` | what Plex shows for this tool in its device list |
| `plex.client_id` | stored in `state_dir` | the identity Plex keys this install's device entry on |
| `theintrodb.api_key` | | optional TheIntroDB API key |
| `theintrodb.daily_budget` | `1000` | requests per UTC day |
| `sources.chapters` | `false` | use chapter names as a source |
Expand All @@ -373,9 +381,17 @@ Lookup order: `$PLEX_SYNC_CONFIG`, `./plex-sync.toml`,
| `state_dir` | `~/.config/plex-sync` | ledger, backups and undo journals |

Environment variables: `PLEX_URL`, `PLEX_TOKEN`, `PLEX_DB`, `PLEX_CONFIG_DIR`,
`PLEX_SYNC_CLIENT_ID`, `PLEX_SYNC_DEVICE_NAME`,
`TIDB_API_KEY`, `TIDB_API_URL`, `PLEX_SYNC_STATE_DIR`, `PLEX_SYNC_LOG_LEVEL`,
`PLEX_SYNC_CHAPTERS`, `PLEX_SYNC_DETECTION`, `PLEX_SYNC_ALLOW_LIVE`.

Plex identifies a caller by an identifier it sends on every request, and treats
one it has not seen as a new device — so the identifier is stored in the state
directory and reused, and the first run of an install is the only one that
registers a device. Set `plex.device_name` (or `PLEX_SYNC_DEVICE_NAME`) to
whatever you recognise in Plex's device list, because that is the name Plex will
show and notify with; a container should set it to its own name.

---

## Sources
Expand Down
20 changes: 20 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,26 @@ The other causes are ordinary: no read access to the database, or episodes whose
show rows carry no provider ids. Check what `plex-sync library` prints for the
ids an item would be looked up with.

## A "new device used your server" notification every run

Expected once, not nightly. Plex identifies a caller by the identifier it sends,
and one it has not seen before is a new device, which is what it notifies about.

The identifier is stored in `<state_dir>/client-id` and reused, so a fresh
install registers one device and keeps it. Every run looking like a new device
means that file is not surviving: most often the state directory is inside a
container that is recreated without a volume, or it is somewhere that is wiped.
Keep `state_dir` — the same directory the ledger lives in — on something
persistent.

If the notification has empty brackets where the device name should be, the name
is not set. `plex.device_name`, or `PLEX_SYNC_DEVICE_NAME` for a container, is
what Plex shows; it defaults to `plex-sync`.

The device list in Plex is not cleaned up by any of this. Runs from before the
identifier was stored each left an entry of their own, and those stay until they
are removed by hand.

## "no-provider-id"

The item has no TMDb, IMDb or Tvdb id, so there is nothing to look it up by.
Expand Down
9 changes: 9 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@ func Open(cfg *config.Config, log *slog.Logger, opts Options) (*App, error) {
}
}

// The identifier Plex keys this install's device entry on is kept here, once,
// so that a nightly run is the same device to Plex every night. It is not
// fatal when it cannot be stored: the tool still works, it just registers as
// a new device, which is the thing this exists to stop.
if err := cfg.ResolvePlexIdentity(); err != nil {
log.Warn("could not keep a durable Plex device identity, so Plex will see each run as a new device",
"error", err)
}

application := &App{
Cfg: cfg,
Log: log,
Expand Down
31 changes: 30 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ type Config struct {

// Path is where the config was loaded from, empty for defaults.
Path string `toml:"-"`
// PlexClientIDResolved records that ResolvePlexIdentity supplied
// Plex.ClientID from the state directory rather than finding it set by a
// person. It exists for one decision: a resolved identifier is left out of
// the file when the configuration is written, and one that was chosen is
// kept even when it happens to equal what is stored. Comparing the two
// values instead would quietly discard an override that was deliberate.
PlexClientIDResolved bool `toml:"-"`
}

// Plex configures how to reach Plex and where its database lives.
Expand All @@ -72,6 +79,16 @@ type Plex struct {
AllowFusePath bool `toml:"allow_fuse_path"`
// InsecureSkipVerify is only for a self-signed local Plex.
InsecureSkipVerify bool `toml:"insecure_skip_verify"`
// ClientID is what Plex keys this install's device entry on: an identifier
// it has not seen before is a new device, which is what a "used a new device
// to access your server" notification is sent for. Empty uses the identifier
// stored in the state directory, created on first run. Set it only when the
// state directory is not durable, or to give several installs one identity.
ClientID string `toml:"client_id"`
// DeviceName is what Plex calls this tool in its device list and in that
// notification. Empty means "plex-sync"; PLEX_SYNC_DEVICE_NAME overrides
// both, which is what a container should set.
DeviceName string `toml:"device_name"`
}

// TheIntroDB configures the API client.
Expand Down Expand Up @@ -616,6 +633,8 @@ func (c *Config) applyEnv() {
str("PLEX_CONFIG_DIR", &c.Plex.ConfigDir)
boolean("PLEX_INSECURE", &c.Plex.InsecureSkipVerify)
boolean("PLEX_ALLOW_FUSE_PATH", &c.Plex.AllowFusePath)
str(EnvClientID, &c.Plex.ClientID)
str(EnvDeviceName, &c.Plex.DeviceName)

str("TIDB_API_KEY", &c.TheIntroDB.APIKey)
str("TIDB_API_URL", &c.TheIntroDB.BaseURL)
Expand Down Expand Up @@ -818,7 +837,8 @@ func exampleConfig(stateDir string) string {
return `# plex-sync configuration.
# Every value here is optional; the defaults shown are the built-in ones.
# Environment variables override this file (PLEX_URL, PLEX_TOKEN, PLEX_DB,
# PLEX_CONFIG_DIR, TIDB_API_KEY, TIDB_API_URL, PLEX_SYNC_STATE_DIR).
# PLEX_CONFIG_DIR, PLEX_SYNC_CLIENT_ID, PLEX_SYNC_DEVICE_NAME,
# TIDB_API_KEY, TIDB_API_URL, PLEX_SYNC_STATE_DIR).

# Top-level keys must come before the first section header, or TOML reads them
# as part of that section.
Expand All @@ -837,6 +857,15 @@ token = ""
database = ""
# Alternatively, the Plex application-support directory.
config_dir = ""
# What Plex shows for this tool in its device list, and in the "a new device
# used your server" notification. Empty means "plex-sync". A container should
# set this (or PLEX_SYNC_DEVICE_NAME) to the name it is known by, because that
# is the name that will appear.
device_name = ""
# The identifier Plex keys this install's device entry on, so it does not treat
# every run as a new device. Empty uses the one stored in the state directory,
# which is created on first run; only set it when that directory is not durable.
client_id = ""

[theintrodb]
# The TheIntroDB API key is optional. With a key the daily allowance is higher
Expand Down
220 changes: 220 additions & 0 deletions internal/config/identity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
package config

import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)

// Plex device identity: the client identifier Plex keys this install's device
// entry on, and the name it shows for it.
//
// Both exist because of one report. A scheduled run produced a "used a new
// device to access your server" notification every night, with nothing in the
// brackets where the device name goes. The identifier was generated per process,
// so Plex saw a new device on every run; no device name was sent at all, so its
// notification had nothing to print. Neither is visible from inside Plex: the
// tool worked, and the only symptom was mail the user could not place.
const (
// EnvClientID overrides the stored client identifier.
EnvClientID = "PLEX_SYNC_CLIENT_ID"
// EnvDeviceName overrides the name Plex shows for this tool.
EnvDeviceName = "PLEX_SYNC_DEVICE_NAME"
// ClientIDFile is the stored identifier inside the state directory.
ClientIDFile = "client-id"
// DefaultDeviceName is what Plex shows when nothing names this tool.
DefaultDeviceName = "plex-sync"
// clientIDPrefix marks a generated identifier as this tool's.
clientIDPrefix = "plex-sync-"
// maxClientIDLen bounds a stored identifier. Plex does not publish a limit;
// this one is small enough to be obviously sane and far larger than the
// identifiers real clients send.
maxClientIDLen = 128

// clientIDWaitAttempts and clientIDWaitInterval bound the wait for another
// process to finish writing an identifier it has created but not yet
// written. Two processes racing here is a startup collision, not a
// workload, so the window is a fraction of a second and the wait is over
// rather than indefinite.
clientIDWaitAttempts = 50
clientIDWaitInterval = 2 * time.Millisecond
)

// ResolvedDeviceName is the name Plex shows for this tool: the environment, then
// the file, then "plex-sync".
//
// The environment is read first because a container's name is the one thing it
// should be told rather than discover: its state directory is often all it has,
// and the name a person recognises in a Plex device list is usually the container
// or host they put it on.
func (p Plex) ResolvedDeviceName() string {
if v := strings.TrimSpace(os.Getenv(EnvDeviceName)); v != "" {
return v
}
if v := strings.TrimSpace(p.DeviceName); v != "" {
return v
}
return DefaultDeviceName
}

// ResolvePlexIdentity fills in the client identifier when nothing has chosen one,
// storing a generated one in the state directory.
//
// A stored identifier is the whole point: Plex treats an identifier it has not
// seen as a new device, so one that changes between runs turns a nightly timer
// into a nightly notification, and fills the device list with a device per run.
// Keeping it in the state directory rather than in the configuration file is
// deliberate -- it belongs to the install, not to a person, so it follows the
// state and not a config that gets copied between machines.
func (c *Config) ResolvePlexIdentity() error {
if v := strings.TrimSpace(os.Getenv(EnvClientID)); v != "" {
c.Plex.ClientID = v
return nil
}
if v := strings.TrimSpace(c.Plex.ClientID); v != "" {
return nil
}
if stored := c.StoredClientID(); stored != "" {
c.Plex.ClientID = stored
c.PlexClientIDResolved = true
return nil
}

id := NewClientID()
if err := os.MkdirAll(c.StateDir, 0o755); err != nil {
return fmt.Errorf("create state directory %s: %w", c.StateDir, err)
}
// O_EXCL, so that two processes starting against the same state directory
// agree on one identifier: the one that creates the file wins, and the other
// adopts what it wrote. Without it both generate one, both write, each keeps
// its own in memory -- two devices to Plex from one install -- and the file
// holds whichever finished last.
f, err := os.OpenFile(c.ClientIDPath(), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
switch {
case err == nil:
_, writeErr := f.WriteString(id + "\n")
closeErr := f.Close()
if writeErr != nil {
return fmt.Errorf("write %s: %w", c.ClientIDPath(), writeErr)
}
if closeErr != nil {
return fmt.Errorf("write %s: %w", c.ClientIDPath(), closeErr)
}
c.Plex.ClientID = id
c.PlexClientIDResolved = true
return nil
case errors.Is(err, os.ErrExist):
// Another process created it between the read above and this open, so
// its identifier is the install's and ours is not. The file is created
// before it is written, so an immediate read can catch it still empty;
// waiting for it is bounded, and running without one is worse than
// waiting a moment.
for attempt := 0; attempt < clientIDWaitAttempts; attempt++ {
if stored := c.StoredClientID(); stored != "" {
c.Plex.ClientID = stored
c.PlexClientIDResolved = true
return nil
}
time.Sleep(clientIDWaitInterval)
}
// It exists and holds nothing usable: a crash between the create and
// the write, or a file that was truncated or edited. Replacing it is
// the only way out, because otherwise every run waits and then uses an
// identifier that is not the one on disk.
if err := writeClientID(c.ClientIDPath(), id); err != nil {
return err
}
c.Plex.ClientID = id
c.PlexClientIDResolved = true
return nil
default:
return fmt.Errorf("write %s: %w", c.ClientIDPath(), err)
}
}

// writeClientID replaces the stored identifier through a temporary file, so a
// reader arriving during the write sees either the old value or the new one and
// never half of either.
func writeClientID(path, id string) error {
dir := filepath.Dir(path)
temp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp*")
if err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
name := temp.Name()
defer func() { _ = os.Remove(name) }() // a no-op once renamed

if _, err := temp.WriteString(id + "\n"); err != nil {
_ = temp.Close()
return fmt.Errorf("write %s: %w", path, err)
}
if err := temp.Close(); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
if err := os.Chmod(name, 0o600); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
if err := os.Rename(name, path); err != nil {
return fmt.Errorf("replace %s: %w", path, err)
}
return nil
}

// ClientIDPath is where the identifier is stored.
func (c *Config) ClientIDPath() string {
return filepath.Join(c.StateDir, ClientIDFile)
}

// StoredClientID returns the identifier already stored for this state directory,
// or "" when there is not a usable one.
//
// The file is read from disk and its contents end up in a request header, so what
// is there is validated rather than trusted. A file that was truncated, edited
// or written as something else entirely must be replaced rather than sent.
func (c *Config) StoredClientID() string {
raw, err := os.ReadFile(c.ClientIDPath())
if err != nil {
return ""
}
return usableClientID(string(raw))
}

// NewClientID returns a fresh identifier in the shape Plex expects: its own
// prefix, so an operator reading Plex's device list can tell what the device is
// even before its name says so.
func NewClientID() string {
var buf [16]byte
if _, err := rand.Read(buf[:]); err != nil {
// crypto/rand does not fail on any platform this ships to. The
// fallback is not a good identifier, but a run is not worth failing
// over one, and a timestamp is still better than nothing at all.
return clientIDPrefix + strconv.FormatInt(time.Now().UnixNano(), 16)
}
return clientIDPrefix + hex.EncodeToString(buf[:])
}

// usableClientID returns the identifier held in a file's contents, or "" when
// what is there is not one.
func usableClientID(raw string) string {
id := strings.TrimSpace(raw)
if id == "" || len(id) > maxClientIDLen {
return ""
}
for _, r := range id {
switch {
case r >= 'a' && r <= 'z',
r >= 'A' && r <= 'Z',
r >= '0' && r <= '9',
r == '-', r == '_', r == '.':
default:
return ""
}
}
return id
}
Loading
Loading