shelfy/internal/debridlink/client.go
2025-06-19 20:15:23 +02:00

580 lines
17 KiB
Go

package debridlink
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
"gorm.io/gorm"
)
const baseURL = "https://debrid-link.com/api/v2/"
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:"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
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 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
}
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)
}
// =========================== 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}
// if err := c.doJSON(ctx, "POST", "stream/transcode", nil, body, &resp); err != nil {
// return "", err
// }
// return resp.TranscodeID, nil
// }
func (c *Client) CreateTranscode(ctx context.Context, fileID string) (*StreamInfo, error) {
body := map[string]string{"id": fileID}
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"`
Source string `json:"source"`
} `json:"file"`
} `json:"value"`
}
path := "stream/transcode/add"
if err := c.doJSON(ctx, "POST", path, nil, body, &raw); err != nil {
return nil, err
}
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
}