This commit is contained in:
Venkatesan Ravi
2025-10-31 12:44:32 -07:00
parent 0384e6c0b4
commit 7a20ea0c72
31 changed files with 41510 additions and 1094 deletions

View File

@@ -4,10 +4,22 @@ root = true
indent_style = space
indent_size = 4
[*.json]
indent_style = space
indent_size = 2
[*.nix]
indent_style = space
indent_size = 2
[*.ts]
indent_style = space
indent_size = 2
[*.yaml]
indent_style = space
indent_size = 2
[*.yml]
indent_style = space
indent_size = 2

1
.gitignore vendored
View File

@@ -1 +1,2 @@
node_modules
result

4
config.yml Normal file
View File

@@ -0,0 +1,4 @@
version: 1
base_url: "http://10.0.0.76:8096"
system:
enableMetrics: true

20
esbuild.ts Normal file
View File

@@ -0,0 +1,20 @@
// esbuild.cjs
const esbuild = require("esbuild");
async function main() {
await esbuild.build({
entryPoints: ["./src/cli.ts"],
bundle: true,
sourcemap: "inline",
platform: "node",
target: "node22",
format: "cjs",
outfile: "bundle.cjs",
logLevel: "info",
});
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -40,7 +40,10 @@
formatter = (treefmt-nix.lib.evalModule pkgs ./treefmt.nix).config.build.wrapper;
packages.default = import ./nix/package.nix {inherit pkgs;};
packages.default = import ./nix/package.nix {
inherit pkgs;
inherit (pkgs) lib;
};
};
};
}

38898
generated/schema.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

17
go.mod
View File

@@ -1,17 +0,0 @@
module jellarr
go 1.24.6
require (
github.com/onsi/gomega v1.38.2
github.com/sj14/jellyfin-go v0.4.1
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/google/go-cmp v0.7.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/text v0.28.0 // indirect
gopkg.in/validator.v2 v2.0.1 // indirect
)

39
go.sum
View File

@@ -1,39 +0,0 @@
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/onsi/ginkgo/v2 v2.25.1 h1:Fwp6crTREKM+oA6Cz4MsO8RhKQzs2/gOIVOUscMAfZY=
github.com/onsi/ginkgo/v2 v2.25.1/go.mod h1:ppTWQ1dh9KM/F1XgpeRqelR+zHVwV81DGRSDnFxK7Sk=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/sj14/jellyfin-go v0.4.1 h1:q+ra25LU+Ids6iLzEJZ4KFM46Z5gwz1XYZiCeZtik5c=
github.com/sj14/jellyfin-go v0.4.1/go.mod h1:K3ozYgrTZF4403JWijLJyNDlGm+nkNZvfU5YRC+GYYc=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/validator.v2 v2.0.1 h1:xF0KWyGWXm/LM2G1TrEjqOu4pa6coO9AlWSf3msVfDY=
gopkg.in/validator.v2 v2.0.1/go.mod h1:lIUZBlB3Im4s/eYp39Ry/wkR02yOPhZ9IwIRBjuPuG8=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,24 +1,52 @@
{pkgs}:
pkgs.buildGoModule {
{
lib,
pkgs,
...
}:
pkgs.stdenvNoCC.mkDerivation (finalAttrs: {
buildPhase = ''
runHook preBuild
pnpm build
runHook postBuild
'';
checkPhase = ''
runHook preCheck
export HOME="$TMPDIR"
export GOCACHE="$TMPDIR/go-cache"
go test ./src/cmd/jellarr/... ./src/tests/...
pnpm test
runHook postCheck
'';
doCheck = true;
ldflags = ["-s" "-w"];
meta = with pkgs.lib; {
description = "Declarative Jellyfin configuration engine (typed Go client)";
installPhase = ''
runHook preInstall
install -Dm644 -t $out/share bundle.cjs
makeWrapper ${lib.getExe pkgs.nodejs_24} $out/bin/jellarr \
--add-flags "$out/share/bundle.cjs"
runHook postInstall
'';
meta = {
description = "Declarative Jellyfin configuration engine (TypeScript, bundled)";
homepage = "https://github.com/venkyr77/jellarr";
license = licenses.agpl3Only;
license = lib.licenses.agpl3Only;
mainProgram = "jellarr";
platforms = platforms.linux ++ platforms.darwin;
platforms = lib.platforms.all;
};
nativeBuildInputs = [
pkgs.makeBinaryWrapper
pkgs.nodejs_24
pkgs.pnpm.configHook
];
pname = "jellarr";
pnpmDeps = pkgs.pnpm.fetchDeps {
fetcherVersion = 1;
hash = "sha256-xhTEHi8UVeYD/OtTQMtFYk6SqX43+Tx73tCOoyCwOyw=";
inherit (finalAttrs) pname src version;
};
src = ../.;
subPackages = ["src/cmd/jellarr"];
vendorHash = "sha256-aIUKXAmLtq3bXesEVndQxLAFKmDmIWiEYhM1P6+IMKg=";
version = "0.1.0";
}
})

26
package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"devDependencies": {
"@types/node": "^24.9.2",
"@vitest/coverage-v8": "^4.0.6",
"esbuild": "^0.25.11",
"eslint": "^9.38.0",
"tsx": "^4.20.6",
"typescript": "^5.9.3",
"vitest": "^4.0.6"
},
"dependencies": {
"commander": "^14.0.2",
"dotenv": "^17.2.3",
"openapi-fetch": "^0.15.0",
"undici": "^7.16.0",
"yaml": "^2.8.1",
"zod": "^4.1.12"
},
"scripts": {
"build": "tsx esbuild.ts",
"dev": "tsx src/cli.ts",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"typegen": "npx openapi-typescript https://api.jellyfin.org/openapi/jellyfin-openapi-stable.json -o ./generated/schema.d.ts"
}
}

2401
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

18
src/cli.ts Normal file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env node
import { Command } from "commander";
import { applySystem } from "./system";
const program = new Command();
program
.name("jellarr-ts")
.description("Minimal Jellyfin config applier")
.option("-c, --config <path>", "YAML config file", "config.yml")
.action(async (opts) => {
await applySystem(opts.config);
});
program.parseAsync().catch((err) => {
console.error(err);
process.exit(1);
});

22
src/client.ts Normal file
View File

@@ -0,0 +1,22 @@
import createClient from "openapi-fetch";
import type { paths } from "../generated/schema";
export function makeClient(baseUrl: string, apiKey: string) {
const client = createClient<paths>({
baseUrl: baseUrl.replace(/\/+$/, ""),
});
client.use({
async onRequest({ request }) {
const headers = new Headers(request.headers);
headers.set("X-Emby-Token", apiKey);
headers.set(
"X-Emby-Authorization",
'MediaBrowser Client="jellarr-ts", Device="cli", Version="0.0.1"',
);
return new Request(request, { headers });
},
});
return client;
}

View File

@@ -1,49 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"jellarr/src/internal/api"
"jellarr/src/internal/apply"
"jellarr/src/internal/config"
)
var (
cfgPath = "config/config.yml"
applyAll = apply.ApplyAll
)
func getConfigFilePath() string {
fs := flag.NewFlagSet("jellarr", flag.ContinueOnError)
cfg := cfgPath
fs.StringVar(&cfg, "configFile", cfgPath, "path to config file")
fs.Parse(os.Args[1:])
return cfg
}
func run() error {
cfg, err := config.Load(getConfigFilePath())
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
key := os.Getenv("JELLARR_API_KEY")
if key == "" {
return fmt.Errorf("set JELLARR_API_KEY")
}
jf := api.New(cfg.BaseUrl, key)
return applyAll(context.Background(), jf, cfg)
}
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
fmt.Println("✅ jellarr apply complete")
}

View File

@@ -1,92 +0,0 @@
package main
import (
"context"
"os"
"testing"
. "github.com/onsi/gomega"
"jellarr/src/internal/api"
"jellarr/src/internal/config"
)
func withArgs(args []string, fn func()) {
orig := os.Args
defer func() { os.Args = orig }()
os.Args = args
fn()
}
func TestGetConfigWithDefaultFilePath(t *testing.T) {
// Arrange
g := NewWithT(t)
// Act
withArgs([]string{"jellarr"}, func() {
path := getConfigFilePath()
// Assert
g.Expect(path).To(Equal(cfgPath)) // default is "config/config.yml"
})
}
func TestGetConfigWithOverridenFilePath(t *testing.T) {
// Arrange
g := NewWithT(t)
override := "/tmp/test.yml"
// Act
withArgs([]string{"jellarr", "--configFile", override}, func() {
path := getConfigFilePath()
// Assert
g.Expect(path).To(Equal(override))
})
}
func TestRunWithNoApiKey(t *testing.T) {
// Arrange
g := NewWithT(t)
_ = os.Unsetenv("JELLARR_API_KEY")
sample := "../../../sample-config.yml"
var err error
// Act
withArgs([]string{"jellarr", "--configFile", sample}, func() {
err = run()
})
// Assert
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring("JELLARR_API_KEY"))
}
func TestRunHappyPath(t *testing.T) {
// Arrange
g := NewWithT(t)
os.Setenv("JELLARR_API_KEY", "dummy")
defer os.Unsetenv("JELLARR_API_KEY")
called := false
orig_applyAll := applyAll
defer func() { applyAll = orig_applyAll }()
applyAll = func(_ context.Context, _ api.JF, _ *config.Config) error {
called = true
return nil
}
sample := "../../../sample-config.yml"
var err error
// Act
withArgs([]string{"jellarr", "--configFile", sample}, func() {
err = run()
})
// Assert
g.Expect(err).NotTo(HaveOccurred())
g.Expect(called).To(BeTrue(), "run should invoke applyAll when everything is configured correctly")
}

View File

@@ -1,33 +0,0 @@
package api
import (
"context"
"fmt"
"net/http"
jellyfin "github.com/sj14/jellyfin-go/api"
)
type configurationAPI interface {
GetConfiguration(ctx context.Context) jellyfin.ApiGetConfigurationRequest
GetConfigurationExecute(r jellyfin.ApiGetConfigurationRequest) (*jellyfin.ServerConfiguration, *http.Response, error)
UpdateConfiguration(ctx context.Context) jellyfin.ApiUpdateConfigurationRequest
UpdateConfigurationExecute(r jellyfin.ApiUpdateConfigurationRequest) (*http.Response, error)
}
type Client struct {
Conf configurationAPI
}
func new(baseURL, apiKey string) JF {
cfg := &jellyfin.Configuration{
Servers: jellyfin.ServerConfigurations{{URL: baseURL}},
DefaultHeader: map[string]string{
"Authorization": fmt.Sprintf(`MediaBrowser Token="%s"`, apiKey),
},
}
ac := jellyfin.NewAPIClient(cfg)
return &Client{
Conf: ac.ConfigurationAPI,
}
}

View File

@@ -1,43 +0,0 @@
package api
import (
"context"
jellyfin "github.com/sj14/jellyfin-go/api"
"jellarr/src/internal/mapper"
"jellarr/src/internal/model"
)
func (cl *Client) GetSystem(ctx context.Context) (model.SystemState, error) {
req := cl.Conf.GetConfiguration(ctx)
cfg, _, err := cl.Conf.GetConfigurationExecute(req)
if err != nil {
return model.SystemState{}, err
}
return model.SystemState{
EnableMetrics: cfg.GetEnableMetrics(),
PluginRepositories: mapper.FromJFRepos(cfg.GetPluginRepositories()),
TrickplayOptions: mapper.FromJFTrickplayOptions(cfg.GetTrickplayOptions()),
}, nil
}
func (cl *Client) UpdateSystem(ctx context.Context, in model.SystemSpec) error {
body := jellyfin.ServerConfiguration{}
if in.EnableMetrics != nil {
body.EnableMetrics = in.EnableMetrics
}
if in.PluginRepositories != nil {
body.PluginRepositories = mapper.ToJFRepos(in.PluginRepositories)
}
if in.TrickplayOptions != nil {
body.TrickplayOptions = mapper.ToJFTrickplayOptions(*in.TrickplayOptions)
}
req := cl.Conf.UpdateConfiguration(ctx).ServerConfiguration(body)
_, err := cl.Conf.UpdateConfigurationExecute(req)
return err
}

View File

@@ -1,14 +0,0 @@
package api
import (
"context"
"jellarr/src/internal/model"
)
type JF interface {
GetSystem(ctx context.Context) (model.SystemState, error)
UpdateSystem(ctx context.Context, in model.SystemSpec) error
}
func New(baseURL, apiKey string) JF { return new(baseURL, apiKey) }

View File

@@ -1,12 +0,0 @@
package apply
import (
"context"
"jellarr/src/internal/api"
"jellarr/src/internal/config"
)
func ApplyAll(ctx context.Context, jf api.JF, cfg *config.Config) error {
return ApplySystem(ctx, jf, cfg.System)
}

View File

@@ -1,78 +0,0 @@
package apply
import (
"context"
"fmt"
"sort"
"jellarr/src/internal/api"
"jellarr/src/internal/model"
)
func ApplySystem(ctx context.Context, jf api.JF, desired model.SystemSpec) error {
cur, err := jf.GetSystem(ctx)
if err != nil {
return fmt.Errorf("get system: %w", err)
}
changed := false
if desired.EnableMetrics != nil && cur.EnableMetrics != *desired.EnableMetrics {
changed = true
}
if desired.PluginRepositories != nil && !EqualReposUnordered(cur.PluginRepositories, desired.PluginRepositories) {
changed = true
}
if desired.TrickplayOptions != nil && !AreTrickplayOptionsEqual(cur.TrickplayOptions, *desired.TrickplayOptions) {
changed = true
}
if changed {
fmt.Println("→ updating system config")
if err := jf.UpdateSystem(ctx, desired); err != nil {
return fmt.Errorf("update system failed: %w", err)
}
fmt.Println("✓ updated system config")
return nil
} else {
fmt.Println("✓ system config already up to date")
return nil
}
}
func EqualReposUnordered(a, b []model.PluginRepository) bool {
if len(a) != len(b) {
return false
}
ac := append([]model.PluginRepository(nil), a...)
bc := append([]model.PluginRepository(nil), b...)
SortRepos(ac)
SortRepos(bc)
for i := range ac {
if ac[i] != bc[i] {
return false
}
}
return true
}
func SortRepos(r []model.PluginRepository) {
sort.Slice(r, func(i, j int) bool {
if r[i].Name != r[j].Name {
return r[i].Name < r[j].Name
}
if r[i].URL != r[j].URL {
return r[i].URL < r[j].URL
}
if r[i].Enabled != r[j].Enabled {
return r[i].Enabled && !r[j].Enabled
}
return false
})
}
func AreTrickplayOptionsEqual(a, b model.TrickplayOptions) bool {
return a.EnableHwAcceleration == b.EnableHwAcceleration
}

View File

@@ -1,27 +0,0 @@
package config
import (
"os"
"gopkg.in/yaml.v3"
"jellarr/src/internal/model"
)
type Config struct {
Version int `yaml:"version"`
BaseUrl string `yaml:"base_url"`
System model.SystemSpec `yaml:"system"`
}
func Load(path string) (*Config, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var c Config
if err := yaml.Unmarshal(b, &c); err != nil {
return nil, err
}
return &c, nil
}

View File

@@ -1,46 +0,0 @@
package mapper
import (
jellyfin "github.com/sj14/jellyfin-go/api"
"jellarr/src/internal/model"
)
func ToJFRepos(in []model.PluginRepository) []jellyfin.RepositoryInfo {
out := make([]jellyfin.RepositoryInfo, 0, len(in))
for _, r := range in {
name := jellyfin.NewNullableString(&r.Name)
url := jellyfin.NewNullableString(&r.URL)
enabled := r.Enabled
out = append(out, jellyfin.RepositoryInfo{
Name: *name,
Url: *url,
Enabled: &enabled,
})
}
return out
}
func FromJFRepos(in []jellyfin.RepositoryInfo) []model.PluginRepository {
out := make([]model.PluginRepository, 0, len(in))
for _, r := range in {
out = append(out, model.PluginRepository{
Name: r.GetName(),
URL: r.GetUrl(),
Enabled: r.GetEnabled(),
})
}
return out
}
func FromJFTrickplayOptions(in jellyfin.TrickplayOptions) model.TrickplayOptions {
return model.TrickplayOptions{
EnableHwAcceleration: in.GetEnableHwAcceleration(),
}
}
func ToJFTrickplayOptions(in model.TrickplayOptions) *jellyfin.TrickplayOptions {
return &jellyfin.TrickplayOptions{
EnableHwAcceleration: &in.EnableHwAcceleration,
}
}

View File

@@ -1,23 +0,0 @@
package model
type PluginRepository struct {
Name string `yaml:"name"`
URL string `yaml:"url"`
Enabled bool `yaml:"enabled"`
}
type TrickplayOptions struct {
EnableHwAcceleration bool `yaml:"enableHwAcceleration"`
}
type SystemSpec struct {
EnableMetrics *bool `yaml:"enableMetrics,omitempty"`
PluginRepositories []PluginRepository `yaml:"pluginRepositories,omitempty"`
TrickplayOptions *TrickplayOptions `yaml:"trickplayOptions,omitempty"`
}
type SystemState struct {
EnableMetrics bool
PluginRepositories []PluginRepository
TrickplayOptions TrickplayOptions
}

43
src/system.ts Normal file
View File

@@ -0,0 +1,43 @@
import YAML from "yaml";
import { promises as fs } from "fs";
import { makeClient } from "./client";
interface Config {
version: number;
base_url: string;
system: { enableMetrics: boolean };
}
export async function applySystem(path: string) {
const cfg = YAML.parse(await fs.readFile(path, "utf8")) as Config;
const { base_url, system } = cfg;
const apiKey = process.env.JELLYFIN_API_KEY;
if (!apiKey) throw new Error("JELLYFIN_API_KEY required");
const jf = makeClient(base_url, apiKey);
const read = await jf.GET("/System/Configuration");
if (read.error) {
throw new Error(
`Failed to get config: ${read.response?.status ?? "unknown"}`,
);
}
const current = read.data;
if (current?.EnableMetrics === system.enableMetrics) {
console.log("✓ Already up to date");
return;
}
console.log("→ Updating EnableMetrics...");
const body = { ...(current as any), EnableMetrics: system.enableMetrics };
const write = await jf.POST("/System/Configuration", { body });
if (write.error) {
throw new Error(`Update failed: ${write.response?.status ?? "unknown"}`);
}
console.log("✓ Updated");
}

View File

@@ -1,159 +0,0 @@
package api_test
import (
"context"
"errors"
"net/http"
"testing"
. "github.com/onsi/gomega"
. "github.com/onsi/gomega/gstruct"
jellyfin "github.com/sj14/jellyfin-go/api"
"jellarr/src/internal/api"
"jellarr/src/internal/model"
)
type mockClient struct {
getCalled bool
updateCalled bool
err error
cfg *jellyfin.ServerConfiguration
}
func (m *mockClient) GetConfiguration(ctx context.Context) jellyfin.ApiGetConfigurationRequest {
m.getCalled = true
var r jellyfin.ApiGetConfigurationRequest
return r
}
func (m *mockClient) GetConfigurationExecute(_ jellyfin.ApiGetConfigurationRequest) (*jellyfin.ServerConfiguration, *http.Response, error) {
if m.err != nil {
return nil, nil, m.err
}
if m.cfg == nil {
m.cfg = &jellyfin.ServerConfiguration{}
}
return m.cfg, nil, nil
}
func (m *mockClient) UpdateConfiguration(ctx context.Context) jellyfin.ApiUpdateConfigurationRequest {
m.updateCalled = true
var r jellyfin.ApiUpdateConfigurationRequest
return r
}
func (m *mockClient) UpdateConfigurationExecute(_ jellyfin.ApiUpdateConfigurationRequest) (*http.Response, error) {
if m.err != nil {
return nil, m.err
}
return nil, nil
}
func ptr[T any](v T) *T { return &v }
func TestGetSystemHappyPath(t *testing.T) {
// Arrange
g := NewWithT(t)
m := &mockClient{
cfg: &jellyfin.ServerConfiguration{
EnableMetrics: ptr(true),
PluginRepositories: []jellyfin.RepositoryInfo{
{
Name: *jellyfin.NewNullableString(ptr("Repo")),
Url: *jellyfin.NewNullableString(ptr("https://repo")),
Enabled: ptr(true),
},
},
TrickplayOptions: &jellyfin.TrickplayOptions{
EnableHwAcceleration: ptr(true),
},
},
}
// Act
cl := &api.Client{Conf: m}
state, err := cl.GetSystem(context.Background())
// Assert
g.Expect(err).NotTo(HaveOccurred())
g.Expect(m.getCalled).To(BeTrue(), "GetConfiguration should be called")
g.Expect(state).To(
MatchAllFields(Fields{
"EnableMetrics": BeTrue(),
"PluginRepositories": ConsistOf(
MatchAllFields(Fields{
"Name": Equal("Repo"),
"URL": Equal("https://repo"),
"Enabled": BeTrue(),
}),
),
"TrickplayOptions": MatchAllFields(Fields{
"EnableHwAcceleration": BeTrue(),
}),
}),
)
}
func WhenGetConfigurationErrorsThenGetSystemErrors(t *testing.T) {
// Arrange
g := NewWithT(t)
m := &mockClient{err: errors.New("boom")}
// Act
cl := &api.Client{Conf: m}
_, err := cl.GetSystem(context.Background())
// Assert
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring("boom"))
g.Expect(m.getCalled).To(BeTrue())
}
func TestUpdateSystemHappyPath(t *testing.T) {
// Arrange
g := NewWithT(t)
m := &mockClient{}
spec := model.SystemSpec{
EnableMetrics: ptr(true),
PluginRepositories: []model.PluginRepository{
{Name: "A", URL: "https://repo", Enabled: true},
},
TrickplayOptions: &model.TrickplayOptions{
EnableHwAcceleration: true,
},
}
// Act
cl := &api.Client{Conf: m}
err := cl.UpdateSystem(context.Background(), spec)
// Assert
g.Expect(err).NotTo(HaveOccurred())
g.Expect(m.updateCalled).To(BeTrue())
}
func WhenUpdateConfigurationErrorsThenUpdateSystemErrors(t *testing.T) {
// Arrange
g := NewWithT(t)
m := &mockClient{err: errors.New("fail")}
spec := model.SystemSpec{
EnableMetrics: ptr(true),
PluginRepositories: []model.PluginRepository{
{Name: "A", URL: "https://repo", Enabled: true},
},
TrickplayOptions: &model.TrickplayOptions{
EnableHwAcceleration: true,
},
}
// Act
cl := &api.Client{Conf: m}
// Assert
err := cl.UpdateSystem(context.Background(), spec)
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring("fail"))
g.Expect(m.updateCalled).To(BeTrue())
}

View File

@@ -1,131 +0,0 @@
package apply_test
import (
"context"
"testing"
. "github.com/onsi/gomega"
"jellarr/src/internal/apply"
"jellarr/src/internal/model"
)
type mockJF struct {
state model.SystemState
updatedSpec *model.SystemSpec
}
func (m *mockJF) GetSystem(ctx context.Context) (model.SystemState, error) { return m.state, nil }
func (m *mockJF) UpdateSystem(ctx context.Context, in model.SystemSpec) error {
m.updatedSpec = &in
return nil
}
func ptr[T any](v T) *T { return &v }
func WhenNoChangesThenApplySystemDoesNotApplyAnyChanges(t *testing.T) {
// Arrange
g := NewWithT(t)
m := &mockJF{
state: model.SystemState{
EnableMetrics: true,
PluginRepositories: []model.PluginRepository{
{Name: "A", URL: "u1", Enabled: true},
},
TrickplayOptions: model.TrickplayOptions{
EnableHwAcceleration: true,
},
},
}
desired := model.SystemSpec{
EnableMetrics: ptr(true),
PluginRepositories: []model.PluginRepository{
{Name: "A", URL: "u1", Enabled: true},
},
TrickplayOptions: &model.TrickplayOptions{
EnableHwAcceleration: true,
},
}
// Act
err := apply.ApplySystem(context.Background(), m, desired)
// Assert
g.Expect(err).NotTo(HaveOccurred())
g.Expect(m.updatedSpec).To(BeNil(), "should not update when desired == current")
}
func WhenChangesThenApplySystemAppliesChanges(t *testing.T) {
// Arrange
g := NewWithT(t)
m := &mockJF{
state: model.SystemState{
EnableMetrics: true,
PluginRepositories: []model.PluginRepository{
{Name: "A", URL: "u1", Enabled: true},
},
TrickplayOptions: model.TrickplayOptions{
EnableHwAcceleration: true,
},
},
}
desired := model.SystemSpec{
EnableMetrics: ptr(true),
PluginRepositories: []model.PluginRepository{
{Name: "A", URL: "u1", Enabled: true},
{Name: "B", URL: "u2", Enabled: false},
},
TrickplayOptions: &model.TrickplayOptions{
EnableHwAcceleration: true,
},
}
// Act
err := apply.ApplySystem(context.Background(), m, desired)
// Assert
g.Expect(err).NotTo(HaveOccurred())
g.Expect(m.updatedSpec).NotTo(BeNil(), "expected update to be triggered")
g.Expect(*m.updatedSpec).To(Equal(desired))
}
func TestEqualReposUnordered(t *testing.T) {
// Arrange
g := NewWithT(t)
a := []model.PluginRepository{
{Name: "A", URL: "u1", Enabled: true},
{Name: "B", URL: "u2", Enabled: false},
}
b := []model.PluginRepository{
{Name: "B", URL: "u2", Enabled: false},
{Name: "A", URL: "u1", Enabled: true},
}
c := []model.PluginRepository{
{Name: "A", URL: "u1", Enabled: false},
}
// Act & Assert
g.Expect(apply.EqualReposUnordered(a, b)).To(BeTrue(), "same sets should be equal regardless of order")
g.Expect(apply.EqualReposUnordered(a, c)).To(BeFalse(), "different sets should not be equal")
}
func TestAreTrickplayOptionsEqual(t *testing.T) {
// Arrange
g := NewWithT(t)
// Act & Assert
g.Expect(apply.AreTrickplayOptionsEqual(
model.TrickplayOptions{EnableHwAcceleration: true},
model.TrickplayOptions{EnableHwAcceleration: true},
)).To(BeTrue())
g.Expect(apply.AreTrickplayOptionsEqual(
model.TrickplayOptions{EnableHwAcceleration: true},
model.TrickplayOptions{EnableHwAcceleration: false},
)).To(BeFalse())
}

View File

@@ -1,152 +0,0 @@
package config_test
import (
"os"
"path/filepath"
"testing"
. "github.com/onsi/gomega"
. "github.com/onsi/gomega/gstruct"
"jellarr/src/internal/config"
)
func TestLoadHappyPath(t *testing.T) {
// Arrange
g := NewWithT(t)
p := writeTmpYml(t, `
version: 1
base_url: "http://localhost:8096"
system:
enableMetrics: true
pluginRepositories:
- name: "Repo"
url: "https://repo"
enabled: true
trickplayOptions:
enableHwAcceleration: true
`)
// Act
cfg, err := config.Load(p)
// Assert
g.Expect(err).NotTo(HaveOccurred())
g.Expect(*cfg).To(MatchFields(IgnoreExtras, Fields{
"Version": Equal(1),
"BaseUrl": Equal("http://localhost:8096"),
"System": MatchAllFields(Fields{
"EnableMetrics": PointTo(BeTrue()),
"PluginRepositories": ConsistOf(
MatchAllFields(Fields{
"Name": Equal("Repo"),
"URL": Equal("https://repo"),
"Enabled": BeTrue(),
}),
),
"TrickplayOptions": PointTo(MatchAllFields(Fields{
"EnableHwAcceleration": BeTrue(),
})),
}),
}))
}
func WhenNoEnableMetricsInConfigThenEnableMetricsInSystemSpecIsNil(t *testing.T) {
// Arrange
g := NewWithT(t)
p := writeTmpYml(t, `
version: 1
base_url: "http://localhost:8096"
system:
pluginRepositories:
- name: "Repo"
url: "https://repo"
enabled: true
trickplayOptions:
enableHwAcceleration: true
`)
// Act
cfg, err := config.Load(p)
// Assert
g.Expect(err).NotTo(HaveOccurred())
g.Expect(cfg.System.EnableMetrics).To(BeNil())
g.Expect(cfg.System.PluginRepositories).NotTo(BeNil())
g.Expect(cfg.System.TrickplayOptions).NotTo(BeNil())
}
func WhenNoPluginRepositoriesInConfigThenPluginRepositoriesInSystemSpecIsNil(t *testing.T) {
// Arrange
g := NewWithT(t)
p := writeTmpYml(t, `
version: 1
base_url: "http://localhost:8096"
system:
enableMetrics: true
trickplayOptions:
enableHwAcceleration: true
`)
// Act
cfg, err := config.Load(p)
// Assert
g.Expect(err).NotTo(HaveOccurred())
g.Expect(cfg.System.EnableMetrics).NotTo(BeNil())
g.Expect(cfg.System.PluginRepositories).To(BeNil())
g.Expect(cfg.System.TrickplayOptions).NotTo(BeNil())
}
func WhenNoTrickplayOptionsInConfigThenTrickplayOptionsInSystemSpecIsNil(t *testing.T) {
// Arrange
g := NewWithT(t)
p := writeTmpYml(t, `
version: 1
base_url: "http://localhost:8096"
system:
enableMetrics: true
pluginRepositories:
- name: "Repo"
url: "https://repo"
enabled: true
`)
// Act
cfg, err := config.Load(p)
// Assert
g.Expect(err).NotTo(HaveOccurred())
g.Expect(cfg.System.EnableMetrics).NotTo(BeNil())
g.Expect(cfg.System.PluginRepositories).NotTo(BeNil())
g.Expect(cfg.System.TrickplayOptions).To(BeNil())
}
func WhenBadYAMLThenLoadErrors(t *testing.T) {
//Arrange
g := NewWithT(t)
p := writeTmpYml(t, "system: [1,2")
// Act
_, err := config.Load(p)
// Assert
g.Expect(err).To(HaveOccurred())
}
func writeTmpYml(t *testing.T, yml string) string {
dir := t.TempDir()
p := filepath.Join(dir, "cfg.yml")
if err := os.WriteFile(p, []byte(yml), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
return p
}

View File

@@ -1,124 +0,0 @@
package mapper_test
import (
"testing"
. "github.com/onsi/gomega"
. "github.com/onsi/gomega/gstruct"
jellyfin "github.com/sj14/jellyfin-go/api"
"jellarr/src/internal/mapper"
"jellarr/src/internal/model"
)
func TestRepoRoundTripHappyPath(t *testing.T) {
// Arrange
g := NewWithT(t)
in := []model.PluginRepository{
{Name: "A", URL: "u1", Enabled: true},
{Name: "B", URL: "u2", Enabled: false},
}
// Act
jf := mapper.ToJFRepos(in)
back := mapper.FromJFRepos(jf)
// Assert
g.Expect(back).To(Equal(in))
}
func TestRepoRoundTripWithEmptyValues(t *testing.T) {
// Arrange
g := NewWithT(t)
in := []model.PluginRepository{}
// Act
jf := mapper.ToJFRepos(in)
back := mapper.FromJFRepos(jf)
// Assert
g.Expect(back).To(Equal(in))
}
func TestFromJFReposHandlesNull(t *testing.T) {
// Arrange
g := NewWithT(t)
jf := []jellyfin.RepositoryInfo{
{},
}
// Act
back := mapper.FromJFRepos(jf)
// Assert
g.Expect(back).To(Equal([]model.PluginRepository{
{Name: "", URL: "", Enabled: false},
}))
}
func TestToJFReposHandlesEmptyValues(t *testing.T) {
// Arrange
g := NewWithT(t)
in := []model.PluginRepository{
{Name: "", URL: "", Enabled: false},
}
// Act
out := mapper.ToJFRepos(in)
// Assert
g.Expect(out).To(HaveLen(1))
g.Expect(out[0]).To(MatchAllFields(Fields{
"Name": WithTransform(func(n jellyfin.NullableString) string {
if v := n.Get(); v != nil {
return *v
}
return ""
}, Equal("")),
"Url": WithTransform(func(u jellyfin.NullableString) string {
if v := u.Get(); v != nil {
return *v
}
return ""
}, Equal("")),
"Enabled": PointTo(BeFalse()),
}))
}
func TestTrickplayOptionsRoundTripHappyPath(t *testing.T) {
// Arrange
g := NewWithT(t)
in := model.TrickplayOptions{
EnableHwAcceleration: true,
}
// Act
jf := mapper.ToJFTrickplayOptions(in)
back := mapper.FromJFTrickplayOptions(*jf)
// Assert
g.Expect(back).To(Equal(in))
}
func TestTrickplayOptionsRoundTripWithEmptyValues(t *testing.T) {
// Arrange
g := NewWithT(t)
in := model.TrickplayOptions{}
// Act
jf := mapper.ToJFTrickplayOptions(in)
back := mapper.FromJFTrickplayOptions(*jf)
// Assert
g.Expect(back).To(Equal(in))
}

View File

@@ -1,39 +0,0 @@
package model_test
import (
"os"
"testing"
. "github.com/onsi/gomega"
. "github.com/onsi/gomega/gstruct"
"gopkg.in/yaml.v3"
"jellarr/src/internal/model"
)
func TestSystemSpecYAML(t *testing.T) {
// Arrange
g := NewWithT(t)
data, err := os.ReadFile("../../../../sample-config.yml")
g.Expect(err).NotTo(HaveOccurred())
var root struct {
Version int `yaml:"version"`
BaseURL string `yaml:"base_url"`
System model.SystemSpec `yaml:"system"`
}
// Act
err = yaml.Unmarshal(data, &root)
// Assert
g.Expect(err).NotTo(HaveOccurred())
g.Expect(root.System.EnableMetrics).To(PointTo(BeTrue()))
g.Expect(root.System.PluginRepositories).To(ConsistOf(
MatchAllFields(Fields{
"Name": Equal("Jellyfin Official"),
"URL": Equal("https://repo.jellyfin.org/releases/plugin/manifest.json"),
"Enabled": BeTrue(),
}),
))
}

View File

@@ -17,13 +17,17 @@ _: {
priority = 30;
};
gofmt = {
prettier = {
enable = true;
priority = 40;
settings.editorconfig = true;
};
deno = {
enable = true;
includes = [
"*.md"
];
priority = 50;
};
};

14
tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"],
"strict": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src", "generated"]
}