diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 6c37cd7..dd1d447 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -14,22 +14,20 @@ permissions: read-all jobs: build: - - runs-on: ubuntu-24.04-arm steps: - uses: actions/checkout@v4 - name: Buildah Action uses: redhat-actions/buildah-build@v2 with: - image: phantom + image: ghcr.io/${{ github.repository }} tags: ${{ github.sha }} containerfiles: | ./Containerfile extra-args: | --target=build - name: Run go vet - run: podman run phantom:${{ github.sha }} go vet -v ./... + run: podman run ghcr.io/${{ github.repository }}:${{ github.sha }} go vet -v ./... - name: Run go test - run: podman run phantom:${{ github.sha }} go test -v ./... + run: podman run ghcr.io/${{ github.repository }}:${{ github.sha }} go test -v ./... diff --git a/Containerfile b/Containerfile index 435a847..29500db 100644 --- a/Containerfile +++ b/Containerfile @@ -2,7 +2,7 @@ FROM docker.io/golang:1.24.2-alpine3.21 AS build WORKDIR /app COPY . . RUN apk add build-base musl-dev opencv-dev icu-libs --repository=https://dl-cdn.alpinelinux.org/alpine/edge/community -RUN go build main.go +RUN go build cmd/main.go FROM docker.io/alpine:3.21 WORKDIR /app diff --git a/README.md b/README.md index d2d62e2..83d6ec9 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,20 @@ stream video from usb device over the web In order to install opencv4 you can either rely on your system package manager or compile it directly using: ```bash -pushd $GOPATH/gocv.io/x/gocv +cd $GOPATH/gocv.io/x/gocv make install -popd ``` +## Getting started +Build the container + +```bash +podman build -t phantom:$(git describe) . +``` + +Run the container + +```bash +podman run --device /dev/video0 -p 8080:8080 localhost/phantom:$(git describe) /app/main +``` diff --git a/api/middlewares/recovery.go b/api/middlewares/recovery.go deleted file mode 100644 index 95d61c5..0000000 --- a/api/middlewares/recovery.go +++ /dev/null @@ -1,23 +0,0 @@ -package middlewares - -import ( - "log" - "net/http" -) - -func Recovery(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer func() { - err := recover() - if err != nil { - log.Println(err) - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("{\"error\": \"Internal Server Error\"}")) - } - }() - - next.ServeHTTP(w, r) - }) -} diff --git a/api/server.go b/api/server.go deleted file mode 100644 index 04ac067..0000000 --- a/api/server.go +++ /dev/null @@ -1,36 +0,0 @@ -package api - -import ( - "log/slog" - "net/http" - "os" - - "github.com/bugbundle/phantom/api/middlewares" - "github.com/bugbundle/phantom/api/routes" -) - -func Server(addr string) { - // Configure default logging to JSON - logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) - slog.SetDefault(logger) - - router := http.NewServeMux() - - router.HandleFunc("/", routes.Homepage) - router.HandleFunc("POST /cameras", routes.CreateCamera) - router.HandleFunc("GET /cameras/status", routes.StreamStatus) - router.HandleFunc("DELETE /cameras", routes.DeleteCamera) - router.HandleFunc("GET /cameras", routes.StreamVideo) - - server_config := &http.Server{ - Addr: addr, - Handler: middlewares.LoggingHandler( - middlewares.Recovery(router), - ), - } - - slog.Info("Starting server...", "interface", addr) - if err := server_config.ListenAndServe(); err != nil { - slog.Error("An error occured !", "interface", addr, "error", err) - } -} diff --git a/api/templates/index.html.tpl b/api/templates/index.html.tpl deleted file mode 100644 index 37270d4..0000000 --- a/api/templates/index.html.tpl +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - Phantom dashboard - - - - -
-
- -
-
-
-
- - diff --git a/cmd/main.go b/cmd/main.go index 490aee2..e95e196 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,23 +1,9 @@ -package cmd +package main import ( - "fmt" - "os" - - "github.com/spf13/cobra" + "github.com/bugbundle/phantom/internal/adapter/http" ) -var rootCmd = &cobra.Command{ - Use: "phantom", - Short: "phantom is used to stream video device over internet using multipart", - Run: func(cmd *cobra.Command, args []string) { - cmd.Usage() - }, -} - -func Execute() { - if err := rootCmd.Execute(); err != nil { - fmt.Println(err) - os.Exit(1) - } +func main() { + http.Server("0.0.0.0:8080") } diff --git a/cmd/server.go b/cmd/server.go deleted file mode 100644 index 1147220..0000000 --- a/cmd/server.go +++ /dev/null @@ -1,25 +0,0 @@ -package cmd - -import ( - "log" - - "github.com/bugbundle/phantom/api" - "github.com/spf13/cobra" -) - -func init() { - rootCmd.AddCommand(serverCmd) - serverCmd.Flags().String("addr", "127.0.0.1:8080", "Listening interface") -} - -var serverCmd = &cobra.Command{ - Use: "server", - Short: "Run phantom in server mode", - Run: func(cmd *cobra.Command, args []string) { - addr, err := cmd.Flags().GetString("addr") - if err != nil { - log.Printf("Can't retrieve addr flag.") - } - api.Server(addr) - }, -} diff --git a/go.mod b/go.mod index bcbcac0..b1f7012 100644 --- a/go.mod +++ b/go.mod @@ -2,12 +2,4 @@ module github.com/bugbundle/phantom go 1.22.2 -require ( - github.com/spf13/cobra v1.9.1 - gocv.io/x/gocv v0.41.0 -) - -require ( - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/spf13/pflag v1.0.6 // indirect -) +require gocv.io/x/gocv v0.41.0 diff --git a/go.sum b/go.sum index 3abaa46..ffdc39e 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,2 @@ -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= gocv.io/x/gocv v0.41.0 h1:KM+zRXUP28b6dHfhy+4JxDODbCNQNtLg8kio+YE7TqA= gocv.io/x/gocv v0.41.0/go.mod h1:zYdWMj29WAEznM3Y8NsU3A0TRq/wR/cy75jeUypThqU= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/adapter/camera/camera.go b/internal/adapter/camera/camera.go new file mode 100644 index 0000000..522ea51 --- /dev/null +++ b/internal/adapter/camera/camera.go @@ -0,0 +1 @@ +package camera diff --git a/internal/adapter/http/http.go b/internal/adapter/http/http.go new file mode 100644 index 0000000..08dff3c --- /dev/null +++ b/internal/adapter/http/http.go @@ -0,0 +1,51 @@ +package http + +import ( + "fmt" + "log/slog" + "net/http" + + "github.com/bugbundle/phantom/internal/adapter/logger" + httpRoutes "github.com/bugbundle/phantom/internal/app/http" +) + +func Recovery(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + err := recover() + if err != nil { + slog.Error(fmt.Sprint(err)) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("{\"error\": \"Internal Server Error\"}")) + } + }() + + next.ServeHTTP(w, r) + }) +} + +func Server(addr string) { + fmt.Println("here") + router := http.NewServeMux() + + router.HandleFunc("/", httpRoutes.Homepage) + router.HandleFunc("POST /cameras", httpRoutes.CreateCamera) + router.HandleFunc("GET /cameras/status", httpRoutes.StreamStatus) + router.HandleFunc("DELETE /cameras", httpRoutes.DeleteCamera) + router.HandleFunc("GET /cameras", httpRoutes.StreamVideo) + + fmt.Println("hore") + server_config := &http.Server{ + Addr: addr, + Handler: logger.LoggingHandler( + Recovery(router), + ), + } + + slog.Info("Starting server...", "interface", addr) + if err := server_config.ListenAndServe(); err != nil { + slog.Error("An error occured !", "interface", addr, "error", err) + } +} diff --git a/api/middlewares/logging.go b/internal/adapter/logger/logger.go similarity index 83% rename from api/middlewares/logging.go rename to internal/adapter/logger/logger.go index ed97ee3..33e0c8d 100644 --- a/api/middlewares/logging.go +++ b/internal/adapter/logger/logger.go @@ -1,9 +1,10 @@ -package middlewares +package logger import ( "context" "log/slog" "net/http" + "os" ) type statusResponseWriter struct { @@ -11,6 +12,11 @@ type statusResponseWriter struct { statusCode int } +func init() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + slog.SetDefault(logger) +} + func LoggingHandler(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { statusResponseWriter := &statusResponseWriter{ diff --git a/api/routes/stream.go b/internal/app/http/http.go similarity index 92% rename from api/routes/stream.go rename to internal/app/http/http.go index 54e67aa..78eb1c8 100644 --- a/api/routes/stream.go +++ b/internal/app/http/http.go @@ -1,4 +1,4 @@ -package routes +package http import ( "fmt" @@ -10,14 +10,14 @@ import ( "net/textproto" "time" - "github.com/bugbundle/phantom/api/utils" + "github.com/bugbundle/phantom/internal/port/camera" "gocv.io/x/gocv" ) // Return default Homepage, a simple alpineJS application to users func Homepage(w http.ResponseWriter, r *http.Request) { w.Header().Add("Content-Type", "text/html") - template, err := template.ParseFiles("api/templates/index.html.tpl") + template, err := template.ParseFiles("templates/index.html.tpl") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -30,20 +30,19 @@ func Homepage(w http.ResponseWriter, r *http.Request) { func StreamStatus(w http.ResponseWriter, r *http.Request) { // If the camera is unavailable return 428 - _, err := utils.GetCamera() + _, err := camera.GetCamera() if err != nil { w.Write([]byte("false")) return } w.Write([]byte("true")) - return } // This function retrieve camera device and start streaming using multipart/x-mixed-replace // TODO: Add device number option func StreamVideo(w http.ResponseWriter, r *http.Request) { // If the camera is unavailable return 428 - webcam, err := utils.GetCamera() + webcam, err := camera.GetCamera() if err != nil { w.Header().Set("Content-Type", "application/json") http.Error(w, "{\"reason\": \"webcam is not started\"}", http.StatusPreconditionRequired) @@ -100,7 +99,7 @@ func StreamVideo(w http.ResponseWriter, r *http.Request) { // TODO: Add device number option func CreateCamera(w http.ResponseWriter, r *http.Request) { // Trigger singleton to instanciate camera - utils.CreateOrGetCamera() + camera.CreateOrGetCamera() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) } @@ -108,7 +107,7 @@ func CreateCamera(w http.ResponseWriter, r *http.Request) { // Delete Camera using DELETE request // TODO: Add device number option func DeleteCamera(w http.ResponseWriter, r *http.Request) { - utils.DeleteCamera() + camera.DeleteCamera() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) } diff --git a/internal/domain/camera/camera.go b/internal/domain/camera/camera.go new file mode 100644 index 0000000..522ea51 --- /dev/null +++ b/internal/domain/camera/camera.go @@ -0,0 +1 @@ +package camera diff --git a/api/utils/camera.go b/internal/port/camera/camera.go similarity index 90% rename from api/utils/camera.go rename to internal/port/camera/camera.go index 517d237..8f9df94 100644 --- a/api/utils/camera.go +++ b/internal/port/camera/camera.go @@ -1,4 +1,4 @@ -package utils +package camera import ( "errors" @@ -7,13 +7,26 @@ import ( "gocv.io/x/gocv" ) -var instance *WebCamSingleton - type WebCamSingleton struct { - capture *gocv.VideoCapture + Capture *gocv.VideoCapture isOpen bool } +var instance *WebCamSingleton + +// CaptureImage captures an image from the webcam and returns it. +func (wc *WebCamSingleton) CaptureImage() (*gocv.Mat, error) { + if !wc.IsOpen() { + return nil, fmt.Errorf("webcam is not open") + } + + img := gocv.NewMat() + if ok := wc.Capture.Read(&img); !ok { + return nil, fmt.Errorf("failed to read image from webcam") + } + return &img, nil +} + func GetCamera() (*WebCamSingleton, error) { if instance != nil { return instance, nil @@ -54,7 +67,7 @@ func (wc *WebCamSingleton) Open() error { return fmt.Errorf("error opening video capture device: %v", err) } - wc.capture = capture + wc.Capture = capture wc.isOpen = true return nil } @@ -66,19 +79,6 @@ func (wc *WebCamSingleton) Stop() { return } - wc.capture.Close() + wc.Capture.Close() wc.isOpen = false } - -// CaptureImage captures an image from the webcam and returns it. -func (wc *WebCamSingleton) CaptureImage() (*gocv.Mat, error) { - if !wc.isOpen { - return nil, fmt.Errorf("webcam is not open") - } - - img := gocv.NewMat() - if ok := wc.capture.Read(&img); !ok { - return nil, fmt.Errorf("failed to read image from webcam") - } - return &img, nil -} diff --git a/main.go b/main.go deleted file mode 100644 index a41c339..0000000 --- a/main.go +++ /dev/null @@ -1,9 +0,0 @@ -package main - -import ( - "github.com/bugbundle/phantom/cmd" -) - -func main() { - cmd.Execute() -} diff --git a/templates/index.html.tpl b/templates/index.html.tpl new file mode 100644 index 0000000..c50d8b8 --- /dev/null +++ b/templates/index.html.tpl @@ -0,0 +1,30 @@ + + + + + + Phantom dashboard + + + +
+ +
+ + +
+
+ + +