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 }