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.
33 from gpodder
.liblogger
import log
45 from htmlentitydefs
import entitydefs
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
)
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
):
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.
94 if os
.path
.isdir( path
):
100 log( 'Could not create directory: %s', path
)
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:
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
)
134 if url
.startswith( 'http://') or url
.startswith( 'https://') or url
.startswith( 'ftp://'):
137 if url
.startswith('feed://') or url
.startswith('itpc://'):
138 return 'http://' + url
[7:]
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
)
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
)
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
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.
183 if os
.path
.dirname( path
) == '/':
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
)
193 for item
in os
.listdir(path
):
195 sum += calculate_size(os
.path
.join(path
, item
))
197 log('Cannot get size for %s', path
)
199 log('Cannot access: %s', path
)
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.
215 if not os
.access(filename
, os
.R_OK
):
219 s
= os
.stat(filename
)
220 timestamp
= s
[stat
.ST_MTIME
]
221 return datetime
.datetime
.fromtimestamp(timestamp
)
223 log('Cannot get modification timestamp for %s', filename
)
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
)
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)
248 >>> file_age_to_String(2)
252 return _('one day ago')
254 return _('%d days ago') % days
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
):
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:
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
:
297 elif timestamp_date
== yesterday
:
298 return _('Yesterday')
301 diff
= int( (time
.time() - timestamp
)/seconds_in_a_day
)
303 log('Warning: Cannot convert "%s" to date.', timestamp
, traceback
=True)
308 return str(datetime
.datetime
.fromtimestamp(timestamp
).strftime('%A'))
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.
334 bytesize
= float( bytesize
)
336 return _('(unknown)')
339 return _('(unknown)')
346 ( used_unit
, used_value
) = ( 'B', bytesize
)
348 for ( unit
, value
) in units
:
349 if bytesize
>= value
:
350 used_value
= bytesize
/ float(value
)
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
)
365 # Remove any extracted cover art that might exist
366 for cover_file
in glob
.glob( '%s.cover.*' % ( path
, )):
367 os
.unlink( cover_file
)
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
)
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.
460 'audio': [ 'mp3', 'ogg', 'wav', 'wma', 'aac', 'm4a' ],
461 'video': [ 'mp4', 'avi', 'mpg', 'mpeg', 'm4v', 'mov', 'divx', 'flv', 'wmv', '3gp' ],
467 if extension
[0] == '.':
468 extension
= extension
[1:]
470 extension
= extension
.lower()
473 if extension
in types
[type]:
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
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()
502 icon
= icon_theme
.load_icon(icon_name
, icon_size
, 0)
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()
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)
519 log('(get_tree_icon) Error adding emblem to icon "%s".', icon_name
)
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)
529 log('(get_tree_icon) Error adding emblem to icon "%s".', icon_name
)
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)
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
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.
565 s = '{episode.title} World'
567 print object_string_formatter( s, episode = e)
571 for ( key
, o
) in kwargs
.items():
572 matches
= re
.findall( r
'\{%s\.([^\}]+)\}' % key
, s
)
574 if hasattr( o
, attr
):
576 from_s
= '{%s.%s}' % ( key
, attr
)
577 to_s
= getattr( o
, attr
)
578 result
= result
.replace( from_s
, to_s
)
580 log( 'Could not replace attribute "%s" in string "%s".', attr
, s
)
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
598 filename_url
= 'file://%s' % 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
622 if 'PATH' not in os
.environ
:
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
):
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
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
)
647 d
= xml
.dom
.minidom
.parseString(doc
)
649 log('Error parsing document from itms:// URL: %s', e
)
652 for pairs
in d
.getElementsByTagName('dict'):
653 for node
in pairs
.childNodes
:
654 if node
.nodeType
!= node
.ELEMENT_NODE
:
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':
664 if node
.tagName
== 'string' and node
.childNodes
.length
> 0:
665 if node
.firstChild
.nodeType
== node
.TEXT_NODE
:
666 return node
.firstChild
.data
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
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
)
684 if usock
.headers
.get('content-encoding', None) == 'gzip':
685 data
= gzip
.GzipFile(fileobj
=StringIO
.StringIO(data
)).read()
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
702 if not 'phobos.apple.com' in url
.lower():
703 # This doesn't look like an iTunes URL
707 data
= http_get_and_gunzip(url
)
708 (url
,) = re
.findall("itmsOpen\('([^']*)", data
)
709 return parse_itunes_xml(url
)
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
):
729 gobject
.idle_add(func
, *args
)
734 def discover_bluetooth_devices():
736 This is a generator function that returns
737 (address, name) tuples of all nearby bluetooth
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.
747 # If the user has python-bluez installed
749 log('Using python-bluez to find nearby bluetooth devices')
750 for name
, addr
in bluetooth
.discover_devices(lookup_names
=True):
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()
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'):
774 elif find_command('gnome-obex-send'):
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".
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
)
811 log('Cannot send file. Please install "bluetooth-sendto" or "gnome-obex-send".')
812 if callback_finished
is not None:
813 callback_finished(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)
826 >>> format_seconds_to_hour_min_sec(62)
827 '1 minute and 2 seconds'
831 return _('0 seconds')
836 seconds
= seconds
%3600
842 result
.append(_('1 hour'))
844 result
.append(_('%i hours') % hours
)
847 result
.append(_('1 minute'))
849 result
.append(_('%i minutes') % minutes
)
852 result
.append(_('1 second'))
854 result
.append(_('%i seconds') % seconds
)
857 return (' '+_('and')+' ').join((', '.join(result
[:-1]), result
[-1]))
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
:])
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://')):
894 r
= proxy_request(url
, proxy
)
897 log('Trying to get metainfo for %s', url
)
899 if 'content-length' in r
.msg
:
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
:
908 parsed_date
= feedparser
._parse
_date
(r
.msg
['last-modified'])
909 pubdate
= time
.mktime(parsed_date
)
910 result
['pubdate'] = pubdate
912 log('Error converting last-modified header.')
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".
923 subprocess
.Popen(['xdg-open', filename
])
924 # FIXME: Win32-specific "open" code needed here
925 # as fallback when xdg-open not available
928 log('Cannot open file/folder: "%s"', filename
, sender
=self
, traceback
=True)
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)
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
]
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
):
984 (directory
, tail_data
) = os
.path
.split(directory
)
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.
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
)
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
)
1022 if cache
is not None:
1023 cache
[(key
, max_width
, max_height
)] = result