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')