Skip to content

Commit 837ee1d

Browse files
authored
Merge pull request #34 from nullable-eth/feature/exclude-labels
feat: EXCLUDE_LABELS env var for per-item opt-out
2 parents add74f2 + 2de4fdc commit 837ee1d

5 files changed

Lines changed: 186 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## [Unreleased]
4+
5+
### Added
6+
- `EXCLUDE_LABELS` environment variable (default empty): comma-separated list of Plex labels that mark items as opted-out of labelarr. Items carrying any of these labels are skipped during both apply and removal passes. Case-insensitive; surrounding whitespace and empty values in the CSV are ignored. Logged at startup when active (`[INFO] EXCLUDE_LABELS active - items tagged with any of [...] will be skipped`) and per skipped item under `VERBOSE_LOGGING=true`.
7+
8+
### Documentation
9+
- README `Library Selection` section now documents `MOVIE_LIBRARY_EXCLUDE` and `TV_LIBRARY_EXCLUDE`, which were added in 1.3.0 but only appeared in the changelog.
10+
311
## [1.3.2] - 2026-04-21
412

513
### Security

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@ Pick one approach per media type:
9292
| `TV_PROCESS_ALL=true` | Process all TV show libraries |
9393
| `TV_LIBRARY_ID=2` | Process a specific TV library by ID |
9494

95+
Optionally narrow what gets processed within those libraries:
96+
97+
| Variable | Default | Description |
98+
|----------|---------|-------------|
99+
| `MOVIE_LIBRARY_EXCLUDE` | (empty) | Comma-separated Plex library **IDs** to skip when `MOVIE_PROCESS_ALL=true` (e.g. `MOVIE_LIBRARY_EXCLUDE=8,12`). Useful for keeping a "Home Videos" library out of the scan. |
100+
| `TV_LIBRARY_EXCLUDE` | (empty) | Same as above for TV libraries. |
101+
| `EXCLUDE_LABELS` | (empty) | Comma-separated **per-item opt-out** label list. Any Plex item carrying one of these labels is skipped on both apply and removal paths. Case-insensitive. Example: `EXCLUDE_LABELS=labelarr:skip,home video`. Tag the offending items in Plex (Edit -> Tags -> Labels) and labelarr will leave them alone. |
102+
95103
### Optional
96104

97105
| Variable | Default | Description |

internal/config/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type Config struct {
2121
TVLibraryID string
2222
TVProcessAll bool
2323
TVLibraryExclude []string
24+
ExcludeLabels []string
2425
WebhookOnly bool
2526
UpdateField string
2627
RemoveMode string
@@ -78,6 +79,7 @@ func Load() *Config {
7879
TVLibraryID: os.Getenv("TV_LIBRARY_ID"),
7980
TVProcessAll: getBoolEnvWithDefault("TV_PROCESS_ALL", false),
8081
TVLibraryExclude: parseCSV(os.Getenv("TV_LIBRARY_EXCLUDE")),
82+
ExcludeLabels: parseCSV(os.Getenv("EXCLUDE_LABELS")),
8183
WebhookOnly: getBoolEnvWithDefault("WEBHOOK_ONLY", false),
8284
UpdateField: getEnvWithDefault("UPDATE_FIELD", "label"),
8385
RemoveMode: os.Getenv("REMOVE"),

internal/media/processor.go

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package media
33
import (
44
"fmt"
55
"regexp"
6+
"sort"
67
"strings"
78
"sync"
89
"time"
@@ -58,6 +59,10 @@ type Processor struct {
5859
cacheMu sync.RWMutex
5960
processingMu sync.Mutex
6061
processing map[string]bool
62+
63+
// excludeLabels is the lowercased set of Plex labels that mark items as opted-out.
64+
// Built once from config.ExcludeLabels in NewProcessor.
65+
excludeLabels map[string]struct{}
6166
}
6267

6368
// NewProcessor creates a new generic media processor
@@ -76,15 +81,25 @@ func NewProcessor(cfg *config.Config, clients Clients) (*Processor, error) {
7681
}
7782
}
7883

84+
excludeLabels := make(map[string]struct{}, len(cfg.ExcludeLabels))
85+
for _, l := range cfg.ExcludeLabels {
86+
t := strings.TrimSpace(strings.ToLower(l))
87+
if t == "" {
88+
continue
89+
}
90+
excludeLabels[t] = struct{}{}
91+
}
92+
7993
processor := &Processor{
80-
config: cfg,
81-
plexClient: plexClient,
82-
tmdbClient: tmdbClient,
83-
radarrClient: radarrClient,
84-
sonarrClient: sonarrClient,
85-
storage: stor,
86-
keywordCache: make(map[string][]string),
87-
processing: make(map[string]bool),
94+
config: cfg,
95+
plexClient: plexClient,
96+
tmdbClient: tmdbClient,
97+
radarrClient: radarrClient,
98+
sonarrClient: sonarrClient,
99+
storage: stor,
100+
keywordCache: make(map[string][]string),
101+
processing: make(map[string]bool),
102+
excludeLabels: excludeLabels,
88103
}
89104

90105
// Initialize exporter if export is enabled
@@ -108,9 +123,33 @@ func NewProcessor(cfg *config.Config, clients Clients) (*Processor, error) {
108123
fmt.Printf("[SYNC] Running in ephemeral mode - no persistent storage (set DATA_DIR to enable)\n")
109124
}
110125

126+
if len(excludeLabels) > 0 {
127+
labels := make([]string, 0, len(excludeLabels))
128+
for l := range excludeLabels {
129+
labels = append(labels, l)
130+
}
131+
sort.Strings(labels)
132+
fmt.Printf("[INFO] EXCLUDE_LABELS active - items tagged with any of %v will be skipped (case-insensitive)\n", labels)
133+
}
134+
111135
return processor, nil
112136
}
113137

138+
// isExcludedByLabel reports whether the item carries any Plex label listed in
139+
// EXCLUDE_LABELS. Match is case-insensitive. When excluded, returns the actual
140+
// label tag from Plex (preserving the original casing) for logging.
141+
func (p *Processor) isExcludedByLabel(item MediaItem) (string, bool) {
142+
if len(p.excludeLabels) == 0 {
143+
return "", false
144+
}
145+
for _, lbl := range item.GetLabel() {
146+
if _, ok := p.excludeLabels[strings.ToLower(lbl.Tag)]; ok {
147+
return lbl.Tag, true
148+
}
149+
}
150+
return "", false
151+
}
152+
114153
// GetExporter returns the exporter instance if export is enabled
115154
func (p *Processor) GetExporter() *export.Exporter {
116155
return p.exporter
@@ -242,6 +281,11 @@ func (p *Processor) ProcessSingleItem(ratingKey, libraryID string, mediaType Med
242281

243282
fmt.Printf("[WEBHOOK] Processing single item: %s (%d)\n", item.GetTitle(), item.GetYear())
244283

284+
if tag, skip := p.isExcludedByLabel(item); skip {
285+
fmt.Printf("[SKIP] %s (%d) excluded by label %q (EXCLUDE_LABELS)\n", item.GetTitle(), item.GetYear(), tag)
286+
return nil
287+
}
288+
245289
tmdbID := p.extractTMDbID(item, mediaType)
246290
if tmdbID == "" {
247291
fmt.Printf("[SKIP] No TMDb ID found for: %s\n", item.GetTitle())
@@ -394,6 +438,14 @@ func (p *Processor) ProcessAllItems(libraryID string, libraryName string, mediaT
394438
for _, item := range b.items {
395439
processedCount++
396440

441+
if tag, skipExcl := p.isExcludedByLabel(item); skipExcl {
442+
if p.config.VerboseLogging {
443+
fmt.Printf(" [SKIP] %s (%d) excluded by label %q (EXCLUDE_LABELS)\n", item.GetTitle(), item.GetYear(), tag)
444+
}
445+
skippedItems++
446+
continue
447+
}
448+
397449
if totalCount > 100 {
398450
progress := (processedCount * 100) / totalCount
399451
if progress >= lastProgressReport+10 {
@@ -694,6 +746,14 @@ func (p *Processor) RemoveKeywordsFromItems(libraryID string, mediaType MediaTyp
694746
fmt.Printf("[STATS] Removal Progress: %d/%d (%.1f%%)\n", processedCount, len(items), float64(processedCount)/float64(len(items))*100)
695747
}
696748

749+
if tag, skipExcl := p.isExcludedByLabel(item); skipExcl {
750+
if p.config.VerboseLogging {
751+
fmt.Printf(" [SKIP] %s (%d) excluded by label %q (EXCLUDE_LABELS)\n", item.GetTitle(), item.GetYear(), tag)
752+
}
753+
skippedCount++
754+
continue
755+
}
756+
697757
tmdbID := p.extractTMDbID(item, mediaType)
698758
if tmdbID == "" {
699759
skippedCount++

internal/media/processor_test.go

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
package media
22

3-
import "testing"
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/nullable-eth/labelarr/internal/plex"
8+
)
49

510
func TestExtractTMDbIDFromPath(t *testing.T) {
611
tests := []struct {
@@ -272,3 +277,97 @@ func TestExtractTMDbIDFromPathEdgeCases(t *testing.T) {
272277
})
273278
}
274279
}
280+
281+
func TestIsExcludedByLabel(t *testing.T) {
282+
tests := []struct {
283+
name string
284+
excludeLabels []string
285+
itemLabels []string
286+
wantSkip bool
287+
wantMatchedTag string
288+
}{
289+
{
290+
name: "no exclude labels configured",
291+
excludeLabels: nil,
292+
itemLabels: []string{"labelarr:skip"},
293+
wantSkip: false,
294+
},
295+
{
296+
name: "item has no labels",
297+
excludeLabels: []string{"labelarr:skip"},
298+
itemLabels: nil,
299+
wantSkip: false,
300+
},
301+
{
302+
name: "exact match",
303+
excludeLabels: []string{"labelarr:skip"},
304+
itemLabels: []string{"labelarr:skip"},
305+
wantSkip: true, wantMatchedTag: "labelarr:skip",
306+
},
307+
{
308+
name: "case-insensitive match (config upper, label lower)",
309+
excludeLabels: []string{"LABELARR:SKIP"},
310+
itemLabels: []string{"labelarr:skip"},
311+
wantSkip: true, wantMatchedTag: "labelarr:skip",
312+
},
313+
{
314+
name: "case-insensitive match (config lower, label mixed)",
315+
excludeLabels: []string{"home video"},
316+
itemLabels: []string{"Home Video"},
317+
wantSkip: true, wantMatchedTag: "Home Video",
318+
},
319+
{
320+
name: "multiple exclude labels, second one matches",
321+
excludeLabels: []string{"foo", "labelarr:skip", "bar"},
322+
itemLabels: []string{"family", "labelarr:skip"},
323+
wantSkip: true, wantMatchedTag: "labelarr:skip",
324+
},
325+
{
326+
name: "no match",
327+
excludeLabels: []string{"labelarr:skip"},
328+
itemLabels: []string{"family", "vacation"},
329+
wantSkip: false,
330+
},
331+
{
332+
name: "whitespace and casing in config are normalized",
333+
excludeLabels: []string{" Labelarr:Skip "},
334+
itemLabels: []string{"labelarr:skip"},
335+
wantSkip: true, wantMatchedTag: "labelarr:skip",
336+
},
337+
{
338+
name: "empty string in config is ignored",
339+
excludeLabels: []string{"", "labelarr:skip"},
340+
itemLabels: []string{""},
341+
wantSkip: false,
342+
},
343+
}
344+
345+
for _, tc := range tests {
346+
t.Run(tc.name, func(t *testing.T) {
347+
// Build the lowercased set the way NewProcessor does.
348+
set := make(map[string]struct{})
349+
for _, l := range tc.excludeLabels {
350+
k := strings.TrimSpace(strings.ToLower(l))
351+
if k == "" {
352+
continue
353+
}
354+
set[k] = struct{}{}
355+
}
356+
p := &Processor{excludeLabels: set}
357+
358+
plexLabels := make([]plex.Label, 0, len(tc.itemLabels))
359+
for _, l := range tc.itemLabels {
360+
plexLabels = append(plexLabels, plex.Label{Tag: l})
361+
}
362+
item := plex.Movie{Title: "test", Year: 2020, Label: plexLabels}
363+
364+
gotTag, gotSkip := p.isExcludedByLabel(item)
365+
if gotSkip != tc.wantSkip {
366+
t.Errorf("skip=%v, want %v (matched tag %q)", gotSkip, tc.wantSkip, gotTag)
367+
}
368+
if tc.wantSkip && gotTag != tc.wantMatchedTag {
369+
t.Errorf("matched tag=%q, want %q", gotTag, tc.wantMatchedTag)
370+
}
371+
})
372+
}
373+
}

0 commit comments

Comments
 (0)