feat: expose playlist imports over HTTP

This commit is contained in:
DiheChen committed 2026-11-10 10:05:38 +08:00
1 parent 2bf241ae3c
commit 8966954489
11 files changed
+866 -6

No files matched your search

+9 -1
View File
@@ -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},
+50
View File
@@ -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
}
+3 -1
View File
@@ -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,
+178
View File
@@ -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": "暂时只能导入网易云的歌单"}}
+154
View File
@@ -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)
+91
View File
@@ -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": "房间列表",
+276 -1
View File
@@ -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",
+52 -1
View File
@@ -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}
@@ -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;
+34 -1
View File
@@ -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<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 }),
importMyPlaylist: (body: ImportMyPlaylistRequest) => send<ImportMyPlaylistResponse>({ method: 'POST', path: '/api/me/playlists/imports', auth: 'member', body }),
resolveMyPlaylistLink: (body: ResolveMyPlaylistLinkRequest) => send<ResolveMyPlaylistLinkResponse>({ method: 'POST', path: '/api/me/playlist-links', auth: 'member', body }),
importMyPlatformPlaylist: (body: ImportMyPlatformPlaylistRequest) => send<ImportMyPlatformPlaylistResponse>({ method: 'POST', path: '/api/me/playlists/platform-imports', 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 }),
+11 -1
View File
@@ -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