-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathplugin.py
More file actions
2461 lines (2194 loc) · 118 KB
/
Copy pathplugin.py
File metadata and controls
2461 lines (2194 loc) · 118 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
VOD to Media Library — Dispatcharr VOD .strm Generator Plugin
(slug: vod2mlib)
v1.18.0 — cleaner NFO titles (provider tags/quality tokens stripped)
plus an option to omit <title> entirely so Jellyfin uses TMDB;
collapse duplicate episode relations to one .strm; bigger
series batch sizes; warn when a TMDB ID is missing.
MIT License
Copyright (c) 2025-2026 shedunraid (original author)
Copyright (c) 2026 R3XCHRIS (downstream maintainer, fork)
Upstream: https://github.com/shedunraid/VOD2MLIB
This fork: https://github.com/R3XCHRIS/VOD2MLIB
"""
import os
import re
from typing import Dict, Any
from concurrent.futures import ThreadPoolExecutor, as_completed
class Plugin:
"""Generate .strm files for VOD movies from Dispatcharr."""
name = "VOD to Media Library"
version = "1.18.0"
help_url = "https://github.com/R3XCHRIS/VOD2MLIB#readme"
description = (
"Convert Dispatcharr VODs into media-server-friendly .strm files, with "
"optional NFO metadata, batch processing, and a cron-driven auto-rescan."
)
# Tunables
MAX_WORKERS = 3
LOG_EVERY = 50
LOG_FIRST_N = 10
MAX_FILENAME_LEN = 200
# Cap the category list printed by Scan — catalogues can have hundreds.
SCAN_CATEGORY_LIMIT = 40
# Schedule task identity (django-celery-beat row name + Celery task name)
SCHEDULE_TASK_NAME = "vod2mlib.auto_rescan"
SCHEDULED_TASK_CELERY_NAME = "vod2mlib.scheduled_rescan"
# The legacy default Dispatcharr URL — a placeholder that must NOT be
# shipped into .strm files. We reject it explicitly to catch users who
# forgot to click Save after editing the URL field.
PLACEHOLDER_DISPATCHARR_URL = "http://192.168.99.11:9191"
# File suffixes the plugin writes (used by cleanup and skip logic)
_PLUGIN_FILE_SUFFIXES = ('.strm', '.nfo')
# Language / provider tag prefixes stripped from titles and category names.
# Handles the formats providers actually use, each guarded against eating
# real titles (see issue #3):
# * "EN - Title" — dash, any 2-3 letter code. Requires whitespace BEFORE
# the dash so "AC-130" / "MI-5" are preserved.
# * "EN| Title" — pipe, any 2-3 letter code (a leading pipe is never a
# real title, so any code is safe here).
# * "EN Title" — bare space, restricted to "EN" ONLY so real titles
# like "IT Chapter Two", "UP (2009)", "ED TV" survive.
# * "▪NL▪ Title" — bullet-wrapped code, e.g. ▪NL▪ / ▪MULTIG▪ (any 2-8
# letters between marker symbols).
_BULLET_CHARS = r'▪▫■□●○•·◦‣⁃︎️'
# Provider quality/edition tags that lead a title, e.g. '4K-A+ Title',
# 'A+ Title'. Only tokens containing '+' are stripped here: a leading '+'
# token is never part of a real title, whereas letter-hyphen-letter forms
# very much are ('X-Men', 'AC-130', 'MI-5'), so those are left alone.
# Reported by @matrix26 (provider tags like 4K-A+, EN-TOP, AMZ).
# Provider plus-tags: "A+ ", "4K-A+ ", "VIP+ ". Uppercase-only and a
# MANDATORY trailing space, so real titles that merely contain a "+"
# survive — "Love+War" and "Genera+ion" are both real films.
_PROVIDER_PLUS_TAG_RE = re.compile(r'^[A-Z0-9]{1,6}(?:[-_][A-Z0-9]{1,6})*\+\s+')
_LEADING_DELIM_TAG_RE = re.compile(r'^(?:[\[\(\|]\s*[A-Za-z0-9][A-Za-z0-9 +\-]{0,9}\s*[\]\)\|]\s*)+')
_EMPTY_DELIM_RE = re.compile(r'[\[\(]\s*[\]\)]')
_LANGUAGE_PREFIX_RE = re.compile(
r'^(?:'
r'[A-Z]{2,3}\s+-\s*' # EN - Title
r'|[A-Z]{2,3}\s*\|\s*' # EN| Title
r'|EN\s+' # EN Title (EN only)
r'|[' + _BULLET_CHARS + r']+\s*[A-Za-z]{2,8}\s*[' + _BULLET_CHARS + r']+\s*' # ▪NL▪ Title
r')'
)
_TRAILING_YEAR_RE = re.compile(r'\s*\((\d{4})\)\s*$')
# First-(YYYY) detector for v1.15.0+ folder-name cleanup. Some providers ship
# titles like "Cool Hand Luke 4K (1967) PAUL NEWMAN (1967)" — ChannelsDVR
# scrapes off the folder name and fails to match those because of the
# trailing junk. Truncating at the first (YYYY) yields "Cool Hand Luke 4K"
# which then has quality tokens stripped to give "Cool Hand Luke".
_FIRST_YEAR_RE = re.compile(r'\((\d{4})\)')
# Bare trailing year (no parens) at the very end of a title, e.g.
# "Wicked: For Good - 2025". The negative lookbehind stops it matching the
# tail of a longer digit run ("12345"). Used by
# _strip_redundant_trailing_year to de-duplicate the year a provider stuffs
# into the title against the (YYYY) suffix the plugin adds.
_BARE_TRAILING_YEAR_RE = re.compile(r'(?<!\d)(\d{4})\s*$')
# Quality / encoding tokens commonly stuffed into provider VOD titles.
# Stripped from folder names so media-server scrapers see a clean title.
# Word-boundary anchored so legitimate substrings ("Whiplash" etc.) survive.
_QUALITY_TOKEN_ALT = (
r'4K|UHD|FHD|HD|SD|HDR(?:10\+?)?|HEVC|H\.?26[45]|x26[45]|'
r'1080p|720p|2160p|480p|BluRay|BDRip|DVDRip|WEB-?DL|HDTV|REMUX'
)
_QUALITY_TOKEN_RE = re.compile(r'\b(' + _QUALITY_TOKEN_ALT + r')\b', re.IGNORECASE)
# Edge-anchored variants for NFO titles. Folder names can afford to strip
# these anywhere; a <title> cannot — "NTSF:SD:SUV::" is a real show, and
# removing its interior "SD" corrupts the name.
_LEADING_QUALITY_RE = re.compile(
r'^(?:(?:' + _QUALITY_TOKEN_ALT + r')\b[\s\-_:|]*)+', re.IGNORECASE)
_TRAILING_QUALITY_RE = re.compile(
r'(?:[\s\-_:|]*\b(?:' + _QUALITY_TOKEN_ALT + r'))+\s*$', re.IGNORECASE)
# If removing a trailing quality token leaves the title dangling on a
# connector, the token was part of the name: "WWII in HD" is a real
# series, and "WWII in" is not a title anyone meant to write.
_DANGLING_TAIL_RE = re.compile(
r'\b(?:a|an|the|in|on|at|of|to|for|and|or|with|from|is|my)$', re.IGNORECASE)
# Year-bucket category names like "2026 Movies", "1990s Series",
# "2020 TV Shows" — these are navigation buckets from the IPTV provider's
# category list, not real genres. Suppressed when the genre would
# otherwise be one of these.
_YEAR_BUCKET_GENRE_RE = re.compile(
r'^\d{2,4}s?\s+(movies?|series|tv\s*shows?)$',
re.IGNORECASE,
)
fields = [
{
"id": "_about",
"label": "About",
"type": "info",
"description": "Workflow:\n 1. Configure paths below.\n 2. Actions → Scan → see catalogue totals.\n 3. Actions → Generate Movies / Generate Series (start with Batch Size 10).\n 4. (Optional) Turn ON Refresh Existing Series, set cron, click Apply Schedule for nightly auto-rescan.\n\nDocs: https://github.com/R3XCHRIS/VOD2MLIB",
},
{
"id": "_section_paths",
"label": "[PATHS & HOSTS]",
"type": "info",
"description": "Where to write .strm files and how media servers reach Dispatcharr.",
},
{
"id": "root_folder",
"label": "Root Folder for Movies",
"type": "string",
"default": "/VODS/Movies",
"help_text": "Path inside the Dispatcharr container where movie folders will be created."
},
{
"id": "series_root_folder",
"label": "Root Folder for Series",
"type": "string",
"default": "/VODS/Series",
"help_text": "Path inside the Dispatcharr container where series folders will be created."
},
{
"id": "dispatcharr_url",
"label": "Dispatcharr URL (REQUIRED)",
"type": "string",
"default": "",
"placeholder": "http://192.168.1.10:9191",
"help_text": "Required. The externally-reachable URL of your Dispatcharr instance — this gets baked into every .strm file, so it must resolve from wherever your media server runs. localhost works ONLY if the media server is on the same host with shared network namespace; otherwise use a routable LAN IP/hostname. Don't forget to click Save."
},
{
"id": "_section_movies",
"label": "[MOVIES]",
"type": "info",
"description": "Settings for the Generate Movies action.",
},
{
"id": "batch_size",
"label": "Batch Size (Movies)",
"type": "select",
"default": "250",
"options": [
{"value": "10", "label": "10 movies"},
{"value": "100", "label": "100 movies"},
{"value": "200", "label": "200 movies"},
{"value": "500", "label": "500 movies"},
{"value": "1000", "label": "1000 movies"},
{"value": "all", "label": "All movies"}
],
"help_text": "Number of movies to process in this run"
},
{
"id": "generate_nfo",
"label": "Generate Movie NFO Files",
"type": "boolean",
"default": True,
"help_text": "Create .nfo metadata files for movies"
},
{
"id": "nfo_omit_title",
"label": "Omit <title> from NFO files",
"type": "boolean",
"default": False,
"help_text": "Leave the `<title>` element OUT of generated movie and tvshow NFO files. Jellyfin (and Emby) treat a `<title>` in the NFO as authoritative and will NOT override it from TMDB — so if your provider prefixes titles with tags like `4K-A+`, `EN-TOP` or `AMZ`, that junk becomes the displayed name. With this ON the plugin still writes the NFO (IDs, plot, genres, rating, poster) but omits the title, letting your media server take the clean title from TMDB via the `<tmdbid>` we already emit. OFF by default (unchanged behaviour). Note v1.18.0 also cleans provider junk out of the title, so try that first — this is the belt-and-braces option. Episode NFOs always keep their title (media servers match episodes by season/episode number)."
},
{
"id": "nest_movies_by_category",
"label": "Nest Movies by Category",
"type": "boolean",
"default": False,
"help_text": "Wrap each movie's folder inside a subfolder named by its M3U category. Useful when your provider organises movies by genre. Movies without a category go into a folder named 'Unassigned'. Same content with different categories (e.g. 4K vs HD) gets separate folders intentionally — turn ON Dedupe Movies Across Categories below to suppress this for genre-overlap cases."
},
{
"id": "dedupe_movies_across_categories",
"label": "Dedupe Movies Across Categories",
"type": "boolean",
"default": False,
"help_text": "When `Nest Movies by Category` is ON and a movie is tagged with multiple categories upstream (e.g. 'Action' AND 'Sci-Fi'), write the `.strm` under the first category only (alphabetical by category name) instead of duplicating across all of them. No effect when `Nest Movies by Category` is OFF — in that case multi-category movies already resolve to the same folder. Use this when you want one folder per movie regardless of provider tagging; your media server's genre tags still reflect every category via the NFO. ⚠ MIGRATION: changing this on an already-generated library does NOT remove the old duplicate folders — it just stops creating new ones. To clean up existing duplicates, run `[⚠ DANGER] Clean up Movies` once, then re-generate."
},
{
"id": "append_tmdb_id_to_folder",
"label": "Append TMDB ID to folder names",
"type": "boolean",
"default": False,
"help_text": "Append a TMDB id tag to every Movies and Series folder name when a TMDB ID is known — e.g. `Cool Hand Luke (1967) {tmdb-378}/`. Media servers honour this as a forced exact match, which is the safest defence against name collisions and bad metadata scrapes. Pick the convention your server expects with `TMDB Folder Tag Format` below. ⚠ MIGRATION: the plugin does NOT rename existing folders in place — turning this on (or off) for an already-generated library writes the new folder names ALONGSIDE the old ones, creating duplicates. To switch cleanly, run `[⚠ DANGER] Clean up Movies` / `Series` first, then re-generate; or accept the duplicates until the old folders age out."
},
{
"id": "tmdb_tag_format",
"label": "TMDB Folder Tag Format",
"type": "select",
"default": "plex",
"options": [
{"value": "plex", "label": "Plex / ChannelsDVR — {tmdb-123}"},
{"value": "jellyfin", "label": "Jellyfin / Emby — [tmdbid-123]"}
],
"help_text": "Which convention to use for the TMDB folder tag — media servers disagree, and each ignores the other's format. `Plex / ChannelsDVR` writes `Cool Hand Luke (1967) {tmdb-378}`; `Jellyfin / Emby` writes `Cool Hand Luke (1967) [tmdbid-378]`. Only has an effect when `Append TMDB ID to folder names` is ON. Defaults to Plex for backwards compatibility with libraries generated before v1.16.1 — if you use Jellyfin or Emby, switch this to `jellyfin` or the tag is silently ignored by your server. ⚠ MIGRATION: changing the format renames every folder, and the plugin does NOT rename in place — the new names are written ALONGSIDE the old ones. Run `[⚠ DANGER] Clean up Movies` / `Series` first, then re-generate."
},
{
"id": "omit_stream_id",
"label": "Don't pin .strm files to a specific provider stream",
"type": "boolean",
"default": False,
"help_text": "When ON, .strm URLs omit ?stream_id=, so Dispatcharr's VOD proxy resolves and fails over across every account carrying the title instead of being locked to the one relation this plugin happened to pick. Requires a patched Dispatcharr with VOD failover support (PR #1398). When OFF (default), the .strm is pinned to this plugin's selected relation, matching original behavior."
},
{
"id": "category_filter",
"label": "Category Filter (include only)",
"type": "string",
"default": "",
"help_text": "Only generate content whose M3U CATEGORY name STARTS WITH one of these comma-separated prefixes — e.g. `[EN],[FR]` or `EN`. Case-insensitive. Leave empty to generate all (active) content. Ideal for large multi-language catalogues where you only want one or two languages: it filters at the database-query level, so unwanted folders are never created (no generate-then-clean-up waste). Applies to BOTH Movies and Series. When a filter is set, content with no category — or a category that doesn't match — is skipped. ⚠ This matches the CATEGORY name, NOT the movie/series title — many providers put a language tag in the title (`|EN| The Matrix`) while the category is something else entirely (`FOR ADULTS`). Run `[LIBRARY] Catalogue snapshot` to print your real category names and see exactly which ones your filter matches."
},
{
"id": "category_exclude",
"label": "Category Exclude (block list)",
"type": "string",
"default": "",
"help_text": "Skip content whose M3U CATEGORY name starts with any of these comma-separated prefixes — e.g. `FOR ADULTS,XXX`. Case-insensitive. Applied AFTER the include filter, so you can use both together. This is usually what you want for 'everything EXCEPT adult content': leave `Category Filter` empty and put the unwanted categories here, rather than trying to list every category you do want. Content with no category is never excluded. Run `[LIBRARY] Catalogue snapshot` to see your exact category names. ⚠ Already-generated folders are not removed when you add an exclude — run `[⚠ DANGER] Clean up` once, then re-generate."
},
{
"id": "_section_series",
"label": "[SERIES]",
"type": "info",
"description": "Settings for the Generate Series action.",
},
{
"id": "series_batch_size",
"label": "Batch Size (Series)",
"type": "select",
"default": "10",
"options": [
{"value": "1", "label": "1 series (testing)"},
{"value": "5", "label": "5 series"},
{"value": "10", "label": "10 series"},
{"value": "25", "label": "25 series"},
{"value": "50", "label": "50 series"},
{"value": "100", "label": "100 series"},
{"value": "250", "label": "250 series"},
{"value": "all", "label": "All series (may time out — use the schedule)"}
],
"help_text": "Series to process per click (episodes are auto-fetched for each, so series are much slower than movies). ⚠ This button runs synchronously and your reverse proxy will usually cut it off after ~60s with a 504 — the run keeps going server-side, but you lose the result. For a big catalogue don't use 'All' here: set the cron to 'Full rescan' and click [SCHEDULE] Apply / Update. Scheduled runs execute on the Celery worker with no HTTP timeout."
},
{
"id": "generate_series_nfo",
"label": "Generate Series NFO Files",
"type": "boolean",
"default": True,
"help_text": "Create .nfo metadata files for series and episodes"
},
{
"id": "refresh_existing",
"label": "Refresh Existing Series (rescan-friendly)",
"type": "boolean",
"default": False,
"help_text": "Re-evaluate series that already have folders, picking up new episodes added upstream AND rewriting existing episode .strm files so they pick up the current Dispatcharr URL. .nfo files (including tvshow.nfo) are only written when missing, so your edits are preserved. Turn ON for cron rescans."
},
{
"id": "nest_series_by_category",
"label": "Nest Series by Category",
"type": "boolean",
"default": False,
"help_text": "Wrap each series' folder inside a subfolder named by its M3U category. Useful when your provider organises series by genre. Series without a category go into a folder named 'Unassigned'. Same content with different categories gets separate folders intentionally — turn ON Dedupe Series Across Categories below to suppress this for genre-overlap cases."
},
{
"id": "dedupe_series_across_categories",
"label": "Dedupe Series Across Categories",
"type": "boolean",
"default": False,
"help_text": "When `Nest Series by Category` is ON and a series is tagged with multiple categories upstream, write the series folder + episodes under the first category only (alphabetical by category name) instead of duplicating across all of them. No effect when `Nest Series by Category` is OFF. ⚠ MIGRATION: changing this on an already-generated library does NOT remove the old duplicate folders — run `[⚠ DANGER] Clean up Series` once, then re-generate, to clean them up."
},
{
"id": "_section_schedule",
"label": "[AUTO-RESCAN SCHEDULE]",
"type": "info",
"description": "Configure the cron job. Click Apply in the Actions tab to register or update.",
},
{
"id": "schedule_cron",
"label": "Auto-Rescan Schedule (cron)",
"type": "string",
"default": "0 3 * * *",
"help_text": "Standard 5-field cron: 'minute hour day-of-month month day-of-week'. Default '0 3 * * *' = every day at 03:00. Used by 'Apply Schedule'."
},
{
"id": "schedule_timezone",
"label": "Schedule Timezone",
"type": "string",
"default": "",
"placeholder": "Europe/London",
"help_text": "IANA timezone name the cron expression is interpreted in (e.g. 'Europe/London', 'America/New_York', 'Australia/Sydney'). Leave empty to use UTC. Affects when the cron fires — '0 3 * * *' in 'Europe/London' means 03:00 London time year-round (handling BST automatically), not 03:00 UTC."
},
{
"id": "schedule_target",
"label": "Scheduled Action",
"type": "select",
"default": "rescan_all",
"options": [
{"value": "scan_all_vods", "label": "Scan only (totals)"},
{"value": "generate_movies", "label": "Movies only"},
{"value": "generate_series", "label": "Series only"},
{"value": "rescan_all", "label": "Full rescan (movies + series)"}
],
"help_text": "Which action the scheduler should run on each tick."
}
]
actions = [
{
"id": "scan_all_vods",
"label": "[LIBRARY] Catalogue snapshot",
"description": "Count unique Movies and Series in the Dispatcharr database. Read-only.",
"button_label": "Scan",
"button_variant": "outline",
"button_color": "blue",
},
{
"id": "generate_movies",
"label": "[GENERATE] Movies",
"description": "Process movies per Batch Size. Existing .strm files are skipped.",
"button_label": "Generate",
"button_variant": "filled",
"button_color": "green",
},
{
"id": "generate_series",
"label": "[GENERATE] Series",
"description": "Create episode .strm files. See 'Refresh Existing Series' setting.",
"button_label": "Generate",
"button_variant": "filled",
"button_color": "green",
},
{
"id": "rescan_all",
"label": "[GENERATE] Full rescan",
"description": "Rescan then force regenerate Movies + Series.",
"button_label": "Rescan all",
"button_variant": "filled",
"button_color": "teal",
"confirm": {
"required": True,
"title": "Run full rescan now?",
"message": "Full rescan walks every Movie and every Series, re-fetching episode lists from the M3U source and writing any missing files. On large catalogues this can take many minutes. The cron schedule already runs this action nightly — only click here for an immediate refresh.",
},
},
{
"id": "schedule_status",
"label": "[SCHEDULE] Show status",
"description": "Show registered cron, last run, and total runs.",
"button_label": "Status",
"button_variant": "outline",
"button_color": "blue",
},
{
"id": "schedule_test_fire",
"label": "[SCHEDULE] Test fire now",
"description": "Fire the scheduled task immediately. Verifies the cron pipeline.",
"button_label": "Test fire",
"button_variant": "outline",
"button_color": "blue",
"confirm": {
"required": True,
"title": "Fire scheduled task now?",
"message": "Runs the same action the cron will fire (with the snapshotted settings) right now. Useful to verify the pipeline works. May take many minutes depending on the action.",
},
},
{
"id": "apply_schedule",
"label": "[SCHEDULE] Apply / Update",
"description": "Register or update the cron task. Re-click after changing any setting.",
"button_label": "Apply",
"button_variant": "outline",
"button_color": "blue",
},
{
"id": "remove_schedule",
"label": "[SCHEDULE] Unschedule",
"description": "Remove the periodic auto-rescan task.",
"button_label": "Remove",
"button_variant": "outline",
"button_color": "orange",
"confirm": {
"required": True,
"title": "Remove auto-rescan schedule?",
"message": "This unregisters the periodic task. You can re-create it any time with Apply.",
},
},
{
"id": "cleanup_movies",
"label": "[⚠ DANGER] Clean up Movies",
"description": "Delete plugin .strm/.nfo from Movies root. User files preserved.",
"button_label": "Clean up",
"button_variant": "filled",
"button_color": "red",
"confirm": {
"required": True,
"title": "Delete generated movie files?",
"message": "This deletes every .strm and .nfo file this plugin created under your Movies root. User-added files (subtitles, posters, custom .nfo) in those folders are preserved. Continue?",
},
},
{
"id": "cleanup_series",
"label": "[⚠ DANGER] Clean up Series",
"description": "Delete plugin .strm/.nfo from Series root. User files preserved.",
"button_label": "Clean up",
"button_variant": "filled",
"button_color": "red",
"confirm": {
"required": True,
"title": "Delete generated series files?",
"message": "This deletes every .strm and .nfo file this plugin created under your Series root. User-added files in those folders are preserved. Continue?",
},
},
]
def run(self, action: str, params: dict, context: dict):
"""Execute plugin action."""
logger = context.get("logger")
settings = context.get("settings", {})
logger.info("=" * 60)
logger.info("VOD .strm Generator v%s", self.version)
logger.info("Action: %s", action)
logger.info("=" * 60)
if action == "scan_all_vods":
return self._scan_all_vods(settings, logger)
elif action == "generate_movies":
return self._generate_movies(settings, logger)
elif action == "generate_series":
return self._generate_series(settings, logger)
elif action == "cleanup_movies":
return self._cleanup_movies(settings, logger)
elif action == "cleanup_series":
return self._cleanup_series(settings, logger)
elif action == "rescan_all":
return self._rescan_all(settings, logger)
elif action == "apply_schedule":
return self._apply_schedule(settings, logger)
elif action == "remove_schedule":
return self._remove_schedule(settings, logger)
elif action == "schedule_status":
return self._schedule_status(settings, logger)
elif action == "schedule_test_fire":
return self._schedule_test_fire(settings, logger)
return {"status": "error", "message": f"Unknown action: {action}"}
def _scan_all_vods(self, settings: Dict[str, Any], logger):
"""Scan and show total movies and series available."""
logger.info("Scanning VODs in Dispatcharr...")
logger.info("")
try:
from apps.vod.models import Movie, Series, M3UMovieRelation, M3USeriesRelation
from django.db.models import Count
except ImportError as e:
logger.error("Failed to import models: %s", e)
return {"status": "error", "message": f"Import error: {e}"}
try:
# Counts are filtered to content that has at least one relation on an
# ACTIVE M3U account — same definition Dispatcharr's own VODs UI and
# the proxy use. Generate only writes active content, so the scan
# totals now match what will actually be produced. Orphaned content
# (no active provider) is surfaced separately so the gap is visible.
active_movie = (
Movie.objects
.filter(m3u_relations__m3u_account__is_active=True)
.distinct().count()
)
active_series = (
Series.objects
.filter(m3u_relations__m3u_account__is_active=True)
.distinct().count()
)
total_movie = Movie.objects.count()
total_series = Series.objects.count()
orphan_movie = total_movie - active_movie
orphan_series = total_series - active_series
movie_relations = M3UMovieRelation.objects.filter(m3u_account__is_active=True).count()
series_relations = M3USeriesRelation.objects.filter(m3u_account__is_active=True).count()
logger.info("=" * 60)
logger.info("MOVIES: %d active (%d M3U relations)", active_movie, movie_relations)
if orphan_movie:
logger.info(" %d orphaned — no active provider (won't generate)", orphan_movie)
logger.info("SERIES: %d active (%d M3U relations)", active_series, series_relations)
if orphan_series:
logger.info(" %d orphaned — no active provider (won't generate)", orphan_series)
logger.info("=" * 60)
# Category breakdown — the exact names to type into Category Filter
# / Category Exclude. People guess at names their provider shows in
# titles rather than the category the DB actually stores (#8), so
# print them, and mark which ones the current filter matches.
cat_filter_prefixes = self._parse_category_filter(settings.get("category_filter"))
cat_exclude_prefixes = self._parse_category_filter(settings.get("category_exclude"))
counts = {}
for model, idx in ((M3UMovieRelation, 0), (M3USeriesRelation, 1)):
for row in (model.objects
.filter(m3u_account__is_active=True)
.values("category__name")
.annotate(n=Count("id"))):
entry = counts.setdefault(row["category__name"], [0, 0])
entry[idx] += row["n"]
if counts:
logger.info("")
logger.info("CATEGORIES (%d) — use these names in Category Filter / Exclude:", len(counts))
if cat_filter_prefixes or cat_exclude_prefixes:
logger.info(" ✓ = included by your filter ✗ = dropped by your exclude")
ordered = sorted(counts.items(), key=lambda kv: -(kv[1][0] + kv[1][1]))
shown = 0
matched = 0
for name, (mv, sr) in ordered:
included = (
self._matches_category_prefixes(name, cat_filter_prefixes)
if cat_filter_prefixes else True
)
if included and self._matches_category_prefixes(name, cat_exclude_prefixes):
included = False
if included:
matched += 1
if shown < self.SCAN_CATEGORY_LIMIT:
mark = " " if not (cat_filter_prefixes or cat_exclude_prefixes) else ("✓" if included else "✗")
logger.info(" %s %6d %r", mark, mv + sr, name if name else "(no category)")
shown += 1
if len(ordered) > shown:
logger.info(" ... and %d more", len(ordered) - shown)
if cat_filter_prefixes or cat_exclude_prefixes:
logger.info("")
logger.info(" → %d of %d categories will generate.", matched, len(ordered))
if matched == 0:
logger.warning(" ⚠ NOTHING matches — Generate will produce no files.")
logger.warning(" Your filter %s doesn't match any category name above.", cat_filter_prefixes)
logger.warning(" Note the filter matches the CATEGORY name, not the movie title.")
logger.info("=" * 60)
logger.info("")
logger.info("Use 'Generate Movie .strm Files' for movies")
logger.info("Use 'Generate Series .strm Files' for series")
message = f"Found {active_movie} movies and {active_series} series"
if orphan_movie or orphan_series:
message += f" ({orphan_movie + orphan_series} orphaned — no active provider)"
return {
"status": "ok",
"message": message,
"movies": active_movie,
"series": active_series,
"movies_orphaned": orphan_movie,
"series_orphaned": orphan_series,
}
except Exception as e:
logger.error("Scan failed: %s", e)
return {"status": "error", "message": f"Scan error: {e}"}
def _category_subfolder(self, category_name: str, nest: bool) -> str:
"""Return the category subfolder segment to insert into a path.
Returns "" when nest is False (caller should not insert a layer).
Returns the sanitised raw category name when nest is True and a
category is provided. Returns "Unassigned" when nest is True but
no category is available.
"""
if not nest:
return ""
cat = (category_name or "").strip()
if not cat:
return "Unassigned"
return self._sanitize_filename(cat)
def _movie_target_paths(self, movie, root_folder: str, category_name: str = "", nest: bool = False, append_tmdb_id: bool = False, tmdb_tag_format: str = "plex"):
"""Compute the (folder_path, strm_filename, clean_name, year) for a movie.
When nest=True the folder is wrapped in a category subfolder named
by the raw M3U category (or 'Unassigned' if none).
When append_tmdb_id=True AND the movie has a tmdb_id, the folder name
gets a Plex/ChannelsDVR-friendly `{tmdb-NNN}` suffix for exact
metadata matching. The strm filename inside the folder is NOT
affected — only the folder name, since that's what scrapers read.
"""
raw_name = movie.name or f"Unknown Movie {movie.id}"
clean_name, title_year = self._extract_clean_name_and_year(raw_name)
year = movie.year or title_year
clean_name, year = self._strip_redundant_trailing_year(clean_name, year)
safe = self._sanitize_filename(clean_name)
if year:
base_name = f"{safe} ({year})"
strm_filename = f"{safe} ({year}).strm"
else:
base_name = safe
strm_filename = f"{safe}.strm"
folder_name = self._apply_tmdb_suffix(base_name, movie, append_tmdb_id, tmdb_tag_format)
cat_segment = self._category_subfolder(category_name, nest)
if cat_segment:
folder_path = os.path.join(root_folder, cat_segment, folder_name)
else:
folder_path = os.path.join(root_folder, folder_name)
return folder_path, strm_filename, clean_name, year
def _strip_redundant_trailing_year(self, name, year):
"""Remove a bare trailing year a provider stuffed into the title, so it
doesn't get doubled against the `(YYYY)` suffix the plugin adds.
Two modes:
* If `year` is known and the title ends with that exact year
(optionally after a separator), strip it — `Wicked: For Good - 2025`
+ year 2025 -> `Wicked: For Good`.
* If `year` is None and the title ends with a plausible bare year
(1900–2100), ADOPT it as the year and strip it — so
`Wicked: For Good - 2025` with no DB year still yields a clean
`Wicked: For Good (2025)/` folder.
Guards:
* `Blade Runner 2049` (DB year 2017) — trailing 2049 ≠ 2017, kept.
* `Room 1408` (DB year 2007) — 1408 ≠ 2007 and < 1900, kept.
* `1984` / `2012` where the year IS the whole title — never stripped
to empty.
Returns `(name, year)` — `year` may be newly adopted in mode two.
"""
if not name:
return name, year
m = self._BARE_TRAILING_YEAR_RE.search(name)
if not m:
return name, year
trailing = int(m.group(1))
if year is None:
if not (1900 <= trailing <= 2100):
return name, year
adopted = trailing
elif trailing == year:
adopted = year
else:
return name, year
stripped = name[: m.start()].rstrip(" -–—_:.,").strip()
if not stripped:
# The year is the entire title (e.g. "1984", "2012") — keep it.
return name, year
return stripped, adopted
def _parse_category_filter(self, category_filter):
"""Split the comma-separated category-filter setting into a clean list
of non-empty prefixes. Pure/testable."""
return [pfx.strip() for pfx in (category_filter or "").split(",") if pfx.strip()]
def _matches_category_prefixes(self, category_name, prefixes) -> bool:
"""True when `category_name` starts with any of `prefixes`
(case-insensitive). Mirrors the DB-side `category__name__istartswith`
so the Scan diagnostic and the actual query agree.
Content with no category never matches — which is why an include-only
filter also drops uncategorised content.
"""
if not prefixes:
return False
name = (category_name or "").strip().lower()
if not name:
return False
return any(name.startswith(p.strip().lower()) for p in prefixes if p.strip())
def _apply_category_exclude(self, query, category_exclude):
"""Drop relations whose category name starts with any of the
comma-separated prefixes (case-insensitive).
Applied *after* the include filter. Content with no category is NOT
excluded — only categories that explicitly match are dropped — so
`category_exclude` is a safe way to say "everything except X" without
silently losing uncategorised titles (issue #8).
"""
prefixes = self._parse_category_filter(category_exclude)
if not prefixes:
return query
from django.db.models import Q
q = Q()
for pfx in prefixes:
q |= Q(category__name__istartswith=pfx)
return query.exclude(q)
def _apply_category_filter(self, query, category_filter):
"""Restrict an M3U relation queryset to categories whose name starts
with any of the comma-separated prefixes (case-insensitive).
Include-only: when a filter is set, relations with no category — or a
category that doesn't match any prefix — are excluded. Empty filter is
a no-op (generate everything). Composes with the active-account filter
and the dedup ordering.
"""
prefixes = self._parse_category_filter(category_filter)
if not prefixes:
return query
from django.db.models import Q
q = Q()
for pfx in prefixes:
q |= Q(category__name__istartswith=pfx)
return query.filter(q)
def _build_proxy_url(self, dispatcharr_url, content_type, uuid, stream_id, omit_stream_id=False):
"""Build a Dispatcharr VOD proxy URL for a .strm file.
Omits the `?stream_id=` query parameter when `omit_stream_id` is set
(or when no stream_id is available), letting Dispatcharr's VOD proxy
pick / fail over across accounts by priority instead of being pinned
to one relation — see #5 and Dispatcharr#1398. Included by default so
existing behaviour is unchanged.
"""
base = f"{dispatcharr_url}/proxy/vod/{content_type}/{uuid}"
if omit_stream_id or not stream_id:
return base
return f"{base}?stream_id={stream_id}"
def _apply_tmdb_suffix(self, base_name: str, obj, append_tmdb_id: bool, tag_format: str = "plex") -> str:
"""Append a TMDB id tag to a folder base name when the toggle is on and
the object exposes a tmdb_id. Returns unchanged otherwise.
The two media-server ecosystems use different conventions, and each
ignores the other's (issue #9):
* `plex` -> `Title (Year) {tmdb-123}` — Plex's Personal Media
agent and ChannelsDVR's local-media scraper.
* `jellyfin` -> `Title (Year) [tmdbid-123]` — Jellyfin and Emby.
Defaults to `plex` because that's what pre-v1.16.1 releases emitted;
changing an existing library's tag format renames every folder, so the
switch has to be opt-in (see the setting's migration note).
"""
if not append_tmdb_id:
return base_name
tmdb_id = (getattr(obj, "tmdb_id", "") or "").strip()
if not tmdb_id:
return base_name
if (tag_format or "plex").strip().lower() == "jellyfin":
return f"{base_name} [tmdbid-{tmdb_id}]"
return f"{base_name} {{tmdb-{tmdb_id}}}"
def _generate_movies(self, settings: Dict[str, Any], logger, refresh_urls: bool = False):
"""Generate movie .strm files according to batch size.
Lazily walks M3UMovieRelation via iterator() so the batch limit is
honoured even when most candidates are already-done. Stops scanning
as soon as target_batch new files have been written.
refresh_urls is an internal flag set by _rescan_all (and not a
user-visible setting). When True, existing .strm files are rewritten
with the current Dispatcharr URL; .nfo files are still preserved.
"""
root_folder = settings.get("root_folder", "/VODS/Movies")
dispatcharr_url = (settings.get("dispatcharr_url") or "").rstrip("/")
batch_size = settings.get("batch_size") or "250"
generate_nfo = settings.get("generate_nfo", True)
refresh_existing = bool(refresh_urls)
nest_by_cat = bool(settings.get("nest_movies_by_category", False))
dedupe_across_cats = bool(settings.get("dedupe_movies_across_categories", False))
append_tmdb_id = bool(settings.get("append_tmdb_id_to_folder", False))
tmdb_tag_format = (settings.get("tmdb_tag_format") or "plex").strip().lower()
omit_stream_id = bool(settings.get("omit_stream_id", False))
category_filter = (settings.get("category_filter") or "").strip()
category_exclude = (settings.get("category_exclude") or "").strip()
nfo_omit_title = bool(settings.get("nfo_omit_title", False))
ok, err = self._validate_dispatcharr_url(dispatcharr_url, logger)
if not ok:
logger.error(err)
return {"status": "error", "message": err}
self._log_config(logger, {
"Root Folder": root_folder,
"Dispatcharr URL": self._mask_url(dispatcharr_url),
"Batch Size": batch_size,
"Generate NFO": "Yes" if generate_nfo else "No",
"Refresh Existing": "Yes" if refresh_existing else "No",
"Nest by category": "Yes" if nest_by_cat else "No",
"Dedupe across cats": "Yes" if dedupe_across_cats else "No",
"Append TMDB ID": ("Yes (%s)" % tmdb_tag_format) if append_tmdb_id else "No",
"Category filter": category_filter or "(all)",
"Category exclude": category_exclude or "(none)",
})
try:
from apps.vod.models import M3UMovieRelation
except ImportError as e:
logger.error("Failed to import models: %s", e)
return {"status": "error", "message": f"Import error: {e}"}
try:
# Only generate for relations on an ACTIVE M3U account — content
# whose provider was deactivated upstream must not produce .strm
# files (they'd point at a dead provider). Matches the scan totals.
query = (
M3UMovieRelation.objects
.select_related('movie', 'm3u_account', 'category')
.filter(m3u_account__is_active=True)
)
query = self._apply_category_filter(query, category_filter)
query = self._apply_category_exclude(query, category_exclude)
if dedupe_across_cats:
# Deterministic "first category wins" requires a stable sort.
# Alphabetical by category name, then relation id as a tiebreaker.
# Only applied when the toggle is ON so we don't penalise normal
# iteration with an unnecessary ORDER BY on the relation table.
query = query.order_by('category__name', 'id')
total_count = query.count()
if total_count == 0:
# Don't just say "nothing found" — if a filter is set it's
# almost certainly the cause, and users mistake the category
# name for the title prefix their provider shows (#8).
if category_filter or category_exclude:
msg = (
"No movies matched your Category Filter/Exclude — nothing generated. "
"Run '[LIBRARY] Catalogue snapshot' to see the real category names "
"(the filter matches the CATEGORY name, not the movie title)."
)
logger.warning(msg)
return {"status": "ok", "message": msg, "processed": 0, "filtered_out": True}
return {"status": "ok", "message": "No movies found to process", "processed": 0}
target_batch = total_count if batch_size == "all" else int(batch_size)
logger.info("Total relations: %d. Target batch: %s", total_count, "all" if batch_size == "all" else target_batch)
except Exception as e:
logger.error("Database query failed: %s", e)
return {"status": "error", "message": f"Database error: {e}"}
try:
os.makedirs(root_folder, exist_ok=True)
except OSError as e:
return {"status": "error", "message": f"Folder creation error: {e}"}
created_strm = 0
refreshed_strm = 0
unchanged_strm = 0
missing_tmdb_id = 0
created_nfo = 0
skipped = 0
deduped = 0
errors = 0
scanned = 0
# seen-set is only used when dedupe is on; kept as None otherwise so the
# membership check short-circuits cheaply for everyone else.
seen_movie_uuids = set() if dedupe_across_cats else None
logger.info("Processing movies:")
logger.info("-" * 60)
for relation in query.iterator():
scanned += 1
movie = relation.movie
if seen_movie_uuids is not None:
if movie.uuid in seen_movie_uuids:
# Same movie already written under an earlier-alphabetical
# category. Skip — counts under `deduped` not `skipped`.
deduped += 1
continue
seen_movie_uuids.add(movie.uuid)
cat_name = relation.category.name if relation.category else ""
# Track titles the TMDB tag can't be applied to, so "I ticked the
# box and nothing changed" isn't silent (reported by @drahmed86).
if append_tmdb_id and not (getattr(movie, "tmdb_id", "") or "").strip():
missing_tmdb_id += 1
movie_folder, strm_filename, movie_name, year = self._movie_target_paths(
movie, root_folder, cat_name, nest_by_cat, append_tmdb_id, tmdb_tag_format,
)
strm_path = os.path.join(movie_folder, strm_filename)
is_existing = os.path.exists(strm_path)
if is_existing and not refresh_existing:
skipped += 1
continue
proxy_url = self._build_proxy_url(
dispatcharr_url, "movie", movie.uuid, relation.stream_id, omit_stream_id,
)
written = created_strm + refreshed_strm
log_this = (written + 1) % self.LOG_EVERY == 1 or written < self.LOG_FIRST_N
verb = "refreshed" if is_existing else "created"
if log_this:
logger.info("")
logger.info("[%d %s / %d scanned] %s (%s)", written + 1, verb, scanned, movie_name, year or "—")
try:
os.makedirs(movie_folder, exist_ok=True)
changed = self._write_if_different_preserve_times(strm_path, proxy_url)
if not changed:
unchanged_strm += 1
elif is_existing:
refreshed_strm += 1
else:
created_strm += 1
wrote_nfo = False
if generate_nfo:
nfo_filename = strm_filename.replace('.strm', '.nfo')
nfo_path = os.path.join(movie_folder, nfo_filename)
if not os.path.exists(nfo_path):
category_name = relation.category.name if relation.category else ""
with open(nfo_path, 'w', encoding='utf-8') as f:
f.write(self._generate_nfo(movie, category_name, nfo_omit_title))
created_nfo += 1
wrote_nfo = True
if log_this:
if changed:
logger.info(" ✓ wrote .strm%s", " + .nfo" if wrote_nfo else "")
else:
logger.info(" · .strm already current (mtime preserved)%s", " + wrote .nfo" if wrote_nfo else "")
except OSError as e:
logger.error(" ✗ %s: %s", movie_name, e)
errors += 1
if batch_size != "all":
# In refresh mode an already-current file still counts as
# "processed" for pacing, so the batch limit behaves as it did
# before no-op writes were skipped (#11).
limit_hit = (
(refreshed_strm + created_strm + unchanged_strm) >= target_batch
if refresh_existing
else created_strm >= target_batch
)
if limit_hit:
logger.info("")
if refresh_existing:
logger.info("Batch complete: %d new + %d refreshed .strm (scanned %d).", created_strm, refreshed_strm, scanned)
else:
logger.info("Batch complete: %d new .strm written (scanned %d, %d already done).", created_strm, scanned, skipped)
break
logger.info("")
logger.info("=" * 60)
logger.info("SUMMARY:")
logger.info(" Total relations: %d", total_count)
logger.info(" Scanned: %d", scanned)
logger.info(" Already on disk: %d", skipped)
if dedupe_across_cats:
logger.info(" Deduped (multi-cat): %d", deduped)
logger.info(" .strm created: %d", created_strm)
if refresh_existing:
logger.info(" .strm refreshed: %d (URL changed)", refreshed_strm)
logger.info(" .strm unchanged: %d (skipped, mtime preserved)", unchanged_strm)
if generate_nfo:
logger.info(" .nfo created: %d", created_nfo)
logger.info(" Errors: %d", errors)
if append_tmdb_id and missing_tmdb_id:
logger.warning(
" ⚠ %d title(s) had no TMDB ID, so no {tmdb-…} tag was added to those "
"folders — your provider didn't supply one. This is not a plugin error.",
missing_tmdb_id,
)
logger.info("=" * 60)
summary_msg = f"Wrote {created_strm} new .strm files"
if refresh_existing and refreshed_strm:
summary_msg += f", refreshed {refreshed_strm}"
if refresh_existing and unchanged_strm:
summary_msg += f", {unchanged_strm} already current"
if generate_nfo and created_nfo:
summary_msg += f" + {created_nfo} .nfo"
if skipped:
summary_msg += f" ({skipped} already on disk)"
if dedupe_across_cats and deduped:
summary_msg += f", deduped {deduped} multi-category duplicates"
return {
"status": "ok",
"message": summary_msg,
"total_in_db": total_count,