248 lines
8.5 KiB
Go
248 lines
8.5 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
type embyPosterState struct {
|
|
LocalFile string `json:"localFile,omitempty"`
|
|
LocalSource string `json:"localSource,omitempty"`
|
|
LocalError string `json:"localError,omitempty"`
|
|
LocalCheckedAt time.Time `json:"localCheckedAt"`
|
|
LocalRetryAt time.Time `json:"localRetryAt"`
|
|
SourceURL string `json:"sourceUrl,omitempty"`
|
|
Destination string `json:"destination,omitempty"`
|
|
ItemID string `json:"itemId,omitempty"`
|
|
ImageTag string `json:"imageTag,omitempty"`
|
|
SyncedSource string `json:"syncedSource,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
CheckedAt time.Time `json:"checkedAt"`
|
|
SyncedAt time.Time `json:"syncedAt"`
|
|
RetryAt time.Time `json:"retryAt"`
|
|
Failures int `json:"failures,omitempty"`
|
|
}
|
|
|
|
func embyPosterDestination(settings embySyncSettings, key []byte) string {
|
|
digest := sha256.Sum256([]byte(settings.ServerURL + "\x00" + settings.OutputDir + "\x00" + embyMetadataID(key, "instance")))
|
|
return hex.EncodeToString(digest[:])
|
|
}
|
|
|
|
func embyPosterSourceHash(remote string) string {
|
|
digest := sha256.Sum256([]byte(remote))
|
|
return hex.EncodeToString(digest[:])
|
|
}
|
|
|
|
func embyRemotePosterDue(poster embyPosterState, interval time.Duration) time.Time {
|
|
if poster.Status == "missing" || poster.Status == "unconfigured" || poster.Status == "local" || poster.SourceURL == "" {
|
|
return time.Time{}
|
|
}
|
|
due := poster.CheckedAt.Add(interval)
|
|
if !poster.RetryAt.IsZero() {
|
|
due = poster.RetryAt
|
|
}
|
|
if poster.CheckedAt.IsZero() {
|
|
due = time.Now()
|
|
}
|
|
return due
|
|
}
|
|
|
|
func embyLocalPosterDue(poster embyPosterState, interval time.Duration) time.Time {
|
|
if poster.SourceURL == "" {
|
|
return time.Time{}
|
|
}
|
|
if !poster.LocalRetryAt.IsZero() {
|
|
return poster.LocalRetryAt
|
|
}
|
|
if poster.LocalCheckedAt.IsZero() || poster.LocalFile == "" || poster.LocalSource != embyPosterSourceHash(poster.SourceURL) {
|
|
return time.Now()
|
|
}
|
|
return poster.LocalCheckedAt.Add(interval)
|
|
}
|
|
|
|
func embyPosterDue(poster embyPosterState, interval time.Duration) time.Time {
|
|
due := embyRemotePosterDue(poster, interval)
|
|
if local := embyLocalPosterDue(poster, interval); !local.IsZero() && (due.IsZero() || local.Before(due)) {
|
|
due = local
|
|
}
|
|
return due
|
|
}
|
|
|
|
func (manager *embySyncManager) preparePosters(document *embySyncDocument, targets []Drama, key []byte) {
|
|
destination := embyPosterDestination(document.Settings, key)
|
|
for _, drama := range targets {
|
|
entry := document.Entries[drama.ID]
|
|
if entry.Episodes == 0 && !entry.Merged {
|
|
continue
|
|
}
|
|
poster := entry.Poster
|
|
if poster.Destination != destination {
|
|
poster = embyPosterState{SourceURL: poster.SourceURL, Destination: destination}
|
|
}
|
|
remote := embyDramaCover(drama)
|
|
if remote == "" {
|
|
remote = embyDramaCover(Drama{CoverURL: poster.SourceURL})
|
|
}
|
|
if poster.SourceURL != remote {
|
|
poster.SourceURL = remote
|
|
poster.CheckedAt, poster.RetryAt, poster.Failures = time.Time{}, time.Time{}, 0
|
|
poster.Status, poster.Error = "pending", ""
|
|
poster.LocalCheckedAt, poster.LocalRetryAt = time.Time{}, time.Time{}
|
|
}
|
|
if remote == "" {
|
|
poster.Status, poster.Error = "missing", ""
|
|
} else if document.Settings.ServerURL == "" {
|
|
if poster.LocalFile != "" && poster.LocalSource == embyPosterSourceHash(remote) && poster.LocalError == "" {
|
|
poster.Status, poster.Error = "local", ""
|
|
} else if poster.LocalError != "" {
|
|
poster.Status, poster.Error = "failed", poster.LocalError
|
|
} else {
|
|
poster.Status, poster.Error = "pending", ""
|
|
}
|
|
} else if poster.Status == "" || poster.Status == "missing" || poster.Status == "unconfigured" {
|
|
poster.Status = "pending"
|
|
poster.CheckedAt, poster.RetryAt = time.Time{}, time.Time{}
|
|
}
|
|
entry.Poster = poster
|
|
document.Entries[drama.ID] = entry
|
|
}
|
|
}
|
|
|
|
func (manager *embySyncManager) postponePoster(poster *embyPosterState, waiting bool, err error, key string) {
|
|
poster.Status = "failed"
|
|
if waiting {
|
|
poster.Status = "waiting"
|
|
}
|
|
poster.Error = manager.safeError(err, key)
|
|
poster.CheckedAt = time.Now()
|
|
if poster.Failures < 5 {
|
|
poster.Failures++
|
|
}
|
|
delays := [...]time.Duration{time.Minute, 2 * time.Minute, 5 * time.Minute, 10 * time.Minute, 15 * time.Minute}
|
|
poster.RetryAt = time.Now().Add(delays[poster.Failures-1])
|
|
}
|
|
|
|
func (manager *embySyncManager) synchronizePosters(ctx context.Context, document *embySyncDocument, targets []Drama, key []byte) {
|
|
if document.Settings.ServerURL == "" {
|
|
return
|
|
}
|
|
interval := time.Duration(document.Settings.IntervalMinutes) * time.Minute
|
|
var due []string
|
|
for _, drama := range targets {
|
|
poster := document.Entries[drama.ID].Poster
|
|
if next := embyRemotePosterDue(poster, interval); !next.IsZero() && !next.After(time.Now()) {
|
|
due = append(due, drama.ID)
|
|
}
|
|
}
|
|
sort.Slice(due, func(i, j int) bool {
|
|
left, right := document.Entries[due[i]].Poster.CheckedAt, document.Entries[due[j]].Poster.CheckedAt
|
|
if left.Equal(right) {
|
|
return due[i] < due[j]
|
|
}
|
|
return left.Before(right)
|
|
})
|
|
if len(due) > 20 {
|
|
due = due[:20]
|
|
}
|
|
if len(due) == 0 {
|
|
return
|
|
}
|
|
client := newEmbyAPIClient(document.Settings)
|
|
defer client.close()
|
|
listCtx, cancel := context.WithTimeout(ctx, time.Minute)
|
|
items, listErr := client.series(listCtx)
|
|
cancel()
|
|
for _, id := range due {
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
entry := document.Entries[id]
|
|
poster := entry.Poster
|
|
document.PosterChecked++
|
|
if listErr != nil {
|
|
manager.postponePoster(&poster, false, listErr, document.Settings.APIKey)
|
|
} else {
|
|
item, err := matchEmbySeries(items, entry.Folder, embyMetadataID(key, id))
|
|
if err != nil {
|
|
manager.postponePoster(&poster, false, err, document.Settings.APIKey)
|
|
} else if item.ID == "" {
|
|
manager.postponePoster(&poster, true, errors.New("等待 Emby 扫描此剧;请确认输出目录已加入电视剧媒体库,并启用 NFO 元数据读取"), document.Settings.APIKey)
|
|
} else {
|
|
manager.synchronizePoster(ctx, client, document, id, &poster, item, key)
|
|
}
|
|
}
|
|
entry.Poster = poster
|
|
document.Entries[id] = entry
|
|
}
|
|
}
|
|
|
|
func (manager *embySyncManager) synchronizePoster(ctx context.Context, client *embyAPIClient, document *embySyncDocument, id string, poster *embyPosterState, item embyMediaItem, key []byte) {
|
|
currentTag := item.ImageTags["Primary"]
|
|
sourceHash := embyPosterSourceHash(poster.SourceURL)
|
|
sameItem := poster.ItemID == item.ID
|
|
managed := sameItem && poster.ImageTag != "" && currentTag == poster.ImageTag
|
|
if currentTag != "" && !managed {
|
|
poster.Status, poster.Error = "existing", ""
|
|
poster.ItemID, poster.ImageTag, poster.SyncedSource = item.ID, "", ""
|
|
poster.SyncedAt = time.Time{}
|
|
poster.CheckedAt, poster.RetryAt, poster.Failures = time.Now(), time.Time{}, 0
|
|
return
|
|
}
|
|
if currentTag != "" && managed && poster.SyncedSource == sourceHash {
|
|
poster.Status, poster.Error, poster.ImageTag = "synced", "", currentTag
|
|
poster.CheckedAt, poster.RetryAt, poster.Failures = time.Now(), time.Time{}, 0
|
|
return
|
|
}
|
|
address := embyCoverURL(Drama{ID: id, CoverURL: poster.SourceURL}, document.Settings.BaseURL, key, document.OwnerID)
|
|
if address == "" {
|
|
manager.postponePoster(poster, false, errors.New("海报地址无效,请更新剧集资料后重试"), document.Settings.APIKey)
|
|
return
|
|
}
|
|
requestCtx, cancel := context.WithTimeout(ctx, 95*time.Second)
|
|
defer cancel()
|
|
tag, uploaded, err := client.downloadPoster(requestCtx, item.ID, address)
|
|
if uploaded {
|
|
poster.ItemID, poster.SyncedSource, poster.ImageTag = item.ID, sourceHash, ""
|
|
poster.SyncedAt = time.Now()
|
|
document.PostersUpdated++
|
|
}
|
|
if err != nil {
|
|
manager.postponePoster(poster, false, err, document.Settings.APIKey)
|
|
return
|
|
}
|
|
poster.ImageTag, poster.Status, poster.Error = tag, "synced", ""
|
|
poster.CheckedAt, poster.RetryAt, poster.Failures = time.Now(), time.Time{}, 0
|
|
}
|
|
|
|
func (document *embySyncDocument) countPosters(targets []Drama) {
|
|
document.PostersPending, document.PostersFailed, document.PostersSynced = 0, 0, 0
|
|
for _, drama := range targets {
|
|
poster := document.Entries[drama.ID].Poster
|
|
if poster.LocalError != "" {
|
|
document.PostersFailed++
|
|
continue
|
|
}
|
|
switch poster.Status {
|
|
case "pending", "waiting":
|
|
document.PostersPending++
|
|
case "failed":
|
|
document.PostersFailed++
|
|
case "synced", "local":
|
|
document.PostersSynced++
|
|
}
|
|
}
|
|
}
|
|
|
|
func (document *embySyncDocument) posterError() error {
|
|
if document.PostersFailed == 0 {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("%d 部海报同步失败,分集文件保留,海报会自动重试;请查看下方详情", document.PostersFailed)
|
|
}
|