twotone 1.0.0__tar.gz

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.
Files changed (47) hide show
  1. twotone-1.0.0/LICENSE +8 -0
  2. twotone-1.0.0/PKG-INFO +27 -0
  3. twotone-1.0.0/README.md +115 -0
  4. twotone-1.0.0/pyproject.toml +37 -0
  5. twotone-1.0.0/setup.cfg +4 -0
  6. twotone-1.0.0/twotone/__init__.py +0 -0
  7. twotone-1.0.0/twotone/__main__.py +5 -0
  8. twotone-1.0.0/twotone/tools/__init__.py +0 -0
  9. twotone-1.0.0/twotone/tools/concatenate.py +201 -0
  10. twotone-1.0.0/twotone/tools/language_fixer.py +704 -0
  11. twotone-1.0.0/twotone/tools/melt/__init__.py +7 -0
  12. twotone-1.0.0/twotone/tools/melt/attachments_picker.py +19 -0
  13. twotone-1.0.0/twotone/tools/melt/debug_routines.py +68 -0
  14. twotone-1.0.0/twotone/tools/melt/duplicates_source.py +14 -0
  15. twotone-1.0.0/twotone/tools/melt/jellyfin.py +182 -0
  16. twotone-1.0.0/twotone/tools/melt/melt.py +12 -0
  17. twotone-1.0.0/twotone/tools/melt/melt_analyzer.py +431 -0
  18. twotone-1.0.0/twotone/tools/melt/melt_cache.py +205 -0
  19. twotone-1.0.0/twotone/tools/melt/melt_common.py +81 -0
  20. twotone-1.0.0/twotone/tools/melt/melt_performer.py +1086 -0
  21. twotone-1.0.0/twotone/tools/melt/melt_plan.py +148 -0
  22. twotone-1.0.0/twotone/tools/melt/melt_tool.py +234 -0
  23. twotone-1.0.0/twotone/tools/melt/pair_matcher.py +1718 -0
  24. twotone-1.0.0/twotone/tools/melt/phash_cache.py +22 -0
  25. twotone-1.0.0/twotone/tools/melt/static_source.py +36 -0
  26. twotone-1.0.0/twotone/tools/melt/streams_picker.py +306 -0
  27. twotone-1.0.0/twotone/tools/merge.py +401 -0
  28. twotone-1.0.0/twotone/tools/subtitles_fixer.py +266 -0
  29. twotone-1.0.0/twotone/tools/tool.py +42 -0
  30. twotone-1.0.0/twotone/tools/transcode.py +454 -0
  31. twotone-1.0.0/twotone/tools/utilities.py +162 -0
  32. twotone-1.0.0/twotone/tools/utils/files_utils.py +78 -0
  33. twotone-1.0.0/twotone/tools/utils/generic_utils.py +138 -0
  34. twotone-1.0.0/twotone/tools/utils/image_utils.py +32 -0
  35. twotone-1.0.0/twotone/tools/utils/language_utils.py +42 -0
  36. twotone-1.0.0/twotone/tools/utils/process_utils.py +107 -0
  37. twotone-1.0.0/twotone/tools/utils/requirements_utils.py +239 -0
  38. twotone-1.0.0/twotone/tools/utils/subtitles_utils.py +274 -0
  39. twotone-1.0.0/twotone/tools/utils/tmdb_cache.py +98 -0
  40. twotone-1.0.0/twotone/tools/utils/video_utils.py +1026 -0
  41. twotone-1.0.0/twotone/twotone.py +283 -0
  42. twotone-1.0.0/twotone.egg-info/PKG-INFO +27 -0
  43. twotone-1.0.0/twotone.egg-info/SOURCES.txt +45 -0
  44. twotone-1.0.0/twotone.egg-info/dependency_links.txt +1 -0
  45. twotone-1.0.0/twotone.egg-info/entry_points.txt +2 -0
  46. twotone-1.0.0/twotone.egg-info/requires.txt +22 -0
  47. twotone-1.0.0/twotone.egg-info/top_level.txt +1 -0
twotone-1.0.0/LICENSE ADDED
@@ -0,0 +1,8 @@
1
+
2
+ Copyright 2023 Michał Walenciak
3
+
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
5
+
6
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
7
+
8
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
twotone-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.4
2
+ Name: twotone
3
+ Version: 1.0.0
4
+ Summary: Tools for batch video file manipulations
5
+ Requires-Python: >=3.10
6
+ License-File: LICENSE
7
+ Requires-Dist: argcomplete>=3.6.3
8
+ Requires-Dist: faust_cchardet>=2.1.19
9
+ Requires-Dist: ImageHash>=4.3.2
10
+ Requires-Dist: numpy>=2.2.6
11
+ Requires-Dist: opencv_python>=4.11.0.86
12
+ Requires-Dist: overrides>=7.7.0
13
+ Requires-Dist: Pillow>=12.2.0
14
+ Requires-Dist: platformdirs>=4.9.6
15
+ Requires-Dist: py3langid>=0.3.0
16
+ Requires-Dist: pycountry>=24.6.1
17
+ Requires-Dist: pysubs2>=1.8.0
18
+ Requires-Dist: Requests>=2.33.1
19
+ Requires-Dist: scikit_learn>=1.7.2
20
+ Requires-Dist: scikit-image>=0.25.2
21
+ Requires-Dist: scipy>=1.15.3
22
+ Requires-Dist: tqdm>=4.67.1
23
+ Provides-Extra: ocr
24
+ Requires-Dist: pytesseract; extra == "ocr"
25
+ Provides-Extra: test
26
+ Requires-Dist: parameterized>=0.9.0; extra == "test"
27
+ Dynamic: license-file
@@ -0,0 +1,115 @@
1
+ ## Tools for Batch Video File Manipulations
2
+
3
+ ### Overview
4
+
5
+ **TwoTone** is a versatile tool with various subtools for batch video file manipulations.
6
+
7
+ ### Installation
8
+
9
+ ```bash
10
+ pip install -e .
11
+ ```
12
+
13
+ This installs TwoTone in editable mode: the `twotone` command appears on PATH, and any code changes take effect immediately without reinstalling.
14
+
15
+ ### Running TwoTone
16
+
17
+ ```bash
18
+ twotone <global options> <tool-name> <tool-specific options>
19
+ ```
20
+
21
+ ### Shell Autocompletion (bash)
22
+
23
+ ```bash
24
+ twotone --install-completion
25
+ ```
26
+
27
+ Open a new terminal. Tab-completion now works:
28
+
29
+ ```
30
+ twotone <TAB> → concatenate, language_fix, melt, merge, ...
31
+ twotone --<TAB> → --verbose, --quiet, --no-dry-run, ...
32
+ twotone merge --<TAB> → merge-specific arguments
33
+ ```
34
+
35
+ To remove: `twotone --uninstall-completion`
36
+
37
+ ### Getting Help
38
+
39
+ To see a list of global options, available tools, and their descriptions:
40
+
41
+ ```bash
42
+ twotone --help
43
+ ```
44
+
45
+ To get help for a specific tool:
46
+
47
+ ```bash
48
+ twotone <tool-name> --help
49
+ ```
50
+
51
+ ### Important Notes
52
+
53
+ Dry Run Mode: all tools run in a dry run mode by default (no files are being modified). Use the -r or --no-dry-run global option to perform actual operation. In live run mode most tools remove their input files after successful processing.<br/>
54
+ It is safe to stop execution with ctrl+c. All tools handle proper signal and will stop as soon as possible.<br/>
55
+ Working Directory: use -w or --working-dir to specify where temporary and working files are created. By default the directory is taken from the operating system's user data location for the application.<br/>
56
+
57
+ Data Safety: Always back up your data before using any tool, as source files may be deleted during processing.
58
+
59
+ ### Available Tools
60
+ #### Merge Video Files with Subtitles into MKV Files
61
+
62
+ The merge tool searches for movie and subtitle files and merges them into a single MKV file.
63
+
64
+ By default, subtitles are added without a language label. You can specify a language with the --language option.
65
+
66
+ For a full list of options:
67
+
68
+ ```bash
69
+ twotone merge --help
70
+ ```
71
+
72
+ #### Fix Missing Track Languages
73
+
74
+ The language_fix tool scans MKV files and fills missing language metadata for subtitle tracks by extracting them and running language detection. It can also infer audio track language from the track name (use --audio to enable; heuristic may be inaccurate).
75
+
76
+ ```bash
77
+ twotone language_fix --help
78
+ ```
79
+
80
+ #### Automatic Video transcoding
81
+
82
+ The transcode tool transcodes videos using the x265 codec.
83
+
84
+ It takes a video directory as an input and determines the optimal CRF for each video by comparing the original and encoded versions.
85
+ The script aims to achieve a quality level where SSIM ≈ 0.98 (by default).
86
+
87
+ For a full list of options:
88
+
89
+ ```bash
90
+ twotone transcode --help
91
+ ```
92
+
93
+ #### Movies concatenation
94
+
95
+ The concatenate tool looks for movie files which seem to be split into a few files (like CD1, CD2 etc) and glues them into one file.
96
+
97
+ ```bash
98
+ twotone concatenate --help
99
+ ```
100
+
101
+ #### Combine Duplicate Videos (melt)
102
+
103
+ The melt tool scans for duplicate video files and creates a single output using the best quality segments from each copy. Duplicates can be provided manually or taken from a Jellyfin server. Input files are kept intact by default.
104
+
105
+ ```bash
106
+ twotone melt --help
107
+ ```
108
+
109
+ #### Miscellaneous utilities
110
+
111
+ The utilities tool groups smaller helpers. Currently it provides the *scenes* subtool for extracting frames from a video and saving them into per-scene folders.
112
+
113
+ ```bash
114
+ twotone utilities scenes --help
115
+ ```
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "twotone"
7
+ version = "1.0.0"
8
+ description = "Tools for batch video file manipulations"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "argcomplete>=3.6.3",
12
+ "faust_cchardet>=2.1.19",
13
+ "ImageHash>=4.3.2",
14
+ "numpy>=2.2.6",
15
+ "opencv_python>=4.11.0.86",
16
+ "overrides>=7.7.0",
17
+ "Pillow>=12.2.0",
18
+ "platformdirs>=4.9.6",
19
+ "py3langid>=0.3.0",
20
+ "pycountry>=24.6.1",
21
+ "pysubs2>=1.8.0",
22
+ "Requests>=2.33.1",
23
+ "scikit_learn>=1.7.2",
24
+ "scikit-image>=0.25.2",
25
+ "scipy>=1.15.3",
26
+ "tqdm>=4.67.1",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ ocr = ["pytesseract"]
31
+ test = ["parameterized>=0.9.0"]
32
+
33
+ [project.scripts]
34
+ twotone = "twotone.twotone:main"
35
+
36
+ [tool.setuptools.packages.find]
37
+ include = ["twotone*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,5 @@
1
+
2
+ from .twotone import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
File without changes
@@ -0,0 +1,201 @@
1
+
2
+ import argparse
3
+ import logging
4
+ import os
5
+ import re
6
+ from dataclasses import dataclass
7
+ from collections import defaultdict
8
+ from overrides import override
9
+ from tqdm import tqdm
10
+
11
+ from .tool import EmptyPlan, Plan, Tool
12
+ from twotone.tools.utils import generic_utils, process_utils, video_utils, files_utils
13
+
14
+
15
+ class Concatenate(generic_utils.InterruptibleProcess):
16
+ def __init__(self, logger: logging.Logger, working_dir: str):
17
+ super().__init__()
18
+
19
+ self.logger = logger
20
+ self.working_dir = working_dir
21
+
22
+ def analyze(self, path: str, ignore_warnings: bool = False) -> dict[str, list[tuple[str, int]]] | None:
23
+ self.logger.info(f"Collecting video files from path {path}")
24
+ video_files = video_utils.collect_video_files(path, self)
25
+
26
+ self.logger.info("Finding splitted videos")
27
+ parts_regex = re.compile("(.*[^0-9a-z]+)(cd\\d+)([^0-9a-z]+.*)", re.IGNORECASE)
28
+
29
+ splitted = []
30
+ for video_file in video_files:
31
+ if parts_regex.match(video_file):
32
+ splitted.append(video_file)
33
+ else:
34
+ self.logger.debug(f"File {video_file} does not match pattern")
35
+
36
+ self.logger.info("Matching videos")
37
+ matched_videos: dict[str, list[tuple[str, int]]] = defaultdict(list)
38
+ for video in splitted:
39
+ match = parts_regex.search(video)
40
+ if match is None:
41
+ continue
42
+
43
+ path = match.group(1)
44
+ if path[-1] == os.sep:
45
+ # movie path is like: /dir/movie/cd1.mp4
46
+ # repeat last dir name as a base for output video file
47
+ last_dir_name = os.path.basename(os.path.normpath(path))
48
+ name_without_part_number = os.path.join(path, last_dir_name) + match.group(3)
49
+ else:
50
+ # movie path is like: /dir/movie/movie cd1.mp4.
51
+ # remove last char before CDXXX as it is most likely space or hyphen and use it as a base for output video file
52
+ name_without_part_number = path[:-1] + match.group(3)
53
+
54
+ # ffmpeg does not support rmvb container, use mp4
55
+ dir, name, extn = files_utils.split_path(name_without_part_number)
56
+ if extn.lower() == "rmvb":
57
+ name_without_part_number = os.path.join(dir, name + ".mkv")
58
+
59
+ part = match.group(2)
60
+ partNo = int(part[2:]) # drop 'CD'
61
+ matched_videos[name_without_part_number].append((video, partNo))
62
+
63
+ self.logger.info("Processing groups")
64
+ warnings = False
65
+ sorted_videos: dict[str, list[tuple[str, int]]] = {}
66
+ valid_videos: dict[str, list[tuple[str, int]]] = {}
67
+ for common_name, details in matched_videos.items():
68
+
69
+ # sort parts by part number [1]
70
+ details = sorted(details, key = lambda detail: detail[1])
71
+ sorted_videos[common_name] = details
72
+ group_has_warning = False
73
+
74
+ # collect all part numbers
75
+ parts = []
76
+ for _, partNo in details:
77
+ parts.append(partNo)
78
+
79
+ if len(parts) < 2:
80
+ self.logger.warning(f"There are less than two parts for video represented under a common name: {common_name}")
81
+ warnings = True
82
+ group_has_warning = True
83
+
84
+ # expect parts to be numbered from 1 to N
85
+ for i, value in enumerate(parts):
86
+ if i + 1 != value:
87
+ self.logger.warning(f"There is a mismatch in CD numbers for a group of files represented under a common name: {common_name}")
88
+ warnings = True
89
+ group_has_warning = True
90
+
91
+ if group_has_warning and ignore_warnings:
92
+ continue
93
+ valid_videos[common_name] = details
94
+
95
+ if warnings and not ignore_warnings:
96
+ self.logger.error("Fix above warnings and try again")
97
+ return None
98
+
99
+ if ignore_warnings:
100
+ return valid_videos
101
+ return sorted_videos
102
+
103
+ def perform(self, sorted_videos: dict[str, list[tuple[str, int]]]) -> None:
104
+ self.logger.info("Starting concatenation")
105
+ for output, details in tqdm(sorted_videos.items(), desc="Concatenating", unit="movie", **generic_utils.get_tqdm_defaults()):
106
+ self.check_for_stop()
107
+
108
+ input_files = [video for video, _ in details]
109
+
110
+ audio_codec = "copy"
111
+ for input_file in input_files:
112
+ file_details = video_utils.get_video_data(input_file)
113
+ audio_streams = file_details.get("audio", [])
114
+ for audio_stream in audio_streams:
115
+ codec = audio_stream.get("codec")
116
+ if codec and codec.lower() == "cook":
117
+ audio_codec = "aac"
118
+ break
119
+
120
+ def escape_path(path: str) -> str:
121
+ return path.replace("'", "'\\''")
122
+
123
+ input_file_content = [f"file '{escape_path(input_file)}'" for input_file in input_files]
124
+ with files_utils.TempFileManager("\n".join(input_file_content), "txt", directory=self.working_dir) as input_file:
125
+ ffmpeg_args = ["-f", "concat", "-safe", "0", "-i", input_file, "-c:v", "copy", "-c:a", audio_codec, output]
126
+
127
+ self.logger.info(f"Concatenating files into {output} file")
128
+ status = process_utils.start_process("ffmpeg", ffmpeg_args)
129
+ if status.returncode == 0:
130
+ for input_file in input_files:
131
+ os.remove(input_file)
132
+ else:
133
+ self.logger.error(f"Problems with concatenation, skipping file {output}")
134
+ self.logger.debug(status.stdout)
135
+ self.logger.debug(status.stderr)
136
+ if os.path.exists(output):
137
+ os.remove(output)
138
+
139
+
140
+ class ConcatenateTool(Tool):
141
+ def __init__(self) -> None:
142
+ super().__init__()
143
+
144
+ @override
145
+ def setup_parser(self, parser: argparse.ArgumentParser):
146
+ parser.description = (
147
+ "Concatenate is a tool for concatenating video files splitted into many files into one.\n"
148
+ "For example if you have movie consisting of two files: movie-cd1.avi and movie-cd2.avi\n"
149
+ "then 'concatenate' tool will glue them into one file 'movie.avi'.\n"
150
+ "If your files come with subtitle files, you may want to use 'merge' tool first\n"
151
+ "to merge video files with corresponding subtitle files.\n"
152
+ "Otherwise you will end up with one video file and two subtitle files for cd1 and cd2 which will be useless now"
153
+ )
154
+ parser.add_argument('videos_path',
155
+ nargs=1,
156
+ help='Path with videos to concatenate.')
157
+ parser.add_argument('--ignore-warnings',
158
+ action='store_true',
159
+ help='Skip videos with warnings and continue with valid groups.')
160
+
161
+ @override
162
+ def analyze(self, args, logger: logging.Logger, working_dir: str) -> Plan:
163
+ concatenator = Concatenate(logger, working_dir=working_dir)
164
+ analysis = concatenator.analyze(args.videos_path[0], ignore_warnings=args.ignore_warnings)
165
+ if analysis is None:
166
+ return EmptyPlan()
167
+ return ConcatenatePlan(items=analysis)
168
+
169
+ @override
170
+ def perform(self, args, logger: logging.Logger, working_dir: str, plan: Plan) -> None:
171
+ if not isinstance(plan, ConcatenatePlan):
172
+ raise TypeError(f"Expected ConcatenatePlan, got {type(plan).__name__}")
173
+
174
+ concatenator = Concatenate(logger, working_dir)
175
+ concatenator.perform(plan.items)
176
+
177
+
178
+ @dataclass
179
+ class ConcatenatePlan:
180
+ items: dict[str, list[tuple[str, int]]]
181
+
182
+ def is_empty(self) -> bool:
183
+ return not self.items
184
+
185
+ def render(self, logger: logging.Logger) -> None:
186
+ if not self.items:
187
+ logger.info("No videos to concatenate.")
188
+ return
189
+
190
+ logger.info("Planned concatenations: %d", len(self.items))
191
+ for output, details in self.items.items():
192
+ paths = [path for path, _ in details]
193
+ common_path = os.path.commonpath(paths) if paths else ""
194
+ if common_path:
195
+ logger.info("Files from %s:", common_path)
196
+ for path, _ in details:
197
+ logger.info(" %s", os.path.relpath(path, common_path))
198
+ else:
199
+ for path, part in details:
200
+ logger.info(" part %d: %s", part, path)
201
+ logger.info(" -> %s", output)