mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 21:06:38 +08:00
fix docker media path normalization
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -352,6 +353,44 @@ func TestListMediaVisibleUsesSpecificCloudChildLibraryAsDisplayTarget(t *testing
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetMediaUsesMappedLocalCategoryLibraryAsDisplayTarget(t *testing.T) {
|
||||||
|
containerRoot := filepath.Join(t.TempDir(), "media")
|
||||||
|
t.Setenv("MEDIASTATION_MEDIA_CONTAINER_DIR", containerRoot)
|
||||||
|
|
||||||
|
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||||||
|
repos := repository.New(db)
|
||||||
|
parent := model.Library{Name: "电视剧", Path: containerRoot, Type: "tv", Enabled: true}
|
||||||
|
child := model.Library{Name: "国产剧", Path: filepath.Join("media", "电视剧", "国产剧"), Type: "tv", Enabled: true}
|
||||||
|
for _, lib := range []*model.Library{&parent, &child} {
|
||||||
|
if err := repos.Library.Create(t.Context(), lib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mediaPath := filepath.Join(containerRoot, "电视剧", "国产剧", "剧集", "Season 01", "剧集 - S01E01.mkv")
|
||||||
|
if err := repos.DB.Create(&model.Media{
|
||||||
|
Base: model.Base{ID: "media-1"},
|
||||||
|
LibraryID: parent.ID,
|
||||||
|
Title: "剧集",
|
||||||
|
Path: mediaPath,
|
||||||
|
SeasonNum: 1,
|
||||||
|
EpisodeNum: 1,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
|
||||||
|
|
||||||
|
got, err := svc.GetMedia(t.Context(), "media-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.DisplayLibraryID != child.ID {
|
||||||
|
t.Fatalf("display library = %q, want mapped child library %q", got.DisplayLibraryID, child.ID)
|
||||||
|
}
|
||||||
|
if got.DisplayLibraryPath != filepath.Clean(filepath.Join(containerRoot, "电视剧", "国产剧")) {
|
||||||
|
t.Fatalf("display library path = %q, want mapped child path", got.DisplayLibraryPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStartAllCloudLibraryScansIncludesMergedCloudMounts(t *testing.T) {
|
func TestStartAllCloudLibraryScansIncludesMergedCloudMounts(t *testing.T) {
|
||||||
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||||||
repos := repository.New(db)
|
repos := repository.New(db)
|
||||||
|
|||||||
@@ -92,11 +92,11 @@ func (s *FileManagerService) allowedRoots() (map[string]string, error) {
|
|||||||
if label == "" {
|
if label == "" {
|
||||||
label = fmt.Sprintf("路径%d", i+1)
|
label = fmt.Sprintf("路径%d", i+1)
|
||||||
}
|
}
|
||||||
add("library:"+l.Name+":"+label, root.Path)
|
add("library:"+l.Name+":"+label, resolveMappedDestinationPath(root.Path))
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
add("library:"+l.Name, l.Path)
|
add("library:"+l.Name, resolveMappedDestinationPath(l.Path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ func (s *MediaService) attachLibraryMetadata(ctx context.Context, items []model.
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
byID := make(map[string]model.Library, len(libs))
|
byID := make(map[string]model.Library, len(libs))
|
||||||
for _, lib := range libs {
|
for i := range libs {
|
||||||
|
libs[i] = normalizeLocalLibraryPathForDisplay(libs[i])
|
||||||
|
lib := libs[i]
|
||||||
byID[lib.ID] = lib
|
byID[lib.ID] = lib
|
||||||
}
|
}
|
||||||
resolver := newMediaDisplayLibraryResolver(ctx, s.repo, libs)
|
resolver := newMediaDisplayLibraryResolver(ctx, s.repo, libs)
|
||||||
@@ -58,7 +60,8 @@ func newMediaDisplayLibraryResolver(ctx context.Context, repo *repository.Contai
|
|||||||
displayLibraries: displayLibraries,
|
displayLibraries: displayLibraries,
|
||||||
}
|
}
|
||||||
for _, lib := range libs {
|
for _, lib := range libs {
|
||||||
resolver.byID[lib.ID] = lib
|
normalized := normalizeLocalLibraryPathForDisplay(lib)
|
||||||
|
resolver.byID[normalized.ID] = normalized
|
||||||
}
|
}
|
||||||
for _, lib := range displayLibraries {
|
for _, lib := range displayLibraries {
|
||||||
resolver.displayByID[lib.ID] = lib
|
resolver.displayByID[lib.ID] = lib
|
||||||
@@ -146,13 +149,16 @@ func (r mediaDisplayLibraryResolver) bestPathDisplayLibrary(media model.Media) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
mediaPath := cleanPathForVolumeMapping(media.Path)
|
mediaPath := cleanPathForVolumeMapping(media.Path)
|
||||||
|
if isRelativeVolumeMarkerPath(media.Path) {
|
||||||
|
mediaPath = cleanPathForVolumeMapping(resolveMappedDestinationPath(media.Path))
|
||||||
|
}
|
||||||
var best model.Library
|
var best model.Library
|
||||||
bestLen := 0
|
bestLen := 0
|
||||||
for _, lib := range r.displayLibraries {
|
for _, lib := range r.displayLibraries {
|
||||||
if _, ok := ParseCloudLibraryMount(lib.Path); ok || !lib.Enabled {
|
if _, ok := ParseCloudLibraryMount(lib.Path); ok || !lib.Enabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
libPath := cleanPathForVolumeMapping(lib.Path)
|
libPath := cleanPathForVolumeMapping(resolveMappedDestinationPath(lib.Path))
|
||||||
if libPath == "" || libPath == "." {
|
if libPath == "" || libPath == "." {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -169,3 +175,17 @@ func (r mediaDisplayLibraryResolver) bestPathDisplayLibrary(media model.Media) (
|
|||||||
}
|
}
|
||||||
return model.Library{}, false
|
return model.Library{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeLocalLibraryPathForDisplay(lib model.Library) model.Library {
|
||||||
|
if _, ok := ParseCloudLibraryMount(lib.Path); ok {
|
||||||
|
return lib
|
||||||
|
}
|
||||||
|
lib.Path = resolveMappedDestinationPath(lib.Path)
|
||||||
|
for i := range lib.Roots {
|
||||||
|
if _, ok := ParseCloudLibraryMount(lib.Roots[i].Path); ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lib.Roots[i].Path = resolveMappedDestinationPath(lib.Roots[i].Path)
|
||||||
|
}
|
||||||
|
return lib
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ func resolveMappedDestinationPath(path string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
clean := filepath.Clean(path)
|
clean := filepath.Clean(path)
|
||||||
if _, err := os.Stat(clean); err == nil {
|
if _, err := os.Stat(clean); err == nil && !isRelativeVolumeMarkerPath(clean) {
|
||||||
return clean
|
return clean
|
||||||
}
|
}
|
||||||
for _, candidate := range mappedPathCandidates(clean) {
|
for _, candidate := range mappedPathCandidates(clean) {
|
||||||
@@ -97,6 +97,11 @@ func mappedPathCandidates(input string) []string {
|
|||||||
candidates = append(candidates, candidate)
|
candidates = append(candidates, candidate)
|
||||||
}
|
}
|
||||||
clean := filepath.Clean(input)
|
clean := filepath.Clean(input)
|
||||||
|
if isRelativeVolumeMarkerPath(clean) {
|
||||||
|
for _, candidate := range dockerVolumePathCandidates(clean) {
|
||||||
|
add(candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
add(clean)
|
add(clean)
|
||||||
for _, candidate := range dockerVolumePathCandidates(input) {
|
for _, candidate := range dockerVolumePathCandidates(input) {
|
||||||
add(candidate)
|
add(candidate)
|
||||||
@@ -141,6 +146,22 @@ func dockerVolumePathCandidates(path string) []string {
|
|||||||
{env: "MEDIASTATION_MEDIA_DIR", container: envOrDefault("MEDIASTATION_MEDIA_CONTAINER_DIR", "/media")},
|
{env: "MEDIASTATION_MEDIA_DIR", container: envOrDefault("MEDIASTATION_MEDIA_CONTAINER_DIR", "/media")},
|
||||||
{env: "MEDIASTATION_DOWNLOAD_DIR", container: envOrDefault("MEDIASTATION_DOWNLOAD_CONTAINER_DIR", "/downloads")},
|
{env: "MEDIASTATION_DOWNLOAD_DIR", container: envOrDefault("MEDIASTATION_DOWNLOAD_CONTAINER_DIR", "/downloads")},
|
||||||
} {
|
} {
|
||||||
|
container := cleanPathForVolumeMapping(mapping.container)
|
||||||
|
if container == "." || container == "" || strings.HasPrefix(container, ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
relativeMarker := strings.TrimPrefix(normalized, "./")
|
||||||
|
if !strings.HasPrefix(relativeMarker, "/") {
|
||||||
|
containerBase := strings.TrimSpace(filepath.Base(container))
|
||||||
|
if relativeMarker == containerBase {
|
||||||
|
addCandidate(container)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(relativeMarker, containerBase+"/") {
|
||||||
|
addCandidate(container + strings.TrimPrefix(relativeMarker, containerBase))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
host := cleanPathForVolumeMapping(os.Getenv(mapping.env))
|
host := cleanPathForVolumeMapping(os.Getenv(mapping.env))
|
||||||
if host == "." || host == "" || strings.HasPrefix(host, ".") {
|
if host == "." || host == "" || strings.HasPrefix(host, ".") {
|
||||||
continue
|
continue
|
||||||
@@ -152,10 +173,6 @@ func dockerVolumePathCandidates(path string) []string {
|
|||||||
if strings.HasPrefix(normalized, host+"/") {
|
if strings.HasPrefix(normalized, host+"/") {
|
||||||
addCandidate(mapping.container + strings.TrimPrefix(normalized, host))
|
addCandidate(mapping.container + strings.TrimPrefix(normalized, host))
|
||||||
}
|
}
|
||||||
container := cleanPathForVolumeMapping(mapping.container)
|
|
||||||
if container == "." || container == "" || strings.HasPrefix(container, ".") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if normalized == container {
|
if normalized == container {
|
||||||
addCandidate(host)
|
addCandidate(host)
|
||||||
continue
|
continue
|
||||||
@@ -187,6 +204,20 @@ func dockerVolumePathCandidates(path string) []string {
|
|||||||
return candidates
|
return candidates
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isRelativeVolumeMarkerPath(path string) bool {
|
||||||
|
normalized := cleanPathForVolumeMapping(path)
|
||||||
|
normalized = strings.TrimPrefix(normalized, "./")
|
||||||
|
if normalized == "" || strings.HasPrefix(normalized, "/") || filepath.IsAbs(path) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, marker := range []string{"media", "downloads"} {
|
||||||
|
if normalized == marker || strings.HasPrefix(normalized, marker+"/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func cleanPathForVolumeMapping(path string) string {
|
func cleanPathForVolumeMapping(path string) string {
|
||||||
path = strings.TrimSpace(path)
|
path = strings.TrimSpace(path)
|
||||||
if path == "" {
|
if path == "" {
|
||||||
|
|||||||
@@ -88,6 +88,24 @@ func TestResolveAccessibleLibraryPathKeepsAccessibleContainerPath(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveAccessibleLibraryPathMapsRelativeDockerMediaMarker(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
containerRoot := filepath.Join(root, "container", "media")
|
||||||
|
containerLibrary := filepath.Join(containerRoot, "电视剧", "国产剧")
|
||||||
|
if err := os.MkdirAll(containerLibrary, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("MEDIASTATION_MEDIA_CONTAINER_DIR", containerRoot)
|
||||||
|
|
||||||
|
got, err := resolveAccessibleLibraryPath(filepath.Join("media", "电视剧", "国产剧"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveAccessibleLibraryPath() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != filepath.Clean(containerLibrary) {
|
||||||
|
t.Fatalf("resolveAccessibleLibraryPath() = %q, want %q", got, filepath.Clean(containerLibrary))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInferLibraryKindFromCategoryPathOverridesMovieDefault(t *testing.T) {
|
func TestInferLibraryKindFromCategoryPathOverridesMovieDefault(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -142,6 +160,26 @@ func TestResolveMappedDestinationPathPrefersConfiguredContainerMapping(t *testin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveMappedDestinationPathPrefersContainerForRelativeMediaMarker(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
workDir := filepath.Join(root, "work")
|
||||||
|
containerMedia := filepath.Join(root, "container", "media")
|
||||||
|
if err := os.MkdirAll(filepath.Join(workDir, "media", "电视剧", "国产剧"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(containerMedia, "电视剧", "国产剧"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Chdir(workDir)
|
||||||
|
t.Setenv("MEDIASTATION_MEDIA_CONTAINER_DIR", containerMedia)
|
||||||
|
|
||||||
|
got := resolveMappedDestinationPath(filepath.Join("media", "电视剧", "国产剧"))
|
||||||
|
want := filepath.Join(containerMedia, "电视剧", "国产剧")
|
||||||
|
if got != filepath.Clean(want) {
|
||||||
|
t.Fatalf("resolveMappedDestinationPath() = %q, want %q", got, filepath.Clean(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveAccessibleMappedPathMapsWindowsDownloadVariants(t *testing.T) {
|
func TestResolveAccessibleMappedPathMapsWindowsDownloadVariants(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
containerDownloads := filepath.Join(root, "container", "downloads")
|
containerDownloads := filepath.Join(root, "container", "downloads")
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ func (o *OrganizerService) OrganizeLibraryWithOptions(ctx context.Context, libra
|
|||||||
return nil, errors.New("organize source must be a local directory; cloud libraries should be managed from external storage scan/mount")
|
return nil, errors.New("organize source must be a local directory; cloud libraries should be managed from external storage scan/mount")
|
||||||
}
|
}
|
||||||
// 目的地目录:已位于该根下的文件视为已整理;受 dest_path 覆盖与设置影响。
|
// 目的地目录:已位于该根下的文件视为已整理;受 dest_path 覆盖与设置影响。
|
||||||
baseRoot := normalizeOrganizeDestinationRoot(o.resolveBaseRoot(ctx, lib, opts.DestPath))
|
baseRoot := normalizeMappedOrganizeDestinationRoot(o.resolveBaseRoot(ctx, lib, opts.DestPath))
|
||||||
if _, ok := ParseCloudLibraryMount(baseRoot); ok {
|
if _, ok := ParseCloudLibraryMount(baseRoot); ok {
|
||||||
return nil, errors.New("organize destination must be a local writable media directory; enable cloud transfer in external storage when writing to cloud")
|
return nil, errors.New("organize destination must be a local writable media directory; enable cloud transfer in external storage when writing to cloud")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func (o *OrganizerService) organizeLibraryForLayout(ctx context.Context, destRoo
|
|||||||
}
|
}
|
||||||
return model.Library{}, false
|
return model.Library{}, false
|
||||||
}
|
}
|
||||||
destRoot = filepath.Clean(strings.TrimSpace(destRoot))
|
destRoot = normalizeMappedOrganizeDestinationRoot(destRoot)
|
||||||
mediaType = normalizeOrganizeMediaType(mediaType)
|
mediaType = normalizeOrganizeMediaType(mediaType)
|
||||||
aliases := o.organizeCategoryAliases(mediaType, category)
|
aliases := o.organizeCategoryAliases(mediaType, category)
|
||||||
|
|
||||||
@@ -48,6 +48,7 @@ func (o *OrganizerService) organizeLibraryForLayout(ctx context.Context, destRoo
|
|||||||
// "手动整理"等暂存库不作为入库目标,避免把媒体留在暂存目录里。
|
// "手动整理"等暂存库不作为入库目标,避免把媒体留在暂存目录里。
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
lib.Path = resolveMappedDestinationPath(lib.Path)
|
||||||
scopeScore, inScope := o.organizeLibraryTargetScopeScore(lib.Path, destRoot, mediaType, category)
|
scopeScore, inScope := o.organizeLibraryTargetScopeScore(lib.Path, destRoot, mediaType, category)
|
||||||
if !inScope {
|
if !inScope {
|
||||||
continue
|
continue
|
||||||
@@ -183,7 +184,7 @@ func (o *OrganizerService) ensureOrganizeLibraryForRoot(ctx context.Context, roo
|
|||||||
if o == nil || o.repo == nil || o.repo.Library == nil {
|
if o == nil || o.repo == nil || o.repo.Library == nil {
|
||||||
return model.Library{}, false
|
return model.Library{}, false
|
||||||
}
|
}
|
||||||
root = filepath.Clean(strings.TrimSpace(root))
|
root = resolveMappedDestinationPath(root)
|
||||||
if root == "" || root == "." {
|
if root == "" || root == "." {
|
||||||
return model.Library{}, false
|
return model.Library{}, false
|
||||||
}
|
}
|
||||||
@@ -206,7 +207,7 @@ func (o *OrganizerService) ensureOrganizeLibraryForRoot(ctx context.Context, roo
|
|||||||
if _, ok := ParseCloudLibraryMount(lib.Path); ok {
|
if _, ok := ParseCloudLibraryMount(lib.Path); ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
lib.Path = filepath.Clean(lib.Path)
|
lib.Path = resolveMappedDestinationPath(lib.Path)
|
||||||
if strings.EqualFold(lib.Path, root) {
|
if strings.EqualFold(lib.Path, root) {
|
||||||
return lib, true
|
return lib, true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func (o *OrganizerService) resolveOrganizeMediaRequest(ctx context.Context, medi
|
|||||||
}
|
}
|
||||||
requestedBaseRoot := o.resolveBaseRoot(ctx, lib, opts.DestPath)
|
requestedBaseRoot := o.resolveBaseRoot(ctx, lib, opts.DestPath)
|
||||||
mediaType, mediaCategory := o.effectiveOrganizeOverrides(opts, requestedBaseRoot)
|
mediaType, mediaCategory := o.effectiveOrganizeOverrides(opts, requestedBaseRoot)
|
||||||
baseRoot := normalizeOrganizeDestinationRoot(requestedBaseRoot)
|
baseRoot := normalizeMappedOrganizeDestinationRoot(requestedBaseRoot)
|
||||||
if _, ok := ParseCloudLibraryMount(baseRoot); ok {
|
if _, ok := ParseCloudLibraryMount(baseRoot); ok {
|
||||||
return organizeMediaRequest{}, errors.New("organize destination must be a local writable media directory; enable cloud transfer in external storage when writing to cloud")
|
return organizeMediaRequest{}, errors.New("organize destination must be a local writable media directory; enable cloud transfer in external storage when writing to cloud")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,6 +235,10 @@ func normalizeOrganizeDestinationRoot(root string) string {
|
|||||||
return cleaned
|
return cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeMappedOrganizeDestinationRoot(root string) string {
|
||||||
|
return normalizeOrganizeDestinationRoot(resolveMappedDestinationPath(root))
|
||||||
|
}
|
||||||
|
|
||||||
func pathAlreadyEndsWith(path, suffix string) bool {
|
func pathAlreadyEndsWith(path, suffix string) bool {
|
||||||
base := strings.TrimSpace(filepath.Base(filepath.Clean(path)))
|
base := strings.TrimSpace(filepath.Base(filepath.Clean(path)))
|
||||||
return strings.EqualFold(base, suffix)
|
return strings.EqualFold(base, suffix)
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ func (o *OrganizerService) reclassifyScannedMedia(ctx context.Context, media mod
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
baseRoot := normalizeOrganizeDestinationRoot(requestedBaseRoot)
|
baseRoot := normalizeMappedOrganizeDestinationRoot(requestedBaseRoot)
|
||||||
targetLibrary, matched := o.organizeLibraryForLayout(ctx, baseRoot, mediaType, category)
|
targetLibrary, matched := o.organizeLibraryForLayout(ctx, baseRoot, mediaType, category)
|
||||||
if !matched || strings.TrimSpace(targetLibrary.ID) == "" || strings.TrimSpace(targetLibrary.Path) == "" {
|
if !matched || strings.TrimSpace(targetLibrary.ID) == "" || strings.TrimSpace(targetLibrary.Path) == "" {
|
||||||
targetRoot := categoryRoot(o.organizeRoot(baseRoot, mediaType, category), category)
|
targetRoot := categoryRoot(o.organizeRoot(baseRoot, mediaType, category), category)
|
||||||
|
|||||||
@@ -88,6 +88,12 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
|||||||
// Boot 启动后台工作进程(watcher, downloads poller, subscription scheduler)。
|
// Boot 启动后台工作进程(watcher, downloads poller, subscription scheduler)。
|
||||||
// 在 AutoMigrate 后调用一次。
|
// 在 AutoMigrate 后调用一次。
|
||||||
func (c *Container) Boot() {
|
func (c *Container) Boot() {
|
||||||
|
if err := c.NormalizeLocalLibraryPaths(c.stopCtx); err != nil {
|
||||||
|
c.Log.Warn("normalize local library paths failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
if err := c.NormalizeCloudLibraryTypes(c.stopCtx); err != nil {
|
||||||
|
c.Log.Warn("normalize cloud library types failed", zap.Error(err))
|
||||||
|
}
|
||||||
if err := c.Watcher.Start(c.stopCtx); err != nil {
|
if err := c.Watcher.Start(c.stopCtx); err != nil {
|
||||||
c.Log.Warn("watcher start failed", zap.Error(err))
|
c.Log.Warn("watcher start failed", zap.Error(err))
|
||||||
}
|
}
|
||||||
@@ -96,9 +102,6 @@ func (c *Container) Boot() {
|
|||||||
if err := c.APIConfig.SeedDefaults(c.stopCtx); err != nil {
|
if err := c.APIConfig.SeedDefaults(c.stopCtx); err != nil {
|
||||||
c.Log.Warn("api config seed failed", zap.Error(err))
|
c.Log.Warn("api config seed failed", zap.Error(err))
|
||||||
}
|
}
|
||||||
if err := c.NormalizeCloudLibraryTypes(c.stopCtx); err != nil {
|
|
||||||
c.Log.Warn("normalize cloud library types failed", zap.Error(err))
|
|
||||||
}
|
|
||||||
go c.warmMediaSearchIndex(c.stopCtx)
|
go c.warmMediaSearchIndex(c.stopCtx)
|
||||||
|
|
||||||
// 加载所有已配置的下载客户端
|
// 加载所有已配置的下载客户端
|
||||||
|
|||||||
@@ -186,12 +186,12 @@ func (b *serviceContainerBuilder) libraryRoots() []string {
|
|||||||
if !root.Enabled || strings.TrimSpace(root.Path) == "" {
|
if !root.Enabled || strings.TrimSpace(root.Path) == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
roots = append(roots, root.Path)
|
roots = append(roots, resolveMappedDestinationPath(root.Path))
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(l.Path) != "" {
|
if strings.TrimSpace(l.Path) != "" {
|
||||||
roots = append(roots, l.Path)
|
roots = append(roots, resolveMappedDestinationPath(l.Path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return roots
|
return roots
|
||||||
|
|||||||
@@ -2,10 +2,82 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func (c *Container) NormalizeLocalLibraryPaths(ctx context.Context) error {
|
||||||
|
if c == nil || c.Repo == nil || c.Repo.Library == nil || c.Repo.DB == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
libs, err := c.Repo.Library.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, lib := range libs {
|
||||||
|
if _, ok := ParseCloudLibraryMount(lib.Path); ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(lib.Roots) == 0 {
|
||||||
|
normalized := normalizePersistedLocalLibraryPath(lib.Path)
|
||||||
|
if sameLibraryPath(normalized, lib.Path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := c.Repo.DB.WithContext(ctx).
|
||||||
|
Model(&model.Library{}).
|
||||||
|
Where("id = ?", lib.ID).
|
||||||
|
Update("path", normalized).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
primaryPath := ""
|
||||||
|
for i, root := range lib.Roots {
|
||||||
|
if _, ok := ParseCloudLibraryMount(root.Path); ok {
|
||||||
|
if i == 0 {
|
||||||
|
primaryPath = root.Path
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
normalized := normalizePersistedLocalLibraryPath(root.Path)
|
||||||
|
if i == 0 {
|
||||||
|
primaryPath = normalized
|
||||||
|
}
|
||||||
|
if sameLibraryPath(normalized, root.Path) || strings.TrimSpace(root.ID) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := c.Repo.DB.WithContext(ctx).
|
||||||
|
Model(&model.LibraryRoot{}).
|
||||||
|
Where("id = ?", root.ID).
|
||||||
|
Update("path", normalized).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(primaryPath) != "" && !sameLibraryPath(primaryPath, lib.Path) {
|
||||||
|
if err := c.Repo.DB.WithContext(ctx).
|
||||||
|
Model(&model.Library{}).
|
||||||
|
Where("id = ?", lib.ID).
|
||||||
|
Update("path", primaryPath).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePersistedLocalLibraryPath(pathValue string) string {
|
||||||
|
pathValue = strings.TrimSpace(pathValue)
|
||||||
|
if pathValue == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !isRelativeVolumeMarkerPath(pathValue) {
|
||||||
|
return filepath.Clean(pathValue)
|
||||||
|
}
|
||||||
|
return resolveMappedDestinationPath(pathValue)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Container) NormalizeCloudLibraryTypes(ctx context.Context) error {
|
func (c *Container) NormalizeCloudLibraryTypes(ctx context.Context) error {
|
||||||
if c == nil || c.Repo == nil || c.Repo.Library == nil || c.Repo.DB == nil {
|
if c == nil || c.Repo == nil || c.Repo.Library == nil || c.Repo.DB == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeLocalLibraryPathsRewritesRelativeDockerMediaRoot(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
containerRoot := filepath.Join(root, "container", "media")
|
||||||
|
containerLibrary := filepath.Join(containerRoot, "电视剧", "国产剧")
|
||||||
|
if err := os.MkdirAll(containerLibrary, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("MEDIASTATION_MEDIA_CONTAINER_DIR", containerRoot)
|
||||||
|
|
||||||
|
db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{})
|
||||||
|
repos := repository.New(db)
|
||||||
|
lib := model.Library{Name: "国产剧", Path: filepath.Join("media", "电视剧", "国产剧"), Type: "tv", Enabled: true}
|
||||||
|
if err := repos.Library.CreateWithRoots(t.Context(), &lib, []model.LibraryRoot{{
|
||||||
|
Name: "国产剧",
|
||||||
|
Path: filepath.Join("media", "电视剧", "国产剧"),
|
||||||
|
Enabled: true,
|
||||||
|
}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := &Container{Repo: repos}
|
||||||
|
if err := svc.NormalizeLocalLibraryPaths(t.Context()); err != nil {
|
||||||
|
t.Fatalf("NormalizeLocalLibraryPaths() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := repos.Library.FindByID(t.Context(), lib.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := filepath.Clean(containerLibrary)
|
||||||
|
if got.Path != want {
|
||||||
|
t.Fatalf("library path = %q, want %q", got.Path, want)
|
||||||
|
}
|
||||||
|
if len(got.Roots) != 1 || got.Roots[0].Path != want {
|
||||||
|
t.Fatalf("library roots = %#v, want path %q", got.Roots, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizePersistedLocalLibraryPathKeepsEmptyPathEmpty(t *testing.T) {
|
||||||
|
if got := normalizePersistedLocalLibraryPath(""); got != "" {
|
||||||
|
t.Fatalf("normalizePersistedLocalLibraryPath(empty) = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user