mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-03 12:26:36 +08:00
feat: add licensing and access controls
This commit is contained in:
@@ -20,10 +20,10 @@ import (
|
||||
|
||||
// AuthService handles registration, login, and JWT issuance.
|
||||
type AuthService struct {
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
tokenSvc *TokenService
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
tokenSvc *TokenService
|
||||
permissionSvc *PermissionService
|
||||
}
|
||||
|
||||
@@ -36,9 +36,14 @@ func NewAuthService(cfg *config.Config, log *zap.Logger, repo *repository.Contai
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid username or password")
|
||||
ErrUsernameTaken = errors.New("username already taken")
|
||||
ErrUserInactive = errors.New("user account is inactive")
|
||||
ErrUserInactive = errors.New("user account is inactive")
|
||||
ErrUserLimitReached = errors.New("user limit reached")
|
||||
)
|
||||
|
||||
// MaxUsers is kept for compatibility with tests and callers; dynamic runtime
|
||||
// checks use LicensedMaxUsers so official licensed builds can raise the quota.
|
||||
const MaxUsers = OpenSourceUserLimit
|
||||
|
||||
// SeedAdmin makes sure at least one admin user exists. It mirrors the
|
||||
// MediaStation behaviour: if no admin row is found we create
|
||||
// `admin / admin123` (overridable through ADMIN_INITIAL_PASSWORD) and warn.
|
||||
@@ -89,6 +94,11 @@ func (s *AuthService) Register(ctx context.Context, username, password string) (
|
||||
} else if existing != nil {
|
||||
return nil, nil, ErrUsernameTaken
|
||||
}
|
||||
if n, err := s.repo.User.Count(ctx); err != nil {
|
||||
return nil, nil, err
|
||||
} else if n >= LicensedMaxUsers(ctx, s.repo) {
|
||||
return nil, nil, ErrUserLimitReached
|
||||
}
|
||||
hash, err := hashPassword(password)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -118,8 +128,8 @@ func (s *AuthService) Register(ctx context.Context, username, password string) (
|
||||
|
||||
// LoginResponse 登录响应结构。
|
||||
type LoginResponse struct {
|
||||
User *model.User `json:"user"`
|
||||
Tokens *TokenPair `json:"tokens"`
|
||||
User *model.User `json:"user"`
|
||||
Tokens *TokenPair `json:"tokens"`
|
||||
}
|
||||
|
||||
// Login validates credentials and returns the user + a fresh JWT token pair.
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
func newAuthTestServices(t *testing.T) (*repository.Container, *AuthService, *ProfileService, *PermissionService) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.UserPermission{}, &model.RefreshToken{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret"
|
||||
log := zap.NewNop()
|
||||
permissions := NewPermissionService(log, repos)
|
||||
tokenSvc := NewTokenService(cfg, log, repos)
|
||||
auth := NewAuthService(cfg, log, repos, tokenSvc, permissions)
|
||||
profile := NewProfileService(log, repos)
|
||||
return repos, auth, profile, permissions
|
||||
}
|
||||
|
||||
func TestRegisterRejectsMoreThanTwentyUsers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos, auth, _, _ := newAuthTestServices(t)
|
||||
for i := 0; i < MaxUsers; i++ {
|
||||
if err := repos.User.Create(ctx, &model.User{
|
||||
Username: fmt.Sprintf("user-%02d", i),
|
||||
PasswordHash: "hash",
|
||||
Role: "user",
|
||||
Tier: "free",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
_, _, err := auth.Register(ctx, "overflow", "password")
|
||||
if !errors.Is(err, ErrUserLimitReached) {
|
||||
t.Fatalf("expected ErrUserLimitReached, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPermissionsAreViewerOnly(t *testing.T) {
|
||||
perms := DefaultPermissions("user-1")
|
||||
if !perms.CanViewDashboard || !perms.CanPlayMedia || !perms.CanExternalPlayer {
|
||||
t.Fatal("viewer defaults must allow library viewing, playback, and external players")
|
||||
}
|
||||
if perms.CanManageDownloads || perms.CanManageSubscriptions || perms.CanManageFiles ||
|
||||
perms.CanEditMedia || perms.CanRescrape || perms.CanCaptureFrames ||
|
||||
perms.CanManageSites || perms.CanManageUsers || perms.CanManageStrm {
|
||||
t.Fatal("viewer defaults must not allow downloads, scraping, media edits, or file management")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminEffectivePermissionsAreAllGranted(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos, _, _, permissions := newAuthTestServices(t)
|
||||
admin := &model.User{Username: "admin", PasswordHash: "hash", Role: "admin", Tier: "plus"}
|
||||
if err := repos.User.Create(ctx, admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
perms, err := permissions.Effective(ctx, admin.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !perms.CanEditMedia || !perms.CanRescrape || !perms.CanUseAI ||
|
||||
!perms.CanCaptureFrames || !perms.CanManageUsers || !perms.CanAccessSettings {
|
||||
t.Fatal("admin effective permissions must grant every advanced capability")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAdminCannotBeDemoted(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos, _, profile, _ := newAuthTestServices(t)
|
||||
admin := &model.User{Username: "admin", PasswordHash: "hash", Role: "admin", Tier: "plus"}
|
||||
if err := repos.User.Create(ctx, admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := profile.AdminUpdateRole(ctx, admin.ID, "user")
|
||||
if err == nil {
|
||||
t.Fatal("expected default admin demotion to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -11,18 +11,23 @@ import (
|
||||
// intentionally separate from model.Media because the item may not exist in
|
||||
// the local library yet.
|
||||
type ExternalMediaResult struct {
|
||||
Source string `json:"source"`
|
||||
MediaType string `json:"media_type,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Overview string `json:"overview,omitempty"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
BackdropURL string `json:"backdrop_url,omitempty"`
|
||||
Year int `json:"year,omitempty"`
|
||||
Rating float32 `json:"rating,omitempty"`
|
||||
TMDbID int `json:"tmdb_id,omitempty"`
|
||||
BangumiID int `json:"bangumi_id,omitempty"`
|
||||
DoubanID string `json:"douban_id,omitempty"`
|
||||
SubscribeKeyword string `json:"subscribe_keyword"`
|
||||
Source string `json:"source"`
|
||||
MediaType string `json:"media_type,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Overview string `json:"overview,omitempty"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
BackdropURL string `json:"backdrop_url,omitempty"`
|
||||
Year int `json:"year,omitempty"`
|
||||
Rating float32 `json:"rating,omitempty"`
|
||||
TMDbID int `json:"tmdb_id,omitempty"`
|
||||
BangumiID int `json:"bangumi_id,omitempty"`
|
||||
DoubanID string `json:"douban_id,omitempty"`
|
||||
SubscribeKeyword string `json:"subscribe_keyword"`
|
||||
TotalEpisodes int `json:"total_episodes,omitempty"`
|
||||
DownloadedEpisodes int `json:"downloaded_episodes,omitempty"`
|
||||
LocalMediaCount int `json:"local_media_count,omitempty"`
|
||||
MissingEpisodes []int `json:"missing_episodes,omitempty"`
|
||||
InLibrary bool `json:"in_library"`
|
||||
}
|
||||
|
||||
// SearchExternalMedia fans out one normalized search intent to TMDb, Douban
|
||||
@@ -39,6 +44,10 @@ func SearchExternalMedia(ctx context.Context, query string, year int, mediaType
|
||||
if m == nil || strings.TrimSpace(m.Title) == "" {
|
||||
return
|
||||
}
|
||||
totalEpisodes := 0
|
||||
if source == "tmdb" && typ == "tv" && m.TMDbID > 0 && tmdb != nil {
|
||||
totalEpisodes, _ = tmdb.GetTVEpisodeCount(ctx, m.TMDbID)
|
||||
}
|
||||
results = append(results, ExternalMediaResult{
|
||||
Source: source,
|
||||
MediaType: typ,
|
||||
@@ -51,6 +60,7 @@ func SearchExternalMedia(ctx context.Context, query string, year int, mediaType
|
||||
TMDbID: m.TMDbID,
|
||||
BangumiID: m.BangumiID,
|
||||
SubscribeKeyword: buildSubscribeKeyword(m.Title, m.Year),
|
||||
TotalEpisodes: totalEpisodes,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ import (
|
||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||
)
|
||||
|
||||
// AutoInstallFFmpeg 在启动时检测并自动安装 ffmpeg/ffprobe
|
||||
// AutoInstallFFmpeg is only called by the admin tool-install endpoint. The
|
||||
// server must not auto-download or keep ffmpeg/ffprobe running during startup.
|
||||
func AutoInstallFFmpeg(log *zap.Logger, cfg *config.Config) (ffprobePath, ffmpegPath string) {
|
||||
// 1. 优先使用配置 / PATH / 本机常见软件目录中的现有工具。
|
||||
if path, err := resolveLocalExecutable(cfg.App.FFprobePath, "ffprobe"); err == nil {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
OpenSourceUserLimit = 20
|
||||
LicensedUserLimit = 100
|
||||
|
||||
LicenseSettingActivation = "license.activation"
|
||||
)
|
||||
|
||||
type LicenseActivationState struct {
|
||||
Valid bool `json:"valid"`
|
||||
LicenseType string `json:"license_type,omitempty"`
|
||||
ExpiryDate string `json:"expiry_date,omitempty"`
|
||||
MaxDevices int `json:"max_devices,omitempty"`
|
||||
DaysRemaining *int `json:"days_remaining,omitempty"`
|
||||
NextHeartbeat string `json:"next_heartbeat,omitempty"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
DeviceName string `json:"device_name,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
func LicensedMaxUsers(ctx context.Context, repos *repository.Container) int64 {
|
||||
if LicenseActive(ctx, repos) {
|
||||
return LicensedUserLimit
|
||||
}
|
||||
return OpenSourceUserLimit
|
||||
}
|
||||
|
||||
func LicenseActive(ctx context.Context, repos *repository.Container) bool {
|
||||
if repos == nil || repos.Setting == nil {
|
||||
return false
|
||||
}
|
||||
raw, err := repos.Setting.Get(ctx, LicenseSettingActivation)
|
||||
if err != nil || raw == "" {
|
||||
return false
|
||||
}
|
||||
var state LicenseActivationState
|
||||
if err := json.Unmarshal([]byte(raw), &state); err != nil {
|
||||
return false
|
||||
}
|
||||
return state.Valid && !licenseExpired(state.ExpiryDate)
|
||||
}
|
||||
|
||||
func licenseExpired(expiry string) bool {
|
||||
if expiry == "" {
|
||||
return false
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} {
|
||||
if t, err := time.Parse(layout, expiry); err == nil {
|
||||
return time.Now().After(t)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
var availabilityNoiseRE = regexp.MustCompile(`(?i)(自动订阅|订阅|全集|合集|complete|batch|season\s*\d+|s\d{1,2}|s\d{1,2}e\d{1,3}|第\s*\d+\s*季|第\s*\d+\s*[集话話期]|\(\d{4}\)|\b\d{4}\b|2160p|1080p|720p|4k|uhd|bluray|blu-ray|web-?dl|hdtv|remux|x26[45]|h\.?26[45]|hevc|avc|hdr10?\+?|dovi|dv|atmos|aac|ddp?5\.1|truehd|flac)`)
|
||||
|
||||
type LocalAvailability struct {
|
||||
DownloadedEpisodes int
|
||||
TotalEpisodes int
|
||||
LocalMediaCount int
|
||||
MissingEpisodes []int
|
||||
InLibrary bool
|
||||
ExistingEpisodeKeys map[string]struct{}
|
||||
MissingEpisodeKeys map[string]struct{}
|
||||
}
|
||||
|
||||
func EnrichExternalMediaAvailability(ctx context.Context, repo *repository.Container, items []ExternalMediaResult) {
|
||||
for i := range items {
|
||||
availability := LookupLocalAvailability(ctx, repo, items[i].Title, items[i].SubscribeKeyword, items[i].MediaType, items[i].TotalEpisodes)
|
||||
items[i].DownloadedEpisodes = availability.DownloadedEpisodes
|
||||
items[i].LocalMediaCount = availability.LocalMediaCount
|
||||
items[i].MissingEpisodes = availability.MissingEpisodes
|
||||
items[i].InLibrary = availability.InLibrary
|
||||
if items[i].TotalEpisodes == 0 {
|
||||
items[i].TotalEpisodes = availability.TotalEpisodes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func EnrichSubscriptionProgress(ctx context.Context, repo *repository.Container, items []model.Subscription) {
|
||||
for i := range items {
|
||||
availability := SubscriptionLocalAvailability(ctx, repo, &items[i])
|
||||
items[i].DownloadedEpisodes = availability.DownloadedEpisodes
|
||||
items[i].LocalMediaCount = availability.LocalMediaCount
|
||||
items[i].MissingEpisodes = availability.MissingEpisodes
|
||||
items[i].InLibrary = availability.InLibrary
|
||||
if items[i].TotalEpisodes == 0 {
|
||||
items[i].TotalEpisodes = availability.TotalEpisodes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func SubscriptionLocalAvailability(ctx context.Context, repo *repository.Container, sub *model.Subscription) LocalAvailability {
|
||||
if sub == nil {
|
||||
return LocalAvailability{}
|
||||
}
|
||||
expected := sub.TotalEpisodes
|
||||
return LookupLocalAvailability(ctx, repo, sub.Name, sub.Filter, sub.MediaType, expected)
|
||||
}
|
||||
|
||||
func LookupLocalAvailability(ctx context.Context, repo *repository.Container, title, keyword, mediaType string, expectedTotal int) LocalAvailability {
|
||||
out := LocalAvailability{
|
||||
TotalEpisodes: expectedTotal,
|
||||
ExistingEpisodeKeys: map[string]struct{}{},
|
||||
MissingEpisodeKeys: map[string]struct{}{},
|
||||
}
|
||||
if repo == nil || repo.DB == nil {
|
||||
return out
|
||||
}
|
||||
query := availabilityQuery(title, keyword)
|
||||
if query == "" {
|
||||
return out
|
||||
}
|
||||
like := "%" + query + "%"
|
||||
var rows []model.Media
|
||||
if err := repo.DB.WithContext(ctx).
|
||||
Where("title LIKE ? OR original_name LIKE ?", like, like).
|
||||
Order("season_num asc, episode_num asc, created_at desc").
|
||||
Limit(2000).
|
||||
Find(&rows).Error; err != nil {
|
||||
return out
|
||||
}
|
||||
out.LocalMediaCount = len(rows)
|
||||
out.InLibrary = len(rows) > 0
|
||||
if len(rows) == 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
seriesLike := isSubscriptionSeriesType(mediaType)
|
||||
for _, row := range rows {
|
||||
if row.EpisodeNum <= 0 {
|
||||
continue
|
||||
}
|
||||
season := row.SeasonNum
|
||||
if season <= 0 {
|
||||
season = 1
|
||||
}
|
||||
key := episodeKey(season, row.EpisodeNum)
|
||||
out.ExistingEpisodeKeys[key] = struct{}{}
|
||||
}
|
||||
if seriesLike || len(out.ExistingEpisodeKeys) > 0 {
|
||||
out.DownloadedEpisodes = len(out.ExistingEpisodeKeys)
|
||||
out.MissingEpisodes = missingEpisodes(out.ExistingEpisodeKeys, out.TotalEpisodes)
|
||||
for _, episode := range out.MissingEpisodes {
|
||||
out.MissingEpisodeKeys[episodeKey(1, episode)] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
out.DownloadedEpisodes = 1
|
||||
if out.TotalEpisodes == 0 {
|
||||
out.TotalEpisodes = 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func missingEpisodes(existing map[string]struct{}, total int) []int {
|
||||
if total <= 0 {
|
||||
return nil
|
||||
}
|
||||
missing := make([]int, 0)
|
||||
for episode := 1; episode <= total; episode++ {
|
||||
if _, ok := existing[episodeKey(1, episode)]; ok {
|
||||
continue
|
||||
}
|
||||
missing = append(missing, episode)
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func availabilityQuery(title, keyword string) string {
|
||||
for _, candidate := range []string{keyword, title} {
|
||||
cleaned := cleanAvailabilityTitle(candidate)
|
||||
if cleaned != "" {
|
||||
return cleaned
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cleanAvailabilityTitle(value string) string {
|
||||
value = availabilityNoiseRE.ReplaceAllString(value, " ")
|
||||
value = strings.TrimSpace(strings.Join(strings.Fields(value), " "))
|
||||
value = strings.TrimSuffix(value, "-")
|
||||
value = strings.TrimSpace(value)
|
||||
return value
|
||||
}
|
||||
|
||||
func episodeKey(season, episode int) string {
|
||||
if season <= 0 {
|
||||
season = 1
|
||||
}
|
||||
return fmt.Sprintf("%02dE%03d", season, episode)
|
||||
}
|
||||
|
||||
func missingEpisodeSet(availability LocalAvailability) map[int]struct{} {
|
||||
out := make(map[int]struct{}, len(availability.MissingEpisodes))
|
||||
for _, episode := range availability.MissingEpisodes {
|
||||
out[episode] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedEpisodeCandidates(candidates []siteSearchCandidate) []siteSearchCandidate {
|
||||
byEpisode := make(map[string]siteSearchCandidate)
|
||||
order := make([]string, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Episode <= 0 {
|
||||
continue
|
||||
}
|
||||
season := candidate.Season
|
||||
if season <= 0 {
|
||||
season = 1
|
||||
}
|
||||
key := episodeKey(season, candidate.Episode)
|
||||
if current, ok := byEpisode[key]; ok {
|
||||
if current.Score < candidate.Score {
|
||||
byEpisode[key] = candidate
|
||||
}
|
||||
continue
|
||||
}
|
||||
byEpisode[key] = candidate
|
||||
order = append(order, key)
|
||||
}
|
||||
sort.Strings(order)
|
||||
selected := make([]siteSearchCandidate, 0, len(order))
|
||||
for _, key := range order {
|
||||
selected = append(selected, byEpisode[key])
|
||||
}
|
||||
return selected
|
||||
}
|
||||
@@ -30,18 +30,24 @@ func NewPermissionService(log *zap.Logger, repo *repository.Container) *Permissi
|
||||
func DefaultPermissions(userID string) *model.UserPermission {
|
||||
return &model.UserPermission{
|
||||
UserID: userID,
|
||||
CanViewDashboard: true,
|
||||
CanPlayMedia: true,
|
||||
CanCast: true,
|
||||
CanExternalPlayer: true,
|
||||
CanFavorite: true,
|
||||
CanViewHistory: true,
|
||||
CanViewDashboard: true,
|
||||
CanViewDiscover: true,
|
||||
CanCast: true,
|
||||
CanEditMedia: false,
|
||||
CanRescrape: false,
|
||||
CanUseAI: false,
|
||||
CanCaptureFrames: false,
|
||||
CanManageDownloads: false,
|
||||
CanViewDiscover: false,
|
||||
CanManageSubscriptions: false,
|
||||
CanManageSites: false,
|
||||
CanUseAIAssistant: false,
|
||||
CanManageUsers: false,
|
||||
CanManageFiles: false,
|
||||
CanManageStrm: false,
|
||||
CanUseAIAssistant: false,
|
||||
CanAccessSettings: false,
|
||||
}
|
||||
}
|
||||
@@ -50,18 +56,24 @@ func DefaultPermissions(userID string) *model.UserPermission {
|
||||
func adminGrant(userID string) *model.UserPermission {
|
||||
return &model.UserPermission{
|
||||
UserID: userID,
|
||||
CanViewDashboard: true,
|
||||
CanPlayMedia: true,
|
||||
CanCast: true,
|
||||
CanExternalPlayer: true,
|
||||
CanFavorite: true,
|
||||
CanViewHistory: true,
|
||||
CanViewDashboard: true,
|
||||
CanViewDiscover: true,
|
||||
CanEditMedia: true,
|
||||
CanRescrape: true,
|
||||
CanUseAI: true,
|
||||
CanCaptureFrames: true,
|
||||
CanManageDownloads: true,
|
||||
CanViewDiscover: true,
|
||||
CanManageSubscriptions: true,
|
||||
CanManageSites: true,
|
||||
CanUseAIAssistant: true,
|
||||
CanManageUsers: true,
|
||||
CanManageFiles: true,
|
||||
CanManageStrm: true,
|
||||
CanCast: true,
|
||||
CanUseAIAssistant: true,
|
||||
CanAccessSettings: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,8 +59,16 @@ func (p *ProfileService) AdminUpdateRole(ctx context.Context, userID, role strin
|
||||
if role != "admin" && role != "user" {
|
||||
return nil, errors.New("role must be admin or user")
|
||||
}
|
||||
if err := p.repo.DB.Model(&model.User{}).Where("id = ?", userID).
|
||||
Update("role", role).Error; err != nil {
|
||||
if firstAdmin, err := p.repo.User.FirstAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
} else if firstAdmin != nil && firstAdmin.ID == userID && role != "admin" {
|
||||
return nil, errors.New("default admin must keep admin role")
|
||||
}
|
||||
updates := map[string]any{"role": role}
|
||||
if role == "admin" {
|
||||
updates["tier"] = "plus"
|
||||
}
|
||||
if err := p.repo.User.UpdateFields(ctx, userID, updates); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.repo.User.FindByID(ctx, userID)
|
||||
|
||||
@@ -50,5 +50,9 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) {
|
||||
}
|
||||
case "transcode.video_bitrate", "transcoder.video_bitrate":
|
||||
cfg.Transcoder.VideoBitrate = value
|
||||
case "license.server_url":
|
||||
cfg.License.ServerURL = value
|
||||
case "license.hmac_secret":
|
||||
cfg.License.HMACSecret = value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
//
|
||||
// The HTTP layer decides which mode to use based on the request path:
|
||||
//
|
||||
// GET /api/stream/:id → direct play
|
||||
// GET /api/hls/:id/index.m3u8 → HLS playlist
|
||||
// GET /api/hls/:id/seg_NNNNN.ts → HLS segment
|
||||
// GET /api/stream/:id → direct play
|
||||
// GET /api/hls/:id/index.m3u8 → HLS playlist
|
||||
// GET /api/hls/:id/seg_NNNNN.ts → HLS segment
|
||||
package service
|
||||
|
||||
import (
|
||||
@@ -82,6 +82,8 @@ func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaI
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.Header().Set("Content-Disposition", "inline")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
http.ServeContent(w, r, stat.Name(), stat.ModTime(), f)
|
||||
return nil
|
||||
}
|
||||
@@ -104,6 +106,7 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request,
|
||||
stat, _ := f.Stat()
|
||||
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Content-Disposition", "inline")
|
||||
if r.URL.RawQuery != "" {
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
@@ -164,6 +167,7 @@ func (s *StreamService) ServeHLSSegment(w http.ResponseWriter, r *http.Request,
|
||||
stat, _ := f.Stat()
|
||||
w.Header().Set("Content-Type", "video/mp2t")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Header().Set("Content-Disposition", "inline")
|
||||
http.ServeContent(w, r, stat.Name(), stat.ModTime(), f)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -285,7 +285,8 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
|
||||
seenSet[g] = struct{}{}
|
||||
}
|
||||
|
||||
candidates := selectSiteSearchCandidates(results, sub, seenSet)
|
||||
availability := SubscriptionLocalAvailability(ctx, s.repo, sub)
|
||||
candidates := selectSiteSearchCandidates(results, sub, seenSet, availability)
|
||||
var lastEnqueueErr error
|
||||
queued := 0
|
||||
var resources []string
|
||||
@@ -343,7 +344,7 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}) []siteSearchCandidate {
|
||||
func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, availability ...LocalAvailability) []siteSearchCandidate {
|
||||
candidates := make([]siteSearchCandidate, 0, len(results))
|
||||
for _, item := range results {
|
||||
if !matchesSubscriptionRules(sub, item.Title) {
|
||||
@@ -390,41 +391,47 @@ func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription,
|
||||
return candidates[:1]
|
||||
}
|
||||
|
||||
var local LocalAvailability
|
||||
if len(availability) > 0 {
|
||||
local = availability[0]
|
||||
}
|
||||
if local.LocalMediaCount > 0 {
|
||||
if local.TotalEpisodes > 0 && len(local.MissingEpisodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
missingSet := missingEpisodeSet(local)
|
||||
onlyMissing := make([]siteSearchCandidate, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Episode <= 0 {
|
||||
continue
|
||||
}
|
||||
season := candidate.Season
|
||||
if season <= 0 {
|
||||
season = 1
|
||||
}
|
||||
if _, exists := local.ExistingEpisodeKeys[episodeKey(season, candidate.Episode)]; exists {
|
||||
continue
|
||||
}
|
||||
if local.TotalEpisodes > 0 {
|
||||
if _, missing := missingSet[candidate.Episode]; !missing {
|
||||
continue
|
||||
}
|
||||
}
|
||||
onlyMissing = append(onlyMissing, candidate)
|
||||
}
|
||||
return sortedEpisodeCandidates(onlyMissing)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Pack {
|
||||
return []siteSearchCandidate{candidate}
|
||||
}
|
||||
}
|
||||
|
||||
byEpisode := make(map[string]siteSearchCandidate)
|
||||
order := make([]string, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Episode <= 0 {
|
||||
continue
|
||||
}
|
||||
season := candidate.Season
|
||||
if season <= 0 {
|
||||
season = 1
|
||||
}
|
||||
key := fmt.Sprintf("%02dE%03d", season, candidate.Episode)
|
||||
if current, ok := byEpisode[key]; ok {
|
||||
if current.Score < candidate.Score {
|
||||
byEpisode[key] = candidate
|
||||
}
|
||||
continue
|
||||
}
|
||||
byEpisode[key] = candidate
|
||||
order = append(order, key)
|
||||
}
|
||||
if len(order) == 0 {
|
||||
selected := sortedEpisodeCandidates(candidates)
|
||||
if len(selected) == 0 {
|
||||
return candidates[:1]
|
||||
}
|
||||
|
||||
selected := make([]siteSearchCandidate, 0, len(order))
|
||||
sort.Strings(order)
|
||||
for _, key := range order {
|
||||
selected = append(selected, byEpisode[key])
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
|
||||
@@ -98,3 +98,43 @@ func TestSiteSearchKeywordCanUseIMDB(t *testing.T) {
|
||||
t.Fatalf("keyword = %q, want imdb id", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectSiteSearchCandidatesOnlyQueuesMissingLocalEpisodes(t *testing.T) {
|
||||
sub := &model.Subscription{Name: "间谍过家家 自动订阅", Filter: "间谍过家家", MediaType: "tv", TotalEpisodes: 3}
|
||||
results := []SearchResult{
|
||||
{Title: "间谍过家家 S01 Complete 1080p", DownloadURL: "https://pt/download/pack", Seeders: 100},
|
||||
{Title: "间谍过家家 S01E01 1080p", DownloadURL: "https://pt/download/1", Seeders: 90},
|
||||
{Title: "间谍过家家 S01E02 1080p", DownloadURL: "https://pt/download/2", Seeders: 80},
|
||||
{Title: "间谍过家家 S01E03 1080p", DownloadURL: "https://pt/download/3", Seeders: 70},
|
||||
}
|
||||
availability := LocalAvailability{
|
||||
TotalEpisodes: 3,
|
||||
LocalMediaCount: 2,
|
||||
MissingEpisodes: []int{3},
|
||||
ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 1): {}, episodeKey(1, 2): {}},
|
||||
}
|
||||
|
||||
got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability)
|
||||
if len(got) != 1 || got[0].Episode != 3 {
|
||||
t.Fatalf("selected %#v, want only missing episode 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectSiteSearchCandidatesWithUnknownTotalSkipsExistingEpisodes(t *testing.T) {
|
||||
sub := &model.Subscription{Name: "葬送的芙莉莲 自动订阅", Filter: "葬送的芙莉莲", MediaType: "anime"}
|
||||
results := []SearchResult{
|
||||
{Title: "葬送的芙莉莲 S01 Complete 1080p", DownloadURL: "https://pt/download/pack", Seeders: 100},
|
||||
{Title: "葬送的芙莉莲 S01E01 1080p", DownloadURL: "https://pt/download/1", Seeders: 90},
|
||||
{Title: "葬送的芙莉莲 S01E02 1080p", DownloadURL: "https://pt/download/2", Seeders: 80},
|
||||
{Title: "葬送的芙莉莲 S01E03 1080p", DownloadURL: "https://pt/download/3", Seeders: 70},
|
||||
}
|
||||
availability := LocalAvailability{
|
||||
LocalMediaCount: 2,
|
||||
ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 1): {}, episodeKey(1, 2): {}},
|
||||
}
|
||||
|
||||
got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability)
|
||||
if len(got) != 1 || got[0].Episode != 3 {
|
||||
t.Fatalf("selected %#v, want only not-yet-local episode 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,6 +399,28 @@ func (t *TMDbProvider) GetDetails(ctx context.Context, tmdbID int, mediaType str
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *TMDbProvider) GetTVEpisodeCount(ctx context.Context, tmdbID int) (int, error) {
|
||||
if tmdbID <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
apiKey := t.resolveAPIKey(ctx)
|
||||
if apiKey == "" {
|
||||
return 0, nil
|
||||
}
|
||||
base := t.resolveBaseURL(ctx)
|
||||
q := url.Values{}
|
||||
q.Set("api_key", apiKey)
|
||||
q.Set("language", "zh-CN")
|
||||
u := base + "/tv/" + fmt.Sprint(tmdbID) + "?" + q.Encode()
|
||||
var r struct {
|
||||
NumberOfEpisodes int `json:"number_of_episodes"`
|
||||
}
|
||||
if err := t.getJSON(ctx, u, &r); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.NumberOfEpisodes, nil
|
||||
}
|
||||
|
||||
// deduplicate removes duplicates from a string slice.
|
||||
func deduplicate(s []string) []string {
|
||||
if len(s) == 0 {
|
||||
|
||||
Reference in New Issue
Block a user