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