feat: page, sort and search stored media and delete several at once in the admin API

This commit is contained in:
DiheChen committed 2026-11-11 10:58:32 +08:00
1 parent c39331aa75
commit 599f1284ec
23 files changed
+1161 -213

No files matched your search

+13 -4
View File
@@ -1,10 +1,19 @@
-- name: ListStoredMedia :many
SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks
WHERE media_key IS NOT NULL
ORDER BY prepared_at DESC, track_id;
-- 曲目 ID 按字节比较,排序不随数据库的 locale 变化。
SELECT media_key::text AS media_key, source, track_id, title, artist, media_size::bigint AS media_size, cover_url, cover_key, prepared_at, last_played_at FROM tracks
WHERE media_key IS NOT NULL AND title ILIKE '%' || @pattern::text || '%' ESCAPE '\'
ORDER BY CASE WHEN @sort::text = 'stored' THEN prepared_at END DESC,
CASE WHEN @sort::text = 'idle' THEN last_played_at END ASC NULLS FIRST,
CASE WHEN @sort::text = 'size' THEN media_size END DESC,
track_id COLLATE "C"
LIMIT @page_size::bigint OFFSET @skip::bigint;
-- name: CountStoredMedia :one
SELECT count(*) AS total, coalesce(sum(media_size), 0)::bigint AS total_bytes FROM tracks
WHERE media_key IS NOT NULL AND title ILIKE '%' || @pattern::text || '%' ESCAPE '\';
-- name: StoredMedia :one
SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks
SELECT media_key::text AS media_key, source, track_id, title, artist, media_size::bigint AS media_size, cover_url, cover_key, prepared_at, last_played_at FROM tracks
WHERE media_key = @media_key::text;
-- name: DeleteStoredMedia :exec
+1 -3
View File
@@ -160,7 +160,7 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
srv.handle(mux, protocol.RouteAdminSetGithubCredential, srv.adminSetGithubCredential)
srv.handle(mux, protocol.RouteAdminDeleteGithubCredential, srv.adminDeleteGithubCredential)
srv.handle(mux, protocol.RouteAdminListMedia, srv.adminListMedia)
srv.handle(mux, protocol.RouteAdminDeleteMedia, srv.adminDeleteMedia)
srv.handle(mux, protocol.RouteAdminRemoveMedia, srv.adminRemoveMedia)
srv.handle(mux, protocol.RouteAdminUpdateSite, srv.adminUpdateSite)
srv.handle(mux, protocol.RouteAdminOpenRegistration, srv.adminOpenRegistration)
srv.handle(mux, protocol.RouteAdminCloseRegistration, srv.adminCloseRegistration)
@@ -326,9 +326,7 @@ var knownErrors = []struct {
{errUnreadableBody, protocol.ErrorCodeInvalidArgument},
{port.ErrRoomNotFound, protocol.ErrorCodeNotFound},
{port.ErrSlugTaken, protocol.ErrorCodeConflict},
{media.ErrNotFound, protocol.ErrorCodeNotFound},
{media.ErrNotInRoom, protocol.ErrorCodeNotFound},
{media.ErrInUse, protocol.ErrorCodeConflict},
{identity.ErrInvalidMemberToken, protocol.ErrorCodeUnauthorized},
{identity.ErrWrongPassword, protocol.ErrorCodeUnauthorized},
{admin.ErrInvalidToken, protocol.ErrorCodeUnauthorized},
+48 -14
View File
@@ -3,10 +3,9 @@ package api
import (
"context"
"net/http"
"regexp"
"time"
"github.com/dihemoe/alisten/internal/app/media"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
"github.com/dihemoe/alisten/protocol"
)
@@ -43,33 +42,68 @@ func (s *server) redirectTarget(ctx context.Context, r *http.Request, t track.Tr
return s.Playback.URL(ctx, t, time.Now())
}
var mediaKeyPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
var mediaSorts = map[protocol.MediaSort]port.MediaSort{
protocol.MediaSortStored: port.MediaByStored,
protocol.MediaSortIdle: port.MediaByIdle,
protocol.MediaSortSize: port.MediaBySize,
}
func (s *server) adminListMedia(ctx context.Context, _ call) (any, error) {
list, err := s.Media.List(ctx)
func (s *server) adminListMedia(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeAdminListMediaRequest(c.body)
if err != nil {
return nil, err
}
items := make([]protocol.StoredMedia, len(list))
for i, m := range list {
q := port.MediaQuery{Page: 1, Sort: port.MediaByStored}
if req.Page != nil {
q.Page = int(*req.Page)
}
if req.Sort != nil {
q.Sort = mediaSorts[*req.Sort]
}
if req.Q != nil {
q.Title = *req.Q
}
page, err := s.Media.List(ctx, q)
if err != nil {
return nil, err
}
items := make([]protocol.StoredMedia, len(page.Items))
for i, m := range page.Items {
link := m.Link
// 后台把 link 当作链接打开,B 站的歌给出视频页面。
if m.Source == track.Bilibili {
link = track.BilibiliPageURL(m.Link)
}
items[i] = protocol.StoredMedia{Key: m.Key, Source: protocol.Source(m.Source), Link: link, Title: m.Title, Size: m.Size, StoredAtMs: m.StoredAt.UnixMilli()}
items[i] = protocol.StoredMedia{
Key: m.Key,
Source: protocol.Source(m.Source),
Link: link,
Title: m.Title,
Artist: m.Artist,
CoverURL: s.coverURL(m.CoverURL, m.CoverKey),
Size: m.Size,
StoredAtMs: m.StoredAt.UnixMilli(),
}
if m.LastPlayedAt != nil {
played := m.LastPlayedAt.UnixMilli()
items[i].LastPlayedAtMs = &played
}
}
return protocol.AdminListMediaResponse{Items: items}, nil
return protocol.AdminListMediaResponse{Items: items, Total: int64(page.Total), TotalBytes: page.TotalBytes, Page: int64(q.Page), PageSize: port.MediaPageSize}, nil
}
func (s *server) adminDeleteMedia(ctx context.Context, c call) (any, error) {
key := c.r.PathValue("key")
if !mediaKeyPattern.MatchString(key) {
return nil, media.ErrNotFound
func (s *server) adminRemoveMedia(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeAdminRemoveMediaRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.Media.Delete(ctx, key)
keys := make([]string, len(req.Media))
for i, m := range req.Media {
keys[i] = m.Key
}
deleted, inUse, err := s.Media.DeleteMany(ctx, keys)
if err != nil {
return nil, err
}
return protocol.AdminRemoveMediaResponse{Deleted: deleted, InUse: inUse}, nil
}
+44 -44
View File
@@ -24,63 +24,63 @@ import (
"github.com/dihemoe/alisten/protocol"
)
type storedMedia struct {
items []port.StoredMedia
used bool
}
func (m *storedMedia) ListStoredMedia(context.Context) ([]port.StoredMedia, error) {
return m.items, nil
}
func (m *storedMedia) StoredMedia(_ context.Context, key string) (port.StoredMedia, bool, error) {
for _, item := range m.items {
if item.Key == key {
return item, true, nil
}
}
return port.StoredMedia{}, false, nil
}
func (m *storedMedia) DeleteStoredMedia(context.Context, string) error {
m.items = nil
return nil
}
func (m *storedMedia) TrackInUse(context.Context, track.Track) (bool, error) {
return m.used, nil
}
func TestAdminMedia(t *testing.T) {
key := media.Key("https://example.com/a.mp3")
other := media.Key("https://example.com/b.mp3")
video := media.Key("BV1xx411c7mD/2")
played := time.UnixMilli(1760000000456)
stored := &storedMedia{items: []port.StoredMedia{
{Key: key, Source: track.Link, Link: "https://example.com/a.mp3", Title: "晴天", Size: 12290, StoredAt: time.UnixMilli(1760000000123), LastPlayedAt: &played},
{Key: media.Key("https://example.com/b.mp3"), Source: track.Link, Link: "https://example.com/b.mp3", Title: "七里香", Size: 1, StoredAt: time.UnixMilli(1760000000789)},
{Key: video, Source: track.Bilibili, Link: "BV1xx411c7mD/2", Title: "晴天(钢琴版)", Size: 2, StoredAt: time.UnixMilli(1760000000999)},
}}
library := porttest.NewMediaLibrary(
port.StoredMedia{Key: key, Source: track.Link, Link: "https://example.com/a.mp3", Title: "晴天", Artist: "周杰伦", Size: 12290, CoverKey: track.ObjectKey("link/" + key + "/cover.jpg"), StoredAt: time.UnixMilli(1760000000123)},
port.StoredMedia{Key: other, Source: track.Link, Link: "https://example.com/b.mp3", Title: "七里香", Size: 1, CoverURL: "https://example.com/b.jpg", StoredAt: time.UnixMilli(1760000000789), LastPlayedAt: &played},
port.StoredMedia{Key: video, Source: track.Bilibili, Link: "BV1xx411c7mD/2", Title: "晴天(钢琴版)", Artist: "某个 UP 主", Size: 2, StoredAt: time.UnixMilli(1760000000999)},
)
store := porttest.New()
s := services(store, store, "admin-secret")
s.Media = media.NewLibrary(stored, stored, porttest.NewMediaStore())
s.StorageURL = "https://alisten.example/storage/alisten"
s.Media = media.NewLibrary(library, inRooms{{Source: track.Bilibili, ID: "BV1xx411c7mD/2"}}, porttest.NewMediaStore())
server, _ := start(t, s)
admin := []string{"Authorization", "Bearer admin-secret"}
want := response{status: http.StatusOK, body: `{"items":[` +
`{"key":"` + key + `","lastPlayedAtMs":1760000000456,"link":"https://example.com/a.mp3","size":12290,"source":"link","storedAtMs":1760000000123,"title":"晴天"},` +
`{"key":"` + media.Key("https://example.com/b.mp3") + `","lastPlayedAtMs":null,"link":"https://example.com/b.mp3","size":1,"source":"link","storedAtMs":1760000000789,"title":"七里香"},` +
`{"key":"` + video + `","lastPlayedAtMs":null,"link":"https://www.bilibili.com/video/BV1xx411c7mD?p=2","size":2,"source":"bilibili","storedAtMs":1760000000999,"title":"晴天(钢琴版)"}]}`}
if got := send(t, server, http.MethodGet, "/api/admin/media", "", admin...); got != want {
t.Errorf("列出已存储的媒体 = %#v\n期望 %#v", got, want)
`{"artist":"某个 UP 主","coverUrl":"","key":"` + video + `","lastPlayedAtMs":null,"link":"https://www.bilibili.com/video/BV1xx411c7mD?p=2","size":2,"source":"bilibili","storedAtMs":1760000000999,"title":"晴天(钢琴版)"},` +
`{"artist":"","coverUrl":"https://example.com/b.jpg","key":"` + other + `","lastPlayedAtMs":1760000000456,"link":"https://example.com/b.mp3","size":1,"source":"link","storedAtMs":1760000000789,"title":"七里香"},` +
`{"artist":"周杰伦","coverUrl":"https://alisten.example/storage/alisten/link/` + key + `/cover.jpg","key":"` + key + `","lastPlayedAtMs":null,"link":"https://example.com/a.mp3","size":12290,"source":"link","storedAtMs":1760000000123,"title":"晴天"}],` +
`"page":1,"pageSize":50,"total":3,"totalBytes":12293}`}
if got := send(t, server, http.MethodPost, "/api/admin/media/queries", "{}", admin...); got != want {
t.Errorf("按默认参数列出已存储的媒体 = %#v\n期望 %#v", got, want)
}
for _, tt := range []struct {
body string
page int64
want []string
total, size int64
}{
{`{"sort": "stored"}`, 1, []string{video, other, key}, 3, 12293},
{`{"sort": "idle"}`, 1, []string{video, key, other}, 3, 12293},
{`{"sort": "size", "q": "晴天"}`, 1, []string{key, video}, 2, 12292},
{`{"page": 2}`, 2, []string{}, 3, 12293},
} {
got := send(t, server, http.MethodPost, "/api/admin/media/queries", tt.body, admin...)
var page protocol.AdminListMediaResponse
if err := json.Unmarshal([]byte(got.body), &page); err != nil {
t.Fatalf("%s = %#v", tt.body, got)
}
keys := make([]string, len(page.Items))
for i, m := range page.Items {
keys[i] = m.Key
}
if got.status != http.StatusOK || !slices.Equal(keys, tt.want) || page.Page != tt.page || page.Total != tt.total || page.TotalBytes != tt.size {
t.Errorf("%s = %#v,期望第 %d 页 %q,共 %d 首 %d 字节", tt.body, got, tt.page, tt.want, tt.total, tt.size)
}
}
stored.used = true
if got, want := send(t, server, http.MethodDelete, "/api/admin/media/"+key, "", admin...), failure(protocol.ErrorCodeConflict, "这首歌还在房间的播放列表中,不能删除"); got != want {
t.Errorf("删除仍被房间引用的媒体 = %#v,期望 %#v", got, want)
if got := send(t, server, http.MethodPost, "/api/admin/media/removals", `{"media": [{"key": "`+key+`"}, {"key": "not-a-key"}]}`, admin...); got.status != http.StatusBadRequest {
t.Errorf("批量删除里有格式不对的 key = %#v,期望 400、一首也不删", got)
}
if got, want := send(t, server, http.MethodPost, "/api/admin/media/removals", `{"media": [{"key": "`+video+`"}, {"key": "`+key+`"}, {"key": "`+media.Key("https://example.com/missing.mp3")+`"}]}`, admin...),
(response{status: http.StatusOK, body: `{"deleted":["` + key + `"],"inUse":["` + video + `"]}`}); got != want {
t.Errorf("批量删除 = %#v,期望 %#v", got, want)
}
stored.used = false
resp, header := exchange(t, server, http.MethodDelete, "/api/admin/media/"+key, "", admin...)
checkNoContent(t, "删除媒体", resp, header)
}
type playURL map[string]string
@@ -10,6 +10,23 @@ import (
"time"
)
const countStoredMedia = `-- name: CountStoredMedia :one
SELECT count(*) AS total, coalesce(sum(media_size), 0)::bigint AS total_bytes FROM tracks
WHERE media_key IS NOT NULL AND title ILIKE '%' || $1::text || '%' ESCAPE '\'
`
type CountStoredMediaRow struct {
Total int64
TotalBytes int64
}
func (q *Queries) CountStoredMedia(ctx context.Context, pattern string) (CountStoredMediaRow, error) {
row := q.db.QueryRow(ctx, countStoredMedia, pattern)
var i CountStoredMediaRow
err := row.Scan(&i.Total, &i.TotalBytes)
return i, err
}
const deleteStoredMedia = `-- name: DeleteStoredMedia :exec
DELETE FROM tracks WHERE media_key = $1::text
`
@@ -20,24 +37,43 @@ func (q *Queries) DeleteStoredMedia(ctx context.Context, mediaKey string) error
}
const listStoredMedia = `-- name: ListStoredMedia :many
SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks
WHERE media_key IS NOT NULL
ORDER BY prepared_at DESC, track_id
SELECT media_key::text AS media_key, source, track_id, title, artist, media_size::bigint AS media_size, cover_url, cover_key, prepared_at, last_played_at FROM tracks
WHERE media_key IS NOT NULL AND title ILIKE '%' || $1::text || '%' ESCAPE '\'
ORDER BY CASE WHEN $2::text = 'stored' THEN prepared_at END DESC,
CASE WHEN $2::text = 'idle' THEN last_played_at END ASC NULLS FIRST,
CASE WHEN $2::text = 'size' THEN media_size END DESC,
track_id COLLATE "C"
LIMIT $4::bigint OFFSET $3::bigint
`
type ListStoredMediaParams struct {
Pattern string
Sort string
Skip int64
PageSize int64
}
type ListStoredMediaRow struct {
MediaKey string
Source string
TrackID string
Title string
Artist *string
MediaSize int64
CoverURL *string
CoverKey *string
PreparedAt time.Time
LastPlayedAt *time.Time
}
func (q *Queries) ListStoredMedia(ctx context.Context) ([]ListStoredMediaRow, error) {
rows, err := q.db.Query(ctx, listStoredMedia)
// 曲目 ID 按字节比较,排序不随数据库的 locale 变化。
func (q *Queries) ListStoredMedia(ctx context.Context, arg ListStoredMediaParams) ([]ListStoredMediaRow, error) {
rows, err := q.db.Query(ctx, listStoredMedia,
arg.Pattern,
arg.Sort,
arg.Skip,
arg.PageSize,
)
if err != nil {
return nil, err
}
@@ -50,7 +86,9 @@ func (q *Queries) ListStoredMedia(ctx context.Context) ([]ListStoredMediaRow, er
&i.Source,
&i.TrackID,
&i.Title,
&i.Artist,
&i.MediaSize,
&i.CoverURL,
&i.CoverKey,
&i.PreparedAt,
&i.LastPlayedAt,
@@ -66,7 +104,7 @@ func (q *Queries) ListStoredMedia(ctx context.Context) ([]ListStoredMediaRow, er
}
const storedMedia = `-- name: StoredMedia :one
SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks
SELECT media_key::text AS media_key, source, track_id, title, artist, media_size::bigint AS media_size, cover_url, cover_key, prepared_at, last_played_at FROM tracks
WHERE media_key = $1::text
`
@@ -75,7 +113,9 @@ type StoredMediaRow struct {
Source string
TrackID string
Title string
Artist *string
MediaSize int64
CoverURL *string
CoverKey *string
PreparedAt time.Time
LastPlayedAt *time.Time
@@ -89,7 +129,9 @@ func (q *Queries) StoredMedia(ctx context.Context, mediaKey string) (StoredMedia
&i.Source,
&i.TrackID,
&i.Title,
&i.Artist,
&i.MediaSize,
&i.CoverURL,
&i.CoverKey,
&i.PreparedAt,
&i.LastPlayedAt,
+40 -8
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -15,16 +16,45 @@ import (
var _ port.MediaLibrary = (*Store)(nil)
func (s *Store) ListStoredMedia(ctx context.Context) ([]port.StoredMedia, error) {
rows, err := s.q.ListStoredMedia(ctx)
var mediaSorts = map[port.MediaSort]string{port.MediaByStored: "stored", port.MediaByIdle: "idle", port.MediaBySize: "size"}
// 搜索的文字按字面匹配:LIKE 的通配符和转义符都要转义。
var likeEscaper = strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
func (s *Store) ListStoredMedia(ctx context.Context, q port.MediaQuery) (port.MediaPage, error) {
pattern := likeEscaper.Replace(q.Title)
var page port.MediaPage
// 总数和这一页在同一个快照里读,两者才对得上。
err := pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error {
qtx := s.q.WithTx(tx)
count, err := qtx.CountStoredMedia(ctx, pattern)
if err != nil {
return err
}
page.Total, page.TotalBytes = int(count.Total), count.TotalBytes
// 页码来自客户端,可能大到算偏移量时溢出;超出最后一页时也不必再查。
if int64(q.Page-1) > count.Total/port.MediaPageSize {
return nil
}
rows, err := qtx.ListStoredMedia(ctx, query.ListStoredMediaParams{
Pattern: pattern,
Sort: mediaSorts[q.Sort],
Skip: int64(q.Page-1) * port.MediaPageSize,
PageSize: port.MediaPageSize,
})
if err != nil {
return err
}
page.Items = make([]port.StoredMedia, len(rows))
for i, r := range rows {
page.Items[i] = storedMedia(query.StoredMediaRow(r))
}
return nil
})
if err != nil {
return nil, fmt.Errorf("读取已存储的媒体:%w", err)
return port.MediaPage{}, fmt.Errorf("读取已存储的媒体:%w", err)
}
media := make([]port.StoredMedia, len(rows))
for i, r := range rows {
media[i] = storedMedia(query.StoredMediaRow(r))
}
return media, nil
return page, nil
}
func (s *Store) StoredMedia(ctx context.Context, key string) (port.StoredMedia, bool, error) {
@@ -51,7 +81,9 @@ func storedMedia(r query.StoredMediaRow) port.StoredMedia {
Source: track.Source(r.Source),
Link: r.TrackID,
Title: r.Title,
Artist: orEmpty(r.Artist),
Size: r.MediaSize,
CoverURL: orEmpty(r.CoverURL),
CoverKey: track.ObjectKey(orEmpty(r.CoverKey)),
StoredAt: r.PreparedAt.UTC(),
LastPlayedAt: utc(r.LastPlayedAt),
+122 -12
View File
@@ -1,7 +1,12 @@
package postgres
import (
"cmp"
"fmt"
"math"
"reflect"
"slices"
"strings"
"testing"
"time"
@@ -10,10 +15,11 @@ import (
"github.com/dihemoe/alisten/internal/domain/track"
)
func saveStored(t *testing.T, store *Store, tr track.Track, pageURL, key, cover string, size int64) {
func saveStored(t *testing.T, store *Store, tr track.Track, pageURL, key string, meta track.Meta, size int64) {
t.Helper()
meta.Title, meta.PageURL = "标题 "+key, pageURL
p := port.PreparedTrack{
Meta: track.Meta{Title: "标题 " + key, CoverKey: track.ObjectKey(cover), PageURL: pageURL},
Meta: meta,
Result: track.Prepared{Duration: time.Minute, Gain: 1},
MediaKey: key,
MediaSize: size,
@@ -27,9 +33,9 @@ func TestStoredMedia(t *testing.T) {
store, _ := newStore(t)
a := track.Track{Source: track.Link, ID: "https://example.com/a.mp3"}
video := track.Track{Source: track.Bilibili, ID: "BV1xx411c7mD/2"}
saveStored(t, store, a, a.ID, "aaa", "link/aaa/cover.jpg", 100)
saveStored(t, store, track.Track{Source: track.Link, ID: "https://example.com/b.mp3"}, "https://example.com/b.mp3", "bbb", "", 200)
saveStored(t, store, video, "https://www.bilibili.com/video/BV1xx411c7mD?p=2", "ccc", "link/ccc/cover.jpg", 300)
saveStored(t, store, a, a.ID, "aaa", track.Meta{Artist: "周杰伦", CoverKey: "link/aaa/cover.jpg"}, 100)
saveStored(t, store, track.Track{Source: track.Link, ID: "https://example.com/b.mp3"}, "https://example.com/b.mp3", "bbb", track.Meta{CoverURL: "https://example.com/b.jpg"}, 200)
saveStored(t, store, video, "https://www.bilibili.com/video/BV1xx411c7mD?p=2", "ccc", track.Meta{Artist: "某个 UP 主", CoverKey: "link/ccc/cover.jpg"}, 300)
addTrack(t, store, netease("1"), track.Meta{Title: "晴天"}, track.Prepared{Duration: time.Minute, Gain: 1})
id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
@@ -37,22 +43,23 @@ func TestStoredMedia(t *testing.T) {
played := room.Entry{ID: 1, Track: a, By: alice}
saveRoom(t, store, term, snap, room.RecordPlay{Entry: played, StartedAt: t0, EndedAt: t0.Add(time.Minute)})
list, err := store.ListStoredMedia(t.Context())
page, err := store.ListStoredMedia(t.Context(), port.MediaQuery{Page: 1, Sort: port.MediaByStored})
if err != nil {
t.Fatal(err)
}
list := page.Items
if len(list) != 3 || list[0].Key != "ccc" || list[1].Key != "bbb" || list[2].Key != "aaa" {
t.Fatalf("已存储的媒体 = %+v,期望两首链接和一首 B 站的歌,后存入的在前", list)
}
got := list[2]
if got.Source != track.Link || got.Link != a.ID || got.Title != "标题 aaa" || got.Size != 100 || got.CoverKey != "link/aaa/cover.jpg" ||
if got.Source != track.Link || got.Link != a.ID || got.Title != "标题 aaa" || got.Artist != "周杰伦" || got.Size != 100 || got.CoverURL != "" || got.CoverKey != "link/aaa/cover.jpg" ||
got.StoredAt.IsZero() || got.LastPlayedAt == nil || !got.LastPlayedAt.Equal(t0) {
t.Errorf("播放过的链接 = %+v", got)
}
if b := list[1]; b.Source != track.Link || b.CoverKey != "" || b.LastPlayedAt != nil {
t.Errorf("没有封面、没有播放过的链接 = %+v", b)
if b := list[1]; b.Source != track.Link || b.Artist != "" || b.CoverURL != "https://example.com/b.jpg" || b.CoverKey != "" || b.LastPlayedAt != nil {
t.Errorf("没有歌手、封面不在对象存储里、没有播放过的链接 = %+v", b)
}
if c := list[0]; c.Source != track.Bilibili || c.Link != video.ID || c.CoverKey != "link/ccc/cover.jpg" || c.Size != 300 {
if c := list[0]; c.Source != track.Bilibili || c.Link != video.ID || c.Artist != "某个 UP 主" || c.CoverKey != "link/ccc/cover.jpg" || c.Size != 300 {
t.Errorf("B 站的歌 = %+v", c)
}
@@ -74,7 +81,110 @@ func TestStoredMedia(t *testing.T) {
t.Errorf("清除之后 %v 的准备结果 = %v, %v,期望没有", tr, ok, err)
}
}
if list, err := store.ListStoredMedia(t.Context()); err != nil || len(list) != 1 {
t.Errorf("清除之后的列表 = %+v, %v", list, err)
if page, err := store.ListStoredMedia(t.Context(), port.MediaQuery{Page: 1, Sort: port.MediaByStored}); err != nil || len(page.Items) != 1 || page.Total != 1 {
t.Errorf("清除之后的列表 = %+v, %v", page, err)
}
}
func mediaKeys(items []port.StoredMedia) []string {
keys := make([]string, len(items))
for i, m := range items {
keys[i] = m.Key
}
slices.Sort(keys)
return keys
}
// 120 首:存储时间和最近播放时间各不相同,其中 10 首没有播放过;每 3 首大小相同,要靠曲目 ID 分出先后。
// 有 3 首的标题含 50%_off;另外两首只在 % 或 _ 被当作通配符时才会匹配它。
func TestStoredMediaPages(t *testing.T) {
store, pool := newStore(t)
titles := map[int]string{5: "Summer 50%_off", 50: "50%_OFF 特价", 95: "全场50%_Off", 20: "50abc_off", 30: "50%Xoff", 40: `C:\music`}
all := make([]port.StoredMedia, 120)
var totalBytes int64
for i := range all {
// 曲目 ID 的顺序与存入的顺序不同,大小相同时是否按曲目 ID 排才看得出来。
link := fmt.Sprintf("https://example.com/%03d.mp3", i*7%120)
m := port.StoredMedia{
Key: fmt.Sprintf("key%03d", i), Source: track.Link, Link: link, Title: cmp.Or(titles[i], fmt.Sprintf("第 %d 首", i)),
Size: int64(1000 + i*11%40), StoredAt: t0.Add(time.Duration(i*13%120) * time.Minute),
}
if i%12 != 0 {
played := t0.Add(time.Duration(i*17%120) * time.Hour)
m.LastPlayedAt = &played
}
saveStored(t, store, track.Track{Source: track.Link, ID: link}, link, m.Key, track.Meta{}, m.Size)
if _, err := pool.Exec(t.Context(), "UPDATE tracks SET title = $1, prepared_at = $2, last_played_at = $3 WHERE media_key = $4", m.Title, m.StoredAt, m.LastPlayedAt, m.Key); err != nil {
t.Fatal(err)
}
all[i] = m
totalBytes += m.Size
}
list := func(q port.MediaQuery) port.MediaPage {
t.Helper()
page, err := store.ListStoredMedia(t.Context(), q)
if err != nil {
t.Fatal(err)
}
return page
}
// 每一页都报告全部的数量和大小;翻过最后一页是空的。
pages := func(sort port.MediaSort) []port.StoredMedia {
t.Helper()
var items []port.StoredMedia
for i, want := range []int{50, 50, 20, 0} {
page := list(port.MediaQuery{Page: i + 1, Sort: sort})
if len(page.Items) != want || page.Total != 120 || page.TotalBytes != totalBytes {
t.Fatalf("排序 %d 的第 %d 页有 %d 首,共 %d 首 %d 字节,期望 %d 首,共 120 首 %d 字节", sort, i+1, len(page.Items), page.Total, page.TotalBytes, want, totalBytes)
}
items = append(items, page.Items...)
}
if got, want := mediaKeys(items), mediaKeys(all); !slices.Equal(got, want) {
t.Fatalf("排序 %d 依次翻页 = %q,期望不重复也不遗漏", sort, got)
}
return items
}
inOrder := func(name string, items []port.StoredMedia, compare func(a, b port.StoredMedia) int) {
t.Helper()
for i := 1; i < len(items); i++ {
if compare(items[i-1], items[i]) >= 0 {
t.Fatalf("%s:%+v 排在了 %+v 之前", name, items[i-1], items[i])
}
}
}
byLink := func(a, b port.StoredMedia) int { return strings.Compare(a.Link, b.Link) }
inOrder("按存储时间", pages(port.MediaByStored), func(a, b port.StoredMedia) int { return b.StoredAt.Compare(a.StoredAt) })
idle := pages(port.MediaByIdle)
for i, m := range idle {
if (m.LastPlayedAt == nil) != (i < 10) {
t.Fatalf("按最近播放的第 %d 首 = %+v,期望前 10 首是没有播放过的", i+1, m)
}
}
inOrder("没有播放过的", idle[:10], byLink)
inOrder("按最近播放", idle[10:], func(a, b port.StoredMedia) int { return a.LastPlayedAt.Compare(*b.LastPlayedAt) })
inOrder("按大小", pages(port.MediaBySize), func(a, b port.StoredMedia) int { return cmp.Or(cmp.Compare(b.Size, a.Size), byLink(a, b)) })
if page := list(port.MediaQuery{Page: math.MaxInt, Sort: port.MediaBySize}); len(page.Items) != 0 || page.Total != 120 || page.TotalBytes != totalBytes {
t.Errorf("页码极大时 = %d 首,共 %d 首 %d 字节,期望没有,总数照常", len(page.Items), page.Total, page.TotalBytes)
}
for _, tt := range []struct {
title string
want []int
}{
{"50%_off", []int{5, 50, 95}},
{"OFF", []int{5, 20, 30, 50, 95}},
{`:\m`, []int{40}},
} {
var want []port.StoredMedia
var bytes int64
for _, i := range tt.want {
want = append(want, all[i])
bytes += all[i].Size
}
page := list(port.MediaQuery{Page: 1, Sort: port.MediaByStored, Title: tt.title})
if got := mediaKeys(page.Items); !slices.Equal(got, mediaKeys(want)) || page.Total != len(want) || page.TotalBytes != bytes {
t.Errorf("搜索 %q = %q,共 %d 首 %d 字节,期望 %q,共 %d 首 %d 字节", tt.title, got, page.Total, page.TotalBytes, mediaKeys(want), len(want), bytes)
}
}
}
+17 -2
View File
@@ -23,8 +23,23 @@ func NewLibrary(media port.MediaLibrary, usage port.TrackUsage, store port.Media
return &Library{media: media, usage: usage, store: store}
}
func (l *Library) List(ctx context.Context) ([]port.StoredMedia, error) {
return l.media.ListStoredMedia(ctx)
func (l *Library) List(ctx context.Context, q port.MediaQuery) (port.MediaPage, error) {
return l.media.ListStoredMedia(ctx, q)
}
// 逐个删除:正在房间里使用的放进 inUse,不存在的忽略,其他错误立即返回,已经删掉的不会恢复。
func (l *Library) DeleteMany(ctx context.Context, keys []string) (deleted, inUse []string, err error) {
for _, key := range keys {
switch err := l.Delete(ctx, key); {
case err == nil:
deleted = append(deleted, key)
case errors.Is(err, ErrInUse):
inUse = append(inUse, key)
case !errors.Is(err, ErrNotFound):
return nil, nil, err
}
}
return deleted, inUse, nil
}
// 先删除对象,再清除准备结果:中途失败时准备结果还在,后台仍能看到这首歌并重试;
+77 -45
View File
@@ -1,7 +1,6 @@
package media
import (
"context"
"errors"
"slices"
"strings"
@@ -13,59 +12,52 @@ import (
"github.com/dihemoe/alisten/internal/domain/track"
)
type library struct {
media map[string]port.StoredMedia
used map[track.Track]bool
func stored(tr track.Track) port.StoredMedia {
key := Key(tr.ID)
return port.StoredMedia{Key: key, Source: tr.Source, Link: tr.ID, Title: "晴天", Size: 3, CoverKey: track.ObjectKey(CoverObject(key, "jpg")), StoredAt: time.Now()}
}
func (l *library) ListStoredMedia(context.Context) ([]port.StoredMedia, error) {
var list []port.StoredMedia
for _, m := range l.media {
list = append(list, m)
}
return list, nil
}
func (l *library) StoredMedia(_ context.Context, key string) (port.StoredMedia, bool, error) {
m, ok := l.media[key]
return m, ok, nil
}
func (l *library) DeleteStoredMedia(_ context.Context, key string) error {
delete(l.media, key)
return nil
}
func (l *library) TrackInUse(_ context.Context, t track.Track) (bool, error) {
return l.used[t], nil
}
func newLibrary(t *testing.T) (*Library, *library, *porttest.MediaStore) {
// 每首歌的音频和封面都在对象存储中,另外还有一个不属于它们的对象。
func newLibrary(t *testing.T, used inRooms, media ...port.StoredMedia) (*Library, *porttest.MediaLibrary, *porttest.MediaStore) {
t.Helper()
key := Key(link.ID)
lib := &library{
media: map[string]port.StoredMedia{key: {Key: key, Source: track.Link, Link: link.ID, Title: "晴天", Size: 3, CoverKey: track.ObjectKey(CoverObject(key, "jpg")), StoredAt: time.Now()}},
used: map[track.Track]bool{},
}
lib := porttest.NewMediaLibrary(media...)
store := porttest.NewMediaStore()
for _, object := range []string{AudioObject(key), CoverObject(key, "jpg"), AudioObject("other")} {
objects := []string{AudioObject("other")}
for _, m := range media {
objects = append(objects, AudioObject(m.Key), string(m.CoverKey))
}
for _, object := range objects {
if err := store.Put(t.Context(), object, strings.NewReader("data"), "application/octet-stream"); err != nil {
t.Fatal(err)
}
}
return NewLibrary(lib, lib, store), lib, store
return NewLibrary(lib, used, store), lib, store
}
func storedKeys(t *testing.T, lib *porttest.MediaLibrary) []string {
t.Helper()
page, err := lib.ListStoredMedia(t.Context(), port.MediaQuery{Page: 1, Sort: port.MediaByStored})
if err != nil {
t.Fatal(err)
}
keys := make([]string, len(page.Items))
for i, m := range page.Items {
keys[i] = m.Key
}
slices.Sort(keys)
return keys
}
func TestDeleteRemovesObjectsAndResult(t *testing.T) {
l, lib, store := newLibrary(t)
l, lib, store := newLibrary(t, inRooms{}, stored(link))
if err := l.Delete(t.Context(), Key(link.ID)); err != nil {
t.Fatal(err)
}
if keys := store.Keys(); !slices.Equal(keys, []string{AudioObject("other")}) {
t.Errorf("删除之后的对象 = %q,期望音频和封面都已删除", keys)
}
if len(lib.media) != 0 {
t.Error("删除之后准备结果应已清除")
if keys := storedKeys(t, lib); len(keys) != 0 {
t.Errorf("删除之后还有准备结果 %q", keys)
}
if err := l.Delete(t.Context(), Key(link.ID)); !errors.Is(err, ErrNotFound) {
t.Errorf("再次删除 = %v,期望 ErrNotFound", err)
@@ -73,26 +65,66 @@ func TestDeleteRemovesObjectsAndResult(t *testing.T) {
}
func TestDeleteRefusesMediaInUse(t *testing.T) {
l, lib, store := newLibrary(t)
stored := lib.media[Key(link.ID)]
stored.Source = track.Bilibili
lib.media[Key(link.ID)] = stored
lib.used[track.Track{Source: track.Bilibili, ID: link.ID}] = true
video := track.Track{Source: track.Bilibili, ID: link.ID}
l, lib, store := newLibrary(t, inRooms{video: true}, stored(video))
if err := l.Delete(t.Context(), Key(link.ID)); !errors.Is(err, ErrInUse) {
t.Errorf("删除仍被房间引用的歌 = %v,期望 ErrInUse", err)
}
if len(store.Keys()) != 3 || len(lib.media) != 1 {
if len(store.Keys()) != 3 || len(storedKeys(t, lib)) != 1 {
t.Error("仍被房间引用的歌不应删除任何东西")
}
}
func TestDeleteKeepsResultWhenObjectsCannotBeDeleted(t *testing.T) {
l, lib, store := newLibrary(t)
l, lib, store := newLibrary(t, inRooms{}, stored(link))
store.Fail(errors.New("连接被拒绝"))
if err := l.Delete(t.Context(), Key(link.ID)); err == nil {
t.Fatal("对象删除失败时应返回错误")
}
if len(lib.media) != 1 {
if len(storedKeys(t, lib)) != 1 {
t.Error("对象删除失败时准备结果应保留,后台还能看到这首歌并重试")
}
}
func TestDeleteManySkipsMediaInUseAndMissing(t *testing.T) {
other := track.Track{Source: track.Link, ID: "https://example.com/b.mp3"}
l, lib, store := newLibrary(t, inRooms{bilibili: true}, stored(link), stored(bilibili), stored(other))
deleted, inUse, err := l.DeleteMany(t.Context(), []string{Key(bilibili.ID), Key(link.ID), Key("https://example.com/missing.mp3"), Key(other.ID)})
if err != nil {
t.Fatal(err)
}
if want := []string{Key(link.ID), Key(other.ID)}; !slices.Equal(deleted, want) {
t.Errorf("删掉的 = %q,期望 %q", deleted, want)
}
if want := []string{Key(bilibili.ID)}; !slices.Equal(inUse, want) {
t.Errorf("正在使用的 = %q,期望 %q", inUse, want)
}
if keys := storedKeys(t, lib); !slices.Equal(keys, []string{Key(bilibili.ID)}) {
t.Errorf("剩下的准备结果 = %q,期望只剩正在使用的", keys)
}
if keys, want := store.Keys(), []string{AudioObject("other"), AudioObject(Key(bilibili.ID)), CoverObject(Key(bilibili.ID), "jpg")}; !slices.Equal(keys, slices.Sorted(slices.Values(want))) {
t.Errorf("剩下的对象 = %q,期望 %q", keys, want)
}
}
func TestDeleteManyDeletesAll(t *testing.T) {
l, lib, _ := newLibrary(t, inRooms{}, stored(link), stored(bilibili))
deleted, inUse, err := l.DeleteMany(t.Context(), []string{Key(link.ID), Key(bilibili.ID)})
if err != nil || !slices.Equal(deleted, []string{Key(link.ID), Key(bilibili.ID)}) || len(inUse) != 0 {
t.Errorf("DeleteMany = %q, %q, %v,期望两首都删掉", deleted, inUse, err)
}
if keys := storedKeys(t, lib); len(keys) != 0 {
t.Errorf("删除之后还有准备结果 %q", keys)
}
}
func TestDeleteManyStopsAtOtherErrors(t *testing.T) {
l, lib, store := newLibrary(t, inRooms{}, stored(link), stored(bilibili))
store.Fail(errors.New("连接被拒绝"))
if _, _, err := l.DeleteMany(t.Context(), []string{Key(link.ID), Key(bilibili.ID)}); err == nil {
t.Fatal("对象删除失败时应返回错误")
}
if len(storedKeys(t, lib)) != 2 {
t.Error("对象删除失败时准备结果应保留")
}
}
+36 -5
View File
@@ -21,18 +21,49 @@ type StoredMedia struct {
Key string
Source track.Source
// 曲目 ID:链接曲目是链接本身,B 站的歌是 BV 号。
Link string
Title string
Link string
Title string
Artist string
// 音频的字节数。
Size int64
Size int64
// 两者的含义与 track.Meta 的同名字段相同。
CoverURL string
CoverKey track.ObjectKey
StoredAt time.Time
LastPlayedAt *time.Time
}
// 每种排序都以曲目 ID 作为最后的排序键,翻页时不重复也不遗漏。
type MediaSort uint8
const (
// 存储时间从新到旧。
MediaByStored MediaSort = iota + 1
// 最近播放从早到晚,从没播放过的排最前。
MediaByIdle
// 从大到小。
MediaBySize
)
const MediaPageSize = 50
type MediaQuery struct {
// 从 1 开始,超出最后一页时返回空的一页。
Page int
Sort MediaSort
// 标题包含它就算匹配,不区分大小写,按字面匹配;为空表示不搜索。
Title string
}
type MediaPage struct {
Items []StoredMedia
// 匹配的全部歌曲的数量和音频的字节数,不只是这一页。
Total int
TotalBytes int64
}
type MediaLibrary interface {
// 最近存入的在前。
ListStoredMedia(ctx context.Context) ([]StoredMedia, error)
ListStoredMedia(ctx context.Context, q MediaQuery) (MediaPage, error)
StoredMedia(ctx context.Context, key string) (m StoredMedia, ok bool, err error)
// 清除这首歌的准备结果。
DeleteStoredMedia(ctx context.Context, key string) error
+74
View File
@@ -1,10 +1,12 @@
package porttest
import (
"cmp"
"context"
"io"
"maps"
"slices"
"strings"
"sync"
"github.com/dihemoe/alisten/internal/app/port"
@@ -79,3 +81,75 @@ func (s *MediaStore) Fail(err error) {
defer s.mu.Unlock()
s.failure = err
}
var _ port.MediaLibrary = (*MediaLibrary)(nil)
type MediaLibrary struct {
mu sync.Mutex
media map[string]port.StoredMedia
}
func NewMediaLibrary(media ...port.StoredMedia) *MediaLibrary {
l := &MediaLibrary{media: map[string]port.StoredMedia{}}
for _, m := range media {
l.media[m.Key] = m
}
return l
}
func (l *MediaLibrary) ListStoredMedia(_ context.Context, q port.MediaQuery) (port.MediaPage, error) {
l.mu.Lock()
defer l.mu.Unlock()
var page port.MediaPage
var matched []port.StoredMedia
for _, m := range l.media {
if strings.Contains(strings.ToLower(m.Title), strings.ToLower(q.Title)) {
matched = append(matched, m)
page.TotalBytes += m.Size
}
}
slices.SortFunc(matched, func(a, b port.StoredMedia) int {
return cmp.Or(compareMedia(q.Sort, a, b), strings.Compare(a.Link, b.Link))
})
page.Total = len(matched)
if q.Page-1 > len(matched)/port.MediaPageSize {
return page, nil
}
start := (q.Page - 1) * port.MediaPageSize
page.Items = matched[start:min(start+port.MediaPageSize, len(matched))]
return page, nil
}
func compareMedia(sort port.MediaSort, a, b port.StoredMedia) int {
switch sort {
case port.MediaByStored:
return b.StoredAt.Compare(a.StoredAt)
case port.MediaByIdle:
switch {
case a.LastPlayedAt == nil && b.LastPlayedAt == nil:
return 0
case a.LastPlayedAt == nil:
return -1
case b.LastPlayedAt == nil:
return 1
}
return a.LastPlayedAt.Compare(*b.LastPlayedAt)
case port.MediaBySize:
return cmp.Compare(b.Size, a.Size)
}
return 0
}
func (l *MediaLibrary) StoredMedia(_ context.Context, key string) (port.StoredMedia, bool, error) {
l.mu.Lock()
defer l.mu.Unlock()
m, ok := l.media[key]
return m, ok, nil
}
func (l *MediaLibrary) DeleteStoredMedia(_ context.Context, key string) error {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.media, key)
return nil
}
+71
View File
@@ -0,0 +1,71 @@
package porttest
import (
"fmt"
"math"
"slices"
"testing"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
)
func mediaKeys(items []port.StoredMedia) []string {
keys := make([]string, len(items))
for i, m := range items {
keys[i] = m.Key
}
return keys
}
// 与 postgres 适配器的 TestStoredMediaPages 相同的规则。
func TestMediaLibraryPages(t *testing.T) {
t0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
played := func(h time.Duration) *time.Time {
at := t0.Add(h * time.Hour)
return &at
}
lib := NewMediaLibrary(
port.StoredMedia{Key: "a", Source: track.Link, Link: "https://example.com/c", Title: "Summer 50%_OFF", Size: 300, StoredAt: t0.Add(time.Hour)},
port.StoredMedia{Key: "b", Source: track.Link, Link: "https://example.com/a", Title: "50abc_off", Size: 300, StoredAt: t0.Add(3 * time.Hour), LastPlayedAt: played(5)},
port.StoredMedia{Key: "c", Source: track.Link, Link: "https://example.com/b", Title: "50%Xoff", Size: 100, StoredAt: t0.Add(2 * time.Hour), LastPlayedAt: played(4)},
port.StoredMedia{Key: "d", Source: track.Bilibili, Link: "https://example.com/d", Title: "晴天", Size: 200, StoredAt: t0},
)
for _, tt := range []struct {
q port.MediaQuery
want []string
total int
bytes int64
}{
{port.MediaQuery{Page: 1, Sort: port.MediaByStored}, []string{"b", "c", "a", "d"}, 4, 900},
{port.MediaQuery{Page: 1, Sort: port.MediaByIdle}, []string{"a", "d", "c", "b"}, 4, 900},
{port.MediaQuery{Page: 1, Sort: port.MediaBySize}, []string{"b", "a", "d", "c"}, 4, 900},
{port.MediaQuery{Page: 2, Sort: port.MediaBySize}, []string{}, 4, 900},
{port.MediaQuery{Page: math.MaxInt, Sort: port.MediaBySize}, []string{}, 4, 900},
{port.MediaQuery{Page: 1, Sort: port.MediaByStored, Title: "50%_off"}, []string{"a"}, 1, 300},
{port.MediaQuery{Page: 1, Sort: port.MediaByStored, Title: "OFF"}, []string{"b", "c", "a"}, 3, 700},
} {
page, err := lib.ListStoredMedia(t.Context(), tt.q)
if got := mediaKeys(page.Items); err != nil || !slices.Equal(got, tt.want) || page.Total != tt.total || page.TotalBytes != tt.bytes {
t.Errorf("%+v = %q,共 %d 首 %d 字节,%v,期望 %q,共 %d 首 %d 字节", tt.q, got, page.Total, page.TotalBytes, err, tt.want, tt.total, tt.bytes)
}
}
var many []port.StoredMedia
for i := range port.MediaPageSize + 1 {
many = append(many, port.StoredMedia{Key: fmt.Sprintf("key%02d", i), Link: fmt.Sprintf("https://example.com/%02d", i), Size: 1, StoredAt: t0})
}
lib = NewMediaLibrary(many...)
var listed []port.StoredMedia
for p, want := range []int{port.MediaPageSize, 1, 0} {
page, err := lib.ListStoredMedia(t.Context(), port.MediaQuery{Page: p + 1, Sort: port.MediaByStored})
if err != nil || len(page.Items) != want || page.Total != len(many) {
t.Fatalf("第 %d 页 = %d 首,共 %d 首,%v,期望 %d 首,共 %d 首", p+1, len(page.Items), page.Total, err, want, len(many))
}
listed = append(listed, page.Items...)
}
if got, want := mediaKeys(listed), mediaKeys(many); !slices.Equal(got, want) {
t.Errorf("依次翻页 = %q,期望按曲目 ID 排的 %q", got, want)
}
}
+57
View File
@@ -252,3 +252,60 @@ func TestPlaylistLinkTracks(t *testing.T) {
t.Errorf("换一种大小写移出之后还有 %+v,期望没有", items.Items)
}
}
// 翻过最后一页时总数照常;批量删除时,正在房间里播放的歌不删,不存在的忽略,其余的连同音频一起删掉。
func TestAdminMediaPagesAndRemovals(t *testing.T) {
contractEnv(t, startPostgres(t)(t), startValkey(t)(t))
svc := startService(t)
dbConfig, err := loadDatabaseConfig()
if err != nil {
t.Fatal(err)
}
pool, err := pgxpool.NewWithConfig(t.Context(), dbConfig)
if err != nil {
t.Fatal(err)
}
t.Cleanup(pool.Close)
objectsConfig, err := loadStorageConfig()
if err != nil {
t.Fatal(err)
}
objects := storage.New(objectsConfig)
const playing, idle = "https://example.com/playing.mp3", "https://example.com/idle.mp3"
for _, link := range []string{playing, idle} {
prepared := port.PreparedTrack{
Meta: track.Meta{Title: link, PageURL: link},
Result: track.Prepared{Duration: time.Hour, Gain: 1},
MediaKey: media.Key(link),
MediaSize: 3,
}
if err := postgres.New(pool).SavePreparedTrack(t.Context(), track.Track{Source: track.Link, ID: link}, prepared); err != nil {
t.Fatal(err)
}
if err := objects.Put(t.Context(), media.AudioObject(media.Key(link)), strings.NewReader("mp3"), "audio/mpeg"); err != nil {
t.Fatal(err)
}
}
_, ticket := enterRoom(t, svc, createPersistentRoom(t, svc, "大厅"))
roomRequest(t, svc, ticket, "POST /api/room/queue", `{"track": {"source": "link", "id": "`+playing+`"}}`)
admin := "\nAuthorization: Bearer admin-secret\n\n"
call := func(request, want string) {
t.Helper()
if line, status, _, body := send(t, svc.server, request); status != http.StatusOK || strings.TrimSpace(string(body)) != want {
t.Errorf("%s = %d %s,期望 %s", line, status, body, want)
}
}
call("POST /api/admin/media/queries"+admin+`{"page": 2}`, `{"items":[],"page":2,"pageSize":50,"total":2,"totalBytes":6}`)
call("POST /api/admin/media/removals"+admin+`{"media": [{"key": "`+media.Key(playing)+`"}, {"key": "`+media.Key(idle)+`"}, {"key": "`+media.Key("https://example.com/missing.mp3")+`"}]}`,
`{"deleted":["`+media.Key(idle)+`"],"inUse":["`+media.Key(playing)+`"]}`)
if line, status, _, body := send(t, svc.server, "POST /api/admin/media/queries"+admin+"{}"); status != http.StatusOK ||
!strings.Contains(string(body), `"key":"`+media.Key(playing)+`"`) || !strings.HasSuffix(strings.TrimSpace(string(body)), `"page":1,"pageSize":50,"total":1,"totalBytes":3}`) {
t.Errorf("%s = %d %s,期望只剩正在播放的歌", line, status, body)
}
for link, want := range map[string]bool{playing: true, idle: false} {
if exists, err := objects.Exists(t.Context(), media.AudioObject(media.Key(link))); err != nil || exists != want {
t.Errorf("%s 的音频还在 = %v, %v,期望 %v", link, exists, err, want)
}
}
}
+6 -2
View File
@@ -84,15 +84,19 @@ Authorization: Bearer admin-secret
{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}}
-- request --
GET /api/admin/media
POST /api/admin/media/queries
Authorization: Bearer admin-secret
{}
-- response --
403
{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}}
-- request --
DELETE /api/admin/media/66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391
POST /api/admin/media/removals
Authorization: Bearer admin-secret
{"media": [{"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}]}
-- response --
403
{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}}
+96 -9
View File
@@ -322,29 +322,116 @@ Authorization: Bearer admin-secret
{"set": false, "updatedAtMs": 0}
-- request --
GET /api/admin/media
POST /api/admin/media/queries
Authorization: Bearer admin-secret
{}
-- response --
200
{"items": []}
{"items": [], "total": 0, "totalBytes": 0, "page": 1, "pageSize": 50}
-- request --
DELETE /api/admin/media/66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391
POST /api/admin/media/queries
Authorization: Bearer admin-secret
{"page": 3, "sort": "idle", "q": "50%_off"}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "媒体不存在"}}
200
{"items": [], "total": 0, "totalBytes": 0, "page": 3, "pageSize": 50}
-- request --
DELETE /api/admin/media/not-a-key
POST /api/admin/media/queries
Authorization: Bearer admin-secret
{"sort": "recent"}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "媒体不存在"}}
400
{"error": {"code": "INVALID_ARGUMENT", "message": "sort:取值应为 stored、idle、size 之一"}}
-- request --
GET /api/admin/media
POST /api/admin/media/queries
Authorization: Bearer admin-secret
{"q": "晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴"}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "q:长度不能超过 100 个字符"}}
-- request --
POST /api/admin/media/queries
Authorization: Bearer admin-secret
{"page": 0}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "page:不能小于 1"}}
-- request --
POST /api/admin/media/removals
Authorization: Bearer admin-secret
{"media": [{"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}]}
-- response --
200
{"deleted": [], "inUse": []}
-- request --
POST /api/admin/media/removals
Authorization: Bearer admin-secret
{"media": [{"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "not-a-key"}]}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "media[1].key:格式不正确"}}
-- request --
POST /api/admin/media/removals
Authorization: Bearer admin-secret
{"media": [{"key": "66CF7FBB71EA804452BE64DB6A2002116BA136DB17C97DBB7ED4A74C8D052391"}]}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "media[0].key:格式不正确"}}
-- request --
POST /api/admin/media/removals
Authorization: Bearer admin-secret
{"media": []}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "media:元素不能少于 1 个"}}
-- request --
POST /api/admin/media/removals
Authorization: Bearer admin-secret
{"media": [{"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}]}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "media:元素不能超过 50 个"}}
-- request --
POST /api/admin/media/queries
{}
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}}
-- request --
POST /api/admin/media/removals
{"media": [{"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}]}
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}}
-- request --
POST /api/admin/media/queries
Authorization: Bearer $alice
{}
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}}
+10 -9
View File
@@ -203,10 +203,11 @@ func TestWorkerPreparesLinks(t *testing.T) {
}
admin := "Authorization: Bearer admin-secret"
if line, status, _, body := send(t, svc.server, "GET /api/admin/media\n"+admin); status != http.StatusOK || !strings.Contains(string(body), `"key":"`+key+`"`) {
t.Errorf("%s = %d %s,期望列出这首链接", line, status, body)
if line, status, _, body := send(t, svc.server, "POST /api/admin/media/queries\n"+admin+"\n\n{}"); status != http.StatusOK || !strings.Contains(string(body), `"coverUrl":"`+entryMeta["coverUrl"].(string)+`","key":"`+key+`"`) {
t.Errorf("%s = %d %s,期望列出这首链接和它的封面", line, status, body)
}
if line, status, _, body := send(t, svc.server, "DELETE /api/admin/media/"+key+"\n"+admin); status != http.StatusConflict {
remove := "POST /api/admin/media/removals\n" + admin + "\n\n" + `{"media": [{"key": "` + key + `"}]}`
if line, status, _, body := send(t, svc.server, remove); status != http.StatusOK || strings.TrimSpace(string(body)) != `{"deleted":[],"inUse":["`+key+`"]}` {
t.Errorf("%s = %d %s,期望正在播放的链接不能删除", line, status, body)
}
@@ -214,18 +215,18 @@ func TestWorkerPreparesLinks(t *testing.T) {
client.readUntil(t, func(m map[string]any) bool { return m["type"] == "playback.voteSkip" })
// 切歌之后要等落盘完成,链接曲目才不再被房间引用。
for deadline := time.Now().Add(messageWait); ; time.Sleep(100 * time.Millisecond) {
_, status, _, _ := send(t, svc.server, "DELETE /api/admin/media/"+key+"\n"+admin)
if status == http.StatusNoContent {
_, status, _, body := send(t, svc.server, remove)
if status == http.StatusOK && strings.TrimSpace(string(body)) == `{"deleted":["`+key+`"],"inUse":[]}` {
break
}
if time.Now().After(deadline) {
t.Fatalf("切歌之后删除媒体 = %d", status)
t.Fatalf("切歌之后删除媒体 = %d %s", status, body)
}
}
if status, _ := anonymousGet(t, now["mediaUrl"].(string)); status == http.StatusOK {
t.Error("删除之后仍能读到音频")
}
if _, status, _, body := send(t, svc.server, "GET /api/admin/media\n"+admin); status != http.StatusOK || strings.TrimSpace(string(body)) != `{"items":[]}` {
if _, status, _, body := send(t, svc.server, "POST /api/admin/media/queries\n"+admin+"\n\n{}"); status != http.StatusOK || strings.TrimSpace(string(body)) != `{"items":[],"page":1,"pageSize":50,"total":0,"totalBytes":0}` {
t.Errorf("删除之后的列表 = %d %s", status, body)
}
@@ -293,7 +294,7 @@ func TestWorkerPreparesBilibili(t *testing.T) {
t.Errorf("转码的输入 = %q,期望下载到的原始音频", inputs)
}
admin := "Authorization: Bearer admin-secret"
if line, status, _, body := send(t, svc.server, "GET /api/admin/media\n"+admin); status != http.StatusOK || !strings.Contains(string(body), `"key":"`+key+`"`) ||
if line, status, _, body := send(t, svc.server, "POST /api/admin/media/queries\n"+admin+"\n\n{}"); status != http.StatusOK || !strings.Contains(string(body), `"key":"`+key+`"`) ||
!strings.Contains(string(body), `"source":"bilibili"`) || !strings.Contains(string(body), `"link":"https://www.bilibili.com/video/BV1xx411c7mD?p=2"`) {
t.Errorf("%s = %d %s,期望列出这首 B 站的歌", line, status, body)
}
@@ -350,7 +351,7 @@ func TestLinkCaseVariantsAreOneTrack(t *testing.T) {
if n := downloads.downloads(); n != 1 {
t.Errorf("下载了 %d 次,期望两种写法共用一次下载", n)
}
if line, status, _, body := send(t, svc.server, "GET /api/admin/media\nAuthorization: Bearer admin-secret"); status != http.StatusOK || strings.Count(string(body), `"key"`) != 1 || !strings.Contains(string(body), `"key":"`+media.Key(link)+`"`) {
if line, status, _, body := send(t, svc.server, "POST /api/admin/media/queries\nAuthorization: Bearer admin-secret\n\n{}"); status != http.StatusOK || strings.Count(string(body), `"key"`) != 1 || !strings.Contains(string(body), `"key":"`+media.Key(link)+`"`) {
t.Errorf("%s = %d %s,期望只存了一份", line, status, body)
}
}
+93
View File
@@ -16,6 +16,7 @@ var (
patternLoginRequestUsername = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
patternRedeemPairingCodeRequestCode = regexp.MustCompile(`^[2-9A-HJ-NP-Z]{8}$`)
patternRegisterRequestUsername = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
patternStoredMediaRefKey = regexp.MustCompile(`^[0-9a-f]{64}$`)
patternTrackIDWhenBilibili = regexp.MustCompile(`^BV[1-9A-HJ-NP-Za-km-z]{10}(?:/([2-9]|[1-9][0-9]{1,3}))?$`)
)
@@ -182,6 +183,14 @@ func DecodeAdminSetGithubCredentialRequest(body []byte) (AdminSetGithubCredentia
return decodeAdminSetGithubCredentialRequest(body, "")
}
func DecodeAdminListMediaRequest(body []byte) (AdminListMediaRequest, error) {
return decodeAdminListMediaRequest(body, "")
}
func DecodeAdminRemoveMediaRequest(body []byte) (AdminRemoveMediaRequest, error) {
return decodeAdminRemoveMediaRequest(body, "")
}
func DecodeAdminUpdateSiteRequest(body []byte) (AdminUpdateSiteRequest, error) {
return decodeAdminUpdateSiteRequest(body, "")
}
@@ -346,12 +355,66 @@ func decodeAdminCreateRoomRequest(raw json.RawMessage, path string) (AdminCreate
return v, nil
}
func decodeAdminListMediaRequest(raw json.RawMessage, path string) (AdminListMediaRequest, error) {
var v AdminListMediaRequest
m, err := decodeObject(raw, path, "page", "q", "sort")
if err != nil {
return v, err
}
if r, ok := m["page"]; ok {
x, err := decodeInteger(r, field(path, "page"))
if err != nil {
return v, err
}
if err = checkMinimum(x, field(path, "page"), 1); err != nil {
return v, err
}
v.Page = &x
}
if r, ok := m["q"]; ok {
x, err := decodeString(r, field(path, "q"))
if err != nil {
return v, err
}
if err = checkMaxLength(x, field(path, "q"), 100); err != nil {
return v, err
}
v.Q = &x
}
if r, ok := m["sort"]; ok {
x, err := decodeMediaSort(r, field(path, "sort"))
if err != nil {
return v, err
}
v.Sort = &x
}
return v, nil
}
func decodeAdminOpenRegistrationRequest(raw json.RawMessage, path string) (AdminOpenRegistrationRequest, error) {
var v AdminOpenRegistrationRequest
_, err := decodeObject(raw, path)
return v, err
}
func decodeAdminRemoveMediaRequest(raw json.RawMessage, path string) (AdminRemoveMediaRequest, error) {
var v AdminRemoveMediaRequest
m, err := decodeObject(raw, path, "media")
if err != nil {
return v, err
}
if err = requireFields(m, path, "media"); err != nil {
return v, err
}
if v.Media, err = decodeArray(decodeStoredMediaRef, 50)(m["media"], field(path, "media")); err != nil {
return v, err
}
if err = checkMinItems(v.Media, field(path, "media"), 1); err != nil {
return v, err
}
return v, nil
}
func decodeAdminResetAccountPasswordRequest(raw json.RawMessage, path string) (AdminResetAccountPasswordRequest, error) {
var v AdminResetAccountPasswordRequest
_, err := decodeObject(raw, path)
@@ -912,6 +975,18 @@ func decodeLoginRequest(raw json.RawMessage, path string) (LoginRequest, error)
return v, nil
}
func decodeMediaSort(raw json.RawMessage, path string) (MediaSort, error) {
s, err := decodeString(raw, path)
if err != nil {
return "", err
}
switch v := MediaSort(s); v {
case MediaSortStored, MediaSortIdle, MediaSortSize:
return v, nil
}
return "", invalid(path, "取值应为 stored、idle、size 之一")
}
func decodeMoveMyPlaylistItemsRequest(raw json.RawMessage, path string) (MoveMyPlaylistItemsRequest, error) {
var v MoveMyPlaylistItemsRequest
m, err := decodeObject(raw, path, "before", "tracks")
@@ -1366,6 +1441,24 @@ func decodeStartGithubLoginRequest(raw json.RawMessage, path string) (StartGithu
return v, nil
}
func decodeStoredMediaRef(raw json.RawMessage, path string) (StoredMediaRef, error) {
var v StoredMediaRef
m, err := decodeObject(raw, path, "key")
if err != nil {
return v, err
}
if err = requireFields(m, path, "key"); err != nil {
return v, err
}
if v.Key, err = decodeString(m["key"], field(path, "key")); err != nil {
return v, err
}
if err = checkPattern(v.Key, field(path, "key"), patternStoredMediaRefKey); err != nil {
return v, err
}
return v, nil
}
func decodeTrack(raw json.RawMessage, path string) (Track, error) {
var v Track
m, err := decodeObject(raw, path, "id", "source")
+55 -11
View File
@@ -309,11 +309,26 @@
"source": {"$ref": "#/$defs/Source"},
"link": {"type": "string", "description": "这首歌的页面:链接曲目是链接本身,B 站的歌是视频页面"},
"title": {"type": "string"},
"artist": {"type": "string", "description": "不知道时为空字符串"},
"coverUrl": {"type": "string", "description": "可以直接打开的封面地址,没有封面时为空字符串"},
"size": {"type": "integer", "description": "音频文件的字节数"},
"storedAtMs": {"type": "integer"},
"lastPlayedAtMs": {"anyOf": [{"type": "integer"}, {"type": "null"}], "description": "没有播放过时为 null"}
},
"required": ["key", "source", "link", "title", "size", "storedAtMs", "lastPlayedAtMs"]
"required": ["key", "source", "link", "title", "artist", "coverUrl", "size", "storedAtMs", "lastPlayedAtMs"]
},
"MediaSort": {
"type": "string",
"description": "stored:存储时间从新到旧;idle:最近播放从早到晚,从没播放过的排最前;size:占用从大到小。三种排序都以曲目 ID 作为最后的排序键,翻页时不重复也不遗漏",
"enum": ["stored", "idle", "size"]
},
"StoredMediaRef": {
"type": "object",
"additionalProperties": false,
"properties": {
"key": {"type": "string", "description": "StoredMedia 的 key:64 个小写十六进制字符", "pattern": "^[0-9a-f]{64}$"}
},
"required": ["key"]
},
"SourceAccount": {
"type": "object",
@@ -1544,26 +1559,55 @@
},
{
"name": "adminListMedia",
"description": "已存储的链接曲目和 B 站的歌",
"method": "GET",
"path": "/api/admin/media",
"description": "已存储的链接曲目和 B 站的歌,每页 50 首;页码超出最后一页时 items 为空,其余字段照常",
"method": "POST",
"path": "/api/admin/media/queries",
"auth": "admin",
"request": {
"type": "object",
"additionalProperties": false,
"properties": {
"page": {"$ref": "#/$defs/Page", "description": "页码,从 1 开始,缺省为 1"},
"sort": {"$ref": "#/$defs/MediaSort", "description": "缺省为 stored"},
"q": {"type": "string", "description": "按标题搜索:标题包含它就算匹配,不区分大小写,% 和 _ 按字面匹配;缺省或为空时不搜索", "maxLength": 100}
}
},
"response": {
"type": "object",
"additionalProperties": false,
"properties": {
"items": {"type": "array", "items": {"$ref": "#/$defs/StoredMedia"}}
"items": {"type": "array", "items": {"$ref": "#/$defs/StoredMedia"}},
"total": {"type": "integer", "description": "匹配的歌曲数,不只是这一页"},
"totalBytes": {"type": "integer", "description": "匹配的歌曲的音频字节数之和"},
"page": {"type": "integer"},
"pageSize": {"type": "integer", "description": "每页的条数,固定为 50"}
},
"required": ["items"]
"required": ["items", "total", "totalBytes", "page", "pageSize"]
}
},
{
"name": "adminDeleteMedia",
"description": "删除一首已存储的歌的音频和封面,并清除它的准备结果",
"method": "DELETE",
"path": "/api/admin/media/{key}",
"name": "adminRemoveMedia",
"description": "删除若干首已存储的歌的音频和封面,并清除它们的准备结果,一次 1 到 50 首:正在房间里使用的不会删除,放进 inUse;不存在的忽略。逐首删除,中途出错时返回错误,出错之前已经删掉的不会恢复,用同样的请求重试即可",
"method": "POST",
"path": "/api/admin/media/removals",
"auth": "admin",
"status": 204
"request": {
"type": "object",
"additionalProperties": false,
"properties": {
"media": {"type": "array", "items": {"$ref": "#/$defs/StoredMediaRef"}, "minItems": 1, "maxItems": 50}
},
"required": ["media"]
},
"response": {
"type": "object",
"additionalProperties": false,
"properties": {
"deleted": {"type": "array", "items": {"type": "string"}, "description": "删掉的歌的 key"},
"inUse": {"type": "array", "items": {"type": "string"}, "description": "正在房间里使用、没有删除的歌的 key"}
},
"required": ["deleted", "inUse"]
}
},
{
"name": "adminUpdateSite",
+149 -19
View File
@@ -307,6 +307,26 @@
],
"type": "object"
},
"AdminListMediaRequest": {
"additionalProperties": false,
"properties": {
"page": {
"description": "页码,从 1 开始,缺省为 1",
"minimum": 1,
"type": "integer"
},
"q": {
"description": "按标题搜索:标题包含它就算匹配,不区分大小写,% 和 _ 按字面匹配;缺省或为空时不搜索",
"maxLength": 100,
"type": "string"
},
"sort": {
"$ref": "#/components/schemas/MediaSort",
"description": "缺省为 stored"
}
},
"type": "object"
},
"AdminListMediaResponse": {
"additionalProperties": false,
"properties": {
@@ -315,10 +335,29 @@
"$ref": "#/components/schemas/StoredMedia"
},
"type": "array"
},
"page": {
"type": "integer"
},
"pageSize": {
"description": "每页的条数,固定为 50",
"type": "integer"
},
"total": {
"description": "匹配的歌曲数,不只是这一页",
"type": "integer"
},
"totalBytes": {
"description": "匹配的歌曲的音频字节数之和",
"type": "integer"
}
},
"required": [
"items"
"items",
"total",
"totalBytes",
"page",
"pageSize"
],
"type": "object"
},
@@ -341,6 +380,47 @@
"additionalProperties": false,
"type": "object"
},
"AdminRemoveMediaRequest": {
"additionalProperties": false,
"properties": {
"media": {
"items": {
"$ref": "#/components/schemas/StoredMediaRef"
},
"maxItems": 50,
"minItems": 1,
"type": "array"
}
},
"required": [
"media"
],
"type": "object"
},
"AdminRemoveMediaResponse": {
"additionalProperties": false,
"properties": {
"deleted": {
"description": "删掉的歌的 key",
"items": {
"type": "string"
},
"type": "array"
},
"inUse": {
"description": "正在房间里使用、没有删除的歌的 key",
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"deleted",
"inUse"
],
"type": "object"
},
"AdminResetAccountPasswordRequest": {
"additionalProperties": false,
"type": "object"
@@ -1134,6 +1214,15 @@
],
"type": "object"
},
"MediaSort": {
"description": "stored:存储时间从新到旧;idle:最近播放从早到晚,从没播放过的排最前;size:占用从大到小。三种排序都以曲目 ID 作为最后的排序键,翻页时不重复也不遗漏",
"enum": [
"stored",
"idle",
"size"
],
"type": "string"
},
"Member": {
"additionalProperties": false,
"properties": {
@@ -2048,6 +2137,14 @@
"StoredMedia": {
"additionalProperties": false,
"properties": {
"artist": {
"description": "不知道时为空字符串",
"type": "string"
},
"coverUrl": {
"description": "可以直接打开的封面地址,没有封面时为空字符串",
"type": "string"
},
"key": {
"description": "曲目 ID 的 SHA-256(十六进制),用于删除;链接曲目的 ID 是规范化之后的链接",
"type": "string"
@@ -2086,12 +2183,28 @@
"source",
"link",
"title",
"artist",
"coverUrl",
"size",
"storedAtMs",
"lastPlayedAtMs"
],
"type": "object"
},
"StoredMediaRef": {
"additionalProperties": false,
"properties": {
"key": {
"description": "StoredMedia 的 key:64 个小写十六进制字符",
"pattern": "^[0-9a-f]{64}$",
"type": "string"
}
},
"required": [
"key"
],
"type": "object"
},
"Track": {
"additionalProperties": false,
"allOf": [
@@ -2265,7 +2378,7 @@
},
"info": {
"title": "Alisten",
"version": "6bee23791ffa52ad"
"version": "ce462456c63356c6"
},
"openapi": "3.1.0",
"paths": {
@@ -3062,9 +3175,19 @@
"summary": "写入或覆盖 YouTube cookies,不返回 cookies"
}
},
"/api/admin/media": {
"get": {
"/api/admin/media/queries": {
"post": {
"operationId": "adminListMedia",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminListMediaRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
@@ -3092,24 +3215,31 @@
"adminToken": []
}
],
"summary": "已存储的链接曲目和 B 站的歌"
"summary": "已存储的链接曲目和 B 站的歌,每页 50 首;页码超出最后一页时 items 为空,其余字段照常"
}
},
"/api/admin/media/{key}": {
"delete": {
"operationId": "adminDeleteMedia",
"parameters": [
{
"in": "path",
"name": "key",
"required": true,
"schema": {
"type": "string"
"/api/admin/media/removals": {
"post": {
"operationId": "adminRemoveMedia",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminRemoveMediaRequest"
}
}
}
],
},
"required": true
},
"responses": {
"204": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminRemoveMediaResponse"
}
}
},
"description": "成功"
},
"default": {
@@ -3128,7 +3258,7 @@
"adminToken": []
}
],
"summary": "删除一首已存储的歌的音频和封面,并清除它的准备结果"
"summary": "删除若干首已存储的歌的音频和封面,并清除它们的准备结果,一次 1 到 50 首:正在房间里使用的不会删除,放进 inUse;不存在的忽略。逐首删除,中途出错时返回错误,出错之前已经删掉的不会恢复,用同样的请求重试即可"
}
},
"/api/admin/registration": {
+56 -4
View File
@@ -4,7 +4,7 @@ package protocol
import "encoding/json"
const Version = "6bee23791ffa52ad"
const Version = "ce462456c63356c6"
type APIKey struct {
Key string `json:"key"`
@@ -95,8 +95,18 @@ type AdminGetYoutubeCredentialResponse struct {
UpdatedAtMs int64 `json:"updatedAtMs"`
}
type AdminListMediaRequest struct {
Page *int64 `json:"page,omitempty"`
Q *string `json:"q,omitempty"`
Sort *MediaSort `json:"sort,omitempty"`
}
type AdminListMediaResponse struct {
Items []StoredMedia `json:"items"`
Items []StoredMedia `json:"items"`
Page int64 `json:"page"`
PageSize int64 `json:"pageSize"`
Total int64 `json:"total"`
TotalBytes int64 `json:"totalBytes"`
}
func (v AdminListMediaResponse) MarshalJSON() ([]byte, error) {
@@ -121,6 +131,34 @@ func (v AdminListRoomsResponse) MarshalJSON() ([]byte, error) {
type AdminOpenRegistrationRequest struct{}
type AdminRemoveMediaRequest struct {
Media []StoredMediaRef `json:"media"`
}
func (v AdminRemoveMediaRequest) MarshalJSON() ([]byte, error) {
type plain AdminRemoveMediaRequest
if v.Media == nil {
v.Media = []StoredMediaRef{}
}
return json.Marshal(plain(v))
}
type AdminRemoveMediaResponse struct {
Deleted []string `json:"deleted"`
InUse []string `json:"inUse"`
}
func (v AdminRemoveMediaResponse) MarshalJSON() ([]byte, error) {
type plain AdminRemoveMediaResponse
if v.Deleted == nil {
v.Deleted = []string{}
}
if v.InUse == nil {
v.InUse = []string{}
}
return json.Marshal(plain(v))
}
type AdminResetAccountPasswordRequest struct{}
type AdminResetAccountPasswordResponse struct {
@@ -444,6 +482,14 @@ type LoginResponse struct {
MemberToken string `json:"memberToken"`
}
type MediaSort string
const (
MediaSortStored MediaSort = "stored"
MediaSortIdle MediaSort = "idle"
MediaSortSize MediaSort = "size"
)
type Member struct {
AvatarHash string `json:"avatarHash"`
ID string `json:"id"`
@@ -854,6 +900,8 @@ type StartGithubLoginResponse struct {
}
type StoredMedia struct {
Artist string `json:"artist"`
CoverURL string `json:"coverUrl"`
Key string `json:"key"`
LastPlayedAtMs *int64 `json:"lastPlayedAtMs"`
Link string `json:"link"`
@@ -863,6 +911,10 @@ type StoredMedia struct {
Title string `json:"title"`
}
type StoredMediaRef struct {
Key string `json:"key"`
}
type Track struct {
ID string `json:"id"`
Source Source `json:"source"`
@@ -985,8 +1037,8 @@ var (
RouteAdminGetGithubCredential = Route{Pattern: "GET /api/admin/credentials/github", Auth: AuthAdmin, Status: 200}
RouteAdminSetGithubCredential = Route{Pattern: "PUT /api/admin/credentials/github", Auth: AuthAdmin, Status: 204}
RouteAdminDeleteGithubCredential = Route{Pattern: "DELETE /api/admin/credentials/github", Auth: AuthAdmin, Status: 204}
RouteAdminListMedia = Route{Pattern: "GET /api/admin/media", Auth: AuthAdmin, Status: 200}
RouteAdminDeleteMedia = Route{Pattern: "DELETE /api/admin/media/{key}", Auth: AuthAdmin, Status: 204}
RouteAdminListMedia = Route{Pattern: "POST /api/admin/media/queries", Auth: AuthAdmin, Status: 200}
RouteAdminRemoveMedia = Route{Pattern: "POST /api/admin/media/removals", Auth: AuthAdmin, Status: 200}
RouteAdminUpdateSite = Route{Pattern: "PATCH /api/admin/site", Auth: AuthAdmin, Status: 200}
RouteAdminOpenRegistration = Route{Pattern: "PUT /api/admin/registration", Auth: AuthAdmin, Status: 204}
RouteAdminCloseRegistration = Route{Pattern: "DELETE /api/admin/registration", Auth: AuthAdmin, Status: 204}
+22 -3
View File
@@ -1,6 +1,6 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
import type { APIKey, Account, AdminAccount, ErrorInfo, GitHubClient, GitHubIntent, ImportedTrack, Member, MyListen, MyPlaylist, MyPlaylistItem, QQLoginType, QRLogin, QRLoginResult, RoomListing, SharedPlaylist, Source, SourceAccount, StoredMedia, Track } from '../realtime/protocol.gen'
import type { APIKey, Account, AdminAccount, ErrorInfo, GitHubClient, GitHubIntent, ImportedTrack, MediaSort, Member, MyListen, MyPlaylist, MyPlaylistItem, QQLoginType, QRLogin, QRLoginResult, RoomListing, SharedPlaylist, Source, SourceAccount, StoredMedia, StoredMediaRef, Track } from '../realtime/protocol.gen'
export interface AddMyPlaylistItemRequest {
track: Track
@@ -65,8 +65,18 @@ export interface AdminGetYoutubeCredentialResponse {
updatedAtMs: number
}
export interface AdminListMediaRequest {
page?: number
q?: string
sort?: MediaSort
}
export interface AdminListMediaResponse {
items: StoredMedia[]
page: number
pageSize: number
total: number
totalBytes: number
}
export interface AdminListRoomsResponse {
@@ -75,6 +85,15 @@ export interface AdminListRoomsResponse {
export type AdminOpenRegistrationRequest = Record<string, never>
export interface AdminRemoveMediaRequest {
media: StoredMediaRef[]
}
export interface AdminRemoveMediaResponse {
deleted: string[]
inUse: string[]
}
export type AdminResetAccountPasswordRequest = Record<string, never>
export interface AdminResetAccountPasswordResponse {
@@ -391,8 +410,8 @@ export function httpClient(send: HttpSend) {
adminGetGithubCredential: () => send<AdminGetGithubCredentialResponse>({ method: 'GET', path: '/api/admin/credentials/github', auth: 'admin' }),
adminSetGithubCredential: (body: AdminSetGithubCredentialRequest) => send<void>({ method: 'PUT', path: '/api/admin/credentials/github', auth: 'admin', body }),
adminDeleteGithubCredential: () => send<void>({ method: 'DELETE', path: '/api/admin/credentials/github', auth: 'admin' }),
adminListMedia: () => send<AdminListMediaResponse>({ method: 'GET', path: '/api/admin/media', auth: 'admin' }),
adminDeleteMedia: (key: string) => send<void>({ method: 'DELETE', path: `/api/admin/media/${encodeURIComponent(key)}`, auth: 'admin' }),
adminListMedia: (body: AdminListMediaRequest) => send<AdminListMediaResponse>({ method: 'POST', path: '/api/admin/media/queries', auth: 'admin', body }),
adminRemoveMedia: (body: AdminRemoveMediaRequest) => send<AdminRemoveMediaResponse>({ method: 'POST', path: '/api/admin/media/removals', auth: 'admin', body }),
adminUpdateSite: (body: AdminUpdateSiteRequest) => send<AdminUpdateSiteResponse>({ method: 'PATCH', path: '/api/admin/site', auth: 'admin', body }),
adminOpenRegistration: () => send<void>({ method: 'PUT', path: '/api/admin/registration', auth: 'admin', body: {} }),
adminCloseRegistration: () => send<void>({ method: 'DELETE', path: '/api/admin/registration', auth: 'admin' }),
+9 -1
View File
@@ -1,6 +1,6 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
export const PROTOCOL_VERSION = '6bee23791ffa52ad'
export const PROTOCOL_VERSION = 'ce462456c63356c6'
export interface APIKey {
key: string
@@ -76,6 +76,8 @@ export interface ImportedTrack {
track: Track
}
export type MediaSort = 'stored' | 'idle' | 'size'
export interface Member {
avatarHash: string
id: string
@@ -308,6 +310,8 @@ export interface SourceAccount {
}
export interface StoredMedia {
artist: string
coverUrl: string
key: string
lastPlayedAtMs: number | null
link: string
@@ -317,6 +321,10 @@ export interface StoredMedia {
title: string
}
export interface StoredMediaRef {
key: string
}
export interface Track {
id: string
source: Source
+17 -12
View File
@@ -1,10 +1,11 @@
import type { AdminListMediaResponse } from '@/core/api'
import type { StoredMedia as Media } from '@/core/realtime'
import { Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { api } from '@/core/api'
import { bookTitle } from '@/shared/lib/book-title'
import { formatDateTime } from '@/shared/lib/time'
import { ConfirmDialog, IconButton, Skeleton } from '@/shared/ui'
import { ConfirmDialog, IconButton, notificationStore, Skeleton } from '@/shared/ui'
import { adminStore } from './admin-store'
function formatSize(bytes: number): string {
@@ -12,17 +13,17 @@ function formatSize(bytes: number): string {
}
export function StoredMedia() {
const [media, setMedia] = useState<Media[] | null>(null)
const [media, setMedia] = useState<AdminListMediaResponse | null>(null)
const [failed, setFailed] = useState(false)
const [reload, setReload] = useState(0)
const [deleting, setDeleting] = useState<Media | null>(null)
useEffect(() => {
let active = true
void adminStore.call(() => api.adminListMedia()).then((result) => {
void adminStore.call(() => api.adminListMedia({ page: 1 })).then((result) => {
if (!active)
return
if (result)
setMedia(result.items)
setMedia(result)
else
setFailed(true)
})
@@ -35,12 +36,11 @@ export function StoredMedia() {
setMedia(null)
setReload(n => n + 1)
}
const totalSize = media?.reduce((sum, item) => sum + item.size, 0) ?? 0
return (
<section className="admin-section" aria-labelledby="admin-media-title">
<div className="section-heading">
<h2 id="admin-media-title" className="text-ui-lg">已存储的媒体</h2>
{media && <span className="text-ui-sm muted">{media.length ? `${media.length} 首 · ${formatSize(totalSize)}` : '0 首'}</span>}
{media && <span className="text-ui-sm muted">{media.total ? `${media.total} 首 · ${formatSize(media.totalBytes)}` : '0 首'}</span>}
</div>
{failed
? (
@@ -51,11 +51,11 @@ export function StoredMedia() {
)
: !media
? <Skeleton rows={3} />
: !media.length
: !media.items.length
? <p className="admin-empty text-ui-caption muted">还没有存储任何歌曲。</p>
: (
<ul className="admin-list">
{media.map(item => (
{media.items.map(item => (
<li className="admin-row" key={item.key}>
<div className="admin-row-copy">
<strong className="text-ui-base truncate">{item.title}</strong>
@@ -75,11 +75,16 @@ export function StoredMedia() {
confirmLabel="删除"
danger
onConfirm={async () => {
if (await adminStore.call(() => api.adminDeleteMedia(deleting.key)) !== null) {
setDeleting(null)
setFailed(false)
setReload(n => n + 1)
const result = await adminStore.call(() => api.adminRemoveMedia({ media: [{ key: deleting.key }] }))
if (result === null)
return
if (result.inUse.length) {
notificationStore.error('这首歌还在房间的播放列表中,不能删除')
return
}
setDeleting(null)
setFailed(false)
setReload(n => n + 1)
}}
onClose={() => setDeleting(null)}
>