feat: Refactor video handling in announcements

- Updated the announcement detail component to directly use images and videos from the data structure.
- Changed the form edit component to accommodate the new media structure, replacing AnnunciWithImages with AnnunciWithMedia.
- Modified the announcements list to initialize videos as an empty array.
- Enhanced the announcement view to handle external videos and updated the footer to display them correctly.
- Introduced a new utility function to generate YouTube embed URLs.
- Updated the database schema to replace url_video with external_videos and added a new videos_refs table for video management.
- Implemented video processing logic, including downloading, transcoding, and thumbnail generation.
- Added migration scripts for the new videos_refs table and updated existing references in the database.
This commit is contained in:
Marco Pedone 2025-11-18 15:20:14 +01:00
parent 05cc912335
commit f4262b8711
26 changed files with 886 additions and 258 deletions

View file

@ -7,7 +7,7 @@ tmp_dir = "tmp"
bin = "tmp\\main.exe"
cmd = "go build -o ./tmp/main.exe ."
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", "images", ".bin"]
exclude_dir = ["assets", "tmp", "vendor", "testdata", "images", ".bin", "videos"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false

View file

@ -14,10 +14,10 @@ RUN go mod download
COPY . .
# Build the Go app
RUN go build -o main .
RUN CGO_ENABLED=0 GOOS=linux go build -o main .
# Start a new stage from alpine:latest
FROM alpine:3.22 AS runner
FROM alpine:latest AS runner
ARG POSTGRES_USER
ARG POSTGRES_PASSWORD
@ -29,7 +29,7 @@ ARG CONCURRENT_IMAGES
ARG STORAGE_URL
ARG STORAGE_TOKEN
RUN apk --no-cache add ca-certificates libc6-compat
RUN apk add --no-cache ffmpeg ca-certificates libc6-compat
ENV GOMEMLIMIT=2750MiB
ENV GOGC=100
ENV PGHOST=$PGHOST
@ -52,8 +52,9 @@ COPY --from=builder /app/scripts /app/scripts
RUN chmod +x /app/scripts/*.sh
#COPY --from=builder /app/.env /app/.env
# create images
# create folders
RUN mkdir images
RUN mkdir videos
RUN apk add curl

View file

@ -3,11 +3,13 @@ package config
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"sync"
"github.com/joho/godotenv"
"github.com/labstack/gommon/log"
)
// Config holds all application configuration
@ -26,6 +28,11 @@ type Config struct {
ConcurrentLimit int
Path string
}
Videos struct {
Enabled bool
ConcurrentLimit int
Path string
}
Storage struct {
Endpoint string
Token string
@ -67,6 +74,11 @@ func Load() *Config {
Cfg.Images.ConcurrentLimit = getEnvAsInt("CONCURRENT_IMAGES", 5)
Cfg.Images.Path = filepath.Join(pwd, "images")
// Video processing config
Cfg.Videos.Enabled = getEnvAsBool("VIDEOOPTION", true)
Cfg.Videos.ConcurrentLimit = getEnvAsInt("CONCURRENT_VIDEOS", 2)
Cfg.Videos.Path = filepath.Join(pwd, "videos")
// Storage config
Cfg.Storage.Endpoint = getEnv("STORAGE_URL", "")
Cfg.Storage.Token = getEnv("STORAGE_TOKEN", "")
@ -80,6 +92,11 @@ func Load() *Config {
}
})
if err := exec.Command("ffmpeg", "-version").Run(); err != nil {
log.Errorf("FFmpeg not found: %w", err)
Cfg.Videos.Enabled = false
}
return &Cfg
}

View file

@ -188,7 +188,7 @@ func extractData_bkp(annuncio typesdefs.AnnuncioBKP, extractedData chan typesdef
w := false
tmp.Web = &w
tmp.Url_video = nil
tmp.External_videos = nil
if annuncio.Accessori != nil && annuncio.Accessori.Accessorio != nil {
tmpAcc := make([]string, 0)
@ -230,7 +230,7 @@ func extractData_bkp(annuncio typesdefs.AnnuncioBKP, extractedData chan typesdef
}
tmp.Caratteristiche = &tmpCaratteristiche
tmp.Url_video = nil
tmp.External_videos = nil
extractedData <- tmp

View file

@ -14,21 +14,22 @@ import (
"net/http"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"github.com/labstack/echo/v4"
"github.com/nfnt/resize"
"github.com/nickalie/go-webpbin"
)
func AddToImageProcessing(tmp *typesdefs.AnnuncioParsed, annuncio *typesdefs.AnnuncioXML, p *[]typesdefs.ImagesToProcess) {
func AddToImageProcessing(tmp *typesdefs.AnnuncioParsed, annuncio *typesdefs.AnnuncioXML, p *[]typesdefs.MediaToProcess) {
if annuncio.Foto == nil || len(*annuncio.Foto) == 0 {
return
}
for idx, url := range *annuncio.Foto {
*p = append(*p, typesdefs.ImagesToProcess{
*p = append(*p, typesdefs.MediaToProcess{
Codice: tmp.Codice,
Url: url,
Order: idx,
@ -36,10 +37,10 @@ func AddToImageProcessing(tmp *typesdefs.AnnuncioParsed, annuncio *typesdefs.Ann
}
}
func ProcessImagePool(toProcess *[]typesdefs.ImagesToProcess) ([]typesdefs.UploadedImage, error) {
func ProcessImagePool(toProcess *[]typesdefs.MediaToProcess) ([]typesdefs.UploadedMedia, error) {
var (
ProcessedImages []typesdefs.ProcessedImage
UploadedImages []typesdefs.UploadedImage
ProcessedImages []typesdefs.ProcessedMedia
UploadedImages []typesdefs.UploadedMedia
mu sync.Mutex
wg sync.WaitGroup
)
@ -55,17 +56,21 @@ func ProcessImagePool(toProcess *[]typesdefs.ImagesToProcess) ([]typesdefs.Uploa
for _, img := range *toProcess {
sem <- struct{}{} // acquire semaphore
wg.Add(1)
go func(img typesdefs.ImagesToProcess) {
go func(img typesdefs.MediaToProcess) {
defer wg.Done()
defer func() { <-sem }() // release semaphore
imgPath := filepath.Join(config.Cfg.Images.Path, fmt.Sprintf("%s_%d%s", img.Codice, img.Order, filepath.Ext(img.Url)))
downloadImage(img.Url, imgPath)
err := downloadImage(img.Url, imgPath)
if err != nil {
log.Printf("Failed to download image %s: %v", img.Url, err)
return // Exit goroutine on download failure
}
webpPath, thumbnailwebpPath, err := Conversion(imgPath)
if err != nil {
log.Fatalf("Failed to convert image: %v", err)
}
mu.Lock()
ProcessedImages = append(ProcessedImages, typesdefs.ProcessedImage{
ProcessedImages = append(ProcessedImages, typesdefs.ProcessedMedia{
Codice: img.Codice,
Order: img.Order,
Path: webpPath,
@ -81,25 +86,25 @@ func ProcessImagePool(toProcess *[]typesdefs.ImagesToProcess) ([]typesdefs.Uploa
for _, img := range ProcessedImages {
uploadSem <- struct{}{} // acquire semaphore
uploadWg.Add(1)
go func(img typesdefs.ProcessedImage) {
go func(img typesdefs.ProcessedMedia) {
defer uploadWg.Done()
defer func() { <-uploadSem }() // release semaphore
imgId, err := UploadFile(img.Path)
imgId, err := UploadFile(img.Path, "image/webp", "images")
if err != nil {
log.Printf("failed to upload image: %v", err)
return
}
thumbId, err := UploadFile(img.ThumnailPath)
thumbId, err := UploadFile(img.ThumnailPath, "image/webp", "images")
if err != nil {
log.Printf("failed to upload thumbnail: %v", err)
return
}
mu.Lock()
UploadedImages = append(UploadedImages, typesdefs.UploadedImage{
UploadedImages = append(UploadedImages, typesdefs.UploadedMedia{
Codice: img.Codice,
Order: img.Order,
ImgId: imgId,
MediaId: imgId,
ThumbId: thumbId,
OGUrl: img.OGUrl,
})
@ -123,21 +128,38 @@ func clearImageFolder() error {
return nil
}
func downloadImage(url, filename string) error {
response, err := http.Get(url)
client := &http.Client{
Timeout: 2 * time.Minute, // Adjust based on expected image sizes
}
response, err := client.Get(url)
if err != nil {
return fmt.Errorf("failed to download image: %v", err)
return fmt.Errorf("failed to download image: %w", err)
}
defer response.Body.Close()
// Check HTTP status
if response.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download image: HTTP %d", response.StatusCode)
}
// Optional: Validate Content-Type
contentType := response.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "image/") {
log.Printf("Warning: unexpected content type %s for %s", contentType, url)
}
file, err := os.Create(filename)
if err != nil {
return fmt.Errorf("failed to create image file: %v", err)
return fmt.Errorf("failed to create image file: %w", err)
}
defer file.Close()
_, err = io.Copy(file, response.Body)
if err != nil {
return fmt.Errorf("failed to save image file: %v", err)
return fmt.Errorf("failed to save image file: %w", err)
}
return nil
}
@ -257,9 +279,9 @@ func CodiciToProcessImages(annunci *typesdefs.AnnunciXML) ([]string, error) {
return nil, fmt.Errorf("failed to list bucket: %w", err)
}
//make a list of bucket ids
bucketIds := []string{}
bucketIdsMap := make(map[string]bool)
for _, item := range currentBucketState {
bucketIds = append(bucketIds, item.ID)
bucketIdsMap[item.ID] = true
}
db_images, err := queries.GetImagesFromDB()
@ -290,12 +312,9 @@ func CodiciToProcessImages(annunci *typesdefs.AnnunciXML) ([]string, error) {
found := false
for _, ref := range refFoto {
if fotoUrl == ref.Og_url {
if slices.Contains(bucketIds, ref.Img) && slices.Contains(bucketIds, ref.Thumb) {
if bucketIdsMap[ref.Img] && bucketIdsMap[ref.Thumb] {
found = true
break
} else {
needProcess = true
break
}
}
}
@ -303,9 +322,6 @@ func CodiciToProcessImages(annunci *typesdefs.AnnunciXML) ([]string, error) {
needProcess = true
break
}
if needProcess {
break
}
}
if needProcess {
codiciToProcess = append(codiciToProcess, *annuncio.Codice)
@ -342,13 +358,3 @@ func ForceClearImages(codici []string) error {
}
return nil
}
func DeleteIdsFromStorage(ids []string) error {
for _, id := range ids {
err := DeleteFile(id)
if err != nil {
return fmt.Errorf("failed to delete file with id %s: %w", id, err)
}
}
return nil
}

View file

@ -90,15 +90,15 @@ func (m *MiogestHandler) GenerateCategorie() {
}
// ANNUNCI PARSED
func (m *MiogestHandler) GetAnnunciParsed(p *[]typesdefs.ImagesToProcess, forceImages bool) typesdefs.AnnunciParsed {
func (m *MiogestHandler) GetAnnunciParsed(imgPool *[]typesdefs.MediaToProcess, videoPool *[]typesdefs.MediaToProcess, forceMediaStale bool) typesdefs.AnnunciParsed {
// salvo il parsing nello struct nel caso voglio implementare un caching e non fare il parsing ad ogni chiamata
m.AnnunciParsed = m.ParseAnnunci("", p, forceImages)
m.AnnunciParsed = m.ParseAnnunci("", imgPool, videoPool, forceMediaStale)
return m.AnnunciParsed
}
// versione per singolo annuncio
func (m *MiogestHandler) GetAnnuncioParsed(codFilter string, p *[]typesdefs.ImagesToProcess) typesdefs.AnnunciParsed {
data := m.ParseAnnunci(codFilter, p, false)
func (m *MiogestHandler) GetAnnuncioParsed(codFilter string, imgPool *[]typesdefs.MediaToProcess, videoPool *[]typesdefs.MediaToProcess) typesdefs.AnnunciParsed {
data := m.ParseAnnunci(codFilter, imgPool, videoPool, false)
if len(data.Annuncio) == 0 {
return typesdefs.AnnunciParsed{}
}
@ -106,7 +106,7 @@ func (m *MiogestHandler) GetAnnuncioParsed(codFilter string, p *[]typesdefs.Imag
}
func (m *MiogestHandler) ParseAnnunci(codFilter string, p *[]typesdefs.ImagesToProcess, forceImages bool) typesdefs.AnnunciParsed {
func (m *MiogestHandler) ParseAnnunci(codFilter string, imgPool *[]typesdefs.MediaToProcess, videoPool *[]typesdefs.MediaToProcess, forceMediaStale bool) typesdefs.AnnunciParsed {
annunci := m.GetAnnunci()
if len(annunci.Annuncio) == 0 {
@ -126,7 +126,7 @@ func (m *MiogestHandler) ParseAnnunci(codFilter string, p *[]typesdefs.ImagesToP
image_codes_to_process := []string{}
if config.Cfg.Images.Enabled {
var err error
if forceImages {
if forceMediaStale {
// process all images
image_codes_to_process = make([]string, 0, len(annunci.Annuncio))
for _, annuncio := range annunci.Annuncio {
@ -152,6 +152,38 @@ func (m *MiogestHandler) ParseAnnunci(codFilter string, p *[]typesdefs.ImagesToP
log.Fatalf("Failed to clear images for codice %s: %v", codFilter, err.Error())
}
}
video_codes_to_process := []string{}
if config.Cfg.Videos.Enabled {
var err error
if forceMediaStale {
// process all videos
video_codes_to_process = make([]string, 0, len(annunci.Annuncio))
for _, annuncio := range annunci.Annuncio {
if annuncio.Codice != nil {
video_codes_to_process = append(video_codes_to_process, *annuncio.Codice)
}
}
} else {
// process only new or updated videos
video_codes_to_process, err = CodiciToProcessVideos(&annunci)
if err != nil {
log.Fatalf("Failed to find videos to update: %v", err.Error())
}
}
if codFilter != "" {
// if processing single codice, ensure it's in the list
if !slices.Contains(video_codes_to_process, codFilter) {
video_codes_to_process = append(video_codes_to_process, codFilter)
}
}
err = ForceClearVideos(video_codes_to_process)
if err != nil {
log.Fatalf("Failed to clear videos for codice %s: %v", codFilter, err.Error())
}
}
var AnnunciArray typesdefs.AnnunciParsed
@ -160,7 +192,8 @@ func (m *MiogestHandler) ParseAnnunci(codFilter string, p *[]typesdefs.ImagesToP
for i := range annunci.Annuncio {
imgToUpdate := slices.Contains(image_codes_to_process, *annunci.Annuncio[i].Codice)
result := extractData_update(&annunci.Annuncio[i], imgToUpdate, m, p)
updateVideos := slices.Contains(video_codes_to_process, *annunci.Annuncio[i].Codice)
result := extractData_update(&annunci.Annuncio[i], imgToUpdate, updateVideos, m, imgPool, videoPool)
AnnunciArray.Annuncio = append(AnnunciArray.Annuncio, result)
}
@ -178,7 +211,7 @@ func strUpd(s string) *string {
return &s
}
func extractData_update(annuncio *typesdefs.AnnuncioXML, updateImages bool, m *MiogestHandler, p *[]typesdefs.ImagesToProcess) typesdefs.AnnuncioParsed {
func extractData_update(annuncio *typesdefs.AnnuncioXML, updateImages bool, updateVideos bool, m *MiogestHandler, imgPool *[]typesdefs.MediaToProcess, videoPool *[]typesdefs.MediaToProcess) typesdefs.AnnuncioParsed {
var tmp typesdefs.AnnuncioParsed = typesdefs.AnnuncioParsed{}
tmp.Codice = utils.RemoveWhitespace(utils.GetStringValue(annuncio.Codice))
processLocatore(&tmp, annuncio)
@ -231,20 +264,21 @@ func extractData_update(annuncio *typesdefs.AnnuncioXML, updateImages bool, m *M
tmp.Web = boolUpd(true)
videos := []string{}
if updateVideos {
AddToVideoProcessing(&tmp, annuncio, videoPool)
}
if annuncio.Video != nil {
videos = *annuncio.Video
}
miogestVideos := []string{}
if annuncio.AMMedias != nil {
for _, media := range *annuncio.AMMedias {
if media.AMVideo != nil && media.AMVideo.Url != nil {
miogestVideos = append(miogestVideos, *media.AMVideo.Url)
ext_videos := []string{}
for _, v := range *annuncio.Video {
if strings.Contains(v, "youtu") {
ext_videos = append(ext_videos, v)
}
tmp.External_videos = &ext_videos
}
} else {
tmp.External_videos = &[]string{}
}
concatenatedVideos := slices.Concat(videos, miogestVideos)
tmp.Url_video = &concatenatedVideos
if annuncio.Accessori != nil {
tmp.Accessori = annuncio.Accessori
@ -253,7 +287,7 @@ func extractData_update(annuncio *typesdefs.AnnuncioXML, updateImages bool, m *M
tmp.Caratteristiche = &tmpcaratt
if updateImages {
AddToImageProcessing(&tmp, annuncio, p)
AddToImageProcessing(&tmp, annuncio, imgPool)
}
return tmp

View file

@ -84,7 +84,7 @@ func AnnunciInsert(annunci typesdefs.AnnunciParsed, resetAllBeforeUpdate bool) e
stato = $42,
web = $43,
homepage = $44,
url_video = $45,
external_videos = $45,
disponibile_da = $47,
permanenza = $48,
persone = $49,
@ -101,7 +101,7 @@ func AnnunciInsert(annunci typesdefs.AnnunciParsed, resetAllBeforeUpdate bool) e
annuncio.Numero_balconi, annuncio.Numero_terrazzi, annuncio.Numero_box, annuncio.Numero_postiauto,
annuncio.Caratteristiche, annuncio.Accessori, annuncio.Titolo_it, annuncio.Desc_it, annuncio.Titolo_en,
annuncio.Desc_en, annuncio.Stato, annuncio.Web, annuncio.Homepage,
annuncio.Url_video, annuncio.Codice, annuncio.Disponibile_da, annuncio.Permanenza, annuncio.Persone)
annuncio.External_videos, annuncio.Codice, annuncio.Disponibile_da, annuncio.Permanenza, annuncio.Persone)
} else {
batch.Queue(`
@ -150,7 +150,7 @@ func AnnunciInsert(annunci typesdefs.AnnunciParsed, resetAllBeforeUpdate bool) e
stato,
web,
homepage,
url_video,
external_videos,
codice,
disponibile_da,
permanenza,
@ -169,7 +169,7 @@ func AnnunciInsert(annunci typesdefs.AnnunciParsed, resetAllBeforeUpdate bool) e
annuncio.Numero_balconi, annuncio.Numero_terrazzi, annuncio.Numero_box, annuncio.Numero_postiauto,
annuncio.Caratteristiche, annuncio.Accessori, annuncio.Titolo_it, annuncio.Desc_it, annuncio.Titolo_en,
annuncio.Desc_en, annuncio.Stato, annuncio.Web, annuncio.Homepage,
annuncio.Url_video, annuncio.Codice, annuncio.Disponibile_da, annuncio.Permanenza, annuncio.Persone)
annuncio.External_videos, annuncio.Codice, annuncio.Disponibile_da, annuncio.Permanenza, annuncio.Persone)
}
}
err = Db.Dbpool.SendBatch(Db.Ctx, batch).Close()
@ -241,7 +241,7 @@ func SetFromBkp(annunci typesdefs.AnnunciParsed) error {
stato,
web,
homepage,
url_video,
external_videos,
codice
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46)
@ -256,7 +256,7 @@ func SetFromBkp(annunci typesdefs.AnnunciParsed) error {
annuncio.Numero_balconi, annuncio.Numero_terrazzi, annuncio.Numero_box, annuncio.Numero_postiauto,
annuncio.Caratteristiche, annuncio.Accessori, annuncio.Titolo_it, annuncio.Desc_it, annuncio.Titolo_en,
annuncio.Desc_en, annuncio.Stato, annuncio.Web, annuncio.Homepage,
annuncio.Url_video, annuncio.Codice)
annuncio.External_videos, annuncio.Codice)
}
err := Db.Dbpool.SendBatch(Db.Ctx, batch).Close()
@ -266,11 +266,3 @@ func SetFromBkp(annunci typesdefs.AnnunciParsed) error {
fmt.Println("Annunci insert/update done")
return nil
}
func AnnunciResetImages() error {
_, err := Db.Dbpool.Exec(Db.Ctx, "UPDATE public.annunci SET url_immagini = NULL, url_video = NULL")
if err != nil {
return fmt.Errorf("failed to reset images: %w", err)
}
return nil
}

View file

@ -8,7 +8,7 @@ import (
"github.com/jackc/pgx/v5"
)
func SetImagesToDB(pool *[]typesdefs.UploadedImage) error {
func SetImagesToDB(pool *[]typesdefs.UploadedMedia) error {
batch := &pgx.Batch{}
for _, data := range *pool {
@ -16,7 +16,7 @@ func SetImagesToDB(pool *[]typesdefs.UploadedImage) error {
INSERT INTO public.images_refs (codice, ordine, img, thumb, og_url)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (codice, ordine) DO UPDATE SET img = $3, thumb = $4
`, data.Codice, data.Order, data.ImgId, data.ThumbId, data.OGUrl)
`, data.Codice, data.Order, data.MediaId, data.ThumbId, data.OGUrl)
}
if err := Db.Dbpool.SendBatch(Db.Ctx, batch).Close(); err != nil {

View file

@ -0,0 +1,53 @@
package queries
import (
"backend/typesdefs"
"fmt"
"github.com/georgysavva/scany/v2/pgxscan"
"github.com/jackc/pgx/v5"
)
func SetVideosToDB(pool *[]typesdefs.UploadedMedia) error {
batch := &pgx.Batch{}
for _, data := range *pool {
batch.Queue(`
INSERT INTO public.videos_refs (codice, ordine, video, thumb, og_url)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (codice, ordine) DO UPDATE SET video = $3, thumb = $4
`, data.Codice, data.Order, data.MediaId, data.ThumbId, data.OGUrl)
}
if err := Db.Dbpool.SendBatch(Db.Ctx, batch).Close(); err != nil {
return fmt.Errorf("failed to insert/update video batch: %w", err)
}
return nil
}
func GetVideosFromDB() ([]typesdefs.VideosRefs, error) {
var videos []typesdefs.VideosRefs
err := pgxscan.Select(Db.Ctx, Db.Dbpool, &videos, "SELECT * FROM public.videos_refs ORDER BY ordine ASC")
if err != nil && err != pgx.ErrNoRows {
return nil, fmt.Errorf("failed to get videos: %w", err)
}
return videos, nil
}
func GetCodiceVideosFromDB(codice string) ([]typesdefs.VideosRefs, error) {
var videos []typesdefs.VideosRefs
err := pgxscan.Select(Db.Ctx, Db.Dbpool, &videos, "SELECT * FROM public.videos_refs WHERE codice = $1 ORDER BY ordine ASC", codice)
if err != nil && err != pgx.ErrNoRows {
return nil, fmt.Errorf("failed to get videos: %w", err)
}
return videos, nil
}
func ClearVideosRefsByCodice(codice string) error {
_, err := Db.Dbpool.Exec(Db.Ctx, "DELETE FROM public.videos_refs WHERE codice = $1", codice)
if err != nil {
return fmt.Errorf("failed to clear videos refs by codice: %w", err)
}
return nil
}

View file

@ -104,43 +104,60 @@ func (s *Server) SetupRoutes() *echo.Echo {
})
e.GET("/parse", func(c echo.Context) error {
start := time.Now()
var data = s.miogest.GetAnnunciParsed(nil, false)
var data = s.miogest.GetAnnunciParsed(nil, nil, false)
end := time.Now()
fmt.Println("time taken:", end.Sub(start).String())
return c.JSONPretty(http.StatusOK, data, " ")
})
e.GET("/update", func(c echo.Context) error {
forceImages := false
forceImagesStr := c.QueryParam("forceimages")
if forceImagesStr == "true" {
forceImages = true
forceMediaStale := false
forceMediaStr := c.QueryParam("forcemedia")
if forceMediaStr == "true" {
forceMediaStale = true
}
imgPool := []typesdefs.ImagesToProcess{}
var data = s.miogest.GetAnnunciParsed(&imgPool, forceImages)
imgPool := []typesdefs.MediaToProcess{}
videoPool := []typesdefs.MediaToProcess{}
var data = s.miogest.GetAnnunciParsed(&imgPool, &videoPool, forceMediaStale)
imgRefs, err := ProcessImagePool(&imgPool)
if err != nil {
return c.String(http.StatusInternalServerError, err.Error())
}
videoRefs, err := ProcessVideoPool(&videoPool)
if err != nil {
return c.String(http.StatusInternalServerError, err.Error())
}
err = queries.AnnunciInsert(data, true)
if err != nil {
return c.String(http.StatusInternalServerError, err.Error())
}
err = queries.SetImagesToDB(&imgRefs)
if err != nil {
return c.String(http.StatusInternalServerError, err.Error())
}
err = queries.SetVideosToDB(&videoRefs)
if err != nil {
return c.String(http.StatusInternalServerError, err.Error())
}
return c.String(http.StatusOK, "Updated")
})
e.GET("/update-cod/:cod", func(c echo.Context) error {
cod := c.Param("cod")
imgPool := []typesdefs.ImagesToProcess{}
var data = s.miogest.GetAnnuncioParsed(cod, &imgPool)
imgPool := []typesdefs.MediaToProcess{}
videoPool := []typesdefs.MediaToProcess{}
var data = s.miogest.GetAnnuncioParsed(cod, &imgPool, &videoPool)
imgRefs, err := ProcessImagePool(&imgPool)
if err != nil {
return c.String(http.StatusInternalServerError, err.Error())
}
videoRefs, err := ProcessVideoPool(&videoPool)
if err != nil {
return c.String(http.StatusInternalServerError, err.Error())
}
err = queries.AnnunciInsert(data, false)
if err != nil {
return c.String(http.StatusInternalServerError, err.Error())
@ -149,6 +166,10 @@ func (s *Server) SetupRoutes() *echo.Echo {
if err != nil {
return c.String(http.StatusInternalServerError, err.Error())
}
err = queries.SetVideosToDB(&videoRefs)
if err != nil {
return c.String(http.StatusInternalServerError, err.Error())
}
return c.String(http.StatusOK, "Updated")
})

View file

@ -30,6 +30,16 @@ func DeleteFile(id string) error {
return nil
}
func DeleteIdsFromStorage(ids []string) error {
for _, id := range ids {
err := DeleteFile(id)
if err != nil {
return fmt.Errorf("failed to delete file with id %s: %w", id, err)
}
}
return nil
}
func GetFile(path string) ([]byte, error) {
panic("unimplemented")
}
@ -136,7 +146,7 @@ func ListBucket(bucketName string) ([]FileMetadata, error) {
// }
func UploadFile(path string) (string, error) {
func UploadFile(path string, mimeType string, bucket string) (string, error) {
file, err := os.Open(path)
if err != nil {
@ -147,8 +157,6 @@ func UploadFile(path string) (string, error) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
mimeType := "image/webp"
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, filepath.Base(path)))
h.Set("Content-Type", mimeType)
@ -160,7 +168,7 @@ func UploadFile(path string) (string, error) {
return "", fmt.Errorf("failed to copy file data: %w", err)
}
err = writer.WriteField("bucket", "images")
err = writer.WriteField("bucket", bucket)
if err != nil {
return "", fmt.Errorf("failed to write bucket field: %w", err)
}

View file

@ -257,7 +257,7 @@ type AnnuncioParsed struct {
Titolo_en *string
Unita_condominio *string
Numero_vani *string
Url_video *[]string
External_videos *[]string
Web *bool
Disponibile_da *string
Permanenza *[]int
@ -308,7 +308,7 @@ type AnnunciDB struct {
Web *bool
Caratteristiche *map[string]interface{}
Homepage *bool
Url_video *[]string
External_videos *[]string
Email *string
Creato_il *time.Time
Modificato_il *time.Time
@ -358,13 +358,21 @@ type ImagesRefs struct {
Og_url string
}
type ImagesToProcess struct {
type VideosRefs struct {
Codice string
Ordine int
Video string
Thumb string
Og_url string
}
type MediaToProcess struct {
Codice string
Url string
Order int
}
type ProcessedImage struct {
type ProcessedMedia struct {
Codice string
Order int
Path string
@ -372,10 +380,10 @@ type ProcessedImage struct {
OGUrl string
}
type UploadedImage struct {
type UploadedMedia struct {
Codice string
Order int
ImgId string
MediaId string
ThumbId string
OGUrl string
}

399
apps/backend/video.go Normal file
View file

@ -0,0 +1,399 @@
package main
import (
"backend/config"
"backend/queries"
"backend/typesdefs"
"backend/utils"
"fmt"
"image/jpeg"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/labstack/echo/v4"
"github.com/nfnt/resize"
"github.com/nickalie/go-webpbin"
)
func AddToVideoProcessing(tmp *typesdefs.AnnuncioParsed, annuncio *typesdefs.AnnuncioXML, p *[]typesdefs.MediaToProcess) {
miogestVideos := []string{}
if annuncio.AMMedias != nil {
for _, media := range *annuncio.AMMedias {
if media.AMVideo != nil && media.AMVideo.Url != nil {
if strings.Contains(*media.AMVideo.Url, "video.miogest") {
miogestVideos = append(miogestVideos, *media.AMVideo.Url)
}
}
}
}
for idx, url := range miogestVideos {
*p = append(*p, typesdefs.MediaToProcess{
Codice: tmp.Codice,
Url: url,
Order: idx,
})
}
}
func ProcessVideoPool(toProcess *[]typesdefs.MediaToProcess) ([]typesdefs.UploadedMedia, error) {
var (
ProcessedVideos []typesdefs.ProcessedMedia
UploadedVideos []typesdefs.UploadedMedia
mu sync.Mutex
wg sync.WaitGroup
)
sem := make(chan struct{}, config.Cfg.Videos.ConcurrentLimit)
// clear videos folder
err := clearVideoFolder()
if err != nil {
log.Fatalf("Failed to initialize image processing: %v", err)
}
// process videos
for _, video := range *toProcess {
sem <- struct{}{} // acquire semaphore
wg.Add(1)
go func(video typesdefs.MediaToProcess) {
defer wg.Done()
defer func() { <-sem }() // release semaphore
videoPath := filepath.Join(config.Cfg.Videos.Path, fmt.Sprintf("%s_%d%s", video.Codice, video.Order, filepath.Ext(video.Url)))
err := downloadVideo(video.Url, videoPath)
if err != nil {
log.Printf("Failed to download video %s: %v", video.Url, err)
return // Exit goroutine on download failure
}
// Transcode and generate thumbnail
mp4Path, thumbnailPath, err := VideoConversion(videoPath)
if err != nil {
log.Printf("Failed to convert video %s: %v", videoPath, err)
return
}
mu.Lock()
ProcessedVideos = append(ProcessedVideos, typesdefs.ProcessedMedia{
Codice: video.Codice,
Order: video.Order,
Path: mp4Path,
ThumnailPath: thumbnailPath,
OGUrl: video.Url,
})
mu.Unlock()
}(video)
}
wg.Wait()
uploadSem := make(chan struct{}, config.Cfg.Videos.ConcurrentLimit)
var uploadWg sync.WaitGroup
for _, video := range ProcessedVideos {
uploadSem <- struct{}{} // acquire semaphore
uploadWg.Add(1)
go func(video typesdefs.ProcessedMedia) {
defer uploadWg.Done()
defer func() { <-uploadSem }() // release semaphore
videoId, err := UploadFile(video.Path, "video/mp4", "videos")
if err != nil {
log.Printf("failed to upload video: %v", err)
return
}
thumbId, err := UploadFile(video.ThumnailPath, "image/webp", "videos")
if err != nil {
log.Printf("failed to upload thumbnail: %v", err)
return
}
mu.Lock()
UploadedVideos = append(UploadedVideos, typesdefs.UploadedMedia{
Codice: video.Codice,
Order: video.Order,
MediaId: videoId,
ThumbId: thumbId,
OGUrl: video.OGUrl,
})
mu.Unlock()
}(video)
}
uploadWg.Wait()
return UploadedVideos, nil
}
func clearVideoFolder() error {
err := utils.SafeRemoveAll(config.Cfg.Videos.Path)
if err != nil {
return err
}
err = os.Mkdir(config.Cfg.Videos.Path, 0755)
if err != nil {
return err
}
return nil
}
func downloadVideo(url, filename string) error {
client := &http.Client{
Timeout: 5 * time.Minute, // Adjust based on expected video sizes
}
response, err := client.Get(url)
if err != nil {
return fmt.Errorf("failed to download video: %w", err)
}
defer response.Body.Close()
// Check HTTP status
if response.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download video: HTTP %d", response.StatusCode)
}
// Optional: Validate Content-Type
contentType := response.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "video/") && !strings.HasPrefix(contentType, "application/") {
log.Printf("Warning: unexpected content type %s for %s", contentType, url)
}
file, err := os.Create(filename)
if err != nil {
return fmt.Errorf("failed to create video file: %w", err)
}
defer file.Close()
_, err = io.Copy(file, response.Body)
if err != nil {
return fmt.Errorf("failed to save video file: %w", err)
}
return nil
}
// VideoConversion transcodes video and generates thumbnail
func VideoConversion(input string) (string, string, error) {
outputdir := filepath.Dir(input)
basename := filepath.Base(input)
ext := filepath.Ext(basename)
nameWithoutExt := basename[0 : len(basename)-len(ext)]
tempVideo := filepath.Join(outputdir, nameWithoutExt+"_temp.mp4")
videoOutput := filepath.Join(outputdir, nameWithoutExt+".mp4")
thumbOutput := filepath.Join(outputdir, "thumbnail-"+nameWithoutExt+".webp")
// 1. Transcode video to H.264/AAC MP4 (web-optimized)
log.Println("Transcoding video:", input)
err := transcodeVideo(input, tempVideo)
if err != nil {
return "", "", fmt.Errorf("failed to transcode video: %w", err)
}
// 2. Extract thumbnail from video at 1 second
log.Println("Extracting thumbnail from:", input)
err = extractThumbnail(input, thumbOutput)
if err != nil {
return "", "", fmt.Errorf("failed to extract thumbnail: %w", err)
}
os.Remove(input)
err = os.Rename(tempVideo, videoOutput)
if err != nil {
return "", "", fmt.Errorf("failed to rename converted video: %w", err)
}
return videoOutput, thumbOutput, nil
}
// transcodeVideo converts video to web-optimized MP4
func transcodeVideo(input, output string) error {
// FFmpeg command for web optimization:
// -c:v libx264: H.264 codec (widely supported)
// -preset fast: encoding speed/quality tradeoff
// -crf 23: quality (lower = better, 18-28 recommended)
// -c:a aac: AAC audio codec
// -b:a 128k: audio bitrate
// -movflags +faststart: enable streaming before full download
// -vf scale=1280:-2: scale to max width 1280, maintain aspect ratio
cmd := exec.Command("ffmpeg",
"-i", input,
"-c:v", "libx264",
"-preset", "fast",
"-crf", "23",
"-c:a", "aac",
"-b:a", "128k",
"-movflags", "+faststart",
"-vf", "scale=min(1280\\,iw):-2", // Escape comma for cross-platform
"-y",
output,
)
output_bytes, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("ffmpeg transcode failed: %w, output: %s", err, string(output_bytes))
}
return nil
}
// extractThumbnail generates a WebP thumbnail from video
func extractThumbnail(input, output string) error {
// Extract frame at 1 second as JPEG to temp file
tempJpeg := output + ".tmp.jpg"
defer os.Remove(tempJpeg)
cmd := exec.Command("ffmpeg",
"-i", input,
"-ss", "00:00:01", // seek to 1 second
"-vframes", "1", // extract 1 frame
"-q:v", "2", // JPEG quality (2-31, lower = better)
"-y",
tempJpeg,
)
output_bytes, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("ffmpeg thumbnail extraction failed: %w, output: %s", err, string(output_bytes))
}
// Convert JPEG to WebP thumbnail
f, err := os.Open(tempJpeg)
if err != nil {
return fmt.Errorf("failed to open temp jpeg: %w", err)
}
defer f.Close()
img, err := jpeg.Decode(f)
if err != nil {
return fmt.Errorf("failed to decode jpeg: %w", err)
}
// Resize to thumbnail (320px width)
resizedImg := resize.Resize(320, 0, img, resize.Lanczos3)
out, err := os.Create(output)
if err != nil {
return fmt.Errorf("failed to create thumbnail: %w", err)
}
defer out.Close()
err = webpbin.Encode(out, resizedImg)
if err != nil {
return fmt.Errorf("failed to encode webp: %w", err)
}
return nil
}
func FallbackVideo(c echo.Context) error {
// Serve a fallback image if the requested image is not found
fallbackPath := "fallback.webp" // Path to your fallback image
if _, err := os.Stat(fallbackPath); os.IsNotExist(err) {
return c.String(http.StatusNotFound, "Fallback image not found")
}
return c.File(fallbackPath)
}
type videRef struct {
Og_url string
Video string
Thumb string
}
func CodiciToProcessVideos(annunci *typesdefs.AnnunciXML) ([]string, error) {
currentBucketState, err := ListBucket("videos")
if err != nil {
return nil, fmt.Errorf("failed to list bucket: %w", err)
}
//make a list of bucket ids
bucketIdsMap := make(map[string]bool)
for _, item := range currentBucketState {
bucketIdsMap[item.ID] = true
}
db_videos, err := queries.GetVideosFromDB()
if err != nil {
return nil, err
}
dbVideosMap := make(map[string][]videRef) // Convert slice to map for faster lookup
for _, ref := range db_videos {
dbVideosMap[ref.Codice] = append(dbVideosMap[ref.Codice], videRef{
Og_url: ref.Og_url,
Video: ref.Video,
Thumb: ref.Thumb,
})
}
var codiciToProcess []string
for _, annuncio := range annunci.Annuncio {
if annuncio.Codice != nil && annuncio.AMMedias != nil {
refVideos, existsInDb := dbVideosMap[*annuncio.Codice]
if !existsInDb {
codiciToProcess = append(codiciToProcess, *annuncio.Codice)
continue
}
needProcess := false
for _, media := range *annuncio.AMMedias {
if media.AMVideo == nil || media.AMVideo.Url == nil {
continue
}
if !strings.Contains(*media.AMVideo.Url, "video.miogest") {
continue
}
found := false
for _, ref := range refVideos {
if *media.AMVideo.Url == ref.Og_url {
if bucketIdsMap[ref.Video] && bucketIdsMap[ref.Thumb] {
found = true
break
}
}
}
if !found {
needProcess = true
break
}
}
if needProcess {
codiciToProcess = append(codiciToProcess, *annuncio.Codice)
}
}
}
return codiciToProcess, nil
}
func ForceClearVideos(codici []string) error {
var idsToDelete []string
for _, codice := range codici {
refs, err := queries.GetCodiceVideosFromDB(codice)
if err != nil {
return fmt.Errorf("failed to get videos by codice %s: %w", codice, err)
}
for _, ref := range refs {
idsToDelete = append(idsToDelete, ref.Video)
idsToDelete = append(idsToDelete, ref.Thumb)
}
}
err := DeleteIdsFromStorage(idsToDelete)
if err != nil {
return fmt.Errorf("failed to delete videos from storage: %w", err)
}
for _, codice := range codici {
err := queries.ClearVideosRefsByCodice(codice)
if err != nil {
return fmt.Errorf("failed to clear videos by codice %s: %w", codice, err)
}
}
return nil
}

View file

@ -0,0 +1,59 @@
-- Images_refs index
CREATE INDEX IF NOT EXISTS annunci_imgs ON images_refs (codice);
-- Videos_refs Table
CREATE TABLE IF NOT EXISTS public.videos_refs (
codice TEXT COLLATE pg_catalog."default" NOT NULL,
ordine integer NOT NULL,
video TEXT COLLATE pg_catalog."default" NOT NULL,
thumb TEXT COLLATE pg_catalog."default" NOT NULL,
og_url TEXT COLLATE pg_catalog."default" NOT NULL
);
-- Unique Codice and Ordine for Videos_refs Table
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'video_cod_order'
AND conrelid = 'public.videos_refs'::regclass
) THEN
ALTER TABLE public.videos_refs
ADD CONSTRAINT video_cod_order UNIQUE (codice, ordine);
END IF;
END $$;
-- Foreign Key to Annunci for Videos_refs Table
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'cod_video'
AND conrelid = 'public.videos_refs'::regclass
) THEN
ALTER TABLE public.videos_refs
ADD CONSTRAINT cod_video
FOREIGN KEY (codice) REFERENCES public.annunci (codice)
ON UPDATE CASCADE ON DELETE CASCADE;
END IF;
END $$;
-- Index for Videos_refs Table
CREATE INDEX IF NOT EXISTS annunci_videos ON videos_refs (codice);
-- Rename url_video to external_videos in Annunci Table
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name='annunci'
AND column_name='url_video'
) THEN
ALTER TABLE public.annunci
RENAME COLUMN url_video TO external_videos;
END IF;
END $$;

View file

@ -29,8 +29,9 @@ import {
import { camereTesti, handleConsegna } from "~/lib/annuncio_details";
import { cn, formatCurrency } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider";
import type { ImagesRefs } from "~/schemas/public/ImagesRefs";
import type { VideosRefs } from "~/schemas/public/VideosRefs";
import type { AnnuncioRicerca } from "~/server/controllers/annunci.controller";
import { Badge } from "./ui/badge";
import { Skeleton } from "./ui/skeleton";
import { VideoPlayer } from "./videoPlayer";
@ -125,7 +126,7 @@ export const CardAnnuncio = ({
tipo,
stato,
className,
url_video: videos,
videos,
media_updated_at,
images,
homepage,
@ -226,10 +227,6 @@ export const CardAnnuncio = ({
</CarouselItem>
))}
{videos?.map((video) => {
if (!video) return null;
if (video.includes("youtu")) {
return null;
}
return (
<CarouselItem
className={cn("group relative")}
@ -243,14 +240,10 @@ export const CardAnnuncio = ({
cacheKey={
media_updated_at?.toString() || new Date().toString()
}
className="h-56"
coverImage={
images?.[0]
? `/storage-api/get/${images[0].img}?image=true`
: ""
}
className="h-64 object-cover"
coverImage={`/storage-api/get/${video.thumb}?image=true`}
key={`videoplayer-${video}`}
videoSrc={video}
videoSrc={`/storage-api/get/${video.video}?video=true`}
/>
</CarouselItem>
);
@ -392,8 +385,8 @@ export const CarouselAnnuncio = ({
updated_at,
}: {
single?: boolean;
immagini: string[] | null;
videos: string[] | null;
immagini: Pick<ImagesRefs, "img" | "thumb">[];
videos: Pick<VideosRefs, "video" | "thumb">[];
updated_at: Date | null;
}) => {
const [openModal, setOpenModal] = useState(false);
@ -416,10 +409,10 @@ export const CarouselAnnuncio = ({
? ""
: !single && "md:basis-1/2 lg:basis-1/3",
)}
key={`${img}-cF`}
key={`${img.img}-cF`}
>
<ImageFlbk
alt={img}
alt={img.img}
className={cn(
"aspect-square max-h-92 w-full rounded-md bg-[#e6e9ec] object-cover sm:max-h-80 xl:max-h-[25rem]",
immagini.length === 1 && "object-contain",
@ -427,7 +420,7 @@ export const CarouselAnnuncio = ({
height={400}
onClick={() => handleOpenModal(idx)}
priority={idx === 0}
src={`/storage-api/get/${img}?image=true&${updated_at?.toString() || new Date().toString()}`}
src={`/storage-api/get/${img.img}?image=true&${updated_at?.toString() || new Date().toString()}`}
width={800}
/>
<Maximize2
@ -437,10 +430,6 @@ export const CarouselAnnuncio = ({
</CarouselItem>
))}
{videos?.map((video, idx) => {
if (!video) return null;
if (video.includes("youtu")) {
return null;
}
return (
<CarouselItem
className={cn(
@ -449,18 +438,14 @@ export const CarouselAnnuncio = ({
? ""
: !single && "md:basis-1/2 lg:basis-1/3",
)}
key={`videoplayer-${video}`}
key={`videoplayer-${video.video}`}
>
<VideoPlayer
cacheKey={updated_at?.toString() || new Date().toString()}
className="h-72"
coverImage={
immagini?.[0]
? `/storage-api/get/${immagini[0]}?image=true`
: ""
}
className="aspect-square max-h-92 object-cover sm:max-h-80 xl:max-h-[25rem]"
coverImage={`/storage-api/get/${video.thumb}?image=true`}
key={`videoplayer-${idx}-${openModal}`}
videoSrc={video}
videoSrc={`/storage-api/get/${video.video}?video=true`}
/>
<Maximize2
@ -496,35 +481,27 @@ export const CarouselAnnuncio = ({
<Carousel opts={{ loop: true, startIndex: idxModal }}>
<CarouselContent>
{immagini?.map((img, idx) => (
<CarouselItem key={`${img}-cD`}>
<CarouselItem key={`${img.img}-cD`}>
<ImageFlbk
alt={`carousel-img-${idx}`}
className="mx-auto h-[90vh] w-full rounded-md bg-[#e6e9ec] object-contain sm:w-[80vw]"
height={1080}
src={`/storage-api/get/${img}?image=true&${updated_at?.toString() || new Date().toString()}`}
src={`/storage-api/get/${img.img}?image=true&${updated_at?.toString() || new Date().toString()}`}
width={1920}
/>
</CarouselItem>
))}
{videos?.map((video) => {
if (!video) return null;
if (video.includes("youtu")) {
return null;
}
return (
<CarouselItem
className="relative flex items-center justify-center"
key={`carousel-videoplayer-${video}`}
key={`carousel-videoplayer-${video.video}`}
>
<VideoPlayer
cacheKey={updated_at?.toString() || new Date().toString()}
className="absolute mx-auto h-[90vh] w-full max-w-fit rounded-md object-contain"
coverImage={
immagini?.[0]
? `/storage-api/get/${immagini[0]}?image=true`
: ""
}
videoSrc={video}
coverImage={`/storage-api/get/${video.thumb}?image=true`}
videoSrc={`/storage-api/get/${video.video}?video=true`}
/>
</CarouselItem>
);

View file

@ -59,9 +59,9 @@ export const AnnuncioDettaglio = ({ data }: { data: AnnuncioRicerca }) => {
<div className="flex flex-col gap-2 px-2">
<div className="w-full contain-inline-size">
<CarouselAnnuncio
immagini={data.images.map((img) => img.img)}
immagini={data.images}
updated_at={data.media_updated_at}
videos={data.url_video}
videos={data.videos}
/>
</div>

View file

@ -46,12 +46,12 @@ import { cn } from "~/lib/utils";
import { useZodForm } from "~/lib/zodForm";
import { StatusBadge } from "~/pages/area-riservata/admin/edit-annuncio/[id]";
import { useTranslation } from "~/providers/I18nProvider";
import type { AnnunciWithImages } from "~/server/controllers/annunci.controller";
import type { AnnunciWithMedia } from "~/server/controllers/annunci.controller";
import { zAnnuncioId } from "~/server/utils/zod_types";
import { api } from "~/utils/api";
import type { Caratteristiche } from "~/utils/kanel-types";
export const AnnuncioEditForm = ({ data }: { data: AnnunciWithImages }) => {
export const AnnuncioEditForm = ({ data }: { data: AnnunciWithMedia }) => {
const schema = z.object({
accessori: z.string().array().nullable(),
@ -516,10 +516,10 @@ export const AnnuncioEditForm = ({ data }: { data: AnnunciWithImages }) => {
</CardHeader>
<CardContent>
<CarouselAnnuncio
immagini={data.images?.map((v) => v.img) || "fallback"}
immagini={data.images}
single
updated_at={data.media_updated_at}
videos={data.url_video}
videos={data.videos}
/>
</CardContent>
</Card>
@ -1050,7 +1050,6 @@ export const AnnuncioEditForm = ({ data }: { data: AnnunciWithImages }) => {
"h-[42px] w-full pl-3 text-left font-medium",
!field.value && "text-muted-foreground",
"rounded-lg border border-neutral-300 shadow-xs outline-hidden file:border-0 file:bg-transparent file:font-medium file:text-sm disabled:cursor-not-allowed disabled:opacity-50 dark:focus:border-transparent",
)}
id="disponibile_da"
variant={"outline"}

View file

@ -434,6 +434,7 @@ const AnnunciList = () => {
key={annuncio.codice}
{...annuncio}
eager={idx <= 9}
videos={[]}
/>
))}
</div>

View file

@ -60,6 +60,7 @@ import { useTranslation } from "~/providers/I18nProvider";
import { useSession } from "~/providers/SessionProvider";
import type { Annunci } from "~/schemas/public/Annunci";
import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum";
import type { VideosRefs } from "~/schemas/public/VideosRefs";
import { generateSSGHelper } from "~/server/utils/ssgHelper";
import { api } from "~/utils/api";
@ -135,9 +136,9 @@ const AnnuncioView = ({ cod, flag }: Omit<AnnuncioProps, "meta">) => {
<TouchProvider>
<div className="mx-auto w-full max-w-[100rem] px-2 sm:px-8">
<CarouselAnnuncio
immagini={data.images.map((img) => img.img)}
immagini={data.images}
updated_at={data.media_updated_at}
videos={data.url_video}
videos={data.videos}
/>
<div className="flex flex-col sm:py-8 md:flex-row-reverse md:gap-2">
@ -200,14 +201,10 @@ const AnnuncioView = ({ cod, flag }: Omit<AnnuncioProps, "meta">) => {
<AnnuncioFooter
comune={data.comune}
consegna={data.consegna}
first_image={
data.images?.[0]
? `/storage-api/get/${data.images[0]}?image=true`
: ""
}
external_videos={data.external_videos || []}
tipo={data.tipo}
updated_at={data.media_updated_at}
url_video={data.url_video || []}
videos={data.videos}
/>
</div>
</TouchProvider>
@ -615,33 +612,45 @@ const AnnuncioFooter = ({
tipo,
comune,
consegna,
url_video,
first_image,
videos,
external_videos,
updated_at,
}: {
tipo: string | null;
comune: string | null;
consegna: number | null;
url_video: string[];
first_image: string;
videos: Pick<VideosRefs, "video" | "thumb">[];
external_videos: string[];
updated_at: Date | null;
}) => {
return (
<div className="flex flex-col gap-2">
<p className="px-2 font-semibold text-lg">Video:</p>
<div className="flex flex-col flex-wrap gap-4 sm:flex-row sm:gap-2 sm:px-2">
{url_video.map((video) => {
if (!video) return null;
if (video.includes("youtu")) {
return null;
}
{videos.map((video) => {
return (
<VideoPlayer
cacheKey={updated_at?.toString() || new Date().toString()}
className="h-96 max-w-96"
coverImage={first_image}
key={`videoplayer-${video}`} // Cache busting
videoSrc={video}
coverImage={`/storage-api/get/${video.thumb}?image=true`}
key={`videoplayer-${video.video}`} // Cache busting
videoSrc={`/storage-api/get/${video.video}?video=true`}
/>
);
})}
{external_videos.map((videoUrl) => {
const embedUrl = getYouTubeEmbedUrl(videoUrl);
if (!embedUrl) return null;
return (
<iframe
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
className="h-96 max-w-96 rounded-lg"
key={`external-videoplayer-${videoUrl}`}
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
src={embedUrl}
title="YouTube video player"
/>
);
})}
@ -665,3 +674,51 @@ const AnnuncioFooter = ({
</div>
);
};
export const getYouTubeEmbedUrl = (url: string): string | null => {
try {
const urlObj = new URL(url);
let videoId: string | null = null;
// youtube.com/watch?v=VIDEO_ID
if (
urlObj.hostname.includes("youtube.com") &&
urlObj.pathname === "/watch"
) {
videoId = urlObj.searchParams.get("v");
}
// youtu.be/VIDEO_ID
if (urlObj.hostname === "youtu.be") {
videoId = urlObj.pathname.slice(1);
}
// youtube.com/embed/VIDEO_ID (already embed format)
if (
urlObj.hostname.includes("youtube.com") &&
urlObj.pathname.startsWith("/embed/")
) {
videoId = urlObj.pathname.split("/embed/")[1]?.split("?")[0] || null;
}
if (!videoId) return null;
// Use youtube-nocookie.com domain (privacy-enhanced mode)
// Add parameters to reduce ads and suggestions
const params = new URLSearchParams({
rel: "0", // Don't show related videos from other channels
modestbranding: "1", // Minimal YouTube branding
controls: "1", // Show player controls
showinfo: "0", // Don't show video info before playing
fs: "1", // Allow fullscreen
iv_load_policy: "3", // Hide video annotations
disablekb: "0", // Enable keyboard controls
autoplay: "0", // Don't autoplay
// cc_load_policy: "1", // Show closed captions (optional)
});
return `https://www.youtube-nocookie.com/embed/${videoId}?${params.toString()}`;
} catch {
return null;
}
};

View file

@ -93,7 +93,7 @@ export default interface AnnunciTable {
homepage: ColumnType<boolean | null, boolean | null, boolean | null>;
url_video: ColumnType<string[] | null, string[] | null, string[] | null>;
external_videos: ColumnType<string[] | null, string[] | null, string[] | null>;
email: ColumnType<string | null, string | null, string | null>;

View file

@ -21,6 +21,7 @@ import type { default as EtichetteTable } from './Etichette';
import type { default as MessagesTable } from './Messages';
import type { default as ProvincieTable } from './Provincie';
import type { default as UsersAnagraficaTable } from './UsersAnagrafica';
import type { default as VideosRefsTable } from './VideosRefs';
import type { default as BanlistTable } from './Banlist';
import type { default as NazioniTable } from './Nazioni';
import type { default as ImagesRefsTable } from './ImagesRefs';
@ -68,6 +69,8 @@ export default interface PublicSchema {
users_anagrafica: UsersAnagraficaTable;
videos_refs: VideosRefsTable;
banlist: BanlistTable;
nazioni: NazioniTable;

View file

@ -0,0 +1,23 @@
// @generated
// This file is automatically generated by Kanel. Do not modify manually.
import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely';
/** Represents the table public.videos_refs */
export default interface VideosRefsTable {
codice: ColumnType<string, string, string>;
ordine: ColumnType<number, number, number>;
video: ColumnType<string, string, string>;
thumb: ColumnType<string, string, string>;
og_url: ColumnType<string, string, string>;
}
export type VideosRefs = Selectable<VideosRefsTable>;
export type NewVideosRefs = Insertable<VideosRefsTable>;
export type VideosRefsUpdate = Updateable<VideosRefsTable>;

View file

@ -1,5 +1,4 @@
import { TRPCError } from "@trpc/server";
import { jsonArrayFrom } from "kysely/helpers/postgres";
import z from "zod/v4";
import { env } from "~/env";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
@ -13,6 +12,7 @@ import {
import { NewMail } from "~/server/services/mailer";
import { findUser_byId_MINI } from "~/server/services/user.service";
import { zAnnuncioId, zUserId } from "~/server/utils/zod_types";
import { withImages, withVideos } from "~/utils/kysely-helper";
export const intrestsRouter = createTRPCRouter({
addIntrest: protectedProcedure
@ -90,7 +90,7 @@ export const intrestsRouter = createTRPCRouter({
}
const annunci = await db
.selectFrom("annunci")
.select((eb) => [
.select((_eb) => [
"annunci.id",
"annunci.codice",
"annunci.comune",
@ -106,17 +106,12 @@ export const intrestsRouter = createTRPCRouter({
"annunci.desc_it",
"annunci.modificato_il",
"annunci.stato",
"annunci.url_video",
"annunci.external_videos",
"annunci.media_updated_at",
"annunci.homepage",
"annunci.web",
jsonArrayFrom(
eb
.selectFrom("images_refs")
.select(["images_refs.img", "images_refs.thumb"])
.whereRef("images_refs.codice", "=", "annunci.codice")
.orderBy("images_refs.ordine", "asc"),
).as("images"),
withImages(),
withVideos(),
])
.where("web", "=", true)
.where("stato", "!=", "Sospeso")

View file

@ -1,5 +1,4 @@
import { TRPCError } from "@trpc/server";
import { jsonArrayFrom } from "kysely/helpers/postgres";
import { env } from "~/env";
import type {
Annunci,
@ -8,7 +7,9 @@ import type {
} from "~/schemas/public/Annunci";
import type { ImagesRefs } from "~/schemas/public/ImagesRefs";
import type { ServizioServizioId } from "~/schemas/public/Servizio";
import type { VideosRefs } from "~/schemas/public/VideosRefs";
import { db } from "~/server/db";
import { withImages, withVideos } from "~/utils/kysely-helper";
import { revalidate } from "../utils/revalidationHelper";
// const ratelimit = new RateLimiterHandler({
@ -16,8 +17,9 @@ import { revalidate } from "../utils/revalidationHelper";
// maxRequests: 10,
// analytics: true,
// });
export type AnnunciWithImages = Annunci & {
export type AnnunciWithMedia = Annunci & {
images: Pick<ImagesRefs, "img" | "thumb">[];
videos: Pick<VideosRefs, "video" | "thumb">[];
};
export const getAnnunciListHandler = async (): Promise<
@ -72,20 +74,12 @@ export const getAnnunciByCod = async ({
cod,
}: {
cod: string;
}): Promise<AnnunciWithImages | null> => {
}): Promise<AnnunciWithMedia | null> => {
try {
const annuncio = await db
.selectFrom("annunci")
.selectAll()
.select((eb) =>
jsonArrayFrom(
eb
.selectFrom("images_refs")
.whereRef("images_refs.codice", "=", "annunci.codice")
.select(["img", "thumb"])
.orderBy("images_refs.ordine", "asc"),
).as("images"),
)
.select((_eb) => [withImages(), withVideos()])
.where("annunci.web", "=", true)
.where("codice", "=", cod)
.executeTakeFirst();
@ -107,17 +101,7 @@ export const getAnnunciMetaByCod = async ({ cod }: { cod: string }) => {
try {
const annuncio = await db
.selectFrom("annunci")
.select((eb) => [
"titolo_it",
"desc_it",
jsonArrayFrom(
eb
.selectFrom("images_refs")
.whereRef("images_refs.codice", "=", "annunci.codice")
.select(["img", "thumb"])
.orderBy("images_refs.ordine", "asc"),
).as("images"),
])
.select((_eb) => ["titolo_it", "desc_it", withImages()])
.where("annunci.web", "=", true)
.where("codice", "=", cod)
.executeTakeFirst();
@ -147,20 +131,12 @@ export const getAnnunciById_rawImgUrls = async ({
id,
}: {
id: AnnunciId;
}): Promise<AnnunciWithImages | null> => {
}): Promise<AnnunciWithMedia | null> => {
try {
const annuncio = await db
.selectFrom("annunci")
.selectAll()
.select((eb) => [
jsonArrayFrom(
eb
.selectFrom("images_refs")
.select(["images_refs.img", "images_refs.thumb"])
.whereRef("images_refs.codice", "=", "annunci.codice")
.orderBy("images_refs.ordine", "asc"),
).as("images"),
])
.select((_eb) => [withImages(), withVideos()])
.where("id", "=", id)
.executeTakeFirst();
if (!annuncio) {
@ -196,7 +172,7 @@ export const get_AnnunciPositionsHandler = async ({
try {
let query = db
.selectFrom("annunci")
.select((eb) => [
.select((_eb) => [
"id",
"codice",
"comune",
@ -213,18 +189,13 @@ export const get_AnnunciPositionsHandler = async ({
"web",
"modificato_il",
"stato",
"url_video",
"external_videos",
"lon_secondario",
"lat_secondario",
"media_updated_at",
"homepage",
jsonArrayFrom(
eb
.selectFrom("images_refs")
.whereRef("images_refs.codice", "=", "annunci.codice")
.select(["img", "thumb"])
.orderBy("images_refs.ordine", "asc"),
).as("images"),
withImages(),
withVideos(),
])
.where("web", "=", true)
@ -272,12 +243,13 @@ export type AnnuncioRicerca = Pick<
| "desc_it"
| "modificato_il"
| "stato"
| "url_video"
| "external_videos"
| "media_updated_at"
| "homepage"
| "web"
> & {
images: Pick<ImagesRefs, "img" | "thumb">[];
videos: Pick<VideosRefs, "video" | "thumb">[];
};
export const getCursor_AnnunciHandler = async ({
@ -301,7 +273,7 @@ export const getCursor_AnnunciHandler = async ({
try {
let query = db
.selectFrom("annunci")
.select((eb) => [
.select((_eb) => [
"id",
"codice",
"comune",
@ -318,16 +290,11 @@ export const getCursor_AnnunciHandler = async ({
"web",
"modificato_il",
"stato",
"url_video",
"external_videos",
"media_updated_at",
"homepage",
jsonArrayFrom(
eb
.selectFrom("images_refs")
.whereRef("images_refs.codice", "=", "annunci.codice")
.select(["img", "thumb"])
.orderBy("images_refs.ordine", "asc"),
).as("images"),
withImages(),
withVideos(),
])
.where("web", "=", true)

View file

@ -41,6 +41,7 @@ import {
import { db } from "~/server/db";
import { NewMail, type NewMailProps } from "~/server/services/mailer";
import { getUser } from "~/server/services/user.service";
import { withImages, withVideos } from "~/utils/kysely-helper";
import type { AnnuncioRicerca } from "./annunci.controller";
export const addServizio = async (data: NewServizio) => {
@ -677,7 +678,7 @@ export const getAllServizioAnnunci = async (userId: UsersId) => {
.selectFrom("servizio_annunci")
.selectAll("servizio_annunci")
.innerJoin("annunci", "annunci.id", "servizio_annunci.annunci_id")
.select((eb) => [
.select((_eb) => [
"annunci.id",
"annunci.codice",
"annunci.comune",
@ -693,17 +694,12 @@ export const getAllServizioAnnunci = async (userId: UsersId) => {
"annunci.desc_it",
"annunci.modificato_il",
"annunci.stato",
"annunci.url_video",
"annunci.external_videos",
"annunci.media_updated_at",
"annunci.homepage",
"annunci.web",
jsonArrayFrom(
eb
.selectFrom("images_refs")
.select(["images_refs.img", "images_refs.thumb"])
.whereRef("images_refs.codice", "=", "annunci.codice")
.orderBy("images_refs.ordine", "asc"),
).as("images"),
withImages(),
withVideos(),
])
//ignora annunci che non sono disponibili
.where("annunci.web", "=", true)
@ -1166,17 +1162,12 @@ export const sendServizioEmail = async ({
const annunci = await db
.selectFrom("servizio_annunci")
.innerJoin("annunci", "annunci.id", "servizio_annunci.annunci_id")
.select((eb) => [
.select((_eb) => [
"annunci.titolo_it",
"annunci.codice",
"annunci.prezzo",
jsonArrayFrom(
eb
.selectFrom("images_refs")
.select(["images_refs.img", "images_refs.thumb"])
.whereRef("images_refs.codice", "=", "annunci.codice")
.orderBy("images_refs.ordine", "asc"),
).as("images"),
withImages(),
withVideos(),
])
.where("servizio_annunci.servizio_id", "=", servizioId)
.execute();
@ -1604,7 +1595,7 @@ export const getCompatibileAnnunci = async ({
let qry = db
.selectFrom("annunci")
.distinctOn("annunci.id")
.select((eb) => [
.select((_eb) => [
"id",
"codice",
"comune",
@ -1622,15 +1613,10 @@ export const getCompatibileAnnunci = async ({
"media_updated_at",
"homepage",
"numero_camere",
"url_video",
"external_videos",
"modificato_il",
jsonArrayFrom(
eb
.selectFrom("images_refs")
.select(["images_refs.img", "images_refs.thumb"])
.whereRef("images_refs.codice", "=", "annunci.codice")
.orderBy("images_refs.ordine", "asc"),
).as("images"),
withImages(),
withVideos(),
])
.where("web", "=", true)
.where("stato", "!=", "Sospeso")

View file

@ -30,3 +30,25 @@ export function getUserInfo() {
.whereRef("users.id", "=", "chats.user_ref"),
).as("userinfo");
}
export function withImages() {
const eb = expressionBuilder<Database, "annunci">();
return jsonArrayFrom(
eb
.selectFrom("images_refs")
.select(["img", "thumb"])
.whereRef("images_refs.codice", "=", "annunci.codice")
.orderBy("images_refs.ordine", "asc"),
).as("images");
}
export function withVideos() {
const eb = expressionBuilder<Database, "annunci">();
return jsonArrayFrom(
eb
.selectFrom("videos_refs")
.select(["video", "thumb"])
.whereRef("videos_refs.codice", "=", "annunci.codice")
.orderBy("videos_refs.ordine", "asc"),
).as("videos");
}