plexer-cli 0.0.3.post4__py3-none-any.whl → 0.1.2__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.
- plexer_cli/const.py +20 -1
- plexer_cli/file_manager.py +177 -57
- plexer_cli/main.py +34 -11
- plexer_cli/metadata.py +111 -16
- {plexer_cli-0.0.3.post4.dist-info → plexer_cli-0.1.2.dist-info}/METADATA +36 -26
- plexer_cli-0.1.2.dist-info/RECORD +11 -0
- {plexer_cli-0.0.3.post4.dist-info → plexer_cli-0.1.2.dist-info}/WHEEL +1 -1
- plexer_cli-0.0.3.post4.dist-info/RECORD +0 -11
- {plexer_cli-0.0.3.post4.dist-info → plexer_cli-0.1.2.dist-info}/entry_points.txt +0 -0
- {plexer_cli-0.0.3.post4.dist-info → plexer_cli-0.1.2.dist-info}/licenses/LICENSE +0 -0
plexer_cli/const.py
CHANGED
|
@@ -4,4 +4,23 @@ Plexer - Normalize media files for use with Plex Media Server
|
|
|
4
4
|
Module: Const - collection of global variables used across the application
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
ARTIFACT_NAME_REGEX = r"^(.+) (\([\d]{4}\)) ?(\{edition-.+\})?$"
|
|
8
|
+
ARTIFACT_HEURISTICS_PATTERNS = {
|
|
9
|
+
"name": r"^(.+?)([\.\-\_\(\[][1|2])", # anything before the first instance of commonly-used separators
|
|
10
|
+
"common_editions": r"(?:[Dd]irector(?:')?s|[Ff]inal|[Ee]xtended|[Tt]heatrical|[Uu]nrated)[\.\-\_ ](?:Cut|Edit)|Uncut", # common multi-word edition names (e.g., "Directors Cut", "Theatrical Edit", etc.)
|
|
11
|
+
"edition": r"([A-Za-z0-9']+)([\.\-\_ ]{1})([Ee][Dd][Ii][Tt][Ii][Oo][Nn])|(?:{edition-)(.+)(?:})", # "XXX edition" (case-insensitive) or standard Plex format
|
|
12
|
+
"release_year": r"(19|20)([0-9]{2})", # any 4 digit number between 1900 and 2099
|
|
13
|
+
}
|
|
14
|
+
ARTIFACT_FILE_TYPE_WHITELIST = [
|
|
15
|
+
# Videos
|
|
16
|
+
"video/mp4",
|
|
17
|
+
"video/x-matroska", # .mkv
|
|
18
|
+
"video/x-msvideo" # .avi
|
|
19
|
+
# Subtitles
|
|
20
|
+
"application/x-subrip" # .srt
|
|
21
|
+
"text/plain" # .sub subtitle files
|
|
22
|
+
"text/subtitle" # .sub subtitle files (ALT)
|
|
23
|
+
"application/octet-stream" # .sub index files
|
|
24
|
+
# Metadata
|
|
25
|
+
"text/x-nfo", # .nfo
|
|
26
|
+
]
|
plexer_cli/file_manager.py
CHANGED
|
@@ -5,12 +5,14 @@ Module: File Manager - code for file-related ops
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import os
|
|
8
|
+
import re
|
|
9
|
+
|
|
8
10
|
from pathlib import Path
|
|
9
11
|
from magic import from_file
|
|
10
12
|
from logzero import logger
|
|
11
13
|
|
|
12
14
|
from .artifact import Artifact
|
|
13
|
-
from .const import
|
|
15
|
+
from .const import ARTIFACT_NAME_REGEX, ARTIFACT_FILE_TYPE_WHITELIST
|
|
14
16
|
from .metadata import Metadata
|
|
15
17
|
|
|
16
18
|
|
|
@@ -28,104 +30,222 @@ class FileManager:
|
|
|
28
30
|
|
|
29
31
|
def get_artifacts(self, tgt_dir="") -> list:
|
|
30
32
|
"""
|
|
31
|
-
Gather the names of all files and directories in a given directory and return as list
|
|
32
|
-
|
|
33
|
+
Gather the names of all files and directories in a given directory and return as list.
|
|
33
34
|
|
|
34
|
-
Target directory is the source directory by default.
|
|
35
|
+
Target directory is the source directory by default, but can be specified via parameter.
|
|
35
36
|
"""
|
|
36
37
|
|
|
37
38
|
artifacts = []
|
|
38
39
|
tgt_dir = tgt_dir if tgt_dir else self.src_dir
|
|
39
40
|
|
|
40
41
|
with os.scandir(tgt_dir) as sd_iter:
|
|
41
|
-
for
|
|
42
|
+
for dir_artifact in sd_iter:
|
|
42
43
|
try:
|
|
43
|
-
artifact_mime_type = from_file(
|
|
44
|
+
artifact_mime_type = from_file(dir_artifact.path, mime=True)
|
|
44
45
|
except IsADirectoryError:
|
|
45
46
|
artifact_mime_type = "directory"
|
|
46
47
|
|
|
47
48
|
artifacts.append(
|
|
48
49
|
Artifact(
|
|
49
|
-
name=
|
|
50
|
-
path=
|
|
50
|
+
name=dir_artifact.name,
|
|
51
|
+
path=dir_artifact.path,
|
|
51
52
|
mime_type=artifact_mime_type,
|
|
52
53
|
)
|
|
53
54
|
)
|
|
54
55
|
|
|
55
56
|
return artifacts
|
|
56
57
|
|
|
57
|
-
def
|
|
58
|
+
def check_artifact(self, artifact: Artifact) -> bool:
|
|
58
59
|
"""
|
|
59
|
-
Perform any
|
|
60
|
+
Perform any checks needed to determine if the artifact is valid for further processing
|
|
60
61
|
|
|
61
62
|
Right now, this includes:
|
|
62
|
-
*
|
|
63
|
+
* Checking if the artifact name is in a valid format required by Plex
|
|
63
64
|
"""
|
|
64
65
|
|
|
65
|
-
|
|
66
|
-
if artifact.name == METADATA_FILE_NAME:
|
|
67
|
-
# float it to the top of the artifact set
|
|
68
|
-
artifacts.pop(idx)
|
|
69
|
-
artifacts.insert(0, artifact)
|
|
66
|
+
valid_artifact = False
|
|
70
67
|
|
|
71
|
-
|
|
68
|
+
artifact_name_reg = re.compile(ARTIFACT_NAME_REGEX)
|
|
69
|
+
if artifact_name_reg.match(artifact.name):
|
|
70
|
+
logger.debug(f"artifact name format is VALID: {artifact.name}")
|
|
72
71
|
|
|
73
|
-
|
|
72
|
+
valid_artifact = True
|
|
73
|
+
else:
|
|
74
|
+
logger.debug(f"artifact name format is INVALID: {artifact.name}")
|
|
75
|
+
|
|
76
|
+
return valid_artifact
|
|
77
|
+
|
|
78
|
+
def rename_artifact(
|
|
79
|
+
self, artifact: Artifact, video_metadata: Metadata, dry_run=False
|
|
80
|
+
) -> Artifact:
|
|
81
|
+
"""
|
|
82
|
+
Rename an artifact to the new name generated from the given metadata
|
|
74
83
|
|
|
75
|
-
|
|
84
|
+
Returns the new, updated artifact object
|
|
76
85
|
"""
|
|
77
|
-
Traverse the given directory artifacts, rename the
|
|
78
|
-
video files accordingly, and delete everything else
|
|
79
86
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
87
|
+
new_artifact_name = f"{video_metadata.name} ({video_metadata.release_year})"
|
|
88
|
+
|
|
89
|
+
if video_metadata.edition:
|
|
90
|
+
new_artifact_name += f" {{edition-{video_metadata.edition}}}"
|
|
91
|
+
|
|
92
|
+
# get artifact file info for srrc/dst path generation
|
|
93
|
+
artifact_file_path = Path(artifact.absolute_path)
|
|
94
|
+
artifact_parent_dir = artifact_file_path.parent
|
|
95
|
+
artifact_file_ext = (
|
|
96
|
+
"" if artifact.mime_type == "directory" else artifact_file_path.suffix
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
src_file = artifact_file_path.absolute()
|
|
100
|
+
dst_file = f"{artifact_parent_dir}/{new_artifact_name}{artifact_file_ext}"
|
|
101
|
+
|
|
102
|
+
logger.debug(
|
|
103
|
+
f"renaming artifact: [ OLD PATH: {src_file} ] to [ NEW PATH: {dst_file} ]"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if src_file != dst_file:
|
|
107
|
+
logger.debug(
|
|
108
|
+
f"executing rename operation on filesystem: {src_file} -> {dst_file}"
|
|
109
|
+
)
|
|
110
|
+
if not dry_run:
|
|
111
|
+
os.rename(src_file, dst_file)
|
|
112
|
+
artifact.name = new_artifact_name
|
|
113
|
+
artifact.absolute_path = dst_file
|
|
114
|
+
else:
|
|
115
|
+
logger.debug("dry run enabled; skipping actual rename operation")
|
|
116
|
+
else:
|
|
117
|
+
logger.debug(
|
|
118
|
+
"source and destination paths are identical; skipping rename operation"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return artifact
|
|
122
|
+
|
|
123
|
+
def analyze_artifact(
|
|
124
|
+
self, artifact: Artifact, prompt_behavior: str, dry_run: bool
|
|
125
|
+
) -> Metadata:
|
|
126
|
+
"""
|
|
127
|
+
Analyze the given artifact and return a Metadata object with the relevant info
|
|
83
128
|
"""
|
|
84
129
|
|
|
130
|
+
logger.debug(f"starting analysis of artifact: {artifact.name}")
|
|
131
|
+
|
|
132
|
+
# use heuristics to attempt to determine metadata from directory name
|
|
85
133
|
video_metadata = Metadata()
|
|
134
|
+
video_metadata.metadata_found = False
|
|
86
135
|
|
|
87
|
-
|
|
136
|
+
if video_metadata.do_heuristic_analysis(file_name=artifact.name):
|
|
137
|
+
logger.info(
|
|
138
|
+
f"metadata found for directory via heuristics - name: {video_metadata.name}, release_year: {video_metadata.release_year}",
|
|
139
|
+
)
|
|
140
|
+
video_metadata.metadata_found = True
|
|
88
141
|
|
|
89
|
-
|
|
142
|
+
if prompt_behavior == "all" or (
|
|
143
|
+
prompt_behavior == "default" and not video_metadata.metadata_found
|
|
144
|
+
):
|
|
90
145
|
logger.info(
|
|
91
|
-
"
|
|
92
|
-
artifact.name,
|
|
93
|
-
artifact.absolute_path,
|
|
94
|
-
artifact.mime_type,
|
|
146
|
+
"user requested all prompts OR empty or incomplete metadata found for directory via heuristics; prompting user for manual input"
|
|
95
147
|
)
|
|
148
|
+
video_metadata.prompt_user_for_metadata()
|
|
96
149
|
|
|
97
|
-
|
|
98
|
-
logger.info("subdirectory found, processing")
|
|
150
|
+
return video_metadata
|
|
99
151
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
152
|
+
def process_artifacts(
|
|
153
|
+
self,
|
|
154
|
+
artifacts: list,
|
|
155
|
+
video_metadata=Metadata(),
|
|
156
|
+
prompt_behavior="default",
|
|
157
|
+
dry_run=False,
|
|
158
|
+
) -> None:
|
|
159
|
+
"""
|
|
160
|
+
Traverse the given set of artifacts, rename the video files accordingly, and delete everything else.
|
|
106
161
|
|
|
107
|
-
|
|
108
|
-
|
|
162
|
+
Subdirectories are processed recursively.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
logger.debug("starting artifact processing")
|
|
166
|
+
|
|
167
|
+
for artifact in artifacts:
|
|
168
|
+
logger.info(
|
|
169
|
+
f"processing artifact: [ FILE: {artifact.name} | PATH: {artifact.absolute_path} | FILE TYPE: {artifact.mime_type} ]"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if artifact.mime_type == "directory":
|
|
173
|
+
logger.info(
|
|
174
|
+
"subdirectory found; assuming media directory and processing accordingly"
|
|
109
175
|
)
|
|
110
|
-
|
|
111
|
-
#
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
file_ext = file_path.suffix
|
|
117
|
-
|
|
118
|
-
src_file = artifact.absolute_path
|
|
119
|
-
dst_file = (
|
|
120
|
-
f"{file_dir}/{video_metadata.name}"
|
|
121
|
-
f" ({video_metadata.release_year}){file_ext}"
|
|
176
|
+
|
|
177
|
+
# perform analysis for video metadata
|
|
178
|
+
generated_video_metadata = self.analyze_artifact(
|
|
179
|
+
artifact=artifact,
|
|
180
|
+
prompt_behavior=prompt_behavior,
|
|
181
|
+
dry_run=dry_run,
|
|
122
182
|
)
|
|
123
183
|
|
|
124
|
-
|
|
184
|
+
if generated_video_metadata.metadata_found:
|
|
185
|
+
logger.info(
|
|
186
|
+
"analysis successful, metadata found; renaming artifact based on gathered metadata"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
artifact = self.rename_artifact(
|
|
190
|
+
artifact=artifact,
|
|
191
|
+
video_metadata=generated_video_metadata,
|
|
192
|
+
dry_run=dry_run,
|
|
193
|
+
)
|
|
125
194
|
|
|
126
|
-
|
|
195
|
+
video_metadata = generated_video_metadata
|
|
196
|
+
else:
|
|
197
|
+
logger.warning(
|
|
198
|
+
"no metadata found for directory after exhausting all methods; skipping artifact processing"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
# once metadata is available, start recursive subprocessing
|
|
204
|
+
new_dir_artifacts = self.get_artifacts(tgt_dir=artifact.absolute_path)
|
|
205
|
+
if new_dir_artifacts:
|
|
206
|
+
self.process_artifacts(
|
|
207
|
+
artifacts=new_dir_artifacts,
|
|
208
|
+
video_metadata=video_metadata,
|
|
209
|
+
prompt_behavior=prompt_behavior,
|
|
210
|
+
dry_run=dry_run,
|
|
211
|
+
)
|
|
127
212
|
else:
|
|
128
|
-
|
|
129
|
-
|
|
213
|
+
if not video_metadata.metadata_found:
|
|
214
|
+
raise ValueError(
|
|
215
|
+
f"file artifact found ({artifact.name}) but no video metadata provided; unable to proceed"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
logger.info(
|
|
219
|
+
f"file artifact found, processing accordingly: {artifact.name}"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# delete any files with file-types not whitelisted; rename whitelisted files accordingly
|
|
223
|
+
if artifact.mime_type not in ARTIFACT_FILE_TYPE_WHITELIST:
|
|
224
|
+
logger.warning(
|
|
225
|
+
f"file artifact is not a whitelisted file type; deleting: {artifact.name}"
|
|
226
|
+
)
|
|
227
|
+
if not dry_run:
|
|
228
|
+
os.remove(artifact.absolute_path)
|
|
229
|
+
else:
|
|
230
|
+
logger.debug(
|
|
231
|
+
"dry run enabled; skipping actual delete operation"
|
|
232
|
+
)
|
|
233
|
+
else:
|
|
234
|
+
logger.debug(
|
|
235
|
+
"file artifact is a whitelisted file type", artifact.mime_type
|
|
236
|
+
)
|
|
130
237
|
|
|
131
|
-
|
|
238
|
+
if self.check_artifact(artifact=artifact):
|
|
239
|
+
logger.info(
|
|
240
|
+
"file artifact already in a valid format, skipping rename operation"
|
|
241
|
+
)
|
|
242
|
+
else:
|
|
243
|
+
logger.info(
|
|
244
|
+
"file artifact is NOT in a valid format; renaming accordingly"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
artifact = self.rename_artifact(
|
|
248
|
+
artifact=artifact,
|
|
249
|
+
video_metadata=video_metadata,
|
|
250
|
+
dry_run=dry_run,
|
|
251
|
+
)
|
plexer_cli/main.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/usr/bin/env
|
|
1
|
+
#!/usr/bin/env -S uv run
|
|
2
2
|
"""
|
|
3
3
|
Plexer - Normalize media files for use with Plex Media Server
|
|
4
4
|
|
|
@@ -6,7 +6,7 @@ Main App Entrypoint
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
__author__ = "magneticstain"
|
|
9
|
-
__version__ = "0.
|
|
9
|
+
__version__ = "0.1.2"
|
|
10
10
|
__license__ = "MIT"
|
|
11
11
|
|
|
12
12
|
import argparse
|
|
@@ -17,6 +17,7 @@ from logzero import logger
|
|
|
17
17
|
|
|
18
18
|
from plexer_cli.file_manager import FileManager
|
|
19
19
|
|
|
20
|
+
|
|
20
21
|
def fetch_cli_args() -> argparse.Namespace:
|
|
21
22
|
"""Parse CLI arguments passed to the application during startup"""
|
|
22
23
|
|
|
@@ -31,6 +32,19 @@ def fetch_cli_args() -> argparse.Namespace:
|
|
|
31
32
|
parser.add_argument("-s", "--source-dir", action="store", required=True)
|
|
32
33
|
parser.add_argument("-d", "--destination-dir", action="store", required=True)
|
|
33
34
|
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--prompt",
|
|
37
|
+
choices=["all", "none", "default"],
|
|
38
|
+
default="default",
|
|
39
|
+
help="Behavior to take in regards to user prompts (e.g. correcting names for overwriting files). all = prompt user for every artifact; none = never prompt - just trust the heuristics; default = prompt for incomplete matches only",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--dry-run",
|
|
44
|
+
action="store_true",
|
|
45
|
+
help="Perform a trial run with no changes made",
|
|
46
|
+
)
|
|
47
|
+
|
|
34
48
|
return parser.parse_args()
|
|
35
49
|
|
|
36
50
|
|
|
@@ -39,20 +53,29 @@ def main():
|
|
|
39
53
|
|
|
40
54
|
cli_args = fetch_cli_args()
|
|
41
55
|
|
|
42
|
-
# logzero.
|
|
43
|
-
|
|
44
|
-
|
|
56
|
+
# logzero.logfile(None)
|
|
57
|
+
if cli_args.verbose == 1:
|
|
58
|
+
logzero.loglevel(logzero.INFO)
|
|
59
|
+
elif cli_args.verbose >= 2:
|
|
60
|
+
logzero.loglevel(logzero.DEBUG)
|
|
61
|
+
else:
|
|
62
|
+
logzero.loglevel(logzero.WARNING)
|
|
45
63
|
|
|
46
64
|
logger.info("starting Plexer")
|
|
47
65
|
logger.debug("options: %s", cli_args)
|
|
66
|
+
if cli_args.dry_run:
|
|
67
|
+
logger.info("performing a dry run; NO CHANGES WILL BE MADE")
|
|
48
68
|
|
|
49
69
|
fm = FileManager(src_dir=cli_args.source_dir, dst_dir=cli_args.destination_dir)
|
|
50
70
|
|
|
51
|
-
# get and prep artifacts for processing
|
|
52
|
-
logger.debug("prepping artifacts for processing")
|
|
53
|
-
artifacts = fm.prep_artifacts(artifacts=fm.get_artifacts())
|
|
54
|
-
logger.info("%d artifact(s) found in source directory", len(artifacts))
|
|
55
|
-
|
|
56
71
|
logger.info("processing artifacts")
|
|
57
|
-
fm.
|
|
72
|
+
fm.process_artifacts(
|
|
73
|
+
artifacts=fm.get_artifacts(),
|
|
74
|
+
prompt_behavior=cli_args.prompt,
|
|
75
|
+
dry_run=cli_args.dry_run,
|
|
76
|
+
)
|
|
58
77
|
logger.info("artifact processing completed successfully")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
main()
|
plexer_cli/metadata.py
CHANGED
|
@@ -4,8 +4,11 @@ Plexer - Normalize media files for use with Plex Media Server
|
|
|
4
4
|
Module: Metadata - code for analyzing and managing video metadata
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
import
|
|
7
|
+
import re
|
|
8
8
|
from logzero import logger
|
|
9
|
+
from prompt_toolkit import PromptSession
|
|
10
|
+
|
|
11
|
+
from .const import ARTIFACT_HEURISTICS_PATTERNS
|
|
9
12
|
|
|
10
13
|
|
|
11
14
|
class Metadata:
|
|
@@ -14,28 +17,120 @@ class Metadata:
|
|
|
14
17
|
"""
|
|
15
18
|
|
|
16
19
|
name = ""
|
|
17
|
-
|
|
20
|
+
edition = ""
|
|
21
|
+
release_year = 1900
|
|
22
|
+
metadata_found = False
|
|
18
23
|
|
|
19
|
-
def __init__(
|
|
24
|
+
def __init__(
|
|
25
|
+
self, name="", edition="", release_year=1900, metadata_found=False
|
|
26
|
+
) -> None:
|
|
20
27
|
self.name = name
|
|
21
|
-
self.
|
|
28
|
+
self.edition = edition
|
|
29
|
+
if release_year >= 0:
|
|
30
|
+
self.release_year = release_year
|
|
31
|
+
self.metadata_found = metadata_found
|
|
32
|
+
|
|
33
|
+
def scrub_artifact_name(self, artifact_name: str) -> str:
|
|
34
|
+
"""
|
|
35
|
+
Remove common separators and file extensions from given artifact name.
|
|
36
|
+
|
|
37
|
+
Typically used to produce a cleaner string as part of heuristic analysis.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
logger.debug("scrubbing artifact name: %s", artifact_name)
|
|
41
|
+
|
|
42
|
+
# replace common separators with spaces and strip surrounding whitespace
|
|
43
|
+
scrubbed_name = re.sub(r"[\.\_\-\(\)\[\]]+", " ", artifact_name).strip()
|
|
44
|
+
|
|
45
|
+
logger.debug("artifact name post-scrubbing: %s", scrubbed_name)
|
|
46
|
+
|
|
47
|
+
return scrubbed_name
|
|
48
|
+
|
|
49
|
+
def prompt_user_for_metadata(self) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Prompt the user for metadata values via CLI input
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
logger.debug("prompting user for metadata input")
|
|
55
|
+
|
|
56
|
+
prompt_sess = PromptSession()
|
|
22
57
|
|
|
23
|
-
|
|
58
|
+
user_name = prompt_sess.prompt(
|
|
59
|
+
"Enter the correct name for this media: ", default=self.name
|
|
60
|
+
)
|
|
61
|
+
user_edition = prompt_sess.prompt(
|
|
62
|
+
"Enter the correct edition for this media (optional): ",
|
|
63
|
+
default=self.edition,
|
|
64
|
+
)
|
|
65
|
+
user_release_year = prompt_sess.prompt(
|
|
66
|
+
"Enter the release year for this media: ",
|
|
67
|
+
default=str(
|
|
68
|
+
self.release_year
|
|
69
|
+
), # `default` argument _must_ be a string, per docs
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
self.name = user_name
|
|
73
|
+
self.edition = user_edition
|
|
74
|
+
self.release_year = int(user_release_year)
|
|
75
|
+
self.metadata_found = True
|
|
76
|
+
|
|
77
|
+
def do_heuristic_analysis(self, file_name: str) -> bool:
|
|
24
78
|
"""
|
|
25
|
-
|
|
79
|
+
Analyze given file name and attempt to extract metadata values via heuristics
|
|
26
80
|
"""
|
|
27
81
|
|
|
28
|
-
logger.debug("
|
|
82
|
+
logger.debug("performing heuristic analysis on artifact: %s", file_name)
|
|
29
83
|
|
|
30
|
-
|
|
31
|
-
|
|
84
|
+
# perform basic heuristics
|
|
85
|
+
## NAME
|
|
86
|
+
possible_name = re.search(ARTIFACT_HEURISTICS_PATTERNS["name"], file_name)
|
|
87
|
+
if possible_name:
|
|
88
|
+
self.name = self.scrub_artifact_name(possible_name.group(1))
|
|
32
89
|
|
|
33
|
-
|
|
90
|
+
## EDITION
|
|
91
|
+
# try checking for commonly-used edition names first
|
|
92
|
+
possible_edition = re.search(
|
|
93
|
+
ARTIFACT_HEURISTICS_PATTERNS["common_editions"], file_name
|
|
94
|
+
)
|
|
95
|
+
if possible_edition and possible_edition.group(0):
|
|
96
|
+
self.edition = self.scrub_artifact_name(possible_edition.group(0))
|
|
97
|
+
else:
|
|
98
|
+
# if common edition names aren't found, try using a more generic pattern
|
|
99
|
+
possible_edition = re.search(
|
|
100
|
+
ARTIFACT_HEURISTICS_PATTERNS["edition"], file_name
|
|
101
|
+
)
|
|
34
102
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
103
|
+
if possible_edition:
|
|
104
|
+
if possible_edition.group(1):
|
|
105
|
+
self.edition = self.scrub_artifact_name(possible_edition.group(1))
|
|
106
|
+
elif possible_edition.group(2):
|
|
107
|
+
self.edition = self.scrub_artifact_name(possible_edition.group(2))
|
|
108
|
+
|
|
109
|
+
## RELEASE YEAR
|
|
110
|
+
possible_release_year = re.findall(
|
|
111
|
+
ARTIFACT_HEURISTICS_PATTERNS["release_year"], file_name
|
|
112
|
+
)
|
|
113
|
+
if possible_release_year:
|
|
114
|
+
# if multiple years are found, take the last one since it's more likely to be the correct value
|
|
115
|
+
# (e.g. "Movie.Name.1999.1080p.2020" should yield 2020 as the release year, not 1999)
|
|
116
|
+
self.release_year = int(
|
|
117
|
+
"".join(possible_release_year[-1])
|
|
118
|
+
) # Ex: [('19', '99'), ('20', '20')] -> ('20', '20') -> 2020
|
|
119
|
+
|
|
120
|
+
if possible_name and possible_release_year:
|
|
121
|
+
logger.debug(
|
|
122
|
+
"heuristic analysis results - name: %s, edition: %s, release_year: %d",
|
|
123
|
+
self.name,
|
|
124
|
+
self.edition,
|
|
125
|
+
self.release_year,
|
|
41
126
|
)
|
|
127
|
+
|
|
128
|
+
self.metadata_found = True
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
logger.debug("heuristic analysis produced partial or no results")
|
|
132
|
+
self.metadata_found = (
|
|
133
|
+
False # in case it has been overwritten from the default value
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return False
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: plexer_cli
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Normalize media files for use with Plex Media Server
|
|
5
|
-
Project-URL: Repository, https://github.com/magneticstain/plexer
|
|
6
|
-
Project-URL: Documentation, https://github.com/magneticstain/plexer
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Normalize media files and directories for use with Plex Media Server
|
|
5
|
+
Project-URL: Repository, https://github.com/magneticstain/plexer
|
|
6
|
+
Project-URL: Documentation, https://github.com/magneticstain/plexer-cli/wiki
|
|
7
7
|
Project-URL: Issues, https://github.com/magneticstain/plexer/issues
|
|
8
|
-
Project-URL: Source, https://github.com/magneticstain/plexer
|
|
9
8
|
Author-email: Josh Carlson <837837+magneticstain@users.noreply.github.com>
|
|
10
9
|
License-Expression: MIT
|
|
11
10
|
License-File: LICENSE
|
|
@@ -16,16 +15,21 @@ Classifier: Framework :: Hatch
|
|
|
16
15
|
Classifier: Framework :: Pytest
|
|
17
16
|
Classifier: Framework :: tox
|
|
18
17
|
Classifier: Intended Audience :: System Administrators
|
|
19
|
-
Classifier:
|
|
18
|
+
Classifier: Natural Language :: English
|
|
19
|
+
Classifier: Operating System :: MacOS
|
|
20
20
|
Classifier: Operating System :: Unix
|
|
21
21
|
Classifier: Programming Language :: Python
|
|
22
|
-
Classifier: Programming Language :: Python :: 3
|
|
22
|
+
Classifier: Programming Language :: Python :: 3
|
|
23
23
|
Classifier: Programming Language :: Python :: 3.10
|
|
24
24
|
Classifier: Programming Language :: Python :: 3.11
|
|
25
25
|
Classifier: Programming Language :: Python :: 3.12
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
27
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
28
|
+
Classifier: Topic :: Multimedia :: Video
|
|
26
29
|
Classifier: Topic :: Utilities
|
|
27
|
-
Requires-Python: >=3.
|
|
30
|
+
Requires-Python: >=3.10
|
|
28
31
|
Requires-Dist: logzero~=1.7.0
|
|
32
|
+
Requires-Dist: prompt-toolkit>=3.0.52
|
|
29
33
|
Requires-Dist: python-magic~=0.4.27
|
|
30
34
|
Description-Content-Type: text/markdown
|
|
31
35
|
|
|
@@ -41,8 +45,8 @@ Description-Content-Type: text/markdown
|
|
|
41
45
|

|
|
42
46
|

|
|
43
47
|
|
|
44
|
-
[](https://scorecard.dev/viewer/?uri=github.com/magneticstain/plexer)
|
|
45
|
-
[](https://scorecard.dev/viewer/?uri=github.com/magneticstain/plexer-cli)
|
|
49
|
+
[](https://www.bestpractices.dev/projects/13659)
|
|
46
50
|
|
|
47
51
|
[](https://github.com/magneticstain/plexer/actions/workflows/run_full_test_suite.yml)
|
|
48
52
|
[](https://github.com/magneticstain/plexer/actions/workflows/release.yml)
|
|
@@ -60,15 +64,13 @@ Currently, Plexer only supports organizing movies and other individual video fil
|
|
|
60
64
|
### Roadmap
|
|
61
65
|
|
|
62
66
|
- [ ] Support for TV Shows ([#28](https://github.com/magneticstain/plexer/issues/28))
|
|
63
|
-
- [
|
|
67
|
+
- [X] Docker implementations ([#29](https://github.com/magneticstain/plexer/issues/29))
|
|
64
68
|
- [ ] MacOS support ([#30](https://github.com/magneticstain/plexer/issues/30))
|
|
65
69
|
- [ ] Windows support ([#31](https://github.com/magneticstain/plexer/issues/31))
|
|
66
|
-
- [
|
|
70
|
+
- [X] Metadata inference using file/directory info (i.e. analyze the video files to "guess" what the name, year, etc is) ([#32](https://github.com/magneticstain/plexer/issues/32))
|
|
67
71
|
|
|
68
72
|
## Install
|
|
69
73
|
|
|
70
|
-
To install Plexer, there are a few options available.
|
|
71
|
-
|
|
72
74
|
### Docker
|
|
73
75
|
|
|
74
76
|
The most portable way to install and run Plexer is by using Docker.
|
|
@@ -89,7 +91,7 @@ docker run --rm -it magneticstain/plexer-cli
|
|
|
89
91
|
docker run --rm -it ghcr.io/magneticstain/plexer-cli
|
|
90
92
|
```
|
|
91
93
|
|
|
92
|
-
#### Via Local Build
|
|
94
|
+
#### Via Local Image Build
|
|
93
95
|
|
|
94
96
|
In the case that container registries are unavailable, there's also the option to build the image locally. To do that, check out the `main` branch of this repo, build the Plexer image, and run it.
|
|
95
97
|
|
|
@@ -101,21 +103,19 @@ docker run --rm -it plexer_cli
|
|
|
101
103
|
|
|
102
104
|
## Requirements
|
|
103
105
|
|
|
104
|
-
###
|
|
106
|
+
### Linux and MacOS Native Install
|
|
105
107
|
|
|
106
|
-
|
|
108
|
+
If you've installed plexer-cli natively (i.e. outside of a Docker container) on Ubuntu, Devian, Fedora, etc, or MacOS, you will also need to install additional dependencies:
|
|
107
109
|
|
|
108
|
-
|
|
110
|
+
#### Python-Magic
|
|
109
111
|
|
|
110
|
-
|
|
112
|
+
While `python-magic` is imported in the standard fashion (via pip/python requirements), it itself depends on the presence of the libmagic C library. To install this library, follow [the installation instructions provided in the `python-magic` README](https://github.com/ahupp/python-magic?tab=readme-ov-file#installation).
|
|
111
113
|
|
|
112
|
-
|
|
114
|
+
### Media Metadata
|
|
113
115
|
|
|
114
|
-
|
|
115
|
-
echo -n "Media Name: ";read MEDIA_NAME;echo -n "Release Year (YYYY): ";read RELEASE_YEAR;echo "{\"name\": \"${MEDIA_NAME}\", \"release_year\": \"${RELEASE_YEAR}\"}" > .plexer
|
|
116
|
-
```
|
|
116
|
+
The most important requirement before running plexer is to ensure that you've created a `.plexer` file in each of your target directories.
|
|
117
117
|
|
|
118
|
-
|
|
118
|
+
This is a JSON-formatted file that includes the movie metadata required by Plexer to perform its jobs.
|
|
119
119
|
|
|
120
120
|
## Usage
|
|
121
121
|
|
|
@@ -149,7 +149,17 @@ For developing with Plexer, there are several tools that are in use:
|
|
|
149
149
|
1. [Hatch](https://hatch.pypa.io/1.12/)
|
|
150
150
|
1. Analysis Tools:
|
|
151
151
|
1. [Ruff](https://docs.astral.sh/ruff/)
|
|
152
|
-
1. [Codacy](https://app.codacy.com/gh/magneticstain/plexer/dashboard)
|
|
153
152
|
1. Testing:
|
|
154
153
|
1. [Pytest](https://docs.pytest.org/en/latest/)
|
|
155
154
|
1. [Tox](https://tox.wiki/en/stable/)
|
|
155
|
+
|
|
156
|
+
### Coding Standards and Conventions
|
|
157
|
+
|
|
158
|
+
- Pre-commit hooks are utilized and managed in `.pre-commit-config.yml`
|
|
159
|
+
- For example, `ruff` is run as part of every commit
|
|
160
|
+
- These pre-commit hooks **MUST** be installed and utilized by all developers
|
|
161
|
+
|
|
162
|
+
#### Testing
|
|
163
|
+
|
|
164
|
+
- All functions **MUST** have unit tests created for them
|
|
165
|
+
- Tests are executed using **VS Code** with the `launch.json` config file in `.vscode`
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
plexer_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
plexer_cli/artifact.py,sha256=JnaWeEM9msrs_EZpfGn7Xr1TcG8A7mUG-7BXuKNwXdU,378
|
|
3
|
+
plexer_cli/const.py,sha256=fr7dfKuovYv5N7lo0sGgsDP59pmWDRdjktDWUTmLGB4,1169
|
|
4
|
+
plexer_cli/file_manager.py,sha256=-1iV6UPnmwV1JJ5gTUrSz99O-Bm4zh2ljy6n2xX9wcI,9026
|
|
5
|
+
plexer_cli/main.py,sha256=skc4OrYoSyG3ovqREjF7tIm7pQbJ3nrPjec29GpUtx0,2306
|
|
6
|
+
plexer_cli/metadata.py,sha256=xot4tLGd_eAvaZcb17EY8w7LaNo7m2CUis5YSJEbHEw,4681
|
|
7
|
+
plexer_cli-0.1.2.dist-info/METADATA,sha256=zOM_JQHmbf1OBwiy_Bh657l3uEEipPE-mZczOPCqw-k,7324
|
|
8
|
+
plexer_cli-0.1.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
9
|
+
plexer_cli-0.1.2.dist-info/entry_points.txt,sha256=0kHe-oXDezw_oNI1nB5LLLlFwMIhGN4QNv3h-mWWPHc,48
|
|
10
|
+
plexer_cli-0.1.2.dist-info/licenses/LICENSE,sha256=cs3weOTez4n04m4foiWXOU6C6Pr83ORocewlbPDaJGs,1069
|
|
11
|
+
plexer_cli-0.1.2.dist-info/RECORD,,
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
plexer_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
plexer_cli/artifact.py,sha256=JnaWeEM9msrs_EZpfGn7Xr1TcG8A7mUG-7BXuKNwXdU,378
|
|
3
|
-
plexer_cli/const.py,sha256=U49nn11PvkOcvUf5eg1_hsYs12ULg_f2VtL81hWje-w,178
|
|
4
|
-
plexer_cli/file_manager.py,sha256=nfmBnQXK8JWPp4INfLE6kVqeD6NMxFnn1YoTmywI4Dg,4153
|
|
5
|
-
plexer_cli/main.py,sha256=HEvxX4g047KB4yM-719ZK1UR6mdciFvdMo5SLwIqeJ8,1687
|
|
6
|
-
plexer_cli/metadata.py,sha256=UQ0nazq3agv7-h4NiVHNlpf2z_6rPLCBQ3TfIXKn0F8,1170
|
|
7
|
-
plexer_cli-0.0.3.post4.dist-info/METADATA,sha256=Ou4zAgwRaPp4O9Kt_PCDWRCrvBR-_kX_wF-WaEQWk6M,6806
|
|
8
|
-
plexer_cli-0.0.3.post4.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
|
|
9
|
-
plexer_cli-0.0.3.post4.dist-info/entry_points.txt,sha256=0kHe-oXDezw_oNI1nB5LLLlFwMIhGN4QNv3h-mWWPHc,48
|
|
10
|
-
plexer_cli-0.0.3.post4.dist-info/licenses/LICENSE,sha256=cs3weOTez4n04m4foiWXOU6C6Pr83ORocewlbPDaJGs,1069
|
|
11
|
-
plexer_cli-0.0.3.post4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|