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