tagliatelle

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs

include-cbz.go (8805B)


      1 package main
      2 
      3 import (
      4 	"archive/zip"
      5 	"fmt"
      6 	"image"
      7 	"image/jpeg"
      8 	"io"
      9 	"log"
     10 	"net/http"
     11 	"os"
     12 	"path/filepath"
     13 	"sort"
     14 	"strings"
     15 )
     16 
     17 // generateCBZThumbnail creates a 2x2 collage thumbnail from a CBZ file
     18 func generateCBZThumbnail(cbzPath, uploadDir, filename string) error {
     19 
     20 	thumbDir := filepath.Join(uploadDir, "thumbnails")
     21 
     22 	if err := os.MkdirAll(thumbDir, 0755); err != nil {
     23 		return fmt.Errorf("failed to create thumbnails directory: %v", err)
     24 	}
     25 
     26 	thumbPath := filepath.Join(thumbDir, filename+".jpg")
     27 
     28 	// Open the CBZ (ZIP) file
     29 	r, err := zip.OpenReader(cbzPath)
     30 	if err != nil {
     31 		return fmt.Errorf("failed to open CBZ: %v", err)
     32 	}
     33 	defer r.Close()
     34 
     35 	// Get list of image files from the archive
     36 	var imageFiles []*zip.File
     37 	for _, f := range r.File {
     38 		if !f.FileInfo().IsDir() {
     39 			ext := strings.ToLower(filepath.Ext(f.Name))
     40 			if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".webp" {
     41 				imageFiles = append(imageFiles, f)
     42 			}
     43 		}
     44 	}
     45 
     46 	if len(imageFiles) == 0 {
     47 		return fmt.Errorf("no images found in CBZ")
     48 	}
     49 
     50 	// Sort files by name to get consistent ordering
     51 	sort.Slice(imageFiles, func(i, j int) bool {
     52 		return imageFiles[i].Name < imageFiles[j].Name
     53 	})
     54 
     55 	// Select up to 4 images evenly distributed
     56 	var selectedFiles []*zip.File
     57 	if len(imageFiles) <= 4 {
     58 		selectedFiles = imageFiles
     59 	} else {
     60 		// Pick 4 images evenly distributed through the comic
     61 		step := len(imageFiles) / 4
     62 		for i := 0; i < 4; i++ {
     63 			selectedFiles = append(selectedFiles, imageFiles[i*step])
     64 		}
     65 	}
     66 
     67 	// Load the selected images
     68 	var images []image.Image
     69 	for _, f := range selectedFiles {
     70 		rc, err := f.Open()
     71 		if err != nil {
     72 			log.Printf("Warning: generateCBZThumbnail: failed to open %s: %v", f.Name, err)
     73 			continue
     74 		}
     75 
     76 		img, _, err := image.Decode(rc)
     77 		rc.Close()
     78 		if err != nil {
     79 			log.Printf("Warning: generateCBZThumbnail: failed to decode %s: %v", f.Name, err)
     80 			continue
     81 		}
     82 		images = append(images, img)
     83 	}
     84 
     85 	if len(images) == 0 {
     86 		return fmt.Errorf("failed to decode any images from CBZ")
     87 	}
     88 
     89 	// Create collage
     90 	collage := createCollage(images, 400) // 400px target width
     91 
     92 	// Save as JPEG
     93 	outFile, err := os.Create(thumbPath)
     94 	if err != nil {
     95 		return fmt.Errorf("failed to create thumbnail file: %v", err)
     96 	}
     97 	defer outFile.Close()
     98 
     99 	if err := jpeg.Encode(outFile, collage, &jpeg.Options{Quality: 85}); err != nil {
    100 		return fmt.Errorf("failed to encode JPEG: %v", err)
    101 	}
    102 
    103 	return nil
    104 }
    105 
    106 // createCollage creates a 2x2 grid from up to 4 images
    107 func createCollage(images []image.Image, targetWidth int) image.Image {
    108 	// Calculate cell size (half of target width)
    109 	cellSize := targetWidth / 2
    110 
    111 	// Create output image
    112 	collageImg := image.NewRGBA(image.Rect(0, 0, targetWidth, targetWidth))
    113 
    114 	// Fill with white background
    115 	for y := 0; y < targetWidth; y++ {
    116 		for x := 0; x < targetWidth; x++ {
    117 			collageImg.Set(x, y, image.White)
    118 		}
    119 	}
    120 
    121 	// Positions for the 2x2 grid
    122 	positions := []image.Point{
    123 		{0, 0},                    // Top-left
    124 		{cellSize, 0},             // Top-right
    125 		{0, cellSize},             // Bottom-left
    126 		{cellSize, cellSize},      // Bottom-right
    127 	}
    128 
    129 	// Draw each image
    130 	for i, img := range images {
    131 		if i >= 4 {
    132 			break
    133 		}
    134 
    135 		// Resize image to fit cell
    136 		resized := resizeImage(img, cellSize, cellSize)
    137 
    138 		// Draw at position
    139 		pos := positions[i]
    140 		drawImage(collageImg, resized, pos.X, pos.Y)
    141 	}
    142 
    143 	return collageImg
    144 }
    145 
    146 // resizeImage resizes an image to fit within maxWidth x maxHeight while maintaining aspect ratio
    147 func resizeImage(img image.Image, maxWidth, maxHeight int) image.Image {
    148 	bounds := img.Bounds()
    149 	srcWidth := bounds.Dx()
    150 	srcHeight := bounds.Dy()
    151 
    152 	// Calculate scale to fit
    153 	scaleX := float64(maxWidth) / float64(srcWidth)
    154 	scaleY := float64(maxHeight) / float64(srcHeight)
    155 	scale := scaleX
    156 	if scaleY < scale {
    157 		scale = scaleY
    158 	}
    159 
    160 	newWidth := int(float64(srcWidth) * scale)
    161 	newHeight := int(float64(srcHeight) * scale)
    162 
    163 	// Create new image
    164 	dst := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight))
    165 
    166 	// Simple nearest-neighbor scaling
    167 	for y := 0; y < newHeight; y++ {
    168 		for x := 0; x < newWidth; x++ {
    169 			srcX := int(float64(x) / scale)
    170 			srcY := int(float64(y) / scale)
    171 			dst.Set(x, y, img.At(srcX+bounds.Min.X, srcY+bounds.Min.Y))
    172 		}
    173 	}
    174 
    175 	return dst
    176 }
    177 
    178 // drawImage draws src onto dst at the given position
    179 func drawImage(dst *image.RGBA, src image.Image, x, y int) {
    180 	bounds := src.Bounds()
    181 	for dy := 0; dy < bounds.Dy(); dy++ {
    182 		for dx := 0; dx < bounds.Dx(); dx++ {
    183 			dst.Set(x+dx, y+dy, src.At(bounds.Min.X+dx, bounds.Min.Y+dy))
    184 		}
    185 	}
    186 }
    187 
    188 // CBZImage represents a single image within a CBZ file
    189 type CBZImage struct {
    190 	Filename string
    191 	Index    int
    192 }
    193 
    194 // getCBZImages returns a list of images in a CBZ file
    195 func getCBZImages(cbzPath string) ([]CBZImage, error) {
    196 	r, err := zip.OpenReader(cbzPath)
    197 	if err != nil {
    198 		return nil, err
    199 	}
    200 	defer r.Close()
    201 
    202 	var images []CBZImage
    203 	for i, f := range r.File {
    204 		if !f.FileInfo().IsDir() {
    205 			ext := strings.ToLower(filepath.Ext(f.Name))
    206 			if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".webp" {
    207 				images = append(images, CBZImage{
    208 					Filename: f.Name,
    209 					Index:    i,
    210 				})
    211 			}
    212 		}
    213 	}
    214 
    215 	// Sort by filename for consistent ordering
    216 	sort.Slice(images, func(i, j int) bool {
    217 		return images[i].Filename < images[j].Filename
    218 	})
    219 
    220 	// Re-index after sorting
    221 	for i := range images {
    222 		images[i].Index = i
    223 	}
    224 
    225 	return images, nil
    226 }
    227 
    228 // serveCBZImage extracts and serves a specific image from a CBZ file
    229 func serveCBZImage(w http.ResponseWriter, cbzPath string, imageIndex int) error {
    230 	r, err := zip.OpenReader(cbzPath)
    231 	if err != nil {
    232 		return err
    233 	}
    234 	defer r.Close()
    235 
    236 	// Get sorted list of images
    237 	var imageFiles []*zip.File
    238 	for _, f := range r.File {
    239 		if !f.FileInfo().IsDir() {
    240 			ext := strings.ToLower(filepath.Ext(f.Name))
    241 			if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".webp" {
    242 				imageFiles = append(imageFiles, f)
    243 			}
    244 		}
    245 	}
    246 
    247 	sort.Slice(imageFiles, func(i, j int) bool {
    248 		return imageFiles[i].Name < imageFiles[j].Name
    249 	})
    250 
    251 	if imageIndex < 0 || imageIndex >= len(imageFiles) {
    252 		return fmt.Errorf("image index out of range")
    253 	}
    254 
    255 	targetFile := imageFiles[imageIndex]
    256 
    257 	// Set content type based on extension
    258 	ext := strings.ToLower(filepath.Ext(targetFile.Name))
    259 	switch ext {
    260 	case ".jpg", ".jpeg":
    261 		w.Header().Set("Content-Type", "image/jpeg")
    262 	case ".png":
    263 		w.Header().Set("Content-Type", "image/png")
    264 	case ".webp":
    265 		w.Header().Set("Content-Type", "image/webp")
    266 	}
    267 
    268 	rc, err := targetFile.Open()
    269 	if err != nil {
    270 		return err
    271 	}
    272 	defer rc.Close()
    273 
    274 	_, err = io.Copy(w, rc)
    275 	return err
    276 }
    277 
    278 // cbzViewerHandler handles the CBZ gallery viewer
    279 func cbzViewerHandler(w http.ResponseWriter, r *http.Request) {
    280 	// Extract file ID from URL
    281 	parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/cbz/"), "/")
    282 	if len(parts) < 1 {
    283 		renderError(w, "Invalid CBZ viewer path", http.StatusBadRequest)
    284 		return
    285 	}
    286 
    287 	fileID := parts[0]
    288 
    289 	// Get the file from database
    290 	var f File
    291 	err := db.QueryRow("SELECT id, filename, path, COALESCE(description, '') FROM files WHERE id = ?", fileID).
    292 		Scan(&f.ID, &f.Filename, &f.Path, &f.Description)
    293 	if err != nil {
    294 		log.Printf("Error: cbzViewerHandler: file not found for id=%s: %v", fileID, err)
    295 		renderError(w, "File not found", http.StatusNotFound)
    296 		return
    297 	}
    298 
    299 	cbzPath := filepath.Join(config.UploadDir, f.Path)
    300 
    301 	// Check if requesting a specific image
    302 	if len(parts) >= 3 && parts[1] == "image" {
    303 		imageIndex := 0
    304 		fmt.Sscanf(parts[2], "%d", &imageIndex)
    305 
    306 		if err := serveCBZImage(w, cbzPath, imageIndex); err != nil {
    307 			log.Printf("Error: cbzViewerHandler: failed to serve image index %d from %s: %v", imageIndex, cbzPath, err)
    308 			renderError(w, "Failed to serve image", http.StatusInternalServerError)
    309 		}
    310 		return
    311 	}
    312 
    313 	// Get list of images
    314 	images, err := getCBZImages(cbzPath)
    315 	if err != nil {
    316 		log.Printf("Error: cbzViewerHandler: failed to read CBZ contents at %s: %v", cbzPath, err)
    317 		renderError(w, "Failed to read CBZ contents", http.StatusInternalServerError)
    318 		return
    319 	}
    320 
    321 	// Determine which image to display
    322 	currentIndex := 0
    323 	if len(parts) >= 2 {
    324 		fmt.Sscanf(parts[1], "%d", &currentIndex)
    325 	}
    326 
    327 	if currentIndex < 0 {
    328 		currentIndex = 0
    329 	}
    330 	if currentIndex >= len(images) {
    331 		currentIndex = len(images) - 1
    332 	}
    333 
    334 	// Prepare data for template
    335 	type CBZViewData struct {
    336 		File         File
    337 		Images       []CBZImage
    338 		CurrentIndex int
    339 		TotalImages  int
    340 		HasPrev      bool
    341 		HasNext      bool
    342 	}
    343 
    344 	viewData := CBZViewData{
    345 		File:         f,
    346 		Images:       images,
    347 		CurrentIndex: currentIndex,
    348 		TotalImages:  len(images),
    349 		HasPrev:      currentIndex > 0,
    350 		HasNext:      currentIndex < len(images)-1,
    351 	}
    352 
    353 	pageData := buildPageData(f.Filename, viewData)
    354 
    355 	renderTemplate(w, "cbz_viewer.html", pageData)
    356 }