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:
parent
05cc912335
commit
f4262b8711
26 changed files with 886 additions and 258 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
53
apps/backend/queries/videos.go
Normal file
53
apps/backend/queries/videos.go
Normal 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
|
||||
}
|
||||
|
|
@ -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")
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
399
apps/backend/video.go
Normal 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
|
||||
}
|
||||
59
apps/db/migrations/26_videos_refs.up.sql
Normal file
59
apps/db/migrations/26_videos_refs.up.sql
Normal 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 $$;
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -434,6 +434,7 @@ const AnnunciList = () => {
|
|||
key={annuncio.codice}
|
||||
{...annuncio}
|
||||
eager={idx <= 9}
|
||||
videos={[]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
23
apps/infoalloggi/src/schemas/public/VideosRefs.ts
Normal file
23
apps/infoalloggi/src/schemas/public/VideosRefs.ts
Normal 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>;
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue