Update the UI more efficiently, make it much faster
[gpodder.git] / src / gpodder / util.py
blob2ebd7f27606b681f67cf88e5c5b3207acec86fb0
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2008 Thomas Perl and the gPodder Team
6 # gPodder is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 3 of the License, or
9 # (at your option) any later version.
11 # gPodder is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
21 # util.py -- Misc utility functions
22 # Thomas Perl <thp@perli.net> 2007-08-04
25 """Miscellaneous helper functions for gPodder
27 This module provides helper and utility functions for gPodder that
28 are not tied to any specific part of gPodder.
30 """
32 import gpodder
33 from gpodder.liblogger import log
35 import gtk
36 import gobject
38 import os
39 import os.path
40 import glob
41 import stat
43 import re
44 import subprocess
45 from htmlentitydefs import entitydefs
46 import time
47 import locale
48 import gzip
49 import datetime
50 import threading
52 import urlparse
53 import urllib
54 import urllib2
55 import httplib
56 import webbrowser
57 import mimetypes
59 import feedparser
61 import StringIO
62 import xml.dom.minidom
65 # Try to detect OS encoding (by Leonid Ponomarev)
66 encoding = 'iso-8859-15'
67 if 'LANG' in os.environ and '.' in os.environ['LANG']:
68 lang = os.environ['LANG']
69 (language, encoding) = lang.rsplit('.', 1)
70 log('Detected encoding: %s', encoding)
71 enc = encoding
72 else:
73 # Using iso-8859-15 here as (hopefully) sane default
74 # see http://en.wikipedia.org/wiki/ISO/IEC_8859-1
75 log('Using ISO-8859-15 as encoding. If this')
76 log('is incorrect, please set your $LANG variable.')
79 if gpodder.interface == gpodder.GUI:
80 ICON_UNPLAYED = gtk.STOCK_YES
81 ICON_LOCKED = 'emblem-nowrite'
82 ICON_MISSING = gtk.STOCK_STOP
83 elif gpodder.interface == gpodder.MAEMO:
84 ICON_UNPLAYED = 'qgn_list_gene_favor'
85 ICON_LOCKED = 'qgn_indi_KeypadLk_lock'
86 ICON_MISSING = gtk.STOCK_STOP # FIXME!
88 def make_directory( path):
89 """
90 Tries to create a directory if it does not exist already.
91 Returns True if the directory exists after the function
92 call, False otherwise.
93 """
94 if os.path.isdir( path):
95 return True
97 try:
98 os.makedirs( path)
99 except:
100 log( 'Could not create directory: %s', path)
101 return False
103 return True
106 def normalize_feed_url( url):
108 Converts any URL to http:// or ftp:// so that it can be
109 used with "wget". If the URL cannot be converted (invalid
110 or unknown scheme), "None" is returned.
112 This will also normalize feed:// and itpc:// to http://
113 Also supported are phobos.apple.com links (iTunes podcast)
114 and itms:// links (iTunes podcast direct link).
116 If no URL scheme is defined (e.g. "curry.com"), we will
117 simply assume the user intends to add a http:// feed.
120 if not url or len( url) < 8:
121 return None
123 if not '://' in url:
124 url = 'http://' + url
126 if url.startswith('itms://'):
127 url = parse_itunes_xml(url)
129 # Links to "phobos.apple.com"
130 url = itunes_discover_rss(url)
131 if url is None:
132 return None
134 if url.startswith( 'http://') or url.startswith( 'https://') or url.startswith( 'ftp://'):
135 return url
137 if url.startswith('feed://') or url.startswith('itpc://'):
138 return 'http://' + url[7:]
140 return None
143 def username_password_from_url( url):
145 Returns a tuple (username,password) containing authentication
146 data from the specified URL or (None,None) if no authentication
147 data can be found in the URL.
149 (username, password) = (None, None)
151 (scheme, netloc, path, params, query, fragment) = urlparse.urlparse( url)
153 if '@' in netloc:
154 (authentication, netloc) = netloc.rsplit('@', 1)
155 if ':' in authentication:
156 (username, password) = authentication.split(':', 1)
157 username = urllib.unquote(username)
158 password = urllib.unquote(password)
159 else:
160 username = urllib.unquote(authentication)
162 return (username, password)
165 def directory_is_writable( path):
167 Returns True if the specified directory exists and is writable
168 by the current user.
170 return os.path.isdir( path) and os.access( path, os.W_OK)
173 def calculate_size( path):
175 Tries to calculate the size of a directory, including any
176 subdirectories found. The returned value might not be
177 correct if the user doesn't have appropriate permissions
178 to list all subdirectories of the given path.
180 if path is None:
181 return 0L
183 if os.path.dirname( path) == '/':
184 return 0L
186 if os.path.isfile( path):
187 return os.path.getsize( path)
189 if os.path.isdir( path) and not os.path.islink( path):
190 sum = os.path.getsize( path)
192 try:
193 for item in os.listdir(path):
194 try:
195 sum += calculate_size(os.path.join(path, item))
196 except:
197 log('Cannot get size for %s', path)
198 except:
199 log('Cannot access: %s', path)
201 return sum
203 return 0L
206 def file_modification_datetime(filename):
208 Returns the modification date of the specified file
209 as a datetime.datetime object or None if the modification
210 date cannot be determined.
212 if filename is None:
213 return None
215 if not os.access(filename, os.R_OK):
216 return None
218 try:
219 s = os.stat(filename)
220 timestamp = s[stat.ST_MTIME]
221 return datetime.datetime.fromtimestamp(timestamp)
222 except:
223 log('Cannot get modification timestamp for %s', filename)
224 return None
227 def file_age_in_days(filename):
229 Returns the age of the specified filename in days or
230 zero if the modification date cannot be determined.
232 dt = file_modification_datetime(filename)
233 if dt is None:
234 return 0
235 else:
236 return (datetime.datetime.now()-dt).days
239 def file_age_to_string(days):
241 Converts a "number of days" value to a string that
242 can be used in the UI to display the file age.
244 >>> file_age_to_string(0)
246 >>> file_age_to_string(1)
247 'one day ago'
248 >>> file_age_to_String(2)
249 '2 days ago'
251 if days == 1:
252 return _('one day ago')
253 elif days > 1:
254 return _('%d days ago') % days
255 else:
256 return ''
259 def get_free_disk_space(path):
261 Calculates the free disk space available to the current user
262 on the file system that contains the given path.
264 If the path (or its parent folder) does not yet exist, this
265 function returns zero.
268 if not os.path.exists(path):
269 return 0
271 s = os.statvfs(path)
273 return s.f_bavail * s.f_bsize
276 def format_date(timestamp):
278 Converts a UNIX timestamp to a date representation. This
279 function returns "Today", "Yesterday", a weekday name or
280 the date in %x format, which (according to the Python docs)
281 is the "Locale's appropriate date representation".
283 Returns None if there has been an error converting the
284 timestamp to a string representation.
286 if timestamp is None:
287 return None
289 seconds_in_a_day = 60*60*24
291 today = time.localtime()[:3]
292 yesterday = time.localtime(time.time() - seconds_in_a_day)[:3]
293 timestamp_date = time.localtime(timestamp)[:3]
295 if timestamp_date == today:
296 return _('Today')
297 elif timestamp_date == yesterday:
298 return _('Yesterday')
300 try:
301 diff = int( (time.time() - timestamp)/seconds_in_a_day )
302 except:
303 log('Warning: Cannot convert "%s" to date.', timestamp, traceback=True)
304 return None
306 if diff < 7:
307 # Weekday name
308 return str(datetime.datetime.fromtimestamp(timestamp).strftime('%A'))
309 else:
310 # Locale's appropriate date representation
311 return str(datetime.datetime.fromtimestamp(timestamp).strftime('%x'))
314 def format_filesize(bytesize, use_si_units=False, digits=2):
316 Formats the given size in bytes to be human-readable,
318 Returns a localized "(unknown)" string when the bytesize
319 has a negative value.
321 si_units = (
322 ( 'kB', 10**3 ),
323 ( 'MB', 10**6 ),
324 ( 'GB', 10**9 ),
327 binary_units = (
328 ( 'KiB', 2**10 ),
329 ( 'MiB', 2**20 ),
330 ( 'GiB', 2**30 ),
333 try:
334 bytesize = float( bytesize)
335 except:
336 return _('(unknown)')
338 if bytesize < 0:
339 return _('(unknown)')
341 if use_si_units:
342 units = si_units
343 else:
344 units = binary_units
346 ( used_unit, used_value ) = ( 'B', bytesize )
348 for ( unit, value ) in units:
349 if bytesize >= value:
350 used_value = bytesize / float(value)
351 used_unit = unit
353 return ('%.'+str(digits)+'f %s') % (used_value, used_unit)
356 def delete_file( path):
358 Tries to delete the given filename and silently
359 ignores deletion errors (if the file doesn't exist).
360 Also deletes extracted cover files if they exist.
362 log( 'Trying to delete: %s', path)
363 try:
364 os.unlink( path)
365 # Remove any extracted cover art that might exist
366 for cover_file in glob.glob( '%s.cover.*' % ( path, )):
367 os.unlink( cover_file)
369 except:
370 pass
374 def remove_html_tags(html):
376 Remove HTML tags from a string and replace numeric and
377 named entities with the corresponding character, so the
378 HTML text can be displayed in a simple text view.
380 # If we would want more speed, we could make these global
381 re_strip_tags = re.compile('<[^>]*>')
382 re_unicode_entities = re.compile('&#(\d{2,4});')
383 re_html_entities = re.compile('&(.{2,8});')
384 re_newline_tags = re.compile('(<br[^>]*>|<[/]?ul[^>]*>|</li>)', re.I)
385 re_listing_tags = re.compile('<li[^>]*>', re.I)
387 result = html
389 # Convert common HTML elements to their text equivalent
390 result = re_newline_tags.sub('\n', result)
391 result = re_listing_tags.sub('\n * ', result)
392 result = re.sub('<[Pp]>', '\n\n', result)
394 # Remove all HTML/XML tags from the string
395 result = re_strip_tags.sub('', result)
397 # Convert numeric XML entities to their unicode character
398 result = re_unicode_entities.sub(lambda x: unichr(int(x.group(1))), result)
400 # Convert named HTML entities to their unicode character
401 result = re_html_entities.sub(lambda x: unicode(entitydefs.get(x.group(1),''), 'iso-8859-1'), result)
403 # Convert more than two newlines to two newlines
404 result = re.sub('([\r\n]{2})([\r\n])+', '\\1', result)
406 return result.strip()
409 def extension_from_mimetype(mimetype):
411 Simply guesses what the file extension should be from the mimetype
413 return mimetypes.guess_extension(mimetype) or ''
415 def filename_from_url(url):
417 Extracts the filename and (lowercase) extension (with dot)
418 from a URL, e.g. http://server.com/file.MP3?download=yes
419 will result in the string ("file", ".mp3") being returned.
421 This function will also try to best-guess the "real"
422 extension for a media file (audio, video) by
423 trying to match an extension to these types and recurse
424 into the query string to find better matches, if the
425 original extension does not resolve to a known type.
427 http://my.net/redirect.php?my.net/file.ogg => ("file", ".ogg")
428 http://server/get.jsp?file=/episode0815.MOV => ("episode0815", ".mov")
429 http://s/redirect.mp4?http://serv2/test.mp4 => ("test", ".mp4")
431 (scheme, netloc, path, para, query, fragid) = urlparse.urlparse(url)
432 (filename, extension) = os.path.splitext(os.path.basename( urllib.unquote(path)))
434 if file_type_by_extension(extension) is not None and not \
435 query.startswith(scheme+'://'):
436 # We have found a valid extension (audio, video)
437 # and the query string doesn't look like a URL
438 return ( filename, extension.lower() )
440 # If the query string looks like a possible URL, try that first
441 if len(query.strip()) > 0 and query.find('/') != -1:
442 query_url = '://'.join((scheme, urllib.unquote(query)))
443 (query_filename, query_extension) = filename_from_url(query_url)
445 if file_type_by_extension(query_extension) is not None:
446 return os.path.splitext(os.path.basename(query_url))
448 # No exact match found, simply return the original filename & extension
449 return ( filename, extension.lower() )
452 def file_type_by_extension( extension):
454 Tries to guess the file type by looking up the filename
455 extension from a table of known file types. Will return
456 the type as string ("audio" or "video") or
457 None if the file type cannot be determined.
459 types = {
460 'audio': [ 'mp3', 'ogg', 'wav', 'wma', 'aac', 'm4a' ],
461 'video': [ 'mp4', 'avi', 'mpg', 'mpeg', 'm4v', 'mov', 'divx', 'flv', 'wmv', '3gp' ],
464 if extension == '':
465 return None
467 if extension[0] == '.':
468 extension = extension[1:]
470 extension = extension.lower()
472 for type in types:
473 if extension in types[type]:
474 return type
476 return None
479 def get_tree_icon(icon_name, add_bullet=False, add_padlock=False, add_missing=False, icon_cache=None, icon_size=32):
481 Loads an icon from the current icon theme at the specified
482 size, suitable for display in a gtk.TreeView.
484 Optionally adds a green bullet (the GTK Stock "Yes" icon)
485 to the Pixbuf returned. Also, a padlock icon can be added.
487 If an icon_cache parameter is supplied, it has to be a
488 dictionary and will be used to store generated icons.
490 On subsequent calls, icons will be loaded from cache if
491 the cache is supplied again and the icon is found in
492 the cache.
494 global ICON_UNPLAYED, ICON_LOCKED, ICON_MISSING
496 if icon_cache is not None and (icon_name,add_bullet,add_padlock,icon_size) in icon_cache:
497 return icon_cache[(icon_name,add_bullet,add_padlock,icon_size)]
499 icon_theme = gtk.icon_theme_get_default()
501 try:
502 icon = icon_theme.load_icon(icon_name, icon_size, 0)
503 except:
504 log( '(get_tree_icon) Warning: Cannot load icon with name "%s", will use default icon.', icon_name)
505 icon = icon_theme.load_icon(gtk.STOCK_DIALOG_QUESTION, icon_size, 0)
507 if icon and (add_bullet or add_padlock or add_missing):
508 # We'll modify the icon, so use .copy()
509 if add_missing:
510 log('lalala')
511 try:
512 icon = icon.copy()
513 emblem = icon_theme.load_icon(ICON_MISSING, int(float(icon_size)*1.2/3.0), 0)
514 (width, height) = (emblem.get_width(), emblem.get_height())
515 xpos = icon.get_width() - width
516 ypos = icon.get_height() - height
517 emblem.composite(icon, xpos, ypos, width, height, xpos, ypos, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
518 except:
519 log('(get_tree_icon) Error adding emblem to icon "%s".', icon_name)
520 elif add_bullet:
521 try:
522 icon = icon.copy()
523 emblem = icon_theme.load_icon(ICON_UNPLAYED, int(float(icon_size)*1.2/3.0), 0)
524 (width, height) = (emblem.get_width(), emblem.get_height())
525 xpos = icon.get_width() - width
526 ypos = icon.get_height() - height
527 emblem.composite(icon, xpos, ypos, width, height, xpos, ypos, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
528 except:
529 log('(get_tree_icon) Error adding emblem to icon "%s".', icon_name)
530 if add_padlock:
531 try:
532 icon = icon.copy()
533 emblem = icon_theme.load_icon(ICON_LOCKED, int(float(icon_size)/2.0), 0)
534 (width, height) = (emblem.get_width(), emblem.get_height())
535 emblem.composite(icon, 0, 0, width, height, 0, 0, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
536 except:
537 log('(get_tree_icon) Error adding emblem to icon "%s".', icon_name)
539 if icon_cache is not None:
540 icon_cache[(icon_name,add_bullet,add_padlock,icon_size)] = icon
542 return icon
545 def get_first_line( s):
547 Returns only the first line of a string, stripped so
548 that it doesn't have whitespace before or after.
550 return s.strip().split('\n')[0].strip()
553 def object_string_formatter( s, **kwargs):
555 Makes attributes of object passed in as keyword
556 arguments available as {OBJECTNAME.ATTRNAME} in
557 the passed-in string and returns a string with
558 the above arguments replaced with the attribute
559 values of the corresponding object.
561 Example:
563 e = Episode()
564 e.title = 'Hello'
565 s = '{episode.title} World'
567 print object_string_formatter( s, episode = e)
568 => 'Hello World'
570 result = s
571 for ( key, o ) in kwargs.items():
572 matches = re.findall( r'\{%s\.([^\}]+)\}' % key, s)
573 for attr in matches:
574 if hasattr( o, attr):
575 try:
576 from_s = '{%s.%s}' % ( key, attr )
577 to_s = getattr( o, attr)
578 result = result.replace( from_s, to_s)
579 except:
580 log( 'Could not replace attribute "%s" in string "%s".', attr, s)
582 return result
585 def format_desktop_command( command, filename):
587 Formats a command template from the "Exec=" line of a .desktop
588 file to a string that can be invoked in a shell.
590 Handled format strings: %U, %u, %F, %f and a fallback that
591 appends the filename as first parameter of the command.
593 See http://standards.freedesktop.org/desktop-entry-spec/1.0/ar01s06.html
595 if '://' in filename:
596 filename_url = filename
597 else:
598 filename_url = 'file://%s' % filename
600 items = {
601 '%U': filename_url,
602 '%u': filename_url,
603 '%F': filename,
604 '%f': filename,
607 for key, value in items.items():
608 if command.find( key) >= 0:
609 return command.replace( key, value)
611 return '%s "%s"' % ( command, filename )
614 def find_command( command):
616 Searches the system's PATH for a specific command that is
617 executable by the user. Returns the first occurence of an
618 executable binary in the PATH, or None if the command is
619 not available.
622 if 'PATH' not in os.environ:
623 return None
625 for path in os.environ['PATH'].split( os.pathsep):
626 command_file = os.path.join( path, command)
627 if os.path.isfile( command_file) and os.access( command_file, os.X_OK):
628 return command_file
630 return None
633 def parse_itunes_xml(url):
635 Parses an XML document in the "url" parameter (this has to be
636 a itms:// or http:// URL to a XML doc) and searches all "<dict>"
637 elements for the first occurence of a "<key>feedURL</key>"
638 element and then continues the search for the string value of
639 this key.
641 This returns the RSS feed URL for Apple iTunes Podcast XML
642 documents that are retrieved by itunes_discover_rss().
644 url = url.replace('itms://', 'http://')
645 doc = http_get_and_gunzip(url)
646 try:
647 d = xml.dom.minidom.parseString(doc)
648 except Exception, e:
649 log('Error parsing document from itms:// URL: %s', e)
650 return None
651 last_key = None
652 for pairs in d.getElementsByTagName('dict'):
653 for node in pairs.childNodes:
654 if node.nodeType != node.ELEMENT_NODE:
655 continue
657 if node.tagName == 'key' and node.childNodes.length > 0:
658 if node.firstChild.nodeType == node.TEXT_NODE:
659 last_key = node.firstChild.data
661 if last_key != 'feedURL':
662 continue
664 if node.tagName == 'string' and node.childNodes.length > 0:
665 if node.firstChild.nodeType == node.TEXT_NODE:
666 return node.firstChild.data
668 return None
671 def http_get_and_gunzip(uri):
673 Does a HTTP GET request and tells the server that we accept
674 gzip-encoded data. This is necessary, because the Apple iTunes
675 server will always return gzip-encoded data, regardless of what
676 we really request.
678 Returns the uncompressed document at the given URI.
680 request = urllib2.Request(uri)
681 request.add_header("Accept-encoding", "gzip")
682 usock = urllib2.urlopen(request)
683 data = usock.read()
684 if usock.headers.get('content-encoding', None) == 'gzip':
685 data = gzip.GzipFile(fileobj=StringIO.StringIO(data)).read()
686 return data
689 def itunes_discover_rss(url):
691 Takes an iTunes-specific podcast URL and turns it
692 into a "normal" RSS feed URL. If the given URL is
693 not a phobos.apple.com URL, we will simply return
694 the URL and assume it's already an RSS feed URL.
696 Idea from Andrew Clarke's itunes-url-decoder.py
699 if url is None:
700 return url
702 if not 'phobos.apple.com' in url.lower():
703 # This doesn't look like an iTunes URL
704 return url
706 try:
707 data = http_get_and_gunzip(url)
708 (url,) = re.findall("itmsOpen\('([^']*)", data)
709 return parse_itunes_xml(url)
710 except:
711 return None
714 def idle_add(func, *args):
716 This is a wrapper function that does the Right
717 Thing depending on if we are running a GTK+ GUI or
718 not. If not, we're simply calling the function.
720 If we are a GUI app, we use gobject.idle_add() to
721 call the function later - this is needed for
722 threads to be able to modify GTK+ widget data.
724 if gpodder.interface in (gpodder.GUI, gpodder.MAEMO):
725 def x(f, *a):
726 f(*a)
727 return False
729 gobject.idle_add(func, *args)
730 else:
731 func(*args)
734 def discover_bluetooth_devices():
736 This is a generator function that returns
737 (address, name) tuples of all nearby bluetooth
738 devices found.
740 If the user has python-bluez installed, it will
741 be used. If not, we're trying to use "hcitool".
743 If neither python-bluez or hcitool are available,
744 this function is the empty generator.
746 try:
747 # If the user has python-bluez installed
748 import bluetooth
749 log('Using python-bluez to find nearby bluetooth devices')
750 for name, addr in bluetooth.discover_devices(lookup_names=True):
751 yield (name, addr)
752 except:
753 if find_command('hcitool') is not None:
754 log('Using hcitool to find nearby bluetooth devices')
755 # If the user has "hcitool" installed
756 p = subprocess.Popen(['hcitool', 'scan'], stdout=subprocess.PIPE)
757 for line in p.stdout:
758 match = re.match('^\t([^\t]+)\t([^\t]+)\n$', line)
759 if match is not None:
760 (addr, name) = match.groups()
761 yield (name, addr)
762 else:
763 log('Cannot find either python-bluez or hcitool - no bluetooth?')
764 return # <= empty generator
767 def bluetooth_available():
769 Returns True or False depending on the availability
770 of bluetooth functionality on the system.
772 if find_command('bluetooth-sendto'):
773 return True
774 elif find_command('gnome-obex-send'):
775 return True
776 else:
777 return False
780 def bluetooth_send_file(filename, device=None, callback_finished=None):
782 Sends a file via bluetooth using gnome-obex send.
783 Optional parameter device is the bluetooth address
784 of the device; optional parameter callback_finished
785 is a callback function that will be called when the
786 sending process has finished - it gets one parameter
787 that is either True (when sending succeeded) or False
788 when there was some error.
790 This function tries to use "bluetooth-sendto", and if
791 it is not available, it also tries "gnome-obex-send".
793 command_line = None
795 if find_command('bluetooth-sendto'):
796 command_line = ['bluetooth-sendto']
797 if device is not None:
798 command_line.append('--device=%s' % device)
799 elif find_command('gnome-obex-send'):
800 command_line = ['gnome-obex-send']
801 if device is not None:
802 command_line += ['--dest', device]
804 if command_line is not None:
805 command_line.append(filename)
806 result = (subprocess.Popen(command_line).wait() == 0)
807 if callback_finished is not None:
808 callback_finished(result)
809 return result
810 else:
811 log('Cannot send file. Please install "bluetooth-sendto" or "gnome-obex-send".')
812 if callback_finished is not None:
813 callback_finished(False)
814 return False
817 def format_seconds_to_hour_min_sec(seconds):
819 Take the number of seconds and format it into a
820 human-readable string (duration).
822 >>> format_seconds_to_hour_min_sec(3834)
823 '1 hour, 3 minutes and 54 seconds'
824 >>> format_seconds_to_hour_min_sec(2600)
825 '1 hour'
826 >>> format_seconds_to_hour_min_sec(62)
827 '1 minute and 2 seconds'
830 if seconds < 1:
831 return _('0 seconds')
833 result = []
835 hours = seconds/3600
836 seconds = seconds%3600
838 minutes = seconds/60
839 seconds = seconds%60
841 if hours == 1:
842 result.append(_('1 hour'))
843 elif hours > 1:
844 result.append(_('%i hours') % hours)
846 if minutes == 1:
847 result.append(_('1 minute'))
848 elif minutes > 1:
849 result.append(_('%i minutes') % minutes)
851 if seconds == 1:
852 result.append(_('1 second'))
853 elif seconds > 1:
854 result.append(_('%i seconds') % seconds)
856 if len(result) > 1:
857 return (' '+_('and')+' ').join((', '.join(result[:-1]), result[-1]))
858 else:
859 return result[0]
861 def proxy_request(url, proxy=None, method='HEAD'):
862 if proxy is None or proxy.strip() == '':
863 (scheme, netloc, path, parms, qry, fragid) = urlparse.urlparse(url)
864 conn = httplib.HTTPConnection(netloc)
865 start = len(scheme) + len('://') + len(netloc)
866 conn.request(method, url[start:])
867 else:
868 (scheme, netloc, path, parms, qry, fragid) = urlparse.urlparse(proxy)
869 conn = httplib.HTTPConnection(netloc)
870 conn.request(method, url)
872 return conn.getresponse()
874 def get_episode_info_from_url(url, proxy=None):
876 Try to get information about a podcast episode by sending
877 a HEAD request to the HTTP server and parsing the result.
879 The return value is a dict containing all fields that
880 could be parsed from the URL. This currently contains:
882 "length": The size of the file in bytes
883 "pubdate": The unix timestamp for the pubdate
885 If the "proxy" parameter is used, it has to be the URL
886 of the HTTP proxy server to use, e.g. http://proxy:8080/
888 If there is an error, this function returns {}. This will
889 only function with http:// and https:// URLs.
891 if not (url.startswith('http://') or url.startswith('https://')):
892 return {}
894 r = proxy_request(url, proxy)
895 result = {}
897 log('Trying to get metainfo for %s', url)
899 if 'content-length' in r.msg:
900 try:
901 length = int(r.msg['content-length'])
902 result['length'] = length
903 except ValueError, e:
904 log('Error converting content-length header.')
906 if 'last-modified' in r.msg:
907 try:
908 parsed_date = feedparser._parse_date(r.msg['last-modified'])
909 pubdate = time.mktime(parsed_date)
910 result['pubdate'] = pubdate
911 except:
912 log('Error converting last-modified header.')
914 return result
917 def gui_open(filename):
919 Open a file or folder with the default application set
920 by the Desktop environment. This uses "xdg-open".
922 try:
923 subprocess.Popen(['xdg-open', filename])
924 # FIXME: Win32-specific "open" code needed here
925 # as fallback when xdg-open not available
926 return True
927 except:
928 log('Cannot open file/folder: "%s"', filename, sender=self, traceback=True)
929 return False
932 def open_website(url):
934 Opens the specified URL using the default system web
935 browser. This uses Python's "webbrowser" module, so
936 make sure your system is set up correctly.
938 threading.Thread(target=webbrowser.open, args=(url,)).start()
940 def sanitize_encoding(filename):
942 Generate a sanitized version of a string (i.e.
943 remove invalid characters and encode in the
944 detected native language encoding)
946 global encoding
947 return filename.encode(encoding, 'ignore')
950 def sanitize_filename(filename, max_length=0, use_ascii=False):
952 Generate a sanitized version of a filename that can
953 be written on disk (i.e. remove/replace invalid
954 characters and encode in the native language) and
955 trim filename if greater than max_length (0 = no limit).
957 If use_ascii is True, don't encode in the native language,
958 but use only characters from the ASCII character set.
960 if max_length > 0 and len(filename) > max_length:
961 log('Limiting file/folder name "%s" to %d characters.', filename, max_length)
962 filename = filename[:max_length]
964 global encoding
965 if use_ascii:
966 e = 'ascii'
967 else:
968 e = encoding
969 return re.sub('[/|?*<>:+\[\]\"\\\]', '_', filename.strip().encode(e, 'ignore'))
972 def find_mount_point(directory):
974 Try to find the mount point for a given directory.
975 If the directory is itself a mount point, return
976 it. If not, remove the last part of the path and
977 re-check if it's a mount point. If the directory
978 resides on your root filesystem, "/" is returned.
980 while os.path.split(directory)[0] != '/':
981 if os.path.ismount(directory):
982 return directory
983 else:
984 (directory, tail_data) = os.path.split(directory)
986 return '/'
989 def resize_pixbuf_keep_ratio(pixbuf, max_width, max_height, key=None, cache=None):
991 Resizes a GTK Pixbuf but keeps its aspect ratio.
993 Returns None if the pixbuf does not need to be
994 resized or the newly resized pixbuf if it does.
996 The optional parameter "key" is used to identify
997 the image in the "cache", which is a dict-object
998 that holds already-resized pixbufs to access.
1000 changed = False
1002 if cache is not None:
1003 if (key, max_width, max_height) in cache:
1004 return cache[(key, max_width, max_height)]
1006 # Resize if too wide
1007 if pixbuf.get_width() > max_width:
1008 f = float(max_width)/pixbuf.get_width()
1009 (width, height) = (int(pixbuf.get_width()*f), int(pixbuf.get_height()*f))
1010 pixbuf = pixbuf.scale_simple(width, height, gtk.gdk.INTERP_BILINEAR)
1011 changed = True
1013 # Resize if too high
1014 if pixbuf.get_height() > max_height:
1015 f = float(max_height)/pixbuf.get_height()
1016 (width, height) = (int(pixbuf.get_width()*f), int(pixbuf.get_height()*f))
1017 pixbuf = pixbuf.scale_simple(width, height, gtk.gdk.INTERP_BILINEAR)
1018 changed = True
1020 if changed:
1021 result = pixbuf
1022 if cache is not None:
1023 cache[(key, max_width, max_height)] = result
1024 else:
1025 result = None
1027 return result