settings.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. import json
  2. import os
  3. from .utils.appdirs import AppDirs
  4. try:
  5. from yaml import load as yaml_load, dump as yaml_dump, YAMLError
  6. except ImportError:
  7. useYAML = False
  8. else:
  9. useYAML = True
  10. try:
  11. from yaml import CLoader as YAMLLoader, CDumper as YAMLDumper
  12. except ImportError:
  13. from yaml import Loader as YAMLLoader, Dumper as YAMLDumper
  14. __all__ = ['ShowroomSettings', 'settings']
  15. ARGS_TO_SETTINGS = {
  16. "record_all": "filter.all",
  17. "output_dir": "directory.output",
  18. "data_dir": "directory.data",
  19. "index_dir": "directory.index",
  20. "config": "file.config",
  21. "max_priority": "throttle.max.priority",
  22. "max_watches": "throttle.max.watches",
  23. "max_downloads": "throttle.max.downloads",
  24. "live_rate": "throttle.rate.live",
  25. "schedule_rate": "throttle.rate.schedule",
  26. "names": "filter.wanted",
  27. "logging": "ffmpeg.logging",
  28. "noisy": "feedback.console",
  29. # "comments": "comments.record" # this was screwing up comment recording (setting it to always on)
  30. }
  31. _dirs = AppDirs('Showroom', appauthor=False)
  32. # TODO: refactor this into data.path, index.path, config.path, etc. ?
  33. # TODO: paths should automatically call expanduser, but they need to be marked as such
  34. DEFAULTS = {
  35. "directory": {
  36. "data": os.path.expanduser('~/Downloads/Showroom'),
  37. "output": '{data}',
  38. "index": None,
  39. "log": _dirs.user_log_dir,
  40. "config": _dirs.user_config_dir,
  41. # This setting is NOT respected by Downloader (it uses {output}/active always)
  42. "temp": '{data}/active'
  43. },
  44. "file": {
  45. "config": '{directory.config}/showroom.conf',
  46. "schedule": '{directory.data}/schedule.json',
  47. "completed": '{directory.data}/completed.json'
  48. },
  49. "throttle": {
  50. "max": {
  51. "downloads": 80,
  52. "watches": 50,
  53. "priority": 80
  54. },
  55. "rate": {
  56. "upcoming": 180.0,
  57. "onlives": 7.0,
  58. "watch": 2.0,
  59. "live": 60.0
  60. },
  61. "timeout": {
  62. "download": 23.0
  63. }
  64. },
  65. "ffmpeg": {
  66. "logging": False,
  67. "path": "ffmpeg",
  68. "container": "mp4" # mp4 or ts/TS
  69. },
  70. "filter": {
  71. "all": False,
  72. "wanted": [],
  73. "unwanted": []
  74. },
  75. "feedback": {
  76. "console": False, # this actually should be a loglevel
  77. "write_schedules_to_file": True
  78. },
  79. "system": {
  80. "make_symlinks": True,
  81. "symlink_dirs": ('log', 'config')
  82. },
  83. "comments": {
  84. "record": False,
  85. "default_update_interval": 7.0,
  86. "max_update_interval": 30.0,
  87. "min_update_interval": 2.0,
  88. "max_priority": 100
  89. },
  90. "environment": {}
  91. }
  92. _default_args = {
  93. "record_all": False,
  94. # "comments": False,
  95. "noisy": False,
  96. "logging": False,
  97. "names": []
  98. }
  99. DEFAULTS_NEW = {
  100. "data": {
  101. "path": os.path.expanduser('~/Downloads/Showroom'),
  102. },
  103. "output": {
  104. # TODO: remove this?
  105. "path": '{data.path}',
  106. },
  107. "index": {
  108. "path": '{data.path}/index',
  109. },
  110. "log": {
  111. "path": _dirs.user_log_dir,
  112. },
  113. "config": {
  114. # Is there a point to including this?
  115. "path": _dirs.user_config_dir + '/showroom.conf',
  116. },
  117. "temp": {
  118. # TODO: Fix downloader so it respects this
  119. "path": '{data}/active'
  120. },
  121. "file": {
  122. "config": '{config.path}/showroom.conf',
  123. "schedule": '{data.path}/schedule.json',
  124. "completed": '{data.path}/completed.json'
  125. },
  126. "throttle": {
  127. "max": {
  128. "downloads": 80,
  129. "watches": 50,
  130. "priority": 80
  131. },
  132. "rate": {
  133. "upcoming": 180.0,
  134. "onlives": 7.0,
  135. "watch": 2.0,
  136. "live": 60.0
  137. },
  138. "timeout": {
  139. "download": 23.0
  140. }
  141. },
  142. "ffmpeg": {
  143. "logging": False,
  144. "path": "ffmpeg",
  145. "container": "mp4"
  146. },
  147. "filter": {
  148. "all": False,
  149. "wanted": [],
  150. "unwanted": []
  151. },
  152. "feedback": {
  153. "console": False, # this actually should be a loglevel
  154. "write_schedules_to_file": True
  155. },
  156. "system": {
  157. # TODO: Fix this to work with the new paths
  158. "make_symlinks": True,
  159. "symlink_dirs": ('log', 'config')
  160. },
  161. "comments": {
  162. "record": False,
  163. "default_update_interval": 7.0,
  164. "max_update_interval": 30.0,
  165. "min_update_interval": 2.0,
  166. "max_priority": 100
  167. },
  168. "environment": {}
  169. }
  170. def _clean_args(args):
  171. new_args = {}
  172. for k, v in vars(args).items():
  173. if _default_args.get(k) != v:
  174. new_args[k] = v
  175. return new_args
  176. def load_config(path):
  177. # TODO: support old-style setting names? i.e. pass them through ARGS_TO_SETTINGS ?
  178. data = {}
  179. yaml_err = ""
  180. if useYAML:
  181. try:
  182. # this assumes only one document
  183. with open(path, encoding='utf8') as infp:
  184. data = yaml_load(infp, Loader=YAMLLoader)
  185. except FileNotFoundError:
  186. return {}
  187. except YAMLError as e:
  188. yaml_err = 'YAML parsing error in file {}'.format(path)
  189. if hasattr(e, 'problem_mark'):
  190. mark = e.problem_mark
  191. yaml_err + '\nError on Line:{} Column:{}'.format(mark.line + 1, mark.column + 1)
  192. else:
  193. return _convert_old_config(data)
  194. try:
  195. with open(path, encoding='utf8') as infp:
  196. data = json.load(infp)
  197. except FileNotFoundError:
  198. return {}
  199. except json.JSONDecodeError as e:
  200. if useYAML and yaml_err:
  201. print(yaml_err)
  202. else:
  203. print('JSON parsing error in file {}'.format(path),
  204. 'Error on Line: {} Column: {}'.format(e.lineno, e.colno), sep='\n')
  205. data = _convert_old_config(data)
  206. # if 'directory' in data:
  207. # for k, v in data['directory'].items():
  208. # data['directory'][k] = os.path.expanduser(v)
  209. return data
  210. def _convert_old_config(config_data):
  211. new_data = config_data.copy()
  212. for key in config_data.keys():
  213. if key in ARGS_TO_SETTINGS:
  214. # what will SettingsDict do with stuff like:
  215. # {"directory": {"data": "data"},
  216. # "directory.data": "data"}
  217. new_key = ARGS_TO_SETTINGS[key]
  218. new_data[new_key] = config_data[key]
  219. else:
  220. new_data[key] = config_data[key]
  221. return new_data
  222. # inherit from mapping or dict?
  223. class SettingsDict(dict):
  224. """
  225. Holds a mutable collection of items, all addressable either by .name or by
  226. [key].
  227. Each key must be either a string or an int, in the case of ints, the key will
  228. be converted to a string. i.e. sd[0] is the same as sd['0']
  229. Actually though do I basically just want a SimpleNamespace?
  230. """
  231. def __init__(self, sub_dict: dict, top=None):
  232. super().__init__()
  233. sub_dict = sub_dict.copy()
  234. self._dict = {}
  235. self._formatting = False
  236. if top is None:
  237. self._top = self
  238. else:
  239. self._top = top
  240. self._dict.update(self.__wrap_dicts(sub_dict))
  241. def __repr__(self):
  242. r = []
  243. for key in self.keys():
  244. r.append('{k}: {v}'.format(k=repr(key), v=repr(self[key])))
  245. return '{' + ', '.join(r) + '}'
  246. def __wrap_dicts(self, dct):
  247. for key in dct:
  248. dct[key] = self.__wrap(dct[key])
  249. return dct
  250. def __wrap_lists(self, lst):
  251. for i in range(len(lst)):
  252. lst[i] = self.__wrap(lst[i])
  253. return lst
  254. def __wrap(self, item):
  255. if type(item) is not type(self):
  256. # too much redundancy
  257. if isinstance(item, dict):
  258. item = SettingsDict(item, self._top)
  259. elif isinstance(item, list):
  260. # tuples can't be changed, sets can't hold unhashable types
  261. item = self.__wrap_lists(item)
  262. return item
  263. def __getattr__(self, name):
  264. return self[name]
  265. def __setattr__(self, name, value):
  266. self[name] = value
  267. def __getitem__(self, key):
  268. if super().__contains__(key) or key.startswith('_'):
  269. return super().__getitem__(key)
  270. elif '.' in key:
  271. key, subkeys = key.split('.', 1)
  272. val = self._dict[key][subkeys]
  273. else:
  274. val = self._dict.get(key)
  275. if val and key == 'path':
  276. # atm this only works for ffmpeg.path, where it isn't usually needed
  277. # in the future I will fix this so directory.data -> data.path etc.
  278. # and home-relative paths can finally be used
  279. val = os.path.expanduser(val)
  280. if self._top._formatting and isinstance(val, str) and '{' in val:
  281. try:
  282. return val.format(**self._dict)
  283. except KeyError:
  284. return val.format(**self._top._dict)
  285. return val
  286. def __setitem__(self, key, value):
  287. if super().__contains__(key) or key.startswith('_'):
  288. super().__setitem__(key, value)
  289. elif '.' in key:
  290. key, subkeys = key.split('.', 1)
  291. self._dict[key][subkeys] = self.__wrap(value)
  292. else:
  293. self._dict[key] = self.__wrap(value)
  294. def __delitem__(self, key):
  295. if super().__contains__(key) or key.startswith('_'):
  296. super().__delitem__(key)
  297. elif '.' in key:
  298. key, subkeys = key.split('.', 1)
  299. del self._dict[key][subkeys]
  300. else:
  301. del self._dict[key]
  302. def __iter__(self):
  303. return (k for k in self._dict)
  304. def __len__(self):
  305. return len(self._dict)
  306. def __contains__(self, item):
  307. # TODO: work with dot notation too
  308. return item in self._dict
  309. def keys(self):
  310. return self._dict.keys()
  311. def items(self):
  312. for key in self._dict.keys():
  313. yield key, self[key]
  314. def update(self, other=None, **kwargs):
  315. if other is not None:
  316. for key, o_val in other.items():
  317. s_val = self[key] if key in self else None
  318. if isinstance(o_val, dict) and isinstance(s_val, SettingsDict):
  319. s_val.update(o_val)
  320. elif o_val is not None:
  321. self[key] = o_val
  322. for key in kwargs:
  323. s_val = self.get(key)
  324. o_val = kwargs[key]
  325. if isinstance(o_val, SettingsDict) and isinstance(s_val, SettingsDict):
  326. s_val.update(o_val)
  327. elif o_val is not None:
  328. self[key] = o_val
  329. class ShowroomSettings(SettingsDict):
  330. def __init__(self, settings_dict: dict=DEFAULTS):
  331. self._formatting = False
  332. super().__init__(settings_dict, top=self)
  333. self._formatting = True
  334. # TODO: add some properties like e.g. curr_time, curr_date that can be including in formatting specifiers
  335. @classmethod
  336. def from_file(cls, path=None):
  337. new = cls(DEFAULTS)
  338. if not path:
  339. path = new.file.config
  340. config_data = load_config(path)
  341. new.update(config_data)
  342. new.makedirs(new)
  343. return new
  344. @classmethod
  345. def from_args(cls, args):
  346. args = _clean_args(args)
  347. new = cls.from_file(path=args.get('config', None))
  348. args_data = {}
  349. for key in args:
  350. if args[key] is not None and key in ARGS_TO_SETTINGS:
  351. new_key = ARGS_TO_SETTINGS[key]
  352. args_data[new_key] = args[key]
  353. # for k, v in args_data.items():
  354. # if k.startswith('directory'):
  355. # args_data[k] = os.path.expanduser(v)
  356. new.update(args_data)
  357. new.makedirs(new)
  358. return new
  359. @staticmethod
  360. def makedirs(settings):
  361. links = []
  362. for dir_key, dir_path in settings.directory.items():
  363. if dir_path is None:
  364. continue
  365. os.makedirs(dir_path, exist_ok=True)
  366. if dir_key in settings.system.symlink_dirs:
  367. links.append((dir_key, dir_path))
  368. if settings.system.make_symlinks:
  369. for item in links:
  370. # symlinks the log, index, and config folders to the data directory
  371. # TODO: whenever these three directories are changed, remove the old symlinks
  372. # and create new ones
  373. dest_path = os.path.join(settings.directory.data, item[0])
  374. if os.path.exists(dest_path):
  375. continue
  376. else:
  377. os.symlink(os.path.abspath(item[1]), dest_path, target_is_directory=True)
  378. settings = ShowroomSettings.from_file()
  379. # old defaults, for reference
  380. """
  381. DEFAULTS = {"output_dir": "output",
  382. "index_dir": "index",
  383. "log_dir": "logs",
  384. "config_dir": _dirs.user_config_dir,
  385. "config_file": 'config.json',
  386. # TODO: allow using {output_dir}/data or similar directly in config file
  387. "data_dir": None, # if None, defaults to {output_dir}/data
  388. # "schedule_file": 'schedule.json',
  389. # "completed_file": 'completed.json',
  390. "record_all": False,
  391. # maximums
  392. "max_downloads": 80,
  393. "max_watches": 50,
  394. "max_priority": 80,
  395. # manager rate control
  396. "upcoming_rate": 180.0, # check api/live/upcoming
  397. "onlives_rate": 7.0, # check api/live/onlives
  398. # watcher rate control
  399. "watch_rate": 2.0, # check if watched room is live
  400. "live_rate": 60.0, # check if unwanted stream is still live
  401. "download_timeout": 23.0, # too short and we lose the start of the stream
  402. # end of day
  403. # "end_hour": 4,
  404. # "resume_hour": 5,
  405. # ffmpeg flags
  406. "ffmpeg_logging": True,
  407. # TODO: proper verbosity levels
  408. "noisy": False,
  409. 'write_schedules_to_file': True}
  410. """
  411. # sample yaml config file, mostly without values
  412. """
  413. directory:
  414. data: null # ~/Downloads/Showroom
  415. output: {data}
  416. index: index
  417. log: null
  418. config: null
  419. temp: {data}/active
  420. file:
  421. config: showroom.conf
  422. schedule: schedule.json
  423. completed: completed.json
  424. throttle:
  425. max:
  426. downloads: 80
  427. watches: 50
  428. priority: 80
  429. rate:
  430. upcoming: 180.0
  431. onlives: 7.0
  432. watch: 2.0
  433. live: 60.0
  434. timeout:
  435. download: 23.0
  436. ffmpeg:
  437. logging: true
  438. filter:
  439. all: false
  440. wanted: []
  441. unwanted: []
  442. feedback:
  443. console: false
  444. write_schedules_file: true
  445. """