debridlink
This commit is contained in:
parent
4dd0484b80
commit
188058f957
2
.env
Normal file
2
.env
Normal file
@ -0,0 +1,2 @@
|
||||
DEBRIDLINK_CLIENT_ID=bMs32shaby43qVGVKnkRqw
|
||||
DEBRIDLINK_CLIENT_SECRET=XRMJ8JNJtJZBsRP8gzBnYc6huLK2cqXay3ihKId9mt4
|
||||
@ -4,3 +4,5 @@ services:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
volumes: [] # pas de montage source
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
@ -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
1
go.mod
@ -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
2
go.sum
@ -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=
|
||||
|
||||
8
internal/config/config.go
Normal file
8
internal/config/config.go
Normal file
@ -0,0 +1,8 @@
|
||||
package config
|
||||
|
||||
import "os"
|
||||
|
||||
var (
|
||||
DebridClientID = os.Getenv("DEBRIDLINK_CLIENT_ID")
|
||||
DebridClientSecret = os.Getenv("DEBRIDLINK_CLIENT_SECRET")
|
||||
)
|
||||
@ -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")
|
||||
|
||||
495
internal/debridlink/client.go
Normal file
495
internal/debridlink/client.go
Normal 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
|
||||
}
|
||||
@ -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")
|
||||
|
||||
5
main.go
5
main.go
@ -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()
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
BIN
shelfly_db.db
BIN
shelfly_db.db
Binary file not shown.
42
templates/accounts_table.pages.tmpl
Normal file
42
templates/accounts_table.pages.tmpl
Normal 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>
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
39
templates/oauth_device_code.pages.tmpl
Normal file
39
templates/oauth_device_code.pages.tmpl
Normal 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>
|
||||
1
tmp/stdout
Normal file
1
tmp/stdout
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user