cmd/msrserve: initial drop-in replacement server

This is still quite ugly and only meant to get things going for now, as
a proof of concept. It is usable though, and quite fast.

Signed-off-by: Yonah <contrib@gensokyo.uk>
This commit is contained in:
Yonah 2025-09-19 01:15:23 +09:00
parent 30599306f1
commit 8edf742aaa
Signed by: yonah
SSH Key Fingerprint: SHA256:vnQvK8+XXH9Tbni2AV1a/8qdVK/zPcXw52GM0ruQvwA
75 changed files with 426 additions and 0 deletions

1
.gitignore vendored
View File

@ -6,6 +6,7 @@
*.dylib
*.pkg
/msrfetch
/msrserve
# Content-addressed media files
/data

187
cmd/msrserve/api.go Normal file
View File

@ -0,0 +1,187 @@
package main
import (
"flag"
"log"
"net/http"
"strconv"
"strings"
"git.gensokyo.uk/yonah/monstersirenfetch"
)
var (
flagMetadataPath string
flagAutoplay string
)
func init() {
flag.StringVar(&flagMetadataPath, "m", "data/metadata", "Path to enriched metadata")
flag.StringVar(&flagAutoplay, "autoplay", "048794", "Value to set for autoplay field in /api/songs")
}
func handleAPINew(metadata *monstersirenfetch.Metadata) http.HandlerFunc {
if metadata == nil {
log.Fatal("invalid metadata")
}
const (
// TODO(ophestra): stub these out with local artwork
fontsetResp = `{"code":0,"msg":"","data":{"Sans-Regular":{"tt":"/static/SourceHanSansCN-Regular.ttf","eot":"/static/SourceHanSansCN-Regular.eot","svg":"/static/SourceHanSansCN-Regular.svg","woff":"/static/SourceHanSansCN-Regular.woff"},"Sans-Bold":{"tt":"/static/SourceHanSansCN-Bold.ttf","eot":"/static/SourceHanSansCN-Bold.eot","svg":"/static/SourceHanSansCN-Bold.svg","woff":"/static/SourceHanSansCN-Bold.woff"}}}`
recommendsResp = `{"code":0,"msg":"","data":[{"title":"#AUS小屋","coverUrl":"https://web.hycdn.cn/siren/pic/20230228/ba2bf4c42b3852a8b35e9721a8c8bbb7.jpg","cover":{"private":false,"path":"siren/pic/20230228/ba2bf4c42b3852a8b35e9721a8c8bbb7.jpg"},"description":"\"嗨,各位,这个冬天过得怎么样?\n我们一直呆在室内眼看着春天到来再不出门雪就要化了于是大家都被dan拉着去比赛堆雪人。虽然能动弹动弹是很不错但“这是为了阻止alty祸害厨房”这句话就很多余了不是吗\n我和frost堆到一半决定给雪人戴一个耳朵头箍frost还把围巾也围上去了造就了一个可爱的卡特斯。就在我们以为赢定了的时候回头看见dan正站在一个巨大的雪人旁边手舞足蹈。呃不得不说气势很惊人有一瞬间甘拜下风的念头。\n但是看了看被惊到的aya旁边的……小雪人我觉得我和frost的雪人至少","type":2,"data":"750452"},{"title":"#EMPEROR","coverUrl":"https://web.hycdn.cn/siren/pic/20230122/89969a75098c07cd8e289bad831ea4a2.jpg","cover":{"private":false,"path":"siren/pic/20230122/89969a75098c07cd8e289bad831ea4a2.jpg"},"description":"","type":2,"data":"578833"},{"title":"#AUS小屋","coverUrl":"https://web.hycdn.cn/siren/pic/20221128/ede2a04423632c17b702a656414a8c04.jpg","cover":{"private":false,"path":"siren/pic/20221128/ede2a04423632c17b702a656414a8c04.jpg"},"description":"一起用被炉(有点热!)","type":2,"data":"750452"},{"title":"#AUS小屋","coverUrl":"https://web.hycdn.cn/siren/pic/20220930/f4c7886eabcc1f3f178e7d4a9e5f7a21.jpg","cover":{"private":false,"path":"siren/pic/20220930/f4c7886eabcc1f3f178e7d4a9e5f7a21.jpg"},"description":"","type":2,"data":"336217"},{"title":"#D.D.D.PHOTO","coverUrl":"https://web.hycdn.cn/siren/pic/20221228/93393e28e1d0caff9b61c951eaf6a5f5.jpg","cover":{"private":false,"path":"siren/pic/20221228/93393e28e1d0caff9b61c951eaf6a5f5.jpg"},"description":"","type":2,"data":"241307"},{"title":"live演出","coverUrl":"https://web.hycdn.cn/siren/pic/20230427/b524e7b99982a972609de43998f61b46.jpg","cover":{"private":false,"path":"siren/pic/20230427/b524e7b99982a972609de43998f61b46.jpg"},"description":"\"春天好各位。AUS与仲春一起归来了。\n今天我们刚结束了新live的最后一场演出。我站在舞台中央被音浪和节奏包围着即使晃眼的灯光遮挡了视线也能感受到大家在台下拼命挥舞双手这同样让我很激动。\n我唱哑了嗓子alty弹到指头隐隐作痛frost不时就会揉揉手腕一向精力充沛的dan回工作室后也累瘫了但我们一路笑得很大声连frost也在旁边勾着嘴角。毕竟对于创作者来说欣赏者的热爱足以融化春寒和创作的倦怠对吧\"","type":2,"data":"605965"}]}`
)
var (
fontsetRespData = []byte(fontsetResp)
recommendsRespData = []byte(recommendsResp)
)
var albumsRespData []byte
{
resp := monstersirenfetch.AlbumsResponse{Data: make(monstersirenfetch.AlbumsData, len(metadata.Albums))}
for i := range metadata.Albums {
if !metadata.Albums[i].Copy(&resp.Data[i], monstersirenfetch.AlbumVariantBase) {
log.Fatal("cannot copy album metadata for base variant")
}
}
albumsRespData = mustMarshalJSON(resp)
}
// data, detail
albumCidData := make(map[int][2][]byte, len(metadata.Albums))
for i := range metadata.Albums {
var (
a = &metadata.Albums[i]
v monstersirenfetch.AlbumResponse
d [2][]byte
)
if !a.Copy(&v.Data, monstersirenfetch.AlbumVariantData) {
log.Fatal("cannot copy album metadata for data variant")
}
d[0] = mustMarshalJSON(&v)
if !a.Copy(&v.Data, monstersirenfetch.AlbumVariantDetail) {
log.Fatal("cannot copy album metadata for detail variant")
}
d[1] = mustMarshalJSON(&v)
albumCidData[int(a.CID)] = d
}
if len(albumCidData) != len(metadata.Albums) {
log.Fatalf("album has duplicate cid: %d != %d", len(albumCidData), len(metadata.Albums))
}
var songsRespData []byte
{
resp := monstersirenfetch.SongsResponse{Data: monstersirenfetch.SongsData{
List: make([]monstersirenfetch.Song, len(metadata.Songs)),
Autoplay: flagAutoplay,
}}
for i := range metadata.Songs {
if !metadata.Songs[i].Copy(&resp.Data.List[i], monstersirenfetch.SongVariantBase) {
log.Fatal("cannot copy song metadata for base variant")
}
}
songsRespData = mustMarshalJSON(resp)
}
songsCidData := make(map[int][]byte, len(metadata.Songs))
for i := range metadata.Songs {
var (
s = metadata.Songs[i]
v monstersirenfetch.SongResponse
)
if !s.Copy(&v.Data, monstersirenfetch.SongVariantFull) {
log.Fatal("cannot copy song metadata for full variant")
}
songsCidData[int(s.CID)] = mustMarshalJSON(&v)
}
if len(songsCidData) != len(metadata.Songs) {
log.Fatalf("song has duplicate cid: %d != %d", len(songsCidData), len(metadata.Songs))
}
log.Printf("loaded %d albums and %d songs", len(metadata.Albums), len(metadata.Songs))
return func(writer http.ResponseWriter, request *http.Request) {
if request.URL == nil {
log.Printf("got invalid request %p", request)
return
}
const (
prefix = "/api/"
endpointAlbum = prefix + "album/"
endpointSong = prefix + "song/"
endpointAlbumSuffixData = "/data"
endpointAlbumSuffixDetail = "/detail"
)
switch request.URL.Path {
case prefix + "fontset":
writeResp(writer, fontsetRespData)
case prefix + "recommends":
writeResp(writer, recommendsRespData)
case prefix + "albums":
writeResp(writer, albumsRespData)
case prefix + "songs":
writeResp(writer, songsRespData)
default:
if strings.HasPrefix(request.URL.Path, endpointAlbum) {
v := request.URL.Path[len(endpointAlbum):]
detail := strings.HasSuffix(v, endpointAlbumSuffixDetail)
if detail {
v = v[:len(v)-len(endpointAlbumSuffixDetail)]
} else if strings.HasSuffix(v, endpointAlbumSuffixData) {
v = v[:len(v)-len(endpointAlbumSuffixData)]
} else {
writer.WriteHeader(http.StatusNotFound)
writeResp(writer, []byte("unsupported album endpoint"))
return
}
if c, err := strconv.Atoi(v); err != nil {
writer.WriteHeader(http.StatusBadRequest)
writeResp(writer, []byte("invalid cid"))
return
} else if albumData, ok := albumCidData[c]; !ok {
writer.WriteHeader(http.StatusNotFound)
writeResp(writer, []byte("not found"))
return
} else if detail {
writeResp(writer, albumData[1])
return
} else {
writeResp(writer, albumData[0])
return
}
}
if strings.HasPrefix(request.URL.Path, endpointSong) {
v := request.URL.Path[len(endpointSong):]
if c, err := strconv.Atoi(v); err != nil {
writer.WriteHeader(http.StatusBadRequest)
writeResp(writer, []byte("invalid cid"))
return
} else if songData, ok := songsCidData[c]; !ok {
writer.WriteHeader(http.StatusNotFound)
writeResp(writer, []byte("not found"))
return
} else {
writeResp(writer, songData)
return
}
}
verboseln("api endpoint", request.URL.Path, "not implemented")
writer.WriteHeader(http.StatusNotFound)
writeResp(writer, []byte("null"))
}
}
}

45
cmd/msrserve/data.go Normal file
View File

@ -0,0 +1,45 @@
package main
import (
"flag"
"log"
"net/http"
"path"
"strings"
)
var (
flagFetchDirPath string
)
func init() {
flag.StringVar(&flagFetchDirPath, "d", "data", "Path to content-addressed store")
}
func handleDataNew(storePath string) http.HandlerFunc {
// hash string to url
urlMap := mustReadJSON[map[string]string](path.Join(storePath, "map"))
urlMapInverse := make(map[string]string, len(urlMap))
for hs, u := range urlMap {
urlMapInverse[u] = hs
}
log.Printf("inverted %d media entries", len(urlMap))
return func(writer http.ResponseWriter, request *http.Request) {
if request.URL == nil {
log.Printf("got invalid request %p", request)
return
}
fixedPath := strings.Replace(request.URL.Path, "/data/https:", "https:/", 1)
if hs, ok := urlMapInverse[fixedPath]; !ok {
verboseln("media path", fixedPath, "not found")
writer.WriteHeader(http.StatusNotFound)
writeResp(writer, []byte("not found"))
return
} else {
http.ServeFile(writer, request, path.Join(storePath, hs))
}
}
}

122
cmd/msrserve/main.go Normal file
View File

@ -0,0 +1,122 @@
package main
import (
"context"
"embed"
"encoding/json"
"errors"
"flag"
"io/fs"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"git.gensokyo.uk/yonah/monstersirenfetch"
)
const shutdownTimeout = 5 * time.Second
var (
flagVerbose bool
flagAddr string
)
func init() {
flag.BoolVar(&flagVerbose, "v", false, "Increase log verbosity")
flag.StringVar(&flagAddr, "a", ":3000",
`TCP address for the server to listen on, in the form "host:port".`)
}
var (
//go:embed all:static
staticFS embed.FS
)
func main() {
log.SetFlags(0)
log.SetPrefix("msrserve: ")
flag.Parse()
mux := http.NewServeMux()
mux.HandleFunc("/", http.FileServerFS(mustSub(staticFS, "static")).ServeHTTP)
mux.HandleFunc("/{$}", serveAppShell)
mux.HandleFunc("/about/{$}", serveAppShell)
mux.HandleFunc("/music/", serveAppShell)
mux.HandleFunc("/info/{$}", serveAppShell)
mux.HandleFunc("/contact/{$}", serveAppShell)
mux.HandleFunc("GET /api/", handleAPINew(mustReadJSON[*monstersirenfetch.Metadata](flagMetadataPath)))
mux.HandleFunc("/data/", handleDataNew(flagFetchDirPath))
s := http.Server{Addr: flagAddr, Handler: mux}
sig := make(chan os.Signal, 2)
go func() {
defer signal.Stop(sig)
v := <-sig
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
log.Print(v)
if err := s.Shutdown(ctx); err != nil {
log.Fatal(err)
}
}()
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
if err := s.ListenAndServe(); err != nil {
if !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}
}
func serveAppShell(w http.ResponseWriter, r *http.Request) {
http.ServeFileFS(w, r, staticFS, "static/appShell.html")
}
func writeResp(writer http.ResponseWriter, data []byte) bool {
if _, err := writer.Write(data); err != nil {
verboseln(err)
return false
}
return true
}
func mustMarshalJSON(v any) []byte {
if data, err := json.Marshal(v); err != nil {
log.Fatal(err)
return nil
} else {
return data
}
}
func mustReadJSON[T any](pathname string) T {
var v T
if r, err := os.OpenFile(pathname, os.O_RDONLY, 0); err != nil {
log.Fatal(err)
} else if err = json.NewDecoder(r).Decode(&v); err != nil {
log.Fatal(err)
} else if err = r.Close(); err != nil {
log.Fatal(err)
}
return v
}
func mustSub(fsys fs.FS, dir string) fs.FS {
if sub, err := fs.Sub(fsys, dir); err != nil {
log.Fatal(err)
return nil
} else {
return sub
}
}
func verboseln(v ...any) {
if flagVerbose {
log.Println(v...)
}
}

View File

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no"
/>
<link
rel="shortcut icon"
type="image/x-icon"
href="/favicon.ico"
/>
<link
rel="stylesheet"
href="/assets/umi.62693412.css"
/>
<script>
window.routerBase = "/";
</script>
<script>
//! umi version: 3.2.16
</script>
</head>
<body>
<div id="root"></div>
<script src="/assets/umi.87fedd26.js"></script>
</body>
</html>

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@ -0,0 +1 @@
/*! hg-web-sdk@2.10.11 Copyright (C) 2017 - 2025 Hypergryph Co.,Ltd. All Rights Reserved. Published at 2025-09-12 11:52:40 */!function(){"use strict";var e,t,n,r,o={7384:function(e){e.exports=void 0}},f={};function c(e){var t=f[e];if(void 0!==t)return t.exports;var n=f[e]={id:e,exports:{}};return o[e].call(n.exports,n,n.exports,c),n.exports}c.m=o,c.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return c.d(t,{a:t}),t},t=Object.getPrototypeOf?function(e){return Object.getPrototypeOf(e)}:function(e){return e.__proto__},c.t=function(n,r){if(1&r&&(n=this(n)),8&r)return n;if("object"==typeof n&&n){if(4&r&&n.__esModule)return n;if(16&r&&"function"==typeof n.then)return n}var o=Object.create(null);c.r(o);var f={};e=e||[null,t({}),t([]),t(t)];for(var u=2&r&&n;"object"==typeof u&&!~e.indexOf(u);u=t(u))Object.getOwnPropertyNames(u).forEach((function(e){f[e]=function(){return n[e]}}));return f.default=function(){return n},c.d(o,f),o},c.d=function(e,t){for(var n in t)c.o(t,n)&&!c.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:t[n]})},c.f={},c.e=function(e){return Promise.all(Object.keys(c.f).reduce((function(t,n){return c.f[n](e,t),t}),[]))},c.u=function(e){return e+"."+{28:"8a36160d124e1fc0d695",93:"ded0c4ea6557e13e450a",121:"5496c920e3be96ec1c85",129:"262222be26cc102d11d9",306:"7ea4490145ac18b4b0f6",374:"dca7e29a1476aafa8ab1",389:"c4c636ec9a56be1b5965",533:"8647f436c1f4cfe38581",576:"91297a5c93a1b33d078e",590:"fe49eee5dbd7d2e738aa",592:"91c1d092dcffddfa3356",616:"9b2b6c931fdd0cfbcc34",623:"44d788954a39216e54ea",664:"76278e41ec6bc96a5535",694:"8f6120ad4997e6b2885a",754:"38b331149223a3b5db08",787:"24cdd1c202257bf4fadb",852:"9317493ac1a6da74b065",881:"28683b921b7e29e534be",987:"80ad9ecfa1a14168688b"}[e]+".js"},c.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),c.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n={},r="@hg/hg-web-sdk:",c.l=function(e,t,o,f){if(n[e])n[e].push(t);else{var u,i;if(void 0!==o)for(var a=document.getElementsByTagName("script"),d=0;d<a.length;d++){var b=a[d];if(b.getAttribute("src")==e||b.getAttribute("data-webpack")==r+o){u=b;break}}u||(i=!0,(u=document.createElement("script")).charset="utf-8",u.timeout=120,c.nc&&u.setAttribute("nonce",c.nc),u.setAttribute("data-webpack",r+o),u.src=e),n[e]=[t];var l=function(t,r){u.onerror=u.onload=null,clearTimeout(s);var o=n[e];if(delete n[e],u.parentNode&&u.parentNode.removeChild(u),o&&o.forEach((function(e){return e(r)})),t)return t(r)},s=setTimeout(l.bind(null,void 0,{type:"timeout",target:u}),12e4);u.onerror=l.bind(null,u.onerror),u.onload=l.bind(null,u.onload),i&&document.head.appendChild(u)}},c.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},c.p="/assets/hg_web_sdk/lib/",function(){c.b=document.baseURI||self.location.href;var e={650:0};c.f.j=function(t,n){var r=c.o(e,t)?e[t]:void 0;if(0!==r)if(r)n.push(r[2]);else{var o=new Promise((function(n,o){r=e[t]=[n,o]}));n.push(r[2]=o);var f=c.p+c.u(t),u=new Error;c.l(f,(function(n){if(c.o(e,t)&&(0!==(r=e[t])&&(e[t]=void 0),r)){var o=n&&("load"===n.type?"missing":n.type),f=n&&n.target&&n.target.src;u.message="Loading chunk "+t+" failed.\n("+o+": "+f+")",u.name="ChunkLoadError",u.type=o,u.request=f,r[1](u)}}),"chunk-"+t,t)}};var t=function(t,n){var r,o,f=n[0],u=n[1],i=n[2],a=0;if(f.some((function(t){return 0!==e[t]}))){for(r in u)c.o(u,r)&&(c.m[r]=u[r]);if(i)i(c)}for(t&&t(n);a<f.length;a++)o=f[a],c.o(e,o)&&e[o]&&e[o][0](),e[o]=0},n=self.webpackChunk_hg_hg_web_sdk=self.webpackChunk_hg_hg_web_sdk||[];n.forEach(t.bind(null,0)),n.push=t.bind(null,n.push.bind(n))}(),c.nc=void 0,c.e(129).then(c.bind(c,9129))}();

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 41 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 92 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 840 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 47 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 131 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 756 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 995 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 659 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<path fill="none" stroke="#c6c9ce" stroke-width="2px" d="M 4 4 l 8 24 l 19 -2 Z" />
</svg>

After

Width:  |  Height:  |  Size: 175 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 36 36"><path fill="#c6c9ce" d="M33 18A15 15 0 1118 3a15 15 0 0115 15zm-15-5a5 5 0 105 5 5 5 0 00-5-5z"/><path fill="#c6c9ce" d="M13 34c-7-2-8-3-11-11a22 22 0 0011 11z" /></svg>

After

Width:  |  Height:  |  Size: 252 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<path fill="#c6c9ce" stroke="#c6c9ce" stroke-width="2px" d="M 4 4 l 8 24 l 19 -2 Z" />
</svg>

After

Width:  |  Height:  |  Size: 178 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

File diff suppressed because one or more lines are too long