Add support for portable mode (bug 236)
[gpodder.git] / src / gpodder / config.py
blob9161cc9a7e41369266dce90a65fd7b909dfedc9c
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/>.
22 # config.py -- gPodder Configuration Manager
23 # Thomas Perl <thp@perli.net> 2007-11-02
27 import gpodder
28 from gpodder import util
29 from gpodder.liblogger import log
31 import atexit
32 import os
33 import time
34 import threading
35 import ConfigParser
37 _ = gpodder.gettext
39 if gpodder.ui.fremantle:
40 default_download_dir = os.path.join(os.path.expanduser('~'), 'MyDocs/Podcasts')
41 elif gpodder.ui.diablo:
42 default_download_dir = '/media/mmc2/gpodder'
43 else:
44 default_download_dir = os.path.join(os.path.expanduser('~'), 'gpodder-downloads')
46 gPodderSettings = {
47 # General settings
48 'player': (str, 'default',
49 ("The default player for all media, if set to 'default' this will "
50 "attempt to use xdg-open on linux or the built-in media player on maemo.")),
51 'videoplayer': (str, 'default',
52 ("The default player for video")),
53 'opml_url': (str, 'http://gpodder.org/directory.opml',
54 ("A URL pointing to an OPML file which can be used to bulk-add podcasts.")),
55 'toplist_url': (str, 'http://gpodder.org/toplist.opml',
56 ("A URL pointing to a gPodder web services top podcasts list")),
57 'custom_sync_name': ( str, '{episode.basename}',
58 ("The name used when copying a file to a FS-based device. Available "
59 "options are: episode.basename, episode.title, episode.published")),
60 'custom_sync_name_enabled': ( bool, True,
61 ("Enables renaming files when transfered to an FS-based device with "
62 "respect to the 'custom_sync_name'.")),
63 'max_downloads': ( int, 1,
64 ("The maximum number of simultaneous downloads allowed at a single "
65 "time. Requires 'max_downloads_enabled'.")),
66 'max_downloads_enabled': ( bool, True,
67 ("The 'max_downloads' setting will only work if this is set to 'True'.")),
68 'limit_rate': ( bool, False,
69 ("The 'limit_rate_value' setting will only work if this is set to 'True'.")),
70 'limit_rate_value': ( float, 500.0,
71 ("Set a global speed limit (in KB/s) when downloading files. "
72 "Requires 'limit_rate'.")),
73 'episode_old_age': ( int, 7,
74 ("The number of days before an episode is considered old. "
75 "Must be used in conjunction with 'auto_remove_old_episodes'.")),
77 # Boolean config flags
78 'update_on_startup': ( bool, False,
79 ("Update the feed cache on startup.")),
80 'only_sync_not_played': ( bool, False,
81 ("Only sync episodes to a device that have not been marked played in gPodder.")),
82 'fssync_channel_subfolders': ( bool, True,
83 ("Create a directory for every feed when syncing to an FS-based device "
84 "instead of putting all the episodes in a single directory.")),
85 'on_sync_mark_played': ( bool, False,
86 ("After syncing an episode, mark it as played in gPodder.")),
87 'on_sync_delete': ( bool, False,
88 ("After syncing an episode, delete it from gPodder.")),
89 'auto_remove_old_episodes': ( bool, False,
90 ("Remove episodes older than 'episode_old_age' days on startup.")),
91 'auto_update_feeds': (bool, False,
92 ("Automatically update feeds when gPodder is minimized. "
93 "See 'auto_update_frequency' and 'auto_download'.")),
94 'auto_update_frequency': (int, 20,
95 ("The frequency (in minutes) at which gPodder will update all feeds "
96 "if 'auto_update_feeds' is enabled.")),
97 'auto_cleanup_downloads': (bool, True,
98 ('Automatically removed cancelled and finished downloads from the list')),
99 'episode_list_descriptions': (bool, True,
100 ("Display the episode's description under the episode title in the GUI.")),
101 'show_toolbar': (bool, True,
102 ("Show the toolbar in the GUI's main window.")),
103 'ipod_purge_old_episodes': (bool, False,
104 ("Remove episodes from an iPod device if they've been marked as played "
105 "on the device and they have no rating set (the rating can be set on "
106 "the device by the user to prevent deletion).")),
107 'ipod_delete_played_from_db': (bool, False,
108 ("Remove episodes from gPodder if they've been marked as played "
109 "on the device and they have no rating set (the rating can be set on "
110 "the device by the user to prevent deletion).")),
111 'mp3_player_delete_played': (bool, False,
112 ("Removes episodes from an FS-based device that have been marked as "
113 "played in gPodder. Note: only works if 'only_sync_not_played' is "
114 "also enabled.")),
115 'disable_pre_sync_conversion': (bool, False,
116 ("Disable pre-synchronization conversion of OGG files. This should be "
117 "enabled for deviced that natively support OGG. Eg. Rockbox, iAudio")),
119 # Tray icon and notification settings
120 'display_tray_icon': (bool, False,
121 ("Whether or not gPodder should display an icon in the system tray.")),
122 'minimize_to_tray': (bool, False,
123 ("If 'display_tray_icon' is enabled, when gPodder is minimized it will "
124 "not be visible in the window list.")),
125 'start_iconified': (bool, False,
126 ("When gPodder starts, send it to the tray immediately.")),
127 'enable_notifications': (bool, True,
128 ("Let gPodder use notification bubbles when it can completed certain "
129 "tasks like downloading an episode or finishing syncing to a device.")),
130 'on_quit_ask': (bool, True,
131 ("Ask the user to confirm quitting the application.")),
132 'auto_download': (str, 'never',
133 ("Auto download episodes (never, minimized, always)")),
134 'do_not_show_new_episodes_dialog': (bool, False,
135 ("Do not show the new episodes dialog after updating feed cache when "
136 "gPodder is not minimized")),
139 # Settings that are updated directly in code
140 'ipod_mount': ( str, '/media/ipod',
141 ("The moint point for an iPod Device.")),
142 'mp3_player_folder': ( str, '/media/usbdisk',
143 ("The moint point for an FS-based device.")),
144 'device_type': ( str, 'none',
145 ("The device type: 'mtp', 'filesystem' or 'ipod'")),
146 'download_dir': (str, default_download_dir,
147 ("The default directory that podcast episodes are downloaded to.")),
149 # Playlist Management settings
150 'mp3_player_playlist_file': (str, 'PLAYLISTS/gpodder.m3u',
151 ("The relative path to where the playlist is stored on an FS-based device.")),
152 'mp3_player_playlist_absolute_path': (bool, True,
153 ("Whether or not the the playlist should contain relative or absolute "
154 "paths; this is dependent on the player.")),
155 'mp3_player_playlist_win_path': (bool, True,
156 ("Whether or not the player requires Windows-style paths in the playlist.")),
158 # Special settings (not in preferences)
159 'on_quit_systray': (bool, False,
160 ("When the 'X' button is clicked do not quit, send gPodder to the tray.")),
161 'max_episodes_per_feed': (int, 200,
162 ("The maximum number of episodes that gPodder will display in the episode "
163 "list. Note: Set this to a lower value on slower hardware to speed up "
164 "rendering of the episode list.")),
165 'mp3_player_use_scrobbler_log': (bool, False,
166 ("Attempt to use a Device's scrobbler.log to mark episodes as played in "
167 "gPodder. Useful for Rockbox players.")),
168 'mp3_player_max_filename_length': (int, 100,
169 ("The maximum filename length for FS-based devices.")),
170 'rockbox_copy_coverart' : (bool, False,
171 ("Create rockbox-compatible coverart and copy it to the device when "
172 "syncing. See: 'rockbox_coverart_size'.")),
173 'rockbox_coverart_size' : (int, 100,
174 ("The width of the coverart for the user's Rockbox player/skin.")),
175 'custom_player_copy_coverart' : (bool, False,
176 ("Create custom coverart for FS-based players.")),
177 'custom_player_coverart_size' : (int, 176,
178 ("The width of the coverart for the user's FS-based player.")),
179 'custom_player_coverart_name' : (str, 'folder.jpg',
180 ("The name of the coverart file accepted by the user's FS-based player.")),
181 'custom_player_coverart_format' : (str, 'JPEG',
182 ("The image format accepted by the user's FS-based player.")),
183 'podcast_list_icon_size': (int, 32,
184 ("The width of the icon used in the podcast channel list.")),
185 'cmd_all_downloads_complete': (str, '',
186 ("The path to a command that gets run after all downloads are completed.")),
187 'cmd_download_complete': (str, '',
188 ("The path to a command that gets run after a single download completes. "
189 "See http://wiki.gpodder.org/wiki/Time_stretching for more info.")),
190 'enable_html_shownotes': (bool, True,
191 ("Allow HTML to be rendered in the episode information dialog.")),
192 'maemo_enable_gestures': (bool, False,
193 ("Enable fancy gestures on Maemo.")),
194 'sync_disks_after_transfer': (bool, True,
195 ("Call 'sync' after tranfering episodes to a device.")),
196 'resume_ask_every_episode': (bool, False,
197 ("If there are episode downloads that can be resumed, ask whether or "
198 "not to resume every single one.")),
199 'enable_fingerscroll': (bool, False,
200 ("Enable the use of finger-scrollable widgets on Maemo.")),
201 'double_click_episode_action': (str, 'shownotes',
202 ("Episode double-click/enter action handler (shownotes, download, stream)")),
203 'on_drag_mark_played': (bool, False,
204 ("Mark episode as played when using drag'n'drop to copy/open it")),
206 'feed_update_skipping': (bool, True,
207 ('Skip podcasts that are unlikely to have new episodes when updating feeds.')),
208 'allow_empty_feeds': (bool, False,
209 ('Allow subscribing to feeds without episodes')),
211 'episode_list_view_mode': (int, 1, # "Hide deleted episodes" (see gtkui/model.py)
212 ('Internally used (current view mode)')),
213 'podcast_list_view_mode': (int, 1, # Only on Fremantle
214 ('Internally used (current view mode)')),
215 'podcast_list_hide_boring': (bool, False,
216 ('Hide podcasts in the main window for which the episode list is empty')),
217 'podcast_list_view_all': (bool, False,
218 ('Show an additional entry in the podcast list that contains all episodes')),
220 'audio_played_dbus': (bool, False,
221 ('Set to True if the audio player notifies gPodder about played episodes')),
222 'video_played_dbus': (bool, False,
223 ('Set to True if the video player notifies gPodder about played episodes')),
225 'rotation_mode': (int, 0,
226 ('Internally used on Maemo 5 for the current rotation mode')),
228 'youtube_preferred_fmt_id': (int, 18,
229 ('The preferred video format that should be downloaded from YouTube.')),
231 # Settings for my.gpodder.org
232 'my_gpodder_username': (str, '',
233 ("The user's gPodder web services username.")),
234 'my_gpodder_password': (str, '',
235 ("The user's gPodder web services password.")),
236 'my_gpodder_autoupload': (bool, False,
237 ("Upload the user's podcast list to the gPodder web services when "
238 "gPodder is closed.")),
239 'my_gpodder_service': (str, 'http://my.gpodder.org',
240 ('The base URL of the my.gpodder.org service.')),
242 # Paned position
243 'paned_position': ( int, 200,
244 ("The width of the channel list.")),
247 # Helper function to add window-specific properties (position and size)
248 def window_props(config_prefix, x=-1, y=-1, width=700, height=500):
249 return {
250 config_prefix+'_x': (int, x),
251 config_prefix+'_y': (int, y),
252 config_prefix+'_width': (int, width),
253 config_prefix+'_height': (int, height),
254 config_prefix+'_maximized': (bool, False),
257 # Register window-specific properties
258 gPodderSettings.update(window_props('main_window', width=700, height=500))
259 gPodderSettings.update(window_props('episode_selector', width=600, height=400))
260 gPodderSettings.update(window_props('episode_window', width=500, height=400))
263 class Config(dict):
264 Settings = gPodderSettings
266 # Number of seconds after which settings are auto-saved
267 WRITE_TO_DISK_TIMEOUT = 60
269 def __init__(self, filename='gpodder.conf'):
270 dict.__init__(self)
271 self.__save_thread = None
272 self.__filename = filename
273 self.__section = 'gpodder-conf-1'
274 self.__observers = []
276 self.load()
277 self.apply_fixes()
279 download_dir = os.environ.get('GPODDER_DOWNLOAD_DIR', None)
280 if download_dir is not None:
281 log('Setting download_dir from environment: %s', download_dir, sender=self)
282 self.download_dir = download_dir
284 atexit.register( self.__atexit)
286 def apply_fixes(self):
287 # Here you can add fixes in case syntax changes. These will be
288 # applied whenever a configuration file is loaded.
289 if '{channel' in self.custom_sync_name:
290 log('Fixing OLD syntax {channel.*} => {podcast.*} in custom_sync_name.', sender=self)
291 self.custom_sync_name = self.custom_sync_name.replace('{channel.', '{podcast.')
293 def __getattr__(self, name):
294 if name in self.Settings:
295 return self[name]
296 else:
297 raise AttributeError('%s is not a setting' % name)
299 def get_description(self, option_name):
300 description = _('No description available.')
302 if self.Settings.get(option_name) is not None:
303 row = self.Settings[option_name]
304 if len(row) >= 3:
305 description = row[2]
307 return description
309 def add_observer(self, callback):
311 Add a callback function as observer. This callback
312 will be called when a setting changes. It should
313 have this signature:
315 observer(name, old_value, new_value)
317 The "name" is the setting name, the "old_value" is
318 the value that has been overwritten with "new_value".
320 if callback not in self.__observers:
321 self.__observers.append(callback)
322 else:
323 log('Observer already added: %s', repr(callback), sender=self)
325 def remove_observer(self, callback):
327 Remove an observer previously added to this object.
329 if callback in self.__observers:
330 self.__observers.remove(callback)
331 else:
332 log('Observer not added :%s', repr(callback), sender=self)
334 def schedule_save(self):
335 if self.__save_thread is None:
336 self.__save_thread = threading.Thread(target=self.save_thread_proc)
337 self.__save_thread.setDaemon(True)
338 self.__save_thread.start()
340 def save_thread_proc(self):
341 time.sleep(self.WRITE_TO_DISK_TIMEOUT)
342 if self.__save_thread is not None:
343 self.save()
345 def __atexit(self):
346 if self.__save_thread is not None:
347 self.save()
349 def save(self, filename=None):
350 if filename is None:
351 filename = self.__filename
353 log('Flushing settings to disk', sender=self)
355 parser = ConfigParser.RawConfigParser()
356 parser.add_section(self.__section)
358 for key, value in self.Settings.items():
359 fieldtype, default = value[:2]
360 parser.set(self.__section, key, getattr(self, key, default))
362 try:
363 parser.write(open(filename, 'w'))
364 except:
365 log('Cannot write settings to %s', filename, sender=self)
366 raise IOError('Cannot write to file: %s' % filename)
368 self.__save_thread = None
370 def load(self, filename=None):
371 if filename is not None:
372 self.__filename = filename
374 parser = ConfigParser.RawConfigParser()
376 if os.path.exists(self.__filename):
377 try:
378 parser.read(self.__filename)
379 except:
380 log('Cannot parse config file: %s', self.__filename,
381 sender=self, traceback=True)
383 for key, value in self.Settings.items():
384 fieldtype, default = value[:2]
385 try:
386 if not parser.has_section(self.__section):
387 value = default
388 elif fieldtype == int:
389 value = parser.getint(self.__section, key)
390 elif fieldtype == float:
391 value = parser.getfloat(self.__section, key)
392 elif fieldtype == bool:
393 value = parser.getboolean(self.__section, key)
394 else:
395 value = fieldtype(parser.get(self.__section, key))
396 except:
397 log('Invalid value in %s for %s: %s', self.__filename,
398 key, value, sender=self, traceback=True)
399 value = default
401 self[key] = value
403 def toggle_flag(self, name):
404 if name in self.Settings:
405 (fieldtype, default) = self.Settings[name][:2]
406 if fieldtype == bool:
407 setattr(self, name, not getattr(self, name))
408 else:
409 log('Cannot toggle value: %s (not boolean)', name, sender=self)
410 else:
411 log('Invalid setting name: %s', name, sender=self)
413 def update_field(self, name, new_value):
414 if name in self.Settings:
415 (fieldtype, default) = self.Settings[name][:2]
416 try:
417 new_value = fieldtype(new_value)
418 except:
419 log('Cannot convert "%s" to %s. Ignoring.', str(new_value), fieldtype.__name__, sender=self)
420 return False
421 setattr(self, name, new_value)
422 return True
423 else:
424 log('Invalid setting name: %s', name, sender=self)
425 return False
427 def __setattr__(self, name, value):
428 if name in self.Settings:
429 fieldtype, default = self.Settings[name][:2]
430 try:
431 if self[name] != fieldtype(value):
432 old_value = self[name]
433 log('Update %s: %s => %s', name, old_value, value, sender=self)
434 self[name] = fieldtype(value)
435 for observer in self.__observers:
436 try:
437 # Notify observer about config change
438 observer(name, old_value, self[name])
439 except:
440 log('Error while calling observer: %s',
441 repr(observer), sender=self,
442 traceback=True)
443 self.schedule_save()
444 except:
445 raise ValueError('%s has to be of type %s' % (name, fieldtype.__name__))
446 else:
447 object.__setattr__(self, name, value)