feat: expose member playlists over HTTP

This commit is contained in:
DiheChen committed 2026-11-09 21:27:41 +08:00
1 parent 95a56267a4
commit fafde9041c
13 files changed
+1280 -33

No files matched your search

+26 -9
View File
@@ -21,6 +21,7 @@ import (
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/internal/app/lobby"
"github.com/dihemoe/alisten/internal/app/media"
"github.com/dihemoe/alisten/internal/app/playlist"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/realtime"
"github.com/dihemoe/alisten/internal/app/site"
@@ -36,15 +37,16 @@ const (
)
type Services struct {
Identity *identity.Service
Accounts *identity.Accounts
Lobby *lobby.Service
Admin *admin.Service
Gateway *realtime.Gateway
Catalog *catalog.Service
Media *media.Library
Playback *media.Playback
Site *site.Service
Identity *identity.Service
Accounts *identity.Accounts
Playlists *playlist.Service
Lobby *lobby.Service
Admin *admin.Service
Gateway *realtime.Gateway
Catalog *catalog.Service
Media *media.Library
Playback *media.Playback
Site *site.Service
// 浏览器访问对象存储时使用的地址前缀,结尾没有 /。
StorageURL string
@@ -105,6 +107,13 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
srv.handle(mux, protocol.RouteChangePassword, srv.changePassword)
srv.handle(mux, protocol.RouteLogin, srv.login)
srv.handle(mux, protocol.RouteLogout, srv.logout)
srv.handle(mux, protocol.RouteListMyPlaylists, srv.listMyPlaylists)
srv.handle(mux, protocol.RouteCreateMyPlaylist, srv.createMyPlaylist)
srv.handle(mux, protocol.RouteRenameMyPlaylist, srv.renameMyPlaylist)
srv.handle(mux, protocol.RouteDeleteMyPlaylist, srv.deleteMyPlaylist)
srv.handle(mux, protocol.RouteListMyPlaylistItems, srv.listMyPlaylistItems)
srv.handle(mux, protocol.RouteAddMyPlaylistItem, srv.addMyPlaylistItem)
srv.handle(mux, protocol.RouteRemoveMyPlaylistItem, srv.removeMyPlaylistItem)
srv.handle(mux, protocol.RouteListRooms, srv.listRooms)
srv.handle(mux, protocol.RouteGetRoom, srv.getRoom)
srv.handle(mux, protocol.RouteCreateRoom, srv.createRoom)
@@ -299,6 +308,14 @@ var knownErrors = []struct {
{identity.ErrAccountNotFound, protocol.ErrorCodeNotFound},
{port.ErrUsernameTaken, protocol.ErrorCodeConflict},
{port.ErrAlreadyRegistered, protocol.ErrorCodeConflict},
{port.ErrPlaylistNotFound, protocol.ErrorCodeNotFound},
{port.ErrFavoritesFixed, protocol.ErrorCodeConflict},
{playlist.ErrNotRegistered, protocol.ErrorCodeForbidden},
{playlist.ErrInvalidName, protocol.ErrorCodeInvalidArgument},
{playlist.ErrTooManyPlaylists, protocol.ErrorCodeConflict},
{playlist.ErrPlaylistFull, protocol.ErrorCodeConflict},
{playlist.ErrTrackNotReady, protocol.ErrorCodeConflict},
{playlist.ErrTooFast, protocol.ErrorCodeRateLimited},
{realtime.ErrRequesterNotAllowed, protocol.ErrorCodeInvalidArgument},
{site.ErrInvalidBrandName, protocol.ErrorCodeInvalidArgument},
{realtime.ErrUnavailable, protocol.ErrorCodeUnavailable},
+1 -1
View File
@@ -41,7 +41,7 @@ func (s *server) answer(ctx context.Context, roomID string, data any) (resp any,
func summaries(songs []port.Song) []protocol.TrackSummary {
items := make([]protocol.TrackSummary, len(songs))
for i, s := range songs {
items[i] = protocol.TrackSummary{Track: protocol.Track{Source: protocol.Source(s.Track.Source), ID: s.Track.ID}, Meta: meta(s.Meta)}
items[i] = protocol.TrackSummary{Track: trackDTO(s.Track), Meta: meta(s.Meta)}
}
return items
}
+30 -10
View File
@@ -48,11 +48,7 @@ func (s *server) action(ctx context.Context, roomID string, by member.Member, bo
// 协议解码保证要么只给出 track,要么只给出 source 和 keyword。
func pick(d protocol.QueueAddRequest) realtime.Pick {
if d.Track != nil {
t := track.Track{Source: track.Source(d.Track.Source), ID: d.Track.ID}
if t.Source == track.Link {
t.ID = track.LinkID(t.ID)
}
return realtime.Pick{Track: t, Requester: deref(d.Requester)}
return realtime.Pick{Track: trackOf(*d.Track), Requester: deref(d.Requester)}
}
return realtime.Pick{Source: track.Source(*d.Source), Keyword: *d.Keyword, Requester: deref(d.Requester)}
}
@@ -145,18 +141,42 @@ func (s *server) mediaURL(t track.Track) string {
return "/media/" + string(t.Source) + "/" + url.PathEscape(t.ID)
}
// 链接按点歌时的写法规范化:曲目表和歌单里的链接曲目都用规范化之后的 ID。
func trackOf(d protocol.Track) track.Track {
t := track.Track{Source: track.Source(d.Source), ID: d.ID}
if t.Source == track.Link {
t.ID = track.LinkID(t.ID)
}
return t
}
// 封面在对象存储中的曲目,对外的封面地址由公开地址拼出。
func (s *server) trackMeta(m track.Meta) protocol.TrackMeta {
d := meta(m)
d.CoverURL = s.coverURL(m.CoverURL, m.CoverKey)
return d
}
func (s *server) coverURL(url string, key track.ObjectKey) string {
if key != "" {
return s.StorageURL + "/" + string(key)
}
return url
}
func trackDTO(t track.Track) protocol.Track {
return protocol.Track{Source: protocol.Source(t.Source), ID: t.ID}
}
func (s *server) entry(e room.Entry) protocol.Entry {
d := protocol.Entry{
ID: strconv.FormatUint(uint64(e.ID), 10),
Track: protocol.Track{Source: protocol.Source(e.Track.Source), ID: e.Track.ID},
Meta: meta(e.Meta),
Track: trackDTO(e.Track),
Meta: s.trackMeta(e.Meta),
Auto: e.Auto,
Likes: int64(e.Likes),
Status: protocol.EntryStatusPreparing,
}
if e.Meta.CoverKey != "" {
d.Meta.CoverURL = s.StorageURL + "/" + string(e.Meta.CoverKey)
}
if !e.Auto {
by := memberDTO(e.By)
d.By = &by
+100
View File
@@ -0,0 +1,100 @@
package api
import (
"context"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/protocol"
)
func (s *server) playlistDTO(p port.PlaylistRecord) protocol.MyPlaylist {
var cover *string
if p.Cover != nil {
url := s.coverURL(p.Cover.URL, p.Cover.Key)
cover = &url
}
return protocol.MyPlaylist{
ID: p.ID,
Name: p.Name,
Favorites: p.Favorites,
TrackCount: int64(p.TrackCount),
CreatedAtMs: p.CreatedAt.UnixMilli(),
UpdatedAtMs: p.UpdatedAt.UnixMilli(),
CoverURL: cover,
}
}
func (s *server) playlistItemDTO(item port.PlaylistItem) protocol.MyPlaylistItem {
return protocol.MyPlaylistItem{
Track: trackDTO(item.Track),
Meta: s.trackMeta(item.Meta),
}
}
func (s *server) listMyPlaylists(ctx context.Context, c call) (any, error) {
lists, err := s.Playlists.List(ctx, c.member.ID)
if err != nil {
return nil, err
}
items := make([]protocol.MyPlaylist, len(lists))
for i, p := range lists {
items[i] = s.playlistDTO(p)
}
return protocol.ListMyPlaylistsResponse{Items: items}, nil
}
func (s *server) createMyPlaylist(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeCreateMyPlaylistRequest(c.body)
if err != nil {
return nil, err
}
p, err := s.Playlists.Create(ctx, c.member.ID, req.Name)
if err != nil {
return nil, err
}
return s.playlistDTO(p), nil
}
func (s *server) renameMyPlaylist(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeRenameMyPlaylistRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.Playlists.Rename(ctx, c.member.ID, c.r.PathValue("id"), req.Name)
}
func (s *server) deleteMyPlaylist(ctx context.Context, c call) (any, error) {
return nil, s.Playlists.Delete(ctx, c.member.ID, c.r.PathValue("id"))
}
func (s *server) listMyPlaylistItems(ctx context.Context, c call) (any, error) {
list, err := s.Playlists.Items(ctx, c.member.ID, c.r.PathValue("id"))
if err != nil {
return nil, err
}
items := make([]protocol.MyPlaylistItem, len(list))
for i, item := range list {
items[i] = s.playlistItemDTO(item)
}
return protocol.ListMyPlaylistItemsResponse{Items: items}, nil
}
func (s *server) addMyPlaylistItem(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeAddMyPlaylistItemRequest(c.body)
if err != nil {
return nil, err
}
item, err := s.Playlists.Add(ctx, c.member.ID, c.r.PathValue("id"), trackOf(req.Track))
if err != nil {
return nil, err
}
return protocol.AddMyPlaylistItemResponse{Item: s.playlistItemDTO(item)}, nil
}
func (s *server) removeMyPlaylistItem(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeRemoveMyPlaylistItemRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.Playlists.Remove(ctx, c.member.ID, c.r.PathValue("id"), trackOf(req.Track))
}
+89
View File
@@ -1,6 +1,7 @@
package bootstrap
import (
"encoding/json"
"io"
"log/slog"
"net/http"
@@ -155,3 +156,91 @@ func TestRestoredLinksNeedTheirAudio(t *testing.T) {
t.Errorf("音频还在的链接有 %d 个准备任务,期望没有", n)
}
}
// 准备好的链接曲目进了歌单之后,三处封面地址都由对象存储的公开地址和封面的对象键拼出;换一种大小写写法也能找到它。
func TestPlaylistLinkTracks(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)
const link = "https://example.com/Song.mp3"
cover := track.ObjectKey(media.CoverObject(media.Key(link), "jpg"))
prepared := port.PreparedTrack{
Meta: track.Meta{Title: "Song.mp3", CoverKey: cover, PageURL: link, Duration: 2 * time.Second},
Result: track.Prepared{Duration: 2 * time.Second, 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)
}
coverURL := "https://alisten.example/storage/alisten/" + string(cover)
call := func(request string, status int, resp any) {
t.Helper()
line, got, _, body := send(t, svc.server, request)
if got != status {
t.Fatalf("%s = %d %s,期望 %d", line, got, body, status)
}
if resp != nil {
if err := json.Unmarshal(body, resp); err != nil {
t.Fatalf("%s 的响应体 %s:%v", line, body, err)
}
}
}
var session struct {
MemberToken string `json:"memberToken"`
}
call("POST /api/members\n\n{}", http.StatusCreated, &session)
auth := "\nAuthorization: Bearer " + session.MemberToken
call("POST /api/account"+auth+"\n\n"+`{"username": "alice", "password": "correct horse"}`, http.StatusCreated, nil)
type item struct {
Track struct {
ID string `json:"id"`
} `json:"track"`
Meta struct {
CoverURL string `json:"coverUrl"`
} `json:"meta"`
}
var lists struct {
Items []struct {
ID string `json:"id"`
CoverURL *string `json:"coverUrl"`
} `json:"items"`
}
call("GET /api/me/playlists"+auth, http.StatusOK, &lists)
favorites := lists.Items[0].ID
var added struct {
Item item `json:"item"`
}
call("POST /api/me/playlists/"+favorites+"/items"+auth+"\n\n"+`{"track": {"source": "link", "id": "HTTPS://EXAMPLE.COM/Song.mp3"}}`, http.StatusCreated, &added)
if added.Item.Track.ID != link || added.Item.Meta.CoverURL != coverURL {
t.Errorf("加入的曲目 = %+v,期望 ID %s、封面 %s", added.Item, link, coverURL)
}
call("GET /api/me/playlists"+auth, http.StatusOK, &lists)
if got := lists.Items[0].CoverURL; got == nil || *got != coverURL {
t.Errorf("歌单的封面 = %v,期望 %s", got, coverURL)
}
var items struct {
Items []item `json:"items"`
}
call("GET /api/me/playlists/"+favorites+"/items"+auth, http.StatusOK, &items)
if len(items.Items) != 1 || items.Items[0].Track.ID != link || items.Items[0].Meta.CoverURL != coverURL {
t.Errorf("歌单里的曲目 = %+v,期望一首 ID %s、封面 %s", items.Items, link, coverURL)
}
call("POST /api/me/playlists/"+favorites+"/removals"+auth+"\n\n"+`{"track": {"source": "link", "id": "hTTps://example.COM/Song.mp3"}}`, http.StatusNoContent, nil)
call("GET /api/me/playlists/"+favorites+"/items"+auth, http.StatusOK, &items)
if len(items.Items) != 0 {
t.Errorf("换一种大小写移出之后还有 %+v,期望没有", items.Items)
}
}
+11 -9
View File
@@ -28,6 +28,7 @@ import (
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/internal/app/lobby"
"github.com/dihemoe/alisten/internal/app/media"
"github.com/dihemoe/alisten/internal/app/playlist"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/prepare"
"github.com/dihemoe/alisten/internal/app/realtime"
@@ -77,15 +78,16 @@ func newPublic(cfg serveConfig, pool *pgxpool.Pool, rdb *goredis.Client, instanc
registerRuntimeMetrics(metrics, host, gateway)
rooms := lobby.New(store, leases)
services := api.Services{
Identity: identity.New(store, store, store, gateway),
Accounts: identity.NewAccounts(store, store, redis.NewAccountQuota(rdb), redis.NewDevices(rdb), gateway),
Lobby: rooms,
Admin: admin.New(cfg.adminToken, rooms, store, store, store, src.neteaseLogin, src.qqLogin, src.bilibiliLogin, store, gateway),
Gateway: gateway,
Catalog: music,
Media: media.NewLibrary(store, store, objects),
Playback: media.NewPlayback(src.playback, store),
Site: site.New(store),
Identity: identity.New(store, store, store, gateway),
Accounts: identity.NewAccounts(store, store, redis.NewAccountQuota(rdb), redis.NewDevices(rdb), gateway),
Playlists: playlist.New(store, store, store, music, redis.NewAccountQuota(rdb)),
Lobby: rooms,
Admin: admin.New(cfg.adminToken, rooms, store, store, store, src.neteaseLogin, src.qqLogin, src.bilibiliLogin, store, gateway),
Gateway: gateway,
Catalog: music,
Media: media.NewLibrary(store, store, objects),
Playback: media.NewPlayback(src.playback, store),
Site: site.New(store),
StorageURL: cfg.storageURL,
}
+237
View File
@@ -0,0 +1,237 @@
歌单:只有已注册的账号能使用;注册时自带不能重命名、不能删除的“我喜欢”;收藏时元数据由服务端查询,同一首歌只收一次,新加入的在前;别人的歌单和不存在的一样。
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
-- request --
GET /api/me/playlists
Authorization: Bearer $alice
-- response --
403
{"error": {"code": "FORBIDDEN", "message": "注册账号之后才能使用歌单"}}
-- request --
POST /api/me/playlists
Authorization: Bearer $alice
{"name": "通勤"}
-- response --
403
{"error": {"code": "FORBIDDEN", "message": "注册账号之后才能使用歌单"}}
-- request --
POST /api/account
Authorization: Bearer $alice
{"username": "alice", "password": "correct horse"}
-- response --
201
{"account": {"username": "alice"}}
-- request --
GET /api/me/playlists
Authorization: Bearer $alice
-- response --
200
{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null}]}
-- request --
POST /api/me/playlists/$favorites/items
Authorization: Bearer $alice
{"track": {"source": "netease", "id": "186016"}}
-- response --
201
{"item": {"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000}}}
-- request --
POST /api/me/playlists/$favorites/items
Authorization: Bearer $alice
{"track": {"source": "netease", "id": "186016"}}
-- response --
201
{"item": {"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000}}}
-- request --
POST /api/me/playlists/$favorites/items
Authorization: Bearer $alice
{"track": {"source": "netease", "id": "185811"}}
-- response --
201
{"item": {"track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000}}}
-- request --
GET /api/me/playlists/$favorites/items
Authorization: Bearer $alice
-- response --
200
{"items": [
{"track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000}},
{"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000}}
]}
-- request --
POST /api/me/playlists/$favorites/items
Authorization: Bearer $alice
{"track": {"source": "link", "id": "https://example.com/song.mp3"}}
-- response --
409
{"error": {"code": "CONFLICT", "message": "这首歌还在准备,准备好之后再保存"}}
-- request --
POST /api/me/playlists
Authorization: Bearer $alice
{"name": " 通勤 "}
-- response --
201
{"id": "$commute", "name": "通勤", "favorites": false, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null}
-- request --
POST /api/me/playlists
Authorization: Bearer $alice
{"name": " "}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "name:去掉首尾空白后应有 1 到 50 个字符,其中至少一个看得见,不能包含控制字符"}}
-- request --
PATCH /api/me/playlists/$commute
Authorization: Bearer $alice
{"name": "上班路上"}
-- response --
204
-- request --
PATCH /api/me/playlists/$favorites
Authorization: Bearer $alice
{"name": "别的名字"}
-- response --
409
{"error": {"code": "CONFLICT", "message": "“我喜欢”不能重命名或删除"}}
-- request --
DELETE /api/me/playlists/$favorites
Authorization: Bearer $alice
-- response --
409
{"error": {"code": "CONFLICT", "message": "“我喜欢”不能重命名或删除"}}
-- request --
GET /api/me/playlists
Authorization: Bearer $alice
-- response --
200
{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/qilixiang.jpg"}, {"id": "$commute", "name": "上班路上", "favorites": false, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null}]}
-- request --
POST /api/me/playlists/$favorites/removals
Authorization: Bearer $alice
{"track": {"source": "netease", "id": "186016"}}
-- response --
204
-- request --
POST /api/me/playlists/$favorites/removals
Authorization: Bearer $alice
{"track": {"source": "netease", "id": "186016"}}
-- response --
204
-- request --
GET /api/me/playlists/$favorites/items
Authorization: Bearer $alice
-- response --
200
{"items": [{"track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000}}]}
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$bob", "member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}}
-- request --
POST /api/account
Authorization: Bearer $bob
{"username": "bob", "password": "correct horse"}
-- response --
201
{"account": {"username": "bob"}}
-- request --
PATCH /api/me/playlists/$favorites
Authorization: Bearer $bob
{"name": "别人的名字"}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
-- request --
POST /api/me/playlists/$favorites/items
Authorization: Bearer $bob
{"track": {"source": "netease", "id": "186016"}}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
-- request --
POST /api/me/playlists/$favorites/removals
Authorization: Bearer $bob
{"track": {"source": "netease", "id": "185811"}}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
-- request --
GET /api/me/playlists/$favorites/items
Authorization: Bearer $bob
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
-- request --
DELETE /api/me/playlists/$commute
Authorization: Bearer $bob
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
-- request --
GET /api/me/playlists/not-a-uuid/items
Authorization: Bearer $alice
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
-- request --
DELETE /api/me/playlists/$commute
Authorization: Bearer $alice
-- response --
204
-- request --
GET /api/me/playlists
Authorization: Bearer $alice
-- response --
200
{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 1, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/qilixiang.jpg"}]}
+82
View File
@@ -87,6 +87,22 @@ func DecodeLoginRequest(body []byte) (LoginRequest, error) {
return decodeLoginRequest(body, "")
}
func DecodeCreateMyPlaylistRequest(body []byte) (CreateMyPlaylistRequest, error) {
return decodeCreateMyPlaylistRequest(body, "")
}
func DecodeRenameMyPlaylistRequest(body []byte) (RenameMyPlaylistRequest, error) {
return decodeRenameMyPlaylistRequest(body, "")
}
func DecodeAddMyPlaylistItemRequest(body []byte) (AddMyPlaylistItemRequest, error) {
return decodeAddMyPlaylistItemRequest(body, "")
}
func DecodeRemoveMyPlaylistItemRequest(body []byte) (RemoveMyPlaylistItemRequest, error) {
return decodeRemoveMyPlaylistItemRequest(body, "")
}
func DecodeCreateRoomRequest(body []byte) (CreateRoomRequest, error) {
return decodeCreateRoomRequest(body, "")
}
@@ -147,6 +163,21 @@ func DecodeAdminResetAccountPasswordRequest(body []byte) (AdminResetAccountPassw
return decodeAdminResetAccountPasswordRequest(body, "")
}
func decodeAddMyPlaylistItemRequest(raw json.RawMessage, path string) (AddMyPlaylistItemRequest, error) {
var v AddMyPlaylistItemRequest
m, err := decodeObject(raw, path, "track")
if err != nil {
return v, err
}
if err = requireFields(m, path, "track"); err != nil {
return v, err
}
if v.Track, err = decodeTrack(m["track"], field(path, "track")); err != nil {
return v, err
}
return v, nil
}
func decodeAdminCheckBilibiliLoginRequest(raw json.RawMessage, path string) (AdminCheckBilibiliLoginRequest, error) {
var v AdminCheckBilibiliLoginRequest
m, err := decodeObject(raw, path, "key")
@@ -426,6 +457,24 @@ func decodeCreateMemberRequest(raw json.RawMessage, path string) (CreateMemberRe
return v, err
}
func decodeCreateMyPlaylistRequest(raw json.RawMessage, path string) (CreateMyPlaylistRequest, error) {
var v CreateMyPlaylistRequest
m, err := decodeObject(raw, path, "name")
if err != nil {
return v, err
}
if err = requireFields(m, path, "name"); err != nil {
return v, err
}
if v.Name, err = decodeString(m["name"], field(path, "name")); err != nil {
return v, err
}
if err = checkMaxLength(v.Name, field(path, "name"), 200); err != nil {
return v, err
}
return v, nil
}
func decodeCreateRoomRequest(raw json.RawMessage, path string) (CreateRoomRequest, error) {
var v CreateRoomRequest
m, err := decodeObject(raw, path, "description", "name", "password")
@@ -712,6 +761,39 @@ func decodeRegisterRequest(raw json.RawMessage, path string) (RegisterRequest, e
return v, nil
}
func decodeRemoveMyPlaylistItemRequest(raw json.RawMessage, path string) (RemoveMyPlaylistItemRequest, error) {
var v RemoveMyPlaylistItemRequest
m, err := decodeObject(raw, path, "track")
if err != nil {
return v, err
}
if err = requireFields(m, path, "track"); err != nil {
return v, err
}
if v.Track, err = decodeTrack(m["track"], field(path, "track")); err != nil {
return v, err
}
return v, nil
}
func decodeRenameMyPlaylistRequest(raw json.RawMessage, path string) (RenameMyPlaylistRequest, error) {
var v RenameMyPlaylistRequest
m, err := decodeObject(raw, path, "name")
if err != nil {
return v, err
}
if err = requireFields(m, path, "name"); err != nil {
return v, err
}
if v.Name, err = decodeString(m["name"], field(path, "name")); err != nil {
return v, err
}
if err = checkMaxLength(v.Name, field(path, "name"), 200); err != nil {
return v, err
}
return v, nil
}
func decodeRoomSyncRequest(raw json.RawMessage, path string) (RoomSyncRequest, error) {
var v RoomSyncRequest
_, err := decodeObject(raw, path)
+135
View File
@@ -175,6 +175,30 @@
},
"required": ["member", "username"]
},
"PlaylistName": {"type": "string", "description": "歌单名:去掉首尾空白后 1 到 50 个字符", "maxLength": 200},
"MyPlaylist": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {"type": "string"},
"name": {"type": "string"},
"favorites": {"type": "boolean", "description": "是否是“我喜欢”"},
"trackCount": {"type": "integer"},
"createdAtMs": {"type": "integer"},
"updatedAtMs": {"type": "integer"},
"coverUrl": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "歌单里排在最前、有封面的那首歌的封面;没有时为 null"}
},
"required": ["id", "name", "favorites", "trackCount", "createdAtMs", "updatedAtMs", "coverUrl"]
},
"MyPlaylistItem": {
"type": "object",
"additionalProperties": false,
"properties": {
"track": {"$ref": "#/$defs/Track"},
"meta": {"$ref": "#/$defs/TrackMeta"}
},
"required": ["track", "meta"]
},
"Members": {
"type": "object",
"additionalProperties": false,
@@ -810,6 +834,117 @@
"auth": "member",
"status": 204
},
{
"name": "listMyPlaylists",
"description": "我的歌单:“我喜欢”在最前,其余按创建的先后;只有已注册的账号能使用歌单",
"method": "GET",
"path": "/api/me/playlists",
"auth": "member",
"response": {
"type": "object",
"additionalProperties": false,
"properties": {
"items": {"type": "array", "items": {"$ref": "#/$defs/MyPlaylist"}}
},
"required": ["items"]
}
},
{
"name": "createMyPlaylist",
"description": "新建歌单:最多 50 个(含“我喜欢”),可以重名",
"method": "POST",
"path": "/api/me/playlists",
"auth": "member",
"status": 201,
"request": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": {"$ref": "#/$defs/PlaylistName"}
},
"required": ["name"]
},
"response": {"$ref": "#/$defs/MyPlaylist"}
},
{
"name": "renameMyPlaylist",
"description": "重命名歌单;“我喜欢”不能重命名",
"method": "PATCH",
"path": "/api/me/playlists/{id}",
"auth": "member",
"status": 204,
"request": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": {"$ref": "#/$defs/PlaylistName"}
},
"required": ["name"]
}
},
{
"name": "deleteMyPlaylist",
"description": "删除歌单;“我喜欢”不能删除",
"method": "DELETE",
"path": "/api/me/playlists/{id}",
"auth": "member",
"status": 204
},
{
"name": "listMyPlaylistItems",
"description": "歌单里的歌,新加入的在前",
"method": "GET",
"path": "/api/me/playlists/{id}/items",
"auth": "member",
"response": {
"type": "object",
"additionalProperties": false,
"properties": {
"items": {"type": "array", "items": {"$ref": "#/$defs/MyPlaylistItem"}}
},
"required": ["items"]
}
},
{
"name": "addMyPlaylistItem",
"description": "把一首歌加入歌单:元数据由服务端查询,链接曲目要先准备好;已经在歌单里时不变;一个歌单最多 1000 首",
"method": "POST",
"path": "/api/me/playlists/{id}/items",
"auth": "member",
"status": 201,
"request": {
"type": "object",
"additionalProperties": false,
"properties": {
"track": {"$ref": "#/$defs/Track"}
},
"required": ["track"]
},
"response": {
"type": "object",
"additionalProperties": false,
"properties": {
"item": {"$ref": "#/$defs/MyPlaylistItem"}
},
"required": ["item"]
}
},
{
"name": "removeMyPlaylistItem",
"description": "把一首歌移出歌单;不在歌单里也算成功",
"method": "POST",
"path": "/api/me/playlists/{id}/removals",
"auth": "member",
"status": 204,
"request": {
"type": "object",
"additionalProperties": false,
"properties": {
"track": {"$ref": "#/$defs/Track"}
},
"required": ["track"]
}
},
{
"name": "listRooms",
"description": "房间列表",
+450 -1
View File
@@ -41,6 +41,30 @@
],
"type": "string"
},
"AddMyPlaylistItemRequest": {
"additionalProperties": false,
"properties": {
"track": {
"$ref": "#/components/schemas/Track"
}
},
"required": [
"track"
],
"type": "object"
},
"AddMyPlaylistItemResponse": {
"additionalProperties": false,
"properties": {
"item": {
"$ref": "#/components/schemas/MyPlaylistItem"
}
},
"required": [
"item"
],
"type": "object"
},
"AdminAccount": {
"additionalProperties": false,
"properties": {
@@ -437,6 +461,20 @@
],
"type": "object"
},
"CreateMyPlaylistRequest": {
"additionalProperties": false,
"properties": {
"name": {
"description": "歌单名:去掉首尾空白后 1 到 50 个字符",
"maxLength": 200,
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"CreateRoomRequest": {
"additionalProperties": false,
"properties": {
@@ -641,6 +679,36 @@
],
"type": "object"
},
"ListMyPlaylistItemsResponse": {
"additionalProperties": false,
"properties": {
"items": {
"items": {
"$ref": "#/components/schemas/MyPlaylistItem"
},
"type": "array"
}
},
"required": [
"items"
],
"type": "object"
},
"ListMyPlaylistsResponse": {
"additionalProperties": false,
"properties": {
"items": {
"items": {
"$ref": "#/components/schemas/MyPlaylist"
},
"type": "array"
}
},
"required": [
"items"
],
"type": "object"
},
"ListRoomsResponse": {
"additionalProperties": false,
"properties": {
@@ -749,6 +817,67 @@
],
"type": "object"
},
"MyPlaylist": {
"additionalProperties": false,
"properties": {
"coverUrl": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "歌单里排在最前、有封面的那首歌的封面;没有时为 null"
},
"createdAtMs": {
"type": "integer"
},
"favorites": {
"description": "是否是“我喜欢”",
"type": "boolean"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"trackCount": {
"type": "integer"
},
"updatedAtMs": {
"type": "integer"
}
},
"required": [
"id",
"name",
"favorites",
"trackCount",
"createdAtMs",
"updatedAtMs",
"coverUrl"
],
"type": "object"
},
"MyPlaylistItem": {
"additionalProperties": false,
"properties": {
"meta": {
"$ref": "#/components/schemas/TrackMeta"
},
"track": {
"$ref": "#/components/schemas/Track"
}
},
"required": [
"track",
"meta"
],
"type": "object"
},
"NowPlaying": {
"additionalProperties": false,
"properties": {
@@ -1060,6 +1189,32 @@
],
"type": "object"
},
"RemoveMyPlaylistItemRequest": {
"additionalProperties": false,
"properties": {
"track": {
"$ref": "#/components/schemas/Track"
}
},
"required": [
"track"
],
"type": "object"
},
"RenameMyPlaylistRequest": {
"additionalProperties": false,
"properties": {
"name": {
"description": "歌单名:去掉首尾空白后 1 到 50 个字符",
"maxLength": 200,
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"RoomInfo": {
"additionalProperties": false,
"properties": {
@@ -1511,7 +1666,7 @@
},
"info": {
"title": "Alisten",
"version": "000a0b37ea0f0adc"
"version": "7380d77bc52d7f95"
},
"openapi": "3.1.0",
"paths": {
@@ -2648,6 +2803,300 @@
"summary": "修改昵称和头像;email 用来计算头像哈希,已注册的账号同时保存它,去掉首尾空白后应形如 name@example.com,空字符串表示清除"
}
},
"/api/me/playlists": {
"get": {
"operationId": "listMyPlaylists",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ListMyPlaylistsResponse"
}
}
},
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": [
{
"memberToken": []
}
],
"summary": "我的歌单:“我喜欢”在最前,其余按创建的先后;只有已注册的账号能使用歌单"
},
"post": {
"operationId": "createMyPlaylist",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateMyPlaylistRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MyPlaylist"
}
}
},
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": [
{
"memberToken": []
}
],
"summary": "新建歌单:最多 50 个(含“我喜欢”),可以重名"
}
},
"/api/me/playlists/{id}": {
"delete": {
"operationId": "deleteMyPlaylist",
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": [
{
"memberToken": []
}
],
"summary": "删除歌单;“我喜欢”不能删除"
},
"patch": {
"operationId": "renameMyPlaylist",
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RenameMyPlaylistRequest"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": [
{
"memberToken": []
}
],
"summary": "重命名歌单;“我喜欢”不能重命名"
}
},
"/api/me/playlists/{id}/items": {
"get": {
"operationId": "listMyPlaylistItems",
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ListMyPlaylistItemsResponse"
}
}
},
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": [
{
"memberToken": []
}
],
"summary": "歌单里的歌,新加入的在前"
},
"post": {
"operationId": "addMyPlaylistItem",
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AddMyPlaylistItemRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AddMyPlaylistItemResponse"
}
}
},
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": [
{
"memberToken": []
}
],
"summary": "把一首歌加入歌单:元数据由服务端查询,链接曲目要先准备好;已经在歌单里时不变;一个歌单最多 1000 首"
}
},
"/api/me/playlists/{id}/removals": {
"post": {
"operationId": "removeMyPlaylistItem",
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RemoveMyPlaylistItemRequest"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": [
{
"memberToken": []
}
],
"summary": "把一首歌移出歌单;不在歌单里也算成功"
}
},
"/api/members": {
"post": {
"operationId": "createMember",
+67 -1
View File
@@ -4,7 +4,7 @@ package protocol
import "encoding/json"
const Version = "000a0b37ea0f0adc"
const Version = "7380d77bc52d7f95"
type APIKey struct {
Key string `json:"key"`
@@ -26,6 +26,14 @@ const (
ActivityKindFailed ActivityKind = "failed"
)
type AddMyPlaylistItemRequest struct {
Track Track `json:"track"`
}
type AddMyPlaylistItemResponse struct {
Item MyPlaylistItem `json:"item"`
}
type AdminAccount struct {
Member Member `json:"member"`
Username string `json:"username"`
@@ -163,6 +171,10 @@ type CreateMemberResponse struct {
MemberToken string `json:"memberToken"`
}
type CreateMyPlaylistRequest struct {
Name string `json:"name"`
}
type CreateRoomRequest struct {
Description string `json:"description"`
Name string `json:"name"`
@@ -245,6 +257,30 @@ type HelloResponse struct {
ServerTimeMs int64 `json:"serverTimeMs"`
}
type ListMyPlaylistItemsResponse struct {
Items []MyPlaylistItem `json:"items"`
}
func (v ListMyPlaylistItemsResponse) MarshalJSON() ([]byte, error) {
type plain ListMyPlaylistItemsResponse
if v.Items == nil {
v.Items = []MyPlaylistItem{}
}
return json.Marshal(plain(v))
}
type ListMyPlaylistsResponse struct {
Items []MyPlaylist `json:"items"`
}
func (v ListMyPlaylistsResponse) MarshalJSON() ([]byte, error) {
type plain ListMyPlaylistsResponse
if v.Items == nil {
v.Items = []MyPlaylist{}
}
return json.Marshal(plain(v))
}
type ListRoomsResponse struct {
Items []RoomListing `json:"items"`
}
@@ -289,6 +325,21 @@ func (v Members) MarshalJSON() ([]byte, error) {
return json.Marshal(plain(v))
}
type MyPlaylist struct {
CoverURL *string `json:"coverUrl"`
CreatedAtMs int64 `json:"createdAtMs"`
Favorites bool `json:"favorites"`
ID string `json:"id"`
Name string `json:"name"`
TrackCount int64 `json:"trackCount"`
UpdatedAtMs int64 `json:"updatedAtMs"`
}
type MyPlaylistItem struct {
Meta TrackMeta `json:"meta"`
Track Track `json:"track"`
}
type NowPlaying struct {
DurationMs int64 `json:"durationMs"`
Entry Entry `json:"entry"`
@@ -445,6 +496,14 @@ type RegisterResponse struct {
Account Account `json:"account"`
}
type RemoveMyPlaylistItemRequest struct {
Track Track `json:"track"`
}
type RenameMyPlaylistRequest struct {
Name string `json:"name"`
}
type RoomActivityEvent struct {
Entry *Entry `json:"entry,omitempty"`
Kind ActivityKind `json:"kind"`
@@ -661,6 +720,13 @@ var (
RouteChangePassword = Route{Pattern: "PUT /api/account/password", Auth: AuthMember, Status: 204}
RouteLogin = Route{Pattern: "POST /api/sessions", Auth: AuthMember, Status: 201}
RouteLogout = Route{Pattern: "DELETE /api/sessions/current", Auth: AuthMember, Status: 204}
RouteListMyPlaylists = Route{Pattern: "GET /api/me/playlists", Auth: AuthMember, Status: 200}
RouteCreateMyPlaylist = Route{Pattern: "POST /api/me/playlists", Auth: AuthMember, Status: 201}
RouteRenameMyPlaylist = Route{Pattern: "PATCH /api/me/playlists/{id}", Auth: AuthMember, Status: 204}
RouteDeleteMyPlaylist = Route{Pattern: "DELETE /api/me/playlists/{id}", Auth: AuthMember, Status: 204}
RouteListMyPlaylistItems = Route{Pattern: "GET /api/me/playlists/{id}/items", Auth: AuthMember, Status: 200}
RouteAddMyPlaylistItem = Route{Pattern: "POST /api/me/playlists/{id}/items", Auth: AuthMember, Status: 201}
RouteRemoveMyPlaylistItem = Route{Pattern: "POST /api/me/playlists/{id}/removals", Auth: AuthMember, Status: 204}
RouteListRooms = Route{Pattern: "GET /api/rooms", Auth: AuthNone, Status: 200}
RouteGetRoom = Route{Pattern: "GET /api/rooms/{ref}", Auth: AuthNone, Status: 200}
RouteCreateRoom = Route{Pattern: "POST /api/rooms", Auth: AuthNone, Status: 201, Location: true}
+36 -1
View File
@@ -1,6 +1,14 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
import type { APIKey, Account, AdminAccount, ErrorInfo, Member, QQLoginType, QRLogin, QRLoginResult, RoomListing, SourceAccount, StoredMedia } from '../realtime/protocol.gen'
import type { APIKey, Account, AdminAccount, ErrorInfo, Member, MyPlaylist, MyPlaylistItem, QQLoginType, QRLogin, QRLoginResult, RoomListing, SourceAccount, StoredMedia, Track } from '../realtime/protocol.gen'
export interface AddMyPlaylistItemRequest {
track: Track
}
export interface AddMyPlaylistItemResponse {
item: MyPlaylistItem
}
export interface AdminCheckBilibiliLoginRequest {
key: string
@@ -108,6 +116,10 @@ export interface CreateMemberResponse {
memberToken: string
}
export interface CreateMyPlaylistRequest {
name: string
}
export interface CreateRoomRequest {
description: string
name: string
@@ -136,6 +148,14 @@ export interface GetSiteResponse {
registrationOpen: boolean
}
export interface ListMyPlaylistItemsResponse {
items: MyPlaylistItem[]
}
export interface ListMyPlaylistsResponse {
items: MyPlaylist[]
}
export interface ListRoomsResponse {
items: RoomListing[]
}
@@ -162,6 +182,14 @@ export interface RegisterResponse {
account: Account
}
export interface RemoveMyPlaylistItemRequest {
track: Track
}
export interface RenameMyPlaylistRequest {
name: string
}
export interface UpdateMeRequest {
email?: string
name?: string
@@ -195,6 +223,13 @@ export function httpClient(send: HttpSend) {
changePassword: (body: ChangePasswordRequest) => send<void>({ method: 'PUT', path: '/api/account/password', auth: 'member', body }),
login: (body: LoginRequest) => send<LoginResponse>({ method: 'POST', path: '/api/sessions', auth: 'member', body }),
logout: () => send<void>({ method: 'DELETE', path: '/api/sessions/current', auth: 'member' }),
listMyPlaylists: () => send<ListMyPlaylistsResponse>({ method: 'GET', path: '/api/me/playlists', auth: 'member' }),
createMyPlaylist: (body: CreateMyPlaylistRequest) => send<MyPlaylist>({ method: 'POST', path: '/api/me/playlists', auth: 'member', body }),
renameMyPlaylist: (id: string, body: RenameMyPlaylistRequest) => send<void>({ method: 'PATCH', path: `/api/me/playlists/${encodeURIComponent(id)}`, auth: 'member', body }),
deleteMyPlaylist: (id: string) => send<void>({ method: 'DELETE', path: `/api/me/playlists/${encodeURIComponent(id)}`, auth: 'member' }),
listMyPlaylistItems: (id: string) => send<ListMyPlaylistItemsResponse>({ method: 'GET', path: `/api/me/playlists/${encodeURIComponent(id)}/items`, auth: 'member' }),
addMyPlaylistItem: (id: string, body: AddMyPlaylistItemRequest) => send<AddMyPlaylistItemResponse>({ method: 'POST', path: `/api/me/playlists/${encodeURIComponent(id)}/items`, auth: 'member', body }),
removeMyPlaylistItem: (id: string, body: RemoveMyPlaylistItemRequest) => send<void>({ method: 'POST', path: `/api/me/playlists/${encodeURIComponent(id)}/removals`, auth: 'member', body }),
listRooms: () => send<ListRoomsResponse>({ method: 'GET', path: '/api/rooms', auth: 'none' }),
getRoom: (ref: string) => send<RoomListing>({ method: 'GET', path: `/api/rooms/${encodeURIComponent(ref)}`, auth: 'none' }),
createRoom: (body: CreateRoomRequest) => send<CreateRoomResponse>({ method: 'POST', path: '/api/rooms', auth: 'none', body }),
+16 -1
View File
@@ -1,6 +1,6 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
export const PROTOCOL_VERSION = '000a0b37ea0f0adc'
export const PROTOCOL_VERSION = '7380d77bc52d7f95'
export interface APIKey {
key: string
@@ -72,6 +72,21 @@ export interface Members {
sessions: Member[]
}
export interface MyPlaylist {
coverUrl: string | null
createdAtMs: number
favorites: boolean
id: string
name: string
trackCount: number
updatedAtMs: number
}
export interface MyPlaylistItem {
meta: TrackMeta
track: Track
}
export interface NowPlaying {
durationMs: number
entry: Entry