feat(app): add internal components batch 2
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDownloadGroupingKeepsExistingDirectoriesAcrossChanges(t *testing.T) {
|
||||
cfg := defaultConfig()
|
||||
cfg.OutputDir = t.TempDir()
|
||||
d := &Downloader{cfg: cfg}
|
||||
old := Drama{ID: historyFixtureDramaID, Source: sourceHongguo, Title: "同名剧"}
|
||||
flat, err := d.downloadDramaDirectory(old, old.Title, "")
|
||||
if err != nil || flat != filepath.Join(cfg.OutputDir, old.Title) {
|
||||
t.Fatal("default layout changed", flat, err)
|
||||
}
|
||||
sentinel := filepath.Join(flat, "001.mp4")
|
||||
if err := os.WriteFile(sentinel, []byte("original episode"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grouped := true
|
||||
d.downloadGrouping = &grouped
|
||||
old.Title = "更新后的剧名"
|
||||
if same, err := d.downloadDramaDirectory(old, old.Title, ""); err != nil || same != flat {
|
||||
t.Fatal("enabling grouping or renaming moved an existing drama", same, err)
|
||||
}
|
||||
var groupedPaths []string
|
||||
for index, id := range []string{"hongguo:7000000000000000002", "hongguo:7000000000000000003"} {
|
||||
drama := Drama{ID: id, Source: sourceHongguo, Title: "同名剧"}
|
||||
directory, err := d.downloadDramaDirectory(drama, drama.Title, "")
|
||||
want := filepath.Join(cfg.OutputDir, "红果", drama.Title)
|
||||
if index > 0 {
|
||||
want += "_" + hashShort(drama.ID)
|
||||
}
|
||||
if err != nil || directory != want {
|
||||
t.Fatal("new drama did not use its source directory", directory, err)
|
||||
}
|
||||
groupedPaths = append(groupedPaths, directory)
|
||||
}
|
||||
if groupedPaths[0] == groupedPaths[1] {
|
||||
t.Fatal("same title from different drama IDs collided")
|
||||
}
|
||||
|
||||
d = &Downloader{cfg: cfg}
|
||||
if same, err := d.downloadDramaDirectory(old, old.Title, ""); err != nil || same != flat {
|
||||
t.Fatal("restart lost the original flat directory", same, err)
|
||||
}
|
||||
for index, id := range []string{"hongguo:7000000000000000002", "hongguo:7000000000000000003"} {
|
||||
drama := Drama{ID: id, Source: sourceHongguo}
|
||||
if same, err := d.downloadDramaDirectory(drama, "再次改名", ""); err != nil || same != groupedPaths[index] {
|
||||
t.Fatal("disabling grouping moved an existing grouped drama", same, err)
|
||||
}
|
||||
}
|
||||
if body, _ := os.ReadFile(sentinel); string(body) != "original episode" {
|
||||
t.Fatal("directory changes touched existing media")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadGroupingRejectsSourceSymlinksAndDramaCollision(t *testing.T) {
|
||||
for _, kind := range []string{"symlink", "existing-drama"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
cfg := defaultConfig()
|
||||
cfg.OutputDir, cfg.GroupBySource = t.TempDir(), true
|
||||
drama := Drama{ID: historyFixtureDramaID, Source: sourceHongguo}
|
||||
group := filepath.Join(cfg.OutputDir, dramaSourceFolder(drama))
|
||||
outside := t.TempDir()
|
||||
if kind == "symlink" {
|
||||
if err := os.Symlink(outside, group); err != nil {
|
||||
t.Skip("symlinks unavailable")
|
||||
}
|
||||
} else {
|
||||
if ok, err := claimDownloadDirectory(group, "older-drama", false); err != nil || !ok {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := (&Downloader{cfg: cfg}).downloadDramaDirectory(drama, "新剧", ""); err == nil {
|
||||
t.Fatal("unsafe source parent accepted")
|
||||
}
|
||||
if entries, _ := os.ReadDir(outside); len(entries) != 0 {
|
||||
t.Fatal("wrote through source symlink")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(group, "新剧")); !os.IsNotExist(err) {
|
||||
t.Fatal("created new drama inside an existing drama")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadDirectoryOwnershipAndConcurrentAllocation(t *testing.T) {
|
||||
cfg := defaultConfig()
|
||||
cfg.OutputDir, cfg.GroupBySource = t.TempDir(), true
|
||||
d := &Downloader{cfg: cfg}
|
||||
drama := Drama{ID: historyFixtureDramaID, Source: sourceHongguo}
|
||||
results := make(chan string, 12)
|
||||
var workers sync.WaitGroup
|
||||
for i := 0; i < cap(results); i++ {
|
||||
workers.Add(1)
|
||||
go func() {
|
||||
defer workers.Done()
|
||||
directory, err := d.downloadDramaDirectory(drama, "并发同一剧", "")
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
results <- directory
|
||||
}()
|
||||
}
|
||||
workers.Wait()
|
||||
close(results)
|
||||
want := filepath.Join(cfg.OutputDir, "红果", "并发同一剧")
|
||||
for directory := range results {
|
||||
if directory != want {
|
||||
t.Fatal("concurrent enqueue allocated different directories", directory)
|
||||
}
|
||||
}
|
||||
duplicate := filepath.Join(cfg.OutputDir, "重复副本")
|
||||
if ok, err := claimDownloadDirectory(duplicate, drama.ID, false); err != nil || !ok {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := (&Downloader{cfg: cfg}).downloadDramaDirectory(drama, "第三份", ""); err == nil || !strings.Contains(err.Error(), "多个") {
|
||||
t.Fatal("ambiguous ownership created a third copy", err)
|
||||
}
|
||||
if same, err := (&Downloader{cfg: cfg}).downloadDramaDirectory(drama, "第三份", want); err != nil || same != want {
|
||||
t.Fatal("persisted task location did not resolve ambiguity", same, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceGroupingConfigAndTasklessDirectorySurviveRestart(t *testing.T) {
|
||||
cfg := defaultConfig()
|
||||
if cfg.GroupBySource || documentFromConfig(cfg).Download.GroupBySource {
|
||||
t.Fatal("grouping must be opt-in")
|
||||
}
|
||||
directory := t.TempDir()
|
||||
enabled := true
|
||||
settings := runtimeSettings{Concurrency: 2, RequestConcurrency: 2, RequestIntervalMS: 500, GroupBySource: &enabled}
|
||||
if err := saveRuntimeSettings(directory, settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.GroupBySource = nil
|
||||
settings.Concurrency = 3
|
||||
if err := saveRuntimeSettings(directory, settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
document, err := readConfigDocument(runtimeSettingsPath(directory))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := document.config()
|
||||
if err != nil || !loaded.GroupBySource || loaded.Concurrency != 3 {
|
||||
t.Fatal("older settings request reset grouping", err)
|
||||
}
|
||||
savedPath := filepath.Join(t.TempDir(), "原目录")
|
||||
app := &UIApp{cfg: cfg, statePath: filepath.Join(directory, "ui-state.json"),
|
||||
dramaDirectories: map[string]string{historyFixtureDramaID: savedPath}}
|
||||
if err := app.saveStateLocked(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restarted := &UIApp{cfg: cfg, statePath: app.statePath, tasks: map[string]*UITask{}}
|
||||
restarted.loadState()
|
||||
if got := restarted.dramaDirectoryLocked(historyFixtureDramaID); got != savedPath || len(restarted.tasks) != 0 {
|
||||
t.Fatal("cleaning task records lost the stable download directory", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadDirectoryRejectsUnsupportedSources(t *testing.T) {
|
||||
cfg := defaultConfig()
|
||||
cfg.OutputDir, cfg.GroupBySource = t.TempDir(), true
|
||||
d := &Downloader{cfg: cfg}
|
||||
for _, drama := range []Drama{
|
||||
{ID: "huangdou:7000000000000000002", Source: "huangdou"},
|
||||
{ID: historyFixtureDramaID, Source: "huangdou"},
|
||||
{ID: "huangguo_video:123", Source: sourceHongguo},
|
||||
} {
|
||||
if _, err := d.downloadDramaDirectory(drama, "不支持的来源", ""); err == nil {
|
||||
t.Fatal("accepted a non-Hongguo drama", drama.ID)
|
||||
}
|
||||
}
|
||||
if entries, err := os.ReadDir(cfg.OutputDir); err != nil || len(entries) != 0 {
|
||||
t.Fatal("unsupported source created download directories", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type hlsAsset struct {
|
||||
extension string
|
||||
remote string
|
||||
body []byte
|
||||
playlist bool
|
||||
fallbacks []hlsAsset
|
||||
}
|
||||
|
||||
type hlsProxy struct {
|
||||
downloader *Downloader
|
||||
credentials *providerMediaCredentials
|
||||
client *http.Client
|
||||
server *http.Server
|
||||
base string
|
||||
referer string
|
||||
key []byte
|
||||
mediaKey []byte
|
||||
mu sync.Mutex
|
||||
assets map[string]hlsAsset
|
||||
assetIDs map[string]string
|
||||
failure error
|
||||
root string
|
||||
retries int
|
||||
host string
|
||||
diagnostic func(diagnosticEvent)
|
||||
query string
|
||||
acquire func(context.Context) (func(), error)
|
||||
cache *downloadMediaCache
|
||||
}
|
||||
|
||||
var hlsURIAttribute = regexp.MustCompile(`URI="([^"]+)"`)
|
||||
|
||||
func (d *Downloader) newHLSProxy(ctx context.Context, media providerMedia, key []byte) (*hlsProxy, error) {
|
||||
nonce := randomHex(16)
|
||||
if nonce == "" {
|
||||
return nil, errors.New("无法创建本地下载会话")
|
||||
}
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建本地 HLS 转发失败: %w", err)
|
||||
}
|
||||
proxy := &hlsProxy{
|
||||
downloader: d,
|
||||
credentials: media.credentials,
|
||||
client: &http.Client{Transport: d.client.Transport, Jar: d.client.Jar, CheckRedirect: d.client.CheckRedirect},
|
||||
base: "http://" + listener.Addr().String() + "/" + nonce + "/",
|
||||
referer: media.Referer,
|
||||
key: key,
|
||||
mediaKey: media.HLSKey,
|
||||
assets: map[string]hlsAsset{},
|
||||
assetIDs: map[string]string{},
|
||||
retries: d.cfg.Retries,
|
||||
diagnostic: d.recordDiagnostic,
|
||||
}
|
||||
if remote, err := url.Parse(media.URL); err == nil {
|
||||
proxy.host = remote.Hostname()
|
||||
}
|
||||
proxy.root = proxy.addAsset(playbackRootAsset(media))
|
||||
proxy.server = &http.Server{
|
||||
Handler: proxy,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
BaseContext: func(net.Listener) context.Context { return ctx },
|
||||
}
|
||||
go func() {
|
||||
if err := proxy.server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
proxy.recordError(err)
|
||||
}
|
||||
}()
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
func playbackRootAsset(media providerMedia) hlsAsset {
|
||||
asset := hlsAsset{remote: media.URL, body: []byte(media.Playlist), playlist: media.Playlist != ""}
|
||||
for _, fallback := range mediaFallbackVariants(media) {
|
||||
asset.fallbacks = append(asset.fallbacks, hlsAsset{remote: fallback.URL, body: []byte(fallback.Playlist), playlist: fallback.Playlist != ""})
|
||||
}
|
||||
if asset.playlist && !strings.Contains(media.Playlist, "#EXT-X-ENDLIST") && !strings.Contains(media.Playlist, "#EXT-X-STREAM-INF:") {
|
||||
asset.body = nil
|
||||
}
|
||||
return asset
|
||||
}
|
||||
|
||||
func (proxy *hlsProxy) Close() {
|
||||
if proxy.server != nil {
|
||||
_ = proxy.server.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (proxy *hlsProxy) Err() error {
|
||||
proxy.mu.Lock()
|
||||
defer proxy.mu.Unlock()
|
||||
return proxy.failure
|
||||
}
|
||||
|
||||
func (proxy *hlsProxy) recordError(err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
proxy.mu.Lock()
|
||||
first := proxy.failure == nil
|
||||
if proxy.failure == nil {
|
||||
proxy.failure = publicError(err)
|
||||
}
|
||||
proxy.mu.Unlock()
|
||||
if first && proxy.diagnostic != nil {
|
||||
proxy.diagnostic(diagnosticEvent{Event: "media.failed", Host: proxy.host, Message: err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
func (proxy *hlsProxy) addAsset(asset hlsAsset) string {
|
||||
proxy.mu.Lock()
|
||||
defer proxy.mu.Unlock()
|
||||
if existing := proxy.assetIDs[asset.remote]; existing != "" {
|
||||
return existing
|
||||
}
|
||||
if len(proxy.assets) >= 16384 {
|
||||
return ""
|
||||
}
|
||||
extension := asset.extension
|
||||
if extension == "" {
|
||||
remote, _ := url.Parse(asset.remote)
|
||||
if remote != nil {
|
||||
extension = strings.ToLower(path.Ext(remote.Path))
|
||||
}
|
||||
switch extension {
|
||||
case ".m3u8":
|
||||
asset.playlist = true
|
||||
case ".mp4", ".m4s", ".m4a", ".aac", ".mp3", ".vtt", ".key", ".ts":
|
||||
default:
|
||||
extension = ".ts"
|
||||
}
|
||||
}
|
||||
if asset.playlist {
|
||||
extension = ".m3u8"
|
||||
} else if len(asset.body) > 0 {
|
||||
extension = ".key"
|
||||
}
|
||||
asset.extension = extension
|
||||
local := proxy.base + strconv.Itoa(len(proxy.assets)+1) + extension
|
||||
if proxy.query != "" {
|
||||
local += "?" + proxy.query
|
||||
}
|
||||
parsed, _ := url.Parse(local)
|
||||
proxy.assets[parsed.Path] = asset
|
||||
proxy.assetIDs[asset.remote] = local
|
||||
return local
|
||||
}
|
||||
|
||||
func (proxy *hlsProxy) rewritePlaylist(raw, baseURL string) (string, error) {
|
||||
base, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rewrite := func(reference string, playlist, key, initialization bool) (string, error) {
|
||||
parsed, err := url.Parse(reference)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
remote := base.ResolveReference(parsed).String()
|
||||
if !isProviderHTTPMediaURL(remote) {
|
||||
return "", errors.New("HLS 包含非 HTTP/HTTPS 资源地址")
|
||||
}
|
||||
asset := hlsAsset{remote: remote, playlist: playlist || strings.HasSuffix(strings.ToLower(parsed.Path), ".m3u8")}
|
||||
if key {
|
||||
asset.extension = ".key"
|
||||
} else if initialization {
|
||||
asset.extension = ".mp4"
|
||||
}
|
||||
resolved, _ := url.Parse(remote)
|
||||
if key && len(proxy.key) > 0 && strings.Contains(parsed.Path, "/api/app/vid/sec") {
|
||||
asset.body = proxy.key
|
||||
}
|
||||
if key && len(proxy.mediaKey) == 16 && strings.HasSuffix(resolved.Path, "/enc.key") {
|
||||
asset.body = proxy.mediaKey
|
||||
}
|
||||
local := proxy.addAsset(asset)
|
||||
if local == "" {
|
||||
return "", errors.New("HLS 资源数量超过限制")
|
||||
}
|
||||
return local, nil
|
||||
}
|
||||
lines := strings.Split(strings.TrimPrefix(raw, "\ufeff"), "\n")
|
||||
nextPlaylist := false
|
||||
for index, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "#EXT-X-STREAM-INF:") {
|
||||
nextPlaylist = true
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "#") {
|
||||
var rewriteErr error
|
||||
lines[index] = hlsURIAttribute.ReplaceAllStringFunc(line, func(attribute string) string {
|
||||
reference := hlsURIAttribute.FindStringSubmatch(attribute)[1]
|
||||
local, err := rewrite(reference, strings.HasPrefix(trimmed, "#EXT-X-MEDIA:") || strings.HasPrefix(trimmed, "#EXT-X-I-FRAME-STREAM-INF:"), strings.HasPrefix(trimmed, "#EXT-X-KEY:") || strings.HasPrefix(trimmed, "#EXT-X-SESSION-KEY:"), strings.HasPrefix(trimmed, "#EXT-X-MAP:"))
|
||||
if err != nil {
|
||||
rewriteErr = err
|
||||
return attribute
|
||||
}
|
||||
return `URI="` + local + `"`
|
||||
})
|
||||
if rewriteErr != nil {
|
||||
return "", rewriteErr
|
||||
}
|
||||
} else if trimmed != "" {
|
||||
lines[index], err = rewrite(trimmed, nextPlaylist, false, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nextPlaylist = false
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n"), nil
|
||||
}
|
||||
|
||||
func hlsAssetContentType(extension string) string {
|
||||
switch extension {
|
||||
case ".mp4", ".m4s":
|
||||
return "video/mp4"
|
||||
case ".ts":
|
||||
return "video/mp2t"
|
||||
case ".m4a":
|
||||
return "audio/mp4"
|
||||
case ".aac":
|
||||
return "audio/aac"
|
||||
case ".mp3":
|
||||
return "audio/mpeg"
|
||||
case ".vtt":
|
||||
return "text/vtt"
|
||||
case ".key":
|
||||
return "application/octet-stream"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (proxy *hlsProxy) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodGet && request.Method != http.MethodHead {
|
||||
writer.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
proxy.mu.Lock()
|
||||
asset, found := proxy.assets[request.URL.Path]
|
||||
proxy.mu.Unlock()
|
||||
if !found {
|
||||
http.NotFound(writer, request)
|
||||
return
|
||||
}
|
||||
body := asset.body
|
||||
if len(body) == 0 {
|
||||
if proxy.acquire != nil {
|
||||
release, err := proxy.acquire(request.Context())
|
||||
if err != nil {
|
||||
http.Error(writer, "媒体连接正在等待资源,请稍后重试", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
}
|
||||
method := request.Method
|
||||
if asset.playlist {
|
||||
method = http.MethodGet
|
||||
}
|
||||
buildRequest := func(candidate hlsAsset) (*http.Request, error) {
|
||||
upstream, err := http.NewRequestWithContext(request.Context(), method, candidate.remote, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
upstream.Header.Set("User-Agent", userAgent)
|
||||
upstream.Header.Set("Referer", proxy.referer)
|
||||
upstream.Header.Set("Accept-Encoding", "identity")
|
||||
if origin, err := url.Parse(proxy.referer); err == nil && origin.Host != "" {
|
||||
upstream.Header.Set("Origin", origin.Scheme+"://"+origin.Host)
|
||||
}
|
||||
for _, header := range []string{"Range", "If-Range"} {
|
||||
if value := request.Header.Get(header); value != "" && !candidate.playlist {
|
||||
upstream.Header.Set(header, value)
|
||||
}
|
||||
}
|
||||
return upstream, nil
|
||||
}
|
||||
candidates := append([]hlsAsset{asset}, asset.fallbacks...)
|
||||
if proxy.cache != nil && !asset.playlist && request.Method == http.MethodGet && request.Header.Get("Range") == "" {
|
||||
for _, candidate := range candidates {
|
||||
file, size, cacheErr := proxy.cache.open(request.Context(), proxy, candidate, buildRequest)
|
||||
if cacheErr == nil {
|
||||
defer file.Close()
|
||||
if contentType := hlsAssetContentType(candidate.extension); contentType != "" {
|
||||
writer.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
writer.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
||||
http.ServeContent(writer, request, "media", time.Time{}, file)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
var upstream *http.Request
|
||||
var response *http.Response
|
||||
var err error
|
||||
for index, candidate := range candidates {
|
||||
upstream, err = buildRequest(candidate)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
response, err = proxy.fetch(upstream)
|
||||
if err == nil && response.StatusCode >= 200 && response.StatusCode < 300 {
|
||||
asset = candidate
|
||||
break
|
||||
}
|
||||
if response != nil {
|
||||
if response.StatusCode == http.StatusRequestedRangeNotSatisfiable || response.StatusCode >= 400 && response.StatusCode < 500 && response.StatusCode != http.StatusUnauthorized && response.StatusCode != http.StatusForbidden && response.StatusCode != http.StatusNotFound && response.StatusCode != http.StatusGone {
|
||||
break
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
}
|
||||
if index == len(candidates)-1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
proxy.fail(writer, request, err)
|
||||
return
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
_ = response.Body.Close()
|
||||
if response.StatusCode == http.StatusRequestedRangeNotSatisfiable {
|
||||
writer.Header().Set("Content-Range", response.Header.Get("Content-Range"))
|
||||
writer.WriteHeader(response.StatusCode)
|
||||
return
|
||||
}
|
||||
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden || response.StatusCode == http.StatusNotFound || response.StatusCode == http.StatusGone {
|
||||
proxy.recordError(fmt.Errorf("媒体源 HTTP %d", response.StatusCode))
|
||||
writer.WriteHeader(response.StatusCode)
|
||||
return
|
||||
}
|
||||
proxy.fail(writer, request, fmt.Errorf("HLS 资源请求失败: %s HTTP %d", upstream.URL.Hostname(), response.StatusCode))
|
||||
return
|
||||
}
|
||||
if asset.playlist || strings.Contains(response.Header.Get("Content-Type"), "mpegurl") {
|
||||
defer response.Body.Close()
|
||||
body, err = io.ReadAll(io.LimitReader(response.Body, providerMaxBodyBytes+1))
|
||||
if err != nil || len(body) > providerMaxBodyBytes {
|
||||
proxy.fail(writer, request, errors.New("读取 HLS 播放列表失败或内容过大"))
|
||||
return
|
||||
}
|
||||
asset.playlist = true
|
||||
if response.Request != nil && response.Request.URL != nil {
|
||||
asset.remote = response.Request.URL.String()
|
||||
}
|
||||
} else {
|
||||
reader := proxy.mediaReader(upstream, response)
|
||||
defer reader.Close()
|
||||
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "Content-Encoding", "ETag", "Last-Modified"} {
|
||||
if value := response.Header.Get(header); value != "" {
|
||||
writer.Header().Set(header, value)
|
||||
}
|
||||
}
|
||||
if contentType := hlsAssetContentType(asset.extension); contentType != "" {
|
||||
writer.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
writer.WriteHeader(response.StatusCode)
|
||||
if request.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
if _, err := io.Copy(writer, reader); err != nil && reader.failure != nil && request.Context().Err() == nil {
|
||||
proxy.recordError(reader.failure)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
if asset.playlist {
|
||||
if !bytes.HasPrefix(bytes.TrimSpace(bytes.TrimPrefix(body, []byte("\ufeff"))), []byte("#EXTM3U")) {
|
||||
proxy.fail(writer, request, errors.New("上游没有返回有效的 HLS 播放列表"))
|
||||
return
|
||||
}
|
||||
rewritten, err := proxy.rewritePlaylist(string(body), asset.remote)
|
||||
if err != nil {
|
||||
proxy.fail(writer, request, err)
|
||||
return
|
||||
}
|
||||
body = []byte(rewritten)
|
||||
writer.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
||||
} else {
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
}
|
||||
http.ServeContent(writer, request, "media", time.Time{}, bytes.NewReader(body))
|
||||
}
|
||||
|
||||
func (proxy *hlsProxy) fetch(request *http.Request) (*http.Response, error) {
|
||||
request = request.Clone(providerMediaContext(request.Context(), proxy.credentials))
|
||||
attempts := mediaRequestAttempts(proxy.retries)
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < attempts; attempt++ {
|
||||
if attempt > 0 {
|
||||
timer := time.NewTimer(time.Duration(attempt) * time.Second)
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-request.Context().Done():
|
||||
timer.Stop()
|
||||
return nil, request.Context().Err()
|
||||
}
|
||||
}
|
||||
var response *http.Response
|
||||
var err error
|
||||
if proxy.downloader != nil {
|
||||
response, err = proxy.downloader.doMediaRequestWithClient(request.Clone(request.Context()), proxy.client)
|
||||
} else {
|
||||
upstream := request.Clone(request.Context())
|
||||
mediaRequestHeaders(upstream, proxy.referer)
|
||||
client := proxy.client
|
||||
if proxy.credentials != nil {
|
||||
if err := proxy.credentials.apply(upstream); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client = proxy.credentials.client(client)
|
||||
}
|
||||
response, err = client.Do(upstream)
|
||||
}
|
||||
if err == nil {
|
||||
return response, nil
|
||||
}
|
||||
if request.Context().Err() != nil {
|
||||
return nil, request.Context().Err()
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return nil, fmt.Errorf("媒体资源连接失败(%s,已尝试 %d 次;可在代理设置中检测连接): %w", request.URL.Hostname(), attempts, publicError(lastErr))
|
||||
}
|
||||
|
||||
func (proxy *hlsProxy) fail(writer http.ResponseWriter, request *http.Request, err error) {
|
||||
if request.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
proxy.recordError(err)
|
||||
http.Error(writer, "上游媒体请求失败", http.StatusBadGateway)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (d *Downloader) downloadMediaWithProgress(ctx context.Context, task Task, callback func(DownloadProgress)) error {
|
||||
if ok, size := existingGood(task.OutPath, d.cfg.SkipBytes); ok {
|
||||
if callback != nil {
|
||||
callback(DownloadProgress{Percent: 100, DownloadedBytes: size, TotalBytes: size, Phase: "completed"})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if callback != nil {
|
||||
callback(DownloadProgress{Phase: "preparing"})
|
||||
}
|
||||
partPath := strings.TrimSuffix(task.OutPath, filepath.Ext(task.OutPath)) + ".part.mp4"
|
||||
if err := os.MkdirAll(filepath.Dir(task.OutPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
progress := newDownloadProgressState(partPath, task.OutPath, task.Chapter.MediaSize, callback)
|
||||
progress.report("resolving", true)
|
||||
retries := d.cfg.Retries
|
||||
if retries <= 0 {
|
||||
retries = 3
|
||||
}
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= retries; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if attempt > 1 {
|
||||
_ = os.Remove(partPath)
|
||||
progress = newDownloadProgressState(partPath, task.OutPath, task.Chapter.MediaSize, callback)
|
||||
progress.report("retrying", true)
|
||||
select {
|
||||
case <-time.After(time.Duration(attempt*2) * time.Second):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
progress.report("resolving", true)
|
||||
media, err := d.resolveProviderMedia(ctx, task)
|
||||
if err != nil {
|
||||
lastErr = publicError(err)
|
||||
continue
|
||||
}
|
||||
media, err = d.selectDownloadQuality(ctx, task, media)
|
||||
if err != nil {
|
||||
lastErr = publicError(err)
|
||||
continue
|
||||
}
|
||||
if len(media.CENCKey) != 0 && (len(media.CENCKey) != 16 || media.Playlist != "") {
|
||||
return errors.New("CENC 媒体的密钥或格式无效")
|
||||
}
|
||||
if handled, directErr := d.downloadDirectProviderMedia(ctx, task, media, nil, progress); handled {
|
||||
if directErr == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = directErr
|
||||
continue
|
||||
}
|
||||
ffmpeg, err := d.ensureFFmpeg(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(partPath)
|
||||
progress.setMediaTotal(media.Duration)
|
||||
proxy, err := d.newHLSProxy(ctx, media, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if media.Playlist != "" {
|
||||
if cache, cacheErr := prepareDownloadMediaCache(partPath+".segments", media, nil); cacheErr == nil {
|
||||
proxy.cache = cache
|
||||
} else {
|
||||
proxy.Close()
|
||||
lastErr = cacheErr
|
||||
continue
|
||||
}
|
||||
}
|
||||
inputURL := proxy.root
|
||||
origin, _ := url.Parse(media.Referer)
|
||||
headers := fmt.Sprintf("User-Agent: %s\r\nReferer: %s\r\n", userAgent, media.Referer)
|
||||
if origin != nil && origin.Host != "" {
|
||||
headers += "Origin: " + origin.Scheme + "://" + origin.Host + "\r\n"
|
||||
}
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
||||
args := []string{
|
||||
"-hide_banner", "-loglevel", "error", "-nostats",
|
||||
"-rw_timeout", "20000000",
|
||||
"-protocol_whitelist", "http,https,tcp,tls,crypto,httpproxy",
|
||||
"-headers", headers,
|
||||
}
|
||||
if media.Playlist != "" {
|
||||
args = append(args, "-allowed_extensions", "ALL")
|
||||
}
|
||||
if len(media.CENCKey) > 0 {
|
||||
args = append(args, "-decryption_key", hex.EncodeToString(media.CENCKey))
|
||||
}
|
||||
args = append(args,
|
||||
"-i", inputURL,
|
||||
"-c", "copy",
|
||||
"-progress", "pipe:1",
|
||||
"-f", "mp4",
|
||||
"-y", partPath,
|
||||
)
|
||||
cmd := ffmpegMediaCommand(cmdCtx, ffmpeg, args...)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
if proxy != nil {
|
||||
proxy.Close()
|
||||
}
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
if proxy != nil {
|
||||
proxy.Close()
|
||||
}
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
if proxy != nil {
|
||||
proxy.Close()
|
||||
}
|
||||
cancel()
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
progress.report("downloading", true)
|
||||
stderrDone := make(chan string, 1)
|
||||
go func() {
|
||||
var b cappedStringWriter
|
||||
b.limit = 64 * 1024
|
||||
_, _ = io.Copy(&b, stderr)
|
||||
stderrDone <- b.String()
|
||||
}()
|
||||
progressDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(progressDone)
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
for scanner.Scan() {
|
||||
key, val, ok := strings.Cut(scanner.Text(), "=")
|
||||
if ok && (key == "out_time_us" || key == "out_time_ms") {
|
||||
if micros, err := strconv.ParseInt(strings.TrimSpace(val), 10, 64); err == nil && micros >= 0 {
|
||||
progress.setMediaElapsed(time.Duration(micros) * time.Microsecond)
|
||||
progress.report("downloading", false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
tickerDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(tickerDone)
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
progress.report("downloading", false)
|
||||
case <-progressDone:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
waitErr := cmd.Wait()
|
||||
<-progressDone
|
||||
<-tickerDone
|
||||
stderrText := <-stderrDone
|
||||
commandErr := cmdCtx.Err()
|
||||
cancel()
|
||||
var proxyErr error
|
||||
if proxy != nil {
|
||||
proxyErr = proxy.Err()
|
||||
proxy.Close()
|
||||
}
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return ctxErr
|
||||
}
|
||||
if commandErr != nil {
|
||||
lastErr = commandErr
|
||||
_ = os.Remove(partPath)
|
||||
continue
|
||||
}
|
||||
if proxyErr != nil {
|
||||
lastErr = proxyErr
|
||||
continue
|
||||
}
|
||||
if waitErr != nil {
|
||||
lastErr = fmt.Errorf("ffmpeg failed: %v %s", waitErr, truncate(stderrText, 1000))
|
||||
_ = os.Remove(partPath)
|
||||
continue
|
||||
}
|
||||
st, err := os.Stat(partPath)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if st.Size() < 100*1024 {
|
||||
lastErr = fmt.Errorf("downloaded file too small: %d bytes", st.Size())
|
||||
_ = os.Remove(partPath)
|
||||
continue
|
||||
}
|
||||
if err := os.Rename(partPath, task.OutPath); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
_ = os.RemoveAll(partPath + ".segments")
|
||||
progress.report("completed", true)
|
||||
return nil
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type cappedStringWriter struct {
|
||||
b strings.Builder
|
||||
limit int
|
||||
}
|
||||
|
||||
func (w *cappedStringWriter) Write(p []byte) (int, error) {
|
||||
if w.limit <= 0 {
|
||||
return len(p), nil
|
||||
}
|
||||
remaining := w.limit - w.b.Len()
|
||||
if remaining > 0 {
|
||||
if len(p) > remaining {
|
||||
_, _ = w.b.Write(p[:remaining])
|
||||
} else {
|
||||
_, _ = w.b.Write(p)
|
||||
}
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (w *cappedStringWriter) String() string { return w.b.String() }
|
||||
|
||||
type downloadProgressState struct {
|
||||
mu sync.Mutex
|
||||
callbackMu sync.Mutex
|
||||
callback func(DownloadProgress)
|
||||
partPath string
|
||||
outPath string
|
||||
totalBytes int64
|
||||
mediaTotal time.Duration
|
||||
mediaDone time.Duration
|
||||
started time.Time
|
||||
lastSample time.Time
|
||||
lastBytes int64
|
||||
byteProgress int64
|
||||
hasByteProgress bool
|
||||
speed float64
|
||||
lastReport time.Time
|
||||
lastPercent int
|
||||
lastPhase string
|
||||
}
|
||||
|
||||
func newDownloadProgressState(partPath, outPath string, totalBytes int64, callback func(DownloadProgress)) *downloadProgressState {
|
||||
return &downloadProgressState{partPath: partPath, outPath: outPath, totalBytes: totalBytes, callback: callback, started: time.Now()}
|
||||
}
|
||||
|
||||
func (p *downloadProgressState) setMediaTotal(total time.Duration) {
|
||||
p.mu.Lock()
|
||||
p.mediaTotal = total
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *downloadProgressState) setTotalBytes(total int64) {
|
||||
if total <= 0 {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.totalBytes = total
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *downloadProgressState) setByteProgress(downloaded, total int64) {
|
||||
if downloaded < 0 {
|
||||
downloaded = 0
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.byteProgress = downloaded
|
||||
p.hasByteProgress = true
|
||||
if total > 0 {
|
||||
p.totalBytes = total
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *downloadProgressState) setMediaElapsed(elapsed time.Duration) {
|
||||
if elapsed < 0 {
|
||||
elapsed = 0
|
||||
}
|
||||
p.mu.Lock()
|
||||
if elapsed > p.mediaDone {
|
||||
p.mediaDone = elapsed
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *downloadProgressState) snapshot(now time.Time, phase string) DownloadProgress {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
bytesDownloaded := int64(0)
|
||||
if p.hasByteProgress {
|
||||
bytesDownloaded = p.byteProgress
|
||||
} else if st, err := os.Stat(p.partPath); err == nil && !st.IsDir() {
|
||||
bytesDownloaded = st.Size()
|
||||
} else if phase == "completed" && p.outPath != "" {
|
||||
if st, err := os.Stat(p.outPath); err == nil && !st.IsDir() {
|
||||
bytesDownloaded = st.Size()
|
||||
}
|
||||
}
|
||||
if !p.lastSample.IsZero() {
|
||||
seconds := now.Sub(p.lastSample).Seconds()
|
||||
if seconds > 0 && bytesDownloaded >= p.lastBytes {
|
||||
p.speed = float64(bytesDownloaded-p.lastBytes) / seconds
|
||||
}
|
||||
}
|
||||
p.lastSample = now
|
||||
p.lastBytes = bytesDownloaded
|
||||
if p.speed < 0 || math.IsNaN(p.speed) || math.IsInf(p.speed, 0) {
|
||||
p.speed = 0
|
||||
}
|
||||
elapsed := now.Sub(p.started)
|
||||
if elapsed < 0 {
|
||||
elapsed = 0
|
||||
}
|
||||
mediaElapsed := p.mediaDone
|
||||
mediaTotal := p.mediaTotal
|
||||
percent := 0
|
||||
if mediaTotal > 0 {
|
||||
percent = int(float64(mediaElapsed) / float64(mediaTotal) * 100)
|
||||
} else if p.totalBytes > 0 {
|
||||
percent = int(float64(bytesDownloaded) / float64(p.totalBytes) * 100)
|
||||
}
|
||||
if phase == "completed" {
|
||||
percent = 100
|
||||
if mediaTotal > 0 {
|
||||
mediaElapsed = mediaTotal
|
||||
}
|
||||
}
|
||||
if percent < 0 {
|
||||
percent = 0
|
||||
}
|
||||
if phase != "completed" && percent >= 100 {
|
||||
percent = 99
|
||||
} else if percent > 100 {
|
||||
percent = 100
|
||||
}
|
||||
remaining := time.Duration(0)
|
||||
if mediaTotal > 0 && mediaElapsed > 0 {
|
||||
remaining = time.Duration(float64(mediaTotal-mediaElapsed) * float64(elapsed) / float64(mediaElapsed))
|
||||
} else if p.totalBytes > 0 && p.speed > 0 && bytesDownloaded < p.totalBytes {
|
||||
remaining = time.Duration(float64(p.totalBytes-bytesDownloaded) / p.speed * float64(time.Second))
|
||||
}
|
||||
if remaining < 0 || math.IsNaN(float64(remaining)) || math.IsInf(float64(remaining), 0) {
|
||||
remaining = 0
|
||||
}
|
||||
return DownloadProgress{Percent: percent, DownloadedBytes: bytesDownloaded, TotalBytes: p.totalBytes, SpeedBytesPerSecond: p.speed, Elapsed: elapsed, Remaining: remaining, MediaElapsed: mediaElapsed, MediaTotal: mediaTotal, Phase: phase}
|
||||
}
|
||||
|
||||
func (p *downloadProgressState) report(phase string, force bool) {
|
||||
if p.callback == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
p.callbackMu.Lock()
|
||||
defer p.callbackMu.Unlock()
|
||||
progress := p.snapshot(now, phase)
|
||||
p.mu.Lock()
|
||||
changed := force || progress.Percent != p.lastPercent || progress.Phase != p.lastPhase || now.Sub(p.lastReport) >= time.Second
|
||||
if changed {
|
||||
p.lastPercent = progress.Percent
|
||||
p.lastPhase = progress.Phase
|
||||
p.lastReport = now
|
||||
}
|
||||
p.mu.Unlock()
|
||||
if changed {
|
||||
p.callback(progress)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func readDownloadRequest(writer http.ResponseWriter, request *http.Request) ([]string, *int, bool) {
|
||||
var input struct {
|
||||
IDs []string `json:"ids"`
|
||||
Quality *int `json:"quality"`
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&input); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "下载参数无效"})
|
||||
return nil, nil, false
|
||||
}
|
||||
if decoder.Decode(new(any)) != io.EOF || input.Quality != nil && (*input.Quality < 0 || *input.Quality > 4320) {
|
||||
writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "下载清晰度选项无效"})
|
||||
return nil, nil, false
|
||||
}
|
||||
ids, ok := cleanIDList(writer, input.IDs)
|
||||
if !ok {
|
||||
return nil, nil, false
|
||||
}
|
||||
for index, id := range ids {
|
||||
_, sourceID, valid := splitProviderDramaID(id)
|
||||
if !valid {
|
||||
writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "此版本仅支持红果剧集 ID"})
|
||||
return nil, nil, false
|
||||
}
|
||||
ids[index] = providerDramaID(sourceHongguo, sourceID)
|
||||
}
|
||||
return ids, input.Quality, true
|
||||
}
|
||||
|
||||
func preferredDownloadQuality(requested int, available []int) int {
|
||||
best, lowest := 0, 0
|
||||
for _, quality := range available {
|
||||
if quality <= 0 || quality > 4320 {
|
||||
continue
|
||||
}
|
||||
if lowest == 0 || quality < lowest {
|
||||
lowest = quality
|
||||
}
|
||||
if (requested <= 0 || quality <= requested) && quality > best {
|
||||
best = quality
|
||||
}
|
||||
}
|
||||
if best == 0 {
|
||||
return lowest
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func (downloader *Downloader) selectDownloadQuality(ctx context.Context, task Task, media providerMedia) (providerMedia, error) {
|
||||
qualities := []int{media.Quality}
|
||||
validVariants := make([]providerMedia, 0, len(media.Variants)+1)
|
||||
for _, variant := range media.Variants {
|
||||
if isProviderHTTPMediaURL(variant.URL) {
|
||||
qualities = append(qualities, variant.Quality)
|
||||
validVariants = append(validVariants, variant)
|
||||
}
|
||||
}
|
||||
if isProviderHTTPMediaURL(media.URL) {
|
||||
current := media
|
||||
current.Variants = nil
|
||||
validVariants = append(validVariants, current)
|
||||
}
|
||||
media.Variants = validVariants
|
||||
if variants := playbackHLSVariants(media.Playlist); len(variants) > 0 {
|
||||
qualities = nil
|
||||
for _, variant := range variants {
|
||||
qualities = append(qualities, variant.quality)
|
||||
}
|
||||
}
|
||||
quality := preferredDownloadQuality(task.DownloadQuality, qualities)
|
||||
selected, err := downloader.selectPlaybackQuality(context.WithValue(ctx, playbackQualityKey{}, quality), media)
|
||||
if err != nil {
|
||||
return providerMedia{}, err
|
||||
}
|
||||
var available []int
|
||||
for _, option := range playbackQualityOptions(selected) {
|
||||
available = append(available, option.Value)
|
||||
}
|
||||
downloader.recordDiagnostic(diagnosticEvent{Level: "info", Event: "download.media", Source: sourceFromDramaID(task.DramaID),
|
||||
DramaID: task.DramaID, DramaTitle: task.DramaTitle, Episode: task.Index, Quality: selected.Quality, RequestedQuality: task.DownloadQuality, AvailableQualities: available,
|
||||
Message: "下载保留源媒体的音视频编码和分辨率"})
|
||||
return selected, nil
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDownloadQualityExplicitChoiceAndFallback(t *testing.T) {
|
||||
d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) {
|
||||
return rankingHTTPResponse(request, http.StatusOK, "#EXTM3U\n#EXTINF:2,\nsynthetic.ts\n#EXT-X-ENDLIST\n"), nil
|
||||
})
|
||||
low := providerMedia{URL: "https://media.example.test/low.mp4", Quality: 720}
|
||||
high := providerMedia{URL: "https://media.example.test/high.mp4", Quality: 1080}
|
||||
media := high
|
||||
media.Variants = []providerMedia{high, low, {URL: "file:///invalid", Quality: 2160}}
|
||||
master := "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=1080x1920\nhigh.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=720x1280\nlow.m3u8\n"
|
||||
for _, input := range []struct{ requested, expected int }{{0, 1080}, {2160, 1080}, {1080, 1080}, {900, 720}, {720, 720}, {480, 720}} {
|
||||
task := Task{DownloadQuality: input.requested}
|
||||
for _, source := range []providerMedia{media, {URL: "https://media.example.test/master.m3u8", Playlist: master}} {
|
||||
selected, err := d.selectDownloadQuality(context.Background(), task, source)
|
||||
if err != nil || selected.Quality != input.expected {
|
||||
t.Fatalf("requested %d: selected %d, expected %d: %v", input.requested, selected.Quality, input.expected, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
events := readDiagnosticEvents(t, d.diagnostics.path)
|
||||
last := events[len(events)-1]
|
||||
if last.RequestedQuality != 480 || last.Quality != 720 {
|
||||
t.Fatal("fallback log lost requested and actual quality", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadQualityRequestValidation(t *testing.T) {
|
||||
for _, input := range []struct {
|
||||
body string
|
||||
valid bool
|
||||
quality int
|
||||
}{
|
||||
{`{"ids":["hongguo:7000000000000000001"]}`, true, 0},
|
||||
{`{"ids":["hongguo:7000000000000000001"],"quality":720}`, true, 720},
|
||||
{`{"ids":["hongguo:7000000000000000001"],"quality":-1}`, false, 0},
|
||||
{`{"ids":["hongguo:7000000000000000001"],"quality":9999}`, false, 0},
|
||||
{`{"ids":["hongguo:7000000000000000001"],"quality":"720"}`, false, 0},
|
||||
{`{"ids":["hongguo:7000000000000000001"],"quality":720.5}`, false, 0},
|
||||
{`{"ids":[]}`, false, 0},
|
||||
{`{"ids":["hongguo:7000000000000000001"]} {}`, false, 0},
|
||||
} {
|
||||
writer := httptest.NewRecorder()
|
||||
_, quality, valid := readDownloadRequest(writer, httptest.NewRequest(http.MethodPost, "/api/ui/download", strings.NewReader(input.body)))
|
||||
if valid != input.valid || valid && quality != nil && *quality != input.quality {
|
||||
t.Fatal(input.body, valid, quality, writer.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func downloadQualityApp(t *testing.T) *UIApp {
|
||||
t.Helper()
|
||||
d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) {
|
||||
t.Error("download task fixture attempted a network request", request.URL)
|
||||
return nil, errors.New("fixture only")
|
||||
})
|
||||
a := &UIApp{cfg: d.cfg, downloader: d, tasks: map[string]*UITask{}, statePath: filepath.Join(d.cfg.dataDirectory(), "ui-state.json")}
|
||||
a.cond = sync.NewCond(&a.mu)
|
||||
drama := Drama{ID: "hongguo:7000000000000000001", Source: sourceHongguo, SourceID: "7000000000000000001", Title: "无图画质任务"}
|
||||
a.dramas = []Drama{drama}
|
||||
client := d.hongguoClient()
|
||||
client.details[drama.SourceID] = hongguoDetailEntry{Drama: drama, Chapters: []Chapter{
|
||||
{ID: "hongguo:7000000000000000001:7000000000000000002", Source: sourceHongguo, Title: "第 1 集", CurrentEpisode: rawEpisode(1)},
|
||||
}, ExpiresAt: time.Now().Add(time.Hour)}
|
||||
return a
|
||||
}
|
||||
|
||||
func waitDownloadQualityParse(t *testing.T, a *UIApp) []uiTaskView {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
a.mu.Lock()
|
||||
pending := len(a.parsingInFlight) > 0
|
||||
views := a.taskViewsLocked()
|
||||
a.mu.Unlock()
|
||||
if !pending {
|
||||
return views
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("chapter parsing did not finish")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDownloadQualitySurvivesQueueRestartRetryAndUpdate(t *testing.T) {
|
||||
a := downloadQualityApp(t)
|
||||
body := `{"ids":["hongguo:7000000000000000001"],"quality":720}`
|
||||
writer := httptest.NewRecorder()
|
||||
a.handleDownload(writer, httptest.NewRequest(http.MethodPost, "/api/ui/download", strings.NewReader(body)))
|
||||
if writer.Code != http.StatusAccepted {
|
||||
t.Fatal(writer.Code, writer.Body.String())
|
||||
}
|
||||
views := waitDownloadQualityParse(t, a)
|
||||
if len(views) != 1 || views[0].DownloadQuality != 720 {
|
||||
t.Fatal("queued task lost selected quality", views)
|
||||
}
|
||||
restarted := &UIApp{cfg: a.cfg, downloader: a.downloader, tasks: map[string]*UITask{}, statePath: a.statePath}
|
||||
restarted.cond = sync.NewCond(&restarted.mu)
|
||||
restarted.loadState()
|
||||
task := restarted.tasks[views[0].ID]
|
||||
if task == nil || task.Source.DownloadQuality != 720 {
|
||||
t.Fatal("restart lost quality", task)
|
||||
}
|
||||
task.Status = uiStatusFailed
|
||||
selection, _ := json.Marshal(map[string]any{"ids": []string{task.ID}})
|
||||
restarted.handleRetry(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/ui/tasks/retry", bytes.NewReader(selection)))
|
||||
if task.Status != uiStatusQueued || task.Source.DownloadQuality != 720 {
|
||||
t.Fatal("retry changed quality", task)
|
||||
}
|
||||
restarted.handlePause(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/ui/tasks/pause", bytes.NewReader(selection)))
|
||||
restarted.handleResume(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/ui/tasks/resume", bytes.NewReader(selection)))
|
||||
if task.Status != uiStatusQueued || task.Source.DownloadQuality != 720 {
|
||||
t.Fatal("pause/resume changed quality", task)
|
||||
}
|
||||
client := a.downloader.hongguoClient()
|
||||
entry := client.details["7000000000000000001"]
|
||||
entry.Chapters = append(entry.Chapters, Chapter{ID: "hongguo:7000000000000000001:7000000000000000003", Source: sourceHongguo, Title: "第 2 集", CurrentEpisode: rawEpisode(2)})
|
||||
client.details["7000000000000000001"] = entry
|
||||
restarted.handleUpdate(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/ui/update", strings.NewReader(`{"ids":["hongguo:7000000000000000001"]}`)))
|
||||
views = waitDownloadQualityParse(t, restarted)
|
||||
if len(views) != 2 || views[0].DownloadQuality != 720 || views[1].DownloadQuality != 720 {
|
||||
t.Fatal("new episode update did not retain collection quality", views)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadQualityFailedChapterParseKeepsPreference(t *testing.T) {
|
||||
a := downloadQualityApp(t)
|
||||
client := a.downloader.hongguoClient()
|
||||
entry := client.details["7000000000000000001"]
|
||||
empty := entry
|
||||
empty.Chapters = nil
|
||||
client.details["7000000000000000001"] = empty
|
||||
a.handleDownload(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/ui/download", strings.NewReader(`{"ids":["hongguo:7000000000000000001"],"quality":540}`)))
|
||||
views := waitDownloadQualityParse(t, a)
|
||||
if len(views) != 1 || views[0].Status != uiStatusFailed || views[0].DownloadQuality != 540 {
|
||||
t.Fatal("chapter failure lost quality", views)
|
||||
}
|
||||
client.details["7000000000000000001"] = entry
|
||||
selection, _ := json.Marshal(map[string]any{"ids": []string{views[0].ID}})
|
||||
a.handleRetry(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/ui/tasks/retry", bytes.NewReader(selection)))
|
||||
views = waitDownloadQualityParse(t, a)
|
||||
if len(views) != 1 || views[0].Status != uiStatusQueued || views[0].DownloadQuality != 540 {
|
||||
t.Fatal("reparsed chapter lost quality", views)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadQualityKeepsHighestAvailableSource(t *testing.T) {
|
||||
d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) {
|
||||
if request.URL.String() != "https://media.example.test/high.m3u8" {
|
||||
t.Fatal("download requested an unselected quality", request.URL)
|
||||
}
|
||||
return rankingHTTPResponse(request, http.StatusOK, "#EXTM3U\n#EXTINF:2.0,\nsynthetic.ts\n#EXT-X-ENDLIST\n"), nil
|
||||
})
|
||||
task := Task{DramaID: "hongguo:7000000000000000001", Index: 1}
|
||||
ctx := context.WithValue(context.Background(), playbackQualityKey{}, 720)
|
||||
low := providerMedia{URL: "https://media.example.test/low.mp4", Quality: 720}
|
||||
high := providerMedia{URL: "https://media.example.test/high.mp4", Quality: 1080}
|
||||
media := low
|
||||
media.Variants = []providerMedia{low, high}
|
||||
selected, err := d.selectDownloadQuality(ctx, task, media)
|
||||
if err != nil || selected.Quality != 1080 || selected.URL != high.URL {
|
||||
t.Fatal("playback quality reduced the download quality", selected.Quality, err)
|
||||
}
|
||||
selected, err = d.selectDownloadQuality(ctx, task, low)
|
||||
if err != nil || selected.Quality != 720 || selected.URL != low.URL {
|
||||
t.Fatal("download invented a higher source quality", selected, err)
|
||||
}
|
||||
master := "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=9000000,RESOLUTION=720x1280\nlow.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=1080x1920\nhigh.m3u8\n"
|
||||
selected, err = d.selectDownloadQuality(ctx, task, providerMedia{URL: "https://media.example.test/master.m3u8", Playlist: master, Quality: 720})
|
||||
if err != nil || selected.Quality != 1080 || strings.Contains(selected.Playlist, "low.m3u8") || !strings.Contains(selected.Playlist, "high.m3u8") {
|
||||
t.Fatal("download did not explicitly select the highest HLS resolution", selected, err)
|
||||
}
|
||||
events := readDiagnosticEvents(t, d.diagnostics.path)
|
||||
last := events[len(events)-1]
|
||||
if last.Event != "download.media" || last.Quality != 1080 || !reflect.DeepEqual(last.AvailableQualities, []int{1080, 720}) {
|
||||
t.Fatal("download log did not retain the source quality evidence", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadPreserves1080pPixelsAndPackets(t *testing.T) {
|
||||
app, session := nativePlaybackFixture(t, "2", "1080x1920")
|
||||
source := session.tasks[0].OutPath
|
||||
body, err := os.ReadFile(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path != "/fixture.mp4" {
|
||||
t.Error("download requested an unrelated resource")
|
||||
http.NotFound(writer, request)
|
||||
return
|
||||
}
|
||||
http.ServeContent(writer, request, "fixture.mp4", time.Time{}, bytes.NewReader(body))
|
||||
}))
|
||||
defer server.Close()
|
||||
d := app.downloader
|
||||
d.client, d.cfg.Retries, d.cfg.SkipBytes = server.Client(), 1, 1
|
||||
task := Task{DramaID: "hongguo:7000000000000000001", DramaTitle: "合成原画下载样本", Index: 1,
|
||||
OutPath: filepath.Join(t.TempDir(), "download.mp4"), Chapter: Chapter{ID: "hongguo:7000000000000000001:1", Source: sourceHongguo, VideoURL: server.URL + "/fixture.mp4"}}
|
||||
ctx, cancel := context.WithTimeout(context.WithValue(context.Background(), playbackQualityKey{}, 720), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := d.DownloadEpisode(ctx, task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := probeMergeMedia(ctx, d.cfg.FFmpeg, task.OutPath)
|
||||
if err != nil || info.width != 1080 || info.height != 1920 {
|
||||
t.Fatal("download reduced the source resolution", info, err)
|
||||
}
|
||||
if !reflect.DeepEqual(playbackFixturePackets(t, d.cfg.FFmpeg, source), playbackFixturePackets(t, d.cfg.FFmpeg, task.OutPath)) {
|
||||
t.Fatal("download re-encoded the original video or audio")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Downloader struct {
|
||||
cfg Config
|
||||
client *http.Client
|
||||
providerMu sync.Mutex
|
||||
providerHosts map[string]string
|
||||
hongguoOnce sync.Once
|
||||
hongguo *hongguoAppClient
|
||||
rankings rankingCache
|
||||
limiter *requestLimiter
|
||||
proxyRouter *proxyRouter
|
||||
ffmpegMu sync.Mutex
|
||||
ffmpegInstaller *ffmpegInstaller
|
||||
diagnostics *diagnosticLog
|
||||
directoryMu sync.Mutex
|
||||
downloadDirectories map[string]string
|
||||
downloadGrouping *bool
|
||||
}
|
||||
|
||||
func NewDownloader(cfg Config) *Downloader {
|
||||
if cfg.OutputDir == "" {
|
||||
cfg.OutputDir = defaultOutputDir()
|
||||
}
|
||||
if cfg.FFmpeg == "" {
|
||||
cfg.FFmpeg = "ffmpeg"
|
||||
}
|
||||
if cfg.Concurrency <= 0 {
|
||||
cfg.Concurrency = 2
|
||||
}
|
||||
if cfg.RequestConcurrency <= 0 {
|
||||
cfg.RequestConcurrency = 2
|
||||
}
|
||||
if cfg.RequestIntervalMS <= 0 {
|
||||
cfg.RequestIntervalMS = 500
|
||||
}
|
||||
loadRuntimeSettings(&cfg)
|
||||
if absolute, err := filepath.Abs(cfg.dataDirectory()); err == nil {
|
||||
cfg.dataDir = absolute
|
||||
}
|
||||
cfg.outputDirSetting = cfg.OutputDir
|
||||
if absolute, err := filepath.Abs(cfg.OutputDir); err == nil {
|
||||
cfg.OutputDir = absolute
|
||||
}
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: cfg.InsecureTLS}
|
||||
transport.MaxIdleConnsPerHost = 8
|
||||
transport.ResponseHeaderTimeout = 20 * time.Second
|
||||
router := &proxyRouter{}
|
||||
router.configure(cfg.ProxyURL)
|
||||
transport.Proxy = router.proxy
|
||||
return &Downloader{cfg: cfg, client: &http.Client{Transport: newImageTransport(transport, transport), Timeout: 45 * time.Second}, providerHosts: map[string]string{}, limiter: newRequestLimiter(cfg.RequestConcurrency, time.Duration(cfg.RequestIntervalMS)*time.Millisecond), proxyRouter: router, diagnostics: newDiagnosticLog(cfg.dataDirectory())}
|
||||
}
|
||||
|
||||
func (d *Downloader) DownloadEpisode(ctx context.Context, task Task) error {
|
||||
return d.DownloadEpisodeWithProgress(ctx, task, nil)
|
||||
}
|
||||
|
||||
func (d *Downloader) DownloadEpisodeWithProgress(ctx context.Context, task Task, callback func(DownloadProgress)) (resultErr error) {
|
||||
defer func() {
|
||||
if ctx.Err() == nil {
|
||||
d.recordTaskFailure("download.failed", task, 0, resultErr)
|
||||
}
|
||||
}()
|
||||
if !isHongguoTask(task) {
|
||||
return errors.New("此版本仅支持红果剧集")
|
||||
}
|
||||
return d.downloadMediaWithProgress(ctx, task, callback)
|
||||
}
|
||||
|
||||
func DownloadTasks(ctx context.Context, d *Downloader, tasks []Task) []Result {
|
||||
if len(tasks) == 0 {
|
||||
return nil
|
||||
}
|
||||
jobs := make(chan Task)
|
||||
results := make(chan Result)
|
||||
var done int64
|
||||
workers := d.cfg.Concurrency
|
||||
if workers > len(tasks) {
|
||||
workers = len(tasks)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for i := 1; i <= workers; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for task := range jobs {
|
||||
cur := atomic.AddInt64(&done, 1)
|
||||
fmt.Printf(" [线程%d] [%d/%d] 《%s》 第%d/%d 集\n", id, cur, len(tasks), task.DramaTitle, task.Index, task.Total)
|
||||
err := d.DownloadEpisode(ctx, task)
|
||||
if err != nil {
|
||||
results <- Result{Task: task, OK: false, Err: err.Error()}
|
||||
} else {
|
||||
results <- Result{Task: task, OK: true}
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
go func() {
|
||||
for _, task := range tasks {
|
||||
jobs <- task
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
close(results)
|
||||
}()
|
||||
var out []Result
|
||||
for r := range results {
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type embyAPIClient struct {
|
||||
settings embySyncSettings
|
||||
client *http.Client
|
||||
transport *http.Transport
|
||||
}
|
||||
|
||||
type embyMediaItem struct {
|
||||
ID string `json:"Id"`
|
||||
Type string `json:"Type"`
|
||||
Path string `json:"Path"`
|
||||
ProviderIDs map[string]string `json:"ProviderIds"`
|
||||
ImageTags map[string]string `json:"ImageTags"`
|
||||
}
|
||||
|
||||
type embyItemList struct {
|
||||
Items []embyMediaItem `json:"Items"`
|
||||
TotalRecordCount int `json:"TotalRecordCount"`
|
||||
}
|
||||
|
||||
func newEmbyAPIClient(settings embySyncSettings) *embyAPIClient {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = nil
|
||||
return &embyAPIClient{settings: settings, transport: transport,
|
||||
client: &http.Client{Transport: transport, Timeout: 95 * time.Second,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
|
||||
}
|
||||
|
||||
func (client *embyAPIClient) close() {
|
||||
client.transport.CloseIdleConnections()
|
||||
}
|
||||
|
||||
func (client *embyAPIClient) request(ctx context.Context, method, endpoint string, query url.Values, result any) error {
|
||||
address := client.settings.ServerURL + endpoint
|
||||
if len(query) > 0 {
|
||||
address += "?" + query.Encode()
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, method, address, nil)
|
||||
if err != nil {
|
||||
return errors.New("Emby 请求地址无效")
|
||||
}
|
||||
request.Header.Set("X-Emby-Token", client.settings.APIKey)
|
||||
request.Header.Set("Accept", "application/json")
|
||||
if method == http.MethodPost && strings.HasSuffix(endpoint, "/RemoteImages/Download") {
|
||||
request.Body = io.NopCloser(strings.NewReader("{}"))
|
||||
request.ContentLength = 2
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
response, err := client.client.Do(request)
|
||||
if err != nil {
|
||||
var failure *url.Error
|
||||
if errors.As(err, &failure) {
|
||||
err = failure.Err
|
||||
}
|
||||
return fmt.Errorf("Emby 连接失败:%w", publicError(err))
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return fmt.Errorf("Emby HTTP %d", response.StatusCode)
|
||||
}
|
||||
if result == nil {
|
||||
_, err = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
|
||||
return err
|
||||
}
|
||||
const limit = 4 << 20
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, limit+1))
|
||||
if err != nil {
|
||||
return errors.New("Emby 响应读取失败")
|
||||
}
|
||||
if len(body) > limit || json.Unmarshal(body, result) != nil {
|
||||
return errors.New("Emby 返回的剧集信息无效或过大")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *embyAPIClient) series(ctx context.Context) ([]embyMediaItem, error) {
|
||||
var items []embyMediaItem
|
||||
seen := make(map[string]bool)
|
||||
for start := 0; start < 100000; {
|
||||
query := url.Values{"Recursive": {"true"}, "IncludeItemTypes": {"Series"}, "Fields": {"Path,ProviderIds"},
|
||||
"EnableImages": {"true"}, "EnableImageTypes": {"Primary"}, "ImageTypeLimit": {"1"},
|
||||
"SortBy": {"SortName"}, "SortOrder": {"Ascending"}, "StartIndex": {strconv.Itoa(start)}, "Limit": {"200"}}
|
||||
var page embyItemList
|
||||
if err := client.request(ctx, http.MethodGet, "/Items", query, &page); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
added := 0
|
||||
for _, item := range page.Items {
|
||||
if validEmbyItemID(item.ID) && item.Type == "Series" && !seen[item.ID] {
|
||||
seen[item.ID] = true
|
||||
items = append(items, item)
|
||||
added++
|
||||
}
|
||||
}
|
||||
start += len(page.Items)
|
||||
if len(page.Items) == 0 || page.TotalRecordCount > 0 && start >= page.TotalRecordCount ||
|
||||
page.TotalRecordCount == 0 && len(page.Items) < 200 {
|
||||
return items, nil
|
||||
}
|
||||
if added == 0 {
|
||||
return nil, errors.New("Emby 剧集分页未前进,请稍后重试")
|
||||
}
|
||||
}
|
||||
return nil, errors.New("Emby 剧集列表超过 100000 部,暂时无法完成海报匹配")
|
||||
}
|
||||
|
||||
func validEmbyItemID(id string) bool {
|
||||
if len(id) == 0 || len(id) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, char := range id {
|
||||
if char != '-' && char != '_' && (char < '0' || char > '9') && (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func embyProviderID(item embyMediaItem, provider string) string {
|
||||
for name, value := range item.ProviderIDs {
|
||||
if strings.EqualFold(name, provider) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func matchEmbySeries(items []embyMediaItem, folder, identity string) (embyMediaItem, error) {
|
||||
var matches []embyMediaItem
|
||||
for _, item := range items {
|
||||
if item.Type != "Series" || !validEmbyItemID(item.ID) {
|
||||
continue
|
||||
}
|
||||
providerID := embyProviderID(item, "juku")
|
||||
if providerID != "" && providerID != identity {
|
||||
continue
|
||||
}
|
||||
itemPath := strings.TrimRight(strings.ReplaceAll(item.Path, "\\", "/"), "/")
|
||||
if !validEmbyFolder(folder) || folder == "" || !strings.HasSuffix("/"+itemPath, "/"+folder) {
|
||||
continue
|
||||
}
|
||||
matches = append(matches, item)
|
||||
}
|
||||
if len(matches) > 1 {
|
||||
return embyMediaItem{}, errors.New("Emby 中存在多个相同导出目录,请移除重复的媒体库挂载后重试")
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return embyMediaItem{}, nil
|
||||
}
|
||||
return matches[0], nil
|
||||
}
|
||||
|
||||
func (client *embyAPIClient) downloadPoster(ctx context.Context, id, address string) (string, bool, error) {
|
||||
if !validEmbyItemID(id) {
|
||||
return "", false, errors.New("Emby 剧集 ID 无效")
|
||||
}
|
||||
query := url.Values{"Type": {"Primary"}, "ImageUrl": {address}}
|
||||
if err := client.request(ctx, http.MethodPost, "/Items/"+id+"/RemoteImages/Download", query, nil); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
tag, err := client.posterTag(ctx, id)
|
||||
return tag, true, err
|
||||
}
|
||||
|
||||
func (client *embyAPIClient) posterTag(ctx context.Context, id string) (string, error) {
|
||||
var result embyItemList
|
||||
if err := client.request(ctx, http.MethodGet, "/Items", url.Values{"Ids": {id}, "EnableImages": {"true"}}, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, item := range result.Items {
|
||||
if item.ID == id && item.ImageTags["Primary"] != "" {
|
||||
return item.ImageTags["Primary"], nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("Emby 尚未返回海报状态,将自动重试")
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type embyChapter struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Number int `json:"number"`
|
||||
}
|
||||
|
||||
func embyFolderName(drama Drama) string {
|
||||
folder := []rune(safeFilename(drama.DisplayTitle()))
|
||||
for len(string(folder)) > 180 {
|
||||
folder = folder[:len(folder)-1]
|
||||
}
|
||||
identity := sha256.Sum256([]byte(drama.ID))
|
||||
return string(folder) + " [" + hex.EncodeToString(identity[:8]) + "]"
|
||||
}
|
||||
|
||||
func embyChapterRecords(id string, chapters []Chapter, previous []embyChapter) ([]embyChapter, error) {
|
||||
if len(chapters) == 0 && len(previous) == 0 || len(chapters) > 2000 || len(previous) > 2000 {
|
||||
return nil, errors.New("可导出分集数无效")
|
||||
}
|
||||
byID := make(map[string]embyChapter, len(previous)+len(chapters))
|
||||
used := make(map[int]bool, len(previous)+len(chapters))
|
||||
for _, chapter := range previous {
|
||||
if !validEmbyIdentity(id, chapter.ID) || chapter.Number < 1 || chapter.Number > 2000 || used[chapter.Number] || byID[chapter.ID].ID != "" {
|
||||
return nil, errors.New("已有 Emby 分集清单无效,原文件已保留")
|
||||
}
|
||||
byID[chapter.ID], used[chapter.Number] = chapter, true
|
||||
}
|
||||
seen := make(map[string]bool, len(chapters))
|
||||
for index, chapter := range chapters {
|
||||
if !validEmbyIdentity(id, chapter.ID) || seen[chapter.ID] {
|
||||
return nil, errors.New("站源未提供唯一、稳定的分集 ID,暂不能导出")
|
||||
}
|
||||
seen[chapter.ID] = true
|
||||
record := byID[chapter.ID]
|
||||
if record.ID == "" {
|
||||
number := index + 1
|
||||
for used[number] && number <= 2000 {
|
||||
number++
|
||||
}
|
||||
if number > 2000 {
|
||||
return nil, errors.New("Emby 分集数超过 2000 集")
|
||||
}
|
||||
record = embyChapter{ID: chapter.ID, Number: number}
|
||||
used[number] = true
|
||||
}
|
||||
record.Title = firstNonEmpty(chapter.Title, "第 "+chapter.EpisodeString(index+1)+" 集")
|
||||
byID[chapter.ID] = record
|
||||
}
|
||||
result := make([]embyChapter, 0, len(byID))
|
||||
for _, chapter := range byID {
|
||||
result = append(result, chapter)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].Number < result[j].Number })
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func writeEmbyFiles(drama Drama, chapters []embyChapter, base string, key []byte, owner string, write func(string, []byte) error, merged ...*embyMergedRecord) error {
|
||||
title := drama.DisplayTitle()
|
||||
type nfoValue struct {
|
||||
Value string `xml:",chardata"`
|
||||
Type string `xml:"type,attr,omitempty"`
|
||||
Aspect string `xml:"aspect,attr,omitempty"`
|
||||
}
|
||||
var thumb *nfoValue
|
||||
if cover := embyCoverURL(drama, base, key, owner); cover != "" {
|
||||
thumb = &nfoValue{Value: cover, Aspect: "poster"}
|
||||
}
|
||||
show := struct {
|
||||
XMLName xml.Name `xml:"tvshow"`
|
||||
Title string `xml:"title"`
|
||||
Plot string `xml:"plot,omitempty"`
|
||||
Identity nfoValue `xml:"uniqueid"`
|
||||
Thumb *nfoValue `xml:"thumb,omitempty"`
|
||||
}{Title: title, Plot: firstNonEmpty(drama.Desc, drama.Intro), Identity: nfoValue{Value: embyMetadataID(key, drama.ID), Type: "juku"}, Thumb: thumb}
|
||||
body, err := xml.MarshalIndent(show, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = write("tvshow.nfo", append([]byte(xml.Header), body...)); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, chapter := range chapters {
|
||||
query := url.Values{"id": {drama.ID}, "chapter": {chapter.ID}, "key": {embyToken(key, drama.ID, chapter.ID, owner)}}
|
||||
if owner != "" {
|
||||
query.Set("account", owner)
|
||||
}
|
||||
path := fmt.Sprintf("Season 01/S01E%03d", chapter.Number)
|
||||
episode := struct {
|
||||
XMLName xml.Name `xml:"episodedetails"`
|
||||
Title string `xml:"title"`
|
||||
Show string `xml:"showtitle"`
|
||||
Season int `xml:"season"`
|
||||
Episode int `xml:"episode"`
|
||||
}{Title: chapter.Title, Show: title, Season: 1, Episode: chapter.Number}
|
||||
body, err = xml.MarshalIndent(episode, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = write(path+".nfo", append([]byte(xml.Header), body...)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = write(path+".strm", []byte(base+"/api/emby/stream.m3u8?"+query.Encode()+"\n")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(merged) > 0 && merged[0] != nil {
|
||||
return writeEmbyMerged(drama, merged[0], base, key, owner, write)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user