mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-07 05:46:38 +08:00
feat: 支持临时登录密码并完善 Emby 媒体详情兼容
- 新增 6 位临时密码生成与验证接口,支持 TV 及客户端快速登录 - 媒体及剧集详情补充 People 演职员信息解析与返回 - 优化剧集背景图与海报标签继承机制及播放时长兜底逻辑 - 增加临时密码登录相关的单元测试
This commit is contained in:
+115
-1
@@ -3,10 +3,13 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -18,6 +21,12 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
type temporaryPasswordEntry struct {
|
||||
userID string
|
||||
username string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// AuthService handles registration, login, and JWT issuance.
|
||||
type AuthService struct {
|
||||
cfg *config.Config
|
||||
@@ -25,11 +34,21 @@ type AuthService struct {
|
||||
repo *repository.Container
|
||||
tokenSvc *TokenService
|
||||
permissionSvc *PermissionService
|
||||
|
||||
tempPassMu sync.RWMutex
|
||||
tempPasswords map[string]temporaryPasswordEntry
|
||||
}
|
||||
|
||||
// NewAuthService is the constructor.
|
||||
func NewAuthService(cfg *config.Config, log *zap.Logger, repo *repository.Container, tokenSvc *TokenService, permissionSvc *PermissionService) *AuthService {
|
||||
return &AuthService{cfg: cfg, log: log, repo: repo, tokenSvc: tokenSvc, permissionSvc: permissionSvc}
|
||||
return &AuthService{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
repo: repo,
|
||||
tokenSvc: tokenSvc,
|
||||
permissionSvc: permissionSvc,
|
||||
tempPasswords: make(map[string]temporaryPasswordEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// Common service-level errors.
|
||||
@@ -254,3 +273,98 @@ func hashPassword(p string) (string, error) {
|
||||
}
|
||||
return string(h), nil
|
||||
}
|
||||
|
||||
const temporaryPasswordTTL = 5 * time.Minute
|
||||
|
||||
// CreateTemporaryPassword 为指定用户生成一个 6 位数字的临时登录密码(有效期 5 分钟),
|
||||
// 供 Emby 电视端/客户端进行无键盘或快速输入登录。
|
||||
func (s *AuthService) CreateTemporaryPassword(ctx context.Context, userID string) (string, int, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return "", 0, errors.New("auth service unavailable")
|
||||
}
|
||||
user, err := s.repo.User.FindByID(ctx, userID)
|
||||
if err != nil || user == nil {
|
||||
return "", 0, ErrInvalidCredentials
|
||||
}
|
||||
if !user.IsActive {
|
||||
return "", 0, ErrUserInactive
|
||||
}
|
||||
if user.ExpiredAt != nil && time.Now().After(*user.ExpiredAt) {
|
||||
return "", 0, ErrUserExpired
|
||||
}
|
||||
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(900000))
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
code := fmt.Sprintf("%06d", n.Int64()+100000)
|
||||
|
||||
s.tempPassMu.Lock()
|
||||
defer s.tempPassMu.Unlock()
|
||||
now := time.Now()
|
||||
for k, v := range s.tempPasswords {
|
||||
if now.After(v.expiresAt) {
|
||||
delete(s.tempPasswords, k)
|
||||
}
|
||||
}
|
||||
s.tempPasswords[code] = temporaryPasswordEntry{
|
||||
userID: user.ID,
|
||||
username: user.Username,
|
||||
expiresAt: now.Add(temporaryPasswordTTL),
|
||||
}
|
||||
|
||||
return code, int(temporaryPasswordTTL.Seconds()), nil
|
||||
}
|
||||
|
||||
// VerifyAndConsumeTemporaryPassword 校验并消费临时登录密码(阅后即焚)。
|
||||
func (s *AuthService) VerifyAndConsumeTemporaryPassword(ctx context.Context, username, code string) (*model.User, bool) {
|
||||
if s == nil || s.repo == nil {
|
||||
return nil, false
|
||||
}
|
||||
code = strings.TrimSpace(code)
|
||||
if len(code) != 6 {
|
||||
return nil, false
|
||||
}
|
||||
s.tempPassMu.Lock()
|
||||
entry, ok := s.tempPasswords[code]
|
||||
if ok {
|
||||
delete(s.tempPasswords, code)
|
||||
}
|
||||
s.tempPassMu.Unlock()
|
||||
|
||||
if !ok || time.Now().After(entry.expiresAt) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if strings.TrimSpace(username) != "" && !strings.EqualFold(strings.TrimSpace(username), entry.username) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
user, err := s.repo.User.FindByID(ctx, entry.userID)
|
||||
if err != nil || user == nil || !user.IsActive {
|
||||
return nil, false
|
||||
}
|
||||
if user.ExpiredAt != nil && time.Now().After(*user.ExpiredAt) {
|
||||
return nil, false
|
||||
}
|
||||
return user, true
|
||||
}
|
||||
|
||||
// LoginWithTemporaryPassword 尝试使用 6 位数字临时登录密码 (OTP) 进行登录。
|
||||
func (s *AuthService) LoginWithTemporaryPassword(ctx context.Context, username, code string) (*LoginResponse, error) {
|
||||
user, ok := s.VerifyAndConsumeTemporaryPassword(ctx, username, code)
|
||||
if !ok || user == nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if s.tokenSvc == nil {
|
||||
return nil, errors.New("token service unavailable")
|
||||
}
|
||||
tokens, err := s.tokenSvc.IssuePair(ctx, user.ID, user.Role, user.Tier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &LoginResponse{
|
||||
User: user,
|
||||
Tokens: tokens,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -455,6 +459,13 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
|
||||
|
||||
runTimeTicks := int64(m.DurationSec) * 10_000_000
|
||||
durationMs := int64(m.DurationSec) * 1000
|
||||
if durationMs <= 0 && posMs > 0 {
|
||||
durationMs = posMs * 2
|
||||
if durationMs < 30*60*1000 {
|
||||
durationMs = 30 * 60 * 1000
|
||||
}
|
||||
runTimeTicks = durationMs * 10_000
|
||||
}
|
||||
played := posMs > 0 && durationMs > 0 && posMs >= durationMs*9/10
|
||||
pct := 0.0
|
||||
if durationMs > 0 {
|
||||
@@ -488,6 +499,7 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
|
||||
"ImageTags": imageTags,
|
||||
"BackdropImageTags": backdropTags,
|
||||
"Genres": splitCSV(m.Genres),
|
||||
"People": e.resolveMediaPeople(ctx, m),
|
||||
"ProviderIds": map[string]string{
|
||||
"Tmdb": intToStr(m.TMDbID),
|
||||
"Bangumi": intToStr(m.BangumiID),
|
||||
@@ -501,8 +513,92 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
|
||||
},
|
||||
"MediaSources": e.mediaSourcesForItem(ctx, m, true, false),
|
||||
}
|
||||
if primaryArtwork != "" {
|
||||
item["PrimaryImageTag"] = m.ID
|
||||
}
|
||||
if seriesID != "" {
|
||||
if sEntry, ok, _ := e.payloadSeriesEntry(ctx, seriesID); ok {
|
||||
if sEntry.posterURL != "" {
|
||||
item["SeriesPrimaryImageTag"] = seriesID
|
||||
}
|
||||
if len(backdropTags) == 0 && sEntry.backdropURL != "" {
|
||||
item["ParentBackdropItemId"] = seriesID
|
||||
item["ParentBackdropImageTags"] = []string{seriesID + "-bd"}
|
||||
}
|
||||
}
|
||||
}
|
||||
if premiered, ok := embyPremiereDate(m.ReleaseDate); ok {
|
||||
item["PremiereDate"] = premiered
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []map[string]any {
|
||||
if m == nil || strings.TrimSpace(m.Path) == "" {
|
||||
return []map[string]any{}
|
||||
}
|
||||
dir := filepath.Dir(m.Path)
|
||||
ext := filepath.Ext(m.Path)
|
||||
base := strings.TrimSuffix(m.Path, ext)
|
||||
candidates := []string{
|
||||
base + ".nfo",
|
||||
filepath.Join(dir, "movie.nfo"),
|
||||
filepath.Join(dir, "tvshow.nfo"),
|
||||
}
|
||||
|
||||
people := make([]map[string]any, 0)
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, p := range candidates {
|
||||
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
|
||||
doc, ok, err := decodeNFOFile(p)
|
||||
if err == nil && ok && doc != nil {
|
||||
for _, d := range doc.Directors {
|
||||
name := strings.TrimSpace(d)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
personID := embyPersonID(name, "Director")
|
||||
if seen[personID] {
|
||||
continue
|
||||
}
|
||||
seen[personID] = true
|
||||
people = append(people, map[string]any{
|
||||
"Id": personID,
|
||||
"Name": name,
|
||||
"Type": "Director",
|
||||
"Role": "Director",
|
||||
})
|
||||
}
|
||||
for _, a := range doc.Actors {
|
||||
name := strings.TrimSpace(a.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
personID := embyPersonID(name, "Actor")
|
||||
if seen[personID] {
|
||||
continue
|
||||
}
|
||||
seen[personID] = true
|
||||
role := strings.TrimSpace(a.Role)
|
||||
if role == "" {
|
||||
role = "Actor"
|
||||
}
|
||||
people = append(people, map[string]any{
|
||||
"Id": personID,
|
||||
"Name": name,
|
||||
"Type": "Actor",
|
||||
"Role": role,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return people
|
||||
}
|
||||
|
||||
func embyPersonID(name, roleType string) string {
|
||||
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(name)) + ":" + strings.ToLower(strings.TrimSpace(roleType))))
|
||||
return "person-" + hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
@@ -23,10 +23,17 @@ type embyLibraryTypeEntry struct {
|
||||
found bool // 库不存在时 found=false,调用方可退回计数启发式
|
||||
}
|
||||
|
||||
type embyPayloadSeriesEntry struct {
|
||||
title string
|
||||
posterURL string
|
||||
backdropURL string
|
||||
found bool
|
||||
}
|
||||
|
||||
type embyPayloadCache struct {
|
||||
mu sync.Mutex
|
||||
libTypes map[string]embyLibraryTypeEntry
|
||||
series map[string]string // series_id -> title("" 表示不存在/无标题)
|
||||
series map[string]embyPayloadSeriesEntry
|
||||
}
|
||||
|
||||
func (c *embyPayloadCache) libraryType(id string) (embyLibraryTypeEntry, bool) {
|
||||
@@ -45,14 +52,33 @@ func (c *embyPayloadCache) setLibraryType(id string, entry embyLibraryTypeEntry)
|
||||
func (c *embyPayloadCache) seriesTitle(id string) (string, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
title, ok := c.series[id]
|
||||
return title, ok
|
||||
entry, ok := c.series[id]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return entry.title, true
|
||||
}
|
||||
|
||||
func (c *embyPayloadCache) seriesEntry(id string) (embyPayloadSeriesEntry, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
entry, ok := c.series[id]
|
||||
return entry, ok
|
||||
}
|
||||
|
||||
func (c *embyPayloadCache) setSeriesEntry(id string, entry embyPayloadSeriesEntry) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.series[id] = entry
|
||||
}
|
||||
|
||||
func (c *embyPayloadCache) setSeriesTitle(id, title string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.series[id] = title
|
||||
entry := c.series[id]
|
||||
entry.title = title
|
||||
entry.found = true
|
||||
c.series[id] = entry
|
||||
}
|
||||
|
||||
// withPayloadCache attaches a fresh request-scoped cache if none exists yet.
|
||||
@@ -65,7 +91,7 @@ func (e *EmbyService) withPayloadCache(ctx context.Context) context.Context {
|
||||
}
|
||||
return context.WithValue(ctx, embyPayloadCacheKey{}, &embyPayloadCache{
|
||||
libTypes: map[string]embyLibraryTypeEntry{},
|
||||
series: map[string]string{},
|
||||
series: map[string]embyPayloadSeriesEntry{},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -113,17 +139,22 @@ func (e *EmbyService) prefetchPayloadCache(ctx context.Context, rows []model.Med
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(seriesIDs) > 0 {
|
||||
var series []model.Series
|
||||
if err := e.repo.DB.WithContext(ctx).Select("id, title").Where("id IN ?", seriesIDs).Find(&series).Error; err == nil {
|
||||
for _, s := range series {
|
||||
cache.setSeriesTitle(s.ID, s.Title)
|
||||
if len(seriesIDs) > 0 {
|
||||
var series []model.Series
|
||||
if err := e.repo.DB.WithContext(ctx).Select("id, title, poster_url, backdrop_url").Where("id IN ?", seriesIDs).Find(&series).Error; err == nil {
|
||||
for _, s := range series {
|
||||
cache.setSeriesEntry(s.ID, embyPayloadSeriesEntry{
|
||||
title: s.Title,
|
||||
posterURL: s.PosterURL,
|
||||
backdropURL: s.BackdropURL,
|
||||
found: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// payloadLibraryType resolves a library type through the request cache,
|
||||
// payloadLibraryType resolves a library type through the request cache,
|
||||
// falling back to a direct lookup when no cache is attached. found=false
|
||||
// means the library row does not exist (soft-deleted or orphaned id).
|
||||
func (e *EmbyService) payloadLibraryType(ctx context.Context, libraryID string) (typ string, found bool, err error) {
|
||||
@@ -152,24 +183,50 @@ func (e *EmbyService) payloadLibraryType(ctx context.Context, libraryID string)
|
||||
// payloadSeriesTitle resolves a series title through the request cache,
|
||||
// falling back to a direct lookup when no cache is attached.
|
||||
func (e *EmbyService) payloadSeriesTitle(ctx context.Context, seriesID string) (string, bool, error) {
|
||||
if cache, ok := ctx.Value(embyPayloadCacheKey{}).(*embyPayloadCache); ok {
|
||||
if title, hit := cache.seriesTitle(seriesID); hit {
|
||||
return title, true, nil
|
||||
}
|
||||
var s model.Series
|
||||
if err := e.repo.DB.WithContext(ctx).Select("id, title").Where("id = ?", seriesID).First(&s).Error; err != nil {
|
||||
cache.setSeriesTitle(seriesID, "")
|
||||
return "", true, nil
|
||||
}
|
||||
cache.setSeriesTitle(s.ID, s.Title)
|
||||
return s.Title, true, nil
|
||||
}
|
||||
series, err := e.repo.Series.FindByID(ctx, seriesID)
|
||||
if err != nil {
|
||||
entry, ok, err := e.payloadSeriesEntry(ctx, seriesID)
|
||||
if err != nil || !ok {
|
||||
return "", false, err
|
||||
}
|
||||
if series == nil {
|
||||
return "", false, nil
|
||||
}
|
||||
return series.Title, true, nil
|
||||
return entry.title, true, nil
|
||||
}
|
||||
|
||||
// payloadSeriesEntry resolves a series entry through the request cache,
|
||||
// falling back to a direct lookup when no cache is attached.
|
||||
func (e *EmbyService) payloadSeriesEntry(ctx context.Context, seriesID string) (embyPayloadSeriesEntry, bool, error) {
|
||||
if cache, ok := ctx.Value(embyPayloadCacheKey{}).(*embyPayloadCache); ok {
|
||||
if entry, hit := cache.seriesEntry(seriesID); hit {
|
||||
return entry, entry.found, nil
|
||||
}
|
||||
if e.repo != nil && e.repo.Series != nil {
|
||||
s, err := e.repo.Series.FindByID(ctx, seriesID)
|
||||
if err != nil || s == nil {
|
||||
cache.setSeriesEntry(seriesID, embyPayloadSeriesEntry{})
|
||||
return embyPayloadSeriesEntry{}, false, err
|
||||
}
|
||||
entry := embyPayloadSeriesEntry{
|
||||
title: s.Title,
|
||||
posterURL: s.PosterURL,
|
||||
backdropURL: s.BackdropURL,
|
||||
found: true,
|
||||
}
|
||||
cache.setSeriesEntry(seriesID, entry)
|
||||
return entry, true, nil
|
||||
}
|
||||
}
|
||||
if e.repo != nil && e.repo.Series != nil {
|
||||
series, err := e.repo.Series.FindByID(ctx, seriesID)
|
||||
if err != nil {
|
||||
return embyPayloadSeriesEntry{}, false, err
|
||||
}
|
||||
if series == nil {
|
||||
return embyPayloadSeriesEntry{}, false, nil
|
||||
}
|
||||
return embyPayloadSeriesEntry{
|
||||
title: series.Title,
|
||||
posterURL: series.PosterURL,
|
||||
backdropURL: series.BackdropURL,
|
||||
found: true,
|
||||
}, true, nil
|
||||
}
|
||||
return embyPayloadSeriesEntry{}, false, nil
|
||||
}
|
||||
|
||||
@@ -27,16 +27,20 @@ func (e *EmbyService) seriesPayload(group embySeriesGroup) map[string]any {
|
||||
"CommunityRating": group.Rating,
|
||||
"RecursiveItemCount": len(group.Episodes),
|
||||
"ChildCount": len(e.seasonsForSeries(group)),
|
||||
"DateCreated": group.CreatedAt,
|
||||
"DateLastMediaAdded": lastMediaAdded,
|
||||
"ImageTags": imageTags,
|
||||
"BackdropImageTags": backdropTags,
|
||||
"DateCreated": group.CreatedAt,
|
||||
"DateLastMediaAdded": lastMediaAdded,
|
||||
"ImageTags": imageTags,
|
||||
"BackdropImageTags": backdropTags,
|
||||
"People": []map[string]any{},
|
||||
"ProviderIds": map[string]string{
|
||||
"Tmdb": intToStr(group.TMDbID),
|
||||
"Bangumi": intToStr(group.BangumiID),
|
||||
},
|
||||
"UserData": emptyUserData(),
|
||||
}
|
||||
if group.PosterURL != "" {
|
||||
item["PrimaryImageTag"] = group.ID
|
||||
}
|
||||
if premiered, ok := embyPremiereDate(group.ReleaseDate); ok {
|
||||
item["PremiereDate"] = premiered
|
||||
}
|
||||
@@ -53,7 +57,7 @@ func (e *EmbyService) seasonPayload(season embySeasonGroup) map[string]any {
|
||||
if season.Series.BackdropURL != "" {
|
||||
backdropTags = append(backdropTags, season.ID+"-bd")
|
||||
}
|
||||
return map[string]any{
|
||||
item := map[string]any{
|
||||
"Id": season.ID,
|
||||
"Name": season.Name,
|
||||
"ServerId": embyServerID,
|
||||
@@ -67,6 +71,16 @@ func (e *EmbyService) seasonPayload(season embySeasonGroup) map[string]any {
|
||||
"ChildCount": len(season.Episodes),
|
||||
"ImageTags": imageTags,
|
||||
"BackdropImageTags": backdropTags,
|
||||
"People": []map[string]any{},
|
||||
"UserData": emptyUserData(),
|
||||
}
|
||||
if season.Series.PosterURL != "" {
|
||||
item["PrimaryImageTag"] = season.ID
|
||||
item["SeriesPrimaryImageTag"] = season.Series.ID
|
||||
}
|
||||
if season.Series.BackdropURL != "" {
|
||||
item["ParentBackdropItemId"] = season.Series.ID
|
||||
item["ParentBackdropImageTags"] = []string{season.Series.ID + "-bd"}
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user