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 if gpodder
.interface
== gpodder
.MAEMO
:
59 gpodder_dir
= '/media/mmc2/gpodder/'
60 self
.osso_c
= osso
.Context('gpodder_osso_sender', '1.0', False)
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
):
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
113 # 0..40% -> import channels
115 p_step
= 40.0/len(channels
)
117 log('Importing %s', c
['url'], sender
=self
)
118 status_callback(p
, _('Adding podcast: %s') % c
['title'])
119 add_callback(c
['url'])
124 # 40..50% -> import localdb
125 channels
= load_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
)
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)
144 # flush the localdb updates for this channel
145 status_callback(p
, _('Writing changes to database'))
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
)
162 # 65..90% -> fix up all episode statuses
163 channels
= load_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()
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)
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)
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
)
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')
230 body
.appendChild(create_outline(doc
, url
))
231 opml
.appendChild(body
)
232 open(filename
,'w').write(doc
.toxml(encoding
='utf-8'))
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
)
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':
244 elif self
.config
.device_type
in ('filesystem', 'mtp'):
245 return _('MP3 player')
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
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
))
269 log( 'Successfully removed %s.', ddir
)
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
)
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
)
287 log( 'Fixing a bug in shutil. See http://bugs.python.org/issue2549')
288 errno
= subprocess
.call(["rm", "-rf", self
.config
.download_dir
])
290 log( 'Error while deleting %s: rm returned error %i', self
.config
.download_dir
, errno
)
292 except Exception, exc
:
293 log( 'Error while moving %s to %s: %s',self
.config
.download_dir
, new_downloaddir
, exc
)
296 self
.config
.download_dir
= new_downloaddir
298 downloaddir
= property(fget
=get_download_dir
,fset
=set_download_dir
)
300 def send_subscriptions(self
):
302 subprocess
.Popen(['xdg-email', '--subject', _('My podcast subscriptions'),
303 '--attach', self
.channel_opml_file
])
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
330 log('Non-audio or video file type, using xdg-open for %s', filename
, sender
=self
)
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
)
336 subprocess
.Popen( command_line
)
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
)
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
)
354 # Simply copy the .torrent with a suitable name
356 target_filename
= os
.path
.join( self
.config
.bittorrent_dir
, os
.path
.splitext( target_filename
)[0] + '.torrent')
357 shutil
.copyfile( torrent_filename
, target_filename
)
360 log( 'Torrent copy failed: %s => %s.', torrent_filename
, target_filename
)
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
)
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
)
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
)
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
404 self
.read_from_file()
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
):
414 fp
= open( self
.filename
, 'w')
416 fp
.write( url
+ "\n")
418 log( 'Wrote %d history entries.', len( self
), sender
= self
)
420 def add_item( self
, data
, autosave
= True):
422 if data
and type( data
) is types
.ListType
:
423 # Support passing a list of urls to this function
425 affected
= affected
+ self
.add_item( url
, autosave
= False)
428 log( 'Adding: %s', data
, sender
= self
)
430 affected
= affected
+ 1
432 if affected
and autosave
:
437 def del_item( self
, data
, autosave
= True):
439 if data
and type( data
) is types
.ListType
:
440 # Support passing a list of urls to this function
442 affected
= affected
+ self
.del_item( url
, autosave
= False)
445 log( 'Removing: %s', data
, sender
= self
)
447 affected
= affected
+ 1
449 if affected
and autosave
:
455 class ChannelSettings(object):
457 DEPRECATED - Only used for migration to SQLite
459 SETTINGS_TO_MIGRATE
= ('sync_to_devices', 'override_title', 'username', 'password')
463 def migrate_settings(cls
, channel
):
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
]
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