From 8966954489faed7b8c9cf727c45a628df1d53cec Mon Sep 17 00:00:00 2001 From: DiheChen Date: Tue, 10 Nov 2026 10:05:38 +0800 Subject: [PATCH] feat: expose playlist imports over HTTP --- internal/adapter/in/api/api.go | 10 +- internal/adapter/in/api/playlist.go | 50 ++++ internal/bootstrap/public.go | 4 +- .../testdata/http/playlist-imports.txtar | 178 +++++++++++ protocol/decode.gen.go | 154 ++++++++++ protocol/idl.json | 91 ++++++ protocol/openapi.gen.json | 277 +++++++++++++++++- protocol/types.gen.go | 53 +++- .../templates/alisten.d/proxy.conf.template | 8 + web/src/core/api/http.gen.ts | 35 ++- web/src/core/realtime/protocol.gen.ts | 12 +- 11 files changed, 866 insertions(+), 6 deletions(-) create mode 100644 internal/bootstrap/testdata/http/playlist-imports.txtar diff --git a/internal/adapter/in/api/api.go b/internal/adapter/in/api/api.go index 82e5d87..74898c7 100644 --- a/internal/adapter/in/api/api.go +++ b/internal/adapter/in/api/api.go @@ -36,7 +36,9 @@ const ( maxBodyBytes = 64 << 10 // 歌单的批量接口一次最多 1000 首,链接曲目的 ID 最长 2048 字节,每首再留 256 字节给来源、字段名和 JSON 的标点。 maxBatchBodyBytes = 1000 * (2048 + 256) - requestDeadline = 3 * time.Minute + // 导入文件一次最多 1000 首,每首除了曲目还带标题、歌手、专辑和两个地址。nginx 中 /api/me/playlists/imports 的上限要与它保持一致。 + maxImportBodyBytes = 1 << 20 + requestDeadline = 3 * time.Minute ) type Services struct { @@ -120,6 +122,9 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider, srv.handle(mux, protocol.RouteListMyTrackPlaylists, srv.listMyTrackPlaylists) srv.handleLimit(mux, protocol.RouteCopyMyPlaylistItems, maxBatchBodyBytes, srv.copyMyPlaylistItems) srv.handleLimit(mux, protocol.RouteMoveMyPlaylistItems, maxBatchBodyBytes, srv.moveMyPlaylistItems) + srv.handleLimit(mux, protocol.RouteImportMyPlaylist, maxImportBodyBytes, srv.importMyPlaylist) + srv.handle(mux, protocol.RouteResolveMyPlaylistLink, srv.resolveMyPlaylistLink) + srv.handle(mux, protocol.RouteImportMyPlatformPlaylist, srv.importMyPlatformPlaylist) srv.handle(mux, protocol.RouteListRooms, srv.listRooms) srv.handle(mux, protocol.RouteGetRoom, srv.getRoom) srv.handle(mux, protocol.RouteCreateRoom, srv.createRoom) @@ -326,6 +331,9 @@ var knownErrors = []struct { {playlist.ErrPlaylistFull, protocol.ErrorCodeConflict}, {playlist.ErrTrackNotReady, protocol.ErrorCodeConflict}, {playlist.ErrTooFast, protocol.ErrorCodeRateLimited}, + {playlist.ErrInvalidPlaylistLink, protocol.ErrorCodeInvalidArgument}, + {playlist.ErrUnsupportedPlaylist, protocol.ErrorCodeInvalidArgument}, + {playlist.ErrShortLinkFailed, protocol.ErrorCodeProviderError}, {playlistorder.ErrBeforeMoved, protocol.ErrorCodeInvalidArgument}, {playlistorder.ErrBeforeMissing, protocol.ErrorCodeConflict}, {realtime.ErrRequesterNotAllowed, protocol.ErrorCodeInvalidArgument}, diff --git a/internal/adapter/in/api/playlist.go b/internal/adapter/in/api/playlist.go index ca3f28b..4c2a632 100644 --- a/internal/adapter/in/api/playlist.go +++ b/internal/adapter/in/api/playlist.go @@ -2,7 +2,9 @@ package api import ( "context" + "time" + "github.com/dihemoe/alisten/internal/app/playlist" "github.com/dihemoe/alisten/internal/app/port" "github.com/dihemoe/alisten/internal/domain/track" "github.com/dihemoe/alisten/protocol" @@ -144,3 +146,51 @@ func (s *server) moveMyPlaylistItems(ctx context.Context, c call) (any, error) { } return nil, s.Playlists.Move(ctx, c.member.ID, c.r.PathValue("id"), tracksOf(req.Tracks), before) } + +func (s *server) importMyPlaylist(ctx context.Context, c call) (any, error) { + req, err := protocol.DecodeImportMyPlaylistRequest(c.body) + if err != nil { + return nil, err + } + tracks := make([]playlist.ImportedTrack, len(req.Tracks)) + for i, t := range req.Tracks { + tracks[i] = playlist.ImportedTrack{ + Track: trackOf(t.Track), + Title: t.Title, + Artist: deref(t.Artist), + Album: deref(t.Album), + CoverURL: deref(t.CoverURL), + PageURL: deref(t.PageURL), + Duration: time.Duration(t.DurationMs) * time.Millisecond, + } + } + p, n, err := s.Playlists.ImportFile(ctx, c.member.ID, req.Name, tracks) + if err != nil { + return nil, err + } + return protocol.ImportMyPlaylistResponse{Playlist: s.playlistDTO(p), Imported: int64(n)}, nil +} + +func (s *server) resolveMyPlaylistLink(ctx context.Context, c call) (any, error) { + req, err := protocol.DecodeResolveMyPlaylistLinkRequest(c.body) + if err != nil { + return nil, err + } + source, id, err := s.Playlists.ResolveLink(ctx, c.member.ID, req.Link) + if err != nil { + return nil, err + } + return protocol.ResolveMyPlaylistLinkResponse{Source: protocol.Source(source), PlaylistID: id}, nil +} + +func (s *server) importMyPlatformPlaylist(ctx context.Context, c call) (any, error) { + req, err := protocol.DecodeImportMyPlatformPlaylistRequest(c.body) + if err != nil { + return nil, err + } + p, n, err := s.Playlists.ImportPlatform(ctx, c.member.ID, req.Name, track.Source(req.Source), req.PlaylistID) + if err != nil { + return nil, err + } + return protocol.ImportMyPlatformPlaylistResponse{Playlist: s.playlistDTO(p), Imported: int64(n)}, nil +} diff --git a/internal/bootstrap/public.go b/internal/bootstrap/public.go index 34fe137..e4da9cd 100644 --- a/internal/bootstrap/public.go +++ b/internal/bootstrap/public.go @@ -22,6 +22,7 @@ import ( "github.com/dihemoe/alisten/internal/adapter/out/postgres" "github.com/dihemoe/alisten/internal/adapter/out/queue" "github.com/dihemoe/alisten/internal/adapter/out/redis" + "github.com/dihemoe/alisten/internal/adapter/out/shortlink" "github.com/dihemoe/alisten/internal/adapter/out/storage" "github.com/dihemoe/alisten/internal/app/admin" "github.com/dihemoe/alisten/internal/app/catalog" @@ -33,6 +34,7 @@ import ( "github.com/dihemoe/alisten/internal/app/prepare" "github.com/dihemoe/alisten/internal/app/realtime" "github.com/dihemoe/alisten/internal/app/site" + playlistlink "github.com/dihemoe/alisten/internal/domain/playlist" "github.com/dihemoe/alisten/internal/domain/track" ) @@ -80,7 +82,7 @@ func newPublic(cfg serveConfig, pool *pgxpool.Pool, rdb *goredis.Client, instanc services := api.Services{ 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), nil), + Playlists: playlist.New(store, store, store, music, redis.NewAccountQuota(rdb), shortlink.New(playlistlink.AllowedRedirectHost, playlistlink.Recognized)), Lobby: rooms, Admin: admin.New(cfg.adminToken, rooms, store, store, store, src.neteaseLogin, src.qqLogin, src.bilibiliLogin, store, gateway), Gateway: gateway, diff --git a/internal/bootstrap/testdata/http/playlist-imports.txtar b/internal/bootstrap/testdata/http/playlist-imports.txtar new file mode 100644 index 0000000..0633da8 --- /dev/null +++ b/internal/bootstrap/testdata/http/playlist-imports.txtar @@ -0,0 +1,178 @@ +歌单导入:只有已注册的账号能导入。导入文件时,曲目表里已有的歌用服务端的元数据,没有的用文件里的元数据(不是平台自己的 https 地址的封面和页面链接被丢弃,链接曲目没有封面);重复的歌只留第一次出现的位置,标题为空的跳过,来源中的第一首排在歌单最前。链接可以识别出平台歌单,目前只能导入网易云的歌单。 + +-- request -- +POST /api/members + +{} +-- response -- +201 +{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}} + +-- request -- +POST /api/me/playlists/imports +Authorization: Bearer $alice + +{"name": "游客", "tracks": []} +-- response -- +403 +{"error": {"code": "FORBIDDEN", "message": "注册账号之后才能使用歌单"}} + +-- request -- +POST /api/me/playlist-links +Authorization: Bearer $alice + +{"link": "https://music.163.com/#/playlist?id=3778678"} +-- response -- +403 +{"error": {"code": "FORBIDDEN", "message": "注册账号之后才能使用歌单"}} + +-- request -- +POST /api/me/playlists/platform-imports +Authorization: Bearer $alice + +{"name": "游客", "source": "netease", "playlistId": "3778678"} +-- response -- +403 +{"error": {"code": "FORBIDDEN", "message": "注册账号之后才能使用歌单"}} + +-- request -- +POST /api/account +Authorization: Bearer $alice + +{"username": "alice", "password": "correct horse"} +-- response -- +201 +{"account": {"username": "alice"}} + +-- request -- +POST /api/rooms + +{"name": "临时", "description": ""} +-- response -- +201 +Location: /api/rooms/$room +{"id": "$room"} + +-- request -- +POST /api/rooms/$room/tickets +Authorization: Bearer $alice + +{} +-- response -- +201 +{"ticket": "$ticket"} + +-- stream alice -- +Authorization: Bearer $ticket +-- event alice -- +event: snapshot +data: {"room": {"term": 1, "seq": 0, "room": {"id": "$room", "name": "临时", "description": "", "kind": "ephemeral"}, + "playMode": "sequential", "nowPlaying": null, "pending": null, "items": [], + "members": {"sessions": [], "onlineCount": 0}}} + +-- request -- +POST /api/room/queue +Authorization: Bearer $ticket + +{"track": {"source": "netease", "id": "186016"}} +-- response -- +201 +Location: /api/room/queue/1 +{"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"}} +-- event alice -- +event: room.activity +id: 1 +data: {"kind": "event", "type": "room.activity", "term": 1, "seq": 1, "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": "点了《晴天》"}} +-- event alice -- +event: queue.updated +id: 2 +data: {"kind": "event", "type": "queue.updated", "term": 1, "seq": 2, "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"}}} +-- event alice -- +event: queue.updated +id: 3 +data: {"kind": "event", "type": "queue.updated", "term": 1, "seq": 3, "data": {"pending": null, "items": []}} +-- event alice -- +event: playback.updated +id: 4 +data: {"kind": "event", "type": "playback.updated", "term": 1, "seq": 4, "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 -- +POST /api/me/playlists/imports +Authorization: Bearer $alice + +{"name": " 文件里的歌单 ", "tracks": [ + {"track": {"source": "netease", "id": "186016"}, "title": "改过的标题", "artist": "改过的歌手", "durationMs": 1000, "coverUrl": "https://evil.example/c.jpg"}, + {"track": {"source": "netease", "id": "999"}, "title": "文件里的歌", "artist": "歌手", "album": "专辑", "durationMs": 61000, "coverUrl": "http://p1.music.126.net/a.jpg", "pageUrl": "https://evil.example/song"}, + {"track": {"source": "link", "id": "https://example.com/song.mp3"}, "title": "链接曲目", "durationMs": 5000, "coverUrl": "https://p1.music.126.net/b.jpg"}, + {"track": {"source": "netease", "id": "186016"}, "title": "重复", "durationMs": 1}, + {"track": {"source": "netease", "id": "1000"}, "title": " ", "durationMs": 1} +]} +-- response -- +201 +{"playlist": {"id": "$imported", "name": "文件里的歌单", "favorites": false, "trackCount": 3, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/example.jpg"}, "imported": 3} + +-- request -- +GET /api/me/playlists/$imported/items +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}}, + {"track": {"source": "netease", "id": "999"}, "meta": {"title": "文件里的歌", "artist": "歌手", "album": "专辑", "coverUrl": "", "pageUrl": "https://music.163.com/#/song?id=999", "durationMs": 61000}}, + {"track": {"source": "link", "id": "https://example.com/song.mp3"}, "meta": {"title": "链接曲目", "artist": "", "album": "", "coverUrl": "", "pageUrl": "https://example.com/song.mp3", "durationMs": 5000}} +]} + +-- request -- +POST /api/me/playlists/imports +Authorization: Bearer $alice + +{"name": " ", "tracks": []} +-- response -- +201 +{"playlist": {"id": "$empty", "name": "导入的歌单", "favorites": false, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null}, "imported": 0} + +-- request -- +POST /api/me/playlist-links +Authorization: Bearer $alice + +{"link": "https://music.163.com/#/playlist?id=3778678"} +-- response -- +200 +{"source": "netease", "playlistId": "3778678"} + +-- request -- +POST /api/me/playlist-links +Authorization: Bearer $alice + +{"link": "https://example.com/playlist?id=1"} +-- response -- +400 +{"error": {"code": "INVALID_ARGUMENT", "message": "不是网易云或 QQ 音乐的歌单链接"}} + +-- request -- +POST /api/me/playlists/platform-imports +Authorization: Bearer $alice + +{"name": "周杰伦精选", "source": "netease", "playlistId": "3778678"} +-- response -- +201 +{"playlist": {"id": "$platform", "name": "周杰伦精选", "favorites": false, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/example.jpg"}, "imported": 2} + +-- request -- +POST /api/me/playlists/platform-imports +Authorization: Bearer $alice + +{"name": "QQ", "source": "qq", "playlistId": "1"} +-- response -- +400 +{"error": {"code": "INVALID_ARGUMENT", "message": "暂时只能导入网易云的歌单"}} diff --git a/protocol/decode.gen.go b/protocol/decode.gen.go index 6af1d22..74c631b 100644 --- a/protocol/decode.gen.go +++ b/protocol/decode.gen.go @@ -114,6 +114,18 @@ func DecodeListMyTrackPlaylistsRequest(body []byte) (ListMyTrackPlaylistsRequest return decodeListMyTrackPlaylistsRequest(body, "") } +func DecodeImportMyPlaylistRequest(body []byte) (ImportMyPlaylistRequest, error) { + return decodeImportMyPlaylistRequest(body, "") +} + +func DecodeResolveMyPlaylistLinkRequest(body []byte) (ResolveMyPlaylistLinkRequest, error) { + return decodeResolveMyPlaylistLinkRequest(body, "") +} + +func DecodeImportMyPlatformPlaylistRequest(body []byte) (ImportMyPlatformPlaylistRequest, error) { + return decodeImportMyPlatformPlaylistRequest(body, "") +} + func DecodeCreateRoomRequest(body []byte) (CreateRoomRequest, error) { return decodeCreateRoomRequest(body, "") } @@ -578,6 +590,127 @@ func decodeHelloRequest(raw json.RawMessage, path string) (HelloRequest, error) return v, nil } +func decodeImportMyPlatformPlaylistRequest(raw json.RawMessage, path string) (ImportMyPlatformPlaylistRequest, error) { + var v ImportMyPlatformPlaylistRequest + m, err := decodeObject(raw, path, "name", "playlistId", "source") + if err != nil { + return v, err + } + if err = requireFields(m, path, "name", "playlistId", "source"); 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 + } + if v.PlaylistID, err = decodeString(m["playlistId"], field(path, "playlistId")); err != nil { + return v, err + } + if err = checkMinLength(v.PlaylistID, field(path, "playlistId"), 1); err != nil { + return v, err + } + if err = checkMaxLength(v.PlaylistID, field(path, "playlistId"), 100); err != nil { + return v, err + } + if v.Source, err = decodeSource(m["source"], field(path, "source")); err != nil { + return v, err + } + return v, nil +} + +func decodeImportMyPlaylistRequest(raw json.RawMessage, path string) (ImportMyPlaylistRequest, error) { + var v ImportMyPlaylistRequest + m, err := decodeObject(raw, path, "name", "tracks") + if err != nil { + return v, err + } + if err = requireFields(m, path, "name", "tracks"); 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"), 500); err != nil { + return v, err + } + if v.Tracks, err = decodeArray(decodeImportedTrack, 1000)(m["tracks"], field(path, "tracks")); err != nil { + return v, err + } + return v, nil +} + +func decodeImportedTrack(raw json.RawMessage, path string) (ImportedTrack, error) { + var v ImportedTrack + m, err := decodeObject(raw, path, "album", "artist", "coverUrl", "durationMs", "pageUrl", "title", "track") + if err != nil { + return v, err + } + if err = requireFields(m, path, "durationMs", "title", "track"); err != nil { + return v, err + } + if r, ok := m["album"]; ok { + x, err := decodeString(r, field(path, "album")) + if err != nil { + return v, err + } + if err = checkMaxLength(x, field(path, "album"), 500); err != nil { + return v, err + } + v.Album = &x + } + if r, ok := m["artist"]; ok { + x, err := decodeString(r, field(path, "artist")) + if err != nil { + return v, err + } + if err = checkMaxLength(x, field(path, "artist"), 500); err != nil { + return v, err + } + v.Artist = &x + } + if r, ok := m["coverUrl"]; ok { + x, err := decodeString(r, field(path, "coverUrl")) + if err != nil { + return v, err + } + if err = checkMaxLength(x, field(path, "coverUrl"), 2048); err != nil { + return v, err + } + v.CoverURL = &x + } + if v.DurationMs, err = decodeInteger(m["durationMs"], field(path, "durationMs")); err != nil { + return v, err + } + if err = checkMinimum(v.DurationMs, field(path, "durationMs"), 0); err != nil { + return v, err + } + if err = checkMaximum(v.DurationMs, field(path, "durationMs"), 86400000); err != nil { + return v, err + } + if r, ok := m["pageUrl"]; ok { + x, err := decodeString(r, field(path, "pageUrl")) + if err != nil { + return v, err + } + if err = checkMaxLength(x, field(path, "pageUrl"), 2048); err != nil { + return v, err + } + v.PageURL = &x + } + if v.Title, err = decodeString(m["title"], field(path, "title")); err != nil { + return v, err + } + if err = checkMaxLength(v.Title, field(path, "title"), 500); err != nil { + return v, err + } + if v.Track, err = decodeTrack(m["track"], field(path, "track")); err != nil { + return v, err + } + return v, nil +} + func decodeListMyTrackPlaylistsRequest(raw json.RawMessage, path string) (ListMyTrackPlaylistsRequest, error) { var v ListMyTrackPlaylistsRequest m, err := decodeObject(raw, path, "track") @@ -859,6 +992,27 @@ func decodeRenameMyPlaylistRequest(raw json.RawMessage, path string) (RenameMyPl return v, nil } +func decodeResolveMyPlaylistLinkRequest(raw json.RawMessage, path string) (ResolveMyPlaylistLinkRequest, error) { + var v ResolveMyPlaylistLinkRequest + m, err := decodeObject(raw, path, "link") + if err != nil { + return v, err + } + if err = requireFields(m, path, "link"); err != nil { + return v, err + } + if v.Link, err = decodeString(m["link"], field(path, "link")); err != nil { + return v, err + } + if err = checkMinLength(v.Link, field(path, "link"), 1); err != nil { + return v, err + } + if err = checkMaxLength(v.Link, field(path, "link"), 2048); 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 0b520a8..72726c2 100644 --- a/protocol/idl.json +++ b/protocol/idl.json @@ -194,6 +194,20 @@ }, "required": ["track", "meta"] }, + "ImportedTrack": { + "type": "object", + "additionalProperties": false, + "properties": { + "track": {"$ref": "#/$defs/Track"}, + "title": {"type": "string", "maxLength": 500}, + "artist": {"type": "string", "maxLength": 500}, + "album": {"type": "string", "maxLength": 500}, + "durationMs": {"type": "integer", "minimum": 0, "maximum": 86400000}, + "coverUrl": {"type": "string", "maxLength": 2048}, + "pageUrl": {"type": "string", "maxLength": 2048} + }, + "required": ["track", "title", "durationMs"] + }, "Members": { "type": "object", "additionalProperties": false, @@ -1002,6 +1016,83 @@ "required": ["playlistIds"] } }, + { + "name": "importMyPlaylist", + "description": "把导出的歌单文件里的歌导入成一个新歌单,歌单名不合法时用“导入的歌单”;只收前 1000 首,重复的只留第一次出现的位置;曲目表里已有的歌用服务端的元数据,没有的用文件里的,标题为空的跳过;新歌单受“最多 50 个”的限制;返回实际导入的数量", + "method": "POST", + "path": "/api/me/playlists/imports", + "auth": "member", + "status": 201, + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": {"type": "string", "maxLength": 500}, + "tracks": {"type": "array", "items": {"$ref": "#/$defs/ImportedTrack"}, "maxItems": 1000} + }, + "required": ["name", "tracks"] + }, + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "playlist": {"$ref": "#/$defs/MyPlaylist"}, + "imported": {"type": "integer"} + }, + "required": ["playlist", "imported"] + } + }, + { + "name": "resolveMyPlaylistLink", + "description": "识别平台歌单的链接,短链接会跟随跳转;目前只支持网易云", + "method": "POST", + "path": "/api/me/playlist-links", + "auth": "member", + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "link": {"type": "string", "minLength": 1, "maxLength": 2048} + }, + "required": ["link"] + }, + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "source": {"$ref": "#/$defs/Source"}, + "playlistId": {"type": "string"} + }, + "required": ["source", "playlistId"] + } + }, + { + "name": "importMyPlatformPlaylist", + "description": "把平台歌单导入成一个新歌单,规则同 importMyPlaylist,元数据取自平台;目前只支持网易云", + "method": "POST", + "path": "/api/me/playlists/platform-imports", + "auth": "member", + "status": 201, + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": {"$ref": "#/$defs/PlaylistName"}, + "source": {"$ref": "#/$defs/Source"}, + "playlistId": {"type": "string", "minLength": 1, "maxLength": 100} + }, + "required": ["name", "source", "playlistId"] + }, + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "playlist": {"$ref": "#/$defs/MyPlaylist"}, + "imported": {"type": "integer"} + }, + "required": ["playlist", "imported"] + } + }, { "name": "listRooms", "description": "房间列表", diff --git a/protocol/openapi.gen.json b/protocol/openapi.gen.json index 04e596c..66975c7 100644 --- a/protocol/openapi.gen.json +++ b/protocol/openapi.gen.json @@ -713,6 +713,122 @@ ], "type": "object" }, + "ImportMyPlatformPlaylistRequest": { + "additionalProperties": false, + "properties": { + "name": { + "description": "歌单名:去掉首尾空白后 1 到 50 个字符", + "maxLength": 200, + "type": "string" + }, + "playlistId": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/Source" + } + }, + "required": [ + "name", + "source", + "playlistId" + ], + "type": "object" + }, + "ImportMyPlatformPlaylistResponse": { + "additionalProperties": false, + "properties": { + "imported": { + "type": "integer" + }, + "playlist": { + "$ref": "#/components/schemas/MyPlaylist" + } + }, + "required": [ + "playlist", + "imported" + ], + "type": "object" + }, + "ImportMyPlaylistRequest": { + "additionalProperties": false, + "properties": { + "name": { + "maxLength": 500, + "type": "string" + }, + "tracks": { + "items": { + "$ref": "#/components/schemas/ImportedTrack" + }, + "maxItems": 1000, + "type": "array" + } + }, + "required": [ + "name", + "tracks" + ], + "type": "object" + }, + "ImportMyPlaylistResponse": { + "additionalProperties": false, + "properties": { + "imported": { + "type": "integer" + }, + "playlist": { + "$ref": "#/components/schemas/MyPlaylist" + } + }, + "required": [ + "playlist", + "imported" + ], + "type": "object" + }, + "ImportedTrack": { + "additionalProperties": false, + "properties": { + "album": { + "maxLength": 500, + "type": "string" + }, + "artist": { + "maxLength": 500, + "type": "string" + }, + "coverUrl": { + "maxLength": 2048, + "type": "string" + }, + "durationMs": { + "maximum": 86400000, + "minimum": 0, + "type": "integer" + }, + "pageUrl": { + "maxLength": 2048, + "type": "string" + }, + "title": { + "maxLength": 500, + "type": "string" + }, + "track": { + "$ref": "#/components/schemas/Track" + } + }, + "required": [ + "track", + "title", + "durationMs" + ], + "type": "object" + }, "ListMyPlaylistItemsResponse": { "additionalProperties": false, "properties": { @@ -1290,6 +1406,36 @@ ], "type": "object" }, + "ResolveMyPlaylistLinkRequest": { + "additionalProperties": false, + "properties": { + "link": { + "maxLength": 2048, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "link" + ], + "type": "object" + }, + "ResolveMyPlaylistLinkResponse": { + "additionalProperties": false, + "properties": { + "playlistId": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/Source" + } + }, + "required": [ + "source", + "playlistId" + ], + "type": "object" + }, "RoomInfo": { "additionalProperties": false, "properties": { @@ -1741,7 +1887,7 @@ }, "info": { "title": "Alisten", - "version": "5b9cd2cb39780e46" + "version": "42bbfea8c8da3f68" }, "openapi": "3.1.0", "paths": { @@ -2878,6 +3024,49 @@ "summary": "修改昵称和头像;email 用来计算头像哈希,已注册的账号同时保存它,去掉首尾空白后应形如 name@example.com,空字符串表示清除" } }, + "/api/me/playlist-links": { + "post": { + "operationId": "resolveMyPlaylistLink", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResolveMyPlaylistLinkRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResolveMyPlaylistLinkResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "memberToken": [] + } + ], + "summary": "识别平台歌单的链接,短链接会跟随跳转;目前只支持网易云" + } + }, "/api/me/playlist-memberships": { "post": { "operationId": "listMyTrackPlaylists", @@ -2995,6 +3184,92 @@ "summary": "新建歌单:最多 50 个(含“我喜欢”),可以重名" } }, + "/api/me/playlists/imports": { + "post": { + "operationId": "importMyPlaylist", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportMyPlaylistRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportMyPlaylistResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "memberToken": [] + } + ], + "summary": "把导出的歌单文件里的歌导入成一个新歌单,歌单名不合法时用“导入的歌单”;只收前 1000 首,重复的只留第一次出现的位置;曲目表里已有的歌用服务端的元数据,没有的用文件里的,标题为空的跳过;新歌单受“最多 50 个”的限制;返回实际导入的数量" + } + }, + "/api/me/playlists/platform-imports": { + "post": { + "operationId": "importMyPlatformPlaylist", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportMyPlatformPlaylistRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportMyPlatformPlaylistResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "memberToken": [] + } + ], + "summary": "把平台歌单导入成一个新歌单,规则同 importMyPlaylist,元数据取自平台;目前只支持网易云" + } + }, "/api/me/playlists/{id}": { "delete": { "operationId": "deleteMyPlaylist", diff --git a/protocol/types.gen.go b/protocol/types.gen.go index 95327f3..e48fbfb 100644 --- a/protocol/types.gen.go +++ b/protocol/types.gen.go @@ -4,7 +4,7 @@ package protocol import "encoding/json" -const Version = "5b9cd2cb39780e46" +const Version = "42bbfea8c8da3f68" type APIKey struct { Key string `json:"key"` @@ -274,6 +274,45 @@ type HelloResponse struct { ServerTimeMs int64 `json:"serverTimeMs"` } +type ImportMyPlatformPlaylistRequest struct { + Name string `json:"name"` + PlaylistID string `json:"playlistId"` + Source Source `json:"source"` +} + +type ImportMyPlatformPlaylistResponse struct { + Imported int64 `json:"imported"` + Playlist MyPlaylist `json:"playlist"` +} + +type ImportMyPlaylistRequest struct { + Name string `json:"name"` + Tracks []ImportedTrack `json:"tracks"` +} + +func (v ImportMyPlaylistRequest) MarshalJSON() ([]byte, error) { + type plain ImportMyPlaylistRequest + if v.Tracks == nil { + v.Tracks = []ImportedTrack{} + } + return json.Marshal(plain(v)) +} + +type ImportMyPlaylistResponse struct { + Imported int64 `json:"imported"` + Playlist MyPlaylist `json:"playlist"` +} + +type ImportedTrack struct { + Album *string `json:"album,omitempty"` + Artist *string `json:"artist,omitempty"` + CoverURL *string `json:"coverUrl,omitempty"` + DurationMs int64 `json:"durationMs"` + PageURL *string `json:"pageUrl,omitempty"` + Title string `json:"title"` + Track Track `json:"track"` +} + type ListMyPlaylistItemsResponse struct { Items []MyPlaylistItem `json:"items"` } @@ -556,6 +595,15 @@ type RenameMyPlaylistRequest struct { Name string `json:"name"` } +type ResolveMyPlaylistLinkRequest struct { + Link string `json:"link"` +} + +type ResolveMyPlaylistLinkResponse struct { + PlaylistID string `json:"playlistId"` + Source Source `json:"source"` +} + type RoomActivityEvent struct { Entry *Entry `json:"entry,omitempty"` Kind ActivityKind `json:"kind"` @@ -782,6 +830,9 @@ var ( 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} + RouteImportMyPlaylist = Route{Pattern: "POST /api/me/playlists/imports", Auth: AuthMember, Status: 201} + RouteResolveMyPlaylistLink = Route{Pattern: "POST /api/me/playlist-links", Auth: AuthMember, Status: 200} + RouteImportMyPlatformPlaylist = Route{Pattern: "POST /api/me/playlists/platform-imports", Auth: AuthMember, Status: 201} 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/nginx/templates/alisten.d/proxy.conf.template b/web/nginx/templates/alisten.d/proxy.conf.template index 978234c..73c3188 100644 --- a/web/nginx/templates/alisten.d/proxy.conf.template +++ b/web/nginx/templates/alisten.d/proxy.conf.template @@ -19,6 +19,14 @@ location ^~ /api/ { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://$serve; } + + # 取值必须不小于 serve 对导入文件接口的请求体上限 maxImportBodyBytes(1 MiB),原因同上。 + location = /api/me/playlists/imports { + client_max_body_size 1m; + client_body_buffer_size 1m; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://$serve; + } } location ^~ /media/ { proxy_pass http://$serve; diff --git a/web/src/core/api/http.gen.ts b/web/src/core/api/http.gen.ts index fc20946..9a26828 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, Member, MyPlaylist, MyPlaylistItem, QQLoginType, QRLogin, QRLoginResult, RoomListing, SourceAccount, StoredMedia, Track } from '../realtime/protocol.gen' +import type { APIKey, Account, AdminAccount, ErrorInfo, ImportedTrack, Member, MyPlaylist, MyPlaylistItem, QQLoginType, QRLogin, QRLoginResult, RoomListing, Source, SourceAccount, StoredMedia, Track } from '../realtime/protocol.gen' export interface AddMyPlaylistItemRequest { track: Track @@ -157,6 +157,27 @@ export interface GetSiteResponse { registrationOpen: boolean } +export interface ImportMyPlatformPlaylistRequest { + name: string + playlistId: string + source: Source +} + +export interface ImportMyPlatformPlaylistResponse { + imported: number + playlist: MyPlaylist +} + +export interface ImportMyPlaylistRequest { + name: string + tracks: ImportedTrack[] +} + +export interface ImportMyPlaylistResponse { + imported: number + playlist: MyPlaylist +} + export interface ListMyPlaylistItemsResponse { items: MyPlaylistItem[] } @@ -210,6 +231,15 @@ export interface RenameMyPlaylistRequest { name: string } +export interface ResolveMyPlaylistLinkRequest { + link: string +} + +export interface ResolveMyPlaylistLinkResponse { + playlistId: string + source: Source +} + export interface UpdateMeRequest { email?: string name?: string @@ -253,6 +283,9 @@ export function httpClient(send: HttpSend) { 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 }), + importMyPlaylist: (body: ImportMyPlaylistRequest) => send({ method: 'POST', path: '/api/me/playlists/imports', auth: 'member', body }), + resolveMyPlaylistLink: (body: ResolveMyPlaylistLinkRequest) => send({ method: 'POST', path: '/api/me/playlist-links', auth: 'member', body }), + importMyPlatformPlaylist: (body: ImportMyPlatformPlaylistRequest) => send({ method: 'POST', path: '/api/me/playlists/platform-imports', 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 9277743..d2801a6 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 = '5b9cd2cb39780e46' +export const PROTOCOL_VERSION = '42bbfea8c8da3f68' export interface APIKey { key: string @@ -61,6 +61,16 @@ export interface HelloResponse { serverTimeMs: number } +export interface ImportedTrack { + album?: string + artist?: string + coverUrl?: string + durationMs: number + pageUrl?: string + title: string + track: Track +} + export interface Member { avatarHash: string id: string