First step of ADR-008. internal/transcode answers one question: can this host
hardware-transcode, and with which encoder.
The rule the package exists to enforce is that a device node is not proof. A
node can exist while the driver cannot encode, and that produces the worst
outcome ADR-008 describes: a transcode that starts, looks like it works, and
then cannot keep up. So every candidate runs a real two-frame probe encode and
is dropped unless that encode exits cleanly.
This host is a perfect illustration and the reason the rule is not theoretical:
it has /dev/dri/renderD128 AND Nvidia nodes. The render node exists, but VAAPI
fails to initialise against it ("Failed to initialise VAAPI connection"). A
detection that trusted the node would have picked VAAPI and been wrong. The
probe rejected it and selected NVENC, which was verified independently with
ffmpeg outside this code.
The probe is injected, so every branch is testable with no hardware present,
including the important one: device present, probe fails. Verified red-green -
ignoring the probe result fails TestDetectProbeFails with "got vaapi, want no
capability".
One real bug found while proving it: the probe used testsrc=size=64x48, below
NVENC's minimum frame size, so a host with working NVENC would have reported no
capability at all. Raised to 320x240, still two frames.
Also added internal/transcode to the arch layer map as a layer-1 primitive
beside probe and engine, which the arch rule caught immediately.
Not wired into the engine registry or playback yet - that is the next step.
365 lines
11 KiB
Go
365 lines
11 KiB
Go
// Package arch holds no code. It exists so that the import rules this project
|
|
// depends on are enforced by the test suite instead of by good intentions.
|
|
//
|
|
// A layering rule that is only written down in a comment is a rule that will be
|
|
// broken within a month, and the breakage is invisible: an upward import does
|
|
// not fail to compile, it just quietly welds two layers together until nothing
|
|
// can be moved. These tests fail loudly instead.
|
|
package arch
|
|
|
|
import (
|
|
"go/parser"
|
|
"go/token"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const modulePath = "github.com/mithral/vellum/"
|
|
|
|
// Layers, lowest first. A package may import from a LOWER layer or its own
|
|
// layer, never from a higher one. Peers are allowed because they genuinely
|
|
// collaborate (metadata enriches items that media serves); upward imports are
|
|
// not, because they invert the dependency and make the lower layer unusable on
|
|
// its own.
|
|
var layers = map[string]int{
|
|
// 0 — foundation. No knowledge of the application.
|
|
"internal/core": 0,
|
|
"internal/httpx": 0,
|
|
"internal/natsort": 0,
|
|
"internal/arch": 0,
|
|
"migrations": 0,
|
|
|
|
// 1 — primitives the application is built from.
|
|
"internal/auth": 1,
|
|
"internal/jobs": 1,
|
|
"internal/stream": 1,
|
|
"internal/progress": 1,
|
|
"internal/probe": 1,
|
|
"internal/transcode": 1,
|
|
"internal/engine": 1,
|
|
"internal/apitoken": 1,
|
|
|
|
// 2 — leaves that the application is built from: the data seam, and the
|
|
// external catalogues. providers sits here so it stays a leaf — a
|
|
// metadata client must be usable from a test with no database.
|
|
"internal/catalog": 2,
|
|
"internal/providers": 2,
|
|
|
|
// 3 — the application.
|
|
"internal/library": 3,
|
|
"internal/scanner": 3,
|
|
"internal/media": 3,
|
|
"internal/home": 3,
|
|
"internal/search": 3,
|
|
"internal/metadata": 3,
|
|
"internal/playback": 3,
|
|
"internal/modules/comics": 3,
|
|
"internal/modules/ebooks": 3,
|
|
"internal/modules/games": 3,
|
|
"internal/modules/photos": 3,
|
|
|
|
// 4 — adapters: HTTP surfaces and foreign protocols.
|
|
"internal/httpapi": 4,
|
|
"internal/jellyfin": 4,
|
|
"internal/jellyfin/dto": 3,
|
|
"internal/subsonic": 4,
|
|
"internal/subsonic/dto": 3,
|
|
"internal/cli": 4,
|
|
// discovery is an adapter in the same sense the protocol handlers are: it
|
|
// speaks a foreign wire format (mDNS) on behalf of the process and knows
|
|
// nothing about the media model.
|
|
"internal/discovery": 4,
|
|
|
|
// 5 — the only place everything is wired together.
|
|
"cmd/vellum": 5,
|
|
}
|
|
|
|
// repoRoot walks up until it finds go.mod, so the test works regardless of
|
|
// where `go test` is invoked from.
|
|
func repoRoot(t *testing.T) string {
|
|
t.Helper()
|
|
dir, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for {
|
|
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
|
return dir
|
|
}
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
t.Fatal("could not find go.mod above the test directory")
|
|
}
|
|
dir = parent
|
|
}
|
|
}
|
|
|
|
// importsOf parses a directory and returns the internal packages it imports.
|
|
func importsOf(t *testing.T, dir string) []string {
|
|
t.Helper()
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", dir, err)
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") || strings.HasSuffix(e.Name(), "_test.go") {
|
|
continue
|
|
}
|
|
fset := token.NewFileSet()
|
|
f, err := parser.ParseFile(fset, filepath.Join(dir, e.Name()), nil, parser.ImportsOnly)
|
|
if err != nil {
|
|
t.Fatalf("parse %s: %v", e.Name(), err)
|
|
}
|
|
for _, imp := range f.Imports {
|
|
p := strings.Trim(imp.Path.Value, `"`)
|
|
if strings.HasPrefix(p, modulePath) {
|
|
seen[strings.TrimPrefix(p, modulePath)] = true
|
|
}
|
|
}
|
|
}
|
|
out := make([]string, 0, len(seen))
|
|
for p := range seen {
|
|
out = append(out, p)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// TestNoUpwardImports is the rule that keeps the layers separable.
|
|
func TestNoUpwardImports(t *testing.T) {
|
|
root := repoRoot(t)
|
|
for pkg, layer := range layers {
|
|
dir := filepath.Join(root, filepath.FromSlash(pkg))
|
|
if _, err := os.Stat(dir); err != nil {
|
|
continue // package not present (e.g. a module was removed)
|
|
}
|
|
for _, dep := range importsOf(t, dir) {
|
|
depLayer, known := layers[dep]
|
|
if !known {
|
|
continue // a package outside the map; TestEveryPackageIsLayered catches it
|
|
}
|
|
if depLayer > layer {
|
|
t.Errorf("%s (layer %d) imports %s (layer %d): upward dependency",
|
|
pkg, layer, dep, depLayer)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCoreStaysPure is the module rule: core may not know about any feature, or
|
|
// it stops being the thing everything else can depend on.
|
|
func TestCoreStaysPure(t *testing.T) {
|
|
root := repoRoot(t)
|
|
for _, dep := range importsOf(t, filepath.Join(root, "internal", "core")) {
|
|
if dep != "migrations" {
|
|
t.Errorf("internal/core imports %s; core may depend only on migrations", dep)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCatalogHasNoHTTP keeps the data seam a data seam. If it grows an
|
|
// http.ResponseWriter it can no longer be reused by a job, a CLI or a test
|
|
// without dragging a request in.
|
|
func TestCatalogHasNoHTTP(t *testing.T) {
|
|
root := repoRoot(t)
|
|
for _, dep := range importsOf(t, filepath.Join(root, "internal", "catalog")) {
|
|
if dep == "internal/httpx" || dep == "internal/httpapi" {
|
|
t.Errorf("internal/catalog imports %s; the data seam must not know about HTTP", dep)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestModulesDoNotImportEachOther keeps modules independently removable. A
|
|
// module that imports a sibling cannot be dropped without dropping both.
|
|
func TestModulesDoNotImportEachOther(t *testing.T) {
|
|
root := repoRoot(t)
|
|
modsDir := filepath.Join(root, "internal", "modules")
|
|
mods, err := os.ReadDir(modsDir)
|
|
if err != nil {
|
|
t.Skip("no modules directory")
|
|
}
|
|
for _, m := range mods {
|
|
if !m.IsDir() {
|
|
continue
|
|
}
|
|
self := "internal/modules/" + m.Name()
|
|
for _, dep := range importsOf(t, filepath.Join(modsDir, m.Name())) {
|
|
if strings.HasPrefix(dep, "internal/modules/") && dep != self {
|
|
t.Errorf("%s imports %s; modules must not depend on each other", self, dep)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEveryPackageIsLayered stops the map above from silently going stale. A
|
|
// new package that nobody placed in a layer is a package with no rules, which
|
|
// is how the layering erodes.
|
|
func TestEveryPackageIsLayered(t *testing.T) {
|
|
root := repoRoot(t)
|
|
var found []string
|
|
|
|
var walk func(base string)
|
|
walk = func(base string) {
|
|
entries, err := os.ReadDir(base)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
full := filepath.Join(base, e.Name())
|
|
rel, _ := filepath.Rel(root, full)
|
|
rel = filepath.ToSlash(rel)
|
|
// Only directories that actually contain Go files are packages.
|
|
if hasGoFiles(full) {
|
|
found = append(found, rel)
|
|
}
|
|
walk(full)
|
|
}
|
|
}
|
|
walk(filepath.Join(root, "internal"))
|
|
found = append(found, "cmd/vellum")
|
|
|
|
for _, pkg := range found {
|
|
if _, ok := layers[pkg]; !ok {
|
|
t.Errorf("%s is not in the layers map; add it so the import rules apply to it", pkg)
|
|
}
|
|
}
|
|
}
|
|
|
|
func hasGoFiles(dir string) bool {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, e := range entries {
|
|
if !e.IsDir() && strings.HasSuffix(e.Name(), ".go") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// allImportsOf returns every import of a package's non-test files, including
|
|
// standard library ones. importsOf only reports internal packages, which is
|
|
// right for the layering rules and wrong for asking "does this package know
|
|
// about SQL".
|
|
func allImportsOf(t *testing.T, dir string) []string {
|
|
t.Helper()
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", dir, err)
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") || strings.HasSuffix(e.Name(), "_test.go") {
|
|
continue
|
|
}
|
|
fset := token.NewFileSet()
|
|
f, err := parser.ParseFile(fset, filepath.Join(dir, e.Name()), nil, parser.ImportsOnly)
|
|
if err != nil {
|
|
t.Fatalf("parse %s: %v", e.Name(), err)
|
|
}
|
|
for _, imp := range f.Imports {
|
|
seen[strings.Trim(imp.Path.Value, `"`)] = true
|
|
}
|
|
}
|
|
out := make([]string, 0, len(seen))
|
|
for p := range seen {
|
|
out = append(out, p)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// sqlImporters are the packages permitted to import database/sql in their
|
|
// non-test files.
|
|
//
|
|
// This list is the guard against the storage seam eroding by drift. The seam
|
|
// exists so the engine is swappable; it only holds if the set of packages that
|
|
// know about *sql.DB does not quietly grow. Adding a package here is a
|
|
// deliberate act with a reason, not a side effect of a feature.
|
|
//
|
|
// A package that only needs database/sql to build a TEST database does not
|
|
// belong here — the check ignores _test.go files on purpose.
|
|
var sqlImporters = map[string]bool{
|
|
"internal/apitoken": true,
|
|
"internal/auth": true,
|
|
"internal/catalog": true,
|
|
"internal/cli": true,
|
|
"internal/core": true,
|
|
"internal/home": true,
|
|
"internal/httpapi": true,
|
|
"internal/jellyfin": true,
|
|
"internal/jobs": true,
|
|
"internal/library": true,
|
|
"internal/media": true,
|
|
"internal/metadata": true,
|
|
"internal/modules/comics": true,
|
|
"internal/modules/ebooks": true,
|
|
"internal/modules/games": true,
|
|
"internal/modules/photos": true,
|
|
"internal/progress": true,
|
|
"internal/scanner": true,
|
|
"internal/search": true,
|
|
"internal/subsonic": true,
|
|
}
|
|
|
|
// threadedConsumers must NOT import database/sql. These are packages that have
|
|
// been moved onto the catalog.Store interface; if one of them reaches for
|
|
// *sql.DB again, the seam is being undone and this test says so.
|
|
var threadedConsumers = []string{
|
|
"internal/playback",
|
|
}
|
|
|
|
// TestDatabaseSQLImportersAreDeclared freezes the blast radius of database/sql.
|
|
// A new package that needs it must be added to the list deliberately, so the
|
|
// seam cannot erode one convenient import at a time.
|
|
func TestDatabaseSQLImportersAreDeclared(t *testing.T) {
|
|
root := repoRoot(t)
|
|
for pkg := range layers {
|
|
dir := filepath.Join(root, filepath.FromSlash(pkg))
|
|
if _, err := os.Stat(dir); err != nil {
|
|
continue
|
|
}
|
|
usesSQL := false
|
|
for _, imp := range allImportsOf(t, dir) {
|
|
if imp == "database/sql" {
|
|
usesSQL = true
|
|
break
|
|
}
|
|
}
|
|
switch {
|
|
case usesSQL && !sqlImporters[pkg]:
|
|
t.Errorf("%s imports database/sql but is not in sqlImporters.\n"+
|
|
"If it genuinely needs its own SQL, add it to the list with a reason.\n"+
|
|
"If it is reading items, take a catalog.Store instead.", pkg)
|
|
case !usesSQL && sqlImporters[pkg]:
|
|
t.Errorf("%s is listed in sqlImporters but no longer imports database/sql.\n"+
|
|
"Remove it from the list so the list keeps meaning something.", pkg)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestThreadedConsumersStayOffSQL is the concrete regression guard: a package
|
|
// already moved onto the seam must not drift back.
|
|
func TestThreadedConsumersStayOffSQL(t *testing.T) {
|
|
root := repoRoot(t)
|
|
for _, pkg := range threadedConsumers {
|
|
dir := filepath.Join(root, filepath.FromSlash(pkg))
|
|
if _, err := os.Stat(dir); err != nil {
|
|
continue
|
|
}
|
|
for _, imp := range allImportsOf(t, dir) {
|
|
if imp == "database/sql" {
|
|
t.Errorf("%s is on the catalog.Store seam but imports database/sql again.\n"+
|
|
"That undoes the seam: take a catalog.Store, do not reach for *sql.DB.", pkg)
|
|
}
|
|
}
|
|
}
|
|
}
|