feat: 网页端完整浏览/播放远程 Emby 挂载库

- 后端新增远程库→本地结构映射(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 转码等不适用操作;后台管理面板过滤远程库
This commit is contained in:
truewhile
2026-09-01 11:32:54 +08:00
parent 6af0e5fdcf
commit 4dc9cbe2e7
12 changed files with 774 additions and 25 deletions
+153 -15
View File
@@ -25,9 +25,32 @@ type createLibraryReq struct {
CreatePerSubfolder bool `json:"create_per_subfolder"`
}
// webLibraryPayload 是 /api/libraries 返回的库条目:本地库与远程 Emby 挂载库
// 统一结构(远程库附加 is_remote_emby / remote_source 只读标记)。
type webLibraryPayload struct {
model.Library
IsRemoteEmby bool `json:"is_remote_emby,omitempty"`
RemoteSource string `json:"remote_source,omitempty"`
Total int64 `json:"total,omitempty"`
Cards []service.SeriesCard `json:"cards,omitempty"`
}
// remoteLibraryItemTypes 远程库内容拉取时按 CollectionType 过滤直属条目,
// 避免电影库里的合集文件夹(Folder) 漏出为电影卡片。
func remoteLibraryItemTypes(collectionType string) string {
switch collectionType {
case "movies":
return "Movie"
case "tvshows":
return "Series"
}
return ""
}
func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
libs, err := svc.Media.ListLibraries(c.Request.Context())
ctx := c.Request.Context()
libs, err := svc.Media.ListLibraries(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -35,39 +58,81 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
role, _ := c.Get(middleware.CtxUserRole)
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1")
if !includeHidden {
libs = service.FilterDisplayCloudLibraries(c.Request.Context(), svc.Repo, libs)
libs = service.FilterDisplayCloudLibraries(ctx, svc.Repo, libs)
visibility := mediaVisibilityForRequest(c, svc)
filtered := libs[:0]
for _, lib := range libs {
if service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, lib, visibility) {
if service.LibraryVisibleForUser(ctx, svc.Repo, lib, visibility) {
filtered = append(filtered, lib)
}
}
libs = filtered
}
withPreview := c.Query("with_preview") == "1" || c.Query("with_preview") == "true"
limit := 10
if withPreview {
limit, _ := strconv.Atoi(c.DefaultQuery("preview_limit", c.DefaultQuery("limit", "10")))
limit, _ = strconv.Atoi(c.DefaultQuery("preview_limit", c.DefaultQuery("limit", "10")))
if limit <= 0 {
limit = 10
} else if limit > 100 {
limit = 100
}
previews, err := svc.Media.ListLibrariesWithPreview(c.Request.Context(), libs, mediaVisibilityForRequest(c, svc), limit)
}
out := make([]webLibraryPayload, 0, len(libs)+8)
if withPreview {
previews, err := svc.Media.ListLibrariesWithPreview(ctx, libs, mediaVisibilityForRequest(c, svc), limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, previews)
return
for _, p := range previews {
out = append(out, webLibraryPayload{Library: p.Library, Total: p.Total, Cards: p.Cards})
}
} else {
for _, l := range libs {
out = append(out, webLibraryPayload{Library: l})
}
}
c.JSON(http.StatusOK, libs)
// 远程 Emby 挂载库追加在本地库之后。
if svc.EmbyRemote != nil {
if views, err := svc.EmbyRemote.RemoteLibraries(ctx); err == nil {
for _, v := range views {
wl := webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
if withPreview {
if acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID); acct != nil {
itemTypes := remoteLibraryItemTypes(v.CollectionType)
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
wl.Total = total
}
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, acct, v.RemoteID, limit); err == nil {
wl.Cards = cards
}
}
}
out = append(out, wl)
}
}
}
c.JSON(http.StatusOK, out)
}
}
func getLibraryHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
lib, err := svc.Repo.Library.FindByID(c.Request.Context(), c.Param("id"))
ctx := c.Request.Context()
id := c.Param("id")
// 远程 Emby 挂载库详情。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
acctID, remoteID, _ := service.DecodeEmbyRemoteID(id)
view, err := svc.EmbyRemote.RemoteLibraryByID(ctx, acctID, remoteID)
if err != nil || view == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, webLibraryPayload{Library: view.Library, IsRemoteEmby: true, RemoteSource: view.AccountName})
return
}
lib, err := svc.Repo.Library.FindByID(ctx, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -79,14 +144,14 @@ func getLibraryHandler(svc *service.Container) gin.HandlerFunc {
role, _ := c.Get(middleware.CtxUserRole)
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1")
if !includeHidden {
libs := service.FilterDisplayCloudLibraries(c.Request.Context(), svc.Repo, []model.Library{*lib})
if len(libs) == 0 || !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, libs[0], mediaVisibilityForRequest(c, svc)) {
libs := service.FilterDisplayCloudLibraries(ctx, svc.Repo, []model.Library{*lib})
if len(libs) == 0 || !service.LibraryVisibleForUser(ctx, svc.Repo, libs[0], mediaVisibilityForRequest(c, svc)) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, libs[0])
c.JSON(http.StatusOK, webLibraryPayload{Library: libs[0]})
} else {
c.JSON(http.StatusOK, lib)
c.JSON(http.StatusOK, webLibraryPayload{Library: *lib})
}
}
}
@@ -232,8 +297,37 @@ func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
func listMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
ctx := c.Request.Context()
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
// 远程 Emby 库:转发远程直属条目并映射为本地 Media 结构(分页由远程承接)。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
acctID, remoteID, _ := service.DecodeEmbyRemoteID(id)
acct := svc.EmbyRemote.AccountByID(ctx, acctID)
if acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
itemTypes := ""
if view, err := svc.EmbyRemote.RemoteLibraryByID(ctx, acctID, remoteID); err == nil && view != nil {
itemTypes = remoteLibraryItemTypes(view.CollectionType)
}
items, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, acct, remoteID, itemTypes, (page-1)*size, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if items == nil {
items = []model.Media{}
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": total,
"page": page,
"page_size": size,
})
return
}
groupVersions := c.DefaultQuery("group_versions", "1") != "0"
if !groupVersions {
items, total, err := svc.Media.ListMediaVisible(c.Request.Context(), id, page, size, mediaVisibilityForRequest(c, svc))
@@ -271,7 +365,33 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
func getMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
ctx := c.Request.Context()
id := c.Param("id")
// 远程 Emby 条目:拉远程详情并映射为本地 Media 结构。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
acctID, remoteID, _ := service.DecodeEmbyRemoteID(id)
acct := svc.EmbyRemote.AccountByID(ctx, acctID)
if acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
m, err := svc.EmbyRemote.RemoteMediaDetail(ctx, acct, remoteID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if m == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, m)
return
}
m, err := svc.Media.GetMedia(ctx, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -365,7 +485,25 @@ func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
func streamHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
ctx := c.Request.Context()
id := c.Param("id")
// 远程 Emby 条目:302 直连远程流(网页播放器 video 标签跟随重定向)。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
acctID, remoteID, _ := service.DecodeEmbyRemoteID(id)
acct := svc.EmbyRemote.AccountByID(ctx, acctID)
if acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.Redirect(http.StatusFound, target)
return
}
m, err := svc.Media.GetMedia(ctx, id)
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
+85 -3
View File
@@ -65,7 +65,49 @@ func listSeasonsHandler(svc *service.Container) gin.HandlerFunc {
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 {
ctx := c.Request.Context()
// 远程剧集库:远程 Series 映射为系列卡片。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(libID) {
acctID, remoteID, _ := service.DecodeEmbyRemoteID(libID)
acct := svc.EmbyRemote.AccountByID(ctx, acctID)
if acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
cards, err := svc.EmbyRemote.RemoteSeriesCards(ctx, acct, remoteID)
if err != nil {
writeInternalOrCanceled(c, err)
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(cards) {
start = len(cards)
}
end := start + size
if end > len(cards) {
end = len(cards)
}
pageItems := cards[start:end]
if pageItems == nil {
pageItems = []service.SeriesCard{}
}
c.JSON(http.StatusOK, gin.H{
"items": pageItems,
"total": len(cards),
"page": page,
"page_size": size,
})
return
}
if lib, err := svc.Repo.Library.FindByID(ctx, 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
@@ -114,7 +156,27 @@ func listLibrarySeriesEpisodesHandler(svc *service.Container) gin.HandlerFunc {
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 {
ctx := c.Request.Context()
// 远程系列 key(伪装系列 ID):转发远程该系列全部剧集。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(key) {
acctID, remoteSeriesID, _ := service.DecodeEmbyRemoteID(key)
acct := svc.EmbyRemote.AccountByID(ctx, acctID)
if acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
items, err := svc.EmbyRemote.RemoteEpisodes(ctx, acct, remoteSeriesID)
if err != nil {
writeInternalOrCanceled(c, err)
return
}
if items == nil {
items = []model.Media{}
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
return
}
if lib, err := svc.Repo.Library.FindByID(ctx, 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
@@ -136,7 +198,27 @@ func listMediaEpisodesHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
return
}
items, err := svc.Media.ListMediaEpisodes(c.Request.Context(), id, mediaVisibilityForRequest(c, svc))
ctx := c.Request.Context()
// 远程条目:单集→同系列集列表;系列/季/文件夹→子集;电影→自身单条。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
acctID, remoteID, _ := service.DecodeEmbyRemoteID(id)
acct := svc.EmbyRemote.AccountByID(ctx, acctID)
if acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
items, err := svc.EmbyRemote.RemoteEpisodes(ctx, acct, remoteID)
if err != nil {
writeInternalOrCanceled(c, err)
return
}
if items == nil {
items = []model.Media{}
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
return
}
items, err := svc.Media.ListMediaEpisodes(ctx, id, mediaVisibilityForRequest(c, svc))
if err != nil {
writeInternalOrCanceled(c, err)
return