diff --git a/db/queries/playlists.sql b/db/queries/playlists.sql index dbfc854..bbd88bb 100644 --- a/db/queries/playlists.sql +++ b/db/queries/playlists.sql @@ -98,3 +98,9 @@ WHERE i.playlist_id = @playlist_id AND i.source = s.source AND i.track_id = k.tr -- name: TouchPlaylist :exec UPDATE playlists SET updated_at = now() WHERE id = @id; + +-- name: TrackPlaylists :many +SELECT p.id FROM playlists p +JOIN playlist_items i ON i.playlist_id = p.id +WHERE p.owner_id = @owner_id AND i.source = @source AND i.track_id = @track_id +ORDER BY p.id; diff --git a/internal/adapter/in/api/api.go b/internal/adapter/in/api/api.go index 4142fe9..232e888 100644 --- a/internal/adapter/in/api/api.go +++ b/internal/adapter/in/api/api.go @@ -121,6 +121,7 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider, srv.handle(mux, protocol.RouteListMyPlaylistItems, srv.listMyPlaylistItems) srv.handle(mux, protocol.RouteAddMyPlaylistItem, srv.addMyPlaylistItem) srv.handleLimit(mux, protocol.RouteRemoveMyPlaylistItems, maxBatchBodyBytes, srv.removeMyPlaylistItems) + srv.handle(mux, protocol.RouteListMyTrackPlaylists, srv.listMyTrackPlaylists) srv.handleLimit(mux, protocol.RouteCopyMyPlaylistItems, maxBatchBodyBytes, srv.copyMyPlaylistItems) srv.handleLimit(mux, protocol.RouteMoveMyPlaylistItems, maxBatchBodyBytes, srv.moveMyPlaylistItems) srv.handle(mux, protocol.RouteListRooms, srv.listRooms) diff --git a/internal/adapter/in/api/api_test.go b/internal/adapter/in/api/api_test.go index f21af2b..5483499 100644 --- a/internal/adapter/in/api/api_test.go +++ b/internal/adapter/in/api/api_test.go @@ -201,6 +201,10 @@ func TestPlaylistBatchBodyLimit(t *testing.T) { if got := send(t, server, http.MethodPost, "/api/me/playlists/"+favorites+"/removals", tooLarge, auth...); got != want { t.Errorf("超过批量上限的请求体 = %#v,期望 %#v", got, want) } + want = failure(protocol.ErrorCodeInvalidArgument, "请求体不能超过 64 KiB") + if got := send(t, server, http.MethodPost, "/api/me/playlist-memberships", `{"tracks":`+list+`}`, auth...); got != want { + t.Errorf("其他接口超过 64 KiB 的请求体 = %#v,期望 %#v", got, want) + } } func TestMalformedPathParametersAreNotFound(t *testing.T) { diff --git a/internal/adapter/in/api/playlist.go b/internal/adapter/in/api/playlist.go index 9e1b6e9..65b22f6 100644 --- a/internal/adapter/in/api/playlist.go +++ b/internal/adapter/in/api/playlist.go @@ -108,6 +108,18 @@ func (s *server) removeMyPlaylistItems(ctx context.Context, c call) (any, error) return nil, s.Playlists.Remove(ctx, c.member.ID, c.r.PathValue("id"), tracksOf(req.Tracks)) } +func (s *server) listMyTrackPlaylists(ctx context.Context, c call) (any, error) { + req, err := protocol.DecodeListMyTrackPlaylistsRequest(c.body) + if err != nil { + return nil, err + } + ids, err := s.Playlists.Containing(ctx, c.member.ID, trackOf(req.Track)) + if err != nil { + return nil, err + } + return protocol.ListMyTrackPlaylistsResponse{PlaylistIds: ids}, nil +} + func (s *server) copyMyPlaylistItems(ctx context.Context, c call) (any, error) { req, err := protocol.DecodeCopyMyPlaylistItemsRequest(c.body) if err != nil { diff --git a/internal/adapter/out/postgres/internal/query/playlists.sql.go b/internal/adapter/out/postgres/internal/query/playlists.sql.go index 4ee48df..9be6d29 100644 --- a/internal/adapter/out/postgres/internal/query/playlists.sql.go +++ b/internal/adapter/out/postgres/internal/query/playlists.sql.go @@ -452,3 +452,36 @@ func (q *Queries) TouchPlaylist(ctx context.Context, id string) error { _, err := q.db.Exec(ctx, touchPlaylist, id) return err } + +const trackPlaylists = `-- name: TrackPlaylists :many +SELECT p.id FROM playlists p +JOIN playlist_items i ON i.playlist_id = p.id +WHERE p.owner_id = $1 AND i.source = $2 AND i.track_id = $3 +ORDER BY p.id +` + +type TrackPlaylistsParams struct { + OwnerID string + Source string + TrackID string +} + +func (q *Queries) TrackPlaylists(ctx context.Context, arg TrackPlaylistsParams) ([]string, error) { + rows, err := q.db.Query(ctx, trackPlaylists, arg.OwnerID, arg.Source, arg.TrackID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/adapter/out/postgres/playlists.go b/internal/adapter/out/postgres/playlists.go index d3e05e8..6ae6f05 100644 --- a/internal/adapter/out/postgres/playlists.go +++ b/internal/adapter/out/postgres/playlists.go @@ -218,6 +218,17 @@ func (s *Store) RemovePlaylistItems(ctx context.Context, ownerID, id string, tra return nil } +func (s *Store) TrackPlaylists(ctx context.Context, ownerID string, t track.Track) ([]string, error) { + ids, err := s.q.TrackPlaylists(ctx, query.TrackPlaylistsParams{OwnerID: ownerID, Source: string(t.Source), TrackID: t.ID}) + if err != nil { + return nil, fmt.Errorf("查询曲目 %s 所在的歌单:%w", t.ID, err) + } + if ids == nil { + ids = []string{} + } + return ids, nil +} + func (s *Store) CopyPlaylistItems(ctx context.Context, ownerID, fromID, toID string, tracks []track.Track, limit int) (int, error) { var added int64 err := s.owned(ctx, ownerID, fromID) diff --git a/internal/adapter/out/postgres/playlists_test.go b/internal/adapter/out/postgres/playlists_test.go index ade2216..7c17371 100644 --- a/internal/adapter/out/postgres/playlists_test.go +++ b/internal/adapter/out/postgres/playlists_test.go @@ -503,6 +503,30 @@ func TestConcurrentRemovalsAndDeletesDoNotDeadlock(t *testing.T) { } } +func TestTrackPlaylistsOnlyListsOwnPlaylists(t *testing.T) { + store, _ := newStore(t) + alice := registered(t, store, "alice") + bob := registered(t, store, "bob") + favorites := favoritesOf(t, store, alice.ID) + commute := filled(t, store, alice.ID, "通勤", "1") + filled(t, store, alice.ID, "空", "2") + bobFavorites := favoritesOf(t, store, bob.ID) + if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, songItem("1"), 10); err != nil { + t.Fatal(err) + } + if err := store.AddPlaylistItem(t.Context(), bob.ID, bobFavorites, songItem("1"), 10); err != nil { + t.Fatal(err) + } + ids, err := store.TrackPlaylists(t.Context(), alice.ID, songItem("1").Track) + if want := []string{favorites, commute}; err != nil || !slices.Equal(ids, want) { + t.Errorf("所在的歌单 = %v, %v,期望只有本人的 %v", ids, err, want) + } + ids, err = store.TrackPlaylists(t.Context(), alice.ID, songItem("3").Track) + if err != nil || ids == nil || len(ids) != 0 { + t.Errorf("不在任何歌单里的歌 = %#v, %v,期望空切片", ids, err) + } +} + func TestMovePlaylistItems(t *testing.T) { store, _ := newStore(t) alice := registered(t, store, "alice") diff --git a/internal/app/playlist/playlist.go b/internal/app/playlist/playlist.go index 41ed90d..50043c5 100644 --- a/internal/app/playlist/playlist.go +++ b/internal/app/playlist/playlist.go @@ -124,6 +124,13 @@ func (s *Service) Remove(ctx context.Context, memberID, id string, tracks []trac return s.playlists.RemovePlaylistItems(ctx, memberID, id, tracks) } +func (s *Service) Containing(ctx context.Context, memberID string, t track.Track) ([]string, error) { + if err := s.registered(ctx, memberID); err != nil { + return nil, err + } + return s.playlists.TrackPlaylists(ctx, memberID, t) +} + func (s *Service) Copy(ctx context.Context, memberID, fromID, toID string, tracks []track.Track) (int, error) { if err := s.registered(ctx, memberID); err != nil { return 0, err diff --git a/internal/app/playlist/playlist_test.go b/internal/app/playlist/playlist_test.go index 9f2a3f1..e9614d5 100644 --- a/internal/app/playlist/playlist_test.go +++ b/internal/app/playlist/playlist_test.go @@ -119,6 +119,9 @@ func TestGuestCannotUsePlaylists(t *testing.T) { if _, err := f.svc.Add(t.Context(), guest.ID, "any", sunny); !errors.Is(err, ErrNotRegistered) { t.Errorf("游客收藏 = %v,期望 ErrNotRegistered", err) } + if _, err := f.svc.Containing(t.Context(), guest.ID, sunny); !errors.Is(err, ErrNotRegistered) { + t.Errorf("游客查询所在的歌单 = %v,期望 ErrNotRegistered", err) + } if err := f.svc.Remove(t.Context(), guest.ID, "any", []track.Track{sunny}); !errors.Is(err, ErrNotRegistered) { t.Errorf("游客批量移出 = %v,期望 ErrNotRegistered", err) } diff --git a/internal/app/port/playlist.go b/internal/app/port/playlist.go index ecf8302..b1f0a80 100644 --- a/internal/app/port/playlist.go +++ b/internal/app/port/playlist.go @@ -51,6 +51,8 @@ type Playlists interface { AddPlaylistItem(ctx context.Context, ownerID, id string, item PlaylistItem, limit int) error // 不在歌单里的歌忽略。 RemovePlaylistItems(ctx context.Context, ownerID, id string, tracks []track.Track) error + // 只看这个成员自己的歌单;没有命中时返回空切片。 + TrackPlaylists(ctx context.Context, ownerID string, t track.Track) ([]string, error) // 把 fromID 歌单里的这些歌连同元数据复制到 toID 歌单,返回复制的数量。fromID 不属于 ownerID 时返回 ErrPlaylistNotFound。 // 不在 fromID 里的和已在 toID 里的跳过;新复制的排在最前,tracks[0] 最靠前。 // 复制之后超过 limit 首时整批拒绝,返回 ErrPlaylistLimit。 diff --git a/internal/app/port/porttest/store.go b/internal/app/port/porttest/store.go index ba5227a..5803397 100644 --- a/internal/app/port/porttest/store.go +++ b/internal/app/port/porttest/store.go @@ -676,6 +676,18 @@ func (s *Store) RemovePlaylistItems(_ context.Context, ownerID, id string, track return nil } +func (s *Store) TrackPlaylists(_ context.Context, ownerID string, t track.Track) ([]string, error) { + s.mu.Lock() + defer s.mu.Unlock() + ids := []string{} + for _, p := range s.playlists { + if p.owner == ownerID && slices.ContainsFunc(p.items, func(i port.PlaylistItem) bool { return i.Track == t }) { + ids = append(ids, p.id) + } + } + return ids, nil +} + func (s *Store) CopyPlaylistItems(_ context.Context, ownerID, fromID, toID string, tracks []track.Track, limit int) (int, error) { s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/bootstrap/media_test.go b/internal/bootstrap/media_test.go index 1d22a0e..f537275 100644 --- a/internal/bootstrap/media_test.go +++ b/internal/bootstrap/media_test.go @@ -5,6 +5,7 @@ import ( "io" "log/slog" "net/http" + "slices" "strings" "testing" "time" @@ -238,6 +239,13 @@ func TestPlaylistLinkTracks(t *testing.T) { t.Errorf("歌单里的曲目 = %+v,期望一首 ID %s、封面 %s", items.Items, link, coverURL) } + var memberships struct { + PlaylistIDs []string `json:"playlistIds"` + } + call("POST /api/me/playlist-memberships"+auth+"\n\n"+`{"track": {"source": "link", "id": "https://Example.com/Song.mp3"}}`, http.StatusOK, &memberships) + if !slices.Equal(memberships.PlaylistIDs, []string{favorites}) { + t.Errorf("换一种大小写查所在的歌单 = %v,期望 [%s]", memberships.PlaylistIDs, favorites) + } call("POST /api/me/playlists/"+favorites+"/removals"+auth+"\n\n"+`{"tracks": [{"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 { diff --git a/internal/bootstrap/testdata/http/playlists.txtar b/internal/bootstrap/testdata/http/playlists.txtar index 5c502ae..be43217 100644 --- a/internal/bootstrap/testdata/http/playlists.txtar +++ b/internal/bootstrap/testdata/http/playlists.txtar @@ -226,6 +226,24 @@ Authorization: Bearer $alice 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": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/example.jpg"}]} +-- request -- +POST /api/me/playlist-memberships +Authorization: Bearer $alice + +{"track": {"source": "netease", "id": "186016"}} +-- response -- +200 +{"playlistIds": ["$favorites", "$commute"]} + +-- request -- +POST /api/me/playlist-memberships +Authorization: Bearer $alice + +{"track": {"source": "netease", "id": "999"}} +-- response -- +200 +{"playlistIds": []} + -- request -- POST /api/me/playlists/$favorites/removals Authorization: Bearer $alice diff --git a/protocol/decode.gen.go b/protocol/decode.gen.go index fa4a6bf..6af1d22 100644 --- a/protocol/decode.gen.go +++ b/protocol/decode.gen.go @@ -110,6 +110,10 @@ func DecodeMoveMyPlaylistItemsRequest(body []byte) (MoveMyPlaylistItemsRequest, return decodeMoveMyPlaylistItemsRequest(body, "") } +func DecodeListMyTrackPlaylistsRequest(body []byte) (ListMyTrackPlaylistsRequest, error) { + return decodeListMyTrackPlaylistsRequest(body, "") +} + func DecodeCreateRoomRequest(body []byte) (CreateRoomRequest, error) { return decodeCreateRoomRequest(body, "") } @@ -574,6 +578,21 @@ func decodeHelloRequest(raw json.RawMessage, path string) (HelloRequest, error) return v, nil } +func decodeListMyTrackPlaylistsRequest(raw json.RawMessage, path string) (ListMyTrackPlaylistsRequest, error) { + var v ListMyTrackPlaylistsRequest + 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 decodeLoginRequest(raw json.RawMessage, path string) (LoginRequest, error) { var v LoginRequest m, err := decodeObject(raw, path, "password", "username") diff --git a/protocol/idl.json b/protocol/idl.json index 67090c2..0b520a8 100644 --- a/protocol/idl.json +++ b/protocol/idl.json @@ -979,6 +979,29 @@ "required": ["tracks"] } }, + { + "name": "listMyTrackPlaylists", + "description": "这首歌在我的哪些歌单里", + "method": "POST", + "path": "/api/me/playlist-memberships", + "auth": "member", + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "track": {"$ref": "#/$defs/Track"} + }, + "required": ["track"] + }, + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "playlistIds": {"type": "array", "items": {"type": "string"}} + }, + "required": ["playlistIds"] + } + }, { "name": "listRooms", "description": "房间列表", diff --git a/protocol/openapi.gen.json b/protocol/openapi.gen.json index 21221cf..04e596c 100644 --- a/protocol/openapi.gen.json +++ b/protocol/openapi.gen.json @@ -743,6 +743,33 @@ ], "type": "object" }, + "ListMyTrackPlaylistsRequest": { + "additionalProperties": false, + "properties": { + "track": { + "$ref": "#/components/schemas/Track" + } + }, + "required": [ + "track" + ], + "type": "object" + }, + "ListMyTrackPlaylistsResponse": { + "additionalProperties": false, + "properties": { + "playlistIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "playlistIds" + ], + "type": "object" + }, "ListRoomsResponse": { "additionalProperties": false, "properties": { @@ -1714,7 +1741,7 @@ }, "info": { "title": "Alisten", - "version": "000976bd80c1113a" + "version": "5b9cd2cb39780e46" }, "openapi": "3.1.0", "paths": { @@ -2851,6 +2878,49 @@ "summary": "修改昵称和头像;email 用来计算头像哈希,已注册的账号同时保存它,去掉首尾空白后应形如 name@example.com,空字符串表示清除" } }, + "/api/me/playlist-memberships": { + "post": { + "operationId": "listMyTrackPlaylists", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListMyTrackPlaylistsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListMyTrackPlaylistsResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "memberToken": [] + } + ], + "summary": "这首歌在我的哪些歌单里" + } + }, "/api/me/playlists": { "get": { "operationId": "listMyPlaylists", diff --git a/protocol/types.gen.go b/protocol/types.gen.go index 1857798..95327f3 100644 --- a/protocol/types.gen.go +++ b/protocol/types.gen.go @@ -4,7 +4,7 @@ package protocol import "encoding/json" -const Version = "000976bd80c1113a" +const Version = "5b9cd2cb39780e46" type APIKey struct { Key string `json:"key"` @@ -298,6 +298,22 @@ func (v ListMyPlaylistsResponse) MarshalJSON() ([]byte, error) { return json.Marshal(plain(v)) } +type ListMyTrackPlaylistsRequest struct { + Track Track `json:"track"` +} + +type ListMyTrackPlaylistsResponse struct { + PlaylistIds []string `json:"playlistIds"` +} + +func (v ListMyTrackPlaylistsResponse) MarshalJSON() ([]byte, error) { + type plain ListMyTrackPlaylistsResponse + if v.PlaylistIds == nil { + v.PlaylistIds = []string{} + } + return json.Marshal(plain(v)) +} + type ListRoomsResponse struct { Items []RoomListing `json:"items"` } @@ -765,6 +781,7 @@ var ( RouteRemoveMyPlaylistItems = Route{Pattern: "POST /api/me/playlists/{id}/removals", Auth: AuthMember, Status: 204} RouteCopyMyPlaylistItems = Route{Pattern: "POST /api/me/playlists/{id}/copies", Auth: AuthMember, Status: 200} RouteMoveMyPlaylistItems = Route{Pattern: "POST /api/me/playlists/{id}/moves", Auth: AuthMember, Status: 204} + RouteListMyTrackPlaylists = Route{Pattern: "POST /api/me/playlist-memberships", Auth: AuthMember, Status: 200} 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 3f96de9..fc20946 100644 --- a/web/src/core/api/http.gen.ts +++ b/web/src/core/api/http.gen.ts @@ -165,6 +165,14 @@ export interface ListMyPlaylistsResponse { items: MyPlaylist[] } +export interface ListMyTrackPlaylistsRequest { + track: Track +} + +export interface ListMyTrackPlaylistsResponse { + playlistIds: string[] +} + export interface ListRoomsResponse { items: RoomListing[] } @@ -244,6 +252,7 @@ export function httpClient(send: HttpSend) { removeMyPlaylistItems: (id: string, body: RemoveMyPlaylistItemsRequest) => send({ method: 'POST', path: `/api/me/playlists/${encodeURIComponent(id)}/removals`, auth: 'member', body }), copyMyPlaylistItems: (id: string, body: CopyMyPlaylistItemsRequest) => send({ method: 'POST', path: `/api/me/playlists/${encodeURIComponent(id)}/copies`, auth: 'member', body }), moveMyPlaylistItems: (id: string, body: MoveMyPlaylistItemsRequest) => send({ method: 'POST', path: `/api/me/playlists/${encodeURIComponent(id)}/moves`, auth: 'member', body }), + listMyTrackPlaylists: (body: ListMyTrackPlaylistsRequest) => send({ method: 'POST', path: '/api/me/playlist-memberships', 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 acb94e8..9277743 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 = '000976bd80c1113a' +export const PROTOCOL_VERSION = '5b9cd2cb39780e46' export interface APIKey { key: string