Files
ggjuku/internal/app/emby_feedback_test.go
2026-09-30 10:19:54 +08:00

355 lines
16 KiB
Go
Raw Permalink 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 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("<season>0</season>")) || !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")
}
}