├── internal ├── pkg │ ├── ark │ │ ├── version.go │ │ └── server.go │ ├── arkscanner │ │ ├── scanner.go │ │ ├── scanner_picture.go │ │ └── scanner_character.go │ ├── util │ │ ├── cloze │ │ │ └── ignore.go │ │ ├── pathutil │ │ │ ├── pattern.go │ │ │ ├── ext.go │ │ │ └── parent.go │ │ └── fileutil │ │ │ └── files.go │ ├── arkprocessor │ │ ├── processor.go │ │ ├── processor_picture.go │ │ └── processor_character.go │ ├── cols │ │ ├── map.go │ │ └── filter.go │ ├── arkjson │ │ └── arkjson.go │ ├── arkdata │ │ ├── download.go │ │ ├── unzip.go │ │ ├── data_test.go │ │ └── data.go │ ├── arkparser │ │ ├── story_pictures.go │ │ ├── story_review_meta_table.go │ │ ├── directive.go │ │ ├── story_characters.go │ │ ├── story_review_table.go │ │ └── arkparser.go │ └── arkassets │ │ ├── assets_test.go │ │ ├── unzip.go │ │ ├── fetch.go │ │ ├── unpack.go │ │ ├── raw.go │ │ └── assets.go └── app │ ├── story │ ├── model_tree.go │ ├── story.go │ ├── model_art_picture.go │ ├── model_group.go │ ├── model_art_character.go │ ├── model_story.go │ ├── controller.go │ ├── repo.go │ └── service.go │ ├── artext │ ├── artext.go │ ├── service.go │ ├── controller.go │ ├── ext_character.go │ └── ext_story.go │ ├── app.go │ ├── gallery │ ├── gallery.go │ ├── service.go │ ├── model.go │ ├── controller.go │ └── repository.go │ ├── art │ ├── art.go │ ├── art_content.go │ ├── art_art.go │ ├── art_variant.go │ ├── service.go │ ├── controller.go │ └── repository.go │ ├── infra │ ├── fiber.go │ ├── config.go │ └── gorm.go │ └── updateloop │ ├── update_art_thumbnail.go │ ├── update_story.go │ ├── updateloop.go │ ├── repo_version.go │ ├── update_gallery.go │ └── update_art.go ├── deploy ├── reverse_proxy │ ├── Dockerfile │ └── Caddyfile └── docker-compose.yml ├── assets ├── arkwaifu_phantom.png ├── arkwaifu_phantom@0.25x.png └── README.md ├── tools └── extractor │ ├── requirements.txt │ ├── .gitignore │ └── main.py ├── cmd ├── service │ └── main.go └── updateloop │ └── main.go ├── .github └── workflows │ ├── automatic-release.yml │ ├── docker-image-service.yml │ └── docker-image-updateloop.yml ├── .gitignore ├── LICENSE ├── go.mod └── README.md /internal/pkg/ark/version.go: -------------------------------------------------------------------------------- 1 | package ark 2 | 3 | type Version = string 4 | -------------------------------------------------------------------------------- /internal/app/story/model_tree.go: -------------------------------------------------------------------------------- 1 | package story 2 | 3 | type Tree = []Group 4 | -------------------------------------------------------------------------------- /deploy/reverse_proxy/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM caddy:2.6 2 | 3 | COPY Caddyfile /etc/caddy/Caddyfile 4 | -------------------------------------------------------------------------------- /assets/arkwaifu_phantom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlandiaYingman/arkwaifu/HEAD/assets/arkwaifu_phantom.png -------------------------------------------------------------------------------- /internal/pkg/arkscanner/scanner.go: -------------------------------------------------------------------------------- 1 | package arkscanner 2 | 3 | type Scanner struct { 4 | Root string 5 | } 6 | -------------------------------------------------------------------------------- /assets/arkwaifu_phantom@0.25x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlandiaYingman/arkwaifu/HEAD/assets/arkwaifu_phantom@0.25x.png -------------------------------------------------------------------------------- /deploy/reverse_proxy/Caddyfile: -------------------------------------------------------------------------------- 1 | http:// 2 | 3 | reverse_proxy * {$FRONTEND_ADDRESS} 4 | reverse_proxy /api* {$BACKEND_ADDRESS} 5 | -------------------------------------------------------------------------------- /assets/README.md: -------------------------------------------------------------------------------- 1 | # Assets 2 | 3 | All resources in this directory are licensed under 4 | [Attribution-NonCommercial 4.0 International (CC BY-NC 4.0)](https://creativecommons.org/licenses/by-nc/4.0/). -------------------------------------------------------------------------------- /internal/pkg/util/cloze/ignore.go: -------------------------------------------------------------------------------- 1 | package cloze 2 | 3 | import "io" 4 | 5 | // IgnoreErr closes a closer but ignore its error explicitly. 6 | func IgnoreErr(closer io.Closer) { 7 | _ = closer.Close() 8 | } 9 | -------------------------------------------------------------------------------- /tools/extractor/requirements.txt: -------------------------------------------------------------------------------- 1 | archspec==0.2.5 2 | astc-encoder-py==0.1.11 3 | attrs==25.3.0 4 | Brotli==1.1.0 5 | etcpak==0.9.13 6 | fsspec==2025.3.2 7 | lz4==4.4.4 8 | pillow==11.1.0 9 | pyfmodex==0.7.2 10 | texture2ddecoder==1.0.5 11 | UnityPy==1.21.3 -------------------------------------------------------------------------------- /internal/pkg/arkprocessor/processor.go: -------------------------------------------------------------------------------- 1 | package arkprocessor 2 | 3 | // Processor provides functionalities for processing art. The term "process" 4 | // here refers to merging color channel and alpha channel of arts together, 5 | // merging faces variation and bodies of character arts together, etc. 6 | type Processor struct { 7 | Root string 8 | } 9 | -------------------------------------------------------------------------------- /internal/app/story/story.go: -------------------------------------------------------------------------------- 1 | package story 2 | 3 | import "go.uber.org/fx" 4 | 5 | func FxModule() fx.Option { 6 | return fx.Module("story", 7 | fx.Provide( 8 | newRepo, 9 | newService, 10 | newController, 11 | ), 12 | fx.Invoke( 13 | fx.Annotate( 14 | registerController, 15 | fx.ParamTags(``, `optional:"true"`), 16 | ), 17 | ), 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /internal/app/artext/artext.go: -------------------------------------------------------------------------------- 1 | package artext 2 | 3 | import ( 4 | "go.uber.org/fx" 5 | ) 6 | 7 | func FxModule() fx.Option { 8 | registerController := func(controller *Controller, param RegisterParams) { controller.register(param) } 9 | 10 | return fx.Module("art-extension", 11 | fx.Provide( 12 | newService, 13 | newController, 14 | ), 15 | fx.Invoke(registerController), 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /cmd/service/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/flandiayingman/arkwaifu/internal/app" 5 | "github.com/flandiayingman/arkwaifu/internal/app/infra" 6 | "go.uber.org/fx" 7 | ) 8 | 9 | func main() { 10 | fxApp := fx.New( 11 | fx.Provide( 12 | infra.ProvideConfig, 13 | infra.ProvideFiber, 14 | infra.ProvideGorm, 15 | ), 16 | app.FxModules(), 17 | ) 18 | fxApp.Run() 19 | } 20 | -------------------------------------------------------------------------------- /internal/app/artext/service.go: -------------------------------------------------------------------------------- 1 | package artext 2 | 3 | import ( 4 | "github.com/flandiayingman/arkwaifu/internal/app/art" 5 | "github.com/flandiayingman/arkwaifu/internal/app/story" 6 | ) 7 | 8 | type Service struct { 9 | art *art.Service 10 | story *story.Service 11 | } 12 | 13 | func newService(serviceArt *art.Service, serviceStory *story.Service) *Service { 14 | return &Service{ 15 | art: serviceArt, 16 | story: serviceStory, 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /cmd/updateloop/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/flandiayingman/arkwaifu/internal/app" 5 | "github.com/flandiayingman/arkwaifu/internal/app/infra" 6 | "github.com/flandiayingman/arkwaifu/internal/app/updateloop" 7 | "go.uber.org/fx" 8 | ) 9 | 10 | func main() { 11 | fxApp := fx.New( 12 | fx.Provide( 13 | infra.ProvideConfig, 14 | infra.ProvideGorm, 15 | ), 16 | app.FxModules(), 17 | updateloop.FxModule(), 18 | ) 19 | fxApp.Run() 20 | } 21 | -------------------------------------------------------------------------------- /internal/pkg/cols/map.go: -------------------------------------------------------------------------------- 1 | package cols 2 | 3 | func Map[In any, Out any](in []In, f func(i In) (o Out)) (out []Out) { 4 | for _, element := range in { 5 | out = append(out, f(element)) 6 | } 7 | return 8 | } 9 | 10 | func MapErr[In any, Out any](in []In, f func(i In) (o Out, err error)) (out []Out, err error) { 11 | for _, i := range in { 12 | var o Out 13 | o, err = f(i) 14 | if err != nil { 15 | return 16 | } 17 | out = append(out, o) 18 | } 19 | return 20 | } 21 | -------------------------------------------------------------------------------- /internal/app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/flandiayingman/arkwaifu/internal/app/art" 5 | "github.com/flandiayingman/arkwaifu/internal/app/artext" 6 | "github.com/flandiayingman/arkwaifu/internal/app/gallery" 7 | "github.com/flandiayingman/arkwaifu/internal/app/story" 8 | "go.uber.org/fx" 9 | ) 10 | 11 | func FxModules() fx.Option { 12 | return fx.Options( 13 | artext.FxModule(), 14 | art.FxModule(), 15 | story.FxModule(), 16 | gallery.FxModule(), 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /internal/app/gallery/gallery.go: -------------------------------------------------------------------------------- 1 | package gallery 2 | 3 | import "go.uber.org/fx" 4 | 5 | // FxModule teaches a fx.App how to instantiate an art.Service, and also 6 | // optionally register its HTTP endpoints on api.V1 if provided. 7 | func FxModule() fx.Option { 8 | return fx.Module("gallery", 9 | fx.Provide( 10 | newRepository, 11 | newService, 12 | newController, 13 | ), 14 | fx.Invoke( 15 | fx.Annotate( 16 | registerController, 17 | fx.ParamTags(``, `optional:"true"`), 18 | ), 19 | ), 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /internal/pkg/cols/filter.go: -------------------------------------------------------------------------------- 1 | package cols 2 | 3 | func Filter[T any](in []T, predicate func(element T) bool) (out []T) { 4 | for _, element := range in { 5 | if predicate(element) { 6 | out = append(out, element) 7 | } 8 | } 9 | return 10 | } 11 | 12 | func FilterErr[T any](in []T, predicate func(element T) (bool, error)) (out []T, err error) { 13 | for _, element := range in { 14 | ok, err := predicate(element) 15 | if err != nil { 16 | return nil, err 17 | } 18 | if ok { 19 | out = append(out, element) 20 | } 21 | } 22 | return 23 | } 24 | -------------------------------------------------------------------------------- /internal/pkg/arkjson/arkjson.go: -------------------------------------------------------------------------------- 1 | package arkjson 2 | 3 | import ( 4 | "github.com/Jeffail/gabs/v2" 5 | "path/filepath" 6 | ) 7 | 8 | const ( 9 | StoryReviewMetaTablePath = "assets/torappu/dynamicassets/gamedata/excel/story_review_meta_table.json" 10 | ReplicateTable = "assets/torappu/dynamicassets/gamedata/excel/replicate_table.json" 11 | RetroTable = "assets/torappu/dynamicassets/gamedata/excel/retro_table.json" 12 | RoguelikeTopicTable = "assets/torappu/dynamicassets/gamedata/excel/roguelike_topic_table.json" 13 | ) 14 | 15 | func Get(root string, path string) (*gabs.Container, error) { 16 | json, err := gabs.ParseJSONFile(filepath.Join(root, path)) 17 | if err != nil { 18 | return nil, err 19 | } 20 | return json, nil 21 | } 22 | -------------------------------------------------------------------------------- /internal/app/artext/controller.go: -------------------------------------------------------------------------------- 1 | package artext 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "go.uber.org/fx" 6 | ) 7 | 8 | type Controller struct { 9 | service *Service 10 | } 11 | 12 | func newController(service *Service) *Controller { 13 | return &Controller{service: service} 14 | } 15 | 16 | type RegisterParams struct { 17 | fx.In 18 | Router fiber.Router `optional:"true"` 19 | } 20 | 21 | func (c *Controller) register(params RegisterParams) { 22 | router := params.Router 23 | if router == nil { 24 | return 25 | } 26 | 27 | // extension of story 28 | router.Get("arts", 29 | c.GetArtsOfStoryGroup, 30 | c.GetArtsOfStory, 31 | c.GetArtsExceptForStoryArts, 32 | ) 33 | 34 | // extension of character 35 | router.Get("arts/:id/siblings", c.GetSiblingsOfCharacterArt) 36 | } 37 | -------------------------------------------------------------------------------- /internal/app/story/model_art_picture.go: -------------------------------------------------------------------------------- 1 | package story 2 | 3 | import "github.com/flandiayingman/arkwaifu/internal/pkg/ark" 4 | 5 | type PictureArt struct { 6 | Server ark.Server `json:"server" gorm:"primaryKey;type:game_server"` 7 | ID string `json:"id" gorm:"primaryKey;check:id=lower(id)"` 8 | StoryID string `json:"storyID" gorm:"primaryKey"` 9 | Category string `json:"category" gorm:""` 10 | 11 | Title string `json:"title" gorm:""` 12 | Subtitle string `json:"subtitle" gorm:""` 13 | 14 | SortID *uint64 `json:"-" gorm:"unique;autoIncrement"` 15 | } 16 | 17 | type AggregatedPictureArt struct { 18 | Server ark.Server `json:"server"` 19 | ID string `json:"id"` 20 | Category string `json:"category"` 21 | 22 | Title string `json:"title"` 23 | Subtitle string `json:"subtitle"` 24 | } 25 | -------------------------------------------------------------------------------- /internal/app/art/art.go: -------------------------------------------------------------------------------- 1 | // Package art provides functionalities related to Art and Variant of the game, 2 | // including serving them, manipulating them and keep them persisted. 3 | // 4 | // This package exposes its main interfaces by fx via FxModule function. However, 5 | // it is still easy to instantiate an art.Service by simply creating it. 6 | package art 7 | 8 | import ( 9 | "go.uber.org/fx" 10 | ) 11 | 12 | // FxModule teaches a fx.App how to instantiate an art.Service, and also 13 | // optionally register its HTTP endpoints on api.V1 if provided. 14 | func FxModule() fx.Option { 15 | return fx.Module("art", 16 | fx.Provide( 17 | newRepo, 18 | newService, 19 | newController, 20 | ), 21 | fx.Invoke( 22 | fx.Annotate( 23 | registerController, 24 | fx.ParamTags(``, `optional:"true"`), 25 | ), 26 | ), 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /internal/app/story/model_group.go: -------------------------------------------------------------------------------- 1 | package story 2 | 3 | import "github.com/flandiayingman/arkwaifu/internal/pkg/ark" 4 | 5 | type Group struct { 6 | Server ark.Server `json:"server" gorm:"primaryKey;type:game_server"` 7 | ID string `json:"id" gorm:"primaryKey;check:id=lower(id)"` 8 | Name string `json:"name" gorm:""` 9 | Type GroupType `json:"type" gorm:"type:story_group_type"` 10 | Stories []Story `json:"stories" gorm:"foreignKey:Server,GroupID;reference:Server,ID;constraint:OnDelete:CASCADE"` 11 | 12 | SortID *uint64 `json:"-" gorm:"unique;autoIncrement"` 13 | } 14 | 15 | type GroupType = string 16 | 17 | const ( 18 | GroupTypeMainStory GroupType = "main-story" 19 | GroupTypeMajorEvent GroupType = "major-event" 20 | GroupTypeMinorEvent GroupType = "minor-event" 21 | GroupTypeOther GroupType = "other" 22 | ) 23 | -------------------------------------------------------------------------------- /.github/workflows/automatic-release.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Automatic Release 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the master branch 8 | push: 9 | tags: [ "v**" ] 10 | 11 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 12 | jobs: 13 | # This workflow contains a single job called "build" 14 | build: 15 | # The type of runner that the job will run on 16 | runs-on: ubuntu-latest 17 | 18 | # Steps represent a sequence of tasks that will be executed as part of the job 19 | steps: 20 | - uses: "marvinpinto/action-automatic-releases@latest" 21 | with: 22 | repo_token: ${{ secrets.GITHUB_TOKEN }} 23 | prerelease: false 24 | -------------------------------------------------------------------------------- /internal/app/story/model_art_character.go: -------------------------------------------------------------------------------- 1 | package story 2 | 3 | import ( 4 | "github.com/flandiayingman/arkwaifu/internal/pkg/ark" 5 | "github.com/lib/pq" 6 | ) 7 | 8 | type CharacterArt struct { 9 | Server ark.Server `json:"server" gorm:"primaryKey;type:game_server"` 10 | ID string `json:"id" gorm:"primaryKey;check:id=lower(id)"` 11 | StoryID string `json:"storyID" gorm:"primaryKey"` 12 | Category string `json:"category" gorm:""` 13 | 14 | Names pq.StringArray `json:"names" gorm:"type:text[];default:array[]::text[]"` 15 | 16 | SortID *uint64 `json:"-" gorm:"unique;autoIncrement"` 17 | } 18 | 19 | type AggregatedCharacterArt struct { 20 | Server ark.Server `json:"server"` 21 | ID string `json:"id"` 22 | Category string `json:"category"` 23 | 24 | Names pq.StringArray `json:"names" gorm:"type:text[];default:array[]::text[]"` 25 | } 26 | -------------------------------------------------------------------------------- /internal/app/gallery/service.go: -------------------------------------------------------------------------------- 1 | package gallery 2 | 3 | import "github.com/flandiayingman/arkwaifu/internal/pkg/ark" 4 | 5 | type Service struct { 6 | r *repository 7 | } 8 | 9 | func newService(r *repository) *Service { 10 | return &Service{r: r} 11 | } 12 | 13 | func (s *Service) ListGalleries(server ark.Server) ([]Gallery, error) { 14 | return s.r.ListGalleries(server) 15 | } 16 | 17 | func (s *Service) GetGalleryByID(server ark.Server, id string) (*Gallery, error) { 18 | return s.r.GetGalleryByID(server, id) 19 | } 20 | 21 | func (s *Service) ListGalleryArts(server ark.Server) ([]Art, error) { 22 | return s.r.ListGalleryArts(server) 23 | } 24 | 25 | func (s *Service) GetGalleryArtByID(server ark.Server, id string) (*Art, error) { 26 | return s.r.GetGalleryArtByID(server, id) 27 | } 28 | 29 | func (s *Service) Put(g []Gallery) (err error) { 30 | return s.r.Put(g) 31 | } 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Ark Waifu ### 2 | arkwaifu-root 3 | .env 4 | 5 | # Created by https://www.toptal.com/developers/gitignore/api/go 6 | # Edit at https://www.toptal.com/developers/gitignore?templates=go 7 | 8 | ### Go ### 9 | # If you prefer the allow list template instead of the deny list, see community template: 10 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 11 | # 12 | # Binaries for programs and plugins 13 | *.exe 14 | *.exe~ 15 | *.dll 16 | *.so 17 | *.dylib 18 | 19 | # Test binary, built with `go test -c` 20 | *.test 21 | 22 | # Output of the go coverage tool, specifically when used with LiteIDE 23 | *.out 24 | 25 | # Dependency directories (remove the comment below to include it) 26 | # vendor/ 27 | 28 | # Go workspace file 29 | go.work 30 | 31 | ### Go Patch ### 32 | /vendor/ 33 | /Godeps/ 34 | 35 | # End of https://www.toptal.com/developers/gitignore/api/go -------------------------------------------------------------------------------- /internal/app/infra/fiber.go: -------------------------------------------------------------------------------- 1 | package infra 2 | 3 | import ( 4 | "context" 5 | "go.uber.org/fx" 6 | "time" 7 | 8 | "github.com/gofiber/fiber/v2" 9 | "github.com/gofiber/fiber/v2/middleware/cors" 10 | "github.com/gofiber/fiber/v2/middleware/logger" 11 | ) 12 | 13 | func ProvideFiber(lc fx.Lifecycle, config *Config) (*fiber.App, fiber.Router) { 14 | app := fiber.New(fiber.Config{ 15 | AppName: "Arkwaifu", 16 | ReadTimeout: time.Second * 20, 17 | WriteTimeout: time.Second * 20, 18 | BodyLimit: 16 * 1024 * 1024, 19 | UnescapePath: true, 20 | }) 21 | app.Use(cors.New(cors.Config{ 22 | AllowOrigins: "*", 23 | })).Use(logger.New()) 24 | 25 | lc.Append(fx.Hook{ 26 | OnStart: func(ctx context.Context) error { 27 | go func() { 28 | err := app.Listen(config.Address) 29 | if err != nil { 30 | panic(err) 31 | } 32 | }() 33 | return nil 34 | }, 35 | OnStop: func(ctx context.Context) error { 36 | return app.ShutdownWithContext(ctx) 37 | }, 38 | }) 39 | 40 | return app, app.Group("api/v1") 41 | } 42 | -------------------------------------------------------------------------------- /internal/pkg/arkdata/download.go: -------------------------------------------------------------------------------- 1 | package arkdata 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/cavaliergopher/grab/v3" 7 | "github.com/pkg/errors" 8 | "os" 9 | ) 10 | 11 | func download(ctx context.Context, repoOwner, repoName, sha string) (string, string, error) { 12 | temp, err := os.MkdirTemp("", "arkdata_download-*") 13 | if err != nil { 14 | return "", "", errors.WithStack(err) 15 | } 16 | 17 | ctx, cancelCtx := context.WithCancel(ctx) 18 | defer cancelCtx() 19 | 20 | request, err := grab.NewRequest(temp, urlOfZipball(repoOwner, repoName, sha)) 21 | if err != nil { 22 | os.RemoveAll(temp) 23 | return "", "", errors.WithStack(err) 24 | } 25 | 26 | request = request.WithContext(ctx) 27 | client := grab.NewClient() 28 | client.UserAgent = "FlandiaYingman/arkwaifu" 29 | response := client.Do(request) 30 | return temp, response.Filename, response.Err() 31 | } 32 | 33 | func urlOfZipball(repoOwner, repoName, sha string) string { 34 | return fmt.Sprintf("https://api.github.com/repos/%s/%s/zipball/%s", repoOwner, repoName, sha) 35 | } 36 | -------------------------------------------------------------------------------- /internal/app/story/model_story.go: -------------------------------------------------------------------------------- 1 | package story 2 | 3 | import "github.com/flandiayingman/arkwaifu/internal/pkg/ark" 4 | 5 | type Story struct { 6 | Server ark.Server `json:"server" gorm:"primaryKey;type:game_server"` 7 | ID string `json:"id" gorm:"primaryKey;check:id=lower(id)"` 8 | 9 | Tag Tag `json:"tag" gorm:"type:story_tag"` 10 | TagText string `json:"tagText" gorm:""` 11 | Code string `json:"code" gorm:""` 12 | Name string `json:"name" gorm:""` 13 | Info string `json:"info" gorm:""` 14 | 15 | GroupID string `json:"groupID" gorm:""` 16 | SortID *uint64 `json:"-" gorm:"unique;autoIncrement"` 17 | 18 | PictureArts []PictureArt `json:"pictureArts" gorm:"foreignKey:Server,StoryID;reference:(Server,ID);constraint:OnDelete:CASCADE"` 19 | CharacterArts []CharacterArt `json:"characterArts" gorm:"foreignKey:Server,StoryID;reference:(Server,ID);constraint:OnDelete:CASCADE"` 20 | } 21 | 22 | type Tag = string 23 | 24 | const ( 25 | TagBefore Tag = "before" 26 | TagAfter Tag = "after" 27 | TagInterlude Tag = "interlude" 28 | ) 29 | -------------------------------------------------------------------------------- /internal/app/art/art_content.go: -------------------------------------------------------------------------------- 1 | package art 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | _ "github.com/chai2010/webp" 7 | "image" 8 | _ "image/jpeg" 9 | _ "image/png" 10 | "path" 11 | ) 12 | 13 | // Content helps store and take the content of a variant to or from the 14 | // filesystem. 15 | type Content struct { 16 | ID string 17 | Category Category 18 | Variation Variation 19 | Content []byte 20 | } 21 | 22 | func (s *Content) String() string { 23 | if s.Variation == VariationOrigin { 24 | return fmt.Sprintf("%s", s.ID) 25 | } else { 26 | return fmt.Sprintf("%s/%s", s.Variation, s.ID) 27 | } 28 | } 29 | 30 | func (s *Content) PathRel() string { 31 | return path.Join(string(s.Category), s.String()+".webp") 32 | } 33 | 34 | func (s *Content) Check() (*image.Config, error) { 35 | config, format, err := image.DecodeConfig(bytes.NewReader(s.Content)) 36 | if err != nil { 37 | return nil, fmt.Errorf("cannot decode content file %v: %w", s, err) 38 | } 39 | if format != "webp" { 40 | return nil, fmt.Errorf("the format of content file %v is not webp", s) 41 | } 42 | return &config, nil 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Flandia Yingman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/pkg/util/pathutil/pattern.go: -------------------------------------------------------------------------------- 1 | package pathutil 2 | 3 | import ( 4 | "github.com/gobwas/glob" 5 | "io/fs" 6 | "path/filepath" 7 | ) 8 | 9 | func MatchAny(patterns []string, name string) (bool, error) { 10 | name = filepath.ToSlash(name) 11 | for _, pattern := range patterns { 12 | match, err := Match(pattern, name) 13 | if err != nil { 14 | return false, err 15 | } 16 | if match { 17 | return true, nil 18 | } 19 | } 20 | return false, nil 21 | } 22 | 23 | func Match(pattern string, name string) (bool, error) { 24 | globber, err := glob.Compile(pattern, '/', '\\') 25 | if err != nil { 26 | return false, err 27 | } 28 | return globber.Match(name), nil 29 | } 30 | 31 | func Glob(pattern []string, root string) ([]string, error) { 32 | results := make([]string, 0) 33 | return results, filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { 34 | rel, err := filepath.Rel(root, p) 35 | if err != nil { 36 | return err 37 | } 38 | match, err := MatchAny(pattern, rel) 39 | if err != nil { 40 | return err 41 | } 42 | if match { 43 | results = append(results, p) 44 | } 45 | 46 | return nil 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /internal/app/gallery/model.go: -------------------------------------------------------------------------------- 1 | package gallery 2 | 3 | import "github.com/flandiayingman/arkwaifu/internal/pkg/ark" 4 | 5 | type Gallery struct { 6 | Server ark.Server `json:"-" gorm:"primaryKey;type:game_server"` 7 | 8 | ID string `json:"id" gorm:"primaryKey;type:text COLLATE numeric;check:id=lower(id)"` 9 | Name string `json:"name" gorm:""` 10 | Description string `json:"description" gorm:""` 11 | Arts []Art `json:"arts" gorm:"foreignKey:Server,GalleryID;references:Server,ID;constraint:OnDelete:CASCADE"` 12 | } 13 | 14 | func (g Gallery) TableName() string { 15 | return "gallery_galleries" 16 | } 17 | 18 | type Art struct { 19 | Server ark.Server `json:"-" gorm:"primaryKey;type:game_server"` 20 | GalleryID string `json:"-" gorm:""` 21 | SortID int `json:"-" gorm:""` 22 | 23 | ID string `json:"id" gorm:"primaryKey;type:text COLLATE numeric;check:id=lower(id)"` 24 | Name string `json:"name" gorm:""` 25 | Description string `json:"description" gorm:""` 26 | ArtID string `json:"artID" gorm:"type:text COLLATE numeric;check:id=lower(id)"` 27 | } 28 | 29 | func (a Art) TableName() string { 30 | return "gallery_arts" 31 | } 32 | -------------------------------------------------------------------------------- /internal/app/infra/config.go: -------------------------------------------------------------------------------- 1 | package infra 2 | 3 | import ( 4 | "errors" 5 | "github.com/caarlos0/env/v6" 6 | "github.com/goccy/go-yaml" 7 | "github.com/google/uuid" 8 | "os" 9 | "path/filepath" 10 | ) 11 | 12 | type Config struct { 13 | Address string `env:"ADDRESS" envDefault:"0.0.0.0:7080"` 14 | Root string `env:"ROOT" envDefault:"./arkwaifu-root"` 15 | PostgresDSN string `env:"POSTGRES_DSN"` 16 | Users []User 17 | } 18 | 19 | type User struct { 20 | ID uuid.UUID 21 | Name string 22 | } 23 | 24 | func ProvideConfig() (*Config, error) { 25 | cfg := Config{} 26 | err := env.Parse(&cfg) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | cfg.Users, err = provideUsers(cfg.Root) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | return &cfg, nil 37 | } 38 | 39 | func provideUsers(root string) ([]User, error) { 40 | path := filepath.Join(root, "users.yaml") 41 | file, err := os.ReadFile(path) 42 | if err != nil && errors.Is(err, os.ErrNotExist) { 43 | return nil, nil 44 | } 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | users := make([]User, 0) 50 | err = yaml.Unmarshal(file, &users) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | return users, nil 56 | } 57 | -------------------------------------------------------------------------------- /internal/pkg/arkparser/story_pictures.go: -------------------------------------------------------------------------------- 1 | package arkparser 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | func (p *Parser) ParsePictures(directives []Directive) []*StoryPicture { 8 | pictureSlice := make([]string, 0) 9 | pictureMap := make(map[string]StoryPicture) 10 | 11 | addPicture := func(id string, category string) { 12 | picture := pictureMap[id] 13 | picture.ID = id 14 | picture.Category = category 15 | pictureMap[id] = picture 16 | pictureSlice = append(pictureSlice, id) 17 | } 18 | 19 | for _, directive := range directives { 20 | switch directive.Name { 21 | case "image": 22 | if id, ok := directive.Params["image"]; ok { 23 | addPicture(id, "image") 24 | } 25 | case "background": 26 | if id, ok := directive.Params["image"]; ok { 27 | addPicture(id, "background") 28 | } 29 | case "largebg", "gridbg": 30 | if ids, ok := directive.Params["imagegroup"]; ok { 31 | for _, id := range strings.Split(ids, "/") { 32 | addPicture(id, "background") 33 | } 34 | } 35 | case "showitem": 36 | if id, ok := directive.Params["image"]; ok { 37 | addPicture(id, "item") 38 | } 39 | } 40 | } 41 | 42 | result := make([]*StoryPicture, 0) 43 | for _, id := range pictureSlice { 44 | picture := pictureMap[id] 45 | result = append(result, &picture) 46 | } 47 | 48 | return result 49 | } 50 | -------------------------------------------------------------------------------- /internal/pkg/util/pathutil/ext.go: -------------------------------------------------------------------------------- 1 | package pathutil 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "strings" 7 | ) 8 | 9 | // Stem returns the name of the path without the extension (e.g. foo.bar.zip -> foo). 10 | func Stem(path string) string { 11 | return RemoveAllExt(filepath.Base(path)) 12 | } 13 | 14 | // RemoveExt returns the path without the extension. 15 | func RemoveExt(path string) string { 16 | return strings.TrimSuffix(path, filepath.Ext(path)) 17 | } 18 | 19 | // RemoveAllExt returns the path without all extensions. 20 | func RemoveAllExt(path string) string { 21 | return strings.TrimSuffix(path, AllExt(path)) 22 | } 23 | 24 | // AllExt returns all extensions (e.g., ".tar.gz" instead of ".gz") from the file name (including "."). 25 | func AllExt(path string) string { 26 | _, after, found := strings.Cut(filepath.Base(path), ".") 27 | if found { 28 | return "." + after 29 | } else { 30 | return "" 31 | } 32 | } 33 | 34 | // ReplaceExt replaces the extension of the given path with the given extension. 35 | func ReplaceExt(path string, ext string) string { 36 | withoutExt := RemoveExt(path) 37 | return fmt.Sprintf("%v%v", withoutExt, ext) 38 | } 39 | 40 | // ReplaceAllExt replaces all extensions of the given path with the given extension. 41 | func ReplaceAllExt(path string, ext string) string { 42 | withoutExt := RemoveAllExt(path) 43 | return fmt.Sprintf("%v%v", withoutExt, ext) 44 | } 45 | -------------------------------------------------------------------------------- /internal/pkg/arkprocessor/processor_picture.go: -------------------------------------------------------------------------------- 1 | package arkprocessor 2 | 3 | import ( 4 | _ "github.com/chai2010/webp" 5 | "github.com/flandiayingman/arkwaifu/internal/pkg/arkscanner" 6 | "github.com/pkg/errors" 7 | "image" 8 | _ "image/jpeg" 9 | _ "image/png" 10 | "os" 11 | "path/filepath" 12 | ) 13 | 14 | type PictureArt arkscanner.PictureArt 15 | 16 | type PictureArtImage struct { 17 | Image image.Image 18 | Art *PictureArt 19 | } 20 | 21 | // ProcessPictureArt process the picture art. 22 | // 23 | // Since picture arts are trivial and different from character arts, the only 24 | // thing this method does is to read the art image and return it. 25 | func (p *Processor) ProcessPictureArt(art *PictureArt) (*PictureArtImage, error) { 26 | img, err := art.decode(p.Root) 27 | if err != nil { 28 | return nil, errors.Wrapf(err, "process picture art %s", art.ID) 29 | } else { 30 | return &PictureArtImage{ 31 | Image: img, 32 | Art: art, 33 | }, nil 34 | } 35 | } 36 | 37 | func (a *PictureArt) decode(root string) (image.Image, error) { 38 | artPath := filepath.Join(root, (*arkscanner.PictureArt)(a).Path()) 39 | 40 | artFile, err := os.Open(artPath) 41 | if err != nil { 42 | return nil, errors.WithStack(err) 43 | } 44 | defer func() { _ = artFile.Close() }() 45 | 46 | img, _, err := image.Decode(artFile) 47 | if err != nil { 48 | return nil, errors.WithStack(err) 49 | } 50 | 51 | return img, nil 52 | } 53 | -------------------------------------------------------------------------------- /internal/app/art/art_art.go: -------------------------------------------------------------------------------- 1 | package art 2 | 3 | import "fmt" 4 | 5 | type Art struct { 6 | ID string `gorm:"primaryKey;type:text COLLATE numeric;check:id=lower(id)" json:"id"` 7 | Category Category `gorm:"type:art_category" json:"category"` 8 | 9 | Variants []Variant `gorm:"" json:"variants,omitempty" validate:""` 10 | } 11 | 12 | func NewArt(id string, category Category) *Art { 13 | return &Art{ 14 | ID: id, 15 | Category: category, 16 | Variants: nil, 17 | } 18 | } 19 | 20 | type Category string 21 | 22 | const ( 23 | CategoryImage Category = "image" 24 | CategoryBackground Category = "background" 25 | CategoryItem Category = "item" 26 | CategoryCharacter Category = "character" 27 | ) 28 | 29 | func ParseCategory(str string) (Category, error) { 30 | category := Category(str) 31 | switch category { 32 | case 33 | CategoryImage, 34 | CategoryBackground, 35 | CategoryItem, 36 | CategoryCharacter: 37 | return category, nil 38 | default: 39 | return "", fmt.Errorf("string %q is not a category", str) 40 | } 41 | } 42 | 43 | func MustParseCategory(str string) Category { 44 | category, err := ParseCategory(str) 45 | if err != nil { 46 | panic(err) 47 | } 48 | return category 49 | } 50 | 51 | func (c *Category) UnmarshalJSON(bytes []byte) error { 52 | category, err := ParseCategory(string(bytes)) 53 | if err != nil { 54 | return err 55 | } 56 | *c = category 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /internal/pkg/arkassets/assets_test.go: -------------------------------------------------------------------------------- 1 | package arkassets 2 | 3 | import ( 4 | "context" 5 | "github.com/flandiayingman/arkwaifu/internal/pkg/ark" 6 | "github.com/flandiayingman/arkwaifu/internal/pkg/util/pathutil" 7 | "github.com/stretchr/testify/assert" 8 | "os" 9 | "path" 10 | "testing" 11 | ) 12 | 13 | func TestGetGameAssets(t *testing.T) { 14 | type args struct { 15 | ctx context.Context 16 | version ark.Version 17 | dst string 18 | patterns []string 19 | } 20 | tests := []struct { 21 | name string 22 | args args 23 | wantSuccess bool 24 | wantErr bool 25 | }{ 26 | { 27 | name: "avg/imgs/avg_img_0_0.ab", 28 | args: args{ 29 | ctx: context.Background(), 30 | version: "", 31 | dst: "./test_dst", 32 | patterns: []string{"avg/imgs/avg_img_0_0.ab"}, 33 | }, 34 | wantSuccess: true, 35 | wantErr: false, 36 | }, 37 | } 38 | for _, tt := range tests { 39 | t.Run(tt.name, func(t *testing.T) { 40 | defer os.RemoveAll(tt.args.dst) 41 | if err := GetGameAssets(tt.args.ctx, tt.args.version, tt.args.dst, tt.args.patterns); (err != nil) != tt.wantErr { 42 | t.Errorf("GetGameAssets() error = %v, wantErr %v", err, tt.wantErr) 43 | } 44 | files, err := pathutil.Glob([]string{"**"}, path.Join(tt.args.dst)) 45 | if err != nil { 46 | t.Error(err) 47 | } 48 | if tt.wantSuccess { 49 | assert.NotEmpty(t, files) 50 | } else { 51 | assert.Empty(t, files) 52 | } 53 | }) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /internal/pkg/arkparser/story_review_meta_table.go: -------------------------------------------------------------------------------- 1 | package arkparser 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/pkg/errors" 6 | "github.com/wk8/go-ordered-map/v2" 7 | "os" 8 | "path/filepath" 9 | ) 10 | 11 | type JsonResPic struct { 12 | Id string `json:"id"` 13 | Desc string `json:"desc"` 14 | AssetPath string `json:"assetPath"` 15 | PicDescription string `json:"picDescription"` 16 | } 17 | 18 | type JsonComponent struct { 19 | Pic struct { 20 | Pics orderedmap.OrderedMap[string, JsonPic] `json:"pics"` 21 | } `json:"pic"` 22 | } 23 | 24 | type JsonPic struct { 25 | PicId string `json:"picId"` 26 | PicSortId int `json:"picSortId"` 27 | } 28 | 29 | type JsonStoryReviewMetaTable struct { 30 | ActArchiveResData struct { 31 | Pics orderedmap.OrderedMap[string, JsonResPic] `json:"pics"` 32 | } `json:"actArchiveResData"` 33 | ActArchiveData struct { 34 | Components orderedmap.OrderedMap[string, JsonComponent] `json:"components"` 35 | } `json:"actArchiveData"` 36 | } 37 | 38 | func (p *Parser) ParseStoryReviewMetaTable() (*JsonStoryReviewMetaTable, error) { 39 | jsonPath := filepath.Join(p.Root, p.Prefix, "gamedata/excel/story_review_meta_table.json") 40 | jsonData, err := os.ReadFile(jsonPath) 41 | if err != nil { 42 | return nil, errors.WithStack(err) 43 | } 44 | 45 | var obj JsonStoryReviewMetaTable 46 | err = json.Unmarshal(jsonData, &obj) 47 | if err != nil { 48 | return nil, errors.WithStack(err) 49 | } 50 | 51 | return &obj, nil 52 | } 53 | -------------------------------------------------------------------------------- /internal/app/artext/ext_character.go: -------------------------------------------------------------------------------- 1 | package artext 2 | 3 | import ( 4 | "fmt" 5 | "github.com/flandiayingman/arkwaifu/internal/app/art" 6 | "github.com/gofiber/fiber/v2" 7 | "github.com/pkg/errors" 8 | "regexp" 9 | "strconv" 10 | ) 11 | 12 | func parseCharacterID(characterID string) (base string, body int, face int, err error) { 13 | // Regex Pattern like: {BASE}#{FACE}${BODY} 14 | pattern := regexp.MustCompile(`(\w+)#(\d+)\$(\d+)`) 15 | matches := pattern.FindStringSubmatch(characterID) 16 | if matches == nil { 17 | return "", 0, 0, errors.Errorf("invalid character ID %s", characterID) 18 | } 19 | 20 | base = matches[1] 21 | face, err = strconv.Atoi(matches[2]) 22 | body, err = strconv.Atoi(matches[3]) 23 | 24 | if err != nil { 25 | return "", 0, 0, errors.Errorf("invalid character ID %s: %w", characterID, err) 26 | } 27 | return base, body, face, nil 28 | } 29 | 30 | // GetSiblingsOfCharacterArt gets the sibling characters of the specified character. 31 | // 32 | // Sibling Characters: the characters that have the same base name as the specified character. 33 | func (s *Service) GetSiblingsOfCharacterArt(characterID string) (siblings []*art.Art, err error) { 34 | base, _, _, err := parseCharacterID(characterID) 35 | if err != nil { 36 | return nil, err 37 | } 38 | return s.art.SelectArtsByIDLike(fmt.Sprintf("%s%%", base)) 39 | } 40 | 41 | func (c *Controller) GetSiblingsOfCharacterArt(ctx *fiber.Ctx) error { 42 | characterID := ctx.Params("id") 43 | siblings, err := c.service.GetSiblingsOfCharacterArt(characterID) 44 | if err != nil { 45 | return err 46 | } 47 | return ctx.JSON(siblings) 48 | } 49 | -------------------------------------------------------------------------------- /.github/workflows/docker-image-service.yml: -------------------------------------------------------------------------------- 1 | name: "Docker Image CI: service" 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | tags: 7 | - "v**" 8 | pull_request: 9 | branches: [ master ] 10 | 11 | jobs: 12 | 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Set up Docker Buildx 19 | uses: docker/setup-buildx-action@v1 20 | 21 | - name: Docker Login 22 | uses: docker/login-action@v1.14.1 23 | with: 24 | # Server address of Docker registry. If not set then will default to Docker Hub 25 | registry: "ghcr.io" 26 | # Username used to log against the Docker registry 27 | username: ${{ github.actor }} 28 | # Password or personal access token used to log against the Docker registry 29 | password: ${{ secrets.GITHUB_TOKEN }} 30 | 31 | - name: Build and push Docker images 32 | uses: docker/build-push-action@v2.9.0 33 | with: 34 | # List of external cache sources for buildx (e.g., user/app:cache, type=local,src=path/to/dir) 35 | cache-from: type=gha 36 | # List of cache export destinations for buildx (e.g., user/app:cache, type=local,dest=path/to/dir) 37 | cache-to: type=gha,mode=max 38 | # Path to the Dockerfile 39 | file: "build/service.Dockerfile" 40 | # Push is a shorthand for --output=type=registry 41 | push: ${{ startsWith(github.ref, 'refs/tags/') }} 42 | # List of tags 43 | tags: | 44 | ghcr.io/flandiayingman/arkwaifu/service:latest 45 | ghcr.io/flandiayingman/arkwaifu/service:${{ github.ref_name }} 46 | -------------------------------------------------------------------------------- /.github/workflows/docker-image-updateloop.yml: -------------------------------------------------------------------------------- 1 | name: "Docker Image CI: updateloop" 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | tags: 7 | - "v**" 8 | pull_request: 9 | branches: [ master ] 10 | 11 | jobs: 12 | 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Set up Docker Buildx 19 | uses: docker/setup-buildx-action@v1 20 | 21 | - name: Docker Login 22 | uses: docker/login-action@v1.14.1 23 | with: 24 | # Server address of Docker registry. If not set then will default to Docker Hub 25 | registry: "ghcr.io" 26 | # Username used to log against the Docker registry 27 | username: ${{ github.actor }} 28 | # Password or personal access token used to log against the Docker registry 29 | password: ${{ secrets.GITHUB_TOKEN }} 30 | 31 | - name: Build and push Docker images 32 | uses: docker/build-push-action@v2.9.0 33 | with: 34 | # List of external cache sources for buildx (e.g., user/app:cache, type=local,src=path/to/dir) 35 | cache-from: type=gha 36 | # List of cache export destinations for buildx (e.g., user/app:cache, type=local,dest=path/to/dir) 37 | cache-to: type=gha,mode=max 38 | # Path to the Dockerfile 39 | file: "build/updateloop.Dockerfile" 40 | # Push is a shorthand for --output=type=registry 41 | push: ${{ startsWith(github.ref, 'refs/tags/') }} 42 | # List of tags 43 | tags: | 44 | ghcr.io/flandiayingman/arkwaifu/updateloop:latest 45 | ghcr.io/flandiayingman/arkwaifu/updateloop:${{ github.ref_name }} 46 | -------------------------------------------------------------------------------- /internal/app/art/art_variant.go: -------------------------------------------------------------------------------- 1 | package art 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type Variant struct { 8 | ArtID string `gorm:"primaryKey;type:text COLLATE numeric;check:art_id=lower(art_id)" json:"artID"` 9 | Variation Variation `gorm:"primaryKey;type:art_variation" json:"variation,omitempty"` 10 | 11 | ContentPresent bool `gorm:"" json:"contentPresent"` 12 | ContentWidth *int `gorm:"" json:"contentWidth,omitempty"` 13 | ContentHeight *int `gorm:"" json:"contentHeight,omitempty"` 14 | } 15 | 16 | func NewVariant(id string, variation Variation) *Variant { 17 | return &Variant{ 18 | ArtID: id, 19 | Variation: variation, 20 | ContentPresent: false, 21 | ContentWidth: nil, 22 | ContentHeight: nil, 23 | } 24 | } 25 | 26 | func (v *Variant) String() string { 27 | return fmt.Sprintf("%s/%s", v.Variation, v.ArtID) 28 | } 29 | 30 | type Variation string 31 | 32 | const ( 33 | VariationOrigin Variation = "origin" 34 | VariationRealEsrganX4Plus Variation = "real-esrgan(realesrgan-x4plus)" 35 | VariationRealEsrganX4PlusAnime Variation = "real-esrgan(realesrgan-x4plus-anime)" 36 | VariationThumbnail Variation = "thumbnail" 37 | ) 38 | 39 | func ParseVariation(str string) (Variation, error) { 40 | variation := Variation(str) 41 | switch variation { 42 | case 43 | VariationOrigin, 44 | VariationRealEsrganX4Plus, 45 | VariationRealEsrganX4PlusAnime, 46 | VariationThumbnail: 47 | return variation, nil 48 | default: 49 | return "", fmt.Errorf("string %q is not a variation", str) 50 | } 51 | } 52 | 53 | func (v *Variation) UnmarshalJSON(bytes []byte) error { 54 | variation, err := ParseVariation(string(bytes)) 55 | if err != nil { 56 | return err 57 | } 58 | *v = variation 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /internal/pkg/ark/server.go: -------------------------------------------------------------------------------- 1 | package ark 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | "strings" 6 | ) 7 | 8 | // Server represents different server of the game. E.g., CN and EN. 9 | type Server = string 10 | 11 | const ( 12 | // CnServer stands for China Server 13 | CnServer Server = "CN" 14 | 15 | // EnServer stands for English Server 16 | EnServer Server = "EN" 17 | 18 | // JpServer stands for Japan Server 19 | JpServer Server = "JP" 20 | 21 | // KrServer stands for Korea Server 22 | KrServer Server = "KR" 23 | 24 | // TwServer stands for Taiwan Server 25 | TwServer Server = "TW" 26 | ) 27 | 28 | var ( 29 | Servers = []Server{CnServer, EnServer, JpServer, KrServer, TwServer} 30 | ) 31 | 32 | func ParseServer(s string) (Server, error) { 33 | EqualFolds := func(s string, others ...string) bool { 34 | for _, other := range others { 35 | if strings.EqualFold(s, other) { 36 | return true 37 | } 38 | } 39 | return false 40 | } 41 | if EqualFolds(s, "CN", "zh-CN") { 42 | return CnServer, nil 43 | } 44 | if EqualFolds(s, "EN", "en-US", "en-GB") { 45 | return EnServer, nil 46 | } 47 | if EqualFolds(s, "JP", "ja-JP") { 48 | return JpServer, nil 49 | } 50 | if EqualFolds(s, "KR", "ko-KR") { 51 | return KrServer, nil 52 | } 53 | if EqualFolds(s, "TW", "zh-TW") { 54 | return TwServer, nil 55 | } 56 | return "", errors.Errorf("unknown server: %v", s) 57 | } 58 | 59 | func MustParseServer(s string) Server { 60 | server, err := ParseServer(s) 61 | if err != nil { 62 | panic(err) 63 | } 64 | return server 65 | } 66 | 67 | func LanguageCodeUnderscore(s Server) string { 68 | switch s { 69 | case CnServer: 70 | return "zh_CN" 71 | case EnServer: 72 | return "en_US" 73 | case JpServer: 74 | return "ja_JP" 75 | case KrServer: 76 | return "ko_KR" 77 | case TwServer: 78 | return "zh_TW" 79 | } 80 | return "" 81 | } 82 | -------------------------------------------------------------------------------- /internal/app/infra/gorm.go: -------------------------------------------------------------------------------- 1 | package infra 2 | 3 | import ( 4 | "fmt" 5 | "gorm.io/driver/postgres" 6 | "gorm.io/gorm" 7 | "gorm.io/gorm/logger" 8 | "log" 9 | "os" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | type Gorm struct { 15 | *gorm.DB 16 | } 17 | 18 | func ProvideGorm(config *Config) (*Gorm, error) { 19 | dsn := config.PostgresDSN 20 | 21 | l := logger.New(log.New(os.Stdout, "\r\n", log.LstdFlags), logger.Config{ 22 | SlowThreshold: 1 * time.Second, 23 | LogLevel: logger.Warn, 24 | IgnoreRecordNotFoundError: false, 25 | Colorful: true, 26 | }) 27 | 28 | db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ 29 | Logger: l, 30 | }) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | return &Gorm{db}, err 36 | } 37 | 38 | func (db *Gorm) CreateCollateNumeric() error { 39 | // The collation is copied from Postgres Docs 24.2.2.3.2. ICU Collations 40 | // (https://www.postgresql.org/docs/current/collation.html) and 'IF NOT EXISTS' is inserted. 41 | return db.Exec(`CREATE COLLATION IF NOT EXISTS numeric (provider = icu, locale = 'en-u-kn-true');`).Error 42 | } 43 | 44 | func (db *Gorm) CreateEnum(name string, values ...string) error { 45 | // Surround enum values with double quotes, since it is an identifier. 46 | name = fmt.Sprintf("\"%s\"", name) 47 | for i, value := range values { 48 | // Surround enum values with single quotes, since they are strings 49 | values[i] = fmt.Sprintf("'%s'", value) 50 | } 51 | // Use fmt.Sprintf because a possible BUG occurs when I tried using parameterized query. 52 | // However, this function is called only by static arguments, so it is considered safe. 53 | return db.Exec(fmt.Sprintf( 54 | `DO $$ BEGIN CREATE TYPE %s AS ENUM (%s); EXCEPTION WHEN duplicate_object THEN null; END $$;`, 55 | name, 56 | strings.Join(values, ","), 57 | )).Error 58 | } 59 | -------------------------------------------------------------------------------- /internal/pkg/util/pathutil/parent.go: -------------------------------------------------------------------------------- 1 | package pathutil 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "strings" 7 | ) 8 | 9 | // ChangeParent changes the parent of srcPath from srcDir to dstDir, and returns the dstPath. 10 | // If srcPath is not a child of srcDir, it returns an error. 11 | // 12 | // For example 13 | // ChangeParent("/home/user/dir/file.txt", "/home", "/root") // -> "/root/user/dir/file.txt" 14 | // ChangeParent("/home/user/file.txt", "/home/user", "/home/user/dir") // -> "/home/user/dir/file.txt" 15 | func ChangeParent(srcPath string, srcDir string, dstDir string) (dstPath string, err error) { 16 | relativePath, err := filepath.Rel(srcDir, srcPath) 17 | if err != nil { 18 | err = fmt.Errorf("failed to make relative path: %w", err) 19 | return 20 | } 21 | dstPath = filepath.Join(dstDir, relativePath) 22 | return 23 | } 24 | 25 | // MustChangeParent calls ChangeParent and panics if an error occurs. 26 | // It is safe to use MustChangeParent in, for example, os.WalkDir. 27 | func MustChangeParent(srcPath string, srcDir string, dstDir string) string { 28 | dstPath, err := ChangeParent(srcPath, srcDir, dstDir) 29 | if err != nil { 30 | panic(err) 31 | } 32 | return dstPath 33 | } 34 | 35 | const slash = "/" 36 | 37 | func Splits(path string) (parts []string) { 38 | path = filepath.ToSlash(path) 39 | if filepath.IsAbs(path) && filepath.VolumeName(path) != "" { 40 | volume := filepath.VolumeName(path) + slash 41 | parts = append(parts, volume) 42 | path = strings.TrimPrefix(path, volume) 43 | } else if filepath.IsAbs(path) { 44 | root := slash 45 | parts = append(parts, root) 46 | path = strings.TrimPrefix(path, root) 47 | } else if filepath.VolumeName(path) != "" { 48 | volume := filepath.VolumeName(path) 49 | parts = append(parts, volume) 50 | path = strings.TrimPrefix(path, volume) 51 | } 52 | parts = append(parts, strings.Split(path, slash)...) 53 | return 54 | } 55 | -------------------------------------------------------------------------------- /internal/pkg/arkdata/unzip.go: -------------------------------------------------------------------------------- 1 | package arkdata 2 | 3 | import ( 4 | "context" 5 | "github.com/flandiayingman/arkwaifu/internal/pkg/ark" 6 | "github.com/flandiayingman/arkwaifu/internal/pkg/cols" 7 | "github.com/flandiayingman/arkwaifu/internal/pkg/util/cloze" 8 | "github.com/flandiayingman/arkwaifu/internal/pkg/util/fileutil" 9 | "github.com/flandiayingman/arkwaifu/internal/pkg/util/pathutil" 10 | "github.com/mholt/archiver/v4" 11 | "github.com/pkg/errors" 12 | "os" 13 | "path" 14 | "path/filepath" 15 | ) 16 | 17 | func unzip(ctx context.Context, zipball string, patterns []string, server ark.Server) (string, error) { 18 | temp, err := os.MkdirTemp("", "arkdata_unzip") 19 | if err != nil { 20 | return "", errors.WithStack(err) 21 | } 22 | 23 | // Since the zipball contains a directory directly named by the stem of the zipball, 24 | // the prefix is gotten for generating the file path inside the zipball. 25 | patterns = cols.Map(patterns, func(pattern string) string { 26 | return path.Join(pathutil.Stem(zipball), ark.LanguageCodeUnderscore(server), pattern) 27 | }) 28 | 29 | reader, err := os.Open(zipball) 30 | if err != nil { 31 | return "", errors.WithStack(err) 32 | } 33 | defer cloze.IgnoreErr(reader) 34 | 35 | zip := archiver.Zip{} 36 | return temp, zip.Extract(ctx, reader, nil, func(ctx context.Context, file archiver.File) error { 37 | if file.IsDir() { 38 | return nil 39 | } 40 | 41 | match, err := pathutil.MatchAny(patterns, file.NameInArchive) 42 | if err != nil { 43 | return errors.WithStack(err) 44 | } 45 | if !match { 46 | return nil 47 | } 48 | 49 | fileName := path.Join(pathutil.Splits(file.NameInArchive)[2:]...) 50 | filePath := filepath.Join(temp, DefaultPrefix, fileName) 51 | 52 | reader, err := file.Open() 53 | if err != nil { 54 | return errors.WithStack(err) 55 | } 56 | 57 | err = fileutil.MkFileFromReader(filePath, reader) 58 | if err != nil { 59 | return errors.WithStack(err) 60 | } 61 | 62 | return nil 63 | }) 64 | } 65 | -------------------------------------------------------------------------------- /deploy/docker-compose.yml: -------------------------------------------------------------------------------- 1 | ### The Docker Compose file for deploying Arkwaifu on the official server. 2 | ### Others may modify the file to fit their condition. 3 | 4 | services: 5 | 6 | reverse_proxy: 7 | depends_on: 8 | - frontend 9 | - backend 10 | build: ./reverse_proxy 11 | ports: 12 | - 80:80 13 | environment: 14 | - FRONTEND_ADDRESS=frontend:80 15 | - BACKEND_ADDRESS=backend:7080 16 | 17 | frontend: 18 | image: ghcr.io/flandiayingman/arkwaifu-frontend:latest 19 | 20 | backend: 21 | depends_on: 22 | database: 23 | condition: service_healthy 24 | image: ghcr.io/flandiayingman/arkwaifu/service:latest 25 | volumes: 26 | - ./arkwaifu-root:/var/arkwaifu-root 27 | environment: 28 | - POSTGRES_DSN=postgres://postgres@database 29 | - ROOT=/var/arkwaifu-root 30 | restart: always 31 | 32 | updateloop: 33 | depends_on: 34 | database: 35 | condition: service_healthy 36 | image: ghcr.io/flandiayingman/arkwaifu/updateloop:latest 37 | volumes: 38 | - ./arkwaifu-root:/var/arkwaifu-root 39 | environment: 40 | - POSTGRES_DSN=postgres://postgres@database 41 | - ROOT=/var/arkwaifu-root 42 | restart: always 43 | 44 | database: 45 | image: postgres:15.3-alpine 46 | ports: 47 | # Only expose the port on localhost. That should prevent unauthorized accesses. 48 | # This is for administrators to manage the database, not for other containers to access. 49 | - 127.0.0.1:5432:5432 50 | environment: 51 | # Since the database is not exposed to public, it is considered safe to trust. 52 | - POSTGRES_HOST_AUTH_METHOD=trust 53 | # Persist the data. 54 | volumes: 55 | - ./arkwaifu-root/postgres-data:/var/lib/postgresql/data 56 | # We do not know when the Postgres is ready for listening connections, so we use pg_isready utility to check it. 57 | healthcheck: 58 | test: [ "CMD-SHELL", "pg_isready -U postgres" ] 59 | interval: 3s 60 | timeout: 3s 61 | retries: 10 -------------------------------------------------------------------------------- /internal/pkg/arkscanner/scanner_picture.go: -------------------------------------------------------------------------------- 1 | package arkscanner 2 | 3 | import ( 4 | "fmt" 5 | "github.com/flandiayingman/arkwaifu/internal/pkg/util/pathutil" 6 | "github.com/pkg/errors" 7 | "path" 8 | "path/filepath" 9 | ) 10 | 11 | type PictureArt struct { 12 | ID string 13 | Name string 14 | Kind string 15 | } 16 | 17 | func (a *PictureArt) Path() string { 18 | subdirectory := subdirectoryOfCategory(a.Kind) 19 | return path.Join("assets/torappu/dynamicassets", "avg", subdirectory, a.Name) 20 | } 21 | 22 | func subdirectoryOfCategory(category string) string { 23 | switch category { 24 | case "image": 25 | return "images" 26 | case "background": 27 | return "backgrounds" 28 | case "item": 29 | return "items" 30 | default: 31 | panic(fmt.Sprintf("unrecognized category %s", category)) 32 | } 33 | } 34 | 35 | const PictureArtPath = "assets/torappu/dynamicassets/avg" 36 | 37 | func (scanner *Scanner) ScanForPictureArts() ([]*PictureArt, error) { 38 | baseDir := filepath.Join(scanner.Root, PictureArtPath) 39 | imageArts, err := scanner.scanPictures(baseDir, "images", "image") 40 | if err != nil { 41 | return nil, errors.WithStack(err) 42 | } 43 | backgroundArts, err := scanner.scanPictures(baseDir, "backgrounds", "background") 44 | if err != nil { 45 | return nil, errors.WithStack(err) 46 | } 47 | itemArts, err := scanner.scanPictures(baseDir, "items", "item") 48 | if err != nil { 49 | return nil, errors.WithStack(err) 50 | } 51 | 52 | var arts []*PictureArt 53 | arts = append(arts, imageArts...) 54 | arts = append(arts, backgroundArts...) 55 | arts = append(arts, itemArts...) 56 | 57 | return arts, nil 58 | } 59 | 60 | func (scanner *Scanner) scanPictures(base string, sub string, kind string) ([]*PictureArt, error) { 61 | files, err := filepath.Glob(filepath.Join(base, sub, "*.png")) 62 | if err != nil { 63 | return nil, errors.WithStack(err) 64 | } 65 | 66 | var pictures []*PictureArt 67 | for _, file := range files { 68 | pictures = append(pictures, &PictureArt{ 69 | ID: pathutil.Stem(file), 70 | Name: filepath.Base(file), 71 | Kind: kind, 72 | }) 73 | } 74 | 75 | return pictures, nil 76 | } 77 | -------------------------------------------------------------------------------- /internal/app/gallery/controller.go: -------------------------------------------------------------------------------- 1 | package gallery 2 | 3 | import ( 4 | "github.com/flandiayingman/arkwaifu/internal/pkg/ark" 5 | "github.com/gofiber/fiber/v2" 6 | ) 7 | 8 | type controller struct { 9 | service *Service 10 | } 11 | 12 | func newController(service *Service) *controller { 13 | return &controller{service} 14 | } 15 | func registerController(c *controller, router fiber.Router) { 16 | if router == nil { 17 | return 18 | } 19 | router.Get(":server/galleries", c.ListGalleries) 20 | router.Get(":server/galleries/:id", c.GetGalleryByID) 21 | router.Get(":server/gallery-arts", c.ListGalleryArt) 22 | router.Get(":server/gallery-arts/:id", c.GetGalleryArtByID) 23 | } 24 | 25 | func (c *controller) ListGalleries(ctx *fiber.Ctx) error { 26 | server, err := ark.ParseServer(ctx.Params("server")) 27 | if err != nil { 28 | return fiber.ErrBadRequest 29 | } 30 | 31 | galleries, err := c.service.ListGalleries(server) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | return ctx.JSON(galleries) 37 | } 38 | func (c *controller) GetGalleryByID(ctx *fiber.Ctx) error { 39 | server, err := ark.ParseServer(ctx.Params("server")) 40 | if err != nil { 41 | return fiber.ErrBadRequest 42 | } 43 | id := ctx.Params("id") 44 | if id == "" { 45 | return fiber.ErrBadRequest 46 | } 47 | 48 | gallery, err := c.service.GetGalleryByID(server, id) 49 | 50 | return ctx.JSON(gallery) 51 | } 52 | 53 | func (c *controller) ListGalleryArt(ctx *fiber.Ctx) error { 54 | server, err := ark.ParseServer(ctx.Params("server")) 55 | if err != nil { 56 | return fiber.ErrBadRequest 57 | } 58 | 59 | galleryArts, err := c.service.ListGalleryArts(server) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | return ctx.JSON(galleryArts) 65 | } 66 | func (c *controller) GetGalleryArtByID(ctx *fiber.Ctx) error { 67 | server, err := ark.ParseServer(ctx.Params("server")) 68 | if err != nil { 69 | return fiber.ErrBadRequest 70 | } 71 | id := ctx.Params("id") 72 | if id == "" { 73 | return fiber.ErrBadRequest 74 | } 75 | 76 | galleryArt, err := c.service.GetGalleryArtByID(server, id) 77 | if err != nil { 78 | return err 79 | } 80 | 81 | return ctx.JSON(galleryArt) 82 | } 83 | -------------------------------------------------------------------------------- /internal/pkg/arkassets/unzip.go: -------------------------------------------------------------------------------- 1 | package arkassets 2 | 3 | import ( 4 | "context" 5 | "github.com/mholt/archiver/v4" 6 | "github.com/pkg/errors" 7 | "github.com/rs/zerolog/log" 8 | 9 | "io" 10 | "io/fs" 11 | "os" 12 | "path" 13 | "path/filepath" 14 | ) 15 | 16 | func unzip(ctx context.Context, src string) (string, error) { 17 | tempDir, err := os.MkdirTemp("", "arkassets_unzip-*") 18 | if err != nil { 19 | return "", errors.WithStack(err) 20 | } 21 | 22 | return tempDir, filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { 23 | if err != nil { 24 | return errors.WithStack(err) 25 | } 26 | if d.IsDir() { 27 | return nil 28 | } 29 | if err := ctx.Err(); err != nil { 30 | return errors.WithStack(err) 31 | } 32 | 33 | err = unzipFile(ctx, path, tempDir) 34 | if err != nil { 35 | return errors.WithStack(err) 36 | } 37 | return nil 38 | }) 39 | } 40 | 41 | func unzipFile(ctx context.Context, src string, dst string) error { 42 | zip := archiver.Zip{} 43 | 44 | srcFile, err := os.Open(src) 45 | if err != nil { 46 | return errors.WithStack(err) 47 | } 48 | defer srcFile.Close() 49 | 50 | err = zip.Extract(ctx, srcFile, nil, func(ctx context.Context, f archiver.File) error { 51 | if err := ctx.Err(); err != nil { 52 | return errors.WithStack(err) 53 | } 54 | 55 | compFile, err := f.Open() 56 | if err != nil { 57 | return errors.WithStack(err) 58 | } 59 | defer compFile.Close() 60 | 61 | compFileName := f.NameInArchive 62 | 63 | src := filepath.ToSlash(filepath.Clean(src)) 64 | dst := filepath.ToSlash(filepath.Clean(path.Join(dst, compFileName))) 65 | err = os.MkdirAll(filepath.Dir(dst), os.ModePerm) 66 | if err != nil { 67 | return errors.WithStack(err) 68 | } 69 | 70 | dstFile, err := os.Create(dst) 71 | if err != nil { 72 | return errors.WithStack(err) 73 | } 74 | defer dstFile.Close() 75 | 76 | _, err = io.Copy(dstFile, compFile) 77 | if err != nil { 78 | return errors.WithStack(err) 79 | } 80 | 81 | log.Info(). 82 | Str("src", src). 83 | Str("dst", dst). 84 | Msg("Unzipped resource src to dst.") 85 | 86 | return nil 87 | }) 88 | return errors.WithStack(err) 89 | } 90 | -------------------------------------------------------------------------------- /internal/app/gallery/repository.go: -------------------------------------------------------------------------------- 1 | package gallery 2 | 3 | import ( 4 | "github.com/flandiayingman/arkwaifu/internal/app/infra" 5 | "github.com/flandiayingman/arkwaifu/internal/pkg/ark" 6 | "gorm.io/gorm" 7 | "gorm.io/gorm/clause" 8 | ) 9 | 10 | type repository struct { 11 | db *infra.Gorm 12 | } 13 | 14 | func newRepository(db *infra.Gorm) (*repository, error) { 15 | r := repository{db: db} 16 | err := r.init() 17 | if err != nil { 18 | return nil, err 19 | } 20 | return &r, nil 21 | } 22 | 23 | func (r *repository) init() (err error) { 24 | err = r.db.CreateCollateNumeric() 25 | if err != nil { 26 | return err 27 | } 28 | err = r.db.CreateEnum("game_server", 29 | ark.CnServer, 30 | ark.EnServer, 31 | ark.JpServer, 32 | ark.KrServer, 33 | ark.TwServer, 34 | ) 35 | if err != nil { 36 | return err 37 | } 38 | err = r.db.AutoMigrate(&Gallery{}) 39 | if err != nil { 40 | return err 41 | } 42 | err = r.db.AutoMigrate(&Art{}) 43 | if err != nil { 44 | return err 45 | } 46 | return nil 47 | } 48 | 49 | func (r *repository) ListGalleries(server ark.Server) ([]Gallery, error) { 50 | var galleries []Gallery 51 | return galleries, r.db. 52 | Preload("Arts", func(db *gorm.DB) *gorm.DB { return db.Order("gallery_arts.sort_id") }). 53 | Order("id"). 54 | Find(&galleries, "server = ?", server). 55 | Error 56 | } 57 | 58 | func (r *repository) GetGalleryByID(server ark.Server, id string) (*Gallery, error) { 59 | var galleries Gallery 60 | return &galleries, r.db. 61 | Preload("Arts", func(db *gorm.DB) *gorm.DB { return db.Order("gallery_arts.sort_id") }). 62 | Take(&galleries, "(server, id) = (?, ?)", server, id). 63 | Error 64 | } 65 | 66 | func (r *repository) ListGalleryArts(server ark.Server) ([]Art, error) { 67 | var arts []Art 68 | return arts, r.db. 69 | Order("gallery_id, sort_id, id"). 70 | Find(&arts, "server = ?", server). 71 | Error 72 | } 73 | 74 | func (r *repository) GetGalleryArtByID(server ark.Server, id string) (*Art, error) { 75 | var art Art 76 | return &art, r.db. 77 | Take(&art, "(server, id) = (?, ?)", server, id). 78 | Error 79 | } 80 | 81 | func (r *repository) Put(g []Gallery) error { 82 | return r.db. 83 | Clauses(clause.OnConflict{UpdateAll: true}). 84 | Create(&g). 85 | Error 86 | } 87 | -------------------------------------------------------------------------------- /internal/app/updateloop/update_art_thumbnail.go: -------------------------------------------------------------------------------- 1 | package updateloop 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "github.com/chai2010/webp" 7 | "github.com/disintegration/imaging" 8 | "github.com/flandiayingman/arkwaifu/internal/app/art" 9 | "github.com/rs/zerolog/log" 10 | "golang.org/x/sync/errgroup" 11 | "image" 12 | "runtime" 13 | "time" 14 | ) 15 | 16 | func (s *Service) attemptUpdateArtThumbnails(ctx context.Context) { 17 | arts, err := s.artService.SelectArtsWhoseVariantAbsent("thumbnail") 18 | if err != nil { 19 | log.Error(). 20 | Err(err). 21 | Caller(). 22 | Msg("Failed to select arts.") 23 | } 24 | if len(arts) > 0 { 25 | err := s.updateArtsThumbnail(ctx, arts) 26 | if err != nil { 27 | log.Error(). 28 | Err(err). 29 | Caller(). 30 | Msg("Failed to update arts thumbnail.") 31 | } 32 | } 33 | } 34 | 35 | func (s *Service) updateArtsThumbnail(ctx context.Context, arts []*art.Art) error { 36 | log.Info().Msgf("Begin updating arts thumbnail, using %d goroutines", runtime.NumCPU()) 37 | begin := time.Now() 38 | 39 | ctx, cancel := context.WithCancel(ctx) 40 | defer cancel() 41 | 42 | eg, ctx := errgroup.WithContext(ctx) 43 | eg.SetLimit(runtime.NumCPU()) 44 | 45 | for _, art := range arts { 46 | art := art 47 | eg.Go(func() error { 48 | return s.updateArtThumbnail(ctx, art) 49 | }) 50 | } 51 | 52 | err := eg.Wait() 53 | if err != nil { 54 | return err 55 | } 56 | 57 | log.Info().Msgf("End updating arts thumbnail, elapsed %v", time.Since(begin)) 58 | return nil 59 | } 60 | 61 | func (s *Service) updateArtThumbnail(ctx context.Context, a *art.Art) error { 62 | content, err := s.artService.TakeContent(a.ID, art.VariationOrigin) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | img, _, err := image.Decode(bytes.NewReader(content)) 68 | if err != nil { 69 | return err 70 | } 71 | 72 | img = imaging.Fit(img, 360, 360*4, imaging.Lanczos) 73 | 74 | buf := bytes.Buffer{} 75 | err = webp.Encode(&buf, img, &webp.Options{ 76 | Lossless: false, 77 | Quality: 75, 78 | Exact: false, 79 | }) 80 | if err != nil { 81 | return err 82 | } 83 | 84 | err = s.artService.UpsertVariants(art.NewVariant(a.ID, art.VariationThumbnail)) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | err = s.artService.StoreContent(a.ID, art.VariationThumbnail, buf.Bytes()) 90 | if err != nil { 91 | return err 92 | } 93 | 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /internal/pkg/arkparser/directive.go: -------------------------------------------------------------------------------- 1 | package arkparser 2 | 3 | import ( 4 | "fmt" 5 | "github.com/pkg/errors" 6 | "os" 7 | "path" 8 | "regexp" 9 | "strings" 10 | ) 11 | 12 | type Directive struct { 13 | Name string 14 | Params map[string]string 15 | } 16 | 17 | var DirectiveRegex = regexp.MustCompile(`\[(\w*)(?:\((.*)\))?]|\[name="(.*)"]`) 18 | 19 | func ParseDirectives(raw string) []Directive { 20 | directives := make([]Directive, 0) 21 | matches := DirectiveRegex.FindAllStringSubmatch(raw, -1) 22 | for _, match := range matches { 23 | if match[3] == "" { 24 | name := strings.ToLower(match[1]) 25 | params := ParseDirectiveParams(match[2]) 26 | directives = append(directives, Directive{ 27 | Name: name, 28 | Params: params, 29 | }) 30 | } else { 31 | name := "" 32 | params := make(map[string]string) 33 | params["name"] = match[3] 34 | directives = append(directives, Directive{ 35 | Name: name, 36 | Params: params, 37 | }) 38 | } 39 | } 40 | return directives 41 | } 42 | 43 | func ParseDirectiveParams(rawParams string) map[string]string { 44 | params := make(map[string]string) 45 | for _, rawParam := range splitTokens(rawParams) { 46 | key, value, _ := strings.Cut(rawParam, "=") 47 | key = strings.ToLower(strings.TrimSpace(key)) 48 | value = strings.Trim(strings.TrimSpace(value), `"`) 49 | params[key] = value 50 | } 51 | return params 52 | } 53 | 54 | // splitTokens splits a string separated by comma which is not in double quotes. 55 | func splitTokens(s string) []string { 56 | if len(s) <= 0 { 57 | return nil 58 | } 59 | tokens := make([]string, 0) 60 | pos := 0 61 | inQuotes := false 62 | for i, char := range s { 63 | if char == '"' { 64 | inQuotes = !inQuotes 65 | } else if char == ',' && !inQuotes { 66 | tokens = append(tokens, s[pos:i]) 67 | pos = i + 1 68 | } 69 | } 70 | lastToken := s[pos:] 71 | if lastToken == "," { 72 | tokens = append(tokens, "") 73 | } else { 74 | tokens = append(tokens, lastToken) 75 | } 76 | return tokens 77 | } 78 | 79 | func (p *Parser) ParseStoryText(storyTextPath string) ([]Directive, error) { 80 | storyTextPath = fmt.Sprintf("%s.txt", storyTextPath) 81 | storyTextPath = path.Join(p.Root, p.Prefix, "gamedata/story", storyTextPath) 82 | storyTextData, err := os.ReadFile(storyTextPath) 83 | if err != nil { 84 | return nil, errors.WithStack(err) 85 | } 86 | 87 | return ParseDirectives(string(storyTextData)), nil 88 | } 89 | -------------------------------------------------------------------------------- /internal/pkg/arkassets/fetch.go: -------------------------------------------------------------------------------- 1 | package arkassets 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/flandiayingman/arkwaifu/internal/pkg/cols" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/cavaliergopher/grab/v3" 11 | "github.com/pkg/errors" 12 | "github.com/rs/zerolog/log" 13 | ) 14 | 15 | const fetchWorkers = 16 16 | const retryWorkers = 1 17 | 18 | func fetch(ctx context.Context, infoList []Info) (string, error) { 19 | tempDir, err := os.MkdirTemp("", "arkassets_fetch-*") 20 | if err != nil { 21 | return "", errors.WithStack(err) 22 | } 23 | 24 | ctx, cancelCtx := context.WithCancel(ctx) 25 | defer cancelCtx() 26 | 27 | requests, err := cols.MapErr(infoList, func(i Info) (*grab.Request, error) { 28 | request, err := grab.NewRequest(tempDir, i.Url()) 29 | if err != nil { 30 | return nil, errors.WithStack(err) 31 | } 32 | request = request.WithContext(ctx) 33 | return request, nil 34 | }) 35 | responses := grab.DefaultClient.DoBatch(fetchWorkers, requests...) 36 | 37 | num := 0 38 | total := len(requests) 39 | requests = nil 40 | for response := range responses { 41 | srcFile := response.Request.URL().String() 42 | dstFile := filepath.ToSlash(filepath.Clean(response.Filename)) 43 | 44 | log := log.With(). 45 | Str("src", srcFile). 46 | Str("dst", dstFile). 47 | Int("num", num). 48 | Int("total", total). 49 | Str("progress", fmt.Sprintf("%.3f", float64(num)/float64(total))). 50 | Logger() 51 | 52 | err := response.Err() 53 | if err != nil { 54 | req := response.Request 55 | req = req.WithContext(ctx) 56 | requests = append(requests, req) 57 | log.Info().Msg("Failed to fetch resource; appending to retry queue") 58 | } else { 59 | log.Info().Msg("Fetched resource.") 60 | } 61 | num++ 62 | } 63 | 64 | responses = grab.DefaultClient.DoBatch(retryWorkers, requests...) 65 | 66 | num = 0 67 | total = len(requests) 68 | for response := range responses { 69 | srcFile := response.Request.URL().String() 70 | dstFile := filepath.ToSlash(filepath.Clean(response.Filename)) 71 | 72 | err := response.Err() 73 | if err != nil { 74 | return "", errors.Wrapf(err, "srcFile:%v; dstFile: %v", srcFile, dstFile) 75 | } 76 | log.Info(). 77 | Str("src", srcFile). 78 | Str("dst", dstFile). 79 | Int("num", num). 80 | Int("total", total). 81 | Str("progress", fmt.Sprintf("%.3f", float64(num)/float64(total))). 82 | Msg("Fetched resource in retry queue.") 83 | num++ 84 | } 85 | return tempDir, nil 86 | } 87 | -------------------------------------------------------------------------------- /internal/app/art/service.go: -------------------------------------------------------------------------------- 1 | package art 2 | 3 | import ( 4 | "errors" 5 | "github.com/flandiayingman/arkwaifu/internal/app/infra" 6 | "github.com/google/uuid" 7 | "strings" 8 | ) 9 | 10 | type Service struct { 11 | repo *repository 12 | 13 | users []infra.User 14 | } 15 | 16 | func newService(config *infra.Config, repo *repository) *Service { 17 | return &Service{ 18 | repo: repo, 19 | users: config.Users, 20 | } 21 | } 22 | 23 | var ( 24 | ErrNotFound = errors.New("the resource with the identifier(s) is not found") 25 | ) 26 | 27 | func (s *Service) SelectArts() ([]*Art, error) { 28 | return s.repo.SelectArts() 29 | } 30 | func (s *Service) SelectArtsByCategory(category Category) ([]*Art, error) { 31 | return s.repo.SelectArtsByCategory(string(category)) 32 | } 33 | func (s *Service) SelectArtsByIDs(ids []string) ([]*Art, error) { 34 | for i, id := range ids { 35 | ids[i] = strings.ToLower(id) 36 | } 37 | return s.repo.SelectArtsByIDs(ids) 38 | } 39 | func (s *Service) SelectArtsByIDLike(like string) ([]*Art, error) { 40 | return s.repo.SelectArtsByIDLike(like) 41 | } 42 | func (s *Service) SelectArt(id string) (*Art, error) { 43 | return s.repo.SelectArt(strings.ToLower(id)) 44 | } 45 | func (s *Service) SelectVariants(id string) ([]*Variant, error) { 46 | return s.repo.SelectVariants(strings.ToLower(id)) 47 | } 48 | func (s *Service) SelectVariant(id string, variation Variation) (*Variant, error) { 49 | return s.repo.SelectVariant(strings.ToLower(id), string(variation)) 50 | } 51 | 52 | func (s *Service) UpsertArts(arts ...*Art) error { 53 | for _, art := range arts { 54 | art.ID = strings.ToLower(art.ID) 55 | } 56 | return s.repo.UpsertArts(arts...) 57 | } 58 | func (s *Service) UpsertVariants(variants ...*Variant) error { 59 | for _, variant := range variants { 60 | variant.ArtID = strings.ToLower(variant.ArtID) 61 | } 62 | return s.repo.UpsertVariants(variants...) 63 | } 64 | 65 | func (s *Service) StoreContent(id string, variation Variation, content []byte) (err error) { 66 | return s.repo.StoreContent(strings.ToLower(id), string(variation), content) 67 | } 68 | func (s *Service) TakeContent(id string, variation Variation) (content []byte, err error) { 69 | return s.repo.TakeContent(strings.ToLower(id), string(variation)) 70 | } 71 | 72 | func (s *Service) SelectArtsWhoseVariantAbsent(variation Variation) ([]*Art, error) { 73 | return s.repo.SelectArtsWhereVariantAbsent(string(variation)) 74 | } 75 | 76 | func (s *Service) Authenticate(uuid uuid.UUID) *infra.User { 77 | for _, user := range s.users { 78 | if user.ID == uuid { 79 | return &user 80 | } 81 | } 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /internal/pkg/arkassets/unpack.go: -------------------------------------------------------------------------------- 1 | package arkassets 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "github.com/flandiayingman/arkwaifu/internal/pkg/util/fileutil" 7 | "github.com/pkg/errors" 8 | "github.com/rs/zerolog/log" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | "strings" 13 | ) 14 | 15 | const ( 16 | extractorLocation = "./tools/extractor" 17 | ) 18 | 19 | func unpack(ctx context.Context, src string) (string, error) { 20 | tempDir, err := os.MkdirTemp("", "arkassets_unpack-*") 21 | if err != nil { 22 | return "", errors.WithStack(err) 23 | } 24 | 25 | err = findExtractor() 26 | if err != nil { 27 | return "", errors.WithStack(err) 28 | } 29 | 30 | srcAbs, err := filepath.Abs(src) 31 | if err != nil { 32 | return "", errors.WithStack(err) 33 | } 34 | dstAbs, err := filepath.Abs(tempDir) 35 | if err != nil { 36 | return "", errors.WithStack(err) 37 | } 38 | 39 | args := []string{"-u", "main.py", srcAbs, dstAbs} 40 | cmd := exec.CommandContext(ctx, "python", args...) 41 | cmd.Dir = extractorLocation 42 | 43 | stdout, err := cmd.StdoutPipe() 44 | if err != nil { 45 | return "", errors.WithStack(err) 46 | } 47 | scanner := bufio.NewScanner(stdout) 48 | go func(scanner *bufio.Scanner) { 49 | for scanner.Scan() { 50 | t := scanner.Text() 51 | log.Debug(). 52 | Str("output", t). 53 | Msg("Output from stdout of the extractor... ") 54 | split := strings.SplitN(t, "=>", 2) 55 | if len(split) != 2 { 56 | continue 57 | } 58 | srcFile := filepath.ToSlash(filepath.Clean(split[0])) 59 | dstFile := filepath.ToSlash(filepath.Clean(split[1])) 60 | log.Info(). 61 | Str("src", srcFile). 62 | Str("dst", dstFile). 63 | Msg("Unpacked the resource src to dst. ") 64 | } 65 | }(scanner) 66 | stderr, err := cmd.StderrPipe() 67 | if err != nil { 68 | return "", errors.WithStack(err) 69 | } 70 | errScanner := bufio.NewScanner(stderr) 71 | go func(errScanner *bufio.Scanner) { 72 | for errScanner.Scan() { 73 | t := errScanner.Text() 74 | log.Warn(). 75 | Str("output", t). 76 | Msg("Output from stderr of the extractor... ") 77 | } 78 | }(errScanner) 79 | 80 | err = cmd.Start() 81 | if err != nil { 82 | return "", errors.WithStack(err) 83 | } 84 | 85 | err = cmd.Wait() 86 | if err != nil { 87 | return "", errors.WithStack(err) 88 | } 89 | 90 | return tempDir, nil 91 | } 92 | 93 | func findExtractor() error { 94 | exists, err := fileutil.Exists(extractorLocation) 95 | if err != nil { 96 | return errors.WithStack(err) 97 | } 98 | if !exists { 99 | return errors.New("cannot find extractor") 100 | } 101 | return nil 102 | } 103 | -------------------------------------------------------------------------------- /internal/pkg/arkassets/raw.go: -------------------------------------------------------------------------------- 1 | package arkassets 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/pkg/errors" 7 | "net/http" 8 | ) 9 | 10 | // Version represents a raw response of "https://ak-conf.hypergryph.com/config/prod/official/Android/version". 11 | type Version struct { 12 | ResVersion string `json:"resVersion"` 13 | ClientVersion string `json:"clientVersion"` 14 | } 15 | 16 | // HotUpdateList represents a raw response of "https://ak.hycdn.cn/assetbundle/official/Android/assets/{resVersion}/hot_update_list.json" 17 | type HotUpdateList struct { 18 | FullPack FullPack `json:"fullPack"` 19 | VersionID string `json:"versionId"` 20 | AbInfos []AbInfo `json:"abInfos"` 21 | CountOfTypedRes int `json:"countOfTypedRes"` 22 | PackInfos []PackInfo `json:"packInfos"` 23 | } 24 | 25 | type FullPack struct { 26 | TotalSize int `json:"totalSize"` 27 | AbSize int `json:"abSize"` 28 | Type string `json:"type"` 29 | Cid int `json:"cid"` 30 | } 31 | 32 | type AbInfo struct { 33 | Name string `json:"name"` 34 | Hash string `json:"hash"` 35 | MD5 string `json:"md5"` 36 | TotalSize int `json:"totalSize"` 37 | AbSize int `json:"abSize"` 38 | Cid int `json:"cid"` 39 | Pid string `json:"pid,omitempty"` 40 | Type string `json:"type,omitempty"` 41 | } 42 | 43 | type PackInfo struct { 44 | Name string `json:"name"` 45 | Hash string `json:"hash"` 46 | MD5 string `json:"md5"` 47 | TotalSize int `json:"totalSize"` 48 | AbSize int `json:"abSize"` 49 | Cid int `json:"cid"` 50 | } 51 | 52 | // GetRawVersion gets the raw response from Arknights version API. 53 | func GetRawVersion() (Version, error) { 54 | resp, err := http.Get("https://ak-conf.hypergryph.com/config/prod/official/Android/version") 55 | if err != nil { 56 | return Version{}, errors.WithStack(err) 57 | } 58 | defer resp.Body.Close() 59 | 60 | arkVersion := Version{} 61 | err = json.NewDecoder(resp.Body).Decode(&arkVersion) 62 | if err != nil { 63 | return Version{}, errors.WithStack(err) 64 | } 65 | return arkVersion, nil 66 | } 67 | 68 | // GetRawResources gets the raw response of Arknights resource API with specified resource version. 69 | func GetRawResources(resVersion string) (HotUpdateList, error) { 70 | urlResourceList := GetResURL(resVersion, "hot_update_list.json") 71 | 72 | resp, err := http.Get(urlResourceList) 73 | if err != nil { 74 | return HotUpdateList{}, errors.WithStack(err) 75 | } 76 | defer resp.Body.Close() 77 | 78 | resourcesList := HotUpdateList{} 79 | err = json.NewDecoder(resp.Body).Decode(&resourcesList) 80 | if err != nil { 81 | return HotUpdateList{}, errors.WithStack(err) 82 | } 83 | return resourcesList, nil 84 | } 85 | 86 | // GetResURL gets the URL to download the specified asset with specified resource version. 87 | func GetResURL(resVersion string, res string) string { 88 | return fmt.Sprintf("https://ak.hycdn.cn/assetbundle/official/Android/assets/%v/%v", resVersion, res) 89 | } 90 | -------------------------------------------------------------------------------- /internal/app/story/controller.go: -------------------------------------------------------------------------------- 1 | package story 2 | 3 | import ( 4 | "github.com/flandiayingman/arkwaifu/internal/pkg/ark" 5 | "github.com/gofiber/fiber/v2" 6 | ) 7 | 8 | type controller struct { 9 | service *Service 10 | } 11 | 12 | func newController(service *Service) *controller { 13 | return &controller{service} 14 | } 15 | func registerController(c *controller, router fiber.Router) { 16 | if router == nil { 17 | return 18 | } 19 | router.Get(":server/story-groups", c.GetGroups) 20 | router.Get(":server/story-groups/:id", c.GetGroup) 21 | router.Get(":server/stories", c.GetStories) 22 | router.Get(":server/stories/:id", c.GetStory) 23 | 24 | router.Get(":server/aggregated-picture-arts/:id", c.GetAggregatedPictureArt) 25 | router.Get(":server/aggregated-character-arts/:id", c.GetAggregatedCharacterArt) 26 | } 27 | 28 | func (c *controller) GetGroups(ctx *fiber.Ctx) error { 29 | server, err := ark.ParseServer(ctx.Params("server")) 30 | if err != nil { 31 | return fiber.ErrBadRequest 32 | } 33 | 34 | queryType := ctx.Query("type") 35 | 36 | queryFilter := GroupFilter{ 37 | Type: queryType, 38 | } 39 | 40 | groups, err := c.service.GetStoryGroups(server, queryFilter) 41 | if err != nil { 42 | return err 43 | } 44 | return ctx.JSON(groups) 45 | } 46 | func (c *controller) GetGroup(ctx *fiber.Ctx) error { 47 | server, err := ark.ParseServer(ctx.Params("server")) 48 | if err != nil { 49 | return fiber.ErrBadRequest 50 | } 51 | id := ctx.Params("id") 52 | if id == "" { 53 | return fiber.ErrBadRequest 54 | } 55 | 56 | group, err := c.service.GetStoryGroup(server, id) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | return ctx.JSON(group) 62 | } 63 | 64 | func (c *controller) GetStories(ctx *fiber.Ctx) error { 65 | server, err := ark.ParseServer(ctx.Params("server")) 66 | if err != nil { 67 | return fiber.ErrBadRequest 68 | } 69 | 70 | stories, err := c.service.GetStories(server) 71 | if err != nil { 72 | return err 73 | } 74 | 75 | return ctx.JSON(stories) 76 | } 77 | func (c *controller) GetStory(ctx *fiber.Ctx) error { 78 | server, err := ark.ParseServer(ctx.Params("server")) 79 | if err != nil { 80 | return fiber.ErrBadRequest 81 | } 82 | id := ctx.Params("id") 83 | if id == "" { 84 | return fiber.ErrBadRequest 85 | } 86 | 87 | story, err := c.service.GetStory(server, id) 88 | if err != nil { 89 | return err 90 | } 91 | 92 | return ctx.JSON(story) 93 | } 94 | 95 | func (c *controller) GetAggregatedPictureArt(ctx *fiber.Ctx) error { 96 | server, err := ark.ParseServer(ctx.Params("server")) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | id := ctx.Params("id") 102 | 103 | art, err := c.service.GetAggregatedPictureArt(server, id) 104 | if err != nil { 105 | return err 106 | } 107 | 108 | return ctx.JSON(art) 109 | } 110 | func (c *controller) GetAggregatedCharacterArt(ctx *fiber.Ctx) error { 111 | server, err := ark.ParseServer(ctx.Params("server")) 112 | if err != nil { 113 | return err 114 | } 115 | 116 | id := ctx.Params("id") 117 | 118 | art, err := c.service.GetAggregatedCharacterArt(server, id) 119 | if err != nil { 120 | return err 121 | } 122 | 123 | return ctx.JSON(art) 124 | } 125 | -------------------------------------------------------------------------------- /internal/pkg/arkparser/story_characters.go: -------------------------------------------------------------------------------- 1 | package arkparser 2 | 3 | import ( 4 | "fmt" 5 | "github.com/wk8/go-ordered-map/v2" 6 | "regexp" 7 | ) 8 | 9 | func (p *Parser) ParseCharacters(directives []Directive) []*StoryCharacter { 10 | stage := characterStage{ 11 | spotlight: "", 12 | characters: make(map[string]string), 13 | history: make([]string, 0), 14 | names: make(map[string][]string), 15 | } 16 | for _, directive := range directives { 17 | switch directive.Name { 18 | case "": 19 | name := directive.Params["name"] 20 | stage.name(name) 21 | case "character": 22 | id1 := NormalizeCharacterID(directive.Params["name"]) 23 | id2 := NormalizeCharacterID(directive.Params["name2"]) 24 | stage.take("1", id1) 25 | stage.take("2", id2) 26 | stage.focus(directive.Params["focus"], "1") 27 | case "charslot": 28 | if id := NormalizeCharacterID(directive.Params["name"]); id != "" { 29 | stage.take(directive.Params["slot"], id) 30 | stage.focus(directive.Params["focus"], directive.Params["slot"]) 31 | } else { 32 | stage.exit() 33 | } 34 | case "dialog": 35 | stage.exit() 36 | } 37 | } 38 | 39 | result := make([]*StoryCharacter, 0) 40 | for _, id := range stage.history { 41 | result = append(result, &StoryCharacter{ 42 | ID: id, 43 | Names: unique(stage.names[id]), 44 | }) 45 | } 46 | 47 | return result 48 | } 49 | 50 | func NormalizeCharacterID(id string) string { 51 | if id == "" { 52 | return "" 53 | } 54 | 55 | regex := regexp.MustCompile(`^(.*?)(?:#(\d+))?(?:\$(\d+))?$`) 56 | matches := regex.FindStringSubmatch(id) 57 | if len(matches) == 0 { 58 | return "" 59 | } 60 | 61 | id = matches[1] 62 | faceNum, bodyNum := "1", "1" // default values are 1 63 | if matches[2] != "" { 64 | faceNum = matches[2] 65 | } 66 | if matches[3] != "" { 67 | bodyNum = matches[3] 68 | } 69 | 70 | return fmt.Sprintf("%s#%s$%s", id, faceNum, bodyNum) 71 | } 72 | 73 | type characterStage struct { 74 | spotlight string 75 | characters map[string]string 76 | 77 | history []string 78 | names map[string][]string 79 | } 80 | 81 | func (s *characterStage) protagonist() string { 82 | return s.characters[s.spotlight] 83 | } 84 | func (s *characterStage) focus(slot string, defaultSlot string) { 85 | if slot != "" { 86 | s.spotlight = slot 87 | } else if len(s.characters) == 1 { 88 | for slot, _ := range s.characters { 89 | s.spotlight = slot 90 | } 91 | } else { 92 | s.spotlight = "" 93 | } 94 | } 95 | func (s *characterStage) take(slot string, id string) { 96 | if id != "" { 97 | s.characters[slot] = id 98 | s.history = append(s.history, id) 99 | } else { 100 | delete(s.characters, slot) 101 | } 102 | } 103 | func (s *characterStage) name(name string) { 104 | s.names[s.protagonist()] = append(s.names[s.protagonist()], name) 105 | } 106 | func (s *characterStage) exit() { 107 | s.spotlight = "" 108 | s.characters = make(map[string]string) 109 | } 110 | 111 | func unique[T comparable](slice []T) []T { 112 | m := orderedmap.New[T, struct{}](len(slice)) 113 | for _, el := range slice { 114 | m.Set(el, struct{}{}) 115 | } 116 | slice = nil 117 | for pair := m.Oldest(); pair != nil; pair = pair.Next() { 118 | slice = append(slice, pair.Key) 119 | } 120 | return slice 121 | } 122 | -------------------------------------------------------------------------------- /internal/app/updateloop/update_story.go: -------------------------------------------------------------------------------- 1 | package updateloop 2 | 3 | import ( 4 | "context" 5 | "github.com/flandiayingman/arkwaifu/internal/pkg/ark" 6 | "github.com/flandiayingman/arkwaifu/internal/pkg/arkdata" 7 | "github.com/flandiayingman/arkwaifu/internal/pkg/arkparser" 8 | "github.com/rs/zerolog/log" 9 | _ "image/jpeg" // register jpeg codec 10 | _ "image/png" // register png codec 11 | "os" 12 | "time" 13 | ) 14 | 15 | var ( 16 | storyPatterns = []string{ 17 | "gamedata/excel/**", 18 | "gamedata/story/**", 19 | } 20 | ) 21 | 22 | func (s *Service) getRemoteStoryVersion(ctx context.Context, server ark.Server) (ark.Version, error) { 23 | resourceVersion, err := arkdata.GetLatestDataVersion(ctx, server) 24 | if err != nil { 25 | return "", err 26 | } 27 | return resourceVersion.ResourceVersion, err 28 | } 29 | 30 | func (s *Service) getLocalStoryVersion(ctx context.Context, server ark.Server) (ark.Version, error) { 31 | return s.repo.selectStoryVersion(ctx, server) 32 | } 33 | 34 | func (s *Service) attemptUpdateStory(ctx context.Context, server ark.Server) { 35 | log := log.With(). 36 | Str("server", server). 37 | Logger() 38 | 39 | log.Info(). 40 | Msg("Attempting to update stories of the server...") 41 | 42 | localStoryVersion, err := s.getLocalStoryVersion(ctx, server) 43 | if err != nil { 44 | log.Err(err). 45 | Msg("Failed to get the local story version of the server. ") 46 | return 47 | } 48 | 49 | remoteStoryVersion, err := s.getRemoteStoryVersion(ctx, server) 50 | if err != nil { 51 | log.Err(err). 52 | Msg("Failed to get the remote story version of the server. ") 53 | return 54 | } 55 | 56 | log = log.With(). 57 | Str("localStoryVersion", localStoryVersion). 58 | Str("remoteStoryVersion", remoteStoryVersion). 59 | Logger() 60 | 61 | if localStoryVersion != remoteStoryVersion { 62 | log.Info(). 63 | Msg("Updating the stories of the server, since the story versions are not identical. ") 64 | begin := time.Now() 65 | err = s.updateStories(ctx, server, remoteStoryVersion) 66 | if err != nil { 67 | log.Err(err). 68 | Msg("Update loop failed to update the stories") 69 | } 70 | log.Info(). 71 | Dur("elapsed", time.Since(begin)). 72 | Msg("Updated the stories of the server successfully. ") 73 | } else { 74 | log.Info(). 75 | Msg("Skip updating the stories of the server, since the story versions are identical. ") 76 | } 77 | } 78 | 79 | func (s *Service) updateStories(ctx context.Context, server ark.Server, version ark.Version) error { 80 | root, err := os.MkdirTemp("", "arkwaifu-updateloop-story-*") 81 | if err != nil { 82 | return err 83 | } 84 | defer os.RemoveAll(root) 85 | 86 | err = arkdata.GetGameData(ctx, server, version, storyPatterns, root) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | parser := arkparser.Parser{ 92 | Root: root, 93 | Prefix: "assets/torappu/dynamicassets", 94 | } 95 | 96 | storyTree, err := parser.Parse() 97 | if err != nil { 98 | return err 99 | } 100 | err = s.storyService.PopulateFrom(storyTree, server) 101 | if err != nil { 102 | return err 103 | } 104 | 105 | err = s.repo.upsertStoryVersion(ctx, &storyVersion{ 106 | Server: server, 107 | Version: version, 108 | }) 109 | if err != nil { 110 | return err 111 | } 112 | 113 | return nil 114 | } 115 | -------------------------------------------------------------------------------- /internal/pkg/arkdata/data_test.go: -------------------------------------------------------------------------------- 1 | package arkdata 2 | 3 | import ( 4 | "context" 5 | "github.com/flandiayingman/arkwaifu/internal/pkg/ark" 6 | "github.com/flandiayingman/arkwaifu/internal/pkg/util/pathutil" 7 | "github.com/stretchr/testify/assert" 8 | "os" 9 | "path" 10 | "testing" 11 | ) 12 | 13 | func TestGetGameData(t *testing.T) { 14 | type args struct { 15 | ctx context.Context 16 | server ark.Server 17 | version ark.Version 18 | patterns []string 19 | dst string 20 | } 21 | tests := []struct { 22 | name string 23 | args args 24 | }{ 25 | { 26 | name: "specific version story_review_table", 27 | args: args{ 28 | ctx: context.Background(), 29 | server: ark.CnServer, 30 | version: "23-07-27-18-50-06-aeb568", 31 | patterns: []string{"**/story_review_table.json"}, 32 | dst: "./test_1", 33 | }, 34 | }, 35 | { 36 | name: "latest all .json", 37 | args: args{ 38 | ctx: context.Background(), 39 | server: ark.CnServer, 40 | version: "", 41 | patterns: []string{"**/*.json"}, 42 | dst: "./test_2", 43 | }, 44 | }, 45 | { 46 | name: "latest all stories", 47 | args: args{ 48 | ctx: context.Background(), 49 | server: ark.CnServer, 50 | version: "", 51 | patterns: []string{"gamedata/story/**/*.txt"}, 52 | dst: "./test_3", 53 | }, 54 | }, 55 | } 56 | for _, tt := range tests { 57 | t.Run(tt.name, func(t *testing.T) { 58 | defer os.RemoveAll(tt.args.dst) 59 | err := GetGameData(tt.args.ctx, tt.args.server, tt.args.version, tt.args.patterns, tt.args.dst) 60 | if err != nil { 61 | t.Error(err) 62 | } 63 | 64 | files, err := pathutil.Glob(tt.args.patterns, path.Join(tt.args.dst, DefaultPrefix)) 65 | if err != nil { 66 | t.Error(err) 67 | } 68 | 69 | assert.NotEmpty(t, files) 70 | }) 71 | } 72 | } 73 | 74 | func TestGetCompositeGameData(t *testing.T) { 75 | type args struct { 76 | ctx context.Context 77 | server ark.Server 78 | version ark.Version 79 | patterns []string 80 | dst string 81 | } 82 | tests := []struct { 83 | name string 84 | args args 85 | successful bool 86 | }{ 87 | { 88 | name: "specific version story_review_table", 89 | args: args{ 90 | ctx: context.Background(), 91 | server: ark.CnServer, 92 | version: "23-07-27-18-50-06-aeb568", 93 | patterns: []string{"**/story_review_table.json"}, 94 | dst: "./test_1", 95 | }, 96 | successful: true, 97 | }, 98 | { 99 | name: "latest all .json", 100 | args: args{ 101 | ctx: context.Background(), 102 | server: ark.CnServer, 103 | version: "", 104 | patterns: []string{"**/*.json"}, 105 | dst: "./test_2", 106 | }, 107 | successful: true, 108 | }, 109 | { 110 | name: "latest all stories", 111 | args: args{ 112 | ctx: context.Background(), 113 | server: ark.CnServer, 114 | version: "", 115 | patterns: []string{"gamedata/story/**/*.txt"}, 116 | dst: "./test_3", 117 | }, 118 | successful: false, 119 | }, 120 | } 121 | for _, tt := range tests { 122 | t.Run(tt.name, func(t *testing.T) { 123 | defer os.RemoveAll(tt.args.dst) 124 | err := GetCompositeGameData(tt.args.ctx, tt.args.server, tt.args.version, tt.args.patterns, tt.args.dst) 125 | 126 | files, err := pathutil.Glob(tt.args.patterns, path.Join(tt.args.dst, DefaultPrefix)) 127 | if err != nil { 128 | t.Error(err) 129 | } 130 | 131 | if tt.successful { 132 | assert.NotEmpty(t, files) 133 | } else { 134 | assert.Empty(t, files) 135 | } 136 | }) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /tools/extractor/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/python 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 4 | 5 | ### Python ### 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | cover/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | .pybuilder/ 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | # For a library or package, you might want to ignore these files since the code is 92 | # intended to run in multiple environments; otherwise, check them in: 93 | # .python-version 94 | 95 | # pipenv 96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 99 | # install all needed dependencies. 100 | #Pipfile.lock 101 | 102 | # poetry 103 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 104 | # This is especially recommended for binary packages to ensure reproducibility, and is more 105 | # commonly ignored for libraries. 106 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 107 | #poetry.lock 108 | 109 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 110 | __pypackages__/ 111 | 112 | # Celery stuff 113 | celerybeat-schedule 114 | celerybeat.pid 115 | 116 | # SageMath parsed files 117 | *.sage.py 118 | 119 | # Environments 120 | .env 121 | .venv 122 | env/ 123 | venv/ 124 | ENV/ 125 | env.bak/ 126 | venv.bak/ 127 | 128 | # Spyder project settings 129 | .spyderproject 130 | .spyproject 131 | 132 | # Rope project settings 133 | .ropeproject 134 | 135 | # mkdocs documentation 136 | /site 137 | 138 | # mypy 139 | .mypy_cache/ 140 | .dmypy.json 141 | dmypy.json 142 | 143 | # Pyre type checker 144 | .pyre/ 145 | 146 | # pytype static type analyzer 147 | .pytype/ 148 | 149 | # Cython debug symbols 150 | cython_debug/ 151 | 152 | # PyCharm 153 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 154 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 155 | # and can be added to the global gitignore or merged into this file. For a more nuclear 156 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 157 | #.idea/ 158 | 159 | # End of https://www.toptal.com/developers/gitignore/api/python -------------------------------------------------------------------------------- /internal/app/artext/ext_story.go: -------------------------------------------------------------------------------- 1 | package artext 2 | 3 | import ( 4 | "github.com/flandiayingman/arkwaifu/internal/app/art" 5 | "github.com/flandiayingman/arkwaifu/internal/pkg/ark" 6 | "github.com/gofiber/fiber/v2" 7 | ) 8 | 9 | func (c *Controller) GetArtsOfStoryGroup(ctx *fiber.Ctx) error { 10 | queryGroup := ctx.Query("group") 11 | if queryGroup == "" { 12 | return ctx.Next() 13 | } 14 | 15 | server, err := ark.ParseServer(ctx.Query("server")) 16 | if err != nil { 17 | return fiber.ErrBadRequest 18 | } 19 | 20 | arts, err := c.service.GetArtsOfStoryGroup(server, queryGroup) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | return ctx.JSON(arts) 26 | } 27 | 28 | func (c *Controller) GetArtsOfStory(ctx *fiber.Ctx) error { 29 | queryStory := ctx.Query("story") 30 | if queryStory == "" { 31 | return ctx.Next() 32 | } 33 | 34 | server, err := ark.ParseServer(ctx.Query("server")) 35 | if err != nil { 36 | return fiber.ErrBadRequest 37 | } 38 | 39 | arts, err := c.service.GetArtsOfStory(server, queryStory) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | return ctx.JSON(arts) 45 | } 46 | 47 | func (c *Controller) GetArtsExceptForStoryArts(ctx *fiber.Ctx) error { 48 | queryExceptForStoryArts := ctx.QueryBool("except-for-story-arts", false) 49 | if !queryExceptForStoryArts { 50 | return ctx.Next() 51 | } 52 | 53 | server, err := ark.ParseServer(ctx.Query("server")) 54 | if err != nil { 55 | return fiber.ErrBadRequest 56 | } 57 | 58 | arts, err := c.service.GetArtsExceptForStoryArts(server) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | return ctx.JSON(arts) 64 | } 65 | 66 | func (s *Service) GetArtsOfStoryGroup(server ark.Server, groupID string) ([]*art.Art, error) { 67 | group, err := s.story.GetStoryGroup(server, groupID) 68 | if err != nil { 69 | return nil, err 70 | } 71 | 72 | ids := make([]string, 0) 73 | for _, story := range group.Stories { 74 | for _, art := range story.PictureArts { 75 | ids = append(ids, art.ID) 76 | } 77 | for _, art := range story.CharacterArts { 78 | ids = append(ids, art.ID) 79 | } 80 | } 81 | 82 | arts, err := s.art.SelectArtsByIDs(ids) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | return arts, err 88 | } 89 | 90 | func (s *Service) GetArtsOfStory(server ark.Server, storyID string) ([]*art.Art, error) { 91 | story, err := s.story.GetStory(server, storyID) 92 | if err != nil { 93 | return nil, err 94 | } 95 | 96 | ids := make([]string, 0) 97 | for _, art := range story.PictureArts { 98 | ids = append(ids, art.ID) 99 | } 100 | for _, art := range story.CharacterArts { 101 | ids = append(ids, art.ID) 102 | } 103 | 104 | arts, err := s.art.SelectArtsByIDs(ids) 105 | if err != nil { 106 | return nil, err 107 | } 108 | 109 | return arts, err 110 | } 111 | 112 | // GetArtsExceptForStoryArts gets all arts but except for the arts which are present in the story tree. 113 | func (s *Service) GetArtsExceptForStoryArts(server ark.Server) ([]*art.Art, error) { 114 | pictureArts, err := s.story.GetPictureArts(server) 115 | if err != nil { 116 | return nil, err 117 | } 118 | characterArts, err := s.story.GetCharacterArts(server) 119 | if err != nil { 120 | return nil, err 121 | } 122 | 123 | storyArtsIDSet := make(map[string]bool) 124 | for _, pictureArt := range pictureArts { 125 | storyArtsIDSet[pictureArt.ID] = true 126 | } 127 | for _, characterArt := range characterArts { 128 | storyArtsIDSet[characterArt.ID] = true 129 | } 130 | 131 | arts, err := s.art.SelectArts() 132 | if err != nil { 133 | return nil, err 134 | } 135 | 136 | result := make([]*art.Art, 0) 137 | for _, art := range arts { 138 | if !storyArtsIDSet[art.ID] { 139 | result = append(result, art) 140 | } 141 | } 142 | 143 | return result, nil 144 | } 145 | -------------------------------------------------------------------------------- /internal/pkg/arkparser/story_review_table.go: -------------------------------------------------------------------------------- 1 | package arkparser 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/pkg/errors" 7 | "github.com/wk8/go-ordered-map/v2" 8 | "os" 9 | "path" 10 | "path/filepath" 11 | ) 12 | 13 | type JsonStoryGroup struct { 14 | ID string `json:"id"` 15 | Name string `json:"name"` 16 | Type string `json:"actType"` 17 | Stories []*JsonStory `json:"infoUnlockDatas"` 18 | } 19 | type JsonStory struct { 20 | ID string `json:"storyId"` 21 | GroupID string `json:"storyGroup"` 22 | Code string `json:"storyCode"` 23 | Name string `json:"storyName"` 24 | Info string `json:"storyInfo"` 25 | Text string `json:"storyTxt"` 26 | Tag JsonStoryTag `json:"avgTag"` 27 | } 28 | type JsonStoryTag struct { 29 | Type string 30 | Text string 31 | } 32 | 33 | func (t *JsonStoryTag) UnmarshalJSON(data []byte) error { 34 | var s string 35 | err := json.Unmarshal(data, &s) 36 | if err != nil { 37 | return errors.WithStack(err) 38 | } 39 | switch s { 40 | case TagBeforeCN, TagBeforeEN, TagBeforeJP, TagBeforeKR: 41 | *t = JsonStoryTag{ 42 | Type: TagBefore, 43 | Text: s, 44 | } 45 | case TagAfterCN, TagAfterEN, TagAfterJP, TagAfterKR: 46 | *t = JsonStoryTag{ 47 | Type: TagAfter, 48 | Text: s, 49 | } 50 | case TagInterludeCN, TagInterludeEN, TagInterludeJP, TagInterludeKR: 51 | *t = JsonStoryTag{ 52 | Type: TagInterlude, 53 | Text: s, 54 | } 55 | default: 56 | return errors.Errorf("unknown story tag: %s", s) 57 | } 58 | return nil 59 | } 60 | 61 | const ( 62 | TypeMain string = "MAIN_STORY" 63 | TypeActivity string = "ACTIVITY_STORY" 64 | TypeMiniActivity string = "MINI_STORY" 65 | TypeNone string = "NONE" 66 | 67 | TagBefore string = "BEFORE" 68 | TagAfter string = "AFTER" 69 | TagInterlude string = "INTERLUDE" 70 | 71 | TagBeforeCN string = "行动前" 72 | TagAfterCN string = "行动后" 73 | TagInterludeCN string = "幕间" 74 | TagBeforeEN string = "Before Operation" 75 | TagAfterEN string = "After Operation" 76 | TagInterludeEN string = "Interlude" 77 | TagBeforeJP string = "戦闘前" 78 | TagAfterJP string = "戦闘後" 79 | TagInterludeJP string = "幕間" 80 | TagBeforeKR string = "작전 전" 81 | TagAfterKR string = "작전 후" 82 | TagInterludeKR string = "브릿지" 83 | ) 84 | 85 | func (p *Parser) ParseStoryReviewTable() ([]*JsonStoryGroup, error) { 86 | jsonPath := filepath.Join(p.Root, p.Prefix, "gamedata/excel/story_review_table.json") 87 | jsonData, err := os.ReadFile(jsonPath) 88 | if err != nil { 89 | return nil, errors.WithStack(err) 90 | } 91 | 92 | // key: story group id; value: story group 93 | dynamicJsonObject := orderedmap.New[string, JsonStoryGroup]() 94 | err = json.Unmarshal(jsonData, &dynamicJsonObject) 95 | if err != nil { 96 | return nil, errors.WithStack(err) 97 | } 98 | 99 | result := make([]*JsonStoryGroup, 0, dynamicJsonObject.Len()) 100 | for pair := dynamicJsonObject.Oldest(); pair != nil; pair = pair.Next() { 101 | result = append(result, &pair.Value) 102 | } 103 | 104 | return result, nil 105 | } 106 | 107 | func (p *Parser) GetInfo(infoPath string) (string, error) { 108 | infoPath = fmt.Sprintf("[uc]%s.txt", infoPath) 109 | infoPath = path.Join(p.Root, p.Prefix, "gamedata/story", infoPath) 110 | 111 | bytes, err := os.ReadFile(infoPath) 112 | if err != nil { 113 | return "", errors.WithStack(err) 114 | } 115 | 116 | return string(bytes), nil 117 | } 118 | func (p *Parser) GetText(textPath string) (string, error) { 119 | textPath = fmt.Sprintf("%s.txt", textPath) 120 | textPath = path.Join(p.Root, p.Prefix, "gamedata/story", textPath) 121 | 122 | bytes, err := os.ReadFile(textPath) 123 | if err != nil { 124 | return "", errors.WithStack(err) 125 | } 126 | 127 | return string(bytes), nil 128 | } 129 | -------------------------------------------------------------------------------- /internal/pkg/arkparser/arkparser.go: -------------------------------------------------------------------------------- 1 | package arkparser 2 | 3 | import "github.com/pkg/errors" 4 | 5 | type Parser struct { 6 | Root string 7 | Prefix string 8 | 9 | pictureMap map[string]*StoryPicture 10 | characterMap map[string]*StoryCharacter 11 | } 12 | 13 | type StoryTree struct { 14 | StoryGroups []*StoryGroup 15 | } 16 | 17 | type StoryGroup struct { 18 | ID string 19 | Name string 20 | Type string 21 | Stories []*Story 22 | } 23 | 24 | type Story struct { 25 | ID string 26 | GroupID string 27 | Code string 28 | Name string 29 | Info string 30 | TagType string 31 | TagText string 32 | 33 | Pictures []*StoryPicture 34 | Characters []*StoryCharacter 35 | } 36 | 37 | type StoryCharacter struct { 38 | ID string 39 | Names []string 40 | } 41 | 42 | type StoryPicture struct { 43 | ID string 44 | Category string 45 | Title string 46 | Subtitle string 47 | } 48 | 49 | func (p *Parser) getPicture(id string) *StoryPicture { 50 | picture, ok := p.pictureMap[id] 51 | if !ok { 52 | picture = &StoryPicture{} 53 | p.pictureMap[id] = picture 54 | } 55 | return picture 56 | } 57 | func (p *Parser) setPicture(id string, picture *StoryPicture) { 58 | p.pictureMap[id] = picture 59 | } 60 | func (p *Parser) getCharacter(id string) *StoryCharacter { 61 | character, ok := p.characterMap[id] 62 | if !ok { 63 | character = &StoryCharacter{} 64 | p.characterMap[id] = character 65 | } 66 | return character 67 | } 68 | func (p *Parser) setCharacter(id string, character *StoryCharacter) { 69 | p.characterMap[id] = character 70 | } 71 | 72 | func (p *Parser) Parse() (*StoryTree, error) { 73 | jsonStoryReviewTable, err := p.ParseStoryReviewTable() 74 | if err != nil { 75 | return nil, errors.WithStack(err) 76 | } 77 | 78 | tree := StoryTree{} 79 | picIndex := make(map[string][]*StoryPicture) 80 | charIndex := make(map[string][]*StoryCharacter) 81 | for _, jsonGroup := range jsonStoryReviewTable { 82 | group := StoryGroup{ 83 | ID: jsonGroup.ID, 84 | Name: jsonGroup.Name, 85 | Type: jsonGroup.Type, 86 | Stories: nil, 87 | } 88 | for _, jsonStory := range jsonGroup.Stories { 89 | info, _ := p.GetInfo(jsonStory.Info) 90 | // If info is not found, it is likely that there is no info for the story. 91 | // Example: main_14_level_main_14-20_beg 92 | //if err != nil { 93 | // return nil, errors.WithStack(err) 94 | //} 95 | directives, err := p.ParseStoryText(jsonStory.Text) 96 | if err != nil { 97 | return nil, errors.WithStack(err) 98 | } 99 | 100 | pictures := p.ParsePictures(directives) 101 | characters := p.ParseCharacters(directives) 102 | story := Story{ 103 | ID: jsonStory.ID, 104 | GroupID: jsonStory.ID, 105 | Code: jsonStory.Code, 106 | Name: jsonStory.Name, 107 | Info: info, 108 | TagType: jsonStory.Tag.Type, 109 | TagText: jsonStory.Tag.Text, 110 | Pictures: pictures, 111 | Characters: characters, 112 | } 113 | 114 | for _, picture := range pictures { 115 | picIndex[picture.ID] = append(picIndex[picture.ID], picture) 116 | } 117 | for _, character := range characters { 118 | charIndex[character.ID] = append(charIndex[character.ID], character) 119 | } 120 | 121 | group.Stories = append(group.Stories, &story) 122 | } 123 | tree.StoryGroups = append(tree.StoryGroups, &group) 124 | } 125 | 126 | storyReviewMetaTable, err := p.ParseStoryReviewMetaTable() 127 | if err != nil { 128 | return nil, errors.WithStack(err) 129 | } 130 | for pair := storyReviewMetaTable.ActArchiveResData.Pics.Oldest(); pair != nil; pair = pair.Next() { 131 | pic := pair.Value 132 | for _, picture := range picIndex[pic.AssetPath] { 133 | picture.Title = pic.Desc 134 | picture.Subtitle = pic.PicDescription 135 | } 136 | } 137 | 138 | return &tree, nil 139 | } 140 | -------------------------------------------------------------------------------- /internal/app/updateloop/updateloop.go: -------------------------------------------------------------------------------- 1 | // Package updateloop provide functionalities to keep the local assets 2 | // up-to-date. 3 | // 4 | // The operation "update" contains 3 sub-operations, and the subject of update 5 | // can be either PictureArt module or Story module. 6 | // 7 | // Pull. It pulls the assets from the remote API, either the HyperGryph API or 8 | // the ArknightsGameData GitHub repository API. It also unpacks or decompresses 9 | // the assets from the downloaded files, therefore, whether the assets are 10 | // pulled from either API, the result directory structure will turn out the 11 | // same. 12 | // 13 | // Preprocess. It converts the pulled assets to the form which the submitter of 14 | // the "submit" sub-operation can recognize. Moreover, it does some image 15 | // preprocessing such as merge the alpha channel and merge the different faces 16 | // onto the body for characters (合并差分). 17 | // 18 | // Submit. It parses the preprocessed assets and submits them to the art service 19 | // and the story service. 20 | // 21 | // After the above sub-operations, all intermediate files shall be deleted to 22 | // preserve the disk space. 23 | package updateloop 24 | 25 | import ( 26 | "context" 27 | "fmt" 28 | "github.com/flandiayingman/arkwaifu/internal/app/art" 29 | "github.com/flandiayingman/arkwaifu/internal/app/gallery" 30 | "github.com/flandiayingman/arkwaifu/internal/app/story" 31 | "github.com/flandiayingman/arkwaifu/internal/pkg/ark" 32 | "github.com/rs/zerolog" 33 | "github.com/rs/zerolog/log" 34 | "go.uber.org/fx" 35 | "os" 36 | "time" 37 | ) 38 | 39 | func init() { 40 | log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) 41 | } 42 | 43 | func FxModule() fx.Option { 44 | return fx.Module("updateloop", 45 | fx.Provide( 46 | newRepo, 47 | newService, 48 | ), 49 | fx.Invoke( 50 | registerService, 51 | ), 52 | ) 53 | } 54 | 55 | // Service provides all functionalities of update. 56 | type Service struct { 57 | repo *repo 58 | 59 | artService *art.Service 60 | storyService *story.Service 61 | galleryService *gallery.Service 62 | } 63 | 64 | func newService( 65 | artService *art.Service, 66 | storyService *story.Service, 67 | galleryService *gallery.Service, 68 | repo *repo, 69 | ) *Service { 70 | return &Service{ 71 | artService: artService, 72 | storyService: storyService, 73 | galleryService: galleryService, 74 | repo: repo, 75 | } 76 | } 77 | 78 | func registerService(service *Service, lc fx.Lifecycle) { 79 | loopCtx, cancelLoop := context.WithCancel(context.Background()) 80 | lc.Append(fx.Hook{ 81 | OnStart: func(ctx context.Context) error { 82 | go service.Loop(loopCtx) 83 | return nil 84 | }, 85 | OnStop: func(ctx context.Context) error { 86 | cancelLoop() 87 | return nil 88 | }, 89 | }) 90 | } 91 | 92 | func (s *Service) Loop(ctx context.Context) { 93 | for { 94 | select { 95 | case <-ctx.Done(): 96 | break 97 | default: 98 | s.AttemptUpdate(ctx) 99 | } 100 | time.Sleep(5 * time.Minute) 101 | } 102 | } 103 | 104 | // AttemptUpdate attempts to update the resources. 105 | func (s *Service) AttemptUpdate(ctx context.Context) { 106 | log := log.With(). 107 | Logger() 108 | log.Info().Msg("Update loop is attempting to update the assets... ") 109 | 110 | defer func() { 111 | v := recover() 112 | if v != nil { 113 | log.Error(). 114 | Str("err", fmt.Sprintf("%+v", v)). 115 | Msg("Update loop has recovered from panic.") 116 | } 117 | }() 118 | 119 | s.attemptUpdateArt(ctx) 120 | s.attemptUpdateArtThumbnails(ctx) 121 | 122 | for _, server := range ark.Servers { 123 | // Skip TW server, since we haven't implemented it yet. 124 | if server == ark.TwServer { 125 | continue 126 | } 127 | s.attemptUpdateStory(ctx, server) 128 | s.attemptUpdateGalleries(ctx, server) 129 | } 130 | 131 | log.Info().Msg("Update loop has completed this attempt to update the assets..") 132 | } 133 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/flandiayingman/arkwaifu 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/Jeffail/gabs/v2 v2.7.0 7 | github.com/ahmetb/go-linq/v3 v3.2.0 8 | github.com/alitto/pond v1.9.2 9 | github.com/caarlos0/env/v6 v6.10.1 10 | github.com/cavaliergopher/grab/v3 v3.0.1 11 | github.com/chai2010/webp v1.1.1 12 | github.com/disintegration/imaging v1.6.2 13 | github.com/gobwas/glob v0.2.3 14 | github.com/goccy/go-yaml v1.17.1 15 | github.com/gofiber/fiber/v2 v2.52.6 16 | github.com/google/go-github/v52 v52.0.0 17 | github.com/google/uuid v1.6.0 18 | github.com/lib/pq v1.10.9 19 | github.com/mholt/archiver/v4 v4.0.0-alpha.8 20 | github.com/pkg/errors v0.9.1 21 | github.com/rs/zerolog v1.34.0 22 | github.com/stretchr/testify v1.10.0 23 | github.com/wk8/go-ordered-map/v2 v2.1.8 24 | go.uber.org/fx v1.23.0 25 | golang.org/x/sync v0.12.0 26 | gorm.io/driver/postgres v1.5.11 27 | gorm.io/gorm v1.25.12 28 | ) 29 | 30 | require ( 31 | github.com/ProtonMail/go-crypto v1.1.6 // indirect 32 | github.com/STARRY-S/zip v0.2.2 // indirect 33 | github.com/andybalholm/brotli v1.1.1 // indirect 34 | github.com/bahlo/generic-list-go v0.2.0 // indirect 35 | github.com/bodgit/plumbing v1.3.0 // indirect 36 | github.com/bodgit/sevenzip v1.6.0 // indirect 37 | github.com/bodgit/windows v1.0.1 // indirect 38 | github.com/buger/jsonparser v1.1.1 // indirect 39 | github.com/cloudflare/circl v1.6.0 // indirect 40 | github.com/davecgh/go-spew v1.1.1 // indirect 41 | github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect 42 | github.com/fatih/color v1.18.0 // indirect 43 | github.com/golang/protobuf v1.5.4 // indirect 44 | github.com/golang/snappy v1.0.0 // indirect 45 | github.com/google/go-querystring v1.1.0 // indirect 46 | github.com/hashicorp/errwrap v1.1.0 // indirect 47 | github.com/hashicorp/go-multierror v1.1.1 // indirect 48 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 49 | github.com/jackc/pgpassfile v1.0.0 // indirect 50 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 51 | github.com/jackc/pgx/v5 v5.7.4 // indirect 52 | github.com/jackc/puddle/v2 v2.2.2 // indirect 53 | github.com/jinzhu/inflection v1.0.0 // indirect 54 | github.com/jinzhu/now v1.1.5 // indirect 55 | github.com/klauspost/compress v1.18.0 // indirect 56 | github.com/klauspost/pgzip v1.2.6 // indirect 57 | github.com/mailru/easyjson v0.9.0 // indirect 58 | github.com/mattn/go-colorable v0.1.14 // indirect 59 | github.com/mattn/go-isatty v0.0.20 // indirect 60 | github.com/mattn/go-runewidth v0.0.16 // indirect 61 | github.com/nwaples/rardecode/v2 v2.1.1 // indirect 62 | github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect 63 | github.com/pierrec/lz4/v4 v4.1.22 // indirect 64 | github.com/pmezard/go-difflib v1.0.0 // indirect 65 | github.com/rivo/uniseg v0.4.7 // indirect 66 | github.com/rogpeppe/go-internal v1.13.1 // indirect 67 | github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 // indirect 68 | github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect 69 | github.com/sorairolake/lzip-go v0.3.7 // indirect 70 | github.com/stretchr/objx v0.5.2 // indirect 71 | github.com/therootcompany/xz v1.0.1 // indirect 72 | github.com/tinylib/msgp v1.2.5 // indirect 73 | github.com/ulikunitz/xz v0.5.12 // indirect 74 | github.com/valyala/bytebufferpool v1.0.0 // indirect 75 | github.com/valyala/fasthttp v1.60.0 // indirect 76 | github.com/valyala/tcplisten v1.0.0 // indirect 77 | go.uber.org/atomic v1.11.0 // indirect 78 | go.uber.org/dig v1.18.1 // indirect 79 | go.uber.org/multierr v1.11.0 // indirect 80 | go.uber.org/zap v1.27.0 // indirect 81 | go4.org v0.0.0-20230225012048-214862532bf5 // indirect 82 | golang.org/x/crypto v0.36.0 // indirect 83 | golang.org/x/image v0.25.0 // indirect 84 | golang.org/x/net v0.38.0 // indirect 85 | golang.org/x/oauth2 v0.28.0 // indirect 86 | golang.org/x/sys v0.31.0 // indirect 87 | golang.org/x/text v0.23.0 // indirect 88 | golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 89 | google.golang.org/appengine v1.6.8 // indirect 90 | google.golang.org/protobuf v1.36.6 // indirect 91 | gopkg.in/yaml.v3 v3.0.1 // indirect 92 | ) 93 | -------------------------------------------------------------------------------- /internal/pkg/arkassets/assets.go: -------------------------------------------------------------------------------- 1 | package arkassets 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/ahmetb/go-linq/v3" 7 | "github.com/flandiayingman/arkwaifu/internal/pkg/ark" 8 | "github.com/flandiayingman/arkwaifu/internal/pkg/cols" 9 | "github.com/flandiayingman/arkwaifu/internal/pkg/util/fileutil" 10 | "github.com/flandiayingman/arkwaifu/internal/pkg/util/pathutil" 11 | "github.com/pkg/errors" 12 | "os" 13 | "regexp" 14 | "strings" 15 | ) 16 | 17 | const ( 18 | AssetBaseUrl = "https://ak.hycdn.cn/assetbundle/official/Android/assets" 19 | ) 20 | 21 | type Info struct { 22 | Name string 23 | MD5 string 24 | Version ark.Version 25 | } 26 | 27 | func (i Info) Url() string { 28 | i.Name = strings.ReplaceAll(i.Name, `/`, `_`) 29 | i.Name = strings.ReplaceAll(i.Name, `#`, `__`) 30 | i.Name = regexp.MustCompile(`\.(.*?)$`).ReplaceAllString(i.Name, ".dat") 31 | return fmt.Sprintf("%s/%s/%s", AssetBaseUrl, i.Version, i.Name) 32 | } 33 | 34 | func GetGameAssets(ctx context.Context, version ark.Version, dst string, patterns []string) error { 35 | var err error 36 | 37 | if version == "" { 38 | version, err = GetLatestVersion() 39 | if err != nil { 40 | return errors.WithStack(err) 41 | } 42 | } 43 | 44 | infoList, err := GetInfoList(version) 45 | if err != nil { 46 | return errors.WithStack(err) 47 | } 48 | 49 | infoList, err = filter(infoList, patterns) 50 | if err != nil { 51 | return errors.WithStack(err) 52 | } 53 | 54 | err = get(ctx, infoList, dst) 55 | if err != nil { 56 | return errors.WithStack(err) 57 | } 58 | 59 | return nil 60 | } 61 | func UpdateGameAssets(ctx context.Context, oldResVer string, newResVer string, dst string, patterns []string) error { 62 | var err error 63 | 64 | if oldResVer == "" { 65 | return GetGameAssets(ctx, newResVer, dst, patterns) 66 | } 67 | if newResVer == "" { 68 | newResVer, err = GetLatestVersion() 69 | if err != nil { 70 | return errors.WithStack(err) 71 | } 72 | } 73 | 74 | oldInfoList, err := GetInfoList(oldResVer) 75 | if err != nil { 76 | return errors.WithStack(err) 77 | } 78 | newInfoList, err := GetInfoList(newResVer) 79 | if err != nil { 80 | return errors.WithStack(err) 81 | } 82 | 83 | oldInfoList, err = filter(oldInfoList, patterns) 84 | if err != nil { 85 | return errors.WithStack(err) 86 | } 87 | newInfoList, err = filter(newInfoList, patterns) 88 | if err != nil { 89 | return errors.WithStack(err) 90 | } 91 | 92 | infoList := CalculateDifferences(newInfoList, oldInfoList) 93 | err = get(ctx, infoList, dst) 94 | if err != nil { 95 | return errors.WithStack(err) 96 | } 97 | 98 | return nil 99 | } 100 | 101 | func GetLatestVersion() (string, error) { 102 | version, err := GetRawVersion() 103 | if err != nil { 104 | return "", errors.WithStack(err) 105 | } 106 | 107 | return version.ResVersion, nil 108 | } 109 | 110 | func GetInfoList(version ark.Version) ([]Info, error) { 111 | resources, err := GetRawResources(version) 112 | if err != nil { 113 | return nil, errors.WithStack(err) 114 | } 115 | 116 | infos := make([]Info, len(resources.AbInfos)) 117 | for i, info := range resources.AbInfos { 118 | if err != nil { 119 | return nil, errors.WithStack(err) 120 | } 121 | infos[i] = Info{ 122 | Name: info.Name, 123 | MD5: info.MD5, 124 | Version: version, 125 | } 126 | } 127 | return infos, nil 128 | } 129 | 130 | func filter(infoList []Info, patterns []string) ([]Info, error) { 131 | return cols.FilterErr(infoList, func(i Info) (bool, error) { 132 | return pathutil.MatchAny(patterns, i.Name) 133 | }) 134 | } 135 | 136 | func get(ctx context.Context, infoList []Info, dst string) error { 137 | fetch, err := fetch(ctx, infoList) 138 | if err != nil { 139 | return errors.WithStack(err) 140 | } 141 | defer os.RemoveAll(fetch) 142 | unzip, err := unzip(ctx, fetch) 143 | if err != nil { 144 | return errors.WithStack(err) 145 | } 146 | defer os.RemoveAll(unzip) 147 | unpack, err := unpack(ctx, unzip) 148 | if err != nil { 149 | return errors.WithStack(err) 150 | } 151 | defer os.RemoveAll(unpack) 152 | 153 | err = fileutil.MoveAllContent(unpack, dst) 154 | if err != nil { 155 | return errors.WithStack(err) 156 | } 157 | return nil 158 | } 159 | 160 | func CalculateDifferences(new []Info, old []Info) []Info { 161 | convert := func(i interface{}) interface{} { 162 | info := i.(Info) 163 | return struct { 164 | name string 165 | md5 string 166 | }{ 167 | name: info.Name, 168 | md5: info.MD5, 169 | } 170 | } 171 | newQuery := linq.From(new) 172 | oldQuery := linq.From(old) 173 | var result []Info 174 | newQuery. 175 | ExceptBy(oldQuery, convert). 176 | ToSlice(&result) 177 | return result 178 | } 179 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | logo 3 | 4 | # Arkwaifu (arkwaifu) 5 | 6 | [![](https://pkg.go.dev/badge/github.com/flandiayingman/arkwaifu.svg)](https://pkg.go.dev/github.com/flandiayingman/arkwaifu) 7 | ![](https://img.shields.io/github/license/FlandiaYingman/arkwaifu?style=flat-square) 8 | ![](https://img.shields.io/github/v/release/FlandiaYingman/arkwaifu?style=flat-square) 9 | 10 | ![](https://img.shields.io/github/actions/workflow/status/FlandiaYingman/arkwaifu/docker-image-service.yml?style=flat-square&label=build%3A%20service) 11 | ![](https://img.shields.io/github/actions/workflow/status/FlandiaYingman/arkwaifu/docker-image-updateloop.yml?style=flat-square&label=build%3A%20updateloop) 12 | ![](https://img.shields.io/website?style=flat-square&url=https%3A%2F%2Farkwaifu.cc%2F) 13 | 14 | Arkwaifu is a website which, arranges and provides almost all artworks extracted from Arknights (the game). Arkwaifu 15 | also enlarges (4x) the artworks with super-resolution neural networks - Real-ESRGAN. 16 | 17 | 🎉 Arkwaifu v1 has released! Check it at [arkwaifu.cc](https://arkwaifu.cc/)! 18 | 19 | 🇨🇳 The China mirror has been suspended due to the v0 → v1 upgrade. We will make our best efforts to get it back soon. 20 | 21 | For more information of the frontend of Arkwaifu, please refer to 22 | the [frontend repo](https://github.com/FlandiaYingman/arkwaifu-frontend). 23 | 24 | ## Features 25 | 26 | - The arts are automatically updated as soon as the game updates. 27 | - Assets are enlarged with super-resolution neural networks (Real-ESRGAN). 28 | 29 | ## Available Arts 30 | 31 | Now, only arts that appear in the "in-game stories", are available, including **images**, **backgrounds** and * 32 | *characters**. 33 | 34 | ### Images 35 | 36 | Images are the exquisite artworks that appear when some special events in the stories happen. 37 | 38 | 39 | 40 | 41 | ### Backgrounds 42 | 43 | Backgrounds are the artworks that always appear during dialogue between characters. 44 | 45 | 46 | 47 | ### Characters 48 | 49 | Characters are the artworks that represents characters that appear during dialogue. 50 | 51 | 52 | 53 | ## V1 Roadmap 54 | 55 | - [x] Switchable language. 56 | - [ ] Support searching. 57 | 58 | ## Acknowledgements 59 | 60 | Thanks to my friend [Galvin Gao](https://github.com/GalvinGao)! 61 | He helped me a lot in the front-end development and choosing frameworks. I really appreciate the "getting hands dirty" 62 | methodology very much. 63 | 64 | Thanks to my friend [Martin Wang](https://github.com/martinwang2002)! 65 | He helped me in extracting the gamedata assets, and also in some details of automatically updating the assets from the 66 | game. 67 | 68 | Thanks to my friend Lily! She drew the fascinating [Phantom logo](assets/arkwaifu_phantom.png) of this project. 69 | 70 | Thanks to [Penguin Statistics](https://penguin-stats.io/)! 71 | The prototype of this project referenced and is inspired by Penguin 72 | Statistics' [backend v3](https://github.com/penguin-statistics/backend-next). 73 | 74 | Thanks to [xinntao](https://github.com/xinntao), [nihui](https://github.com/nihui), and the other contributors 75 | of [Real-ESRGAN](https://github.com/xinntao/Real-ESRGAN) 76 | and [Real-CUGAN](https://github.com/bilibili/ailab/tree/main/Real-CUGAN)! They created the neural networks this project 77 | utilizes for enlarging assets. 78 | 79 | ## License 80 | 81 | The source code of this project is licensed under the [MIT License](LICENSE). 82 | 83 | The assets of this project are licensed under 84 | [Attribution-NonCommercial 4.0 International (CC BY-NC 4.0)](https://creativecommons.org/licenses/by-nc/4.0/). 85 | 86 | This project utilizes resources and other works from the game Arknights. The copyright of such works belongs to the 87 | provider of the game, 上海鹰角网络科技有限公司 (Shanghai Hypergryph Network Technology Co., Ltd). 88 | 89 | This project utilizes [Real-ESRGAN](https://github.com/xinntao/Real-ESRGAN) 90 | and [Real-ESRGAN-ncnn-vulkan](https://github.com/xinntao/Real-ESRGAN-ncnn-vulkan), which are respectively licensed under 91 | the BSD-3-Clause license and the MIT License. 92 | 93 | This project utilizes [Real-CUGAN](https://github.com/bilibili/ailab/tree/main/Real-CUGAN) 94 | and [Real-CUGAN-ncnn-vulkan](https://github.com/nihui/realcugan-ncnn-vulkan), which are both licensed under the MIT 95 | License. 96 | 97 | Some initial template source code of this project is inspired by and copied from 98 | the [backend v3](https://github.com/penguin-statistics/backend-next) of [Penguin Statistics](https://penguin-stats.io/), 99 | which is licensed under the [MIT License](https://github.com/penguin-statistics/backend-next/blob/dev/LICENSE). 100 | -------------------------------------------------------------------------------- /internal/app/updateloop/repo_version.go: -------------------------------------------------------------------------------- 1 | package updateloop 2 | 3 | import ( 4 | "context" 5 | "github.com/flandiayingman/arkwaifu/internal/app/infra" 6 | "github.com/flandiayingman/arkwaifu/internal/pkg/ark" 7 | "gorm.io/gorm" 8 | "gorm.io/gorm/clause" 9 | ) 10 | 11 | type artVersion struct { 12 | Lock *int `gorm:"primaryKey;check:lock=0"` 13 | Version ark.Version `gorm:""` 14 | } 15 | 16 | type storyVersion struct { 17 | Server ark.Server `gorm:"primaryKey;type:game_server"` 18 | Version ark.Version `gorm:""` 19 | } 20 | 21 | type galleryVersion struct { 22 | Server ark.Server `gorm:"primaryKey;type:game_server"` 23 | Version ark.Version `gorm:""` 24 | } 25 | 26 | type repo struct { 27 | *infra.Gorm 28 | } 29 | 30 | var zeroPtr = func() *int { 31 | zero := 0 32 | return &zero 33 | }() 34 | 35 | func newRepo(db *infra.Gorm) (*repo, error) { 36 | var err error 37 | repo := &repo{db} 38 | err = repo.initArtVersionTable() 39 | if err != nil { 40 | return nil, err 41 | } 42 | err = repo.initStoryVersionTable() 43 | if err != nil { 44 | return nil, err 45 | } 46 | err = repo.initGalleryVersionTable() 47 | if err != nil { 48 | return nil, err 49 | } 50 | return repo, nil 51 | } 52 | 53 | func (r *repo) initArtVersionTable() error { 54 | err := r.AutoMigrate(&artVersion{}) 55 | if err != nil { 56 | return err 57 | } 58 | var result *gorm.DB 59 | result = r. 60 | Clauses(clause.OnConflict{DoNothing: true}). 61 | Create(&artVersion{ 62 | Lock: zeroPtr, 63 | Version: "", 64 | }) 65 | if result.Error != nil { 66 | return result.Error 67 | } 68 | return nil 69 | } 70 | 71 | func (r *repo) initStoryVersionTable() error { 72 | err := r.AutoMigrate(&storyVersion{}) 73 | if err != nil { 74 | return err 75 | } 76 | var result *gorm.DB 77 | result = r. 78 | Clauses(clause.OnConflict{DoNothing: true}). 79 | Create(&storyVersion{Server: ark.CnServer}) 80 | if result.Error != nil { 81 | return result.Error 82 | } 83 | result = r. 84 | Clauses(clause.OnConflict{DoNothing: true}). 85 | Create(&storyVersion{Server: ark.EnServer}) 86 | if result.Error != nil { 87 | return result.Error 88 | } 89 | result = r. 90 | Clauses(clause.OnConflict{DoNothing: true}). 91 | Create(&storyVersion{Server: ark.JpServer}) 92 | if result.Error != nil { 93 | return result.Error 94 | } 95 | result = r. 96 | Clauses(clause.OnConflict{DoNothing: true}). 97 | Create(&storyVersion{Server: ark.KrServer}) 98 | if result.Error != nil { 99 | return result.Error 100 | } 101 | result = r. 102 | Clauses(clause.OnConflict{DoNothing: true}). 103 | Create(&storyVersion{Server: ark.TwServer}) 104 | if result.Error != nil { 105 | return result.Error 106 | } 107 | return nil 108 | } 109 | func (r *repo) initGalleryVersionTable() error { 110 | err := r.AutoMigrate(&galleryVersion{}) 111 | if err != nil { 112 | return err 113 | } 114 | var result *gorm.DB 115 | result = r. 116 | Clauses(clause.OnConflict{DoNothing: true}). 117 | Create(&galleryVersion{Server: ark.CnServer}) 118 | if result.Error != nil { 119 | return result.Error 120 | } 121 | result = r. 122 | Clauses(clause.OnConflict{DoNothing: true}). 123 | Create(&galleryVersion{Server: ark.EnServer}) 124 | if result.Error != nil { 125 | return result.Error 126 | } 127 | result = r. 128 | Clauses(clause.OnConflict{DoNothing: true}). 129 | Create(&galleryVersion{Server: ark.JpServer}) 130 | if result.Error != nil { 131 | return result.Error 132 | } 133 | result = r. 134 | Clauses(clause.OnConflict{DoNothing: true}). 135 | Create(&galleryVersion{Server: ark.KrServer}) 136 | if result.Error != nil { 137 | return result.Error 138 | } 139 | result = r. 140 | Clauses(clause.OnConflict{DoNothing: true}). 141 | Create(&galleryVersion{Server: ark.TwServer}) 142 | if result.Error != nil { 143 | return result.Error 144 | } 145 | return nil 146 | } 147 | 148 | func (r *repo) upsertArtVersion(ctx context.Context, v *artVersion) error { 149 | return r. 150 | WithContext(ctx). 151 | Clauses(clause.OnConflict{UpdateAll: true}). 152 | Create(&v). 153 | Error 154 | } 155 | func (r *repo) upsertStoryVersion(ctx context.Context, v *storyVersion) error { 156 | return r. 157 | WithContext(ctx). 158 | Clauses(clause.OnConflict{UpdateAll: true}). 159 | Create(&v). 160 | Error 161 | } 162 | func (r *repo) upsertGalleryVersion(ctx context.Context, v *galleryVersion) error { 163 | return r. 164 | WithContext(ctx). 165 | Clauses(clause.OnConflict{UpdateAll: true}). 166 | Create(&v). 167 | Error 168 | } 169 | func (r *repo) selectArtVersion(ctx context.Context) (ark.Version, error) { 170 | var v artVersion 171 | err := r. 172 | WithContext(ctx). 173 | Take(&v). 174 | Error 175 | return v.Version, err 176 | } 177 | func (r *repo) selectStoryVersion(ctx context.Context, server ark.Server) (ark.Version, error) { 178 | var v storyVersion 179 | err := r. 180 | WithContext(ctx). 181 | Where("server = ?", server). 182 | Take(&v). 183 | Error 184 | return v.Version, err 185 | } 186 | func (r *repo) selectGalleryVersion(ctx context.Context, server ark.Server) (ark.Version, error) { 187 | var v galleryVersion 188 | err := r. 189 | WithContext(ctx). 190 | Where("server = ?", server). 191 | Take(&v). 192 | Error 193 | return v.Version, err 194 | } 195 | -------------------------------------------------------------------------------- /internal/app/art/controller.go: -------------------------------------------------------------------------------- 1 | package art 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/gofiber/fiber/v2" 7 | "github.com/google/uuid" 8 | ) 9 | 10 | type controller struct { 11 | service *Service 12 | } 13 | 14 | func newController(s *Service) *controller { 15 | c := controller{s} 16 | return &c 17 | } 18 | func registerController(c *controller, router fiber.Router) { 19 | if router == nil { 20 | return 21 | } 22 | 23 | router.Use("arts", c.Authenticator) 24 | 25 | router.Get("arts", c.GetArts) 26 | router.Get("arts/:id", c.GetArt) 27 | router.Put("arts/:id", c.PutArt) 28 | 29 | router.Put("arts/:id/variants/:variation", c.PutVariant) 30 | 31 | router.Get("arts/:id/variants/:variation/content", c.GetContent) 32 | router.Put("arts/:id/variants/:variation/content", c.PutContent) 33 | } 34 | 35 | func (c *controller) GetArts(ctx *fiber.Ctx) error { 36 | categoryString := ctx.Query("category") 37 | variationString := ctx.Query("absent-variation") 38 | var arts []*Art 39 | var err error 40 | if variationString != "" { 41 | variation, err := ParseVariation(variationString) 42 | if err != nil { 43 | return errors.Join(fiber.ErrBadRequest, err) 44 | } 45 | arts, err = c.service.SelectArtsWhoseVariantAbsent(variation) 46 | if err != nil { 47 | return err 48 | } 49 | } else if categoryString != "" { 50 | category, err := ParseCategory(categoryString) 51 | if err != nil { 52 | return errors.Join(fiber.ErrBadRequest, err) 53 | } 54 | arts, err = c.service.SelectArtsByCategory(category) 55 | if err != nil { 56 | return err 57 | } 58 | } else { 59 | arts, err = c.service.SelectArts() 60 | if err != nil { 61 | return err 62 | } 63 | } 64 | return ctx.JSON(arts) 65 | } 66 | func (c *controller) GetArt(ctx *fiber.Ctx) error { 67 | id := ctx.Params("id") 68 | 69 | art, err := c.service.SelectArt(id) 70 | if err != nil { 71 | return err 72 | } 73 | 74 | return ctx.JSON(art) 75 | } 76 | func (c *controller) GetVariants(ctx *fiber.Ctx) error { 77 | id := ctx.Params("id") 78 | 79 | variants, err := c.service.SelectVariants(id) 80 | if err != nil { 81 | return err 82 | } 83 | 84 | return ctx.JSON(variants) 85 | } 86 | func (c *controller) GetVariant(ctx *fiber.Ctx) error { 87 | id := ctx.Params("id") 88 | variation, err := ParseVariation(ctx.Params("variation")) 89 | if err != nil { 90 | return errors.Join(fiber.ErrBadRequest, err) 91 | } 92 | 93 | variants, err := c.service.SelectVariant(id, variation) 94 | if err != nil { 95 | return err 96 | } 97 | 98 | return ctx.JSON(variants) 99 | } 100 | 101 | func (c *controller) PutArt(ctx *fiber.Ctx) error { 102 | id := ctx.Params("id") 103 | 104 | art := new(Art) 105 | err := ctx.BodyParser(&art) 106 | if err != nil { 107 | return errors.Join(fiber.ErrBadRequest, err) 108 | } 109 | art.ID = id 110 | 111 | err = c.service.UpsertArts(art) 112 | if err != nil { 113 | return err 114 | } 115 | 116 | return ctx.SendStatus(fiber.StatusOK) 117 | } 118 | func (c *controller) PutVariant(ctx *fiber.Ctx) error { 119 | id := ctx.Params("id") 120 | variation := ctx.Params("variation") 121 | 122 | variant := Variant{} 123 | err := ctx.BodyParser(&variant) 124 | if err != nil { 125 | return errors.Join(fiber.ErrBadRequest, err) 126 | } 127 | variant.ArtID = id 128 | variant.Variation, err = ParseVariation(variation) 129 | if err != nil { 130 | return errors.Join(fiber.ErrBadRequest, err) 131 | } 132 | 133 | err = c.service.UpsertVariants(&variant) 134 | if err != nil { 135 | return err 136 | } 137 | 138 | return ctx.SendStatus(fiber.StatusOK) 139 | } 140 | 141 | type ContentParams struct { 142 | ID string `param:"id"` 143 | Variation Variation `param:"variation"` 144 | } 145 | 146 | func (c *controller) GetContent(ctx *fiber.Ctx) error { 147 | params := ContentParams{} 148 | err := ctx.ParamsParser(¶ms) 149 | if err != nil { 150 | return err 151 | } 152 | 153 | content, err := c.service.TakeContent(params.ID, params.Variation) 154 | if err != nil { 155 | return err 156 | } 157 | 158 | ctx.Type("webp") 159 | return ctx.Send(content) 160 | } 161 | func (c *controller) PutContent(ctx *fiber.Ctx) error { 162 | params := ContentParams{} 163 | err := ctx.ParamsParser(¶ms) 164 | if err != nil { 165 | return err 166 | } 167 | 168 | err = c.service.StoreContent(params.ID, params.Variation, ctx.Body()) 169 | if err != nil { 170 | return err 171 | } 172 | 173 | return nil 174 | } 175 | 176 | type variantQueryFilter struct { 177 | } 178 | 179 | func (c *controller) Authenticator(ctx *fiber.Ctx) error { 180 | if c.SkipAuthentication(ctx) { 181 | return ctx.Next() 182 | } 183 | return c.Authenticate(ctx) 184 | } 185 | func (c *controller) SkipAuthentication(ctx *fiber.Ctx) bool { 186 | return ctx.Method() != fiber.MethodPut 187 | } 188 | 189 | func (c *controller) Authenticate(ctx *fiber.Ctx) error { 190 | idStr := ctx.Query("user", "") 191 | if idStr == "" { 192 | return ctx. 193 | Status(fiber.StatusUnauthorized). 194 | SendString("no user credential provided") 195 | } 196 | id, err := uuid.Parse(idStr) 197 | if err != nil { 198 | return ctx. 199 | Status(fiber.StatusBadRequest). 200 | SendString(fmt.Sprintf("cannot parse id of user: %s", idStr)) 201 | } 202 | user := c.service.Authenticate(id) 203 | if user == nil { 204 | return ctx. 205 | Status(fiber.StatusUnauthorized). 206 | SendString(fmt.Sprintf("cannot find user with id: %s", id)) 207 | } 208 | return ctx.Next() 209 | } 210 | -------------------------------------------------------------------------------- /internal/app/story/repo.go: -------------------------------------------------------------------------------- 1 | package story 2 | 3 | import ( 4 | "github.com/flandiayingman/arkwaifu/internal/app/infra" 5 | "github.com/flandiayingman/arkwaifu/internal/pkg/ark" 6 | "gorm.io/gorm" 7 | ) 8 | 9 | type repo struct { 10 | db *infra.Gorm 11 | } 12 | 13 | func (r *repo) init() (err error) { 14 | err = r.db.CreateEnum("game_server", "CN", "EN", "JP", "KR", "TW") 15 | if err != nil { 16 | return err 17 | } 18 | err = r.db.CreateEnum("story_tag", "before", "after", "interlude") 19 | if err != nil { 20 | return err 21 | } 22 | err = r.db.CreateEnum("story_group_type", "main-story", "major-event", "minor-event", "other") 23 | if err != nil { 24 | return err 25 | } 26 | err = r.db.AutoMigrate(&Group{}) 27 | if err != nil { 28 | return err 29 | } 30 | err = r.db.AutoMigrate(&Story{}) 31 | if err != nil { 32 | return err 33 | } 34 | err = r.db.AutoMigrate(&PictureArt{}) 35 | if err != nil { 36 | return err 37 | } 38 | err = r.db.AutoMigrate(&CharacterArt{}) 39 | if err != nil { 40 | return err 41 | } 42 | return nil 43 | } 44 | 45 | func newRepo(db *infra.Gorm) (*repo, error) { 46 | r := repo{db} 47 | err := r.init() 48 | if err != nil { 49 | return nil, err 50 | } 51 | return &r, nil 52 | } 53 | 54 | func (r *repo) SelectStories(server ark.Server) ([]*Story, error) { 55 | stories := make([]*Story, 0) 56 | result := r.db. 57 | Preload("PictureArts", func(db *gorm.DB) *gorm.DB { return db.Order("picture_arts.sort_id") }). 58 | Preload("CharacterArts", func(db *gorm.DB) *gorm.DB { return db.Order("character_arts.sort_id") }). 59 | Order("sort_id"). 60 | Find(&stories, "server = ?", server) 61 | return stories, result.Error 62 | } 63 | func (r *repo) SelectStory(id string, server ark.Server) (*Story, error) { 64 | story := &Story{} 65 | result := r.db. 66 | Preload("PictureArts", func(db *gorm.DB) *gorm.DB { return db.Order("picture_arts.sort_id") }). 67 | Preload("CharacterArts", func(db *gorm.DB) *gorm.DB { return db.Order("character_arts.sort_id") }). 68 | Take(&story, "(id, server) = (?, ?)", id, server) 69 | return story, result.Error 70 | } 71 | func (r *repo) SelectStoryGroups(server ark.Server) ([]*Group, error) { 72 | groups := make([]*Group, 0) 73 | result := r.db. 74 | Preload("Stories", func(db *gorm.DB) *gorm.DB { return db.Order("stories.sort_id") }). 75 | Preload("Stories.PictureArts", func(db *gorm.DB) *gorm.DB { return db.Order("picture_arts.sort_id") }). 76 | Preload("Stories.CharacterArts", func(db *gorm.DB) *gorm.DB { return db.Order("character_arts.sort_id") }). 77 | Order("sort_id"). 78 | Find(&groups, "server = ?", server) 79 | return groups, result.Error 80 | } 81 | func (r *repo) SelectStoryGroupsByType(server ark.Server, groupType GroupType) ([]*Group, error) { 82 | groups := make([]*Group, 0) 83 | result := r.db. 84 | Preload("Stories", func(db *gorm.DB) *gorm.DB { return db.Order("stories.sort_id") }). 85 | Preload("Stories.PictureArts", func(db *gorm.DB) *gorm.DB { return db.Order("picture_arts.sort_id") }). 86 | Preload("Stories.CharacterArts", func(db *gorm.DB) *gorm.DB { return db.Order("character_arts.sort_id") }). 87 | Order("sort_id"). 88 | Find(&groups, "(server, type) = (?, ?)", server, groupType) 89 | return groups, result.Error 90 | } 91 | func (r *repo) SelectStoryGroup(id string, server ark.Server) (*Group, error) { 92 | group := &Group{} 93 | result := r.db. 94 | Preload("Stories", func(db *gorm.DB) *gorm.DB { return db.Order("stories.sort_id") }). 95 | Preload("Stories.PictureArts", func(db *gorm.DB) *gorm.DB { return db.Order("picture_arts.sort_id") }). 96 | Preload("Stories.CharacterArts", func(db *gorm.DB) *gorm.DB { return db.Order("character_arts.sort_id") }). 97 | Take(&group, "(id, server) = (?, ?)", id, server) 98 | return group, result.Error 99 | } 100 | 101 | func (r *repo) SelectPictureArts(server ark.Server) ([]*PictureArt, error) { 102 | arts := make([]*PictureArt, 0) 103 | return arts, r.db. 104 | Order("sort_id"). 105 | Find(&arts, "server = ?", server).Error 106 | } 107 | func (r *repo) SelectAggregatedPictureArtByID(server ark.Server, id string) (*AggregatedPictureArt, error) { 108 | art := &AggregatedPictureArt{} 109 | err := r.db. 110 | Model(&PictureArt{}). 111 | Take(&art, "(server, id) = (?, ?)", server, id). 112 | Error 113 | if err != nil { 114 | return nil, err 115 | } 116 | return art, nil 117 | } 118 | func (r *repo) SelectCharacterArts(server ark.Server) ([]*CharacterArt, error) { 119 | arts := make([]*CharacterArt, 0) 120 | return arts, r.db. 121 | Order("sort_id"). 122 | Find(&arts, "server = ?", server).Error 123 | } 124 | func (r *repo) SelectAggregatedCharacterArtByID(server ark.Server, id string) (*AggregatedCharacterArt, error) { 125 | var err error 126 | art := &AggregatedCharacterArt{} 127 | 128 | err = r.db. 129 | Model(&CharacterArt{}). 130 | Take(&art, "(server, id) = (?, ?)", server, id). 131 | Error 132 | if err != nil { 133 | return nil, err 134 | } 135 | 136 | err = r.db. 137 | Table("(?) as dt(aggregated_names)", 138 | r.db. 139 | Model(&CharacterArt{}). 140 | Select("unnest(names)"). 141 | Where("(server, id) = (?, ?)", server, id), 142 | ). 143 | Select("array_agg(DISTINCT aggregated_names) as names"). 144 | Scan(&art). 145 | Error 146 | if err != nil { 147 | return nil, err 148 | } 149 | 150 | return art, nil 151 | } 152 | 153 | func (r *repo) UpsertStoryGroups(groups []Group) error { 154 | // UpsertStoryGroups does not actually upsert values. 155 | // Because of the existence of SortID (we want to re-generate it), values are deleted and then inserted. 156 | return r.db.Transaction(func(tx *gorm.DB) error { 157 | var err error 158 | err = tx.Delete(&groups).Error 159 | if err != nil { 160 | return err 161 | } 162 | err = tx.Create(&groups).Error 163 | if err != nil { 164 | return err 165 | } 166 | return nil 167 | }) 168 | } 169 | -------------------------------------------------------------------------------- /internal/app/story/service.go: -------------------------------------------------------------------------------- 1 | package story 2 | 3 | import ( 4 | "fmt" 5 | "github.com/flandiayingman/arkwaifu/internal/pkg/ark" 6 | arkparser2 "github.com/flandiayingman/arkwaifu/internal/pkg/arkparser" 7 | cols "github.com/flandiayingman/arkwaifu/internal/pkg/cols" 8 | "strings" 9 | ) 10 | 11 | type Service struct { 12 | r *repo 13 | } 14 | 15 | func newService(r *repo) *Service { 16 | return &Service{r: r} 17 | } 18 | 19 | type GroupFilter struct { 20 | Type string 21 | } 22 | 23 | func (s *Service) GetStories(server ark.Server) ([]*Story, error) { 24 | return s.r.SelectStories(server) 25 | } 26 | func (s *Service) GetStory(server ark.Server, id string) (*Story, error) { 27 | return s.r.SelectStory(id, server) 28 | } 29 | func (s *Service) GetStoryGroups(server ark.Server, filter GroupFilter) ([]*Group, error) { 30 | if filter.Type != "" { 31 | return s.r.SelectStoryGroupsByType(server, filter.Type) 32 | } 33 | return s.r.SelectStoryGroups(server) 34 | } 35 | func (s *Service) GetStoryGroup(server ark.Server, id string) (*Group, error) { 36 | return s.r.SelectStoryGroup(id, server) 37 | } 38 | 39 | func (s *Service) GetPictureArts(server ark.Server) ([]*PictureArt, error) { 40 | return s.r.SelectPictureArts(server) 41 | } 42 | func (s *Service) GetAggregatedPictureArt(server ark.Server, id string) (*AggregatedPictureArt, error) { 43 | return s.r.SelectAggregatedPictureArtByID(server, id) 44 | } 45 | 46 | func (s *Service) GetCharacterArts(server ark.Server) ([]*CharacterArt, error) { 47 | return s.r.SelectCharacterArts(server) 48 | } 49 | func (s *Service) GetAggregatedCharacterArt(server ark.Server, id string) (*AggregatedCharacterArt, error) { 50 | return s.r.SelectAggregatedCharacterArtByID(server, id) 51 | } 52 | 53 | func (s *Service) PopulateFrom(rawTree *arkparser2.StoryTree, server ark.Server) error { 54 | converter := objectConverter{server: server} 55 | 56 | tree, err := converter.convertStoryTree(rawTree) 57 | if err != nil { 58 | return fmt.Errorf("converter error when populating story service: %w", err) 59 | } 60 | 61 | for _, group := range tree { 62 | // Prevent error: extended protocol limited to 65535 parameters 63 | // TODO: Find a more efficient way to prevent error. 64 | err = s.r.UpsertStoryGroups([]Group{group}) 65 | if err != nil { 66 | return err 67 | } 68 | } 69 | 70 | return nil 71 | } 72 | 73 | type objectConverter struct { 74 | server ark.Server 75 | } 76 | 77 | func (c *objectConverter) convertStoryTree(tree *arkparser2.StoryTree) (Tree, error) { 78 | return cols.MapErr(tree.StoryGroups, c.convertStoryGroup) 79 | } 80 | func (c *objectConverter) convertStoryGroup(rawGroup *arkparser2.StoryGroup) (Group, error) { 81 | group := Group{ 82 | Server: c.server, 83 | ID: strings.ToLower(rawGroup.ID), 84 | Name: rawGroup.Name, 85 | Type: "", 86 | Stories: nil, 87 | SortID: nil, // SortID is auto-increment, no need to handle it. 88 | } 89 | 90 | var err error 91 | 92 | group.Type, err = c.convertGroupType(rawGroup.Type) 93 | if err != nil { 94 | return Group{}, err 95 | } 96 | 97 | group.Stories, err = cols.MapErr(rawGroup.Stories, c.convertStory) 98 | if err != nil { 99 | return Group{}, err 100 | } 101 | 102 | return group, nil 103 | } 104 | func (c *objectConverter) convertGroupType(rawType string) (GroupType, error) { 105 | switch rawType { 106 | case arkparser2.TypeMain: 107 | return GroupTypeMainStory, nil 108 | case arkparser2.TypeActivity: 109 | return GroupTypeMajorEvent, nil 110 | case arkparser2.TypeMiniActivity: 111 | return GroupTypeMinorEvent, nil 112 | case arkparser2.TypeNone: 113 | return GroupTypeOther, nil 114 | default: 115 | return "", fmt.Errorf("unknown story group type: %v", rawType) 116 | } 117 | } 118 | func (c *objectConverter) convertStory(rawStory *arkparser2.Story) (Story, error) { 119 | story := Story{ 120 | Server: c.server, 121 | ID: strings.ToLower(rawStory.ID), 122 | Tag: "", 123 | TagText: rawStory.TagText, 124 | Code: rawStory.Code, 125 | Name: rawStory.Name, 126 | Info: rawStory.Info, 127 | GroupID: rawStory.GroupID, 128 | SortID: nil, // SortID is auto-increment, no need to handle it. 129 | PictureArts: cols.Map(rawStory.Pictures, c.convertPictureArt), 130 | CharacterArts: cols.Map(rawStory.Characters, c.convertCharacterArt), 131 | } 132 | 133 | var err error 134 | 135 | story.Tag, err = c.convertStoryTagType(rawStory.TagType) 136 | if err != nil { 137 | return Story{}, err 138 | } 139 | 140 | return story, nil 141 | } 142 | func (c *objectConverter) convertStoryTagType(rawType string) (Tag, error) { 143 | switch rawType { 144 | case arkparser2.TagBefore: 145 | return TagBefore, nil 146 | case arkparser2.TagAfter: 147 | return TagAfter, nil 148 | case arkparser2.TagInterlude: 149 | return TagInterlude, nil 150 | default: 151 | return "", fmt.Errorf("unknown arkparser story group type: %v", rawType) 152 | } 153 | } 154 | func (c *objectConverter) convertPictureArt(rawPicture *arkparser2.StoryPicture) PictureArt { 155 | return PictureArt{ 156 | Server: c.server, 157 | ID: strings.ToLower(rawPicture.ID), 158 | StoryID: "", // This will be auto-generated by the ORM framework 159 | Category: rawPicture.Category, 160 | 161 | Title: rawPicture.Title, 162 | Subtitle: rawPicture.Subtitle, 163 | 164 | SortID: nil, // SortID is auto-increment, no need to handle it. 165 | } 166 | } 167 | func (c *objectConverter) convertCharacterArt(rawCharacter *arkparser2.StoryCharacter) CharacterArt { 168 | return CharacterArt{ 169 | Server: c.server, 170 | ID: strings.ToLower(rawCharacter.ID), 171 | StoryID: "", // This will be auto-generated by the ORM framework 172 | Category: "character", 173 | 174 | Names: rawCharacter.Names, 175 | 176 | SortID: nil, // SortID is auto-increment, no need to handle it. 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /internal/pkg/arkdata/data.go: -------------------------------------------------------------------------------- 1 | package arkdata 2 | 3 | import ( 4 | "context" 5 | "github.com/flandiayingman/arkwaifu/internal/pkg/ark" 6 | "github.com/flandiayingman/arkwaifu/internal/pkg/util/fileutil" 7 | "github.com/google/go-github/v52/github" 8 | "github.com/pkg/errors" 9 | "os" 10 | "regexp" 11 | ) 12 | 13 | const ( 14 | RepoOwner = "Kengxxiao" 15 | RepoName = "ArknightsGameData" 16 | CompositeRepoOwner = "FlandiaYingman" 17 | CompositeRepoName = "ArknightsGameDataComposite" 18 | DefaultPrefix = "assets/torappu/dynamicassets" 19 | ) 20 | 21 | var ( 22 | githubClient = github.NewClient(nil) 23 | 24 | // RepoCommitMessageRegex is for matching that a commit is a resource-updating commit. 25 | // 26 | // Examples: 27 | // - [CN UPDATE] Client:2.0.01 Data:23-05-11-16-35-19-8a6fe7 [BOT TEST] - Match: CN, 2.0.01, 23-05-11-16-35-19-8a6fe7 28 | // - [EN UPDATE] Client:15.9.01 Data:23-04-25-10-10-55-972129 - Match: EN, 15.9.01, 23-04-25-10-10-55-972129 29 | RepoCommitMessageRegex = regexp.MustCompile("\\[(CN|EN|JP|KR) UPDATE] Client:([.\\d]*) Data:([-\\w]*)") 30 | ) 31 | 32 | type DataInfo struct { 33 | Name string 34 | } 35 | 36 | type DataVersion struct { 37 | GameServer ark.Server 38 | ClientVersion string 39 | ResourceVersion string 40 | CommitSHA string 41 | } 42 | 43 | func GetGameData(ctx context.Context, server ark.Server, version ark.Version, patterns []string, dst string) error { 44 | return getGameData(ctx, server, version, RepoOwner, RepoName, patterns, dst) 45 | } 46 | func GetLatestDataVersion(ctx context.Context, server ark.Server) (*DataVersion, error) { 47 | return getLatestDataVersion(ctx, server, RepoOwner, RepoName) 48 | } 49 | func GetDataVersion(ctx context.Context, server ark.Server, resVer string) (*DataVersion, error) { 50 | return getDataVersion(ctx, server, resVer, RepoOwner, RepoName) 51 | } 52 | 53 | func GetCompositeGameData(ctx context.Context, server ark.Server, version ark.Version, patterns []string, dst string) error { 54 | return getGameData(ctx, server, version, CompositeRepoOwner, CompositeRepoName, patterns, dst) 55 | } 56 | func GetLatestCompositeDataVersion(ctx context.Context, server ark.Server) (*DataVersion, error) { 57 | return getLatestDataVersion(ctx, server, CompositeRepoOwner, CompositeRepoName) 58 | } 59 | func GetCompositeDataVersion(ctx context.Context, server ark.Server, resVer string) (*DataVersion, error) { 60 | return getDataVersion(ctx, server, resVer, CompositeRepoOwner, CompositeRepoName) 61 | } 62 | 63 | func getGameData(ctx context.Context, server ark.Server, version ark.Version, repoOwner string, repoName string, patterns []string, dst string) error { 64 | var sha string 65 | if version != "" { 66 | dataVersion, err := getDataVersion(ctx, server, version, repoOwner, repoName) 67 | if err != nil { 68 | return errors.WithStack(err) 69 | } 70 | sha = dataVersion.CommitSHA 71 | } else { 72 | dataVersion, err := getLatestDataVersion(ctx, server, repoOwner, repoName) 73 | if err != nil { 74 | return errors.WithStack(err) 75 | } 76 | sha = dataVersion.CommitSHA 77 | } 78 | 79 | zipballDir, zipball, err := download(ctx, repoOwner, repoName, sha) 80 | if err != nil { 81 | return errors.WithStack(err) 82 | } 83 | defer os.RemoveAll(zipballDir) 84 | defer os.RemoveAll(zipball) 85 | 86 | data, err := unzip(ctx, zipball, patterns, server) 87 | if err != nil { 88 | return errors.WithStack(err) 89 | } 90 | defer os.RemoveAll(data) 91 | 92 | err = fileutil.MoveAllContent(data, dst) 93 | if err != nil { 94 | return errors.WithStack(err) 95 | } 96 | 97 | return nil 98 | } 99 | func getLatestDataVersion(ctx context.Context, server ark.Server, repoOwner string, repoName string) (*DataVersion, error) { 100 | commit, err := findCommit(ctx, repoOwner, repoName, func(commit *github.RepositoryCommit) bool { 101 | version := parseCommit(commit) 102 | return version != nil && version.GameServer == server 103 | }) 104 | if err != nil { 105 | return nil, errors.Wrapf(err, "cannot find latest DataVersion") 106 | } 107 | return parseCommit(commit), nil 108 | } 109 | func getDataVersion(ctx context.Context, server ark.Server, resVer string, repoOwner string, repoName string) (*DataVersion, error) { 110 | commit, err := findCommit(ctx, repoOwner, repoName, func(commit *github.RepositoryCommit) bool { 111 | version := parseCommit(commit) 112 | return version != nil && version.GameServer == server && version.ResourceVersion == resVer 113 | }) 114 | if err != nil { 115 | return nil, errors.Wrapf(err, "cannot find DataVersion by %s", resVer) 116 | } 117 | if commit == nil { 118 | return nil, errors.Errorf("no DataVersion can be found by %s", resVer) 119 | } 120 | return parseCommit(commit), nil 121 | } 122 | 123 | func parseCommit(commit *github.RepositoryCommit) *DataVersion { 124 | matches := RepoCommitMessageRegex.FindStringSubmatch(commit.GetCommit().GetMessage()) 125 | if matches == nil { 126 | return nil 127 | } else { 128 | return &DataVersion{ 129 | GameServer: ark.MustParseServer(matches[1]), 130 | ClientVersion: matches[2], 131 | ResourceVersion: matches[3], 132 | CommitSHA: commit.GetSHA(), 133 | } 134 | } 135 | } 136 | func findCommit(ctx context.Context, owner, repo string, predicate func(*github.RepositoryCommit) bool) (*github.RepositoryCommit, error) { 137 | const perPage = 100 138 | var page = 1 139 | var opts = &github.CommitsListOptions{} 140 | for { 141 | opts.PerPage = perPage 142 | opts.Page = page 143 | page++ 144 | 145 | commits, _, err := githubClient.Repositories.ListCommits(ctx, owner, repo, opts) 146 | if err != nil { 147 | return nil, errors.WithStack(err) 148 | } 149 | 150 | for _, commit := range commits { 151 | if predicate(commit) { 152 | return commit, nil 153 | } 154 | } 155 | 156 | if len(commits) < perPage { 157 | break 158 | } 159 | } 160 | return nil, nil 161 | } 162 | -------------------------------------------------------------------------------- /internal/app/updateloop/update_gallery.go: -------------------------------------------------------------------------------- 1 | package updateloop 2 | 3 | import ( 4 | "context" 5 | "github.com/flandiayingman/arkwaifu/internal/app/gallery" 6 | "github.com/flandiayingman/arkwaifu/internal/pkg/ark" 7 | "github.com/flandiayingman/arkwaifu/internal/pkg/arkdata" 8 | "github.com/flandiayingman/arkwaifu/internal/pkg/arkjson" 9 | "github.com/pkg/errors" 10 | "github.com/rs/zerolog/log" 11 | "os" 12 | "strings" 13 | "time" 14 | ) 15 | 16 | var ( 17 | galleryPatterns = []string{ 18 | "gamedata/excel/story_review_meta_table.json", 19 | "gamedata/excel/replicate_table.json", 20 | "gamedata/excel/retro_table.json", 21 | "gamedata/excel/roguelike_topic_table.json", 22 | } 23 | ) 24 | 25 | func (s *Service) getRemoteGalleryVersion(ctx context.Context, server ark.Server) (ark.Version, error) { 26 | v, err := arkdata.GetLatestDataVersion(ctx, server) 27 | return v.ResourceVersion, err 28 | } 29 | 30 | func (s *Service) getLocalGalleryVersion(ctx context.Context, server ark.Server) (ark.Version, error) { 31 | return s.repo.selectGalleryVersion(ctx, server) 32 | } 33 | 34 | func (s *Service) attemptUpdateGalleries(ctx context.Context, server ark.Server) { 35 | log := log.With(). 36 | Str("server", server). 37 | Logger() 38 | log.Info().Msg("Attempting to update galleries of the server. ") 39 | localVersion, err := s.getLocalGalleryVersion(ctx, server) 40 | if err != nil { 41 | log.Err(err). 42 | Msg("Failed to get the local gallery version of the server. ") 43 | return 44 | } 45 | remoteVersion, err := s.getRemoteGalleryVersion(ctx, server) 46 | if err != nil { 47 | log.Err(err). 48 | Msg("Failed to get the remote gallery version of the server. ") 49 | return 50 | } 51 | log = log.With(). 52 | Str("localVersion", localVersion). 53 | Str("remoteVersion", remoteVersion). 54 | Logger() 55 | if localVersion != remoteVersion { 56 | log.Info().Msg("Updating the stories of the server, since the gallery versions are not identical. ") 57 | begin := time.Now() 58 | err = s.updateGalleries(ctx, server, remoteVersion) 59 | if err != nil { 60 | log.Err(err).Msg("Update loop failed to update the stories") 61 | } 62 | log.Info(). 63 | Str("elapsed", time.Since(begin).String()). 64 | Msg("Updated the stories of the server successfully. ") 65 | } else { 66 | log.Info().Msg("Skip updating the stories of the server, since the gallery versions are identical. ") 67 | } 68 | } 69 | 70 | func (s *Service) updateGalleries(ctx context.Context, server ark.Server, version ark.Version) error { 71 | root, err := os.MkdirTemp("", "arkwaifu-updateloop-gallery-*") 72 | if err != nil { 73 | return err 74 | } 75 | defer os.RemoveAll(root) 76 | 77 | err = arkdata.GetGameData(ctx, server, version, galleryPatterns, root) 78 | if err != nil { 79 | return err 80 | } 81 | 82 | galleries, err := ParseToGalleries(server, root) 83 | if err != nil { 84 | return err 85 | } 86 | 87 | err = s.galleryService.Put(galleries) 88 | if err != nil { 89 | return errors.WithStack(err) 90 | } 91 | 92 | err = s.repo.upsertGalleryVersion(ctx, &galleryVersion{ 93 | Server: server, 94 | Version: version, 95 | }) 96 | if err != nil { 97 | return err 98 | } 99 | 100 | return nil 101 | } 102 | 103 | func ParseToGalleries(server ark.Server, root string) ([]gallery.Gallery, error) { 104 | jsonStoryReviewMetaTable, err := arkjson.Get(root, arkjson.StoryReviewMetaTablePath) 105 | if err != nil { 106 | return nil, err 107 | } 108 | jsonRetroTable, err := arkjson.Get(root, arkjson.RetroTable) 109 | if err != nil { 110 | return nil, err 111 | } 112 | jsonReplicateTable, err := arkjson.Get(root, arkjson.ReplicateTable) 113 | if err != nil { 114 | return nil, err 115 | } 116 | jsonRoguelikeTopicTable, err := arkjson.Get(root, arkjson.RoguelikeTopicTable) 117 | if err != nil { 118 | return nil, err 119 | } 120 | 121 | artMap := make(map[string]gallery.Art) 122 | for _, c := range jsonStoryReviewMetaTable.S("actArchiveResData", "pics").Children() { 123 | artMap[strings.ToLower(c.S("id").Data().(string))] = gallery.Art{ 124 | Server: server, 125 | GalleryID: "", // Auto Generated 126 | SortID: 0, 127 | ID: strings.ToLower(c.S("id").Data().(string)), 128 | Name: c.S("desc").Data().(string), 129 | Description: c.S("picDescription").Data().(string), 130 | ArtID: strings.ToLower(c.S("assetPath").Data().(string)), 131 | } 132 | } 133 | 134 | galleryMap := make(map[string]gallery.Gallery) 135 | for _, c := range jsonRetroTable.S("retroActList").Children() { 136 | for _, actID := range c.S("linkedActId").Children() { 137 | galleryMap[strings.ToLower(actID.Data().(string))] = gallery.Gallery{ 138 | Server: server, 139 | ID: strings.ToLower(actID.Data().(string)), 140 | Name: c.S("name").Data().(string), 141 | Description: c.S("detail").Data().(string), 142 | Arts: nil, 143 | } 144 | } 145 | } 146 | for _, c := range jsonRoguelikeTopicTable.S("topics").Children() { 147 | galleryMap[strings.ToLower(c.S("id").Data().(string))] = gallery.Gallery{ 148 | Server: server, 149 | ID: strings.ToLower(c.S("id").Data().(string)), 150 | Name: c.S("name").Data().(string), 151 | Description: c.S("lineText").Data().(string), 152 | Arts: nil, 153 | } 154 | } 155 | 156 | galleries := make([]gallery.Gallery, 0) 157 | for id, c := range jsonStoryReviewMetaTable.Search("actArchiveData", "components").ChildrenMap() { 158 | if jsonReplicateTable.Exists(id) { 159 | continue 160 | } 161 | if gallery, ok := galleryMap[strings.ToLower(id)]; ok { 162 | for _, pic := range c.S("pic", "pics").Children() { 163 | art := artMap[strings.ToLower(pic.S("picId").Data().(string))] 164 | art.SortID = int(pic.S("picSortId").Data().(float64)) 165 | gallery.Arts = append(gallery.Arts, art) 166 | } 167 | galleries = append(galleries, gallery) 168 | } 169 | } 170 | 171 | return galleries, nil 172 | } 173 | -------------------------------------------------------------------------------- /internal/pkg/arkprocessor/processor_character.go: -------------------------------------------------------------------------------- 1 | package arkprocessor 2 | 3 | import ( 4 | "fmt" 5 | _ "github.com/chai2010/webp" 6 | "github.com/disintegration/imaging" 7 | "github.com/flandiayingman/arkwaifu/internal/pkg/arkscanner" 8 | "github.com/pkg/errors" 9 | "image" 10 | "image/color" 11 | "image/draw" 12 | _ "image/jpeg" 13 | _ "image/png" 14 | "math" 15 | "os" 16 | "path/filepath" 17 | ) 18 | 19 | type CharacterArt arkscanner.CharacterArt 20 | 21 | type CharacterArtImage struct { 22 | Image image.Image 23 | Art *CharacterArt 24 | BodyNum int 25 | FaceNum int 26 | } 27 | 28 | func (i *CharacterArtImage) ID() string { 29 | return fmt.Sprintf("%s#%d$%d", i.Art.ID, i.FaceNum, i.BodyNum) 30 | } 31 | 32 | // ProcessCharacterArt process a character art. 33 | // 34 | // Since picture arts are complicated, the process operation consists of: 35 | // - Decode all relevant images, including color channel and alpha channel of 36 | // faces and bodies image. 37 | // - For each face or body, merge the alpha channels onto the color channels. 38 | // - For each face, merge it onto its corresponding body. 39 | func (p *Processor) ProcessCharacterArt(art *CharacterArt) ([]CharacterArtImage, error) { 40 | var result []CharacterArtImage 41 | for i, body := range art.BodyVariations { 42 | bodyImage, err := art.decode(p.Root, i+1, 0) 43 | if err != nil { 44 | return nil, errors.WithStack(err) 45 | } 46 | 47 | // Sometimes, there are some character arts that have no actual images given. We'll just skip them. 48 | // Hope HyperGryph will remember what they've removed and remember to remove them from the stories. 49 | // Thanks to HyperGryph ^^ 50 | if bodyImage == nil { 51 | continue 52 | } 53 | 54 | if body.PixelToUnits != 1.0 { 55 | bodyImage = imaging.Resize( 56 | bodyImage, 57 | int(math.Round(float64(bodyImage.Bounds().Dx())/body.PixelToUnits)), 58 | int(math.Round(float64(bodyImage.Bounds().Dy())/body.PixelToUnits)), 59 | imaging.Lanczos, 60 | ) 61 | } 62 | 63 | for j, face := range body.FaceVariations { 64 | faceImage, err := art.decode(p.Root, i+1, j+1) 65 | if err != nil { 66 | return nil, errors.WithStack(err) 67 | } 68 | 69 | // Same reason above. 70 | // Thanks to HyperGryph ^^ 71 | if faceImage == nil { 72 | continue 73 | } 74 | 75 | if face.PixelToUnits != 1.0 { 76 | faceImage = imaging.Resize( 77 | faceImage, 78 | int(math.Round(float64(faceImage.Bounds().Dx())/face.PixelToUnits)), 79 | int(math.Round(float64(faceImage.Bounds().Dy())/face.PixelToUnits)), 80 | imaging.Lanczos, 81 | ) 82 | } 83 | 84 | if !face.WholeBody { 85 | faceImage, err = mergeCharacterFace(bodyImage, faceImage, body.FaceRectangle) 86 | if err != nil { 87 | return nil, errors.WithStack(err) 88 | } 89 | } 90 | result = append(result, CharacterArtImage{ 91 | Image: faceImage, 92 | Art: art, 93 | BodyNum: i + 1, 94 | FaceNum: j + 1, 95 | }) 96 | } 97 | } 98 | return result, nil 99 | } 100 | 101 | func (a *CharacterArt) decode(root string, bodyNum, faceNum int) (image.Image, error) { 102 | var filePath, filePathAlpha string 103 | if faceNum > 0 { 104 | filePath = filepath.Join(root, (*arkscanner.CharacterArt)(a).FacePath(bodyNum, faceNum)) 105 | filePathAlpha = filepath.Join(root, (*arkscanner.CharacterArt)(a).FacePathAlpha(bodyNum, faceNum)) 106 | } else { 107 | filePath = filepath.Join(root, (*arkscanner.CharacterArt)(a).BodyPath(bodyNum)) 108 | filePathAlpha = filepath.Join(root, (*arkscanner.CharacterArt)(a).BodyPathAlpha(bodyNum)) 109 | } 110 | if filePath == root { 111 | return nil, nil 112 | } 113 | 114 | var img, imgAlpha image.Image 115 | var err error 116 | img, err = decode(filePath) 117 | if err != nil { 118 | return nil, errors.WithStack(err) 119 | } 120 | if filePathAlpha != root { 121 | imgAlpha, err = decode(filePathAlpha) 122 | if err != nil { 123 | return nil, errors.WithStack(err) 124 | } 125 | } 126 | 127 | if imgAlpha != nil { 128 | img, err = mergeAlphaChannel(img, imgAlpha) 129 | if err != nil { 130 | return nil, errors.WithStack(err) 131 | } 132 | } 133 | 134 | return img, nil 135 | } 136 | 137 | func decode(filePath string) (image.Image, error) { 138 | file, err := os.Open(filePath) 139 | if err != nil { 140 | return nil, errors.WithStack(err) 141 | } 142 | defer func() { _ = file.Close() }() 143 | 144 | img, _, err := image.Decode(file) 145 | if err != nil { 146 | return nil, errors.WithStack(err) 147 | } 148 | return img, nil 149 | } 150 | func mergeAlphaChannel(base image.Image, alpha image.Image) (*image.NRGBA, error) { 151 | // TODO: DrawMask instead 152 | if base.Bounds() != alpha.Bounds() { 153 | alpha = imaging.Resize(alpha, base.Bounds().Dx(), base.Bounds().Dy(), imaging.Lanczos) 154 | } 155 | canvas := image.NewNRGBA(base.Bounds()) 156 | for i := canvas.Bounds().Min.X; i < canvas.Bounds().Max.X; i++ { 157 | for j := canvas.Bounds().Min.Y; j < canvas.Bounds().Max.Y; j++ { 158 | baseColor := color.NRGBAModel.Convert(base.At(i, j)).(color.NRGBA) 159 | alphaColor := color.GrayModel.Convert(alpha.At(i, j)).(color.Gray) 160 | baseColor.A = alphaColor.Y 161 | canvas.SetNRGBA(i, j, baseColor) 162 | } 163 | } 164 | return canvas, nil 165 | } 166 | 167 | func mergeCharacterFace(body image.Image, face image.Image, faceRect image.Rectangle) (*image.NRGBA, error) { 168 | if body == nil || face == nil { 169 | return nil, errors.Errorf("the images are nil! ") 170 | } 171 | 172 | if !faceRect.In(body.Bounds()) { 173 | return nil, errors.Errorf("merge character face: face rectangle %v is not in the body's bounds %v", faceRect, body.Bounds()) 174 | } 175 | face = imaging.Resize(face, faceRect.Dx(), faceRect.Dy(), imaging.Lanczos) 176 | 177 | canvas := image.NewNRGBA(body.Bounds()) 178 | draw.Draw(canvas, body.Bounds(), body, image.Point{}, draw.Over) 179 | draw.Draw(canvas, faceRect, face, image.Point{}, draw.Over) 180 | return canvas, nil 181 | } 182 | -------------------------------------------------------------------------------- /tools/extractor/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import functools 3 | import json 4 | import os 5 | import sys 6 | from concurrent.futures import ProcessPoolExecutor 7 | from pathlib import Path 8 | from typing import Dict, Tuple 9 | 10 | import PIL.Image 11 | import UnityPy 12 | from UnityPy.classes import Object, Texture2D, Sprite, TextAsset, MonoBehaviour, GameObject 13 | 14 | # flush every line to prevent blocking outputs 15 | # noinspection PyShadowingBuiltins 16 | print = functools.partial(print, flush=True) 17 | 18 | # initialize PIL to preload supported formats 19 | PIL.Image.preinit() 20 | PIL.Image.init() 21 | 22 | 23 | def unpack_assets(src: Path, dst: Path, workers=None): 24 | try: 25 | if src.is_dir(): 26 | print(f"searching files in {src}...") 27 | with ProcessPoolExecutor(max_workers=workers) as executor: 28 | for it in src.glob('**/*'): 29 | if it.is_file(): 30 | print(f"found {it} in {src}...") 31 | executor.submit(unpack_assets, it, dst, None) 32 | 33 | elif src.is_file(): 34 | env = UnityPy.load(str(src)) 35 | for container, obj_reader in env.container.items(): 36 | container = Path(container) 37 | obj = obj_reader.read() 38 | 39 | dst_subdir = container / '..' / obj.m_Name 40 | path_id_path = (dst / container / '..' / f"{obj.m_Name}.json").resolve() 41 | tt_path_id_path = (dst / container / '..' / f"{obj.m_Name}.typetree.json").resolve() 42 | path_id_dict, tt_path_id_dict = export(obj, dst, dst_subdir) 43 | if len(path_id_dict) > 0: 44 | with open(path_id_path, "w", encoding="utf8") as file: 45 | json.dump(path_id_dict, file, ensure_ascii=False, indent=4) 46 | with open(tt_path_id_path, "w", encoding="utf8") as file: 47 | json.dump(tt_path_id_dict, file, ensure_ascii=False, indent=4) 48 | 49 | else: 50 | print(f"WARN: {src} is not dir neither file; skipping", file=sys.stderr) 51 | except Exception as e: 52 | print(f"ERROR: {src} failed to unpack: {e}", file=sys.stderr) 53 | 54 | 55 | def export( 56 | obj: Object, dst: Path, dst_subdir: Path, 57 | path_id_dict: Dict[int, str] = None, 58 | tt_path_id_dict: Dict[int, str] = None 59 | ) -> Tuple[Dict[int, str], Dict[int, str]]: 60 | path_id_dict = {} if path_id_dict is None else path_id_dict 61 | tt_path_id_dict = {} if tt_path_id_dict is None else tt_path_id_dict 62 | 63 | container = Path(obj.object_reader.container) if obj.object_reader.container else None 64 | 65 | obj_name = getattr(obj, 'm_Name', '') 66 | obj_type = obj.object_reader.type.name 67 | 68 | path_id = obj.object_reader.path_id 69 | 70 | match obj: 71 | case Texture2D() | Sprite(): 72 | obj: Texture2D | Sprite 73 | 74 | obj_path = Path(os.path.normpath(container or dst_subdir / f"{obj_name}.png")) 75 | 76 | dest = (dst / (container or obj_path)).resolve() 77 | json_dest = (dst / (container or obj_path).with_suffix(f'.{obj_type}.json')).resolve() 78 | dest.parent.mkdir(parents=True, exist_ok=True) 79 | json_dest.parent.mkdir(parents=True, exist_ok=True) 80 | path_id_dict[path_id] = str(dest.name) 81 | tt_path_id_dict[path_id] = str(json_dest.name) 82 | 83 | if dest.exists() and obj_type in ["Sprite"]: 84 | # Sometimes there are some Sprite and Texture2D with the same name 85 | # Texture2D is preferred over Sprite, so we skip the Sprite 86 | # This is because Texture2D is usually the original image 87 | print(f"skipping {obj_path}({obj_type}), file already exists", file=sys.stderr) 88 | else: 89 | if dest.suffix in PIL.Image.EXTENSION and PIL.Image.EXTENSION[dest.suffix] in PIL.Image.SAVE: 90 | obj.image.save(dest) 91 | print(f"{obj_path}({obj_type})=>{dest}") 92 | else: 93 | print(f"cannot export {obj_path}({obj_type}), format is not supported", file=sys.stderr) 94 | 95 | with open(json_dest, "w", encoding="utf8") as file: 96 | json.dump(obj.object_reader.read_typetree(), file, 97 | ensure_ascii=False, 98 | indent=4, 99 | default=lambda o: '') 100 | print(f"{obj_path}({obj_type})=>{json_dest}") 101 | 102 | case MonoBehaviour(): 103 | obj: MonoBehaviour 104 | 105 | script = obj.m_Script.read() 106 | obj_name = script.m_Name 107 | 108 | obj_path = Path(os.path.normpath(container or dst_subdir / f"{obj_name}.json")) 109 | 110 | dest = (dst / (container or obj_path)).resolve() 111 | dest.parent.mkdir(parents=True, exist_ok=True) 112 | path_id_dict[path_id] = str(dest.name) 113 | 114 | with open(dest, "w", encoding="utf8") as file: 115 | json.dump(obj.object_reader.read_typetree(), file, 116 | ensure_ascii=False, 117 | indent=4, 118 | default=lambda o: '') 119 | print(f"{obj_path}({obj_type})=>{dest}") 120 | 121 | case GameObject(): 122 | obj: GameObject 123 | 124 | obj_readers = obj.assets_file.objects.values() 125 | dst_subdir = dst_subdir / '..' / obj_name 126 | for obj_reader in obj_readers: 127 | if obj_reader is obj.object_reader: 128 | continue 129 | export(obj_reader.read(), dst, dst_subdir, path_id_dict, tt_path_id_dict) 130 | 131 | case _: 132 | print(f"skipping {obj_name}({obj_type}), type {obj_type} not supported", file=sys.stderr) 133 | 134 | return path_id_dict, tt_path_id_dict 135 | 136 | 137 | def main(): 138 | parser = argparse.ArgumentParser() 139 | parser.add_argument( 140 | "src", nargs="+", 141 | help="Path to source file or directory." 142 | ) 143 | parser.add_argument( 144 | "dst", 145 | help="Path to destination directory." 146 | ) 147 | parser.add_argument( 148 | "-w", "--workers", nargs="?", default=None, 149 | help="Specify the concurrency workers count." 150 | ) 151 | args = parser.parse_args() 152 | 153 | for src in args.src: 154 | unpack_assets( 155 | Path(src), 156 | Path(args.dst), 157 | workers=int(args.workers) if args.workers else None 158 | ) 159 | 160 | 161 | if __name__ == '__main__': 162 | main() 163 | -------------------------------------------------------------------------------- /internal/app/updateloop/update_art.go: -------------------------------------------------------------------------------- 1 | package updateloop 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "github.com/alitto/pond" 8 | "github.com/chai2010/webp" 9 | "github.com/flandiayingman/arkwaifu/internal/app/art" 10 | "github.com/flandiayingman/arkwaifu/internal/pkg/ark" 11 | "github.com/flandiayingman/arkwaifu/internal/pkg/arkassets" 12 | "github.com/flandiayingman/arkwaifu/internal/pkg/arkprocessor" 13 | "github.com/flandiayingman/arkwaifu/internal/pkg/arkscanner" 14 | "github.com/flandiayingman/arkwaifu/internal/pkg/cols" 15 | "github.com/rs/zerolog/log" 16 | "os" 17 | "runtime" 18 | "time" 19 | ) 20 | 21 | var ( 22 | artPatterns = []string{ 23 | "avg/imgs/**", 24 | "avg/bg/**", 25 | "avg/items/**", 26 | "avg/characters/**", 27 | } 28 | ) 29 | 30 | type task func() error 31 | 32 | func (s *Service) getRemoteArtVersion(ctx context.Context) (ark.Version, error) { 33 | return arkassets.GetLatestVersion() 34 | } 35 | func (s *Service) getLocalArtVersion(ctx context.Context) (ark.Version, error) { 36 | return s.repo.selectArtVersion(ctx) 37 | } 38 | 39 | func (s *Service) attemptUpdateArt(ctx context.Context) { 40 | localArtVersion, err := s.getLocalArtVersion(ctx) 41 | if err != nil { 42 | log.Error(). 43 | Err(err). 44 | Caller(). 45 | Msg("Failed to get the local art version.") 46 | return 47 | } 48 | remoteArtVersion, err := s.getRemoteArtVersion(ctx) 49 | if err != nil { 50 | log.Error(). 51 | Err(err). 52 | Caller(). 53 | Msg("Failed to get the remote art version.") 54 | return 55 | } 56 | if localArtVersion != remoteArtVersion { 57 | err := s.updateArts(ctx, localArtVersion, remoteArtVersion) 58 | if err != nil { 59 | log.Error(). 60 | Err(err). 61 | Str("localArtVersion", localArtVersion). 62 | Str("remoteArtVersion", remoteArtVersion). 63 | Msg("Failed to update arts") 64 | } 65 | } 66 | } 67 | 68 | func (s *Service) updateArts(ctx context.Context, oldVersion, newVersion string) error { 69 | root, err := os.MkdirTemp("", "arkwaifu-updateloop-art-*") 70 | if err != nil { 71 | return err 72 | } 73 | defer func() { _ = os.RemoveAll(root) }() 74 | 75 | err = arkassets.UpdateGameAssets(ctx, oldVersion, newVersion, root, artPatterns) 76 | if err != nil { 77 | return err 78 | } 79 | 80 | pictureArtTasks, err := s.createPictureArtSubmitTasks(root) 81 | if err != nil { 82 | return err 83 | } 84 | characterArtTasks, err := s.createCharacterArtSubmitTasks(root) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | workerNum := runtime.NumCPU() 90 | taskNum := len(pictureArtTasks) + len(characterArtTasks) 91 | log.Info().Msgf("Begin submitting arts, using %v workers to run %d tasks", workerNum, taskNum) 92 | 93 | begin := time.Now() 94 | pool := pond.New(workerNum, taskNum) 95 | defer pool.Stop() 96 | group, _ := pool.GroupContext(ctx) 97 | for _, artTask := range pictureArtTasks { 98 | group.Submit(artTask) 99 | } 100 | for _, artTask := range characterArtTasks { 101 | group.Submit(artTask) 102 | } 103 | err = group.Wait() 104 | if err != nil { 105 | return err 106 | } 107 | 108 | err = s.repo.upsertArtVersion(ctx, &artVersion{ 109 | Lock: zeroPtr, 110 | Version: newVersion, 111 | }) 112 | if err != nil { 113 | return err 114 | } 115 | 116 | log.Info().Msgf("End submitting arts, elapsed %v", time.Since(begin)) 117 | return nil 118 | } 119 | 120 | func (s *Service) createPictureArtSubmitTasks(root string) (tasks []task, err error) { 121 | scanner := arkscanner.Scanner{Root: root} 122 | 123 | pictureArts, err := scanner.ScanForPictureArts() 124 | if err != nil { 125 | return nil, err 126 | } 127 | 128 | for _, art := range pictureArts { 129 | tasks = append(tasks, s.createPictureArtSubmitTask(root, art)) 130 | } 131 | 132 | return 133 | } 134 | func (s *Service) createPictureArtSubmitTask(root string, art *arkscanner.PictureArt) task { 135 | return func() error { 136 | log.Info().Msgf("Submitting the picture art %v...", *art) 137 | err := s.submitPictureArt(root, art) 138 | if err != nil { 139 | return fmt.Errorf("failed to submit picture art %v: %w", art, err) 140 | } 141 | return nil 142 | } 143 | } 144 | func (s *Service) submitPictureArt(root string, pic *arkscanner.PictureArt) error { 145 | processor := arkprocessor.Processor{Root: root} 146 | img, err := processor.ProcessPictureArt((*arkprocessor.PictureArt)(pic)) 147 | if err != nil { 148 | return err 149 | } 150 | 151 | err = s.artService.UpsertArts(art.NewArt(img.Art.ID, art.MustParseCategory(img.Art.Kind))) 152 | if err != nil { 153 | return err 154 | } 155 | 156 | err = s.artService.UpsertVariants(art.NewVariant(img.Art.ID, art.VariationOrigin)) 157 | if err != nil { 158 | return err 159 | } 160 | 161 | var buf bytes.Buffer 162 | err = webp.Encode(&buf, img.Image, &webp.Options{Lossless: true}) 163 | if err != nil { 164 | return err 165 | } 166 | err = s.artService.StoreContent(img.Art.ID, art.VariationOrigin, buf.Bytes()) 167 | if err != nil { 168 | return err 169 | } 170 | 171 | return nil 172 | } 173 | 174 | func (s *Service) createCharacterArtSubmitTasks(root string) (tasks []task, err error) { 175 | scanner := arkscanner.Scanner{Root: root} 176 | 177 | characterArts, err := scanner.ScanForCharacterArts() 178 | if err != nil { 179 | return nil, err 180 | } 181 | 182 | for _, art := range characterArts { 183 | tasks = append(tasks, s.createCharacterArtSubmitTask(root, art)) 184 | } 185 | 186 | return 187 | } 188 | func (s *Service) createCharacterArtSubmitTask(root string, art *arkscanner.CharacterArt) task { 189 | return func() error { 190 | log.Info().Msgf("Submitting the character art %v...", *art) 191 | err := s.submitCharacterArt(root, art) 192 | if err != nil { 193 | return fmt.Errorf("failed to submit picture art %v: %w", art, err) 194 | } 195 | return nil 196 | } 197 | } 198 | func (s *Service) submitCharacterArt(root string, char *arkscanner.CharacterArt) error { 199 | processor := arkprocessor.Processor{Root: root} 200 | imgs, err := processor.ProcessCharacterArt((*arkprocessor.CharacterArt)(char)) 201 | if err != nil { 202 | return err 203 | } 204 | if len(imgs) == 0 { 205 | return nil 206 | } 207 | 208 | err = s.artService.UpsertArts(cols.Map(imgs, func(img arkprocessor.CharacterArtImage) *art.Art { 209 | return art.NewArt(img.ID(), art.MustParseCategory(img.Art.Kind)) 210 | })...) 211 | if err != nil { 212 | return err 213 | } 214 | 215 | err = s.artService.UpsertVariants(cols.Map(imgs, func(img arkprocessor.CharacterArtImage) *art.Variant { 216 | return art.NewVariant(img.ID(), art.VariationOrigin) 217 | })...) 218 | if err != nil { 219 | return err 220 | } 221 | 222 | for _, img := range imgs { 223 | var buf bytes.Buffer 224 | err := webp.Encode(&buf, img.Image, &webp.Options{Lossless: true}) 225 | if err != nil { 226 | return err 227 | } 228 | err = s.artService.StoreContent(img.ID(), art.VariationOrigin, buf.Bytes()) 229 | if err != nil { 230 | return err 231 | } 232 | } 233 | return nil 234 | } 235 | -------------------------------------------------------------------------------- /internal/app/art/repository.go: -------------------------------------------------------------------------------- 1 | package art 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/flandiayingman/arkwaifu/internal/app/infra" 7 | "github.com/flandiayingman/arkwaifu/internal/pkg/util/fileutil" 8 | "gorm.io/gorm" 9 | "gorm.io/gorm/clause" 10 | "os" 11 | "path/filepath" 12 | ) 13 | 14 | type repository struct { 15 | db *infra.Gorm 16 | ContentDir string 17 | } 18 | 19 | func newRepo(conf *infra.Config, db *infra.Gorm) (*repository, error) { 20 | r := repository{ 21 | db: db, 22 | ContentDir: filepath.Join(conf.Root, "arts-content"), 23 | } 24 | err := r.init() 25 | if err != nil { 26 | return nil, err 27 | } 28 | return &r, nil 29 | } 30 | 31 | func (r *repository) init() (err error) { 32 | err = r.db.CreateCollateNumeric() 33 | if err != nil { 34 | return err 35 | } 36 | err = r.db.CreateEnum("art_category", 37 | string(CategoryImage), 38 | string(CategoryBackground), 39 | string(CategoryItem), 40 | string(CategoryCharacter), 41 | ) 42 | if err != nil { 43 | return err 44 | } 45 | err = r.db.CreateEnum("art_variation", 46 | string(VariationOrigin), 47 | string(VariationRealEsrganX4Plus), 48 | string(VariationRealEsrganX4PlusAnime), 49 | string(VariationThumbnail), 50 | ) 51 | if err != nil { 52 | return err 53 | } 54 | err = r.db.AutoMigrate(&Art{}) 55 | if err != nil { 56 | return err 57 | } 58 | err = r.db.AutoMigrate(&Variant{}) 59 | if err != nil { 60 | return err 61 | } 62 | return nil 63 | } 64 | 65 | func (r *repository) SelectArts() ([]*Art, error) { 66 | arts := make([]*Art, 0) 67 | result := r.db. 68 | Preload("Variants", func(db *gorm.DB) *gorm.DB { return db.Order("variants.variation") }). 69 | Order("category, id"). 70 | Find(&arts) 71 | if result.Error != nil { 72 | return nil, result.Error 73 | } 74 | return arts, nil 75 | } 76 | func (r *repository) SelectArtsByIDs(ids []string) ([]*Art, error) { 77 | arts := make([]*Art, 0) 78 | result := r.db. 79 | Preload("Variants", func(db *gorm.DB) *gorm.DB { return db.Order("variants.variation") }). 80 | Joins("JOIN UNNEST(?) WITH ORDINALITY t(id, ord) USING (id)", clause.Expr{SQL: "ARRAY[?]", Vars: []any{ids}, WithoutParentheses: true}). 81 | Order("t.ord"). 82 | Find(&arts, ids) 83 | if result.Error != nil { 84 | return nil, result.Error 85 | } 86 | return arts, nil 87 | } 88 | func (r *repository) SelectArtsByCategory(category string) ([]*Art, error) { 89 | arts := make([]*Art, 0) 90 | result := r.db. 91 | Preload("Variants", func(db *gorm.DB) *gorm.DB { return db.Order("variants.variation") }). 92 | Where("category = ?", category). 93 | Order("category, id"). 94 | Find(&arts) 95 | if result.Error != nil { 96 | return nil, result.Error 97 | } 98 | return arts, nil 99 | } 100 | func (r *repository) SelectArtsByIDLike(like string) ([]*Art, error) { 101 | arts := make([]*Art, 0) 102 | result := r.db. 103 | Preload("Variants", func(db *gorm.DB) *gorm.DB { return db.Order("variants.variation") }). 104 | Where("id LIKE ?", like). 105 | Order("category, id"). 106 | Find(&arts) 107 | if result.Error != nil { 108 | return nil, result.Error 109 | } 110 | return arts, nil 111 | } 112 | func (r *repository) SelectArt(id string) (*Art, error) { 113 | art := new(Art) 114 | result := r.db. 115 | Preload("Variants", func(db *gorm.DB) *gorm.DB { return db.Order("variants.variation") }). 116 | Take(&art, "id = ?", id) 117 | if errors.Is(result.Error, gorm.ErrRecordNotFound) { 118 | return nil, errors.Join(ErrNotFound, result.Error) 119 | } 120 | if result.Error != nil { 121 | return nil, result.Error 122 | } 123 | return art, nil 124 | } 125 | 126 | func (r *repository) SelectVariants(id string) ([]*Variant, error) { 127 | variants := make([]*Variant, 0) 128 | result := r.db. 129 | Where("art_id = ?", id). 130 | Order("variation"). 131 | Find(&variants) 132 | if result.Error != nil { 133 | return nil, result.Error 134 | } 135 | return variants, nil 136 | } 137 | func (r *repository) SelectVariant(artID string, typ string) (*Variant, error) { 138 | variant := new(Variant) 139 | result := r.db.Take(&variant, "art_id = ?, type = ?", artID, typ) 140 | if errors.Is(result.Error, gorm.ErrRecordNotFound) { 141 | return nil, errors.Join(ErrNotFound, result.Error) 142 | } 143 | if result.Error != nil { 144 | return nil, result.Error 145 | } 146 | return variant, nil 147 | } 148 | 149 | func (r *repository) UpsertArts(arts ...*Art) error { 150 | return r.db. 151 | Clauses(clause.OnConflict{UpdateAll: true}). 152 | Create(&arts).Error 153 | } 154 | func (r *repository) UpsertVariants(variants ...*Variant) error { 155 | err := r.db. 156 | Clauses(clause.OnConflict{UpdateAll: true}). 157 | Create(&variants).Error 158 | if err != nil { 159 | return err 160 | } 161 | return nil 162 | } 163 | 164 | func (r *repository) StoreContent(id string, variation string, content []byte) (err error) { 165 | err = r.db.Transaction(func(tx *gorm.DB) (err error) { 166 | // If the corresponding art exists, select it. 167 | art := Art{} 168 | err = r.db. 169 | Where("id = ?", id). 170 | Take(&art). 171 | Error 172 | if err != nil { 173 | return err 174 | } 175 | 176 | // If the corresponding variant exists, select it. 177 | variant := Variant{} 178 | err = r.db. 179 | Clauses(clause.Locking{Strength: "UPDATE"}). 180 | Where("(art_id, variation) = (?, ?)", id, variation). 181 | Take(&variant). 182 | Error 183 | if err != nil { 184 | return err 185 | } 186 | 187 | contentObj := Content{ 188 | ID: art.ID, 189 | Category: art.Category, 190 | Variation: variant.Variation, 191 | Content: content, 192 | } 193 | 194 | // Check whether the content object is valid. 195 | config, err := contentObj.Check() 196 | if err != nil { 197 | return err 198 | } 199 | 200 | // Update the corresponding variant. 201 | variant.ContentPresent = true 202 | variant.ContentWidth = &config.Width 203 | variant.ContentHeight = &config.Height 204 | 205 | // Write content's change on variant to database 206 | err = r.UpsertVariants(&variant) 207 | if err != nil { 208 | return err 209 | } 210 | 211 | // Write content to filesystem. 212 | path := filepath.Join(r.ContentDir, contentObj.PathRel()) 213 | err = fileutil.MkFileFromBytes(path, contentObj.Content) 214 | if err != nil { 215 | return err 216 | } 217 | 218 | return nil 219 | }) 220 | return 221 | } 222 | func (r *repository) TakeContent(id string, variation string) (content []byte, err error) { 223 | // If the corresponding art exists, select it. 224 | art := Art{} 225 | err = r.db. 226 | Where("id = ?", id). 227 | Take(&art). 228 | Error 229 | if err != nil { 230 | return nil, err 231 | } 232 | 233 | // If the corresponding variant exists, select it. 234 | variant := Variant{} 235 | err = r.db. 236 | Where("(art_id, variation) = (?, ?)", id, variation). 237 | Take(&variant). 238 | Error 239 | if err != nil { 240 | return nil, err 241 | } 242 | 243 | if variant.ContentPresent { 244 | contentObj := Content{ 245 | ID: art.ID, 246 | Category: art.Category, 247 | Variation: variant.Variation, 248 | Content: nil, 249 | } 250 | // Read content from the filesystem. 251 | path := filepath.Join(r.ContentDir, contentObj.PathRel()) 252 | return os.ReadFile(path) 253 | } else { 254 | // Error because content is not present. 255 | return nil, fmt.Errorf("content id=%s variation=%s is not present", id, variation) 256 | } 257 | } 258 | 259 | func (r *repository) SelectArtsWhereVariantAbsent(variation string) ([]*Art, error) { 260 | arts := make([]*Art, 0) 261 | err := r.db. 262 | Preload("Variants", func(db *gorm.DB) *gorm.DB { return db.Order("variants.variation") }). 263 | Order("category, id"). 264 | Not("EXISTS (SELECT 1 FROM variants WHERE (arts.id, ?) = (variants.art_id, variants.variation))", variation). 265 | Find(&arts). 266 | Error 267 | return arts, err 268 | } 269 | -------------------------------------------------------------------------------- /internal/pkg/util/fileutil/files.go: -------------------------------------------------------------------------------- 1 | package fileutil 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "golang.org/x/sync/errgroup" 8 | "io" 9 | "io/fs" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | 14 | "github.com/flandiayingman/arkwaifu/internal/pkg/util/pathutil" 15 | ) 16 | 17 | // MkParents creates all parents of a file with mode 0755 (before unmask). 18 | // If the parents of the file already exist, MkParents does nothing. 19 | func MkParents(filePath string) error { 20 | err := os.MkdirAll(filepath.Dir(filePath), 0755) 21 | if err != nil { 22 | return fmt.Errorf("failed to create parent directories of %s: %v", filePath, err) 23 | } 24 | return err 25 | } 26 | 27 | // MkFile creates a file and its parents with respectively mode 0666 and 0755 (both before unmask). 28 | // If the parents of the file already exist, MkFile does nothing. 29 | // If the file already exist, MkFile truncates it. 30 | func MkFile(filePath string) (*os.File, error) { 31 | err := MkParents(filePath) 32 | if err != nil { 33 | return nil, err 34 | } 35 | file, err := os.Create(filePath) 36 | if err != nil { 37 | return nil, fmt.Errorf("failed to create file %s: %v", filePath, err) 38 | } 39 | return file, nil 40 | } 41 | 42 | // CopyAllContent copies all files from srcDir to dstDir recursively. 43 | // Only files' name and content are guaranteed to be the same. 44 | // Only files are copied, (empty) directories are ignored. 45 | // Any existing files will be overridden. 46 | func CopyAllContent(srcDir string, dstDir string) error { 47 | return filepath.WalkDir(srcDir, func(srcPath string, entry fs.DirEntry, err error) error { 48 | if err != nil { 49 | return err 50 | } 51 | if entry.IsDir() { 52 | return nil 53 | } 54 | 55 | dstPath := pathutil.MustChangeParent(srcPath, srcDir, dstDir) 56 | err = CopyContent(srcPath, dstPath) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | return nil 62 | }) 63 | } 64 | 65 | // MoveAllContent moves all files from srcDir to dstDir recursively. 66 | // Only files' name and content are guaranteed to be the same. 67 | // Only files are moved, (empty) directories are ignored. 68 | // Any existing files will be overridden. 69 | // 70 | // Use MoveAllContent to prevent os.Rename to panic "invalid cross-device link". 71 | func MoveAllContent(srcDir string, dstDir string) error { 72 | return filepath.WalkDir(srcDir, func(srcPath string, entry fs.DirEntry, err error) error { 73 | if err != nil { 74 | return err 75 | } 76 | if entry.IsDir() { 77 | return nil 78 | } 79 | 80 | dstPath := pathutil.MustChangeParent(srcPath, srcDir, dstDir) 81 | err = MoveContent(srcPath, dstPath) 82 | if err != nil { 83 | return err 84 | } 85 | 86 | return nil 87 | }) 88 | } 89 | 90 | // CopyContent copies the file src to dst. 91 | // Only the file's name and content are guaranteed to be the same. 92 | // The parents will be created if they don't exist. 93 | // The existing file will be overridden. 94 | func CopyContent(src string, dst string) error { 95 | srcFile, err := os.Open(src) 96 | if err != nil { 97 | return fmt.Errorf("failed to open src %s: %w", src, err) 98 | } 99 | defer srcFile.Close() 100 | 101 | dstFile, err := MkFile(dst) 102 | if err != nil { 103 | return fmt.Errorf("failed to make dst %s: %w", dst, err) 104 | } 105 | defer dstFile.Close() 106 | 107 | _, err = io.Copy(dstFile, srcFile) 108 | if err != nil { 109 | return fmt.Errorf("failed to copy to dst %s from src %s: %w", dst, src, err) 110 | } 111 | 112 | return nil 113 | } 114 | 115 | // MoveContent moves the file src to dst. 116 | // Only the file's name and content are guaranteed to be the same. 117 | // The parents will be created if they don't exist. 118 | // The existing file will be overridden. 119 | func MoveContent(src string, dst string) error { 120 | err := os.Rename(src, dst) 121 | if err != nil { 122 | err = CopyContent(src, dst) 123 | if err != nil { 124 | return err 125 | } 126 | 127 | err = os.Remove(src) 128 | if err != nil { 129 | return fmt.Errorf("failed to remove src %s: %w", src, err) 130 | } 131 | } 132 | 133 | return nil 134 | } 135 | 136 | // Exists checks if the file or directory exists. 137 | // If Exists cannot determinate whether the file or directory exists (e.g., permission error), it returns an error. 138 | func Exists(path string) (bool, error) { 139 | _, err := os.Stat(path) 140 | if errors.Is(err, os.ErrNotExist) { 141 | return false, nil 142 | } 143 | if err != nil { 144 | return false, err 145 | } 146 | return true, nil 147 | } 148 | 149 | // LowercaseAll renames the directory to lowercase recursively. 150 | // If a file or directory is already lower-cased, LowercaseAll does nothing to it. 151 | func LowercaseAll(dirPath string) error { 152 | return filepath.WalkDir(dirPath, func(path string, entry fs.DirEntry, err error) error { 153 | if err != nil { 154 | return err 155 | } 156 | return Lowercase(path) 157 | }) 158 | } 159 | 160 | // Lowercase renames the file or directory to lowercase. 161 | // If the file or directory is already lower-cased, it does nothing. 162 | func Lowercase(path string) error { 163 | lowerPath := filepath.Join(filepath.Dir(path), strings.ToLower(filepath.Base(path))) 164 | if path != lowerPath { 165 | return os.Rename(path, lowerPath) 166 | } else { 167 | return nil 168 | } 169 | } 170 | 171 | // List lists all files and directories under the directory dirPath (no recursive). 172 | func List(dirPath string) ([]string, error) { 173 | dirEntries, err := os.ReadDir(dirPath) 174 | if err != nil { 175 | return nil, err 176 | } 177 | var all []string 178 | for _, dirEntry := range dirEntries { 179 | all = append(all, filepath.Join(dirPath, dirEntry.Name())) 180 | } 181 | return all, err 182 | } 183 | 184 | // ListFiles lists all files under the directory dirPath (no recursive). 185 | func ListFiles(dirPath string) ([]string, error) { 186 | dirEntries, err := os.ReadDir(dirPath) 187 | if err != nil { 188 | return nil, err 189 | } 190 | var all []string 191 | for _, dirEntry := range dirEntries { 192 | if dirEntry.IsDir() { 193 | continue 194 | } 195 | all = append(all, filepath.Join(dirPath, dirEntry.Name())) 196 | } 197 | return all, err 198 | } 199 | 200 | // ListDirs lists all directories under the directory dirPath (no recursive). 201 | func ListDirs(dirPath string) ([]string, error) { 202 | dirEntries, err := os.ReadDir(dirPath) 203 | if err != nil { 204 | return nil, err 205 | } 206 | var all []string 207 | for _, dirEntry := range dirEntries { 208 | if dirEntry.IsDir() { 209 | all = append(all, filepath.Join(dirPath, dirEntry.Name())) 210 | } 211 | } 212 | return all, err 213 | } 214 | 215 | // ListAll lists all files and directories under the directory dirPath (recursive). 216 | func ListAll(dirPath string) ([]string, error) { 217 | var all []string 218 | err := filepath.WalkDir(dirPath, func(path string, entry fs.DirEntry, err error) error { 219 | if err != nil { 220 | return err 221 | } 222 | all = append(all, path) 223 | return nil 224 | }) 225 | return all, err 226 | } 227 | 228 | // ListAllFiles lists all files under the directory dirPath (recursive). 229 | func ListAllFiles(dirPath string) ([]string, error) { 230 | var allFiles []string 231 | err := filepath.WalkDir(dirPath, func(path string, entry fs.DirEntry, err error) error { 232 | if err != nil { 233 | return err 234 | } 235 | if entry.IsDir() { 236 | return nil 237 | } 238 | allFiles = append(allFiles, path) 239 | return nil 240 | }) 241 | return allFiles, err 242 | } 243 | 244 | // ListALlDirs lists all directories under the directory dirPath (recursive). 245 | func ListALlDirs(dirPath string) ([]string, error) { 246 | var allFiles []string 247 | err := filepath.WalkDir(dirPath, func(path string, entry fs.DirEntry, err error) error { 248 | if err != nil { 249 | return err 250 | } 251 | if entry.IsDir() { 252 | allFiles = append(allFiles, path) 253 | } 254 | return nil 255 | }) 256 | return allFiles, err 257 | } 258 | 259 | // MkFileFromReader calls MkFile and then writes the content of the reader to the file. 260 | // 261 | // If the reader implements io.Closer, it would be closed when the method returns. 262 | func MkFileFromReader(filePath string, r io.Reader) error { 263 | defer func() { _ = CloseIfIsCloser(r) }() 264 | 265 | f, err := MkFile(filePath) 266 | if err != nil { 267 | return err 268 | } 269 | defer func() { _ = f.Close() }() 270 | 271 | _, err = io.Copy(f, r) 272 | return nil 273 | } 274 | 275 | // MkFileFromBytes calls MkFile and then writes the content of the bytes to the file. 276 | func MkFileFromBytes(filePath string, b []byte) error { 277 | return MkFileFromReader(filePath, bytes.NewReader(b)) 278 | } 279 | 280 | type PathWithContent struct { 281 | FilePath string 282 | Content []byte 283 | } 284 | 285 | func MkFilesFromBytes(goroutines int, s ...PathWithContent) error { 286 | eg := errgroup.Group{} 287 | eg.SetLimit(goroutines) 288 | for i := range s { 289 | pwc := s[i] 290 | work := func() error { return MkFileFromBytes(pwc.FilePath, pwc.Content) } 291 | eg.Go(work) 292 | } 293 | return eg.Wait() 294 | } 295 | 296 | func CloseIfIsCloser(r any) error { 297 | if closer, ok := r.(io.Closer); ok { 298 | return closer.Close() 299 | } 300 | return nil 301 | } 302 | -------------------------------------------------------------------------------- /internal/pkg/arkscanner/scanner_character.go: -------------------------------------------------------------------------------- 1 | package arkscanner 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/flandiayingman/arkwaifu/internal/pkg/cols" 7 | "github.com/flandiayingman/arkwaifu/internal/pkg/util/pathutil" 8 | "github.com/pkg/errors" 9 | "image" 10 | "math" 11 | "os" 12 | "path" 13 | "path/filepath" 14 | ) 15 | 16 | type ( 17 | CharacterArt struct { 18 | ID string 19 | Kind string 20 | BodyVariations []CharacterArtBodyVariation 21 | } 22 | CharacterArtBodyVariation struct { 23 | BodySprite string 24 | BodySpriteAlpha string 25 | FaceRectangle image.Rectangle 26 | FaceVariations []CharacterArtFaceVariation 27 | 28 | // PixelToUnits is the ratio of pixel to unit. 29 | // In other words, the resulting image is scaled by *dividing* this value. 30 | // This is because the original image is in units, and we convert it to pixels. 31 | PixelToUnits float64 32 | } 33 | CharacterArtFaceVariation struct { 34 | FaceSprite string 35 | FaceSpriteAlpha string 36 | WholeBody bool 37 | 38 | // PixelToUnits is the ratio of pixel to unit. 39 | // In other words, the resulting image is scaled by *dividing* this value. 40 | // This is because the original image is in units, and we convert it to pixels. 41 | PixelToUnits float64 42 | } 43 | ) 44 | 45 | func (a *CharacterArt) FacePath(bodyNum int, faceNum int) string { 46 | // note: "*num" starts with 1, not 0 47 | sprite := a.BodyVariations[bodyNum-1].FaceVariations[faceNum-1].FaceSprite 48 | if sprite == "" { 49 | return "" 50 | } 51 | return path.Join(characterPrefix, a.ID, sprite) 52 | } 53 | func (a *CharacterArt) FacePathAlpha(bodyNum int, faceNum int) string { 54 | // note: "*num" starts with 1, not 0 55 | sprite := a.BodyVariations[bodyNum-1].FaceVariations[faceNum-1].FaceSpriteAlpha 56 | if sprite == "" { 57 | return "" 58 | } 59 | return path.Join(characterPrefix, a.ID, sprite) 60 | } 61 | func (a *CharacterArt) BodyPath(bodyNum int) string { 62 | // note: "*num" starts with 1, not 0 63 | sprite := a.BodyVariations[bodyNum-1].BodySprite 64 | if sprite == "" { 65 | return "" 66 | } 67 | return path.Join(characterPrefix, a.ID, sprite) 68 | } 69 | func (a *CharacterArt) BodyPathAlpha(bodyNum int) string { 70 | // note: "*num" starts with 1, not 0 71 | sprite := a.BodyVariations[bodyNum-1].BodySpriteAlpha 72 | if sprite == "" { 73 | return "" 74 | } 75 | return path.Join(characterPrefix, a.ID, sprite) 76 | } 77 | 78 | var ( 79 | characterPrefix = "assets/torappu/dynamicassets/avg/characters" 80 | ) 81 | 82 | func (scanner *Scanner) ScanForCharacterArts() ([]*CharacterArt, error) { 83 | rootCharacterArts := filepath.Join(scanner.Root, characterPrefix) 84 | characterEntries, err := os.ReadDir(rootCharacterArts) 85 | if errors.Is(err, os.ErrNotExist) { 86 | return nil, nil 87 | } 88 | if err != nil { 89 | return nil, errors.WithStack(err) 90 | } 91 | 92 | characterEntries = cols.Filter(characterEntries, func(element os.DirEntry) bool { return element.IsDir() }) 93 | characterIDs := cols.Map(characterEntries, func(i os.DirEntry) (o string) { return pathutil.Stem(i.Name()) }) 94 | characterArts, err := cols.MapErr(characterIDs, scanner.ScanCharacter) 95 | if err != nil { 96 | return nil, errors.WithStack(err) 97 | } 98 | 99 | return characterArts, nil 100 | } 101 | func (scanner *Scanner) ScanCharacter(id string) (*CharacterArt, error) { 102 | hubGroupArt, err := scanner.scanHubGroupOfCharacter(id) 103 | if err != nil { 104 | return nil, errors.WithStack(err) 105 | } 106 | hubArt, err := scanner.scanHubOfCharacter(id) 107 | if err != nil { 108 | return nil, errors.WithStack(err) 109 | } 110 | 111 | if hubGroupArt == nil && hubArt == nil { 112 | return nil, errors.Errorf("scan character %s: neither the hub group nor the hub exist", id) 113 | } 114 | if hubGroupArt != nil && hubArt != nil { 115 | return nil, errors.Errorf("scan character %s: both the hub group and the hub exist", id) 116 | } 117 | 118 | if hubGroupArt != nil { 119 | return hubGroupArt, nil 120 | } else { 121 | return hubArt, nil 122 | } 123 | } 124 | 125 | func (scanner *Scanner) scanHubGroupOfCharacter(id string) (*CharacterArt, error) { 126 | hubPath := filepath.Join(scanner.Root, characterPrefix, id, "AVGCharacterSpriteHubGroup.json") 127 | hubJson, err := os.ReadFile(hubPath) 128 | if errors.Is(err, os.ErrNotExist) { 129 | return nil, nil 130 | } 131 | if err != nil { 132 | return nil, errors.WithStack(err) 133 | } 134 | 135 | var hubGroup CharacterSpriteHubGroup 136 | err = json.Unmarshal(hubJson, &hubGroup) 137 | if err != nil { 138 | return nil, errors.WithStack(err) 139 | } 140 | 141 | pathIDMap, err := scanner.scanPathIDMapOfCharacter(id) 142 | if err != nil { 143 | return nil, errors.WithStack(err) 144 | } 145 | 146 | ttMap, err := scanner.scanTypeTreeMapOfCharacter(id) 147 | if err != nil { 148 | return nil, errors.WithStack(err) 149 | } 150 | 151 | art := hubGroup.toArt(id, pathIDMap, ttMap) 152 | return &art, nil 153 | } 154 | func (scanner *Scanner) scanHubOfCharacter(id string) (*CharacterArt, error) { 155 | hubPath := filepath.Join(scanner.Root, characterPrefix, id, "AVGCharacterSpriteHub.json") 156 | hubJson, err := os.ReadFile(hubPath) 157 | if errors.Is(err, os.ErrNotExist) { 158 | return nil, nil 159 | } 160 | if err != nil { 161 | return nil, errors.WithStack(err) 162 | } 163 | 164 | var hub CharacterSpriteHub 165 | err = json.Unmarshal(hubJson, &hub) 166 | if err != nil { 167 | return nil, errors.WithStack(err) 168 | } 169 | 170 | pathIDMap, err := scanner.scanPathIDMapOfCharacter(id) 171 | if err != nil { 172 | return nil, errors.WithStack(err) 173 | } 174 | 175 | ttMap, err := scanner.scanTypeTreeMapOfCharacter(id) 176 | if err != nil { 177 | return nil, errors.WithStack(err) 178 | } 179 | 180 | hubGroup := CharacterSpriteHubGroup{SpriteGroups: []CharacterSpriteHub{hub}} 181 | art := hubGroup.toArt(id, pathIDMap, ttMap) 182 | return &art, nil 183 | } 184 | func (scanner *Scanner) scanPathIDMapOfCharacter(id string) (map[int64]string, error) { 185 | mapPath := filepath.Join(scanner.Root, characterPrefix, fmt.Sprintf("%s.json", id)) 186 | mapJson, err := os.ReadFile(mapPath) 187 | if err != nil { 188 | return nil, errors.WithStack(err) 189 | } 190 | 191 | pathIDMap := make(map[int64]string) 192 | err = json.Unmarshal(mapJson, &pathIDMap) 193 | if err != nil { 194 | return nil, errors.WithStack(err) 195 | } 196 | 197 | return pathIDMap, nil 198 | } 199 | func (scanner *Scanner) scanTypeTreeMapOfCharacter(id string) (map[int64]SpriteTypeTree, error) { 200 | mapPath := filepath.Join(scanner.Root, characterPrefix, fmt.Sprintf("%s.typetree.json", id)) 201 | mapJson, err := os.ReadFile(mapPath) 202 | if err != nil { 203 | return nil, errors.WithStack(err) 204 | } 205 | 206 | ttPathIDMap := make(map[int64]string) 207 | err = json.Unmarshal(mapJson, &ttPathIDMap) 208 | if err != nil { 209 | return nil, errors.WithStack(err) 210 | } 211 | 212 | ttMap := make(map[int64]SpriteTypeTree) 213 | for k, v := range ttPathIDMap { 214 | subMapPath := filepath.Join(scanner.Root, characterPrefix, id, v) 215 | subMapJson, err := os.ReadFile(subMapPath) 216 | if err != nil { 217 | return nil, errors.WithStack(err) 218 | } 219 | 220 | var tt SpriteTypeTree 221 | err = json.Unmarshal(subMapJson, &tt) 222 | if err != nil { 223 | return nil, errors.WithStack(err) 224 | } 225 | ttMap[k] = tt 226 | } 227 | 228 | return ttMap, nil 229 | } 230 | 231 | type ( 232 | CharacterSpriteHubGroup struct { 233 | SpriteGroups []CharacterSpriteHub `json:"spriteGroups"` 234 | } 235 | CharacterSpriteHub struct { 236 | Sprites []CharacterSprite `json:"sprites"` 237 | FacePos struct { 238 | X float64 `json:"x"` 239 | Y float64 `json:"y"` 240 | } `json:"FacePos"` 241 | FaceSize struct { 242 | X float64 `json:"x"` 243 | Y float64 `json:"y"` 244 | } `json:"FaceSize"` 245 | } 246 | CharacterSprite struct { 247 | Sprite struct { 248 | MPathID int64 `json:"m_PathID"` 249 | } `json:"sprite"` 250 | AlphaTex struct { 251 | MPathID int64 `json:"m_PathID"` 252 | } `json:"alphaTex"` 253 | IsWholeBody int `json:"isWholeBody"` 254 | } 255 | SpriteTypeTree struct { 256 | PixelsToUnits float64 `json:"m_PixelsToUnits"` 257 | } 258 | ) 259 | 260 | func (c *CharacterSpriteHubGroup) toArt(id string, pathIDMap map[int64]string, ttMap map[int64]SpriteTypeTree) (a CharacterArt) { 261 | convertSpriteHubToArt := func(i CharacterSpriteHub) (o CharacterArtBodyVariation) { return i.toArt(pathIDMap, ttMap) } 262 | a = CharacterArt{ 263 | ID: id, 264 | Kind: "character", 265 | BodyVariations: cols.Map(c.SpriteGroups, convertSpriteHubToArt), 266 | } 267 | return 268 | } 269 | func (c *CharacterSpriteHub) toArt(pathIDMap map[int64]string, ttMap map[int64]SpriteTypeTree) (a CharacterArtBodyVariation) { 270 | convertSpriteToArt := func(i CharacterSprite) (o CharacterArtFaceVariation) { return i.toArt(pathIDMap, ttMap) } 271 | a = CharacterArtBodyVariation{ 272 | BodySprite: "", 273 | BodySpriteAlpha: "", 274 | FaceRectangle: image.Rect( 275 | int(math.Round(c.FacePos.X)), 276 | int(math.Round(c.FacePos.Y)), 277 | int(math.Round(c.FacePos.X+c.FaceSize.X)), 278 | int(math.Round(c.FacePos.Y+c.FaceSize.Y)), 279 | ), 280 | FaceVariations: cols.Map(c.Sprites, convertSpriteToArt), 281 | PixelToUnits: 1.0, 282 | } 283 | // If the face pos is valid, then extract the last face variation as the body. 284 | // Otherwise, all variations contain the whole body. 285 | if c.FacePos.X >= 0 && c.FacePos.Y >= 0 { 286 | lastVariation := a.FaceVariations[len(a.FaceVariations)-1] 287 | a.BodySprite = lastVariation.FaceSprite 288 | a.BodySpriteAlpha = lastVariation.FaceSpriteAlpha 289 | a.PixelToUnits = lastVariation.PixelToUnits 290 | a.FaceVariations = a.FaceVariations[:len(a.FaceVariations)-1] 291 | } else { 292 | for i := range a.FaceVariations { 293 | a.FaceVariations[i].WholeBody = true 294 | } 295 | } 296 | return 297 | } 298 | func (c *CharacterSprite) toArt(pathIDMap map[int64]string, ttMap map[int64]SpriteTypeTree) (a CharacterArtFaceVariation) { 299 | a = CharacterArtFaceVariation{ 300 | FaceSprite: pathIDMap[c.Sprite.MPathID], 301 | FaceSpriteAlpha: pathIDMap[c.AlphaTex.MPathID], 302 | WholeBody: c.IsWholeBody != 0, 303 | PixelToUnits: ttMap[c.Sprite.MPathID].PixelsToUnits / 100.0, 304 | } 305 | return 306 | } 307 | --------------------------------------------------------------------------------