Compare commits

...

2 Commits

Author SHA1 Message Date
ndmg
20268f2e3d refactoring 2026-11-07 17:28:49 +00:00
ndmg
230ed14f69 refactoring 2026-11-07 17:27:59 +00:00
28 changed files with 103 additions and 243 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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"]

View File

@ -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):

View File

@ -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):

View File

@ -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):

View File

@ -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):

View File

@ -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):

View File

@ -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)

View File

@ -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,

View File

@ -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__":

View File

@ -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)

7
services/path.py Normal file
View File

@ -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("/"))

View File

@ -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%;
}

Binary file not shown.

View File

@ -1,39 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<title>Document</title>
</head>
<body>
<div class="wrapper">
{% for playlist in playlists %}
<a href="/playlist?id={{playlist.id}}">
<div class="playlist">
<div class="preview">
</div>
<h3>{{playlist.name}}</h3>
</div>
</a>
{% endfor %}
<hr>
{% for video in videos %}
<a href="/video?id={{video.id}}">
<div class="video">
<div class="preview">
<img src="{{video.preview_path}}" alt="">
</div>
{% if video.title %}
<h3>{{video.title}}</h3>
<small>{{video.date}}</small>
{% else %}
<h3>{{video.filename}}</h3>
{% endif %}
</div>
</a>
{% endfor %}
</div>
</body>
</html>

View File

@ -1,21 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div class="wrapper">
{% for channel_id, name, video_count in channels %}
<a href="/channel?id={{channel_id}}">
<div class="channel">
<h3>{{name}}</h3>
<p>videos: {{video_count}}</p>
</div>
</a>
{% endfor %}
</div>
</body>
</html>

View File

@ -1,23 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=, initial-scale=1.0">
<link rel="stylesheet" href="/static/css/style.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<title>Document</title>
</head>
<body>
<div class="wrapper">
{% for photo in photos %}
<div class="photo">
<img src="{{photo.path}}" alt="">
</div>
{% endfor %}
<a href="{{page_dec}}">page-1</a>
<a href="{{page_inc}}">page+1</a>
</div>
</body>
</html>

View File

@ -1,25 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=, initial-scale=1.0">
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<title>Document</title>
</head>
<body>
<div class="wrapper">
<h2>count: {{video_count}}</h2>
{% for video in videos %}
<a href="/video?id={{video.id}}">
<div class="video">
<div class="preview">
<img src="{{video.preview_path}}" alt="">
</div>
<h3>{{video.filename}}</h3>
</div>
</a>
{% endfor %}
</div>
</body>
</html>

View File

@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<title>Document</title>
</head>
<body>
<div class="video">
<video controls>
<source src="{{path}}" type="video/mp4">
</video>
</div>
</body>
</html>

View File

@ -1,29 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<title>Document</title>
</head>
<body>
<div class="wrapper">
{% for video in videos %}
<a href="/video?id={{video.id}}">
<div class="video">
<div class="preview">
<img src="{{video.preview_path}}" alt="">
</div>
{% if video.title %}
<h3>{{video.title}}</h3>
<small>{{video.date}}</small>
{% else %}
<h3>{{video.filename}}</h3>
{% endif %}
</div>
</a>
{% endfor %}
</div>
</body>
</html>

View File

@ -8,7 +8,7 @@ from telethon import TelegramClient
from ..core.config import PHOTOS_DIR
ENV_PATH = Path(__file__).resolve().parent / ".env"
ENV_PATH = Path(__file__).resolve().parent.parent / ".env"
load_dotenv(ENV_PATH)
API_ID = os.getenv("API_ID")
@ -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)

View File

@ -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]:

View File

@ -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

View File

@ -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),

View File

@ -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)