Compare commits

..
24 Commits
Author SHA1 Message Date
DiheChen 0f84f11fd2 feat: import playlists from files, links and search results
CI / go (push) Canceled after 0s
CI / web (push) Canceled after 0s
CI / images (Dockerfile, server, alisten) (push) Canceled after 0s
CI / images (web/Dockerfile, web, ) (push) Canceled after 0s
CI / images (worker.Dockerfile, worker, alisten-worker) (push) Canceled after 0s
CI / helm-release (push) Canceled after 0s
CI / deploy (push) Canceled after 0s
2026-11-10 10:33:17 +08:00
DiheChen 93a91aad81 feat: export playlists to files and import them in the web client 2026-11-10 10:15:11 +08:00
DiheChen 8966954489 feat: expose playlist imports over HTTP 2026-11-10 10:05:38 +08:00
DiheChen 2bf241ae3c feat: import playlists from files and from NetEase links 2026-11-10 09:53:38 +08:00
DiheChen ed49e3520f feat: look up prepared tracks in bulk and create a playlist with its songs at once 2026-11-10 09:50:31 +08:00
DiheChen 5cc46400e3 feat: recognize NetEase and QQ Music playlist links and resolve their short links 2026-11-10 09:18:05 +08:00
DiheChen a15cc0cff9 feat: select several playlist songs to remove, save or reorder them and drag songs into place 2026-11-09 21:33:06 +08:00
DiheChen 3a7a3470ad feat: browse my playlists as a sortable list or a glowing cover grid and open each one 2026-11-09 21:32:21 +08:00
DiheChen 5be0f2b713 feat: save songs to my playlists from the player, queue and history 2026-11-09 21:31:18 +08:00
DiheChen 6cbf0e15c0 feat: track member playlists in the web client 2026-11-09 21:30:19 +08:00
DiheChen cd84727b6b feat: tell which of my playlists contain a song 2026-11-09 21:29:43 +08:00
DiheChen b26019f459 feat: remove, copy and reorder several playlist songs at once 2026-11-09 21:28:12 +08:00
DiheChen ded566bf25 feat: expose member playlists over HTTP 2026-11-09 21:27:41 +08:00
DiheChen 629eb30d3b feat: manage member playlists with server-side track metadata 2026-11-09 21:26:41 +08:00
DiheChen 2083399d7c feat: store playlists with a fixed favorites list for every account 2026-11-09 21:25:18 +08:00
DiheChen 848b66b6b2 feat: bind room tickets to member sessions and disconnect revoked sessions 2026-11-09 21:24:32 +08:00
DiheChen a85d7dac83 feat: register and sign in from the settings page 2026-11-09 21:23:23 +08:00
DiheChen b0b21e5c99 feat: switch registration and reset account passwords in the admin page 2026-11-09 21:22:40 +08:00
DiheChen a5749aeb7b feat: expose account registration, login and admin recovery over HTTP 2026-11-09 21:21:23 +08:00
DiheChen cc46cc5684 feat: register, log in and manage member accounts 2026-11-09 21:20:12 +08:00
DiheChen 6ec6baa903 feat: store whether registration is open 2026-11-09 21:19:37 +08:00
DiheChen 2271f8ecee feat: store accounts and per-device sessions for members 2026-11-09 21:18:26 +08:00
DiheChen 98a1605a83 feat: reject emails that are not shaped like name@example.com when updating a profile 2026-11-09 21:17:42 +08:00
DiheChen 773ec302a7 feat: take the client address from proxies in private networks 2026-11-09 21:16:26 +08:00
70 changed files with 3166 additions and 356 deletions

No files matched your search

-1
View File
@@ -2,7 +2,6 @@
ALISTEN_HOST=127.0.0.1 # 绑定的 ip
ALISTEN_PORT=9876 # 监听的端口
ALISTEN_ALLOWED_ORIGINS="" # 逗号分隔的 Origin 允许列表,留空时仅允许 ALISTEN_PUBLIC_URL
ALISTEN_TRUSTED_PROXIES="" # 逗号分隔的 IP 地址或 CIDR,来自这些地址的反向代理传来的 X-Forwarded-For 才被采信;留空时,compose.prod.yaml 信任 proxy 网络 172.31.250.0/24,其他情况按连接的对端地址限流
ALISTEN_LOG_LEVEL=info # 日志等级, 可选值为 debug、info、warn、error
ALISTEN_WORKER_TMP_SIZE="1g" # Worker 临时文件大小
ALISTEN_ADMIN_TOKEN="" # 进入管理后台需要的凭证, 留空则禁用后台
-12
View File
@@ -39,18 +39,6 @@ vim .env
# 然后使用原来的部署命令重新启动
```
`proxy` 网络已经存在时,docker compose 不会按 `compose.prod.yaml` 修改它的网段。如果下面的命令输出的不是 `172.31.250.0/24`(比如网络由没有固定网段的旧版创建),serve 就不会信任 traefik 转发的客户端地址,所有客户端会被算进同一个限流额度。这时先停止服务、删除这个网络,再用原来的部署命令启动:
```bash
docker network inspect proxy --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}'
docker compose \
-f compose.yaml \
-f compose.prod.yaml \
down
# down 已经删掉这个网络时,下面的命令会提示网络不存在,可以忽略
docker network rm proxy
```
修改源代码后,自行构建和启动:
```bash
-7
View File
@@ -53,7 +53,6 @@ services:
environment:
ALISTEN_PUBLIC_URL: https://${ALISTEN_DOMAIN}
STORAGE_PUBLIC_URL: ${STORAGE_PUBLIC_URL}
ALISTEN_TRUSTED_PROXIES: ${ALISTEN_TRUSTED_PROXIES:-172.31.250.0/24}
networks:
- backend
- db
@@ -116,12 +115,6 @@ services:
networks:
proxy:
name: proxy
# traefik 经这个网络转发请求,serve 默认信任这个网段传来的 X-Forwarded-For。与已有的 docker 网络冲突时改这里,并同步修改 ALISTEN_TRUSTED_PROXIES。
# 网络已经存在时 docker compose 不会按这里改它的网段:proxy 网络不是这个网段时(比如由没有固定网段的旧版创建),
# 先 down 停止服务,再 docker network rm proxy 删除这个网络,然后启动。
ipam:
config:
- subnet: 172.31.250.0/24
backend:
name: alisten-backend
db:
-1
View File
@@ -40,7 +40,6 @@ services:
<<: *alisten-env
ALISTEN_PUBLIC_URL: ${ALISTEN_PUBLIC_URL}
ALISTEN_ALLOWED_ORIGINS: ${ALISTEN_ALLOWED_ORIGINS}
ALISTEN_TRUSTED_PROXIES: ${ALISTEN_TRUSTED_PROXIES:-}
ALISTEN_ADMIN_TOKEN: ${ALISTEN_ADMIN_TOKEN}
STORAGE_PUBLIC_URL: ${STORAGE_PUBLIC_URL}
GOMAXPROCS: "2"
+4
View File
@@ -50,6 +50,10 @@ SELECT count(*) FROM playlist_items WHERE playlist_id = @playlist_id;
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
VALUES (@playlist_id, @source, @track_id, @title, @artist, @album, @cover_url, @cover_key, @duration_ms, @page_url, @position);
-- name: InsertPlaylistItems :copyfrom
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
VALUES (@playlist_id, @source, @track_id, @title, @artist, @album, @cover_url, @cover_key, @duration_ms, @page_url, @position);
-- name: SetPlaylistSeq :exec
UPDATE playlists SET item_seq = @item_seq, updated_at = now() WHERE id = @id;
+6
View File
@@ -12,3 +12,9 @@ ON CONFLICT (source, track_id) DO UPDATE SET
lyric = excluded.lyric, lyric_words = excluded.lyric_words, lyric_trans = excluded.lyric_trans,
media_key = excluded.media_key, media_size = excluded.media_size,
prepared_at = now();
-- name: TrackMetas :many
SELECT t.source, t.track_id, t.title, t.artist, t.album, t.cover_url, t.cover_key, t.page_url, t.duration_ms
FROM unnest(@sources::text[]) WITH ORDINALITY AS s(source, ord)
JOIN unnest(@track_ids::text[]) WITH ORDINALITY AS k(track_id, ord) USING (ord)
JOIN tracks t ON t.source = s.source AND t.track_id = k.track_id;
-2
View File
@@ -63,8 +63,6 @@ spec:
value: {{ printf "https://%s" (include "alisten.domain" .) | quote }}
- name: ALISTEN_ALLOWED_ORIGINS
value: {{ .Values.allowedOrigins | quote }}
- name: ALISTEN_TRUSTED_PROXIES
value: {{ .Values.trustedProxies | quote }}
- name: STORAGE_PUBLIC_URL
value: {{ required "storage.publicUrl 不能为空" .Values.storage.publicUrl | quote }}
{{- include "alisten.databaseEnv" . | nindent 12 }}
-2
View File
@@ -1,7 +1,5 @@
domain: ""
allowedOrigins: ""
# 逗号分隔的 CIDR:只有来自这些地址的反向代理传来的 X-Forwarded-For 才被采信,用于按客户端 IP 限流。
trustedProxies: ""
logLevel: info
otlpEndpoint: ""
database:
+10 -6
View File
@@ -8,7 +8,6 @@ import (
"io"
"log/slog"
"net/http"
"net/netip"
"runtime/debug"
"slices"
"strings"
@@ -37,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 {
@@ -54,9 +55,6 @@ type Services struct {
// 浏览器访问对象存储时使用的地址前缀,结尾没有 /。
StorageURL string
// 只有来自这些地址的反向代理传来的 X-Forwarded-For 才被采信。
TrustedProxies []netip.Prefix
}
type server struct {
@@ -124,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)
@@ -216,7 +217,7 @@ func (s *server) serve(ctx context.Context, w http.ResponseWriter, r *http.Reque
logCtx, err = ctx, s.panicked(ctx, r, value)
}
}()
c := call{r: r, clientIP: clientIP(r, s.TrustedProxies)}
c := call{r: r, clientIP: clientIP(r)}
switch auth {
case protocol.AuthNone:
case protocol.AuthMember:
@@ -330,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},
+1 -1
View File
@@ -158,7 +158,7 @@ func TestRequestBodyLimit(t *testing.T) {
func TestPlaylistBatchBodyLimit(t *testing.T) {
store := porttest.New()
s := services(store, store, "")
s.Playlists = playlist.New(store, store, noTracks{}, s.Catalog, nil)
s.Playlists = playlist.New(store, store, noTracks{}, s.Catalog, nil, nil)
server, _ := start(t, s)
var created protocol.CreateMemberResponse
if err := json.Unmarshal([]byte(send(t, server, http.MethodPost, "/api/members", "{}").body), &created); err != nil {
+1 -1
View File
@@ -41,7 +41,7 @@ func (s *server) answer(ctx context.Context, roomID string, data any) (resp any,
func summaries(songs []port.Song) []protocol.TrackSummary {
items := make([]protocol.TrackSummary, len(songs))
for i, s := range songs {
items[i] = protocol.TrackSummary{Track: protocol.Track{Source: protocol.Source(s.Track.Source), ID: s.Track.ID}, Meta: meta(s.Meta)}
items[i] = protocol.TrackSummary{Track: trackDTO(s.Track), Meta: meta(s.Meta)}
}
return items
}
+4
View File
@@ -17,6 +17,10 @@ func (noTracks) PreparedTrack(context.Context, track.Track) (port.PreparedTrack,
return port.PreparedTrack{}, false, nil
}
func (noTracks) TrackMetas(context.Context, []track.Track) (map[track.Track]track.Meta, error) {
return nil, nil
}
func (noTracks) SavePreparedTrack(context.Context, track.Track, port.PreparedTrack) error {
return nil
}
+6 -7
View File
@@ -3,20 +3,19 @@ package api
import (
"net/http"
"net/netip"
"slices"
"strings"
)
// 只有对端是受信任的代理时才读 X-Forwarded-For,因为其他来源可以随意填写它。从最右端往左跳过受信任的代理,
// 只有对端是回环、私有网段或链路本地地址的代理时才读 X-Forwarded-For,因为其他来源可以随意填写它。从最右端往左跳过受信任的代理,
// 第一个不受信任的地址就是客户端;全部受信任时取最左端的地址。受信任的代理只会写入合法的地址,
// 遇到不合法的值说明已经到了客户端自己填写的部分,此时改用对端地址。
func clientIP(r *http.Request, trusted []netip.Prefix) string {
func clientIP(r *http.Request) string {
peer, err := netip.ParseAddrPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
addr := peer.Addr().Unmap()
if !containsAddr(trusted, addr) {
if !trustedProxy(addr) {
return addr.String()
}
var hops []string
@@ -31,13 +30,13 @@ func clientIP(r *http.Request, trusted []netip.Prefix) string {
break
}
hop = hop.Unmap()
if i == 0 || !containsAddr(trusted, hop) {
if i == 0 || !trustedProxy(hop) {
return hop.String()
}
}
return addr.String()
}
func containsAddr(prefixes []netip.Prefix, addr netip.Addr) bool {
return slices.ContainsFunc(prefixes, func(p netip.Prefix) bool { return p.Contains(addr) })
func trustedProxy(addr netip.Addr) bool {
return addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()
}
+10 -16
View File
@@ -2,27 +2,29 @@ package api
import (
"net/http/httptest"
"net/netip"
"testing"
)
func TestClientIP(t *testing.T) {
trusted := []netip.Prefix{netip.MustParsePrefix("10.42.0.0/16"), netip.MustParsePrefix("127.0.0.1/32")}
cases := []struct {
name string
remote string
forwarded []string
want string
}{
{"对端不受信任时不看 X-Forwarded-For", "203.0.113.7:4321", []string{"198.51.100.1"}, "203.0.113.7"},
{"受信任的代理转发时取客户端地址", "10.42.0.153:4321", []string{"203.0.113.7"}, "203.0.113.7"},
{"跳过右端受信任的代理", "127.0.0.1:4321", []string{"203.0.113.7, 10.42.0.1"}, "203.0.113.7"},
{"公网对端不看 X-Forwarded-For", "203.0.113.7:4321", []string{"198.51.100.1"}, "203.0.113.7"},
{"私有网段的代理转发时取公网客户端地址", "10.42.0.153:4321", []string{"203.0.113.7"}, "203.0.113.7"},
{"回环对端同样可信", "127.0.0.1:4321", []string{"203.0.113.7"}, "203.0.113.7"},
{"IPv6 回环对端同样可信", "[::1]:4321", []string{"203.0.113.7"}, "203.0.113.7"},
{"链路本地对端同样可信", "[fe80::1]:4321", []string{"203.0.113.7"}, "203.0.113.7"},
{"链式代理跳过右端的私有地址", "127.0.0.1:4321", []string{"203.0.113.7, 10.42.0.1, 192.168.1.2"}, "203.0.113.7"},
{"客户端自己填在左边的地址不采用", "10.42.0.153:4321", []string{"198.51.100.1, 203.0.113.7"}, "203.0.113.7"},
{"多个头按顺序拼接", "10.42.0.153:4321", []string{"198.51.100.1", "203.0.113.7"}, "203.0.113.7"},
{"全部受信任时取最左端", "10.42.0.153:4321", []string{"10.42.0.1"}, "10.42.0.1"},
{"全部是私有地址时取最左端", "10.42.0.153:4321", []string{"192.168.1.5, 10.42.0.1"}, "192.168.1.5"},
{"没有 X-Forwarded-For 时取对端", "10.42.0.153:4321", nil, "10.42.0.153"},
{"遇到不合法的地址就停下,取对端", "10.42.0.153:4321", []string{"203.0.113.7, unknown, 10.42.0.1"}, "10.42.0.153"},
{"IPv4 映射的 IPv6 地址按 IPv4 处理", "[::ffff:10.42.0.153]:4321", []string{"::ffff:203.0.113.7"}, "203.0.113.7"},
{"IPv4 映射的 IPv6 对端按 IPv4 处理", "[::ffff:10.42.0.153]:4321", []string{"::ffff:203.0.113.7"}, "203.0.113.7"},
{"IPv4 映射的 IPv6 私有跳按 IPv4 处理", "10.42.0.153:4321", []string{"203.0.113.7, ::ffff:10.42.0.1"}, "203.0.113.7"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -31,17 +33,9 @@ func TestClientIP(t *testing.T) {
for _, v := range tc.forwarded {
r.Header.Add("X-Forwarded-For", v)
}
if got := clientIP(r, trusted); got != tc.want {
if got := clientIP(r); got != tc.want {
t.Errorf("clientIP = %q,期望 %q", got, tc.want)
}
})
}
t.Run("没有配置受信任的代理", func(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
r.RemoteAddr = "10.42.0.153:4321"
r.Header.Set("X-Forwarded-For", "203.0.113.7")
if got := clientIP(r, nil); got != "10.42.0.153" {
t.Errorf("clientIP = %q,期望对端地址", got)
}
})
}
+5 -1
View File
@@ -164,10 +164,14 @@ func (s *server) coverURL(url string, key track.ObjectKey) string {
return url
}
func trackDTO(t track.Track) protocol.Track {
return protocol.Track{Source: protocol.Source(t.Source), ID: t.ID}
}
func (s *server) entry(e room.Entry) protocol.Entry {
d := protocol.Entry{
ID: strconv.FormatUint(uint64(e.ID), 10),
Track: protocol.Track{Source: protocol.Source(e.Track.Source), ID: e.Track.ID},
Track: trackDTO(e.Track),
Meta: s.trackMeta(e.Meta),
Auto: e.Auto,
Likes: int64(e.Likes),
+51 -1
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"
@@ -27,7 +29,7 @@ func (s *server) playlistDTO(p port.PlaylistRecord) protocol.MyPlaylist {
func (s *server) playlistItemDTO(item port.PlaylistItem) protocol.MyPlaylistItem {
return protocol.MyPlaylistItem{
Track: protocol.Track{Source: protocol.Source(item.Track.Source), ID: item.Track.ID},
Track: trackDTO(item.Track),
Meta: s.trackMeta(item.Meta),
}
}
@@ -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
}
@@ -9,6 +9,48 @@ import (
"context"
)
// iteratorForInsertPlaylistItems implements pgx.CopyFromSource.
type iteratorForInsertPlaylistItems struct {
rows []InsertPlaylistItemsParams
skippedFirstNextCall bool
}
func (r *iteratorForInsertPlaylistItems) Next() bool {
if len(r.rows) == 0 {
return false
}
if !r.skippedFirstNextCall {
r.skippedFirstNextCall = true
return true
}
r.rows = r.rows[1:]
return len(r.rows) > 0
}
func (r iteratorForInsertPlaylistItems) Values() ([]interface{}, error) {
return []interface{}{
r.rows[0].PlaylistID,
r.rows[0].Source,
r.rows[0].TrackID,
r.rows[0].Title,
r.rows[0].Artist,
r.rows[0].Album,
r.rows[0].CoverURL,
r.rows[0].CoverKey,
r.rows[0].DurationMs,
r.rows[0].PageURL,
r.rows[0].Position,
}, nil
}
func (r iteratorForInsertPlaylistItems) Err() error {
return nil
}
func (q *Queries) InsertPlaylistItems(ctx context.Context, arg []InsertPlaylistItemsParams) (int64, error) {
return q.db.CopyFrom(ctx, []string{"playlist_items"}, []string{"playlist_id", "source", "track_id", "title", "artist", "album", "cover_url", "cover_key", "duration_ms", "page_url", "position"}, &iteratorForInsertPlaylistItems{rows: arg})
}
// iteratorForInsertPlays implements pgx.CopyFromSource.
type iteratorForInsertPlays struct {
rows []InsertPlaysParams
@@ -182,6 +182,20 @@ func (q *Queries) InsertPlaylistItem(ctx context.Context, arg InsertPlaylistItem
return err
}
type InsertPlaylistItemsParams struct {
PlaylistID string
Source string
TrackID string
Title string
Artist *string
Album *string
CoverURL *string
CoverKey *string
DurationMs int64
PageURL string
Position int64
}
const lockFavorites = `-- name: LockFavorites :exec
SELECT id FROM playlists WHERE owner_id = $1 AND kind = 'favorites' FOR UPDATE
`
@@ -106,3 +106,57 @@ func (q *Queries) SavePreparedTrack(ctx context.Context, arg SavePreparedTrackPa
)
return err
}
const trackMetas = `-- name: TrackMetas :many
SELECT t.source, t.track_id, t.title, t.artist, t.album, t.cover_url, t.cover_key, t.page_url, t.duration_ms
FROM unnest($1::text[]) WITH ORDINALITY AS s(source, ord)
JOIN unnest($2::text[]) WITH ORDINALITY AS k(track_id, ord) USING (ord)
JOIN tracks t ON t.source = s.source AND t.track_id = k.track_id
`
type TrackMetasParams struct {
Sources []string
TrackIds []string
}
type TrackMetasRow struct {
Source string
TrackID string
Title string
Artist *string
Album *string
CoverURL *string
CoverKey *string
PageURL string
DurationMs int64
}
func (q *Queries) TrackMetas(ctx context.Context, arg TrackMetasParams) ([]TrackMetasRow, error) {
rows, err := q.db.Query(ctx, trackMetas, arg.Sources, arg.TrackIds)
if err != nil {
return nil, err
}
defer rows.Close()
var items []TrackMetasRow
for rows.Next() {
var i TrackMetasRow
if err := rows.Scan(
&i.Source,
&i.TrackID,
&i.Title,
&i.Artist,
&i.Album,
&i.CoverURL,
&i.CoverKey,
&i.PageURL,
&i.DurationMs,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+62 -13
View File
@@ -49,19 +49,8 @@ func (s *Store) MemberPlaylists(ctx context.Context, ownerID string) ([]port.Pla
func (s *Store) CreatePlaylist(ctx context.Context, ownerID, name string, limit int) (port.PlaylistRecord, error) {
var created query.CreatePlaylistRow
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
if err := q.LockFavorites(ctx, ownerID); err != nil {
return err
}
n, err := q.CountPlaylists(ctx, ownerID)
if err != nil {
return err
}
if n >= int64(limit) {
return port.ErrPlaylistLimit
}
created, err = q.CreatePlaylist(ctx, query.CreatePlaylistParams{OwnerID: ownerID, Name: name})
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) (err error) {
created, err = createPlaylist(ctx, s.q.WithTx(tx), ownerID, name, limit)
return err
})
if err != nil {
@@ -70,6 +59,66 @@ func (s *Store) CreatePlaylist(ctx context.Context, ownerID, name string, limit
return port.PlaylistRecord{ID: created.ID, Name: name, CreatedAt: created.CreatedAt, UpdatedAt: created.UpdatedAt}, nil
}
func (s *Store) ImportPlaylist(ctx context.Context, ownerID, name string, items []port.PlaylistItem, limit int) (port.PlaylistRecord, error) {
var created query.CreatePlaylistRow
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) (err error) {
q := s.q.WithTx(tx)
if created, err = createPlaylist(ctx, q, ownerID, name, limit); err != nil {
return err
}
seq := int64(len(items))
rows := make([]query.InsertPlaylistItemsParams, len(items))
for i, item := range items {
t, m := item.Track, item.Meta
rows[i] = query.InsertPlaylistItemsParams{
PlaylistID: created.ID,
Source: string(t.Source),
TrackID: t.ID,
Title: m.Title,
Artist: nullable(m.Artist),
Album: nullable(m.Album),
CoverURL: nullable(m.CoverURL),
CoverKey: nullable(string(m.CoverKey)),
DurationMs: m.Duration.Milliseconds(),
PageURL: m.PageURL,
Position: seq - int64(i),
}
}
if _, err = q.InsertPlaylistItems(ctx, rows); err != nil {
return err
}
return q.SetPlaylistSeq(ctx, query.SetPlaylistSeqParams{ID: created.ID, ItemSeq: seq})
})
if err != nil {
return port.PlaylistRecord{}, fmt.Errorf("成员 %s 导入 %d 首歌到新歌单:%w", ownerID, len(items), err)
}
return port.PlaylistRecord{ID: created.ID, Name: name, TrackCount: len(items), CreatedAt: created.CreatedAt, UpdatedAt: created.UpdatedAt, Cover: firstCover(items)}, nil
}
// 要在事务中调用:“我喜欢”的行锁持续到新建之后,同一个账号并发新建时才会依次计数。
func createPlaylist(ctx context.Context, q *query.Queries, ownerID, name string, limit int) (query.CreatePlaylistRow, error) {
if err := q.LockFavorites(ctx, ownerID); err != nil {
return query.CreatePlaylistRow{}, err
}
n, err := q.CountPlaylists(ctx, ownerID)
if err != nil {
return query.CreatePlaylistRow{}, err
}
if n >= int64(limit) {
return query.CreatePlaylistRow{}, port.ErrPlaylistLimit
}
return q.CreatePlaylist(ctx, query.CreatePlaylistParams{OwnerID: ownerID, Name: name})
}
func firstCover(items []port.PlaylistItem) *port.Cover {
for _, item := range items {
if m := item.Meta; m.CoverURL != "" || m.CoverKey != "" {
return &port.Cover{URL: m.CoverURL, Key: m.CoverKey}
}
}
return nil
}
func (s *Store) RenamePlaylist(ctx context.Context, ownerID, id, name string) error {
if !isUUID(id) {
return port.ErrPlaylistNotFound
@@ -171,7 +171,7 @@ func TestPlaylistItems(t *testing.T) {
}
func TestConcurrentPlaylistAddsRespectLimit(t *testing.T) {
store, _ := newStore(t)
store, pool := newStore(t)
alice := registered(t, store, "alice")
favorites := favoritesOf(t, store, alice.ID)
var wg sync.WaitGroup
@@ -185,6 +185,15 @@ func TestConcurrentPlaylistAddsRespectLimit(t *testing.T) {
if items, err := store.PlaylistItems(t.Context(), alice.ID, favorites); err != nil || len(items) != 5 {
t.Errorf("并发加入之后有 %d 首, %v,期望正好 5 首", len(items), err)
}
var distinct, seq int64
var largest int64
if err := pool.QueryRow(t.Context(), `SELECT count(DISTINCT i.position), max(i.position), max(p.item_seq)
FROM playlist_items i JOIN playlists p ON p.id = i.playlist_id WHERE i.playlist_id = $1`, favorites).Scan(&distinct, &largest, &seq); err != nil {
t.Fatal(err)
}
if distinct != 5 || largest > seq {
t.Errorf("排序值有 %d 种、最大 %d,歌单的 item_seq 是 %d,期望 5 首的排序值两两不同且都不大于 item_seq", distinct, largest, seq)
}
}
func TestConcurrentPlaylistCreatesRespectLimit(t *testing.T) {
@@ -601,3 +610,82 @@ func TestMovePlaylistItemsAdvancesUpdatedAt(t *testing.T) {
t.Error("移动之后更新时间没有推进")
}
}
func TestImportPlaylist(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
items := []port.PlaylistItem{songItem("3"), clipItem, songItem("1")}
imported, err := store.ImportPlaylist(t.Context(), alice.ID, "导入的歌单", items, 50)
if err != nil {
t.Fatal(err)
}
if got, err := store.PlaylistItems(t.Context(), alice.ID, imported.ID); err != nil || !slices.Equal(got, items) {
t.Errorf("导入之后的曲目 = %#v, %v,期望与来源同序 %#v", got, err, items)
}
lists, err := store.MemberPlaylists(t.Context(), alice.ID)
if err != nil || len(lists) != 2 {
t.Fatalf("歌单 = %#v, %v,期望“我喜欢”和导入的歌单", lists, err)
}
read := lists[1]
if imported.ID != read.ID || imported.Name != "导入的歌单" || imported.Favorites || imported.TrackCount != 3 ||
!imported.CreatedAt.Equal(read.CreatedAt) || !imported.UpdatedAt.Equal(read.UpdatedAt) {
t.Errorf("导入返回 %#v,期望与读出的 %#v 一致、共 3 首", imported, read)
}
if want := (port.Cover{URL: songItem("3").Meta.CoverURL}); imported.Cover == nil || *imported.Cover != want || read.Cover == nil || *read.Cover != want {
t.Errorf("封面 = %+v / %+v,期望排在最前的 %+v", imported.Cover, read.Cover, want)
}
if err := store.AddPlaylistItem(t.Context(), alice.ID, imported.ID, songItem("9"), 1000); err != nil {
t.Fatal(err)
}
if got := displayIDs(t, store, alice.ID, imported.ID); !slices.Equal(got, []string{"9", "3", clipItem.Track.ID, "1"}) {
t.Errorf("导入之后再加入 = %v,期望新加入的排在最前", got)
}
}
func TestImportEmptyPlaylist(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
imported, err := store.ImportPlaylist(t.Context(), alice.ID, "空的", nil, 50)
if err != nil || imported.Name != "空的" || imported.TrackCount != 0 || imported.Cover != nil {
t.Fatalf("导入空列表 = %#v, %v,期望建出一个没有曲目的歌单", imported, err)
}
if got, err := store.PlaylistItems(t.Context(), alice.ID, imported.ID); err != nil || len(got) != 0 {
t.Errorf("曲目 = %#v, %v,期望为空", got, err)
}
}
func TestImportPlaylistRollsBackAsAWhole(t *testing.T) {
store, pool := newStore(t)
alice := registered(t, store, "alice")
items := []port.PlaylistItem{songItem("1"), songItem("2")}
if _, err := store.ImportPlaylist(t.Context(), alice.ID, "超限", items, 1); !errors.Is(err, port.ErrPlaylistLimit) {
t.Errorf("歌单数到了上限 = %v,期望 ErrPlaylistLimit", err)
}
broken := songItem("3")
broken.Meta.Title = "坏\x00标题"
if _, err := store.ImportPlaylist(t.Context(), alice.ID, "写入失败", append(items, broken), 50); err == nil {
t.Error("有一首写不进去时应报错")
}
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || len(lists) != 1 {
t.Errorf("失败之后的歌单 = %#v, %v,期望只剩“我喜欢”", lists, err)
}
var count int
if err := pool.QueryRow(t.Context(), `SELECT count(*) FROM playlist_items`).Scan(&count); err != nil || count != 0 {
t.Errorf("失败之后的曲目数 = %d, %v,期望 0", count, err)
}
}
func TestConcurrentImportsRespectLimit(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
var wg sync.WaitGroup
for i := range 10 {
wg.Go(func() {
_, _ = store.ImportPlaylist(t.Context(), alice.ID, "导入"+strconv.Itoa(i), []port.PlaylistItem{songItem(strconv.Itoa(i))}, 5)
})
}
wg.Wait()
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || len(lists) != 5 {
t.Errorf("并发导入之后有 %d 个歌单, %v,期望正好 5 个(含“我喜欢”)", len(lists), err)
}
}
+23 -4
View File
@@ -13,7 +13,10 @@ import (
"github.com/dihemoe/alisten/internal/domain/track"
)
var _ port.Tracks = (*Store)(nil)
var (
_ port.Tracks = (*Store)(nil)
_ port.TrackLookup = (*Store)(nil)
)
func (s *Store) PreparedTrack(ctx context.Context, t track.Track) (port.PreparedTrack, bool, error) {
row, err := s.q.PreparedTrack(ctx, query.PreparedTrackParams{Source: string(t.Source), TrackID: t.ID})
@@ -27,6 +30,19 @@ func (s *Store) PreparedTrack(ctx context.Context, t track.Track) (port.Prepared
return port.PreparedTrack{Meta: meta, Result: result, MediaKey: orEmpty(row.MediaKey), MediaSize: orZero(row.MediaSize)}, true, nil
}
func (s *Store) TrackMetas(ctx context.Context, ts []track.Track) (map[track.Track]track.Meta, error) {
sources, ids := splitTracks(ts)
rows, err := s.q.TrackMetas(ctx, query.TrackMetasParams{Sources: sources, TrackIds: ids})
if err != nil {
return nil, fmt.Errorf("读取 %d 首歌的元数据:%w", len(ts), err)
}
found := make(map[track.Track]track.Meta, len(rows))
for _, r := range rows {
found[track.Track{Source: track.Source(r.Source), ID: r.TrackID}] = storedMeta(r.Title, orEmpty(r.Artist), orEmpty(r.Album), orEmpty(r.CoverURL), orEmpty(r.CoverKey), r.PageURL, r.DurationMs)
}
return found, nil
}
func (s *Store) SavePreparedTrack(ctx context.Context, t track.Track, p port.PreparedTrack) error {
params := query.SavePreparedTrackParams{
Source: string(t.Source),
@@ -53,9 +69,12 @@ func (s *Store) SavePreparedTrack(ctx context.Context, t track.Track, p port.Pre
}
func prepared(title, artist, album, coverURL, coverKey, pageURL string, durationMs int64, gain float64, lyric, lyricWords, lyricTrans string) (track.Meta, track.Prepared) {
d := time.Duration(durationMs) * time.Millisecond
return track.Meta{Title: title, Artist: artist, Album: album, CoverURL: coverURL, CoverKey: track.ObjectKey(coverKey), PageURL: pageURL, Duration: d},
track.Prepared{Duration: d, Gain: gain, Lyric: lyric, LyricWords: lyricWords, LyricTrans: lyricTrans}
meta := storedMeta(title, artist, album, coverURL, coverKey, pageURL, durationMs)
return meta, track.Prepared{Duration: meta.Duration, Gain: gain, Lyric: lyric, LyricWords: lyricWords, LyricTrans: lyricTrans}
}
func storedMeta(title, artist, album, coverURL, coverKey, pageURL string, durationMs int64) track.Meta {
return track.Meta{Title: title, Artist: artist, Album: album, CoverURL: coverURL, CoverKey: track.ObjectKey(coverKey), PageURL: pageURL, Duration: time.Duration(durationMs) * time.Millisecond}
}
func orZero(p *int64) int64 {
@@ -1,6 +1,7 @@
package postgres
import (
"maps"
"testing"
"time"
@@ -124,3 +125,42 @@ func TestLinkTrackRoundTrip(t *testing.T) {
t.Errorf("恢复出的条目 = %+v,期望已就绪,元数据取自曲目表", snap.Waiting[0])
}
}
func TestTrackMetas(t *testing.T) {
store, _ := newStore(t)
link := track.Track{Source: track.Link, ID: "https://example.com/a.mp3"}
saved := map[track.Track]port.PreparedTrack{
netease("1"): {
Meta: track.Meta{Title: "晴天", Artist: "周杰伦", Album: "叶惠美", CoverURL: "https://p1.music.126.net/a.jpg", PageURL: "https://music.163.com/#/song?id=1", Duration: time.Minute},
Result: track.Prepared{Duration: 61500 * time.Millisecond, Gain: 0.5, Lyric: "词", LyricWords: "逐字", LyricTrans: "翻译"},
},
link: {
Meta: track.Meta{Title: "a.mp3", CoverKey: "link/abc/cover.webp", PageURL: link.ID, Duration: 3 * time.Second},
Result: track.Prepared{Duration: 3 * time.Second, Gain: 1},
MediaKey: "abc",
MediaSize: 12290,
},
}
want := map[track.Track]track.Meta{}
for tr, p := range saved {
if err := store.SavePreparedTrack(t.Context(), tr, p); err != nil {
t.Fatal(err)
}
read, _, err := store.PreparedTrack(t.Context(), tr)
if err != nil {
t.Fatal(err)
}
want[tr] = read.Meta
}
if want[netease("1")].Duration != 61500*time.Millisecond {
t.Fatalf("PreparedTrack 读出的时长 = %v,期望准备得到的时长", want[netease("1")].Duration)
}
wanted := []track.Track{netease("2"), netease("1"), {Source: track.QQ, ID: "1"}, link, netease("1")}
got, err := store.TrackMetas(t.Context(), wanted)
if err != nil || !maps.Equal(got, want) {
t.Errorf("批量读取 = %#v, %v\n期望只有命中的、与 PreparedTrack 读出的元数据一致 %#v:音源和 ID 都要对上,重复的只算一次", got, err, want)
}
if got, err := store.TrackMetas(t.Context(), nil); err != nil || len(got) != 0 {
t.Errorf("什么也不查 = %#v, %v,期望空的结果", got, err)
}
}
@@ -0,0 +1,87 @@
package shortlink
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"time"
"github.com/dihemoe/alisten/internal/app/port"
)
const (
maxRedirects = 5
timeout = 5 * time.Second
)
type Resolver struct {
allowed func(host string) bool
done func(link string) bool
client *http.Client
timeout time.Duration
}
var _ port.ShortLinks = (*Resolver)(nil)
// allowed 检查每一跳(包括起点)的主机。跳转到 done 认得的地址时就停下,不再请求它:
// 网易云的短链接最后一跳是很慢的歌单页面,逐跳请求到底会超过总超时。
func New(allowed func(host string) bool, done func(link string) bool) *Resolver {
return &Resolver{
allowed: allowed,
done: done,
// 跳转由 Resolve 逐跳处理,这样每一跳的主机都能先检查再发请求。
client: &http.Client{
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
},
timeout: timeout,
}
}
func (r *Resolver) Resolve(ctx context.Context, link string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, r.timeout)
defer cancel()
current := link
for hop := 0; ; hop++ {
u, err := url.Parse(current)
if err != nil {
return "", fmt.Errorf("解析链接: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", fmt.Errorf("不支持的协议 %q", u.Scheme)
}
if !r.allowed(u.Hostname()) {
return "", fmt.Errorf("跳转到不受信任的主机 %q", u.Hostname())
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, current, nil)
if err != nil {
return "", err
}
resp, err := r.client.Do(req)
if err != nil {
return "", fmt.Errorf("请求短链接: %w", err)
}
_ = resp.Body.Close()
switch resp.StatusCode {
case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther,
http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
default:
return current, nil
}
if hop == maxRedirects {
return "", errors.New("跳转次数过多")
}
next, err := resp.Location()
if err != nil {
return "", fmt.Errorf("读取跳转地址: %w", err)
}
current = next.String()
if r.done(current) {
return current, nil
}
}
}
@@ -0,0 +1,124 @@
package shortlink
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func allowLoopback(host string) bool { return host == "127.0.0.1" }
func never(string) bool { return false }
func TestResolve(t *testing.T) {
var methods []string
mux := http.NewServeMux()
mux.HandleFunc("/short", func(w http.ResponseWriter, r *http.Request) {
methods = append(methods, r.Method)
http.Redirect(w, r, "/mid", http.StatusFound)
})
mux.HandleFunc("/mid", func(w http.ResponseWriter, r *http.Request) {
methods = append(methods, r.Method)
http.Redirect(w, r, "/playlist?id=1", http.StatusMovedPermanently)
})
mux.HandleFunc("/playlist", func(w http.ResponseWriter, r *http.Request) {
methods = append(methods, r.Method)
_, _ = w.Write([]byte("body"))
})
mux.HandleFunc("/l", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, r.URL.Path+"x", http.StatusFound)
})
mux.HandleFunc("/lx", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/l", http.StatusFound)
})
mux.HandleFunc("/five", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/f2", http.StatusFound) })
mux.HandleFunc("/f2", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/f3", http.StatusFound) })
mux.HandleFunc("/f3", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/f4", http.StatusFound) })
mux.HandleFunc("/f4", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/f5", http.StatusFound) })
mux.HandleFunc("/f5", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/playlist?id=2", http.StatusFound) })
mux.HandleFunc("/evil", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "http://localhost:1/x", http.StatusFound)
})
mux.HandleFunc("/slow", func(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(2 * time.Second):
case <-r.Context().Done():
}
})
srv := httptest.NewServer(mux)
defer srv.Close()
r := New(allowLoopback, never)
ctx := context.Background()
t.Run("正常跳转且只发 GET", func(t *testing.T) {
methods = nil
got, err := r.Resolve(ctx, srv.URL+"/short")
if err != nil {
t.Fatal(err)
}
if want := srv.URL + "/playlist?id=1"; got != want {
t.Fatalf("got %q; want %q", got, want)
}
if strings.Join(methods, ",") != "GET,GET,GET" {
t.Fatalf("methods = %v", methods)
}
})
t.Run("跳到认得的地址时不再请求它", func(t *testing.T) {
methods = nil
recognized := New(allowLoopback, func(link string) bool { return strings.Contains(link, "/playlist?") })
got, err := recognized.Resolve(ctx, srv.URL+"/short")
if err != nil {
t.Fatal(err)
}
if want := srv.URL + "/playlist?id=1"; got != want {
t.Fatalf("got %q; want %q", got, want)
}
if strings.Join(methods, ",") != "GET,GET" {
t.Fatalf("methods = %v,期望不请求 /playlist", methods)
}
})
t.Run("五次跳转可以", func(t *testing.T) {
got, err := r.Resolve(ctx, srv.URL+"/five")
if err != nil {
t.Fatal(err)
}
if want := srv.URL + "/playlist?id=2"; got != want {
t.Fatalf("got %q; want %q", got, want)
}
})
t.Run("超过五次", func(t *testing.T) {
if _, err := r.Resolve(ctx, srv.URL+"/l"); err == nil {
t.Fatal("want error")
}
})
t.Run("跳到不允许的主机", func(t *testing.T) {
if _, err := r.Resolve(ctx, srv.URL+"/evil"); err == nil {
t.Fatal("want error")
}
})
t.Run("起点不允许", func(t *testing.T) {
if _, err := New(func(string) bool { return false }, never).Resolve(ctx, srv.URL+"/playlist"); err == nil {
t.Fatal("want error")
}
})
t.Run("非 http 协议", func(t *testing.T) {
if _, err := r.Resolve(ctx, "file:///etc/passwd"); err == nil {
t.Fatal("want error")
}
})
t.Run("超时", func(t *testing.T) {
short := New(allowLoopback, never)
short.timeout = 100 * time.Millisecond
start := time.Now()
if _, err := short.Resolve(ctx, srv.URL+"/slow"); err == nil {
t.Fatal("want error")
}
if time.Since(start) > time.Second {
t.Fatal("没有按期限中止")
}
})
}
+8
View File
@@ -84,6 +84,14 @@ func (s *Service) PlaylistTracks(ctx context.Context, roomID string, source trac
if err := s.take(ctx, roomID); err != nil {
return nil, err
}
return s.PlaylistSongs(ctx, source, playlistID)
}
// 不取房间的搜索额度:导入歌单按成员另外限流。
func (s *Service) PlaylistSongs(ctx context.Context, source track.Source, playlistID string) ([]port.Song, error) {
if source != track.NetEase {
return nil, unsupported(source, "歌单内的歌曲")
}
songs, err := s.netease.PlaylistTracks(ctx, playlistID)
if err != nil {
return nil, upstream(source, "获取歌单内的歌曲", err)
+20
View File
@@ -244,6 +244,26 @@ func TestFirstMatch(t *testing.T) {
}
}
func TestPlaylistSongsSkipsRoomQuota(t *testing.T) {
f := newFixture(t, history{})
songs := []port.Song{song(track.NetEase, "1", "晴天"), song(track.NetEase, "2", "七里香")}
f.add(f.netease, songs...)
f.netease.AddPlaylist(port.Playlist{ID: "p1", Name: "周杰伦"}, "2", "1")
if got, err := f.svc.PlaylistSongs(t.Context(), track.NetEase, "p1"); err != nil || !reflect.DeepEqual(got, []port.Song{songs[1], songs[0]}) {
t.Errorf("歌单内的歌曲 = %v, %v,期望按歌单的顺序", got, err)
}
if f.quota.calls != 0 {
t.Errorf("取了 %d 次房间的搜索额度,导入不走房间的额度", f.quota.calls)
}
if _, err := f.svc.PlaylistSongs(t.Context(), track.QQ, "p1"); !errors.Is(err, port.ErrProviderFailed) {
t.Errorf("QQ 音乐的歌单 = %v,期望 ErrProviderFailed", err)
}
f.netease.Fail(errors.New("网易云不可用"))
if _, err := f.svc.PlaylistSongs(t.Context(), track.NetEase, "p1"); !errors.Is(err, port.ErrProviderFailed) {
t.Errorf("上游出错时的错误 = %v,期望 ErrProviderFailed", err)
}
}
func TestTrackMetaOfBilibili(t *testing.T) {
f := newFixture(t, history{})
video := track.Track{Source: track.Bilibili, ID: "BV1xx411c7mD/2"}
+186
View File
@@ -0,0 +1,186 @@
package playlist
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/dihemoe/alisten/internal/app/port"
playlistlink "github.com/dihemoe/alisten/internal/domain/playlist"
"github.com/dihemoe/alisten/internal/domain/track"
)
const (
defaultImportName = "导入的歌单"
// 解析短链接和导入平台歌单都要访问外部,各自按成员限流,额度相同。
outboundBurst = 10
outboundRefill = 6 * time.Minute
)
var (
ErrInvalidPlaylistLink = errors.New("不是网易云或 QQ 音乐的歌单链接")
ErrUnsupportedPlaylist = errors.New("暂时只能导入网易云的歌单")
ErrShortLinkFailed = errors.New("没能打开这个短链接")
)
type ImportedTrack struct {
Track track.Track
Title, Artist, Album, CoverURL, PageURL string
Duration time.Duration
}
// 链接曲目的 Track.ID 必须已经按 track.LinkID 规范化。
func (s *Service) ImportFile(ctx context.Context, memberID, name string, tracks []ImportedTrack) (port.PlaylistRecord, int, error) {
if err := s.registered(ctx, memberID); err != nil {
return port.PlaylistRecord{}, 0, err
}
name, err := validName(name)
if err != nil {
name = defaultImportName
}
tracks = firstOccurrences(tracks, func(t ImportedTrack) track.Track { return t.Track })
keys := make([]track.Track, len(tracks))
for i, t := range tracks {
keys[i] = t.Track
}
known, err := s.tracks.TrackMetas(ctx, keys)
if err != nil {
return port.PlaylistRecord{}, 0, err
}
items := make([]port.PlaylistItem, 0, len(tracks))
for _, t := range tracks {
if m, ok := known[t.Track]; ok {
items = append(items, port.PlaylistItem{Track: t.Track, Meta: m})
} else if strings.TrimSpace(t.Title) != "" {
items = append(items, port.PlaylistItem{Track: t.Track, Meta: fileMeta(t)})
}
}
return s.importItems(ctx, memberID, name, items)
}
func (s *Service) ResolveLink(ctx context.Context, memberID, raw string) (track.Source, string, error) {
if err := s.registered(ctx, memberID); err != nil {
return "", "", err
}
link, err := importable(playlistlink.ParseLink(raw))
if err != nil {
return "", "", err
}
if !link.Short {
return link.Source, link.ID, nil
}
if err := s.take(ctx, "playlist-link:member:"+memberID, outboundBurst, outboundRefill); err != nil {
return "", "", err
}
final, err := s.shortLinks.Resolve(ctx, link.URL)
if err != nil {
return "", "", fmt.Errorf("解析短链接 %s:%w:%w", link.URL, err, ErrShortLinkFailed)
}
resolved, err := importable(playlistlink.ParseLink(final))
if err != nil {
return "", "", err
}
if resolved.Short {
return "", "", fmt.Errorf("短链接 %s 停在了 %s:%w", link.URL, final, ErrShortLinkFailed)
}
return resolved.Source, resolved.ID, nil
}
func (s *Service) ImportPlatform(ctx context.Context, memberID, name string, source track.Source, playlistID string) (port.PlaylistRecord, int, error) {
if err := s.registered(ctx, memberID); err != nil {
return port.PlaylistRecord{}, 0, err
}
name, err := validName(name)
if err != nil {
return port.PlaylistRecord{}, 0, err
}
if source != track.NetEase {
return port.PlaylistRecord{}, 0, ErrUnsupportedPlaylist
}
if err := s.take(ctx, "playlist-import:member:"+memberID, outboundBurst, outboundRefill); err != nil {
return port.PlaylistRecord{}, 0, err
}
songs, err := s.catalog.PlaylistSongs(ctx, source, playlistID)
if err != nil {
return port.PlaylistRecord{}, 0, err
}
songs = firstOccurrences(songs, func(song port.Song) track.Track { return song.Track })
items := make([]port.PlaylistItem, len(songs))
for i, song := range songs {
items[i] = port.PlaylistItem(song)
}
return s.importItems(ctx, memberID, name, items)
}
func (s *Service) importItems(ctx context.Context, memberID, name string, items []port.PlaylistItem) (port.PlaylistRecord, int, error) {
p, err := s.playlists.ImportPlaylist(ctx, memberID, name, items, maxPlaylists)
if errors.Is(err, port.ErrPlaylistLimit) {
return port.PlaylistRecord{}, 0, ErrTooManyPlaylists
}
if err != nil {
return port.PlaylistRecord{}, 0, err
}
return p, len(items), nil
}
// 只看前 maxItems 首,其中重复的歌只留第一次出现的位置。
func firstOccurrences[T any](items []T, key func(T) track.Track) []T {
items = items[:min(len(items), maxItems)]
seen := make(map[track.Track]bool, len(items))
kept := make([]T, 0, len(items))
for _, item := range items {
if k := key(item); !seen[k] {
seen[k] = true
kept = append(kept, item)
}
}
return kept
}
func importable(link playlistlink.Link, ok bool) (playlistlink.Link, error) {
switch {
case !ok:
return playlistlink.Link{}, ErrInvalidPlaylistLink
case link.Source != track.NetEase:
return playlistlink.Link{}, ErrUnsupportedPlaylist
}
return link, nil
}
// 曲目表里没有的歌只能用文件里的元数据。文件可以随意改写,所以地址只留平台自己的 https 地址。
func fileMeta(t ImportedTrack) track.Meta {
m := track.Meta{Title: t.Title, Artist: t.Artist, Album: t.Album, Duration: t.Duration}
switch id := t.Track.ID; t.Track.Source {
case track.NetEase:
m.CoverURL = platformURL(t.CoverURL, func(host string) bool { return strings.HasSuffix(host, ".music.126.net") })
if m.PageURL = platformURL(t.PageURL, isHost("music.163.com")); m.PageURL == "" {
m.PageURL = track.NetEasePageURL(id)
}
case track.QQ:
m.CoverURL = platformURL(t.CoverURL, isHost("y.gtimg.cn"))
if m.PageURL = platformURL(t.PageURL, isHost("y.qq.com")); m.PageURL == "" {
m.PageURL = track.QQPageURL(id)
}
// B 站和链接曲目的封面存在导出站点的对象存储里,换一个站点就失效。
case track.Bilibili:
m.PageURL = track.BilibiliPageURL(id)
case track.Link:
m.PageURL = id
}
return m
}
func platformURL(raw string, allowed func(host string) bool) string {
u, err := url.Parse(raw)
if err != nil || u.Scheme != "https" || u.User != nil || u.Port() != "" || !allowed(strings.ToLower(u.Hostname())) {
return ""
}
return raw
}
func isHost(want string) func(string) bool {
return func(host string) bool { return host == want }
}
+300
View File
@@ -0,0 +1,300 @@
package playlist
import (
"errors"
"slices"
"strconv"
"strings"
"testing"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
)
func netease(id string) track.Track { return track.Track{Source: track.NetEase, ID: id} }
func qq(id string) track.Track { return track.Track{Source: track.QQ, ID: id} }
func itemTracks(items []port.PlaylistItem) []track.Track {
tracks := make([]track.Track, len(items))
for i, item := range items {
tracks[i] = item.Track
}
return tracks
}
func TestImportFileChoosesMeta(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
clipMeta := track.Meta{Title: "a.mp3", CoverKey: "link/abc/cover.webp", PageURL: clip.ID, Duration: time.Minute}
knownMeta := track.Meta{Title: "已知", PageURL: "https://y.qq.com/n/ryqq/songDetail/known"}
f.tracks[sunny] = port.PreparedTrack{Meta: sunnyMeta}
f.tracks[clip] = port.PreparedTrack{Meta: clipMeta}
f.tracks[qq("known")] = port.PreparedTrack{Meta: knownMeta}
video := track.Track{Source: track.Bilibili, ID: "BV1xx411c7mD/2"}
other := track.Track{Source: track.Link, ID: "https://example.com/b.mp3"}
cases := []struct {
name string
in ImportedTrack
want *track.Meta // nil 表示跳过
}{
{"曲目表里有:完全用服务端的元数据",
ImportedTrack{Track: sunny, Title: "文件里的标题", Artist: "别人", CoverURL: "https://p1.music.126.net/other.jpg", Duration: time.Second},
&sunnyMeta},
{"链接曲目在曲目表里:用服务端的封面",
ImportedTrack{Track: clip, Title: "文件里的标题", CoverURL: "https://evil.example/a.jpg"},
&clipMeta},
{"曲目表里有:文件里的标题为空也不跳过",
ImportedTrack{Track: qq("known")},
&knownMeta},
{"网易云:平台的 https 地址保留",
ImportedTrack{Track: netease("185811"), Title: "七里香", Artist: "周杰伦", Album: "七里香", CoverURL: "https://p2.music.126.net/b.jpg", PageURL: "https://music.163.com/song?id=185811", Duration: 299 * time.Second},
&track.Meta{Title: "七里香", Artist: "周杰伦", Album: "七里香", CoverURL: "https://p2.music.126.net/b.jpg", PageURL: "https://music.163.com/song?id=185811", Duration: 299 * time.Second}},
{"网易云:http 和伪装的域名丢弃,页面链接由 ID 推出",
ImportedTrack{Track: netease("1"), Title: "一", CoverURL: "http://p1.music.126.net/a.jpg", PageURL: "https://music.163.com.evil.example/song?id=1"},
&track.Meta{Title: "一", PageURL: "https://music.163.com/#/song?id=1"}},
{"网易云:封面只认 music.126.net 的子域名,带 userinfo 的地址丢弃",
ImportedTrack{Track: netease("2"), Title: "二", CoverURL: "https://music.126.net.evil.example/a.jpg", PageURL: "https://user@music.163.com/song?id=2"},
&track.Meta{Title: "二", PageURL: "https://music.163.com/#/song?id=2"}},
{"网易云:封面主机只是字面上以 music.126.net 结尾的丢弃",
ImportedTrack{Track: netease("6"), Title: "六", CoverURL: "https://evilmusic.126.net/a.jpg", PageURL: "https://music.163.com/song?id=6"},
&track.Meta{Title: "六", PageURL: "https://music.163.com/song?id=6"}},
{"网易云:带端口的地址丢弃",
ImportedTrack{Track: netease("3"), Title: "三", CoverURL: "https://p1.music.126.net:8443/a.jpg", PageURL: "https://music.163.com:8443/song?id=3"},
&track.Meta{Title: "三", PageURL: "https://music.163.com/#/song?id=3"}},
{"QQ 音乐:平台的 https 地址保留,域名不区分大小写",
ImportedTrack{Track: qq("0039MnYb0qxYhV"), Title: "晴天", CoverURL: "https://Y.GTIMG.CN/music/a.jpg", PageURL: "https://y.qq.com/n/ryqq/songDetail/0039MnYb0qxYhV"},
&track.Meta{Title: "晴天", CoverURL: "https://Y.GTIMG.CN/music/a.jpg", PageURL: "https://y.qq.com/n/ryqq/songDetail/0039MnYb0qxYhV"}},
{"QQ 音乐:别家平台的地址也丢弃",
ImportedTrack{Track: qq("002"), Title: "二", CoverURL: "https://p1.music.126.net/a.jpg", PageURL: "https://music.163.com/#/song?id=2"},
&track.Meta{Title: "二", PageURL: "https://y.qq.com/n/ryqq/songDetail/002"}},
{"B 站:页面链接由 ID 推出,封面丢弃",
ImportedTrack{Track: video, Title: "晴天(钢琴版)", Artist: "某 UP 主", CoverURL: "https://i0.hdslb.com/a.jpg", PageURL: "https://www.bilibili.com/video/BV1xx411c7mD", Duration: 2 * time.Minute},
&track.Meta{Title: "晴天(钢琴版)", Artist: "某 UP 主", PageURL: "https://www.bilibili.com/video/BV1xx411c7mD?p=2", Duration: 2 * time.Minute}},
{"链接曲目:页面链接就是 ID,封面丢弃",
ImportedTrack{Track: other, Title: "b.mp3", CoverURL: "https://example.com/b.jpg", PageURL: "https://evil.example/"},
&track.Meta{Title: "b.mp3", PageURL: other.ID}},
{"曲目表里没有、标题为空:跳过",
ImportedTrack{Track: netease("4"), Artist: "无名"},
nil},
{"曲目表里没有、标题只有空白:跳过",
ImportedTrack{Track: netease("5"), Title: " \t"},
nil},
}
in := make([]ImportedTrack, len(cases))
var want []track.Track
for i, c := range cases {
in[i] = c.in
if c.want != nil {
want = append(want, c.in.Track)
}
}
p, n, err := f.svc.ImportFile(t.Context(), alice.ID, "导入", in)
if err != nil || n != len(want) || p.TrackCount != len(want) {
t.Fatalf("ImportFile = %#v, %d, %v,期望导入 %d 首", p, n, err, len(want))
}
items, err := f.svc.Items(t.Context(), alice.ID, p.ID)
if err != nil {
t.Fatal(err)
}
if got := itemTracks(items); !slices.Equal(got, want) {
t.Errorf("导入的曲目 = %v,期望与文件同序 %v", got, want)
}
for _, c := range cases {
i := slices.IndexFunc(items, func(item port.PlaylistItem) bool { return item.Track == c.in.Track })
switch {
case c.want == nil && i >= 0:
t.Errorf("%s:导入了 %#v", c.name, items[i].Meta)
case c.want != nil && i < 0:
t.Errorf("%s:没有导入", c.name)
case c.want != nil && items[i].Meta != *c.want:
t.Errorf("%s:元数据 = %#v\n期望 %#v", c.name, items[i].Meta, *c.want)
}
}
}
func TestImportFileTruncatesThenKeepsFirstOccurrences(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
in := []ImportedTrack{{Track: netease("b"), Title: "B"}, {Track: netease("a"), Title: "A"}, {Track: netease("b"), Title: "又一个 B"}}
for i := range 999 {
in = append(in, ImportedTrack{Track: netease(strconv.Itoa(i)), Title: "歌"})
}
p, n, err := f.svc.ImportFile(t.Context(), alice.ID, "导入", in)
if err != nil || n != 999 {
t.Fatalf("ImportFile = %#v, %d, %v,期望前 1000 首去掉 1 首重复的,导入 999 首", p, n, err)
}
items, err := f.svc.Items(t.Context(), alice.ID, p.ID)
if err != nil {
t.Fatal(err)
}
if items[0].Meta.Title != "B" || items[1].Track != netease("a") || items[2].Track != netease("0") || items[len(items)-1].Track != netease("996") {
t.Errorf("导入的曲目以 %v 开头、%v 结尾,期望重复的歌留在第一次出现的位置、第 1000 首之后的不导入", itemTracks(items[:3]), items[len(items)-1].Track)
}
}
func TestImportFileFallsBackToDefaultName(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
song := []ImportedTrack{{Track: sunny, Title: "晴天"}}
for _, name := range []string{"", " ", strings.Repeat("歌", 51), "通\n勤", "\u200b"} {
if p, _, err := f.svc.ImportFile(t.Context(), alice.ID, name, song); err != nil || p.Name != "导入的歌单" {
t.Errorf("文件里的名字 %q:%#v, %v,期望改用“导入的歌单”", name, p, err)
}
}
if p, _, err := f.svc.ImportFile(t.Context(), alice.ID, " 通勤 ", song); err != nil || p.Name != "通勤" {
t.Errorf("合法的名字 = %#v, %v,期望去掉首尾空白后照用", p, err)
}
}
func TestImportFileIsNotRateLimitedButRespectsPlaylistLimit(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
f.quota.exhausted = true
for i := range 49 {
if _, _, err := f.svc.ImportFile(t.Context(), alice.ID, "导入"+strconv.Itoa(i), nil); err != nil {
t.Fatalf("第 %d 次导入 = %v", i+1, err)
}
}
if len(f.quota.keys) != 0 {
t.Errorf("导入文件不访问外部,不应取额度:%v", f.quota.keys)
}
if _, _, err := f.svc.ImportFile(t.Context(), alice.ID, "第 51 个", nil); !errors.Is(err, ErrTooManyPlaylists) {
t.Errorf("第 51 个歌单 = %v,期望 ErrTooManyPlaylists", err)
}
}
func TestResolveLink(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
f.links["https://163cn.tv/abc"] = "https://music.163.com/playlist?id=456&userid=1"
f.links["https://163cn.tv/song"] = "https://music.163.com/song?id=1"
f.links["https://163cn.tv/stuck"] = "https://163cn.tv/stuck"
f.links["https://163cn.tv/qq"] = "https://y.qq.com/n/ryqq/playlist/789"
linkQuota := "playlist-link:member:" + alice.ID
cases := []struct {
raw string
id string
err error
quota []string
}{
{raw: "https://music.163.com/#/playlist?id=123", id: "123"},
{raw: "分享歌单:通勤 https://163cn.tv/abc (来自网易云音乐)", id: "456", quota: []string{linkQuota}},
{raw: "https://163cn.tv/broken", err: ErrShortLinkFailed, quota: []string{linkQuota}},
{raw: "https://163cn.tv/stuck", err: ErrShortLinkFailed, quota: []string{linkQuota}},
{raw: "https://163cn.tv/song", err: ErrInvalidPlaylistLink, quota: []string{linkQuota}},
{raw: "https://163cn.tv/qq", err: ErrUnsupportedPlaylist, quota: []string{linkQuota}},
{raw: "https://example.com/playlist?id=1", err: ErrInvalidPlaylistLink},
{raw: "随便一段文字", err: ErrInvalidPlaylistLink},
{raw: "https://y.qq.com/n/ryqq/playlist/789", err: ErrUnsupportedPlaylist},
{raw: "https://c6.y.qq.com/base/fcgi-bin/u?__=abc", err: ErrUnsupportedPlaylist},
}
for _, c := range cases {
before := len(f.quota.keys)
source, id, err := f.svc.ResolveLink(t.Context(), alice.ID, c.raw)
if c.err != nil && !errors.Is(err, c.err) {
t.Errorf("ResolveLink(%q) = %v,期望 %v", c.raw, err, c.err)
}
if c.err == nil && (err != nil || source != track.NetEase || id != c.id) {
t.Errorf("ResolveLink(%q) = %q, %q, %v,期望网易云的歌单 %s", c.raw, source, id, err, c.id)
}
if taken := f.quota.keys[before:]; !slices.Equal(taken, c.quota) {
t.Errorf("ResolveLink(%q) 取的额度 = %v,期望 %v:长链接在本地识别,只有解析短链接才取链接额度", c.raw, taken, c.quota)
}
}
if f.quota.burst != 10 || f.quota.refill != 6*time.Minute {
t.Errorf("链接额度是突发 %d 次、每 %v 恢复 1 次,期望突发 10 次、每 6 分钟恢复 1 次", f.quota.burst, f.quota.refill)
}
}
func TestImportPlatform(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
sevenMeta := track.Meta{Title: "七里香", Artist: "周杰伦", PageURL: "https://music.163.com/#/song?id=185811", Duration: 299 * time.Second}
f.netease.Add("185811", sevenMeta, "", nil)
f.netease.AddPlaylist(port.Playlist{ID: "p1", Name: "周杰伦"}, "185811", sunny.ID, "185811")
p, n, err := f.svc.ImportPlatform(t.Context(), alice.ID, " 周杰伦 ", track.NetEase, "p1")
if err != nil || n != 2 || p.Name != "周杰伦" || p.TrackCount != 2 {
t.Fatalf("ImportPlatform = %#v, %d, %v,期望去掉重复的、导入 2 首", p, n, err)
}
items, err := f.svc.Items(t.Context(), alice.ID, p.ID)
want := []port.PlaylistItem{{Track: netease("185811"), Meta: sevenMeta}, {Track: sunny, Meta: sunnyMeta}}
if err != nil || !slices.Equal(items, want) {
t.Errorf("导入的曲目 = %#v, %v\n期望与平台同序、用音源的元数据 %#v", items, err, want)
}
if want := []string{"playlist-import:member:" + alice.ID}; !slices.Equal(f.quota.keys, want) || f.quota.burst != 10 || f.quota.refill != 6*time.Minute {
t.Errorf("额度 = %v(突发 %d,每 %v 恢复 1 次),期望 %v(突发 10,每 6 分钟恢复 1 次)", f.quota.keys, f.quota.burst, f.quota.refill, want)
}
}
func TestImportPlatformTruncatesThenDedupes(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
ids := []string{"0"}
for i := range 1001 {
id := strconv.Itoa(i)
f.netease.Add(id, track.Meta{Title: "歌" + id}, "", nil)
ids = append(ids, id)
}
f.netease.AddPlaylist(port.Playlist{ID: "big"}, ids...)
p, n, err := f.svc.ImportPlatform(t.Context(), alice.ID, "大歌单", track.NetEase, "big")
if err != nil || n != 999 {
t.Fatalf("ImportPlatform = %#v, %d, %v,期望前 1000 首去掉 1 首重复的,导入 999 首", p, n, err)
}
items, err := f.svc.Items(t.Context(), alice.ID, p.ID)
if err != nil || items[0].Track != netease("0") || items[len(items)-1].Track != netease("998") {
t.Errorf("导入的曲目 = %d 首, %v,期望从 0 到 998", len(items), err)
}
}
func TestImportPlatformRejects(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
f.netease.AddPlaylist(port.Playlist{ID: "p1"}, sunny.ID)
if _, _, err := f.svc.ImportPlatform(t.Context(), alice.ID, "QQ", track.QQ, "p1"); !errors.Is(err, ErrUnsupportedPlaylist) {
t.Errorf("QQ 音乐的歌单 = %v,期望 ErrUnsupportedPlaylist", err)
}
if _, _, err := f.svc.ImportPlatform(t.Context(), alice.ID, " ", track.NetEase, "p1"); !errors.Is(err, ErrInvalidName) {
t.Errorf("空的歌单名 = %v,期望 ErrInvalidName", err)
}
if len(f.quota.keys) != 0 {
t.Errorf("被拒绝的请求不应取额度:%v", f.quota.keys)
}
if _, _, err := f.svc.ImportPlatform(t.Context(), alice.ID, "不存在", track.NetEase, "missing"); !errors.Is(err, port.ErrProviderFailed) {
t.Errorf("音源里没有的歌单 = %v,期望 ErrProviderFailed", err)
}
for i := range 49 {
if _, err := f.svc.Create(t.Context(), alice.ID, "歌单"+strconv.Itoa(i)); err != nil {
t.Fatal(err)
}
}
if _, _, err := f.svc.ImportPlatform(t.Context(), alice.ID, "第 51 个", track.NetEase, "p1"); !errors.Is(err, ErrTooManyPlaylists) {
t.Errorf("第 51 个歌单 = %v,期望 ErrTooManyPlaylists", err)
}
}
func TestPlatformImportsAreRateLimited(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
f.netease.AddPlaylist(port.Playlist{ID: "p1"}, sunny.ID)
f.links["https://163cn.tv/abc"] = "https://music.163.com/playlist?id=1"
f.quota.exhausted = true
if source, id, err := f.svc.ResolveLink(t.Context(), alice.ID, "https://music.163.com/playlist?id=1"); err != nil || source != track.NetEase || id != "1" {
t.Errorf("额度用完时解析长链接 = %q, %q, %v,期望照常识别", source, id, err)
}
if _, _, err := f.svc.ResolveLink(t.Context(), alice.ID, "https://163cn.tv/abc"); !errors.Is(err, ErrTooFast) {
t.Errorf("额度用完时解析短链接 = %v,期望 ErrTooFast", err)
}
if _, _, err := f.svc.ImportPlatform(t.Context(), alice.ID, "周杰伦", track.NetEase, "p1"); !errors.Is(err, ErrTooFast) {
t.Errorf("额度用完时导入 = %v,期望 ErrTooFast", err)
}
if want := []string{"playlist-link:member:" + alice.ID, "playlist-import:member:" + alice.ID}; !slices.Equal(f.quota.keys, want) {
t.Errorf("取的额度 = %v,期望短链接取链接额度、导入取导入额度 %v", f.quota.keys, want)
}
if lists, _ := f.svc.List(t.Context(), alice.ID); len(lists) != 1 {
t.Errorf("额度用完时不应建歌单:%#v", lists)
}
}
+20 -12
View File
@@ -32,15 +32,16 @@ var (
)
type Service struct {
playlists port.Playlists
members port.Members
tracks port.Tracks
catalog *catalog.Service
quota port.AccountQuota
playlists port.Playlists
members port.Members
tracks port.TrackLookup
catalog *catalog.Service
quota port.AccountQuota
shortLinks port.ShortLinks
}
func New(playlists port.Playlists, members port.Members, tracks port.Tracks, catalog *catalog.Service, quota port.AccountQuota) *Service {
return &Service{playlists: playlists, members: members, tracks: tracks, catalog: catalog, quota: quota}
func New(playlists port.Playlists, members port.Members, tracks port.TrackLookup, catalog *catalog.Service, quota port.AccountQuota, shortLinks port.ShortLinks) *Service {
return &Service{playlists: playlists, members: members, tracks: tracks, catalog: catalog, quota: quota, shortLinks: shortLinks}
}
func (s *Service) List(ctx context.Context, memberID string) ([]port.PlaylistRecord, error) {
@@ -95,13 +96,9 @@ func (s *Service) Add(ctx context.Context, memberID, id string, t track.Track) (
if err := s.registered(ctx, memberID); err != nil {
return port.PlaylistItem{}, err
}
wait, err := s.quota.Take(ctx, "playlist:member:"+memberID, addBurst, addRefill)
if err != nil {
if err := s.take(ctx, "playlist:member:"+memberID, addBurst, addRefill); err != nil {
return port.PlaylistItem{}, err
}
if wait > 0 {
return port.PlaylistItem{}, ErrTooFast
}
meta, err := s.meta(ctx, t)
if err != nil {
return port.PlaylistItem{}, err
@@ -160,6 +157,17 @@ func (s *Service) registered(ctx context.Context, memberID string) error {
return nil
}
func (s *Service) take(ctx context.Context, key string, burst int, refill time.Duration) error {
wait, err := s.quota.Take(ctx, key, burst, refill)
if err != nil {
return err
}
if wait > 0 {
return ErrTooFast
}
return nil
}
// 链接曲目准备好之前没有元数据,音源也查不到。
func (s *Service) meta(ctx context.Context, t track.Track) (track.Meta, error) {
if t.Source != track.Link {
+42 -6
View File
@@ -31,6 +31,16 @@ func (t tracksTable) PreparedTrack(_ context.Context, k track.Track) (port.Prepa
return p, ok, nil
}
func (t tracksTable) TrackMetas(_ context.Context, ts []track.Track) (map[track.Track]track.Meta, error) {
found := map[track.Track]track.Meta{}
for _, k := range ts {
if p, ok := t[k]; ok {
found[k] = p.Meta
}
}
return found, nil
}
func (tracksTable) SavePreparedTrack(context.Context, track.Track, port.PreparedTrack) error {
return nil
}
@@ -54,21 +64,37 @@ func (roomQuota) TakePick(context.Context, string, int, time.Duration) (time.Dur
type accountQuota struct {
exhausted bool
keys []string
// 最近一次取额度时给的额度。
burst int
refill time.Duration
}
func (q *accountQuota) Take(_ context.Context, key string, _ int, _ time.Duration) (time.Duration, error) {
func (q *accountQuota) Take(_ context.Context, key string, burst int, refill time.Duration) (time.Duration, error) {
q.keys = append(q.keys, key)
q.burst, q.refill = burst, refill
if q.exhausted {
return time.Second, nil
}
return 0, nil
}
// 短链接到解析结果;不在表里的短链接解析失败。
type shortLinks map[string]string
func (l shortLinks) Resolve(_ context.Context, link string) (string, error) {
if final, ok := l[link]; ok {
return final, nil
}
return "", errors.New("跳转到了不受信任的主机")
}
type fixture struct {
svc *Service
store *porttest.Store
tracks tracksTable
quota *accountQuota
svc *Service
store *porttest.Store
tracks tracksTable
quota *accountQuota
netease *porttest.Source
links shortLinks
}
func newFixture(t *testing.T) fixture {
@@ -80,7 +106,8 @@ func newFixture(t *testing.T) fixture {
tracks := tracksTable{}
music := catalog.New(netease, porttest.NewSource(track.QQ), bilibili, bilibili, tracks, noHistory{}, store, roomQuota{})
q := &accountQuota{}
return fixture{svc: New(store, store, tracks, music, q), store: store, tracks: tracks, quota: q}
links := shortLinks{}
return fixture{svc: New(store, store, tracks, music, q, links), store: store, tracks: tracks, quota: q, netease: netease, links: links}
}
func (f fixture) member(t *testing.T, username string) member.Member {
@@ -131,6 +158,15 @@ func TestGuestCannotUsePlaylists(t *testing.T) {
if err := f.svc.Move(t.Context(), guest.ID, "any", []track.Track{sunny}, nil); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客调整顺序 = %v,期望 ErrNotRegistered", err)
}
if _, _, err := f.svc.ImportFile(t.Context(), guest.ID, "导入", []ImportedTrack{{Track: sunny, Title: "晴天"}}); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客导入文件 = %v,期望 ErrNotRegistered", err)
}
if _, _, err := f.svc.ResolveLink(t.Context(), guest.ID, "https://music.163.com/playlist?id=1"); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客解析歌单链接 = %v,期望 ErrNotRegistered", err)
}
if _, _, err := f.svc.ImportPlatform(t.Context(), guest.ID, "导入", track.NetEase, "1"); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客导入平台歌单 = %v,期望 ErrNotRegistered", err)
}
if len(f.quota.keys) != 0 {
t.Errorf("游客不应消耗额度:%v", f.quota.keys)
}
+3
View File
@@ -41,6 +41,9 @@ type Playlists interface {
MemberPlaylists(ctx context.Context, ownerID string) ([]PlaylistRecord, error)
// 已有 limit 个歌单(含“我喜欢”)时返回 ErrPlaylistLimit。
CreatePlaylist(ctx context.Context, ownerID, name string, limit int) (PlaylistRecord, error)
// 新建歌单并写入 items,items[0] 排在最前;返回的记录带曲目数和封面。已有 limit 个歌单(含“我喜欢”)时返回 ErrPlaylistLimit。
// 调用方保证 items 中没有重复的歌、不超过歌单的曲目上限。
ImportPlaylist(ctx context.Context, ownerID, name string, items []PlaylistItem, limit int) (PlaylistRecord, error)
// “我喜欢”返回 ErrFavoritesFixed。
RenamePlaylist(ctx context.Context, ownerID, id, name string) error
// “我喜欢”返回 ErrFavoritesFixed。
+41 -2
View File
@@ -578,6 +578,44 @@ func (s *Store) CreatePlaylist(_ context.Context, ownerID, name string, limit in
}
s.mu.Lock()
defer s.mu.Unlock()
p, err := s.newPlaylist(ownerID, name, limit)
if err != nil {
return port.PlaylistRecord{}, err
}
return port.PlaylistRecord{ID: p.id, Name: name, CreatedAt: p.created, UpdatedAt: p.updated}, nil
}
func (s *Store) ImportPlaylist(_ context.Context, ownerID, name string, items []port.PlaylistItem, limit int) (port.PlaylistRecord, error) {
if err := checkText(name); err != nil {
return port.PlaylistRecord{}, err
}
seen := make(map[track.Track]bool, len(items))
for _, item := range items {
m := item.Meta
if err := checkText(item.Track.ID, m.Title, m.Artist, m.Album, m.CoverURL, string(m.CoverKey), m.PageURL); err != nil {
return port.PlaylistRecord{}, err
}
if seen[item.Track] {
return port.PlaylistRecord{}, fmt.Errorf("歌单里已经有 %s/%s:违反主键约束", item.Track.Source, item.Track.ID)
}
seen[item.Track] = true
}
s.mu.Lock()
defer s.mu.Unlock()
p, err := s.newPlaylist(ownerID, name, limit)
if err != nil {
return port.PlaylistRecord{}, err
}
for i := len(items) - 1; i >= 0; i-- {
item := items[i]
item.Meta.Duration = item.Meta.Duration.Truncate(time.Millisecond)
p.items = append(p.items, item)
}
return port.PlaylistRecord{ID: p.id, Name: name, TrackCount: len(p.items), CreatedAt: p.created, UpdatedAt: p.updated, Cover: p.cover()}, nil
}
// 调用方持有 s.mu。
func (s *Store) newPlaylist(ownerID, name string, limit int) (*storedPlaylist, error) {
n := 0
for _, p := range s.playlists {
if p.owner == ownerID {
@@ -585,12 +623,12 @@ func (s *Store) CreatePlaylist(_ context.Context, ownerID, name string, limit in
}
}
if n >= limit {
return port.PlaylistRecord{}, port.ErrPlaylistLimit
return nil, port.ErrPlaylistLimit
}
now := time.Now()
p := &storedPlaylist{id: s.newID(), owner: ownerID, name: name, created: now, updated: now}
s.playlists = append(s.playlists, p)
return port.PlaylistRecord{ID: p.id, Name: name, CreatedAt: now, UpdatedAt: now}, nil
return p, nil
}
func (s *Store) RenamePlaylist(_ context.Context, ownerID, id, name string) error {
@@ -656,6 +694,7 @@ func (s *Store) AddPlaylistItem(_ context.Context, ownerID, id string, item port
if len(p.items) >= limit {
return port.ErrPlaylistLimit
}
item.Meta.Duration = item.Meta.Duration.Truncate(time.Millisecond)
p.items = append(p.items, item)
p.updated = time.Now()
return nil
+51 -1
View File
@@ -1,6 +1,12 @@
package porttest
import "testing"
import (
"testing"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
)
func TestUpdateMemberRejectsTextPostgreSQLCannotStore(t *testing.T) {
store := New()
@@ -13,3 +19,47 @@ func TestUpdateMemberRejectsTextPostgreSQLCannotStore(t *testing.T) {
t.Error("含 NUL 的邮箱应报错")
}
}
func TestAddPlaylistItemKeepsMilliseconds(t *testing.T) {
store := New()
m, err := store.CreateMember(t.Context(), []byte("token"), "甲")
if err != nil {
t.Fatal(err)
}
list, err := store.CreatePlaylist(t.Context(), m.ID, "歌单", 10)
if err != nil {
t.Fatal(err)
}
item := port.PlaylistItem{
Track: track.Track{Source: "netease", ID: "1"},
Meta: track.Meta{Title: "歌", Duration: 3*time.Minute + 1234*time.Microsecond + 567*time.Nanosecond},
}
if err := store.AddPlaylistItem(t.Context(), m.ID, list.ID, item, 10); err != nil {
t.Fatal(err)
}
items, err := store.PlaylistItems(t.Context(), m.ID, list.ID)
if err != nil {
t.Fatal(err)
}
if want := 3*time.Minute + time.Millisecond; items[0].Meta.Duration != want {
t.Errorf("Duration = %v,期望 %v", items[0].Meta.Duration, want)
}
}
func TestImportPlaylistRejectsWhatPostgreSQLRejects(t *testing.T) {
store := New()
m, err := store.CreateMember(t.Context(), []byte("token"), "甲")
if err != nil {
t.Fatal(err)
}
song := port.PlaylistItem{Track: track.Track{Source: "netease", ID: "1"}, Meta: track.Meta{Title: "歌"}}
broken := port.PlaylistItem{Track: track.Track{Source: "netease", ID: "2"}, Meta: track.Meta{Title: "坏\x00标题"}}
for name, items := range map[string][]port.PlaylistItem{"重复的歌": {song, song}, "含 NUL 的标题": {song, broken}} {
if _, err := store.ImportPlaylist(t.Context(), m.ID, "导入", items, 10); err == nil {
t.Errorf("%s应报错", name)
}
}
if lists, _ := store.MemberPlaylists(t.Context(), m.ID); len(lists) != 0 {
t.Errorf("失败之后的歌单 = %#v,期望一个也没有建", lists)
}
}
+8
View File
@@ -0,0 +1,8 @@
package port
import "context"
type ShortLinks interface {
// 返回跳转之后的最终地址;跳转到不受信任的主机、跳转过多或超时都返回错误。
Resolve(ctx context.Context, link string) (string, error)
}
+6
View File
@@ -20,3 +20,9 @@ type Tracks interface {
PreparedTrack(ctx context.Context, t track.Track) (p PreparedTrack, ok bool, err error)
SavePreparedTrack(ctx context.Context, t track.Track, p PreparedTrack) error
}
type TrackLookup interface {
PreparedTrack(ctx context.Context, t track.Track) (p PreparedTrack, ok bool, err error)
// 与 PreparedTrack 读出的 Meta 相同,时长是准备得到的时长。
TrackMetas(ctx context.Context, ts []track.Track) (map[track.Track]track.Meta, error)
}
+2 -45
View File
@@ -30,9 +30,8 @@ type serveConfig struct {
redis *goredis.Options
storage storage.Config
// 浏览器访问对象存储时使用的地址前缀,结尾没有 /。
storageURL string
trustedProxies []netip.Prefix
tracing bool
storageURL string
tracing bool
}
func loadServeConfig() (serveConfig, error) {
@@ -58,10 +57,6 @@ func loadServeConfig() (serveConfig, error) {
return serveConfig{}, err
}
adminToken := os.Getenv("ALISTEN_ADMIN_TOKEN")
trustedProxies, err := parseTrustedProxies(os.Getenv("ALISTEN_TRUSTED_PROXIES"))
if err != nil {
return serveConfig{}, err
}
database, err := loadDatabaseConfig()
if err != nil {
return serveConfig{}, err
@@ -96,7 +91,6 @@ func loadServeConfig() (serveConfig, error) {
redis: redis,
storage: objects,
storageURL: storageURL,
trustedProxies: trustedProxies,
tracing: tracing,
}, nil
}
@@ -145,43 +139,6 @@ func parseAllowedOrigins(value, fallback string) ([]string, error) {
return origins, nil
}
func parseTrustedProxies(value string) ([]netip.Prefix, error) {
if value == "" {
return nil, nil
}
var prefixes []netip.Prefix
for raw := range strings.SplitSeq(value, ",") {
item := strings.TrimSpace(raw)
if item == "" {
return nil, errors.New("ALISTEN_TRUSTED_PROXIES 中有空项")
}
prefix, err := parseTrustedProxy(item)
if err != nil {
return nil, fmt.Errorf("ALISTEN_TRUSTED_PROXIES 中的 %q 不是 IP 地址或 CIDR:%w", item, err)
}
prefixes = append(prefixes, prefix)
}
return prefixes, nil
}
// 单个地址视为只含它自己的网段。clientIP 先把 IPv4 映射的 IPv6 地址还原成 IPv4 再匹配,所以 ::ffff: 开头的网段换成等价的
// IPv4 网段,否则永远匹配不上。带 zone 的地址永远匹配不上网段,按不合法处理。
func parseTrustedProxy(item string) (netip.Prefix, error) {
prefix, err := netip.ParsePrefix(item)
if err != nil {
addr, addrErr := netip.ParseAddr(item)
if addrErr != nil || addr.Zone() != "" {
return netip.Prefix{}, err
}
prefix = netip.PrefixFrom(addr, addr.BitLen())
}
prefix = prefix.Masked()
if prefix.Addr().Is4In6() {
prefix = netip.PrefixFrom(prefix.Addr().Unmap(), prefix.Bits()-96)
}
return prefix, nil
}
func canonicalPort(port string) bool {
n, err := strconv.Atoi(port)
return err == nil && n >= 1 && n <= 65535 && strconv.Itoa(n) == port
-35
View File
@@ -2,9 +2,7 @@ package bootstrap
import (
"log/slog"
"net/netip"
"os"
"slices"
"testing"
)
@@ -85,36 +83,3 @@ func TestLoadLogLevel(t *testing.T) {
}
func ptr(s string) *string { return &s }
func TestParseTrustedProxies(t *testing.T) {
got, err := parseTrustedProxies("10.42.0.0/16, 127.0.0.1/32,::1/128,10.43.1.2/16")
want := []netip.Prefix{
netip.MustParsePrefix("10.42.0.0/16"),
netip.MustParsePrefix("127.0.0.1/32"),
netip.MustParsePrefix("::1/128"),
netip.MustParsePrefix("10.43.0.0/16"),
}
if err != nil || !slices.Equal(got, want) {
t.Errorf("parseTrustedProxies = %v, %v,期望 %v", got, err, want)
}
if got, err := parseTrustedProxies(""); err != nil || got != nil {
t.Errorf("空值 = %v, %v,期望没有受信任的代理", got, err)
}
// 单个地址只含它自己;客户端地址先还原成 IPv4 再匹配,所以 IPv4 映射的网段换成等价的 IPv4 网段。
for value, want := range map[string]string{
"10.42.0.1": "10.42.0.1/32",
"fd00::1": "fd00::1/128",
"::ffff:10.42.0.0/112": "10.42.0.0/16",
"::ffff:10.42.0.1": "10.42.0.1/32",
"::ffff:0.0.0.0/96": "0.0.0.0/0",
} {
if got, err := parseTrustedProxies(value); err != nil || !slices.Equal(got, []netip.Prefix{netip.MustParsePrefix(want)}) {
t.Errorf("parseTrustedProxies(%q) = %v, %v,期望 %s", value, got, err, want)
}
}
for _, bad := range []string{"10.42.0.0/16,", "not-a-cidr", "10.42.0.1/33", "fe80::1%eth0", "10.42.0.256"} {
if _, err := parseTrustedProxies(bad); err == nil {
t.Errorf("parseTrustedProxies(%q) 应报错", bad)
}
}
}
-1
View File
@@ -344,7 +344,6 @@ func contractEnv(t *testing.T, databaseURL, redisURL string) {
t.Setenv("ALISTEN_PUBLIC_URL", "https://alisten.example")
t.Setenv("ALISTEN_ALLOWED_ORIGINS", "https://alisten.example,http://localhost:9876")
t.Setenv("ALISTEN_ADMIN_TOKEN", "admin-secret")
t.Setenv("ALISTEN_TRUSTED_PROXIES", "")
t.Setenv("STORAGE_PUBLIC_URL", "https://alisten.example/storage/alisten")
}
+4 -3
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)),
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,
@@ -89,8 +91,7 @@ func newPublic(cfg serveConfig, pool *pgxpool.Pool, rdb *goredis.Client, instanc
Playback: media.NewPlayback(src.playback, store),
Site: site.New(store),
StorageURL: cfg.storageURL,
TrustedProxies: cfg.trustedProxies,
StorageURL: cfg.storageURL,
}
httpLogger := base.With("component", "http")
return public{
-3
View File
@@ -1,8 +1,5 @@
登录按客户端 IP 限流:同一个 IP 1 分钟内最多尝试 10 次,换一个用户名也算在内;请求经受信任的代理转发时,按 X-Forwarded-For 中的客户端地址计数,另一个客户端不受影响。
-- env --
ALISTEN_TRUSTED_PROXIES=127.0.0.0/8,::1/128
-- request --
POST /api/members
+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": "暂时只能导入网易云的歌单"}}
+93
View File
@@ -0,0 +1,93 @@
package playlist
import (
"net/url"
"regexp"
"strings"
"github.com/dihemoe/alisten/internal/domain/track"
)
// URL 是从文本中取出的链接;短链接要靠它去解析。
type Link struct {
Source track.Source
ID string
Short bool
URL string
}
var (
urlPattern = regexp.MustCompile(`(?i)https?://[^\s<>"',。;!?、)】》]+`)
digits = regexp.MustCompile(`^[0-9]+$`)
qqPath = regexp.MustCompile(`^/n/(?:ryqq|yqq)/playlist/([0-9]+)(?:\.html)?$`)
)
// 重定向只能落在这些主机上,按小写比较。
var redirectHosts = map[string]bool{
"163cn.tv": true,
"music.163.com": true,
"y.music.163.com": true,
"c6.y.qq.com": true,
"y.qq.com": true,
"i.y.qq.com": true,
}
func AllowedRedirectHost(host string) bool {
return redirectHosts[strings.ToLower(host)]
}
// 用户粘贴的分享文本里夹着别的文字,所以只取第一个 http(s) 链接来识别,后面的链接不看。
func ParseLink(raw string) (Link, bool) {
link := strings.TrimRight(urlPattern.FindString(raw), ".,;:!?)]}>")
if link == "" {
return Link{}, false
}
u, err := url.Parse(link)
if err != nil || u.User != nil || u.Port() != "" {
return Link{}, false
}
switch strings.ToLower(u.Hostname()) {
case "163cn.tv":
return Link{Source: track.NetEase, Short: true, URL: link}, true
case "c6.y.qq.com":
return Link{Source: track.QQ, Short: true, URL: link}, true
case "music.163.com", "y.music.163.com":
path, query := u.Path, u.Query()
// 桌面版把路由放在片段里:/#/playlist?id=<id>。
if frag, ok := strings.CutPrefix(u.Fragment, "/"); ok && path == "/" {
p, q, _ := strings.Cut(frag, "?")
path = "/" + p
if query, err = url.ParseQuery(q); err != nil {
return Link{}, false
}
}
if path != "/playlist" && path != "/m/playlist" {
return Link{}, false
}
if id := query.Get("id"); digits.MatchString(id) {
return Link{Source: track.NetEase, ID: id, URL: link}, true
}
case "y.qq.com":
if m := qqPath.FindStringSubmatch(u.Path); m != nil {
return Link{Source: track.QQ, ID: m[1], URL: link}, true
}
case "i.y.qq.com":
if u.Path != "/n2/m/share/details/taoge.html" {
return Link{}, false
}
q := u.Query()
for _, key := range []string{"id", "disstid"} {
if id := q.Get(key); digits.MatchString(id) {
return Link{Source: track.QQ, ID: id, URL: link}, true
}
}
}
return Link{}, false
}
// 链接已经能直接识别出歌单,不必再跟随跳转。
func Recognized(link string) bool {
l, ok := ParseLink(link)
return ok && !l.Short
}
+79
View File
@@ -0,0 +1,79 @@
package playlist
import (
"testing"
"github.com/dihemoe/alisten/internal/domain/track"
)
func TestParseLink(t *testing.T) {
tests := []struct {
name string
raw string
want Link
ok bool
}{
{"网易云哈希路由", "https://music.163.com/#/playlist?id=123", Link{Source: track.NetEase, ID: "123", URL: "https://music.163.com/#/playlist?id=123"}, true},
{"网易云桌面", "https://music.163.com/playlist?id=123", Link{Source: track.NetEase, ID: "123", URL: "https://music.163.com/playlist?id=123"}, true},
{"网易云移动", "https://y.music.163.com/m/playlist?id=123&userid=9&creatorId=9", Link{Source: track.NetEase, ID: "123", URL: "https://y.music.163.com/m/playlist?id=123&userid=9&creatorId=9"}, true},
{"网易云哈希带其他参数", "https://music.163.com/#/playlist?app_version=1&id=123", Link{Source: track.NetEase, ID: "123", URL: "https://music.163.com/#/playlist?app_version=1&id=123"}, true},
{"网易云 http", "http://music.163.com/playlist?id=123", Link{Source: track.NetEase, ID: "123", URL: "http://music.163.com/playlist?id=123"}, true},
{"QQ 桌面", "https://y.qq.com/n/ryqq/playlist/456", Link{Source: track.QQ, ID: "456", URL: "https://y.qq.com/n/ryqq/playlist/456"}, true},
{"QQ 旧版", "https://y.qq.com/n/yqq/playlist/456.html", Link{Source: track.QQ, ID: "456", URL: "https://y.qq.com/n/yqq/playlist/456.html"}, true},
{"QQ 移动 id", "https://i.y.qq.com/n2/m/share/details/taoge.html?id=456&hosteuin=x", Link{Source: track.QQ, ID: "456", URL: "https://i.y.qq.com/n2/m/share/details/taoge.html?id=456&hosteuin=x"}, true},
{"QQ 移动 disstid", "https://i.y.qq.com/n2/m/share/details/taoge.html?disstid=456", Link{Source: track.QQ, ID: "456", URL: "https://i.y.qq.com/n2/m/share/details/taoge.html?disstid=456"}, true},
{"网易云短链接", "https://163cn.tv/abc", Link{Source: track.NetEase, Short: true, URL: "https://163cn.tv/abc"}, true},
{"QQ 短链接", "https://c6.y.qq.com/base/fcgi-bin/u?__=abc", Link{Source: track.QQ, Short: true, URL: "https://c6.y.qq.com/base/fcgi-bin/u?__=abc"}, true},
{"分享文本", "分享歌单:我的歌单 https://163cn.tv/abc (来自网易云音乐)", Link{Source: track.NetEase, Short: true, URL: "https://163cn.tv/abc"}, true},
{"分享文本带句号", "看看这个 https://music.163.com/playlist?id=123。", Link{Source: track.NetEase, ID: "123", URL: "https://music.163.com/playlist?id=123"}, true},
{"主机大小写", "HTTPS://Music.163.COM/playlist?id=123", Link{Source: track.NetEase, ID: "123", URL: "HTTPS://Music.163.COM/playlist?id=123"}, true},
{"其他网站", "https://example.com/playlist?id=123", Link{}, false},
{"主机后缀伪装", "https://music.163.com.evil.com/playlist?id=123", Link{}, false},
{"userinfo 伪装", "https://music.163.com@evil.com/playlist?id=123", Link{}, false},
{"网易云缺少 id", "https://music.163.com/playlist", Link{}, false},
{"网易云 id 非数字", "https://music.163.com/playlist?id=abc", Link{}, false},
{"网易云歌曲链接", "https://music.163.com/song?id=123", Link{}, false},
{"QQ 缺少 id", "https://y.qq.com/n/ryqq/playlist/", Link{}, false},
{"QQ id 非数字", "https://y.qq.com/n/ryqq/playlist/abc", Link{}, false},
{"QQ 移动缺少 id", "https://i.y.qq.com/n2/m/share/details/taoge.html", Link{}, false},
{"QQ 歌曲链接", "https://y.qq.com/n/ryqq/songDetail/abc", Link{}, false},
{"没有链接", "只是一段文字", Link{}, false},
{"空", "", Link{}, false},
{"非 http", "ftp://music.163.com/playlist?id=123", Link{}, false},
{"只取第一个链接", "https://example.com/a https://music.163.com/playlist?id=123", Link{}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := ParseLink(tt.raw)
if ok != tt.ok || got != tt.want {
t.Fatalf("ParseLink(%q) = %+v, %v; want %+v, %v", tt.raw, got, ok, tt.want, tt.ok)
}
})
}
}
func TestAllowedRedirectHost(t *testing.T) {
for host, want := range map[string]bool{
"163cn.tv": true, "music.163.com": true, "y.music.163.com": true,
"c6.y.qq.com": true, "y.qq.com": true, "i.y.qq.com": true, "Y.QQ.com": true,
"evil.com": false, "music.163.com.evil.com": false, "qq.com": false, "": false, "127.0.0.1": false,
} {
if got := AllowedRedirectHost(host); got != want {
t.Errorf("AllowedRedirectHost(%q) = %v; want %v", host, got, want)
}
}
}
func TestRecognized(t *testing.T) {
for link, want := range map[string]bool{
"https://music.163.com/#/playlist?id=123": true,
"https://y.qq.com/n/ryqq/playlist/7039876": true,
"https://163cn.tv/abc": false,
"https://example.com/playlist?id=1": false,
} {
if got := Recognized(link); got != want {
t.Errorf("Recognized(%q) = %v,期望 %v", link, got, want)
}
}
}
+9
View File
@@ -1,6 +1,7 @@
package track
import (
"net/url"
"strings"
"time"
)
@@ -40,6 +41,14 @@ func asciiLower(s string) string {
}, s)
}
func NetEasePageURL(id string) string {
return "https://music.163.com/#/song?id=" + url.QueryEscape(id)
}
func QQPageURL(id string) string {
return "https://y.qq.com/n/ryqq/songDetail/" + url.PathEscape(id)
}
type Meta struct {
Title string
Artist string
+14
View File
@@ -18,3 +18,17 @@ func TestLinkID(t *testing.T) {
}
}
}
func TestPlatformPageURL(t *testing.T) {
tests := []struct{ got, want string }{
{NetEasePageURL("186016"), "https://music.163.com/#/song?id=186016"},
{QQPageURL("0039MnYb0qxYhV"), "https://y.qq.com/n/ryqq/songDetail/0039MnYb0qxYhV"},
{NetEasePageURL("1&id=2#x"), "https://music.163.com/#/song?id=1%26id%3D2%23x"},
{QQPageURL("a/../b?c"), "https://y.qq.com/n/ryqq/songDetail/a%2F..%2Fb%3Fc"},
}
for _, tt := range tests {
if tt.got != tt.want {
t.Errorf("页面链接 = %q,期望 %q", tt.got, tt.want)
}
}
}
+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}
@@ -11,6 +11,22 @@ location ^~ /api/ {
client_body_buffer_size 64k;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://$serve;
# 取值必须不小于 serve 对批量歌单接口的请求体上限 maxBatchBodyBytes(2304000 字节),否则大请求会被 nginx 以 HTML 的 413 拒绝,或写入临时文件。
location ~ ^/api/me/playlists/[^/]+/(removals|copies|moves)$ {
client_max_body_size 2250k;
client_body_buffer_size 2250k;
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
@@ -0,0 +1,21 @@
import { openAccountSettings } from './account-settings'
export function AccountPrompt({ message, onClose }: { message: string, onClose: () => void }) {
return (
<>
<p className="text-ui-base muted">{message}</p>
<div className="dialog-actions">
<button
type="button"
className="button-primary"
onClick={() => {
onClose()
openAccountSettings()
}}
>
去账号设置
</button>
</div>
</>
)
}
@@ -1,4 +1,4 @@
// 游客在保存对话框里点“去账号设置”时打开账号设置。设置页在哪里、怎样打开由应用壳决定,启动时设置。
// 游客在保存歌曲或歌单的对话框里点“去账号设置”时打开账号设置。设置页在哪里、怎样打开由应用壳决定,启动时设置。
let open = (): void => {}
export function openAccountSettings(): void {
+129
View File
@@ -0,0 +1,129 @@
import type { LucideIcon } from 'lucide-react'
import type { KeyboardEvent, ReactNode, ToggleEvent } from 'react'
import { useEffect, useId, useRef, useState } from 'react'
const menuGap = 4
const viewportMargin = 8
const menuKeys: Record<string, (index: number, count: number) => number> = {
ArrowDown: (index, count) => (index + 1) % count,
ArrowUp: (index, count) => (index - 1 + count) % count,
Home: () => 0,
End: (_, count) => count - 1,
}
export interface MenuAction {
icon: LucideIcon
label: string
danger?: boolean
run: () => void
}
export function ActionMenu({ actions, menuLabel, align = 'end', className, label, title, disabled, children }: {
actions: MenuAction[]
menuLabel: string
align?: 'start' | 'end'
className: string
label?: string
title?: string
disabled?: boolean
children: ReactNode
}) {
const menuId = useId()
const buttonRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [open, setOpen] = useState(false)
// 菜单在顶层按视口定位,不跟随页面滚动,所以滚动和缩放窗口时收起。
useEffect(() => {
if (!open)
return
const close = () => menuRef.current?.hidePopover()
document.addEventListener('scroll', close, true)
window.addEventListener('resize', close)
return () => {
document.removeEventListener('scroll', close, true)
window.removeEventListener('resize', close)
}
}, [open])
const items = () => [...menuRef.current!.querySelectorAll<HTMLElement>('[role="menuitem"]')]
// 显示之前量不到菜单的尺寸:先贴着按钮下方对齐,显示之后超出视口再翻到按钮上方、收进视口。
const place = (event: ToggleEvent<HTMLDivElement>) => {
if (event.newState !== 'open')
return
const button = buttonRef.current!.getBoundingClientRect()
const { style } = event.currentTarget
style.top = `${button.bottom + menuGap}px`
style.left = align === 'start' ? `${button.left}px` : 'auto'
style.right = align === 'end' ? `${document.documentElement.clientWidth - button.right}px` : 'auto'
}
const onToggle = (event: ToggleEvent<HTMLDivElement>) => {
const opened = event.newState === 'open'
setOpen(opened)
if (!opened)
return
const menu = event.currentTarget
const button = buttonRef.current!.getBoundingClientRect()
const rect = menu.getBoundingClientRect()
if (rect.bottom > window.innerHeight - viewportMargin)
menu.style.top = `${Math.max(viewportMargin, button.top - menuGap - rect.height)}px`
const left = Math.max(viewportMargin, Math.min(rect.left, document.documentElement.clientWidth - viewportMargin - rect.width))
if (left !== rect.left) {
menu.style.left = `${left}px`
menu.style.right = 'auto'
}
items()[0]?.focus()
}
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Tab') {
event.currentTarget.hidePopover()
return
}
const move = menuKeys[event.key]
if (!move)
return
event.preventDefault()
const list = items()
list[move(list.indexOf(document.activeElement as HTMLElement), list.length)]?.focus()
}
return (
<>
<button
ref={buttonRef}
type="button"
className={className}
popoverTarget={menuId}
aria-haspopup="menu"
aria-expanded={open}
aria-label={label}
title={title}
disabled={disabled}
>
{children}
</button>
<div ref={menuRef} id={menuId} className="playlist-menu" popover="auto" role="menu" aria-label={menuLabel} onBeforeToggle={place} onToggle={onToggle} onKeyDown={onKeyDown}>
{actions.map(({ icon: Icon, label, danger, run }) => (
<button
key={label}
type="button"
role="menuitem"
tabIndex={-1}
className={danger ? 'playlist-menu-item playlist-menu-danger' : 'playlist-menu-item'}
onClick={() => {
menuRef.current!.hidePopover()
run()
}}
>
<Icon size={16} strokeWidth={1.7} aria-hidden="true" />
{label}
</button>
))}
</div>
</>
)
}
+112
View File
@@ -0,0 +1,112 @@
import type { MyPlaylist } from '@/core/realtime'
import { FileUp, Import, Link } from 'lucide-react'
import { useRef, useState } from 'react'
import { Modal, notificationStore } from '@/shared/ui'
import { ActionMenu } from './action-menu'
import { importLimit, parsePlaylistFile } from './playlist-file'
import { playlistsStore } from './playlists-store'
import { defaultImportName } from './save-platform-playlist'
async function importFile(file: File): Promise<void> {
const content = await file.text().catch(() => null)
const parsed = content === null ? '没能读取这个文件。' : parsePlaylistFile(content)
if (typeof parsed === 'string') {
notificationStore.error(parsed)
return
}
const result = await playlistsStore.importFile(parsed.name, parsed.tracks)
if (!result)
return
const done = `已导入 ${result.imported} 首到“${result.playlist.name}”`
if (parsed.truncated)
notificationStore.success(`${done}。文件里超过 ${importLimit} 首,只导入了前 ${importLimit} 首。`, 6000)
else
notificationStore.success(done)
}
export function ImportMenu({ onOpen }: { onOpen: (playlist: MyPlaylist) => void }) {
const fileRef = useRef<HTMLInputElement>(null)
const [importing, setImporting] = useState(false)
const [linking, setLinking] = useState(false)
return (
<>
<ActionMenu
actions={[
{ icon: FileUp, label: '从文件导入', run: () => fileRef.current!.click() },
{ icon: Link, label: '从链接导入', run: () => setLinking(true) },
]}
menuLabel="导入歌单"
className="button-secondary"
disabled={importing}
>
<Import size={18} strokeWidth={1.7} aria-hidden="true" />
{importing ? '正在导入…' : '导入'}
</ActionMenu>
<input
ref={fileRef}
type="file"
accept=".json,application/json"
hidden
onChange={async (event) => {
const file = event.target.files?.[0]
// 清空之后,再选同一个文件也会触发 change。
event.target.value = ''
if (!file)
return
setImporting(true)
await importFile(file)
setImporting(false)
}}
/>
{linking && (
<ImportLinkDialog
onClose={() => setLinking(false)}
onImported={(playlist) => {
setLinking(false)
onOpen(playlist)
}}
/>
)}
</>
)
}
function ImportLinkDialog({ onClose, onImported }: { onClose: () => void, onImported: (playlist: MyPlaylist) => void }) {
const [link, setLink] = useState('')
const [name, setName] = useState(defaultImportName)
const [busy, setBusy] = useState(false)
return (
<Modal title="从链接导入" onClose={onClose}>
<form
className="form-stack"
onSubmit={async (event) => {
event.preventDefault()
if (busy)
return
setBusy(true)
const found = await playlistsStore.resolveLink(link)
const result = found && await playlistsStore.importPlatform(name, found.source, found.playlistId)
if (!result) {
setBusy(false)
return
}
notificationStore.success(`已导入 ${result.imported} 首`)
onImported(result.playlist)
}}
>
<label>
歌单链接
<input data-autofocus required placeholder="粘贴网易云音乐的歌单链接或分享文字" value={link} onChange={event => setLink(event.target.value)} />
</label>
<label>
歌单名
<input required maxLength={50} value={name} onChange={event => setName(event.target.value)} />
</label>
<div className="dialog-actions">
<button type="button" className="button-secondary" onClick={onClose}>取消</button>
<button type="submit" className="button-primary" disabled={busy || link.trim() === '' || name.trim() === ''}>{busy ? '正在导入…' : '导入'}</button>
</div>
</form>
</Modal>
)
}
+1
View File
@@ -3,6 +3,7 @@ import { lazyPage } from '@/shared/ui'
export { setAccountSettingsOpener } from './account-settings'
export { playlistsStore } from './playlists-store'
export { saveToPlaylistLabel } from './save-label'
export { SavePlatformPlaylistButton } from './save-platform-playlist'
export { SaveToPlaylistButton, SaveToPlaylistDialog } from './save-to-playlist'
export const PlaylistsPage = lazyPage(() => import('./playlists-page').then(module => module.PlaylistsPage))
export const MyPlaylistPage = lazyPage(() => import('./my-playlist-page').then(module => module.MyPlaylistPage))
@@ -9,9 +9,10 @@ import { roomStore } from '@/core/room'
import { enqueue } from '@/features/queue'
import { bookTitle } from '@/shared/lib/book-title'
import { useStore } from '@/shared/lib/use-store'
import { EmptyState, Modal, Skeleton } from '@/shared/ui'
import { EmptyState, Modal, notificationStore, Skeleton } from '@/shared/ui'
import { FavoritesMark, PlaylistCover } from './playlist-cover'
import { formatPlaylistDate } from './playlist-date'
import { exportFileName, exportPlaylist } from './playlist-file'
import { PlaylistMenu } from './playlist-menu'
import { PlaylistTracks } from './playlist-tracks'
import { playlistsStore, trackKey } from './playlists-store'
@@ -27,6 +28,23 @@ function keysOf(items: readonly MyPlaylistItem[]): string[] {
return items.map(item => trackKey(item.track))
}
// 导出服务端的曲目:页面上的曲目可能还没读到,或者带着没同步完的移动和移出。
async function exportToFile(playlist: MyPlaylist): Promise<void> {
try {
const { items } = await api.listMyPlaylistItems(playlist.id)
const url = URL.createObjectURL(new Blob([exportPlaylist(playlist.name, items, Date.now())], { type: 'application/json' }))
const link = document.createElement('a')
link.href = url
link.download = exportFileName(playlist.name)
link.click()
// 下载在 click() 时已经开始,到下一个任务再释放对象 URL。
setTimeout(() => URL.revokeObjectURL(url))
}
catch (error) {
notificationStore.error(`没能导出:${errorMessage(error)}`)
}
}
export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void }) {
const playlist = useStore(playlistsStore, s => s.playlists?.find(p => p.id === id) ?? null)
const listed = useStore(playlistsStore, s => s.playlists !== null)
@@ -80,8 +98,7 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [selecting])
const [now] = useState(Date.now)
const name = playlist?.name ?? '歌单'
const now = useStore(playlistsStore, s => s.loadedAt)
// 读到了自己的歌单,里面却没有它:在别处删掉了,或者换了账号。
const gone = listed && !playlist
// 读不到时不再显示之前的曲目:它们可能已经不在了,也可能属于之前的账号。
@@ -211,7 +228,7 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
<PlaylistCover cover={playlist?.coverUrl ?? null} favorites={playlist?.favorites ?? false} size="lg" />
<div className="my-playlist-info">
<h1 id="my-playlist-title" className="my-playlist-name">
<span>{name}</span>
{playlist || listed ? <span>{playlist?.name ?? '歌单'}</span> : <span className="my-playlist-name-pending" role="img" aria-label="正在加载" />}
{playlist?.favorites && <FavoritesMark size={22} />}
</h1>
{playlist && (
@@ -225,7 +242,7 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
{selecting ? '完成' : '选择'}
</button>
)}
{playlist && !playlist.favorites && <PlaylistMenu playlist={playlist} align="start" className="my-playlist-menu" onDeleted={onBack} />}
{playlist && <PlaylistMenu playlist={playlist} align="start" className="my-playlist-menu" onDeleted={onBack} onExport={() => void exportToFile(playlist)} />}
</div>
</div>
</header>
@@ -0,0 +1,108 @@
import type { MyPlaylistItem } from '@/core/realtime'
import { describe, expect, it } from 'vitest'
import { exportFileName, exportPlaylist, importLimit, parsePlaylistFile } from './playlist-file'
const sunny: MyPlaylistItem = {
track: { source: 'netease', id: '186016' },
meta: { title: '晴天', artist: '周杰伦', album: '叶惠美', coverUrl: 'https://p1.music.126.net/a.jpg', pageUrl: 'https://music.163.com/#/song?id=186016', durationMs: 269000 },
}
const clip: MyPlaylistItem = {
track: { source: 'link', id: 'https://example.com/a.mp3' },
meta: { title: 'a.mp3', artist: '', album: '', coverUrl: '', pageUrl: 'https://example.com/a.mp3', durationMs: 0 },
}
function file(tracks: unknown, extra: Record<string, unknown> = {}): string {
return JSON.stringify({ format: 'alisten.playlist', name: '通勤', exportedAt: '2026-11-09T12:00:00.000Z', tracks, ...extra })
}
describe('exportPlaylist', () => {
it('按设计的格式导出,曲目保持歌单里的先后', () => {
const exported = JSON.parse(exportPlaylist('通勤', [sunny, clip], Date.UTC(2026, 10, 9, 12)))
expect(exported).toEqual({
format: 'alisten.playlist',
name: '通勤',
exportedAt: '2026-11-09T12:00:00.000Z',
tracks: [
{ source: 'netease', id: '186016', title: '晴天', artist: '周杰伦', album: '叶惠美', durationMs: 269000, coverUrl: 'https://p1.music.126.net/a.jpg', pageUrl: 'https://music.163.com/#/song?id=186016' },
{ source: 'link', id: 'https://example.com/a.mp3', title: 'a.mp3', artist: '', album: '', durationMs: 0, coverUrl: '', pageUrl: 'https://example.com/a.mp3' },
],
})
})
it('导出的文件能原样读回', () => {
expect(parsePlaylistFile(exportPlaylist('通勤', [sunny, clip], 0))).toEqual({
name: '通勤',
truncated: false,
tracks: [
{ track: sunny.track, title: '晴天', artist: '周杰伦', album: '叶惠美', durationMs: 269000, coverUrl: 'https://p1.music.126.net/a.jpg', pageUrl: 'https://music.163.com/#/song?id=186016' },
{ track: clip.track, title: 'a.mp3', artist: '', album: '', durationMs: 0, coverUrl: '', pageUrl: 'https://example.com/a.mp3' },
],
})
})
})
describe('exportFileName', () => {
it('用歌单名作文件名', () => {
expect(exportFileName('我喜欢')).toBe('我喜欢.json')
})
it('替换文件名里不能用的字符', () => {
expect(exportFileName('a/b\\c:d*e?f"g<h>i|j\tk\u007Fl')).toBe('a_b_c_d_e_f_g_h_i_j_k_l.json')
})
})
describe('parsePlaylistFile', () => {
const notPlaylistFile = '这个文件不是导出的歌单。'
const cases: { name: string, content: string, want: string }[] = [
{ name: '不是 JSON', content: '晴天', want: notPlaylistFile },
{ name: '不是对象', content: '[]', want: notPlaylistFile },
{ name: '没有 format', content: JSON.stringify({ name: '通勤', tracks: [] }), want: notPlaylistFile },
{ name: 'format 不对', content: file([], { format: 'alisten.history' }), want: notPlaylistFile },
{ name: 'tracks 不是数组', content: file({ 0: sunny.track }), want: notPlaylistFile },
{ name: '空数组', content: file([]), want: '这个歌单文件里没有歌。' },
{ name: '缺少音源', content: file([{ id: '186016', title: '晴天' }]), want: '文件里的第 1 首歌缺少音源或 ID,没有导入。' },
{ name: '音源不认识', content: file([{ source: 'netease', id: '1' }, { source: 'spotify', id: '2' }]), want: '文件里的第 2 首歌缺少音源或 ID,没有导入。' },
{ name: '音源是原型上的属性名', content: file([{ source: 'toString', id: '1' }]), want: '文件里的第 1 首歌缺少音源或 ID,没有导入。' },
{ name: 'ID 为空', content: file([{ source: 'qq', id: '' }]), want: '文件里的第 1 首歌缺少音源或 ID,没有导入。' },
{ name: 'ID 不是字符串', content: file([{ source: 'netease', id: 186016 }]), want: '文件里的第 1 首歌缺少音源或 ID,没有导入。' },
{ name: '曲目不是对象', content: file([null]), want: '文件里的第 1 首歌缺少音源或 ID,没有导入。' },
]
for (const { name, content, want } of cases) {
it(name, () => {
expect(parsePlaylistFile(content)).toBe(want)
})
}
it('元数据类型不对时当作没有', () => {
const parsed = parsePlaylistFile(file([{ source: 'qq', id: '0039MnYb0qxYhV', title: 7, artist: null, album: ['叶惠美'], durationMs: '269000' }]))
expect(parsed).toEqual({ name: '通勤', truncated: false, tracks: [{ track: { source: 'qq', id: '0039MnYb0qxYhV' }, title: '', durationMs: 0 }] })
})
it('时长不是 0 到 24 小时之间的整数毫秒时当作未知', () => {
const durations = [-1, 1.5, 86_400_001, 86_400_000]
const parsed = parsePlaylistFile(file(durations.map((durationMs, index) => ({ source: 'netease', id: String(index), title: '晴天', durationMs }))))
expect(typeof parsed === 'string' ? parsed : parsed.tracks.map(track => track.durationMs)).toEqual([0, 0, 0, 86_400_000])
})
it('歌单名缺少或不是字符串时为空,由服务端换成默认的名字', () => {
const parsed = parsePlaylistFile(file([sunny.track], { name: 42 }))
expect(typeof parsed === 'string' ? parsed : parsed.name).toBe('')
})
it('超过 1000 首只取前 1000 首,后面的不检查', () => {
const tracks: unknown[] = Array.from({ length: importLimit }, (_, index) => ({ source: 'netease', id: String(index), title: `第 ${index + 1} 首` }))
tracks.push({ source: 'spotify', id: 'x' })
const parsed = parsePlaylistFile(file(tracks))
if (typeof parsed === 'string')
throw new Error(parsed)
expect(parsed.truncated).toBe(true)
expect(parsed.tracks).toHaveLength(importLimit)
expect(parsed.tracks.at(-1)?.track).toEqual({ source: 'netease', id: String(importLimit - 1) })
})
it('正好 1000 首不算截断', () => {
const tracks = Array.from({ length: importLimit }, (_, index) => ({ source: 'netease', id: String(index), title: '晴天' }))
const parsed = parsePlaylistFile(file(tracks))
expect(typeof parsed === 'string' ? parsed : parsed.truncated).toBe(false)
})
})
@@ -0,0 +1,82 @@
import type { ImportedTrack, MyPlaylistItem, Source } from '@/core/realtime'
const format = 'alisten.playlist'
export const importLimit = 1000
const maxDurationMs = 86_400_000
const sources: ReadonlySet<string> = new Set<Source>(['netease', 'qq', 'bilibili', 'link'])
const notPlaylistFile = '这个文件不是导出的歌单。'
export interface PlaylistFile {
name: string
tracks: ImportedTrack[]
// 文件里超过 importLimit 首,只取了前面的。
truncated: boolean
}
export function exportPlaylist(name: string, items: readonly MyPlaylistItem[], now: number): string {
const tracks = items.map(({ track, meta }) => ({
source: track.source,
id: track.id,
title: meta.title,
artist: meta.artist,
album: meta.album,
durationMs: meta.durationMs,
coverUrl: meta.coverUrl,
pageUrl: meta.pageUrl,
}))
return JSON.stringify({ format, name, exportedAt: new Date(now).toISOString(), tracks }, null, 2)
}
// Windows 的文件名不能含 \ / : * ? " < > | 和控制字符,其他系统也不能含 /。
export function exportFileName(name: string): string {
return `${name.replace(/[\\/:*?"<>|\p{Cc}]/gu, '_')}.json`
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function text(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined
}
// 文件可以随意改写:元数据的类型不对时当作没有,由服务端决定用不用;音源和 ID 不对就认不出是哪首歌,整个文件不导入。
function importedTrack(entry: unknown): ImportedTrack | null {
if (!isRecord(entry) || typeof entry.source !== 'string' || !sources.has(entry.source) || typeof entry.id !== 'string' || entry.id === '')
return null
const { durationMs } = entry
const imported: ImportedTrack = {
track: { source: entry.source as Source, id: entry.id },
title: text(entry.title) ?? '',
durationMs: typeof durationMs === 'number' && Number.isInteger(durationMs) && durationMs >= 0 && durationMs <= maxDurationMs ? durationMs : 0,
}
for (const key of ['artist', 'album', 'coverUrl', 'pageUrl'] as const) {
const value = text(entry[key])
if (value !== undefined)
imported[key] = value
}
return imported
}
// 返回读到的歌单,或者给用户看的错误信息。
export function parsePlaylistFile(content: string): PlaylistFile | string {
let data: unknown
try {
data = JSON.parse(content)
}
catch {
return notPlaylistFile
}
if (!isRecord(data) || data.format !== format || !Array.isArray(data.tracks))
return notPlaylistFile
if (data.tracks.length === 0)
return '这个歌单文件里没有歌。'
const tracks: ImportedTrack[] = []
for (const [index, entry] of data.tracks.slice(0, importLimit).entries()) {
const track = importedTrack(entry)
if (!track)
return `文件里的第 ${index + 1} 首歌缺少音源或 ID,没有导入。`
tracks.push(track)
}
return { name: text(data.name) ?? '', tracks, truncated: data.tracks.length > importLimit }
}
+19 -99
View File
@@ -1,118 +1,38 @@
import type { KeyboardEvent, ToggleEvent } from 'react'
import type { MenuAction } from './action-menu'
import type { MyPlaylist } from '@/core/realtime'
import { Ellipsis, Pencil, Trash2 } from 'lucide-react'
import { useEffect, useId, useRef, useState } from 'react'
import { Ellipsis, FileDown, Pencil, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { Modal } from '@/shared/ui'
import { ActionMenu } from './action-menu'
import { playlistsStore } from './playlists-store'
const menuGap = 4
const viewportMargin = 8
const menuKeys: Record<string, (index: number, count: number) => number> = {
ArrowDown: (index, count) => (index + 1) % count,
ArrowUp: (index, count) => (index - 1 + count) % count,
Home: () => 0,
End: (_, count) => count - 1,
}
export function PlaylistMenu({ playlist, align = 'end', className = '', onDeleted }: {
export function PlaylistMenu({ playlist, align = 'end', className = '', onDeleted, onExport }: {
playlist: MyPlaylist
align?: 'start' | 'end'
className?: string
onDeleted?: () => void
onExport?: () => void
}) {
const menuId = useId()
const buttonRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [open, setOpen] = useState(false)
const [dialog, setDialog] = useState<'rename' | 'delete' | null>(null)
// 菜单在顶层按视口定位,不跟随页面滚动,所以滚动和缩放窗口时收起。
useEffect(() => {
if (!open)
return
const close = () => menuRef.current?.hidePopover()
document.addEventListener('scroll', close, true)
window.addEventListener('resize', close)
return () => {
document.removeEventListener('scroll', close, true)
window.removeEventListener('resize', close)
}
}, [open])
const items = () => [...menuRef.current!.querySelectorAll<HTMLElement>('[role="menuitem"]')]
// 显示之前量不到菜单的尺寸:先贴着按钮下方对齐,显示之后超出视口再翻到按钮上方、收进视口。
const place = (event: ToggleEvent<HTMLDivElement>) => {
if (event.newState !== 'open')
return
const button = buttonRef.current!.getBoundingClientRect()
const { style } = event.currentTarget
style.top = `${button.bottom + menuGap}px`
style.left = align === 'start' ? `${button.left}px` : 'auto'
style.right = align === 'end' ? `${document.documentElement.clientWidth - button.right}px` : 'auto'
}
const onToggle = (event: ToggleEvent<HTMLDivElement>) => {
const opened = event.newState === 'open'
setOpen(opened)
if (!opened)
return
const menu = event.currentTarget
const button = buttonRef.current!.getBoundingClientRect()
const rect = menu.getBoundingClientRect()
if (rect.bottom > window.innerHeight - viewportMargin)
menu.style.top = `${Math.max(viewportMargin, button.top - menuGap - rect.height)}px`
const left = Math.max(viewportMargin, Math.min(rect.left, document.documentElement.clientWidth - viewportMargin - rect.width))
if (left !== rect.left) {
menu.style.left = `${left}px`
menu.style.right = 'auto'
}
items()[0]?.focus()
}
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Tab') {
event.currentTarget.hidePopover()
return
}
const move = menuKeys[event.key]
if (!move)
return
event.preventDefault()
const list = items()
list[move(list.indexOf(document.activeElement as HTMLElement), list.length)]?.focus()
}
const choose = (next: 'rename' | 'delete') => {
menuRef.current!.hidePopover()
setDialog(next)
}
const actions: MenuAction[] = []
if (!playlist.favorites)
actions.push({ icon: Pencil, label: '重命名', run: () => setDialog('rename') })
if (onExport)
actions.push({ icon: FileDown, label: '导出', run: onExport })
if (!playlist.favorites)
actions.push({ icon: Trash2, label: '删除', danger: true, run: () => setDialog('delete') })
return (
<>
<button
ref={buttonRef}
type="button"
<ActionMenu
actions={actions}
menuLabel={`“${playlist.name}”的操作`}
align={align}
className={`icon-button playlist-menu-button ${className}`}
popoverTarget={menuId}
aria-haspopup="menu"
aria-expanded={open}
aria-label={`“${playlist.name}”的更多操作`}
label={`“${playlist.name}”的更多操作`}
title="更多操作"
>
<Ellipsis size={20} strokeWidth={1.7} aria-hidden="true" />
</button>
<div ref={menuRef} id={menuId} className="playlist-menu" popover="auto" role="menu" aria-label={`“${playlist.name}”的操作`} onBeforeToggle={place} onToggle={onToggle} onKeyDown={onKeyDown}>
<button type="button" role="menuitem" tabIndex={-1} className="playlist-menu-item" onClick={() => choose('rename')}>
<Pencil size={16} strokeWidth={1.7} aria-hidden="true" />
重命名
</button>
<button type="button" role="menuitem" tabIndex={-1} className="playlist-menu-item playlist-menu-danger" onClick={() => choose('delete')}>
<Trash2 size={16} strokeWidth={1.7} aria-hidden="true" />
删除
</button>
</div>
</ActionMenu>
{dialog === 'rename' && <RenameDialog playlist={playlist} onClose={() => setDialog(null)} />}
{dialog === 'delete' && <DeleteDialog playlist={playlist} onClose={() => setDialog(null)} onDeleted={onDeleted} />}
</>
+44 -19
View File
@@ -3,12 +3,13 @@ import type { PlaylistSort, SortKey, SortOrder } from './playlist-sort'
import type { ViewPreference } from './view-preference'
import type { MyPlaylist } from '@/core/realtime'
import { ArrowDown, ArrowDownAZ, ArrowDownNarrowWide, ArrowDownWideNarrow, ArrowDownZA, ArrowUp, LayoutGrid, List, Plus, Star } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { errorMessage } from '@/core/errors'
import { profileStore } from '@/features/settings'
import { useStore } from '@/shared/lib/use-store'
import { EmptyState, IconButton, Modal, notificationStore, Select, Skeleton } from '@/shared/ui'
import { CreatePlaylistForm } from './create-playlist-form'
import { ImportMenu } from './import-menu'
import { FavoritesMark, PlaylistCover } from './playlist-cover'
import { formatPlaylistDate } from './playlist-date'
import { PlaylistMenu } from './playlist-menu'
@@ -48,11 +49,14 @@ export function PlaylistsPage({ onOpen, onRegister }: { onOpen: (playlist: MyPla
const member = useStore(profileStore, s => s.member)
const account = useStore(profileStore, s => s.account)
const playlists = useStore(playlistsStore, s => s.playlists)
const failed = useStore(playlistsStore, s => s.failed)
const error = useStore(playlistsStore, s => s.error)
const profileFailed = useStore(profileStore, s => s.failed)
const [preference, setPreference] = useState(loadViewPreference)
const [creating, setCreating] = useState(false)
const [now] = useState(Date.now)
// 删除歌单之后要获得焦点的歌单(null 表示标题);每次删除换一个新对象,让同一个目标也能再次触发。
const [refocus, setRefocus] = useState<{ id: string | null } | null>(null)
const titleRef = useRef<HTMLHeadingElement>(null)
const now = useStore(playlistsStore, s => s.loadedAt)
useEffect(() => {
if (!member)
loadProfile()
@@ -61,7 +65,19 @@ export function PlaylistsPage({ onOpen, onRegister }: { onOpen: (playlist: MyPla
if (account)
void playlistsStore.load()
}, [account])
useEffect(() => {
if (!refocus)
return
const row = refocus.id === null ? null : document.querySelector<HTMLElement>(`[data-playlist-open="${CSS.escape(refocus.id)}"]`)
const target = row ?? titleRef.current
target?.focus()
}, [refocus])
const { view, sort } = preference
const onDeleted = (deleted: MyPlaylist) => {
const rows = sortPlaylists(playlists ?? [], sort)
const at = rows.findIndex(p => p.id === deleted.id)
setRefocus({ id: (rows[at + 1] ?? rows[at - 1])?.id ?? null })
}
const update = (next: ViewPreference) => {
saveViewPreference(next)
setPreference(next)
@@ -82,21 +98,26 @@ export function PlaylistsPage({ onOpen, onRegister }: { onOpen: (playlist: MyPla
)
}
else if (playlists === null) {
body = failed
? <button type="button" className="button-secondary" onClick={() => void playlistsStore.load()}>重试</button>
: <Skeleton rows={4} />
body = error === null
? <Skeleton rows={4} />
: (
<div className="playlists-error">
<p className="text-ui-base muted">{`读取失败:${error}`}</p>
<button type="button" className="button-secondary" onClick={() => void playlistsStore.load()}>重试</button>
</div>
)
}
else {
const sorted = sortPlaylists(playlists, sort)
body = view === 'list'
? <PlaylistTable playlists={sorted} sort={sort} now={now} onSort={sortBy} onOpen={onOpen} />
: <PlaylistGrid playlists={sorted} now={now} onOpen={onOpen} />
? <PlaylistTable playlists={sorted} sort={sort} now={now} onSort={sortBy} onOpen={onOpen} onDeleted={onDeleted} />
: <PlaylistGrid playlists={sorted} now={now} onOpen={onOpen} onDeleted={onDeleted} />
}
return (
<section className="page-content playlists-page" aria-labelledby="playlists-title">
<header className="playlists-toolbar">
<div className="playlists-heading">
<h1 id="playlists-title" className="text-ui-xl">我的歌单</h1>
<h1 ref={titleRef} id="playlists-title" tabIndex={-1} className="text-ui-xl">我的歌单</h1>
{shown && <p className="text-ui-sm muted">{`${shown.length} 个歌单`}</p>}
</div>
{shown && (
@@ -125,10 +146,13 @@ export function PlaylistsPage({ onOpen, onRegister }: { onOpen: (playlist: MyPla
/>
</div>
</div>
<button type="button" className="button-primary playlists-create" onClick={() => setCreating(true)}>
<Plus size={18} strokeWidth={2} aria-hidden="true" />
新建歌单
</button>
<div className="playlists-actions">
<ImportMenu onOpen={onOpen} />
<button type="button" className="button-primary" onClick={() => setCreating(true)}>
<Plus size={18} strokeWidth={2} aria-hidden="true" />
新建歌单
</button>
</div>
</>
)}
</header>
@@ -138,12 +162,13 @@ export function PlaylistsPage({ onOpen, onRegister }: { onOpen: (playlist: MyPla
)
}
function PlaylistTable({ playlists, sort, now, onSort, onOpen }: {
function PlaylistTable({ playlists, sort, now, onSort, onOpen, onDeleted }: {
playlists: MyPlaylist[]
sort: PlaylistSort
now: number
onSort: (key: SortKey) => void
onOpen: (playlist: MyPlaylist) => void
onDeleted: (playlist: MyPlaylist) => void
}) {
return (
<div className="playlist-table" role="table" aria-label="我的歌单">
@@ -168,7 +193,7 @@ function PlaylistTable({ playlists, sort, now, onSort, onOpen }: {
{playlists.map(playlist => (
<div key={playlist.id} className="playlist-table-row playlist-row" role="row">
<div className="playlist-col-name" role="cell">
<button type="button" className="playlist-row-open" onClick={() => onOpen(playlist)}>
<button type="button" className="playlist-row-open" data-playlist-open={playlist.id} onClick={() => onOpen(playlist)}>
<PlaylistCover cover={playlist.coverUrl} favorites={playlist.favorites} size="sm" />
<span className="playlist-row-copy">
<span className="playlist-row-title">
@@ -183,7 +208,7 @@ function PlaylistTable({ playlists, sort, now, onSort, onOpen }: {
<div className="playlist-col-updatedAt text-ui-sm muted" role="cell">{formatPlaylistDate(playlist.updatedAtMs, now)}</div>
<div className="playlist-col-createdAt text-ui-sm muted" role="cell">{formatPlaylistDate(playlist.createdAtMs, now)}</div>
<div className="playlist-col-menu" role="cell">
{!playlist.favorites && <PlaylistMenu playlist={playlist} />}
{!playlist.favorites && <PlaylistMenu playlist={playlist} onDeleted={() => onDeleted(playlist)} />}
</div>
</div>
))}
@@ -192,19 +217,19 @@ function PlaylistTable({ playlists, sort, now, onSort, onOpen }: {
)
}
function PlaylistGrid({ playlists, now, onOpen }: { playlists: MyPlaylist[], now: number, onOpen: (playlist: MyPlaylist) => void }) {
function PlaylistGrid({ playlists, now, onOpen, onDeleted }: { playlists: MyPlaylist[], now: number, onOpen: (playlist: MyPlaylist) => void, onDeleted: (playlist: MyPlaylist) => void }) {
return (
<ul className="playlist-grid">
{playlists.map(playlist => (
<li key={playlist.id} className="playlist-card">
<button type="button" className="playlist-card-open" onClick={() => onOpen(playlist)}>
<button type="button" className="playlist-card-open" data-playlist-open={playlist.id} onClick={() => onOpen(playlist)}>
<span className="playlist-card-art">
<PlaylistCover cover={playlist.coverUrl} favorites={playlist.favorites} size="md" />
</span>
<strong className="text-ui-base truncate">{playlist.name}</strong>
<span className="text-ui-sm muted truncate">{`${playlist.trackCount} 首 · ${formatPlaylistDate(playlist.updatedAtMs, now)}`}</span>
</button>
{!playlist.favorites && <PlaylistMenu playlist={playlist} className="playlist-card-menu" />}
{!playlist.favorites && <PlaylistMenu playlist={playlist} className="playlist-card-menu" onDeleted={() => onDeleted(playlist)} />}
</li>
))}
</ul>
@@ -33,6 +33,8 @@ const meta = { title: '晴天', artist: '周杰伦', album: '叶惠美', coverUr
const notReady = '这首歌还在准备,准备好之后再保存'
const full = '一个歌单最多 1000 首歌'
const changed = '歌单已经变了,刷新之后再试'
const imported = { id: 'p3', name: '导入的歌单', favorites: false, trackCount: 2, createdAtMs: 2, updatedAtMs: 2, coverUrl: null }
const fileTrack = { track: sunny, title: '晴天', durationMs: 269000 }
let requests: string[]
let release: (() => void)[]
@@ -40,6 +42,7 @@ let failRemove: boolean
let failAdd: boolean
let failCopy: boolean
let failMove: boolean
let importFailure: { status: number, code: string, message: string } | null
let bodies: unknown[]
let holdReads: boolean
let failReads: boolean
@@ -80,6 +83,7 @@ beforeEach(() => {
failAdd = false
failCopy = false
failMove = false
importFailure = null
bodies = []
holdReads = false
failReads = false
@@ -110,6 +114,14 @@ beforeEach(() => {
}
if (init.body)
bodies.push(JSON.parse(String(init.body)))
if (importFailure && /^POST \/api\/me\/(?:playlists\/(?:imports|platform-imports)|playlist-links)$/.test(request))
return json(importFailure.status, { error: { code: importFailure.code, message: importFailure.message } })
if (request === 'POST /api/me/playlists/imports' || request === 'POST /api/me/playlists/platform-imports') {
serverPlaylists.push(imported)
return json(201, { playlist: imported, imported: 2 })
}
if (request === 'POST /api/me/playlist-links')
return json(200, { source: 'netease', playlistId: '3778678' })
const write = /^POST \/api\/me\/playlists\/(\w+)\/(items|removals|copies|moves)$/.exec(request)
if (write?.[2] === 'items') {
if (failAdd)
@@ -184,6 +196,23 @@ describe('playlistsStore', () => {
expect(requests).toEqual(['GET /api/me/playlists'])
})
it('每次读到歌单都记下读取的时间,相对日期以它为准', async () => {
vi.useFakeTimers({ toFake: ['Date'] })
try {
vi.setSystemTime(new Date('2026-01-01T23:59:00'))
const { playlistsStore } = await import('./playlists-store')
await playlistsStore.load()
const before = playlistsStore.state.loadedAt
vi.setSystemTime(new Date('2026-01-02T00:01:00'))
await playlistsStore.load()
expect(before).toBe(new Date('2026-01-01T23:59:00').getTime())
expect(playlistsStore.state.loadedAt).toBe(new Date('2026-01-02T00:01:00').getTime())
}
finally {
vi.useRealTimers()
}
})
it('containing 读取包含这首歌的歌单', async () => {
const { included } = await loaded()
expect(included(sunny)).toBe(true)
@@ -271,7 +300,7 @@ describe('playlistsStore', () => {
await done
expect(requests).toEqual(['POST /api/me/playlists/p1/removals', 'POST /api/me/playlist-memberships', 'GET /api/me/playlists'])
expect(included(sunny)).toBe(true)
expect(playlistsStore.state.failed).toBe(true)
expect(playlistsStore.state.error).toBe('暂时不可用')
})
it('读取期间正在加入的歌以本地为准', async () => {
@@ -442,6 +471,22 @@ describe('playlistsStore', () => {
expect(playlistsStore.state.playlists).toEqual([favorites])
})
it('换账号之后,上一个账号未完成的保存失败也不提示', async () => {
const { playlistsStore } = await import('./playlists-store')
const { profileStore } = await import('@/features/settings')
const { notificationStore } = await import('@/shared/ui')
playlistsStore.start()
await profileStore.registered({ username: 'alice' })
await flush()
failAdd = true
const done = playlistsStore.setMembership(favorites, rain, true)
await flush()
await profileStore.registered({ username: 'bob' })
release.shift()!()
await done
expect(notificationStore.state.notifications).toEqual([])
})
it('换账号之后,上一个账号读到的所在歌单作废', async () => {
const { playlistsStore } = await import('./playlists-store')
const { profileStore } = await import('@/features/settings')
@@ -486,4 +531,102 @@ describe('playlistsStore', () => {
await settle()
expect(playlistsStore.state.playlists).toEqual([favorites, commute])
})
it('importFile 导入成功后新歌单立刻出现在列表里,再重新读取', async () => {
const { playlistsStore } = await import('./playlists-store')
await playlistsStore.load()
requests = []
holdReads = true
const done = playlistsStore.importFile('通勤', [fileTrack])
await flush()
release.shift()!()
expect(await done).toEqual({ playlist: imported, imported: 2 })
expect(playlistsStore.state.playlists).toEqual([favorites, imported])
expect(bodies).toEqual([{ name: '通勤', tracks: [fileTrack] }])
holdReads = false
await settle()
expect(requests).toEqual(['POST /api/me/playlists/imports', 'GET /api/me/playlists'])
expect(playlistsStore.state.playlists).toEqual([favorites, imported])
})
it('importFile 遇到 INVALID_ARGUMENT 时说是文件的问题,不重新读取', async () => {
const { playlistsStore } = await import('./playlists-store')
const { notificationStore } = await import('@/shared/ui')
importFailure = { status: 400, code: 'INVALID_ARGUMENT', message: 'tracks[0].track.id:不是合法的链接' }
const done = playlistsStore.importFile('通勤', [fileTrack])
await settle()
expect(await done).toBeNull()
expect(requests).toEqual(['POST /api/me/playlists/imports'])
expect(notificationStore.state.notifications.map(n => n.message)).toEqual(['文件里有不符合要求的内容,没有导入。'])
})
it('importFile 超过歌单数上限时提示服务端的文案', async () => {
const { playlistsStore } = await import('./playlists-store')
const { notificationStore } = await import('@/shared/ui')
importFailure = { status: 409, code: 'CONFLICT', message: '最多只能建 50 个歌单' }
const done = playlistsStore.importFile('通勤', [fileTrack])
await settle()
expect(await done).toBeNull()
expect(notificationStore.state.notifications.map(n => n.message)).toEqual(['最多只能建 50 个歌单'])
})
it('resolveLink 返回音源和歌单 ID,不重新读取歌单', async () => {
const { playlistsStore } = await import('./playlists-store')
const link = '分享歌单:通勤 https://163cn.tv/abc'
const done = playlistsStore.resolveLink(link)
await settle()
expect(await done).toEqual({ source: 'netease', playlistId: '3778678' })
expect(requests).toEqual(['POST /api/me/playlist-links'])
expect(bodies).toEqual([{ link }])
})
it('resolveLink 认不出链接时提示服务端的文案', async () => {
const { playlistsStore } = await import('./playlists-store')
const { notificationStore } = await import('@/shared/ui')
importFailure = { status: 400, code: 'INVALID_ARGUMENT', message: '不是网易云或 QQ 音乐的歌单链接' }
const done = playlistsStore.resolveLink('https://example.com/')
await settle()
expect(await done).toBeNull()
expect(notificationStore.state.notifications.map(n => n.message)).toEqual(['不是网易云或 QQ 音乐的歌单链接'])
})
it('resolveLink 超过 2048 字节的文本不发出', async () => {
const { playlistsStore } = await import('./playlists-store')
const { notificationStore } = await import('@/shared/ui')
expect(await playlistsStore.resolveLink('歌'.repeat(683))).toBeNull()
expect(requests).toEqual([])
expect(notificationStore.state.notifications.map(n => n.message)).toEqual(['文字太长了,只粘贴歌单的链接或分享文字就好。'])
const done = playlistsStore.resolveLink('歌'.repeat(682))
await settle()
await done
expect(requests).toEqual(['POST /api/me/playlist-links'])
})
it('importPlatform 导入成功后新歌单立刻出现在列表里,再重新读取', async () => {
const { playlistsStore } = await import('./playlists-store')
holdReads = true
const done = playlistsStore.importPlatform('通勤', 'netease', '3778678')
await flush()
release.shift()!()
expect(await done).toEqual({ playlist: imported, imported: 2 })
expect(playlistsStore.state.playlists).toEqual([imported])
expect(bodies).toEqual([{ name: '通勤', source: 'netease', playlistId: '3778678' }])
holdReads = false
await settle()
expect(requests).toEqual(['POST /api/me/playlists/platform-imports', 'GET /api/me/playlists'])
expect(playlistsStore.state.playlists).toEqual([favorites, imported])
})
it('importPlatform 歌单名不合法时说明规则,其他错误提示服务端的文案', async () => {
const { playlistsStore } = await import('./playlists-store')
const { notificationStore } = await import('@/shared/ui')
importFailure = { status: 400, code: 'INVALID_ARGUMENT', message: '歌单名不合法' }
const first = playlistsStore.importPlatform(' ', 'netease', '3778678')
await settle()
importFailure = { status: 429, code: 'RATE_LIMITED', message: '导入太频繁了,稍后再试' }
const second = playlistsStore.importPlatform('通勤', 'netease', '3778678')
await settle()
expect([await first, await second]).toEqual([null, null])
expect(notificationStore.state.notifications.map(n => n.message)).toEqual(['歌单名要 1 到 50 个字。', '导入太频繁了,稍后再试'])
})
})
+64 -11
View File
@@ -1,4 +1,6 @@
import type { MyPlaylist, Track } from '@/core/realtime'
import type { ImportMyPlatformPlaylistResponse, ImportMyPlaylistResponse, ResolveMyPlaylistLinkResponse } from '@/core/api'
import type { ErrorTexts } from '@/core/errors'
import type { ImportedTrack, MyPlaylist, Source, Track } from '@/core/realtime'
import { api } from '@/core/api'
import { errorMessage } from '@/core/errors'
import { profileStore } from '@/features/settings'
@@ -16,14 +18,21 @@ function membershipKey(playlistId: string, track: Track): string {
interface PlaylistsState {
// 游客,或者还没有读到时为 null。
playlists: MyPlaylist[] | null
failed: boolean
// 最近一次读取失败的原因,成功读取后为 null。
error: string | null
// 歌单最近一次读取成功的时间,相对日期(今天、昨天)以它为准:窗口重新获得焦点会重新读取,跨过午夜后标签随之更新。
loadedAt: number
// 曲目 → 包含它的歌单 id,只有读过的曲目才有。正在同步的歌单是最后一次勾选的结果,同步负责把服务端改成一致。
memberships: ReadonlyMap<string, ReadonlySet<string>>
}
const empty: PlaylistsState = { playlists: null, failed: false, memberships: new Map() }
const empty: PlaylistsState = { playlists: null, error: null, loadedAt: 0, memberships: new Map() }
const nameTexts = { INVALID_ARGUMENT: '歌单名要 1 到 50 个字。' }
const fileTexts = { INVALID_ARGUMENT: '文件里有不符合要求的内容,没有导入。' }
// 接口只收 1 到 2048 字节的文本:太长的这里不发出,对话框不提交空的,INVALID_ARGUMENT 就只剩认不出链接,这时的 message 是写给用户的。
const maxLinkBytes = 2048
const linkTexts: ErrorTexts = { INVALID_ARGUMENT: message => message }
class PlaylistsStore extends Store<PlaylistsState> {
#account: string | null = null
@@ -42,7 +51,7 @@ class PlaylistsStore extends Store<PlaylistsState> {
this.#onProfile()
}
// 失败时只设置 failed,不抛出。
// 失败时只设置 error,不抛出。
load(): Promise<void> {
return this.#load(++this.#generation)
}
@@ -91,8 +100,7 @@ class PlaylistsStore extends Store<PlaylistsState> {
async create(name: string): Promise<MyPlaylist | null> {
try {
const playlist = await api.createMyPlaylist({ name })
this.setState({ playlists: [...(this.state.playlists ?? []), playlist] })
void this.load()
this.#added(playlist)
return playlist
}
catch (error) {
@@ -101,6 +109,44 @@ class PlaylistsStore extends Store<PlaylistsState> {
}
}
async importFile(name: string, tracks: ImportedTrack[]): Promise<ImportMyPlaylistResponse | null> {
try {
const imported = await api.importMyPlaylist({ name, tracks })
this.#added(imported.playlist)
return imported
}
catch (error) {
notificationStore.error(errorMessage(error, fileTexts))
return null
}
}
async resolveLink(link: string): Promise<ResolveMyPlaylistLinkResponse | null> {
if (new TextEncoder().encode(link).length > maxLinkBytes) {
notificationStore.error('文字太长了,只粘贴歌单的链接或分享文字就好。')
return null
}
try {
return await api.resolveMyPlaylistLink({ link })
}
catch (error) {
notificationStore.error(errorMessage(error, linkTexts))
return null
}
}
async importPlatform(name: string, source: Source, playlistId: string): Promise<ImportMyPlatformPlaylistResponse | null> {
try {
const imported = await api.importMyPlatformPlaylist({ name, source, playlistId })
this.#added(imported.playlist)
return imported
}
catch (error) {
notificationStore.error(errorMessage(error, nameTexts))
return null
}
}
async rename(playlist: MyPlaylist, name: string): Promise<boolean> {
try {
await api.renameMyPlaylist(playlist.id, { name })
@@ -164,6 +210,12 @@ class PlaylistsStore extends Store<PlaylistsState> {
}
}
// 先放进列表再重新读取:打开新歌单时,列表里要已经有它,否则详情页会以为它被删掉了。
#added(playlist: MyPlaylist): void {
this.setState({ playlists: [...(this.state.playlists ?? []), playlist] })
void this.load()
}
// 请求进行中又改了勾选时,等它完成之后按最新的勾选再发;失败时恢复成服务端的状态,不再发出。
async #sync(playlistId: string, track: Track, saved: boolean): Promise<void> {
const account = this.#account
@@ -177,8 +229,9 @@ class PlaylistsStore extends Store<PlaylistsState> {
saved = included
}
catch (error) {
if (this.#account === account)
this.#setIncluded(playlistId, track, saved)
if (this.#account !== account)
return
this.#setIncluded(playlistId, track, saved)
notificationStore.error(errorMessage(error))
return
}
@@ -189,11 +242,11 @@ class PlaylistsStore extends Store<PlaylistsState> {
try {
const { items } = await api.listMyPlaylists()
if (generation === this.#generation)
this.setState({ playlists: items, failed: false })
this.setState({ playlists: items, error: null, loadedAt: Date.now() })
}
catch {
catch (error) {
if (generation === this.#generation)
this.setState({ failed: true })
this.setState({ error: errorMessage(error) })
}
}
+3 -1
View File
@@ -45,9 +45,10 @@
.playlists-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; grid-template-areas: 'heading controls create'; align-items: end; gap: 12px; margin-bottom: 28px; }
.playlists-heading { grid-area: heading; min-width: 0; }
.playlists-heading h1 { line-height: 1.25; }
.playlists-error { display: flex; flex-direction: column; align-items: flex-start; gap: 12px; }
.playlists-heading p { margin-top: 4px; }
.playlists-controls { grid-area: controls; display: flex; align-items: center; gap: 8px; }
.playlists-create { grid-area: create; }
.playlists-actions { grid-area: create; display: flex; gap: 8px; }
.playlists-view-switch { display: flex; gap: 2px; padding: 2px; border: 1px solid var(--color-border); border-radius: 8px; }
.playlists-view-switch .icon-button { width: 32px; height: 30px; border-radius: 6px; color: var(--color-foreground-subtle); }
.playlists-view-switch .icon-button svg { width: 18px; height: 18px; }
@@ -106,6 +107,7 @@
.my-playlist-header { isolation: isolate; display: flex; align-items: flex-end; gap: 28px; margin-bottom: 16px; padding-bottom: 28px; border-bottom: 1px solid var(--color-border); }
.my-playlist-info { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; min-width: 0; }
.my-playlist-name { display: flex; align-items: center; gap: 10px; max-width: 100%; font-size: calc(var(--text-ui-xl) + 8px); font-weight: 700; line-height: 1.2; }
.my-playlist-name > span.my-playlist-name-pending { display: block; width: 12em; max-width: 100%; height: 0.9em; border-radius: 6px; background: var(--color-surface-hover); }
.my-playlist-name > span { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow-wrap: anywhere; }
.my-playlist-actions { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
.my-playlist-select { min-width: 64px; border-radius: 999px; }
@@ -0,0 +1,62 @@
import type { Source } from '@/core/realtime'
import { ListPlus } from 'lucide-react'
import { useState } from 'react'
import { profileStore } from '@/features/settings'
import { useStore } from '@/shared/lib/use-store'
import { Modal, notificationStore } from '@/shared/ui'
import { AccountPrompt } from './account-prompt'
import { playlistsStore } from './playlists-store'
export const defaultImportName = '导入的歌单'
export function SavePlatformPlaylistButton({ source, playlistId, name }: { source: Source, playlistId: string, name?: string }) {
const [open, setOpen] = useState(false)
return (
<>
<button type="button" className="button-secondary" onClick={() => setOpen(true)}>
<ListPlus size={18} strokeWidth={1.7} aria-hidden="true" />
保存为我的歌单
</button>
{open && <SavePlatformPlaylistDialog source={source} playlistId={playlistId} name={name ?? defaultImportName} onClose={() => setOpen(false)} />}
</>
)
}
function SavePlatformPlaylistDialog({ source, playlistId, name: original, onClose }: { source: Source, playlistId: string, name: string, onClose: () => void }) {
const account = useStore(profileStore, s => s.account)
const [name, setName] = useState(original)
const [busy, setBusy] = useState(false)
return (
<Modal title="保存为我的歌单" onClose={onClose}>
{account
? (
<form
className="form-stack"
onSubmit={async (event) => {
event.preventDefault()
if (busy)
return
setBusy(true)
const result = await playlistsStore.importPlatform(name, source, playlistId)
if (!result) {
setBusy(false)
return
}
notificationStore.success(`已保存为“${result.playlist.name}”,共 ${result.imported} 首`)
onClose()
}}
>
<label>
歌单名
<input data-autofocus required maxLength={50} value={name} onChange={event => setName(event.target.value)} />
</label>
<div className="dialog-actions">
<button type="button" className="button-secondary" onClick={onClose}>取消</button>
<button type="submit" className="button-primary" disabled={busy || name.trim() === ''}>{busy ? '正在保存…' : '保存'}</button>
</div>
</form>
)
: <AccountPrompt message="注册或登录之后就能保存这个歌单。" onClose={onClose} />}
</Modal>
)
}
+17 -30
View File
@@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from 'react'
import { profileStore } from '@/features/settings'
import { useStore } from '@/shared/lib/use-store'
import { IconButton, Modal, notificationStore, Skeleton } from '@/shared/ui'
import { openAccountSettings } from './account-settings'
import { AccountPrompt } from './account-prompt'
import { CreatePlaylistForm } from './create-playlist-form'
import { FavoritesMark, PlaylistCover } from './playlist-cover'
import { sortPlaylists } from './playlist-sort'
@@ -37,7 +37,7 @@ export function SaveToPlaylistDialog(props: SaveToPlaylistProps) {
const from = 'from' in props ? props.from : null
const account = useStore(profileStore, s => s.account)
const playlists = useStore(playlistsStore, s => s.playlists)
const failed = useStore(playlistsStore, s => s.failed)
const error = useStore(playlistsStore, s => s.error)
const included = useStore(playlistsStore, s => track ? s.memberships.get(trackKey(track)) : undefined) ?? none
const [picked, setPicked] = useState<ReadonlySet<string>>(none)
const [order, setOrder] = useState<string[] | null>(null)
@@ -104,39 +104,26 @@ export function SaveToPlaylistDialog(props: SaveToPlaylistProps) {
let body
if (!account) {
body = <AccountPrompt message="注册或登录之后就能把歌保存到歌单。" onClose={onClose} />
}
else if (readFailed || (playlists === null && error !== null)) {
body = (
<>
<p className="text-ui-base muted">注册或登录之后就能把歌保存到歌单。</p>
<div className="dialog-actions">
<button
type="button"
className="button-primary"
onClick={() => {
onClose()
openAccountSettings()
}}
>
去账号设置
</button>
</div>
<p className="text-ui-base muted">{error === null ? '读取失败' : `读取失败:${error}`}</p>
<button
type="button"
className="button-secondary"
onClick={() => {
setReadFailed(false)
setOrder(null)
setAttempt(n => n + 1)
}}
>
重试
</button>
</>
)
}
else if (readFailed || (playlists === null && failed)) {
body = (
<button
type="button"
className="button-secondary"
onClick={() => {
setReadFailed(false)
setOrder(null)
setAttempt(n => n + 1)
}}
>
重试
</button>
)
}
else if (rows === null) {
body = <Skeleton rows={3} />
}
+4 -1
View File
@@ -5,6 +5,7 @@ import type { SongItem } from '@/shared/ui'
import { ArrowLeft, ArrowRight, CirclePlus, Clock3, Search, Sparkles } from 'lucide-react'
import { roomStore, sameTrack } from '@/core/room'
import { historyStore } from '@/features/history'
import { SavePlatformPlaylistButton } from '@/features/playlists'
import { enqueue } from '@/features/queue'
import { bookTitle } from '@/shared/lib/book-title'
import { useStore } from '@/shared/lib/use-store'
@@ -346,6 +347,7 @@ export function SearchPage({ inlineSearchClassName, onSearch, onOpenPlaylist }:
}
export function PlaylistPage({ title, page, onPage }: { title: string | undefined, page: number, onPage: (page: number) => void }) {
const playlist = useStore(searchStore, s => s.playlist)
const tracks = useStore(searchStore, s => s.tracks)
const total = useStore(searchStore, s => s.tracksTotal)
const loading = useStore(searchStore, s => s.loading)
@@ -353,8 +355,9 @@ export function PlaylistPage({ title, page, onPage }: { title: string | undefine
const connected = useStore(roomStore, s => s.status === 'open')
return (
<div className="page-content search-page">
<header className="search-page-heading">
<header className="search-page-heading search-playlist-heading">
<h1 className="text-ui-xl">{title ?? '歌单'}</h1>
{playlist && <SavePlatformPlaylistButton source={playlist.source} playlistId={playlist.id} name={title} />}
</header>
{failed && (
<div className="search-error" role="alert">
+2
View File
@@ -24,6 +24,8 @@
.search-source .select-trigger { padding: 7px 12px; border-radius: 999px; background: transparent; }
.search-page-heading { margin: 16px 0 28px; }
.search-page-heading p { margin-top: 6px; overflow-wrap: anywhere; }
.search-playlist-heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 16px; }
.search-playlist-heading h1 { min-width: 0; overflow-wrap: anywhere; }
.search-recommend-hint { display: none; align-items: center; gap: 6px; }
.search-recommend-hint > svg { color: var(--color-brand); }
.search-page .section-heading { margin-bottom: 16px; }