Files
MyClub/internal/controllers/gallery_controller.go
T
Tomas Dvorak dfc079288f hot fix #1
2026-01-26 08:13:18 +01:00

498 lines
15 KiB
Go

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)
}
}