mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
fix: secure adult visibility and telegram bot access
This commit is contained in:
@@ -238,7 +238,7 @@ mkdir -p data cache media downloads
|
||||
```bash
|
||||
cat > .env <<'EOF'
|
||||
# 固定版本;需要升级时改成新的 MediaStationGo-vX.Y.Z 后执行 docker compose pull && docker compose up -d
|
||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.20
|
||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.21
|
||||
MEDIASTATION_HTTP_PORT=18080
|
||||
|
||||
# 程序数据和缓存建议放在 MediaStationGo 部署目录下,便于备份和迁移。
|
||||
@@ -307,7 +307,7 @@ vim docker-compose.yml
|
||||
#
|
||||
# 镜像版本:
|
||||
# 默认拉取 latest;如需固定版本,创建 .env 并写入:
|
||||
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.20
|
||||
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.21
|
||||
#
|
||||
# 路径映射总览:
|
||||
# /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。
|
||||
@@ -516,7 +516,7 @@ docker compose up -d
|
||||
|
||||
```bash
|
||||
cat > .env <<'EOF'
|
||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.20
|
||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.21
|
||||
MEDIASTATION_HTTP_PORT=18080
|
||||
MEDIASTATION_DATA_DIR=./data
|
||||
MEDIASTATION_CACHE_DIR=./cache
|
||||
@@ -779,26 +779,26 @@ cd MediaStationGo
|
||||
|
||||
| 平台 | 包名示例 |
|
||||
| --- | --- |
|
||||
| Linux x86_64 | `MediaStationGo-v0.0.20-linux-amd64.tar.gz` |
|
||||
| Linux ARM64 | `MediaStationGo-v0.0.20-linux-arm64.tar.gz` |
|
||||
| Windows x86_64 | `MediaStationGo-v0.0.20-windows-amd64.zip` |
|
||||
| macOS Intel | `MediaStationGo-v0.0.20-darwin-amd64.tar.gz` |
|
||||
| macOS Apple Silicon | `MediaStationGo-v0.0.20-darwin-arm64.tar.gz` |
|
||||
| Linux x86_64 | `MediaStationGo-v0.0.21-linux-amd64.tar.gz` |
|
||||
| Linux ARM64 | `MediaStationGo-v0.0.21-linux-arm64.tar.gz` |
|
||||
| Windows x86_64 | `MediaStationGo-v0.0.21-windows-amd64.zip` |
|
||||
| macOS Intel | `MediaStationGo-v0.0.21-darwin-amd64.tar.gz` |
|
||||
| macOS Apple Silicon | `MediaStationGo-v0.0.21-darwin-arm64.tar.gz` |
|
||||
|
||||
部署步骤:
|
||||
|
||||
```bash
|
||||
# Linux 示例
|
||||
tar -xzf MediaStationGo-v0.0.20-linux-amd64.tar.gz
|
||||
cd MediaStationGo-v0.0.20-linux-amd64
|
||||
tar -xzf MediaStationGo-v0.0.21-linux-amd64.tar.gz
|
||||
cd MediaStationGo-v0.0.21-linux-amd64
|
||||
MEDIASTATION_APP_PORT=18080 ./mediastation-go
|
||||
```
|
||||
|
||||
Windows:
|
||||
|
||||
```powershell
|
||||
Expand-Archive .\MediaStationGo-v0.0.20-windows-amd64.zip
|
||||
cd .\MediaStationGo-v0.0.20-windows-amd64
|
||||
Expand-Archive .\MediaStationGo-v0.0.21-windows-amd64.zip
|
||||
cd .\MediaStationGo-v0.0.21-windows-amd64
|
||||
$env:MEDIASTATION_APP_PORT = "18080"
|
||||
.\mediastation-go.exe
|
||||
```
|
||||
|
||||
+11
-11
@@ -235,7 +235,7 @@ mkdir -p data cache media downloads
|
||||
|
||||
```bash
|
||||
cat > .env <<'EOF'
|
||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.20
|
||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.21
|
||||
MEDIASTATION_HTTP_PORT=18080
|
||||
MEDIASTATION_DATA_DIR=./data
|
||||
MEDIASTATION_CACHE_DIR=./cache
|
||||
@@ -344,7 +344,7 @@ For production, pin a specific release tag instead of using `latest`. Recommende
|
||||
|
||||
```bash
|
||||
cat > .env <<'EOF'
|
||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.20
|
||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.21
|
||||
MEDIASTATION_HTTP_PORT=18080
|
||||
MEDIASTATION_DATA_DIR=./data
|
||||
MEDIASTATION_CACHE_DIR=./cache
|
||||
@@ -593,25 +593,25 @@ Each release provides multi-platform archives:
|
||||
|
||||
| Platform | Package example |
|
||||
| --- | --- |
|
||||
| Linux x86_64 | `MediaStationGo-v0.0.20-linux-amd64.tar.gz` |
|
||||
| Linux ARM64 | `MediaStationGo-v0.0.20-linux-arm64.tar.gz` |
|
||||
| Windows x86_64 | `MediaStationGo-v0.0.20-windows-amd64.zip` |
|
||||
| macOS Intel | `MediaStationGo-v0.0.20-darwin-amd64.tar.gz` |
|
||||
| macOS Apple Silicon | `MediaStationGo-v0.0.20-darwin-arm64.tar.gz` |
|
||||
| Linux x86_64 | `MediaStationGo-v0.0.21-linux-amd64.tar.gz` |
|
||||
| Linux ARM64 | `MediaStationGo-v0.0.21-linux-arm64.tar.gz` |
|
||||
| Windows x86_64 | `MediaStationGo-v0.0.21-windows-amd64.zip` |
|
||||
| macOS Intel | `MediaStationGo-v0.0.21-darwin-amd64.tar.gz` |
|
||||
| macOS Apple Silicon | `MediaStationGo-v0.0.21-darwin-arm64.tar.gz` |
|
||||
|
||||
Linux example:
|
||||
|
||||
```bash
|
||||
tar -xzf MediaStationGo-v0.0.20-linux-amd64.tar.gz
|
||||
cd MediaStationGo-v0.0.20-linux-amd64
|
||||
tar -xzf MediaStationGo-v0.0.21-linux-amd64.tar.gz
|
||||
cd MediaStationGo-v0.0.21-linux-amd64
|
||||
MEDIASTATION_APP_PORT=18080 ./mediastation-go
|
||||
```
|
||||
|
||||
Windows example:
|
||||
|
||||
```powershell
|
||||
Expand-Archive .\MediaStationGo-v0.0.20-windows-amd64.zip
|
||||
cd .\MediaStationGo-v0.0.20-windows-amd64
|
||||
Expand-Archive .\MediaStationGo-v0.0.21-windows-amd64.zip
|
||||
cd .\MediaStationGo-v0.0.21-windows-amd64
|
||||
$env:MEDIASTATION_APP_PORT = "18080"
|
||||
.\mediastation-go.exe
|
||||
```
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
#
|
||||
# 镜像版本:
|
||||
# 默认拉取 latest;如需固定版本,创建 .env 并写入:
|
||||
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.20
|
||||
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.21
|
||||
#
|
||||
# 路径映射总览:
|
||||
# /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。
|
||||
|
||||
@@ -318,7 +318,11 @@ func embyFallbackUser(id string) gin.H {
|
||||
|
||||
func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
out, err := svc.Emby.Views(c.Request.Context())
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
out, err := svc.Emby.Views(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -335,8 +339,13 @@ func embyVirtualFoldersHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
uid := embyUserID(c)
|
||||
visibility := service.UserDefaultMediaVisibility(c.Request.Context(), svc.Repo, uid)
|
||||
out := make([]gin.H, 0, len(libs))
|
||||
for _, lib := range libs {
|
||||
if !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, lib, visibility) {
|
||||
continue
|
||||
}
|
||||
collectionType := "movies"
|
||||
switch lib.Type {
|
||||
case "tv", "anime", "variety":
|
||||
@@ -538,7 +547,11 @@ func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
|
||||
func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
out, err := svc.Emby.PlaybackInfo(c.Request.Context(), c.Param("id"))
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
out, err := svc.Emby.PlaybackInfo(c.Request.Context(), c.Param("id"), uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -555,8 +568,14 @@ func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// 直接代理到我们的 /api/stream/{id}(同一个 ServeFile)。
|
||||
func embyVideoStreamHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := embyUserID(c)
|
||||
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
|
||||
if err != nil || item == nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
// 直接调用 Stream service 写入 response
|
||||
err := svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id"))
|
||||
err = svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id"))
|
||||
if err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,11 @@ type verifyPlayProfilePINReq struct {
|
||||
PIN string `json:"pin"`
|
||||
}
|
||||
|
||||
type deletePlayProfileReq struct {
|
||||
PIN string `json:"pin"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// listPlayProfilesHandler returns only the caller's own profiles.
|
||||
func listPlayProfilesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
@@ -82,7 +87,44 @@ func updatePlayProfileHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func deletePlayProfileHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
if err := svc.PlayProfiles.DeleteForUser(c.Request.Context(), c.Param("id"), toString(uid)); errors.Is(err, service.ErrPlayProfileNotFound) {
|
||||
userID := toString(uid)
|
||||
var req deletePlayProfileReq
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
profile, err := svc.Repo.PlayProfile.FindByID(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if profile == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "profile not found"})
|
||||
return
|
||||
}
|
||||
if profile.UserID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "profile forbidden"})
|
||||
return
|
||||
}
|
||||
verified := false
|
||||
if profile.RequirePIN && req.PIN != "" {
|
||||
if _, err := svc.PlayProfiles.VerifyPIN(c.Request.Context(), profile.ID, userID, req.PIN); err == nil {
|
||||
verified = true
|
||||
}
|
||||
}
|
||||
if !verified && req.Password != "" {
|
||||
if err := svc.Auth.VerifyPassword(c.Request.Context(), userID, req.Password); err == nil {
|
||||
verified = true
|
||||
}
|
||||
}
|
||||
if !verified {
|
||||
if profile.RequirePIN {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "删除此 Profile 需要输入 PIN 或当前账号密码"})
|
||||
} else {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "删除此 Profile 需要输入当前账号密码"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := svc.PlayProfiles.DeleteForUser(c.Request.Context(), profile.ID, userID); errors.Is(err, service.ErrPlayProfileNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "profile not found"})
|
||||
return
|
||||
} else if errors.Is(err, service.ErrPlayProfileForbidden) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -18,8 +19,19 @@ func updateProfileHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
u, err := svc.Profile.UpdateProfile(c.Request.Context(), uid.(string), patch)
|
||||
userID := uid.(string)
|
||||
if patch.HideAdult != nil {
|
||||
if err := svc.Auth.VerifyPassword(c.Request.Context(), userID, patch.Password); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "需要输入当前账号密码确认"})
|
||||
return
|
||||
}
|
||||
}
|
||||
u, err := svc.Profile.UpdateProfile(c.Request.Context(), userID, patch)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrUsernameTaken) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "username already taken"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -18,8 +17,10 @@ import (
|
||||
)
|
||||
|
||||
func mediaVisibilityForRequest(c *gin.Context, svc *service.Container) service.MediaVisibility {
|
||||
adultEnabled := settingBool(c, svc, "adult.enabled", false)
|
||||
visibility := service.MediaVisibility{IncludeNSFW: adultEnabled}
|
||||
userID := currentUserID(c)
|
||||
adultEnabled := service.AdultContentEnabled(c.Request.Context(), svc.Repo)
|
||||
userHidesAdult := service.UserHidesAdult(c.Request.Context(), svc.Repo, userID)
|
||||
visibility := service.UserDefaultMediaVisibility(c.Request.Context(), svc.Repo, userID)
|
||||
profile, locked := selectedPlayProfile(c, svc)
|
||||
if locked {
|
||||
return service.MediaVisibility{
|
||||
@@ -30,7 +31,7 @@ func mediaVisibilityForRequest(c *gin.Context, svc *service.Container) service.M
|
||||
if profile == nil {
|
||||
return visibility
|
||||
}
|
||||
visibility.IncludeNSFW = adultEnabled && profile.AllowAdult
|
||||
visibility.IncludeNSFW = adultEnabled && profile.AllowAdult && !userHidesAdult
|
||||
visibility.AllowedLibraryIDs = profileAllowedLibraryIDs(*profile)
|
||||
return visibility
|
||||
}
|
||||
@@ -99,14 +100,7 @@ func currentUserID(c *gin.Context) string {
|
||||
}
|
||||
|
||||
func profileAllowedLibraryIDs(profile model.PlayProfile) []string {
|
||||
if strings.TrimSpace(profile.AllowedLibraryIDs) == "" {
|
||||
return nil
|
||||
}
|
||||
var ids []string
|
||||
if err := json.Unmarshal([]byte(profile.AllowedLibraryIDs), &ids); err != nil {
|
||||
return nil
|
||||
}
|
||||
return ids
|
||||
return service.DecodeAllowedLibraryIDs(profile.AllowedLibraryIDs)
|
||||
}
|
||||
|
||||
func signPlayProfilePINToken(svc *service.Container, userID, profileID string, expiresAt time.Time) string {
|
||||
|
||||
@@ -40,6 +40,7 @@ type User struct {
|
||||
Nickname string `gorm:"size:128" json:"nickname,omitempty"`
|
||||
Email string `gorm:"size:128" json:"email,omitempty"`
|
||||
AvatarURL string `gorm:"size:255" json:"avatar_url,omitempty"`
|
||||
HideAdult bool `gorm:"default:false" json:"hide_adult"`
|
||||
ForcePasswordReset bool `gorm:"default:false" json:"force_password_reset"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||
@@ -318,6 +319,7 @@ func AllModels() []interface{} {
|
||||
&ApiConfig{},
|
||||
&DownloadClient{},
|
||||
&NotifyChannel{},
|
||||
&TelegramBinding{},
|
||||
&STRMRecord{},
|
||||
&PlayProfile{},
|
||||
&StorageConfig{},
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package model
|
||||
|
||||
// TelegramBinding links a Telegram account to a local MediaStationGo user.
|
||||
// The binding is password-verified when /start is used, then reused for
|
||||
// low-risk self-service actions such as toggling adult-library visibility.
|
||||
type TelegramBinding struct {
|
||||
Base
|
||||
TelegramUserID int64 `gorm:"uniqueIndex;not null" json:"telegram_user_id"`
|
||||
TelegramName string `gorm:"size:128" json:"telegram_name,omitempty"`
|
||||
ChatID int64 `gorm:"index" json:"chat_id"`
|
||||
UserID string `gorm:"index;size:36;not null" json:"user_id"`
|
||||
}
|
||||
@@ -159,6 +159,9 @@ func (s *AuthService) Login(ctx context.Context, username, password string) (*Lo
|
||||
|
||||
// ChangePassword updates the user password if the old one matches.
|
||||
func (s *AuthService) ChangePassword(ctx context.Context, userID, oldPwd, newPwd string) error {
|
||||
if strings.TrimSpace(newPwd) == "" || len(newPwd) < 6 {
|
||||
return errors.New("new password must be at least 6 characters")
|
||||
}
|
||||
u, err := s.repo.User.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -176,6 +179,23 @@ func (s *AuthService) ChangePassword(ctx context.Context, userID, oldPwd, newPwd
|
||||
return s.repo.User.UpdatePassword(ctx, userID, hash)
|
||||
}
|
||||
|
||||
// VerifyPassword checks a user's current password without mutating account
|
||||
// state. It is used for sensitive self-service actions such as hiding adult
|
||||
// libraries or deleting play profiles.
|
||||
func (s *AuthService) VerifyPassword(ctx context.Context, userID, password string) error {
|
||||
u, err := s.repo.User.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u == nil || strings.TrimSpace(password) == "" {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IssueToken signs a JWT for the given user (60min validity, includes tier).
|
||||
func (s *AuthService) IssueToken(u *model.User) (string, error) {
|
||||
claims := Claims{
|
||||
|
||||
+104
-21
@@ -169,13 +169,17 @@ func (e *EmbyService) userPayload(u *model.User) map[string]any {
|
||||
// ─── Views / MediaFolders ────────────────────────────────────────────────────
|
||||
|
||||
// Views 返回 Emby 中"虚拟根目录"——每个 library 一个条目。
|
||||
func (e *EmbyService) Views(ctx context.Context) (map[string]any, error) {
|
||||
func (e *EmbyService) Views(ctx context.Context, userID string) (map[string]any, error) {
|
||||
libs, err := e.repo.Library.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
visibility := UserDefaultMediaVisibility(ctx, e.repo, userID)
|
||||
items := make([]map[string]any, 0, len(libs))
|
||||
for _, l := range libs {
|
||||
if !LibraryVisibleForUser(ctx, e.repo, l, visibility) {
|
||||
continue
|
||||
}
|
||||
items = append(items, e.libraryAsView(&l))
|
||||
}
|
||||
return map[string]any{"Items": items, "TotalRecordCount": len(items)}, nil
|
||||
@@ -290,16 +294,16 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
|
||||
}
|
||||
|
||||
if p.ParentID == "" && p.SearchTerm == "" && !p.Recursive && len(p.IncludeItemTypes) == 0 {
|
||||
return e.Views(ctx)
|
||||
return e.Views(ctx, p.UserID)
|
||||
}
|
||||
|
||||
if season, ok, err := e.findSeasonGroup(ctx, p.ParentID); err != nil {
|
||||
if season, ok, err := e.findSeasonGroup(ctx, p.ParentID, p.UserID); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
return e.episodeItems(ctx, season.Episodes, p)
|
||||
}
|
||||
|
||||
if series, ok, err := e.findSeriesGroup(ctx, p.ParentID); err != nil {
|
||||
if series, ok, err := e.findSeriesGroup(ctx, p.ParentID, p.UserID); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
if p.Recursive || containsItemType(p.IncludeItemTypes, "Episode") {
|
||||
@@ -329,6 +333,7 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
|
||||
|
||||
func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
|
||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{})
|
||||
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
|
||||
if p.ParentID != "" {
|
||||
q = q.Where("library_id = ? OR series_id = ?", p.ParentID, p.ParentID)
|
||||
}
|
||||
@@ -375,6 +380,7 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string
|
||||
}
|
||||
|
||||
func (e *EmbyService) episodeItems(ctx context.Context, rows []model.Media, p ItemsParams) (map[string]any, error) {
|
||||
rows = e.filterMediaRowsForUser(ctx, rows, p.UserID)
|
||||
if p.SearchTerm != "" {
|
||||
filtered := rows[:0]
|
||||
needle := strings.ToLower(p.SearchTerm)
|
||||
@@ -428,14 +434,14 @@ func (e *EmbyService) payloadsForMedia(ctx context.Context, rows []model.Media,
|
||||
// Item 单条目详情。
|
||||
func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[string]any, error) {
|
||||
if strings.HasPrefix(mediaID, embyVirtualSeasonPrefix) {
|
||||
if season, ok, err := e.findSeasonGroup(ctx, mediaID); err != nil {
|
||||
if season, ok, err := e.findSeasonGroup(ctx, mediaID, userID); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
return e.seasonPayload(season), nil
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(mediaID, embyVirtualSeriesPrefix) {
|
||||
if series, ok, err := e.findSeriesGroup(ctx, mediaID); err != nil {
|
||||
if series, ok, err := e.findSeriesGroup(ctx, mediaID, userID); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
return e.seriesPayload(series), nil
|
||||
@@ -446,13 +452,16 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
|
||||
return nil, err
|
||||
}
|
||||
if m == nil {
|
||||
if series, ok, err := e.findSeriesGroup(ctx, mediaID); err != nil {
|
||||
if series, ok, err := e.findSeriesGroup(ctx, mediaID, userID); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
return e.seriesPayload(series), nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if !UserDefaultMediaVisibility(ctx, e.repo, userID).Allows(m) {
|
||||
return nil, nil
|
||||
}
|
||||
fav := false
|
||||
pos := int64(0)
|
||||
if userID != "" {
|
||||
@@ -477,6 +486,7 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
|
||||
limit = 20
|
||||
}
|
||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
|
||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||
if parentID != "" {
|
||||
if episodic, err := e.libraryIsEpisodic(ctx, parentID); err == nil && episodic {
|
||||
resp, err := e.seriesItemsForLibrary(ctx, parentID, ItemsParams{
|
||||
@@ -540,7 +550,9 @@ func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int)
|
||||
posByID[h.MediaID] = h.PositionMs
|
||||
}
|
||||
var medias []model.Media
|
||||
if err := e.repo.DB.WithContext(ctx).Where("id IN ?", ids).Find(&medias).Error; err != nil {
|
||||
q := e.repo.DB.WithContext(ctx).Where("id IN ?", ids)
|
||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||
if err := q.Find(&medias).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 维持时间倒序
|
||||
@@ -638,6 +650,7 @@ func (e *EmbyService) itemPayload(m *model.Media, fav bool, posMs int64) map[str
|
||||
|
||||
func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID string, p ItemsParams) (map[string]any, error) {
|
||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("season_num > 0 OR episode_num > 0")
|
||||
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
|
||||
if libraryID != "" {
|
||||
q = q.Where("library_id = ?", libraryID)
|
||||
}
|
||||
@@ -677,12 +690,13 @@ func (e *EmbyService) libraryIsEpisodic(ctx context.Context, libraryID string) (
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func (e *EmbyService) findSeriesGroup(ctx context.Context, id string) (embySeriesGroup, bool, error) {
|
||||
func (e *EmbyService) findSeriesGroup(ctx context.Context, id, userID string) (embySeriesGroup, bool, error) {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return embySeriesGroup{}, false, nil
|
||||
}
|
||||
var rows []model.Media
|
||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("season_num > 0 OR episode_num > 0")
|
||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||
if !strings.HasPrefix(id, embyVirtualSeriesPrefix) {
|
||||
q = q.Where("series_id = ?", id)
|
||||
}
|
||||
@@ -716,13 +730,15 @@ func (e *EmbyService) findSeriesGroup(ctx context.Context, id string) (embySerie
|
||||
return embySeriesGroup{}, false, nil
|
||||
}
|
||||
|
||||
func (e *EmbyService) findSeasonGroup(ctx context.Context, id string) (embySeasonGroup, bool, error) {
|
||||
func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (embySeasonGroup, bool, error) {
|
||||
if strings.TrimSpace(id) == "" || !strings.HasPrefix(id, embyVirtualSeasonPrefix) {
|
||||
return embySeasonGroup{}, false, nil
|
||||
}
|
||||
var rows []model.Media
|
||||
if err := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
||||
Where("season_num > 0 OR episode_num > 0").
|
||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
||||
Where("season_num > 0 OR episode_num > 0")
|
||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||
if err := q.
|
||||
Order("season_num asc, episode_num asc, created_at asc").
|
||||
Find(&rows).Error; err != nil {
|
||||
return embySeasonGroup{}, false, err
|
||||
@@ -900,14 +916,14 @@ func (e *EmbyService) ImageURL(ctx context.Context, id, imageType string) (strin
|
||||
return backdrop
|
||||
}
|
||||
if strings.HasPrefix(id, embyVirtualSeasonPrefix) {
|
||||
if season, ok, err := e.findSeasonGroup(ctx, id); err != nil {
|
||||
if season, ok, err := e.findSeasonGroup(ctx, id, ""); err != nil {
|
||||
return "", err
|
||||
} else if ok {
|
||||
return pick(season.Series.PosterURL, season.Series.BackdropURL), nil
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(id, embyVirtualSeriesPrefix) {
|
||||
if series, ok, err := e.findSeriesGroup(ctx, id); err != nil {
|
||||
if series, ok, err := e.findSeriesGroup(ctx, id, ""); err != nil {
|
||||
return "", err
|
||||
} else if ok {
|
||||
return pick(series.PosterURL, series.BackdropURL), nil
|
||||
@@ -920,7 +936,7 @@ func (e *EmbyService) ImageURL(ctx context.Context, id, imageType string) (strin
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if series, ok, err := e.findSeriesGroup(ctx, id); err != nil {
|
||||
if series, ok, err := e.findSeriesGroup(ctx, id, ""); err != nil {
|
||||
return "", err
|
||||
} else if ok {
|
||||
return pick(series.PosterURL, series.BackdropURL), nil
|
||||
@@ -1050,6 +1066,66 @@ func emptyUserData() map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EmbyService) applyUserMediaVisibility(ctx context.Context, q *gorm.DB, userID string) *gorm.DB {
|
||||
visibility := UserDefaultMediaVisibility(ctx, e.repo, userID)
|
||||
if !visibility.IncludeNSFW {
|
||||
q = q.Where("nsfw = ?", false)
|
||||
if hidden := e.hiddenLibraryIDs(ctx, visibility); len(hidden) > 0 {
|
||||
q = q.Where("library_id NOT IN ?", hidden)
|
||||
}
|
||||
}
|
||||
if len(visibility.AllowedLibraryIDs) > 0 {
|
||||
q = q.Where("library_id IN ?", visibility.AllowedLibraryIDs)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func (e *EmbyService) filterMediaRowsForUser(ctx context.Context, rows []model.Media, userID string) []model.Media {
|
||||
visibility := UserDefaultMediaVisibility(ctx, e.repo, userID)
|
||||
if visibility.IncludeNSFW && len(visibility.AllowedLibraryIDs) == 0 {
|
||||
return rows
|
||||
}
|
||||
allowed := map[string]bool{}
|
||||
for _, id := range visibility.AllowedLibraryIDs {
|
||||
allowed[id] = true
|
||||
}
|
||||
hiddenLibraries := map[string]bool{}
|
||||
for _, id := range e.hiddenLibraryIDs(ctx, visibility) {
|
||||
hiddenLibraries[id] = true
|
||||
}
|
||||
out := rows[:0]
|
||||
for _, row := range rows {
|
||||
if row.NSFW && !visibility.IncludeNSFW {
|
||||
continue
|
||||
}
|
||||
if hiddenLibraries[row.LibraryID] {
|
||||
continue
|
||||
}
|
||||
if len(allowed) > 0 && !allowed[row.LibraryID] {
|
||||
continue
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *EmbyService) hiddenLibraryIDs(ctx context.Context, visibility MediaVisibility) []string {
|
||||
if visibility.IncludeNSFW {
|
||||
return nil
|
||||
}
|
||||
libs, err := e.repo.Library.List(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, 0)
|
||||
for _, lib := range libs {
|
||||
if !LibraryVisibleForUser(ctx, e.repo, lib, visibility) {
|
||||
ids = append(ids, lib.ID)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
@@ -1067,8 +1143,8 @@ func maxInt(a, b int) int {
|
||||
// ─── Playback ────────────────────────────────────────────────────────────────
|
||||
|
||||
// PlaybackInfo returns a PlaybackInfoResponse usable by Emby clients.
|
||||
func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID string) (map[string]any, error) {
|
||||
m, err := e.playableMedia(ctx, mediaID)
|
||||
func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string) (map[string]any, error) {
|
||||
m, err := e.playableMedia(ctx, mediaID, userID)
|
||||
if err != nil || m == nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1078,18 +1154,25 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID string) (map[str
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *EmbyService) playableMedia(ctx context.Context, id string) (*model.Media, error) {
|
||||
if season, ok, err := e.findSeasonGroup(ctx, id); err != nil {
|
||||
func (e *EmbyService) playableMedia(ctx context.Context, id, userID string) (*model.Media, error) {
|
||||
if season, ok, err := e.findSeasonGroup(ctx, id, userID); err != nil {
|
||||
return nil, err
|
||||
} else if ok && len(season.Episodes) > 0 {
|
||||
return &season.Episodes[0], nil
|
||||
}
|
||||
if series, ok, err := e.findSeriesGroup(ctx, id); err != nil {
|
||||
if series, ok, err := e.findSeriesGroup(ctx, id, userID); err != nil {
|
||||
return nil, err
|
||||
} else if ok && len(series.Episodes) > 0 {
|
||||
return &series.Episodes[0], nil
|
||||
}
|
||||
return e.repo.Media.FindByID(ctx, id)
|
||||
m, err := e.repo.Media.FindByID(ctx, id)
|
||||
if err != nil || m == nil {
|
||||
return m, err
|
||||
}
|
||||
if !UserDefaultMediaVisibility(ctx, e.repo, userID).Allows(m) {
|
||||
return nil, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// mediaSource 是 /Items 与 /PlaybackInfo 共享的 MediaSource 结构。
|
||||
|
||||
@@ -87,7 +87,7 @@ func TestEmbyItemsExposeSeriesSeasonEpisodeHierarchy(t *testing.T) {
|
||||
t.Fatalf("latest should be grouped by series: %#v", latest)
|
||||
}
|
||||
|
||||
playback, err := svc.PlaybackInfo(t.Context(), seriesID)
|
||||
playback, err := svc.PlaybackInfo(t.Context(), seriesID, "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("series playback fallback: %v", err)
|
||||
}
|
||||
@@ -161,6 +161,44 @@ func TestEmbyUserPolicyDisablesDownloadsForViewers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyHidesAdultLibrariesForUserLock(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
viewer := &model.User{Username: "viewer", Role: "user", Tier: "free", IsActive: true, HideAdult: true}
|
||||
if err := svc.repo.User.Create(t.Context(), viewer); err != nil {
|
||||
t.Fatalf("create viewer: %v", err)
|
||||
}
|
||||
safe := model.Library{Name: "电影", Path: `/media/movies`, Type: "movie", Enabled: true}
|
||||
adult := model.Library{Name: "9KG 成人", Path: `/media/9KG`, Type: "movie", Enabled: true}
|
||||
if err := svc.repo.Library.Create(t.Context(), &safe); err != nil {
|
||||
t.Fatalf("create safe library: %v", err)
|
||||
}
|
||||
if err := svc.repo.Library.Create(t.Context(), &adult); err != nil {
|
||||
t.Fatalf("create adult library: %v", err)
|
||||
}
|
||||
if err := svc.repo.DB.Create(&model.Media{LibraryID: safe.ID, Title: "安全电影", Path: `/media/movies/a.mkv`}).Error; err != nil {
|
||||
t.Fatalf("create safe media: %v", err)
|
||||
}
|
||||
if err := svc.repo.DB.Create(&model.Media{LibraryID: adult.ID, Title: "成人电影", Path: `/media/9KG/a.mkv`}).Error; err != nil {
|
||||
t.Fatalf("create adult media: %v", err)
|
||||
}
|
||||
|
||||
root, err := svc.Items(t.Context(), ItemsParams{UserID: viewer.ID, Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("root items: %v", err)
|
||||
}
|
||||
items := root["Items"].([]map[string]any)
|
||||
if len(items) != 1 || items[0]["Name"] != "电影" {
|
||||
t.Fatalf("adult library should be hidden: %#v", items)
|
||||
}
|
||||
adultItems, err := svc.Items(t.Context(), ItemsParams{UserID: viewer.ID, ParentID: adult.ID, Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("adult items: %v", err)
|
||||
}
|
||||
if got := adultItems["TotalRecordCount"]; got != int64(0) {
|
||||
t.Fatalf("adult media should be hidden, total=%#v payload=%#v", got, adultItems)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestEmbyService(t *testing.T) *EmbyService {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
|
||||
@@ -26,8 +26,12 @@ func NewProfileService(log *zap.Logger, repo *repository.Container) *ProfileServ
|
||||
// ProfileUpdate is the patch object accepted by UpdateProfile. Empty
|
||||
// fields are ignored so the same payload can be reused across screens.
|
||||
type ProfileUpdate struct {
|
||||
Username *string `json:"username,omitempty"`
|
||||
Nickname *string `json:"nickname,omitempty"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||
HideAdult *bool `json:"hide_adult,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateProfile applies a non-credential patch to the user.
|
||||
@@ -35,7 +39,29 @@ func (p *ProfileService) UpdateProfile(ctx context.Context, userID string, patch
|
||||
if userID == "" {
|
||||
return nil, errors.New("missing user id")
|
||||
}
|
||||
current, err := p.repo.User.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if current == nil {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
updates := map[string]any{}
|
||||
if patch.Username != nil {
|
||||
v := strings.TrimSpace(*patch.Username)
|
||||
if v == "" {
|
||||
return nil, errors.New("username required")
|
||||
}
|
||||
if existing, err := p.repo.User.FindByUsername(ctx, v); err != nil {
|
||||
return nil, err
|
||||
} else if existing != nil && existing.ID != userID {
|
||||
return nil, ErrUsernameTaken
|
||||
}
|
||||
updates["username"] = v
|
||||
}
|
||||
if patch.Nickname != nil {
|
||||
updates["nickname"] = strings.TrimSpace(*patch.Nickname)
|
||||
}
|
||||
if patch.Email != nil {
|
||||
v := strings.TrimSpace(*patch.Email)
|
||||
updates["email"] = v
|
||||
@@ -43,6 +69,9 @@ func (p *ProfileService) UpdateProfile(ctx context.Context, userID string, patch
|
||||
if patch.AvatarURL != nil {
|
||||
updates["avatar_url"] = strings.TrimSpace(*patch.AvatarURL)
|
||||
}
|
||||
if patch.HideAdult != nil {
|
||||
updates["hide_adult"] = *patch.HideAdult
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
if err := p.repo.DB.Model(&model.User{}).Where("id = ?", userID).
|
||||
Updates(updates).Error; err != nil {
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
@@ -24,8 +26,9 @@ import (
|
||||
|
||||
// TelegramUpdate 是 Telegram Bot API 推送的 update 对象。
|
||||
type TelegramUpdate struct {
|
||||
UpdateID int `json:"update_id"`
|
||||
Message *TelegramMessage `json:"message,omitempty"`
|
||||
UpdateID int `json:"update_id"`
|
||||
Message *TelegramMessage `json:"message,omitempty"`
|
||||
CallbackQuery *TelegramCallbackQuery `json:"callback_query,omitempty"`
|
||||
}
|
||||
|
||||
// TelegramMessage 是 Telegram 消息对象。
|
||||
@@ -37,6 +40,13 @@ type TelegramMessage struct {
|
||||
Date int `json:"date"`
|
||||
}
|
||||
|
||||
type TelegramCallbackQuery struct {
|
||||
ID string `json:"id"`
|
||||
From TelegramUser `json:"from"`
|
||||
Message *TelegramMessage `json:"message,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// TelegramUser 是 Telegram 用户对象。
|
||||
type TelegramUser struct {
|
||||
ID int `json:"id"`
|
||||
@@ -50,6 +60,16 @@ type TelegramChat struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type telegramCommandReply struct {
|
||||
Text string
|
||||
Buttons [][]telegramInlineButton
|
||||
}
|
||||
|
||||
type telegramInlineButton struct {
|
||||
Text string `json:"text"`
|
||||
Data string `json:"callback_data"`
|
||||
}
|
||||
|
||||
// TelegramBotService 处理 Telegram Bot 的交互命令。
|
||||
type TelegramBotService struct {
|
||||
log *zap.Logger
|
||||
@@ -77,6 +97,10 @@ func (s *TelegramBotService) HandleWebhook(ctx context.Context, body []byte) err
|
||||
return fmt.Errorf("invalid update: %w", err)
|
||||
}
|
||||
|
||||
if update.CallbackQuery != nil {
|
||||
return s.handleCallback(ctx, update.CallbackQuery)
|
||||
}
|
||||
|
||||
if update.Message == nil || update.Message.Text == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -97,11 +121,11 @@ func (s *TelegramBotService) HandleWebhook(ctx context.Context, body []byte) err
|
||||
reply, err := s.executeCommand(ctx, channel, msg, text)
|
||||
if err != nil {
|
||||
s.log.Error("command failed", zap.Error(err))
|
||||
_ = s.reply(ctx, channel, msg.Chat.ID, "命令执行失败: "+err.Error())
|
||||
_ = s.reply(ctx, channel, msg.Chat.ID, telegramCommandReply{Text: "命令执行失败: " + err.Error()})
|
||||
return nil
|
||||
}
|
||||
|
||||
if reply != "" {
|
||||
if reply.Text != "" {
|
||||
if err := s.reply(ctx, channel, msg.Chat.ID, reply); err != nil {
|
||||
s.log.Error("reply failed", zap.Error(err))
|
||||
}
|
||||
@@ -111,57 +135,111 @@ func (s *TelegramBotService) HandleWebhook(ctx context.Context, body []byte) err
|
||||
}
|
||||
|
||||
// executeCommand 解析命令并执行。
|
||||
func (s *TelegramBotService) executeCommand(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, text string) (string, error) {
|
||||
func (s *TelegramBotService) executeCommand(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, text string) (telegramCommandReply, error) {
|
||||
parts := strings.Fields(text)
|
||||
if len(parts) == 0 {
|
||||
return "", nil
|
||||
return telegramCommandReply{}, nil
|
||||
}
|
||||
|
||||
cmd := strings.ToLower(parts[0])
|
||||
args := parts[1:]
|
||||
if msg.Chat.Type != "" && msg.Chat.Type != "private" && !s.telegramChatAllowed(channel, msg.Chat.ID) {
|
||||
return telegramCommandReply{Text: "此群组/频道未绑定到 Bot 管理入口,请在通知渠道里填写「命令群组/频道 Chat ID」。"}, nil
|
||||
}
|
||||
|
||||
switch cmd {
|
||||
case "/start":
|
||||
return s.cmdStart(msg), nil
|
||||
return s.cmdStart(ctx, msg, args), nil
|
||||
case "/help":
|
||||
return s.cmdHelp(), nil
|
||||
return telegramCommandReply{Text: s.cmdHelp(ctx, msg)}, nil
|
||||
case "/hideadult", "/hide_adult", "/adult":
|
||||
return s.cmdHideAdult(ctx, msg, args), nil
|
||||
case "/status":
|
||||
if !s.telegramUserIsAdmin(ctx, msg.From.ID) {
|
||||
return telegramCommandReply{Text: "此命令仅管理员可用。普通用户只能使用 /start 绑定账号,并通过按钮隐藏成人目录。"}, nil
|
||||
}
|
||||
return s.cmdStatus(ctx)
|
||||
case "/search":
|
||||
if !s.telegramUserIsAdmin(ctx, msg.From.ID) {
|
||||
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
|
||||
}
|
||||
return s.cmdSearch(ctx, args)
|
||||
case "/downloads":
|
||||
if !s.telegramUserIsAdmin(ctx, msg.From.ID) {
|
||||
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
|
||||
}
|
||||
return s.cmdDownloads(ctx)
|
||||
case "/stats":
|
||||
if !s.telegramUserIsAdmin(ctx, msg.From.ID) {
|
||||
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
|
||||
}
|
||||
return s.cmdStats(ctx)
|
||||
default:
|
||||
return fmt.Sprintf("未知命令: %s\n\n输入 /help 查看可用命令列表。", cmd), nil
|
||||
return telegramCommandReply{Text: fmt.Sprintf("未知命令: %s\n\n输入 /help 查看可用命令列表。", cmd)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// cmdStart 处理 /start 命令。
|
||||
func (s *TelegramBotService) cmdStart(msg *TelegramMessage) string {
|
||||
func (s *TelegramBotService) cmdStart(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply {
|
||||
name := msg.From.FirstName
|
||||
if msg.From.Username != "" {
|
||||
name = "@" + msg.From.Username
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"<b>欢迎使用 MediaStationGo</b>\n\n"+
|
||||
"你好 %s!你已成功连接到媒体中心。\n\n"+
|
||||
"<b>可用命令:</b>\n"+
|
||||
"📊 /status — 系统运行状态\n"+
|
||||
"🔍 /search 关键词 — 搜索媒体\n"+
|
||||
"📥 /downloads — 下载进度\n"+
|
||||
"📈 /stats — 媒体库统计\n"+
|
||||
"❓ /help — 帮助信息",
|
||||
name,
|
||||
)
|
||||
if len(args) == 0 {
|
||||
if binding := s.telegramBinding(ctx, msg.From.ID); binding != nil {
|
||||
user, _ := s.repo.User.FindByID(ctx, binding.UserID)
|
||||
status := "未隐藏"
|
||||
if user != nil && user.HideAdult {
|
||||
status = "已隐藏"
|
||||
}
|
||||
return telegramCommandReply{
|
||||
Text: fmt.Sprintf("<b>MediaStationGo 已绑定</b>\n\n你好 %s,当前账号:<b>%s</b>\n成人目录:<b>%s</b>", name, userNameOrFallback(user), status),
|
||||
Buttons: [][]telegramInlineButton{{{
|
||||
Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[user != nil && user.HideAdult],
|
||||
Data: "adult_toggle",
|
||||
}}},
|
||||
}
|
||||
}
|
||||
return telegramCommandReply{Text: "<b>欢迎使用 MediaStationGo</b>\n\n普通用户请先绑定账号:\n<code>/start 用户名 密码</code>\n或:<code>/start 用户名-密码</code>\n\n如果没有账号,请联系管理员注册。"}
|
||||
}
|
||||
username, password := parseStartCredentials(args)
|
||||
if username == "" || password == "" {
|
||||
return telegramCommandReply{Text: "绑定格式不正确,请使用:\n<code>/start 用户名 密码</code>\n或:<code>/start 用户名-密码</code>"}
|
||||
}
|
||||
user, err := s.repo.User.FindByUsername(ctx, username)
|
||||
if err != nil || user == nil {
|
||||
return telegramCommandReply{Text: "未找到此用户,请联系管理员注册。"}
|
||||
}
|
||||
if !user.IsActive {
|
||||
return telegramCommandReply{Text: "此账号已被禁用,请联系管理员。"}
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||
return telegramCommandReply{Text: "账号或密码错误。"}
|
||||
}
|
||||
if err := s.upsertTelegramBinding(ctx, msg, user.ID); err != nil {
|
||||
return telegramCommandReply{Text: "绑定失败:" + err.Error()}
|
||||
}
|
||||
return telegramCommandReply{
|
||||
Text: fmt.Sprintf("绑定成功:<b>%s</b>\n\n普通用户只能使用此 Bot 管理自己的成人目录隐藏状态;系统状态、搜索、下载和统计命令仅管理员可用。", user.Username),
|
||||
Buttons: [][]telegramInlineButton{{{
|
||||
Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[user.HideAdult],
|
||||
Data: "adult_toggle",
|
||||
}}},
|
||||
}
|
||||
}
|
||||
|
||||
// cmdHelp 处理 /help 命令。
|
||||
func (s *TelegramBotService) cmdHelp() string {
|
||||
func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage) string {
|
||||
if !s.telegramUserIsAdmin(ctx, msg.From.ID) {
|
||||
return "<b>MediaStationGo 用户命令</b>\n\n" +
|
||||
"<b>/start 用户名 密码</b> — 绑定账号\n" +
|
||||
"<b>/hideadult on|off</b> — 隐藏或显示成人目录\n\n" +
|
||||
"系统状态、搜索、下载列表与统计命令仅管理员可用。"
|
||||
}
|
||||
return "<b>MediaStationGo 命令列表</b>\n\n" +
|
||||
"<b>/start</b> — 开始使用\n" +
|
||||
"<b>/help</b> — 帮助信息\n" +
|
||||
"<b>/hideadult on|off</b> — 隐藏/显示当前绑定账号的成人目录\n" +
|
||||
"<b>/status</b> — 系统运行状态\n" +
|
||||
"<b>/search 关键词</b> — 搜索媒体库\n" +
|
||||
"<b>/downloads</b> — 下载列表\n" +
|
||||
@@ -174,7 +252,43 @@ func (s *TelegramBotService) cmdHelp() string {
|
||||
}
|
||||
|
||||
// cmdStatus 处理 /status 命令。
|
||||
func (s *TelegramBotService) cmdStatus(ctx context.Context) (string, error) {
|
||||
func (s *TelegramBotService) cmdHideAdult(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply {
|
||||
binding := s.telegramBinding(ctx, msg.From.ID)
|
||||
if binding == nil {
|
||||
return telegramCommandReply{Text: "请先绑定账号:<code>/start 用户名 密码</code>"}
|
||||
}
|
||||
user, err := s.repo.User.FindByID(ctx, binding.UserID)
|
||||
if err != nil || user == nil {
|
||||
return telegramCommandReply{Text: "绑定用户不存在,请重新 /start 绑定。"}
|
||||
}
|
||||
next := true
|
||||
if len(args) > 0 {
|
||||
switch strings.ToLower(strings.TrimSpace(args[0])) {
|
||||
case "off", "false", "0", "show", "显示", "关闭":
|
||||
next = false
|
||||
case "on", "true", "1", "hide", "隐藏", "开启":
|
||||
next = true
|
||||
default:
|
||||
next = !user.HideAdult
|
||||
}
|
||||
} else {
|
||||
next = !user.HideAdult
|
||||
}
|
||||
if err := s.repo.User.UpdateFields(ctx, user.ID, map[string]any{"hide_adult": next}); err != nil {
|
||||
return telegramCommandReply{Text: "更新失败:" + err.Error()}
|
||||
}
|
||||
status := map[bool]string{true: "已隐藏", false: "已显示"}[next]
|
||||
return telegramCommandReply{
|
||||
Text: "成人目录" + status + "。此设置会同步影响网页与第三方客户端。",
|
||||
Buttons: [][]telegramInlineButton{{{
|
||||
Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[next],
|
||||
Data: "adult_toggle",
|
||||
}}},
|
||||
}
|
||||
}
|
||||
|
||||
// cmdStatus 处理 /status 命令。
|
||||
func (s *TelegramBotService) cmdStatus(ctx context.Context) (telegramCommandReply, error) {
|
||||
var mediaCount int64
|
||||
s.repo.DB.Model(&model.Media{}).Count(&mediaCount)
|
||||
|
||||
@@ -182,18 +296,18 @@ func (s *TelegramBotService) cmdStatus(ctx context.Context) (string, error) {
|
||||
s.repo.DB.Raw("SELECT COALESCE(SUM(size_bytes), 0) FROM media").Scan(&totalSize)
|
||||
totalSizeGB := float64(totalSize) / 1024 / 1024 / 1024
|
||||
|
||||
return fmt.Sprintf(
|
||||
return telegramCommandReply{Text: fmt.Sprintf(
|
||||
"<b>系统运行状态</b>\n\n"+
|
||||
"🎬 媒体总数: <b>%d</b>\n"+
|
||||
"💾 存储占用: <b>%.1f GB</b>",
|
||||
mediaCount, totalSizeGB,
|
||||
), nil
|
||||
)}, nil
|
||||
}
|
||||
|
||||
// cmdSearch 处理 /search 命令。
|
||||
func (s *TelegramBotService) cmdSearch(ctx context.Context, args []string) (string, error) {
|
||||
func (s *TelegramBotService) cmdSearch(ctx context.Context, args []string) (telegramCommandReply, error) {
|
||||
if len(args) == 0 {
|
||||
return "请提供搜索关键词\n例: <code>/search 哥斯拉</code>", nil
|
||||
return telegramCommandReply{Text: "请提供搜索关键词\n例: <code>/search 哥斯拉</code>"}, nil
|
||||
}
|
||||
|
||||
keyword := strings.Join(args, " ")
|
||||
@@ -202,11 +316,11 @@ func (s *TelegramBotService) cmdSearch(ctx context.Context, args []string) (stri
|
||||
Order("year DESC").Limit(8).
|
||||
Find(&results).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
return telegramCommandReply{}, err
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return fmt.Sprintf("未找到与 <b>%s</b> 相关的媒体", keyword), nil
|
||||
return telegramCommandReply{Text: fmt.Sprintf("未找到与 <b>%s</b> 相关的媒体", keyword)}, nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
@@ -223,11 +337,11 @@ func (s *TelegramBotService) cmdSearch(ctx context.Context, args []string) (stri
|
||||
sb.WriteString(fmt.Sprintf("%d. <b>%s</b>%s%s — %s\n", i+1, m.Title, year, ep, formatSize(m.SizeBytes)))
|
||||
}
|
||||
|
||||
return sb.String(), nil
|
||||
return telegramCommandReply{Text: sb.String()}, nil
|
||||
}
|
||||
|
||||
// cmdDownloads 处理 /downloads 命令。
|
||||
func (s *TelegramBotService) cmdDownloads(ctx context.Context) (string, error) {
|
||||
func (s *TelegramBotService) cmdDownloads(ctx context.Context) (telegramCommandReply, error) {
|
||||
type Row struct {
|
||||
Title string
|
||||
Status string
|
||||
@@ -236,11 +350,11 @@ func (s *TelegramBotService) cmdDownloads(ctx context.Context) (string, error) {
|
||||
if err := s.repo.DB.Raw(
|
||||
"SELECT COALESCE(NULLIF(title,''),'下载任务') as title, COALESCE(status,'unknown') as status FROM download_tasks ORDER BY created_at DESC LIMIT 8",
|
||||
).Scan(&rows).Error; err != nil {
|
||||
return "", err
|
||||
return telegramCommandReply{}, err
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
return "当前没有下载任务。", nil
|
||||
return telegramCommandReply{Text: "当前没有下载任务。"}, nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
@@ -265,11 +379,11 @@ func (s *TelegramBotService) cmdDownloads(ctx context.Context) (string, error) {
|
||||
sb.WriteString(fmt.Sprintf("%s %s\n", icon, name))
|
||||
}
|
||||
|
||||
return sb.String(), nil
|
||||
return telegramCommandReply{Text: sb.String()}, nil
|
||||
}
|
||||
|
||||
// cmdStats 处理 /stats 命令。
|
||||
func (s *TelegramBotService) cmdStats(ctx context.Context) (string, error) {
|
||||
func (s *TelegramBotService) cmdStats(ctx context.Context) (telegramCommandReply, error) {
|
||||
var totalMedia int64
|
||||
s.repo.DB.Model(&model.Media{}).Count(&totalMedia)
|
||||
|
||||
@@ -307,7 +421,7 @@ func (s *TelegramBotService) cmdStats(ctx context.Context) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String(), nil
|
||||
return telegramCommandReply{Text: sb.String()}, nil
|
||||
}
|
||||
|
||||
// ── Polling ──
|
||||
@@ -421,7 +535,7 @@ func (s *TelegramBotService) pollLoop(ctx context.Context, botToken string) {
|
||||
// ── Message Sending ──
|
||||
|
||||
// reply 通过 Telegram Bot API 发送回复消息。
|
||||
func (s *TelegramBotService) reply(ctx context.Context, channel *model.NotifyChannel, chatID int, text string) error {
|
||||
func (s *TelegramBotService) reply(ctx context.Context, channel *model.NotifyChannel, chatID int, reply telegramCommandReply) error {
|
||||
botToken := ""
|
||||
if channel != nil {
|
||||
configStr := channel.Config
|
||||
@@ -439,9 +553,23 @@ func (s *TelegramBotService) reply(ctx context.Context, channel *model.NotifyCha
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"chat_id": strconv.Itoa(chatID),
|
||||
"text": text,
|
||||
"text": reply.Text,
|
||||
"parse_mode": "HTML",
|
||||
}
|
||||
if len(reply.Buttons) > 0 {
|
||||
keyboard := make([][]map[string]string, 0, len(reply.Buttons))
|
||||
for _, row := range reply.Buttons {
|
||||
buttons := make([]map[string]string, 0, len(row))
|
||||
for _, button := range row {
|
||||
buttons = append(buttons, map[string]string{
|
||||
"text": button.Text,
|
||||
"callback_data": button.Data,
|
||||
})
|
||||
}
|
||||
keyboard = append(keyboard, buttons)
|
||||
}
|
||||
payload["reply_markup"] = map[string]interface{}{"inline_keyboard": keyboard}
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
|
||||
@@ -482,13 +610,121 @@ func (s *TelegramBotService) findChannelByChatID(ctx context.Context, chatID int
|
||||
}
|
||||
var cfg map[string]string
|
||||
json.Unmarshal([]byte(configStr), &cfg)
|
||||
if cfg["chat_id"] == target {
|
||||
if cfg["chat_id"] == target || cfg["command_chat_id"] == target {
|
||||
return &ch
|
||||
}
|
||||
}
|
||||
if len(channels) == 1 && channels[0].Enabled {
|
||||
return &channels[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TelegramBotService) handleCallback(ctx context.Context, cb *TelegramCallbackQuery) error {
|
||||
if cb == nil || cb.Message == nil {
|
||||
return nil
|
||||
}
|
||||
channel := s.findChannelByChatID(ctx, cb.Message.Chat.ID)
|
||||
switch strings.TrimSpace(cb.Data) {
|
||||
case "adult_toggle":
|
||||
msg := *cb.Message
|
||||
msg.From = cb.From
|
||||
reply := s.cmdHideAdult(ctx, &msg, nil)
|
||||
if reply.Text != "" {
|
||||
return s.reply(ctx, channel, cb.Message.Chat.ID, reply)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TelegramBotService) telegramBinding(ctx context.Context, telegramUserID int) *model.TelegramBinding {
|
||||
if telegramUserID == 0 {
|
||||
return nil
|
||||
}
|
||||
var binding model.TelegramBinding
|
||||
err := s.repo.DB.WithContext(ctx).Where("telegram_user_id = ?", int64(telegramUserID)).First(&binding).Error
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &binding
|
||||
}
|
||||
|
||||
func (s *TelegramBotService) telegramUserIsAdmin(ctx context.Context, telegramUserID int) bool {
|
||||
binding := s.telegramBinding(ctx, telegramUserID)
|
||||
if binding == nil {
|
||||
return false
|
||||
}
|
||||
user, err := s.repo.User.FindByID(ctx, binding.UserID)
|
||||
return err == nil && user != nil && user.Role == "admin" && user.IsActive
|
||||
}
|
||||
|
||||
func (s *TelegramBotService) telegramChatAllowed(channel *model.NotifyChannel, chatID int) bool {
|
||||
if channel == nil {
|
||||
return false
|
||||
}
|
||||
configStr := channel.Config
|
||||
if s.crypto != nil && configStr != "" {
|
||||
configStr = s.crypto.Decrypt(configStr)
|
||||
}
|
||||
var cfg map[string]string
|
||||
if err := json.Unmarshal([]byte(configStr), &cfg); err != nil {
|
||||
return false
|
||||
}
|
||||
target := strconv.Itoa(chatID)
|
||||
commandChatID := strings.TrimSpace(cfg["command_chat_id"])
|
||||
if commandChatID != "" {
|
||||
return commandChatID == target
|
||||
}
|
||||
return strings.TrimSpace(cfg["chat_id"]) == target
|
||||
}
|
||||
|
||||
func (s *TelegramBotService) upsertTelegramBinding(ctx context.Context, msg *TelegramMessage, userID string) error {
|
||||
name := strings.TrimSpace(msg.From.FirstName)
|
||||
if msg.From.Username != "" {
|
||||
name = "@" + strings.TrimSpace(msg.From.Username)
|
||||
}
|
||||
var existing model.TelegramBinding
|
||||
err := s.repo.DB.WithContext(ctx).Where("telegram_user_id = ?", int64(msg.From.ID)).First(&existing).Error
|
||||
if err == nil {
|
||||
return s.repo.DB.WithContext(ctx).Model(&existing).Updates(map[string]any{
|
||||
"telegram_name": name,
|
||||
"chat_id": int64(msg.Chat.ID),
|
||||
"user_id": userID,
|
||||
}).Error
|
||||
}
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return err
|
||||
}
|
||||
return s.repo.DB.WithContext(ctx).Create(&model.TelegramBinding{
|
||||
TelegramUserID: int64(msg.From.ID),
|
||||
TelegramName: name,
|
||||
ChatID: int64(msg.Chat.ID),
|
||||
UserID: userID,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func parseStartCredentials(args []string) (string, string) {
|
||||
if len(args) >= 2 {
|
||||
return strings.TrimSpace(args[0]), strings.TrimSpace(strings.Join(args[1:], " "))
|
||||
}
|
||||
if len(args) == 1 {
|
||||
raw := strings.TrimSpace(args[0])
|
||||
for _, sep := range []string{"-", ":", ":"} {
|
||||
if parts := strings.SplitN(raw, sep, 2); len(parts) == 2 {
|
||||
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func userNameOrFallback(user *model.User) string {
|
||||
if user == nil || strings.TrimSpace(user.Username) == "" {
|
||||
return "未知用户"
|
||||
}
|
||||
return user.Username
|
||||
}
|
||||
|
||||
// ── Webhook Management ──
|
||||
|
||||
// SetWebhook 注册 Telegram Bot Webhook URL。
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
// AdultContentEnabled reads the global Adult / NSFW switch.
|
||||
func AdultContentEnabled(ctx context.Context, repo *repository.Container) bool {
|
||||
if repo == nil || repo.Setting == nil {
|
||||
return false
|
||||
}
|
||||
value, err := repo.Setting.Get(ctx, "adult.enabled")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes", "on", "enabled", "启用", "开启":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// UserHidesAdult reports whether a user's own lock overrides all profiles.
|
||||
func UserHidesAdult(ctx context.Context, repo *repository.Container, userID string) bool {
|
||||
if strings.TrimSpace(userID) == "" || repo == nil || repo.User == nil {
|
||||
return false
|
||||
}
|
||||
user, err := repo.User.FindByID(ctx, userID)
|
||||
return err == nil && user != nil && user.HideAdult
|
||||
}
|
||||
|
||||
// UserDefaultMediaVisibility is the visibility policy used by clients that
|
||||
// cannot pass a web play-profile token, notably Emby/Jellyfin-compatible apps.
|
||||
func UserDefaultMediaVisibility(ctx context.Context, repo *repository.Container, userID string) MediaVisibility {
|
||||
visibility := MediaVisibility{IncludeNSFW: AdultContentEnabled(ctx, repo)}
|
||||
if repo == nil {
|
||||
return visibility
|
||||
}
|
||||
if UserHidesAdult(ctx, repo, userID) {
|
||||
visibility.IncludeNSFW = false
|
||||
}
|
||||
if userID == "" || repo.PlayProfile == nil {
|
||||
return visibility
|
||||
}
|
||||
rows, err := repo.PlayProfile.ListByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return visibility
|
||||
}
|
||||
for _, row := range rows {
|
||||
if !row.IsDefault {
|
||||
continue
|
||||
}
|
||||
visibility.IncludeNSFW = visibility.IncludeNSFW && row.AllowAdult
|
||||
visibility.AllowedLibraryIDs = DecodeAllowedLibraryIDs(row.AllowedLibraryIDs)
|
||||
break
|
||||
}
|
||||
return visibility
|
||||
}
|
||||
|
||||
// DecodeAllowedLibraryIDs normalises a PlayProfile allowed-library JSON string.
|
||||
func DecodeAllowedLibraryIDs(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
var ids []string
|
||||
if err := json.Unmarshal([]byte(raw), &ids); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := ids[:0]
|
||||
for _, id := range ids {
|
||||
if strings.TrimSpace(id) != "" {
|
||||
out = append(out, strings.TrimSpace(id))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// LibraryVisibleForUser applies profile library limits and adult-directory
|
||||
// hiding to a library card/folder.
|
||||
func LibraryVisibleForUser(ctx context.Context, repo *repository.Container, lib model.Library, visibility MediaVisibility) bool {
|
||||
if len(visibility.AllowedLibraryIDs) > 0 {
|
||||
found := false
|
||||
for _, id := range visibility.AllowedLibraryIDs {
|
||||
if id == lib.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if visibility.IncludeNSFW {
|
||||
return true
|
||||
}
|
||||
if LibraryLooksAdult(lib) {
|
||||
return false
|
||||
}
|
||||
if repo != nil && repo.DB != nil {
|
||||
var count int64
|
||||
_ = repo.DB.WithContext(ctx).Model(&model.Media{}).
|
||||
Where("library_id = ? AND nsfw = ?", lib.ID, true).
|
||||
Count(&count).Error
|
||||
if count > 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// LibraryLooksAdult catches adult-only roots even before all rows are scraped.
|
||||
func LibraryLooksAdult(lib model.Library) bool {
|
||||
text := strings.ToLower(strings.TrimSpace(lib.Name + " " + lib.Path + " " + lib.Type))
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
for _, token := range []string{"成人", "限制级", "nsfw", "adult", "jav", "javdb", "javbus", "9kg", "里番", "番号"} {
|
||||
if strings.Contains(text, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -38,6 +38,6 @@ export const playProfilesAPI = {
|
||||
.post<PlayProfilePINVerifyResponse>(`/play-profiles/${id}/verify-pin`, { pin })
|
||||
.then((r) => r.data),
|
||||
|
||||
remove: (id: string) =>
|
||||
api.delete(`/play-profiles/${id}`).then((r) => r.data),
|
||||
remove: (id: string, proof?: { pin?: string; password?: string }) =>
|
||||
api.delete(`/play-profiles/${id}`, { data: proof ?? {} }).then((r) => r.data),
|
||||
}
|
||||
|
||||
@@ -2,7 +2,14 @@ import { api } from './client'
|
||||
import type { User } from '../types'
|
||||
|
||||
export const profileAPI = {
|
||||
update: (patch: { email?: string; avatar_url?: string }) =>
|
||||
update: (patch: {
|
||||
username?: string
|
||||
nickname?: string
|
||||
email?: string
|
||||
avatar_url?: string
|
||||
hide_adult?: boolean
|
||||
password?: string
|
||||
}) =>
|
||||
api.patch<User>('/me', patch).then((r) => r.data),
|
||||
|
||||
adminUpdateRole: (id: string, role: 'admin' | 'user') =>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { FormEvent, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { KeyRound } from 'lucide-react'
|
||||
|
||||
type PasswordOptions = {
|
||||
title?: string
|
||||
message?: string
|
||||
confirmText?: string
|
||||
}
|
||||
|
||||
export function requestPassword(options: PasswordOptions): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const root = createRoot(host)
|
||||
const close = (value: string | null) => {
|
||||
root.unmount()
|
||||
host.remove()
|
||||
resolve(value)
|
||||
}
|
||||
root.render(<PasswordDialog options={options} onClose={close} />)
|
||||
})
|
||||
}
|
||||
|
||||
function PasswordDialog({
|
||||
options,
|
||||
onClose,
|
||||
}: {
|
||||
options: PasswordOptions
|
||||
onClose: (value: string | null) => void
|
||||
}) {
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
const onSubmit = (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (!password) return
|
||||
onClose(password)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
|
||||
onClick={() => onClose(null)}
|
||||
>
|
||||
<form
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onSubmit={onSubmit}
|
||||
className="w-full max-w-sm overflow-hidden rounded-3xl border border-white/70 bg-white shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex gap-4 p-5">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-primary-400/10 text-brand-500">
|
||||
<KeyRound size={22} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-display text-lg font-bold text-ink-600">
|
||||
{options.title || '需要密码确认'}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-ink-50">
|
||||
{options.message || '请输入当前账号密码以继续。'}
|
||||
</p>
|
||||
<input
|
||||
autoFocus
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
className="mt-4 w-full rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-ink-600 outline-none transition focus:border-brand-500 focus:bg-white focus:ring-4 focus:ring-brand-100/40"
|
||||
placeholder="当前账号密码"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-gray-100 bg-gray-50/80 px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClose(null)}
|
||||
className="rounded-xl border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-ink-100 hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!password}
|
||||
className="rounded-xl bg-brand-500 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-brand-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{options.confirmText || '确认'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -183,7 +183,7 @@ function channelSummary(ch: NotifyChannel): string {
|
||||
const cfg = ch.config ?? {}
|
||||
switch (ch.type) {
|
||||
case 'telegram':
|
||||
return `Bot ${String(cfg.bot_token ?? '').slice(0, 10)}… → chat ${cfg.chat_id ?? '-'}`
|
||||
return `Bot ${String(cfg.bot_token ?? '').slice(0, 10)}… → 通知 ${cfg.chat_id ?? '-'} · 命令 ${cfg.command_chat_id ?? cfg.chat_id ?? '-'}`
|
||||
case 'wechat':
|
||||
return `SendKey ${String(cfg.sendkey ?? '').slice(0, 10)}…`
|
||||
case 'bark':
|
||||
@@ -200,7 +200,7 @@ function channelSummary(ch: NotifyChannel): string {
|
||||
// ─── Form Modal ─────────────────────────────────────────────────────────────
|
||||
|
||||
const EMPTY_CONFIG: Record<NotifyChannel['type'], Record<string, string>> = {
|
||||
telegram: { bot_token: '', chat_id: '' },
|
||||
telegram: { bot_token: '', chat_id: '', command_chat_id: '' },
|
||||
wechat: { sendkey: '' },
|
||||
bark: { device_key: '', server: '' },
|
||||
webhook: { url: '', method: 'POST', headers: '', body_template: '' },
|
||||
@@ -311,6 +311,17 @@ function ChannelFormModal({
|
||||
onChange={(e) => updateConfig('chat_id', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="命令群组/频道 Chat ID (可选)">
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="留空则使用上方 Chat ID;填写后只有该群组/频道可唤醒 Bot"
|
||||
value={config.command_chat_id ?? ''}
|
||||
onChange={(e) => updateConfig('command_chat_id', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="rounded-2xl border border-primary-400/15 bg-primary-400/5 px-4 py-3 text-xs leading-6 text-ink-50">
|
||||
普通用户只能通过 <code>/start 用户名 密码</code> 绑定账号,并使用隐藏成人目录按钮;<code>/status</code>、<code>/search</code>、<code>/downloads</code>、<code>/stats</code> 仅管理员可用。
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { playProfilesAPI, type PlayProfileInput } from '../api/play_profiles'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { usePlayProfileStore } from '../stores/playProfile'
|
||||
import { confirmAction } from '../components/ConfirmDialog'
|
||||
import { requestPassword } from '../components/PasswordDialog'
|
||||
import { requestPIN } from '../components/PinDialog'
|
||||
import type { Library, PlayProfile } from '../types'
|
||||
|
||||
@@ -48,9 +49,27 @@ export function ProfileManagementPage() {
|
||||
}, [])
|
||||
|
||||
const onDelete = async (p: PlayProfile) => {
|
||||
if (!(await confirmAction({ title: '删除播放档案', message: `确定删除 Profile「${p.name}」?`, confirmText: '删除' }))) return
|
||||
if (!(await confirmAction({ title: '删除播放档案', message: `确定删除 Profile「${p.name}」? 删除前需要再次验证。`, confirmText: '继续删除' }))) return
|
||||
try {
|
||||
await playProfilesAPI.remove(p.id)
|
||||
const proof: { pin?: string; password?: string } = {}
|
||||
if (p.require_pin) {
|
||||
const pin = await requestPIN({
|
||||
title: '删除 Profile 需要 PIN',
|
||||
message: `请输入「${p.name}」的 PIN;也可以取消后改用账号密码删除。`,
|
||||
profileName: p.name,
|
||||
})
|
||||
if (!pin) return
|
||||
proof.pin = pin
|
||||
} else {
|
||||
const password = await requestPassword({
|
||||
title: '删除 Profile 需要密码',
|
||||
message: `请输入当前账号密码以删除「${p.name}」。`,
|
||||
confirmText: '删除',
|
||||
})
|
||||
if (!password) return
|
||||
proof.password = password
|
||||
}
|
||||
await playProfilesAPI.remove(p.id, proof)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
|
||||
@@ -1,28 +1,51 @@
|
||||
import { FormEvent, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { KeyRound, Save } from 'lucide-react'
|
||||
import { EyeOff, KeyRound, Save } from 'lucide-react'
|
||||
|
||||
import { authAPI } from '../api/auth'
|
||||
import { profileAPI } from '../api/profile'
|
||||
import { requestPassword } from '../components/PasswordDialog'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
export function ProfilePage() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const setUser = useAuthStore((s) => s.setUser)
|
||||
|
||||
const [username, setUsername] = useState(user?.username ?? '')
|
||||
const [nickname, setNickname] = useState(user?.nickname ?? '')
|
||||
const [email, setEmail] = useState(user?.email ?? '')
|
||||
const [avatar, setAvatar] = useState(user?.avatar_url ?? '')
|
||||
const [hideAdult, setHideAdult] = useState(Boolean(user?.hide_adult))
|
||||
const [oldPwd, setOldPwd] = useState('')
|
||||
const [newPwd, setNewPwd] = useState('')
|
||||
|
||||
const onProfile = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
const u = await profileAPI.update({ email, avatar_url: avatar })
|
||||
let password: string | undefined
|
||||
if (hideAdult !== Boolean(user?.hide_adult)) {
|
||||
const input = await requestPassword({
|
||||
title: hideAdult ? '隐藏成人目录' : '取消隐藏成人目录',
|
||||
message: '此设置会同步影响 Web 与 Emby/Jellyfin/Infuse 等第三方客户端,请输入当前账号密码确认。',
|
||||
confirmText: '保存设置',
|
||||
})
|
||||
if (!input) return
|
||||
password = input
|
||||
}
|
||||
const u = await profileAPI.update({
|
||||
username,
|
||||
nickname,
|
||||
email,
|
||||
avatar_url: avatar,
|
||||
hide_adult: hideAdult,
|
||||
password,
|
||||
})
|
||||
setUser(u)
|
||||
toast.success('资料已更新')
|
||||
} catch {
|
||||
toast.error('保存失败')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +71,20 @@ export function ProfilePage() {
|
||||
<form onSubmit={onProfile} className="glass-panel space-y-4">
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">基本信息</h2>
|
||||
<Field label="用户名">
|
||||
<input className="input-base" value={user?.username ?? ''} disabled />
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="昵称">
|
||||
<input
|
||||
className="input-base"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="角色">
|
||||
<input className="input-base" value={user?.role ?? ''} disabled />
|
||||
@@ -68,6 +104,22 @@ export function ProfilePage() {
|
||||
onChange={(e) => setAvatar(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<label className="flex items-start justify-between gap-4 rounded-2xl border border-gray-200 bg-white/70 p-4">
|
||||
<span>
|
||||
<span className="flex items-center gap-2 font-medium text-ink-600">
|
||||
<EyeOff size={16} /> 隐藏成人目录
|
||||
</span>
|
||||
<span className="mt-1 block text-sm leading-6 text-ink-50">
|
||||
开启后当前账号在网页、外部播放器链接以及 Emby/Jellyfin/Infuse 等第三方客户端中都不会显示成人媒体库和 NSFW 条目。
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-5 w-5 accent-brand-500"
|
||||
checked={hideAdult}
|
||||
onChange={(e) => setHideAdult(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="neon-button">
|
||||
<Save size={16} /> 保存
|
||||
</button>
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface User {
|
||||
nickname?: string
|
||||
email?: string
|
||||
avatar_url?: string
|
||||
hide_adult?: boolean
|
||||
force_password_reset: boolean
|
||||
is_active: boolean
|
||||
is_default_admin?: boolean
|
||||
|
||||
Reference in New Issue
Block a user