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
18 from plugin
import GetPlugin
, EncodeUnicode
20 SCRIPTDIR
= os
.path
.dirname(__file__
)
22 SERVER_INFO
= """<?xml version="1.0" encoding="utf-8"?>
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>
31 VIDEO_FORMATS
= """<?xml version="1.0" encoding="utf-8"?>
33 <Format><ContentType>video/x-tivo-mpeg</ContentType><Description/></Format>
36 VIDEO_FORMATS_TS
= """<?xml version="1.0" encoding="utf-8"?>
38 <Format><ContentType>video/x-tivo-mpeg</ContentType><Description/></Format>
39 <Format><ContentType>video/x-tivo-mpeg-ts</ContentType><Description/></Format>
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>
45 <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
46 <link rel="stylesheet" type="text/css" href="/main.css">
47 </head> <body> %s </body> </html>"""
49 RELOAD
= '<p>The <a href="%s">page</a> will reload in %d seconds.</p>'
50 UNSUP
= '<h3>Unsupported Command</h3> <p>Query:</p> <ul>%s</ul>'
52 class TivoHTTPServer(SocketServer
.ThreadingMixIn
, BaseHTTPServer
.HTTPServer
):
53 def __init__(self
, server_address
, RequestHandlerClass
):
57 self
.logger
= logging
.getLogger('pyTivo')
58 BaseHTTPServer
.HTTPServer
.__init
__(self
, server_address
,
60 self
.daemon_threads
= True
62 def add_container(self
, name
, settings
):
63 if name
in self
.containers
or name
== 'TiVoConnect':
64 raise "Container Name in use"
66 self
.containers
[name
] = settings
68 self
.logger
.error('Unable to add container ' + name
)
71 self
.containers
.clear()
72 for section
, settings
in config
.getShares():
73 self
.add_container(section
, settings
)
75 def handle_error(self
, request
, client_address
):
76 self
.logger
.exception('Exception during request from %s' %
79 def set_beacon(self
, beacon
):
82 def set_service_status(self
, status
):
83 self
.in_service
= status
85 class TivoHTTPHandler(BaseHTTPServer
.BaseHTTPRequestHandler
):
86 def __init__(self
, request
, client_address
, server
):
87 self
.wbufsize
= 0x10000
88 self
.server_version
= 'pyTivo/1.0'
89 self
.protocol_version
= 'HTTP/1.1'
91 BaseHTTPServer
.BaseHTTPRequestHandler
.__init
__(self
, request
,
92 client_address
, server
)
94 def address_string(self
):
95 host
, port
= self
.client_address
[:2]
98 def version_string(self
):
99 """ Override version_string() so it doesn't include the Python
103 return self
.server_version
106 tsn
= self
.headers
.getheader('TiVo_TCD_ID',
107 self
.headers
.getheader('tsn', ''))
108 if not self
.authorize(tsn
):
111 if tsn
and (not config
.tivos_found
or tsn
in config
.tivos
):
112 attr
= config
.tivos
.get(tsn
, {})
113 if 'address' not in attr
:
114 attr
['address'] = self
.address_string()
115 if 'name' not in attr
:
116 attr
['name'] = self
.server
.beacon
.get_name(attr
['address'])
117 config
.tivos
[tsn
] = attr
120 path
, opts
= self
.path
.split('?', 1)
121 query
= cgi
.parse_qs(opts
)
126 if path
== '/TiVoConnect':
127 self
.handle_query(query
, tsn
)
130 splitpath
= [x
for x
in unquote_plus(path
).split('/') if x
]
132 self
.handle_file(query
, splitpath
)
134 ## Not a file not a TiVo command
138 tsn
= self
.headers
.getheader('TiVo_TCD_ID',
139 self
.headers
.getheader('tsn', ''))
140 if not self
.authorize(tsn
):
142 ctype
, pdict
= cgi
.parse_header(self
.headers
.getheader('content-type'))
143 if ctype
== 'multipart/form-data':
144 query
= cgi
.parse_multipart(self
.rfile
, pdict
)
146 length
= int(self
.headers
.getheader('content-length'))
147 qs
= self
.rfile
.read(length
)
148 query
= cgi
.parse_qs(qs
, keep_blank_values
=1)
149 self
.handle_query(query
, tsn
)
151 def do_command(self
, query
, command
, target
, tsn
):
152 for name
, container
in config
.getShares(tsn
):
154 plugin
= GetPlugin(container
['type'])
155 if hasattr(plugin
, command
):
157 self
.container
= container
158 method
= getattr(plugin
, command
)
165 def handle_query(self
, query
, tsn
):
167 if 'Command' in query
and len(query
['Command']) >= 1:
169 command
= query
['Command'][0]
171 # If we are looking at the root container
172 if (command
== 'QueryContainer' and
173 (not 'Container' in query
or query
['Container'][0] == '/')):
174 self
.root_container()
177 if 'Container' in query
:
178 # Dispatch to the container plugin
179 basepath
= query
['Container'][0].split('/')[0]
180 if self
.do_command(query
, command
, basepath
, tsn
):
183 elif command
== 'QueryItem':
184 path
= query
.get('Url', [''])[0]
185 splitpath
= [x
for x
in unquote_plus(path
).split('/') if x
]
186 if splitpath
and not '..' in splitpath
:
187 if self
.do_command(query
, command
, splitpath
[0], tsn
):
190 elif (command
== 'QueryFormats' and 'SourceFormat' in query
and
191 query
['SourceFormat'][0].startswith('video')):
192 if config
.is_ts_capable(tsn
):
193 self
.send_xml(VIDEO_FORMATS_TS
)
195 self
.send_xml(VIDEO_FORMATS
)
198 elif command
== 'QueryServer':
199 self
.send_xml(SERVER_INFO
)
202 elif command
in ('FlushServer', 'ResetServer'):
203 # Does nothing -- included for completeness
204 self
.send_response(200)
205 self
.send_header('Content-Length', '0')
210 # If we made it here it means we couldn't match the request to
212 self
.unsupported(query
)
214 def send_content_file(self
, path
):
215 lmdate
= os
.path
.getmtime(path
)
217 handle
= open(path
, 'rb')
223 mime
= mimetypes
.guess_type(path
)[0]
224 self
.send_response(200)
226 self
.send_header('Content-Type', mime
)
227 self
.send_header('Content-Length', os
.path
.getsize(path
))
228 self
.send_header('Last-Modified', formatdate(lmdate
))
231 # Send the body of the file
233 shutil
.copyfileobj(handle
, self
.wfile
)
239 def handle_file(self
, query
, splitpath
):
240 if '..' not in splitpath
: # Protect against path exploits
241 ## Pass it off to a plugin?
242 for name
, container
in self
.server
.containers
.items():
243 if splitpath
[0] == name
:
245 self
.container
= container
246 base
= os
.path
.normpath(container
['path'])
247 path
= os
.path
.join(base
, *splitpath
[1:])
248 plugin
= GetPlugin(container
['type'])
249 plugin
.send_file(self
, path
, query
)
252 ## Serve it from a "content" directory?
253 base
= os
.path
.join(SCRIPTDIR
, *splitpath
[:-1])
254 path
= os
.path
.join(base
, 'content', splitpath
[-1])
256 if os
.path
.isfile(path
):
257 self
.send_content_file(path
)
263 def authorize(self
, tsn
=None):
264 # if allowed_clients is empty, we are completely open
265 allowed_clients
= config
.getAllowedClients()
266 if not allowed_clients
or (tsn
and config
.isTsnInConfig(tsn
)):
268 client_ip
= self
.client_address
[0]
269 for allowedip
in allowed_clients
:
270 if client_ip
.startswith(allowedip
):
273 self
.send_fixed('Unauthorized.', 'text/plain', 403)
276 def log_message(self
, format
, *args
):
277 self
.server
.logger
.info("%s [%s] %s" % (self
.address_string(),
278 self
.log_date_time_string(), format
%args
))
280 def send_fixed(self
, page
, mime
, code
=200, refresh
=''):
281 squeeze
= (len(page
) > 256 and mime
.startswith('text') and
282 'gzip' in self
.headers
.getheader('Accept-Encoding', ''))
285 gzip
.GzipFile(mode
='wb', fileobj
=out
).write(page
)
286 page
= out
.getvalue()
288 self
.send_response(code
)
289 self
.send_header('Content-Type', mime
)
290 self
.send_header('Content-Length', len(page
))
292 self
.send_header('Content-Encoding', 'gzip')
293 self
.send_header('Expires', '0')
295 self
.send_header('Refresh', refresh
)
297 self
.wfile
.write(page
)
300 def send_xml(self
, page
):
301 self
.send_fixed(page
, 'text/xml')
303 def send_html(self
, page
, code
=200, refresh
=''):
304 self
.send_fixed(page
, 'text/html; charset=utf-8', code
, refresh
)
306 def root_container(self
):
307 tsn
= self
.headers
.getheader('TiVo_TCD_ID', '')
308 tsnshares
= config
.getShares(tsn
)
310 for section
, settings
in tsnshares
:
312 mime
= GetPlugin(settings
['type']).CONTENT_TYPE
313 if mime
.split('/')[1] in ('tivo-videos', 'tivo-music',
315 settings
['content_type'] = mime
316 tsncontainers
.append((section
, settings
))
317 except Exception, msg
:
318 self
.server
.logger
.error(section
+ ' - ' + str(msg
))
319 t
= Template(file=os
.path
.join(SCRIPTDIR
, 'templates',
320 'root_container.tmpl'),
321 filter=EncodeUnicode
)
322 if self
.server
.beacon
.bd
:
323 t
.renamed
= self
.server
.beacon
.bd
.renamed
326 t
.containers
= tsncontainers
327 t
.hostname
= socket
.gethostname()
330 self
.send_xml(str(t
))
333 t
= Template(file=os
.path
.join(SCRIPTDIR
, 'templates',
335 filter=EncodeUnicode
)
338 if config
.get_server('tivo_mak') and config
.get_server('togo_path'):
339 t
.togo
= 'Pull from TiVos:<br>'
343 if (config
.get_server('tivo_username') and
344 config
.get_server('tivo_password')):
345 t
.shares
= 'Push from video shares:<br>'
349 for section
, settings
in config
.getShares():
350 plugin_type
= settings
.get('type')
351 if plugin_type
== 'settings':
352 t
.admin
+= ('<a href="/TiVoConnect?Command=Settings&' +
353 'Container=' + quote(section
) +
354 '">Settings</a><br>')
355 elif plugin_type
== 'togo' and t
.togo
:
356 for tsn
in config
.tivos
:
357 if tsn
and 'address' in config
.tivos
[tsn
]:
358 t
.togo
+= ('<a href="/TiVoConnect?' +
359 'Command=NPL&Container=' + quote(section
) +
360 '&TiVo=' + config
.tivos
[tsn
]['address'] +
361 '">' + config
.tivos
[tsn
]['name'] +
363 elif plugin_type
and t
.shares
:
364 plugin
= GetPlugin(plugin_type
)
365 if hasattr(plugin
, 'Push'):
366 t
.shares
+= ('<a href="/TiVoConnect?Command=' +
367 'QueryContainer&Container=' +
368 quote(section
) + '&Format=text/html">' +
369 section
+ '</a><br>')
371 self
.send_html(str(t
))
373 def unsupported(self
, query
):
374 message
= UNSUP
% '\n'.join(['<li>%s: %s</li>' % (key
, repr(value
))
375 for key
, value
in query
.items()])
376 text
= BASE_HTML
% message
377 self
.send_html(text
, code
=404)
379 def redir(self
, message
, seconds
=2):
380 url
= self
.headers
.getheader('Referer')
382 message
+= RELOAD
% (url
, seconds
)
383 refresh
= '%d; url=%s' % (seconds
, url
)
386 text
= (BASE_HTML
% message
).encode('utf-8')
387 self
.send_html(text
, refresh
=refresh
)