Support for YouTube in CoverDownloader.
[gpodder.git] / src / gpodder / libgpodder.py
blob9a16f419b336a4cd0c16fa65cd3a0b59e7a3dea2
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 # libgpodder.py -- gpodder configuration
22 # thomas perl <thp@perli.net> 20051030
26 import gtk
27 import gtk.gdk
28 import thread
29 import threading
30 import urllib
31 import shutil
32 import xml.dom.minidom
34 import gpodder
35 from gpodder import util
36 from gpodder import opml
37 from gpodder import config
38 from gpodder import dumbshelve
39 from gpodder.dbsqlite import db
41 import os
42 import os.path
43 import glob
44 import types
45 import subprocess
46 import sys
48 from liblogger import log
50 import shlex
52 if gpodder.interface == gpodder.MAEMO:
53 import osso
55 class gPodderLib(object):
56 def __init__( self):
57 log('Creating gPodderLib()', sender=self)
58 if gpodder.interface == gpodder.MAEMO:
59 gpodder_dir = '/media/mmc2/gpodder/'
60 self.osso_c = osso.Context('gpodder_osso_sender', '1.0', False)
61 else:
62 gpodder_dir = os.path.expanduser('~/.config/gpodder/')
63 util.make_directory( gpodder_dir)
65 self.tempdir = gpodder_dir
66 self.channel_settings_file = os.path.join(gpodder_dir, 'channelsettings.pickle.db')
68 self.channel_opml_file = os.path.join(gpodder_dir, 'channels.opml')
69 self.channel_xml_file = os.path.join(gpodder_dir, 'channels.xml')
71 if os.path.exists(self.channel_xml_file) and not os.path.exists(self.channel_opml_file):
72 log('Trying to migrate channel list (channels.xml => channels.opml)', sender=self)
73 self.migrate_channels_xml()
75 self.config = config.Config( os.path.join( gpodder_dir, 'gpodder.conf'))
76 util.make_directory(self.config.bittorrent_dir)
78 # We need to make a seamless upgrade, so by default the video player is not specified
79 # so the first time this application is run it will detect this and set it to the same
80 # as the audio player. This keeps gPodder functionality identical to that prior to the
81 # upgrade. The user can then set a specific video player if they so wish.
82 if self.config.videoplayer == 'unspecified':
83 self.config.videoplayer = self.config.player
85 self.gpodder_dir = gpodder_dir
86 not db.setup({ 'database': os.path.join(gpodder_dir, 'database.sqlite'), 'gl': self })
88 def migrate_to_sqlite(self, add_callback, status_callback, load_channels, get_localdb):
89 """
90 Migrates from the 0.11.3 data storage format
91 to the new SQLite-based storage format.
93 add_callback should accept one parameter:
94 + url (the url for a channel to be added)
96 status_callback should accept two parameters:
97 + percentage (a float, 0..100)
98 + message (current status message, a string)
100 load_channels should return the channel list
102 get_localdb should accept one parameter:
103 + channel (a channel object)
104 and should return a list of episodes
106 if os.path.exists(self.channel_opml_file):
107 channels = opml.Importer(gl.channel_opml_file).items
108 else:
109 channels = []
111 p = 0.0
113 # 0..40% -> import channels
114 if len(channels):
115 p_step = 40.0/len(channels)
116 for c in channels:
117 log('Importing %s', c['url'], sender=self)
118 status_callback(p, _('Adding podcast: %s') % c['title'])
119 add_callback(c['url'])
120 p += p_step
121 else:
122 p = 40.0
124 # 40..50% -> import localdb
125 channels = load_channels()
126 if len(channels):
127 p_step = 10.0/len(channels)
128 for channel in channels:
129 status_callback(p, _('Loading LocalDB for %s') % channel.title)
130 if os.path.exists(channel.index_file):
131 episodes = get_localdb(channel)
132 else:
133 episodes = []
134 if len(episodes):
135 p_step_2 = p_step/len(episodes)
136 for episode in episodes:
137 ### status_callback(p, _('Adding episode: %s') % episode.title)
138 # This, or all episodes will be marked as new after import.
139 episode.is_played = True
140 if (episode.file_exists()):
141 episode.mark(state=db.STATE_DOWNLOADED)
142 episode.save(bulk=True)
143 p += p_step_2
144 # flush the localdb updates for this channel
145 status_callback(p, _('Writing changes to database'))
146 else:
147 p += p_step
148 else:
149 p += 10.0
151 # 50..65% -> import download history
152 download_history = HistoryStore(os.path.join(self.gpodder_dir, 'download-history.txt'))
153 if len(download_history):
154 p_step = 15.0/len(download_history)
155 for url in download_history:
156 ### status_callback(p, _('Adding to history: %s') % url)
157 db.mark_episode(url, state=db.STATE_DELETED)
158 p += p_step
159 else:
160 p += 15.0
162 # 65..90% -> fix up all episode statuses
163 channels = load_channels()
164 if len(channels):
165 p_step = 25.0/len(channels)
166 for channel in channels:
167 status_callback(p, _('Migrating settings for %s') % channel.title)
168 ChannelSettings.migrate_settings(channel)
169 status_callback(p, _('Fixing episodes in %s') % channel.title)
170 all_episodes = channel.get_all_episodes()
171 if len(all_episodes):
172 p_step_2 = p_step/len(all_episodes)
173 for episode in all_episodes:
174 ### status_callback(p, _('Checking episode: %s') % episode.title)
175 if episode.state == db.STATE_DELETED and episode.file_exists():
176 episode.mark(state=db.STATE_DOWNLOADED, is_played=False)
177 # episode.fix_corrupted_state()
178 p += p_step_2
179 else:
180 p += p_step
181 else:
182 p += 25.0
184 # 90..95% -> import playback history
185 playback_history = HistoryStore(os.path.join(self.gpodder_dir, 'playback-history.txt'))
186 if len(playback_history):
187 p_step = 5.0/len(playback_history)
188 for url in playback_history:
189 ### status_callback(p, _('Playback history: %s') % url)
190 db.mark_episode(url, is_played=True)
191 p += p_step
192 else:
193 p += 5.0
195 # 95..100% -> import locked history
196 locked_history = HistoryStore(os.path.join(self.gpodder_dir, 'lock-history.txt'))
197 if len(locked_history):
198 p_step = 5.0/len(locked_history)
199 for url in locked_history:
200 ### status_callback(p, _('Locked history: %s') % url)
201 db.mark_episode(url, is_locked=True)
202 p += p_step
203 else:
204 p += 5.0
206 def migrate_channels_xml(self):
207 """Migrate old (gPodder < 0.9.5) channels.xml to channels.opml
209 This function does a one-time conversion of the old
210 channels.xml file format to the new (supported by
211 0.9.5, the default on 0.10.0) channels.opml format.
213 def channels_xml_iter(filename='channels.xml'):
214 for e in xml.dom.minidom.parse(filename).getElementsByTagName('url'):
215 yield ''.join(n.data for n in e.childNodes if n.nodeType==n.TEXT_NODE)
217 def create_outline(doc, url):
218 outline = doc.createElement('outline')
219 for w in (('title', ''), ('text', ''), ('xmlUrl', url), ('type', 'rss')):
220 outline.setAttribute(*w)
221 return outline
223 def export_opml(urls, filename='channels.opml'):
224 doc = xml.dom.minidom.Document()
225 opml = doc.createElement('opml')
226 opml.setAttribute('version', '1.1')
227 doc.appendChild(opml)
228 body = doc.createElement('body')
229 for url in urls:
230 body.appendChild(create_outline(doc, url))
231 opml.appendChild(body)
232 open(filename,'w').write(doc.toxml(encoding='utf-8'))
234 try:
235 export_opml(channels_xml_iter(self.channel_xml_file), self.channel_opml_file)
236 shutil.move(self.channel_xml_file, self.channel_xml_file+'.converted')
237 log('Successfully converted channels.xml to channels.opml', sender=self)
238 except:
239 log('Cannot convert old channels.xml to channels.opml', traceback=True, sender=self)
241 def get_device_name( self):
242 if self.config.device_type == 'ipod':
243 return _('iPod')
244 elif self.config.device_type in ('filesystem', 'mtp'):
245 return _('MP3 player')
246 else:
247 log( 'Warning: Called get_device_name() when no device was selected.', sender = self)
248 return '(unknown device)'
250 def format_filesize(self, bytesize, digits=2):
251 return util.format_filesize(bytesize, self.config.use_si_units, digits)
253 def clean_up_downloads( self, delete_partial = False):
254 # Clean up temporary files left behind by old gPodder versions
255 if delete_partial:
256 temporary_files = glob.glob( '%s/*/.tmp-*' % ( self.downloaddir, ))
257 for tempfile in temporary_files:
258 util.delete_file( tempfile)
260 # Clean up empty download folders
261 download_dirs = glob.glob( '%s/*' % ( self.downloaddir, ))
262 for ddir in download_dirs:
263 if os.path.isdir( ddir):
264 globr = glob.glob( '%s/*' % ( ddir, ))
265 if not globr and ddir != self.config.bittorrent_dir:
266 log( 'Stale download directory found: %s', os.path.basename( ddir))
267 try:
268 os.rmdir( ddir)
269 log( 'Successfully removed %s.', ddir)
270 except:
271 log( 'Could not remove %s.', ddir)
273 def get_download_dir( self):
274 util.make_directory( self.config.download_dir)
275 return self.config.download_dir
277 def set_download_dir( self, new_downloaddir):
278 if self.config.download_dir != new_downloaddir:
279 log( 'Moving downloads from %s to %s', self.config.download_dir, new_downloaddir)
280 try:
281 # Fix error when moving over disk boundaries
282 if os.path.isdir( new_downloaddir) and not os.listdir( new_downloaddir):
283 os.rmdir( new_downloaddir)
285 shutil.move( self.config.download_dir, new_downloaddir)
286 except NameError:
287 log( 'Fixing a bug in shutil. See http://bugs.python.org/issue2549')
288 errno = subprocess.call(["rm", "-rf", self.config.download_dir])
289 if errno <> 0:
290 log( 'Error while deleting %s: rm returned error %i', self.config.download_dir, errno)
291 return
292 except Exception, exc:
293 log( 'Error while moving %s to %s: %s',self.config.download_dir, new_downloaddir, exc)
294 return
296 self.config.download_dir = new_downloaddir
298 downloaddir = property(fget=get_download_dir,fset=set_download_dir)
300 def send_subscriptions(self):
301 try:
302 subprocess.Popen(['xdg-email', '--subject', _('My podcast subscriptions'),
303 '--attach', self.channel_opml_file])
304 except:
305 return False
307 return True
309 def playback_episode(self, episode):
310 db.mark_episode(episode.url, is_played=True)
311 filename = episode.local_filename()
313 if gpodder.interface == gpodder.MAEMO and not self.config.maemo_allow_custom_player:
314 # Use the built-in Nokia Mediaplayer here
315 filename = filename.encode('utf-8')
316 osso_rpc = osso.Rpc(self.osso_c)
317 service = 'com.nokia.mediaplayer'
318 path = '/com/nokia/mediaplayer'
319 osso_rpc.rpc_run(service, path, service, 'mime_open', ('file://'+filename,))
320 return (True, service)
322 # Determine the file type and set the player accordingly.
323 file_type = episode.file_type()
325 if file_type == 'video':
326 player = self.config.videoplayer
327 elif file_type == 'audio':
328 player = self.config.player
329 else:
330 log('Non-audio or video file type, using xdg-open for %s', filename, sender=self)
331 player = 'xdg-open'
333 command_line = shlex.split(util.format_desktop_command(player, filename).encode('utf-8'))
334 log( 'Command line: [ %s ]', ', '.join( [ '"%s"' % p for p in command_line ]), sender = self)
335 try:
336 subprocess.Popen( command_line)
337 except:
338 return ( False, command_line[0] )
339 return ( True, command_line[0] )
341 def invoke_torrent( self, url, torrent_filename, target_filename):
342 db.mark_episode(url, is_played=True)
344 if self.config.use_gnome_bittorrent:
345 if util.find_command('gnome-btdownload') is None:
346 log( 'Cannot find "gnome-btdownload". Please install gnome-bittorrent.', sender = self)
347 return False
349 command = 'gnome-btdownload "%s" --saveas "%s"' % ( torrent_filename, os.path.join( self.config.bittorrent_dir, target_filename))
350 log( command, sender = self)
351 os.system( '%s &' % command)
352 return True
353 else:
354 # Simply copy the .torrent with a suitable name
355 try:
356 target_filename = os.path.join( self.config.bittorrent_dir, os.path.splitext( target_filename)[0] + '.torrent')
357 shutil.copyfile( torrent_filename, target_filename)
358 return True
359 except:
360 log( 'Torrent copy failed: %s => %s.', torrent_filename, target_filename)
362 return False
364 def ext_command_thread(self, notification, command_line):
366 This is the function that will be called in a separate
367 thread that will call an external command (specified by
368 command_line). In case of problem (i.e. the command has
369 not been found or there has been another error), we will
370 call the notification function with two arguments - the
371 first being the error message and the second being the
372 title to be used for the error message.
375 log("(ExtCommandThread) Excuting command Line [%s]", command_line)
377 p = subprocess.Popen(command_line, shell=True, stdout=sys.stdout, stderr=sys.stderr)
378 result = p.wait()
380 if result == 127:
381 title = _('User command not found')
382 message = _('The user command [%s] was not found.\nPlease check your user command settings in the preferences dialog.' % command_line)
383 notification(message, title)
384 elif result == 126:
385 title = _('User command permission denied')
386 message = _('Permission denied when trying to execute user command [%s].\nPlease check that you have permissions to execute this command.' % command_line)
387 notification(message, title)
388 elif result > 0 :
389 title = _('User command returned an error')
390 message = _('The user command [%s] returned an error code of [%d]' % (command_line,result))
391 notification(message, title)
393 log("(ExtCommandThread) Finished command line [%s] result [%d]",command_line,result)
396 class HistoryStore( types.ListType):
398 DEPRECATED - Only used for migration to SQLite
401 def __init__( self, filename):
402 self.filename = filename
403 try:
404 self.read_from_file()
405 except:
406 log( 'Creating new history list.', sender = self)
408 def read_from_file( self):
409 for line in open( self.filename, 'r'):
410 self.append( line.strip())
412 def save_to_file( self):
413 if len( self):
414 fp = open( self.filename, 'w')
415 for url in self:
416 fp.write( url + "\n")
417 fp.close()
418 log( 'Wrote %d history entries.', len( self), sender = self)
420 def add_item( self, data, autosave = True):
421 affected = 0
422 if data and type( data) is types.ListType:
423 # Support passing a list of urls to this function
424 for url in data:
425 affected = affected + self.add_item( url, autosave = False)
426 else:
427 if data not in self:
428 log( 'Adding: %s', data, sender = self)
429 self.append( data)
430 affected = affected + 1
432 if affected and autosave:
433 self.save_to_file()
435 return affected
437 def del_item( self, data, autosave = True):
438 affected = 0
439 if data and type( data) is types.ListType:
440 # Support passing a list of urls to this function
441 for url in data:
442 affected = affected + self.del_item( url, autosave = False)
443 else:
444 if data in self:
445 log( 'Removing: %s', data, sender = self)
446 self.remove( data)
447 affected = affected + 1
449 if affected and autosave:
450 self.save_to_file()
452 return affected
455 class ChannelSettings(object):
457 DEPRECATED - Only used for migration to SQLite
459 SETTINGS_TO_MIGRATE = ('sync_to_devices', 'override_title', 'username', 'password')
460 storage = None
462 @classmethod
463 def migrate_settings(cls, channel):
464 url = channel.url
465 settings = {}
467 if cls.storage is None:
468 if os.path.exists(gl.channel_settings_file):
469 cls.storage = dumbshelve.open_shelve(gl.channel_settings_file)
471 # We might have failed to open the shelve if we didn't have a settings
472 # file in the first place (e.g., the user just deleted the database and
473 # reimports everything from channels.opml).
474 if cls.storage is not None:
475 if isinstance(url, unicode):
476 url = url.encode('utf-8')
477 if cls.storage.has_key(url):
478 settings = cls.storage[url]
480 if settings:
481 log('Migrating settings for %s', url)
482 for key in cls.SETTINGS_TO_MIGRATE:
483 if settings.has_key(key):
484 log('Migrating key %s', key)
485 setattr(channel, key, settings[key])
488 # Global, singleton gPodderLib object
489 gl = gPodderLib()