mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +08:00
refactor: split modules and harden scraping workflows
This commit is contained in:
@@ -24,6 +24,9 @@ func listUsersHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if svc.Sessions != nil {
|
||||
svc.Sessions.ApplyToUsers(c.Request.Context(), users)
|
||||
}
|
||||
c.JSON(http.StatusOK, users)
|
||||
}
|
||||
}
|
||||
@@ -132,6 +135,10 @@ func deleteUserHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "default admin cannot be deleted"})
|
||||
return
|
||||
}
|
||||
if svc.Sessions != nil && svc.Sessions.UserRecentlyActive(c.Request.Context(), c.Param("id"), service.RealtimeDeletionGuardWindow()) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "user has a recent realtime session; confirm the user is offline before deletion"})
|
||||
return
|
||||
}
|
||||
if err := svc.Repo.User.Delete(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
func TestDeleteUserRefusesRecentRealtimeSession(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
admin := model.User{Base: model.Base{ID: "admin"}, Username: "admin", PasswordHash: "x", Role: "admin", IsActive: true}
|
||||
viewer := model.User{Base: model.Base{ID: "viewer"}, Username: "viewer", PasswordHash: "x", Role: "user", IsActive: true}
|
||||
if err := repos.DB.Create(&[]model.User{admin, viewer}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tracker := service.NewSessionTrackerService(zap.NewNop())
|
||||
tracker.RecordLogin(t.Context(), viewer.ID, viewer.Username, "dev-1", "Apple TV", "Yamby", "10.0.0.8")
|
||||
svc := &service.Container{Repo: repos, Sessions: tracker}
|
||||
router := gin.New()
|
||||
router.DELETE("/admin/users/:id", deleteUserHandler(svc))
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/admin/users/viewer", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if found, _ := repos.User.FindByID(t.Context(), viewer.ID); found == nil {
|
||||
t.Fatal("recent realtime user should not be deleted")
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,12 @@ func loginHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if svc.Sessions != nil {
|
||||
svc.Sessions.RecordLogin(c.Request.Context(), resp.User.ID, resp.User.Username, "", "Web", "Web", c.ClientIP())
|
||||
}
|
||||
if resp.Tokens != nil {
|
||||
setAccessTokenCookie(c, resp.Tokens.AccessToken, int(resp.Tokens.ExpiresIn))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": resp.User,
|
||||
"tokens": resp.Tokens,
|
||||
@@ -65,6 +71,9 @@ func registerHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if tokens != nil {
|
||||
setAccessTokenCookie(c, tokens.AccessToken, int(tokens.ExpiresIn))
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"user": u,
|
||||
"tokens": tokens,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
func setAccessTokenCookie(c *gin.Context, token string, maxAgeSeconds int) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return
|
||||
}
|
||||
if maxAgeSeconds <= 0 {
|
||||
maxAgeSeconds = int(service.AccessTokenDuration.Seconds())
|
||||
}
|
||||
writeAccessTokenCookie(c, token, maxAgeSeconds)
|
||||
}
|
||||
|
||||
func clearAccessTokenCookie(c *gin.Context) {
|
||||
writeAccessTokenCookie(c, "", -1)
|
||||
}
|
||||
|
||||
func writeAccessTokenCookie(c *gin.Context, value string, maxAgeSeconds int) {
|
||||
cookie := &http.Cookie{
|
||||
Name: middleware.AccessTokenCookieName,
|
||||
Value: value,
|
||||
Path: middleware.AccessTokenCookiePath,
|
||||
MaxAge: maxAgeSeconds,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: requestIsHTTPS(c),
|
||||
}
|
||||
if maxAgeSeconds > 0 {
|
||||
cookie.Expires = time.Now().Add(time.Duration(maxAgeSeconds) * time.Second)
|
||||
} else if maxAgeSeconds < 0 {
|
||||
cookie.Expires = time.Unix(0, 0)
|
||||
}
|
||||
http.SetCookie(c.Writer, cookie)
|
||||
}
|
||||
|
||||
func requestIsHTTPS(c *gin.Context) bool {
|
||||
if c == nil || c.Request == nil {
|
||||
return false
|
||||
}
|
||||
if c.Request.TLS != nil {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
||||
)
|
||||
|
||||
func TestSetAccessTokenCookie(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "https://media.local/api/auth/login", nil)
|
||||
|
||||
setAccessTokenCookie(c, "access-token", 3600)
|
||||
|
||||
cookie := findResponseCookie(t, w, middleware.AccessTokenCookieName)
|
||||
if cookie.Value != "access-token" {
|
||||
t.Fatalf("cookie value = %q", cookie.Value)
|
||||
}
|
||||
if cookie.Path != middleware.AccessTokenCookiePath {
|
||||
t.Fatalf("cookie path = %q, want %q", cookie.Path, middleware.AccessTokenCookiePath)
|
||||
}
|
||||
if cookie.MaxAge != 3600 {
|
||||
t.Fatalf("cookie max age = %d, want 3600", cookie.MaxAge)
|
||||
}
|
||||
if !cookie.HttpOnly {
|
||||
t.Fatal("cookie should be HttpOnly")
|
||||
}
|
||||
if !cookie.Secure {
|
||||
t.Fatal("https request should set Secure cookie")
|
||||
}
|
||||
if cookie.SameSite != http.SameSiteLaxMode {
|
||||
t.Fatalf("cookie SameSite = %v, want Lax", cookie.SameSite)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearAccessTokenCookie(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "http://127.0.0.1:8080/api/me/logout", nil)
|
||||
|
||||
clearAccessTokenCookie(c)
|
||||
|
||||
cookie := findResponseCookie(t, w, middleware.AccessTokenCookieName)
|
||||
if cookie.MaxAge >= 0 {
|
||||
t.Fatalf("clear cookie max age = %d, want negative", cookie.MaxAge)
|
||||
}
|
||||
if cookie.Path != middleware.AccessTokenCookiePath {
|
||||
t.Fatalf("cookie path = %q, want %q", cookie.Path, middleware.AccessTokenCookiePath)
|
||||
}
|
||||
if cookie.Secure {
|
||||
t.Fatal("plain http request should not set Secure cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func findResponseCookie(t *testing.T, w *httptest.ResponseRecorder, name string) *http.Cookie {
|
||||
t.Helper()
|
||||
for _, cookie := range w.Result().Cookies() {
|
||||
if cookie.Name == name {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing response cookie %q", name)
|
||||
return nil
|
||||
}
|
||||
@@ -39,6 +39,7 @@ func refreshHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// so the Vue frontend's logout button gets a 200 instead of 404.
|
||||
func logoutHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
clearAccessTokenCookie(c)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
+60
-214
@@ -3,18 +3,10 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
@@ -25,6 +17,10 @@ import (
|
||||
func cloudListHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
typ := c.Param("type")
|
||||
if !service.IsAdminCloudConfigurable(typ) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported cloud provider", "items": []any{}})
|
||||
return
|
||||
}
|
||||
dir := c.Query("dir")
|
||||
entries, err := svc.StorageCfg.CloudList(c.Request.Context(), typ, dir)
|
||||
if err != nil {
|
||||
@@ -35,10 +31,62 @@ func cloudListHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func cloudMkdirHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
typ := c.Param("type")
|
||||
if !service.IsAdminCloudConfigurable(typ) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported cloud provider"})
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Dir string `json:"dir"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
entry, err := svc.StorageCfg.CloudMkdir(c.Request.Context(), typ, in.Dir, in.Name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"entry": entry})
|
||||
}
|
||||
}
|
||||
|
||||
func cloudRenameHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
typ := c.Param("type")
|
||||
if !service.IsAdminCloudConfigurable(typ) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported cloud provider"})
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Ref string `json:"ref" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
entry, err := svc.StorageCfg.CloudRename(c.Request.Context(), typ, in.Ref, in.Name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"entry": entry})
|
||||
}
|
||||
}
|
||||
|
||||
// cloudImportHandler turns a cloud file into a playable 302-backed media item.
|
||||
func cloudImportHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
typ := c.Param("type")
|
||||
if !service.IsAdminCloudConfigurable(typ) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported cloud provider"})
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Ref string `json:"ref" binding:"required"`
|
||||
Name string `json:"name"`
|
||||
@@ -63,6 +111,10 @@ func cloudImportHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func cloudMountHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
typ := c.Param("type")
|
||||
if !service.IsAdminCloudConfigurable(typ) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported cloud provider"})
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Dir string `json:"dir"`
|
||||
DirPath string `json:"dir_path"`
|
||||
@@ -284,209 +336,3 @@ func cloud115QRPollHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusOK, st)
|
||||
}
|
||||
}
|
||||
|
||||
// cloudPlayHandler resolves a cloud file to its direct link and either issues a
|
||||
// 302 redirect (true offload — host does not stream the bytes) or, when the
|
||||
// provider requires authenticated headers, reverse-proxies the response.
|
||||
func cloudPlayHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
typ := c.Param("type")
|
||||
ref := c.Query("ref")
|
||||
if ref == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ref required"})
|
||||
return
|
||||
}
|
||||
if !enforceScopedCloudPlaybackToken(c, svc, typ, ref) {
|
||||
return
|
||||
}
|
||||
serveCloudResolvedLink(svc, c, typ, ref)
|
||||
}
|
||||
}
|
||||
|
||||
func serveCloudResolvedLink(svc *service.Container, c *gin.Context, typ, ref string) {
|
||||
if isCloudImageRef(ref) && svc != nil && svc.ImageProxy != nil {
|
||||
if svc.ImageProxy.ServeCloudCached(c.Writer, c.Request, typ+":"+ref) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if svc == nil || svc.StorageCfg == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud storage service unavailable"})
|
||||
return
|
||||
}
|
||||
resolveStart := time.Now()
|
||||
link, err := svc.StorageCfg.CloudResolve(c.Request.Context(), typ, ref, c.Request.UserAgent())
|
||||
resolveDur := time.Since(resolveStart)
|
||||
if err != nil {
|
||||
logCloudPlayback(svc, "cloud playback resolve failed",
|
||||
append(cloudPlaybackLogFields(typ, ref, nil, resolveDur), zap.Error(err))...)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if isCloudImageRef(ref) && svc.ImageProxy != nil {
|
||||
if err := svc.ImageProxy.ServeCloudResolved(c.Request.Context(), c.Writer, c.Request, typ+":"+ref, link); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
if isCloudImageRef(ref) {
|
||||
c.Header("Cache-Control", "public, max-age=2592000, immutable")
|
||||
}
|
||||
if !link.Proxy {
|
||||
// Pure offload: send the client straight to the cloud CDN.
|
||||
setRedirectNoStoreHeaders(c)
|
||||
logCloudPlayback(svc, "cloud playback redirect",
|
||||
append(cloudPlaybackLogFields(typ, ref, link, resolveDur),
|
||||
zap.String("mode", "redirect"),
|
||||
zap.Int("status", http.StatusFound),
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("range", c.GetHeader("Range")),
|
||||
)...)
|
||||
c.Redirect(http.StatusFound, link.URL)
|
||||
return
|
||||
}
|
||||
// Proxy mode: the direct link needs auth headers the browser cannot
|
||||
// carry. Stream through with Range forwarding.
|
||||
method := c.Request.Method
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
req, err := http.NewRequestWithContext(c.Request.Context(), method, link.URL, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
for k, v := range link.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if rng := c.GetHeader("Range"); rng != "" {
|
||||
req.Header.Set("Range", rng)
|
||||
}
|
||||
if accept := c.GetHeader("Accept"); accept != "" {
|
||||
req.Header.Set("Accept", accept)
|
||||
}
|
||||
if c.GetHeader("Accept-Encoding") == "" {
|
||||
req.Header.Set("Accept-Encoding", "identity")
|
||||
}
|
||||
upstreamStart := time.Now()
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
upstreamHeaderDur := time.Since(upstreamStart)
|
||||
if err != nil {
|
||||
logCloudPlayback(svc, "cloud playback proxy upstream failed",
|
||||
append(cloudPlaybackLogFields(typ, ref, link, resolveDur),
|
||||
zap.String("mode", "proxy"),
|
||||
zap.String("method", method),
|
||||
zap.String("range", c.GetHeader("Range")),
|
||||
zap.Int64("upstream_header_ms", durationMilliseconds(upstreamHeaderDur)),
|
||||
zap.Error(err),
|
||||
)...)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
for _, h := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Last-Modified"} {
|
||||
if v := resp.Header.Get(h); v != "" {
|
||||
c.Header(h, v)
|
||||
}
|
||||
}
|
||||
if c.Writer.Header().Get("Accept-Ranges") == "" {
|
||||
c.Header("Accept-Ranges", "bytes")
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
}
|
||||
c.Status(resp.StatusCode)
|
||||
var copied int64
|
||||
var copyErr error
|
||||
streamStart := time.Now()
|
||||
if c.Request.Method != http.MethodHead {
|
||||
copied, copyErr = io.Copy(c.Writer, resp.Body)
|
||||
}
|
||||
fields := append(cloudPlaybackLogFields(typ, ref, link, resolveDur),
|
||||
zap.String("mode", "proxy"),
|
||||
zap.String("method", method),
|
||||
zap.String("range", c.GetHeader("Range")),
|
||||
zap.Int("status", resp.StatusCode),
|
||||
zap.String("content_range", resp.Header.Get("Content-Range")),
|
||||
zap.String("content_length", resp.Header.Get("Content-Length")),
|
||||
zap.Int64("upstream_header_ms", durationMilliseconds(upstreamHeaderDur)),
|
||||
zap.Int64("stream_ms", durationMilliseconds(time.Since(streamStart))),
|
||||
zap.Int64("total_ms", durationMilliseconds(time.Since(resolveStart))),
|
||||
zap.Int64("bytes", copied),
|
||||
)
|
||||
if copyErr != nil {
|
||||
logCloudPlayback(svc, "cloud playback proxy copy failed", append(fields, zap.Error(copyErr))...)
|
||||
return
|
||||
}
|
||||
logCloudPlayback(svc, "cloud playback proxy finished", fields...)
|
||||
}
|
||||
|
||||
func isCloudImageRef(ref string) bool {
|
||||
ref = strings.ToLower(strings.TrimSpace(ref))
|
||||
for _, suffix := range []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"} {
|
||||
if strings.HasSuffix(ref, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func logCloudPlayback(svc *service.Container, msg string, fields ...zap.Field) {
|
||||
if svc == nil || svc.Log == nil {
|
||||
return
|
||||
}
|
||||
svc.Log.Info(msg, fields...)
|
||||
}
|
||||
|
||||
func cloudPlaybackLogFields(typ, ref string, link *cloud.DirectLink, resolveDur time.Duration) []zap.Field {
|
||||
refHash, refExt := cloudPlaybackRefFingerprint(ref)
|
||||
fields := []zap.Field{
|
||||
zap.String("provider", strings.TrimSpace(typ)),
|
||||
zap.String("ref_hash", refHash),
|
||||
zap.String("ref_ext", refExt),
|
||||
zap.Int64("resolve_ms", durationMilliseconds(resolveDur)),
|
||||
}
|
||||
if link != nil {
|
||||
fields = append(fields,
|
||||
zap.String("target_host", cloudPlaybackLinkHost(link.URL)),
|
||||
zap.Bool("headers_required", len(link.Headers) > 0),
|
||||
zap.Strings("header_names", cloudPlaybackHeaderNames(link.Headers)),
|
||||
)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func cloudPlaybackRefFingerprint(ref string) (string, string) {
|
||||
ref = strings.TrimSpace(ref)
|
||||
sum := sha256.Sum256([]byte(ref))
|
||||
ext := strings.ToLower(path.Ext(strings.Trim(strings.ReplaceAll(ref, "\\", "/"), "/")))
|
||||
return hex.EncodeToString(sum[:])[:12], ext
|
||||
}
|
||||
|
||||
func cloudPlaybackLinkHost(raw string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
return u.Host
|
||||
}
|
||||
|
||||
func cloudPlaybackHeaderNames(headers map[string]string) []string {
|
||||
if len(headers) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(headers))
|
||||
for key := range headers {
|
||||
if key = strings.TrimSpace(key); key != "" {
|
||||
out = append(out, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func durationMilliseconds(d time.Duration) int64 {
|
||||
if d <= 0 {
|
||||
return 0
|
||||
}
|
||||
return d.Milliseconds()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service/cloud"
|
||||
)
|
||||
|
||||
type cloudPlaybackRequest struct {
|
||||
svc *service.Container
|
||||
c *gin.Context
|
||||
typ string
|
||||
ref string
|
||||
link *cloud.DirectLink
|
||||
resolveStart time.Time
|
||||
resolveDur time.Duration
|
||||
}
|
||||
|
||||
// cloudPlayHandler resolves a cloud file to its direct link and either issues a
|
||||
// 302 redirect (true offload — host does not stream the bytes) or, when the
|
||||
// provider requires authenticated headers, reverse-proxies the response.
|
||||
func cloudPlayHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
typ := c.Param("type")
|
||||
ref := c.Query("ref")
|
||||
if !service.IsAdminCloudConfigurable(typ) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported cloud provider"})
|
||||
return
|
||||
}
|
||||
if ref == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ref required"})
|
||||
return
|
||||
}
|
||||
if !enforceScopedCloudPlaybackToken(c, svc, typ, ref) {
|
||||
return
|
||||
}
|
||||
serveCloudResolvedLink(svc, c, typ, ref)
|
||||
}
|
||||
}
|
||||
|
||||
func serveCloudResolvedLink(svc *service.Container, c *gin.Context, typ, ref string) {
|
||||
if isCloudImageRef(ref) && svc != nil && svc.ImageProxy != nil {
|
||||
if svc.ImageProxy.ServeCloudCached(c.Writer, c.Request, typ+":"+ref) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if svc == nil || svc.StorageCfg == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud storage service unavailable"})
|
||||
return
|
||||
}
|
||||
resolveStart := time.Now()
|
||||
link, err := svc.StorageCfg.CloudResolve(c.Request.Context(), typ, ref, c.Request.UserAgent())
|
||||
resolveDur := time.Since(resolveStart)
|
||||
if err != nil {
|
||||
logCloudPlayback(svc, "cloud playback resolve failed",
|
||||
append(cloudPlaybackLogFields(typ, ref, nil, resolveDur), zap.Error(err))...)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if isCloudImageRef(ref) && svc.ImageProxy != nil {
|
||||
if err := svc.ImageProxy.ServeCloudResolved(c.Request.Context(), c.Writer, c.Request, typ+":"+ref, link); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
if isCloudImageRef(ref) {
|
||||
c.Header("Cache-Control", "public, max-age=2592000, immutable")
|
||||
}
|
||||
if !link.Proxy {
|
||||
// Pure offload: send the client straight to the cloud CDN.
|
||||
setRedirectNoStoreHeaders(c)
|
||||
logCloudPlayback(svc, "cloud playback redirect",
|
||||
append(cloudPlaybackLogFields(typ, ref, link, resolveDur),
|
||||
zap.String("mode", "redirect"),
|
||||
zap.Int("status", http.StatusFound),
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("range", c.GetHeader("Range")),
|
||||
)...)
|
||||
c.Redirect(http.StatusFound, link.URL)
|
||||
return
|
||||
}
|
||||
proxyCloudResolvedLink(cloudPlaybackRequest{
|
||||
svc: svc,
|
||||
c: c,
|
||||
typ: typ,
|
||||
ref: ref,
|
||||
link: link,
|
||||
resolveStart: resolveStart,
|
||||
resolveDur: resolveDur,
|
||||
})
|
||||
}
|
||||
|
||||
func proxyCloudResolvedLink(playback cloudPlaybackRequest) {
|
||||
c := playback.c
|
||||
clientMethod := playback.c.Request.Method
|
||||
if clientMethod == "" {
|
||||
clientMethod = http.MethodGet
|
||||
}
|
||||
upstreamMethod := clientMethod
|
||||
if upstreamMethod == http.MethodHead {
|
||||
upstreamMethod = http.MethodGet
|
||||
}
|
||||
req, err := http.NewRequestWithContext(c.Request.Context(), upstreamMethod, playback.link.URL, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
for k, v := range playback.link.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if rng := c.GetHeader("Range"); rng != "" {
|
||||
req.Header.Set("Range", rng)
|
||||
} else if clientMethod == http.MethodHead {
|
||||
req.Header.Set("Range", "bytes=0-0")
|
||||
}
|
||||
if accept := c.GetHeader("Accept"); accept != "" {
|
||||
req.Header.Set("Accept", accept)
|
||||
}
|
||||
if c.GetHeader("Accept-Encoding") == "" {
|
||||
req.Header.Set("Accept-Encoding", "identity")
|
||||
}
|
||||
upstreamStart := time.Now()
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
upstreamHeaderDur := time.Since(upstreamStart)
|
||||
if err != nil {
|
||||
logCloudPlayback(playback.svc, "cloud playback proxy upstream failed",
|
||||
append(cloudPlaybackLogFields(playback.typ, playback.ref, playback.link, playback.resolveDur),
|
||||
zap.String("mode", "proxy"),
|
||||
zap.String("method", clientMethod),
|
||||
zap.String("upstream_method", upstreamMethod),
|
||||
zap.String("range", c.GetHeader("Range")),
|
||||
zap.Int64("upstream_header_ms", durationMilliseconds(upstreamHeaderDur)),
|
||||
zap.Error(err),
|
||||
)...)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
for _, h := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Last-Modified"} {
|
||||
if v := resp.Header.Get(h); v != "" {
|
||||
c.Header(h, v)
|
||||
}
|
||||
}
|
||||
if c.Writer.Header().Get("Accept-Ranges") == "" {
|
||||
c.Header("Accept-Ranges", "bytes")
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
handleCloudProxyError(playback, req, resp, clientMethod, upstreamMethod, upstreamHeaderDur)
|
||||
return
|
||||
}
|
||||
streamCloudProxyResponse(playback, req, resp, clientMethod, upstreamMethod, upstreamHeaderDur)
|
||||
}
|
||||
|
||||
func handleCloudProxyError(playback cloudPlaybackRequest, req *http.Request, resp *http.Response, clientMethod, upstreamMethod string, upstreamHeaderDur time.Duration) {
|
||||
c := playback.c
|
||||
c.Header("Cache-Control", "no-store")
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
fields := append(cloudPlaybackLogFields(playback.typ, playback.ref, playback.link, playback.resolveDur),
|
||||
zap.String("mode", "proxy"),
|
||||
zap.String("method", clientMethod),
|
||||
zap.String("upstream_method", upstreamMethod),
|
||||
zap.String("range", c.GetHeader("Range")),
|
||||
zap.String("upstream_range", req.Header.Get("Range")),
|
||||
zap.Int("status", resp.StatusCode),
|
||||
zap.String("content_range", resp.Header.Get("Content-Range")),
|
||||
zap.String("content_length", resp.Header.Get("Content-Length")),
|
||||
zap.String("upstream_error_body", strings.TrimSpace(string(body))),
|
||||
zap.Int64("upstream_header_ms", durationMilliseconds(upstreamHeaderDur)),
|
||||
zap.Int64("total_ms", durationMilliseconds(time.Since(playback.resolveStart))),
|
||||
)
|
||||
logCloudPlayback(playback.svc, "cloud playback proxy upstream returned error", fields...)
|
||||
c.Status(resp.StatusCode)
|
||||
if clientMethod != http.MethodHead && len(body) > 0 {
|
||||
_, _ = c.Writer.Write(body)
|
||||
}
|
||||
}
|
||||
|
||||
func streamCloudProxyResponse(playback cloudPlaybackRequest, req *http.Request, resp *http.Response, clientMethod, upstreamMethod string, upstreamHeaderDur time.Duration) {
|
||||
c := playback.c
|
||||
c.Status(resp.StatusCode)
|
||||
var copied int64
|
||||
var copyErr error
|
||||
streamStart := time.Now()
|
||||
if c.Request.Method != http.MethodHead {
|
||||
copied, copyErr = io.Copy(c.Writer, resp.Body)
|
||||
}
|
||||
fields := append(cloudPlaybackLogFields(playback.typ, playback.ref, playback.link, playback.resolveDur),
|
||||
zap.String("mode", "proxy"),
|
||||
zap.String("method", clientMethod),
|
||||
zap.String("upstream_method", upstreamMethod),
|
||||
zap.String("range", c.GetHeader("Range")),
|
||||
zap.String("upstream_range", req.Header.Get("Range")),
|
||||
zap.Int("status", resp.StatusCode),
|
||||
zap.String("content_range", resp.Header.Get("Content-Range")),
|
||||
zap.String("content_length", resp.Header.Get("Content-Length")),
|
||||
zap.Int64("upstream_header_ms", durationMilliseconds(upstreamHeaderDur)),
|
||||
zap.Int64("stream_ms", durationMilliseconds(time.Since(streamStart))),
|
||||
zap.Int64("total_ms", durationMilliseconds(time.Since(playback.resolveStart))),
|
||||
zap.Int64("bytes", copied),
|
||||
)
|
||||
if copyErr != nil {
|
||||
logCloudPlayback(playback.svc, "cloud playback proxy copy failed", append(fields, zap.Error(copyErr))...)
|
||||
return
|
||||
}
|
||||
logCloudPlayback(playback.svc, "cloud playback proxy finished", fields...)
|
||||
}
|
||||
|
||||
func isCloudImageRef(ref string) bool {
|
||||
ref = strings.ToLower(strings.TrimSpace(ref))
|
||||
for _, suffix := range []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn"} {
|
||||
if strings.HasSuffix(ref, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func logCloudPlayback(svc *service.Container, msg string, fields ...zap.Field) {
|
||||
if svc == nil || svc.Log == nil {
|
||||
return
|
||||
}
|
||||
svc.Log.Info(msg, fields...)
|
||||
}
|
||||
|
||||
func cloudPlaybackLogFields(typ, ref string, link *cloud.DirectLink, resolveDur time.Duration) []zap.Field {
|
||||
refHash, refExt := cloudPlaybackRefFingerprint(ref)
|
||||
fields := []zap.Field{
|
||||
zap.String("provider", strings.TrimSpace(typ)),
|
||||
zap.String("ref_hash", refHash),
|
||||
zap.String("ref_ext", refExt),
|
||||
zap.Int64("resolve_ms", durationMilliseconds(resolveDur)),
|
||||
}
|
||||
if link != nil {
|
||||
fields = append(fields,
|
||||
zap.String("target_host", cloudPlaybackLinkHost(link.URL)),
|
||||
zap.Bool("headers_required", len(link.Headers) > 0),
|
||||
zap.Strings("header_names", cloudPlaybackHeaderNames(link.Headers)),
|
||||
)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func cloudPlaybackRefFingerprint(ref string) (string, string) {
|
||||
ref = strings.TrimSpace(ref)
|
||||
sum := sha256.Sum256([]byte(ref))
|
||||
ext := strings.ToLower(path.Ext(strings.Trim(strings.ReplaceAll(ref, "\\", "/"), "/")))
|
||||
return hex.EncodeToString(sum[:])[:12], ext
|
||||
}
|
||||
|
||||
func cloudPlaybackLinkHost(raw string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
return u.Host
|
||||
}
|
||||
|
||||
func cloudPlaybackHeaderNames(headers map[string]string) []string {
|
||||
if len(headers) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(headers))
|
||||
for key := range headers {
|
||||
if key = strings.TrimSpace(key); key != "" {
|
||||
out = append(out, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func durationMilliseconds(d time.Duration) int64 {
|
||||
if d <= 0 {
|
||||
return 0
|
||||
}
|
||||
return d.Milliseconds()
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service/cloud"
|
||||
)
|
||||
|
||||
func TestCloudMountLibraryNameDefaultsToDirectoryBaseName(t *testing.T) {
|
||||
@@ -48,3 +57,98 @@ func TestCloudPlaybackDiagnosticsDoNotExposeRawRefOrURL(t *testing.T) {
|
||||
t.Fatalf("header names = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCloudHandlersRejectQuarkBrowsing(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/admin/cloud/:type/list", cloudListHandler(nil))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/cloud/quark/list?dir=0", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d body=%s, want 400", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "unsupported cloud provider") {
|
||||
t.Fatalf("body = %s, want unsupported cloud provider", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudPlayRejectsQuarkProvider(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/api/cloud/play/:type", cloudPlayHandler(nil))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/cloud/play/quark?ref=file-1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d body=%s, want 400", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "unsupported cloud provider") {
|
||||
t.Fatalf("body = %s, want unsupported cloud provider", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudArtworkProxyServesCachedImageWithoutCloudResolve(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("cached-cloud-poster"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
imageProxy := service.NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, zap.NewNop())
|
||||
stableKey := "openlist:/Anime/JianLai/poster.jpg"
|
||||
if err := imageProxy.PrefetchCloudResolved(t.Context(), stableKey, &cloud.DirectLink{URL: upstream.URL + "/poster.jpg"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.GET("/api/img/cloud/:type", cloudArtworkProxyHandler(&service.Container{ImageProxy: imageProxy}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/img/cloud/openlist?ref=%2FAnime%2FJianLai%2Fposter.jpg", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s, want 200", w.Code, w.Body.String())
|
||||
}
|
||||
if got := w.Body.String(); got != "cached-cloud-poster" {
|
||||
t.Fatalf("body = %q, want cached poster", got)
|
||||
}
|
||||
if got := w.Header().Get("Cache-Control"); !strings.Contains(got, "max-age=2592000") {
|
||||
t.Fatalf("cache-control = %q, want long static cache", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudArtworkProxyAcceptsCachedTBNImage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("cached-tbn-poster"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
imageProxy := service.NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, zap.NewNop())
|
||||
stableKey := "openlist:/Movies/Movie.tbn"
|
||||
if err := imageProxy.PrefetchCloudResolved(t.Context(), stableKey, &cloud.DirectLink{URL: upstream.URL + "/Movie.tbn"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.GET("/api/img/cloud/:type", cloudArtworkProxyHandler(&service.Container{ImageProxy: imageProxy}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/img/cloud/openlist?ref=%2FMovies%2FMovie.tbn", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s, want 200", w.Code, w.Body.String())
|
||||
}
|
||||
if got := w.Body.String(); got != "cached-tbn-poster" {
|
||||
t.Fatalf("body = %q, want cached tbn poster", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ func trendingHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if items == nil {
|
||||
items = []service.Match{}
|
||||
}
|
||||
svc.Discover.WarmMatchArtwork(items)
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
}
|
||||
@@ -41,6 +42,7 @@ func popularHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if items == nil {
|
||||
items = []service.Match{}
|
||||
}
|
||||
svc.Discover.WarmMatchArtwork(items)
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,24 +16,37 @@ import (
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
type discoverSectionDef struct {
|
||||
Key string
|
||||
Label string
|
||||
Provider string
|
||||
}
|
||||
|
||||
var discoverSectionCatalog = []discoverSectionDef{
|
||||
{Key: "tmdb_trending_day", Label: "TMDb 今日趋势", Provider: "tmdb"},
|
||||
{Key: "tmdb_trending_week", Label: "TMDb 本周热门", Provider: "tmdb"},
|
||||
{Key: "tmdb_popular_movie", Label: "TMDb 热门电影", Provider: "tmdb"},
|
||||
{Key: "tmdb_popular_tv", Label: "TMDb 热门剧集", Provider: "tmdb"},
|
||||
{Key: "tmdb_top_rated_movie", Label: "TMDb 高分电影", Provider: "tmdb"},
|
||||
{Key: "douban_hot_movie", Label: "豆瓣热门电影", Provider: "douban"},
|
||||
{Key: "douban_hot_tv", Label: "豆瓣热门剧集", Provider: "douban"},
|
||||
{Key: "douban_top_movie", Label: "豆瓣高分电影", Provider: "douban"},
|
||||
{Key: "bangumi_calendar", Label: "Bangumi 每日放送", Provider: "bangumi"},
|
||||
}
|
||||
|
||||
// discoverSectionsHandler returns the catalog of sections the UI can
|
||||
// pick from. The names match the upstream Vue UI so existing settings
|
||||
// keep working.
|
||||
func discoverSectionsHandler(_ *service.Container) gin.HandlerFunc {
|
||||
func discoverSectionsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"sections": []gin.H{
|
||||
{"key": "tmdb_trending_day", "label": "TMDb 今日趋势", "provider": "tmdb"},
|
||||
{"key": "tmdb_trending_week", "label": "TMDb 本周热门", "provider": "tmdb"},
|
||||
{"key": "tmdb_popular_movie", "label": "TMDb 热门电影", "provider": "tmdb"},
|
||||
{"key": "tmdb_popular_tv", "label": "TMDb 热门剧集", "provider": "tmdb"},
|
||||
{"key": "tmdb_top_rated_movie", "label": "TMDb 高分电影", "provider": "tmdb"},
|
||||
{"key": "douban_hot_movie", "label": "豆瓣热门电影", "provider": "douban"},
|
||||
{"key": "douban_hot_tv", "label": "豆瓣热门剧集", "provider": "douban"},
|
||||
{"key": "douban_top_movie", "label": "豆瓣高分电影", "provider": "douban"},
|
||||
{"key": "bangumi_calendar", "label": "Bangumi 每日放送", "provider": "bangumi"},
|
||||
},
|
||||
})
|
||||
sections := make([]gin.H, 0, len(discoverSectionCatalog))
|
||||
for _, section := range discoverSectionCatalog {
|
||||
if !discoverProviderEnabled(c.Request.Context(), svc, section.Provider) {
|
||||
continue
|
||||
}
|
||||
sections = append(sections, gin.H{"key": section.Key, "label": section.Label, "provider": section.Provider})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"sections": sections})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,19 +58,51 @@ func discoverFeedHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
keys := strings.Split(c.DefaultQuery("sections", "tmdb_trending_day,tmdb_popular_movie,douban_hot_movie,bangumi_calendar"), ",")
|
||||
out := gin.H{}
|
||||
artworkItems := []service.ExternalMediaResult{}
|
||||
for _, raw := range keys {
|
||||
k := strings.TrimSpace(raw)
|
||||
if provider := discoverSectionProvider(k); provider != "" && !discoverProviderEnabled(c.Request.Context(), svc, provider) {
|
||||
out[k] = []service.ExternalMediaResult{}
|
||||
continue
|
||||
}
|
||||
items, err := discoverSectionItems(c.Request.Context(), svc, k)
|
||||
if err != nil {
|
||||
svc.Log.Debug("discover fetch failed")
|
||||
items = nil
|
||||
}
|
||||
artworkItems = append(artworkItems, items...)
|
||||
out[k] = items
|
||||
}
|
||||
svc.Discover.WarmExternalArtwork(artworkItems)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
func discoverSectionProvider(key string) string {
|
||||
for _, section := range discoverSectionCatalog {
|
||||
if section.Key == key {
|
||||
return section.Provider
|
||||
}
|
||||
}
|
||||
switch key {
|
||||
case "trending_day", "trending_week", "popular_movie", "popular_tv", "top_rated_movie", "upcoming_movie":
|
||||
return "tmdb"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func discoverProviderEnabled(ctx context.Context, svc *service.Container, provider string) bool {
|
||||
if svc == nil || svc.APIConfig == nil || strings.TrimSpace(provider) == "" {
|
||||
return true
|
||||
}
|
||||
cfg, err := svc.APIConfig.Get(ctx, provider)
|
||||
if err != nil || cfg == nil {
|
||||
return true
|
||||
}
|
||||
return cfg.Enabled
|
||||
}
|
||||
|
||||
func discoverSectionItems(ctx context.Context, svc *service.Container, k string) ([]service.ExternalMediaResult, error) {
|
||||
switch k {
|
||||
case "tmdb_trending_day", "tmdb_trending_week", "tmdb_popular_movie", "tmdb_popular_tv", "tmdb_top_rated_movie",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
func TestDiscoverProviderEnabledHonorsAPIConfigToggle(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.APIConfig{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
apiConfig := service.NewAPIConfigService(zap.NewNop(), repos, service.NewCryptoService("", zap.NewNop()))
|
||||
enabled := false
|
||||
if _, err := apiConfig.Update(t.Context(), "douban", service.APIConfigPatch{Enabled: &enabled}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := &service.Container{APIConfig: apiConfig}
|
||||
|
||||
if discoverProviderEnabled(t.Context(), svc, "douban") {
|
||||
t.Fatal("disabled API config should disable discover provider")
|
||||
}
|
||||
if !discoverProviderEnabled(t.Context(), svc, "missing-provider") {
|
||||
t.Fatal("missing API config should keep discover provider available")
|
||||
}
|
||||
}
|
||||
+2
-1831
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,241 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
||||
)
|
||||
|
||||
// embyError 返回 Emby 风格的错误(顶层 Code/Message)。
|
||||
func embyError(c *gin.Context, status int, msg string) {
|
||||
c.JSON(status, gin.H{"Code": status, "Message": msg})
|
||||
}
|
||||
|
||||
// embyUserID 从中间件中获取 user id。Emby auth middleware 写入 CtxUserID。
|
||||
func embyUserID(c *gin.Context) string {
|
||||
if uid, ok := c.Get(middleware.CtxUserID); ok {
|
||||
if s, ok := uid.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const embyCompatSessionTTL = 30 * time.Minute
|
||||
|
||||
type embyCompatSession struct {
|
||||
token string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
var embyCompatSessions = struct {
|
||||
sync.RWMutex
|
||||
items map[string]embyCompatSession
|
||||
}{items: map[string]embyCompatSession{}}
|
||||
|
||||
func embyAuthRequiredWithSessionFallback(secret string) gin.HandlerFunc {
|
||||
required := middleware.EmbyAuthRequired(secret)
|
||||
return func(c *gin.Context) {
|
||||
if embyRequestToken(c) == "" {
|
||||
if token := embyCompatSessionToken(c); token != "" {
|
||||
c.Request.Header.Set("X-Emby-Token", token)
|
||||
}
|
||||
}
|
||||
required(c)
|
||||
}
|
||||
}
|
||||
|
||||
func embyRememberCompatSession(c *gin.Context, token string) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return
|
||||
}
|
||||
keys := embyCompatSessionKeys(c)
|
||||
if len(keys) == 0 {
|
||||
return
|
||||
}
|
||||
expiresAt := time.Now().Add(embyCompatSessionTTL)
|
||||
embyCompatSessions.Lock()
|
||||
defer embyCompatSessions.Unlock()
|
||||
if len(embyCompatSessions.items) > 1000 {
|
||||
now := time.Now()
|
||||
for key, session := range embyCompatSessions.items {
|
||||
if now.After(session.expiresAt) {
|
||||
delete(embyCompatSessions.items, key)
|
||||
}
|
||||
}
|
||||
if len(embyCompatSessions.items) > 1000 {
|
||||
embyCompatSessions.items = map[string]embyCompatSession{}
|
||||
}
|
||||
}
|
||||
for _, key := range keys {
|
||||
embyCompatSessions.items[key] = embyCompatSession{token: token, expiresAt: expiresAt}
|
||||
}
|
||||
}
|
||||
|
||||
func embyCompatSessionToken(c *gin.Context) string {
|
||||
keys := embyCompatSessionKeys(c)
|
||||
if len(keys) == 0 {
|
||||
return ""
|
||||
}
|
||||
now := time.Now()
|
||||
embyCompatSessions.RLock()
|
||||
defer embyCompatSessions.RUnlock()
|
||||
for _, key := range keys {
|
||||
session, ok := embyCompatSessions.items[key]
|
||||
if ok && now.Before(session.expiresAt) {
|
||||
return session.token
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func embyCompatSessionKeys(c *gin.Context) []string {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
ip := strings.TrimSpace(c.ClientIP())
|
||||
if ip == "" {
|
||||
return nil
|
||||
}
|
||||
keys := []string{}
|
||||
add := func(kind, value string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
keys = append(keys, ip+"\x00"+kind+"\x00"+value)
|
||||
}
|
||||
}
|
||||
add("device", firstHeaderValue(c, "X-Emby-Device-Id", "X-Emby-DeviceId", "X-MediaBrowser-Device-Id", "X-MediaBrowser-DeviceId"))
|
||||
add("ua", c.GetHeader("User-Agent"))
|
||||
return keys
|
||||
}
|
||||
|
||||
func firstHeaderValue(c *gin.Context, names ...string) string {
|
||||
for _, name := range names {
|
||||
if value := strings.TrimSpace(c.GetHeader(name)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type embyClientInfo struct {
|
||||
DeviceID string
|
||||
DeviceName string
|
||||
Client string
|
||||
}
|
||||
|
||||
func embyClientInfoFromRequest(c *gin.Context) embyClientInfo {
|
||||
auth := parseMediaBrowserAuthorization(firstHeaderValue(c,
|
||||
"X-Emby-Authorization",
|
||||
"X-MediaBrowser-Authorization",
|
||||
"Authorization",
|
||||
))
|
||||
info := embyClientInfo{
|
||||
DeviceID: firstNonEmptyHeaderString(
|
||||
firstHeaderValue(c, "X-Emby-Device-Id", "X-Emby-DeviceId", "X-MediaBrowser-Device-Id", "X-MediaBrowser-DeviceId"),
|
||||
auth["DeviceId"],
|
||||
auth["DeviceID"],
|
||||
),
|
||||
DeviceName: firstNonEmptyHeaderString(
|
||||
firstHeaderValue(c, "X-Emby-Device-Name", "X-Emby-DeviceName", "X-MediaBrowser-Device-Name", "X-MediaBrowser-DeviceName"),
|
||||
auth["Device"],
|
||||
),
|
||||
Client: firstNonEmptyHeaderString(
|
||||
firstHeaderValue(c, "X-Emby-Client", "X-MediaBrowser-Client"),
|
||||
auth["Client"],
|
||||
),
|
||||
}
|
||||
ua := strings.TrimSpace(c.GetHeader("User-Agent"))
|
||||
if info.Client == "" {
|
||||
info.Client = embyClientFromUserAgent(ua)
|
||||
}
|
||||
if info.DeviceName == "" {
|
||||
info.DeviceName = embyDeviceFromUserAgent(ua)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func parseMediaBrowserAuthorization(raw string) map[string]string {
|
||||
out := map[string]string{}
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return out
|
||||
}
|
||||
for _, prefix := range []string{"MediaBrowser ", "Emby "} {
|
||||
if strings.HasPrefix(raw, prefix) {
|
||||
raw = strings.TrimSpace(strings.TrimPrefix(raw, prefix))
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
key, value, ok := strings.Cut(strings.TrimSpace(part), "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.Trim(strings.TrimSpace(value), `"`)
|
||||
if key != "" && value != "" {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstNonEmptyHeaderString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func embyClientFromUserAgent(ua string) string {
|
||||
ua = strings.TrimSpace(ua)
|
||||
lower := strings.ToLower(ua)
|
||||
switch {
|
||||
case strings.Contains(lower, "infuse"):
|
||||
return "Infuse"
|
||||
case strings.Contains(lower, "emby"):
|
||||
return "Emby"
|
||||
case strings.Contains(lower, "jellyfin"):
|
||||
return "Jellyfin"
|
||||
case strings.Contains(lower, "yamby"):
|
||||
return "Yamby"
|
||||
case strings.Contains(lower, "vidhub"):
|
||||
return "VidHub"
|
||||
case strings.Contains(lower, "hills"):
|
||||
return "Hills"
|
||||
default:
|
||||
return ua
|
||||
}
|
||||
}
|
||||
|
||||
func embyDeviceFromUserAgent(ua string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(ua))
|
||||
switch {
|
||||
case strings.Contains(lower, "android"):
|
||||
return "Android"
|
||||
case strings.Contains(lower, "iphone"):
|
||||
return "iPhone"
|
||||
case strings.Contains(lower, "ipad"):
|
||||
return "iPad"
|
||||
case strings.Contains(lower, "ios"):
|
||||
return "iOS"
|
||||
case strings.Contains(lower, "windows"):
|
||||
return "Windows PC"
|
||||
case strings.Contains(lower, "macintosh") || strings.Contains(lower, "mac os"):
|
||||
return "Mac"
|
||||
case strings.Contains(lower, "linux"):
|
||||
return "Linux PC"
|
||||
case strings.Contains(lower, "appletv") || strings.Contains(lower, "apple tv"):
|
||||
return "Apple TV"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type embyAuthByNameReq struct {
|
||||
Username string `json:"Username"`
|
||||
Pw string `json:"Pw"`
|
||||
Password string `json:"Password"`
|
||||
PasswordMd5 string `json:"PasswordMd5"`
|
||||
PasswordSha1 string `json:"PasswordSha1"`
|
||||
}
|
||||
|
||||
func parseEmbyAuthByNameReq(c *gin.Context) (embyAuthByNameReq, error) {
|
||||
req := embyAuthByNameReq{}
|
||||
if strings.Contains(strings.ToLower(c.GetHeader("Content-Type")), "json") {
|
||||
var body map[string]any
|
||||
if err := c.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) {
|
||||
return req, err
|
||||
}
|
||||
fillEmbyAuthFromMap(&req, body)
|
||||
}
|
||||
|
||||
if req.Username == "" || (req.Pw == "" && req.Password == "" && req.PasswordMd5 == "" && req.PasswordSha1 == "") {
|
||||
_ = c.Request.ParseForm()
|
||||
if req.Username == "" {
|
||||
req.Username = firstFormValue(c, "Username", "username", "Name", "name")
|
||||
}
|
||||
if req.Pw == "" {
|
||||
req.Pw = firstFormValue(c, "Pw", "pw")
|
||||
}
|
||||
if req.Password == "" {
|
||||
req.Password = firstFormValue(c, "Password", "password")
|
||||
}
|
||||
if req.PasswordMd5 == "" {
|
||||
req.PasswordMd5 = firstFormValue(c, "PasswordMd5", "passwordMd5", "password_md5")
|
||||
}
|
||||
if req.PasswordSha1 == "" {
|
||||
req.PasswordSha1 = firstFormValue(c, "PasswordSha1", "passwordSha1", "password_sha1")
|
||||
}
|
||||
}
|
||||
|
||||
if req.Username == "" {
|
||||
req.Username = firstQueryValue(c, "Username", "username", "Name", "name")
|
||||
}
|
||||
if req.Pw == "" {
|
||||
req.Pw = firstQueryValue(c, "Pw", "pw")
|
||||
}
|
||||
if req.Password == "" {
|
||||
req.Password = firstQueryValue(c, "Password", "password")
|
||||
}
|
||||
if req.PasswordMd5 == "" {
|
||||
req.PasswordMd5 = firstQueryValue(c, "PasswordMd5", "passwordMd5", "password_md5")
|
||||
}
|
||||
if req.PasswordSha1 == "" {
|
||||
req.PasswordSha1 = firstQueryValue(c, "PasswordSha1", "passwordSha1", "password_sha1")
|
||||
}
|
||||
if req.Username == "" || (req.Pw == "" && req.Password == "" && req.PasswordMd5 == "" && req.PasswordSha1 == "") {
|
||||
fillEmbyAuthFromRawBody(c, &req)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func fillEmbyAuthFromMap(req *embyAuthByNameReq, body map[string]any) {
|
||||
if req.Username == "" {
|
||||
req.Username = firstStringFromMap(body, "Username", "username", "UserName", "userName", "Name", "name", "LoginName", "loginName")
|
||||
}
|
||||
if req.Pw == "" {
|
||||
req.Pw = firstStringFromMap(body, "Pw", "pw", "PW")
|
||||
}
|
||||
if req.Password == "" {
|
||||
req.Password = firstStringFromMap(body, "Password", "password", "Pass", "pass", "Pwd", "pwd")
|
||||
}
|
||||
if req.PasswordMd5 == "" {
|
||||
req.PasswordMd5 = firstStringFromMap(body, "PasswordMd5", "passwordMd5", "password_md5")
|
||||
}
|
||||
if req.PasswordSha1 == "" {
|
||||
req.PasswordSha1 = firstStringFromMap(body, "PasswordSha1", "passwordSha1", "password_sha1")
|
||||
}
|
||||
}
|
||||
|
||||
func fillEmbyAuthFromRawBody(c *gin.Context, req *embyAuthByNameReq) {
|
||||
if c.Request == nil || c.Request.Body == nil {
|
||||
return
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(raw))
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 0 {
|
||||
return
|
||||
}
|
||||
if bytes.HasPrefix(raw, []byte("{")) {
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(raw, &body); err == nil {
|
||||
fillEmbyAuthFromMap(req, body)
|
||||
}
|
||||
return
|
||||
}
|
||||
if values, err := url.ParseQuery(string(raw)); err == nil {
|
||||
fillEmbyAuthFromValues(req, values)
|
||||
}
|
||||
}
|
||||
|
||||
func fillEmbyAuthFromValues(req *embyAuthByNameReq, values url.Values) {
|
||||
if req.Username == "" {
|
||||
req.Username = firstValue(values, "Username", "username", "UserName", "userName", "Name", "name", "LoginName", "loginName")
|
||||
}
|
||||
if req.Pw == "" {
|
||||
req.Pw = firstValue(values, "Pw", "pw", "PW")
|
||||
}
|
||||
if req.Password == "" {
|
||||
req.Password = firstValue(values, "Password", "password", "Pass", "pass", "Pwd", "pwd")
|
||||
}
|
||||
if req.PasswordMd5 == "" {
|
||||
req.PasswordMd5 = firstValue(values, "PasswordMd5", "passwordMd5", "password_md5")
|
||||
}
|
||||
if req.PasswordSha1 == "" {
|
||||
req.PasswordSha1 = firstValue(values, "PasswordSha1", "passwordSha1", "password_sha1")
|
||||
}
|
||||
}
|
||||
|
||||
func firstValue(values url.Values, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := strings.TrimSpace(values.Get(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstStringFromMap(body map[string]any, keys ...string) string {
|
||||
if len(body) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, key := range keys {
|
||||
if value, ok := body[key]; ok {
|
||||
if s, ok := value.(string); ok {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstFormValue(c *gin.Context, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if values, ok := c.Request.PostForm[key]; ok && len(values) > 0 {
|
||||
if value := strings.TrimSpace(values[0]); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstQueryValue(c *gin.Context, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := strings.TrimSpace(c.Query(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"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"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
func TestParseEmbyAuthByNameReqAcceptsLowercaseJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/Users/AuthenticateByName", strings.NewReader(`{"username":"alice","password":"secret"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
req, err := parseEmbyAuthByNameReq(c)
|
||||
if err != nil {
|
||||
t.Fatalf("parseEmbyAuthByNameReq returned error: %v", err)
|
||||
}
|
||||
if req.Username != "alice" || req.Password != "secret" {
|
||||
t.Fatalf("unexpected request: %#v", req)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEmbyAuthByNameReqAcceptsFormBody(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/Users/AuthenticateByName", strings.NewReader("Username=bob&Pw=secret"))
|
||||
c.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
req, err := parseEmbyAuthByNameReq(c)
|
||||
if err != nil {
|
||||
t.Fatalf("parseEmbyAuthByNameReq returned error: %v", err)
|
||||
}
|
||||
if req.Username != "bob" || req.Pw != "secret" {
|
||||
t.Fatalf("unexpected request: %#v", req)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEmbyAuthByNameReqAcceptsJSONWithoutContentType(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/emby/users/authenticatebyname", strings.NewReader(`{"UserName":"carol","PW":"secret"}`))
|
||||
|
||||
req, err := parseEmbyAuthByNameReq(c)
|
||||
if err != nil {
|
||||
t.Fatalf("parseEmbyAuthByNameReq returned error: %v", err)
|
||||
}
|
||||
if req.Username != "carol" || req.Pw != "secret" {
|
||||
t.Fatalf("unexpected request: %#v", req)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyAuthenticateByNameAcceptsCaseVariantUsernameAndPath(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.UserPermission{}, &model.RefreshToken{}, &model.Setting{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret"
|
||||
log := zap.NewNop()
|
||||
permissions := service.NewPermissionService(log, repos)
|
||||
auth := service.NewAuthService(cfg, log, repos, service.NewTokenService(cfg, log, repos), permissions)
|
||||
if _, _, err := auth.Register(context.Background(), "viewer", "secret-pass"); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, &service.Container{
|
||||
Repo: repos,
|
||||
Auth: auth,
|
||||
Emby: service.NewEmbyService(cfg, log, repos),
|
||||
Audit: service.NewAuditService(log, repos),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/emby/users/authenticatebyname", strings.NewReader(`{"Username":"Viewer","Pw":"secret-pass"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload["AccessToken"] == "" {
|
||||
t.Fatalf("missing AccessToken: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyAuthenticateRecordsMediaBrowserClientInfo(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret"
|
||||
log := zap.NewNop()
|
||||
permissions := service.NewPermissionService(log, repos)
|
||||
auth := service.NewAuthService(cfg, log, repos, service.NewTokenService(cfg, log, repos), permissions)
|
||||
if _, _, err := auth.Register(context.Background(), "viewer", "secret-pass"); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, &service.Container{
|
||||
Repo: repos,
|
||||
Auth: auth,
|
||||
Emby: service.NewEmbyService(cfg, log, repos),
|
||||
Device: service.NewDeviceService(log, repos),
|
||||
Audit: service.NewAuditService(log, repos),
|
||||
Permissions: permissions,
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/emby/Users/AuthenticateByName", strings.NewReader(`{"Username":"viewer","Pw":"secret-pass"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-MediaBrowser-Authorization", `MediaBrowser Client="Infuse", Device="PC", DeviceId="device-42"`)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
user, err := repos.User.FindByUsername(context.Background(), "viewer")
|
||||
if err != nil {
|
||||
t.Fatalf("find user: %v", err)
|
||||
}
|
||||
devices, err := repos.UserDevice.ListByUser(context.Background(), user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list devices: %v", err)
|
||||
}
|
||||
if len(devices) != 1 {
|
||||
t.Fatalf("devices = %#v, want one recorded device", devices)
|
||||
}
|
||||
if devices[0].DeviceID != "device-42" || devices[0].DeviceName != "PC" || devices[0].Client != "Infuse" {
|
||||
t.Fatalf("device info not parsed from MediaBrowser header: %#v", devices[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
var embyPlaceholderPNG = []byte{
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
||||
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41,
|
||||
0x54, 0x78, 0x9c, 0x63, 0x50, 0xd1, 0x30, 0xf8,
|
||||
0x0f, 0x00, 0x02, 0x6c, 0x01, 0x7c, 0x30, 0xed,
|
||||
0x6e, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
|
||||
0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
}
|
||||
|
||||
// embyItemImageHandler 把 /Items/{id}/Images/Primary 等请求直接输出为图片。
|
||||
// Emby 客户端缓存图片 URL 时经常不会继续携带 token;如果重定向到受保护的
|
||||
// /api/img 会变成 401,所以这里复用 ImageProxy 但不再走 /api 路由。
|
||||
func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
clearEmbyImageNoStoreHeaders(c)
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
|
||||
defer cancel()
|
||||
req := c.Request.WithContext(ctx)
|
||||
id := c.Param("id")
|
||||
imgType := strings.ToLower(c.Param("type"))
|
||||
raw, err := svc.Emby.ImageURL(ctx, id, imgType)
|
||||
if err != nil || raw == "" {
|
||||
embyServePlaceholderImage(c)
|
||||
return
|
||||
}
|
||||
if typ, ref, ok := service.ParseCloudArtworkURL(raw); ok {
|
||||
c.Request = req
|
||||
serveCloudResolvedLink(svc, c, typ, ref)
|
||||
return
|
||||
}
|
||||
if svc.ImageProxy == nil {
|
||||
embyServePlaceholderImage(c)
|
||||
return
|
||||
}
|
||||
if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil {
|
||||
embyServePlaceholderImage(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clearEmbyImageNoStoreHeaders(c *gin.Context) {
|
||||
c.Writer.Header().Del("Pragma")
|
||||
c.Writer.Header().Del("Expires")
|
||||
}
|
||||
|
||||
func embyServePlaceholderImage(c *gin.Context) {
|
||||
c.Header("Content-Type", "image/png")
|
||||
c.Header("Cache-Control", "public, max-age=3600")
|
||||
c.Header("Content-Length", strconv.Itoa(len(embyPlaceholderPNG)))
|
||||
if c.Request.Method == http.MethodHead {
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "image/png", embyPlaceholderPNG)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
|
||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "50"))
|
||||
offset, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = firstQueryValue(c, "UserId", "userId", "userid")
|
||||
}
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
splitOpt := func(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return service.ItemsParams{
|
||||
UserID: uid,
|
||||
ParentID: firstQueryValue(c, "ParentId", "parentId", "parentid"),
|
||||
IDs: splitOpt(firstQueryValue(c, "Ids", "ids")),
|
||||
SearchTerm: firstQueryValue(c, "SearchTerm", "searchTerm", "searchterm"),
|
||||
IncludeItemTypes: splitOpt(firstQueryValue(c, "IncludeItemTypes", "includeItemTypes", "includeitemtypes")),
|
||||
Filters: splitOpt(firstQueryValue(c, "Filters", "filters")),
|
||||
Recursive: strings.EqualFold(firstQueryValue(c, "Recursive", "recursive"), "true"),
|
||||
SortBy: firstQueryValue(c, "SortBy", "sortBy", "sortby"),
|
||||
SortOrder: firstQueryValue(c, "SortOrder", "sortOrder", "sortorder"),
|
||||
Limit: limit,
|
||||
StartIndex: offset,
|
||||
}
|
||||
}
|
||||
|
||||
func embyFirstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func embyItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
out, err := svc.Emby.Items(c.Request.Context(), parseEmbyItemsParams(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
embyAttachRequestTokenToMediaSources(c, out)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
func embyItemByIDHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
out, err := svc.Emby.Item(c.Request.Context(), id, uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if out == nil {
|
||||
embyError(c, http.StatusNotFound, "item not found")
|
||||
return
|
||||
}
|
||||
embyAttachRequestTokenToMediaSources(c, out)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
func embyUserItemByIDHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
switch strings.ToLower(c.Param("id")) {
|
||||
case "latest":
|
||||
embyLatestItemsHandler(svc)(c)
|
||||
case "resume":
|
||||
embyResumeItemsHandler(svc)(c)
|
||||
default:
|
||||
embyItemByIDHandler(svc)(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = firstQueryValue(c, "UserId", "userId", "userid")
|
||||
}
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
|
||||
out, err := svc.Emby.LatestItems(c.Request.Context(), uid, firstQueryValue(c, "ParentId", "parentId", "parentid"), limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
embyAttachRequestTokenToMediaSources(c, out)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = firstQueryValue(c, "UserId", "userId", "userid")
|
||||
}
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
|
||||
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
embyAttachRequestTokenToMediaSources(c, out)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
func embyItemsCountsHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"MovieCount": 0,
|
||||
"SeriesCount": 0,
|
||||
"EpisodeCount": 0,
|
||||
"ItemCount": 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func embyDisplayPreferencesHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"Id": c.Param("id"),
|
||||
"ViewType": "Poster",
|
||||
"SortBy": "SortName",
|
||||
"SortOrder": "Ascending",
|
||||
"IndexBy": "SortName",
|
||||
"RememberIndexing": false,
|
||||
"PrimaryImageHeight": 250,
|
||||
"PrimaryImageWidth": 250,
|
||||
"ScrollDirection": "Horizontal",
|
||||
"ShowSidebar": true,
|
||||
"CustomPrefs": gin.H{
|
||||
"homeexploresection": "1",
|
||||
"homesection0": "smalllibrarytiles",
|
||||
"homesection1": "resume",
|
||||
"homesection2": "latestmedia",
|
||||
"homesection3": "nextup",
|
||||
"homesection4": "none",
|
||||
"homesection5": "none",
|
||||
"homesection6": "none",
|
||||
"latestItems": "true",
|
||||
"landing-livetv": "false",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func embySaveDisplayPreferencesHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
params := service.ItemsParams{
|
||||
UserID: firstQueryValue(c, "UserId", "userId"),
|
||||
ParentID: c.Param("id"),
|
||||
Limit: 500,
|
||||
}
|
||||
out, err := svc.Emby.Items(c.Request.Context(), params)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
embyAttachRequestTokenToMediaSources(c, out)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
parentID := firstQueryValue(c, "SeasonId", "seasonId")
|
||||
if parentID == "" {
|
||||
parentID = c.Param("id")
|
||||
}
|
||||
params := service.ItemsParams{
|
||||
UserID: firstQueryValue(c, "UserId", "userId"),
|
||||
ParentID: parentID,
|
||||
IncludeItemTypes: []string{"Episode"},
|
||||
Recursive: true,
|
||||
Limit: 500,
|
||||
}
|
||||
out, err := svc.Emby.Items(c.Request.Context(), params)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
embyAttachRequestTokenToMediaSources(c, out)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"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"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service/cloud"
|
||||
)
|
||||
|
||||
func TestEmbyItemImageServesWithoutAPIAuth(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Media{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
posterPath := filepath.Join(t.TempDir(), "poster.png")
|
||||
if err := os.WriteFile(posterPath, []byte{
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
||||
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41,
|
||||
0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
|
||||
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00,
|
||||
0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
|
||||
0x42, 0x60, 0x82,
|
||||
}, 0o644); err != nil {
|
||||
t.Fatalf("write poster: %v", err)
|
||||
}
|
||||
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{
|
||||
App: config.AppConfig{DataDir: filepath.Dir(posterPath)},
|
||||
Cache: config.CacheConfig{CacheDir: t.TempDir()},
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "media-1"},
|
||||
Title: "Poster Test",
|
||||
Path: "D:\\media\\poster-test.mp4",
|
||||
PosterURL: posterPath,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, "test-secret", &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(cfg, zap.NewNop(), repos),
|
||||
ImageProxy: service.NewImageProxy(cfg, zap.NewNop()),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Items/media-1/Images/Primary", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if location := w.Header().Get("Location"); location != "" {
|
||||
t.Fatalf("expected direct image response, got redirect to %q", location)
|
||||
}
|
||||
if contentType := w.Header().Get("Content-Type"); !strings.Contains(contentType, "image/png") {
|
||||
t.Fatalf("expected png content type, got %q", contentType)
|
||||
}
|
||||
if got := w.Header().Get("Cache-Control"); !strings.Contains(got, "max-age=2592000") {
|
||||
t.Fatalf("image Cache-Control = %q, want long browser cache", got)
|
||||
}
|
||||
if got := w.Header().Get("Pragma"); got != "" {
|
||||
t.Fatalf("image Pragma = %q, want empty", got)
|
||||
}
|
||||
if got := w.Header().Get("Expires"); got != "" {
|
||||
t.Fatalf("image Expires = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyItemImageServesCachedCloudArtworkWithoutResolve(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("emby-cached-cloud-poster"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Media{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}
|
||||
imageProxy := service.NewImageProxy(cfg, zap.NewNop())
|
||||
ref := "/Movies/Cloud Movie/poster.jpg"
|
||||
if err := imageProxy.PrefetchCloudResolved(t.Context(), "openlist:"+ref, &cloud.DirectLink{URL: upstream.URL + "/poster.jpg"}); err != nil {
|
||||
t.Fatalf("prefetch cloud poster: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "cloud-media-1"},
|
||||
Title: "Cloud Poster Test",
|
||||
Path: "cloud://openlist/Movies/Cloud Movie/movie.mkv",
|
||||
PosterURL: service.CloudArtworkURL("openlist", ref),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, "test-secret", &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(cfg, zap.NewNop(), repos),
|
||||
ImageProxy: imageProxy,
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Items/cloud-media-1/Images/Primary", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := w.Body.String(); got != "emby-cached-cloud-poster" {
|
||||
t.Fatalf("body = %q, want cached cloud poster", got)
|
||||
}
|
||||
if location := w.Header().Get("Location"); location != "" {
|
||||
t.Fatalf("expected direct cached image response, got redirect to %q", location)
|
||||
}
|
||||
if got := w.Header().Get("Cache-Control"); !strings.Contains(got, "max-age=2592000") {
|
||||
t.Fatalf("image Cache-Control = %q, want long browser cache", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyMissingItemImageReturnsTransparentPlaceholder(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, "test-secret", &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(cfg, zap.NewNop(), repos),
|
||||
ImageProxy: service.NewImageProxy(cfg, zap.NewNop()),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodHead, "/Items/missing/Images/Primary", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected placeholder status 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if contentType := w.Header().Get("Content-Type"); !strings.Contains(contentType, "image/png") {
|
||||
t.Fatalf("expected png content type, got %q", contentType)
|
||||
}
|
||||
if length := w.Header().Get("Content-Length"); length == "" || length == "0" {
|
||||
t.Fatalf("expected placeholder content length, got %q", length)
|
||||
}
|
||||
if got := w.Header().Get("Pragma"); got != "" {
|
||||
t.Fatalf("placeholder Pragma = %q, want empty", got)
|
||||
}
|
||||
if got := w.Header().Get("Expires"); got != "" {
|
||||
t.Fatalf("placeholder Expires = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyUserItemByIDRouteReturnsJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Media{}, &model.Favorite{}, &model.PlaybackHistory{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
lib := model.Library{Name: "剧集", Path: "D:\\media\\tv", Type: "tv", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "episode-1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Test Show",
|
||||
Path: "D:\\media\\tv\\Test Show\\Season 01\\Test Show - S01E01.mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
Container: "mkv",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Users/user-1/Items/episode-1", nil)
|
||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
req.Header.Set("If-None-Match", `"stale-client-cache"`)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if contentType := w.Header().Get("Content-Type"); !strings.Contains(contentType, "application/json") {
|
||||
t.Fatalf("expected JSON content type, got %q body=%s", contentType, w.Body.String())
|
||||
}
|
||||
var item map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &item); err != nil {
|
||||
t.Fatalf("decode item: %v", err)
|
||||
}
|
||||
if item["Id"] != "episode-1" || item["Type"] != "Episode" {
|
||||
t.Fatalf("unexpected item payload: %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyUserItemByIDRouteReturnsLibraryView(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
lib := model.Library{Base: model.Base{ID: "lib-tv"}, Name: "剧集", Path: "D:\\media\\tv", Type: "tv", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Users/user-1/Items/lib-tv", nil)
|
||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var item map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &item); err != nil {
|
||||
t.Fatalf("decode item: %v", err)
|
||||
}
|
||||
if item["Id"] != "lib-tv" || item["Type"] != "CollectionFolder" || item["CollectionType"] != "tvshows" {
|
||||
t.Fatalf("unexpected library payload: %#v", item)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
out, err := svc.Emby.PlaybackInfo(c.Request.Context(), c.Param("id"), uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if out == nil {
|
||||
embyError(c, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
embyAttachRequestTokenToMediaSources(c, out)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
func embyAttachRequestTokenToMediaSources(c *gin.Context, out any) {
|
||||
token := embyRequestToken(c)
|
||||
if token == "" || out == nil {
|
||||
return
|
||||
}
|
||||
embyAttachTokenToMediaSourcesValue(out, token)
|
||||
}
|
||||
|
||||
func embyAttachTokenToMediaSourcesValue(value any, token string) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
embyAttachTokenToMediaSourcesMap(typed, token)
|
||||
case gin.H:
|
||||
embyAttachTokenToMediaSourcesMap(map[string]any(typed), token)
|
||||
case []map[string]any:
|
||||
for _, item := range typed {
|
||||
embyAttachTokenToMediaSourcesMap(item, token)
|
||||
}
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
embyAttachTokenToMediaSourcesValue(item, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func embyAttachTokenToMediaSourcesMap(out map[string]any, token string) {
|
||||
if out == nil {
|
||||
return
|
||||
}
|
||||
if sources, ok := out["MediaSources"].([]map[string]any); ok {
|
||||
embyAttachTokenToMediaSources(sources, token)
|
||||
} else if sources, ok := out["MediaSources"].([]any); ok {
|
||||
for _, source := range sources {
|
||||
if sourceMap, ok := source.(map[string]any); ok {
|
||||
embyAttachTokenToMediaSources([]map[string]any{sourceMap}, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
if items, ok := out["Items"]; ok {
|
||||
embyAttachTokenToMediaSourcesValue(items, token)
|
||||
}
|
||||
}
|
||||
|
||||
func embyAttachTokenToMediaSources(sources []map[string]any, token string) {
|
||||
for _, source := range sources {
|
||||
for _, key := range []string{"DirectStreamUrl", "TranscodingUrl"} {
|
||||
raw, ok := source[key].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
source[key] = embyAppendAPIKey(raw, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func embyRequestToken(c *gin.Context) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"api_key", "apiKey", "ApiKey", "token", "X-Emby-Token", "X-MediaBrowser-Token"} {
|
||||
if value := strings.TrimSpace(c.Query(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
for _, header := range []string{"X-Emby-Token", "X-MediaBrowser-Token"} {
|
||||
if value := strings.TrimSpace(c.GetHeader(header)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
for _, header := range []string{"Authorization", "X-Emby-Authorization", "X-MediaBrowser-Authorization"} {
|
||||
if token := embyTokenFromAuthHeader(c.GetHeader(header)); token != "" {
|
||||
return token
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func embyTokenFromAuthHeader(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
for _, prefix := range []string{"Bearer ", "Emby "} {
|
||||
if strings.HasPrefix(value, prefix) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(value, prefix))
|
||||
}
|
||||
}
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
part = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(part), "MediaBrowser "))
|
||||
if !strings.HasPrefix(part, "Token=") {
|
||||
continue
|
||||
}
|
||||
token := strings.TrimSpace(strings.TrimPrefix(part, "Token="))
|
||||
return strings.Trim(token, `"`)
|
||||
}
|
||||
if strings.Contains(value, "Token=") {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func embyAppendAPIKey(raw, token string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
token = strings.TrimSpace(token)
|
||||
if raw == "" || token == "" {
|
||||
return raw
|
||||
}
|
||||
if strings.HasPrefix(raw, "//") {
|
||||
return raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.IsAbs() {
|
||||
return raw
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("api_key") == "" && q.Get("apiKey") == "" && q.Get("token") == "" {
|
||||
q.Set("api_key", token)
|
||||
u.RawQuery = q.Encode()
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// embyVideoStreamHandler 是 GET /Videos/{id}/stream 的入口,
|
||||
// 直接代理到我们的 /api/stream/{id}(同一个 ServeFile)。
|
||||
func embyVideoStreamHandler(svc *service.Container, cloudMode string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := embyUserID(c)
|
||||
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if item == nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if embyShouldRedirectVideoStreamToSTRM(c, svc, c.Param("id"), cloudMode) {
|
||||
target := "/api/stream/" + url.PathEscape(strings.TrimSpace(c.Param("id")))
|
||||
if token := embyPlaybackRedirectToken(c, svc); token != "" {
|
||||
target = embyAppendAPIKey(target, token)
|
||||
}
|
||||
setRedirectNoStoreHeaders(c)
|
||||
c.Redirect(http.StatusFound, absoluteRequestURL(c, target))
|
||||
return
|
||||
}
|
||||
// 直接调用 Stream service 写入 response。
|
||||
// 此前这里把所有错误一律吞成 404:云盘 Cookie 过期、直链解析失败、
|
||||
// STRM 播放被关闭……在第三方播放器上全部表现为「404 不存在」,
|
||||
// 无法排查。现在区分:行不存在→404;云盘播放不可用/上游故障→502+原因。
|
||||
err = svc.Stream.ServeFileWithCloudMode(c.Writer, c.Request, c.Param("id"), cloudMode)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, service.ErrMediaNotFound):
|
||||
c.Status(http.StatusNotFound)
|
||||
case errors.Is(err, service.ErrCloudPlaybackDisabled):
|
||||
if !c.Writer.Written() {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
}
|
||||
default:
|
||||
if !c.Writer.Written() {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func embyPlaybackRedirectToken(c *gin.Context, svc *service.Container) string {
|
||||
if token := embyRequestToken(c); token != "" {
|
||||
return token
|
||||
}
|
||||
if c == nil || svc == nil || svc.Auth == nil || svc.Repo == nil || svc.Repo.User == nil {
|
||||
return ""
|
||||
}
|
||||
uid := embyUserID(c)
|
||||
if uid == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := svc.Repo.User.FindByID(c.Request.Context(), uid)
|
||||
if err != nil || u == nil {
|
||||
return ""
|
||||
}
|
||||
token, err := svc.Auth.IssueEmbyToken(u)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func embyShouldRedirectVideoStreamToSTRM(c *gin.Context, svc *service.Container, mediaID, cloudMode string) bool {
|
||||
if c == nil || svc == nil || svc.Repo == nil || svc.Repo.Media == nil || cloudMode != service.CloudPlaybackModeRedirectProxy {
|
||||
return false
|
||||
}
|
||||
settings := service.CloudPlaybackSettings(c.Request.Context(), svc.Repo)
|
||||
if settings.PreferredMode != service.CloudPlaybackModeSTRM || !settings.STRMEnabled {
|
||||
return false
|
||||
}
|
||||
m, err := svc.Repo.Media.FindByID(c.Request.Context(), mediaID)
|
||||
if err != nil || m == nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(m.STRMURL) != ""
|
||||
}
|
||||
|
||||
func embyVideoHLSPlaylistHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := embyUserID(c)
|
||||
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
|
||||
if err != nil || item == nil || svc.Stream == nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
err = svc.Stream.ServeHLSPlaylist(c.Writer, c.Request, c.Param("id"))
|
||||
if errors.Is(err, service.ErrTranscodeDisabled) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "transcode disabled"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrTranscodeBusy) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "transcode busy"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func embyVideoHLSSegmentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := embyUserID(c)
|
||||
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
|
||||
if err != nil || item == nil || svc.Stream == nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := svc.Stream.ServeHLSSegment(c.Writer, c.Request, c.Param("id"), c.Param("seg")); err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
func TestEmbyLowercasePlaybackInfoRouteReturnsJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
lib := model.Library{Name: "电影", Path: t.TempDir(), Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "media-1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Lowercase Playback",
|
||||
Path: filepath.Join(lib.Path, "lowercase-playback.mp4"),
|
||||
Container: "mp4",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/users/user-1/items/media-1/playbackinfo", nil)
|
||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode playback info: %v", err)
|
||||
}
|
||||
if _, ok := body["MediaSources"]; !ok {
|
||||
t.Fatalf("missing MediaSources: %#v", body)
|
||||
}
|
||||
sources, ok := body["MediaSources"].([]any)
|
||||
if !ok || len(sources) == 0 {
|
||||
t.Fatalf("unexpected MediaSources: %#v", body["MediaSources"])
|
||||
}
|
||||
source, ok := sources[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected MediaSource: %#v", sources[0])
|
||||
}
|
||||
directURL, _ := source["DirectStreamUrl"].(string)
|
||||
if !strings.Contains(directURL, "api_key=") {
|
||||
t.Fatalf("DirectStreamUrl should carry api_key for clients that do not repeat auth headers: %#v", source)
|
||||
}
|
||||
transcodeURL, _ := source["TranscodingUrl"].(string)
|
||||
if transcodeURL != "" && !strings.Contains(transcodeURL, "api_key=") {
|
||||
t.Fatalf("TranscodingUrl should carry api_key: %#v", source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyPlaybackInfoDoesNotExposeTokenInCloudPath(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.Setting.Set(t.Context(), service.CloudPlaybackModeSettingKey, service.CloudPlaybackModeSTRM); err != nil {
|
||||
t.Fatalf("set cloud playback mode: %v", err)
|
||||
}
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
lib := model.Library{Name: "OpenList", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "cloud-1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Cloud Movie",
|
||||
Path: "cloud://openlist/Movies/Movie.mkv",
|
||||
STRMURL: "/api/cloud/play/openlist?ref=%2FMovies%2FMovie.mkv",
|
||||
Container: "mkv",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/users/user-1/items/cloud-1/playbackinfo", nil)
|
||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode playback info: %v", err)
|
||||
}
|
||||
source := body["MediaSources"].([]any)[0].(map[string]any)
|
||||
pathURL, _ := source["Path"].(string)
|
||||
if pathURL != "/api/stream/cloud-1" {
|
||||
t.Fatalf("cloud Path should stay as non-tokenized display stream URL, got %#v", source)
|
||||
}
|
||||
if strings.Contains(pathURL, "api_key=") || strings.Contains(pathURL, "token=") {
|
||||
t.Fatalf("cloud Path must not expose auth key/token: %#v", source)
|
||||
}
|
||||
if strings.Contains(pathURL, "/api/cloud/play/") {
|
||||
t.Fatalf("cloud Path should not expose naked cloud play URL: %#v", source)
|
||||
}
|
||||
directURL, _ := source["DirectStreamUrl"].(string)
|
||||
if !strings.HasPrefix(directURL, "/api/stream/cloud-1") || !strings.Contains(directURL, "api_key=") {
|
||||
t.Fatalf("DirectStreamUrl should stay tokenized: %#v", source)
|
||||
}
|
||||
if source["SupportsDirectPlay"] != true {
|
||||
t.Fatalf("cloud media should advertise DirectPlay when tokenized Path is playable: %#v", source)
|
||||
}
|
||||
if source["SupportsTranscoding"] != false {
|
||||
t.Fatalf("cloud media should not advertise host transcoding: %#v", source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyItemsDoNotExposeTokenInEmbeddedCloudPath(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.Setting.Set(t.Context(), service.CloudPlaybackModeSettingKey, service.CloudPlaybackModeSTRM); err != nil {
|
||||
t.Fatalf("set cloud playback mode: %v", err)
|
||||
}
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
lib := model.Library{Name: "OpenList", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "cloud-1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Cloud Movie",
|
||||
Path: "cloud://openlist/Movies/Movie.mkv",
|
||||
STRMURL: "/api/cloud/play/openlist?ref=%2FMovies%2FMovie.mkv",
|
||||
Container: "mkv",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
token := signedTestToken(t, secret)
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/Users/user-1/Items?IncludeItemTypes=Movie&Recursive=true&Limit=5&X-Emby-Token="+token, nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode items: %v", err)
|
||||
}
|
||||
items := body["Items"].([]any)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("unexpected items: %#v", body["Items"])
|
||||
}
|
||||
source := items[0].(map[string]any)["MediaSources"].([]any)[0].(map[string]any)
|
||||
pathURL, _ := source["Path"].(string)
|
||||
if pathURL != "/api/stream/cloud-1" {
|
||||
t.Fatalf("embedded cloud Path should stay as non-tokenized display stream URL, got %#v", source)
|
||||
}
|
||||
if strings.Contains(pathURL, "api_key=") || strings.Contains(pathURL, "token=") {
|
||||
t.Fatalf("embedded cloud Path must not expose auth key/token: %#v", source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyVideoStreamUsesSTRMWhenRedirectProxyDisabled(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.Setting.Set(t.Context(), service.CloudPlaybackModeSettingKey, service.CloudPlaybackModeSTRM); err != nil {
|
||||
t.Fatalf("set cloud playback mode: %v", err)
|
||||
}
|
||||
if err := repos.Setting.Set(t.Context(), service.CloudPlaybackSTRMEnabledSettingKey, "true"); err != nil {
|
||||
t.Fatalf("enable strm playback: %v", err)
|
||||
}
|
||||
if err := repos.Setting.Set(t.Context(), service.CloudPlaybackRedirectEnabledSettingKey, "false"); err != nil {
|
||||
t.Fatalf("disable redirect playback: %v", err)
|
||||
}
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
lib := model.Library{Name: "OpenList", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "cloud-1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Cloud Movie",
|
||||
Path: "cloud://openlist/Movies/Movie.mkv",
|
||||
STRMURL: "/api/cloud/play/openlist?ref=%2FMovies%2FMovie.mkv",
|
||||
Container: "mkv",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
cfg := &config.Config{Secrets: config.SecretsConfig{JWTSecret: secret}}
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(cfg, zap.NewNop(), repos),
|
||||
Stream: service.NewStreamService(cfg, zap.NewNop(), repos, nil),
|
||||
})
|
||||
|
||||
token := signedTestToken(t, secret)
|
||||
req := httptest.NewRequest(http.MethodGet, "/videos/cloud-1/stream?api_key="+token, nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
loc := w.Header().Get("Location")
|
||||
if !strings.Contains(loc, "/api/stream/cloud-1") || !strings.Contains(loc, "api_key=") {
|
||||
t.Fatalf("STRM mode should redirect /Videos fallback to tokenized /api/stream, got %q", loc)
|
||||
}
|
||||
if got := w.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") {
|
||||
t.Fatalf("STRM fallback redirect Cache-Control = %q, want no-store", got)
|
||||
}
|
||||
if strings.Contains(loc, "/api/cloud/play/") {
|
||||
t.Fatalf("STRM mode should not expose cloud play directly from /Videos fallback: %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyVideoStreamIssuesTokenForSessionFallbackSTRMRedirect(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.Setting.Set(t.Context(), service.CloudPlaybackModeSettingKey, service.CloudPlaybackModeSTRM); err != nil {
|
||||
t.Fatalf("set cloud playback mode: %v", err)
|
||||
}
|
||||
if err := repos.Setting.Set(t.Context(), service.CloudPlaybackSTRMEnabledSettingKey, "true"); err != nil {
|
||||
t.Fatalf("enable strm playback: %v", err)
|
||||
}
|
||||
if err := repos.Setting.Set(t.Context(), service.CloudPlaybackRedirectEnabledSettingKey, "false"); err != nil {
|
||||
t.Fatalf("disable redirect playback: %v", err)
|
||||
}
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}
|
||||
if err := repos.User.Create(t.Context(), &user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
lib := model.Library{Name: "OpenList", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "cloud-1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Cloud Movie",
|
||||
Path: "cloud://openlist/Movies/Movie.mkv",
|
||||
STRMURL: "/api/cloud/play/openlist?ref=%2FMovies%2FMovie.mkv",
|
||||
Container: "mkv",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
cfg := &config.Config{Secrets: config.SecretsConfig{JWTSecret: secret}}
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Auth: service.NewAuthService(cfg, zap.NewNop(), repos, nil, nil),
|
||||
Emby: service.NewEmbyService(cfg, zap.NewNop(), repos),
|
||||
Stream: service.NewStreamService(cfg, zap.NewNop(), repos, nil),
|
||||
}
|
||||
router := gin.New()
|
||||
router.GET("/videos/:id/stream", func(c *gin.Context) {
|
||||
c.Set(middleware.CtxUserID, user.ID)
|
||||
c.Set(middleware.CtxUserRole, user.Role)
|
||||
embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy)(c)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/videos/cloud-1/stream", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
loc := w.Header().Get("Location")
|
||||
if !strings.Contains(loc, "/api/stream/cloud-1") || !strings.Contains(loc, "api_key=") {
|
||||
t.Fatalf("session fallback redirect should include api_key for /api/stream, got %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyLowercaseVideoStreamRouteServesMedia(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
mediaPath := filepath.Join(dir, "sample.mp4")
|
||||
if err := os.WriteFile(mediaPath, []byte("fake-video-bytes"), 0o644); err != nil {
|
||||
t.Fatalf("write media: %v", err)
|
||||
}
|
||||
lib := model.Library{Name: "电影", Path: dir, Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "media-1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Lowercase Stream",
|
||||
Path: mediaPath,
|
||||
Container: "mp4",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
Stream: service.NewStreamService(&config.Config{}, zap.NewNop(), repos, nil),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/videos/media-1/stream?api_key="+signedTestToken(t, secret), nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := w.Body.String(); got != "fake-video-bytes" {
|
||||
t.Fatalf("unexpected stream body: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyPrefixedAPIStreamRouteServesMedia(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
mediaPath := filepath.Join(dir, "sample.mp4")
|
||||
if err := os.WriteFile(mediaPath, []byte("fake-video-bytes"), 0o644); err != nil {
|
||||
t.Fatalf("write media: %v", err)
|
||||
}
|
||||
lib := model.Library{Name: "电影", Path: dir, Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "media-1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Prefixed API Stream",
|
||||
Path: mediaPath,
|
||||
Container: "mp4",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
Stream: service.NewStreamService(&config.Config{}, zap.NewNop(), repos, nil),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/api/stream/media-1?api_key="+signedTestToken(t, secret), nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := w.Body.String(); got != "fake-video-bytes" {
|
||||
t.Fatalf("unexpected stream body: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyVideoStreamRedirectKeepsMediaBrowserAuthorizationToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
lib := model.Library{Name: "OpenList", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "cloud-1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Cloud Movie",
|
||||
Path: "cloud://openlist/Movies/Movie.mkv",
|
||||
STRMURL: "/api/cloud/play/openlist?ref=%2FMovies%2FMovie.mkv",
|
||||
Container: "mkv",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
Stream: service.NewStreamService(&config.Config{}, zap.NewNop(), repos, nil),
|
||||
})
|
||||
|
||||
token := signedTestToken(t, secret)
|
||||
req := httptest.NewRequest(http.MethodGet, "/videos/cloud-1/stream", nil)
|
||||
req.Header.Set("X-MediaBrowser-Authorization", `MediaBrowser Client="Infuse", Device="PC", Token="`+token+`"`)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
loc := w.Header().Get("Location")
|
||||
if !strings.Contains(loc, "/api/cloud/play/openlist?") || !strings.Contains(loc, "token=") {
|
||||
t.Fatalf("redirect Location should target tokenized cloud play endpoint, got %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyLowercaseOriginalHeadRouteServesHeaders(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
mediaPath := filepath.Join(dir, "sample.mp4")
|
||||
if err := os.WriteFile(mediaPath, []byte("fake-video-bytes"), 0o644); err != nil {
|
||||
t.Fatalf("write media: %v", err)
|
||||
}
|
||||
lib := model.Library{Name: "电影", Path: dir, Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "media-1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Lowercase Original",
|
||||
Path: mediaPath,
|
||||
Container: "mp4",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
Stream: service.NewStreamService(&config.Config{}, zap.NewNop(), repos, nil),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodHead, "/videos/media-1/original.mp4?api_key="+signedTestToken(t, secret), nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if w.Body.Len() != 0 {
|
||||
t.Fatalf("HEAD response should not include body, got %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyLowercaseVideoHLSRouteDoesNot404WhenDirectOnly(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
mediaPath := filepath.Join(dir, "sample.mp4")
|
||||
if err := os.WriteFile(mediaPath, []byte("fake-video-bytes"), 0o644); err != nil {
|
||||
t.Fatalf("write media: %v", err)
|
||||
}
|
||||
lib := model.Library{Name: "电影", Path: dir, Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "media-1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Lowercase HLS",
|
||||
Path: mediaPath,
|
||||
Container: "mp4",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
if err := repos.Setting.Set(t.Context(), service.PlaybackDirectOnlySettingKey, "true"); err != nil {
|
||||
t.Fatalf("set direct-only: %v", err)
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
Stream: service.NewStreamService(&config.Config{}, zap.NewNop(), repos, nil),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/videos/media-1/master.m3u8?api_key="+signedTestToken(t, secret), nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code == http.StatusNotFound {
|
||||
t.Fatalf("lowercase HLS route should be registered, got 404")
|
||||
}
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("direct-only HLS should return 409, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
type embyPlayingReq struct {
|
||||
ItemId string `json:"ItemId"`
|
||||
PositionTicks int64 `json:"PositionTicks"`
|
||||
RunTimeTicks int64 `json:"RunTimeTicks"`
|
||||
}
|
||||
|
||||
func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := embyUserID(c)
|
||||
if uid == "" {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var req embyPlayingReq
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
if req.ItemId == "" {
|
||||
req.ItemId = c.Query("ItemId")
|
||||
}
|
||||
if req.PositionTicks == 0 {
|
||||
req.PositionTicks, _ = strconv.ParseInt(c.Query("PositionTicks"), 10, 64)
|
||||
}
|
||||
if req.RunTimeTicks == 0 {
|
||||
req.RunTimeTicks, _ = strconv.ParseInt(c.Query("RunTimeTicks"), 10, 64)
|
||||
}
|
||||
if req.ItemId == "" {
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
clientInfo := embyClientInfoFromRequest(c)
|
||||
if svc.Device != nil && svc.Device.IsDeviceKicked(c.Request.Context(), uid, clientInfo.DeviceID) {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_ = svc.Emby.RecordProgress(c.Request.Context(), uid, req.ItemId, req.PositionTicks, req.RunTimeTicks)
|
||||
stopped := strings.Contains(strings.ToLower(c.FullPath()+" "+c.Request.URL.Path), "stopped")
|
||||
if svc.Sessions != nil {
|
||||
svc.Sessions.RecordPlayback(c.Request.Context(), uid, "",
|
||||
clientInfo.DeviceID,
|
||||
clientInfo.DeviceName,
|
||||
clientInfo.Client,
|
||||
c.ClientIP(),
|
||||
req.ItemId,
|
||||
req.PositionTicks,
|
||||
req.RunTimeTicks,
|
||||
stopped)
|
||||
}
|
||||
if svc.Device != nil && !stopped {
|
||||
svc.Device.RecordPlayback(c.Request.Context(), uid,
|
||||
clientInfo.DeviceID,
|
||||
clientInfo.DeviceName,
|
||||
clientInfo.Client)
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
mid := c.Param("itemId")
|
||||
if uid == "" || mid == "" {
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := svc.Emby.SetFavorite(c.Request.Context(), uid, mid, fav); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out, _ := svc.Emby.Item(c.Request.Context(), mid, uid)
|
||||
if out != nil {
|
||||
c.JSON(http.StatusOK, out["UserData"])
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"IsFavorite": fav})
|
||||
}
|
||||
}
|
||||
|
||||
func embyMarkPlayedHandler(svc *service.Container, played bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
mid := c.Param("itemId")
|
||||
if uid == "" || mid == "" {
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := svc.Emby.MarkPlayed(c.Request.Context(), uid, mid, played); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if played && svc.Device != nil {
|
||||
clientInfo := embyClientInfoFromRequest(c)
|
||||
svc.Device.RecordPlayback(c.Request.Context(), uid, clientInfo.DeviceID, clientInfo.DeviceName, clientInfo.Client)
|
||||
}
|
||||
out, _ := svc.Emby.Item(c.Request.Context(), mid, uid)
|
||||
if out != nil {
|
||||
c.JSON(http.StatusOK, out["UserData"])
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"Played": played})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
// registerEmbyRoutes 在 r 上挂双前缀("" + "/emby")的 Emby 兼容路由。
|
||||
func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container) {
|
||||
for _, prefix := range []string{"/emby", ""} {
|
||||
grp := r.Group(prefix)
|
||||
grp.Use(embyNoStoreHeaders())
|
||||
|
||||
registerEmbyRootRoutes(grp, prefix, svc)
|
||||
registerEmbyPublicRoutes(grp, svc)
|
||||
registerEmbyPublicImageRoutes(grp, svc)
|
||||
|
||||
// 鉴权后端点
|
||||
auth := grp.Group("", embyAuthRequiredWithSessionFallback(jwtSecret), activeEmbyUserRequired(svc))
|
||||
registerEmbyAuthenticatedRoutes(auth, prefix, svc)
|
||||
}
|
||||
}
|
||||
|
||||
type embyRouteHandlerFactory func(*service.Container) gin.HandlerFunc
|
||||
|
||||
func embyNoStoreHeaders() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Header("Pragma", "no-cache")
|
||||
c.Header("Expires", "0")
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func registerEmbyRootRoutes(grp *gin.RouterGroup, prefix string, svc *service.Container) {
|
||||
if prefix != "/emby" {
|
||||
return
|
||||
}
|
||||
grp.GET("", embyRootHandler(svc))
|
||||
grp.HEAD("", embyRootHandler(svc))
|
||||
grp.GET("/", embyRootHandler(svc))
|
||||
grp.HEAD("/", embyRootHandler(svc))
|
||||
}
|
||||
|
||||
func registerEmbyPublicRoutes(grp *gin.RouterGroup, svc *service.Container) {
|
||||
registerEmbyPublicSystemRoutes(grp, svc)
|
||||
registerEmbyPublicSessionRoutes(grp, svc)
|
||||
registerEmbyPublicClientRoutes(grp, svc)
|
||||
}
|
||||
|
||||
func registerEmbyPublicSystemRoutes(grp *gin.RouterGroup, svc *service.Container) {
|
||||
registerEmbyGetHeadRoutes(grp, svc, []string{"/System/Info/Public", "/system/info/public"}, embySystemInfoPublicHandler)
|
||||
registerEmbyGetHeadRoutes(grp, svc, []string{"/System/Info", "/system/info"}, embySystemInfoHandler)
|
||||
registerEmbyGetRoutes(grp, svc, []string{"/System/Endpoint", "/system/endpoint"}, embySystemEndpointHandler)
|
||||
registerEmbyGetHeadRoutes(grp, svc, []string{"/System/Ext/ServerDomains", "/system/ext/serverdomains"}, embyServerDomainsHandler)
|
||||
registerEmbyGetHeadRoutes(grp, svc, []string{"/System/Configuration/Public", "/system/configuration/public"}, embyPublicServerConfigurationHandler)
|
||||
registerEmbyGetHeadRoutes(grp, svc, []string{"/Startup/Configuration", "/startup/configuration"}, embyStartupConfigurationHandler)
|
||||
registerEmbyPostRoutes(grp, svc, []string{"/Startup/Complete", "/startup/complete"}, embyNoContentHandler)
|
||||
registerEmbyGetHeadRoutes(grp, svc, []string{"/QuickConnect/Enabled", "/quickconnect/enabled"}, embyQuickConnectEnabledHandler)
|
||||
for _, path := range []string{"/System/Ping", "/system/ping"} {
|
||||
grp.GET(path, embyPingHandler(svc))
|
||||
grp.HEAD(path, embyPingHandler(svc))
|
||||
grp.POST(path, embyPingHandler(svc))
|
||||
}
|
||||
}
|
||||
|
||||
func registerEmbyPublicSessionRoutes(grp *gin.RouterGroup, svc *service.Container) {
|
||||
registerEmbyPostRoutes(grp, svc, []string{
|
||||
"/Sessions/Capabilities", "/Sessions/Capabilities/Full",
|
||||
"/sessions/capabilities", "/sessions/capabilities/full",
|
||||
}, embyNoContentHandler)
|
||||
|
||||
// 30/min per IP: many Emby clients sit behind a single NAT/reverse-proxy
|
||||
// IP, so a low limit would throttle legitimate logins into 429s.
|
||||
embyLoginLimiter := middleware.NewRateLimiter(30, 1*time.Minute)
|
||||
for _, path := range []string{"/Users/AuthenticateByName", "/Users/authenticatebyname", "/users/AuthenticateByName", "/users/authenticatebyname"} {
|
||||
grp.POST(path, middleware.RateLimit(embyLoginLimiter), embyAuthByNameHandler(svc))
|
||||
}
|
||||
|
||||
registerEmbyGetRoutes(grp, svc, []string{"/Users/Public", "/users/public"}, embyPublicUsersHandler)
|
||||
}
|
||||
|
||||
func registerEmbyPublicClientRoutes(grp *gin.RouterGroup, svc *service.Container) {
|
||||
registerEmbyGetRoutes(grp, svc, []string{"/Branding/Configuration", "/branding/configuration"}, embyBrandingConfigHandler)
|
||||
registerEmbyGetHeadRoutes(grp, svc, []string{"/Branding/Css", "/branding/css"}, embyBrandingCSSHandler)
|
||||
registerEmbyGetRoutes(grp, svc, []string{"/Localization/Options", "/localization/options"}, embyLocalizationOptionsHandler)
|
||||
registerEmbyGetRoutes(grp, svc, []string{"/Localization/Cultures", "/Localization/cultures", "/localization/cultures"}, embyLocalizationCulturesHandler)
|
||||
registerEmbyGetHeadRoutes(grp, svc, []string{"/CustomCssJS/Scripts", "/customcssjs/scripts"}, embyCustomCSSJSScriptsHandler)
|
||||
for _, path := range []string{"/embywebsocket", "/EmbyWebSocket"} {
|
||||
grp.GET(path, embyWebSocketHandler(svc))
|
||||
grp.HEAD(path, embyNoContentHandler(svc))
|
||||
}
|
||||
registerEmbyPostRoutes(grp, svc, []string{"/Sessions/Logout", "/sessions/logout"}, embySessionLogoutHandler)
|
||||
grp.GET("/DisplayPreferences/:id", embyDisplayPreferencesHandler(svc))
|
||||
grp.POST("/DisplayPreferences/:id", embySaveDisplayPreferencesHandler(svc))
|
||||
grp.GET("/displaypreferences/:id", embyDisplayPreferencesHandler(svc))
|
||||
grp.POST("/displaypreferences/:id", embySaveDisplayPreferencesHandler(svc))
|
||||
}
|
||||
|
||||
func registerEmbyPublicImageRoutes(grp *gin.RouterGroup, svc *service.Container) {
|
||||
// 图片公开(Infuse 缓存 URL 时会丢 token)
|
||||
grp.GET("/Items/:id/Images/:type", embyItemImageHandler(svc))
|
||||
grp.GET("/Items/:id/Images/:type/:index", embyItemImageHandler(svc))
|
||||
grp.HEAD("/Items/:id/Images/:type", embyItemImageHandler(svc))
|
||||
grp.GET("/items/:id/images/:type", embyItemImageHandler(svc))
|
||||
grp.GET("/items/:id/images/:type/:index", embyItemImageHandler(svc))
|
||||
grp.HEAD("/items/:id/images/:type", embyItemImageHandler(svc))
|
||||
}
|
||||
|
||||
func registerEmbyGetRoutes(grp *gin.RouterGroup, svc *service.Container, paths []string, factory embyRouteHandlerFactory) {
|
||||
for _, path := range paths {
|
||||
grp.GET(path, factory(svc))
|
||||
}
|
||||
}
|
||||
|
||||
func registerEmbyGetHeadRoutes(grp *gin.RouterGroup, svc *service.Container, paths []string, factory embyRouteHandlerFactory) {
|
||||
for _, path := range paths {
|
||||
grp.GET(path, factory(svc))
|
||||
grp.HEAD(path, factory(svc))
|
||||
}
|
||||
}
|
||||
|
||||
func registerEmbyPostRoutes(grp *gin.RouterGroup, svc *service.Container, paths []string, factory embyRouteHandlerFactory) {
|
||||
for _, path := range paths {
|
||||
grp.POST(path, factory(svc))
|
||||
}
|
||||
}
|
||||
|
||||
func registerEmbyAuthenticatedRoutes(auth *gin.RouterGroup, prefix string, svc *service.Container) {
|
||||
registerEmbyAuthenticatedUserRoutes(auth, svc)
|
||||
registerEmbyAuthenticatedItemRoutes(auth, svc)
|
||||
registerEmbyAuthenticatedPlaybackRoutes(auth, prefix, svc)
|
||||
registerEmbyAuthenticatedProgressRoutes(auth, svc)
|
||||
registerEmbyAuthenticatedUserDataRoutes(auth, svc)
|
||||
registerEmbyAuthenticatedSystemRoutes(auth, svc)
|
||||
registerLowercaseEmbyAuthRoutes(auth, svc)
|
||||
}
|
||||
|
||||
func registerEmbyAuthenticatedUserRoutes(auth *gin.RouterGroup, svc *service.Container) {
|
||||
auth.GET("/Users/Me", embyMeHandler(svc))
|
||||
auth.GET("/Users", embyListUsersHandler(svc))
|
||||
auth.GET("/Users/:userId", embyGetUserByIDHandler(svc))
|
||||
auth.GET("/Users/:userId/Views", embyViewsHandler(svc))
|
||||
auth.GET("/Library/MediaFolders", embyViewsHandler(svc))
|
||||
auth.GET("/Library/VirtualFolders", embyVirtualFoldersHandler(svc))
|
||||
auth.GET("/Library/SelectableMediaFolders", embyVirtualFoldersHandler(svc))
|
||||
}
|
||||
|
||||
func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Container) {
|
||||
auth.GET("/Items", embyItemsHandler(svc))
|
||||
auth.GET("/Users/:userId/Items", embyItemsHandler(svc))
|
||||
auth.GET("/Items/Counts", embyItemsCountsHandler(svc))
|
||||
auth.GET("/Users/:userId/Items/Counts", embyItemsCountsHandler(svc))
|
||||
auth.GET("/Items/Latest", embyLatestItemsHandler(svc))
|
||||
auth.GET("/Items/Resume", embyResumeItemsHandler(svc))
|
||||
auth.GET("/Items/:id", embyItemByIDHandler(svc))
|
||||
auth.GET("/Users/:userId/Items/:id", embyUserItemByIDHandler(svc))
|
||||
auth.GET("/Shows/:id/Seasons", embyShowSeasonsHandler(svc))
|
||||
auth.GET("/Shows/:id/Episodes", embyShowEpisodesHandler(svc))
|
||||
auth.GET("/Users/:userId/Shows/:id/Seasons", embyShowSeasonsHandler(svc))
|
||||
auth.GET("/Users/:userId/Shows/:id/Episodes", embyShowEpisodesHandler(svc))
|
||||
auth.GET("/Shows/NextUp", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Users/:userId/Shows/NextUp", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/MediaSegments/:id", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Artists", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Persons", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Genres", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Shows/Upcoming", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Users/:userId/Shows/Upcoming", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Items/:id/Similar", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Items/:id/ThumbnailSet", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Items/:id/ThemeMedia", embyThemeMediaHandler(svc))
|
||||
auth.GET("/Users/:userId/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Users/:userId/Items/:id/Intros", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Items/:id/Intros", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/api/danmu/:id/raw", embyDanmuRawHandler(svc))
|
||||
}
|
||||
|
||||
func registerEmbyAuthenticatedPlaybackRoutes(auth *gin.RouterGroup, prefix string, svc *service.Container) {
|
||||
auth.GET("/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
|
||||
auth.POST("/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
|
||||
auth.GET("/Users/:userId/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
|
||||
auth.POST("/Users/:userId/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
|
||||
|
||||
registerEmbyVideoStreamRoutes(auth, svc, "/Videos")
|
||||
if prefix == "/emby" {
|
||||
auth.GET("/api/stream/:id", embyVideoStreamHandler(svc, service.CloudPlaybackModeSTRM))
|
||||
auth.HEAD("/api/stream/:id", embyVideoStreamHandler(svc, service.CloudPlaybackModeSTRM))
|
||||
}
|
||||
auth.GET("/Videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc))
|
||||
auth.HEAD("/Videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc))
|
||||
auth.GET("/Videos/:id/main.m3u8", embyVideoHLSPlaylistHandler(svc))
|
||||
auth.HEAD("/Videos/:id/main.m3u8", embyVideoHLSPlaylistHandler(svc))
|
||||
auth.GET("/Videos/:id/:seg", embyVideoHLSSegmentHandler(svc))
|
||||
}
|
||||
|
||||
func registerEmbyVideoStreamRoutes(auth *gin.RouterGroup, svc *service.Container, basePath string) {
|
||||
streamHandler := func() gin.HandlerFunc {
|
||||
return embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy)
|
||||
}
|
||||
for _, path := range []string{"/:id/stream", "/:id/stream.:container", "/:id/original", "/:id/original.:container"} {
|
||||
fullPath := basePath + path
|
||||
auth.GET(fullPath, streamHandler())
|
||||
auth.HEAD(fullPath, streamHandler())
|
||||
}
|
||||
}
|
||||
|
||||
func registerEmbyAuthenticatedProgressRoutes(auth *gin.RouterGroup, svc *service.Container) {
|
||||
auth.POST("/Sessions/Playing", embyPlayingProgressHandler(svc))
|
||||
auth.POST("/Sessions/Playing/Progress", embyPlayingProgressHandler(svc))
|
||||
auth.POST("/Sessions/Playing/Stopped", embyPlayingProgressHandler(svc))
|
||||
}
|
||||
|
||||
func registerEmbyAuthenticatedUserDataRoutes(auth *gin.RouterGroup, svc *service.Container) {
|
||||
auth.POST("/Users/:userId/FavoriteItems/:itemId", embyFavoriteHandler(svc, true))
|
||||
auth.DELETE("/Users/:userId/FavoriteItems/:itemId", embyFavoriteHandler(svc, false))
|
||||
auth.POST("/Users/:userId/PlayedItems/:itemId", embyMarkPlayedHandler(svc, true))
|
||||
auth.DELETE("/Users/:userId/PlayedItems/:itemId", embyMarkPlayedHandler(svc, false))
|
||||
}
|
||||
|
||||
func registerEmbyAuthenticatedSystemRoutes(auth *gin.RouterGroup, svc *service.Container) {
|
||||
auth.GET("/Sessions", embySessionsHandler(svc))
|
||||
auth.GET("/System/Configuration", embyServerConfigurationHandler(svc))
|
||||
auth.GET("/System/WakeOnLanInfo", embyEmptyArrayHandler(svc))
|
||||
auth.GET("/ScheduledTasks", embyEmptyArrayHandler(svc))
|
||||
auth.GET("/LiveTv/Recordings", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/System/ActivityLog/Entries", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Web/ConfigurationPages", embyEmptyArrayHandler(svc))
|
||||
auth.POST("/Users/:userId/Configuration", embyNoContentHandler(svc))
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
func registerLowercaseEmbyAuthRoutes(auth *gin.RouterGroup, svc *service.Container) {
|
||||
registerLowercaseEmbyUserRoutes(auth, svc)
|
||||
registerLowercaseEmbyItemRoutes(auth, svc)
|
||||
registerLowercaseEmbyPlaybackRoutes(auth, svc)
|
||||
registerLowercaseEmbyProgressRoutes(auth, svc)
|
||||
registerLowercaseEmbyUserDataRoutes(auth, svc)
|
||||
registerLowercaseEmbySystemRoutes(auth, svc)
|
||||
}
|
||||
|
||||
func registerLowercaseEmbyUserRoutes(auth *gin.RouterGroup, svc *service.Container) {
|
||||
auth.GET("/users/me", embyMeHandler(svc))
|
||||
auth.GET("/users", embyListUsersHandler(svc))
|
||||
auth.GET("/users/:userId", embyGetUserByIDHandler(svc))
|
||||
auth.GET("/users/:userId/views", embyViewsHandler(svc))
|
||||
auth.GET("/library/mediafolders", embyViewsHandler(svc))
|
||||
auth.GET("/library/virtualfolders", embyVirtualFoldersHandler(svc))
|
||||
auth.GET("/library/selectablemediafolders", embyVirtualFoldersHandler(svc))
|
||||
}
|
||||
|
||||
func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Container) {
|
||||
auth.GET("/items", embyItemsHandler(svc))
|
||||
auth.GET("/users/:userId/items", embyItemsHandler(svc))
|
||||
auth.GET("/items/counts", embyItemsCountsHandler(svc))
|
||||
auth.GET("/users/:userId/items/counts", embyItemsCountsHandler(svc))
|
||||
auth.GET("/items/latest", embyLatestItemsHandler(svc))
|
||||
auth.GET("/items/resume", embyResumeItemsHandler(svc))
|
||||
auth.GET("/items/:id", embyItemByIDHandler(svc))
|
||||
auth.GET("/users/:userId/items/:id", embyUserItemByIDHandler(svc))
|
||||
auth.GET("/shows/:id/seasons", embyShowSeasonsHandler(svc))
|
||||
auth.GET("/shows/:id/episodes", embyShowEpisodesHandler(svc))
|
||||
auth.GET("/users/:userId/shows/:id/seasons", embyShowSeasonsHandler(svc))
|
||||
auth.GET("/users/:userId/shows/:id/episodes", embyShowEpisodesHandler(svc))
|
||||
auth.GET("/shows/nextup", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/users/:userId/shows/nextup", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/mediasegments/:id", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/artists", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/persons", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/genres", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/shows/upcoming", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/users/:userId/shows/upcoming", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/items/:id/similar", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/items/:id/thumbnailset", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/items/:id/thememedia", embyThemeMediaHandler(svc))
|
||||
auth.GET("/users/:userId/items/:id/specialfeatures", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/users/:userId/items/:id/intros", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/items/:id/specialfeatures", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/items/:id/intros", embyEmptyItemsHandler(svc))
|
||||
}
|
||||
|
||||
func registerLowercaseEmbyPlaybackRoutes(auth *gin.RouterGroup, svc *service.Container) {
|
||||
auth.GET("/items/:id/playbackinfo", embyPlaybackInfoHandler(svc))
|
||||
auth.POST("/items/:id/playbackinfo", embyPlaybackInfoHandler(svc))
|
||||
auth.GET("/users/:userId/items/:id/playbackinfo", embyPlaybackInfoHandler(svc))
|
||||
auth.POST("/users/:userId/items/:id/playbackinfo", embyPlaybackInfoHandler(svc))
|
||||
|
||||
registerEmbyVideoStreamRoutes(auth, svc, "/videos")
|
||||
auth.GET("/videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc))
|
||||
auth.HEAD("/videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc))
|
||||
auth.GET("/videos/:id/main.m3u8", embyVideoHLSPlaylistHandler(svc))
|
||||
auth.HEAD("/videos/:id/main.m3u8", embyVideoHLSPlaylistHandler(svc))
|
||||
auth.GET("/videos/:id/:seg", embyVideoHLSSegmentHandler(svc))
|
||||
}
|
||||
|
||||
func registerLowercaseEmbyProgressRoutes(auth *gin.RouterGroup, svc *service.Container) {
|
||||
auth.POST("/sessions/playing", embyPlayingProgressHandler(svc))
|
||||
auth.POST("/sessions/playing/progress", embyPlayingProgressHandler(svc))
|
||||
auth.POST("/sessions/playing/stopped", embyPlayingProgressHandler(svc))
|
||||
}
|
||||
|
||||
func registerLowercaseEmbyUserDataRoutes(auth *gin.RouterGroup, svc *service.Container) {
|
||||
auth.POST("/users/:userId/favoriteitems/:itemId", embyFavoriteHandler(svc, true))
|
||||
auth.DELETE("/users/:userId/favoriteitems/:itemId", embyFavoriteHandler(svc, false))
|
||||
auth.POST("/users/:userId/playeditems/:itemId", embyMarkPlayedHandler(svc, true))
|
||||
auth.DELETE("/users/:userId/playeditems/:itemId", embyMarkPlayedHandler(svc, false))
|
||||
}
|
||||
|
||||
func registerLowercaseEmbySystemRoutes(auth *gin.RouterGroup, svc *service.Container) {
|
||||
auth.GET("/sessions", embySessionsHandler(svc))
|
||||
auth.GET("/system/configuration", embyServerConfigurationHandler(svc))
|
||||
auth.GET("/system/wakeonlaninfo", embyEmptyArrayHandler(svc))
|
||||
auth.GET("/scheduledtasks", embyEmptyArrayHandler(svc))
|
||||
auth.GET("/livetv/recordings", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/system/activitylog/entries", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/web/configurationpages", embyEmptyArrayHandler(svc))
|
||||
auth.POST("/users/:userId/configuration", embyNoContentHandler(svc))
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
func embySessionsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc.Sessions == nil {
|
||||
c.JSON(http.StatusOK, []any{})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0)
|
||||
for _, sess := range svc.Sessions.List(c.Request.Context()) {
|
||||
last := sess.LastActivityAt
|
||||
itemID := sess.ItemID
|
||||
playState := gin.H{
|
||||
"PositionTicks": sess.PositionTicks,
|
||||
"IsPaused": sess.IsPaused,
|
||||
"PlayMethod": "DirectStream",
|
||||
"CanSeek": true,
|
||||
}
|
||||
row := gin.H{
|
||||
"Id": sess.ID,
|
||||
"ServerId": "mediastation-go-001",
|
||||
"Client": sess.Client,
|
||||
"DeviceId": sess.DeviceID,
|
||||
"DeviceName": sess.DeviceName,
|
||||
"UserId": sess.UserID,
|
||||
"UserName": sess.UserName,
|
||||
"LastActivityDate": last,
|
||||
"RemoteEndPoint": sess.RemoteEndPoint,
|
||||
"PlayState": playState,
|
||||
"SupportsRemoteControl": true,
|
||||
}
|
||||
if itemID != "" && sess.IsPlaying {
|
||||
row["NowPlayingItem"] = gin.H{"Id": itemID}
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
func embySessionLogoutHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc.Sessions != nil {
|
||||
uid := embyUserID(c)
|
||||
clientInfo := embyClientInfoFromRequest(c)
|
||||
svc.Sessions.Logout(c.Request.Context(), uid, clientInfo.DeviceID, c.ClientIP())
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
func TestEmbySessionsReturnsRealtimeSession(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
tracker := service.NewSessionTrackerService(zap.NewNop())
|
||||
tracker.RecordPlayback(t.Context(), "user-1", "viewer", "dev-1", "Apple TV", "Yamby", "10.0.0.8", "media-1", 1000, 2000, false)
|
||||
svc := &service.Container{Sessions: tracker}
|
||||
router := gin.New()
|
||||
router.GET("/Sessions", embySessionsHandler(svc))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/Sessions", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := w.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("cache-control = %q, want no-store", got)
|
||||
}
|
||||
var rows []map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("sessions = %d, want 1: %s", len(rows), w.Body.String())
|
||||
}
|
||||
if rows[0]["UserId"] != "user-1" || rows[0]["DeviceId"] != "dev-1" || rows[0]["Client"] != "Yamby" {
|
||||
t.Fatalf("session payload = %#v", rows[0])
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339Nano, rows[0]["LastActivityDate"].(string)); err != nil {
|
||||
t.Fatalf("LastActivityDate should be RFC3339 time, got %#v", rows[0]["LastActivityDate"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
func embyNoContentHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func embyWebSocketHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !websocket.IsWebSocketUpgrade(c.Request) {
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
if _, _, err := conn.NextReader(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func embyServerConfigurationHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"EnableFolderView": true,
|
||||
"EnableGroupingIntoCollections": true,
|
||||
"EnableExternalContentInSuggestions": false,
|
||||
"ImageSavingConvention": "Compatible",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func embyPublicServerConfigurationHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"IsStartupWizardCompleted": true,
|
||||
"EnableRemoteAccess": true,
|
||||
"EnableUPnP": false,
|
||||
"EnableHttps": false,
|
||||
"RequireHttps": false,
|
||||
"LocalNetworkSubnets": []string{},
|
||||
"LocalNetworkAddresses": []string{},
|
||||
"RemoteClientBitrateLimit": 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func embyStartupConfigurationHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"IsStartupWizardCompleted": true,
|
||||
"StartupWizardCompleted": true,
|
||||
"EnableRemoteAccess": true,
|
||||
"UICulture": "zh-CN",
|
||||
"MetadataCountryCode": "CN",
|
||||
"PreferredMetadataLanguage": "zh-CN",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func embyQuickConnectEnabledHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, false)
|
||||
}
|
||||
}
|
||||
|
||||
func embyEmptyItemsHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"Items": []any{}, "TotalRecordCount": 0})
|
||||
}
|
||||
}
|
||||
|
||||
func embyEmptyArrayHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, []any{})
|
||||
}
|
||||
}
|
||||
|
||||
func embyCustomCSSJSScriptsHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "application/javascript; charset=utf-8", nil)
|
||||
}
|
||||
}
|
||||
|
||||
func embyLocalizationCulturesHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, []gin.H{
|
||||
{
|
||||
"DisplayName": "简体中文",
|
||||
"Name": "zh-CN",
|
||||
"ThreeLetterISOLanguageName": "zho",
|
||||
"TwoLetterISOLanguageName": "zh",
|
||||
"ThreeLetterISOLanguageNames": []string{"zho", "chi"},
|
||||
"IsRightToLeft": false,
|
||||
},
|
||||
{
|
||||
"DisplayName": "English",
|
||||
"Name": "en-US",
|
||||
"ThreeLetterISOLanguageName": "eng",
|
||||
"TwoLetterISOLanguageName": "en",
|
||||
"ThreeLetterISOLanguageNames": []string{"eng"},
|
||||
"IsRightToLeft": false,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func embyThemeMediaHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
empty := gin.H{"Items": []any{}, "TotalRecordCount": 0}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ThemeVideosResult": empty,
|
||||
"ThemeSongsResult": empty,
|
||||
"SoundtrackSongsResult": empty,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func embyServerDomainsHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, []any{})
|
||||
}
|
||||
}
|
||||
|
||||
func embyDanmuRawHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "text/plain; charset=utf-8", nil)
|
||||
}
|
||||
}
|
||||
|
||||
func embyBrandingConfigHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"LoginDisclaimer": "",
|
||||
"CustomCss": "",
|
||||
"SplashscreenEnabled": false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func embyBrandingCSSHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "text/css; charset=utf-8", []byte(""))
|
||||
}
|
||||
}
|
||||
|
||||
func embyLocalizationOptionsHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, []map[string]any{
|
||||
{"Name": "简体中文", "Value": "zh-CN"},
|
||||
{"Name": "English", "Value": "en-US"},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
func embySystemInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, embyWithRequestAddress(c, svc.Emby.SystemInfo()))
|
||||
}
|
||||
}
|
||||
|
||||
func embySystemInfoPublicHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, embyWithRequestAddress(c, svc.Emby.SystemInfoPublic()))
|
||||
}
|
||||
}
|
||||
|
||||
func embyRequestBaseURL(c *gin.Context) string {
|
||||
proto := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto"))
|
||||
if proto == "" {
|
||||
if c.Request != nil && c.Request.TLS != nil {
|
||||
proto = "https"
|
||||
} else {
|
||||
proto = "http"
|
||||
}
|
||||
}
|
||||
if comma := strings.Index(proto, ","); comma >= 0 {
|
||||
proto = strings.TrimSpace(proto[:comma])
|
||||
}
|
||||
|
||||
host := strings.TrimSpace(c.GetHeader("X-Forwarded-Host"))
|
||||
if host == "" && c.Request != nil {
|
||||
host = strings.TrimSpace(c.Request.Host)
|
||||
}
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(proto+"://"+host, "/")
|
||||
}
|
||||
|
||||
func embyWithRequestAddress(c *gin.Context, payload map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(payload)+2)
|
||||
for key, value := range payload {
|
||||
out[key] = value
|
||||
}
|
||||
if address := embyRequestBaseURL(c); address != "" {
|
||||
out["LocalAddress"] = address
|
||||
out["WanAddress"] = address
|
||||
out["PublishedServerUrl"] = address
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func embySystemEndpointHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"IsLocal": true,
|
||||
"IsInNetwork": true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func embyPingHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Emby/Jellyfin 期望 plain text "Emby Server"
|
||||
c.String(http.StatusOK, "Emby Server")
|
||||
}
|
||||
}
|
||||
|
||||
func embyRootHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, embyPublicSystemInfoPayload(c, svc))
|
||||
}
|
||||
}
|
||||
|
||||
func embyPublicSystemInfoPayload(c *gin.Context, svc *service.Container) map[string]any {
|
||||
if svc != nil && svc.Emby != nil {
|
||||
return embyWithRequestAddress(c, svc.Emby.SystemInfoPublic())
|
||||
}
|
||||
return embyWithRequestAddress(c, map[string]any{
|
||||
"Id": "mediastation-go-001",
|
||||
"ServerId": "mediastation-go-001",
|
||||
"ServerName": "MediaStationGo",
|
||||
"Version": "4.8.10.0",
|
||||
"ServerVersion": "4.8.10.0",
|
||||
"ProductName": "Emby Server",
|
||||
"OperatingSystem": "Windows",
|
||||
"SupportsHttps": false,
|
||||
"SupportsAutoDiscovery": true,
|
||||
"StartupWizardCompleted": true,
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,174 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
// ─── Users / Auth ────────────────────────────────────────────────────────────
|
||||
|
||||
// embyAuthByNameHandler 处理 POST /Users/AuthenticateByName。
|
||||
//
|
||||
// 这是 Emby 客户端登录的唯一入口(Infuse / Yamby / Hills 等都走这里)。
|
||||
// 用户名+密码 → 调用我们已有的 AuthService.Login → 返回 AccessToken + User。
|
||||
func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
req, err := parseEmbyAuthByNameReq(c)
|
||||
if err != nil {
|
||||
embyError(c, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
password := req.Pw
|
||||
if password == "" {
|
||||
password = req.Password
|
||||
}
|
||||
if strings.TrimSpace(req.Username) == "" || password == "" {
|
||||
if req.PasswordMd5 != "" || req.PasswordSha1 != "" {
|
||||
embyError(c, http.StatusBadRequest, "plain password required")
|
||||
return
|
||||
}
|
||||
embyError(c, http.StatusBadRequest, "missing username or password")
|
||||
return
|
||||
}
|
||||
resp, err := svc.Auth.Login(c.Request.Context(), req.Username, password)
|
||||
if err != nil {
|
||||
embyError(c, http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
// 记录登录设备会话并执行防共享检测(登录客户端数 / 设备指纹)。
|
||||
clientInfo := embyClientInfoFromRequest(c)
|
||||
if svc.Sessions != nil {
|
||||
svc.Sessions.RecordLogin(c.Request.Context(), resp.User.ID, resp.User.Username,
|
||||
clientInfo.DeviceID,
|
||||
clientInfo.DeviceName,
|
||||
clientInfo.Client,
|
||||
c.ClientIP())
|
||||
}
|
||||
if svc.Device != nil {
|
||||
svc.Device.RecordLogin(c.Request.Context(), resp.User.ID,
|
||||
clientInfo.DeviceID,
|
||||
clientInfo.DeviceName,
|
||||
clientInfo.Client,
|
||||
c.ClientIP())
|
||||
}
|
||||
userPayload, _ := svc.Emby.FindUser(c.Request.Context(), resp.User.ID)
|
||||
// Emby/Jellyfin 客户端没有 refresh token 机制:它们把这里返回的
|
||||
// AccessToken 长期保存并反复使用。若返回 60 分钟的普通 access
|
||||
// token,客户端每小时就会掉登录、无法播放、媒体库无法刷新。因此
|
||||
// 签发长期令牌(IssueEmbyToken)匹配 Emby 持久化令牌语义。
|
||||
accessToken := resp.Tokens.AccessToken
|
||||
if longLived, err := svc.Auth.IssueEmbyToken(resp.User); err == nil && longLived != "" {
|
||||
accessToken = longLived
|
||||
}
|
||||
embyRememberCompatSession(c, accessToken)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"AccessToken": accessToken,
|
||||
"ServerId": "mediastation-go-001",
|
||||
"User": userPayload,
|
||||
"SessionInfo": gin.H{
|
||||
"Id": resp.User.ID,
|
||||
"UserId": resp.User.ID,
|
||||
"UserName": resp.User.Username,
|
||||
"Client": clientInfo.Client,
|
||||
"DeviceId": clientInfo.DeviceID,
|
||||
"DeviceName": clientInfo.DeviceName,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func embyPublicUsersHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 公开用户列表(Emby Web 客户端登录页拉这个,列出可见用户)。
|
||||
users, err := svc.Emby.ListUsers(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, []any{})
|
||||
return
|
||||
}
|
||||
// 公开版本只暴露 Id + Name,不包含 Policy。
|
||||
out := make([]map[string]any, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, map[string]any{
|
||||
"Id": u["Id"],
|
||||
"Name": u["Name"],
|
||||
"ServerId": u["ServerId"],
|
||||
"HasPassword": true,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
func embyListUsersHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
users, err := svc.Emby.ListUsers(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, users)
|
||||
}
|
||||
}
|
||||
|
||||
func embyMeHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := embyUserID(c)
|
||||
if uid == "" {
|
||||
embyError(c, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
u, err := svc.Emby.FindUser(c.Request.Context(), uid)
|
||||
if err != nil || u == nil {
|
||||
embyError(c, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, u)
|
||||
}
|
||||
}
|
||||
|
||||
func embyGetUserByIDHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
u, err := svc.Emby.FindUser(c.Request.Context(), c.Param("userId"))
|
||||
if err == nil && u != nil {
|
||||
c.JSON(http.StatusOK, u)
|
||||
return
|
||||
}
|
||||
if authUID := embyUserID(c); authUID != "" && authUID != c.Param("userId") {
|
||||
u, err = svc.Emby.FindUser(c.Request.Context(), authUID)
|
||||
if err == nil && u != nil {
|
||||
c.JSON(http.StatusOK, u)
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, embyFallbackUser(c.Param("userId")))
|
||||
}
|
||||
}
|
||||
|
||||
func embyFallbackUser(id string) gin.H {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
id = "mediastation-user"
|
||||
}
|
||||
return gin.H{
|
||||
"Id": id,
|
||||
"Name": "MediaStationGo",
|
||||
"ServerId": "mediastation-go-001",
|
||||
"HasPassword": true,
|
||||
"HasConfiguredPassword": true,
|
||||
"HasConfiguredEasyPassword": false,
|
||||
"EnableAutoLogin": false,
|
||||
"Policy": gin.H{
|
||||
"IsAdministrator": true,
|
||||
"EnableContentDeletion": true,
|
||||
"EnableRemoteControlOfOtherUsers": true,
|
||||
"EnableSharedDeviceControl": true,
|
||||
"EnableRemoteAccess": true,
|
||||
"EnableAllDevices": true,
|
||||
"EnableAllChannels": true,
|
||||
"EnableAllFolders": true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
out, err := svc.Emby.Views(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
embyAttachRequestTokenToMediaSources(c, out)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
func embyVirtualFoldersHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
libs, err := svc.Repo.Library.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
libs = service.FilterDisplayCloudLibraries(c.Request.Context(), svc.Repo, libs)
|
||||
uid := embyUserID(c)
|
||||
visibility := service.UserDefaultMediaVisibility(c.Request.Context(), svc.Repo, uid)
|
||||
out := make([]gin.H, 0, len(libs))
|
||||
for _, lib := range libs {
|
||||
if !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, lib, visibility) {
|
||||
continue
|
||||
}
|
||||
collectionType := "movies"
|
||||
switch lib.Type {
|
||||
case "tv", "anime", "variety":
|
||||
collectionType = "tvshows"
|
||||
case "music":
|
||||
collectionType = "music"
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"Name": lib.Name,
|
||||
"Locations": []string{lib.Path},
|
||||
"CollectionType": collectionType,
|
||||
"ItemId": lib.ID,
|
||||
"Id": lib.ID,
|
||||
"PrimaryImageItemId": lib.ID,
|
||||
"RefreshStatus": "Idle",
|
||||
"LibraryOptions": gin.H{},
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,20 @@ import (
|
||||
)
|
||||
|
||||
type manualScrapeApplyReq struct {
|
||||
MediaIDs []string `json:"media_ids"`
|
||||
Match service.ManualScrapeRequest `json:"match"`
|
||||
MediaIDs []string `json:"media_ids"`
|
||||
Match service.ManualScrapeRequest `json:"match"`
|
||||
EpisodeArtwork *bool `json:"episode_artwork"`
|
||||
EpisodeImages *bool `json:"episode_images"`
|
||||
}
|
||||
|
||||
func (r manualScrapeApplyReq) episodeArtworkOption() *bool {
|
||||
if r.EpisodeImages != nil {
|
||||
return r.EpisodeImages
|
||||
}
|
||||
if r.EpisodeArtwork != nil {
|
||||
return r.EpisodeArtwork
|
||||
}
|
||||
return r.Match.EpisodeArtworkOption()
|
||||
}
|
||||
|
||||
const manualScrapeApplyTimeout = 5 * time.Minute
|
||||
@@ -72,10 +84,11 @@ func manualScrapeApplyBatchHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
applyCtx, cancel := manualScrapeApplyContext(c)
|
||||
defer cancel()
|
||||
options := service.ScrapeOptions{EpisodeArtwork: req.episodeArtworkOption()}
|
||||
applied := 0
|
||||
errorsOut := make([]string, 0)
|
||||
for _, id := range ids {
|
||||
if _, err := svc.Scraper.ApplyManualMatch(applyCtx, id, req.Match); err != nil {
|
||||
if _, err := svc.Scraper.ApplyManualMatchWithOptions(applyCtx, id, req.Match, options); err != nil {
|
||||
errorsOut = append(errorsOut, id+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
libs = service.FilterDeprecatedNativeCloudLibraries(libs)
|
||||
role, _ := c.Get(middleware.CtxUserRole)
|
||||
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("all") == "1")
|
||||
if !includeHidden {
|
||||
@@ -109,7 +110,7 @@ func scanLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
"estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度",
|
||||
})
|
||||
}
|
||||
_, _, _ = svc.Scan.StartCloudLibraryScan(id, false)
|
||||
_, _, _ = svc.Scan.StartCloudLibraryScan(id, true)
|
||||
finishHTTPTask(task, nil, "queued", "云盘扫描已加入后台队列", map[string]int64{"queued": 1}, nil)
|
||||
c.JSON(http.StatusAccepted, gin.H{
|
||||
"library_id": id,
|
||||
@@ -119,7 +120,7 @@ func scanLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
"probed": 0,
|
||||
"queued": true,
|
||||
"cloud": true,
|
||||
"message": "云盘扫描已在后台运行,发现的媒体会自动加入当前媒体库",
|
||||
"message": "云盘扫描已在后台运行,发现的媒体会自动加入当前媒体库;若已开启自动刮削,会在扫描后补齐元数据",
|
||||
"estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度",
|
||||
})
|
||||
return
|
||||
|
||||
@@ -80,16 +80,22 @@ func listFavoritesAliasHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// path; the AI hint comes from svc.AI when configured.
|
||||
func aiScrapeMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
options, err := scrapeOptionsFromRequest(c, false)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid scrape options"})
|
||||
return
|
||||
}
|
||||
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || m == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
|
||||
return
|
||||
}
|
||||
if err := svc.Scraper.EnrichOne(c.Request.Context(), m); err != nil {
|
||||
if err := svc.Scraper.EnrichOneWithOptions(c.Request.Context(), m, options); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, m)
|
||||
refreshed, _ := svc.Repo.Media.FindByID(c.Request.Context(), m.ID)
|
||||
c.JSON(http.StatusOK, refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -154,6 +158,82 @@ func TestListMediaGroupsMultipleVersionsByDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListLibrarySeriesDoesNotTruncateLargeEpisodeLibraries(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Media{}, &model.Setting{}, &model.PlayProfile{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "国漫", Path: "cloud://openlist/国漫", Type: "anime", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := make([]model.Media, 0, 2001)
|
||||
for i := 1; i <= 2001; i++ {
|
||||
rows = append(rows, model.Media{
|
||||
Base: model.Base{ID: fmt.Sprintf("ep-%04d", i), CreatedAt: time.Now().Add(time.Duration(i) * time.Second)},
|
||||
LibraryID: lib.ID,
|
||||
Title: "大剧",
|
||||
Path: fmt.Sprintf("cloud://openlist/国漫/大剧 (2026) {tmdb-123}/Season 1/大剧.S01E%04d.mkv", i),
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: i,
|
||||
})
|
||||
}
|
||||
if err := repos.DB.CreateInBatches(rows, 500).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
|
||||
}
|
||||
|
||||
series := requestLibrarySeries(t, svc, "/api/libraries/"+lib.ID+"/series", lib.ID)
|
||||
if series.Total != 1 || len(series.Items) != 1 {
|
||||
t.Fatalf("series response total=%d len=%d body=%#v", series.Total, len(series.Items), series)
|
||||
}
|
||||
if series.Items[0].Count != 2001 {
|
||||
t.Fatalf("series count = %d, want 2001", series.Items[0].Count)
|
||||
}
|
||||
if !strings.HasPrefix(series.Items[0].Key, "series:") ||
|
||||
strings.Contains(series.Items[0].Key, "lib:") ||
|
||||
strings.Contains(series.Items[0].Key, "show:") {
|
||||
t.Fatalf("series key = %q, want compact non-raw key", series.Items[0].Key)
|
||||
}
|
||||
episodes := requestLibrarySeriesEpisodes(t, svc, "/api/libraries/"+lib.ID+"/series/episodes?key="+url.QueryEscape(series.Items[0].Key), lib.ID)
|
||||
if episodes.Total != 2001 || len(episodes.Items) != 2001 {
|
||||
t.Fatalf("episodes total=%d len=%d, want 2001", episodes.Total, len(episodes.Items))
|
||||
}
|
||||
if episodes.Items[0].EpisodeNum != 1 || episodes.Items[len(episodes.Items)-1].EpisodeNum != 2001 {
|
||||
t.Fatalf("episode order first=%d last=%d", episodes.Items[0].EpisodeNum, episodes.Items[len(episodes.Items)-1].EpisodeNum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrapeOptionsFromRequestPreservesEpisodeImagesFalse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/media/ep-1/scrape", bytes.NewBufferString(`{"episode_images":false,"refresh_matched":true}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
options, err := scrapeOptionsFromRequest(c, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if options.EpisodeArtwork == nil {
|
||||
t.Fatal("EpisodeArtwork is nil, want explicit false")
|
||||
}
|
||||
if *options.EpisodeArtwork {
|
||||
t.Fatal("EpisodeArtwork = true, want false")
|
||||
}
|
||||
if !options.IncludeMatched {
|
||||
t.Fatal("IncludeMatched = false, want true from refresh_matched")
|
||||
}
|
||||
}
|
||||
|
||||
func requestLibraries(t *testing.T, svc *service.Container, userID, role, path string) []model.Library {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
@@ -177,6 +257,16 @@ type mediaListResponse struct {
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type seriesListResponse struct {
|
||||
Items []service.SeriesCard `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type seriesEpisodesResponse struct {
|
||||
Items []model.Media `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
func requestMediaList(t *testing.T, svc *service.Container, path, libraryID string) mediaListResponse {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
@@ -195,3 +285,41 @@ func requestMediaList(t *testing.T, svc *service.Container, path, libraryID stri
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func requestLibrarySeries(t *testing.T, svc *service.Container, path, libraryID string) seriesListResponse {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set(middleware.CtxUserID, "user-1")
|
||||
c.Set(middleware.CtxUserRole, "user")
|
||||
c.Params = gin.Params{{Key: "id", Value: libraryID}}
|
||||
c.Request = httptest.NewRequest(http.MethodGet, path, nil)
|
||||
listLibrarySeriesHandler(svc)(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s status = %d body=%s", path, w.Code, w.Body.String())
|
||||
}
|
||||
var payload seriesListResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode series list: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func requestLibrarySeriesEpisodes(t *testing.T, svc *service.Container, path, libraryID string) seriesEpisodesResponse {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set(middleware.CtxUserID, "user-1")
|
||||
c.Set(middleware.CtxUserRole, "user")
|
||||
c.Params = gin.Params{{Key: "id", Value: libraryID}}
|
||||
c.Request = httptest.NewRequest(http.MethodGet, path, nil)
|
||||
listLibrarySeriesEpisodesHandler(svc)(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s status = %d body=%s", path, w.Code, w.Body.String())
|
||||
}
|
||||
var payload seriesEpisodesResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode series episodes: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ func externalPlayersHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
token := externalPlaybackToken(c, svc, m.ID, m.DurationSec)
|
||||
streamURL := absoluteRequestURL(c, "/api/stream/"+m.ID+"?token="+url.QueryEscape(token)+externalProfileQuery(c))
|
||||
streamURL := externalPlaybackURL(c, svc, "/api/stream/"+m.ID+"?token="+url.QueryEscape(token)+externalProfileQuery(c))
|
||||
escapedStream := url.QueryEscape(streamURL)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"url": streamURL,
|
||||
@@ -100,7 +100,7 @@ func externalURLHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
token := externalPlaybackToken(c, svc, m.ID, m.DurationSec)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"url": absoluteRequestURL(c, "/api/stream/"+m.ID+"?token="+url.QueryEscape(token)+externalProfileQuery(c)),
|
||||
"url": externalPlaybackURL(c, svc, "/api/stream/"+m.ID+"?token="+url.QueryEscape(token)+externalProfileQuery(c)),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,71 @@ func externalPlaybackToken(c *gin.Context, svc *service.Container, mediaID strin
|
||||
return token
|
||||
}
|
||||
|
||||
func externalPlaybackURL(c *gin.Context, svc *service.Container, path string) string {
|
||||
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||
return path
|
||||
}
|
||||
headerOrigin := sanitizedPublicOrigin(c.GetHeader("X-MediaStation-Public-Origin"))
|
||||
if headerOrigin != "" && !isLocalPublicOrigin(headerOrigin) {
|
||||
return joinOriginPath(headerOrigin, path)
|
||||
}
|
||||
if svc != nil {
|
||||
if origin := sanitizedPublicOrigin(service.PublicServerURL(c.Request.Context(), svc.Repo, svc.Cfg)); origin != "" {
|
||||
return joinOriginPath(origin, path)
|
||||
}
|
||||
}
|
||||
if headerOrigin != "" {
|
||||
return joinOriginPath(headerOrigin, path)
|
||||
}
|
||||
return absoluteRequestURL(c, path)
|
||||
}
|
||||
|
||||
func isLocalPublicOrigin(origin string) bool {
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u == nil {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(strings.Trim(u.Hostname(), "[]"))
|
||||
switch host {
|
||||
case "localhost", "127.0.0.1", "::1":
|
||||
return true
|
||||
default:
|
||||
return strings.HasPrefix(host, "127.")
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizedPublicOrigin(raw string) string {
|
||||
raw = strings.TrimSpace(strings.Split(raw, ",")[0])
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u == nil {
|
||||
return ""
|
||||
}
|
||||
scheme := strings.ToLower(strings.TrimSpace(u.Scheme))
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return ""
|
||||
}
|
||||
if strings.TrimSpace(u.Host) == "" {
|
||||
return ""
|
||||
}
|
||||
u.Scheme = scheme
|
||||
u.User = nil
|
||||
u.Path = ""
|
||||
u.RawPath = ""
|
||||
u.RawQuery = ""
|
||||
u.Fragment = ""
|
||||
return strings.TrimRight(u.String(), "/")
|
||||
}
|
||||
|
||||
func joinOriginPath(origin, path string) string {
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
return strings.TrimRight(origin, "/") + path
|
||||
}
|
||||
|
||||
func absoluteRequestURL(c *gin.Context, path string) string {
|
||||
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||
return path
|
||||
|
||||
@@ -84,6 +84,137 @@ func TestExternalURLUsesMediaScopedPlaybackToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalURLPrefersBrowserPublicOriginOverForwardedSource(t *testing.T) {
|
||||
router, _, secret := newPlaybackScopeTestRouter(t)
|
||||
loginToken := signedTestToken(t, secret)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://origin.internal/api/playback/media-1/external-url", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+loginToken)
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
req.Header.Set("X-Forwarded-Host", "media.v6.agonyz.dpdns.org")
|
||||
req.Header.Set("X-MediaStation-Public-Origin", "https://media.agonyz.dpdns.org")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
streamURL, err := url.Parse(payload.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse stream url: %v", err)
|
||||
}
|
||||
if got, want := streamURL.Scheme+"://"+streamURL.Host, "https://media.agonyz.dpdns.org"; got != want {
|
||||
t.Fatalf("external url origin = %q, want %q; full url=%s", got, want, payload.URL)
|
||||
}
|
||||
if strings.Contains(payload.URL, "media.v6.agonyz.dpdns.org") {
|
||||
t.Fatalf("external url should not use forwarded source host: %s", payload.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalPlayersSanitizeBrowserPublicOrigin(t *testing.T) {
|
||||
router, _, secret := newPlaybackScopeTestRouter(t)
|
||||
loginToken := signedTestToken(t, secret)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://origin.internal/api/playback/media-1/external-players", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+loginToken)
|
||||
req.Header.Set("X-MediaStation-Public-Origin", "https://user:pass@media.agonyz.dpdns.org/sneaky/path?x=1#frag")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
URL string `json:"url"`
|
||||
Players []struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
} `json:"players"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(payload.URL, "https://media.agonyz.dpdns.org/api/stream/media-1?") {
|
||||
t.Fatalf("sanitized stream url = %q", payload.URL)
|
||||
}
|
||||
if strings.Contains(payload.URL, "user:pass") || strings.Contains(payload.URL, "sneaky") || strings.Contains(payload.URL, "x=1") || strings.Contains(payload.URL, "#frag") {
|
||||
t.Fatalf("stream url contains unsafe origin components: %s", payload.URL)
|
||||
}
|
||||
for _, player := range payload.Players {
|
||||
if !strings.Contains(player.URL, "media.agonyz.dpdns.org") {
|
||||
t.Fatalf("%s player url does not include sanitized public host: %s", player.Name, player.URL)
|
||||
}
|
||||
if strings.Contains(player.URL, "user:pass") || strings.Contains(player.URL, "sneaky") {
|
||||
t.Fatalf("%s player url contains unsafe origin components: %s", player.Name, player.URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalURLFallsBackToConfiguredPublicServerURL(t *testing.T) {
|
||||
router, svc, secret := newPlaybackScopeTestRouter(t)
|
||||
loginToken := signedTestToken(t, secret)
|
||||
if err := svc.Repo.Setting.Set(t.Context(), "app.server_url", "https://public.example.test"); err != nil {
|
||||
t.Fatalf("set public url: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://origin.internal/api/playback/media-1/external-url", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+loginToken)
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
req.Header.Set("X-Forwarded-Host", "source.example.test")
|
||||
req.Header.Set("X-MediaStation-Public-Origin", "javascript:alert(1)")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(payload.URL, "https://public.example.test/api/stream/media-1?") {
|
||||
t.Fatalf("external url = %q, want configured public origin", payload.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalURLPrefersConfiguredPublicServerURLOverLocalBrowserOrigin(t *testing.T) {
|
||||
router, svc, secret := newPlaybackScopeTestRouter(t)
|
||||
loginToken := signedTestToken(t, secret)
|
||||
if err := svc.Repo.Setting.Set(t.Context(), "app.server_url", "https://media.example.test"); err != nil {
|
||||
t.Fatalf("set public url: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:8080/api/playback/media-1/external-url", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+loginToken)
|
||||
req.Header.Set("X-MediaStation-Public-Origin", "http://127.0.0.1:8080")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(payload.URL, "https://media.example.test/api/stream/media-1?") {
|
||||
t.Fatalf("external url = %q, want configured public origin instead of localhost", payload.URL)
|
||||
}
|
||||
if strings.Contains(payload.URL, "127.0.0.1:8080") {
|
||||
t.Fatalf("external url should not keep local browser origin when public url is configured: %s", payload.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedPlaybackTokenCannotStreamAnotherMedia(t *testing.T) {
|
||||
router, svc, _ := newPlaybackScopeTestRouter(t)
|
||||
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
|
||||
@@ -248,6 +379,7 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
|
||||
api := router.Group("/api")
|
||||
api.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret))
|
||||
api.GET("/playback/:id/external-url", externalURLHandler(svc))
|
||||
api.GET("/playback/:id/external-players", externalPlayersHandler(svc))
|
||||
api.GET("/stream/:id", streamHandler(svc))
|
||||
api.GET("/cloud/play/:type", cloudPlayHandler(svc))
|
||||
return router, svc, cfg.Secrets.JWTSecret
|
||||
|
||||
@@ -57,6 +57,7 @@ func (h *RefreshHandler) RefreshToken(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
setAccessTokenCookie(c, tokens.AccessToken, int(tokens.ExpiresIn))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
@@ -72,6 +73,7 @@ func (h *RefreshHandler) RefreshToken(c *gin.Context) {
|
||||
// Logout 登出当前用户。
|
||||
// POST /api/auth/logout
|
||||
func (h *RefreshHandler) Logout(c *gin.Context) {
|
||||
clearAccessTokenCookie(c)
|
||||
userID := c.GetString("ctx_user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": nil})
|
||||
|
||||
@@ -17,14 +17,22 @@ import (
|
||||
// 异步执行, 立即返回 202;通过 WS hub "scrape" topic 推送进度。
|
||||
func repairAndRescrapeAllHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
options, err := scrapeOptionsFromRequest(c, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid scrape options"})
|
||||
return
|
||||
}
|
||||
task := startScrapeHTTPTask(svc, "全库修复并重刮", "", "")
|
||||
go func() {
|
||||
result, err := svc.RepairAndRescrapeAllLibraries(context.Background())
|
||||
go func(options service.ScrapeOptions) {
|
||||
result, err := svc.RepairAndRescrapeAllLibraries(context.Background(), options)
|
||||
metrics := map[string]int64{
|
||||
"repaired": int64(result.Repaired),
|
||||
"libraries": int64(result.Libraries),
|
||||
"matched": int64(result.Matched),
|
||||
"reset": int64(result.Reset),
|
||||
"repaired": int64(result.Repaired),
|
||||
"reclassified": int64(result.Reclassified),
|
||||
"libraries": int64(result.Libraries),
|
||||
"matched": int64(result.Matched),
|
||||
"processed": int64(result.Processed),
|
||||
"errors": int64(result.Errors),
|
||||
"reset": int64(result.Reset),
|
||||
}
|
||||
stage := "completed"
|
||||
message := "全库修复并重刮完成"
|
||||
@@ -33,7 +41,7 @@ func repairAndRescrapeAllHandler(svc *service.Container) gin.HandlerFunc {
|
||||
message = "全库修复并重刮失败"
|
||||
}
|
||||
finishHTTPTask(task, err, stage, message, metrics, nil)
|
||||
}()
|
||||
}(options)
|
||||
c.JSON(http.StatusAccepted, gin.H{"status": "started"})
|
||||
}
|
||||
}
|
||||
@@ -46,14 +54,22 @@ func repairAndRescrapeAllHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func repairAndRescrapeLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
libraryID := c.Param("id")
|
||||
options, err := scrapeOptionsFromRequest(c, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid scrape options"})
|
||||
return
|
||||
}
|
||||
task := startScrapeHTTPTask(svc, "媒体库修复并重刮", "", "")
|
||||
go func() {
|
||||
result, err := svc.RepairAndRescrapeLibrary(context.Background(), libraryID)
|
||||
go func(options service.ScrapeOptions) {
|
||||
result, err := svc.RepairAndRescrapeLibrary(context.Background(), libraryID, options)
|
||||
metrics := map[string]int64{
|
||||
"repaired": int64(result.Repaired),
|
||||
"libraries": int64(result.Libraries),
|
||||
"matched": int64(result.Matched),
|
||||
"reset": int64(result.Reset),
|
||||
"repaired": int64(result.Repaired),
|
||||
"reclassified": int64(result.Reclassified),
|
||||
"libraries": int64(result.Libraries),
|
||||
"matched": int64(result.Matched),
|
||||
"processed": int64(result.Processed),
|
||||
"errors": int64(result.Errors),
|
||||
"reset": int64(result.Reset),
|
||||
}
|
||||
stage := "completed"
|
||||
message := "媒体库修复并重刮完成"
|
||||
@@ -62,7 +78,7 @@ func repairAndRescrapeLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
message = "媒体库修复并重刮失败"
|
||||
}
|
||||
finishHTTPTask(task, err, stage, message, metrics, nil)
|
||||
}()
|
||||
}(options)
|
||||
c.JSON(http.StatusAccepted, gin.H{"status": "started"})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,98 +10,120 @@ import (
|
||||
)
|
||||
|
||||
func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.Container) {
|
||||
// Admin-only endpoints.
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret), middleware.AdminRequired())
|
||||
{
|
||||
admin.GET("/users", listUsersHandler(svc))
|
||||
admin.POST("/users", createUserHandler(svc))
|
||||
admin.PATCH("/users/:id", updateUserHandler(svc))
|
||||
admin.PATCH("/users/:id/password", resetUserPasswordHandler(svc))
|
||||
admin.PATCH("/users/:id/status", updateUserStatusHandler(svc))
|
||||
admin.PATCH("/users/:id/role", adminUpdateRoleHandler(svc))
|
||||
admin.DELETE("/users/:id", deleteUserHandler(svc))
|
||||
admin.GET("/settings", listSettingsHandler(svc))
|
||||
admin.PUT("/settings", updateSettingHandler(svc))
|
||||
admin.GET("/logs", recentLogsHandler(svc))
|
||||
|
||||
// Permissions admin.
|
||||
admin.GET("/users/:id/permissions", getUserPermissionsHandler(svc))
|
||||
admin.PUT("/users/:id/permissions", updateUserPermissionsHandler(svc))
|
||||
admin.POST("/users/:id/permissions/reset", resetUserPermissionsHandler(svc))
|
||||
|
||||
// Storage configs (Alist / S3 / WebDAV / 网盘).
|
||||
admin.GET("/storage/status", listStorageConfigsHandler(svc))
|
||||
admin.GET("/storage/:type", getStorageConfigHandler(svc))
|
||||
admin.PUT("/storage/:type", saveStorageConfigHandler(svc))
|
||||
admin.POST("/storage/:type/test", testStorageConfigHandler(svc))
|
||||
admin.POST("/storage/:type/logout", logoutStorageConfigHandler(svc))
|
||||
admin.POST("/storage/:type/upload-local", storageUploadLocalHandler(svc))
|
||||
|
||||
// Cloud disk (115 / 夸克) browsing, QR login and 302 import.
|
||||
admin.POST("/cloud/scan-all", cloudScanAllHandler(svc))
|
||||
admin.POST("/cloud/scan/cancel", cloudScanCancelHandler(svc))
|
||||
admin.GET("/cloud/scan/status", cloudScanStatusHandler(svc))
|
||||
admin.GET("/cloud/:type/list", cloudListHandler(svc))
|
||||
admin.POST("/cloud/:type/import", cloudImportHandler(svc))
|
||||
admin.POST("/cloud/:type/mount", cloudMountHandler(svc))
|
||||
admin.POST("/cloud/:type/qr/start", cloud115QRStartHandler(svc))
|
||||
admin.POST("/cloud/:type/qr/poll", cloud115QRPollHandler(svc))
|
||||
|
||||
// Download client CRUD.
|
||||
admin.GET("/download/clients", listDownloadClientsHandler(svc))
|
||||
admin.POST("/download/clients", createDownloadClientHandler(svc))
|
||||
admin.PUT("/download/clients/:id", updateDownloadClientHandler(svc))
|
||||
admin.DELETE("/download/clients/:id", deleteDownloadClientHandler(svc))
|
||||
admin.POST("/download/clients/:id/test", testDownloadClientHandler(svc))
|
||||
admin.GET("/download/aria2/stats", aria2StatsHandler(svc))
|
||||
|
||||
// System scheduler trigger alias.
|
||||
admin.POST("/system/scheduler/:name/trigger", schedulerTriggerHandler(svc))
|
||||
|
||||
// Database backup.
|
||||
admin.GET("/backups", listBackupsHandler(svc))
|
||||
admin.POST("/backups", createBackupHandler(svc))
|
||||
admin.DELETE("/backups", deleteBackupHandler(svc))
|
||||
admin.POST("/backups/restore", restoreBackupHandler(svc))
|
||||
|
||||
// Notifications (test endpoint).
|
||||
admin.POST("/notify/test", notifyTestHandler(svc))
|
||||
|
||||
// Notify channels CRUD + per-channel test.
|
||||
admin.GET("/notify/channels", listNotifyChannelsHandler(svc))
|
||||
admin.POST("/notify/channels", createNotifyChannelHandler(svc))
|
||||
admin.PUT("/notify/channels/:id", updateNotifyChannelHandler(svc))
|
||||
admin.DELETE("/notify/channels/:id", deleteNotifyChannelHandler(svc))
|
||||
admin.POST("/notify/channels/:id/test", testNotifyChannelHandler(svc))
|
||||
|
||||
// Telegram Bot webhook management.
|
||||
admin.GET("/telegram/webhook", telegramGetWebhookHandler(svc))
|
||||
admin.POST("/telegram/webhook", telegramSetWebhookHandler(svc))
|
||||
admin.POST("/telegram/polling/start", telegramStartPollingHandler(svc))
|
||||
admin.POST("/telegram/polling/stop", telegramStopPollingHandler(svc))
|
||||
|
||||
// File organizer.
|
||||
admin.POST("/media/:id/organize", organizeMediaHandler(svc))
|
||||
admin.POST("/libraries/:id/organize", organizeLibraryHandler(svc))
|
||||
admin.GET("/organize/sources", organizeSourcesHandler(svc))
|
||||
admin.POST("/organize/source", organizeDirectoryHandler(svc))
|
||||
|
||||
// 全库修复+重刮:从路径占位符回填缺失外部 ID,然后批量重刮整库。
|
||||
admin.POST("/media/repair-rescrape", repairAndRescrapeAllHandler(svc))
|
||||
// 单库修复+重刮:只对指定媒体库回填占位符外部 ID 并重刮。
|
||||
admin.POST("/libraries/:id/repair-rescrape", repairAndRescrapeLibraryHandler(svc))
|
||||
|
||||
// API key management (encrypted at rest).
|
||||
admin.GET("/api-configs", listAPIConfigsHandler(svc))
|
||||
admin.GET("/api-configs/:provider", getAPIConfigHandler(svc))
|
||||
admin.PUT("/api-configs/:provider", updateAPIConfigHandler(svc))
|
||||
admin.DELETE("/api-configs/:provider", deleteAPIConfigHandler(svc))
|
||||
|
||||
// Scheduled jobs.
|
||||
admin.GET("/scheduler", schedulerStatusHandler(svc))
|
||||
admin.POST("/scheduler/:name/run", schedulerRunHandler(svc))
|
||||
|
||||
}
|
||||
|
||||
registerAdminUserRoutes(admin, svc)
|
||||
registerAdminPermissionRoutes(admin, svc)
|
||||
registerAdminStorageRoutes(admin, svc)
|
||||
registerAdminCloudRoutes(admin, svc)
|
||||
registerAdminDownloadClientRoutes(admin, svc)
|
||||
registerAdminSystemRoutes(admin, svc)
|
||||
registerAdminBackupRoutes(admin, svc)
|
||||
registerAdminNotificationRoutes(admin, svc)
|
||||
registerAdminTelegramRoutes(admin, svc)
|
||||
registerAdminOrganizerRoutes(admin, svc)
|
||||
registerAdminRepairRoutes(admin, svc)
|
||||
registerAdminAPIConfigRoutes(admin, svc)
|
||||
registerAdminSchedulerRoutes(admin, svc)
|
||||
}
|
||||
|
||||
func registerAdminUserRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
admin.GET("/users", listUsersHandler(svc))
|
||||
admin.POST("/users", createUserHandler(svc))
|
||||
admin.PATCH("/users/:id", updateUserHandler(svc))
|
||||
admin.PATCH("/users/:id/password", resetUserPasswordHandler(svc))
|
||||
admin.PATCH("/users/:id/status", updateUserStatusHandler(svc))
|
||||
admin.PATCH("/users/:id/role", adminUpdateRoleHandler(svc))
|
||||
admin.DELETE("/users/:id", deleteUserHandler(svc))
|
||||
admin.GET("/settings", listSettingsHandler(svc))
|
||||
admin.PUT("/settings", updateSettingHandler(svc))
|
||||
admin.GET("/logs", recentLogsHandler(svc))
|
||||
}
|
||||
|
||||
func registerAdminPermissionRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
admin.GET("/users/:id/permissions", getUserPermissionsHandler(svc))
|
||||
admin.PUT("/users/:id/permissions", updateUserPermissionsHandler(svc))
|
||||
admin.POST("/users/:id/permissions/reset", resetUserPermissionsHandler(svc))
|
||||
}
|
||||
|
||||
func registerAdminStorageRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
admin.GET("/storage/status", listStorageConfigsHandler(svc))
|
||||
admin.GET("/storage/:type", getStorageConfigHandler(svc))
|
||||
admin.PUT("/storage/:type", saveStorageConfigHandler(svc))
|
||||
admin.POST("/storage/:type/test", testStorageConfigHandler(svc))
|
||||
admin.POST("/storage/:type/logout", logoutStorageConfigHandler(svc))
|
||||
admin.POST("/storage/:type/upload-local", storageUploadLocalHandler(svc))
|
||||
}
|
||||
|
||||
func registerAdminCloudRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
admin.POST("/cloud/scan-all", cloudScanAllHandler(svc))
|
||||
admin.POST("/cloud/scan/cancel", cloudScanCancelHandler(svc))
|
||||
admin.GET("/cloud/scan/status", cloudScanStatusHandler(svc))
|
||||
admin.GET("/cloud/:type/list", cloudListHandler(svc))
|
||||
admin.POST("/cloud/:type/mkdir", cloudMkdirHandler(svc))
|
||||
admin.PUT("/cloud/:type/rename", cloudRenameHandler(svc))
|
||||
admin.POST("/cloud/:type/import", cloudImportHandler(svc))
|
||||
admin.POST("/cloud/:type/mount", cloudMountHandler(svc))
|
||||
admin.POST("/cloud/:type/qr/start", cloud115QRStartHandler(svc))
|
||||
admin.POST("/cloud/:type/qr/poll", cloud115QRPollHandler(svc))
|
||||
}
|
||||
|
||||
func registerAdminDownloadClientRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
admin.GET("/download/clients", listDownloadClientsHandler(svc))
|
||||
admin.POST("/download/clients", createDownloadClientHandler(svc))
|
||||
admin.PUT("/download/clients/:id", updateDownloadClientHandler(svc))
|
||||
admin.DELETE("/download/clients/:id", deleteDownloadClientHandler(svc))
|
||||
admin.POST("/download/clients/:id/test", testDownloadClientHandler(svc))
|
||||
admin.GET("/download/aria2/stats", aria2StatsHandler(svc))
|
||||
}
|
||||
|
||||
func registerAdminSystemRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
admin.POST("/system/scheduler/:name/trigger", schedulerTriggerHandler(svc))
|
||||
}
|
||||
|
||||
func registerAdminBackupRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
admin.GET("/backups", listBackupsHandler(svc))
|
||||
admin.POST("/backups", createBackupHandler(svc))
|
||||
admin.DELETE("/backups", deleteBackupHandler(svc))
|
||||
admin.POST("/backups/restore", restoreBackupHandler(svc))
|
||||
}
|
||||
|
||||
func registerAdminNotificationRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
admin.POST("/notify/test", notifyTestHandler(svc))
|
||||
admin.GET("/notify/channels", listNotifyChannelsHandler(svc))
|
||||
admin.POST("/notify/channels", createNotifyChannelHandler(svc))
|
||||
admin.PUT("/notify/channels/:id", updateNotifyChannelHandler(svc))
|
||||
admin.DELETE("/notify/channels/:id", deleteNotifyChannelHandler(svc))
|
||||
admin.POST("/notify/channels/:id/test", testNotifyChannelHandler(svc))
|
||||
}
|
||||
|
||||
func registerAdminTelegramRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
admin.GET("/telegram/webhook", telegramGetWebhookHandler(svc))
|
||||
admin.POST("/telegram/webhook", telegramSetWebhookHandler(svc))
|
||||
admin.POST("/telegram/polling/start", telegramStartPollingHandler(svc))
|
||||
admin.POST("/telegram/polling/stop", telegramStopPollingHandler(svc))
|
||||
}
|
||||
|
||||
func registerAdminOrganizerRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
admin.POST("/media/:id/organize", organizeMediaHandler(svc))
|
||||
admin.POST("/libraries/:id/organize", organizeLibraryHandler(svc))
|
||||
admin.GET("/organize/sources", organizeSourcesHandler(svc))
|
||||
admin.POST("/organize/source", organizeDirectoryHandler(svc))
|
||||
}
|
||||
|
||||
func registerAdminRepairRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
admin.POST("/media/repair-rescrape", repairAndRescrapeAllHandler(svc))
|
||||
admin.POST("/libraries/:id/repair-rescrape", repairAndRescrapeLibraryHandler(svc))
|
||||
}
|
||||
|
||||
func registerAdminAPIConfigRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
admin.GET("/api-configs", listAPIConfigsHandler(svc))
|
||||
admin.GET("/api-configs/:provider", getAPIConfigHandler(svc))
|
||||
admin.PUT("/api-configs/:provider", updateAPIConfigHandler(svc))
|
||||
admin.DELETE("/api-configs/:provider", deleteAPIConfigHandler(svc))
|
||||
}
|
||||
|
||||
func registerAdminSchedulerRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
admin.GET("/scheduler", schedulerStatusHandler(svc))
|
||||
admin.POST("/scheduler/:name/run", schedulerRunHandler(svc))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
func TestAdminRouteSurfacesAreRegistered(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
router := gin.New()
|
||||
Register(router, &config.Config{
|
||||
Secrets: config.SecretsConfig{JWTSecret: "test-secret"},
|
||||
}, zap.NewNop(), &service.Container{Log: zap.NewNop()})
|
||||
|
||||
routes := map[string]bool{}
|
||||
for _, route := range router.Routes() {
|
||||
routes[route.Method+" "+route.Path] = true
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
"GET /api/admin/users",
|
||||
"GET /api/admin/users/:id/permissions",
|
||||
"GET /api/admin/storage/status",
|
||||
"GET /api/admin/cloud/:type/list",
|
||||
"GET /api/admin/download/clients",
|
||||
"POST /api/admin/system/scheduler/:name/trigger",
|
||||
"POST /api/admin/backups",
|
||||
"GET /api/admin/notify/channels",
|
||||
"GET /api/admin/telegram/webhook",
|
||||
"GET /api/admin/organize/sources",
|
||||
"POST /api/admin/media/repair-rescrape",
|
||||
"GET /api/admin/api-configs",
|
||||
"POST /api/admin/scheduler/:name/run",
|
||||
} {
|
||||
if !routes[want] {
|
||||
t.Fatalf("%s route is not registered", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,261 +10,35 @@ import (
|
||||
)
|
||||
|
||||
func registerAuthenticatedRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.Container) {
|
||||
// Authenticated endpoints.
|
||||
authed := api.Group("/")
|
||||
authed.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret))
|
||||
authed.Use(activeUserRequired(svc))
|
||||
{
|
||||
authed.GET("/me", meHandler(svc))
|
||||
authed.PATCH("/me", updateProfileHandler(svc))
|
||||
authed.POST("/me/password", changePasswordHandler(svc))
|
||||
authed.POST("/me/logout", logoutHandler(svc))
|
||||
|
||||
// Permissions.
|
||||
authed.GET("/auth/permissions", getMyPermissionsHandler(svc))
|
||||
|
||||
// License activation bridge (admin only; talks to the configured license server).
|
||||
authed.GET("/license/status", middleware.AdminRequired(), licenseStatusHandler(svc))
|
||||
authed.POST("/license/activate", middleware.AdminRequired(), licenseActivateHandler(svc))
|
||||
authed.POST("/license/heartbeat", middleware.AdminRequired(), licenseHeartbeatHandler(svc))
|
||||
|
||||
// Libraries.
|
||||
authed.GET("/libraries", listLibrariesHandler(svc))
|
||||
authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc))
|
||||
authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(svc))
|
||||
authed.POST("/libraries/:id/scan", middleware.AdminRequired(), scanLibraryHandler(svc))
|
||||
authed.POST("/libraries/:id/scrape", middleware.AdminRequired(), scrapeLibraryHandler(svc))
|
||||
|
||||
authed.GET("/libraries/:id/media", listMediaHandler(svc))
|
||||
authed.GET("/libraries/:id/seasons", listSeasonsHandler(svc))
|
||||
|
||||
// Media.
|
||||
authed.GET("/media/:id", getMediaHandler(svc))
|
||||
authed.GET("/media", searchMediaHandler(svc))
|
||||
authed.PATCH("/media/:id/metadata", middleware.AdminRequired(), updateMediaMetadataHandler(svc))
|
||||
authed.POST("/media/:id/scrape", middleware.AdminRequired(), scrapeOneHandler(svc))
|
||||
authed.GET("/media/:id/scrape/search", middleware.AdminRequired(), manualScrapeSearchHandler(svc))
|
||||
authed.POST("/media/:id/scrape/apply", middleware.AdminRequired(), manualScrapeApplyOneHandler(svc))
|
||||
authed.POST("/media/scrape/apply", middleware.AdminRequired(), manualScrapeApplyBatchHandler(svc))
|
||||
authed.POST("/media/:id/probe", middleware.AdminRequired(), reprobeHandler(svc))
|
||||
authed.DELETE("/media/:id", middleware.AdminRequired(), deleteMediaHandler(svc))
|
||||
authed.POST("/media/:id/restore", middleware.AdminRequired(), restoreMediaHandler(svc))
|
||||
authed.DELETE("/media/:id/purge", middleware.AdminRequired(), purgeMediaHandler(svc))
|
||||
authed.GET("/media/:id/subtitles", listSubtitlesHandler(svc))
|
||||
authed.GET("/subtitles/:id", serveSubtitleHandler(svc))
|
||||
authed.POST("/media/:id/nfo", middleware.AdminRequired(), exportNFOHandler(svc))
|
||||
authed.POST("/libraries/:id/nfo", middleware.AdminRequired(), exportLibraryNFOHandler(svc))
|
||||
|
||||
// Streaming.
|
||||
authed.GET("/stream/:id", streamHandler(svc))
|
||||
authed.HEAD("/stream/:id", streamHandler(svc))
|
||||
authed.GET("/hls/:id/index.m3u8", hlsPlaylistHandler(svc))
|
||||
authed.GET("/hls/:id/:seg", hlsSegmentHandler(svc))
|
||||
authed.DELETE("/hls/:id", stopTranscodeHandler(svc))
|
||||
|
||||
// Cloud-disk 302 playback redirect (resolves a fresh direct link).
|
||||
authed.GET("/cloud/play/:type", cloudPlayHandler(svc))
|
||||
authed.HEAD("/cloud/play/:type", cloudPlayHandler(svc))
|
||||
|
||||
// Image proxy (URL passed as ?url=...).
|
||||
authed.GET("/img", imageProxyHandler(svc))
|
||||
|
||||
// History / favourites / playlists.
|
||||
authed.GET("/history", recentHistoryHandler(svc))
|
||||
authed.POST("/history", recordProgressHandler(svc))
|
||||
|
||||
authed.GET("/favourites", listFavouritesHandler(svc))
|
||||
authed.POST("/favourites/:id", toggleFavouriteHandler(svc))
|
||||
|
||||
// Storage breakdown.
|
||||
authed.GET("/storage", storageBreakdownHandler(svc))
|
||||
|
||||
authed.GET("/playlists", listPlaylistsHandler(svc))
|
||||
authed.POST("/playlists", createPlaylistHandler(svc))
|
||||
authed.GET("/playlists/:id", getPlaylistHandler(svc))
|
||||
authed.POST("/playlists/:id/items", addPlaylistItemHandler(svc))
|
||||
authed.DELETE("/playlists/:id/items/:media_id", removePlaylistItemHandler(svc))
|
||||
authed.DELETE("/playlists/:id", deletePlaylistHandler(svc))
|
||||
|
||||
// Downloads.
|
||||
authed.GET("/downloads", requirePermission(svc, "can_manage_downloads"), listDownloadsHandler(svc))
|
||||
authed.POST("/downloads", requirePermission(svc, "can_manage_downloads"), addDownloadHandler(svc))
|
||||
authed.DELETE("/downloads/:hash", requirePermission(svc, "can_manage_downloads"), deleteDownloadHandler(svc))
|
||||
authed.POST("/downloads/relocate", requirePermission(svc, "can_manage_downloads"), relocateDownloadHandler(svc))
|
||||
authed.POST("/downloads/reload", requirePermission(svc, "can_manage_downloads"), reloadDownloadConfigHandler(svc))
|
||||
|
||||
// Subscriptions.
|
||||
authed.GET("/subscriptions", requirePermission(svc, "can_manage_subscriptions"), listSubscriptionsHandler(svc))
|
||||
authed.GET("/subscriptions/history", requirePermission(svc, "can_manage_subscriptions"), listSubscriptionHistoryHandler(svc))
|
||||
authed.POST("/subscriptions", requirePermission(svc, "can_manage_subscriptions"), createSubscriptionHandler(svc))
|
||||
authed.DELETE("/subscriptions/:id", requirePermission(svc, "can_manage_subscriptions"), deleteSubscriptionHandler(svc))
|
||||
authed.POST("/subscriptions/:id/restore", requirePermission(svc, "can_manage_subscriptions"), restoreSubscriptionHandler(svc))
|
||||
authed.POST("/subscriptions/:id/run", requirePermission(svc, "can_manage_subscriptions"), runSubscriptionHandler(svc))
|
||||
|
||||
// Stats / dashboard.
|
||||
authed.GET("/stats", statsHandler(svc))
|
||||
authed.GET("/tasks", middleware.AdminRequired(), tasksHandler(svc))
|
||||
|
||||
// Discover (TMDb trending / popular).
|
||||
authed.GET("/discover/trending", requirePermission(svc, "can_view_discover"), trendingHandler(svc))
|
||||
authed.GET("/discover/popular", requirePermission(svc, "can_view_discover"), popularHandler(svc))
|
||||
|
||||
// AI.
|
||||
authed.GET("/ai/status", requirePermission(svc, "can_use_ai"), aiStatusHandler(svc))
|
||||
authed.POST("/ai/search", requirePermission(svc, "can_use_ai"), smartSearchHandler(svc))
|
||||
authed.GET("/ai/recommend", requirePermission(svc, "can_use_ai"), aiRecommendHandler(svc))
|
||||
|
||||
// File browser (used by the library-path picker).
|
||||
authed.GET("/files", middleware.AdminRequired(), browseFilesHandler(svc))
|
||||
authed.POST("/files/folders", middleware.AdminRequired(), createFolderHandler(svc))
|
||||
authed.PUT("/files/rename", middleware.AdminRequired(), renameFileHandler(svc))
|
||||
authed.DELETE("/files", middleware.AdminRequired(), deleteFileHandler(svc))
|
||||
authed.POST("/files/transfer", middleware.AdminRequired(), transferFileHandler(svc))
|
||||
|
||||
// DLNA discovery + cast.
|
||||
authed.GET("/dlna/devices", dlnaListHandler(svc))
|
||||
authed.POST("/dlna/cast", dlnaCastHandler(svc))
|
||||
|
||||
// STRM (URL-as-file).
|
||||
authed.PUT("/media/:id/strm", middleware.AdminRequired(), setSTRMHandler(svc))
|
||||
authed.DELETE("/media/:id/strm", middleware.AdminRequired(), clearSTRMHandler(svc))
|
||||
authed.POST("/strm/import", middleware.AdminRequired(), importSTRMHandler(svc))
|
||||
authed.POST("/strm/generate", middleware.AdminRequired(), generateSTRMHandler(svc))
|
||||
|
||||
// Duplicate finder.
|
||||
authed.GET("/duplicates", middleware.AdminRequired(), listDuplicatesHandler(svc))
|
||||
authed.POST("/duplicates/scan", middleware.AdminRequired(), detectDuplicatesHandler(svc))
|
||||
authed.POST("/duplicates/unmark", middleware.AdminRequired(), unmarkDuplicatesHandler(svc))
|
||||
|
||||
// Site management + cross-site torrent search (via SiteHandler).
|
||||
siteHandler := NewSiteHandler(svc)
|
||||
authed.GET("/sites", requirePermission(svc, "can_manage_sites"), siteHandler.ListSites)
|
||||
authed.GET("/sites/types", requirePermission(svc, "can_manage_sites"), siteHandler.GetSiteTypes)
|
||||
authed.GET("/sites/auth-types", requirePermission(svc, "can_manage_sites"), siteHandler.GetAuthTypes)
|
||||
authed.POST("/sites", requirePermission(svc, "can_manage_sites"), siteHandler.CreateSite)
|
||||
authed.GET("/sites/:id", requirePermission(svc, "can_manage_sites"), siteHandler.GetSite)
|
||||
authed.PUT("/sites/:id", requirePermission(svc, "can_manage_sites"), siteHandler.UpdateSite)
|
||||
authed.DELETE("/sites/:id", requirePermission(svc, "can_manage_sites"), siteHandler.DeleteSite)
|
||||
authed.POST("/sites/:id/test", requirePermission(svc, "can_manage_sites"), siteHandler.TestSite)
|
||||
authed.GET("/sites/search", requirePermission(svc, "can_manage_sites"), siteSearchHandler(svc))
|
||||
|
||||
// Recycle bin.
|
||||
authed.GET("/recycle", middleware.AdminRequired(), listRecycleHandler(svc))
|
||||
authed.POST("/recycle/restore", middleware.AdminRequired(), restoreMediaBatchHandler(svc))
|
||||
authed.POST("/recycle/purge", middleware.AdminRequired(), purgeMediaBatchHandler(svc))
|
||||
|
||||
authed.GET("/ws", wsHandler(svc))
|
||||
|
||||
// SSE event stream.
|
||||
authed.GET("/events", sseHandler(svc))
|
||||
|
||||
// Scheduler.
|
||||
authed.GET("/scheduler/tasks", schedulerListTasksHandler(svc))
|
||||
authed.POST("/scheduler/tasks/:id/run", middleware.AdminRequired(), schedulerRunTaskHandler(svc))
|
||||
authed.GET("/scheduler/status", schedulerGetStatusHandler(svc))
|
||||
|
||||
// ── Auxiliary endpoints used by the React UI rails ──
|
||||
authed.GET("/media/recent", recentMediaHandler(svc))
|
||||
authed.GET("/media/stats", mediaStatsHandler(svc))
|
||||
|
||||
// Watch history (extra surface beyond /history).
|
||||
authed.GET("/watch-history", historyListHandler(svc))
|
||||
authed.GET("/watch-history/stats", historyStatsHandler(svc))
|
||||
authed.GET("/watch-history/continue", historyContinueHandler(svc))
|
||||
authed.DELETE("/watch-history", historyDeleteHandler(svc))
|
||||
authed.DELETE("/watch-history/:id", historyDeleteOneHandler(svc))
|
||||
|
||||
// Multi-section TMDb feed used by DiscoverPage.
|
||||
authed.GET("/discover/sections", requirePermission(svc, "can_view_discover"), discoverSectionsHandler(svc))
|
||||
authed.GET("/discover/feed", requirePermission(svc, "can_view_discover"), discoverFeedHandler(svc))
|
||||
|
||||
// System metadata + read-only scheduler view.
|
||||
authed.GET("/system/info", systemInfoHandler(svc))
|
||||
authed.GET("/system/status", systemStatusHandler(svc))
|
||||
authed.GET("/system/scheduler", systemSchedulerHandler(svc))
|
||||
|
||||
// Richer dashboard rails.
|
||||
authed.GET("/stats/overview", statsOverviewHandler(svc))
|
||||
authed.GET("/stats/trend", statsTrendHandler(svc))
|
||||
authed.GET("/stats/top-content", statsTopContentHandler(svc))
|
||||
authed.GET("/stats/libraries", statsLibrariesHandler(svc))
|
||||
authed.GET("/stats/monitor", statsMonitorHandler(svc))
|
||||
|
||||
// Multi-persona play profiles (caller-scoped).
|
||||
authed.GET("/play-profiles", listPlayProfilesHandler(svc))
|
||||
authed.POST("/play-profiles", createPlayProfileHandler(svc))
|
||||
authed.PUT("/play-profiles/:id", updatePlayProfileHandler(svc))
|
||||
authed.POST("/play-profiles/:id/verify-pin", verifyPlayProfilePINHandler(svc))
|
||||
authed.DELETE("/play-profiles/:id", deletePlayProfileHandler(svc))
|
||||
|
||||
// ── Search aliases ──
|
||||
authed.GET("/search", searchUnifiedHandler(svc))
|
||||
authed.GET("/search/advanced", searchAdvancedHandler(svc))
|
||||
authed.GET("/search/tmdb", searchTMDbHandler(svc))
|
||||
authed.GET("/search/sites", searchSitesHandler(svc))
|
||||
|
||||
// ── System extras ──
|
||||
authed.GET("/system/config", listSystemConfigHandler(svc))
|
||||
authed.GET("/settings/schema", schemaHandler(svc))
|
||||
authed.GET("/system/events/ticket", systemEventsTicketHandler(svc))
|
||||
|
||||
// ── Per-user stats ──
|
||||
authed.GET("/stats/user/:id", statsUserHandler(svc))
|
||||
authed.GET("/stats/top-users", statsTopUsersHandler(svc))
|
||||
authed.POST("/stats/play", statsPlayHandler(svc))
|
||||
|
||||
// ── Sites extras ──
|
||||
authed.GET("/sites/:id/resource", requirePermission(svc, "can_manage_sites"), siteResourceHandler(svc))
|
||||
authed.GET("/sites/:id/userdata", requirePermission(svc, "can_manage_sites"), siteUserdataHandler(svc))
|
||||
|
||||
// ── Subscription extras ──
|
||||
authed.PUT("/subscriptions/:id", requirePermission(svc, "can_manage_subscriptions"), updateSubscriptionHandler(svc))
|
||||
authed.POST("/subscriptions/:id/search", requirePermission(svc, "can_manage_subscriptions"), searchSubscriptionHandler(svc))
|
||||
|
||||
// ── Playlist extras ──
|
||||
authed.POST("/playlists/:id/reorder", reorderPlaylistHandler(svc))
|
||||
authed.DELETE("/playlists/:id/items/by-id/:item_id", deletePlaylistItemByIDHandler(svc))
|
||||
|
||||
// ── DLNA per-renderer control ──
|
||||
authed.POST("/dlna/:uuid/play", dlnaPlayHandler(svc))
|
||||
authed.POST("/dlna/:uuid/pause", dlnaPauseHandler(svc))
|
||||
authed.POST("/dlna/:uuid/stop", dlnaStopHandler(svc))
|
||||
authed.GET("/dlna/:uuid/status", dlnaStatusHandler(svc))
|
||||
|
||||
// ── Media favourite alias surface ──
|
||||
authed.GET("/favorites", listFavoritesAliasHandler(svc))
|
||||
authed.POST("/media/:id/favorite", addMediaFavoriteHandler(svc))
|
||||
authed.DELETE("/media/:id/favorite", removeMediaFavoriteHandler(svc))
|
||||
authed.GET("/media/:id/favorite/status", getMediaFavoriteStatusHandler(svc))
|
||||
authed.POST("/media/:id/ai-scrape", requirePermission(svc, "can_rescrape"), aiScrapeMediaHandler(svc))
|
||||
authed.POST("/media/scrape/test", requirePermission(svc, "can_rescrape"), scrapeTestHandler(svc))
|
||||
authed.POST("/media/organize", requirePermission(svc, "can_manage_files"), organizeBulkHandler(svc))
|
||||
|
||||
// ── Playback metadata + external player handoff ──
|
||||
authed.GET("/playback/:id/info", playbackInfoHandler(svc))
|
||||
authed.POST("/playback/:id/progress", playbackProgressHandler(svc))
|
||||
authed.GET("/playback/:id/external-players", externalPlayersHandler(svc))
|
||||
authed.GET("/playback/:id/external-url", externalURLHandler(svc))
|
||||
authed.GET("/playback/transcode/:job_id/status", transcodeStatusHandler(svc))
|
||||
|
||||
// ── Download task ops + sync triggers ──
|
||||
authed.POST("/download/:id/pause", requirePermission(svc, "can_manage_downloads"), downloadPauseHandler(svc))
|
||||
authed.POST("/download/:id/resume", requirePermission(svc, "can_manage_downloads"), downloadResumeHandler(svc))
|
||||
authed.POST("/download/:id/organize", requirePermission(svc, "can_manage_files"), downloadOrganizeOneHandler(svc))
|
||||
authed.POST("/download/organize", requirePermission(svc, "can_manage_files"), downloadOrganizeAllHandler(svc))
|
||||
authed.POST("/download/sync", requirePermission(svc, "can_manage_downloads"), downloadSyncHandler(svc))
|
||||
authed.POST("/download/start-auto-sync", requirePermission(svc, "can_manage_downloads"), downloadAutoSyncHandler(svc))
|
||||
authed.GET("/download/tasks", requirePermission(svc, "can_manage_downloads"), downloadTasksAliasHandler(svc))
|
||||
|
||||
// ── Assistant (multi-turn AI chat) ──
|
||||
authed.GET("/admin/assistant/sessions", listAssistantSessionsHandler(svc))
|
||||
authed.POST("/admin/assistant/sessions", createAssistantSessionHandler(svc))
|
||||
authed.GET("/admin/assistant/session/:id", getAssistantSessionHandler(svc))
|
||||
authed.DELETE("/admin/assistant/session/:id", deleteAssistantSessionHandler(svc))
|
||||
authed.POST("/admin/assistant/chat", assistantChatHandler(svc))
|
||||
authed.POST("/admin/assistant/execute", assistantExecuteHandler(svc))
|
||||
authed.POST("/admin/assistant/undo/:op_id", assistantUndoHandler(svc))
|
||||
authed.GET("/admin/assistant/history", assistantHistoryHandler(svc))
|
||||
}
|
||||
|
||||
registerAuthedUserAndLicenseRoutes(authed, svc)
|
||||
registerAuthedLibraryRoutes(authed, svc)
|
||||
registerAuthedMediaRoutes(authed, svc)
|
||||
registerAuthedPlaybackAndProxyRoutes(authed, svc)
|
||||
registerAuthedCollectionRoutes(authed, svc)
|
||||
registerAuthedDownloadRoutes(authed, svc)
|
||||
registerAuthedSubscriptionRoutes(authed, svc)
|
||||
registerAuthedStatsDiscoveryAndAIRoutes(authed, svc)
|
||||
registerAuthedFileRoutes(authed, svc)
|
||||
registerAuthedDLNARoutes(authed, svc)
|
||||
registerAuthedSTRMRoutes(authed, svc)
|
||||
registerAuthedDuplicateRoutes(authed, svc)
|
||||
registerAuthedSiteRoutes(authed, svc)
|
||||
registerAuthedRecycleAndRealtimeRoutes(authed, svc)
|
||||
registerAuthedSchedulerRoutes(authed, svc)
|
||||
registerAuthedUISurfaceRoutes(authed, svc)
|
||||
registerAuthedSearchRoutes(authed, svc)
|
||||
registerAuthedSystemExtraRoutes(authed, svc)
|
||||
registerAuthedStatsExtraRoutes(authed, svc)
|
||||
registerAuthedSitesExtraRoutes(authed, svc)
|
||||
registerAuthedSubscriptionExtraRoutes(authed, svc)
|
||||
registerAuthedPlaylistExtraRoutes(authed, svc)
|
||||
registerAuthedDLNAControlRoutes(authed, svc)
|
||||
registerAuthedFavoriteAndMediaActionRoutes(authed, svc)
|
||||
registerAuthedPlaybackExtraRoutes(authed, svc)
|
||||
registerAuthedDownloadOpsRoutes(authed, svc)
|
||||
registerAuthedAssistantRoutes(authed, svc)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/me", meHandler(svc))
|
||||
authed.PATCH("/me", updateProfileHandler(svc))
|
||||
authed.POST("/me/password", changePasswordHandler(svc))
|
||||
authed.POST("/me/logout", logoutHandler(svc))
|
||||
|
||||
authed.GET("/auth/permissions", getMyPermissionsHandler(svc))
|
||||
|
||||
authed.GET("/license/status", middleware.AdminRequired(), licenseStatusHandler(svc))
|
||||
authed.POST("/license/activate", middleware.AdminRequired(), licenseActivateHandler(svc))
|
||||
authed.POST("/license/heartbeat", middleware.AdminRequired(), licenseHeartbeatHandler(svc))
|
||||
}
|
||||
|
||||
func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/libraries", listLibrariesHandler(svc))
|
||||
authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc))
|
||||
authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(svc))
|
||||
authed.POST("/libraries/:id/scan", middleware.AdminRequired(), scanLibraryHandler(svc))
|
||||
authed.POST("/libraries/:id/scrape", middleware.AdminRequired(), scrapeLibraryHandler(svc))
|
||||
|
||||
authed.GET("/libraries/:id/media", listMediaHandler(svc))
|
||||
authed.GET("/libraries/:id/series", listLibrarySeriesHandler(svc))
|
||||
authed.GET("/libraries/:id/series/episodes", listLibrarySeriesEpisodesHandler(svc))
|
||||
authed.GET("/libraries/:id/seasons", listSeasonsHandler(svc))
|
||||
}
|
||||
|
||||
func registerAuthedMediaRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/media/:id", getMediaHandler(svc))
|
||||
authed.GET("/media", searchMediaHandler(svc))
|
||||
authed.PATCH("/media/:id/metadata", middleware.AdminRequired(), updateMediaMetadataHandler(svc))
|
||||
authed.POST("/media/:id/scrape", middleware.AdminRequired(), scrapeOneHandler(svc))
|
||||
authed.GET("/media/:id/scrape/search", middleware.AdminRequired(), manualScrapeSearchHandler(svc))
|
||||
authed.POST("/media/:id/scrape/apply", middleware.AdminRequired(), manualScrapeApplyOneHandler(svc))
|
||||
authed.POST("/media/scrape/apply", middleware.AdminRequired(), manualScrapeApplyBatchHandler(svc))
|
||||
authed.POST("/media/:id/probe", middleware.AdminRequired(), reprobeHandler(svc))
|
||||
authed.DELETE("/media/:id", middleware.AdminRequired(), deleteMediaHandler(svc))
|
||||
authed.POST("/media/:id/restore", middleware.AdminRequired(), restoreMediaHandler(svc))
|
||||
authed.DELETE("/media/:id/purge", middleware.AdminRequired(), purgeMediaHandler(svc))
|
||||
authed.GET("/media/:id/subtitles", listSubtitlesHandler(svc))
|
||||
authed.GET("/subtitles/:id", serveSubtitleHandler(svc))
|
||||
authed.POST("/media/:id/nfo", middleware.AdminRequired(), exportNFOHandler(svc))
|
||||
authed.POST("/libraries/:id/nfo", middleware.AdminRequired(), exportLibraryNFOHandler(svc))
|
||||
}
|
||||
|
||||
func registerAuthedPlaybackAndProxyRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/stream/:id", streamHandler(svc))
|
||||
authed.HEAD("/stream/:id", streamHandler(svc))
|
||||
authed.GET("/hls/:id/index.m3u8", hlsPlaylistHandler(svc))
|
||||
authed.GET("/hls/:id/:seg", hlsSegmentHandler(svc))
|
||||
authed.DELETE("/hls/:id", stopTranscodeHandler(svc))
|
||||
|
||||
authed.GET("/cloud/play/:type", cloudPlayHandler(svc))
|
||||
authed.HEAD("/cloud/play/:type", cloudPlayHandler(svc))
|
||||
|
||||
authed.GET("/img/cloud/:type", cloudArtworkProxyHandler(svc))
|
||||
authed.HEAD("/img/cloud/:type", cloudArtworkProxyHandler(svc))
|
||||
authed.GET("/img", imageProxyHandler(svc))
|
||||
}
|
||||
|
||||
func registerAuthedCollectionRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/history", recentHistoryHandler(svc))
|
||||
authed.POST("/history", recordProgressHandler(svc))
|
||||
|
||||
authed.GET("/favourites", listFavouritesHandler(svc))
|
||||
authed.POST("/favourites/:id", toggleFavouriteHandler(svc))
|
||||
|
||||
authed.GET("/storage", storageBreakdownHandler(svc))
|
||||
|
||||
authed.GET("/playlists", listPlaylistsHandler(svc))
|
||||
authed.POST("/playlists", createPlaylistHandler(svc))
|
||||
authed.GET("/playlists/:id", getPlaylistHandler(svc))
|
||||
authed.POST("/playlists/:id/items", addPlaylistItemHandler(svc))
|
||||
authed.DELETE("/playlists/:id/items/:media_id", removePlaylistItemHandler(svc))
|
||||
authed.DELETE("/playlists/:id", deletePlaylistHandler(svc))
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
func registerAuthedUISurfaceRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/media/recent", recentMediaHandler(svc))
|
||||
authed.GET("/media/stats", mediaStatsHandler(svc))
|
||||
|
||||
authed.GET("/watch-history", historyListHandler(svc))
|
||||
authed.GET("/watch-history/stats", historyStatsHandler(svc))
|
||||
authed.GET("/watch-history/continue", historyContinueHandler(svc))
|
||||
authed.DELETE("/watch-history", historyDeleteHandler(svc))
|
||||
authed.DELETE("/watch-history/:id", historyDeleteOneHandler(svc))
|
||||
|
||||
authed.GET("/discover/sections", requirePermission(svc, "can_view_discover"), discoverSectionsHandler(svc))
|
||||
authed.GET("/discover/feed", requirePermission(svc, "can_view_discover"), discoverFeedHandler(svc))
|
||||
|
||||
authed.GET("/system/info", systemInfoHandler(svc))
|
||||
authed.GET("/system/status", systemStatusHandler(svc))
|
||||
authed.GET("/system/scheduler", systemSchedulerHandler(svc))
|
||||
|
||||
authed.GET("/stats/overview", statsOverviewHandler(svc))
|
||||
authed.GET("/stats/trend", statsTrendHandler(svc))
|
||||
authed.GET("/stats/top-content", statsTopContentHandler(svc))
|
||||
authed.GET("/stats/libraries", statsLibrariesHandler(svc))
|
||||
authed.GET("/stats/monitor", statsMonitorHandler(svc))
|
||||
|
||||
authed.GET("/play-profiles", listPlayProfilesHandler(svc))
|
||||
authed.POST("/play-profiles", createPlayProfileHandler(svc))
|
||||
authed.PUT("/play-profiles/:id", updatePlayProfileHandler(svc))
|
||||
authed.POST("/play-profiles/:id/verify-pin", verifyPlayProfilePINHandler(svc))
|
||||
authed.DELETE("/play-profiles/:id", deletePlayProfileHandler(svc))
|
||||
}
|
||||
|
||||
func registerAuthedSearchRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/search", searchUnifiedHandler(svc))
|
||||
authed.GET("/search/advanced", searchAdvancedHandler(svc))
|
||||
authed.GET("/search/tmdb", searchTMDbHandler(svc))
|
||||
authed.GET("/search/sites", searchSitesHandler(svc))
|
||||
}
|
||||
|
||||
func registerAuthedSystemExtraRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/system/config", listSystemConfigHandler(svc))
|
||||
authed.GET("/settings/schema", schemaHandler(svc))
|
||||
authed.GET("/system/events/ticket", systemEventsTicketHandler(svc))
|
||||
}
|
||||
|
||||
func registerAuthedStatsExtraRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/stats/user/:id", statsUserHandler(svc))
|
||||
authed.GET("/stats/top-users", statsTopUsersHandler(svc))
|
||||
authed.POST("/stats/play", statsPlayHandler(svc))
|
||||
}
|
||||
|
||||
func registerAuthedSitesExtraRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/sites/:id/resource", requirePermission(svc, "can_manage_sites"), siteResourceHandler(svc))
|
||||
authed.GET("/sites/:id/userdata", requirePermission(svc, "can_manage_sites"), siteUserdataHandler(svc))
|
||||
}
|
||||
|
||||
func registerAuthedSubscriptionExtraRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.PUT("/subscriptions/:id", requirePermission(svc, "can_manage_subscriptions"), updateSubscriptionHandler(svc))
|
||||
authed.POST("/subscriptions/:id/search", requirePermission(svc, "can_manage_subscriptions"), searchSubscriptionHandler(svc))
|
||||
}
|
||||
|
||||
func registerAuthedPlaylistExtraRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.POST("/playlists/:id/reorder", reorderPlaylistHandler(svc))
|
||||
authed.DELETE("/playlists/:id/items/by-id/:item_id", deletePlaylistItemByIDHandler(svc))
|
||||
}
|
||||
|
||||
func registerAuthedDLNAControlRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.POST("/dlna/:uuid/play", dlnaPlayHandler(svc))
|
||||
authed.POST("/dlna/:uuid/pause", dlnaPauseHandler(svc))
|
||||
authed.POST("/dlna/:uuid/stop", dlnaStopHandler(svc))
|
||||
authed.GET("/dlna/:uuid/status", dlnaStatusHandler(svc))
|
||||
}
|
||||
|
||||
func registerAuthedFavoriteAndMediaActionRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/favorites", listFavoritesAliasHandler(svc))
|
||||
authed.POST("/media/:id/favorite", addMediaFavoriteHandler(svc))
|
||||
authed.DELETE("/media/:id/favorite", removeMediaFavoriteHandler(svc))
|
||||
authed.GET("/media/:id/favorite/status", getMediaFavoriteStatusHandler(svc))
|
||||
authed.POST("/media/:id/ai-scrape", requirePermission(svc, "can_rescrape"), aiScrapeMediaHandler(svc))
|
||||
authed.POST("/media/scrape/test", requirePermission(svc, "can_rescrape"), scrapeTestHandler(svc))
|
||||
authed.POST("/media/organize", requirePermission(svc, "can_manage_files"), organizeBulkHandler(svc))
|
||||
}
|
||||
|
||||
func registerAuthedPlaybackExtraRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/playback/:id/info", playbackInfoHandler(svc))
|
||||
authed.POST("/playback/:id/progress", playbackProgressHandler(svc))
|
||||
authed.GET("/playback/:id/external-players", externalPlayersHandler(svc))
|
||||
authed.GET("/playback/:id/external-url", externalURLHandler(svc))
|
||||
authed.GET("/playback/transcode/:job_id/status", transcodeStatusHandler(svc))
|
||||
}
|
||||
|
||||
func registerAuthedDownloadOpsRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.POST("/download/:id/pause", requirePermission(svc, "can_manage_downloads"), downloadPauseHandler(svc))
|
||||
authed.POST("/download/:id/resume", requirePermission(svc, "can_manage_downloads"), downloadResumeHandler(svc))
|
||||
authed.POST("/download/:id/organize", requirePermission(svc, "can_manage_files"), downloadOrganizeOneHandler(svc))
|
||||
authed.POST("/download/organize", requirePermission(svc, "can_manage_files"), downloadOrganizeAllHandler(svc))
|
||||
authed.POST("/download/sync", requirePermission(svc, "can_manage_downloads"), downloadSyncHandler(svc))
|
||||
authed.POST("/download/start-auto-sync", requirePermission(svc, "can_manage_downloads"), downloadAutoSyncHandler(svc))
|
||||
authed.GET("/download/tasks", requirePermission(svc, "can_manage_downloads"), downloadTasksAliasHandler(svc))
|
||||
}
|
||||
|
||||
func registerAuthedAssistantRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/admin/assistant/sessions", listAssistantSessionsHandler(svc))
|
||||
authed.POST("/admin/assistant/sessions", createAssistantSessionHandler(svc))
|
||||
authed.GET("/admin/assistant/session/:id", getAssistantSessionHandler(svc))
|
||||
authed.DELETE("/admin/assistant/session/:id", deleteAssistantSessionHandler(svc))
|
||||
authed.POST("/admin/assistant/chat", assistantChatHandler(svc))
|
||||
authed.POST("/admin/assistant/execute", assistantExecuteHandler(svc))
|
||||
authed.POST("/admin/assistant/undo/:op_id", assistantUndoHandler(svc))
|
||||
authed.GET("/admin/assistant/history", assistantHistoryHandler(svc))
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
func registerAuthedDownloadRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/downloads", requirePermission(svc, "can_manage_downloads"), listDownloadsHandler(svc))
|
||||
authed.POST("/downloads", requirePermission(svc, "can_manage_downloads"), addDownloadHandler(svc))
|
||||
authed.DELETE("/downloads/:hash", requirePermission(svc, "can_manage_downloads"), deleteDownloadHandler(svc))
|
||||
authed.POST("/downloads/relocate", requirePermission(svc, "can_manage_downloads"), relocateDownloadHandler(svc))
|
||||
authed.POST("/downloads/reload", requirePermission(svc, "can_manage_downloads"), reloadDownloadConfigHandler(svc))
|
||||
}
|
||||
|
||||
func registerAuthedSubscriptionRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/subscriptions", requirePermission(svc, "can_manage_subscriptions"), listSubscriptionsHandler(svc))
|
||||
authed.GET("/subscriptions/history", requirePermission(svc, "can_manage_subscriptions"), listSubscriptionHistoryHandler(svc))
|
||||
authed.POST("/subscriptions", requirePermission(svc, "can_manage_subscriptions"), createSubscriptionHandler(svc))
|
||||
authed.DELETE("/subscriptions/:id", requirePermission(svc, "can_manage_subscriptions"), deleteSubscriptionHandler(svc))
|
||||
authed.POST("/subscriptions/:id/restore", requirePermission(svc, "can_manage_subscriptions"), restoreSubscriptionHandler(svc))
|
||||
authed.POST("/subscriptions/:id/run", requirePermission(svc, "can_manage_subscriptions"), runSubscriptionHandler(svc))
|
||||
}
|
||||
|
||||
func registerAuthedStatsDiscoveryAndAIRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/stats", statsHandler(svc))
|
||||
authed.GET("/tasks", middleware.AdminRequired(), tasksHandler(svc))
|
||||
|
||||
authed.GET("/discover/trending", requirePermission(svc, "can_view_discover"), trendingHandler(svc))
|
||||
authed.GET("/discover/popular", requirePermission(svc, "can_view_discover"), popularHandler(svc))
|
||||
|
||||
authed.GET("/ai/status", requirePermission(svc, "can_use_ai"), aiStatusHandler(svc))
|
||||
authed.POST("/ai/search", requirePermission(svc, "can_use_ai"), smartSearchHandler(svc))
|
||||
authed.GET("/ai/recommend", requirePermission(svc, "can_use_ai"), aiRecommendHandler(svc))
|
||||
}
|
||||
|
||||
func registerAuthedFileRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/files", middleware.AdminRequired(), browseFilesHandler(svc))
|
||||
authed.POST("/files/folders", middleware.AdminRequired(), createFolderHandler(svc))
|
||||
authed.PUT("/files/rename", middleware.AdminRequired(), renameFileHandler(svc))
|
||||
authed.DELETE("/files", middleware.AdminRequired(), deleteFileHandler(svc))
|
||||
authed.POST("/files/transfer", middleware.AdminRequired(), transferFileHandler(svc))
|
||||
}
|
||||
|
||||
func registerAuthedDLNARoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/dlna/devices", dlnaListHandler(svc))
|
||||
authed.POST("/dlna/cast", dlnaCastHandler(svc))
|
||||
}
|
||||
|
||||
func registerAuthedSTRMRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.PUT("/media/:id/strm", middleware.AdminRequired(), setSTRMHandler(svc))
|
||||
authed.DELETE("/media/:id/strm", middleware.AdminRequired(), clearSTRMHandler(svc))
|
||||
authed.POST("/strm/import", middleware.AdminRequired(), importSTRMHandler(svc))
|
||||
authed.POST("/strm/generate", middleware.AdminRequired(), generateSTRMHandler(svc))
|
||||
}
|
||||
|
||||
func registerAuthedDuplicateRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/duplicates", middleware.AdminRequired(), listDuplicatesHandler(svc))
|
||||
authed.POST("/duplicates/scan", middleware.AdminRequired(), detectDuplicatesHandler(svc))
|
||||
authed.POST("/duplicates/unmark", middleware.AdminRequired(), unmarkDuplicatesHandler(svc))
|
||||
}
|
||||
|
||||
func registerAuthedSiteRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
siteHandler := NewSiteHandler(svc)
|
||||
authed.GET("/sites", requirePermission(svc, "can_manage_sites"), siteHandler.ListSites)
|
||||
authed.GET("/sites/types", requirePermission(svc, "can_manage_sites"), siteHandler.GetSiteTypes)
|
||||
authed.GET("/sites/auth-types", requirePermission(svc, "can_manage_sites"), siteHandler.GetAuthTypes)
|
||||
authed.POST("/sites", requirePermission(svc, "can_manage_sites"), siteHandler.CreateSite)
|
||||
authed.GET("/sites/:id", requirePermission(svc, "can_manage_sites"), siteHandler.GetSite)
|
||||
authed.PUT("/sites/:id", requirePermission(svc, "can_manage_sites"), siteHandler.UpdateSite)
|
||||
authed.DELETE("/sites/:id", requirePermission(svc, "can_manage_sites"), siteHandler.DeleteSite)
|
||||
authed.POST("/sites/:id/test", requirePermission(svc, "can_manage_sites"), siteHandler.TestSite)
|
||||
authed.GET("/sites/search", requirePermission(svc, "can_manage_sites"), siteSearchHandler(svc))
|
||||
}
|
||||
|
||||
func registerAuthedRecycleAndRealtimeRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/recycle", middleware.AdminRequired(), listRecycleHandler(svc))
|
||||
authed.POST("/recycle/restore", middleware.AdminRequired(), restoreMediaBatchHandler(svc))
|
||||
authed.POST("/recycle/purge", middleware.AdminRequired(), purgeMediaBatchHandler(svc))
|
||||
|
||||
authed.GET("/ws", wsHandler(svc))
|
||||
authed.GET("/events", sseHandler(svc))
|
||||
}
|
||||
|
||||
func registerAuthedSchedulerRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/scheduler/tasks", schedulerListTasksHandler(svc))
|
||||
authed.POST("/scheduler/tasks/:id/run", middleware.AdminRequired(), schedulerRunTaskHandler(svc))
|
||||
authed.GET("/scheduler/status", schedulerGetStatusHandler(svc))
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
func TestAuthenticatedRouteSurfacesAreRegistered(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
router := gin.New()
|
||||
Register(router, &config.Config{
|
||||
Secrets: config.SecretsConfig{JWTSecret: "test-secret"},
|
||||
}, zap.NewNop(), &service.Container{Log: zap.NewNop()})
|
||||
|
||||
routes := map[string]bool{}
|
||||
for _, route := range router.Routes() {
|
||||
routes[route.Method+" "+route.Path] = true
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
"GET /api/me",
|
||||
"GET /api/auth/permissions",
|
||||
"GET /api/libraries",
|
||||
"GET /api/media",
|
||||
"GET /api/stream/:id",
|
||||
"GET /api/storage",
|
||||
"GET /api/downloads",
|
||||
"GET /api/subscriptions",
|
||||
"GET /api/sites/search",
|
||||
"GET /api/watch-history",
|
||||
"GET /api/discover/feed",
|
||||
"GET /api/playback/:id/info",
|
||||
"GET /api/download/tasks",
|
||||
"GET /api/admin/assistant/history",
|
||||
} {
|
||||
if !routes[want] {
|
||||
t.Fatalf("%s route is not registered", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ package handler
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -31,15 +32,20 @@ func listSeasonsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
}
|
||||
var rows []model.Media
|
||||
err := svc.Repo.DB.Where(&model.Media{LibraryID: libID}).
|
||||
Order("season_num asc, episode_num asc").
|
||||
Find(&rows).Error
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
var rows []model.Media
|
||||
const pageSize = 2000
|
||||
for page := 1; ; page++ {
|
||||
pageRows, total, err := svc.Media.ListMediaVisible(c.Request.Context(), libID, page, pageSize, visibility)
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
rows = append(rows, pageRows...)
|
||||
if int64(len(rows)) >= total || len(pageRows) < pageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
buckets := make(map[int][]model.Media)
|
||||
for _, r := range rows {
|
||||
if !visibility.Allows(&r) {
|
||||
@@ -55,3 +61,65 @@ func listSeasonsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusOK, gin.H{"seasons": out})
|
||||
}
|
||||
}
|
||||
|
||||
func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
libID := c.Param("id")
|
||||
if lib, err := svc.Repo.Library.FindByID(c.Request.Context(), libID); err == nil && lib != nil {
|
||||
if !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, *lib, mediaVisibilityForRequest(c, svc)) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
}
|
||||
items, total, err := svc.Media.ListLibrarySeriesCards(c.Request.Context(), libID, mediaVisibilityForRequest(c, svc))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "500"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 || size > 1000 {
|
||||
size = 500
|
||||
}
|
||||
start := (page - 1) * size
|
||||
if start > len(items) {
|
||||
start = len(items)
|
||||
}
|
||||
end := start + size
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": items[start:end],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": size,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func listLibrarySeriesEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
libID := c.Param("id")
|
||||
key := c.Query("key")
|
||||
if key == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "key is required"})
|
||||
return
|
||||
}
|
||||
if lib, err := svc.Repo.Library.FindByID(c.Request.Context(), libID); err == nil && lib != nil {
|
||||
if !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, *lib, mediaVisibilityForRequest(c, svc)) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
}
|
||||
items, err := svc.Media.ListLibrarySeriesEpisodes(c.Request.Context(), libID, key, mediaVisibilityForRequest(c, svc))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package handler — Alist / S3 / WebDAV storage config endpoints.
|
||||
// Package handler — external storage config endpoints.
|
||||
package handler
|
||||
|
||||
import (
|
||||
@@ -25,6 +25,10 @@ func listStorageConfigsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// getStorageConfigHandler returns one config (with the decrypted body).
|
||||
func getStorageConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !service.IsAdminStorageConfigurable(c.Param("type")) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported storage type"})
|
||||
return
|
||||
}
|
||||
row, err := svc.StorageCfg.Get(c.Request.Context(), c.Param("type"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -42,6 +46,10 @@ func getStorageConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// the type via URL and the body as a JSON object.
|
||||
func saveStorageConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !service.IsAdminStorageConfigurable(c.Param("type")) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported storage type"})
|
||||
return
|
||||
}
|
||||
var in service.StorageInput
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -63,6 +71,10 @@ func saveStorageConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// testStorageConfigHandler probes an unsaved config.
|
||||
func testStorageConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !service.IsAdminStorageConfigurable(c.Param("type")) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "unsupported storage type"})
|
||||
return
|
||||
}
|
||||
var in service.StorageInput
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -80,6 +92,10 @@ func testStorageConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func logoutStorageConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
typ := c.Param("type")
|
||||
if !service.IsAdminStorageConfigurable(typ) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported storage type"})
|
||||
return
|
||||
}
|
||||
row, err := svc.StorageCfg.Logout(c.Request.Context(), typ)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -94,6 +110,10 @@ func logoutStorageConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
|
||||
func storageUploadLocalHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !service.IsAdminStorageConfigurable(c.Param("type")) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported storage type"})
|
||||
return
|
||||
}
|
||||
var req service.CloudUploadInput
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestStorageConfigHandlersRejectQuark(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.PUT("/admin/storage/:type", saveStorageConfigHandler(nil))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/admin/storage/quark", strings.NewReader(`{"type":"quark","config":{"cookie":"x"}}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d body=%s, want 400", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "unsupported storage type") {
|
||||
t.Fatalf("body = %s, want unsupported storage type", w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -79,16 +80,99 @@ func imageProxyHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func cloudArtworkProxyHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
typ := c.Param("type")
|
||||
ref := c.Query("ref")
|
||||
if !service.IsAdminCloudConfigurable(typ) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported cloud provider"})
|
||||
return
|
||||
}
|
||||
if ref == "" || !isCloudImageRef(ref) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "image ref required"})
|
||||
return
|
||||
}
|
||||
if svc == nil || svc.ImageProxy == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "image proxy unavailable"})
|
||||
return
|
||||
}
|
||||
stableKey := typ + ":" + ref
|
||||
if svc.ImageProxy.ServeCloudCached(c.Writer, c.Request, stableKey) {
|
||||
return
|
||||
}
|
||||
if svc.StorageCfg == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud storage service unavailable"})
|
||||
return
|
||||
}
|
||||
link, err := svc.StorageCfg.CloudResolve(c.Request.Context(), typ, ref, c.Request.UserAgent())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := svc.ImageProxy.ServeCloudResolved(c.Request.Context(), c.Writer, c.Request, stableKey, link); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type scrapeRequest struct {
|
||||
EpisodeArtwork *bool `json:"episode_artwork"`
|
||||
EpisodeImages *bool `json:"episode_images"`
|
||||
RefreshMatched *bool `json:"refresh_matched"`
|
||||
IncludeMatched *bool `json:"include_matched"`
|
||||
}
|
||||
|
||||
func (r scrapeRequest) episodeArtworkOption() *bool {
|
||||
if r.EpisodeImages != nil {
|
||||
return r.EpisodeImages
|
||||
}
|
||||
return r.EpisodeArtwork
|
||||
}
|
||||
|
||||
func (r scrapeRequest) includeMatchedOption() bool {
|
||||
if r.IncludeMatched != nil {
|
||||
return *r.IncludeMatched
|
||||
}
|
||||
if r.RefreshMatched != nil {
|
||||
return *r.RefreshMatched
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func scrapeOptionsFromRequest(c *gin.Context, retryNoMatch bool) (service.ScrapeOptions, error) {
|
||||
options := service.ScrapeOptions{RetryNoMatch: retryNoMatch}
|
||||
if c.Request.Body == nil || c.Request.ContentLength == 0 {
|
||||
return options, nil
|
||||
}
|
||||
var req scrapeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return options, nil
|
||||
}
|
||||
return options, err
|
||||
}
|
||||
options.EpisodeArtwork = req.episodeArtworkOption()
|
||||
options.IncludeMatched = req.includeMatchedOption()
|
||||
return options, nil
|
||||
}
|
||||
|
||||
// scrapeOneHandler enriches a single media via the configured scraper chain.
|
||||
func scrapeOneHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
options, err := scrapeOptionsFromRequest(c, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid scrape options"})
|
||||
return
|
||||
}
|
||||
options.IncludeMatched = true
|
||||
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || m == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
task := startScrapeHTTPTask(svc, "手动刮削媒体", m.Title, m.Path)
|
||||
if err := svc.Scraper.EnrichOne(c.Request.Context(), m); err != nil {
|
||||
if err := svc.Scraper.EnrichOneWithOptions(c.Request.Context(), m, options); err != nil {
|
||||
finishHTTPTask(task, err, "scrape", "手动刮削媒体失败", nil, nil)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -103,10 +187,16 @@ func scrapeOneHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// scrapeLibraryHandler retries every pending/no_match media in a library.
|
||||
// scrapeLibraryHandler manually refreshes every scrapeable row in a library.
|
||||
func scrapeLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
libID := c.Param("id")
|
||||
options, err := scrapeOptionsFromRequest(c, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid scrape options"})
|
||||
return
|
||||
}
|
||||
options.IncludeMatched = true
|
||||
var task *service.TaskHandle
|
||||
if lib, err := svc.Repo.Library.FindByID(c.Request.Context(), libID); err == nil && lib != nil {
|
||||
task = startScrapeHTTPTask(svc, "手动刮削媒体库", lib.Name, lib.Path)
|
||||
@@ -115,9 +205,16 @@ func scrapeLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
// Run in the background so HTTP returns instantly; the WS hub
|
||||
// pushes per-item progress on the "scrape" topic.
|
||||
go func(libID string, task *service.TaskHandle) {
|
||||
matched, err := svc.Scraper.EnrichLibrary(context.Background(), libID, true)
|
||||
metrics := map[string]int64{"matched": int64(matched)}
|
||||
go func(libID string, task *service.TaskHandle, options service.ScrapeOptions) {
|
||||
result, err := svc.Scraper.EnrichLibraryDetailedWithOptions(context.Background(), libID, options)
|
||||
metrics := map[string]int64{
|
||||
"matched": int64(result.Matched),
|
||||
"processed": int64(result.Processed),
|
||||
"candidates": int64(result.Candidates),
|
||||
}
|
||||
if result.Failed > 0 {
|
||||
metrics["errors"] = int64(result.Failed)
|
||||
}
|
||||
stage := "completed"
|
||||
message := "手动刮削媒体库结束"
|
||||
if err != nil {
|
||||
@@ -125,7 +222,7 @@ func scrapeLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
message = "手动刮削媒体库失败"
|
||||
}
|
||||
finishHTTPTask(task, err, stage, message, metrics, nil)
|
||||
}(libID, task)
|
||||
}(libID, task, options)
|
||||
c.JSON(http.StatusAccepted, gin.H{"status": "scraping"})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ func importSTRMHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
type generateSTRMReq struct {
|
||||
LibraryID string `json:"library_id" binding:"required"`
|
||||
LibraryID string `json:"library_id"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -122,7 +122,7 @@ func generateSTRMHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if baseURL == "" {
|
||||
baseURL = strings.TrimRight(absoluteRequestURL(c, "/"), "/")
|
||||
}
|
||||
res, err := strmSvc.GenerateForLibrary(c.Request.Context(), service.GenerateSTRMOptions{
|
||||
options := service.GenerateSTRMOptions{
|
||||
LibraryID: req.LibraryID,
|
||||
OutputDir: req.OutputDir,
|
||||
BaseURL: baseURL,
|
||||
@@ -130,7 +130,14 @@ func generateSTRMHandler(svc *service.Container) gin.HandlerFunc {
|
||||
Overwrite: req.Overwrite,
|
||||
IncludeLocal: true,
|
||||
PlaybackToken: strmPlaybackTokenForRequest(c, svc),
|
||||
})
|
||||
}
|
||||
var res *service.GenerateSTRMResult
|
||||
var err error
|
||||
if strings.TrimSpace(req.LibraryID) == "*" {
|
||||
res, err = strmSvc.GenerateForAllLibraries(c.Request.Context(), options)
|
||||
} else {
|
||||
res, err = strmSvc.GenerateForLibrary(c.Request.Context(), options)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -24,6 +24,8 @@ type subscriptionReq struct {
|
||||
PosterURL string `json:"poster_url"`
|
||||
BackdropURL string `json:"backdrop_url"`
|
||||
Overview string `json:"overview"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Year int `json:"year"`
|
||||
Resolution string `json:"resolution"`
|
||||
Quality string `json:"quality"`
|
||||
Effects string `json:"effects"`
|
||||
@@ -62,6 +64,8 @@ func createSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
|
||||
PosterURL: req.PosterURL,
|
||||
BackdropURL: req.BackdropURL,
|
||||
Overview: req.Overview,
|
||||
OriginalName: req.OriginalName,
|
||||
Year: req.Year,
|
||||
Resolution: req.Resolution,
|
||||
Quality: req.Quality,
|
||||
Effects: req.Effects,
|
||||
|
||||
@@ -85,12 +85,11 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc {
|
||||
{"key": "cloud.boot_scan_enabled", "type": "toggle", "label": "启动后立即扫描网盘"},
|
||||
{"key": "cloud.upload_auto_enabled", "type": "toggle", "label": "启用自动转存"},
|
||||
{"key": "cloud.upload_provider", "type": "select", "label": "转存目标", "options": []gin.H{
|
||||
{"value": "openlist", "label": "OpenList(推荐,可桥接 115/123/阿里/夸克)"},
|
||||
{"value": "clouddrive2", "label": "CloudDrive2(推荐,可桥接 115/123/阿里/夸克)"},
|
||||
{"value": "openlist", "label": "OpenList(推荐,可桥接 115/123/阿里等)"},
|
||||
{"value": "clouddrive2", "label": "CloudDrive2(推荐,可桥接 115/123/阿里等)"},
|
||||
{"value": "alist", "label": "Alist(可桥接多网盘)"},
|
||||
{"value": "webdav", "label": "WebDAV"},
|
||||
{"value": "cloud115", "label": "115 原生(待接分片上传)"},
|
||||
{"value": "quark", "label": "夸克原生(待接分片上传)"},
|
||||
}},
|
||||
{"key": "cloud.upload_source_dir", "type": "text", "label": "本地源目录"},
|
||||
{"key": "cloud.upload_dest_path", "type": "text", "label": "网盘目标目录"},
|
||||
|
||||
@@ -8,16 +8,25 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
const tasksLiveTorrentSnapshotMaxAge = 30 * time.Second
|
||||
|
||||
func tasksHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
transcodes := svc.Transcoder.Active()
|
||||
_, torrents, _ := svc.Downloads.List(c.Request.Context())
|
||||
var transcodes []service.ActiveJob
|
||||
if svc.Transcoder != nil {
|
||||
transcodes = svc.Transcoder.Active()
|
||||
}
|
||||
var torrents []service.QBitTorrent
|
||||
if svc.Downloads != nil {
|
||||
torrents = svc.Downloads.LiveTorrentSnapshot(tasksLiveTorrentSnapshotMaxAge)
|
||||
}
|
||||
background := service.TaskSnapshot{}
|
||||
if svc.Tasks != nil {
|
||||
background = svc.Tasks.Snapshot()
|
||||
|
||||
Reference in New Issue
Block a user