ownfoil 2.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. ownfoil/__init__.py +0 -0
  2. ownfoil/_app/app.py +545 -0
  3. ownfoil/_app/auth.py +370 -0
  4. ownfoil/_app/clients/__init__.py +9 -0
  5. ownfoil/_app/clients/client.py +171 -0
  6. ownfoil/_app/clients/cyberfoil.py +137 -0
  7. ownfoil/_app/clients/sphaira.py +154 -0
  8. ownfoil/_app/clients/tinfoil.py +189 -0
  9. ownfoil/_app/constants.py +158 -0
  10. ownfoil/_app/containers/__init__.py +8 -0
  11. ownfoil/_app/containers/cnmt.py +67 -0
  12. ownfoil/_app/containers/compression.py +182 -0
  13. ownfoil/_app/containers/container.py +28 -0
  14. ownfoil/_app/containers/verification.py +156 -0
  15. ownfoil/_app/db.py +691 -0
  16. ownfoil/_app/file_watcher.py +323 -0
  17. ownfoil/_app/gql/README.md +550 -0
  18. ownfoil/_app/gql/__init__.py +5 -0
  19. ownfoil/_app/gql/cache.py +77 -0
  20. ownfoil/_app/gql/context.py +40 -0
  21. ownfoil/_app/gql/docs.py +44 -0
  22. ownfoil/_app/gql/filters.py +665 -0
  23. ownfoil/_app/gql/mutations.py +238 -0
  24. ownfoil/_app/gql/resolvers.py +1174 -0
  25. ownfoil/_app/gql/scalars.py +15 -0
  26. ownfoil/_app/gql/schema.py +227 -0
  27. ownfoil/_app/gql/selection.py +43 -0
  28. ownfoil/_app/gql/types.py +557 -0
  29. ownfoil/_app/gql/view.py +111 -0
  30. ownfoil/_app/library.py +381 -0
  31. ownfoil/_app/local.py +76 -0
  32. ownfoil/_app/migrations/README +1 -0
  33. ownfoil/_app/migrations/alembic.ini +51 -0
  34. ownfoil/_app/migrations/env.py +111 -0
  35. ownfoil/_app/migrations/script.py.mako +23 -0
  36. ownfoil/_app/migrations/versions/78c33e9bffce_drop_old_files_table_and_create_new_.py +109 -0
  37. ownfoil/_app/migrations/versions/a1b2c3d4e5f6_add_tasks_and_ignored_events.py +63 -0
  38. ownfoil/_app/migrations/versions/a7b8c9d0e1f2_add_title_overrides.py +61 -0
  39. ownfoil/_app/migrations/versions/b2c3d4e5f6a7_add_organized_column_to_files.py +22 -0
  40. ownfoil/_app/migrations/versions/b8c9d0e1f2a3_add_added_at_to_files.py +29 -0
  41. ownfoil/_app/migrations/versions/c3d4e5f6a7b8_add_worker_id_to_tasks.py +22 -0
  42. ownfoil/_app/migrations/versions/c9d0e1f2a3b4_add_verification_to_files.py +37 -0
  43. ownfoil/_app/migrations/versions/d4e5f6a7b8c9_add_temp_files.py +28 -0
  44. ownfoil/_app/migrations/versions/e5f6a7b8c9d0_add_release_date_to_apps.py +25 -0
  45. ownfoil/_app/migrations/versions/f6a7b8c9d0e1_add_app_files_file_id_index.py +25 -0
  46. ownfoil/_app/realtime.py +233 -0
  47. ownfoil/_app/run.py +101 -0
  48. ownfoil/_app/settings.py +334 -0
  49. ownfoil/_app/static/favicon/apple-touch-icon.png +0 -0
  50. ownfoil/_app/static/favicon/favicon-96x96.png +0 -0
  51. ownfoil/_app/static/favicon/favicon.ico +0 -0
  52. ownfoil/_app/static/favicon/favicon.svg +5 -0
  53. ownfoil/_app/static/favicon/site.webmanifest +21 -0
  54. ownfoil/_app/static/favicon/web-app-manifest-192x192.png +0 -0
  55. ownfoil/_app/static/favicon/web-app-manifest-512x512.png +0 -0
  56. ownfoil/_app/static/js/format.js +9 -0
  57. ownfoil/_app/static/js/realtime.js +87 -0
  58. ownfoil/_app/static/logo/logo-1024.png +0 -0
  59. ownfoil/_app/static/logo/logo-128.png +0 -0
  60. ownfoil/_app/static/logo/logo-256.png +0 -0
  61. ownfoil/_app/static/logo/logo-512.png +0 -0
  62. ownfoil/_app/static/logo/logo-64.png +0 -0
  63. ownfoil/_app/static/logo/logo.svg +3 -0
  64. ownfoil/_app/static/style.css +411 -0
  65. ownfoil/_app/task_events.py +150 -0
  66. ownfoil/_app/tasks.py +1089 -0
  67. ownfoil/_app/templates/base.html +36 -0
  68. ownfoil/_app/templates/index.html +707 -0
  69. ownfoil/_app/templates/login.html +34 -0
  70. ownfoil/_app/templates/nav.html +43 -0
  71. ownfoil/_app/templates/profile.html +7 -0
  72. ownfoil/_app/templates/settings.html +1469 -0
  73. ownfoil/_app/templates/settings_layout.html +33 -0
  74. ownfoil/_app/templates/setup.html +542 -0
  75. ownfoil/_app/templates/stats.html +169 -0
  76. ownfoil/_app/templates/tasks.html +467 -0
  77. ownfoil/_app/titledb/__init__.py +9 -0
  78. ownfoil/_app/titledb/schema.py +142 -0
  79. ownfoil/_app/titledb/store.py +611 -0
  80. ownfoil/_app/titledb/update.py +106 -0
  81. ownfoil/_app/titles.py +177 -0
  82. ownfoil/_app/utils.py +397 -0
  83. ownfoil/_app/worker.py +153 -0
  84. ownfoil/_app/worker_pool.py +89 -0
  85. ownfoil/cli.py +77 -0
  86. ownfoil-2.4.0.dist-info/METADATA +79 -0
  87. ownfoil-2.4.0.dist-info/RECORD +90 -0
  88. ownfoil-2.4.0.dist-info/WHEEL +4 -0
  89. ownfoil-2.4.0.dist-info/entry_points.txt +2 -0
  90. ownfoil-2.4.0.dist-info/licenses/LICENSE.md +661 -0
ownfoil/__init__.py ADDED
File without changes
ownfoil/_app/app.py ADDED
@@ -0,0 +1,545 @@
1
+ import datetime
2
+ from flask import Flask, render_template, request, redirect, url_for, jsonify, send_from_directory, Response
3
+ from flask_login import LoginManager
4
+ from flask_sock import Sock
5
+ from functools import wraps
6
+ from file_watcher import Watcher
7
+ import threading
8
+ import logging
9
+ import sys
10
+ import copy
11
+ import flask.cli
12
+ flask.cli.show_server_banner = lambda *args: None
13
+ from constants import *
14
+ from settings import *
15
+ from db import *
16
+ from auth import *
17
+ from utils import *
18
+ from library import *
19
+ import json
20
+ import tasks as tasks_mod
21
+ import realtime
22
+ import titledb
23
+ import os
24
+ from clients import CyberFoilClient, TinfoilClient, SphairaClient
25
+
26
+ def init():
27
+ global watcher
28
+ global watcher_thread
29
+ # Create the file watcher and register callbacks BEFORE starting the observer
30
+ logger.info('Initializing File Watcher...')
31
+ watcher = Watcher(on_library_change)
32
+ watcher.add_file_callback(CONFIG_FILE, on_settings_change)
33
+ watcher_thread = threading.Thread(target=watcher.run)
34
+ watcher_thread.daemon = True
35
+ watcher_thread.start()
36
+
37
+ # init libraries
38
+ library_paths = get_library_paths()
39
+ init_libraries(app, watcher, library_paths)
40
+
41
+ # Enqueue initial titledb update (re-enqueues itself on completion)
42
+ with app.app_context():
43
+ tasks_mod.enqueue_task('startup')
44
+
45
+ os.makedirs(CONFIG_DIR, exist_ok=True)
46
+ os.makedirs(DATA_DIR, exist_ok=True)
47
+
48
+ ## Global variables
49
+ watcher = None
50
+ watcher_thread = None
51
+ pool = None # Set by entrypoint after WorkerPool is created
52
+
53
+ # Configure logging
54
+ formatter = ColoredFormatter(LOG_FORMAT, datefmt=LOG_DATEFMT)
55
+ handler = logging.StreamHandler(sys.stdout)
56
+ handler.setFormatter(formatter)
57
+
58
+ logging.basicConfig(
59
+ level=logging.INFO,
60
+ handlers=[handler]
61
+ )
62
+
63
+ # Create main logger
64
+ logger = logging.getLogger('main')
65
+ logger.setLevel(logging.DEBUG)
66
+
67
+ # Apply filter to hide date from http access logs
68
+ logging.getLogger('werkzeug').addFilter(FilterRemoveDateFromWerkzeugLogs())
69
+
70
+ # Suppress specific Alembic INFO logs
71
+ logging.getLogger('alembic.runtime.migration').setLevel(logging.WARNING)
72
+
73
+ @login_manager.user_loader
74
+ def load_user(user_id):
75
+ # since the user_id is just the primary key of our user table, use it in the query for the user
76
+ return User.query.filter_by(id=user_id).first()
77
+
78
+ def on_settings_change():
79
+ """Settings file changed: refresh cache, scale workers, and re-apply watcher config."""
80
+ settings = get_settings()
81
+ if pool is not None:
82
+ desired = max(1, settings.get('worker', {}).get('count', 1))
83
+ if desired != pool.count:
84
+ logger.info(f'Settings changed: scaling workers from {pool.count} to {desired}')
85
+ pool.scale(desired)
86
+ if watcher is not None:
87
+ # Reconcile off this thread: this callback runs inside the native observer's dispatch,
88
+ # which holds the observer lock that schedule/unschedule also need.
89
+ threading.Thread(target=watcher.reconcile, daemon=True).start()
90
+
91
+ def on_library_change(events):
92
+ """Enqueue individual tasks per file event, skipping ignored events."""
93
+ with app.app_context():
94
+ for event in events:
95
+ if event.type == 'deleted':
96
+ # Ignore marks are written before our own removes, so they are checked ahead of
97
+ # the temp mark: a converted source carries both, and a mark left unpopped leaks.
98
+ if pop_ignored_event(src_path=event.src_path, dest_path=''):
99
+ continue
100
+ # Also check if this delete is part of a move (dest_path != '')
101
+ if pop_ignored_event(src_path=event.src_path):
102
+ continue
103
+ # A path a task is writing is not a library file - unless it is a committed row,
104
+ # in which case the file really is gone and has to leave the library.
105
+ if is_temp_file(event.src_path) and not file_exists_in_db(event.src_path):
106
+ continue
107
+ tasks_mod.enqueue_task('handle_file_deleted', {
108
+ 'filepath': event.src_path,
109
+ })
110
+ continue
111
+ if is_temp_file(event.src_path) or (
112
+ event.dest_path and is_temp_file(event.dest_path)):
113
+ continue
114
+ if event.type == 'moved':
115
+ if pop_ignored_event(src_path=event.src_path, dest_path=event.dest_path):
116
+ continue
117
+ tasks_mod.enqueue_task('handle_file_moved', {
118
+ 'library_path': event.directory,
119
+ 'src_path': event.src_path,
120
+ 'dest_path': event.dest_path,
121
+ })
122
+ elif event.type == 'created':
123
+ if pop_ignored_event(dest_path=event.src_path):
124
+ continue
125
+ tasks_mod.enqueue_task('handle_file_added', {
126
+ 'library_path': event.directory,
127
+ 'filepath': event.src_path,
128
+ })
129
+ elif event.type == 'modified':
130
+ tasks_mod.enqueue_task('handle_file_added', {
131
+ 'library_path': event.directory,
132
+ 'filepath': event.src_path,
133
+ })
134
+ elif event.type == 'dir_deleted':
135
+ # A folder was moved out/removed; its files are gone, delete them by path prefix.
136
+ tasks_mod.enqueue_task('handle_dir_deleted', {'dirpath': event.src_path})
137
+
138
+ def create_app(db_uri=None):
139
+ app = Flask(__name__)
140
+ app.url_map.strict_slashes = False # Disable automatic trailing slash redirects globally, needed for Sphaira
141
+ app.config["SQLALCHEMY_DATABASE_URI"] = db_uri or OWNFOIL_DB
142
+ # TODO: generate random secret_key
143
+ app.config['SECRET_KEY'] = '8accb915665f11dfa15c2db1a4e8026905f57716'
144
+ app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
145
+
146
+ db.init_app(app)
147
+ migrate.init_app(app, db)
148
+ login_manager.init_app(app)
149
+
150
+ app.register_blueprint(auth_blueprint)
151
+
152
+ return app
153
+
154
+ # Create app
155
+ app = create_app()
156
+
157
+ # Realtime WebSocket. ping_interval keeps idle sockets alive through proxies and lets
158
+ # the server notice a peer that went away without closing.
159
+ app.config['SOCK_SERVER_OPTIONS'] = {'ping_interval': 25}
160
+ sock = Sock(app)
161
+ realtime.init_app(app)
162
+ import task_events # noqa: F401 — registers the tasks/workers topics
163
+
164
+
165
+ @sock.route('/api/ws')
166
+ def realtime_ws(ws):
167
+ """Multiplexed realtime stream: /api/ws?topics=tasks,workers"""
168
+ requested = [t for t in request.args.get('topics', '').split(',') if t]
169
+ realtime.serve(ws, requested)
170
+
171
+ # Add proper Seek support to Range requests
172
+ from werkzeug.wsgi import FileWrapper as WerkzeugFileWrapper
173
+
174
+ @app.before_request
175
+ def use_seekable_file_wrapper_for_range_requests():
176
+ """
177
+ Gunicorn's WSGI FileWrapper does not expose seekable(), seek()
178
+ and tell(). Werkzeug therefore handles Range requests by reading
179
+ and discarding the file from the beginning.
180
+
181
+ Use Werkzeug's seekable wrapper for Range requests.
182
+ """
183
+ if request.headers.get("Range"):
184
+ request.environ["wsgi.file_wrapper"] = WerkzeugFileWrapper
185
+
186
+
187
+ # List of supported client classes
188
+ SUPPORTED_CLIENTS = [CyberFoilClient, TinfoilClient, SphairaClient]
189
+
190
+ def get_client_for_request(request):
191
+ """Identify and return the appropriate client for the request, or None if no client matches."""
192
+ for client_class in SUPPORTED_CLIENTS:
193
+ if client_class.identify_client(request):
194
+ return client_class(get_settings())
195
+ return None
196
+
197
+ def file_access(f):
198
+ """Decorator for file serving endpoints with basic authentication (no client identification required)."""
199
+ @wraps(f)
200
+ def _file_access(*args, **kwargs):
201
+ # Check if shop is private
202
+ if not get_settings()['shop']['public']:
203
+ # Shop is private, require authentication
204
+ auth_success, auth_error, user = basic_auth(request)
205
+ if not auth_success:
206
+ return jsonify({'error': auth_error}), 401
207
+ elif not user.has_shop_access():
208
+ return jsonify({'error': f'User "{user.user}" does not have access to the shop.'}), 403
209
+
210
+ return f(*args, **kwargs)
211
+ return _file_access
212
+
213
+ def access_shop():
214
+ return render_template('index.html', title='Library', admin_account_created=admin_account_created())
215
+
216
+ @access_required('shop')
217
+ def access_shop_auth():
218
+ return access_shop()
219
+
220
+ @app.route('/', defaults={'path': ''})
221
+ @app.route('/<path:path>')
222
+ def index(path=None):
223
+ """Main shop endpoint routing to either client-specific shop or web browser UI."""
224
+ # Check if this is a client request
225
+ client = get_client_for_request(request)
226
+
227
+ if client:
228
+ # Check if client is enabled
229
+ client_name = client.CLIENT_NAME.lower()
230
+ client_settings = get_settings().get('shop', {}).get('clients', {}).get(client_name, {})
231
+ if not client_settings.get('enabled', False):
232
+ logger.warning(f"{client.CLIENT_NAME} connection from {request.remote_addr} - Client is disabled")
233
+ return client.error_response(f"Shop access from {client.CLIENT_NAME} is disabled.")
234
+
235
+ # Handle client request
236
+ logger.info(f"{client.CLIENT_NAME} connection from {request.remote_addr}")
237
+ return client.handle_request(request)
238
+
239
+ # Browser request - serve web UI
240
+ elif path:
241
+ return redirect('/')
242
+
243
+ if not get_settings()['shop']['public']:
244
+ return access_shop_auth()
245
+ return access_shop()
246
+
247
+ @app.route('/admin')
248
+ @access_required('admin')
249
+ def admin_page():
250
+ return redirect('/admin/settings')
251
+
252
+ @app.route('/admin/settings')
253
+ @access_required('admin')
254
+ def settings_page():
255
+ with open(os.path.join(TITLEDB_DIR, 'languages.json')) as f:
256
+ languages = json.load(f)
257
+ languages = dict(sorted(languages.items()))
258
+ return render_template(
259
+ 'settings.html',
260
+ title='Settings',
261
+ languages_from_titledb=languages,
262
+ admin_account_created=admin_account_created())
263
+
264
+ @app.route('/admin/tasks')
265
+ @access_required('admin')
266
+ def tasks_page():
267
+ import task_events
268
+ return render_template('tasks.html', title='Tasks',
269
+ max_tasks=task_events.MAX_TASKS,
270
+ admin_account_created=admin_account_created())
271
+
272
+ @app.route('/admin/stats')
273
+ @access_required('admin')
274
+ def stats_page():
275
+ return render_template('stats.html', title='Stats',
276
+ admin_account_created=admin_account_created())
277
+
278
+ @app.route('/setup')
279
+ def setup_page():
280
+ """Setup page showing client information and connection instructions."""
281
+ settings = get_settings()
282
+
283
+ # Check if user has access (must have shop access or shop must be public)
284
+ if not settings['shop']['public'] and admin_account_created():
285
+ if not current_user.is_authenticated:
286
+ return login_manager.unauthorized()
287
+ if not current_user.has_shop_access():
288
+ return 'Forbidden', 403
289
+
290
+ local_address = None
291
+ local_port = None
292
+
293
+ # Get remote host from configuration
294
+ remote_host = settings['shop'].get('host', '')
295
+
296
+ # Check if we're accessing via the configured remote host
297
+ # If so, hide the local tab since we're already remote
298
+ show_local_tab = not remote_host or remote_host != request.host
299
+ if show_local_tab:
300
+ # The Host header is the browser's view of the server (localhost, an mDNS name, a
301
+ # container name) and is often unreachable from the Switch, so prefer our own LAN IP.
302
+ local_address = get_lan_ip() or request.host.split(':')[0]
303
+ local_port = request.host.split(':')[1] if ':' in request.host else 80
304
+
305
+ # Check if clients are enabled
306
+ tinfoil_enabled = settings.get('shop', {}).get('clients', {}).get('tinfoil', {}).get('enabled', False)
307
+ sphaira_enabled = settings.get('shop', {}).get('clients', {}).get('sphaira', {}).get('enabled', False)
308
+ cyberfoil_enabled = settings.get('shop', {}).get('clients', {}).get('cyberfoil', {}).get('enabled', False)
309
+
310
+ # Check if shop is public
311
+ shop_public = settings['shop']['public']
312
+
313
+ return render_template(
314
+ 'setup.html',
315
+ title='Setup',
316
+ local_address=local_address,
317
+ local_port=local_port,
318
+ remote_host=remote_host,
319
+ show_local_tab=show_local_tab,
320
+ tinfoil_enabled=tinfoil_enabled,
321
+ sphaira_enabled=sphaira_enabled,
322
+ cyberfoil_enabled=cyberfoil_enabled,
323
+ shop_public=shop_public,
324
+ admin_account_created=admin_account_created()
325
+ )
326
+
327
+ @app.get('/api/settings')
328
+ @access_required('admin')
329
+ def get_settings_api():
330
+ settings = copy.deepcopy(get_settings())
331
+ # Inject runtime key status for the UI
332
+ valid, missing, corrupt = load_keys()
333
+ settings['titles']['valid_keys'] = valid
334
+ settings['titles']['missing_keys'] = missing
335
+ settings['titles']['corrupt_keys'] = corrupt
336
+ # Strip hauth values for privacy (don't send to client)
337
+ if 'clients' in settings['shop']:
338
+ for client_name, client_settings in settings['shop']['clients'].items():
339
+ if 'hauth' in client_settings:
340
+ # Replace hauth dict with empty dict to keep it private
341
+ settings['shop']['clients'][client_name]['hauth'] = {}
342
+ return jsonify(settings)
343
+
344
+ @app.post('/api/settings/titles')
345
+ @access_required('admin')
346
+ def set_titles_settings_api():
347
+ title_settings = request.json
348
+ region = title_settings['region']
349
+ language = title_settings['language']
350
+ with open(os.path.join(TITLEDB_DIR, 'languages.json')) as f:
351
+ languages = json.load(f)
352
+ languages = dict(sorted(languages.items()))
353
+
354
+ if region not in languages or language not in languages[region]:
355
+ resp = {
356
+ 'success': False,
357
+ 'errors': [{
358
+ 'path': 'titles',
359
+ 'error': f"The region/language pair {region}/{language} is not available."
360
+ }]
361
+ }
362
+ return jsonify(resp)
363
+
364
+ current = get_settings()
365
+ if region != current['titles']['region'] or language != current['titles']['language']:
366
+ set_titles_settings(region, language)
367
+ tasks_mod.update_scheduled_task('update_titledb', datetime.datetime.utcnow())
368
+
369
+ resp = {
370
+ 'success': True,
371
+ 'errors': []
372
+ }
373
+ return jsonify(resp)
374
+
375
+ @app.post('/api/settings/shop')
376
+ @access_required('admin')
377
+ def set_shop_settings_api():
378
+ data = request.json
379
+ set_shop_settings(data)
380
+ resp = {
381
+ 'success': True,
382
+ 'errors': []
383
+ }
384
+ return jsonify(resp)
385
+
386
+ @app.route('/api/settings/library/paths', methods=['GET', 'POST', 'DELETE'])
387
+ @access_required('admin')
388
+ def library_paths_api():
389
+ global watcher
390
+ if request.method == 'POST':
391
+ data = request.json
392
+ success, errors = add_library_complete(app, watcher, data['path'])
393
+ if success:
394
+ tasks_mod.enqueue_task('scan_library', {'library_path': data['path']})
395
+ resp = {
396
+ 'success': success,
397
+ 'errors': errors
398
+ }
399
+ elif request.method == 'GET':
400
+ resp = {'success': True, 'errors': [], 'paths': get_library_paths(),
401
+ 'watcher': get_watcher_config()}
402
+ elif request.method == 'DELETE':
403
+ data = request.json
404
+ success, errors = remove_library_complete(app, watcher, data['path'])
405
+ resp = {
406
+ 'success': success,
407
+ 'errors': errors
408
+ }
409
+ return jsonify(resp)
410
+
411
+ @app.post('/api/settings/library/watcher')
412
+ @access_required('admin')
413
+ def set_library_watcher_settings_api():
414
+ global watcher
415
+ success, errors = set_watcher_settings(request.json)
416
+ if not success:
417
+ return jsonify({'success': success, 'errors': errors})
418
+ # Re-apply the new config live (runs in the request thread, not the observer thread)
419
+ watcher.reconcile()
420
+ return jsonify({'success': True, 'errors': []})
421
+
422
+ @app.post('/api/settings/library/management')
423
+ @access_required('admin')
424
+ def set_library_management_settings_api():
425
+ data = request.json
426
+ set_library_management_settings(data)
427
+ tasks_mod.enqueue_task('process_library')
428
+ resp = {
429
+ 'success': True,
430
+ 'errors': []
431
+ }
432
+ return jsonify(resp)
433
+
434
+ @app.post('/api/settings/scheduler')
435
+ @access_required('admin')
436
+ def set_scheduler_settings_api():
437
+ from utils import interval_string_to_timedelta
438
+ data = request.json
439
+ titledb_update_interval_str = data.get('titledb_update_interval')
440
+
441
+ if titledb_update_interval_str is not None:
442
+ is_valid, error_msg = validate_interval_string(titledb_update_interval_str)
443
+ if not is_valid:
444
+ return jsonify({
445
+ 'success': False,
446
+ 'errors': [{'path': 'scheduler/titledb_update_interval', 'error': error_msg}]
447
+ })
448
+
449
+ set_scheduler_settings(data)
450
+
451
+ if titledb_update_interval_str is not None:
452
+ delta = interval_string_to_timedelta(titledb_update_interval_str)
453
+ run_after = datetime.datetime.utcnow() + delta if delta else None
454
+ tasks_mod.update_scheduled_task('update_titledb', run_after)
455
+
456
+ return jsonify({'success': True, 'errors': []})
457
+
458
+ @app.post('/api/settings/worker')
459
+ @access_required('admin')
460
+ def set_worker_settings_api():
461
+ data = request.json
462
+ count = data.get('count')
463
+ if count is not None:
464
+ try:
465
+ count = int(count)
466
+ except (TypeError, ValueError):
467
+ return jsonify({'success': False, 'errors': [{'path': 'worker/count', 'error': 'Must be an integer'}]})
468
+ if count < 1:
469
+ return jsonify({'success': False, 'errors': [{'path': 'worker/count', 'error': 'Must be at least 1'}]})
470
+ data['count'] = count
471
+ group_limits = data.get('group_limits')
472
+ if group_limits is not None:
473
+ try:
474
+ io = int(group_limits['io'])
475
+ except (TypeError, ValueError, KeyError):
476
+ return jsonify({'success': False, 'errors': [{'path': 'worker/group_limits', 'error': 'Must be an integer'}]})
477
+ if io < 1:
478
+ return jsonify({'success': False, 'errors': [{'path': 'worker/group_limits', 'error': 'Must be at least 1'}]})
479
+ # Merge into current limits so any other groups are preserved.
480
+ current = get_settings().get('worker', {}).get('group_limits', {})
481
+ data['group_limits'] = {**current, 'io': io}
482
+ set_worker_settings(data)
483
+ return jsonify({'success': True, 'errors': []})
484
+
485
+ @app.post('/api/upload')
486
+ @access_required('admin')
487
+ def upload_file():
488
+ errors = []
489
+ success = False
490
+ valid_keys = None
491
+ try:
492
+ file = request.files['file']
493
+ if file and allowed_file(file.filename):
494
+ # filename = secure_filename(file.filename)
495
+ file.save(KEYS_FILE)
496
+ logger.info(f'Validating {file.filename}...')
497
+ valid_keys, missing_keys, corrupt_keys = load_keys(KEYS_FILE)
498
+ if valid_keys:
499
+ tasks_mod.enqueue_task('process_library')
500
+ else:
501
+ logger.warning(f'Invalid keys from {file.filename}')
502
+ success = True
503
+ logger.info('Successfully saved keys.txt')
504
+
505
+ except Exception as e:
506
+ logger.error(f'Failed to upload console keys file: {e}')
507
+ os.remove(KEYS_FILE)
508
+ success = False
509
+ errors.append(str(e))
510
+
511
+ resp = {
512
+ 'success': success,
513
+ 'errors': errors,
514
+ 'data': {}
515
+ }
516
+
517
+ if valid_keys is not None:
518
+ resp['data']['valid_keys'] = valid_keys
519
+ resp['data']['missing_keys'] = missing_keys
520
+ resp['data']['corrupt_keys'] = corrupt_keys
521
+
522
+ return jsonify(resp)
523
+
524
+
525
+ from gql import graphql_dispatch
526
+
527
+ app.add_url_rule(
528
+ '/api/graphql',
529
+ view_func=graphql_dispatch,
530
+ methods=['GET', 'POST'],
531
+ )
532
+
533
+ @app.route('/api/get_game/<int:id>')
534
+ @file_access
535
+ def serve_game(id):
536
+ """Serve a game file to authenticated clients."""
537
+ filepath = db.session.query(Files.filepath).filter_by(id=id).scalar()
538
+ if not filepath:
539
+ return jsonify({'error': f'No file with id {id}.'}), 404
540
+ filedir, filename = os.path.split(filepath)
541
+ # Count only once the response exists: clients probe files with a Range before taking
542
+ # them, and a range past the end of the file raises out of here without transferring.
543
+ response = send_from_directory(filedir, filename)
544
+ increment_download_count_throttled(filepath, client_address(request))
545
+ return response