From 443f4acfa917daab2c1af7fd70271bc3c765855b Mon Sep 17 00:00:00 2001 From: XingfenD Date: Mon, 14 Sep 2026 19:50:34 +0800 Subject: [PATCH] feat(media): Media service with EnsureCover, EnsurePage, PageIndex, ChaptersOf, writeAtomic --- backend/internal/media/media.go | 168 ++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 backend/internal/media/media.go diff --git a/backend/internal/media/media.go b/backend/internal/media/media.go new file mode 100644 index 0000000..9531eb4 --- /dev/null +++ b/backend/internal/media/media.go @@ -0,0 +1,168 @@ +package media + +import ( + "context" + "fmt" + "io" + "log" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "time" + + "booklib/internal/bookfile" + "booklib/internal/config" + "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 +} + +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) []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([]Chapter, len(grps)) + for i, g := range grps { + out[i] = 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 +} + +// Chapter represents a CBZ chapter. +type Chapter struct { + Title string `json:"title"` + Start int `json:"start"` +} + +// EnsurePage extracts a single page to the cache. Returns the cache file path. +func (m *M) EnsurePage(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. +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 { + log.Printf("media: write %s: %v", name, err) + os.Remove(tmp) + return err + } + dst := filepath.Join(dir, name) + if err := os.Rename(tmp, dst); err != nil { + log.Printf("media: rename %s: %v", name, err) + os.Remove(tmp) + return err + } + return nil +}