mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
4dc9cbe2e7
- 后端新增远程库→本地结构映射(emby_remote_web.go):Library/Media/SeriesCard 与本地完全同构,前端无感知 - /api/libraries(含 with_preview)、/api/libraries/:id、/api/libraries/:id/media、 /api/libraries/:id/series、/api/libraries/:id/series/episodes、/api/media/:id、 /api/media/:id/episodes 全部支持远程伪装 ID 透传映射 - /api/stream/:id 对远程条目 302 直连远程 Emby 原地址(播放不经过本机) - 库内容按 CollectionType 过滤(电影库取 Movie、剧集库取 Series), RecursiveItemCount 作为系列集数;修复伪装 ID 双重编码导致的图片/详情 500 - SPA fallback 放行 /library/embyremote~ 前缀(不再被当作 Emby API 路由 404) - 前端:远程库/条目标记 is_remote_emby,隐藏扫描/刮削/编辑/NFO/回收站/ HLS 转码等不适用操作;后台管理面板过滤远程库
198 lines
5.1 KiB
Go
198 lines
5.1 KiB
Go
package main
|
||
|
||
import (
|
||
"io/fs"
|
||
"mime"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"go.uber.org/zap"
|
||
|
||
"github.com/ShukeBta/MMTL/internal/config"
|
||
"github.com/ShukeBta/MMTL/internal/handler"
|
||
"github.com/ShukeBta/MMTL/internal/middleware"
|
||
"github.com/ShukeBta/MMTL/internal/service"
|
||
|
||
"github.com/ShukeBta/MMTL/web"
|
||
)
|
||
|
||
func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container) *gin.Engine {
|
||
if !cfg.App.Debug {
|
||
gin.SetMode(gin.ReleaseMode)
|
||
}
|
||
r := gin.New()
|
||
r.Use(gin.Recovery())
|
||
r.Use(middleware.RequestLogger(logger))
|
||
if !cfg.App.Debug && len(cfg.App.CORSOrigins) == 0 {
|
||
logger.Warn("CORS: no origins configured in production — CORS headers will be omitted (same-origin enforced). Set app.cors_origins for cross-origin access.")
|
||
}
|
||
r.Use(middleware.CORS(cfg.App.CORSOrigins, cfg.App.Debug))
|
||
|
||
handler.Register(r, cfg, logger, svc)
|
||
|
||
// Prefer a directory on disk when configured explicitly (e.g. the Docker image
|
||
// mounts web/dist from the build stage, or an operator overrides app.web_dir
|
||
// with a custom skin). Otherwise fall back to the SPA embedded into the binary,
|
||
// which is what makes the cross-platform single-file artifacts work.
|
||
uiFS := webui.DistFS()
|
||
if dir := cfg.App.WebDir; dir != "" {
|
||
disk := os.DirFS(dir)
|
||
if index, err := fs.Stat(disk, "index.html"); err == nil && !index.IsDir() {
|
||
uiFS = disk
|
||
}
|
||
}
|
||
serveSPA(r, uiFS)
|
||
return r
|
||
}
|
||
|
||
// serveSPA serves the React build artifacts and falls back to index.html for
|
||
// non-API, non-asset paths so client-side routing keeps working. The UI tree
|
||
// comes from root, which is either the compiled-in SPA or an on-disk web dir.
|
||
func serveSPA(r *gin.Engine, root fs.FS) {
|
||
assets := r.Group("/assets")
|
||
assets.Use(func(c *gin.Context) {
|
||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||
c.Next()
|
||
})
|
||
assets.GET("/*filepath", serveFSDir(root, "assets"))
|
||
brand := r.Group("/brand")
|
||
brand.Use(func(c *gin.Context) {
|
||
setNoCacheHeaders(c)
|
||
c.Next()
|
||
})
|
||
brand.GET("/*filepath", serveFSDir(root, "brand"))
|
||
for _, rootFile := range []string{"/favicon.ico", "/favicon.svg", "/artwork-cache-sw.js"} {
|
||
name := strings.TrimPrefix(rootFile, "/")
|
||
r.GET(rootFile, serveFSFile(root, name))
|
||
r.HEAD(rootFile, serveFSFile(root, name))
|
||
}
|
||
r.NoRoute(func(c *gin.Context) {
|
||
path := c.Request.URL.Path
|
||
if shouldBypassSPAFallback(path) {
|
||
c.Status(http.StatusNotFound)
|
||
return
|
||
}
|
||
setNoCacheHeaders(c)
|
||
data, err := fs.ReadFile(root, "index.html")
|
||
if err != nil {
|
||
c.String(http.StatusNotFound, "MMTL web UI not found")
|
||
return
|
||
}
|
||
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
|
||
})
|
||
}
|
||
|
||
// serveFSDir serves a static subdirectory of root. A missing asset returns 404.
|
||
func serveFSDir(root fs.FS, dir string) gin.HandlerFunc {
|
||
sub, err := fs.Sub(root, dir)
|
||
if err != nil {
|
||
return func(c *gin.Context) { c.Status(http.StatusNotFound) }
|
||
}
|
||
handler := http.StripPrefix("/"+dir, http.FileServerFS(sub))
|
||
return func(c *gin.Context) {
|
||
handler.ServeHTTP(c.Writer, c.Request)
|
||
}
|
||
}
|
||
|
||
// serveFSFile serves a single root-level file (favicon / service worker) with
|
||
// no-cache headers. It reads from root, which may be the embedded SPA or disk.
|
||
func serveFSFile(root fs.FS, name string) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
setNoCacheHeaders(c)
|
||
data, err := fs.ReadFile(root, name)
|
||
if err != nil {
|
||
c.Status(http.StatusNotFound)
|
||
return
|
||
}
|
||
c.Data(http.StatusOK, mimeTypeByName(name), data)
|
||
}
|
||
}
|
||
|
||
// mimeTypeByName returns an HTTP content type guessed from a file extension.
|
||
func mimeTypeByName(name string) string {
|
||
switch mime.TypeByExtension(filepath.Ext(name)) {
|
||
case "":
|
||
return "application/octet-stream"
|
||
default:
|
||
return mime.TypeByExtension(filepath.Ext(name))
|
||
}
|
||
}
|
||
|
||
func setNoCacheHeaders(c *gin.Context) {
|
||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||
c.Header("Pragma", "no-cache")
|
||
c.Header("Expires", "0")
|
||
}
|
||
|
||
func shouldBypassSPAFallback(path string) bool {
|
||
if isFrontendLibraryRoute(path) {
|
||
return false
|
||
}
|
||
lower := strings.ToLower(path)
|
||
for _, exact := range []string{
|
||
"/emby",
|
||
} {
|
||
if lower == exact {
|
||
return true
|
||
}
|
||
}
|
||
for _, prefix := range []string{
|
||
"/api/",
|
||
"/emby/",
|
||
"/system/",
|
||
"/users/",
|
||
"/items/",
|
||
"/shows/",
|
||
"/library/",
|
||
"/videos/",
|
||
"/sessions/",
|
||
"/displaypreferences/",
|
||
"/branding/",
|
||
"/localization/",
|
||
"/startup/",
|
||
"/quickconnect/",
|
||
"/socket",
|
||
"/embywebsocket",
|
||
} {
|
||
if strings.HasPrefix(lower, prefix) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func isFrontendLibraryRoute(path string) bool {
|
||
const prefix = "/library/"
|
||
if !strings.HasPrefix(path, prefix) {
|
||
return false
|
||
}
|
||
id := strings.TrimPrefix(path, prefix)
|
||
if strings.Contains(id, "/") {
|
||
return false
|
||
}
|
||
// 远程 Emby 挂载库的伪装 ID(embyremote~account~remote)也是前端库路由,
|
||
// 需要交给 SPA 而非当作 Emby API 路径 404。
|
||
if strings.HasPrefix(id, "embyremote~") {
|
||
return true
|
||
}
|
||
if len(id) != 36 {
|
||
return false
|
||
}
|
||
for i, ch := range id {
|
||
switch i {
|
||
case 8, 13, 18, 23:
|
||
if ch != '-' {
|
||
return false
|
||
}
|
||
default:
|
||
if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F')) {
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
return true
|
||
}
|