Sort episodes by published date when sending to folder
[gpodder.git] / src / gpodder / config.py
blob7182a216bb05bd8b3af6af1c1201e21a599cbd98
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2018 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 atexit
28 import logging
29 import os
30 import time
32 import gpodder
33 from gpodder import jsonconfig, util
35 _ = gpodder.gettext
37 defaults = {
38 # External applications used for playback
39 'player': {
40 'audio': 'default',
41 'video': 'default',
44 # gpodder.net settings
45 'mygpo': {
46 'enabled': False,
47 'server': 'gpodder.net',
48 'username': '',
49 'password': '',
50 'device': {
51 'uid': util.get_hostname(),
52 'type': 'desktop',
53 'caption': _('gPodder on %s') % util.get_hostname(),
57 # Various limits (downloading, updating, etc..)
58 'limit': {
59 'bandwidth': {
60 'enabled': False,
61 'kbps': 500.0, # maximum kB/s per download
63 'downloads': {
64 'enabled': True,
65 'concurrent': 1,
66 'concurrent_max': 16,
68 'episodes': 200, # max episodes per feed
71 # Behavior of downloads
72 'downloads': {
73 'chronological_order': True, # download older episodes first
76 # Automatic feed updates, download removal and retry on download timeout
77 'auto': {
78 'update': {
79 'enabled': False,
80 'frequency': 20, # minutes
83 'cleanup': {
84 'days': 7,
85 'played': False,
86 'unplayed': False,
87 'unfinished': True,
90 'retries': 3, # number of retries when downloads time out
93 'check_connection': True,
95 # Software updates from gpodder.org
96 'software_update': {
97 'check_on_startup': True, # check for updates on start
98 'last_check': 0, # unix timestamp of last update check
99 'interval': 5, # interval (in days) to check for updates
102 'ui': {
103 # Settings for the Command-Line Interface
104 'cli': {
105 'colors': True,
108 # Settings for the Gtk UI
109 'gtk': {
110 'state': {
111 'main_window': {
112 'width': 700,
113 'height': 500,
114 'x': -1, 'y': -1, 'maximized': False,
116 'paned_position': 200,
117 'episode_list_size': 200,
119 'episode_column_sort_id': 0,
120 'episode_column_sort_order': False,
121 'episode_column_order': [],
123 'podcastdirectory': {
124 'width': -1,
125 'height': -1,
126 'x': -1, 'y': -1, 'maximized': False,
128 'preferences': {
129 'width': -1,
130 'height': -1,
131 'x': -1, 'y': -1, 'maximized': False,
133 'config_editor': {
134 'width': -1,
135 'height': -1,
136 'x': -1, 'y': -1, 'maximized': False,
138 'channel_editor': {
139 'width': -1,
140 'height': -1,
141 'x': -1, 'y': -1, 'maximized': False,
143 'episode_selector': {
144 'width': 600,
145 'height': 400,
146 'x': -1, 'y': -1, 'maximized': False,
148 'episode_window': {
149 'width': 500,
150 'height': 400,
151 'x': -1, 'y': -1, 'maximized': False,
153 'export_to_local_folder': {
154 'width': 500,
155 'height': 400,
156 'x': -1, 'y': -1, 'maximized': False,
160 'toolbar': False,
161 'new_episodes': 'show', # ignore, show, queue, download
162 'only_added_are_new': False, # Only just added episodes are considered new after an update
163 'live_search_delay': 200,
164 'search_always_visible': False,
165 'find_as_you_type': True,
167 'podcast_list': {
168 'view_mode': 1,
169 'hide_empty': False,
170 'all_episodes': True,
171 'sections': True,
174 'episode_list': {
175 'view_mode': 1,
176 'always_show_new': True,
177 'trim_title_prefix': True,
178 'descriptions': True,
179 'show_released_time': False,
180 'right_align_released_column': False,
181 'ctrl_click_to_sort': False,
182 'columns': int('110', 2), # bitfield of visible columns
185 'download_list': {
186 'remove_finished': True,
189 'html_shownotes': True, # enable webkit renderer
190 'color_scheme': None, # system, light or dark. Initialized in app.py
194 # Synchronization with portable devices (MP3 players, etc..)
195 'device_sync': {
196 'device_type': 'none', # Possible values: 'none', 'filesystem', 'ipod'
197 'device_folder': '/media',
199 'one_folder_per_podcast': True,
200 'skip_played_episodes': True,
201 'delete_played_episodes': False,
202 'delete_deleted_episodes': False,
204 'max_filename_length': 120,
206 'compare_episode_filesize': True,
208 'custom_sync_name': '{episode.sortdate}_{episode.title}',
209 'custom_sync_name_enabled': False,
211 'after_sync': {
212 'mark_episodes_played': False,
213 'delete_episodes': False,
214 'sync_disks': False,
216 'playlists': {
217 'create': True,
218 'two_way_sync': False,
219 'use_absolute_path': True,
220 'folder': 'Playlists',
221 'extension': 'm3u',
226 'youtube': {
227 'preferred_fmt_id': 18, # default fmt_id (see fallbacks in youtube.py)
228 'preferred_fmt_ids': [], # for advanced uses (custom fallback sequence)
229 'preferred_hls_fmt_id': 93, # default fmt_id (see fallbacks in youtube.py)
230 'preferred_hls_fmt_ids': [], # for advanced uses (custom fallback sequence)
233 'vimeo': {
234 'fileformat': '720p', # preferred file format (see vimeo.py)
237 'network': {
238 'use_proxy': False,
239 'proxy_type': 'socks5h', # Possible values: socks5h (routes dns through the proxy), socks5, http
240 'proxy_hostname': '127.0.0.1',
241 'proxy_port': '8123',
242 'proxy_use_username_password': False,
243 'proxy_username': '',
244 'proxy_password': '',
247 'extensions': {
248 'enabled': [],
250 'sendto': {
251 'custom_file_format': '{episode.title}',
252 'custom_file_format_enabled': False,
256 logger = logging.getLogger(__name__)
258 # Global variable for network proxies. Updated when the network proxy in the config changes
259 _proxies = None
262 def get_network_proxy_observer(config):
263 """Return an observer function inside a closure containing given config instance."""
265 def get_proxies_from_config(config):
266 proxies = None
267 if config.network.use_proxy:
268 protocol = config.network.proxy_type
269 user_pass = ""
270 if config.network.proxy_use_username_password:
271 user_pass = f"{config.network.proxy_username}:{config.network.proxy_password}@"
272 proxy_url = f"{protocol}://{user_pass}{config.network.proxy_hostname}:{config.network.proxy_port}"
273 proxies = {"http": proxy_url, "https": proxy_url}
274 logger.debug(f"config observer returning proxies: {proxies}")
275 return proxies
277 def network_proxy_observer(name, old_value, new_value):
278 global _proxies
279 if name.startswith("network."):
280 _proxies = get_proxies_from_config(config)
282 return network_proxy_observer
285 def config_value_to_string(config_value):
286 config_type = type(config_value)
288 if config_type == list:
289 return ','.join(map(config_value_to_string, config_value))
290 elif config_type in (str, str):
291 return config_value
292 else:
293 return str(config_value)
296 def string_to_config_value(new_value, old_value):
297 config_type = type(old_value)
299 if config_type == list:
300 return [_f for _f in [x.strip() for x in new_value.split(',')] if _f]
301 elif config_type == bool:
302 return (new_value.strip().lower() in ('1', 'true'))
303 else:
304 return config_type(new_value)
307 class Config(object):
308 # Number of seconds after which settings are auto-saved
309 WRITE_TO_DISK_TIMEOUT = 60
311 def __init__(self, filename='gpodder.json'):
312 self.__json_config = jsonconfig.JsonConfig(default=defaults,
313 on_key_changed=self._on_key_changed)
314 self.__save_thread = None
315 self.__filename = filename
316 self.__observers = []
318 self.load()
319 self.migrate_defaults()
321 # If there is no configuration file, we create one here (bug 1511)
322 if not os.path.exists(self.__filename):
323 self.save()
325 atexit.register(self.__atexit)
327 def register_defaults(self, defaults):
329 Register default configuration options (e.g. for extensions)
331 This function takes a dictionary that will be merged into the
332 current configuration if the keys don't yet exist. This can
333 be used to add a default configuration for extension modules.
335 self.__json_config._merge_keys(defaults)
337 def add_observer(self, callback):
339 Add a callback function as observer. This callback
340 will be called when a setting changes. It should
341 have this signature:
343 observer(name, old_value, new_value)
345 The "name" is the setting name, the "old_value" is
346 the value that has been overwritten with "new_value".
348 if callback not in self.__observers:
349 self.__observers.append(callback)
350 else:
351 logger.warning('Observer already added: %s', repr(callback))
353 def remove_observer(self, callback):
355 Remove an observer previously added to this object.
357 if callback in self.__observers:
358 self.__observers.remove(callback)
359 else:
360 logger.warning('Observer not added: %s', repr(callback))
362 def all_keys(self):
363 return self.__json_config._keys_iter()
365 def schedule_save(self):
366 if self.__save_thread is None:
367 self.__save_thread = util.run_in_background(self.save_thread_proc, True)
369 def save_thread_proc(self):
370 time.sleep(self.WRITE_TO_DISK_TIMEOUT)
371 if self.__save_thread is not None:
372 self.save()
374 def __atexit(self):
375 if self.__save_thread is not None:
376 self.save()
378 def save(self, filename=None):
379 if filename is None:
380 filename = self.__filename
382 logger.info('Flushing settings to disk')
384 try:
385 # revoke unix group/world permissions (this has no effect under windows)
386 umask = os.umask(0o077)
387 with open(filename + '.tmp', 'wt') as fp:
388 fp.write(repr(self.__json_config))
389 util.atomic_rename(filename + '.tmp', filename)
390 except:
391 logger.error('Cannot write settings to %s', filename)
392 util.delete_file(filename + '.tmp')
393 raise
394 finally:
395 os.umask(umask)
397 self.__save_thread = None
399 def load(self, filename=None):
400 if filename is not None:
401 self.__filename = filename
403 if os.path.exists(self.__filename):
404 try:
405 with open(self.__filename, 'rt') as f:
406 data = f.read()
407 new_keys_added = self.__json_config._restore(data)
408 except:
409 logger.warning('Cannot parse config file: %s',
410 self.__filename, exc_info=True)
411 new_keys_added = False
413 if new_keys_added:
414 logger.info('New default keys added - saving config.')
415 self.save()
417 def toggle_flag(self, name):
418 setattr(self, name, not getattr(self, name))
420 def update_field(self, name, new_value):
421 """Update a config field, converting strings to the right types"""
422 old_value = self._lookup(name)
423 new_value = string_to_config_value(new_value, old_value)
424 setattr(self, name, new_value)
425 return True
427 def _on_key_changed(self, name, old_value, value):
428 if 'ui.gtk.state' not in name:
429 # Only log non-UI state changes
430 logger.debug('%s: %s -> %s', name, old_value, value)
431 for observer in self.__observers:
432 try:
433 observer(name, old_value, value)
434 except Exception as exception:
435 logger.error('Error while calling observer %r: %s',
436 observer, exception, exc_info=True)
438 self.schedule_save()
440 def __getattr__(self, name):
441 return getattr(self.__json_config, name)
443 def __setattr__(self, name, value):
444 if name.startswith('_'):
445 object.__setattr__(self, name, value)
446 return
448 setattr(self.__json_config, name, value)
450 def migrate_defaults(self):
451 """ change default values in config """
452 if self.device_sync.max_filename_length == 999:
453 logger.debug("setting config.device_sync.max_filename_length=120"
454 " (999 is bad for NTFS and ext{2-4})")
455 self.device_sync.max_filename_length = 120
457 def clamp_range(self, name, minval, maxval):
458 value = getattr(self, name)
459 if value < minval:
460 setattr(self, name, minval)
461 return True
462 if value > maxval:
463 setattr(self, name, maxval)
464 return True
465 return False