fix: auto-scan cloud libraries on boot + allow CORS for media playback

- Add BootCloudLibraries() to auto-scan all cloud libraries on startup
- Delay 3s to avoid conflict with system init, scan without auto-scrape
- Enable CORS for /api/cloud/play/* and /api/img to support 3rd-party players
- Fixes issue where each user triggers separate cloud library scans
- Fixes issue where Infuse/Emby apps cannot play cloud resources
This commit is contained in:
ShukeBta
2026-06-11 11:25:43 +08:00
parent d90b58ba22
commit 62b204367c
40 changed files with 431 additions and 140 deletions
+1 -1
View File
@@ -59,7 +59,7 @@ func main() {
// Ensure data / cache / web dirs exist.
for _, d := range []string{cfg.App.DataDir, cfg.Cache.CacheDir} {
if err := os.MkdirAll(d, 0o755); err != nil {
if err := os.MkdirAll(d, 0o750); err != nil {
logger.Fatal("create dir failed", zap.String("dir", d), zap.Error(err))
}
}
+2 -2
View File
@@ -309,7 +309,7 @@ func (c *Config) normalize() error {
if c.Secrets.JWTSecret == "" {
// 持久化自动生成的密钥以在操作员忘记配置时保持会话稳定。
path := filepath.Join(c.App.DataDir, ".jwt_secret")
if data, err := os.ReadFile(path); err == nil && len(data) > 0 {
if data, err := os.ReadFile(path); err == nil && len(data) > 0 { // #nosec G304 -- path is fixed to .jwt_secret under configured DataDir.
c.Secrets.JWTSecret = strings.TrimSpace(string(data))
} else {
buf := make([]byte, 32)
@@ -317,7 +317,7 @@ func (c *Config) normalize() error {
return fmt.Errorf("generate jwt secret: %w", err)
}
c.Secrets.JWTSecret = hex.EncodeToString(buf)
_ = os.MkdirAll(c.App.DataDir, 0o755)
_ = os.MkdirAll(c.App.DataDir, 0o750)
_ = os.WriteFile(path, []byte(c.Secrets.JWTSecret), 0o600)
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ import (
const (
licenseServerURLSetting = "license.server_url"
licenseHMACSecretSetting = "license.hmac_secret"
licenseHMACSecretSetting = "license.hmac_secret" // #nosec G101 -- setting key name, not the HMAC secret value.
licenseDeviceIDSetting = "license.device_id"
licenseDeviceNameSetting = "license.device_name"
)
+1
View File
@@ -259,3 +259,4 @@ func extractToken(c *gin.Context) string {
}
return ""
}
+32 -10
View File
@@ -46,10 +46,29 @@ func (b *BackupService) backupDir() string {
return filepath.Join(b.cfg.App.DataDir, "backups")
}
func (b *BackupService) backupFilePath(filename string) (string, error) {
if !isValidBackupFilename(filename) {
return "", errors.New("invalid filename")
}
dir, err := filepath.Abs(b.backupDir())
if err != nil {
return "", err
}
path, err := filepath.Abs(filepath.Join(dir, filename))
if err != nil {
return "", err
}
rel, err := filepath.Rel(dir, path)
if err != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
return "", errors.New("invalid filename")
}
return path, nil
}
// Create produces a new hot backup via "VACUUM INTO".
func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) {
dir := b.backupDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, err
}
ts := time.Now().Format("20060102_150405")
@@ -106,10 +125,10 @@ func (b *BackupService) List() ([]BackupInfo, error) {
// Delete removes a single backup file.
func (b *BackupService) Delete(filename string) error {
if !isValidBackupFilename(filename) {
return errors.New("invalid filename")
path, err := b.backupFilePath(filename)
if err != nil {
return err
}
path := filepath.Join(b.backupDir(), filename)
return os.Remove(path)
}
@@ -117,26 +136,29 @@ func (b *BackupService) Delete(filename string) error {
// reverse. WARNING: this is destructive — the live DB will be replaced.
// Callers should shut down the server after this call.
func (b *BackupService) Restore(ctx context.Context, filename string) error {
if !isValidBackupFilename(filename) {
return errors.New("invalid filename")
src, err := b.backupFilePath(filename)
if err != nil {
return err
}
src := filepath.Join(b.backupDir(), filename)
if _, err := os.Stat(src); err != nil {
return fmt.Errorf("backup not found: %s", filename)
}
dbPath := b.cfg.Database.DBPath
dbPath, err := filepath.Abs(b.cfg.Database.DBPath)
if err != nil || dbPath == "" {
return errors.New("invalid database path")
}
// Strategy: rename live → live.old, copy backup → live, delete old.
old := dbPath + ".before_restore"
if err := os.Rename(dbPath, old); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
data, err := os.ReadFile(src)
data, err := os.ReadFile(src) // #nosec G304 -- src is constrained by backupFilePath to the configured backups directory.
if err != nil {
// Revert
_ = os.Rename(old, dbPath)
return err
}
if err := os.WriteFile(dbPath, data, 0o644); err != nil {
if err := os.WriteFile(dbPath, data, 0o600); err != nil { // #nosec G703 -- database path is trusted process config and normalized before restore.
_ = os.Rename(old, dbPath)
return err
}
+3 -3
View File
@@ -6,8 +6,8 @@
//
// We implement the minimal subset needed to enrich anime libraries:
//
// GET /search/subject/{keywords}?type=2&responseGroup=small
// GET /v0/subjects/{id} (cover)
// GET /search/subject/{keywords}?type=2&responseGroup=small
// GET /v0/subjects/{id} (cover)
//
// The provider gracefully no-ops when bangumi_access_token is empty.
package service
@@ -100,7 +100,7 @@ func (b *BangumiProvider) Search(ctx context.Context, query string) (*Match, err
Rating: r.Rating.Score,
}
if len(r.Air) >= 4 {
fmt.Sscanf(r.Air[:4], "%d", &m.Year)
_, _ = fmt.Sscanf(r.Air[:4], "%d", &m.Year)
}
return m, nil
}
+50
View File
@@ -0,0 +1,50 @@
package service
import (
"context"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
// BootCloudLibraries 在系统启动后自动扫描所有云盘媒体库,使媒体对所有用户立即可见。
// 避免每个用户首次访问时都触发扫描。
func (c *Container) BootCloudLibraries(ctx context.Context) {
if c == nil || c.Repo == nil || c.Scan == nil {
return
}
libs, err := c.Repo.Library.List(ctx)
if err != nil {
c.Log.Warn("boot cloud libraries: list failed", zap.Error(err))
return
}
cloudLibs := make([]model.Library, 0)
for _, lib := range libs {
if _, ok := ParseCloudLibraryMount(lib.Path); ok {
cloudLibs = append(cloudLibs, lib)
}
}
if len(cloudLibs) == 0 {
return
}
c.Log.Info("boot: scheduling cloud library scans", zap.Int("count", len(cloudLibs)))
// 延迟3秒后启动,避免和系统初始化任务冲突
time.AfterFunc(3*time.Second, func() {
for _, lib := range cloudLibs {
libID := lib.ID
libName := lib.Name
go func() {
scanCtx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
defer cancel()
c.Log.Info("boot: scanning cloud library", zap.String("id", libID), zap.String("name", libName))
if _, err := c.Scan.ScanLibraryWithoutAutoScrape(scanCtx, libID); err != nil {
c.Log.Warn("boot: cloud library scan failed", zap.String("id", libID), zap.String("name", libName), zap.Error(err))
} else {
c.Log.Info("boot: cloud library scan completed", zap.String("id", libID), zap.String("name", libName))
}
}()
}
})
}
+1 -8
View File
@@ -2,7 +2,6 @@ package service
import (
"context"
"crypto/rand"
"fmt"
"strconv"
"strings"
@@ -238,13 +237,7 @@ const codeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // no ambiguous 0/O/1/I
func randomCode(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
// crypto/rand failure is extremely unlikely; fall back to time noise.
seed := time.Now().UnixNano()
for i := range b {
b[i] = byte(seed >> (uint(i%8) * 8))
}
}
secureRandomBytes(b)
out := make([]byte, n)
for i := range b {
out[i] = codeAlphabet[int(b[i])%len(codeAlphabet)]
+1 -1
View File
@@ -32,7 +32,7 @@ func walkAndPrune(root string, cutoff time.Time) error {
return nil
}
if info.ModTime().Before(cutoff) {
_ = os.Remove(path)
_ = os.Remove(path) // #nosec G122 -- cache pruning is best-effort under the configured cache root.
}
return nil
})
+1 -2
View File
@@ -5,7 +5,6 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
"math/rand"
"strings"
"time"
@@ -304,7 +303,7 @@ func randomWindowDays(min, max int) int {
if max == min {
return min
}
return min + rand.Intn(max-min+1)
return min + secureRandomIntn(max-min+1)
}
func (s *DeviceService) userMatchesCleanupPolicy(ctx context.Context, u *model.User, cfg botConfig) (bool, string) {
+1 -1
View File
@@ -159,7 +159,7 @@ func (d *DiscoverService) Fetch(ctx context.Context, path string) ([]Match, erro
date = r.FirstAirDate
}
if len(date) >= 4 {
fmt.Sscanf(date[:4], "%d", &m.Year)
_, _ = fmt.Sscanf(date[:4], "%d", &m.Year)
}
out = append(out, m)
}
+1 -2
View File
@@ -20,7 +20,6 @@ import (
"context"
"encoding/json"
"fmt"
"math/rand"
"net/http"
"net/url"
"strings"
@@ -117,7 +116,7 @@ func (d *DoubanProvider) Search(ctx context.Context, query string) (*DoubanMatch
}
func (d *DoubanProvider) setHeaders(req *http.Request) {
req.Header.Set("User-Agent", userAgents[rand.Intn(len(userAgents))])
req.Header.Set("User-Agent", userAgents[secureRandomIntn(len(userAgents))])
req.Header.Set("Referer", "https://movie.douban.com/")
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
+5 -5
View File
@@ -1,6 +1,6 @@
// Package service — duplicate-file finder.
//
// DuplicateService computes a sparse-sample MD5 (head + middle + tail,
// DuplicateService computes a sparse-sample SHA-256 (head + middle + tail,
// 1 MiB each, plus the file size to break collisions) for every media
// file and groups identical hashes into "duplicate sets". The first row
// (preferring scraped + larger files) is kept as the primary; the rest
@@ -13,7 +13,7 @@ package service
import (
"context"
"crypto/md5"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
@@ -250,14 +250,14 @@ func pickPrimary(group []model.Media) model.Media {
return group[0]
}
// SparseFileHash computes the head+mid+tail MD5 of a file, suffixed with
// SparseFileHash computes the head+mid+tail SHA-256 of a file, suffixed with
// the file size so two files that happen to collide on the sample window
// but differ in length are still distinguishable.
func SparseFileHash(path string) (string, error) {
if path == "" {
return "", errors.New("empty path")
}
f, err := os.Open(path)
f, err := os.Open(path) // #nosec G304 -- path is selected from configured media library files for duplicate detection.
if err != nil {
return "", err
}
@@ -267,7 +267,7 @@ func SparseFileHash(path string) (string, error) {
return "", err
}
size := st.Size()
h := md5.New()
h := sha256.New()
if size <= int64(sampleSize)*3 {
if _, err := io.Copy(h, f); err != nil {
return "", err
+9 -4
View File
@@ -13,7 +13,7 @@ package service
import (
"context"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
@@ -42,6 +42,11 @@ const embyServerID = "mediastation-go-001"
// the login handshake, even when the API shape is compatible enough for us.
const embyCompatVersion = "4.8.10.0"
const (
embyLocalAuthenticationProviderID = "Emby.Server.Implementations.LocalAuthenticationProvider" // #nosec G101 -- Emby provider identifier, not a credential.
embyLocalPasswordResetProviderID = "Emby.Server.Implementations.LocalPasswordResetProvider" // #nosec G101 -- Emby provider identifier, not a credential.
)
// PlaybackDirectOnlySettingKey 控制「客户端直连解码」模式:开启后宿主机
// 不再提供转码,所有播放交给第三方客户端本地解码(direct play / 302 直链),
// 以释放宿主机 CPU 资源。
@@ -181,8 +186,8 @@ func (e *EmbyService) userPayload(u *model.User) map[string]any {
"EnableAllChannels": true,
"EnableAllFolders": true,
"EnableAllDevices": true,
"AuthenticationProviderId": "Emby.Server.Implementations.LocalAuthenticationProvider",
"PasswordResetProviderId": "Emby.Server.Implementations.LocalPasswordResetProvider",
"AuthenticationProviderId": embyLocalAuthenticationProviderID,
"PasswordResetProviderId": embyLocalPasswordResetProviderID,
},
}
}
@@ -1209,7 +1214,7 @@ func inferSeriesNameFromPath(path string) string {
}
func stableEmbyID(prefix string, parts ...string) string {
h := sha1.New()
h := sha256.New()
for _, part := range parts {
_, _ = h.Write([]byte(strings.ToLower(strings.TrimSpace(part))))
_, _ = h.Write([]byte{0})
+99 -14
View File
@@ -1,6 +1,7 @@
package service
import (
"archive/zip"
"bytes"
"context"
"fmt"
@@ -18,6 +19,11 @@ import (
"github.com/ShukeBta/MediaStationGo/internal/config"
)
const (
maxFFmpegZipEntryBytes = int64(2 << 30)
maxFFmpegZipTotalBytes = int64(4 << 30)
)
// 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) {
@@ -95,7 +101,7 @@ func downloadFFmpegWindows(log *zap.Logger, installDir string) (bool, error) {
log.Info("开始下载 ffmpeg...")
// 创建安装目录
if err := os.MkdirAll(installDir, 0755); err != nil {
if err := os.MkdirAll(installDir, 0o750); err != nil {
return false, fmt.Errorf("创建安装目录失败: %w", err)
}
@@ -175,7 +181,7 @@ func downloadFile(log *zap.Logger, url, filepath string) error {
return fmt.Errorf("下载失败,HTTP 状态码: %d", resp.StatusCode)
}
out, err := os.Create(filepath)
out, err := os.Create(filepath) // #nosec G304 -- filepath is generated by the installer under its temporary download directory.
if err != nil {
return err
}
@@ -185,19 +191,98 @@ func downloadFile(log *zap.Logger, url, filepath string) error {
return err
}
// unzip 解压 ZIP 文件 (简化版,实际应该使用 archive/zip)
// unzip 解压 ZIP 文件。
func unzip(log *zap.Logger, zipPath, destDir string) error {
// Windows 使用 PowerShell 解压
if runtime.GOOS == "windows" {
if err := os.MkdirAll(destDir, 0755); err != nil {
if err := os.MkdirAll(destDir, 0o750); err != nil {
return err
}
destRoot, err := filepath.Abs(destDir)
if err != nil {
return err
}
reader, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer reader.Close()
var totalWritten int64
for _, file := range reader.File {
if file.UncompressedSize64 > uint64(maxFFmpegZipEntryBytes) {
return fmt.Errorf("zip entry too large: %s", file.Name)
}
target, err := safeZipTarget(destRoot, file.Name)
if err != nil {
return err
}
info := file.FileInfo()
if info.Mode()&os.ModeSymlink != 0 {
log.Warn("跳过 ZIP 符号链接", zap.String("name", file.Name))
continue
}
if info.IsDir() {
if err := os.MkdirAll(target, 0o750); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
return err
}
src, err := file.Open()
if err != nil {
return err
}
mode := info.Mode().Perm()
if mode == 0 {
mode = 0o644
}
dst, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) // #nosec G304 -- target is constrained by safeZipTarget to the extraction directory.
if err != nil {
_ = src.Close()
return err
}
written, err := io.Copy(dst, io.LimitReader(src, maxFFmpegZipEntryBytes+1))
totalWritten += written
if err != nil {
_ = dst.Close()
_ = src.Close()
return err
}
if written > maxFFmpegZipEntryBytes || totalWritten > maxFFmpegZipTotalBytes {
_ = dst.Close()
_ = src.Close()
return fmt.Errorf("zip content too large: %s", file.Name)
}
if err := dst.Close(); err != nil {
_ = src.Close()
return err
}
if err := src.Close(); err != nil {
return err
}
cmd := exec.Command("powershell", "-Command", fmt.Sprintf("Expand-Archive -Path '%s' -DestinationPath '%s' -Force", zipPath, destDir))
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
return fmt.Errorf("不支持的操作系统")
return nil
}
func safeZipTarget(destRoot, name string) (string, error) {
trimmed := strings.TrimSpace(name)
if strings.HasPrefix(trimmed, "/") || strings.HasPrefix(trimmed, "\\") || filepath.IsAbs(trimmed) {
return "", fmt.Errorf("unsafe zip path: %s", name)
}
cleanName := filepath.Clean(strings.ReplaceAll(trimmed, "\\", "/"))
if cleanName == "." || strings.HasPrefix(cleanName, "..") || filepath.IsAbs(cleanName) {
return "", fmt.Errorf("unsafe zip path: %s", name)
}
target := filepath.Join(destRoot, cleanName)
targetAbs, err := filepath.Abs(target)
if err != nil {
return "", err
}
rel, err := filepath.Rel(destRoot, targetAbs)
if err != nil || rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
return "", fmt.Errorf("unsafe zip path: %s", name)
}
return targetAbs, nil
}
func findFFmpegPackageRoot(root string) (string, error) {
@@ -268,17 +353,17 @@ func copyTree(srcPath, dstPath string) error {
return nil
}
in, err := os.Open(srcPath)
in, err := os.Open(srcPath) // #nosec G304 -- srcPath is produced by walking the validated extracted ffmpeg package tree.
if err != nil {
return err
}
defer in.Close()
if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil {
if err := os.MkdirAll(filepath.Dir(dstPath), 0o750); err != nil {
return err
}
out, err := os.Create(dstPath)
out, err := os.Create(dstPath) // #nosec G304 -- dstPath is generated under the configured ffmpeg install directory.
if err != nil {
return err
}
+1 -1
View File
@@ -83,7 +83,7 @@ func (f *FFprobeService) Probe(ctx context.Context, path string) (*ProbeResult,
probeCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
cmd := exec.CommandContext(probeCtx, bin,
cmd := exec.CommandContext(probeCtx, bin, // #nosec G204 -- bin is resolved by resolveLocalExecutable before execution.
"-v", "error",
"-print_format", "json",
"-show_format",
+1 -1
View File
@@ -145,7 +145,7 @@ func (s *FileManagerService) CreateFolder(parent, name string) (*FileOperationRe
if !s.withinAllowed(dst, roots) {
return nil, ErrPathOutOfBounds
}
if err := os.MkdirAll(dst, 0o755); err != nil {
if err := os.MkdirAll(dst, 0o755); err != nil { // #nosec G301 -- user-created media directories must remain readable by NAS/player users.
return nil, err
}
return &FileOperationResult{Path: dst}, nil
+19 -19
View File
@@ -15,7 +15,7 @@ package service
import (
"bytes"
"context"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
@@ -241,14 +241,14 @@ func (p *ImageProxy) cloudImageCachePaths(stableKey string) (string, string, str
if stableKey == "" {
stableKey = "unknown"
}
sum := sha1.Sum([]byte("cloud-image:" + stableKey))
sum := sha256.Sum256([]byte("cloud-image:" + stableKey))
key := "cloud-" + hex.EncodeToString(sum[:])
cachePath := filepath.Join(p.cacheDir, key)
return key, cachePath, cachePath + ".fail"
}
func serveCachedImageFile(w http.ResponseWriter, r *http.Request, key, cachePath string) bool {
data, err := os.ReadFile(cachePath)
data, err := os.ReadFile(cachePath) // #nosec G304 -- cachePath is derived from a SHA-256 cache key under the configured cache directory.
if err != nil || len(data) == 0 {
return false
}
@@ -342,14 +342,14 @@ func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.R
}
host := strings.ToLower(u.Host)
// Cache key = sha1(url)
sum := sha1.Sum([]byte(raw))
// Cache key = sha256(url)
sum := sha256.Sum256([]byte(raw))
key := hex.EncodeToString(sum[:])
cachePath := filepath.Join(p.cacheDir, key)
failPath := cachePath + ".fail"
// Cache hit.
if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 {
if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 { // #nosec G304 -- cachePath is derived from a SHA-256 cache key under the configured cache directory.
w.Header().Set("Content-Type", detectContentType(data))
w.Header().Set("Cache-Control", imageBrowserCacheControl)
stat, _ := os.Stat(cachePath)
@@ -368,7 +368,7 @@ func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.R
}
// Cache miss → fetch upstream.
if err := os.MkdirAll(p.cacheDir, 0o755); err != nil {
if err := os.MkdirAll(p.cacheDir, 0o750); err != nil {
p.log.Warn("imageproxy: mkdir failed", zap.String("dir", p.cacheDir), zap.Error(err))
servePlaceholder(w)
return nil
@@ -417,14 +417,14 @@ func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.R
tmp, tmpErr := os.CreateTemp(p.cacheDir, "img-*.tmp")
if tmpErr == nil {
if _, werr := tmp.Write(data); werr == nil {
tmp.Close()
_ = tmp.Close()
if rerr := os.Rename(tmp.Name(), cachePath); rerr != nil {
_ = os.Remove(tmp.Name())
} else {
_ = os.Remove(failPath)
}
} else {
tmp.Close()
_ = tmp.Close()
_ = os.Remove(tmp.Name())
}
}
@@ -502,7 +502,7 @@ func (p *ImageProxy) PrefetchCloudResolved(ctx context.Context, stableKey string
}
func (p *ImageProxy) fetchAndCacheCloudImage(ctx context.Context, stableKey string, link *cloud.DirectLink, userAgent string) ([]byte, string, error) {
if err := os.MkdirAll(p.cacheDir, 0o755); err != nil {
if err := os.MkdirAll(p.cacheDir, 0o750); err != nil {
return nil, "", err
}
_, cachePath, failPath := p.cloudImageCachePaths(stableKey)
@@ -547,14 +547,14 @@ func (p *ImageProxy) fetchAndCacheCloudImage(ctx context.Context, stableKey stri
tmp, tmpErr := os.CreateTemp(p.cacheDir, "img-cloud-*.tmp")
if tmpErr == nil {
if _, werr := tmp.Write(data); werr == nil {
tmp.Close()
_ = tmp.Close()
if rerr := os.Rename(tmp.Name(), cachePath); rerr != nil {
_ = os.Remove(tmp.Name())
} else {
_ = os.Remove(failPath)
}
} else {
tmp.Close()
_ = tmp.Close()
_ = os.Remove(tmp.Name())
}
}
@@ -568,12 +568,12 @@ func (p *ImageProxy) fetchAndCacheCloudImage(ctx context.Context, stableKey stri
}
func (p *ImageProxy) markImageFetchFailed(failPath string) {
if err := os.MkdirAll(filepath.Dir(failPath), 0o755); err != nil {
if err := os.MkdirAll(filepath.Dir(failPath), 0o750); err != nil {
return
}
p.mu.Lock()
defer p.mu.Unlock()
_ = os.WriteFile(failPath, []byte(time.Now().Format(time.RFC3339Nano)), 0o644)
_ = os.WriteFile(failPath, []byte(time.Now().Format(time.RFC3339Nano)), 0o600)
}
// Fetch 拉取远程图片并返回字节和 Content-Type(带缓存)。
@@ -584,16 +584,16 @@ func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, err
}
// Cache lookup
sum := sha1.Sum([]byte(raw))
sum := sha256.Sum256([]byte(raw))
key := hex.EncodeToString(sum[:])
cachePath := filepath.Join(p.cacheDir, key)
if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 {
if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 { // #nosec G304 -- cachePath is derived from a SHA-256 cache key under the configured cache directory.
return data, detectContentType(data), nil
}
// Fetch upstream
if err := os.MkdirAll(p.cacheDir, 0o755); err != nil {
if err := os.MkdirAll(p.cacheDir, 0o750); err != nil {
return nil, "", err
}
@@ -622,12 +622,12 @@ func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, err
tmp, terr := os.CreateTemp(p.cacheDir, "img-*.tmp")
if terr == nil {
if _, werr := tmp.Write(data); werr == nil {
tmp.Close()
_ = tmp.Close()
if rerr := os.Rename(tmp.Name(), cachePath); rerr != nil {
_ = os.Remove(tmp.Name())
}
} else {
tmp.Close()
_ = tmp.Close()
_ = os.Remove(tmp.Name())
}
}
+2 -2
View File
@@ -202,7 +202,7 @@ func readSeriesMetadata(mediaPath, libraryRoot string) (*LocalMetadata, error) {
}
func readNFO(path string) (*nfoDocument, string, error) {
body, err := os.ReadFile(path)
body, err := os.ReadFile(path) // #nosec G304 -- path is a discovered NFO sidecar under the configured library root.
if err != nil {
return nil, "", err
}
@@ -647,7 +647,7 @@ func isRejectedPosterName(name string) bool {
}
func likelyPosterImage(path string) bool {
file, err := os.Open(path)
file, err := os.Open(path) // #nosec G304 -- path is a discovered artwork sidecar under the configured library root.
if err != nil {
return false
}
+1 -1
View File
@@ -110,7 +110,7 @@ func localExecutableCandidates(name string) []string {
func commandOutput(ctx context.Context, timeout time.Duration, name string, args ...string) ([]byte, error) {
cmdCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(cmdCtx, name, args...)
cmd := exec.CommandContext(cmdCtx, name, args...) // #nosec G204 -- callers pass paths resolved by resolveLocalExecutable.
out, err := cmd.CombinedOutput()
if cmdCtx.Err() != nil {
return out, cmdCtx.Err()
+3 -3
View File
@@ -4,8 +4,8 @@
// source video. We export a minimal subset that those scrapers consume
// happily:
//
// movie.mkv -> movie.nfo (<movie>...</movie>)
// tvshow/ -> tvshow.nfo (<tvshow>...</tvshow>) [future]
// movie.mkv -> movie.nfo (<movie>...</movie>)
// tvshow/ -> tvshow.nfo (<tvshow>...</tvshow>) [future]
//
// Today only the per-movie writer is implemented; the per-show / per-episode
// writers are stubbed with TODO markers.
@@ -168,7 +168,7 @@ func WriteMediaNFO(m *model.Media) (string, error) {
return "", err
}
dst := nfoPath(m.Path)
if err := os.WriteFile(dst, []byte(xml.Header+string(out)+"\n"), 0o644); err != nil {
if err := os.WriteFile(dst, []byte(xml.Header+string(out)+"\n"), 0o644); err != nil { // #nosec G306 -- NFO sidecars must remain readable by media players.
return "", err
}
return dst, nil
+6 -6
View File
@@ -162,7 +162,7 @@ func (o *OrganizerService) OrganizeMediaWithOptions(ctx context.Context, mediaID
}
// Create directories.
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { // #nosec G301 -- organized media directories must remain readable by NAS/player users.
return "", err
}
@@ -360,19 +360,19 @@ func moveFile(src, dst string) error {
// Cross-device: stream copy → remove. This can temporarily consume the
// destination file size while copying, but the source is removed after the
// copy succeeds.
in, err := os.Open(src)
in, err := os.Open(src) // #nosec G304 -- src is selected from configured media/download roots by the organizer.
if err != nil {
return err
}
defer in.Close()
// O_EXCL 保证不会覆盖已存在的目标。
f, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
f, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) // #nosec G304,G302 -- dst is organizer-generated; media files must remain readable by local players.
if err != nil {
return err
}
if _, werr := io.Copy(f, in); werr != nil {
f.Close()
os.Remove(dst)
_ = f.Close()
_ = os.Remove(dst)
return werr
}
if cerr := f.Close(); cerr != nil {
@@ -398,7 +398,7 @@ func transferSidecarNFO(srcMedia, dstMedia string, mode TransferMode) error {
if _, err := os.Stat(dst); err == nil {
return nil
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { // #nosec G301 -- sidecar media directories must remain readable by NAS/player users.
return err
}
return transferFile(src, dst, mode)
+2 -2
View File
@@ -294,7 +294,7 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo
res.Organized++
return nil
}
if err := os.MkdirAll(destDir, 0o755); err != nil {
if err := os.MkdirAll(destDir, 0o755); err != nil { // #nosec G301 -- organized media directories must remain readable by NAS/player users.
return err
}
if _, err := os.Stat(dst); err == nil {
@@ -591,7 +591,7 @@ func (o *OrganizerService) replaceVersions(ctx context.Context, src string, exis
_ = o.repo.DB.WithContext(ctx).Where("path = ?", e).Delete(&model.Media{}).Error
}
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { // #nosec G301 -- organized media directories must remain readable by NAS/player users.
return err
}
if err := transferFile(src, dst, mode); err != nil {
+2 -2
View File
@@ -16,7 +16,7 @@ package service
import (
"bytes"
"context"
"crypto/sha1"
"crypto/sha1" // #nosec G505 -- BitTorrent v1 info-hash is SHA-1 by protocol.
"encoding/hex"
"encoding/json"
"errors"
@@ -335,7 +335,7 @@ func torrentInfoHash(data []byte) string {
if !ok {
return ""
}
sum := sha1.Sum(data[start:end])
sum := sha1.Sum(data[start:end]) // #nosec G401 -- BitTorrent v1 info-hash is SHA-1 by protocol, not a security hash.
return hex.EncodeToString(sum[:])
}
+13 -11
View File
@@ -664,11 +664,7 @@ func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, auto
// Online enrichment is opt-in. Local NFO is always consumed first during
// the scan, and matched rows are excluded from EnrichLibrary's pending set.
if autoScrape && s.scraper != nil && s.scraper.AnyEnabled() && s.autoScrapeEnabled(ctx) {
go func(libID string) {
if _, err := s.scraper.EnrichLibrary(context.Background(), libID); err != nil {
s.log.Warn("scraper enrich failed", zap.Error(err))
}
}(lib.ID)
s.startAutoScrape(ctx, lib.ID)
}
return res, nil
}
@@ -872,15 +868,21 @@ func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Librar
})
s.maybeGenerateSTRMAfterScan(lib.ID)
if autoScrape && s.scraper != nil && s.scraper.AnyEnabled() && s.autoScrapeEnabled(ctx) {
go func(libID string) {
if _, err := s.scraper.EnrichLibrary(context.Background(), libID); err != nil {
s.log.Warn("scraper enrich failed", zap.Error(err))
}
}(lib.ID)
s.startAutoScrape(ctx, lib.ID)
}
return res, nil
}
func (s *ScannerService) startAutoScrape(ctx context.Context, libraryID string) {
scrapeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Minute)
go func() {
defer cancel()
if _, err := s.scraper.EnrichLibrary(scrapeCtx, libraryID); err != nil {
s.log.Warn("scraper enrich failed", zap.Error(err))
}
}()
}
func (s *ScannerService) existingCloudMediaPaths(ctx context.Context, libraryID string) (map[string]struct{}, error) {
var rows []struct {
Path string
@@ -1299,7 +1301,7 @@ func (s *ScannerService) resolveCloudSTRMTarget(ctx context.Context, typ, ref st
}
func readLocalSTRMTarget(path string) (string, error) {
data, err := os.ReadFile(path)
data, err := os.ReadFile(path) // #nosec G304 -- path is a discovered .strm file under the configured library root.
if err != nil {
return "", err
}
+1 -2
View File
@@ -13,7 +13,6 @@ package service
import (
"context"
"math/rand"
"path/filepath"
"regexp"
"strconv"
@@ -688,7 +687,7 @@ func (s *ScraperService) scrapeDelay(ctx context.Context) time.Duration {
if maxMS == minMS {
return time.Duration(minMS) * time.Millisecond
}
return time.Duration(minMS+rand.Intn(maxMS-minMS+1)) * time.Millisecond
return time.Duration(minMS+secureRandomIntn(maxMS-minMS+1)) * time.Millisecond
}
func (s *ScraperService) scrapeDelaySetting(ctx context.Context, key string, fallback int) int {
+44
View File
@@ -0,0 +1,44 @@
package service
import (
"crypto/rand"
"crypto/sha256"
"math/big"
"strconv"
"time"
)
func secureRandomBytes(buf []byte) {
if len(buf) == 0 {
return
}
if _, err := rand.Read(buf); err == nil {
return
}
seed := sha256.Sum256([]byte(time.Now().Format(time.RFC3339Nano)))
for offset := 0; offset < len(buf); {
offset += copy(buf[offset:], seed[:])
seed = sha256.Sum256(seed[:])
}
}
func secureRandomIntn(max int) int {
if max <= 0 {
return 0
}
n, err := rand.Int(rand.Reader, big.NewInt(int64(max)))
if err == nil {
value, convErr := strconv.Atoi(n.String())
if convErr == nil {
return value
}
}
seed := sha256.Sum256([]byte(time.Now().Format(time.RFC3339Nano)))
value := new(big.Int).SetBytes(seed[:])
value.Mod(value, big.NewInt(int64(max)))
fallback, convErr := strconv.Atoi(value.String())
if convErr != nil {
return 0
}
return fallback
}
+59
View File
@@ -0,0 +1,59 @@
package service
import (
"path/filepath"
"testing"
"github.com/ShukeBta/MediaStationGo/internal/config"
)
func TestValidateSTRMProxyURLBlocksPrivateTargets(t *testing.T) {
blocked := []string{
"http://127.0.0.1/video.mkv",
"http://192.168.1.2/video.mkv",
"http://169.254.169.254/latest/meta-data",
"file:///etc/passwd",
}
for _, raw := range blocked {
if _, err := validateSTRMProxyURL(raw); err == nil {
t.Fatalf("validateSTRMProxyURL(%q) allowed unsafe target", raw)
}
}
}
func TestValidateSTRMProxyURLAllowsPublicHTTP(t *testing.T) {
for _, raw := range []string{"https://example.com/video.mkv", "http://8.8.8.8/video.mkv"} {
if _, err := validateSTRMProxyURL(raw); err != nil {
t.Fatalf("validateSTRMProxyURL(%q) = %v, want nil", raw, err)
}
}
}
func TestBackupFilePathRejectsTraversal(t *testing.T) {
svc := &BackupService{cfg: &config.Config{}}
svc.cfg.App.DataDir = t.TempDir()
for _, name := range []string{"../evil.db", `..\evil.db`, "nested/evil.db", "evil.sqlite"} {
if _, err := svc.backupFilePath(name); err == nil {
t.Fatalf("backupFilePath(%q) allowed traversal or non-backup file", name)
}
}
path, err := svc.backupFilePath("mediastation_20260611_010203.db")
if err != nil {
t.Fatalf("backupFilePath(valid) = %v", err)
}
if filepath.Dir(path) != filepath.Join(svc.cfg.App.DataDir, "backups") {
t.Fatalf("backupFilePath(valid) dir = %q", filepath.Dir(path))
}
}
func TestSafeZipTargetRejectsZipSlip(t *testing.T) {
root := t.TempDir()
for _, name := range []string{"../evil.exe", `..\evil.exe`, "/tmp/evil.exe"} {
if _, err := safeZipTarget(root, name); err == nil {
t.Fatalf("safeZipTarget(%q) allowed zip-slip path", name)
}
}
if _, err := safeZipTarget(root, "ffmpeg/bin/ffmpeg.exe"); err != nil {
t.Fatalf("safeZipTarget(valid) = %v", err)
}
}
+4
View File
@@ -249,6 +249,9 @@ func (c *Container) Boot() {
// 启动调度器定时任务
c.Scheduler.Start(c.stopCtx)
// 自动扫描云盘媒体库,使内容对所有用户立即可见
c.BootCloudLibraries(c.stopCtx)
// 账号删号/保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。
// 每天触发一次评估;规则里的窗口可随机,不固定。
if c.Device != nil {
@@ -335,3 +338,4 @@ func (c *Container) Close() {
// unused guard
var _ = time.Now
+2 -2
View File
@@ -301,7 +301,7 @@ func (a *alistUploader) exists(ctx context.Context, remotePath string) (bool, er
}
func (a *alistUploader) upload(ctx context.Context, localPath, remotePath string, size int64) error {
f, err := os.Open(localPath)
f, err := os.Open(localPath) // #nosec G304 -- localPath is selected from configured local media files before upload.
if err != nil {
return err
}
@@ -418,7 +418,7 @@ func (w *webDAVUploader) exists(ctx context.Context, remotePath string) (bool, e
}
func (w *webDAVUploader) upload(ctx context.Context, localPath, remotePath string, size int64) error {
f, err := os.Open(localPath)
f, err := os.Open(localPath) // #nosec G304 -- localPath is selected from configured local media files before upload.
if err != nil {
return err
}
+3 -3
View File
@@ -245,7 +245,7 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request,
return errors.New("hls playlist not ready")
}
playlist := s.transcoder.PlaylistPath(mediaID)
f, err := os.Open(playlist)
f, err := os.Open(playlist) // #nosec G304 -- playlist path is generated under the transcoder cache directory for this media ID.
if err != nil {
return err
}
@@ -304,10 +304,10 @@ func (s *StreamService) ServeHLSSegment(w http.ResponseWriter, r *http.Request,
return err
}
dir, _ := filepath.Abs(s.transcoder.HLSDir(mediaID))
if !strings.HasPrefix(abs, dir) {
if !pathWithin(abs, dir) {
return errors.New("path escape")
}
f, err := os.Open(abs)
f, err := os.Open(abs) // #nosec G304 -- abs is constrained to the HLS cache directory with pathWithin.
if err != nil {
return err
}
+26 -5
View File
@@ -102,7 +102,7 @@ func (s *STRMService) GenerateForLibrary(ctx context.Context, opts GenerateSTRMO
_ = s.repo.Setting.Set(ctx, "strm.auto_generate_enabled", strconv.FormatBool(opts.Enabled))
_ = s.repo.Setting.Set(ctx, "strm.output_dir", outputDir)
}
if err := os.MkdirAll(outputDir, 0o755); err != nil {
if err := os.MkdirAll(outputDir, 0o755); err != nil { // #nosec G301 -- STRM output directories must stay readable by NAS/player users.
return nil, err
}
@@ -175,12 +175,12 @@ func (s *STRMService) generateOne(ctx context.Context, lib model.Library, media
if _, err := os.Stat(filePath); err == nil {
action = "updated"
}
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { // #nosec G301 -- STRM output directories must stay readable by NAS/player users.
item.Action = "error"
item.Reason = err.Error()
return item
}
if err := os.WriteFile(filePath, []byte(playURL+"\n"), 0o644); err != nil {
if err := os.WriteFile(filePath, []byte(playURL+"\n"), 0o644); err != nil { // #nosec G306 -- STRM files are media sidecars intended to be readable by players.
item.Action = "error"
item.Reason = err.Error()
return item
@@ -429,8 +429,13 @@ func (s *STRMService) ProxySTRM(ctx context.Context, id string, req *http.Reques
return ErrSTRMProtocolInvalid
}
targetURL, err := validateSTRMProxyURL(record.URL)
if err != nil {
return err
}
// 创建代理请求
proxyReq, err := http.NewRequestWithContext(ctx, req.Method, record.URL, nil)
proxyReq, err := http.NewRequestWithContext(ctx, req.Method, targetURL.String(), nil)
if err != nil {
return fmt.Errorf("create proxy request: %w", err)
}
@@ -452,7 +457,7 @@ func (s *STRMService) ProxySTRM(ctx context.Context, id string, req *http.Reques
}
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(proxyReq)
resp, err := client.Do(proxyReq) // #nosec G107,G704 -- STRM proxy target is validated by validateSTRMProxyURL before request creation.
if err != nil {
return fmt.Errorf("proxy request failed: %w", err)
}
@@ -474,6 +479,22 @@ func (s *STRMService) ProxySTRM(ctx context.Context, id string, req *http.Reques
return err
}
func validateSTRMProxyURL(raw string) (*url.URL, error) {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Scheme == "" || u.Host == "" {
return nil, ErrSTRMURLInvalid
}
switch strings.ToLower(u.Scheme) {
case "http", "https":
default:
return nil, ErrSTRMProtocolInvalid
}
if isPrivateHost(u.Hostname()) {
return nil, ErrSTRMURLInvalid
}
return u, nil
}
// validateSTRM 验证 STRM 记录。
func (s *STRMService) validateSTRM(record *model.STRMRecord) error {
if record.Title == "" {
+6 -6
View File
@@ -6,10 +6,10 @@
//
// External-subtitle discovery rules (matching MediaStation Python defaults):
//
// 1. Same directory, same basename, different extension.
// 2. Same directory, ".sub/" or "subs/" subdirectory.
// 3. Sibling languages e.g. movie.zh.srt / movie.en.srt → exposed as
// ?lang=zh / ?lang=en.
// 1. Same directory, same basename, different extension.
// 2. Same directory, ".sub/" or "subs/" subdirectory.
// 3. Sibling languages e.g. movie.zh.srt / movie.en.srt → exposed as
// ?lang=zh / ?lang=en.
//
// Supported extensions: .srt, .ass, .ssa, .vtt.
package service
@@ -139,11 +139,11 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W
return err
}
mediaDir, _ := filepath.Abs(filepath.Dir(m.Path))
if !strings.HasPrefix(abs, mediaDir) {
if !pathWithin(abs, mediaDir) {
return fmt.Errorf("path escape")
}
f, err := os.Open(abs)
f, err := os.Open(abs) // #nosec G304 -- abs is constrained to the media file directory with pathWithin.
if err != nil {
return err
}
+2 -2
View File
@@ -224,7 +224,7 @@ func telegramPostJSONDecode(ctx context.Context, cfg map[string]string, method s
continue
}
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
_ = resp.Body.Close()
if resp.StatusCode >= 400 {
lastErr = fmt.Errorf("telegram api error %d: %s", resp.StatusCode, sanitizeTelegramText(string(respBody)))
continue
@@ -257,7 +257,7 @@ func telegramGetJSONDecode(ctx context.Context, cfg map[string]string, method st
continue
}
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
_ = resp.Body.Close()
if resp.StatusCode >= 400 {
lastErr = fmt.Errorf("telegram api error %d: %s", resp.StatusCode, sanitizeTelegramText(string(respBody)))
continue
+13 -5
View File
@@ -518,7 +518,9 @@ func (s *TelegramBotService) cmdStatus(ctx context.Context) (telegramCommandRepl
s.mediaStatsQuery(libraryIDs).Count(&mediaCount)
var totalSize int64
s.mediaStatsQuery(libraryIDs).Select("COALESCE(SUM(size_bytes), 0)").Row().Scan(&totalSize)
if err := s.mediaStatsQuery(libraryIDs).Select("COALESCE(SUM(size_bytes), 0)").Row().Scan(&totalSize); err != nil {
return telegramCommandReply{}, err
}
totalSizeGB := float64(totalSize) / 1024 / 1024 / 1024
return telegramCommandReply{Text: fmt.Sprintf(
@@ -621,7 +623,9 @@ func (s *TelegramBotService) cmdStats(ctx context.Context) (telegramCommandReply
s.mediaStatsQuery(libraryIDs).Count(&totalMedia)
var totalSize int64
s.mediaStatsQuery(libraryIDs).Select("COALESCE(SUM(size_bytes), 0)").Row().Scan(&totalSize)
if err := s.mediaStatsQuery(libraryIDs).Select("COALESCE(SUM(size_bytes), 0)").Row().Scan(&totalSize); err != nil {
return telegramCommandReply{}, err
}
type LibStat struct {
Name string
@@ -799,8 +803,10 @@ func (s *TelegramBotService) pollLoop(ctx context.Context, cfg map[string]string
continue
}
go func(u TelegramUpdate) {
handlerCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
raw, _ := json.Marshal(u)
_ = s.HandleWebhook(context.Background(), raw)
_ = s.HandleWebhook(handlerCtx, raw)
}(upd)
}
}
@@ -830,7 +836,7 @@ func telegramPollingRequest(ctx context.Context, clients []*http.Client, pollURL
continue
}
respBody, _ := io.ReadAll(resp.Body)
resp.Body.Close()
_ = resp.Body.Close()
if resp.StatusCode >= 400 {
lastErr = fmt.Errorf("telegram api error %d: %s", resp.StatusCode, sanitizeTelegramText(string(respBody)))
continue
@@ -965,7 +971,9 @@ func (s *TelegramBotService) findChannelByChatID(ctx context.Context, chatID int
configStr = s.crypto.Decrypt(configStr)
}
var cfg map[string]string
json.Unmarshal([]byte(configStr), &cfg)
if err := json.Unmarshal([]byte(configStr), &cfg); err != nil {
continue
}
if cfg["chat_id"] == target || cfg["command_chat_id"] == target ||
cfg["group_chat_id"] == target || cfg["channel_chat_id"] == target {
return &ch
+4 -4
View File
@@ -3,9 +3,9 @@
// TheTVDBProvider implements two methods used by the scraper for TV /
// anime libraries:
//
// Login() -> exchanges secrets.thetvdb_api_key for
// a JWT (cached for 24h).
// SearchSeries(query) -> /search?query=...&type=series
// Login() -> exchanges secrets.thetvdb_api_key for
// a JWT (cached for 24h).
// SearchSeries(query) -> /search?query=...&type=series
//
// The provider is enabled iff secrets.thetvdb_api_key is non-empty. When
// disabled every method returns nil, nil so the scraper orchestrator can
@@ -133,7 +133,7 @@ func (t *TheTVDBProvider) SearchSeries(ctx context.Context, query string) (*Matc
PosterURL: r.Image,
}
if len(r.Year) >= 4 {
fmt.Sscanf(r.Year[:4], "%d", &m.Year)
_, _ = fmt.Sscanf(r.Year[:4], "%d", &m.Year)
}
return m, nil
}
+2 -2
View File
@@ -197,7 +197,7 @@ func (t *TMDbProvider) SearchMovie(ctx context.Context, query string, year int)
m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath
}
if len(r.ReleaseDate) >= 4 {
fmt.Sscanf(r.ReleaseDate[:4], "%d", &m.Year)
_, _ = fmt.Sscanf(r.ReleaseDate[:4], "%d", &m.Year)
}
return m, nil
}
@@ -263,7 +263,7 @@ func (t *TMDbProvider) SearchTV(ctx context.Context, query string, year int) (*M
m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath
}
if len(r.FirstAirDate) >= 4 {
fmt.Sscanf(r.FirstAirDate[:4], "%d", &m.Year)
_, _ = fmt.Sscanf(r.FirstAirDate[:4], "%d", &m.Year)
}
return m, nil
}
+2 -2
View File
@@ -124,7 +124,7 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
}
outDir := t.HLSDir(mediaID)
if err := os.MkdirAll(outDir, 0o755); err != nil {
if err := os.MkdirAll(outDir, 0o750); err != nil {
t.mu.Unlock()
return "", err
}
@@ -294,7 +294,7 @@ func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, source s
args := buildFFmpegArgs(t.cfg, source, playlist, segments)
cmd := exec.CommandContext(ctx, bin, args...)
cmd := exec.CommandContext(ctx, bin, args...) // #nosec G204 -- bin is resolved by resolveFFmpegPath and args are passed without a shell.
cmd.Stderr = os.Stderr
t.log.Info("transcode started",
+4 -4
View File
@@ -79,18 +79,18 @@ func transferFile(src, dst string, mode TransferMode) error {
// copyFile 流式复制 src 到 dst(保留源文件)。O_EXCL 保证不覆盖已存在目标。
func copyFile(src, dst string) error {
in, err := os.Open(src)
in, err := os.Open(src) // #nosec G304 -- src is selected from configured media/download roots by the organizer.
if err != nil {
return err
}
defer in.Close()
f, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
f, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) // #nosec G304,G302 -- dst is organizer-generated; media files must remain readable by local players.
if err != nil {
return err
}
if _, werr := io.Copy(f, in); werr != nil {
f.Close()
os.Remove(dst)
_ = f.Close()
_ = os.Remove(dst)
return werr
}
return f.Close()
+1 -1
View File
@@ -84,7 +84,7 @@ func (a *TransmissionAdapter) pingLocked(ctx context.Context) error {
return err
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode == 409 {
// 正常:需要 CSRF token
a.sessionID = resp.Header.Get("X-Transmission-Session-Id")