367 lines
16 KiB
Python
367 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, RedirectResponse
|
|
from urllib.parse import quote
|
|
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.
|
|
"""
|
|
# Deprecated legacy route — redirect clients to the V3 action endpoint
|
|
# New V3 action: /v3/action/hosted_file/{hosted_file_id}/clip_video
|
|
params = [
|
|
f"link_to_type={quote(link_to_type)}",
|
|
f"link_to_id={quote(link_to_id)}",
|
|
f"start_time={quote(start_time)}",
|
|
f"end_time={quote(end_time)}",
|
|
]
|
|
if filename_no_ext:
|
|
params.append(f"filename_no_ext={quote(filename_no_ext)}")
|
|
if reencode:
|
|
params.append(f"reencode=true")
|
|
if scale_down:
|
|
params.append(f"scale_down=true")
|
|
|
|
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() ###
|
|
|
|
|
|
@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')
|