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.
- ownfoil/__init__.py +1 -0
- ownfoil/_app/app.py +653 -0
- ownfoil/_app/auth.py +370 -0
- ownfoil/_app/clients/__init__.py +9 -0
- ownfoil/_app/clients/client.py +171 -0
- ownfoil/_app/clients/cyberfoil.py +137 -0
- ownfoil/_app/clients/sphaira.py +197 -0
- ownfoil/_app/clients/tinfoil.py +189 -0
- ownfoil/_app/constants.py +149 -0
- ownfoil/_app/db.py +583 -0
- ownfoil/_app/file_compression.py +191 -0
- ownfoil/_app/file_watcher.py +308 -0
- ownfoil/_app/library.py +555 -0
- ownfoil/_app/local.py +42 -0
- ownfoil/_app/migrations/README +1 -0
- ownfoil/_app/migrations/alembic.ini +51 -0
- ownfoil/_app/migrations/env.py +111 -0
- ownfoil/_app/migrations/script.py.mako +23 -0
- ownfoil/_app/migrations/versions/78c33e9bffce_drop_old_files_table_and_create_new_.py +109 -0
- ownfoil/_app/migrations/versions/a1b2c3d4e5f6_add_tasks_and_ignored_events.py +63 -0
- ownfoil/_app/migrations/versions/b2c3d4e5f6a7_add_organized_column_to_files.py +22 -0
- ownfoil/_app/migrations/versions/c3d4e5f6a7b8_add_worker_id_to_tasks.py +22 -0
- ownfoil/_app/migrations/versions/d4e5f6a7b8c9_add_temp_files.py +28 -0
- ownfoil/_app/run.py +97 -0
- ownfoil/_app/settings.py +327 -0
- ownfoil/_app/shop.py +52 -0
- ownfoil/_app/static/favicon/apple-touch-icon.png +0 -0
- ownfoil/_app/static/favicon/favicon-96x96.png +0 -0
- ownfoil/_app/static/favicon/favicon.ico +0 -0
- ownfoil/_app/static/favicon/favicon.svg +5 -0
- ownfoil/_app/static/favicon/site.webmanifest +21 -0
- ownfoil/_app/static/favicon/web-app-manifest-192x192.png +0 -0
- ownfoil/_app/static/favicon/web-app-manifest-512x512.png +0 -0
- ownfoil/_app/static/logo/logo-1024.png +0 -0
- ownfoil/_app/static/logo/logo-128.png +0 -0
- ownfoil/_app/static/logo/logo-256.png +0 -0
- ownfoil/_app/static/logo/logo-512.png +0 -0
- ownfoil/_app/static/logo/logo-64.png +0 -0
- ownfoil/_app/static/logo/logo.svg +3 -0
- ownfoil/_app/static/style.css +125 -0
- ownfoil/_app/tasks.py +967 -0
- ownfoil/_app/templates/base.html +36 -0
- ownfoil/_app/templates/index.html +656 -0
- ownfoil/_app/templates/login.html +34 -0
- ownfoil/_app/templates/nav.html +43 -0
- ownfoil/_app/templates/profile.html +7 -0
- ownfoil/_app/templates/settings.html +1411 -0
- ownfoil/_app/templates/setup.html +546 -0
- ownfoil/_app/titledb.py +100 -0
- ownfoil/_app/titledb_store.py +533 -0
- ownfoil/_app/titles.py +239 -0
- ownfoil/_app/utils.py +352 -0
- ownfoil/_app/worker.py +149 -0
- ownfoil/_app/worker_pool.py +89 -0
- ownfoil/cli.py +64 -0
- ownfoil-2.4.0.dev0.dist-info/METADATA +221 -0
- ownfoil-2.4.0.dev0.dist-info/RECORD +60 -0
- ownfoil-2.4.0.dev0.dist-info/WHEEL +4 -0
- ownfoil-2.4.0.dev0.dist-info/entry_points.txt +2 -0
- 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
|
+
|