package app import ( "archive/zip" "bytes" "context" "errors" "io" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "sync/atomic" "testing" "time" ) func TestEmbySourceGroupingKeepsOldFoldersAndStableLinks(t *testing.T) { settings := embySyncSettings{OutputDir: t.TempDir(), BaseURL: "http://library.test"} drama := Drama{ID: historyFixtureDramaID, Source: sourceHongguo, Title: "同名剧"} key := bytes.Repeat([]byte{9}, 32) ctx := context.Background() flat, _, _, err := syncEmbyDramaFiles(ctx, settings, drama, embySyncTestChapters(), "", key, "owner") if err != nil { t.Fatal(err) } first := filepath.Join(settings.OutputDir, flat, "Season 01", "S01E001.strm") before, _ := os.ReadFile(first) settings.GroupBySource = true drama.Title = "修改后的标题" same, _, _, err := syncEmbyDramaFiles(ctx, settings, drama, embySyncTestChapters(), "", key, "owner") if err != nil || same != flat || strings.Contains(same, "/") { t.Fatal("grouping or title change duplicated an old export", same, err) } after, _ := os.ReadFile(first) if !bytes.Equal(before, after) { t.Fatal("grouping changed a previous episode link") } for _, id := range []string{"hongguo:7000000000000000002", "hongguo:7000000000000000003"} { newDrama := Drama{ID: id, Source: sourceHongguo, Title: "同名剧"} folder, _, _, err := syncEmbyDramaFiles(ctx, settings, newDrama, embySyncTestChapters(), "", key, "owner") if err != nil || !strings.HasPrefix(folder, dramaSourceFolder(newDrama)+"/") { t.Fatal("new export did not use its source folder", folder, err) } settings.GroupBySource = false newDrama.Title = "新的资料标题" recovered, _, _, err := syncEmbyDramaFiles(ctx, settings, newDrama, embySyncTestChapters(), "", key, "owner") if err != nil || recovered != folder { t.Fatal("switching grouping off moved an existing export", recovered, err) } item, err := matchEmbySeries([]embyMediaItem{{ID: "matched", Type: "Series", Path: "D:\\Emby\\" + strings.ReplaceAll(folder, "/", "\\")}}, folder, "") if err != nil || item.ID != "matched" { t.Fatal("nested Windows Emby path did not match", err) } settings.GroupBySource = true } settings.OutputDir = t.TempDir() outside := t.TempDir() if err := os.Symlink(outside, filepath.Join(settings.OutputDir, "红果")); err != nil { t.Skip("symlinks unavailable") } if _, _, _, err := syncEmbyDramaFiles(ctx, settings, drama, embySyncTestChapters(), "", key, "owner"); err == nil { t.Fatal("followed a source-folder symlink") } if entries, _ := os.ReadDir(outside); len(entries) != 0 { t.Fatal("wrote outside the export root") } } func TestEmbyMergedArchiveSignedRangesAndPermissions(t *testing.T) { app, admin := administratorFixture(t) member := newAccountTestBrowser(t, app) member.register(t, "merged-viewer") owner := app.browserViewers().accountStore().state.Accounts["merged-viewer"].ID drama := app.dramas[0] body := bytes.Repeat([]byte("synthetic-merged-media"), 30) path := filepath.Join(t.TempDir(), "merged.mp4") if err := os.WriteFile(path, body, 0600); err != nil { t.Fatal(err) } app.merges = map[string]*UIMergeState{drama.ID: {DramaID: drama.ID, DramaTitle: drama.Title, Status: "success", StartEpisode: 1, EndEpisode: 88, Merged: 88, OutputPath: path}} key, err := app.embySigningKey(true) if err != nil { t.Fatal(err) } record := app.embyMergedRecord(drama.ID) folder := "红果/" + embyFolderName(drama) archiveBody, err := buildEmbyArchiveInFolder(drama, embySyncTestChapters(), "http://library.test", key, owner, folder, record) if err != nil { t.Fatal(err) } archive, err := zip.NewReader(bytes.NewReader(archiveBody), int64(len(archiveBody))) if err != nil || len(archive.File) != 7 { t.Fatal("archive lost ordinary episodes or the merged special", err) } var address string for _, file := range archive.File { if !strings.HasPrefix(file.Name, folder+"/") { t.Fatal("archive escaped the source folder", file.Name) } reader, err := file.Open() if err != nil { t.Fatal(err) } data, _ := io.ReadAll(reader) reader.Close() if strings.HasSuffix(file.Name, "Season 00/S00E001.strm") { address = strings.TrimSpace(string(data)) } if strings.HasSuffix(file.Name, "Season 00/S00E001.nfo") && (!bytes.Contains(data, []byte("0")) || !bytes.Contains(data, []byte("第1–88集"))) { t.Fatal("merged special has incorrect NFO") } } if address == "" { t.Fatal("merged STRM missing") } request := func(target *UIApp, method, link, span string) *httptest.ResponseRecorder { writer := httptest.NewRecorder() req := httptest.NewRequest(method, link, nil) req.Header.Set("Range", span) target.routes().ServeHTTP(writer, req) return writer } for _, test := range []struct { method, span string code, length int }{{"GET", "bytes=0-15", 206, 16}, {"GET", "bytes=-16", 206, 16}, {"HEAD", "", 200, 0}, {"GET", "bytes=99999-", 416, -1}, {"POST", "", 405, -1}} { result := request(app, test.method, address, test.span) if result.Code != test.code || test.length >= 0 && result.Body.Len() != test.length { t.Fatal("merged media failed an Emby range/probe request", test, result.Code, result.Body.String()) } } app.statePath = filepath.Join(t.TempDir(), "ui-state.json") if err := app.saveStateLocked(); err != nil { t.Fatal(err) } restarted := &UIApp{cfg: app.cfg, downloader: app.downloader, statePath: app.statePath, tasks: map[string]*UITask{}} restarted.loadState() if result := request(restarted, "GET", address, "bytes=0-15"); result.Code != 206 || !bytes.Equal(result.Body.Bytes(), body[:16]) { t.Fatal("merged link did not survive restart without episode tasks", result.Code) } for _, field := range []string{"key", "account", "chapter", "id"} { parsed, _ := url.Parse(address) query := parsed.Query() query.Set(field, "forged") parsed.RawQuery = query.Encode() if result := request(app, "GET", parsed.String(), ""); result.Code != 403 { t.Fatal("forged merged link accepted", field, result.Code) } } viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/admin/accounts/permissions", map[string]any{"username": "merged-viewer", "onlineOnly": true})) if result := request(app, "GET", address, ""); result.Code != 403 { t.Fatal("revoked export permission retained merged access", result.Code) } if err := os.Rename(path, path+".moved"); err != nil { t.Fatal(err) } if app.embyMergedRecord(drama.ID) != nil { t.Fatal("missing merged file was offered for export") } } func TestEmbyMergeCompletionWakesSyncAndMergedOnlyLibrary(t *testing.T) { app, admin := administratorFixture(t) manager := app.embySyncer() viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/following", map[string]any{"dramaId": historyFixtureDramaID, "saved": true})) manager.document.OwnerID = app.browserViewers().accountStore().state.Accounts["admin"].ID manager.document.Settings = embySyncSettings{Enabled: true, Following: true, IntervalMinutes: 60, OutputDir: t.TempDir(), BaseURL: "http://library.test"} manager.runOnce(context.Background()) entry := manager.document.Entries[historyFixtureDramaID] first := filepath.Join(manager.document.Settings.OutputDir, entry.Folder, "Season 01", "S01E001.strm") before, err := os.ReadFile(first) if err != nil { t.Fatal(err) } path := filepath.Join(t.TempDir(), "merged.mp4") if err := os.WriteFile(path, []byte("completed media fixture"), 0600); err != nil { t.Fatal(err) } app.statePath = filepath.Join(t.TempDir(), "ui-state.json") app.setMergeState(uiMergeResult{DramaID: historyFixtureDramaID, DramaTitle: "已合并短剧", StartEpisode: 1, EndEpisode: 3, Merged: 3, OutputPath: path}, "success", 100, false, false) manager.runOnce(context.Background()) entry = manager.document.Entries[historyFixtureDramaID] if !entry.Merged || entry.Episodes != 3 || manager.document.WrittenFiles != 2 { t.Fatal("recently synced drama did not receive the completed merge", entry, manager.document.WrittenFiles) } if after, _ := os.ReadFile(first); !bytes.Equal(before, after) { t.Fatal("adding a merged version altered the existing episode link") } broken := app.downloader.hongguoClient().details["7000000000000000001"] broken.Chapters = nil app.downloader.hongguoClient().details["7000000000000000001"] = broken app.tasks, app.taskOrder, app.dramas = nil, nil, nil manager.document.Settings.Following, manager.document.Settings.Downloads = false, true manager.document.Settings.OutputDir = t.TempDir() manager.document.Entries = map[string]embySyncEntry{} app.cfg.GroupBySource = true manager.runOnce(context.Background()) entry = manager.document.Entries[historyFixtureDramaID] if entry.Error != "" || !entry.Merged || entry.Episodes != 0 || !strings.HasPrefix(entry.Folder, "红果/") { t.Fatal("merged-only drama was omitted from downloads sync", entry, manager.document.Error) } if _, err := os.Stat(filepath.Join(manager.document.Settings.OutputDir, entry.Folder, "Season 00", "S00E001.strm")); err != nil { t.Fatal("merged-only STRM missing", err) } } func TestEmbyMergedExportSurvivesUnavailableSource(t *testing.T) { app, admin := administratorFixture(t) drama := app.dramas[0] path := filepath.Join(t.TempDir(), "merged.mp4") if err := os.WriteFile(path, []byte("completed local merge"), 0600); err != nil { t.Fatal(err) } app.merges = map[string]*UIMergeState{drama.ID: {DramaID: drama.ID, DramaTitle: drama.Title, Status: "success", StartEpisode: 1, EndEpisode: 3, OutputPath: path}} client := app.downloader.hongguoClient() client.mu.Lock() delete(client.details, "7000000000000000001") client.mu.Unlock() var requests atomic.Int32 app.downloader.client = &http.Client{Transport: rankingTransport(func(request *http.Request) (*http.Response, error) { requests.Add(1) return rankingHTTPResponse(request, http.StatusServiceUnavailable, "synthetic unavailable source"), nil })} response := admin.request(t, http.MethodPost, "/api/emby/export", map[string]any{"dramaId": drama.ID, "baseUrl": "http://library.test"}) if response.Code != http.StatusOK || requests.Load() == 0 { t.Fatal("local merged export depended on source availability", response.Code, response.Body.String()) } archive, err := zip.NewReader(bytes.NewReader(response.Body.Bytes()), int64(response.Body.Len())) if err != nil || len(archive.File) != 3 { t.Fatal("merged-only ZIP must contain the show NFO and merged STRM/NFO", err) } manager := app.embySyncer() manager.document.OwnerID = app.browserViewers().accountStore().state.Accounts["admin"].ID manager.document.Settings = embySyncSettings{Enabled: true, Downloads: true, IntervalMinutes: 60, OutputDir: t.TempDir(), BaseURL: "http://library.test"} manager.runOnce(context.Background()) entry := manager.document.Entries[drama.ID] if entry.Error != "" || !entry.Merged || entry.Episodes != 0 { t.Fatal("automatic sync lost an offline local merge", entry) } if _, err := os.Stat(filepath.Join(manager.document.Settings.OutputDir, entry.Folder, "Season 00", "S00E001.strm")); err != nil { t.Fatal(err) } if _, _, err := app.embyExportChapters(context.Background(), drama.ID, nil); err == nil { t.Fatal("source failure was hidden without a local merge") } ctx, cancel := context.WithCancel(context.Background()) cancel() if _, _, err := app.embyExportChapters(ctx, drama.ID, app.embyMergedRecord(drama.ID)); !errors.Is(err, context.Canceled) { t.Fatal("merged export ignored cancellation", err) } } func TestEmbyLocalPosterIsStableAndPreservesCustomArtwork(t *testing.T) { app := viewerTestApp(t) drama := app.dramas[0] drama.CoverURL = embyFixtureCover settings := embySyncSettings{GroupBySource: true, OutputDir: t.TempDir(), BaseURL: "http://library.test"} ctx := context.Background() key := bytes.Repeat([]byte{8}, 32) folder, _, _, err := syncEmbyDramaFiles(ctx, settings, drama, embySyncTestChapters(), "", key, "owner") if err != nil { t.Fatal(err) } data := embySyntheticPoster(t) var requests atomic.Int32 app.downloader.client.Transport = embyPosterTestTransport(func(request *http.Request) (*http.Response, error) { requests.Add(1) return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(bytes.NewReader(data)), Request: request}, nil }) name, changed, err := app.syncLocalEmbyPoster(ctx, settings, drama, folder, embyFixtureCover) if err != nil || !changed || name != "poster.png" || !strings.HasPrefix(folder, "红果/") { t.Fatal("local artwork did not join the grouped media directory", name, changed, err) } posterPath := filepath.Join(settings.OutputDir, filepath.FromSlash(folder), name) if _, _, _, err := syncEmbyDramaFiles(ctx, settings, drama, embySyncTestChapters(), folder, key, "owner", &embyMergedRecord{1, 2}); err != nil { t.Fatal(err) } if _, changed, err := app.syncLocalEmbyPoster(ctx, settings, drama, folder, embyFixtureCover); err != nil || changed || requests.Load() != 1 { t.Fatal("core sync lost artwork ownership or rewrote the poster", err) } custom := []byte("user-edited poster") if err := os.WriteFile(posterPath, custom, 0600); err != nil { t.Fatal(err) } if _, changed, err := app.syncLocalEmbyPoster(ctx, settings, drama, folder, embyFixtureCover+"&revision=2"); err != nil || changed || requests.Load() != 1 { t.Fatal("user-modified poster was overwritten", err) } if body, _ := os.ReadFile(posterPath); !bytes.Equal(body, custom) { t.Fatal("custom image bytes changed") } settings.OutputDir = t.TempDir() folder, _, _, err = syncEmbyDramaFiles(ctx, settings, drama, embySyncTestChapters(), "", key, "owner") if err != nil { t.Fatal(err) } posterPath = filepath.Join(settings.OutputDir, filepath.FromSlash(folder), "poster.png") app.downloader.client.Transport = embyPosterTestTransport(func(request *http.Request) (*http.Response, error) { if err := os.WriteFile(posterPath, custom, 0600); err != nil { t.Error(err) } return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(bytes.NewReader(data)), Request: request}, nil }) if _, changed, err := app.syncLocalEmbyPoster(ctx, settings, drama, folder, embyFixtureCover+"&revision=during-fetch"); err != nil || changed { t.Fatal("artwork added during fetch was replaced", err) } if body, _ := os.ReadFile(posterPath); !bytes.Equal(body, custom) { t.Fatal("fetch raced with user artwork") } } func TestEmbyLocalPosterFailureRetriesWithoutLosingSTRM(t *testing.T) { app := viewerTestApp(t) manager := app.embySyncer() drama := app.dramas[0] drama.CoverURL = embyFixtureCover settings := embySyncSettings{OutputDir: t.TempDir(), BaseURL: "http://library.test", IntervalMinutes: 60} folder, _, _, err := syncEmbyDramaFiles(context.Background(), settings, drama, embySyncTestChapters(), "", make([]byte, 32), "owner") if err != nil { t.Fatal(err) } var calls atomic.Int32 app.downloader.client.Transport = embyPosterTestTransport(func(*http.Request) (*http.Response, error) { calls.Add(1) return nil, errors.New("fixture offline") }) document := embySyncDocument{Settings: settings, Entries: map[string]embySyncEntry{drama.ID: {Folder: folder, Episodes: 2}}} manager.preparePosters(&document, []Drama{drama}, make([]byte, 32)) manager.synchronizeLocalPosters(context.Background(), &document, []Drama{drama}) poster := document.Entries[drama.ID].Poster if poster.LocalError == "" || poster.Status != "failed" || time.Until(poster.LocalRetryAt) < 4*time.Minute { t.Fatal("offline poster lost bounded retry state", poster) } manager.synchronizeLocalPosters(context.Background(), &document, []Drama{drama}) if calls.Load() != 1 { t.Fatal("failed image was fetched before retry time") } if _, err := os.Stat(filepath.Join(settings.OutputDir, folder, "Season 01", "S01E001.strm")); err != nil { t.Fatal("poster failure damaged STRM", err) } poster.LocalFile, poster.LocalSource = "poster.png", embyPosterSourceHash(poster.SourceURL) poster.LocalRetryAt = time.Now().Add(-time.Second) if next := embyLocalPosterDue(poster, time.Hour); next.After(time.Now()) { t.Fatal("an existing poster suppressed the scheduled repair") } }