taggart

Simple golang tagging filesystem webapp
Log | Files | Refs

include-cbz.go (8492B)


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