mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Add Emby-compatible request logging and ordered playback progress
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// EmbyCompatLogger 将 Emby/Jellyfin 兼容面的请求独立记录,便于针对具体
|
||||
// 客户端、设备和未实现接口排查兼容问题。
|
||||
//
|
||||
// isEmbyPath 由 handler 层提供,用于识别 /emby/* 以及无前缀根路径形式的
|
||||
// Emby API。除路径特征外,带 X-Emby-* / X-MediaBrowser-* 等客户端凭据的
|
||||
// 请求也会被识别,因此尚未实现且没有已知路径前缀的新接口也能被记录。
|
||||
func EmbyCompatLogger(log *zap.Logger, isEmbyPath func(string) bool) gin.HandlerFunc {
|
||||
if log == nil {
|
||||
return func(c *gin.Context) { c.Next() }
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if c == nil || c.Request == nil {
|
||||
return
|
||||
}
|
||||
|
||||
requestPath := c.Request.URL.Path
|
||||
pathMatched := isEmbyPath != nil && isEmbyPath(requestPath)
|
||||
hasSignature := hasEmbyClientSignature(c)
|
||||
if !pathMatched && !hasSignature {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
|
||||
status := c.Writer.Status()
|
||||
route := c.FullPath()
|
||||
// 根路径 Emby 命名空间很宽,可能与 SPA 路由重名。没有客户端标识且
|
||||
// 最终由 SPA 返回 200 时不应污染兼容日志;服务端路由或 404 仍记录。
|
||||
if !hasSignature && !hasExplicitEmbyPrefix(requestPath) && strings.TrimSpace(route) == "" && status < http.StatusBadRequest {
|
||||
return
|
||||
}
|
||||
unimplemented := isUnimplementedEmbyRequest(c, route, status, hasSignature)
|
||||
fields := embyCompatLogFields(c, requestPath, status, route, time.Since(start), unimplemented)
|
||||
|
||||
switch {
|
||||
case unimplemented:
|
||||
log.Warn("emby API not implemented", fields...)
|
||||
case status >= http.StatusBadRequest:
|
||||
log.Warn("emby request failed", fields...)
|
||||
default:
|
||||
log.Info("emby request", fields...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isUnimplementedEmbyRequest(c *gin.Context, route string, status int, hasSignature bool) bool {
|
||||
if strings.TrimSpace(route) != "" || c.Request.Method == http.MethodOptions {
|
||||
return false
|
||||
}
|
||||
if status == http.StatusNotFound {
|
||||
return true
|
||||
}
|
||||
// 无前端 API 前缀的未知 Emby 路径可能被 SPA 兜底为 200 HTML。
|
||||
// 已识别为 Emby 客户端的这类响应同样说明兼容接口尚未实现。
|
||||
return hasSignature && status >= http.StatusOK && status < http.StatusMultipleChoices &&
|
||||
strings.Contains(strings.ToLower(c.Writer.Header().Get("Content-Type")), "text/html")
|
||||
}
|
||||
|
||||
func embyCompatLogFields(c *gin.Context, requestPath string, status int, route string, duration time.Duration, unimplemented bool) []zap.Field {
|
||||
client := embyCompatClientInfo(c)
|
||||
fields := []zap.Field{
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("path", requestPath),
|
||||
zap.String("normalized_path", c.Request.URL.Path),
|
||||
zap.String("route", route),
|
||||
zap.Int("status", status),
|
||||
zap.Duration("duration", duration),
|
||||
zap.String("ip", c.ClientIP()),
|
||||
zap.Bool("unimplemented", unimplemented),
|
||||
}
|
||||
if queryKeys := sortedQueryKeys(c); len(queryKeys) > 0 {
|
||||
fields = append(fields, zap.Strings("query_keys", queryKeys))
|
||||
}
|
||||
if client.Client != "" {
|
||||
fields = append(fields, zap.String("client", client.Client))
|
||||
}
|
||||
if client.Device != "" {
|
||||
fields = append(fields, zap.String("device", client.Device))
|
||||
}
|
||||
if client.DeviceID != "" {
|
||||
fields = append(fields, zap.String("device_id", client.DeviceID))
|
||||
}
|
||||
if client.Version != "" {
|
||||
fields = append(fields, zap.String("client_version", client.Version))
|
||||
}
|
||||
if client.UserID != "" {
|
||||
fields = append(fields, zap.String("user_id", client.UserID))
|
||||
}
|
||||
if userAgent := strings.TrimSpace(c.GetHeader("User-Agent")); userAgent != "" {
|
||||
fields = append(fields, zap.String("user_agent", userAgent))
|
||||
}
|
||||
if contentType := strings.TrimSpace(c.GetHeader("Content-Type")); contentType != "" {
|
||||
fields = append(fields, zap.String("content_type", contentType))
|
||||
}
|
||||
if errs := c.Errors.Errors(); len(errs) > 0 {
|
||||
fields = append(fields, zap.Strings("errors", truncateStrings(errs, 8, 512)))
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func hasEmbyClientSignature(c *gin.Context) bool {
|
||||
for _, name := range []string{
|
||||
"X-Emby-Token",
|
||||
"X-MediaBrowser-Token",
|
||||
"X-Emby-Authorization",
|
||||
"X-MediaBrowser-Authorization",
|
||||
"X-Emby-Client",
|
||||
"X-MediaBrowser-Client",
|
||||
"X-Emby-Device-Id",
|
||||
"X-Emby-DeviceId",
|
||||
"X-Emby-Device-Name",
|
||||
"X-Emby-Version",
|
||||
"X-Emby-UserId",
|
||||
"X-MediaBrowser-Device-Id",
|
||||
"X-MediaBrowser-DeviceId",
|
||||
"X-MediaBrowser-Device-Name",
|
||||
"X-MediaBrowser-Version",
|
||||
"X-MediaBrowser-UserId",
|
||||
} {
|
||||
if strings.TrimSpace(c.GetHeader(name)) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
auth := strings.ToLower(strings.TrimSpace(c.GetHeader("Authorization")))
|
||||
if strings.HasPrefix(auth, "emby ") || strings.HasPrefix(auth, "mediabrowser ") {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, key := range []string{"X-Emby-Token", "X-MediaBrowser-Token"} {
|
||||
if strings.TrimSpace(c.Query(key)) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
ua := strings.ToLower(strings.TrimSpace(c.GetHeader("User-Agent")))
|
||||
for _, marker := range []string{
|
||||
"emby", "jellyfin", "infuse", "senplayer", "fileball", "vidhub", "hills", "rodelplayer",
|
||||
} {
|
||||
if strings.Contains(ua, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type embyCompatClient struct {
|
||||
Client string
|
||||
Device string
|
||||
DeviceID string
|
||||
Version string
|
||||
UserID string
|
||||
}
|
||||
|
||||
func embyCompatClientInfo(c *gin.Context) embyCompatClient {
|
||||
auth := parseEmbyCompatAuthorization(firstNonEmptyString(
|
||||
c.GetHeader("X-Emby-Authorization"),
|
||||
c.GetHeader("X-MediaBrowser-Authorization"),
|
||||
c.GetHeader("Authorization"),
|
||||
))
|
||||
return embyCompatClient{
|
||||
Client: firstNonEmptyString(
|
||||
c.GetHeader("X-Emby-Client"),
|
||||
c.GetHeader("X-MediaBrowser-Client"),
|
||||
c.Query("Client"),
|
||||
c.Query("client"),
|
||||
c.Query("X-Emby-Client"),
|
||||
auth["client"],
|
||||
),
|
||||
Device: firstNonEmptyString(
|
||||
c.GetHeader("X-Emby-Device-Name"),
|
||||
c.GetHeader("X-MediaBrowser-Device-Name"),
|
||||
c.Query("Device"),
|
||||
c.Query("DeviceName"),
|
||||
c.Query("device"),
|
||||
c.Query("deviceName"),
|
||||
auth["device"],
|
||||
),
|
||||
DeviceID: firstNonEmptyString(
|
||||
c.GetHeader("X-Emby-Device-Id"),
|
||||
c.GetHeader("X-Emby-DeviceId"),
|
||||
c.GetHeader("X-MediaBrowser-Device-Id"),
|
||||
c.GetHeader("X-MediaBrowser-DeviceId"),
|
||||
c.Query("DeviceId"),
|
||||
c.Query("DeviceID"),
|
||||
c.Query("deviceId"),
|
||||
c.Query("deviceID"),
|
||||
auth["deviceid"],
|
||||
),
|
||||
Version: firstNonEmptyString(
|
||||
c.GetHeader("X-Emby-Version"),
|
||||
c.GetHeader("X-MediaBrowser-Version"),
|
||||
c.Query("Version"),
|
||||
c.Query("version"),
|
||||
auth["version"],
|
||||
),
|
||||
UserID: firstNonEmptyString(
|
||||
c.Query("UserId"),
|
||||
c.Query("userId"),
|
||||
auth["userid"],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func parseEmbyCompatAuthorization(raw string) map[string]string {
|
||||
out := map[string]string{}
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return out
|
||||
}
|
||||
if len(raw) >= len("MediaBrowser ") && strings.EqualFold(raw[:len("MediaBrowser ")], "MediaBrowser ") {
|
||||
raw = raw[len("MediaBrowser "):]
|
||||
} else if len(raw) >= len("Emby ") && strings.EqualFold(raw[:len("Emby ")], "Emby ") {
|
||||
raw = raw[len("Emby "):]
|
||||
}
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(part, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
value = strings.Trim(strings.TrimSpace(value), `"`)
|
||||
if key != "" && value != "" {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sortedQueryKeys(c *gin.Context) []string {
|
||||
query := c.Request.URL.Query()
|
||||
keys := make([]string, 0, len(query))
|
||||
for key := range query {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func truncateStrings(values []string, maxItems, maxLen int) []string {
|
||||
if len(values) > maxItems {
|
||||
values = values[:maxItems]
|
||||
}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if len(value) > maxLen {
|
||||
value = value[:maxLen] + "..."
|
||||
}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasExplicitEmbyPrefix(path string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(path))
|
||||
return lower == "/emby" || strings.HasPrefix(lower, "/emby/")
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
func TestEmbyCompatLoggerRecordsClientAndUnimplementedRequest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
core, logs := observer.New(zap.InfoLevel)
|
||||
router := gin.New()
|
||||
router.Use(EmbyCompatLogger(zap.New(core), func(path string) bool {
|
||||
return strings.HasPrefix(path, "/emby/") || strings.HasPrefix(path, "/System/")
|
||||
}))
|
||||
router.GET("/emby/System/Info/Public", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ServerName": "MeBox"})
|
||||
})
|
||||
router.NoRoute(func(c *gin.Context) {
|
||||
c.Status(http.StatusNotFound)
|
||||
})
|
||||
|
||||
successReq := httptest.NewRequest(http.MethodGet, "/emby/System/Info/Public?api_key=do-not-log", nil)
|
||||
successReq.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Infuse", Device="Apple TV", DeviceId="device-1", Version="8.2", UserId="user-1", Token="secret-token"`)
|
||||
successResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(successResp, successReq)
|
||||
if successResp.Code != http.StatusOK {
|
||||
t.Fatalf("success status = %d, want 200", successResp.Code)
|
||||
}
|
||||
|
||||
missingReq := httptest.NewRequest(http.MethodPost, "/Search/Hints", nil)
|
||||
missingReq.Header.Set("X-Emby-Token", "secret-token")
|
||||
missingResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(missingResp, missingReq)
|
||||
if missingResp.Code != http.StatusNotFound {
|
||||
t.Fatalf("missing status = %d, want 404", missingResp.Code)
|
||||
}
|
||||
|
||||
entries := logs.All()
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("logged entries = %d, want 2: %#v", len(entries), entries)
|
||||
}
|
||||
|
||||
success := entries[0].ContextMap()
|
||||
if success["path"] != "/emby/System/Info/Public" {
|
||||
t.Fatalf("success path = %#v", success["path"])
|
||||
}
|
||||
if success["client"] != "Infuse" || success["device"] != "Apple TV" || success["device_id"] != "device-1" {
|
||||
t.Fatalf("client fields not parsed: %#v", success)
|
||||
}
|
||||
if success["unimplemented"] != false {
|
||||
t.Fatalf("success unimplemented = %#v, want false", success["unimplemented"])
|
||||
}
|
||||
if _, ok := success["token"]; ok {
|
||||
t.Fatalf("token must not be logged: %#v", success)
|
||||
}
|
||||
if success["query_keys"] == nil {
|
||||
t.Fatalf("query_keys missing: %#v", success)
|
||||
}
|
||||
if strings.Contains(entries[0].Message, "secret-token") {
|
||||
t.Fatalf("token leaked in message: %q", entries[0].Message)
|
||||
}
|
||||
|
||||
missing := entries[1].ContextMap()
|
||||
if entries[1].Message != "emby API not implemented" {
|
||||
t.Fatalf("missing message = %q", entries[1].Message)
|
||||
}
|
||||
if missing["path"] != "/Search/Hints" || missing["unimplemented"] != true {
|
||||
t.Fatalf("missing fields = %#v", missing)
|
||||
}
|
||||
if missing["method"] != http.MethodPost {
|
||||
t.Fatalf("missing method = %#v", missing["method"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyCompatLoggerSkipsUnrelatedRequests(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
core, logs := observer.New(zap.InfoLevel)
|
||||
router := gin.New()
|
||||
router.Use(EmbyCompatLogger(zap.New(core), func(string) bool { return false }))
|
||||
router.GET("/api/health", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
||||
req.Header.Set("Authorization", "Bearer regular-token")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
if logs.Len() != 0 {
|
||||
t.Fatalf("unrelated request should not be logged: %#v", logs.All())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyCompatLoggerMarksSPAFallbackAsUnimplemented(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
core, logs := observer.New(zap.InfoLevel)
|
||||
router := gin.New()
|
||||
router.Use(EmbyCompatLogger(zap.New(core), func(string) bool { return false }))
|
||||
router.NoRoute(func(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte("<html></html>"))
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/NewEmbyFeature/Test", nil)
|
||||
req.Header.Set("X-Emby-Token", "secret-token")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
entries := logs.All()
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("logged entries = %d, want 1", len(entries))
|
||||
}
|
||||
if entries[0].Message != "emby API not implemented" {
|
||||
t.Fatalf("message = %q, want unimplemented warning", entries[0].Message)
|
||||
}
|
||||
if entries[0].ContextMap()["unimplemented"] != true {
|
||||
t.Fatalf("unimplemented field missing: %#v", entries[0].ContextMap())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user