refactoring
This commit is contained in:
parent
a9c23a2118
commit
230ed14f69
46
README.md
46
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
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
# core/authx.py
|
|
||||||
from authx import AuthX, AuthXConfig
|
from authx import AuthX, AuthXConfig
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
|
|
@ -7,7 +6,6 @@ config = AuthXConfig(
|
||||||
JWT_SECRET_KEY=settings.SECRET_KEY,
|
JWT_SECRET_KEY=settings.SECRET_KEY,
|
||||||
JWT_ALGORITHM=settings.ALGORITHM,
|
JWT_ALGORITHM=settings.ALGORITHM,
|
||||||
|
|
||||||
# откуда берём токен
|
|
||||||
JWT_TOKEN_LOCATION=["cookies"],
|
JWT_TOKEN_LOCATION=["cookies"],
|
||||||
|
|
||||||
# access
|
# access
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,17 @@
|
||||||
from pathlib import Path
|
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_NAME = "yt"
|
||||||
YT_DIR = BASE_DIR / "media" / YT_DIR_NAME # RSS/media/
|
YT_DIR = BASE_DIR / "media" / YT_DIR_NAME
|
||||||
THUMBS = "_thumbs"
|
THUMBS = "_thumbs"
|
||||||
THUMBS_DIR = YT_DIR / THUMBS # RSS/media/yt/_thumbs
|
THUMBS_DIR = YT_DIR / THUMBS
|
||||||
|
PHOTOS_DIR = BASE_DIR / "media" / "media"
|
||||||
# ---- фото (если /photos отдаёт RSS/media/media) ----
|
|
||||||
PHOTOS_DIR = BASE_DIR / "media" / "media" # RSS/media/media
|
|
||||||
PHOTOS_DIR_RESPNS = BASE_DIR / "media"
|
PHOTOS_DIR_RESPNS = BASE_DIR / "media"
|
||||||
|
|
||||||
# ---- шаблоны, статика ----
|
|
||||||
TEMPLATES_DIR = BASE_DIR / "templates"
|
TEMPLATES_DIR = BASE_DIR / "templates"
|
||||||
STATIC_DIR = BASE_DIR / "static"
|
STATIC_DIR = BASE_DIR / "static"
|
||||||
|
|
||||||
# ---- расширения видео ----
|
|
||||||
VIDEO_EXTENSIONS = {".mp4", ".mkv", ".webm", ".avi", ".mov", ".flv", ".m4v"}
|
VIDEO_EXTENSIONS = {".mp4", ".mkv", ".webm", ".avi", ".mov", ".flv", ".m4v"}
|
||||||
|
|
||||||
AUDIO_EXTENSIONS = {".mp3"}
|
AUDIO_EXTENSIONS = {".mp3"}
|
||||||
|
|
@ -32,7 +27,7 @@ class Settings(BaseSettings):
|
||||||
ALGORITHM: str = "HS256"
|
ALGORITHM: str = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||||
COOKIE_SECURE: bool = False # True на продакшене (HTTPS)
|
COOKIE_SECURE: bool = False
|
||||||
COOKIE_SAMESITE: str = "lax"
|
COOKIE_SAMESITE: str = "lax"
|
||||||
COOKIE_DOMAIN: str | None = None
|
COOKIE_DOMAIN: str | None = None
|
||||||
|
|
||||||
|
|
|
||||||
13
core/db.py
13
core/db.py
|
|
@ -1,3 +1,6 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
from sqlalchemy.ext.asyncio import (
|
from sqlalchemy.ext.asyncio import (
|
||||||
AsyncSession,
|
AsyncSession,
|
||||||
async_sessionmaker,
|
async_sessionmaker,
|
||||||
|
|
@ -5,12 +8,20 @@ from sqlalchemy.ext.asyncio import (
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from .config import BASE_DIR
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
pass
|
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)
|
engine = create_async_engine(DB_URL, echo=True)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@ services:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_DB=rssdb
|
- POSTGRES_DB=${DB_NAME}
|
||||||
- POSTGRES_USER=ndmg
|
- POSTGRES_USER=${DB_USER}
|
||||||
- POSTGRES_PASSWORD=marselina5$
|
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:5432:5432"
|
- "127.0.0.1:5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -15,8 +15,8 @@ services:
|
||||||
image: getmeili/meilisearch:v1.10
|
image: getmeili/meilisearch:v1.10
|
||||||
ports: ["7700:7700"]
|
ports: ["7700:7700"]
|
||||||
environment:
|
environment:
|
||||||
MEILI_MASTER_KEY: "super-secret-master-key-change-me"
|
MEILI_MASTER_KEY: $MEILI_KEY
|
||||||
MEILI_ENV: "development" # production требует master key
|
MEILI_ENV: "development"
|
||||||
MEILI_NO_ANALYTICS: "true"
|
MEILI_NO_ANALYTICS: "true"
|
||||||
volumes: ["meili_data:/meili_data"]
|
volumes: ["meili_data:/meili_data"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ class Category(Base):
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
title: Mapped[str] = mapped_column(String(64))
|
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):
|
class Photo(Base):
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,6 @@
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator
|
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 AudioSchema(BaseModel):
|
class AudioSchema(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,8 @@
|
||||||
from datetime import date as DateType
|
from datetime import date as DateType
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
from pydantic import BaseModel, field_validator
|
from pydantic import BaseModel, 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 ChannelItem(BaseModel):
|
class ChannelItem(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,8 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator
|
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 PhotoItem(BaseModel):
|
class PhotoItem(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,8 @@
|
||||||
from datetime import date as DateType
|
from datetime import date as DateType
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
from pydantic import BaseModel, field_validator
|
from pydantic import BaseModel, 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 VideoItem(BaseModel):
|
class VideoItem(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,9 @@
|
||||||
from datetime import date as DateType
|
from datetime import date as DateType
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator
|
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):
|
class VideoOut(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,6 @@ from ..models.video_model import Video
|
||||||
|
|
||||||
|
|
||||||
def video_to_document(video: Video) -> dict:
|
def video_to_document(video: Video) -> dict:
|
||||||
"""
|
|
||||||
Требует, чтобы video.channel и video.playlist были загружены
|
|
||||||
(selectinload), иначе будет ленивая загрузка в синхронном контексте → ошибка.
|
|
||||||
"""
|
|
||||||
return {
|
return {
|
||||||
"id": video.id,
|
"id": video.id,
|
||||||
"title": video.title or video.filename,
|
"title": video.title or video.filename,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
# search/reindex.py
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
@ -37,7 +36,7 @@ async def full_reindex():
|
||||||
|
|
||||||
async def _push(docs: list[dict]):
|
async def _push(docs: list[dict]):
|
||||||
task = videos_index().add_documents(docs)
|
task = videos_index().add_documents(docs)
|
||||||
# дожидаемся, чтобы видеть ошибки и гарантировать применение
|
|
||||||
client.wait_for_task(task.task_uid)
|
client.wait_for_task(task.task_uid)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,15 @@
|
||||||
import meilisearch
|
import os
|
||||||
|
|
||||||
MEILI_URL = "http://localhost:7700"
|
import meilisearch
|
||||||
MEILI_KEY = "super-secret-master-key-change-me"
|
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"
|
INDEX = "videos"
|
||||||
|
|
||||||
client = meilisearch.Client(MEILI_URL, MEILI_KEY)
|
client = meilisearch.Client(MEILI_URL, MEILI_KEY)
|
||||||
|
|
|
||||||
7
services/path.py
Normal file
7
services/path.py
Normal 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("/"))
|
||||||
|
|
@ -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.
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -44,7 +44,7 @@ async def download_photos(client):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await msg.download_media(file=full_path)
|
await msg.download_media(file=full_path)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e:
|
||||||
print(e)
|
print(e)
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
|
|
@ -61,7 +61,6 @@ async def main():
|
||||||
image.add_data(qr.url)
|
image.add_data(qr.url)
|
||||||
image.make(fit=True)
|
image.make(fit=True)
|
||||||
image.print_ascii(invert=True)
|
image.print_ascii(invert=True)
|
||||||
# user = await qr.wait()
|
|
||||||
|
|
||||||
await download_photos(client)
|
await download_photos(client)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from pathlib import Path
|
||||||
|
|
||||||
from ..core.config import BASE_DIR, YT_DIR
|
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 = [
|
GLOBAL_ARGS = [
|
||||||
|
|
@ -26,8 +26,8 @@ GLOBAL_ARGS = [
|
||||||
|
|
||||||
DEFAULT_FORMAT = (
|
DEFAULT_FORMAT = (
|
||||||
"bestvideo[height<=720][height>=480]+bestaudio/"
|
"bestvideo[height<=720][height>=480]+bestaudio/"
|
||||||
# "bestvideo[height<=1080][height>=480]+bestaudio/"
|
"bestvideo[height<=1080][height>=480]+bestaudio/"
|
||||||
# "best[height>=480]"
|
"best[height>=480]"
|
||||||
)
|
)
|
||||||
|
|
||||||
def load_channels(config_path: Path, yt_dir: Path) -> list[dict]:
|
def load_channels(config_path: Path, yt_dir: Path) -> list[dict]:
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ async def update_yt():
|
||||||
preview_path = f"{COVERS}/{name}.jpg"
|
preview_path = f"{COVERS}/{name}.jpg"
|
||||||
|
|
||||||
path = str(relative_path)
|
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:
|
if filename in existing_audios:
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -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):
|
if ".Trash" in str(video) or any(part.startswith('.') for part in video.parts):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 1. Пропускаем временные файлы и аудио-дорожки yt-dlp
|
|
||||||
if ".temp." in video.name or ".f" in video.name and video.suffix == ".webm":
|
if ".temp." in video.name or ".f" in video.name and video.suffix == ".webm":
|
||||||
return False
|
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:
|
if out.exists() and out.stat().st_size > 0:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 2. Пытаемся взять ВТОРУЮ дорожку (с вопросительным знаком, чтобы не падало)
|
|
||||||
code, err = await run([
|
code, err = await run([
|
||||||
"ffmpeg", "-y", "-loglevel", "error",
|
"ffmpeg", "-y", "-loglevel", "error",
|
||||||
"-i", str(video),
|
"-i", str(video),
|
||||||
|
|
@ -38,7 +36,6 @@ async def extract_cover(video: Path, out: Path, sem: asyncio.Semaphore) -> bool:
|
||||||
])
|
])
|
||||||
|
|
||||||
if code != 0:
|
if code != 0:
|
||||||
# 3. Если второй нет, пытаемся взять ПЕРВУЮ (тоже с вопросительным знаком)
|
|
||||||
code, err = await run([
|
code, err = await run([
|
||||||
"ffmpeg", "-y", "-loglevel", "error",
|
"ffmpeg", "-y", "-loglevel", "error",
|
||||||
"-i", str(video),
|
"-i", str(video),
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,10 @@ async def update_yt():
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|
||||||
|
vkey = (channel.id, filename)
|
||||||
|
if vkey in existing_videos:
|
||||||
|
continue
|
||||||
|
|
||||||
video = Video(filename=filename, path=path, preview_path=preview_path,
|
video = Video(filename=filename, path=path, preview_path=preview_path,
|
||||||
playlist=None, channel=channel, title=title, date=date)
|
playlist=None, channel=channel, title=title, date=date)
|
||||||
videos.append(video)
|
videos.append(video)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user