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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -70,6 +71,7 @@ Commands marked **[write]** mutate station state and require `--write` (or
| Command | Description |
|---------|-------------|
| `wxyc flowsheet tail [-n <limit>]` | Show the most recent entries (default 10). |
| `wxyc flowsheet show <show_id>` | Show every entry of one past or current show, in play order. Show ids come from `djs playlists`. |
| `wxyc flowsheet start [--name <s>] [--as <s>] [--specialty <id>]` | **[write]** Start your show, or join the active show as co-host. |
| `wxyc flowsheet add --track <t> [--artist <a>] [--album <a>] [--label <l>] [--album-id <id>] [--rotation-id <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. |
Expand All @@ -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

Expand Down
40 changes: 39 additions & 1 deletion cmd/djs.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package cmd

import (
"encoding/json"
"fmt"
"strconv"
"strings"

Expand All @@ -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),
Expand All @@ -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
Expand Down
100 changes: 100 additions & 0 deletions cmd/djs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
Expand Down Expand Up @@ -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")
}
}
29 changes: 29 additions & 0 deletions cmd/flowsheet.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"fmt"
"slices"
"strconv"
"strings"

Expand All @@ -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"),
Expand Down Expand Up @@ -338,6 +340,33 @@ func newFlowsheetTailCmd(app *App) *cobra.Command {
return cmd
}

func newFlowsheetShowCmd(app *App) *cobra.Command {
return &cobra.Command{
Use: "show <show_id>",
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 {
Expand Down
62 changes: 62 additions & 0 deletions cmd/flowsheet_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
39 changes: 39 additions & 0 deletions internal/api/playlists_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
8 changes: 8 additions & 0 deletions internal/api/reads.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 11 additions & 1 deletion internal/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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"`
Expand Down
Loading