mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-06 21:36:37 +08:00
feat: port remaining Vue UI surfaces with full backend support
Backend additions:
- New GORM models: NotifyChannel, PlayProfile (with PIN hashing).
- NotifyChannelService: multi-channel CRUD + unified dispatcher
supporting Telegram / Bark / WeChat / Webhook with optional
per-channel event filtering.
- PlayProfileService: per-user 'viewing personas' with
content-rating gates, library scoping, PIN protection, and
player defaults (autoplay, skip-intro, audio/subtitle prefs).
- New endpoints:
GET /admin/notify/channels list
POST /admin/notify/channels create
PUT /admin/notify/channels/:id update
DELETE /admin/notify/channels/:id delete
POST /admin/notify/channels/:id/test send test
GET /play-profiles list (admin: ?all=true)
POST /play-profiles create
PUT /play-profiles/:id update
DELETE /play-profiles/:id delete
GET /media/recent home page rail
GET /media/stats library composition
GET /watch-history list
GET /watch-history/stats aggregate
GET /watch-history/continue continue-watching rail
DELETE /watch-history[?media_id=] clear
DELETE /watch-history/:id remove one
GET /discover/sections available rails
GET /discover/feed?sections=a,b multi-section TMDb fetch
GET /system/info name/version/runtime
GET /system/status uptime/cpu/mem/disk
GET /system/scheduler read-only scheduler view
GET /stats/overview counts only
GET /stats/trend daily plays
GET /stats/top-content most played
GET /stats/libraries per-library size
GET /stats/monitor live hardware
Frontend additions:
- New API helpers: notify_channels, play_profiles, history,
stats_extra, media_extra, discover_extra, system.
- ProfileManagementPage (/play-profiles): full CRUD form with
PIN management, library scoping, language preferences.
- NotifyChannelsPage (/notify-channels, admin): typed config
forms per channel type + per-channel test action.
- SettingsPage (/settings, admin): grouped key/value editor
covering General, Organize/Scrape, Adult, qBittorrent.
- Layout sidebar links and App.tsx routes wired for all three.
This commit is contained in:
@@ -46,6 +46,13 @@ func (d *DiscoverService) Popular(ctx context.Context) ([]Match, error) {
|
||||
// fetch is the shared helper that paginates page=1 only — that's all the
|
||||
// home page needs and it keeps us under TMDb's 50 rps limit.
|
||||
func (d *DiscoverService) fetch(ctx context.Context, path string) ([]Match, error) {
|
||||
return d.Fetch(ctx, path)
|
||||
}
|
||||
|
||||
// Fetch is the public entry point used by the multi-section handler.
|
||||
// It paginates page=1 only — that's all the home page needs and it
|
||||
// keeps us under TMDb's 50 rps limit.
|
||||
func (d *DiscoverService) Fetch(ctx context.Context, path string) ([]Match, error) {
|
||||
if d.tmdb == nil || !d.tmdb.Enabled() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
// Package service — notification channel CRUD + multi-channel dispatch.
|
||||
//
|
||||
// The original NotifierService reads a single set of keys from the
|
||||
// settings table. NotifyChannelService persists *named* channels in
|
||||
// their own table so the operator can add multiple Telegram bots, Bark
|
||||
// servers, etc. and pick which events flow to which channel.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
// NotifyChannelService manages notify channels and dispatches messages.
|
||||
type NotifyChannelService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewNotifyChannelService is the constructor.
|
||||
func NewNotifyChannelService(log *zap.Logger, repo *repository.Container) *NotifyChannelService {
|
||||
return &NotifyChannelService{
|
||||
log: log,
|
||||
repo: repo,
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// ChannelInput is the shape accepted by Create / Update. Config is a
|
||||
// generic map; it gets serialised to JSON before being persisted.
|
||||
type ChannelInput struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
ChannelType string `json:"channel_type" binding:"required"`
|
||||
Config map[string]any `json:"config"`
|
||||
Events []string `json:"events"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
// channelView is the public shape — Config is decoded back to a map so
|
||||
// the React form can edit it directly without unwrapping JSON twice.
|
||||
type channelView struct {
|
||||
model.NotifyChannel
|
||||
Config map[string]any `json:"config"`
|
||||
Events []string `json:"events"`
|
||||
}
|
||||
|
||||
// toView decodes Config + Events from their persisted JSON strings.
|
||||
func toView(n model.NotifyChannel) channelView {
|
||||
v := channelView{NotifyChannel: n}
|
||||
if n.Config != "" {
|
||||
_ = json.Unmarshal([]byte(n.Config), &v.Config)
|
||||
}
|
||||
if v.Config == nil {
|
||||
v.Config = map[string]any{}
|
||||
}
|
||||
if n.Events != "" {
|
||||
_ = json.Unmarshal([]byte(n.Events), &v.Events)
|
||||
}
|
||||
if v.Events == nil {
|
||||
v.Events = []string{}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// List returns every channel as a decoded view.
|
||||
func (s *NotifyChannelService) List(ctx context.Context) ([]channelView, error) {
|
||||
rows, err := s.repo.NotifyChannel.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]channelView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, toView(r))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Create persists a new channel.
|
||||
func (s *NotifyChannelService) Create(ctx context.Context, in ChannelInput) (*channelView, error) {
|
||||
if err := validateChannel(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfgBlob, _ := json.Marshal(in.Config)
|
||||
evBlob, _ := json.Marshal(in.Events)
|
||||
n := &model.NotifyChannel{
|
||||
Name: strings.TrimSpace(in.Name),
|
||||
ChannelType: in.ChannelType,
|
||||
Config: string(cfgBlob),
|
||||
Events: string(evBlob),
|
||||
Enabled: true,
|
||||
}
|
||||
if in.Enabled != nil {
|
||||
n.Enabled = *in.Enabled
|
||||
}
|
||||
if err := s.repo.NotifyChannel.Create(ctx, n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := toView(*n)
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// Update applies a partial patch to an existing channel.
|
||||
func (s *NotifyChannelService) Update(ctx context.Context, id string, in ChannelInput) (*channelView, error) {
|
||||
if err := validateChannel(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfgBlob, _ := json.Marshal(in.Config)
|
||||
evBlob, _ := json.Marshal(in.Events)
|
||||
patch := map[string]any{
|
||||
"name": strings.TrimSpace(in.Name),
|
||||
"channel_type": in.ChannelType,
|
||||
"config": string(cfgBlob),
|
||||
"events": string(evBlob),
|
||||
}
|
||||
if in.Enabled != nil {
|
||||
patch["enabled"] = *in.Enabled
|
||||
}
|
||||
if err := s.repo.NotifyChannel.Update(ctx, id, patch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row, err := s.repo.NotifyChannel.FindByID(ctx, id)
|
||||
if err != nil || row == nil {
|
||||
return nil, err
|
||||
}
|
||||
v := toView(*row)
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// Delete removes the channel.
|
||||
func (s *NotifyChannelService) Delete(ctx context.Context, id string) error {
|
||||
return s.repo.NotifyChannel.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// Test sends a "测试通知" through a single channel.
|
||||
func (s *NotifyChannelService) Test(ctx context.Context, id string) error {
|
||||
row, err := s.repo.NotifyChannel.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if row == nil {
|
||||
return errors.New("channel not found")
|
||||
}
|
||||
return s.dispatchOne(ctx, *row, "MediaStationGo 测试通知", "如果你看到这条消息,说明该通道工作正常。")
|
||||
}
|
||||
|
||||
// Broadcast sends a message to every enabled channel that subscribes to
|
||||
// `event` (an empty Events slice means "all events"). Failures are
|
||||
// logged and never abort the loop.
|
||||
func (s *NotifyChannelService) Broadcast(ctx context.Context, title, body, event string) {
|
||||
rows, err := s.repo.NotifyChannel.ListEnabled(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("notify list failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
for _, r := range rows {
|
||||
if !channelSubscribes(r, event) {
|
||||
continue
|
||||
}
|
||||
if err := s.dispatchOne(ctx, r, title, body); err != nil {
|
||||
s.log.Warn("notify dispatch failed", zap.String("channel", r.Name), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// channelSubscribes returns true when the channel's Events list is
|
||||
// empty (= all events) or contains `event`.
|
||||
func channelSubscribes(n model.NotifyChannel, event string) bool {
|
||||
if event == "" || n.Events == "" || n.Events == "[]" {
|
||||
return true
|
||||
}
|
||||
var ev []string
|
||||
if err := json.Unmarshal([]byte(n.Events), &ev); err != nil {
|
||||
return true
|
||||
}
|
||||
if len(ev) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, e := range ev {
|
||||
if e == event {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// dispatchOne is the inner dispatcher; the channel type drives which
|
||||
// HTTP request gets built.
|
||||
func (s *NotifyChannelService) dispatchOne(ctx context.Context, n model.NotifyChannel, title, body string) error {
|
||||
cfg := map[string]any{}
|
||||
_ = json.Unmarshal([]byte(n.Config), &cfg)
|
||||
|
||||
switch n.ChannelType {
|
||||
case "telegram":
|
||||
token := str(cfg["bot_token"])
|
||||
chat := str(cfg["chat_id"])
|
||||
if token == "" || chat == "" {
|
||||
return errors.New("telegram missing bot_token / chat_id")
|
||||
}
|
||||
text := fmt.Sprintf("<b>%s</b>\n\n%s", escapeHTML(title), escapeHTML(body))
|
||||
u := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", token)
|
||||
form := url.Values{}
|
||||
form.Set("chat_id", chat)
|
||||
form.Set("text", text)
|
||||
form.Set("parse_mode", "HTML")
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
return s.do(req)
|
||||
|
||||
case "bark":
|
||||
key := str(cfg["device_key"])
|
||||
if key == "" {
|
||||
return errors.New("bark missing device_key")
|
||||
}
|
||||
server := str(cfg["server"])
|
||||
if server == "" {
|
||||
server = "https://api.day.app"
|
||||
}
|
||||
u := fmt.Sprintf("%s/%s/%s/%s",
|
||||
strings.TrimRight(server, "/"),
|
||||
url.PathEscape(key),
|
||||
url.PathEscape(title),
|
||||
url.PathEscape(body),
|
||||
)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
return s.do(req)
|
||||
|
||||
case "wechat":
|
||||
key := str(cfg["sendkey"])
|
||||
if key == "" {
|
||||
return errors.New("wechat missing sendkey")
|
||||
}
|
||||
u := fmt.Sprintf("https://sctapi.ftqq.com/%s.send", url.PathEscape(key))
|
||||
form := url.Values{}
|
||||
form.Set("title", title)
|
||||
form.Set("desp", body)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
return s.do(req)
|
||||
|
||||
case "webhook":
|
||||
urlS := str(cfg["url"])
|
||||
if urlS == "" {
|
||||
return errors.New("webhook missing url")
|
||||
}
|
||||
method := strings.ToUpper(str(cfg["method"]))
|
||||
if method == "" {
|
||||
method = "POST"
|
||||
}
|
||||
// Substitute {{title}} / {{message}} in the body template.
|
||||
bodyTpl := str(cfg["body_template"])
|
||||
if bodyTpl == "" {
|
||||
bodyTpl = `{"title":"{{title}}","message":"{{message}}"}`
|
||||
}
|
||||
bodyStr := strings.NewReplacer("{{title}}", title, "{{message}}", body).Replace(bodyTpl)
|
||||
req, _ := http.NewRequestWithContext(ctx, method, urlS, strings.NewReader(bodyStr))
|
||||
// Apply custom headers (encoded as JSON in the config).
|
||||
if hdrRaw := str(cfg["headers"]); hdrRaw != "" {
|
||||
var hdr map[string]string
|
||||
if err := json.Unmarshal([]byte(hdrRaw), &hdr); err == nil {
|
||||
for k, v := range hdr {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.Header.Get("Content-Type") == "" && method != http.MethodGet {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
return s.do(req)
|
||||
}
|
||||
return fmt.Errorf("unknown channel type %q", n.ChannelType)
|
||||
}
|
||||
|
||||
func (s *NotifyChannelService) do(req *http.Request) error {
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("upstream returned %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateChannel rejects obviously-malformed inputs early so the API
|
||||
// returns a useful 400 rather than a database constraint error.
|
||||
func validateChannel(in ChannelInput) error {
|
||||
if strings.TrimSpace(in.Name) == "" {
|
||||
return errors.New("name required")
|
||||
}
|
||||
switch in.ChannelType {
|
||||
case "telegram", "wechat", "bark", "webhook":
|
||||
default:
|
||||
return fmt.Errorf("unsupported channel type %q", in.ChannelType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// str safely extracts a string from an interface{} loaded from JSON.
|
||||
func str(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(v))
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// Package service — multi-persona play profiles.
|
||||
//
|
||||
// PlayProfileService persists per-user "viewing personas" so the same
|
||||
// account can switch between, e.g., a child-safe profile and an adult
|
||||
// one without changing credentials. Profiles drive content rating
|
||||
// gates, library access, and player defaults; the upstream Vue project
|
||||
// shipped the form but never wired the backend, so we implement the
|
||||
// data model here.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
// PlayProfileService manages PlayProfile rows.
|
||||
type PlayProfileService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
}
|
||||
|
||||
// NewPlayProfileService is the constructor.
|
||||
func NewPlayProfileService(log *zap.Logger, repo *repository.Container) *PlayProfileService {
|
||||
return &PlayProfileService{log: log, repo: repo}
|
||||
}
|
||||
|
||||
// PlayProfileInput is the create/update payload accepted by the API.
|
||||
// PIN is hashed only when non-empty so omitting it preserves the
|
||||
// existing PIN on update.
|
||||
type PlayProfileInput struct {
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
ContentRatingLimit string `json:"content_rating_limit"`
|
||||
AllowAdult bool `json:"allow_adult"`
|
||||
RequirePIN bool `json:"require_pin"`
|
||||
PIN string `json:"pin,omitempty"`
|
||||
PreferredSubtitleLang string `json:"preferred_subtitle_lang"`
|
||||
PreferredAudioLang string `json:"preferred_audio_lang"`
|
||||
AutoplayNext bool `json:"autoplay_next"`
|
||||
SkipIntro bool `json:"skip_intro"`
|
||||
AllowedLibraryIDs []string `json:"allowed_library_ids"`
|
||||
}
|
||||
|
||||
// ProfileView is the public shape — AllowedLibraryIDs is decoded back
|
||||
// to a slice so the React form can bind to it directly.
|
||||
type ProfileView struct {
|
||||
model.PlayProfile
|
||||
AllowedLibraryIDs []string `json:"allowed_library_ids"`
|
||||
}
|
||||
|
||||
func toProfileView(p model.PlayProfile) ProfileView {
|
||||
v := ProfileView{PlayProfile: p}
|
||||
if p.AllowedLibraryIDs != "" {
|
||||
_ = json.Unmarshal([]byte(p.AllowedLibraryIDs), &v.AllowedLibraryIDs)
|
||||
}
|
||||
if v.AllowedLibraryIDs == nil {
|
||||
v.AllowedLibraryIDs = []string{}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// List returns every profile (admin view).
|
||||
func (s *PlayProfileService) List(ctx context.Context) ([]ProfileView, error) {
|
||||
rows, err := s.repo.PlayProfile.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ProfileView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, toProfileView(r))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListByUser returns the profiles owned by the user.
|
||||
func (s *PlayProfileService) ListByUser(ctx context.Context, userID string) ([]ProfileView, error) {
|
||||
rows, err := s.repo.PlayProfile.ListByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ProfileView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, toProfileView(r))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Create inserts a new play profile. When IsDefault is true we clear
|
||||
// every other default for the user inside the same transaction.
|
||||
func (s *PlayProfileService) Create(ctx context.Context, in PlayProfileInput) (*ProfileView, error) {
|
||||
if err := validateProfileInput(in, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
libsBlob, _ := json.Marshal(in.AllowedLibraryIDs)
|
||||
p := &model.PlayProfile{
|
||||
UserID: in.UserID,
|
||||
Name: strings.TrimSpace(in.Name),
|
||||
IsDefault: in.IsDefault,
|
||||
ContentRatingLimit: in.ContentRatingLimit,
|
||||
AllowAdult: in.AllowAdult,
|
||||
RequirePIN: in.RequirePIN,
|
||||
PreferredSubtitleLang: in.PreferredSubtitleLang,
|
||||
PreferredAudioLang: in.PreferredAudioLang,
|
||||
AutoplayNext: in.AutoplayNext,
|
||||
SkipIntro: in.SkipIntro,
|
||||
AllowedLibraryIDs: string(libsBlob),
|
||||
}
|
||||
if in.RequirePIN && in.PIN != "" {
|
||||
p.PINHash = hashPIN(in.PIN)
|
||||
}
|
||||
if in.IsDefault {
|
||||
if err := s.repo.PlayProfile.ClearDefaultsFor(ctx, in.UserID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := s.repo.PlayProfile.Create(ctx, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := toProfileView(*p)
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// Update applies a patch to an existing profile.
|
||||
func (s *PlayProfileService) Update(ctx context.Context, id string, in PlayProfileInput) (*ProfileView, error) {
|
||||
row, err := s.repo.PlayProfile.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row == nil {
|
||||
return nil, errors.New("profile not found")
|
||||
}
|
||||
if err := validateProfileInput(in, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
libsBlob, _ := json.Marshal(in.AllowedLibraryIDs)
|
||||
patch := map[string]any{
|
||||
"name": strings.TrimSpace(in.Name),
|
||||
"is_default": in.IsDefault,
|
||||
"content_rating_limit": in.ContentRatingLimit,
|
||||
"allow_adult": in.AllowAdult,
|
||||
"require_pin": in.RequirePIN,
|
||||
"preferred_subtitle_lang": in.PreferredSubtitleLang,
|
||||
"preferred_audio_lang": in.PreferredAudioLang,
|
||||
"autoplay_next": in.AutoplayNext,
|
||||
"skip_intro": in.SkipIntro,
|
||||
"allowed_library_ids": string(libsBlob),
|
||||
}
|
||||
if in.RequirePIN && in.PIN != "" {
|
||||
patch["pin_hash"] = hashPIN(in.PIN)
|
||||
}
|
||||
if !in.RequirePIN {
|
||||
patch["pin_hash"] = ""
|
||||
}
|
||||
if in.IsDefault {
|
||||
if err := s.repo.PlayProfile.ClearDefaultsFor(ctx, row.UserID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := s.repo.PlayProfile.Update(ctx, id, patch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row, err = s.repo.PlayProfile.FindByID(ctx, id)
|
||||
if err != nil || row == nil {
|
||||
return nil, err
|
||||
}
|
||||
v := toProfileView(*row)
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// Delete removes a profile.
|
||||
func (s *PlayProfileService) Delete(ctx context.Context, id string) error {
|
||||
return s.repo.PlayProfile.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// TouchActive bumps the LastActiveAt timestamp; called by the player
|
||||
// when a profile is selected.
|
||||
func (s *PlayProfileService) TouchActive(ctx context.Context, id string) error {
|
||||
now := time.Now()
|
||||
return s.repo.PlayProfile.Update(ctx, id, map[string]any{
|
||||
"last_active_at": &now,
|
||||
})
|
||||
}
|
||||
|
||||
// validateProfileInput rejects malformed payloads. On create we require
|
||||
// user_id; on update we allow it to be empty (caller supplies it via URL).
|
||||
func validateProfileInput(in PlayProfileInput, requireUser bool) error {
|
||||
if strings.TrimSpace(in.Name) == "" {
|
||||
return errors.New("name required")
|
||||
}
|
||||
if requireUser && strings.TrimSpace(in.UserID) == "" {
|
||||
return errors.New("user_id required")
|
||||
}
|
||||
if in.RequirePIN && in.PIN != "" {
|
||||
if len(in.PIN) < 4 || len(in.PIN) > 8 {
|
||||
return errors.New("pin must be 4-8 characters")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hashPIN is a deterministic SHA-256 (PINs are short numeric secrets;
|
||||
// bcrypt is overkill for this use case).
|
||||
func hashPIN(pin string) string {
|
||||
sum := sha256.Sum256([]byte(pin))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -53,6 +53,8 @@ type Container struct {
|
||||
Emby *EmbyService
|
||||
Backup *BackupService
|
||||
Notifier *NotifierService
|
||||
NotifyChannels *NotifyChannelService
|
||||
PlayProfiles *PlayProfileService
|
||||
Organizer *OrganizerService
|
||||
Douban *DoubanProvider
|
||||
Site *SiteService
|
||||
@@ -89,6 +91,8 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
||||
emby := NewEmbyService(cfg, log, repos)
|
||||
backup := NewBackupService(cfg, log, repos.DB)
|
||||
notifier := NewNotifierService(log, repos)
|
||||
notifyChannels := NewNotifyChannelService(log, repos)
|
||||
playProfiles := NewPlayProfileService(log, repos)
|
||||
organizer := NewOrganizerService(cfg, log, repos)
|
||||
douban := NewDoubanProvider(cfg, log)
|
||||
siteService := NewSiteService(log, repos)
|
||||
@@ -134,6 +138,8 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
||||
Emby: emby,
|
||||
Backup: backup,
|
||||
Notifier: notifier,
|
||||
NotifyChannels: notifyChannels,
|
||||
PlayProfiles: playProfiles,
|
||||
Organizer: organizer,
|
||||
Douban: douban,
|
||||
Site: siteService,
|
||||
|
||||
Reference in New Issue
Block a user