From d9a97b6331285313f39c9531478d37aab2a99764 Mon Sep 17 00:00:00 2001 From: DiheChen Date: Mon, 9 Nov 2026 01:36:20 +0800 Subject: [PATCH] feat: expose member playlists over HTTP --- internal/adapter/in/api/api.go | 35 +- internal/adapter/in/api/convert.go | 34 +- internal/adapter/in/api/playlist.go | 100 ++++ internal/bootstrap/public.go | 20 +- .../bootstrap/testdata/http/playlists.txtar | 210 ++++++++ protocol/decode.gen.go | 82 ++++ protocol/idl.json | 135 ++++++ protocol/openapi.gen.json | 451 +++++++++++++++++- protocol/types.gen.go | 68 ++- web/src/core/api/http.gen.ts | 37 +- web/src/core/realtime/protocol.gen.ts | 17 +- 11 files changed, 1158 insertions(+), 31 deletions(-) create mode 100644 internal/adapter/in/api/playlist.go create mode 100644 internal/bootstrap/testdata/http/playlists.txtar diff --git a/internal/adapter/in/api/api.go b/internal/adapter/in/api/api.go index 4cf3c04..b256028 100644 --- a/internal/adapter/in/api/api.go +++ b/internal/adapter/in/api/api.go @@ -22,6 +22,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" @@ -37,15 +38,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 @@ -109,6 +111,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) @@ -303,6 +312,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}, diff --git a/internal/adapter/in/api/convert.go b/internal/adapter/in/api/convert.go index 2d7d2e7..c0a5744 100644 --- a/internal/adapter/in/api/convert.go +++ b/internal/adapter/in/api/convert.go @@ -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,38 @@ 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 (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), + 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 diff --git a/internal/adapter/in/api/playlist.go b/internal/adapter/in/api/playlist.go new file mode 100644 index 0000000..db67c5f --- /dev/null +++ b/internal/adapter/in/api/playlist.go @@ -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: protocol.Track{Source: protocol.Source(item.Track.Source), ID: item.Track.ID}, + 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)) +} diff --git a/internal/bootstrap/public.go b/internal/bootstrap/public.go index 51eab44..46f9329 100644 --- a/internal/bootstrap/public.go +++ b/internal/bootstrap/public.go @@ -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, TrustedProxies: cfg.trustedProxies, diff --git a/internal/bootstrap/testdata/http/playlists.txtar b/internal/bootstrap/testdata/http/playlists.txtar new file mode 100644 index 0000000..780e030 --- /dev/null +++ b/internal/bootstrap/testdata/http/playlists.txtar @@ -0,0 +1,210 @@ +歌单:只有已注册的账号能使用;注册时自带不能重命名、不能删除的“我喜欢”;收藏时元数据由服务端查询,同一首歌只收一次,新加入的在前;别人的歌单和不存在的一样。 + +-- 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 -- +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"}]} diff --git a/protocol/decode.gen.go b/protocol/decode.gen.go index b6da440..c704057 100644 --- a/protocol/decode.gen.go +++ b/protocol/decode.gen.go @@ -86,6 +86,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, "") } @@ -146,6 +162,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") @@ -425,6 +456,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") @@ -701,6 +750,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) diff --git a/protocol/idl.json b/protocol/idl.json index 026e713..dcf2a09 100644 --- a/protocol/idl.json +++ b/protocol/idl.json @@ -170,6 +170,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, @@ -803,6 +827,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": "房间列表", diff --git a/protocol/openapi.gen.json b/protocol/openapi.gen.json index 353fab1..864f9d1 100644 --- a/protocol/openapi.gen.json +++ b/protocol/openapi.gen.json @@ -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": { @@ -738,6 +806,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": { @@ -1049,6 +1178,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": { @@ -1500,7 +1655,7 @@ }, "info": { "title": "Alisten", - "version": "eae091a3e1daf18c" + "version": "1132502d511e1bab" }, "openapi": "3.1.0", "paths": { @@ -2637,6 +2792,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", diff --git a/protocol/types.gen.go b/protocol/types.gen.go index f66ecdd..cb88bb5 100644 --- a/protocol/types.gen.go +++ b/protocol/types.gen.go @@ -4,7 +4,7 @@ package protocol import "encoding/json" -const Version = "eae091a3e1daf18c" +const Version = "1132502d511e1bab" 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"` } @@ -287,6 +323,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"` @@ -443,6 +494,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"` @@ -659,6 +718,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} diff --git a/web/src/core/api/http.gen.ts b/web/src/core/api/http.gen.ts index 810a074..4803b48 100644 --- a/web/src/core/api/http.gen.ts +++ b/web/src/core/api/http.gen.ts @@ -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[] } @@ -160,6 +180,14 @@ export interface RegisterResponse { account: Account } +export interface RemoveMyPlaylistItemRequest { + track: Track +} + +export interface RenameMyPlaylistRequest { + name: string +} + export interface UpdateMeRequest { email?: string name?: string @@ -193,6 +221,13 @@ export function httpClient(send: HttpSend) { changePassword: (body: ChangePasswordRequest) => send({ method: 'PUT', path: '/api/account/password', auth: 'member', body }), login: (body: LoginRequest) => send({ method: 'POST', path: '/api/sessions', auth: 'member', body }), logout: () => send({ method: 'DELETE', path: '/api/sessions/current', auth: 'member' }), + listMyPlaylists: () => send({ method: 'GET', path: '/api/me/playlists', auth: 'member' }), + createMyPlaylist: (body: CreateMyPlaylistRequest) => send({ method: 'POST', path: '/api/me/playlists', auth: 'member', body }), + renameMyPlaylist: (id: string, body: RenameMyPlaylistRequest) => send({ method: 'PATCH', path: `/api/me/playlists/${encodeURIComponent(id)}`, auth: 'member', body }), + deleteMyPlaylist: (id: string) => send({ method: 'DELETE', path: `/api/me/playlists/${encodeURIComponent(id)}`, auth: 'member' }), + listMyPlaylistItems: (id: string) => send({ method: 'GET', path: `/api/me/playlists/${encodeURIComponent(id)}/items`, auth: 'member' }), + addMyPlaylistItem: (id: string, body: AddMyPlaylistItemRequest) => send({ method: 'POST', path: `/api/me/playlists/${encodeURIComponent(id)}/items`, auth: 'member', body }), + removeMyPlaylistItem: (id: string, body: RemoveMyPlaylistItemRequest) => send({ method: 'POST', path: `/api/me/playlists/${encodeURIComponent(id)}/removals`, auth: 'member', body }), listRooms: () => send({ method: 'GET', path: '/api/rooms', auth: 'none' }), getRoom: (ref: string) => send({ method: 'GET', path: `/api/rooms/${encodeURIComponent(ref)}`, auth: 'none' }), createRoom: (body: CreateRoomRequest) => send({ method: 'POST', path: '/api/rooms', auth: 'none', body }), diff --git a/web/src/core/realtime/protocol.gen.ts b/web/src/core/realtime/protocol.gen.ts index d93e606..34f8c7e 100644 --- a/web/src/core/realtime/protocol.gen.ts +++ b/web/src/core/realtime/protocol.gen.ts @@ -1,6 +1,6 @@ // Code generated by protocolgen from protocol/idl.json; DO NOT EDIT. -export const PROTOCOL_VERSION = 'eae091a3e1daf18c' +export const PROTOCOL_VERSION = '1132502d511e1bab' 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