diff --git a/internal/adapter/in/api/api.go b/internal/adapter/in/api/api.go index 74898c7..29dff26 100644 --- a/internal/adapter/in/api/api.go +++ b/internal/adapter/in/api/api.go @@ -19,6 +19,7 @@ import ( "github.com/dihemoe/alisten/internal/app/admin" "github.com/dihemoe/alisten/internal/app/catalog" "github.com/dihemoe/alisten/internal/app/identity" + "github.com/dihemoe/alisten/internal/app/listen" "github.com/dihemoe/alisten/internal/app/lobby" "github.com/dihemoe/alisten/internal/app/media" "github.com/dihemoe/alisten/internal/app/playlist" @@ -52,6 +53,7 @@ type Services struct { Media *media.Library Playback *media.Playback Site *site.Service + Listens *listen.Service // 浏览器访问对象存储时使用的地址前缀,结尾没有 /。 StorageURL string @@ -158,6 +160,11 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider, srv.handle(mux, protocol.RouteAdminCloseRegistration, srv.adminCloseRegistration) srv.handle(mux, protocol.RouteAdminGetAccount, srv.adminGetAccount) srv.handle(mux, protocol.RouteAdminResetAccountPassword, srv.adminResetAccountPassword) + srv.handle(mux, protocol.RouteListMyListens, srv.listMyListens) + srv.handle(mux, protocol.RouteRemoveMyListen, srv.removeMyListen) + srv.handle(mux, protocol.RouteClearMyListens, srv.clearMyListens) + srv.handle(mux, protocol.RouteEnableListenRecording, srv.enableListenRecording) + srv.handle(mux, protocol.RouteDisableListenRecording, srv.disableListenRecording) mux.HandleFunc("GET /ws", srv.websocket) mux.HandleFunc("GET /api/room/events", srv.roomEvents) mux.HandleFunc("GET /media/{source}/{id}", srv.redirectMedia) diff --git a/internal/adapter/in/api/handlers.go b/internal/adapter/in/api/handlers.go index 2124593..fd46bc6 100644 --- a/internal/adapter/in/api/handlers.go +++ b/internal/adapter/in/api/handlers.go @@ -42,6 +42,9 @@ func (s *server) getMe(ctx context.Context, c call) (any, error) { dto := accountDTO(account) resp.Account = &dto } + if resp.RecordListens, err = s.Listens.Recording(ctx, c.member.ID); err != nil { + return nil, err + } return resp, nil } diff --git a/internal/adapter/in/api/listen.go b/internal/adapter/in/api/listen.go new file mode 100644 index 0000000..32062f3 --- /dev/null +++ b/internal/adapter/in/api/listen.go @@ -0,0 +1,42 @@ +package api + +import ( + "context" + + "github.com/dihemoe/alisten/protocol" +) + +func (s *server) listMyListens(ctx context.Context, c call) (any, error) { + listens, err := s.Listens.List(ctx, c.member.ID) + if err != nil { + return nil, err + } + items := make([]protocol.MyListen, len(listens)) + for i, l := range listens { + items[i] = protocol.MyListen{Track: trackDTO(l.Track), Meta: s.trackMeta(l.Meta), HeardAtMs: l.HeardAt.UnixMilli()} + } + return protocol.ListMyListensResponse{Items: items}, nil +} + +func (s *server) removeMyListen(ctx context.Context, c call) (any, error) { + req, err := protocol.DecodeRemoveMyListenRequest(c.body) + if err != nil { + return nil, err + } + return nil, s.Listens.Remove(ctx, c.member.ID, trackOf(req.Track)) +} + +func (s *server) clearMyListens(ctx context.Context, c call) (any, error) { + return nil, s.Listens.Clear(ctx, c.member.ID) +} + +func (s *server) enableListenRecording(ctx context.Context, c call) (any, error) { + if _, err := protocol.DecodeEnableListenRecordingRequest(c.body); err != nil { + return nil, err + } + return nil, s.Listens.SetRecording(ctx, c.member.ID, true) +} + +func (s *server) disableListenRecording(ctx context.Context, c call) (any, error) { + return nil, s.Listens.SetRecording(ctx, c.member.ID, false) +} diff --git a/internal/app/listen/listen.go b/internal/app/listen/listen.go new file mode 100644 index 0000000..047a50a --- /dev/null +++ b/internal/app/listen/listen.go @@ -0,0 +1,38 @@ +package listen + +import ( + "context" + + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/track" +) + +// 游客和已注册成员都有播放历史,不像歌单要求注册。 +type Service struct { + listens port.Listens +} + +func New(listens port.Listens) *Service { + return &Service{listens: listens} +} + +func (s *Service) List(ctx context.Context, memberID string) ([]port.Listen, error) { + return s.listens.MemberListens(ctx, memberID) +} + +// 链接曲目的 t.ID 必须已经按 track.LinkID 规范化。 +func (s *Service) Remove(ctx context.Context, memberID string, t track.Track) error { + return s.listens.DeleteListen(ctx, memberID, t) +} + +func (s *Service) Clear(ctx context.Context, memberID string) error { + return s.listens.ClearListens(ctx, memberID) +} + +func (s *Service) Recording(ctx context.Context, memberID string) (bool, error) { + return s.listens.ListenRecording(ctx, memberID) +} + +func (s *Service) SetRecording(ctx context.Context, memberID string, on bool) error { + return s.listens.SetListenRecording(ctx, memberID, on) +} diff --git a/internal/bootstrap/public.go b/internal/bootstrap/public.go index 0eb31ca..65cc0eb 100644 --- a/internal/bootstrap/public.go +++ b/internal/bootstrap/public.go @@ -27,6 +27,7 @@ import ( "github.com/dihemoe/alisten/internal/app/admin" "github.com/dihemoe/alisten/internal/app/catalog" "github.com/dihemoe/alisten/internal/app/identity" + "github.com/dihemoe/alisten/internal/app/listen" "github.com/dihemoe/alisten/internal/app/lobby" "github.com/dihemoe/alisten/internal/app/media" "github.com/dihemoe/alisten/internal/app/playlist" @@ -90,6 +91,7 @@ func newPublic(cfg serveConfig, pool *pgxpool.Pool, rdb *goredis.Client, instanc Media: media.NewLibrary(store, store, objects), Playback: media.NewPlayback(src.playback, store), Site: site.New(store), + Listens: listen.New(store), StorageURL: cfg.storageURL, } diff --git a/internal/bootstrap/testdata/http/account.txtar b/internal/bootstrap/testdata/http/account.txtar index 8d4ef59..6938ed8 100644 --- a/internal/bootstrap/testdata/http/account.txtar +++ b/internal/bootstrap/testdata/http/account.txtar @@ -22,7 +22,7 @@ GET /api/me Authorization: Bearer $alice -- response -- 200 -{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}} +{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}, "recordListens": true} -- request -- PATCH /api/me @@ -38,7 +38,7 @@ GET /api/me Authorization: Bearer $alice -- response -- 200 -{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM"}} +{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM"}, "recordListens": true} -- request -- POST /api/account @@ -62,7 +62,7 @@ GET /api/me Authorization: Bearer $bob -- response -- 200 -{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}} +{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}, "recordListens": true} -- request -- PATCH /api/me @@ -78,7 +78,7 @@ GET /api/me Authorization: Bearer $bob -- response -- 200 -{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}} +{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "recordListens": true} -- request -- PATCH /api/me @@ -139,7 +139,7 @@ GET /api/me Authorization: Bearer $alice2 -- response -- 200 -{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM"}} +{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM"}, "recordListens": true} -- request -- PATCH /api/me @@ -155,7 +155,7 @@ GET /api/me Authorization: Bearer $alice -- response -- 200 -{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}} +{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}, "recordListens": true} -- request -- GET /api/me diff --git a/internal/bootstrap/testdata/http/session.txtar b/internal/bootstrap/testdata/http/session.txtar index bbc1368..0222d24 100644 --- a/internal/bootstrap/testdata/http/session.txtar +++ b/internal/bootstrap/testdata/http/session.txtar @@ -21,7 +21,7 @@ GET /api/me Authorization: Bearer $alice -- response -- 200 -{"member": {"id": "$aliceId", "name": "$guestName", "avatarHash": ""}} +{"member": {"id": "$aliceId", "name": "$guestName", "avatarHash": ""}, "recordListens": true} -- request -- PATCH /api/me @@ -73,11 +73,11 @@ GET /api/me Authorization: Bearer $alice -- response -- 200 -{"member": {"id": "$aliceId", "name": "甲", "avatarHash": ""}} +{"member": {"id": "$aliceId", "name": "甲", "avatarHash": ""}, "recordListens": true} -- request -- GET /api/me Authorization: Bearer $bob -- response -- 200 -{"member": {"id": "$bobId", "name": "乙", "avatarHash": ""}} +{"member": {"id": "$bobId", "name": "乙", "avatarHash": ""}, "recordListens": true} diff --git a/internal/bootstrap/testdata/ws/listens.txtar b/internal/bootstrap/testdata/ws/listens.txtar new file mode 100644 index 0000000..39a0df9 --- /dev/null +++ b/internal/bootstrap/testdata/ws/listens.txtar @@ -0,0 +1,249 @@ +播放历史:开播时记给房间里的成员,开播之后进来的成员也记上正在播放的歌,关闭了记录的成员不记,已有的历史保留; +可以删除一首、清空;游客登录账号时,游客的历史并入账号。读取历史要有成员 token。 + +-- request -- +POST /api/members + +{} +-- response -- +201 +{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}} + +-- request -- +POST /api/account +Authorization: Bearer $alice + +{"username": "alice", "password": "correct horse"} +-- response -- +201 +{"account": {"username": "alice"}} + +-- request -- +GET /api/me/listens +Authorization: Bearer $alice +-- response -- +200 +{"items": []} + +-- request -- +POST /api/members + +{} +-- response -- +201 +{"memberToken": "$bob", "member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}} + +-- request -- +DELETE /api/me/listen-recording +Authorization: Bearer $bob +-- response -- +204 + +-- request -- +GET /api/me +Authorization: Bearer $bob +-- response -- +200 +{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}, "recordListens": false} + +-- request -- +POST /api/admin/rooms +Authorization: Bearer admin-secret + +{"name": "大厅", "description": ""} +-- response -- +201 +Location: /api/rooms/$room +{"id": "$room"} + +-- request -- +POST /api/rooms/$room/tickets +Authorization: Bearer $alice + +{} +-- response -- +201 +{"ticket": "$ticketA"} + +-- connect alice -- +-- send alice -- +{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$room", "ticket": "$ticketA", "protocolVersion": "$protocolVersion"}} +-- receive alice -- +{"kind": "response", "type": "hello", "id": "h1", "data": { + "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, + "serverTimeMs": 1, + "room": {"term": 1, "seq": 1, "room": {"id": "$room", "name": "大厅", "description": "", "kind": "persistent"}, + "playMode": "sequential", "nowPlaying": null, "pending": null, "items": [], + "members": {"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 1}}}} + +-- send alice -- +{"kind": "request", "type": "queue.add", "id": "a1", "data": {"track": {"source": "netease", "id": "186016"}}} +-- receive alice -- +{"kind": "event", "type": "room.activity", "term": 1, "seq": 2, "data": {"kind": "added", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, + "entry": {"id": "1", "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}, + "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}, + "message": "点了《晴天》"}} +-- receive alice -- +{"kind": "event", "type": "queue.updated", "term": 1, "seq": 3, "data": {"items": [], + "pending": {"id": "1", "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}, + "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}}} +-- receive alice -- +{"kind": "response", "type": "queue.add", "id": "a1", "data": {"entry": {"id": "1", "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}, + "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}}} +-- receive alice -- +{"kind": "event", "type": "queue.updated", "term": 1, "seq": 4, "data": {"pending": null, "items": []}} +-- receive alice -- +{"kind": "event", "type": "playback.updated", "term": 1, "seq": 5, "data": {"nowPlaying": { + "entry": {"id": "1", "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": 269500}, + "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "ready"}, + "mediaUrl": "/media/netease/186016", "startedAtMs": 1, "durationMs": 269500, "gain": 1, "lyric": "[00:01.00]故事的小黄花"}}} + +-- request -- +GET /api/me/listens +Authorization: Bearer $alice +-- response -- +200 +{"items": [{"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": 269500}, "heardAtMs": 1}]} + +-- request -- +POST /api/rooms/$room/tickets +Authorization: Bearer $bob + +{} +-- response -- +201 +{"ticket": "$ticketB"} + +-- connect bob -- +-- send bob -- +{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$room", "ticket": "$ticketB", "protocolVersion": "$protocolVersion"}} +-- receive bob -- +{"kind": "response", "type": "hello", "id": "h1", "data": { + "member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}, + "serverTimeMs": 1, + "room": {"term": 1, "seq": 6, "room": {"id": "$room", "name": "大厅", "description": "", "kind": "persistent"}, + "playMode": "sequential", "pending": null, "items": [], + "nowPlaying": { + "entry": {"id": "1", "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": 269500}, + "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "ready"}, + "mediaUrl": "/media/netease/186016", "startedAtMs": 1, "durationMs": 269500, "gain": 1, "lyric": "[00:01.00]故事的小黄花"}, + "members": {"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, {"id": "$bobId", "name": "$bobName", "avatarHash": ""}], "onlineCount": 2}}}} +-- receive alice -- +{"kind": "event", "type": "members.updated", "term": 1, "seq": 6, "data": { + "sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, {"id": "$bobId", "name": "$bobName", "avatarHash": ""}], "onlineCount": 2}} + +-- request -- +GET /api/me/listens +Authorization: Bearer $bob +-- response -- +200 +{"items": []} + +-- request -- +PUT /api/me/listen-recording +Authorization: Bearer $bob + +{} +-- response -- +204 + +-- request -- +GET /api/me +Authorization: Bearer $bob +-- response -- +200 +{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}, "recordListens": true} + +-- disconnect bob -- +-- receive alice -- +{"kind": "event", "type": "members.updated", "term": 1, "seq": 7, "data": { + "sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 1}} + +-- connect again -- +-- send again -- +{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$room", "ticket": "$ticketB", "protocolVersion": "$protocolVersion"}} +-- receive again -- +{"kind": "response", "type": "hello", "id": "h1", "data": { + "member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}, + "serverTimeMs": 1, + "room": {"term": 1, "seq": 8, "room": {"id": "$room", "name": "大厅", "description": "", "kind": "persistent"}, + "playMode": "sequential", "pending": null, "items": [], + "nowPlaying": { + "entry": {"id": "1", "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": 269500}, + "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "ready"}, + "mediaUrl": "/media/netease/186016", "startedAtMs": 1, "durationMs": 269500, "gain": 1, "lyric": "[00:01.00]故事的小黄花"}, + "members": {"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, {"id": "$bobId", "name": "$bobName", "avatarHash": ""}], "onlineCount": 2}}}} +-- receive alice -- +{"kind": "event", "type": "members.updated", "term": 1, "seq": 8, "data": { + "sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, {"id": "$bobId", "name": "$bobName", "avatarHash": ""}], "onlineCount": 2}} + +-- request -- +GET /api/me/listens +Authorization: Bearer $bob +-- response -- +200 +{"items": [{"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": 269500}, "heardAtMs": 1}]} + +-- request -- +POST /api/me/listens/removals +Authorization: Bearer $alice + +{"track": {"source": "netease", "id": "186016"}} +-- response -- +204 + +-- request -- +POST /api/me/listens/removals +Authorization: Bearer $alice + +{"track": {"source": "qq", "id": "0039MnYb0qxYhV"}} +-- response -- +204 + +-- request -- +GET /api/me/listens +Authorization: Bearer $alice +-- response -- +200 +{"items": []} + +-- request -- +POST /api/sessions +Authorization: Bearer $bob + +{"username": "alice", "password": "correct horse"} +-- response -- +201 +{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}, "device": "$bobDevice"} +-- closed again -- +4401 +-- receive alice -- +{"kind": "event", "type": "members.updated", "term": 1, "seq": 9, "data": { + "sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 1}} + +-- request -- +GET /api/me/listens +Authorization: Bearer $alice2 +-- response -- +200 +{"items": [{"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": 269500}, "heardAtMs": 1}]} + +-- request -- +DELETE /api/me/listens +Authorization: Bearer $alice2 +-- response -- +204 + +-- request -- +GET /api/me/listens +Authorization: Bearer $alice +-- response -- +200 +{"items": []} + +-- request -- +GET /api/me/listens +-- response -- +401 +{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}} diff --git a/protocol/decode.gen.go b/protocol/decode.gen.go index 9e4f72a..a3d7fc6 100644 --- a/protocol/decode.gen.go +++ b/protocol/decode.gen.go @@ -187,6 +187,14 @@ func DecodeAdminResetAccountPasswordRequest(body []byte) (AdminResetAccountPassw return decodeAdminResetAccountPasswordRequest(body, "") } +func DecodeRemoveMyListenRequest(body []byte) (RemoveMyListenRequest, error) { + return decodeRemoveMyListenRequest(body, "") +} + +func DecodeEnableListenRecordingRequest(body []byte) (EnableListenRecordingRequest, error) { + return decodeEnableListenRecordingRequest(body, "") +} + func decodeAddMyPlaylistItemRequest(raw json.RawMessage, path string) (AddMyPlaylistItemRequest, error) { var v AddMyPlaylistItemRequest m, err := decodeObject(raw, path, "track") @@ -570,6 +578,12 @@ func decodeCreateTicketRequest(raw json.RawMessage, path string) (CreateTicketRe return v, nil } +func decodeEnableListenRecordingRequest(raw json.RawMessage, path string) (EnableListenRecordingRequest, error) { + var v EnableListenRecordingRequest + _, err := decodeObject(raw, path) + return v, err +} + func decodeHelloRequest(raw json.RawMessage, path string) (HelloRequest, error) { var v HelloRequest m, err := decodeObject(raw, path, "protocolVersion", "roomId", "ticket") @@ -967,6 +981,21 @@ func decodeRegisterRequest(raw json.RawMessage, path string) (RegisterRequest, e return v, nil } +func decodeRemoveMyListenRequest(raw json.RawMessage, path string) (RemoveMyListenRequest, error) { + var v RemoveMyListenRequest + 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 decodeRemoveMyPlaylistItemsRequest(raw json.RawMessage, path string) (RemoveMyPlaylistItemsRequest, error) { var v RemoveMyPlaylistItemsRequest m, err := decodeObject(raw, path, "tracks") diff --git a/protocol/idl.json b/protocol/idl.json index 51deeba..a756bcb 100644 --- a/protocol/idl.json +++ b/protocol/idl.json @@ -352,6 +352,16 @@ "message": {"type": "string", "description": "平台给出的说明,可能为空"} }, "required": ["status", "message"] + }, + "MyListen": { + "type": "object", + "additionalProperties": false, + "properties": { + "track": {"$ref": "#/$defs/Track"}, + "meta": {"$ref": "#/$defs/TrackMeta", "description": "听到时的元数据"}, + "heardAtMs": {"type": "integer", "description": "最近一次听到这首歌时它的开播时间"} + }, + "required": ["track", "meta", "heardAtMs"] } }, "x-ws": { @@ -741,9 +751,10 @@ "additionalProperties": false, "properties": { "member": {"$ref": "#/$defs/Member"}, - "account": {"$ref": "#/$defs/Account"} + "account": {"$ref": "#/$defs/Account"}, + "recordListens": {"type": "boolean", "description": "是否把在房间里听到的歌记进播放历史"} }, - "required": ["member"] + "required": ["member", "recordListens"] } }, { @@ -1553,6 +1564,66 @@ }, "required": ["password"] } + }, + { + "name": "listMyListens", + "description": "我的播放历史:在房间里听到过的歌,每首只留最近一次,最近听到的在前,最多 500 首;同一账号的所有设备看到同一份", + "method": "GET", + "path": "/api/me/listens", + "auth": "member", + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": {"type": "array", "items": {"$ref": "#/$defs/MyListen"}} + }, + "required": ["items"] + } + }, + { + "name": "removeMyListen", + "description": "从播放历史中删除一首歌;不在历史里的忽略", + "method": "POST", + "path": "/api/me/listens/removals", + "auth": "member", + "status": 204, + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "track": {"$ref": "#/$defs/Track"} + }, + "required": ["track"] + } + }, + { + "name": "clearMyListens", + "description": "清空播放历史", + "method": "DELETE", + "path": "/api/me/listens", + "auth": "member", + "status": 204 + }, + { + "name": "enableListenRecording", + "description": "开始把在房间里听到的歌记进播放历史", + "method": "PUT", + "path": "/api/me/listen-recording", + "auth": "member", + "status": 204, + "request": { + "type": "object", + "additionalProperties": false, + "properties": {} + } + }, + { + "name": "disableListenRecording", + "description": "不再记录播放历史,已有的历史保留", + "method": "DELETE", + "path": "/api/me/listen-recording", + "auth": "member", + "status": 204 } ] } diff --git a/protocol/openapi.gen.json b/protocol/openapi.gen.json index 63fbc97..5f91d75 100644 --- a/protocol/openapi.gen.json +++ b/protocol/openapi.gen.json @@ -570,6 +570,10 @@ "additionalProperties": false, "type": "object" }, + "EnableListenRecordingRequest": { + "additionalProperties": false, + "type": "object" + }, "Entry": { "additionalProperties": false, "properties": { @@ -690,10 +694,15 @@ }, "member": { "$ref": "#/components/schemas/Member" + }, + "recordListens": { + "description": "是否把在房间里听到的歌记进播放历史", + "type": "boolean" } }, "required": [ - "member" + "member", + "recordListens" ], "type": "object" }, @@ -829,6 +838,21 @@ ], "type": "object" }, + "ListMyListensResponse": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/MyListen" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" + }, "ListMyPlaylistItemsResponse": { "additionalProperties": false, "properties": { @@ -1014,6 +1038,28 @@ ], "type": "object" }, + "MyListen": { + "additionalProperties": false, + "properties": { + "heardAtMs": { + "description": "最近一次听到这首歌时它的开播时间", + "type": "integer" + }, + "meta": { + "$ref": "#/components/schemas/TrackMeta", + "description": "听到时的元数据" + }, + "track": { + "$ref": "#/components/schemas/Track" + } + }, + "required": [ + "track", + "meta", + "heardAtMs" + ], + "type": "object" + }, "MyPlaylist": { "additionalProperties": false, "properties": { @@ -1386,6 +1432,18 @@ ], "type": "object" }, + "RemoveMyListenRequest": { + "additionalProperties": false, + "properties": { + "track": { + "$ref": "#/components/schemas/Track" + } + }, + "required": [ + "track" + ], + "type": "object" + }, "RemoveMyPlaylistItemsRequest": { "additionalProperties": false, "properties": { @@ -1898,7 +1956,7 @@ }, "info": { "title": "Alisten", - "version": "ed36759d403f9894" + "version": "ae9a632d648f3840" }, "openapi": "3.1.0", "paths": { @@ -3035,6 +3093,159 @@ "summary": "修改昵称和头像;email 用来计算头像哈希,已注册的账号同时保存它,去掉首尾空白后应形如 name@example.com,空字符串表示清除" } }, + "/api/me/listen-recording": { + "delete": { + "operationId": "disableListenRecording", + "responses": { + "204": { + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "memberToken": [] + } + ], + "summary": "不再记录播放历史,已有的历史保留" + }, + "put": { + "operationId": "enableListenRecording", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnableListenRecordingRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "memberToken": [] + } + ], + "summary": "开始把在房间里听到的歌记进播放历史" + } + }, + "/api/me/listens": { + "delete": { + "operationId": "clearMyListens", + "responses": { + "204": { + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "memberToken": [] + } + ], + "summary": "清空播放历史" + }, + "get": { + "operationId": "listMyListens", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListMyListensResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "memberToken": [] + } + ], + "summary": "我的播放历史:在房间里听到过的歌,每首只留最近一次,最近听到的在前,最多 500 首;同一账号的所有设备看到同一份" + } + }, + "/api/me/listens/removals": { + "post": { + "operationId": "removeMyListen", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RemoveMyListenRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "memberToken": [] + } + ], + "summary": "从播放历史中删除一首歌;不在历史里的忽略" + } + }, "/api/me/playlist-links": { "post": { "operationId": "resolveMyPlaylistLink", diff --git a/protocol/types.gen.go b/protocol/types.gen.go index fb7cdcf..d367074 100644 --- a/protocol/types.gen.go +++ b/protocol/types.gen.go @@ -4,7 +4,7 @@ package protocol import "encoding/json" -const Version = "ed36759d403f9894" +const Version = "ae9a632d648f3840" type APIKey struct { Key string `json:"key"` @@ -212,6 +212,8 @@ type CreateTicketResponse struct { type Empty struct{} +type EnableListenRecordingRequest struct{} + type Entry struct { Auto bool `json:"auto"` By *Member `json:"by"` @@ -253,8 +255,9 @@ type ErrorInfo struct { } type GetMeResponse struct { - Account *Account `json:"account,omitempty"` - Member Member `json:"member"` + Account *Account `json:"account,omitempty"` + Member Member `json:"member"` + RecordListens bool `json:"recordListens"` } type GetSiteResponse struct { @@ -313,6 +316,18 @@ type ImportedTrack struct { Track Track `json:"track"` } +type ListMyListensResponse struct { + Items []MyListen `json:"items"` +} + +func (v ListMyListensResponse) MarshalJSON() ([]byte, error) { + type plain ListMyListensResponse + if v.Items == nil { + v.Items = []MyListen{} + } + return json.Marshal(plain(v)) +} + type ListMyPlaylistItemsResponse struct { Items []MyPlaylistItem `json:"items"` } @@ -410,6 +425,12 @@ func (v MoveMyPlaylistItemsRequest) MarshalJSON() ([]byte, error) { return json.Marshal(plain(v)) } +type MyListen struct { + HeardAtMs int64 `json:"heardAtMs"` + Meta TrackMeta `json:"meta"` + Track Track `json:"track"` +} + type MyPlaylist struct { CoverURL *string `json:"coverUrl"` CreatedAtMs int64 `json:"createdAtMs"` @@ -581,6 +602,10 @@ type RegisterResponse struct { Account Account `json:"account"` } +type RemoveMyListenRequest struct { + Track Track `json:"track"` +} + type RemoveMyPlaylistItemsRequest struct { Tracks []Track `json:"tracks"` } @@ -865,6 +890,11 @@ var ( RouteAdminCloseRegistration = Route{Pattern: "DELETE /api/admin/registration", Auth: AuthAdmin, Status: 204} RouteAdminGetAccount = Route{Pattern: "GET /api/admin/accounts/{username}", Auth: AuthAdmin, Status: 200} RouteAdminResetAccountPassword = Route{Pattern: "POST /api/admin/accounts/{username}/password", Auth: AuthAdmin, Status: 200} + RouteListMyListens = Route{Pattern: "GET /api/me/listens", Auth: AuthMember, Status: 200} + RouteRemoveMyListen = Route{Pattern: "POST /api/me/listens/removals", Auth: AuthMember, Status: 204} + RouteClearMyListens = Route{Pattern: "DELETE /api/me/listens", Auth: AuthMember, Status: 204} + RouteEnableListenRecording = Route{Pattern: "PUT /api/me/listen-recording", Auth: AuthMember, Status: 204} + RouteDisableListenRecording = Route{Pattern: "DELETE /api/me/listen-recording", Auth: AuthMember, Status: 204} ) var RoomRoutes = []RoomRoute{ diff --git a/web/src/core/api/http.gen.ts b/web/src/core/api/http.gen.ts index b9a8e0f..0e0fe95 100644 --- a/web/src/core/api/http.gen.ts +++ b/web/src/core/api/http.gen.ts @@ -1,6 +1,6 @@ // Code generated by protocolgen from protocol/idl.json; DO NOT EDIT. -import type { APIKey, Account, AdminAccount, ErrorInfo, ImportedTrack, Member, MyPlaylist, MyPlaylistItem, QQLoginType, QRLogin, QRLoginResult, RoomListing, Source, SourceAccount, StoredMedia, Track } from '../realtime/protocol.gen' +import type { APIKey, Account, AdminAccount, ErrorInfo, ImportedTrack, Member, MyListen, MyPlaylist, MyPlaylistItem, QQLoginType, QRLogin, QRLoginResult, RoomListing, Source, SourceAccount, StoredMedia, Track } from '../realtime/protocol.gen' export interface AddMyPlaylistItemRequest { track: Track @@ -147,9 +147,12 @@ export interface CreateTicketResponse { ticket: string } +export type EnableListenRecordingRequest = Record + export interface GetMeResponse { account?: Account member: Member + recordListens: boolean } export interface GetSiteResponse { @@ -178,6 +181,10 @@ export interface ImportMyPlaylistResponse { playlist: MyPlaylist } +export interface ListMyListensResponse { + items: MyListen[] +} + export interface ListMyPlaylistItemsResponse { items: MyPlaylistItem[] } @@ -225,6 +232,10 @@ export interface RegisterResponse { account: Account } +export interface RemoveMyListenRequest { + track: Track +} + export interface RemoveMyPlaylistItemsRequest { tracks: Track[] } @@ -318,5 +329,10 @@ export function httpClient(send: HttpSend) { adminCloseRegistration: () => send({ method: 'DELETE', path: '/api/admin/registration', auth: 'admin' }), adminGetAccount: (username: string) => send({ method: 'GET', path: `/api/admin/accounts/${encodeURIComponent(username)}`, auth: 'admin' }), adminResetAccountPassword: (username: string) => send({ method: 'POST', path: `/api/admin/accounts/${encodeURIComponent(username)}/password`, auth: 'admin', body: {} }), + listMyListens: () => send({ method: 'GET', path: '/api/me/listens', auth: 'member' }), + removeMyListen: (body: RemoveMyListenRequest) => send({ method: 'POST', path: '/api/me/listens/removals', auth: 'member', body }), + clearMyListens: () => send({ method: 'DELETE', path: '/api/me/listens', auth: 'member' }), + enableListenRecording: () => send({ method: 'PUT', path: '/api/me/listen-recording', auth: 'member', body: {} }), + disableListenRecording: () => send({ method: 'DELETE', path: '/api/me/listen-recording', auth: 'member' }), } } diff --git a/web/src/core/realtime/protocol.gen.ts b/web/src/core/realtime/protocol.gen.ts index 55e443e..0bb87cf 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 = 'ed36759d403f9894' +export const PROTOCOL_VERSION = 'ae9a632d648f3840' export interface APIKey { key: string @@ -82,6 +82,12 @@ export interface Members { sessions: Member[] } +export interface MyListen { + heardAtMs: number + meta: TrackMeta + track: Track +} + export interface MyPlaylist { coverUrl: string | null createdAtMs: number