Compare commits
2 Commits
e297f0fb84
...
b1f97c105a
| Author | SHA1 | Date | |
|---|---|---|---|
|
b1f97c105a
|
|||
|
157f626c2e
|
+5
-2
@@ -3,6 +3,9 @@
|
|||||||
# CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o jellybit ./cmd/jellybit
|
# CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o jellybit ./cmd/jellybit
|
||||||
# distroless/static несёт CA-сертификаты (HTTPS к LLM/TMDB). Пользователь
|
# distroless/static несёт CA-сертификаты (HTTPS к LLM/TMDB). Пользователь
|
||||||
# задаётся в compose (user: "1000:1000").
|
# задаётся в compose (user: "1000:1000").
|
||||||
|
#
|
||||||
|
# Тома (см. compose): /config (ro, рендерится плейбуком — восстановимо при
|
||||||
|
# деплое) + /data (SQLite, бекапить-и-не-терять).
|
||||||
FROM gcr.io/distroless/static-debian12
|
FROM gcr.io/distroless/static-debian12
|
||||||
|
|
||||||
COPY jellybit /usr/local/bin/jellybit
|
COPY jellybit /usr/local/bin/jellybit
|
||||||
@@ -10,8 +13,8 @@ COPY jellybit /usr/local/bin/jellybit
|
|||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
# В distroless нет shell/curl — проверку делает сам бинарь (порт берёт из
|
# В distroless нет shell/curl — проверку делает сам бинарь (порт берёт из
|
||||||
# /data/config.toml). compose может переопределить параметры healthcheck.
|
# /config/config.toml — дефолтный путь). compose может переопределить параметры.
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
CMD ["/usr/local/bin/jellybit", "healthcheck"]
|
CMD ["/usr/local/bin/jellybit", "healthcheck"]
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/jellybit", "--config", "/data/config.toml"]
|
ENTRYPOINT ["/usr/local/bin/jellybit", "--config", "/config/config.toml"]
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ jellybit recognize <infohash> --dry-run [--context "..."] --config ./config.toml
|
|||||||
бинарь: `jellybit healthcheck` (GET `/healthz` по порту из конфига, exit 0/1).
|
бинарь: `jellybit healthcheck` (GET `/healthz` по порту из конфига, exit 0/1).
|
||||||
|
|
||||||
Контейнер: `user 1000:1000`, порт `8080` на хост, mount `/srv/media` (единая
|
Контейнер: `user 1000:1000`, порт `8080` на хост, mount `/srv/media` (единая
|
||||||
песочница для хардлинков) + data-том с `config.toml`/SQLite; к qBittorrent —
|
песочница для хардлинков) + том `/config` (ro, `config.toml`, восстановим при
|
||||||
по сети Docker. Конкретная деплой-обвязка (плейбук, секреты) держится в
|
деплое) + data-том `/data` (SQLite, бекапить); к qBittorrent — по сети Docker.
|
||||||
отдельном приватном репозитории и в комплект не входит.
|
Конкретная деплой-обвязка (плейбук, секреты) держится в отдельном приватном
|
||||||
|
репозитории и в комплект не входит.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
// нет shell/curl: docker зовёт сам бинарь.
|
// нет shell/curl: docker зовёт сам бинарь.
|
||||||
func runHealthcheck(args []string) error {
|
func runHealthcheck(args []string) error {
|
||||||
fs := flag.NewFlagSet("healthcheck", flag.ContinueOnError)
|
fs := flag.NewFlagSet("healthcheck", flag.ContinueOnError)
|
||||||
configPath := fs.String("config", "/data/config.toml", "путь к config.toml")
|
configPath := fs.String("config", "/config/config.toml", "путь к config.toml")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import (
|
|||||||
// Только чтение: ни записи в БД, ни хардлинков.
|
// Только чтение: ни записи в БД, ни хардлинков.
|
||||||
func runRecognize(args []string) error {
|
func runRecognize(args []string) error {
|
||||||
fs := flag.NewFlagSet("recognize", flag.ContinueOnError)
|
fs := flag.NewFlagSet("recognize", flag.ContinueOnError)
|
||||||
configPath := fs.String("config", "/data/config.toml", "путь к config.toml")
|
configPath := fs.String("config", "/config/config.toml", "путь к config.toml")
|
||||||
dryRun := fs.Bool("dry-run", true, "только показать план, без изменений (единственный режим)")
|
dryRun := fs.Bool("dry-run", true, "только показать план, без изменений (единственный режим)")
|
||||||
contextStr := fs.String("context", "", "доп. текстовый контекст для распознавания")
|
contextStr := fs.String("context", "", "доп. текстовый контекст для распознавания")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import (
|
|||||||
// воркер (фоном) → HTTP-сервер; останавливается по SIGINT/SIGTERM.
|
// воркер (фоном) → HTTP-сервер; останавливается по SIGINT/SIGTERM.
|
||||||
func runServe(args []string) error {
|
func runServe(args []string) error {
|
||||||
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
||||||
configPath := fs.String("config", "/data/config.toml", "путь к config.toml")
|
configPath := fs.String("config", "/config/config.toml", "путь к config.toml")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -283,7 +283,8 @@ Jellyfin ([jellyfin-layout.md](jellyfin-layout.md)). Правила:
|
|||||||
|
|
||||||
- **qBit** — `savepath=/srv/media/downloads`, temp `/srv/media/incomplete`.
|
- **qBit** — `savepath=/srv/media/downloads`, temp `/srv/media/incomplete`.
|
||||||
- **jellybit** — читает `downloads`, пишет в `movies`/`series`; свой
|
- **jellybit** — читает `downloads`, пишет в `movies`/`series`; свой
|
||||||
SQLite/конфиг — отдельным mount'ом `/srv/applications/jellybit/data`.
|
SQLite — отдельным mount'ом `/srv/applications/jellybit/data`, конфиг —
|
||||||
|
отдельным `/srv/applications/jellybit/config`.
|
||||||
- **Jellyfin** — библиотеки указывают на `movies`/`series` (не на корень
|
- **Jellyfin** — библиотеки указывают на `movies`/`series` (не на корень
|
||||||
`/srv/media`, иначе в индекс попадут downloads/incomplete).
|
`/srv/media`, иначе в индекс попадут downloads/incomplete).
|
||||||
|
|
||||||
@@ -323,10 +324,13 @@ Jellybit работает в **docker** — в одной среде с qBittorr
|
|||||||
- **`user: "1000:1000"`**, UMASK 022 — единый системный пользователь
|
- **`user: "1000:1000"`**, UMASK 022 — единый системный пользователь
|
||||||
umbar; созданные каталоги 0755, файлы-ссылки наследуют inode источника.
|
umbar; созданные каталоги 0755, файлы-ссылки наследуют inode источника.
|
||||||
- **mount `/srv/media`** (единая песочница) — для хардлинков и move
|
- **mount `/srv/media`** (единая песочница) — для хардлинков и move
|
||||||
(см. «Пути и контейнеры»); `/srv/applications/jellybit/data` — отдельно.
|
(см. «Пути и контейнеры»); каталоги jellybit — отдельно.
|
||||||
|
- **mount конфига** `/srv/applications/jellybit/config` → `/config` (ro):
|
||||||
|
`config.toml` (0600). Восстановим при деплое (рендерит плейбук umbar) —
|
||||||
|
бекапить не нужно.
|
||||||
- **mount данных** `/srv/applications/jellybit/data` → `/data`: SQLite
|
- **mount данных** `/srv/applications/jellybit/data` → `/data`: SQLite
|
||||||
(`/data/jellybit.db`) и `config.toml`. Без него редеплой стёр бы всё
|
(`/data/jellybit.db`). Бекапить-и-не-терять — без него редеплой стёр бы
|
||||||
in-flight состояние.
|
всё in-flight состояние.
|
||||||
- **healthcheck** на `/healthz`.
|
- **healthcheck** на `/healthz`.
|
||||||
|
|
||||||
Разделение ответственности:
|
Разделение ответственности:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -340,7 +341,9 @@ func errJSON(err error) map[string]string {
|
|||||||
return map[string]string{"error": err.Error()}
|
return map[string]string{"error": err.Error()}
|
||||||
}
|
}
|
||||||
|
|
||||||
// requestLogger пишет структурированный лог по каждому запросу.
|
// requestLogger пишет структурированный лог по каждому запросу. Частые
|
||||||
|
// служебные запросы (healthcheck, GET-страницы веб-UI с авто-рефрешем) пишем
|
||||||
|
// на DEBUG, чтобы не зашумлять INFO; мутации и REST API остаются на INFO.
|
||||||
func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
|
func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -349,7 +352,7 @@ func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
|
|||||||
|
|
||||||
next.ServeHTTP(ww, r)
|
next.ServeHTTP(ww, r)
|
||||||
|
|
||||||
logger.Info("http request",
|
logger.Log(r.Context(), requestLogLevel(r), "http request",
|
||||||
"method", r.Method,
|
"method", r.Method,
|
||||||
"path", r.URL.Path,
|
"path", r.URL.Path,
|
||||||
"status", ww.Status(),
|
"status", ww.Status(),
|
||||||
@@ -360,3 +363,17 @@ func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requestLogLevel понижает уровень для частых служебных запросов: healthcheck
|
||||||
|
// и GET-страницы веб-UI (список авто-рефрешится каждые 5 с). Мутации и REST
|
||||||
|
// API (`/api/...`) остаются на INFO.
|
||||||
|
func requestLogLevel(r *http.Request) slog.Level {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/healthz":
|
||||||
|
return slog.LevelDebug
|
||||||
|
case r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api"):
|
||||||
|
return slog.LevelDebug
|
||||||
|
default:
|
||||||
|
return slog.LevelInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRequestLogLevel(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
method, path string
|
||||||
|
want slog.Level
|
||||||
|
}{
|
||||||
|
{"GET", "/healthz", slog.LevelDebug}, // healthcheck — тихо
|
||||||
|
{"GET", "/", slog.LevelDebug}, // список (авто-рефреш)
|
||||||
|
{"GET", "/review/1", slog.LevelDebug}, // страница ревью
|
||||||
|
{"GET", "/api/downloads", slog.LevelInfo}, // REST API — на INFO
|
||||||
|
{"POST", "/ui/downloads/1/apply", slog.LevelInfo}, // мутация — на INFO
|
||||||
|
{"POST", "/api/downloads", slog.LevelInfo}, // приём — на INFO
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
r := httptest.NewRequest(c.method, c.path, nil)
|
||||||
|
if got := requestLogLevel(r); got != c.want {
|
||||||
|
t.Errorf("%s %s: level=%v, want %v", c.method, c.path, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user