Adding toggle to Channel page to stop downloading comments for certain channels.

This commit is contained in:
Roland 2025-02-19 14:52:50 -07:00
parent 47d4d72bf8
commit 581d5eaf6e
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
20 changed files with 150 additions and 77 deletions

View file

@ -0,0 +1,31 @@
"""Channel.download_missing_data
Revision ID: f7229a67e333
Revises: dc6637ced17b
Create Date: 2025-02-19 14:02:57.952791
"""
import os
from alembic import op
from sqlalchemy.orm import Session
# revision identifiers, used by Alembic.
revision = 'f7229a67e333'
down_revision = 'dc6637ced17b'
branch_labels = None
depends_on = None
DOCKERIZED = True if os.environ.get('DOCKER', '').lower().startswith('t') else False
def upgrade():
bind = op.get_bind()
session = Session(bind=bind)
session.execute('ALTER TABLE channel ADD COLUMN IF NOT EXISTS download_missing_data BOOLEAN DEFAULT True')
def downgrade():
bind = op.get_bind()
session = Session(bind=bind)
session.execute('ALTER TABLE channel DROP COLUMN IF EXISTS download_missing_data')

View file

@ -7,6 +7,7 @@ import {
ErrorMessage,
humanFileSize,
humanNumber,
InfoPopup,
SearchInput,
secondsToFrequency,
secondsToFullDuration,
@ -36,7 +37,7 @@ import {SortableTable} from "./SortableTable";
import {toast} from "react-semantic-toasts-2";
import {RecurringDownloadsTable} from "./admin/Downloads";
import {TagsContext, TagsSelector} from "../Tags";
import {InputForm} from "../hooks/useForm";
import {InputForm, ToggleForm} from "../hooks/useForm";
import {ChannelDownloadForm, DestinationForm, DownloadTagsSelector} from "./Download";
@ -123,6 +124,7 @@ export function ChannelPage({create, header}) {
name: channel.name,
directory: channel.directory,
url: channel.url,
download_missing_data: channel.download_missing_data,
};
let response = null;
@ -184,7 +186,7 @@ export function ChannelPage({create, header}) {
'name',
);
} else {
setErrorMessage('Invalid channel', 'Unable to save channel. See logs.');
setErrorMessage('Invalid channel', error.message || error.error || 'Unable to save channel. See logs.');
}
} else {
console.error('Did not get a response for channel!');
@ -327,6 +329,18 @@ export function ChannelPage({create, header}) {
</Grid.Row>;
}
const downloadMissingDataInfo = 'Automatically download missing comments, etc, in the background.';
const downloadMissingDataLabel = <>Download Missing Data<InfoPopup content={downloadMissingDataInfo}/></>;
const downloadMissingDataRow = <Grid.Row>
<Grid.Column>
<ToggleForm
form={form}
label={downloadMissingDataLabel}
path='download_missing_data'
/>
</Grid.Column>
</Grid.Row>;
let messageRow;
if (error || success) {
messageRow = <Grid.Row columns={1}>
@ -384,6 +398,7 @@ export function ChannelPage({create, header}) {
</Grid.Column>
</Grid.Row>
{channelTagRow}
{downloadMissingDataRow}
{!create && (channel.url || channel.rss_url) &&
<SimpleAccordion title='Details'>
<Grid>

View file

@ -467,6 +467,7 @@ export const useChannel = (channel_id) => {
directory: '',
url: '',
tag_name: null,
download_missing_data: null,
};
const fetchChannel = async () => {
@ -479,6 +480,7 @@ export const useChannel = (channel_id) => {
c['url'] = c['url'] || '';
c['download_frequency'] = c['download_frequency'] || '';
c['match_regex'] = c['match_regex'] || '';
c['download_missing_data'] = c['download_missing_data'] ?? true;
return c;
}
@ -487,6 +489,7 @@ export const useChannel = (channel_id) => {
name: form.formData.name,
directory: form.formData.directory,
url: form.formData.url,
download_missing_data: form.formData.download_missing_data,
};
if (channel_id) {

View file

@ -437,6 +437,7 @@ export function ToggleForm({form, name, label, path, icon = null, iconSize = 'bi
disabled={form.disabled}
name={name}
checked={inputProps.value}
onChange={inputProps.onChange}
/>
</FormInput>
}

View file

@ -5,5 +5,5 @@ from wrolpi.errors import APIError
class InvalidArchive(APIError):
code = 'INVALID_ARCHIVE'
message = 'The archive is invalid. See server logs.'
summary = 'The archive is invalid. See server logs.'
status_code = HTTPStatus.BAD_REQUEST

View file

@ -5,11 +5,11 @@ from wrolpi.errors import APIError
class NoInventories(APIError):
code = 'NO_INVENTORIES'
message = 'No Inventories'
summary = 'No Inventories'
status_code = HTTPStatus.BAD_REQUEST
class InventoriesVersionMismatch(APIError):
code = 'INVENTORIES_VERSION_MISMATCH'
message = 'Inventories version in the DB does not match the inventories config'
summary = 'Inventories version in the DB does not match the inventories config'
status_code = HTTPStatus.BAD_REQUEST

View file

@ -5,17 +5,17 @@ from wrolpi.errors import APIError
class InvalidOTP(APIError):
code = 'INVALID_OTP'
message = 'OTP has invalid characters'
summary = 'OTP has invalid characters'
status_code = HTTPStatus.BAD_REQUEST
class InvalidPlaintext(APIError):
code = 'INVALID_PLAINTEXT'
message = 'Plaintext has invalid characters'
summary = 'Plaintext has invalid characters'
status_code = HTTPStatus.BAD_REQUEST
class InvalidCiphertext(APIError):
code = 'INVALID_CIPHERTEXT'
message = 'Ciphertext has invalid characters'
summary = 'Ciphertext has invalid characters'
status_code = HTTPStatus.BAD_REQUEST

View file

@ -127,10 +127,10 @@ async def test_channel_conflicts(async_client, test_session, test_directory):
request, response = await _post_channel(new_channel)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json == {'cause': {'code': 'CHANNEL_NAME_CONFLICT',
'error': 'The channel name is already taken.',
'error': 'Bad Request',
'message': 'The channel name is already taken.'},
'code': 'VALIDATION_ERROR',
'error': 'Could not validate the contents of the request',
'error': 'Bad Request',
'message': 'Could not validate the contents of the request'}
# Directory was already used
@ -141,10 +141,10 @@ async def test_channel_conflicts(async_client, test_session, test_directory):
request, response = await _post_channel(new_channel)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json == {'cause': {'code': 'CHANNEL_DIRECTORY_CONFLICT',
'error': 'The directory is already used by another channel.',
'error': 'Bad Request',
'message': 'The directory is already used by another channel.'},
'code': 'VALIDATION_ERROR',
'error': 'Could not validate the contents of the request',
'error': 'Bad Request',
'message': 'Could not validate the contents of the request'}
# URL is already used
@ -158,10 +158,10 @@ async def test_channel_conflicts(async_client, test_session, test_directory):
request, response = await _post_channel(new_channel)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json == {'cause': {'code': 'CHANNEL_URL_CONFLICT',
'error': 'The URL is already used by another channel.',
'error': 'Bad Request',
'message': 'The URL is already used by another channel.'},
'code': 'VALIDATION_ERROR',
'error': 'Could not validate the contents of the request',
'error': 'Bad Request',
'message': 'Could not validate the contents of the request'}
@ -170,21 +170,15 @@ def test_channel_empty_url_doesnt_conflict(test_client, test_session, test_direc
channel_directory = tempfile.TemporaryDirectory(dir=test_directory).name
pathlib.Path(channel_directory).mkdir()
new_channel = {
'name': 'Fooz',
'directory': channel_directory,
}
request, response = test_client.post('/api/videos/channels', content=json.dumps(new_channel))
new_channel = dict(name='Fooz', directory=channel_directory)
request, response = test_client.post('/api/videos/channels', json=new_channel)
assert response.status_code == HTTPStatus.CREATED, response.json
location = response.headers['Location']
channel_directory2 = tempfile.TemporaryDirectory(dir=test_directory).name
pathlib.Path(channel_directory2).mkdir()
new_channel = {
'name': 'Barz',
'directory': channel_directory2,
}
request, response = test_client.post('/api/videos/channels', content=json.dumps(new_channel))
new_channel = dict(name='Barz', directory=channel_directory2)
request, response = test_client.post('/api/videos/channels', json=new_channel)
assert response.status_code == HTTPStatus.CREATED, response.json
assert location != response.headers['Location']

View file

@ -5,47 +5,47 @@ from wrolpi.errors import APIError
class UnknownVideo(APIError):
code = 'UNKNOWN_VIDEO'
message = 'The video could not be found.'
summary = 'The video could not be found.'
status_code = HTTPStatus.NOT_FOUND
class UnknownChannel(APIError):
code = 'UNKNOWN_CHANNEL'
message = 'The channel could not be found.'
summary = 'The channel could not be found.'
status_code = HTTPStatus.NOT_FOUND
class InvalidChannel(APIError):
code = 'INVALID_CHANNEL'
message = 'The channel is not supported'
summary = 'The channel is not supported'
status_code = HTTPStatus.BAD_REQUEST
class ChannelNameConflict(APIError):
code = 'CHANNEL_NAME_CONFLICT'
message = 'The channel name is already taken.'
summary = 'The channel name is already taken.'
status_code = HTTPStatus.BAD_REQUEST
class ChannelURLConflict(APIError):
code = 'CHANNEL_URL_CONFLICT'
message = 'The URL is already used by another channel.'
summary = 'The URL is already used by another channel.'
status_code = HTTPStatus.BAD_REQUEST
class ChannelDirectoryConflict(APIError):
code = 'CHANNEL_DIRECTORY_CONFLICT'
message = 'The directory is already used by another channel.'
summary = 'The directory is already used by another channel.'
status_code = HTTPStatus.BAD_REQUEST
class ChannelSourceIdConflict(APIError):
code = 'CHANNEL_SOURCE_ID_CONFLICT'
message = 'Search is empty, search_str must have content.'
summary = 'Search is empty, search_str must have content.'
status_code = HTTPStatus.BAD_REQUEST
class ChannelURLEmpty(APIError):
code = 'CHANNEL_URL_EMPTY'
message = 'This channel has no URL'
summary = 'This channel has no URL'
status_code = HTTPStatus.BAD_REQUEST

View file

@ -297,7 +297,7 @@ class ChannelsConfig(ConfigFile):
if not channel.source_id and channel.url and flags.have_internet.is_set():
# If we can download from a channel, we must have its source_id.
if download_manager.can_download:
if download_manager.can_download and channel.download_missing_data:
logger.info(f'Fetching channel source id for {channel}')
background_task(fetch_channel_source_id(channel.id))

View file

@ -528,6 +528,7 @@ class Channel(ModelHelper, Base):
directory: pathlib.Path = Column(MediaPathType)
generate_posters = Column(Boolean, default=False) # generating posters may delete files, and can be slow.
calculate_duration = Column(Boolean, default=True) # use ffmpeg to extract duration (slower than info json).
download_missing_data = Column(Boolean, default=True) # fetch missing data like `source_id` and video comments.
source_id = Column(String) # the ID from the source website.
refreshed = Column(Boolean, default=False) # The files in the Channel have been refreshed.
@ -633,6 +634,7 @@ class Channel(ModelHelper, Base):
config = dict(
calculate_duration=self.calculate_duration,
directory=str(self.directory),
download_missing_data=self.download_missing_data,
downloads=[{'url': i.url, 'frequency': i.frequency} for i in self.downloads],
generate_posters=self.generate_posters,
name=self.name,

View file

@ -10,6 +10,7 @@ class ChannelPostRequest:
name: str
directory: str
calculate_duration: Optional[bool] = None
download_missing_data: Optional[bool] = True
generate_posters: Optional[bool] = None
source_id: Optional[str] = None
tag_name: Optional[str] = None
@ -26,6 +27,7 @@ class ChannelPostRequest:
class ChannelPutRequest:
calculate_duration: Optional[bool] = None
directory: Optional[str] = None
download_missing_data: Optional[bool] = True
generate_posters: Optional[bool] = None
mkdir: Optional[bool] = None
name: Optional[str] = None

View file

@ -288,6 +288,25 @@ async def test_import_channel_delete_missing_channels(await_switches, test_sessi
assert str(channel2.directory) not in test_channels_config.read_text()
@pytest.mark.asyncio
async def test_import_channel_download_comments(await_switches, test_session, channel_factory,
test_channels_config):
channel = channel_factory()
assert channel.download_missing_data is True
# download_missing_data is saved to the config.
save_channels_config()
await await_switches()
assert 'download_missing_data: true' in test_channels_config.read_text()
# change download_missing_data to False, channel should be updated on import.
contents = test_channels_config.read_text()
contents = contents.replace('download_missing_data: true', 'download_missing_data: false')
test_channels_config.write_text(contents)
import_channels_config()
assert channel.download_missing_data is False
@pytest.mark.asyncio
async def test_ffprobe_json(async_client, video_file, corrupted_video_file):
content = await common.ffprobe_json(video_file)

View file

@ -7,7 +7,7 @@ import yt_dlp
from sqlalchemy import or_
from sqlalchemy.orm import Session
from modules.videos.models import Video
from modules.videos.models import Video, Channel
from wrolpi.common import logger, limit_concurrent, wrol_mode_check
from wrolpi.dates import now
from wrolpi.db import get_db_session, optional_session
@ -209,7 +209,13 @@ async def get_missing_videos_comments(limit: int = VIDEO_COMMENTS_FETCH_COUNT):
one_month_ago > FileGroup.published_datetime,
FileGroup.published_datetime == None,
),
# Do not download a Videos with a Channel and Channel.download_missing_data is False.
or_(
Channel.download_missing_data == True,
Channel.id == None,
)
).join(FileGroup) \
.outerjoin(Channel) \
.order_by(FileGroup.published_datetime.nullsfirst()) \
.limit(limit)
video_urls = [i.file_group.url for i in videos]

View file

@ -5,23 +5,23 @@ from wrolpi.errors import APIError
class UnknownZim(APIError):
code = 'UNKNOWN_ZIM'
message = 'Failed to find the Zim'
summary = 'Failed to find the Zim'
status_code = HTTPStatus.NOT_FOUND
class UnknownZimEntry(APIError):
code = 'UNKNOWN_ZIM_ENTRY'
message = 'Failed to find the Zim entry at that path'
summary = 'Failed to find the Zim entry at that path'
status_code = HTTPStatus.NOT_FOUND
class UnknownZimTagEntry(APIError):
code = 'UNKNOWN_ZIM_TAG_ENTRY'
message = 'Failed to find the TagZimEntry'
summary = 'Failed to find the TagZimEntry'
status_code = HTTPStatus.NOT_FOUND
class UnknownZimSubscription(APIError):
code = 'UNKNOWN_ZIM_SUBSCRIPTION'
message = 'Failed to find the ZimSubscription'
summary = 'Failed to find the ZimSubscription'
status_code = HTTPStatus.NOT_FOUND

View file

@ -30,7 +30,7 @@ def longitude_to_tile_x(lon: float, zoom: int) -> int:
def get_tile(host: str, zoom: int, x: int, y: int) -> bytes:
response = requests.get(f"{host}/tile/{zoom}/{x}/{y}.png", verify=False)
response = requests.get(f"{host}/{zoom}/{x}/{y}.png", verify=False, timeout=5 * 60)
if response.status_code != 200:
raise RuntimeError(f"Request failed with status code {response.status_code}")
return response.content
@ -75,7 +75,7 @@ def main(
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Download map tiles from WROLPi")
parser.add_argument('-H', '--host', type=str,
default='https://localhost:8084/',
default='https://localhost:8084/hot',
help='The URL of your map tiles server',
)
parser.add_argument('-a', '--zoom-start', type=int, default=0,

View file

@ -1,13 +1,13 @@
import asyncio
import json
import logging
from time import time
from asyncio import CancelledError
from datetime import datetime, timezone, date
from decimal import Decimal
from functools import wraps
from http import HTTPStatus
from pathlib import Path
from time import time
from sanic import response, HTTPResponse, Request, Sanic, SanicException
@ -74,7 +74,7 @@ def get_error_json(exception: BaseException):
"""Return a JSON representation of the Exception instance."""
if isinstance(exception, APIError):
# An exception from WROLPi.
body = dict(error=str(exception), message=exception.message, code=exception.code)
body = dict(error=str(exception), message=exception.summary, code=exception.code)
elif isinstance(exception, SanicException):
# An exception from Sanic.
body = dict(error=str(exception), message=exception.message, code=type(exception).__name__)

View file

@ -5,185 +5,185 @@ from sanic import SanicException
class APIError(SanicException):
code = 'APIError'
message = 'No summary defined'
summary = 'No summary defined'
status_code = HTTPStatus.INTERNAL_SERVER_ERROR
class UnknownDirectory(APIError):
code = 'UNKNOWN_DIRECTORY'
message = 'The directory does not exist.'
summary = 'The directory does not exist.'
status_code = HTTPStatus.NOT_FOUND
class UnknownFile(APIError):
code = 'UNKNOWN_FILE'
message = 'The file does not exist.'
summary = 'The file does not exist.'
status_code = HTTPStatus.NOT_FOUND
class SearchEmpty(APIError):
code = 'SEARCH_EMPTY'
message = 'Search is empty, search_str must have content.'
summary = 'Search is empty, search_str must have content.'
status_code = HTTPStatus.BAD_REQUEST
class ValidationError(APIError):
code = 'VALIDATION_ERROR'
message = 'Could not validate the contents of the request'
summary = 'Could not validate the contents of the request'
status_code = HTTPStatus.BAD_REQUEST
class InvalidOrderBy(ValidationError):
code = 'INVALID_ORDER_BY'
message = 'Invalid order_by'
summary = 'Invalid order_by'
status_code = HTTPStatus.BAD_REQUEST
class WROLModeEnabled(APIError):
code = 'WROL_MODE_ENABLED'
message = 'This method is disabled while WROL Mode is enabled.'
summary = 'This method is disabled while WROL Mode is enabled.'
status_code = HTTPStatus.FORBIDDEN
class InvalidDownload(APIError):
code = 'INVALID_DOWNLOAD'
message = 'The URL cannot be downloaded.'
summary = 'The URL cannot be downloaded.'
status_code = HTTPStatus.BAD_REQUEST
class UnknownDownload(APIError):
code = 'UNKNOWN_DOWNLOAD'
message = 'The Download cannot be found.'
summary = 'The Download cannot be found.'
status_code = HTTPStatus.NOT_FOUND
class UnrecoverableDownloadError(APIError):
code = 'UNRECOVERABLE_DOWNLOAD_ERROR'
message = 'Download experienced an error which cannot be recovered. Download will be deleted.'
summary = 'Download experienced an error which cannot be recovered. Download will be deleted.'
status_code = HTTPStatus.BAD_REQUEST
class InvalidFile(APIError):
code = 'INVALID_FILE'
message = 'File does not exist or is a directory'
summary = 'File does not exist or is a directory'
status_code = HTTPStatus.BAD_REQUEST
class NativeOnly(APIError):
code = 'NATIVE_ONLY'
message = 'This functionality is only supported outside a docker container'
summary = 'This functionality is only supported outside a docker container'
status_code = HTTPStatus.BAD_REQUEST
class HotspotError(APIError):
code = 'HOTSPOT_ERROR'
message = 'Updating/accessing Hotspot encountered an error'
summary = 'Updating/accessing Hotspot encountered an error'
status_code = HTTPStatus.INTERNAL_SERVER_ERROR
class LogLevelError(APIError):
code = 'LOG_LEVEL_ERROR'
message = 'Log level is invalid'
summary = 'Log level is invalid'
status_code = HTTPStatus.BAD_REQUEST
class UnknownArchive(APIError):
code = 'UNKNOWN_ARCHIVE'
message = 'Unable to find the archive'
summary = 'Unable to find the archive'
status_code = HTTPStatus.NOT_FOUND
class UnknownTag(APIError):
code = 'UNKNOWN_TAG'
message = 'Unable to find the tag'
summary = 'Unable to find the tag'
status_code = HTTPStatus.NOT_FOUND
class UsedTag(APIError):
code = 'USED_TAG'
message = 'Tag is being used'
summary = 'Tag is being used'
status_code = HTTPStatus.BAD_REQUEST
class InvalidTag(APIError):
code = 'INVALID_TAG'
message = 'Tag is not allowed'
summary = 'Tag is not allowed'
status_code = HTTPStatus.BAD_REQUEST
class FileGroupIsTagged(APIError):
code = 'FILE_GROUP_IS_TAGGED'
message = 'FileGroup is tagged'
summary = 'FileGroup is tagged'
status_code = HTTPStatus.CONFLICT
class FileGroupAlreadyTagged(APIError):
code = 'FILE_GROUP_ALREADY_TAGGED'
message = 'FileGroup is already tagged with this Tag'
summary = 'FileGroup is already tagged with this Tag'
status_code = HTTPStatus.CONFLICT
class FileUploadFailed(APIError):
code = 'FILE_UPLOAD_FAILED'
message = 'Failed to upload file'
summary = 'Failed to upload file'
status_code = HTTPStatus.BAD_REQUEST
class FileConflict(APIError):
code = 'FILE_CONFLICT'
message = 'File/directory already exists'
summary = 'File/directory already exists'
status_code = HTTPStatus.CONFLICT
class HotspotPasswordTooShort(APIError):
code = 'HOTSPOT_PASSWORD_TOO_SHORT'
message = 'Hotspot password must be at least 8 characters'
summary = 'Hotspot password must be at least 8 characters'
status_code = HTTPStatus.BAD_REQUEST
class ShutdownFailed(APIError):
code = 'SHUTDOWN_FAILED'
message = 'Unable to shutdown the WROLPi'
summary = 'Unable to shutdown the WROLPi'
status_code = HTTPStatus.INTERNAL_SERVER_ERROR
class InvalidConfig(APIError):
code = 'INVALID_CONFIG'
message = 'Config is invalid'
summary = 'Config is invalid'
status_code = HTTPStatus.BAD_REQUEST
class NoPrimaryFile(APIError):
code = 'NO_PRIMARY_FILE'
message = 'Could not find a primary file in the provided group'
summary = 'Could not find a primary file in the provided group'
status_code = HTTPStatus.INTERNAL_SERVER_ERROR
class InvalidDatetime(APIError):
code = 'INVALID_DATETIME'
message = 'Unable to parse a datetime string'
summary = 'Unable to parse a datetime string'
status_code = HTTPStatus.INTERNAL_SERVER_ERROR
class InvalidDirectory(APIError):
code = 'INVALID_DIRECTORY'
message = 'Directory is not valid or impossible'
summary = 'Directory is not valid or impossible'
status_code = HTTPStatus.BAD_REQUEST
class RefreshConflict(APIError):
code = 'REFRESH_CONFLICT'
message = 'Not possible during file refresh'
summary = 'Not possible during file refresh'
status_code = HTTPStatus.CONFLICT
class IgnoredDirectoryError(APIError):
code = 'IGNORED_DIRECTORY_ERROR'
message = 'Directory is being ignored'
summary = 'Directory is being ignored'
status_code = HTTPStatus.BAD_REQUEST
class DownloadError(APIError):
code = 'DOWNLOAD_ERROR'
message = 'Unable to complete download'
summary = 'Unable to complete download'
status_code = HTTPStatus.INTERNAL_SERVER_ERROR

View file

@ -1615,7 +1615,7 @@ async def upsert_file(file: pathlib.Path | str, tag_names: List[str] = None) ->
if url and download_manager.is_skipped(url):
download_manager.remove_from_skip_list(url)
upsert_directories(file.parents, [])
upsert_directories([], file.parents)
session.commit()

View file

@ -261,7 +261,7 @@ def test_hotspot_settings(test_session, test_client, test_wrolpi_config):
request, response = test_client.patch('/api/settings', content=json.dumps(content))
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json == {'code': 'HOTSPOT_PASSWORD_TOO_SHORT',
'error': 'Hotspot password must be at least 8 characters',
'error': 'Bad Request',
'message': 'Hotspot password must be at least 8 characters'
}
@ -714,7 +714,7 @@ def test_recursive_errors():
try:
two()
except Exception as e2:
raise ValidationError('A broad error') from e2
raise ValidationError() from e2
try:
three()
@ -722,7 +722,7 @@ def test_recursive_errors():
response = json_error_handler(None, e)
assert json.loads(response.body.decode()) == {
'code': 'VALIDATION_ERROR',
'error': 'A broad error',
'error': 'Bad Request', # Fallback when no message is passed on exception creation.
'message': 'Could not validate the contents of the request',
'cause': {
'code': 'SEARCH_EMPTY',