diff --git a/README.md b/README.md index e5951cc..ab074f6 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ wxyc labels list wxyc schedule wxyc bin list wxyc djs playlists # your past shows; pass a dj_id for another DJ +wxyc djs playlists --full # every track of every one of your past shows ``` Add `--json` to any command for machine-readable output. The rule is uniform: @@ -70,6 +71,7 @@ Commands marked **[write]** mutate station state and require `--write` (or | Command | Description | |---------|-------------| | `wxyc flowsheet tail [-n ]` | Show the most recent entries (default 10). | +| `wxyc flowsheet show ` | Show every entry of one past or current show, in play order. Show ids come from `djs playlists`. | | `wxyc flowsheet start [--name ] [--as ] [--specialty ]` | **[write]** Start your show, or join the active show as co-host. | | `wxyc flowsheet add --track [--artist ] [--album ] [--label ] [--album-id ] [--rotation-id ] [--segue] [--request]` | **[write]** Add a played track. `--track` is required; `--artist`/`--album` are required unless `--album-id` is given (the server backfills from it). | | `wxyc flowsheet talkset` | **[write]** Log a talkset. | @@ -90,7 +92,7 @@ Commands marked **[write]** mutate station state and require `--write` (or | Command | Description | |---------|-------------| -| `wxyc djs playlists [dj_id]` | List a DJ's past shows. Defaults to your own (read from the token). | +| `wxyc djs playlists [dj_id] [--full]` | List a DJ's past shows. Defaults to your own (read from the token). Each show carries only a short preview; `--full` fetches every show's complete track list (one request per show) and lists one row per track — with `--json`, an array of full show bodies. | ### `labels` — record labels diff --git a/cmd/djs.go b/cmd/djs.go index 5b15493..384b795 100644 --- a/cmd/djs.go +++ b/cmd/djs.go @@ -1,6 +1,8 @@ package cmd import ( + "encoding/json" + "fmt" "strconv" "strings" @@ -16,7 +18,8 @@ func newDJsCmd(app *App) *cobra.Command { } func newDJsPlaylistsCmd(app *App) *cobra.Command { - return &cobra.Command{ + var full bool + cmd := &cobra.Command{ Use: "playlists [dj_id]", Short: "List a DJ's past shows (defaults to your own)", Args: cobra.MaximumNArgs(1), @@ -29,9 +32,44 @@ func newDJsPlaylistsCmd(app *App) *cobra.Command { if err != nil { return err } + if full { + return emitFullPlaylists(cmd, app, playlists) + } return app.render.EmitRaw(raw, []string{"DATE", "SHOW", "DJS", "PREVIEW"}, playlistRows(playlists)) }, } + cmd.Flags().BoolVar(&full, "full", false, "fetch every track of every show (one request per show)") + return cmd +} + +// emitFullPlaylists fetches each listed show's complete flowsheet. --json +// emits a JSON array of the server's show bodies, byte-for-byte; the table +// lists one row per track, tagged with its show's date and id. +func emitFullPlaylists(cmd *cobra.Command, app *App, playlists []api.Playlist) error { + bodies := make([]json.RawMessage, 0, len(playlists)) + var rows [][]string + for _, p := range playlists { + show, raw, err := app.client.Show(cmd.Context(), p.Show) + if err != nil { + return fmt.Errorf("show %d: %w", p.Show, err) + } + bodies = append(bodies, raw) + date := p.Date + if len(date) >= 10 { + date = date[:10] + } + for _, e := range inPlayOrder(show.Entries) { + if e.EntryType != "track" { + continue + } + rows = append(rows, []string{date, strconv.Itoa(p.Show), e.ArtistName, e.TrackTitle, e.AlbumTitle}) + } + } + combined, err := json.Marshal(bodies) + if err != nil { + return err + } + return app.render.EmitRaw(combined, []string{"DATE", "SHOW", "ARTIST", "TRACK", "ALBUM"}, rows) } // resolveDJID uses the positional arg when given, otherwise falls back to the diff --git a/cmd/djs_test.go b/cmd/djs_test.go index 386376c..991532c 100644 --- a/cmd/djs_test.go +++ b/cmd/djs_test.go @@ -2,6 +2,7 @@ package cmd import ( "encoding/base64" + "encoding/json" "net/http" "net/http/httptest" "strings" @@ -62,3 +63,102 @@ func TestPlaylists_TableProjection(t *testing.T) { t.Errorf("preview should skip non-track rows:\n%s", out) } } + +// showServer serves /djs/playlists with the given body and /flowsheet/playlist +// with one full show per show_id from shows, counting show fetches. +func showServer(t *testing.T, playlists string, shows map[string]string) (*httptest.Server, *int) { + t.Helper() + fetches := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/djs/playlists": + w.Write([]byte(playlists)) + case "/flowsheet/playlist": + fetches++ + body, ok := shows[r.URL.Query().Get("show_id")] + if !ok { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"message":"Show not found"}`)) + return + } + w.Write([]byte(body)) + default: + t.Errorf("unexpected path %s", r.URL.Path) + } + })) + return srv, &fetches +} + +const showFull42 = `{"id":42,"start_time":"2026-10-16T15:59:19.052Z","show_djs":[{"id":"abc","dj_name":"Ryan Shaw"}],` + + `"entries":[` + + `{"entry_type":"show_start","dj_name":"Ryan Shaw"},` + + `{"entry_type":"track","artist_name":"Little Mazarn","track_title":"White Fang","album_title":"Little Mazarn"},` + + `{"entry_type":"track","artist_name":"Yasmin Williams","track_title":"Jarabi","album_title":"Urban Driftwood"},` + + `{"entry_type":"talkset"},` + + `{"entry_type":"track","artist_name":"Duke Pearson","track_title":"The Fakir","album_title":"Prairie Dog"}` + + `]}` + +func TestPlaylistsFull_FetchesEveryShow(t *testing.T) { + playlists := `[` + + `{"show":42,"date":"2026-10-16T15:59:19.052Z","preview":[{"entry_type":"track","artist_name":"Little Mazarn","track_title":"White Fang"}]},` + + `{"show":43,"date":"2026-10-30T16:00:00.000Z","preview":[]}` + + `]` + show43 := `{"id":43,"start_time":"2026-10-30T16:00:00.000Z","entries":[` + + `{"entry_type":"track","artist_name":"Japan","track_title":"Talking Drum","album_title":"Tin Drum"}]}` + srv, fetches := showServer(t, playlists, map[string]string{"42": showFull42, "43": show43}) + defer srv.Close() + t.Setenv("WXYC_API_URL", srv.URL) + t.Setenv("WXYC_JWT", "unused") + + out, err := runCLI(t, "djs", "playlists", "abc", "--full") + if err != nil { + t.Fatal(err) + } + if *fetches != 2 { + t.Errorf("fetched %d shows, want 2", *fetches) + } + // One row per track, tagged with its show's date; markers are dropped. + for _, want := range []string{"2026-10-16", "The Fakir", "2026-10-30", "Talking Drum"} { + if !strings.Contains(out, want) { + t.Errorf("table missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "talkset") { + t.Errorf("--full table should list tracks only:\n%s", out) + } +} + +func TestPlaylistsFull_JSONIsArrayOfShowBodies(t *testing.T) { + playlists := `[{"show":42,"date":"2026-10-16T15:59:19.052Z","preview":[]}]` + srv, _ := showServer(t, playlists, map[string]string{"42": showFull42}) + defer srv.Close() + t.Setenv("WXYC_API_URL", srv.URL) + t.Setenv("WXYC_JWT", "unused") + + out, err := runCLI(t, "--json", "djs", "playlists", "abc", "--full") + if err != nil { + t.Fatal(err) + } + var shows []map[string]any + if err := json.Unmarshal([]byte(out), &shows); err != nil { + t.Fatalf("--json --full is not a JSON array: %v\n%s", err, out) + } + if len(shows) != 1 || shows[0]["show_djs"] == nil { + t.Errorf("want one full show body, got %s", out) + } + if entries, _ := shows[0]["entries"].([]any); len(entries) != 5 { + t.Errorf("entries = %d, want all 5", len(entries)) + } +} + +func TestPlaylistsFull_MissingShowFails(t *testing.T) { + playlists := `[{"show":99,"date":"2026-10-16T15:59:19.052Z","preview":[]}]` + srv, _ := showServer(t, playlists, map[string]string{}) + defer srv.Close() + t.Setenv("WXYC_API_URL", srv.URL) + t.Setenv("WXYC_JWT", "unused") + + if _, err := runCLI(t, "djs", "playlists", "abc", "--full"); err == nil { + t.Error("want an error when a listed show 404s") + } +} diff --git a/cmd/flowsheet.go b/cmd/flowsheet.go index 812da11..ec637d8 100644 --- a/cmd/flowsheet.go +++ b/cmd/flowsheet.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "slices" "strconv" "strings" @@ -13,6 +14,7 @@ func newFlowsheetCmd(app *App) *cobra.Command { fs := &cobra.Command{Use: "flowsheet", Short: "Read and manage the on-air flowsheet log"} fs.AddCommand( newFlowsheetTailCmd(app), + newFlowsheetShowCmd(app), newFlowsheetStartCmd(app), newFlowsheetAddCmd(app), newFlowsheetMarkerCmd(app, "talkset", "Log a talkset", "Talkset", "talkset"), @@ -338,6 +340,33 @@ func newFlowsheetTailCmd(app *App) *cobra.Command { return cmd } +func newFlowsheetShowCmd(app *App) *cobra.Command { + return &cobra.Command{ + Use: "show ", + Short: "Show every entry of one show (ids from `djs playlists`)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + showID, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("show_id must be an integer, got %q", args[0]) + } + show, raw, err := app.client.Show(cmd.Context(), showID) + if err != nil { + return err + } + return app.render.EmitRaw(raw, []string{"TYPE", "ARTIST", "TRACK", "ALBUM"}, entryRows(inPlayOrder(show.Entries))) + }, + } +} + +// inPlayOrder returns a show's entries sorted first-played first. The server +// sends them newest-first; --json keeps that order, the table reads top-down. +func inPlayOrder(entries []api.FlowsheetEntry) []api.FlowsheetEntry { + sorted := slices.Clone(entries) + slices.SortStableFunc(sorted, func(a, b api.FlowsheetEntry) int { return a.PlayOrder - b.PlayOrder }) + return sorted +} + func entryRows(entries []api.FlowsheetEntry) [][]string { rows := make([][]string, 0, len(entries)) for _, e := range entries { diff --git a/cmd/flowsheet_test.go b/cmd/flowsheet_test.go index f018a89..179f13e 100644 --- a/cmd/flowsheet_test.go +++ b/cmd/flowsheet_test.go @@ -322,3 +322,65 @@ func TestFlowsheetStart_Specialty(t *testing.T) { }) } } + +func TestFlowsheetShow_ListsEveryEntry(t *testing.T) { + srv, _ := showServer(t, `[]`, map[string]string{"42": showFull42}) + defer srv.Close() + t.Setenv("WXYC_API_URL", srv.URL) + t.Setenv("WXYC_JWT", "unused") + + out, err := runCLI(t, "flowsheet", "show", "42") + if err != nil { + t.Fatal(err) + } + // All three tracks, not just a preview's worth. + for _, want := range []string{"White Fang", "Jarabi", "The Fakir", "talkset"} { + if !strings.Contains(out, want) { + t.Errorf("table missing %q:\n%s", want, out) + } + } +} + +func TestFlowsheetShow_RejectsNonIntID(t *testing.T) { + t.Setenv("WXYC_JWT", "unused") + if _, err := runCLI(t, "flowsheet", "show", "abc"); err == nil { + t.Error("want error for non-integer show_id") + } +} + +func TestFlowsheetShow_JSONIsServerBody(t *testing.T) { + srv, _ := showServer(t, `[]`, map[string]string{"42": showFull42}) + defer srv.Close() + t.Setenv("WXYC_API_URL", srv.URL) + t.Setenv("WXYC_JWT", "unused") + + out, err := runCLI(t, "--json", "flowsheet", "show", "42") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, `"show_djs"`) || !strings.Contains(out, `"The Fakir"`) { + t.Errorf("--json should pass the full show through:\n%s", out) + } +} + +func TestFlowsheetShow_TableIsInPlayOrder(t *testing.T) { + // The server returns a show's entries newest-first (play_order descending). + body := `{"id":42,"entries":[` + + `{"play_order":3,"entry_type":"show_end","dj_name":"Ryan Shaw"},` + + `{"play_order":2,"entry_type":"track","artist_name":"Little Mazarn","track_title":"White Fang"},` + + `{"play_order":1,"entry_type":"show_start","dj_name":"Ryan Shaw"}` + + `]}` + srv, _ := showServer(t, `[]`, map[string]string{"42": body}) + defer srv.Close() + t.Setenv("WXYC_API_URL", srv.URL) + t.Setenv("WXYC_JWT", "unused") + + out, err := runCLI(t, "flowsheet", "show", "42") + if err != nil { + t.Fatal(err) + } + start, track, end := strings.Index(out, "show_start"), strings.Index(out, "White Fang"), strings.Index(out, "show_end") + if !(start < track && track < end) { + t.Errorf("table not in play order (start=%d track=%d end=%d):\n%s", start, track, end, out) + } +} diff --git a/internal/api/playlists_test.go b/internal/api/playlists_test.go index e684f56..63f2c4f 100644 --- a/internal/api/playlists_test.go +++ b/internal/api/playlists_test.go @@ -42,3 +42,42 @@ func TestPlaylists(t *testing.T) { t.Errorf("raw dropped unmodeled field: %s", raw) } } + +func TestShow(t *testing.T) { + // Mirrors GET /flowsheet/playlist: show metadata spread at the top level, + // adjacent-show ids, and the full entry list. legacy_dj_name and + // metadata_status are unmodeled and must still survive in raw. + body := `{"id":1950333,"primary_dj_id":null,"legacy_dj_name":"Ryan Shaw",` + + `"start_time":"2026-07-17T17:59:38.121Z","end_time":"2026-07-17T20:59:04.215Z",` + + `"show_name":null,"specialty_show_name":"","show_djs":[],` + + `"previous_show_id":1950329,"next_show_id":1950339,` + + `"entries":[` + + `{"id":1,"show_id":1950333,"play_order":1,"entry_type":"show_start"},` + + `{"id":2,"show_id":1950333,"play_order":2,"entry_type":"track","artist_name":"The Moles",` + + `"track_title":"Cars For Kings Cross","album_title":"Untune the Sky","metadata_status":"done"}` + + `]}` + + var q string + srv := serve(t, "/flowsheet/playlist", body, &q) + defer srv.Close() + + c := &Client{BaseURL: srv.URL, HTTP: srv.Client()} + s, raw, err := c.Show(context.Background(), 1950333) + if err != nil { + t.Fatal(err) + } + if q != "show_id=1950333" { + t.Errorf("query = %q, want show_id=1950333", q) + } + if s.ID != 1950333 || s.StartTime != "2026-07-17T17:59:38.121Z" { + t.Errorf("decoded %+v", s) + } + if len(s.Entries) != 2 || s.Entries[1].TrackTitle != "Cars For Kings Cross" { + t.Errorf("entries = %+v", s.Entries) + } + for _, want := range []string{"legacy_dj_name", "metadata_status", "previous_show_id"} { + if !strings.Contains(string(raw), want) { + t.Errorf("raw dropped unmodeled field %s: %s", want, raw) + } + } +} diff --git a/internal/api/reads.go b/internal/api/reads.go index 067bf5a..6958711 100644 --- a/internal/api/reads.go +++ b/internal/api/reads.go @@ -56,6 +56,14 @@ func (c *Client) Playlists(ctx context.Context, djID string) ([]Playlist, []byte return p, raw, err } +// Show returns one show's metadata and every flowsheet entry in it, in play +// order. The endpoint is public; a missing show is a 404. +func (c *Client) Show(ctx context.Context, showID int) (Show, []byte, error) { + var s Show + raw, err := c.getInto(ctx, "/flowsheet/playlist", map[string]string{"show_id": strconv.Itoa(showID)}, &s) + return s, raw, err +} + // Rotation returns the current rotation as raw JSON. The shape is deep // (nested reconciled_identity, COALESCE fallbacks), so there is no decoded // projection at the client layer; the command decodes generically. diff --git a/internal/api/types.go b/internal/api/types.go index c4c39e2..fa4e3e6 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -203,7 +203,7 @@ type Label struct { // Playlist is one past show a DJ was on, as returned by /djs/playlists. The // server caps Preview at the show's first few flowsheet entries (in play order, -// message rows excluded); there is no endpoint for a show's full track list. +// message rows excluded); fetch the full track list with Client.Show. type Playlist struct { Show int `json:"show"` ShowName string `json:"show_name"` @@ -213,6 +213,16 @@ type Playlist struct { Preview []FlowsheetEntry `json:"preview"` } +// Show is one show's metadata and its complete flowsheet, as returned by +// /flowsheet/playlist. Only the fields the table renders are modeled; the +// rest (legacy_dj_name, previous/next_show_id, …) pass through --json. +type Show struct { + ID int `json:"id"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + Entries []FlowsheetEntry `json:"entries"` +} + // PlaylistDJ names one of the DJs credited on a show. DJName may be empty. type PlaylistDJ struct { DJID string `json:"dj_id"`