settings.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. from youtube import util
  2. import ast
  3. import re
  4. import os
  5. import collections
  6. import flask
  7. from flask import request
  8. SETTINGS_INFO = collections.OrderedDict([
  9. ('app_public', {
  10. 'type': bool,
  11. 'default': False,
  12. 'comment': '''Set app public mode, disabled by default''',
  13. 'hidden': True,
  14. 'category': 'network',
  15. }),
  16. ('app_url', {
  17. 'type': str,
  18. 'default': 'http://localhost',
  19. 'comment': '''Set URL of app 'http://localhost' by default''',
  20. 'hidden': True,
  21. 'category': 'network',
  22. }),
  23. ('route_tor', {
  24. 'type': int,
  25. 'default': 0,
  26. 'label': 'Route Tor',
  27. 'comment': '''0 - Off
  28. 1 - On, except video
  29. 2 - On, including video (see warnings)''',
  30. 'options': [
  31. (0, 'Off'),
  32. (1, 'On, except video'),
  33. (2, 'On, including video (see warnings)'),
  34. ],
  35. 'category': 'network',
  36. }),
  37. ('tor_port', {
  38. 'type': int,
  39. 'default': 9150,
  40. 'comment': '',
  41. 'category': 'network',
  42. }),
  43. ('tor_control_port', {
  44. 'type': int,
  45. 'default': 9151,
  46. 'comment': '',
  47. 'category': 'network',
  48. }),
  49. ('port_number', {
  50. 'type': int,
  51. 'default': 8080,
  52. 'comment': '',
  53. 'category': 'network',
  54. }),
  55. ('allow_foreign_addresses', {
  56. 'type': bool,
  57. 'default': False,
  58. 'comment': '''This will allow others to connect to your YT Local instance as a website.
  59. For security reasons, enabling this is not recommended.''',
  60. 'hidden': True,
  61. 'category': 'network',
  62. }),
  63. ('allow_foreign_post_requests', {
  64. 'type': bool,
  65. 'default': False,
  66. 'comment': '''Enables requests from foreign addresses to make post requests.
  67. For security reasons, enabling this is not recommended.''',
  68. 'hidden': True,
  69. 'category': 'network',
  70. }),
  71. ('subtitles_mode', {
  72. 'type': int,
  73. 'default': 0,
  74. 'comment': '''0 - off by default
  75. 1 - only manually created subtitles on by default
  76. 2 - enable even if automatically generated is all that's available''',
  77. 'label': 'Default subtitles mode',
  78. 'options': [
  79. (0, 'Off'),
  80. (1, 'Manually created only'),
  81. (2, 'Automatic if manual unavailable'),
  82. ],
  83. 'category': 'playback',
  84. }),
  85. ('subtitles_language', {
  86. 'type': str,
  87. 'default': 'en',
  88. 'comment': '''ISO 639 language code: https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes''',
  89. 'category': 'playback',
  90. }),
  91. ('related_videos_mode', {
  92. 'type': int,
  93. 'default': 1,
  94. 'comment': '''0 - Related videos disabled
  95. 1 - Related videos always shown
  96. 2 - Related videos hidden; shown by clicking a button''',
  97. 'options': [
  98. (0, 'Disabled'),
  99. (1, 'Always shown'),
  100. (2, 'Shown by clicking button'),
  101. ],
  102. 'category': 'interface',
  103. }),
  104. ('comments_mode', {
  105. 'type': int,
  106. 'default': 1,
  107. 'comment': '''0 - Video comments disabled
  108. 1 - Video comments always shown
  109. 2 - Video comments hidden; shown by clicking a button''',
  110. 'options': [
  111. (0, 'Disabled'),
  112. (1, 'Always shown'),
  113. (2, 'Shown by clicking button'),
  114. ],
  115. 'category': 'interface',
  116. }),
  117. ('enable_comment_avatars', {
  118. 'type': bool,
  119. 'default': True,
  120. 'comment': '',
  121. 'category': 'interface',
  122. }),
  123. ('default_comment_sorting', {
  124. 'type': int,
  125. 'default': 0,
  126. 'comment': '''0 to sort by top
  127. 1 to sort by newest''',
  128. 'options': [
  129. (0, 'Top'),
  130. (1, 'Newest'),
  131. ],
  132. }),
  133. ('theater_mode', {
  134. 'type': bool,
  135. 'default': True,
  136. 'comment': '',
  137. 'category': 'interface',
  138. }),
  139. ('default_resolution', {
  140. 'type': int,
  141. 'default': 720,
  142. 'comment': '',
  143. 'options': [
  144. (360, '360p'),
  145. (480, '480p'),
  146. (720, '720p'),
  147. ],
  148. 'category': 'playback',
  149. }),
  150. ('use_video_player', {
  151. 'type': int,
  152. 'default': 1,
  153. 'comment': '',
  154. 'options': [
  155. (0, 'Native'),
  156. (1, 'Native with hotkeys'),
  157. (2, 'Plyr'),
  158. ],
  159. 'category': 'interface',
  160. }),
  161. ('proxy_images', {
  162. 'label': 'Route images',
  163. 'type': bool,
  164. 'default': True,
  165. 'comment': '',
  166. 'category': 'network',
  167. }),
  168. ('use_comments_js', {
  169. 'label': 'Enable comments.js',
  170. 'type': bool,
  171. 'default': True,
  172. 'comment': '',
  173. 'category': 'interface',
  174. }),
  175. ('use_sponsorblock_js', {
  176. 'label': 'Enable SponsorBlock',
  177. 'type': bool,
  178. 'default': False,
  179. 'comment': '',
  180. 'category': 'playback',
  181. }),
  182. ('theme', {
  183. 'type': int,
  184. 'default': 0,
  185. 'comment': '',
  186. 'options': [
  187. (0, 'Light'),
  188. (1, 'Gray'),
  189. (2, 'Dark'),
  190. ],
  191. 'category': 'interface',
  192. }),
  193. ('font', {
  194. 'type': int,
  195. 'default': 1,
  196. 'comment': '',
  197. 'options': [
  198. (0, 'Browser default'),
  199. (1, 'Liberation Serif'),
  200. (2, 'Arial'),
  201. (3, 'Verdana'),
  202. (4, 'Tahoma'),
  203. ],
  204. 'category': 'interface',
  205. }),
  206. ('embed_page_mode', {
  207. 'type': bool,
  208. 'label': 'Enable embed page',
  209. 'default': True,
  210. 'comment': '',
  211. 'category': 'interface',
  212. }),
  213. ('autocheck_subscriptions', {
  214. 'type': bool,
  215. 'default': 0,
  216. 'comment': '',
  217. }),
  218. ('gather_googlevideo_domains', {
  219. 'type': bool,
  220. 'default': False,
  221. 'comment': '''Developer use to debug 403s''',
  222. 'hidden': True,
  223. }),
  224. ('debugging_save_responses', {
  225. 'type': bool,
  226. 'default': False,
  227. 'comment': '''Save all responses from youtube for debugging''',
  228. 'hidden': True,
  229. }),
  230. ('settings_version', {
  231. 'type': int,
  232. 'default': 3,
  233. 'comment': '''Do not change, remove, or comment out this value, or else your settings may be lost or corrupted''',
  234. 'hidden': True,
  235. }),
  236. ])
  237. program_directory = os.path.dirname(os.path.realpath(__file__))
  238. acceptable_targets = SETTINGS_INFO.keys() | {'enable_comments', 'enable_related_videos'}
  239. def comment_string(comment):
  240. result = ''
  241. for line in comment.splitlines():
  242. result += '# ' + line + '\n'
  243. return result
  244. def save_settings(settings_dict):
  245. with open(settings_file_path, 'w', encoding='utf-8') as file:
  246. for setting_name, setting_info in SETTINGS_INFO.items():
  247. file.write(comment_string(setting_info['comment']) + setting_name + ' = ' + repr(settings_dict[setting_name]) + '\n\n')
  248. def add_missing_settings(settings_dict):
  249. result = default_settings()
  250. result.update(settings_dict)
  251. return result
  252. def default_settings():
  253. return {key: setting_info['default'] for key, setting_info in SETTINGS_INFO.items()}
  254. def upgrade_to_2(settings_dict):
  255. '''Upgrade to settings version 2'''
  256. new_settings = settings_dict.copy()
  257. if 'enable_comments' in settings_dict:
  258. new_settings['comments_mode'] = int(settings_dict['enable_comments'])
  259. del new_settings['enable_comments']
  260. if 'enable_related_videos' in settings_dict:
  261. new_settings['related_videos_mode'] = int(settings_dict['enable_related_videos'])
  262. del new_settings['enable_related_videos']
  263. new_settings['settings_version'] = 2
  264. return new_settings
  265. def upgrade_to_3(settings_dict):
  266. new_settings = settings_dict.copy()
  267. if 'route_tor' in settings_dict:
  268. new_settings['route_tor'] = int(settings_dict['route_tor'])
  269. new_settings['settings_version'] = 3
  270. return new_settings
  271. upgrade_functions = {
  272. 1: upgrade_to_2,
  273. 2: upgrade_to_3,
  274. }
  275. def log_ignored_line(line_number, message):
  276. print("WARNING: Ignoring settings.txt line " + str(node.lineno) + " (" + message + ")")
  277. if os.path.isfile("settings.txt"):
  278. print("Running in portable mode")
  279. settings_dir = os.path.normpath('./')
  280. data_dir = os.path.normpath('./data')
  281. else:
  282. print("Running in non-portable mode")
  283. settings_dir = os.path.expanduser(os.path.normpath("~/.youtube-local"))
  284. data_dir = os.path.expanduser(os.path.normpath("~/.youtube-local/data"))
  285. if not os.path.exists(settings_dir):
  286. os.makedirs(settings_dir)
  287. settings_file_path = os.path.join(settings_dir, 'settings.txt')
  288. try:
  289. with open(settings_file_path, 'r', encoding='utf-8') as file:
  290. settings_text = file.read()
  291. except FileNotFoundError:
  292. current_settings_dict = default_settings()
  293. save_settings(current_settings_dict)
  294. else:
  295. if re.fullmatch(r'\s*', settings_text): # blank file
  296. current_settings_dict = default_settings()
  297. save_settings(current_settings_dict)
  298. else:
  299. # parse settings in a safe way, without exec
  300. current_settings_dict = {}
  301. attributes = {
  302. ast.Constant: 'value',
  303. ast.NameConstant: 'value',
  304. ast.Num: 'n',
  305. ast.Str: 's',
  306. }
  307. module_node = ast.parse(settings_text)
  308. for node in module_node.body:
  309. if type(node) != ast.Assign:
  310. log_ignored_line(node.lineno, "only assignments are allowed")
  311. continue
  312. if len(node.targets) > 1:
  313. log_ignored_line(node.lineno, "only simple single-variable assignments allowed")
  314. continue
  315. target = node.targets[0]
  316. if type(target) != ast.Name:
  317. log_ignored_line(node.lineno, "only simple single-variable assignments allowed")
  318. continue
  319. if target.id not in acceptable_targets:
  320. log_ignored_line(node.lineno, target.id + " is not a valid setting")
  321. continue
  322. if type(node.value) not in attributes:
  323. log_ignored_line(node.lineno, "only literals allowed for values")
  324. continue
  325. current_settings_dict[target.id] = node.value.__getattribute__(attributes[type(node.value)])
  326. # upgrades
  327. latest_version = SETTINGS_INFO['settings_version']['default']
  328. while current_settings_dict.get('settings_version', 1) < latest_version:
  329. current_version = current_settings_dict.get('settings_version', 1)
  330. print('Upgrading settings.txt to version', current_version+1)
  331. upgrade_func = upgrade_functions[current_version]
  332. # Must add missing settings here rather than below because
  333. # save_settings needs all settings to be present
  334. current_settings_dict = add_missing_settings(
  335. upgrade_func(current_settings_dict))
  336. save_settings(current_settings_dict)
  337. # some settings not in the file, add those missing settings to the file
  338. if not current_settings_dict.keys() >= SETTINGS_INFO.keys():
  339. print('Adding missing settings to settings.txt')
  340. current_settings_dict = add_missing_settings(current_settings_dict)
  341. save_settings(current_settings_dict)
  342. globals().update(current_settings_dict)
  343. if route_tor:
  344. print("Tor routing is ON")
  345. else:
  346. print("Tor routing is OFF - your YouTube activity is NOT anonymous")
  347. hooks = {}
  348. def add_setting_changed_hook(setting, func):
  349. '''Called right after new settings take effect'''
  350. if setting in hooks:
  351. hooks[setting].append(func)
  352. else:
  353. hooks[setting] = [func]
  354. def set_img_prefix(old_value=None, value=None):
  355. global img_prefix
  356. if value is None:
  357. value = proxy_images
  358. if value:
  359. img_prefix = '/'
  360. else:
  361. img_prefix = ''
  362. set_img_prefix()
  363. add_setting_changed_hook('proxy_images', set_img_prefix)
  364. categories = ['network', 'interface', 'playback', 'other']
  365. def settings_page():
  366. if request.method == 'GET':
  367. settings_by_category = {categ: [] for categ in categories}
  368. for setting_name, setting_info in SETTINGS_INFO.items():
  369. categ = setting_info.get('category', 'other')
  370. settings_by_category[categ].append(
  371. (setting_name, setting_info, current_settings_dict[setting_name])
  372. )
  373. return flask.render_template(
  374. 'settings.html',
  375. categories=categories,
  376. settings_by_category=settings_by_category,
  377. )
  378. elif request.method == 'POST':
  379. for key, value in request.values.items():
  380. if key in SETTINGS_INFO:
  381. if SETTINGS_INFO[key]['type'] is bool and value == 'on':
  382. current_settings_dict[key] = True
  383. else:
  384. current_settings_dict[key] = SETTINGS_INFO[key]['type'](value)
  385. else:
  386. flask.abort(400)
  387. # need this bullshit because browsers don't send anything when an input is unchecked
  388. expected_inputs = {setting_name for setting_name, setting_info in SETTINGS_INFO.items() if not SETTINGS_INFO[setting_name].get('hidden', False)}
  389. missing_inputs = expected_inputs - set(request.values.keys())
  390. for setting_name in missing_inputs:
  391. assert SETTINGS_INFO[setting_name]['type'] is bool, missing_inputs
  392. current_settings_dict[setting_name] = False
  393. # find settings that have changed to prepare setting hook calls
  394. to_call = []
  395. for setting_name, value in current_settings_dict.items():
  396. old_value = globals()[setting_name]
  397. if value != old_value and setting_name in hooks:
  398. for func in hooks[setting_name]:
  399. to_call.append((func, old_value, value))
  400. globals().update(current_settings_dict)
  401. save_settings(current_settings_dict)
  402. # call setting hooks
  403. for func, old_value, value in to_call:
  404. func(old_value, value)
  405. return flask.redirect(util.URL_ORIGIN + '/settings', 303)
  406. else:
  407. flask.abort(400)