wrolpi/wrolpi/root_api.py
Roland 19f4958fb5 zim: ship a default fallback Zim and honor the configured Zim directory
Two related gaps in how WROLPi serves Kiwix content:

1. A fresh WROLPi (or one whose zims are all corrupt/absent) showed a blank
   Kiwix.  Every image now ships a small built-in default Zim that Kiwix falls
   back to, telling the user to download real Zim files.

2. The startup scripts hardcoded the `/zims` directory, ignoring wrolpi.yaml's
   `zims_destination` (which the Python side already honors).

Changes:
- New wrolpi/scripts/read_config_value.sh: a zero-dependency (awk) reader so
  non-Python scripts can reliably read top-level wrolpi.yaml scalars.  Also a
  config_value() wrapper in wrolpi/scripts/lib.sh.
- New wrolpi/scripts/default_zim/: HTML source + a reproducible builder
  (ghcr.io/openzim/zim-tools) that outputs the committed wrolpi/blobs/default.zim.
- New wrolpi/blobs/: any committed blob here is copied to /opt/wrolpi-blobs by
  the pi-gen and debian-live builds; the Docker zim image bakes it in.
- start_kiwix_serve.sh honors zims_destination and falls back to the default
  Zim when no functioning zim is found.
- docker/zim/entrypoint.sh gains the same default-Zim fallback.  It needs no
  config read: it already scans the whole mounted media dir, and
  zims_destination is always a subdirectory of that mount.
- Changing zims_destination via the settings API now restarts Kiwix so it
  serves the new directory without a reboot.

Tests: new test_read_config_value.py, extended test_start_kiwix_serve.py
(configured dir + default-Zim fallback), and a root_api test for the restart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:09:57 -06:00

864 lines
32 KiB
Python

import asyncio
import json
import pathlib
import re
from http import HTTPStatus
from zoneinfo import ZoneInfo
import vininfo.exceptions
from sanic import response, Blueprint, __version__ as sanic_version
from sanic.request import Request
from sanic_ext import validate
from sanic_ext.extensions.openapi import openapi
from vininfo import Vin
from vininfo.details._base import VinDetails
from modules.archive.api import archive_bp
from modules.docs.api import docs_bp
from modules.flasher.api import flasher_bp
from modules.inventory import inventory_bp
from modules.map.api import map_bp
from modules.videos.api import videos_bp
from modules.zim.api import zim_bp
from wrolpi import flags, schema, dates
from wrolpi import tags
from wrolpi.api_utils import json_response, api_app
from wrolpi.collections.api import collection_bp
from wrolpi.common import logger, get_wrolpi_config, wrol_mode_enabled, get_media_directory, \
wrol_mode_check, native_only, disable_wrol_mode, enable_wrol_mode, get_global_statistics, url_strip_host, \
set_global_log_level, get_relative_to_media_directory, search_other_estimates, set_system_timezone
from wrolpi.config_api import config_bp
from wrolpi.dates import now
from wrolpi.downloader import download_manager
from wrolpi.errors import WROLModeEnabled, InvalidConfig, ValidationError
from wrolpi.events import get_events, Events
from wrolpi.files import files_bp
from wrolpi.files.lib import get_file_statistics, search_file_suggestion_count
from wrolpi.log_levels import int_to_name
from wrolpi.tags import Tag
from wrolpi.vars import DOCKERIZED, IS_RPI, IS_RPI4, IS_RPI5, API_HOST, API_PORT, API_WORKERS, API_DEBUG, \
API_ACCESS_LOG, truthy_arg, API_AUTO_RELOAD
from wrolpi.version import __version__
logger = logger.getChild(__name__)
api_app.config.FALLBACK_ERROR_FORMAT = 'json'
api_bp = Blueprint('RootAPI', url_prefix='/api')
# Blueprints order here defines what order they are displayed in OpenAPI Docs.
api_app.blueprint(api_bp)
api_app.blueprint(archive_bp)
api_app.blueprint(collection_bp) # Unified collection endpoints
api_app.blueprint(config_bp)
api_app.blueprint(docs_bp)
api_app.blueprint(files_bp)
api_app.blueprint(flasher_bp)
api_app.blueprint(inventory_bp)
api_app.blueprint(map_bp)
api_app.blueprint(videos_bp)
api_app.blueprint(zim_bp)
def run_webserver(
host: str = API_HOST,
port: int = API_PORT,
workers: int = API_WORKERS,
api_debug: bool = API_DEBUG,
access_log: bool = API_ACCESS_LOG,
):
# Attach all blueprints after they have been defined.
kwargs = dict(
host=host,
port=port,
workers=workers,
debug=api_debug,
access_log=access_log,
auto_reload=DOCKERIZED,
)
logger.warning(f'Running Sanic {sanic_version} with kwargs {kwargs}')
return api_app.run(**kwargs)
def init_parser(parser):
# Called by WROLPI's main() function
parser.add_argument('-H', '--host', default=API_HOST, help='What network interface to connect webserver')
parser.add_argument('-p', '--port', default=API_PORT, type=int, help='What port to connect webserver')
parser.add_argument('-w', '--workers', default=API_WORKERS, type=int, help='How many web workers to run')
parser.add_argument('--access-log', default=API_ACCESS_LOG, type=truthy_arg, help='Enable Sanic access log')
parser.add_argument('--api-debug', default=API_DEBUG, type=truthy_arg, help='Enable Sanic debug log')
parser.add_argument('--api-auto-reload', default=API_AUTO_RELOAD, type=truthy_arg, help='Enable Sanic auto reload')
def main(args):
return run_webserver(
host=args.host,
port=args.port,
workers=args.workers,
api_debug=args.api_debug,
access_log=args.access_log,
)
index_html = '''
<html>
<body>
<p>
This is a WROLPi API.
<ul>
<li>You can test it at this endpoint <a href="/api/echo">/api/echo</a></li>
<li>You can view the docs at <a href="/docs">/docs</a></li>
</ul>
</p>
</body>
</html>
'''
@api_app.get('/')
@api_bp.get('/')
async def index(_):
return response.html(index_html)
@api_bp.route('/echo', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'])
@openapi.definition(
description='Echo whatever is sent to this.',
response=schema.EchoResponse,
)
async def echo(request: Request):
try:
request_json = request.json
except Exception as e:
logger.error('Failed to parse request JSON', exc_info=e)
request_json = None
request_headers = dict(request.headers)
ret = dict(
form=request.form,
headers=request_headers,
json=request_json,
method=request.method,
args=request.args,
)
return response.json(ret)
@api_bp.route('/settings', methods=['GET', 'OPTIONS'])
@openapi.definition(
description='Get WROLPi settings',
response=schema.SettingsResponse,
)
def get_settings(_: Request):
wrolpi_config = get_wrolpi_config()
# Drop special directories (e.g. zims) that must never be ignored, if present from older configs.
from wrolpi.files.lib import sanitize_ignored_directories
sanitize_ignored_directories()
ignored_directories = [get_relative_to_media_directory(i) for i in wrolpi_config.ignored_directories]
settings = {
'archive_destination': wrolpi_config.archive_destination,
'download_manager_disabled': download_manager.is_disabled,
'download_manager_stopped': download_manager.is_stopped,
'download_daily_limit_global': wrolpi_config.download_daily_limit_global,
'download_daily_limit_per_domain': wrolpi_config.download_daily_limit_per_domain,
'download_on_startup': wrolpi_config.download_on_startup,
'download_timeout': wrolpi_config.download_timeout,
'download_wait': wrolpi_config.download_wait,
'download_window_start': wrolpi_config.download_window_start,
'download_window_end': wrolpi_config.download_window_end,
'hotspot_device': wrolpi_config.hotspot_device,
'hotspot_on_startup': wrolpi_config.hotspot_on_startup,
'hotspot_password': wrolpi_config.hotspot_password,
'hotspot_ssid': wrolpi_config.hotspot_ssid,
# Hotspot/throttle status now from Controller (/controller/api/hotspot/status, /controller/api/throttle/status)
'check_for_upgrades': wrolpi_config.check_for_upgrades,
'ignore_outdated_zims': wrolpi_config.ignore_outdated_zims,
'ignored_directories': ignored_directories,
'log_level': api_app.shared_ctx.log_level.value,
'map_default_location': wrolpi_config.map_default_location,
'map_destination': wrolpi_config.map_destination,
'nav_color': wrolpi_config.nav_color,
'media_directory': str(get_media_directory()), # Convert to string to avoid conversion to relative.
'playlists_destination': wrolpi_config.playlists_destination,
'tags_directory': wrolpi_config.tags_directory,
'throttle_on_startup': wrolpi_config.throttle_on_startup,
'version': __version__,
'videos_destination': wrolpi_config.videos_destination,
'wrol_mode': wrolpi_config.wrol_mode,
'zims_destination': wrolpi_config.zims_destination,
'require_cookies_unlocked': wrolpi_config.require_cookies_unlocked,
'require_media_mounted': wrolpi_config.require_media_mounted,
'save_ffprobe_json': wrolpi_config.save_ffprobe_json,
'timezone': wrolpi_config.timezone,
}
return json_response(settings)
@api_bp.patch('/settings')
@openapi.definition(
description='Update WROLPi settings',
body=schema.SettingsRequest,
validate=True,
)
async def update_settings(_: Request, body: schema.SettingsRequest):
if wrol_mode_enabled() and body.wrol_mode is None:
# Cannot update settings while WROL Mode is enabled, unless you want to disable WROL Mode.
raise WROLModeEnabled()
if body.wrol_mode is False:
# Disable WROL Mode
await disable_wrol_mode()
return response.empty()
elif body.wrol_mode is True:
# Enable WROL Mode
enable_wrol_mode()
return response.empty()
if body.hotspot_password and len(body.hotspot_password) < 8:
raise InvalidConfig('Hotspot password must be at least 8 characters')
# Remove any keys with None values, then save the config.
new_config = {k: v for k, v in body.__dict__.items() if v is not None}
wrolpi_config = get_wrolpi_config()
old_zims_destination = wrolpi_config.zims_destination
if not new_config:
raise InvalidConfig()
if body.archive_destination and pathlib.Path(body.archive_destination).is_absolute():
raise InvalidConfig('Archive directory must be relative to media directory')
elif not body.archive_destination:
new_config['archive_destination'] = wrolpi_config.default_config['archive_destination']
if body.videos_destination and pathlib.Path(body.videos_destination).is_absolute():
raise InvalidConfig('Videos directory must be relative to media directory')
elif not body.videos_destination:
new_config['videos_destination'] = wrolpi_config.default_config['videos_destination']
if body.map_destination and pathlib.Path(body.map_destination).is_absolute():
raise InvalidConfig('Map directory must be relative to media directory')
elif not body.map_destination:
new_config['map_destination'] = wrolpi_config.default_config['map_destination']
if body.zims_destination and pathlib.Path(body.zims_destination).is_absolute():
raise InvalidConfig('Zims directory must be relative to media directory')
elif not body.zims_destination:
new_config['zims_destination'] = wrolpi_config.default_config['zims_destination']
if body.playlists_destination:
# The playlists sync deletes files in its directory, so this destination gets stricter
# validation: no traversal, and no overlap with the other content destinations. Compare
# against the destinations in this same request when provided.
from types import SimpleNamespace
from wrolpi.collections.sync import validate_playlists_destination
effective = SimpleNamespace(
videos_destination=body.videos_destination or wrolpi_config.videos_destination,
archive_destination=body.archive_destination or wrolpi_config.archive_destination,
map_destination=body.map_destination or wrolpi_config.map_destination,
zims_destination=body.zims_destination or wrolpi_config.zims_destination,
)
if error := validate_playlists_destination(body.playlists_destination, effective):
raise InvalidConfig(error)
else:
new_config['playlists_destination'] = wrolpi_config.default_config['playlists_destination']
# Validate timezone if provided; empty string clears it.
if body.timezone is not None:
if body.timezone == '':
new_config['timezone'] = None
else:
try:
ZoneInfo(body.timezone)
except (KeyError, Exception):
raise InvalidConfig(f'Invalid timezone: {body.timezone}')
# Daily download limits must be non-negative integers. 0 (or null) means unlimited.
for field_name in ('download_daily_limit_global', 'download_daily_limit_per_domain'):
value = getattr(body, field_name, None)
if value is not None and value < 0:
raise InvalidConfig(f'{field_name} must be a non-negative integer')
# Handle download window fields: empty string clears the value.
for field_name in ('download_window_start', 'download_window_end'):
value = getattr(body, field_name, None)
if value is not None:
if value == '':
new_config[field_name] = None
else:
if not re.match(r'^\d{2}:\d{2}$', value):
raise InvalidConfig(f'{field_name} must be in HH:MM format')
new_config[field_name] = value
log_level = new_config.pop('log_level', None)
if isinstance(log_level, int):
set_global_log_level(log_level)
# Persist log level to config
wrolpi_config.log_level = int_to_name(log_level)
# Save config settings (hotspot control is now through Controller endpoints)
wrolpi_config.update(new_config)
# If the Zim directory changed, restart Kiwix so it serves the new directory
# without waiting for a reboot. Local import avoids a circular import
# (modules depend on wrolpi, not the reverse).
if wrolpi_config.zims_destination != old_zims_destination:
from modules.zim.lib import restart_kiwix_handler
restart_kiwix_handler.activate_switch()
# Apply timezone to the system via Controller (native deployments only).
if body.timezone is not None and not DOCKERIZED:
timezone_value = new_config.get('timezone')
if timezone_value:
await set_system_timezone(timezone_value)
return response.empty()
@api_bp.post('/valid_regex')
@openapi.definition(
description='Check if the regex is valid.',
body=schema.RegexRequest,
response=schema.RegexResponse,
validate=True,
)
def valid_regex(_: Request, body: schema.RegexRequest):
try:
re.compile(body.regex)
return response.json({'valid': True, 'regex': body.regex})
except re.error:
return response.json({'valid': False, 'regex': body.regex}, HTTPStatus.BAD_REQUEST)
@api_bp.post('/download')
@openapi.definition(
description='Download all the URLs that are provided.',
body=schema.DownloadRequest,
validate=True,
)
@wrol_mode_check
async def post_download(request: Request, body: schema.DownloadRequest):
# Raises an InvalidDownload if the Downloader cannot be found.
download_manager.find_downloader_by_name(body.downloader)
session = request.ctx.session
kwargs = dict(downloader_name=body.downloader,
sub_downloader_name=body.sub_downloader, reset_attempts=True,
destination=body.destination, tag_names=body.tag_names,
settings=body.settings, override_skip=True)
if body.frequency:
download_manager.recurring_download(session, body.urls[0], body.frequency, collection_id=body.collection_id,
**kwargs)
else:
download_manager.create_downloads(session, body.urls, **kwargs)
if download_manager.disabled.is_set() or download_manager.stopped.is_set():
# Downloads are disabled, warn the user.
Events.send_downloads_disabled('Download created. But, downloads are disabled.')
return response.empty(status=HTTPStatus.CREATED)
@api_bp.put('/download/<download_id:int>')
@openapi.definition(
description='Update properties of the Download',
body=schema.DownloadRequest,
validate=True,
)
@wrol_mode_check
async def put_download(request: Request, download_id: int, body: schema.DownloadRequest):
# Raises an InvalidDownload if the Downloader cannot be found.
download_manager.find_downloader_by_name(body.downloader)
if len(body.urls) != 1:
raise ValidationError('Only one URL can be specified when updating a Download')
session = request.ctx.session
download_manager.update_download(
session,
id_=download_id,
url=body.urls[0],
downloader=body.downloader,
destination=body.destination,
frequency=body.frequency,
tag_names=body.tag_names,
sub_downloader=body.sub_downloader,
settings=body.settings,
collection_id=body.collection_id,
)
if download_manager.disabled.is_set() or download_manager.stopped.is_set():
# Downloads are disabled, warn the user.
Events.send_downloads_disabled('Download created. But, downloads are disabled.')
return response.empty()
@api_bp.delete('/download/<download_id:int>')
@openapi.description('Delete a download')
@wrol_mode_check
async def delete_download(request: Request, download_id: int):
session = request.ctx.session
deleted = download_manager.delete_download(session, download_id)
return response.empty(HTTPStatus.NO_CONTENT if deleted else HTTPStatus.NOT_FOUND)
@api_bp.post('/download/<download_id:int>/restart')
@openapi.description('Restart a download.')
async def restart_download(request: Request, download_id: int):
session = request.ctx.session
download_manager.restart_download(session, download_id)
return response.empty()
@api_bp.get('/download')
@openapi.description('Get all Downloads so they can be displayed to the User.')
async def get_downloads(_: Request):
data = download_manager.get_fe_downloads()
# Convert `destination` to relative.
for download in data['once_downloads']:
download['destination'] = get_relative_to_media_directory(download['destination']) \
if download['destination'] else None
for download in data['recurring_downloads']:
download['destination'] = get_relative_to_media_directory(download['destination']) \
if download['destination'] else None
return json_response(data)
@api_bp.post('/download/<download_id:int>/kill')
@openapi.description('Kill a download. It will be stopped if it is pending.')
async def kill_download(_: Request, download_id: int):
download_manager.kill_download(download_id)
return response.empty()
@api_bp.post('/download/kill')
@openapi.description('Kill all downloads. Disable downloading.')
async def kill_downloads(_: Request):
logger.warning('Disabled downloads')
download_manager.disable()
return response.empty()
@api_bp.post('/download/enable')
@openapi.description('Enable and start downloading.')
async def enable_downloads(_: Request):
await download_manager.enable()
return response.empty()
@api_bp.post('/download/clear_completed')
@openapi.description('Clear completed downloads')
async def clear_completed(request: Request):
session = request.ctx.session
download_manager.delete_completed(session)
return response.empty()
@api_bp.post('/download/clear_failed')
@openapi.description('Clear failed downloads')
async def clear_failed(request: Request):
session = request.ctx.session
download_manager.delete_failed(session)
return response.empty()
@api_bp.post('/download/retry_once')
@openapi.description('Retry failed once-downloads')
async def retry_once(_: Request):
download_manager.retry_downloads(reset_attempts=True)
return response.empty()
@api_bp.post('/download/delete_once')
@openapi.description('Delete all once downloads')
async def delete_once(request: Request):
session = request.ctx.session
download_manager.delete_once(session)
return response.empty()
@api_bp.post('/download/batch/delete')
@openapi.definition(
description='Delete specific downloads by their IDs',
body=schema.DownloadBatchRequest,
validate=True,
)
@wrol_mode_check
async def batch_delete_downloads(request: Request, body: schema.DownloadBatchRequest):
session = request.ctx.session
deleted_ids = download_manager.delete_downloads_by_ids(session, body.download_ids)
return json_response({'deleted_ids': deleted_ids})
@api_bp.post('/download/batch/retry')
@openapi.definition(
description='Retry specific downloads by their IDs',
body=schema.DownloadBatchRequest,
validate=True,
)
@wrol_mode_check
async def batch_retry_downloads(request: Request, body: schema.DownloadBatchRequest):
session = request.ctx.session
count = download_manager.retry_downloads_by_ids(session, body.download_ids)
return json_response({'retried_count': count})
@api_bp.post('/download/batch/clear')
@openapi.definition(
description='Clear specific completed downloads by their IDs',
body=schema.DownloadBatchRequest,
validate=True,
)
@wrol_mode_check
async def batch_clear_downloads(request: Request, body: schema.DownloadBatchRequest):
session = request.ctx.session
deleted_ids = download_manager.clear_completed_by_ids(session, body.download_ids)
return json_response({'deleted_ids': deleted_ids})
@api_bp.get('/downloaders')
@openapi.description('List all Downloaders that can be specified by the user.')
async def get_downloaders(_: Request):
downloaders = download_manager.list_downloaders()
disabled = download_manager.disabled.is_set()
ret = dict(downloaders=downloaders, manager_disabled=disabled)
return json_response(ret)
@api_bp.get('/status')
@openapi.definition(
description='Get the status of CPU/load/etc.',
response=schema.StatusResponse,
)
async def get_status(request: Request):
downloads = dict()
if flags.db_up.is_set():
try:
downloads = download_manager.get_summary()
except Exception as e:
logger.debug('Unable to get download status', exc_info=e)
sanic_workers = dict()
if hasattr(request.app, 'multiplexer'):
# `multiplexer` may be empty while testing.
sanic_workers = {
i: {'pid': j['pid'], 'state': j['state']}
for i, j in request.app.multiplexer.workers.items()
if i.startswith('Sanic-Server')
}
wrolpi_config = get_wrolpi_config()
if wrolpi_config.timezone:
tz = ZoneInfo(wrolpi_config.timezone)
local_time = now().astimezone(tz).isoformat()
else:
local_time = now().astimezone().isoformat()
ret = dict(
dockerized=DOCKERIZED,
downloads=downloads,
flags=flags.get_flags(),
# Hotspot and throttle status now come from Controller (/controller/api/stats)
is_rpi4=IS_RPI4,
is_rpi5=IS_RPI5,
is_rpi=IS_RPI,
local_time=local_time,
sanic_workers=sanic_workers,
version=__version__,
wrol_mode=wrol_mode_enabled(),
# Include all stats from status worker (includes upgrade info and git_branch).
**api_app.shared_ctx.status,
)
return json_response(ret)
@api_bp.get('/statistics')
@openapi.definition(
description='Get summary statistics of all files',
)
async def get_statistics(_):
file_statistics = get_file_statistics()
global_statistics = get_global_statistics()
return json_response({
'file_statistics': file_statistics,
'global_statistics': global_statistics,
})
@api_bp.get('/events/feed')
@openapi.description('Get all events after the provided date.')
@validate(query=schema.EventsRequest)
async def feed(request: Request, query: schema.EventsRequest):
# Get the current datetime from the API. The frontend will use this to request any events that happen after it's
# previous request. The API decides what the time is, just in case the RPi's clock is wrong, or no NTP is
# available.
start = now()
after = None if query.after in (None, 'None') else dates.strpdate(query.after)
events = get_events(after)
return json_response(dict(events=events, now=start))
@api_bp.get('/tag')
@openapi.definition(
description='Get a list of all Tags',
)
async def get_tags_request(request: Request):
tags_ = tags.get_tags()
tag_names = request.args.getlist('tag_names')
result = dict(tags=tags_)
if tag_names:
limit = request.args.get('limit')
limit = int(limit) if limit is not None else None
result['overlapping_tag_names'] = tags.get_overlapping_tags(tag_names, limit=limit)
return json_response(result)
@api_bp.get('/tag/recent')
@openapi.definition(
description='Get the most recently used tags',
)
async def get_recent_tags_request(_: Request):
tag_names = tags.get_recent_tags()
return json_response(dict(tag_names=tag_names))
@api_bp.post('/tag', name='tag_crate')
@api_bp.post('/tag/<tag_id:int>', name='tag_update')
@openapi.definition(
description='Create or update a Tag',
body=schema.TagRequest,
validate=True,
)
async def post_tag(request: Request, body: schema.TagRequest, tag_id: int = None):
session = request.ctx.session
await tags.upsert_tag(session, body.name, body.color, tag_id)
if tag_id:
return response.empty(HTTPStatus.OK)
else:
return response.empty(HTTPStatus.CREATED)
@api_bp.delete('/tag/<tag_id:int>')
async def delete_tag_request(request: Request, tag_id: int):
session = request.ctx.session
Tag.find_by_id(session, tag_id).delete()
return response.empty()
@api_bp.post('/notify')
@validate(schema.NotifyRequest)
async def post_notify(_: Request, body: schema.NotifyRequest):
"""Sends a notify Event"""
url = url_strip_host(body.url)
Events.send_user_notify(body.message, url)
return response.empty(HTTPStatus.CREATED)
@api_bp.post('/vin_number_decoder')
@openapi.definition(
description='Decode a VIN number',
body=schema.VINDecoderRequest,
response=schema.VINDecoderResponse,
validate=True,
)
async def post_vin_number_decoder(_: Request, body: schema.VINDecoderRequest):
try:
vin = Vin(body.vin_number)
except vininfo.exceptions.VininfoException:
return json_response({}, HTTPStatus.BAD_REQUEST)
def detail_to_json(details: VinDetails, key: str):
if details:
attr = getattr(details, key)
if attr and attr.name:
name = attr.name
if isinstance(name, list):
return ', '.join(str(i) for i in name)
else:
return name
vin = schema.VIN(
country=vin.country,
manufacturer=vin.manufacturer,
region=vin.region,
years=','.join(map(str, vin.years)),
body=detail_to_json(vin.details, 'body'),
engine=detail_to_json(vin.details, 'engine'),
model=detail_to_json(vin.details, 'model'),
plant=detail_to_json(vin.details, 'plant'),
transmission=detail_to_json(vin.details, 'transmission'),
serial=detail_to_json(vin.details, 'serial'),
)
return json_response(dict(vin=vin))
@api_bp.get('/upgrade/check')
@openapi.definition(description='Check for available WROLPi updates')
@native_only
async def get_upgrade_check(request: Request):
"""
Check if an update is available by comparing local git HEAD with origin/{branch}.
Query params:
force: If 'true', force a fresh git fetch before checking.
"""
from wrolpi.upgrade import check_for_update
force = request.args.get('force', 'false').lower() == 'true'
result = check_for_update(fetch=force)
# Update shared_ctx.status so /api/status returns the latest info.
# Using shared_ctx.status (a manager.dict) ensures info is shared across all workers.
api_app.shared_ctx.status['update_available'] = result.get('update_available', False)
api_app.shared_ctx.status['latest_commit'] = result.get('latest_commit')
api_app.shared_ctx.status['current_commit'] = result.get('current_commit')
api_app.shared_ctx.status['commits_behind'] = result.get('commits_behind', 0)
return json_response(result)
@api_bp.post('/upgrade/start')
@openapi.definition(description='Start WROLPi upgrade')
@native_only
@wrol_mode_check
async def post_upgrade_start(_: Request):
"""
Trigger the WROLPi upgrade process.
This executes /opt/wrolpi/upgrade.sh which will:
1. Stop the API and app services
2. Fetch latest code from git
3. Run upgrade scripts
4. Restart services
The frontend should redirect to the maintenance page after calling this.
Returns 202 Accepted as the upgrade runs asynchronously.
"""
from wrolpi.upgrade import start_upgrade
await start_upgrade()
return response.empty(HTTPStatus.ACCEPTED)
@api_bp.post('/search_suggestions')
@openapi.definition(
description='Suggest related Channels/Domains/etc. to the user.',
body=schema.SearchSuggestionsRequest,
validate=True,
)
async def post_search_suggestions(request: Request, body: schema.SearchSuggestionsRequest):
from modules.videos.channel.lib import search_channels_by_name
from modules.archive.lib import search_domains_by_name
from modules.docs.lib import search_authors_by_name, search_subjects_by_name
session = request.ctx.session
channels, domains, authors, subjects = await asyncio.gather(
search_channels_by_name(session, body.search_str, order_by_video_count=body.order_by_video_count),
search_domains_by_name(session, body.search_str),
search_authors_by_name(session, body.search_str),
search_subjects_by_name(session, body.search_str),
)
ret = dict(
channels=channels,
domains=domains,
authors=authors,
subjects=subjects,
)
return json_response(ret)
@api_bp.post('/search_file_estimates')
@openapi.definition(
description='Get a count of FileGroup suggestions.',
body=schema.SearchFileEstimateRequest,
validate=True,
)
async def post_search_file_estimates(_: Request, body: schema.SearchFileEstimateRequest):
"""Used by the Global search to suggest FileGroup count to the user."""
counts = await search_file_suggestion_count(
body.search_str,
body.tag_names,
body.mimetypes,
body.months,
body.from_year,
body.to_year,
body.any_tag,
)
ret = dict(
file_groups=counts['file_groups'],
file_groups_deep=counts['file_groups_deep'],
)
return json_response(ret)
@api_bp.post('/search_other_estimates')
@openapi.definition(
description='Get a count of other suggestions.',
body=schema.SearchOtherEstimateRequest,
validate=True,
)
async def post_search_other_estimates(_: Request, body: schema.SearchOtherEstimateRequest):
"""Used by the Global search to suggest FileGroup count to the user."""
others = await search_other_estimates(body.tag_names)
ret = dict(others=others)
return json_response(ret)
# Browser extension distribution.
# Files are committed to wrolpi/static/extensions/ and served as binary
# downloads. The whitelist prevents path traversal and limits exposure to
# only the two filenames the install page knows about.
EXTENSION_DIR = pathlib.Path(__file__).parent / 'static' / 'extensions'
EXTENSION_FILES = {
'wrolpi-chrome.zip': 'application/zip',
# Firefox click-to-install requires this exact Content-Type.
'wrolpi-firefox.xpi': 'application/x-xpinstall',
}
@api_bp.get('/extensions')
@openapi.description('Metadata for the WROLPi browser extension binaries.')
async def get_extensions_metadata(_: Request):
versions = {}
versions_path = EXTENSION_DIR / 'versions.json'
if versions_path.is_file():
try:
versions = json.loads(versions_path.read_text())
except json.JSONDecodeError:
logger.warning(f'Could not parse {versions_path}')
files = {}
for name in EXTENSION_FILES:
path = EXTENSION_DIR / name
files[name] = {
'available': path.is_file(),
'size_bytes': path.stat().st_size if path.is_file() else None,
}
return json_response(dict(files=files, versions=versions))
@api_bp.get('/extensions/<filename:str>')
@openapi.description('Download a WROLPi browser extension binary.')
async def get_extension_file(_: Request, filename: str):
if filename not in EXTENSION_FILES:
return response.json({'error': f'Unknown extension file: {filename}'}, status=HTTPStatus.NOT_FOUND)
path = EXTENSION_DIR / filename
if not path.is_file():
return response.json({'error': f'Extension file not installed: {filename}'},
status=HTTPStatus.NOT_FOUND)
mime_type = EXTENSION_FILES[filename]
headers = {}
# Firefox click-to-install requires application/x-xpinstall AND that we do
# NOT send Content-Disposition: attachment — the latter forces the browser
# into "save file" mode, bypassing the add-on install handler. The Chrome
# zip still wants attachment so the browser writes a file instead of
# navigating away from the page.
if mime_type != 'application/x-xpinstall':
headers['Content-Disposition'] = f'attachment; filename="{filename}"'
return await response.file(str(path), mime_type=mime_type, headers=headers)