Добавил каркас приложения
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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()),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Package ingest — use-case приёма загрузки, общий для всех транспортов.
|
||||
//
|
||||
// Заглушка: реализация в фазе Ф1 (см. docs/specs/architecture.md).
|
||||
package ingest
|
||||
@@ -0,0 +1,4 @@
|
||||
// Package layout — конвенции Jellyfin, санитизация путей, хардлинкер, undo.
|
||||
//
|
||||
// Заглушка: реализация в фазе Ф3 (см. docs/specs/jellyfin-layout.md).
|
||||
package layout
|
||||
@@ -0,0 +1,4 @@
|
||||
// Package llm — провайдер LLM за интерфейсом (дискриминатор type).
|
||||
//
|
||||
// Заглушка: реализация в фазе Ф2 (см. docs/specs/recognition.md).
|
||||
package llm
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Package metadata — интерфейс баз метаданных и клиенты TMDB/TVDB (опц.).
|
||||
//
|
||||
// Заглушка: реализация в фазе Ф4 (см. docs/specs/architecture.md).
|
||||
package metadata
|
||||
@@ -0,0 +1,4 @@
|
||||
// Package qbt — клиент qBittorrent WebUI API (сессия, добавление, опрос).
|
||||
//
|
||||
// Заглушка: реализация в фазе Ф1 (см. docs/specs/architecture.md).
|
||||
package qbt
|
||||
@@ -0,0 +1,4 @@
|
||||
// Package recognize — пред-парс имени, вызов LLM и модель уверенности.
|
||||
//
|
||||
// Заглушка: реализация в фазе Ф2 (см. docs/specs/recognition.md).
|
||||
package recognize
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Package tgbot — Telegram-адаптер, парсер сообщений бота и исходящие пинги.
|
||||
//
|
||||
// Заглушка: реализация в фазе Ф5 (см. docs/specs/review-ux.md).
|
||||
package tgbot
|
||||
@@ -0,0 +1,4 @@
|
||||
// Package worker — владелец машины состояний и поллинга qBittorrent.
|
||||
//
|
||||
// Заглушка: реализация в фазе Ф1 (см. docs/specs/architecture.md).
|
||||
package worker
|
||||
Reference in New Issue
Block a user