Initial import: secure Emby STRM exporter
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/media
|
||||
ACE_STREAM_URL=http://localhost:6878/ace/getstream?infohash={infohash}&.mp4
|
||||
@@ -0,0 +1,5 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
output/
|
||||
.env
|
||||
@@ -0,0 +1,18 @@
|
||||
# Emby STRM Exporter
|
||||
|
||||
Восстановленный и безопасно переоформленный вариант `GenerateEmbyContent.py`.
|
||||
Экспортёр читает строки медиатеки из PostgreSQL и создаёт дерево `.strm`/`.nfo`
|
||||
для Emby. Настройки и учётные данные передаются только через окружение.
|
||||
|
||||
## Подготовка
|
||||
|
||||
```bash
|
||||
python -m pip install -e .
|
||||
cp .env.example .env # заполните значения и экспортируйте их в окружение
|
||||
export DATABASE_URL='postgresql://...'
|
||||
export ACE_STREAM_URL='http://localhost:6878/ace/getstream?infohash={infohash}&.mp4'
|
||||
emby-strm-export output/ 1
|
||||
```
|
||||
|
||||
Ожидается прежняя схема с таблицами `categorys` и `torrents`. Запросы оставлены
|
||||
параметризованными; исходные пароли, IP-адреса и API-ключи не переносились.
|
||||
@@ -0,0 +1,16 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "emby-strm-exporter"
|
||||
version = "0.1.0"
|
||||
description = "Export media rows from PostgreSQL to an Emby STRM library."
|
||||
requires-python = ">=3.10"
|
||||
dependencies = ["psycopg[binary]>=3.1"]
|
||||
|
||||
[project.scripts]
|
||||
emby-strm-export = "emby_strm_exporter.cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Экспорт медиазаписей PostgreSQL в файловую библиотеку Emby."""
|
||||
@@ -0,0 +1,23 @@
|
||||
"""CLI-оболочка экспортёра: параметры среды остаются вне репозитория."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from .exporter import export_category, required_environment
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Экспортировать выбранную категорию и вывести количество файлов."""
|
||||
|
||||
parser = argparse.ArgumentParser(description="Export a PostgreSQL media category to STRM files")
|
||||
parser.add_argument("output", type=Path)
|
||||
parser.add_argument("category", type=int)
|
||||
args = parser.parse_args()
|
||||
database_url, stream_url = required_environment()
|
||||
print(f"Exported {export_category(args.output, args.category, database_url, stream_url)} media rows")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Изолированная логика экспорта без секретов и жёстко заданных путей."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg
|
||||
|
||||
|
||||
def safe_path_part(value: object) -> str:
|
||||
"""Нормализовать название фильма перед созданием каталога."""
|
||||
|
||||
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", str(value)).strip(". ") or "untitled"
|
||||
|
||||
|
||||
def export_category(output_dir: Path, category_id: int, database_url: str, stream_url: str) -> int:
|
||||
"""Экспортировать наиболее раздаваемую версию каждого названия в `.strm`."""
|
||||
|
||||
query = '''
|
||||
SELECT s.name, s.year, s.quality, s.imdb, s.infohash
|
||||
FROM torrents s
|
||||
JOIN (
|
||||
SELECT category_id, name, quality, MAX(seeds) AS seeds
|
||||
FROM torrents GROUP BY category_id, name, quality
|
||||
) best ON best.category_id = s.category_id AND best.name = s.name
|
||||
AND best.quality = s.quality AND best.seeds = s.seeds
|
||||
WHERE s.category_id = %s
|
||||
ORDER BY s.name, s.quality
|
||||
'''
|
||||
with psycopg.connect(database_url) as connection, connection.cursor() as cursor:
|
||||
cursor.execute("SELECT name FROM categorys WHERE site_code = %s", (category_id,))
|
||||
category = cursor.fetchone()
|
||||
if not category:
|
||||
raise ValueError(f"Category {category_id} was not found")
|
||||
cursor.execute(query, (category_id,))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
root = output_dir / safe_path_part(category[0])
|
||||
for name, year, quality, imdb, infohash in rows:
|
||||
title, release_year = safe_path_part(name), safe_path_part(year or "unknown")
|
||||
movie_dir = root / release_year / f"{title} ({release_year})"
|
||||
movie_dir.mkdir(parents=True, exist_ok=True)
|
||||
base_name = f"{title} ({release_year})"
|
||||
(movie_dir / f"{base_name}-{safe_path_part(quality)}.strm").write_text(
|
||||
stream_url.format(infohash=infohash) + "\n", encoding="utf-8"
|
||||
)
|
||||
if imdb:
|
||||
(movie_dir / f"{base_name}.nfo").write_text(
|
||||
f"<movie><imdbid>{imdb}</imdbid></movie>\n", encoding="utf-8"
|
||||
)
|
||||
return len(rows)
|
||||
|
||||
|
||||
def required_environment() -> tuple[str, str]:
|
||||
"""Получить обязательные настройки и не допустить запуска с пустым секретом."""
|
||||
|
||||
database_url = os.environ.get("DATABASE_URL")
|
||||
stream_url = os.environ.get("ACE_STREAM_URL")
|
||||
if not database_url or not stream_url:
|
||||
raise RuntimeError("DATABASE_URL and ACE_STREAM_URL must be set")
|
||||
if "{infohash}" not in stream_url:
|
||||
raise RuntimeError("ACE_STREAM_URL must contain {infohash}")
|
||||
return database_url, stream_url
|
||||
Reference in New Issue
Block a user