debridlink

This commit is contained in:
cangui 2025-06-09 16:13:32 +02:00
parent 4dd0484b80
commit 188058f957
18 changed files with 925 additions and 40 deletions

2
.env Normal file
View File

@ -0,0 +1,2 @@
DEBRIDLINK_CLIENT_ID=bMs32shaby43qVGVKnkRqw
DEBRIDLINK_CLIENT_SECRET=XRMJ8JNJtJZBsRP8gzBnYc6huLK2cqXay3ihKId9mt4

View File

@ -4,3 +4,5 @@ services:
context: .
dockerfile: Dockerfile
volumes: [] # pas de montage source
env_file:
- .env

View File

@ -8,4 +8,6 @@ services:
- "4000:4000"
volumes:
- .:/app
- ./shelfly_db.db:/app/shelfly_db.db
- ./shelfly_db.db:/app/shelfly_db.db
env_file:
- .env

1
go.mod
View File

@ -25,6 +25,7 @@ require (
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.31.0
golang.org/x/text v0.21.0 // indirect
gorm.io/driver/mysql v1.5.7 // indirect

2
go.sum
View File

@ -29,6 +29,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=

View File

@ -0,0 +1,8 @@
package config
import "os"
var (
DebridClientID = os.Getenv("DEBRIDLINK_CLIENT_ID")
DebridClientSecret = os.Getenv("DEBRIDLINK_CLIENT_SECRET")
)

View File

@ -1,8 +1,10 @@
package db
import (
"fmt"
"app/shelfly/internal/debridlink"
"app/shelfly/internal/models"
"fmt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
@ -17,7 +19,26 @@ func InitDB()*gorm.DB {
}
// Migrate the schema
db.AutoMigrate(&models.User{},&models.Files{},&models.LibrarySection{},&models.MediaItem{},&models.MediaPart{},&models.MetadataItem{},&models.SectionLocation{},&models.Tag{},&models.Tagging{},&models.PathDownload{})
db.AutoMigrate(
&models.User{},
&models.Files{},
&models.LibrarySection{},
&models.MediaItem{},
&models.MediaPart{},
&models.MetadataItem{},
&models.SectionLocation{},
&models.Tag{},
&models.Tagging{},
&models.PathDownload{},
&debridlink.File{},
&debridlink.Link{},
&debridlink.RSSFeed{},
&debridlink.RSSItem{},
&debridlink.Torrent{},
&debridlink.DebridAccount{},
)
fmt.Println("Connexion réussie à MySQL !")
fmt.Println("Auto migration completed")

View File

@ -0,0 +1,495 @@
package debridlink
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
"gorm.io/gorm"
)
const baseURL = "https://api.debrid-link.com/"
type Client struct {
http *http.Client
db *gorm.DB
clientID string
clientSecret string
account *DebridAccount
}
type DebridAccount struct {
ID uint `gorm:"primaryKey"`
Host string `gorm:"column:host" json:"host"`
Username string `gorm:"column:username" json:"username"`
Password string `gorm:"column:password" json:"password"`
IsActive bool `gorm:"column:is_active" json:"is_active"`
AccessToken string `gorm:"column:access_token"`
RefreshToken string `gorm:"column:refresh_token"`
ExpiresAt time.Time `gorm:"column:expires_at"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
type TokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
}
func NewClient(db *gorm.DB) *Client {
return &Client{
http: &http.Client{Timeout: 15 * time.Second},
db: db,
clientID: "bMs32shaby43qVGVKnkRqw",
clientSecret: "XRMJ8JNJtJZBsRP8gzBnYc6huLK2cqXay3ihKId9mt4",
}
}
func (c *Client) SetAccount(account *DebridAccount) {
c.account = account
}
func (c *Client) refreshAccessToken(ctx context.Context) error {
form := url.Values{}
form.Set("client_id", c.clientID)
form.Set("client_secret", c.clientSecret)
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", c.account.RefreshToken)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"oauth/token", strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("refresh failed: %s", string(b))
}
var tokenResp TokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return err
}
c.account.AccessToken = tokenResp.AccessToken
c.account.RefreshToken = tokenResp.RefreshToken
c.account.ExpiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
return c.db.Save(c.account).Error
}
func (c *Client) ToggleActiveStatus(ctx context.Context, id uint) error {
if c.db == nil {
return errors.New("la connexion à la base de données est manquante")
}
var account DebridAccount
if err := c.db.First(&account, id).Error; err != nil {
return fmt.Errorf("compte introuvable : %w", err)
}
account.IsActive = !account.IsActive
account.UpdatedAt = time.Now()
if err := c.db.Save(&account).Error; err != nil {
return fmt.Errorf("échec de la mise à jour : %w", err)
}
return nil
}
// ===========================
// CRUD pour DebridAccount
// ===========================
func (c *Client) CreateDebridAccount(ctx context.Context, acc *DebridAccount) error {
return c.db.Create(acc).Error
}
func (c *Client) GetDebridAccount(ctx context.Context, id uint) (*DebridAccount, error) {
var acc DebridAccount
if err := c.db.First(&acc, id).Error; err != nil {
return nil, err
}
return &acc, nil
}
func (c *Client) ListDebridAccounts(ctx context.Context) ([]DebridAccount, error) {
var accounts []DebridAccount
if err := c.db.Order("id desc").Find(&accounts).Error; err != nil {
return nil, err
}
return accounts, nil
}
func (c *Client) UpdateDebridAccount(ctx context.Context, acc *DebridAccount) error {
return c.db.Save(acc).Error
}
func (c *Client) DeleteDebridAccount(ctx context.Context, id uint) error {
return c.db.Delete(&DebridAccount{}, id).Error
}
func (c *Client) RequestDeviceCodeWithCredentials(ctx context.Context, username, password string) (*DeviceCodeResponse, error) {
form := url.Values{}
log.Println("[DEBUG] Envoi device_code avec :")
log.Println("client_id:", c.clientID)
log.Println("username:", username)
log.Println("password (len):", len(password))
log.Println("scope:", form.Get("scope"))
log.Println("URL:", "https://debrid-link.com/api/oauth/device/code")
form.Set("client_id", c.clientID)
form.Set("grant_type", "password") // possible variation
form.Set("username", username)
form.Set("password", password)
form.Set("scope", "get.post.downloader get.post.seedbox get.account")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://debrid-link.com/api/oauth/device/code", strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("device code request failed: %s", string(body))
}
var result DeviceCodeResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result, nil
}
func (c *Client) RequestDeviceCode(ctx context.Context) (*DeviceCodeResponse, error) {
form := url.Values{}
form.Set("client_id", c.clientID)
form.Set("scope", "get.post.downloader get.post.seedbox get.account")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://debrid-link.com/api/oauth/device/code", strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("device code request failed: %s", string(body))
}
var result DeviceCodeResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result, nil
}
type DeviceCodeResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURL string `json:"verification_url"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
func (c *Client) PasswordGrant(ctx context.Context, username, password string) (*TokenResponse, error) {
form := url.Values{}
form.Set("client_id", c.clientID)
form.Set("client_secret", c.clientSecret)
form.Set("grant_type", "password")
form.Set("username", username)
form.Set("password", password)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://debrid-link.com/api/oauth/token", strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("auth error: %s", string(body))
}
var tokens TokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil {
return nil, err
}
return &tokens, nil
}
func (c *Client) PollDeviceToken(ctx context.Context, deviceCode string, interval int) (*TokenResponse, error) {
form := url.Values{}
form.Set("client_id", c.clientID)
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
form.Set("code", deviceCode)
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(time.Duration(interval) * time.Second):
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://debrid-link.com/api/oauth/token", strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == 200 {
var tokens TokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil {
return nil, err
}
return &tokens, nil
}
body, _ := io.ReadAll(resp.Body)
if strings.Contains(string(body), "authorization_pending") {
continue // Attente de validation utilisateur
}
return nil, fmt.Errorf("device auth failed: %s", string(body))
}
}
}
func (c *Client) doJSON(ctx context.Context, method, path string, params url.Values, body, out interface{}) error {
if c.account == nil {
return errors.New("no active Debrid account")
}
if time.Now().After(c.account.ExpiresAt) {
if err := c.refreshAccessToken(ctx); err != nil {
return err
}
}
urlStr := baseURL + path
if params != nil {
urlStr += "?" + params.Encode()
}
var reqBody io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
reqBody = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, urlStr, reqBody)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.account.AccessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
data, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP error %d: %s", resp.StatusCode, string(data))
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
return nil
}
// =========================== RSS ===========================
type RSSFeed struct {
ID string `json:"id" gorm:"column:id;primaryKey"`
URL string `json:"url" gorm:"column:url"`
Enabled bool `json:"enabled" gorm:"column:enabled"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
type RSSItem struct {
Title string `json:"title" gorm:"column:title"`
Link string `json:"link" gorm:"column:link"`
PubDate time.Time `json:"pubDate" gorm:"column:pub_date"`
}
func (c *Client) ListRSS(ctx context.Context) ([]RSSFeed, error) {
var feeds []RSSFeed
if err := c.doJSON(ctx, "GET", "rss", nil, nil, &feeds); err != nil {
return nil, err
}
return feeds, nil
}
func (c *Client) AddRSS(ctx context.Context, url string) (*RSSFeed, error) {
var feed RSSFeed
body := map[string]string{"url": url}
if err := c.doJSON(ctx, "POST", "rss", nil, body, &feed); err != nil {
return nil, err
}
return &feed, nil
}
func (c *Client) TestRSS(ctx context.Context, url string) ([]RSSItem, error) {
var items []RSSItem
body := map[string]string{"url": url}
if err := c.doJSON(ctx, "POST", "rss/test", nil, body, &items); err != nil {
return nil, err
}
return items, nil
}
func (c *Client) DeleteRSS(ctx context.Context, id string) error {
return c.doJSON(ctx, "DELETE", fmt.Sprintf("rss/%s", id), nil, nil, nil)
}
// =========================== Seedbox ===========================
type Torrent struct {
ID string `json:"id" gorm:"column:id;primaryKey"`
Name string `json:"name" gorm:"column:name"`
Status string `json:"status" gorm:"column:status"`
Progress int `json:"progress" gorm:"column:progress"`
Added time.Time `json:"added" gorm:"column:added"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
func (c *Client) ListTorrents(ctx context.Context) ([]Torrent, error) {
var torrents []Torrent
if err := c.doJSON(ctx, "GET", "seedbox/torrents", nil, nil, &torrents); err != nil {
return nil, err
}
return torrents, nil
}
func (c *Client) AddTorrent(ctx context.Context, link string) (*Torrent, error) {
var t Torrent
body := map[string]string{"link": link}
if err := c.doJSON(ctx, "POST", "seedbox/torrents", nil, body, &t); err != nil {
return nil, err
}
return &t, nil
}
func (c *Client) RemoveTorrents(ctx context.Context, ids []string) error {
body := map[string][]string{"ids": ids}
return c.doJSON(ctx, "DELETE", "seedbox/torrents", nil, body, nil)
}
// =========================== 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"`
}
func (c *Client) ListLinks(ctx context.Context) ([]Link, error) {
var links []Link
if err := c.doJSON(ctx, "GET", "downloader/links", nil, nil, &links); err != nil {
return nil, err
}
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 {
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)
}
// =========================== Files ===========================
type File struct {
ID string `json:"id" gorm:"column:id;primaryKey"`
Name string `json:"name" gorm:"column:name"`
Size int64 `json:"size" gorm:"column:size"`
Link string `json:"link" gorm:"column:link"`
ParentID string `json:"parent_id" gorm:"column:parent_id"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
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)
if err := c.doJSON(ctx, "GET", path, nil, nil, &files); err != nil {
return nil, err
}
return files, nil
}
// =========================== Stream ===========================
func (c *Client) CreateTranscode(ctx context.Context, fileID, preset string) (string, error) {
var resp struct{ TranscodeID string `json:"transcodeId"` }
body := map[string]string{"fileId": fileID, "preset": preset}
if err := c.doJSON(ctx, "POST", "stream/transcode", nil, body, &resp); err != nil {
return "", err
}
return resp.TranscodeID, nil
}
func (c *Client) GetTranscode(ctx context.Context, transcodeID string) (map[string]interface{}, error) {
var result map[string]interface{}
path := fmt.Sprintf("stream/transcode/%s", transcodeID)
if err := c.doJSON(ctx, "GET", path, nil, nil, &result); err != nil {
return nil, err
}
return result, nil
}

View File

@ -50,7 +50,12 @@ func RoutesProtected(r *mux.Router, bd *gorm.DB) {
r.HandleFunc("/menuLibary", renders.Library)
r.HandleFunc("/godownloader/downloads", renders.GoDownload)
r.HandleFunc("/godownloader/linkcollectors", renders.GoDownloadLinkCollectors)
r.HandleFunc("/godownloader/settings", renders.GoDownloadSetting)
r.HandleFunc("/godownloader/settings", renders.GoDownloadSetting(bd))
r.HandleFunc("/godownloader/poll-status", renders.PollStatusHandler(bd))
r.HandleFunc("/godownloader/table-refresh", renders.GoDownloadPartialTable(bd))
r.HandleFunc("/godownloader/settings/delete", renders.GoDownloadSettingDelete(bd))
// API user
r.HandleFunc("/api/user/create", users.CreateUser(bd)).Methods("POST")
r.HandleFunc("/api/user/update/{id}", users.UpdateUser(bd)).Methods("PUT")

View File

@ -9,9 +9,14 @@ 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

@ -1,10 +1,15 @@
package renders
import (
"app/shelfly/internal/debridlink"
"app/shelfly/internal/models"
"context"
"encoding/json"
"log"
"net/http"
"strconv"
"text/template"
"time"
"gorm.io/gorm"
)
@ -85,10 +90,244 @@ func GoDownload(w http.ResponseWriter, r *http.Request) {
func GoDownloadLinkCollectors(w http.ResponseWriter, r *http.Request) {
renderPartial(w, "godownloader_linkcollectors",nil)
}
func GoDownloadSetting(w http.ResponseWriter, r *http.Request) {
renderPartial(w, "godownloader_setting",nil)
func GetDebridClient(db *gorm.DB) *debridlink.Client {
return debridlink.NewClient(db)
}
func GoDownloadSettingDelete(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
DebridClient := GetDebridClient(db)
idStr := r.URL.Query().Get("id")
if idStr == "" {
http.Error(w, "ID manquant", http.StatusBadRequest)
return
}
idUint, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
http.Error(w, "ID invalide", http.StatusBadRequest)
return
}
if err := DebridClient.DeleteDebridAccount(ctx, uint(idUint)); err != nil {
http.Error(w, "Erreur lors de la suppression", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/godownloader/settings", http.StatusSeeOther)
}
}
func GoDownloadSettingToggleActive(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
DebridClient := debridlink.NewClient(db)
idStr := r.URL.Query().Get("id")
idUint, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
http.Error(w, "ID invalide", http.StatusBadRequest)
return
}
err = DebridClient.ToggleActiveStatus(ctx, uint(idUint))
if err != nil {
log.Println("Erreur lors du toggle:", err)
http.Error(w, "Échec de mise à jour", http.StatusInternalServerError)
return
}
// Récupérer la liste mise à jour
accounts, err := DebridClient.ListDebridAccounts(ctx)
if err != nil {
http.Error(w, "Erreur lors du chargement des comptes", http.StatusInternalServerError)
return
}
// HTMX ou page normale
if r.Header.Get("HX-Request") == "true" {
renderPartial(w, "partials/accounts_table", map[string]interface{}{
"accounts": accounts,
})
} else {
renderPartial(w, "godownloader_setting", map[string]interface{}{
"accounts": accounts,
})
}
}
}
func GoDownloadSetting(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
client := debridlink.NewClient(db)
switch r.Method {
case http.MethodPost:
if err := r.ParseForm(); err != nil {
http.Error(w, "Form invalide", http.StatusBadRequest)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
deviceResp, err := client.RequestDeviceCodeWithCredentials(ctx, username, password)
if err != nil {
log.Println("[OAuth2] Erreur device_code:", err)
http.Error(w, "Erreur OAuth: "+err.Error(), http.StatusInternalServerError)
return
}
// Affiche le code + URL dans #auth-status
renderPartial(w, "oauth_device_code", map[string]any{
"code": deviceResp.UserCode,
"url": deviceResp.VerificationURL,
})
// Polling async
go func() {
tokens, err := client.PollDeviceToken(context.Background(), deviceResp.DeviceCode, deviceResp.Interval)
if err != nil {
log.Println("[OAuth2] Polling échoué:", err)
return
}
account := &debridlink.DebridAccount{
Host: "debrid-link.com",
Username: username,
Password: password,
IsActive: true,
AccessToken: tokens.AccessToken,
RefreshToken: tokens.RefreshToken,
ExpiresAt: time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second),
}
if err := db.Create(account).Error; err != nil {
log.Println("[DB] Sauvegarde échouée:", err)
return
}
log.Println("[OAuth2] Compte sauvegardé")
}()
case http.MethodGet:
accounts, _ := client.ListDebridAccounts(ctx)
renderPartial(w, "godownloader_setting", map[string]any{
"accounts": accounts,
})
}
}
}
func GoDownloadPartialTable(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
client := debridlink.NewClient(db)
accounts, _ := client.ListDebridAccounts(ctx)
renderPartial(w, "accounts_table", map[string]any{
"accounts": accounts,
})
}}
func PollStatusHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var count int64
db.Model(&debridlink.DebridAccount{}).Where("is_active = ?", true).Count(&count)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{
"success": count > 0,
})
}
}
// func GoDownloadSetting(db *gorm.DB) http.HandlerFunc {
// return func(w http.ResponseWriter, r *http.Request) {
// ctx := r.Context()
// // Initialise le client avec .env (client_id, secret)
// DebridClient := debridlink.NewClient(db)
// switch r.Method {
// case http.MethodPost:
// if err := r.ParseForm(); err != nil {
// http.Error(w, "Formulaire invalide", http.StatusBadRequest)
// return
// }
// host := r.FormValue("host")
// username := r.FormValue("username")
// password := r.FormValue("password")
// isActive := r.FormValue("is_active") == "on"
// // Authentification via Password Grant
// tokens, err := DebridClient.PasswordGrant(ctx, username, password)
// if err != nil {
// log.Println("[OAuth2] Erreur:", err)
// http.Error(w, "Authentification échouée", http.StatusUnauthorized)
// return
// }
// // Création du compte à enregistrer
// account := &debridlink.DebridAccount{
// Host: host,
// Username: username,
// Password: password,
// IsActive: isActive,
// AccessToken: tokens.AccessToken,
// RefreshToken: tokens.RefreshToken,
// ExpiresAt: time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second),
// }
// if err := db.Save(account).Error; err != nil {
// log.Println("[DB] Sauvegarde échouée:", err)
// http.Error(w, "Erreur DB", http.StatusInternalServerError)
// return
// }
// var accounts []debridlink.DebridAccount
// db.Order("id desc").Find(&accounts)
// if r.Header.Get("HX-Request") == "true" {
// renderPartial(w, "partials/accounts_table", map[string]interface{}{
// "accounts": accounts,
// })
// return
// }
// renderPartial(w, "godownloader_setting", map[string]interface{}{
// "accounts": accounts,
// })
// case http.MethodGet:
// var accounts []debridlink.DebridAccount
// db.Order("id desc").Find(&accounts)
// renderPartial(w, "godownloader_setting", map[string]interface{}{
// "accounts": accounts,
// })
// }
// }
// }
func renderPartial(w http.ResponseWriter, templ string, data map[string]interface{}) {
t, err := template.ParseFiles("./templates/" + templ + ".pages.tmpl")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = t.Execute(w, data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func renderTemplate(w http.ResponseWriter, templ string, data map[string]interface{}) {
t, err := template.ParseFiles(
@ -109,16 +348,4 @@ func renderTemplate(w http.ResponseWriter, templ string, data map[string]interfa
}
}
func renderPartial(w http.ResponseWriter, templ string, data map[string]interface{}) {
t, err := template.ParseFiles("./templates/" + templ + ".pages.tmpl")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = t.Execute(w, data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}

Binary file not shown.

View File

@ -0,0 +1,42 @@
<div id="accounts-table">
{{ if .accounts }}
<table class="table is-striped is-fullwidth">
<thead>
<tr>
<th>Host</th>
<th>Utilisateur</th>
<th>Actif</th>
<th>Token</th>
<th>Expire</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{ range .accounts }}
<tr>
<td>{{ .Host }}</td>
<td>{{ .Username }}</td>
<td>{{ if .IsActive }}✅{{ else }}❌{{ end }}</td>
<td><code>{{ .AccessToken | printf "%.12s..." }}</code></td>
<td>{{ .ExpiresAt.Format "02/01/2006 15:04" }}</td>
<td>
<div class="buttons">
<a class="button is-danger is-light"
hx-get="/godownloader/settings/delete?id={{ .ID }}"
>Supprimer</a>
<a class="button is-info is-light"
hx-get="/godownloader/settings/toggle?id={{ .ID }}"
hx-target="#accounts-table"
hx-swap="outerHTML">
{{ if .IsActive }}Désactiver{{ else }}Activer{{ end }}
</a>
</div>
</td>
</tr>
{{ end }}
</tbody>
</table>
{{ else }}
<p>Aucun compte enregistré.</p>
{{ end }}
</div>

View File

@ -11,16 +11,9 @@
<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="/godownloader/downloads" hx-target="#content" hx-swap-oob="beforeend">Downloads</a></li>
<li><a hx-get="/godownloader/linkcollectors" hx-target="#content" hx-swap-oob="beforeend">Linkcollectors</a></li>
</ul>
</li>
<p class="menu-label">Library</p>
<li><a hx-get="/library" class="nav-link"

View File

@ -1,15 +1,55 @@
<h1>Host setting</h1>
<h1>Paramètres Debrid-Link</h1>
<form>
<label>List host</label>
<div class="select is-primary">
<select>
<option>Select dropdown</option>
<option value="1">Debrid-link.com</option>
</select>
</div>
<label>Username</label>
<input class="input is-primary cell" type="text" value="">
<label>Password</label>
<input class="input is-primary cell" type="password" value="">
</form>
<form hx-post="/godownloader/settings" hx-target="#auth-status" hx-swap="innerHTML">
<input type="text" name="username" placeholder="Email" required>
<input type="password" name="password" placeholder="Mot de passe" required>
<button type="submit">Se connecter</button>
</form>
<div id="auth-status"></div>
<h2 class="title is-4">Comptes enregistrés</h2>
<div id="accounts-table">
{{ if .accounts }}
<table class="table is-striped is-fullwidth">
<thead>
<tr>
<th>Host</th>
<th>Utilisateur</th>
<th>Actif</th>
<th>Token</th>
<th>Expire</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{ range .accounts }}
<tr>
<td>{{ .Host }}</td>
<td>{{ .Username }}</td>
<td>{{ if .IsActive }}✅{{ else }}❌{{ end }}</td>
<td><code>{{ .AccessToken | printf "%.12s..." }}</code></td>
<td>{{ .ExpiresAt.Format "02/01/2006 15:04" }}</td>
<td>
<div class="buttons">
<a class="button is-danger is-light"
hx-get="/godownloader/settings/delete?id={{ .ID }}"
hx-target="#accounts-table"
hx-swap="outerHTML">Supprimer</a>
<a class="button is-info is-light"
hx-get="/godownloader/settings/toggle?id={{ .ID }}"
hx-target="#accounts-table"
hx-swap="outerHTML">
{{ if .IsActive }}Désactiver{{ else }}Activer{{ end }}
</a>
</div>
</td>
</tr>
{{ end }}
</tbody>
</table>
{{ else }}
<p>Aucun compte enregistré.</p>
{{ end }}
</div>

View File

@ -0,0 +1,39 @@
<div id="debrid-auth-status" class="notification is-info">
<p>Pour activer votre compte Debrid-Link :</p>
<p><strong>Code :</strong> {{ .code }}</p>
<p><strong>URL :</strong> <a href="{{ .url }}" target="_blank">{{ .url }}</a></p>
</div>
<script>
let attempts = 0;
const maxAttempts = 10;
const interval = setInterval(() => {
attempts++;
if (attempts > maxAttempts) {
clearInterval(interval);
return;
}
fetch('/godownloader/poll-status')
.then(res => res.json())
.then(data => {
if (data.success) {
clearInterval(interval);
// Cible la notification en attente
const notifContainer = document.getElementById('debrid-auth-status');
if (notifContainer) {
notifContainer.className = 'notification is-success';
notifContainer.innerHTML = '✅ Compte activé avec succès !';
setTimeout(() => notifContainer.remove(), 5000);
}
// Rafraîchit la table des comptes
htmx.ajax('GET', '/godownloader/table-refresh', {
target: '#accounts-table',
swap: 'outerHTML'
});
}
});
}, 5000);
</script>

BIN
tmp/main Normal file → Executable file

Binary file not shown.

1
tmp/stdout Normal file
View File

@ -0,0 +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