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/subs.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""subs: extract embedded subtitle tracks from MKV and MP4 files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .common import (
|
|
13
|
+
PlexToolError,
|
|
14
|
+
add_command,
|
|
15
|
+
add_connection_args,
|
|
16
|
+
connect,
|
|
17
|
+
paint,
|
|
18
|
+
pick_sections,
|
|
19
|
+
plural,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# mkv subtitle codec_id -> file extension
|
|
23
|
+
CODEC_EXT = {
|
|
24
|
+
"S_TEXT/UTF8": "srt",
|
|
25
|
+
"S_TEXT/ASCII": "srt",
|
|
26
|
+
"S_TEXT/ASS": "ass",
|
|
27
|
+
"S_TEXT/SSA": "ass",
|
|
28
|
+
"S_TEXT/WEBVTT": "vtt",
|
|
29
|
+
"S_HDMV/PGS": "sup",
|
|
30
|
+
"S_VOBSUB": "sub", # also produces an .idx file
|
|
31
|
+
"S_TEXT/USF": "usf",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# ffprobe codec_name -> (file extension, ffmpeg subtitle codec). Text formats only: ffmpeg
|
|
35
|
+
# can't turn the image-based ones found in MP4 files into usable sidecar files.
|
|
36
|
+
FFMPEG_TEXT = {
|
|
37
|
+
"mov_text": ("srt", "srt"),
|
|
38
|
+
"subrip": ("srt", "srt"),
|
|
39
|
+
"srt": ("srt", "srt"),
|
|
40
|
+
"text": ("srt", "srt"),
|
|
41
|
+
"ass": ("ass", "ass"),
|
|
42
|
+
"ssa": ("ass", "ass"),
|
|
43
|
+
"webvtt": ("vtt", "webvtt"),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# Minimal ISO 639-2 -> 639-1 mapping for common languages; others stay 3-letter.
|
|
47
|
+
ISO3_TO_ISO1 = {
|
|
48
|
+
"eng": "en", "spa": "es", "fre": "fr", "fra": "fr", "ger": "de", "deu": "de", "ita": "it",
|
|
49
|
+
"jpn": "ja", "chi": "zh", "zho": "zh", "kor": "ko", "rus": "ru", "por": "pt", "dut": "nl",
|
|
50
|
+
"nld": "nl", "ara": "ar", "pol": "pl", "swe": "sv", "nor": "no", "dan": "da", "fin": "fi",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
FORCED_NAME = re.compile(r"\bforced\b", re.IGNORECASE)
|
|
54
|
+
SDH_NAME = re.compile(r"\bsdh\b|hearing[ -]impaired|closed[ -]caption|\bcc\b", re.IGNORECASE)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def codec_ext(codec_id: str) -> str:
|
|
58
|
+
return CODEC_EXT.get(codec_id, "sub")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def lang_code(ietf: str | None, iso3: str | None) -> str:
|
|
62
|
+
"""Short language code for a track. Prefers the BCP-47 tag ('pt-BR' -> 'pt'), else maps ISO 639-2."""
|
|
63
|
+
if ietf and ietf != "und":
|
|
64
|
+
return ietf.split("-")[0]
|
|
65
|
+
if not iso3 or iso3 == "und":
|
|
66
|
+
return "und"
|
|
67
|
+
return ISO3_TO_ISO1.get(iso3, iso3)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def flags_for(name: str | None, forced: bool, sdh: bool) -> tuple[bool, bool]:
|
|
71
|
+
"""Combine a track's real flags with hints in its name ('English (SDH)', 'Forced')."""
|
|
72
|
+
name = name or ""
|
|
73
|
+
return (bool(forced) or bool(FORCED_NAME.search(name)),
|
|
74
|
+
bool(sdh) or bool(SDH_NAME.search(name)))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Reading tracks (each returns [{'id', 'lang', 'forced', 'sdh', 'ext', 'codec', ...}])
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def mkv_tracks(identify: dict) -> list[dict]:
|
|
82
|
+
"""Subtitle tracks from `mkvmerge -J` output."""
|
|
83
|
+
tracks = []
|
|
84
|
+
for track in identify.get("tracks", []):
|
|
85
|
+
if track.get("type") != "subtitles":
|
|
86
|
+
continue
|
|
87
|
+
props = track.get("properties", {})
|
|
88
|
+
forced, sdh = flags_for(props.get("track_name"), props.get("forced_track"),
|
|
89
|
+
props.get("flag_hearing_impaired"))
|
|
90
|
+
codec = props.get("codec_id", "")
|
|
91
|
+
tracks.append({
|
|
92
|
+
"id": track["id"],
|
|
93
|
+
"codec": codec,
|
|
94
|
+
"ext": codec_ext(codec),
|
|
95
|
+
"lang": lang_code(props.get("language_ietf"), props.get("language")),
|
|
96
|
+
"forced": forced,
|
|
97
|
+
"sdh": sdh,
|
|
98
|
+
})
|
|
99
|
+
return tracks
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def mp4_tracks(probe: dict) -> list[dict]:
|
|
103
|
+
"""Subtitle streams from `ffprobe -show_streams` output; 'ext' is None if not extractable."""
|
|
104
|
+
tracks = []
|
|
105
|
+
for stream in probe.get("streams", []):
|
|
106
|
+
if stream.get("codec_type") != "subtitle":
|
|
107
|
+
continue
|
|
108
|
+
tags = stream.get("tags", {})
|
|
109
|
+
disposition = stream.get("disposition", {})
|
|
110
|
+
forced, sdh = flags_for(tags.get("title") or tags.get("handler_name"),
|
|
111
|
+
disposition.get("forced"), disposition.get("hearing_impaired"))
|
|
112
|
+
codec = stream.get("codec_name", "")
|
|
113
|
+
ext, ff_codec = FFMPEG_TEXT.get(codec, (None, None))
|
|
114
|
+
tracks.append({
|
|
115
|
+
"id": stream["index"],
|
|
116
|
+
"codec": codec,
|
|
117
|
+
"ext": ext,
|
|
118
|
+
"ffmpeg_codec": ff_codec,
|
|
119
|
+
"lang": lang_code(None, tags.get("language")),
|
|
120
|
+
"forced": forced,
|
|
121
|
+
"sdh": sdh,
|
|
122
|
+
})
|
|
123
|
+
return tracks
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def output_names(video: Path, tracks: list[dict]) -> list[Path]:
|
|
127
|
+
"""Where each track goes: <video>.<lang>[.forced][.sdh].<ext>.
|
|
128
|
+
|
|
129
|
+
Plex reads the language and flags from the end of the name. Tracks that would get the same
|
|
130
|
+
name (same language and flags) are told apart with a number in front of the language:
|
|
131
|
+
<video>.2.<lang>.<ext>, <video>.3.<lang>.<ext>.
|
|
132
|
+
"""
|
|
133
|
+
seen: dict[tuple, int] = {}
|
|
134
|
+
names = []
|
|
135
|
+
for track in tracks:
|
|
136
|
+
key = (track["lang"], track["forced"], track["sdh"])
|
|
137
|
+
count = seen.get(key, 0)
|
|
138
|
+
seen[key] = count + 1
|
|
139
|
+
middle = track["lang"] if count == 0 else f"{count + 1}.{track['lang']}"
|
|
140
|
+
if track["forced"]:
|
|
141
|
+
middle += ".forced"
|
|
142
|
+
if track["sdh"]:
|
|
143
|
+
middle += ".sdh"
|
|
144
|
+
names.append(video.with_name(f"{video.stem}.{middle}.{track['ext']}"))
|
|
145
|
+
return names
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
# Calling the external tools
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def _run(cmd: list[str]):
|
|
153
|
+
return subprocess.run(cmd, check=False, capture_output=True, text=True)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def probe_mkv(path: Path) -> list[dict]:
|
|
157
|
+
result = _run(["mkvmerge", "-J", str(path)])
|
|
158
|
+
if result.returncode >= 2: # 1 is just warnings
|
|
159
|
+
raise PlexToolError(result.stderr.strip() or result.stdout.strip() or "mkvmerge failed")
|
|
160
|
+
return mkv_tracks(json.loads(result.stdout))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def extract_mkv(path: Path, track: dict, out: Path) -> str | None:
|
|
164
|
+
result = _run(["mkvextract", "tracks", str(path), f"{track['id']}:{out}"])
|
|
165
|
+
return result.stdout.strip() or "mkvextract failed" if result.returncode >= 2 else None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def probe_mp4(path: Path) -> list[dict]:
|
|
169
|
+
result = _run(["ffprobe", "-v", "error", "-print_format", "json", "-show_streams",
|
|
170
|
+
"-select_streams", "s", str(path)])
|
|
171
|
+
if result.returncode != 0:
|
|
172
|
+
raise PlexToolError(result.stderr.strip() or "ffprobe failed")
|
|
173
|
+
return mp4_tracks(json.loads(result.stdout))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def extract_mp4(path: Path, track: dict, out: Path) -> str | None:
|
|
177
|
+
result = _run(["ffmpeg", "-nostdin", "-v", "error", "-y", "-i", str(path),
|
|
178
|
+
"-map", f"0:{track['id']}", "-c:s", track["ffmpeg_codec"], str(out)])
|
|
179
|
+
return result.stderr.strip() or "ffmpeg failed" if result.returncode != 0 else None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# What each kind of video file needs. A kind whose tools are missing is skipped, not fatal.
|
|
183
|
+
KINDS = {
|
|
184
|
+
"mkv": {"suffixes": (".mkv",), "tools": ("mkvmerge", "mkvextract"), "package": "MKVToolNix",
|
|
185
|
+
"probe": probe_mkv, "extract": extract_mkv},
|
|
186
|
+
"mp4": {"suffixes": (".mp4", ".m4v"), "tools": ("ffmpeg", "ffprobe"), "package": "FFmpeg",
|
|
187
|
+
"probe": probe_mp4, "extract": extract_mp4},
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def find_videos(root: Path, kinds: list[str]) -> dict[str, list[Path]]:
|
|
192
|
+
"""Files under root, grouped by kind ('mkv', 'mp4')."""
|
|
193
|
+
found: dict[str, list[Path]] = {k: [] for k in kinds}
|
|
194
|
+
for path in sorted(root.rglob("*")):
|
|
195
|
+
if not path.is_file():
|
|
196
|
+
continue
|
|
197
|
+
for kind in kinds:
|
|
198
|
+
if path.suffix.lower() in KINDS[kind]["suffixes"]:
|
|
199
|
+
found[kind].append(path)
|
|
200
|
+
return found
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def missing_tools(kind: str) -> list[str]:
|
|
204
|
+
return [tool for tool in KINDS[kind]["tools"] if not shutil.which(tool)]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def extract_file(kind: str, video: Path, commit: bool, force: bool) -> int:
|
|
208
|
+
"""Extract (or preview extracting) every subtitle track; returns how many were written/planned."""
|
|
209
|
+
print(f">> {video}")
|
|
210
|
+
try:
|
|
211
|
+
tracks = KINDS[kind]["probe"](video)
|
|
212
|
+
except (PlexToolError, ValueError) as e:
|
|
213
|
+
print(paint(f" ! cannot read this file, skipping: {e}", "yellow"))
|
|
214
|
+
return 0
|
|
215
|
+
if not tracks:
|
|
216
|
+
print(" (no subtitle tracks)")
|
|
217
|
+
return 0
|
|
218
|
+
|
|
219
|
+
usable = []
|
|
220
|
+
for track in tracks:
|
|
221
|
+
if track["ext"] is None:
|
|
222
|
+
print(paint(f" track {track['id']} ({track['codec']}): can't be extracted, skipped", "yellow"))
|
|
223
|
+
else:
|
|
224
|
+
usable.append(track)
|
|
225
|
+
|
|
226
|
+
count = 0
|
|
227
|
+
for track, out in zip(usable, output_names(video, usable)):
|
|
228
|
+
print(f" track {track['id']} ({track['codec']}, lang={track['lang']}) -> {out.name}")
|
|
229
|
+
if out.exists() and not force:
|
|
230
|
+
print(" [exists] skipped (use --force to overwrite)")
|
|
231
|
+
continue
|
|
232
|
+
if commit:
|
|
233
|
+
error = KINDS[kind]["extract"](video, track, out)
|
|
234
|
+
if error:
|
|
235
|
+
print(paint(f" ! failed: {error}", "red"))
|
|
236
|
+
continue
|
|
237
|
+
count += 1
|
|
238
|
+
return count
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
# Command
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
def parse_types(text: str) -> list[str]:
|
|
246
|
+
types = [t.strip().lower() for t in text.split(",") if t.strip()]
|
|
247
|
+
bad = [t for t in types if t not in KINDS]
|
|
248
|
+
if bad or not types:
|
|
249
|
+
raise argparse.ArgumentTypeError(f"types must be from: {', '.join(KINDS)}")
|
|
250
|
+
return types
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def add_parser(subparsers) -> None:
|
|
254
|
+
parser = add_command(
|
|
255
|
+
subparsers, "subs", "extract embedded subtitles from MKV and MP4 files",
|
|
256
|
+
"Work with subtitles. 'extract' pulls embedded tracks out of video files: MKV files need "
|
|
257
|
+
"mkvmerge and mkvextract (from MKVToolNix), MP4 files need ffmpeg and ffprobe, and you "
|
|
258
|
+
"only need the tools for the types you use. 'export' saves subtitles Plex downloaded on "
|
|
259
|
+
"demand next to the video, and 'fetch' searches for new ones.",
|
|
260
|
+
["plextool subs extract /mnt/media/Movies # embedded tracks, preview",
|
|
261
|
+
"plextool subs export -t 'Some Show' --commit # Plex's downloaded subs",
|
|
262
|
+
"plextool subs fetch -t 'Some Movie' --lang en --list # browse available subs"])
|
|
263
|
+
sub = parser.add_subparsers(dest="subs_command", required=True, metavar="action")
|
|
264
|
+
|
|
265
|
+
extract = add_command(
|
|
266
|
+
sub, "extract", "extract every subtitle track next to its video",
|
|
267
|
+
"Recursively find .mkv, .mp4 and .m4v files under DIRECTORY and save each subtitle track "
|
|
268
|
+
"beside its video, named so Plex finds it: Movie.en.srt, with .forced or .sdh after the "
|
|
269
|
+
"language for tracks flagged that way (Movie.en.forced.srt). Flags come from the track's "
|
|
270
|
+
"own flags or from its name. If two tracks would get the same name, the later ones are "
|
|
271
|
+
"numbered: Movie.2.en.srt. Existing files are skipped unless --force. Nothing is "
|
|
272
|
+
"written unless --commit is given.\n\nImage-based subtitles in MP4 files can't be "
|
|
273
|
+
"extracted and are reported. With --scan LIBRARY, ask Plex to scan afterwards so the new "
|
|
274
|
+
"files show up. That is the only part that needs a Plex connection.",
|
|
275
|
+
["plextool subs extract /mnt/media/Movies",
|
|
276
|
+
"plextool subs extract /mnt/media/Movies --commit",
|
|
277
|
+
"plextool subs extract . --types mkv --commit",
|
|
278
|
+
"plextool subs extract . --commit --scan Movies --scan-path /data/Movies"])
|
|
279
|
+
extract.add_argument("directory", nargs="?", default=".", help="where to search (default: current directory)")
|
|
280
|
+
extract.add_argument("--types", type=parse_types, default=list(KINDS), metavar="LIST",
|
|
281
|
+
help="file types to process, comma-separated from: "
|
|
282
|
+
f"{', '.join(KINDS)} (default: all)")
|
|
283
|
+
extract.add_argument("-f", "--force", action="store_true", help="overwrite subtitle files that already exist")
|
|
284
|
+
extract.add_argument("-y", "--commit", action="store_true", help="write the files (default is a dry run)")
|
|
285
|
+
extract.add_argument("--scan", metavar="LIBRARY", help="after extracting, scan this Plex library")
|
|
286
|
+
extract.add_argument("--scan-path", metavar="PATH",
|
|
287
|
+
help="folder to scan, as the Plex server sees it (default: DIRECTORY)")
|
|
288
|
+
add_connection_args(extract)
|
|
289
|
+
extract.set_defaults(run=run_extract)
|
|
290
|
+
|
|
291
|
+
from . import ondemand # imported here because ondemand builds on this module
|
|
292
|
+
|
|
293
|
+
ondemand.add_actions(sub)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def run_extract(args: argparse.Namespace) -> int:
|
|
297
|
+
root = Path(args.directory)
|
|
298
|
+
if not root.is_dir():
|
|
299
|
+
raise PlexToolError(f"Not a directory: {root}")
|
|
300
|
+
|
|
301
|
+
groups = find_videos(root, args.types)
|
|
302
|
+
if not any(groups.values()):
|
|
303
|
+
print(f"No {'/'.join(args.types)} files found under {root}.")
|
|
304
|
+
return 0
|
|
305
|
+
|
|
306
|
+
total = videos = 0
|
|
307
|
+
skipped: list[str] = []
|
|
308
|
+
for kind, files in groups.items():
|
|
309
|
+
if not files:
|
|
310
|
+
continue
|
|
311
|
+
absent = missing_tools(kind)
|
|
312
|
+
if absent:
|
|
313
|
+
skipped.append(f"{plural(len(files), kind + ' file')} skipped: {' and '.join(absent)} "
|
|
314
|
+
f"not found (install {KINDS[kind]['package']})")
|
|
315
|
+
continue
|
|
316
|
+
for video in files:
|
|
317
|
+
total += extract_file(kind, video, args.commit, args.force)
|
|
318
|
+
videos += len(files)
|
|
319
|
+
|
|
320
|
+
for note in skipped:
|
|
321
|
+
print(paint(f"\n! {note}", "yellow"))
|
|
322
|
+
if skipped and not videos:
|
|
323
|
+
raise PlexToolError("None of the video files found can be processed with the tools installed.")
|
|
324
|
+
|
|
325
|
+
verb = "Extracted" if args.commit else "Would extract"
|
|
326
|
+
print(f"\n{verb} {plural(total, 'subtitle file')} from {plural(videos, 'video')}.")
|
|
327
|
+
if not args.commit and total:
|
|
328
|
+
print("Dry run only. Re-run with --commit to write them.")
|
|
329
|
+
|
|
330
|
+
if args.scan and args.commit and total:
|
|
331
|
+
plex = connect(args)
|
|
332
|
+
(section,) = pick_sections(plex, [args.scan])
|
|
333
|
+
path = args.scan_path or str(root.resolve())
|
|
334
|
+
section.update(path=path)
|
|
335
|
+
print(f"Asked Plex to scan {path} in '{section.title}'.")
|
|
336
|
+
elif args.scan and not args.commit:
|
|
337
|
+
print(f"(With --commit, Plex library '{args.scan}' would be scanned.)")
|
|
338
|
+
return 0
|
plextool/watched.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""watched: copy, back up, restore and bulk-set watched state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from plexapi.exceptions import NotFound
|
|
9
|
+
from plexapi.myplex import MyPlexAccount
|
|
10
|
+
from plexapi.server import PlexServer
|
|
11
|
+
from requests.exceptions import RequestException
|
|
12
|
+
|
|
13
|
+
from .common import (
|
|
14
|
+
SECTION_LEVELS,
|
|
15
|
+
PlexToolError,
|
|
16
|
+
add_command,
|
|
17
|
+
add_connection_args,
|
|
18
|
+
add_library_arg,
|
|
19
|
+
connect,
|
|
20
|
+
describe,
|
|
21
|
+
paint,
|
|
22
|
+
pick_sections,
|
|
23
|
+
plural,
|
|
24
|
+
read_json,
|
|
25
|
+
write_json,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Each kind of watched item: (name in files and flags, library type, item type)
|
|
29
|
+
KINDS = (("movies", "movie", "movie"), ("episodes", "show", "episode"))
|
|
30
|
+
FORMAT_VERSION = 1
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def user_server(args: argparse.Namespace, admin: PlexServer, name: str | None) -> PlexServer:
|
|
34
|
+
"""Open the server as the named user (the owner if name is None or is the owner)."""
|
|
35
|
+
if not name:
|
|
36
|
+
return admin
|
|
37
|
+
try:
|
|
38
|
+
account = MyPlexAccount(token=args.token)
|
|
39
|
+
except RequestException as e:
|
|
40
|
+
raise PlexToolError(f"Could not reach plex.tv: {e}") from None
|
|
41
|
+
own_names = {n.lower() for n in (account.username, account.email, account.title) if n}
|
|
42
|
+
if name.lower() in own_names:
|
|
43
|
+
return admin
|
|
44
|
+
try:
|
|
45
|
+
user = account.user(name)
|
|
46
|
+
except NotFound:
|
|
47
|
+
raise PlexToolError(f"'{name}' is not a user on this account.") from None
|
|
48
|
+
token = user.get_token(admin.machineIdentifier)
|
|
49
|
+
if not token:
|
|
50
|
+
raise PlexToolError(
|
|
51
|
+
f"Could not get an access token for '{name}'. Is this server shared with them?")
|
|
52
|
+
try:
|
|
53
|
+
return PlexServer(args.base_url, token)
|
|
54
|
+
except RequestException as e:
|
|
55
|
+
raise PlexToolError(f"Could not connect as '{name}': {e}") from None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def wanted_kinds(args: argparse.Namespace) -> list[tuple[str, str, str]]:
|
|
59
|
+
both = not (args.movies or args.shows)
|
|
60
|
+
return [k for k in KINDS if both or (k[0] == "movies" and args.movies)
|
|
61
|
+
or (k[0] == "episodes" and args.shows)]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def collect_watched(server: PlexServer, kinds) -> dict:
|
|
65
|
+
"""{'movies': {guid: title}, 'episodes': {guid: title}} of what this user has watched."""
|
|
66
|
+
found = {}
|
|
67
|
+
for label, section_type, libtype in kinds:
|
|
68
|
+
found[label] = {}
|
|
69
|
+
for section in server.library.sections():
|
|
70
|
+
if section.TYPE == section_type:
|
|
71
|
+
for item in section.search(libtype=libtype, unwatched=False):
|
|
72
|
+
found[label][item.guid] = describe(item)
|
|
73
|
+
return found
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def apply_watched(server: PlexServer, watched: dict, kinds, commit: bool) -> int:
|
|
77
|
+
"""Mark items watched where `watched` says they should be; returns how many."""
|
|
78
|
+
count = 0
|
|
79
|
+
for label, section_type, libtype in kinds:
|
|
80
|
+
guids = watched.get(label, {})
|
|
81
|
+
if not guids:
|
|
82
|
+
continue
|
|
83
|
+
print(paint(f"\n{label.upper()}", "cyan"))
|
|
84
|
+
for section in server.library.sections():
|
|
85
|
+
if section.TYPE != section_type:
|
|
86
|
+
continue
|
|
87
|
+
for item in section.search(libtype=libtype, unwatched=True):
|
|
88
|
+
if item.guid not in guids:
|
|
89
|
+
continue
|
|
90
|
+
if commit:
|
|
91
|
+
item.markPlayed()
|
|
92
|
+
print(f" [MARKED] {describe(item)}")
|
|
93
|
+
else:
|
|
94
|
+
print(f" [DRY RUN] Would mark watched: {describe(item)}")
|
|
95
|
+
count += 1
|
|
96
|
+
return count
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def report(total: int, who: str, commit: bool) -> None:
|
|
100
|
+
verb = "Marked" if commit else "Would mark"
|
|
101
|
+
print(f"\n{verb} {total} item(s) as watched for {who}.")
|
|
102
|
+
if not commit and total:
|
|
103
|
+
print("Dry run only. Re-run with --commit to apply.")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def to_file_data(user: str, watched: dict) -> dict:
|
|
107
|
+
return {
|
|
108
|
+
"version": FORMAT_VERSION,
|
|
109
|
+
"user": user,
|
|
110
|
+
"exported": datetime.now().replace(microsecond=0).isoformat(),
|
|
111
|
+
**{label: [{"guid": g, "title": t} for g, t in items.items()] for label, items in watched.items()},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def from_file_data(data: dict) -> dict:
|
|
116
|
+
if data.get("version") != FORMAT_VERSION:
|
|
117
|
+
raise PlexToolError("Unrecognised file: not a plextool watched export.")
|
|
118
|
+
return {label: {e["guid"]: e["title"] for e in data.get(label, [])} for label, _, _ in KINDS}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# Commands
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def run_copy(args: argparse.Namespace) -> int:
|
|
126
|
+
admin = connect(args)
|
|
127
|
+
if args.source.lower() == args.dest.lower():
|
|
128
|
+
raise PlexToolError("--from and --to are the same user.")
|
|
129
|
+
kinds = wanted_kinds(args)
|
|
130
|
+
print(f"Source: {args.source} Destination: {args.dest} "
|
|
131
|
+
f"Mode: {'COMMIT' if args.commit else 'DRY RUN'}")
|
|
132
|
+
watched = collect_watched(user_server(args, admin, args.source), kinds)
|
|
133
|
+
for label, items in watched.items():
|
|
134
|
+
print(f" Source has watched {plural(len(items), label.rstrip('s'))}.")
|
|
135
|
+
total = apply_watched(user_server(args, admin, args.dest), watched, kinds, args.commit)
|
|
136
|
+
report(total, args.dest, args.commit)
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def run_export(args: argparse.Namespace) -> int:
|
|
141
|
+
admin = connect(args)
|
|
142
|
+
watched = collect_watched(user_server(args, admin, args.user), wanted_kinds(args))
|
|
143
|
+
data = to_file_data(args.user or "owner", watched)
|
|
144
|
+
out = write_json(args.file, data)
|
|
145
|
+
print(f"Wrote {sum(len(v) for v in watched.values())} watched item(s) to "
|
|
146
|
+
f"{'standard output' if args.file == '-' else args.file}", file=out)
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def run_import(args: argparse.Namespace) -> int:
|
|
151
|
+
watched = from_file_data(read_json(args.file))
|
|
152
|
+
admin = connect(args)
|
|
153
|
+
total = apply_watched(user_server(args, admin, args.user), watched, wanted_kinds(args), args.commit)
|
|
154
|
+
report(total, args.user or "the owner", args.commit)
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def run_mark(args: argparse.Namespace) -> int:
|
|
159
|
+
admin = connect(args)
|
|
160
|
+
server = user_server(args, admin, args.user)
|
|
161
|
+
# The user's view of the library, so match sections by name on their connection.
|
|
162
|
+
names = [s.title for s in pick_sections(admin, args.library)]
|
|
163
|
+
count = 0
|
|
164
|
+
for name in names:
|
|
165
|
+
section = server.library.section(name)
|
|
166
|
+
level = args.level or section.TYPE
|
|
167
|
+
if level not in SECTION_LEVELS[section.TYPE]:
|
|
168
|
+
continue
|
|
169
|
+
for item in section.search(title=args.title, libtype=level):
|
|
170
|
+
action = "unwatched" if args.unwatched else "watched"
|
|
171
|
+
print(f" {'[MARKED]' if args.commit else '[DRY RUN]'} {describe(item)} -> {action}")
|
|
172
|
+
if args.commit:
|
|
173
|
+
item.markUnplayed() if args.unwatched else item.markPlayed()
|
|
174
|
+
count += 1
|
|
175
|
+
print(f"\n{plural(count, 'item')} {'updated' if args.commit else 'would be updated'}.")
|
|
176
|
+
if not args.commit and count:
|
|
177
|
+
print("Dry run only. Re-run with --commit to apply.")
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _kind_flags(parser) -> None:
|
|
182
|
+
parser.add_argument("--movies", action="store_true", help="movies only")
|
|
183
|
+
parser.add_argument("--shows", action="store_true", help="TV episodes only")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def add_parser(subparsers) -> None:
|
|
187
|
+
parser = add_command(
|
|
188
|
+
subparsers, 'watched',
|
|
189
|
+
'copy, back up, restore or bulk-set watched state',
|
|
190
|
+
"Work with watched state. --token must be the server owner's. Where an action takes a user, the owner can be named too; leaving --user off means the owner. Only the watched flag moves: view counts, last-watched dates and resume positions do not. Watchlists live on plex.tv and are not covered. Everything is a dry run unless you pass --commit.",
|
|
191
|
+
['plextool watched copy --from alice --to bob --commit',
|
|
192
|
+
'plextool watched export backup.json',
|
|
193
|
+
"plextool watched mark -t 'Some Show' --commit"])
|
|
194
|
+
sub = parser.add_subparsers(dest="watched_command", required=True, metavar="action")
|
|
195
|
+
|
|
196
|
+
copy = add_command(
|
|
197
|
+
sub, 'copy',
|
|
198
|
+
'copy watched state from one user to another',
|
|
199
|
+
'Mark items watched for --to where --from has watched them. Users can be a username, email or Home user name. Useful when splitting a shared account, or when someone closes and reopens their Plex account.',
|
|
200
|
+
['plextool watched copy --from alice --to bob # preview',
|
|
201
|
+
'plextool watched copy --from alice --to bob --movies --commit'])
|
|
202
|
+
add_connection_args(copy)
|
|
203
|
+
copy.add_argument("--from", dest="source", required=True, metavar="USER",
|
|
204
|
+
help="user to copy from (username, email, or Home user name)")
|
|
205
|
+
copy.add_argument("--to", dest="dest", required=True, metavar="USER", help="user to copy to")
|
|
206
|
+
_kind_flags(copy)
|
|
207
|
+
copy.add_argument("-y", "--commit", action="store_true", help="write the changes (default is a dry run)")
|
|
208
|
+
copy.set_defaults(run=run_copy)
|
|
209
|
+
|
|
210
|
+
export = add_command(
|
|
211
|
+
sub, 'export',
|
|
212
|
+
"save a user's watched state to a JSON file",
|
|
213
|
+
'Write what a user has watched to a file, as a backup or to move to another server. Read-only on the server.',
|
|
214
|
+
['plextool watched export backup.json',
|
|
215
|
+
'plextool watched export alice.json --user alice --shows'])
|
|
216
|
+
add_connection_args(export)
|
|
217
|
+
export.add_argument("file", help="file to write ('-' for standard output)")
|
|
218
|
+
export.add_argument("--user", metavar="USER", help="user to export (default: the token owner)")
|
|
219
|
+
_kind_flags(export)
|
|
220
|
+
export.set_defaults(run=run_export)
|
|
221
|
+
|
|
222
|
+
imp = add_command(
|
|
223
|
+
sub, 'import',
|
|
224
|
+
'restore watched state from a JSON file',
|
|
225
|
+
"Mark matching unwatched items watched, from a file made by 'watched export'. It never marks anything unwatched.",
|
|
226
|
+
['plextool watched import backup.json # preview',
|
|
227
|
+
'plextool watched import alice.json --user alice --commit'])
|
|
228
|
+
add_connection_args(imp)
|
|
229
|
+
imp.add_argument("file", help="file written by 'watched export' ('-' for standard input)")
|
|
230
|
+
imp.add_argument("--user", metavar="USER", help="user to import into (default: the token owner)")
|
|
231
|
+
_kind_flags(imp)
|
|
232
|
+
imp.add_argument("-y", "--commit", action="store_true", help="write the changes (default is a dry run)")
|
|
233
|
+
imp.set_defaults(run=run_import)
|
|
234
|
+
|
|
235
|
+
mark = add_command(
|
|
236
|
+
sub, 'mark',
|
|
237
|
+
'mark movies, shows, seasons or episodes watched/unwatched',
|
|
238
|
+
'Mark everything matching a title watched or unwatched, such as a whole series or season. Title matching is by substring, so check the preview.',
|
|
239
|
+
["plextool watched mark -l 'TV Shows' -t 'Some Show' # preview",
|
|
240
|
+
"plextool watched mark -t 'Some Show' --level season --unwatched --commit"])
|
|
241
|
+
add_connection_args(mark)
|
|
242
|
+
add_library_arg(mark)
|
|
243
|
+
mark.add_argument("-t", "--title", required=True, help="title to match (substring)")
|
|
244
|
+
mark.add_argument("--level", choices=sorted({lv for lvs in SECTION_LEVELS.values() for lv in lvs}),
|
|
245
|
+
help="item type (default: movie in movie libraries, show in TV libraries)")
|
|
246
|
+
mark.add_argument("--unwatched", action="store_true", help="mark unwatched instead of watched")
|
|
247
|
+
mark.add_argument("--user", metavar="USER", help="user to change (default: the token owner)")
|
|
248
|
+
mark.add_argument("-y", "--commit", action="store_true", help="write the changes (default is a dry run)")
|
|
249
|
+
mark.set_defaults(run=run_mark)
|