This commit is contained in:
cangui 2025-06-21 18:17:16 +02:00
parent 3fc81e5c80
commit 3409091557
6 changed files with 180 additions and 9 deletions

View File

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

View File

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

View File

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

View File

@ -29,15 +29,14 @@
<ul class="menu-list" id="libraryMenu" hidden>
<li>
<a class="is-active">Choise Library</a>
<ul>
<ul id="paths-list">
{{range .paths}}
<li>
<span class="icon-text">
<span><a>{{ .PathName }}</a></span>
<span class="icon">
<a style="padding-top: 0;height: 0;"><i class="fas fa-ellipsis-v"></i></a>
</span>
</span>
<a href="#" class="path-link" data-id="{{.ID}}">
{{.PathName}}
<i class="fas fa-ellipsis-v"></i>
</a>
</li>
{{end}}
</ul>
</li>

View File

@ -0,0 +1,11 @@
{{define "media_detail"}}
<div class="detail">
<img src="{{.item.UserThumbURL}}" alt="{{.item.Title}}" class="cover">
<h1>{{.item.Title}}</h1>
{{with .item.Summary}}<p>{{.}}</p>{{end}}
<video controls autoplay width="100%" poster="{{.item.UserThumbURL}}">
<source src="/stream/{{.item.MediaPartID}}" type="video/mp4">
Votre navigateur ne supporte pas la vidéo.
</video>
</div>
{{end}}

View File

@ -0,0 +1,19 @@
{{define "media_list"}}
<div class="grid">
{{if .mediaItems}}
{{range .mediaItems}}
<div class="card">
<a href="/media/{{.MediaPartID}}">
<img src="{{.UserThumbURL}}" alt="{{.Title}}" class="thumb" />
<div class="infos">
<h3>{{.Title}}</h3>
{{with .Summary}}<p>{{.}}</p>{{end}}
</div>
</a>
</div>
{{end}}
{{else}}
<p>Aucun média trouvé dans ce dossier.</p>
{{end}}
</div>
{{end}}