diff --git a/internal/route/main.go b/internal/route/main.go index 653e117..d27337d 100644 --- a/internal/route/main.go +++ b/internal/route/main.go @@ -223,6 +223,10 @@ func RoutesProtected(r *mux.Router, bd *gorm.DB) { r.HandleFunc("/folders", renders.StreamHandler) r.HandleFunc("/folders/detail", renders.DetailHandler).Methods("GET") +r.HandleFunc("/api/paths/{id:[0-9]+}/media", renders.PathMedia(bd)).Methods("GET") +r.HandleFunc("/stream/{partID:[0-9]+}", renders.Stream(bd)).Methods("GET") +r.HandleFunc("/media/{partID:[0-9]+}", renders.MediaDetail(bd)).Methods("GET") + //API Scan folder } diff --git a/renders/renders.go b/renders/renders.go index cf2b83d..a154340 100644 --- a/renders/renders.go +++ b/renders/renders.go @@ -11,6 +11,7 @@ import ( "log" "net/http" "os" + "os/exec" "path/filepath" "regexp" "strconv" @@ -839,7 +840,136 @@ func getAllPaths(db *gorm.DB) []*models.PathDownload { return paths } +type mediaItemView struct { + models.MetadataItem + MediaPartID int64 +} +// PathMedia renvoie la partial HTML de la grille de médias +func PathMedia(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + pid, _ := strconv.ParseInt(mux.Vars(r)["id"], 10, 64) + + // 1) tentative depuis la BDD + var items []mediaItemView + db.Table("metadata_items"). + Select("metadata_items.*, media_parts.id AS media_part_id"). + Joins("JOIN media_items ON media_items.metadata_item_id = metadata_items.id"). + Joins("JOIN media_parts ON media_parts.media_item_id = media_items.id"). + Where("metadata_items.library_section_id = ?", pid). + Scan(&items) + + // 2) si rien en BDD, on scan physiquement le dossier et on crée un item minimal + if len(items) == 0 { + var pd models.PathDownload + if err := db.First(&pd, pid).Error; err == nil { + files, _ := filepath.Glob(filepath.Join(pd.Path, "*.*")) + for _, f := range files { + // on prend juste le nom de fichier comme titre + items = append(items, mediaItemView{ + MetadataItem: models.MetadataItem{ + Title: filepath.Base(f), + }, + MediaPartID: 0, // pas de part en BDD + }) + } + } + } + + renderPartial(w, "media_list", map[string]interface{}{ + "mediaItems": items, + }) + } +} + +// MediaDetail affiche la page détail + player +func MediaDetail(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + partID, _ := strconv.ParseInt(mux.Vars(r)["partID"], 10, 64) + + // on récupère 1 metadata + file pour ce media_part + var item struct { + models.MetadataItem + MediaPartID int64 + File string + } + db.Table("metadata_items"). + Select("metadata_items.*, media_parts.id AS media_part_id, media_parts.file"). + Joins("JOIN media_items ON media_items.metadata_item_id = metadata_items.id"). + Joins("JOIN media_parts ON media_parts.media_item_id = media_items.id"). + Where("media_parts.id = ?", partID). + Scan(&item) + + if item.MediaPartID == 0 { + http.Error(w, "Média introuvable", http.StatusNotFound) + return + } + + renderTemplate(w, "media_detail", map[string]interface{}{ + "item": item, + }) + } +} + +// Stream : transcode à la volée en MP4 progressif et pipe directement dans la réponse +func Stream(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + partID, _ := strconv.ParseInt(mux.Vars(r)["partID"], 10, 64) + var part models.MediaPart + if err := db.First(&part, partID).Error; err != nil { + http.Error(w, "Not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "video/mp4") + // ffmpeg en pipe + cmd := exec.CommandContext(r.Context(), + "ffmpeg", + "-i", part.File, + "-c:v", "libx264", + "-c:a", "aac", + "-movflags", "frag_keyframe+empty_moov+faststart", + "-f", "mp4", + "pipe:1", + ) + cmd.Stdout = w + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + log.Println("ffmpeg:", err) + } + } +} +// FfmpegProbeOutput reflète la partie “format” et “streams” de ffprobe JSON +type ffprobeOut struct { + Format struct { + Duration string `json:"duration"` + } `json:"format"` + Streams []struct { + CodecType string `json:"codec_type"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + } `json:"streams"` +} + +// appelle ffprobe et parse le JSON +func probe(ctx context.Context, file string) (*ffprobeOut, error) { + cmd := exec.CommandContext(ctx, + "ffprobe", "-v", "error", + "-print_format", "json", + "-show_format", "-show_streams", + file, + ) + out, err := cmd.Output() + if err != nil { + return nil, err + } + var info ffprobeOut + if err := json.Unmarshal(out, &info); err != nil { + return nil, err + } + return &info, nil +} diff --git a/templates/assets/js/index.js b/templates/assets/js/index.js index 73e60f9..4d88fa8 100644 --- a/templates/assets/js/index.js +++ b/templates/assets/js/index.js @@ -85,4 +85,12 @@ function hide(target){ document.addEventListener("htmx:afterOnLoad", function (event) { // console.log("Réponse du serveur :", event.detail.xhr.responseText); }); - + document.querySelectorAll('.path-link').forEach(el=>{ + el.addEventListener('click', e=>{ + e.preventDefault(); + let id = el.dataset.id; + fetch('/api/paths/'+id+'/media') + .then(r=>r.text()) + .then(html=> document.getElementById('content').innerHTML = html); + }); + }); diff --git a/templates/dashboard.pages.tmpl b/templates/dashboard.pages.tmpl index 2ce7363..84dec18 100644 --- a/templates/dashboard.pages.tmpl +++ b/templates/dashboard.pages.tmpl @@ -29,16 +29,15 @@