settings.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562
  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': 9050,
  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': 9010,
  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. (144, '144p'),
  145. (240, '240p'),
  146. (360, '360p'),
  147. (480, '480p'),
  148. (720, '720p'),
  149. (1080, '1080p'),
  150. (1440, '1440p'),
  151. (2160, '2160p'),
  152. ],
  153. 'category': 'playback',
  154. }),
  155. ('codec_rank_av1', {
  156. 'type': int,
  157. 'default': 1,
  158. 'label': 'AV1 Codec Ranking',
  159. 'comment': '',
  160. 'options': [(1, '#1'), (2, '#2'), (3, '#3')],
  161. 'category': 'playback',
  162. }),
  163. ('codec_rank_vp', {
  164. 'type': int,
  165. 'default': 2,
  166. 'label': 'VP8/VP9 Codec Ranking',
  167. 'comment': '',
  168. 'options': [(1, '#1'), (2, '#2'), (3, '#3')],
  169. 'category': 'playback',
  170. }),
  171. ('codec_rank_h264', {
  172. 'type': int,
  173. 'default': 3,
  174. 'label': 'H.264 Codec Ranking',
  175. 'comment': '',
  176. 'options': [(1, '#1'), (2, '#2'), (3, '#3')],
  177. 'category': 'playback',
  178. 'description': (
  179. 'Which video codecs to prefer. Codecs given the same '
  180. 'ranking will use smaller file size as a tiebreaker.'
  181. )
  182. }),
  183. ('prefer_uni_sources', {
  184. 'label': 'Prefer integrated sources',
  185. 'type': bool,
  186. 'default': False,
  187. 'comment': '',
  188. 'category': 'playback',
  189. 'description': 'If enabled and the default resolution is set to 360p or 720p, uses the unified (integrated) video files which contain audio and video, with buffering managed by the browser. If disabled, always uses the separate audio and video files through custom buffer management in av-merge via MediaSource.',
  190. }),
  191. ('use_video_player', {
  192. 'type': int,
  193. 'default': 1,
  194. 'comment': '',
  195. 'options': [
  196. (0, 'Native'),
  197. (1, 'Native with hotkeys'),
  198. (2, 'Plyr'),
  199. ],
  200. 'category': 'interface',
  201. }),
  202. ('use_video_download', {
  203. 'type': int,
  204. 'default': 0,
  205. 'comment': '',
  206. 'options': [
  207. (0, 'Disabled'),
  208. (1, 'Enabled'),
  209. ],
  210. 'category': 'interface',
  211. 'comment': '''If enabled, you may incur legal issues with RIAA. Disabled by default.
  212. More info: https://torrentfreak.com/riaa-thwarts-youts-attempt-to-declare-youtube-ripping-legal-221002/
  213. Archive: https://archive.ph/OZQbN''',
  214. }),
  215. ('proxy_images', {
  216. 'label': 'Route images',
  217. 'type': bool,
  218. 'default': True,
  219. 'comment': '',
  220. 'category': 'network',
  221. }),
  222. ('use_comments_js', {
  223. 'label': 'Enable comments.js',
  224. 'type': bool,
  225. 'default': True,
  226. 'comment': '',
  227. 'category': 'interface',
  228. }),
  229. ('use_sponsorblock_js', {
  230. 'label': 'Enable SponsorBlock',
  231. 'type': bool,
  232. 'default': False,
  233. 'comment': '',
  234. 'category': 'playback',
  235. }),
  236. ('theme', {
  237. 'type': int,
  238. 'default': 0,
  239. 'comment': '',
  240. 'options': [
  241. (0, 'Light'),
  242. (1, 'Gray'),
  243. (2, 'Dark'),
  244. ],
  245. 'category': 'interface',
  246. }),
  247. ('font', {
  248. 'type': int,
  249. 'default': 1,
  250. 'comment': '',
  251. 'options': [
  252. (0, 'Browser default'),
  253. (1, 'Liberation Serif'),
  254. (2, 'Arial'),
  255. (3, 'Verdana'),
  256. (4, 'Tahoma'),
  257. ],
  258. 'category': 'interface',
  259. }),
  260. ('embed_page_mode', {
  261. 'type': bool,
  262. 'label': 'Enable embed page',
  263. 'default': True,
  264. 'comment': '',
  265. 'category': 'interface',
  266. }),
  267. ('autocheck_subscriptions', {
  268. 'type': bool,
  269. 'default': 0,
  270. 'comment': '',
  271. }),
  272. ('gather_googlevideo_domains', {
  273. 'type': bool,
  274. 'default': False,
  275. 'comment': '''Developer use to debug 403s''',
  276. 'hidden': True,
  277. }),
  278. ('debugging_save_responses', {
  279. 'type': bool,
  280. 'default': False,
  281. 'comment': '''Save all responses from youtube for debugging''',
  282. 'hidden': True,
  283. }),
  284. ('settings_version', {
  285. 'type': int,
  286. 'default': 4,
  287. 'comment': '''Do not change, remove, or comment out this value, or else your settings may be lost or corrupted''',
  288. 'hidden': True,
  289. }),
  290. ])
  291. program_directory = os.path.dirname(os.path.realpath(__file__))
  292. acceptable_targets = SETTINGS_INFO.keys() | {
  293. 'enable_comments', 'enable_related_videos', 'preferred_video_codec'
  294. }
  295. def comment_string(comment):
  296. result = ''
  297. for line in comment.splitlines():
  298. result += '# ' + line + '\n'
  299. return result
  300. def save_settings(settings_dict):
  301. with open(settings_file_path, 'w', encoding='utf-8') as file:
  302. for setting_name, setting_info in SETTINGS_INFO.items():
  303. file.write(comment_string(setting_info['comment']) + setting_name + ' = ' + repr(settings_dict[setting_name]) + '\n\n')
  304. def add_missing_settings(settings_dict):
  305. result = default_settings()
  306. result.update(settings_dict)
  307. return result
  308. def default_settings():
  309. return {key: setting_info['default'] for key, setting_info in SETTINGS_INFO.items()}
  310. def upgrade_to_2(settings_dict):
  311. '''Upgrade to settings version 2'''
  312. new_settings = settings_dict.copy()
  313. if 'enable_comments' in settings_dict:
  314. new_settings['comments_mode'] = int(settings_dict['enable_comments'])
  315. del new_settings['enable_comments']
  316. if 'enable_related_videos' in settings_dict:
  317. new_settings['related_videos_mode'] = int(settings_dict['enable_related_videos'])
  318. del new_settings['enable_related_videos']
  319. new_settings['settings_version'] = 2
  320. return new_settings
  321. def upgrade_to_3(settings_dict):
  322. new_settings = settings_dict.copy()
  323. if 'route_tor' in settings_dict:
  324. new_settings['route_tor'] = int(settings_dict['route_tor'])
  325. new_settings['settings_version'] = 3
  326. return new_settings
  327. def upgrade_to_4(settings_dict):
  328. new_settings = settings_dict.copy()
  329. if 'preferred_video_codec' in settings_dict:
  330. pref = settings_dict['preferred_video_codec']
  331. if pref == 0:
  332. new_settings['codec_rank_h264'] = 1
  333. new_settings['codec_rank_vp'] = 2
  334. new_settings['codec_rank_av1'] = 3
  335. else:
  336. new_settings['codec_rank_h264'] = 3
  337. new_settings['codec_rank_vp'] = 2
  338. new_settings['codec_rank_av1'] = 1
  339. del new_settings['preferred_video_codec']
  340. new_settings['settings_version'] = 4
  341. return new_settings
  342. upgrade_functions = {
  343. 1: upgrade_to_2,
  344. 2: upgrade_to_3,
  345. 3: upgrade_to_4,
  346. }
  347. def log_ignored_line(line_number, message):
  348. print("WARNING: Ignoring settings.txt line " + str(node.lineno) + " (" + message + ")")
  349. if os.path.isfile("settings.txt"):
  350. print("Running in portable mode")
  351. settings_dir = os.path.normpath('./')
  352. data_dir = os.path.normpath('./data')
  353. else:
  354. print("Running in non-portable mode")
  355. settings_dir = os.path.expanduser(os.path.normpath("~/.yt-local"))
  356. data_dir = os.path.expanduser(os.path.normpath("~/.yt-local/data"))
  357. if not os.path.exists(settings_dir):
  358. os.makedirs(settings_dir)
  359. settings_file_path = os.path.join(settings_dir, 'settings.txt')
  360. try:
  361. with open(settings_file_path, 'r', encoding='utf-8') as file:
  362. settings_text = file.read()
  363. except FileNotFoundError:
  364. current_settings_dict = default_settings()
  365. save_settings(current_settings_dict)
  366. else:
  367. if re.fullmatch(r'\s*', settings_text): # blank file
  368. current_settings_dict = default_settings()
  369. save_settings(current_settings_dict)
  370. else:
  371. # parse settings in a safe way, without exec
  372. current_settings_dict = {}
  373. attributes = {
  374. ast.Constant: 'value',
  375. ast.NameConstant: 'value',
  376. ast.Num: 'n',
  377. ast.Str: 's',
  378. }
  379. module_node = ast.parse(settings_text)
  380. for node in module_node.body:
  381. if type(node) != ast.Assign:
  382. log_ignored_line(node.lineno, "only assignments are allowed")
  383. continue
  384. if len(node.targets) > 1:
  385. log_ignored_line(node.lineno, "only simple single-variable assignments allowed")
  386. continue
  387. target = node.targets[0]
  388. if type(target) != ast.Name:
  389. log_ignored_line(node.lineno, "only simple single-variable assignments allowed")
  390. continue
  391. if target.id not in acceptable_targets:
  392. log_ignored_line(node.lineno, target.id + " is not a valid setting")
  393. continue
  394. if type(node.value) not in attributes:
  395. log_ignored_line(node.lineno, "only literals allowed for values")
  396. continue
  397. current_settings_dict[target.id] = node.value.__getattribute__(attributes[type(node.value)])
  398. # upgrades
  399. latest_version = SETTINGS_INFO['settings_version']['default']
  400. while current_settings_dict.get('settings_version', 1) < latest_version:
  401. current_version = current_settings_dict.get('settings_version', 1)
  402. print('Upgrading settings.txt to version', current_version+1)
  403. upgrade_func = upgrade_functions[current_version]
  404. # Must add missing settings here rather than below because
  405. # save_settings needs all settings to be present
  406. current_settings_dict = add_missing_settings(
  407. upgrade_func(current_settings_dict))
  408. save_settings(current_settings_dict)
  409. # some settings not in the file, add those missing settings to the file
  410. if not current_settings_dict.keys() >= SETTINGS_INFO.keys():
  411. print('Adding missing settings to settings.txt')
  412. current_settings_dict = add_missing_settings(current_settings_dict)
  413. save_settings(current_settings_dict)
  414. globals().update(current_settings_dict)
  415. if route_tor:
  416. print("Tor routing is ON")
  417. else:
  418. print("Tor routing is OFF - your YouTube activity is NOT anonymous")
  419. hooks = {}
  420. def add_setting_changed_hook(setting, func):
  421. '''Called right after new settings take effect'''
  422. if setting in hooks:
  423. hooks[setting].append(func)
  424. else:
  425. hooks[setting] = [func]
  426. def set_img_prefix(old_value=None, value=None):
  427. global img_prefix
  428. if value is None:
  429. value = proxy_images
  430. if value:
  431. img_prefix = '/'
  432. else:
  433. img_prefix = ''
  434. set_img_prefix()
  435. add_setting_changed_hook('proxy_images', set_img_prefix)
  436. categories = ['network', 'interface', 'playback', 'other']
  437. def settings_page():
  438. if request.method == 'GET':
  439. settings_by_category = {categ: [] for categ in categories}
  440. for setting_name, setting_info in SETTINGS_INFO.items():
  441. categ = setting_info.get('category', 'other')
  442. settings_by_category[categ].append(
  443. (setting_name, setting_info, current_settings_dict[setting_name])
  444. )
  445. return flask.render_template(
  446. 'settings.html',
  447. categories=categories,
  448. settings_by_category=settings_by_category,
  449. )
  450. elif request.method == 'POST':
  451. for key, value in request.values.items():
  452. if key in SETTINGS_INFO:
  453. if SETTINGS_INFO[key]['type'] is bool and value == 'on':
  454. current_settings_dict[key] = True
  455. else:
  456. current_settings_dict[key] = SETTINGS_INFO[key]['type'](value)
  457. else:
  458. flask.abort(400)
  459. # need this bullshit because browsers don't send anything when an input is unchecked
  460. expected_inputs = {setting_name for setting_name, setting_info in SETTINGS_INFO.items() if not SETTINGS_INFO[setting_name].get('hidden', False)}
  461. missing_inputs = expected_inputs - set(request.values.keys())
  462. for setting_name in missing_inputs:
  463. assert SETTINGS_INFO[setting_name]['type'] is bool, missing_inputs
  464. current_settings_dict[setting_name] = False
  465. # find settings that have changed to prepare setting hook calls
  466. to_call = []
  467. for setting_name, value in current_settings_dict.items():
  468. old_value = globals()[setting_name]
  469. if value != old_value and setting_name in hooks:
  470. for func in hooks[setting_name]:
  471. to_call.append((func, old_value, value))
  472. globals().update(current_settings_dict)
  473. save_settings(current_settings_dict)
  474. # call setting hooks
  475. for func, old_value, value in to_call:
  476. func(old_value, value)
  477. return flask.redirect(util.URL_ORIGIN + '/settings', 303)
  478. else:
  479. flask.abort(400)