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') })
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'))
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
)
163 # 65..90% -> fix up all episode statuses
164 channels
= load_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()
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)
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)
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
)
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')
231 body
.appendChild(create_outline(doc
, url
))
232 opml
.appendChild(body
)
233 open(filename
,'w').write(doc
.toxml(encoding
='utf-8'))
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
)
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':
245 elif self
.config
.device_type
== 'filesystem':
246 return _('MP3 player')
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
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
))
270 log( 'Successfully removed %s.', ddir
)
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
)
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
)
288 log( 'Fixing a bug in shutil. See http://bugs.python.org/issue2549')
289 errno
= subprocess
.call(["rm", "-rf", self
.config
.download_dir
])
291 log( 'Error while deleting %s: rm returned error %i', self
.config
.download_dir
, errno
)
293 except Exception, exc
:
294 log( 'Error while moving %s to %s: %s',self
.config
.download_dir
, new_downloaddir
, exc
)
297 self
.config
.download_dir
= new_downloaddir
299 downloaddir
= property(fget
=get_download_dir
,fset
=set_download_dir
)
301 def send_subscriptions(self
):
303 subprocess
.Popen(['xdg-email', '--subject', _('My podcast subscriptions'),
304 '--attach', self
.channel_opml_file
])
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
331 log('Non-audio or video file type, using xdg-open for %s', filename
, sender
=self
)
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
)
337 subprocess
.Popen( command_line
)
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
)
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
)
355 # Simply copy the .torrent with a suitable name
357 target_filename
= os
.path
.join( self
.config
.bittorrent_dir
, os
.path
.splitext( target_filename
)[0] + '.torrent')
358 shutil
.copyfile( torrent_filename
, target_filename
)
361 log( 'Torrent copy failed: %s => %s.', torrent_filename
, target_filename
)
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
)
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
)
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
)
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
405 self
.read_from_file()
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
):
415 fp
= open( self
.filename
, 'w')
417 fp
.write( url
+ "\n")
419 log( 'Wrote %d history entries.', len( self
), sender
= self
)
421 def add_item( self
, data
, autosave
= True):
423 if data
and type( data
) is types
.ListType
:
424 # Support passing a list of urls to this function
426 affected
= affected
+ self
.add_item( url
, autosave
= False)
429 log( 'Adding: %s', data
, sender
= self
)
431 affected
= affected
+ 1
433 if affected
and autosave
:
438 def del_item( self
, data
, autosave
= True):
440 if data
and type( data
) is types
.ListType
:
441 # Support passing a list of urls to this function
443 affected
= affected
+ self
.del_item( url
, autosave
= False)
446 log( 'Removing: %s', data
, sender
= self
)
448 affected
= affected
+ 1
450 if affected
and autosave
:
456 class ChannelSettings(object):
458 DEPRECATED - Only used for migration to SQLite
460 SETTINGS_TO_MIGRATE
= ('sync_to_devices', 'override_title', 'username', 'password')
464 def migrate_settings(cls
, channel
):
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
]
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