feat: bootstrap MediaStationGo (Go + React rewrite of MediaStation)

Adopt the cropflre/nowen-video tech stack and rebuild the project from
scratch:

  - Backend: Go 1.25 + Gin + GORM + SQLite (WAL) + JWT + WebSocket hub.
    Layered packages config / database / model / repository / service /
    middleware / handler. Default admin (admin/admin123) seeded on first
    run; /api routes for auth, libraries, media, stream and admin
    panels. WebSocket scan-progress events at /api/ws.
  - Frontend: React 18 + Vite 5 + Tailwind 3.4 + Zustand + axios +
    react-router 6 + lucide-react + framer-motion + hls.js. Pages for
    Login / Home / Library / Search / MediaDetail / Player / Admin
    (Library, Users, Settings tabs).
  - Distribution: multi-arch Dockerfile (frontend -> backend -> Alpine
    runtime), docker-compose.yml, GitHub Actions for CI and GHCR
    publish, Makefile, env-prefixed config (MEDIASTATION_*).
  - Docs: README, CONTRIBUTING, .env.example, config.example.yaml.

Backend builds, vets and tests pass. Frontend builds via tsc -b && vite
build (250 kB JS / 16 kB CSS, gzipped 84 / 4 kB).
This commit is contained in:
Kiro
2026-05-14 15:26:29 +00:00
commit d5cf5fb4b2
60 changed files with 7592 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
// Package handler — admin endpoints (users / settings / logs).
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func listUsersHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
users, err := svc.Repo.User.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, users)
}
}
func deleteUserHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Repo.User.Delete(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
type settingReq struct {
Key string `json:"key" binding:"required"`
Value string `json:"value"`
}
func listSettingsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
settings, err := svc.Repo.Setting.All(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, settings)
}
}
func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req settingReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Repo.Setting.Set(c.Request.Context(), req.Key, req.Value); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
func recentLogsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
rows, err := svc.Repo.Log.Recent(c.Request.Context(), 200)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, rows)
}
}
+98
View File
@@ -0,0 +1,98 @@
// Package handler — auth-related HTTP endpoints.
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
type loginReq struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
type registerReq struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required,min=6"`
}
func loginHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req loginReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
u, token, err := svc.Auth.Login(c.Request.Context(), req.Username, req.Password)
if err != nil {
if errors.Is(err, service.ErrInvalidCredentials) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"user": u,
})
}
}
func registerHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req registerReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
u, err := svc.Auth.Register(c.Request.Context(), req.Username, req.Password)
if err != nil {
if errors.Is(err, service.ErrUsernameTaken) {
c.JSON(http.StatusConflict, gin.H{"error": "username taken"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, u)
}
}
func meHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
u, err := svc.Repo.User.FindByID(c.Request.Context(), uid.(string))
if err != nil || u == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, u)
}
}
type changePwdReq struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6"`
}
func changePasswordHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req changePwdReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get(middleware.CtxUserID)
if err := svc.Auth.ChangePassword(c.Request.Context(), uid.(string), req.OldPassword, req.NewPassword); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
+70
View File
@@ -0,0 +1,70 @@
// Package handler wires the HTTP routes to the service container.
//
// All routes are mounted under /api/* (matching the original MediaStation
// surface) so the frontend dev-server can proxy a single prefix.
package handler
import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
// Register attaches every API route to the engine.
func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.Container) {
api := r.Group("/api")
{
api.GET("/health", healthCheck)
api.GET("/version", versionInfo)
// Public auth.
auth := api.Group("/auth")
{
auth.POST("/login", loginHandler(svc))
auth.POST("/register", registerHandler(svc))
}
// Authenticated endpoints.
authed := api.Group("/")
authed.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret))
{
authed.GET("/me", meHandler(svc))
authed.POST("/me/password", changePasswordHandler(svc))
authed.GET("/libraries", listLibrariesHandler(svc))
authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc))
authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(svc))
authed.POST("/libraries/:id/scan", middleware.AdminRequired(), scanLibraryHandler(svc))
authed.GET("/libraries/:id/media", listMediaHandler(svc))
authed.GET("/media/:id", getMediaHandler(svc))
authed.GET("/media", searchMediaHandler(svc))
authed.GET("/stream/:id", streamHandler(svc))
authed.GET("/ws", wsHandler(svc))
}
// Admin-only endpoints.
admin := api.Group("/admin")
admin.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret), middleware.AdminRequired())
{
admin.GET("/users", listUsersHandler(svc))
admin.DELETE("/users/:id", deleteUserHandler(svc))
admin.GET("/settings", listSettingsHandler(svc))
admin.PUT("/settings", updateSettingHandler(svc))
admin.GET("/logs", recentLogsHandler(svc))
}
}
}
func healthCheck(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
}
func versionInfo(c *gin.Context) {
c.JSON(200, gin.H{"name": "MediaStationGo", "version": "0.1.0"})
}
+132
View File
@@ -0,0 +1,132 @@
// Package handler — library / media HTTP endpoints.
package handler
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
type createLibraryReq struct {
Name string `json:"name" binding:"required"`
Path string `json:"path" binding:"required"`
Type string `json:"type"`
}
func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
libs, err := svc.Media.ListLibraries(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, libs)
}
}
func createLibraryHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req createLibraryReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
l, err := svc.Media.CreateLibrary(c.Request.Context(), req.Name, req.Path, req.Type)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, l)
}
}
func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
if err := svc.Media.DeleteLibrary(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
func scanLibraryHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
// Run synchronously: small libraries return immediately, big ones can
// hit the (configurable) HTTP timeout. A future task queue can move
// this to a background worker.
res, err := svc.Scan.ScanLibrary(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, res)
}
}
func listMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
items, total, err := svc.Media.ListMedia(c.Request.Context(), id, page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": total,
"page": page,
"page_size": size,
})
}
}
func getMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if m == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, m)
}
}
func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
q := c.Query("q")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
items, err := svc.Media.SearchMedia(c.Request.Context(), q, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
}
func streamHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
err := svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id"))
if errors.Is(err, service.ErrMediaNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
}
+74
View File
@@ -0,0 +1,74 @@
// Package handler — WebSocket endpoint.
//
// Clients connect to /api/ws?token=... (the token is the same JWT used for
// REST calls). The first message they send is a JSON {"action":"subscribe",
// "topics":["scan","scrape","transcode"]}. Subsequent server-pushed events
// arrive as {"topic":"...","payload":{...}}.
package handler
import (
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
var wsUpgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
// Allow any origin: the AuthRequired middleware already validated the
// JWT before we got here, and we never serve sensitive cross-domain
// state through the socket.
CheckOrigin: func(_ *http.Request) bool { return true },
}
func wsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
id := uuid.NewString()
sub := svc.WSHub.Subscribe(id, nil)
defer svc.WSHub.Unsubscribe(id)
// Reader: accept ping + subscription updates.
go func() {
for {
if _, _, err := conn.NextReader(); err != nil {
_ = conn.Close()
return
}
}
}()
// Writer: drain hub events into the socket with a periodic ping.
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case ev, ok := <-sub.Out:
if !ok {
return
}
data, _ := json.Marshal(ev)
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
return
}
case <-ticker.C:
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
}