diff --git a/README.md b/README.md index e69de29..d670676 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,46 @@ +# RSS / Archive Backend + +Бэкенд для домашнего архива медиафайлов (видео, аудио, фото). +Проект представляет собой асинхронное API на FastAPI, которое собирает данные из локальных директорий, агрегирует их в PostgreSQL и отдает на фронтенд. + +## 🛠 Технологический стек + +- **Язык:** Python 3.14+ +- **Фреймворк:** FastAPI +- **База данных:** PostgreSQL 16 (Docker) +- **ORM:** SQLAlchemy 2.0 (Async) + psycopg (async) +- **Валидация:** Pydantic v2 +- **Сервер:** Uvicorn + +## 📂 Архитектура проекта + +```text +RSS/ +├── core/ # Конфигурация и подключение к БД +│ ├── config.py # Пути к медиа, константы +│ └── db.py # Движок SQLAlchemy, Base, SessionLocal +├── models/ # SQLAlchemy-модели (таблицы) +│ ├── video_model.py +│ ├── audio_model.py +│ └── photo_model.py +├── schemas/ # Pydantic-схемы (валидация и сериализация) +│ ├── video_schemas.py +│ ├── audio_schemas.py +│ └── photo_schemas.py +├── routers/ # Эндпоинты API +│ ├── video.py +│ ├── audio.py +│ ├── photo.py +│ └── ... +├── utils/ # Скрипты синхронизации (парсеры) +│ ├── update_videos.py +│ ├── update_audio.py +│ └── update_thumbs.py +├── media/ # Медиафайлы (монтируется в контейнер/отдается статикой) +│ ├── yt/ # Видео +│ ├── audio/ # Аудио +│ ├── _thumbs/ # Превью для видео +│ ├── _covers/ # Обложки для аудио +│ └── photos/ # Фото +├── main.py # Точка входа, подключение роутеров и статики +└── requirements.txt \ No newline at end of file diff --git a/core/authx.py b/core/authx.py index 34e5b51..0fee7ef 100644 --- a/core/authx.py +++ b/core/authx.py @@ -1,4 +1,3 @@ -# core/authx.py from authx import AuthX, AuthXConfig from .config import settings @@ -7,7 +6,6 @@ config = AuthXConfig( JWT_SECRET_KEY=settings.SECRET_KEY, JWT_ALGORITHM=settings.ALGORITHM, - # откуда берём токен JWT_TOKEN_LOCATION=["cookies"], # access diff --git a/core/config.py b/core/config.py index 77c8ef9..90f35d8 100644 --- a/core/config.py +++ b/core/config.py @@ -1,22 +1,17 @@ from pathlib import Path -BASE_DIR = Path(__file__).resolve().parent.parent # RSS/ +BASE_DIR = Path(__file__).resolve().parent.parent -# ---- видео (YouTube) ---- YT_DIR_NAME = "yt" -YT_DIR = BASE_DIR / "media" / YT_DIR_NAME # RSS/media/ +YT_DIR = BASE_DIR / "media" / YT_DIR_NAME THUMBS = "_thumbs" -THUMBS_DIR = YT_DIR / THUMBS # RSS/media/yt/_thumbs - -# ---- фото (если /photos отдаёт RSS/media/media) ---- -PHOTOS_DIR = BASE_DIR / "media" / "media" # RSS/media/media +THUMBS_DIR = YT_DIR / THUMBS +PHOTOS_DIR = BASE_DIR / "media" / "media" PHOTOS_DIR_RESPNS = BASE_DIR / "media" -# ---- шаблоны, статика ---- TEMPLATES_DIR = BASE_DIR / "templates" STATIC_DIR = BASE_DIR / "static" -# ---- расширения видео ---- VIDEO_EXTENSIONS = {".mp4", ".mkv", ".webm", ".avi", ".mov", ".flv", ".m4v"} AUDIO_EXTENSIONS = {".mp3"} @@ -32,7 +27,7 @@ class Settings(BaseSettings): ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 REFRESH_TOKEN_EXPIRE_DAYS: int = 7 - COOKIE_SECURE: bool = False # True на продакшене (HTTPS) + COOKIE_SECURE: bool = False COOKIE_SAMESITE: str = "lax" COOKIE_DOMAIN: str | None = None diff --git a/core/db.py b/core/db.py index d217857..7421102 100644 --- a/core/db.py +++ b/core/db.py @@ -1,3 +1,6 @@ +import os + +from dotenv import load_dotenv from sqlalchemy.ext.asyncio import ( AsyncSession, async_sessionmaker, @@ -5,12 +8,20 @@ from sqlalchemy.ext.asyncio import ( ) from sqlalchemy.orm import DeclarativeBase +from .config import BASE_DIR + class Base(DeclarativeBase): pass +dotenv_path = BASE_DIR / '.env' +load_dotenv(dotenv_path=dotenv_path) -DB_URL = "postgresql+psycopg://ndmg:marselina5$@127.0.0.1:5432/rssdb" +DB_PASSWORD = os.getenv("DB_PASSWORD") +DB_USER = os.getenv("DB_USER") +DB_NAME = os.getenv("DB_NAME") + +DB_URL = f"postgresql+psycopg://{DB_USER}:{DB_PASSWORD}@127.0.0.1:5432/{DB_NAME}" engine = create_async_engine(DB_URL, echo=True) diff --git a/docker-compose.yml b/docker-compose.yml index c2cbe21..cf18b8a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,9 +3,9 @@ services: image: postgres:16 restart: unless-stopped environment: - - POSTGRES_DB=rssdb - - POSTGRES_USER=ndmg - - POSTGRES_PASSWORD=marselina5$ + - POSTGRES_DB=${DB_NAME} + - POSTGRES_USER=${DB_USER} + - POSTGRES_PASSWORD=${DB_PASSWORD} ports: - "127.0.0.1:5432:5432" volumes: @@ -15,8 +15,8 @@ services: image: getmeili/meilisearch:v1.10 ports: ["7700:7700"] environment: - MEILI_MASTER_KEY: "super-secret-master-key-change-me" - MEILI_ENV: "development" # production требует master key + MEILI_MASTER_KEY: $MEILI_KEY + MEILI_ENV: "development" MEILI_NO_ANALYTICS: "true" volumes: ["meili_data:/meili_data"] diff --git a/models/photo_model.py b/models/photo_model.py index 7f4e668..712be89 100644 --- a/models/photo_model.py +++ b/models/photo_model.py @@ -11,7 +11,7 @@ class Category(Base): id: Mapped[int] = mapped_column(primary_key=True) title: Mapped[str] = mapped_column(String(64)) - photos: Mapped[list[Photo]] = relationship(back_populates="category") # noqa: F821 + photos: Mapped[list[Photo]] = relationship(back_populates="category") class Photo(Base): diff --git a/schemas/audio_schema.py b/schemas/audio_schema.py index 70fbd69..08d2e4b 100644 --- a/schemas/audio_schema.py +++ b/schemas/audio_schema.py @@ -1,12 +1,6 @@ -from urllib.parse import quote - from pydantic import BaseModel, ConfigDict, field_validator - -def encode_path(path: str) -> str: - if not path: - return "" - return "/".join(quote(part, safe="") for part in path.split("/")) +from ..services.path import encode_path class AudioSchema(BaseModel): diff --git a/schemas/channel_schemas.py b/schemas/channel_schemas.py index a2babe5..471b702 100644 --- a/schemas/channel_schemas.py +++ b/schemas/channel_schemas.py @@ -1,13 +1,8 @@ from datetime import date as DateType -from urllib.parse import quote from pydantic import BaseModel, field_validator - -def encode_path(path: str) -> str: - if not path: - return "" - return "/".join(quote(part, safe="") for part in path.split("/")) +from ..services.path import encode_path class ChannelItem(BaseModel): diff --git a/schemas/photo_schemas.py b/schemas/photo_schemas.py index a744e09..529743f 100644 --- a/schemas/photo_schemas.py +++ b/schemas/photo_schemas.py @@ -1,14 +1,8 @@ from __future__ import annotations -from urllib.parse import quote - from pydantic import BaseModel, ConfigDict, field_validator - -def encode_path(path: str) -> str: - if not path: - return "" - return "/".join(quote(part, safe="") for part in path.split("/")) +from ..services.path import encode_path class PhotoItem(BaseModel): diff --git a/schemas/playlist_schema.py b/schemas/playlist_schema.py index ba0f4cf..51f5c14 100644 --- a/schemas/playlist_schema.py +++ b/schemas/playlist_schema.py @@ -1,13 +1,8 @@ from datetime import date as DateType -from urllib.parse import quote from pydantic import BaseModel, field_validator - -def encode_path(path: str) -> str: - if not path: - return "" - return "/".join(quote(part, safe="") for part in path.split("/")) +from ..services.path import encode_path class VideoItem(BaseModel): diff --git a/schemas/video_schemas.py b/schemas/video_schemas.py index 061f04d..564557c 100644 --- a/schemas/video_schemas.py +++ b/schemas/video_schemas.py @@ -1,13 +1,9 @@ from datetime import date as DateType -from urllib.parse import quote from pydantic import BaseModel, ConfigDict, field_validator +from ..services.path import encode_path -def encode_path(path: str) -> str: - if not path: - return "" - return "/".join(quote(part, safe="") for part in path.split("/")) class VideoOut(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/search/documents.py b/search/documents.py index 06b92dd..7bafd9c 100644 --- a/search/documents.py +++ b/search/documents.py @@ -2,10 +2,6 @@ from ..models.video_model import Video def video_to_document(video: Video) -> dict: - """ - Требует, чтобы video.channel и video.playlist были загружены - (selectinload), иначе будет ленивая загрузка в синхронном контексте → ошибка. - """ return { "id": video.id, "title": video.title or video.filename, diff --git a/search/reindex.py b/search/reindex.py index d14bf2b..e136afd 100644 --- a/search/reindex.py +++ b/search/reindex.py @@ -1,4 +1,3 @@ -# search/reindex.py import asyncio from sqlalchemy import select @@ -37,7 +36,7 @@ async def full_reindex(): async def _push(docs: list[dict]): task = videos_index().add_documents(docs) - # дожидаемся, чтобы видеть ошибки и гарантировать применение + client.wait_for_task(task.task_uid) if __name__ == "__main__": diff --git a/search/search_client.py b/search/search_client.py index 4a17347..242e780 100644 --- a/search/search_client.py +++ b/search/search_client.py @@ -1,7 +1,15 @@ -import meilisearch +import os -MEILI_URL = "http://localhost:7700" -MEILI_KEY = "super-secret-master-key-change-me" +import meilisearch +from dotenv import load_dotenv + +from ..core.config import BASE_DIR + +load_dotenv(BASE_DIR / '.env') + + +MEILI_URL = os.getenv("MEILI_URL") +MEILI_KEY = os.getenv("MEILI_KEY") INDEX = "videos" client = meilisearch.Client(MEILI_URL, MEILI_KEY) diff --git a/services/path.py b/services/path.py new file mode 100644 index 0000000..906f1d9 --- /dev/null +++ b/services/path.py @@ -0,0 +1,7 @@ +from urllib.parse import quote + + +def encode_path(path: str) -> str: + if not path: + return "" + return "/".join(quote(part, safe="") for part in path.split("/")) diff --git a/static/css/style.css b/static/css/style.css deleted file mode 100644 index bf64aaf..0000000 --- a/static/css/style.css +++ /dev/null @@ -1,21 +0,0 @@ -@font-face { - font-family: "Roboto Mono"; - src: url("/static/fonts/RobotoMono-Bold.ttf"); -} - -* { - margin: 0; - padding: 0; - box-sizing: border-box; - font-family: "Roboto Mono"; -} - -.wrapper { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - width: 100%; -} - -.photo > img { - width: 100%; -} \ No newline at end of file diff --git a/static/fonts/RobotoMono-Bold.ttf b/static/fonts/RobotoMono-Bold.ttf deleted file mode 100644 index bef439f..0000000 Binary files a/static/fonts/RobotoMono-Bold.ttf and /dev/null differ diff --git a/templates/channel_detail.html b/templates/channel_detail.html deleted file mode 100644 index 62e25db..0000000 --- a/templates/channel_detail.html +++ /dev/null @@ -1,39 +0,0 @@ - - -
- - - -videos: {{video_count}}
-