from __future__ import annotations from dataclasses import dataclass from pathlib import Path from typing import NamedTuple import anyio from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import ( DirectoryConflictError, DirectoryNotFoundError, FileConflictError, FileNotFoundError, PreviewNotSupportedError, RangeNotSatisfiableError, ValidationError, ) from app.core.ids import new_id from app.models.entities import Blob, FileEntry, FileVersion from app.repositories.blob_replica_repository import BlobReplicaRepository from app.repositories.directory_repository import DirectoryRepository from app.repositories.file_repository import FileRepository from app.repositories.preview_artifact_repository import PreviewArtifactRepository from app.services.local_storage_service import LocalStorageService from app.services.storage_service import StorageService @dataclass class CreateFileMetadataInput: directory_id: str name: str mime_type: str | None size_bytes: int content_hash: str storage_layout: str = "single" class FileStreamResult(NamedTuple): file_entry: FileEntry file_path: Path file_size: int start: int end: int class FileService: def __init__(self, session: AsyncSession) -> None: self.session = session self.file_repository = FileRepository(session) self.blob_replica_repository = BlobReplicaRepository(session) self.directory_repository = DirectoryRepository(session) self.preview_artifact_repository = PreviewArtifactRepository(session) self.local_storage_service = LocalStorageService(session) self.storage_service = StorageService(session) async def get_file(self, file_id: str) -> FileEntry: file_entry = await self.file_repository.get_by_id(file_id) if file_entry is None or file_entry.is_deleted or file_entry.deleted_at is not None: raise FileNotFoundError() return file_entry async def get_file_detail(self, file_id: str) -> tuple[FileEntry, list]: file_entry = await self.get_file(file_id) if file_entry.current_version_id is None: return file_entry, [] artifacts = await self.preview_artifact_repository.list_by_file_version(file_entry.current_version_id) return file_entry, artifacts async def list_deleted_files(self) -> list[FileEntry]: return await self.file_repository.list_deleted() async def create_file_metadata(self, payload: CreateFileMetadataInput) -> FileEntry: directory = await self.directory_repository.get_by_id(payload.directory_id) if directory is None or directory.deleted_at is not None: raise DirectoryNotFoundError() cleaned_name = self._validate_file_name(payload.name) await self._ensure_name_available(directory.id, cleaned_name) file_entry_id = new_id("fil") file_version_id = new_id("ver") blob_id = new_id("blb") file_entry = FileEntry( id=file_entry_id, directory_id=directory.id, name=cleaned_name, mime_type=payload.mime_type, size_bytes=payload.size_bytes, current_version_id=file_version_id, is_deleted=False, ) file_version = FileVersion( id=file_version_id, file_entry_id=file_entry_id, content_hash=payload.content_hash, size_bytes=payload.size_bytes, storage_layout=payload.storage_layout, ) blob = Blob( id=blob_id, file_version_id=file_version_id, blob_index=0, kind="file", content_hash=payload.content_hash, size_bytes=payload.size_bytes, logical_offset=0, ) created = await self.file_repository.create_file_graph(file_entry, file_version, blob) await self.session.commit() return created async def rename_file(self, file_id: str, name: str) -> FileEntry: file_entry = await self.get_file(file_id) cleaned_name = self._validate_file_name(name) if file_entry.name == cleaned_name: return file_entry await self._ensure_name_available(file_entry.directory_id, cleaned_name) file_entry.name = cleaned_name saved = await self.file_repository.save(file_entry) await self.session.commit() return saved async def move_file(self, file_id: str, target_parent_id: str) -> FileEntry: file_entry = await self.get_file(file_id) target_parent = await self.directory_repository.get_by_id(target_parent_id) if target_parent is None or target_parent.deleted_at is not None: raise DirectoryNotFoundError() if file_entry.directory_id == target_parent.id: return file_entry await self._ensure_name_available(target_parent.id, file_entry.name) file_entry.directory_id = target_parent.id saved = await self.file_repository.save(file_entry) await self.session.commit() return saved async def delete_file(self, file_id: str) -> FileEntry: file_entry = await self.get_file(file_id) deleted = await self.file_repository.soft_delete(file_entry) await self.session.commit() return deleted async def restore_file(self, file_id: str) -> FileEntry: file_entry = await self.file_repository.get_by_id(file_id) if file_entry is None or not file_entry.is_deleted or file_entry.deleted_at is None: raise FileNotFoundError() directory = await self.directory_repository.get_by_id(file_entry.directory_id) if directory is None or directory.deleted_at is not None: raise DirectoryNotFoundError() conflicting_directory = await self.directory_repository.get_by_parent_and_name(directory.id, file_entry.name) if conflicting_directory is not None: raise DirectoryConflictError("A directory with this name already exists in the target location.") existing_file = await self.file_repository.get_by_directory_and_name(directory.id, file_entry.name) if existing_file is not None: raise FileConflictError("A file with this name already exists in the target location.") restored = await self.file_repository.restore(file_entry) await self.session.commit() return restored async def resolve_download(self, file_id: str) -> tuple[FileEntry, Path]: file_entry = await self.get_file(file_id) blob = await self.file_repository.get_primary_blob_for_file(file_entry.id) if blob is None: raise FileNotFoundError() try: file_path = await self.storage_service.resolve_best_local_path(blob) except Exception as exc: raise FileNotFoundError() from exc if file_path is None: raise FileNotFoundError() return file_entry, file_path async def resolve_preview(self, file_id: str) -> tuple[FileEntry, Path]: file_entry, file_path = await self.resolve_download(file_id) if not self._supports_inline_preview(file_entry.mime_type): raise PreviewNotSupportedError() return file_entry, file_path async def resolve_stream(self, file_id: str, range_header: str | None) -> FileStreamResult: file_entry, file_path = await self.resolve_download(file_id) file_size = await anyio.to_thread.run_sync(lambda: file_path.stat().st_size) start, end = self._parse_range(range_header, file_size) return FileStreamResult( file_entry=file_entry, file_path=file_path, file_size=file_size, start=start, end=end, ) async def _ensure_name_available(self, directory_id: str, name: str) -> None: conflicting_directory = await self.directory_repository.get_by_parent_and_name(directory_id, name) if conflicting_directory is not None: raise DirectoryConflictError("A directory with this name already exists in the target location.") existing_file = await self.file_repository.get_by_directory_and_name(directory_id, name) if existing_file is not None: raise FileConflictError("A file with this name already exists in the target location.") @staticmethod def _validate_file_name(name: str) -> str: cleaned_name = name.strip() if not cleaned_name: raise ValidationError("File name must not be empty.") if "/" in cleaned_name: raise ValidationError("File name must not contain '/'.") return cleaned_name @staticmethod def _parse_range(range_header: str | None, file_size: int) -> tuple[int, int]: if file_size <= 0: return 0, 0 if not range_header: return 0, file_size - 1 if not range_header.startswith("bytes="): raise RangeNotSatisfiableError() value = range_header.removeprefix("bytes=") if "," in value: raise RangeNotSatisfiableError() start_text, _, end_text = value.partition("-") try: if start_text == "": suffix_length = int(end_text) if suffix_length <= 0: raise RangeNotSatisfiableError() start = max(file_size - suffix_length, 0) end = file_size - 1 else: start = int(start_text) end = file_size - 1 if end_text == "" else int(end_text) except ValueError as exc: raise RangeNotSatisfiableError() from exc if start < 0 or end < start or start >= file_size: raise RangeNotSatisfiableError() end = min(end, file_size - 1) return start, end @staticmethod def _supports_inline_preview(mime_type: str | None) -> bool: if mime_type is None: return False return mime_type.startswith("image/") or mime_type == "application/pdf"