-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathplugin.py
More file actions
1926 lines (1696 loc) · 86 KB
/
plugin.py
File metadata and controls
1926 lines (1696 loc) · 86 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.15.1 — optional Dedupe Movies/Series Across Categories toggle:
when nesting is on and a title is tagged with multiple
categories upstream, only the first-encountered category
gets the folder (alphabetical). Closes issue #1.
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.15.1"
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
# 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')
# Title cleaning. Whitespace required before the dash so 'AC-130' is preserved.
_LANGUAGE_PREFIX_RE = re.compile(r'^[A-Z]{2,3}\s+-\s*')
_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})\)')
# 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_RE = re.compile(
r'\b(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)\b',
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": "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."
},
{
"id": "append_tmdb_id_to_folder",
"label": "Append TMDB ID to folder names",
"type": "boolean",
"default": False,
"help_text": "Append `{tmdb-NNN}` to every Movies and Series folder name when a TMDB ID is known — e.g. `Cool Hand Luke (1967) {tmdb-378}/`. Plex's Personal Media agent and ChannelsDVR's local-media scraper both honour this convention for forced exact matches, which is the safest defence against name collisions and bad metadata scrapes. Off by default because flipping it on an existing library renames every folder — the plugin creates the new names alongside the old ones; you'd need to `[⚠ DANGER] Clean up` first or accept duplicates."
},
{
"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": "all", "label": "All series (slow!)"}
],
"help_text": "Series to process (episodes auto-fetched for each)"
},
{
"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."
},
{
"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
except ImportError as e:
logger.error("Failed to import models: %s", e)
return {"status": "error", "message": f"Import error: {e}"}
try:
movie_count = Movie.objects.count()
series_count = Series.objects.count()
movie_relations = M3UMovieRelation.objects.count()
series_relations = M3USeriesRelation.objects.count()
logger.info("=" * 60)
logger.info("MOVIES: %d unique (%d M3U relations)", movie_count, movie_relations)
logger.info("SERIES: %d unique (%d M3U relations)", series_count, series_relations)
logger.info("=" * 60)
logger.info("")
logger.info("Use 'Generate Movie .strm Files' for movies")
logger.info("Use 'Generate Series .strm Files' for series")
return {
"status": "ok",
"message": f"Found {movie_count} movies and {series_count} series",
"movies": movie_count,
"series": series_count
}
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):
"""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
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)
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 _apply_tmdb_suffix(self, base_name: str, obj, append_tmdb_id: bool) -> str:
"""Append `{tmdb-NNN}` to a folder base name when the toggle is on and
the object exposes a tmdb_id. Returns unchanged otherwise.
Plex's [Personal Media Movies] agent treats `{tmdb-N}` / `{imdb-ttN}`
as a forced-match override. ChannelsDVR's local-media scraper does the
same. Off by default since flipping the toggle changes existing folder
names — users would need to clean up the old folders or accept the new
ones living alongside.
"""
if not append_tmdb_id:
return base_name
tmdb_id = (getattr(obj, "tmdb_id", "") or "").strip()
if not tmdb_id:
return base_name
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))
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" if append_tmdb_id else "No",
})
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:
query = M3UMovieRelation.objects.select_related('movie', 'm3u_account', 'category')
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:
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
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 ""
movie_folder, strm_filename, movie_name, year = self._movie_target_paths(
movie, root_folder, cat_name, nest_by_cat, append_tmdb_id,
)
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 = f"{dispatcharr_url}/proxy/vod/movie/{movie.uuid}?stream_id={relation.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)
with open(strm_path, 'w', encoding='utf-8') as f:
f.write(proxy_url)
if 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))
created_nfo += 1
wrote_nfo = True
if log_this:
logger.info(" ✓ wrote .strm%s", " + .nfo" if wrote_nfo else "")
except OSError as e:
logger.error(" ✗ %s: %s", movie_name, e)
errors += 1
if batch_size != "all":
limit_hit = (
(refreshed_strm + created_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", refreshed_strm)
if generate_nfo:
logger.info(" .nfo created: %d", created_nfo)
logger.info(" Errors: %d", errors)
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 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,
"scanned": scanned,
"created_strm": created_strm,
"refreshed_strm": refreshed_strm,
"created_nfo": created_nfo if generate_nfo else 0,
"skipped": skipped,
"deduped": deduped,
"errors": errors,
}
def _series_target_folder(self, series, series_root: str, category_name: str = "", nest: bool = False, append_tmdb_id: bool = False):
"""Compute the target folder for a series. Returns (folder_path, clean_name, year).
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 series has a tmdb_id, the folder name
gets a `{tmdb-NNN}` suffix for Plex/ChannelsDVR exact matching. See
`_apply_tmdb_suffix` for caveats around flipping the toggle on an
existing library.
"""
raw_name = series.name or f"Unknown Series {series.id}"
clean_name, title_year = self._extract_clean_name_and_year(raw_name)
year = series.year or title_year
safe = self._sanitize_filename(clean_name)
base_name = f"{safe} ({year})" if year else safe
folder_name = self._apply_tmdb_suffix(base_name, series, append_tmdb_id)
cat_segment = self._category_subfolder(category_name, nest)
if cat_segment:
return os.path.join(series_root, cat_segment, folder_name), clean_name, year
return os.path.join(series_root, folder_name), clean_name, year
def _series_already_processed(self, series_folder: str) -> bool:
"""A series is considered processed if its folder contains any 'Season ...' subdir."""
if not os.path.isdir(series_folder):
return False
try:
return any(
item.startswith("Season") and os.path.isdir(os.path.join(series_folder, item))
for item in os.listdir(series_folder)
)
except OSError:
return False
def _generate_series(self, settings: Dict[str, Any], logger):
"""Generate series .strm files with episodes using parallel processing."""
series_root = settings.get("series_root_folder", "/VODS/Series")
dispatcharr_url = (settings.get("dispatcharr_url") or "").rstrip("/")
batch_size = settings.get("series_batch_size") or "10"
generate_nfo = settings.get("generate_series_nfo", True)
refresh_existing = bool(settings.get("refresh_existing", False))
nest_by_cat = bool(settings.get("nest_series_by_category", False))
dedupe_across_cats = bool(settings.get("dedupe_series_across_categories", False))
append_tmdb_id = bool(settings.get("append_tmdb_id_to_folder", 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, {
"Series Root": series_root,
"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",
"Workers": self.MAX_WORKERS,
})
try:
from apps.vod.models import M3USeriesRelation
except ImportError as e:
logger.error("Failed to import models: %s", e)
return {"status": "error", "message": f"Import error: {e}"}
try:
query = M3USeriesRelation.objects.select_related('series', 'm3u_account', 'category')
if dedupe_across_cats:
# See _generate_movies for rationale — deterministic
# alphabetical-by-category-name ordering so "first category wins"
# is repeatable across runs.
query = query.order_by('category__name', 'id')
total_count = query.count()
if batch_size == "all":
target_batch = total_count
logger.info("Mode: process ALL %d series", total_count)
else:
target_batch = int(batch_size)
logger.info("Target batch size: %d (of %d total)", target_batch, total_count)
if total_count == 0:
return {"status": "ok", "message": "No series found"}
except Exception as e:
logger.error("Query failed: %s", e)
return {"status": "error", "message": f"Database error: {e}"}
try:
os.makedirs(series_root, exist_ok=True)
except OSError as e:
return {"status": "error", "message": f"Folder creation error: {e}"}
if refresh_existing:
logger.info("Refresh-existing mode: scanning all series for new episodes...")
else:
logger.info("Filtering already-processed series...")
to_process = []
scanned = 0
deduped = 0
seen_series_uuids = set() if dedupe_across_cats else None
for series_rel in query.iterator():
scanned += 1
if seen_series_uuids is not None:
if series_rel.series.uuid in seen_series_uuids:
# Same series tagged under multiple categories — already
# going to be processed under the alphabetically-first one.
deduped += 1
continue
seen_series_uuids.add(series_rel.series.uuid)
if not refresh_existing:
cat_name = series_rel.category.name if series_rel.category else ""
folder, _, _ = self._series_target_folder(
series_rel.series, series_root, cat_name, nest_by_cat, append_tmdb_id,
)
if self._series_already_processed(folder):
continue
to_process.append(series_rel)
if batch_size != "all" and len(to_process) >= target_batch:
break
skipped_pre = scanned - len(to_process) - deduped
if refresh_existing:
logger.info("Scanned %d series; %d to evaluate this run", scanned, len(to_process))
else:
logger.info("Scanned %d series; %d already processed (skipped); %d to process this run", scanned, skipped_pre, len(to_process))
if dedupe_across_cats and deduped:
logger.info("Deduped %d multi-category series (only the first-encountered category survives).", deduped)
logger.info("")
if not to_process:
logger.info("Nothing to process.")
return {
"status": "ok",
"message": f"Nothing to process; {skipped_pre} series already done.",
"series_processed": 0,
"episodes_created": 0,
"nfo_created": 0,
"deduped": deduped,
"errors": 0,
}
created_strm = 0
refreshed_strm = 0
created_nfo = 0
errors = 0
series_created = 0
series_uptodate = 0
failures = []
logger.info("Processing %d series with %d parallel workers:", len(to_process), self.MAX_WORKERS)
logger.info("-" * 60)
with ThreadPoolExecutor(max_workers=self.MAX_WORKERS) as executor:
futures = {
executor.submit(
self._process_single_series,
series_rel,
dispatcharr_url,
generate_nfo,
series_root,
logger,
refresh_existing,
nest_by_cat,
append_tmdb_id,
): series_rel
for series_rel in to_process
}
for idx, future in enumerate(as_completed(futures), 1):
series_rel = futures[future]
try:
result = future.result()
except Exception as e:
name = getattr(getattr(series_rel, "series", None), "name", "?")
logger.error("[%d/%d] Worker raised for '%s': %s", idx, len(futures), name, e)
errors += 1
failures.append(f"{name}: {e}")
continue
if result.get("uptodate"):
series_uptodate += 1
elif result.get("created"):
series_created += 1
created_strm += result["episodes"]
refreshed_strm += result.get("refreshed", 0)
created_nfo += result["nfo_files"]
if "error" in result:
errors += 1
failures.append(f"{result.get('series_name', '?')}: {result['error']}")
logger.info("[%d/%d] %s", idx, len(futures), result["message"])
logger.info("")
logger.info("=" * 60)
logger.info("SUMMARY:")
logger.info(" Series with new content: %d", series_created)
logger.info(" Series up-to-date: %d", series_uptodate)
logger.info(" New episode .strm files: %d", created_strm)
if refresh_existing:
logger.info(" Refreshed episode URLs: %d", refreshed_strm)
if generate_nfo:
logger.info(" New NFO files: %d", created_nfo)
logger.info(" Errors: %d", errors)
logger.info("=" * 60)
if series_created == 0 and series_uptodate > 0:
summary_msg = f"All {series_uptodate} evaluated series already up-to-date — no new episodes."
else:
summary_msg = f"Wrote {created_strm} new episodes across {series_created} series"
if refresh_existing and refreshed_strm:
summary_msg += f", refreshed {refreshed_strm} episode URL{'s' if refreshed_strm != 1 else ''}"
if series_uptodate:
summary_msg += f" ({series_uptodate} already up-to-date)"
if generate_nfo and created_nfo:
summary_msg += f" + {created_nfo} NFO"
if failures:
logger.info("")
logger.info("Failed series:")
for f in failures[:20]:
logger.info(" - %s", f)
if len(failures) > 20:
logger.info(" ... and %d more", len(failures) - 20)
return {
"status": "ok",
"message": summary_msg,
"series_processed": series_created,
"series_uptodate": series_uptodate,
"episodes_created": created_strm,
"episodes_refreshed": refreshed_strm,
"nfo_created": created_nfo if generate_nfo else 0,
"deduped": deduped,
"errors": errors,
"failures": failures,
}
def _process_single_series(self, series_rel, dispatcharr_url, generate_nfo, series_root, logger, refresh_existing=False, nest_by_cat=False, append_tmdb_id=False):
"""Process a single series. Idempotent: writes only missing episode files.
With refresh_existing=False, callers should pre-filter already-done
series for performance. With refresh_existing=True, every series is
re-evaluated and the M3U source is re-fetched so newly-aired episodes
are picked up.
When nest_by_cat=True the series folder is wrapped in a subfolder
named by the M3U category (raw, sanitised) or 'Unassigned'.
"""
from apps.vod.models import M3UEpisodeRelation
from apps.vod.tasks import refresh_series_episodes
series = series_rel.series
cat_name = series_rel.category.name if series_rel.category else ""
series_folder, series_name, _year = self._series_target_folder(
series, series_root, cat_name, nest_by_cat, append_tmdb_id,
)
try:
custom_props = series_rel.custom_properties or {}
should_refetch = refresh_existing or not custom_props.get('episodes_fetched', False)
if should_refetch:
try:
refresh_series_episodes(
account=series_rel.m3u_account,
series=series_rel.series,
external_series_id=series_rel.external_series_id,
)
except Exception as fetch_err:
logger.warning("refresh_series_episodes failed for %s: %s", series_name, fetch_err)
episodes = list(
M3UEpisodeRelation.objects.filter(
m3u_account=series_rel.m3u_account,
episode__series=series,
)
.select_related('episode')
.order_by('episode__season_number', 'episode__episode_number')
)
episode_count = len(episodes)
if episode_count == 0:
return {
"created": False,
"uptodate": False,
"series_name": series_name,
"episodes": 0,
"nfo_files": 0,
"message": f"{series_name} - No episodes found",
}
os.makedirs(series_folder, exist_ok=True)
new_episodes = 0
refreshed_episodes = 0
new_nfo = 0
if generate_nfo:
tvshow_nfo_path = os.path.join(series_folder, "tvshow.nfo")
if not os.path.isfile(tvshow_nfo_path):
category_name = series_rel.category.name if series_rel.category else ""
tvshow_content = self._generate_tvshow_nfo(series, category_name)
with open(tvshow_nfo_path, 'w', encoding='utf-8') as f:
f.write(tvshow_content)
new_nfo += 1
for episode_rel in episodes:
episode = episode_rel.episode
season_num = episode.season_number or 0
episode_num = episode.episode_number or 0
season_folder_name = f"Season {season_num:02d}"
season_folder = os.path.join(series_folder, season_folder_name)
episode_title = episode.name or ""
if episode_title:
clean_title = self._clean_title(episode_title)
filename = f"{series_name} - S{season_num:02d}E{episode_num:02d} - {clean_title}"
else:
filename = f"{series_name} - S{season_num:02d}E{episode_num:02d}"
filename = self._sanitize_filename(filename)
strm_path = os.path.join(season_folder, f"{filename}.strm")
is_existing = os.path.isfile(strm_path)
if is_existing and not refresh_existing:
continue
os.makedirs(season_folder, exist_ok=True)
proxy_url = f"{dispatcharr_url}/proxy/vod/episode/{episode.uuid}?stream_id={episode_rel.stream_id}"