1 #!/usr/bin/env python |
1 #!/usr/bin/env python |
|
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 """ |
2 |
10 |
3 import subprocess |
11 import subprocess |
4 from eit import readeit, eitinfo |
12 from eit import readeit, eitinfo |
5 import os, shlex |
13 import os, shlex |
6 |
14 |
7 def filter_lines(data, search): |
15 def filter_lines(data, search): |
|
16 """ |
|
17 input: data = \n separated string |
|
18 output: tuple containing all lines where search is found |
|
19 """ |
8 ret = [] |
20 ret = [] |
9 for line in data.split("\n"): |
21 for line in data.split("\n"): |
10 if line.find(search) == -1: |
22 if line.find(search) == -1: |
11 continue |
23 continue |
12 ret.append(line) |
24 ret.append(line) |
13 return "\n".join(ret) |
25 return "\n".join(ret) |
14 |
26 |
15 def run_command(command): |
27 def run_command(command): |
16 process = subprocess.Popen(shlex.split(command), stdout=subprocess.PIPE) |
28 """ |
|
29 run command as blocking subprocess, returns exit code |
|
30 """ |
|
31 process = subprocess.Popen(shlex.split(command), \ |
|
32 stdout=subprocess.PIPE) |
17 while True: |
33 while True: |
18 output = process.stdout.readline() |
34 output = process.stdout.readline() |
19 if output == '' and process.poll() is not None: |
35 if output == '' and process.poll() is not None: |
20 break |
36 break |
21 if output: |
37 if output: |
22 print output.strip() |
38 print output.strip() |
23 rc = process.poll() |
39 rc = process.poll() |
24 return rc |
40 return rc |
25 |
41 |
26 class ts2mkv(object): |
42 class ts2mkv(object): |
|
43 """ |
|
44 Main worker class, contains all the magic & ffmpeg voodoo |
|
45 """ |
27 def __init__(self, crf=19, tune='film', scaleto_720p=True, rename=False): |
46 def __init__(self, crf=19, tune='film', scaleto_720p=True, rename=False): |
28 self.msg_prepare = "" |
47 self.msg_prepare = "" |
29 self.msg_eit = "" |
48 self.msg_eit = "" |
30 self.msg_ffmpeg = "" |
49 self.msg_ffmpeg = "" |
31 self.command = None |
50 self.command = None |
35 |
54 |
36 self.video_options = [ |
55 self.video_options = [ |
37 "-c:v libx264", |
56 "-c:v libx264", |
38 "-preset faster", # slow |
57 "-preset faster", # slow |
39 "-tune %s" % tune, # film / animation |
58 "-tune %s" % tune, # film / animation |
40 "-crf %i" % crf # 21, better 19 |
59 "-crf %i" % crf, # 21, better 19 |
41 ] |
60 ] |
42 self.audio_options = [ |
61 self.audio_options = [ |
43 "-c:a copy", |
62 "-c:a copy", |
44 ] |
63 ] |
|
64 |
|
65 self.filename = None |
|
66 self.outfilebase = None |
45 |
67 |
46 def get_stream_index(self, data): |
68 def get_stream_index(self, data): |
|
69 """ |
|
70 input: ffmpeg stream info string |
|
71 output: ffmpeg stream mapping part |
|
72 """ |
47 idx = data.find("Stream #") |
73 idx = data.find("Stream #") |
48 if idx == -1: |
74 if idx == -1: |
49 return "" |
75 return "" |
50 idx += 8 |
76 idx += 8 |
51 self.msg_prepare += "Selecting: %s\n" % data |
77 self.msg_prepare += "Selecting: %s\n" % data |
52 return data[idx:idx+3] |
78 return data[idx:idx+3] |
53 |
79 |
54 def get_movie_description(self): |
80 def get_movie_description(self): |
|
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 """ |
55 if not self.filename: |
88 if not self.filename: |
56 return |
89 return |
57 # read the EIT file |
90 # read the EIT file |
58 # TODO: fallback to meta file if no EIT |
|
59 # TODO: is there a way to get the imdb for the movie automagically? |
|
60 # http://www.omdbapi.com/apikey.aspx |
|
61 filename = os.path.splitext(self.filename)[0] + ".eit" |
91 filename = os.path.splitext(self.filename)[0] + ".eit" |
62 self.msg_eit = readeit(filename) |
92 self.msg_eit = readeit(filename) |
63 if not self.rename or not self.msg_eit: |
93 if not self.rename or not self.msg_eit: |
64 return |
94 return |
65 info = eitinfo(filename) |
95 info = eitinfo(filename) |
100 return None |
134 return None |
101 self.msg_prepare += "Crop detected: %s\n" % option |
135 self.msg_prepare += "Crop detected: %s\n" % option |
102 return option |
136 return option |
103 |
137 |
104 def get_ffmpeg_command(self): |
138 def get_ffmpeg_command(self): |
|
139 """ |
|
140 Too complex to describe, this does all the magic |
|
141 output: produces internal ffmpeg command list (empty command list on error) |
|
142 """ |
105 if not self.filename: |
143 if not self.filename: |
106 return None |
144 return None |
107 |
145 |
108 commands = [] |
146 commands = [] |
109 fn = "\\'".join(p for p in self.filename.split("'")) |
147 fn = "\\'".join(p for p in self.filename.split("'")) |
110 fn = fn.replace(" ", "\\ ") |
148 fn = fn.replace(" ", "\\ ") |
111 outfn = self.outfilebase + ".mkv" |
149 outfn = self.outfilebase + ".mkv" |
112 outfn = "\\'".join(p for p in outfn.split("'")) |
150 outfn = "\\'".join(p for p in outfn.split("'")) |
113 outfn = outfn.replace(" ", "\\ ") |
151 outfn = outfn.replace(" ", "\\ ") |
114 |
|
115 # ffmpeg -ss 00:05:00 -t 2 -i testfiles/chappie.ts -vf "cropdetect=24:16:0" -f null - |
|
116 |
152 |
117 cmd = ["ffmpeg", |
153 cmd = ["ffmpeg", |
118 "-ss 00:05:00", "-t 1", # search to 5 minutes, analyze 1 second |
154 "-ss 00:05:00", "-t 1", # search to 5 minutes, analyze 1 second |
119 "-i %s" % fn, |
155 "-i %s" % fn, |
120 "-vf \"cropdetect=24:2:0\"", # detect black bar crop on top and bottom |
156 "-vf \"cropdetect=24:2:0\"", # detect black bar crop on top and bottom |
121 "-f null", "-" # no output file |
157 "-f null", "-" # no output file |
122 ] |
158 ] |
123 print " ".join(cmd) |
159 print " ".join(cmd) |
124 p = subprocess.Popen(shlex.split(" ".join(cmd)), stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
160 p = subprocess.Popen(shlex.split(" ".join(cmd)), \ |
|
161 stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
125 out, err = p.communicate() |
162 out, err = p.communicate() |
126 print "Command return code: ", p.poll() |
163 print "Command return code: ", p.poll() |
127 self.msg_ffmpeg = out + "\n" + err |
164 self.msg_ffmpeg = out + "\n" + err |
128 self.msg_ffmpeg = self.msg_ffmpeg[self.msg_ffmpeg.find("Input #0"):] |
165 self.msg_ffmpeg = self.msg_ffmpeg[self.msg_ffmpeg.find("Input #0"):] |
129 |
166 |
208 |
246 |
209 commands.append(" ".join(cmd)) |
247 commands.append(" ".join(cmd)) |
210 return commands |
248 return commands |
211 |
249 |
212 def load(self, filename): |
250 def load(self, filename): |
|
251 """ |
|
252 First step: setup, analyze & prepare for conversion |
|
253 """ |
213 self.filename = filename |
254 self.filename = filename |
214 self.outfilebase = os.path.splitext(filename)[0] |
255 self.outfilebase = os.path.splitext(filename)[0] |
215 self.get_movie_description() |
256 self.get_movie_description() |
216 self.command = self.get_ffmpeg_command() |
257 self.command = self.get_ffmpeg_command() |
217 |
258 |
218 def convert(self): |
259 def convert(self): |
|
260 """ |
|
261 Second step: write info text file and start ffmpeg conversion |
|
262 requires successful load as first step |
|
263 returns ffmpeg conversion exit status |
|
264 """ |
219 if not self.command: |
265 if not self.command: |
220 return None |
266 return None |
221 fd = open(self.outfilebase + ".txt", "wb") |
267 fd = open(self.outfilebase + ".txt", "wb") |
222 fd.write(self.msg_eit) |
268 fd.write(self.msg_eit) |
223 fd.write("\n\n# ---DEBUG---\n\n") |
269 fd.write("\n\n# ---DEBUG---\n\n") |
224 fd.write(self.msg_prepare) |
270 fd.write(self.msg_prepare) |
225 fd.write(self.msg_ffmpeg) |
271 fd.write(self.msg_ffmpeg) |
226 fd.close() |
272 fd.close() |
227 #print self.msg_ffmpeg |
273 #print self.msg_ffmpeg |
228 |
274 |
229 for cmd in mkv.command: |
275 for cmd in self.command: |
230 print "Executing ffmpeg:\n%s\n" % cmd |
276 print "Executing ffmpeg:\n%s\n" % cmd |
231 return run_command(cmd) |
277 return run_command(cmd) |
232 |
278 |
233 |
279 |
234 |
280 |
235 if __name__ == "__main__": |
281 if __name__ == "__main__": |
236 # parse command line options |
282 # parse command line options |
237 import argparse, sys, glob |
283 import argparse, glob |
238 |
284 |
239 parser = argparse.ArgumentParser(description = 'DVB-TS to MKV kung-fu') |
285 parser = argparse.ArgumentParser(description='DVB-TS to MKV kung-fu') |
240 parser.add_argument('--crf', type=int, default=19, |
286 parser.add_argument('--crf', type=int, default=19, \ |
241 help='h264 crf (default 19)') |
287 help='h264 crf (default 19)') |
242 parser.add_argument('--tune', default='film', |
288 parser.add_argument('--tune', default='film', \ |
243 help='ffmpeg tune preset [film, animation] (default is film)') |
289 help='ffmpeg tune preset [film, animation] (default is film)') |
244 parser.add_argument('--ns', action='store_true', default=False, |
290 parser.add_argument('--ns', action='store_true', default=False, \ |
245 help='no rescaling (default is scale to 720p)') |
291 help='no rescaling (default is scale to 720p)') |
246 parser.add_argument('--rename', action='store_true', default=False, |
292 parser.add_argument('--rename', action='store_true', default=False, \ |
247 help='rename file basename to name and genre from EIT file if present') |
293 help='rename file basename to name and genre from EIT file if present') |
248 parser.add_argument('input', metavar='input', nargs='+', |
294 parser.add_argument('input', metavar='input', nargs='+', \ |
249 help='one or more files, glob style syntax') |
295 help='one or more files, glob style syntax') |
250 |
296 |
251 args = parser.parse_args() |
297 args = parser.parse_args() |
252 mkv = ts2mkv( |
298 processor = ts2mkv(crf=args.crf, tune=args.tune, scaleto_720p=(not args.ns), \ |
253 crf = args.crf, |
299 rename=args.rename) |
254 tune = args.tune, |
|
255 scaleto_720p = (not args.ns), |
|
256 rename = args.rename |
|
257 ) |
|
258 |
|
259 #os.system('cls' if os.name == 'nt' else 'clear') |
|
260 |
300 |
261 for srcstr in args.input: |
301 for srcstr in args.input: |
262 src = glob.glob(srcstr) |
302 src = glob.glob(srcstr) |
263 for filename in src: |
303 for srcfile in src: |
264 print filename |
304 print "Processing: %s" % srcfile |
265 mkv.load(filename) |
305 processor.load(srcfile) |
266 mkv.convert() |
306 processor.convert() |
267 |
307 |
268 |
308 |