/start 用户名 密码\n或:/start 用户名-密码\n\n如果没有账号,请联系管理员注册。"}
+ }
+ username, password := parseStartCredentials(args)
+ if username == "" || password == "" {
+ return telegramCommandReply{Text: "绑定格式不正确,请使用:\n/start 用户名 密码\n或:/start 用户名-密码"}
+ }
+ 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("绑定成功:%s\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 "MediaStationGo 用户命令\n\n" +
+ "/start 用户名 密码 — 绑定账号\n" +
+ "/hideadult on|off — 隐藏或显示成人目录\n\n" +
+ "系统状态、搜索、下载列表与统计命令仅管理员可用。"
+ }
return "MediaStationGo 命令列表\n\n" +
"/start — 开始使用\n" +
"/help — 帮助信息\n" +
+ "/hideadult on|off — 隐藏/显示当前绑定账号的成人目录\n" +
"/status — 系统运行状态\n" +
"/search 关键词 — 搜索媒体库\n" +
"/downloads — 下载列表\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: "请先绑定账号:/start 用户名 密码"}
+ }
+ 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(
"系统运行状态\n\n"+
"🎬 媒体总数: %d\n"+
"💾 存储占用: %.1f GB",
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例: /search 哥斯拉", nil
+ return telegramCommandReply{Text: "请提供搜索关键词\n例: /search 哥斯拉"}, 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("未找到与 %s 相关的媒体", keyword), nil
+ return telegramCommandReply{Text: fmt.Sprintf("未找到与 %s 相关的媒体", 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. %s%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。
diff --git a/internal/service/visibility.go b/internal/service/visibility.go
new file mode 100644
index 0000000..159aac0
--- /dev/null
+++ b/internal/service/visibility.go
@@ -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
+}
diff --git a/web/src/api/play_profiles.ts b/web/src/api/play_profiles.ts
index 502a440..158a73c 100644
--- a/web/src/api/play_profiles.ts
+++ b/web/src/api/play_profiles.ts
@@ -38,6 +38,6 @@ export const playProfilesAPI = {
.post/start 用户名 密码 绑定账号,并使用隐藏成人目录按钮;/status、/search、/downloads、/stats 仅管理员可用。
+