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
32 import xml
.dom
.minidom
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
48 from liblogger
import log
52 if gpodder
.interface
== gpodder
.MAEMO
:
55 class gPodderLib(object):
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'):
68 shutil
.move(os
.path
.join(old_dir
, filename
), os
.path
.join(gpodder_dir
, filename
))
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', '*')):
76 shutil
.move(folder
, os
.path
.join(old_dir
, os
.path
.basename(folder
)))
78 log('Cannot move %s to %s!', folder
, old_dir
, sender
=self
, traceback
=True)
80 os
.rmdir(os
.path
.join(old_dir
, 'downloads'))
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
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
150 # 0..40% -> import channels
152 p_step
= 40.0/len(channels
)
154 log('Importing %s', c
['url'], sender
=self
)
155 status_callback(p
, _('Adding podcast: %s') % c
['title'])
156 add_callback(c
['url'])
161 # 40..50% -> import localdb
162 channels
= load_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
)
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)
181 # flush the localdb updates for this channel
182 status_callback(p
, _('Writing changes to database'))
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
)
199 # 65..90% -> fix up all episode statuses
200 channels
= load_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()
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)
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)
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
)
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')
267 body
.appendChild(create_outline(doc
, url
))
268 opml
.appendChild(body
)
269 open(filename
,'w').write(doc
.toxml(encoding
='utf-8'))
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
)
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':
281 elif self
.config
.device_type
in ('filesystem', 'mtp'):
282 return _('MP3 player')
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
)
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
301 download_dirs
= glob
.glob( '%s/*' % ( self
.downloaddir
, ))
302 for ddir
in download_dirs
:
303 if os
.path
.isdir( ddir
):
304 globr
= glob
.glob( '%s/*' % ( ddir
, ))
306 log( 'Stale download directory found: %s', os
.path
.basename( ddir
))
309 log( 'Successfully removed %s.', ddir
)
311 log( 'Could not remove %s.', ddir
)
313 def get_download_dir( self
):
314 util
.make_directory( self
.config
.download_dir
)
315 return self
.config
.download_dir
317 def set_download_dir( self
, new_downloaddir
):
318 if self
.config
.download_dir
!= new_downloaddir
:
319 log( 'Moving downloads from %s to %s', self
.config
.download_dir
, new_downloaddir
)
321 # Fix error when moving over disk boundaries
322 if os
.path
.isdir( new_downloaddir
) and not os
.listdir( new_downloaddir
):
323 os
.rmdir( new_downloaddir
)
325 shutil
.move( self
.config
.download_dir
, new_downloaddir
)
327 log( 'Fixing a bug in shutil. See http://bugs.python.org/issue2549')
328 errno
= subprocess
.call(["rm", "-rf", self
.config
.download_dir
])
330 log( 'Error while deleting %s: rm returned error %i', self
.config
.download_dir
, errno
)
332 except Exception, exc
:
333 log( 'Error while moving %s to %s: %s',self
.config
.download_dir
, new_downloaddir
, exc
)
336 self
.config
.download_dir
= new_downloaddir
338 downloaddir
= property(fget
=get_download_dir
,fset
=set_download_dir
)
340 def send_subscriptions(self
):
342 subprocess
.Popen(['xdg-email', '--subject', _('My podcast subscriptions'),
343 '--attach', self
.channel_opml_file
])
349 def playback_episode(self
, episode
, stream
=False):
351 # A streamed file acts as if it has been deleted
352 episode
.state
= db
.STATE_DELETED
353 db
.save_episode(episode
)
354 filename
= episode
.url
356 filename
= episode
.local_filename()
357 db
.mark_episode(episode
.url
, is_played
=True)
359 if gpodder
.interface
== gpodder
.MAEMO
and not self
.config
.maemo_allow_custom_player
:
360 # Use the built-in Nokia Mediaplayer here
361 filename
= filename
.encode('utf-8')
362 osso_rpc
= osso
.Rpc(self
.osso_c
)
363 service
= 'com.nokia.mediaplayer'
364 path
= '/com/nokia/mediaplayer'
365 if not '://' in filename
:
366 filename
= 'file://' + filename
367 osso_rpc
.rpc_run(service
, path
, service
, 'mime_open', (filename
,))
368 return (True, service
)
370 # Determine the file type and set the player accordingly.
371 file_type
= episode
.file_type()
373 if file_type
== 'video':
374 player
= self
.config
.videoplayer
375 elif file_type
== 'audio':
376 player
= self
.config
.player
380 # we should use the default player or no player is set
381 if player
== 'default' or player
== '':
382 return (util
.gui_open(filename
), player
)
384 command_line
= shlex
.split(util
.format_desktop_command(player
, filename
).encode('utf-8'))
385 log( 'Command line: [ %s ]', ', '.join( [ '"%s"' % p
for p
in command_line
]), sender
= self
)
387 subprocess
.Popen( command_line
)
389 return ( False, command_line
[0] )
390 return ( True, command_line
[0] )
392 def ext_command_thread(self
, notification
, command_line
):
394 This is the function that will be called in a separate
395 thread that will call an external command (specified by
396 command_line). In case of problem (i.e. the command has
397 not been found or there has been another error), we will
398 call the notification function with two arguments - the
399 first being the error message and the second being the
400 title to be used for the error message.
403 log("(ExtCommandThread) Excuting command Line [%s]", command_line
)
405 p
= subprocess
.Popen(command_line
, shell
=True, stdout
=sys
.stdout
, stderr
=sys
.stderr
)
409 title
= _('User command not found')
410 message
= _('The user command [%s] was not found.\nPlease check your user command settings in the preferences dialog.' % command_line
)
411 notification(message
, title
)
413 title
= _('User command permission denied')
414 message
= _('Permission denied when trying to execute user command [%s].\nPlease check that you have permissions to execute this command.' % command_line
)
415 notification(message
, title
)
417 title
= _('User command returned an error')
418 message
= _('The user command [%s] returned an error code of [%d]' % (command_line
,result
))
419 notification(message
, title
)
421 log("(ExtCommandThread) Finished command line [%s] result [%d]",command_line
,result
)
424 class HistoryStore( types
.ListType
):
426 DEPRECATED - Only used for migration to SQLite
429 def __init__( self
, filename
):
430 self
.filename
= filename
432 self
.read_from_file()
434 log( 'Creating new history list.', sender
= self
)
436 def read_from_file( self
):
437 for line
in open( self
.filename
, 'r'):
438 self
.append( line
.strip())
440 def save_to_file( self
):
442 fp
= open( self
.filename
, 'w')
444 fp
.write( url
+ "\n")
446 log( 'Wrote %d history entries.', len( self
), sender
= self
)
448 def add_item( self
, data
, autosave
= True):
450 if data
and type( data
) is types
.ListType
:
451 # Support passing a list of urls to this function
453 affected
= affected
+ self
.add_item( url
, autosave
= False)
456 log( 'Adding: %s', data
, sender
= self
)
458 affected
= affected
+ 1
460 if affected
and autosave
:
465 def del_item( self
, data
, autosave
= True):
467 if data
and type( data
) is types
.ListType
:
468 # Support passing a list of urls to this function
470 affected
= affected
+ self
.del_item( url
, autosave
= False)
473 log( 'Removing: %s', data
, sender
= self
)
475 affected
= affected
+ 1
477 if affected
and autosave
:
483 class ChannelSettings(object):
485 DEPRECATED - Only used for migration to SQLite
487 SETTINGS_TO_MIGRATE
= ('sync_to_devices', 'override_title', 'username', 'password')
491 def migrate_settings(cls
, channel
):
495 if cls
.storage
is None:
496 if os
.path
.exists(gl
.channel_settings_file
):
497 cls
.storage
= dumbshelve
.open_shelve(gl
.channel_settings_file
)
499 # We might have failed to open the shelve if we didn't have a settings
500 # file in the first place (e.g., the user just deleted the database and
501 # reimports everything from channels.opml).
502 if cls
.storage
is not None:
503 if isinstance(url
, unicode):
504 url
= url
.encode('utf-8')
505 if cls
.storage
.has_key(url
):
506 settings
= cls
.storage
[url
]
509 log('Migrating settings for %s', url
)
510 for key
in cls
.SETTINGS_TO_MIGRATE
:
511 if settings
.has_key(key
):
512 log('Migrating key %s', key
)
513 setattr(channel
, key
, settings
[key
])
516 # Global, singleton gPodderLib object