Remove *_played_dbus from config
[gpodder.git] / src / gpodder / util.py
blob06d33d08e016dbe7ed80fc3e299058ebf94e7941
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2011 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 os
36 import os.path
37 import platform
38 import glob
39 import stat
40 import shlex
41 import socket
42 import sys
44 import re
45 import subprocess
46 from htmlentitydefs import entitydefs
47 import time
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
64 _ = gpodder.gettext
65 N_ = gpodder.ngettext
68 import locale
69 locale.setlocale(locale.LC_ALL, '')
71 # Native filesystem encoding detection
72 encoding = sys.getfilesystemencoding()
74 if encoding is None:
75 if 'LANG' in os.environ and '.' in os.environ['LANG']:
76 lang = os.environ['LANG']
77 (language, encoding) = lang.rsplit('.', 1)
78 log('Detected encoding: %s', encoding)
79 elif gpodder.ui.maemo:
80 encoding = 'utf-8'
81 elif gpodder.win32:
82 # To quote http://docs.python.org/howto/unicode.html:
83 # ,,on Windows, Python uses the name "mbcs" to refer
84 # to whatever the currently configured encoding is``
85 encoding = 'mbcs'
86 else:
87 encoding = 'iso-8859-15'
88 log('Assuming encoding: ISO-8859-15 ($LANG not set).')
91 # Used by file_type_by_extension()
92 _BUILTIN_FILE_TYPES = None
95 def make_directory( path):
96 """
97 Tries to create a directory if it does not exist already.
98 Returns True if the directory exists after the function
99 call, False otherwise.
101 if os.path.isdir( path):
102 return True
104 try:
105 os.makedirs( path)
106 except:
107 log( 'Could not create directory: %s', path)
108 return False
110 return True
113 def normalize_feed_url(url):
115 Converts any URL to http:// or ftp:// so that it can be
116 used with "wget". If the URL cannot be converted (invalid
117 or unknown scheme), "None" is returned.
119 This will also normalize feed:// and itpc:// to http://.
121 >>> normalize_feed_url('itpc://example.org/podcast.rss')
122 'http://example.org/podcast.rss'
124 If no URL scheme is defined (e.g. "curry.com"), we will
125 simply assume the user intends to add a http:// feed.
127 >>> normalize_feed_url('curry.com')
128 'http://curry.com'
130 There are even some more shortcuts for advanced users
131 and lazy typists (see the source for details).
133 >>> normalize_feed_url('fb:43FPodcast')
134 'http://feeds.feedburner.com/43FPodcast'
136 if not url or len(url) < 8:
137 return None
139 # This is a list of prefixes that you can use to minimize the amount of
140 # keystrokes that you have to use.
141 # Feel free to suggest other useful prefixes, and I'll add them here.
142 PREFIXES = {
143 'fb:': 'http://feeds.feedburner.com/%s',
144 'yt:': 'http://www.youtube.com/rss/user/%s/videos.rss',
145 'sc:': 'http://soundcloud.com/%s',
146 'fm4od:': 'http://onapp1.orf.at/webcam/fm4/fod/%s.xspf',
149 for prefix, expansion in PREFIXES.iteritems():
150 if url.startswith(prefix):
151 url = expansion % (url[len(prefix):],)
152 break
154 # Assume HTTP for URLs without scheme
155 if not '://' in url:
156 url = 'http://' + url
158 # The scheme of the URL should be all-lowercase
159 (scheme, rest) = url.split('://', 1)
160 scheme = scheme.lower()
162 # feed://, itpc:// and itms:// are really http://
163 if scheme in ('feed', 'itpc', 'itms'):
164 scheme = 'http'
166 # Re-assemble our URL
167 url = scheme + '://' + rest
169 if scheme in ('http', 'https', 'ftp', 'file'):
170 return url
172 return None
175 def username_password_from_url(url):
176 r"""
177 Returns a tuple (username,password) containing authentication
178 data from the specified URL or (None,None) if no authentication
179 data can be found in the URL.
181 See Section 3.1 of RFC 1738 (http://www.ietf.org/rfc/rfc1738.txt)
183 >>> username_password_from_url('https://@host.com/')
184 ('', None)
185 >>> username_password_from_url('telnet://host.com/')
186 (None, None)
187 >>> username_password_from_url('ftp://foo:@host.com/')
188 ('foo', '')
189 >>> username_password_from_url('http://a:b@host.com/')
190 ('a', 'b')
191 >>> username_password_from_url(1)
192 Traceback (most recent call last):
194 ValueError: URL has to be a string or unicode object.
195 >>> username_password_from_url(None)
196 Traceback (most recent call last):
198 ValueError: URL has to be a string or unicode object.
199 >>> username_password_from_url('http://a@b:c@host.com/')
200 Traceback (most recent call last):
202 ValueError: "@" must be encoded for username/password (RFC1738).
203 >>> username_password_from_url('ftp://a:b:c@host.com/')
204 Traceback (most recent call last):
206 ValueError: ":" must be encoded for username/password (RFC1738).
207 >>> username_password_from_url('http://i%2Fo:P%40ss%3A@host.com/')
208 ('i/o', 'P@ss:')
209 >>> username_password_from_url('ftp://%C3%B6sterreich@host.com/')
210 ('\xc3\xb6sterreich', None)
211 >>> username_password_from_url('http://w%20x:y%20z@example.org/')
212 ('w x', 'y z')
214 if type(url) not in (str, unicode):
215 raise ValueError('URL has to be a string or unicode object.')
217 (username, password) = (None, None)
219 (scheme, netloc, path, params, query, fragment) = urlparse.urlparse(url)
221 if '@' in netloc:
222 (authentication, netloc) = netloc.rsplit('@', 1)
223 if ':' in authentication:
224 (username, password) = authentication.split(':', 1)
225 # RFC1738 dictates that we should not allow these unquoted
226 # characters in the username and password field (Section 3.1).
227 for c in (':', '@', '/'):
228 if c in username or c in password:
229 raise ValueError('"%c" must be encoded for username/password (RFC1738).' % c)
230 username = urllib.unquote(username)
231 password = urllib.unquote(password)
232 else:
233 username = urllib.unquote(authentication)
235 return (username, password)
238 def directory_is_writable( path):
240 Returns True if the specified directory exists and is writable
241 by the current user.
243 return os.path.isdir( path) and os.access( path, os.W_OK)
246 def calculate_size( path):
248 Tries to calculate the size of a directory, including any
249 subdirectories found. The returned value might not be
250 correct if the user doesn't have appropriate permissions
251 to list all subdirectories of the given path.
253 if path is None:
254 return 0L
256 if os.path.dirname( path) == '/':
257 return 0L
259 if os.path.isfile( path):
260 return os.path.getsize( path)
262 if os.path.isdir( path) and not os.path.islink( path):
263 sum = os.path.getsize( path)
265 try:
266 for item in os.listdir(path):
267 try:
268 sum += calculate_size(os.path.join(path, item))
269 except:
270 log('Cannot get size for %s', path)
271 except:
272 log('Cannot access: %s', path)
274 return sum
276 return 0L
279 def file_modification_datetime(filename):
281 Returns the modification date of the specified file
282 as a datetime.datetime object or None if the modification
283 date cannot be determined.
285 if filename is None:
286 return None
288 if not os.access(filename, os.R_OK):
289 return None
291 try:
292 s = os.stat(filename)
293 timestamp = s[stat.ST_MTIME]
294 return datetime.datetime.fromtimestamp(timestamp)
295 except:
296 log('Cannot get modification timestamp for %s', filename)
297 return None
300 def file_modification_timestamp(filename):
302 Returns the modification date of the specified file as a number
303 or -1 if the modification date cannot be determined.
305 if filename is None:
306 return -1
307 try:
308 s = os.stat(filename)
309 return s[stat.ST_MTIME]
310 except:
311 log('Cannot get modification timestamp for %s', filename)
312 return -1
315 def file_age_in_days(filename):
317 Returns the age of the specified filename in days or
318 zero if the modification date cannot be determined.
320 dt = file_modification_datetime(filename)
321 if dt is None:
322 return 0
323 else:
324 return (datetime.datetime.now()-dt).days
327 def file_age_to_string(days):
329 Converts a "number of days" value to a string that
330 can be used in the UI to display the file age.
332 >>> file_age_to_string(0)
334 >>> file_age_to_string(1)
335 u'1 day ago'
336 >>> file_age_to_string(2)
337 u'2 days ago'
339 if days < 1:
340 return ''
341 else:
342 return N_('%(count)d day ago', '%(count)d days ago', days) % {'count':days}
345 def get_free_disk_space_win32(path):
347 Win32-specific code to determine the free disk space remaining
348 for a given path. Uses code from:
350 http://mail.python.org/pipermail/python-list/2003-May/203223.html
353 drive, tail = os.path.splitdrive(path)
355 try:
356 import win32file
357 userFree, userTotal, freeOnDisk = win32file.GetDiskFreeSpaceEx(drive)
358 return userFree
359 except ImportError:
360 log('Warning: Running on Win32 but win32api/win32file not installed.')
362 # Cannot determine free disk space
363 return 0
366 def get_free_disk_space(path):
368 Calculates the free disk space available to the current user
369 on the file system that contains the given path.
371 If the path (or its parent folder) does not yet exist, this
372 function returns zero.
375 if not os.path.exists(path):
376 return 0
378 if gpodder.win32:
379 return get_free_disk_space_win32(path)
381 s = os.statvfs(path)
383 return s.f_bavail * s.f_bsize
386 def format_date(timestamp):
388 Converts a UNIX timestamp to a date representation. This
389 function returns "Today", "Yesterday", a weekday name or
390 the date in %x format, which (according to the Python docs)
391 is the "Locale's appropriate date representation".
393 Returns None if there has been an error converting the
394 timestamp to a string representation.
396 if timestamp is None:
397 return None
399 seconds_in_a_day = 60*60*24
401 today = time.localtime()[:3]
402 yesterday = time.localtime(time.time() - seconds_in_a_day)[:3]
403 try:
404 timestamp_date = time.localtime(timestamp)[:3]
405 except ValueError, ve:
406 log('Warning: Cannot convert timestamp', traceback=True)
407 return None
409 if timestamp_date == today:
410 return _('Today')
411 elif timestamp_date == yesterday:
412 return _('Yesterday')
414 try:
415 diff = int( (time.time() - timestamp)/seconds_in_a_day )
416 except:
417 log('Warning: Cannot convert "%s" to date.', timestamp, traceback=True)
418 return None
420 try:
421 timestamp = datetime.datetime.fromtimestamp(timestamp)
422 except:
423 return None
425 if diff < 7:
426 # Weekday name
427 return str(timestamp.strftime('%A').decode(encoding))
428 else:
429 # Locale's appropriate date representation
430 return str(timestamp.strftime('%x'))
433 def format_filesize(bytesize, use_si_units=False, digits=2):
435 Formats the given size in bytes to be human-readable,
437 Returns a localized "(unknown)" string when the bytesize
438 has a negative value.
440 si_units = (
441 ( 'kB', 10**3 ),
442 ( 'MB', 10**6 ),
443 ( 'GB', 10**9 ),
446 binary_units = (
447 ( 'KiB', 2**10 ),
448 ( 'MiB', 2**20 ),
449 ( 'GiB', 2**30 ),
452 try:
453 bytesize = float( bytesize)
454 except:
455 return _('(unknown)')
457 if bytesize < 0:
458 return _('(unknown)')
460 if use_si_units:
461 units = si_units
462 else:
463 units = binary_units
465 ( used_unit, used_value ) = ( 'B', bytesize )
467 for ( unit, value ) in units:
468 if bytesize >= value:
469 used_value = bytesize / float(value)
470 used_unit = unit
472 return ('%.'+str(digits)+'f %s') % (used_value, used_unit)
475 def delete_file(filename):
476 """Delete a file from the filesystem
478 Errors (permissions errors or file not found)
479 are silently ignored.
481 try:
482 os.remove(filename)
483 except:
484 pass
487 def remove_html_tags(html):
489 Remove HTML tags from a string and replace numeric and
490 named entities with the corresponding character, so the
491 HTML text can be displayed in a simple text view.
493 if html is None:
494 return None
496 # If we would want more speed, we could make these global
497 re_strip_tags = re.compile('<[^>]*>')
498 re_unicode_entities = re.compile('&#(\d{2,4});')
499 re_html_entities = re.compile('&(.{2,8});')
500 re_newline_tags = re.compile('(<br[^>]*>|<[/]?ul[^>]*>|</li>)', re.I)
501 re_listing_tags = re.compile('<li[^>]*>', re.I)
503 result = html
505 # Convert common HTML elements to their text equivalent
506 result = re_newline_tags.sub('\n', result)
507 result = re_listing_tags.sub('\n * ', result)
508 result = re.sub('<[Pp]>', '\n\n', result)
510 # Remove all HTML/XML tags from the string
511 result = re_strip_tags.sub('', result)
513 # Convert numeric XML entities to their unicode character
514 result = re_unicode_entities.sub(lambda x: unichr(int(x.group(1))), result)
516 # Convert named HTML entities to their unicode character
517 result = re_html_entities.sub(lambda x: unicode(entitydefs.get(x.group(1),''), 'iso-8859-1'), result)
519 # Convert more than two newlines to two newlines
520 result = re.sub('([\r\n]{2})([\r\n])+', '\\1', result)
522 return result.strip()
525 def wrong_extension(extension):
527 Determine if a given extension looks like it's
528 wrong (e.g. empty, extremely long or spaces)
530 Returns True if the extension most likely is a
531 wrong one and should be replaced.
533 >>> wrong_extension('.mp3')
534 False
535 >>> wrong_extension('.divx')
536 False
537 >>> wrong_extension('mp3')
538 True
539 >>> wrong_extension('')
540 True
541 >>> wrong_extension('.12 - Everybody')
542 True
543 >>> wrong_extension('.mp3 ')
544 True
545 >>> wrong_extension('.')
546 True
547 >>> wrong_extension('.42')
548 True
550 if not extension:
551 return True
552 elif len(extension) > 5:
553 return True
554 elif ' ' in extension:
555 return True
556 elif extension == '.':
557 return True
558 elif not extension.startswith('.'):
559 return True
560 else:
561 try:
562 # ".<number>" is an invalid extension
563 float(extension)
564 return True
565 except:
566 pass
568 return False
571 def extension_from_mimetype(mimetype):
573 Simply guesses what the file extension should be from the mimetype
575 MIMETYPE_EXTENSIONS = {
576 # This is required for YouTube downloads on Maemo 5
577 'video/x-flv': '.flv',
578 'video/mp4': '.mp4',
580 if mimetype in MIMETYPE_EXTENSIONS:
581 return MIMETYPE_EXTENSIONS[mimetype]
582 return mimetypes.guess_extension(mimetype) or ''
585 def extension_correct_for_mimetype(extension, mimetype):
587 Check if the given filename extension (e.g. ".ogg") is a possible
588 extension for a given mimetype (e.g. "application/ogg") and return
589 a boolean value (True if it's possible, False if not). Also do
591 >>> extension_correct_for_mimetype('.ogg', 'application/ogg')
592 True
593 >>> extension_correct_for_mimetype('.ogv', 'video/ogg')
594 True
595 >>> extension_correct_for_mimetype('.ogg', 'audio/mpeg')
596 False
597 >>> extension_correct_for_mimetype('mp3', 'audio/mpeg')
598 Traceback (most recent call last):
600 ValueError: "mp3" is not an extension (missing .)
601 >>> extension_correct_for_mimetype('.mp3', 'audio mpeg')
602 Traceback (most recent call last):
604 ValueError: "audio mpeg" is not a mimetype (missing /)
606 if not '/' in mimetype:
607 raise ValueError('"%s" is not a mimetype (missing /)' % mimetype)
608 if not extension.startswith('.'):
609 raise ValueError('"%s" is not an extension (missing .)' % extension)
611 # Create a "default" extension from the mimetype, e.g. "application/ogg"
612 # becomes ".ogg", "audio/mpeg" becomes ".mpeg", etc...
613 default = ['.'+mimetype.split('/')[-1]]
615 return extension in default+mimetypes.guess_all_extensions(mimetype)
618 def filename_from_url(url):
620 Extracts the filename and (lowercase) extension (with dot)
621 from a URL, e.g. http://server.com/file.MP3?download=yes
622 will result in the string ("file", ".mp3") being returned.
624 This function will also try to best-guess the "real"
625 extension for a media file (audio, video) by
626 trying to match an extension to these types and recurse
627 into the query string to find better matches, if the
628 original extension does not resolve to a known type.
630 http://my.net/redirect.php?my.net/file.ogg => ("file", ".ogg")
631 http://server/get.jsp?file=/episode0815.MOV => ("episode0815", ".mov")
632 http://s/redirect.mp4?http://serv2/test.mp4 => ("test", ".mp4")
634 (scheme, netloc, path, para, query, fragid) = urlparse.urlparse(url)
635 (filename, extension) = os.path.splitext(os.path.basename( urllib.unquote(path)))
637 if file_type_by_extension(extension) is not None and not \
638 query.startswith(scheme+'://'):
639 # We have found a valid extension (audio, video)
640 # and the query string doesn't look like a URL
641 return ( filename, extension.lower() )
643 # If the query string looks like a possible URL, try that first
644 if len(query.strip()) > 0 and query.find('/') != -1:
645 query_url = '://'.join((scheme, urllib.unquote(query)))
646 (query_filename, query_extension) = filename_from_url(query_url)
648 if file_type_by_extension(query_extension) is not None:
649 return os.path.splitext(os.path.basename(query_url))
651 # No exact match found, simply return the original filename & extension
652 return ( filename, extension.lower() )
655 def file_type_by_extension(extension):
657 Tries to guess the file type by looking up the filename
658 extension from a table of known file types. Will return
659 "audio", "video" or None.
661 >>> file_type_by_extension('.aif')
662 'audio'
663 >>> file_type_by_extension('.3GP')
664 'video'
665 >>> file_type_by_extension('.txt') is None
666 True
667 >>> file_type_by_extension(None) is None
668 True
669 >>> file_type_by_extension('ogg')
670 Traceback (most recent call last):
672 ValueError: Extension does not start with a dot: ogg
674 if not extension:
675 return None
677 if not extension.startswith('.'):
678 raise ValueError('Extension does not start with a dot: %s' % extension)
680 global _BUILTIN_FILE_TYPES
681 if _BUILTIN_FILE_TYPES is None:
682 # List all types that are not in the default mimetypes.types_map
683 # (even if they might be detected by mimetypes.guess_type)
684 # For OGG, see http://wiki.xiph.org/MIME_Types_and_File_Extensions
685 audio_types = ('.ogg', '.oga', '.spx', '.flac', '.axa', \
686 '.aac', '.m4a', '.m4b', '.wma')
687 video_types = ('.ogv', '.axv', '.mp4', \
688 '.mkv', '.m4v', '.divx', '.flv', '.wmv', '.3gp')
689 _BUILTIN_FILE_TYPES = {}
690 _BUILTIN_FILE_TYPES.update((ext, 'audio') for ext in audio_types)
691 _BUILTIN_FILE_TYPES.update((ext, 'video') for ext in video_types)
693 extension = extension.lower()
695 if extension in _BUILTIN_FILE_TYPES:
696 return _BUILTIN_FILE_TYPES[extension]
698 # Need to prepend something to the extension, so guess_type works
699 type, encoding = mimetypes.guess_type('file'+extension)
701 if type is not None and '/' in type:
702 filetype, rest = type.split('/', 1)
703 if filetype in ('audio', 'video', 'image'):
704 return filetype
706 return None
709 def get_first_line( s):
711 Returns only the first line of a string, stripped so
712 that it doesn't have whitespace before or after.
714 return s.strip().split('\n')[0].strip()
717 def object_string_formatter( s, **kwargs):
719 Makes attributes of object passed in as keyword
720 arguments available as {OBJECTNAME.ATTRNAME} in
721 the passed-in string and returns a string with
722 the above arguments replaced with the attribute
723 values of the corresponding object.
725 Example:
727 e = Episode()
728 e.title = 'Hello'
729 s = '{episode.title} World'
731 print object_string_formatter( s, episode = e)
732 => 'Hello World'
734 result = s
735 for ( key, o ) in kwargs.items():
736 matches = re.findall( r'\{%s\.([^\}]+)\}' % key, s)
737 for attr in matches:
738 if hasattr( o, attr):
739 try:
740 from_s = '{%s.%s}' % ( key, attr )
741 to_s = getattr( o, attr)
742 result = result.replace( from_s, to_s)
743 except:
744 log( 'Could not replace attribute "%s" in string "%s".', attr, s)
746 return result
749 def format_desktop_command(command, filenames):
751 Formats a command template from the "Exec=" line of a .desktop
752 file to a string that can be invoked in a shell.
754 Handled format strings: %U, %u, %F, %f and a fallback that
755 appends the filename as first parameter of the command.
757 See http://standards.freedesktop.org/desktop-entry-spec/1.0/ar01s06.html
759 Returns a list of commands to execute, either one for
760 each filename if the application does not support multiple
761 file names or one for all filenames (%U, %F or unknown).
763 command = shlex.split(command)
765 command_before = command
766 command_after = []
767 multiple_arguments = True
768 for fieldcode in ('%U', '%F', '%u', '%f'):
769 if fieldcode in command:
770 command_before = command[:command.index(fieldcode)]
771 command_after = command[command.index(fieldcode)+1:]
772 multiple_arguments = fieldcode in ('%U', '%F')
773 break
775 if multiple_arguments:
776 return [command_before + filenames + command_after]
778 commands = []
779 for filename in filenames:
780 commands.append(command_before+[filename]+command_after)
782 return commands
784 def url_strip_authentication(url):
786 Strips authentication data from an URL. Returns the URL with
787 the authentication data removed from it.
789 >>> url_strip_authentication('https://host.com/')
790 'https://host.com/'
791 >>> url_strip_authentication('telnet://foo:bar@host.com/')
792 'telnet://host.com/'
793 >>> url_strip_authentication('ftp://billy@example.org')
794 'ftp://example.org'
795 >>> url_strip_authentication('ftp://billy:@example.org')
796 'ftp://example.org'
797 >>> url_strip_authentication('http://aa:bc@localhost/x')
798 'http://localhost/x'
799 >>> url_strip_authentication('http://i%2Fo:P%40ss%3A@blubb.lan/u.html')
800 'http://blubb.lan/u.html'
801 >>> url_strip_authentication('http://c:d@x.org/')
802 'http://x.org/'
803 >>> url_strip_authentication('http://P%40%3A:i%2F@cx.lan')
804 'http://cx.lan'
806 url_parts = list(urlparse.urlsplit(url))
807 # url_parts[1] is the HOST part of the URL
809 # Remove existing authentication data
810 if '@' in url_parts[1]:
811 url_parts[1] = url_parts[1].split('@', 2)[1]
813 return urlparse.urlunsplit(url_parts)
816 def url_add_authentication(url, username, password):
818 Adds authentication data (username, password) to a given
819 URL in order to construct an authenticated URL.
821 >>> url_add_authentication('https://host.com/', '', None)
822 'https://host.com/'
823 >>> url_add_authentication('http://example.org/', None, None)
824 'http://example.org/'
825 >>> url_add_authentication('telnet://host.com/', 'foo', 'bar')
826 'telnet://foo:bar@host.com/'
827 >>> url_add_authentication('ftp://example.org', 'billy', None)
828 'ftp://billy@example.org'
829 >>> url_add_authentication('ftp://example.org', 'billy', '')
830 'ftp://billy:@example.org'
831 >>> url_add_authentication('http://localhost/x', 'aa', 'bc')
832 'http://aa:bc@localhost/x'
833 >>> url_add_authentication('http://blubb.lan/u.html', 'i/o', 'P@ss:')
834 'http://i%2Fo:P%40ss%3A@blubb.lan/u.html'
835 >>> url_add_authentication('http://a:b@x.org/', 'c', 'd')
836 'http://c:d@x.org/'
837 >>> url_add_authentication('http://i%2F:P%40%3A@cx.lan', 'P@:', 'i/')
838 'http://P%40%3A:i%2F@cx.lan'
839 >>> url_add_authentication('http://x.org/', 'a b', 'c d')
840 'http://a%20b:c%20d@x.org/'
842 if username is None or username == '':
843 return url
845 username = urllib.quote(username, safe='')
847 if password is not None:
848 password = urllib.quote(password, safe='')
849 auth_string = ':'.join((username, password))
850 else:
851 auth_string = username
853 url = url_strip_authentication(url)
855 url_parts = list(urlparse.urlsplit(url))
856 # url_parts[1] is the HOST part of the URL
857 url_parts[1] = '@'.join((auth_string, url_parts[1]))
859 return urlparse.urlunsplit(url_parts)
862 def urlopen(url):
864 An URL opener with the User-agent set to gPodder (with version)
866 username, password = username_password_from_url(url)
867 if username is not None or password is not None:
868 url = url_strip_authentication(url)
869 password_mgr = urllib2.HTTPPasswordMgrWithDefaultRealm()
870 password_mgr.add_password(None, url, username, password)
871 handler = urllib2.HTTPBasicAuthHandler(password_mgr)
872 opener = urllib2.build_opener(handler)
873 else:
874 opener = urllib2.build_opener()
876 headers = {'User-agent': gpodder.user_agent}
877 request = urllib2.Request(url, headers=headers)
878 return opener.open(request)
880 def get_real_url(url):
882 Gets the real URL of a file and resolves all redirects.
884 try:
885 return urlopen(url).geturl()
886 except:
887 log('Error getting real url for %s', url, traceback=True)
888 return url
891 def find_command( command):
893 Searches the system's PATH for a specific command that is
894 executable by the user. Returns the first occurence of an
895 executable binary in the PATH, or None if the command is
896 not available.
899 if 'PATH' not in os.environ:
900 return None
902 for path in os.environ['PATH'].split( os.pathsep):
903 command_file = os.path.join( path, command)
904 if os.path.isfile( command_file) and os.access( command_file, os.X_OK):
905 return command_file
907 return None
910 def http_get_and_gunzip(uri):
912 Does a HTTP GET request and tells the server that we accept
913 gzip-encoded data. This is necessary, because the Apple iTunes
914 server will always return gzip-encoded data, regardless of what
915 we really request.
917 Returns the uncompressed document at the given URI.
919 request = urllib2.Request(uri)
920 request.add_header("Accept-encoding", "gzip")
921 usock = urllib2.urlopen(request)
922 data = usock.read()
923 if usock.headers.get('content-encoding', None) == 'gzip':
924 data = gzip.GzipFile(fileobj=StringIO.StringIO(data)).read()
925 return data
928 def idle_add(func, *args):
930 This is a wrapper function that does the Right
931 Thing depending on if we are running a GTK+ GUI or
932 not. If not, we're simply calling the function.
934 If we are a GUI app, we use gobject.idle_add() to
935 call the function later - this is needed for
936 threads to be able to modify GTK+ widget data.
938 if gpodder.ui.desktop or gpodder.ui.maemo:
939 import gobject
940 def x(f, *a):
941 f(*a)
942 return False
944 gobject.idle_add(func, *args)
945 else:
946 func(*args)
949 def bluetooth_available():
951 Returns True or False depending on the availability
952 of bluetooth functionality on the system.
954 if gpodder.ui.maemo:
955 return True
956 elif find_command('bluetooth-sendto') or \
957 find_command('gnome-obex-send'):
958 return True
959 else:
960 return False
962 def bluetooth_send_files_maemo(filenames):
963 """Maemo implementation of Bluetooth file transfer
965 Takes a list of (absolute and local) filenames that are
966 submitted to the Maemo Bluetooth UI for file transfer.
968 This method works in Diablo and also in Fremantle.
970 import dbus
971 bus = dbus.SystemBus()
972 o = bus.get_object('com.nokia.bt_ui', '/com/nokia/bt_ui', False)
973 i = dbus.Interface(o, 'com.nokia.bt_ui')
974 i.show_send_file_dlg(['file://'+f for f in filenames])
975 return True
978 def bluetooth_send_file(filename):
980 Sends a file via bluetooth.
982 This function tries to use "bluetooth-sendto", and if
983 it is not available, it also tries "gnome-obex-send".
985 command_line = None
987 if find_command('bluetooth-sendto'):
988 command_line = ['bluetooth-sendto']
989 elif find_command('gnome-obex-send'):
990 command_line = ['gnome-obex-send']
992 if command_line is not None:
993 command_line.append(filename)
994 return (subprocess.Popen(command_line).wait() == 0)
995 else:
996 log('Cannot send file. Please install "bluetooth-sendto" or "gnome-obex-send".')
997 return False
1000 def format_time(value):
1001 """Format a seconds value to a string
1003 >>> format_time(0)
1004 '00:00'
1005 >>> format_time(20)
1006 '00:20'
1007 >>> format_time(3600)
1008 '01:00:00'
1009 >>> format_time(10921)
1010 '03:02:01'
1012 dt = datetime.datetime.utcfromtimestamp(value)
1013 if dt.hour == 0:
1014 return dt.strftime('%M:%S')
1015 else:
1016 return dt.strftime('%H:%M:%S')
1019 def parse_time(value):
1020 """Parse a time string into seconds
1021 >>> parse_time('00:00')
1023 >>> parse_time('00:00:00')
1025 >>> parse_time('00:20')
1027 >>> parse_time('00:00:20')
1029 >>> parse_time('01:00:00')
1030 3600
1031 >>> parse_time('03:02:01')
1032 10921
1034 if not value:
1035 raise ValueError('Invalid value: %s' % (str(value),))
1037 for format in ('%H:%M:%S', '%M:%S'):
1038 try:
1039 t = time.strptime(value, format)
1040 return (t.tm_hour * 60 + t.tm_min) * 60 + t.tm_sec
1041 except ValueError, ve:
1042 continue
1044 return int(value)
1047 def format_seconds_to_hour_min_sec(seconds):
1049 Take the number of seconds and format it into a
1050 human-readable string (duration).
1052 >>> format_seconds_to_hour_min_sec(3834)
1053 u'1 hour, 3 minutes and 54 seconds'
1054 >>> format_seconds_to_hour_min_sec(3600)
1055 u'1 hour'
1056 >>> format_seconds_to_hour_min_sec(62)
1057 u'1 minute and 2 seconds'
1060 if seconds < 1:
1061 return N_('%(count)d second', '%(count)d seconds', seconds) % {'count':seconds}
1063 result = []
1065 seconds = int(seconds)
1067 hours = seconds/3600
1068 seconds = seconds%3600
1070 minutes = seconds/60
1071 seconds = seconds%60
1073 if hours:
1074 result.append(N_('%(count)d hour', '%(count)d hours', hours) % {'count':hours})
1076 if minutes:
1077 result.append(N_('%(count)d minute', '%(count)d minutes', minutes) % {'count':minutes})
1079 if seconds:
1080 result.append(N_('%(count)d second', '%(count)d seconds', seconds) % {'count':seconds})
1082 if len(result) > 1:
1083 return (' '+_('and')+' ').join((', '.join(result[:-1]), result[-1]))
1084 else:
1085 return result[0]
1087 def http_request(url, method='HEAD'):
1088 (scheme, netloc, path, parms, qry, fragid) = urlparse.urlparse(url)
1089 conn = httplib.HTTPConnection(netloc)
1090 start = len(scheme) + len('://') + len(netloc)
1091 conn.request(method, url[start:])
1092 return conn.getresponse()
1094 def get_episode_info_from_url(url):
1096 Try to get information about a podcast episode by sending
1097 a HEAD request to the HTTP server and parsing the result.
1099 The return value is a dict containing all fields that
1100 could be parsed from the URL. This currently contains:
1102 "length": The size of the file in bytes
1103 "pubdate": The unix timestamp for the pubdate
1105 If there is an error, this function returns {}. This will
1106 only function with http:// and https:// URLs.
1108 if not (url.startswith('http://') or url.startswith('https://')):
1109 return {}
1111 r = http_request(url)
1112 result = {}
1114 log('Trying to get metainfo for %s', url)
1116 if 'content-length' in r.msg:
1117 try:
1118 length = int(r.msg['content-length'])
1119 result['length'] = length
1120 except ValueError, e:
1121 log('Error converting content-length header.')
1123 if 'last-modified' in r.msg:
1124 try:
1125 parsed_date = feedparser._parse_date(r.msg['last-modified'])
1126 pubdate = time.mktime(parsed_date)
1127 result['pubdate'] = pubdate
1128 except:
1129 log('Error converting last-modified header.')
1131 return result
1134 def gui_open(filename):
1136 Open a file or folder with the default application set
1137 by the Desktop environment. This uses "xdg-open" on all
1138 systems with a few exceptions:
1140 on Win32, os.startfile() is used
1141 on Maemo, osso is used to communicate with Nokia Media Player
1143 try:
1144 if gpodder.ui.maemo:
1145 try:
1146 import osso
1147 except ImportError, ie:
1148 log('Cannot import osso module on maemo.')
1149 return False
1151 log('Using Nokia Media Player to open %s', filename)
1152 context = osso.Context('gPodder', gpodder.__version__, False)
1153 filename = filename.encode('utf-8')
1155 # Fix for Maemo bug 7162 (for local files with "#" in filename)
1156 if filename.startswith('/'):
1157 filename = 'file://' + urllib.quote(filename)
1159 rpc = osso.Rpc(context)
1160 app = 'mediaplayer'
1162 _unneeded, extension = os.path.splitext(filename.lower())
1164 # Fix for Maemo bug 5588 (use PDF viewer and images app)
1165 if extension == '.pdf':
1166 app = 'osso_pdfviewer'
1167 elif extension in ('.jpg', '.jpeg', '.png'):
1168 app = 'image_viewer'
1170 svc, path = (x % app for x in ('com.nokia.%s', '/com/nokia/%s'))
1171 rpc.rpc_run(svc, path, svc, 'mime_open', (filename,))
1172 elif gpodder.win32:
1173 os.startfile(filename)
1174 else:
1175 subprocess.Popen(['xdg-open', filename])
1176 return True
1177 except:
1178 log('Cannot open file/folder: "%s"', filename, traceback=True)
1179 return False
1182 def open_website(url):
1184 Opens the specified URL using the default system web
1185 browser. This uses Python's "webbrowser" module, so
1186 make sure your system is set up correctly.
1188 if gpodder.ui.maemo:
1189 import osso
1190 context = osso.Context('gPodder', gpodder.__version__, False)
1191 rpc = osso.Rpc(context)
1192 rpc.rpc_run_with_defaults('osso_browser', \
1193 'open_new_window', \
1194 (url,))
1195 else:
1196 threading.Thread(target=webbrowser.open, args=(url,)).start()
1198 def sanitize_encoding(filename):
1199 r"""
1200 Generate a sanitized version of a string (i.e.
1201 remove invalid characters and encode in the
1202 detected native language encoding).
1204 >>> sanitize_encoding('\x80')
1206 >>> sanitize_encoding(u'unicode')
1207 'unicode'
1209 global encoding
1210 if not isinstance(filename, unicode):
1211 filename = filename.decode(encoding, 'ignore')
1212 return filename.encode(encoding, 'ignore')
1215 def sanitize_filename(filename, max_length=0, use_ascii=False):
1217 Generate a sanitized version of a filename that can
1218 be written on disk (i.e. remove/replace invalid
1219 characters and encode in the native language) and
1220 trim filename if greater than max_length (0 = no limit).
1222 If use_ascii is True, don't encode in the native language,
1223 but use only characters from the ASCII character set.
1225 global encoding
1226 if use_ascii:
1227 e = 'ascii'
1228 else:
1229 e = encoding
1231 if not isinstance(filename, unicode):
1232 filename = filename.decode(encoding, 'ignore')
1234 if max_length > 0 and len(filename) > max_length:
1235 log('Limiting file/folder name "%s" to %d characters.', filename, max_length)
1236 filename = filename[:max_length]
1238 return re.sub('[/|?*<>:+\[\]\"\\\]', '_', filename.strip().encode(e, 'ignore'))
1241 def find_mount_point(directory):
1243 Try to find the mount point for a given directory.
1244 If the directory is itself a mount point, return
1245 it. If not, remove the last part of the path and
1246 re-check if it's a mount point. If the directory
1247 resides on your root filesystem, "/" is returned.
1249 >>> find_mount_point('/')
1252 >>> find_mount_point(u'/something')
1253 Traceback (most recent call last):
1255 ValueError: Convert unicode objects to str first.
1257 >>> find_mount_point(None)
1258 Traceback (most recent call last):
1260 ValueError: Directory names should be of type str.
1262 >>> find_mount_point(42)
1263 Traceback (most recent call last):
1265 ValueError: Directory names should be of type str.
1267 >>> from minimock import mock, restore
1268 >>> mocked_mntpoints = ('/', '/home', '/media/usbdisk', '/media/cdrom')
1269 >>> mock('os.path.ismount', returns_func=lambda x: x in mocked_mntpoints)
1271 >>> # For mocking os.getcwd(), we simply use a lambda to avoid the
1272 >>> # massive output of "Called os.getcwd()" lines in this doctest
1273 >>> os.getcwd = lambda: '/home/thp'
1275 >>> find_mount_point('.')
1276 Called os.path.ismount('/home/thp')
1277 Called os.path.ismount('/home')
1278 '/home'
1279 >>> find_mount_point('relativity')
1280 Called os.path.ismount('/home/thp/relativity')
1281 Called os.path.ismount('/home/thp')
1282 Called os.path.ismount('/home')
1283 '/home'
1284 >>> find_mount_point('/media/usbdisk/')
1285 Called os.path.ismount('/media/usbdisk')
1286 '/media/usbdisk'
1287 >>> find_mount_point('/home/thp/Desktop')
1288 Called os.path.ismount('/home/thp/Desktop')
1289 Called os.path.ismount('/home/thp')
1290 Called os.path.ismount('/home')
1291 '/home'
1292 >>> find_mount_point('/media/usbdisk/Podcasts/With Spaces')
1293 Called os.path.ismount('/media/usbdisk/Podcasts/With Spaces')
1294 Called os.path.ismount('/media/usbdisk/Podcasts')
1295 Called os.path.ismount('/media/usbdisk')
1296 '/media/usbdisk'
1297 >>> find_mount_point('/home/')
1298 Called os.path.ismount('/home')
1299 '/home'
1300 >>> find_mount_point('/media/cdrom/../usbdisk/blubb//')
1301 Called os.path.ismount('/media/usbdisk/blubb')
1302 Called os.path.ismount('/media/usbdisk')
1303 '/media/usbdisk'
1304 >>> restore()
1306 if isinstance(directory, unicode):
1307 # We do not accept unicode strings, because they could fail when
1308 # trying to be converted to some native encoding, so fail loudly
1309 # and leave it up to the callee to encode into the proper encoding.
1310 raise ValueError('Convert unicode objects to str first.')
1312 if not isinstance(directory, str):
1313 raise ValueError('Directory names should be of type str.')
1315 directory = os.path.abspath(directory)
1317 while directory != '/':
1318 if os.path.ismount(directory):
1319 return directory
1320 else:
1321 (directory, tail_data) = os.path.split(directory)
1323 return '/'
1326 # matches http:// and ftp:// and mailto://
1327 protocolPattern = re.compile(r'^\w+://')
1329 def isabs(string):
1331 @return true if string is an absolute path or protocoladdress
1332 for addresses beginning in http:// or ftp:// or ldap:// -
1333 they are considered "absolute" paths.
1334 Source: http://code.activestate.com/recipes/208993/
1336 if protocolPattern.match(string): return 1
1337 return os.path.isabs(string)
1339 def rel2abs(path, base = os.curdir):
1340 """ converts a relative path to an absolute path.
1342 @param path the path to convert - if already absolute, is returned
1343 without conversion.
1344 @param base - optional. Defaults to the current directory.
1345 The base is intelligently concatenated to the given relative path.
1346 @return the relative path of path from base
1347 Source: http://code.activestate.com/recipes/208993/
1349 if isabs(path): return path
1350 retval = os.path.join(base,path)
1351 return os.path.abspath(retval)
1353 def commonpath(l1, l2, common=[]):
1355 helper functions for relpath
1356 Source: http://code.activestate.com/recipes/208993/
1358 if len(l1) < 1: return (common, l1, l2)
1359 if len(l2) < 1: return (common, l1, l2)
1360 if l1[0] != l2[0]: return (common, l1, l2)
1361 return commonpath(l1[1:], l2[1:], common+[l1[0]])
1363 def relpath(p1, p2):
1365 Finds relative path from p1 to p2
1366 Source: http://code.activestate.com/recipes/208993/
1368 pathsplit = lambda s: s.split(os.path.sep)
1370 (common,l1,l2) = commonpath(pathsplit(p1), pathsplit(p2))
1371 p = []
1372 if len(l1) > 0:
1373 p = [ ('..'+os.sep) * len(l1) ]
1374 p = p + l2
1375 if len(p) is 0:
1376 return "."
1378 return os.path.join(*p)
1381 def run_external_command(command_line):
1383 This is the function that will be called in a separate
1384 thread that will call an external command (specified by
1385 command_line). In case of problem (i.e. the command has
1386 not been found or there has been another error), we will
1387 call the notification function with two arguments - the
1388 first being the error message and the second being the
1389 title to be used for the error message.
1392 def open_process(command_line):
1393 log('Running external command: %s', command_line)
1394 p = subprocess.Popen(command_line, shell=True)
1395 result = p.wait()
1396 if result == 127:
1397 log('Command not found: %s', command_line)
1398 elif result == 126:
1399 log('Command permission denied: %s', command_line)
1400 elif result > 0:
1401 log('Command returned an error (%d): %s', result, command_line)
1402 else:
1403 log('Command finished successfully: %s', command_line)
1405 threading.Thread(target=open_process, args=(command_line,)).start()
1407 def get_hostname():
1408 """Return the hostname of this computer
1410 This can be implemented in a different way on each
1411 platform and should yield a unique-per-user device ID.
1413 nodename = platform.node()
1415 if nodename:
1416 return nodename
1418 # Fallback - but can this give us "localhost"?
1419 return socket.gethostname()
1421 def detect_device_type():
1422 """Device type detection for gpodder.net
1424 This function tries to detect on which
1425 kind of device gPodder is running on.
1427 Possible return values:
1428 desktop, laptop, mobile, server, other
1430 if gpodder.ui.maemo:
1431 return 'mobile'
1432 elif glob.glob('/proc/acpi/battery/*'):
1433 # Linux: If we have a battery, assume Laptop
1434 return 'laptop'
1436 return 'desktop'
1439 def write_m3u_playlist(m3u_filename, episodes, extm3u=True):
1440 """Create an M3U playlist from a episode list
1442 If the parameter "extm3u" is False, the list of
1443 episodes should be a list of filenames, and no
1444 extended information will be written into the
1445 M3U files (#EXTM3U / #EXTINF).
1447 If the parameter "extm3u" is True (default), then the
1448 list of episodes should be PodcastEpisode objects,
1449 as the extended metadata will be taken from them.
1451 f = open(m3u_filename, 'w')
1453 if extm3u:
1454 # Mandatory header for extended playlists
1455 f.write('#EXTM3U\n')
1457 for episode in episodes:
1458 if not extm3u:
1459 # Episode objects are strings that contain file names
1460 f.write(episode+'\n')
1461 continue
1463 if episode.was_downloaded(and_exists=True):
1464 filename = episode.local_filename(create=False)
1465 assert filename is not None
1467 if os.path.dirname(filename).startswith(os.path.dirname(m3u_filename)):
1468 filename = filename[len(os.path.dirname(m3u_filename)+os.sep):]
1469 f.write('#EXTINF:0,'+episode.playlist_title()+'\n')
1470 f.write(filename+'\n')
1472 f.close()