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 f7c8dc7..213c154 100644 --- a/cmd/plex-client/main.go +++ b/cmd/plex-client/main.go @@ -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() @@ -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) 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() 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 )