feat: page, sort and search stored media and delete several at once in the admin API

This commit is contained in:
DiheChen committed 2026-11-11 00:19:07 +08:00
1 parent c39331aa75
commit fb751ca57f
23 files changed
+1161 -213

No files matched your search

+93
View File
@@ -16,6 +16,7 @@ var (
patternLoginRequestUsername = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
patternRedeemPairingCodeRequestCode = regexp.MustCompile(`^[2-9A-HJ-NP-Z]{8}$`)
patternRegisterRequestUsername = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
patternStoredMediaRefKey = regexp.MustCompile(`^[0-9a-f]{64}$`)
patternTrackIDWhenBilibili = regexp.MustCompile(`^BV[1-9A-HJ-NP-Za-km-z]{10}(?:/([2-9]|[1-9][0-9]{1,3}))?$`)
)
@@ -182,6 +183,14 @@ func DecodeAdminSetGithubCredentialRequest(body []byte) (AdminSetGithubCredentia
return decodeAdminSetGithubCredentialRequest(body, "")
}
func DecodeAdminListMediaRequest(body []byte) (AdminListMediaRequest, error) {
return decodeAdminListMediaRequest(body, "")
}
func DecodeAdminRemoveMediaRequest(body []byte) (AdminRemoveMediaRequest, error) {
return decodeAdminRemoveMediaRequest(body, "")
}
func DecodeAdminUpdateSiteRequest(body []byte) (AdminUpdateSiteRequest, error) {
return decodeAdminUpdateSiteRequest(body, "")
}
@@ -346,12 +355,66 @@ func decodeAdminCreateRoomRequest(raw json.RawMessage, path string) (AdminCreate
return v, nil
}
func decodeAdminListMediaRequest(raw json.RawMessage, path string) (AdminListMediaRequest, error) {
var v AdminListMediaRequest
m, err := decodeObject(raw, path, "page", "q", "sort")
if err != nil {
return v, err
}
if r, ok := m["page"]; ok {
x, err := decodeInteger(r, field(path, "page"))
if err != nil {
return v, err
}
if err = checkMinimum(x, field(path, "page"), 1); err != nil {
return v, err
}
v.Page = &x
}
if r, ok := m["q"]; ok {
x, err := decodeString(r, field(path, "q"))
if err != nil {
return v, err
}
if err = checkMaxLength(x, field(path, "q"), 100); err != nil {
return v, err
}
v.Q = &x
}
if r, ok := m["sort"]; ok {
x, err := decodeMediaSort(r, field(path, "sort"))
if err != nil {
return v, err
}
v.Sort = &x
}
return v, nil
}
func decodeAdminOpenRegistrationRequest(raw json.RawMessage, path string) (AdminOpenRegistrationRequest, error) {
var v AdminOpenRegistrationRequest
_, err := decodeObject(raw, path)
return v, err
}
func decodeAdminRemoveMediaRequest(raw json.RawMessage, path string) (AdminRemoveMediaRequest, error) {
var v AdminRemoveMediaRequest
m, err := decodeObject(raw, path, "media")
if err != nil {
return v, err
}
if err = requireFields(m, path, "media"); err != nil {
return v, err
}
if v.Media, err = decodeArray(decodeStoredMediaRef, 50)(m["media"], field(path, "media")); err != nil {
return v, err
}
if err = checkMinItems(v.Media, field(path, "media"), 1); err != nil {
return v, err
}
return v, nil
}
func decodeAdminResetAccountPasswordRequest(raw json.RawMessage, path string) (AdminResetAccountPasswordRequest, error) {
var v AdminResetAccountPasswordRequest
_, err := decodeObject(raw, path)
@@ -912,6 +975,18 @@ func decodeLoginRequest(raw json.RawMessage, path string) (LoginRequest, error)
return v, nil
}
func decodeMediaSort(raw json.RawMessage, path string) (MediaSort, error) {
s, err := decodeString(raw, path)
if err != nil {
return "", err
}
switch v := MediaSort(s); v {
case MediaSortStored, MediaSortIdle, MediaSortSize:
return v, nil
}
return "", invalid(path, "取值应为 stored、idle、size 之一")
}
func decodeMoveMyPlaylistItemsRequest(raw json.RawMessage, path string) (MoveMyPlaylistItemsRequest, error) {
var v MoveMyPlaylistItemsRequest
m, err := decodeObject(raw, path, "before", "tracks")
@@ -1366,6 +1441,24 @@ func decodeStartGithubLoginRequest(raw json.RawMessage, path string) (StartGithu
return v, nil
}
func decodeStoredMediaRef(raw json.RawMessage, path string) (StoredMediaRef, error) {
var v StoredMediaRef
m, err := decodeObject(raw, path, "key")
if err != nil {
return v, err
}
if err = requireFields(m, path, "key"); err != nil {
return v, err
}
if v.Key, err = decodeString(m["key"], field(path, "key")); err != nil {
return v, err
}
if err = checkPattern(v.Key, field(path, "key"), patternStoredMediaRefKey); err != nil {
return v, err
}
return v, nil
}
func decodeTrack(raw json.RawMessage, path string) (Track, error) {
var v Track
m, err := decodeObject(raw, path, "id", "source")
+55 -11
View File
@@ -309,11 +309,26 @@
"source": {"$ref": "#/$defs/Source"},
"link": {"type": "string", "description": "这首歌的页面:链接曲目是链接本身,B 站的歌是视频页面"},
"title": {"type": "string"},
"artist": {"type": "string", "description": "不知道时为空字符串"},
"coverUrl": {"type": "string", "description": "可以直接打开的封面地址,没有封面时为空字符串"},
"size": {"type": "integer", "description": "音频文件的字节数"},
"storedAtMs": {"type": "integer"},
"lastPlayedAtMs": {"anyOf": [{"type": "integer"}, {"type": "null"}], "description": "没有播放过时为 null"}
},
"required": ["key", "source", "link", "title", "size", "storedAtMs", "lastPlayedAtMs"]
"required": ["key", "source", "link", "title", "artist", "coverUrl", "size", "storedAtMs", "lastPlayedAtMs"]
},
"MediaSort": {
"type": "string",
"description": "stored:存储时间从新到旧;idle:最近播放从早到晚,从没播放过的排最前;size:占用从大到小。三种排序都以曲目 ID 作为最后的排序键,翻页时不重复也不遗漏",
"enum": ["stored", "idle", "size"]
},
"StoredMediaRef": {
"type": "object",
"additionalProperties": false,
"properties": {
"key": {"type": "string", "description": "StoredMedia 的 key:64 个小写十六进制字符", "pattern": "^[0-9a-f]{64}$"}
},
"required": ["key"]
},
"SourceAccount": {
"type": "object",
@@ -1544,26 +1559,55 @@
},
{
"name": "adminListMedia",
"description": "已存储的链接曲目和 B 站的歌",
"method": "GET",
"path": "/api/admin/media",
"description": "已存储的链接曲目和 B 站的歌,每页 50 首;页码超出最后一页时 items 为空,其余字段照常",
"method": "POST",
"path": "/api/admin/media/queries",
"auth": "admin",
"request": {
"type": "object",
"additionalProperties": false,
"properties": {
"page": {"$ref": "#/$defs/Page", "description": "页码,从 1 开始,缺省为 1"},
"sort": {"$ref": "#/$defs/MediaSort", "description": "缺省为 stored"},
"q": {"type": "string", "description": "按标题搜索:标题包含它就算匹配,不区分大小写,% 和 _ 按字面匹配;缺省或为空时不搜索", "maxLength": 100}
}
},
"response": {
"type": "object",
"additionalProperties": false,
"properties": {
"items": {"type": "array", "items": {"$ref": "#/$defs/StoredMedia"}}
"items": {"type": "array", "items": {"$ref": "#/$defs/StoredMedia"}},
"total": {"type": "integer", "description": "匹配的歌曲数,不只是这一页"},
"totalBytes": {"type": "integer", "description": "匹配的歌曲的音频字节数之和"},
"page": {"type": "integer"},
"pageSize": {"type": "integer", "description": "每页的条数,固定为 50"}
},
"required": ["items"]
"required": ["items", "total", "totalBytes", "page", "pageSize"]
}
},
{
"name": "adminDeleteMedia",
"description": "删除一首已存储的歌的音频和封面,并清除它的准备结果",
"method": "DELETE",
"path": "/api/admin/media/{key}",
"name": "adminRemoveMedia",
"description": "删除若干首已存储的歌的音频和封面,并清除它们的准备结果,一次 1 到 50 首:正在房间里使用的不会删除,放进 inUse;不存在的忽略。逐首删除,中途出错时返回错误,出错之前已经删掉的不会恢复,用同样的请求重试即可",
"method": "POST",
"path": "/api/admin/media/removals",
"auth": "admin",
"status": 204
"request": {
"type": "object",
"additionalProperties": false,
"properties": {
"media": {"type": "array", "items": {"$ref": "#/$defs/StoredMediaRef"}, "minItems": 1, "maxItems": 50}
},
"required": ["media"]
},
"response": {
"type": "object",
"additionalProperties": false,
"properties": {
"deleted": {"type": "array", "items": {"type": "string"}, "description": "删掉的歌的 key"},
"inUse": {"type": "array", "items": {"type": "string"}, "description": "正在房间里使用、没有删除的歌的 key"}
},
"required": ["deleted", "inUse"]
}
},
{
"name": "adminUpdateSite",
+149 -19
View File
@@ -307,6 +307,26 @@
],
"type": "object"
},
"AdminListMediaRequest": {
"additionalProperties": false,
"properties": {
"page": {
"description": "页码,从 1 开始,缺省为 1",
"minimum": 1,
"type": "integer"
},
"q": {
"description": "按标题搜索:标题包含它就算匹配,不区分大小写,% 和 _ 按字面匹配;缺省或为空时不搜索",
"maxLength": 100,
"type": "string"
},
"sort": {
"$ref": "#/components/schemas/MediaSort",
"description": "缺省为 stored"
}
},
"type": "object"
},
"AdminListMediaResponse": {
"additionalProperties": false,
"properties": {
@@ -315,10 +335,29 @@
"$ref": "#/components/schemas/StoredMedia"
},
"type": "array"
},
"page": {
"type": "integer"
},
"pageSize": {
"description": "每页的条数,固定为 50",
"type": "integer"
},
"total": {
"description": "匹配的歌曲数,不只是这一页",
"type": "integer"
},
"totalBytes": {
"description": "匹配的歌曲的音频字节数之和",
"type": "integer"
}
},
"required": [
"items"
"items",
"total",
"totalBytes",
"page",
"pageSize"
],
"type": "object"
},
@@ -341,6 +380,47 @@
"additionalProperties": false,
"type": "object"
},
"AdminRemoveMediaRequest": {
"additionalProperties": false,
"properties": {
"media": {
"items": {
"$ref": "#/components/schemas/StoredMediaRef"
},
"maxItems": 50,
"minItems": 1,
"type": "array"
}
},
"required": [
"media"
],
"type": "object"
},
"AdminRemoveMediaResponse": {
"additionalProperties": false,
"properties": {
"deleted": {
"description": "删掉的歌的 key",
"items": {
"type": "string"
},
"type": "array"
},
"inUse": {
"description": "正在房间里使用、没有删除的歌的 key",
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"deleted",
"inUse"
],
"type": "object"
},
"AdminResetAccountPasswordRequest": {
"additionalProperties": false,
"type": "object"
@@ -1134,6 +1214,15 @@
],
"type": "object"
},
"MediaSort": {
"description": "stored:存储时间从新到旧;idle:最近播放从早到晚,从没播放过的排最前;size:占用从大到小。三种排序都以曲目 ID 作为最后的排序键,翻页时不重复也不遗漏",
"enum": [
"stored",
"idle",
"size"
],
"type": "string"
},
"Member": {
"additionalProperties": false,
"properties": {
@@ -2048,6 +2137,14 @@
"StoredMedia": {
"additionalProperties": false,
"properties": {
"artist": {
"description": "不知道时为空字符串",
"type": "string"
},
"coverUrl": {
"description": "可以直接打开的封面地址,没有封面时为空字符串",
"type": "string"
},
"key": {
"description": "曲目 ID 的 SHA-256(十六进制),用于删除;链接曲目的 ID 是规范化之后的链接",
"type": "string"
@@ -2086,12 +2183,28 @@
"source",
"link",
"title",
"artist",
"coverUrl",
"size",
"storedAtMs",
"lastPlayedAtMs"
],
"type": "object"
},
"StoredMediaRef": {
"additionalProperties": false,
"properties": {
"key": {
"description": "StoredMedia 的 key:64 个小写十六进制字符",
"pattern": "^[0-9a-f]{64}$",
"type": "string"
}
},
"required": [
"key"
],
"type": "object"
},
"Track": {
"additionalProperties": false,
"allOf": [
@@ -2265,7 +2378,7 @@
},
"info": {
"title": "Alisten",
"version": "6bee23791ffa52ad"
"version": "ce462456c63356c6"
},
"openapi": "3.1.0",
"paths": {
@@ -3062,9 +3175,19 @@
"summary": "写入或覆盖 YouTube cookies,不返回 cookies"
}
},
"/api/admin/media": {
"get": {
"/api/admin/media/queries": {
"post": {
"operationId": "adminListMedia",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminListMediaRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
@@ -3092,24 +3215,31 @@
"adminToken": []
}
],
"summary": "已存储的链接曲目和 B 站的歌"
"summary": "已存储的链接曲目和 B 站的歌,每页 50 首;页码超出最后一页时 items 为空,其余字段照常"
}
},
"/api/admin/media/{key}": {
"delete": {
"operationId": "adminDeleteMedia",
"parameters": [
{
"in": "path",
"name": "key",
"required": true,
"schema": {
"type": "string"
"/api/admin/media/removals": {
"post": {
"operationId": "adminRemoveMedia",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminRemoveMediaRequest"
}
}
}
],
},
"required": true
},
"responses": {
"204": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminRemoveMediaResponse"
}
}
},
"description": "成功"
},
"default": {
@@ -3128,7 +3258,7 @@
"adminToken": []
}
],
"summary": "删除一首已存储的歌的音频和封面,并清除它的准备结果"
"summary": "删除若干首已存储的歌的音频和封面,并清除它们的准备结果,一次 1 到 50 首:正在房间里使用的不会删除,放进 inUse;不存在的忽略。逐首删除,中途出错时返回错误,出错之前已经删掉的不会恢复,用同样的请求重试即可"
}
},
"/api/admin/registration": {
+56 -4
View File
@@ -4,7 +4,7 @@ package protocol
import "encoding/json"
const Version = "6bee23791ffa52ad"
const Version = "ce462456c63356c6"
type APIKey struct {
Key string `json:"key"`
@@ -95,8 +95,18 @@ type AdminGetYoutubeCredentialResponse struct {
UpdatedAtMs int64 `json:"updatedAtMs"`
}
type AdminListMediaRequest struct {
Page *int64 `json:"page,omitempty"`
Q *string `json:"q,omitempty"`
Sort *MediaSort `json:"sort,omitempty"`
}
type AdminListMediaResponse struct {
Items []StoredMedia `json:"items"`
Items []StoredMedia `json:"items"`
Page int64 `json:"page"`
PageSize int64 `json:"pageSize"`
Total int64 `json:"total"`
TotalBytes int64 `json:"totalBytes"`
}
func (v AdminListMediaResponse) MarshalJSON() ([]byte, error) {
@@ -121,6 +131,34 @@ func (v AdminListRoomsResponse) MarshalJSON() ([]byte, error) {
type AdminOpenRegistrationRequest struct{}
type AdminRemoveMediaRequest struct {
Media []StoredMediaRef `json:"media"`
}
func (v AdminRemoveMediaRequest) MarshalJSON() ([]byte, error) {
type plain AdminRemoveMediaRequest
if v.Media == nil {
v.Media = []StoredMediaRef{}
}
return json.Marshal(plain(v))
}
type AdminRemoveMediaResponse struct {
Deleted []string `json:"deleted"`
InUse []string `json:"inUse"`
}
func (v AdminRemoveMediaResponse) MarshalJSON() ([]byte, error) {
type plain AdminRemoveMediaResponse
if v.Deleted == nil {
v.Deleted = []string{}
}
if v.InUse == nil {
v.InUse = []string{}
}
return json.Marshal(plain(v))
}
type AdminResetAccountPasswordRequest struct{}
type AdminResetAccountPasswordResponse struct {
@@ -444,6 +482,14 @@ type LoginResponse struct {
MemberToken string `json:"memberToken"`
}
type MediaSort string
const (
MediaSortStored MediaSort = "stored"
MediaSortIdle MediaSort = "idle"
MediaSortSize MediaSort = "size"
)
type Member struct {
AvatarHash string `json:"avatarHash"`
ID string `json:"id"`
@@ -854,6 +900,8 @@ type StartGithubLoginResponse struct {
}
type StoredMedia struct {
Artist string `json:"artist"`
CoverURL string `json:"coverUrl"`
Key string `json:"key"`
LastPlayedAtMs *int64 `json:"lastPlayedAtMs"`
Link string `json:"link"`
@@ -863,6 +911,10 @@ type StoredMedia struct {
Title string `json:"title"`
}
type StoredMediaRef struct {
Key string `json:"key"`
}
type Track struct {
ID string `json:"id"`
Source Source `json:"source"`
@@ -985,8 +1037,8 @@ var (
RouteAdminGetGithubCredential = Route{Pattern: "GET /api/admin/credentials/github", Auth: AuthAdmin, Status: 200}
RouteAdminSetGithubCredential = Route{Pattern: "PUT /api/admin/credentials/github", Auth: AuthAdmin, Status: 204}
RouteAdminDeleteGithubCredential = Route{Pattern: "DELETE /api/admin/credentials/github", Auth: AuthAdmin, Status: 204}
RouteAdminListMedia = Route{Pattern: "GET /api/admin/media", Auth: AuthAdmin, Status: 200}
RouteAdminDeleteMedia = Route{Pattern: "DELETE /api/admin/media/{key}", Auth: AuthAdmin, Status: 204}
RouteAdminListMedia = Route{Pattern: "POST /api/admin/media/queries", Auth: AuthAdmin, Status: 200}
RouteAdminRemoveMedia = Route{Pattern: "POST /api/admin/media/removals", Auth: AuthAdmin, Status: 200}
RouteAdminUpdateSite = Route{Pattern: "PATCH /api/admin/site", Auth: AuthAdmin, Status: 200}
RouteAdminOpenRegistration = Route{Pattern: "PUT /api/admin/registration", Auth: AuthAdmin, Status: 204}
RouteAdminCloseRegistration = Route{Pattern: "DELETE /api/admin/registration", Auth: AuthAdmin, Status: 204}