Properly update existing episodes (bug 211)
[gpodder.git] / src / gpodder / util.py
blob4113d993439b4a6dc3c54b162dc7b55d187a75ed
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 torrent_filename( filename):
411 Checks if a file is a ".torrent" file by examining its
412 contents and searching for the file name of the file
413 to be downloaded.
415 Returns the name of the file the ".torrent" will download
416 or None if no filename is found (the file is no ".torrent")
418 if not os.path.exists( filename):
419 return None
421 header = open( filename).readline()
422 try:
423 header.index( '6:pieces')
424 name_length_pos = header.index('4:name') + 6
426 colon_pos = header.find( ':', name_length_pos)
427 name_length = int(header[name_length_pos:colon_pos]) + 1
428 name = header[(colon_pos + 1):(colon_pos + name_length)]
429 return name
430 except:
431 return None
433 def extension_from_mimetype(mimetype):
435 Simply guesses what the file extension should be from the mimetype
437 return mimetypes.guess_extension(mimetype) or ''
439 def filename_from_url(url):
441 Extracts the filename and (lowercase) extension (with dot)
442 from a URL, e.g. http://server.com/file.MP3?download=yes
443 will result in the string ("file", ".mp3") being returned.
445 This function will also try to best-guess the "real"
446 extension for a media file (audio, video, torrent) by
447 trying to match an extension to these types and recurse
448 into the query string to find better matches, if the
449 original extension does not resolve to a known type.
451 http://my.net/redirect.php?my.net/file.ogg => ("file", ".ogg")
452 http://server/get.jsp?file=/episode0815.MOV => ("episode0815", ".mov")
453 http://s/redirect.mp4?http://serv2/test.mp4 => ("test", ".mp4")
455 (scheme, netloc, path, para, query, fragid) = urlparse.urlparse(url)
456 (filename, extension) = os.path.splitext(os.path.basename( urllib.unquote(path)))
458 if file_type_by_extension(extension) is not None and not \
459 query.startswith(scheme+'://'):
460 # We have found a valid extension (audio, video, torrent)
461 # and the query string doesn't look like a URL
462 return ( filename, extension.lower() )
464 # If the query string looks like a possible URL, try that first
465 if len(query.strip()) > 0 and query.find('/') != -1:
466 query_url = '://'.join((scheme, urllib.unquote(query)))
467 (query_filename, query_extension) = filename_from_url(query_url)
469 if file_type_by_extension(query_extension) is not None:
470 return os.path.splitext(os.path.basename(query_url))
472 # No exact match found, simply return the original filename & extension
473 return ( filename, extension.lower() )
476 def file_type_by_extension( extension):
478 Tries to guess the file type by looking up the filename
479 extension from a table of known file types. Will return
480 the type as string ("audio", "video" or "torrent") or
481 None if the file type cannot be determined.
483 types = {
484 'audio': [ 'mp3', 'ogg', 'wav', 'wma', 'aac', 'm4a' ],
485 'video': [ 'mp4', 'avi', 'mpg', 'mpeg', 'm4v', 'mov', 'divx', 'flv', 'wmv', '3gp' ],
486 'torrent': [ 'torrent' ],
489 if extension == '':
490 return None
492 if extension[0] == '.':
493 extension = extension[1:]
495 extension = extension.lower()
497 for type in types:
498 if extension in types[type]:
499 return type
501 return None
504 def get_tree_icon(icon_name, add_bullet=False, add_padlock=False, add_missing=False, icon_cache=None, icon_size=32):
506 Loads an icon from the current icon theme at the specified
507 size, suitable for display in a gtk.TreeView.
509 Optionally adds a green bullet (the GTK Stock "Yes" icon)
510 to the Pixbuf returned. Also, a padlock icon can be added.
512 If an icon_cache parameter is supplied, it has to be a
513 dictionary and will be used to store generated icons.
515 On subsequent calls, icons will be loaded from cache if
516 the cache is supplied again and the icon is found in
517 the cache.
519 global ICON_UNPLAYED, ICON_LOCKED, ICON_MISSING
521 if icon_cache is not None and (icon_name,add_bullet,add_padlock,icon_size) in icon_cache:
522 return icon_cache[(icon_name,add_bullet,add_padlock,icon_size)]
524 icon_theme = gtk.icon_theme_get_default()
526 try:
527 icon = icon_theme.load_icon(icon_name, icon_size, 0)
528 except:
529 log( '(get_tree_icon) Warning: Cannot load icon with name "%s", will use default icon.', icon_name)
530 icon = icon_theme.load_icon(gtk.STOCK_DIALOG_QUESTION, icon_size, 0)
532 if icon and (add_bullet or add_padlock or add_missing):
533 # We'll modify the icon, so use .copy()
534 if add_missing:
535 log('lalala')
536 try:
537 icon = icon.copy()
538 emblem = icon_theme.load_icon(ICON_MISSING, int(float(icon_size)*1.2/3.0), 0)
539 (width, height) = (emblem.get_width(), emblem.get_height())
540 xpos = icon.get_width() - width
541 ypos = icon.get_height() - height
542 emblem.composite(icon, xpos, ypos, width, height, xpos, ypos, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
543 except:
544 log('(get_tree_icon) Error adding emblem to icon "%s".', icon_name)
545 elif add_bullet:
546 try:
547 icon = icon.copy()
548 emblem = icon_theme.load_icon(ICON_UNPLAYED, int(float(icon_size)*1.2/3.0), 0)
549 (width, height) = (emblem.get_width(), emblem.get_height())
550 xpos = icon.get_width() - width
551 ypos = icon.get_height() - height
552 emblem.composite(icon, xpos, ypos, width, height, xpos, ypos, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
553 except:
554 log('(get_tree_icon) Error adding emblem to icon "%s".', icon_name)
555 if add_padlock:
556 try:
557 icon = icon.copy()
558 emblem = icon_theme.load_icon(ICON_LOCKED, int(float(icon_size)/2.0), 0)
559 (width, height) = (emblem.get_width(), emblem.get_height())
560 emblem.composite(icon, 0, 0, width, height, 0, 0, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
561 except:
562 log('(get_tree_icon) Error adding emblem to icon "%s".', icon_name)
564 if icon_cache is not None:
565 icon_cache[(icon_name,add_bullet,add_padlock,icon_size)] = icon
567 return icon
570 def get_first_line( s):
572 Returns only the first line of a string, stripped so
573 that it doesn't have whitespace before or after.
575 return s.strip().split('\n')[0].strip()
578 def object_string_formatter( s, **kwargs):
580 Makes attributes of object passed in as keyword
581 arguments available as {OBJECTNAME.ATTRNAME} in
582 the passed-in string and returns a string with
583 the above arguments replaced with the attribute
584 values of the corresponding object.
586 Example:
588 e = Episode()
589 e.title = 'Hello'
590 s = '{episode.title} World'
592 print object_string_formatter( s, episode = e)
593 => 'Hello World'
595 result = s
596 for ( key, o ) in kwargs.items():
597 matches = re.findall( r'\{%s\.([^\}]+)\}' % key, s)
598 for attr in matches:
599 if hasattr( o, attr):
600 try:
601 from_s = '{%s.%s}' % ( key, attr )
602 to_s = getattr( o, attr)
603 result = result.replace( from_s, to_s)
604 except:
605 log( 'Could not replace attribute "%s" in string "%s".', attr, s)
607 return result
610 def format_desktop_command( command, filename):
612 Formats a command template from the "Exec=" line of a .desktop
613 file to a string that can be invoked in a shell.
615 Handled format strings: %U, %u, %F, %f and a fallback that
616 appends the filename as first parameter of the command.
618 See http://standards.freedesktop.org/desktop-entry-spec/1.0/ar01s06.html
620 items = {
621 '%U': 'file://%s' % filename,
622 '%u': 'file://%s' % filename,
623 '%F': filename,
624 '%f': filename,
627 for key, value in items.items():
628 if command.find( key) >= 0:
629 return command.replace( key, value)
631 return '%s "%s"' % ( command, filename )
634 def find_command( command):
636 Searches the system's PATH for a specific command that is
637 executable by the user. Returns the first occurence of an
638 executable binary in the PATH, or None if the command is
639 not available.
642 if 'PATH' not in os.environ:
643 return None
645 for path in os.environ['PATH'].split( os.pathsep):
646 command_file = os.path.join( path, command)
647 if os.path.isfile( command_file) and os.access( command_file, os.X_OK):
648 return command_file
650 return None
653 def parse_itunes_xml(url):
655 Parses an XML document in the "url" parameter (this has to be
656 a itms:// or http:// URL to a XML doc) and searches all "<dict>"
657 elements for the first occurence of a "<key>feedURL</key>"
658 element and then continues the search for the string value of
659 this key.
661 This returns the RSS feed URL for Apple iTunes Podcast XML
662 documents that are retrieved by itunes_discover_rss().
664 url = url.replace('itms://', 'http://')
665 doc = http_get_and_gunzip(url)
666 try:
667 d = xml.dom.minidom.parseString(doc)
668 except Exception, e:
669 log('Error parsing document from itms:// URL: %s', e)
670 return None
671 last_key = None
672 for pairs in d.getElementsByTagName('dict'):
673 for node in pairs.childNodes:
674 if node.nodeType != node.ELEMENT_NODE:
675 continue
677 if node.tagName == 'key' and node.childNodes.length > 0:
678 if node.firstChild.nodeType == node.TEXT_NODE:
679 last_key = node.firstChild.data
681 if last_key != 'feedURL':
682 continue
684 if node.tagName == 'string' and node.childNodes.length > 0:
685 if node.firstChild.nodeType == node.TEXT_NODE:
686 return node.firstChild.data
688 return None
691 def http_get_and_gunzip(uri):
693 Does a HTTP GET request and tells the server that we accept
694 gzip-encoded data. This is necessary, because the Apple iTunes
695 server will always return gzip-encoded data, regardless of what
696 we really request.
698 Returns the uncompressed document at the given URI.
700 request = urllib2.Request(uri)
701 request.add_header("Accept-encoding", "gzip")
702 usock = urllib2.urlopen(request)
703 data = usock.read()
704 if usock.headers.get('content-encoding', None) == 'gzip':
705 data = gzip.GzipFile(fileobj=StringIO.StringIO(data)).read()
706 return data
709 def itunes_discover_rss(url):
711 Takes an iTunes-specific podcast URL and turns it
712 into a "normal" RSS feed URL. If the given URL is
713 not a phobos.apple.com URL, we will simply return
714 the URL and assume it's already an RSS feed URL.
716 Idea from Andrew Clarke's itunes-url-decoder.py
719 if url is None:
720 return url
722 if not 'phobos.apple.com' in url.lower():
723 # This doesn't look like an iTunes URL
724 return url
726 try:
727 data = http_get_and_gunzip(url)
728 (url,) = re.findall("itmsOpen\('([^']*)", data)
729 return parse_itunes_xml(url)
730 except:
731 return None
734 def idle_add(func, *args):
736 This is a wrapper function that does the Right
737 Thing depending on if we are running a GTK+ GUI or
738 not. If not, we're simply calling the function.
740 If we are a GUI app, we use gobject.idle_add() to
741 call the function later - this is needed for
742 threads to be able to modify GTK+ widget data.
744 if gpodder.interface in (gpodder.GUI, gpodder.MAEMO):
745 def x(f, *a):
746 f(*a)
747 return False
749 gobject.idle_add(func, *args)
750 else:
751 func(*args)
754 def discover_bluetooth_devices():
756 This is a generator function that returns
757 (address, name) tuples of all nearby bluetooth
758 devices found.
760 If the user has python-bluez installed, it will
761 be used. If not, we're trying to use "hcitool".
763 If neither python-bluez or hcitool are available,
764 this function is the empty generator.
766 try:
767 # If the user has python-bluez installed
768 import bluetooth
769 log('Using python-bluez to find nearby bluetooth devices')
770 for name, addr in bluetooth.discover_devices(lookup_names=True):
771 yield (name, addr)
772 except:
773 if find_command('hcitool') is not None:
774 log('Using hcitool to find nearby bluetooth devices')
775 # If the user has "hcitool" installed
776 p = subprocess.Popen(['hcitool', 'scan'], stdout=subprocess.PIPE)
777 for line in p.stdout:
778 match = re.match('^\t([^\t]+)\t([^\t]+)\n$', line)
779 if match is not None:
780 (addr, name) = match.groups()
781 yield (name, addr)
782 else:
783 log('Cannot find either python-bluez or hcitool - no bluetooth?')
784 return # <= empty generator
787 def bluetooth_send_file(filename, device=None, callback_finished=None):
789 Sends a file via bluetooth using gnome-obex send.
790 Optional parameter device is the bluetooth address
791 of the device; optional parameter callback_finished
792 is a callback function that will be called when the
793 sending process has finished - it gets one parameter
794 that is either True (when sending succeeded) or False
795 when there was some error.
797 This function tries to use "bluetooth-sendto", and if
798 it is not available, it also tries "gnome-obex-send".
800 command_line = None
802 if find_command('bluetooth-sendto'):
803 command_line = ['bluetooth-sendto']
804 if device is not None:
805 command_line.append('--device=%s' % device)
806 elif find_command('gnome-obex-send'):
807 command_line = ['gnome-obex-send']
808 if device is not None:
809 command_line += ['--dest', device]
811 if command_line is not None:
812 command_line.append(filename)
813 result = (subprocess.Popen(command_line).wait() == 0)
814 if callback_finished is not None:
815 callback_finished(result)
816 return result
817 else:
818 log('Cannot send file. Please install "bluetooth-sendto" or "gnome-obex-send".')
819 if callback_finished is not None:
820 callback_finished(False)
821 return False
824 def format_seconds_to_hour_min_sec(seconds):
826 Take the number of seconds and format it into a
827 human-readable string (duration).
829 >>> format_seconds_to_hour_min_sec(3834)
830 '1 hour, 3 minutes and 54 seconds'
831 >>> format_seconds_to_hour_min_sec(2600)
832 '1 hour'
833 >>> format_seconds_to_hour_min_sec(62)
834 '1 minute and 2 seconds'
837 if seconds < 1:
838 return _('0 seconds')
840 result = []
842 hours = seconds/3600
843 seconds = seconds%3600
845 minutes = seconds/60
846 seconds = seconds%60
848 if hours == 1:
849 result.append(_('1 hour'))
850 elif hours > 1:
851 result.append(_('%i hours') % hours)
853 if minutes == 1:
854 result.append(_('1 minute'))
855 elif minutes > 1:
856 result.append(_('%i minutes') % minutes)
858 if seconds == 1:
859 result.append(_('1 second'))
860 elif seconds > 1:
861 result.append(_('%i seconds') % seconds)
863 if len(result) > 1:
864 return (' '+_('and')+' ').join((', '.join(result[:-1]), result[-1]))
865 else:
866 return result[0]
869 def get_episode_info_from_url(url, proxy=None):
871 Try to get information about a podcast episode by sending
872 a HEAD request to the HTTP server and parsing the result.
874 The return value is a dict containing all fields that
875 could be parsed from the URL. This currently contains:
877 "length": The size of the file in bytes
878 "pubdate": The unix timestamp for the pubdate
880 If the "proxy" parameter is used, it has to be the URL
881 of the HTTP proxy server to use, e.g. http://proxy:8080/
883 If there is an error, this function returns {}. This will
884 only function with http:// and https:// URLs.
886 if not (url.startswith('http://') or url.startswith('https://')):
887 return {}
889 if proxy is None or proxy.strip() == '':
890 (scheme, netloc, path, parms, qry, fragid) = urlparse.urlparse(url)
891 conn = httplib.HTTPConnection(netloc)
892 start = len(scheme) + len('://') + len(netloc)
893 conn.request('HEAD', url[start:])
894 else:
895 (scheme, netloc, path, parms, qry, fragid) = urlparse.urlparse(proxy)
896 conn = httplib.HTTPConnection(netloc)
897 conn.request('HEAD', url)
899 r = conn.getresponse()
900 result = {}
902 log('Trying to get metainfo for %s', url)
904 if 'content-length' in r.msg:
905 try:
906 length = int(r.msg['content-length'])
907 result['length'] = length
908 except ValueError, e:
909 log('Error converting content-length header.')
911 if 'last-modified' in r.msg:
912 try:
913 parsed_date = feedparser._parse_date(r.msg['last-modified'])
914 pubdate = time.mktime(parsed_date)
915 result['pubdate'] = pubdate
916 except:
917 log('Error converting last-modified header.')
919 return result
922 def gui_open(filename):
924 Open a file or folder with the default application set
925 by the Desktop environment. This uses "xdg-open".
927 try:
928 subprocess.Popen(['xdg-open', filename])
929 # FIXME: Win32-specific "open" code needed here
930 # as fallback when xdg-open not available
931 except:
932 log('Cannot open file/folder: "%s"', filename, sender=self, traceback=True)
935 def open_website(url):
937 Opens the specified URL using the default system web
938 browser. This uses Python's "webbrowser" module, so
939 make sure your system is set up correctly.
941 threading.Thread(target=webbrowser.open, args=(url,)).start()
943 def sanitize_encoding(filename):
945 Generate a sanitized version of a string (i.e.
946 remove invalid characters and encode in the
947 detected native language encoding)
949 global encoding
950 return filename.encode(encoding, 'ignore')
953 def sanitize_filename(filename, max_length=0):
955 Generate a sanitized version of a filename that can
956 be written on disk (i.e. remove/replace invalid
957 characters and encode in the native language) and
958 trim filename if greater than max_length (0 = no limit).
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 return re.sub('[/|?*<>:+\[\]\"\\\]', '_', filename.strip().encode(encoding, 'ignore'))
968 def find_mount_point(directory):
970 Try to find the mount point for a given directory.
971 If the directory is itself a mount point, return
972 it. If not, remove the last part of the path and
973 re-check if it's a mount point. If the directory
974 resides on your root filesystem, "/" is returned.
976 while os.path.split(directory)[0] != '/':
977 if os.path.ismount(directory):
978 return directory
979 else:
980 (directory, tail_data) = os.path.split(directory)
982 return '/'
985 def resize_pixbuf_keep_ratio(pixbuf, max_width, max_height, key=None, cache=None):
987 Resizes a GTK Pixbuf but keeps its aspect ratio.
989 Returns None if the pixbuf does not need to be
990 resized or the newly resized pixbuf if it does.
992 The optional parameter "key" is used to identify
993 the image in the "cache", which is a dict-object
994 that holds already-resized pixbufs to access.
996 changed = False
998 if cache is not None:
999 if (key, max_width, max_height) in cache:
1000 return cache[(key, max_width, max_height)]
1002 # Resize if too wide
1003 if pixbuf.get_width() > max_width:
1004 f = float(max_width)/pixbuf.get_width()
1005 (width, height) = (int(pixbuf.get_width()*f), int(pixbuf.get_height()*f))
1006 pixbuf = pixbuf.scale_simple(width, height, gtk.gdk.INTERP_BILINEAR)
1007 changed = True
1009 # Resize if too high
1010 if pixbuf.get_height() > max_height:
1011 f = float(max_height)/pixbuf.get_height()
1012 (width, height) = (int(pixbuf.get_width()*f), int(pixbuf.get_height()*f))
1013 pixbuf = pixbuf.scale_simple(width, height, gtk.gdk.INTERP_BILINEAR)
1014 changed = True
1016 if changed:
1017 result = pixbuf
1018 if cache is not None:
1019 cache[(key, max_width, max_height)] = result
1020 else:
1021 result = None
1023 return result