Compare commits
No commits in common. "20268f2e3d59473ca2c8321cc46e6a054f64ffdb" and "a9c23a211855c6379fe30106e0c013907f0c8ff8" have entirely different histories.
20268f2e3d
...
a9c23a2118
46
README.md
46
README.md
|
|
@ -1,46 +0,0 @@
|
||||||
# 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,3 +1,4 @@
|
||||||
|
# core/authx.py
|
||||||
from authx import AuthX, AuthXConfig
|
from authx import AuthX, AuthXConfig
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
|
|
@ -6,6 +7,7 @@ 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,17 +1,22 @@
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent # RSS/
|
||||||
|
|
||||||
|
# ---- видео (YouTube) ----
|
||||||
YT_DIR_NAME = "yt"
|
YT_DIR_NAME = "yt"
|
||||||
YT_DIR = BASE_DIR / "media" / YT_DIR_NAME
|
YT_DIR = BASE_DIR / "media" / YT_DIR_NAME # RSS/media/
|
||||||
THUMBS = "_thumbs"
|
THUMBS = "_thumbs"
|
||||||
THUMBS_DIR = YT_DIR / THUMBS
|
THUMBS_DIR = YT_DIR / THUMBS # RSS/media/yt/_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"}
|
||||||
|
|
@ -27,7 +32,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
|
COOKIE_SECURE: bool = False # True на продакшене (HTTPS)
|
||||||
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,6 +1,3 @@
|
||||||
import os
|
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
from sqlalchemy.ext.asyncio import (
|
from sqlalchemy.ext.asyncio import (
|
||||||
AsyncSession,
|
AsyncSession,
|
||||||
async_sessionmaker,
|
async_sessionmaker,
|
||||||
|
|
@ -8,20 +5,12 @@ 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_PASSWORD = os.getenv("DB_PASSWORD")
|
DB_URL = "postgresql+psycopg://ndmg:marselina5$@127.0.0.1:5432/rssdb"
|
||||||
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=${DB_NAME}
|
- POSTGRES_DB=rssdb
|
||||||
- POSTGRES_USER=${DB_USER}
|
- POSTGRES_USER=ndmg
|
||||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
- POSTGRES_PASSWORD=marselina5$
|
||||||
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: $MEILI_KEY
|
MEILI_MASTER_KEY: "super-secret-master-key-change-me"
|
||||||
MEILI_ENV: "development"
|
MEILI_ENV: "development" # production требует master key
|
||||||
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")
|
photos: Mapped[list[Photo]] = relationship(back_populates="category") # noqa: F821
|
||||||
|
|
||||||
|
|
||||||
class Photo(Base):
|
class Photo(Base):
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
|
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,8 +1,13 @@
|
||||||
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,8 +1,14 @@
|
||||||
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,8 +1,13 @@
|
||||||
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,9 +1,13 @@
|
||||||
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,6 +2,10 @@ 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,3 +1,4 @@
|
||||||
|
# search/reindex.py
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
@ -36,7 +37,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,15 +1,7 @@
|
||||||
import os
|
|
||||||
|
|
||||||
import meilisearch
|
import meilisearch
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
from ..core.config import BASE_DIR
|
MEILI_URL = "http://localhost:7700"
|
||||||
|
MEILI_KEY = "super-secret-master-key-change-me"
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
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("/"))
|
|
||||||
21
static/css/style.css
Normal file
21
static/css/style.css
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
@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%;
|
||||||
|
}
|
||||||
BIN
static/fonts/RobotoMono-Bold.ttf
Normal file
BIN
static/fonts/RobotoMono-Bold.ttf
Normal file
Binary file not shown.
39
templates/channel_detail.html
Normal file
39
templates/channel_detail.html
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<!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>
|
||||||
21
templates/channels.html
Normal file
21
templates/channels.html
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<!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>
|
||||||
23
templates/index.html
Normal file
23
templates/index.html
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<!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>
|
||||||
25
templates/playlist.html
Normal file
25
templates/playlist.html
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<!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>
|
||||||
16
templates/video_detail.html
Normal file
16
templates/video_detail.html
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<!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>
|
||||||
29
templates/videos.html
Normal file
29
templates/videos.html
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<!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>
|
||||||
|
|
@ -8,7 +8,7 @@ from telethon import TelegramClient
|
||||||
|
|
||||||
from ..core.config import PHOTOS_DIR
|
from ..core.config import PHOTOS_DIR
|
||||||
|
|
||||||
ENV_PATH = Path(__file__).resolve().parent.parent / ".env"
|
ENV_PATH = Path(__file__).resolve().parent / ".env"
|
||||||
load_dotenv(ENV_PATH)
|
load_dotenv(ENV_PATH)
|
||||||
|
|
||||||
API_ID = os.getenv("API_ID")
|
API_ID = os.getenv("API_ID")
|
||||||
|
|
@ -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:
|
except Exception as e: # noqa: BLE001
|
||||||
print(e)
|
print(e)
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
|
|
@ -61,6 +61,7 @@ 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")
|
COOKIES_PATH = Path(f"{BASE_DIR}/utils/cookies.txt") # или из config
|
||||||
|
|
||||||
|
|
||||||
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)
|
title, date = parse_preview_path(preview_path) # noqa: RUF059
|
||||||
|
|
||||||
if filename in existing_audios:
|
if filename in existing_audios:
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ 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
|
||||||
|
|
||||||
|
|
@ -27,6 +28,7 @@ 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),
|
||||||
|
|
@ -36,6 +38,7 @@ 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,10 +116,6 @@ 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