Recursing through playlists can cayse hangups. Omitting this doesn't
[pyTivo/wmcbrine.git] / httpserver.py
blob7ac2205e0571031a358f546e285a58ff8bef63d8
1 import BaseHTTPServer
2 import SocketServer
3 import cgi
4 import gzip
5 import logging
6 import mimetypes
7 import os
8 import shutil
9 import socket
10 import time
11 from cStringIO import StringIO
12 from email.utils import formatdate
13 from urllib import unquote_plus, quote
14 from xml.sax.saxutils import escape
16 from Cheetah.Template import Template
17 import config
18 from plugin import GetPlugin, EncodeUnicode
20 SCRIPTDIR = os.path.dirname(__file__)
22 SERVER_INFO = """<?xml version="1.0" encoding="utf-8"?>
23 <TiVoServer>
24 <Version>1.6</Version>
25 <InternalName>pyTivo</InternalName>
26 <InternalVersion>1.0</InternalVersion>
27 <Organization>pyTivo Developers</Organization>
28 <Comment>http://pytivo.sf.net/</Comment>
29 </TiVoServer>"""
31 VIDEO_FORMATS = """<?xml version="1.0" encoding="utf-8"?>
32 <TiVoFormats>
33 <Format><ContentType>video/x-tivo-mpeg</ContentType><Description/></Format>
34 </TiVoFormats>"""
36 VIDEO_FORMATS_TS = """<?xml version="1.0" encoding="utf-8"?>
37 <TiVoFormats>
38 <Format><ContentType>video/x-tivo-mpeg</ContentType><Description/></Format>
39 <Format><ContentType>video/x-tivo-mpeg-ts</ContentType><Description/></Format>
40 </TiVoFormats>"""
42 BASE_HTML = """<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
43 "http://www.w3.org/TR/html4/strict.dtd">
44 <html> <head><title>pyTivo</title></head> <body> %s </body> </html>"""
46 RELOAD = '<p>The <a href="%s">page</a> will reload in %d seconds.</p>'
47 UNSUP = '<h3>Unsupported Command</h3> <p>Query:</p> <ul>%s</ul>'
49 class TivoHTTPServer(SocketServer.ThreadingMixIn, BaseHTTPServer.HTTPServer):
50 def __init__(self, server_address, RequestHandlerClass):
51 self.containers = {}
52 self.stop = False
53 self.restart = False
54 self.logger = logging.getLogger('pyTivo')
55 BaseHTTPServer.HTTPServer.__init__(self, server_address,
56 RequestHandlerClass)
57 self.daemon_threads = True
59 def add_container(self, name, settings):
60 if name in self.containers or name == 'TiVoConnect':
61 raise "Container Name in use"
62 try:
63 self.containers[name] = settings
64 except KeyError:
65 self.logger.error('Unable to add container ' + name)
67 def reset(self):
68 self.containers.clear()
69 for section, settings in config.getShares():
70 self.add_container(section, settings)
72 def handle_error(self, request, client_address):
73 self.logger.exception('Exception during request from %s' %
74 (client_address,))
76 def set_beacon(self, beacon):
77 self.beacon = beacon
79 def set_service_status(self, status):
80 self.in_service = status
82 class TivoHTTPHandler(BaseHTTPServer.BaseHTTPRequestHandler):
83 def __init__(self, request, client_address, server):
84 self.wbufsize = 0x10000
85 self.server_version = 'pyTivo/1.0'
86 self.protocol_version = 'HTTP/1.1'
87 self.sys_version = ''
88 BaseHTTPServer.BaseHTTPRequestHandler.__init__(self, request,
89 client_address, server)
91 def address_string(self):
92 host, port = self.client_address[:2]
93 return host
95 def version_string(self):
96 """ Override version_string() so it doesn't include the Python
97 version.
99 """
100 return self.server_version
102 def do_GET(self):
103 tsn = self.headers.getheader('TiVo_TCD_ID',
104 self.headers.getheader('tsn', ''))
105 if not self.authorize(tsn):
106 return
107 if tsn:
108 ip = self.address_string()
109 config.tivos[tsn] = ip
111 if not tsn in config.tivo_names or config.tivo_names[tsn] == tsn:
112 config.tivo_names[tsn] = self.server.beacon.get_name(ip)
114 if '?' in self.path:
115 path, opts = self.path.split('?', 1)
116 query = cgi.parse_qs(opts)
117 else:
118 path = self.path
119 query = {}
121 if path == '/TiVoConnect':
122 self.handle_query(query, tsn)
123 else:
124 ## Get File
125 splitpath = [x for x in unquote_plus(path).split('/') if x]
126 if splitpath:
127 self.handle_file(query, splitpath)
128 else:
129 ## Not a file not a TiVo command
130 self.infopage()
132 def do_POST(self):
133 tsn = self.headers.getheader('TiVo_TCD_ID',
134 self.headers.getheader('tsn', ''))
135 if not self.authorize(tsn):
136 return
137 ctype, pdict = cgi.parse_header(self.headers.getheader('content-type'))
138 if ctype == 'multipart/form-data':
139 query = cgi.parse_multipart(self.rfile, pdict)
140 else:
141 length = int(self.headers.getheader('content-length'))
142 qs = self.rfile.read(length)
143 query = cgi.parse_qs(qs, keep_blank_values=1)
144 self.handle_query(query, tsn)
146 def do_command(self, query, command, target, tsn):
147 for name, container in config.getShares(tsn):
148 if target == name:
149 plugin = GetPlugin(container['type'])
150 if hasattr(plugin, command):
151 self.cname = name
152 self.container = container
153 method = getattr(plugin, command)
154 method(self, query)
155 return True
156 else:
157 break
158 return False
160 def handle_query(self, query, tsn):
161 mname = False
162 if 'Command' in query and len(query['Command']) >= 1:
164 command = query['Command'][0]
166 # If we are looking at the root container
167 if (command == 'QueryContainer' and
168 (not 'Container' in query or query['Container'][0] == '/')):
169 self.root_container()
170 return
172 if 'Container' in query:
173 # Dispatch to the container plugin
174 basepath = query['Container'][0].split('/')[0]
175 if self.do_command(query, command, basepath, tsn):
176 return
178 elif command == 'QueryItem':
179 path = query.get('Url', [''])[0]
180 splitpath = [x for x in unquote_plus(path).split('/') if x]
181 if splitpath and not '..' in splitpath:
182 if self.do_command(query, command, splitpath[0], tsn):
183 return
185 elif (command == 'QueryFormats' and 'SourceFormat' in query and
186 query['SourceFormat'][0].startswith('video')):
187 if config.is_ts_capable(tsn):
188 self.send_xml(VIDEO_FORMATS_TS)
189 else:
190 self.send_xml(VIDEO_FORMATS)
191 return
193 elif command == 'QueryServer':
194 self.send_xml(SERVER_INFO)
195 return
197 elif command in ('FlushServer', 'ResetServer'):
198 # Does nothing -- included for completeness
199 self.send_response(200)
200 self.send_header('Content-Length', '0')
201 self.end_headers()
202 self.wfile.flush()
203 return
205 # If we made it here it means we couldn't match the request to
206 # anything.
207 self.unsupported(query)
209 def send_content_file(self, path):
210 lmdate = os.path.getmtime(path)
211 try:
212 handle = open(path, 'rb')
213 except:
214 self.send_error(404)
215 return
217 # Send the header
218 mime = mimetypes.guess_type(path)[0]
219 self.send_response(200)
220 if mime:
221 self.send_header('Content-Type', mime)
222 self.send_header('Content-Length', os.path.getsize(path))
223 self.send_header('Last-Modified', formatdate(lmdate))
224 self.end_headers()
226 # Send the body of the file
227 try:
228 shutil.copyfileobj(handle, self.wfile)
229 except:
230 pass
231 handle.close()
232 self.wfile.flush()
234 def handle_file(self, query, splitpath):
235 if '..' not in splitpath: # Protect against path exploits
236 ## Pass it off to a plugin?
237 for name, container in self.server.containers.items():
238 if splitpath[0] == name:
239 self.cname = name
240 self.container = container
241 base = os.path.normpath(container['path'])
242 path = os.path.join(base, *splitpath[1:])
243 plugin = GetPlugin(container['type'])
244 plugin.send_file(self, path, query)
245 return
247 ## Serve it from a "content" directory?
248 base = os.path.join(SCRIPTDIR, *splitpath[:-1])
249 path = os.path.join(base, 'content', splitpath[-1])
251 if os.path.isfile(path):
252 self.send_content_file(path)
253 return
255 ## Give up
256 self.send_error(404)
258 def authorize(self, tsn=None):
259 # if allowed_clients is empty, we are completely open
260 allowed_clients = config.getAllowedClients()
261 if not allowed_clients or (tsn and config.isTsnInConfig(tsn)):
262 return True
263 client_ip = self.client_address[0]
264 for allowedip in allowed_clients:
265 if client_ip.startswith(allowedip):
266 return True
268 self.send_fixed('Unauthorized.', 'text/plain', 403)
269 return False
271 def log_message(self, format, *args):
272 self.server.logger.info("%s [%s] %s" % (self.address_string(),
273 self.log_date_time_string(), format%args))
275 def send_fixed(self, page, mime, code=200, refresh=''):
276 squeeze = (len(page) > 256 and mime.startswith('text') and
277 'gzip' in self.headers.getheader('Accept-Encoding', ''))
278 if squeeze:
279 out = StringIO()
280 gzip.GzipFile(mode='wb', fileobj=out).write(page)
281 page = out.getvalue()
282 out.close()
283 self.send_response(code)
284 self.send_header('Content-Type', mime)
285 self.send_header('Content-Length', len(page))
286 if squeeze:
287 self.send_header('Content-Encoding', 'gzip')
288 self.send_header('Expires', '0')
289 if refresh:
290 self.send_header('Refresh', refresh)
291 self.end_headers()
292 self.wfile.write(page)
293 self.wfile.flush()
295 def send_xml(self, page):
296 self.send_fixed(page, 'text/xml')
298 def send_html(self, page, code=200, refresh=''):
299 self.send_fixed(page, 'text/html; charset=utf-8', code, refresh)
301 def root_container(self):
302 tsn = self.headers.getheader('TiVo_TCD_ID', '')
303 tsnshares = config.getShares(tsn)
304 tsncontainers = []
305 for section, settings in tsnshares:
306 try:
307 mime = GetPlugin(settings['type']).CONTENT_TYPE
308 if mime.split('/')[1] in ('tivo-videos', 'tivo-music',
309 'tivo-photos'):
310 settings['content_type'] = mime
311 tsncontainers.append((section, settings))
312 except Exception, msg:
313 self.server.logger.error(section + ' - ' + str(msg))
314 t = Template(file=os.path.join(SCRIPTDIR, 'templates',
315 'root_container.tmpl'),
316 filter=EncodeUnicode)
317 if self.server.beacon.bd:
318 t.renamed = self.server.beacon.bd.renamed
319 else:
320 t.renamed = {}
321 t.containers = tsncontainers
322 t.hostname = socket.gethostname()
323 t.escape = escape
324 t.quote = quote
325 self.send_xml(str(t))
327 def infopage(self):
328 t = Template(file=os.path.join(SCRIPTDIR, 'templates',
329 'info_page.tmpl'),
330 filter=EncodeUnicode)
331 t.admin = ''
333 if config.get_server('tivo_mak') and config.get_server('togo_path'):
334 t.togo = '<br>Pull from TiVos:<br>'
335 else:
336 t.togo = ''
338 if (config.get_server('tivo_username') and
339 config.get_server('tivo_password')):
340 t.shares = '<br>Push from video shares:<br>'
341 else:
342 t.shares = ''
344 for section, settings in config.getShares():
345 plugin_type = settings.get('type')
346 if plugin_type == 'settings':
347 t.admin += ('<a href="/TiVoConnect?Command=Settings&amp;' +
348 'Container=' + quote(section) +
349 '">Settings</a><br>')
350 elif plugin_type == 'togo' and t.togo:
351 for tsn in config.tivos:
352 if tsn:
353 t.togo += ('<a href="/TiVoConnect?' +
354 'Command=NPL&amp;Container=' + quote(section) +
355 '&amp;TiVo=' + config.tivos[tsn] + '">' +
356 escape(config.tivo_names[tsn]) + '</a><br>')
357 elif plugin_type and t.shares:
358 plugin = GetPlugin(plugin_type)
359 if hasattr(plugin, 'Push'):
360 t.shares += ('<a href="/TiVoConnect?Command=' +
361 'QueryContainer&amp;Container=' +
362 quote(section) + '&Format=text/html">' +
363 section + '</a><br>')
365 self.send_html(str(t))
367 def unsupported(self, query):
368 message = UNSUP % '\n'.join(['<li>%s: %s</li>' % (escape(key),
369 escape(repr(value)))
370 for key, value in query.items()])
371 text = BASE_HTML % message
372 self.send_html(text, code=404)
374 def redir(self, message, seconds=2):
375 url = self.headers.getheader('Referer')
376 if url:
377 message += RELOAD % (escape(url), seconds)
378 refresh = '%d; url=%s' % (seconds, url)
379 else:
380 refresh = ''
381 text = (BASE_HTML % message).encode('utf-8')
382 self.send_html(text, refresh=refresh)