diff --git a/.gitignore b/.gitignore index 8274916..8ead06c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ *.dylib *.pkg /msrfetch +/msrserve # Content-addressed media files /data diff --git a/cmd/msrserve/api.go b/cmd/msrserve/api.go new file mode 100644 index 0000000..54fa967 --- /dev/null +++ b/cmd/msrserve/api.go @@ -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")) + } + } +} diff --git a/cmd/msrserve/data.go b/cmd/msrserve/data.go new file mode 100644 index 0000000..85e740a --- /dev/null +++ b/cmd/msrserve/data.go @@ -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)) + } + } +} diff --git a/cmd/msrserve/main.go b/cmd/msrserve/main.go new file mode 100644 index 0000000..0f13d18 --- /dev/null +++ b/cmd/msrserve/main.go @@ -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...) + } +} diff --git a/cmd/msrserve/static/appShell.html b/cmd/msrserve/static/appShell.html new file mode 100644 index 0000000..49e55f0 --- /dev/null +++ b/cmd/msrserve/static/appShell.html @@ -0,0 +1,30 @@ + + +
+ + + + + + + + + + + + + diff --git a/cmd/msrserve/static/assets/hg_web_sdk/lib/129.262222be26cc102d11d9.js b/cmd/msrserve/static/assets/hg_web_sdk/lib/129.262222be26cc102d11d9.js new file mode 100644 index 0000000..3a7cc78 --- /dev/null +++ b/cmd/msrserve/static/assets/hg_web_sdk/lib/129.262222be26cc102d11d9.js @@ -0,0 +1,19 @@ +/*! hg-web-sdk@2.10.11 Copyright (C) 2017 - 2025 Hypergryph Co.,Ltd. All Rights Reserved. Published at 2025-09-12 11:52:40 */ +(self.webpackChunk_hg_hg_web_sdk=self.webpackChunk_hg_hg_web_sdk||[]).push([[129],{9412:function(t,e,n){!function(t){"use strict";function e(t,e,n,r){if("a"===n&&!r)throw new TypeError("Private accessor was defined without a getter");if("function"==typeof e?t!==e||!r:!e.has(t))throw new TypeError("Cannot read private member from an object whose class did not declare it");return"m"===n?r:"a"===n?r.call(t):r?r.value:e.get(t)}function r(t,e,n,r,i){if("m"===r)throw new TypeError("Private method is not writable");if("a"===r&&!i)throw new TypeError("Private accessor was defined without a setter");if("function"==typeof e?t!==e||!i:!e.has(t))throw new TypeError("Cannot write private member to an object whose class did not declare it");return"a"===r?i.call(t,n):i?i.value=n:e.set(t,n),n}function i(t){return i="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},i(t)}var o="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof window?window:void 0!==n.g?n.g:"undefined"!=typeof self?self:{},a={};!function(t,e){!function(n,r){var o="1.0.34",a="",s="?",u="function",c="undefined",l="object",d="string",h="major",f="model",p="name",v="type",g="vendor",m="version",b="architecture",_="console",w="mobile",y="tablet",k="smarttv",S="wearable",E="embedded",x=350,A="Amazon",T="Apple",O="ASUS",C="BlackBerry",I="Browser",G="Chrome",P="Firefox",R="Google",j="Huawei",L="LG",N="Microsoft",z="Motorola",D="Opera",U="Samsung",J="Sharp",B="Sony",q="Xiaomi",H="Zebra",M="Facebook",Z="Chromium OS",F="Mac OS",K=function(t,e){var n={};for(var r in t)e[r]&&e[r].length%2==0?n[r]=e[r].concat(t[r]):n[r]=t[r];return n},V=function(t){for(var e={},n=0;n>1,m=23===t?a(2,-24)-a(2,-77):0,g=e<0||0===e&&1/e<0?1:0,y=0;for(e=r(e),e!=e||e===n?(l=e!=e?1:0,s=h):(s=i(o(e)/u),e*(f=a(2,-s))<1&&(s--,f*=2),e+=s+v>=1?m/f:m*a(2,1-v),e*f>=2&&(s++,f/=2),s+v>=h?(l=0,s=h):s+v>=1?(l=(e*f-1)*a(2,t),s+=v):(l=e*a(2,v-1)*a(2,t),s=0));t>=8;d[y++]=255&l,l/=256,t-=8);for(s=s< =i&&(r=0,g(),s.remaining?(n=a,m("loopComplete"),s.loopBegan=!1,"alternate"===s.direction&&l()):(s.paused=!0,s.completed||(s.completed=!0,m("loopComplete"),m("complete"),!s.passThrough&&"Promise"in window&&(o(),c(s)))))}return s.reset=function(){var e=s.direction;s.passThrough=!1,s.currentTime=0,s.progress=0,s.paused=!0,s.began=!1,s.loopBegan=!1,s.changeBegan=!1,s.completed=!1,s.changeCompleted=!1,s.reversePlayback=!1,s.reversed="reverse"===e,s.remaining=s.loop,t=s.children,i=t.length;for(var n=i;n--;)s.children[n].reset();(s.reversed&&!0!==s.loop||"alternate"===e&&1===s.loop)&&s.remaining++,v(s.reversed?s.duration:0)},s.set=function(e,t){return ce(e,t),s},s.tick=function(e){a=e,n||(n=a),b((a+(r-n))*be.speed)},s.seek=function(e){b(f(e))},s.pause=function(){s.paused=!0,d()},s.play=function(){s.paused&&(s.completed&&s.reset(),s.paused=!1,ve.push(s),d(),he||ge())},s.reverse=function(){l(),s.completed=!s.reversed,d()},s.restart=function(){s.reset(),s.play()},s.reset(),s.autoplay&&s.play(),s}function Ee(e,t){for(var n=t.length;n--;)w(e,t[n].animatable.target)&&t.splice(n,1)}function we(e){for(var t=ee(e),n=ve.length;n--;){var r=ve[n],a=r.animations,i=r.children;Ee(t,a);for(var o=i.length;o--;){var u=i[o],c=u.animations;Ee(t,c),c.length||u.children.length||i.splice(o,1)}a.length||i.length||r.pause()}}function _e(e,t){void 0===t&&(t={});var n=t.direction||"normal",r=t.easing?m(t.easing):null,a=t.grid,i=t.axis,o=t.from||0,u="first"===o,c="center"===o,s="last"===o,f=l.arr(e),d=f?parseFloat(e[0]):parseFloat(e),p=f?parseFloat(e[1]):0,h=A(f?e[1]:e)||0,v=t.start||0+(f?d:0),g=[],y=0;return function(e,t,l){if(u&&(o=0),c&&(o=(l-1)/2),s&&(o=l-1),!g.length){for(var m=0;m1?n[a-1]:i,u=a>2?n[2]:i;o=e.length>3&&"function"==typeof o?(a--,o):i,u&&iu(n[0],n[1],u)&&(o=a<3?i:o,a=1),t=nt(t);while(++r-1?a[o?t[u]:u]:i}}function mo(e){return Io((function(t){var n=t.length,r=n,a=xr.prototype.thru;e&&t.reverse();while(r--){var o=t[r];if("function"!=typeof o)throw new it(s);if(a&&!u&&"wrapper"==Bo(o))var u=new xr([],!0)}r=u?r:n;while(++r