Rewritten in go and python

This commit is contained in:
2024-11-06 01:25:27 +04:00
parent 9f22d9678d
commit d8449237bb
50 changed files with 3824 additions and 879 deletions

1
server/.dockerignore Normal file
View File

@ -0,0 +1 @@
data

1
server/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/data/

16
server/Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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")
}

View 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
View 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
View 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
View 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
View 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
View 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,
)
}

View 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)
})
}

View 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)
}
}

View 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
}

View 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",
}

View 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
}

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS imdb;

View File

@ -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
);

View 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
}

View 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
View 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
View 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
}

View 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
}