Migrate clip/convert to V3 actions; add background clip support, redirect legacy route; update frontend guide
This commit is contained in:
@@ -17,8 +17,7 @@ RUN apt-get update; \
|
|||||||
# Install Python requirements
|
# Install Python requirements
|
||||||
# This file is now located in the project root
|
# This file is now located in the project root
|
||||||
COPY requirements.txt /tmp/requirements.txt
|
COPY requirements.txt /tmp/requirements.txt
|
||||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
RUN pip install -r /tmp/requirements.txt
|
||||||
pip install -r /tmp/requirements.txt
|
|
||||||
|
|
||||||
# Create a reference of actual installed versions
|
# Create a reference of actual installed versions
|
||||||
RUN pip freeze >> /tmp/aether_fastapi_requirements_current.txt
|
RUN pip freeze >> /tmp/aether_fastapi_requirements_current.txt
|
||||||
|
|||||||
@@ -34,18 +34,23 @@ async def clip_video_method(
|
|||||||
Business logic for clipping a video using ffmpeg and saving as a new hosted_file.
|
Business logic for clipping a video using ffmpeg and saving as a new hosted_file.
|
||||||
Returns the new hosted_file dict or False.
|
Returns the new hosted_file dict or False.
|
||||||
"""
|
"""
|
||||||
|
# NOTE: This function is invoked by the hosted_file router at
|
||||||
|
# `/hosted_file/{hosted_file_id}/clip_video` and returns the created
|
||||||
|
# hosted_file metadata (or False) so the router can build the standard
|
||||||
|
# response body consumed by frontends.
|
||||||
hosted_file_obj = load_hosted_file_obj(hosted_file_id=hosted_file_id)
|
hosted_file_obj = load_hosted_file_obj(hosted_file_id=hosted_file_id)
|
||||||
if not hosted_file_obj: return False
|
if not hosted_file_obj: return False
|
||||||
|
|
||||||
file_hash = hosted_file_obj.hash_sha256
|
file_hash = hosted_file_obj.hash_sha256
|
||||||
hosted_files_path = settings.FILES_PATH['hosted_files_root']
|
hosted_files_path = settings.FILES_PATH['hosted_files_root']
|
||||||
full_file_path = os.path.join(hosted_files_path, file_hash[0:2], f'{file_hash}.file')
|
full_file_path = os.path.join(hosted_files_path, file_hash[0:2], f'{file_hash}.file')
|
||||||
|
|
||||||
if not os.path.exists(full_file_path): return False
|
if not os.path.exists(full_file_path): return False
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.mp4') as tmp_video_file_clip:
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.mp4') as tmp_video_file_clip:
|
||||||
tmp_video_file_clip_path = tmp_video_file_clip.name
|
tmp_video_file_clip_path = tmp_video_file_clip.name
|
||||||
|
|
||||||
|
try:
|
||||||
if scale_down:
|
if scale_down:
|
||||||
new_filename = f'{filename_no_ext}_[clip_scaled].{to_type}'
|
new_filename = f'{filename_no_ext}_[clip_scaled].{to_type}'
|
||||||
cmd = f'ffmpeg -hide_banner -loglevel error -nostats -y -i {full_file_path} -ss {start_time} -to {end_time} -vf "scale=w=1920:h=1080:force_original_aspect_ratio=decrease" -c:v libx264 -crf 23 -maxrate 2M -bufsize 2M -c:a copy -movflags +faststart {tmp_video_file_clip_path}'
|
cmd = f'ffmpeg -hide_banner -loglevel error -nostats -y -i {full_file_path} -ss {start_time} -to {end_time} -vf "scale=w=1920:h=1080:force_original_aspect_ratio=decrease" -c:v libx264 -crf 23 -maxrate 2M -bufsize 2M -c:a copy -movflags +faststart {tmp_video_file_clip_path}'
|
||||||
@@ -59,7 +64,8 @@ async def clip_video_method(
|
|||||||
args = shlex.split(cmd)
|
args = shlex.split(cmd)
|
||||||
try:
|
try:
|
||||||
subprocess.run(args, check=True, capture_output=True, text=True)
|
subprocess.run(args, check=True, capture_output=True, text=True)
|
||||||
except subprocess.CalledProcessError:
|
except subprocess.CalledProcessError as e:
|
||||||
|
log.exception(f'ffmpeg failed: returncode={e.returncode}; stdout={e.stdout}; stderr={e.stderr}')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
file_info = await save_file_to_hosted_file(
|
file_info = await save_file_to_hosted_file(
|
||||||
@@ -79,6 +85,12 @@ async def clip_video_method(
|
|||||||
new_obj = Hosted_File_Base(**file_info)
|
new_obj = Hosted_File_Base(**file_info)
|
||||||
if res_id := create_hosted_file_obj(hosted_file_obj_new=new_obj):
|
if res_id := create_hosted_file_obj(hosted_file_obj_new=new_obj):
|
||||||
return load_hosted_file_obj(hosted_file_id=res_id, model_as_dict=True)
|
return load_hosted_file_obj(hosted_file_id=res_id, model_as_dict=True)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if os.path.exists(tmp_video_file_clip_path):
|
||||||
|
os.unlink(tmp_video_file_clip_path)
|
||||||
|
except Exception:
|
||||||
|
log.exception('Failed to remove temporary video clip file')
|
||||||
return False
|
return False
|
||||||
# ### END ### API Hosted File Methods ### clip_video_method() ###
|
# ### END ### API Hosted File Methods ### clip_video_method() ###
|
||||||
|
|
||||||
@@ -94,24 +106,41 @@ async def convert_file_method(
|
|||||||
to_type: str = 'webp',
|
to_type: str = 'webp',
|
||||||
):
|
):
|
||||||
from pdf2image import convert_from_path
|
from pdf2image import convert_from_path
|
||||||
|
|
||||||
|
# NOTE: Invoked by the hosted_file router at
|
||||||
|
# `/hosted_file/{hosted_file_id}/convert_file`. This helper currently
|
||||||
|
# converts the first page of a PDF to an image (webp/png) and saves a
|
||||||
|
# new hosted_file record; it returns that record or False on failure.
|
||||||
|
|
||||||
hosted_file_obj = load_hosted_file_obj(hosted_file_id=hosted_file_id)
|
hosted_file_obj = load_hosted_file_obj(hosted_file_id=hosted_file_id)
|
||||||
if not hosted_file_obj: return False
|
if not hosted_file_obj: return False
|
||||||
|
|
||||||
|
# Ensure input is a PDF (pdf2image is designed for PDFs)
|
||||||
|
if (getattr(hosted_file_obj, 'extension', None) or '').lower() != 'pdf' and (getattr(hosted_file_obj, 'content_type', None) or '') != 'application/pdf':
|
||||||
|
log.warning('convert_file_method called on non-PDF file')
|
||||||
|
return False
|
||||||
|
|
||||||
full_file_path = os.path.join(settings.FILES_PATH['hosted_files_root'], hosted_file_obj.hash_sha256[0:2], f'{hosted_file_obj.hash_sha256}.file')
|
full_file_path = os.path.join(settings.FILES_PATH['hosted_files_root'], hosted_file_obj.hash_sha256[0:2], f'{hosted_file_obj.hash_sha256}.file')
|
||||||
if not os.path.exists(full_file_path): return False
|
if not os.path.exists(full_file_path): return False
|
||||||
|
if not os.path.exists(full_file_path): return False
|
||||||
|
|
||||||
save_path = os.path.join(settings.FILES_PATH['hosted_tmp_root'], 'convert_file', f'conv_{int(time.time())}.{to_type}')
|
save_path = os.path.join(settings.FILES_PATH['hosted_tmp_root'], 'convert_file', f'conv_{int(time.time())}.{to_type}')
|
||||||
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||||||
|
|
||||||
images = convert_from_path(full_file_path, size=(3840, None))
|
try:
|
||||||
image = images[0]
|
images = convert_from_path(full_file_path, size=(3840, None))
|
||||||
|
image = images[0]
|
||||||
if to_type == 'webp':
|
|
||||||
image.save(save_path, lossless=False, quality=90)
|
if to_type == 'webp':
|
||||||
elif to_type == 'png':
|
image.save(save_path, lossless=False, quality=90)
|
||||||
image.save(save_path, compress_level=9)
|
elif to_type == 'png':
|
||||||
else: return False
|
image.save(save_path, compress_level=9)
|
||||||
|
else:
|
||||||
|
log.warning(f'Unsupported target type for convert_file_method: {to_type}')
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
log.exception('Error converting file to image')
|
||||||
|
return False
|
||||||
|
|
||||||
file_info = await save_file_to_hosted_file(
|
file_info = await save_file_to_hosted_file(
|
||||||
file_path = save_path,
|
file_path = save_path,
|
||||||
@@ -129,6 +158,12 @@ async def convert_file_method(
|
|||||||
else:
|
else:
|
||||||
new_obj = Hosted_File_Base(**file_info)
|
new_obj = Hosted_File_Base(**file_info)
|
||||||
if res_id := create_hosted_file_obj(hosted_file_obj_new=new_obj):
|
if res_id := create_hosted_file_obj(hosted_file_obj_new=new_obj):
|
||||||
|
# cleanup tmp file
|
||||||
|
try:
|
||||||
|
if os.path.exists(save_path):
|
||||||
|
os.unlink(save_path)
|
||||||
|
except Exception:
|
||||||
|
log.exception('Failed to remove temporary converted file')
|
||||||
return load_hosted_file_obj(hosted_file_id=res_id, model_as_dict=True)
|
return load_hosted_file_obj(hosted_file_id=res_id, model_as_dict=True)
|
||||||
return False
|
return False
|
||||||
# ### END ### API Hosted File Methods ### convert_file_method() ###
|
# ### END ### API Hosted File Methods ### convert_file_method() ###
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from app.methods.hosted_file_methods import (
|
|||||||
create_hosted_file_link, delete_hosted_file_link, get_hosted_file_link_rec_list
|
create_hosted_file_link, delete_hosted_file_link, get_hosted_file_link_rec_list
|
||||||
)
|
)
|
||||||
from app.methods.lib_media import convert_file_method
|
from app.methods.lib_media import convert_file_method
|
||||||
|
from app.methods.lib_media import clip_video_method
|
||||||
from app.lib_general_v3 import (
|
from app.lib_general_v3 import (
|
||||||
AccountContext, get_account_context, get_account_context_optional,
|
AccountContext, get_account_context, get_account_context_optional,
|
||||||
SerializationParams, DelayParams
|
SerializationParams, DelayParams
|
||||||
@@ -42,7 +43,7 @@ def validate_file_extension(filename: str, allowed_extensions: List[str]):
|
|||||||
"""
|
"""
|
||||||
if not allowed_extensions:
|
if not allowed_extensions:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
ext = filename.rsplit('.', 1)[-1].lower()
|
ext = filename.rsplit('.', 1)[-1].lower()
|
||||||
if ext not in [e.lower().strip('.') for e in allowed_extensions]:
|
if ext not in [e.lower().strip('.') for e in allowed_extensions]:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -85,7 +86,7 @@ async def upload_files_action(
|
|||||||
- Returns clean Vision IDs.
|
- Returns clean Vision IDs.
|
||||||
"""
|
"""
|
||||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||||
|
|
||||||
# 1. Resolve Parent IDs
|
# 1. Resolve Parent IDs
|
||||||
account_id_random = account_id
|
account_id_random = account_id
|
||||||
if res_account_id := redis_lookup_id_random(record_id_random=account_id, table_name='account'):
|
if res_account_id := redis_lookup_id_random(record_id_random=account_id, table_name='account'):
|
||||||
@@ -132,7 +133,7 @@ async def upload_files_action(
|
|||||||
):
|
):
|
||||||
# Use existing record
|
# Use existing record
|
||||||
hosted_file_id_int = existing_rec.get('id')
|
hosted_file_id_int = existing_rec.get('id')
|
||||||
|
|
||||||
# Migration check: Update subdirectory if missing or mismatched
|
# Migration check: Update subdirectory if missing or mismatched
|
||||||
if file_info.get('subdirectory_path') and existing_rec.get('subdirectory_path') != file_info['subdirectory_path']:
|
if file_info.get('subdirectory_path') and existing_rec.get('subdirectory_path') != file_info['subdirectory_path']:
|
||||||
log.info(f"Updating subdirectory_path for existing record {hosted_file_id_int} to {file_info['subdirectory_path']}")
|
log.info(f"Updating subdirectory_path for existing record {hosted_file_id_int} to {file_info['subdirectory_path']}")
|
||||||
@@ -140,7 +141,7 @@ async def upload_files_action(
|
|||||||
table_name = 'hosted_file',
|
table_name = 'hosted_file',
|
||||||
data = {'id': hosted_file_id_int, 'subdirectory_path': file_info['subdirectory_path']}
|
data = {'id': hosted_file_id_int, 'subdirectory_path': file_info['subdirectory_path']}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Reload to get the latest DB state (including updated path)
|
# Reload to get the latest DB state (including updated path)
|
||||||
hosted_file_dict = load_hosted_file_obj(hosted_file_id=hosted_file_id_int, model_as_dict=True)
|
hosted_file_dict = load_hosted_file_obj(hosted_file_id=hosted_file_id_int, model_as_dict=True)
|
||||||
else:
|
else:
|
||||||
@@ -148,7 +149,7 @@ async def upload_files_action(
|
|||||||
file_info['account_id'] = account_id_int
|
file_info['account_id'] = account_id_int
|
||||||
file_info['account_id_random'] = account_id_random
|
file_info['account_id_random'] = account_id_random
|
||||||
new_hosted_file_obj = Hosted_File_Base(**file_info)
|
new_hosted_file_obj = Hosted_File_Base(**file_info)
|
||||||
|
|
||||||
if res_new_id := create_hosted_file_obj(hosted_file_obj_new=new_hosted_file_obj):
|
if res_new_id := create_hosted_file_obj(hosted_file_obj_new=new_hosted_file_obj):
|
||||||
hosted_file_id_int = res_new_id
|
hosted_file_id_int = res_new_id
|
||||||
hosted_file_dict = load_hosted_file_obj(hosted_file_id=hosted_file_id_int, model_as_dict=True)
|
hosted_file_dict = load_hosted_file_obj(hosted_file_id=hosted_file_id_int, model_as_dict=True)
|
||||||
@@ -201,11 +202,11 @@ async def download_file_action(
|
|||||||
|
|
||||||
# 1. Auth Bypass Logic (site_key and simplified key)
|
# 1. Auth Bypass Logic (site_key and simplified key)
|
||||||
is_authorized = False
|
is_authorized = False
|
||||||
|
|
||||||
# Priority A: Standard Auth (JWT or API Key)
|
# Priority A: Standard Auth (JWT or API Key)
|
||||||
if account.auth_method != 'guest':
|
if account.auth_method != 'guest':
|
||||||
is_authorized = True
|
is_authorized = True
|
||||||
|
|
||||||
# Priority B: Simplified Access Pattern (?key=ANY_VALID_ACCOUNT_ID)
|
# Priority B: Simplified Access Pattern (?key=ANY_VALID_ACCOUNT_ID)
|
||||||
elif key:
|
elif key:
|
||||||
# For now, to unblock the frontend, any valid account_id_random is sufficient.
|
# For now, to unblock the frontend, any valid account_id_random is sufficient.
|
||||||
@@ -221,17 +222,17 @@ async def download_file_action(
|
|||||||
if site_res := sql_select(sql=sql, data={'key': site_key}):
|
if site_res := sql_select(sql=sql, data={'key': site_key}):
|
||||||
is_authorized = True
|
is_authorized = True
|
||||||
log.info(f"Auth Bypass: Download authorized via site_key.")
|
log.info(f"Auth Bypass: Download authorized via site_key.")
|
||||||
|
|
||||||
if not is_authorized:
|
if not is_authorized:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Authentication required or invalid access key.")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Authentication required or invalid access key.")
|
||||||
|
|
||||||
# 2. Resolve File Record
|
# 2. Resolve File Record
|
||||||
# ID Vision: Attempt to resolve the ID.
|
# ID Vision: Attempt to resolve the ID.
|
||||||
# 🛑 REMINDER: If adding a new specialized 'container' object (like event_person_profile),
|
# 🛑 REMINDER: If adding a new specialized 'container' object (like event_person_profile),
|
||||||
# ensure the lookup logic is mirrored here to allow direct downloads via container ID.
|
# ensure the lookup logic is mirrored here to allow direct downloads via container ID.
|
||||||
# If not found in hosted_file, check if it's an event_file or archive_content ID that we can resolve.
|
# If not found in hosted_file, check if it's an event_file or archive_content ID that we can resolve.
|
||||||
resolved_id = redis_lookup_id_random(record_id_random=hosted_file_id, table_name='hosted_file')
|
resolved_id = redis_lookup_id_random(record_id_random=hosted_file_id, table_name='hosted_file')
|
||||||
|
|
||||||
if not resolved_id:
|
if not resolved_id:
|
||||||
log.info(f"ID {hosted_file_id} not found in hosted_file. Checking container tables...")
|
log.info(f"ID {hosted_file_id} not found in hosted_file. Checking container tables...")
|
||||||
# A. Check event_file
|
# A. Check event_file
|
||||||
@@ -239,7 +240,7 @@ async def download_file_action(
|
|||||||
if ef_rec := sql_select(sql="SELECT hosted_file_id FROM event_file WHERE id = :id", data={'id': ef_id}):
|
if ef_rec := sql_select(sql="SELECT hosted_file_id FROM event_file WHERE id = :id", data={'id': ef_id}):
|
||||||
resolved_id = ef_rec.get('hosted_file_id')
|
resolved_id = ef_rec.get('hosted_file_id')
|
||||||
log.info(f"Resolved event_file {hosted_file_id} to hosted_file {resolved_id}")
|
log.info(f"Resolved event_file {hosted_file_id} to hosted_file {resolved_id}")
|
||||||
|
|
||||||
# B. Check archive_content
|
# B. Check archive_content
|
||||||
if not resolved_id:
|
if not resolved_id:
|
||||||
if ac_id := redis_lookup_id_random(record_id_random=hosted_file_id, table_name='archive_content'):
|
if ac_id := redis_lookup_id_random(record_id_random=hosted_file_id, table_name='archive_content'):
|
||||||
@@ -305,7 +306,7 @@ async def download_file_action(
|
|||||||
'Content-Disposition': f'attachment; filename="{safe_filename}"; filename*=utf-8\'\'{encoded_filename}'
|
'Content-Disposition': f'attachment; filename="{safe_filename}"; filename*=utf-8\'\'{encoded_filename}'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return FileResponse(full_file_path, filename=target_filename, media_type=media_type)
|
return FileResponse(full_file_path, filename=target_filename, media_type=media_type)
|
||||||
|
|
||||||
|
|
||||||
@@ -329,7 +330,7 @@ async def download_file_by_hash_action(
|
|||||||
# For now, we strictly require a valid machine API key (auth_method will not be 'guest')
|
# For now, we strictly require a valid machine API key (auth_method will not be 'guest')
|
||||||
if account.auth_method == 'guest':
|
if account.auth_method == 'guest':
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Valid API Key required for hash-based downloads."
|
detail="Valid API Key required for hash-based downloads."
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -349,7 +350,7 @@ async def download_file_by_hash_action(
|
|||||||
# 3. Serve File
|
# 3. Serve File
|
||||||
target_filename = filename or f"file_{sha256[:8]}.bin"
|
target_filename = filename or f"file_{sha256[:8]}.bin"
|
||||||
media_type = mimetypes.guess_type(target_filename)[0] or 'application/octet-stream'
|
media_type = mimetypes.guess_type(target_filename)[0] or 'application/octet-stream'
|
||||||
|
|
||||||
return FileResponse(full_file_path, filename=target_filename, media_type=media_type)
|
return FileResponse(full_file_path, filename=target_filename, media_type=media_type)
|
||||||
|
|
||||||
|
|
||||||
@@ -427,7 +428,7 @@ async def delete_file_action(
|
|||||||
|
|
||||||
if rm_orphan and is_orphan:
|
if rm_orphan and is_orphan:
|
||||||
log.info(f"File {file_id_int} is an orphan. Cleaning up...")
|
log.info(f"File {file_id_int} is an orphan. Cleaning up...")
|
||||||
|
|
||||||
# Method Handling
|
# Method Handling
|
||||||
if method == 'delete':
|
if method == 'delete':
|
||||||
# Hard delete: Record + Disk
|
# Hard delete: Record + Disk
|
||||||
@@ -481,3 +482,55 @@ async def convert_file(
|
|||||||
return mk_resp(data=result)
|
return mk_resp(data=result)
|
||||||
return mk_resp(data=None, status_code=400, status_message="Conversion failed.")
|
return mk_resp(data=None, status_code=400, status_message="Conversion failed.")
|
||||||
# ### END ### API V3 Hosted File Action ### convert_file() ###
|
# ### END ### API V3 Hosted File Action ### convert_file() ###
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/{hosted_file_id}/clip_video', response_model=Resp_Body_Base)
|
||||||
|
async def clip_video(
|
||||||
|
hosted_file_id: str = Path(min_length=11, max_length=22),
|
||||||
|
link_to_type: str = Query(...),
|
||||||
|
link_to_id: str = Query(...),
|
||||||
|
start_time: str = Query(..., min_length=8, max_length=8),
|
||||||
|
end_time: str = Query(..., min_length=8, max_length=8),
|
||||||
|
filename_no_ext: str = Query('automated_hosted_file_clip_video'),
|
||||||
|
reencode: bool = Query(False),
|
||||||
|
scale_down: bool = Query(False),
|
||||||
|
background: bool = Query(False),
|
||||||
|
account: AccountContext = Depends(get_account_context),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Clip a segment from a hosted video and save as a new hosted_file record.
|
||||||
|
Supports optional background scheduling returning `202 Accepted` when `background=true`.
|
||||||
|
"""
|
||||||
|
lid_int = redis_lookup_id_random(record_id_random=link_to_id, table_name=link_to_type)
|
||||||
|
if not lid_int:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Linked object not found: {link_to_type}:{link_to_id}")
|
||||||
|
|
||||||
|
async def _run_clip():
|
||||||
|
try:
|
||||||
|
return await clip_video_method(
|
||||||
|
hosted_file_id=hosted_file_id,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
account_id=account.account_id,
|
||||||
|
account_id_random=account.account_id_random,
|
||||||
|
link_to_type=link_to_type,
|
||||||
|
link_to_id=lid_int,
|
||||||
|
filename_no_ext=filename_no_ext,
|
||||||
|
reencode=reencode,
|
||||||
|
scale_down=scale_down,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
log.exception('Background clip task failed')
|
||||||
|
return None
|
||||||
|
|
||||||
|
if background:
|
||||||
|
# Schedule and return 202 Accepted
|
||||||
|
asyncio.create_task(_run_clip())
|
||||||
|
return mk_resp(data={'task': 'scheduled'}, status_code=202, status_message='Clip scheduled (background)')
|
||||||
|
|
||||||
|
result = await _run_clip()
|
||||||
|
if result:
|
||||||
|
return mk_resp(data=result)
|
||||||
|
return mk_resp(data=None, status_code=400, status_message="Clip failed.")
|
||||||
|
# ### END ### API V3 Hosted File Action ### clip_video() ###
|
||||||
|
# ### END ### API V3 Hosted File Action ### convert_file() ###
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import aiofiles, datetime, hashlib, mimetypes, os, pathlib, random, shutil, subprocess, shlex, tempfile, time
|
import aiofiles, datetime, hashlib, mimetypes, os, pathlib, random, shutil, subprocess, shlex, tempfile, time
|
||||||
from fastapi import APIRouter, Body, Depends, File, Form, Header, HTTPException, Path, Query, Response, status, UploadFile
|
from fastapi import APIRouter, Body, Depends, File, Form, Header, HTTPException, Path, Query, Response, status, UploadFile
|
||||||
from fastapi.responses import FileResponse, StreamingResponse
|
from fastapi.responses import FileResponse, StreamingResponse, RedirectResponse
|
||||||
|
from urllib.parse import quote
|
||||||
from pydantic import BaseModel, EmailStr, Field
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
from typing import Dict, List, Optional, Set, Union
|
from typing import Dict, List, Optional, Set, Union
|
||||||
|
|
||||||
@@ -9,9 +10,9 @@ from app.config import settings
|
|||||||
from app.db_sql import sql_insert, sql_update, sql_insert_or_update, sql_select, sql_delete, redis_lookup_id_random
|
from app.db_sql import sql_insert, sql_update, sql_insert_or_update, sql_select, sql_delete, redis_lookup_id_random
|
||||||
|
|
||||||
from app.methods.hosted_file_methods import (
|
from app.methods.hosted_file_methods import (
|
||||||
create_hosted_file_obj, handle_delete_hosted_file, load_hosted_file_obj,
|
create_hosted_file_obj, handle_delete_hosted_file, load_hosted_file_obj,
|
||||||
save_file, save_file_to_hosted_file, create_hosted_file_link,
|
save_file, save_file_to_hosted_file, create_hosted_file_link,
|
||||||
delete_hosted_file_link, get_hosted_file_link_rec_list, lookup_file_hash,
|
delete_hosted_file_link, get_hosted_file_link_rec_list, lookup_file_hash,
|
||||||
check_for_hosted_file_hash_file, directory_check_method
|
check_for_hosted_file_hash_file, directory_check_method
|
||||||
)
|
)
|
||||||
from app.methods.lib_media import clip_video_method, convert_file_method
|
from app.methods.lib_media import clip_video_method, convert_file_method
|
||||||
@@ -35,10 +36,10 @@ async def directory_check(
|
|||||||
"""
|
"""
|
||||||
log.setLevel(logging.INFO)
|
log.setLevel(logging.INFO)
|
||||||
result_list = directory_check_method(rm_orphan=rm_orphan)
|
result_list = directory_check_method(rm_orphan=rm_orphan)
|
||||||
|
|
||||||
if result_list is False:
|
if result_list is False:
|
||||||
return mk_resp(data=False, status_code=500, response=commons.response, status_message='Hosted files directory not found.')
|
return mk_resp(data=False, status_code=500, response=commons.response, status_message='Hosted files directory not found.')
|
||||||
|
|
||||||
return mk_resp(data=result_list, response=commons.response, status_message=f'Processed {len(result_list)} files.')
|
return mk_resp(data=result_list, response=commons.response, status_message=f'Processed {len(result_list)} files.')
|
||||||
# ### END ### API Hosted File ### directory_check() ###
|
# ### END ### API Hosted File ### directory_check() ###
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ async def download_hosted_file(
|
|||||||
commons: Common_Route_Params = Depends(common_route_params),
|
commons: Common_Route_Params = Depends(common_route_params),
|
||||||
):
|
):
|
||||||
log.setLevel(logging.INFO)
|
log.setLevel(logging.INFO)
|
||||||
|
|
||||||
# ID Resolve
|
# ID Resolve
|
||||||
if hfid_int := redis_lookup_id_random(record_id_random=hosted_file_id, table_name='hosted_file'): pass
|
if hfid_int := redis_lookup_id_random(record_id_random=hosted_file_id, table_name='hosted_file'): pass
|
||||||
else: return mk_resp(data=None, status_code=404, response=commons.response, status_message='The hosted_file ID was invalid or not found.')
|
else: return mk_resp(data=None, status_code=404, response=commons.response, status_message='The hosted_file ID was invalid or not found.')
|
||||||
@@ -63,7 +64,7 @@ async def download_hosted_file(
|
|||||||
|
|
||||||
target_filename = filename or hosted_file_obj.filename
|
target_filename = filename or hosted_file_obj.filename
|
||||||
hash_sha256 = hosted_file_obj.hash_sha256
|
hash_sha256 = hosted_file_obj.hash_sha256
|
||||||
|
|
||||||
hosted_files_path = settings.FILES_PATH['hosted_files_root']
|
hosted_files_path = settings.FILES_PATH['hosted_files_root']
|
||||||
subdir = hosted_file_obj.subdirectory_path or ''
|
subdir = hosted_file_obj.subdirectory_path or ''
|
||||||
file_path = os.path.join(hosted_files_path, subdir, f'{hash_sha256}.file')
|
file_path = os.path.join(hosted_files_path, subdir, f'{hash_sha256}.file')
|
||||||
@@ -151,7 +152,7 @@ async def upload_files(
|
|||||||
Legacy Upload Route (V2). Preserved for frontend compatibility.
|
Legacy Upload Route (V2). Preserved for frontend compatibility.
|
||||||
"""
|
"""
|
||||||
log.setLevel(logging.INFO)
|
log.setLevel(logging.INFO)
|
||||||
|
|
||||||
acc_id_rand = account_id
|
acc_id_rand = account_id
|
||||||
if acc_id_int := redis_lookup_id_random(record_id_random=acc_id_rand, table_name='account'): pass
|
if acc_id_int := redis_lookup_id_random(record_id_random=acc_id_rand, table_name='account'): pass
|
||||||
else: return mk_resp(data=None, status_code=400, response=response)
|
else: return mk_resp(data=None, status_code=400, response=response)
|
||||||
@@ -192,7 +193,7 @@ async def upload_files(
|
|||||||
|
|
||||||
# Final metadata and linking
|
# Final metadata and linking
|
||||||
hosted_file_dict.update({'saved': True, 'already_exists': file_info['already_exists'], 'copy_timer': file_info['copy_timer']})
|
hosted_file_dict.update({'saved': True, 'already_exists': file_info['already_exists'], 'copy_timer': file_info['copy_timer']})
|
||||||
|
|
||||||
if link_to_type not in ['event', 'event_location', 'event_session', 'event_presentation', 'event_presenter', 'event_badge', 'event_exhibit', 'event_person']:
|
if link_to_type not in ['event', 'event_location', 'event_session', 'event_presentation', 'event_presenter', 'event_badge', 'event_exhibit', 'event_person']:
|
||||||
create_hosted_file_link(account_id=acc_id_int, hosted_file_id=hfid_int, link_to_type=link_to_type, link_to_id=lid_int)
|
create_hosted_file_link(account_id=acc_id_int, hosted_file_id=hfid_int, link_to_type=link_to_type, link_to_id=lid_int)
|
||||||
|
|
||||||
@@ -301,21 +302,23 @@ async def clip_video(
|
|||||||
"""
|
"""
|
||||||
Modularized video clipping route.
|
Modularized video clipping route.
|
||||||
"""
|
"""
|
||||||
if lid_int := redis_lookup_id_random(record_id_random=link_to_id, table_name=link_to_type):
|
# Deprecated legacy route — redirect clients to the V3 action endpoint
|
||||||
result = await clip_video_method(
|
# New V3 action: /v3/action/hosted_file/{hosted_file_id}/clip_video
|
||||||
hosted_file_id = hosted_file_id,
|
params = [
|
||||||
start_time = start_time,
|
f"link_to_type={quote(link_to_type)}",
|
||||||
end_time = end_time,
|
f"link_to_id={quote(link_to_id)}",
|
||||||
account_id = commons.x_account_id,
|
f"start_time={quote(start_time)}",
|
||||||
account_id_random = commons.x_account_id_random,
|
f"end_time={quote(end_time)}",
|
||||||
link_to_type = link_to_type,
|
]
|
||||||
link_to_id = lid_int,
|
if filename_no_ext:
|
||||||
filename_no_ext = filename_no_ext,
|
params.append(f"filename_no_ext={quote(filename_no_ext)}")
|
||||||
reencode = reencode,
|
if reencode:
|
||||||
scale_down = scale_down
|
params.append(f"reencode=true")
|
||||||
)
|
if scale_down:
|
||||||
if result: return mk_resp(data=result, response=commons.response)
|
params.append(f"scale_down=true")
|
||||||
return mk_resp(data=None, status_code=400, response=commons.response)
|
|
||||||
|
target = f"/v3/action/hosted_file/{hosted_file_id}/clip_video?" + "&".join(params)
|
||||||
|
return RedirectResponse(url=target, status_code=307)
|
||||||
# ### END ### API Hosted File ### clip_video() ###
|
# ### END ### API Hosted File ### clip_video() ###
|
||||||
|
|
||||||
|
|
||||||
@@ -332,7 +335,7 @@ async def create_video(
|
|||||||
# Specialized utility route kept inline for now.
|
# Specialized utility route kept inline for now.
|
||||||
from wand.image import Image
|
from wand.image import Image
|
||||||
from wand.drawing import Drawing
|
from wand.drawing import Drawing
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.mp3') as audio_file:
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.mp3') as audio_file:
|
||||||
audio_file.write(await file.read())
|
audio_file.write(await file.read())
|
||||||
audio_file_path = audio_file.name
|
audio_file_path = audio_file.name
|
||||||
@@ -359,5 +362,5 @@ async def create_video(
|
|||||||
video_name = f"{tempfile.gettempdir()}/{title_part_1}.mp4"
|
video_name = f"{tempfile.gettempdir()}/{title_part_1}.mp4"
|
||||||
cmd = f"ffmpeg -y -loop 1 -i {title_image_path} -i {audio_file_path} -c:a copy -c:v libx264 -shortest {video_name}"
|
cmd = f"ffmpeg -y -loop 1 -i {title_image_path} -i {audio_file_path} -c:a copy -c:v libx264 -shortest {video_name}"
|
||||||
subprocess.run(shlex.split(cmd), check=True)
|
subprocess.run(shlex.split(cmd), check=True)
|
||||||
|
|
||||||
return FileResponse(video_name, media_type='video/mp4', filename=f'{title_part_1}.mp4')
|
return FileResponse(video_name, media_type='video/mp4', filename=f'{title_part_1}.mp4')
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ The primary way to retrieve data.
|
|||||||
|
|
||||||
### C. POST Create / PATCH Update
|
### C. POST Create / PATCH Update
|
||||||
Modify data in the system.
|
Modify data in the system.
|
||||||
* **Endpoints:**
|
* **Endpoints:**
|
||||||
* `POST /v3/crud/{obj_type}/`
|
* `POST /v3/crud/{obj_type}/`
|
||||||
* `PATCH /v3/crud/{obj_type}/{id}`
|
* `PATCH /v3/crud/{obj_type}/{id}`
|
||||||
* **Strict Mode (Default):** The API validates your payload against the Pydantic model. If you send fields that do not exist in the model, the database might return a 400 "Unknown column" error.
|
* **Strict Mode (Default):** The API validates your payload against the Pydantic model. If you send fields that do not exist in the model, the database might return a 400 "Unknown column" error.
|
||||||
@@ -123,6 +123,38 @@ Every Event File (`event_file`) **must** have a linked Hosted File (`hosted_file
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 6. Hosted File Actions: Convert & Clip (Frontend Notes)
|
||||||
|
|
||||||
|
These helper endpoints let the frontend request small server-side transformations without uploading new blobs. They return a newly-created `hosted_file` metadata object on success.
|
||||||
|
|
||||||
|
- **Convert (PDF → Image)**
|
||||||
|
- Method: `GET`
|
||||||
|
- Path: `/v3/action/hosted_file/{hosted_file_id}/convert_file`
|
||||||
|
- Required query params: `link_to_type`, `link_to_id`
|
||||||
|
- Optional query params: `filename_no_ext` (defaults to `automated_hosted_file_conversion`), `to_type` (defaults to `webp`)
|
||||||
|
- Auth: standard V3 headers (`x-aether-api-key`, `x-account-id` / `x-no-account-id` / `?jwt=`)
|
||||||
|
- Behavior: converts the first page of a PDF to `webp` or `png`, saves a new `hosted_file`, and returns its metadata. Returns 400 on failure.
|
||||||
|
|
||||||
|
- **Clip Video**
|
||||||
|
- Method: `GET`
|
||||||
|
- Path: `/v3/action/hosted_file/{hosted_file_id}/clip_video`
|
||||||
|
- Required query params: `link_to_type`, `link_to_id`, `start_time`, `end_time` (format `HH:MM:SS`)
|
||||||
|
- Optional query params: `filename_no_ext` (defaults to `automated_hosted_file_clip_video`), `reencode` (bool), `scale_down` (bool)
|
||||||
|
- Auth: standard V3 headers
|
||||||
|
- Behavior: extracts a clip using `ffmpeg` and saves it as a new `hosted_file`. Defaults to stream-copying to be fast; set `reencode=true` to force H.264 or `scale_down=true` to resize. Returns 400 on failure.
|
||||||
|
- Behavior: extracts a clip using `ffmpeg` and saves it as a new `hosted_file`.
|
||||||
|
- Defaults to stream-copying to be fast; set `reencode=true` to force H.264 or `scale_down=true` to resize.
|
||||||
|
- For longer-running clips you can schedule the job in the background by adding `?background=true`. When scheduled the API returns `202 Accepted` and the clip runs asynchronously on the server; check the returned `hosted_file` record later via the standard V3 `hosted_file` endpoints.
|
||||||
|
- Returns 400 on synchronous failure; returns 202 when scheduled successfully.
|
||||||
|
|
||||||
|
Frontend guidance:
|
||||||
|
|
||||||
|
- Call these routes with the same `link_to_type` / `link_to_id` you plan to associate the resulting hosted_file with — the server resolves random IDs for you.
|
||||||
|
- After a successful response, use the V3 `hosted_file` action endpoints (download/delete) to manage or retrieve the new file.
|
||||||
|
- These endpoints run synchronously and can take time for large inputs; for heavy or batch workloads use a queued job pattern instead.
|
||||||
|
- These endpoints may take time for large inputs. Prefer using `?background=true` to schedule work and receive a `202 Accepted` response for async processing. For heavy or batch workloads use a queued job pattern instead.
|
||||||
|
|
||||||
|
|
||||||
## 5. Troubleshooting 403 Forbidden
|
## 5. Troubleshooting 403 Forbidden
|
||||||
|
|
||||||
If you receive a 403 on a valid ID:
|
If you receive a 403 on a valid ID:
|
||||||
@@ -7,8 +7,8 @@ API_KEY = "PMM4n50teUCaOMMTN8qOJA"
|
|||||||
ACCOUNT_ID = "Q8lR8Ai8hx2FjbQ3C_EH1Q" # OSIT
|
ACCOUNT_ID = "Q8lR8Ai8hx2FjbQ3C_EH1Q" # OSIT
|
||||||
|
|
||||||
# IDs for testing ID Vision resolution
|
# IDs for testing ID Vision resolution
|
||||||
HOSTED_FILE_ID = "PUwhbT2tMgU"
|
HOSTED_FILE_ID = "PUwhbT2tMgU"
|
||||||
EVENT_FILE_ID = "cFyCd7FPZe9"
|
EVENT_FILE_ID = "2e_MFBgY5eg"
|
||||||
ARCHIVE_CONTENT_ID = "UjKzrk-GKu5"
|
ARCHIVE_CONTENT_ID = "UjKzrk-GKu5"
|
||||||
|
|
||||||
# Hash for testing content-addressable storage
|
# Hash for testing content-addressable storage
|
||||||
@@ -18,7 +18,7 @@ def test_download_by_id(label, test_id):
|
|||||||
print(f"--- Testing Download via {label}: {test_id} ---")
|
print(f"--- Testing Download via {label}: {test_id} ---")
|
||||||
url = f"{BASE_URL}/{test_id}/download"
|
url = f"{BASE_URL}/{test_id}/download"
|
||||||
headers = {"X-Aether-API-Key": API_KEY, "x-account-id": ACCOUNT_ID}
|
headers = {"X-Aether-API-Key": API_KEY, "x-account-id": ACCOUNT_ID}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(url, headers=headers, stream=True)
|
response = requests.get(url, headers=headers, stream=True)
|
||||||
print(f"Status: {response.status_code}")
|
print(f"Status: {response.status_code}")
|
||||||
@@ -35,7 +35,7 @@ def test_download_by_id(label, test_id):
|
|||||||
def test_download_by_hash_query():
|
def test_download_by_hash_query():
|
||||||
print(f"\n--- Testing Hash Download via Query Param API Key ---")
|
print(f"\n--- Testing Hash Download via Query Param API Key ---")
|
||||||
url = f"{BASE_URL}/hash/{FILE_HASH}/download?api_key={API_KEY}"
|
url = f"{BASE_URL}/hash/{FILE_HASH}/download?api_key={API_KEY}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(url)
|
response = requests.get(url)
|
||||||
print(f"Status: {response.status_code}")
|
print(f"Status: {response.status_code}")
|
||||||
@@ -57,7 +57,7 @@ def test_download_streaming():
|
|||||||
"x-account-id": ACCOUNT_ID,
|
"x-account-id": ACCOUNT_ID,
|
||||||
"Range": "bytes=0-10"
|
"Range": "bytes=0-10"
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(url, headers=headers)
|
response = requests.get(url, headers=headers)
|
||||||
print(f"Status: {response.status_code} (Expected 206)")
|
print(f"Status: {response.status_code} (Expected 206)")
|
||||||
@@ -79,7 +79,7 @@ if __name__ == "__main__":
|
|||||||
test_download_by_hash_query(),
|
test_download_by_hash_query(),
|
||||||
test_download_streaming()
|
test_download_streaming()
|
||||||
]
|
]
|
||||||
|
|
||||||
if all(results):
|
if all(results):
|
||||||
print("\n🎉 ALL DOWNLOAD PATTERNS VERIFIED!")
|
print("\n🎉 ALL DOWNLOAD PATTERNS VERIFIED!")
|
||||||
else:
|
else:
|
||||||
|
|||||||
97
tests/unit/test_unit_media_methods.py
Normal file
97
tests/unit/test_unit_media_methods.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
# Add project root to path
|
||||||
|
sys.path.append(os.getcwd())
|
||||||
|
|
||||||
|
# Mock configuration and low-level deps BEFORE importing the target module
|
||||||
|
mock_config = MagicMock()
|
||||||
|
mock_settings = MagicMock()
|
||||||
|
mock_settings.FILES_PATH = {
|
||||||
|
'hosted_files_root': '/tmp',
|
||||||
|
'hosted_tmp_root': '/tmp'
|
||||||
|
}
|
||||||
|
mock_config.settings = mock_settings
|
||||||
|
sys.modules['app.config'] = mock_config
|
||||||
|
sys.modules['app.lib_general'] = MagicMock()
|
||||||
|
sys.modules['app.db_sql'] = MagicMock()
|
||||||
|
|
||||||
|
from app.methods import lib_media
|
||||||
|
|
||||||
|
|
||||||
|
def test_clip_video_method():
|
||||||
|
print('--- test_clip_video_method ---')
|
||||||
|
|
||||||
|
hosted_obj = SimpleNamespace(hash_sha256='aa11bb22cc33ddeeff00112233445566778899aa', extension='mp4')
|
||||||
|
|
||||||
|
# Prepare mocked behaviors
|
||||||
|
async def _async_save(*args, **kwargs):
|
||||||
|
return {'saved': True, 'hash_sha256': 'newhash'}
|
||||||
|
|
||||||
|
with patch.object(lib_media, 'load_hosted_file_obj', side_effect=[hosted_obj, {'id': 'EXIST_ID', 'hosted_file_id': 'EXIST_ID', 'filename': 'clip.mp4'}]) as mock_load, \
|
||||||
|
patch.object(lib_media, 'save_file_to_hosted_file', new=_async_save) as mock_save, \
|
||||||
|
patch('subprocess.run', return_value=None) as mock_run, \
|
||||||
|
patch('os.path.exists', return_value=True) as mock_exists, \
|
||||||
|
patch.object(lib_media, 'sql_select', return_value={'id': 'EXIST_ID'}) as mock_sql:
|
||||||
|
|
||||||
|
res = asyncio.run(lib_media.clip_video_method(
|
||||||
|
hosted_file_id='SOME_ID',
|
||||||
|
start_time='00:00:01',
|
||||||
|
end_time='00:00:05',
|
||||||
|
account_id=1,
|
||||||
|
link_to_type='archive_content',
|
||||||
|
link_to_id=1,
|
||||||
|
))
|
||||||
|
|
||||||
|
print('Result:', res)
|
||||||
|
assert isinstance(res, dict)
|
||||||
|
assert res.get('id') == 'EXIST_ID'
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_file_method():
|
||||||
|
print('--- test_convert_file_method ---')
|
||||||
|
|
||||||
|
hosted_obj = SimpleNamespace(hash_sha256='ff22ee33dd44cc55bb66aa77cc88dd99ee001122', extension='pdf', content_type='application/pdf')
|
||||||
|
|
||||||
|
class DummyImage:
|
||||||
|
def save(self, path, **kwargs):
|
||||||
|
with open(path, 'wb') as f:
|
||||||
|
f.write(b'PNGDATA')
|
||||||
|
|
||||||
|
async def _async_save2(*args, **kwargs):
|
||||||
|
return {'saved': True, 'hash_sha256': 'newhash2'}
|
||||||
|
|
||||||
|
# Provide a dummy pdf2image module to satisfy the runtime import inside the function
|
||||||
|
sys.modules['pdf2image'] = MagicMock()
|
||||||
|
sys.modules['pdf2image'].convert_from_path = lambda *a, **k: [DummyImage()]
|
||||||
|
|
||||||
|
with patch.object(lib_media, 'load_hosted_file_obj', side_effect=[hosted_obj, {'id': 'EXIST_ID2', 'hosted_file_id': 'EXIST_ID2', 'filename': 'conv.webp'}]) as mock_load, \
|
||||||
|
patch.object(lib_media, 'save_file_to_hosted_file', new=_async_save2) as mock_save, \
|
||||||
|
patch('os.path.exists', return_value=True) as mock_exists, \
|
||||||
|
patch.object(lib_media, 'sql_select', return_value={'id': 'EXIST_ID2'}) as mock_sql, \
|
||||||
|
patch.object(lib_media, 'create_hosted_file_obj', return_value='NEW_ID') as mock_create:
|
||||||
|
|
||||||
|
res = asyncio.run(lib_media.convert_file_method(
|
||||||
|
hosted_file_id='SOME_ID',
|
||||||
|
link_to_type='archive_content',
|
||||||
|
link_to_id=1,
|
||||||
|
account_id=1,
|
||||||
|
to_type='webp'
|
||||||
|
))
|
||||||
|
|
||||||
|
print('Result:', res)
|
||||||
|
assert isinstance(res, dict)
|
||||||
|
assert res.get('id') == 'EXIST_ID2'
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
test_clip_video_method()
|
||||||
|
test_convert_file_method()
|
||||||
|
print('\n🎉 MEDIA METHOD TESTS PASSED')
|
||||||
|
except AssertionError as e:
|
||||||
|
print('\n❌ TEST FAILED:', e)
|
||||||
|
raise
|
||||||
Reference in New Issue
Block a user