- Adds fields_to_exclude_from_db to Archive_Content_Base to prevent SQL errors on non-existent columns. - Updates documentation for V3 Create/Update patterns and the x-ae-ignore-extra-fields header. - Propagates account_id_random to hosted file and media processing methods.
364 lines
16 KiB
Python
364 lines
16 KiB
Python
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.responses import FileResponse, StreamingResponse
|
|
from pydantic import BaseModel, EmailStr, Field
|
|
from typing import Dict, List, Optional, Set, Union
|
|
|
|
from app.lib_general import log, logging, common_route_params, Common_Route_Params, common_route_params_min, Common_Route_Params_Min
|
|
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.methods.hosted_file_methods import (
|
|
create_hosted_file_obj, handle_delete_hosted_file, load_hosted_file_obj,
|
|
save_file, save_file_to_hosted_file, create_hosted_file_link,
|
|
delete_hosted_file_link, get_hosted_file_link_rec_list, lookup_file_hash,
|
|
check_for_hosted_file_hash_file, directory_check_method
|
|
)
|
|
from app.methods.lib_media import clip_video_method, convert_file_method
|
|
|
|
from app.models.hosted_file_models import Hosted_File_Base
|
|
from app.models.response_models import Resp_Body_Base, mk_resp
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ### BEGIN ### API Hosted File ### directory_check() ###
|
|
# Updated 2026-02-03 (Modularized)
|
|
@router.get('/directory_check', response_model=Resp_Body_Base)
|
|
async def directory_check(
|
|
rm_orphan: bool = False,
|
|
commons: Common_Route_Params = Depends(common_route_params),
|
|
):
|
|
"""
|
|
Scans hosted_files root and migrates legacy files to 2-char subdirectories.
|
|
"""
|
|
log.setLevel(logging.INFO)
|
|
result_list = directory_check_method(rm_orphan=rm_orphan)
|
|
|
|
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=result_list, response=commons.response, status_message=f'Processed {len(result_list)} files.')
|
|
# ### END ### API Hosted File ### directory_check() ###
|
|
|
|
|
|
# ### BEGIN ### API Hosted File ### download_hosted_file() ###
|
|
# Updated 2022-08-08
|
|
@router.get('/{hosted_file_id}/download', response_model=Resp_Body_Base)
|
|
async def download_hosted_file(
|
|
hosted_file_id: str = Path(min_length=11, max_length=22),
|
|
filename: str = Query(None, min_length=4, max_length=255),
|
|
commons: Common_Route_Params = Depends(common_route_params),
|
|
):
|
|
log.setLevel(logging.INFO)
|
|
|
|
# ID Resolve
|
|
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.')
|
|
|
|
hosted_file_obj = load_hosted_file_obj(hosted_file_id=hfid_int)
|
|
if not hosted_file_obj:
|
|
return mk_resp(data=False, status_code=400, response=commons.response)
|
|
|
|
target_filename = filename or hosted_file_obj.filename
|
|
hash_sha256 = hosted_file_obj.hash_sha256
|
|
|
|
hosted_files_path = settings.FILES_PATH['hosted_files_root']
|
|
subdir = hosted_file_obj.subdirectory_path or ''
|
|
file_path = os.path.join(hosted_files_path, subdir, f'{hash_sha256}.file')
|
|
|
|
if os.path.exists(file_path):
|
|
return FileResponse(file_path, filename=target_filename)
|
|
else:
|
|
log.error(f'Physical file missing: {file_path}')
|
|
return mk_resp(data=None, status_code=404, response=commons.response, status_message='Physical file not found on server.')
|
|
# ### END ### API Hosted File ### download_hosted_file() ###
|
|
|
|
|
|
# ### BEGIN ### API Hosted File ### file_streamer() ###
|
|
async def file_streamer(path: str, start: int, end: int):
|
|
chunk_size = 8192
|
|
async with aiofiles.open(path, mode='rb') as f:
|
|
await f.seek(start)
|
|
while True:
|
|
chunk_start = await f.tell()
|
|
if chunk_start >= end: break
|
|
bytes_to_read = min(chunk_size, end - chunk_start)
|
|
data = await f.read(bytes_to_read)
|
|
if not data: break
|
|
yield data
|
|
# ### END ### API Hosted File ### file_streamer() ###
|
|
|
|
|
|
# ### BEGIN ### API Hosted File ### stream_hosted_file() ###
|
|
@router.get('/{hosted_file_id}/stream')
|
|
async def stream_hosted_file(
|
|
hosted_file_id: str = Path(min_length=11, max_length=22),
|
|
filename: str = Query(None, min_length=4, max_length=255),
|
|
range: str = Header(),
|
|
commons: Common_Route_Params = Depends(common_route_params),
|
|
):
|
|
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)
|
|
|
|
hosted_file_obj = load_hosted_file_obj(hosted_file_id=hfid_int)
|
|
if not hosted_file_obj: return mk_resp(data=False, status_code=400, response=commons.response)
|
|
|
|
file_path = os.path.join(settings.FILES_PATH['hosted_files_root'], hosted_file_obj.subdirectory_path or '', f'{hosted_file_obj.hash_sha256}.file')
|
|
|
|
if os.path.exists(file_path):
|
|
file_size = os.stat(file_path).st_size
|
|
range_parts = range.replace('bytes=', '').split('-')
|
|
start = int(range_parts[0])
|
|
end = int(range_parts[1]) if len(range_parts) > 1 and range_parts[1] else file_size - 1
|
|
|
|
if start >= file_size:
|
|
raise HTTPException(status_code=status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE)
|
|
|
|
end = min(end, file_size - 1)
|
|
content_length = end - start + 1
|
|
|
|
return StreamingResponse(
|
|
file_streamer(file_path, start, end + 1),
|
|
media_type = mimetypes.guess_type(filename or hosted_file_obj.filename)[0],
|
|
status_code = status.HTTP_206_PARTIAL_CONTENT,
|
|
headers = {
|
|
'Accept-Ranges': 'bytes',
|
|
'Content-Range': f'bytes {start}-{end}/{file_size}',
|
|
'Content-Length': str(content_length),
|
|
}
|
|
)
|
|
return mk_resp(data=None, status_code=404, response=commons.response)
|
|
# ### END ### API Hosted File ### stream_hosted_file() ###
|
|
|
|
|
|
# ### BEGIN ### API Hosted File Route ### upload_files() ###
|
|
@router.post('/upload_files')
|
|
async def upload_files(
|
|
file_list: List[UploadFile] = File(...),
|
|
account_id: str = Form(..., min_length=1, max_length=22),
|
|
link_to_type: str = Form(...),
|
|
link_to_id: str = Form(..., min_length=1, max_length=22),
|
|
check_allowed_extension: bool = False,
|
|
x_account_id: str = Header(..., ),
|
|
return_obj: bool = True,
|
|
by_alias: bool = True,
|
|
exclude_unset: bool = True,
|
|
response: Response = Response,
|
|
):
|
|
"""
|
|
Legacy Upload Route (V2). Preserved for frontend compatibility.
|
|
"""
|
|
log.setLevel(logging.INFO)
|
|
|
|
acc_id_rand = account_id
|
|
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)
|
|
|
|
lid_rand = link_to_id
|
|
if lid_int := redis_lookup_id_random(record_id_random=lid_rand, table_name=link_to_type): pass
|
|
else: return mk_resp(data=None, status_code=400, response=response)
|
|
|
|
hosted_file_list = []
|
|
for file_obj in file_list:
|
|
file_info = await save_file(
|
|
file = file_obj,
|
|
account_id = acc_id_int,
|
|
account_id_random = acc_id_rand,
|
|
link_to_type = link_to_type,
|
|
link_to_id = lid_int,
|
|
link_to_id_random = lid_rand,
|
|
check_allowed_extension = check_allowed_extension,
|
|
)
|
|
|
|
if not file_info['saved']: continue
|
|
|
|
# Deduplication & DB Sync
|
|
if hosted_file_sel := sql_select(table_name='hosted_file', field_name='hash_sha256', field_value=file_info['hash_sha256']):
|
|
hfid_int = hosted_file_sel.get('id')
|
|
# Migration check
|
|
if not hosted_file_sel.get('subdirectory_path') and file_info.get('subdirectory_path'):
|
|
sql_update(table_name='hosted_file', data={'id': hfid_int, 'subdirectory_path': file_info['subdirectory_path']})
|
|
hosted_file_dict = load_hosted_file_obj(hosted_file_id=hfid_int, model_as_dict=True)
|
|
else:
|
|
file_info['account_id'] = acc_id_int
|
|
file_info['account_id_random'] = acc_id_rand
|
|
new_obj = Hosted_File_Base(**file_info)
|
|
if res_id := create_hosted_file_obj(hosted_file_obj_new=new_obj):
|
|
hosted_file_dict = load_hosted_file_obj(hosted_file_id=res_id, model_as_dict=True)
|
|
hfid_int = res_id
|
|
else: continue
|
|
|
|
# Final metadata and linking
|
|
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']:
|
|
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)
|
|
|
|
hosted_file_list.append(hosted_file_dict)
|
|
|
|
return mk_resp(data=hosted_file_list, response=response)
|
|
# ### END ### API Hosted File Route ### upload_files() ###
|
|
|
|
|
|
@router.delete('/{hosted_file_id}', response_model=Resp_Body_Base)
|
|
async def delete_hosted_file(
|
|
hosted_file_id: str = Path(min_length=11, max_length=22),
|
|
link_to_type: str = None,
|
|
link_to_id: Union[int, str] = None,
|
|
rm_orphan: bool = False,
|
|
commons: Common_Route_Params = Depends(common_route_params),
|
|
):
|
|
if hfid_int := redis_lookup_id_random(record_id_random=hosted_file_id, table_name='hosted_file'):
|
|
if handle_delete_hosted_file(account_id=commons.x_account_id, hosted_file_id=hfid_int, link_to_type=link_to_type, link_to_id=link_to_id, rm_orphan=rm_orphan):
|
|
return mk_resp(data=True, response=commons.response)
|
|
return mk_resp(data=None, status_code=404, response=commons.response)
|
|
|
|
|
|
@router.get('/{hosted_file_id}', response_model=Resp_Body_Base)
|
|
async def get_hosted_file_obj_route(
|
|
hosted_file_id: str = Path(min_length=11, max_length=22),
|
|
enabled: str = 'enabled',
|
|
x_account_id: str = Header(...),
|
|
response: Response = Response,
|
|
):
|
|
if hfid_int := redis_lookup_id_random(record_id_random=hosted_file_id, table_name='hosted_file'):
|
|
if obj := load_hosted_file_obj(hosted_file_id=hfid_int, enabled=enabled, model_as_dict=True):
|
|
return mk_resp(data=obj, response=response)
|
|
return mk_resp(data=None, status_code=404, response=response)
|
|
|
|
|
|
@router.get('/hash/{hosted_file_hash}', response_model=Resp_Body_Base)
|
|
async def check_hosted_file_obj_w_hash(
|
|
hosted_file_hash: str = Path(min_length=64, max_length=64),
|
|
check_for_local: Optional[bool] = True,
|
|
commons: Common_Route_Params = Depends(common_route_params),
|
|
):
|
|
if hfid := lookup_file_hash(file_hash=hosted_file_hash):
|
|
obj = load_hosted_file_obj(hosted_file_id=hfid, model_as_dict=True)
|
|
if check_for_local and obj:
|
|
if check := check_for_hosted_file_hash_file(file_hash=hosted_file_hash, sub_dir=obj.get('subdirectory_path', '')):
|
|
obj['hosted_file_found_check'] = True
|
|
obj['hosted_file_size_check'] = check['file_size']
|
|
return mk_resp(data=obj, response=commons.response)
|
|
return mk_resp(data=False, status_code=404, response=commons.response)
|
|
|
|
|
|
@router.get('/tmp/{subdirectory}/{filename}/download', response_model=Resp_Body_Base)
|
|
async def download_tmp(
|
|
subdirectory: str = Path(min_length=1, max_length=100),
|
|
filename: str = Path(min_length=4, max_length=255),
|
|
commons: Common_Route_Params = Depends(common_route_params),
|
|
):
|
|
path = os.path.join(settings.FILES_PATH['hosted_tmp_root'], subdirectory, filename)
|
|
if os.path.exists(path):
|
|
return FileResponse(path, filename=filename)
|
|
return mk_resp(data=False, status_code=404, response=commons.response)
|
|
|
|
|
|
# ### BEGIN ### API Hosted File Route ### convert_file() ###
|
|
@router.get('/{hosted_file_id}/convert_file')
|
|
async def convert_file(
|
|
hosted_file_id: str = Path(min_length=11, max_length=22),
|
|
link_to_type: str = Query(...),
|
|
link_to_id: str = Query(...),
|
|
filename_no_ext: str = Query('automated_hosted_file_conversion'),
|
|
to_type: str = 'webp',
|
|
commons: Common_Route_Params = Depends(common_route_params),
|
|
):
|
|
"""
|
|
Modularized file conversion route.
|
|
"""
|
|
if lid_int := redis_lookup_id_random(record_id_random=link_to_id, table_name=link_to_type):
|
|
result = await convert_file_method(
|
|
hosted_file_id = hosted_file_id,
|
|
link_to_type = link_to_type,
|
|
link_to_id = lid_int,
|
|
account_id = commons.x_account_id,
|
|
account_id_random = commons.x_account_id_random,
|
|
filename_no_ext = filename_no_ext,
|
|
to_type = to_type
|
|
)
|
|
if result: return mk_resp(data=result, response=commons.response)
|
|
return mk_resp(data=None, status_code=400, response=commons.response)
|
|
# ### END ### API Hosted File Route ### convert_file() ###
|
|
|
|
|
|
# ### BEGIN ### API Hosted File ### clip_video() ###
|
|
@router.get('/{hosted_file_id}/clip_video')
|
|
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),
|
|
commons: Common_Route_Params = Depends(common_route_params),
|
|
):
|
|
"""
|
|
Modularized video clipping route.
|
|
"""
|
|
if lid_int := redis_lookup_id_random(record_id_random=link_to_id, table_name=link_to_type):
|
|
result = await clip_video_method(
|
|
hosted_file_id = hosted_file_id,
|
|
start_time = start_time,
|
|
end_time = end_time,
|
|
account_id = commons.x_account_id,
|
|
account_id_random = commons.x_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
|
|
)
|
|
if result: return mk_resp(data=result, response=commons.response)
|
|
return mk_resp(data=None, status_code=400, response=commons.response)
|
|
# ### END ### API Hosted File ### clip_video() ###
|
|
|
|
|
|
@router.post('/create_video')
|
|
async def create_video(
|
|
file: UploadFile = File(...),
|
|
title_image: UploadFile = File(None),
|
|
title_part_1: str = Form(...),
|
|
title_part_2: str = Form(...),
|
|
subtitle_part_1: str = Form(...),
|
|
subtitle_part_2: str = Form(...),
|
|
font_color: str = Form('darkblue'),
|
|
):
|
|
# Specialized utility route kept inline for now.
|
|
from wand.image import Image
|
|
from wand.drawing import Drawing
|
|
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.mp3') as audio_file:
|
|
audio_file.write(await file.read())
|
|
audio_file_path = audio_file.name
|
|
|
|
if title_image:
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.png') as ti_opened:
|
|
ti_opened.write(await title_image.read())
|
|
title_image_path = ti_opened.name
|
|
else:
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.png') as ti_opened:
|
|
title_image_path = ti_opened.name
|
|
with Image(width=1280, height=720, background='lightblue') as img:
|
|
img.save(filename=title_image_path)
|
|
|
|
with Image(filename=title_image_path) as img:
|
|
with Drawing() as ctx:
|
|
ctx.font_family = 'DejaVu Sans'
|
|
ctx.font_size = 72
|
|
ctx.fill_color = font_color
|
|
ctx.text(0, 150, title_part_1)
|
|
ctx(img)
|
|
img.save(filename=title_image_path)
|
|
|
|
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}"
|
|
subprocess.run(shlex.split(cmd), check=True)
|
|
|
|
return FileResponse(video_name, media_type='video/mp4', filename=f'{title_part_1}.mp4')
|