feat: implement relative path storage and URL resolution for media files

- Add libraryService dependency to CollectionHandler and OPDSHandler for centralized path resolution
- Create internal/utils/mediaurl.go with ResolveMediaURL() function as single source of truth
- Update GetMediaItem and ListMediaItems handlers to return resolved URLs in API responses
- Update collection handlers (GetCollection, TestRules, PreviewCollection) to use resolved cover URLs
- Update progress handler (GetAllProgress) to use resolved cover URLs
- Add library_id to GetCollectionItems SQL query to enable URL resolution
- Refactor media scanner to store relative paths instead of absolute filesystem paths
- Add ResolveMediaPath() to LibraryService for resolving relative paths to absolute paths
- Add ServeFile endpoint at /uploads/library-:id/* for authenticated file serving
- Add MimeTypes map to library_service.go for consistent MIME type handling
- Update DownloadBook handler to use resolved filesystem paths
- Add getRelativePath() helper to MediaScanner for converting absolute to relative paths
- Use strings.EqualFold for case-insensitive path comparisons in zip extraction

This change enables the application to work with relative paths stored in the
database, making it portable across different server environments while
maintaining backward compatibility with existing absolute paths.
This commit is contained in:
2026-02-27 16:51:44 -05:00
parent 123ab0c966
commit 209e9f2a3c
13 changed files with 300 additions and 99 deletions
+55 -1
View File
@@ -31,7 +31,38 @@ const (
var AllowedExtensions = map[string][]string{
LibraryTypeEbooks: {".epub", ".pdf", ".mobi", ".azw", ".azw3", ".txt", ".rtf", ".doc", ".docx", ".lit", ".fb2", ".pdb"},
LibraryTypeComics: {".cbz", ".cbr", ".cb7", ".cbt", ".pdf"},
LibraryTypeManga: {".cbz", ".cbr", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"},
LibraryTypeManga: {".cbz", ".cbr", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".avif", ".tiff", ".tif"},
}
var MimeTypes = map[string]string{
// Images (manga)
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".bmp": "image/bmp",
".webp": "image/webp",
".avif": "image/avif",
".tiff": "image/tiff",
".tif": "image/tiff",
// Comics
".cbz": "application/vnd.comicbook+zip",
".cbr": "application/vnd.comicbook-rar",
".cb7": "application/x-cb7",
".cbt": "application/x-cbt",
// Ebooks
".epub": "application/epub+zip",
".pdf": "application/pdf",
".mobi": "application/x-mobipocket-ebook",
".azw": "application/vnd.amazon.ebook",
".azw3": "application/vnd.amazon.ebook",
".txt": "text/plain",
".rtf": "application/rtf",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".lit": "application/x-msreader",
".fb2": "application/x-fictionbook+xml",
".pdb": "application/vnd.palm",
}
// GetLibraryTypes retrieves all available library types
@@ -254,3 +285,26 @@ func (s *LibraryService) BrowseDirectories(ctx context.Context, path string) ([]
return dirs, cleanPath, parentPath, nil
}
func (s *LibraryService) ResolveMediaPath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) {
// Get library folders for this library
folders, err := s.db.GetLibraryFolders(ctx, libraryID)
if err != nil || len(folders) == 0 {
return "", fmt.Errorf("no library folders found for library")
}
// Try each folder - find one where the relative path makes sense
for _, folder := range folders {
fullPath := filepath.Join(folder.FolderPath, relativePath)
if _, err := os.Stat(fullPath); err == nil {
return fullPath, nil
}
}
// Fallback: use first folder (file might not exist yet during scan)
if len(folders) > 0 {
return filepath.Join(folders[0].FolderPath, relativePath), nil
}
return "", fmt.Errorf("could not resolve path")
}
+27 -34
View File
@@ -26,6 +26,7 @@ import (
"time"
"bookhoard/internal/sevenzip"
epub "github.com/ArcadiaLin/go-epub"
"github.com/fsnotify/fsnotify"
"github.com/jackc/pgx/v5"
@@ -514,7 +515,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
if len(coverImage) > 0 && metadata.CoverPath == "" {
coverPath := path + ".cover.jpg"
if err := os.WriteFile(coverPath, coverImage, 0644); err == nil {
metadata.CoverPath = coverPath
metadata.CoverPath = s.getRelativePath(coverPath)
}
}
fmt.Printf("Extracted comic metadata from %s: title=%s, series=%s, issue=%d\n",
@@ -576,7 +577,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""},
Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""},
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
FilePath: path,
FilePath: s.getRelativePath(path),
FileSize: pgtype.Int8{Int64: info.Size(), Valid: true},
MimeType: pgtype.Text{String: s.getMimeType(path), Valid: true},
CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""},
@@ -614,7 +615,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
_, err = s.db.CreateMediaItemFormat(ctx, database.CreateMediaItemFormatParams{
MediaItemID: createdItem.ID,
FormatType: format.FormatType,
FilePath: pgtype.Text{String: format.FilePath, Valid: true},
FilePath: pgtype.Text{String: s.getRelativePath(format.FilePath), Valid: true},
FileSha256: pgtype.Text{String: format.FileSHA256, Valid: true},
FileSizeBytes: pgtype.Int8{Int64: format.FileSizeBytes, Valid: true},
MimeType: pgtype.Text{String: format.MimeType, Valid: true},
@@ -642,13 +643,13 @@ func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
if err != nil {
fmt.Printf("Warning: failed to extract EPUB cover from %s: %v\n", path, err)
} else if coverPath != "" {
metadata.CoverPath = coverPath
metadata.CoverPath = s.getRelativePath(coverPath)
}
// If no embedded cover, try sidecar
if metadata.CoverPath == "" {
sidecarCover := findSidecarCover(path)
if sidecarCover != "" {
metadata.CoverPath = sidecarCover
metadata.CoverPath = s.getRelativePath(sidecarCover)
}
}
return metadata, nil
@@ -845,7 +846,7 @@ func findCoverImageInZip(files []*zip.File) string {
for _, name := range coverNames {
for _, f := range files {
if strings.ToLower(f.Name) == strings.ToLower(name) {
if strings.EqualFold(f.Name, name) {
return f.Name
}
}
@@ -926,7 +927,7 @@ func readFileFromZip(files []*zip.File, name string) ([]byte, error) {
func extractImageFromZip(files []*zip.File, imagePath, opfDir string) ([]byte, error) {
// Try direct match first
for _, f := range files {
if strings.ToLower(f.Name) == strings.ToLower(imagePath) {
if strings.EqualFold(f.Name, imagePath) {
rc, err := f.Open()
if err != nil {
return nil, err
@@ -939,7 +940,7 @@ func extractImageFromZip(files []*zip.File, imagePath, opfDir string) ([]byte, e
// Try resolved path
resolvedPath := resolveOPFPath(opfDir, imagePath)
for _, f := range files {
if strings.ToLower(f.Name) == strings.ToLower(resolvedPath) {
if strings.EqualFold(f.Name, resolvedPath) {
rc, err := f.Open()
if err != nil {
return nil, err
@@ -1057,14 +1058,14 @@ func (s *MediaScanner) extractPDFMetadata(path string) (*MediaMetadata, error) {
if err != nil {
fmt.Printf("Warning: failed to extract PDF cover from %s: %v\n", path, err)
} else if coverPath != "" {
metadata.CoverPath = coverPath
metadata.CoverPath = s.getRelativePath(coverPath)
}
// If no embedded cover, try sidecar
if metadata.CoverPath == "" {
sidecarCover := findSidecarCover(path)
if sidecarCover != "" {
metadata.CoverPath = sidecarCover
metadata.CoverPath = s.getRelativePath(sidecarCover)
}
}
@@ -1469,7 +1470,7 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, mediaItemID pgtype.U
Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""},
Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""},
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""},
CoverImagePath: pgtype.Text{String: s.getRelativePath(metadata.CoverPath), Valid: metadata.CoverPath != ""},
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
Tags: metadata.Tags,
@@ -1492,30 +1493,10 @@ func (s *MediaScanner) getMediaItemByFilePath(ctx context.Context, filePath stri
func (s *MediaScanner) getMimeType(path string) string {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".epub":
return "application/epub+zip"
case ".pdf":
return "application/pdf"
case ".mobi":
return "application/x-mobipocket-ebook"
case ".azw3":
return "application/vnd.amazon.ebook"
case ".fb2":
return "application/x-fictionbook+xml"
case ".txt":
return "text/plain"
case ".cbz":
return "application/vnd.comicbook+zip"
case ".cbr":
return "application/vnd.comicbook-rar"
case ".cb7":
return "application/x-7z-compressed"
case ".cbt":
return "application/x-tar"
default:
return "application/octet-stream"
if mime, ok := MimeTypes[ext]; ok {
return mime
}
return "application/octet-stream"
}
func (s *MediaScanner) WatchChanges(ctx context.Context) {
@@ -1811,3 +1792,15 @@ func (s *MediaScanner) extractHashInfo(filePath string) (*HashInfo, *FormatInfo,
return hashInfo, formatInfo, nil
}
func (s *MediaScanner) getRelativePath(absolutePath string) string {
// Get the base folder paths from scanner
for _, baseFolder := range s.folders {
// Check if path is within this base folder
if relPath, ok := strings.CutPrefix(absolutePath, baseFolder); ok {
return strings.TrimPrefix(relPath, "/")
}
}
// Fallback: if no match, return as-is (shouldn't happen)
return absolutePath
}