twotone 1.0.0__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.
Files changed (42) hide show
  1. twotone/__init__.py +0 -0
  2. twotone/__main__.py +5 -0
  3. twotone/tools/__init__.py +0 -0
  4. twotone/tools/concatenate.py +201 -0
  5. twotone/tools/language_fixer.py +704 -0
  6. twotone/tools/melt/__init__.py +7 -0
  7. twotone/tools/melt/attachments_picker.py +19 -0
  8. twotone/tools/melt/debug_routines.py +68 -0
  9. twotone/tools/melt/duplicates_source.py +14 -0
  10. twotone/tools/melt/jellyfin.py +182 -0
  11. twotone/tools/melt/melt.py +12 -0
  12. twotone/tools/melt/melt_analyzer.py +431 -0
  13. twotone/tools/melt/melt_cache.py +205 -0
  14. twotone/tools/melt/melt_common.py +81 -0
  15. twotone/tools/melt/melt_performer.py +1086 -0
  16. twotone/tools/melt/melt_plan.py +148 -0
  17. twotone/tools/melt/melt_tool.py +234 -0
  18. twotone/tools/melt/pair_matcher.py +1718 -0
  19. twotone/tools/melt/phash_cache.py +22 -0
  20. twotone/tools/melt/static_source.py +36 -0
  21. twotone/tools/melt/streams_picker.py +306 -0
  22. twotone/tools/merge.py +401 -0
  23. twotone/tools/subtitles_fixer.py +266 -0
  24. twotone/tools/tool.py +42 -0
  25. twotone/tools/transcode.py +454 -0
  26. twotone/tools/utilities.py +162 -0
  27. twotone/tools/utils/files_utils.py +78 -0
  28. twotone/tools/utils/generic_utils.py +138 -0
  29. twotone/tools/utils/image_utils.py +32 -0
  30. twotone/tools/utils/language_utils.py +42 -0
  31. twotone/tools/utils/process_utils.py +107 -0
  32. twotone/tools/utils/requirements_utils.py +239 -0
  33. twotone/tools/utils/subtitles_utils.py +274 -0
  34. twotone/tools/utils/tmdb_cache.py +98 -0
  35. twotone/tools/utils/video_utils.py +1026 -0
  36. twotone/twotone.py +283 -0
  37. twotone-1.0.0.dist-info/METADATA +27 -0
  38. twotone-1.0.0.dist-info/RECORD +42 -0
  39. twotone-1.0.0.dist-info/WHEEL +5 -0
  40. twotone-1.0.0.dist-info/entry_points.txt +2 -0
  41. twotone-1.0.0.dist-info/licenses/LICENSE +8 -0
  42. twotone-1.0.0.dist-info/top_level.txt +1 -0
twotone/__init__.py ADDED
File without changes
twotone/__main__.py ADDED
@@ -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)