Sat, 25 Nov 2017 13:08:41 +0100
ffmpeg concatenate multiple input files .ts .ts.NNN
7 | 1 | #!/usr/bin/env python |
10 | 2 | """ |
3 | DVB-TS to MKV kung-fu | |
4 | 2017 by mdd | |
5 | ||
6 | Toolkit / executable to automagically convert DVB recordings to h264 mkv. | |
7 | Automatic audio stream selection (deu/eng) | |
8 | Automatic crop detection to remove cinematic bars | |
9 | """ | |
7 | 10 | |
11 | import subprocess | |
9 | 12 | from eit import readeit, eitinfo |
7 | 13 | import os, shlex |
14 | ||
15 | def filter_lines(data, search): | |
10 | 16 | """ |
17 | input: data = \n separated string | |
18 | output: tuple containing all lines where search is found | |
19 | """ | |
7 | 20 | ret = [] |
21 | for line in data.split("\n"): | |
22 | if line.find(search) == -1: | |
23 | continue | |
24 | ret.append(line) | |
25 | return "\n".join(ret) | |
26 | ||
27 | def run_command(command): | |
10 | 28 | """ |
29 | run command as blocking subprocess, returns exit code | |
30 | """ | |
31 | process = subprocess.Popen(shlex.split(command), \ | |
32 | stdout=subprocess.PIPE) | |
7 | 33 | while True: |
34 | output = process.stdout.readline() | |
35 | if output == '' and process.poll() is not None: | |
36 | break | |
37 | if output: | |
38 | print output.strip() | |
39 | rc = process.poll() | |
40 | return rc | |
41 | ||
42 | class ts2mkv(object): | |
10 | 43 | """ |
44 | Main worker class, contains all the magic & ffmpeg voodoo | |
45 | """ | |
9 | 46 | def __init__(self, crf=19, tune='film', scaleto_720p=True, rename=False): |
7 | 47 | self.msg_prepare = "" |
48 | self.msg_eit = "" | |
49 | self.msg_ffmpeg = "" | |
50 | self.command = None | |
51 | ||
9 | 52 | self.scaleto_720p = scaleto_720p |
53 | self.rename = rename | |
8 | 54 | |
7 | 55 | self.video_options = [ |
56 | "-c:v libx264", | |
8 | 57 | "-preset faster", # slow |
9 | 58 | "-tune %s" % tune, # film / animation |
10 | 59 | "-crf %i" % crf, # 21, better 19 |
60 | ] | |
7 | 61 | self.audio_options = [ |
62 | "-c:a copy", | |
10 | 63 | ] |
64 | ||
65 | self.filename = None | |
66 | self.outfilebase = None | |
7 | 67 | |
68 | def get_stream_index(self, data): | |
10 | 69 | """ |
70 | input: ffmpeg stream info string | |
71 | output: ffmpeg stream mapping part | |
72 | """ | |
7 | 73 | idx = data.find("Stream #") |
74 | if idx == -1: | |
75 | return "" | |
76 | idx += 8 | |
77 | self.msg_prepare += "Selecting: %s\n" % data | |
78 | return data[idx:idx+3] | |
79 | ||
9 | 80 | def get_movie_description(self): |
10 | 81 | """ |
82 | looks for eit file with same basename of current filename | |
83 | parse the eit file for txt infofile and optional build new | |
84 | output filename base with movie name and genre | |
85 | ||
86 | output: nothing, manipulates internal variables | |
87 | """ | |
9 | 88 | if not self.filename: |
89 | return | |
7 | 90 | # read the EIT file |
9 | 91 | filename = os.path.splitext(self.filename)[0] + ".eit" |
92 | self.msg_eit = readeit(filename) | |
93 | if not self.rename or not self.msg_eit: | |
94 | return | |
95 | info = eitinfo(filename) | |
96 | name = info.eit.get("name") | |
97 | if name == "": | |
98 | # cancel rename, no movie title found! | |
99 | return | |
100 | genre = info.eit.get("genre") | |
101 | if genre != "": | |
102 | name = "%s (%s)" % (name, genre) | |
103 | # build new filename | |
104 | name = name.replace(' : ', ' - ') | |
105 | name = name.replace(': ', ' - ') | |
106 | name = name.replace(':', '-') | |
107 | name = name.replace('/', '') | |
108 | name = name.replace('\\', '') | |
109 | name = name.replace('?', '') | |
110 | name = name.replace('*', '') | |
111 | name = name.replace('\"', '\'') | |
112 | ||
113 | self.outfilebase = os.path.join( | |
114 | os.path.dirname(filename), | |
115 | name | |
116 | ) | |
117 | ||
7 | 118 | |
8 | 119 | def get_crop_option(self): |
10 | 120 | """ |
121 | parse the ffmpeg analyze output cropdetect lines | |
122 | returns None or valid crop string for ffmpeg video filter | |
123 | """ | |
8 | 124 | lines = filter_lines(self.msg_ffmpeg, "[Parsed_cropdetect").split("\n") |
125 | option = None | |
126 | for line in lines: | |
127 | tmp = line[line.find(" crop="):].strip() | |
128 | #print "DEBUG: " + tmp | |
129 | if not option: | |
130 | option = tmp | |
131 | else: | |
132 | if option != tmp: | |
133 | self.msg_prepare += "WARNING: cropdetect inconsistent over scan time, disabling autocrop\n" | |
134 | return None | |
135 | self.msg_prepare += "Crop detected: %s\n" % option | |
136 | return option | |
137 | ||
9 | 138 | def get_ffmpeg_command(self): |
10 | 139 | """ |
140 | Too complex to describe, this does all the magic | |
141 | output: produces internal ffmpeg command list (empty command list on error) | |
142 | """ | |
9 | 143 | if not self.filename: |
144 | return None | |
145 | ||
11 | 146 | |
7 | 147 | commands = [] |
9 | 148 | fn = "\\'".join(p for p in self.filename.split("'")) |
149 | fn = fn.replace(" ", "\\ ") | |
150 | outfn = self.outfilebase + ".mkv" | |
11 | 151 | # double-check: pull the kill switch and exit if outfile exists already! |
152 | # we do not want to overwrite files in accident (caused by automatic file naming) | |
153 | if len(glob.glob(outfn)) > 0: | |
154 | print "Output file exists: %s" % outfn | |
155 | print "NOT overwriting it!" | |
156 | return None | |
9 | 157 | outfn = "\\'".join(p for p in outfn.split("'")) |
158 | outfn = outfn.replace(" ", "\\ ") | |
7 | 159 | |
8 | 160 | cmd = ["ffmpeg", |
161 | "-ss 00:05:00", "-t 1", # search to 5 minutes, analyze 1 second | |
162 | "-i %s" % fn, | |
163 | "-vf \"cropdetect=24:2:0\"", # detect black bar crop on top and bottom | |
164 | "-f null", "-" # no output file | |
165 | ] | |
166 | print " ".join(cmd) | |
10 | 167 | p = subprocess.Popen(shlex.split(" ".join(cmd)), \ |
168 | stdout=subprocess.PIPE, stderr=subprocess.PIPE) | |
7 | 169 | out, err = p.communicate() |
8 | 170 | print "Command return code: ", p.poll() |
7 | 171 | self.msg_ffmpeg = out + "\n" + err |
172 | self.msg_ffmpeg = self.msg_ffmpeg[self.msg_ffmpeg.find("Input #0"):] | |
8 | 173 | |
7 | 174 | # find "Stream #0:" lines |
175 | info = filter_lines(self.msg_ffmpeg, "Stream #0:") | |
176 | ||
177 | ||
178 | # CRAP! ffmpeg cannot decode dvb_teletext streams to srt :( | |
179 | # are there any subtitle streams?! | |
180 | #s = filter_lines(info, "Subtitle:") | |
181 | #s = filter_lines(s, "(deu):") | |
182 | #if s != "": | |
183 | # s = self.get_stream_index(s.split("\n")[0]) | |
184 | # commands.append([ | |
185 | # "ffmpeg", | |
186 | # "-txt_format text", | |
187 | # "-i %s" % fn, | |
188 | # "-map %s" % s, | |
189 | # "%s.srt" % os.path.splitext(fn)[0] | |
190 | # ]) | |
191 | ||
192 | v = self.get_stream_index( | |
193 | filter_lines(info, "Video:")) | |
194 | if v == "": | |
195 | print "No video stream found" | |
196 | return None | |
197 | ||
9 | 198 | audiomap = [] |
7 | 199 | audioall = filter_lines(info, "Audio:") |
200 | audio = filter_lines(audioall, "(deu):") | |
201 | a = self.get_stream_index( | |
202 | filter_lines(audio, "ac3")) | |
203 | # TODO: wenn kein ac3 stream dann dts oder mpeg fallback | |
204 | if a == "": | |
9 | 205 | print audioall |
206 | print "No AC3 german audio stream found" | |
207 | # try to find the first german audio stream | |
208 | a = self.get_stream_index(audio.split("\n")[0]) | |
209 | if a == "": | |
210 | print "No other german audio streams, trying english ac3..." | |
211 | else: | |
212 | print "Selecting first german stream." | |
213 | audiomap.append(a) | |
214 | else: | |
215 | audiomap.append(a) | |
7 | 216 | |
217 | audio = filter_lines(audioall, "(eng):") | |
218 | a = self.get_stream_index( | |
219 | filter_lines(audio, "ac3")) | |
220 | if a != "": | |
221 | # append english audio too! | |
9 | 222 | print "Selecting english ac3 stream." |
7 | 223 | audiomap.append(a) |
224 | ||
9 | 225 | if len(audiomap) == 0: |
226 | print "No suitable audio stream found, aborting." | |
227 | return None | |
7 | 228 | |
8 | 229 | |
7 | 230 | self.msg_prepare += "Video Stream selected: Stream #%s\n" % v |
11 | 231 | |
232 | # TODO: Old dreambox images did a file split: .ts .ts.001 .ts.002 etc. | |
233 | # Find all these files and join them! | |
12 | 234 | inputs = [fn] |
235 | if os.path.splitext(fn)[1].lower() == '.ts': | |
236 | for fpart in glob.glob(self.filename + '.' + ('[0-9]' * 3)): | |
237 | fn = "\\'".join(p for p in fpart.split("'")) | |
238 | fn = fn.replace(" ", "\\ ") | |
239 | inputs.append(fn) | |
240 | #inputs.append(shlex.split(fpart)) | |
241 | ||
242 | if len(inputs) > 1: | |
243 | # use ffmpeg input concat function | |
244 | # attention, ffmpeg doesnt like escape sequences | |
245 | fn = "\"concat:" + \ | |
246 | "|".join(inputs)\ | |
247 | .replace('\ ', ' ')\ | |
248 | .replace("\'", "'")\ | |
249 | + "\"" | |
250 | ||
251 | idx = 0 | |
252 | for tmp in inputs: | |
253 | self.msg_prepare += "Input file #%i: %s\n" % ( | |
254 | idx, os.path.basename(tmp)) | |
255 | idx += 1 | |
11 | 256 | |
7 | 257 | cmd = [ |
258 | "ffmpeg", | |
259 | "-i %s" % fn, | |
260 | "-map %s" % v, | |
261 | ] | |
8 | 262 | flt = [] |
263 | crop = self.get_crop_option() | |
264 | if crop: | |
265 | flt.append(crop) | |
266 | if self.scaleto_720p: | |
10 | 267 | # -2 ensures division by two for codec |
268 | flt.append("scale='min(1280,iw)':-2'") | |
8 | 269 | self.msg_prepare += "Scaling cropped output stream to 720p\n" |
270 | if len(flt) > 0: | |
271 | # append video filters | |
272 | cmd.append('-filter:v "%s"' % ",".join(flt)) | |
7 | 273 | for a in audiomap: |
274 | self.msg_prepare += "Audio Stream selected: Stream #%s\n" % a | |
275 | cmd.append("-map %s" % a) | |
276 | cmd.extend(self.video_options) | |
277 | cmd.extend(self.audio_options) | |
9 | 278 | cmd.append(outfn) |
7 | 279 | |
280 | commands.append(" ".join(cmd)) | |
281 | return commands | |
282 | ||
283 | def load(self, filename): | |
10 | 284 | """ |
285 | First step: setup, analyze & prepare for conversion | |
286 | """ | |
7 | 287 | self.filename = filename |
9 | 288 | self.outfilebase = os.path.splitext(filename)[0] |
289 | self.get_movie_description() | |
290 | self.command = self.get_ffmpeg_command() | |
7 | 291 | |
9 | 292 | def convert(self): |
10 | 293 | """ |
294 | Second step: write info text file and start ffmpeg conversion | |
295 | requires successful load as first step | |
296 | returns ffmpeg conversion exit status | |
297 | """ | |
9 | 298 | if not self.command: |
299 | return None | |
300 | fd = open(self.outfilebase + ".txt", "wb") | |
301 | fd.write(self.msg_eit) | |
7 | 302 | fd.write("\n\n# ---DEBUG---\n\n") |
9 | 303 | fd.write(self.msg_prepare) |
304 | fd.write(self.msg_ffmpeg) | |
7 | 305 | fd.close() |
9 | 306 | #print self.msg_ffmpeg |
7 | 307 | |
10 | 308 | for cmd in self.command: |
8 | 309 | print "Executing ffmpeg:\n%s\n" % cmd |
9 | 310 | return run_command(cmd) |
311 | ||
312 | ||
313 | ||
314 | if __name__ == "__main__": | |
315 | # parse command line options | |
10 | 316 | import argparse, glob |
9 | 317 | |
10 | 318 | parser = argparse.ArgumentParser(description='DVB-TS to MKV kung-fu') |
319 | parser.add_argument('--crf', type=int, default=19, \ | |
9 | 320 | help='h264 crf (default 19)') |
10 | 321 | parser.add_argument('--tune', default='film', \ |
9 | 322 | help='ffmpeg tune preset [film, animation] (default is film)') |
10 | 323 | parser.add_argument('--ns', action='store_true', default=False, \ |
9 | 324 | help='no rescaling (default is scale to 720p)') |
10 | 325 | parser.add_argument('--rename', action='store_true', default=False, \ |
9 | 326 | help='rename file basename to name and genre from EIT file if present') |
10 | 327 | parser.add_argument('input', metavar='input', nargs='+', \ |
9 | 328 | help='one or more files, glob style syntax') |
329 | ||
330 | args = parser.parse_args() | |
10 | 331 | processor = ts2mkv(crf=args.crf, tune=args.tune, scaleto_720p=(not args.ns), \ |
332 | rename=args.rename) | |
9 | 333 | |
334 | for srcstr in args.input: | |
335 | src = glob.glob(srcstr) | |
10 | 336 | for srcfile in src: |
337 | print "Processing: %s" % srcfile | |
338 | processor.load(srcfile) | |
339 | processor.convert() | |
9 | 340 | |
341 |