include-cbz.go (8692B)
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 // getCBZImages returns a list of images in a CBZ file 189 func getCBZImages(cbzPath string) ([]CBZImage, error) { 190 r, err := zip.OpenReader(cbzPath) 191 if err != nil { 192 return nil, err 193 } 194 defer r.Close() 195 196 var images []CBZImage 197 for i, f := range r.File { 198 if !f.FileInfo().IsDir() { 199 ext := strings.ToLower(filepath.Ext(f.Name)) 200 if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".webp" { 201 images = append(images, CBZImage{ 202 Filename: f.Name, 203 Index: i, 204 }) 205 } 206 } 207 } 208 209 // Sort by filename for consistent ordering 210 sort.Slice(images, func(i, j int) bool { 211 return images[i].Filename < images[j].Filename 212 }) 213 214 // Re-index after sorting 215 for i := range images { 216 images[i].Index = i 217 } 218 219 return images, nil 220 } 221 222 // serveCBZImage extracts and serves a specific image from a CBZ file 223 func serveCBZImage(w http.ResponseWriter, cbzPath string, imageIndex int) error { 224 r, err := zip.OpenReader(cbzPath) 225 if err != nil { 226 return err 227 } 228 defer r.Close() 229 230 // Get sorted list of images 231 var imageFiles []*zip.File 232 for _, f := range r.File { 233 if !f.FileInfo().IsDir() { 234 ext := strings.ToLower(filepath.Ext(f.Name)) 235 if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".webp" { 236 imageFiles = append(imageFiles, f) 237 } 238 } 239 } 240 241 sort.Slice(imageFiles, func(i, j int) bool { 242 return imageFiles[i].Name < imageFiles[j].Name 243 }) 244 245 if imageIndex < 0 || imageIndex >= len(imageFiles) { 246 return fmt.Errorf("image index out of range") 247 } 248 249 targetFile := imageFiles[imageIndex] 250 251 // Set content type based on extension 252 ext := strings.ToLower(filepath.Ext(targetFile.Name)) 253 switch ext { 254 case ".jpg", ".jpeg": 255 w.Header().Set("Content-Type", "image/jpeg") 256 case ".png": 257 w.Header().Set("Content-Type", "image/png") 258 case ".webp": 259 w.Header().Set("Content-Type", "image/webp") 260 } 261 262 rc, err := targetFile.Open() 263 if err != nil { 264 return err 265 } 266 defer rc.Close() 267 268 _, err = io.Copy(w, rc) 269 return err 270 } 271 272 // cbzViewerHandler handles the CBZ gallery viewer 273 func cbzViewerHandler(w http.ResponseWriter, r *http.Request) { 274 // Extract file ID from URL 275 parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/cbz/"), "/") 276 if len(parts) < 1 { 277 renderError(w, "Invalid CBZ viewer path", http.StatusBadRequest) 278 return 279 } 280 281 fileID := parts[0] 282 283 // Get the file from database 284 var f File 285 err := db.QueryRow("SELECT id, filename, path, COALESCE(description, '') FROM files WHERE id = ?", fileID). 286 Scan(&f.ID, &f.Filename, &f.Path, &f.Description) 287 if err != nil { 288 log.Printf("Error: cbzViewerHandler: file not found for id=%s: %v", fileID, err) 289 renderError(w, "File not found", http.StatusNotFound) 290 return 291 } 292 293 cbzPath := filepath.Join(config.UploadDir, f.Path) 294 295 // Check if requesting a specific image 296 if len(parts) >= 3 && parts[1] == "image" { 297 imageIndex := 0 298 fmt.Sscanf(parts[2], "%d", &imageIndex) 299 300 if err := serveCBZImage(w, cbzPath, imageIndex); err != nil { 301 log.Printf("Error: cbzViewerHandler: failed to serve image index %d from %s: %v", imageIndex, cbzPath, err) 302 renderError(w, "Failed to serve image", http.StatusInternalServerError) 303 } 304 return 305 } 306 307 // Get list of images 308 images, err := getCBZImages(cbzPath) 309 if err != nil { 310 log.Printf("Error: cbzViewerHandler: failed to read CBZ contents at %s: %v", cbzPath, err) 311 renderError(w, "Failed to read CBZ contents", http.StatusInternalServerError) 312 return 313 } 314 315 // Determine which image to display 316 currentIndex := 0 317 if len(parts) >= 2 { 318 fmt.Sscanf(parts[1], "%d", ¤tIndex) 319 } 320 321 if currentIndex < 0 { 322 currentIndex = 0 323 } 324 if currentIndex >= len(images) { 325 currentIndex = len(images) - 1 326 } 327 328 // Prepare data for template 329 type CBZViewData struct { 330 File File 331 Images []CBZImage 332 CurrentIndex int 333 TotalImages int 334 HasPrev bool 335 HasNext bool 336 } 337 338 viewData := CBZViewData{ 339 File: f, 340 Images: images, 341 CurrentIndex: currentIndex, 342 TotalImages: len(images), 343 HasPrev: currentIndex > 0, 344 HasNext: currentIndex < len(images)-1, 345 } 346 347 pageData := buildPageData(f.Filename, viewData) 348 349 renderTemplate(w, "cbz_viewer.html", pageData) 350 }