From ecc8d690394d2703880740fb1b981a55e372f649 Mon Sep 17 00:00:00 2001 From: xds Date: Tue, 24 Feb 2026 16:42:46 +0300 Subject: [PATCH] inspirations --- adapters/s3_adapter.py | 20 +++- aiws.py | 2 + api/dependency.py | 7 +- api/endpoints/assets_router.py | 68 +++++++++++-- api/endpoints/idea_router.py | 15 ++- api/endpoints/inspiration_router.py | 95 ++++++++++++++++++ api/models/IdeaRequest.py | 2 + api/models/InspirationRequest.py | 29 ++++++ api/service/idea_service.py | 15 ++- api/service/inspiration_service.py | 146 ++++++++++++++++++++++++++++ models/Asset.py | 2 + models/Idea.py | 1 + models/Inspiration.py | 16 +++ repos/dao.py | 2 + repos/inspiration_repo.py | 54 ++++++++++ requirements.txt | 1 + 16 files changed, 458 insertions(+), 17 deletions(-) create mode 100644 api/endpoints/inspiration_router.py create mode 100644 api/models/InspirationRequest.py create mode 100644 api/service/inspiration_service.py create mode 100644 models/Inspiration.py create mode 100644 repos/inspiration_repo.py diff --git a/adapters/s3_adapter.py b/adapters/s3_adapter.py index be99ce5..62bac44 100644 --- a/adapters/s3_adapter.py +++ b/adapters/s3_adapter.py @@ -1,5 +1,5 @@ from contextlib import asynccontextmanager -from typing import Optional, BinaryIO +from typing import Optional, BinaryIO, AsyncGenerator import aioboto3 from botocore.exceptions import ClientError import os @@ -56,11 +56,25 @@ class S3Adapter: print(f"Error downloading from S3: {e}") return None - async def stream_file(self, object_name: str, chunk_size: int = 65536): + async def get_file_size(self, object_name: str) -> Optional[int]: + """Returns the size of the file in bytes.""" + try: + async with self._get_client() as client: + response = await client.head_object(Bucket=self.bucket_name, Key=object_name) + return response['ContentLength'] + except ClientError as e: + print(f"Error getting file size from S3: {e}") + return None + + async def stream_file(self, object_name: str, range_header: Optional[str] = None, chunk_size: int = 65536) -> AsyncGenerator[bytes, None]: """Streams a file from S3 yielding chunks. Memory-efficient for large files.""" try: async with self._get_client() as client: - response = await client.get_object(Bucket=self.bucket_name, Key=object_name) + args = {'Bucket': self.bucket_name, 'Key': object_name} + if range_header: + args['Range'] = range_header + + response = await client.get_object(**args) # aioboto3 Body is an aiohttp StreamReader wrapper body = response['Body'] diff --git a/aiws.py b/aiws.py index fab7e92..55e3ad0 100644 --- a/aiws.py +++ b/aiws.py @@ -45,6 +45,7 @@ from api.endpoints.project_router import router as project_api_router from api.endpoints.idea_router import router as idea_api_router from api.endpoints.post_router import router as post_api_router from api.endpoints.environment_router import router as environment_api_router +from api.endpoints.inspiration_router import router as inspiration_api_router logger = logging.getLogger(__name__) @@ -222,6 +223,7 @@ app.include_router(project_api_router) app.include_router(idea_api_router) app.include_router(post_api_router) app.include_router(environment_api_router) +app.include_router(inspiration_api_router) # Prometheus Metrics (Instrument after all routers are added) Instrumentator( diff --git a/api/dependency.py b/api/dependency.py index 1e8e8e9..3dd305c 100644 --- a/api/dependency.py +++ b/api/dependency.py @@ -62,4 +62,9 @@ async def get_album_service(dao: DAO = Depends(get_dao)) -> AlbumService: from api.service.post_service import PostService def get_post_service(dao: DAO = Depends(get_dao)) -> PostService: - return PostService(dao) \ No newline at end of file + return PostService(dao) + +from api.service.inspiration_service import InspirationService + +def get_inspiration_service(dao: DAO = Depends(get_dao), s3_adapter: S3Adapter = Depends(get_s3_adapter)) -> InspirationService: + return InspirationService(dao, s3_adapter) diff --git a/api/endpoints/assets_router.py b/api/endpoints/assets_router.py index ccaad7c..a1073c9 100644 --- a/api/endpoints/assets_router.py +++ b/api/endpoints/assets_router.py @@ -42,8 +42,9 @@ async def get_asset( if not asset: raise HTTPException(status_code=404, detail="Asset not found") - headers = { - "Cache-Control": "public, max-age=31536000, immutable" + base_headers = { + "Cache-Control": "public, max-age=31536000, immutable", + "Accept-Ranges": "bytes" } # Thumbnail: маленький, можно грузить в RAM @@ -51,17 +52,70 @@ async def get_asset( if asset.minio_thumbnail_object_name and s3_adapter: thumb_bytes = await s3_adapter.get_file(asset.minio_thumbnail_object_name) if thumb_bytes: - return Response(content=thumb_bytes, media_type="image/jpeg", headers=headers) + return Response(content=thumb_bytes, media_type="image/jpeg", headers=base_headers) # Fallback: thumbnail in DB if asset.thumbnail: - return Response(content=asset.thumbnail, media_type="image/jpeg", headers=headers) + return Response(content=asset.thumbnail, media_type="image/jpeg", headers=base_headers) # No thumbnail available — fall through to main content # Main content: стримим из S3 без загрузки в RAM if asset.minio_object_name and s3_adapter: content_type = "image/png" - # if asset.content_type == AssetContentType.VIDEO: - # content_type = "video/mp4" + if asset.content_type == AssetContentType.VIDEO: + content_type = "video/mp4" # Or detect from extension if stored + elif asset.content_type == AssetContentType.IMAGE: + content_type = "image/png" # Default for images + + # Better content type detection based on extension if possible, but for now this is okay + if asset.minio_object_name.endswith(".mp4"): + content_type = "video/mp4" + elif asset.minio_object_name.endswith(".mov"): + content_type = "video/quicktime" + elif asset.minio_object_name.endswith(".jpg") or asset.minio_object_name.endswith(".jpeg"): + content_type = "image/jpeg" + + # Handle Range requests for video streaming + range_header = request.headers.get("range") + file_size = await s3_adapter.get_file_size(asset.minio_object_name) + + if range_header and file_size: + try: + # Parse Range header: bytes=start-end + byte_range = range_header.replace("bytes=", "") + start_str, end_str = byte_range.split("-") + start = int(start_str) + end = int(end_str) if end_str else file_size - 1 + + # Validate range + if start >= file_size: + # 416 Range Not Satisfiable + return Response(status_code=416, headers={"Content-Range": f"bytes */{file_size}"}) + + chunk_size = end - start + 1 + + headers = base_headers.copy() + headers.update({ + "Content-Range": f"bytes {start}-{end}/{file_size}", + "Content-Length": str(chunk_size), + }) + + # Pass the exact range string to S3 + s3_range = f"bytes={start}-{end}" + + return StreamingResponse( + s3_adapter.stream_file(asset.minio_object_name, range_header=s3_range), + status_code=206, + headers=headers, + media_type=content_type + ) + except ValueError: + pass # Fallback to full content if range parsing fails + + # Full content response + headers = base_headers.copy() + if file_size: + headers["Content-Length"] = str(file_size) + return StreamingResponse( s3_adapter.stream_file(asset.minio_object_name), media_type=content_type, @@ -70,7 +124,7 @@ async def get_asset( # Fallback: data stored in DB (legacy) if asset.data: - return Response(content=asset.data, media_type="image/png", headers=headers) + return Response(content=asset.data, media_type="image/png", headers=base_headers) raise HTTPException(status_code=404, detail="Asset data not found") diff --git a/api/endpoints/idea_router.py b/api/endpoints/idea_router.py index 6510b8c..7003fa1 100644 --- a/api/endpoints/idea_router.py +++ b/api/endpoints/idea_router.py @@ -20,7 +20,13 @@ async def create_idea( ): pid = project_id or request.project_id - return await idea_service.create_idea(request.name, request.description, pid, str(current_user["_id"])) + return await idea_service.create_idea( + name=request.name, + description=request.description, + project_id=pid, + user_id=str(current_user["_id"]), + inspiration_id=request.inspiration_id + ) @router.get("", response_model=List[IdeaResponse]) async def get_ideas( @@ -48,7 +54,12 @@ async def update_idea( request: IdeaUpdateRequest, idea_service: IdeaService = Depends(get_idea_service) ): - idea = await idea_service.update_idea(idea_id, request.name, request.description) + idea = await idea_service.update_idea( + idea_id=idea_id, + name=request.name, + description=request.description, + inspiration_id=request.inspiration_id + ) if not idea: raise HTTPException(status_code=404, detail="Idea not found") return idea diff --git a/api/endpoints/inspiration_router.py b/api/endpoints/inspiration_router.py new file mode 100644 index 0000000..e942016 --- /dev/null +++ b/api/endpoints/inspiration_router.py @@ -0,0 +1,95 @@ +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from api.dependency import get_inspiration_service, get_project_id +from api.endpoints.auth import get_current_user +from api.models.InspirationRequest import InspirationCreateRequest, InspirationResponse, InspirationListResponse +from api.service.inspiration_service import InspirationService +from models.Inspiration import Inspiration + +router = APIRouter(prefix="/api/inspirations", tags=["Inspirations"]) + + +@router.post("", response_model=InspirationResponse, status_code=status.HTTP_201_CREATED) +async def create_inspiration( + request: InspirationCreateRequest, + project_id: Optional[str] = Depends(get_project_id), + current_user: dict = Depends(get_current_user), + service: InspirationService = Depends(get_inspiration_service) +): + pid = project_id or request.project_id + + inspiration = await service.create_inspiration( + source_url=request.source_url, + created_by=str(current_user["_id"]), + project_id=pid, + caption=request.caption + ) + return inspiration + + +@router.get("", response_model=InspirationListResponse) +async def get_inspirations( + project_id: Optional[str] = Depends(get_project_id), + limit: int = 20, + offset: int = 0, + current_user: dict = Depends(get_current_user), + service: InspirationService = Depends(get_inspiration_service) +): + # If project_id is provided, filter by it. Otherwise, filter by user. + # Or maybe we want to see all user's inspirations if no project is selected? + # Let's follow the pattern: if project_id is present, show project's inspirations. + # If not, show user's personal inspirations (where project_id is None) OR all user's inspirations? + # Usually "My Inspirations" means created by me. + + # Let's assume: + # If project_id -> filter by project_id (and maybe created_by if we want strict ownership, but usually project members share) + # If no project_id -> filter by created_by (personal) + + pid = project_id + uid = str(current_user["_id"]) + + inspirations = await service.get_inspirations(project_id=pid, created_by=uid if not pid else None, limit=limit, offset=offset) + total_count = await service.dao.inspirations.count_inspirations(project_id=pid, created_by=uid if not pid else None) + + return InspirationListResponse( + inspirations=[InspirationResponse(**inspiration.model_dump()) for inspiration in inspirations], + total_count=total_count + ) + + +@router.get("/{inspiration_id}", response_model=InspirationResponse) +async def get_inspiration( + inspiration_id: str, + service: InspirationService = Depends(get_inspiration_service), + current_user: dict = Depends(get_current_user) +): + inspiration = await service.get_inspiration(inspiration_id) + if not inspiration: + raise HTTPException(status_code=404, detail="Inspiration not found") + return inspiration + + +@router.patch("/{inspiration_id}/complete", response_model=InspirationResponse) +async def mark_inspiration_complete( + inspiration_id: str, + is_completed: bool = True, + service: InspirationService = Depends(get_inspiration_service), + current_user: dict = Depends(get_current_user) +): + inspiration = await service.mark_as_completed(inspiration_id, is_completed) + if not inspiration: + raise HTTPException(status_code=404, detail="Inspiration not found") + return inspiration + + +@router.delete("/{inspiration_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_inspiration( + inspiration_id: str, + service: InspirationService = Depends(get_inspiration_service), + current_user: dict = Depends(get_current_user) +): + success = await service.delete_inspiration(inspiration_id) + if not success: + raise HTTPException(status_code=404, detail="Inspiration not found") + return None diff --git a/api/models/IdeaRequest.py b/api/models/IdeaRequest.py index 82138f7..327adbb 100644 --- a/api/models/IdeaRequest.py +++ b/api/models/IdeaRequest.py @@ -7,10 +7,12 @@ class IdeaCreateRequest(BaseModel): name: str description: Optional[str] = None project_id: Optional[str] = None # Optional in body if passed via header/dependency + inspiration_id: Optional[str] = None class IdeaUpdateRequest(BaseModel): name: Optional[str] = None description: Optional[str] = None + inspiration_id: Optional[str] = None class IdeaResponse(Idea): last_generation: Optional[GenerationResponse] = None diff --git a/api/models/InspirationRequest.py b/api/models/InspirationRequest.py new file mode 100644 index 0000000..802dc3b --- /dev/null +++ b/api/models/InspirationRequest.py @@ -0,0 +1,29 @@ +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel + +from models.Inspiration import Inspiration + + +class InspirationCreateRequest(BaseModel): + source_url: str + caption: Optional[str] = None + project_id: Optional[str] = None + + +class InspirationResponse(BaseModel): + id: str + source_url: str + caption: str | None = None + asset_id: str + is_completed: bool + created_by: str + project_id: str | None = None + created_at: datetime + updated_at: datetime + + +class InspirationListResponse(BaseModel): + inspirations: List[InspirationResponse] + total_count: int diff --git a/api/service/idea_service.py b/api/service/idea_service.py index e154564..4613dce 100644 --- a/api/service/idea_service.py +++ b/api/service/idea_service.py @@ -7,8 +7,14 @@ class IdeaService: def __init__(self, dao: DAO): self.dao = dao - async def create_idea(self, name: str, description: Optional[str], project_id: Optional[str], user_id: str) -> Idea: - idea = Idea(name=name, description=description, project_id=project_id, created_by=user_id) + async def create_idea(self, name: str, description: Optional[str], project_id: Optional[str], user_id: str, inspiration_id: Optional[str] = None) -> Idea: + idea = Idea( + name=name, + description=description, + project_id=project_id, + created_by=user_id, + inspiration_id=inspiration_id + ) idea_id = await self.dao.ideas.create_idea(idea) idea.id = idea_id return idea @@ -19,7 +25,7 @@ class IdeaService: async def get_idea(self, idea_id: str) -> Optional[Idea]: return await self.dao.ideas.get_idea(idea_id) - async def update_idea(self, idea_id: str, name: Optional[str] = None, description: Optional[str] = None) -> Optional[Idea]: + async def update_idea(self, idea_id: str, name: Optional[str] = None, description: Optional[str] = None, inspiration_id: Optional[str] = None) -> Optional[Idea]: idea = await self.dao.ideas.get_idea(idea_id) if not idea: return None @@ -28,6 +34,8 @@ class IdeaService: idea.name = name if description is not None: idea.description = description + if inspiration_id is not None: + idea.inspiration_id = inspiration_id idea.updated_at = datetime.now() await self.dao.ideas.update_idea(idea) @@ -72,4 +80,3 @@ class IdeaService: return True return False - diff --git a/api/service/inspiration_service.py b/api/service/inspiration_service.py new file mode 100644 index 0000000..6cb44ba --- /dev/null +++ b/api/service/inspiration_service.py @@ -0,0 +1,146 @@ +import asyncio +import logging +import os +import tempfile +from datetime import datetime +from typing import List, Optional, Tuple + +import httpx +from fastapi import HTTPException + +from models.Asset import Asset, AssetType, AssetContentType +from models.Inspiration import Inspiration +from repos.dao import DAO +from adapters.s3_adapter import S3Adapter + +# Try to import yt_dlp, but don't crash if it's missing (though we added it to requirements) +try: + import yt_dlp +except ImportError: + yt_dlp = None + +logger = logging.getLogger(__name__) + +class InspirationService: + def __init__(self, dao: DAO, s3_adapter: S3Adapter): + self.dao = dao + self.s3_adapter = s3_adapter + + async def create_inspiration(self, source_url: str, created_by: str, project_id: Optional[str] = None, caption: Optional[str] = None) -> Inspiration: + # 1. Download content from Instagram + try: + content_bytes, content_type, ext = await self._download_content(source_url) + except Exception as e: + logger.error(f"Failed to download content from {source_url}: {e}") + raise HTTPException(status_code=400, detail=f"Failed to download content: {str(e)}") + + # 2. Save as Asset + filename = f"inspirations/{datetime.now().strftime('%Y%m%d_%H%M%S')}_insta.{ext}" + + await self.s3_adapter.upload_file(filename, content_bytes, content_type=content_type) + + asset = Asset( + name=f"Inspiration from {source_url}", + type=AssetType.INSPIRATION, + content_type=AssetContentType.VIDEO if content_type.startswith("video") else AssetContentType.IMAGE, + minio_object_name=filename, + minio_bucket=self.s3_adapter.bucket_name, + created_by=created_by, + project_id=project_id + ) + asset_id = await self.dao.assets.create_asset(asset) + + # 3. Create Inspiration object + inspiration = Inspiration( + source_url=source_url, + caption=caption, + asset_id=str(asset_id), + created_by=created_by, + project_id=project_id + ) + insp_id = await self.dao.inspirations.create_inspiration(inspiration) + inspiration.id = insp_id + + return inspiration + + async def get_inspirations(self, project_id: Optional[str], created_by: str, limit: int = 20, offset: int = 0) -> List[Inspiration]: + return await self.dao.inspirations.get_inspirations(project_id, created_by, limit, offset) + + async def get_inspiration(self, inspiration_id: str) -> Optional[Inspiration]: + return await self.dao.inspirations.get_inspiration(inspiration_id) + + async def mark_as_completed(self, inspiration_id: str, is_completed: bool = True) -> Optional[Inspiration]: + inspiration = await self.dao.inspirations.get_inspiration(inspiration_id) + if not inspiration: + return None + + inspiration.is_completed = is_completed + inspiration.updated_at = datetime.now() + await self.dao.inspirations.update_inspiration(inspiration) + return inspiration + + async def delete_inspiration(self, inspiration_id: str) -> bool: + inspiration = await self.dao.inspirations.get_inspiration(inspiration_id) + if not inspiration: + return False + + # Delete associated asset + if inspiration.asset_id: + await self.dao.assets.delete_asset(inspiration.asset_id) + + return await self.dao.inspirations.delete_inspiration(inspiration_id) + + async def _download_content(self, url: str) -> Tuple[bytes, str, str]: + """ + Downloads content using yt-dlp. + Returns (content_bytes, content_type, extension) + """ + if not yt_dlp: + raise RuntimeError("yt-dlp is not installed") + + logger.info(f"Downloading from {url} using yt-dlp...") + + def run_yt_dlp(): + with tempfile.TemporaryDirectory() as tmpdirname: + ydl_opts = { + 'outtmpl': f'{tmpdirname}/%(id)s.%(ext)s', + 'quiet': True, + 'no_warnings': True, + 'format': 'best', # Best quality single file + 'noplaylist': True, # Only single video if it's a playlist/profile + 'writethumbnail': False, + 'writesubtitles': False, + } + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + ydl.download([url]) + + # Find the downloaded file + files = os.listdir(tmpdirname) + if not files: + raise Exception("No files downloaded") + + # Pick the largest file if multiple (e.g. if yt-dlp downloaded parts) + # But with 'format': 'best', it should be one. + # If carousel, it might be multiple. Let's pick the first one. + filename = files[0] + filepath = os.path.join(tmpdirname, filename) + + with open(filepath, 'rb') as f: + data = f.read() + + ext = filename.split('.')[-1].lower() + + # Determine content type + if ext in ['mp4', 'mov', 'avi', 'mkv', 'webm']: + content_type = f"video/{ext}" + if ext == 'mov': content_type = "video/quicktime" + elif ext in ['jpg', 'jpeg', 'png', 'webp']: + content_type = f"image/{ext}" + if ext == 'jpg': content_type = "image/jpeg" + else: + content_type = "application/octet-stream" + + return data, content_type, ext + + return await asyncio.to_thread(run_yt_dlp) diff --git a/models/Asset.py b/models/Asset.py index af00ef4..29ab56e 100644 --- a/models/Asset.py +++ b/models/Asset.py @@ -8,10 +8,12 @@ from pydantic import BaseModel, computed_field, Field, model_validator class AssetContentType(str, Enum): IMAGE = 'image' PROMPT = 'prompt' + VIDEO = 'video' class AssetType(str, Enum): UPLOADED = 'uploaded' GENERATED = 'generated' + INSPIRATION = 'inspiration' class Asset(BaseModel): diff --git a/models/Idea.py b/models/Idea.py index 4a2aeab..b65093b 100644 --- a/models/Idea.py +++ b/models/Idea.py @@ -7,6 +7,7 @@ class Idea(BaseModel): name: str = "New Idea" description: Optional[str] = None project_id: Optional[str] = None + inspiration_id: Optional[str] = None # Link to Inspiration created_by: str # User ID is_deleted: bool = False created_at: datetime = Field(default_factory=datetime.now) diff --git a/models/Inspiration.py b/models/Inspiration.py new file mode 100644 index 0000000..0649772 --- /dev/null +++ b/models/Inspiration.py @@ -0,0 +1,16 @@ +from datetime import datetime, UTC +from typing import Optional + +from pydantic import BaseModel, Field + + +class Inspiration(BaseModel): + id: str | None = None + source_url: str + caption: str | None = None + asset_id: str + is_completed: bool = False + created_by: str + project_id: str | None = None + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) diff --git a/repos/dao.py b/repos/dao.py index 6c53045..c0df4d4 100644 --- a/repos/dao.py +++ b/repos/dao.py @@ -9,6 +9,7 @@ from repos.project_repo import ProjectRepo from repos.idea_repo import IdeaRepo from repos.post_repo import PostRepo from repos.environment_repo import EnvironmentRepo +from repos.inspiration_repo import InspirationRepo from typing import Optional @@ -25,3 +26,4 @@ class DAO: self.ideas = IdeaRepo(client, db_name) self.posts = PostRepo(client, db_name) self.environments = EnvironmentRepo(client, db_name) + self.inspirations = InspirationRepo(client, db_name) diff --git a/repos/inspiration_repo.py b/repos/inspiration_repo.py new file mode 100644 index 0000000..e6239a3 --- /dev/null +++ b/repos/inspiration_repo.py @@ -0,0 +1,54 @@ +from typing import List, Optional + +from bson import ObjectId +from motor.motor_asyncio import AsyncIOMotorClient + +from models.Inspiration import Inspiration + + +class InspirationRepo: + def __init__(self, client: AsyncIOMotorClient, db_name="bot_db"): + self.collection = client[db_name]["inspirations"] + + async def create_inspiration(self, inspiration: Inspiration) -> str: + res = await self.collection.insert_one(inspiration.model_dump(exclude={"id"})) + return str(res.inserted_id) + + async def get_inspiration(self, inspiration_id: str) -> Optional[Inspiration]: + res = await self.collection.find_one({"_id": ObjectId(inspiration_id)}) + if res: + res["id"] = str(res.pop("_id")) + return Inspiration(**res) + return None + + async def get_inspirations(self, project_id: Optional[str] = None, created_by: Optional[str] = None, limit: int = 20, offset: int = 0) -> List[Inspiration]: + query = {} + if project_id: + query["project_id"] = project_id + if created_by: + query["created_by"] = created_by + + cursor = self.collection.find(query).sort("created_at", -1).skip(offset).limit(limit) + inspirations = [] + async for doc in cursor: + doc["id"] = str(doc.pop("_id")) + inspirations.append(Inspiration(**doc)) + return inspirations + + async def count_inspirations(self, project_id: Optional[str] = None, created_by: Optional[str] = None) -> int: + query = {} + if project_id: + query["project_id"] = project_id + if created_by: + query["created_by"] = created_by + return await self.collection.count_documents(query) + + async def update_inspiration(self, inspiration: Inspiration): + await self.collection.update_one( + {"_id": ObjectId(inspiration.id)}, + {"$set": inspiration.model_dump(exclude={"id"})} + ) + + async def delete_inspiration(self, inspiration_id: str) -> bool: + res = await self.collection.delete_one({"_id": ObjectId(inspiration_id)}) + return res.deleted_count > 0 diff --git a/requirements.txt b/requirements.txt index 89ef602..7f66731 100644 --- a/requirements.txt +++ b/requirements.txt @@ -52,3 +52,4 @@ python-multipart==0.0.22 email-validator prometheus-fastapi-instrumentator pydantic-settings==2.13.0 +yt-dlp