feat: tell which of my playlists contain a song
This commit is contained in:
1 parent
4a59c639cd
commit
c87b48f014
19 files changed
+282
-3
No files matched your search
@@ -98,3 +98,9 @@ WHERE i.playlist_id = @playlist_id AND i.source = s.source AND i.track_id = k.tr
|
||||
|
||||
-- name: TouchPlaylist :exec
|
||||
UPDATE playlists SET updated_at = now() WHERE id = @id;
|
||||
|
||||
-- name: TrackPlaylists :many
|
||||
SELECT p.id FROM playlists p
|
||||
JOIN playlist_items i ON i.playlist_id = p.id
|
||||
WHERE p.owner_id = @owner_id AND i.source = @source AND i.track_id = @track_id
|
||||
ORDER BY p.id;
|
||||
@@ -121,6 +121,7 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
|
||||
srv.handle(mux, protocol.RouteListMyPlaylistItems, srv.listMyPlaylistItems)
|
||||
srv.handle(mux, protocol.RouteAddMyPlaylistItem, srv.addMyPlaylistItem)
|
||||
srv.handleLimit(mux, protocol.RouteRemoveMyPlaylistItems, maxBatchBodyBytes, srv.removeMyPlaylistItems)
|
||||
srv.handle(mux, protocol.RouteListMyTrackPlaylists, srv.listMyTrackPlaylists)
|
||||
srv.handleLimit(mux, protocol.RouteCopyMyPlaylistItems, maxBatchBodyBytes, srv.copyMyPlaylistItems)
|
||||
srv.handleLimit(mux, protocol.RouteMoveMyPlaylistItems, maxBatchBodyBytes, srv.moveMyPlaylistItems)
|
||||
srv.handle(mux, protocol.RouteListRooms, srv.listRooms)
|
||||
|
||||
@@ -201,6 +201,10 @@ func TestPlaylistBatchBodyLimit(t *testing.T) {
|
||||
if got := send(t, server, http.MethodPost, "/api/me/playlists/"+favorites+"/removals", tooLarge, auth...); got != want {
|
||||
t.Errorf("超过批量上限的请求体 = %#v,期望 %#v", got, want)
|
||||
}
|
||||
want = failure(protocol.ErrorCodeInvalidArgument, "请求体不能超过 64 KiB")
|
||||
if got := send(t, server, http.MethodPost, "/api/me/playlist-memberships", `{"tracks":`+list+`}`, auth...); got != want {
|
||||
t.Errorf("其他接口超过 64 KiB 的请求体 = %#v,期望 %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedPathParametersAreNotFound(t *testing.T) {
|
||||
|
||||
@@ -108,6 +108,18 @@ func (s *server) removeMyPlaylistItems(ctx context.Context, c call) (any, error)
|
||||
return nil, s.Playlists.Remove(ctx, c.member.ID, c.r.PathValue("id"), tracksOf(req.Tracks))
|
||||
}
|
||||
|
||||
func (s *server) listMyTrackPlaylists(ctx context.Context, c call) (any, error) {
|
||||
req, err := protocol.DecodeListMyTrackPlaylistsRequest(c.body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids, err := s.Playlists.Containing(ctx, c.member.ID, trackOf(req.Track))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return protocol.ListMyTrackPlaylistsResponse{PlaylistIds: ids}, nil
|
||||
}
|
||||
|
||||
func (s *server) copyMyPlaylistItems(ctx context.Context, c call) (any, error) {
|
||||
req, err := protocol.DecodeCopyMyPlaylistItemsRequest(c.body)
|
||||
if err != nil {
|
||||
|
||||
@@ -452,3 +452,36 @@ func (q *Queries) TouchPlaylist(ctx context.Context, id string) error {
|
||||
_, err := q.db.Exec(ctx, touchPlaylist, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const trackPlaylists = `-- name: TrackPlaylists :many
|
||||
SELECT p.id FROM playlists p
|
||||
JOIN playlist_items i ON i.playlist_id = p.id
|
||||
WHERE p.owner_id = $1 AND i.source = $2 AND i.track_id = $3
|
||||
ORDER BY p.id
|
||||
`
|
||||
|
||||
type TrackPlaylistsParams struct {
|
||||
OwnerID string
|
||||
Source string
|
||||
TrackID string
|
||||
}
|
||||
|
||||
func (q *Queries) TrackPlaylists(ctx context.Context, arg TrackPlaylistsParams) ([]string, error) {
|
||||
rows, err := q.db.Query(ctx, trackPlaylists, arg.OwnerID, arg.Source, arg.TrackID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -218,6 +218,17 @@ func (s *Store) RemovePlaylistItems(ctx context.Context, ownerID, id string, tra
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) TrackPlaylists(ctx context.Context, ownerID string, t track.Track) ([]string, error) {
|
||||
ids, err := s.q.TrackPlaylists(ctx, query.TrackPlaylistsParams{OwnerID: ownerID, Source: string(t.Source), TrackID: t.ID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询曲目 %s 所在的歌单:%w", t.ID, err)
|
||||
}
|
||||
if ids == nil {
|
||||
ids = []string{}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (s *Store) CopyPlaylistItems(ctx context.Context, ownerID, fromID, toID string, tracks []track.Track, limit int) (int, error) {
|
||||
var added int64
|
||||
err := s.owned(ctx, ownerID, fromID)
|
||||
|
||||
@@ -503,6 +503,30 @@ func TestConcurrentRemovalsAndDeletesDoNotDeadlock(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackPlaylistsOnlyListsOwnPlaylists(t *testing.T) {
|
||||
store, _ := newStore(t)
|
||||
alice := registered(t, store, "alice")
|
||||
bob := registered(t, store, "bob")
|
||||
favorites := favoritesOf(t, store, alice.ID)
|
||||
commute := filled(t, store, alice.ID, "通勤", "1")
|
||||
filled(t, store, alice.ID, "空", "2")
|
||||
bobFavorites := favoritesOf(t, store, bob.ID)
|
||||
if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, songItem("1"), 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.AddPlaylistItem(t.Context(), bob.ID, bobFavorites, songItem("1"), 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids, err := store.TrackPlaylists(t.Context(), alice.ID, songItem("1").Track)
|
||||
if want := []string{favorites, commute}; err != nil || !slices.Equal(ids, want) {
|
||||
t.Errorf("所在的歌单 = %v, %v,期望只有本人的 %v", ids, err, want)
|
||||
}
|
||||
ids, err = store.TrackPlaylists(t.Context(), alice.ID, songItem("3").Track)
|
||||
if err != nil || ids == nil || len(ids) != 0 {
|
||||
t.Errorf("不在任何歌单里的歌 = %#v, %v,期望空切片", ids, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMovePlaylistItems(t *testing.T) {
|
||||
store, _ := newStore(t)
|
||||
alice := registered(t, store, "alice")
|
||||
|
||||
@@ -124,6 +124,13 @@ func (s *Service) Remove(ctx context.Context, memberID, id string, tracks []trac
|
||||
return s.playlists.RemovePlaylistItems(ctx, memberID, id, tracks)
|
||||
}
|
||||
|
||||
func (s *Service) Containing(ctx context.Context, memberID string, t track.Track) ([]string, error) {
|
||||
if err := s.registered(ctx, memberID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.playlists.TrackPlaylists(ctx, memberID, t)
|
||||
}
|
||||
|
||||
func (s *Service) Copy(ctx context.Context, memberID, fromID, toID string, tracks []track.Track) (int, error) {
|
||||
if err := s.registered(ctx, memberID); err != nil {
|
||||
return 0, err
|
||||
|
||||
@@ -119,6 +119,9 @@ func TestGuestCannotUsePlaylists(t *testing.T) {
|
||||
if _, err := f.svc.Add(t.Context(), guest.ID, "any", sunny); !errors.Is(err, ErrNotRegistered) {
|
||||
t.Errorf("游客收藏 = %v,期望 ErrNotRegistered", err)
|
||||
}
|
||||
if _, err := f.svc.Containing(t.Context(), guest.ID, sunny); !errors.Is(err, ErrNotRegistered) {
|
||||
t.Errorf("游客查询所在的歌单 = %v,期望 ErrNotRegistered", err)
|
||||
}
|
||||
if err := f.svc.Remove(t.Context(), guest.ID, "any", []track.Track{sunny}); !errors.Is(err, ErrNotRegistered) {
|
||||
t.Errorf("游客批量移出 = %v,期望 ErrNotRegistered", err)
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ type Playlists interface {
|
||||
AddPlaylistItem(ctx context.Context, ownerID, id string, item PlaylistItem, limit int) error
|
||||
// 不在歌单里的歌忽略。
|
||||
RemovePlaylistItems(ctx context.Context, ownerID, id string, tracks []track.Track) error
|
||||
// 只看这个成员自己的歌单;没有命中时返回空切片。
|
||||
TrackPlaylists(ctx context.Context, ownerID string, t track.Track) ([]string, error)
|
||||
// 把 fromID 歌单里的这些歌连同元数据复制到 toID 歌单,返回复制的数量。fromID 不属于 ownerID 时返回 ErrPlaylistNotFound。
|
||||
// 不在 fromID 里的和已在 toID 里的跳过;新复制的排在最前,tracks[0] 最靠前。
|
||||
// 复制之后超过 limit 首时整批拒绝,返回 ErrPlaylistLimit。
|
||||
|
||||
@@ -676,6 +676,18 @@ func (s *Store) RemovePlaylistItems(_ context.Context, ownerID, id string, track
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) TrackPlaylists(_ context.Context, ownerID string, t track.Track) ([]string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
ids := []string{}
|
||||
for _, p := range s.playlists {
|
||||
if p.owner == ownerID && slices.ContainsFunc(p.items, func(i port.PlaylistItem) bool { return i.Track == t }) {
|
||||
ids = append(ids, p.id)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (s *Store) CopyPlaylistItems(_ context.Context, ownerID, fromID, toID string, tracks []track.Track, limit int) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -238,6 +239,13 @@ func TestPlaylistLinkTracks(t *testing.T) {
|
||||
t.Errorf("歌单里的曲目 = %+v,期望一首 ID %s、封面 %s", items.Items, link, coverURL)
|
||||
}
|
||||
|
||||
var memberships struct {
|
||||
PlaylistIDs []string `json:"playlistIds"`
|
||||
}
|
||||
call("POST /api/me/playlist-memberships"+auth+"\n\n"+`{"track": {"source": "link", "id": "https://Example.com/Song.mp3"}}`, http.StatusOK, &memberships)
|
||||
if !slices.Equal(memberships.PlaylistIDs, []string{favorites}) {
|
||||
t.Errorf("换一种大小写查所在的歌单 = %v,期望 [%s]", memberships.PlaylistIDs, favorites)
|
||||
}
|
||||
call("POST /api/me/playlists/"+favorites+"/removals"+auth+"\n\n"+`{"tracks": [{"source": "link", "id": "hTTps://example.COM/Song.mp3"}]}`, http.StatusNoContent, nil)
|
||||
call("GET /api/me/playlists/"+favorites+"/items"+auth, http.StatusOK, &items)
|
||||
if len(items.Items) != 0 {
|
||||
|
||||
@@ -226,6 +226,24 @@ Authorization: Bearer $alice
|
||||
200
|
||||
{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/qilixiang.jpg"}, {"id": "$commute", "name": "上班路上", "favorites": false, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/example.jpg"}]}
|
||||
|
||||
-- request --
|
||||
POST /api/me/playlist-memberships
|
||||
Authorization: Bearer $alice
|
||||
|
||||
{"track": {"source": "netease", "id": "186016"}}
|
||||
-- response --
|
||||
200
|
||||
{"playlistIds": ["$favorites", "$commute"]}
|
||||
|
||||
-- request --
|
||||
POST /api/me/playlist-memberships
|
||||
Authorization: Bearer $alice
|
||||
|
||||
{"track": {"source": "netease", "id": "999"}}
|
||||
-- response --
|
||||
200
|
||||
{"playlistIds": []}
|
||||
|
||||
-- request --
|
||||
POST /api/me/playlists/$favorites/removals
|
||||
Authorization: Bearer $alice
|
||||
|
||||
@@ -110,6 +110,10 @@ func DecodeMoveMyPlaylistItemsRequest(body []byte) (MoveMyPlaylistItemsRequest,
|
||||
return decodeMoveMyPlaylistItemsRequest(body, "")
|
||||
}
|
||||
|
||||
func DecodeListMyTrackPlaylistsRequest(body []byte) (ListMyTrackPlaylistsRequest, error) {
|
||||
return decodeListMyTrackPlaylistsRequest(body, "")
|
||||
}
|
||||
|
||||
func DecodeCreateRoomRequest(body []byte) (CreateRoomRequest, error) {
|
||||
return decodeCreateRoomRequest(body, "")
|
||||
}
|
||||
@@ -574,6 +578,21 @@ func decodeHelloRequest(raw json.RawMessage, path string) (HelloRequest, error)
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeListMyTrackPlaylistsRequest(raw json.RawMessage, path string) (ListMyTrackPlaylistsRequest, error) {
|
||||
var v ListMyTrackPlaylistsRequest
|
||||
m, err := decodeObject(raw, path, "track")
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = requireFields(m, path, "track"); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.Track, err = decodeTrack(m["track"], field(path, "track")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeLoginRequest(raw json.RawMessage, path string) (LoginRequest, error) {
|
||||
var v LoginRequest
|
||||
m, err := decodeObject(raw, path, "password", "username")
|
||||
|
||||
@@ -979,6 +979,29 @@
|
||||
"required": ["tracks"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "listMyTrackPlaylists",
|
||||
"description": "这首歌在我的哪些歌单里",
|
||||
"method": "POST",
|
||||
"path": "/api/me/playlist-memberships",
|
||||
"auth": "member",
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"track": {"$ref": "#/$defs/Track"}
|
||||
},
|
||||
"required": ["track"]
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"playlistIds": {"type": "array", "items": {"type": "string"}}
|
||||
},
|
||||
"required": ["playlistIds"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "listRooms",
|
||||
"description": "房间列表",
|
||||
|
||||
@@ -743,6 +743,33 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ListMyTrackPlaylistsRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"track": {
|
||||
"$ref": "#/components/schemas/Track"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"track"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ListMyTrackPlaylistsResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"playlistIds": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"playlistIds"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ListRoomsResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -1714,7 +1741,7 @@
|
||||
},
|
||||
"info": {
|
||||
"title": "Alisten",
|
||||
"version": "000976bd80c1113a"
|
||||
"version": "5b9cd2cb39780e46"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
@@ -2851,6 +2878,49 @@
|
||||
"summary": "修改昵称和头像;email 用来计算头像哈希,已注册的账号同时保存它,去掉首尾空白后应形如 name@example.com,空字符串表示清除"
|
||||
}
|
||||
},
|
||||
"/api/me/playlist-memberships": {
|
||||
"post": {
|
||||
"operationId": "listMyTrackPlaylists",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ListMyTrackPlaylistsRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ListMyTrackPlaylistsResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "成功"
|
||||
},
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "失败"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"memberToken": []
|
||||
}
|
||||
],
|
||||
"summary": "这首歌在我的哪些歌单里"
|
||||
}
|
||||
},
|
||||
"/api/me/playlists": {
|
||||
"get": {
|
||||
"operationId": "listMyPlaylists",
|
||||
|
||||
+18
-1
@@ -4,7 +4,7 @@ package protocol
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const Version = "000976bd80c1113a"
|
||||
const Version = "5b9cd2cb39780e46"
|
||||
|
||||
type APIKey struct {
|
||||
Key string `json:"key"`
|
||||
@@ -298,6 +298,22 @@ func (v ListMyPlaylistsResponse) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(plain(v))
|
||||
}
|
||||
|
||||
type ListMyTrackPlaylistsRequest struct {
|
||||
Track Track `json:"track"`
|
||||
}
|
||||
|
||||
type ListMyTrackPlaylistsResponse struct {
|
||||
PlaylistIds []string `json:"playlistIds"`
|
||||
}
|
||||
|
||||
func (v ListMyTrackPlaylistsResponse) MarshalJSON() ([]byte, error) {
|
||||
type plain ListMyTrackPlaylistsResponse
|
||||
if v.PlaylistIds == nil {
|
||||
v.PlaylistIds = []string{}
|
||||
}
|
||||
return json.Marshal(plain(v))
|
||||
}
|
||||
|
||||
type ListRoomsResponse struct {
|
||||
Items []RoomListing `json:"items"`
|
||||
}
|
||||
@@ -765,6 +781,7 @@ var (
|
||||
RouteRemoveMyPlaylistItems = Route{Pattern: "POST /api/me/playlists/{id}/removals", Auth: AuthMember, Status: 204}
|
||||
RouteCopyMyPlaylistItems = Route{Pattern: "POST /api/me/playlists/{id}/copies", Auth: AuthMember, Status: 200}
|
||||
RouteMoveMyPlaylistItems = Route{Pattern: "POST /api/me/playlists/{id}/moves", Auth: AuthMember, Status: 204}
|
||||
RouteListMyTrackPlaylists = Route{Pattern: "POST /api/me/playlist-memberships", Auth: AuthMember, Status: 200}
|
||||
RouteListRooms = Route{Pattern: "GET /api/rooms", Auth: AuthNone, Status: 200}
|
||||
RouteGetRoom = Route{Pattern: "GET /api/rooms/{ref}", Auth: AuthNone, Status: 200}
|
||||
RouteCreateRoom = Route{Pattern: "POST /api/rooms", Auth: AuthNone, Status: 201, Location: true}
|
||||
|
||||
@@ -165,6 +165,14 @@ export interface ListMyPlaylistsResponse {
|
||||
items: MyPlaylist[]
|
||||
}
|
||||
|
||||
export interface ListMyTrackPlaylistsRequest {
|
||||
track: Track
|
||||
}
|
||||
|
||||
export interface ListMyTrackPlaylistsResponse {
|
||||
playlistIds: string[]
|
||||
}
|
||||
|
||||
export interface ListRoomsResponse {
|
||||
items: RoomListing[]
|
||||
}
|
||||
@@ -244,6 +252,7 @@ export function httpClient(send: HttpSend) {
|
||||
removeMyPlaylistItems: (id: string, body: RemoveMyPlaylistItemsRequest) => send<void>({ method: 'POST', path: `/api/me/playlists/${encodeURIComponent(id)}/removals`, auth: 'member', body }),
|
||||
copyMyPlaylistItems: (id: string, body: CopyMyPlaylistItemsRequest) => send<CopyMyPlaylistItemsResponse>({ method: 'POST', path: `/api/me/playlists/${encodeURIComponent(id)}/copies`, auth: 'member', body }),
|
||||
moveMyPlaylistItems: (id: string, body: MoveMyPlaylistItemsRequest) => send<void>({ method: 'POST', path: `/api/me/playlists/${encodeURIComponent(id)}/moves`, auth: 'member', body }),
|
||||
listMyTrackPlaylists: (body: ListMyTrackPlaylistsRequest) => send<ListMyTrackPlaylistsResponse>({ method: 'POST', path: '/api/me/playlist-memberships', auth: 'member', body }),
|
||||
listRooms: () => send<ListRoomsResponse>({ method: 'GET', path: '/api/rooms', auth: 'none' }),
|
||||
getRoom: (ref: string) => send<RoomListing>({ method: 'GET', path: `/api/rooms/${encodeURIComponent(ref)}`, auth: 'none' }),
|
||||
createRoom: (body: CreateRoomRequest) => send<CreateRoomResponse>({ method: 'POST', path: '/api/rooms', auth: 'none', body }),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
|
||||
|
||||
export const PROTOCOL_VERSION = '000976bd80c1113a'
|
||||
export const PROTOCOL_VERSION = '5b9cd2cb39780e46'
|
||||
|
||||
export interface APIKey {
|
||||
key: string
|
||||
|
||||
Reference in new issue
Block a user