feat: add backend, database, and protocol foundations
This commit is contained in:
commit
ec08d487e1
179 files changed
+27079
No files matched your search
@@ -0,0 +1,358 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/admin"
|
||||
"github.com/dihemoe/alisten/internal/app/catalog"
|
||||
"github.com/dihemoe/alisten/internal/app/identity"
|
||||
"github.com/dihemoe/alisten/internal/app/lobby"
|
||||
"github.com/dihemoe/alisten/internal/app/media"
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/app/realtime"
|
||||
"github.com/dihemoe/alisten/internal/app/site"
|
||||
"github.com/dihemoe/alisten/internal/domain/member"
|
||||
"github.com/dihemoe/alisten/internal/domain/room"
|
||||
"github.com/dihemoe/alisten/internal/lib/logctx"
|
||||
"github.com/dihemoe/alisten/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
maxBodyBytes = 64 << 10
|
||||
requestDeadline = 3 * time.Minute
|
||||
)
|
||||
|
||||
type Services struct {
|
||||
Identity *identity.Service
|
||||
Lobby *lobby.Service
|
||||
Admin *admin.Service
|
||||
Gateway *realtime.Gateway
|
||||
Catalog *catalog.Service
|
||||
Media *media.Library
|
||||
Playback *media.Playback
|
||||
Site *site.Service
|
||||
|
||||
// 浏览器访问对象存储时使用的地址前缀,结尾没有 /。
|
||||
StorageURL string
|
||||
}
|
||||
|
||||
type server struct {
|
||||
Services
|
||||
tracer trace.Tracer
|
||||
logger *slog.Logger
|
||||
commands *prometheus.CounterVec
|
||||
redirects *prometheus.CounterVec
|
||||
}
|
||||
|
||||
type call struct {
|
||||
r *http.Request
|
||||
body []byte
|
||||
member member.Member
|
||||
roomID string
|
||||
ticket string
|
||||
// 票据是常驻房间的 API Key。
|
||||
bot bool
|
||||
}
|
||||
|
||||
type handlerFunc func(ctx context.Context, c call) (any, error)
|
||||
|
||||
// 带 Location 的接口,处理函数返回 located。
|
||||
type located struct {
|
||||
location string
|
||||
body any
|
||||
}
|
||||
|
||||
func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider, logger *slog.Logger) http.Handler {
|
||||
commands := prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "alisten_room_commands_total",
|
||||
Help: "房间内请求的数量,按请求类型和结果计数,结果为 ok 或错误码。",
|
||||
}, []string{"type", "result"})
|
||||
redirects := prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "alisten_media_redirects_total",
|
||||
Help: "/media 跳转的数量,source 为 netease 或 qq,结果为 ok 或错误码。",
|
||||
}, []string{"source", "result"})
|
||||
metrics.MustRegister(commands, redirects)
|
||||
srv := &server{
|
||||
Services: s,
|
||||
tracer: tracer.Tracer("github.com/dihemoe/alisten/internal/adapter/in/api"),
|
||||
logger: logger,
|
||||
commands: commands,
|
||||
redirects: redirects,
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
srv.handle(mux, protocol.RouteCreateMember, srv.createMember)
|
||||
srv.handle(mux, protocol.RouteGetMe, srv.getMe)
|
||||
srv.handle(mux, protocol.RouteUpdateMe, srv.updateMe)
|
||||
srv.handle(mux, protocol.RouteListRooms, srv.listRooms)
|
||||
srv.handle(mux, protocol.RouteGetRoom, srv.getRoom)
|
||||
srv.handle(mux, protocol.RouteCreateRoom, srv.createRoom)
|
||||
srv.handle(mux, protocol.RouteCreateTicket, srv.createTicket)
|
||||
srv.handle(mux, protocol.RouteGetSite, srv.getSite)
|
||||
for _, route := range protocol.RoomRoutes {
|
||||
srv.handle(mux, route.Route, srv.roomRequest(route))
|
||||
}
|
||||
srv.handle(mux, protocol.RouteAdminListRooms, srv.adminListRooms)
|
||||
srv.handle(mux, protocol.RouteAdminCreateRoom, srv.adminCreateRoom)
|
||||
srv.handle(mux, protocol.RouteAdminUpdateRoom, srv.adminUpdateRoom)
|
||||
srv.handle(mux, protocol.RouteAdminDeleteRoom, srv.adminDeleteRoom)
|
||||
srv.handle(mux, protocol.RouteAdminGetRoomApiKey, srv.adminGetRoomAPIKey)
|
||||
srv.handle(mux, protocol.RouteAdminResetRoomApiKey, srv.adminResetRoomAPIKey)
|
||||
srv.handle(mux, protocol.RouteAdminGetNeteaseCredential, srv.adminGetNeteaseCredential)
|
||||
srv.handle(mux, protocol.RouteAdminGetQQCredential, srv.adminGetQQCredential)
|
||||
srv.handle(mux, protocol.RouteAdminStartNeteaseLogin, srv.adminStartNeteaseLogin)
|
||||
srv.handle(mux, protocol.RouteAdminCheckNeteaseLogin, srv.adminCheckNeteaseLogin)
|
||||
srv.handle(mux, protocol.RouteAdminStartQQLogin, srv.adminStartQQLogin)
|
||||
srv.handle(mux, protocol.RouteAdminCheckQQLogin, srv.adminCheckQQLogin)
|
||||
srv.handle(mux, protocol.RouteAdminGetBilibiliCredential, srv.adminGetBilibiliCredential)
|
||||
srv.handle(mux, protocol.RouteAdminStartBilibiliLogin, srv.adminStartBilibiliLogin)
|
||||
srv.handle(mux, protocol.RouteAdminCheckBilibiliLogin, srv.adminCheckBilibiliLogin)
|
||||
srv.handle(mux, protocol.RouteAdminGetYoutubeCredential, srv.adminGetYoutubeCredential)
|
||||
srv.handle(mux, protocol.RouteAdminSetYoutubeCredential, srv.adminSetYoutubeCredential)
|
||||
srv.handle(mux, protocol.RouteAdminDeleteYoutubeCredential, srv.adminDeleteYoutubeCredential)
|
||||
srv.handle(mux, protocol.RouteAdminListMedia, srv.adminListMedia)
|
||||
srv.handle(mux, protocol.RouteAdminDeleteMedia, srv.adminDeleteMedia)
|
||||
srv.handle(mux, protocol.RouteAdminUpdateSite, srv.adminUpdateSite)
|
||||
mux.HandleFunc("GET /ws", srv.websocket)
|
||||
mux.HandleFunc("GET /api/room/events", srv.roomEvents)
|
||||
mux.HandleFunc("GET /media/{source}/{id}", srv.redirectMedia)
|
||||
// HTTP 的错误响应体是 JSON,而 ServeMux 自带的 404、405 响应是纯文本。不带方法的兜底路由也会匹配方法不符的请求,
|
||||
// 因此它们得到 404:错误码中没有与 405 对应的。
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeError(w, protocol.ErrorInfo{Code: protocol.ErrorCodeNotFound, Message: "接口不存在"})
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
// 不带 Origin 的请求不检查:bot 从服务端发起的调用没有 Origin。
|
||||
func SameOrigin(allowedOrigins []string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if origin := r.Header.Get("Origin"); origin != "" && !slices.Contains(allowedOrigins, origin) {
|
||||
writeError(w, protocol.ErrorInfo{Code: protocol.ErrorCodeForbidden, Message: "服务器不接受从这个网址发出的操作,请用网站的正式地址打开。"})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handle(mux *http.ServeMux, route protocol.Route, fn handlerFunc) {
|
||||
mux.HandleFunc(route.Pattern, func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), requestDeadline)
|
||||
defer cancel()
|
||||
ctx, resp, err := s.serve(ctx, w, r, route.Auth, func(ctx context.Context, c call) (any, error) {
|
||||
resp, err := fn(ctx, c)
|
||||
if err != nil || !route.Location {
|
||||
return resp, err
|
||||
}
|
||||
l := resp.(located)
|
||||
w.Header().Set("Location", l.location)
|
||||
return l.body, nil
|
||||
})
|
||||
if err != nil {
|
||||
s.fail(ctx, w, r, err)
|
||||
return
|
||||
}
|
||||
if route.Status == http.StatusNoContent {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
writeJSON(w, route.Status, resp)
|
||||
})
|
||||
}
|
||||
|
||||
// 先鉴权再读请求体:后台没有启用时,无论请求是否合法都返回 FORBIDDEN。返回的 ctx 带有凭证所属的成员和房间,
|
||||
// 出错时的日志也要带上它们。
|
||||
func (s *server) serve(ctx context.Context, w http.ResponseWriter, r *http.Request, auth protocol.Auth, fn handlerFunc) (logCtx context.Context, _ any, err error) {
|
||||
defer func() {
|
||||
if value := recover(); value != nil {
|
||||
logCtx, err = ctx, s.panicked(ctx, r, value)
|
||||
}
|
||||
}()
|
||||
c := call{r: r}
|
||||
switch auth {
|
||||
case protocol.AuthNone:
|
||||
case protocol.AuthMember:
|
||||
m, err := s.Identity.Authenticate(ctx, bearer(r))
|
||||
if err != nil {
|
||||
return ctx, nil, err
|
||||
}
|
||||
c.member = m
|
||||
ctx = logctx.WithMember(ctx, m.ID)
|
||||
case protocol.AuthTicket:
|
||||
c.ticket = bearer(r)
|
||||
holder, err := s.Identity.VerifyTicket(ctx, c.ticket)
|
||||
if err != nil {
|
||||
return ctx, nil, err
|
||||
}
|
||||
c.roomID, c.member, c.bot = holder.RoomID, holder.Member, holder.Bot
|
||||
ctx = logctx.WithMember(logctx.WithRoom(ctx, holder.RoomID), holder.Member.ID)
|
||||
case protocol.AuthAdmin:
|
||||
if err := s.Admin.Authorize(bearer(r)); err != nil {
|
||||
return ctx, nil, err
|
||||
}
|
||||
default:
|
||||
// 未知的凭证类型按失败处理,不能当作不需要凭证。
|
||||
return ctx, nil, fmt.Errorf("不支持的凭证类型 %d", auth)
|
||||
}
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodDelete {
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes))
|
||||
if err != nil {
|
||||
return ctx, nil, bodyError(r, err)
|
||||
}
|
||||
c.body = body
|
||||
}
|
||||
resp, err := fn(ctx, c)
|
||||
return ctx, resp, err
|
||||
}
|
||||
|
||||
// HTTP 的错误响应体是 JSON,所以 panic 要在写出响应之前恢复,交给 fail 回复 INTERNAL。
|
||||
func (s *server) panicked(ctx context.Context, r *http.Request, value any) error {
|
||||
// ErrAbortHandler 表示有意中止响应,net/http 处理它时不记录日志。
|
||||
if err, ok := value.(error); ok && errors.Is(err, http.ErrAbortHandler) {
|
||||
panic(value)
|
||||
}
|
||||
s.logger.ErrorContext(ctx, "处理 HTTP 请求时发生 panic",
|
||||
"panic", fmt.Sprint(value),
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"stack", string(debug.Stack()),
|
||||
)
|
||||
return errPanicked
|
||||
}
|
||||
|
||||
var errUnreadableBody = errors.New("无法读取请求体")
|
||||
|
||||
// 恢复 panic 的地方已经记录了日志,回复时不再重复记录。
|
||||
var errPanicked = errors.New("服务器出错了,请稍后再试。")
|
||||
|
||||
// 无法读取请求体是客户端的问题,不是内部错误。连接读取失败时,net/http 在返回错误之前已经取消了
|
||||
// r.Context(),此时按客户端断开处理;连接仍在时,是请求体本身不合法,比如分块编码有误。
|
||||
func bodyError(r *http.Request, err error) error {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
return err
|
||||
}
|
||||
if r.Context().Err() != nil {
|
||||
return r.Context().Err()
|
||||
}
|
||||
return errUnreadableBody
|
||||
}
|
||||
|
||||
// 认证方案的名称不区分大小写,和凭证之间可以有多个空格(RFC 9110)。
|
||||
func bearer(r *http.Request) string {
|
||||
scheme, credential, ok := strings.Cut(r.Header.Get("Authorization"), " ")
|
||||
if !ok || !strings.EqualFold(scheme, "Bearer") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimLeft(credential, " ")
|
||||
}
|
||||
|
||||
var knownErrors = []struct {
|
||||
err error
|
||||
code protocol.ErrorCode
|
||||
}{
|
||||
{errUnreadableBody, protocol.ErrorCodeInvalidArgument},
|
||||
{port.ErrRoomNotFound, protocol.ErrorCodeNotFound},
|
||||
{port.ErrSlugTaken, protocol.ErrorCodeConflict},
|
||||
{media.ErrNotFound, protocol.ErrorCodeNotFound},
|
||||
{media.ErrNotInRoom, protocol.ErrorCodeNotFound},
|
||||
{media.ErrInUse, protocol.ErrorCodeConflict},
|
||||
{identity.ErrInvalidMemberToken, protocol.ErrorCodeUnauthorized},
|
||||
{identity.ErrWrongPassword, protocol.ErrorCodeUnauthorized},
|
||||
{admin.ErrInvalidToken, protocol.ErrorCodeUnauthorized},
|
||||
{admin.ErrDisabled, protocol.ErrorCodeForbidden},
|
||||
{admin.ErrUnrecognizedCookies, protocol.ErrorCodeInvalidArgument},
|
||||
{identity.ErrInvalidTicket, protocol.ErrorCodeUnauthorized},
|
||||
{identity.ErrBotSession, protocol.ErrorCodeUnauthorized},
|
||||
{realtime.ErrRequesterNotAllowed, protocol.ErrorCodeInvalidArgument},
|
||||
{site.ErrInvalidBrandName, protocol.ErrorCodeInvalidArgument},
|
||||
{realtime.ErrUnavailable, protocol.ErrorCodeUnavailable},
|
||||
{realtime.ErrTooManyObservers, protocol.ErrorCodeRateLimited},
|
||||
{port.ErrProviderFailed, protocol.ErrorCodeProviderError},
|
||||
{media.ErrNoPlayback, protocol.ErrorCodeProviderError},
|
||||
{catalog.ErrRateLimited, protocol.ErrorCodeRateLimited},
|
||||
{errClientOutdated, protocol.ErrorCodeClientOutdated},
|
||||
{errPanicked, protocol.ErrorCodeInternal},
|
||||
}
|
||||
|
||||
func errorInfo(err error) (info protocol.ErrorInfo, expected bool) {
|
||||
var argument *protocol.ArgumentError
|
||||
if errors.As(err, &argument) {
|
||||
return protocol.ErrorInfo{Code: protocol.ErrorCodeInvalidArgument, Message: argument.Error()}, true
|
||||
}
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
return protocol.ErrorInfo{Code: protocol.ErrorCodeInvalidArgument, Message: "请求体不能超过 64 KiB"}, true
|
||||
}
|
||||
var roomErr *room.Error
|
||||
if errors.As(err, &roomErr) {
|
||||
return protocol.ErrorInfo{Code: roomErrorCodes[roomErr.Kind], Message: roomErr.Message}, true
|
||||
}
|
||||
var rejectedLink *catalog.RejectedLinkError
|
||||
if errors.As(err, &rejectedLink) {
|
||||
return protocol.ErrorInfo{Code: protocol.ErrorCodeInvalidArgument, Message: rejectedLink.Error()}, true
|
||||
}
|
||||
for _, known := range knownErrors {
|
||||
if errors.Is(err, known.err) {
|
||||
return protocol.ErrorInfo{Code: known.code, Message: known.err.Error()}, true
|
||||
}
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return protocol.ErrorInfo{Code: protocol.ErrorCodeUnavailable, Message: "服务器没有及时响应,请稍后再试。"}, true
|
||||
}
|
||||
return protocol.ErrorInfo{Code: protocol.ErrorCodeInternal, Message: "服务器出错了,请稍后再试。"}, false
|
||||
}
|
||||
|
||||
// 请求类型取自解码之后的请求,取值有限,可以作为标签。
|
||||
func (s *server) count(typ string, err error) {
|
||||
s.commands.WithLabelValues(typ, resultLabel(err)).Inc()
|
||||
}
|
||||
|
||||
func resultLabel(err error) string {
|
||||
if err == nil {
|
||||
return "ok"
|
||||
}
|
||||
info, _ := errorInfo(err)
|
||||
return string(info.Code)
|
||||
}
|
||||
|
||||
func (s *server) fail(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
|
||||
if errors.Is(err, context.Canceled) && r.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
info, expected := errorInfo(err)
|
||||
if !expected {
|
||||
s.logger.ErrorContext(withRequestID(ctx, err), "处理 HTTP 请求失败", "method", r.Method, "path", r.URL.Path, "error", err)
|
||||
}
|
||||
writeError(w, info)
|
||||
}
|
||||
|
||||
func withRequestID(ctx context.Context, err error) context.Context {
|
||||
var failed *realtime.RequestError
|
||||
if errors.As(err, &failed) {
|
||||
return logctx.WithRequest(ctx, string(failed.ID))
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, info protocol.ErrorInfo) {
|
||||
writeJSON(w, info.Code.HTTPStatus(), protocol.ErrorResponse{Error: info})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
"github.com/dihemoe/alisten/protocol"
|
||||
)
|
||||
|
||||
func (s *server) answer(ctx context.Context, roomID string, data any) (resp any, handled bool, err error) {
|
||||
switch d := data.(type) {
|
||||
case protocol.SearchTracksRequest:
|
||||
songs, total, err := s.Catalog.SearchTracks(ctx, roomID, track.Source(d.Source), d.Keyword, d.Page, d.PageSize)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
return protocol.SearchTracksResponse{Items: summaries(songs), Total: total}, true, nil
|
||||
case protocol.SearchPlaylistsRequest:
|
||||
found, total, err := s.Catalog.SearchPlaylists(ctx, roomID, track.Source(d.Source), d.Keyword, d.Page, d.PageSize)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
return protocol.SearchPlaylistsResponse{Items: playlists(d.Source, found), Total: total}, true, nil
|
||||
case protocol.PlaylistTracksRequest:
|
||||
songs, err := s.Catalog.PlaylistTracks(ctx, roomID, track.Source(d.Source), d.PlaylistID)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
return protocol.PlaylistTracksResponse{Items: summaries(songs), Total: int64(len(songs))}, true, nil
|
||||
case protocol.RecommendListRequest:
|
||||
songs, err := s.Catalog.RecommendList(ctx, roomID)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
return protocol.RecommendListResponse{Items: summaries(songs)}, true, nil
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func summaries(songs []port.Song) []protocol.TrackSummary {
|
||||
items := make([]protocol.TrackSummary, len(songs))
|
||||
for i, s := range songs {
|
||||
items[i] = protocol.TrackSummary{Track: protocol.Track{Source: protocol.Source(s.Track.Source), ID: s.Track.ID}, Meta: meta(s.Meta)}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func playlists(source protocol.Source, found []port.Playlist) []protocol.PlaylistSummary {
|
||||
items := make([]protocol.PlaylistSummary, len(found))
|
||||
for i, p := range found {
|
||||
items[i] = protocol.PlaylistSummary{
|
||||
Source: source,
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
CoverURL: p.CoverURL,
|
||||
Creator: p.Creator,
|
||||
Description: p.Description,
|
||||
TrackCount: p.TrackCount,
|
||||
PlayCount: p.PlayCount,
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/media"
|
||||
"github.com/dihemoe/alisten/internal/app/realtime"
|
||||
"github.com/dihemoe/alisten/internal/domain/member"
|
||||
"github.com/dihemoe/alisten/internal/domain/room"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
"github.com/dihemoe/alisten/protocol"
|
||||
)
|
||||
|
||||
var roomErrorCodes = map[room.ErrorKind]protocol.ErrorCode{
|
||||
room.NotFound: protocol.ErrorCodeNotFound,
|
||||
room.Forbidden: protocol.ErrorCodeForbidden,
|
||||
room.Conflict: protocol.ErrorCodeConflict,
|
||||
room.QueueFull: protocol.ErrorCodeQueueFull,
|
||||
room.RateLimited: protocol.ErrorCodeRateLimited,
|
||||
room.NothingPlaying: protocol.ErrorCodeNothingPlaying,
|
||||
}
|
||||
|
||||
// bot 表示 by 是常驻房间的 bot 成员。返回的成员是房间命令的发起人,bot 代填了点歌人时换成那个名字。
|
||||
func (s *server) action(ctx context.Context, by member.Member, bot bool, data any) (room.Action, member.Member, error) {
|
||||
switch d := data.(type) {
|
||||
case protocol.RoomSyncRequest:
|
||||
return room.Sync{}, by, nil
|
||||
case protocol.QueueAddRequest:
|
||||
return s.Gateway.Resolve(ctx, by, bot, pick(d))
|
||||
case protocol.QueueRemoveRequest:
|
||||
return room.Remove{Entry: entryID(d.EntryID)}, by, nil
|
||||
case protocol.QueueLikeRequest:
|
||||
return room.Like{Entry: entryID(d.EntryID)}, by, nil
|
||||
case protocol.PlaybackVoteSkipRequest:
|
||||
return room.VoteSkip{}, by, nil
|
||||
case protocol.SettingsSetPlayModeRequest:
|
||||
return room.SetPlayMode{Mode: playModeOf(d.PlayMode)}, by, nil
|
||||
case protocol.ChatSendRequest:
|
||||
return room.Chat{Text: d.Content}, by, nil
|
||||
}
|
||||
panic(fmt.Sprintf("未知的房间请求 %T", data))
|
||||
}
|
||||
|
||||
// 协议解码保证要么只给出 track,要么只给出 source 和 keyword。
|
||||
func pick(d protocol.QueueAddRequest) realtime.Pick {
|
||||
if d.Track != nil {
|
||||
t := track.Track{Source: track.Source(d.Track.Source), ID: d.Track.ID}
|
||||
if t.Source == track.Link {
|
||||
t.ID = track.LinkID(t.ID)
|
||||
}
|
||||
return realtime.Pick{Track: t, Requester: deref(d.Requester)}
|
||||
}
|
||||
return realtime.Pick{Source: track.Source(*d.Source), Keyword: *d.Keyword, Requester: deref(d.Requester)}
|
||||
}
|
||||
|
||||
// 条目 ID 从 1 开始,无法解析的 ID 当作不存在的 0。
|
||||
func entryID(id string) room.EntryID {
|
||||
n, err := strconv.ParseUint(id, 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return room.EntryID(n)
|
||||
}
|
||||
|
||||
func (s *server) result(res room.Result) any {
|
||||
switch res := res.(type) {
|
||||
case room.Done:
|
||||
return protocol.Empty{}
|
||||
case room.EnqueueResult:
|
||||
return protocol.QueueAddResponse{Entry: s.entry(res.Entry)}
|
||||
case room.LikeResult:
|
||||
return protocol.QueueLikeResponse{Likes: int64(res.Likes)}
|
||||
case room.VoteResult:
|
||||
return protocol.PlaybackVoteSkipResponse{Votes: int64(res.Votes), Required: int64(res.Required), Skipped: res.Skipped}
|
||||
case room.SyncResult:
|
||||
return protocol.RoomSyncResponse{Room: s.snapshot(res.View)}
|
||||
}
|
||||
panic(fmt.Sprintf("未知的房间回复 %T", res))
|
||||
}
|
||||
|
||||
func (s *server) event(e room.Event) (string, any) {
|
||||
switch e := e.(type) {
|
||||
case room.PlaybackChanged:
|
||||
return protocol.EventPlaybackUpdated, protocol.PlaybackUpdatedEvent{NowPlaying: s.nowPlaying(e.Current)}
|
||||
case room.QueueChanged:
|
||||
return protocol.EventQueueUpdated, protocol.QueueUpdatedEvent{Pending: s.entryPtr(e.Pending), Items: s.entries(e.Waiting)}
|
||||
case room.MembersChanged:
|
||||
return protocol.EventMembersUpdated, members(e.Sessions)
|
||||
case room.ModeChanged:
|
||||
return protocol.EventSettingsUpdated, protocol.SettingsUpdatedEvent{PlayMode: playMode(e.Mode)}
|
||||
case room.InfoChanged:
|
||||
return protocol.EventRoomUpdated, protocol.RoomUpdatedEvent{Name: e.Name, Description: e.Description}
|
||||
case room.ChatPosted:
|
||||
return protocol.EventChatMessage, protocol.ChatMessageEvent{Member: memberDTO(e.Member), Content: e.Text, SentAtMs: e.SentAt.UnixMilli()}
|
||||
case room.Activity:
|
||||
return protocol.EventRoomActivity, s.activity(e)
|
||||
}
|
||||
panic(fmt.Sprintf("未知的房间事件 %T", e))
|
||||
}
|
||||
|
||||
func (s *server) snapshot(v room.View) protocol.RoomSnapshot {
|
||||
return protocol.RoomSnapshot{
|
||||
Term: int64(v.Term),
|
||||
Seq: int64(v.Seq),
|
||||
Room: protocol.RoomInfo{ID: v.Snapshot.Info.ID, Name: v.Snapshot.Info.Name, Description: v.Snapshot.Info.Description, Kind: roomKind(v.Snapshot.Info.Kind)},
|
||||
PlayMode: playMode(v.Snapshot.Mode),
|
||||
NowPlaying: s.nowPlaying(v.Snapshot.Current),
|
||||
Pending: s.entryPtr(v.Snapshot.Pending),
|
||||
Items: s.entries(v.Snapshot.Waiting),
|
||||
Members: members(v.Sessions),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) nowPlaying(p *room.NowPlaying) *protocol.NowPlaying {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return &protocol.NowPlaying{
|
||||
Entry: s.entry(p.Entry),
|
||||
MediaURL: s.mediaURL(p.Entry.Track),
|
||||
StartedAtMs: p.StartedAt.UnixMilli(),
|
||||
DurationMs: p.Entry.Prep.Duration.Milliseconds(),
|
||||
Gain: p.Entry.Prep.Gain,
|
||||
Lyric: p.Entry.Prep.Lyric,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) mediaURL(t track.Track) string {
|
||||
if media.Stored(t.Source) {
|
||||
return s.StorageURL + "/" + media.AudioObject(media.Key(t.ID))
|
||||
}
|
||||
return "/media/" + string(t.Source) + "/" + url.PathEscape(t.ID)
|
||||
}
|
||||
|
||||
func (s *server) entry(e room.Entry) protocol.Entry {
|
||||
d := protocol.Entry{
|
||||
ID: strconv.FormatUint(uint64(e.ID), 10),
|
||||
Track: protocol.Track{Source: protocol.Source(e.Track.Source), ID: e.Track.ID},
|
||||
Meta: meta(e.Meta),
|
||||
Auto: e.Auto,
|
||||
Likes: int64(e.Likes),
|
||||
Status: protocol.EntryStatusPreparing,
|
||||
}
|
||||
if e.Meta.CoverKey != "" {
|
||||
d.Meta.CoverURL = s.StorageURL + "/" + string(e.Meta.CoverKey)
|
||||
}
|
||||
if !e.Auto {
|
||||
by := memberDTO(e.By)
|
||||
d.By = &by
|
||||
}
|
||||
if e.Ready {
|
||||
d.Status = protocol.EntryStatusReady
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (s *server) entryPtr(e *room.Entry) *protocol.Entry {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
d := s.entry(*e)
|
||||
return &d
|
||||
}
|
||||
|
||||
func (s *server) entries(es []room.Entry) []protocol.Entry {
|
||||
items := make([]protocol.Entry, len(es))
|
||||
for i, e := range es {
|
||||
items[i] = s.entry(e)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func meta(m track.Meta) protocol.TrackMeta {
|
||||
return protocol.TrackMeta{Title: m.Title, Artist: m.Artist, Album: m.Album, CoverURL: m.CoverURL, PageURL: m.PageURL, DurationMs: m.Duration.Milliseconds()}
|
||||
}
|
||||
|
||||
func members(sessions []room.Session) protocol.Members {
|
||||
list := make([]protocol.Member, len(sessions))
|
||||
for i, s := range sessions {
|
||||
list[i] = memberDTO(s.Member)
|
||||
}
|
||||
return protocol.Members{Sessions: list, OnlineCount: int64(len(sessions))}
|
||||
}
|
||||
|
||||
var activityKinds = map[room.ActivityKind]protocol.ActivityKind{
|
||||
room.ActivityAdded: protocol.ActivityKindAdded,
|
||||
room.ActivityRemoved: protocol.ActivityKindRemoved,
|
||||
room.ActivityLiked: protocol.ActivityKindLiked,
|
||||
room.ActivityVoted: protocol.ActivityKindVoted,
|
||||
room.ActivitySkipped: protocol.ActivityKindSkipped,
|
||||
room.ActivityFailed: protocol.ActivityKindFailed,
|
||||
}
|
||||
|
||||
func (s *server) activity(a room.Activity) protocol.RoomActivityEvent {
|
||||
e := s.entry(a.Entry)
|
||||
d := protocol.RoomActivityEvent{Kind: activityKinds[a.Kind], Entry: &e, Message: activityMessage(a)}
|
||||
if a.Member != (member.Member{}) {
|
||||
m := memberDTO(a.Member)
|
||||
d.Member = &m
|
||||
}
|
||||
if a.Kind == room.ActivityVoted || a.Kind == room.ActivitySkipped {
|
||||
votes, required := int64(a.Votes), int64(a.Required)
|
||||
d.Votes, d.Required = &votes, &required
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// 歌名里已经有书名号时不再加一层:B 站的标题常带《》。
|
||||
func bookTitle(title string) string {
|
||||
if strings.ContainsAny(title, "《》") {
|
||||
return title
|
||||
}
|
||||
return "《" + title + "》"
|
||||
}
|
||||
|
||||
func activityMessage(a room.Activity) string {
|
||||
title := a.Entry.Meta.Title
|
||||
switch a.Kind {
|
||||
case room.ActivityAdded:
|
||||
return "点了" + bookTitle(title)
|
||||
case room.ActivityRemoved:
|
||||
return "移除了" + bookTitle(title)
|
||||
case room.ActivityLiked:
|
||||
return fmt.Sprintf("给%s点了赞,共 %d 个赞", bookTitle(title), a.Entry.Likes)
|
||||
case room.ActivityVoted:
|
||||
return fmt.Sprintf("投票切歌(%d/%d)", a.Votes, a.Required)
|
||||
case room.ActivitySkipped:
|
||||
return "投票切歌成功,换下一首"
|
||||
}
|
||||
return fmt.Sprintf("%s没能准备好:%s", bookTitle(title), a.Reason)
|
||||
}
|
||||
|
||||
func playMode(m room.PlayMode) protocol.PlayMode {
|
||||
switch m {
|
||||
case room.Random:
|
||||
return protocol.PlayModeRandom
|
||||
case room.Rotation:
|
||||
return protocol.PlayModeRotation
|
||||
}
|
||||
return protocol.PlayModeSequential
|
||||
}
|
||||
|
||||
func playModeOf(m protocol.PlayMode) room.PlayMode {
|
||||
switch m {
|
||||
case protocol.PlayModeRandom:
|
||||
return room.Random
|
||||
case protocol.PlayModeRotation:
|
||||
return room.Rotation
|
||||
}
|
||||
return room.Sequential
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/realtime"
|
||||
"github.com/dihemoe/alisten/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
// 小于入口的读超时:nginx 默认 60 秒收不到数据就断开。
|
||||
streamHeartbeat = 25 * time.Second
|
||||
// 客户端不再读取时,写入在这个期限之后失败,事件流随之结束:否则关闭事件流(包括退出时)要一直等阻塞的写入。
|
||||
streamWriteTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
var streamCloses = map[realtime.CloseReason]streamClose{
|
||||
realtime.CloseRestart: {Code: protocol.CloseServiceRestart, Message: "服务端实例退出或订阅中断,请重新连接"},
|
||||
realtime.CloseRevoked: {Code: protocol.CloseUnauthorized, Message: "凭证已失效:房间设置或修改了密码,或者 API Key 已重置"},
|
||||
realtime.CloseRoomGone: {Code: protocol.CloseNotFound, Message: "房间已关闭或被删除"},
|
||||
realtime.CloseOverflow: {Code: protocol.CloseSendQueueFull, Message: "积压的事件过多,请重新连接"},
|
||||
// 不是 1012:共用一把 Key 的两个 bot 看到它就该停下,而不是重连之后互相顶掉。
|
||||
realtime.CloseReplaced: {Code: 4409, Message: "同一凭证打开了新的事件流"},
|
||||
}
|
||||
|
||||
type streamClose struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// 房间的事件流:先发 snapshot,data 与 room.sync 的响应相同;之后是房间事件,data 与 WebSocket 上的报文相同;
|
||||
// 结束之前发 close,code 沿用 WebSocket 的关闭码,让给同一凭证的新事件流时是 4409。每次连接都从新快照开始,
|
||||
// 不按 Last-Event-ID 补发断开期间的事件。
|
||||
func (s *server) roomEvents(w http.ResponseWriter, r *http.Request) {
|
||||
// 打开事件流的期限与 WebSocket 的握手相同,打开之后的推送不设期限。
|
||||
setup, cancel := context.WithTimeout(r.Context(), helloTimeout)
|
||||
ctx, opened, err := s.serve(setup, w, r, protocol.AuthTicket, s.observe)
|
||||
cancel()
|
||||
if err != nil {
|
||||
s.fail(ctx, w, r, err)
|
||||
return
|
||||
}
|
||||
o := opened.(*realtime.Observer)
|
||||
defer s.Gateway.Unobserve(o)
|
||||
header := w.Header()
|
||||
header.Set("Content-Type", "text/event-stream")
|
||||
header.Set("Cache-Control", "no-cache")
|
||||
// nginx 默认缓冲 /api/ 的响应。
|
||||
header.Set("X-Accel-Buffering", "no")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
stream := eventStream{w: w, rc: http.NewResponseController(w), logger: s.logger}
|
||||
for {
|
||||
wait, cancel := context.WithTimeout(r.Context(), streamHeartbeat)
|
||||
m, reason, err := o.Next(wait)
|
||||
cancel()
|
||||
switch {
|
||||
case r.Context().Err() != nil:
|
||||
return
|
||||
case err != nil:
|
||||
if !stream.write([]byte(": ping\n\n")) {
|
||||
return
|
||||
}
|
||||
case reason != 0:
|
||||
stream.event(ctx, "close", "", streamCloses[reason])
|
||||
return
|
||||
default:
|
||||
name, id, data := s.streamMessage(m)
|
||||
if !stream.event(ctx, name, id, data) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 确认票据失败或 panic 时都要退订,否则事件流的名额和房间频道一直被占着。
|
||||
func (s *server) observe(ctx context.Context, c call) (any, error) {
|
||||
// 内存中只留凭证的哈希。
|
||||
credential := sha256.Sum256([]byte(c.ticket))
|
||||
o, err := s.Gateway.Observe(ctx, c.roomID, c.member, c.bot, string(credential[:]))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
confirmed := false
|
||||
defer func() {
|
||||
if !confirmed {
|
||||
s.Gateway.Unobserve(o)
|
||||
}
|
||||
}()
|
||||
// 作废时先删除票据、再通知断开:事件流在本实例生效之前发出的通知它收不到,所以生效之后再确认一次票据。
|
||||
if _, err := s.Identity.VerifyTicket(ctx, c.ticket); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
confirmed = true
|
||||
return o, nil
|
||||
}
|
||||
|
||||
func (s *server) streamMessage(m realtime.Message) (name, id string, data any) {
|
||||
switch m := m.(type) {
|
||||
case realtime.Snapshot:
|
||||
return "snapshot", "", protocol.RoomSyncResponse{Room: s.snapshot(m.View)}
|
||||
case realtime.Event:
|
||||
msg := s.message(m, "")
|
||||
return msg.Type, strconv.FormatInt(msg.Seq, 10), msg
|
||||
}
|
||||
panic(fmt.Sprintf("未知的事件流消息 %T", m))
|
||||
}
|
||||
|
||||
type eventStream struct {
|
||||
w http.ResponseWriter
|
||||
rc *http.ResponseController
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// data 只占一行:json.Marshal 的输出中没有换行。
|
||||
func (st eventStream) event(ctx context.Context, name, id string, data any) bool {
|
||||
payload, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
st.logger.ErrorContext(ctx, "编码事件流的消息失败", "event", name, "error", err)
|
||||
return false
|
||||
}
|
||||
frame := "event: " + name + "\n"
|
||||
if id != "" {
|
||||
frame += "id: " + id + "\n"
|
||||
}
|
||||
return st.write([]byte(frame + "data: " + string(payload) + "\n\n"))
|
||||
}
|
||||
|
||||
func (st eventStream) write(frame []byte) bool {
|
||||
_ = st.rc.SetWriteDeadline(time.Now().Add(streamWriteTimeout))
|
||||
if _, err := st.w.Write(frame); err != nil {
|
||||
return false
|
||||
}
|
||||
return st.rc.Flush() == nil
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/admin"
|
||||
"github.com/dihemoe/alisten/internal/app/identity"
|
||||
"github.com/dihemoe/alisten/internal/app/lobby"
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/member"
|
||||
"github.com/dihemoe/alisten/internal/domain/room"
|
||||
"github.com/dihemoe/alisten/protocol"
|
||||
)
|
||||
|
||||
// 路径中的房间 ID 和别名不经过协议解码。格式不正确的房间 ID 或别名一定不存在,其中含有 U+0000 等字符的还会让
|
||||
// PostgreSQL 报错,所以直接按不存在处理。
|
||||
var (
|
||||
uuidPattern = regexp.MustCompile(`^(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
|
||||
slugPattern = regexp.MustCompile(`^[a-z0-9-]{1,32}$`)
|
||||
)
|
||||
|
||||
func (s *server) createMember(ctx context.Context, c call) (any, error) {
|
||||
if _, err := protocol.DecodeCreateMemberRequest(c.body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
session, err := s.Identity.CreateMember(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return protocol.CreateMemberResponse{MemberToken: session.MemberToken, Member: memberDTO(session.Member)}, nil
|
||||
}
|
||||
|
||||
func (s *server) getMe(_ context.Context, c call) (any, error) {
|
||||
return protocol.GetMeResponse{Member: memberDTO(c.member)}, nil
|
||||
}
|
||||
|
||||
func (s *server) updateMe(ctx context.Context, c call) (any, error) {
|
||||
req, err := protocol.DecodeUpdateMeRequest(c.body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, err := s.Identity.UpdateProfile(ctx, c.member.ID, identity.Profile{Name: req.Name, Email: req.Email})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return protocol.UpdateMeResponse{Member: memberDTO(m)}, nil
|
||||
}
|
||||
|
||||
func (s *server) listRooms(ctx context.Context, _ call) (any, error) {
|
||||
rooms, err := s.Lobby.ListRooms(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return protocol.ListRoomsResponse{Items: listings(rooms)}, nil
|
||||
}
|
||||
|
||||
func (s *server) getRoom(ctx context.Context, c call) (any, error) {
|
||||
ref := c.r.PathValue("ref")
|
||||
if !uuidPattern.MatchString(ref) && !slugPattern.MatchString(ref) {
|
||||
return nil, port.ErrRoomNotFound
|
||||
}
|
||||
r, err := s.Lobby.FindRoom(ctx, ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return listing(r), nil
|
||||
}
|
||||
|
||||
func (s *server) createRoom(ctx context.Context, c call) (any, error) {
|
||||
req, err := protocol.DecodeCreateRoomRequest(c.body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := s.Lobby.CreateRoom(ctx, lobby.NewRoom{Name: req.Name, Description: req.Description, Password: deref(req.Password)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return located{location: "/api/rooms/" + id, body: protocol.CreateRoomResponse{ID: id}}, nil
|
||||
}
|
||||
|
||||
func (s *server) createTicket(ctx context.Context, c call) (any, error) {
|
||||
id := c.r.PathValue("id")
|
||||
if !uuidPattern.MatchString(id) {
|
||||
return nil, port.ErrRoomNotFound
|
||||
}
|
||||
req, err := protocol.DecodeCreateTicketRequest(c.body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ticket, err := s.Identity.IssueTicket(ctx, c.member.ID, id, deref(req.Password))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return protocol.CreateTicketResponse{Ticket: ticket}, nil
|
||||
}
|
||||
|
||||
func (s *server) adminListRooms(ctx context.Context, _ call) (any, error) {
|
||||
rooms, err := s.Admin.ListRooms(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return protocol.AdminListRoomsResponse{Items: listings(rooms)}, nil
|
||||
}
|
||||
|
||||
func (s *server) adminCreateRoom(ctx context.Context, c call) (any, error) {
|
||||
req, err := protocol.DecodeAdminCreateRoomRequest(c.body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := s.Admin.CreateRoom(ctx, admin.NewRoom{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Slug: deref(req.Slug),
|
||||
Password: deref(req.Password),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return located{location: "/api/rooms/" + id, body: protocol.AdminCreateRoomResponse{ID: id}}, nil
|
||||
}
|
||||
|
||||
func (s *server) adminGetNeteaseCredential(ctx context.Context, _ call) (any, error) {
|
||||
status, err := s.Admin.NeteaseCredentialStatus(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return protocol.AdminGetNeteaseCredentialResponse{Set: status.Set, Account: sourceAccountDTO(status.Account)}, nil
|
||||
}
|
||||
|
||||
func (s *server) adminGetQQCredential(ctx context.Context, _ call) (any, error) {
|
||||
status, err := s.Admin.QQCredentialStatus(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return protocol.AdminGetQQCredentialResponse{Set: status.Set, Account: sourceAccountDTO(status.Account)}, nil
|
||||
}
|
||||
|
||||
func (s *server) adminGetBilibiliCredential(ctx context.Context, _ call) (any, error) {
|
||||
status, err := s.Admin.BilibiliCredentialStatus(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return protocol.AdminGetBilibiliCredentialResponse{Set: status.Set, Account: sourceAccountDTO(status.Account)}, nil
|
||||
}
|
||||
|
||||
func (s *server) adminGetYoutubeCredential(ctx context.Context, _ call) (any, error) {
|
||||
status, err := s.Admin.YouTubeCookiesStatus(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := protocol.AdminGetYoutubeCredentialResponse{Set: status.Set}
|
||||
// 零值的 UnixMilli 是负数,协议约定没有填写时为 0。
|
||||
if !status.UpdatedAt.IsZero() {
|
||||
resp.UpdatedAtMs = status.UpdatedAt.UnixMilli()
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func sourceAccountDTO(account *port.SourceAccount) *protocol.SourceAccount {
|
||||
if account == nil {
|
||||
return nil
|
||||
}
|
||||
return &protocol.SourceAccount{ID: account.ID, Name: account.Name, Vip: account.VIP, Verified: account.Verified}
|
||||
}
|
||||
|
||||
func memberDTO(m member.Member) protocol.Member {
|
||||
return protocol.Member{ID: m.ID, Name: m.Name, AvatarHash: m.AvatarHash}
|
||||
}
|
||||
|
||||
func listings(rooms []lobby.Room) []protocol.RoomListing {
|
||||
items := make([]protocol.RoomListing, len(rooms))
|
||||
for i, r := range rooms {
|
||||
items[i] = listing(r)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func listing(r lobby.Room) protocol.RoomListing {
|
||||
l := protocol.RoomListing{
|
||||
ID: r.Info.ID,
|
||||
Name: r.Info.Name,
|
||||
Description: r.Info.Description,
|
||||
Kind: roomKind(r.Info.Kind),
|
||||
HasPassword: r.HasPassword,
|
||||
OnlineCount: int64(r.Online),
|
||||
}
|
||||
if r.Slug != "" {
|
||||
l.Slug = &r.Slug
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func roomKind(k room.Kind) protocol.RoomKind {
|
||||
if k == room.Persistent {
|
||||
return protocol.RoomKindPersistent
|
||||
}
|
||||
return protocol.RoomKindEphemeral
|
||||
}
|
||||
|
||||
func deref(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/media"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
"github.com/dihemoe/alisten/protocol"
|
||||
)
|
||||
|
||||
func (s *server) redirectMedia(w http.ResponseWriter, r *http.Request) {
|
||||
source := track.Source(r.PathValue("source"))
|
||||
if source != track.NetEase && source != track.QQ {
|
||||
writeError(w, protocol.ErrorInfo{Code: protocol.ErrorCodeNotFound, Message: "接口不存在"})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), requestDeadline)
|
||||
defer cancel()
|
||||
target, err := s.redirectTarget(ctx, r, track.Track{Source: source, ID: r.PathValue("id")})
|
||||
// 客户端已断开不是服务端的错误,不计入跳转的结果。
|
||||
if r.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
s.redirects.WithLabelValues(string(source), resultLabel(err)).Inc()
|
||||
if err != nil {
|
||||
s.fail(ctx, w, r, err)
|
||||
return
|
||||
}
|
||||
// 播放地址有时效,不能让浏览器缓存这次跳转。
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
http.Redirect(w, r, target, http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *server) redirectTarget(ctx context.Context, r *http.Request, t track.Track) (_ string, err error) {
|
||||
defer func() {
|
||||
if value := recover(); value != nil {
|
||||
err = s.panicked(ctx, r, value)
|
||||
}
|
||||
}()
|
||||
return s.Playback.URL(ctx, t, time.Now())
|
||||
}
|
||||
|
||||
var mediaKeyPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
|
||||
func (s *server) adminListMedia(ctx context.Context, _ call) (any, error) {
|
||||
list, err := s.Media.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]protocol.StoredMedia, len(list))
|
||||
for i, m := range list {
|
||||
link := m.Link
|
||||
// 后台把 link 当作链接打开,B 站的歌给出视频页面。
|
||||
if m.Source == track.Bilibili {
|
||||
link = track.BilibiliPageURL(m.Link)
|
||||
}
|
||||
items[i] = protocol.StoredMedia{Key: m.Key, Source: protocol.Source(m.Source), Link: link, Title: m.Title, Size: m.Size, StoredAtMs: m.StoredAt.UnixMilli()}
|
||||
if m.LastPlayedAt != nil {
|
||||
played := m.LastPlayedAt.UnixMilli()
|
||||
items[i].LastPlayedAtMs = &played
|
||||
}
|
||||
}
|
||||
return protocol.AdminListMediaResponse{Items: items}, nil
|
||||
}
|
||||
|
||||
func (s *server) adminDeleteMedia(ctx context.Context, c call) (any, error) {
|
||||
key := c.r.PathValue("key")
|
||||
if !mediaKeyPattern.MatchString(key) {
|
||||
return nil, media.ErrNotFound
|
||||
}
|
||||
return nil, s.Media.Delete(ctx, key)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
"rsc.io/qr"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/protocol"
|
||||
)
|
||||
|
||||
func (s *server) adminStartNeteaseLogin(ctx context.Context, c call) (any, error) {
|
||||
if _, err := protocol.DecodeAdminStartNeteaseLoginRequest(c.body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
session, err := s.Admin.CreateNeteaseQRLogin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return qrLogin(session)
|
||||
}
|
||||
|
||||
func (s *server) adminCheckNeteaseLogin(ctx context.Context, c call) (any, error) {
|
||||
req, err := protocol.DecodeAdminCheckNeteaseLoginRequest(c.body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := s.Admin.CheckNeteaseQRLogin(ctx, req.Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return qrLoginResult(result), nil
|
||||
}
|
||||
|
||||
func (s *server) adminStartQQLogin(ctx context.Context, c call) (any, error) {
|
||||
req, err := protocol.DecodeAdminStartQQLoginRequest(c.body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
session, err := s.Admin.CreateQQQRLogin(ctx, port.QQLoginType(req.Type))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return qrLogin(session)
|
||||
}
|
||||
|
||||
func (s *server) adminCheckQQLogin(ctx context.Context, c call) (any, error) {
|
||||
req, err := protocol.DecodeAdminCheckQQLoginRequest(c.body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := s.Admin.CheckQQQRLogin(ctx, port.QQLoginType(req.Type), req.Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return qrLoginResult(result), nil
|
||||
}
|
||||
|
||||
func (s *server) adminStartBilibiliLogin(ctx context.Context, c call) (any, error) {
|
||||
if _, err := protocol.DecodeAdminStartBilibiliLoginRequest(c.body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
session, err := s.Admin.CreateBilibiliQRLogin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return qrLogin(session)
|
||||
}
|
||||
|
||||
func (s *server) adminCheckBilibiliLogin(ctx context.Context, c call) (any, error) {
|
||||
req, err := protocol.DecodeAdminCheckBilibiliLoginRequest(c.body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := s.Admin.CheckBilibiliQRLogin(ctx, req.Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return qrLoginResult(result), nil
|
||||
}
|
||||
|
||||
func qrLogin(session port.QRLoginSession) (protocol.QRLogin, error) {
|
||||
image := session.QRImage
|
||||
if image == "" {
|
||||
if session.LoginURL == "" {
|
||||
return protocol.QRLogin{}, fmt.Errorf("音源没有给出二维码图片和登录链接:%w", port.ErrProviderFailed)
|
||||
}
|
||||
code, err := qr.Encode(session.LoginURL, qr.M)
|
||||
if err != nil {
|
||||
return protocol.QRLogin{}, fmt.Errorf("把登录链接编码成二维码:%w:%w", err, port.ErrProviderFailed)
|
||||
}
|
||||
image = "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG())
|
||||
}
|
||||
return protocol.QRLogin{Key: session.Key, QrImage: image, ExpiresAtMs: session.ExpiresAt.UnixMilli()}, nil
|
||||
}
|
||||
|
||||
func qrLoginResult(r port.QRLoginResult) protocol.QRLoginResult {
|
||||
return protocol.QRLoginResult{Status: protocol.QRLoginStatus(r.Status), Message: r.Message}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/admin"
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/protocol"
|
||||
)
|
||||
|
||||
// bot 没有会话,用房间票据通过 HTTP 发送房间内请求,不出现在在线列表中。
|
||||
func (s *server) roomRequest(route protocol.RoomRoute) handlerFunc {
|
||||
return func(ctx context.Context, c call) (_ any, err error) {
|
||||
data, err := route.Decode(c.r.PathValue, c.r.URL.RawQuery, c.body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 与 WebSocket 一致,客户端已断开的请求不计数。panic 由 serve 恢复并回复 500,这里按 INTERNAL 计数后原样抛出。
|
||||
defer func() {
|
||||
if value := recover(); value != nil {
|
||||
s.count(route.Type, errPanicked)
|
||||
panic(value)
|
||||
}
|
||||
if c.r.Context().Err() == nil {
|
||||
s.count(route.Type, err)
|
||||
}
|
||||
}()
|
||||
if resp, handled, err := s.answer(ctx, c.roomID, data); handled {
|
||||
return resp, err
|
||||
}
|
||||
a, by, err := s.action(ctx, c.member, c.bot, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := s.Gateway.Do(ctx, c.roomID, by, a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := s.result(res)
|
||||
if added, ok := resp.(protocol.QueueAddResponse); ok {
|
||||
return located{location: "/api/room/queue/" + added.Entry.ID, body: added}, nil
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) adminUpdateRoom(ctx context.Context, c call) (any, error) {
|
||||
id := c.r.PathValue("id")
|
||||
if !uuidPattern.MatchString(id) {
|
||||
return nil, port.ErrRoomNotFound
|
||||
}
|
||||
req, err := protocol.DecodeAdminUpdateRoomRequest(c.body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
change := admin.RoomChange{Name: req.Name, Description: req.Description, Slug: req.Slug, Password: req.Password}
|
||||
return nil, s.Admin.UpdateRoom(ctx, id, change)
|
||||
}
|
||||
|
||||
func (s *server) adminGetRoomAPIKey(ctx context.Context, c call) (any, error) {
|
||||
id := c.r.PathValue("id")
|
||||
if !uuidPattern.MatchString(id) {
|
||||
return nil, port.ErrRoomNotFound
|
||||
}
|
||||
status, err := s.Admin.RoomAPIKeyStatus(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := protocol.AdminGetRoomApiKeyResponse{Set: status.Set}
|
||||
// 零值的 UnixMilli 是负数,协议约定没有 Key 时为 0。
|
||||
if !status.CreatedAt.IsZero() {
|
||||
resp.CreatedAtMs = status.CreatedAt.UnixMilli()
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *server) adminResetRoomAPIKey(ctx context.Context, c call) (any, error) {
|
||||
id := c.r.PathValue("id")
|
||||
if !uuidPattern.MatchString(id) {
|
||||
return nil, port.ErrRoomNotFound
|
||||
}
|
||||
if _, err := protocol.DecodeAdminResetRoomApiKeyRequest(c.body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := s.Admin.ResetRoomAPIKey(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return located{location: "/api/admin/rooms/" + id + "/api-key", body: protocol.APIKey{Key: key}}, nil
|
||||
}
|
||||
|
||||
func (s *server) adminDeleteRoom(ctx context.Context, c call) (any, error) {
|
||||
id := c.r.PathValue("id")
|
||||
if !uuidPattern.MatchString(id) {
|
||||
return nil, port.ErrRoomNotFound
|
||||
}
|
||||
return nil, s.Admin.DeleteRoom(ctx, id)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/dihemoe/alisten/protocol"
|
||||
)
|
||||
|
||||
func (s *server) getSite(ctx context.Context, _ call) (any, error) {
|
||||
name, err := s.Site.BrandName(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return protocol.GetSiteResponse{BrandName: name}, nil
|
||||
}
|
||||
|
||||
func (s *server) adminUpdateSite(ctx context.Context, c call) (any, error) {
|
||||
req, err := protocol.DecodeAdminUpdateSiteRequest(c.body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name, err := s.Site.SetBrandName(ctx, req.BrandName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return protocol.AdminUpdateSiteResponse{BrandName: name}, nil
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/app/realtime"
|
||||
"github.com/dihemoe/alisten/internal/lib/logctx"
|
||||
"github.com/dihemoe/alisten/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
helloTimeout = 10 * time.Second
|
||||
idleTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
var errClientOutdated = errors.New("前端与服务端的协议版本不一致,请刷新页面")
|
||||
|
||||
var handshakeCloseCodes = map[protocol.ErrorCode]websocket.StatusCode{
|
||||
protocol.ErrorCodeInvalidArgument: protocol.CloseInvalidArgument,
|
||||
protocol.ErrorCodeInternal: protocol.CloseInternal,
|
||||
protocol.ErrorCodeUnavailable: protocol.CloseUnavailable,
|
||||
protocol.ErrorCodeUnauthorized: protocol.CloseUnauthorized,
|
||||
protocol.ErrorCodeNotFound: protocol.CloseNotFound,
|
||||
protocol.ErrorCodeClientOutdated: protocol.CloseClientOutdated,
|
||||
}
|
||||
|
||||
var sessionCloseCodes = map[realtime.CloseReason]websocket.StatusCode{
|
||||
realtime.CloseRestart: protocol.CloseServiceRestart,
|
||||
realtime.CloseRevoked: protocol.CloseUnauthorized,
|
||||
realtime.CloseRoomGone: protocol.CloseNotFound,
|
||||
realtime.CloseOverflow: protocol.CloseSendQueueFull,
|
||||
}
|
||||
|
||||
type requestTag struct {
|
||||
typ string
|
||||
id string
|
||||
}
|
||||
|
||||
func (s *server) websocket(w http.ResponseWriter, r *http.Request) {
|
||||
var lastFrame atomic.Int64
|
||||
touch := func() { lastFrame.Store(time.Now().UnixNano()) }
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
// Accept 默认要求 Origin 与 Host 头一致,而经过入口转发时 Host 不一定与浏览器页面的 Origin 相同。
|
||||
InsecureSkipVerify: true,
|
||||
OnPingReceived: func(context.Context, []byte) bool {
|
||||
touch()
|
||||
return true
|
||||
},
|
||||
OnPongReceived: func(context.Context, []byte) { touch() },
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = conn.CloseNow() }()
|
||||
conn.SetReadLimit(maxBodyBytes)
|
||||
// 连接被 Hijack 之后,请求的 context 不再反映连接的状态。
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// 超时时先以 4408 关闭连接,再取消 ctx:如果先取消,正在读取第一帧的 Read 会直接断开连接,
|
||||
// 客户端收不到 4408。
|
||||
timeout := time.AfterFunc(helloTimeout, func() {
|
||||
_ = conn.Close(protocol.CloseHandshakeTimeout, "")
|
||||
cancel()
|
||||
})
|
||||
sess, helloID := s.handshake(ctx, conn)
|
||||
if !timeout.Stop() {
|
||||
if sess != nil {
|
||||
s.Gateway.Leave(sess)
|
||||
}
|
||||
return
|
||||
}
|
||||
if sess == nil {
|
||||
return
|
||||
}
|
||||
defer s.Gateway.Leave(sess)
|
||||
ctx = logctx.WithMember(logctx.WithRoom(ctx, sess.Room()), sess.Member().ID)
|
||||
|
||||
touch()
|
||||
go watchIdle(ctx, conn, &lastFrame)
|
||||
go s.write(ctx, conn, sess, helloID)
|
||||
for {
|
||||
typ, frame, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
touch()
|
||||
s.dispatch(ctx, conn, sess, typ, frame)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) handshake(ctx context.Context, conn *websocket.Conn) (*realtime.Session, string) {
|
||||
typ, frame, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
return nil, ""
|
||||
}
|
||||
// span 在读到第一帧之后才开始,等待客户端发送的时间不计入。
|
||||
ctx, span := s.tracer.Start(ctx, "WS hello", trace.WithSpanKind(trace.SpanKindServer))
|
||||
defer span.End()
|
||||
req, err := decodeFrame(typ, frame)
|
||||
if err == nil {
|
||||
var sess *realtime.Session
|
||||
if sess, err = s.hello(ctx, req); err == nil {
|
||||
span.SetAttributes(attribute.String("alisten.room_id", sess.Room()))
|
||||
return sess, req.ID
|
||||
}
|
||||
}
|
||||
info := s.requestError(ctx, req.Type, err)
|
||||
s.reject(ctx, conn, req, info)
|
||||
_ = conn.Close(handshakeCloseCodes[info.Code], "")
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
func (s *server) hello(ctx context.Context, req protocol.Request) (*realtime.Session, error) {
|
||||
if req.Type != protocol.RequestHello {
|
||||
return nil, &protocol.ArgumentError{Reason: "第一帧必须是 hello"}
|
||||
}
|
||||
hello := req.Data.(protocol.HelloRequest)
|
||||
if hello.ProtocolVersion != protocol.Version {
|
||||
return nil, errClientOutdated
|
||||
}
|
||||
// 不是 UUID 的值会被当作别名查找。
|
||||
if !uuidPattern.MatchString(hello.RoomID) {
|
||||
return nil, port.ErrRoomNotFound
|
||||
}
|
||||
return s.Identity.EnterRoom(ctx, hello.RoomID, hello.Ticket)
|
||||
}
|
||||
|
||||
func decodeFrame(typ websocket.MessageType, frame []byte) (protocol.Request, error) {
|
||||
if typ != websocket.MessageText || !utf8.Valid(frame) {
|
||||
return protocol.Request{}, &protocol.ArgumentError{Reason: "只接受 UTF-8 编码的 JSON 文本帧"}
|
||||
}
|
||||
return protocol.DecodeRequest(frame)
|
||||
}
|
||||
|
||||
func (s *server) dispatch(ctx context.Context, conn *websocket.Conn, sess *realtime.Session, typ websocket.MessageType, frame []byte) {
|
||||
req, err := decodeFrame(typ, frame)
|
||||
if err == nil && req.Type == protocol.RequestHello {
|
||||
err = &protocol.ArgumentError{Reason: "已经完成握手"}
|
||||
}
|
||||
if err != nil {
|
||||
s.reject(sess.Context(), conn, req, s.requestError(ctx, req.Type, err))
|
||||
return
|
||||
}
|
||||
if req.Type == protocol.RequestPing {
|
||||
ping := req.Data.(protocol.PingRequest)
|
||||
s.writeMessage(sess.Context(), conn, protocol.Response(req.Type, req.ID, protocol.PingResponse{ClientTimeMs: ping.ClientTimeMs, ServerTimeMs: time.Now().UnixMilli()}))
|
||||
return
|
||||
}
|
||||
if !sess.Begin() {
|
||||
s.commands.WithLabelValues(req.Type, string(protocol.ErrorCodeRateLimited)).Inc()
|
||||
s.reject(sess.Context(), conn, req, protocol.ErrorInfo{Code: protocol.ErrorCodeRateLimited, Message: "操作太快了,请稍后再试。"})
|
||||
return
|
||||
}
|
||||
go s.command(ctx, sess, req)
|
||||
}
|
||||
|
||||
func (s *server) command(ctx context.Context, sess *realtime.Session, req protocol.Request) {
|
||||
ctx, cancel := context.WithTimeout(ctx, requestDeadline)
|
||||
defer cancel()
|
||||
ctx, span := s.tracer.Start(ctx, "WS "+req.Type, trace.WithSpanKind(trace.SpanKindServer), trace.WithAttributes(attribute.String("alisten.room_id", sess.Room())))
|
||||
defer span.End()
|
||||
tag := requestTag{typ: req.Type, id: req.ID}
|
||||
// 这个 goroutine 不在 net/http 的恢复范围之内,panic 会让进程退出,本实例上的房间随之全部停止。
|
||||
defer func() {
|
||||
if value := recover(); value != nil {
|
||||
s.logger.ErrorContext(ctx, "处理 WebSocket 请求时发生 panic", "panic", fmt.Sprint(value), "type", req.Type, "stack", string(debug.Stack()))
|
||||
s.requestError(ctx, req.Type, errPanicked)
|
||||
sess.Fail(tag, errPanicked)
|
||||
}
|
||||
}()
|
||||
if err := s.perform(ctx, sess, tag, req.Data); err != nil {
|
||||
s.requestError(ctx, req.Type, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) perform(ctx context.Context, sess *realtime.Session, tag requestTag, data any) error {
|
||||
if resp, handled, err := s.answer(ctx, sess.Room(), data); handled {
|
||||
if err != nil {
|
||||
sess.Fail(tag, err)
|
||||
} else {
|
||||
sess.Answer(tag, resp)
|
||||
}
|
||||
return err
|
||||
}
|
||||
// 握手拒绝了 API Key,会话的成员不是 bot,房间命令总以会话的成员发出。
|
||||
a, _, err := s.action(ctx, sess.Member(), false, data)
|
||||
if err != nil {
|
||||
sess.Fail(tag, err)
|
||||
return err
|
||||
}
|
||||
return s.Gateway.Command(ctx, sess, tag, a)
|
||||
}
|
||||
|
||||
func (s *server) write(ctx context.Context, conn *websocket.Conn, sess *realtime.Session, helloID string) {
|
||||
for {
|
||||
m, reason, err := sess.Next(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if reason != 0 {
|
||||
_ = conn.Close(sessionCloseCodes[reason], "")
|
||||
return
|
||||
}
|
||||
// 写入以会话的 ctx 为期限:客户端不再读取、发送队列溢出时,阻塞的写入随会话关闭而中止。
|
||||
if !s.writeMessage(sess.Context(), conn, s.message(m, helloID)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) message(m realtime.Message, helloID string) protocol.Message {
|
||||
switch m := m.(type) {
|
||||
case realtime.Hello:
|
||||
return protocol.Response(protocol.RequestHello, helloID, protocol.HelloResponse{Member: memberDTO(m.Member), ServerTimeMs: time.Now().UnixMilli(), Room: s.snapshot(m.View)})
|
||||
case realtime.Event:
|
||||
typ, data := s.event(m.Emit.Event)
|
||||
return protocol.RoomEvent(typ, int64(m.Emit.Term), int64(m.Emit.Seq), data)
|
||||
case realtime.Reply:
|
||||
// 按写给客户端的回复计数,会话关闭时没有写出的回复不计。意外的错误已在处理请求时记录。
|
||||
tag := m.Tag.(requestTag)
|
||||
s.count(tag.typ, m.Err)
|
||||
if m.Err != nil {
|
||||
info, _ := errorInfo(m.Err)
|
||||
return protocol.Failure(tag.typ, tag.id, info)
|
||||
}
|
||||
return protocol.Response(tag.typ, tag.id, s.result(m.Result))
|
||||
case realtime.Answer:
|
||||
tag := m.Tag.(requestTag)
|
||||
s.count(tag.typ, nil)
|
||||
return protocol.Response(tag.typ, tag.id, m.Data)
|
||||
}
|
||||
panic(fmt.Sprintf("未知的会话消息 %T", m))
|
||||
}
|
||||
|
||||
// 错误码对应 5xx 时把请求的 span 标为出错,与 HTTP 请求的 span 一致。
|
||||
// 会话结束时被取消的请求不记录日志,也不标为出错。
|
||||
func (s *server) requestError(ctx context.Context, typ string, err error) protocol.ErrorInfo {
|
||||
info, expected := errorInfo(err)
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return info
|
||||
}
|
||||
if !expected {
|
||||
s.logger.ErrorContext(withRequestID(ctx, err), "处理 WebSocket 请求失败", "type", typ, "error", err)
|
||||
}
|
||||
if info.Code.HTTPStatus() >= http.StatusInternalServerError {
|
||||
trace.SpanFromContext(ctx).SetStatus(codes.Error, string(info.Code))
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func (s *server) reject(ctx context.Context, conn *websocket.Conn, req protocol.Request, info protocol.ErrorInfo) {
|
||||
if req.ID == "" {
|
||||
s.writeMessage(ctx, conn, protocol.ErrorEvent(info))
|
||||
return
|
||||
}
|
||||
s.writeMessage(ctx, conn, protocol.Failure(req.Type, req.ID, info))
|
||||
}
|
||||
|
||||
func (s *server) writeMessage(ctx context.Context, conn *websocket.Conn, m protocol.Message) bool {
|
||||
frame, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
s.logger.ErrorContext(ctx, "编码 WebSocket 消息失败", "type", m.Type, "error", err)
|
||||
return false
|
||||
}
|
||||
return conn.Write(ctx, websocket.MessageText, frame) == nil
|
||||
}
|
||||
|
||||
func watchIdle(ctx context.Context, conn *websocket.Conn, lastFrame *atomic.Int64) {
|
||||
timer := time.NewTimer(idleTimeout)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
idle := time.Since(time.Unix(0, lastFrame.Load()))
|
||||
if idle >= idleTimeout {
|
||||
_ = conn.CloseNow()
|
||||
return
|
||||
}
|
||||
timer.Reset(idleTimeout - idle)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/dihemoe/alisten/protocol"
|
||||
)
|
||||
|
||||
func (s *server) adminSetYoutubeCredential(ctx context.Context, c call) (any, error) {
|
||||
req, err := protocol.DecodeAdminSetYoutubeCredentialRequest(c.body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, s.Admin.SetYouTubeCookies(ctx, req.Cookies)
|
||||
}
|
||||
|
||||
func (s *server) adminDeleteYoutubeCredential(ctx context.Context, _ call) (any, error) {
|
||||
return nil, s.Admin.ClearYouTubeCookies(ctx)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package ops
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
func NewHandler(ready func() bool, metrics prometheus.Gatherer) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, "ok\n")
|
||||
})
|
||||
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
if !ready() {
|
||||
http.Error(w, "not ready", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, "ok\n")
|
||||
})
|
||||
mux.Handle("GET /metrics", promhttp.HandlerFor(metrics, promhttp.HandlerOpts{}))
|
||||
return mux
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/riverqueue/river"
|
||||
"github.com/riverqueue/river/riverdriver/riverpgxv5"
|
||||
"github.com/riverqueue/river/rivertype"
|
||||
"github.com/riverqueue/rivercontrib/otelriver"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
"github.com/dihemoe/alisten/internal/lib/logctx"
|
||||
)
|
||||
|
||||
const (
|
||||
prepareQueue = "prepare"
|
||||
prepareWorkers = 4
|
||||
prepareTimeout = 15 * time.Minute
|
||||
// 进程崩溃后停留在 running 状态的任务,最多这么久之后由 River 重新调度;River 默认至少要等 1 小时,
|
||||
// 期间这首歌一直处于准备中。
|
||||
rescueStuckJobs = 20 * time.Minute
|
||||
)
|
||||
|
||||
// internal/adapter/out/queue 按同样的队列名提交链接和 B 站的歌的准备任务。
|
||||
const (
|
||||
linkQueue = "ytdlp"
|
||||
bilibiliQueue = "bilibili"
|
||||
mediaWorkers = 1
|
||||
)
|
||||
|
||||
// 适配器之间不能互相 import,internal/adapter/out/queue 按同样的 kind 和字段提交任务。
|
||||
type prepareArgs struct {
|
||||
Source string `json:"source"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (prepareArgs) Kind() string { return "prepare" }
|
||||
|
||||
type prepareWorker struct {
|
||||
river.WorkerDefaults[prepareArgs]
|
||||
run func(context.Context, track.Track) error
|
||||
}
|
||||
|
||||
func (w *prepareWorker) Work(ctx context.Context, job *river.Job[prepareArgs]) error {
|
||||
err := w.run(logctx.WithJob(ctx, job.ID), job.Args.track())
|
||||
var rejected *port.RejectedError
|
||||
if errors.As(err, &rejected) {
|
||||
return river.JobCancel(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (a prepareArgs) track() track.Track {
|
||||
return track.Track{Source: track.Source(a.Source), ID: a.ID}
|
||||
}
|
||||
|
||||
// run 返回 *port.RejectedError 时任务取消,返回其他错误时由 River 重试。
|
||||
func NewClient(pool *pgxpool.Pool, run func(context.Context, track.Track) error, tracer trace.TracerProvider, logger *slog.Logger) (*river.Client[pgx.Tx], error) {
|
||||
return newClient(pool, map[string]river.QueueConfig{prepareQueue: {MaxWorkers: prepareWorkers}}, run, tracer, logger)
|
||||
}
|
||||
|
||||
// 执行链接和 B 站的歌的准备任务,每个队列在每个进程中同时只执行一个。
|
||||
func NewMediaClient(pool *pgxpool.Pool, run func(context.Context, track.Track) error, tracer trace.TracerProvider, logger *slog.Logger) (*river.Client[pgx.Tx], error) {
|
||||
return newClient(pool, map[string]river.QueueConfig{linkQueue: {MaxWorkers: mediaWorkers}, bilibiliQueue: {MaxWorkers: mediaWorkers}}, run, tracer, logger)
|
||||
}
|
||||
|
||||
func newClient(pool *pgxpool.Pool, queues map[string]river.QueueConfig, run func(context.Context, track.Track) error, tracer trace.TracerProvider, logger *slog.Logger) (*river.Client[pgx.Tx], error) {
|
||||
workers := river.NewWorkers()
|
||||
river.AddWorker(workers, &prepareWorker{run: run})
|
||||
return river.NewClient(riverpgxv5.New(pool), &river.Config{
|
||||
Queues: queues,
|
||||
Workers: workers,
|
||||
JobTimeout: prepareTimeout,
|
||||
RescueStuckJobsAfter: rescueStuckJobs,
|
||||
// 任务在单独的 trace 中执行,只链接到提交它的 span:任务可能很久之后才执行,失败后还会重试,同一首歌的任务也由所有等待它的房间共用。
|
||||
Plugins: []rivertype.Plugin{otelriver.NewMiddleware(&otelriver.MiddlewareConfig{TracerProvider: tracer, EnableTracePropagation: true})},
|
||||
// River 在 info 级别记录每次启停和每个出错的任务,出错的任务由 Watch 记录。
|
||||
// 放进 river 分组,River 自己的属性(如 job_id)才不会与日志的关联字段重名。
|
||||
Logger: slog.New(warnings{logger.Handler()}).WithGroup("river"),
|
||||
})
|
||||
}
|
||||
|
||||
type warnings struct{ slog.Handler }
|
||||
|
||||
func (h warnings) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
return level >= slog.LevelWarn && h.Handler.Enabled(ctx, level)
|
||||
}
|
||||
|
||||
func (h warnings) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return warnings{h.Handler.WithAttrs(attrs)}
|
||||
}
|
||||
|
||||
func (h warnings) WithGroup(name string) slog.Handler { return warnings{h.Handler.WithGroup(name)} }
|
||||
|
||||
// 在 client 启动之前调用,返回的函数一直运行到 client 停止。River 的事件可能丢失,等待的一方会定期按任务 ID 核对。
|
||||
func Watch(client *river.Client[pgx.Tx], finished func(port.PrepareOutcome), observe func(source track.Source, result string, elapsed time.Duration), logger *slog.Logger) func() {
|
||||
events, _ := client.Subscribe(river.EventKindJobCompleted, river.EventKindJobCancelled, river.EventKindJobFailed)
|
||||
return func() {
|
||||
for e := range events {
|
||||
if e.Job.Kind != (prepareArgs{}).Kind() {
|
||||
continue
|
||||
}
|
||||
ctx := logctx.WithJob(context.Background(), e.Job.ID)
|
||||
var args prepareArgs
|
||||
if err := json.Unmarshal(e.Job.EncodedArgs, &args); err != nil {
|
||||
logger.ErrorContext(ctx, "无法解码准备任务的参数", "error", err)
|
||||
continue
|
||||
}
|
||||
t := args.track()
|
||||
outcome := port.PrepareOutcome{Track: t, JobID: e.Job.ID}
|
||||
switch {
|
||||
case e.Kind == river.EventKindJobCompleted:
|
||||
observe(t.Source, "ok", e.JobStats.RunDuration)
|
||||
case e.Kind == river.EventKindJobCancelled:
|
||||
outcome.Reason = reason(e.Job)
|
||||
observe(t.Source, "rejected", e.JobStats.RunDuration)
|
||||
case e.Job.State == rivertype.JobStateDiscarded:
|
||||
outcome.Reason = port.UnavailableReason
|
||||
observe(t.Source, "failed", e.JobStats.RunDuration)
|
||||
logger.WarnContext(ctx, "准备任务失败", "source", t.Source, "track_id", t.ID, "attempt", e.Job.Attempt, "error", reason(e.Job))
|
||||
default:
|
||||
logger.WarnContext(ctx, "准备任务失败,稍后重试", "source", t.Source, "track_id", t.ID, "attempt", e.Job.Attempt, "error", reason(e.Job))
|
||||
continue
|
||||
}
|
||||
finished(outcome)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// River 记录取消的原因时带有 "JobCancelError: " 前缀;internal/adapter/out/queue 以同样的方式提取任务失败的原因。
|
||||
func reason(job *rivertype.JobRow) string {
|
||||
if len(job.Errors) == 0 {
|
||||
return "准备任务失败"
|
||||
}
|
||||
return strings.TrimPrefix(job.Errors[len(job.Errors)-1].Error, "JobCancelError: ")
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package bilibili
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
)
|
||||
|
||||
const (
|
||||
qrGenerateURL = "https://passport.bilibili.com/x/passport-login/web/qrcode/generate"
|
||||
qrPollURL = "https://passport.bilibili.com/x/passport-login/web/qrcode/poll"
|
||||
navURL = "https://api.bilibili.com/x/web-interface/nav"
|
||||
viewURL = "https://api.bilibili.com/x/web-interface/view"
|
||||
pagelistURL = "https://api.bilibili.com/x/player/pagelist"
|
||||
playurlURL = "https://api.bilibili.com/x/player/playurl"
|
||||
|
||||
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36"
|
||||
referer = "https://www.bilibili.com/"
|
||||
|
||||
apiTimeout = 30 * time.Second
|
||||
downloadTimeout = 10 * time.Minute
|
||||
qrLoginTTL = 3 * time.Minute
|
||||
bodyLimit = 4 << 20
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
api *http.Client
|
||||
download *http.Client
|
||||
root string
|
||||
loadCredential func(context.Context) (string, error)
|
||||
saveCredential func(context.Context, string) error
|
||||
observe func(source, op string, elapsed time.Duration, err error)
|
||||
}
|
||||
|
||||
var (
|
||||
_ port.BilibiliSource = (*Client)(nil)
|
||||
_ port.BilibiliLinks = (*Client)(nil)
|
||||
_ port.BilibiliQRLoginSource = (*Client)(nil)
|
||||
)
|
||||
|
||||
// upstream 用于全部平台请求,不能带 Timeout:API 请求的期限由 context 控制(30 秒)。
|
||||
// 下载复制一份 client,保留它的 Transport,期限放宽到 downloadTimeout;每次跳转都重新校验目标地址。
|
||||
// root 是下载中转文件的目录,必须是进程崩溃后残留会被启动清理删掉的目录(worker 的工作目录);
|
||||
// 不下载文件的进程传空字符串,此时 Download 显式失败而不是写进系统临时目录。
|
||||
func New(upstream *http.Client, root string, loadCredential func(context.Context) (string, error), saveCredential func(context.Context, string) error, observe func(source, op string, elapsed time.Duration, err error)) *Client {
|
||||
download := *upstream
|
||||
download.Timeout = downloadTimeout
|
||||
download.CheckRedirect = func(req *http.Request, _ []*http.Request) error {
|
||||
return checkDownloadURL(req.URL)
|
||||
}
|
||||
return &Client{
|
||||
api: upstream,
|
||||
download: &download,
|
||||
root: root,
|
||||
loadCredential: loadCredential,
|
||||
saveCredential: saveCredential,
|
||||
observe: observe,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) report(op string, start time.Time, err error) {
|
||||
if c.observe != nil {
|
||||
c.observe("bilibili", op, time.Since(start), err)
|
||||
}
|
||||
}
|
||||
|
||||
// 凭据对调用方是不透明字符串;Cookie 和 refresh token 的编码由本包决定。
|
||||
type credential struct {
|
||||
Cookie string `json:"cookie"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
}
|
||||
|
||||
func encodeCredential(cookie, refreshToken string) string {
|
||||
b, _ := json.Marshal(credential{Cookie: cookie, RefreshToken: refreshToken})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func decodeCredential(raw string) (credential, error) {
|
||||
var cred credential
|
||||
if err := json.Unmarshal([]byte(raw), &cred); err != nil {
|
||||
return credential{}, fmt.Errorf("bilibili 凭据格式无法解析:%w", err)
|
||||
}
|
||||
return cred, nil
|
||||
}
|
||||
|
||||
// cookie 读取保存的凭据;没有凭据时返回空字符串。
|
||||
func (c *Client) cookie(ctx context.Context) (string, error) {
|
||||
raw, err := c.loadCredential(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取 Bilibili 凭据:%w", err)
|
||||
}
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
cred, err := decodeCredential(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cred.Cookie, nil
|
||||
}
|
||||
|
||||
// 请求地址可能含敏感查询参数,返回错误不得携带地址。
|
||||
func (c *Client) get(ctx context.Context, rawURL, cookie string) ([]byte, []*http.Cookie, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, apiTimeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Referer", referer)
|
||||
if cookie != "" {
|
||||
req.Header.Set("Cookie", cookie)
|
||||
}
|
||||
resp, err := c.api.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("请求 B 站接口失败:%w", requestError(err))
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("bilibili 接口返回 HTTP %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+1))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("读取 B 站接口的响应:%w", err)
|
||||
}
|
||||
if len(body) > bodyLimit {
|
||||
return nil, nil, errors.New("bilibili 接口的响应过大")
|
||||
}
|
||||
return body, resp.Cookies(), nil
|
||||
}
|
||||
|
||||
type envelope struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// getData 解开 code/message/data 信封;code 非 0 时按平台拒绝或普通错误分类。
|
||||
func (c *Client) getData(ctx context.Context, rawURL, cookie string, data any) error {
|
||||
body, _, err := c.get(ctx, rawURL, cookie)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var env envelope
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
return fmt.Errorf("解析 B 站接口的响应:%w", err)
|
||||
}
|
||||
if env.Code != 0 {
|
||||
return classifyCode(env.Code, env.Message)
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, data); err != nil {
|
||||
return fmt.Errorf("解析 B 站接口的数据:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 这些 code 表示平台明确拒绝了这首歌:准备不再重试。
|
||||
var rejectionReasons = map[int]string{
|
||||
-400: "视频不存在或请求有误",
|
||||
-404: "视频不存在或已被删除",
|
||||
-403: "没有权限观看这个视频",
|
||||
62002: "视频不可见",
|
||||
62004: "视频还在审核中",
|
||||
}
|
||||
|
||||
func classifyCode(code int, message string) error {
|
||||
if reason, ok := rejectionReasons[code]; ok {
|
||||
if message != "" {
|
||||
reason += "(" + message + ")"
|
||||
}
|
||||
return &port.RejectedError{Err: errors.New(reason)}
|
||||
}
|
||||
if message == "" {
|
||||
return fmt.Errorf("bilibili 接口返回错误:code=%d", code)
|
||||
}
|
||||
return fmt.Errorf("bilibili 接口返回错误:code=%d message=%s", code, message)
|
||||
}
|
||||
|
||||
// requestError 去掉 url.Error 携带的方法和地址:签名地址不能进错误和日志。
|
||||
func requestError(err error) error {
|
||||
var uerr *url.Error
|
||||
if errors.As(err, &uerr) {
|
||||
return uerr.Err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// isDownloadHost 只允许 B 站的域名:bilibili.com、bilivideo.com、hdslb.com 和它们的子域名。
|
||||
func isDownloadHost(host string) bool {
|
||||
for _, d := range []string{"bilibili.com", "bilivideo.com", "hdslb.com"} {
|
||||
if host == d || strings.HasSuffix(host, "."+d) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// downloadURL 校验并规范化平台返回的地址:协议相对地址补成 https,主机必须在 B 站域名内,http 升级成 https。
|
||||
func downloadURL(rawURL string) (string, error) {
|
||||
if strings.HasPrefix(rawURL, "//") {
|
||||
rawURL = "https:" + rawURL
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return "", errors.New("平台返回的下载地址无法解析")
|
||||
}
|
||||
if err := checkDownloadURL(u); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func checkDownloadURL(u *url.URL) error {
|
||||
host := strings.ToLower(u.Hostname())
|
||||
if !isDownloadHost(host) {
|
||||
return fmt.Errorf("平台返回的下载地址不在 B 站域名内:%s", host)
|
||||
}
|
||||
if u.Scheme == "http" {
|
||||
u.Scheme = "https"
|
||||
}
|
||||
if u.Scheme != "https" {
|
||||
return fmt.Errorf("平台返回的下载地址不是 HTTPS:%s", u.Scheme)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package bilibili
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
func (c *Client) Download(ctx context.Context, id string) (port.BilibiliDownload, func(), error) {
|
||||
bvid, page, ok := track.ParseBilibiliID(id)
|
||||
if !ok {
|
||||
return port.BilibiliDownload{}, nil, fmt.Errorf("不是 Bilibili 曲目 ID:%q", id)
|
||||
}
|
||||
if c.root == "" {
|
||||
return port.BilibiliDownload{}, nil, errors.New("没有配置下载目录,这个进程不下载文件")
|
||||
}
|
||||
meta, info, err := c.trackMeta(ctx, "download_view", "download_pagelist", bvid, page)
|
||||
if err != nil {
|
||||
return port.BilibiliDownload{}, nil, err
|
||||
}
|
||||
cookie, err := c.cookie(ctx)
|
||||
if err != nil {
|
||||
return port.BilibiliDownload{}, nil, err
|
||||
}
|
||||
audioURLs, err := c.playURLs(ctx, bvid, info.cid, cookie)
|
||||
if err != nil {
|
||||
return port.BilibiliDownload{}, nil, err
|
||||
}
|
||||
|
||||
dir, err := os.MkdirTemp(c.root, "bilibili-")
|
||||
if err != nil {
|
||||
return port.BilibiliDownload{}, nil, fmt.Errorf("创建临时目录:%w", err)
|
||||
}
|
||||
release := func() { _ = os.RemoveAll(dir) }
|
||||
// 用 defer 是为了 panic 时也删除目录。
|
||||
done := false
|
||||
defer func() {
|
||||
if !done {
|
||||
release()
|
||||
}
|
||||
}()
|
||||
|
||||
audio := filepath.Join(dir, "audio")
|
||||
// 平台返回的节点未必都能下:baseUrl 常指向 EdgeOne 承载的镜像,可能直接拒绝(HTTP 567);
|
||||
// 同一条流的 base_url、backup_url 仍是完整签名地址,逐个尝试,全部失败才报错。
|
||||
var downloadErr error
|
||||
for _, audioURL := range audioURLs {
|
||||
if downloadErr = c.downloadFile(ctx, "download_audio", audioURL, cookie, audio); downloadErr == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if downloadErr != nil {
|
||||
return port.BilibiliDownload{}, nil, fmt.Errorf("下载音频失败(尝试了全部 %d 个节点):%w", len(audioURLs), downloadErr)
|
||||
}
|
||||
d := port.BilibiliDownload{Meta: meta, Audio: file(audio)}
|
||||
if coverURL, ext, ok := coverInfo(info.pic); ok {
|
||||
cover := filepath.Join(dir, "cover."+ext)
|
||||
if err := c.downloadFile(ctx, "download_cover", coverURL, cookie, cover); err != nil {
|
||||
return port.BilibiliDownload{}, nil, err
|
||||
}
|
||||
d.Cover, d.CoverExt = file(cover), ext
|
||||
}
|
||||
done = true
|
||||
return d, release, nil
|
||||
}
|
||||
|
||||
type playurlData struct {
|
||||
Durl []struct {
|
||||
URL string `json:"url"`
|
||||
BackupURL []string `json:"backup_url"`
|
||||
} `json:"durl"`
|
||||
Dash struct {
|
||||
Audio []struct {
|
||||
ID int `json:"id"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
BaseURL2 string `json:"base_url"`
|
||||
BackupURL []string `json:"backup_url"`
|
||||
} `json:"audio"`
|
||||
} `json:"dash"`
|
||||
}
|
||||
|
||||
// 优先以 fnval=80 在带 baseUrl 的 dash.audio 中选择 ID 最高的普通立体声 AAC(不选 FLAC 和杜比);
|
||||
// 没有可用候选地址时以 fnval=0、qn=16 回退到 durl 首项,避免为音频下载高清视频。
|
||||
// 候选地址按 baseUrl、base_url、backup_url 排序。
|
||||
func (c *Client) playURLs(ctx context.Context, bvid string, cid int64, cookie string) ([]string, error) {
|
||||
data, err := c.fetchPlayurl(ctx, 80, 127, bvid, cid, cookie)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bestID := -1
|
||||
var candidates []string
|
||||
for _, a := range data.Dash.Audio {
|
||||
if a.ID > bestID && a.BaseURL != "" {
|
||||
bestID = a.ID
|
||||
candidates = dedupe([]string{a.BaseURL, a.BaseURL2}, a.BackupURL)
|
||||
}
|
||||
}
|
||||
if len(candidates) > 0 {
|
||||
return candidates, nil
|
||||
}
|
||||
data, err = c.fetchPlayurl(ctx, 0, 16, bvid, cid, cookie)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data.Durl) > 0 && data.Durl[0].URL != "" {
|
||||
return dedupe([]string{data.Durl[0].URL}, data.Durl[0].BackupURL), nil
|
||||
}
|
||||
return nil, errors.New("平台没有返回可下载的音频")
|
||||
}
|
||||
|
||||
func dedupe(first []string, rest []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, u := range append(first, rest...) {
|
||||
if u != "" && !seen[u] {
|
||||
seen[u] = true
|
||||
out = append(out, u)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Client) fetchPlayurl(ctx context.Context, fnval, qn int, bvid string, cid int64, cookie string) (d playurlData, err error) {
|
||||
start := time.Now()
|
||||
defer func() { c.report("download_playurl", start, err) }()
|
||||
u := fmt.Sprintf("%s?fnval=%d&qn=%d&bvid=%s&cid=%d", playurlURL, fnval, qn, url.QueryEscape(bvid), cid)
|
||||
err = c.getData(ctx, u, cookie, &d)
|
||||
return d, err
|
||||
}
|
||||
|
||||
// 平台返回的地址不可信,发送请求前必须经 downloadURL 校验。
|
||||
func (c *Client) downloadFile(ctx context.Context, op, rawURL, cookie, dest string) (err error) {
|
||||
start := time.Now()
|
||||
defer func() { c.report(op, start, err) }()
|
||||
safe, err := downloadURL(rawURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, safe, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Referer", referer)
|
||||
// EdgeOne 承载的节点拦截完整文件的 GET(返回 567),带 Range 的请求会回源到源站放行。
|
||||
req.Header.Set("Range", "bytes=0-")
|
||||
if cookie != "" {
|
||||
req.Header.Set("Cookie", cookie)
|
||||
}
|
||||
resp, err := c.download.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("下载文件失败:%w", requestError(err))
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
|
||||
return fmt.Errorf("下载文件返回 HTTP %d", resp.StatusCode)
|
||||
}
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建文件:%w", err)
|
||||
}
|
||||
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||
_ = f.Close()
|
||||
return fmt.Errorf("保存文件:%w", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return fmt.Errorf("保存文件:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 封面地址无效、不在 B 站域名或格式不支持时跳过封面。
|
||||
func coverInfo(pic string) (string, string, bool) {
|
||||
if pic == "" {
|
||||
return "", "", false
|
||||
}
|
||||
safe, err := downloadURL(pic)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
u, err := url.Parse(safe)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
ext := strings.ToLower(strings.TrimPrefix(path.Ext(u.Path), "."))
|
||||
switch ext {
|
||||
case "jpg", "jpeg":
|
||||
ext = "jpg"
|
||||
case "png", "webp":
|
||||
default:
|
||||
return "", "", false
|
||||
}
|
||||
return safe, ext, true
|
||||
}
|
||||
|
||||
type file string
|
||||
|
||||
func (f file) Open() (io.ReadSeekCloser, error) {
|
||||
return os.Open(string(f))
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package bilibili
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
var plainAV = regexp.MustCompile(`^[aA][vV]([0-9]+)$`)
|
||||
|
||||
func (c *Client) Recognize(ctx context.Context, link string) (string, bool, error) {
|
||||
if id, ok := plainID(link); ok {
|
||||
return id, true, nil
|
||||
}
|
||||
u, err := url.Parse(link)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
if host == "b23.tv" {
|
||||
return c.resolveShortLink(ctx, u)
|
||||
}
|
||||
if !isBilibiliHost(host) {
|
||||
return "", false, nil
|
||||
}
|
||||
return classifyVideoURL(u)
|
||||
}
|
||||
|
||||
func isBilibiliHost(host string) bool {
|
||||
return host == "bilibili.com" || strings.HasSuffix(host, ".bilibili.com")
|
||||
}
|
||||
|
||||
// plainID 处理不带 scheme 的写法:BV 号(可带分 P)和 av 号。
|
||||
func plainID(s string) (string, bool) {
|
||||
if bvid, page, ok := track.ParseBilibiliID(s); ok {
|
||||
return track.BilibiliID(bvid, page), true
|
||||
}
|
||||
if m := plainAV.FindStringSubmatch(s); m != nil {
|
||||
if aid, err := strconv.ParseInt(m[1], 10, 64); err == nil {
|
||||
if bvid, ok := avToBV(aid); ok {
|
||||
return bvid, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// classifyVideoURL 处理 bilibili.com 的链接:视频返回曲目 ID,其他(番剧、直播、个人空间等)都拒绝。
|
||||
func classifyVideoURL(u *url.URL) (string, bool, error) {
|
||||
if !strings.HasPrefix(u.Path, "/video/") {
|
||||
return "", false, &port.RejectedError{Err: errors.New("这个 B 站链接不是视频,番剧、直播和个人空间等都不支持")}
|
||||
}
|
||||
idPart := strings.Trim(strings.TrimPrefix(u.Path, "/video/"), "/")
|
||||
var bvid string
|
||||
switch parsed, page, ok := track.ParseBilibiliID(idPart); {
|
||||
case ok && page == 1:
|
||||
bvid = parsed
|
||||
default:
|
||||
m := plainAV.FindStringSubmatch(idPart)
|
||||
if m == nil {
|
||||
return "", false, &port.RejectedError{Err: fmt.Errorf("无法识别的 B 站视频 ID:%s", idPart)}
|
||||
}
|
||||
aid, err := strconv.ParseInt(m[1], 10, 64)
|
||||
if err != nil {
|
||||
return "", false, &port.RejectedError{Err: fmt.Errorf("无法识别的 B 站视频 ID:%s", idPart)}
|
||||
}
|
||||
converted, ok := avToBV(aid)
|
||||
if !ok {
|
||||
return "", false, &port.RejectedError{Err: fmt.Errorf("无法识别的 B 站视频 ID:%s", idPart)}
|
||||
}
|
||||
bvid = converted
|
||||
}
|
||||
page := 1
|
||||
if p := u.Query().Get("p"); p != "" {
|
||||
if n, err := strconv.Atoi(p); err == nil && n >= 1 {
|
||||
page = n
|
||||
}
|
||||
}
|
||||
// 曲目 ID 的分 P 最多 4 位,更大序号的视频不存在。
|
||||
if page > 9999 {
|
||||
return "", false, &port.RejectedError{Err: fmt.Errorf("这个视频没有第 %d P", page)}
|
||||
}
|
||||
return track.BilibiliID(bvid, page), true, nil
|
||||
}
|
||||
|
||||
func (c *Client) resolveShortLink(ctx context.Context, u *url.URL) (id string, ok bool, err error) {
|
||||
start := time.Now()
|
||||
defer func() { c.report("recognize", start, err) }()
|
||||
ctx, cancel := context.WithTimeout(ctx, apiTimeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
// 保留注入 client 的 Transport,只禁止跟随重定向:跳转地址由下面的识别逻辑校验。
|
||||
client := *c.api
|
||||
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("请求 b23.tv 短链失败:%w", requestError(err))
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode < 300 || resp.StatusCode >= 400 {
|
||||
return "", false, fmt.Errorf("b23.tv 短链返回 HTTP %d", resp.StatusCode)
|
||||
}
|
||||
target, err := url.Parse(resp.Header.Get("Location"))
|
||||
if err != nil || (target.Scheme != "http" && target.Scheme != "https") || target.Host == "" {
|
||||
return "", false, errors.New("b23.tv 短链没有给出跳转地址")
|
||||
}
|
||||
if !isBilibiliHost(strings.ToLower(target.Hostname())) {
|
||||
return "", false, &port.RejectedError{Err: errors.New("b23.tv 短链指向的不是 B 站视频")}
|
||||
}
|
||||
id, _, err = classifyVideoURL(target)
|
||||
return id, err == nil, err
|
||||
}
|
||||
|
||||
// av 号和 BV 号的换算,与平台前端使用的算法相同。
|
||||
const (
|
||||
bvTable = "fZodR9XQDSUm21yCkr6zBqiveYah8bt4xsWpHnJE7jL5VG3guMTKNPAwcF"
|
||||
bvXOR = 177451812
|
||||
bvAdd = 8728348608
|
||||
)
|
||||
|
||||
var (
|
||||
bvPositions = [6]int{11, 10, 3, 8, 4, 6}
|
||||
bvBase = [6]int64{1, 58, 58 * 58, 58 * 58 * 58, 58 * 58 * 58 * 58, 58 * 58 * 58 * 58 * 58}
|
||||
)
|
||||
|
||||
func avToBV(aid int64) (string, bool) {
|
||||
if aid <= 0 {
|
||||
return "", false
|
||||
}
|
||||
x := (aid ^ bvXOR) + bvAdd
|
||||
r := []byte("BV1 4 1 7 ")
|
||||
for i, pos := range bvPositions {
|
||||
r[pos] = bvTable[x/bvBase[i]%58]
|
||||
}
|
||||
bvid := string(r)
|
||||
// 超出 6 位 base-58 能表示的范围时换算不成立。
|
||||
if back, ok := bvToAV(bvid); !ok || back != aid {
|
||||
return "", false
|
||||
}
|
||||
return bvid, true
|
||||
}
|
||||
|
||||
func bvToAV(bvid string) (int64, bool) {
|
||||
if len(bvid) != len("BV1 4 1 7 ") {
|
||||
return 0, false
|
||||
}
|
||||
var x int64
|
||||
for i, pos := range bvPositions {
|
||||
idx := strings.IndexByte(bvTable, bvid[pos])
|
||||
if idx < 0 {
|
||||
return 0, false
|
||||
}
|
||||
x += int64(idx) * bvBase[i]
|
||||
}
|
||||
return (x - bvAdd) ^ bvXOR, true
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package bilibili
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
)
|
||||
|
||||
func (c *Client) CreateQRLogin(ctx context.Context) (session port.QRLoginSession, err error) {
|
||||
start := time.Now()
|
||||
defer func() { c.report("login_create", start, err) }()
|
||||
var data struct {
|
||||
URL string `json:"url"`
|
||||
QRCodeKey string `json:"qrcode_key"`
|
||||
}
|
||||
if err := c.getData(ctx, qrGenerateURL, "", &data); err != nil {
|
||||
return port.QRLoginSession{}, err
|
||||
}
|
||||
if strings.TrimSpace(data.QRCodeKey) == "" {
|
||||
return port.QRLoginSession{}, errors.New("平台没有返回二维码 key")
|
||||
}
|
||||
// QRImage 留空:调用方按 LoginURL 生成二维码图片。
|
||||
return port.QRLoginSession{
|
||||
Key: data.QRCodeKey,
|
||||
LoginURL: data.URL,
|
||||
ExpiresAt: time.Now().Add(qrLoginTTL),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) CheckQRLogin(ctx context.Context, key string) (result port.QRLoginResult, err error) {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
return port.QRLoginResult{}, errors.New("二维码 key 为空")
|
||||
}
|
||||
start := time.Now()
|
||||
defer func() { c.report("login_check", start, err) }()
|
||||
body, cookies, err := c.get(ctx, qrPollURL+"?qrcode_key="+url.QueryEscape(key), "")
|
||||
if err != nil {
|
||||
return port.QRLoginResult{}, err
|
||||
}
|
||||
var payload struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return port.QRLoginResult{}, fmt.Errorf("解析扫码结果:%w", err)
|
||||
}
|
||||
if payload.Code != 0 {
|
||||
return port.QRLoginResult{}, classifyCode(payload.Code, payload.Message)
|
||||
}
|
||||
result = port.QRLoginResult{
|
||||
Status: qrStatus(payload.Data.Code),
|
||||
Message: cmp.Or(strings.TrimSpace(payload.Data.Message), strings.TrimSpace(payload.Message)),
|
||||
}
|
||||
if payload.Data.Code != 0 {
|
||||
return result, nil
|
||||
}
|
||||
// Set-Cookie 和 refresh_token 只写入服务端凭据,不放入 QRLoginResult。
|
||||
cookie := joinCookies(cookies)
|
||||
if cookie == "" {
|
||||
return port.QRLoginResult{}, errors.New("扫码成功但平台没有返回 Cookie")
|
||||
}
|
||||
if err := c.saveCredential(ctx, encodeCredential(cookie, payload.Data.RefreshToken)); err != nil {
|
||||
return port.QRLoginResult{}, fmt.Errorf("保存 Bilibili 凭据:%w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func qrStatus(code int) port.QRLoginStatus {
|
||||
switch code {
|
||||
case 0:
|
||||
return port.QRLoginSuccess
|
||||
case 86038:
|
||||
return port.QRLoginExpired
|
||||
case 86090:
|
||||
return port.QRLoginScanned
|
||||
case 86101:
|
||||
return port.QRLoginWaiting
|
||||
default:
|
||||
return port.QRLoginFailed
|
||||
}
|
||||
}
|
||||
|
||||
func joinCookies(cookies []*http.Cookie) string {
|
||||
parts := make([]string, 0, len(cookies))
|
||||
for _, cookie := range cookies {
|
||||
if strings.TrimSpace(cookie.Name) != "" {
|
||||
parts = append(parts, cookie.Name+"="+cookie.Value)
|
||||
}
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
type navData struct {
|
||||
IsLogin bool `json:"isLogin"`
|
||||
Uname string `json:"uname"`
|
||||
Mid int64 `json:"mid"`
|
||||
VipStatus int `json:"vipStatus"`
|
||||
VipType int `json:"vipType"`
|
||||
}
|
||||
|
||||
// fetchNav 不检查信封的 code:未登录时平台返回 -101,由调用方按 isLogin 区分。
|
||||
func (c *Client) fetchNav(ctx context.Context, op, cookie string) (d navData, err error) {
|
||||
start := time.Now()
|
||||
defer func() { c.report(op, start, err) }()
|
||||
body, _, err := c.get(ctx, navURL, cookie)
|
||||
if err != nil {
|
||||
return navData{}, err
|
||||
}
|
||||
var payload struct {
|
||||
Data navData `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return navData{}, fmt.Errorf("解析账号信息:%w", err)
|
||||
}
|
||||
return payload.Data, nil
|
||||
}
|
||||
|
||||
func (n navData) vip() bool {
|
||||
return n.IsLogin && n.VipStatus == 1 && n.VipType > 0
|
||||
}
|
||||
|
||||
func (c *Client) CredentialStatus(ctx context.Context) (port.CredentialStatus, error) {
|
||||
raw, err := c.loadCredential(ctx)
|
||||
if err != nil {
|
||||
return port.CredentialStatus{}, fmt.Errorf("读取 Bilibili 凭据:%w", err)
|
||||
}
|
||||
if raw == "" {
|
||||
return port.CredentialStatus{}, nil
|
||||
}
|
||||
cred, err := decodeCredential(raw)
|
||||
if err != nil {
|
||||
return port.CredentialStatus{}, err
|
||||
}
|
||||
nav, err := c.fetchNav(ctx, "credential_status", cred.Cookie)
|
||||
if err != nil {
|
||||
return port.CredentialStatus{}, err
|
||||
}
|
||||
if !nav.IsLogin {
|
||||
return port.CredentialStatus{Set: true}, nil
|
||||
}
|
||||
name, vip := nav.Uname, nav.vip()
|
||||
return port.CredentialStatus{
|
||||
Set: true,
|
||||
Account: &port.SourceAccount{
|
||||
ID: strconv.FormatInt(nav.Mid, 10),
|
||||
Name: &name,
|
||||
VIP: &vip,
|
||||
Verified: true,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package bilibili
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
type viewData struct {
|
||||
BVID string `json:"bvid"`
|
||||
Title string `json:"title"`
|
||||
Pic string `json:"pic"`
|
||||
Owner struct {
|
||||
Name string `json:"name"`
|
||||
Mid int64 `json:"mid"`
|
||||
} `json:"owner"`
|
||||
}
|
||||
|
||||
type pageInfo struct {
|
||||
CID int64 `json:"cid"`
|
||||
Page int `json:"page"`
|
||||
Part string `json:"part"`
|
||||
Duration int `json:"duration"`
|
||||
}
|
||||
|
||||
type videoInfo struct {
|
||||
cid int64
|
||||
pic string
|
||||
}
|
||||
|
||||
func (c *Client) TrackMeta(ctx context.Context, id string) (track.Meta, error) {
|
||||
bvid, page, ok := track.ParseBilibiliID(id)
|
||||
if !ok {
|
||||
return track.Meta{}, fmt.Errorf("不是 Bilibili 曲目 ID:%q", id)
|
||||
}
|
||||
meta, _, err := c.trackMeta(ctx, "meta_view", "meta_pagelist", bvid, page)
|
||||
return meta, err
|
||||
}
|
||||
|
||||
// trackMeta 取标题、UP 主和时长;CoverURL 和 PageURL 留空:封面在准备时下载存储,页面地址由调用方按 ID 拼出。
|
||||
// pagelist 是存在性判断和 cid 的来源;view 接口经常被风控(未登录 412、登录后伪装 -404),
|
||||
// 失败时退化为 pagelist 的元数据:标题用分 P 标题(分 P 标题为空时用 BV 号),UP 主和封面缺失。
|
||||
func (c *Client) trackMeta(ctx context.Context, viewOp, pagelistOp, bvid string, page int) (track.Meta, videoInfo, error) {
|
||||
cookie, err := c.cookie(ctx)
|
||||
if err != nil {
|
||||
return track.Meta{}, videoInfo{}, err
|
||||
}
|
||||
pages, err := c.fetchPagelist(ctx, pagelistOp, bvid, cookie)
|
||||
if err != nil {
|
||||
return track.Meta{}, videoInfo{}, err
|
||||
}
|
||||
p, ok := findPage(pages, page)
|
||||
if !ok {
|
||||
return track.Meta{}, videoInfo{}, &port.RejectedError{Err: fmt.Errorf("这个视频没有第 %d P", page)}
|
||||
}
|
||||
meta := track.Meta{Title: p.Part, Duration: time.Duration(p.Duration) * time.Second}
|
||||
if meta.Title == "" {
|
||||
meta.Title = bvid
|
||||
}
|
||||
info := videoInfo{cid: p.CID}
|
||||
view, err := c.fetchView(ctx, viewOp, bvid, cookie)
|
||||
if err != nil {
|
||||
return meta, info, nil
|
||||
}
|
||||
title := view.Title
|
||||
if p.Part != "" && p.Part != view.Title {
|
||||
title = view.Title + " - " + p.Part
|
||||
}
|
||||
meta.Title, meta.Artist = title, view.Owner.Name
|
||||
info.pic = view.Pic
|
||||
return meta, info, nil
|
||||
}
|
||||
|
||||
func (c *Client) fetchView(ctx context.Context, op, bvid, cookie string) (d viewData, err error) {
|
||||
start := time.Now()
|
||||
defer func() { c.report(op, start, err) }()
|
||||
err = c.getData(ctx, viewURL+"?bvid="+url.QueryEscape(bvid), cookie, &d)
|
||||
return d, err
|
||||
}
|
||||
|
||||
func (c *Client) fetchPagelist(ctx context.Context, op, bvid, cookie string) (pages []pageInfo, err error) {
|
||||
start := time.Now()
|
||||
defer func() { c.report(op, start, err) }()
|
||||
err = c.getData(ctx, pagelistURL+"?bvid="+url.QueryEscape(bvid), cookie, &pages)
|
||||
return pages, err
|
||||
}
|
||||
|
||||
func findPage(pages []pageInfo, page int) (pageInfo, bool) {
|
||||
for _, p := range pages {
|
||||
if p.Page == page {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return pageInfo{}, false
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
)
|
||||
|
||||
const (
|
||||
executable = "ffmpeg"
|
||||
waitDelay = 5 * time.Second
|
||||
outputLimit = 64 << 10
|
||||
)
|
||||
|
||||
type Transcoder struct {
|
||||
root string
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
var _ port.Transcoder = (*Transcoder)(nil)
|
||||
|
||||
func New(root string, logger *slog.Logger) *Transcoder {
|
||||
return &Transcoder{root: root, logger: logger}
|
||||
}
|
||||
|
||||
func (t *Transcoder) ToMP3(ctx context.Context, audio port.LinkFile) (port.LinkFile, func(), error) {
|
||||
dir, err := os.MkdirTemp(t.root, "ffmpeg-*")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("创建临时目录:%w", err)
|
||||
}
|
||||
release := func() { _ = os.RemoveAll(dir) }
|
||||
// 用 defer 是为了 panic 时也删除目录:River 会恢复任务中的 panic,worker 继续运行。
|
||||
done := false
|
||||
defer func() {
|
||||
if !done {
|
||||
release()
|
||||
}
|
||||
}()
|
||||
mp3, err := t.transcode(ctx, audio, dir)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
done = true
|
||||
return mp3, release, nil
|
||||
}
|
||||
|
||||
func (t *Transcoder) transcode(ctx context.Context, audio port.LinkFile, dir string) (port.LinkFile, error) {
|
||||
// 先复制成文件再交给 ffmpeg:MP4 的 moov 可能在文件末尾,从管道读不了。文件名不带扩展名,ffmpeg 只能按内容识别格式。
|
||||
input := filepath.Join(dir, "input")
|
||||
if err := copyFile(input, audio); err != nil {
|
||||
return nil, fmt.Errorf("复制输入的音频:%w", err)
|
||||
}
|
||||
output := filepath.Join(dir, "audio.mp3")
|
||||
stderr, err := run(ctx, args(input, output))
|
||||
if err == nil {
|
||||
// 转码成功后立即删除输入副本,不等 release:源音频和 MP3 在上传期间仍占用临时空间,
|
||||
// 同一容器还可能并行准备其他链接。
|
||||
_ = os.Remove(input)
|
||||
return file(output), nil
|
||||
}
|
||||
ctxErr := ctx.Err()
|
||||
// 任务被取消(worker 退出)不是转码的问题,不记日志。
|
||||
if !errors.Is(ctxErr, context.Canceled) {
|
||||
t.logger.WarnContext(ctx, "ffmpeg 转码失败", "stderr", string(stderr), "error", err)
|
||||
}
|
||||
if ctxErr != nil {
|
||||
return nil, fmt.Errorf("ffmpeg 转码:%w", ctxErr)
|
||||
}
|
||||
return nil, fmt.Errorf("ffmpeg 转码失败:%w", err)
|
||||
}
|
||||
|
||||
func copyFile(dst string, src port.LinkFile) error {
|
||||
in, err := src.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = in.Close() }()
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
_ = out.Close()
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
func args(input, output string) []string {
|
||||
return []string{
|
||||
"-nostdin", "-hide_banner", "-loglevel", "error",
|
||||
// 输入可能引用网络地址,例如 HLS 播放列表,所以只允许读本地文件。输入选项要放在 -i 之前。
|
||||
"-protocol_whitelist", "file",
|
||||
"-i", input,
|
||||
"-map", "0:a:0", "-vn", "-sn", "-dn",
|
||||
// 来源的标签和章节是用户上传的内容,ffmpeg 默认会把它们复制进 mp3,所以不复制。
|
||||
"-map_metadata", "-1", "-map_chapters", "-1",
|
||||
// 与 yt-dlp 的 --audio-format mp3 默认质量一致。
|
||||
"-c:a", "libmp3lame", "-q:a", "5",
|
||||
output,
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, argv []string) (stderr []byte, err error) {
|
||||
cmd := exec.CommandContext(ctx, executable, argv...)
|
||||
cmd.WaitDelay = waitDelay
|
||||
errOut := &tail{limit: outputLimit}
|
||||
cmd.Stdout, cmd.Stderr = io.Discard, errOut
|
||||
err = cmd.Run()
|
||||
return errOut.buf, err
|
||||
}
|
||||
|
||||
type tail struct {
|
||||
limit int
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func (t *tail) Write(p []byte) (int, error) {
|
||||
t.buf = append(t.buf, p...)
|
||||
if extra := len(t.buf) - t.limit; extra > 0 {
|
||||
t.buf = t.buf[extra:]
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
type file string
|
||||
|
||||
func (f file) Open() (io.ReadSeekCloser, error) {
|
||||
r, err := os.Open(string(f))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Package linkcookies 把数据库中保存的 YouTube cookies 写成 yt-dlp 需要的临时文件:
|
||||
// yt-dlp 只接受文件路径,cookies 必须短暂落盘。文件写在 worker 工作根目录下权限 0700
|
||||
// 的临时目录中,下载结束由调用方 release 删除;进程崩溃留下的残留由 worker 启动时的
|
||||
// 清理逻辑删除。yt-dlp 运行中会把更新后的 cookies 写回文件,所以 release 删除整个目录。
|
||||
package linkcookies
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
root string
|
||||
load func(context.Context) (string, error)
|
||||
}
|
||||
|
||||
var _ port.LinkCookies = (*Manager)(nil)
|
||||
|
||||
// load 返回保存的 cookies,未填写时返回空字符串。
|
||||
func New(root string, load func(context.Context) (string, error)) *Manager {
|
||||
return &Manager{root: root, load: load}
|
||||
}
|
||||
|
||||
func (m *Manager) CookieFile(ctx context.Context, link string) (string, func(), bool, error) {
|
||||
if !isYouTube(link) {
|
||||
return "", nil, false, nil
|
||||
}
|
||||
cookies, err := m.load(ctx)
|
||||
if err != nil {
|
||||
return "", nil, false, fmt.Errorf("读取 YouTube cookies:%w", err)
|
||||
}
|
||||
if strings.TrimSpace(cookies) == "" {
|
||||
return "", nil, false, nil
|
||||
}
|
||||
dir, err := os.MkdirTemp(m.root, "cookies-")
|
||||
if err != nil {
|
||||
return "", nil, false, fmt.Errorf("创建 cookies 临时目录:%w", err)
|
||||
}
|
||||
path := filepath.Join(dir, "cookies.txt")
|
||||
if err := os.WriteFile(path, []byte(cookies), 0o600); err != nil {
|
||||
_ = os.RemoveAll(dir)
|
||||
return "", nil, false, fmt.Errorf("写入 cookies 文件:%w", err)
|
||||
}
|
||||
release := func() { _ = os.RemoveAll(dir) }
|
||||
return path, release, true, nil
|
||||
}
|
||||
|
||||
// 链接由用户提交,yt-dlp 接受不带 scheme 的 YouTube 短链接。
|
||||
func isYouTube(link string) bool {
|
||||
if !strings.Contains(link, "://") {
|
||||
link = "https://" + link
|
||||
}
|
||||
u, err := url.Parse(link)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(strings.TrimSuffix(u.Hostname(), "."))
|
||||
for _, domain := range []string{"youtube.com", "youtu.be", "youtube-nocookie.com"} {
|
||||
if host == domain || strings.HasSuffix(host, "."+domain) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package netease
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
)
|
||||
|
||||
const accountAPI = "https://music.163.com/weapi/nuser/account/get"
|
||||
|
||||
func (c *Client) CurrentAccount(ctx context.Context) (*port.SourceAccount, error) {
|
||||
form, err := weapi(map[string]any{"csrf_token": ""})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
base
|
||||
Profile struct {
|
||||
UserID int64 `json:"userId"`
|
||||
Nickname string `json:"nickname"`
|
||||
VipType int `json:"vipType"`
|
||||
} `json:"profile"`
|
||||
}
|
||||
if err := c.call(ctx, "account", accountAPI, form, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Profile.UserID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
account := &port.SourceAccount{
|
||||
ID: strconv.FormatInt(resp.Profile.UserID, 10),
|
||||
VIP: new(bool),
|
||||
Verified: true,
|
||||
}
|
||||
*account.VIP = resp.Profile.VipType != 0
|
||||
if resp.Profile.Nickname != "" {
|
||||
account.Name = &resp.Profile.Nickname
|
||||
}
|
||||
return account, nil
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package netease
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/golang-lru/v2/expirable"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/lib/chinaip"
|
||||
)
|
||||
|
||||
const (
|
||||
requestTimeout = 30 * time.Second
|
||||
downloadTimeout = 10 * time.Minute
|
||||
cacheSize = 512
|
||||
cacheTTL = 30 * time.Minute
|
||||
cookieTTL = 30 * time.Second
|
||||
cookieLoadTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
|
||||
|
||||
type Client struct {
|
||||
http *http.Client
|
||||
api *http.Client
|
||||
cookie cookieCache
|
||||
cache *expirable.LRU[string, any]
|
||||
observe func(source, op string, elapsed time.Duration, err error)
|
||||
}
|
||||
|
||||
func New(httpClient *http.Client, cookie func(context.Context) (string, error), saveCookie func(context.Context, string) error, observe func(source, op string, elapsed time.Duration, err error)) *Client {
|
||||
api := *httpClient
|
||||
api.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
return &Client{
|
||||
http: httpClient,
|
||||
api: &api,
|
||||
cookie: cookieCache{load: cookie, store: saveCookie},
|
||||
cache: expirable.NewLRU[string, any](cacheSize, nil, cacheTTL),
|
||||
observe: observe,
|
||||
}
|
||||
}
|
||||
|
||||
type cookieCache struct {
|
||||
load func(context.Context) (string, error)
|
||||
store func(context.Context, string) error
|
||||
mu sync.Mutex
|
||||
value string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
func (c *cookieCache) get(ctx context.Context) (string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if time.Now().Before(c.expires) {
|
||||
return c.value, nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, cookieLoadTimeout)
|
||||
defer cancel()
|
||||
value, err := c.load(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
c.value, c.expires = value, time.Now().Add(cookieTTL)
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (c *cookieCache) put(ctx context.Context, value string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
ctx, cancel := context.WithTimeout(ctx, cookieLoadTimeout)
|
||||
defer cancel()
|
||||
if err := c.store(ctx, value); err != nil {
|
||||
return err
|
||||
}
|
||||
c.value, c.expires = value, time.Now().Add(cookieTTL)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cached[T any](c *Client, key string, fetch func() (T, error)) (T, error) {
|
||||
if v, ok := c.cache.Get(key); ok {
|
||||
return v.(T), nil
|
||||
}
|
||||
v, err := fetch()
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
c.cache.Add(key, v)
|
||||
return v, nil
|
||||
}
|
||||
|
||||
type response interface{ status() int }
|
||||
|
||||
type base struct {
|
||||
Code int `json:"code"`
|
||||
}
|
||||
|
||||
func (b base) status() int { return b.Code }
|
||||
|
||||
func (c *Client) call(ctx context.Context, op, endpoint string, form url.Values, out response) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, requestTimeout)
|
||||
defer cancel()
|
||||
cookie, err := c.cookie.get(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
start := time.Now()
|
||||
err = c.post(ctx, endpoint, form, cookie, out)
|
||||
if err == nil && out.status() != http.StatusOK {
|
||||
err = fmt.Errorf("网易云的接口 %s 返回错误码 %d", op, out.status())
|
||||
}
|
||||
c.observe("netease", op, time.Since(start), err)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) post(ctx context.Context, endpoint string, form url.Values, cookie string, out response) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return errors.New("网易云的请求地址无效")
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Referer", "https://music.163.com/")
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
chinaip.Set(req.Header)
|
||||
if cookie != "" {
|
||||
req.Header.Set("Cookie", cookie)
|
||||
}
|
||||
resp, err := c.api.Do(req)
|
||||
if err != nil {
|
||||
return requestError(req.URL.Host, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("网易云返回 HTTP %d", resp.StatusCode)
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return fmt.Errorf("解析网易云的响应:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func linux(data any) (url.Values, error) {
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return url.Values{"eparams": {linuxParams(b)}}, nil
|
||||
}
|
||||
|
||||
func weapi(data any) (url.Values, error) {
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params, encSecKey := weapiParams(b, newSecretKey())
|
||||
return url.Values{"params": {params}, "encSecKey": {encSecKey}}, nil
|
||||
}
|
||||
|
||||
func eapi(endpoint string, data any) (url.Values, error) {
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return url.Values{"params": {eapiParams(u.Path, b)}}, nil
|
||||
}
|
||||
|
||||
func (c *Client) download(ctx context.Context, audioURL string) (io.ReadCloser, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, downloadTimeout)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, audioURL, nil)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, errors.New("网易云的播放地址无效")
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, requestError(req.URL.Host, err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_ = resp.Body.Close()
|
||||
cancel()
|
||||
return nil, fmt.Errorf("下载网易云的音频:HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return &audio{ReadCloser: resp.Body, cancel: cancel}, nil
|
||||
}
|
||||
|
||||
func requestError(host string, err error) error {
|
||||
var urlError *url.Error
|
||||
if errors.As(err, &urlError) {
|
||||
err = urlError.Err
|
||||
}
|
||||
// 播放地址的查询参数可能携带签名,错误包装不得泄漏完整 URL。
|
||||
if strings.Contains(err.Error(), "failed to parse Location header") {
|
||||
return fmt.Errorf("请求 %s:解析跳转地址失败", host)
|
||||
}
|
||||
return fmt.Errorf("请求 %s:%w", host, err)
|
||||
}
|
||||
|
||||
type audio struct {
|
||||
io.ReadCloser
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (a *audio) Close() error {
|
||||
err := a.ReadCloser.Close()
|
||||
a.cancel()
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package netease
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"math/rand/v2"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
linuxKey = "rFgB&h#%2?^eDg:Q"
|
||||
eapiKey = "e82ckenh8dichen8"
|
||||
weapiNonce = "0CoJUm6Qyw8W8jud"
|
||||
weapiIV = "0102030405060708"
|
||||
weapiExponent = "010001"
|
||||
weapiModulus = "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7"
|
||||
)
|
||||
|
||||
func linuxParams(data []byte) string {
|
||||
return strings.ToUpper(hex.EncodeToString(ecb(data, linuxKey)))
|
||||
}
|
||||
|
||||
func eapiParams(path string, payload []byte) string {
|
||||
path = strings.Replace(path, "/eapi/", "/api/", 1)
|
||||
digest := md5.Sum(fmt.Appendf(nil, "nobody%suse%smd5forencrypt", path, payload))
|
||||
data := fmt.Appendf(nil, "%s-36cd479b6b5-%s-36cd479b6b5-%s", path, payload, hex.EncodeToString(digest[:]))
|
||||
return hex.EncodeToString(ecb(data, eapiKey))
|
||||
}
|
||||
|
||||
func weapiParams(data []byte, secretKey string) (params, encSecKey string) {
|
||||
params = cbc([]byte(cbc(data, weapiNonce)), secretKey)
|
||||
reversed := []byte(secretKey)
|
||||
slices.Reverse(reversed)
|
||||
text := new(big.Int).SetBytes(reversed)
|
||||
exponent, _ := new(big.Int).SetString(weapiExponent, 16)
|
||||
modulus, _ := new(big.Int).SetString(weapiModulus, 16)
|
||||
return params, fmt.Sprintf("%0256x", text.Exp(text, exponent, modulus))
|
||||
}
|
||||
|
||||
func newSecretKey() string {
|
||||
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
key := make([]byte, 16)
|
||||
for i := range key {
|
||||
key[i] = letters[rand.IntN(len(letters))]
|
||||
}
|
||||
return string(key)
|
||||
}
|
||||
|
||||
func ecb(data []byte, key string) []byte {
|
||||
block, _ := aes.NewCipher([]byte(key))
|
||||
data = pad(data)
|
||||
out := make([]byte, len(data))
|
||||
for i := 0; i < len(data); i += aes.BlockSize {
|
||||
block.Encrypt(out[i:i+aes.BlockSize], data[i:i+aes.BlockSize])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cbc(data []byte, key string) string {
|
||||
block, _ := aes.NewCipher([]byte(key))
|
||||
data = pad(data)
|
||||
out := make([]byte, len(data))
|
||||
cipher.NewCBCEncrypter(block, []byte(weapiIV)).CryptBlocks(out, data)
|
||||
return base64.StdEncoding.EncodeToString(out)
|
||||
}
|
||||
|
||||
func pad(data []byte) []byte {
|
||||
n := aes.BlockSize - len(data)%aes.BlockSize
|
||||
return append(slices.Clip(data), bytes.Repeat([]byte{byte(n)}, n)...)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package netease
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/lib/chinaip"
|
||||
)
|
||||
|
||||
const (
|
||||
qrLoginCreateURL = "https://interface.music.163.com/api/login/qrcode/unikey"
|
||||
qrLoginCheckURL = "https://interface.music.163.com/api/login/qrcode/client/login"
|
||||
qrLoginUserAgent = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36 Chrome/91.0.4472.164 NeteaseMusicDesktop/3.0.18.203152"
|
||||
)
|
||||
|
||||
func (c *Client) CreateQRLogin(ctx context.Context) (port.QRLoginSession, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, requestTimeout)
|
||||
defer cancel()
|
||||
var payload struct {
|
||||
base
|
||||
Unikey string `json:"unikey"`
|
||||
}
|
||||
start := time.Now()
|
||||
_, err := c.loginRequest(ctx, qrLoginCreateURL, url.Values{"type": {"3"}}, &payload)
|
||||
if err == nil && payload.Code != http.StatusOK {
|
||||
err = fmt.Errorf("网易云的接口 login_create 返回错误码 %d", payload.Code)
|
||||
}
|
||||
if err == nil && payload.Unikey == "" {
|
||||
err = errors.New("网易云创建扫码登录的响应缺少 unikey")
|
||||
}
|
||||
c.observe("netease", "login_create", time.Since(start), err)
|
||||
if err != nil {
|
||||
return port.QRLoginSession{}, err
|
||||
}
|
||||
return port.QRLoginSession{
|
||||
Key: payload.Unikey,
|
||||
LoginURL: "https://music.163.com/login?codekey=" + url.QueryEscape(payload.Unikey),
|
||||
ExpiresAt: time.Now().Add(5 * time.Minute),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) CheckQRLogin(ctx context.Context, key string) (result port.QRLoginResult, err error) {
|
||||
if key == "" {
|
||||
return port.QRLoginResult{}, errors.New("网易云扫码登录的 key 为空")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, requestTimeout)
|
||||
defer cancel()
|
||||
var payload struct {
|
||||
base
|
||||
Message string `json:"message"`
|
||||
Cookie string `json:"cookie"`
|
||||
}
|
||||
start := time.Now()
|
||||
defer func() { c.observe("netease", "login_check", time.Since(start), err) }()
|
||||
resp, err := c.loginRequest(ctx, qrLoginCheckURL, url.Values{"key": {key}, "type": {"3"}}, &payload)
|
||||
if err != nil {
|
||||
return port.QRLoginResult{}, err
|
||||
}
|
||||
result = port.QRLoginResult{Status: neteaseLoginStatus(payload.Code), Message: payload.Message}
|
||||
if result.Status != port.QRLoginSuccess {
|
||||
return result, nil
|
||||
}
|
||||
cookie := payload.Cookie
|
||||
if cookie == "" {
|
||||
cookies := resp.Cookies()
|
||||
sort.SliceStable(cookies, func(i, j int) bool { return cookies[i].Name < cookies[j].Name })
|
||||
pairs := make([]string, len(cookies))
|
||||
for i, cookie := range cookies {
|
||||
pairs[i] = cookie.Name + "=" + cookie.Value
|
||||
}
|
||||
cookie = strings.Join(pairs, "; ")
|
||||
}
|
||||
if cookie == "" {
|
||||
return port.QRLoginResult{}, errors.New("网易云扫码登录成功的响应缺少 Cookie")
|
||||
}
|
||||
if err = c.cookie.put(ctx, cookie); err != nil {
|
||||
return port.QRLoginResult{}, fmt.Errorf("保存网易云 Cookie:%w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func neteaseLoginStatus(code int) port.QRLoginStatus {
|
||||
switch code {
|
||||
case 800:
|
||||
return port.QRLoginExpired
|
||||
case 801:
|
||||
return port.QRLoginWaiting
|
||||
case 802:
|
||||
return port.QRLoginScanned
|
||||
case 803:
|
||||
return port.QRLoginSuccess
|
||||
default:
|
||||
return port.QRLoginFailed
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) loginRequest(ctx context.Context, endpoint string, form url.Values, out any) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Referer", "https://music.163.com/")
|
||||
req.Header.Set("User-Agent", qrLoginUserAgent)
|
||||
chinaip.Set(req.Header)
|
||||
resp, err := c.api.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求网易云:%w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("网易云返回 HTTP %d", resp.StatusCode)
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return nil, fmt.Errorf("解析网易云的响应:%w", err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package netease
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
const (
|
||||
linuxAPI = "https://music.163.com/api/linux/forward"
|
||||
detailAPI = "https://music.163.com/weapi/v3/song/detail"
|
||||
playlistAPI = "https://music.163.com/weapi/v3/playlist/detail"
|
||||
lyricAPI = "https://music.163.com/weapi/song/lyric"
|
||||
similarAPI = "https://music.163.com/weapi/discovery/simiSong"
|
||||
weapiURLAPI = "https://music.163.com/weapi/song/enhance/player/url"
|
||||
eapiURLAPI = "https://interface3.music.163.com/eapi/song/enhance/player/url/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
searchTracks = 1
|
||||
searchPlaylists = 1000
|
||||
detailBatch = 500
|
||||
)
|
||||
|
||||
var (
|
||||
_ port.NetEaseSource = (*Client)(nil)
|
||||
_ port.PlayURLs = (*Client)(nil)
|
||||
)
|
||||
|
||||
type (
|
||||
artist struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
album struct {
|
||||
Name string `json:"name"`
|
||||
PicURL string `json:"picUrl"`
|
||||
}
|
||||
|
||||
song struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Artists []artist `json:"ar"`
|
||||
Album album `json:"al"`
|
||||
Duration int64 `json:"dt"`
|
||||
}
|
||||
|
||||
similarSong struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Artists []artist `json:"artists"`
|
||||
Album album `json:"album"`
|
||||
Duration int64 `json:"duration"`
|
||||
}
|
||||
|
||||
playlist struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CoverImgURL string `json:"coverImgUrl"`
|
||||
Description string `json:"description"`
|
||||
Creator struct {
|
||||
Nickname string `json:"nickname"`
|
||||
} `json:"creator"`
|
||||
TrackCount int64 `json:"trackCount"`
|
||||
PlayCount int64 `json:"playCount"`
|
||||
}
|
||||
|
||||
urlResponse struct {
|
||||
base
|
||||
Data []struct {
|
||||
URL string `json:"url"`
|
||||
FreeTrialInfo any `json:"freeTrialInfo"`
|
||||
} `json:"data"`
|
||||
}
|
||||
)
|
||||
|
||||
func (c *Client) SearchTracks(ctx context.Context, keyword string) ([]port.Song, error) {
|
||||
return cached(c, "search_tracks "+keyword, func() ([]port.Song, error) {
|
||||
var resp struct {
|
||||
base
|
||||
Result struct {
|
||||
Songs []song `json:"songs"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := c.search(ctx, "search_tracks", keyword, searchTracks, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
songs := make([]port.Song, len(resp.Result.Songs))
|
||||
for i, s := range resp.Result.Songs {
|
||||
songs[i] = s.song()
|
||||
}
|
||||
return songs, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) SearchPlaylists(ctx context.Context, keyword string) ([]port.Playlist, error) {
|
||||
return cached(c, "search_playlists "+keyword, func() ([]port.Playlist, error) {
|
||||
var resp struct {
|
||||
base
|
||||
Result struct {
|
||||
Playlists []playlist `json:"playlists"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := c.search(ctx, "search_playlists", keyword, searchPlaylists, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
playlists := make([]port.Playlist, len(resp.Result.Playlists))
|
||||
for i, p := range resp.Result.Playlists {
|
||||
playlists[i] = port.Playlist{
|
||||
ID: strconv.FormatInt(p.ID, 10),
|
||||
Name: p.Name,
|
||||
CoverURL: p.CoverImgURL,
|
||||
Creator: p.Creator.Nickname,
|
||||
Description: p.Description,
|
||||
TrackCount: p.TrackCount,
|
||||
PlayCount: p.PlayCount,
|
||||
}
|
||||
}
|
||||
return playlists, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) search(ctx context.Context, op, keyword string, kind int, out response) error {
|
||||
form, err := linux(map[string]any{
|
||||
"method": "POST",
|
||||
"url": "http://music.163.com/api/cloudsearch/pc",
|
||||
"params": map[string]any{"s": keyword, "type": kind, "offset": 0, "limit": 100},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.call(ctx, op, linuxAPI, form, out)
|
||||
}
|
||||
|
||||
func (c *Client) PlaylistTracks(ctx context.Context, playlistID string) ([]port.Song, error) {
|
||||
return cached(c, "playlist_tracks "+playlistID, func() ([]port.Song, error) {
|
||||
form, err := weapi(map[string]any{"id": playlistID, "n": 0, "csrf_token": ""})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
base
|
||||
Playlist struct {
|
||||
TrackIDs []struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"trackIds"`
|
||||
} `json:"playlist"`
|
||||
}
|
||||
if err := c.call(ctx, "playlist_tracks", playlistAPI, form, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]string, len(resp.Playlist.TrackIDs))
|
||||
for i, t := range resp.Playlist.TrackIDs {
|
||||
ids[i] = strconv.FormatInt(t.ID, 10)
|
||||
}
|
||||
var songs []port.Song
|
||||
for start := 0; start < len(ids); start += detailBatch {
|
||||
batch, err := c.details(ctx, ids[start:min(start+detailBatch, len(ids))])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
songs = append(songs, batch...)
|
||||
}
|
||||
return songs, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) TrackMeta(ctx context.Context, id string) (track.Meta, error) {
|
||||
return cached(c, "track_meta "+id, func() (track.Meta, error) {
|
||||
songs, err := c.details(ctx, []string{id})
|
||||
if err != nil {
|
||||
return track.Meta{}, err
|
||||
}
|
||||
if len(songs) == 0 {
|
||||
return track.Meta{}, &port.RejectedError{Err: errors.New("网易云中没有这首歌")}
|
||||
}
|
||||
return songs[0].Meta, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) details(ctx context.Context, ids []string) ([]port.Song, error) {
|
||||
refs := make([]map[string]string, len(ids))
|
||||
for i, id := range ids {
|
||||
refs[i] = map[string]string{"id": id}
|
||||
}
|
||||
cJSON, err := json.Marshal(refs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idsJSON, err := json.Marshal(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
form, err := weapi(map[string]any{"c": string(cJSON), "ids": string(idsJSON)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
base
|
||||
Songs []song `json:"songs"`
|
||||
}
|
||||
if err := c.call(ctx, "track_detail", detailAPI, form, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found := make(map[string]port.Song, len(resp.Songs))
|
||||
for _, s := range resp.Songs {
|
||||
found[strconv.FormatInt(s.ID, 10)] = s.song()
|
||||
}
|
||||
songs := make([]port.Song, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if s, ok := found[id]; ok {
|
||||
songs = append(songs, s)
|
||||
}
|
||||
}
|
||||
return songs, nil
|
||||
}
|
||||
|
||||
func (c *Client) SimilarTracks(ctx context.Context, id string) ([]port.Song, error) {
|
||||
return cached(c, "similar "+id, func() ([]port.Song, error) {
|
||||
form, err := weapi(map[string]any{"songid": id, "limit": 50, "offset": 0})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
base
|
||||
Songs []similarSong `json:"songs"`
|
||||
}
|
||||
if err := c.call(ctx, "similar", similarAPI, form, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
songs := make([]port.Song, len(resp.Songs))
|
||||
for i, s := range resp.Songs {
|
||||
songs[i] = toSong(s.ID, s.Name, s.Artists, s.Album, s.Duration)
|
||||
}
|
||||
return songs, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) Resolve(ctx context.Context, id string) (port.Resolved, error) {
|
||||
meta, err := c.TrackMeta(ctx, id)
|
||||
if err != nil {
|
||||
return port.Resolved{}, err
|
||||
}
|
||||
lyric, err := c.lyric(ctx, id)
|
||||
if err != nil {
|
||||
return port.Resolved{}, err
|
||||
}
|
||||
audioURL, err := c.PlayURL(ctx, id)
|
||||
if err != nil {
|
||||
return port.Resolved{}, err
|
||||
}
|
||||
audio, err := c.download(ctx, audioURL)
|
||||
if err != nil {
|
||||
return port.Resolved{}, err
|
||||
}
|
||||
return port.Resolved{Meta: meta, Lyric: lyric, Audio: audio}, nil
|
||||
}
|
||||
|
||||
func (c *Client) lyric(ctx context.Context, id string) (string, error) {
|
||||
return cached(c, "lyric "+id, func() (string, error) {
|
||||
form, err := weapi(map[string]any{"csrf_token": "", "id": id, "lv": -1, "tv": -1})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var resp struct {
|
||||
base
|
||||
Lrc struct {
|
||||
Lyric string `json:"lyric"`
|
||||
} `json:"lrc"`
|
||||
}
|
||||
if err := c.call(ctx, "lyric", lyricAPI, form, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return text(resp.Lrc.Lyric), nil
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) PlayURL(ctx context.Context, id string) (string, error) {
|
||||
eapiForm, err := eapi(eapiURLAPI, map[string]any{
|
||||
"ids": []json.Number{json.Number(id)},
|
||||
"level": "exhigh",
|
||||
"encodeType": "flac",
|
||||
"header": `{"os":"pc","appver":"","osver":"","deviceId":"pyncm!","requestId":"12345678"}`,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var exhigh urlResponse
|
||||
exhighErr := c.call(ctx, "play_url", eapiURLAPI, eapiForm, &exhigh)
|
||||
if u := exhigh.url(); exhighErr == nil && u != "" {
|
||||
return u, nil
|
||||
}
|
||||
weapiForm, err := weapi(map[string]any{"ids": []string{id}, "br": 320000})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var standard urlResponse
|
||||
standardErr := c.call(ctx, "play_url", weapiURLAPI, weapiForm, &standard)
|
||||
if u := standard.url(); standardErr == nil && u != "" {
|
||||
return u, nil
|
||||
}
|
||||
if err := errors.Join(exhighErr, standardErr); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if exhigh.trial() || standard.trial() {
|
||||
return "", &port.RejectedError{Err: errors.New("这首歌需要网易云会员账号才能完整播放,请管理员在后台设置会员账号的 Cookie")}
|
||||
}
|
||||
return "", &port.RejectedError{Err: errors.New("网易云没有提供这首歌的播放地址")}
|
||||
}
|
||||
|
||||
func (r urlResponse) trial() bool {
|
||||
return len(r.Data) > 0 && r.Data[0].FreeTrialInfo != nil
|
||||
}
|
||||
|
||||
func (r urlResponse) url() string {
|
||||
if len(r.Data) == 0 || r.trial() {
|
||||
return ""
|
||||
}
|
||||
return r.Data[0].URL
|
||||
}
|
||||
|
||||
func (s song) song() port.Song {
|
||||
return toSong(s.ID, s.Name, s.Artists, s.Album, s.Duration)
|
||||
}
|
||||
|
||||
func toSong(id int64, name string, artists []artist, al album, durationMs int64) port.Song {
|
||||
sid := strconv.FormatInt(id, 10)
|
||||
names := make([]string, 0, len(artists))
|
||||
for _, a := range artists {
|
||||
if a.Name != "" {
|
||||
names = append(names, a.Name)
|
||||
}
|
||||
}
|
||||
return port.Song{
|
||||
Track: track.Track{Source: track.NetEase, ID: sid},
|
||||
Meta: track.Meta{
|
||||
Title: text(name),
|
||||
Artist: text(strings.Join(names, ", ")),
|
||||
Album: text(al.Name),
|
||||
CoverURL: text(al.PicURL),
|
||||
PageURL: "https://music.163.com/#/song?id=" + sid,
|
||||
Duration: time.Duration(durationMs) * time.Millisecond,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func text(s string) string {
|
||||
return strings.ReplaceAll(strings.ToValidUTF8(s, "\uFFFD"), "\x00", "")
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
)
|
||||
|
||||
var _ port.APIKeys = (*Store)(nil)
|
||||
|
||||
const botName = "bot"
|
||||
|
||||
func (s *Store) RoomAPIKeyStatus(ctx context.Context, roomID string) (port.APIKeyStatus, error) {
|
||||
createdAt, err := s.q.BotTicketCreatedAt(ctx, roomID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return port.APIKeyStatus{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return port.APIKeyStatus{}, fmt.Errorf("读取房间 %s 的 API Key 状态:%w", roomID, err)
|
||||
}
|
||||
return port.APIKeyStatus{Set: true, CreatedAt: createdAt}, nil
|
||||
}
|
||||
|
||||
func (s *Store) ResetRoomAPIKey(ctx context.Context, roomID string, k port.NewAPIKey) error {
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
q := s.q.WithTx(tx)
|
||||
bot, err := q.LockRoomBot(ctx, roomID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return port.ErrRoomNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if bot != nil {
|
||||
if err := q.DeleteMemberTickets(ctx, query.DeleteMemberTicketsParams{RoomID: roomID, MemberID: *bot}); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
m, err := q.CreateMember(ctx, query.CreateMemberParams{TokenHash: k.BotTokenHash, Name: botName})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := q.SetRoomBot(ctx, query.SetRoomBotParams{ID: roomID, BotMemberID: m.ID}); err != nil {
|
||||
return err
|
||||
}
|
||||
bot = &m.ID
|
||||
}
|
||||
return q.CreateTicket(ctx, query.CreateTicketParams{TokenHash: k.TokenHash, RoomID: roomID, MemberID: *bot})
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("重置房间 %s 的 API Key:%w", roomID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
)
|
||||
|
||||
var _ port.Credentials = (*Store)(nil)
|
||||
var _ port.YouTubeCookies = (*Store)(nil)
|
||||
|
||||
const (
|
||||
neteaseCookie = "netease"
|
||||
qqCookie = "qq"
|
||||
bilibiliCredential = "bilibili"
|
||||
youtubeCredential = "youtube"
|
||||
)
|
||||
|
||||
func (s *Store) NeteaseCookie(ctx context.Context) (string, error) {
|
||||
cookie, err := s.q.Credential(ctx, neteaseCookie)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取网易云 Cookie:%w", err)
|
||||
}
|
||||
return cookie, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetNeteaseCookie(ctx context.Context, cookie string) error {
|
||||
if err := s.setCredential(ctx, neteaseCookie, cookie); err != nil {
|
||||
return fmt.Errorf("保存网易云 Cookie:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) QQCookie(ctx context.Context) (string, error) {
|
||||
cookie, err := s.q.Credential(ctx, qqCookie)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取 QQ Cookie:%w", err)
|
||||
}
|
||||
return cookie, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetQQCookie(ctx context.Context, cookie string) error {
|
||||
if err := s.setCredential(ctx, qqCookie, cookie); err != nil {
|
||||
return fmt.Errorf("保存 QQ Cookie:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) BilibiliCredential(ctx context.Context) (string, error) {
|
||||
credential, err := s.q.Credential(ctx, bilibiliCredential)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取 Bilibili 凭据:%w", err)
|
||||
}
|
||||
return credential, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetBilibiliCredential(ctx context.Context, credential string) error {
|
||||
if err := s.setCredential(ctx, bilibiliCredential, credential); err != nil {
|
||||
return fmt.Errorf("保存 Bilibili 凭据:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SetYouTubeCookies(ctx context.Context, cookies string) error {
|
||||
if err := s.setCredential(ctx, youtubeCredential, cookies); err != nil {
|
||||
return fmt.Errorf("保存 YouTube cookies:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) YouTubeCookies(ctx context.Context) (string, error) {
|
||||
cookies, err := s.q.Credential(ctx, youtubeCredential)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取 YouTube cookies:%w", err)
|
||||
}
|
||||
return cookies, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetCookies(ctx context.Context, cookies string) error {
|
||||
return s.SetYouTubeCookies(ctx, cookies)
|
||||
}
|
||||
|
||||
func (s *Store) Status(ctx context.Context) (port.YouTubeCookiesStatus, error) {
|
||||
credential, err := s.q.CredentialWithUpdatedAt(ctx, youtubeCredential)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return port.YouTubeCookiesStatus{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return port.YouTubeCookiesStatus{}, fmt.Errorf("读取 YouTube cookies 状态:%w", err)
|
||||
}
|
||||
return port.YouTubeCookiesStatus{Set: true, UpdatedAt: credential.UpdatedAt}, nil
|
||||
}
|
||||
|
||||
func (s *Store) setCredential(ctx context.Context, name, value string) error {
|
||||
if value == "" {
|
||||
return s.q.DeleteCredential(ctx, name)
|
||||
}
|
||||
return s.q.SetCredential(ctx, query.SetCredentialParams{Name: name, Value: value})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
var (
|
||||
_ port.PlayHistory = (*Store)(nil)
|
||||
_ port.TrackUsage = (*Store)(nil)
|
||||
)
|
||||
|
||||
func (s *Store) AutoDJSeeds(ctx context.Context, roomID string, includeQueue bool) ([]track.Track, error) {
|
||||
ids, err := s.q.AutoDJSeeds(ctx, query.AutoDJSeedsParams{RoomID: roomID, IncludeQueue: includeQueue})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取房间 %s 的 AutoDJ 种子:%w", roomID, err)
|
||||
}
|
||||
seeds := make([]track.Track, len(ids))
|
||||
for i, id := range ids {
|
||||
seeds[i] = track.Track{Source: track.NetEase, ID: id}
|
||||
}
|
||||
return seeds, nil
|
||||
}
|
||||
|
||||
func (s *Store) AutoDJExclusions(ctx context.Context, roomID string) ([]track.Track, error) {
|
||||
rows, err := s.q.AutoDJExclusions(ctx, roomID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取房间 %s 的 AutoDJ 排除项:%w", roomID, err)
|
||||
}
|
||||
excluded := make([]track.Track, len(rows))
|
||||
for i, r := range rows {
|
||||
excluded[i] = track.Track{Source: track.Source(r.Source), ID: r.TrackID}
|
||||
}
|
||||
return excluded, nil
|
||||
}
|
||||
|
||||
func (s *Store) TrackInUse(ctx context.Context, t track.Track) (bool, error) {
|
||||
used, err := s.q.TrackInUse(ctx, query.TrackInUseParams{Source: string(t.Source), TrackID: t.ID})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("检查曲目 %s %s 是否仍被房间引用:%w", t.Source, t.ID, err)
|
||||
}
|
||||
return used, nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: apikeys.sql
|
||||
|
||||
package query
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
const botTicketCreatedAt = `-- name: BotTicketCreatedAt :one
|
||||
SELECT t.created_at FROM room_tickets t JOIN rooms r ON r.id = t.room_id AND r.bot_member_id = t.member_id
|
||||
WHERE t.room_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) BotTicketCreatedAt(ctx context.Context, roomID string) (time.Time, error) {
|
||||
row := q.db.QueryRow(ctx, botTicketCreatedAt, roomID)
|
||||
var created_at time.Time
|
||||
err := row.Scan(&created_at)
|
||||
return created_at, err
|
||||
}
|
||||
|
||||
const deleteMemberTickets = `-- name: DeleteMemberTickets :exec
|
||||
DELETE FROM room_tickets WHERE room_id = $1 AND member_id = $2
|
||||
`
|
||||
|
||||
type DeleteMemberTicketsParams struct {
|
||||
RoomID string
|
||||
MemberID string
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteMemberTickets(ctx context.Context, arg DeleteMemberTicketsParams) error {
|
||||
_, err := q.db.Exec(ctx, deleteMemberTickets, arg.RoomID, arg.MemberID)
|
||||
return err
|
||||
}
|
||||
|
||||
const lockRoomBot = `-- name: LockRoomBot :one
|
||||
SELECT bot_member_id FROM rooms WHERE id = $1 FOR NO KEY UPDATE
|
||||
`
|
||||
|
||||
// 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE:同一个房间的重置要依次进行,
|
||||
// 同时到达的首次生成才只会创建一个 bot 成员,最后只留下一把 Key。
|
||||
func (q *Queries) LockRoomBot(ctx context.Context, id string) (*string, error) {
|
||||
row := q.db.QueryRow(ctx, lockRoomBot, id)
|
||||
var bot_member_id *string
|
||||
err := row.Scan(&bot_member_id)
|
||||
return bot_member_id, err
|
||||
}
|
||||
|
||||
const setRoomBot = `-- name: SetRoomBot :exec
|
||||
UPDATE rooms SET bot_member_id = $1::uuid WHERE id = $2
|
||||
`
|
||||
|
||||
type SetRoomBotParams struct {
|
||||
BotMemberID string
|
||||
ID string
|
||||
}
|
||||
|
||||
func (q *Queries) SetRoomBot(ctx context.Context, arg SetRoomBotParams) error {
|
||||
_, err := q.db.Exec(ctx, setRoomBot, arg.BotMemberID, arg.ID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: copyfrom.go
|
||||
|
||||
package query
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// iteratorForInsertPlays implements pgx.CopyFromSource.
|
||||
type iteratorForInsertPlays struct {
|
||||
rows []InsertPlaysParams
|
||||
skippedFirstNextCall bool
|
||||
}
|
||||
|
||||
func (r *iteratorForInsertPlays) 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 iteratorForInsertPlays) Values() ([]interface{}, error) {
|
||||
return []interface{}{
|
||||
r.rows[0].RoomID,
|
||||
r.rows[0].Source,
|
||||
r.rows[0].TrackID,
|
||||
r.rows[0].ByMemberID,
|
||||
r.rows[0].ByName,
|
||||
r.rows[0].ByAvatarHash,
|
||||
r.rows[0].Auto,
|
||||
r.rows[0].StartedAt,
|
||||
r.rows[0].EndedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r iteratorForInsertPlays) Err() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *Queries) InsertPlays(ctx context.Context, arg []InsertPlaysParams) (int64, error) {
|
||||
return q.db.CopyFrom(ctx, []string{"plays"}, []string{"room_id", "source", "track_id", "by_member_id", "by_name", "by_avatar_hash", "auto", "started_at", "ended_at"}, &iteratorForInsertPlays{rows: arg})
|
||||
}
|
||||
|
||||
// iteratorForInsertRoomEntries implements pgx.CopyFromSource.
|
||||
type iteratorForInsertRoomEntries struct {
|
||||
rows []InsertRoomEntriesParams
|
||||
skippedFirstNextCall bool
|
||||
}
|
||||
|
||||
func (r *iteratorForInsertRoomEntries) 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 iteratorForInsertRoomEntries) Values() ([]interface{}, error) {
|
||||
return []interface{}{
|
||||
r.rows[0].RoomID,
|
||||
r.rows[0].EntryID,
|
||||
r.rows[0].Slot,
|
||||
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].PageURL,
|
||||
r.rows[0].MetaDurationMs,
|
||||
r.rows[0].ByMemberID,
|
||||
r.rows[0].ByName,
|
||||
r.rows[0].ByAvatarHash,
|
||||
r.rows[0].Auto,
|
||||
r.rows[0].Likes,
|
||||
r.rows[0].Round,
|
||||
r.rows[0].AddedAt,
|
||||
r.rows[0].StartedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r iteratorForInsertRoomEntries) Err() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *Queries) InsertRoomEntries(ctx context.Context, arg []InsertRoomEntriesParams) (int64, error) {
|
||||
return q.db.CopyFrom(ctx, []string{"room_entries"}, []string{"room_id", "entry_id", "slot", "source", "track_id", "title", "artist", "album", "cover_url", "cover_key", "page_url", "meta_duration_ms", "by_member_id", "by_name", "by_avatar_hash", "auto", "likes", "round", "added_at", "started_at"}, &iteratorForInsertRoomEntries{rows: arg})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: credentials.sql
|
||||
|
||||
package query
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
const credential = `-- name: Credential :one
|
||||
SELECT value FROM credentials WHERE name = $1
|
||||
`
|
||||
|
||||
func (q *Queries) Credential(ctx context.Context, name string) (string, error) {
|
||||
row := q.db.QueryRow(ctx, credential, name)
|
||||
var value string
|
||||
err := row.Scan(&value)
|
||||
return value, err
|
||||
}
|
||||
|
||||
const credentialWithUpdatedAt = `-- name: CredentialWithUpdatedAt :one
|
||||
SELECT value, updated_at FROM credentials WHERE name = $1
|
||||
`
|
||||
|
||||
type CredentialWithUpdatedAtRow struct {
|
||||
Value string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (q *Queries) CredentialWithUpdatedAt(ctx context.Context, name string) (CredentialWithUpdatedAtRow, error) {
|
||||
row := q.db.QueryRow(ctx, credentialWithUpdatedAt, name)
|
||||
var i CredentialWithUpdatedAtRow
|
||||
err := row.Scan(&i.Value, &i.UpdatedAt)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteCredential = `-- name: DeleteCredential :exec
|
||||
DELETE FROM credentials WHERE name = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteCredential(ctx context.Context, name string) error {
|
||||
_, err := q.db.Exec(ctx, deleteCredential, name)
|
||||
return err
|
||||
}
|
||||
|
||||
const setCredential = `-- name: SetCredential :exec
|
||||
INSERT INTO credentials (name, value) VALUES ($1, $2)
|
||||
ON CONFLICT (name) DO UPDATE SET value = excluded.value, updated_at = now()
|
||||
`
|
||||
|
||||
type SetCredentialParams struct {
|
||||
Name string
|
||||
Value string
|
||||
}
|
||||
|
||||
func (q *Queries) SetCredential(ctx context.Context, arg SetCredentialParams) error {
|
||||
_, err := q.db.Exec(ctx, setCredential, arg.Name, arg.Value)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package query
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
|
||||
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
|
||||
QueryRow(context.Context, string, ...interface{}) pgx.Row
|
||||
CopyFrom(ctx context.Context, tableName pgx.Identifier, columnNames []string, rowSrc pgx.CopyFromSource) (int64, error)
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: history.sql
|
||||
|
||||
package query
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const autoDJExclusions = `-- name: AutoDJExclusions :many
|
||||
SELECT e.source, e.track_id FROM room_entries e
|
||||
WHERE e.room_id = $1 AND e.slot IN ('pending', 'waiting')
|
||||
UNION
|
||||
SELECT source, track_id FROM (
|
||||
SELECT source, track_id
|
||||
FROM (
|
||||
SELECT c.source, c.track_id, c.started_at FROM room_entries c
|
||||
WHERE c.room_id = $1 AND c.slot = 'current' AND c.auto
|
||||
UNION ALL
|
||||
SELECT p.source, p.track_id, p.started_at FROM (
|
||||
SELECT source, track_id, auto, started_at FROM plays
|
||||
WHERE room_id = $1
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1000
|
||||
) p
|
||||
WHERE p.auto
|
||||
) auto_picks
|
||||
GROUP BY source, track_id
|
||||
ORDER BY max(started_at) DESC
|
||||
LIMIT 128
|
||||
) recent
|
||||
ORDER BY source, track_id
|
||||
`
|
||||
|
||||
type AutoDJExclusionsRow struct {
|
||||
Source string
|
||||
TrackID string
|
||||
}
|
||||
|
||||
func (q *Queries) AutoDJExclusions(ctx context.Context, roomID string) ([]AutoDJExclusionsRow, error) {
|
||||
rows, err := q.db.Query(ctx, autoDJExclusions, roomID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []AutoDJExclusionsRow
|
||||
for rows.Next() {
|
||||
var i AutoDJExclusionsRow
|
||||
if err := rows.Scan(&i.Source, &i.TrackID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const autoDJSeeds = `-- name: AutoDJSeeds :many
|
||||
SELECT track_id FROM (
|
||||
SELECT DISTINCT ON (track_id) track_id, tier, rank
|
||||
FROM (
|
||||
-- 队列中的歌按等待队列的排序规则排列:轮流播放时点赞数相同的先比轮次。
|
||||
SELECT e.track_id, 0 AS tier,
|
||||
row_number() OVER (ORDER BY CASE e.slot WHEN 'current' THEN 0 WHEN 'pending' THEN 1 ELSE 2 END, e.likes DESC,
|
||||
CASE WHEN r.play_mode = 'rotation' THEN e.round END, e.entry_id) AS rank
|
||||
FROM room_entries e
|
||||
JOIN rooms r ON r.id = e.room_id
|
||||
WHERE e.room_id = $1 AND e.source = 'netease' AND NOT e.auto
|
||||
AND (e.slot = 'current' OR $2::boolean)
|
||||
UNION ALL
|
||||
SELECT p.track_id, 1, row_number() OVER (ORDER BY max(p.started_at) DESC)
|
||||
FROM (
|
||||
SELECT track_id, source, auto, started_at FROM plays
|
||||
WHERE room_id = $1
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1000
|
||||
) p
|
||||
WHERE p.source = 'netease' AND NOT p.auto
|
||||
GROUP BY p.track_id
|
||||
) candidates
|
||||
ORDER BY track_id, tier, rank
|
||||
) seeds
|
||||
ORDER BY tier, rank
|
||||
LIMIT 32
|
||||
`
|
||||
|
||||
type AutoDJSeedsParams struct {
|
||||
RoomID string
|
||||
IncludeQueue bool
|
||||
}
|
||||
|
||||
// 只看最近 1000 次播放:按 plays (room_id, started_at) 的索引倒序读到 1000 行就停,耗时与历史的长度无关。
|
||||
func (q *Queries) AutoDJSeeds(ctx context.Context, arg AutoDJSeedsParams) ([]string, error) {
|
||||
rows, err := q.db.Query(ctx, autoDJSeeds, arg.RoomID, arg.IncludeQueue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []string
|
||||
for rows.Next() {
|
||||
var track_id string
|
||||
if err := rows.Scan(&track_id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, track_id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const trackInUse = `-- name: TrackInUse :one
|
||||
SELECT EXISTS (SELECT 1 FROM room_entries WHERE source = $1 AND track_id = $2)
|
||||
`
|
||||
|
||||
type TrackInUseParams struct {
|
||||
Source string
|
||||
TrackID string
|
||||
}
|
||||
|
||||
func (q *Queries) TrackInUse(ctx context.Context, arg TrackInUseParams) (bool, error) {
|
||||
row := q.db.QueryRow(ctx, trackInUse, arg.Source, arg.TrackID)
|
||||
var exists bool
|
||||
err := row.Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: media.sql
|
||||
|
||||
package query
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
const deleteStoredMedia = `-- name: DeleteStoredMedia :exec
|
||||
DELETE FROM tracks WHERE media_key = $1::text
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteStoredMedia(ctx context.Context, mediaKey string) error {
|
||||
_, err := q.db.Exec(ctx, deleteStoredMedia, mediaKey)
|
||||
return err
|
||||
}
|
||||
|
||||
const listStoredMedia = `-- name: ListStoredMedia :many
|
||||
SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks
|
||||
WHERE media_key IS NOT NULL
|
||||
ORDER BY prepared_at DESC, track_id
|
||||
`
|
||||
|
||||
type ListStoredMediaRow struct {
|
||||
MediaKey string
|
||||
Source string
|
||||
TrackID string
|
||||
Title string
|
||||
MediaSize int64
|
||||
CoverKey *string
|
||||
PreparedAt time.Time
|
||||
LastPlayedAt *time.Time
|
||||
}
|
||||
|
||||
func (q *Queries) ListStoredMedia(ctx context.Context) ([]ListStoredMediaRow, error) {
|
||||
rows, err := q.db.Query(ctx, listStoredMedia)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListStoredMediaRow
|
||||
for rows.Next() {
|
||||
var i ListStoredMediaRow
|
||||
if err := rows.Scan(
|
||||
&i.MediaKey,
|
||||
&i.Source,
|
||||
&i.TrackID,
|
||||
&i.Title,
|
||||
&i.MediaSize,
|
||||
&i.CoverKey,
|
||||
&i.PreparedAt,
|
||||
&i.LastPlayedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const storedMedia = `-- name: StoredMedia :one
|
||||
SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks
|
||||
WHERE media_key = $1::text
|
||||
`
|
||||
|
||||
type StoredMediaRow struct {
|
||||
MediaKey string
|
||||
Source string
|
||||
TrackID string
|
||||
Title string
|
||||
MediaSize int64
|
||||
CoverKey *string
|
||||
PreparedAt time.Time
|
||||
LastPlayedAt *time.Time
|
||||
}
|
||||
|
||||
func (q *Queries) StoredMedia(ctx context.Context, mediaKey string) (StoredMediaRow, error) {
|
||||
row := q.db.QueryRow(ctx, storedMedia, mediaKey)
|
||||
var i StoredMediaRow
|
||||
err := row.Scan(
|
||||
&i.MediaKey,
|
||||
&i.Source,
|
||||
&i.TrackID,
|
||||
&i.Title,
|
||||
&i.MediaSize,
|
||||
&i.CoverKey,
|
||||
&i.PreparedAt,
|
||||
&i.LastPlayedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: members.sql
|
||||
|
||||
package query
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const createMember = `-- name: CreateMember :one
|
||||
INSERT INTO members (token_hash, name) VALUES ($1, $2)
|
||||
RETURNING id, token_hash, name, avatar_hash, created_at
|
||||
`
|
||||
|
||||
type CreateMemberParams struct {
|
||||
TokenHash []byte
|
||||
Name string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateMember(ctx context.Context, arg CreateMemberParams) (Member, error) {
|
||||
row := q.db.QueryRow(ctx, createMember, arg.TokenHash, arg.Name)
|
||||
var i Member
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TokenHash,
|
||||
&i.Name,
|
||||
&i.AvatarHash,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const memberByToken = `-- name: MemberByToken :one
|
||||
SELECT id, token_hash, name, avatar_hash, created_at FROM members WHERE token_hash = $1
|
||||
`
|
||||
|
||||
func (q *Queries) MemberByToken(ctx context.Context, tokenHash []byte) (Member, error) {
|
||||
row := q.db.QueryRow(ctx, memberByToken, tokenHash)
|
||||
var i Member
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TokenHash,
|
||||
&i.Name,
|
||||
&i.AvatarHash,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateMember = `-- name: UpdateMember :one
|
||||
UPDATE members
|
||||
SET name = coalesce($1, name),
|
||||
avatar_hash = CASE WHEN $2::text IS NULL THEN avatar_hash ELSE NULLIF($2::text, '') END
|
||||
WHERE id = $3
|
||||
RETURNING id, token_hash, name, avatar_hash, created_at
|
||||
`
|
||||
|
||||
type UpdateMemberParams struct {
|
||||
Name *string
|
||||
AvatarHash *string
|
||||
ID string
|
||||
}
|
||||
|
||||
// 只覆盖传入的列,分别修改昵称和头像的两个并发请求因此不会互相还原;头像哈希传入空串表示清除。
|
||||
func (q *Queries) UpdateMember(ctx context.Context, arg UpdateMemberParams) (Member, error) {
|
||||
row := q.db.QueryRow(ctx, updateMember, arg.Name, arg.AvatarHash, arg.ID)
|
||||
var i Member
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TokenHash,
|
||||
&i.Name,
|
||||
&i.AvatarHash,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package query
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Member struct {
|
||||
ID string
|
||||
TokenHash []byte
|
||||
Name string
|
||||
AvatarHash *string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Room struct {
|
||||
ID string
|
||||
Kind string
|
||||
Slug *string
|
||||
Name string
|
||||
Description string
|
||||
PasswordHash *string
|
||||
Term int64
|
||||
PlayMode string
|
||||
EntrySeq int64
|
||||
CreatedAt time.Time
|
||||
BotMemberID *string
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: rooms.sql
|
||||
|
||||
package query
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const createRoom = `-- name: CreateRoom :one
|
||||
INSERT INTO rooms (kind, slug, name, description, password_hash)
|
||||
VALUES ($1, NULLIF($2::text, ''), $3, $4, NULLIF($5::text, ''))
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
type CreateRoomParams struct {
|
||||
Kind string
|
||||
Slug string
|
||||
Name string
|
||||
Description string
|
||||
PasswordHash string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateRoom(ctx context.Context, arg CreateRoomParams) (string, error) {
|
||||
row := q.db.QueryRow(ctx, createRoom,
|
||||
arg.Kind,
|
||||
arg.Slug,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.PasswordHash,
|
||||
)
|
||||
var id string
|
||||
err := row.Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const deleteRoomTicketsExceptBot = `-- name: DeleteRoomTicketsExceptBot :exec
|
||||
DELETE FROM room_tickets
|
||||
WHERE room_id = $1 AND member_id IS DISTINCT FROM (SELECT bot_member_id FROM rooms WHERE id = $1)
|
||||
`
|
||||
|
||||
// 没有 bot 成员的房间 bot_member_id 为 NULL,写成 <> 就一张票据都不删。
|
||||
func (q *Queries) DeleteRoomTicketsExceptBot(ctx context.Context, roomID string) error {
|
||||
_, err := q.db.Exec(ctx, deleteRoomTicketsExceptBot, roomID)
|
||||
return err
|
||||
}
|
||||
|
||||
const listRooms = `-- name: ListRooms :many
|
||||
SELECT id, kind, slug, name, description, password_hash, term, play_mode, entry_seq, created_at, bot_member_id FROM rooms ORDER BY id
|
||||
`
|
||||
|
||||
func (q *Queries) ListRooms(ctx context.Context) ([]Room, error) {
|
||||
rows, err := q.db.Query(ctx, listRooms)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Room
|
||||
for rows.Next() {
|
||||
var i Room
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Kind,
|
||||
&i.Slug,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.PasswordHash,
|
||||
&i.Term,
|
||||
&i.PlayMode,
|
||||
&i.EntrySeq,
|
||||
&i.CreatedAt,
|
||||
&i.BotMemberID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const roomByID = `-- name: RoomByID :one
|
||||
SELECT id, kind, slug, name, description, password_hash, term, play_mode, entry_seq, created_at, bot_member_id FROM rooms WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) RoomByID(ctx context.Context, id string) (Room, error) {
|
||||
row := q.db.QueryRow(ctx, roomByID, id)
|
||||
var i Room
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Kind,
|
||||
&i.Slug,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.PasswordHash,
|
||||
&i.Term,
|
||||
&i.PlayMode,
|
||||
&i.EntrySeq,
|
||||
&i.CreatedAt,
|
||||
&i.BotMemberID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const roomBySlug = `-- name: RoomBySlug :one
|
||||
SELECT id, kind, slug, name, description, password_hash, term, play_mode, entry_seq, created_at, bot_member_id FROM rooms WHERE slug = $1::text
|
||||
`
|
||||
|
||||
func (q *Queries) RoomBySlug(ctx context.Context, slug string) (Room, error) {
|
||||
row := q.db.QueryRow(ctx, roomBySlug, slug)
|
||||
var i Room
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Kind,
|
||||
&i.Slug,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.PasswordHash,
|
||||
&i.Term,
|
||||
&i.PlayMode,
|
||||
&i.EntrySeq,
|
||||
&i.CreatedAt,
|
||||
&i.BotMemberID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const setRoomPassword = `-- name: SetRoomPassword :execrows
|
||||
UPDATE rooms SET password_hash = NULLIF($1::text, '') WHERE id = $2
|
||||
`
|
||||
|
||||
type SetRoomPasswordParams struct {
|
||||
PasswordHash string
|
||||
ID string
|
||||
}
|
||||
|
||||
func (q *Queries) SetRoomPassword(ctx context.Context, arg SetRoomPasswordParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, setRoomPassword, arg.PasswordHash, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const setRoomSlug = `-- name: SetRoomSlug :execrows
|
||||
UPDATE rooms SET slug = NULLIF($1::text, '') WHERE id = $2
|
||||
`
|
||||
|
||||
type SetRoomSlugParams struct {
|
||||
Slug string
|
||||
ID string
|
||||
}
|
||||
|
||||
func (q *Queries) SetRoomSlug(ctx context.Context, arg SetRoomSlugParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, setRoomSlug, arg.Slug, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: site.sql
|
||||
|
||||
package query
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const brandName = `-- name: BrandName :one
|
||||
SELECT brand_name FROM site
|
||||
`
|
||||
|
||||
func (q *Queries) BrandName(ctx context.Context) (string, error) {
|
||||
row := q.db.QueryRow(ctx, brandName)
|
||||
var brand_name string
|
||||
err := row.Scan(&brand_name)
|
||||
return brand_name, err
|
||||
}
|
||||
|
||||
const setBrandName = `-- name: SetBrandName :exec
|
||||
INSERT INTO site (brand_name) VALUES ($1)
|
||||
ON CONFLICT (id) DO UPDATE SET brand_name = excluded.brand_name
|
||||
`
|
||||
|
||||
func (q *Queries) SetBrandName(ctx context.Context, brandName string) error {
|
||||
_, err := q.db.Exec(ctx, setBrandName, brandName)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: snapshot.sql
|
||||
|
||||
package query
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
const deleteRoom = `-- name: DeleteRoom :execrows
|
||||
DELETE FROM rooms WHERE id = $1 AND term = $2
|
||||
`
|
||||
|
||||
type DeleteRoomParams struct {
|
||||
ID string
|
||||
Term int64
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteRoom(ctx context.Context, arg DeleteRoomParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, deleteRoom, arg.ID, arg.Term)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const deleteRoomEntries = `-- name: DeleteRoomEntries :exec
|
||||
DELETE FROM room_entries WHERE room_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteRoomEntries(ctx context.Context, roomID string) error {
|
||||
_, err := q.db.Exec(ctx, deleteRoomEntries, roomID)
|
||||
return err
|
||||
}
|
||||
|
||||
type InsertPlaysParams struct {
|
||||
RoomID string
|
||||
Source string
|
||||
TrackID string
|
||||
ByMemberID *string
|
||||
ByName *string
|
||||
ByAvatarHash *string
|
||||
Auto bool
|
||||
StartedAt time.Time
|
||||
EndedAt time.Time
|
||||
}
|
||||
|
||||
type InsertRoomEntriesParams struct {
|
||||
RoomID string
|
||||
EntryID int64
|
||||
Slot string
|
||||
Source string
|
||||
TrackID string
|
||||
Title string
|
||||
Artist *string
|
||||
Album *string
|
||||
CoverURL *string
|
||||
CoverKey *string
|
||||
PageURL string
|
||||
MetaDurationMs int64
|
||||
ByMemberID *string
|
||||
ByName *string
|
||||
ByAvatarHash *string
|
||||
Auto bool
|
||||
Likes int32
|
||||
Round int64
|
||||
AddedAt time.Time
|
||||
StartedAt *time.Time
|
||||
}
|
||||
|
||||
const markPlayed = `-- name: MarkPlayed :exec
|
||||
UPDATE tracks SET last_played_at = GREATEST(last_played_at, $1::timestamptz)
|
||||
WHERE source = $2 AND track_id = $3
|
||||
`
|
||||
|
||||
type MarkPlayedParams struct {
|
||||
StartedAt time.Time
|
||||
Source string
|
||||
TrackID string
|
||||
}
|
||||
|
||||
// 不同房间同时落盘时,先开播的那一次可能后写入。
|
||||
func (q *Queries) MarkPlayed(ctx context.Context, arg MarkPlayedParams) error {
|
||||
_, err := q.db.Exec(ctx, markPlayed, arg.StartedAt, arg.Source, arg.TrackID)
|
||||
return err
|
||||
}
|
||||
|
||||
const roomEntries = `-- name: RoomEntries :many
|
||||
SELECT e.entry_id, e.slot, e.source, e.track_id, e.title, e.artist, e.album, e.cover_url, e.cover_key, e.page_url, e.meta_duration_ms,
|
||||
e.by_member_id, e.by_name, e.by_avatar_hash, e.auto, e.likes, e.round, e.added_at, e.started_at,
|
||||
t.title AS track_title, t.artist AS track_artist, t.album AS track_album, t.cover_url AS track_cover_url,
|
||||
t.cover_key AS track_cover_key, t.page_url AS track_page_url, t.duration_ms, t.gain, t.lyric
|
||||
FROM room_entries e
|
||||
LEFT JOIN tracks t ON t.source = e.source AND t.track_id = e.track_id
|
||||
WHERE e.room_id = $1
|
||||
ORDER BY e.entry_id
|
||||
`
|
||||
|
||||
type RoomEntriesRow struct {
|
||||
EntryID int64
|
||||
Slot string
|
||||
Source string
|
||||
TrackID string
|
||||
Title string
|
||||
Artist *string
|
||||
Album *string
|
||||
CoverURL *string
|
||||
CoverKey *string
|
||||
PageURL string
|
||||
MetaDurationMs int64
|
||||
ByMemberID *string
|
||||
ByName *string
|
||||
ByAvatarHash *string
|
||||
Auto bool
|
||||
Likes int32
|
||||
Round int64
|
||||
AddedAt time.Time
|
||||
StartedAt *time.Time
|
||||
TrackTitle *string
|
||||
TrackArtist *string
|
||||
TrackAlbum *string
|
||||
TrackCoverURL *string
|
||||
TrackCoverKey *string
|
||||
TrackPageURL *string
|
||||
DurationMs *int64
|
||||
Gain *float64
|
||||
Lyric *string
|
||||
}
|
||||
|
||||
func (q *Queries) RoomEntries(ctx context.Context, roomID string) ([]RoomEntriesRow, error) {
|
||||
rows, err := q.db.Query(ctx, roomEntries, roomID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []RoomEntriesRow
|
||||
for rows.Next() {
|
||||
var i RoomEntriesRow
|
||||
if err := rows.Scan(
|
||||
&i.EntryID,
|
||||
&i.Slot,
|
||||
&i.Source,
|
||||
&i.TrackID,
|
||||
&i.Title,
|
||||
&i.Artist,
|
||||
&i.Album,
|
||||
&i.CoverURL,
|
||||
&i.CoverKey,
|
||||
&i.PageURL,
|
||||
&i.MetaDurationMs,
|
||||
&i.ByMemberID,
|
||||
&i.ByName,
|
||||
&i.ByAvatarHash,
|
||||
&i.Auto,
|
||||
&i.Likes,
|
||||
&i.Round,
|
||||
&i.AddedAt,
|
||||
&i.StartedAt,
|
||||
&i.TrackTitle,
|
||||
&i.TrackArtist,
|
||||
&i.TrackAlbum,
|
||||
&i.TrackCoverURL,
|
||||
&i.TrackCoverKey,
|
||||
&i.TrackPageURL,
|
||||
&i.DurationMs,
|
||||
&i.Gain,
|
||||
&i.Lyric,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const saveRoomState = `-- name: SaveRoomState :execrows
|
||||
UPDATE rooms SET name = $1, description = $2, play_mode = $3, entry_seq = $4
|
||||
WHERE id = $5 AND term = $6
|
||||
`
|
||||
|
||||
type SaveRoomStateParams struct {
|
||||
Name string
|
||||
Description string
|
||||
PlayMode string
|
||||
EntrySeq int64
|
||||
ID string
|
||||
Term int64
|
||||
}
|
||||
|
||||
func (q *Queries) SaveRoomState(ctx context.Context, arg SaveRoomStateParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, saveRoomState,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.PlayMode,
|
||||
arg.EntrySeq,
|
||||
arg.ID,
|
||||
arg.Term,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const takeRoom = `-- name: TakeRoom :one
|
||||
UPDATE rooms SET term = term + 1 WHERE id = $1 RETURNING id, kind, slug, name, description, password_hash, term, play_mode, entry_seq, created_at, bot_member_id
|
||||
`
|
||||
|
||||
func (q *Queries) TakeRoom(ctx context.Context, id string) (Room, error) {
|
||||
row := q.db.QueryRow(ctx, takeRoom, id)
|
||||
var i Room
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Kind,
|
||||
&i.Slug,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.PasswordHash,
|
||||
&i.Term,
|
||||
&i.PlayMode,
|
||||
&i.EntrySeq,
|
||||
&i.CreatedAt,
|
||||
&i.BotMemberID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: tickets.sql
|
||||
|
||||
package query
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const createTicket = `-- name: CreateTicket :exec
|
||||
INSERT INTO room_tickets (token_hash, room_id, member_id) VALUES ($1, $2, $3)
|
||||
`
|
||||
|
||||
type CreateTicketParams struct {
|
||||
TokenHash []byte
|
||||
RoomID string
|
||||
MemberID string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateTicket(ctx context.Context, arg CreateTicketParams) error {
|
||||
_, err := q.db.Exec(ctx, createTicket, arg.TokenHash, arg.RoomID, arg.MemberID)
|
||||
return err
|
||||
}
|
||||
|
||||
const lockRoomPassword = `-- name: LockRoomPassword :one
|
||||
SELECT coalesce(password_hash, '')::text AS password_hash FROM rooms WHERE id = $1 FOR SHARE
|
||||
`
|
||||
|
||||
// FOR SHARE 让修改密码和删除房间等到这次签发提交之后,它们随后删除票据时会删掉这一张。
|
||||
func (q *Queries) LockRoomPassword(ctx context.Context, id string) (string, error) {
|
||||
row := q.db.QueryRow(ctx, lockRoomPassword, id)
|
||||
var password_hash string
|
||||
err := row.Scan(&password_hash)
|
||||
return password_hash, err
|
||||
}
|
||||
|
||||
const ticketHolder = `-- name: TicketHolder :one
|
||||
SELECT t.room_id, m.id, m.name, m.avatar_hash, r.bot_member_id IS NOT DISTINCT FROM m.id AS bot
|
||||
FROM room_tickets t
|
||||
JOIN members m ON m.id = t.member_id
|
||||
JOIN rooms r ON r.id = t.room_id
|
||||
WHERE t.token_hash = $1
|
||||
`
|
||||
|
||||
type TicketHolderRow struct {
|
||||
RoomID string
|
||||
ID string
|
||||
Name string
|
||||
AvatarHash *string
|
||||
Bot bool
|
||||
}
|
||||
|
||||
func (q *Queries) TicketHolder(ctx context.Context, tokenHash []byte) (TicketHolderRow, error) {
|
||||
row := q.db.QueryRow(ctx, ticketHolder, tokenHash)
|
||||
var i TicketHolderRow
|
||||
err := row.Scan(
|
||||
&i.RoomID,
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.AvatarHash,
|
||||
&i.Bot,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: tracks.sql
|
||||
|
||||
package query
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const preparedTrack = `-- name: PreparedTrack :one
|
||||
SELECT title, artist, album, cover_url, cover_key, page_url, duration_ms, gain, lyric, media_key, media_size FROM tracks
|
||||
WHERE source = $1 AND track_id = $2
|
||||
`
|
||||
|
||||
type PreparedTrackParams struct {
|
||||
Source string
|
||||
TrackID string
|
||||
}
|
||||
|
||||
type PreparedTrackRow struct {
|
||||
Title string
|
||||
Artist *string
|
||||
Album *string
|
||||
CoverURL *string
|
||||
CoverKey *string
|
||||
PageURL string
|
||||
DurationMs int64
|
||||
Gain float64
|
||||
Lyric *string
|
||||
MediaKey *string
|
||||
MediaSize *int64
|
||||
}
|
||||
|
||||
func (q *Queries) PreparedTrack(ctx context.Context, arg PreparedTrackParams) (PreparedTrackRow, error) {
|
||||
row := q.db.QueryRow(ctx, preparedTrack, arg.Source, arg.TrackID)
|
||||
var i PreparedTrackRow
|
||||
err := row.Scan(
|
||||
&i.Title,
|
||||
&i.Artist,
|
||||
&i.Album,
|
||||
&i.CoverURL,
|
||||
&i.CoverKey,
|
||||
&i.PageURL,
|
||||
&i.DurationMs,
|
||||
&i.Gain,
|
||||
&i.Lyric,
|
||||
&i.MediaKey,
|
||||
&i.MediaSize,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const savePreparedTrack = `-- name: SavePreparedTrack :exec
|
||||
INSERT INTO tracks (source, track_id, title, artist, album, cover_url, cover_key, page_url, duration_ms, gain, lyric, media_key, media_size)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
ON CONFLICT (source, track_id) DO UPDATE SET
|
||||
title = excluded.title, artist = excluded.artist, album = excluded.album, cover_url = excluded.cover_url,
|
||||
cover_key = excluded.cover_key, page_url = excluded.page_url, duration_ms = excluded.duration_ms, gain = excluded.gain,
|
||||
lyric = excluded.lyric, media_key = excluded.media_key, media_size = excluded.media_size,
|
||||
prepared_at = now()
|
||||
`
|
||||
|
||||
type SavePreparedTrackParams struct {
|
||||
Source string
|
||||
TrackID string
|
||||
Title string
|
||||
Artist *string
|
||||
Album *string
|
||||
CoverURL *string
|
||||
CoverKey *string
|
||||
PageURL string
|
||||
DurationMs int64
|
||||
Gain float64
|
||||
Lyric *string
|
||||
MediaKey *string
|
||||
MediaSize *int64
|
||||
}
|
||||
|
||||
// 准备任务只对未完成的任务去重,同一首歌可能先后准备两次,以后一次的结果为准。
|
||||
func (q *Queries) SavePreparedTrack(ctx context.Context, arg SavePreparedTrackParams) error {
|
||||
_, err := q.db.Exec(ctx, savePreparedTrack,
|
||||
arg.Source,
|
||||
arg.TrackID,
|
||||
arg.Title,
|
||||
arg.Artist,
|
||||
arg.Album,
|
||||
arg.CoverURL,
|
||||
arg.CoverKey,
|
||||
arg.PageURL,
|
||||
arg.DurationMs,
|
||||
arg.Gain,
|
||||
arg.Lyric,
|
||||
arg.MediaKey,
|
||||
arg.MediaSize,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
var _ port.MediaLibrary = (*Store)(nil)
|
||||
|
||||
func (s *Store) ListStoredMedia(ctx context.Context) ([]port.StoredMedia, error) {
|
||||
rows, err := s.q.ListStoredMedia(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取已存储的媒体:%w", err)
|
||||
}
|
||||
media := make([]port.StoredMedia, len(rows))
|
||||
for i, r := range rows {
|
||||
media[i] = storedMedia(query.StoredMediaRow(r))
|
||||
}
|
||||
return media, nil
|
||||
}
|
||||
|
||||
func (s *Store) StoredMedia(ctx context.Context, key string) (port.StoredMedia, bool, error) {
|
||||
row, err := s.q.StoredMedia(ctx, key)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return port.StoredMedia{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return port.StoredMedia{}, false, fmt.Errorf("读取已存储的媒体 %s:%w", key, err)
|
||||
}
|
||||
return storedMedia(row), true, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteStoredMedia(ctx context.Context, key string) error {
|
||||
if err := s.q.DeleteStoredMedia(ctx, key); err != nil {
|
||||
return fmt.Errorf("清除已存储的媒体 %s 的准备结果:%w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func storedMedia(r query.StoredMediaRow) port.StoredMedia {
|
||||
return port.StoredMedia{
|
||||
Key: r.MediaKey,
|
||||
Source: track.Source(r.Source),
|
||||
Link: r.TrackID,
|
||||
Title: r.Title,
|
||||
Size: r.MediaSize,
|
||||
CoverKey: track.ObjectKey(orEmpty(r.CoverKey)),
|
||||
StoredAt: r.PreparedAt.UTC(),
|
||||
LastPlayedAt: utc(r.LastPlayedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func utc(t *time.Time) *time.Time {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
u := t.UTC()
|
||||
return &u
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/member"
|
||||
)
|
||||
|
||||
var _ port.Members = (*Store)(nil)
|
||||
|
||||
func (s *Store) CreateMember(ctx context.Context, tokenHash []byte, name string) (member.Member, error) {
|
||||
row, err := s.q.CreateMember(ctx, query.CreateMemberParams{TokenHash: tokenHash, Name: name})
|
||||
if err != nil {
|
||||
return member.Member{}, fmt.Errorf("创建成员:%w", err)
|
||||
}
|
||||
return memberOf(row), nil
|
||||
}
|
||||
|
||||
func (s *Store) MemberByToken(ctx context.Context, tokenHash []byte) (member.Member, error) {
|
||||
row, err := s.q.MemberByToken(ctx, tokenHash)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
err = port.ErrMemberNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return member.Member{}, fmt.Errorf("按 token 查找成员:%w", err)
|
||||
}
|
||||
return memberOf(row), nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateMember(ctx context.Context, id string, name, avatarHash *string) (member.Member, error) {
|
||||
row, err := s.q.UpdateMember(ctx, query.UpdateMemberParams{ID: id, Name: name, AvatarHash: avatarHash})
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
err = port.ErrMemberNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return member.Member{}, fmt.Errorf("修改成员 %s:%w", id, err)
|
||||
}
|
||||
return memberOf(row), nil
|
||||
}
|
||||
|
||||
func memberOf(row query.Member) member.Member {
|
||||
return member.Member{ID: row.ID, Name: row.Name, AvatarHash: orEmpty(row.AvatarHash)}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/pressly/goose/v3"
|
||||
"github.com/riverqueue/river/riverdriver/riverpgxv5"
|
||||
"github.com/riverqueue/river/rivermigrate"
|
||||
|
||||
"github.com/dihemoe/alisten/db/migrations"
|
||||
)
|
||||
|
||||
const migrationLock int64 = 0x616c697374656e
|
||||
|
||||
// 锁所在的事务一直占用一个连接,fn 中的迁移要使用连接池中的其他连接。
|
||||
func WithMigrationLock(ctx context.Context, pool *pgxpool.Pool, fn func(context.Context) error) error {
|
||||
return pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error {
|
||||
// 这个事务在整个迁移期间都处于空闲状态,数据库设置的空闲事务超时或事务超时会断开它,锁随之失效。
|
||||
// 后到的迁移要一直等待锁,而数据库设置的 lock_timeout、statement_timeout 和 transaction_timeout 会让它报错而不是等待。
|
||||
if _, err := tx.Exec(ctx, `SELECT set_config('idle_in_transaction_session_timeout', '0', true),
|
||||
set_config('lock_timeout', '0', true), set_config('statement_timeout', '0', true),
|
||||
set_config('transaction_timeout', '0', true)`); err != nil {
|
||||
return fmt.Errorf("关闭迁移锁所在事务的超时:%w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, "SELECT pg_advisory_xact_lock($1)", migrationLock); err != nil {
|
||||
return fmt.Errorf("获取迁移锁:%w", err)
|
||||
}
|
||||
return fn(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
func MigrateSchema(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) error {
|
||||
db := stdlib.OpenDBFromPool(pool)
|
||||
defer func() { _ = db.Close() }()
|
||||
provider, err := goose.NewProvider(goose.DialectPostgres, db, migrations.FS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("加载数据库迁移:%w", err)
|
||||
}
|
||||
results, err := provider.Up(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("执行数据库迁移:%w", err)
|
||||
}
|
||||
for _, r := range results {
|
||||
logger.InfoContext(ctx, "已执行数据库迁移", "migration", r.Source.Version, "file", r.Source.Path, "duration", r.Duration)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func MigrateRiver(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) error {
|
||||
// rivermigrate 的日志带有 version 字段,而 slog 会把重名的字段写两次:放进 river 分组,它才不会与日志的固定字段 version 重名。
|
||||
migrator, err := rivermigrate.New(riverpgxv5.New(pool), &rivermigrate.Config{Logger: logger.WithGroup("river")})
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 River 迁移器:%w", err)
|
||||
}
|
||||
if _, err := migrator.Migrate(ctx, rivermigrate.DirectionUp, nil); err != nil {
|
||||
return fmt.Errorf("执行 River 迁移:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/pressly/goose/v3"
|
||||
"github.com/pressly/goose/v3/database"
|
||||
"github.com/riverqueue/river/riverdriver/riverpgxv5"
|
||||
"github.com/riverqueue/river/rivermigrate"
|
||||
|
||||
"github.com/dihemoe/alisten/db/migrations"
|
||||
)
|
||||
|
||||
// 只读取版本表:goose 的 HasPending 在缺少版本表时会建表,就绪检查不能修改数据库。
|
||||
func CheckMigrations(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
db := stdlib.OpenDBFromPool(pool)
|
||||
defer func() { _ = db.Close() }()
|
||||
provider, err := goose.NewProvider(goose.DialectPostgres, db, migrations.FS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("加载数据库迁移:%w", err)
|
||||
}
|
||||
sources := provider.ListSources()
|
||||
required := sources[len(sources)-1].Version
|
||||
store, err := database.NewStore(database.DialectPostgres, goose.DefaultTablename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 goose 版本表的访问:%w", err)
|
||||
}
|
||||
current, err := store.GetLatestVersion(ctx, db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取数据库迁移的版本:%w", err)
|
||||
}
|
||||
if current < required {
|
||||
return fmt.Errorf("数据库迁移的版本为 %d,低于要求的 %d", current, required)
|
||||
}
|
||||
|
||||
// rivermigrate 每次校验不通过都会记一条 info 日志,而就绪检查每 5 秒执行一次。
|
||||
migrator, err := rivermigrate.New(riverpgxv5.New(pool), &rivermigrate.Config{Logger: slog.New(slog.DiscardHandler)})
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 River 迁移器:%w", err)
|
||||
}
|
||||
result, err := migrator.Validate(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查 River 迁移:%w", err)
|
||||
}
|
||||
if !result.OK {
|
||||
return fmt.Errorf("尚未完成 River 迁移:%s", strings.Join(result.Messages, ";"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/room"
|
||||
)
|
||||
|
||||
var _ port.RoomDirectory = (*Store)(nil)
|
||||
|
||||
func (s *Store) CreateRoom(ctx context.Context, r port.NewRoom) (string, error) {
|
||||
id, err := s.q.CreateRoom(ctx, query.CreateRoomParams{
|
||||
Kind: kindName(r.Kind),
|
||||
Slug: r.Slug,
|
||||
Name: r.Name,
|
||||
Description: r.Description,
|
||||
PasswordHash: r.PasswordHash,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建房间:%w", slugError(err))
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListRooms(ctx context.Context) ([]port.RoomRecord, error) {
|
||||
rows, err := s.q.ListRooms(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("列出房间:%w", err)
|
||||
}
|
||||
records := make([]port.RoomRecord, len(rows))
|
||||
for i, row := range rows {
|
||||
records[i] = roomRecord(row)
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (s *Store) FindRoom(ctx context.Context, ref string) (port.RoomRecord, error) {
|
||||
var row query.Room
|
||||
var err error
|
||||
// 别名最长 32 个字符,不会是 UUID 的形式。
|
||||
if isUUID(ref) {
|
||||
row, err = s.q.RoomByID(ctx, ref)
|
||||
} else {
|
||||
row, err = s.q.RoomBySlug(ctx, ref)
|
||||
}
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
err = port.ErrRoomNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return port.RoomRecord{}, fmt.Errorf("查找房间 %s:%w", ref, err)
|
||||
}
|
||||
return roomRecord(row), nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateRoomAccess(ctx context.Context, id string, c port.AccessChange) error {
|
||||
if !isUUID(id) {
|
||||
return fmt.Errorf("修改房间 %s:%w", id, port.ErrRoomNotFound)
|
||||
}
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
q := s.q.WithTx(tx)
|
||||
if c.Slug != nil {
|
||||
if err := affected(q.SetRoomSlug(ctx, query.SetRoomSlugParams{ID: id, Slug: *c.Slug})); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.PasswordHash == nil {
|
||||
return nil
|
||||
}
|
||||
if err := affected(q.SetRoomPassword(ctx, query.SetRoomPasswordParams{ID: id, PasswordHash: *c.PasswordHash})); err != nil {
|
||||
return err
|
||||
}
|
||||
// 取消密码时,已经签发的票据保持有效。
|
||||
if *c.PasswordHash == "" {
|
||||
return nil
|
||||
}
|
||||
return q.DeleteRoomTicketsExceptBot(ctx, id)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("修改房间 %s:%w", id, slugError(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func affected(rows int64, err error) error {
|
||||
if err == nil && rows == 0 {
|
||||
return port.ErrRoomNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
const uniqueViolation = "23505"
|
||||
|
||||
func slugError(err error) error {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == uniqueViolation && pgErr.ConstraintName == "rooms_slug_key" {
|
||||
return port.ErrSlugTaken
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func roomRecord(row query.Room) port.RoomRecord {
|
||||
return port.RoomRecord{Info: roomInfo(row), Slug: orEmpty(row.Slug), PasswordHash: orEmpty(row.PasswordHash)}
|
||||
}
|
||||
|
||||
func roomInfo(row query.Room) room.Info {
|
||||
return room.Info{ID: row.ID, Name: row.Name, Description: row.Description, Kind: kindOf(row.Kind)}
|
||||
}
|
||||
|
||||
func kindName(k room.Kind) string {
|
||||
if k == room.Persistent {
|
||||
return "persistent"
|
||||
}
|
||||
return "ephemeral"
|
||||
}
|
||||
|
||||
func kindOf(name string) room.Kind {
|
||||
if name == "persistent" {
|
||||
return room.Persistent
|
||||
}
|
||||
return room.Ephemeral
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
)
|
||||
|
||||
var _ port.Site = (*Store)(nil)
|
||||
|
||||
func (s *Store) BrandName(ctx context.Context) (string, error) {
|
||||
name, err := s.q.BrandName(ctx)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取品牌名:%w", err)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetBrandName(ctx context.Context, name string) error {
|
||||
if err := s.q.SetBrandName(ctx, name); err != nil {
|
||||
return fmt.Errorf("保存品牌名:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/member"
|
||||
"github.com/dihemoe/alisten/internal/domain/room"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
var _ port.RoomSnapshots = (*Store)(nil)
|
||||
|
||||
const (
|
||||
slotWaiting = "waiting"
|
||||
slotPending = "pending"
|
||||
slotCurrent = "current"
|
||||
)
|
||||
|
||||
func (s *Store) TakeRoom(ctx context.Context, id string) (uint32, room.Snapshot, error) {
|
||||
if !isUUID(id) {
|
||||
return 0, room.Snapshot{}, fmt.Errorf("接管房间 %s:%w", id, port.ErrRoomNotFound)
|
||||
}
|
||||
var term uint32
|
||||
var snap room.Snapshot
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
q := s.q.WithTx(tx)
|
||||
row, err := q.TakeRoom(ctx, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return port.ErrRoomNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := q.RoomEntries(ctx, row.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
term, snap = uint32(row.Term), snapshotOf(row, entries)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, room.Snapshot{}, fmt.Errorf("接管房间 %s:%w", id, err)
|
||||
}
|
||||
return term, snap, nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveRoom(ctx context.Context, term uint32, snap room.Snapshot, plays []room.RecordPlay) error {
|
||||
id := snap.Info.ID
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
q := s.q.WithTx(tx)
|
||||
updated, err := q.SaveRoomState(ctx, query.SaveRoomStateParams{
|
||||
ID: id,
|
||||
Term: int64(term),
|
||||
Name: snap.Info.Name,
|
||||
Description: snap.Info.Description,
|
||||
PlayMode: modeName(snap.Mode),
|
||||
EntrySeq: int64(snap.EntrySeq),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if updated == 0 {
|
||||
return port.ErrTermExpired
|
||||
}
|
||||
if err := q.DeleteRoomEntries(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := q.InsertRoomEntries(ctx, entryRows(id, snap)); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(plays) == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := q.InsertPlays(ctx, playRows(id, plays)); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, p := range plays {
|
||||
err := q.MarkPlayed(ctx, query.MarkPlayedParams{
|
||||
Source: string(p.Entry.Track.Source),
|
||||
TrackID: p.Entry.Track.ID,
|
||||
StartedAt: p.StartedAt,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("落盘房间 %s:%w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteRoom(ctx context.Context, id string, term uint32) error {
|
||||
deleted, err := s.q.DeleteRoom(ctx, query.DeleteRoomParams{ID: id, Term: int64(term)})
|
||||
if err == nil && deleted == 0 {
|
||||
err = port.ErrTermExpired
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除房间 %s:%w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func snapshotOf(row query.Room, entries []query.RoomEntriesRow) room.Snapshot {
|
||||
s := room.Snapshot{
|
||||
Info: roomInfo(row),
|
||||
Mode: modeOf(row.PlayMode),
|
||||
EntrySeq: room.EntryID(row.EntrySeq),
|
||||
}
|
||||
for _, r := range entries {
|
||||
e := entryOf(r)
|
||||
switch r.Slot {
|
||||
case slotCurrent:
|
||||
s.Current = &room.NowPlaying{Entry: e, StartedAt: r.StartedAt.UTC()}
|
||||
case slotPending:
|
||||
s.Pending = &e
|
||||
default:
|
||||
s.Waiting = append(s.Waiting, e)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func entryOf(r query.RoomEntriesRow) room.Entry {
|
||||
e := room.Entry{
|
||||
ID: room.EntryID(r.EntryID),
|
||||
Track: track.Track{Source: track.Source(r.Source), ID: r.TrackID},
|
||||
Meta: track.Meta{
|
||||
Title: r.Title,
|
||||
Artist: orEmpty(r.Artist),
|
||||
Album: orEmpty(r.Album),
|
||||
CoverURL: orEmpty(r.CoverURL),
|
||||
CoverKey: track.ObjectKey(orEmpty(r.CoverKey)),
|
||||
PageURL: r.PageURL,
|
||||
Duration: time.Duration(r.MetaDurationMs) * time.Millisecond,
|
||||
},
|
||||
By: member.Member{ID: orEmpty(r.ByMemberID), Name: orEmpty(r.ByName), AvatarHash: orEmpty(r.ByAvatarHash)},
|
||||
Auto: r.Auto,
|
||||
Likes: int(r.Likes),
|
||||
Round: int(r.Round),
|
||||
AddedAt: r.AddedAt.UTC(),
|
||||
}
|
||||
// 曲目表只保存准备成功的结果,有对应的行就是已就绪。元数据也取自曲目表:在房间没有 owner 时准备好的歌,
|
||||
// 条目中仍是点歌时的元数据,比如链接曲目的标题仍是链接本身。
|
||||
if r.DurationMs != nil {
|
||||
e.Ready = true
|
||||
e.Meta, e.Prep = prepared(*r.TrackTitle, orEmpty(r.TrackArtist), orEmpty(r.TrackAlbum), orEmpty(r.TrackCoverURL), orEmpty(r.TrackCoverKey), *r.TrackPageURL, *r.DurationMs, *r.Gain, orEmpty(r.Lyric))
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func entryRows(roomID string, s room.Snapshot) []query.InsertRoomEntriesParams {
|
||||
rows := make([]query.InsertRoomEntriesParams, 0, len(s.Waiting)+2)
|
||||
if s.Current != nil {
|
||||
rows = append(rows, entryRow(roomID, slotCurrent, s.Current.Entry, &s.Current.StartedAt))
|
||||
}
|
||||
if s.Pending != nil {
|
||||
rows = append(rows, entryRow(roomID, slotPending, *s.Pending, nil))
|
||||
}
|
||||
for _, e := range s.Waiting {
|
||||
rows = append(rows, entryRow(roomID, slotWaiting, e, nil))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func entryRow(roomID, slot string, e room.Entry, startedAt *time.Time) query.InsertRoomEntriesParams {
|
||||
return query.InsertRoomEntriesParams{
|
||||
RoomID: roomID,
|
||||
EntryID: int64(e.ID),
|
||||
Slot: slot,
|
||||
Source: string(e.Track.Source),
|
||||
TrackID: e.Track.ID,
|
||||
Title: e.Meta.Title,
|
||||
Artist: nullable(e.Meta.Artist),
|
||||
Album: nullable(e.Meta.Album),
|
||||
CoverURL: nullable(e.Meta.CoverURL),
|
||||
CoverKey: nullable(string(e.Meta.CoverKey)),
|
||||
PageURL: e.Meta.PageURL,
|
||||
MetaDurationMs: e.Meta.Duration.Milliseconds(),
|
||||
ByMemberID: memberID(e.By),
|
||||
ByName: nullable(e.By.Name),
|
||||
ByAvatarHash: nullable(e.By.AvatarHash),
|
||||
Auto: e.Auto,
|
||||
Likes: int32(e.Likes),
|
||||
Round: int64(e.Round),
|
||||
AddedAt: e.AddedAt,
|
||||
StartedAt: startedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func playRows(roomID string, plays []room.RecordPlay) []query.InsertPlaysParams {
|
||||
rows := make([]query.InsertPlaysParams, len(plays))
|
||||
for i, p := range plays {
|
||||
rows[i] = query.InsertPlaysParams{
|
||||
RoomID: roomID,
|
||||
Source: string(p.Entry.Track.Source),
|
||||
TrackID: p.Entry.Track.ID,
|
||||
ByMemberID: memberID(p.Entry.By),
|
||||
ByName: nullable(p.Entry.By.Name),
|
||||
ByAvatarHash: nullable(p.Entry.By.AvatarHash),
|
||||
Auto: p.Entry.Auto,
|
||||
StartedAt: p.StartedAt,
|
||||
EndedAt: p.EndedAt,
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func memberID(m member.Member) *string {
|
||||
if m.ID == "" {
|
||||
return nil
|
||||
}
|
||||
return &m.ID
|
||||
}
|
||||
|
||||
func modeName(m room.PlayMode) string {
|
||||
switch m {
|
||||
case room.Random:
|
||||
return "random"
|
||||
case room.Rotation:
|
||||
return "rotation"
|
||||
}
|
||||
return "sequential"
|
||||
}
|
||||
|
||||
func modeOf(name string) room.PlayMode {
|
||||
switch name {
|
||||
case "random":
|
||||
return room.Random
|
||||
case "rotation":
|
||||
return room.Rotation
|
||||
}
|
||||
return room.Sequential
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
q *query.Queries
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool) *Store {
|
||||
return &Store{pool: pool, q: query.New(pool)}
|
||||
}
|
||||
|
||||
var uuidPattern = regexp.MustCompile(`^(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
|
||||
|
||||
// 房间 ID 可能来自客户端:格式不正确的 uuid 参数会让 PostgreSQL 报错,而这样的房间一定不存在。
|
||||
func isUUID(s string) bool {
|
||||
return uuidPattern.MatchString(s)
|
||||
}
|
||||
|
||||
func nullable(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func orEmpty(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/member"
|
||||
)
|
||||
|
||||
var _ port.Tickets = (*Store)(nil)
|
||||
|
||||
func (s *Store) CreateTicket(ctx context.Context, t port.NewTicket) error {
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
q := s.q.WithTx(tx)
|
||||
hash, err := q.LockRoomPassword(ctx, t.RoomID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return port.ErrRoomNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hash != t.PasswordHash {
|
||||
return port.ErrPasswordChanged
|
||||
}
|
||||
return q.CreateTicket(ctx, query.CreateTicketParams{TokenHash: t.TokenHash, RoomID: t.RoomID, MemberID: t.MemberID})
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("签发房间 %s 的票据:%w", t.RoomID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) TicketHolder(ctx context.Context, tokenHash []byte) (port.TicketHolder, error) {
|
||||
row, err := s.q.TicketHolder(ctx, tokenHash)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
err = port.ErrTicketNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return port.TicketHolder{}, fmt.Errorf("查找票据:%w", err)
|
||||
}
|
||||
return port.TicketHolder{
|
||||
RoomID: row.RoomID,
|
||||
Member: member.Member{ID: row.ID, Name: row.Name, AvatarHash: orEmpty(row.AvatarHash)},
|
||||
Bot: row.Bot,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
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})
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return port.PreparedTrack{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return port.PreparedTrack{}, false, fmt.Errorf("读取曲目 %s %s 的准备结果:%w", t.Source, t.ID, err)
|
||||
}
|
||||
meta, result := prepared(row.Title, orEmpty(row.Artist), orEmpty(row.Album), orEmpty(row.CoverURL), orEmpty(row.CoverKey), row.PageURL, row.DurationMs, row.Gain, orEmpty(row.Lyric))
|
||||
return port.PreparedTrack{Meta: meta, Result: result, MediaKey: orEmpty(row.MediaKey), MediaSize: orZero(row.MediaSize)}, true, nil
|
||||
}
|
||||
|
||||
func (s *Store) SavePreparedTrack(ctx context.Context, t track.Track, p port.PreparedTrack) error {
|
||||
params := query.SavePreparedTrackParams{
|
||||
Source: string(t.Source),
|
||||
TrackID: t.ID,
|
||||
Title: p.Meta.Title,
|
||||
Artist: nullable(p.Meta.Artist),
|
||||
Album: nullable(p.Meta.Album),
|
||||
CoverURL: nullable(p.Meta.CoverURL),
|
||||
CoverKey: nullable(string(p.Meta.CoverKey)),
|
||||
PageURL: p.Meta.PageURL,
|
||||
DurationMs: p.Result.Duration.Milliseconds(),
|
||||
Gain: p.Result.Gain,
|
||||
Lyric: nullable(p.Result.Lyric),
|
||||
}
|
||||
if p.MediaKey != "" {
|
||||
params.MediaKey, params.MediaSize = &p.MediaKey, &p.MediaSize
|
||||
}
|
||||
if err := s.q.SavePreparedTrack(ctx, params); err != nil {
|
||||
return fmt.Errorf("保存曲目 %s %s 的准备结果:%w", t.Source, t.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func prepared(title, artist, album, coverURL, coverKey, pageURL string, durationMs int64, gain float64, lyric string) (track.Meta, track.Prepared) {
|
||||
d := time.Duration(durationMs) * time.Millisecond
|
||||
return track.Meta{Title: title, Artist: artist, Album: album, CoverURL: coverURL, CoverKey: track.ObjectKey(coverKey), PageURL: pageURL, Duration: d},
|
||||
track.Prepared{Duration: d, Gain: gain, Lyric: lyric}
|
||||
}
|
||||
|
||||
func orZero(p *int64) int64 {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package qq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
)
|
||||
|
||||
func (c *Client) CurrentAccount(ctx context.Context) (*port.SourceAccount, error) {
|
||||
cookie, err := c.cookie.get(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id := qqAccountID(cookie)
|
||||
if id == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return &port.SourceAccount{ID: id}, nil
|
||||
}
|
||||
|
||||
func qqAccountID(cookie string) string {
|
||||
for _, name := range []string{"wxuin", "uin", "ptui_loginuin", "luin", "pt2gguin", "superuin", "p_uin", "musicid", "userid", "user_id"} {
|
||||
value := qqAccountCookieValue(cookie, name)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimLeft(strings.TrimPrefix(value, "o"), "0"))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func qqAccountCookieValue(cookie, name string) string {
|
||||
for part := range strings.SplitSeq(cookie, ";") {
|
||||
key, value, ok := strings.Cut(part, "=")
|
||||
if ok && strings.TrimSpace(key) == name {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package qq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/golang-lru/v2/expirable"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/lib/chinaip"
|
||||
)
|
||||
|
||||
const (
|
||||
requestTimeout = 30 * time.Second
|
||||
downloadTimeout = 10 * time.Minute
|
||||
cacheSize = 512
|
||||
cacheTTL = 30 * time.Minute
|
||||
cookieTTL = 30 * time.Second
|
||||
cookieLoadTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
http *http.Client
|
||||
api *http.Client
|
||||
cookie cookieCache
|
||||
cache *expirable.LRU[string, any]
|
||||
observe func(source, op string, elapsed time.Duration, err error)
|
||||
}
|
||||
|
||||
func New(httpClient *http.Client, loadCookie func(context.Context) (string, error), saveCookie func(context.Context, string) error, observe func(source, op string, elapsed time.Duration, err error)) *Client {
|
||||
api := *httpClient
|
||||
api.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
return &Client{
|
||||
http: httpClient,
|
||||
api: &api,
|
||||
cookie: cookieCache{load: loadCookie, store: saveCookie},
|
||||
cache: expirable.NewLRU[string, any](cacheSize, nil, cacheTTL),
|
||||
observe: observe,
|
||||
}
|
||||
}
|
||||
|
||||
type cookieCache struct {
|
||||
load func(context.Context) (string, error)
|
||||
store func(context.Context, string) error
|
||||
mu sync.Mutex
|
||||
value string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
func (c *cookieCache) get(ctx context.Context) (string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if time.Now().Before(c.expires) {
|
||||
return c.value, nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, cookieLoadTimeout)
|
||||
defer cancel()
|
||||
value, err := c.load(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
c.value, c.expires = value, time.Now().Add(cookieTTL)
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (c *cookieCache) put(ctx context.Context, value string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
ctx, cancel := context.WithTimeout(ctx, cookieLoadTimeout)
|
||||
defer cancel()
|
||||
if err := c.store(ctx, value); err != nil {
|
||||
return err
|
||||
}
|
||||
c.value, c.expires = value, time.Now().Add(cookieTTL)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cached[T any](c *Client, key string, fetch func() (T, error)) (T, error) {
|
||||
if v, ok := c.cache.Get(key); ok {
|
||||
return v.(T), nil
|
||||
}
|
||||
v, err := fetch()
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
c.cache.Add(key, v)
|
||||
return v, nil
|
||||
}
|
||||
|
||||
type response interface{ check() error }
|
||||
|
||||
type status struct {
|
||||
Code int `json:"code"`
|
||||
}
|
||||
|
||||
func (s status) check() error {
|
||||
if s.Code != 0 {
|
||||
return fmt.Errorf("QQ 音乐返回错误码 %d", s.Code)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, source, op, endpoint string, query url.Values, header http.Header, out response) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, requestTimeout)
|
||||
defer cancel()
|
||||
if source == "qq" {
|
||||
cookie, err := c.cookie.get(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cookie != "" {
|
||||
header = header.Clone()
|
||||
header.Set("Cookie", cookie)
|
||||
}
|
||||
}
|
||||
start := time.Now()
|
||||
err := c.fetch(ctx, endpoint+"?"+query.Encode(), header, out)
|
||||
if err == nil {
|
||||
err = out.check()
|
||||
}
|
||||
c.observe(source, op, time.Since(start), err)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) fetch(ctx context.Context, target string, header http.Header, out response) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return errors.New("QQ 音乐的请求地址无效")
|
||||
}
|
||||
req.Header = header.Clone()
|
||||
chinaip.Set(req.Header)
|
||||
resp, err := c.api.Do(req)
|
||||
if err != nil {
|
||||
return requestError(req.URL.Host, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%s 返回 HTTP %d", req.URL.Host, resp.StatusCode)
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return fmt.Errorf("解析 %s 的响应:%w", req.URL.Host, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) download(ctx context.Context, audioURL string) (io.ReadCloser, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, downloadTimeout)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, audioURL, nil)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, errors.New("QQ 音乐的播放地址无效")
|
||||
}
|
||||
req.Header = audioHeader.Clone()
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, requestError(req.URL.Host, err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_ = resp.Body.Close()
|
||||
cancel()
|
||||
return nil, fmt.Errorf("下载音频:HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return &audio{ReadCloser: resp.Body, cancel: cancel}, nil
|
||||
}
|
||||
|
||||
func requestError(host string, err error) error {
|
||||
var urlError *url.Error
|
||||
if errors.As(err, &urlError) {
|
||||
err = urlError.Err
|
||||
}
|
||||
// 播放地址的查询参数可能携带签名,错误包装不得泄漏完整 URL。
|
||||
if strings.Contains(err.Error(), "failed to parse Location header") {
|
||||
return fmt.Errorf("请求 %s:解析跳转地址失败", host)
|
||||
}
|
||||
return fmt.Errorf("请求 %s:%w", host, err)
|
||||
}
|
||||
|
||||
type audio struct {
|
||||
io.ReadCloser
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (a *audio) Close() error {
|
||||
err := a.ReadCloser.Close()
|
||||
a.cancel()
|
||||
return err
|
||||
}
|
||||
|
||||
func text(s string) string {
|
||||
return strings.ReplaceAll(strings.ToValidUTF8(s, "\uFFFD"), "\x00", "")
|
||||
}
|
||||
@@ -0,0 +1,821 @@
|
||||
package qq
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/lib/chinaip"
|
||||
)
|
||||
|
||||
const (
|
||||
qqLoginAppID = "716027609"
|
||||
qqLoginOAuthClient = "100497308"
|
||||
qqLoginDaid = "383"
|
||||
qqLoginXUIURL = "https://xui.ptlogin2.qq.com/cgi-bin/xlogin"
|
||||
qqLoginQRShowURL = "https://xui.ptlogin2.qq.com/ssl/ptqrshow"
|
||||
qqLoginQRCheckURL = "https://xui.ptlogin2.qq.com/ssl/ptqrlogin"
|
||||
qqLoginOAuthJumpURL = "https://graph.qq.com/oauth2.0/login_jump"
|
||||
qqLoginAuthorizeURL = "https://graph.qq.com/oauth2.0/authorize"
|
||||
qqLoginMusicURL = "https://u.y.qq.com/cgi-bin/musicu.fcg"
|
||||
qqLoginRedirectURI = "https://y.qq.com/portal/wx_redirect.html?login_type=1&surl=https%3A%2F%2Fy.qq.com%2F"
|
||||
weChatLoginAppID = "wx48db31d50e334801"
|
||||
weChatLoginRedirectURI = "https://y.qq.com/portal/wx_redirect.html?login_type=2&surl=https://y.qq.com/"
|
||||
weChatLoginQRConnectURL = "https://open.weixin.qq.com/connect/qrconnect"
|
||||
weChatLoginQRCheckURL = "https://lp.open.weixin.qq.com/connect/l/qrconnect"
|
||||
weChatLoginQRCodeURL = "https://open.weixin.qq.com/connect/qrcode/"
|
||||
)
|
||||
|
||||
var qqLoginMusicURLs = []string{
|
||||
qqLoginMusicURL,
|
||||
"https://szu.y.qq.com/cgi-bin/musicu.fcg",
|
||||
"https://shu.y.qq.com/cgi-bin/musicu.fcg",
|
||||
}
|
||||
|
||||
var (
|
||||
_ port.QQQRLoginSource = (*Client)(nil)
|
||||
weChatQRUUIDPattern = regexp.MustCompile(`(?:connect/l/qrconnect\?uuid=|window\.QRLogin\.uuid\s*=\s*["']|/connect/qrcode/)([A-Za-z0-9_-]+)`)
|
||||
weChatQRErrorPattern = regexp.MustCompile(`wx_errcode\s*=\s*([0-9]+)`)
|
||||
weChatQRCodePattern = regexp.MustCompile(`wx_code\s*=\s*["']([^"']*)["']`)
|
||||
)
|
||||
|
||||
type qqQRLoginState struct {
|
||||
QRSig string `json:"qrsig"`
|
||||
LoginSig string `json:"login_sig"`
|
||||
Cookies map[string]string `json:"cookies"`
|
||||
}
|
||||
|
||||
type weChatQRLoginState struct {
|
||||
UUID string `json:"uuid"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
func (c *Client) CreateQRLogin(ctx context.Context, loginType port.QQLoginType) (session port.QRLoginSession, err error) {
|
||||
switch loginType {
|
||||
case port.QQLoginQQ, port.QQLoginWeChat:
|
||||
default:
|
||||
return port.QRLoginSession{}, fmt.Errorf("未知的 QQ 登录类型 %q", loginType)
|
||||
}
|
||||
start := time.Now()
|
||||
defer func() { c.observe("qq", "login_create", time.Since(start), err) }()
|
||||
if loginType == port.QQLoginWeChat {
|
||||
return c.createWeChatQR(ctx)
|
||||
}
|
||||
return c.createQQQR(ctx)
|
||||
}
|
||||
|
||||
func (c *Client) CheckQRLogin(ctx context.Context, loginType port.QQLoginType, key string) (result port.QRLoginResult, err error) {
|
||||
switch loginType {
|
||||
case port.QQLoginQQ, port.QQLoginWeChat:
|
||||
default:
|
||||
return port.QRLoginResult{}, fmt.Errorf("未知的 QQ 登录类型 %q", loginType)
|
||||
}
|
||||
if key == "" {
|
||||
return port.QRLoginResult{}, errors.New("QQ 扫码登录的 key 为空")
|
||||
}
|
||||
start := time.Now()
|
||||
defer func() { c.observe("qq", "login_check", time.Since(start), err) }()
|
||||
if loginType == port.QQLoginWeChat {
|
||||
return c.checkWeChatQR(ctx, key)
|
||||
}
|
||||
return c.checkQQQR(ctx, key)
|
||||
}
|
||||
|
||||
func (c *Client) createQQQR(ctx context.Context) (port.QRLoginSession, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, requestTimeout)
|
||||
defer cancel()
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return port.QRLoginSession{}, err
|
||||
}
|
||||
client := qqLoginClient(c.api, jar)
|
||||
prewarm := url.Values{
|
||||
"appid": {qqLoginAppID},
|
||||
"daid": {qqLoginDaid},
|
||||
"style": {"33"},
|
||||
"login_text": {"登录"},
|
||||
"hide_title_bar": {"1"},
|
||||
"hide_border": {"1"},
|
||||
"target": {"self"},
|
||||
"s_url": {qqLoginOAuthJumpURL},
|
||||
"pt_3rd_aid": {qqLoginOAuthClient},
|
||||
"pt_feedback_link": {"https://support.qq.com/products/77942?customInfo=.appid100497308"},
|
||||
"theme": {"2"},
|
||||
"verify_theme": {""},
|
||||
}
|
||||
resp, err := qqLoginRequest(ctx, client, http.MethodGet, qqLoginXUIURL+"?"+prewarm.Encode(), nil, nil)
|
||||
if err != nil {
|
||||
return port.QRLoginSession{}, err
|
||||
}
|
||||
if _, err := readQQLoginBody(resp); err != nil {
|
||||
return port.QRLoginSession{}, err
|
||||
}
|
||||
qrQuery := url.Values{
|
||||
"appid": {qqLoginAppID},
|
||||
"e": {"2"},
|
||||
"l": {"M"},
|
||||
"s": {"3"},
|
||||
"d": {"72"},
|
||||
"v": {"4"},
|
||||
"t": {strconv.FormatInt(time.Now().Unix(), 10)},
|
||||
"daid": {qqLoginDaid},
|
||||
"pt_3rd_aid": {qqLoginOAuthClient},
|
||||
"u1": {qqLoginOAuthJumpURL},
|
||||
}
|
||||
resp, err = qqLoginRequest(ctx, client, http.MethodGet, qqLoginQRShowURL+"?"+qrQuery.Encode(), nil, nil)
|
||||
if err != nil {
|
||||
return port.QRLoginSession{}, err
|
||||
}
|
||||
image, err := readQQLoginBody(resp)
|
||||
if err != nil {
|
||||
return port.QRLoginSession{}, err
|
||||
}
|
||||
state := qqQRLoginState{Cookies: make(map[string]string)}
|
||||
mergeQQLoginJar(state.Cookies, jar, qqLoginXUIURL, qqLoginQRShowURL, qqLoginQRCheckURL)
|
||||
for _, cookie := range resp.Cookies() {
|
||||
if cookie.Value != "" {
|
||||
state.Cookies[cookie.Name] = cookie.Value
|
||||
}
|
||||
}
|
||||
state.QRSig = state.Cookies["qrsig"]
|
||||
state.LoginSig = state.Cookies["pt_login_sig"]
|
||||
if state.QRSig == "" {
|
||||
return port.QRLoginSession{}, errors.New("QQ 创建扫码登录的响应缺少 qrsig")
|
||||
}
|
||||
encoded, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return port.QRLoginSession{}, err
|
||||
}
|
||||
return port.QRLoginSession{
|
||||
Key: base64.RawURLEncoding.EncodeToString(encoded),
|
||||
QRImage: "data:image/png;base64," + base64.StdEncoding.EncodeToString(image),
|
||||
ExpiresAt: time.Now().Add(2 * time.Minute),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) checkQQQR(ctx context.Context, key string) (port.QRLoginResult, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, requestTimeout)
|
||||
defer cancel()
|
||||
encoded, err := base64.RawURLEncoding.DecodeString(key)
|
||||
if err != nil {
|
||||
return port.QRLoginResult{}, fmt.Errorf("解码 QQ 扫码登录的 key:%w", err)
|
||||
}
|
||||
var state qqQRLoginState
|
||||
if err := json.Unmarshal(encoded, &state); err != nil {
|
||||
return port.QRLoginResult{}, fmt.Errorf("解析 QQ 扫码登录的 key:%w", err)
|
||||
}
|
||||
if state.QRSig == "" {
|
||||
return port.QRLoginResult{}, errors.New("QQ 扫码登录的 key 缺少 qrsig")
|
||||
}
|
||||
if state.Cookies == nil {
|
||||
state.Cookies = make(map[string]string)
|
||||
}
|
||||
state.Cookies["qrsig"] = state.QRSig
|
||||
if state.LoginSig != "" {
|
||||
state.Cookies["pt_login_sig"] = state.LoginSig
|
||||
}
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return port.QRLoginResult{}, err
|
||||
}
|
||||
names := make([]string, 0, len(state.Cookies))
|
||||
for name := range state.Cookies {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
cookies := make([]*http.Cookie, 0, len(names))
|
||||
for _, name := range names {
|
||||
if state.Cookies[name] != "" {
|
||||
cookies = append(cookies, &http.Cookie{Name: name, Value: state.Cookies[name], Path: "/"})
|
||||
}
|
||||
}
|
||||
for _, rawURL := range []string{qqLoginXUIURL, qqLoginQRShowURL, qqLoginQRCheckURL} {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return port.QRLoginResult{}, err
|
||||
}
|
||||
jar.SetCookies(u, cookies)
|
||||
}
|
||||
client := qqLoginClient(c.api, jar)
|
||||
query := url.Values{
|
||||
"u1": {qqLoginOAuthJumpURL},
|
||||
"ptqrtoken": {qqLoginHash(state.QRSig, 0)},
|
||||
"ptredirect": {"0"},
|
||||
"h": {"1"},
|
||||
"t": {"1"},
|
||||
"g": {"1"},
|
||||
"from_ui": {"1"},
|
||||
"ptlang": {"2052"},
|
||||
"action": {"0-0-" + strconv.FormatInt(time.Now().UnixMilli(), 10)},
|
||||
"js_ver": {"26071711"},
|
||||
"js_type": {"1"},
|
||||
"login_sig": {state.LoginSig},
|
||||
"pt_uistyle": {"40"},
|
||||
"aid": {qqLoginAppID},
|
||||
"daid": {qqLoginDaid},
|
||||
"pt_3rd_aid": {qqLoginOAuthClient},
|
||||
"pt_js_version": {"c1987b96"},
|
||||
}
|
||||
resp, err := qqLoginRequest(ctx, client, http.MethodGet, qqLoginQRCheckURL+"?"+query.Encode(), nil, nil)
|
||||
if err != nil {
|
||||
return port.QRLoginResult{}, err
|
||||
}
|
||||
body, err := readQQLoginBody(resp)
|
||||
if err != nil {
|
||||
return port.QRLoginResult{}, err
|
||||
}
|
||||
fields, err := qqLoginCallbackFields(string(body))
|
||||
if err != nil || len(fields) < 5 {
|
||||
return port.QRLoginResult{}, errors.New("解析 QQ 扫码轮询的响应失败")
|
||||
}
|
||||
code, err := strconv.Atoi(fields[0])
|
||||
if err != nil {
|
||||
return port.QRLoginResult{}, fmt.Errorf("解析 QQ 扫码状态:%w", err)
|
||||
}
|
||||
result := port.QRLoginResult{Status: qqQRLoginStatus(code), Message: fields[4]}
|
||||
if result.Status != port.QRLoginSuccess {
|
||||
return result, nil
|
||||
}
|
||||
if fields[2] == "" {
|
||||
return port.QRLoginResult{}, errors.New("QQ 扫码成功的响应缺少跳转地址")
|
||||
}
|
||||
cookie, err := c.completeQQLogin(ctx, client, fields[2])
|
||||
if err != nil {
|
||||
return port.QRLoginResult{}, err
|
||||
}
|
||||
if cookie == "" {
|
||||
result.Status = port.QRLoginFailed
|
||||
result.Message = "QQ 音乐登录未返回音乐 key"
|
||||
return result, nil
|
||||
}
|
||||
if err := c.cookie.put(ctx, cookie); err != nil {
|
||||
return port.QRLoginResult{}, fmt.Errorf("保存 QQ Cookie:%w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) createWeChatQR(ctx context.Context) (port.QRLoginSession, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, requestTimeout)
|
||||
defer cancel()
|
||||
state, err := qqLoginUUID()
|
||||
if err != nil {
|
||||
return port.QRLoginSession{}, err
|
||||
}
|
||||
query := url.Values{
|
||||
"appid": {weChatLoginAppID},
|
||||
"redirect_uri": {weChatLoginRedirectURI},
|
||||
"response_type": {"code"},
|
||||
"scope": {"snsapi_login"},
|
||||
"state": {state},
|
||||
"href": {"https://y.qq.com/mediastyle/music_v17/src/css/popup_wechat.css#wechat_redirect"},
|
||||
}
|
||||
loginURL := weChatLoginQRConnectURL + "?" + query.Encode()
|
||||
client := qqLoginClient(c.api, nil)
|
||||
resp, err := qqLoginRequest(ctx, client, http.MethodGet, loginURL, nil, nil)
|
||||
if err != nil {
|
||||
return port.QRLoginSession{}, err
|
||||
}
|
||||
html, err := readQQLoginBody(resp)
|
||||
if err != nil {
|
||||
return port.QRLoginSession{}, err
|
||||
}
|
||||
matches := weChatQRUUIDPattern.FindSubmatch(html)
|
||||
if len(matches) != 2 || len(matches[1]) == 0 {
|
||||
return port.QRLoginSession{}, errors.New("微信创建扫码登录的响应缺少 UUID")
|
||||
}
|
||||
uuid := string(matches[1])
|
||||
encoded, err := json.Marshal(weChatQRLoginState{UUID: uuid, State: state})
|
||||
if err != nil {
|
||||
return port.QRLoginSession{}, err
|
||||
}
|
||||
return port.QRLoginSession{
|
||||
Key: base64.RawURLEncoding.EncodeToString(encoded),
|
||||
LoginURL: loginURL,
|
||||
QRImage: weChatLoginQRCodeURL + url.PathEscape(uuid),
|
||||
ExpiresAt: time.Now().Add(5 * time.Minute),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) checkWeChatQR(ctx context.Context, key string) (port.QRLoginResult, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, requestTimeout)
|
||||
defer cancel()
|
||||
encoded, err := base64.RawURLEncoding.DecodeString(key)
|
||||
if err != nil {
|
||||
return port.QRLoginResult{}, fmt.Errorf("解码微信扫码登录的 key:%w", err)
|
||||
}
|
||||
var state weChatQRLoginState
|
||||
if err := json.Unmarshal(encoded, &state); err != nil {
|
||||
return port.QRLoginResult{}, fmt.Errorf("解析微信扫码登录的 key:%w", err)
|
||||
}
|
||||
if state.UUID == "" {
|
||||
return port.QRLoginResult{}, errors.New("微信扫码登录的 key 缺少 UUID")
|
||||
}
|
||||
client := qqLoginClient(c.api, nil)
|
||||
query := url.Values{
|
||||
"uuid": {state.UUID},
|
||||
"_": {strconv.FormatInt(time.Now().UnixMilli(), 10)},
|
||||
}
|
||||
resp, err := qqLoginRequest(ctx, client, http.MethodGet, weChatLoginQRCheckURL+"?"+query.Encode(), nil, nil)
|
||||
if err != nil {
|
||||
return port.QRLoginResult{}, err
|
||||
}
|
||||
body, err := readQQLoginBody(resp)
|
||||
if err != nil {
|
||||
return port.QRLoginResult{}, err
|
||||
}
|
||||
matches := weChatQRErrorPattern.FindSubmatch(body)
|
||||
if len(matches) != 2 {
|
||||
return port.QRLoginResult{}, errors.New("解析微信扫码轮询的响应失败")
|
||||
}
|
||||
code, err := strconv.Atoi(string(matches[1]))
|
||||
if err != nil {
|
||||
return port.QRLoginResult{}, fmt.Errorf("解析微信扫码状态:%w", err)
|
||||
}
|
||||
result := port.QRLoginResult{Status: weChatQRLoginStatus(code)}
|
||||
if result.Status != port.QRLoginSuccess {
|
||||
return result, nil
|
||||
}
|
||||
matches = weChatQRCodePattern.FindSubmatch(body)
|
||||
if len(matches) != 2 || len(matches[1]) == 0 {
|
||||
return port.QRLoginResult{}, errors.New("微信扫码成功的响应缺少授权 code")
|
||||
}
|
||||
cookie, err := c.completeWeChatLogin(ctx, client, string(matches[1]))
|
||||
if err != nil {
|
||||
return port.QRLoginResult{}, err
|
||||
}
|
||||
if cookie == "" {
|
||||
result.Status = port.QRLoginFailed
|
||||
result.Message = "QQ 音乐登录未返回音乐 key"
|
||||
return result, nil
|
||||
}
|
||||
if err := c.cookie.put(ctx, cookie); err != nil {
|
||||
return port.QRLoginResult{}, fmt.Errorf("保存 QQ Cookie:%w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func weChatQRLoginStatus(code int) port.QRLoginStatus {
|
||||
switch code {
|
||||
case 405:
|
||||
return port.QRLoginSuccess
|
||||
case 402:
|
||||
return port.QRLoginExpired
|
||||
case 404:
|
||||
return port.QRLoginScanned
|
||||
case 408:
|
||||
return port.QRLoginWaiting
|
||||
default:
|
||||
return port.QRLoginFailed
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) completeWeChatLogin(ctx context.Context, client *http.Client, code string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, requestTimeout)
|
||||
defer cancel()
|
||||
requestBody, err := json.Marshal(map[string]any{
|
||||
"comm": map[string]any{
|
||||
"tmeAppID": "qqmusic", "tmeLoginType": "1", "g_tk": 5381,
|
||||
"platform": "yqq", "ct": 24, "cv": 0,
|
||||
},
|
||||
"req": map[string]any{
|
||||
"module": "music.login.LoginServer",
|
||||
"method": "Login",
|
||||
"param": map[string]string{"strAppid": weChatLoginAppID, "code": code},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
header := http.Header{
|
||||
"Content-Type": {"application/x-www-form-urlencoded"},
|
||||
"Referer": {weChatLoginRedirectURI},
|
||||
"Origin": {"https://y.qq.com"},
|
||||
"Cookie": {"login_type=2"},
|
||||
}
|
||||
var errs []error
|
||||
for _, endpoint := range qqLoginMusicURLs {
|
||||
cookie, err := completeWeChatLoginAt(ctx, client, endpoint, requestBody, header)
|
||||
if err == nil {
|
||||
return cookie, nil
|
||||
}
|
||||
errs = append(errs, err)
|
||||
}
|
||||
return "", errors.Join(errs...)
|
||||
}
|
||||
|
||||
func completeWeChatLoginAt(ctx context.Context, client *http.Client, endpoint string, requestBody []byte, header http.Header) (string, error) {
|
||||
resp, err := qqLoginRequest(ctx, client, http.MethodPost, endpoint, bytes.NewReader(requestBody), header)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
musicBody, err := readQQLoginBody(resp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
credentials := make(map[string]string)
|
||||
if err := mergeQQMusicLoginResponse(credentials, resp, musicBody); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return serializeQQLoginCredentials(credentials), nil
|
||||
}
|
||||
|
||||
func (c *Client) completeQQLogin(ctx context.Context, client *http.Client, redirectURL string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, requestTimeout)
|
||||
defer cancel()
|
||||
finalJump, err := followQQLogin(ctx, client, redirectURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
credentials := make(map[string]string)
|
||||
mergeQQLoginJar(credentials, client.Jar, finalJump, qqLoginAuthorizeURL, qqLoginRedirectURI)
|
||||
pSKey := credentials["p_skey"]
|
||||
if pSKey == "" {
|
||||
return "", errors.New("QQ 登录跳转后缺少 p_skey")
|
||||
}
|
||||
ui, err := qqLoginUUID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
form := url.Values{
|
||||
"response_type": {"code"},
|
||||
"client_id": {qqLoginOAuthClient},
|
||||
"redirect_uri": {qqLoginRedirectURI},
|
||||
"scope": {"get_user_info,get_app_friends"},
|
||||
"state": {"state"},
|
||||
"switch": {""},
|
||||
"from_ptlogin": {"1"},
|
||||
"src": {"1"},
|
||||
"update_auth": {"1"},
|
||||
"openapi": {"1010_1030"},
|
||||
"g_tk": {qqLoginHash(pSKey, 5381)},
|
||||
"auth_time": {strconv.FormatInt(time.Now().UnixMilli(), 10)},
|
||||
"ui": {ui},
|
||||
}
|
||||
header := http.Header{"Content-Type": {"application/x-www-form-urlencoded"}}
|
||||
resp, err := qqLoginRequest(ctx, client, http.MethodPost, qqLoginAuthorizeURL, strings.NewReader(form.Encode()), header)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
finalOAuth := resp.Request.URL.String()
|
||||
if qqLoginRedirect(resp.StatusCode) {
|
||||
location := resp.Header.Get("Location")
|
||||
if location == "" {
|
||||
closeQQLoginBody(resp)
|
||||
return "", errors.New("QQ OAuth 跳转缺少 Location")
|
||||
}
|
||||
next, err := resp.Request.URL.Parse(location)
|
||||
closeQQLoginBody(resp)
|
||||
if err != nil {
|
||||
return "", errors.New("解析 QQ OAuth 跳转地址失败")
|
||||
}
|
||||
finalOAuth, err = followQQLogin(ctx, client, next.String())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else if _, err := readQQLoginBody(resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
mergeQQLoginJar(credentials, client.Jar, finalOAuth, qqLoginRedirectURI, qqLoginMusicURL)
|
||||
oauthURL, err := url.Parse(finalOAuth)
|
||||
if err != nil {
|
||||
return "", errors.New("解析 QQ OAuth 回调地址失败")
|
||||
}
|
||||
code := oauthURL.Query().Get("code")
|
||||
if code == "" {
|
||||
return "", errors.New("QQ OAuth 回调缺少 code")
|
||||
}
|
||||
requestBody, err := json.Marshal(map[string]any{
|
||||
"comm": map[string]any{"g_tk": 5381, "platform": "yqq", "ct": 24, "cv": 0},
|
||||
"req": map[string]any{
|
||||
"module": "QQConnectLogin.LoginServer",
|
||||
"method": "QQLogin",
|
||||
"param": map[string]string{"code": code},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
header = http.Header{
|
||||
"Content-Type": {"application/json"},
|
||||
"Referer": {"https://y.qq.com/"},
|
||||
"Origin": {"https://y.qq.com"},
|
||||
}
|
||||
resp, err = qqLoginRequest(ctx, client, http.MethodPost, qqLoginMusicURL, bytes.NewReader(requestBody), header)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
musicBody, err := readQQLoginBody(resp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
mergeQQLoginJar(credentials, client.Jar, qqLoginMusicURL, finalOAuth, finalJump)
|
||||
if err := mergeQQMusicLoginResponse(credentials, resp, musicBody); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return serializeQQLoginCredentials(credentials), nil
|
||||
}
|
||||
|
||||
func mergeQQMusicLoginResponse(credentials map[string]string, resp *http.Response, musicBody []byte) error {
|
||||
var payload struct {
|
||||
Code *int `json:"code"`
|
||||
Req struct {
|
||||
Code *int `json:"code"`
|
||||
Data map[string]any `json:"data"`
|
||||
} `json:"req"`
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(musicBody))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
return fmt.Errorf("解析 QQ 音乐登录的响应:%w", err)
|
||||
}
|
||||
if payload.Code == nil || payload.Req.Code == nil {
|
||||
return errors.New("QQ 音乐登录响应的 code 或 req.code 缺失或为 null")
|
||||
}
|
||||
if *payload.Code != 0 || *payload.Req.Code != 0 {
|
||||
return fmt.Errorf("QQ 音乐登录返回错误码 %d、%d", *payload.Code, *payload.Req.Code)
|
||||
}
|
||||
for _, cookie := range resp.Cookies() {
|
||||
if cookie.Value != "" {
|
||||
credentials[cookie.Name] = cookie.Value
|
||||
}
|
||||
}
|
||||
for name, value := range payload.Req.Data {
|
||||
musicKey := false
|
||||
switch name {
|
||||
case "qm_keyst", "qqmusic_key", "musickey", "music_key", "strMusicKey":
|
||||
musicKey = true
|
||||
case "uin", "musicid", "userid", "user_id", "ptui_loginuin", "luin", "pt2gguin", "superuin", "p_uin", "wxuin",
|
||||
"refresh_key", "refresh_token", "openid", "unionid", "wxaccess_token":
|
||||
default:
|
||||
continue
|
||||
}
|
||||
credential := ""
|
||||
switch value := value.(type) {
|
||||
case string:
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
credential = value
|
||||
case json.Number:
|
||||
credential = value.String()
|
||||
default:
|
||||
continue
|
||||
}
|
||||
cookie := &http.Cookie{Name: name, Value: credential}
|
||||
if cookie.Valid() != nil {
|
||||
if musicKey {
|
||||
return fmt.Errorf("QQ 音乐登录响应的音乐 key 字段 %s 无效", name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
credentials[name] = credential
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func serializeQQLoginCredentials(credentials map[string]string) string {
|
||||
if !normalizeQQLoginCredentials(credentials) {
|
||||
return ""
|
||||
}
|
||||
names := make([]string, 0, len(credentials))
|
||||
for name, value := range credentials {
|
||||
if name != "" && value != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
pairs := make([]string, len(names))
|
||||
for i, name := range names {
|
||||
pairs[i] = name + "=" + credentials[name]
|
||||
}
|
||||
return strings.Join(pairs, "; ")
|
||||
}
|
||||
|
||||
func followQQLogin(ctx context.Context, client *http.Client, rawURL string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, requestTimeout)
|
||||
defer cancel()
|
||||
manual := *client
|
||||
manual.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
client = &manual
|
||||
current := rawURL
|
||||
for redirects := 0; ; {
|
||||
if !validQQLoginURL(current) {
|
||||
return "", errors.New("QQ 登录跳转地址仅允许 HTTPS 和 QQ 域名")
|
||||
}
|
||||
resp, err := qqLoginRequest(ctx, client, http.MethodGet, current, nil, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !qqLoginRedirect(resp.StatusCode) {
|
||||
finalURL := resp.Request.URL.String()
|
||||
if _, err := readQQLoginBody(resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return finalURL, nil
|
||||
}
|
||||
if redirects >= 10 {
|
||||
closeQQLoginBody(resp)
|
||||
return "", errors.New("QQ 登录跳转超过 10 次")
|
||||
}
|
||||
location := resp.Header.Get("Location")
|
||||
if location == "" {
|
||||
closeQQLoginBody(resp)
|
||||
return "", errors.New("QQ 登录跳转缺少 Location")
|
||||
}
|
||||
next, err := resp.Request.URL.Parse(location)
|
||||
closeQQLoginBody(resp)
|
||||
if err != nil {
|
||||
return "", errors.New("解析 QQ 登录跳转地址失败")
|
||||
}
|
||||
current = next.String()
|
||||
redirects++
|
||||
}
|
||||
}
|
||||
|
||||
func validQQLoginURL(rawURL string) bool {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || u.Scheme != "https" {
|
||||
return false
|
||||
}
|
||||
host := u.Hostname()
|
||||
return host == "qq.com" || strings.HasSuffix(host, ".qq.com")
|
||||
}
|
||||
|
||||
func qqLoginClient(api *http.Client, jar http.CookieJar) *http.Client {
|
||||
client := *api
|
||||
client.Jar = jar
|
||||
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
return &client
|
||||
}
|
||||
|
||||
func qqLoginRequest(ctx context.Context, client *http.Client, method, target string, body io.Reader, header http.Header) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, target, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if header != nil {
|
||||
req.Header = header.Clone()
|
||||
}
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", desktopHeader.Get("User-Agent"))
|
||||
}
|
||||
chinaip.Set(req.Header)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
var requestError *url.Error
|
||||
if errors.As(err, &requestError) {
|
||||
err = requestError.Err
|
||||
}
|
||||
// net/http 会在 CheckRedirect 前解析 Location,这个错误没有独立类型。
|
||||
// 按文本识别并替换解析错误,避免完整登录跳转地址及查询参数进入错误或日志。
|
||||
if strings.Contains(err.Error(), "failed to parse Location header") {
|
||||
return nil, fmt.Errorf("请求 %s:解析 QQ 登录跳转地址失败", req.URL.Host)
|
||||
}
|
||||
return nil, fmt.Errorf("请求 %s:%w", req.URL.Host, err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func readQQLoginBody(resp *http.Response) ([]byte, error) {
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s 返回 HTTP %d", resp.Request.URL.Host, resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 %s 的响应:%w", resp.Request.URL.Host, err)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func closeQQLoginBody(resp *http.Response) {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
func qqLoginRedirect(status int) bool {
|
||||
return status == http.StatusMovedPermanently || status == http.StatusFound || status == http.StatusSeeOther ||
|
||||
status == http.StatusTemporaryRedirect || status == http.StatusPermanentRedirect
|
||||
}
|
||||
|
||||
func qqLoginCallbackFields(body string) ([]string, error) {
|
||||
var fields []string
|
||||
var field strings.Builder
|
||||
quoted, escaped := false, false
|
||||
for _, r := range body {
|
||||
if !quoted {
|
||||
if r == '\'' {
|
||||
quoted = true
|
||||
field.Reset()
|
||||
}
|
||||
continue
|
||||
}
|
||||
if escaped {
|
||||
field.WriteRune(r)
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
switch r {
|
||||
case '\\':
|
||||
escaped = true
|
||||
case '\'':
|
||||
fields = append(fields, field.String())
|
||||
quoted = false
|
||||
default:
|
||||
field.WriteRune(r)
|
||||
}
|
||||
}
|
||||
if quoted {
|
||||
return nil, errors.New("未闭合的回调字段")
|
||||
}
|
||||
return fields, nil
|
||||
}
|
||||
|
||||
func qqQRLoginStatus(code int) port.QRLoginStatus {
|
||||
switch code {
|
||||
case 0:
|
||||
return port.QRLoginSuccess
|
||||
case 65:
|
||||
return port.QRLoginExpired
|
||||
case 66:
|
||||
return port.QRLoginWaiting
|
||||
case 67:
|
||||
return port.QRLoginScanned
|
||||
default:
|
||||
return port.QRLoginFailed
|
||||
}
|
||||
}
|
||||
|
||||
func qqLoginHash(value string, initial int64) string {
|
||||
hash := initial
|
||||
for _, r := range value {
|
||||
hash += (hash << 5) + int64(r)
|
||||
}
|
||||
return strconv.FormatInt(hash&0x7fffffff, 10)
|
||||
}
|
||||
|
||||
func qqLoginUUID() (string, error) {
|
||||
var id [16]byte
|
||||
if _, err := rand.Read(id[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
id[6] = id[6]&0x0f | 0x40
|
||||
id[8] = id[8]&0x3f | 0x80
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", id[0:4], id[4:6], id[6:8], id[8:10], id[10:16]), nil
|
||||
}
|
||||
|
||||
func mergeQQLoginJar(credentials map[string]string, jar http.CookieJar, rawURLs ...string) {
|
||||
for _, rawURL := range rawURLs {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, cookie := range jar.Cookies(u) {
|
||||
if cookie.Value != "" {
|
||||
credentials[cookie.Name] = cookie.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeQQLoginCredentials(credentials map[string]string) bool {
|
||||
musicKey := ""
|
||||
for _, name := range []string{"qm_keyst", "qqmusic_key", "musickey", "music_key", "strMusicKey"} {
|
||||
if credentials[name] != "" {
|
||||
musicKey = credentials[name]
|
||||
break
|
||||
}
|
||||
}
|
||||
if musicKey == "" {
|
||||
return false
|
||||
}
|
||||
if credentials["qm_keyst"] == "" {
|
||||
credentials["qm_keyst"] = musicKey
|
||||
}
|
||||
if credentials["qqmusic_key"] == "" {
|
||||
credentials["qqmusic_key"] = musicKey
|
||||
}
|
||||
if credentials["uin"] == "" {
|
||||
for _, name := range []string{"musicid", "userid", "user_id", "ptui_loginuin", "luin", "pt2gguin", "superuin", "p_uin", "wxuin"} {
|
||||
if credentials[name] != "" {
|
||||
credentials["uin"] = credentials[name]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package qq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
)
|
||||
|
||||
const (
|
||||
playURLAPI = "https://u.y.qq.com/cgi-bin/musicu.fcg"
|
||||
playURLCDN = "https://ws.stream.qqmusic.qq.com/"
|
||||
)
|
||||
|
||||
type playURLResponse struct {
|
||||
Code *int `json:"code"`
|
||||
Req1 struct {
|
||||
Code *int `json:"code"`
|
||||
Data struct {
|
||||
Items []struct {
|
||||
Filename string `json:"filename"`
|
||||
PURL string `json:"purl"`
|
||||
} `json:"midurlinfo"`
|
||||
} `json:"data"`
|
||||
} `json:"req_1"`
|
||||
}
|
||||
|
||||
func (r playURLResponse) check() error {
|
||||
if r.Code == nil || r.Req1.Code == nil {
|
||||
return errors.New("QQ 音乐播放地址响应的 code 或 req_1.code 缺失或为 null")
|
||||
}
|
||||
if *r.Code != 0 {
|
||||
return fmt.Errorf("QQ 音乐返回错误码 %d", *r.Code)
|
||||
}
|
||||
if *r.Req1.Code != 0 {
|
||||
return fmt.Errorf("QQ 音乐播放地址请求返回错误码 %d", *r.Req1.Code)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ port.PlayURLs = (*Client)(nil)
|
||||
|
||||
func (c *Client) PlayURL(ctx context.Context, songMID string) (string, error) {
|
||||
filenames := []string{
|
||||
"F000" + songMID + songMID + ".flac",
|
||||
"M800" + songMID + songMID + ".mp3",
|
||||
"M500" + songMID + songMID + ".mp3",
|
||||
}
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"req_1": map[string]any{
|
||||
"module": "music.vkey.GetVkey",
|
||||
"method": "UrlGetVkey",
|
||||
"param": map[string]any{
|
||||
"filename": filenames,
|
||||
"guid": "0",
|
||||
"songmid": []string{songMID},
|
||||
"songtype": []int{0},
|
||||
"uin": "0",
|
||||
"loginflag": 1,
|
||||
"platform": "20",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var resp playURLResponse
|
||||
query := url.Values{"data": {string(payload)}}
|
||||
if err := c.get(ctx, "qq", "play_url", playURLAPI, query, desktopHeader, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, filename := range filenames {
|
||||
for _, item := range resp.Req1.Data.Items {
|
||||
if item.Filename == filename && item.PURL != "" {
|
||||
return playURLCDN + item.PURL, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", &port.RejectedError{Err: errors.New("QQ 音乐没有提供这首歌的播放地址")}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package qq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
const (
|
||||
searchAPI = "https://c.y.qq.com/soso/fcgi-bin/search_for_qq_cp"
|
||||
searchPlaylistAPI = "https://c.y.qq.com/soso/fcgi-bin/client_music_search_songlist"
|
||||
detailAPI = "https://c.y.qq.com/v8/fcg-bin/fcg_play_single_song.fcg"
|
||||
lyricAPI = "https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg"
|
||||
)
|
||||
|
||||
var (
|
||||
mobileHeader = http.Header{
|
||||
"User-Agent": {"Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1"},
|
||||
"Referer": {"http://m.y.qq.com"},
|
||||
}
|
||||
desktopHeader = http.Header{
|
||||
"User-Agent": {"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"},
|
||||
"Referer": {"https://y.qq.com/portal/search.html"},
|
||||
}
|
||||
lyricHeader = http.Header{
|
||||
"User-Agent": mobileHeader["User-Agent"],
|
||||
"Referer": {"https://y.qq.com/portal/player.html"},
|
||||
}
|
||||
audioHeader = http.Header{
|
||||
"User-Agent": desktopHeader["User-Agent"],
|
||||
}
|
||||
)
|
||||
|
||||
var _ port.Source = (*Client)(nil)
|
||||
|
||||
type singer struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (c *Client) SearchTracks(ctx context.Context, keyword string) ([]port.Song, error) {
|
||||
return cached(c, "search_tracks "+keyword, func() ([]port.Song, error) {
|
||||
var resp struct {
|
||||
status
|
||||
Data struct {
|
||||
Song struct {
|
||||
List []struct {
|
||||
SongMID string `json:"songmid"`
|
||||
SongName string `json:"songname"`
|
||||
Singer []singer `json:"singer"`
|
||||
AlbumName string `json:"albumname"`
|
||||
AlbumMID string `json:"albummid"`
|
||||
Interval int64 `json:"interval"`
|
||||
} `json:"list"`
|
||||
} `json:"song"`
|
||||
} `json:"data"`
|
||||
}
|
||||
query := url.Values{"w": {keyword}, "format": {"json"}, "p": {"1"}, "n": {"20"}}
|
||||
if err := c.get(ctx, "qq", "search_tracks", searchAPI, query, mobileHeader, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
songs := make([]port.Song, len(resp.Data.Song.List))
|
||||
for i, s := range resp.Data.Song.List {
|
||||
songs[i] = song(s.SongMID, s.SongName, s.Singer, s.AlbumName, s.AlbumMID, s.Interval)
|
||||
}
|
||||
return songs, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) SearchPlaylists(ctx context.Context, keyword string) ([]port.Playlist, error) {
|
||||
return cached(c, "search_playlists "+keyword, func() ([]port.Playlist, error) {
|
||||
var resp struct {
|
||||
status
|
||||
Data struct {
|
||||
List []struct {
|
||||
DissID string `json:"dissid"`
|
||||
DissName string `json:"dissname"`
|
||||
ImgURL string `json:"imgurl"`
|
||||
Introduction string `json:"introduction"`
|
||||
Creator struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"creator"`
|
||||
ListenNum int64 `json:"listennum"`
|
||||
SongCount int64 `json:"song_count"`
|
||||
} `json:"list"`
|
||||
} `json:"data"`
|
||||
}
|
||||
query := url.Values{
|
||||
"query": {keyword}, "page_no": {"0"}, "num_per_page": {"20"}, "format": {"json"},
|
||||
"remoteplace": {"txt.yqq.playlist"}, "flag_qc": {"0"},
|
||||
}
|
||||
if err := c.get(ctx, "qq", "search_playlists", searchPlaylistAPI, query, desktopHeader, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
playlists := make([]port.Playlist, len(resp.Data.List))
|
||||
for i, p := range resp.Data.List {
|
||||
playlists[i] = port.Playlist{
|
||||
ID: p.DissID,
|
||||
Name: html.UnescapeString(p.DissName),
|
||||
CoverURL: p.ImgURL,
|
||||
Creator: html.UnescapeString(p.Creator.Name),
|
||||
Description: html.UnescapeString(p.Introduction),
|
||||
TrackCount: p.SongCount,
|
||||
PlayCount: p.ListenNum,
|
||||
}
|
||||
}
|
||||
return playlists, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) TrackMeta(ctx context.Context, id string) (track.Meta, error) {
|
||||
return cached(c, "track_meta "+id, func() (track.Meta, error) {
|
||||
var resp struct {
|
||||
status
|
||||
Data []struct {
|
||||
MID string `json:"mid"`
|
||||
// title 可包含 Live、伴奏这类版本说明,name 没有。
|
||||
Title string `json:"title"`
|
||||
Singer []singer `json:"singer"`
|
||||
Album struct {
|
||||
MID string `json:"mid"`
|
||||
Name string `json:"name"`
|
||||
} `json:"album"`
|
||||
Interval int64 `json:"interval"`
|
||||
} `json:"data"`
|
||||
}
|
||||
query := url.Values{"songmid": {id}, "format": {"json"}}
|
||||
if err := c.get(ctx, "qq", "track_detail", detailAPI, query, mobileHeader, &resp); err != nil {
|
||||
return track.Meta{}, err
|
||||
}
|
||||
if len(resp.Data) == 0 {
|
||||
return track.Meta{}, &port.RejectedError{Err: errors.New("QQ 音乐中没有这首歌")}
|
||||
}
|
||||
s := resp.Data[0]
|
||||
return song(s.MID, s.Title, s.Singer, s.Album.Name, s.Album.MID, s.Interval).Meta, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) Resolve(ctx context.Context, id string) (port.Resolved, error) {
|
||||
meta, err := c.TrackMeta(ctx, id)
|
||||
if err != nil {
|
||||
return port.Resolved{}, err
|
||||
}
|
||||
lyric, err := c.lyric(ctx, id)
|
||||
if err != nil {
|
||||
return port.Resolved{}, err
|
||||
}
|
||||
audioURL, err := c.PlayURL(ctx, id)
|
||||
if err != nil {
|
||||
return port.Resolved{}, err
|
||||
}
|
||||
audio, err := c.download(ctx, audioURL)
|
||||
if err != nil {
|
||||
return port.Resolved{}, err
|
||||
}
|
||||
return port.Resolved{Meta: meta, Lyric: lyric, Audio: audio}, nil
|
||||
}
|
||||
|
||||
type lyricResponse struct {
|
||||
Code int `json:"code"`
|
||||
Lyric string `json:"lyric"`
|
||||
}
|
||||
|
||||
func (lyricResponse) check() error { return nil }
|
||||
|
||||
func (c *Client) lyric(ctx context.Context, id string) (string, error) {
|
||||
return cached(c, "lyric "+id, func() (string, error) {
|
||||
var resp lyricResponse
|
||||
query := url.Values{
|
||||
"songmid": {id}, "loginUin": {"0"}, "hostUin": {"0"}, "format": {"json"}, "inCharset": {"utf8"},
|
||||
"outCharset": {"utf-8"}, "notice": {"0"}, "platform": {"y.qq.json"}, "needNewCode": {"0"},
|
||||
}
|
||||
if err := c.get(ctx, "qq", "lyric", lyricAPI, query, lyricHeader, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.Code != 0 || resp.Lyric == "" {
|
||||
return "", nil
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(resp.Lyric)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解码 QQ 音乐的歌词:%w", err)
|
||||
}
|
||||
return text(string(b)), nil
|
||||
})
|
||||
}
|
||||
|
||||
func song(mid, title string, singers []singer, album, albumMID string, seconds int64) port.Song {
|
||||
names := make([]string, 0, len(singers))
|
||||
for _, s := range singers {
|
||||
if s.Name != "" {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
}
|
||||
cover := ""
|
||||
if albumMID != "" {
|
||||
cover = "https://y.gtimg.cn/music/photo_new/T002R300x300M000" + albumMID + ".jpg"
|
||||
}
|
||||
return port.Song{
|
||||
Track: track.Track{Source: track.QQ, ID: mid},
|
||||
Meta: track.Meta{
|
||||
Title: text(title),
|
||||
Artist: text(strings.Join(names, ", ")),
|
||||
Album: text(album),
|
||||
CoverURL: text(cover),
|
||||
PageURL: "https://y.qq.com/n/ryqq/songDetail/" + mid,
|
||||
Duration: time.Duration(seconds) * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/riverqueue/river"
|
||||
"github.com/riverqueue/river/rivertype"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
// 任务载荷是跨版本契约:新版本必须能执行旧版本留下的任务。载荷包含曲目的 Source 和 ID。
|
||||
// 适配器之间不能互相 import,internal/adapter/in/worker 按相同的 kind 和字段解码。
|
||||
type prepareArgs struct {
|
||||
Source string `json:"source"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (prepareArgs) Kind() string { return "prepare" }
|
||||
|
||||
// internal/adapter/in/worker 按同样的队列名执行任务。
|
||||
const (
|
||||
prepareQueue = "prepare"
|
||||
linkQueue = "ytdlp"
|
||||
bilibiliQueue = "bilibili"
|
||||
)
|
||||
|
||||
func (a prepareArgs) InsertOpts() river.InsertOpts {
|
||||
return river.InsertOpts{
|
||||
Queue: queueOf(track.Source(a.Source)),
|
||||
MaxAttempts: 2,
|
||||
// 去重不包括已完成的任务:曲目表中的结果被清除之后,River 保留的已完成任务不能阻止重新准备。
|
||||
UniqueOpts: river.UniqueOpts{
|
||||
ByArgs: true,
|
||||
ByState: []rivertype.JobState{
|
||||
rivertype.JobStateAvailable, rivertype.JobStatePending, rivertype.JobStateRetryable,
|
||||
rivertype.JobStateRunning, rivertype.JobStateScheduled,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 链接和 B 站的歌由 worker 准备:只有 worker 的镜像里有 yt-dlp 和 ffmpeg。
|
||||
func queueOf(source track.Source) string {
|
||||
switch source {
|
||||
case track.Link:
|
||||
return linkQueue
|
||||
case track.Bilibili:
|
||||
return bilibiliQueue
|
||||
}
|
||||
return prepareQueue
|
||||
}
|
||||
|
||||
type Queue struct {
|
||||
client *river.Client[pgx.Tx]
|
||||
}
|
||||
|
||||
var _ port.PrepareJobs = (*Queue)(nil)
|
||||
|
||||
func New(client *river.Client[pgx.Tx]) *Queue {
|
||||
return &Queue{client: client}
|
||||
}
|
||||
|
||||
func (q *Queue) Submit(ctx context.Context, t track.Track) (int64, error) {
|
||||
res, err := q.client.Insert(ctx, prepareArgs{Source: string(t.Source), ID: t.ID}, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("提交 %s %s 的准备任务:%w", t.Source, t.ID, err)
|
||||
}
|
||||
return res.Job.ID, nil
|
||||
}
|
||||
|
||||
// 已被 River 清理的任务当作已完成:成功的结果在曲目表中,失败的任务不会再执行。
|
||||
func (q *Queue) Status(ctx context.Context, jobID int64) (port.JobState, string, error) {
|
||||
job, err := q.client.JobGet(ctx, jobID)
|
||||
if errors.Is(err, river.ErrNotFound) {
|
||||
return port.JobFinished, "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("查询准备任务 %d:%w", jobID, err)
|
||||
}
|
||||
switch job.State {
|
||||
case rivertype.JobStateCompleted:
|
||||
return port.JobFinished, "", nil
|
||||
case rivertype.JobStateCancelled:
|
||||
return port.JobFailed, reason(job), nil
|
||||
case rivertype.JobStateDiscarded:
|
||||
return port.JobFailed, port.UnavailableReason, nil
|
||||
}
|
||||
return port.JobPending, "", nil
|
||||
}
|
||||
|
||||
// River 记录取消的原因时带有 "JobCancelError: " 前缀;internal/adapter/in/worker 以同样的方式提取任务失败的原因。
|
||||
func reason(job *rivertype.JobRow) string {
|
||||
if len(job.Errors) == 0 {
|
||||
return "准备任务失败"
|
||||
}
|
||||
return strings.TrimPrefix(job.Errors[len(job.Errors)-1].Error, "JobCancelError: ")
|
||||
}
|
||||
|
||||
// River client 没有统计任务数的接口,所以直接读 River 的任务表。
|
||||
func WaitingLinkJobs(ctx context.Context, pool *pgxpool.Pool) (int64, error) {
|
||||
var n int64
|
||||
if err := pool.QueryRow(ctx, "SELECT count(*) FROM river_job WHERE queue = $1 AND state = 'available'", linkQueue).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("统计等待执行的链接准备任务:%w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
goredis "github.com/redis/go-redis/v9"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.43.0"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/member"
|
||||
)
|
||||
|
||||
var _ port.Bus = (*Bus)(nil)
|
||||
|
||||
const (
|
||||
membersChannel = "members"
|
||||
preparedChannel = "tracks.prepared"
|
||||
roomPrefix = "room:"
|
||||
// 订阅连接空闲这么久发一次 PING,再过这么久仍无回复就换一个新连接:
|
||||
// 半开的 TCP 连接读不到任何错误,只能这样发现。
|
||||
pingInterval = 5 * time.Second
|
||||
reconnectDelay = time.Second
|
||||
// 订阅连接上的写入不使用调用方的期限:go-redis 把写超时当作连接已坏而重建共用的订阅连接,本实例的全部订阅随之中断;
|
||||
// 而调用方可能在 go-redis 内部的锁上一直等到自己的期限用完才开始写,例如 go-redis 正持锁重新连接时。
|
||||
// go-redis 从真正开始写的时刻按 WriteTimeout 计算写入的期限,这个上限只是兜底,要长于持锁重新连接的时间:
|
||||
// 默认的连接、读、写超时各 5 秒,持锁重新连接一次最长约 20–30 秒。
|
||||
sharedWriteTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
var errBusClosed = errors.New("实例间消息的订阅已经关闭")
|
||||
|
||||
func instanceChannel(id string) string { return "inst:" + id }
|
||||
func roomChannel(id string) string { return roomPrefix + id }
|
||||
|
||||
type Bus struct {
|
||||
client *goredis.Client
|
||||
instance string
|
||||
version string
|
||||
tracer trace.Tracer
|
||||
logger *slog.Logger
|
||||
dropped *prometheus.CounterVec
|
||||
done chan struct{}
|
||||
|
||||
// 持有 mu 时不调用可能等网络的 go-redis 方法:Receive 处理 PONG、报告中断都要 mu。
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
ps *goredis.PubSub
|
||||
channels map[string]bool
|
||||
waiters map[string]chan struct{}
|
||||
tokens uint64
|
||||
// 已换成新的订阅对象,但还没有在上面订阅原来的频道。
|
||||
stale bool
|
||||
|
||||
// 以下字段只由 Receive 访问。
|
||||
connected bool
|
||||
pinged bool
|
||||
}
|
||||
|
||||
func NewBus(client *goredis.Client, instanceID, version string, metrics prometheus.Registerer, tracer trace.TracerProvider, logger *slog.Logger) *Bus {
|
||||
dropped := prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "alisten_bus_dropped_total",
|
||||
Help: "被丢弃的实例间消息,reason 为 version(构建版本不一致)或 malformed(无法解码)。",
|
||||
}, []string{"reason"})
|
||||
dropped.WithLabelValues("version")
|
||||
dropped.WithLabelValues("malformed")
|
||||
metrics.MustRegister(dropped)
|
||||
return &Bus{
|
||||
client: client,
|
||||
instance: instanceID,
|
||||
version: version,
|
||||
tracer: tracer.Tracer("github.com/dihemoe/alisten/internal/adapter/out/redis"),
|
||||
logger: logger,
|
||||
dropped: dropped,
|
||||
done: make(chan struct{}),
|
||||
ps: client.Subscribe(context.Background()),
|
||||
channels: map[string]bool{},
|
||||
waiters: map[string]chan struct{}{},
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bus) SubscribeInstance(ctx context.Context) error {
|
||||
return b.subscribe(ctx, instanceChannel(b.instance), membersChannel, preparedChannel)
|
||||
}
|
||||
|
||||
func (b *Bus) SubscribeRoom(ctx context.Context, roomID string) error {
|
||||
return b.subscribe(ctx, roomChannel(roomID))
|
||||
}
|
||||
|
||||
// Redis 按顺序处理一个连接上的命令:订阅之后在同一个连接上发一次 PING,收到它的回复时订阅已经生效。
|
||||
func (b *Bus) subscribe(ctx context.Context, channels ...string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fmt.Errorf("订阅 %s:%w", strings.Join(channels, "、"), err)
|
||||
}
|
||||
b.mu.Lock()
|
||||
if b.closed {
|
||||
b.mu.Unlock()
|
||||
return errBusClosed
|
||||
}
|
||||
for _, ch := range channels {
|
||||
b.channels[ch] = true
|
||||
}
|
||||
b.tokens++
|
||||
token := strconv.FormatUint(b.tokens, 10)
|
||||
confirmed := make(chan struct{})
|
||||
b.waiters[token] = confirmed
|
||||
ps := b.ps
|
||||
b.mu.Unlock()
|
||||
err := writeShared(ctx, func(w context.Context) error { return ps.Subscribe(w, channels...) })
|
||||
if err == nil {
|
||||
err = writeShared(ctx, func(w context.Context) error { return ps.Ping(w, token) })
|
||||
}
|
||||
if err == nil {
|
||||
select {
|
||||
case <-confirmed:
|
||||
return nil
|
||||
case <-b.done:
|
||||
err = errBusClosed
|
||||
case <-ctx.Done():
|
||||
err = ctx.Err()
|
||||
}
|
||||
}
|
||||
b.mu.Lock()
|
||||
delete(b.waiters, token)
|
||||
b.mu.Unlock()
|
||||
return fmt.Errorf("订阅 %s:%w", strings.Join(channels, "、"), err)
|
||||
}
|
||||
|
||||
func (b *Bus) UnsubscribeRoom(ctx context.Context, roomID string) error {
|
||||
ch := roomChannel(roomID)
|
||||
b.mu.Lock()
|
||||
if b.closed {
|
||||
b.mu.Unlock()
|
||||
return errBusClosed
|
||||
}
|
||||
delete(b.channels, ch)
|
||||
ps := b.ps
|
||||
b.mu.Unlock()
|
||||
if err := writeShared(ctx, func(w context.Context) error { return ps.Unsubscribe(w, ch) }); err != nil {
|
||||
return fmt.Errorf("退订 %s:%w", ch, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeShared(ctx context.Context, write func(context.Context) error) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
w, cancel := context.WithTimeout(context.WithoutCancel(ctx), sharedWriteTimeout)
|
||||
defer cancel()
|
||||
// 调用方结束时取消 w:go-redis 只按 ctx 的期限设置读写超时而不理会取消,所以已有连接上的写入不受影响;取消只打断建立连接,
|
||||
// 以免调用方持着 go-redis 的锁等到连接超时,拖住排在后面的调用方。
|
||||
stop := context.AfterFunc(ctx, cancel)
|
||||
defer stop()
|
||||
return write(w)
|
||||
}
|
||||
|
||||
func (b *Bus) PublishRoom(ctx context.Context, roomID string, m port.RoomMessage) error {
|
||||
kind, data, err := encodeRoomMessage(m)
|
||||
if err != nil {
|
||||
return fmt.Errorf("编码房间 %s 的消息:%w", roomID, err)
|
||||
}
|
||||
_, err = b.publish(ctx, roomChannel(roomID), kind, data, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *Bus) SendInput(ctx context.Context, instanceID string, in port.RoomInput) (bool, error) {
|
||||
data, err := encodeInput(in)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("编码房间 %s 的输入:%w", in.Room, err)
|
||||
}
|
||||
carrier := propagation.MapCarrier{}
|
||||
propagation.TraceContext{}.Inject(ctx, carrier)
|
||||
receivers, err := b.publish(ctx, instanceChannel(instanceID), kindInput, data, carrier)
|
||||
return receivers > 0, err
|
||||
}
|
||||
|
||||
func (b *Bus) PublishMember(ctx context.Context, m member.Member) error {
|
||||
data, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = b.publish(ctx, membersChannel, kindMember, data, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *Bus) PublishPrepared(ctx context.Context, o port.PrepareOutcome) error {
|
||||
data, err := json.Marshal(o)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = b.publish(ctx, preparedChannel, kindPrepared, data, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *Bus) publish(ctx context.Context, channel, kind string, data json.RawMessage, carrier map[string]string) (int64, error) {
|
||||
payload, err := json.Marshal(envelope{Version: b.version, From: b.instance, Kind: kind, Trace: carrier, Data: data})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
receivers, err := b.client.Publish(ctx, channel, payload).Result()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("发布到 %s:%w", channel, err)
|
||||
}
|
||||
return receivers, nil
|
||||
}
|
||||
|
||||
// 连接出错时 go-redis 会自行重新连接并重新订阅全部频道,但断开期间的消息已经丢失;连接只是停止响应时 go-redis 无法察觉,
|
||||
// 由这里换一个新连接。两种情况都在恢复订阅之前先报告 SubscriptionLost,让调用方尽早释放房间、断开会话。
|
||||
func (b *Bus) Receive(ctx context.Context) (port.Delivery, error) {
|
||||
stop := context.AfterFunc(ctx, b.close)
|
||||
defer stop()
|
||||
for {
|
||||
b.mu.Lock()
|
||||
ps, closed := b.ps, b.closed
|
||||
var channels []string
|
||||
if b.stale {
|
||||
channels, b.stale = slices.Collect(maps.Keys(b.channels)), false
|
||||
}
|
||||
b.mu.Unlock()
|
||||
if closed {
|
||||
return nil, errors.Join(errBusClosed, ctx.Err())
|
||||
}
|
||||
if len(channels) > 0 {
|
||||
// 即使连不上,go-redis 也会记下这些频道,在连接建立时一并订阅。
|
||||
_ = ps.Subscribe(ctx, channels...)
|
||||
}
|
||||
msg, err := ps.ReceiveTimeout(ctx, pingInterval)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if timeout(err) && !b.pinged {
|
||||
b.pinged = true
|
||||
_ = ps.Ping(ctx)
|
||||
continue
|
||||
}
|
||||
if timeout(err) {
|
||||
b.replace(ps)
|
||||
}
|
||||
lost := b.connected
|
||||
b.connected, b.pinged = false, false
|
||||
if lost {
|
||||
return port.SubscriptionLost{}, nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(reconnectDelay):
|
||||
}
|
||||
continue
|
||||
}
|
||||
b.pinged = false
|
||||
if !b.connected {
|
||||
b.connected = true
|
||||
b.repingWaiters(ctx, ps)
|
||||
}
|
||||
switch msg := msg.(type) {
|
||||
case *goredis.Message:
|
||||
if d, ok := b.decode(ctx, msg); ok {
|
||||
return d, nil
|
||||
}
|
||||
case *goredis.Pong:
|
||||
b.confirm(msg.Payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func timeout(err error) bool {
|
||||
var ne net.Error
|
||||
return errors.As(err, &ne) && ne.Timeout()
|
||||
}
|
||||
|
||||
// 这里不做 I/O:先报告 SubscriptionLost,下一次接收时再连接并订阅原来的频道,停止响应的服务端不会拖延报告。
|
||||
func (b *Bus) replace(old *goredis.PubSub) {
|
||||
b.mu.Lock()
|
||||
if b.closed || b.ps != old {
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
b.ps = b.client.Subscribe(context.Background())
|
||||
b.stale = true
|
||||
b.mu.Unlock()
|
||||
// Close 要等 old 内部的锁,而持有这把锁的写入可能正在等网络。
|
||||
go func() { _ = old.Close() }()
|
||||
}
|
||||
|
||||
// 新连接建立时已经重新订阅了全部频道,之前发出的 PING 都随旧连接丢失了。
|
||||
func (b *Bus) repingWaiters(ctx context.Context, ps *goredis.PubSub) {
|
||||
b.mu.Lock()
|
||||
tokens := slices.Collect(maps.Keys(b.waiters))
|
||||
b.mu.Unlock()
|
||||
for _, token := range tokens {
|
||||
_ = ps.Ping(ctx, token)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bus) confirm(token string) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if confirmed, ok := b.waiters[token]; ok {
|
||||
close(confirmed)
|
||||
delete(b.waiters, token)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bus) close() {
|
||||
b.mu.Lock()
|
||||
if b.closed {
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
b.closed = true
|
||||
close(b.done)
|
||||
ps := b.ps
|
||||
b.mu.Unlock()
|
||||
_ = ps.Close()
|
||||
}
|
||||
|
||||
func (b *Bus) decode(ctx context.Context, msg *goredis.Message) (port.Delivery, bool) {
|
||||
var e envelope
|
||||
if err := json.Unmarshal([]byte(msg.Payload), &e); err != nil {
|
||||
b.malformed(ctx, msg.Channel, err)
|
||||
return nil, false
|
||||
}
|
||||
if e.Version != b.version {
|
||||
b.dropped.WithLabelValues("version").Inc()
|
||||
b.logger.ErrorContext(ctx, "丢弃构建版本不同的实例间消息", "channel", msg.Channel, "from", e.From, "remote_version", e.Version)
|
||||
return nil, false
|
||||
}
|
||||
d, err := b.delivery(msg.Channel, e)
|
||||
if err != nil {
|
||||
b.malformed(ctx, msg.Channel, err)
|
||||
return nil, false
|
||||
}
|
||||
return d, true
|
||||
}
|
||||
|
||||
func (b *Bus) delivery(channel string, e envelope) (port.Delivery, error) {
|
||||
switch {
|
||||
case channel == instanceChannel(b.instance) && e.Kind == kindInput:
|
||||
in, err := decodeInput(e.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return port.InputDelivery{Context: b.received(e.Trace, in.Room), Input: in}, nil
|
||||
case channel == membersChannel && e.Kind == kindMember:
|
||||
var m member.Member
|
||||
err := json.Unmarshal(e.Data, &m)
|
||||
return port.MemberDelivery{Member: m}, err
|
||||
case channel == preparedChannel && e.Kind == kindPrepared:
|
||||
var o port.PrepareOutcome
|
||||
err := json.Unmarshal(e.Data, &o)
|
||||
return port.PreparedDelivery{Outcome: o}, err
|
||||
case strings.HasPrefix(channel, roomPrefix):
|
||||
m, err := decodeRoomMessage(e.Kind, e.Data)
|
||||
return port.RoomDelivery{Room: strings.TrimPrefix(channel, roomPrefix), Message: m}, err
|
||||
}
|
||||
return nil, fmt.Errorf("频道 %s 上不应有 %s 消息", channel, e.Kind)
|
||||
}
|
||||
|
||||
// 带有追踪上下文的输入延续网关的 trace:记录一个 consumer span,owner 处理这条输入时的调用都在它之下。
|
||||
// 这个 span 只标记收到输入的时刻,处理的耗时由它之下的调用体现。
|
||||
func (b *Bus) received(carrier map[string]string, roomID string) context.Context {
|
||||
ctx := propagation.TraceContext{}.Extract(context.Background(), propagation.MapCarrier(carrier))
|
||||
if !trace.SpanContextFromContext(ctx).IsValid() {
|
||||
return ctx
|
||||
}
|
||||
ctx, span := b.tracer.Start(ctx, "receive room input", trace.WithSpanKind(trace.SpanKindConsumer), trace.WithAttributes(
|
||||
semconv.MessagingSystemKey.String("redis"),
|
||||
semconv.MessagingOperationTypeReceive,
|
||||
attribute.String("alisten.room_id", roomID),
|
||||
))
|
||||
span.End()
|
||||
return ctx
|
||||
}
|
||||
|
||||
func (b *Bus) malformed(ctx context.Context, channel string, err error) {
|
||||
b.dropped.WithLabelValues("malformed").Inc()
|
||||
b.logger.ErrorContext(ctx, "丢弃无法解码的实例间消息", "channel", channel, "error", err)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
goredis "github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// 单程时延按往返时间的一半估算。
|
||||
func ClockOffset(ctx context.Context, client *goredis.Client) (time.Duration, error) {
|
||||
start := time.Now()
|
||||
server, err := client.Time(ctx).Result()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("读取 Redis 服务器的时间:%w", err)
|
||||
}
|
||||
return server.Sub(start.Add(time.Since(start) / 2)), nil
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package redis
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/member"
|
||||
"github.com/dihemoe/alisten/internal/domain/room"
|
||||
)
|
||||
|
||||
// 只有同一构建版本的实例互相通信,格式不需要跨版本兼容。
|
||||
type envelope struct {
|
||||
Version string
|
||||
From string
|
||||
Kind string
|
||||
Trace map[string]string `json:",omitempty"`
|
||||
Data json.RawMessage
|
||||
}
|
||||
|
||||
type (
|
||||
wireEmit struct {
|
||||
Term uint32
|
||||
Seq uint64
|
||||
Event string
|
||||
Data json.RawMessage
|
||||
}
|
||||
|
||||
wireReply struct {
|
||||
RequestID room.RequestID
|
||||
Result string
|
||||
Data json.RawMessage
|
||||
Err *room.Error
|
||||
}
|
||||
|
||||
wireInput struct {
|
||||
Room string
|
||||
Gateway string
|
||||
Takeover bool
|
||||
Input string
|
||||
Data json.RawMessage
|
||||
Patch *port.InfoPatch
|
||||
}
|
||||
|
||||
wireCommand struct {
|
||||
RequestID room.RequestID
|
||||
Member member.Member
|
||||
Action string
|
||||
Data json.RawMessage
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
kindEmitted = "Emitted"
|
||||
kindReplied = "Replied"
|
||||
kindInput = "RoomInput"
|
||||
kindMember = "Member"
|
||||
kindPrepared = "Prepared"
|
||||
// room.Command 的 Action 是接口,不能像 inputs 中的类型那样直接编码。
|
||||
inputCommand = "Command"
|
||||
)
|
||||
|
||||
// 接口实现必须登记后才能编码;实现字段中不能再有接口类型。
|
||||
type union[T any] map[string]reflect.Type
|
||||
|
||||
func newUnion[T any](variants ...T) union[T] {
|
||||
u := union[T]{}
|
||||
for _, v := range variants {
|
||||
t := reflect.TypeOf(v)
|
||||
u[t.Name()] = t
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func (u union[T]) encode(v T) (string, json.RawMessage, error) {
|
||||
t := reflect.TypeOf(v)
|
||||
if t == nil || u[t.Name()] != t {
|
||||
return "", nil, fmt.Errorf("不能编码为实例间消息的类型 %T", v)
|
||||
}
|
||||
data, err := json.Marshal(v)
|
||||
return t.Name(), data, err
|
||||
}
|
||||
|
||||
func (u union[T]) decode(name string, data json.RawMessage) (T, error) {
|
||||
var zero T
|
||||
t, ok := u[name]
|
||||
if !ok {
|
||||
return zero, fmt.Errorf("未知的类型 %q", name)
|
||||
}
|
||||
p := reflect.New(t)
|
||||
if err := json.Unmarshal(data, p.Interface()); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
return p.Elem().Interface().(T), nil
|
||||
}
|
||||
|
||||
var (
|
||||
roomMessages = newUnion[port.RoomMessage](port.OwnerChanged{}, port.Rejected{}, port.RoomClosed{}, port.OwnerReleased{}, port.TicketsRevoked{}, port.APIKeyReset{})
|
||||
events = newUnion[room.Event](room.PlaybackChanged{}, room.QueueChanged{}, room.MembersChanged{}, room.ModeChanged{}, room.InfoChanged{}, room.ChatPosted{}, room.Activity{})
|
||||
results = newUnion[room.Result](room.Done{}, room.EnqueueResult{}, room.LikeResult{}, room.VoteResult{}, room.SyncResult{})
|
||||
inputs = newUnion[room.Input](room.Join{}, room.Leave{}, room.MemberUpdated{}, room.Delete{})
|
||||
actions = newUnion[room.Action](room.Enqueue{}, room.Remove{}, room.Like{}, room.VoteSkip{}, room.SetPlayMode{}, room.Chat{}, room.Sync{})
|
||||
)
|
||||
|
||||
func encodeRoomMessage(m port.RoomMessage) (string, json.RawMessage, error) {
|
||||
switch m := m.(type) {
|
||||
case port.Emitted:
|
||||
w := wireEmit{Term: m.Emit.Term, Seq: m.Emit.Seq}
|
||||
var err error
|
||||
if w.Event, w.Data, err = events.encode(m.Emit.Event); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
data, err := json.Marshal(w)
|
||||
return kindEmitted, data, err
|
||||
case port.Replied:
|
||||
w := wireReply{RequestID: m.Reply.RequestID, Err: m.Reply.Err}
|
||||
if m.Reply.Err == nil {
|
||||
var err error
|
||||
if w.Result, w.Data, err = results.encode(m.Reply.Result); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
}
|
||||
data, err := json.Marshal(w)
|
||||
return kindReplied, data, err
|
||||
}
|
||||
return roomMessages.encode(m)
|
||||
}
|
||||
|
||||
func decodeRoomMessage(kind string, data json.RawMessage) (port.RoomMessage, error) {
|
||||
switch kind {
|
||||
case kindEmitted:
|
||||
var w wireEmit
|
||||
if err := json.Unmarshal(data, &w); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e, err := events.decode(w.Event, w.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return port.Emitted{Emit: room.Emit{Term: w.Term, Seq: w.Seq, Event: e}}, nil
|
||||
case kindReplied:
|
||||
var w wireReply
|
||||
if err := json.Unmarshal(data, &w); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply := room.Reply{RequestID: w.RequestID, Err: w.Err}
|
||||
if w.Err == nil {
|
||||
res, err := results.decode(w.Result, w.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply.Result = res
|
||||
}
|
||||
return port.Replied{Reply: reply}, nil
|
||||
}
|
||||
return roomMessages.decode(kind, data)
|
||||
}
|
||||
|
||||
var errEmptyInput = errors.New("房间输入没有内容")
|
||||
|
||||
func encodeInput(in port.RoomInput) (json.RawMessage, error) {
|
||||
w := wireInput{Room: in.Room, Gateway: in.Gateway, Takeover: in.Takeover, Patch: in.Patch}
|
||||
switch i := in.Input.(type) {
|
||||
case nil:
|
||||
if in.Patch == nil {
|
||||
return nil, errEmptyInput
|
||||
}
|
||||
case room.Command:
|
||||
c := wireCommand{RequestID: i.RequestID, Member: i.Member}
|
||||
var err error
|
||||
if c.Action, c.Data, err = actions.encode(i.Action); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.Input = inputCommand
|
||||
if w.Data, err = json.Marshal(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
var err error
|
||||
if w.Input, w.Data, err = inputs.encode(i); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return json.Marshal(w)
|
||||
}
|
||||
|
||||
func decodeInput(data json.RawMessage) (port.RoomInput, error) {
|
||||
var w wireInput
|
||||
if err := json.Unmarshal(data, &w); err != nil {
|
||||
return port.RoomInput{}, err
|
||||
}
|
||||
in := port.RoomInput{Room: w.Room, Gateway: w.Gateway, Takeover: w.Takeover, Patch: w.Patch}
|
||||
switch w.Input {
|
||||
case "":
|
||||
if w.Patch == nil {
|
||||
return port.RoomInput{}, errEmptyInput
|
||||
}
|
||||
case inputCommand:
|
||||
var c wireCommand
|
||||
if err := json.Unmarshal(w.Data, &c); err != nil {
|
||||
return port.RoomInput{}, err
|
||||
}
|
||||
a, err := actions.decode(c.Action, c.Data)
|
||||
if err != nil {
|
||||
return port.RoomInput{}, err
|
||||
}
|
||||
in.Input = room.Command{RequestID: c.RequestID, Member: c.Member, Action: a}
|
||||
default:
|
||||
i, err := inputs.decode(w.Input, w.Data)
|
||||
if err != nil {
|
||||
return port.RoomInput{}, err
|
||||
}
|
||||
in.Input = i
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
goredis "github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
)
|
||||
|
||||
var (
|
||||
_ port.Leases = (*Leases)(nil)
|
||||
_ port.OnlineCounts = (*Leases)(nil)
|
||||
)
|
||||
|
||||
type Leases struct {
|
||||
client *goredis.Client
|
||||
instance string
|
||||
}
|
||||
|
||||
func NewLeases(client *goredis.Client, instanceID string) *Leases {
|
||||
return &Leases{client: client, instance: instanceID}
|
||||
}
|
||||
|
||||
func ownerKey(roomID string) string { return "room:" + roomID + ":owner" }
|
||||
func onlineKey(roomID string) string { return "room:" + roomID + ":online" }
|
||||
|
||||
// 比较租约的值和写入在同一个脚本中完成,其间不会插入其他实例的命令。
|
||||
var (
|
||||
acquireScript = goredis.NewScript(`
|
||||
local owner = redis.call('GET', KEYS[1])
|
||||
if owner and owner ~= ARGV[1] then
|
||||
return 0
|
||||
end
|
||||
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
|
||||
redis.call('SET', KEYS[2], 0, 'PX', ARGV[2])
|
||||
return 1`)
|
||||
|
||||
renewScript = goredis.NewScript(`
|
||||
if redis.call('GET', KEYS[1]) ~= ARGV[1] then
|
||||
return 0
|
||||
end
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[2])
|
||||
redis.call('PEXPIRE', KEYS[2], ARGV[2])
|
||||
return 1`)
|
||||
|
||||
setOnlineScript = goredis.NewScript(`
|
||||
if redis.call('GET', KEYS[1]) ~= ARGV[1] then
|
||||
return 0
|
||||
end
|
||||
redis.call('SET', KEYS[2], ARGV[2], 'PX', redis.call('PTTL', KEYS[1]))
|
||||
return 1`)
|
||||
|
||||
releaseScript = goredis.NewScript(`
|
||||
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
||||
redis.call('DEL', KEYS[1], KEYS[2])
|
||||
end
|
||||
return 0`)
|
||||
)
|
||||
|
||||
func (l *Leases) Acquire(ctx context.Context, roomID string, ttl time.Duration) (bool, error) {
|
||||
n, err := acquireScript.Run(ctx, l.client, []string{ownerKey(roomID), onlineKey(roomID)}, l.instance, ttl.Milliseconds()).Int()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("取得房间 %s 的租约:%w", roomID, err)
|
||||
}
|
||||
return n == 1, nil
|
||||
}
|
||||
|
||||
func (l *Leases) Renew(ctx context.Context, roomID string, ttl time.Duration) (bool, error) {
|
||||
n, err := renewScript.Run(ctx, l.client, []string{ownerKey(roomID), onlineKey(roomID)}, l.instance, ttl.Milliseconds()).Int()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("续期房间 %s 的租约:%w", roomID, err)
|
||||
}
|
||||
return n == 1, nil
|
||||
}
|
||||
|
||||
func (l *Leases) SetOnline(ctx context.Context, roomID string, online int) (bool, error) {
|
||||
n, err := setOnlineScript.Run(ctx, l.client, []string{ownerKey(roomID), onlineKey(roomID)}, l.instance, online).Int()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("写入房间 %s 的在线人数:%w", roomID, err)
|
||||
}
|
||||
return n == 1, nil
|
||||
}
|
||||
|
||||
func (l *Leases) Release(ctx context.Context, roomID string) error {
|
||||
if err := releaseScript.Run(ctx, l.client, []string{ownerKey(roomID), onlineKey(roomID)}, l.instance).Err(); err != nil {
|
||||
return fmt.Errorf("释放房间 %s 的租约:%w", roomID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *Leases) Owners(ctx context.Context, roomIDs []string) ([]string, error) {
|
||||
values, err := mget(ctx, l.client, roomIDs, ownerKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取房间的 owner:%w", err)
|
||||
}
|
||||
owners := make([]string, len(values))
|
||||
for i, v := range values {
|
||||
owners[i], _ = v.(string)
|
||||
}
|
||||
return owners, nil
|
||||
}
|
||||
|
||||
func (l *Leases) OnlineCounts(ctx context.Context, roomIDs []string) ([]int, error) {
|
||||
values, err := mget(ctx, l.client, roomIDs, onlineKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取房间的在线人数:%w", err)
|
||||
}
|
||||
counts := make([]int, len(values))
|
||||
for i, v := range values {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if counts[i], err = strconv.Atoi(s); err != nil {
|
||||
return nil, fmt.Errorf("房间 %s 的在线人数 %q 不是整数", roomIDs[i], s)
|
||||
}
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
// MGET 对不存在的键返回 nil,并且不接受零个键。
|
||||
func mget(ctx context.Context, client *goredis.Client, ids []string, key func(string) string) ([]any, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
keys := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
keys[i] = key(id)
|
||||
}
|
||||
return client.MGet(ctx, keys...).Result()
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
goredis "github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
)
|
||||
|
||||
var _ port.Liveness = (*Liveness)(nil)
|
||||
|
||||
type Liveness struct {
|
||||
client *goredis.Client
|
||||
instance string
|
||||
}
|
||||
|
||||
func NewLiveness(client *goredis.Client, instanceID string) *Liveness {
|
||||
return &Liveness{client: client, instance: instanceID}
|
||||
}
|
||||
|
||||
func aliveKey(instanceID string) string { return "inst:" + instanceID + ":alive" }
|
||||
|
||||
func (l *Liveness) Heartbeat(ctx context.Context, ttl time.Duration) (bool, error) {
|
||||
err := l.client.SetArgs(ctx, aliveKey(l.instance), 1, goredis.SetArgs{TTL: ttl, Get: true}).Err()
|
||||
if errors.Is(err, goredis.Nil) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("刷新实例的存活键:%w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (l *Liveness) Expired(ctx context.Context, instanceIDs []string) ([]string, error) {
|
||||
values, err := mget(ctx, l.client, instanceIDs, aliveKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取实例的存活键:%w", err)
|
||||
}
|
||||
var expired []string
|
||||
for i, v := range values {
|
||||
if v == nil {
|
||||
expired = append(expired, instanceIDs[i])
|
||||
}
|
||||
}
|
||||
return expired, nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Package redis 只存放可以丢失的协调数据:Redis 被清空后系统能自行恢复。
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-redis/redis_rate/v10"
|
||||
goredis "github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
)
|
||||
|
||||
var _ port.SearchQuota = (*SearchQuota)(nil)
|
||||
|
||||
type SearchQuota struct {
|
||||
limiter *redis_rate.Limiter
|
||||
}
|
||||
|
||||
func NewSearchQuota(client *goredis.Client) *SearchQuota {
|
||||
return &SearchQuota{limiter: redis_rate.NewLimiter(client)}
|
||||
}
|
||||
|
||||
func (q *SearchQuota) TakeSearch(ctx context.Context, roomID string, burst int, refill time.Duration) (time.Duration, error) {
|
||||
res, err := q.limiter.Allow(ctx, "room:"+roomID+":search", redis_rate.Limit{Rate: 1, Burst: burst, Period: refill})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("取房间 %s 的搜索额度:%w", roomID, err)
|
||||
}
|
||||
if res.Allowed > 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return res.RetryAfter, nil
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
)
|
||||
|
||||
const (
|
||||
// AWS 的默认区域,在这个区域创建 bucket 时不能指定 LocationConstraint。
|
||||
defaultRegion = "us-east-1"
|
||||
rollbackTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Endpoint string
|
||||
Region string
|
||||
Bucket string
|
||||
AccessKeyID string
|
||||
SecretAccessKey string
|
||||
}
|
||||
|
||||
type Storage struct {
|
||||
client *s3.Client
|
||||
bucket string
|
||||
region string
|
||||
}
|
||||
|
||||
var _ port.MediaStore = (*Storage)(nil)
|
||||
|
||||
func New(cfg Config) *Storage {
|
||||
client := s3.New(s3.Options{
|
||||
BaseEndpoint: aws.String(cfg.Endpoint),
|
||||
Region: cfg.Region,
|
||||
// 为兼容不支持虚拟主机寻址的 S3 服务,统一使用路径寻址。
|
||||
UsePathStyle: true,
|
||||
Credentials: credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, ""),
|
||||
// 仅在操作要求时计算或验证校验和:部分 S3 兼容服务不支持 SDK 默认添加的灵活校验和。
|
||||
RequestChecksumCalculation: aws.RequestChecksumCalculationWhenRequired,
|
||||
ResponseChecksumValidation: aws.ResponseChecksumValidationWhenRequired,
|
||||
})
|
||||
return &Storage{client: client, bucket: cfg.Bucket, region: cfg.Region}
|
||||
}
|
||||
|
||||
// 只为新建的 bucket 设置访问策略:匿名请求只能读取 link/ 下的对象,不能列出对象。
|
||||
// 设置访问策略失败时删除刚创建的 bucket,否则下一次检查会把这个没有策略的 bucket 当作已经配置好的。
|
||||
func (s *Storage) EnsureBucket(ctx context.Context) (created bool, err error) {
|
||||
_, err = s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s.bucket)})
|
||||
if err == nil {
|
||||
return false, nil
|
||||
}
|
||||
if !notFound(err) {
|
||||
return false, fmt.Errorf("检查 bucket %s:%w", s.bucket, err)
|
||||
}
|
||||
input := &s3.CreateBucketInput{Bucket: aws.String(s.bucket)}
|
||||
if s.region != defaultRegion {
|
||||
input.CreateBucketConfiguration = &types.CreateBucketConfiguration{LocationConstraint: types.BucketLocationConstraint(s.region)}
|
||||
}
|
||||
if _, err := s.client.CreateBucket(ctx, input); err != nil {
|
||||
return false, fmt.Errorf("创建 bucket %s:%w", s.bucket, err)
|
||||
}
|
||||
_, err = s.client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{Bucket: aws.String(s.bucket), Policy: aws.String(s.policy())})
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
err = fmt.Errorf("设置 bucket %s 的访问策略:%w", s.bucket, err)
|
||||
// 迁移被取消或超时往往正是设置访问策略失败的原因,回滚不能随 ctx 一起失败。
|
||||
rollback, cancel := context.WithTimeout(context.WithoutCancel(ctx), rollbackTimeout)
|
||||
defer cancel()
|
||||
if _, deleteErr := s.client.DeleteBucket(rollback, &s3.DeleteBucketInput{Bucket: aws.String(s.bucket)}); deleteErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf("删除没有访问策略的 bucket %s:%w", s.bucket, deleteErr))
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
type (
|
||||
policy struct {
|
||||
Version string
|
||||
Statement []statement
|
||||
}
|
||||
statement struct {
|
||||
Effect string
|
||||
Principal principal
|
||||
Action []string
|
||||
Resource []string
|
||||
}
|
||||
principal struct {
|
||||
AWS []string
|
||||
}
|
||||
)
|
||||
|
||||
func (s *Storage) policy() string {
|
||||
b, _ := json.Marshal(policy{
|
||||
Version: "2012-10-17",
|
||||
Statement: []statement{{
|
||||
Effect: "Allow",
|
||||
Principal: principal{AWS: []string{"*"}},
|
||||
Action: []string{"s3:GetObject"},
|
||||
Resource: []string{"arn:aws:s3:::" + s.bucket + "/link/*"},
|
||||
}},
|
||||
})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (s *Storage) Put(ctx context.Context, key string, body io.ReadSeeker, contentType string) error {
|
||||
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
Body: body,
|
||||
ContentType: aws.String(contentType),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("上传 %s:%w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Storage) Exists(ctx context.Context, key string) (bool, error) {
|
||||
_, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: aws.String(s.bucket), Key: aws.String(key)})
|
||||
switch {
|
||||
case err == nil:
|
||||
return true, nil
|
||||
case notFound(err):
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("检查 %s 是否存在:%w", key, err)
|
||||
}
|
||||
|
||||
func (s *Storage) Delete(ctx context.Context, key string) error {
|
||||
if _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: aws.String(s.bucket), Key: aws.String(key)}); err != nil {
|
||||
return fmt.Errorf("删除 %s:%w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HEAD 请求的 404 响应没有响应体,SDK 只能从状态码判断。
|
||||
func notFound(err error) bool {
|
||||
var resp *awshttp.ResponseError
|
||||
return errors.As(err, &resp) && resp.HTTPStatusCode() == http.StatusNotFound
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package ytdlp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"time"
|
||||
)
|
||||
|
||||
const upstreamTimeout = 30 * time.Second
|
||||
|
||||
// 除回环、私有(包括 IPv6 唯一本地地址)、链路本地(包括云元数据服务的 169.254.169.254)、组播和未指定地址之外,
|
||||
// 代理还拒绝连接这些地址段。
|
||||
var blocked = []netip.Prefix{
|
||||
// 「本网络」地址段,不会出现在公网上;IsGlobalUnicast 只排除其中的 0.0.0.0。
|
||||
netip.MustParsePrefix("0.0.0.0/8"),
|
||||
// 运营商级 NAT 使用的共享地址段。
|
||||
netip.MustParsePrefix("100.64.0.0/10"),
|
||||
// IETF 协议分配、基准测试和保留地址段:不会出现在公网上,部分内网和云的网络把它们用作内部地址。
|
||||
netip.MustParsePrefix("192.0.0.0/24"),
|
||||
netip.MustParsePrefix("198.18.0.0/15"),
|
||||
netip.MustParsePrefix("240.0.0.0/4"),
|
||||
// 文档专用地址段:只用于示例,不会出现在公网上,部分内网把它们用作内部地址。
|
||||
netip.MustParsePrefix("192.0.2.0/24"),
|
||||
netip.MustParsePrefix("198.51.100.0/24"),
|
||||
netip.MustParsePrefix("203.0.113.0/24"),
|
||||
netip.MustParsePrefix("2001:db8::/32"),
|
||||
netip.MustParsePrefix("3fff::/20"),
|
||||
// 内嵌 IPv4 地址的 IPv6 地址段:NAT64、SIIT 网关和隧道会把它们转换成任意 IPv4 地址,包括私有地址。
|
||||
netip.MustParsePrefix("64:ff9b::/96"),
|
||||
netip.MustParsePrefix("64:ff9b:1::/48"),
|
||||
netip.MustParsePrefix("::/96"),
|
||||
netip.MustParsePrefix("::ffff:0:0:0/96"),
|
||||
netip.MustParsePrefix("2002::/16"),
|
||||
netip.MustParsePrefix("2001::/32"),
|
||||
// 已废弃的站点本地地址,部分网络仍把它当作内网。
|
||||
netip.MustParsePrefix("fec0::/10"),
|
||||
}
|
||||
|
||||
func public(a netip.Addr) bool {
|
||||
a = a.Unmap()
|
||||
if !a.IsGlobalUnicast() || a.IsPrivate() {
|
||||
return false
|
||||
}
|
||||
for _, p := range blocked {
|
||||
if p.Contains(a) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type refusedError struct {
|
||||
host string
|
||||
}
|
||||
|
||||
func (e *refusedError) Error() string { return fmt.Sprintf("%s 不是公网地址", e.host) }
|
||||
|
||||
// dialPublic 自己解析目标的域名,只连接解析出的公网地址:连接时不再解析域名,DNS 重绑定就无法绕过检查。
|
||||
func dialPublic(resolver *net.Resolver) func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return func(ctx context.Context, _, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var addrs []netip.Addr
|
||||
if a, err := netip.ParseAddr(host); err == nil {
|
||||
addrs = []netip.Addr{a}
|
||||
} else if addrs, err = resolver.LookupNetIP(ctx, "ip", host); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dialer := net.Dialer{Timeout: upstreamTimeout}
|
||||
var errs []error
|
||||
for _, a := range addrs {
|
||||
if !public(a) {
|
||||
continue
|
||||
}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(a.Unmap().String(), port))
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if len(errs) == 0 {
|
||||
return nil, &refusedError{host: host}
|
||||
}
|
||||
return nil, errors.Join(errs...)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package ytdlp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// https 的请求经 CONNECT 建立隧道,http 的请求直接转发,两者的目标都只由 dial 连接。
|
||||
type proxy struct {
|
||||
dial func(ctx context.Context, network, address string) (net.Conn, error)
|
||||
server *http.Server
|
||||
transport *http.Transport
|
||||
forward *httputil.ReverseProxy
|
||||
url string
|
||||
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
refused bool
|
||||
// 隧道两端的连接在 Hijack 之后不再由 server 管理,代理关闭时要由这里关闭。
|
||||
tunnels map[*tunnel]struct{}
|
||||
running sync.WaitGroup
|
||||
}
|
||||
|
||||
type tunnel struct {
|
||||
close func()
|
||||
}
|
||||
|
||||
func startProxy(dial func(ctx context.Context, network, address string) (net.Conn, error)) (*proxy, error) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := &proxy{dial: dial, url: "http://" + listener.Addr().String(), tunnels: map[*tunnel]struct{}{}}
|
||||
p.transport = &http.Transport{DialContext: p.connect, ResponseHeaderTimeout: upstreamTimeout}
|
||||
p.forward = &httputil.ReverseProxy{
|
||||
// 代理请求的地址就是要转发的绝对地址,原样转发,不加 X-Forwarded-For。
|
||||
Rewrite: func(*httputil.ProxyRequest) {},
|
||||
Transport: p.transport,
|
||||
ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) { p.fail(w, err) },
|
||||
}
|
||||
p.server = &http.Server{Handler: p, ReadHeaderTimeout: upstreamTimeout}
|
||||
go func() { _ = p.server.Serve(listener) }()
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (p *proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodConnect:
|
||||
p.tunnel(w, r)
|
||||
case r.URL.IsAbs():
|
||||
p.forward.ServeHTTP(w, r)
|
||||
default:
|
||||
http.Error(w, "只接受代理请求", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *proxy) connect(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
conn, err := p.dial(ctx, network, address)
|
||||
var refused *refusedError
|
||||
if errors.As(err, &refused) {
|
||||
p.mu.Lock()
|
||||
p.refused = true
|
||||
p.mu.Unlock()
|
||||
}
|
||||
return conn, err
|
||||
}
|
||||
|
||||
func (p *proxy) fail(w http.ResponseWriter, err error) {
|
||||
var refused *refusedError
|
||||
if errors.As(err, &refused) {
|
||||
http.Error(w, refused.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
http.Error(w, "连接目标失败", http.StatusBadGateway)
|
||||
}
|
||||
|
||||
func (p *proxy) tunnel(w http.ResponseWriter, r *http.Request) {
|
||||
upstream, err := p.connect(r.Context(), "tcp", r.Host)
|
||||
if err != nil {
|
||||
p.fail(w, err)
|
||||
return
|
||||
}
|
||||
client, buffered, err := http.NewResponseController(w).Hijack()
|
||||
if err != nil {
|
||||
_ = upstream.Close()
|
||||
return
|
||||
}
|
||||
t := &tunnel{close: sync.OnceFunc(func() {
|
||||
_ = client.Close()
|
||||
_ = upstream.Close()
|
||||
})}
|
||||
if !p.hold(t) {
|
||||
t.close()
|
||||
return
|
||||
}
|
||||
defer p.release(t)
|
||||
if _, err := io.WriteString(client, "HTTP/1.1 200 Connection established\r\n\r\n"); err != nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
_, _ = io.Copy(upstream, buffered)
|
||||
t.close()
|
||||
}()
|
||||
_, _ = io.Copy(client, upstream)
|
||||
}
|
||||
|
||||
func (p *proxy) hold(t *tunnel) bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.closed {
|
||||
return false
|
||||
}
|
||||
p.tunnels[t] = struct{}{}
|
||||
p.running.Add(1)
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *proxy) release(t *tunnel) {
|
||||
t.close()
|
||||
p.mu.Lock()
|
||||
delete(p.tunnels, t)
|
||||
p.mu.Unlock()
|
||||
p.running.Done()
|
||||
}
|
||||
|
||||
func (p *proxy) wasRefused() bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.refused
|
||||
}
|
||||
|
||||
func (p *proxy) Close() {
|
||||
_ = p.server.Close()
|
||||
p.mu.Lock()
|
||||
p.closed = true
|
||||
for t := range p.tunnels {
|
||||
t.close()
|
||||
}
|
||||
p.mu.Unlock()
|
||||
p.running.Wait()
|
||||
p.transport.CloseIdleConnections()
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
// worker 会请求用户提交的任意地址,因此 yt-dlp 的网络请求必须经过只连接公网地址的进程内代理;
|
||||
// ffmpeg 无论作为下载器还是后处理器都只能读取本地文件。
|
||||
package ytdlp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
const (
|
||||
// yt-dlp 从 PATH 中查找,它再从 PATH 中查找 ffmpeg、ffprobe 和 Deno。
|
||||
executable = "yt-dlp"
|
||||
waitDelay = 5 * time.Second
|
||||
outputLimit = 64 << 10
|
||||
reasonLimit = 300
|
||||
)
|
||||
|
||||
// 下载失败的原因,用作指标的标签。
|
||||
const (
|
||||
failureUnsupported = "unsupported"
|
||||
failureBlocked = "blocked"
|
||||
failureTimeout = "timeout"
|
||||
failureError = "error"
|
||||
)
|
||||
|
||||
type Downloader struct {
|
||||
root string
|
||||
resolver *net.Resolver
|
||||
cookies port.LinkCookies
|
||||
observe func(elapsed time.Duration, failure string)
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
var _ port.LinkDownloader = (*Downloader)(nil)
|
||||
|
||||
// observe 记下每次运行 yt-dlp 的耗时和失败的原因,成功时 failure 为空;任务被取消(worker 退出)的运行不记。
|
||||
// cookies 必须非 nil:没有 cookies 来源的调用方传恒返回 ok=false 的实现。
|
||||
func New(root string, resolver *net.Resolver, cookies port.LinkCookies, observe func(elapsed time.Duration, failure string), logger *slog.Logger) *Downloader {
|
||||
return &Downloader{root: root, resolver: resolver, cookies: cookies, observe: observe, logger: logger}
|
||||
}
|
||||
|
||||
func (d *Downloader) Download(ctx context.Context, link string) (port.LinkDownload, func(), error) {
|
||||
dir, err := os.MkdirTemp(d.root, "job-")
|
||||
if err != nil {
|
||||
return port.LinkDownload{}, nil, fmt.Errorf("创建临时目录:%w", err)
|
||||
}
|
||||
release := func() { _ = os.RemoveAll(dir) }
|
||||
// 用 defer 是为了 panic 时也删除目录:River 会恢复任务中的 panic,worker 继续运行。
|
||||
done := false
|
||||
defer func() {
|
||||
if !done {
|
||||
release()
|
||||
}
|
||||
}()
|
||||
result, err := d.download(ctx, link, dir)
|
||||
if err != nil {
|
||||
return port.LinkDownload{}, nil, err
|
||||
}
|
||||
done = true
|
||||
return result, release, nil
|
||||
}
|
||||
|
||||
func (d *Downloader) download(ctx context.Context, link, dir string) (port.LinkDownload, error) {
|
||||
cookies, releaseCookies, ok, err := d.cookies.CookieFile(ctx, link)
|
||||
if err != nil {
|
||||
return port.LinkDownload{}, err
|
||||
}
|
||||
if ok {
|
||||
// cookies 文件只在 yt-dlp 运行期间存在,下载结束(包括失败和取消)就删除。
|
||||
defer releaseCookies()
|
||||
}
|
||||
p, err := startProxy(dialPublic(d.resolver))
|
||||
if err != nil {
|
||||
return port.LinkDownload{}, fmt.Errorf("启动 yt-dlp 的代理:%w", err)
|
||||
}
|
||||
defer p.Close()
|
||||
start := time.Now()
|
||||
stdout, stderr, err := run(ctx, dir, args(link, dir, p.url, cookies))
|
||||
var result port.LinkDownload
|
||||
if err == nil {
|
||||
result, err = collect(dir, stdout)
|
||||
}
|
||||
if errors.Is(ctx.Err(), context.Canceled) {
|
||||
return port.LinkDownload{}, ctx.Err()
|
||||
}
|
||||
failure, err := classify(ctx, err, stderr, p.wasRefused())
|
||||
d.observe(time.Since(start), failure)
|
||||
switch failure {
|
||||
case "":
|
||||
return result, nil
|
||||
case failureTimeout, failureError:
|
||||
d.logger.WarnContext(ctx, "yt-dlp 下载失败", "track_id", link, "failure", failure, "stderr", string(stderr), "error", err)
|
||||
}
|
||||
return port.LinkDownload{}, err
|
||||
}
|
||||
|
||||
func args(link, dir, proxy, cookies string) []string {
|
||||
a := []string{
|
||||
// 配置文件可能带入 cookies、--exec 之类的选项。
|
||||
"--ignore-config",
|
||||
"--proxy", proxy,
|
||||
"--downloader", "native",
|
||||
// ffmpeg 的请求不经过代理,所以只允许它读本地文件。原生下载器处理不了时(比如加密的 HLS、直播),yt-dlp 会改用 ffmpeg 下载;
|
||||
// 后处理器也用 ffmpeg 处理下载到的文件,而文件可能引用网络上的地址。给后处理器的参数要写成「后处理器名+ffmpeg_i」,
|
||||
// 不带后处理器名的 ffmpeg_i 只对 ffmpeg 下载器生效。这里列出的后处理器会处理音频文件:转 mp3,以及 HLS 和 DASH 的封装修复。
|
||||
"--downloader-args", "ffmpeg_i:-protocol_whitelist file",
|
||||
"--postprocessor-args", "ExtractAudio+ffmpeg_i:-protocol_whitelist file",
|
||||
"--postprocessor-args", "FixupM3u8+ffmpeg_i:-protocol_whitelist file",
|
||||
"--postprocessor-args", "FixupM4a+ffmpeg_i:-protocol_whitelist file",
|
||||
// 来源文件里的标签是用户提交的内容,转码时 ffmpeg 默认把它们复制成 mp3 的标签,所以不复制。
|
||||
// 来源已经是 mp3 时 yt-dlp 不转码,文件连同标签原样保留,存储的音频仍然不可信。
|
||||
"--postprocessor-args", "ExtractAudio+ffmpeg_o:-map_metadata -1",
|
||||
// --no-playlist 只对同时指向视频和播放列表的链接有效;对播放列表本身,--playlist-items 1 只下载第一项。
|
||||
"--no-playlist", "--playlist-items", "1",
|
||||
"--format", "bestaudio/best",
|
||||
"--extract-audio", "--audio-format", "mp3",
|
||||
"--write-thumbnail",
|
||||
"--paths", dir,
|
||||
"--output", "audio.%(ext)s",
|
||||
"--output", "thumbnail:cover.%(ext)s",
|
||||
"--no-progress",
|
||||
"--print", "after_move:%(.{title,artist,uploader,album,duration})j",
|
||||
// 链接以 - 开头时会被当作选项。
|
||||
"--", link,
|
||||
}
|
||||
if cookies != "" {
|
||||
a = slices.Insert(a, 1, "--cookies", cookies)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func run(ctx context.Context, dir string, args []string) (stdout, stderr []byte, err error) {
|
||||
cmd := exec.CommandContext(ctx, executable, args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = environ()
|
||||
// yt-dlp 会启动 ffmpeg,超时或取消时杀掉整个进程组。
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) }
|
||||
cmd.WaitDelay = waitDelay
|
||||
out, errOut := &tail{limit: outputLimit}, &tail{limit: outputLimit}
|
||||
cmd.Stdout, cmd.Stderr = out, errOut
|
||||
err = cmd.Run()
|
||||
return out.buf, redactCookies(errOut.lines()), err
|
||||
}
|
||||
|
||||
// yt-dlp 读 cookies 文件出错时,会在这些说明后面打印出错的行或值,日志和任务错误里都不能出现。
|
||||
var cookieEcho = regexp.MustCompile(`(cookie file entry|Netscape format cookies file|could not convert string to float)[^\S\n]*\S[^\n]*`)
|
||||
|
||||
func redactCookies(stderr []byte) []byte {
|
||||
return cookieEcho.ReplaceAll(stderr, []byte("${1} [REDACTED]"))
|
||||
}
|
||||
|
||||
// yt-dlp 模拟浏览器时用 curl 发请求,而 curl 即使指定了代理,仍会按环境变量 no_proxy 绕过代理,所以去掉环境中的代理设置。
|
||||
func environ() []string {
|
||||
return slices.DeleteFunc(os.Environ(), func(kv string) bool {
|
||||
name, _, _ := strings.Cut(kv, "=")
|
||||
switch strings.ToLower(name) {
|
||||
case "http_proxy", "https_proxy", "all_proxy", "no_proxy":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
type tail struct {
|
||||
limit int
|
||||
buf []byte
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (t *tail) Write(p []byte) (int, error) {
|
||||
t.buf = append(t.buf, p...)
|
||||
if extra := len(t.buf) - t.limit; extra > 0 {
|
||||
t.buf = t.buf[extra:]
|
||||
t.cut = true
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// 截断后开头的半行认不出是不是 cookies 的回显,丢掉。
|
||||
func (t *tail) lines() []byte {
|
||||
if !t.cut {
|
||||
return t.buf
|
||||
}
|
||||
_, rest, _ := bytes.Cut(t.buf, []byte("\n"))
|
||||
return rest
|
||||
}
|
||||
|
||||
type info struct {
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
Uploader string `json:"uploader"`
|
||||
Album string `json:"album"`
|
||||
Duration *float64 `json:"duration"`
|
||||
}
|
||||
|
||||
var errNoAudio = errors.New("yt-dlp 没有下载到音频")
|
||||
|
||||
func collect(dir string, stdout []byte) (port.LinkDownload, error) {
|
||||
audio := filepath.Join(dir, "audio.mp3")
|
||||
if _, err := os.Stat(audio); err != nil {
|
||||
return port.LinkDownload{}, errNoAudio
|
||||
}
|
||||
var i info
|
||||
if err := json.Unmarshal(bytes.TrimSpace(stdout), &i); err != nil {
|
||||
return port.LinkDownload{}, fmt.Errorf("解析 yt-dlp 输出的元数据:%w", err)
|
||||
}
|
||||
d := port.LinkDownload{
|
||||
Meta: track.Meta{Title: i.Title, Artist: cmp.Or(i.Artist, i.Uploader), Album: i.Album},
|
||||
Audio: file(audio),
|
||||
}
|
||||
if i.Duration != nil {
|
||||
d.Meta.Duration = time.Duration(*i.Duration * float64(time.Second))
|
||||
}
|
||||
if covers, _ := filepath.Glob(filepath.Join(dir, "cover.*")); len(covers) == 1 {
|
||||
d.Cover, d.CoverExt = file(covers[0]), strings.ToLower(strings.TrimPrefix(filepath.Ext(covers[0]), "."))
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
type file string
|
||||
|
||||
func (f file) Open() (io.ReadSeekCloser, error) {
|
||||
r, err := os.Open(string(f))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func classify(ctx context.Context, err error, stderr []byte, refused bool) (string, error) {
|
||||
switch {
|
||||
case err == nil:
|
||||
return "", nil
|
||||
case ctx.Err() != nil:
|
||||
return failureTimeout, fmt.Errorf("yt-dlp 下载超时:%w", ctx.Err())
|
||||
case refused:
|
||||
return failureBlocked, &port.RejectedError{Err: errors.New("链接指向的地址不是公网地址,不能下载")}
|
||||
case errors.Is(err, errNoAudio) || bytes.Contains(stderr, []byte("ERROR: Unsupported URL: ")):
|
||||
return failureUnsupported, &port.RejectedError{Err: errors.New("yt-dlp 不支持这个链接")}
|
||||
}
|
||||
if message := lastError(stderr); message != "" {
|
||||
return failureError, fmt.Errorf("yt-dlp 下载失败:%s", message)
|
||||
}
|
||||
return failureError, fmt.Errorf("yt-dlp 下载失败:%w", err)
|
||||
}
|
||||
|
||||
func lastError(stderr []byte) string {
|
||||
lines := strings.Split(strings.TrimSpace(string(stderr)), "\n")
|
||||
for _, line := range slices.Backward(lines) {
|
||||
if message, ok := strings.CutPrefix(line, "ERROR: "); ok {
|
||||
if r := []rune(message); len(r) > reasonLimit {
|
||||
message = string(r[:reasonLimit]) + "…"
|
||||
}
|
||||
return message
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/identity"
|
||||
"github.com/dihemoe/alisten/internal/app/lobby"
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/app/realtime"
|
||||
"github.com/dihemoe/alisten/internal/domain/room"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDisabled = errors.New("后台没有启用:服务端没有配置管理 token。")
|
||||
ErrInvalidToken = errors.New("管理 token 无效")
|
||||
)
|
||||
|
||||
// 除 Authorize 外的方法都不检查凭证,调用方要先用 Authorize 校验管理 token。
|
||||
type Service struct {
|
||||
token string
|
||||
lobby *lobby.Service
|
||||
rooms port.RoomDirectory
|
||||
keys port.APIKeys
|
||||
credentials port.Credentials
|
||||
// 扫码登录成功时由音源保存登录状态,后台只得到二维码和扫码结果。
|
||||
netease port.NetEaseQRLoginSource
|
||||
qq port.QQQRLoginSource
|
||||
bilibili port.BilibiliQRLoginSource
|
||||
youtube port.YouTubeCookies
|
||||
gateway *realtime.Gateway
|
||||
}
|
||||
|
||||
func New(token string, lobbyService *lobby.Service, rooms port.RoomDirectory, keys port.APIKeys, credentials port.Credentials, netease port.NetEaseQRLoginSource, qq port.QQQRLoginSource, bilibili port.BilibiliQRLoginSource, youtube port.YouTubeCookies, gateway *realtime.Gateway) *Service {
|
||||
return &Service{token: token, lobby: lobbyService, rooms: rooms, keys: keys, credentials: credentials, netease: netease, qq: qq, bilibili: bilibili, youtube: youtube, gateway: gateway}
|
||||
}
|
||||
|
||||
func (s *Service) Authorize(token string) error {
|
||||
if s.token == "" {
|
||||
return ErrDisabled
|
||||
}
|
||||
// 比较哈希而不是原文:长度不同时 ConstantTimeCompare 立即返回,会泄露管理 token 的长度。
|
||||
want, got := sha256.Sum256([]byte(s.token)), sha256.Sum256([]byte(token))
|
||||
if subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
|
||||
return ErrInvalidToken
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ListRooms(ctx context.Context) ([]lobby.Room, error) {
|
||||
rooms, err := s.lobby.ListRooms(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return slices.DeleteFunc(rooms, func(r lobby.Room) bool { return r.Info.Kind != room.Persistent }), nil
|
||||
}
|
||||
|
||||
type NewRoom struct {
|
||||
Name string
|
||||
Description string
|
||||
// 空字符串表示没有别名。别名必须已经按格式校验:存储按格式区分 UUID 和别名,本身不做检查。
|
||||
Slug string
|
||||
Password string
|
||||
}
|
||||
|
||||
func (s *Service) CreateRoom(ctx context.Context, r NewRoom) (string, error) {
|
||||
hash, err := identity.HashPassword(ctx, r.Password)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.rooms.CreateRoom(ctx, port.NewRoom{
|
||||
Kind: room.Persistent,
|
||||
Name: r.Name,
|
||||
Description: r.Description,
|
||||
Slug: r.Slug,
|
||||
PasswordHash: hash,
|
||||
})
|
||||
}
|
||||
|
||||
// nil 表示不修改。Slug 或 Password 为空字符串表示取消别名或密码。
|
||||
type RoomChange struct {
|
||||
Name *string
|
||||
Description *string
|
||||
// 别名必须已经按格式校验:存储按格式区分 UUID 和别名,本身不做检查。
|
||||
Slug *string
|
||||
Password *string
|
||||
}
|
||||
|
||||
// id 必须是 UUID。名称和描述由房间修改,房间落盘之后才返回。设置或修改密码会作废房间成员的票据并断开会话和成员的事件流,
|
||||
// 这一步无法撤销,所以放在最后:前面任何一步失败时票据仍然有效,重试也不会再次作废重新签发的票据。
|
||||
// 断开的通知发不出去时返回错误,此时票据已经作废。
|
||||
// 别名在名称和描述之前修改,别名已被占用时不做任何修改。取消密码不影响已有的票据。
|
||||
func (s *Service) UpdateRoom(ctx context.Context, id string, c RoomChange) error {
|
||||
r, err := s.persistentRoom(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var hash *string
|
||||
if c.Password != nil {
|
||||
h, err := identity.HashPassword(ctx, *c.Password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hash = &h
|
||||
}
|
||||
if c.Slug != nil {
|
||||
if err := s.rooms.UpdateRoomAccess(ctx, r.Info.ID, port.AccessChange{Slug: c.Slug}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.Name != nil || c.Description != nil {
|
||||
if err := s.gateway.UpdateInfo(ctx, r.Info.ID, c.Name, c.Description); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if hash == nil {
|
||||
return nil
|
||||
}
|
||||
if err := s.rooms.UpdateRoomAccess(ctx, r.Info.ID, port.AccessChange{PasswordHash: hash}); err != nil {
|
||||
return err
|
||||
}
|
||||
if *hash == "" {
|
||||
return nil
|
||||
}
|
||||
return s.gateway.Revoke(ctx, r.Info.ID)
|
||||
}
|
||||
|
||||
// id 必须是 UUID。
|
||||
func (s *Service) DeleteRoom(ctx context.Context, id string) error {
|
||||
r, err := s.persistentRoom(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.gateway.Delete(ctx, r.Info.ID)
|
||||
}
|
||||
|
||||
// id 必须是 UUID。
|
||||
func (s *Service) RoomAPIKeyStatus(ctx context.Context, id string) (port.APIKeyStatus, error) {
|
||||
r, err := s.persistentRoom(ctx, id)
|
||||
if err != nil {
|
||||
return port.APIKeyStatus{}, err
|
||||
}
|
||||
return s.keys.RoomAPIKeyStatus(ctx, r.Info.ID)
|
||||
}
|
||||
|
||||
// id 必须是 UUID。还没有 Key 时生成,已经有时旧 Key 立即失效,用旧 Key 打开的事件流随之断开。
|
||||
// 断开事件流的通知发不出去时返回错误,此时 Key 已经重置。
|
||||
func (s *Service) ResetRoomAPIKey(ctx context.Context, id string) (string, error) {
|
||||
r, err := s.persistentRoom(ctx, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
key, k := identity.NewAPIKey()
|
||||
if err := s.keys.ResetRoomAPIKey(ctx, r.Info.ID, k); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.gateway.RevokeAPIKey(ctx, r.Info.ID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (s *Service) persistentRoom(ctx context.Context, id string) (port.RoomRecord, error) {
|
||||
r, err := s.rooms.FindRoom(ctx, id)
|
||||
if err != nil {
|
||||
return port.RoomRecord{}, err
|
||||
}
|
||||
if r.Info.Kind != room.Persistent {
|
||||
return port.RoomRecord{}, port.ErrRoomNotFound
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetYouTubeCookies(ctx context.Context, cookies string) error {
|
||||
normalized, err := normalizeCookies(cookies)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.youtube.SetCookies(ctx, normalized)
|
||||
}
|
||||
|
||||
func (s *Service) ClearYouTubeCookies(ctx context.Context) error {
|
||||
return s.youtube.SetCookies(ctx, "")
|
||||
}
|
||||
|
||||
func (s *Service) NeteaseCredentialStatus(ctx context.Context) (port.CredentialStatus, error) {
|
||||
cookie, err := s.credentials.NeteaseCookie(ctx)
|
||||
if err != nil {
|
||||
return port.CredentialStatus{}, err
|
||||
}
|
||||
status, err := credentialStatus(ctx, cookie, s.netease.CurrentAccount)
|
||||
if err != nil {
|
||||
return port.CredentialStatus{}, fmt.Errorf("查询网易云的账号资料:%w:%w", err, port.ErrProviderFailed)
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (s *Service) QQCredentialStatus(ctx context.Context) (port.CredentialStatus, error) {
|
||||
cookie, err := s.credentials.QQCookie(ctx)
|
||||
if err != nil {
|
||||
return port.CredentialStatus{}, err
|
||||
}
|
||||
status, err := credentialStatus(ctx, cookie, s.qq.CurrentAccount)
|
||||
if err != nil {
|
||||
return port.CredentialStatus{}, fmt.Errorf("查询 QQ 的账号资料:%w:%w", err, port.ErrProviderFailed)
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func credentialStatus(ctx context.Context, cookie string, current func(context.Context) (*port.SourceAccount, error)) (port.CredentialStatus, error) {
|
||||
if cookie == "" {
|
||||
return port.CredentialStatus{}, nil
|
||||
}
|
||||
account, err := current(ctx)
|
||||
if err != nil {
|
||||
return port.CredentialStatus{Set: true}, err
|
||||
}
|
||||
return port.CredentialStatus{Set: true, Account: account}, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateNeteaseQRLogin(ctx context.Context) (port.QRLoginSession, error) {
|
||||
session, err := s.netease.CreateQRLogin(ctx)
|
||||
if err != nil {
|
||||
return port.QRLoginSession{}, fmt.Errorf("生成网易云的登录二维码:%w:%w", err, port.ErrProviderFailed)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s *Service) CheckNeteaseQRLogin(ctx context.Context, key string) (port.QRLoginResult, error) {
|
||||
result, err := s.netease.CheckQRLogin(ctx, key)
|
||||
if err != nil {
|
||||
return port.QRLoginResult{}, fmt.Errorf("查询网易云的扫码结果:%w:%w", err, port.ErrProviderFailed)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateQQQRLogin(ctx context.Context, typ port.QQLoginType) (port.QRLoginSession, error) {
|
||||
session, err := s.qq.CreateQRLogin(ctx, typ)
|
||||
if err != nil {
|
||||
return port.QRLoginSession{}, fmt.Errorf("生成 QQ 的登录二维码:%w:%w", err, port.ErrProviderFailed)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s *Service) CheckQQQRLogin(ctx context.Context, typ port.QQLoginType, key string) (port.QRLoginResult, error) {
|
||||
result, err := s.qq.CheckQRLogin(ctx, typ, key)
|
||||
if err != nil {
|
||||
return port.QRLoginResult{}, fmt.Errorf("查询 QQ 的扫码结果:%w:%w", err, port.ErrProviderFailed)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) BilibiliCredentialStatus(ctx context.Context) (port.CredentialStatus, error) {
|
||||
status, err := s.bilibili.CredentialStatus(ctx)
|
||||
if err != nil {
|
||||
return port.CredentialStatus{}, fmt.Errorf("查询 B 站的登录状态:%w:%w", err, port.ErrProviderFailed)
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateBilibiliQRLogin(ctx context.Context) (port.QRLoginSession, error) {
|
||||
session, err := s.bilibili.CreateQRLogin(ctx)
|
||||
if err != nil {
|
||||
return port.QRLoginSession{}, fmt.Errorf("生成 B 站的登录二维码:%w:%w", err, port.ErrProviderFailed)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s *Service) CheckBilibiliQRLogin(ctx context.Context, key string) (port.QRLoginResult, error) {
|
||||
result, err := s.bilibili.CheckQRLogin(ctx, key)
|
||||
if err != nil {
|
||||
return port.QRLoginResult{}, fmt.Errorf("查询 B 站的扫码结果:%w:%w", err, port.ErrProviderFailed)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) YouTubeCookiesStatus(ctx context.Context) (port.YouTubeCookiesStatus, error) {
|
||||
status, err := s.youtube.Status(ctx)
|
||||
if err != nil {
|
||||
return port.YouTubeCookiesStatus{}, fmt.Errorf("查询 YouTube cookies 的状态:%w", err)
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 用户粘贴的内容无法识别为 cookies 时返回,文本直接展示给后台用户。
|
||||
var ErrUnrecognizedCookies = errors.New("不是能识别的 cookies:请粘贴扩展导出的 cookies.txt,或开发者工具里请求头 cookie 的值。")
|
||||
|
||||
// normalizeCookies 把用户粘贴的内容统一成 Netscape 格式:扩展导出的 cookies.txt 原样保存
|
||||
// (换行统一成 \n),开发者工具里复制的 Cookie 请求头逐条转换。数据库里只存 Netscape 格式。
|
||||
func normalizeCookies(input string) (string, error) {
|
||||
s := strings.TrimSpace(input)
|
||||
if s == "" {
|
||||
return "", ErrUnrecognizedCookies
|
||||
}
|
||||
if netscape, ok := normalizeNetscape(s); ok {
|
||||
return netscape, nil
|
||||
}
|
||||
if header, ok := convertCookieHeader(s); ok {
|
||||
return header, nil
|
||||
}
|
||||
return "", ErrUnrecognizedCookies
|
||||
}
|
||||
|
||||
// 空行和注释行原样保留但不校验;#HttpOnly_ 开头的是有效 cookie 行,不是注释。
|
||||
// 其余每行必须是制表符分隔的 7 列,且至少有一行。
|
||||
func normalizeNetscape(s string) (string, bool) {
|
||||
lines := strings.Split(s, "\n")
|
||||
valid := 0
|
||||
for i, line := range lines {
|
||||
line = strings.TrimSuffix(line, "\r")
|
||||
lines[i] = line
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") && !strings.HasPrefix(trimmed, "#HttpOnly_") {
|
||||
continue
|
||||
}
|
||||
if len(strings.Split(line, "\t")) != 7 {
|
||||
return "", false
|
||||
}
|
||||
valid++
|
||||
}
|
||||
if valid == 0 {
|
||||
return "", false
|
||||
}
|
||||
return strings.Join(lines, "\n"), true
|
||||
}
|
||||
|
||||
// Cookie 请求头只有一行:可以带不区分大小写的 Cookie: 前缀,内容是用 ; 分隔的 名字=值。
|
||||
// 统一转换成 .youtube.com 的 Secure cookie,一年后过期。
|
||||
func convertCookieHeader(s string) (string, bool) {
|
||||
if strings.ContainsAny(s, "\n\t") {
|
||||
return "", false
|
||||
}
|
||||
body := s
|
||||
if len(body) >= len("cookie:") && strings.EqualFold(body[:len("cookie:")], "cookie:") {
|
||||
body = strings.TrimSpace(body[len("cookie:"):])
|
||||
}
|
||||
expiry := strconv.FormatInt(time.Now().AddDate(1, 0, 0).Unix(), 10)
|
||||
var lines []string
|
||||
for _, part := range strings.Split(body, ";") {
|
||||
name, value, ok := strings.Cut(strings.TrimSpace(part), "=")
|
||||
if !ok || name == "" {
|
||||
return "", false
|
||||
}
|
||||
lines = append(lines, ".youtube.com\tTRUE\t/\tTRUE\t"+expiry+"\t"+name+"\t"+value)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return "", false
|
||||
}
|
||||
return "# Netscape HTTP Cookie File\n" + strings.Join(lines, "\n"), true
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/room"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
const (
|
||||
searchBurst = 10
|
||||
searchRefill = time.Minute
|
||||
// 最长等待与上游接口 30 秒的超时之和不超过客户端 60 秒的请求超时。
|
||||
maxSearchWait = 30 * time.Second
|
||||
storeTimeout = 10 * time.Second
|
||||
autoDJCandidates = 25
|
||||
autoDJLimit = 10
|
||||
)
|
||||
|
||||
var ErrRateLimited = errors.New("搜索过于频繁,请稍后再试。")
|
||||
|
||||
// RejectedLinkError 表示链接属于某个音源,但不能点播,例如 B 站的番剧和直播;它的文本就是返回给点歌人的原因。
|
||||
type RejectedLinkError struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *RejectedLinkError) Error() string { return e.Reason }
|
||||
|
||||
type Service struct {
|
||||
netease port.NetEaseSource
|
||||
qq port.Source
|
||||
bilibili port.BilibiliSource
|
||||
links port.BilibiliLinks
|
||||
tracks port.Tracks
|
||||
history port.PlayHistory
|
||||
rooms port.RoomDirectory
|
||||
quota port.SearchQuota
|
||||
}
|
||||
|
||||
func New(netease port.NetEaseSource, qq port.Source, bilibili port.BilibiliSource, links port.BilibiliLinks, tracks port.Tracks, history port.PlayHistory, rooms port.RoomDirectory, quota port.SearchQuota) *Service {
|
||||
return &Service{netease: netease, qq: qq, bilibili: bilibili, links: links, tracks: tracks, history: history, rooms: rooms, quota: quota}
|
||||
}
|
||||
|
||||
// SearchTracks 只在上游一次取回的结果中分页,page 从 1 开始;total 是取回的结果数。
|
||||
func (s *Service) SearchTracks(ctx context.Context, roomID string, source track.Source, keyword string, page, size int64) (songs []port.Song, total int64, err error) {
|
||||
src, err := s.searchable(source)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := s.take(ctx, roomID); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
all, err := src.SearchTracks(ctx, keyword)
|
||||
if err != nil {
|
||||
return nil, 0, upstream(source, "搜索歌曲", err)
|
||||
}
|
||||
return paginate(all, page, size), int64(len(all)), nil
|
||||
}
|
||||
|
||||
func (s *Service) SearchPlaylists(ctx context.Context, roomID string, source track.Source, keyword string, page, size int64) (playlists []port.Playlist, total int64, err error) {
|
||||
src, err := s.searchable(source)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := s.take(ctx, roomID); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
all, err := src.SearchPlaylists(ctx, keyword)
|
||||
if err != nil {
|
||||
return nil, 0, upstream(source, "搜索歌单", err)
|
||||
}
|
||||
return paginate(all, page, size), int64(len(all)), nil
|
||||
}
|
||||
|
||||
func (s *Service) PlaylistTracks(ctx context.Context, roomID string, source track.Source, playlistID string) ([]port.Song, error) {
|
||||
if source != track.NetEase {
|
||||
return nil, unsupported(source, "歌单内的歌曲")
|
||||
}
|
||||
if err := s.take(ctx, roomID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
songs, err := s.netease.PlaylistTracks(ctx, playlistID)
|
||||
if err != nil {
|
||||
return nil, upstream(source, "获取歌单内的歌曲", err)
|
||||
}
|
||||
return songs, nil
|
||||
}
|
||||
|
||||
func (s *Service) RecommendList(ctx context.Context, roomID string) ([]port.Song, error) {
|
||||
if err := s.take(ctx, roomID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
songs, err := s.candidates(ctx, roomID, true)
|
||||
if errors.Is(err, port.ErrProviderFailed) {
|
||||
return nil, nil
|
||||
}
|
||||
return songs, err
|
||||
}
|
||||
|
||||
func (s *Service) TrackMeta(ctx context.Context, t track.Track) (track.Meta, error) {
|
||||
lookup, err := s.metaSource(t.Source)
|
||||
if err != nil {
|
||||
return track.Meta{}, err
|
||||
}
|
||||
readCtx, cancel := context.WithTimeout(ctx, storeTimeout)
|
||||
p, ok, err := s.tracks.PreparedTrack(readCtx, t)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return track.Meta{}, err
|
||||
}
|
||||
if ok {
|
||||
return p.Meta, nil
|
||||
}
|
||||
m, err := lookup(ctx, t.ID)
|
||||
if err != nil {
|
||||
return track.Meta{}, upstream(t.Source, "查询元数据", err)
|
||||
}
|
||||
// B 站的适配器不填页面地址,平台的网址规则在领域中。
|
||||
if t.Source == track.Bilibili {
|
||||
m.PageURL = track.BilibiliPageURL(t.ID)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// B 站的链接转成 B 站音源的曲目,其他链接仍是链接曲目:链接准备完成后才有元数据,在此之前以链接本身作为标题。
|
||||
func (s *Service) ResolveLink(ctx context.Context, link string) (track.Track, track.Meta, error) {
|
||||
id, ok, err := s.links.Recognize(ctx, link)
|
||||
var rejected *port.RejectedError
|
||||
switch {
|
||||
case errors.As(err, &rejected):
|
||||
return track.Track{}, track.Meta{}, &RejectedLinkError{Reason: rejected.Error()}
|
||||
case err != nil:
|
||||
return track.Track{}, track.Meta{}, upstream(track.Bilibili, "识别链接", err)
|
||||
case !ok:
|
||||
return track.Track{Source: track.Link, ID: link}, track.Meta{Title: link, PageURL: link}, nil
|
||||
}
|
||||
if _, _, valid := track.ParseBilibiliID(id); !valid {
|
||||
return track.Track{}, track.Meta{}, fmt.Errorf("哔哩哔哩认出的曲目 ID %q 格式不对:%w", id, port.ErrProviderFailed)
|
||||
}
|
||||
t := track.Track{Source: track.Bilibili, ID: id}
|
||||
m, err := s.TrackMeta(ctx, t)
|
||||
return t, m, err
|
||||
}
|
||||
|
||||
// 按关键词点歌的这次搜索不消耗搜索额度。
|
||||
func (s *Service) FirstMatch(ctx context.Context, source track.Source, keyword string) (track.Track, track.Meta, error) {
|
||||
src, err := s.searchable(source)
|
||||
if err != nil {
|
||||
return track.Track{}, track.Meta{}, err
|
||||
}
|
||||
songs, err := src.SearchTracks(ctx, keyword)
|
||||
if err != nil {
|
||||
return track.Track{}, track.Meta{}, upstream(source, "搜索歌曲", err)
|
||||
}
|
||||
if len(songs) == 0 {
|
||||
return track.Track{}, track.Meta{}, fmt.Errorf("在%s搜索 %q 没有结果:%w", source, keyword, port.ErrProviderFailed)
|
||||
}
|
||||
return songs[0].Track, songs[0].Meta, nil
|
||||
}
|
||||
|
||||
func (s *Service) Recommend(ctx context.Context, roomID string) (track.Track, track.Meta, bool, error) {
|
||||
songs, err := s.candidates(ctx, roomID, false)
|
||||
if err != nil || len(songs) == 0 {
|
||||
return track.Track{}, track.Meta{}, false, err
|
||||
}
|
||||
pick := songs[rand.IntN(len(songs))]
|
||||
return pick.Track, pick.Meta, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) candidates(ctx context.Context, roomID string, includeQueue bool) ([]port.Song, error) {
|
||||
readCtx, cancel := context.WithTimeout(ctx, storeTimeout)
|
||||
defer cancel()
|
||||
seeds, err := s.history.AutoDJSeeds(readCtx, roomID, includeQueue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
excluded, err := s.history.AutoDJExclusions(readCtx, roomID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
skip := make(map[track.Track]bool, len(seeds)+len(excluded))
|
||||
for _, t := range seeds {
|
||||
skip[t] = true
|
||||
}
|
||||
for _, t := range excluded {
|
||||
skip[t] = true
|
||||
}
|
||||
var picks []port.Song
|
||||
for _, seed := range seeds {
|
||||
if len(picks) > autoDJCandidates {
|
||||
break
|
||||
}
|
||||
similar, err := s.netease.SimilarTracks(ctx, seed.ID)
|
||||
if err != nil {
|
||||
return nil, upstream(track.NetEase, "获取相似歌曲", err)
|
||||
}
|
||||
for _, song := range similar {
|
||||
if !skip[song.Track] {
|
||||
skip[song.Track] = true
|
||||
picks = append(picks, song)
|
||||
}
|
||||
}
|
||||
}
|
||||
return picks[:min(len(picks), autoDJLimit)], nil
|
||||
}
|
||||
|
||||
func (s *Service) take(ctx context.Context, roomID string) error {
|
||||
readCtx, cancel := context.WithTimeout(ctx, storeTimeout)
|
||||
r, err := s.rooms.FindRoom(readCtx, roomID)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Info.Kind == room.Persistent {
|
||||
return nil
|
||||
}
|
||||
for {
|
||||
wait, err := s.quota.TakeSearch(ctx, roomID, searchBurst, searchRefill)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if wait == 0 {
|
||||
return nil
|
||||
}
|
||||
if wait > maxSearchWait {
|
||||
return ErrRateLimited
|
||||
}
|
||||
timer := time.NewTimer(wait)
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) metaSource(source track.Source) (func(context.Context, string) (track.Meta, error), error) {
|
||||
switch source {
|
||||
case track.NetEase:
|
||||
return s.netease.TrackMeta, nil
|
||||
case track.QQ:
|
||||
return s.qq.TrackMeta, nil
|
||||
case track.Bilibili:
|
||||
return s.bilibili.TrackMeta, nil
|
||||
}
|
||||
return nil, unsupported(source, "点歌前查询元数据")
|
||||
}
|
||||
|
||||
func (s *Service) searchable(source track.Source) (port.Source, error) {
|
||||
switch source {
|
||||
case track.NetEase:
|
||||
return s.netease, nil
|
||||
case track.QQ:
|
||||
return s.qq, nil
|
||||
}
|
||||
return nil, unsupported(source, "搜索")
|
||||
}
|
||||
|
||||
func unsupported(source track.Source, what string) error {
|
||||
return fmt.Errorf("%s不支持%s:%w", source, what, port.ErrProviderFailed)
|
||||
}
|
||||
|
||||
func upstream(source track.Source, what string, err error) error {
|
||||
return fmt.Errorf("在%s%s:%w:%w", source, what, err, port.ErrProviderFailed)
|
||||
}
|
||||
|
||||
// page 从 1 开始,size 至少为 1;两者都来自客户端,乘积可能溢出。
|
||||
func paginate[T any](items []T, page, size int64) []T {
|
||||
n := int64(len(items))
|
||||
if page-1 > n/size {
|
||||
return nil
|
||||
}
|
||||
start := (page - 1) * size
|
||||
return items[start:min(start+size, n)]
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/app/realtime"
|
||||
"github.com/dihemoe/alisten/internal/domain/member"
|
||||
"github.com/dihemoe/alisten/internal/lib/logctx"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidMemberToken = errors.New("成员 token 无效")
|
||||
ErrInvalidTicket = errors.New("票据无效")
|
||||
ErrWrongPassword = errors.New("房间密码错误")
|
||||
ErrBotSession = errors.New("API Key 只能用于 HTTP 接口")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
members port.Members
|
||||
rooms port.RoomDirectory
|
||||
tickets port.Tickets
|
||||
gateway *realtime.Gateway
|
||||
}
|
||||
|
||||
func New(members port.Members, rooms port.RoomDirectory, tickets port.Tickets, gateway *realtime.Gateway) *Service {
|
||||
return &Service{members: members, rooms: rooms, tickets: tickets, gateway: gateway}
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
MemberToken string
|
||||
Member member.Member
|
||||
}
|
||||
|
||||
func (s *Service) CreateMember(ctx context.Context) (Session, error) {
|
||||
token, hash := newSecret()
|
||||
m, err := s.members.CreateMember(ctx, hash, guestName())
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
return Session{MemberToken: token, Member: m}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Authenticate(ctx context.Context, memberToken string) (member.Member, error) {
|
||||
if memberToken == "" {
|
||||
return member.Member{}, ErrInvalidMemberToken
|
||||
}
|
||||
m, err := s.members.MemberByToken(ctx, hashSecret(memberToken))
|
||||
if errors.Is(err, port.ErrMemberNotFound) {
|
||||
return member.Member{}, ErrInvalidMemberToken
|
||||
}
|
||||
return m, err
|
||||
}
|
||||
|
||||
type Profile struct {
|
||||
Name *string
|
||||
// 只用来计算头像哈希,不保存;空字符串表示清除头像。
|
||||
Email *string
|
||||
}
|
||||
|
||||
func (s *Service) UpdateProfile(ctx context.Context, memberID string, p Profile) (member.Member, error) {
|
||||
var avatar *string
|
||||
if p.Email != nil {
|
||||
hash := avatarHash(*p.Email)
|
||||
avatar = &hash
|
||||
}
|
||||
m, err := s.members.UpdateMember(ctx, memberID, p.Name, avatar)
|
||||
if err != nil {
|
||||
return member.Member{}, err
|
||||
}
|
||||
if err := s.gateway.MemberUpdated(ctx, m); err != nil {
|
||||
return member.Member{}, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *Service) IssueTicket(ctx context.Context, memberID, roomID, password string) (string, error) {
|
||||
r, err := s.rooms.FindRoom(ctx, roomID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if r.PasswordHash != "" {
|
||||
ok, err := checkPassword(ctx, r.PasswordHash, password)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "", ErrWrongPassword
|
||||
}
|
||||
}
|
||||
ticket, hash := newSecret()
|
||||
err = s.tickets.CreateTicket(ctx, port.NewTicket{TokenHash: hash, RoomID: r.Info.ID, MemberID: memberID, PasswordHash: r.PasswordHash})
|
||||
if errors.Is(err, port.ErrPasswordChanged) {
|
||||
// 刚才是按旧密码校验的;客户端重新输入密码后,按新密码校验。
|
||||
return "", ErrWrongPassword
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
func (s *Service) VerifyTicket(ctx context.Context, ticket string) (port.TicketHolder, error) {
|
||||
if ticket == "" {
|
||||
return port.TicketHolder{}, ErrInvalidTicket
|
||||
}
|
||||
holder, err := s.tickets.TicketHolder(ctx, hashSecret(ticket))
|
||||
if errors.Is(err, port.ErrTicketNotFound) {
|
||||
return port.TicketHolder{}, ErrInvalidTicket
|
||||
}
|
||||
return holder, err
|
||||
}
|
||||
|
||||
func (s *Service) EnterRoom(ctx context.Context, roomID, ticket string) (*realtime.Session, error) {
|
||||
r, err := s.rooms.FindRoom(ctx, roomID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
holder, err := s.VerifyTicket(ctx, ticket)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if holder.RoomID != r.Info.ID {
|
||||
return nil, ErrInvalidTicket
|
||||
}
|
||||
// bot 只用 HTTP 接口:它不出现在在线列表中,重置 Key 之后也不会留下用旧 Key 建立的会话。
|
||||
if holder.Bot {
|
||||
return nil, ErrBotSession
|
||||
}
|
||||
return s.gateway.Join(logctx.WithMember(logctx.WithRoom(ctx, r.Info.ID), holder.Member.ID), r.Info.ID, holder.Member)
|
||||
}
|
||||
|
||||
// 每次都准备好 bot 成员的 token:房间还没有 bot 成员时由存储用它创建,这个随机值不交给任何人。
|
||||
func NewAPIKey() (string, port.NewAPIKey) {
|
||||
key, hash := newSecret()
|
||||
_, botHash := newSecret()
|
||||
return key, port.NewAPIKey{TokenHash: hash, BotTokenHash: botHash}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
// 采用 OWASP 密码存储建议中 argon2id 的最低配置:任何成员都能请求换取票据,而每次校验都要分配这么多内存。
|
||||
const (
|
||||
argonTime = 2
|
||||
argonMemory = 19 * 1024 // KiB
|
||||
argonThreads = 1
|
||||
argonKeyLen = 32
|
||||
argonSaltLen = 16
|
||||
)
|
||||
|
||||
var passwordPrefix = fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$", argon2.Version, argonMemory, argonTime, argonThreads)
|
||||
|
||||
var errMalformedPasswordHash = errors.New("房间密码哈希的格式不正确")
|
||||
|
||||
// 每次计算都要分配 argonMemory 的内存,而创建临时房间和换取票据任何人都能请求:不限制并发时,几十个请求就能耗尽内存。argon2 是纯计算,并发超过 GOMAXPROCS 也不会更快。
|
||||
var argonSlots = make(chan struct{}, runtime.GOMAXPROCS(0))
|
||||
|
||||
func idKey(ctx context.Context, password string, salt []byte) ([]byte, error) {
|
||||
select {
|
||||
case argonSlots <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
defer func() { <-argonSlots }()
|
||||
return argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen), nil
|
||||
}
|
||||
|
||||
func HashPassword(ctx context.Context, password string) (string, error) {
|
||||
if password == "" {
|
||||
return "", nil
|
||||
}
|
||||
salt := make([]byte, argonSaltLen)
|
||||
rand.Read(salt)
|
||||
key, err := idKey(ctx, password, salt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return passwordPrefix + base64.RawStdEncoding.EncodeToString(salt) + "$" + base64.RawStdEncoding.EncodeToString(key), nil
|
||||
}
|
||||
|
||||
// 只接受按当前参数生成的哈希:存储中的密码哈希都由 HashPassword 生成。
|
||||
func checkPassword(ctx context.Context, hash, password string) (bool, error) {
|
||||
encoded, ok := strings.CutPrefix(hash, passwordPrefix)
|
||||
if !ok {
|
||||
return false, errMalformedPasswordHash
|
||||
}
|
||||
saltText, keyText, ok := strings.Cut(encoded, "$")
|
||||
if !ok {
|
||||
return false, errMalformedPasswordHash
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.DecodeString(saltText)
|
||||
if err != nil {
|
||||
return false, errMalformedPasswordHash
|
||||
}
|
||||
key, err := base64.RawStdEncoding.DecodeString(keyText)
|
||||
if err != nil || len(key) != argonKeyLen {
|
||||
return false, errMalformedPasswordHash
|
||||
}
|
||||
got, err := idKey(ctx, password, salt)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return subtle.ConstantTimeCompare(got, key) == 1, nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 成员 token 和票据都是 256 位随机值,存储中只有它们的 SHA-256。
|
||||
func newSecret() (string, []byte) {
|
||||
b := make([]byte, 32)
|
||||
// crypto/rand.Read 不会返回错误。
|
||||
rand.Read(b)
|
||||
secret := base64.RawURLEncoding.EncodeToString(b)
|
||||
return secret, hashSecret(secret)
|
||||
}
|
||||
|
||||
func hashSecret(secret string) []byte {
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
func guestName() string {
|
||||
b := make([]byte, 2)
|
||||
rand.Read(b)
|
||||
return "游客-" + hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Gravatar 用去掉首尾空白、转为小写的邮箱计算哈希。
|
||||
func avatarHash(email string) string {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
if email == "" {
|
||||
return ""
|
||||
}
|
||||
sum := md5.Sum([]byte(email))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package lobby
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/identity"
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/app/realtime"
|
||||
"github.com/dihemoe/alisten/internal/domain/room"
|
||||
)
|
||||
|
||||
// 对外只表明房间是否有密码,不暴露密码哈希。
|
||||
type Room struct {
|
||||
Info room.Info
|
||||
Slug string
|
||||
HasPassword bool
|
||||
Online int
|
||||
}
|
||||
|
||||
type NewRoom struct {
|
||||
Name string
|
||||
Description string
|
||||
Password string
|
||||
}
|
||||
|
||||
const onlineTimeout = 5 * time.Second
|
||||
|
||||
type Service struct {
|
||||
rooms port.RoomDirectory
|
||||
online port.OnlineCounts
|
||||
}
|
||||
|
||||
func New(rooms port.RoomDirectory, online port.OnlineCounts) *Service {
|
||||
return &Service{rooms: rooms, online: online}
|
||||
}
|
||||
|
||||
func (s *Service) ListRooms(ctx context.Context) ([]Room, error) {
|
||||
records, err := s.rooms.ListRooms(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.listed(ctx, records)
|
||||
}
|
||||
|
||||
// ref 必须是 UUID 或合法别名的形式:存储按形式区分两者,而且含有 U+0000 的文本会让 PostgreSQL 报错。
|
||||
func (s *Service) FindRoom(ctx context.Context, ref string) (Room, error) {
|
||||
r, err := s.rooms.FindRoom(ctx, ref)
|
||||
if err != nil {
|
||||
return Room{}, err
|
||||
}
|
||||
rooms, err := s.listed(ctx, []port.RoomRecord{r})
|
||||
if err != nil {
|
||||
return Room{}, err
|
||||
}
|
||||
return rooms[0], nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateRoom(ctx context.Context, r NewRoom) (string, error) {
|
||||
hash, err := identity.HashPassword(ctx, r.Password)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.rooms.CreateRoom(ctx, port.NewRoom{
|
||||
Kind: room.Ephemeral,
|
||||
Name: r.Name,
|
||||
Description: r.Description,
|
||||
PasswordHash: hash,
|
||||
})
|
||||
}
|
||||
|
||||
// 在线人数读不出来时不能当作 0,整个请求按 UNAVAILABLE 失败。
|
||||
func (s *Service) listed(ctx context.Context, records []port.RoomRecord) ([]Room, error) {
|
||||
ids := make([]string, len(records))
|
||||
for i, r := range records {
|
||||
ids[i] = r.Info.ID
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, onlineTimeout)
|
||||
defer cancel()
|
||||
counts, err := s.online.OnlineCounts(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w:%w", realtime.ErrUnavailable, err)
|
||||
}
|
||||
rooms := make([]Room, len(records))
|
||||
for i, r := range records {
|
||||
rooms[i] = Room{Info: r.Info, Slug: r.Slug, HasPassword: r.PasswordHash != "", Online: counts[i]}
|
||||
}
|
||||
return rooms, nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("媒体不存在")
|
||||
ErrInUse = errors.New("这首歌还在房间的播放列表中,不能删除")
|
||||
)
|
||||
|
||||
type Library struct {
|
||||
media port.MediaLibrary
|
||||
usage port.TrackUsage
|
||||
store port.MediaStore
|
||||
}
|
||||
|
||||
func NewLibrary(media port.MediaLibrary, usage port.TrackUsage, store port.MediaStore) *Library {
|
||||
return &Library{media: media, usage: usage, store: store}
|
||||
}
|
||||
|
||||
func (l *Library) List(ctx context.Context) ([]port.StoredMedia, error) {
|
||||
return l.media.ListStoredMedia(ctx)
|
||||
}
|
||||
|
||||
// 先删除对象,再清除准备结果:中途失败时准备结果还在,后台仍能看到这首歌并重试;
|
||||
// 音频已经不在的准备结果也不会被使用。
|
||||
func (l *Library) Delete(ctx context.Context, key string) error {
|
||||
m, ok, err := l.media.StoredMedia(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
used, err := l.usage.TrackInUse(ctx, track.Track{Source: m.Source, ID: m.Link})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if used {
|
||||
return ErrInUse
|
||||
}
|
||||
objects := []string{AudioObject(m.Key)}
|
||||
if m.CoverKey != "" {
|
||||
objects = append(objects, string(m.CoverKey))
|
||||
}
|
||||
for _, object := range objects {
|
||||
if err := l.store.Delete(ctx, object); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return l.media.DeleteStoredMedia(ctx, m.Key)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
// 链接和 B 站的歌在准备时下载,音频和封面存进对象存储,浏览器从对象存储读取。
|
||||
func Stored(s track.Source) bool { return s == track.Link || s == track.Bilibili }
|
||||
|
||||
func Key(link string) string {
|
||||
sum := sha256.Sum256([]byte(link))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// 链接和 B 站的歌的对象都在 link/ 下:对象存储的匿名读取策略、nginx 和 Traefik 都只放行这里的对象。
|
||||
func AudioObject(key string) string { return "link/" + key + "/audio.mp3" }
|
||||
|
||||
func CoverObject(key, ext string) string { return "link/" + key + "/cover." + ext }
|
||||
@@ -0,0 +1,133 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/url"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
const (
|
||||
resolveTimeout = 30 * time.Second
|
||||
// 各平台播放地址的有效期未知,1 分钟是保守的取值。
|
||||
reuseWindow = time.Minute
|
||||
reuseLimit = 4096
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoPlayback = errors.New("没有接入这个音源的播放地址")
|
||||
ErrNotInRoom = errors.New("这首歌不在任何房间中")
|
||||
)
|
||||
|
||||
type Playback struct {
|
||||
sources map[track.Source]port.PlayURLs
|
||||
usage port.TrackUsage
|
||||
calls singleflight.Group
|
||||
|
||||
mu sync.Mutex
|
||||
resolved map[track.Track]resolvedURL
|
||||
}
|
||||
|
||||
type resolvedURL struct {
|
||||
url string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
func NewPlayback(sources map[track.Source]port.PlayURLs, usage port.TrackUsage) *Playback {
|
||||
return &Playback{sources: sources, usage: usage, resolved: map[track.Track]resolvedURL{}}
|
||||
}
|
||||
|
||||
// 只为至少出现在一个房间的条目中的歌解析:/media 不需要凭证,登录的账号不能被用来解析任意的歌。这项检查每次都做,
|
||||
// 通过之后才复用 1 分钟内取到的地址。返回的地址总是 https:页面是 https 的,浏览器会拦截 http 的音频。
|
||||
func (p *Playback) URL(ctx context.Context, t track.Track, now time.Time) (string, error) {
|
||||
source, ok := p.sources[t.Source]
|
||||
if !ok {
|
||||
return "", ErrNoPlayback
|
||||
}
|
||||
inRoom, err := p.usage.TrackInUse(ctx, t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !inRoom {
|
||||
return "", ErrNotInRoom
|
||||
}
|
||||
if target, ok := p.reuse(t, now); ok {
|
||||
return target, nil
|
||||
}
|
||||
// 合并的解析不随发起它的请求取消:同时等待的其他请求还要用它的结果。
|
||||
result := p.calls.DoChan(string(t.Source)+"/"+t.ID, func() (v any, err error) {
|
||||
// 解析在独立的 goroutine 中执行,panic 回不到处理请求的 goroutine,net/http 无法恢复它,会让进程崩溃。
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = &panicError{value: r, stack: debug.Stack()}
|
||||
}
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), resolveTimeout)
|
||||
defer cancel()
|
||||
return source.PlayURL(ctx, t.ID)
|
||||
})
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case r := <-result:
|
||||
var panicked *panicError
|
||||
if errors.As(r.Err, &panicked) {
|
||||
// 音源实现中的 panic 是程序缺陷,不是音源出错:交给调用方当作内部错误记录。
|
||||
return "", panicked
|
||||
}
|
||||
if r.Err != nil {
|
||||
return "", fmt.Errorf("解析 %s %s 的播放地址:%w:%w", t.Source, t.ID, r.Err, port.ErrProviderFailed)
|
||||
}
|
||||
target, err := secure(r.Val.(string))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p.remember(t, target, now)
|
||||
return target, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Playback) reuse(t track.Track, now time.Time) (string, bool) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
r, ok := p.resolved[t]
|
||||
return r.url, ok && now.Before(r.expires)
|
||||
}
|
||||
|
||||
func (p *Playback) remember(t track.Track, target string, now time.Time) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
maps.DeleteFunc(p.resolved, func(_ track.Track, r resolvedURL) bool { return !now.Before(r.expires) })
|
||||
if _, ok := p.resolved[t]; !ok && len(p.resolved) >= reuseLimit {
|
||||
return
|
||||
}
|
||||
p.resolved[t] = resolvedURL{url: target, expires: now.Add(reuseWindow)}
|
||||
}
|
||||
|
||||
type panicError struct {
|
||||
value any
|
||||
stack []byte
|
||||
}
|
||||
|
||||
func (e *panicError) Error() string {
|
||||
return fmt.Sprintf("解析播放地址时 panic:%v\n%s", e.value, e.stack)
|
||||
}
|
||||
|
||||
// 只替换 scheme,其余部分原样保留:签名过的地址重新编码之后可能失效。
|
||||
func secure(raw string) (string, error) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {
|
||||
return "", fmt.Errorf("音源给出的播放地址不是 http 或 https 的绝对地址:%w", port.ErrProviderFailed)
|
||||
}
|
||||
return "https" + raw[strings.Index(raw, ":"):], nil
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/room"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
// 接管房间的期限是 10 秒,检查链接和 B 站的歌的音频只占用其中一部分,对象存储变慢时接管仍能完成。
|
||||
const restoreCheckTimeout = 5 * time.Second
|
||||
|
||||
// Tracks 读取准备结果时,链接和 B 站的歌还要求音频仍在对象存储中:音频被清理或丢失之后,这首歌要重新下载。
|
||||
type Tracks struct {
|
||||
port.Tracks
|
||||
store port.MediaStore
|
||||
}
|
||||
|
||||
func NewTracks(tracks port.Tracks, store port.MediaStore) *Tracks {
|
||||
return &Tracks{Tracks: tracks, store: store}
|
||||
}
|
||||
|
||||
// 检查对象存储失败时返回错误,调用方稍后重试,不当作没有结果:否则对象存储短暂不可用时,已经存储的歌都会重新下载。
|
||||
func (t *Tracks) PreparedTrack(ctx context.Context, tr track.Track) (port.PreparedTrack, bool, error) {
|
||||
p, ok, err := t.Tracks.PreparedTrack(ctx, tr)
|
||||
if err != nil || !ok || !Stored(tr.Source) {
|
||||
return p, ok, err
|
||||
}
|
||||
exists, err := t.store.Exists(ctx, AudioObject(p.MediaKey))
|
||||
if err != nil {
|
||||
return port.PreparedTrack{}, false, fmt.Errorf("检查 %s 的音频是否还在对象存储中:%w", tr.ID, err)
|
||||
}
|
||||
if !exists {
|
||||
return port.PreparedTrack{}, false, nil
|
||||
}
|
||||
return p, true, nil
|
||||
}
|
||||
|
||||
// 只检查待播和等待队列:正在播放的歌已经开播。
|
||||
type Snapshots struct {
|
||||
port.RoomSnapshots
|
||||
store port.MediaStore
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewSnapshots(snapshots port.RoomSnapshots, store port.MediaStore, logger *slog.Logger) *Snapshots {
|
||||
return &Snapshots{RoomSnapshots: snapshots, store: store, logger: logger}
|
||||
}
|
||||
|
||||
// 对象存储出错时,其余条目按曲目表恢复:对象存储不可用时房间仍要能被接管。任期已经加 1,所以这里不再返回错误。
|
||||
func (s *Snapshots) TakeRoom(ctx context.Context, id string) (uint32, room.Snapshot, error) {
|
||||
term, snap, err := s.RoomSnapshots.TakeRoom(ctx, id)
|
||||
if err != nil {
|
||||
return term, snap, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, restoreCheckTimeout)
|
||||
defer cancel()
|
||||
entries := make([]*room.Entry, 0, len(snap.Waiting)+1)
|
||||
if snap.Pending != nil {
|
||||
entries = append(entries, snap.Pending)
|
||||
}
|
||||
for i := range snap.Waiting {
|
||||
entries = append(entries, &snap.Waiting[i])
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.Ready || !Stored(e.Track.Source) {
|
||||
continue
|
||||
}
|
||||
exists, err := s.store.Exists(ctx, AudioObject(Key(e.Track.ID)))
|
||||
if err != nil {
|
||||
s.logger.WarnContext(ctx, "检查曲目的音频失败,其余条目按曲目表恢复", "source", e.Track.Source, "track_id", e.Track.ID, "error", err)
|
||||
break
|
||||
}
|
||||
if !exists {
|
||||
e.Ready, e.Prep = false, track.Prepared{}
|
||||
}
|
||||
}
|
||||
return term, snap, nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 常驻房间的 API Key 是房间 bot 成员的票据。
|
||||
type NewAPIKey struct {
|
||||
TokenHash []byte
|
||||
// 房间还没有 bot 成员时用它创建:对应的随机值不交给任何人,bot 因此没有可用的成员 token。
|
||||
BotTokenHash []byte
|
||||
}
|
||||
|
||||
type APIKeyStatus struct {
|
||||
Set bool
|
||||
// Key 的生成时间,Set 为 false 时为零值。
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// roomID 必须是常驻房间。每个房间至多一把 Key;bot 成员在第一次生成时创建,此后一直不变。
|
||||
type APIKeys interface {
|
||||
RoomAPIKeyStatus(ctx context.Context, roomID string) (APIKeyStatus, error)
|
||||
// 还没有 Key 时生成,已经有时旧 Key 立即失效。
|
||||
ResetRoomAPIKey(ctx context.Context, roomID string, k NewAPIKey) error
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
type BilibiliSource interface {
|
||||
// CoverURL 和 PageURL 留空:平台的图片有防盗链,封面在准备时下载存储;页面地址由调用方按 ID 拼出。
|
||||
TrackMeta(ctx context.Context, id string) (track.Meta, error)
|
||||
// 读完文件之后调用 release 删除它们;返回错误时没有要删除的文件。视频不存在、需要付费或地区受限时返回 *RejectedError。
|
||||
Download(ctx context.Context, id string) (d BilibiliDownload, release func(), err error)
|
||||
}
|
||||
|
||||
// 与 LinkDownload 不同,Audio 是平台给的原始文件,可能还带着视频,要转成 mp3 才能分析和上传。
|
||||
type BilibiliDownload struct {
|
||||
// 与 TrackMeta 的结果相同。
|
||||
Meta track.Meta
|
||||
Audio LinkFile
|
||||
// 封面文件和它的扩展名(小写,不带点),没有封面时都为空。
|
||||
Cover LinkFile
|
||||
CoverExt string
|
||||
}
|
||||
|
||||
// 不是 B 站的链接时 ok 为 false、err 为 nil;是 B 站的链接但不是视频(番剧、直播、个人空间等)时返回 *RejectedError。
|
||||
type BilibiliLinks interface {
|
||||
Recognize(ctx context.Context, link string) (id string, ok bool, err error)
|
||||
}
|
||||
|
||||
type BilibiliQRLoginSource interface {
|
||||
CreateQRLogin(context.Context) (QRLoginSession, error)
|
||||
CheckQRLogin(context.Context, string) (QRLoginResult, error)
|
||||
// 是否已登录也由实现判断,凭据不离开适配器。
|
||||
CredentialStatus(context.Context) (CredentialStatus, error)
|
||||
}
|
||||
|
||||
// 只读本地文件,不访问网络。读完 mp3 之后调用 release 删除它;返回错误时没有要删除的文件。
|
||||
type Transcoder interface {
|
||||
ToMP3(ctx context.Context, audio LinkFile) (mp3 LinkFile, release func(), err error)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/domain/member"
|
||||
"github.com/dihemoe/alisten/internal/domain/room"
|
||||
)
|
||||
|
||||
// owner 按产生的顺序发布,订阅的网关按同样的顺序收到。
|
||||
type RoomMessage interface{ roomMessage() }
|
||||
|
||||
type (
|
||||
// 房间开始了新的一次运行:在线列表为空,事件重新编号。各网关要为本地会话重新发送 Join,为事件流重新获取快照。
|
||||
OwnerChanged struct {
|
||||
Term uint32
|
||||
}
|
||||
|
||||
Emitted struct {
|
||||
Emit room.Emit
|
||||
}
|
||||
|
||||
Replied struct {
|
||||
Reply room.Reply
|
||||
}
|
||||
|
||||
// 请求没有被房间处理,之后也不会有回复。
|
||||
Rejected struct {
|
||||
RequestID room.RequestID
|
||||
// 为真时房间不存在,否则是房间暂时不可用。
|
||||
NotFound bool
|
||||
}
|
||||
|
||||
// 房间的数据已经删除,它的会话和事件流要以 4404 断开。
|
||||
RoomClosed struct{}
|
||||
|
||||
OwnerReleased struct{}
|
||||
|
||||
// 房间成员的票据已经作废,会话和成员打开的事件流要以 4401 断开,用 API Key 打开的事件流不受影响。由管理接口发布。
|
||||
TicketsRevoked struct{}
|
||||
|
||||
// 常驻房间的 API Key 已经重置,用 Key 打开的事件流要以 4401 断开,成员的会话和事件流不受影响。由管理接口发布。
|
||||
APIKeyReset struct{}
|
||||
)
|
||||
|
||||
func (OwnerChanged) roomMessage() {}
|
||||
func (Emitted) roomMessage() {}
|
||||
func (Replied) roomMessage() {}
|
||||
func (Rejected) roomMessage() {}
|
||||
func (RoomClosed) roomMessage() {}
|
||||
func (OwnerReleased) roomMessage() {}
|
||||
func (TicketsRevoked) roomMessage() {}
|
||||
func (APIKeyReset) roomMessage() {}
|
||||
|
||||
type RoomInput struct {
|
||||
Room string
|
||||
// 发出输入的网关的实例 ID:owner 据此记录会话所在的网关。
|
||||
Gateway string
|
||||
// Input 与 Patch 恰好有一项不为空。
|
||||
Input room.Input
|
||||
Patch *InfoPatch
|
||||
// 为真时,如果接收输入的实例上没有运行这个房间,就由它接管。
|
||||
Takeover bool
|
||||
}
|
||||
|
||||
// nil 表示保持不变:管理接口只修改给出的字段,由房间按当前的名称和描述补全为 UpdateInfo。
|
||||
type InfoPatch struct {
|
||||
RequestID room.RequestID
|
||||
Name *string
|
||||
Description *string
|
||||
}
|
||||
|
||||
type Delivery interface{ delivery() }
|
||||
|
||||
type (
|
||||
RoomDelivery struct {
|
||||
Room string
|
||||
Message RoomMessage
|
||||
}
|
||||
|
||||
InputDelivery struct {
|
||||
// 带有网关一侧的追踪上下文,owner 在同一条 trace 中处理这条输入;网关一侧没有追踪时为 context.Background()。
|
||||
Context context.Context
|
||||
Input RoomInput
|
||||
}
|
||||
|
||||
MemberDelivery struct {
|
||||
Member member.Member
|
||||
}
|
||||
|
||||
PreparedDelivery struct {
|
||||
Outcome PrepareOutcome
|
||||
}
|
||||
|
||||
// 订阅连接曾经断开,断开期间发给本实例的消息都已丢失。
|
||||
SubscriptionLost struct{}
|
||||
)
|
||||
|
||||
func (RoomDelivery) delivery() {}
|
||||
func (InputDelivery) delivery() {}
|
||||
func (MemberDelivery) delivery() {}
|
||||
func (PreparedDelivery) delivery() {}
|
||||
func (SubscriptionLost) delivery() {}
|
||||
|
||||
// 实现绑定在本实例上:订阅的是本实例的频道,发出的消息带有本实例的 ID。
|
||||
type Bus interface {
|
||||
// 订阅本实例的频道、成员资料的频道和准备结果的频道,返回时订阅已经生效。
|
||||
SubscribeInstance(ctx context.Context) error
|
||||
// 返回时订阅已经生效:此后发布到这个房间频道的消息都会收到。
|
||||
SubscribeRoom(ctx context.Context, roomID string) error
|
||||
UnsubscribeRoom(ctx context.Context, roomID string) error
|
||||
PublishRoom(ctx context.Context, roomID string, m RoomMessage) error
|
||||
// 返回 false 表示目标实例没有订阅它的频道,例如已经停止运行。
|
||||
SendInput(ctx context.Context, instanceID string, in RoomInput) (bool, error)
|
||||
PublishMember(ctx context.Context, m member.Member) error
|
||||
// 各实例都会收到,包括发布的实例。
|
||||
PublishPrepared(ctx context.Context, o PrepareOutcome) error
|
||||
// 按收到的顺序返回下一条消息,直到 ctx 结束;ctx 结束之后 Bus 不能再使用。只能由一个 goroutine 调用。
|
||||
Receive(ctx context.Context) (Delivery, error)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
var ErrProviderFailed = errors.New("音乐平台没有正常响应,请稍后再试。")
|
||||
|
||||
// 房间宿主调用 Prepare 时,ctx 在房间停止运行时取消,结果不再交回房间;
|
||||
// 实现要自己为每次对外请求设置期限。
|
||||
type Preparer interface {
|
||||
// 返回错误表示准备失败,错误的文本就是广播给房间的失败原因。
|
||||
Prepare(ctx context.Context, t track.Track) (track.Meta, track.Prepared, error)
|
||||
// 各实例收到 tracks.prepared 频道上的结果时调用,不能阻塞:调用方是接收实例间消息的 goroutine。
|
||||
Finished(o PrepareOutcome)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Leases 是房间的 owner 租约,值是实例 ID。实现绑定在本实例上:取得、续期和释放的都是本实例的租约。
|
||||
// 房间的在线人数附在租约上,与租约一起续期、一起过期。
|
||||
type Leases interface {
|
||||
// 租约不存在或已经属于本实例时取得它,在线人数记为 0;返回 false 表示租约属于其他实例。
|
||||
Acquire(ctx context.Context, roomID string, ttl time.Duration) (bool, error)
|
||||
// 租约仍属于本实例时续期;返回 false 表示租约已不属于本实例。
|
||||
Renew(ctx context.Context, roomID string, ttl time.Duration) (bool, error)
|
||||
// 租约仍属于本实例时写入在线人数;返回 false 表示租约已不属于本实例。
|
||||
SetOnline(ctx context.Context, roomID string, online int) (bool, error)
|
||||
// 只删除属于本实例的租约。
|
||||
Release(ctx context.Context, roomID string) error
|
||||
// 按 roomIDs 的顺序返回各房间的 owner,没有 owner 时为空字符串。
|
||||
Owners(ctx context.Context, roomIDs []string) ([]string, error)
|
||||
}
|
||||
|
||||
type OnlineCounts interface {
|
||||
// 按 roomIDs 的顺序返回各房间的在线人数,没有 owner 的房间为 0。
|
||||
OnlineCounts(ctx context.Context, roomIDs []string) ([]int, error)
|
||||
}
|
||||
|
||||
// Liveness 是各实例的存活键。实现绑定在本实例上:Heartbeat 刷新的是本实例的存活键。
|
||||
type Liveness interface {
|
||||
// 返回 false 表示刷新之前存活键已经不存在:owner 可能已经让本实例的会话离开了在线列表。
|
||||
Heartbeat(ctx context.Context, ttl time.Duration) (bool, error)
|
||||
Expired(ctx context.Context, instanceIDs []string) ([]string, error)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 凭据未设置时,读取方法都返回空字符串。写入方法在具体的 Store 上,与网易云、QQ 的 SetCookie 相同。
|
||||
type Credentials interface {
|
||||
NeteaseCookie(ctx context.Context) (string, error)
|
||||
QQCookie(ctx context.Context) (string, error)
|
||||
BilibiliCredential(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
type CredentialStatus struct {
|
||||
Set bool
|
||||
Account *SourceAccount
|
||||
}
|
||||
|
||||
type YouTubeCookies interface {
|
||||
// cookies 为空字符串时删除已保存的 cookies。
|
||||
SetCookies(context.Context, string) error
|
||||
Status(context.Context) (YouTubeCookiesStatus, error)
|
||||
}
|
||||
|
||||
type YouTubeCookiesStatus struct {
|
||||
Set bool
|
||||
// 最近一次填写的时间,Set 为 false 时为零值。
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
type PlayHistory interface {
|
||||
// 本房间最近 32 首不同的、由成员点播的网易云歌曲:先是正在播放的条目,includeQueue 时接着是待播和
|
||||
// 等待队列中的条目,然后是按开播时间从近到远的播放记录。
|
||||
AutoDJSeeds(ctx context.Context, roomID string, includeQueue bool) ([]track.Track, error)
|
||||
// 本房间最近 128 首由 AutoDJ 加入的歌(包括正在播放的条目),加上待播和等待队列中的全部条目。
|
||||
AutoDJExclusions(ctx context.Context, roomID string) ([]track.Track, error)
|
||||
}
|
||||
|
||||
type TrackUsage interface {
|
||||
TrackInUse(ctx context.Context, t track.Track) (bool, error)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
type JobState uint8
|
||||
|
||||
const (
|
||||
// 等待执行、正在执行或者等待重试。
|
||||
JobPending JobState = iota + 1
|
||||
// 已完成或者已被清理,不会再执行;成功的结果已经写入曲目表。
|
||||
JobFinished
|
||||
// 被取消或者重试之后仍然失败,不会再执行。
|
||||
JobFailed
|
||||
)
|
||||
|
||||
// 任务重试用完时告诉用户的原因。原因会出现在房间动态里,也会被 bot 转发;最后一次的错误可能是下载工具的输出或 IO 错误,
|
||||
// 只写进日志。音源拒绝时的原因本来就是写给用户的,照旧使用。
|
||||
const UnavailableReason = "暂时无法获取这首歌,可以稍后再点一次"
|
||||
|
||||
// Reason 为空表示成功,结果已经写入曲目表。
|
||||
type PrepareOutcome struct {
|
||||
Track track.Track
|
||||
JobID int64
|
||||
Reason string
|
||||
}
|
||||
|
||||
// 准备任务按曲目去重:同一首歌同一时刻只有一个未结束的任务。
|
||||
type PrepareJobs interface {
|
||||
// 这首歌已有未结束的任务时,返回那个任务的 ID。
|
||||
Submit(ctx context.Context, t track.Track) (jobID int64, err error)
|
||||
// 任务失败时 reason 是写给用户的原因:被取消(音源拒绝)时是拒绝的原因,重试用完时是 UnavailableReason。
|
||||
Status(ctx context.Context, jobID int64) (state JobState, reason string, err error)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
// Meta 只有链接本身给出的标题、歌手、专辑和时长,时长未知时为 0。
|
||||
type LinkDownload struct {
|
||||
Meta track.Meta
|
||||
// 转成 mp3 的音频文件。
|
||||
Audio LinkFile
|
||||
// 封面文件和它的扩展名(小写,不带点),没有封面时都为空。
|
||||
Cover LinkFile
|
||||
CoverExt string
|
||||
}
|
||||
|
||||
type LinkFile interface {
|
||||
// 分析和上传会同时打开同一个文件,每次打开都从头读到同样的内容。
|
||||
Open() (io.ReadSeekCloser, error)
|
||||
}
|
||||
|
||||
type LinkDownloader interface {
|
||||
// 读完文件之后调用 release 删除它们;返回错误时没有要删除的文件。链接不受支持或指向非公网地址时返回 *RejectedError。
|
||||
Download(ctx context.Context, link string) (d LinkDownload, release func(), err error)
|
||||
}
|
||||
|
||||
// 用 yt-dlp 下载链接之前调用。这个链接不需要 cookies 时 ok 为 false;否则下载结束后调用 release,由实现删除文件。
|
||||
type LinkCookies interface {
|
||||
CookieFile(ctx context.Context, link string) (path string, release func(), ok bool, err error)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
type MediaStore interface {
|
||||
// body 要能 Seek:签名要先读一遍算出摘要,失败重试时也要从头再读。
|
||||
Put(ctx context.Context, key string, body io.ReadSeeker, contentType string) error
|
||||
Exists(ctx context.Context, key string) (bool, error)
|
||||
// 对象不存在时同样成功。
|
||||
Delete(ctx context.Context, key string) error
|
||||
}
|
||||
|
||||
type StoredMedia struct {
|
||||
// 曲目 ID 的 SHA-256(十六进制)。链接的曲目 ID 是 http(s) 地址,B 站的是 BV 号,两种 ID 不会相同,所以单凭它就能找到这首歌。
|
||||
Key string
|
||||
Source track.Source
|
||||
// 曲目 ID:链接曲目是链接本身,B 站的歌是 BV 号。
|
||||
Link string
|
||||
Title string
|
||||
// 音频的字节数。
|
||||
Size int64
|
||||
CoverKey track.ObjectKey
|
||||
StoredAt time.Time
|
||||
LastPlayedAt *time.Time
|
||||
}
|
||||
|
||||
type MediaLibrary interface {
|
||||
// 最近存入的在前。
|
||||
ListStoredMedia(ctx context.Context) ([]StoredMedia, error)
|
||||
StoredMedia(ctx context.Context, key string) (m StoredMedia, ok bool, err error)
|
||||
// 清除这首歌的准备结果。
|
||||
DeleteStoredMedia(ctx context.Context, key string) error
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/domain/member"
|
||||
)
|
||||
|
||||
var ErrMemberNotFound = errors.New("成员不存在")
|
||||
|
||||
// 成员 token 只以 SHA-256 哈希的形式出现在端口中。
|
||||
type Members interface {
|
||||
CreateMember(ctx context.Context, tokenHash []byte, name string) (member.Member, error)
|
||||
MemberByToken(ctx context.Context, tokenHash []byte) (member.Member, error)
|
||||
// name 或 avatarHash 为 nil 时保持原值。
|
||||
UpdateMember(ctx context.Context, id string, name, avatarHash *string) (member.Member, error)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 搜索与推荐列表的额度按房间计数,所有实例共用。
|
||||
type SearchQuota interface {
|
||||
// 从房间的额度中取一次:最多突发 burst 次,之后每隔 refill 恢复一次。取到时返回 0,
|
||||
// 额度用完时返回距离下一次有额度还要等待的时长。
|
||||
TakeSearch(ctx context.Context, roomID string, burst int, refill time.Duration) (time.Duration, error)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/domain/room"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrRoomNotFound = errors.New("房间不存在")
|
||||
ErrSlugTaken = errors.New("别名已被占用")
|
||||
)
|
||||
|
||||
// 别名或密码哈希为空字符串表示没有。
|
||||
type (
|
||||
RoomRecord struct {
|
||||
Info room.Info
|
||||
Slug string
|
||||
PasswordHash string
|
||||
}
|
||||
|
||||
NewRoom struct {
|
||||
Kind room.Kind
|
||||
Name string
|
||||
Description string
|
||||
Slug string
|
||||
PasswordHash string
|
||||
}
|
||||
)
|
||||
|
||||
// nil 表示不修改,空字符串表示取消。
|
||||
type AccessChange struct {
|
||||
Slug *string
|
||||
PasswordHash *string
|
||||
}
|
||||
|
||||
type RoomDirectory interface {
|
||||
CreateRoom(ctx context.Context, r NewRoom) (string, error)
|
||||
ListRooms(ctx context.Context) ([]RoomRecord, error)
|
||||
// ref 是房间的 UUID 或别名。
|
||||
FindRoom(ctx context.Context, ref string) (RoomRecord, error)
|
||||
// 设置或修改密码时同时删除房间成员的票据,bot 成员的票据(API Key)保留;调用方随后还要让这个房间的在线会话断开。
|
||||
UpdateRoomAccess(ctx context.Context, id string, c AccessChange) error
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package port
|
||||
|
||||
import "context"
|
||||
|
||||
// 没有设置过品牌名时,BrandName 返回空字符串。
|
||||
type Site interface {
|
||||
BrandName(ctx context.Context) (string, error)
|
||||
SetBrandName(ctx context.Context, name string) error
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/domain/room"
|
||||
)
|
||||
|
||||
// 房间已由新的 owner 接管或已被删除,调用方必须放弃这个房间。
|
||||
var ErrTermExpired = errors.New("任期已过期")
|
||||
|
||||
type RoomSnapshots interface {
|
||||
// 任期加 1 之后,之前任期的写入都会失败,所以只有接管房间的实例才能调用。
|
||||
// 条目是否就绪只按曲目表判断;链接和 B 站的歌的音频是否还在对象存储中,由调用方检查。
|
||||
TakeRoom(ctx context.Context, id string) (uint32, room.Snapshot, error)
|
||||
SaveRoom(ctx context.Context, term uint32, s room.Snapshot, plays []room.RecordPlay) error
|
||||
DeleteRoom(ctx context.Context, id string, term uint32) error
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
type QRLoginStatus string
|
||||
|
||||
const (
|
||||
QRLoginWaiting QRLoginStatus = "waiting"
|
||||
QRLoginScanned QRLoginStatus = "scanned"
|
||||
QRLoginSuccess QRLoginStatus = "success"
|
||||
QRLoginExpired QRLoginStatus = "expired"
|
||||
QRLoginFailed QRLoginStatus = "failed"
|
||||
)
|
||||
|
||||
type QRLoginSession struct {
|
||||
Key string
|
||||
// 二维码编码的登录页链接;平台只给图片时为空。
|
||||
LoginURL string
|
||||
// 浏览器可以直接显示的二维码图片:data: URI 或 https 地址;平台只给登录链接时为空。两者至少有一个。
|
||||
QRImage string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type QRLoginResult struct {
|
||||
Status QRLoginStatus
|
||||
Message string
|
||||
}
|
||||
|
||||
type SourceAccount struct {
|
||||
ID string
|
||||
Name *string
|
||||
VIP *bool
|
||||
Verified bool
|
||||
}
|
||||
|
||||
type QQLoginType string
|
||||
|
||||
const (
|
||||
QQLoginQQ QQLoginType = "qq"
|
||||
QQLoginWeChat QQLoginType = "wx"
|
||||
)
|
||||
|
||||
type NetEaseQRLoginSource interface {
|
||||
CreateQRLogin(context.Context) (QRLoginSession, error)
|
||||
CheckQRLogin(context.Context, string) (QRLoginResult, error)
|
||||
CurrentAccount(context.Context) (*SourceAccount, error)
|
||||
}
|
||||
|
||||
type QQQRLoginSource interface {
|
||||
CreateQRLogin(context.Context, QQLoginType) (QRLoginSession, error)
|
||||
CheckQRLogin(context.Context, QQLoginType, string) (QRLoginResult, error)
|
||||
CurrentAccount(context.Context) (*SourceAccount, error)
|
||||
}
|
||||
|
||||
// RejectedError 表示音源明确拒绝了这首歌,比如歌曲下架或没有版权:准备不再重试。
|
||||
// 它的文本就是准备失败的原因。
|
||||
type RejectedError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *RejectedError) Error() string { return e.Err.Error() }
|
||||
func (e *RejectedError) Unwrap() error { return e.Err }
|
||||
|
||||
type (
|
||||
Song struct {
|
||||
Track track.Track
|
||||
Meta track.Meta
|
||||
}
|
||||
|
||||
Playlist struct {
|
||||
ID string
|
||||
Name string
|
||||
CoverURL string
|
||||
Creator string
|
||||
Description string
|
||||
TrackCount int64
|
||||
PlayCount int64
|
||||
}
|
||||
|
||||
// Audio 由调用方读完后关闭。
|
||||
Resolved struct {
|
||||
Meta track.Meta
|
||||
Lyric string
|
||||
Audio io.ReadCloser
|
||||
}
|
||||
)
|
||||
|
||||
type Source interface {
|
||||
SearchTracks(ctx context.Context, keyword string) ([]Song, error)
|
||||
SearchPlaylists(ctx context.Context, keyword string) ([]Playlist, error)
|
||||
TrackMeta(ctx context.Context, id string) (track.Meta, error)
|
||||
Resolve(ctx context.Context, id string) (Resolved, error)
|
||||
}
|
||||
|
||||
// 只有网易云提供歌单内的歌曲和相似歌曲。
|
||||
type NetEaseSource interface {
|
||||
Source
|
||||
PlaylistTracks(ctx context.Context, playlistID string) ([]Song, error)
|
||||
SimilarTracks(ctx context.Context, id string) ([]Song, error)
|
||||
}
|
||||
|
||||
// 供 /media 跳转使用。播放地址有时效,调用方把取到的地址复用 1 分钟。
|
||||
// 音源明确拒绝这首歌时返回 *RejectedError。实现要自己为对外请求设置期限。
|
||||
type PlayURLs interface {
|
||||
PlayURL(ctx context.Context, id string) (string, error)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/domain/member"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTicketNotFound = errors.New("票据不存在")
|
||||
ErrPasswordChanged = errors.New("房间密码已修改")
|
||||
)
|
||||
|
||||
type NewTicket struct {
|
||||
TokenHash []byte
|
||||
RoomID string
|
||||
MemberID string
|
||||
// 签发前校验密码时依据的密码哈希,空字符串表示房间没有密码。
|
||||
PasswordHash string
|
||||
}
|
||||
|
||||
type TicketHolder struct {
|
||||
RoomID string
|
||||
// 持有者的当前资料。
|
||||
Member member.Member
|
||||
// 票据是常驻房间的 API Key,持有者是这个房间的 bot 成员。
|
||||
Bot bool
|
||||
}
|
||||
|
||||
type Tickets interface {
|
||||
// 房间的密码哈希已经不是 t.PasswordHash 时返回 ErrPasswordChanged:密码在校验之后被修改过,
|
||||
// 设置或修改密码时的作废已经执行,此时签发的票据不会再被作废。
|
||||
CreateTicket(ctx context.Context, t NewTicket) error
|
||||
TicketHolder(ctx context.Context, tokenHash []byte) (TicketHolder, error)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
// 曲目表中只有准备得到的时长,读出的 Meta.Duration 就是 Result.Duration。
|
||||
type PreparedTrack struct {
|
||||
Meta track.Meta
|
||||
Result track.Prepared
|
||||
// 链接和 B 站的歌的 MediaKey 是曲目 ID 的 SHA-256(十六进制),对象存储中音频和封面的对象键由它得出;
|
||||
// MediaSize 是音频的字节数。其他来源为空。
|
||||
MediaKey string
|
||||
MediaSize int64
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package prepare
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/media"
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
const cleanupTimeout = 10 * time.Second
|
||||
|
||||
// 限定封面格式并不能阻止脚本:链接的封面来自用户提交的网页,而对象存储会按匿名请求中的 response-content-type 改写 Content-Type。
|
||||
// 对象存储与页面同源时,要靠入口为 /storage/ 的响应添加 Content-Security-Policy: sandbox 和 X-Content-Type-Options: nosniff 来防范脚本。
|
||||
var coverTypes = map[string]string{"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", "webp": "image/webp"}
|
||||
|
||||
// MediaRunner 准备音频和封面要存进对象存储的歌:链接由 yt-dlp 下载并转成 mp3,B 站的歌由音源下载原始文件,再转成 mp3。
|
||||
type MediaRunner struct {
|
||||
links port.LinkDownloader
|
||||
bilibili port.BilibiliSource
|
||||
transcoder port.Transcoder
|
||||
store port.MediaStore
|
||||
tracks port.Tracks
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewMediaRunner(links port.LinkDownloader, bilibili port.BilibiliSource, transcoder port.Transcoder, store port.MediaStore, tracks port.Tracks, logger *slog.Logger) *MediaRunner {
|
||||
return &MediaRunner{links: links, bilibili: bilibili, transcoder: transcoder, store: store, tracks: tracks, logger: logger}
|
||||
}
|
||||
|
||||
func (r *MediaRunner) Run(ctx context.Context, t track.Track) (err error) {
|
||||
d, release, err := r.download(ctx, t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
key := media.Key(t.ID)
|
||||
if err := r.clear(ctx, key); err != nil {
|
||||
return err
|
||||
}
|
||||
// 上传开始之后任何一步失败,都要删除已经上传的对象:它们匿名可读,却没有准备结果,后台也看不到,不会再有人清理。
|
||||
defer func() {
|
||||
if err != nil {
|
||||
r.discard(ctx, t, key)
|
||||
}
|
||||
}()
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
result track.Prepared
|
||||
analyzeErr error
|
||||
size int64
|
||||
coverKey string
|
||||
uploadErr error
|
||||
)
|
||||
wg.Go(func() { result, analyzeErr = analyzeFile(ctx, r.logger, t, d.Audio, d.Meta.Duration) })
|
||||
size, coverKey, uploadErr = r.upload(ctx, key, d)
|
||||
wg.Wait()
|
||||
// 音频无法分析时重试也没有用,先于上传的错误返回。
|
||||
if analyzeErr != nil {
|
||||
return analyzeErr
|
||||
}
|
||||
if uploadErr != nil {
|
||||
return uploadErr
|
||||
}
|
||||
|
||||
meta := track.Meta{Title: d.Meta.Title, Artist: d.Meta.Artist, Album: d.Meta.Album, CoverKey: track.ObjectKey(coverKey), PageURL: pageURL(t), Duration: d.Meta.Duration}
|
||||
ctx, cancel := context.WithTimeout(ctx, storeTimeout)
|
||||
defer cancel()
|
||||
return r.tracks.SavePreparedTrack(ctx, t, port.PreparedTrack{Meta: meta, Result: result, MediaKey: key, MediaSize: size})
|
||||
}
|
||||
|
||||
// 任务参数来自 River 的任务表,不一定经过网关的校验。
|
||||
func (r *MediaRunner) download(ctx context.Context, t track.Track) (port.LinkDownload, func(), error) {
|
||||
switch {
|
||||
case t.Source == track.Link && httpLink(t.ID):
|
||||
return r.links.Download(ctx, t.ID)
|
||||
case t.Source == track.Bilibili && bilibiliID(t.ID):
|
||||
return r.downloadBilibili(ctx, t.ID)
|
||||
}
|
||||
return port.LinkDownload{}, nil, &port.RejectedError{Err: fmt.Errorf("不能准备来源为 %q、ID 为 %q 的歌", t.Source, t.ID)}
|
||||
}
|
||||
|
||||
// 平台给的原始文件转成 mp3 之后,与链接下载到的文件一样分析和上传。两份文件都保留到上传结束。
|
||||
func (r *MediaRunner) downloadBilibili(ctx context.Context, id string) (port.LinkDownload, func(), error) {
|
||||
d, release, err := r.bilibili.Download(ctx, id)
|
||||
if err != nil {
|
||||
return port.LinkDownload{}, nil, err
|
||||
}
|
||||
// 用 defer 是为了 panic 时也删除文件:River 会恢复任务中的 panic,worker 继续运行。
|
||||
done := false
|
||||
defer func() {
|
||||
if !done {
|
||||
release()
|
||||
}
|
||||
}()
|
||||
mp3, releaseMP3, err := r.transcoder.ToMP3(ctx, d.Audio)
|
||||
if err != nil {
|
||||
return port.LinkDownload{}, nil, err
|
||||
}
|
||||
done = true
|
||||
return port.LinkDownload{Meta: d.Meta, Audio: mp3, Cover: d.Cover, CoverExt: d.CoverExt}, func() {
|
||||
releaseMP3()
|
||||
release()
|
||||
}, nil
|
||||
}
|
||||
|
||||
func pageURL(t track.Track) string {
|
||||
if t.Source == track.Bilibili {
|
||||
return track.BilibiliPageURL(t.ID)
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
|
||||
// 对象键由曲目 ID 决定,重新准备时要先清除上一次留下的对象:这一次封面的格式可能不同,也可能没有封面。
|
||||
func (r *MediaRunner) clear(ctx context.Context, key string) error {
|
||||
errs := []error{r.store.Delete(ctx, media.AudioObject(key))}
|
||||
for ext := range coverTypes {
|
||||
errs = append(errs, r.store.Delete(ctx, media.CoverObject(key, ext)))
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// 尽力删除已经上传的对象,失败只记 WARN:下一次准备同一首歌时还会再清理一遍。ctx 可能已经结束(任务被取消或超时),
|
||||
// 所以使用脱离 ctx 的短期限。
|
||||
func (r *MediaRunner) discard(ctx context.Context, t track.Track, key string) {
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cleanupTimeout)
|
||||
defer cancel()
|
||||
if err := r.clear(ctx, key); err != nil {
|
||||
r.logger.WarnContext(ctx, "删除没有准备成功的歌已上传的对象失败", "source", t.Source, "track_id", t.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func httpLink(link string) bool {
|
||||
u, err := url.Parse(link)
|
||||
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != ""
|
||||
}
|
||||
|
||||
func bilibiliID(id string) bool {
|
||||
_, _, ok := track.ParseBilibiliID(id)
|
||||
return ok
|
||||
}
|
||||
|
||||
func analyzeFile(ctx context.Context, logger *slog.Logger, t track.Track, audio port.LinkFile, fallback time.Duration) (track.Prepared, error) {
|
||||
f, err := audio.Open()
|
||||
if err != nil {
|
||||
return track.Prepared{}, err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
return analyze(ctx, logger, t, ctxReader{ctx: ctx, r: f}, fallback, "")
|
||||
}
|
||||
|
||||
// 解码不接收 ctx,只能靠这层包装在 ctx 结束后让读取失败;否则任务取消或超时之后,长文件的分析仍会继续。
|
||||
type ctxReader struct {
|
||||
ctx context.Context
|
||||
r io.Reader
|
||||
}
|
||||
|
||||
func (c ctxReader) Read(p []byte) (int, error) {
|
||||
if err := c.ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return c.r.Read(p)
|
||||
}
|
||||
|
||||
func (r *MediaRunner) upload(ctx context.Context, key string, d port.LinkDownload) (int64, string, error) {
|
||||
size, err := r.put(ctx, media.AudioObject(key), d.Audio, "audio/mpeg")
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
contentType, ok := coverTypes[d.CoverExt]
|
||||
if d.Cover == nil || !ok {
|
||||
return size, "", nil
|
||||
}
|
||||
coverKey := media.CoverObject(key, d.CoverExt)
|
||||
if _, err := r.put(ctx, coverKey, d.Cover, contentType); err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
return size, coverKey, nil
|
||||
}
|
||||
|
||||
func (r *MediaRunner) put(ctx context.Context, key string, file port.LinkFile, contentType string) (int64, error) {
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
size, err := f.Seek(0, io.SeekEnd)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := r.store.Put(ctx, key, f, contentType); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package prepare
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
"github.com/dihemoe/alisten/internal/lib/logctx"
|
||||
)
|
||||
|
||||
const (
|
||||
// 准备结果的通知可能丢失,任务也可能在写入结果之后、River 记录完成之前停在 running,所以等待中的准备每隔这么久核对一次。
|
||||
checkInterval = 30 * time.Second
|
||||
storeTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
type Preparer struct {
|
||||
tracks port.Tracks
|
||||
jobs port.PrepareJobs
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
waiting map[track.Track]map[chan port.PrepareOutcome]struct{}
|
||||
}
|
||||
|
||||
var _ port.Preparer = (*Preparer)(nil)
|
||||
|
||||
func New(tracks port.Tracks, jobs port.PrepareJobs, logger *slog.Logger) *Preparer {
|
||||
return &Preparer{tracks: tracks, jobs: jobs, logger: logger, waiting: map[track.Track]map[chan port.PrepareOutcome]struct{}{}}
|
||||
}
|
||||
|
||||
// 一直等到这首歌准备好或任务最终失败。读取曲目表、提交或查询任务出错时不当作准备失败,等下一次核对时重试。
|
||||
func (p *Preparer) Prepare(ctx context.Context, t track.Track) (track.Meta, track.Prepared, error) {
|
||||
outcomes := p.watch(t)
|
||||
defer p.unwatch(t, outcomes)
|
||||
check := time.NewTicker(checkInterval)
|
||||
defer check.Stop()
|
||||
var job int64
|
||||
for {
|
||||
result, ok, readErr := p.prepared(ctx, t)
|
||||
if ok {
|
||||
return result.Meta, result.Result, nil
|
||||
}
|
||||
var reason string
|
||||
if readErr == nil {
|
||||
var again bool
|
||||
if job, reason, again = p.follow(ctx, t, job); again {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if reason == "" {
|
||||
var err error
|
||||
if reason, err = p.wait(ctx, job, outcomes, check.C); err != nil {
|
||||
return track.Meta{}, track.Prepared{}, err
|
||||
}
|
||||
}
|
||||
if reason != "" {
|
||||
return track.Meta{}, track.Prepared{}, errors.New(reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Preparer) Finished(o port.PrepareOutcome) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for ch := range p.waiting[o.Track] {
|
||||
// 只保留最新的结果:较早的结果属于已经结束的任务。
|
||||
select {
|
||||
case <-ch:
|
||||
default:
|
||||
}
|
||||
ch <- o
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Preparer) prepared(ctx context.Context, t track.Track) (port.PreparedTrack, bool, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, storeTimeout)
|
||||
defer cancel()
|
||||
result, ok, err := p.tracks.PreparedTrack(ctx, t)
|
||||
if err != nil {
|
||||
p.logger.WarnContext(ctx, "读取准备结果失败,稍后重试", "source", t.Source, "track_id", t.ID, "error", err)
|
||||
}
|
||||
return result, ok, err
|
||||
}
|
||||
|
||||
// 返回之后要跟进的任务,提交失败时为 0。任务已经完成时返回 again,由调用方重新读取曲目表:结果可能是任务在刚才读取曲目表之后才写入的,
|
||||
// 若直接重新提交,链接曲目的任务会先删掉已存储的音频再重新下载。
|
||||
func (p *Preparer) follow(ctx context.Context, t track.Track, job int64) (int64, string, bool) {
|
||||
if job != 0 {
|
||||
state, reason, err := p.status(ctx, job)
|
||||
switch {
|
||||
case err != nil:
|
||||
p.logger.WarnContext(logctx.WithJob(ctx, job), "核对准备任务失败", "source", t.Source, "track_id", t.ID, "error", err)
|
||||
return job, "", false
|
||||
case state == port.JobFailed:
|
||||
return job, reason, false
|
||||
case state == port.JobPending:
|
||||
return job, "", false
|
||||
}
|
||||
return 0, "", true
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, storeTimeout)
|
||||
defer cancel()
|
||||
job, err := p.jobs.Submit(ctx, t)
|
||||
if err != nil {
|
||||
p.logger.WarnContext(ctx, "提交准备任务失败,稍后重试", "source", t.Source, "track_id", t.ID, "error", err)
|
||||
return 0, "", false
|
||||
}
|
||||
return job, "", false
|
||||
}
|
||||
|
||||
func (p *Preparer) wait(ctx context.Context, job int64, outcomes <-chan port.PrepareOutcome, check <-chan time.Time) (string, error) {
|
||||
for {
|
||||
select {
|
||||
case o := <-outcomes:
|
||||
if o.Reason == "" {
|
||||
return "", nil
|
||||
}
|
||||
if o.JobID == job {
|
||||
return o.Reason, nil
|
||||
}
|
||||
case <-check:
|
||||
return "", nil
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Preparer) status(ctx context.Context, job int64) (port.JobState, string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, storeTimeout)
|
||||
defer cancel()
|
||||
return p.jobs.Status(ctx, job)
|
||||
}
|
||||
|
||||
func (p *Preparer) watch(t track.Track) chan port.PrepareOutcome {
|
||||
ch := make(chan port.PrepareOutcome, 1)
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.waiting[t] == nil {
|
||||
p.waiting[t] = map[chan port.PrepareOutcome]struct{}{}
|
||||
}
|
||||
p.waiting[t][ch] = struct{}{}
|
||||
return ch
|
||||
}
|
||||
|
||||
func (p *Preparer) unwatch(t track.Track, ch chan port.PrepareOutcome) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
delete(p.waiting[t], ch)
|
||||
if len(p.waiting[t]) == 0 {
|
||||
delete(p.waiting, t)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package prepare
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
"github.com/dihemoe/alisten/internal/lib/loudness"
|
||||
)
|
||||
|
||||
type Runner struct {
|
||||
sources map[track.Source]port.Source
|
||||
tracks port.Tracks
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewRunner(sources map[track.Source]port.Source, tracks port.Tracks, logger *slog.Logger) *Runner {
|
||||
return &Runner{sources: sources, tracks: tracks, logger: logger}
|
||||
}
|
||||
|
||||
func (r *Runner) Run(ctx context.Context, t track.Track) error {
|
||||
source, ok := r.sources[t.Source]
|
||||
if !ok || t.ID == "" {
|
||||
return &port.RejectedError{Err: fmt.Errorf("不能准备来源为 %q、ID 为 %q 的歌", t.Source, t.ID)}
|
||||
}
|
||||
resolved, err := source.Resolve(ctx, t.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := analyze(ctx, r.logger, t, resolved.Audio, resolved.Meta.Duration, resolved.Lyric)
|
||||
_ = resolved.Audio.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, storeTimeout)
|
||||
defer cancel()
|
||||
return r.tracks.SavePreparedTrack(ctx, t, port.PreparedTrack{Meta: resolved.Meta, Result: result})
|
||||
}
|
||||
|
||||
func analyze(ctx context.Context, logger *slog.Logger, t track.Track, audio io.Reader, fallback time.Duration, lyric string) (track.Prepared, error) {
|
||||
src := &download{r: audio}
|
||||
result, err := loudness.AnalyzeReader(src)
|
||||
if src.err != nil {
|
||||
return track.Prepared{}, fmt.Errorf("下载音频:%w", src.err)
|
||||
}
|
||||
prepared := track.Prepared{Duration: fallback, Gain: 1, Lyric: lyric}
|
||||
if err == nil {
|
||||
prepared.Duration, prepared.Gain = result.Duration, result.Coefficient()
|
||||
} else {
|
||||
logger.WarnContext(ctx, "音频无法解码,增益取 1、时长取元数据", "source", t.Source, "track_id", t.ID, "error", err)
|
||||
}
|
||||
// 时长为 0 的歌会在开播时立刻结束。
|
||||
if prepared.Duration <= 0 {
|
||||
return track.Prepared{}, &port.RejectedError{Err: errors.New("这首歌的音频无法播放")}
|
||||
}
|
||||
return prepared, nil
|
||||
}
|
||||
|
||||
// 记下读取音频时的错误,以区分下载中断和音频本身无法解码。
|
||||
type download struct {
|
||||
r io.Reader
|
||||
err error
|
||||
}
|
||||
|
||||
func (d *download) Read(p []byte) (int, error) {
|
||||
n, err := d.r.Read(p)
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
d.err = err
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,765 @@
|
||||
package realtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/catalog"
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/member"
|
||||
"github.com/dihemoe/alisten/internal/domain/room"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
"github.com/dihemoe/alisten/internal/lib/logctx"
|
||||
)
|
||||
|
||||
const (
|
||||
ownerReplyTimeout = 10 * time.Second
|
||||
unsubscribeDelay = 60 * time.Second
|
||||
retryInterval = 5 * time.Second
|
||||
aliveTTL = 15 * time.Second
|
||||
heartbeatInterval = 5 * time.Second
|
||||
presenceRetryMin = time.Second
|
||||
presenceRetryMax = 10 * time.Second
|
||||
)
|
||||
|
||||
type Gateway struct {
|
||||
self string
|
||||
host *Host
|
||||
catalog *catalog.Service
|
||||
leases port.Leases
|
||||
liveness port.Liveness
|
||||
bus port.Bus
|
||||
logger *slog.Logger
|
||||
ids atomic.Uint64
|
||||
|
||||
mu sync.Mutex
|
||||
closing bool
|
||||
pending map[room.RequestID]*pendingRequest
|
||||
sessions map[string]map[*Session]struct{}
|
||||
// 每个房间的事件流按打开的顺序排列。
|
||||
observers map[string][]*Observer
|
||||
links map[string]*roomLink
|
||||
// 订阅连接每断开一次就加 1:新连接是否已经订阅了这些频道,要等确认才知道,所以之前确认过的房间订阅要重新确认。
|
||||
epoch uint64
|
||||
active sync.WaitGroup
|
||||
stopped chan struct{}
|
||||
}
|
||||
|
||||
// RequestError 是网关为房间请求生成 RequestID 之后,该请求失败时返回的错误。owner 处理这个请求时的日志带有同一个 ID,
|
||||
// 调用方记录失败时带上它,网关和 owner 两侧的日志就能对应起来。
|
||||
type RequestError struct {
|
||||
ID room.RequestID
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *RequestError) Error() string { return e.Err.Error() }
|
||||
func (e *RequestError) Unwrap() error { return e.Err }
|
||||
|
||||
func failed(id room.RequestID, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return &RequestError{ID: id, Err: err}
|
||||
}
|
||||
|
||||
type pendingRequest struct {
|
||||
// 由从 pending 中取走请求的一方调用一次,然后关闭 done;在接收消息的 goroutine 上调用时不能阻塞。
|
||||
deliver func(room.Result, error)
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
type roomLink struct {
|
||||
users int
|
||||
lastHTTP time.Time
|
||||
timer *time.Timer
|
||||
leaving chan struct{}
|
||||
confirmed uint64
|
||||
}
|
||||
|
||||
func New(snapshots port.RoomSnapshots, directory port.RoomDirectory, music *catalog.Service, preparer port.Preparer, cluster Cluster, logger *slog.Logger) (*Host, *Gateway) {
|
||||
h := newHost(snapshots, directory, preparer, music, cluster, logger)
|
||||
g := &Gateway{
|
||||
self: cluster.Instance,
|
||||
host: h,
|
||||
catalog: music,
|
||||
leases: cluster.Leases,
|
||||
liveness: cluster.Liveness,
|
||||
bus: cluster.Bus,
|
||||
logger: logger,
|
||||
stopped: make(chan struct{}),
|
||||
pending: map[room.RequestID]*pendingRequest{},
|
||||
sessions: map[string]map[*Session]struct{}{},
|
||||
observers: map[string][]*Observer{},
|
||||
links: map[string]*roomLink{},
|
||||
epoch: 1,
|
||||
}
|
||||
return h, g
|
||||
}
|
||||
|
||||
// 会话结束时必须调用 Leave。
|
||||
func (g *Gateway) Join(ctx context.Context, roomID string, m member.Member) (*Session, error) {
|
||||
link, err := g.hold(ctx, roomID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := newSession(room.SessionID(g.nextID("s")), roomID, m)
|
||||
s.link = link
|
||||
if !g.register(s) {
|
||||
g.unhold(roomID, link, false)
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if err := g.route(ctx, roomID, envelope{input: room.Join{Session: s.id, Member: m}}, true); err != nil {
|
||||
g.Leave(s)
|
||||
return nil, err
|
||||
}
|
||||
// Join 没有回复,用紧接着的 Sync 回复中的快照作为握手的结果。
|
||||
id := g.newRequestID()
|
||||
g.request(ctx, roomID, id, envelope{input: room.Command{RequestID: id, Member: m, Action: room.Sync{}}}, false, func(res room.Result, e error) {
|
||||
if err = e; err == nil {
|
||||
s.welcome(res.(room.SyncResult).View)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
g.Leave(s)
|
||||
return nil, failed(id, err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// 可以重复调用。
|
||||
func (g *Gateway) Leave(s *Session) {
|
||||
g.mu.Lock()
|
||||
set, ok := g.sessions[s.room]
|
||||
if _, joined := set[s]; !ok || !joined {
|
||||
g.mu.Unlock()
|
||||
return
|
||||
}
|
||||
delete(set, s)
|
||||
if len(set) == 0 {
|
||||
delete(g.sessions, s.room)
|
||||
}
|
||||
g.mu.Unlock()
|
||||
s.left.Store(true)
|
||||
// 第一次发送在调用方的 goroutine 上进行:Shutdown 会等会话 Leave,它返回时 Leave 已经交给 owner;发送失败的才交给后台重试。
|
||||
if leave := leaveOf(s); !g.sendPresence(s.room, false, leave) {
|
||||
go g.retryPresence(s.room, false, leave)
|
||||
}
|
||||
g.unhold(s.room, s.link, false)
|
||||
g.active.Done()
|
||||
}
|
||||
|
||||
// 回复和房间的事件按房间产生的顺序放进会话的发送队列。
|
||||
// 调用之前要先用 s.Begin 占用名额。
|
||||
func (g *Gateway) Command(ctx context.Context, s *Session, tag any, a room.Action) error {
|
||||
id := g.newRequestID()
|
||||
var err error
|
||||
g.request(ctx, s.room, id, envelope{input: room.Command{RequestID: id, Member: s.Member(), Action: a}}, false, func(res room.Result, e error) {
|
||||
err = e
|
||||
s.reply(tag, res, e)
|
||||
})
|
||||
return failed(id, err)
|
||||
}
|
||||
|
||||
func (g *Gateway) Do(ctx context.Context, roomID string, m member.Member, a room.Action) (room.Result, error) {
|
||||
id := g.newRequestID()
|
||||
var res room.Result
|
||||
var err error
|
||||
g.request(ctx, roomID, id, envelope{input: room.Command{RequestID: id, Member: m, Action: a}}, true, func(r room.Result, e error) {
|
||||
res, err = r, e
|
||||
})
|
||||
return res, failed(id, err)
|
||||
}
|
||||
|
||||
// 参数为 nil 表示保持不变;房间落盘之后才返回。
|
||||
func (g *Gateway) UpdateInfo(ctx context.Context, roomID string, name, description *string) error {
|
||||
id := g.newRequestID()
|
||||
var err error
|
||||
g.request(ctx, roomID, id, envelope{patch: &port.InfoPatch{RequestID: id, Name: name, Description: description}}, true, func(_ room.Result, e error) {
|
||||
err = e
|
||||
})
|
||||
return failed(id, err)
|
||||
}
|
||||
|
||||
// 房间删除数据之后,各实例上这个房间的会话和事件流以 CloseRoomGone 关闭。
|
||||
func (g *Gateway) Delete(ctx context.Context, roomID string) error {
|
||||
id := g.newRequestID()
|
||||
var err error
|
||||
g.request(ctx, roomID, id, envelope{input: room.Delete{RequestID: id}}, true, func(_ room.Result, e error) {
|
||||
err = e
|
||||
})
|
||||
return failed(id, err)
|
||||
}
|
||||
|
||||
// Revoke 在房间成员的票据作废之后通知各实例,以 CloseRevoked 关闭这个房间的会话和成员打开的事件流。
|
||||
func (g *Gateway) Revoke(ctx context.Context, roomID string) error {
|
||||
return g.announce(ctx, roomID, port.TicketsRevoked{})
|
||||
}
|
||||
|
||||
// RevokeAPIKey 在常驻房间的 API Key 重置之后通知各实例,以 CloseRevoked 关闭用 Key 打开的事件流。
|
||||
func (g *Gateway) RevokeAPIKey(ctx context.Context, roomID string) error {
|
||||
return g.announce(ctx, roomID, port.APIKeyReset{})
|
||||
}
|
||||
|
||||
func (g *Gateway) announce(ctx context.Context, roomID string, m port.RoomMessage) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, clusterTimeout)
|
||||
defer cancel()
|
||||
if err := g.bus.PublishRoom(ctx, roomID, m); err != nil {
|
||||
return unavailable(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Gateway) MemberUpdated(ctx context.Context, m member.Member) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, clusterTimeout)
|
||||
defer cancel()
|
||||
if err := g.bus.PublishMember(ctx, m); err != nil {
|
||||
return unavailable(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 广播失败时只记录日志:等待的一方会定期按任务 ID 核对。
|
||||
func (g *Gateway) PublishPrepared(o port.PrepareOutcome) {
|
||||
ctx, cancel := context.WithTimeout(logctx.WithJob(context.Background(), o.JobID), clusterTimeout)
|
||||
defer cancel()
|
||||
if err := g.bus.PublishPrepared(ctx, o); err != nil {
|
||||
g.logger.WarnContext(ctx, "广播准备结果失败", "source", o.Track.Source, "track_id", o.Track.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) Shutdown(ctx context.Context) error {
|
||||
g.mu.Lock()
|
||||
if !g.closing {
|
||||
g.closing = true
|
||||
close(g.stopped)
|
||||
}
|
||||
g.mu.Unlock()
|
||||
g.closeAll(CloseRestart)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
g.active.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("等待会话关闭:%w", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) Sessions() int {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
n := 0
|
||||
for _, set := range g.sessions {
|
||||
n += len(set)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
var ErrRequesterNotAllowed = errors.New("requester:只有用常驻房间的 API Key 点歌时才能填写")
|
||||
|
||||
type Pick struct {
|
||||
Track track.Track
|
||||
Source track.Source
|
||||
Keyword string
|
||||
// 点歌人的名字,由 bot 代填。
|
||||
Requester string
|
||||
}
|
||||
|
||||
// by 是发出请求的成员,bot 表示他是常驻房间的 bot 成员。返回的成员是条目的点歌人,房间命令以他的名义发出。
|
||||
func (g *Gateway) Resolve(ctx context.Context, by member.Member, bot bool, p Pick) (room.Enqueue, member.Member, error) {
|
||||
if p.Requester != "" {
|
||||
if !bot {
|
||||
return room.Enqueue{}, member.Member{}, ErrRequesterNotAllowed
|
||||
}
|
||||
by = member.Member{ID: by.ID, Name: p.Requester + "(bot)"}
|
||||
}
|
||||
switch {
|
||||
case p.Keyword != "":
|
||||
t, m, err := g.catalog.FirstMatch(ctx, p.Source, p.Keyword)
|
||||
return room.Enqueue{Track: t, Meta: m}, by, err
|
||||
case p.Track.Source == track.Link:
|
||||
t, m, err := g.catalog.ResolveLink(ctx, p.Track.ID)
|
||||
return room.Enqueue{Track: t, Meta: m}, by, err
|
||||
default:
|
||||
m, err := g.catalog.TrackMeta(ctx, p.Track)
|
||||
return room.Enqueue{Track: p.Track, Meta: m}, by, err
|
||||
}
|
||||
}
|
||||
|
||||
// deliver 恰好被调用一次:收到回复时在接收消息的 goroutine 上调用,失败时在这里调用;返回时 deliver 已经调用过。
|
||||
// 网关先确认订阅了房间频道再发出输入,因为回复和事件都经由房间频道到达。
|
||||
func (g *Gateway) request(ctx context.Context, roomID string, id room.RequestID, e envelope, http bool, deliver func(room.Result, error)) {
|
||||
p := &pendingRequest{deliver: deliver, done: make(chan struct{})}
|
||||
g.mu.Lock()
|
||||
g.pending[id] = p
|
||||
g.mu.Unlock()
|
||||
ctx, cancel := context.WithTimeoutCause(ctx, ownerReplyTimeout, ErrUnavailable)
|
||||
defer cancel()
|
||||
link, err := g.hold(ctx, roomID)
|
||||
if err == nil {
|
||||
defer g.unhold(roomID, link, http)
|
||||
err = g.route(ctx, roomID, e, true)
|
||||
}
|
||||
if err != nil {
|
||||
g.settle(id, nil, err)
|
||||
} else {
|
||||
select {
|
||||
case <-p.done:
|
||||
case <-ctx.Done():
|
||||
g.settle(id, nil, context.Cause(ctx))
|
||||
}
|
||||
}
|
||||
<-p.done
|
||||
}
|
||||
|
||||
func (g *Gateway) route(ctx context.Context, roomID string, e envelope, takeover bool) error {
|
||||
for retried := false; ; retried = true {
|
||||
ownersCtx, cancel := context.WithTimeout(ctx, clusterTimeout)
|
||||
owners, err := g.leases.Owners(ownersCtx, []string{roomID})
|
||||
cancel()
|
||||
if err != nil {
|
||||
return unavailable(err)
|
||||
}
|
||||
switch owner := owners[0]; {
|
||||
case owner == "" && !takeover:
|
||||
return nil
|
||||
case owner == "" || owner == g.self:
|
||||
err := g.host.send(ctx, roomID, e, takeover)
|
||||
// 读到没有 owner 之后、本实例接管之前,其他实例先取得了租约。
|
||||
if errors.Is(err, errOwnedElsewhere) && !retried {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
default:
|
||||
sendCtx, cancel := context.WithTimeout(ctx, clusterTimeout)
|
||||
delivered, err := g.bus.SendInput(sendCtx, owner, port.RoomInput{Room: roomID, Gateway: g.self, Input: e.input, Patch: e.patch, Takeover: takeover})
|
||||
cancel()
|
||||
if err != nil {
|
||||
return unavailable(err)
|
||||
}
|
||||
// owner 所在的实例没有订阅自己的频道:它已经停止,但租约还没有过期。
|
||||
if !delivered {
|
||||
return ErrUnavailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func unavailable(err error) error {
|
||||
return fmt.Errorf("%w:%w", ErrUnavailable, err)
|
||||
}
|
||||
|
||||
// 回复、超时和房间不再处理请求可能同时发生,先从 pending 中取走请求的一方负责送达。
|
||||
func (g *Gateway) settle(id room.RequestID, res room.Result, err error) {
|
||||
g.mu.Lock()
|
||||
p, ok := g.pending[id]
|
||||
delete(g.pending, id)
|
||||
g.mu.Unlock()
|
||||
if ok {
|
||||
p.deliver(res, err)
|
||||
close(p.done)
|
||||
}
|
||||
}
|
||||
|
||||
// 在接收消息的 goroutine 上按房间频道上的顺序调用,不能阻塞。
|
||||
func (g *Gateway) deliver(roomID string, m port.RoomMessage) {
|
||||
switch m := m.(type) {
|
||||
case port.OwnerChanged:
|
||||
g.rejoin(roomID, false)
|
||||
for _, o := range g.roomObservers(roomID) {
|
||||
if o.ownerChanged(m.Term) {
|
||||
go g.resync(o)
|
||||
}
|
||||
}
|
||||
case port.OwnerReleased:
|
||||
// 房间暂时没有 owner,而这里还有它的会话,所以立即接管。
|
||||
g.rejoin(roomID, true)
|
||||
case port.Emitted:
|
||||
for _, q := range g.recipients(roomID) {
|
||||
q.event(m.Emit)
|
||||
}
|
||||
case port.Replied:
|
||||
var err error
|
||||
if m.Reply.Err != nil {
|
||||
err = m.Reply.Err
|
||||
}
|
||||
g.settle(m.Reply.RequestID, m.Reply.Result, err)
|
||||
case port.Rejected:
|
||||
err := ErrUnavailable
|
||||
if m.NotFound {
|
||||
err = port.ErrRoomNotFound
|
||||
}
|
||||
g.settle(m.RequestID, nil, err)
|
||||
case port.RoomClosed:
|
||||
for _, q := range g.recipients(roomID) {
|
||||
q.close(CloseRoomGone)
|
||||
}
|
||||
case port.TicketsRevoked:
|
||||
for _, s := range g.roomSessions(roomID) {
|
||||
s.close(CloseRevoked)
|
||||
}
|
||||
for _, o := range g.roomObservers(roomID) {
|
||||
if !o.bot {
|
||||
o.close(CloseRevoked)
|
||||
}
|
||||
}
|
||||
case port.APIKeyReset:
|
||||
for _, o := range g.roomObservers(roomID) {
|
||||
if o.bot {
|
||||
o.close(CloseRevoked)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 房间的新一次运行从空的在线列表开始,本地会话重新加入,在线列表由此重建。不能在接收消息的 goroutine 上等待房间收下输入。
|
||||
func (g *Gateway) rejoin(roomID string, takeover bool) {
|
||||
sessions := g.roomSessions(roomID)
|
||||
if len(sessions) == 0 {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
for _, s := range sessions {
|
||||
g.deliverPresence(roomID, takeover, joinOf(s))
|
||||
// 会话在重新加入的途中离开时,它的 Leave 可能排在这次 Join 之前,所以补发一次 Leave,以免它留在在线列表中。
|
||||
if s.left.Load() {
|
||||
g.deliverPresence(roomID, false, leaveOf(s))
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 在接收消息的 goroutine 上调用,不能阻塞。
|
||||
func (g *Gateway) memberChanged(m member.Member) {
|
||||
g.mu.Lock()
|
||||
var rooms []string
|
||||
for roomID, set := range g.sessions {
|
||||
updated := false
|
||||
for s := range set {
|
||||
if s.update(m) {
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
if updated {
|
||||
rooms = append(rooms, roomID)
|
||||
}
|
||||
}
|
||||
g.mu.Unlock()
|
||||
if len(rooms) == 0 {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
for _, roomID := range rooms {
|
||||
g.deliverPresence(roomID, false, g.memberUpdateOf(roomID, m.ID))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// owner 靠 Join、Leave 和 MemberUpdated 知道谁在线,这几条消息丢失后没有别的办法补回:离开的会话会一直留在在线列表中,
|
||||
// 临时房间也因此无法关闭;仍然在线的会话则不在列表中。所以送不到 owner 时要一直重试,直到送达。
|
||||
// next 在每次尝试前调用,返回这一次要发的输入;返回 false 表示这条消息已经不必再发。
|
||||
func (g *Gateway) sendPresence(roomID string, takeover bool, next func() (room.Input, bool)) (done bool) {
|
||||
in, ok := next()
|
||||
return !ok || g.route(context.Background(), roomID, envelope{input: in}, takeover) == nil
|
||||
}
|
||||
|
||||
// 网关停止之后不再重试:进程即将退出,本实例的存活键过期之后,owner 会让这里的会话离开。
|
||||
func (g *Gateway) retryPresence(roomID string, takeover bool, next func() (room.Input, bool)) {
|
||||
for delay := presenceRetryMin; ; delay = min(2*delay, presenceRetryMax) {
|
||||
select {
|
||||
case <-g.stopped:
|
||||
return
|
||||
case <-time.After(delay):
|
||||
}
|
||||
if g.sendPresence(roomID, takeover, next) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) deliverPresence(roomID string, takeover bool, next func() (room.Input, bool)) {
|
||||
if !g.sendPresence(roomID, takeover, next) {
|
||||
g.retryPresence(roomID, takeover, next)
|
||||
}
|
||||
}
|
||||
|
||||
func joinOf(s *Session) func() (room.Input, bool) {
|
||||
return func() (room.Input, bool) {
|
||||
if s.ended() {
|
||||
return nil, false
|
||||
}
|
||||
return room.Join{Session: s.id, Member: s.Member()}, true
|
||||
}
|
||||
}
|
||||
|
||||
func leaveOf(s *Session) func() (room.Input, bool) {
|
||||
return func() (room.Input, bool) { return room.Leave{Session: s.id}, true }
|
||||
}
|
||||
|
||||
// 每次都发送这个成员在本地会话记录中的最新资料,而不是触发这次发送的那一份:否则资料再次修改之后,
|
||||
// 先前那次发送的重试如果晚到,会把旧资料写回房间。
|
||||
func (g *Gateway) memberUpdateOf(roomID, memberID string) func() (room.Input, bool) {
|
||||
return func() (room.Input, bool) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
for s := range g.sessions[roomID] {
|
||||
if m := s.Member(); m.ID == memberID {
|
||||
return room.MemberUpdated{Member: m}, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// 订阅连接断开过:会话和事件流可能漏掉了房间的事件,等待中的请求可能收不到回复。会话和事件流以 1012 断开,
|
||||
// 客户端重连之后以新快照为准。
|
||||
func (g *Gateway) restart() {
|
||||
g.mu.Lock()
|
||||
g.epoch++
|
||||
g.mu.Unlock()
|
||||
g.closeAll(CloseRestart)
|
||||
g.mu.Lock()
|
||||
var ids []room.RequestID
|
||||
for id := range g.pending {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
g.mu.Unlock()
|
||||
for _, id := range ids {
|
||||
g.settle(id, nil, ErrUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) closeAll(reason CloseReason) {
|
||||
g.mu.Lock()
|
||||
var all []*sendQueue
|
||||
for _, set := range g.sessions {
|
||||
for s := range set {
|
||||
all = append(all, s.sendQueue)
|
||||
}
|
||||
}
|
||||
for _, set := range g.observers {
|
||||
for _, o := range set {
|
||||
all = append(all, o.sendQueue)
|
||||
}
|
||||
}
|
||||
g.mu.Unlock()
|
||||
for _, q := range all {
|
||||
q.close(reason)
|
||||
}
|
||||
}
|
||||
|
||||
// 订阅本实例的频道并刷新存活键:此后其他实例的网关才能把输入转发到这里,owner 也不会认为本实例的网关已经退出。
|
||||
func (g *Gateway) open(ctx context.Context) error {
|
||||
for {
|
||||
attemptCtx, cancel := context.WithTimeout(ctx, clusterTimeout)
|
||||
err := g.bus.SubscribeInstance(attemptCtx)
|
||||
if err == nil {
|
||||
_, err = g.liveness.Heartbeat(attemptCtx, aliveTTL)
|
||||
}
|
||||
cancel()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
g.logger.WarnContext(ctx, "订阅本实例的频道失败,稍后重试", "error", err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(retryInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) heartbeat(ctx context.Context) {
|
||||
ticker := time.NewTicker(heartbeatInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
beatCtx, cancel := context.WithTimeout(ctx, clusterTimeout)
|
||||
alive, err := g.liveness.Heartbeat(beatCtx, aliveTTL)
|
||||
cancel()
|
||||
// 存活键曾经过期(例如 Redis 被清空),owner 可能已经让本地会话离开了在线列表。
|
||||
if err == nil && !alive {
|
||||
for _, roomID := range g.joinedRooms() {
|
||||
g.rejoin(roomID, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) joinedRooms() []string {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return slices.Collect(maps.Keys(g.sessions))
|
||||
}
|
||||
|
||||
// 订阅连接断开时只通知 lost,不在这里处理:恢复订阅要等这里收到确认。
|
||||
func (g *Gateway) receive(ctx context.Context, lost chan<- struct{}) {
|
||||
for {
|
||||
d, err := g.bus.Receive(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
switch d := d.(type) {
|
||||
case port.RoomDelivery:
|
||||
g.deliver(d.Room, d.Message)
|
||||
case port.InputDelivery:
|
||||
g.host.deliver(d.Context, d.Input)
|
||||
case port.MemberDelivery:
|
||||
g.memberChanged(d.Member)
|
||||
case port.PreparedDelivery:
|
||||
g.host.preparer.Finished(d.Outcome)
|
||||
case port.SubscriptionLost:
|
||||
select {
|
||||
case lost <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 返回时订阅已经生效。确认过的订阅不再重复订阅和等待确认,Redis 不可用时请求也不必等待。
|
||||
// 用完之后调用 unhold。
|
||||
func (g *Gateway) hold(ctx context.Context, roomID string) (*roomLink, error) {
|
||||
for {
|
||||
g.mu.Lock()
|
||||
link := g.links[roomID]
|
||||
if link != nil && link.leaving != nil {
|
||||
leaving := link.leaving
|
||||
g.mu.Unlock()
|
||||
select {
|
||||
case <-leaving:
|
||||
continue
|
||||
case <-ctx.Done():
|
||||
return nil, context.Cause(ctx)
|
||||
}
|
||||
}
|
||||
if link == nil {
|
||||
link = &roomLink{}
|
||||
g.links[roomID] = link
|
||||
}
|
||||
link.users++
|
||||
if link.timer != nil {
|
||||
link.timer.Stop()
|
||||
link.timer = nil
|
||||
}
|
||||
epoch := g.epoch
|
||||
confirmed := link.confirmed == epoch
|
||||
g.mu.Unlock()
|
||||
if confirmed {
|
||||
return link, nil
|
||||
}
|
||||
subscribeCtx, cancel := context.WithTimeout(ctx, clusterTimeout)
|
||||
err := g.bus.SubscribeRoom(subscribeCtx, roomID)
|
||||
cancel()
|
||||
if err != nil {
|
||||
g.unhold(roomID, link, false)
|
||||
return nil, unavailable(err)
|
||||
}
|
||||
g.mu.Lock()
|
||||
link.confirmed = max(link.confirmed, epoch)
|
||||
g.mu.Unlock()
|
||||
return link, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) unhold(roomID string, link *roomLink, http bool) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
link.users--
|
||||
if http {
|
||||
link.lastHTTP = time.Now()
|
||||
}
|
||||
if link.users == 0 {
|
||||
link.timer = time.AfterFunc(time.Until(link.lastHTTP.Add(unsubscribeDelay)), func() { g.unsubscribe(roomID, link) })
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) unsubscribe(roomID string, link *roomLink) {
|
||||
g.mu.Lock()
|
||||
if g.links[roomID] != link || link.users > 0 || link.leaving != nil {
|
||||
g.mu.Unlock()
|
||||
return
|
||||
}
|
||||
link.leaving = make(chan struct{})
|
||||
g.mu.Unlock()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), clusterTimeout)
|
||||
_ = g.bus.UnsubscribeRoom(ctx, roomID)
|
||||
cancel()
|
||||
g.mu.Lock()
|
||||
delete(g.links, roomID)
|
||||
close(link.leaving)
|
||||
g.mu.Unlock()
|
||||
}
|
||||
|
||||
func (g *Gateway) register(s *Session) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.closing {
|
||||
return false
|
||||
}
|
||||
set := g.sessions[s.room]
|
||||
if set == nil {
|
||||
set = map[*Session]struct{}{}
|
||||
g.sessions[s.room] = set
|
||||
}
|
||||
set[s] = struct{}{}
|
||||
g.active.Add(1)
|
||||
return true
|
||||
}
|
||||
|
||||
func (g *Gateway) roomSessions(roomID string) []*Session {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
sessions := make([]*Session, 0, len(g.sessions[roomID]))
|
||||
for s := range g.sessions[roomID] {
|
||||
sessions = append(sessions, s)
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
|
||||
func (g *Gateway) roomObservers(roomID string) []*Observer {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return slices.Clone(g.observers[roomID])
|
||||
}
|
||||
|
||||
// 会话和事件流都收到房间的事件和关闭通知。
|
||||
func (g *Gateway) recipients(roomID string) []*sendQueue {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
var all []*sendQueue
|
||||
for s := range g.sessions[roomID] {
|
||||
all = append(all, s.sendQueue)
|
||||
}
|
||||
for _, o := range g.observers[roomID] {
|
||||
all = append(all, o.sendQueue)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (g *Gateway) newRequestID() room.RequestID {
|
||||
return room.RequestID(g.nextID("r"))
|
||||
}
|
||||
|
||||
func (g *Gateway) nextID(prefix string) string {
|
||||
return g.self + "/" + prefix + strconv.FormatUint(g.ids.Add(1), 10)
|
||||
}
|
||||
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user