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,6 +34,10 @@ 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
|
||||||
|
|
||||||
@@ -46,6 +50,7 @@ async def clip_video_method(
|
|||||||
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() ###
|
||||||
|
|
||||||
@@ -95,23 +107,40 @@ async def convert_file_method(
|
|||||||
):
|
):
|
||||||
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':
|
if to_type == 'webp':
|
||||||
image.save(save_path, lossless=False, quality=90)
|
image.save(save_path, lossless=False, quality=90)
|
||||||
elif to_type == 'png':
|
elif to_type == 'png':
|
||||||
image.save(save_path, compress_level=9)
|
image.save(save_path, compress_level=9)
|
||||||
else: return False
|
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
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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() ###
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -8,7 +8,7 @@ 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
|
||||||
|
|||||||
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