Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ dev.nix
.vscoderesult
vendor/
result
plex-client
/plex-client
plex-cache.db
33 changes: 17 additions & 16 deletions cmd/plex-client/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,9 @@ func main() {
}
}

if cfg.Plex.BaseURL == "" || cfg.Plex.Token == "" {
fmt.Println("❌ Missing configuration.")
fmt.Println("Usage: plex-client --baseurl URL --token TOKEN")
fmt.Println(" or create ~/.config/plex-client/config.toml")
os.Exit(1)
// Check for commands
if len(os.Args) > 1 && os.Args[1] == "login" {
fmt.Println("ℹ️ Login is now handled directly in the TUI.")
}

d, err := db.Open()
Expand All @@ -75,18 +73,21 @@ func main() {
// Use config sync settings
forceSyncFlag := *forceSync || cfg.Sync.ForceSyncOnStart

if !hasData || forceSyncFlag {
fmt.Println("🚀 Syncing library for the first time... This might take a while.")
if err := cache.Sync(p, d, forceSyncFlag); err != nil {
log.Printf("Sync error: %v", err)
}
} else if cfg.Sync.AutoSync {
// Background sync
go func() {
if err := cache.Sync(p, d, false); err != nil {
log.Printf("Background sync error: %v", err)
// Only attempt sync if we are authenticated
if cfg.Plex.Token != "" {
if !hasData || forceSyncFlag {
fmt.Println("🚀 Syncing library for the first time... This might take a while.")
if err := cache.Sync(p, d, forceSyncFlag); err != nil {
log.Printf("Sync error: %v", err)
}
}()
} else if cfg.Sync.AutoSync {
// Background sync
go func() {
if err := cache.Sync(p, d, false); err != nil {
log.Printf("Background sync error: %v", err)
}
}()
}
}

m := tui.NewModel(d, cfg, p)
Expand Down
181 changes: 181 additions & 0 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package auth

import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"runtime"
"time"
)

const (
SignInURL = "https://plex.tv/api/v2/pins"
PollURL = "https://plex.tv/api/v2/pins/%d"
ResourcesURL = "https://plex.tv/api/v2/resources?includeHttps=1"
)

type PlexPin struct {
ID int `json:"id"`
Code string `json:"code"`
AuthToken string `json:"authToken"`
ExpiresAt string `json:"expiresAt"`
}

type PlexResource struct {
Name string `json:"name"`
Product string `json:"product"`
Provides string `json:"provides"`
ClientIdentifier string `json:"clientIdentifier"`
Connections []PlexConnection `json:"connections"`
Owned bool `json:"owned"`
}

type PlexConnection struct {
Protocol string `json:"protocol"`
Address string `json:"address"`
Port int `json:"port"`
Uri string `json:"uri"`
Local bool `json:"local"`
}

type AuthClient struct {
ClientID string
Product string
Version string
Platform string
Device string
Client *http.Client
}

// ... existing NewAuthClient ...

// GetResources fetches the list of available servers
func (a *AuthClient) GetResources(token string) ([]PlexResource, error) {
req, err := http.NewRequest("GET", ResourcesURL, nil)
if err != nil {
return nil, err
}

req.Header.Set("Accept", "application/json")
req.Header.Set("X-Plex-Token", token)
req.Header.Set("X-Plex-Client-Identifier", a.ClientID)
req.Header.Set("X-Plex-Product", a.Product)
req.Header.Set("X-Plex-Version", a.Version)
req.Header.Set("X-Plex-Platform", a.Platform)
req.Header.Set("X-Plex-Device", a.Device)

resp, err := a.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get resources: %s", resp.Status)
}

var resources []PlexResource
if err := json.NewDecoder(resp.Body).Decode(&resources); err != nil {
return nil, err
}

return resources, nil
}

func NewAuthClient(clientID, product, version string) *AuthClient {
return &AuthClient{
ClientID: clientID,
Product: product,
Version: version,
Platform: runtime.GOOS,
Device: "Plex Client CLI",
Client: &http.Client{Timeout: 10 * time.Second},
}
}

// GetPin requests a new PIN from Plex
func (a *AuthClient) GetPin() (*PlexPin, string, error) {
req, err := http.NewRequest("POST", SignInURL, nil)
if err != nil {
return nil, "", err
}

q := req.URL.Query()
q.Add("strong", "true")
req.URL.RawQuery = q.Encode()

req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "Plex Client CLI/0.1.0")
req.Header.Set("X-Plex-Product", a.Product)
req.Header.Set("X-Plex-Client-Identifier", a.ClientID)
req.Header.Set("X-Plex-Version", a.Version)
req.Header.Set("X-Plex-Platform", a.Platform)
req.Header.Set("X-Plex-Device", a.Device)

resp, err := a.Client.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusCreated {
return nil, "", fmt.Errorf("failed to create pin: %s", resp.Status)
}

var pin PlexPin
if err := json.NewDecoder(resp.Body).Decode(&pin); err != nil {
return nil, "", err
}

// Construct the Auth URL with proper encoding
params := url.Values{}
params.Set("clientID", a.ClientID)
params.Set("code", pin.Code)
params.Set("context[device][product]", a.Product)
params.Set("context[device][version]", a.Version)
params.Set("context[device][platform]", a.Platform)
params.Set("context[device][device]", a.Device)
params.Set("forwardUrl", "https://app.plex.tv/desktop") // Optional: where to go after

authLink := fmt.Sprintf("https://app.plex.tv/auth#?%s", params.Encode())
return &pin, authLink, nil
}

// CheckPin polls the API to see if the user has authorized the PIN
func (a *AuthClient) CheckPin(pinID int) (*PlexPin, error) {
urlStr := fmt.Sprintf(PollURL, pinID)
req, err := http.NewRequest("GET", urlStr, nil)
if err != nil {
return nil, err
}

// No query params needed if sending headers?
// Some docs suggest sending 'code' as query param if available, but ID is in URL.
// Let's keep URL clean.

req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "Plex Client CLI/0.1.0")
req.Header.Set("X-Plex-Product", a.Product)
req.Header.Set("X-Plex-Client-Identifier", a.ClientID)
req.Header.Set("X-Plex-Version", a.Version)
req.Header.Set("X-Plex-Platform", a.Platform)
req.Header.Set("X-Plex-Device", a.Device)

resp, err := a.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to check pin: %s", resp.Status)
}

var pin PlexPin
if err := json.NewDecoder(resp.Body).Decode(&pin); err != nil {
return nil, err
}

return &pin, nil
}
22 changes: 20 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package config

import (
"crypto/rand"
"fmt"
"os"
"path/filepath"
"time"

"github.com/BurntSushi/toml"
)
Expand All @@ -15,8 +18,9 @@ type Config struct {
}

type PlexConfig struct {
BaseURL string `toml:"baseurl"`
Token string `toml:"token"`
BaseURL string `toml:"baseurl"`
Token string `toml:"token"`
ClientIdentifier string `toml:"client_identifier"`
}

type PlayerConfig struct {
Expand Down Expand Up @@ -113,9 +117,23 @@ func Load() (*Config, error) {
return nil, err
}

if cfg.Plex.ClientIdentifier == "" {
cfg.Plex.ClientIdentifier = generateClientID()
_ = Save(cfg) // Best effort save
}

return cfg, nil
}

func generateClientID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
// Fallback if random fails
return "plex-client-go-cli-" + time.Now().Format("20060102150405")
}
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}

// Save saves config to TOML
func Save(cfg *Config) error {
dir, err := ConfigDir()
Expand Down
Loading