Files
video-gen/video-gen-api/app/services/upload_resource/path_resolver.py
T

219 lines
8.3 KiB
Python

from __future__ import annotations
import os
import re
import uuid
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Iterable
from urllib.parse import urlsplit
from app.config import settings
from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceTypeEnum
COMMON_IMAGE_RE = re.compile(r"^images/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/video_img_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
COMMON_VIDEO_RE = re.compile(r"^videos/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/video_ref_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
COMMON_AUDIO_RE = re.compile(r"^audios/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/audio_ref_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
MODULE_RE = re.compile(r"^(?P<module>hot_opening_replicate|shot_replicate)/(?P<kind>images|videos)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<prefix>video_img|video_ref)_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
SHOT_SEGMENT_RE = re.compile(r"^shot_segments/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<segment_id>[^/]+)\.mp4$")
LEGACY_GEN_RE = re.compile(r"^(?P<kind>images|videos)/gen_(?P<user_id>[^_]+)_(?P<rand>[0-9a-zA-Z]+)\.(?P<ext>[^/]+)$")
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".svg"}
VIDEO_EXTS = {".mp4", ".mov", ".m4v", ".webm", ".avi", ".mkv"}
AUDIO_EXTS = {".mp3", ".wav", ".m4a", ".aac", ".flac"}
@dataclass(slots=True)
class ParsedUploadPath:
storage_path: str
resource_url: str
file_name: str
file_ext: str
file_size_bytes: int
module: str
resource_type: str
user_id: str | None
created_at: datetime | None
source_model: str | None = None
source_id: str | None = None
skip_reason: str | None = None
def upload_root() -> Path:
return Path(settings.UPLOAD_LOCAL_PATH).resolve()
def to_abs_path(path: str | os.PathLike[str]) -> Path:
p = Path(path)
return p if p.is_absolute() else Path.cwd() / p
def normalize_storage_path(path: str | os.PathLike[str]) -> str:
return str(to_abs_path(path).resolve())
def upload_url_to_storage_path(url: str | None) -> str | None:
if not url:
return None
value = str(url).strip()
if not value:
return None
value = urlsplit(value).path or value
if not value.startswith("/uploads/"):
return None
rel = value[len("/uploads/"):].lstrip("/")
if not rel or ".." in Path(rel).parts:
return None
return str((upload_root() / rel).resolve())
def storage_path_to_upload_url(path: str | os.PathLike[str]) -> str:
abs_path = to_abs_path(path).resolve()
rel = abs_path.relative_to(upload_root()).as_posix()
return f"/uploads/{rel}"
def _parse_created_at(parts: dict[str, str], fallback: datetime | None = None) -> datetime | None:
ymd = parts.get("ymd")
hms = parts.get("hms")
try:
if ymd and hms:
return datetime.strptime(f"{ymd}_{hms}", "%Y%m%d_%H%M%S")
return datetime(int(parts["year"]), int(parts["month"]), int(parts["day"]))
except Exception:
return fallback
def _file_size(path: Path) -> int:
try:
return int(path.stat().st_size)
except OSError:
return 0
def _base(path: Path, module: str, resource_type: str, user_id: str | None, created_at: datetime | None) -> ParsedUploadPath:
return ParsedUploadPath(
storage_path=normalize_storage_path(path),
resource_url=storage_path_to_upload_url(path),
file_name=path.name,
file_ext=path.suffix.lower().lstrip("."),
file_size_bytes=_file_size(path),
module=module,
resource_type=resource_type,
user_id=user_id,
created_at=created_at,
)
def parse_upload_path(path: str | os.PathLike[str], *, include_legacy: bool = False) -> ParsedUploadPath | None:
abs_path = to_abs_path(path).resolve()
if not abs_path.is_file():
return None
try:
rel = abs_path.relative_to(upload_root()).as_posix()
except ValueError:
return None
ignored_prefixes = ("home_materials/",)
if rel in {"site_logo.png"} or rel.startswith(ignored_prefixes) or rel.startswith("pdf_"):
return ParsedUploadPath(
storage_path=normalize_storage_path(abs_path),
resource_url="",
file_name=abs_path.name,
file_ext=abs_path.suffix.lower().lstrip("."),
file_size_bytes=_file_size(abs_path),
module=UploadResourceModuleEnum.COMMON.value,
resource_type="unknown",
user_id=None,
created_at=None,
skip_reason="ignored_path",
)
for pattern, rtype in (
(COMMON_IMAGE_RE, UploadResourceTypeEnum.IMAGE.value),
(COMMON_VIDEO_RE, UploadResourceTypeEnum.VIDEO.value),
(COMMON_AUDIO_RE, UploadResourceTypeEnum.AUDIO.value),
):
m = pattern.match(rel)
if m:
d = m.groupdict()
return _base(abs_path, UploadResourceModuleEnum.COMMON.value, rtype, d.get("user_id"), _parse_created_at(d))
m = MODULE_RE.match(rel)
if m:
d = m.groupdict()
kind = d.get("kind")
rtype = UploadResourceTypeEnum.IMAGE.value if kind == "images" else UploadResourceTypeEnum.VIDEO.value
return _base(abs_path, d["module"], rtype, d.get("user_id"), _parse_created_at(d))
m = SHOT_SEGMENT_RE.match(rel)
if m:
d = m.groupdict()
parsed = _base(abs_path, UploadResourceModuleEnum.SHOT_REPLICATE.value, UploadResourceTypeEnum.SHOT_SEGMENT.value, None, _parse_created_at(d))
parsed.source_model = "ShotReplicateSegment"
parsed.source_id = d["segment_id"]
return parsed
if include_legacy:
m = LEGACY_GEN_RE.match(rel)
if m:
d = m.groupdict()
ext = abs_path.suffix.lower()
if ext in IMAGE_EXTS:
rtype = UploadResourceTypeEnum.IMAGE.value
elif ext in VIDEO_EXTS:
rtype = UploadResourceTypeEnum.VIDEO.value
else:
return None
try:
created = datetime.fromtimestamp(abs_path.stat().st_mtime)
except OSError:
created = None
return _base(abs_path, UploadResourceModuleEnum.COMMON.value, rtype, d.get("user_id"), created)
return ParsedUploadPath(
storage_path=normalize_storage_path(abs_path),
resource_url="",
file_name=abs_path.name,
file_ext=abs_path.suffix.lower().lstrip("."),
file_size_bytes=_file_size(abs_path),
module=UploadResourceModuleEnum.COMMON.value,
resource_type="unknown",
user_id=None,
created_at=None,
skip_reason="unmatched_path",
)
def iter_files(root: str | os.PathLike[str]) -> Iterable[Path]:
base = to_abs_path(root).resolve()
if not base.exists():
return []
return (p for p in base.rglob("*") if p.is_file())
def build_upload_destination(*, module: str, resource_type: str, user_id: str, original_filename: str | None, gen_type: str = "video") -> tuple[Path, str, str]:
now = datetime.now()
date_dir = now.strftime("%Y/%m/%d")
ext = Path(original_filename or "").suffix.lower()
if not ext:
ext = ".mp4" if resource_type == UploadResourceTypeEnum.VIDEO.value else ".mp3" if resource_type == UploadResourceTypeEnum.AUDIO.value else ".png"
timestamp = now.strftime("%Y%m%d_%H%M%S")
suffix = uuid.uuid4().hex[:8]
if resource_type == UploadResourceTypeEnum.AUDIO.value:
filename = f"audio_ref_{user_id}_{timestamp}_{suffix}{ext}"
rel_dir = Path("audios") / date_dir
elif resource_type == UploadResourceTypeEnum.VIDEO.value:
filename = f"video_ref_{user_id}_{timestamp}_{suffix}{ext}"
rel_dir = Path("videos") / date_dir if module == UploadResourceModuleEnum.COMMON.value else Path(module) / "videos" / date_dir
else:
filename = f"{gen_type}_img_{user_id}_{timestamp}_{suffix}{ext}"
rel_dir = Path("images") / date_dir if module == UploadResourceModuleEnum.COMMON.value else Path(module) / "images" / date_dir
dir_path = upload_root() / rel_dir
file_path = dir_path / filename
url = f"/uploads/{(rel_dir / filename).as_posix()}"
return file_path, url, filename