From 599f1284ec15995e7e857dbbbf95ed44d49a2b1f Mon Sep 17 00:00:00 2001 From: DiheChen Date: Wed, 11 Nov 2026 10:58:32 +0800 Subject: [PATCH] feat: page, sort and search stored media and delete several at once in the admin API --- db/queries/media.sql | 17 +- internal/adapter/in/api/api.go | 4 +- internal/adapter/in/api/media.go | 62 +++++-- internal/adapter/in/api/media_test.go | 88 ++++----- .../out/postgres/internal/query/media.sql.go | 54 +++++- internal/adapter/out/postgres/media.go | 48 ++++- internal/adapter/out/postgres/media_test.go | 134 ++++++++++++-- internal/app/media/library.go | 19 +- internal/app/media/library_test.go | 122 ++++++++----- internal/app/port/media.go | 41 ++++- internal/app/port/porttest/media.go | 74 ++++++++ internal/app/port/porttest/media_test.go | 71 ++++++++ internal/bootstrap/media_test.go | 57 ++++++ .../testdata/http/admin-disabled.txtar | 8 +- internal/bootstrap/testdata/http/admin.txtar | 105 ++++++++++- internal/bootstrap/worker_test.go | 19 +- protocol/decode.gen.go | 93 ++++++++++ protocol/idl.json | 66 +++++-- protocol/openapi.gen.json | 168 ++++++++++++++++-- protocol/types.gen.go | 60 ++++++- web/src/core/api/http.gen.ts | 25 ++- web/src/core/realtime/protocol.gen.ts | 10 +- web/src/features/admin/stored-media.tsx | 29 +-- 23 files changed, 1161 insertions(+), 213 deletions(-) create mode 100644 internal/app/port/porttest/media_test.go diff --git a/db/queries/media.sql b/db/queries/media.sql index a6562ff..04ff219 100644 --- a/db/queries/media.sql +++ b/db/queries/media.sql @@ -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 diff --git a/internal/adapter/in/api/api.go b/internal/adapter/in/api/api.go index 355a8f1..1db4240 100644 --- a/internal/adapter/in/api/api.go +++ b/internal/adapter/in/api/api.go @@ -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}, diff --git a/internal/adapter/in/api/media.go b/internal/adapter/in/api/media.go index 808d897..795dc10 100644 --- a/internal/adapter/in/api/media.go +++ b/internal/adapter/in/api/media.go @@ -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 } diff --git a/internal/adapter/in/api/media_test.go b/internal/adapter/in/api/media_test.go index c647841..71138b0 100644 --- a/internal/adapter/in/api/media_test.go +++ b/internal/adapter/in/api/media_test.go @@ -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 diff --git a/internal/adapter/out/postgres/internal/query/media.sql.go b/internal/adapter/out/postgres/internal/query/media.sql.go index 9621289..508d6c3 100644 --- a/internal/adapter/out/postgres/internal/query/media.sql.go +++ b/internal/adapter/out/postgres/internal/query/media.sql.go @@ -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, diff --git a/internal/adapter/out/postgres/media.go b/internal/adapter/out/postgres/media.go index 7f6c913..bacb39f 100644 --- a/internal/adapter/out/postgres/media.go +++ b/internal/adapter/out/postgres/media.go @@ -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), diff --git a/internal/adapter/out/postgres/media_test.go b/internal/adapter/out/postgres/media_test.go index a58c86b..6d10479 100644 --- a/internal/adapter/out/postgres/media_test.go +++ b/internal/adapter/out/postgres/media_test.go @@ -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) + } } } diff --git a/internal/app/media/library.go b/internal/app/media/library.go index 366ce86..15b7906 100644 --- a/internal/app/media/library.go +++ b/internal/app/media/library.go @@ -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 } // 先删除对象,再清除准备结果:中途失败时准备结果还在,后台仍能看到这首歌并重试; diff --git a/internal/app/media/library_test.go b/internal/app/media/library_test.go index c140243..379ec8c 100644 --- a/internal/app/media/library_test.go +++ b/internal/app/media/library_test.go @@ -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("对象删除失败时准备结果应保留") + } +} diff --git a/internal/app/port/media.go b/internal/app/port/media.go index 6564298..10284cd 100644 --- a/internal/app/port/media.go +++ b/internal/app/port/media.go @@ -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 diff --git a/internal/app/port/porttest/media.go b/internal/app/port/porttest/media.go index 2e25e49..7490b3f 100644 --- a/internal/app/port/porttest/media.go +++ b/internal/app/port/porttest/media.go @@ -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 +} diff --git a/internal/app/port/porttest/media_test.go b/internal/app/port/porttest/media_test.go new file mode 100644 index 0000000..840980c --- /dev/null +++ b/internal/app/port/porttest/media_test.go @@ -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) + } +} diff --git a/internal/bootstrap/media_test.go b/internal/bootstrap/media_test.go index f537275..3c0ff9e 100644 --- a/internal/bootstrap/media_test.go +++ b/internal/bootstrap/media_test.go @@ -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) + } + } +} diff --git a/internal/bootstrap/testdata/http/admin-disabled.txtar b/internal/bootstrap/testdata/http/admin-disabled.txtar index 686ed38..2d9b2a3 100644 --- a/internal/bootstrap/testdata/http/admin-disabled.txtar +++ b/internal/bootstrap/testdata/http/admin-disabled.txtar @@ -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。"}} diff --git a/internal/bootstrap/testdata/http/admin.txtar b/internal/bootstrap/testdata/http/admin.txtar index c311299..023143a 100644 --- a/internal/bootstrap/testdata/http/admin.txtar +++ b/internal/bootstrap/testdata/http/admin.txtar @@ -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 无效"}} diff --git a/internal/bootstrap/worker_test.go b/internal/bootstrap/worker_test.go index 9a4640a..ba7f2ad 100644 --- a/internal/bootstrap/worker_test.go +++ b/internal/bootstrap/worker_test.go @@ -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) } } diff --git a/protocol/decode.gen.go b/protocol/decode.gen.go index d0e4660..b505550 100644 --- a/protocol/decode.gen.go +++ b/protocol/decode.gen.go @@ -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") diff --git a/protocol/idl.json b/protocol/idl.json index 82423f9..11391e8 100644 --- a/protocol/idl.json +++ b/protocol/idl.json @@ -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", diff --git a/protocol/openapi.gen.json b/protocol/openapi.gen.json index 9dbbf92..12306d4 100644 --- a/protocol/openapi.gen.json +++ b/protocol/openapi.gen.json @@ -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": { diff --git a/protocol/types.gen.go b/protocol/types.gen.go index 0c9db1b..1b1766a 100644 --- a/protocol/types.gen.go +++ b/protocol/types.gen.go @@ -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} diff --git a/web/src/core/api/http.gen.ts b/web/src/core/api/http.gen.ts index ff60953..1f2a8e4 100644 --- a/web/src/core/api/http.gen.ts +++ b/web/src/core/api/http.gen.ts @@ -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 +export interface AdminRemoveMediaRequest { + media: StoredMediaRef[] +} + +export interface AdminRemoveMediaResponse { + deleted: string[] + inUse: string[] +} + export type AdminResetAccountPasswordRequest = Record export interface AdminResetAccountPasswordResponse { @@ -391,8 +410,8 @@ export function httpClient(send: HttpSend) { adminGetGithubCredential: () => send({ method: 'GET', path: '/api/admin/credentials/github', auth: 'admin' }), adminSetGithubCredential: (body: AdminSetGithubCredentialRequest) => send({ method: 'PUT', path: '/api/admin/credentials/github', auth: 'admin', body }), adminDeleteGithubCredential: () => send({ method: 'DELETE', path: '/api/admin/credentials/github', auth: 'admin' }), - adminListMedia: () => send({ method: 'GET', path: '/api/admin/media', auth: 'admin' }), - adminDeleteMedia: (key: string) => send({ method: 'DELETE', path: `/api/admin/media/${encodeURIComponent(key)}`, auth: 'admin' }), + adminListMedia: (body: AdminListMediaRequest) => send({ method: 'POST', path: '/api/admin/media/queries', auth: 'admin', body }), + adminRemoveMedia: (body: AdminRemoveMediaRequest) => send({ method: 'POST', path: '/api/admin/media/removals', auth: 'admin', body }), adminUpdateSite: (body: AdminUpdateSiteRequest) => send({ method: 'PATCH', path: '/api/admin/site', auth: 'admin', body }), adminOpenRegistration: () => send({ method: 'PUT', path: '/api/admin/registration', auth: 'admin', body: {} }), adminCloseRegistration: () => send({ method: 'DELETE', path: '/api/admin/registration', auth: 'admin' }), diff --git a/web/src/core/realtime/protocol.gen.ts b/web/src/core/realtime/protocol.gen.ts index c942ec5..9132a20 100644 --- a/web/src/core/realtime/protocol.gen.ts +++ b/web/src/core/realtime/protocol.gen.ts @@ -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 diff --git a/web/src/features/admin/stored-media.tsx b/web/src/features/admin/stored-media.tsx index 9ae0815..666d17d 100644 --- a/web/src/features/admin/stored-media.tsx +++ b/web/src/features/admin/stored-media.tsx @@ -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(null) + const [media, setMedia] = useState(null) const [failed, setFailed] = useState(false) const [reload, setReload] = useState(0) const [deleting, setDeleting] = useState(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 (

已存储的媒体

- {media && {media.length ? `${media.length} 首 · ${formatSize(totalSize)}` : '0 首'}} + {media && {media.total ? `${media.total} 首 · ${formatSize(media.totalBytes)}` : '0 首'}}
{failed ? ( @@ -51,11 +51,11 @@ export function StoredMedia() { ) : !media ? - : !media.length + : !media.items.length ?

还没有存储任何歌曲。

: (
    - {media.map(item => ( + {media.items.map(item => (
  • {item.title} @@ -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)} >