Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e539c7d78b | ||
|
|
d73a161e84 | ||
|
|
ffd2b390f0 | ||
|
|
ef4558bd65 | ||
|
|
c87b48f014 | ||
|
|
4a59c639cd | ||
|
|
a07ef81e3c | ||
|
|
ca6603a994 | ||
|
|
224ea02725 | ||
|
|
f4e4d23831 | ||
|
|
83afee3142 | ||
|
|
ec62be2d8d | ||
|
|
872904975d | ||
|
|
b50e645d30 | ||
|
|
7f67df3d41 | ||
|
|
a61df45c32 | ||
|
|
90a3b26c77 | ||
|
|
729e5861f5 |
No files matched your search
@@ -2,6 +2,7 @@
|
||||
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="" # 进入管理后台需要的凭证, 留空则禁用后台
|
||||
|
||||
@@ -39,6 +39,18 @@ 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
|
||||
|
||||
@@ -53,6 +53,7 @@ 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
|
||||
@@ -115,6 +116,12 @@ 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:
|
||||
|
||||
@@ -40,6 +40,7 @@ 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"
|
||||
|
||||
@@ -50,10 +50,6 @@ 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;
|
||||
|
||||
|
||||
@@ -12,9 +12,3 @@ 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;
|
||||
@@ -63,6 +63,8 @@ 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 }}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
domain: ""
|
||||
allowedOrigins: ""
|
||||
# 逗号分隔的 CIDR:只有来自这些地址的反向代理传来的 X-Forwarded-For 才被采信,用于按客户端 IP 限流。
|
||||
trustedProxies: ""
|
||||
logLevel: info
|
||||
otlpEndpoint: ""
|
||||
database:
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -36,9 +37,7 @@ const (
|
||||
maxBodyBytes = 64 << 10
|
||||
// 歌单的批量接口一次最多 1000 首,链接曲目的 ID 最长 2048 字节,每首再留 256 字节给来源、字段名和 JSON 的标点。
|
||||
maxBatchBodyBytes = 1000 * (2048 + 256)
|
||||
// 导入文件一次最多 1000 首,每首除了曲目还带标题、歌手、专辑和两个地址。nginx 中 /api/me/playlists/imports 的上限要与它保持一致。
|
||||
maxImportBodyBytes = 1 << 20
|
||||
requestDeadline = 3 * time.Minute
|
||||
requestDeadline = 3 * time.Minute
|
||||
)
|
||||
|
||||
type Services struct {
|
||||
@@ -55,6 +54,9 @@ type Services struct {
|
||||
|
||||
// 浏览器访问对象存储时使用的地址前缀,结尾没有 /。
|
||||
StorageURL string
|
||||
|
||||
// 只有来自这些地址的反向代理传来的 X-Forwarded-For 才被采信。
|
||||
TrustedProxies []netip.Prefix
|
||||
}
|
||||
|
||||
type server struct {
|
||||
@@ -122,9 +124,6 @@ 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)
|
||||
@@ -217,7 +216,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)}
|
||||
c := call{r: r, clientIP: clientIP(r, s.TrustedProxies)}
|
||||
switch auth {
|
||||
case protocol.AuthNone:
|
||||
case protocol.AuthMember:
|
||||
@@ -331,9 +330,6 @@ 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},
|
||||
|
||||
@@ -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, nil)
|
||||
s.Playlists = playlist.New(store, store, noTracks{}, s.Catalog, 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 {
|
||||
|
||||
@@ -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: trackDTO(s.Track), Meta: meta(s.Meta)}
|
||||
items[i] = protocol.TrackSummary{Track: protocol.Track{Source: protocol.Source(s.Track.Source), ID: s.Track.ID}, Meta: meta(s.Meta)}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
@@ -17,10 +17,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -3,19 +3,20 @@ package api
|
||||
import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 只有对端是回环、私有网段或链路本地地址的代理时才读 X-Forwarded-For,因为其他来源可以随意填写它。从最右端往左跳过受信任的代理,
|
||||
// 只有对端是受信任的代理时才读 X-Forwarded-For,因为其他来源可以随意填写它。从最右端往左跳过受信任的代理,
|
||||
// 第一个不受信任的地址就是客户端;全部受信任时取最左端的地址。受信任的代理只会写入合法的地址,
|
||||
// 遇到不合法的值说明已经到了客户端自己填写的部分,此时改用对端地址。
|
||||
func clientIP(r *http.Request) string {
|
||||
func clientIP(r *http.Request, trusted []netip.Prefix) string {
|
||||
peer, err := netip.ParseAddrPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
addr := peer.Addr().Unmap()
|
||||
if !trustedProxy(addr) {
|
||||
if !containsAddr(trusted, addr) {
|
||||
return addr.String()
|
||||
}
|
||||
var hops []string
|
||||
@@ -30,13 +31,13 @@ func clientIP(r *http.Request) string {
|
||||
break
|
||||
}
|
||||
hop = hop.Unmap()
|
||||
if i == 0 || !trustedProxy(hop) {
|
||||
if i == 0 || !containsAddr(trusted, hop) {
|
||||
return hop.String()
|
||||
}
|
||||
}
|
||||
return addr.String()
|
||||
}
|
||||
|
||||
func trustedProxy(addr netip.Addr) bool {
|
||||
return addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()
|
||||
func containsAddr(prefixes []netip.Prefix, addr netip.Addr) bool {
|
||||
return slices.ContainsFunc(prefixes, func(p netip.Prefix) bool { return p.Contains(addr) })
|
||||
}
|
||||
@@ -2,29 +2,27 @@ 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"}, "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"},
|
||||
{"对端不受信任时不看 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"},
|
||||
{"客户端自己填在左边的地址不采用", "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{"192.168.1.5, 10.42.0.1"}, "192.168.1.5"},
|
||||
{"全部受信任时取最左端", "10.42.0.153:4321", []string{"10.42.0.1"}, "10.42.0.1"},
|
||||
{"没有 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 处理", "10.42.0.153:4321", []string{"203.0.113.7, ::ffff:10.42.0.1"}, "203.0.113.7"},
|
||||
{"IPv4 映射的 IPv6 地址按 IPv4 处理", "[::ffff:10.42.0.153]:4321", []string{"::ffff:203.0.113.7"}, "203.0.113.7"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -33,9 +31,17 @@ func TestClientIP(t *testing.T) {
|
||||
for _, v := range tc.forwarded {
|
||||
r.Header.Add("X-Forwarded-For", v)
|
||||
}
|
||||
if got := clientIP(r); got != tc.want {
|
||||
if got := clientIP(r, trusted); 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -164,14 +164,10 @@ 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: trackDTO(e.Track),
|
||||
Track: protocol.Track{Source: protocol.Source(e.Track.Source), ID: e.Track.ID},
|
||||
Meta: s.trackMeta(e.Meta),
|
||||
Auto: e.Auto,
|
||||
Likes: int64(e.Likes),
|
||||
|
||||
@@ -2,9 +2,7 @@ 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"
|
||||
@@ -29,7 +27,7 @@ func (s *server) playlistDTO(p port.PlaylistRecord) protocol.MyPlaylist {
|
||||
|
||||
func (s *server) playlistItemDTO(item port.PlaylistItem) protocol.MyPlaylistItem {
|
||||
return protocol.MyPlaylistItem{
|
||||
Track: trackDTO(item.Track),
|
||||
Track: protocol.Track{Source: protocol.Source(item.Track.Source), ID: item.Track.ID},
|
||||
Meta: s.trackMeta(item.Meta),
|
||||
}
|
||||
}
|
||||
@@ -146,51 +144,3 @@ 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,48 +9,6 @@ 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,20 +182,6 @@ 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,57 +106,3 @@ 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
|
||||
}
|
||||
@@ -49,8 +49,19 @@ 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) (err error) {
|
||||
created, err = createPlaylist(ctx, s.q.WithTx(tx), ownerID, name, limit)
|
||||
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})
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
@@ -59,66 +70,6 @@ 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, pool := newStore(t)
|
||||
store, _ := newStore(t)
|
||||
alice := registered(t, store, "alice")
|
||||
favorites := favoritesOf(t, store, alice.ID)
|
||||
var wg sync.WaitGroup
|
||||
@@ -185,15 +185,6 @@ 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) {
|
||||
@@ -610,82 +601,3 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,7 @@ import (
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
var (
|
||||
_ port.Tracks = (*Store)(nil)
|
||||
_ port.TrackLookup = (*Store)(nil)
|
||||
)
|
||||
var _ port.Tracks = (*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})
|
||||
@@ -30,19 +27,6 @@ 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),
|
||||
@@ -69,12 +53,9 @@ 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) {
|
||||
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}
|
||||
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}
|
||||
}
|
||||
|
||||
func orZero(p *int64) int64 {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -125,42 +124,3 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
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("没有按期限中止")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -84,14 +84,6 @@ 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)
|
||||
|
||||
@@ -244,26 +244,6 @@ 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"}
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -32,16 +32,15 @@ var (
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
playlists port.Playlists
|
||||
members port.Members
|
||||
tracks port.TrackLookup
|
||||
catalog *catalog.Service
|
||||
quota port.AccountQuota
|
||||
shortLinks port.ShortLinks
|
||||
playlists port.Playlists
|
||||
members port.Members
|
||||
tracks port.Tracks
|
||||
catalog *catalog.Service
|
||||
quota port.AccountQuota
|
||||
}
|
||||
|
||||
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 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 (s *Service) List(ctx context.Context, memberID string) ([]port.PlaylistRecord, error) {
|
||||
@@ -96,9 +95,13 @@ 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
|
||||
}
|
||||
if err := s.take(ctx, "playlist:member:"+memberID, addBurst, addRefill); err != nil {
|
||||
wait, err := s.quota.Take(ctx, "playlist:member:"+memberID, addBurst, addRefill)
|
||||
if 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
|
||||
@@ -157,17 +160,6 @@ 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 {
|
||||
|
||||
@@ -31,16 +31,6 @@ 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
|
||||
}
|
||||
@@ -64,37 +54,21 @@ 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, burst int, refill time.Duration) (time.Duration, error) {
|
||||
func (q *accountQuota) Take(_ context.Context, key string, _ int, _ 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
|
||||
netease *porttest.Source
|
||||
links shortLinks
|
||||
svc *Service
|
||||
store *porttest.Store
|
||||
tracks tracksTable
|
||||
quota *accountQuota
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) fixture {
|
||||
@@ -106,8 +80,7 @@ func newFixture(t *testing.T) fixture {
|
||||
tracks := tracksTable{}
|
||||
music := catalog.New(netease, porttest.NewSource(track.QQ), bilibili, bilibili, tracks, noHistory{}, store, roomQuota{})
|
||||
q := &accountQuota{}
|
||||
links := shortLinks{}
|
||||
return fixture{svc: New(store, store, tracks, music, q, links), store: store, tracks: tracks, quota: q, netease: netease, links: links}
|
||||
return fixture{svc: New(store, store, tracks, music, q), store: store, tracks: tracks, quota: q}
|
||||
}
|
||||
|
||||
func (f fixture) member(t *testing.T, username string) member.Member {
|
||||
@@ -158,15 +131,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -41,9 +41,6 @@ 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。
|
||||
|
||||
@@ -578,44 +578,6 @@ 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 {
|
||||
@@ -623,12 +585,12 @@ func (s *Store) newPlaylist(ownerID, name string, limit int) (*storedPlaylist, e
|
||||
}
|
||||
}
|
||||
if n >= limit {
|
||||
return nil, port.ErrPlaylistLimit
|
||||
return port.PlaylistRecord{}, 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 p, nil
|
||||
return port.PlaylistRecord{ID: p.id, Name: name, CreatedAt: now, UpdatedAt: now}, nil
|
||||
}
|
||||
|
||||
func (s *Store) RenamePlaylist(_ context.Context, ownerID, id, name string) error {
|
||||
@@ -694,7 +656,6 @@ 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
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
package porttest
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestUpdateMemberRejectsTextPostgreSQLCannotStore(t *testing.T) {
|
||||
store := New()
|
||||
@@ -19,47 +13,3 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package port
|
||||
|
||||
import "context"
|
||||
|
||||
type ShortLinks interface {
|
||||
// 返回跳转之后的最终地址;跳转到不受信任的主机、跳转过多或超时都返回错误。
|
||||
Resolve(ctx context.Context, link string) (string, error)
|
||||
}
|
||||
@@ -20,9 +20,3 @@ 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)
|
||||
}
|
||||
@@ -30,8 +30,9 @@ type serveConfig struct {
|
||||
redis *goredis.Options
|
||||
storage storage.Config
|
||||
// 浏览器访问对象存储时使用的地址前缀,结尾没有 /。
|
||||
storageURL string
|
||||
tracing bool
|
||||
storageURL string
|
||||
trustedProxies []netip.Prefix
|
||||
tracing bool
|
||||
}
|
||||
|
||||
func loadServeConfig() (serveConfig, error) {
|
||||
@@ -57,6 +58,10 @@ 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
|
||||
@@ -91,6 +96,7 @@ func loadServeConfig() (serveConfig, error) {
|
||||
redis: redis,
|
||||
storage: objects,
|
||||
storageURL: storageURL,
|
||||
trustedProxies: trustedProxies,
|
||||
tracing: tracing,
|
||||
}, nil
|
||||
}
|
||||
@@ -139,6 +145,43 @@ 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
|
||||
|
||||
@@ -2,7 +2,9 @@ package bootstrap
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"os"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -83,3 +85,36 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -344,6 +344,7 @@ 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")
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ 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"
|
||||
@@ -34,7 +33,6 @@ 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"
|
||||
)
|
||||
|
||||
@@ -82,7 +80,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), shortlink.New(playlistlink.AllowedRedirectHost, playlistlink.Recognized)),
|
||||
Playlists: playlist.New(store, store, store, music, redis.NewAccountQuota(rdb)),
|
||||
Lobby: rooms,
|
||||
Admin: admin.New(cfg.adminToken, rooms, store, store, store, src.neteaseLogin, src.qqLogin, src.bilibiliLogin, store, gateway),
|
||||
Gateway: gateway,
|
||||
@@ -91,7 +89,8 @@ func newPublic(cfg serveConfig, pool *pgxpool.Pool, rdb *goredis.Client, instanc
|
||||
Playback: media.NewPlayback(src.playback, store),
|
||||
Site: site.New(store),
|
||||
|
||||
StorageURL: cfg.storageURL,
|
||||
StorageURL: cfg.storageURL,
|
||||
TrustedProxies: cfg.trustedProxies,
|
||||
}
|
||||
httpLogger := base.With("component", "http")
|
||||
return public{
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
登录按客户端 IP 限流:同一个 IP 1 分钟内最多尝试 10 次,换一个用户名也算在内;请求经受信任的代理转发时,按 X-Forwarded-For 中的客户端地址计数,另一个客户端不受影响。
|
||||
|
||||
-- env --
|
||||
ALISTEN_TRUSTED_PROXIES=127.0.0.0/8,::1/128
|
||||
|
||||
-- request --
|
||||
POST /api/members
|
||||
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
歌单导入:只有已注册的账号能导入。导入文件时,曲目表里已有的歌用服务端的元数据,没有的用文件里的元数据(不是平台自己的 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": "暂时只能导入网易云的歌单"}}
|
||||
@@ -1,93 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package track
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -41,14 +40,6 @@ 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
|
||||
|
||||
@@ -18,17 +18,3 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,18 +114,6 @@ 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, "")
|
||||
}
|
||||
@@ -590,127 +578,6 @@ 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")
|
||||
@@ -992,27 +859,6 @@ 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)
|
||||
|
||||
@@ -194,20 +194,6 @@
|
||||
},
|
||||
"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,
|
||||
@@ -1016,83 +1002,6 @@
|
||||
"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": "房间列表",
|
||||
|
||||
+1
-276
@@ -713,122 +713,6 @@
|
||||
],
|
||||
"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": {
|
||||
@@ -1406,36 +1290,6 @@
|
||||
],
|
||||
"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": {
|
||||
@@ -1887,7 +1741,7 @@
|
||||
},
|
||||
"info": {
|
||||
"title": "Alisten",
|
||||
"version": "42bbfea8c8da3f68"
|
||||
"version": "5b9cd2cb39780e46"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
@@ -3024,49 +2878,6 @@
|
||||
"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",
|
||||
@@ -3184,92 +2995,6 @@
|
||||
"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",
|
||||
|
||||
+1
-52
@@ -4,7 +4,7 @@ package protocol
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const Version = "42bbfea8c8da3f68"
|
||||
const Version = "5b9cd2cb39780e46"
|
||||
|
||||
type APIKey struct {
|
||||
Key string `json:"key"`
|
||||
@@ -274,45 +274,6 @@ 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"`
|
||||
}
|
||||
@@ -595,15 +556,6 @@ 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"`
|
||||
@@ -830,9 +782,6 @@ 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,22 +11,6 @@ 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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
|
||||
|
||||
import type { APIKey, Account, AdminAccount, ErrorInfo, ImportedTrack, Member, MyPlaylist, MyPlaylistItem, QQLoginType, QRLogin, QRLoginResult, RoomListing, Source, SourceAccount, StoredMedia, Track } from '../realtime/protocol.gen'
|
||||
import type { APIKey, Account, AdminAccount, ErrorInfo, Member, MyPlaylist, MyPlaylistItem, QQLoginType, QRLogin, QRLoginResult, RoomListing, SourceAccount, StoredMedia, Track } from '../realtime/protocol.gen'
|
||||
|
||||
export interface AddMyPlaylistItemRequest {
|
||||
track: Track
|
||||
@@ -157,27 +157,6 @@ 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[]
|
||||
}
|
||||
@@ -231,15 +210,6 @@ export interface RenameMyPlaylistRequest {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ResolveMyPlaylistLinkRequest {
|
||||
link: string
|
||||
}
|
||||
|
||||
export interface ResolveMyPlaylistLinkResponse {
|
||||
playlistId: string
|
||||
source: Source
|
||||
}
|
||||
|
||||
export interface UpdateMeRequest {
|
||||
email?: string
|
||||
name?: string
|
||||
@@ -283,9 +253,6 @@ 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 }),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
|
||||
|
||||
export const PROTOCOL_VERSION = '42bbfea8c8da3f68'
|
||||
export const PROTOCOL_VERSION = '5b9cd2cb39780e46'
|
||||
|
||||
export interface APIKey {
|
||||
key: string
|
||||
@@ -61,16 +61,6 @@ 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
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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 {
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,6 @@ 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,10 +9,9 @@ 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, notificationStore, Skeleton } from '@/shared/ui'
|
||||
import { EmptyState, Modal, 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'
|
||||
@@ -28,23 +27,6 @@ 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)
|
||||
@@ -98,7 +80,8 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [selecting])
|
||||
const now = useStore(playlistsStore, s => s.loadedAt)
|
||||
const [now] = useState(Date.now)
|
||||
const name = playlist?.name ?? '歌单'
|
||||
// 读到了自己的歌单,里面却没有它:在别处删掉了,或者换了账号。
|
||||
const gone = listed && !playlist
|
||||
// 读不到时不再显示之前的曲目:它们可能已经不在了,也可能属于之前的账号。
|
||||
@@ -228,7 +211,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">
|
||||
{playlist || listed ? <span>{playlist?.name ?? '歌单'}</span> : <span className="my-playlist-name-pending" role="img" aria-label="正在加载" />}
|
||||
<span>{name}</span>
|
||||
{playlist?.favorites && <FavoritesMark size={22} />}
|
||||
</h1>
|
||||
{playlist && (
|
||||
@@ -242,7 +225,7 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
|
||||
{selecting ? '完成' : '选择'}
|
||||
</button>
|
||||
)}
|
||||
{playlist && <PlaylistMenu playlist={playlist} align="start" className="my-playlist-menu" onDeleted={onBack} onExport={() => void exportToFile(playlist)} />}
|
||||
{playlist && !playlist.favorites && <PlaylistMenu playlist={playlist} align="start" className="my-playlist-menu" onDeleted={onBack} />}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,82 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
@@ -1,38 +1,118 @@
|
||||
import type { MenuAction } from './action-menu'
|
||||
import type { KeyboardEvent, ToggleEvent } from 'react'
|
||||
import type { MyPlaylist } from '@/core/realtime'
|
||||
import { Ellipsis, FileDown, Pencil, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Ellipsis, Pencil, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { Modal } from '@/shared/ui'
|
||||
import { ActionMenu } from './action-menu'
|
||||
import { playlistsStore } from './playlists-store'
|
||||
|
||||
export function PlaylistMenu({ playlist, align = 'end', className = '', onDeleted, onExport }: {
|
||||
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 }: {
|
||||
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)
|
||||
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') })
|
||||
|
||||
// 菜单在顶层按视口定位,不跟随页面滚动,所以滚动和缩放窗口时收起。
|
||||
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)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionMenu
|
||||
actions={actions}
|
||||
menuLabel={`“${playlist.name}”的操作`}
|
||||
align={align}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className={`icon-button playlist-menu-button ${className}`}
|
||||
label={`“${playlist.name}”的更多操作`}
|
||||
popoverTarget={menuId}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={`“${playlist.name}”的更多操作`}
|
||||
title="更多操作"
|
||||
>
|
||||
<Ellipsis size={20} strokeWidth={1.7} aria-hidden="true" />
|
||||
</ActionMenu>
|
||||
</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>
|
||||
{dialog === 'rename' && <RenameDialog playlist={playlist} onClose={() => setDialog(null)} />}
|
||||
{dialog === 'delete' && <DeleteDialog playlist={playlist} onClose={() => setDialog(null)} onDeleted={onDeleted} />}
|
||||
</>
|
||||
|
||||
@@ -3,13 +3,12 @@ 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, useRef, useState } from 'react'
|
||||
import { useEffect, 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'
|
||||
@@ -49,14 +48,11 @@ 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 error = useStore(playlistsStore, s => s.error)
|
||||
const failed = useStore(playlistsStore, s => s.failed)
|
||||
const profileFailed = useStore(profileStore, s => s.failed)
|
||||
const [preference, setPreference] = useState(loadViewPreference)
|
||||
const [creating, setCreating] = useState(false)
|
||||
// 删除歌单之后要获得焦点的歌单(null 表示标题);每次删除换一个新对象,让同一个目标也能再次触发。
|
||||
const [refocus, setRefocus] = useState<{ id: string | null } | null>(null)
|
||||
const titleRef = useRef<HTMLHeadingElement>(null)
|
||||
const now = useStore(playlistsStore, s => s.loadedAt)
|
||||
const [now] = useState(Date.now)
|
||||
useEffect(() => {
|
||||
if (!member)
|
||||
loadProfile()
|
||||
@@ -65,19 +61,7 @@ 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)
|
||||
@@ -98,26 +82,21 @@ export function PlaylistsPage({ onOpen, onRegister }: { onOpen: (playlist: MyPla
|
||||
)
|
||||
}
|
||||
else if (playlists === null) {
|
||||
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>
|
||||
)
|
||||
body = failed
|
||||
? <button type="button" className="button-secondary" onClick={() => void playlistsStore.load()}>重试</button>
|
||||
: <Skeleton rows={4} />
|
||||
}
|
||||
else {
|
||||
const sorted = sortPlaylists(playlists, sort)
|
||||
body = view === 'list'
|
||||
? <PlaylistTable playlists={sorted} sort={sort} now={now} onSort={sortBy} onOpen={onOpen} onDeleted={onDeleted} />
|
||||
: <PlaylistGrid playlists={sorted} now={now} onOpen={onOpen} onDeleted={onDeleted} />
|
||||
? <PlaylistTable playlists={sorted} sort={sort} now={now} onSort={sortBy} onOpen={onOpen} />
|
||||
: <PlaylistGrid playlists={sorted} now={now} onOpen={onOpen} />
|
||||
}
|
||||
return (
|
||||
<section className="page-content playlists-page" aria-labelledby="playlists-title">
|
||||
<header className="playlists-toolbar">
|
||||
<div className="playlists-heading">
|
||||
<h1 ref={titleRef} id="playlists-title" tabIndex={-1} className="text-ui-xl">我的歌单</h1>
|
||||
<h1 id="playlists-title" className="text-ui-xl">我的歌单</h1>
|
||||
{shown && <p className="text-ui-sm muted">{`${shown.length} 个歌单`}</p>}
|
||||
</div>
|
||||
{shown && (
|
||||
@@ -146,13 +125,10 @@ export function PlaylistsPage({ onOpen, onRegister }: { onOpen: (playlist: MyPla
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<button type="button" className="button-primary playlists-create" onClick={() => setCreating(true)}>
|
||||
<Plus size={18} strokeWidth={2} aria-hidden="true" />
|
||||
新建歌单
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
@@ -162,13 +138,12 @@ export function PlaylistsPage({ onOpen, onRegister }: { onOpen: (playlist: MyPla
|
||||
)
|
||||
}
|
||||
|
||||
function PlaylistTable({ playlists, sort, now, onSort, onOpen, onDeleted }: {
|
||||
function PlaylistTable({ playlists, sort, now, onSort, onOpen }: {
|
||||
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="我的歌单">
|
||||
@@ -193,7 +168,7 @@ function PlaylistTable({ playlists, sort, now, onSort, onOpen, onDeleted }: {
|
||||
{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" data-playlist-open={playlist.id} onClick={() => onOpen(playlist)}>
|
||||
<button type="button" className="playlist-row-open" onClick={() => onOpen(playlist)}>
|
||||
<PlaylistCover cover={playlist.coverUrl} favorites={playlist.favorites} size="sm" />
|
||||
<span className="playlist-row-copy">
|
||||
<span className="playlist-row-title">
|
||||
@@ -208,7 +183,7 @@ function PlaylistTable({ playlists, sort, now, onSort, onOpen, onDeleted }: {
|
||||
<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} onDeleted={() => onDeleted(playlist)} />}
|
||||
{!playlist.favorites && <PlaylistMenu playlist={playlist} />}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -217,19 +192,19 @@ function PlaylistTable({ playlists, sort, now, onSort, onOpen, onDeleted }: {
|
||||
)
|
||||
}
|
||||
|
||||
function PlaylistGrid({ playlists, now, onOpen, onDeleted }: { playlists: MyPlaylist[], now: number, onOpen: (playlist: MyPlaylist) => void, onDeleted: (playlist: MyPlaylist) => void }) {
|
||||
function PlaylistGrid({ playlists, now, onOpen }: { playlists: MyPlaylist[], now: number, onOpen: (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" data-playlist-open={playlist.id} onClick={() => onOpen(playlist)}>
|
||||
<button type="button" className="playlist-card-open" 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" onDeleted={() => onDeleted(playlist)} />}
|
||||
{!playlist.favorites && <PlaylistMenu playlist={playlist} className="playlist-card-menu" />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -33,8 +33,6 @@ 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)[]
|
||||
@@ -42,7 +40,6 @@ 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
|
||||
@@ -83,7 +80,6 @@ beforeEach(() => {
|
||||
failAdd = false
|
||||
failCopy = false
|
||||
failMove = false
|
||||
importFailure = null
|
||||
bodies = []
|
||||
holdReads = false
|
||||
failReads = false
|
||||
@@ -114,14 +110,6 @@ 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)
|
||||
@@ -196,23 +184,6 @@ 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)
|
||||
@@ -300,7 +271,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.error).toBe('暂时不可用')
|
||||
expect(playlistsStore.state.failed).toBe(true)
|
||||
})
|
||||
|
||||
it('读取期间正在加入的歌以本地为准', async () => {
|
||||
@@ -471,22 +442,6 @@ 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')
|
||||
@@ -531,102 +486,4 @@ 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 个字。', '导入太频繁了,稍后再试'])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { ImportMyPlatformPlaylistResponse, ImportMyPlaylistResponse, ResolveMyPlaylistLinkResponse } from '@/core/api'
|
||||
import type { ErrorTexts } from '@/core/errors'
|
||||
import type { ImportedTrack, MyPlaylist, Source, Track } from '@/core/realtime'
|
||||
import type { MyPlaylist, Track } from '@/core/realtime'
|
||||
import { api } from '@/core/api'
|
||||
import { errorMessage } from '@/core/errors'
|
||||
import { profileStore } from '@/features/settings'
|
||||
@@ -18,21 +16,14 @@ function membershipKey(playlistId: string, track: Track): string {
|
||||
interface PlaylistsState {
|
||||
// 游客,或者还没有读到时为 null。
|
||||
playlists: MyPlaylist[] | null
|
||||
// 最近一次读取失败的原因,成功读取后为 null。
|
||||
error: string | null
|
||||
// 歌单最近一次读取成功的时间,相对日期(今天、昨天)以它为准:窗口重新获得焦点会重新读取,跨过午夜后标签随之更新。
|
||||
loadedAt: number
|
||||
failed: boolean
|
||||
// 曲目 → 包含它的歌单 id,只有读过的曲目才有。正在同步的歌单是最后一次勾选的结果,同步负责把服务端改成一致。
|
||||
memberships: ReadonlyMap<string, ReadonlySet<string>>
|
||||
}
|
||||
|
||||
const empty: PlaylistsState = { playlists: null, error: null, loadedAt: 0, memberships: new Map() }
|
||||
const empty: PlaylistsState = { playlists: null, failed: false, 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
|
||||
@@ -51,7 +42,7 @@ class PlaylistsStore extends Store<PlaylistsState> {
|
||||
this.#onProfile()
|
||||
}
|
||||
|
||||
// 失败时只设置 error,不抛出。
|
||||
// 失败时只设置 failed,不抛出。
|
||||
load(): Promise<void> {
|
||||
return this.#load(++this.#generation)
|
||||
}
|
||||
@@ -100,7 +91,8 @@ class PlaylistsStore extends Store<PlaylistsState> {
|
||||
async create(name: string): Promise<MyPlaylist | null> {
|
||||
try {
|
||||
const playlist = await api.createMyPlaylist({ name })
|
||||
this.#added(playlist)
|
||||
this.setState({ playlists: [...(this.state.playlists ?? []), playlist] })
|
||||
void this.load()
|
||||
return playlist
|
||||
}
|
||||
catch (error) {
|
||||
@@ -109,44 +101,6 @@ 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 })
|
||||
@@ -210,12 +164,6 @@ 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
|
||||
@@ -229,9 +177,8 @@ class PlaylistsStore extends Store<PlaylistsState> {
|
||||
saved = included
|
||||
}
|
||||
catch (error) {
|
||||
if (this.#account !== account)
|
||||
return
|
||||
this.#setIncluded(playlistId, track, saved)
|
||||
if (this.#account === account)
|
||||
this.#setIncluded(playlistId, track, saved)
|
||||
notificationStore.error(errorMessage(error))
|
||||
return
|
||||
}
|
||||
@@ -242,11 +189,11 @@ class PlaylistsStore extends Store<PlaylistsState> {
|
||||
try {
|
||||
const { items } = await api.listMyPlaylists()
|
||||
if (generation === this.#generation)
|
||||
this.setState({ playlists: items, error: null, loadedAt: Date.now() })
|
||||
this.setState({ playlists: items, failed: false })
|
||||
}
|
||||
catch (error) {
|
||||
catch {
|
||||
if (generation === this.#generation)
|
||||
this.setState({ error: errorMessage(error) })
|
||||
this.setState({ failed: true })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,10 +45,9 @@
|
||||
.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-actions { grid-area: create; display: flex; gap: 8px; }
|
||||
.playlists-create { grid-area: create; }
|
||||
.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; }
|
||||
@@ -107,7 +106,6 @@
|
||||
.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; }
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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 { AccountPrompt } from './account-prompt'
|
||||
import { openAccountSettings } from './account-settings'
|
||||
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 error = useStore(playlistsStore, s => s.error)
|
||||
const failed = useStore(playlistsStore, s => s.failed)
|
||||
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,26 +104,39 @@ 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">{error === null ? '读取失败' : `读取失败:${error}`}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="button-secondary"
|
||||
onClick={() => {
|
||||
setReadFailed(false)
|
||||
setOrder(null)
|
||||
setAttempt(n => n + 1)
|
||||
}}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
<p className="text-ui-base muted">注册或登录之后就能把歌保存到歌单。</p>
|
||||
<div className="dialog-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="button-primary"
|
||||
onClick={() => {
|
||||
onClose()
|
||||
openAccountSettings()
|
||||
}}
|
||||
>
|
||||
去账号设置
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
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} />
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ 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'
|
||||
@@ -347,7 +346,6 @@ 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)
|
||||
@@ -355,9 +353,8 @@ 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 search-playlist-heading">
|
||||
<header className="search-page-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">
|
||||
|
||||
@@ -24,8 +24,6 @@
|
||||
.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; }
|
||||
|
||||
Reference in new issue
Block a user