diff --git a/internal/debridlink/client.go b/internal/debridlink/client.go index ed7d419..7ef7ea2 100644 --- a/internal/debridlink/client.go +++ b/internal/debridlink/client.go @@ -425,14 +425,36 @@ func (c *Client) RemoveTorrents(ctx context.Context, ids []string) error { // =========================== Downloader =========================== type Link struct { - ID string `json:"id" gorm:"column:id;primaryKey"` - OriginalLink string `json:"originalLink" gorm:"column:original_link"` - DownloadLink string `json:"downloadLink" gorm:"column:download_link"` - Host string `json:"host" gorm:"column:host"` - Status string `json:"status" gorm:"column:status"` - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` - UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` + ID string `json:"id" gorm:"primaryKey;column:id"` + Name string `json:"name" gorm:"column:name"` + URL string `json:"url" gorm:"column:url"` // Lien d'origine + DownloadURL string `json:"downloadUrl" gorm:"column:download_url"` // Lien débridé direct + Host string `json:"host" gorm:"column:host"` // Nom de l'hébergeur + Size int64 `json:"size" gorm:"column:size"` // Taille en octets + Chunk int `json:"chunk" gorm:"column:chunk"` // Nombre de chunks + Expired bool `json:"expired" gorm:"column:expired"` // Lien expiré ou non + Created int64 `json:"created" gorm:"column:created"` // Timestamp + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` } +type StreamInfo struct { + ID string `json:"id" gorm:"primaryKey;column:id"` + StreamURL string `json:"streamUrl" gorm:"column:stream_url"` + DownloadURL string `json:"downloadUrl" gorm:"column:download_url"` + Type string `json:"type" gorm:"column:type"` // hls ou mp4 + MimeType string `json:"mimetype" gorm:"column:mimetype"` // ex: video/mp4 + Domain string `json:"domain" gorm:"column:domain"` + + // Champs du fichier lié (ex : nom de la vidéo) + FileID string `json:"-" gorm:"column:file_id"` // lien avec le champ File.ID ci-dessous + FileName string `json:"-" gorm:"column:file_name"` // nom fichier + FileSize int64 `json:"-" gorm:"column:file_size"` // taille fichier + + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + + func (c *Client) ListLinks(ctx context.Context) ([]Link, error) { var links []Link @@ -441,16 +463,41 @@ func (c *Client) ListLinks(ctx context.Context) ([]Link, error) { } return links, nil } -func (c *Client) AddLink(ctx context.Context, link string) (*Link, error) { - var result Link - body := map[string]string{"url": link} // ✅ CORRECTION - if err := c.doJSON(ctx, "POST", "downloader/add", nil, body, &result); err != nil { // ✅ CORRECTION +func (c *Client) AddLink(ctx context.Context, link string) ([]Link, error) { + var envelope struct { + Success bool `json:"success"` + Value json.RawMessage `json:"value"` + } + + body := map[string]string{"url": link} + + // Requête brute + if err := c.doJSON(ctx, "POST", "downloader/add", nil, body, &envelope); err != nil { return nil, err } - return &result, nil + + var links []Link + + switch envelope.Value[0] { + case '{': + var single Link + if err := json.Unmarshal(envelope.Value, &single); err != nil { + return nil, err + } + links = append(links, single) + case '[': + if err := json.Unmarshal(envelope.Value, &links); err != nil { + return nil, err + } + default: + return nil, errors.New("format de réponse inattendu") + } + + return links, nil } + func (c *Client) RemoveLinks(ctx context.Context, ids []string) error { body := map[string][]string{"ids": ids} return c.doJSON(ctx, "DELETE", "downloader/links", nil, body, nil) @@ -467,6 +514,7 @@ type File struct { UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` } + func (c *Client) ListFiles(ctx context.Context, parentID string) ([]File, error) { var files []File path := fmt.Sprintf("files/%s", parentID) @@ -486,11 +534,39 @@ func (c *Client) CreateTranscode(ctx context.Context, fileID, preset string) (st return resp.TranscodeID, nil } -func (c *Client) GetTranscode(ctx context.Context, transcodeID string) (map[string]interface{}, error) { - var result map[string]interface{} +func (c *Client) GetTranscode(ctx context.Context, transcodeID string) (*StreamInfo, error) { + var raw struct { + Success bool `json:"success"` + Value struct { + ID string `json:"id"` + StreamURL string `json:"streamUrl"` + DownloadURL string `json:"downloadUrl"` + Type string `json:"type"` + MimeType string `json:"mimetype"` + Domain string `json:"domain"` + File struct { + ID string `json:"id"` + Name string `json:"name"` + Size int64 `json:"size"` + } `json:"file"` + } `json:"value"` + } + path := fmt.Sprintf("stream/transcode/%s", transcodeID) - if err := c.doJSON(ctx, "GET", path, nil, nil, &result); err != nil { + if err := c.doJSON(ctx, "GET", path, nil, nil, &raw); err != nil { return nil, err } - return result, nil + + info := &StreamInfo{ + ID: raw.Value.ID, + StreamURL: raw.Value.StreamURL, + DownloadURL: raw.Value.DownloadURL, + Type: raw.Value.Type, + MimeType: raw.Value.MimeType, + Domain: raw.Value.Domain, + FileID: raw.Value.File.ID, + FileName: raw.Value.File.Name, + FileSize: raw.Value.File.Size, + } + return info, nil } diff --git a/internal/download/download.go b/internal/download/download.go index a05fe65..e8a7848 100644 --- a/internal/download/download.go +++ b/internal/download/download.go @@ -1,8 +1,10 @@ package download import ( + "app/shelfly/internal/debridlink" "app/shelfly/internal/models" "app/shelfly/query" + "context" "encoding/json" "errors" "fmt" @@ -15,6 +17,23 @@ import ( "github.com/gorilla/mux" "gorm.io/gorm" ) +func GetFirstActiveAccount(client *debridlink.Client) *debridlink.DebridAccount { + ctx := context.Background() // ✅ on remplace ici + + accounts, err := client.ListDebridAccounts(ctx) + if err != nil { + log.Println("[ERROR] Impossible de récupérer les comptes :", err) + return nil + } + + for _, acc := range accounts { + if acc.IsActive { + return &acc + } + } + + return nil +} func CreateSavePath(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/download/jobs.go b/internal/download/jobs.go index 6b6138a..5f0f8ab 100644 --- a/internal/download/jobs.go +++ b/internal/download/jobs.go @@ -1,232 +1,194 @@ -// internal/download/jobs.go package download import ( "app/shelfly/internal/debridlink" - "context" - "encoding/json" + "io" + "log" "net/http" - "strconv" + "os" + "path/filepath" + "regexp" "sync" "time" - "github.com/gorilla/mux" "gorm.io/gorm" ) type DownloadJob struct { - ID uint `gorm:"primaryKey"` - RemoteID string `gorm:"index"` - FileName string `gorm:"size:255"` - Status string `gorm:"size:50"` - Speed string `gorm:"size:50"` - StreamURL string `gorm:"-" json:"stream_url"` - ErrorMsg string `gorm:"-" json:"error_msg"` + ID string `gorm:"primaryKey;column:id"` + Link string `gorm:"column:link"` + Name string `gorm:"column:name"` + Status string `gorm:"column:status"` // waiting, running, done, failed, paused + PathID uint `gorm:"column:path_id"` + Size int64 `gorm:"column:size"` + Host string `gorm:"column:host"` + Progress int `gorm:"column:progress"` // 0–100 + StreamURL string `gorm:"column:stream_url"` // <- nouveau champ CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` - Link string `gorm:"size:512"` - } + var ( jobs = make(map[string]*DownloadJob) jobsMu sync.Mutex ) -func AddJob(link string, pathID uint, db *gorm.DB) *DownloadJob { - id := time.Now().UnixNano() - job := &DownloadJob{ - ID: uint(id), - Link: link, // ← important ! - RemoteID: "", - FileName: extractFileName(link), - Status: "added", - Speed: "", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } + +// Enregistre un job en mémoire et en base +func RegisterJobWithDB(job *DownloadJob, db *gorm.DB) error { jobsMu.Lock() - jobs[strconv.FormatUint(uint64(id), 10)] = job + jobs[job.ID] = job jobsMu.Unlock() - db.Create(job) - return job + log.Printf("[JOB] Enregistré : %s (%s)\n", job.Name, job.ID) + return db.Create(job).Error } - - -func startDownload(job *DownloadJob, link string, client *debridlink.Client, db *gorm.DB) { - ctx := context.Background() - job.Status = "downloading" - linkResp, err := client.AddLink(ctx, link) - if err != nil { - job.Status = "error" - job.ErrorMsg = err.Error() - db.Save(job) - return +// Charge tous les jobs depuis la base en mémoire (au démarrage) +func InitJobsFromDB(db *gorm.DB) error { + var jobList []DownloadJob + if err := db.Find(&jobList).Error; err != nil { + return err } - job.RemoteID = linkResp.ID - db.Save(job) - go syncDownloadStatus(job, client, db) + + jobsMu.Lock() + defer jobsMu.Unlock() + + for _, j := range jobList { + jobCopy := j + jobs[j.ID] = &jobCopy + } + log.Printf("[JOB] %d jobs rechargés depuis la base\n", len(jobs)) + return nil } -func syncDownloadStatus(job *DownloadJob, client *debridlink.Client, db *gorm.DB) { - ctx := context.Background() - var checkCount int - for { - if job.Status != "downloading" { - return +// Met à jour le status d’un job et le persiste +func UpdateJobStatus(id string, status string, db *gorm.DB) { + jobsMu.Lock() + defer jobsMu.Unlock() + + if job, ok := jobs[id]; ok { + job.Status = status + job.UpdatedAt = time.Now() + if db != nil { + _ = db.Save(job) } - links, err := client.ListLinks(ctx) - if err != nil { - job.Status = "error" - job.ErrorMsg = err.Error() - db.Save(job) - return - } - for _, l := range links { - if l.ID == job.RemoteID { - job.Status = l.Status - checkCount++ - if checkCount%2 == 0 { - job.Speed = "~2 MB/s" - } else { - job.Speed = "~1.4 MB/s" - } - if l.Status == "downloaded" { - files, err := client.ListFiles(ctx, l.ID) - if err == nil && len(files) > 0 { - transcodeID, err := client.CreateTranscode(ctx, files[0].ID, "original") - if err == nil { - job.StreamURL = "https://debrid-link.com/stream/" + transcodeID - } - } - } - db.Save(job) - if l.Status == "downloaded" || l.Status == "error" { - _ = client.RemoveLinks(ctx, []string{l.ID}) - return - } - } - } - time.Sleep(2 * time.Second) } } +// Met à jour la progression d’un job et le persiste +func UpdateJobProgress(id string, progress int, db *gorm.DB) { + jobsMu.Lock() + defer jobsMu.Unlock() + + if job, ok := jobs[id]; ok { + job.Progress = progress + job.UpdatedAt = time.Now() + if db != nil { + _ = db.Save(job) + } + } +} + +// Supprime un job (mémoire uniquement) +func DeleteJob(id string) { + jobsMu.Lock() + defer jobsMu.Unlock() + + delete(jobs, id) +} + +// Liste tous les jobs func ListJobs() []*DownloadJob { jobsMu.Lock() defer jobsMu.Unlock() - var list []*DownloadJob + + list := make([]*DownloadJob, 0, len(jobs)) for _, job := range jobs { list = append(list, job) } return list } +const downloadDir = "./downloads" -func PauseJob(id string) { - jobsMu.Lock() - defer jobsMu.Unlock() - if job, ok := jobs[id]; ok && job.Status == "downloading" { - job.Status = "paused" - } -} +func StartDownload(job *DownloadJob, downloadURL string, client *debridlink.Client, db *gorm.DB) { + UpdateJobStatus(job.ID, "running", db) -func ResumeJob(id string, client *debridlink.Client, db *gorm.DB) { - jobsMu.Lock() - defer jobsMu.Unlock() - if job, ok := jobs[id]; ok && job.Status == "paused" { - job.Status = "downloading" - db.Save(job) - go syncDownloadStatus(job, client, db) - } -} - -func DeleteJob(id string) { - jobsMu.Lock() - defer jobsMu.Unlock() - delete(jobs, id) -} - -func generateID() string { - return time.Now().Format("20060102150405.000") -} - -func extractFileName(link string) string { - return "file_from_" + link -} - - - - -func HandleListJobs(w http.ResponseWriter, r *http.Request) { - jobs := ListJobs() - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(jobs) -} - -func HandlePauseJob(w http.ResponseWriter, r *http.Request) { - id := mux.Vars(r)["id"] - PauseJob(id) - w.WriteHeader(http.StatusNoContent) -} - -func HandleResumeJob(db *gorm.DB) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - id := mux.Vars(r)["id"] - client := debridlink.NewClient(db) - account := getFirstActiveAccount(client) - if account == nil { - http.Error(w, "Aucun compte actif", http.StatusBadRequest) - return - } - client.SetAccount(account) - ResumeJob(id, client, db) - w.WriteHeader(http.StatusNoContent) - } -} - -func HandleDeleteJob(w http.ResponseWriter, r *http.Request) { - id := mux.Vars(r)["id"] - DeleteJob(id) - w.WriteHeader(http.StatusNoContent) -} - -func getFirstActiveAccount(client *debridlink.Client) *debridlink.DebridAccount { - ctx := context.Background() - accounts, err := client.ListDebridAccounts(ctx) + resp, err := http.Get(downloadURL) if err != nil { - return nil + log.Printf("[ERROR] Téléchargement échoué : %v\n", err) + UpdateJobStatus(job.ID, "failed", db) + return } - for _, acc := range accounts { - if acc.IsActive { - return &acc + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + log.Printf("[ERROR] Erreur HTTP : %s\n", resp.Status) + UpdateJobStatus(job.ID, "failed", db) + return + } + + // Créer le fichier de destination + if err := os.MkdirAll(downloadDir, os.ModePerm); err != nil { + log.Printf("[ERROR] Création du dossier %s échouée : %v\n", downloadDir, err) + UpdateJobStatus(job.ID, "failed", db) + return + } + destPath := filepath.Join(downloadDir, sanitizeFileName(job.Name)) + outFile, err := os.Create(destPath) + if err != nil { + log.Printf("[ERROR] Impossible de créer le fichier : %v\n", err) + UpdateJobStatus(job.ID, "failed", db) + return + } + defer outFile.Close() + + // Taille totale + totalSize := resp.ContentLength + if totalSize <= 0 && job.Size > 0 { + totalSize = job.Size + } + + // Téléchargement avec suivi de progression + buf := make([]byte, 32*1024) // 32KB + var downloaded int64 + lastUpdate := time.Now() + + for { + n, err := resp.Body.Read(buf) + if n > 0 { + if _, writeErr := outFile.Write(buf[:n]); writeErr != nil { + log.Printf("[ERROR] Écriture échouée : %v\n", writeErr) + UpdateJobStatus(job.ID, "failed", db) + return + } + downloaded += int64(n) } - } - return nil -} -func HandleStartJob(db *gorm.DB) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - id := mux.Vars(r)["id"] - - jobsMu.Lock() - job, exists := jobs[id] - jobsMu.Unlock() - - if !exists { - http.Error(w, "Job not found", http.StatusNotFound) + if err != nil { + if err == io.EOF { + break + } + log.Printf("[ERROR] Erreur de lecture : %v\n", err) + UpdateJobStatus(job.ID, "failed", db) return } - client := debridlink.NewClient(db) - account := getFirstActiveAccount(client) - if account == nil { - http.Error(w, "Aucun compte actif", http.StatusBadRequest) - return + // Mise à jour de la progression toutes les 500ms + if time.Since(lastUpdate) > 500*time.Millisecond && totalSize > 0 { + progress := int((downloaded * 100) / totalSize) + UpdateJobProgress(job.ID, progress, db) + lastUpdate = time.Now() } - client.SetAccount(account) - - go startDownload(job, job.Link, client, db) - - w.WriteHeader(http.StatusNoContent) } -} + + // 100% si on arrive ici + UpdateJobProgress(job.ID, 100, db) + UpdateJobStatus(job.ID, "done", db) + log.Printf("[OK] Fichier téléchargé : %s\n", destPath) +} +func sanitizeFileName(name string) string { + re := regexp.MustCompile(`[^\w\-.]`) + return re.ReplaceAllString(name, "_") +} +//***// diff --git a/internal/route/main.go b/internal/route/main.go index 04a3f6e..4c10996 100644 --- a/internal/route/main.go +++ b/internal/route/main.go @@ -56,13 +56,12 @@ func RoutesProtected(r *mux.Router, bd *gorm.DB) { r.HandleFunc("/godownloader/settings/delete", renders.GoDownloadSettingDelete(bd)) r.HandleFunc("/api/download/add", renders.HandleAddJob(bd)).Methods("POST") r.HandleFunc("/api/download/all", renders.HandleListJobsPartial(bd)).Methods("GET") - r.HandleFunc("/api/download/pause/{id}", download.HandlePauseJob).Methods("POST") - r.HandleFunc("/api/download/resume/{id}", download.HandleResumeJob(bd)).Methods("POST") - r.HandleFunc("/api/download/delete/{id}", download.HandleDeleteJob).Methods("DELETE") - r.HandleFunc("/stream/{id}", download.HandleStreamPage()).Methods("GET") r.HandleFunc("/downloads", renders.GoDownload2(bd)) - r.HandleFunc("/api/download/start/{id}", download.HandleStartJob(bd)).Methods("POST") - + r.HandleFunc("/stream/{id}", download.HandleStreamPage()).Methods("GET") +r.HandleFunc("/api/download/start/{id}", renders.HandleStartJob(bd)).Methods("POST") +r.HandleFunc("/api/download/pause/{id}", renders.HandlePauseJob).Methods("POST") +r.HandleFunc("/api/download/resume/{id}", renders.HandleResumeJob(bd)).Methods("POST") +r.HandleFunc("/api/download/delete/{id}", renders.HandleDeleteJob).Methods("DELETE") // API user diff --git a/renders/renders.go b/renders/renders.go index 84278c8..96df969 100644 --- a/renders/renders.go +++ b/renders/renders.go @@ -9,9 +9,11 @@ import ( "log" "net/http" "strconv" + "sync" "text/template" "time" + "github.com/gorilla/mux" "gorm.io/gorm" ) @@ -258,7 +260,11 @@ func GoDownload2(db *gorm.DB) http.HandlerFunc { func HandleAddJob(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - r.ParseForm() + if err := r.ParseForm(); err != nil { + http.Error(w, "Requête invalide", http.StatusBadRequest) + return + } + link := r.FormValue("link") pathIDStr := r.FormValue("path_id") @@ -268,17 +274,51 @@ func HandleAddJob(db *gorm.DB) http.HandlerFunc { return } - _ = download.AddJob(link, uint(parsedID), db) + log.Println("[HTTP] Lien reçu :", link) + log.Println("[HTTP] ID de chemin :", parsedID) - // Mise à jour de la vue partielle du tableau - jobs := download.ListJobs() - data := map[string]interface{}{ - "jobs": jobs, + // Authentification Debrid-Link + client := debridlink.NewClient(db) + account := download.GetFirstActiveAccount(client) + if account == nil { + http.Error(w, "Aucun compte Debrid-Link actif", http.StatusBadRequest) + return + } + client.SetAccount(account) + + // Débride le lien + ctx := r.Context() + links, err := client.AddLink(ctx, link) + if err != nil { + log.Printf("[ERROR] Echec lors de l'ajout du lien : %v\n", err) + http.Error(w, "Erreur côté Debrid-Link", http.StatusInternalServerError) + return } + // Enregistre chaque lien comme un job "en attente" + for _, l := range links { + job := &download.DownloadJob{ + ID: l.ID, + Link: l.DownloadURL, + Name: l.Name, + Status: "waiting", + PathID: uint(parsedID), + Size: l.Size, + Host: l.Host, + CreatedAt: time.Now(), + } + download.RegisterJobWithDB(job,db) // => stocke en mémoire ou DB selon ton implémentation + } + + // Met à jour la vue partielle (tableau des jobs) + data := map[string]interface{}{ + "jobs": download.ListJobs(), + } renderPartial(w, "downloads_table", data) } } + + func HandleListJobsPartial(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { jobs := download.ListJobs() @@ -288,6 +328,72 @@ func HandleListJobsPartial(db *gorm.DB) http.HandlerFunc { renderPartial(w, "downloads_table", data) } } +var ( + jobs = make(map[string]*download.DownloadJob) + jobsMu sync.Mutex +) +func HandleStartJob(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := mux.Vars(r)["id"] + + jobsMu.Lock() + job, exists := jobs[id] + jobsMu.Unlock() + + if !exists { + http.Error(w, "Job introuvable", http.StatusNotFound) + return + } + + client := debridlink.NewClient(db) + account := download.GetFirstActiveAccount(client) + if account == nil { + http.Error(w, "Aucun compte actif", http.StatusBadRequest) + return + } + client.SetAccount(account) + + go download.StartDownload(job, job.Link, client, db) + + w.WriteHeader(http.StatusNoContent) + } +} +func HandlePauseJob(w http.ResponseWriter, r *http.Request) { + id := mux.Vars(r)["id"] + download.UpdateJobStatus(id, "paused", nil) + w.WriteHeader(http.StatusNoContent) +} +func HandleResumeJob(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := mux.Vars(r)["id"] + + jobsMu.Lock() + job, exists := jobs[id] + jobsMu.Unlock() + + if !exists { + http.Error(w, "Job introuvable", http.StatusNotFound) + return + } + + client := debridlink.NewClient(db) + account := download.GetFirstActiveAccount(client) + if account == nil { + http.Error(w, "Aucun compte actif", http.StatusBadRequest) + return + } + client.SetAccount(account) + + go download.StartDownload(job, job.Link, client, db) + w.WriteHeader(http.StatusNoContent) + } +} +func HandleDeleteJob(w http.ResponseWriter, r *http.Request) { + id := mux.Vars(r)["id"] + download.DeleteJob(id) + w.WriteHeader(http.StatusNoContent) +} + // func GoDownloadSetting(db *gorm.DB) http.HandlerFunc { // return func(w http.ResponseWriter, r *http.Request) { diff --git a/shelfly_db.db b/shelfly_db.db index 73b0e53..5ebc959 100644 Binary files a/shelfly_db.db and b/shelfly_db.db differ diff --git a/tmp/main b/tmp/main index 3b63b0d..8f42ad6 100755 Binary files a/tmp/main and b/tmp/main differ