feat: expose member playlists over HTTP
This commit is contained in:
1 parent
629eb30d3b
commit
ded566bf25
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), gateway, base.With("component", "accounts")),
|
||||
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), gateway, base.With("component", "accounts")),
|
||||
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"}]}
|
||||
Reference in new issue
Block a user