feat(app): add internal components batch 3

This commit is contained in:
吴璨
2026-09-30 10:20:01 +08:00
parent fce3d91aa0
commit 713098f5b7
10 changed files with 3110 additions and 0 deletions
+397
View File
@@ -0,0 +1,397 @@
package app
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"encoding/xml"
"errors"
"image"
"image/png"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
const embyFixtureCover = "https://p3-shortvideo.byteimg.com/synthetic.png?signature=private-cover-token"
type embyPosterTestTransport func(*http.Request) (*http.Response, error)
func (transport embyPosterTestTransport) RoundTrip(request *http.Request) (*http.Response, error) {
return transport(request)
}
func embySyntheticPoster(t *testing.T) []byte {
t.Helper()
var data bytes.Buffer
if err := png.Encode(&data, image.NewRGBA(image.Rect(0, 0, 1, 1))); err != nil {
t.Fatal(err)
}
return data.Bytes()
}
func TestEmbyArchiveIncludesSignedPosterMetadataOnly(t *testing.T) {
drama := Drama{ID: historyFixtureDramaID, Title: "海报 <&>", CoverURL: embyFixtureCover}
key := bytes.Repeat([]byte{7}, 32)
body, err := buildEmbyArchive(drama, embySyncTestChapters(), "https://library.test/juku", key, "poster-owner")
if err != nil {
t.Fatal(err)
}
archive, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
if err != nil {
t.Fatal(err)
}
if len(archive.File) != 5 {
t.Fatal("export should contain only STRM and NFO files")
}
found := false
for _, file := range archive.File {
if !strings.HasSuffix(file.Name, ".strm") && !strings.HasSuffix(file.Name, ".nfo") {
t.Fatal("export created an artwork or media file", file.Name)
}
if !strings.HasSuffix(file.Name, "/tvshow.nfo") {
continue
}
reader, _ := file.Open()
var nfo struct {
Title string `xml:"title"`
Thumb struct {
URL string `xml:",chardata"`
Aspect string `xml:"aspect,attr"`
} `xml:"thumb"`
Identity struct {
Value string `xml:",chardata"`
Type string `xml:"type,attr"`
} `xml:"uniqueid"`
}
err = xml.NewDecoder(reader).Decode(&nfo)
reader.Close()
if err != nil || nfo.Title != drama.Title || nfo.Thumb.Aspect != "poster" || nfo.Identity.Type != "juku" || nfo.Identity.Value != embyMetadataID(key, drama.ID) {
t.Fatal("invalid NFO metadata", err)
}
cover, err := url.Parse(nfo.Thumb.URL)
if err != nil || cover.Scheme != "https" || cover.Host != "library.test" || cover.Path != "/juku/api/emby/cover" {
t.Fatal("cover URL does not preserve the configured reverse proxy path")
}
query := cover.Query()
if query.Get("id") != drama.ID || query.Get("account") != "poster-owner" ||
query.Get("key") != embyCoverToken(key, drama.ID, "poster-owner", query.Get("v")) || strings.Contains(nfo.Thumb.URL, "private-cover-token") {
t.Fatal("cover URL is not scoped to this account and drama")
}
found = true
}
if !found {
t.Fatal("missing tvshow.nfo")
}
drama.CoverURL = "https://untrusted.example.invalid/image"
if embyCoverURL(drama, "https://library.test", key, "poster-owner") != "" {
t.Fatal("untrusted source was included in the exported poster URL")
}
}
func TestEmbyCoverWithoutCookiesChecksSignatureAndCurrentPermissions(t *testing.T) {
app, admin := administratorFixture(t)
member := newAccountTestBrowser(t, app)
member.register(t, "poster-viewer")
store := app.browserViewers().accountStore()
owner := store.state.Accounts["poster-viewer"].ID
viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/admin/settings", map[string]bool{"requireLogin": true, "allowRegistration": true}))
app.dramas[0].CoverURL = embyFixtureCover
data := embySyntheticPoster(t)
var calls atomic.Int32
app.downloader.client.Transport = embyPosterTestTransport(func(request *http.Request) (*http.Response, error) {
calls.Add(1)
if request.URL.String() != embyFixtureCover || request.Header.Get("Referer") == "" || request.Header.Get("X-Emby-Token") != "" {
t.Error("cover request lost source headers or leaked Emby credentials")
}
return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(bytes.NewReader(data)), Request: request}, nil
})
key, err := app.embySigningKey(true)
if err != nil {
t.Fatal(err)
}
address := embyCoverURL(app.dramas[0], "http://library.test", key, owner)
request := func(handler http.Handler, method, address string) *httptest.ResponseRecorder {
result := httptest.NewRecorder()
handler.ServeHTTP(result, httptest.NewRequest(method, address, nil))
return result
}
for _, mutation := range []struct{ name, value string }{
{"id", "hongguo:7000000000000000002"}, {"account", "another-account"}, {"v", strings.Repeat("0", 32)},
{"key", embyToken(key, historyFixtureDramaID, "chapter-1", owner)}, {"key", "invalid"},
} {
parsed, _ := url.Parse(address)
query := parsed.Query()
query.Set(mutation.name, mutation.value)
parsed.RawQuery = query.Encode()
if result := request(app.routes(), http.MethodGet, parsed.String()); result.Code != 403 {
t.Fatal("forged cover was accepted", mutation.name, result.Code)
}
}
if calls.Load() != 0 {
t.Fatal("invalid signatures reached the source")
}
result := request(app.routes(), http.MethodGet, address)
if result.Code != 200 || !bytes.Equal(result.Body.Bytes(), data) || result.Header().Get("Content-Type") != "image/png" ||
result.Header().Get("Cache-Control") != "private, no-store" {
t.Fatal("signed cover was not usable without browser cookies", result.Code)
}
head := request(app.routes(), http.MethodHead, address)
if head.Code != 200 || head.Body.Len() != 0 || calls.Load() != 1 {
t.Fatal("cover HEAD/cache failed")
}
if result := request(app.routes(), http.MethodPost, address); result.Code != 405 {
t.Fatal("unsupported cover method accepted")
}
restarted := &UIApp{cfg: app.cfg, downloader: app.downloader, dramas: app.dramas}
if result := request(restarted.routes(), http.MethodGet, address); result.Code != 200 {
t.Fatal("poster link did not survive restart", result.Code)
}
viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/admin/accounts/permissions", map[string]any{"username": "poster-viewer", "onlineOnly": true}))
before := calls.Load()
if result := request(app.routes(), http.MethodGet, address); result.Code != 403 || calls.Load() != before {
t.Fatal("online-only account retained poster export access")
}
}
func TestEmbyPosterMatchesIdentityAndPortableFolderPaths(t *testing.T) {
folder, identity := "同名剧 [0123456789abcdef]", "fixture-provider-id"
for _, prefix := range []string{"/mnt/emby/", "D:\\Media\\Emby\\"} {
item, err := matchEmbySeries([]embyMediaItem{
{ID: "other", Type: "Series", Path: prefix + "同名剧 [fedcba9876543210]"},
{ID: "wanted", Type: "Series", Path: prefix + folder, ProviderIDs: map[string]string{"Juku": identity}},
}, folder, identity)
if err != nil || item.ID != "wanted" {
t.Fatal("portable folder/identity match failed", item.ID, err)
}
}
items := []embyMediaItem{
{ID: "first", Type: "Series", Path: "/one/" + folder},
{ID: "second", Type: "Series", Path: "/two/" + folder},
}
if _, err := matchEmbySeries(items, folder, identity); err == nil {
t.Fatal("ambiguous folders were matched by title")
}
items[0].ProviderIDs = map[string]string{"juku": "different-instance"}
items[1].ID = "../escape"
if item, err := matchEmbySeries(items, folder, identity); err != nil || item.ID != "" {
t.Fatal("wrong instance or unsafe item ID accepted")
}
}
func TestEmbyPostersScanDelayRetryRestartAndCustomArtwork(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}))
viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/admin/settings", map[string]bool{"requireLogin": true, "allowRegistration": true}))
app.dramas[0].CoverURL = embyFixtureCover
data := embySyntheticPoster(t)
var imageRequests atomic.Int32
app.downloader.client.Transport = embyPosterTestTransport(func(request *http.Request) (*http.Response, error) {
if request.URL.Host != "p3-shortvideo.byteimg.com" {
t.Error("unexpected external request")
return nil, errors.New("external requests forbidden")
}
imageRequests.Add(1)
return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(bytes.NewReader(data)), Request: request}, nil
})
library := httptest.NewServer(app.routes())
defer library.Close()
key, err := app.embySigningKey(true)
if err != nil {
t.Fatal(err)
}
folder := embyFolderName(app.dramas[0])
var ready, failDownload, delayConfirmation atomic.Bool
var scans, uploads atomic.Int32
var imageTag atomic.Value
imageTag.Store("")
item := func() embyMediaItem {
return embyMediaItem{ID: "series-1", Type: "Series", Path: "/container/shared/" + folder,
ProviderIDs: map[string]string{"juku": embyMetadataID(key, historyFixtureDramaID)}, ImageTags: map[string]string{"Primary": imageTag.Load().(string)}}
}
emby := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Header.Get("X-Emby-Token") != "private-api-key" || strings.Contains(request.URL.RawQuery, "private-api-key") {
t.Error("API key missing from header or exposed in URL")
}
switch request.URL.Path {
case "/emby/Library/Refresh":
scans.Add(1)
writer.WriteHeader(204)
case "/emby/Items":
result := embyItemList{}
if ready.Load() && !(request.URL.Query().Get("Ids") != "" && delayConfirmation.Load()) {
result.Items, result.TotalRecordCount = []embyMediaItem{item()}, 1
}
json.NewEncoder(writer).Encode(result)
case "/emby/Items/series-1/RemoteImages/Download":
uploads.Add(1)
if failDownload.Load() {
http.Error(writer, "sensitive upstream body private-api-key", 503)
return
}
if request.Method != http.MethodPost || request.URL.Query().Get("Type") != "Primary" || request.Header.Get("Content-Type") != "application/json" {
t.Error("invalid poster download request")
}
address := request.URL.Query().Get("ImageUrl")
if !strings.HasPrefix(address, library.URL+"/api/emby/cover?") {
t.Error("Emby was sent a source URL instead of the signed application URL")
writer.WriteHeader(400)
return
}
response, err := http.Get(address)
if err != nil {
t.Error(err)
writer.WriteHeader(502)
return
}
body, err := io.ReadAll(response.Body)
response.Body.Close()
if err != nil || response.StatusCode != 200 || !bytes.Equal(body, data) {
t.Error("Emby could not fetch the synthetic cover without cookies", response.StatusCode)
writer.WriteHeader(502)
return
}
imageTag.Store("managed-" + request.URL.Query().Get("ImageUrl"))
writer.WriteHeader(204)
default:
t.Error("unexpected Emby request", request.URL.Path)
writer.WriteHeader(404)
}
}))
defer emby.Close()
manager.document.OwnerID = app.browserViewers().accountStore().state.Accounts["admin"].ID
manager.document.Settings = embySyncSettings{Enabled: true, Following: true, IntervalMinutes: 60, OutputDir: t.TempDir(), BaseURL: library.URL, ServerURL: emby.URL + "/emby", APIKey: "private-api-key"}
forcePoster := func() {
entry := manager.document.Entries[historyFixtureDramaID]
entry.Poster.CheckedAt, entry.Poster.RetryAt = time.Time{}, time.Time{}
manager.document.Entries[historyFixtureDramaID] = entry
}
run := func() embySyncEntry {
manager.runOnce(context.Background())
return manager.document.Entries[historyFixtureDramaID]
}
first := run()
if first.Episodes != 3 || first.Error != "" || first.Poster.Status != "waiting" || scans.Load() != 1 || uploads.Load() != 0 ||
manager.document.PostersPending != 1 || time.Until(manager.document.NextRunAt) > 2*time.Minute {
t.Fatal("first scan did not queue a durable poster retry", first.Poster.Status, manager.document.Error)
}
file := filepath.Join(manager.document.Settings.OutputDir, first.Folder, "Season 01", "S01E001.strm")
before, _ := os.Stat(file)
ready.Store(true)
forcePoster()
second := run()
after, _ := os.Stat(file)
if second.Poster.Status != "synced" || uploads.Load() != 1 || imageRequests.Load() != 1 || manager.document.WrittenFiles != 0 ||
scans.Load() != 1 || !before.ModTime().Equal(after.ModTime()) {
t.Fatal("poster retry rewrote/refetched episodes or did not synchronize", second.Poster.Status, manager.document.Error)
}
forcePoster()
run()
if uploads.Load() != 1 {
t.Fatal("unchanged artwork was downloaded again")
}
restarted := &UIApp{cfg: app.cfg, downloader: app.downloader, dramas: app.dramas}
manager = restarted.embySyncer()
if manager.loadErr != nil || manager.document.Entries[historyFixtureDramaID].Poster.ImageTag == "" {
t.Fatal("restart lost managed artwork state", manager.loadErr)
}
forcePoster()
run()
if uploads.Load() != 1 || scans.Load() != 1 {
t.Fatal("restart duplicated existing artwork or files")
}
setCover := func(address string) {
app.mu.Lock()
app.dramas = append([]Drama(nil), app.dramas...)
app.dramas[0].CoverURL = address
dramas := append([]Drama(nil), app.dramas...)
app.mu.Unlock()
restarted.mu.Lock()
restarted.dramas = dramas
restarted.mu.Unlock()
}
setCover(embyFixtureCover + "&revision=2")
failDownload.Store(true)
failed := run()
if failed.Error != "" || failed.Poster.Status != "failed" || manager.document.PostersFailed != 1 ||
!strings.Contains(failed.Poster.Error, "HTTP 503") || time.Until(failed.Poster.RetryAt) < 30*time.Second {
t.Fatal("poster failure affected episode import or lost retry state", failed.Poster.Status, manager.document.Error)
}
serialized, _ := json.Marshal(manager.view())
if bytes.Contains(serialized, []byte("private-api-key")) || bytes.Contains(serialized, []byte("private-cover-token")) {
t.Fatal("sync status exposed private URLs or credentials")
}
count := uploads.Load()
run()
if uploads.Load() != count {
t.Fatal("failed artwork ignored backoff")
}
failDownload.Store(false)
delayConfirmation.Store(true)
forcePoster()
unconfirmed := run()
if unconfirmed.Poster.SyncedSource == "" || unconfirmed.Poster.Status != "failed" {
t.Fatal("successful submission with delayed confirmation was lost")
}
count = uploads.Load()
delayConfirmation.Store(false)
imageTag.Store("custom-during-confirmation-failure")
forcePoster()
if confirmed := run(); confirmed.Poster.Status != "existing" || confirmed.Poster.ImageTag != "" || uploads.Load() != count {
t.Fatal("unconfirmed image ownership overwrote or adopted custom artwork")
}
imageTag.Store("user-custom-cover")
forcePoster()
if custom := run(); custom.Poster.Status != "existing" || uploads.Load() != count {
t.Fatal("custom artwork was overwritten")
}
setCover(embyFixtureCover + "&revision=3")
if custom := run(); custom.Poster.Status != "existing" || uploads.Load() != count {
t.Fatal("metadata update overwrote custom artwork")
}
imageTag.Store("")
forcePoster()
if repaired := run(); repaired.Poster.Status != "synced" || uploads.Load() != count+1 {
t.Fatal("missing artwork was not repaired")
}
err = filepath.WalkDir(manager.document.Settings.OutputDir, func(path string, entry os.DirEntry, err error) error {
if err == nil && !entry.IsDir() && !strings.HasSuffix(path, ".strm") && !strings.HasSuffix(path, ".nfo") && entry.Name() != ".juku-emby.json" && entry.Name() != "poster.png" {
t.Error("synchronization wrote an unexpected file into the export directory", entry.Name())
}
return err
})
if err != nil {
t.Fatal(err)
}
local := filepath.Join(manager.document.Settings.OutputDir, folder, "poster.png")
if body, err := os.ReadFile(local); err != nil || !bytes.Equal(body, data) {
t.Fatal("automatic sync did not preserve the local poster", err)
}
}
func TestEmbyPosterAPIDoesNotForwardCredentialsOnRedirect(t *testing.T) {
var forwarded atomic.Int32
target := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
forwarded.Add(1)
writer.WriteHeader(200)
}))
defer target.Close()
redirect := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
http.Redirect(writer, request, target.URL, http.StatusFound)
}))
defer redirect.Close()
client := newEmbyAPIClient(embySyncSettings{ServerURL: redirect.URL, APIKey: "private-api-key"})
defer client.close()
if _, err := client.series(context.Background()); err == nil || !strings.Contains(err.Error(), "HTTP 302") || forwarded.Load() != 0 {
t.Fatal("Emby request followed a redirect with credentials", err)
}
}