feat: tell which of my playlists contain a song

This commit is contained in:
DiheChen committed 2026-11-09 21:29:43 +08:00
1 parent 4a59c639cd
commit c87b48f014
19 files changed
+282 -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
@@ -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)
+4
View File
@@ -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) {
+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
}
@@ -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)
@@ -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")
+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
@@ -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()
+8
View File
@@ -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 {
+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, "")
}
@@ -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")
+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": "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",
+18 -1
View File
@@ -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}
+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 = '000976bd80c1113a'
export const PROTOCOL_VERSION = '5b9cd2cb39780e46'
export interface APIKey {
key: string