Files
book-comic-library/backend/internal/media/media.go
T
XingfenD 7baadedeb4 refactor(handlers): consume port interfaces, wire media+upload assembly (Task 25+26)
- H now holds only small port interfaces (UserStore/LibraryStore/BookStore/
  ProgressStore/BookmarkStore/RateLimiter/Scanner/Media/UploadSessions);
  NewRouter is the composition root distributing *store.Store and *redispkg.R
- ports.Media gains EnsurePage; ChaptersOf returns ports.Chapter (media's
  local duplicate dropped); *media.M now provably satisfies ports.Media;
  ports.UploadSessions gains LibraryID for root validation before Complete
- content.go: duplicated page-index cache + cover self-heal + page extract
  logic removed in favor of media service — same redis keys, same contract;
  path traversal check stays in handler (403 semantics preserved)
- getLibrary/getLibRow merged into getLib(c, id); idParam helper dedupes
  :id parsing; isUnique replaced by ports.IsUniqueViolation (Task 28 partial)
- main.go assembles media + upload and passes upload.U as scanner Sweeper

Full gate green: gofmt, vet, go test -p 1 (real PG+Redis, 0 skip)
2026-09-14 22:56:15 +08:00

166 lines
4.2 KiB
Go

package media
import (
"context"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"booklib/internal/bookfile"
"booklib/internal/config"
"booklib/internal/ports"
"booklib/internal/redispkg"
)
// M is the media service — single source of truth for cache layout and content extraction.
type M struct {
cfg *config.Config
rdb *redispkg.R
}
// compile-time proof that *M satisfies the consumer-side interface.
var _ ports.Media = (*M)(nil)
func New(cfg *config.Config, rdb *redispkg.R) *M {
return &M{cfg: cfg, rdb: rdb}
}
// CacheBuster returns a content hash for cache-busting URLs.
func (m *M) CacheBuster(size, modTS int64) string {
return bookfile.Hash(size, modTS)
}
// EnsureCover extracts and caches the cover image for a book.
// Returns nil if the cover is already cached or the format doesn't support covers.
func (m *M) EnsureCover(ctx context.Context, bookID int64, format string, size, modTS int64, root, rel string) error {
var fn func(io.ReaderAt, int64) ([]byte, string, error)
switch format {
case "cbz":
fn = bookfile.CBZCover
case "epub":
fn = bookfile.EPUBCover
default:
return nil
}
f, fsize, err := bookfile.OpenReaderAt(root, rel)
if err != nil {
return err
}
defer f.Close()
img, ext, err := fn(f, fsize)
if err != nil {
return err
}
dir := bookfile.CoverDir(m.cfg.CacheDir, bookfile.DirKey(bookID, bookfile.Hash(size, modTS)))
return WriteAtomic(dir, "cover"+ext, img)
}
// PageIndex returns the page list for a CBZ, using redis cache when available.
func (m *M) PageIndex(ctx context.Context, bookID int64, size, modTS int64, root, rel string) ([]string, error) {
hash := bookfile.Hash(size, modTS)
key := fmt.Sprintf("pagesidx2:%d:%s", bookID, hash)
if v, ok := m.rdb.Get(ctx, key); ok && v != "" {
return strings.Split(v, "\n"), nil
}
f, fsize, err := bookfile.OpenReaderAt(root, rel)
if err != nil {
return nil, err
}
defer f.Close()
idx, err := bookfile.PageIndex(f, fsize)
if err != nil {
return nil, err
}
if len(idx) > 0 {
m.rdb.Set(ctx, key, strings.Join(idx, "\n"), 7*24*time.Hour)
}
return idx, nil
}
// ChaptersOf derives chapters from a page index by grouping pages by parent directory.
func (m *M) ChaptersOf(idx []string) []ports.Chapter {
type grp struct {
dir string
start int
}
var grps []grp
last := "\x00"
for i, n := range idx {
d := path.Dir(n)
if d == last {
continue
}
last = d
if d == "." {
continue
}
grps = append(grps, grp{d, i})
}
if len(grps) < 2 {
return nil
}
titles := make(map[string]int)
out := make([]ports.Chapter, len(grps))
for i, g := range grps {
out[i] = ports.Chapter{Title: path.Base(g.dir), Start: g.start}
titles[out[i].Title]++
}
for i, g := range grps {
if titles[out[i].Title] > 1 {
out[i].Title = g.dir
}
}
return out
}
// EnsurePage extracts a single page to the cache. Returns the cache file path.
func (m *M) EnsurePage(_ context.Context, bookID int64, size, modTS int64, root, rel string, n int, idx []string) (string, error) {
if n >= len(idx) {
return "", fmt.Errorf("page %d out of range", n)
}
ext := strings.ToLower(filepath.Ext(idx[n]))
dir := bookfile.PagesDir(m.cfg.CacheDir, bookfile.DirKey(bookID, bookfile.Hash(size, modTS)))
dst := filepath.Join(dir, strconv.Itoa(n)+ext)
if _, err := os.Stat(dst); err == nil {
return dst, nil // already cached
}
f, fsize, err := bookfile.OpenReaderAt(root, rel)
if err != nil {
return "", err
}
defer f.Close()
data, err := bookfile.ReadEntry(f, fsize, idx[n])
if err != nil {
return "", err
}
if err := WriteAtomic(dir, strconv.Itoa(n)+ext, data); err != nil {
return "", err
}
return dst, nil
}
// WriteAtomic writes data to dir/name via tmp+rename. B11: cleans tmp only on
// failure, so a crash mid-write never leaves a readable half-written file.
// It does not log — callers own the context (which book, which page).
func WriteAtomic(dir, name string, data []byte) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp := filepath.Join(dir, fmt.Sprintf("%s.tmp-%d", name, time.Now().UnixNano()))
if err := os.WriteFile(tmp, data, 0o644); err != nil {
os.Remove(tmp)
return err
}
dst := filepath.Join(dir, name)
if err := os.Rename(tmp, dst); err != nil {
os.Remove(tmp)
return err
}
return nil
}