mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-07 21:48:18 -08:00
* add dev branch container to workflow * correctly set the default range of ActivityGrid * fix: set name/short_name to koito (#61) * fix dev container push workflow * fix: race condition with using getComputedStyle primary color for dynamic activity grid darkening (#76) * Fix race condition with using getComputedStyle primary color for dynamic activity grid darkening Instead just use the color from the current theme directly. Tested works on initial load and theme changes. Fixes https://github.com/gabehf/Koito/issues/75 * Rework theme provider to provide the actual Theme object throughtout the app, in addition to the name Split name out of the Theme struct to simplify custom theme saving/reading * fix: set first artist listed as primary by default (#81) * feat: add server-side configuration with default theme (#90) * docs: add example for usage of the main listenbrainz instance (#71) * docs: add example for usage of the main listenbrainz instance * Update scrobbler.md --------- Co-authored-by: Gabe Farrell <90876006+gabehf@users.noreply.github.com> * feat: add server-side cfg and default theme * fix: repair custom theme --------- Co-authored-by: m0d3rnX <jesper@posteo.de> * docs: add default theme cfg option to docs * feat: add ability to manually scrobble track (#91) * feat: add button to manually scrobble from ui * fix: ensure timestamp is in the past, log fix * test: add integration test * feat: add first listened to dates for media items (#92) * fix: ensure error checks for ErrNoRows * feat: add now playing endpoint and ui (#93) * wip * feat: add now playing * fix: set default theme when config is not set * feat: fetch images from subsonic server (#94) * fix: useQuery instead of useEffect for now playing * feat: custom artist separator regex (#95) * Fix race condition with using getComputedStyle primary color for dynamic activity grid darkening Instead just use the color from the current theme directly. Tested works on initial load and theme changes. Fixes https://github.com/gabehf/Koito/issues/75 * Rework theme provider to provide the actual Theme object throughtout the app, in addition to the name Split name out of the Theme struct to simplify custom theme saving/reading * feat: add server-side configuration with default theme (#90) * docs: add example for usage of the main listenbrainz instance (#71) * docs: add example for usage of the main listenbrainz instance * Update scrobbler.md --------- Co-authored-by: Gabe Farrell <90876006+gabehf@users.noreply.github.com> * feat: add server-side cfg and default theme * fix: repair custom theme --------- Co-authored-by: m0d3rnX <jesper@posteo.de> * fix: rebase errors --------- Co-authored-by: pet <128837728+againstpetra@users.noreply.github.com> Co-authored-by: mlandry <mike.landry@gmail.com> Co-authored-by: m0d3rnX <jesper@posteo.de>
277 lines
8.8 KiB
Go
277 lines
8.8 KiB
Go
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(),
|
|
EnableSubsonic: cfg.SubsonicEnabled(),
|
|
})
|
|
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("Creating test export file")
|
|
// go func() {
|
|
// err := export.ExportData(ctx, "koito", store)
|
|
// if err != nil {
|
|
// l.Err(err).Msg("Failed to generate export file")
|
|
// }
|
|
// }()
|
|
|
|
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 if strings.Contains(file.Name(), "koito") {
|
|
l.Info().Msgf("Import file %s detecting as being Koito export", file.Name())
|
|
err := importer.ImportKoitoFile(logger.NewContext(l), store, 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())
|
|
}
|
|
}
|
|
}
|