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, "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) (*StreamInfo, error) { var raw struct { Success bool `json:"success"` Value struct { ID string `json:"id"` StreamURL string `json:"streamUrl"` DownloadURL string `json:"downloadUrl"` Type string `json:"type"` MimeType string `json:"mimetype"` Domain string `json:"domain"` File struct { ID string `json:"id"` Name string `json:"name"` Size int64 `json:"size"` } `json:"file"` } `json:"value"` } path := fmt.Sprintf("stream/transcode/%s", transcodeID) if err := c.doJSON(ctx, "GET", path, nil, nil, &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 }