feat: add backend, database, and protocol foundations

This commit is contained in:
DiheChen committed 2026-10-30 01:32:19 +08:00
commit ec08d487e1
179 files changed
+27079

No files matched your search

+358
View File
@@ -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)
}
+64
View File
@@ -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
}
+255
View File
@@ -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
}
+141
View File
@@ -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
}
+206
View File
@@ -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
}
+75
View File
@@ -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)
}
+101
View File
@@ -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}
}
+98
View File
@@ -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)
}
+27
View File
@@ -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
}
+299
View File
@@ -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)
}
}
}
+19
View File
@@ -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)
}
+25
View File
@@ -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
}
+145
View File
@@ -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: ")
}
+234
View File
@@ -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
}
+211
View File
@@ -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))
}
+171
View File
@@ -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
}
+167
View File
@@ -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
}
+99
View File
@@ -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
}
+142
View File
@@ -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
}
+41
View File
@@ -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
}
+220
View File
@@ -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
}
+77
View File
@@ -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)...)
}
+126
View File
@@ -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
}
+355
View File
@@ -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", "")
}
+59
View File
@@ -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})
}
+47
View File
@@ -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
}
+67
View File
@@ -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
}
+49
View File
@@ -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)}
}
+64
View File
@@ -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
}
+54
View File
@@ -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
}
+128
View File
@@ -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
}
+31
View File
@@ -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
}
+243
View File
@@ -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
}
+39
View File
@@ -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
}
+51
View File
@@ -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
}
+64
View File
@@ -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
}
+41
View File
@@ -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 ""
}
+199
View File
@@ -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", "")
}
+821
View File
@@ -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
}
+83
View File
@@ -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 音乐没有提供这首歌的播放地址")}
}
+217
View File
@@ -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,
},
}
}
+113
View File
@@ -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
}
+395
View File
@@ -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)
}
+19
View File
@@ -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
}
+218
View File
@@ -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
}
+135
View File
@@ -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()
}
+50
View File
@@ -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
}
+34
View File
@@ -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
}
+152
View File
@@ -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
}
+91
View File
@@ -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...)
}
}
+147
View File
@@ -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()
}
+277
View File
@@ -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 ""
}
+287
View File
@@ -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
}
+75
View File
@@ -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
}
+281
View File
@@ -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)]
}
+139
View File
@@ -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}
}
+78
View File
@@ -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
}
+40
View File
@@ -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[:])
}
+90
View File
@@ -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
}
+57
View File
@@ -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)
}
+21
View File
@@ -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 }
+133
View File
@@ -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
}
+83
View File
@@ -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
}
+26
View File
@@ -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
}
+41
View File
@@ -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)
}
+120
View File
@@ -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)
}
+19
View File
@@ -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)
}
+33
View File
@@ -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)
}
+30
View File
@@ -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
}
+19
View File
@@ -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)
}
+37
View File
@@ -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)
}
+33
View File
@@ -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)
}
+39
View File
@@ -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
}
+18
View File
@@ -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)
}
+13
View File
@@ -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)
}
+45
View File
@@ -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
}
+9
View File
@@ -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
}
+19
View File
@@ -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
}
+112
View File
@@ -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)
}
+36
View File
@@ -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)
}
+22
View File
@@ -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
}
+206
View File
@@ -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
}
+158
View File
@@ -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)
}
}
+76
View File
@@ -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
}
+765
View File
@@ -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