Thomas and Justin's proper file and folder names patch
[gpodder.git] / src / gpodder / libgpodder.py
blob8783dc47f490312deadc30c25b3fc4ad0b3b22f0
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2009 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 gpodder_dir = os.path.expanduser(os.path.join('~', '.config', 'gpodder'))
59 if gpodder.interface == gpodder.MAEMO:
60 self.osso_c = osso.Context('gpodder_osso_sender', '1.0', False)
61 old_dir = '/media/mmc2/gpodder/'
62 if os.path.exists(os.path.join(old_dir, 'channels.opml')) and not os.path.exists(os.path.join(gpodder_dir, 'channels.opml')):
63 # migrate from old (0.13.0 and earlier) gpodder maemo versions
64 # to the current one by moving config files from mmc2 to $HOME
65 util.make_directory(gpodder_dir)
66 for filename in ('channels.opml', 'database.sqlite', 'gpodder.conf'):
67 try:
68 shutil.move(os.path.join(old_dir, filename), os.path.join(gpodder_dir, filename))
69 except:
70 log('Cannot move %s from %s to %s!', filename, old_dir, gpodder_dir, sender=self, traceback=True)
71 if os.path.exists(os.path.join(old_dir, 'downloads')):
72 log('Moving old downloads')
73 # move old download location to new one
74 for folder in glob.glob(os.path.join(old_dir, 'downloads', '*')):
75 try:
76 shutil.move(folder, os.path.join(old_dir, os.path.basename(folder)))
77 except:
78 log('Cannot move %s to %s!', folder, old_dir, sender=self, traceback=True)
79 try:
80 os.rmdir(os.path.join(old_dir, 'downloads'))
81 except:
82 log('Cannot remove old folder %s!', os.path.join(old_dir, 'downloads'), traceback=True)
84 util.make_directory(gpodder_dir)
86 self.tempdir = gpodder_dir
87 self.channel_settings_file = os.path.join(gpodder_dir, 'channelsettings.pickle.db')
89 self.channel_opml_file = os.path.join(gpodder_dir, 'channels.opml')
90 self.channel_xml_file = os.path.join(gpodder_dir, 'channels.xml')
92 if os.path.exists(self.channel_xml_file) and not os.path.exists(self.channel_opml_file):
93 log('Trying to migrate channel list (channels.xml => channels.opml)', sender=self)
94 self.migrate_channels_xml()
96 self.config = config.Config( os.path.join( gpodder_dir, 'gpodder.conf'))
98 if gpodder.interface == gpodder.MAEMO:
99 # Detect changing of SD cards between mmc1/mmc2 if a gpodder
100 # folder exists there (allow moving "gpodder" between SD cards or USB)
101 # Also allow moving "gpodder" to home folder (e.g. rootfs on SD)
102 if not os.path.exists(self.config.download_dir):
103 log('Downloads might have been moved. Trying to locate them...', sender=self)
104 for basedir in ['/media/mmc1', '/media/mmc2']+glob.glob('/media/usb/*')+['/home/user']:
105 dir = os.path.join(basedir, 'gpodder')
106 if os.path.exists(dir):
107 log('Downloads found in: %s', dir, sender=self)
108 self.config.download_dir = dir
109 break
110 else:
111 log('Downloads NOT FOUND in %s', dir, sender=self)
113 # We need to make a seamless upgrade, so by default the video player is not specified
114 # so the first time this application is run it will detect this and set it to the same
115 # as the audio player. This keeps gPodder functionality identical to that prior to the
116 # upgrade. The user can then set a specific video player if they so wish.
117 if self.config.videoplayer == 'unspecified':
118 self.config.videoplayer = self.config.player
120 self.bluetooth_available = util.bluetooth_available()
122 self.gpodder_dir = gpodder_dir
123 not db.setup({ 'database': os.path.join(gpodder_dir, 'database.sqlite'), 'gl': self })
125 def migrate_to_sqlite(self, add_callback, status_callback, load_channels, get_localdb):
127 Migrates from the 0.11.3 data storage format
128 to the new SQLite-based storage format.
130 add_callback should accept one parameter:
131 + url (the url for a channel to be added)
133 status_callback should accept two parameters:
134 + percentage (a float, 0..100)
135 + message (current status message, a string)
137 load_channels should return the channel list
139 get_localdb should accept one parameter:
140 + channel (a channel object)
141 and should return a list of episodes
143 if os.path.exists(self.channel_opml_file):
144 channels = opml.Importer(gl.channel_opml_file).items
145 else:
146 channels = []
148 p = 0.0
150 # 0..40% -> import channels
151 if len(channels):
152 p_step = 40.0/len(channels)
153 for c in channels:
154 log('Importing %s', c['url'], sender=self)
155 status_callback(p, _('Adding podcast: %s') % c['title'])
156 add_callback(c['url'])
157 p += p_step
158 else:
159 p = 40.0
161 # 40..50% -> import localdb
162 channels = load_channels()
163 if len(channels):
164 p_step = 10.0/len(channels)
165 for channel in channels:
166 status_callback(p, _('Loading LocalDB for %s') % channel.title)
167 if os.path.exists(channel.index_file):
168 episodes = get_localdb(channel)
169 else:
170 episodes = []
171 if len(episodes):
172 p_step_2 = p_step/len(episodes)
173 for episode in episodes:
174 ### status_callback(p, _('Adding episode: %s') % episode.title)
175 # This, or all episodes will be marked as new after import.
176 episode.is_played = True
177 if (episode.file_exists()):
178 episode.mark(state=db.STATE_DOWNLOADED)
179 episode.save(bulk=True)
180 p += p_step_2
181 # flush the localdb updates for this channel
182 status_callback(p, _('Writing changes to database'))
183 else:
184 p += p_step
185 else:
186 p += 10.0
188 # 50..65% -> import download history
189 download_history = HistoryStore(os.path.join(self.gpodder_dir, 'download-history.txt'))
190 if len(download_history):
191 p_step = 15.0/len(download_history)
192 for url in download_history:
193 ### status_callback(p, _('Adding to history: %s') % url)
194 db.mark_episode(url, state=db.STATE_DELETED)
195 p += p_step
196 else:
197 p += 15.0
199 # 65..90% -> fix up all episode statuses
200 channels = load_channels()
201 if len(channels):
202 p_step = 25.0/len(channels)
203 for channel in channels:
204 status_callback(p, _('Migrating settings for %s') % channel.title)
205 ChannelSettings.migrate_settings(channel)
206 status_callback(p, _('Fixing episodes in %s') % channel.title)
207 all_episodes = channel.get_all_episodes()
208 if len(all_episodes):
209 p_step_2 = p_step/len(all_episodes)
210 for episode in all_episodes:
211 ### status_callback(p, _('Checking episode: %s') % episode.title)
212 if episode.state == db.STATE_DELETED and episode.file_exists():
213 episode.mark(state=db.STATE_DOWNLOADED, is_played=False)
214 # episode.fix_corrupted_state()
215 p += p_step_2
216 else:
217 p += p_step
218 else:
219 p += 25.0
221 # 90..95% -> import playback history
222 playback_history = HistoryStore(os.path.join(self.gpodder_dir, 'playback-history.txt'))
223 if len(playback_history):
224 p_step = 5.0/len(playback_history)
225 for url in playback_history:
226 ### status_callback(p, _('Playback history: %s') % url)
227 db.mark_episode(url, is_played=True)
228 p += p_step
229 else:
230 p += 5.0
232 # 95..100% -> import locked history
233 locked_history = HistoryStore(os.path.join(self.gpodder_dir, 'lock-history.txt'))
234 if len(locked_history):
235 p_step = 5.0/len(locked_history)
236 for url in locked_history:
237 ### status_callback(p, _('Locked history: %s') % url)
238 db.mark_episode(url, is_locked=True)
239 p += p_step
240 else:
241 p += 5.0
243 def migrate_channels_xml(self):
244 """Migrate old (gPodder < 0.9.5) channels.xml to channels.opml
246 This function does a one-time conversion of the old
247 channels.xml file format to the new (supported by
248 0.9.5, the default on 0.10.0) channels.opml format.
250 def channels_xml_iter(filename='channels.xml'):
251 for e in xml.dom.minidom.parse(filename).getElementsByTagName('url'):
252 yield ''.join(n.data for n in e.childNodes if n.nodeType==n.TEXT_NODE)
254 def create_outline(doc, url):
255 outline = doc.createElement('outline')
256 for w in (('title', ''), ('text', ''), ('xmlUrl', url), ('type', 'rss')):
257 outline.setAttribute(*w)
258 return outline
260 def export_opml(urls, filename='channels.opml'):
261 doc = xml.dom.minidom.Document()
262 opml = doc.createElement('opml')
263 opml.setAttribute('version', '1.1')
264 doc.appendChild(opml)
265 body = doc.createElement('body')
266 for url in urls:
267 body.appendChild(create_outline(doc, url))
268 opml.appendChild(body)
269 open(filename,'w').write(doc.toxml(encoding='utf-8'))
271 try:
272 export_opml(channels_xml_iter(self.channel_xml_file), self.channel_opml_file)
273 shutil.move(self.channel_xml_file, self.channel_xml_file+'.converted')
274 log('Successfully converted channels.xml to channels.opml', sender=self)
275 except:
276 log('Cannot convert old channels.xml to channels.opml', traceback=True, sender=self)
278 def get_device_name( self):
279 if self.config.device_type == 'ipod':
280 return _('iPod')
281 elif self.config.device_type in ('filesystem', 'mtp'):
282 return _('MP3 player')
283 else:
284 log( 'Warning: Called get_device_name() when no device was selected.', sender = self)
285 return '(unknown device)'
287 def format_filesize(self, bytesize, digits=2):
288 return util.format_filesize(bytesize, self.config.use_si_units, digits)
290 def clean_up_downloads(self, delete_partial=False):
291 # Clean up temporary files left behind by old gPodder versions
292 temporary_files = glob.glob('%s/*/.tmp-*' % self.downloaddir)
294 if delete_partial:
295 temporary_files += glob.glob('%s/*/*.partial' % self.downloaddir)
297 for tempfile in temporary_files:
298 util.delete_file(tempfile)
300 # Clean up empty download folders and abandoned download folders
301 download_dirs = glob.glob(os.path.join(self.downloaddir, '*'))
302 for ddir in download_dirs:
303 if os.path.isdir(ddir) and not db.channel_foldername_exists(os.path.basename(ddir)):
304 globr = glob.glob(os.path.join(ddir, '*'))
305 if len(globr) == 0 or (len(globr) == 1 and globr[0].endswith('/cover')):
306 log('Stale download directory found: %s', os.path.basename(ddir), sender=self)
307 shutil.rmtree(ddir, ignore_errors=True)
309 def get_download_dir( self):
310 util.make_directory( self.config.download_dir)
311 return self.config.download_dir
313 def set_download_dir( self, new_downloaddir):
314 if self.config.download_dir != new_downloaddir:
315 log( 'Moving downloads from %s to %s', self.config.download_dir, new_downloaddir)
316 try:
317 # Fix error when moving over disk boundaries
318 if os.path.isdir( new_downloaddir) and not os.listdir( new_downloaddir):
319 os.rmdir( new_downloaddir)
321 shutil.move( self.config.download_dir, new_downloaddir)
322 except NameError:
323 log( 'Fixing a bug in shutil. See http://bugs.python.org/issue2549')
324 errno = subprocess.call(["rm", "-rf", self.config.download_dir])
325 if errno <> 0:
326 log( 'Error while deleting %s: rm returned error %i', self.config.download_dir, errno)
327 return
328 except Exception, exc:
329 log( 'Error while moving %s to %s: %s',self.config.download_dir, new_downloaddir, exc)
330 return
332 self.config.download_dir = new_downloaddir
334 downloaddir = property(fget=get_download_dir,fset=set_download_dir)
336 def send_subscriptions(self):
337 try:
338 subprocess.Popen(['xdg-email', '--subject', _('My podcast subscriptions'),
339 '--attach', self.channel_opml_file])
340 except:
341 return False
343 return True
345 def playback_episode(self, episode, stream=False):
346 if stream:
347 # A streamed file acts as if it has been deleted
348 episode.state = db.STATE_DELETED
349 db.save_episode(episode)
350 filename = episode.url
351 else:
352 filename = episode.local_filename()
353 db.mark_episode(episode.url, is_played=True)
355 if gpodder.interface == gpodder.MAEMO and not self.config.maemo_allow_custom_player:
356 # Use the built-in Nokia Mediaplayer here
357 filename = filename.encode('utf-8')
358 osso_rpc = osso.Rpc(self.osso_c)
359 service = 'com.nokia.mediaplayer'
360 path = '/com/nokia/mediaplayer'
361 if not '://' in filename:
362 filename = 'file://' + filename
363 osso_rpc.rpc_run(service, path, service, 'mime_open', (filename,))
364 return (True, service)
366 # Determine the file type and set the player accordingly.
367 file_type = episode.file_type()
369 if file_type == 'video':
370 player = self.config.videoplayer
371 elif file_type == 'audio':
372 player = self.config.player
373 else:
374 player = 'default'
376 # we should use the default player or no player is set
377 if player == 'default' or player == '':
378 return (util.gui_open(filename), player)
380 command_line = shlex.split(util.format_desktop_command(player, filename).encode('utf-8'))
381 log( 'Command line: [ %s ]', ', '.join( [ '"%s"' % p for p in command_line ]), sender = self)
382 try:
383 subprocess.Popen( command_line)
384 except:
385 return ( False, command_line[0] )
386 return ( True, command_line[0] )
388 def ext_command_thread(self, notification, command_line):
390 This is the function that will be called in a separate
391 thread that will call an external command (specified by
392 command_line). In case of problem (i.e. the command has
393 not been found or there has been another error), we will
394 call the notification function with two arguments - the
395 first being the error message and the second being the
396 title to be used for the error message.
399 log("(ExtCommandThread) Excuting command Line [%s]", command_line)
401 p = subprocess.Popen(command_line, shell=True, stdout=sys.stdout, stderr=sys.stderr)
402 result = p.wait()
404 if result == 127:
405 title = _('User command not found')
406 message = _('The user command [%s] was not found.\nPlease check your user command settings in the preferences dialog.' % command_line)
407 notification(message, title)
408 elif result == 126:
409 title = _('User command permission denied')
410 message = _('Permission denied when trying to execute user command [%s].\nPlease check that you have permissions to execute this command.' % command_line)
411 notification(message, title)
412 elif result > 0 :
413 title = _('User command returned an error')
414 message = _('The user command [%s] returned an error code of [%d]' % (command_line,result))
415 notification(message, title)
417 log("(ExtCommandThread) Finished command line [%s] result [%d]",command_line,result)
420 class HistoryStore( types.ListType):
422 DEPRECATED - Only used for migration to SQLite
425 def __init__( self, filename):
426 self.filename = filename
427 try:
428 self.read_from_file()
429 except:
430 log( 'Creating new history list.', sender = self)
432 def read_from_file( self):
433 for line in open( self.filename, 'r'):
434 self.append( line.strip())
436 def save_to_file( self):
437 if len( self):
438 fp = open( self.filename, 'w')
439 for url in self:
440 fp.write( url + "\n")
441 fp.close()
442 log( 'Wrote %d history entries.', len( self), sender = self)
444 def add_item( self, data, autosave = True):
445 affected = 0
446 if data and type( data) is types.ListType:
447 # Support passing a list of urls to this function
448 for url in data:
449 affected = affected + self.add_item( url, autosave = False)
450 else:
451 if data not in self:
452 log( 'Adding: %s', data, sender = self)
453 self.append( data)
454 affected = affected + 1
456 if affected and autosave:
457 self.save_to_file()
459 return affected
461 def del_item( self, data, autosave = True):
462 affected = 0
463 if data and type( data) is types.ListType:
464 # Support passing a list of urls to this function
465 for url in data:
466 affected = affected + self.del_item( url, autosave = False)
467 else:
468 if data in self:
469 log( 'Removing: %s', data, sender = self)
470 self.remove( data)
471 affected = affected + 1
473 if affected and autosave:
474 self.save_to_file()
476 return affected
479 class ChannelSettings(object):
481 DEPRECATED - Only used for migration to SQLite
483 SETTINGS_TO_MIGRATE = ('sync_to_devices', 'override_title', 'username', 'password')
484 storage = None
486 @classmethod
487 def migrate_settings(cls, channel):
488 url = channel.url
489 settings = {}
491 if cls.storage is None:
492 if os.path.exists(gl.channel_settings_file):
493 cls.storage = dumbshelve.open_shelve(gl.channel_settings_file)
495 # We might have failed to open the shelve if we didn't have a settings
496 # file in the first place (e.g., the user just deleted the database and
497 # reimports everything from channels.opml).
498 if cls.storage is not None:
499 if isinstance(url, unicode):
500 url = url.encode('utf-8')
501 if cls.storage.has_key(url):
502 settings = cls.storage[url]
504 if settings:
505 log('Migrating settings for %s', url)
506 for key in cls.SETTINGS_TO_MIGRATE:
507 if settings.has_key(key):
508 log('Migrating key %s', key)
509 setattr(channel, key, settings[key])
512 # Global, singleton gPodderLib object
513 gl = gPodderLib()