Добавил каркас приложения

This commit is contained in:
2026-06-14 11:17:01 +03:00
parent a48f39d7f0
commit ed4b4fb15e
25 changed files with 824 additions and 8 deletions
+185
View File
@@ -0,0 +1,185 @@
// Package config загружает конфигурацию jellybit из TOML-файла.
package config
import (
"errors"
"fmt"
"os"
"time"
"github.com/pelletier/go-toml/v2"
)
// Config — корневая конфигурация сервиса (см. config.example.toml).
type Config struct {
QBittorrent QBittorrent `toml:"qbittorrent"`
Paths Paths `toml:"paths"`
Storage Storage `toml:"storage"`
LLM LLM `toml:"llm"`
Metadata Metadata `toml:"metadata"`
Worker Worker `toml:"worker"`
Recognition Recognition `toml:"recognition"`
Telegram Telegram `toml:"telegram"`
HTTP HTTP `toml:"http"`
Log Log `toml:"log"`
}
// QBittorrent — доступ к qBittorrent WebUI и раскладка путей загрузок.
type QBittorrent struct {
URL string `toml:"url"`
Username string `toml:"username"`
Password string `toml:"password"`
Category string `toml:"category"`
SavePath string `toml:"savepath"`
PathMap map[string]string `toml:"path_map"`
}
// Paths — хост-пути медиа-песочницы (см. docs/specs/architecture.md).
type Paths struct {
Downloads string `toml:"downloads"`
Movies string `toml:"movies"`
Series string `toml:"series"`
}
// Storage — расположение БД.
type Storage struct {
DBPath string `toml:"db_path"`
}
// LLM — провайдер распознавания (дискриминатор type).
type LLM struct {
Type string `toml:"type"`
BaseURL string `toml:"base_url"`
APIKey string `toml:"api_key"`
Model string `toml:"model"`
Proxy string `toml:"proxy"`
Timeout Duration `toml:"timeout"`
MaxRetries int `toml:"max_retries"`
}
// Metadata — внешние базы метаданных (опциональны).
type Metadata struct {
TMDB MetadataProvider `toml:"tmdb"`
TVDB MetadataProvider `toml:"tvdb"`
}
// MetadataProvider — настройки одного провайдера метаданных.
type MetadataProvider struct {
Enabled bool `toml:"enabled"`
APIKey string `toml:"api_key"`
Proxy string `toml:"proxy"`
Timeout Duration `toml:"timeout"`
}
// Worker — параметры фонового цикла.
type Worker struct {
PollInterval Duration `toml:"poll_interval"`
StuckAfter Duration `toml:"stuck_after"`
MagnetTimeout Duration `toml:"magnet_timeout"`
}
// Recognition — пороги распознавания.
type Recognition struct {
AutoConfidenceThreshold float64 `toml:"auto_confidence_threshold"`
}
// Telegram — настройки бота (Ф5).
type Telegram struct {
Enabled bool `toml:"enabled"`
Token string `toml:"token"`
AllowedUserIDs []int64 `toml:"allowed_user_ids"`
}
// HTTP — параметры веб-сервера.
type HTTP struct {
Listen string `toml:"listen"`
TrustedSubnets []string `toml:"trusted_subnets"`
}
// Log — параметры логирования.
type Log struct {
Level string `toml:"level"`
Format string `toml:"format"`
}
// Duration — time.Duration, читаемый из TOML-строки вида "5s".
type Duration time.Duration
// UnmarshalText разбирает строку длительности (encoding.TextUnmarshaler).
func (d *Duration) UnmarshalText(text []byte) error {
v, err := time.ParseDuration(string(text))
if err != nil {
return err
}
*d = Duration(v)
return nil
}
// Std возвращает обычный time.Duration.
func (d Duration) Std() time.Duration { return time.Duration(d) }
// Default возвращает конфиг с разумными умолчаниями; значения из файла
// перекрывают их при загрузке.
func Default() *Config {
return &Config{
QBittorrent: QBittorrent{
URL: "http://qbit:8989",
Username: "admin",
Category: "jellybit",
SavePath: "/srv/media/downloads",
},
Paths: Paths{
Downloads: "/srv/media/downloads",
Movies: "/srv/media/movies",
Series: "/srv/media/series",
},
Storage: Storage{DBPath: "/data/jellybit.db"},
LLM: LLM{
Type: "openai-compat",
Timeout: Duration(120 * time.Second),
MaxRetries: 3,
},
Metadata: Metadata{
TMDB: MetadataProvider{Timeout: Duration(10 * time.Second)},
TVDB: MetadataProvider{Timeout: Duration(10 * time.Second)},
},
Worker: Worker{
PollInterval: Duration(5 * time.Second),
StuckAfter: Duration(time.Hour),
MagnetTimeout: Duration(30 * time.Minute),
},
Recognition: Recognition{AutoConfidenceThreshold: 0.85},
HTTP: HTTP{Listen: ":8080"},
Log: Log{Level: "info", Format: "json"},
}
}
// Load читает и валидирует конфиг из path.
func Load(path string) (*Config, error) {
cfg := Default()
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config %q: %w", path, err)
}
if err := toml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parse config %q: %w", path, err)
}
if err := cfg.validate(); err != nil {
return nil, fmt.Errorf("invalid config %q: %w", path, err)
}
return cfg, nil
}
func (c *Config) validate() error {
if c.HTTP.Listen == "" {
return errors.New("http.listen is empty")
}
if c.Storage.DBPath == "" {
return errors.New("storage.db_path is empty")
}
if c.LLM.Type != "openai-compat" {
return fmt.Errorf("unsupported llm.type %q (supported: openai-compat)", c.LLM.Type)
}
return nil
}
+52
View File
@@ -0,0 +1,52 @@
// Package httpapi предоставляет HTTP API и веб-UI (server-rendered + htmx).
//
// Сейчас — каркас: только /healthz. Эндпоинты приёма и ревью — в Ф1+.
package httpapi
import (
"encoding/json"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
// NewRouter собирает HTTP-обработчик сервиса.
func NewRouter(logger *slog.Logger) http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Recoverer)
r.Use(requestLogger(logger))
r.Get("/healthz", handleHealthz)
return r
}
func handleHealthz(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// requestLogger пишет структурированный лог по каждому запросу.
func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
start := time.Now()
next.ServeHTTP(ww, r)
logger.Info("http request",
"method", r.Method,
"path", r.URL.Path,
"status", ww.Status(),
"bytes", ww.BytesWritten(),
"duration_ms", time.Since(start).Milliseconds(),
"request_id", middleware.GetReqID(r.Context()),
)
})
}
}
+4
View File
@@ -0,0 +1,4 @@
// Package ingest — use-case приёма загрузки, общий для всех транспортов.
//
// Заглушка: реализация в фазе Ф1 (см. docs/specs/architecture.md).
package ingest
+4
View File
@@ -0,0 +1,4 @@
// Package layout — конвенции Jellyfin, санитизация путей, хардлинкер, undo.
//
// Заглушка: реализация в фазе Ф3 (см. docs/specs/jellyfin-layout.md).
package layout
+4
View File
@@ -0,0 +1,4 @@
// Package llm — провайдер LLM за интерфейсом (дискриминатор type).
//
// Заглушка: реализация в фазе Ф2 (см. docs/specs/recognition.md).
package llm
+34
View File
@@ -0,0 +1,34 @@
// Package logging собирает slog-логгер по настройкам из конфига.
package logging
import (
"log/slog"
"os"
"strings"
)
// New возвращает slog-логгер с указанным уровнем и форматом ("json"|"text").
func New(level, format string) *slog.Logger {
opts := &slog.HandlerOptions{Level: parseLevel(level)}
var handler slog.Handler
if strings.EqualFold(format, "text") {
handler = slog.NewTextHandler(os.Stdout, opts)
} else {
handler = slog.NewJSONHandler(os.Stdout, opts)
}
return slog.New(handler)
}
func parseLevel(level string) slog.Level {
switch strings.ToLower(level) {
case "debug":
return slog.LevelDebug
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
+4
View File
@@ -0,0 +1,4 @@
// Package metadata — интерфейс баз метаданных и клиенты TMDB/TVDB (опц.).
//
// Заглушка: реализация в фазе Ф4 (см. docs/specs/architecture.md).
package metadata
+4
View File
@@ -0,0 +1,4 @@
// Package qbt — клиент qBittorrent WebUI API (сессия, добавление, опрос).
//
// Заглушка: реализация в фазе Ф1 (см. docs/specs/architecture.md).
package qbt
+4
View File
@@ -0,0 +1,4 @@
// Package recognize — пред-парс имени, вызов LLM и модель уверенности.
//
// Заглушка: реализация в фазе Ф2 (см. docs/specs/recognition.md).
package recognize
+92
View File
@@ -0,0 +1,92 @@
-- +goose Up
CREATE TABLE download (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_type TEXT NOT NULL, -- magnet | torrent | url
source_ref TEXT NOT NULL,
context TEXT NOT NULL DEFAULT '',
infohash TEXT,
idempotency_key TEXT,
state TEXT NOT NULL,
error_code TEXT,
error_msg TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX idx_download_idempotency_key
ON download (idempotency_key)
WHERE idempotency_key IS NOT NULL;
CREATE INDEX idx_download_state ON download (state);
CREATE TABLE recognition (
id INTEGER PRIMARY KEY AUTOINCREMENT,
download_id INTEGER NOT NULL REFERENCES download (id) ON DELETE CASCADE,
attempt_no INTEGER NOT NULL DEFAULT 1,
is_current INTEGER NOT NULL DEFAULT 1, -- 0/1
media_type TEXT, -- movie | series
title TEXT,
original_title TEXT,
year INTEGER,
provider TEXT, -- tmdb | tvdb | none
provider_id TEXT,
confidence REAL,
reasons TEXT NOT NULL DEFAULT '[]', -- JSON: причины «не авто»
raw_llm TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_recognition_download ON recognition (download_id);
CREATE TABLE hint (
id INTEGER PRIMARY KEY AUTOINCREMENT,
download_id INTEGER NOT NULL REFERENCES download (id) ON DELETE CASCADE,
text TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_hint_download ON hint (download_id);
CREATE TABLE override (
id INTEGER PRIMARY KEY AUTOINCREMENT,
download_id INTEGER NOT NULL REFERENCES download (id) ON DELETE CASCADE,
field TEXT NOT NULL,
value TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (download_id, field)
);
CREATE TABLE metadata_candidate (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recognition_id INTEGER NOT NULL REFERENCES recognition (id) ON DELETE CASCADE,
provider TEXT NOT NULL,
provider_id TEXT NOT NULL,
title TEXT,
year INTEGER,
chosen INTEGER NOT NULL DEFAULT 0, -- 0/1
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_candidate_recognition ON metadata_candidate (recognition_id);
CREATE TABLE file_link (
id INTEGER PRIMARY KEY AUTOINCREMENT,
download_id INTEGER NOT NULL REFERENCES download (id) ON DELETE CASCADE,
apply_batch_id TEXT NOT NULL,
src_path TEXT NOT NULL,
dst_path TEXT NOT NULL,
kind TEXT NOT NULL, -- video | subtitle | ...
status TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_file_link_download ON file_link (download_id);
CREATE INDEX idx_file_link_batch ON file_link (apply_batch_id);
-- +goose Down
DROP TABLE file_link;
DROP TABLE metadata_candidate;
DROP TABLE override;
DROP TABLE hint;
DROP TABLE recognition;
DROP TABLE download;
+64
View File
@@ -0,0 +1,64 @@
// Package store отвечает за подключение к SQLite и миграции схемы.
package store
import (
"embed"
"fmt"
"os"
"path/filepath"
"github.com/jmoiron/sqlx"
"github.com/pressly/goose/v3"
_ "modernc.org/sqlite" // драйвер database/sql, имя "sqlite"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// Store оборачивает подключение к базе.
type Store struct {
DB *sqlx.DB
}
// Open открывает (создавая при необходимости) базу по пути dbPath,
// прогоняет миграции и возвращает готовый Store.
func Open(dbPath string) (*Store, error) {
if dir := filepath.Dir(dbPath); dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create db dir %q: %w", dir, err)
}
}
dsn := fmt.Sprintf(
"file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)",
dbPath,
)
db, err := sqlx.Connect("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", dbPath, err)
}
if err := migrate(db); err != nil {
_ = db.Close()
return nil, err
}
return &Store{DB: db}, nil
}
func migrate(db *sqlx.DB) error {
goose.SetBaseFS(migrationsFS)
goose.SetLogger(goose.NopLogger()) // не ломать JSON-логи; ошибки идут через return
if err := goose.SetDialect("sqlite3"); err != nil {
return fmt.Errorf("set goose dialect: %w", err)
}
if err := goose.Up(db.DB, "migrations"); err != nil {
return fmt.Errorf("run migrations: %w", err)
}
return nil
}
// Close закрывает подключение к базе.
func (s *Store) Close() error {
return s.DB.Close()
}
+4
View File
@@ -0,0 +1,4 @@
// Package tgbot — Telegram-адаптер, парсер сообщений бота и исходящие пинги.
//
// Заглушка: реализация в фазе Ф5 (см. docs/specs/review-ux.md).
package tgbot
+4
View File
@@ -0,0 +1,4 @@
// Package worker — владелец машины состояний и поллинга qBittorrent.
//
// Заглушка: реализация в фазе Ф1 (см. docs/specs/architecture.md).
package worker