Run mplayer in a background thread so it doesn't block UI
[bluplayer] / bluplayer.py
1 #!/usr/bin/python
2
3 import csv
4 import errno
5 import logging
6 import os
7 import subprocess
8 import sys
9 import urllib
10
11 from lxml import etree
12 from PyQt4.QtCore import *
13 from PyQt4.QtGui import *
14
15
16 XHTML = "http://www.w3.org/1999/xhtml"
17 NS = {
18 "xhtml": XHTML,
19 }
20
21 MAKEMKV_DIR = os.environ.get("MAKEMKV_DIR", os.path.expanduser("~/.makemkv_install"))
22 MAKEMKVCON_PATH = os.environ.get("MAKEMKVCON_PATH", os.path.join(MAKEMKV_DIR, "current", "makemkvcon"))
23 MPLAYER_PATH = os.environ.get("MPLAYER_PATH", "mplayer")
24
25
26 def grab_xml(url):
27 f = urllib.urlopen(url)
28 doc = etree.parse(f)
29 f.close()
30 return doc
31
32 def parse_doc(doc):
33 d = {}
34 tds = doc.xpath("//xhtml:td", namespaces=NS)
35 i = 0
36 while i < len(tds)-1:
37 key = tds[i].text
38 value = tds[i+1]
39 for v in value:
40 if v.tag == "{%s}a" % XHTML:
41 value = v.get("href")
42 break
43 else:
44 value = value.text
45
46 d[key] = value
47 i += 2
48 return d
49
50 def grab_dict(url):
51 doc = grab_xml(url)
52 d = parse_doc(doc)
53 return d
54
55 def format_exception(msg, e=None):
56 msg = "%s\n%s: %s" % (msg, e.__class__.__name__, str(e))
57 logging.error(msg)
58 return msg
59
60
61 class Title(object):
62 def __init__(self, title_page):
63 self.id = title_page["id"]
64 self.duration = title_page["duration"]
65 self.video_url = title_page["file0"]
66
67 def __str__(self):
68 return "Title%s: %s %s" % (self.id, self.duration, self.video_url)
69
70
71 class MakeMkvCon(object):
72 def __init__(self, params):
73 cmd = [MAKEMKVCON_PATH, "--robot"]
74 cmd.extend(params)
75 logging.info("Running makemkvcon: %s", params)
76 self.p = subprocess.Popen(cmd, stdout=subprocess.PIPE)
77 self.fd = self.p.stdout.fileno()
78 self.buf = ""
79 self.finished = False
80 self.status = None
81
82 def decode_line(self, line):
83 logging.debug("makemkvcon output: %s", line)
84 data = csv.reader([line]).next()
85 key, x0 = data[0].split(":", 1)
86 data[0] = x0
87 return key, data
88
89 def __iter__(self):
90 return self
91
92 def next(self):
93 if self.finished:
94 raise StopIteration()
95 while True:
96 pos = self.buf.find("\n")
97 if pos >= 0:
98 result = self.buf[:pos]
99 self.buf = self.buf[pos+1:]
100 return self.decode_line(result)
101
102 try:
103 data = os.read(self.p.stdout.fileno(), 4096)
104 except OSError, e:
105 if e.errno == errno.EINTR:
106 continue
107 if not data:
108 self.status = self.p.wait()
109 logging.info("makemkvcon exited with status %s", self.status)
110 self.finished = True
111 raise StopIteration()
112
113 self.buf += data
114
115
116 class MPlayer(QObject):
117 play_finished = pyqtSignal()
118 fatal_error = pyqtSignal(str)
119
120 def play(self, video_url):
121 logging.info("Running mplayer: %s", video_url)
122 try:
123 cmd = [
124 MPLAYER_PATH,
125 "-fs",
126 "-lavdopts", "threads=%s" % subprocess.check_output("nproc").strip(),
127 "-volume", "100",
128 video_url,
129 ]
130 subprocess.check_call(cmd)
131 except Exception, e:
132 self.fatal_error.emit(format_exception("MPlayer failed to play the video", e))
133 finally:
134 self.play_finished.emit()
135
136
137 class MakeMkv(QObject):
138 title_loaded = pyqtSignal(Title)
139 title_load_complete = pyqtSignal()
140 status = pyqtSignal(str)
141 fatal_error = pyqtSignal(str)
142
143 def install(self):
144 raise NotImplementedError("auto-install not implemented")
145
146 def find_disc(self):
147 self.url = "http://192.168.1.114:51001/"
148 makemkvcon = MakeMkvCon(["info", "disc:9999"])
149 disc_number = None
150 for key, line in makemkvcon:
151 if key == "MSG" and line[0] != "5010":
152 self.status.emit(line[3])
153
154 if disc_number is None and key == "DRV" and line[5]:
155 disc_number = line[0]
156 disc_name = line[5]
157 self.status.emit("Found disc %s" % disc_name)
158
159 if makemkvcon.status == 0:
160 return disc_number
161
162 def run_stream(self, disc_number):
163 makemkvcon = MakeMkvCon(["stream", "disc:%s" % disc_number])
164 for key, line in makemkvcon:
165 if key == "MSG" and line[0] == "4500":
166 # Sometimes the port in field 6 is wrong
167 port = line[5].split(":")[1]
168 url = "http://localhost:%s/" % port
169 self.load_titles(url)
170 elif key == "MSG":
171 self.status.emit(line[3])
172
173 if makemkvcon.status != 0:
174 self.fatal_error.emit("MakeMKV exited with error status: %s" % makemkvcon.status)
175
176 def load_titles(self, url):
177 home_page = grab_dict(url)
178 title_list_page = grab_dict(home_page["titles"])
179 title_count = int(title_list_page["titlecount"])
180 for i in xrange(title_count):
181 title_page = grab_dict(title_list_page["title%d" % i])
182 title = Title(title_page)
183 self.title_loaded.emit(title)
184 self.title_load_complete.emit()
185
186 def run(self):
187 logging.info("MakeMKV thread started")
188
189 if not os.path.isfile(MAKEMKVCON_PATH):
190 try:
191 self.install()
192 except Exception, e:
193 self.fatal_error.emit(format_exception("Failed to install MakeMKV", e))
194 raise
195
196 try:
197 disc_number = self.find_disc()
198 except Exception, e:
199 self.fatal_error.emit(format_exception("Error searching for disc", e))
200 raise
201
202 if not disc_number:
203 self.fatal_error.emit("No disc found, please insert a disc and try again.")
204 return
205
206 try:
207 self.run_stream(disc_number)
208 except Exception, e:
209 self.fatal_error.emit(format_exception("Failed to start MakeMKV", e))
210 raise
211
212 logging.info("MakeMKV thread finished")
213
214
215 class PlayerWindow(QWidget):
216 video_selected = pyqtSignal(str)
217
218 def __init__(self):
219 QWidget.__init__(self)
220
221 self.title_map = {}
222 self.is_playing = False
223
224 self.list_widget = QListWidget(self)
225 self.list_widget.itemActivated.connect(self.handle_activated)
226 self.list_widget.setEnabled(False)
227
228 self.log = QTextEdit(self)
229 self.log.setReadOnly(True)
230
231 self.splitter = QSplitter(Qt.Vertical, self)
232 self.splitter.addWidget(self.list_widget)
233 self.splitter.addWidget(self.log)
234 self.splitter.setSizes([900, 200])
235
236 self.layout = QVBoxLayout(self)
237 self.layout.addWidget(self.splitter)
238 self.setWindowTitle("BluPlayer")
239
240 def add_title(self, title):
241 name = "Title %s (%s)" % (int(title.id)+1, title.duration)
242 self.list_widget.addItem(name)
243 self.title_map[name] = title
244
245 def select_longest_title(self):
246 longest_title = None
247 longest_item = None
248 for i in xrange(self.list_widget.count()):
249 item = self.list_widget.item(i)
250 name = str(item.text())
251 title = self.title_map[name]
252 if longest_title is None or title.duration > longest_title.duration:
253 longest_title = title
254 longest_item = item
255 self.list_widget.setCurrentItem(longest_item)
256 self.list_widget.setEnabled(True)
257 self.list_widget.setFocus()
258
259 def add_log_entry(self, text):
260 self.log.append(text)
261
262 def popup_fatal_error(self, text):
263 QMessageBox.critical(None, "Fatal error", text)
264 qApp.quit()
265
266 def handle_activated(self, item):
267 if self.is_playing:
268 return
269 name = str(item.text())
270 title = self.title_map[name]
271 self.is_playing = True
272 self.list_widget.setEnabled(False)
273 self.video_selected.emit(title.video_url)
274
275 def set_play_finished(self):
276 self.is_playing = False
277 self.list_widget.setEnabled(True)
278 self.list_widget.setFocus()
279
280 def keyPressEvent(self, e):
281 if e.key() in (Qt.Key_Escape, Qt.Key_Backspace):
282 self.close()
283 return
284 QWidget.keyPressEvent(self, e)
285
286
287 def killall_makemkvcon():
288 logging.info("killing makemkvcon")
289 subprocess.Popen(["killall", "makemkvcon"]).wait()
290
291 def main():
292 logging.basicConfig(format="%(levelname)s: %(message)s")
293 logging.getLogger().setLevel(logging.DEBUG)
294 logging.info("Configuring application")
295
296 app = QApplication(sys.argv)
297 app.setQuitOnLastWindowClosed(True)
298
299 default_font = app.font()
300 default_font.setPointSize(16)
301 app.setFont(default_font)
302
303 player_window = PlayerWindow()
304 player_window.resize(1200, 700)
305 player_window.show()
306
307 makemkv = MakeMkv()
308 makemkv_thread = QThread()
309 makemkv.moveToThread(makemkv_thread)
310
311 mplayer = MPlayer()
312 mplayer_thread = QThread()
313 mplayer.moveToThread(mplayer_thread)
314
315 makemkv_thread.started.connect(makemkv.run)
316 makemkv.title_loaded.connect(player_window.add_title)
317 makemkv.title_load_complete.connect(player_window.select_longest_title)
318 makemkv.status.connect(player_window.add_log_entry)
319 makemkv.fatal_error.connect(player_window.popup_fatal_error)
320
321 player_window.video_selected.connect(mplayer.play)
322 mplayer.play_finished.connect(player_window.set_play_finished)
323
324 logging.info("Starting application")
325 makemkv_thread.start()
326 mplayer_thread.start()
327 result = app.exec_()
328
329 logging.info("Shutting down")
330 makemkv_thread.quit()
331 mplayer_thread.quit()
332 killall_makemkvcon()
333 logging.info("Waiting for makemkv thread")
334 makemkv_thread.wait(2000)
335 logging.info("Waiting for mplayer thread")
336 mplayer_thread.wait(2000)
337 logging.info("Exiting...")
338 sys.exit(result)
339
340 if __name__ == "__main__":
341 main()
342