M3U write support for Sandisk Sansa (bug 251)
[gpodder.git] / src / gpodder / util.py
blob2c7fea4b670f4e267b04ddf982ff502fa4ef9f7f
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 # Assume HTTP for URLs without scheme
124 if not '://' in url:
125 url = 'http://' + url
127 # The scheme of the URL should be all-lowercase
128 (scheme, rest) = url.split('://', 1)
129 scheme = scheme.lower()
131 # Remember to parse iTunes XML for itms:// URLs
132 do_parse_itunes_xml = (scheme == 'itms')
134 # feed://, itpc:// and itms:// are really http://
135 if scheme in ('feed', 'itpc', 'itms'):
136 scheme = 'http'
138 # Re-assemble our URL
139 url = scheme + '://' + rest
141 # If we had an itms:// URL, parse XML
142 if do_parse_itunes_xml:
143 url = parse_itunes_xml(url)
145 # Links to "phobos.apple.com"
146 url = itunes_discover_rss(url)
148 if scheme in ('http', 'https', 'ftp'):
149 return url
151 return None
154 def username_password_from_url( url):
156 Returns a tuple (username,password) containing authentication
157 data from the specified URL or (None,None) if no authentication
158 data can be found in the URL.
160 (username, password) = (None, None)
162 (scheme, netloc, path, params, query, fragment) = urlparse.urlparse( url)
164 if '@' in netloc:
165 (authentication, netloc) = netloc.rsplit('@', 1)
166 if ':' in authentication:
167 (username, password) = authentication.split(':', 1)
168 username = urllib.unquote(username)
169 password = urllib.unquote(password)
170 else:
171 username = urllib.unquote(authentication)
173 return (username, password)
176 def directory_is_writable( path):
178 Returns True if the specified directory exists and is writable
179 by the current user.
181 return os.path.isdir( path) and os.access( path, os.W_OK)
184 def calculate_size( path):
186 Tries to calculate the size of a directory, including any
187 subdirectories found. The returned value might not be
188 correct if the user doesn't have appropriate permissions
189 to list all subdirectories of the given path.
191 if path is None:
192 return 0L
194 if os.path.dirname( path) == '/':
195 return 0L
197 if os.path.isfile( path):
198 return os.path.getsize( path)
200 if os.path.isdir( path) and not os.path.islink( path):
201 sum = os.path.getsize( path)
203 try:
204 for item in os.listdir(path):
205 try:
206 sum += calculate_size(os.path.join(path, item))
207 except:
208 log('Cannot get size for %s', path)
209 except:
210 log('Cannot access: %s', path)
212 return sum
214 return 0L
217 def file_modification_datetime(filename):
219 Returns the modification date of the specified file
220 as a datetime.datetime object or None if the modification
221 date cannot be determined.
223 if filename is None:
224 return None
226 if not os.access(filename, os.R_OK):
227 return None
229 try:
230 s = os.stat(filename)
231 timestamp = s[stat.ST_MTIME]
232 return datetime.datetime.fromtimestamp(timestamp)
233 except:
234 log('Cannot get modification timestamp for %s', filename)
235 return None
238 def file_age_in_days(filename):
240 Returns the age of the specified filename in days or
241 zero if the modification date cannot be determined.
243 dt = file_modification_datetime(filename)
244 if dt is None:
245 return 0
246 else:
247 return (datetime.datetime.now()-dt).days
250 def file_age_to_string(days):
252 Converts a "number of days" value to a string that
253 can be used in the UI to display the file age.
255 >>> file_age_to_string(0)
257 >>> file_age_to_string(1)
258 'one day ago'
259 >>> file_age_to_String(2)
260 '2 days ago'
262 if days == 1:
263 return _('one day ago')
264 elif days > 1:
265 return _('%d days ago') % days
266 else:
267 return ''
270 def get_free_disk_space(path):
272 Calculates the free disk space available to the current user
273 on the file system that contains the given path.
275 If the path (or its parent folder) does not yet exist, this
276 function returns zero.
279 if not os.path.exists(path):
280 return 0
282 s = os.statvfs(path)
284 return s.f_bavail * s.f_bsize
287 def format_date(timestamp):
289 Converts a UNIX timestamp to a date representation. This
290 function returns "Today", "Yesterday", a weekday name or
291 the date in %x format, which (according to the Python docs)
292 is the "Locale's appropriate date representation".
294 Returns None if there has been an error converting the
295 timestamp to a string representation.
297 if timestamp is None:
298 return None
300 seconds_in_a_day = 60*60*24
302 today = time.localtime()[:3]
303 yesterday = time.localtime(time.time() - seconds_in_a_day)[:3]
304 timestamp_date = time.localtime(timestamp)[:3]
306 if timestamp_date == today:
307 return _('Today')
308 elif timestamp_date == yesterday:
309 return _('Yesterday')
311 try:
312 diff = int( (time.time() - timestamp)/seconds_in_a_day )
313 except:
314 log('Warning: Cannot convert "%s" to date.', timestamp, traceback=True)
315 return None
317 if diff < 7:
318 # Weekday name
319 return str(datetime.datetime.fromtimestamp(timestamp).strftime('%A'))
320 else:
321 # Locale's appropriate date representation
322 return str(datetime.datetime.fromtimestamp(timestamp).strftime('%x'))
325 def format_filesize(bytesize, use_si_units=False, digits=2):
327 Formats the given size in bytes to be human-readable,
329 Returns a localized "(unknown)" string when the bytesize
330 has a negative value.
332 si_units = (
333 ( 'kB', 10**3 ),
334 ( 'MB', 10**6 ),
335 ( 'GB', 10**9 ),
338 binary_units = (
339 ( 'KiB', 2**10 ),
340 ( 'MiB', 2**20 ),
341 ( 'GiB', 2**30 ),
344 try:
345 bytesize = float( bytesize)
346 except:
347 return _('(unknown)')
349 if bytesize < 0:
350 return _('(unknown)')
352 if use_si_units:
353 units = si_units
354 else:
355 units = binary_units
357 ( used_unit, used_value ) = ( 'B', bytesize )
359 for ( unit, value ) in units:
360 if bytesize >= value:
361 used_value = bytesize / float(value)
362 used_unit = unit
364 return ('%.'+str(digits)+'f %s') % (used_value, used_unit)
367 def delete_file( path):
369 Tries to delete the given filename and silently
370 ignores deletion errors (if the file doesn't exist).
371 Also deletes extracted cover files if they exist.
373 log( 'Trying to delete: %s', path)
374 try:
375 os.unlink( path)
376 # Remove any extracted cover art that might exist
377 for cover_file in glob.glob( '%s.cover.*' % ( path, )):
378 os.unlink( cover_file)
380 except:
381 pass
385 def remove_html_tags(html):
387 Remove HTML tags from a string and replace numeric and
388 named entities with the corresponding character, so the
389 HTML text can be displayed in a simple text view.
391 # If we would want more speed, we could make these global
392 re_strip_tags = re.compile('<[^>]*>')
393 re_unicode_entities = re.compile('&#(\d{2,4});')
394 re_html_entities = re.compile('&(.{2,8});')
395 re_newline_tags = re.compile('(<br[^>]*>|<[/]?ul[^>]*>|</li>)', re.I)
396 re_listing_tags = re.compile('<li[^>]*>', re.I)
398 result = html
400 # Convert common HTML elements to their text equivalent
401 result = re_newline_tags.sub('\n', result)
402 result = re_listing_tags.sub('\n * ', result)
403 result = re.sub('<[Pp]>', '\n\n', result)
405 # Remove all HTML/XML tags from the string
406 result = re_strip_tags.sub('', result)
408 # Convert numeric XML entities to their unicode character
409 result = re_unicode_entities.sub(lambda x: unichr(int(x.group(1))), result)
411 # Convert named HTML entities to their unicode character
412 result = re_html_entities.sub(lambda x: unicode(entitydefs.get(x.group(1),''), 'iso-8859-1'), result)
414 # Convert more than two newlines to two newlines
415 result = re.sub('([\r\n]{2})([\r\n])+', '\\1', result)
417 return result.strip()
420 def extension_from_mimetype(mimetype):
422 Simply guesses what the file extension should be from the mimetype
424 return mimetypes.guess_extension(mimetype) or ''
426 def filename_from_url(url):
428 Extracts the filename and (lowercase) extension (with dot)
429 from a URL, e.g. http://server.com/file.MP3?download=yes
430 will result in the string ("file", ".mp3") being returned.
432 This function will also try to best-guess the "real"
433 extension for a media file (audio, video) by
434 trying to match an extension to these types and recurse
435 into the query string to find better matches, if the
436 original extension does not resolve to a known type.
438 http://my.net/redirect.php?my.net/file.ogg => ("file", ".ogg")
439 http://server/get.jsp?file=/episode0815.MOV => ("episode0815", ".mov")
440 http://s/redirect.mp4?http://serv2/test.mp4 => ("test", ".mp4")
442 (scheme, netloc, path, para, query, fragid) = urlparse.urlparse(url)
443 (filename, extension) = os.path.splitext(os.path.basename( urllib.unquote(path)))
445 if file_type_by_extension(extension) is not None and not \
446 query.startswith(scheme+'://'):
447 # We have found a valid extension (audio, video)
448 # and the query string doesn't look like a URL
449 return ( filename, extension.lower() )
451 # If the query string looks like a possible URL, try that first
452 if len(query.strip()) > 0 and query.find('/') != -1:
453 query_url = '://'.join((scheme, urllib.unquote(query)))
454 (query_filename, query_extension) = filename_from_url(query_url)
456 if file_type_by_extension(query_extension) is not None:
457 return os.path.splitext(os.path.basename(query_url))
459 # No exact match found, simply return the original filename & extension
460 return ( filename, extension.lower() )
463 def file_type_by_extension( extension):
465 Tries to guess the file type by looking up the filename
466 extension from a table of known file types. Will return
467 the type as string ("audio" or "video") or
468 None if the file type cannot be determined.
470 types = {
471 'audio': [ 'mp3', 'ogg', 'wav', 'wma', 'aac', 'm4a' ],
472 'video': [ 'mp4', 'avi', 'mpg', 'mpeg', 'm4v', 'mov', 'divx', 'flv', 'wmv', '3gp' ],
475 if extension == '':
476 return None
478 if extension[0] == '.':
479 extension = extension[1:]
481 extension = extension.lower()
483 for type in types:
484 if extension in types[type]:
485 return type
487 return None
490 def get_tree_icon(icon_name, add_bullet=False, add_padlock=False, add_missing=False, icon_cache=None, icon_size=32):
492 Loads an icon from the current icon theme at the specified
493 size, suitable for display in a gtk.TreeView.
495 Optionally adds a green bullet (the GTK Stock "Yes" icon)
496 to the Pixbuf returned. Also, a padlock icon can be added.
498 If an icon_cache parameter is supplied, it has to be a
499 dictionary and will be used to store generated icons.
501 On subsequent calls, icons will be loaded from cache if
502 the cache is supplied again and the icon is found in
503 the cache.
505 global ICON_UNPLAYED, ICON_LOCKED, ICON_MISSING
507 if icon_cache is not None and (icon_name,add_bullet,add_padlock,icon_size) in icon_cache:
508 return icon_cache[(icon_name,add_bullet,add_padlock,icon_size)]
510 icon_theme = gtk.icon_theme_get_default()
512 try:
513 icon = icon_theme.load_icon(icon_name, icon_size, 0)
514 except:
515 log( '(get_tree_icon) Warning: Cannot load icon with name "%s", will use default icon.', icon_name)
516 icon = icon_theme.load_icon(gtk.STOCK_DIALOG_QUESTION, icon_size, 0)
518 if icon and (add_bullet or add_padlock or add_missing):
519 # We'll modify the icon, so use .copy()
520 if add_missing:
521 log('lalala')
522 try:
523 icon = icon.copy()
524 emblem = icon_theme.load_icon(ICON_MISSING, int(float(icon_size)*1.2/3.0), 0)
525 (width, height) = (emblem.get_width(), emblem.get_height())
526 xpos = icon.get_width() - width
527 ypos = icon.get_height() - height
528 emblem.composite(icon, xpos, ypos, width, height, xpos, ypos, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
529 except:
530 log('(get_tree_icon) Error adding emblem to icon "%s".', icon_name)
531 elif add_bullet:
532 try:
533 icon = icon.copy()
534 emblem = icon_theme.load_icon(ICON_UNPLAYED, int(float(icon_size)*1.2/3.0), 0)
535 (width, height) = (emblem.get_width(), emblem.get_height())
536 xpos = icon.get_width() - width
537 ypos = icon.get_height() - height
538 emblem.composite(icon, xpos, ypos, width, height, xpos, ypos, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
539 except:
540 log('(get_tree_icon) Error adding emblem to icon "%s".', icon_name)
541 if add_padlock:
542 try:
543 icon = icon.copy()
544 emblem = icon_theme.load_icon(ICON_LOCKED, int(float(icon_size)/2.0), 0)
545 (width, height) = (emblem.get_width(), emblem.get_height())
546 emblem.composite(icon, 0, 0, width, height, 0, 0, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
547 except:
548 log('(get_tree_icon) Error adding emblem to icon "%s".', icon_name)
550 if icon_cache is not None:
551 icon_cache[(icon_name,add_bullet,add_padlock,icon_size)] = icon
553 return icon
556 def get_first_line( s):
558 Returns only the first line of a string, stripped so
559 that it doesn't have whitespace before or after.
561 return s.strip().split('\n')[0].strip()
564 def object_string_formatter( s, **kwargs):
566 Makes attributes of object passed in as keyword
567 arguments available as {OBJECTNAME.ATTRNAME} in
568 the passed-in string and returns a string with
569 the above arguments replaced with the attribute
570 values of the corresponding object.
572 Example:
574 e = Episode()
575 e.title = 'Hello'
576 s = '{episode.title} World'
578 print object_string_formatter( s, episode = e)
579 => 'Hello World'
581 result = s
582 for ( key, o ) in kwargs.items():
583 matches = re.findall( r'\{%s\.([^\}]+)\}' % key, s)
584 for attr in matches:
585 if hasattr( o, attr):
586 try:
587 from_s = '{%s.%s}' % ( key, attr )
588 to_s = getattr( o, attr)
589 result = result.replace( from_s, to_s)
590 except:
591 log( 'Could not replace attribute "%s" in string "%s".', attr, s)
593 return result
596 def format_desktop_command( command, filename):
598 Formats a command template from the "Exec=" line of a .desktop
599 file to a string that can be invoked in a shell.
601 Handled format strings: %U, %u, %F, %f and a fallback that
602 appends the filename as first parameter of the command.
604 See http://standards.freedesktop.org/desktop-entry-spec/1.0/ar01s06.html
606 if '://' in filename:
607 filename_url = filename
608 else:
609 filename_url = 'file://%s' % filename
611 items = {
612 '%U': filename_url,
613 '%u': filename_url,
614 '%F': filename,
615 '%f': filename,
618 for key, value in items.items():
619 if command.find( key) >= 0:
620 return command.replace( key, value)
622 return '%s "%s"' % ( command, filename )
625 def find_command( command):
627 Searches the system's PATH for a specific command that is
628 executable by the user. Returns the first occurence of an
629 executable binary in the PATH, or None if the command is
630 not available.
633 if 'PATH' not in os.environ:
634 return None
636 for path in os.environ['PATH'].split( os.pathsep):
637 command_file = os.path.join( path, command)
638 if os.path.isfile( command_file) and os.access( command_file, os.X_OK):
639 return command_file
641 return None
644 def parse_itunes_xml(url):
646 Parses an XML document in the "url" parameter (this has to be
647 a itms:// or http:// URL to a XML doc) and searches all "<dict>"
648 elements for the first occurence of a "<key>feedURL</key>"
649 element and then continues the search for the string value of
650 this key.
652 This returns the RSS feed URL for Apple iTunes Podcast XML
653 documents that are retrieved by itunes_discover_rss().
655 url = url.replace('itms://', 'http://')
656 doc = http_get_and_gunzip(url)
657 try:
658 d = xml.dom.minidom.parseString(doc)
659 except Exception, e:
660 log('Error parsing document from itms:// URL: %s', e)
661 return None
662 last_key = None
663 for pairs in d.getElementsByTagName('dict'):
664 for node in pairs.childNodes:
665 if node.nodeType != node.ELEMENT_NODE:
666 continue
668 if node.tagName == 'key' and node.childNodes.length > 0:
669 if node.firstChild.nodeType == node.TEXT_NODE:
670 last_key = node.firstChild.data
672 if last_key != 'feedURL':
673 continue
675 if node.tagName == 'string' and node.childNodes.length > 0:
676 if node.firstChild.nodeType == node.TEXT_NODE:
677 return node.firstChild.data
679 return None
682 def http_get_and_gunzip(uri):
684 Does a HTTP GET request and tells the server that we accept
685 gzip-encoded data. This is necessary, because the Apple iTunes
686 server will always return gzip-encoded data, regardless of what
687 we really request.
689 Returns the uncompressed document at the given URI.
691 request = urllib2.Request(uri)
692 request.add_header("Accept-encoding", "gzip")
693 usock = urllib2.urlopen(request)
694 data = usock.read()
695 if usock.headers.get('content-encoding', None) == 'gzip':
696 data = gzip.GzipFile(fileobj=StringIO.StringIO(data)).read()
697 return data
700 def itunes_discover_rss(url):
702 Takes an iTunes-specific podcast URL and turns it
703 into a "normal" RSS feed URL. If the given URL is
704 not a phobos.apple.com URL, we will simply return
705 the URL and assume it's already an RSS feed URL.
707 Idea from Andrew Clarke's itunes-url-decoder.py
710 if url is None:
711 return url
713 if not 'phobos.apple.com' in url.lower():
714 # This doesn't look like an iTunes URL
715 return url
717 try:
718 data = http_get_and_gunzip(url)
719 (url,) = re.findall("itmsOpen\('([^']*)", data)
720 return parse_itunes_xml(url)
721 except:
722 return None
725 def idle_add(func, *args):
727 This is a wrapper function that does the Right
728 Thing depending on if we are running a GTK+ GUI or
729 not. If not, we're simply calling the function.
731 If we are a GUI app, we use gobject.idle_add() to
732 call the function later - this is needed for
733 threads to be able to modify GTK+ widget data.
735 if gpodder.interface in (gpodder.GUI, gpodder.MAEMO):
736 def x(f, *a):
737 f(*a)
738 return False
740 gobject.idle_add(func, *args)
741 else:
742 func(*args)
745 def discover_bluetooth_devices():
747 This is a generator function that returns
748 (address, name) tuples of all nearby bluetooth
749 devices found.
751 If the user has python-bluez installed, it will
752 be used. If not, we're trying to use "hcitool".
754 If neither python-bluez or hcitool are available,
755 this function is the empty generator.
757 try:
758 # If the user has python-bluez installed
759 import bluetooth
760 log('Using python-bluez to find nearby bluetooth devices')
761 for name, addr in bluetooth.discover_devices(lookup_names=True):
762 yield (name, addr)
763 except:
764 if find_command('hcitool') is not None:
765 log('Using hcitool to find nearby bluetooth devices')
766 # If the user has "hcitool" installed
767 p = subprocess.Popen(['hcitool', 'scan'], stdout=subprocess.PIPE)
768 for line in p.stdout:
769 match = re.match('^\t([^\t]+)\t([^\t]+)\n$', line)
770 if match is not None:
771 (addr, name) = match.groups()
772 yield (name, addr)
773 else:
774 log('Cannot find either python-bluez or hcitool - no bluetooth?')
775 return # <= empty generator
778 def bluetooth_available():
780 Returns True or False depending on the availability
781 of bluetooth functionality on the system.
783 if find_command('bluetooth-sendto'):
784 return True
785 elif find_command('gnome-obex-send'):
786 return True
787 else:
788 return False
791 def bluetooth_send_file(filename, device=None, callback_finished=None):
793 Sends a file via bluetooth using gnome-obex send.
794 Optional parameter device is the bluetooth address
795 of the device; optional parameter callback_finished
796 is a callback function that will be called when the
797 sending process has finished - it gets one parameter
798 that is either True (when sending succeeded) or False
799 when there was some error.
801 This function tries to use "bluetooth-sendto", and if
802 it is not available, it also tries "gnome-obex-send".
804 command_line = None
806 if find_command('bluetooth-sendto'):
807 command_line = ['bluetooth-sendto']
808 if device is not None:
809 command_line.append('--device=%s' % device)
810 elif find_command('gnome-obex-send'):
811 command_line = ['gnome-obex-send']
812 if device is not None:
813 command_line += ['--dest', device]
815 if command_line is not None:
816 command_line.append(filename)
817 result = (subprocess.Popen(command_line).wait() == 0)
818 if callback_finished is not None:
819 callback_finished(result)
820 return result
821 else:
822 log('Cannot send file. Please install "bluetooth-sendto" or "gnome-obex-send".')
823 if callback_finished is not None:
824 callback_finished(False)
825 return False
828 def format_seconds_to_hour_min_sec(seconds):
830 Take the number of seconds and format it into a
831 human-readable string (duration).
833 >>> format_seconds_to_hour_min_sec(3834)
834 '1 hour, 3 minutes and 54 seconds'
835 >>> format_seconds_to_hour_min_sec(2600)
836 '1 hour'
837 >>> format_seconds_to_hour_min_sec(62)
838 '1 minute and 2 seconds'
841 if seconds < 1:
842 return _('0 seconds')
844 result = []
846 hours = seconds/3600
847 seconds = seconds%3600
849 minutes = seconds/60
850 seconds = seconds%60
852 if hours == 1:
853 result.append(_('1 hour'))
854 elif hours > 1:
855 result.append(_('%i hours') % hours)
857 if minutes == 1:
858 result.append(_('1 minute'))
859 elif minutes > 1:
860 result.append(_('%i minutes') % minutes)
862 if seconds == 1:
863 result.append(_('1 second'))
864 elif seconds > 1:
865 result.append(_('%i seconds') % seconds)
867 if len(result) > 1:
868 return (' '+_('and')+' ').join((', '.join(result[:-1]), result[-1]))
869 else:
870 return result[0]
872 def proxy_request(url, proxy=None, method='HEAD'):
873 if proxy is None or proxy.strip() == '':
874 (scheme, netloc, path, parms, qry, fragid) = urlparse.urlparse(url)
875 conn = httplib.HTTPConnection(netloc)
876 start = len(scheme) + len('://') + len(netloc)
877 conn.request(method, url[start:])
878 else:
879 (scheme, netloc, path, parms, qry, fragid) = urlparse.urlparse(proxy)
880 conn = httplib.HTTPConnection(netloc)
881 conn.request(method, url)
883 return conn.getresponse()
885 def get_episode_info_from_url(url, proxy=None):
887 Try to get information about a podcast episode by sending
888 a HEAD request to the HTTP server and parsing the result.
890 The return value is a dict containing all fields that
891 could be parsed from the URL. This currently contains:
893 "length": The size of the file in bytes
894 "pubdate": The unix timestamp for the pubdate
896 If the "proxy" parameter is used, it has to be the URL
897 of the HTTP proxy server to use, e.g. http://proxy:8080/
899 If there is an error, this function returns {}. This will
900 only function with http:// and https:// URLs.
902 if not (url.startswith('http://') or url.startswith('https://')):
903 return {}
905 r = proxy_request(url, proxy)
906 result = {}
908 log('Trying to get metainfo for %s', url)
910 if 'content-length' in r.msg:
911 try:
912 length = int(r.msg['content-length'])
913 result['length'] = length
914 except ValueError, e:
915 log('Error converting content-length header.')
917 if 'last-modified' in r.msg:
918 try:
919 parsed_date = feedparser._parse_date(r.msg['last-modified'])
920 pubdate = time.mktime(parsed_date)
921 result['pubdate'] = pubdate
922 except:
923 log('Error converting last-modified header.')
925 return result
928 def gui_open(filename):
930 Open a file or folder with the default application set
931 by the Desktop environment. This uses "xdg-open".
933 try:
934 subprocess.Popen(['xdg-open', filename])
935 # FIXME: Win32-specific "open" code needed here
936 # as fallback when xdg-open not available
937 return True
938 except:
939 log('Cannot open file/folder: "%s"', filename, sender=self, traceback=True)
940 return False
943 def open_website(url):
945 Opens the specified URL using the default system web
946 browser. This uses Python's "webbrowser" module, so
947 make sure your system is set up correctly.
949 threading.Thread(target=webbrowser.open, args=(url,)).start()
951 def sanitize_encoding(filename):
953 Generate a sanitized version of a string (i.e.
954 remove invalid characters and encode in the
955 detected native language encoding)
957 global encoding
958 return filename.encode(encoding, 'ignore')
961 def sanitize_filename(filename, max_length=0, use_ascii=False):
963 Generate a sanitized version of a filename that can
964 be written on disk (i.e. remove/replace invalid
965 characters and encode in the native language) and
966 trim filename if greater than max_length (0 = no limit).
968 If use_ascii is True, don't encode in the native language,
969 but use only characters from the ASCII character set.
971 if max_length > 0 and len(filename) > max_length:
972 log('Limiting file/folder name "%s" to %d characters.', filename, max_length)
973 filename = filename[:max_length]
975 global encoding
976 if use_ascii:
977 e = 'ascii'
978 else:
979 e = encoding
980 return re.sub('[/|?*<>:+\[\]\"\\\]', '_', filename.strip().encode(e, 'ignore'))
983 def find_mount_point(directory):
985 Try to find the mount point for a given directory.
986 If the directory is itself a mount point, return
987 it. If not, remove the last part of the path and
988 re-check if it's a mount point. If the directory
989 resides on your root filesystem, "/" is returned.
991 while os.path.split(directory)[0] != '/':
992 if os.path.ismount(directory):
993 return directory
994 else:
995 (directory, tail_data) = os.path.split(directory)
997 return '/'
1000 def resize_pixbuf_keep_ratio(pixbuf, max_width, max_height, key=None, cache=None):
1002 Resizes a GTK Pixbuf but keeps its aspect ratio.
1004 Returns None if the pixbuf does not need to be
1005 resized or the newly resized pixbuf if it does.
1007 The optional parameter "key" is used to identify
1008 the image in the "cache", which is a dict-object
1009 that holds already-resized pixbufs to access.
1011 changed = False
1013 if cache is not None:
1014 if (key, max_width, max_height) in cache:
1015 return cache[(key, max_width, max_height)]
1017 # Resize if too wide
1018 if pixbuf.get_width() > max_width:
1019 f = float(max_width)/pixbuf.get_width()
1020 (width, height) = (int(pixbuf.get_width()*f), int(pixbuf.get_height()*f))
1021 pixbuf = pixbuf.scale_simple(width, height, gtk.gdk.INTERP_BILINEAR)
1022 changed = True
1024 # Resize if too high
1025 if pixbuf.get_height() > max_height:
1026 f = float(max_height)/pixbuf.get_height()
1027 (width, height) = (int(pixbuf.get_width()*f), int(pixbuf.get_height()*f))
1028 pixbuf = pixbuf.scale_simple(width, height, gtk.gdk.INTERP_BILINEAR)
1029 changed = True
1031 if changed:
1032 result = pixbuf
1033 if cache is not None:
1034 cache[(key, max_width, max_height)] = result
1035 else:
1036 result = None
1038 return result
1040 # matches http:// and ftp:// and mailto://
1041 protocolPattern = re.compile(r'^\w+://')
1043 def isabs(string):
1045 @return true if string is an absolute path or protocoladdress
1046 for addresses beginning in http:// or ftp:// or ldap:// -
1047 they are considered "absolute" paths.
1048 Source: http://code.activestate.com/recipes/208993/
1050 if protocolPattern.match(string): return 1
1051 return os.path.isabs(string)
1053 def rel2abs(path, base = os.curdir):
1054 """ converts a relative path to an absolute path.
1056 @param path the path to convert - if already absolute, is returned
1057 without conversion.
1058 @param base - optional. Defaults to the current directory.
1059 The base is intelligently concatenated to the given relative path.
1060 @return the relative path of path from base
1061 Source: http://code.activestate.com/recipes/208993/
1063 if isabs(path): return path
1064 retval = os.path.join(base,path)
1065 return os.path.abspath(retval)
1067 def commonpath(l1, l2, common=[]):
1069 helper functions for relpath
1070 Source: http://code.activestate.com/recipes/208993/
1072 if len(l1) < 1: return (common, l1, l2)
1073 if len(l2) < 1: return (common, l1, l2)
1074 if l1[0] != l2[0]: return (common, l1, l2)
1075 return commonpath(l1[1:], l2[1:], common+[l1[0]])
1077 def relpath(p1, p2):
1079 Finds relative path from p1 to p2
1080 Source: http://code.activestate.com/recipes/208993/
1082 pathsplit = lambda s: s.split(os.path.sep)
1084 (common,l1,l2) = commonpath(pathsplit(p1), pathsplit(p2))
1085 p = []
1086 if len(l1) > 0:
1087 p = [ ('..'+os.sep) * len(l1) ]
1088 p = p + l2
1089 if len(p) is 0:
1090 return "."
1092 return os.path.join(*p)