Skip to content

Commit 9fda89e

Browse files
committed
finished history view implementation
1 parent d072aeb commit 9fda89e

9 files changed

Lines changed: 189 additions & 45 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,12 +111,23 @@ Each archive contains:
111111

112112
<!-- /generated -->
113113

114+
#### `/music` — usage
115+
116+
Subcommands are summarized in the list above; this section expands the main playback flow.
117+
118+
- **`/music play`** — Enter a **URL** (YouTube, SoundCloud, or a direct radio/stream link), a **search query**, or one or more **history ids** (the numbers shown in `/music history`) to queue that track again without a full resolve. Optional **`source`** and **`parser`** override autodetection. You can separate several ids or URLs with spaces, commas, or semicolons (there is a per-command batch limit).
119+
- **`/music history`****`view`**: **Timeline** lists each play in order with a short date; **By URL** merges identical links and shows a **×** multiplier for how often each was played. Use **`page`** to move through long lists. Each row includes an **id** you can pass to **`/music play`**. History is stored per server; very old rows may be removed when the persisted list is trimmed.
120+
- **`/music next`** — Skip to the next track in the queue.
121+
- **`/music stop`** — Stop playback and clear the queue.
122+
114123
Example usage:
115124

116125
```
117126
/music play Never Gonna Give You Up
118127
/music play https://www.youtube.com/watch?v=dQw4w9WgXcQ
119128
/music play http://stream-uk1.radioparadise.com/aac-320
129+
/music play 42
130+
/music history
120131
```
121132

122133
You must be in a voice channel to use `/music play`.

README.md.tmpl

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,23 @@ Each archive contains:
8484

8585
<!-- /generated -->
8686

87+
#### `/music` — usage
88+
89+
Subcommands are summarized in the list above; this section expands the main playback flow.
90+
91+
- **`/music play`** — Enter a **URL** (YouTube, SoundCloud, or a direct radio/stream link), a **search query**, or one or more **history ids** (the numbers shown in `/music history`) to queue that track again without a full resolve. Optional **`source`** and **`parser`** override autodetection. You can separate several ids or URLs with spaces, commas, or semicolons (there is a per-command batch limit).
92+
- **`/music history`** — **`view`**: **Timeline** lists each play in order with a short date; **By URL** merges identical links and shows a **×** multiplier for how often each was played. Use **`page`** to move through long lists. Each row includes an **id** you can pass to **`/music play`**. History is stored per server; very old rows may be removed when the persisted list is trimmed.
93+
- **`/music next`** — Skip to the next track in the queue.
94+
- **`/music stop`** — Stop playback and clear the queue.
95+
8796
Example usage:
8897

8998
```
9099
/music play Never Gonna Give You Up
91100
/music play https://www.youtube.com/watch?v=dQw4w9WgXcQ
92101
/music play http://stream-uk1.radioparadise.com/aac-320
102+
/music play 42
103+
/music history
93104
```
94105

95106
You must be in a voice channel to use `/music play`.
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package music
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"unicode/utf8"
7+
8+
"github.com/keshon/melodix/internal/domain"
9+
)
10+
11+
// historyMaxLineBytes caps rendered line length (embed row); long track titles get middle ellipsis.
12+
const historyMaxLineBytes = 120
13+
14+
const historyMinTitleRunes = 8
15+
16+
func displayTrackTitle(raw string) string {
17+
if strings.TrimSpace(raw) == "" {
18+
return "(no title)"
19+
}
20+
return strings.TrimSpace(raw)
21+
}
22+
23+
// truncateTitleMiddle shortens s to at most maxRunes runes, inserting "..." in the middle when needed.
24+
func truncateTitleMiddle(s string, maxRunes int) string {
25+
if maxRunes < 1 {
26+
return ""
27+
}
28+
r := []rune(s)
29+
if len(r) <= maxRunes {
30+
return s
31+
}
32+
if maxRunes <= 3 {
33+
return string(r[:maxRunes])
34+
}
35+
inner := maxRunes - 3
36+
left := inner / 2
37+
right := inner - left
38+
return string(r[:left]) + "..." + string(r[len(r)-right:])
39+
}
40+
41+
func fitTitleToLineLimit(title string, build func(string) string) string {
42+
if len(build(title)) <= historyMaxLineBytes {
43+
return title
44+
}
45+
n := utf8.RuneCountInString(title)
46+
for max := n; max >= historyMinTitleRunes; max-- {
47+
short := truncateTitleMiddle(title, max)
48+
if len(build(short)) <= historyMaxLineBytes {
49+
return short
50+
}
51+
}
52+
return truncateTitleMiddle(title, historyMinTitleRunes)
53+
}
54+
55+
// historyLine: `id` [title](url) `tail` (spaces only; tail is backtick-wrapped date or ×N play count).
56+
func historyLine(id uint64, title, url, tail string) string {
57+
if url != "" {
58+
return fmt.Sprintf("`%d` [%s](%s) `%s`", id, title, url, tail)
59+
}
60+
return fmt.Sprintf("`%d` %s `%s`", id, title, tail)
61+
}
62+
63+
func formatTimelineLine(m domain.MusicPlayback) string {
64+
tail := m.PlayedAt.Format("02 Jan 2006")
65+
title := displayTrackTitle(m.Title)
66+
build := func(tt string) string {
67+
return historyLine(m.ID, tt, m.URL, tail)
68+
}
69+
title = fitTitleToLineLimit(title, build)
70+
return build(title)
71+
}
72+
73+
func formatCountsLine(r domain.PlaybackCountRow) string {
74+
tail := fmt.Sprintf("×%d", r.Count)
75+
title := displayTrackTitle(r.Title)
76+
build := func(tt string) string {
77+
return historyLine(r.RepresentativeID, tt, r.URL, tail)
78+
}
79+
title = fitTitleToLineLimit(title, build)
80+
return build(title)
81+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package music
2+
3+
import (
4+
"strings"
5+
"testing"
6+
"time"
7+
8+
"github.com/keshon/melodix/internal/domain"
9+
)
10+
11+
func TestTruncateTitleMiddle(t *testing.T) {
12+
t.Parallel()
13+
short := "abc"
14+
if got := truncateTitleMiddle(short, 10); got != short {
15+
t.Fatalf("short: %q", got)
16+
}
17+
long := "abcdefghijklmnopqrstuvwxyz0123456789"
18+
got := truncateTitleMiddle(long, 12)
19+
if len([]rune(got)) != 12 {
20+
t.Fatalf("rune len: %q len=%d", got, len([]rune(got)))
21+
}
22+
if !strings.Contains(got, "...") {
23+
t.Fatalf("expected ellipsis: %q", got)
24+
}
25+
}
26+
27+
func TestFormatTimelineLineShape(t *testing.T) {
28+
t.Parallel()
29+
m := domain.MusicPlayback{
30+
ID: 7,
31+
PlayedAt: time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC),
32+
URL: "https://x.test/a",
33+
Title: "Hi",
34+
}
35+
s := formatTimelineLine(m)
36+
if !strings.Contains(s, "`7`") || !strings.Contains(s, "[Hi]") || !strings.Contains(s, "`15 Mar 2026`") {
37+
t.Fatalf("got %q", s)
38+
}
39+
}
40+
41+
func TestFormatCountsLineNoDate(t *testing.T) {
42+
t.Parallel()
43+
r := domain.PlaybackCountRow{
44+
RepresentativeID: 9,
45+
URL: "https://y.test/b",
46+
Title: "Song",
47+
Count: 4,
48+
LastPlayed: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC),
49+
}
50+
s := formatCountsLine(r)
51+
if strings.Contains(s, "2020") || strings.Contains(s, "Jan") {
52+
t.Fatalf("counts line should not include date: %q", s)
53+
}
54+
if !strings.HasSuffix(s, "`×4`") || !strings.Contains(s, "`9`") || strings.Contains(s, "last ") {
55+
t.Fatalf("got %q", s)
56+
}
57+
}

internal/command/music/slash_music.go

Lines changed: 21 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,12 @@ func (c *MusicCommand) SlashDefinition() *discordgo.ApplicationCommand {
8787
Options: []*discordgo.ApplicationCommandOption{
8888
{
8989
Type: discordgo.ApplicationCommandOptionString,
90-
Name: "mode",
91-
Description: "Timeline (chronological) or counts grouped by URL",
90+
Name: "view",
91+
Description: "Chronological list or plays per link",
9292
Required: false,
9393
Choices: []*discordgo.ApplicationCommandOptionChoice{
94-
{Name: "Timeline (chronological)", Value: "timeline"},
95-
{Name: "Counts (by URL)", Value: "counts"},
94+
{Name: "Timeline", Value: "timeline"},
95+
{Name: "By URL", Value: "counts"},
9696
},
9797
},
9898
{
@@ -148,19 +148,19 @@ func (c *MusicCommand) Run(ctx interface{}) error {
148148
return c.runStop(s, e)
149149

150150
case "history":
151-
mode := "timeline"
151+
view := "timeline"
152152
var page int64 = 1
153153
for _, opt := range sub.Options {
154154
switch opt.Name {
155-
case "mode":
155+
case "view":
156156
if v := strings.TrimSpace(opt.StringValue()); v != "" {
157-
mode = v
157+
view = v
158158
}
159159
case "page":
160160
page = opt.IntValue()
161161
}
162162
}
163-
return c.runHistory(s, e, page, mode, store)
163+
return c.runHistory(s, e, page, view, store)
164164

165165
default:
166166
return discord.RespondEmbedEphemeral(s, e, &discordgo.MessageEmbed{
@@ -309,31 +309,10 @@ func (c *MusicCommand) runPlay(s *discordgo.Session, e *discordgo.InteractionCre
309309

310310
const historyLinesPerPage = 15
311311

312-
func formatTimelineLine(m domain.MusicPlayback) string {
313-
title := m.Title
314-
if title == "" {
315-
title = "(no title)"
316-
}
317-
t := m.PlayedAt.Format("2006-01-02 15:04")
318-
if m.URL != "" {
319-
return fmt.Sprintf("`%d` — [%s](%s) — %s", m.ID, title, m.URL, t)
320-
}
321-
return fmt.Sprintf("`%d` — %s — %s", m.ID, title, t)
322-
}
323-
324-
func formatCountsLine(r domain.PlaybackCountRow) string {
325-
title := r.Title
326-
if title == "" {
327-
title = "(no title)"
328-
}
329-
t := r.LastPlayed.Format("2006-01-02 15:04")
330-
if r.URL != "" {
331-
return fmt.Sprintf("`%d` — ×%d — [%s](%s) — last %s", r.RepresentativeID, r.Count, title, r.URL, t)
332-
}
333-
return fmt.Sprintf("`%d` — ×%d — %s — last %s", r.RepresentativeID, r.Count, title, t)
334-
}
312+
// Shown in /music history footer for replay hint (counts view uses the same sentence as timeline).
313+
const historyFooterReplay = "replay with `/music play <id>`."
335314

336-
func (c *MusicCommand) runHistory(s *discordgo.Session, e *discordgo.InteractionCreate, page int64, mode string, store *storage.Storage) error {
315+
func (c *MusicCommand) runHistory(s *discordgo.Session, e *discordgo.InteractionCreate, page int64, view string, store *storage.Storage) error {
337316
if err := s.InteractionRespond(e.Interaction, &discordgo.InteractionResponse{
338317
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
339318
}); err != nil {
@@ -375,29 +354,29 @@ func (c *MusicCommand) runHistory(s *discordgo.Session, e *discordgo.Interaction
375354
return nil
376355
}
377356

378-
mode = strings.ToLower(strings.TrimSpace(mode))
379-
if mode == "" {
380-
mode = "timeline"
357+
view = strings.ToLower(strings.TrimSpace(view))
358+
if view == "" {
359+
view = "timeline"
381360
}
382361

383362
var lines []string
384363
var totalRows int
385-
var title string
364+
var embedTitle string
386365
var footerExtra string
387366

388-
switch mode {
367+
switch view {
389368
case "counts":
390369
counts := domain.AggregatePlaybackCounts(rows)
391370
totalRows = len(counts)
392-
title = "🎵 Playback history (counts by URL)"
393-
footerExtra = "Distinct URLs; replay id is the latest play for that link."
371+
embedTitle = "🎵 Playback history (by URL)"
372+
footerExtra = historyFooterReplay
394373
for _, r := range counts {
395374
lines = append(lines, formatCountsLine(r))
396375
}
397376
default:
398377
totalRows = len(rows)
399-
title = "🎵 Playback history (timeline)"
400-
footerExtra = "Chronological; replay with `/music play <id>`."
378+
embedTitle = "🎵 Playback history (timeline)"
379+
footerExtra = "Chronological; " + historyFooterReplay
401380
for _, m := range rows {
402381
lines = append(lines, formatTimelineLine(m))
403382
}
@@ -435,7 +414,7 @@ func (c *MusicCommand) runHistory(s *discordgo.Session, e *discordgo.Interaction
435414
}
436415

437416
embed := &discordgo.MessageEmbed{
438-
Title: title,
417+
Title: embedTitle,
439418
Description: desc,
440419
Footer: &discordgo.MessageEmbedFooter{
441420
Text: fmt.Sprintf("Page %d/%d (%d rows). %s", page, totalPages, totalRows, footerExtra),

internal/discord/bot.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import (
1313
"github.com/keshon/melodix/internal/command"
1414
"github.com/keshon/melodix/internal/config"
1515
"github.com/keshon/melodix/internal/discord/voice"
16-
"github.com/keshon/melodix/internal/docs"
16+
"github.com/keshon/melodix/internal/readme"
1717
"github.com/keshon/melodix/internal/storage"
1818
)
1919

@@ -236,7 +236,7 @@ func (b *Bot) onReady(s *discordgo.Session, r *discordgo.Ready) {
236236
// Background services start once across all reconnects.
237237
b.once.Do(func() {
238238
log.Println("[INFO] Starting background services...")
239-
if err := docs.UpdateReadme(commandkit.DefaultRegistry, config.CategoryWeights); err != nil {
239+
if err := readme.UpdateReadme(commandkit.DefaultRegistry, config.CategoryWeights); err != nil {
240240
log.Println("[ERR] Failed to update README:", err)
241241
}
242242
})
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package docs
1+
package readme
22

33
import (
44
"bytes"

test.bat

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
@echo off
2+
setlocal
3+
cd /d "%~dp0"
4+
go test ./... -count=1 -timeout=120s
5+
exit /b %ERRORLEVEL%

0 commit comments

Comments
 (0)