feat(app): add internal components batch 2
This commit is contained in:
@@ -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 尚未返回海报状态,将自动重试")
|
||||
}
|
||||
Reference in New Issue
Block a user