feat: expose member playlists over HTTP
This commit is contained in:
1 parent
95a56267a4
commit
fafde9041c
13 files changed
+1280
-33
No files matched your search
@@ -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},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"}]}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user