This commit is contained in:
cangui 2025-06-12 10:57:10 +02:00
parent c814fec4e0
commit d2d9abfbce
19 changed files with 455 additions and 13 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -1,4 +1,5 @@
version: '3.8'
services:
shelfly:
build:
@ -9,5 +10,6 @@ services:
volumes:
- .:/app
- ./shelfly_db.db:/app/shelfly_db.db
env_file:
- .env
dns:
- 8.8.8.8
- 1.1.1.1

BIN
internal/.DS_Store vendored

Binary file not shown.

View File

@ -2,6 +2,7 @@ package db
import (
"app/shelfly/internal/debridlink"
"app/shelfly/internal/download"
"app/shelfly/internal/models"
"fmt"
@ -36,6 +37,7 @@ func InitDB()*gorm.DB {
&debridlink.RSSItem{},
&debridlink.Torrent{},
&debridlink.DebridAccount{},
&download.DownloadJob{},
)

View File

@ -54,6 +54,7 @@ func NewClient(db *gorm.DB) *Client {
}
}
func (c *Client) SetAccount(account *DebridAccount) {
c.account = account
}
@ -440,16 +441,16 @@ 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{"link": link}
if err := c.doJSON(ctx, "POST", "downloader/links", nil, body, &result); err != nil {
body := map[string]string{"url": link} // ✅ CORRECTION
if err := c.doJSON(ctx, "POST", "downloader/add", nil, body, &result); err != nil { // ✅ CORRECTION
return nil, err
}
return &result, 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)

View File

@ -6,10 +6,12 @@ import (
"encoding/json"
"errors"
"fmt"
"html/template"
"log"
"net/http"
"os"
"strings"
"github.com/gorilla/mux"
"gorm.io/gorm"
)
@ -282,3 +284,30 @@ func PathValidationHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
type StreamPageData struct {
StreamURL string
}
func HandleStreamPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
job := jobs[id]
if job == nil || job.StreamURL == "" {
http.Error(w, "Stream non disponible", http.StatusNotFound)
return
}
tmpl := `<html>
<head><title>Streaming</title></head>
<body style="margin:0;background:#000;">
<video controls autoplay style="width:100vw;height:100vh;">
<source src="{{.StreamURL}}" type="video/mp4">
Votre navigateur ne supporte pas la vidéo HTML5.
</video>
</body>
</html>`
t := template.Must(template.New("stream").Parse(tmpl))
t.Execute(w, StreamPageData{StreamURL: job.StreamURL})
}
}

232
internal/download/jobs.go Normal file
View File

@ -0,0 +1,232 @@
// internal/download/jobs.go
package download
import (
"app/shelfly/internal/debridlink"
"context"
"encoding/json"
"net/http"
"strconv"
"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"`
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(),
}
jobsMu.Lock()
jobs[strconv.FormatUint(uint64(id), 10)] = job
jobsMu.Unlock()
db.Create(job)
return job
}
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
}
job.RemoteID = linkResp.ID
db.Save(job)
go syncDownloadStatus(job, client, db)
}
func syncDownloadStatus(job *DownloadJob, client *debridlink.Client, db *gorm.DB) {
ctx := context.Background()
var checkCount int
for {
if job.Status != "downloading" {
return
}
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)
}
}
func ListJobs() []*DownloadJob {
jobsMu.Lock()
defer jobsMu.Unlock()
var list []*DownloadJob
for _, job := range jobs {
list = append(list, job)
}
return list
}
func PauseJob(id string) {
jobsMu.Lock()
defer jobsMu.Unlock()
if job, ok := jobs[id]; ok && job.Status == "downloading" {
job.Status = "paused"
}
}
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)
if err != nil {
return nil
}
for _, acc := range accounts {
if acc.IsActive {
return &acc
}
}
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)
return
}
client := debridlink.NewClient(db)
account := getFirstActiveAccount(client)
if account == nil {
http.Error(w, "Aucun compte actif", http.StatusBadRequest)
return
}
client.SetAccount(account)
go startDownload(job, job.Link, client, db)
w.WriteHeader(http.StatusNoContent)
}
}

View File

@ -2,6 +2,7 @@ package models
type User struct {
ID uint `json:"id" gorm:"primaryKey"`
Username string `json:"username" gorm:"size:255"`

View File

@ -54,6 +54,15 @@ func RoutesProtected(r *mux.Router, bd *gorm.DB) {
r.HandleFunc("/godownloader/poll-status", renders.PollStatusHandler(bd))
r.HandleFunc("/godownloader/table-refresh", renders.GoDownloadPartialTable(bd))
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")
// API user

View File

@ -9,14 +9,10 @@ import (
"net/http"
"github.com/gorilla/mux"
"github.com/joho/godotenv"
)
func main() {
err := godotenv.Load()
if err != nil {
log.Fatal("Erreur de chargement du fichier .env")
}
// 1. Démarrer le routeur principal
r := mux.NewRouter()

View File

@ -2,6 +2,7 @@ package renders
import (
"app/shelfly/internal/debridlink"
"app/shelfly/internal/download"
"app/shelfly/internal/models"
"context"
"encoding/json"
@ -240,6 +241,53 @@ func PollStatusHandler(db *gorm.DB) http.HandlerFunc {
})
}
}
func GoDownload2(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
jobs := download.ListJobs()
var paths []models.PathDownload
db.Find(&paths)
data := map[string]interface{}{
"jobs": jobs,
"paths": paths,
}
renderTemplate(w, "godownloader_download", data)
}
}
func HandleAddJob(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
link := r.FormValue("link")
pathIDStr := r.FormValue("path_id")
parsedID, err := strconv.Atoi(pathIDStr)
if err != nil {
http.Error(w, "Chemin invalide", http.StatusBadRequest)
return
}
_ = download.AddJob(link, uint(parsedID), db)
// Mise à jour de la vue partielle du tableau
jobs := download.ListJobs()
data := map[string]interface{}{
"jobs": jobs,
}
renderPartial(w, "downloads_table", data)
}
}
func HandleListJobsPartial(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
jobs := download.ListJobs()
data := map[string]interface{}{
"jobs": jobs,
}
renderPartial(w, "downloads_table", data)
}
}
// func GoDownloadSetting(db *gorm.DB) http.HandlerFunc {
// return func(w http.ResponseWriter, r *http.Request) {

Binary file not shown.

BIN
templates/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -11,9 +11,15 @@
<div class="column is-2">
<aside class="menu">
<p class="menu-label">GoDownloader</p>
<ul class="menu-list">
<li><a class="nav-link" onclick="toggleMenuGoDownload(); return false;">GoDownloader</a>
<ul id="menuDownload" hidden>
<li><a hx-get="/downloads" class="nav-link" hx-target="#content" hx-swap="innerHTML">Téléchargements</a></li>
</ul>
</li>
<p class="menu-label">Library</p>
<li><a hx-get="/library" class="nav-link"

View File

@ -0,0 +1,42 @@
<div id="downloads-table" hx-get="/api/download/all" hx-trigger="every 2s" hx-swap="outerHTML">
<table class="table is-fullwidth is-striped">
<thead>
<tr>
<th>Fichier</th>
<th>Statut</th>
<th>Vitesse</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{ range .jobs }}
<tr class="border-b {{ if eq .Status "error" }}bg-red-100 text-red-800{{ end }}">
<td class="px-2 py-1 text-sm">{{ .FileName }}</td>
<td class="px-2 py-1 text-sm">{{ .Status }}</td>
<td class="px-2 py-1 text-sm">{{ .Speed }}</td>
<td class="px-2 py-1 text-sm">
{{ if eq .Status "added" }}
<button hx-post="/api/download/start/{{ .ID }}" class="text-indigo-600">⬇ Télécharger</button>
{{ end }}
{{ if eq .Status "paused" }}
<button hx-post="/api/download/resume/{{ .ID }}" class="text-blue-600">▶</button>
{{ else if eq .Status "downloading" }}
<button hx-post="/api/download/pause/{{ .ID }}" class="text-yellow-600">⏸</button>
{{ end }}
{{ if and (eq .Status "downloaded") .StreamURL }}
<a href="/stream/{{ .ID }}" target="_blank" class="text-green-600 ml-2">🎬 Stream</a>
<button onclick="navigator.clipboard.writeText('{{ .StreamURL }}')" class="text-gray-600 ml-1">📋</button>
<a href="{{ .DownloadLink }}" class="text-blue-600 ml-1" download>⬇</a>
{{ end }}
<button hx-delete="/api/download/delete/{{ .ID }}" class="text-red-600 ml-2">✖</button>
</td>
</tr>
{{ if eq .Status "error" }}
<tr class="bg-red-50 text-sm text-red-600">
<td colspan="4" class="px-2 py-1">Erreur : {{ .ErrorMsg }}</td>
</tr>
{{ end }}
{{ end }}
</tbody>
</table>
</div>

View File

@ -1 +1,75 @@
<h1>Download</h1>
<h1>Download</h1>
<div class="box">
<form hx-post="/api/download/add"
hx-trigger="submit"
hx-swap="none"
hx-on="htmx:afterRequest: this.reset()"
class="mb-4">
<div class="field">
<label class="label">Lien à débrider</label>
<div class="control">
<input class="input" type="text" name="link" placeholder="https://..." required>
</div>
</div>
<div class="field">
<label class="label">Chemin d'enregistrement</label>
<div class="control">
<div class="select">
<select name="path_id">
{{range .paths}}<option value="{{.ID}}">{{.PathName}}</option>{{end}}
</select>
</div>
</div>
</div>
<div class="field is-grouped">
<div class="control">
<button class="button is-primary" type="submit">Ajouter</button>
</div>
</div>
</form>
<div id="downloads-table" hx-get="/api/download/all" hx-trigger="every 2s" hx-swap="outerHTML">
<table class="table is-fullwidth is-striped">
<thead>
<tr>
<th>Fichier</th>
<th>Statut</th>
<th>Vitesse</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{ range .jobs }}
<tr class="border-b {{ if eq .Status "error" }}bg-red-100 text-red-800{{ end }}">
<td class="px-2 py-1 text-sm">{{ .FileName }}</td>
<td class="px-2 py-1 text-sm">{{ .Status }}</td>
<td class="px-2 py-1 text-sm">{{ .Speed }}</td>
<td class="px-2 py-1 text-sm">
{{ if eq .Status "added" }}
<button hx-post="/api/download/start/{{ .ID }}" class="text-indigo-600">⬇ Télécharger</button>
{{ end }}
{{ if eq .Status "paused" }}
<button hx-post="/api/download/resume/{{ .ID }}" class="text-blue-600">▶</button>
{{ else if eq .Status "downloading" }}
<button hx-post="/api/download/pause/{{ .ID }}" class="text-yellow-600">⏸</button>
{{ end }}
{{ if and (eq .Status "downloaded") .StreamURL }}
<a href="/stream/{{ .ID }}" target="_blank" class="text-green-600 ml-2">🎬 Stream</a>
<button onclick="navigator.clipboard.writeText('{{ .StreamURL }}')" class="text-gray-600 ml-1">📋</button>
<a href="{{ .DownloadLink }}" class="text-blue-600 ml-1" download>⬇</a>
{{ end }}
<button hx-delete="/api/download/delete/{{ .ID }}" class="text-red-600 ml-2">✖</button>
</td>
</tr>
{{ if eq .Status "error" }}
<tr class="bg-red-50 text-sm text-red-600">
<td colspan="4" class="px-2 py-1">Erreur : {{ .ErrorMsg }}</td>
</tr>
{{ end }}
{{ end }}
</tbody>
</table>
</div>
</div>

BIN
templates/open ./.DS_Store vendored Normal file

Binary file not shown.

BIN
tmp/main

Binary file not shown.

View File

@ -1 +1 @@
exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1
exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1