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.
- twotone/__init__.py +0 -0
- twotone/__main__.py +5 -0
- twotone/tools/__init__.py +0 -0
- twotone/tools/concatenate.py +201 -0
- twotone/tools/language_fixer.py +704 -0
- twotone/tools/melt/__init__.py +7 -0
- twotone/tools/melt/attachments_picker.py +19 -0
- twotone/tools/melt/debug_routines.py +68 -0
- twotone/tools/melt/duplicates_source.py +14 -0
- twotone/tools/melt/jellyfin.py +182 -0
- twotone/tools/melt/melt.py +12 -0
- twotone/tools/melt/melt_analyzer.py +431 -0
- twotone/tools/melt/melt_cache.py +205 -0
- twotone/tools/melt/melt_common.py +81 -0
- twotone/tools/melt/melt_performer.py +1086 -0
- twotone/tools/melt/melt_plan.py +148 -0
- twotone/tools/melt/melt_tool.py +234 -0
- twotone/tools/melt/pair_matcher.py +1718 -0
- twotone/tools/melt/phash_cache.py +22 -0
- twotone/tools/melt/static_source.py +36 -0
- twotone/tools/melt/streams_picker.py +306 -0
- twotone/tools/merge.py +401 -0
- twotone/tools/subtitles_fixer.py +266 -0
- twotone/tools/tool.py +42 -0
- twotone/tools/transcode.py +454 -0
- twotone/tools/utilities.py +162 -0
- twotone/tools/utils/files_utils.py +78 -0
- twotone/tools/utils/generic_utils.py +138 -0
- twotone/tools/utils/image_utils.py +32 -0
- twotone/tools/utils/language_utils.py +42 -0
- twotone/tools/utils/process_utils.py +107 -0
- twotone/tools/utils/requirements_utils.py +239 -0
- twotone/tools/utils/subtitles_utils.py +274 -0
- twotone/tools/utils/tmdb_cache.py +98 -0
- twotone/tools/utils/video_utils.py +1026 -0
- twotone/twotone.py +283 -0
- twotone-1.0.0.dist-info/METADATA +27 -0
- twotone-1.0.0.dist-info/RECORD +42 -0
- twotone-1.0.0.dist-info/WHEEL +5 -0
- twotone-1.0.0.dist-info/entry_points.txt +2 -0
- twotone-1.0.0.dist-info/licenses/LICENSE +8 -0
- twotone-1.0.0.dist-info/top_level.txt +1 -0
twotone/__init__.py
ADDED
|
File without changes
|
twotone/__main__.py
ADDED
|
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)
|