plextool 3.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plextool/__init__.py +3 -0
- plextool/__main__.py +5 -0
- plextool/audit.py +142 -0
- plextool/auth.py +518 -0
- plextool/cli.py +52 -0
- plextool/common.py +274 -0
- plextool/dupes.py +190 -0
- plextool/ondemand.py +382 -0
- plextool/playlists.py +153 -0
- plextool/recent.py +203 -0
- plextool/resume.py +61 -0
- plextool/scan.py +80 -0
- plextool/server.py +438 -0
- plextool/stale.py +126 -0
- plextool/status.py +68 -0
- plextool/subs.py +338 -0
- plextool/watched.py +249 -0
- plextool-3.0.1.dist-info/METADATA +232 -0
- plextool-3.0.1.dist-info/RECORD +23 -0
- plextool-3.0.1.dist-info/WHEEL +5 -0
- plextool-3.0.1.dist-info/entry_points.txt +2 -0
- plextool-3.0.1.dist-info/licenses/LICENSE +21 -0
- plextool-3.0.1.dist-info/top_level.txt +1 -0
plextool/recent.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""recent: rewrite 'added at' dates to reorder Plex's Recently Added lists."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
|
|
9
|
+
from .common import (
|
|
10
|
+
SECTION_LEVELS,
|
|
11
|
+
PlexToolError,
|
|
12
|
+
add_command,
|
|
13
|
+
add_connection_args,
|
|
14
|
+
connect,
|
|
15
|
+
describe,
|
|
16
|
+
paint,
|
|
17
|
+
pick_sections,
|
|
18
|
+
plural,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_SHIFT_RE = re.compile(r"^([+-]?)(\d+)(min|h|d|w)$")
|
|
22
|
+
_SHIFT_UNITS = {"min": "minutes", "h": "hours", "d": "days", "w": "weeks"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_date(text: str) -> datetime:
|
|
26
|
+
"""Parse 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'."""
|
|
27
|
+
try:
|
|
28
|
+
return datetime.fromisoformat(text)
|
|
29
|
+
except ValueError:
|
|
30
|
+
raise argparse.ArgumentTypeError(
|
|
31
|
+
f"'{text}' is not a date; use YYYY-MM-DD or 'YYYY-MM-DD HH:MM:SS'") from None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_shift(text: str) -> timedelta:
|
|
35
|
+
"""Parse a relative offset such as '+30d', '-2w', '6h' or '90min' (sign optional)."""
|
|
36
|
+
match = _SHIFT_RE.match(text.strip())
|
|
37
|
+
if not match:
|
|
38
|
+
raise argparse.ArgumentTypeError(
|
|
39
|
+
f"'{text}' is not a shift; use a number and a unit (min, h, d, w), eg -30d")
|
|
40
|
+
sign, amount, unit = match.groups()
|
|
41
|
+
delta = timedelta(**{_SHIFT_UNITS[unit]: int(amount)})
|
|
42
|
+
return -delta if sign == "-" else delta
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def new_date(item, args: argparse.Namespace, now: datetime) -> datetime | None:
|
|
46
|
+
"""The date this item should get, or None if it can't be worked out."""
|
|
47
|
+
if args.date:
|
|
48
|
+
return args.date
|
|
49
|
+
if args.now:
|
|
50
|
+
return now
|
|
51
|
+
if args.release:
|
|
52
|
+
return item.originallyAvailableAt
|
|
53
|
+
if item.addedAt is None:
|
|
54
|
+
return None
|
|
55
|
+
return item.addedAt + args.shift
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def find_by_guid(section, level: str, guid: str) -> list:
|
|
59
|
+
"""Find items by Plex GUID (plex://...) or an agent GUID (imdb://, tmdb://, tvdb://)."""
|
|
60
|
+
if guid.startswith("plex://"):
|
|
61
|
+
return section.search(guid=guid, libtype=level)
|
|
62
|
+
return [item for item in section.search(libtype=level)
|
|
63
|
+
if any(g.id == guid for g in getattr(item, "guids", []))]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def find_items(section, level: str, args: argparse.Namespace) -> list:
|
|
67
|
+
if args.title:
|
|
68
|
+
return section.search(title=args.title, libtype=level)
|
|
69
|
+
if args.guid:
|
|
70
|
+
return find_by_guid(section, level, args.guid)
|
|
71
|
+
return section.recentlyAdded(maxresults=args.recent, libtype=level)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def expand(item, cascade: bool) -> list:
|
|
75
|
+
"""The item itself, plus its seasons and episodes if cascading from a show or season."""
|
|
76
|
+
targets = [item]
|
|
77
|
+
if cascade:
|
|
78
|
+
if item.type == "show":
|
|
79
|
+
targets += item.seasons() + item.episodes()
|
|
80
|
+
elif item.type == "season":
|
|
81
|
+
targets += item.episodes()
|
|
82
|
+
return targets
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def show_item(index: int, item) -> None:
|
|
86
|
+
released = getattr(item, "originallyAvailableAt", None)
|
|
87
|
+
print(f"{paint(str(index).rjust(6), 'cyan')}: {paint(describe(item), 'bold')}")
|
|
88
|
+
print(f" Released: {released}")
|
|
89
|
+
print(f" Added: {item.addedAt}")
|
|
90
|
+
print(f" Plex ID: {item.guid}")
|
|
91
|
+
for k, guid in enumerate(getattr(item, "guids", []), start=1):
|
|
92
|
+
print(f" GUID[{k:02d}]: {guid.id}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def apply_dates(item, args: argparse.Namespace, now: datetime) -> dict:
|
|
96
|
+
"""Set (or preview) the new date on item and, if cascading, its children.
|
|
97
|
+
|
|
98
|
+
Returns counts of 'changed' (which means 'would change' on a dry run),
|
|
99
|
+
'unchanged' and 'skipped' (no date available, eg no release date).
|
|
100
|
+
"""
|
|
101
|
+
counts = {"changed": 0, "unchanged": 0, "skipped": 0}
|
|
102
|
+
children = {"changed": 0, "unchanged": 0, "skipped": 0}
|
|
103
|
+
for target in expand(item, args.cascade):
|
|
104
|
+
tally = counts if target is item else children
|
|
105
|
+
date = new_date(target, args, now)
|
|
106
|
+
if date is None:
|
|
107
|
+
tally["skipped"] += 1
|
|
108
|
+
continue
|
|
109
|
+
if target.addedAt == date:
|
|
110
|
+
tally["unchanged"] += 1
|
|
111
|
+
continue
|
|
112
|
+
previous = target.addedAt
|
|
113
|
+
if args.commit:
|
|
114
|
+
target.editAddedAt(date, locked=not args.no_lock)
|
|
115
|
+
target.reload()
|
|
116
|
+
tally["changed"] += 1
|
|
117
|
+
if target is item:
|
|
118
|
+
if args.commit:
|
|
119
|
+
print(paint(f" ! Date changed from {previous} to {target.addedAt}", "yellow"))
|
|
120
|
+
else:
|
|
121
|
+
print(paint(f" ! Date would change from {previous} to {date}", "red"))
|
|
122
|
+
if counts["skipped"]:
|
|
123
|
+
print(paint(" ! No date available for this item (no release date?); skipped", "red"))
|
|
124
|
+
if args.cascade:
|
|
125
|
+
verb = "changed" if args.commit else "would change"
|
|
126
|
+
print(f" + children: {children['changed']} {verb}, "
|
|
127
|
+
f"{children['unchanged']} already correct, {children['skipped']} skipped")
|
|
128
|
+
return {k: counts[k] + children[k] for k in counts}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def add_parser(subparsers) -> None:
|
|
132
|
+
parser = add_command(
|
|
133
|
+
subparsers, 'recent',
|
|
134
|
+
"change 'added at' dates to reorder Recently Added",
|
|
135
|
+
"Change the 'added at' date of movies, shows, seasons or episodes, which moves them in Plex's Recently Added lists. Pick what to change (--title, --guid or --recent) and the new date (--date, --release, --now or --shift). Nothing is written unless --commit is given.\n\nPlex builds the TV Recently Added list from episodes. To reorder a series, use --cascade or --level episode.",
|
|
136
|
+
['plextool recent -l Movies -n 3 --release # preview the last 3 movies',
|
|
137
|
+
'plextool recent -g plex://movie/abc123 --release --commit',
|
|
138
|
+
"plextool recent -l 'TV Shows' -t 'Some Show' --cascade --release --commit",
|
|
139
|
+
"plextool recent -l 'TV Shows' --level episode -n 20 --shift=-7d --commit"])
|
|
140
|
+
add_connection_args(parser)
|
|
141
|
+
parser.add_argument("-l", "--library", action="append", metavar="NAME",
|
|
142
|
+
help="library name; repeat for several (default: all movie and TV libraries)")
|
|
143
|
+
parser.add_argument("--level", choices=sorted({lv for lvs in SECTION_LEVELS.values() for lv in lvs}),
|
|
144
|
+
help="what to change: movie, show, season or episode "
|
|
145
|
+
"(default: movie for movie libraries, show for TV libraries)")
|
|
146
|
+
parser.add_argument("--cascade", action="store_true",
|
|
147
|
+
help="when changing a show or season, change its seasons and episodes too")
|
|
148
|
+
|
|
149
|
+
select = parser.add_mutually_exclusive_group(required=True)
|
|
150
|
+
select.add_argument("-t", "--title", help="select by title (substring match)")
|
|
151
|
+
select.add_argument("-g", "--guid", dest="guid", metavar="GUID",
|
|
152
|
+
help="select by Plex ID (plex://movie/...) or agent ID (imdb://tt..., tmdb://..., tvdb://...)")
|
|
153
|
+
select.add_argument("-n", "--recent", type=int, metavar="N",
|
|
154
|
+
help="select the N most recently added items (per library)")
|
|
155
|
+
|
|
156
|
+
when = parser.add_mutually_exclusive_group(required=True)
|
|
157
|
+
when.add_argument("-d", "--date", type=parse_date, metavar="DATE",
|
|
158
|
+
help="set this date: YYYY-MM-DD or 'YYYY-MM-DD HH:MM:SS'")
|
|
159
|
+
when.add_argument("-r", "--release", action="store_true",
|
|
160
|
+
help="set each item's date to its own original release/air date")
|
|
161
|
+
when.add_argument("-N", "--now", action="store_true", help="set the date to right now")
|
|
162
|
+
when.add_argument("-s", "--shift", type=parse_shift, metavar="OFFSET",
|
|
163
|
+
help="move each item's current date by an offset, eg +2w, 6h, 90min; write negatives with an = sign: --shift=-30d")
|
|
164
|
+
|
|
165
|
+
parser.add_argument("--no-lock", action="store_true",
|
|
166
|
+
help="don't lock the date; by default it is locked so a rescan or "
|
|
167
|
+
"metadata refresh can't reset it")
|
|
168
|
+
parser.add_argument("-y", "--commit", dest="commit", action="store_true",
|
|
169
|
+
help="write the changes (default is a dry run)")
|
|
170
|
+
parser.set_defaults(run=run)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def run(args: argparse.Namespace) -> int:
|
|
174
|
+
if args.recent is not None and args.recent < 1:
|
|
175
|
+
raise PlexToolError("--recent must be at least 1")
|
|
176
|
+
plex = connect(args)
|
|
177
|
+
sections = pick_sections(plex, args.library)
|
|
178
|
+
now = datetime.now().replace(microsecond=0)
|
|
179
|
+
totals = {"changed": 0, "unchanged": 0, "skipped": 0}
|
|
180
|
+
|
|
181
|
+
for section in sections:
|
|
182
|
+
level = args.level or section.TYPE
|
|
183
|
+
if level not in SECTION_LEVELS[section.TYPE]:
|
|
184
|
+
if args.library:
|
|
185
|
+
raise PlexToolError(
|
|
186
|
+
f"--level {level} doesn't apply to the {section.TYPE} library '{section.title}'")
|
|
187
|
+
continue
|
|
188
|
+
if args.cascade and level == "movie":
|
|
189
|
+
raise PlexToolError("--cascade only applies to shows and seasons")
|
|
190
|
+
items = find_items(section, level, args)
|
|
191
|
+
print(paint(f" * {section.title}: found {plural(len(items), level)}", "cyan"))
|
|
192
|
+
for index, item in enumerate(items, start=1):
|
|
193
|
+
show_item(index, item)
|
|
194
|
+
for key, value in apply_dates(item, args, now).items():
|
|
195
|
+
totals[key] += value
|
|
196
|
+
print()
|
|
197
|
+
|
|
198
|
+
verb = "Changed" if args.commit else "Would change"
|
|
199
|
+
print(f"{verb} {totals['changed']} item(s); {totals['unchanged']} already correct; "
|
|
200
|
+
f"{totals['skipped']} skipped.")
|
|
201
|
+
if not args.commit and totals["changed"]:
|
|
202
|
+
print("Dry run only. Re-run with --commit to apply.")
|
|
203
|
+
return 0
|
plextool/resume.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""resume: tidy the Continue Watching list."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
|
|
8
|
+
from .common import PlexToolError, add_command, add_connection_args, connect, describe
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def percent(item) -> int:
|
|
12
|
+
if item.viewOffset and item.duration:
|
|
13
|
+
return min(100, round(100 * item.viewOffset / item.duration))
|
|
14
|
+
return 0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def select(items: list, now: datetime, idle_days: int | None = None) -> list:
|
|
18
|
+
"""Partly watched items, optionally only those untouched for idle_days."""
|
|
19
|
+
chosen = [i for i in items if i.viewOffset]
|
|
20
|
+
if idle_days is not None:
|
|
21
|
+
cutoff = now - timedelta(days=idle_days)
|
|
22
|
+
chosen = [i for i in chosen if i.lastViewedAt is None or i.lastViewedAt < cutoff]
|
|
23
|
+
return chosen
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def add_parser(subparsers) -> None:
|
|
27
|
+
parser = add_command(
|
|
28
|
+
subparsers, 'resume',
|
|
29
|
+
'list or clear Continue Watching entries',
|
|
30
|
+
"List partly-watched items in the token owner's Continue Watching. With --clear, remove them by marking them watched or unwatched. Nothing is written unless --commit is given.",
|
|
31
|
+
['plextool resume',
|
|
32
|
+
'plextool resume --idle 90 --clear watched # preview',
|
|
33
|
+
'plextool resume --idle 90 --clear watched --commit'])
|
|
34
|
+
add_connection_args(parser)
|
|
35
|
+
parser.add_argument("--idle", type=int, metavar="DAYS", help="only items not played in the last DAYS days")
|
|
36
|
+
parser.add_argument("--clear", choices=("watched", "unwatched"),
|
|
37
|
+
help="mark the listed items watched, or unwatched. Unwatched also "
|
|
38
|
+
"resets any earlier view count on that item.")
|
|
39
|
+
parser.add_argument("-y", "--commit", action="store_true", help="write the changes (default is a dry run)")
|
|
40
|
+
parser.set_defaults(run=run)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def run(args: argparse.Namespace) -> int:
|
|
44
|
+
if args.commit and not args.clear:
|
|
45
|
+
raise PlexToolError("--commit needs --clear watched|unwatched")
|
|
46
|
+
plex = connect(args)
|
|
47
|
+
items = select(plex.continueWatching(), datetime.now(), args.idle)
|
|
48
|
+
if not items:
|
|
49
|
+
print("Nothing in progress.")
|
|
50
|
+
return 0
|
|
51
|
+
for number, item in enumerate(items, start=1):
|
|
52
|
+
seen = item.lastViewedAt.strftime("%Y-%m-%d") if item.lastViewedAt else "?"
|
|
53
|
+
print(f"{number:>4} {percent(item):>3}% last played {seen} {describe(item)}")
|
|
54
|
+
if args.clear and args.commit:
|
|
55
|
+
item.markPlayed() if args.clear == "watched" else item.markUnplayed()
|
|
56
|
+
if args.clear:
|
|
57
|
+
verb = "Marked" if args.commit else "Would mark"
|
|
58
|
+
print(f"\n{verb} {len(items)} item(s) {args.clear}.")
|
|
59
|
+
if not args.commit:
|
|
60
|
+
print("Dry run only. Re-run with --commit to apply.")
|
|
61
|
+
return 0
|
plextool/scan.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""scan: trigger library scans, metadata refreshes and clean-up."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from .common import (
|
|
9
|
+
PlexToolError,
|
|
10
|
+
add_command,
|
|
11
|
+
add_connection_args,
|
|
12
|
+
add_library_arg,
|
|
13
|
+
connect,
|
|
14
|
+
paint,
|
|
15
|
+
pick_sections,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def add_parser(subparsers) -> None:
|
|
20
|
+
parser = add_command(
|
|
21
|
+
subparsers, 'scan',
|
|
22
|
+
'scan libraries, refresh metadata, empty trash',
|
|
23
|
+
'Ask the server to scan libraries for new files. With no action option this is a plain scan. Scans run on the server, so the command returns at once unless you use --wait.',
|
|
24
|
+
['plextool scan',
|
|
25
|
+
'plextool scan -l Movies --path /mnt/media/Movies/New --wait',
|
|
26
|
+
"plextool scan -l 'TV Shows' --refresh",
|
|
27
|
+
'plextool scan --empty-trash --optimize'])
|
|
28
|
+
add_connection_args(parser)
|
|
29
|
+
add_library_arg(parser)
|
|
30
|
+
parser.add_argument("--path", help="scan only this folder (as the server sees it); needs one --library")
|
|
31
|
+
parser.add_argument("--refresh", action="store_true",
|
|
32
|
+
help="force a metadata refresh from the internet (slow; locked fields are kept)")
|
|
33
|
+
parser.add_argument("--empty-trash", action="store_true", help="empty the trash of each library")
|
|
34
|
+
parser.add_argument("--optimize", action="store_true", help="optimize the server database")
|
|
35
|
+
parser.add_argument("--clean-bundles", action="store_true", help="remove unused metadata bundles")
|
|
36
|
+
parser.add_argument("--wait", action="store_true", help="wait until the scans finish")
|
|
37
|
+
parser.set_defaults(run=run)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run(args: argparse.Namespace) -> int:
|
|
41
|
+
plex = connect(args)
|
|
42
|
+
sections = pick_sections(plex, args.library)
|
|
43
|
+
if args.path and len(sections) != 1:
|
|
44
|
+
raise PlexToolError("--path needs exactly one --library.")
|
|
45
|
+
|
|
46
|
+
only_maintenance = (args.empty_trash or args.optimize or args.clean_bundles) and not (
|
|
47
|
+
args.refresh or args.path)
|
|
48
|
+
for section in sections:
|
|
49
|
+
if args.empty_trash:
|
|
50
|
+
section.emptyTrash()
|
|
51
|
+
print(f"Emptied trash: {section.title}")
|
|
52
|
+
if args.refresh:
|
|
53
|
+
section.refresh()
|
|
54
|
+
print(f"Refreshing metadata: {section.title}")
|
|
55
|
+
elif not only_maintenance:
|
|
56
|
+
section.update(path=args.path)
|
|
57
|
+
print(f"Scanning: {section.title}" + (f" ({args.path})" if args.path else ""))
|
|
58
|
+
if args.optimize:
|
|
59
|
+
plex.library.optimize()
|
|
60
|
+
print("Database optimization started.")
|
|
61
|
+
if args.clean_bundles:
|
|
62
|
+
plex.library.cleanBundles()
|
|
63
|
+
print("Bundle clean-up started.")
|
|
64
|
+
|
|
65
|
+
if args.wait:
|
|
66
|
+
wait_for_scans(plex, [s.title for s in sections])
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def wait_for_scans(plex, titles: list[str], interval: float = 3.0) -> None:
|
|
71
|
+
"""Poll until none of the named libraries report that they are refreshing."""
|
|
72
|
+
# A scan may take a moment to show up as 'refreshing'.
|
|
73
|
+
time.sleep(interval)
|
|
74
|
+
while True:
|
|
75
|
+
busy = [s.title for s in plex.library.sections() if s.title in titles and s.refreshing]
|
|
76
|
+
if not busy:
|
|
77
|
+
print(paint("Scans finished.", "green"))
|
|
78
|
+
return
|
|
79
|
+
print(f" still working: {', '.join(busy)}")
|
|
80
|
+
time.sleep(interval)
|