Files
MeBox/internal/handler/emby_routes.go
T

290 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"time"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/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, jwtSecret, svc)
registerEmbyPublicImageRoutes(grp, svc)
// 鉴权后端点
auth := grp.Group("", embyAuthRequiredWithSessionFallback(jwtSecret), activeEmbyUserRequired(svc), embyRealtimeSessionActivity(svc))
registerEmbyAuthenticatedRoutes(auth, prefix, svc)
}
// 为没有设置 NoRoute 的 Engine 提供 Emby 路径前缀与大小写纠偏(如单测或独立挂载环境)
r.NoRoute(func(c *gin.Context) {
if TryHandleEmbyNormalizedRoute(c, r) {
return
}
c.Status(404)
})
}
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, jwtSecret string, svc *service.Container) {
registerEmbyPublicSystemRoutes(grp, svc)
registerEmbyPublicSessionRoutes(grp, jwtSecret, svc)
registerEmbyPublicClientRoutes(grp, jwtSecret, 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, jwtSecret string, svc *service.Container) {
for _, path := range []string{
"/Sessions/Capabilities", "/Sessions/Capabilities/Full",
"/sessions/capabilities", "/sessions/capabilities/full",
} {
grp.POST(path, embySessionCapabilitiesHandler(svc, jwtSecret))
}
// 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, jwtSecret string, 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, jwtSecret))
grp.HEAD(path, embyNoContentHandler(svc))
}
for _, path := range []string{"/Sessions/Logout", "/sessions/logout"} {
grp.POST(path, embySessionLogoutHandler(svc, jwtSecret))
}
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)
//
// 不带 Type 的 /Items/{Id}/Images 返回图片清单(ImageInfo 数组),与下面
// 带 Type 的图片字节流是不同接口,必须单独注册,否则会落到 NoRoute 并
// 返回 text/plain 的 404。
grp.GET("/Items/:id/Images", embyItemImagesHandler(svc))
grp.HEAD("/Items/:id/Images", embyItemImagesHandler(svc))
grp.GET("/items/:id/images", embyItemImagesHandler(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))
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))
// 官方 Emby 客户端也可能使用 /Persons/{Name}/Images/{Type} 获取演职人员头像。
grp.GET("/Persons/:name/Images/:type", embyPersonImageHandler(svc))
grp.GET("/Persons/:name/Images/:type/:index", embyPersonImageHandler(svc))
grp.HEAD("/Persons/:name/Images/:type", embyPersonImageHandler(svc))
grp.GET("/persons/:name/images/:type", embyPersonImageHandler(svc))
grp.GET("/persons/:name/images/:type/:index", embyPersonImageHandler(svc))
grp.HEAD("/persons/:name/images/:type", embyPersonImageHandler(svc))
// 用户头像。没有头像时返回带缓存头的 404,避免客户端反复重试。
grp.GET("/Users/:userId/Images/:type", embyUserImageHandler(svc))
grp.HEAD("/Users/:userId/Images/:type", embyUserImageHandler(svc))
grp.GET("/users/:userId/images/:type", embyUserImageHandler(svc))
grp.HEAD("/users/:userId/images/:type", embyUserImageHandler(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("/Users/:userId/Items/Resume", embyResumeItemsHandler(svc))
auth.GET("/UserItems/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")
// Emby 官方接口:附加片段清单。MeBox 不提供附加片段,但必须返回空数组
// 而不是 404 —— 部分客户端(RodelPlayer)在详情页无条件请求它,404 会
// 让它们把条目判定为不完整。必须注册成静态段,否则会被
// /Videos/:id/:seg 的 HLS 兜底路由抢先匹配并返回空 404。
auth.GET("/Videos/:id/AdditionalParts", embyEmptyArrayHandler(svc))
auth.HEAD("/Videos/:id/AdditionalParts", embyEmptyArrayHandler(svc))
auth.GET("/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
auth.HEAD("/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
auth.GET("/Users/:userId/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
auth.HEAD("/Users/:userId/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
// Official Emby/Swagger shape:
// /Videos/{Id}/{MediaSourceId}/Subtitles/{Index}/Stream.{Format}
// The mediaSourceId segment is a bare path param. We name it :seg (matching
// the HLS /Videos/:id/:seg catch-all) so gin allows the two routes to
// coexist — gin permits the same :param name to be both terminal and parent
// of children, but rejects two different param names at the same position.
// The subtitle handler ignores this segment (lookup is by :id + :index).
auth.GET("/Videos/:id/:seg/Subtitles/:index/Stream.:format", embySubtitleStreamHandler(svc))
auth.HEAD("/Videos/:id/:seg/Subtitles/:index/Stream.:format", embySubtitleStreamHandler(svc))
auth.GET("/Users/:userId/Videos/:id/:seg/Subtitles/:index/Stream.:format", embySubtitleStreamHandler(svc))
auth.HEAD("/Users/:userId/Videos/:id/:seg/Subtitles/:index/Stream.:format", embySubtitleStreamHandler(svc))
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))
}