mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-14 00:56: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
|
```bash
|
||||||
cat > .env <<'EOF'
|
cat > .env <<'EOF'
|
||||||
# 固定版本;需要升级时改成新的 MediaStationGo-vX.Y.Z 后执行 docker compose pull && docker compose up -d
|
# 固定版本;需要升级时改成新的 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
|
MEDIASTATION_HTTP_PORT=18080
|
||||||
|
|
||||||
# 程序数据和缓存建议放在 MediaStationGo 部署目录下,便于备份和迁移。
|
# 程序数据和缓存建议放在 MediaStationGo 部署目录下,便于备份和迁移。
|
||||||
@@ -307,7 +307,7 @@ vim docker-compose.yml
|
|||||||
#
|
#
|
||||||
# 镜像版本:
|
# 镜像版本:
|
||||||
# 默认拉取 latest;如需固定版本,创建 .env 并写入:
|
# 默认拉取 latest;如需固定版本,创建 .env 并写入:
|
||||||
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.20
|
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.21
|
||||||
#
|
#
|
||||||
# 路径映射总览:
|
# 路径映射总览:
|
||||||
# /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。
|
# /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。
|
||||||
@@ -516,7 +516,7 @@ docker compose up -d
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cat > .env <<'EOF'
|
cat > .env <<'EOF'
|
||||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.20
|
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.21
|
||||||
MEDIASTATION_HTTP_PORT=18080
|
MEDIASTATION_HTTP_PORT=18080
|
||||||
MEDIASTATION_DATA_DIR=./data
|
MEDIASTATION_DATA_DIR=./data
|
||||||
MEDIASTATION_CACHE_DIR=./cache
|
MEDIASTATION_CACHE_DIR=./cache
|
||||||
@@ -779,26 +779,26 @@ cd MediaStationGo
|
|||||||
|
|
||||||
| 平台 | 包名示例 |
|
| 平台 | 包名示例 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Linux x86_64 | `MediaStationGo-v0.0.20-linux-amd64.tar.gz` |
|
| Linux x86_64 | `MediaStationGo-v0.0.21-linux-amd64.tar.gz` |
|
||||||
| Linux ARM64 | `MediaStationGo-v0.0.20-linux-arm64.tar.gz` |
|
| Linux ARM64 | `MediaStationGo-v0.0.21-linux-arm64.tar.gz` |
|
||||||
| Windows x86_64 | `MediaStationGo-v0.0.20-windows-amd64.zip` |
|
| Windows x86_64 | `MediaStationGo-v0.0.21-windows-amd64.zip` |
|
||||||
| macOS Intel | `MediaStationGo-v0.0.20-darwin-amd64.tar.gz` |
|
| macOS Intel | `MediaStationGo-v0.0.21-darwin-amd64.tar.gz` |
|
||||||
| macOS Apple Silicon | `MediaStationGo-v0.0.20-darwin-arm64.tar.gz` |
|
| macOS Apple Silicon | `MediaStationGo-v0.0.21-darwin-arm64.tar.gz` |
|
||||||
|
|
||||||
部署步骤:
|
部署步骤:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Linux 示例
|
# Linux 示例
|
||||||
tar -xzf MediaStationGo-v0.0.20-linux-amd64.tar.gz
|
tar -xzf MediaStationGo-v0.0.21-linux-amd64.tar.gz
|
||||||
cd MediaStationGo-v0.0.20-linux-amd64
|
cd MediaStationGo-v0.0.21-linux-amd64
|
||||||
MEDIASTATION_APP_PORT=18080 ./mediastation-go
|
MEDIASTATION_APP_PORT=18080 ./mediastation-go
|
||||||
```
|
```
|
||||||
|
|
||||||
Windows:
|
Windows:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Expand-Archive .\MediaStationGo-v0.0.20-windows-amd64.zip
|
Expand-Archive .\MediaStationGo-v0.0.21-windows-amd64.zip
|
||||||
cd .\MediaStationGo-v0.0.20-windows-amd64
|
cd .\MediaStationGo-v0.0.21-windows-amd64
|
||||||
$env:MEDIASTATION_APP_PORT = "18080"
|
$env:MEDIASTATION_APP_PORT = "18080"
|
||||||
.\mediastation-go.exe
|
.\mediastation-go.exe
|
||||||
```
|
```
|
||||||
|
|||||||
+11
-11
@@ -235,7 +235,7 @@ mkdir -p data cache media downloads
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cat > .env <<'EOF'
|
cat > .env <<'EOF'
|
||||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.20
|
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.21
|
||||||
MEDIASTATION_HTTP_PORT=18080
|
MEDIASTATION_HTTP_PORT=18080
|
||||||
MEDIASTATION_DATA_DIR=./data
|
MEDIASTATION_DATA_DIR=./data
|
||||||
MEDIASTATION_CACHE_DIR=./cache
|
MEDIASTATION_CACHE_DIR=./cache
|
||||||
@@ -344,7 +344,7 @@ For production, pin a specific release tag instead of using `latest`. Recommende
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cat > .env <<'EOF'
|
cat > .env <<'EOF'
|
||||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.20
|
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.21
|
||||||
MEDIASTATION_HTTP_PORT=18080
|
MEDIASTATION_HTTP_PORT=18080
|
||||||
MEDIASTATION_DATA_DIR=./data
|
MEDIASTATION_DATA_DIR=./data
|
||||||
MEDIASTATION_CACHE_DIR=./cache
|
MEDIASTATION_CACHE_DIR=./cache
|
||||||
@@ -593,25 +593,25 @@ Each release provides multi-platform archives:
|
|||||||
|
|
||||||
| Platform | Package example |
|
| Platform | Package example |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Linux x86_64 | `MediaStationGo-v0.0.20-linux-amd64.tar.gz` |
|
| Linux x86_64 | `MediaStationGo-v0.0.21-linux-amd64.tar.gz` |
|
||||||
| Linux ARM64 | `MediaStationGo-v0.0.20-linux-arm64.tar.gz` |
|
| Linux ARM64 | `MediaStationGo-v0.0.21-linux-arm64.tar.gz` |
|
||||||
| Windows x86_64 | `MediaStationGo-v0.0.20-windows-amd64.zip` |
|
| Windows x86_64 | `MediaStationGo-v0.0.21-windows-amd64.zip` |
|
||||||
| macOS Intel | `MediaStationGo-v0.0.20-darwin-amd64.tar.gz` |
|
| macOS Intel | `MediaStationGo-v0.0.21-darwin-amd64.tar.gz` |
|
||||||
| macOS Apple Silicon | `MediaStationGo-v0.0.20-darwin-arm64.tar.gz` |
|
| macOS Apple Silicon | `MediaStationGo-v0.0.21-darwin-arm64.tar.gz` |
|
||||||
|
|
||||||
Linux example:
|
Linux example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tar -xzf MediaStationGo-v0.0.20-linux-amd64.tar.gz
|
tar -xzf MediaStationGo-v0.0.21-linux-amd64.tar.gz
|
||||||
cd MediaStationGo-v0.0.20-linux-amd64
|
cd MediaStationGo-v0.0.21-linux-amd64
|
||||||
MEDIASTATION_APP_PORT=18080 ./mediastation-go
|
MEDIASTATION_APP_PORT=18080 ./mediastation-go
|
||||||
```
|
```
|
||||||
|
|
||||||
Windows example:
|
Windows example:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Expand-Archive .\MediaStationGo-v0.0.20-windows-amd64.zip
|
Expand-Archive .\MediaStationGo-v0.0.21-windows-amd64.zip
|
||||||
cd .\MediaStationGo-v0.0.20-windows-amd64
|
cd .\MediaStationGo-v0.0.21-windows-amd64
|
||||||
$env:MEDIASTATION_APP_PORT = "18080"
|
$env:MEDIASTATION_APP_PORT = "18080"
|
||||||
.\mediastation-go.exe
|
.\mediastation-go.exe
|
||||||
```
|
```
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@
|
|||||||
#
|
#
|
||||||
# 镜像版本:
|
# 镜像版本:
|
||||||
# 默认拉取 latest;如需固定版本,创建 .env 并写入:
|
# 默认拉取 latest;如需固定版本,创建 .env 并写入:
|
||||||
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.20
|
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.21
|
||||||
#
|
#
|
||||||
# 路径映射总览:
|
# 路径映射总览:
|
||||||
# /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。
|
# /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。
|
||||||
|
|||||||
@@ -318,7 +318,11 @@ func embyFallbackUser(id string) gin.H {
|
|||||||
|
|
||||||
func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
|
func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -335,8 +339,13 @@ func embyVirtualFoldersHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
uid := embyUserID(c)
|
||||||
|
visibility := service.UserDefaultMediaVisibility(c.Request.Context(), svc.Repo, uid)
|
||||||
out := make([]gin.H, 0, len(libs))
|
out := make([]gin.H, 0, len(libs))
|
||||||
for _, lib := range libs {
|
for _, lib := range libs {
|
||||||
|
if !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, lib, visibility) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
collectionType := "movies"
|
collectionType := "movies"
|
||||||
switch lib.Type {
|
switch lib.Type {
|
||||||
case "tv", "anime", "variety":
|
case "tv", "anime", "variety":
|
||||||
@@ -538,7 +547,11 @@ func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
|
|
||||||
func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -555,8 +568,14 @@ func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
// 直接代理到我们的 /api/stream/{id}(同一个 ServeFile)。
|
// 直接代理到我们的 /api/stream/{id}(同一个 ServeFile)。
|
||||||
func embyVideoStreamHandler(svc *service.Container) gin.HandlerFunc {
|
func embyVideoStreamHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
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
|
// 直接调用 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 {
|
if err != nil {
|
||||||
c.Status(http.StatusNotFound)
|
c.Status(http.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ type verifyPlayProfilePINReq struct {
|
|||||||
PIN string `json:"pin"`
|
PIN string `json:"pin"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type deletePlayProfileReq struct {
|
||||||
|
PIN string `json:"pin"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
// listPlayProfilesHandler returns only the caller's own profiles.
|
// listPlayProfilesHandler returns only the caller's own profiles.
|
||||||
func listPlayProfilesHandler(svc *service.Container) gin.HandlerFunc {
|
func listPlayProfilesHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
@@ -82,7 +87,44 @@ func updatePlayProfileHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
func deletePlayProfileHandler(svc *service.Container) gin.HandlerFunc {
|
func deletePlayProfileHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
uid, _ := c.Get(middleware.CtxUserID)
|
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"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "profile not found"})
|
||||||
return
|
return
|
||||||
} else if errors.Is(err, service.ErrPlayProfileForbidden) {
|
} else if errors.Is(err, service.ErrPlayProfileForbidden) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -18,8 +19,19 @@ func updateProfileHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
uid, _ := c.Get(middleware.CtxUserID)
|
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 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()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -18,8 +17,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func mediaVisibilityForRequest(c *gin.Context, svc *service.Container) service.MediaVisibility {
|
func mediaVisibilityForRequest(c *gin.Context, svc *service.Container) service.MediaVisibility {
|
||||||
adultEnabled := settingBool(c, svc, "adult.enabled", false)
|
userID := currentUserID(c)
|
||||||
visibility := service.MediaVisibility{IncludeNSFW: adultEnabled}
|
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)
|
profile, locked := selectedPlayProfile(c, svc)
|
||||||
if locked {
|
if locked {
|
||||||
return service.MediaVisibility{
|
return service.MediaVisibility{
|
||||||
@@ -30,7 +31,7 @@ func mediaVisibilityForRequest(c *gin.Context, svc *service.Container) service.M
|
|||||||
if profile == nil {
|
if profile == nil {
|
||||||
return visibility
|
return visibility
|
||||||
}
|
}
|
||||||
visibility.IncludeNSFW = adultEnabled && profile.AllowAdult
|
visibility.IncludeNSFW = adultEnabled && profile.AllowAdult && !userHidesAdult
|
||||||
visibility.AllowedLibraryIDs = profileAllowedLibraryIDs(*profile)
|
visibility.AllowedLibraryIDs = profileAllowedLibraryIDs(*profile)
|
||||||
return visibility
|
return visibility
|
||||||
}
|
}
|
||||||
@@ -99,14 +100,7 @@ func currentUserID(c *gin.Context) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func profileAllowedLibraryIDs(profile model.PlayProfile) []string {
|
func profileAllowedLibraryIDs(profile model.PlayProfile) []string {
|
||||||
if strings.TrimSpace(profile.AllowedLibraryIDs) == "" {
|
return service.DecodeAllowedLibraryIDs(profile.AllowedLibraryIDs)
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var ids []string
|
|
||||||
if err := json.Unmarshal([]byte(profile.AllowedLibraryIDs), &ids); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ids
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func signPlayProfilePINToken(svc *service.Container, userID, profileID string, expiresAt time.Time) string {
|
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"`
|
Nickname string `gorm:"size:128" json:"nickname,omitempty"`
|
||||||
Email string `gorm:"size:128" json:"email,omitempty"`
|
Email string `gorm:"size:128" json:"email,omitempty"`
|
||||||
AvatarURL string `gorm:"size:255" json:"avatar_url,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"`
|
ForcePasswordReset bool `gorm:"default:false" json:"force_password_reset"`
|
||||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||||
@@ -318,6 +319,7 @@ func AllModels() []interface{} {
|
|||||||
&ApiConfig{},
|
&ApiConfig{},
|
||||||
&DownloadClient{},
|
&DownloadClient{},
|
||||||
&NotifyChannel{},
|
&NotifyChannel{},
|
||||||
|
&TelegramBinding{},
|
||||||
&STRMRecord{},
|
&STRMRecord{},
|
||||||
&PlayProfile{},
|
&PlayProfile{},
|
||||||
&StorageConfig{},
|
&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.
|
// ChangePassword updates the user password if the old one matches.
|
||||||
func (s *AuthService) ChangePassword(ctx context.Context, userID, oldPwd, newPwd string) error {
|
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)
|
u, err := s.repo.User.FindByID(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -176,6 +179,23 @@ func (s *AuthService) ChangePassword(ctx context.Context, userID, oldPwd, newPwd
|
|||||||
return s.repo.User.UpdatePassword(ctx, userID, hash)
|
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).
|
// IssueToken signs a JWT for the given user (60min validity, includes tier).
|
||||||
func (s *AuthService) IssueToken(u *model.User) (string, error) {
|
func (s *AuthService) IssueToken(u *model.User) (string, error) {
|
||||||
claims := Claims{
|
claims := Claims{
|
||||||
|
|||||||
+104
-21
@@ -169,13 +169,17 @@ func (e *EmbyService) userPayload(u *model.User) map[string]any {
|
|||||||
// ─── Views / MediaFolders ────────────────────────────────────────────────────
|
// ─── Views / MediaFolders ────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Views 返回 Emby 中"虚拟根目录"——每个 library 一个条目。
|
// 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)
|
libs, err := e.repo.Library.List(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
visibility := UserDefaultMediaVisibility(ctx, e.repo, userID)
|
||||||
items := make([]map[string]any, 0, len(libs))
|
items := make([]map[string]any, 0, len(libs))
|
||||||
for _, l := range libs {
|
for _, l := range libs {
|
||||||
|
if !LibraryVisibleForUser(ctx, e.repo, l, visibility) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
items = append(items, e.libraryAsView(&l))
|
items = append(items, e.libraryAsView(&l))
|
||||||
}
|
}
|
||||||
return map[string]any{"Items": items, "TotalRecordCount": len(items)}, nil
|
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 {
|
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
|
return nil, err
|
||||||
} else if ok {
|
} else if ok {
|
||||||
return e.episodeItems(ctx, season.Episodes, p)
|
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
|
return nil, err
|
||||||
} else if ok {
|
} else if ok {
|
||||||
if p.Recursive || containsItemType(p.IncludeItemTypes, "Episode") {
|
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) {
|
func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
|
||||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{})
|
q := e.repo.DB.WithContext(ctx).Model(&model.Media{})
|
||||||
|
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
|
||||||
if p.ParentID != "" {
|
if p.ParentID != "" {
|
||||||
q = q.Where("library_id = ? OR series_id = ?", p.ParentID, 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) {
|
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 != "" {
|
if p.SearchTerm != "" {
|
||||||
filtered := rows[:0]
|
filtered := rows[:0]
|
||||||
needle := strings.ToLower(p.SearchTerm)
|
needle := strings.ToLower(p.SearchTerm)
|
||||||
@@ -428,14 +434,14 @@ func (e *EmbyService) payloadsForMedia(ctx context.Context, rows []model.Media,
|
|||||||
// Item 单条目详情。
|
// Item 单条目详情。
|
||||||
func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[string]any, error) {
|
func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[string]any, error) {
|
||||||
if strings.HasPrefix(mediaID, embyVirtualSeasonPrefix) {
|
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
|
return nil, err
|
||||||
} else if ok {
|
} else if ok {
|
||||||
return e.seasonPayload(season), nil
|
return e.seasonPayload(season), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(mediaID, embyVirtualSeriesPrefix) {
|
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
|
return nil, err
|
||||||
} else if ok {
|
} else if ok {
|
||||||
return e.seriesPayload(series), nil
|
return e.seriesPayload(series), nil
|
||||||
@@ -446,13 +452,16 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if m == nil {
|
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
|
return nil, err
|
||||||
} else if ok {
|
} else if ok {
|
||||||
return e.seriesPayload(series), nil
|
return e.seriesPayload(series), nil
|
||||||
}
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
if !UserDefaultMediaVisibility(ctx, e.repo, userID).Allows(m) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
fav := false
|
fav := false
|
||||||
pos := int64(0)
|
pos := int64(0)
|
||||||
if userID != "" {
|
if userID != "" {
|
||||||
@@ -477,6 +486,7 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
|
|||||||
limit = 20
|
limit = 20
|
||||||
}
|
}
|
||||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
|
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
|
||||||
|
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||||
if parentID != "" {
|
if parentID != "" {
|
||||||
if episodic, err := e.libraryIsEpisodic(ctx, parentID); err == nil && episodic {
|
if episodic, err := e.libraryIsEpisodic(ctx, parentID); err == nil && episodic {
|
||||||
resp, err := e.seriesItemsForLibrary(ctx, parentID, ItemsParams{
|
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
|
posByID[h.MediaID] = h.PositionMs
|
||||||
}
|
}
|
||||||
var medias []model.Media
|
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
|
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) {
|
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.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("season_num > 0 OR episode_num > 0")
|
||||||
|
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
|
||||||
if libraryID != "" {
|
if libraryID != "" {
|
||||||
q = q.Where("library_id = ?", libraryID)
|
q = q.Where("library_id = ?", libraryID)
|
||||||
}
|
}
|
||||||
@@ -677,12 +690,13 @@ func (e *EmbyService) libraryIsEpisodic(ctx context.Context, libraryID string) (
|
|||||||
return count > 0, err
|
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) == "" {
|
if strings.TrimSpace(id) == "" {
|
||||||
return embySeriesGroup{}, false, nil
|
return embySeriesGroup{}, false, nil
|
||||||
}
|
}
|
||||||
var rows []model.Media
|
var rows []model.Media
|
||||||
q := 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 !strings.HasPrefix(id, embyVirtualSeriesPrefix) {
|
if !strings.HasPrefix(id, embyVirtualSeriesPrefix) {
|
||||||
q = q.Where("series_id = ?", id)
|
q = q.Where("series_id = ?", id)
|
||||||
}
|
}
|
||||||
@@ -716,13 +730,15 @@ func (e *EmbyService) findSeriesGroup(ctx context.Context, id string) (embySerie
|
|||||||
return embySeriesGroup{}, false, nil
|
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) {
|
if strings.TrimSpace(id) == "" || !strings.HasPrefix(id, embyVirtualSeasonPrefix) {
|
||||||
return embySeasonGroup{}, false, nil
|
return embySeasonGroup{}, false, nil
|
||||||
}
|
}
|
||||||
var rows []model.Media
|
var rows []model.Media
|
||||||
if err := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
||||||
Where("season_num > 0 OR episode_num > 0").
|
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").
|
Order("season_num asc, episode_num asc, created_at asc").
|
||||||
Find(&rows).Error; err != nil {
|
Find(&rows).Error; err != nil {
|
||||||
return embySeasonGroup{}, false, err
|
return embySeasonGroup{}, false, err
|
||||||
@@ -900,14 +916,14 @@ func (e *EmbyService) ImageURL(ctx context.Context, id, imageType string) (strin
|
|||||||
return backdrop
|
return backdrop
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(id, embyVirtualSeasonPrefix) {
|
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
|
return "", err
|
||||||
} else if ok {
|
} else if ok {
|
||||||
return pick(season.Series.PosterURL, season.Series.BackdropURL), nil
|
return pick(season.Series.PosterURL, season.Series.BackdropURL), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(id, embyVirtualSeriesPrefix) {
|
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
|
return "", err
|
||||||
} else if ok {
|
} else if ok {
|
||||||
return pick(series.PosterURL, series.BackdropURL), nil
|
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if series, ok, err := e.findSeriesGroup(ctx, id); err != nil {
|
if series, ok, err := e.findSeriesGroup(ctx, id, ""); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
} else if ok {
|
} else if ok {
|
||||||
return pick(series.PosterURL, series.BackdropURL), nil
|
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 {
|
func minInt(a, b int) int {
|
||||||
if a < b {
|
if a < b {
|
||||||
return a
|
return a
|
||||||
@@ -1067,8 +1143,8 @@ func maxInt(a, b int) int {
|
|||||||
// ─── Playback ────────────────────────────────────────────────────────────────
|
// ─── Playback ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// PlaybackInfo returns a PlaybackInfoResponse usable by Emby clients.
|
// PlaybackInfo returns a PlaybackInfoResponse usable by Emby clients.
|
||||||
func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID string) (map[string]any, error) {
|
func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string) (map[string]any, error) {
|
||||||
m, err := e.playableMedia(ctx, mediaID)
|
m, err := e.playableMedia(ctx, mediaID, userID)
|
||||||
if err != nil || m == nil {
|
if err != nil || m == nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1078,18 +1154,25 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID string) (map[str
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *EmbyService) playableMedia(ctx context.Context, id string) (*model.Media, error) {
|
func (e *EmbyService) playableMedia(ctx context.Context, id, userID string) (*model.Media, error) {
|
||||||
if season, ok, err := e.findSeasonGroup(ctx, id); err != nil {
|
if season, ok, err := e.findSeasonGroup(ctx, id, userID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else if ok && len(season.Episodes) > 0 {
|
} else if ok && len(season.Episodes) > 0 {
|
||||||
return &season.Episodes[0], nil
|
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
|
return nil, err
|
||||||
} else if ok && len(series.Episodes) > 0 {
|
} else if ok && len(series.Episodes) > 0 {
|
||||||
return &series.Episodes[0], nil
|
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 结构。
|
// mediaSource 是 /Items 与 /PlaybackInfo 共享的 MediaSource 结构。
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ func TestEmbyItemsExposeSeriesSeasonEpisodeHierarchy(t *testing.T) {
|
|||||||
t.Fatalf("latest should be grouped by series: %#v", latest)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("series playback fallback: %v", err)
|
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 {
|
func newTestEmbyService(t *testing.T) *EmbyService {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
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
|
// ProfileUpdate is the patch object accepted by UpdateProfile. Empty
|
||||||
// fields are ignored so the same payload can be reused across screens.
|
// fields are ignored so the same payload can be reused across screens.
|
||||||
type ProfileUpdate struct {
|
type ProfileUpdate struct {
|
||||||
|
Username *string `json:"username,omitempty"`
|
||||||
|
Nickname *string `json:"nickname,omitempty"`
|
||||||
Email *string `json:"email,omitempty"`
|
Email *string `json:"email,omitempty"`
|
||||||
AvatarURL *string `json:"avatar_url,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.
|
// 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 == "" {
|
if userID == "" {
|
||||||
return nil, errors.New("missing user id")
|
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{}
|
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 {
|
if patch.Email != nil {
|
||||||
v := strings.TrimSpace(*patch.Email)
|
v := strings.TrimSpace(*patch.Email)
|
||||||
updates["email"] = v
|
updates["email"] = v
|
||||||
@@ -43,6 +69,9 @@ func (p *ProfileService) UpdateProfile(ctx context.Context, userID string, patch
|
|||||||
if patch.AvatarURL != nil {
|
if patch.AvatarURL != nil {
|
||||||
updates["avatar_url"] = strings.TrimSpace(*patch.AvatarURL)
|
updates["avatar_url"] = strings.TrimSpace(*patch.AvatarURL)
|
||||||
}
|
}
|
||||||
|
if patch.HideAdult != nil {
|
||||||
|
updates["hide_adult"] = *patch.HideAdult
|
||||||
|
}
|
||||||
if len(updates) > 0 {
|
if len(updates) > 0 {
|
||||||
if err := p.repo.DB.Model(&model.User{}).Where("id = ?", userID).
|
if err := p.repo.DB.Model(&model.User{}).Where("id = ?", userID).
|
||||||
Updates(updates).Error; err != nil {
|
Updates(updates).Error; err != nil {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
@@ -24,8 +26,9 @@ import (
|
|||||||
|
|
||||||
// TelegramUpdate 是 Telegram Bot API 推送的 update 对象。
|
// TelegramUpdate 是 Telegram Bot API 推送的 update 对象。
|
||||||
type TelegramUpdate struct {
|
type TelegramUpdate struct {
|
||||||
UpdateID int `json:"update_id"`
|
UpdateID int `json:"update_id"`
|
||||||
Message *TelegramMessage `json:"message,omitempty"`
|
Message *TelegramMessage `json:"message,omitempty"`
|
||||||
|
CallbackQuery *TelegramCallbackQuery `json:"callback_query,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TelegramMessage 是 Telegram 消息对象。
|
// TelegramMessage 是 Telegram 消息对象。
|
||||||
@@ -37,6 +40,13 @@ type TelegramMessage struct {
|
|||||||
Date int `json:"date"`
|
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 用户对象。
|
// TelegramUser 是 Telegram 用户对象。
|
||||||
type TelegramUser struct {
|
type TelegramUser struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
@@ -50,6 +60,16 @@ type TelegramChat struct {
|
|||||||
Type string `json:"type"`
|
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 的交互命令。
|
// TelegramBotService 处理 Telegram Bot 的交互命令。
|
||||||
type TelegramBotService struct {
|
type TelegramBotService struct {
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
@@ -77,6 +97,10 @@ func (s *TelegramBotService) HandleWebhook(ctx context.Context, body []byte) err
|
|||||||
return fmt.Errorf("invalid update: %w", 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 == "" {
|
if update.Message == nil || update.Message.Text == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -97,11 +121,11 @@ func (s *TelegramBotService) HandleWebhook(ctx context.Context, body []byte) err
|
|||||||
reply, err := s.executeCommand(ctx, channel, msg, text)
|
reply, err := s.executeCommand(ctx, channel, msg, text)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("command failed", zap.Error(err))
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if reply != "" {
|
if reply.Text != "" {
|
||||||
if err := s.reply(ctx, channel, msg.Chat.ID, reply); err != nil {
|
if err := s.reply(ctx, channel, msg.Chat.ID, reply); err != nil {
|
||||||
s.log.Error("reply failed", zap.Error(err))
|
s.log.Error("reply failed", zap.Error(err))
|
||||||
}
|
}
|
||||||
@@ -111,57 +135,111 @@ func (s *TelegramBotService) HandleWebhook(ctx context.Context, body []byte) err
|
|||||||
}
|
}
|
||||||
|
|
||||||
// executeCommand 解析命令并执行。
|
// 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)
|
parts := strings.Fields(text)
|
||||||
if len(parts) == 0 {
|
if len(parts) == 0 {
|
||||||
return "", nil
|
return telegramCommandReply{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := strings.ToLower(parts[0])
|
cmd := strings.ToLower(parts[0])
|
||||||
args := parts[1:]
|
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 {
|
switch cmd {
|
||||||
case "/start":
|
case "/start":
|
||||||
return s.cmdStart(msg), nil
|
return s.cmdStart(ctx, msg, args), nil
|
||||||
case "/help":
|
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":
|
case "/status":
|
||||||
|
if !s.telegramUserIsAdmin(ctx, msg.From.ID) {
|
||||||
|
return telegramCommandReply{Text: "此命令仅管理员可用。普通用户只能使用 /start 绑定账号,并通过按钮隐藏成人目录。"}, nil
|
||||||
|
}
|
||||||
return s.cmdStatus(ctx)
|
return s.cmdStatus(ctx)
|
||||||
case "/search":
|
case "/search":
|
||||||
|
if !s.telegramUserIsAdmin(ctx, msg.From.ID) {
|
||||||
|
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
|
||||||
|
}
|
||||||
return s.cmdSearch(ctx, args)
|
return s.cmdSearch(ctx, args)
|
||||||
case "/downloads":
|
case "/downloads":
|
||||||
|
if !s.telegramUserIsAdmin(ctx, msg.From.ID) {
|
||||||
|
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
|
||||||
|
}
|
||||||
return s.cmdDownloads(ctx)
|
return s.cmdDownloads(ctx)
|
||||||
case "/stats":
|
case "/stats":
|
||||||
|
if !s.telegramUserIsAdmin(ctx, msg.From.ID) {
|
||||||
|
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
|
||||||
|
}
|
||||||
return s.cmdStats(ctx)
|
return s.cmdStats(ctx)
|
||||||
default:
|
default:
|
||||||
return fmt.Sprintf("未知命令: %s\n\n输入 /help 查看可用命令列表。", cmd), nil
|
return telegramCommandReply{Text: fmt.Sprintf("未知命令: %s\n\n输入 /help 查看可用命令列表。", cmd)}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cmdStart 处理 /start 命令。
|
// 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
|
name := msg.From.FirstName
|
||||||
if msg.From.Username != "" {
|
if msg.From.Username != "" {
|
||||||
name = "@" + msg.From.Username
|
name = "@" + msg.From.Username
|
||||||
}
|
}
|
||||||
return fmt.Sprintf(
|
if len(args) == 0 {
|
||||||
"<b>欢迎使用 MediaStationGo</b>\n\n"+
|
if binding := s.telegramBinding(ctx, msg.From.ID); binding != nil {
|
||||||
"你好 %s!你已成功连接到媒体中心。\n\n"+
|
user, _ := s.repo.User.FindByID(ctx, binding.UserID)
|
||||||
"<b>可用命令:</b>\n"+
|
status := "未隐藏"
|
||||||
"📊 /status — 系统运行状态\n"+
|
if user != nil && user.HideAdult {
|
||||||
"🔍 /search 关键词 — 搜索媒体\n"+
|
status = "已隐藏"
|
||||||
"📥 /downloads — 下载进度\n"+
|
}
|
||||||
"📈 /stats — 媒体库统计\n"+
|
return telegramCommandReply{
|
||||||
"❓ /help — 帮助信息",
|
Text: fmt.Sprintf("<b>MediaStationGo 已绑定</b>\n\n你好 %s,当前账号:<b>%s</b>\n成人目录:<b>%s</b>", name, userNameOrFallback(user), status),
|
||||||
name,
|
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 命令。
|
// 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" +
|
return "<b>MediaStationGo 命令列表</b>\n\n" +
|
||||||
"<b>/start</b> — 开始使用\n" +
|
"<b>/start</b> — 开始使用\n" +
|
||||||
"<b>/help</b> — 帮助信息\n" +
|
"<b>/help</b> — 帮助信息\n" +
|
||||||
|
"<b>/hideadult on|off</b> — 隐藏/显示当前绑定账号的成人目录\n" +
|
||||||
"<b>/status</b> — 系统运行状态\n" +
|
"<b>/status</b> — 系统运行状态\n" +
|
||||||
"<b>/search 关键词</b> — 搜索媒体库\n" +
|
"<b>/search 关键词</b> — 搜索媒体库\n" +
|
||||||
"<b>/downloads</b> — 下载列表\n" +
|
"<b>/downloads</b> — 下载列表\n" +
|
||||||
@@ -174,7 +252,43 @@ func (s *TelegramBotService) cmdHelp() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// cmdStatus 处理 /status 命令。
|
// 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
|
var mediaCount int64
|
||||||
s.repo.DB.Model(&model.Media{}).Count(&mediaCount)
|
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)
|
s.repo.DB.Raw("SELECT COALESCE(SUM(size_bytes), 0) FROM media").Scan(&totalSize)
|
||||||
totalSizeGB := float64(totalSize) / 1024 / 1024 / 1024
|
totalSizeGB := float64(totalSize) / 1024 / 1024 / 1024
|
||||||
|
|
||||||
return fmt.Sprintf(
|
return telegramCommandReply{Text: fmt.Sprintf(
|
||||||
"<b>系统运行状态</b>\n\n"+
|
"<b>系统运行状态</b>\n\n"+
|
||||||
"🎬 媒体总数: <b>%d</b>\n"+
|
"🎬 媒体总数: <b>%d</b>\n"+
|
||||||
"💾 存储占用: <b>%.1f GB</b>",
|
"💾 存储占用: <b>%.1f GB</b>",
|
||||||
mediaCount, totalSizeGB,
|
mediaCount, totalSizeGB,
|
||||||
), nil
|
)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// cmdSearch 处理 /search 命令。
|
// 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 {
|
if len(args) == 0 {
|
||||||
return "请提供搜索关键词\n例: <code>/search 哥斯拉</code>", nil
|
return telegramCommandReply{Text: "请提供搜索关键词\n例: <code>/search 哥斯拉</code>"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
keyword := strings.Join(args, " ")
|
keyword := strings.Join(args, " ")
|
||||||
@@ -202,11 +316,11 @@ func (s *TelegramBotService) cmdSearch(ctx context.Context, args []string) (stri
|
|||||||
Order("year DESC").Limit(8).
|
Order("year DESC").Limit(8).
|
||||||
Find(&results).Error
|
Find(&results).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return telegramCommandReply{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(results) == 0 {
|
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
|
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)))
|
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 命令。
|
// cmdDownloads 处理 /downloads 命令。
|
||||||
func (s *TelegramBotService) cmdDownloads(ctx context.Context) (string, error) {
|
func (s *TelegramBotService) cmdDownloads(ctx context.Context) (telegramCommandReply, error) {
|
||||||
type Row struct {
|
type Row struct {
|
||||||
Title string
|
Title string
|
||||||
Status string
|
Status string
|
||||||
@@ -236,11 +350,11 @@ func (s *TelegramBotService) cmdDownloads(ctx context.Context) (string, error) {
|
|||||||
if err := s.repo.DB.Raw(
|
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",
|
"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 {
|
).Scan(&rows).Error; err != nil {
|
||||||
return "", err
|
return telegramCommandReply{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(rows) == 0 {
|
if len(rows) == 0 {
|
||||||
return "当前没有下载任务。", nil
|
return telegramCommandReply{Text: "当前没有下载任务。"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
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))
|
sb.WriteString(fmt.Sprintf("%s %s\n", icon, name))
|
||||||
}
|
}
|
||||||
|
|
||||||
return sb.String(), nil
|
return telegramCommandReply{Text: sb.String()}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// cmdStats 处理 /stats 命令。
|
// cmdStats 处理 /stats 命令。
|
||||||
func (s *TelegramBotService) cmdStats(ctx context.Context) (string, error) {
|
func (s *TelegramBotService) cmdStats(ctx context.Context) (telegramCommandReply, error) {
|
||||||
var totalMedia int64
|
var totalMedia int64
|
||||||
s.repo.DB.Model(&model.Media{}).Count(&totalMedia)
|
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 ──
|
// ── Polling ──
|
||||||
@@ -421,7 +535,7 @@ func (s *TelegramBotService) pollLoop(ctx context.Context, botToken string) {
|
|||||||
// ── Message Sending ──
|
// ── Message Sending ──
|
||||||
|
|
||||||
// reply 通过 Telegram Bot API 发送回复消息。
|
// 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 := ""
|
botToken := ""
|
||||||
if channel != nil {
|
if channel != nil {
|
||||||
configStr := channel.Config
|
configStr := channel.Config
|
||||||
@@ -439,9 +553,23 @@ func (s *TelegramBotService) reply(ctx context.Context, channel *model.NotifyCha
|
|||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"chat_id": strconv.Itoa(chatID),
|
"chat_id": strconv.Itoa(chatID),
|
||||||
"text": text,
|
"text": reply.Text,
|
||||||
"parse_mode": "HTML",
|
"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)
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
|
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
|
var cfg map[string]string
|
||||||
json.Unmarshal([]byte(configStr), &cfg)
|
json.Unmarshal([]byte(configStr), &cfg)
|
||||||
if cfg["chat_id"] == target {
|
if cfg["chat_id"] == target || cfg["command_chat_id"] == target {
|
||||||
return &ch
|
return &ch
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(channels) == 1 && channels[0].Enabled {
|
||||||
|
return &channels[0]
|
||||||
|
}
|
||||||
return nil
|
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 ──
|
// ── Webhook Management ──
|
||||||
|
|
||||||
// SetWebhook 注册 Telegram Bot Webhook URL。
|
// 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 })
|
.post<PlayProfilePINVerifyResponse>(`/play-profiles/${id}/verify-pin`, { pin })
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
|
|
||||||
remove: (id: string) =>
|
remove: (id: string, proof?: { pin?: string; password?: string }) =>
|
||||||
api.delete(`/play-profiles/${id}`).then((r) => r.data),
|
api.delete(`/play-profiles/${id}`, { data: proof ?? {} }).then((r) => r.data),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ import { api } from './client'
|
|||||||
import type { User } from '../types'
|
import type { User } from '../types'
|
||||||
|
|
||||||
export const profileAPI = {
|
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),
|
api.patch<User>('/me', patch).then((r) => r.data),
|
||||||
|
|
||||||
adminUpdateRole: (id: string, role: 'admin' | 'user') =>
|
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 ?? {}
|
const cfg = ch.config ?? {}
|
||||||
switch (ch.type) {
|
switch (ch.type) {
|
||||||
case 'telegram':
|
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':
|
case 'wechat':
|
||||||
return `SendKey ${String(cfg.sendkey ?? '').slice(0, 10)}…`
|
return `SendKey ${String(cfg.sendkey ?? '').slice(0, 10)}…`
|
||||||
case 'bark':
|
case 'bark':
|
||||||
@@ -200,7 +200,7 @@ function channelSummary(ch: NotifyChannel): string {
|
|||||||
// ─── Form Modal ─────────────────────────────────────────────────────────────
|
// ─── Form Modal ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const EMPTY_CONFIG: Record<NotifyChannel['type'], Record<string, string>> = {
|
const EMPTY_CONFIG: Record<NotifyChannel['type'], Record<string, string>> = {
|
||||||
telegram: { bot_token: '', chat_id: '' },
|
telegram: { bot_token: '', chat_id: '', command_chat_id: '' },
|
||||||
wechat: { sendkey: '' },
|
wechat: { sendkey: '' },
|
||||||
bark: { device_key: '', server: '' },
|
bark: { device_key: '', server: '' },
|
||||||
webhook: { url: '', method: 'POST', headers: '', body_template: '' },
|
webhook: { url: '', method: 'POST', headers: '', body_template: '' },
|
||||||
@@ -311,6 +311,17 @@ function ChannelFormModal({
|
|||||||
onChange={(e) => updateConfig('chat_id', e.target.value)}
|
onChange={(e) => updateConfig('chat_id', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</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 { useAuthStore } from '../stores/auth'
|
||||||
import { usePlayProfileStore } from '../stores/playProfile'
|
import { usePlayProfileStore } from '../stores/playProfile'
|
||||||
import { confirmAction } from '../components/ConfirmDialog'
|
import { confirmAction } from '../components/ConfirmDialog'
|
||||||
|
import { requestPassword } from '../components/PasswordDialog'
|
||||||
import { requestPIN } from '../components/PinDialog'
|
import { requestPIN } from '../components/PinDialog'
|
||||||
import type { Library, PlayProfile } from '../types'
|
import type { Library, PlayProfile } from '../types'
|
||||||
|
|
||||||
@@ -48,9 +49,27 @@ export function ProfileManagementPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const onDelete = async (p: PlayProfile) => {
|
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 {
|
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('已删除')
|
toast.success('已删除')
|
||||||
await refresh()
|
await refresh()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|||||||
@@ -1,28 +1,51 @@
|
|||||||
import { FormEvent, useState } from 'react'
|
import { FormEvent, useState } from 'react'
|
||||||
import toast from 'react-hot-toast'
|
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 { authAPI } from '../api/auth'
|
||||||
import { profileAPI } from '../api/profile'
|
import { profileAPI } from '../api/profile'
|
||||||
|
import { requestPassword } from '../components/PasswordDialog'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
export function ProfilePage() {
|
export function ProfilePage() {
|
||||||
const user = useAuthStore((s) => s.user)
|
const user = useAuthStore((s) => s.user)
|
||||||
const setUser = useAuthStore((s) => s.setUser)
|
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 [email, setEmail] = useState(user?.email ?? '')
|
||||||
const [avatar, setAvatar] = useState(user?.avatar_url ?? '')
|
const [avatar, setAvatar] = useState(user?.avatar_url ?? '')
|
||||||
|
const [hideAdult, setHideAdult] = useState(Boolean(user?.hide_adult))
|
||||||
const [oldPwd, setOldPwd] = useState('')
|
const [oldPwd, setOldPwd] = useState('')
|
||||||
const [newPwd, setNewPwd] = useState('')
|
const [newPwd, setNewPwd] = useState('')
|
||||||
|
|
||||||
const onProfile = async (e: FormEvent) => {
|
const onProfile = async (e: FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
try {
|
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)
|
setUser(u)
|
||||||
toast.success('资料已更新')
|
toast.success('资料已更新')
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
toast.error('保存失败')
|
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">
|
<form onSubmit={onProfile} className="glass-panel space-y-4">
|
||||||
<h2 className="font-display text-lg font-semibold text-ink-600">基本信息</h2>
|
<h2 className="font-display text-lg font-semibold text-ink-600">基本信息</h2>
|
||||||
<Field label="用户名">
|
<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>
|
||||||
<Field label="角色">
|
<Field label="角色">
|
||||||
<input className="input-base" value={user?.role ?? ''} disabled />
|
<input className="input-base" value={user?.role ?? ''} disabled />
|
||||||
@@ -68,6 +104,22 @@ export function ProfilePage() {
|
|||||||
onChange={(e) => setAvatar(e.target.value)}
|
onChange={(e) => setAvatar(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</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">
|
<button type="submit" className="neon-button">
|
||||||
<Save size={16} /> 保存
|
<Save size={16} /> 保存
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface User {
|
|||||||
nickname?: string
|
nickname?: string
|
||||||
email?: string
|
email?: string
|
||||||
avatar_url?: string
|
avatar_url?: string
|
||||||
|
hide_adult?: boolean
|
||||||
force_password_reset: boolean
|
force_password_reset: boolean
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
is_default_admin?: boolean
|
is_default_admin?: boolean
|
||||||
|
|||||||
Reference in New Issue
Block a user