mirror of https://github.com/gabehf/Koito.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
263 lines
8.3 KiB
263 lines
8.3 KiB
package engine
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path"
|
|
"strings"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/gabehf/koito/engine/middleware"
|
|
"github.com/gabehf/koito/internal/catalog"
|
|
"github.com/gabehf/koito/internal/cfg"
|
|
"github.com/gabehf/koito/internal/db"
|
|
"github.com/gabehf/koito/internal/db/psql"
|
|
"github.com/gabehf/koito/internal/images"
|
|
"github.com/gabehf/koito/internal/importer"
|
|
"github.com/gabehf/koito/internal/logger"
|
|
mbz "github.com/gabehf/koito/internal/mbz"
|
|
"github.com/gabehf/koito/internal/models"
|
|
"github.com/gabehf/koito/internal/utils"
|
|
"github.com/go-chi/chi/v5"
|
|
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
|
"github.com/rs/zerolog"
|
|
)
|
|
|
|
func Run(
|
|
getenv func(string) string,
|
|
w io.Writer,
|
|
version string,
|
|
) error {
|
|
err := cfg.Load(getenv, version)
|
|
if err != nil {
|
|
panic("Engine: Failed to load configuration")
|
|
}
|
|
|
|
l := logger.Get()
|
|
|
|
l.Debug().Msg("Engine: Starting application initialization")
|
|
|
|
if cfg.StructuredLogging() {
|
|
l.Debug().Msg("Engine: Enabling structured logging")
|
|
*l = l.Output(w)
|
|
} else {
|
|
l.Debug().Msg("Engine: Enabling console logging")
|
|
*l = l.Output(zerolog.ConsoleWriter{
|
|
Out: w,
|
|
TimeFormat: time.RFC3339,
|
|
FormatMessage: func(i interface{}) string {
|
|
return fmt.Sprintf("\u001b[30;1m>\u001b[0m %s |", i)
|
|
},
|
|
})
|
|
}
|
|
|
|
ctx := logger.NewContext(l)
|
|
|
|
l.Info().Msgf("Koito %s", version)
|
|
|
|
l.Debug().Msgf("Engine: Checking config directory: %s", cfg.ConfigDir())
|
|
_, err = os.Stat(cfg.ConfigDir())
|
|
if err != nil {
|
|
l.Info().Msgf("Engine: Creating config directory: %s", cfg.ConfigDir())
|
|
err = os.MkdirAll(cfg.ConfigDir(), 0744)
|
|
if err != nil {
|
|
l.Fatal().Err(err).Msg("Engine: Failed to create config directory")
|
|
return err
|
|
}
|
|
}
|
|
l.Info().Msgf("Engine: Using config directory: %s", cfg.ConfigDir())
|
|
|
|
l.Debug().Msgf("Engine: Checking import directory: %s", path.Join(cfg.ConfigDir(), "import"))
|
|
_, err = os.Stat(path.Join(cfg.ConfigDir(), "import"))
|
|
if err != nil {
|
|
l.Info().Msgf("Engine: Creating import directory: %s", path.Join(cfg.ConfigDir(), "import"))
|
|
err = os.Mkdir(path.Join(cfg.ConfigDir(), "import"), 0744)
|
|
if err != nil {
|
|
l.Fatal().Err(err).Msg("Engine: Failed to create import directory")
|
|
return err
|
|
}
|
|
}
|
|
|
|
l.Debug().Msg("Engine: Initializing database connection")
|
|
var store *psql.Psql
|
|
store, err = psql.New()
|
|
for err != nil {
|
|
l.Error().Err(err).Msg("Engine: Failed to connect to database; retrying in 5 seconds")
|
|
time.Sleep(5 * time.Second)
|
|
store, err = psql.New()
|
|
}
|
|
defer store.Close(ctx)
|
|
l.Info().Msg("Engine: Database connection established")
|
|
|
|
l.Debug().Msg("Engine: Initializing MusicBrainz client")
|
|
var mbzC mbz.MusicBrainzCaller
|
|
if !cfg.MusicBrainzDisabled() {
|
|
mbzC = mbz.NewMusicBrainzClient()
|
|
l.Info().Msg("Engine: MusicBrainz client initialized")
|
|
} else {
|
|
mbzC = &mbz.MbzErrorCaller{}
|
|
l.Warn().Msg("Engine: MusicBrainz client disabled")
|
|
}
|
|
|
|
l.Debug().Msg("Engine: Initializing image sources")
|
|
images.Initialize(images.ImageSourceOpts{
|
|
UserAgent: cfg.UserAgent(),
|
|
EnableCAA: !cfg.CoverArtArchiveDisabled(),
|
|
EnableDeezer: !cfg.DeezerDisabled(),
|
|
})
|
|
l.Info().Msg("Engine: Image sources initialized")
|
|
|
|
l.Debug().Msg("Engine: Checking for default user")
|
|
userCount, _ := store.CountUsers(ctx)
|
|
if userCount < 1 {
|
|
l.Info().Msg("Engine: Creating default user")
|
|
user, err := store.SaveUser(ctx, db.SaveUserOpts{
|
|
Username: cfg.DefaultUsername(),
|
|
Password: cfg.DefaultPassword(),
|
|
Role: models.UserRoleAdmin,
|
|
})
|
|
if err != nil {
|
|
l.Fatal().Err(err).Msg("Engine: Failed to save default user in database")
|
|
}
|
|
apikey, err := utils.GenerateRandomString(48)
|
|
if err != nil {
|
|
l.Fatal().Err(err).Msg("Engine: Failed to generate default API key")
|
|
}
|
|
label := "Default"
|
|
_, err = store.SaveApiKey(ctx, db.SaveApiKeyOpts{
|
|
Key: apikey,
|
|
UserID: user.ID,
|
|
Label: label,
|
|
})
|
|
if err != nil {
|
|
l.Fatal().Err(err).Msg("Engine: Failed to save default API key in database")
|
|
}
|
|
l.Info().Msgf("Engine: Default user created. Login: %s : %s", cfg.DefaultUsername(), cfg.DefaultPassword())
|
|
}
|
|
|
|
l.Debug().Msg("Engine: Checking allowed hosts configuration")
|
|
if cfg.AllowAllHosts() {
|
|
l.Warn().Msg("Engine: Configuration allows requests from all hosts. This is a potential security risk!")
|
|
} else if len(cfg.AllowedHosts()) == 0 || cfg.AllowedHosts()[0] == "" {
|
|
l.Warn().Msgf("Engine: No hosts allowed! Did you forget to set the %s variable?", cfg.ALLOWED_HOSTS_ENV)
|
|
} else {
|
|
l.Info().Msgf("Engine: Allowing hosts: %v", cfg.AllowedHosts())
|
|
}
|
|
|
|
if len(cfg.AllowedOrigins()) == 0 || cfg.AllowedOrigins()[0] == "" {
|
|
l.Info().Msgf("Engine: Using default CORS policy")
|
|
} else {
|
|
l.Info().Msgf("Engine: CORS policy: Allowing origins: %v", cfg.AllowedOrigins())
|
|
}
|
|
|
|
if cfg.LbzRelayEnabled() && (cfg.LbzRelayUrl() == "" || cfg.LbzRelayToken() == "") {
|
|
l.Warn().Msg("You have enabled ListenBrainz relay, but either the URL or token is missing. Double check your configuration to make sure it is correct!")
|
|
}
|
|
|
|
l.Debug().Msg("Engine: Setting up HTTP server")
|
|
var ready atomic.Bool
|
|
mux := chi.NewRouter()
|
|
mux.Use(middleware.WithRequestID)
|
|
mux.Use(middleware.Logger(l))
|
|
mux.Use(chimiddleware.Recoverer)
|
|
mux.Use(chimiddleware.RealIP)
|
|
mux.Use(middleware.AllowedHosts)
|
|
bindRoutes(mux, &ready, store, mbzC)
|
|
|
|
httpServer := &http.Server{
|
|
Addr: cfg.ListenAddr(),
|
|
Handler: mux,
|
|
}
|
|
|
|
go func() {
|
|
ready.Store(true)
|
|
l.Info().Msgf("Engine: Listening on %s", cfg.ListenAddr())
|
|
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
l.Fatal().Err(err).Msg("Engine: Error when running ListenAndServe")
|
|
}
|
|
}()
|
|
|
|
l.Debug().Msg("Engine: Checking import configuration")
|
|
if !cfg.SkipImport() {
|
|
go func() {
|
|
RunImporter(l, store, mbzC)
|
|
}()
|
|
}
|
|
|
|
l.Info().Msg("Engine: Pruning orphaned images")
|
|
go catalog.PruneOrphanedImages(logger.NewContext(l), store)
|
|
|
|
l.Info().Msg("Engine: Initialization finished")
|
|
quit := make(chan os.Signal, 1)
|
|
signal.Notify(quit, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
|
|
<-quit
|
|
l.Info().Msg("Engine: Received server shutdown notice")
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
l.Info().Msg("Engine: Waiting for all processes to finish")
|
|
mbzC.Shutdown()
|
|
if err := httpServer.Shutdown(ctx); err != nil {
|
|
l.Fatal().Err(err).Msg("Engine: Error during server shutdown")
|
|
return err
|
|
}
|
|
l.Info().Msg("Engine: Shutdown successful")
|
|
return nil
|
|
}
|
|
|
|
func RunImporter(l *zerolog.Logger, store db.DB, mbzc mbz.MusicBrainzCaller) {
|
|
l.Debug().Msg("Checking for import files...")
|
|
files, err := os.ReadDir(path.Join(cfg.ConfigDir(), "import"))
|
|
if err != nil {
|
|
l.Err(err).Msg("Failed to read files from import dir")
|
|
}
|
|
if len(files) > 0 {
|
|
l.Info().Msg("Files found in import directory. Attempting to import...")
|
|
} else {
|
|
return
|
|
}
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
l.Error().Interface("recover", r).Msg("Panic when importing files")
|
|
}
|
|
}()
|
|
for _, file := range files {
|
|
if file.IsDir() {
|
|
continue
|
|
}
|
|
if strings.Contains(file.Name(), "Streaming_History_Audio") {
|
|
l.Info().Msgf("Import file %s detecting as being Spotify export", file.Name())
|
|
err := importer.ImportSpotifyFile(logger.NewContext(l), store, file.Name())
|
|
if err != nil {
|
|
l.Err(err).Msgf("Failed to import file: %s", file.Name())
|
|
}
|
|
} else if strings.Contains(file.Name(), "maloja") {
|
|
l.Info().Msgf("Import file %s detecting as being Maloja export", file.Name())
|
|
err := importer.ImportMalojaFile(logger.NewContext(l), store, file.Name())
|
|
if err != nil {
|
|
l.Err(err).Msgf("Failed to import file: %s", file.Name())
|
|
}
|
|
} else if strings.Contains(file.Name(), "recenttracks") {
|
|
l.Info().Msgf("Import file %s detecting as being ghan.nl LastFM export", file.Name())
|
|
err := importer.ImportLastFMFile(logger.NewContext(l), store, mbzc, file.Name())
|
|
if err != nil {
|
|
l.Err(err).Msgf("Failed to import file: %s", file.Name())
|
|
}
|
|
} else if strings.Contains(file.Name(), "listenbrainz") {
|
|
l.Info().Msgf("Import file %s detecting as being ListenBrainz export", file.Name())
|
|
err := importer.ImportListenBrainzExport(logger.NewContext(l), store, mbzc, file.Name())
|
|
if err != nil {
|
|
l.Err(err).Msgf("Failed to import file: %s", file.Name())
|
|
}
|
|
} else {
|
|
l.Warn().Msgf("File %s not recognized as a valid import file; make sure it is valid and named correctly", file.Name())
|
|
}
|
|
}
|
|
}
|