package controllers import ( "encoding/json" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "sort" "strings" "time" "fotbal-club/internal/config" "fotbal-club/internal/services" "fotbal-club/pkg/logger" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type GalleryController struct { DB *gorm.DB } func NewGalleryController(db *gorm.DB) *GalleryController { return &GalleryController{DB: db} } // ZoneramaAlbum represents a single album with photos type ZoneramaAlbum struct { ID string `json:"id"` Title string `json:"title"` URL string `json:"url"` Date string `json:"date"` PhotosCount int `json:"photos_count"` ViewsCount int `json:"views_count"` Photos []ZoneramaPhoto `json:"photos"` FetchedAt string `json:"fetched_at,omitempty"` } // ZoneramaPhoto represents a single photo type ZoneramaPhoto struct { ID string `json:"id"` PageURL string `json:"page_url"` Image1500 string `json:"image_1500"` } // ZoneramaProfile represents the profile with list of albums (metadata only) type ZoneramaProfile struct { InputLink string `json:"input_link"` Albums []struct { ID string `json:"id"` Title string `json:"title"` URL string `json:"url"` Date string `json:"date"` PhotosCount int `json:"photos_count"` ViewsCount int `json:"views_count"` } `json:"albums"` FetchedAt string `json:"fetched_at"` } // GetGalleryAlbums returns all fetched albums (public) func (gc *GalleryController) GetGalleryAlbums(c *gin.Context) { albumsFile := filepath.Join("cache", "prefetch", "zonerama_albums.json") data, err := os.ReadFile(albumsFile) if err != nil { c.JSON(http.StatusNoContent, gin.H{"albums": []interface{}{}}) return } var albums []ZoneramaAlbum if err := json.Unmarshal(data, &albums); err != nil { logger.Error("Failed to parse albums JSON: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid albums data"}) return } c.JSON(http.StatusOK, gin.H{"albums": albums}) } // GetGalleryAlbum returns a single album by ID (public) func (gc *GalleryController) GetGalleryAlbum(c *gin.Context) { albumID := c.Param("id") if albumID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Missing album ID"}) return } albumsFile := filepath.Join("cache", "prefetch", "zonerama_albums.json") data, err := os.ReadFile(albumsFile) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Albums not found"}) return } var albums []ZoneramaAlbum if err := json.Unmarshal(data, &albums); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid albums data"}) return } for _, album := range albums { if album.ID == albumID { c.JSON(http.StatusOK, album) return } } c.JSON(http.StatusNotFound, gin.H{"error": "Album not found"}) } // GetGalleryProfile returns the profile metadata (admin) func (gc *GalleryController) GetGalleryProfile(c *gin.Context) { profileFile := filepath.Join("cache", "prefetch", "zonerama_profile.json") data, err := os.ReadFile(profileFile) if err != nil { c.JSON(http.StatusNoContent, gin.H{}) return } var profile ZoneramaProfile if err := json.Unmarshal(data, &profile); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid profile data"}) return } c.JSON(http.StatusOK, profile) } // FetchAlbum fetches a single album and adds it to the albums collection (admin) func (gc *GalleryController) FetchAlbum(c *gin.Context) { var body struct { Link string `json:"link" binding:"required"` PhotoLimit int `json:"photo_limit"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) return } // Validate it's an album URL if !strings.Contains(body.Link, "/Album/") { c.JSON(http.StatusBadRequest, gin.H{"error": "Link must be a Zonerama album URL (must contain /Album/)"}) return } // Set default photo limit if body.PhotoLimit == 0 { body.PhotoLimit = 50 // Default to 50 photos per album } // Call external API (configurable base) apiBase := strings.TrimSuffix(config.AppConfig.ZoneramaAPIBase, "/") apiURL := fmt.Sprintf("%s/zonerama-album?link=%s&photo_limit=%d", apiBase, url.QueryEscape(body.Link), body.PhotoLimit) logger.Info("Fetching album from Zonerama API: %s", apiURL) client := &http.Client{Timeout: 60 * time.Second} req, err := http.NewRequest("GET", apiURL, nil) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request"}) return } req.Header.Set("User-Agent", "fotbal-club/1.0") resp, err := client.Do(req) if err != nil { logger.Error("Album fetch failed: %v", err) c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to fetch album: " + err.Error()}) return } defer resp.Body.Close() if resp.StatusCode != 200 { logger.Error("Album API returned status %d", resp.StatusCode) c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Zonerama API returned status %d", resp.StatusCode)}) return } // Zonerama API returns {"input_link": "...", "albums": [...]} var apiResponse struct { InputLink string `json:"input_link"` Albums []ZoneramaAlbum `json:"albums"` } if err := json.NewDecoder(resp.Body).Decode(&apiResponse); err != nil { logger.Error("Failed to parse album response: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse album data"}) return } if len(apiResponse.Albums) == 0 { logger.Error("No albums returned from API") c.JSON(http.StatusNotFound, gin.H{"error": "Album not found in API response"}) return } // Get the first album (should be the only one for single album endpoint) albumData := apiResponse.Albums[0] // Add fetched timestamp albumData.FetchedAt = time.Now().Format(time.RFC3339) // Load existing albums albumsFile := filepath.Join("cache", "prefetch", "zonerama_albums.json") var albums []ZoneramaAlbum if data, err := os.ReadFile(albumsFile); err == nil { // Primary: try parsing as a plain array (expected format) if err := json.Unmarshal(data, &albums); err != nil || len(albums) == 0 { // Fallback: some older/miswritten caches might be an object {"albums": [...]} var alt struct { Albums []ZoneramaAlbum `json:"albums"` } if err2 := json.Unmarshal(data, &alt); err2 == nil && len(alt.Albums) > 0 { albums = alt.Albums } else if err != nil { // If we failed to parse completely, refuse to overwrite to prevent data loss logger.Error("Failed to parse existing zonerama_albums.json: %v", err) c.JSON(http.StatusConflict, gin.H{"error": "Albums cache is invalid; refusing to overwrite to prevent data loss"}) return } } } // Check if album already exists and update it, or add new found := false for i, a := range albums { if a.ID == albumData.ID { // Reuse existing album if it already has more photos (avoid shrinking due to low photo_limit) if len(a.Photos) >= len(albumData.Photos) { logger.Info("Reusing existing album (kept %d photos vs fetched %d): %s", len(a.Photos), len(albumData.Photos), albumData.ID) // Optionally refresh fetched timestamp if strings.TrimSpace(a.FetchedAt) == "" { a.FetchedAt = albumData.FetchedAt } albums[i] = a } else { // Merge: prefer non-empty fields from new data merged := a if strings.TrimSpace(albumData.Title) != "" { merged.Title = albumData.Title } if strings.TrimSpace(albumData.URL) != "" { merged.URL = albumData.URL } if strings.TrimSpace(albumData.Date) != "" { merged.Date = albumData.Date } if albumData.ViewsCount > 0 { merged.ViewsCount = albumData.ViewsCount } merged.PhotosCount = albumData.PhotosCount merged.Photos = albumData.Photos merged.FetchedAt = albumData.FetchedAt albums[i] = merged logger.Info("Updated existing album with more photos: %s (now %d)", albumData.ID, len(merged.Photos)) } found = true break } } if !found { albums = append([]ZoneramaAlbum{albumData}, albums...) // Prepend new album logger.Info("Added new album: %s", albumData.ID) } // Keep albums ordered by album date (desc), fallback to fetched_at parseDate := func(s string) time.Time { s = strings.TrimSpace(s) if s == "" { return time.Time{} } if t, err := time.Parse("2006-01-02", s); err == nil { return t } if t, err := time.Parse("02.01.2006", s); err == nil { return t } return time.Time{} } sort.Slice(albums, func(i, j int) bool { di := parseDate(albums[i].Date) dj := parseDate(albums[j].Date) if !di.Equal(dj) { return di.After(dj) } // Fallback to fetched_at if dates are equal/unavailable var fi, fj time.Time if t, err := time.Parse(time.RFC3339, strings.TrimSpace(albums[i].FetchedAt)); err == nil { fi = t } if t, err := time.Parse(time.RFC3339, strings.TrimSpace(albums[j].FetchedAt)); err == nil { fj = t } if !fi.Equal(fj) { return fi.After(fj) } return strings.Compare(albums[i].ID, albums[j].ID) > 0 }) // Save back to file (atomic write) if err := os.MkdirAll(filepath.Dir(albumsFile), 0755); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create cache directory"}) return } albumsJSON, err := json.MarshalIndent(albums, "", " ") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to serialize albums"}) return } tmp := albumsFile + ".tmp" if err := os.WriteFile(tmp, albumsJSON, 0644); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save albums"}) return } if err := os.Rename(tmp, albumsFile); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to finalize album save"}) return } logger.Info("Album %s saved successfully with %d photos", albumData.ID, len(albumData.Photos)) // Regenerate flat gallery files for frontend consumption if err := services.RegenerateFlatGalleryFiles(); err != nil { logger.Error("Failed to regenerate flat gallery files: %v", err) // Don't fail the request, just log the error } c.JSON(http.StatusOK, gin.H{ "message": "Album fetched and saved successfully", "album": albumData, }) } // DeleteAlbum removes an album from the collection (admin) func (gc *GalleryController) DeleteAlbum(c *gin.Context) { albumID := c.Param("id") if albumID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Missing album ID"}) return } albumsFile := filepath.Join("cache", "prefetch", "zonerama_albums.json") data, err := os.ReadFile(albumsFile) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Albums not found"}) return } var albums []ZoneramaAlbum if err := json.Unmarshal(data, &albums); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid albums data"}) return } // Find and remove newAlbums := []ZoneramaAlbum{} found := false for _, album := range albums { if album.ID != albumID { newAlbums = append(newAlbums, album) } else { found = true } } if !found { c.JSON(http.StatusNotFound, gin.H{"error": "Album not found"}) return } // Save back albumsJSON, _ := json.MarshalIndent(newAlbums, "", " ") if err := os.WriteFile(albumsFile, albumsJSON, 0644); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save albums"}) return } logger.Info("Deleted album: %s", albumID) // Regenerate flat gallery files for frontend consumption if err := services.RegenerateFlatGalleryFiles(); err != nil { logger.Error("Failed to regenerate flat gallery files: %v", err) // Don't fail the request, just log the error } c.JSON(http.StatusOK, gin.H{"message": "Album deleted successfully"}) } // RefreshFromZonerama triggers a refresh of the Zonerama gallery from the configured URL (admin) func (gc *GalleryController) RefreshFromZonerama(c *gin.Context) { // Load settings to get the Zonerama URL var settings struct { GalleryURL string `json:"gallery_url"` } if err := gc.DB.Table("settings").Select("gallery_url").First(&settings).Error; err != nil { logger.Error("Failed to load settings: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load gallery settings"}) return } zoneramaURL := strings.TrimSpace(settings.GalleryURL) if zoneramaURL == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Zonerama URL is not configured in settings"}) return } // Validate it's a Zonerama URL if !strings.Contains(strings.ToLower(zoneramaURL), "zonerama.com") { c.JSON(http.StatusBadRequest, gin.H{"error": "Configured gallery URL is not a Zonerama URL"}) return } logger.Info("Triggering Zonerama refresh from: %s", zoneramaURL) // Call the refresh service in a goroutine to avoid blocking go func() { if err := services.RefreshZoneramaNow(zoneramaURL); err != nil { logger.Error("Zonerama refresh failed: %v", err) } else { logger.Info("Zonerama refresh completed successfully") // Regenerate flat gallery files for frontend consumption if err := services.RegenerateFlatGalleryFiles(); err != nil { logger.Error("Failed to regenerate flat gallery files: %v", err) } } }() c.JSON(http.StatusOK, gin.H{ "message": "Zonerama refresh started", "url": zoneramaURL, }) } // ProxyImage proxies image requests to Zonerama to avoid CORS issues (public) func (gc *GalleryController) ProxyImage(c *gin.Context) { imageURL := c.Query("url") if imageURL == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Missing image URL"}) return } // Validate that the URL is from Zonerama if !strings.Contains(imageURL, "zonerama.com") { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image URL"}) return } // Fetch the image client := &http.Client{Timeout: 30 * time.Second} req, err := http.NewRequest("GET", imageURL, nil) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request"}) return } req.Header.Set("User-Agent", "fotbal-club/1.0") resp, err := client.Do(req) if err != nil { logger.Error("Image fetch failed: %v", err) c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to fetch image"}) return } defer resp.Body.Close() if resp.StatusCode != 200 { logger.Error("Image fetch returned status %d", resp.StatusCode) c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to fetch image"}) return } // Copy headers for key, values := range resp.Header { for _, value := range values { c.Header(key, value) } } // Set CORS headers c.Header("Access-Control-Allow-Origin", "*") c.Header("Access-Control-Allow-Methods", "GET") // Stream the image c.Status(resp.StatusCode) _, err = io.Copy(c.Writer, resp.Body) if err != nil { logger.Error("Failed to stream image: %v", err) } }