ts2mkv.py

changeset 10
f436a7f94c6a
parent 9
1bf778001041
child 11
821c02fa7070
equal deleted inserted replaced
9:1bf778001041 10:f436a7f94c6a
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)
85 name 115 name
86 ) 116 )
87 117
88 118
89 def get_crop_option(self): 119 def get_crop_option(self):
120 """
121 parse the ffmpeg analyze output cropdetect lines
122 returns None or valid crop string for ffmpeg video filter
123 """
90 lines = filter_lines(self.msg_ffmpeg, "[Parsed_cropdetect").split("\n") 124 lines = filter_lines(self.msg_ffmpeg, "[Parsed_cropdetect").split("\n")
91 option = None 125 option = None
92 for line in lines: 126 for line in lines:
93 tmp = line[line.find(" crop="):].strip() 127 tmp = line[line.find(" crop="):].strip()
94 #print "DEBUG: " + tmp 128 #print "DEBUG: " + tmp
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
192 flt = [] 229 flt = []
193 crop = self.get_crop_option() 230 crop = self.get_crop_option()
194 if crop: 231 if crop:
195 flt.append(crop) 232 flt.append(crop)
196 if self.scaleto_720p: 233 if self.scaleto_720p:
197 flt.append("scale='min(1280,iw)':-2'") # -2 ensures division by two for codec 234 # -2 ensures division by two for codec
235 flt.append("scale='min(1280,iw)':-2'")
198 self.msg_prepare += "Scaling cropped output stream to 720p\n" 236 self.msg_prepare += "Scaling cropped output stream to 720p\n"
199 if len(flt) > 0: 237 if len(flt) > 0:
200 # append video filters 238 # append video filters
201 cmd.append('-filter:v "%s"' % ",".join(flt)) 239 cmd.append('-filter:v "%s"' % ",".join(flt))
202 for a in audiomap: 240 for a in audiomap:
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

mercurial