From 6f54d98ea2bd7aa6cd56b706e6e83e38968cf48a Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Sun, 16 Mar 2025 17:04:24 -0400 Subject: [PATCH] first --- .gitignore | 2 + Makefile | 13 ++++ README.md | 1 + app/engine.go | 67 +++++++++++++++++++ app/middleware/logger.go | 49 ++++++++++++++ app/routes.go | 19 ++++++ app/routes/library.go | 26 +++++++ cmd/api/main.go | 24 +++++++ .../20250316175245_create_initial_tables.sql | 64 ++++++++++++++++++ dev.md | 2 + go.mod | 14 ++++ go.sum | 17 +++++ internal/cfg/cfg.go | 28 ++++++++ internal/db/db.go | 14 ++++ internal/db/sqlite/library.go | 19 ++++++ internal/db/sqlite/mediainfo.go | 19 ++++++ internal/db/sqlite/sqlite.go | 26 +++++++ internal/logger/logger.go | 33 +++++++++ internal/models/library.go | 16 +++++ internal/models/mediainfo.go | 7 ++ internal/models/models.go | 2 + 21 files changed, 462 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 app/engine.go create mode 100644 app/middleware/logger.go create mode 100644 app/routes.go create mode 100644 app/routes/library.go create mode 100644 cmd/api/main.go create mode 100644 db/migrations/20250316175245_create_initial_tables.sql create mode 100644 dev.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/cfg/cfg.go create mode 100644 internal/db/db.go create mode 100644 internal/db/sqlite/library.go create mode 100644 internal/db/sqlite/mediainfo.go create mode 100644 internal/db/sqlite/sqlite.go create mode 100644 internal/logger/logger.go create mode 100644 internal/models/library.go create mode 100644 internal/models/mediainfo.go create mode 100644 internal/models/models.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c3e21cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +kanpeki.db +media \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..578527a --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +run: + go run cmd/api/main.go + +test: + go test ./... + +db.up: + goose -dir db/migrations sqlite3 kanpeki.db up + +db.rm: + rm kanpeki.db + +db.reset: db.rm db.up \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..584ca52 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# kanpeki \ No newline at end of file diff --git a/app/engine.go b/app/engine.go new file mode 100644 index 0000000..a69d868 --- /dev/null +++ b/app/engine.go @@ -0,0 +1,67 @@ +package app + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gabehf/kanpeki/app/middleware" + "github.com/gabehf/kanpeki/internal/cfg" + "github.com/gabehf/kanpeki/internal/db" + "github.com/gabehf/kanpeki/internal/logger" + "github.com/go-chi/chi/v5" + chimiddleware "github.com/go-chi/chi/v5/middleware" + "github.com/rs/zerolog" +) + +func NewServer(logger *zerolog.Logger, cfg *cfg.Config, db db.DB) http.Handler { + mux := chi.NewRouter() + // bind general middleware to mux + mux.Use(middleware.Logger(logger)) + mux.Use(chimiddleware.RealIP) + mux.Use(chimiddleware.Recoverer) + // call router binds on mux + bindRoutes(mux, logger, cfg, db) + return mux +} + +func Run( + ctx context.Context, + getenv func(string) string, + stdout io.Writer, + db db.DB, +) error { + cfg, err := cfg.Load(getenv) + if err != nil { + return fmt.Errorf("failed to load configuration: %v", err) + } + logger := logger.Get(cfg).Output(stdout) + srv := NewServer(&logger, cfg, db) + httpServer := &http.Server{ + Addr: cfg.ListenAddr, + Handler: srv, + } + logger.Info().Msg("listening on " + cfg.ListenAddr) + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return err + } + // Wait for interrupt signal to gracefully shutdown the server with a timeout of 10 seconds. + // Use a buffered channel to avoid missing signals as recommended for signal.Notify + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + <-quit + logger.Info().Msg("recieved server shutdown notice") + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + logger.Info().Msg("waiting for all processes to finish...") + if err := httpServer.Shutdown(ctx); err != nil { + return err + } + logger.Info().Msg("shutdown successful") + return nil +} diff --git a/app/middleware/logger.go b/app/middleware/logger.go new file mode 100644 index 0000000..691d70b --- /dev/null +++ b/app/middleware/logger.go @@ -0,0 +1,49 @@ +package middleware + +import ( + "net/http" + "runtime/debug" + "time" + + "github.com/go-chi/chi/v5/middleware" + "github.com/rs/zerolog" +) + +func Logger(logger *zerolog.Logger) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + log := logger.With().Logger() + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + t1 := time.Now() + defer func() { + t2 := time.Now() + if rec := recover(); rec != nil { + log.Error(). + Str("type", "error"). + Timestamp(). + Interface("recover_info", rec). + Bytes("debug_stack", debug.Stack()). + Msg("log system error") + http.Error(ww, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + log.Info(). + Str("type", "access"). + Timestamp(). + Fields(map[string]interface{}{ + "remote_ip": r.RemoteAddr, + "url": r.URL.Path, + "proto": r.Proto, + "method": r.Method, + "user_agent": r.Header.Get("User-Agent"), + "status": ww.Status(), + "latency_ms": float64(t2.Sub(t1).Nanoseconds()) / 1000000.0, + "bytes_in": r.Header.Get("Content-Length"), + "bytes_out": ww.BytesWritten(), + }). + Msg("incoming_request") + }() + next.ServeHTTP(ww, r) + } + return http.HandlerFunc(fn) + } +} diff --git a/app/routes.go b/app/routes.go new file mode 100644 index 0000000..92a9f40 --- /dev/null +++ b/app/routes.go @@ -0,0 +1,19 @@ +package app + +import ( + "github.com/gabehf/kanpeki/app/routes" + "github.com/gabehf/kanpeki/internal/cfg" + "github.com/gabehf/kanpeki/internal/db" + "github.com/go-chi/chi/v5" + "github.com/rs/zerolog" +) + +func bindRoutes(r *chi.Mux, logger *zerolog.Logger, cfg *cfg.Config, db db.DB) { + r.Route("/api/v1", func(r chi.Router) { + r.Route("/library", func(r chi.Router) { + r.Get("/", routes.GetLibraryInfo(logger, db)) + r.Post("/", routes.CreateLibrary(logger, db)) + r.Delete("/", routes.DeleteLibrary(logger, db)) + }) + }) +} diff --git a/app/routes/library.go b/app/routes/library.go new file mode 100644 index 0000000..6b168c9 --- /dev/null +++ b/app/routes/library.go @@ -0,0 +1,26 @@ +package routes + +import ( + "net/http" + + "github.com/gabehf/kanpeki/internal/db" + "github.com/rs/zerolog" +) + +func CreateLibrary(logger *zerolog.Logger, db db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + + } +} + +func GetLibraryInfo(logger *zerolog.Logger, db db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + + } +} + +func DeleteLibrary(logger *zerolog.Logger, db db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + + } +} diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..f59e4aa --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "context" + "log" + "os" + + "github.com/gabehf/kanpeki/app" + "github.com/gabehf/kanpeki/internal/cfg" + "github.com/gabehf/kanpeki/internal/db/sqlite" +) + +func main() { + ctx := context.Background() + db, err := sqlite.New(&cfg.Config{}) + if err != nil { + log.Fatalf("%s\n", err) + } + defer db.Close() + if err := app.Run(ctx, os.Getenv, os.Stdout, db); err != nil { + log.Fatalf("%s\n", err) + os.Exit(1) + } +} diff --git a/db/migrations/20250316175245_create_initial_tables.sql b/db/migrations/20250316175245_create_initial_tables.sql new file mode 100644 index 0000000..31c618c --- /dev/null +++ b/db/migrations/20250316175245_create_initial_tables.sql @@ -0,0 +1,64 @@ +-- +goose Up +CREATE TABLE libraries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + path TEXT NOT NULL, + type TEXT NOT NULL, -- ('tv' or 'movies') + num_files INTEGER, + size INTEGER +); + +CREATE TABLE tv_shows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + library_id INTEGER NOT NULL, + name TEXT NOT NULL, + num_files INTEGER, + size INTEGER, + FOREIGN KEY (library_id) REFERENCES libraries(id) +); + +CREATE TABLE seasons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tv_show_id INTEGER NOT NULL, + season_number INTEGER NOT NULL, + num_files INTEGER, + size INTEGER, + FOREIGN KEY (tv_show_id) REFERENCES tv_shows(id) +); + +CREATE TABLE movies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + library_id INTEGER NOT NULL, + title TEXT NOT NULL, + last_scanned INTEGER, + FOREIGN KEY (library_id) REFERENCES libraries(id) +); + +CREATE TABLE files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + library_id INTEGER NOT NULL, + tv_show_id INTEGER, -- Nullable for movies + season_id INTEGER, -- Nullable for movies + movie_id INTEGER, -- Nullable for TV episodes + filename TEXT NOT NULL, + last_scanned INTEGER, + FOREIGN KEY (library_id) REFERENCES libraries(id), + FOREIGN KEY (tv_show_id) REFERENCES tv_shows(id), + FOREIGN KEY (season_id) REFERENCES seasons(id), + FOREIGN KEY (movie_id) REFERENCES movies(id) +); + +CREATE TABLE ffprobe_metadata ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL, + data JSON NOT NULL, + FOREIGN KEY (file_id) REFERENCES files(id) +); + +-- +goose Down +DROP TABLE ffprobe_metadata; +DROP TABLE files; +DROP TABLE libraries; +DROP TABLE tv_shows; +DROP TABLE movies; +DROP TABLE seasons; diff --git a/dev.md b/dev.md new file mode 100644 index 0000000..db76f76 --- /dev/null +++ b/dev.md @@ -0,0 +1,2 @@ +# Install pressly/goose for DB migrations +```go install github.com/pressly/goose/v3/cmd/goose@latest``` \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..731aa64 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/gabehf/kanpeki + +go 1.23.7 + +require ( + github.com/go-chi/chi/v5 v5.2.1 + github.com/rs/zerolog v1.33.0 +) + +require ( + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + golang.org/x/sys v0.12.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7d3666d --- /dev/null +++ b/go.sum @@ -0,0 +1,17 @@ +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/cfg/cfg.go b/internal/cfg/cfg.go new file mode 100644 index 0000000..39993c9 --- /dev/null +++ b/internal/cfg/cfg.go @@ -0,0 +1,28 @@ +package cfg + +const ( + BASE_URL_ENV = "KANPEKI_BASE_URL" + DATABASE_URL_ENV = "KANPEKI_DATABASE_URL" + LISTEN_ADDR_ENV = "KANPEKI_LISTEN_ADDR" +) + +type Config struct { + ListenAddr string + BaseUrl string + DatabaseUrl string + LogLevel int +} + +func Load(getenv func(string) string) (*Config, error) { + cfg := new(Config) + cfg.BaseUrl = getenv(BASE_URL_ENV) + if cfg.BaseUrl == "" { + cfg.BaseUrl = "http://localhost" + } + cfg.DatabaseUrl = getenv(DATABASE_URL_ENV) + cfg.ListenAddr = getenv(LISTEN_ADDR_ENV) + if cfg.ListenAddr == "" { + cfg.ListenAddr = ":3000" + } + return cfg, nil +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..4bebc76 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,14 @@ +// package db defines the database interface +package db + +import "github.com/gabehf/kanpeki/internal/models" + +type DB interface { + Close() + SaveLibrary(*models.LibraryInfo) error + GetLibraryInfo(id string) (*models.LibraryInfo, error) + DeleteLibrary(id string) error + SaveMediaInfo(*models.MediaInfo) error + GetMediaInfo(path string) (*models.MediaInfo, error) + DeleteMediaInfo(path string) error +} diff --git a/internal/db/sqlite/library.go b/internal/db/sqlite/library.go new file mode 100644 index 0000000..fb49bd0 --- /dev/null +++ b/internal/db/sqlite/library.go @@ -0,0 +1,19 @@ +package sqlite + +import ( + "errors" + + "github.com/gabehf/kanpeki/internal/models" +) + +func (s *SqliteDB) SaveLibrary(library *models.LibraryInfo) error { + return errors.New("not implemented") +} + +func (s *SqliteDB) GetLibraryInfo(id string) (*models.LibraryInfo, error) { + return nil, errors.New("not implemented") +} + +func (s *SqliteDB) DeleteLibrary(id string) error { + return errors.New("not implemented") +} diff --git a/internal/db/sqlite/mediainfo.go b/internal/db/sqlite/mediainfo.go new file mode 100644 index 0000000..4bab9c4 --- /dev/null +++ b/internal/db/sqlite/mediainfo.go @@ -0,0 +1,19 @@ +package sqlite + +import ( + "errors" + + "github.com/gabehf/kanpeki/internal/models" +) + +func (s *SqliteDB) SaveMediaInfo(MediaInfo *models.MediaInfo) error { + return errors.New("not implemented") +} + +func (s *SqliteDB) GetMediaInfo(id string) (*models.MediaInfo, error) { + return nil, errors.New("not implemented") +} + +func (s *SqliteDB) DeleteMediaInfo(id string) error { + return errors.New("not implemented") +} diff --git a/internal/db/sqlite/sqlite.go b/internal/db/sqlite/sqlite.go new file mode 100644 index 0000000..6219810 --- /dev/null +++ b/internal/db/sqlite/sqlite.go @@ -0,0 +1,26 @@ +// package sqlite implements the db.DB interface using sqlite3 +package sqlite + +import ( + "database/sql" + + "github.com/gabehf/kanpeki/internal/cfg" +) + +type SqliteDB struct { + db *sql.DB +} + +func New(cfg *cfg.Config) (*SqliteDB, error) { + db, err := sql.Open("sqlite3", "kanpeki.db") + if err != nil { + return &SqliteDB{}, err + } + sqldb := new(SqliteDB) + sqldb.db = db + return sqldb, nil +} + +func (s *SqliteDB) Close() { + s.db.Close() +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..96b3ae7 --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,33 @@ +package logger + +import ( + "os" + "sync" + + "github.com/gabehf/kanpeki/internal/cfg" + "github.com/rs/zerolog" +) + +var once sync.Once + +var log zerolog.Logger + +func Get(cfg *cfg.Config) zerolog.Logger { + once.Do(func() { + zerolog.TimeFieldFormat = zerolog.TimeFormatUnixMs + + logLevel := cfg.LogLevel + + log = zerolog.New(os.Stdout). + Level(zerolog.Level(logLevel)). + With(). + Timestamp(). + Logger() + + if os.Getenv("LOG_LEVEL") == "" { + log.Info().Msg("environment variable LOG_LEVEL unset, using default value 0 (debug)") + } + }) + + return log +} diff --git a/internal/models/library.go b/internal/models/library.go new file mode 100644 index 0000000..9dd15c0 --- /dev/null +++ b/internal/models/library.go @@ -0,0 +1,16 @@ +package models + +type LibraryType int + +const ( + LibraryTypeMovies LibraryType = iota + LibraryTypeTV +) + +type LibraryInfo struct { + Id string + Path string + Type LibraryType + Size int64 + NumFiles int +} diff --git a/internal/models/mediainfo.go b/internal/models/mediainfo.go new file mode 100644 index 0000000..77df348 --- /dev/null +++ b/internal/models/mediainfo.go @@ -0,0 +1,7 @@ +package models + +type MediaInfo struct { + Filename string + Path string + FfprobeData []byte +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..3ef9ed9 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,2 @@ +// package models defines the schema of objects in the database +package models