From e2364b171ec14e976b2000b381f02668ac3e23cb Mon Sep 17 00:00:00 2001 From: DiheChen Date: Thu, 5 Nov 2026 01:02:25 +0800 Subject: [PATCH] feat: store word-by-word and translated lyrics --- db/migrations/00002_lyric_words.sql | 4 ++++ db/queries/snapshot.sql | 2 +- db/queries/tracks.sql | 9 +++++---- .../out/postgres/internal/query/snapshot.sql.go | 6 +++++- .../out/postgres/internal/query/tracks.sql.go | 17 +++++++++++++---- internal/adapter/out/postgres/snapshot.go | 2 +- internal/adapter/out/postgres/snapshot_test.go | 2 +- internal/adapter/out/postgres/tracks.go | 8 +++++--- internal/adapter/out/postgres/tracks_test.go | 16 ++++++++++------ 9 files changed, 45 insertions(+), 21 deletions(-) create mode 100644 db/migrations/00002_lyric_words.sql diff --git a/db/migrations/00002_lyric_words.sql b/db/migrations/00002_lyric_words.sql new file mode 100644 index 0000000..5970b64 --- /dev/null +++ b/db/migrations/00002_lyric_words.sql @@ -0,0 +1,4 @@ +-- +goose Up +ALTER TABLE tracks + ADD COLUMN lyric_words text, + ADD COLUMN lyric_trans text; diff --git a/db/queries/snapshot.sql b/db/queries/snapshot.sql index 87e31c9..dec0420 100644 --- a/db/queries/snapshot.sql +++ b/db/queries/snapshot.sql @@ -5,7 +5,7 @@ UPDATE rooms SET term = term + 1 WHERE id = @id RETURNING *; SELECT e.entry_id, e.slot, e.source, e.track_id, e.title, e.artist, e.album, e.cover_url, e.cover_key, e.page_url, e.meta_duration_ms, e.by_member_id, e.by_name, e.by_avatar_hash, e.auto, e.likes, e.round, e.added_at, e.started_at, t.title AS track_title, t.artist AS track_artist, t.album AS track_album, t.cover_url AS track_cover_url, - t.cover_key AS track_cover_key, t.page_url AS track_page_url, t.duration_ms, t.gain, t.lyric + t.cover_key AS track_cover_key, t.page_url AS track_page_url, t.duration_ms, t.gain, t.lyric, t.lyric_words, t.lyric_trans FROM room_entries e LEFT JOIN tracks t ON t.source = e.source AND t.track_id = e.track_id WHERE e.room_id = @room_id diff --git a/db/queries/tracks.sql b/db/queries/tracks.sql index c38916b..d9a571c 100644 --- a/db/queries/tracks.sql +++ b/db/queries/tracks.sql @@ -1,13 +1,14 @@ -- name: PreparedTrack :one -SELECT title, artist, album, cover_url, cover_key, page_url, duration_ms, gain, lyric, media_key, media_size FROM tracks +SELECT title, artist, album, cover_url, cover_key, page_url, duration_ms, gain, lyric, lyric_words, lyric_trans, media_key, media_size FROM tracks WHERE source = @source AND track_id = @track_id; -- name: SavePreparedTrack :exec -- 准备任务只对未完成的任务去重,同一首歌可能先后准备两次,以后一次的结果为准。 -INSERT INTO tracks (source, track_id, title, artist, album, cover_url, cover_key, page_url, duration_ms, gain, lyric, media_key, media_size) -VALUES (@source, @track_id, @title, @artist, @album, @cover_url, @cover_key, @page_url, @duration_ms, @gain, @lyric, @media_key, @media_size) +INSERT INTO tracks (source, track_id, title, artist, album, cover_url, cover_key, page_url, duration_ms, gain, lyric, lyric_words, lyric_trans, media_key, media_size) +VALUES (@source, @track_id, @title, @artist, @album, @cover_url, @cover_key, @page_url, @duration_ms, @gain, @lyric, @lyric_words, @lyric_trans, @media_key, @media_size) ON CONFLICT (source, track_id) DO UPDATE SET title = excluded.title, artist = excluded.artist, album = excluded.album, cover_url = excluded.cover_url, cover_key = excluded.cover_key, page_url = excluded.page_url, duration_ms = excluded.duration_ms, gain = excluded.gain, - lyric = excluded.lyric, media_key = excluded.media_key, media_size = excluded.media_size, + lyric = excluded.lyric, lyric_words = excluded.lyric_words, lyric_trans = excluded.lyric_trans, + media_key = excluded.media_key, media_size = excluded.media_size, prepared_at = now(); diff --git a/internal/adapter/out/postgres/internal/query/snapshot.sql.go b/internal/adapter/out/postgres/internal/query/snapshot.sql.go index 7c4b253..ea67407 100644 --- a/internal/adapter/out/postgres/internal/query/snapshot.sql.go +++ b/internal/adapter/out/postgres/internal/query/snapshot.sql.go @@ -92,7 +92,7 @@ const roomEntries = `-- name: RoomEntries :many SELECT e.entry_id, e.slot, e.source, e.track_id, e.title, e.artist, e.album, e.cover_url, e.cover_key, e.page_url, e.meta_duration_ms, e.by_member_id, e.by_name, e.by_avatar_hash, e.auto, e.likes, e.round, e.added_at, e.started_at, t.title AS track_title, t.artist AS track_artist, t.album AS track_album, t.cover_url AS track_cover_url, - t.cover_key AS track_cover_key, t.page_url AS track_page_url, t.duration_ms, t.gain, t.lyric + t.cover_key AS track_cover_key, t.page_url AS track_page_url, t.duration_ms, t.gain, t.lyric, t.lyric_words, t.lyric_trans FROM room_entries e LEFT JOIN tracks t ON t.source = e.source AND t.track_id = e.track_id WHERE e.room_id = $1 @@ -128,6 +128,8 @@ type RoomEntriesRow struct { DurationMs *int64 Gain *float64 Lyric *string + LyricWords *string + LyricTrans *string } func (q *Queries) RoomEntries(ctx context.Context, roomID string) ([]RoomEntriesRow, error) { @@ -168,6 +170,8 @@ func (q *Queries) RoomEntries(ctx context.Context, roomID string) ([]RoomEntries &i.DurationMs, &i.Gain, &i.Lyric, + &i.LyricWords, + &i.LyricTrans, ); err != nil { return nil, err } diff --git a/internal/adapter/out/postgres/internal/query/tracks.sql.go b/internal/adapter/out/postgres/internal/query/tracks.sql.go index 8287936..007b993 100644 --- a/internal/adapter/out/postgres/internal/query/tracks.sql.go +++ b/internal/adapter/out/postgres/internal/query/tracks.sql.go @@ -10,7 +10,7 @@ import ( ) const preparedTrack = `-- name: PreparedTrack :one -SELECT title, artist, album, cover_url, cover_key, page_url, duration_ms, gain, lyric, media_key, media_size FROM tracks +SELECT title, artist, album, cover_url, cover_key, page_url, duration_ms, gain, lyric, lyric_words, lyric_trans, media_key, media_size FROM tracks WHERE source = $1 AND track_id = $2 ` @@ -29,6 +29,8 @@ type PreparedTrackRow struct { DurationMs int64 Gain float64 Lyric *string + LyricWords *string + LyricTrans *string MediaKey *string MediaSize *int64 } @@ -46,6 +48,8 @@ func (q *Queries) PreparedTrack(ctx context.Context, arg PreparedTrackParams) (P &i.DurationMs, &i.Gain, &i.Lyric, + &i.LyricWords, + &i.LyricTrans, &i.MediaKey, &i.MediaSize, ) @@ -53,12 +57,13 @@ func (q *Queries) PreparedTrack(ctx context.Context, arg PreparedTrackParams) (P } const savePreparedTrack = `-- name: SavePreparedTrack :exec -INSERT INTO tracks (source, track_id, title, artist, album, cover_url, cover_key, page_url, duration_ms, gain, lyric, media_key, media_size) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) +INSERT INTO tracks (source, track_id, title, artist, album, cover_url, cover_key, page_url, duration_ms, gain, lyric, lyric_words, lyric_trans, media_key, media_size) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) ON CONFLICT (source, track_id) DO UPDATE SET title = excluded.title, artist = excluded.artist, album = excluded.album, cover_url = excluded.cover_url, cover_key = excluded.cover_key, page_url = excluded.page_url, duration_ms = excluded.duration_ms, gain = excluded.gain, - lyric = excluded.lyric, media_key = excluded.media_key, media_size = excluded.media_size, + lyric = excluded.lyric, lyric_words = excluded.lyric_words, lyric_trans = excluded.lyric_trans, + media_key = excluded.media_key, media_size = excluded.media_size, prepared_at = now() ` @@ -74,6 +79,8 @@ type SavePreparedTrackParams struct { DurationMs int64 Gain float64 Lyric *string + LyricWords *string + LyricTrans *string MediaKey *string MediaSize *int64 } @@ -92,6 +99,8 @@ func (q *Queries) SavePreparedTrack(ctx context.Context, arg SavePreparedTrackPa arg.DurationMs, arg.Gain, arg.Lyric, + arg.LyricWords, + arg.LyricTrans, arg.MediaKey, arg.MediaSize, ) diff --git a/internal/adapter/out/postgres/snapshot.go b/internal/adapter/out/postgres/snapshot.go index de68f37..b2e70e5 100644 --- a/internal/adapter/out/postgres/snapshot.go +++ b/internal/adapter/out/postgres/snapshot.go @@ -153,7 +153,7 @@ func entryOf(r query.RoomEntriesRow) room.Entry { // 条目中仍是点歌时的元数据,比如链接曲目的标题仍是链接本身。 if r.DurationMs != nil { e.Ready = true - e.Meta, e.Prep = prepared(*r.TrackTitle, orEmpty(r.TrackArtist), orEmpty(r.TrackAlbum), orEmpty(r.TrackCoverURL), orEmpty(r.TrackCoverKey), *r.TrackPageURL, *r.DurationMs, *r.Gain, orEmpty(r.Lyric)) + e.Meta, e.Prep = prepared(*r.TrackTitle, orEmpty(r.TrackArtist), orEmpty(r.TrackAlbum), orEmpty(r.TrackCoverURL), orEmpty(r.TrackCoverKey), *r.TrackPageURL, *r.DurationMs, *r.Gain, orEmpty(r.Lyric), orEmpty(r.LyricWords), orEmpty(r.LyricTrans)) } return e } diff --git a/internal/adapter/out/postgres/snapshot_test.go b/internal/adapter/out/postgres/snapshot_test.go index 5bd02b5..41bb7c8 100644 --- a/internal/adapter/out/postgres/snapshot_test.go +++ b/internal/adapter/out/postgres/snapshot_test.go @@ -72,7 +72,7 @@ func TestSavedSnapshotIsRestored(t *testing.T) { id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"}) term, _ := takeRoom(t, store, id) - playing := track.Prepared{Duration: 3 * time.Minute, Gain: 0.8, Lyric: "[00:00.00]歌词"} + playing := track.Prepared{Duration: 3 * time.Minute, Gain: 0.8, Lyric: "[00:00.00]歌词", LyricWords: "[0,500](0,250,0)歌(250,250,0)词", LyricTrans: "[00:00.00]Lyrics"} queued := track.Prepared{Duration: 2*time.Minute + 500*time.Millisecond, Gain: 1.2} link := track.Track{Source: track.Link, ID: "https://example.com/a.mp3"} meta := track.Meta{Title: "歌", Artist: "人", Album: "专辑", CoverURL: "https://p.example/c.jpg", PageURL: "https://music.163.com/#/song?id=1", Duration: playing.Duration} diff --git a/internal/adapter/out/postgres/tracks.go b/internal/adapter/out/postgres/tracks.go index 6df0a9c..730d5cb 100644 --- a/internal/adapter/out/postgres/tracks.go +++ b/internal/adapter/out/postgres/tracks.go @@ -23,7 +23,7 @@ func (s *Store) PreparedTrack(ctx context.Context, t track.Track) (port.Prepared if err != nil { return port.PreparedTrack{}, false, fmt.Errorf("读取曲目 %s %s 的准备结果:%w", t.Source, t.ID, err) } - meta, result := prepared(row.Title, orEmpty(row.Artist), orEmpty(row.Album), orEmpty(row.CoverURL), orEmpty(row.CoverKey), row.PageURL, row.DurationMs, row.Gain, orEmpty(row.Lyric)) + meta, result := prepared(row.Title, orEmpty(row.Artist), orEmpty(row.Album), orEmpty(row.CoverURL), orEmpty(row.CoverKey), row.PageURL, row.DurationMs, row.Gain, orEmpty(row.Lyric), orEmpty(row.LyricWords), orEmpty(row.LyricTrans)) return port.PreparedTrack{Meta: meta, Result: result, MediaKey: orEmpty(row.MediaKey), MediaSize: orZero(row.MediaSize)}, true, nil } @@ -40,6 +40,8 @@ func (s *Store) SavePreparedTrack(ctx context.Context, t track.Track, p port.Pre DurationMs: p.Result.Duration.Milliseconds(), Gain: p.Result.Gain, Lyric: nullable(p.Result.Lyric), + LyricWords: nullable(p.Result.LyricWords), + LyricTrans: nullable(p.Result.LyricTrans), } if p.MediaKey != "" { params.MediaKey, params.MediaSize = &p.MediaKey, &p.MediaSize @@ -50,10 +52,10 @@ func (s *Store) SavePreparedTrack(ctx context.Context, t track.Track, p port.Pre return nil } -func prepared(title, artist, album, coverURL, coverKey, pageURL string, durationMs int64, gain float64, lyric string) (track.Meta, track.Prepared) { +func prepared(title, artist, album, coverURL, coverKey, pageURL string, durationMs int64, gain float64, lyric, lyricWords, lyricTrans string) (track.Meta, track.Prepared) { d := time.Duration(durationMs) * time.Millisecond return track.Meta{Title: title, Artist: artist, Album: album, CoverURL: coverURL, CoverKey: track.ObjectKey(coverKey), PageURL: pageURL, Duration: d}, - track.Prepared{Duration: d, Gain: gain, Lyric: lyric} + track.Prepared{Duration: d, Gain: gain, Lyric: lyric, LyricWords: lyricWords, LyricTrans: lyricTrans} } func orZero(p *int64) int64 { diff --git a/internal/adapter/out/postgres/tracks_test.go b/internal/adapter/out/postgres/tracks_test.go index 3a1335c..9b65fbc 100644 --- a/internal/adapter/out/postgres/tracks_test.go +++ b/internal/adapter/out/postgres/tracks_test.go @@ -21,7 +21,10 @@ func TestPreparedTrackRoundTrip(t *testing.T) { Title: "晴天", Artist: "周杰伦", Album: "叶惠美", CoverURL: "https://y.gtimg.cn/c.jpg", PageURL: "https://y.qq.com/n/ryqq/songDetail/0039MnYb0qxYhV", Duration: 269 * time.Second, }, - Result: track.Prepared{Duration: 269500 * time.Millisecond, Gain: 0.75, Lyric: "[00:01.00]故事的小黄花"}, + Result: track.Prepared{ + Duration: 269500 * time.Millisecond, Gain: 0.75, Lyric: "[00:01.00]故事的小黄花", + LyricWords: "[1000,500](1000,250,0)故(1250,250,0)事", LyricTrans: "[00:01.00]The story", + }, } if err := store.SavePreparedTrack(t.Context(), song, saved); err != nil { t.Fatal(err) @@ -40,12 +43,12 @@ func TestPreparedTrackRoundTrip(t *testing.T) { func TestSavePreparedTrackOverwritesResult(t *testing.T) { store, pool := newStore(t) song := netease("1") - addTrack(t, store, song, track.Meta{Title: "旧"}, track.Prepared{Duration: time.Minute, Gain: 1}) + addTrack(t, store, song, track.Meta{Title: "旧"}, track.Prepared{Duration: time.Minute, Gain: 1, LyricWords: "旧逐字", LyricTrans: "旧翻译"}) id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"}) term, snap := takeRoom(t, store, id) saveRoom(t, store, term, snap, room.RecordPlay{Entry: room.Entry{ID: 1, Track: song, By: alice}, StartedAt: t0, EndedAt: t0.Add(time.Minute)}) - again := port.PreparedTrack{Meta: track.Meta{Title: "新"}, Result: track.Prepared{Duration: 2 * time.Minute, Gain: 0.5, Lyric: "词"}} + again := port.PreparedTrack{Meta: track.Meta{Title: "新"}, Result: track.Prepared{Duration: 2 * time.Minute, Gain: 0.5, Lyric: "词", LyricWords: "新逐字", LyricTrans: "新翻译"}} addTrack(t, store, song, again.Meta, again.Result) got, _, err := store.PreparedTrack(t.Context(), song) if err != nil { @@ -75,13 +78,14 @@ func TestPreparedTrackStoresMissingValuesAsNull(t *testing.T) { } var missing string err := pool.QueryRow(t.Context(), `SELECT concat_ws(',', CASE WHEN artist IS NULL THEN 'artist' END, CASE WHEN album IS NULL THEN 'album' END, - CASE WHEN cover_url IS NULL THEN 'cover_url' END, CASE WHEN cover_key IS NULL THEN 'cover_key' END, CASE WHEN lyric IS NULL THEN 'lyric' END) + CASE WHEN cover_url IS NULL THEN 'cover_url' END, CASE WHEN cover_key IS NULL THEN 'cover_key' END, CASE WHEN lyric IS NULL THEN 'lyric' END, + CASE WHEN lyric_words IS NULL THEN 'lyric_words' END, CASE WHEN lyric_trans IS NULL THEN 'lyric_trans' END) FROM tracks WHERE track_id = '1'`).Scan(&missing) if err != nil { t.Fatal(err) } - if missing != "artist,album,cover_url,cover_key,lyric" { - t.Errorf("为 NULL 的列 = %s,期望 artist,album,cover_url,cover_key,lyric", missing) + if want := "artist,album,cover_url,cover_key,lyric,lyric_words,lyric_trans"; missing != want { + t.Errorf("为 NULL 的列 = %s,期望 %s", missing, want) } if got, ok, err := store.PreparedTrack(t.Context(), song); err != nil || !ok || got != saved { t.Errorf("读出 %#v, %v, %v\n期望 %#v", got, ok, err, saved)