286 lines
7.8 KiB
Go
286 lines
7.8 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
type embyFolderManifest struct {
|
|
Version int `json:"version"`
|
|
DramaID string `json:"dramaId"`
|
|
Chapters []embyChapter `json:"chapters"`
|
|
Merged *embyMergedRecord `json:"merged,omitempty"`
|
|
PosterFile string `json:"posterFile,omitempty"`
|
|
PosterHash string `json:"posterHash,omitempty"`
|
|
PosterSource string `json:"posterSource,omitempty"`
|
|
}
|
|
|
|
func validEmbyFolder(folder string) bool {
|
|
if folder == "" {
|
|
return true
|
|
}
|
|
parts := strings.Split(folder, "/")
|
|
if len(parts) > 2 || len(parts) == 2 && !isSourceFolder(parts[0]) {
|
|
return false
|
|
}
|
|
for _, part := range parts {
|
|
if part == "" || len(part) > 240 || part == "." || part == ".." || strings.ContainsAny(part, "\\:\x00\r\n") || filepath.Base(part) != part {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func isSourceFolder(folder string) bool {
|
|
if folder == "其他来源" {
|
|
return true
|
|
}
|
|
for _, choice := range accountSourceChoices {
|
|
if folder == safeFilename(choice.Name) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func embyDirectoryParent(root, folder string, create bool) (string, error) {
|
|
if !validEmbyFolder(folder) || folder == "" {
|
|
return "", errors.New("Emby 剧集目录无效")
|
|
}
|
|
parts := strings.Split(folder, "/")
|
|
if len(parts) == 1 {
|
|
return root, nil
|
|
}
|
|
parent := filepath.Join(root, parts[0])
|
|
if create {
|
|
return parent, ensureEmbyDirectory(parent)
|
|
}
|
|
info, err := os.Lstat(parent)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
|
return "", errors.New("Emby 站源目录不是普通文件夹")
|
|
}
|
|
return parent, nil
|
|
}
|
|
|
|
func findManagedEmbyFolder(root string, drama Drama) (string, error) {
|
|
name := embyFolderName(drama)
|
|
suffix := name[strings.LastIndex(name, " ["):]
|
|
var found string
|
|
var scan func(string, string) error
|
|
scan = func(directory, prefix string) error {
|
|
entries, err := os.ReadDir(directory)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() || entry.Type()&os.ModeSymlink != 0 {
|
|
continue
|
|
}
|
|
if prefix == "" && isSourceFolder(entry.Name()) {
|
|
if err := scan(filepath.Join(directory, entry.Name()), entry.Name()+"/"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
folder := prefix + entry.Name()
|
|
if !strings.HasSuffix(entry.Name(), suffix) || !validEmbyFolder(folder) {
|
|
continue
|
|
}
|
|
var manifest embyFolderManifest
|
|
if err := readEmbyJSON(filepath.Join(directory, entry.Name(), ".juku-emby.json"), &manifest, 8<<20); err != nil || manifest.Version != 1 || manifest.DramaID != drama.ID {
|
|
continue
|
|
}
|
|
if found != "" && found != folder {
|
|
return errors.New("发现本剧的多个 Emby 目录,已停止生成重复媒体")
|
|
}
|
|
found = folder
|
|
}
|
|
return nil
|
|
}
|
|
err := scan(root, "")
|
|
return found, err
|
|
}
|
|
|
|
func ensureEmbyDirectory(path string) error {
|
|
info, err := os.Lstat(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return os.Mkdir(path, 0755)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
|
return errors.New("Emby 生成目录不是普通文件夹,已停止写入")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func syncEmbyDramaFiles(ctx context.Context, settings embySyncSettings, drama Drama, chapters []Chapter, folder string, key []byte, owner string, merged ...*embyMergedRecord) (string, int, int, error) {
|
|
if !validEmbyFolder(folder) {
|
|
return folder, 0, 0, errors.New("Emby 剧集目录无效")
|
|
}
|
|
if err := os.MkdirAll(settings.OutputDir, 0755); err != nil {
|
|
return folder, 0, 0, err
|
|
}
|
|
root, err := filepath.EvalSymlinks(settings.OutputDir)
|
|
if err != nil {
|
|
return folder, 0, 0, err
|
|
}
|
|
if folder == "" {
|
|
folder, err = findManagedEmbyFolder(root, drama)
|
|
if err != nil {
|
|
return folder, 0, 0, err
|
|
}
|
|
if folder == "" {
|
|
folder = embyFolderName(drama)
|
|
if settings.GroupBySource {
|
|
folder = dramaSourceFolder(drama) + "/" + folder
|
|
}
|
|
}
|
|
}
|
|
parent, err := embyDirectoryParent(root, folder, true)
|
|
if err != nil {
|
|
return folder, 0, 0, err
|
|
}
|
|
target := filepath.Join(root, folder)
|
|
work := target
|
|
var previous []embyChapter
|
|
manifest := embyFolderManifest{Version: 1, DramaID: drama.ID}
|
|
staged := false
|
|
info, err := os.Lstat(target)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
work, err = os.MkdirTemp(parent, ".juku-emby-")
|
|
if err != nil {
|
|
return folder, 0, 0, err
|
|
}
|
|
staged = true
|
|
defer os.RemoveAll(work)
|
|
if err = os.Chmod(work, 0755); err != nil {
|
|
return folder, 0, 0, err
|
|
}
|
|
} else {
|
|
if err != nil {
|
|
return folder, 0, 0, err
|
|
}
|
|
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
|
return folder, 0, 0, errors.New("Emby 剧集目录不是普通文件夹")
|
|
}
|
|
manifestPath := filepath.Join(work, ".juku-emby.json")
|
|
info, err := os.Lstat(manifestPath)
|
|
if err != nil || !info.Mode().IsRegular() {
|
|
return folder, 0, 0, errors.New("同名目录不属于自动同步,原内容已保留;请使用单独的 Emby 输出目录")
|
|
}
|
|
if err = readEmbyJSON(manifestPath, &manifest, 8<<20); err != nil {
|
|
return folder, 0, 0, err
|
|
}
|
|
if manifest.Version != 1 || manifest.DramaID != drama.ID {
|
|
return folder, 0, 0, errors.New("Emby 目录所属剧集不匹配,原内容已保留")
|
|
}
|
|
previous = manifest.Chapters
|
|
}
|
|
if len(merged) > 0 && merged[0] != nil {
|
|
manifest.Merged = merged[0]
|
|
}
|
|
if manifest.Merged != nil && !manifest.Merged.valid() {
|
|
return folder, 0, 0, errors.New("Emby 合并版记录无效,原文件已保留")
|
|
}
|
|
if len(chapters) == 0 && manifest.Merged == nil {
|
|
return folder, 0, 0, errors.New("站源未返回分集,已有 Emby 文件已保留,稍后将重试")
|
|
}
|
|
var records []embyChapter
|
|
if len(chapters) > 0 || len(previous) > 0 || manifest.Merged == nil {
|
|
records, err = embyChapterRecords(drama.ID, chapters, previous)
|
|
}
|
|
if err != nil {
|
|
return folder, 0, 0, err
|
|
}
|
|
for _, season := range []struct {
|
|
name string
|
|
enabled bool
|
|
}{{"Season 01", len(records) > 0}, {"Season 00", manifest.Merged != nil}} {
|
|
if season.enabled {
|
|
if err = ensureEmbyDirectory(filepath.Join(work, season.name)); err != nil {
|
|
return folder, 0, 0, err
|
|
}
|
|
}
|
|
}
|
|
written := 0
|
|
err = writeEmbyFiles(drama, records, settings.BaseURL, key, owner, func(name string, body []byte) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
changed, err := writeEmbyFile(filepath.Join(work, filepath.FromSlash(name)), body, 0644)
|
|
if changed {
|
|
written++
|
|
}
|
|
return err
|
|
}, manifest.Merged)
|
|
if err == nil {
|
|
manifest.Chapters = records
|
|
body, marshalErr := json.MarshalIndent(manifest, "", " ")
|
|
err = marshalErr
|
|
if err == nil {
|
|
_, err = writeEmbyFile(filepath.Join(work, ".juku-emby.json"), append(body, '\n'), 0600)
|
|
}
|
|
}
|
|
if err == nil {
|
|
err = ctx.Err()
|
|
}
|
|
if staged {
|
|
if err == nil {
|
|
err = os.Rename(work, target)
|
|
}
|
|
if err != nil {
|
|
written = 0
|
|
}
|
|
}
|
|
return folder, written, len(records), err
|
|
}
|
|
|
|
func writeEmbyFile(path string, body []byte, mode os.FileMode) (bool, error) {
|
|
info, err := os.Lstat(path)
|
|
if err == nil {
|
|
if !info.Mode().IsRegular() || info.Size() > 8<<20 {
|
|
return false, errors.New("Emby 目标文件不是普通文件或文件过大,已停止写入")
|
|
}
|
|
old, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if bytes.Equal(old, body) {
|
|
return false, nil
|
|
}
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return false, err
|
|
}
|
|
file, err := os.CreateTemp(filepath.Dir(path), ".juku-emby-write-")
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer os.Remove(file.Name())
|
|
if err = file.Chmod(mode); err == nil {
|
|
_, err = file.Write(body)
|
|
}
|
|
if err == nil {
|
|
err = file.Sync()
|
|
}
|
|
if closeErr := file.Close(); err == nil {
|
|
err = closeErr
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if err = os.Rename(file.Name(), path); err != nil {
|
|
return false, fmt.Errorf("Emby 文件替换失败:%w", err)
|
|
}
|
|
return true, nil
|
|
}
|