ownfoil 2.4.0.dev0__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.

Potentially problematic release.


This version of ownfoil might be problematic. Click here for more details.

Files changed (60) hide show
  1. ownfoil/__init__.py +1 -0
  2. ownfoil/_app/app.py +653 -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 +197 -0
  8. ownfoil/_app/clients/tinfoil.py +189 -0
  9. ownfoil/_app/constants.py +149 -0
  10. ownfoil/_app/db.py +583 -0
  11. ownfoil/_app/file_compression.py +191 -0
  12. ownfoil/_app/file_watcher.py +308 -0
  13. ownfoil/_app/library.py +555 -0
  14. ownfoil/_app/local.py +42 -0
  15. ownfoil/_app/migrations/README +1 -0
  16. ownfoil/_app/migrations/alembic.ini +51 -0
  17. ownfoil/_app/migrations/env.py +111 -0
  18. ownfoil/_app/migrations/script.py.mako +23 -0
  19. ownfoil/_app/migrations/versions/78c33e9bffce_drop_old_files_table_and_create_new_.py +109 -0
  20. ownfoil/_app/migrations/versions/a1b2c3d4e5f6_add_tasks_and_ignored_events.py +63 -0
  21. ownfoil/_app/migrations/versions/b2c3d4e5f6a7_add_organized_column_to_files.py +22 -0
  22. ownfoil/_app/migrations/versions/c3d4e5f6a7b8_add_worker_id_to_tasks.py +22 -0
  23. ownfoil/_app/migrations/versions/d4e5f6a7b8c9_add_temp_files.py +28 -0
  24. ownfoil/_app/run.py +97 -0
  25. ownfoil/_app/settings.py +327 -0
  26. ownfoil/_app/shop.py +52 -0
  27. ownfoil/_app/static/favicon/apple-touch-icon.png +0 -0
  28. ownfoil/_app/static/favicon/favicon-96x96.png +0 -0
  29. ownfoil/_app/static/favicon/favicon.ico +0 -0
  30. ownfoil/_app/static/favicon/favicon.svg +5 -0
  31. ownfoil/_app/static/favicon/site.webmanifest +21 -0
  32. ownfoil/_app/static/favicon/web-app-manifest-192x192.png +0 -0
  33. ownfoil/_app/static/favicon/web-app-manifest-512x512.png +0 -0
  34. ownfoil/_app/static/logo/logo-1024.png +0 -0
  35. ownfoil/_app/static/logo/logo-128.png +0 -0
  36. ownfoil/_app/static/logo/logo-256.png +0 -0
  37. ownfoil/_app/static/logo/logo-512.png +0 -0
  38. ownfoil/_app/static/logo/logo-64.png +0 -0
  39. ownfoil/_app/static/logo/logo.svg +3 -0
  40. ownfoil/_app/static/style.css +125 -0
  41. ownfoil/_app/tasks.py +967 -0
  42. ownfoil/_app/templates/base.html +36 -0
  43. ownfoil/_app/templates/index.html +656 -0
  44. ownfoil/_app/templates/login.html +34 -0
  45. ownfoil/_app/templates/nav.html +43 -0
  46. ownfoil/_app/templates/profile.html +7 -0
  47. ownfoil/_app/templates/settings.html +1411 -0
  48. ownfoil/_app/templates/setup.html +546 -0
  49. ownfoil/_app/titledb.py +100 -0
  50. ownfoil/_app/titledb_store.py +533 -0
  51. ownfoil/_app/titles.py +239 -0
  52. ownfoil/_app/utils.py +352 -0
  53. ownfoil/_app/worker.py +149 -0
  54. ownfoil/_app/worker_pool.py +89 -0
  55. ownfoil/cli.py +64 -0
  56. ownfoil-2.4.0.dev0.dist-info/METADATA +221 -0
  57. ownfoil-2.4.0.dev0.dist-info/RECORD +60 -0
  58. ownfoil-2.4.0.dev0.dist-info/WHEEL +4 -0
  59. ownfoil-2.4.0.dev0.dist-info/entry_points.txt +2 -0
  60. ownfoil-2.4.0.dev0.dist-info/licenses/LICENSE.md +661 -0
ownfoil/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = '2.4.0.dev0'
ownfoil/_app/app.py ADDED
@@ -0,0 +1,653 @@
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 functools import wraps
5
+ from file_watcher import Watcher
6
+ import threading
7
+ import logging
8
+ import sys
9
+ import copy
10
+ import flask.cli
11
+ flask.cli.show_server_banner = lambda *args: None
12
+ from constants import *
13
+ from settings import *
14
+ from db import *
15
+ from shop import *
16
+ from auth import *
17
+ from utils import *
18
+ from library import *
19
+ import json
20
+ import tasks as tasks_mod
21
+ import titledb_store
22
+ import os
23
+ from clients import CyberFoilClient, TinfoilClient, SphairaClient, SPHAIRA_ENDPOINT
24
+
25
+ def init():
26
+ global watcher
27
+ global watcher_thread
28
+ # Create the file watcher and register callbacks BEFORE starting the observer
29
+ logger.info('Initializing File Watcher...')
30
+ watcher = Watcher(on_library_change)
31
+ watcher.add_file_callback(CONFIG_FILE, on_settings_change)
32
+ watcher_thread = threading.Thread(target=watcher.run)
33
+ watcher_thread.daemon = True
34
+ watcher_thread.start()
35
+
36
+ # init libraries
37
+ library_paths = get_library_paths()
38
+ init_libraries(app, watcher, library_paths)
39
+
40
+ # Enqueue initial titledb update (re-enqueues itself on completion)
41
+ with app.app_context():
42
+ tasks_mod.enqueue_task('startup')
43
+
44
+ os.makedirs(CONFIG_DIR, exist_ok=True)
45
+ os.makedirs(DATA_DIR, exist_ok=True)
46
+
47
+ ## Global variables
48
+ watcher = None
49
+ watcher_thread = None
50
+ pool = None # Set by entrypoint after WorkerPool is created
51
+
52
+ # Configure logging
53
+ formatter = ColoredFormatter(LOG_FORMAT, datefmt=LOG_DATEFMT)
54
+ handler = logging.StreamHandler(sys.stdout)
55
+ handler.setFormatter(formatter)
56
+
57
+ logging.basicConfig(
58
+ level=logging.INFO,
59
+ handlers=[handler]
60
+ )
61
+
62
+ # Create main logger
63
+ logger = logging.getLogger('main')
64
+ logger.setLevel(logging.DEBUG)
65
+
66
+ # Apply filter to hide date from http access logs
67
+ logging.getLogger('werkzeug').addFilter(FilterRemoveDateFromWerkzeugLogs())
68
+
69
+ # Suppress specific Alembic INFO logs
70
+ logging.getLogger('alembic.runtime.migration').setLevel(logging.WARNING)
71
+
72
+ @login_manager.user_loader
73
+ def load_user(user_id):
74
+ # since the user_id is just the primary key of our user table, use it in the query for the user
75
+ return User.query.filter_by(id=user_id).first()
76
+
77
+ def on_settings_change():
78
+ """Settings file changed: refresh cache, scale workers, and re-apply watcher config."""
79
+ settings = get_settings()
80
+ if pool is not None:
81
+ desired = max(1, settings.get('worker', {}).get('count', 1))
82
+ if desired != pool.count:
83
+ logger.info(f'Settings changed: scaling workers from {pool.count} to {desired}')
84
+ pool.scale(desired)
85
+ if watcher is not None:
86
+ # Reconcile off this thread: this callback runs inside the native observer's dispatch,
87
+ # which holds the observer lock that schedule/unschedule also need.
88
+ threading.Thread(target=watcher.reconcile, daemon=True).start()
89
+
90
+ def on_library_change(events):
91
+ """Enqueue individual tasks per file event, skipping ignored events."""
92
+ with app.app_context():
93
+ for event in events:
94
+ if is_temp_file(event.src_path) or (
95
+ event.dest_path and is_temp_file(event.dest_path)):
96
+ continue
97
+ if event.type == 'moved':
98
+ if pop_ignored_event(src_path=event.src_path, dest_path=event.dest_path):
99
+ continue
100
+ tasks_mod.enqueue_task('handle_file_moved', {
101
+ 'library_path': event.directory,
102
+ 'src_path': event.src_path,
103
+ 'dest_path': event.dest_path,
104
+ })
105
+ elif event.type == 'deleted':
106
+ if pop_ignored_event(src_path=event.src_path, dest_path=''):
107
+ continue
108
+ # Also check if this delete is part of a move (dest_path != '')
109
+ if pop_ignored_event(src_path=event.src_path):
110
+ continue
111
+ tasks_mod.enqueue_task('handle_file_deleted', {
112
+ 'filepath': event.src_path,
113
+ })
114
+ elif event.type == 'created':
115
+ if pop_ignored_event(dest_path=event.src_path):
116
+ continue
117
+ tasks_mod.enqueue_task('handle_file_added', {
118
+ 'library_path': event.directory,
119
+ 'filepath': event.src_path,
120
+ })
121
+ elif event.type == 'modified':
122
+ tasks_mod.enqueue_task('handle_file_added', {
123
+ 'library_path': event.directory,
124
+ 'filepath': event.src_path,
125
+ })
126
+ elif event.type == 'dir_deleted':
127
+ # A folder was moved out/removed; its files are gone, delete them by path prefix.
128
+ tasks_mod.enqueue_task('handle_dir_deleted', {'dirpath': event.src_path})
129
+
130
+ def create_app(db_uri=None):
131
+ app = Flask(__name__)
132
+ app.url_map.strict_slashes = False # Disable automatic trailing slash redirects globally, needed for Sphaira
133
+ app.config["SQLALCHEMY_DATABASE_URI"] = db_uri or OWNFOIL_DB
134
+ # TODO: generate random secret_key
135
+ app.config['SECRET_KEY'] = '8accb915665f11dfa15c2db1a4e8026905f57716'
136
+ app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
137
+
138
+ db.init_app(app)
139
+ migrate.init_app(app, db)
140
+ login_manager.init_app(app)
141
+
142
+ app.register_blueprint(auth_blueprint)
143
+
144
+ return app
145
+
146
+ # Create app
147
+ app = create_app()
148
+
149
+ # Add proper Seek support to Range requests
150
+ from werkzeug.wsgi import FileWrapper as WerkzeugFileWrapper
151
+
152
+ @app.before_request
153
+ def use_seekable_file_wrapper_for_range_requests():
154
+ """
155
+ Gunicorn's WSGI FileWrapper does not expose seekable(), seek()
156
+ and tell(). Werkzeug therefore handles Range requests by reading
157
+ and discarding the file from the beginning.
158
+
159
+ Use Werkzeug's seekable wrapper for Range requests.
160
+ """
161
+ if request.headers.get("Range"):
162
+ request.environ["wsgi.file_wrapper"] = WerkzeugFileWrapper
163
+
164
+
165
+ # List of supported client classes
166
+ SUPPORTED_CLIENTS = [CyberFoilClient, TinfoilClient, SphairaClient]
167
+
168
+ def get_client_for_request(request):
169
+ """Identify and return the appropriate client for the request, or None if no client matches."""
170
+ for client_class in SUPPORTED_CLIENTS:
171
+ if client_class.identify_client(request):
172
+ return client_class(get_settings())
173
+ return None
174
+
175
+ def file_access(f):
176
+ """Decorator for file serving endpoints with basic authentication (no client identification required)."""
177
+ @wraps(f)
178
+ def _file_access(*args, **kwargs):
179
+ # Check if shop is private
180
+ if not get_settings()['shop']['public']:
181
+ # Shop is private, require authentication
182
+ auth_success, auth_error, user = basic_auth(request)
183
+ if not auth_success:
184
+ return jsonify({'error': auth_error}), 401
185
+ elif not user.has_shop_access():
186
+ return jsonify({'error': f'User "{user.user}" does not have access to the shop.'}), 403
187
+
188
+ return f(*args, **kwargs)
189
+ return _file_access
190
+
191
+ def access_shop():
192
+ return render_template('index.html', title='Library', admin_account_created=admin_account_created())
193
+
194
+ @access_required('shop')
195
+ def access_shop_auth():
196
+ return access_shop()
197
+
198
+ @app.route('/', defaults={'path': ''})
199
+ @app.route(SPHAIRA_ENDPOINT, defaults={'path': SPHAIRA_ENDPOINT.strip('/')})
200
+ @app.route(f'{SPHAIRA_ENDPOINT}/<path:path>')
201
+ @app.route('/<path:path>')
202
+ def index(path=None):
203
+ """Main shop endpoint routing to either client-specific shop or web browser UI."""
204
+ # Check if this is a client request
205
+ client = get_client_for_request(request)
206
+
207
+ if client:
208
+ # Check if client is enabled
209
+ client_name = client.CLIENT_NAME.lower()
210
+ client_settings = get_settings().get('shop', {}).get('clients', {}).get(client_name, {})
211
+ if not client_settings.get('enabled', False):
212
+ logger.warning(f"{client.CLIENT_NAME} connection from {request.remote_addr} - Client is disabled")
213
+ return client.error_response(f"Shop access from {client.CLIENT_NAME} is disabled.")
214
+
215
+ # Handle client request
216
+ logger.info(f"{client.CLIENT_NAME} connection from {request.remote_addr}")
217
+ return client.handle_request(request)
218
+
219
+ # Browser request - serve web UI
220
+ elif path:
221
+ return redirect('/')
222
+
223
+ if not get_settings()['shop']['public']:
224
+ return access_shop_auth()
225
+ return access_shop()
226
+
227
+ @app.route('/settings')
228
+ @access_required('admin')
229
+ def settings_page():
230
+ with open(os.path.join(TITLEDB_DIR, 'languages.json')) as f:
231
+ languages = json.load(f)
232
+ languages = dict(sorted(languages.items()))
233
+ return render_template(
234
+ 'settings.html',
235
+ title='Settings',
236
+ languages_from_titledb=languages,
237
+ admin_account_created=admin_account_created())
238
+
239
+ @app.route('/setup')
240
+ def setup_page():
241
+ """Setup page showing client information and connection instructions."""
242
+ settings = get_settings()
243
+
244
+ # Check if user has access (must have shop access or shop must be public)
245
+ if not settings['shop']['public'] and admin_account_created():
246
+ if not current_user.is_authenticated:
247
+ return login_manager.unauthorized()
248
+ if not current_user.has_shop_access():
249
+ return 'Forbidden', 403
250
+
251
+ local_address = None
252
+ local_port = None
253
+
254
+ # Get remote host from configuration
255
+ remote_host = settings['shop'].get('host', '')
256
+
257
+ # Check if we're accessing via the configured remote host
258
+ # If so, hide the local tab since we're already remote
259
+ show_local_tab = not remote_host or remote_host != request.host
260
+ if show_local_tab:
261
+ local_address = request.host.split(':')[0]
262
+ local_port = request.host.split(':')[1] if ':' in request.host else 80
263
+
264
+ # Check if clients are enabled
265
+ tinfoil_enabled = settings.get('shop', {}).get('clients', {}).get('tinfoil', {}).get('enabled', False)
266
+ sphaira_enabled = settings.get('shop', {}).get('clients', {}).get('sphaira', {}).get('enabled', False)
267
+ cyberfoil_enabled = settings.get('shop', {}).get('clients', {}).get('cyberfoil', {}).get('enabled', False)
268
+
269
+ # Check if shop is public
270
+ shop_public = settings['shop']['public']
271
+
272
+ return render_template(
273
+ 'setup.html',
274
+ title='Setup',
275
+ local_address=local_address,
276
+ local_port=local_port,
277
+ remote_host=remote_host,
278
+ show_local_tab=show_local_tab,
279
+ tinfoil_enabled=tinfoil_enabled,
280
+ sphaira_enabled=sphaira_enabled,
281
+ sphaira_endpoint=SPHAIRA_ENDPOINT,
282
+ cyberfoil_enabled=cyberfoil_enabled,
283
+ shop_public=shop_public,
284
+ admin_account_created=admin_account_created()
285
+ )
286
+
287
+ @app.get('/api/settings')
288
+ @access_required('admin')
289
+ def get_settings_api():
290
+ settings = copy.deepcopy(get_settings())
291
+ # Inject runtime key status for the UI
292
+ valid, missing, corrupt = load_keys()
293
+ settings['titles']['valid_keys'] = valid
294
+ settings['titles']['missing_keys'] = missing
295
+ settings['titles']['corrupt_keys'] = corrupt
296
+ # Strip hauth values for privacy (don't send to client)
297
+ if 'clients' in settings['shop']:
298
+ for client_name, client_settings in settings['shop']['clients'].items():
299
+ if 'hauth' in client_settings:
300
+ # Replace hauth dict with empty dict to keep it private
301
+ settings['shop']['clients'][client_name]['hauth'] = {}
302
+ return jsonify(settings)
303
+
304
+ @app.post('/api/settings/titles')
305
+ @access_required('admin')
306
+ def set_titles_settings_api():
307
+ title_settings = request.json
308
+ region = title_settings['region']
309
+ language = title_settings['language']
310
+ with open(os.path.join(TITLEDB_DIR, 'languages.json')) as f:
311
+ languages = json.load(f)
312
+ languages = dict(sorted(languages.items()))
313
+
314
+ if region not in languages or language not in languages[region]:
315
+ resp = {
316
+ 'success': False,
317
+ 'errors': [{
318
+ 'path': 'titles',
319
+ 'error': f"The region/language pair {region}/{language} is not available."
320
+ }]
321
+ }
322
+ return jsonify(resp)
323
+
324
+ current = get_settings()
325
+ if region != current['titles']['region'] or language != current['titles']['language']:
326
+ set_titles_settings(region, language)
327
+ tasks_mod.update_scheduled_task('update_titledb', datetime.datetime.utcnow())
328
+
329
+ resp = {
330
+ 'success': True,
331
+ 'errors': []
332
+ }
333
+ return jsonify(resp)
334
+
335
+ @app.post('/api/settings/shop')
336
+ @access_required('admin')
337
+ def set_shop_settings_api():
338
+ data = request.json
339
+ set_shop_settings(data)
340
+ resp = {
341
+ 'success': True,
342
+ 'errors': []
343
+ }
344
+ return jsonify(resp)
345
+
346
+ @app.route('/api/settings/library/paths', methods=['GET', 'POST', 'DELETE'])
347
+ @access_required('admin')
348
+ def library_paths_api():
349
+ global watcher
350
+ if request.method == 'POST':
351
+ data = request.json
352
+ success, errors = add_library_complete(app, watcher, data['path'])
353
+ if success:
354
+ tasks_mod.enqueue_task('scan_library', {'library_path': data['path']})
355
+ resp = {
356
+ 'success': success,
357
+ 'errors': errors
358
+ }
359
+ elif request.method == 'GET':
360
+ resp = {'success': True, 'errors': [], 'paths': get_library_paths(),
361
+ 'watcher': get_watcher_config()}
362
+ elif request.method == 'DELETE':
363
+ data = request.json
364
+ success, errors = remove_library_complete(app, watcher, data['path'])
365
+ resp = {
366
+ 'success': success,
367
+ 'errors': errors
368
+ }
369
+ return jsonify(resp)
370
+
371
+ @app.post('/api/settings/library/watcher')
372
+ @access_required('admin')
373
+ def set_library_watcher_settings_api():
374
+ global watcher
375
+ success, errors = set_watcher_settings(request.json)
376
+ if not success:
377
+ return jsonify({'success': success, 'errors': errors})
378
+ # Re-apply the new config live (runs in the request thread, not the observer thread)
379
+ watcher.reconcile()
380
+ return jsonify({'success': True, 'errors': []})
381
+
382
+ @app.post('/api/settings/library/management')
383
+ @access_required('admin')
384
+ def set_library_management_settings_api():
385
+ data = request.json
386
+ set_library_management_settings(data)
387
+ tasks_mod.enqueue_task('organize_library')
388
+ resp = {
389
+ 'success': True,
390
+ 'errors': []
391
+ }
392
+ return jsonify(resp)
393
+
394
+ @app.post('/api/settings/scheduler')
395
+ @access_required('admin')
396
+ def set_scheduler_settings_api():
397
+ from utils import interval_string_to_timedelta
398
+ data = request.json
399
+ scan_interval_str = data.get('scan_interval')
400
+
401
+ if scan_interval_str is not None:
402
+ is_valid, error_msg = validate_interval_string(scan_interval_str)
403
+ if not is_valid:
404
+ return jsonify({
405
+ 'success': False,
406
+ 'errors': [{'path': 'scheduler/scan_interval', 'error': error_msg}]
407
+ })
408
+
409
+ set_scheduler_settings(data)
410
+
411
+ if scan_interval_str is not None:
412
+ delta = interval_string_to_timedelta(scan_interval_str)
413
+ run_after = datetime.datetime.utcnow() + delta if delta else None
414
+ tasks_mod.update_scheduled_task('update_titledb', run_after)
415
+
416
+ return jsonify({'success': True, 'errors': []})
417
+
418
+ @app.post('/api/settings/worker')
419
+ @access_required('admin')
420
+ def set_worker_settings_api():
421
+ data = request.json
422
+ count = data.get('count')
423
+ if count is not None:
424
+ try:
425
+ count = int(count)
426
+ except (TypeError, ValueError):
427
+ return jsonify({'success': False, 'errors': [{'path': 'worker/count', 'error': 'Must be an integer'}]})
428
+ if count < 1:
429
+ return jsonify({'success': False, 'errors': [{'path': 'worker/count', 'error': 'Must be at least 1'}]})
430
+ data['count'] = count
431
+ group_limits = data.get('group_limits')
432
+ if group_limits is not None:
433
+ try:
434
+ io = int(group_limits['io'])
435
+ except (TypeError, ValueError, KeyError):
436
+ return jsonify({'success': False, 'errors': [{'path': 'worker/group_limits', 'error': 'Must be an integer'}]})
437
+ if io < 1:
438
+ return jsonify({'success': False, 'errors': [{'path': 'worker/group_limits', 'error': 'Must be at least 1'}]})
439
+ # Merge into current limits so any other groups are preserved.
440
+ current = get_settings().get('worker', {}).get('group_limits', {})
441
+ data['group_limits'] = {**current, 'io': io}
442
+ set_worker_settings(data)
443
+ return jsonify({'success': True, 'errors': []})
444
+
445
+ @app.post('/api/upload')
446
+ @access_required('admin')
447
+ def upload_file():
448
+ errors = []
449
+ success = False
450
+ valid_keys = None
451
+ try:
452
+ file = request.files['file']
453
+ if file and allowed_file(file.filename):
454
+ # filename = secure_filename(file.filename)
455
+ file.save(KEYS_FILE)
456
+ logger.info(f'Validating {file.filename}...')
457
+ valid_keys, missing_keys, corrupt_keys = load_keys(KEYS_FILE)
458
+ if valid_keys:
459
+ tasks_mod.enqueue_task('identify_library')
460
+ else:
461
+ logger.warning(f'Invalid keys from {file.filename}')
462
+ success = True
463
+ logger.info('Successfully saved keys.txt')
464
+
465
+ except Exception as e:
466
+ logger.error(f'Failed to upload console keys file: {e}')
467
+ os.remove(KEYS_FILE)
468
+ success = False
469
+ errors.append(str(e))
470
+
471
+ resp = {
472
+ 'success': success,
473
+ 'errors': errors,
474
+ 'data': {}
475
+ }
476
+
477
+ if valid_keys is not None:
478
+ resp['data']['valid_keys'] = valid_keys
479
+ resp['data']['missing_keys'] = missing_keys
480
+ resp['data']['corrupt_keys'] = corrupt_keys
481
+
482
+ return jsonify(resp)
483
+
484
+
485
+ @app.get('/api/titledb/custom')
486
+ @access_required('admin')
487
+ def list_custom_titles_api():
488
+ return jsonify({'success': True, 'entries': titledb_store.list_custom_titles()})
489
+
490
+
491
+ @app.post('/api/titledb/custom')
492
+ @access_required('admin')
493
+ def add_custom_title_api():
494
+ data = request.json or {}
495
+ title_id = data.get('id')
496
+ if not title_id:
497
+ return jsonify({'success': False, 'errors': [{'path': 'id', 'error': 'id is required'}]}), 400
498
+ ok, err = titledb_store.add_custom_title(data)
499
+ if not ok:
500
+ return jsonify({'success': False, 'errors': [{'path': 'id', 'error': err}]}), 400
501
+ tasks_mod.enqueue_task('identify_library')
502
+ return jsonify({'success': True, 'errors': []}), 201
503
+
504
+
505
+ @app.delete('/api/titledb/custom/<title_id>')
506
+ @access_required('admin')
507
+ def delete_custom_title_api(title_id):
508
+ ok, err = titledb_store.delete_custom_title(title_id)
509
+ if not ok:
510
+ return jsonify({'success': False, 'errors': [{'path': 'id', 'error': err}]}), 404
511
+ return jsonify({'success': True, 'errors': []})
512
+
513
+
514
+ @app.route('/api/titles', methods=['GET'])
515
+ @access_required('shop')
516
+ def get_all_titles_api():
517
+ titles_library = generate_library()
518
+
519
+ return jsonify({
520
+ 'total': len(titles_library),
521
+ 'games': titles_library
522
+ })
523
+
524
+ @app.route('/api/get_game/<int:id>')
525
+ @file_access
526
+ def serve_game(id):
527
+ """Serve a game file to authenticated clients."""
528
+ filepath = db.session.query(Files.filepath).filter_by(id=id).first()[0]
529
+ filedir, filename = os.path.split(filepath)
530
+ increment_download_count_throttled(filepath, request.remote_addr)
531
+ return send_from_directory(filedir, filename)
532
+
533
+
534
+
535
+ @app.post('/api/library/scan')
536
+ @access_required('admin')
537
+ def scan_library_api():
538
+ data = request.json
539
+ path = data.get('path')
540
+ if path:
541
+ tasks_mod.enqueue_task('scan_library', {'library_path': path})
542
+ else:
543
+ for lib in get_libraries():
544
+ tasks_mod.enqueue_task('scan_library', {'library_path': lib.path})
545
+ return jsonify({'success': True, 'errors': []})
546
+
547
+
548
+ @app.post('/api/tasks')
549
+ @access_required('admin')
550
+ def enqueue_task_api():
551
+ data = request.json
552
+ task_name = data.get('task_name')
553
+ input_data = data.get('input', {})
554
+ try:
555
+ task, created = tasks_mod.enqueue_task(task_name, input_data)
556
+ return jsonify({
557
+ 'success': True,
558
+ 'task_id': task.id,
559
+ 'created': created,
560
+ 'status': task.status,
561
+ }), 201 if created else 200
562
+ except ValueError as e:
563
+ return jsonify({'success': False, 'error': str(e)}), 400
564
+
565
+ def _serialize_task(t, detail=False):
566
+ iso = lambda d: d.isoformat() if d else None
567
+ data = {
568
+ 'id': t.id,
569
+ 'task_name': t.task_name,
570
+ 'status': t.status,
571
+ 'completion_pct': t.completion_pct,
572
+ 'exit_code': t.exit_code,
573
+ 'error_message': t.error_message,
574
+ 'created_at': iso(t.created_at),
575
+ 'started_at': iso(t.started_at),
576
+ 'completed_at': iso(t.completed_at),
577
+ }
578
+ if detail:
579
+ data['input'] = json.loads(t.input_json) if t.input_json else {}
580
+ data['output'] = json.loads(t.output_json) if t.output_json else None
581
+ return data
582
+
583
+
584
+ @app.get('/api/tasks')
585
+ @access_required('admin')
586
+ def list_tasks_api():
587
+ """List top-level tasks (excludes children unless ?include_children=true)."""
588
+ status = request.args.get('status')
589
+ limit = request.args.get('limit', 50, type=int)
590
+ include_children = request.args.get('include_children', 'false').lower() == 'true'
591
+ query = Task.query.order_by(Task.created_at.desc())
592
+ if not include_children:
593
+ query = query.filter(Task.parent_id.is_(None))
594
+ if status:
595
+ query = query.filter_by(status=status)
596
+ return jsonify({'tasks': [_serialize_task(t) for t in query.limit(limit).all()]})
597
+
598
+
599
+ @app.delete('/api/tasks/failed')
600
+ @access_required('admin')
601
+ def clear_failed_tasks_api():
602
+ deleted = Task.query.filter_by(status='failed').delete()
603
+ db.session.commit()
604
+ return jsonify({'success': True, 'deleted': deleted})
605
+
606
+
607
+ @app.post('/api/files/<int:file_id>/compress')
608
+ @access_required('admin')
609
+ def compress_file_api(file_id):
610
+ """Compress a single file to NSZ/XCZ."""
611
+ file = db.session.get(Files, file_id)
612
+ if not file:
613
+ return jsonify({'error': 'File not found'}), 404
614
+ if file.compressed:
615
+ return jsonify({'error': 'File is already compressed'}), 400
616
+ if file.extension not in COMPRESS_EXT:
617
+ return jsonify({'error': 'File type cannot be compressed'}), 400
618
+ tasks_mod.enqueue_task('compress_file', {'file_id': file_id})
619
+ return jsonify({'success': True})
620
+
621
+
622
+ @app.post('/api/files/<int:file_id>/decompress')
623
+ @access_required('admin')
624
+ def decompress_file_api(file_id):
625
+ """Decompress a single compressed file back to NSP/XCI."""
626
+ file = db.session.get(Files, file_id)
627
+ if not file:
628
+ return jsonify({'error': 'File not found'}), 404
629
+ if not file.compressed:
630
+ return jsonify({'error': 'File is not compressed'}), 400
631
+ tasks_mod.enqueue_task('decompress_file', {'file_id': file_id})
632
+ return jsonify({'success': True})
633
+
634
+
635
+ @app.get('/api/tasks/<int:task_id>')
636
+ @access_required('admin')
637
+ def get_task_api(task_id):
638
+ task = tasks_mod.get_task(task_id)
639
+ if not task:
640
+ return jsonify({'error': 'Task not found'}), 404
641
+ data = _serialize_task(task, detail=True)
642
+ data['children'] = [_serialize_task(c, detail=True) for c in task.children.all()]
643
+ return jsonify(data)
644
+
645
+
646
+ @app.delete('/api/tasks/<int:task_id>')
647
+ @access_required('admin')
648
+ def cancel_task_api(task_id):
649
+ cancelled = tasks_mod.cancel_task(task_id)
650
+ if not cancelled:
651
+ return jsonify({'error': 'Task not found or already terminal'}), 404
652
+ return '', 204
653
+