feat: local storage logic & endpoints
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
"""LocalFileStorage — stores files on the local filesystem."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from collections.abc import AsyncGenerator, AsyncIterator
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import anyio
|
||||
|
||||
from app.domain.entities.storage import ObjectStat
|
||||
from app.domain.errors import StorageError
|
||||
|
||||
_EXT_CONTENT_TYPE: dict[str, str] = {
|
||||
"mp3": "audio/mpeg",
|
||||
"flac": "audio/flac",
|
||||
"m4a": "audio/mp4",
|
||||
"aac": "audio/aac",
|
||||
"ogg": "audio/ogg",
|
||||
"opus": "audio/ogg",
|
||||
"wav": "audio/wav",
|
||||
"wma": "audio/x-ms-wma",
|
||||
"aiff": "audio/aiff",
|
||||
"aif": "audio/aiff",
|
||||
}
|
||||
|
||||
|
||||
class LocalFileStorage:
|
||||
def __init__(self, media_path: Path) -> None:
|
||||
self._media_path = media_path
|
||||
|
||||
async def save_file(self, key: str, src_path: Path) -> int:
|
||||
dest = self._media_path / key
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
part = dest.with_suffix(dest.suffix + ".part")
|
||||
shutil.copyfile(str(src_path), str(part))
|
||||
os.replace(str(part), str(dest))
|
||||
return dest.stat().st_size
|
||||
|
||||
async def open_range(
|
||||
self, key: str, start: int, end: int | None
|
||||
) -> tuple[AsyncIterator[bytes], int]:
|
||||
path = self._media_path / key
|
||||
if not path.exists():
|
||||
raise StorageError(f"Object not found: {key}")
|
||||
total_size = path.stat().st_size
|
||||
|
||||
_start = start
|
||||
_end = end
|
||||
_total_size = total_size
|
||||
_path = path
|
||||
|
||||
async def _iter() -> AsyncGenerator[bytes]:
|
||||
async with await anyio.open_file(_path, "rb") as f:
|
||||
await f.seek(_start)
|
||||
remaining = (_end - _start + 1) if _end is not None else (_total_size - _start)
|
||||
while remaining > 0:
|
||||
chunk: bytes = await f.read(min(65536, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
remaining -= len(chunk)
|
||||
|
||||
aiter: AsyncIterator[bytes] = _iter()
|
||||
return aiter, total_size
|
||||
|
||||
async def stat(self, key: str) -> ObjectStat:
|
||||
path = self._media_path / key
|
||||
if not path.exists():
|
||||
raise StorageError(f"Object not found: {key}")
|
||||
st = path.stat()
|
||||
ext = path.suffix.lower().lstrip(".")
|
||||
return ObjectStat(size=st.st_size, content_type=_EXT_CONTENT_TYPE.get(ext))
|
||||
|
||||
async def exists(self, key: str) -> bool:
|
||||
return (self._media_path / key).exists()
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
(self._media_path / key).unlink(missing_ok=True)
|
||||
|
||||
def as_local_path(self, key: str) -> AbstractAsyncContextManager[Path]:
|
||||
return self._as_local_path_cm(key)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _as_local_path_cm(self, key: str) -> AsyncGenerator[Path]:
|
||||
yield self._media_path / key
|
||||
Reference in New Issue
Block a user