- add/update consolidated into ingest(isNew) — persist branch differs, error handling and cover path fully shared (Task 24) - zipIndex/cover use bookfile.OpenReaderAt instead of hand-rolled open+stat pairs - root containment check uses bookfile.Contains (local inside removed) - cover write goes through media.WriteAtomic (now exported, log-free — callers own context); media no longer logs inside the atomic helper
168 lines
4.2 KiB
Go
168 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/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, 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
|
|
}
|