mirror of
https://github.com/aykhans/movier.git
synced 2025-07-05 02:32:38 +00:00
Rewritten in go and python
This commit is contained in:
1
server/.dockerignore
Normal file
1
server/.dockerignore
Normal file
@ -0,0 +1 @@
|
||||
data
|
1
server/.gitignore
vendored
Normal file
1
server/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/data/
|
16
server/Dockerfile
Normal file
16
server/Dockerfile
Normal file
@ -0,0 +1,16 @@
|
||||
FROM golang:1.23.2-alpine AS builder
|
||||
|
||||
WORKDIR /server
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
|
||||
RUN go build -ldflags "-s -w" -o movier
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
WORKDIR /server
|
||||
|
||||
COPY --from=builder /server/movier /server/movier
|
||||
COPY --from=builder /server/pkg/templates /server/pkg/templates
|
82
server/cmd/download.go
Normal file
82
server/cmd/download.go
Normal file
@ -0,0 +1,82 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/aykhans/movier/server/pkg/config"
|
||||
"github.com/aykhans/movier/server/pkg/dto"
|
||||
"github.com/aykhans/movier/server/pkg/utils"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func getDownloadCmd() *cobra.Command {
|
||||
downloadCmd := &cobra.Command{
|
||||
Use: "download",
|
||||
Short: "Movie Data Downloader",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
err := runDownload()
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return downloadCmd
|
||||
}
|
||||
|
||||
func runDownload() error {
|
||||
downloadPath := config.GetDownloadPath()
|
||||
extractPath := config.GetExtractPath()
|
||||
err := utils.MakeDirIfNotExist(downloadPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = utils.MakeDirIfNotExist(extractPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
download(downloadPath, extractPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func download(
|
||||
downloadPath string,
|
||||
extractPath string,
|
||||
) error {
|
||||
for _, downloadConfig := range config.DownloadConfigs {
|
||||
extracted, err := utils.IsDirExist(extractPath + "/" + downloadConfig.ExtractName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if extracted {
|
||||
log.Printf("File %s already extracted. Skipping...\n\n", downloadConfig.ExtractName)
|
||||
continue
|
||||
}
|
||||
|
||||
downloaded, err := utils.IsDirExist(downloadPath + "/" + downloadConfig.DownloadName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if downloaded {
|
||||
log.Printf("File %s already downloaded. Extracting...\n\n", downloadConfig.DownloadName)
|
||||
if err := dto.ExtractGzFile(
|
||||
downloadPath+"/"+downloadConfig.DownloadName,
|
||||
extractPath+"/"+downloadConfig.ExtractName,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("Downloading and extracting %s file...\n\n", downloadConfig.DownloadName)
|
||||
if err := dto.DownloadAndExtractGz(
|
||||
downloadConfig.URL,
|
||||
downloadPath+"/"+downloadConfig.DownloadName,
|
||||
extractPath+"/"+downloadConfig.ExtractName,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
102
server/cmd/filter.go
Normal file
102
server/cmd/filter.go
Normal file
@ -0,0 +1,102 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/aykhans/movier/server/pkg/config"
|
||||
"github.com/aykhans/movier/server/pkg/dto"
|
||||
|
||||
"github.com/aykhans/movier/server/pkg/storage/postgresql"
|
||||
"github.com/aykhans/movier/server/pkg/storage/postgresql/repository"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func getFilterCmd() *cobra.Command {
|
||||
filterCmd := &cobra.Command{
|
||||
Use: "filter",
|
||||
Short: "Movie Data Filter",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
err := runFilter()
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return filterCmd
|
||||
}
|
||||
|
||||
func runFilter() error {
|
||||
generalStartTime := time.Now()
|
||||
extractedPath := config.GetExtractPath()
|
||||
|
||||
log.Printf("Filtering basics data...\n\n")
|
||||
startTime := time.Now()
|
||||
basics, err := dto.FilterBasics(extractedPath + "/title.basics.tsv")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("Basics data filtered. Found %d records (%s)\n\n", len(basics), time.Since(startTime))
|
||||
|
||||
log.Printf("Inserting basics data...\n\n")
|
||||
postgresURL, err := config.NewPostgresURL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := postgresql.NewDB(postgresURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
imdbRepo := repository.NewIMDbRepository(db)
|
||||
startTime = time.Now()
|
||||
err = imdbRepo.InsertMultipleBasics(basics)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("Basics data inserted. (%s)\n\n", time.Since(startTime))
|
||||
|
||||
log.Printf("Filtering principals data...\n\n")
|
||||
tconsts, err := imdbRepo.GetAllTconsts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(tconsts) == 0 {
|
||||
return fmt.Errorf("no tconsts found")
|
||||
}
|
||||
startTime = time.Now()
|
||||
principals, err := dto.FilterPrincipals(extractedPath+"/title.principals.tsv", tconsts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("Principals data filtered. (%s)\n\n", time.Since(startTime))
|
||||
|
||||
log.Printf("Inserting principals data...\n\n")
|
||||
startTime = time.Now()
|
||||
err = imdbRepo.UpdateMultiplePrincipals(principals)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("Principals data inserted. (%s)\n\n", time.Since(startTime))
|
||||
|
||||
log.Printf("Filtering ratings data...\n\n")
|
||||
startTime = time.Now()
|
||||
ratings, err := dto.FilterRatings(extractedPath+"/title.ratings.tsv", tconsts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("Ratings data filtered. (%s)\n\n", time.Since(startTime))
|
||||
|
||||
log.Printf("Inserting ratings data...\n\n")
|
||||
startTime = time.Now()
|
||||
err = imdbRepo.UpdateMultipleRatings(ratings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("Ratings data inserted. (%s)\n\n", time.Since(startTime))
|
||||
|
||||
log.Printf("Filtering done! (%s)\n", time.Since(generalStartTime))
|
||||
return nil
|
||||
}
|
20
server/cmd/root.go
Normal file
20
server/cmd/root.go
Normal file
@ -0,0 +1,20 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "movier",
|
||||
Short: "Movie Recommendation System",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
func Execute() error {
|
||||
rootCmd.AddCommand(getDownloadCmd())
|
||||
rootCmd.AddCommand(getFilterCmd())
|
||||
rootCmd.AddCommand(getServeCmd())
|
||||
return rootCmd.Execute()
|
||||
}
|
75
server/cmd/serve.go
Normal file
75
server/cmd/serve.go
Normal file
@ -0,0 +1,75 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/aykhans/movier/server/pkg/config"
|
||||
"github.com/aykhans/movier/server/pkg/handlers"
|
||||
"github.com/aykhans/movier/server/pkg/storage/postgresql"
|
||||
"github.com/aykhans/movier/server/pkg/storage/postgresql/repository"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
func getServeCmd() *cobra.Command {
|
||||
serveCmd := &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Movie Recommendation Serve",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
err := runServe()
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
fmt.Println("Movie Recommendation Serve")
|
||||
},
|
||||
}
|
||||
return serveCmd
|
||||
}
|
||||
|
||||
func runServe() error {
|
||||
dbURL, err := config.NewPostgresURL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
db, err := postgresql.NewDB(dbURL)
|
||||
defer db.Close(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
imdbRepo := repository.NewIMDbRepository(db)
|
||||
|
||||
grpcRecommenderServiceTarget, err := config.NewRecommenderServiceGrpcTarget()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn, err := grpc.NewClient(
|
||||
grpcRecommenderServiceTarget,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("did not connect to grpc recommender service: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
router := http.NewServeMux()
|
||||
imdbHandler := handlers.NewIMDbHandler(*imdbRepo, conn, config.GetBaseURL())
|
||||
|
||||
router.HandleFunc("GET /ping", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("pong"))
|
||||
})
|
||||
router.HandleFunc("GET /", imdbHandler.HandlerHome)
|
||||
router.HandleFunc("GET /recs", imdbHandler.HandlerGetRecommendations)
|
||||
|
||||
log.Printf("serving on port %d", config.ServePort)
|
||||
err = http.ListenAndServe(fmt.Sprintf(":%d", config.ServePort), handlers.CORSMiddleware(router))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
22
server/go.mod
Normal file
22
server/go.mod
Normal file
@ -0,0 +1,22 @@
|
||||
module github.com/aykhans/movier/server
|
||||
|
||||
go 1.23.2
|
||||
|
||||
require (
|
||||
github.com/jackc/pgx/v5 v5.7.1
|
||||
github.com/spf13/cobra v1.8.1
|
||||
google.golang.org/grpc v1.67.1
|
||||
google.golang.org/protobuf v1.35.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
golang.org/x/crypto v0.27.0 // indirect
|
||||
golang.org/x/net v0.28.0 // indirect
|
||||
golang.org/x/sys v0.25.0 // indirect
|
||||
golang.org/x/text v0.18.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect
|
||||
)
|
48
server/go.sum
Normal file
48
server/go.sum
Normal file
@ -0,0 +1,48 @@
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
|
||||
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
||||
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
||||
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
|
||||
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
|
||||
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
|
||||
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
|
||||
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
|
||||
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
23
server/main.go
Normal file
23
server/main.go
Normal file
@ -0,0 +1,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/aykhans/movier/server/cmd"
|
||||
"github.com/aykhans/movier/server/pkg/config"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
baseDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
config.BaseDir = baseDir
|
||||
|
||||
err = cmd.Execute()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
105
server/pkg/config/config.go
Normal file
105
server/pkg/config/config.go
Normal file
@ -0,0 +1,105 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/aykhans/movier/server/pkg/utils"
|
||||
)
|
||||
|
||||
type DownloadConfig struct {
|
||||
URL string
|
||||
DownloadName string
|
||||
ExtractName string
|
||||
}
|
||||
|
||||
var DownloadConfigs = []DownloadConfig{
|
||||
{
|
||||
URL: "https://datasets.imdbws.com/title.basics.tsv.gz",
|
||||
DownloadName: "title.basics.tsv.gz",
|
||||
ExtractName: "title.basics.tsv",
|
||||
},
|
||||
{
|
||||
URL: "https://datasets.imdbws.com/title.principals.tsv.gz",
|
||||
DownloadName: "title.principals.tsv.gz",
|
||||
ExtractName: "title.principals.tsv",
|
||||
},
|
||||
{
|
||||
URL: "https://datasets.imdbws.com/title.ratings.tsv.gz",
|
||||
DownloadName: "title.ratings.tsv.gz",
|
||||
ExtractName: "title.ratings.tsv",
|
||||
},
|
||||
}
|
||||
|
||||
var BaseDir = "/"
|
||||
|
||||
func GetTemplatePath() string {
|
||||
return BaseDir + "/pkg/templates"
|
||||
}
|
||||
|
||||
func GetDownloadPath() string {
|
||||
return BaseDir + "/data/raw"
|
||||
}
|
||||
|
||||
func GetExtractPath() string {
|
||||
return BaseDir + "/data/extracted"
|
||||
}
|
||||
|
||||
const (
|
||||
ServePort = 8080
|
||||
)
|
||||
|
||||
var TitleTypes = []string{"movie", "tvMovie"}
|
||||
var NconstCategories = []string{"actress", "actor", "director", "writer"}
|
||||
|
||||
func NewPostgresURL() (string, error) {
|
||||
username := utils.GetEnv("POSTGRES_USER", "")
|
||||
if username == "" {
|
||||
return "", fmt.Errorf("POSTGRES_USER env variable is not set")
|
||||
}
|
||||
password := utils.GetEnv("POSTGRES_PASSWORD", "")
|
||||
if password == "" {
|
||||
return "", fmt.Errorf("POSTGRES_PASSWORD env variable is not set")
|
||||
}
|
||||
host := utils.GetEnv("POSTGRES_HOST", "")
|
||||
if host == "" {
|
||||
return "", fmt.Errorf("POSTGRES_HOST env variable is not set")
|
||||
}
|
||||
port := utils.GetEnv("POSTGRES_PORT", "")
|
||||
if port == "" {
|
||||
return "", fmt.Errorf("POSTGRES_PORT env variable is not set")
|
||||
}
|
||||
_, err := strconv.Atoi(port)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("POSTGRES_PORT env variable is not a number")
|
||||
}
|
||||
db := utils.GetEnv("POSTGRES_DB", "")
|
||||
if db == "" {
|
||||
return "", fmt.Errorf("POSTGRES_DB env variable is not set")
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"postgres://%s:%s@%s:%s/%s?sslmode=disable",
|
||||
username, password, host, port, db,
|
||||
), nil
|
||||
}
|
||||
|
||||
func NewRecommenderServiceGrpcTarget() (string, error) {
|
||||
host := utils.GetEnv("RECOMMENDER_SERVICE_GRPC_HOST", "")
|
||||
if host == "" {
|
||||
return "", fmt.Errorf("RECOMMENDER_SERVICE_GRPC_HOST env variable is not set")
|
||||
}
|
||||
port := utils.GetEnv("RECOMMENDER_SERVICE_GRPC_PORT", "")
|
||||
if port == "" {
|
||||
return "", fmt.Errorf("RECOMMENDER_SERVICE_GRPC_PORT env variable is not set")
|
||||
}
|
||||
_, err := strconv.Atoi(port)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("RECOMMENDER_SERVICE_GRPC_PORT env variable is not a number")
|
||||
}
|
||||
return fmt.Sprintf("%s:%s", host, port), nil
|
||||
}
|
||||
|
||||
func GetBaseURL() string {
|
||||
return utils.GetEnv("BASE_URL", "http://localhost:8080")
|
||||
}
|
31
server/pkg/dto/download.go
Normal file
31
server/pkg/dto/download.go
Normal file
@ -0,0 +1,31 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func DownloadAndExtractGz(url, downloadFilepath, extractFilepath string) error {
|
||||
if err := Download(url, downloadFilepath); err != nil {
|
||||
return err
|
||||
}
|
||||
return ExtractGzFile(downloadFilepath, extractFilepath)
|
||||
}
|
||||
|
||||
func Download(url, filepath string) error {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
out, err := os.Create(filepath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
return err
|
||||
}
|
30
server/pkg/dto/extract.go
Normal file
30
server/pkg/dto/extract.go
Normal file
@ -0,0 +1,30 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
func ExtractGzFile(gzFile, extractedFilepath string) error {
|
||||
file, err := os.Open(gzFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
gzReader, err := gzip.NewReader(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer gzReader.Close()
|
||||
|
||||
outFile, err := os.Create(extractedFilepath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
_, err = io.Copy(outFile, gzReader)
|
||||
return err
|
||||
}
|
259
server/pkg/dto/filter.go
Normal file
259
server/pkg/dto/filter.go
Normal file
@ -0,0 +1,259 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/aykhans/movier/server/pkg/config"
|
||||
)
|
||||
|
||||
func FilterBasics(filePath string) ([]Basic, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not open file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
columnCount := 9
|
||||
var headers []string
|
||||
if scanner.Scan() {
|
||||
headers = strings.Split(scanner.Text(), "\t")
|
||||
if len(headers) != columnCount {
|
||||
return nil, fmt.Errorf("expected %d column headers, found %d", columnCount, len(headers))
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("could not read column headers: %v", scanner.Err())
|
||||
}
|
||||
|
||||
var (
|
||||
tconstIndex int = -1
|
||||
titleTypeIndex int = -1
|
||||
startYearIndex int = -1
|
||||
genresIndex int = -1
|
||||
)
|
||||
for i, header := range headers {
|
||||
switch header {
|
||||
case "tconst":
|
||||
tconstIndex = i
|
||||
case "titleType":
|
||||
titleTypeIndex = i
|
||||
case "startYear":
|
||||
startYearIndex = i
|
||||
case "genres":
|
||||
genresIndex = i
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case tconstIndex == -1:
|
||||
return nil, fmt.Errorf("column %s not found", "`tconst`")
|
||||
case titleTypeIndex == -1:
|
||||
return nil, fmt.Errorf("column %s not found", "`titleType`")
|
||||
case startYearIndex == -1:
|
||||
return nil, fmt.Errorf("column %s not found", "`startYear`")
|
||||
case genresIndex == -1:
|
||||
return nil, fmt.Errorf("column %s not found", "`genres`")
|
||||
}
|
||||
|
||||
var basics []Basic
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
columns := strings.Split(line, "\t")
|
||||
if len(columns) != columnCount {
|
||||
fmt.Println("Columns are:", columns)
|
||||
return nil, fmt.Errorf("expected %d columns, found %d", columnCount, len(columns))
|
||||
}
|
||||
|
||||
if slices.Contains(config.TitleTypes, columns[titleTypeIndex]) {
|
||||
var startYearUint16 uint16
|
||||
startYear, err := strconv.Atoi(columns[startYearIndex])
|
||||
if err != nil {
|
||||
startYearUint16 = 0
|
||||
} else {
|
||||
startYearUint16 = uint16(startYear)
|
||||
}
|
||||
|
||||
var genres string
|
||||
if columns[genresIndex] == "\\N" {
|
||||
genres = ""
|
||||
} else {
|
||||
genres = strings.ReplaceAll(strings.ToLower(columns[genresIndex]), " ", "")
|
||||
}
|
||||
|
||||
basics = append(basics, Basic{
|
||||
Tconst: columns[tconstIndex],
|
||||
StartYear: startYearUint16,
|
||||
Genres: genres,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return basics, nil
|
||||
}
|
||||
|
||||
func FilterPrincipals(filePath string, tconsts []string) ([]Principal, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not open file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
columnCount := 6
|
||||
var headers []string
|
||||
if scanner.Scan() {
|
||||
headers = strings.Split(scanner.Text(), "\t")
|
||||
if len(headers) != columnCount {
|
||||
return nil, fmt.Errorf("expected %d column headers, found %d", columnCount, len(headers))
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("could not read column headers: %v", scanner.Err())
|
||||
}
|
||||
|
||||
var (
|
||||
tconstIndex int = -1
|
||||
nconstIndex int = -1
|
||||
categoryIndex int = -1
|
||||
)
|
||||
for i, header := range headers {
|
||||
switch header {
|
||||
case "tconst":
|
||||
tconstIndex = i
|
||||
case "nconst":
|
||||
nconstIndex = i
|
||||
case "category":
|
||||
categoryIndex = i
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case tconstIndex == -1:
|
||||
return nil, fmt.Errorf("column %s not found", "`tconst`")
|
||||
case nconstIndex == -1:
|
||||
return nil, fmt.Errorf("column %s not found", "`nconst`")
|
||||
case categoryIndex == -1:
|
||||
return nil, fmt.Errorf("column %s not found", "`category`")
|
||||
}
|
||||
|
||||
tconstMap := make(map[string][]string)
|
||||
for _, tconst := range tconsts {
|
||||
tconstMap[tconst] = []string{}
|
||||
}
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
columns := strings.Split(line, "\t")
|
||||
if len(columns) != columnCount {
|
||||
fmt.Println("Columns are:", columns)
|
||||
return nil, fmt.Errorf("expected %d columns, found %d", columnCount, len(columns))
|
||||
}
|
||||
|
||||
if slices.Contains(config.NconstCategories, columns[categoryIndex]) {
|
||||
if _, ok := tconstMap[columns[tconstIndex]]; ok {
|
||||
tconstMap[columns[tconstIndex]] = append(tconstMap[columns[tconstIndex]], columns[nconstIndex])
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var principals []Principal
|
||||
for tconst, nconsts := range tconstMap {
|
||||
principals = append(principals, Principal{
|
||||
Tconst: tconst,
|
||||
Nconsts: strings.Join(nconsts, ","),
|
||||
})
|
||||
}
|
||||
return principals, nil
|
||||
}
|
||||
|
||||
func FilterRatings(filePath string, tconsts []string) ([]Ratings, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not open file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
columnCount := 3
|
||||
var headers []string
|
||||
if scanner.Scan() {
|
||||
headers = strings.Split(scanner.Text(), "\t")
|
||||
if len(headers) != columnCount {
|
||||
return nil, fmt.Errorf("expected %d column headers, found %d", columnCount, len(headers))
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("could not read column headers: %v", scanner.Err())
|
||||
}
|
||||
|
||||
var (
|
||||
tconstIndex int = -1
|
||||
averageRatingIndex int = -1
|
||||
numVotesIndex int = -1
|
||||
)
|
||||
for i, header := range headers {
|
||||
switch header {
|
||||
case "tconst":
|
||||
tconstIndex = i
|
||||
case "averageRating":
|
||||
averageRatingIndex = i
|
||||
case "numVotes":
|
||||
numVotesIndex = i
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case tconstIndex == -1:
|
||||
return nil, fmt.Errorf("column %s not found", "`tconst`")
|
||||
case averageRatingIndex == -1:
|
||||
return nil, fmt.Errorf("column %s not found", "`averageRating`")
|
||||
case numVotesIndex == -1:
|
||||
return nil, fmt.Errorf("column %s not found", "`numVotes`")
|
||||
}
|
||||
|
||||
tconstMap := make(map[string][]string)
|
||||
for _, tconst := range tconsts {
|
||||
tconstMap[tconst] = []string{}
|
||||
}
|
||||
var ratings []Ratings
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
columns := strings.Split(line, "\t")
|
||||
if len(columns) != columnCount {
|
||||
fmt.Println("Columns are:", columns)
|
||||
return nil, fmt.Errorf("expected %d columns, found %d", columnCount, len(columns))
|
||||
}
|
||||
|
||||
if _, ok := tconstMap[columns[tconstIndex]]; ok {
|
||||
rating, err := strconv.ParseFloat(columns[averageRatingIndex], 32)
|
||||
if err != nil {
|
||||
rating = 0
|
||||
}
|
||||
|
||||
votes, err := strconv.Atoi(columns[numVotesIndex])
|
||||
if err != nil {
|
||||
votes = 0
|
||||
}
|
||||
|
||||
ratings = append(ratings, Ratings{
|
||||
Tconst: columns[tconstIndex],
|
||||
Rating: math.Round(rating*10) / 10,
|
||||
Votes: votes,
|
||||
})
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ratings, nil
|
||||
}
|
27
server/pkg/dto/models.go
Normal file
27
server/pkg/dto/models.go
Normal file
@ -0,0 +1,27 @@
|
||||
package dto
|
||||
|
||||
type Basic struct {
|
||||
Tconst string `json:"tconst"`
|
||||
StartYear uint16 `json:"startYear"`
|
||||
Genres string `json:"genres"`
|
||||
}
|
||||
|
||||
type Principal struct {
|
||||
Tconst string `json:"tconst"`
|
||||
Nconsts string `json:"nconsts"`
|
||||
}
|
||||
|
||||
type Ratings struct {
|
||||
Tconst string `json:"tconst"`
|
||||
Rating float64 `json:"rating"`
|
||||
Votes int `json:"votes"`
|
||||
}
|
||||
|
||||
type MinMax struct {
|
||||
MinVotes uint `json:"minVotes"`
|
||||
MaxVotes uint `json:"maxVotes"`
|
||||
MinYear uint `json:"minYear"`
|
||||
MaxYear uint `json:"maxYear"`
|
||||
MinRating float64 `json:"minRating"`
|
||||
MaxRating float64 `json:"maxRating"`
|
||||
}
|
58
server/pkg/dto/vector.go
Normal file
58
server/pkg/dto/vector.go
Normal file
@ -0,0 +1,58 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
type CountVectorizer struct {
|
||||
WordIndex map[string]int
|
||||
}
|
||||
|
||||
func NewCountVectorizer() *CountVectorizer {
|
||||
return &CountVectorizer{}
|
||||
}
|
||||
|
||||
func (cv *CountVectorizer) SetWordIndexes(docs [][]string) {
|
||||
cv.WordIndex = make(map[string]int)
|
||||
index := 0
|
||||
for _, doc := range docs {
|
||||
for _, word := range doc {
|
||||
if word == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := cv.WordIndex[word]; !exists {
|
||||
cv.WordIndex[word] = index
|
||||
index++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (cv *CountVectorizer) Vectorize(doc []string) []uint8 {
|
||||
vector := make([]uint8, len(cv.WordIndex))
|
||||
for _, word := range doc {
|
||||
vector[cv.WordIndex[word]]++
|
||||
}
|
||||
return vector
|
||||
}
|
||||
|
||||
func CosineSimilarity(a, b []uint8) (float32, error) {
|
||||
if len(a) != len(b) {
|
||||
return 0, fmt.Errorf("slices must have the same length")
|
||||
}
|
||||
var dotProduct, normA, normB float64
|
||||
for i := 0; i < len(a); i++ {
|
||||
x := float64(a[i])
|
||||
y := float64(b[i])
|
||||
dotProduct += x * y
|
||||
normA += x * x
|
||||
normB += y * y
|
||||
}
|
||||
|
||||
if normA == 0 || normB == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
return float32(dotProduct / (math.Sqrt(normA) * math.Sqrt(normB))), nil
|
||||
}
|
301
server/pkg/handlers/imdb.go
Normal file
301
server/pkg/handlers/imdb.go
Normal file
@ -0,0 +1,301 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/aykhans/movier/server/pkg/dto"
|
||||
"github.com/aykhans/movier/server/pkg/proto"
|
||||
"github.com/aykhans/movier/server/pkg/storage/postgresql/repository"
|
||||
"github.com/aykhans/movier/server/pkg/utils"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type IMDbHandler struct {
|
||||
imdbRepo repository.IMDbRepository
|
||||
grpcRecommenderService *grpc.ClientConn
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func NewIMDbHandler(imdbRepo repository.IMDbRepository, grpcRecommenderService *grpc.ClientConn, baseURL string) *IMDbHandler {
|
||||
return &IMDbHandler{
|
||||
imdbRepo: imdbRepo,
|
||||
grpcRecommenderService: grpcRecommenderService,
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *IMDbHandler) HandlerGetRecommendations(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
|
||||
tconstsQ := query["tconst"]
|
||||
tconstsLen := len(tconstsQ)
|
||||
if tconstsLen < 1 || tconstsLen > 5 {
|
||||
RespondWithJSON(w, ErrorResponse{Error: "tconsts should be between 1 and 5"}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
uniqueTconsts := make(map[string]struct{})
|
||||
for _, str := range tconstsQ {
|
||||
uniqueTconsts[str] = struct{}{}
|
||||
}
|
||||
|
||||
invalidTconsts := []string{}
|
||||
tconsts := []string{}
|
||||
for tconst := range uniqueTconsts {
|
||||
tconstLength := len(tconst)
|
||||
if 9 > tconstLength || tconstLength > 12 || !strings.HasPrefix(tconst, "tt") {
|
||||
invalidTconsts = append(invalidTconsts, tconst)
|
||||
}
|
||||
tconsts = append(tconsts, tconst)
|
||||
}
|
||||
if len(invalidTconsts) > 0 {
|
||||
RespondWithJSON(
|
||||
w,
|
||||
ErrorResponse{
|
||||
Error: fmt.Sprintf("Invalid tconsts: %s", strings.Join(invalidTconsts, ", ")),
|
||||
},
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
n := 5
|
||||
nQuery := query.Get("n")
|
||||
if nQuery != "" {
|
||||
nInt, err := strconv.Atoi(nQuery)
|
||||
if err != nil {
|
||||
RespondWithJSON(w, ErrorResponse{Error: "n should be an integer"}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if nInt < 1 || nInt > 20 {
|
||||
RespondWithJSON(w, ErrorResponse{Error: "n should be greater than 0 and less than 21"}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
n = nInt
|
||||
}
|
||||
|
||||
filter := &proto.Filter{}
|
||||
minVotesQ := query.Get("min_votes")
|
||||
if minVotesQ != "" {
|
||||
minVotesInt, err := strconv.Atoi(minVotesQ)
|
||||
if err != nil {
|
||||
RespondWithJSON(w, ErrorResponse{Error: "min_votes should be an integer"}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !utils.IsUint32(minVotesInt) {
|
||||
RespondWithJSON(w, ErrorResponse{Error: "min_votes should be greater than or equal to 0 and less than or equal to 4294967295"}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
filter.MinVotesOneof = &proto.Filter_MinVotes{MinVotes: uint32(minVotesInt)}
|
||||
}
|
||||
|
||||
maxVotesQ := query.Get("max_votes")
|
||||
if maxVotesQ != "" {
|
||||
maxVotesInt, err := strconv.Atoi(maxVotesQ)
|
||||
if err != nil {
|
||||
RespondWithJSON(w, ErrorResponse{Error: "max_votes should be an integer"}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !utils.IsUint32(maxVotesInt) {
|
||||
RespondWithJSON(w, ErrorResponse{Error: "max_votes should be greater than 0 or equal to and less than or equal to 4294967295"}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if uint32(maxVotesInt) < filter.GetMinVotes() {
|
||||
RespondWithJSON(w, ErrorResponse{Error: "max_votes should be greater than min_votes"}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
filter.MaxVotesOneof = &proto.Filter_MaxVotes{MaxVotes: uint32(maxVotesInt)}
|
||||
}
|
||||
|
||||
minRatingQ := query.Get("min_rating")
|
||||
if minRatingQ != "" {
|
||||
minRatingFloat, err := strconv.ParseFloat(minRatingQ, 32)
|
||||
if err != nil {
|
||||
RespondWithJSON(w, ErrorResponse{Error: "min_rating should be a float"}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if minRatingFloat < 0 || minRatingFloat > 10 {
|
||||
RespondWithJSON(w, ErrorResponse{Error: "min_rating should be greater than or equal to 0.0 and less than equal to 10.0"}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
filter.MinRatingOneof = &proto.Filter_MinRating{MinRating: float32(minRatingFloat)}
|
||||
}
|
||||
|
||||
maxRatingQ := query.Get("max_rating")
|
||||
if maxRatingQ != "" {
|
||||
maxRatingFloat, err := strconv.ParseFloat(maxRatingQ, 32)
|
||||
if err != nil {
|
||||
RespondWithJSON(w, ErrorResponse{Error: "max_rating should be a float"}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if maxRatingFloat < 0 || maxRatingFloat > 10 {
|
||||
RespondWithJSON(w, ErrorResponse{Error: "max_rating should be greater than or equal to 0.0 and less than or equal to 10.0"}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if float32(maxRatingFloat) < filter.GetMinRating() {
|
||||
RespondWithJSON(w, ErrorResponse{Error: "max_rating should be greater than min_rating"}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
filter.MaxRatingOneof = &proto.Filter_MaxRating{MaxRating: float32(maxRatingFloat)}
|
||||
}
|
||||
|
||||
minYearQ := query.Get("min_year")
|
||||
if minYearQ != "" {
|
||||
minYearInt, err := strconv.Atoi(minYearQ)
|
||||
if err != nil {
|
||||
RespondWithJSON(w, ErrorResponse{Error: "min_year should be an integer"}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !utils.IsUint32(minYearInt) {
|
||||
RespondWithJSON(w, ErrorResponse{Error: "min_year should be greater than or equal to 0 and less than or equal to 4294967295"}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
filter.MinYearOneof = &proto.Filter_MinYear{MinYear: uint32(minYearInt)}
|
||||
}
|
||||
|
||||
maxYearQ := query.Get("max_year")
|
||||
if maxYearQ != "" {
|
||||
maxYearInt, err := strconv.Atoi(maxYearQ)
|
||||
if err != nil {
|
||||
RespondWithJSON(w, ErrorResponse{Error: "max_year should be an integer"}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !utils.IsUint32(maxYearInt) {
|
||||
RespondWithJSON(w, ErrorResponse{Error: "max_year should be greater than or equal to 0 and less than or equal to 4294967295"}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if uint32(maxYearInt) < filter.GetMinYear() {
|
||||
RespondWithJSON(w, ErrorResponse{Error: "max_year should be greater than min_year"}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
filter.MaxYearOneof = &proto.Filter_MaxYear{MaxYear: uint32(maxYearInt)}
|
||||
}
|
||||
|
||||
yearWeightQ := query.Get("year_weight")
|
||||
ratingWeightQ := query.Get("rating_weight")
|
||||
genresWeightQ := query.Get("genres_weight")
|
||||
nconstsWeightQ := query.Get("nconsts_weight")
|
||||
|
||||
weight := &proto.Weight{}
|
||||
|
||||
features := []string{}
|
||||
totalSum := 0
|
||||
if yearWeightQ != "" {
|
||||
yearWeight, err := strconv.Atoi(yearWeightQ)
|
||||
if err != nil {
|
||||
RespondWithJSON(w, ErrorResponse{Error: "year_weight should be an integer"}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if yearWeight < 0 || yearWeight > 400 {
|
||||
RespondWithJSON(w, ErrorResponse{Error: "year_weight should be greater than or equal to 0 and less than or equal to 400"}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
weight.Year = uint32(yearWeight)
|
||||
totalSum += yearWeight
|
||||
features = append(features, "year")
|
||||
}
|
||||
if ratingWeightQ != "" {
|
||||
ratingWeight, err := strconv.Atoi(ratingWeightQ)
|
||||
if err != nil {
|
||||
RespondWithJSON(w, ErrorResponse{Error: "rating_weight should be an integer"}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if ratingWeight < 0 || ratingWeight > 400 {
|
||||
RespondWithJSON(w, ErrorResponse{Error: "rating_weight should be greater than or equal to 0 and less than or equal to 400"}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
weight.Rating = uint32(ratingWeight)
|
||||
totalSum += ratingWeight
|
||||
features = append(features, "rating")
|
||||
}
|
||||
if genresWeightQ != "" {
|
||||
genresWeight, err := strconv.Atoi(genresWeightQ)
|
||||
if err != nil {
|
||||
RespondWithJSON(w, ErrorResponse{Error: "genres_weight should be an integer"}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if genresWeight < 0 || genresWeight > 400 {
|
||||
RespondWithJSON(w, ErrorResponse{Error: "genres_weight should be greater than or equal to 0 and less than or equal to 400"}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
weight.Genres = uint32(genresWeight)
|
||||
totalSum += genresWeight
|
||||
features = append(features, "genres")
|
||||
}
|
||||
if nconstsWeightQ != "" {
|
||||
nconstsWeight, err := strconv.Atoi(nconstsWeightQ)
|
||||
if err != nil {
|
||||
RespondWithJSON(w, ErrorResponse{Error: "nconsts_weight should be an integer"}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if nconstsWeight < 0 || nconstsWeight > 400 {
|
||||
RespondWithJSON(w, ErrorResponse{Error: "nconsts_weight should be greater than or equal to 0 and less than or equal to 400"}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
weight.Nconsts = uint32(nconstsWeight)
|
||||
totalSum += nconstsWeight
|
||||
features = append(features, "nconsts")
|
||||
}
|
||||
|
||||
featuresLen := len(features)
|
||||
if featuresLen < 1 {
|
||||
RespondWithJSON(w, ErrorResponse{Error: "At least one feature should be selected"}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if featuresLen*100 != totalSum {
|
||||
RespondWithJSON(w, ErrorResponse{Error: fmt.Sprintf("Sum of the %d features should be equal to %d", featuresLen, featuresLen*100)}, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
client := proto.NewRecommenderClient(h.grpcRecommenderService)
|
||||
response, err := client.GetRecommendations(r.Context(), &proto.Request{
|
||||
Tconsts: tconsts,
|
||||
N: uint32(n),
|
||||
Filter: filter,
|
||||
Weight: weight,
|
||||
})
|
||||
if err != nil {
|
||||
if st, ok := status.FromError(err); ok {
|
||||
switch st.Code() {
|
||||
case codes.InvalidArgument:
|
||||
RespondWithJSON(w, ErrorResponse{Error: st.Message()}, http.StatusBadRequest)
|
||||
case codes.NotFound:
|
||||
RespondWithJSON(w, ErrorResponse{Error: st.Message()}, http.StatusNotFound)
|
||||
case codes.Internal:
|
||||
RespondWithServerError(w)
|
||||
default:
|
||||
fmt.Println(err)
|
||||
RespondWithServerError(w)
|
||||
}
|
||||
return
|
||||
}
|
||||
RespondWithServerError(w)
|
||||
return
|
||||
}
|
||||
|
||||
RespondWithJSON(w, response.Movies, http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *IMDbHandler) HandlerHome(w http.ResponseWriter, r *http.Request) {
|
||||
minMax, err := h.imdbRepo.GetMinMax()
|
||||
if err != nil {
|
||||
log.Printf("error getting min max: %v", err)
|
||||
RespondWithServerError(w)
|
||||
return
|
||||
}
|
||||
|
||||
RespondWithHTML(
|
||||
w, "index.html",
|
||||
struct {
|
||||
MinMax dto.MinMax
|
||||
BaseURL string
|
||||
}{*minMax, h.baseURL},
|
||||
http.StatusOK,
|
||||
)
|
||||
}
|
18
server/pkg/handlers/middlewares.go
Normal file
18
server/pkg/handlers/middlewares.go
Normal file
@ -0,0 +1,18 @@
|
||||
package handlers
|
||||
|
||||
import "net/http"
|
||||
|
||||
func CORSMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
64
server/pkg/handlers/responses.go
Normal file
64
server/pkg/handlers/responses.go
Normal file
@ -0,0 +1,64 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/aykhans/movier/server/pkg/config"
|
||||
)
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func RespondWithServerError(w http.ResponseWriter) {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func RespondWithJSON(w http.ResponseWriter, data any, statusCode int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
if err := json.NewEncoder(w).Encode(data); err != nil {
|
||||
log.Printf("error encoding response: %v", err)
|
||||
RespondWithServerError(w)
|
||||
}
|
||||
}
|
||||
|
||||
func formatNumber(n uint) string {
|
||||
s := fmt.Sprintf("%d", n)
|
||||
var result strings.Builder
|
||||
length := len(s)
|
||||
|
||||
for i, digit := range s {
|
||||
if i > 0 && (length-i)%3 == 0 {
|
||||
result.WriteString(",")
|
||||
}
|
||||
result.WriteRune(digit)
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func RespondWithHTML(w http.ResponseWriter, templateName string, data any, statusCode int) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(statusCode)
|
||||
|
||||
funcMap := template.FuncMap{
|
||||
"formatNumber": formatNumber,
|
||||
}
|
||||
|
||||
t, err := template.New(templateName).Funcs(funcMap).ParseFiles(config.GetTemplatePath() + "/" + templateName)
|
||||
if err != nil {
|
||||
log.Printf("error parsing template: %v", err)
|
||||
RespondWithServerError(w)
|
||||
return
|
||||
}
|
||||
err = t.Execute(w, data)
|
||||
if err != nil {
|
||||
log.Printf("error executing template: %v", err)
|
||||
RespondWithServerError(w)
|
||||
}
|
||||
}
|
587
server/pkg/proto/recommender.pb.go
Normal file
587
server/pkg/proto/recommender.pb.go
Normal file
@ -0,0 +1,587 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.35.1
|
||||
// protoc v5.28.3
|
||||
// source: recommender.proto
|
||||
|
||||
package proto
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type Filter struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
// Types that are assignable to MinVotesOneof:
|
||||
//
|
||||
// *Filter_MinVotes
|
||||
MinVotesOneof isFilter_MinVotesOneof `protobuf_oneof:"min_votes_oneof"`
|
||||
// Types that are assignable to MaxVotesOneof:
|
||||
//
|
||||
// *Filter_MaxVotes
|
||||
MaxVotesOneof isFilter_MaxVotesOneof `protobuf_oneof:"max_votes_oneof"`
|
||||
// Types that are assignable to MinYearOneof:
|
||||
//
|
||||
// *Filter_MinYear
|
||||
MinYearOneof isFilter_MinYearOneof `protobuf_oneof:"min_year_oneof"`
|
||||
// Types that are assignable to MaxYearOneof:
|
||||
//
|
||||
// *Filter_MaxYear
|
||||
MaxYearOneof isFilter_MaxYearOneof `protobuf_oneof:"max_year_oneof"`
|
||||
// Types that are assignable to MinRatingOneof:
|
||||
//
|
||||
// *Filter_MinRating
|
||||
MinRatingOneof isFilter_MinRatingOneof `protobuf_oneof:"min_rating_oneof"`
|
||||
// Types that are assignable to MaxRatingOneof:
|
||||
//
|
||||
// *Filter_MaxRating
|
||||
MaxRatingOneof isFilter_MaxRatingOneof `protobuf_oneof:"max_rating_oneof"`
|
||||
}
|
||||
|
||||
func (x *Filter) Reset() {
|
||||
*x = Filter{}
|
||||
mi := &file_recommender_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *Filter) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*Filter) ProtoMessage() {}
|
||||
|
||||
func (x *Filter) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_recommender_proto_msgTypes[0]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use Filter.ProtoReflect.Descriptor instead.
|
||||
func (*Filter) Descriptor() ([]byte, []int) {
|
||||
return file_recommender_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (m *Filter) GetMinVotesOneof() isFilter_MinVotesOneof {
|
||||
if m != nil {
|
||||
return m.MinVotesOneof
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Filter) GetMinVotes() uint32 {
|
||||
if x, ok := x.GetMinVotesOneof().(*Filter_MinVotes); ok {
|
||||
return x.MinVotes
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m *Filter) GetMaxVotesOneof() isFilter_MaxVotesOneof {
|
||||
if m != nil {
|
||||
return m.MaxVotesOneof
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Filter) GetMaxVotes() uint32 {
|
||||
if x, ok := x.GetMaxVotesOneof().(*Filter_MaxVotes); ok {
|
||||
return x.MaxVotes
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m *Filter) GetMinYearOneof() isFilter_MinYearOneof {
|
||||
if m != nil {
|
||||
return m.MinYearOneof
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Filter) GetMinYear() uint32 {
|
||||
if x, ok := x.GetMinYearOneof().(*Filter_MinYear); ok {
|
||||
return x.MinYear
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m *Filter) GetMaxYearOneof() isFilter_MaxYearOneof {
|
||||
if m != nil {
|
||||
return m.MaxYearOneof
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Filter) GetMaxYear() uint32 {
|
||||
if x, ok := x.GetMaxYearOneof().(*Filter_MaxYear); ok {
|
||||
return x.MaxYear
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m *Filter) GetMinRatingOneof() isFilter_MinRatingOneof {
|
||||
if m != nil {
|
||||
return m.MinRatingOneof
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Filter) GetMinRating() float32 {
|
||||
if x, ok := x.GetMinRatingOneof().(*Filter_MinRating); ok {
|
||||
return x.MinRating
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m *Filter) GetMaxRatingOneof() isFilter_MaxRatingOneof {
|
||||
if m != nil {
|
||||
return m.MaxRatingOneof
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Filter) GetMaxRating() float32 {
|
||||
if x, ok := x.GetMaxRatingOneof().(*Filter_MaxRating); ok {
|
||||
return x.MaxRating
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type isFilter_MinVotesOneof interface {
|
||||
isFilter_MinVotesOneof()
|
||||
}
|
||||
|
||||
type Filter_MinVotes struct {
|
||||
MinVotes uint32 `protobuf:"varint,1,opt,name=min_votes,json=minVotes,proto3,oneof"`
|
||||
}
|
||||
|
||||
func (*Filter_MinVotes) isFilter_MinVotesOneof() {}
|
||||
|
||||
type isFilter_MaxVotesOneof interface {
|
||||
isFilter_MaxVotesOneof()
|
||||
}
|
||||
|
||||
type Filter_MaxVotes struct {
|
||||
MaxVotes uint32 `protobuf:"varint,2,opt,name=max_votes,json=maxVotes,proto3,oneof"`
|
||||
}
|
||||
|
||||
func (*Filter_MaxVotes) isFilter_MaxVotesOneof() {}
|
||||
|
||||
type isFilter_MinYearOneof interface {
|
||||
isFilter_MinYearOneof()
|
||||
}
|
||||
|
||||
type Filter_MinYear struct {
|
||||
MinYear uint32 `protobuf:"varint,3,opt,name=min_year,json=minYear,proto3,oneof"`
|
||||
}
|
||||
|
||||
func (*Filter_MinYear) isFilter_MinYearOneof() {}
|
||||
|
||||
type isFilter_MaxYearOneof interface {
|
||||
isFilter_MaxYearOneof()
|
||||
}
|
||||
|
||||
type Filter_MaxYear struct {
|
||||
MaxYear uint32 `protobuf:"varint,4,opt,name=max_year,json=maxYear,proto3,oneof"`
|
||||
}
|
||||
|
||||
func (*Filter_MaxYear) isFilter_MaxYearOneof() {}
|
||||
|
||||
type isFilter_MinRatingOneof interface {
|
||||
isFilter_MinRatingOneof()
|
||||
}
|
||||
|
||||
type Filter_MinRating struct {
|
||||
MinRating float32 `protobuf:"fixed32,5,opt,name=min_rating,json=minRating,proto3,oneof"`
|
||||
}
|
||||
|
||||
func (*Filter_MinRating) isFilter_MinRatingOneof() {}
|
||||
|
||||
type isFilter_MaxRatingOneof interface {
|
||||
isFilter_MaxRatingOneof()
|
||||
}
|
||||
|
||||
type Filter_MaxRating struct {
|
||||
MaxRating float32 `protobuf:"fixed32,6,opt,name=max_rating,json=maxRating,proto3,oneof"`
|
||||
}
|
||||
|
||||
func (*Filter_MaxRating) isFilter_MaxRatingOneof() {}
|
||||
|
||||
type Weight struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Year uint32 `protobuf:"varint,1,opt,name=year,proto3" json:"year,omitempty"`
|
||||
Rating uint32 `protobuf:"varint,2,opt,name=rating,proto3" json:"rating,omitempty"`
|
||||
Genres uint32 `protobuf:"varint,3,opt,name=genres,proto3" json:"genres,omitempty"`
|
||||
Nconsts uint32 `protobuf:"varint,4,opt,name=nconsts,proto3" json:"nconsts,omitempty"`
|
||||
}
|
||||
|
||||
func (x *Weight) Reset() {
|
||||
*x = Weight{}
|
||||
mi := &file_recommender_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *Weight) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*Weight) ProtoMessage() {}
|
||||
|
||||
func (x *Weight) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_recommender_proto_msgTypes[1]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use Weight.ProtoReflect.Descriptor instead.
|
||||
func (*Weight) Descriptor() ([]byte, []int) {
|
||||
return file_recommender_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *Weight) GetYear() uint32 {
|
||||
if x != nil {
|
||||
return x.Year
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *Weight) GetRating() uint32 {
|
||||
if x != nil {
|
||||
return x.Rating
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *Weight) GetGenres() uint32 {
|
||||
if x != nil {
|
||||
return x.Genres
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *Weight) GetNconsts() uint32 {
|
||||
if x != nil {
|
||||
return x.Nconsts
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Tconsts []string `protobuf:"bytes,1,rep,name=tconsts,proto3" json:"tconsts,omitempty"`
|
||||
N uint32 `protobuf:"varint,2,opt,name=n,proto3" json:"n,omitempty"`
|
||||
Filter *Filter `protobuf:"bytes,3,opt,name=filter,proto3" json:"filter,omitempty"`
|
||||
Weight *Weight `protobuf:"bytes,4,opt,name=weight,proto3" json:"weight,omitempty"`
|
||||
}
|
||||
|
||||
func (x *Request) Reset() {
|
||||
*x = Request{}
|
||||
mi := &file_recommender_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *Request) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*Request) ProtoMessage() {}
|
||||
|
||||
func (x *Request) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_recommender_proto_msgTypes[2]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use Request.ProtoReflect.Descriptor instead.
|
||||
func (*Request) Descriptor() ([]byte, []int) {
|
||||
return file_recommender_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *Request) GetTconsts() []string {
|
||||
if x != nil {
|
||||
return x.Tconsts
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Request) GetN() uint32 {
|
||||
if x != nil {
|
||||
return x.N
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *Request) GetFilter() *Filter {
|
||||
if x != nil {
|
||||
return x.Filter
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Request) GetWeight() *Weight {
|
||||
if x != nil {
|
||||
return x.Weight
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Movies []*RecommendedMovie `protobuf:"bytes,1,rep,name=movies,proto3" json:"movies,omitempty"`
|
||||
}
|
||||
|
||||
func (x *Response) Reset() {
|
||||
*x = Response{}
|
||||
mi := &file_recommender_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *Response) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*Response) ProtoMessage() {}
|
||||
|
||||
func (x *Response) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_recommender_proto_msgTypes[3]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use Response.ProtoReflect.Descriptor instead.
|
||||
func (*Response) Descriptor() ([]byte, []int) {
|
||||
return file_recommender_proto_rawDescGZIP(), []int{3}
|
||||
}
|
||||
|
||||
func (x *Response) GetMovies() []*RecommendedMovie {
|
||||
if x != nil {
|
||||
return x.Movies
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type RecommendedMovie struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Tconst string `protobuf:"bytes,1,opt,name=tconst,proto3" json:"tconst,omitempty"`
|
||||
Weights []string `protobuf:"bytes,2,rep,name=weights,proto3" json:"weights,omitempty"`
|
||||
}
|
||||
|
||||
func (x *RecommendedMovie) Reset() {
|
||||
*x = RecommendedMovie{}
|
||||
mi := &file_recommender_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *RecommendedMovie) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*RecommendedMovie) ProtoMessage() {}
|
||||
|
||||
func (x *RecommendedMovie) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_recommender_proto_msgTypes[4]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use RecommendedMovie.ProtoReflect.Descriptor instead.
|
||||
func (*RecommendedMovie) Descriptor() ([]byte, []int) {
|
||||
return file_recommender_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
func (x *RecommendedMovie) GetTconst() string {
|
||||
if x != nil {
|
||||
return x.Tconst
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *RecommendedMovie) GetWeights() []string {
|
||||
if x != nil {
|
||||
return x.Weights
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var File_recommender_proto protoreflect.FileDescriptor
|
||||
|
||||
var file_recommender_proto_rawDesc = []byte{
|
||||
0x0a, 0x11, 0x72, 0x65, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x2e, 0x70, 0x72,
|
||||
0x6f, 0x74, 0x6f, 0x12, 0x0b, 0x72, 0x65, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x64, 0x65, 0x72,
|
||||
0x22, 0xb4, 0x02, 0x0a, 0x06, 0x46, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x12, 0x1d, 0x0a, 0x09, 0x6d,
|
||||
0x69, 0x6e, 0x5f, 0x76, 0x6f, 0x74, 0x65, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x00,
|
||||
0x52, 0x08, 0x6d, 0x69, 0x6e, 0x56, 0x6f, 0x74, 0x65, 0x73, 0x12, 0x1d, 0x0a, 0x09, 0x6d, 0x61,
|
||||
0x78, 0x5f, 0x76, 0x6f, 0x74, 0x65, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x01, 0x52,
|
||||
0x08, 0x6d, 0x61, 0x78, 0x56, 0x6f, 0x74, 0x65, 0x73, 0x12, 0x1b, 0x0a, 0x08, 0x6d, 0x69, 0x6e,
|
||||
0x5f, 0x79, 0x65, 0x61, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x02, 0x52, 0x07, 0x6d,
|
||||
0x69, 0x6e, 0x59, 0x65, 0x61, 0x72, 0x12, 0x1b, 0x0a, 0x08, 0x6d, 0x61, 0x78, 0x5f, 0x79, 0x65,
|
||||
0x61, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x03, 0x52, 0x07, 0x6d, 0x61, 0x78, 0x59,
|
||||
0x65, 0x61, 0x72, 0x12, 0x1f, 0x0a, 0x0a, 0x6d, 0x69, 0x6e, 0x5f, 0x72, 0x61, 0x74, 0x69, 0x6e,
|
||||
0x67, 0x18, 0x05, 0x20, 0x01, 0x28, 0x02, 0x48, 0x04, 0x52, 0x09, 0x6d, 0x69, 0x6e, 0x52, 0x61,
|
||||
0x74, 0x69, 0x6e, 0x67, 0x12, 0x1f, 0x0a, 0x0a, 0x6d, 0x61, 0x78, 0x5f, 0x72, 0x61, 0x74, 0x69,
|
||||
0x6e, 0x67, 0x18, 0x06, 0x20, 0x01, 0x28, 0x02, 0x48, 0x05, 0x52, 0x09, 0x6d, 0x61, 0x78, 0x52,
|
||||
0x61, 0x74, 0x69, 0x6e, 0x67, 0x42, 0x11, 0x0a, 0x0f, 0x6d, 0x69, 0x6e, 0x5f, 0x76, 0x6f, 0x74,
|
||||
0x65, 0x73, 0x5f, 0x6f, 0x6e, 0x65, 0x6f, 0x66, 0x42, 0x11, 0x0a, 0x0f, 0x6d, 0x61, 0x78, 0x5f,
|
||||
0x76, 0x6f, 0x74, 0x65, 0x73, 0x5f, 0x6f, 0x6e, 0x65, 0x6f, 0x66, 0x42, 0x10, 0x0a, 0x0e, 0x6d,
|
||||
0x69, 0x6e, 0x5f, 0x79, 0x65, 0x61, 0x72, 0x5f, 0x6f, 0x6e, 0x65, 0x6f, 0x66, 0x42, 0x10, 0x0a,
|
||||
0x0e, 0x6d, 0x61, 0x78, 0x5f, 0x79, 0x65, 0x61, 0x72, 0x5f, 0x6f, 0x6e, 0x65, 0x6f, 0x66, 0x42,
|
||||
0x12, 0x0a, 0x10, 0x6d, 0x69, 0x6e, 0x5f, 0x72, 0x61, 0x74, 0x69, 0x6e, 0x67, 0x5f, 0x6f, 0x6e,
|
||||
0x65, 0x6f, 0x66, 0x42, 0x12, 0x0a, 0x10, 0x6d, 0x61, 0x78, 0x5f, 0x72, 0x61, 0x74, 0x69, 0x6e,
|
||||
0x67, 0x5f, 0x6f, 0x6e, 0x65, 0x6f, 0x66, 0x22, 0x66, 0x0a, 0x06, 0x57, 0x65, 0x69, 0x67, 0x68,
|
||||
0x74, 0x12, 0x12, 0x0a, 0x04, 0x79, 0x65, 0x61, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52,
|
||||
0x04, 0x79, 0x65, 0x61, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x61, 0x74, 0x69, 0x6e, 0x67, 0x18,
|
||||
0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x72, 0x61, 0x74, 0x69, 0x6e, 0x67, 0x12, 0x16, 0x0a,
|
||||
0x06, 0x67, 0x65, 0x6e, 0x72, 0x65, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x67,
|
||||
0x65, 0x6e, 0x72, 0x65, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x6e, 0x63, 0x6f, 0x6e, 0x73, 0x74, 0x73,
|
||||
0x18, 0x04, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x07, 0x6e, 0x63, 0x6f, 0x6e, 0x73, 0x74, 0x73, 0x22,
|
||||
0x8b, 0x01, 0x0a, 0x07, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x74,
|
||||
0x63, 0x6f, 0x6e, 0x73, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x74, 0x63,
|
||||
0x6f, 0x6e, 0x73, 0x74, 0x73, 0x12, 0x0c, 0x0a, 0x01, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d,
|
||||
0x52, 0x01, 0x6e, 0x12, 0x2b, 0x0a, 0x06, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x18, 0x03, 0x20,
|
||||
0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x72, 0x65, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x64, 0x65,
|
||||
0x72, 0x2e, 0x46, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x52, 0x06, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72,
|
||||
0x12, 0x2b, 0x0a, 0x06, 0x77, 0x65, 0x69, 0x67, 0x68, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b,
|
||||
0x32, 0x13, 0x2e, 0x72, 0x65, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x2e, 0x57,
|
||||
0x65, 0x69, 0x67, 0x68, 0x74, 0x52, 0x06, 0x77, 0x65, 0x69, 0x67, 0x68, 0x74, 0x22, 0x41, 0x0a,
|
||||
0x08, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x35, 0x0a, 0x06, 0x6d, 0x6f, 0x76,
|
||||
0x69, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x72, 0x65, 0x63, 0x6f,
|
||||
0x6d, 0x6d, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e,
|
||||
0x64, 0x65, 0x64, 0x4d, 0x6f, 0x76, 0x69, 0x65, 0x52, 0x06, 0x6d, 0x6f, 0x76, 0x69, 0x65, 0x73,
|
||||
0x22, 0x44, 0x0a, 0x10, 0x52, 0x65, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x4d,
|
||||
0x6f, 0x76, 0x69, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x74, 0x63, 0x6f, 0x6e, 0x73, 0x74, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x63, 0x6f, 0x6e, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07,
|
||||
0x77, 0x65, 0x69, 0x67, 0x68, 0x74, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x77,
|
||||
0x65, 0x69, 0x67, 0x68, 0x74, 0x73, 0x32, 0x52, 0x0a, 0x0b, 0x52, 0x65, 0x63, 0x6f, 0x6d, 0x6d,
|
||||
0x65, 0x6e, 0x64, 0x65, 0x72, 0x12, 0x43, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x52, 0x65, 0x63, 0x6f,
|
||||
0x6d, 0x6d, 0x65, 0x6e, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x14, 0x2e, 0x72, 0x65,
|
||||
0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
|
||||
0x74, 0x1a, 0x15, 0x2e, 0x72, 0x65, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x2e,
|
||||
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x2c, 0x5a, 0x2a, 0x67, 0x69,
|
||||
0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x61, 0x79, 0x6b, 0x68, 0x61, 0x6e, 0x73,
|
||||
0x2f, 0x6d, 0x6f, 0x76, 0x69, 0x65, 0x72, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x2f, 0x70,
|
||||
0x6b, 0x67, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
file_recommender_proto_rawDescOnce sync.Once
|
||||
file_recommender_proto_rawDescData = file_recommender_proto_rawDesc
|
||||
)
|
||||
|
||||
func file_recommender_proto_rawDescGZIP() []byte {
|
||||
file_recommender_proto_rawDescOnce.Do(func() {
|
||||
file_recommender_proto_rawDescData = protoimpl.X.CompressGZIP(file_recommender_proto_rawDescData)
|
||||
})
|
||||
return file_recommender_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_recommender_proto_msgTypes = make([]protoimpl.MessageInfo, 5)
|
||||
var file_recommender_proto_goTypes = []any{
|
||||
(*Filter)(nil), // 0: recommender.Filter
|
||||
(*Weight)(nil), // 1: recommender.Weight
|
||||
(*Request)(nil), // 2: recommender.Request
|
||||
(*Response)(nil), // 3: recommender.Response
|
||||
(*RecommendedMovie)(nil), // 4: recommender.RecommendedMovie
|
||||
}
|
||||
var file_recommender_proto_depIdxs = []int32{
|
||||
0, // 0: recommender.Request.filter:type_name -> recommender.Filter
|
||||
1, // 1: recommender.Request.weight:type_name -> recommender.Weight
|
||||
4, // 2: recommender.Response.movies:type_name -> recommender.RecommendedMovie
|
||||
2, // 3: recommender.Recommender.GetRecommendations:input_type -> recommender.Request
|
||||
3, // 4: recommender.Recommender.GetRecommendations:output_type -> recommender.Response
|
||||
4, // [4:5] is the sub-list for method output_type
|
||||
3, // [3:4] is the sub-list for method input_type
|
||||
3, // [3:3] is the sub-list for extension type_name
|
||||
3, // [3:3] is the sub-list for extension extendee
|
||||
0, // [0:3] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_recommender_proto_init() }
|
||||
func file_recommender_proto_init() {
|
||||
if File_recommender_proto != nil {
|
||||
return
|
||||
}
|
||||
file_recommender_proto_msgTypes[0].OneofWrappers = []any{
|
||||
(*Filter_MinVotes)(nil),
|
||||
(*Filter_MaxVotes)(nil),
|
||||
(*Filter_MinYear)(nil),
|
||||
(*Filter_MaxYear)(nil),
|
||||
(*Filter_MinRating)(nil),
|
||||
(*Filter_MaxRating)(nil),
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: file_recommender_proto_rawDesc,
|
||||
NumEnums: 0,
|
||||
NumMessages: 5,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
GoTypes: file_recommender_proto_goTypes,
|
||||
DependencyIndexes: file_recommender_proto_depIdxs,
|
||||
MessageInfos: file_recommender_proto_msgTypes,
|
||||
}.Build()
|
||||
File_recommender_proto = out.File
|
||||
file_recommender_proto_rawDesc = nil
|
||||
file_recommender_proto_goTypes = nil
|
||||
file_recommender_proto_depIdxs = nil
|
||||
}
|
121
server/pkg/proto/recommender_grpc.pb.go
Normal file
121
server/pkg/proto/recommender_grpc.pb.go
Normal file
@ -0,0 +1,121 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc v5.28.3
|
||||
// source: recommender.proto
|
||||
|
||||
package proto
|
||||
|
||||
import (
|
||||
context "context"
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the grpc package it is being compiled against.
|
||||
// Requires gRPC-Go v1.64.0 or later.
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
Recommender_GetRecommendations_FullMethodName = "/recommender.Recommender/GetRecommendations"
|
||||
)
|
||||
|
||||
// RecommenderClient is the client API for Recommender service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
type RecommenderClient interface {
|
||||
GetRecommendations(ctx context.Context, in *Request, opts ...grpc.CallOption) (*Response, error)
|
||||
}
|
||||
|
||||
type recommenderClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewRecommenderClient(cc grpc.ClientConnInterface) RecommenderClient {
|
||||
return &recommenderClient{cc}
|
||||
}
|
||||
|
||||
func (c *recommenderClient) GetRecommendations(ctx context.Context, in *Request, opts ...grpc.CallOption) (*Response, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(Response)
|
||||
err := c.cc.Invoke(ctx, Recommender_GetRecommendations_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RecommenderServer is the server API for Recommender service.
|
||||
// All implementations must embed UnimplementedRecommenderServer
|
||||
// for forward compatibility.
|
||||
type RecommenderServer interface {
|
||||
GetRecommendations(context.Context, *Request) (*Response, error)
|
||||
mustEmbedUnimplementedRecommenderServer()
|
||||
}
|
||||
|
||||
// UnimplementedRecommenderServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedRecommenderServer struct{}
|
||||
|
||||
func (UnimplementedRecommenderServer) GetRecommendations(context.Context, *Request) (*Response, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetRecommendations not implemented")
|
||||
}
|
||||
func (UnimplementedRecommenderServer) mustEmbedUnimplementedRecommenderServer() {}
|
||||
func (UnimplementedRecommenderServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeRecommenderServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to RecommenderServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeRecommenderServer interface {
|
||||
mustEmbedUnimplementedRecommenderServer()
|
||||
}
|
||||
|
||||
func RegisterRecommenderServer(s grpc.ServiceRegistrar, srv RecommenderServer) {
|
||||
// If the following call pancis, it indicates UnimplementedRecommenderServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&Recommender_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _Recommender_GetRecommendations_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(Request)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(RecommenderServer).GetRecommendations(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: Recommender_GetRecommendations_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(RecommenderServer).GetRecommendations(ctx, req.(*Request))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// Recommender_ServiceDesc is the grpc.ServiceDesc for Recommender service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var Recommender_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "recommender.Recommender",
|
||||
HandlerType: (*RecommenderServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "GetRecommendations",
|
||||
Handler: _Recommender_GetRecommendations_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "recommender.proto",
|
||||
}
|
16
server/pkg/storage/postgresql/db.go
Normal file
16
server/pkg/storage/postgresql/db.go
Normal file
@ -0,0 +1,16 @@
|
||||
package postgresql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func NewDB(dbURL string) (*pgx.Conn, error) {
|
||||
conn, err := pgx.Connect(context.Background(), dbURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS imdb;
|
@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS imdb (
|
||||
tconst VARCHAR(12) PRIMARY KEY NOT NULL,
|
||||
year SMALLINT NOT NULL DEFAULT 0,
|
||||
genres TEXT NOT NULL DEFAULT '',
|
||||
nconsts TEXT NOT NULL DEFAULT '',
|
||||
rating REAL NOT NULL DEFAULT 0.0,
|
||||
votes INTEGER NOT NULL DEFAULT 0
|
||||
);
|
107
server/pkg/storage/postgresql/repository/imdb.go
Normal file
107
server/pkg/storage/postgresql/repository/imdb.go
Normal file
@ -0,0 +1,107 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/aykhans/movier/server/pkg/dto"
|
||||
)
|
||||
|
||||
type IMDbRepository struct {
|
||||
db *pgx.Conn
|
||||
}
|
||||
|
||||
func NewIMDbRepository(db *pgx.Conn) *IMDbRepository {
|
||||
return &IMDbRepository{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func (repo *IMDbRepository) InsertMultipleBasics(basics []dto.Basic) error {
|
||||
batch := &pgx.Batch{}
|
||||
for _, basic := range basics {
|
||||
batch.Queue(
|
||||
`INSERT INTO imdb (tconst, year, genres)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (tconst) DO UPDATE
|
||||
SET year = EXCLUDED.year, genres = EXCLUDED.genres`,
|
||||
basic.Tconst, basic.StartYear, basic.Genres,
|
||||
)
|
||||
}
|
||||
|
||||
results := repo.db.SendBatch(context.Background(), batch)
|
||||
if err := results.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo *IMDbRepository) GetAllTconsts() ([]string, error) {
|
||||
rows, err := repo.db.Query(
|
||||
context.Background(),
|
||||
"SELECT tconst FROM imdb",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tconsts []string
|
||||
for rows.Next() {
|
||||
var tconst string
|
||||
if err := rows.Scan(&tconst); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tconsts = append(tconsts, tconst)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tconsts, nil
|
||||
}
|
||||
|
||||
func (repo *IMDbRepository) UpdateMultiplePrincipals(principals []dto.Principal) error {
|
||||
batch := &pgx.Batch{}
|
||||
for _, principal := range principals {
|
||||
batch.Queue(
|
||||
`UPDATE imdb SET nconsts = $1 WHERE tconst = $2`,
|
||||
principal.Nconsts, principal.Tconst,
|
||||
)
|
||||
}
|
||||
|
||||
results := repo.db.SendBatch(context.Background(), batch)
|
||||
if err := results.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo *IMDbRepository) UpdateMultipleRatings(ratings []dto.Ratings) error {
|
||||
batch := &pgx.Batch{}
|
||||
for _, rating := range ratings {
|
||||
batch.Queue(
|
||||
`UPDATE imdb SET rating = $1, votes = $2 WHERE tconst = $3`,
|
||||
rating.Rating, rating.Votes, rating.Tconst,
|
||||
)
|
||||
}
|
||||
|
||||
results := repo.db.SendBatch(context.Background(), batch)
|
||||
if err := results.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo *IMDbRepository) GetMinMax() (*dto.MinMax, error) {
|
||||
var minMax dto.MinMax
|
||||
|
||||
err := repo.db.QueryRow(
|
||||
context.Background(),
|
||||
"SELECT MIN(votes), MAX(votes), MIN(year), MAX(year), MIN(rating), MAX(rating) FROM imdb LIMIT 1",
|
||||
).Scan(&minMax.MinVotes, &minMax.MaxVotes, &minMax.MinYear, &minMax.MaxYear, &minMax.MinRating, &minMax.MaxRating)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &minMax, nil
|
||||
}
|
357
server/pkg/templates/index.html
Normal file
357
server/pkg/templates/index.html
Normal file
@ -0,0 +1,357 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Movier</title>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
|
||||
<link rel="icon" href="https://ftp.aykhans.me/web/client/pubshares/hB6VSdCnBCr8gFPeiMuCji/browse?path=%2Fshipit.png"
|
||||
type="image/x-icon">
|
||||
|
||||
<style>
|
||||
.input-wrapper {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.btn-custom-yellow {
|
||||
background-color: #f3ce13;
|
||||
border-color: #f3ce13;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-custom-yellow:hover {
|
||||
background-color: #dbb911;
|
||||
border-color: #dbb911;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-custom-yellow:disabled {
|
||||
background-color: #f3ce13;
|
||||
border-color: #f3ce13;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.single-input {
|
||||
width: 50%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<form id="dynamicForm">
|
||||
<div id="input-container" class="row justify-content-center">
|
||||
<div class="col-md-6 input-wrapper single-input">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" name="tconst" placeholder="tt0000009"
|
||||
pattern="^tt[0-9]+$" minlength="9" maxlength="12" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 text-center">
|
||||
<button type="button" id="add-field" class="btn btn-custom-yellow">
|
||||
<i class="fa-solid fa-plus"></i> Add Field
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="row text-center mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="min-votes">Min Votes ({{ .MinMax.MinVotes | formatNumber }})</label>
|
||||
<input type="number" id="min-votes" name="min-votes" class="form-control mx-sm-3" value="1"
|
||||
min="{{ .MinMax.MinVotes }}" max="{{ .MinMax.MaxVotes }}">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="max-votes">Max Votes ({{ .MinMax.MaxVotes | formatNumber }})</label>
|
||||
<input type="number" id="max-votes" name="max-votes" class="form-control mx-sm-3"
|
||||
min="{{ .MinMax.MinVotes }}" max="{{ .MinMax.MaxVotes }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row text-center mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="min-year">Min Year ({{ .MinMax.MinYear }})</label>
|
||||
<input type="number" id="min-year" name="min-year" class="form-control mx-sm-3" value="1"
|
||||
min="{{ .MinMax.MinYear }}" max="{{ .MinMax.MaxYear }}">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="max-year">Max Year ({{ .MinMax.MaxYear }})</label>
|
||||
<input type="number" id="max-year" name="max-year" class="form-control mx-sm-3"
|
||||
min="{{ .MinMax.MinYear }}" max="{{ .MinMax.MaxYear }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row text-center mb-5">
|
||||
<div class="col-md-6">
|
||||
<label for="min-rating">Min Rating ({{ .MinMax.MinRating }})</label>
|
||||
<input type="number" step="0.1" id="min-rating" name="min-rating"
|
||||
class="form-control mx-sm-3" min="{{ .MinMax.MinRating }}"
|
||||
max="{{ .MinMax.MaxRating }}">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="max-rating">Max Rating ({{ .MinMax.MaxRating }})</label>
|
||||
<input type="number" step="0.1" id="max-rating" name="max-rating"
|
||||
class="form-control mx-sm-3" min="{{ .MinMax.MinRating }}"
|
||||
max="{{ .MinMax.MaxRating }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row text-center mb-4">
|
||||
<div class="col-md-3">
|
||||
<label for="year-weight">Year Weight</label>
|
||||
<input type="number" id="year-weight" name="year-weight" class="form-control mx-sm-3 weight"
|
||||
value="100" min="0" max="400">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="rating-weight">Rating Weight</label>
|
||||
<input type="number" id="rating-weight" name="rating-weight"
|
||||
class="form-control mx-sm-3 weight" value="100" min="0" max="400">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="genres-weight">Genres Weight</label>
|
||||
<input type="number" id="genres-weight" name="genres-weight"
|
||||
class="form-control mx-sm-3 weight" value="100" min="0" max="400">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="nconsts-weight">Nconsts Weight</label>
|
||||
<input type="number" id="nconsts-weight" name="nconsts-weight"
|
||||
class="form-control mx-sm-3 weight" value="100" min="0" max="400">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row text-center">
|
||||
<p id="weight-sum"></p>
|
||||
</div>
|
||||
|
||||
<div class="row justify-content-center text-center mb-4">
|
||||
<div class="col-md-3">
|
||||
<label for="n">Number of Recommendations</label>
|
||||
<input type="number" id="n" name="n" class="form-control mx-sm-3" value="5" min="0"
|
||||
max="20">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mb-4">
|
||||
<p class="response-err" style="color: red;"></p>
|
||||
<button type="submit" class="btn btn-success">Get</button>
|
||||
</div>
|
||||
|
||||
<div class="row response mb-5"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const container = document.getElementById('input-container');
|
||||
const addButton = document.getElementById('add-field');
|
||||
const form = document.getElementById('dynamicForm');
|
||||
const MAX_FIELDS = 5;
|
||||
|
||||
function handleRemoveClick(event) {
|
||||
const removeButton = event.target.closest('.btn-remove');
|
||||
if (removeButton) {
|
||||
const wrapper = removeButton.closest('.input-wrapper');
|
||||
if (wrapper) {
|
||||
wrapper.remove();
|
||||
addButton.disabled = false;
|
||||
rearrangeInputs();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rearrangeInputs() {
|
||||
const wrappers = container.getElementsByClassName('input-wrapper');
|
||||
Array.from(wrappers).forEach((wrapper, index) => {
|
||||
wrapper.className = 'col-md-6 input-wrapper';
|
||||
if (wrappers.length === 1) {
|
||||
wrapper.classList.add('single-input');
|
||||
} else {
|
||||
wrapper.classList.remove('single-input');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addButton.addEventListener('click', function () {
|
||||
const inputGroups = container.getElementsByClassName('input-wrapper');
|
||||
|
||||
if (inputGroups.length < MAX_FIELDS) {
|
||||
const newWrapper = document.createElement('div');
|
||||
newWrapper.className = 'col-md-6 input-wrapper';
|
||||
|
||||
newWrapper.innerHTML = `
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" name="tconst" placeholder="tt0000009" pattern="^tt[0-9]+$" minlength="9" maxlength="12" required>
|
||||
<button type="button" class="btn btn-custom-yellow btn-remove">
|
||||
<i class="fa-solid fa-minus"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(newWrapper);
|
||||
|
||||
if (inputGroups.length === 1) {
|
||||
inputGroups[0].classList.remove('single-input');
|
||||
}
|
||||
|
||||
if (inputGroups.length === MAX_FIELDS) {
|
||||
addButton.disabled = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
container.addEventListener('click', handleRemoveClick);
|
||||
|
||||
const weights = document.querySelectorAll('.weight');
|
||||
const weightSum = document.getElementById('weight-sum');
|
||||
function calculateSum() {
|
||||
let sum = 0;
|
||||
let nonZeroWeights = 0;
|
||||
weights.forEach(weight => {
|
||||
sum += parseInt(weight.value) || 0;
|
||||
if (parseInt(weight.value) > 0) {
|
||||
nonZeroWeights++;
|
||||
}
|
||||
});
|
||||
if (nonZeroWeights * 100 !== sum) {
|
||||
weightSum.textContent = `Total: ${sum} (Total weights must be ${nonZeroWeights * 100})`;
|
||||
weightSum.style.color = 'red';
|
||||
} else {
|
||||
weightSum.textContent = 'Total: ' + sum;
|
||||
weightSum.style.color = 'green';
|
||||
}
|
||||
}
|
||||
|
||||
weights.forEach(weight => {
|
||||
weight.addEventListener('input', calculateSum);
|
||||
});
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(form);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
formData.getAll('tconst').forEach(tconst => {
|
||||
params.append('tconst', tconst);
|
||||
});
|
||||
if (formData.get('min-votes') !== '') {
|
||||
params.append('min_votes', formData.get('min-votes'));
|
||||
}
|
||||
if (formData.get('max-votes') !== '') {
|
||||
params.append('max_votes', formData.get('max-votes'));
|
||||
}
|
||||
if (formData.get('min-year') !== '') {
|
||||
params.append('min_year', formData.get('min-year'));
|
||||
}
|
||||
if (formData.get('max-year') !== '') {
|
||||
params.append('max_year', formData.get('max-year'));
|
||||
}
|
||||
if (formData.get('min-rating') !== '') {
|
||||
params.append('min_rating', formData.get('min-rating'));
|
||||
}
|
||||
if (formData.get('max-rating') !== '') {
|
||||
params.append('max_rating', formData.get('max-rating'));
|
||||
}
|
||||
if (formData.get('year-weight') !== '') {
|
||||
params.append('year_weight', formData.get('year-weight'));
|
||||
}
|
||||
if (formData.get('rating-weight') !== '') {
|
||||
params.append('rating_weight', formData.get('rating-weight'));
|
||||
}
|
||||
if (formData.get('genres-weight') !== '') {
|
||||
params.append('genres_weight', formData.get('genres-weight'));
|
||||
}
|
||||
if (formData.get('nconsts-weight') !== '') {
|
||||
params.append('nconsts_weight', formData.get('nconsts-weight'));
|
||||
}
|
||||
if (formData.get('n') !== '') {
|
||||
params.append('n', formData.get('n'));
|
||||
}
|
||||
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
const responseErr = document.querySelector('.response-err');
|
||||
const responseContainer = document.querySelector('.response');
|
||||
|
||||
async function fetchRecommendations() {
|
||||
responseErr.textContent = '';
|
||||
try {
|
||||
const response = await fetch(`{{ .BaseURL }}/recs?${queryString}`, { method: 'GET' });
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status === 200) {
|
||||
const responseTable = document.createElement('table');
|
||||
responseTable.className = 'table';
|
||||
|
||||
const responseTableHead = document.createElement('thead');
|
||||
const responseTableHeadRow = document.createElement('tr');
|
||||
responseTableHead.appendChild(responseTableHeadRow);
|
||||
const responseTableHeadCellID = document.createElement('th');
|
||||
responseTableHeadCellID.scope = 'col';
|
||||
responseTableHeadCellID.textContent = '#';
|
||||
responseTableHeadRow.appendChild(responseTableHeadCellID);
|
||||
const responseTableHeadCellTconst = document.createElement('th');
|
||||
responseTableHeadCellTconst.scope = 'col';
|
||||
responseTableHeadCellTconst.textContent = 'tconst';
|
||||
responseTableHeadRow.appendChild(responseTableHeadCellTconst);
|
||||
for (let i = 1; i <= data[0].weights.length; i++) {
|
||||
const responseTableHeadCellWeight = document.createElement('th');
|
||||
responseTableHeadCellWeight.scope = 'col';
|
||||
responseTableHeadCellWeight.textContent = i;
|
||||
responseTableHeadRow.appendChild(responseTableHeadCellWeight);
|
||||
}
|
||||
responseTable.appendChild(responseTableHead);
|
||||
|
||||
|
||||
const responseTableBody = document.createElement('tbody');
|
||||
responseTable.appendChild(responseTableBody);
|
||||
|
||||
let rowIndex = 1;
|
||||
for (const d of data) {
|
||||
const row = document.createElement('tr');
|
||||
const rowIndexElement = document.createElement('th');
|
||||
rowIndexElement.scope = 'row';
|
||||
rowIndexElement.textContent = rowIndex;
|
||||
rowIndex++;
|
||||
row.appendChild(rowIndexElement);
|
||||
|
||||
const cellTconst = document.createElement('td');
|
||||
const cellTconstText = document.createElement('a');
|
||||
cellTconstText.href = `https://www.imdb.com/title/${d.tconst}/`;
|
||||
cellTconstText.target = '_blank';
|
||||
cellTconstText.textContent = d.tconst;
|
||||
cellTconst.appendChild(cellTconstText);
|
||||
row.appendChild(cellTconst);
|
||||
for (const c of d.weights) {
|
||||
const cell = document.createElement('td');
|
||||
cell.textContent = c;
|
||||
row.appendChild(cell);
|
||||
}
|
||||
responseTableBody.appendChild(row);
|
||||
}
|
||||
responseContainer.innerHTML = responseTable.outerHTML;
|
||||
} else if (response.status === 400 || response.status === 404) {
|
||||
const errorMessage = data.error || "An error occurred";
|
||||
responseErr.textContent = errorMessage;
|
||||
} else {
|
||||
console.error("Error:", response.status, response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
fetchRecommendations();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
11
server/pkg/utils/env.go
Normal file
11
server/pkg/utils/env.go
Normal file
@ -0,0 +1,11 @@
|
||||
package utils
|
||||
|
||||
import "os"
|
||||
|
||||
func GetEnv(key, default_ string) string {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
return default_
|
||||
}
|
||||
return value
|
||||
}
|
16
server/pkg/utils/file.go
Normal file
16
server/pkg/utils/file.go
Normal file
@ -0,0 +1,16 @@
|
||||
package utils
|
||||
|
||||
import "os"
|
||||
|
||||
func MakeDirIfNotExist(path string) error {
|
||||
return os.MkdirAll(path, os.ModePerm)
|
||||
}
|
||||
|
||||
func IsDirExist(path string) (bool, error) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return true, nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
20
server/pkg/utils/validation.go
Normal file
20
server/pkg/utils/validation.go
Normal file
@ -0,0 +1,20 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"math"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func IsValidPath(path string) bool {
|
||||
return filepath.IsAbs(path)
|
||||
}
|
||||
|
||||
func IsUint32(value int) bool {
|
||||
return value >= 0 && value <= math.MaxUint32
|
||||
}
|
||||
|
||||
func IsInt(value string) bool {
|
||||
_, err := strconv.Atoi(value)
|
||||
return err == nil
|
||||
}
|
Reference in New Issue
Block a user