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 @@ - - - - - - - Document - - -
- {% for playlist in playlists %} - -
-
- -
-

{{playlist.name}}

-
-
- {% endfor %} -
- {% for video in videos %} - -
-
- -
- {% if video.title %} -

{{video.title}}

- {{video.date}} - {% else %} -

{{video.filename}}

- {% endif %} -
-
- {% endfor %} -
- - \ No newline at end of file diff --git a/templates/channels.html b/templates/channels.html deleted file mode 100644 index d06d27a..0000000 --- a/templates/channels.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - Document - - -
- {% for channel_id, name, video_count in channels %} - -
-

{{name}}

-

videos: {{video_count}}

-
-
- {% endfor %} -
- - \ No newline at end of file diff --git a/templates/index.html b/templates/index.html deleted file mode 100644 index 5f85549..0000000 --- a/templates/index.html +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - Document - - -
- {% for photo in photos %} -
- -
- {% endfor %} - - page-1 - page+1 -
- - \ No newline at end of file diff --git a/templates/playlist.html b/templates/playlist.html deleted file mode 100644 index 61eb551..0000000 --- a/templates/playlist.html +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - Document - - -
-

count: {{video_count}}

- {% for video in videos %} - -
-
- -
-

{{video.filename}}

-
-
- - {% endfor %} -
- - \ No newline at end of file diff --git a/templates/video_detail.html b/templates/video_detail.html deleted file mode 100644 index 2b565e6..0000000 --- a/templates/video_detail.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - Document - - -
- -
- - \ No newline at end of file diff --git a/templates/videos.html b/templates/videos.html deleted file mode 100644 index f120fd0..0000000 --- a/templates/videos.html +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - Document - - -
- {% for video in videos %} - -
-
- -
- {% if video.title %} -

{{video.title}}

- {{video.date}} - {% else %} -

{{video.filename}}

- {% endif %} -
-
- - {% endfor %} -
- - \ No newline at end of file diff --git a/utils/downloader.py b/utils/downloader.py index b75d4f7..c3f41ee 100644 --- a/utils/downloader.py +++ b/utils/downloader.py @@ -44,7 +44,7 @@ async def download_photos(client): try: await msg.download_media(file=full_path) - except Exception as e: # noqa: BLE001 + except Exception as e: print(e) async def main(): @@ -61,7 +61,6 @@ async def main(): image.add_data(qr.url) image.make(fit=True) image.print_ascii(invert=True) - # user = await qr.wait() await download_photos(client) diff --git a/utils/sync_yt.py b/utils/sync_yt.py index cb31364..b09280d 100644 --- a/utils/sync_yt.py +++ b/utils/sync_yt.py @@ -6,7 +6,7 @@ from pathlib import Path from ..core.config import BASE_DIR, YT_DIR -COOKIES_PATH = Path(f"{BASE_DIR}/utils/cookies.txt") # или из config +COOKIES_PATH = Path(f"{BASE_DIR}/utils/cookies.txt") GLOBAL_ARGS = [ @@ -26,8 +26,8 @@ GLOBAL_ARGS = [ DEFAULT_FORMAT = ( "bestvideo[height<=720][height>=480]+bestaudio/" - # "bestvideo[height<=1080][height>=480]+bestaudio/" - # "best[height>=480]" + "bestvideo[height<=1080][height>=480]+bestaudio/" + "best[height>=480]" ) def load_channels(config_path: Path, yt_dir: Path) -> list[dict]: diff --git a/utils/update_audio.py b/utils/update_audio.py index 5c6cf71..ecde472 100644 --- a/utils/update_audio.py +++ b/utils/update_audio.py @@ -75,7 +75,7 @@ async def update_yt(): preview_path = f"{COVERS}/{name}.jpg" path = str(relative_path) - title, date = parse_preview_path(preview_path) # noqa: RUF059 + title, date = parse_preview_path(preview_path) if filename in existing_audios: continue diff --git a/utils/update_thumbs.py b/utils/update_thumbs.py index 858aa06..ac834a7 100644 --- a/utils/update_thumbs.py +++ b/utils/update_thumbs.py @@ -19,7 +19,6 @@ async def extract_cover(video: Path, out: Path, sem: asyncio.Semaphore) -> bool: if ".Trash" in str(video) or any(part.startswith('.') for part in video.parts): return False - # 1. Пропускаем временные файлы и аудио-дорожки yt-dlp if ".temp." in video.name or ".f" in video.name and video.suffix == ".webm": return False @@ -28,7 +27,6 @@ async def extract_cover(video: Path, out: Path, sem: asyncio.Semaphore) -> bool: if out.exists() and out.stat().st_size > 0: return True - # 2. Пытаемся взять ВТОРУЮ дорожку (с вопросительным знаком, чтобы не падало) code, err = await run([ "ffmpeg", "-y", "-loglevel", "error", "-i", str(video), @@ -38,7 +36,6 @@ async def extract_cover(video: Path, out: Path, sem: asyncio.Semaphore) -> bool: ]) if code != 0: - # 3. Если второй нет, пытаемся взять ПЕРВУЮ (тоже с вопросительным знаком) code, err = await run([ "ffmpeg", "-y", "-loglevel", "error", "-i", str(video), diff --git a/utils/update_yt_videos.py b/utils/update_yt_videos.py index 6fe2921..2e28f28 100644 --- a/utils/update_yt_videos.py +++ b/utils/update_yt_videos.py @@ -116,6 +116,10 @@ async def update_yt(): else: + vkey = (channel.id, filename) + if vkey in existing_videos: + continue + video = Video(filename=filename, path=path, preview_path=preview_path, playlist=None, channel=channel, title=title, date=date) videos.append(video)