Tue, 01 Jul 2008 22:46:52 -0400 <me@nikosapi.org>
[gpodder.git] / src / gpodder / libgpodder.py
blobb329e226d99b5b01779f12304f40d6a39191ff93
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') })
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 db.commit()
147 else:
148 p += p_step
149 else:
150 p += 10.0
152 # 50..65% -> import download history
153 download_history = HistoryStore(os.path.join(self.gpodder_dir, 'download-history.txt'))
154 if len(download_history):
155 p_step = 15.0/len(download_history)
156 for url in download_history:
157 ### status_callback(p, _('Adding to history: %s') % url)
158 db.mark_episode(url, state=db.STATE_DELETED)
159 p += p_step
160 else:
161 p += 15.0
163 # 65..90% -> fix up all episode statuses
164 channels = load_channels()
165 if len(channels):
166 p_step = 25.0/len(channels)
167 for channel in channels:
168 status_callback(p, _('Migrating settings for %s') % channel.title)
169 ChannelSettings.migrate_settings(channel)
170 status_callback(p, _('Fixing episodes in %s') % channel.title)
171 all_episodes = channel.get_all_episodes()
172 if len(all_episodes):
173 p_step_2 = p_step/len(all_episodes)
174 for episode in all_episodes:
175 ### status_callback(p, _('Checking episode: %s') % episode.title)
176 if episode.state == db.STATE_DELETED and episode.file_exists():
177 episode.mark(state=db.STATE_DOWNLOADED, is_played=False)
178 # episode.fix_corrupted_state()
179 p += p_step_2
180 else:
181 p += p_step
182 else:
183 p += 25.0
185 # 90..95% -> import playback history
186 playback_history = HistoryStore(os.path.join(self.gpodder_dir, 'playback-history.txt'))
187 if len(playback_history):
188 p_step = 5.0/len(playback_history)
189 for url in playback_history:
190 ### status_callback(p, _('Playback history: %s') % url)
191 db.mark_episode(url, is_played=True)
192 p += p_step
193 else:
194 p += 5.0
196 # 95..100% -> import locked history
197 locked_history = HistoryStore(os.path.join(self.gpodder_dir, 'lock-history.txt'))
198 if len(locked_history):
199 p_step = 5.0/len(locked_history)
200 for url in locked_history:
201 ### status_callback(p, _('Locked history: %s') % url)
202 db.mark_episode(url, is_locked=True)
203 p += p_step
204 else:
205 p += 5.0
207 def migrate_channels_xml(self):
208 """Migrate old (gPodder < 0.9.5) channels.xml to channels.opml
210 This function does a one-time conversion of the old
211 channels.xml file format to the new (supported by
212 0.9.5, the default on 0.10.0) channels.opml format.
214 def channels_xml_iter(filename='channels.xml'):
215 for e in xml.dom.minidom.parse(filename).getElementsByTagName('url'):
216 yield ''.join(n.data for n in e.childNodes if n.nodeType==n.TEXT_NODE)
218 def create_outline(doc, url):
219 outline = doc.createElement('outline')
220 for w in (('title', ''), ('text', ''), ('xmlUrl', url), ('type', 'rss')):
221 outline.setAttribute(*w)
222 return outline
224 def export_opml(urls, filename='channels.opml'):
225 doc = xml.dom.minidom.Document()
226 opml = doc.createElement('opml')
227 opml.setAttribute('version', '1.1')
228 doc.appendChild(opml)
229 body = doc.createElement('body')
230 for url in urls:
231 body.appendChild(create_outline(doc, url))
232 opml.appendChild(body)
233 open(filename,'w').write(doc.toxml(encoding='utf-8'))
235 try:
236 export_opml(channels_xml_iter(self.channel_xml_file), self.channel_opml_file)
237 shutil.move(self.channel_xml_file, self.channel_xml_file+'.converted')
238 log('Successfully converted channels.xml to channels.opml', sender=self)
239 except:
240 log('Cannot convert old channels.xml to channels.opml', traceback=True, sender=self)
242 def get_device_name( self):
243 if self.config.device_type == 'ipod':
244 return _('iPod')
245 elif self.config.device_type == 'filesystem':
246 return _('MP3 player')
247 else:
248 log( 'Warning: Called get_device_name() when no device was selected.', sender = self)
249 return '(unknown device)'
251 def format_filesize(self, bytesize, digits=2):
252 return util.format_filesize(bytesize, self.config.use_si_units, digits)
254 def clean_up_downloads( self, delete_partial = False):
255 # Clean up temporary files left behind by old gPodder versions
256 if delete_partial:
257 temporary_files = glob.glob( '%s/*/.tmp-*' % ( self.downloaddir, ))
258 for tempfile in temporary_files:
259 util.delete_file( tempfile)
261 # Clean up empty download folders
262 download_dirs = glob.glob( '%s/*' % ( self.downloaddir, ))
263 for ddir in download_dirs:
264 if os.path.isdir( ddir):
265 globr = glob.glob( '%s/*' % ( ddir, ))
266 if not globr and ddir != self.config.bittorrent_dir:
267 log( 'Stale download directory found: %s', os.path.basename( ddir))
268 try:
269 os.rmdir( ddir)
270 log( 'Successfully removed %s.', ddir)
271 except:
272 log( 'Could not remove %s.', ddir)
274 def get_download_dir( self):
275 util.make_directory( self.config.download_dir)
276 return self.config.download_dir
278 def set_download_dir( self, new_downloaddir):
279 if self.config.download_dir != new_downloaddir:
280 log( 'Moving downloads from %s to %s', self.config.download_dir, new_downloaddir)
281 try:
282 # Fix error when moving over disk boundaries
283 if os.path.isdir( new_downloaddir) and not os.listdir( new_downloaddir):
284 os.rmdir( new_downloaddir)
286 shutil.move( self.config.download_dir, new_downloaddir)
287 except NameError:
288 log( 'Fixing a bug in shutil. See http://bugs.python.org/issue2549')
289 errno = subprocess.call(["rm", "-rf", self.config.download_dir])
290 if errno <> 0:
291 log( 'Error while deleting %s: rm returned error %i', self.config.download_dir, errno)
292 return
293 except Exception, exc:
294 log( 'Error while moving %s to %s: %s',self.config.download_dir, new_downloaddir, exc)
295 return
297 self.config.download_dir = new_downloaddir
299 downloaddir = property(fget=get_download_dir,fset=set_download_dir)
301 def send_subscriptions(self):
302 try:
303 subprocess.Popen(['xdg-email', '--subject', _('My podcast subscriptions'),
304 '--attach', self.channel_opml_file])
305 except:
306 return False
308 return True
310 def playback_episode( self, channel, episode):
311 db.mark_episode(episode.url, is_played=True)
312 filename = episode.local_filename()
314 if gpodder.interface == gpodder.MAEMO and not self.config.maemo_allow_custom_player:
315 # Use the built-in Nokia Mediaplayer here
316 filename = filename.encode('utf-8')
317 osso_rpc = osso.Rpc(self.osso_c)
318 service = 'com.nokia.mediaplayer'
319 path = '/com/nokia/mediaplayer'
320 osso_rpc.rpc_run(service, path, service, 'mime_open', ('file://'+filename,))
321 return (True, service)
323 # Determine the file type and set the player accordingly.
324 file_type = episode.file_type()
326 if file_type == 'video':
327 player = self.config.videoplayer
328 elif file_type == 'audio':
329 player = self.config.player
330 else:
331 log('Non-audio or video file type, using xdg-open for %s', filename, sender=self)
332 player = 'xdg-open'
334 command_line = shlex.split(util.format_desktop_command(player, filename).encode('utf-8'))
335 log( 'Command line: [ %s ]', ', '.join( [ '"%s"' % p for p in command_line ]), sender = self)
336 try:
337 subprocess.Popen( command_line)
338 except:
339 return ( False, command_line[0] )
340 return ( True, command_line[0] )
342 def invoke_torrent( self, url, torrent_filename, target_filename):
343 db.mark_episode(url, is_played=True)
345 if self.config.use_gnome_bittorrent:
346 if util.find_command('gnome-btdownload') is None:
347 log( 'Cannot find "gnome-btdownload". Please install gnome-bittorrent.', sender = self)
348 return False
350 command = 'gnome-btdownload "%s" --saveas "%s"' % ( torrent_filename, os.path.join( self.config.bittorrent_dir, target_filename))
351 log( command, sender = self)
352 os.system( '%s &' % command)
353 return True
354 else:
355 # Simply copy the .torrent with a suitable name
356 try:
357 target_filename = os.path.join( self.config.bittorrent_dir, os.path.splitext( target_filename)[0] + '.torrent')
358 shutil.copyfile( torrent_filename, target_filename)
359 return True
360 except:
361 log( 'Torrent copy failed: %s => %s.', torrent_filename, target_filename)
363 return False
365 def ext_command_thread(self, notification, command_line):
367 This is the function that will be called in a separate
368 thread that will call an external command (specified by
369 command_line). In case of problem (i.e. the command has
370 not been found or there has been another error), we will
371 call the notification function with two arguments - the
372 first being the error message and the second being the
373 title to be used for the error message.
376 log("(ExtCommandThread) Excuting command Line [%s]", command_line)
378 p = subprocess.Popen(command_line, shell=True, stdout=sys.stdout, stderr=sys.stderr)
379 result = p.wait()
381 if result == 127:
382 title = _('User command not found')
383 message = _('The user command [%s] was not found.\nPlease check your user command settings in the preferences dialog.' % command_line)
384 notification(message, title)
385 elif result == 126:
386 title = _('User command permission denied')
387 message = _('Permission denied when trying to execute user command [%s].\nPlease check that you have permissions to execute this command.' % command_line)
388 notification(message, title)
389 elif result > 0 :
390 title = _('User command returned an error')
391 message = _('The user command [%s] returned an error code of [%d]' % (command_line,result))
392 notification(message, title)
394 log("(ExtCommandThread) Finished command line [%s] result [%d]",command_line,result)
397 class HistoryStore( types.ListType):
399 DEPRECATED - Only used for migration to SQLite
402 def __init__( self, filename):
403 self.filename = filename
404 try:
405 self.read_from_file()
406 except:
407 log( 'Creating new history list.', sender = self)
409 def read_from_file( self):
410 for line in open( self.filename, 'r'):
411 self.append( line.strip())
413 def save_to_file( self):
414 if len( self):
415 fp = open( self.filename, 'w')
416 for url in self:
417 fp.write( url + "\n")
418 fp.close()
419 log( 'Wrote %d history entries.', len( self), sender = self)
421 def add_item( self, data, autosave = True):
422 affected = 0
423 if data and type( data) is types.ListType:
424 # Support passing a list of urls to this function
425 for url in data:
426 affected = affected + self.add_item( url, autosave = False)
427 else:
428 if data not in self:
429 log( 'Adding: %s', data, sender = self)
430 self.append( data)
431 affected = affected + 1
433 if affected and autosave:
434 self.save_to_file()
436 return affected
438 def del_item( self, data, autosave = True):
439 affected = 0
440 if data and type( data) is types.ListType:
441 # Support passing a list of urls to this function
442 for url in data:
443 affected = affected + self.del_item( url, autosave = False)
444 else:
445 if data in self:
446 log( 'Removing: %s', data, sender = self)
447 self.remove( data)
448 affected = affected + 1
450 if affected and autosave:
451 self.save_to_file()
453 return affected
456 class ChannelSettings(object):
458 DEPRECATED - Only used for migration to SQLite
460 SETTINGS_TO_MIGRATE = ('sync_to_devices', 'override_title', 'username', 'password')
461 storage = None
463 @classmethod
464 def migrate_settings(cls, channel):
465 url = channel.url
466 settings = {}
468 if cls.storage is None:
469 if os.path.exists(gl.channel_settings_file):
470 cls.storage = dumbshelve.open_shelve(gl.channel_settings_file)
472 # We might have failed to open the shelve if we didn't have a settings
473 # file in the first place (e.g., the user just deleted the database and
474 # reimports everything from channels.opml).
475 if cls.storage is not None:
476 if isinstance(url, unicode):
477 url = url.encode('utf-8')
478 if cls.storage.has_key(url):
479 settings = cls.storage[url]
481 if settings:
482 log('Migrating settings for %s', url)
483 for key in cls.SETTINGS_TO_MIGRATE:
484 if settings.has_key(key):
485 log('Migrating key %s', key)
486 setattr(channel, key, settings[key])
489 # Global, singleton gPodderLib object
490 gl = gPodderLib()