feat: tell which of my playlists contain a song

This commit is contained in:
DiheChen committed 2026-11-09 01:47:45 +08:00
1 parent 5c34450ccf
commit 24abca6dc7
17 files changed
+270 -3

No files matched your search

+6
View File
@@ -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;
+1
View File
@@ -119,6 +119,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.handle(mux, protocol.RouteRemoveMyPlaylistItems, srv.removeMyPlaylistItems)
srv.handle(mux, protocol.RouteListMyTrackPlaylists, srv.listMyTrackPlaylists)
srv.handle(mux, protocol.RouteCopyMyPlaylistItems, srv.copyMyPlaylistItems)
srv.handle(mux, protocol.RouteMoveMyPlaylistItems, srv.moveMyPlaylistItems)
srv.handle(mux, protocol.RouteListRooms, srv.listRooms)
+12
View File
@@ -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 {
@@ -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
}
@@ -210,6 +210,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)
@@ -438,6 +438,30 @@ func TestConcurrentCrossCopiesDoNotDeadlock(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")
+7
View File
@@ -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
+3
View File
@@ -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)
}
+2
View File
@@ -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。
+12
View File
@@ -659,6 +659,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()
+18
View File
@@ -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
+19
View File
@@ -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, "")
}
@@ -577,6 +581,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")
+23
View File
@@ -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": "房间列表",
+71 -1
View File
@@ -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": "236deb132b07fa9e"
"version": "12f9c11ba788ac55"
},
"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",
+18 -1
View File
@@ -4,7 +4,7 @@ package protocol
import "encoding/json"
const Version = "236deb132b07fa9e"
const Version = "12f9c11ba788ac55"
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}
+9
View File
@@ -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<void>({ method: 'POST', path: `/api/me/playlists/${encodeURIComponent(id)}/removals`, auth: 'member', body }),
copyMyPlaylistItems: (id: string, body: CopyMyPlaylistItemsRequest) => send<CopyMyPlaylistItemsResponse>({ method: 'POST', path: `/api/me/playlists/${encodeURIComponent(id)}/copies`, auth: 'member', body }),
moveMyPlaylistItems: (id: string, body: MoveMyPlaylistItemsRequest) => send<void>({ method: 'POST', path: `/api/me/playlists/${encodeURIComponent(id)}/moves`, auth: 'member', body }),
listMyTrackPlaylists: (body: ListMyTrackPlaylistsRequest) => send<ListMyTrackPlaylistsResponse>({ method: 'POST', path: '/api/me/playlist-memberships', auth: 'member', body }),
listRooms: () => send<ListRoomsResponse>({ method: 'GET', path: '/api/rooms', auth: 'none' }),
getRoom: (ref: string) => send<RoomListing>({ method: 'GET', path: `/api/rooms/${encodeURIComponent(ref)}`, auth: 'none' }),
createRoom: (body: CreateRoomRequest) => send<CreateRoomResponse>({ method: 'POST', path: '/api/rooms', auth: 'none', body }),
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
export const PROTOCOL_VERSION = '236deb132b07fa9e'
export const PROTOCOL_VERSION = '12f9c11ba788ac55'
export interface APIKey {
key: string