From 4c19be58f23bd7386476657d4925f8523919907b Mon Sep 17 00:00:00 2001 From: Waddenn Date: Thu, 5 Feb 2026 15:05:23 +0100 Subject: [PATCH 1/2] feat: implement web authentication flow (PIN) and auto-login --- cmd/plex-client/main.go | 145 +++++++++++++++++++++++++++++- internal/auth/auth.go | 181 ++++++++++++++++++++++++++++++++++++++ internal/config/config.go | 22 ++++- 3 files changed, 343 insertions(+), 5 deletions(-) create mode 100644 internal/auth/auth.go diff --git a/cmd/plex-client/main.go b/cmd/plex-client/main.go index f7c8dc7..c5fe7a6 100644 --- a/cmd/plex-client/main.go +++ b/cmd/plex-client/main.go @@ -5,7 +5,13 @@ import ( "fmt" "log" "os" + "os/exec" + "runtime" + "sort" + "strings" + "time" + "github.com/Waddenn/plex-client/internal/auth" "github.com/Waddenn/plex-client/internal/cache" "github.com/Waddenn/plex-client/internal/config" "github.com/Waddenn/plex-client/internal/db" @@ -45,11 +51,26 @@ func main() { } } + // Check for commands + if len(os.Args) > 1 && os.Args[1] == "login" { + runLoginFlow(cfg) + } + 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) + fmt.Print("Would you like to login to Plex now? [Y/n]: ") + + var response string + fmt.Scanln(&response) // wait for enter + + if response == "" || response == "y" || response == "Y" { + // Trigger login flow + runLoginFlow(cfg) + } else { + fmt.Println("Usage: plex-client --baseurl URL --token TOKEN") + fmt.Println(" or create ~/.config/plex-client/config.toml") + os.Exit(1) + } } d, err := db.Open() @@ -95,3 +116,121 @@ func main() { os.Exit(1) } } + +func runLoginFlow(cfg *config.Config) { + clientID := cfg.Plex.ClientIdentifier + if clientID == "" { + // Should be auto-generated by config.Load(), but safety check + clientID = "plex-client-go-cli-fallback" + } + + fmt.Printf("šŸ†” Using Client ID: %s\n", clientID) + authClient := auth.NewAuthClient(clientID, "Plex Client CLI", "0.1.0") + + fmt.Println("šŸ”„ Requesting PIN from Plex...") + pin, authLink, err := authClient.GetPin() + if err != nil { + log.Fatalf("Error getting PIN: %v", err) + } + + fmt.Printf("\nšŸ”— Please open this URL to login:\n%s\n", authLink) + + // Auto-open browser + openBrowser(authLink) + + fmt.Printf("\n(Waiting for authorization...)\n") + + // Poll for token + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + timeout := time.After(2 * time.Minute) + + for { + select { + case <-timeout: + fmt.Println("āŒ Timed out waiting for login.") + os.Exit(1) + case <-ticker.C: + p, err := authClient.CheckPin(pin.ID) + if err != nil { + continue + } + if p.AuthToken != "" { + fmt.Println("āœ… Login successful!") + cfg.Plex.Token = p.AuthToken + + fmt.Println("šŸ” Searching for servers...") + resources, err := authClient.GetResources(p.AuthToken) + if err != nil { + log.Printf("Warning: failed to get resources: %v", err) + } else { + // Find first server + for _, res := range resources { + if res.Provides == "server" || res.Product == "Plex Media Server" { + fmt.Printf("found server: %s\n", res.Name) + + if len(res.Connections) > 0 { + // Sort connections: Remote > Local (Non-Docker) > Docker + sort.Slice(res.Connections, func(i, j int) bool { + return getConnectionScore(res.Connections[i]) > getConnectionScore(res.Connections[j]) + }) + + // Auto-select the best connection (first one after sorting) + selectedConn := res.Connections[0] + cfg.Plex.BaseURL = selectedConn.Uri + + loc := "Remote" + if selectedConn.Local { + loc = "Local" + } + fmt.Printf("āœ… Automatically selected %s connection: %s (%s)\n", loc, selectedConn.Uri, selectedConn.Address) + + fmt.Printf("šŸ”— Connected to %s (%s)\n", res.Name, cfg.Plex.BaseURL) + break + } + } + } + } + + if err := config.Save(cfg); err != nil { + log.Fatalf("Error saving config: %v", err) + } + dir, _ := config.ConfigDir() + fmt.Printf("Configuration saved to %s/config.toml\n", dir) + + // Continue to application instead of exiting + return + } + } + } +} + +func openBrowser(url string) { + var err error + switch runtime.GOOS { + case "linux": + err = exec.Command("xdg-open", url).Start() + case "windows": + err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + case "darwin": + err = exec.Command("open", url).Start() + default: + // Try generic xdg-open for other unix-likes + err = exec.Command("xdg-open", url).Start() + } + if err != nil { + // Non-fatal, just log + // fmt.Printf("Could not open browser: %v\n", err) + } +} + +func getConnectionScore(conn auth.PlexConnection) int { + if !conn.Local { + return 3 // Remote is preferred as per user request + } + // Local connections + if strings.HasPrefix(conn.Address, "172.") { + return 1 // Likely Docker/Container, lowest priority + } + return 2 // Standard Local LAN +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..2e6333f --- /dev/null +++ b/internal/auth/auth.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go index 106f247..aefe959 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,8 +1,11 @@ package config import ( + "crypto/rand" + "fmt" "os" "path/filepath" + "time" "github.com/BurntSushi/toml" ) @@ -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 { @@ -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() From 7f869b29998ef1959f892d7a8f78a01b24d1b7d6 Mon Sep 17 00:00:00 2001 From: Waddenn Date: Thu, 5 Feb 2026 15:43:49 +0100 Subject: [PATCH 2/2] feat: implement native TUI login flow with interactive browser confirmation --- .gitignore | 2 +- cmd/plex-client/main.go | 168 ++----------------- internal/tui/login/model.go | 298 ++++++++++++++++++++++++++++++++++ internal/tui/model.go | 36 +++- internal/tui/shared/styles.go | 1 + 5 files changed, 350 insertions(+), 155 deletions(-) create mode 100644 internal/tui/login/model.go diff --git a/.gitignore b/.gitignore index ce02cd5..cdea07f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,5 @@ dev.nix .vscoderesult vendor/ result -plex-client +/plex-client plex-cache.db \ No newline at end of file diff --git a/cmd/plex-client/main.go b/cmd/plex-client/main.go index c5fe7a6..213c154 100644 --- a/cmd/plex-client/main.go +++ b/cmd/plex-client/main.go @@ -5,13 +5,7 @@ import ( "fmt" "log" "os" - "os/exec" - "runtime" - "sort" - "strings" - "time" - "github.com/Waddenn/plex-client/internal/auth" "github.com/Waddenn/plex-client/internal/cache" "github.com/Waddenn/plex-client/internal/config" "github.com/Waddenn/plex-client/internal/db" @@ -53,24 +47,7 @@ func main() { // Check for commands if len(os.Args) > 1 && os.Args[1] == "login" { - runLoginFlow(cfg) - } - - if cfg.Plex.BaseURL == "" || cfg.Plex.Token == "" { - fmt.Println("āŒ Missing configuration.") - fmt.Print("Would you like to login to Plex now? [Y/n]: ") - - var response string - fmt.Scanln(&response) // wait for enter - - if response == "" || response == "y" || response == "Y" { - // Trigger login flow - runLoginFlow(cfg) - } else { - fmt.Println("Usage: plex-client --baseurl URL --token TOKEN") - fmt.Println(" or create ~/.config/plex-client/config.toml") - os.Exit(1) - } + fmt.Println("ā„¹ļø Login is now handled directly in the TUI.") } d, err := db.Open() @@ -96,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) @@ -116,121 +96,3 @@ func main() { os.Exit(1) } } - -func runLoginFlow(cfg *config.Config) { - clientID := cfg.Plex.ClientIdentifier - if clientID == "" { - // Should be auto-generated by config.Load(), but safety check - clientID = "plex-client-go-cli-fallback" - } - - fmt.Printf("šŸ†” Using Client ID: %s\n", clientID) - authClient := auth.NewAuthClient(clientID, "Plex Client CLI", "0.1.0") - - fmt.Println("šŸ”„ Requesting PIN from Plex...") - pin, authLink, err := authClient.GetPin() - if err != nil { - log.Fatalf("Error getting PIN: %v", err) - } - - fmt.Printf("\nšŸ”— Please open this URL to login:\n%s\n", authLink) - - // Auto-open browser - openBrowser(authLink) - - fmt.Printf("\n(Waiting for authorization...)\n") - - // Poll for token - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - timeout := time.After(2 * time.Minute) - - for { - select { - case <-timeout: - fmt.Println("āŒ Timed out waiting for login.") - os.Exit(1) - case <-ticker.C: - p, err := authClient.CheckPin(pin.ID) - if err != nil { - continue - } - if p.AuthToken != "" { - fmt.Println("āœ… Login successful!") - cfg.Plex.Token = p.AuthToken - - fmt.Println("šŸ” Searching for servers...") - resources, err := authClient.GetResources(p.AuthToken) - if err != nil { - log.Printf("Warning: failed to get resources: %v", err) - } else { - // Find first server - for _, res := range resources { - if res.Provides == "server" || res.Product == "Plex Media Server" { - fmt.Printf("found server: %s\n", res.Name) - - if len(res.Connections) > 0 { - // Sort connections: Remote > Local (Non-Docker) > Docker - sort.Slice(res.Connections, func(i, j int) bool { - return getConnectionScore(res.Connections[i]) > getConnectionScore(res.Connections[j]) - }) - - // Auto-select the best connection (first one after sorting) - selectedConn := res.Connections[0] - cfg.Plex.BaseURL = selectedConn.Uri - - loc := "Remote" - if selectedConn.Local { - loc = "Local" - } - fmt.Printf("āœ… Automatically selected %s connection: %s (%s)\n", loc, selectedConn.Uri, selectedConn.Address) - - fmt.Printf("šŸ”— Connected to %s (%s)\n", res.Name, cfg.Plex.BaseURL) - break - } - } - } - } - - if err := config.Save(cfg); err != nil { - log.Fatalf("Error saving config: %v", err) - } - dir, _ := config.ConfigDir() - fmt.Printf("Configuration saved to %s/config.toml\n", dir) - - // Continue to application instead of exiting - return - } - } - } -} - -func openBrowser(url string) { - var err error - switch runtime.GOOS { - case "linux": - err = exec.Command("xdg-open", url).Start() - case "windows": - err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() - case "darwin": - err = exec.Command("open", url).Start() - default: - // Try generic xdg-open for other unix-likes - err = exec.Command("xdg-open", url).Start() - } - if err != nil { - // Non-fatal, just log - // fmt.Printf("Could not open browser: %v\n", err) - } -} - -func getConnectionScore(conn auth.PlexConnection) int { - if !conn.Local { - return 3 // Remote is preferred as per user request - } - // Local connections - if strings.HasPrefix(conn.Address, "172.") { - return 1 // Likely Docker/Container, lowest priority - } - return 2 // Standard Local LAN -} diff --git a/internal/tui/login/model.go b/internal/tui/login/model.go new file mode 100644 index 0000000..94649a0 --- /dev/null +++ b/internal/tui/login/model.go @@ -0,0 +1,298 @@ +package login + +import ( + "fmt" + "os/exec" + "sort" + "strings" + "time" + + "github.com/Waddenn/plex-client/internal/auth" + "github.com/Waddenn/plex-client/internal/config" + "github.com/Waddenn/plex-client/internal/tui/shared" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// MsgLoginSuccess is sent when login is complete and configured +type MsgLoginSuccess struct { + Config *config.Config +} + +type Model struct { + cfg *config.Config + authClient *auth.AuthClient + pin *auth.PlexPin + authLink string + err error + + width, height int + state state + focus int // 0: Open Browser, 1: Cancel + servers []auth.PlexResource + selectedConn string // BaseURL +} + +type state int + +const ( + stateInit state = iota + stateWaitingForPin + stateConfirmOpen + statePolling + stateSearchingServers + stateSuccess +) + +func NewModel(cfg *config.Config) Model { + clientID := cfg.Plex.ClientIdentifier + if clientID == "" { + clientID = "plex-client-tui-fallback" + } + + return Model{ + cfg: cfg, + authClient: auth.NewAuthClient(clientID, "Plex Client TUI", "0.1.0"), // TODO: Pass version + state: stateInit, + } +} + +func (m Model) Init() tea.Cmd { + return m.getPinCmd +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "ctrl+c" || msg.String() == "q" { + return m, tea.Quit + } + + if m.state == stateConfirmOpen { + switch msg.String() { + case "left", "h", "tab": + m.focus = 0 + case "right", "l", "shift+tab": + m.focus = 1 + case "enter", "o", "y": + if m.focus == 0 { + m.state = statePolling + return m, openBrowserCmd(m.authLink) + } + return m, tea.Quit + } + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case msgPinReady: + if msg.err != nil { + m.err = msg.err + return m, nil + } + m.pin = msg.pin + m.authLink = msg.authLink + m.state = stateConfirmOpen + return m, pollCmd(m.pin.ID, m.authClient) + + case msgPollResult: + if msg.err != nil { + // Poll again? Or fatal? + // For now, simple retry logic is inside pollCmd wrapper usually, + // but here we just get a result. + // If error is just "not ready", we assume pollCmd handles wait? + // Let's make pollCmd wait and retry internally or return "not ready" signal? + // Simpler: The poll command waits a bit and checks. + return m, pollCmd(m.pin.ID, m.authClient) + } + if msg.pin.AuthToken != "" { + m.cfg.Plex.Token = msg.pin.AuthToken // Save token temporarily + m.state = stateSearchingServers + return m, getResourcesCmd(m.authClient, msg.pin.AuthToken) + } + // Not ready yet, poll again + return m, pollCmd(m.pin.ID, m.authClient) + + case msgResourcesReady: + if msg.err != nil { + m.err = msg.err + return m, nil // Wait for user to quit? + } + + // Auto-select best connection + bestURL := selectBestConnection(msg.resources) + if bestURL == "" { + m.err = fmt.Errorf("no suitable Plex server found") + return m, nil + } + + m.cfg.Plex.BaseURL = bestURL + // Save config + if err := config.Save(m.cfg); err != nil { + m.err = err + return m, nil + } + + return m, func() tea.Msg { + return MsgLoginSuccess{Config: m.cfg} + } + } + + return m, nil +} + +func (m Model) View() string { + if m.err != nil { + content := lipgloss.JoinVertical(lipgloss.Center, + shared.StyleTitle.Render("āŒ Login Error"), + "", + shared.StyleItemNormal.Render(m.err.Error()), + "", + shared.StyleDim.Render("Press q to quit"), + ) + return shared.StyleBorder.Render(content) + } + + var content string + switch m.state { + case stateInit, stateWaitingForPin: + content = lipgloss.JoinVertical(lipgloss.Center, + shared.StyleTitle.Render("šŸ”„ Initializing Login..."), + "", + shared.StyleDim.Render("Connecting to Plex.tv"), + ) + + case stateConfirmOpen, statePolling: + var status string + var tip string + + if m.state == stateConfirmOpen { + btnStyle := lipgloss.NewStyle(). + Padding(0, 3). + Margin(1, 1). + Background(shared.ColorDarkGrey). + Foreground(shared.ColorWhite) + + activeBtnStyle := btnStyle.Copy(). + Background(shared.ColorPlexOrange). + Foreground(shared.ColorBlack). + Bold(true) + + openBtn := btnStyle.Render("Open Browser") + cancelBtn := btnStyle.Render("Cancel") + + if m.focus == 0 { + openBtn = activeBtnStyle.Render("Open Browser") + } else { + cancelBtn = activeBtnStyle.Copy(). + Background(lipgloss.Color("#444444")). + Foreground(shared.ColorWhite). + Render("Cancel") + } + + status = lipgloss.JoinHorizontal(lipgloss.Center, openBtn, cancelBtn) + tip = shared.StyleDim.Render("(Use Arrows/Tab to move, Enter to select)") + } else { + status = shared.StyleItemActive.Render("Please sign in via your browser.") + tip = shared.StyleItemNormal.Render("ā³ Waiting for authorization...") + } + + content = lipgloss.JoinVertical(lipgloss.Center, + shared.StyleTitle.Render("šŸ” Plex Authentication"), + "", + status, + tip, + "", + shared.StyleSecondary.Render("Link: "+m.authLink), + ) + + case stateSearchingServers: + content = lipgloss.JoinVertical(lipgloss.Center, + shared.StyleTitle.Render("šŸ” Discovering Servers"), + "", + shared.StyleItemNormal.Render("Locating your Plex Media Server..."), + ) + + case stateSuccess: + content = lipgloss.JoinVertical(lipgloss.Center, + shared.StyleTitle.Render("āœ… Login Successful!"), + "", + shared.StyleItemNormal.Render("Preparing your dashboard..."), + ) + } + + return shared.StyleBorder.Width(60).Render(content) +} + +// -- Commands -- + +type msgPinReady struct { + pin *auth.PlexPin + authLink string + err error +} + +func (m Model) getPinCmd() tea.Msg { + pin, link, err := m.authClient.GetPin() + return msgPinReady{pin, link, err} +} + +func openBrowserCmd(url string) tea.Cmd { + return func() tea.Msg { + _ = exec.Command("xdg-open", url).Start() + return nil + } +} + +type msgPollResult struct { + pin *auth.PlexPin + err error +} + +func pollCmd(pinID int, client *auth.AuthClient) tea.Cmd { + return tea.Tick(2*time.Second, func(t time.Time) tea.Msg { + p, err := client.CheckPin(pinID) + return msgPollResult{p, err} + }) +} + +type msgResourcesReady struct { + resources []auth.PlexResource + err error +} + +func getResourcesCmd(client *auth.AuthClient, token string) tea.Cmd { + return func() tea.Msg { + res, err := client.GetResources(token) + return msgResourcesReady{res, err} + } +} + +// Helper logic +func selectBestConnection(resources []auth.PlexResource) string { + for _, res := range resources { + if res.Provides == "server" || res.Product == "Plex Media Server" { + if len(res.Connections) > 0 { + sort.Slice(res.Connections, func(i, j int) bool { + return getConnectionScore(res.Connections[i]) > getConnectionScore(res.Connections[j]) + }) + return res.Connections[0].Uri + } + } + } + return "" +} + +// Duplicate of main.go logic logic for now, should move to auth/utils? +func getConnectionScore(conn auth.PlexConnection) int { + if !conn.Local { + return 3 + } + if strings.HasPrefix(conn.Address, "172.") { + return 1 + } + return 2 +} diff --git a/internal/tui/model.go b/internal/tui/model.go index c155bf9..45881f8 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -10,6 +10,7 @@ import ( "github.com/Waddenn/plex-client/internal/plex" "github.com/Waddenn/plex-client/internal/tui/browser" "github.com/Waddenn/plex-client/internal/tui/dashboard" + "github.com/Waddenn/plex-client/internal/tui/login" "github.com/Waddenn/plex-client/internal/tui/settings" "github.com/Waddenn/plex-client/internal/tui/shared" tea "github.com/charmbracelet/bubbletea" @@ -27,6 +28,7 @@ type MainModel struct { currentView shared.View // Sub-models + login login.Model dashboard dashboard.Model browser *browser.Model settings settings.Model @@ -39,11 +41,18 @@ type MainModel struct { func NewModel(db *sql.DB, cfg *config.Config, p *plex.Client) MainModel { bm := browser.NewModel(p, db) + + initialView := shared.ViewDashboard + if cfg.Plex.Token == "" { + initialView = shared.ViewLogin + } + return MainModel{ cfg: cfg, db: db, plexClient: p, - currentView: shared.ViewDashboard, + currentView: initialView, + login: login.NewModel(cfg), dashboard: dashboard.NewModel(p), browser: &bm, settings: settings.NewModel(cfg), @@ -51,6 +60,9 @@ func NewModel(db *sql.DB, cfg *config.Config, p *plex.Client) MainModel { } func (m *MainModel) Init() tea.Cmd { + if m.currentView == shared.ViewLogin { + return m.login.Init() + } return m.dashboard.Init() } @@ -85,6 +97,8 @@ func (m *MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Propagate window size to ALL submodels if _, ok := msg.(tea.WindowSizeMsg); ok { m.dashboard, _ = m.dashboard.Update(msg) + newLogin, _ := m.login.Update(msg) + m.login = newLogin.(login.Model) cmd = m.browser.Update(msg) return m, cmd } @@ -193,10 +207,28 @@ func (m *MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case settings.MsgConfigChanged: m.cfg = msg.Config return m, nil + + case login.MsgLoginSuccess: + m.cfg = msg.Config + // Re-init plex client with new token/url + m.plexClient = plex.New(m.cfg.Plex.BaseURL, m.cfg.Plex.Token, "plex-client-go-tui") + + // Update submodels + bm := browser.NewModel(m.plexClient, m.db) + m.browser = &bm + m.dashboard = dashboard.NewModel(m.plexClient) + + // Switch to dashboard + m.currentView = shared.ViewDashboard + return m, m.dashboard.Init() } // Update active submodel switch m.currentView { + case shared.ViewLogin: + newModel, newCmd := m.login.Update(msg) + m.login = newModel.(login.Model) + cmd = newCmd case shared.ViewDashboard: newModel, newCmd := m.dashboard.Update(msg) m.dashboard = newModel @@ -253,6 +285,8 @@ func (m MainModel) playCurrentQueueItem() tea.Cmd { func (m *MainModel) View() string { switch m.currentView { + case shared.ViewLogin: + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.login.View()) case shared.ViewDashboard: return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.dashboard.View()) case shared.ViewMovieBrowser, shared.ViewSeriesBrowser: diff --git a/internal/tui/shared/styles.go b/internal/tui/shared/styles.go index f02fce9..ad23719 100644 --- a/internal/tui/shared/styles.go +++ b/internal/tui/shared/styles.go @@ -96,4 +96,5 @@ const ( ViewPlayer ViewCountdown ViewSettings + ViewLogin )