Add the initial mediamark app
Single Go binary serving the API and an embedded keyboard-first UI for promoting fafflix titles into the cheeztv kids tree via hardlinks. - internal/library: hardlink sync, idempotent re-runs, drift reporting, strict single-path-element name validation as the traversal guard - internal/arr: minimal sonarr/radarr v3 client with a 60s list cache and a key-brokered poster proxy - internal/auth: server-side Authentik group enforcement on every route - internal/server: library JSON API, art proxy, health probes, SPA - ui: two-tile landing page, fuzzy-filtered title list, detail panel - Makefile, Dockerfile, .woodpecker pipelines, pre-commit config
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
/dist/
|
||||
*.out
|
||||
@@ -0,0 +1,27 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-added-large-files
|
||||
- id: check-merge-conflict
|
||||
|
||||
- repo: https://github.com/dnephin/pre-commit-golang
|
||||
rev: v0.5.1
|
||||
hooks:
|
||||
- id: go-fmt
|
||||
- id: go-mod-tidy
|
||||
|
||||
# mediamark has no root-level Go files (all under cmd/, internal/, ui/), so the
|
||||
# dnephin go-vet hook (which runs `go vet` at the repo root) fails with
|
||||
# "no Go files". Vet the whole module instead.
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: go-vet
|
||||
name: go vet
|
||||
entry: go vet ./...
|
||||
language: system
|
||||
types: [go]
|
||||
pass_filenames: false
|
||||
@@ -0,0 +1,23 @@
|
||||
when:
|
||||
- event: pull_request
|
||||
|
||||
steps:
|
||||
- name: docker-build
|
||||
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest
|
||||
settings:
|
||||
repo: artifactapi.k8s.syd1.au.unkin.net/docker-internal/mediamark
|
||||
dockerfile: Dockerfile
|
||||
dry_run: true
|
||||
buildkit_config: |
|
||||
[registry."artifactapi.k8s.syd1.au.unkin.net"]
|
||||
ca = ["/etc/docker/certs.d/artifactapi.k8s.syd1.au.unkin.net/ca.crt"]
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: mediamark-ci
|
||||
resources:
|
||||
requests:
|
||||
memory: 1Gi
|
||||
cpu: 1
|
||||
limits:
|
||||
memory: 4Gi
|
||||
cpu: 2
|
||||
@@ -0,0 +1,29 @@
|
||||
when:
|
||||
- event: tag
|
||||
ref: refs/tags/v*
|
||||
|
||||
steps:
|
||||
- name: docker
|
||||
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest
|
||||
settings:
|
||||
registry: artifactapi.k8s.syd1.au.unkin.net
|
||||
repo: artifactapi.k8s.syd1.au.unkin.net/docker-internal/mediamark
|
||||
dockerfile: Dockerfile
|
||||
build_args:
|
||||
VERSION: ${CI_COMMIT_TAG}
|
||||
buildkit_config: |
|
||||
[registry."artifactapi.k8s.syd1.au.unkin.net"]
|
||||
ca = ["/etc/docker/certs.d/artifactapi.k8s.syd1.au.unkin.net/ca.crt"]
|
||||
tags:
|
||||
- ${CI_COMMIT_TAG}
|
||||
- latest
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: mediamark-ci
|
||||
resources:
|
||||
requests:
|
||||
memory: 1Gi
|
||||
cpu: 1
|
||||
limits:
|
||||
memory: 4Gi
|
||||
cpu: 2
|
||||
@@ -0,0 +1,19 @@
|
||||
when:
|
||||
- event: pull_request
|
||||
|
||||
steps:
|
||||
- name: pre-commit
|
||||
image: golang:1.25
|
||||
commands:
|
||||
- test -z "$(gofmt -l .)"
|
||||
- go vet ./...
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: mediamark-ci
|
||||
resources:
|
||||
requests:
|
||||
memory: 512Mi
|
||||
cpu: 1
|
||||
limits:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
@@ -0,0 +1,18 @@
|
||||
when:
|
||||
- event: pull_request
|
||||
|
||||
steps:
|
||||
- name: test
|
||||
image: golang:1.25
|
||||
commands:
|
||||
- go test -race -count=1 ./...
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: mediamark-ci
|
||||
resources:
|
||||
requests:
|
||||
memory: 512Mi
|
||||
cpu: 1
|
||||
limits:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY go.mod ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION}" -o mediamark ./cmd/mediamark
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
|
||||
COPY --from=builder /build/mediamark /usr/local/bin/mediamark
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["mediamark"]
|
||||
@@ -0,0 +1,71 @@
|
||||
DIST := dist
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)"
|
||||
OS ?= $(shell go env GOOS)
|
||||
ARCH ?= $(shell go env GOARCH)
|
||||
|
||||
REGISTRY := artifactapi.k8s.syd1.au.unkin.net/docker-internal
|
||||
|
||||
# Shipped binaries; each has its own main package under cmd/.
|
||||
BINARIES := mediamark
|
||||
|
||||
.PHONY: all build test vet fmt lint clean images patch minor major _tag pre-commit run
|
||||
|
||||
all: build
|
||||
|
||||
# Mirror the .woodpecker/pre-commit.yaml checks locally.
|
||||
pre-commit:
|
||||
test -z "$$(gofmt -l .)"
|
||||
go vet ./...
|
||||
|
||||
build:
|
||||
@for b in $(BINARIES); do \
|
||||
echo "building $$b"; \
|
||||
CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build $(GOFLAGS) -o $(DIST)/$$b ./cmd/$$b || exit 1; \
|
||||
done
|
||||
|
||||
test:
|
||||
go test -race -count=1 ./...
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
fmt:
|
||||
gofmt -w .
|
||||
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
clean:
|
||||
rm -rf $(DIST)
|
||||
|
||||
# Local convenience: build the container image.
|
||||
images:
|
||||
docker build --build-arg VERSION=$(VERSION) -t $(REGISTRY)/mediamark:$(VERSION) .
|
||||
|
||||
# Local convenience: serve against a scratch media tree with no *arr backends.
|
||||
run: build
|
||||
MEDIAMARK_MEDIA_ROOT=$(PWD)/testdata/media $(DIST)/mediamark
|
||||
|
||||
# Bump helpers — read the latest semver tag and create the next one. CI builds
|
||||
# and pushes the image on the resulting v* tag.
|
||||
_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1)
|
||||
_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0)
|
||||
_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1)
|
||||
_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2)
|
||||
_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3)
|
||||
|
||||
patch:
|
||||
@NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \
|
||||
git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
|
||||
|
||||
minor:
|
||||
@NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \
|
||||
git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
|
||||
|
||||
major:
|
||||
@NEW=v$(shell expr $(_MAJ) + 1).0.0; \
|
||||
git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
|
||||
|
||||
_tag:
|
||||
git push origin $(TAG)
|
||||
@@ -1,3 +1,114 @@
|
||||
# mediamark
|
||||
|
||||
Keyboard-centric web UI to mark media as cheeztv (kids) content via hardlinks; oauth2-proxy fronted, talks to sonarr/radarr APIs
|
||||
Keyboard-centric web UI to mark media as cheeztv (kids) content via hardlinks;
|
||||
oauth2-proxy fronted, talks to sonarr/radarr APIs.
|
||||
|
||||
A single Go binary carrying its own UI. It lists the titles in the `fafflix`
|
||||
library and lets an authorized Authentik group promote any of them into the
|
||||
`cheeztv` kids tree. Promotion is a recursive **hardlink** sync, so a marked
|
||||
title consumes no extra space and unmarking can never destroy media.
|
||||
|
||||
## How marking works
|
||||
|
||||
The media root holds two parallel trees:
|
||||
|
||||
```
|
||||
<media root>/
|
||||
fafflix/{movies,tvseries}/<title>/... source library (radarr / sonarr)
|
||||
cheeztv/{movies,tvseries}/<title>/... kids tree (hardlinks into fafflix)
|
||||
```
|
||||
|
||||
* **Mark** walks the source title and hardlinks every regular file into the
|
||||
same-named directory under `cheeztv/`. It is idempotent: files already
|
||||
pointing at the same inode are left alone, a kids-side file pointing at a
|
||||
*different* inode is replaced, and symlinks/devices are skipped. Re-running it
|
||||
after new episodes land syncs only the new files.
|
||||
* **Unmark** removes the `cheeztv/` directory only. The source inode keeps its
|
||||
link, so nothing is lost.
|
||||
* A marked title whose source has files that are not linked across is reported
|
||||
as `needsSync`, and the detail view offers a **Sync new files** button.
|
||||
|
||||
Title names come from directory entries and are validated as a single clean path
|
||||
element (no separators, no `..`, no absolute paths) before touching the disk.
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is environment-only. Startup fails closed on an empty group
|
||||
allow-list, a relative media root, or a non-HTTP *arr URL.
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `MEDIAMARK_LISTEN` | `:8080` | HTTP listen address |
|
||||
| `MEDIAMARK_MEDIA_ROOT` | `/media` | Root holding `fafflix/` and `cheeztv/` |
|
||||
| `MEDIAMARK_KEYS_DIR` | `/etc/mediamark/keys` | Directory of `sonarr`/`radarr` API key files, re-read per use so VSO rotations land without a restart |
|
||||
| `MEDIAMARK_SONARR_URL` | `http://sonarr.arrstack.svc.cluster.local:8989/3aa168/sonarr` | Sonarr base URL, including its `UrlBase` |
|
||||
| `MEDIAMARK_RADARR_URL` | `http://radarr.arrstack.svc.cluster.local:7878/3aa168/radarr` | Radarr base URL, including its `UrlBase` |
|
||||
| `MEDIAMARK_GROUPS_HEADER` | `X-Forwarded-Groups` | oauth2-proxy header carrying Authentik groups |
|
||||
| `MEDIAMARK_ALLOWED_GROUPS` | `akP-mediamark-user` | Comma/space separated group allow-list; must be non-empty |
|
||||
| `ARR_SONARR_APIKEY` / `ARR_RADARR_APIKEY` | _(unset)_ | Development escape hatch, overrides the key files |
|
||||
|
||||
oauth2-proxy fronts the app, but group membership is re-checked server-side on
|
||||
every API call *and* on the page load itself, so an unauthorized user gets a 403
|
||||
rather than an empty shell. `/livez` and `/readyz` are ungated for the kubelet;
|
||||
`/readyz` fails while the media root is not visible.
|
||||
|
||||
## HTTP API
|
||||
|
||||
| Route | Purpose |
|
||||
| --- | --- |
|
||||
| `GET /api/library/{section}` | Titles in `movies` or `tvseries` with marked/drift state, size, and *arr metadata |
|
||||
| `POST /api/library/{section}/{name}/mark` | Hardlink-sync the title into `cheeztv` (idempotent) |
|
||||
| `POST /api/library/{section}/{name}/unmark` | Remove the title's `cheeztv` directory (idempotent) |
|
||||
| `GET /api/art/{section}/{id}` | Poster proxy; the *arr API key is injected server-side and never exposed |
|
||||
| `GET /livez`, `GET /readyz` | Health probes |
|
||||
|
||||
Sonarr/radarr list responses are cached in memory for ~60s so list pages stay
|
||||
fast; a failed *arr lookup degrades to an art-less listing rather than an error.
|
||||
|
||||
## Keyboard map
|
||||
|
||||
| Where | Key | Action |
|
||||
| --- | --- | --- |
|
||||
| Landing | `m` / `t` | Open Movies / TV Series |
|
||||
| Landing | `←` `→` `↑` `↓` | Move between tiles |
|
||||
| Landing | `Enter` | Open the selected tile |
|
||||
| Section | _any character_ | Filter (fuzzy, fzf-style subsequence scoring) |
|
||||
| Section | `↑` `↓` | Move the selection (scrolls into view) |
|
||||
| Section | `Enter` | Open the title detail |
|
||||
| Section | `m` | Toggle cheeztv on the selection (after arrowing off the search box) |
|
||||
| Section | `Esc` | Clear the search, then return to the landing page |
|
||||
| Detail | `m` | Toggle cheeztv |
|
||||
| Detail | `Esc` | Back to the section list |
|
||||
|
||||
The search box is autofocused, so typing always filters. Arrowing moves focus to
|
||||
the list, which is what frees `m` to act as a shortcut; typing any other
|
||||
character hands focus straight back to the search box.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
make build # dist/mediamark
|
||||
make test # go test -race -count=1 ./...
|
||||
make pre-commit # gofmt + go vet, as CI runs them
|
||||
make lint # golangci-lint
|
||||
uvx pre-commit run --all-files
|
||||
```
|
||||
|
||||
To run against a scratch library with no *arr backends:
|
||||
|
||||
```sh
|
||||
mkdir -p /tmp/mm/{fafflix,cheeztv}/{movies,tvseries}
|
||||
mkdir -p "/tmp/mm/fafflix/movies/Some Film (2019)"
|
||||
MEDIAMARK_MEDIA_ROOT=/tmp/mm ./dist/mediamark
|
||||
```
|
||||
|
||||
Then send the group header yourself, since there is no oauth2-proxy in front:
|
||||
|
||||
```sh
|
||||
curl -H 'X-Forwarded-Groups: akP-mediamark-user' localhost:8080/api/library/movies
|
||||
```
|
||||
|
||||
## Release
|
||||
|
||||
Tagging `v*` (via `make patch|minor|major`) builds and pushes
|
||||
`artifactapi.k8s.syd1.au.unkin.net/docker-internal/mediamark:<tag>`.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Command mediamark serves the media-marking web app: a single binary carrying
|
||||
// its own UI that hardlinks titles from the fafflix library into the cheeztv
|
||||
// kids tree.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/mediamark/internal/arr"
|
||||
"git.unkin.net/unkin/mediamark/internal/config"
|
||||
"git.unkin.net/unkin/mediamark/internal/library"
|
||||
"git.unkin.net/unkin/mediamark/internal/server"
|
||||
"git.unkin.net/unkin/mediamark/ui"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Error("config", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
keys := arr.FileKeys(cfg.KeysDir)
|
||||
lib := library.New(cfg.MediaRoot)
|
||||
arrs := map[library.Section]*arr.Client{
|
||||
library.SectionTVSeries: arr.New(arr.KindSonarr, cfg.SonarrURL, keys),
|
||||
library.SectionMovies: arr.New(arr.KindRadarr, cfg.RadarrURL, keys),
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Listen,
|
||||
Handler: server.New(cfg, lib, arrs, ui.Assets(), log).Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
go func() {
|
||||
log.Info("mediamark listening",
|
||||
"addr", cfg.Listen, "version", version,
|
||||
"mediaRoot", cfg.MediaRoot, "allowedGroups", cfg.AllowedGroups)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Error("serve", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
log.Info("shutting down")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
log.Error("shutdown", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// Package arr is a minimal typed client for the sonarr/radarr v3 APIs, covering
|
||||
// only what mediamark needs: the series/movie list (for titles, artwork ids and
|
||||
// metadata) and a poster proxy.
|
||||
//
|
||||
// The api key is read from a mounted secret on every call so a Vault Secrets
|
||||
// Operator rotation is picked up without a restart, and it is never echoed into
|
||||
// a response or an error string.
|
||||
package arr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// maxListBytes caps a list response; the estate's libraries are a few MB of
|
||||
// JSON at most and an unbounded read from a compromised upstream is a DoS.
|
||||
const maxListBytes = 32 << 20
|
||||
|
||||
// maxArtBytes caps a proxied poster.
|
||||
const maxArtBytes = 16 << 20
|
||||
|
||||
// ErrNoKey is returned when no api key is available for the app.
|
||||
var ErrNoKey = errors.New("no api key available")
|
||||
|
||||
// Kind identifies which *arr an entry came from.
|
||||
type Kind string
|
||||
|
||||
// The supported *arr kinds.
|
||||
const (
|
||||
KindSonarr Kind = "sonarr"
|
||||
KindRadarr Kind = "radarr"
|
||||
)
|
||||
|
||||
// Entry is the subset of a series/movie record mediamark shows.
|
||||
type Entry struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
Overview string `json:"overview"`
|
||||
Status string `json:"status"`
|
||||
// Path is the *arr's on-disk directory; its basename matches the library
|
||||
// directory name.
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// Dir returns the library directory name for the entry.
|
||||
func (e Entry) Dir() string {
|
||||
if e.Path == "" {
|
||||
return ""
|
||||
}
|
||||
return path.Base(strings.TrimRight(strings.ReplaceAll(e.Path, `\`, "/"), "/"))
|
||||
}
|
||||
|
||||
// KeyFunc returns the api key for an app name ("sonarr"/"radarr").
|
||||
type KeyFunc func(app string) (string, bool)
|
||||
|
||||
// Client talks to one *arr instance.
|
||||
type Client struct {
|
||||
kind Kind
|
||||
baseURL string
|
||||
key KeyFunc
|
||||
http *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
cached []Entry
|
||||
cachedAt time.Time
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// New builds a Client for baseURL (which already includes the instance UrlBase).
|
||||
func New(kind Kind, baseURL string, key KeyFunc) *Client {
|
||||
return &Client{
|
||||
kind: kind,
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
key: key,
|
||||
http: &http.Client{Timeout: 30 * time.Second},
|
||||
ttl: 60 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// SetCacheTTL overrides the list cache lifetime (tests use a zero TTL).
|
||||
func (c *Client) SetCacheTTL(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.ttl = d
|
||||
}
|
||||
|
||||
// listPath is the collection endpoint for the kind.
|
||||
func (c *Client) listPath() string {
|
||||
if c.kind == KindSonarr {
|
||||
return "/api/v3/series"
|
||||
}
|
||||
return "/api/v3/movie"
|
||||
}
|
||||
|
||||
// List returns the instance's entries, served from a short-lived cache so list
|
||||
// pages stay fast. A failed refresh does not poison the cache.
|
||||
func (c *Client) List(ctx context.Context) ([]Entry, error) {
|
||||
c.mu.Lock()
|
||||
if c.cached != nil && c.ttl > 0 && time.Since(c.cachedAt) < c.ttl {
|
||||
out := c.cached
|
||||
c.mu.Unlock()
|
||||
return out, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
entries, err := c.fetchList(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.cached, c.cachedAt = entries, time.Now()
|
||||
c.mu.Unlock()
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (c *Client) fetchList(ctx context.Context) ([]Entry, error) {
|
||||
resp, err := c.do(ctx, c.listPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s list: unexpected status %d", c.kind, resp.StatusCode)
|
||||
}
|
||||
out := []Entry{}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, maxListBytes)).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("%s list: decode: %w", c.kind, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Art is a proxied poster response the caller must close.
|
||||
type Art struct {
|
||||
Body io.ReadCloser
|
||||
ContentType string
|
||||
}
|
||||
|
||||
// Poster streams the 250px poster for an entry id.
|
||||
func (c *Client) Poster(ctx context.Context, id int) (*Art, error) {
|
||||
resp, err := c.do(ctx, "/api/v3/mediacover/"+strconv.Itoa(id)+"/poster-250.jpg")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_ = resp.Body.Close()
|
||||
return nil, fmt.Errorf("%s poster %d: unexpected status %d", c.kind, id, resp.StatusCode)
|
||||
}
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if ct == "" {
|
||||
ct = "image/jpeg"
|
||||
}
|
||||
return &Art{
|
||||
Body: struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}{io.LimitReader(resp.Body, maxArtBytes), resp.Body},
|
||||
ContentType: ct,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// do issues an authenticated GET. Errors deliberately name only the app and
|
||||
// path so a key can never reach a log line or an HTTP body.
|
||||
func (c *Client) do(ctx context.Context, p string) (*http.Response, error) {
|
||||
key, ok := c.key(string(c.kind))
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: %w", c.kind, ErrNoKey)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+p, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s %s: build request", c.kind, p)
|
||||
}
|
||||
req.Header.Set("X-Api-Key", key)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s %s: request failed", c.kind, p)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// FileKeys reads *arr api keys from a mounted secret directory, re-reading on
|
||||
// every call so rotations land without a restart. ARR_<APP>_APIKEY overrides it
|
||||
// for local development.
|
||||
func FileKeys(dir string) KeyFunc {
|
||||
return func(app string) (string, bool) {
|
||||
env := "ARR_" + strings.ToUpper(app) + "_APIKEY"
|
||||
if v := strings.TrimSpace(os.Getenv(env)); v != "" {
|
||||
return v, true
|
||||
}
|
||||
if dir == "" {
|
||||
return "", false
|
||||
}
|
||||
// app is a fixed internal constant, never user input, but keep the
|
||||
// read confined to the directory anyway.
|
||||
if strings.ContainsAny(app, `/\`) {
|
||||
return "", false
|
||||
}
|
||||
b, err := os.ReadFile(path.Join(dir, app))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
key := strings.TrimSpace(string(b))
|
||||
return key, key != ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package arr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testKey = "s3cr3t-api-key-value"
|
||||
|
||||
func staticKey(string) (string, bool) { return testKey, true }
|
||||
|
||||
func TestListSonarrAndRadarrHitTheRightEndpoint(t *testing.T) {
|
||||
cases := []struct {
|
||||
kind Kind
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{KindSonarr, "/base/api/v3/series", `[{"id":7,"title":"Bluey","year":2018,"overview":"dogs","status":"continuing","path":"/media/fafflix/tvseries/Bluey"}]`},
|
||||
{KindRadarr, "/base/api/v3/movie", `[{"id":9,"title":"Nemo","year":2003,"overview":"fish","status":"released","path":"/media/fafflix/movies/Finding Nemo (2003)"}]`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(string(c.kind), func(t *testing.T) {
|
||||
var gotPath, gotKey string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath, gotKey = r.URL.Path, r.Header.Get("X-Api-Key")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, c.body)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cl := New(c.kind, srv.URL+"/base", staticKey)
|
||||
entries, err := cl.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotPath != c.path {
|
||||
t.Errorf("path = %q, want %q", gotPath, c.path)
|
||||
}
|
||||
if gotKey != testKey {
|
||||
t.Errorf("X-Api-Key = %q", gotKey)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].Title == "" || entries[0].Year == 0 {
|
||||
t.Fatalf("entries = %+v", entries)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryDirMatchesLibraryDirectory(t *testing.T) {
|
||||
cases := []struct{ path, want string }{
|
||||
{"/media/fafflix/movies/Finding Nemo (2003)", "Finding Nemo (2003)"},
|
||||
{"/media/fafflix/tvseries/Bluey/", "Bluey"},
|
||||
{`C:\media\movies\Cars`, "Cars"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := (Entry{Path: c.path}).Dir(); got != c.want {
|
||||
t.Errorf("Dir(%q) = %q, want %q", c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCachesAndExpires(t *testing.T) {
|
||||
var calls int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
_, _ = io.WriteString(w, `[{"id":1,"title":"A","path":"/x/A"}]`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cl := New(KindSonarr, srv.URL, staticKey)
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := cl.List(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("upstream calls = %d, want 1 (cached)", got)
|
||||
}
|
||||
|
||||
cl.SetCacheTTL(time.Nanosecond)
|
||||
time.Sleep(time.Millisecond)
|
||||
if _, err := cl.List(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 2 {
|
||||
t.Fatalf("upstream calls after expiry = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListErrorsNeverLeakTheKey(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
// Echo the key back the way a chatty upstream error page might.
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = io.WriteString(w, "bad key "+testKey)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cl := New(KindSonarr, srv.URL, staticKey)
|
||||
_, err := cl.List(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("List succeeded on a 401")
|
||||
}
|
||||
if strings.Contains(err.Error(), testKey) {
|
||||
t.Fatalf("error leaked the api key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoKeyFailsClosed(t *testing.T) {
|
||||
cl := New(KindSonarr, "http://unused.invalid", func(string) (string, bool) { return "", false })
|
||||
if _, err := cl.List(context.Background()); !errors.Is(err, ErrNoKey) {
|
||||
t.Fatalf("List = %v, want ErrNoKey", err)
|
||||
}
|
||||
if _, err := cl.Poster(context.Background(), 1); !errors.Is(err, ErrNoKey) {
|
||||
t.Fatalf("Poster = %v, want ErrNoKey", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPosterStreamsWithContentType(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v3/mediacover/42/poster-250.jpg" {
|
||||
t.Errorf("poster path = %q", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("X-Api-Key") != testKey {
|
||||
t.Error("poster request missing the api key")
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write([]byte{0x89, 'P', 'N', 'G'})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cl := New(KindRadarr, srv.URL, staticKey)
|
||||
art, err := cl.Poster(context.Background(), 42)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = art.Body.Close() }()
|
||||
if art.ContentType != "image/png" {
|
||||
t.Errorf("ContentType = %q", art.ContentType)
|
||||
}
|
||||
b, err := io.ReadAll(art.Body)
|
||||
if err != nil || len(b) != 4 {
|
||||
t.Fatalf("body = %v %v", b, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPosterMissingIsAnError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "not found "+testKey, http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cl := New(KindSonarr, srv.URL, staticKey)
|
||||
_, err := cl.Poster(context.Background(), 1)
|
||||
if err == nil {
|
||||
t.Fatal("Poster succeeded on a 404")
|
||||
}
|
||||
if strings.Contains(err.Error(), testKey) {
|
||||
t.Fatalf("error leaked the api key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileKeysReReadsAfterRotation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sonarr")
|
||||
if err := os.WriteFile(path, []byte("first\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kf := FileKeys(dir)
|
||||
if k, ok := kf("sonarr"); !ok || k != "first" {
|
||||
t.Fatalf("key = %q %v", k, ok)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(" second "), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if k, ok := kf("sonarr"); !ok || k != "second" {
|
||||
t.Fatalf("rotated key = %q %v, want trimmed second", k, ok)
|
||||
}
|
||||
if _, ok := kf("radarr"); ok {
|
||||
t.Fatal("missing key file reported ok")
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "empty"), []byte(" \n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := kf("empty"); ok {
|
||||
t.Fatal("blank key file reported ok")
|
||||
}
|
||||
if _, ok := kf("../etc/passwd"); ok {
|
||||
t.Fatal("traversal key name reported ok")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileKeysEnvOverride(t *testing.T) {
|
||||
t.Setenv("ARR_SONARR_APIKEY", "from-env")
|
||||
kf := FileKeys(t.TempDir())
|
||||
if k, ok := kf("sonarr"); !ok || k != "from-env" {
|
||||
t.Fatalf("key = %q %v, want from-env", k, ok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Package auth enforces Authentik group membership from the oauth2-proxy
|
||||
// identity header. oauth2-proxy already gates the route, but mediamark mutates
|
||||
// the on-disk library, so it re-checks the group server-side rather than
|
||||
// trusting the front door alone.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.unkin.net/unkin/mediamark/internal/config"
|
||||
)
|
||||
|
||||
// Middleware rejects requests whose group header carries none of the allowed
|
||||
// groups. header is the request header to read; allowed must be non-empty.
|
||||
type Middleware struct {
|
||||
header string
|
||||
allowed map[string]bool
|
||||
}
|
||||
|
||||
// New builds a Middleware. An empty allowed set denies everything, which is the
|
||||
// correct fail-closed behaviour if config validation is ever bypassed.
|
||||
func New(header string, allowed []string) *Middleware {
|
||||
m := &Middleware{header: header, allowed: make(map[string]bool, len(allowed))}
|
||||
for _, g := range allowed {
|
||||
m.allowed[g] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Permit reports whether the request carries an allowed group.
|
||||
func (m *Middleware) Permit(r *http.Request) bool {
|
||||
if len(m.allowed) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, v := range r.Header.Values(m.header) {
|
||||
for _, g := range config.ParseGroups(v) {
|
||||
if m.allowed[g] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Wrap gates next behind Permit, answering 403 with a plain body that never
|
||||
// echoes the submitted groups back to the caller.
|
||||
func (m *Middleware) Wrap(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !m.Permit(r) {
|
||||
http.Error(w, "forbidden: missing required group", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPermit(t *testing.T) {
|
||||
m := New("X-Forwarded-Groups", []string{"akP-mediamark-user", "akR-admins"})
|
||||
cases := []struct {
|
||||
name string
|
||||
values []string
|
||||
want bool
|
||||
}{
|
||||
{"no header", nil, false},
|
||||
{"empty header", []string{""}, false},
|
||||
{"exact match", []string{"akP-mediamark-user"}, true},
|
||||
{"comma list containing match", []string{"akP-other,akP-mediamark-user,akP-x"}, true},
|
||||
{"space separated", []string{"akP-other akR-admins"}, true},
|
||||
{"padded", []string{" akP-mediamark-user "}, true},
|
||||
{"repeated header lines", []string{"akP-nope", "akR-admins"}, true},
|
||||
{"unrelated groups only", []string{"akP-arrstack-kids,akP-nope"}, false},
|
||||
{"prefix lookalike", []string{"akP-mediamark-users"}, false},
|
||||
{"substring lookalike", []string{"xakP-mediamark-user"}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/library/movies", nil)
|
||||
for _, v := range c.values {
|
||||
r.Header.Add("X-Forwarded-Groups", v)
|
||||
}
|
||||
if got := m.Permit(r); got != c.want {
|
||||
t.Fatalf("Permit = %v, want %v", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyAllowListDeniesEverything(t *testing.T) {
|
||||
m := New("X-Forwarded-Groups", nil)
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set("X-Forwarded-Groups", "anything")
|
||||
if m.Permit(r) {
|
||||
t.Fatal("empty allow-list permitted a request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapBlocksAndPasses(t *testing.T) {
|
||||
m := New("X-Grp", []string{"good"})
|
||||
called := false
|
||||
h := m.Wrap(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/library/movies/x/mark", nil)
|
||||
req.Header.Set("X-Grp", "bad,worse")
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("handler ran despite a forbidden request")
|
||||
}
|
||||
// The rejection must not echo the submitted groups back.
|
||||
if strings.Contains(rec.Body.String(), "worse") {
|
||||
t.Fatalf("403 body echoed submitted groups: %q", rec.Body.String())
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/library/movies/x/mark", nil)
|
||||
req.Header.Set("X-Grp", "good")
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusTeapot || !called {
|
||||
t.Fatalf("allowed request not passed through: status %d called %v", rec.Code, called)
|
||||
}
|
||||
}
|
||||
|
||||
// The configured header is the only one trusted; a client-forged alternative
|
||||
// must not grant access.
|
||||
func TestOnlyConfiguredHeaderIsRead(t *testing.T) {
|
||||
m := New("X-Auth-Request-Groups", []string{"good"})
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set("X-Forwarded-Groups", "good")
|
||||
if m.Permit(r) {
|
||||
t.Fatal("a non-configured header granted access")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Package config loads mediamark runtime configuration from the environment.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config is the fully-resolved mediamark configuration.
|
||||
type Config struct {
|
||||
// Listen is the HTTP listen address, e.g. ":8080".
|
||||
Listen string
|
||||
// MediaRoot holds the fafflix/ (source) and cheeztv/ (kids) library trees.
|
||||
MediaRoot string
|
||||
// KeysDir holds per-app *arr api keys as files named "sonarr"/"radarr",
|
||||
// projected by the Vault Secrets Operator and re-read on every use.
|
||||
KeysDir string
|
||||
// SonarrURL and RadarrURL include the *arr instance's UrlBase; the API
|
||||
// lives at <url>/api/v3/...
|
||||
SonarrURL string
|
||||
RadarrURL string
|
||||
// GroupsHeader is the oauth2-proxy header carrying Authentik group names.
|
||||
GroupsHeader string
|
||||
// AllowedGroups gates every page load and API call. Never empty.
|
||||
AllowedGroups []string
|
||||
}
|
||||
|
||||
// Load resolves configuration from the environment, failing closed on an empty
|
||||
// allow-list (an empty list would authorize nobody or, worse, be read as
|
||||
// "anyone" by a future refactor).
|
||||
func Load() (*Config, error) {
|
||||
c := &Config{
|
||||
Listen: envOr("MEDIAMARK_LISTEN", ":8080"),
|
||||
MediaRoot: envOr("MEDIAMARK_MEDIA_ROOT", "/media"),
|
||||
KeysDir: envOr("MEDIAMARK_KEYS_DIR", "/etc/mediamark/keys"),
|
||||
SonarrURL: envOr("MEDIAMARK_SONARR_URL", "http://sonarr.arrstack.svc.cluster.local:8989/3aa168/sonarr"),
|
||||
RadarrURL: envOr("MEDIAMARK_RADARR_URL", "http://radarr.arrstack.svc.cluster.local:7878/3aa168/radarr"),
|
||||
GroupsHeader: envOr("MEDIAMARK_GROUPS_HEADER", "X-Forwarded-Groups"),
|
||||
AllowedGroups: ParseGroups(envOr("MEDIAMARK_ALLOWED_GROUPS", "akP-mediamark-user")),
|
||||
}
|
||||
|
||||
if len(c.AllowedGroups) == 0 {
|
||||
return nil, fmt.Errorf("MEDIAMARK_ALLOWED_GROUPS must name at least one group")
|
||||
}
|
||||
if strings.TrimSpace(c.GroupsHeader) == "" {
|
||||
return nil, fmt.Errorf("MEDIAMARK_GROUPS_HEADER must not be empty")
|
||||
}
|
||||
if !filepath.IsAbs(c.MediaRoot) {
|
||||
return nil, fmt.Errorf("MEDIAMARK_MEDIA_ROOT %q must be an absolute path", c.MediaRoot)
|
||||
}
|
||||
c.MediaRoot = filepath.Clean(c.MediaRoot)
|
||||
for _, u := range []struct{ name, val string }{{"MEDIAMARK_SONARR_URL", c.SonarrURL}, {"MEDIAMARK_RADARR_URL", c.RadarrURL}} {
|
||||
if !strings.HasPrefix(u.val, "http://") && !strings.HasPrefix(u.val, "https://") {
|
||||
return nil, fmt.Errorf("%s %q must be an http(s) URL", u.name, u.val)
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// ParseGroups splits a group list tolerating both comma and whitespace
|
||||
// separation, dropping empties. oauth2-proxy emits comma-separated groups but
|
||||
// deployments hand-write the allow-list.
|
||||
func ParseGroups(s string) []string {
|
||||
fields := strings.FieldsFunc(s, func(r rune) bool {
|
||||
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == ';'
|
||||
})
|
||||
out := make([]string, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
if f = strings.TrimSpace(f); f != "" {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseGroups(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"", []string{}},
|
||||
{"akP-mediamark-user", []string{"akP-mediamark-user"}},
|
||||
{"a,b,c", []string{"a", "b", "c"}},
|
||||
{" a , b ,, c ", []string{"a", "b", "c"}},
|
||||
{"a b\tc", []string{"a", "b", "c"}},
|
||||
{"a,\nb;c", []string{"a", "b", "c"}},
|
||||
{",,,", []string{}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ParseGroups(c.in); !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("ParseGroups(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Listen != ":8080" || cfg.MediaRoot != "/media" || cfg.KeysDir != "/etc/mediamark/keys" {
|
||||
t.Fatalf("unexpected defaults: %+v", cfg)
|
||||
}
|
||||
if cfg.GroupsHeader != "X-Forwarded-Groups" {
|
||||
t.Fatalf("GroupsHeader = %q", cfg.GroupsHeader)
|
||||
}
|
||||
if !reflect.DeepEqual(cfg.AllowedGroups, []string{"akP-mediamark-user"}) {
|
||||
t.Fatalf("AllowedGroups = %v", cfg.AllowedGroups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFailsClosed(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
}{
|
||||
{"blank allowed groups", map[string]string{"MEDIAMARK_ALLOWED_GROUPS": " , , "}},
|
||||
{"relative media root", map[string]string{"MEDIAMARK_MEDIA_ROOT": "media"}},
|
||||
{"non-http sonarr url", map[string]string{"MEDIAMARK_SONARR_URL": "sonarr:8989"}},
|
||||
{"non-http radarr url", map[string]string{"MEDIAMARK_RADARR_URL": "ftp://radarr"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
for k, v := range c.env {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load succeeded, want error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOverrides(t *testing.T) {
|
||||
t.Setenv("MEDIAMARK_LISTEN", ":9999")
|
||||
t.Setenv("MEDIAMARK_MEDIA_ROOT", "/srv/media/")
|
||||
t.Setenv("MEDIAMARK_ALLOWED_GROUPS", "akP-mediamark-user, akR-admins")
|
||||
t.Setenv("MEDIAMARK_GROUPS_HEADER", "X-Auth-Request-Groups")
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Listen != ":9999" {
|
||||
t.Errorf("Listen = %q", cfg.Listen)
|
||||
}
|
||||
if cfg.MediaRoot != "/srv/media" {
|
||||
t.Errorf("MediaRoot = %q, want cleaned /srv/media", cfg.MediaRoot)
|
||||
}
|
||||
if !reflect.DeepEqual(cfg.AllowedGroups, []string{"akP-mediamark-user", "akR-admins"}) {
|
||||
t.Errorf("AllowedGroups = %v", cfg.AllowedGroups)
|
||||
}
|
||||
if cfg.GroupsHeader != "X-Auth-Request-Groups" {
|
||||
t.Errorf("GroupsHeader = %q", cfg.GroupsHeader)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// Package library models the two-tree media layout and the hardlink sync that
|
||||
// marks a title as kids ("cheeztv") content.
|
||||
//
|
||||
// Layout under the media root:
|
||||
//
|
||||
// fafflix/{tvseries,movies}/<title>/... source library
|
||||
// cheeztv/{tvseries,movies}/<title>/... kids tree (hardlinks into fafflix)
|
||||
//
|
||||
// Marking a title hardlinks every regular file across; unmarking removes only
|
||||
// the kids-side directory, which never destroys data because the source inode
|
||||
// keeps a link.
|
||||
package library
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Tree names, relative to the media root.
|
||||
const (
|
||||
SourceTree = "fafflix"
|
||||
KidsTree = "cheeztv"
|
||||
)
|
||||
|
||||
// Section is a library section directory shared by both trees.
|
||||
type Section string
|
||||
|
||||
// The supported sections.
|
||||
const (
|
||||
SectionTVSeries Section = "tvseries"
|
||||
SectionMovies Section = "movies"
|
||||
)
|
||||
|
||||
// Sections lists every valid section in display order.
|
||||
var Sections = []Section{SectionMovies, SectionTVSeries}
|
||||
|
||||
// ErrInvalidSection is returned for a section outside Sections.
|
||||
var ErrInvalidSection = errors.New("invalid section")
|
||||
|
||||
// ErrInvalidName is returned when a title name is not a single, clean path
|
||||
// element — the guard against path traversal out of the library roots.
|
||||
var ErrInvalidName = errors.New("invalid title name")
|
||||
|
||||
// ErrNotFound is returned when a title has no source directory.
|
||||
var ErrNotFound = errors.New("title not found")
|
||||
|
||||
// ParseSection validates s against the known sections.
|
||||
func ParseSection(s string) (Section, error) {
|
||||
for _, k := range Sections {
|
||||
if string(k) == s {
|
||||
return k, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%w: %q", ErrInvalidSection, s)
|
||||
}
|
||||
|
||||
// ValidateName rejects anything that is not a single, clean path element:
|
||||
// empties, dot entries, absolute paths, and any name containing a separator.
|
||||
// This is the sole traversal guard between an HTTP path segment and the
|
||||
// filesystem, so it is deliberately strict rather than sanitising.
|
||||
func ValidateName(name string) error {
|
||||
switch {
|
||||
case name == "":
|
||||
return fmt.Errorf("%w: empty", ErrInvalidName)
|
||||
case name == "." || name == "..":
|
||||
return fmt.Errorf("%w: dot entry", ErrInvalidName)
|
||||
// A backslash is a legal byte in a Linux filename, but it is a separator on
|
||||
// the platforms that write into these libraries; reject it rather than
|
||||
// reason about which side produced the name.
|
||||
case strings.ContainsRune(name, '/'), strings.ContainsRune(name, '\\'), strings.ContainsRune(name, os.PathSeparator):
|
||||
return fmt.Errorf("%w: contains a path separator", ErrInvalidName)
|
||||
case strings.ContainsRune(name, 0):
|
||||
return fmt.Errorf("%w: contains NUL", ErrInvalidName)
|
||||
case filepath.IsAbs(name):
|
||||
return fmt.Errorf("%w: absolute path", ErrInvalidName)
|
||||
case name != filepath.Clean(name):
|
||||
return fmt.Errorf("%w: not a clean path element", ErrInvalidName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Title is the reported state of one library title.
|
||||
type Title struct {
|
||||
Name string `json:"name"`
|
||||
// Marked is true when the kids tree has a directory of the same name.
|
||||
Marked bool `json:"marked"`
|
||||
// NeedsSync is true for a marked title whose source holds regular files
|
||||
// that are not linked into the kids tree (new episodes, upgrades).
|
||||
NeedsSync bool `json:"needsSync"`
|
||||
// SizeBytes and FileCount describe the source directory.
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
FileCount int `json:"fileCount"`
|
||||
// UnlinkedFiles counts the source files missing from the kids tree; zero
|
||||
// for an unmarked title.
|
||||
UnlinkedFiles int `json:"unlinkedFiles"`
|
||||
}
|
||||
|
||||
// Library reads and mutates the two trees under Root.
|
||||
type Library struct {
|
||||
Root string
|
||||
}
|
||||
|
||||
// New returns a Library rooted at the (cleaned) media root.
|
||||
func New(root string) *Library { return &Library{Root: filepath.Clean(root)} }
|
||||
|
||||
// sourceRoot and kidsRoot are the per-section tree roots.
|
||||
func (l *Library) sourceRoot(s Section) string { return filepath.Join(l.Root, SourceTree, string(s)) }
|
||||
func (l *Library) kidsRoot(s Section) string { return filepath.Join(l.Root, KidsTree, string(s)) }
|
||||
|
||||
// paths resolves the source and kids directories for a validated title, and
|
||||
// re-verifies containment: ValidateName already makes escape impossible, but
|
||||
// the check costs nothing and keeps the invariant local to the filesystem call.
|
||||
func (l *Library) paths(section Section, name string) (src, kids string, err error) {
|
||||
if _, err := ParseSection(string(section)); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := ValidateName(name); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
src = filepath.Join(l.sourceRoot(section), name)
|
||||
kids = filepath.Join(l.kidsRoot(section), name)
|
||||
if !under(l.sourceRoot(section), src) || !under(l.kidsRoot(section), kids) {
|
||||
return "", "", fmt.Errorf("%w: escapes the library root", ErrInvalidName)
|
||||
}
|
||||
return src, kids, nil
|
||||
}
|
||||
|
||||
// under reports whether path is root itself or lies beneath it.
|
||||
func under(root, path string) bool {
|
||||
rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
|
||||
}
|
||||
|
||||
// Ready reports whether the media root is visible, backing /readyz.
|
||||
func (l *Library) Ready() error {
|
||||
fi, err := os.Stat(l.Root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
return fmt.Errorf("media root %q is not a directory", l.Root)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List reports every title in a section, sorted case-insensitively by name.
|
||||
func (l *Library) List(section Section) ([]Title, error) {
|
||||
if _, err := ParseSection(string(section)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(l.sourceRoot(section))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []Title{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Title, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || ValidateName(e.Name()) != nil {
|
||||
continue
|
||||
}
|
||||
t, err := l.Stat(section, e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
li, lj := strings.ToLower(out[i].Name), strings.ToLower(out[j].Name)
|
||||
if li == lj {
|
||||
return out[i].Name < out[j].Name
|
||||
}
|
||||
return li < lj
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Stat reports one title's size, file count, marked state and link drift.
|
||||
func (l *Library) Stat(section Section, name string) (Title, error) {
|
||||
src, kids, err := l.paths(section, name)
|
||||
if err != nil {
|
||||
return Title{}, err
|
||||
}
|
||||
fi, err := os.Stat(src)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return Title{}, fmt.Errorf("%w: %s/%s", ErrNotFound, section, name)
|
||||
}
|
||||
return Title{}, err
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
return Title{}, fmt.Errorf("%w: %s/%s", ErrNotFound, section, name)
|
||||
}
|
||||
|
||||
t := Title{Name: name}
|
||||
if kfi, err := os.Stat(kids); err == nil && kfi.IsDir() {
|
||||
t.Marked = true
|
||||
}
|
||||
|
||||
err = filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
// A vanished file mid-walk is normal on a live library; skip it
|
||||
// rather than failing the whole listing.
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
t.FileCount++
|
||||
t.SizeBytes += info.Size()
|
||||
if !t.Marked {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(src, p)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if !sameFile(p, filepath.Join(kids, rel)) {
|
||||
t.UnlinkedFiles++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Title{}, err
|
||||
}
|
||||
t.NeedsSync = t.Marked && t.UnlinkedFiles > 0
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// sameFile reports whether both paths resolve to the same inode.
|
||||
func sameFile(a, b string) bool {
|
||||
ai, err := os.Lstat(a)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
bi, err := os.Lstat(b)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return os.SameFile(ai, bi)
|
||||
}
|
||||
|
||||
// SyncResult reports what a Mark run changed.
|
||||
type SyncResult struct {
|
||||
Linked int `json:"linked"`
|
||||
Replaced int `json:"replaced"`
|
||||
Unchanged int `json:"unchanged"`
|
||||
Skipped int `json:"skipped"`
|
||||
Dirs int `json:"dirs"`
|
||||
}
|
||||
|
||||
// Mark hardlinks every regular file of a title into the kids tree, creating the
|
||||
// directory skeleton as it goes. It is idempotent: files already linked to the
|
||||
// same inode are left alone, a kids-side file pointing at a different inode is
|
||||
// replaced, and symlinks/devices/sockets are skipped. Re-running after new
|
||||
// episodes arrive syncs only the new files.
|
||||
func (l *Library) Mark(section Section, name string) (SyncResult, error) {
|
||||
src, kids, err := l.paths(section, name)
|
||||
if err != nil {
|
||||
return SyncResult{}, err
|
||||
}
|
||||
fi, err := os.Stat(src)
|
||||
if err != nil || !fi.IsDir() {
|
||||
return SyncResult{}, fmt.Errorf("%w: %s/%s", ErrNotFound, section, name)
|
||||
}
|
||||
if err := os.MkdirAll(kids, 0o755); err != nil {
|
||||
return SyncResult{}, err
|
||||
}
|
||||
|
||||
var res SyncResult
|
||||
walkErr := filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(src, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
dst := filepath.Join(kids, rel)
|
||||
if !under(kids, dst) {
|
||||
// Unreachable for a well-formed walk; belt-and-braces against a
|
||||
// crafted name surviving the entry guards.
|
||||
return fmt.Errorf("refusing to write outside the kids tree: %s", dst)
|
||||
}
|
||||
switch {
|
||||
case d.IsDir():
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
res.Dirs++
|
||||
return nil
|
||||
case !d.Type().IsRegular():
|
||||
res.Skipped++
|
||||
return nil
|
||||
}
|
||||
return linkFile(p, dst, &res)
|
||||
})
|
||||
if walkErr != nil {
|
||||
return res, walkErr
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// linkFile hardlinks src to dst, replacing a stale link to a different inode.
|
||||
func linkFile(src, dst string, res *SyncResult) error {
|
||||
di, err := os.Lstat(dst)
|
||||
switch {
|
||||
case err == nil:
|
||||
si, serr := os.Lstat(src)
|
||||
if serr == nil && di.Mode().IsRegular() && os.SameFile(si, di) {
|
||||
res.Unchanged++
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Link(src, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
res.Replaced++
|
||||
return nil
|
||||
case !os.IsNotExist(err):
|
||||
return err
|
||||
}
|
||||
if err := os.Link(src, dst); err != nil {
|
||||
// A file that disappeared between the walk and the link is not fatal.
|
||||
if os.IsNotExist(err) {
|
||||
res.Skipped++
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
res.Linked++
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unmark removes a title's kids-tree directory. Only hardlinks live there, so
|
||||
// the source library is untouched. Removing an already-absent title succeeds.
|
||||
func (l *Library) Unmark(section Section, name string) error {
|
||||
_, kids, err := l.paths(section, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.RemoveAll(kids)
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newLib builds a media root with the four tree/section dirs and returns it.
|
||||
func newLib(t *testing.T) *Library {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for _, tree := range []string{SourceTree, KidsTree} {
|
||||
for _, s := range Sections {
|
||||
if err := os.MkdirAll(filepath.Join(root, tree, string(s)), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return New(root)
|
||||
}
|
||||
|
||||
// writeSrc creates a source file with content, making parents as needed.
|
||||
func writeSrc(t *testing.T, l *Library, s Section, rel, content string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(l.sourceRoot(s), rel)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestValidateNameRejectsTraversal(t *testing.T) {
|
||||
bad := []string{
|
||||
"", ".", "..", "../etc", "..", "a/b", "/abs", "/etc/passwd",
|
||||
"sub/../..", "./x", "x/", "a\x00b", "..\\evil",
|
||||
}
|
||||
for _, name := range bad {
|
||||
if err := ValidateName(name); err == nil {
|
||||
t.Errorf("ValidateName(%q) = nil, want error", name)
|
||||
} else if !errors.Is(err, ErrInvalidName) {
|
||||
t.Errorf("ValidateName(%q) error %v, want ErrInvalidName", name, err)
|
||||
}
|
||||
}
|
||||
good := []string{"The Muppets", "Movie (2019)", "a.b.c", "...", "dot.dir", "Ünïcødé"}
|
||||
for _, name := range good {
|
||||
if err := ValidateName(name); err != nil {
|
||||
t.Errorf("ValidateName(%q) = %v, want nil", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathsRejectsTraversalAndKeepsContainment(t *testing.T) {
|
||||
l := newLib(t)
|
||||
if _, _, err := l.paths(SectionMovies, "../../etc"); err == nil {
|
||||
t.Fatal("paths accepted a traversal name")
|
||||
}
|
||||
src, kids, err := l.paths(SectionMovies, "Nemo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !under(l.sourceRoot(SectionMovies), src) || !under(l.kidsRoot(SectionMovies), kids) {
|
||||
t.Fatalf("resolved paths escaped roots: %s %s", src, kids)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkOperationsCannotEscapeRoot proves a crafted name never touches a file
|
||||
// outside the media root, even when the target already exists there.
|
||||
func TestMarkOperationsCannotEscapeRoot(t *testing.T) {
|
||||
l := newLib(t)
|
||||
outside := filepath.Join(t.TempDir(), "victim")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"../../../" + filepath.Base(outside), "..", "../movies"} {
|
||||
if _, err := l.Mark(SectionMovies, name); !errors.Is(err, ErrInvalidName) {
|
||||
t.Errorf("Mark(%q) error = %v, want ErrInvalidName", name, err)
|
||||
}
|
||||
if err := l.Unmark(SectionMovies, name); !errors.Is(err, ErrInvalidName) {
|
||||
t.Errorf("Unmark(%q) error = %v, want ErrInvalidName", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(outside); err != nil {
|
||||
t.Fatalf("victim directory was disturbed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkHardlinksTreeAndIsIdempotent(t *testing.T) {
|
||||
l := newLib(t)
|
||||
writeSrc(t, l, SectionTVSeries, "Bluey/Season 01/e01.mkv", "one")
|
||||
writeSrc(t, l, SectionTVSeries, "Bluey/Season 01/e02.mkv", "two")
|
||||
writeSrc(t, l, SectionTVSeries, "Bluey/poster.jpg", "art")
|
||||
|
||||
res, err := l.Mark(SectionTVSeries, "Bluey")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Linked != 3 {
|
||||
t.Fatalf("Linked = %d, want 3", res.Linked)
|
||||
}
|
||||
|
||||
// Every kids file must share an inode with its source.
|
||||
srcFile := filepath.Join(l.sourceRoot(SectionTVSeries), "Bluey/Season 01/e01.mkv")
|
||||
kidsFile := filepath.Join(l.kidsRoot(SectionTVSeries), "Bluey/Season 01/e01.mkv")
|
||||
if !sameFile(srcFile, kidsFile) {
|
||||
t.Fatal("kids file is not a hardlink of the source")
|
||||
}
|
||||
|
||||
// Re-running links nothing new.
|
||||
res2, err := l.Mark(SectionTVSeries, "Bluey")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res2.Linked != 0 || res2.Unchanged != 3 {
|
||||
t.Fatalf("second Mark = %+v, want 0 linked / 3 unchanged", res2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkSyncsNewFilesAndReplacesStaleLinks(t *testing.T) {
|
||||
l := newLib(t)
|
||||
writeSrc(t, l, SectionTVSeries, "Bluey/e01.mkv", "one")
|
||||
if _, err := l.Mark(SectionTVSeries, "Bluey"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A new episode arrives and an existing file is replaced upstream (a new
|
||||
// inode with the same name, as an *arr upgrade does).
|
||||
writeSrc(t, l, SectionTVSeries, "Bluey/e02.mkv", "two")
|
||||
if err := os.Remove(filepath.Join(l.sourceRoot(SectionTVSeries), "Bluey/e01.mkv")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeSrc(t, l, SectionTVSeries, "Bluey/e01.mkv", "one-upgraded")
|
||||
|
||||
tit, err := l.Stat(SectionTVSeries, "Bluey")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tit.NeedsSync || tit.UnlinkedFiles != 2 {
|
||||
t.Fatalf("drift = %+v, want NeedsSync with 2 unlinked", tit)
|
||||
}
|
||||
|
||||
res, err := l.Mark(SectionTVSeries, "Bluey")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Linked != 1 || res.Replaced != 1 {
|
||||
t.Fatalf("sync = %+v, want 1 linked / 1 replaced", res)
|
||||
}
|
||||
tit, err = l.Stat(SectionTVSeries, "Bluey")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tit.NeedsSync || tit.UnlinkedFiles != 0 {
|
||||
t.Fatalf("post-sync drift = %+v, want none", tit)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(l.kidsRoot(SectionTVSeries), "Bluey/e01.mkv"))
|
||||
if err != nil || string(b) != "one-upgraded" {
|
||||
t.Fatalf("stale link not replaced: %q %v", b, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkSkipsSymlinks(t *testing.T) {
|
||||
l := newLib(t)
|
||||
writeSrc(t, l, SectionMovies, "Nemo/movie.mkv", "film")
|
||||
link := filepath.Join(l.sourceRoot(SectionMovies), "Nemo", "elsewhere.mkv")
|
||||
if err := os.Symlink("/dev/null", link); err != nil {
|
||||
t.Skipf("symlinks unsupported: %v", err)
|
||||
}
|
||||
res, err := l.Mark(SectionMovies, "Nemo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Linked != 1 || res.Skipped != 1 {
|
||||
t.Fatalf("res = %+v, want 1 linked / 1 skipped", res)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(l.kidsRoot(SectionMovies), "Nemo", "elsewhere.mkv")); !os.IsNotExist(err) {
|
||||
t.Fatal("symlink was copied into the kids tree")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarkLeavesSourceIntact(t *testing.T) {
|
||||
l := newLib(t)
|
||||
writeSrc(t, l, SectionMovies, "Nemo/movie.mkv", "film")
|
||||
if _, err := l.Mark(SectionMovies, "Nemo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := l.Unmark(SectionMovies, "Nemo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(l.kidsRoot(SectionMovies), "Nemo")); !os.IsNotExist(err) {
|
||||
t.Fatal("kids directory survived unmark")
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(l.sourceRoot(SectionMovies), "Nemo/movie.mkv"))
|
||||
if err != nil || string(b) != "film" {
|
||||
t.Fatalf("source damaged by unmark: %q %v", b, err)
|
||||
}
|
||||
// Unmarking again is a no-op, not an error.
|
||||
if err := l.Unmark(SectionMovies, "Nemo"); err != nil {
|
||||
t.Fatalf("second Unmark = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatAndListReportSizeCountAndMarks(t *testing.T) {
|
||||
l := newLib(t)
|
||||
writeSrc(t, l, SectionMovies, "Zootopia/movie.mkv", "0123456789")
|
||||
writeSrc(t, l, SectionMovies, "Zootopia/extra.nfo", "abc")
|
||||
writeSrc(t, l, SectionMovies, "aladdin/movie.mkv", "xy")
|
||||
if _, err := l.Mark(SectionMovies, "Zootopia"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := l.Stat(SectionMovies, "Zootopia")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.FileCount != 2 || got.SizeBytes != 13 || !got.Marked || got.NeedsSync {
|
||||
t.Fatalf("Stat = %+v, want 2 files / 13 bytes / marked / synced", got)
|
||||
}
|
||||
|
||||
list, err := l.List(SectionMovies)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("List returned %d titles, want 2", len(list))
|
||||
}
|
||||
// Case-insensitive ordering: "aladdin" before "Zootopia".
|
||||
if list[0].Name != "aladdin" || list[1].Name != "Zootopia" {
|
||||
t.Fatalf("List order = %q, %q", list[0].Name, list[1].Name)
|
||||
}
|
||||
if list[0].Marked || !list[1].Marked {
|
||||
t.Fatalf("marked flags wrong: %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMissingSectionIsEmptyNotError(t *testing.T) {
|
||||
l := New(t.TempDir())
|
||||
got, err := l.List(SectionMovies)
|
||||
if err != nil {
|
||||
t.Fatalf("List = %v, want nil error", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("List = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatMissingTitleIsNotFound(t *testing.T) {
|
||||
l := newLib(t)
|
||||
if _, err := l.Stat(SectionMovies, "Ghost"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("Stat = %v, want ErrNotFound", err)
|
||||
}
|
||||
if _, err := l.Mark(SectionMovies, "Ghost"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("Mark = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSection(t *testing.T) {
|
||||
for _, ok := range []string{"movies", "tvseries"} {
|
||||
if _, err := ParseSection(ok); err != nil {
|
||||
t.Errorf("ParseSection(%q) = %v", ok, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "Movies", "music", "../movies", "tvseries/x"} {
|
||||
if _, err := ParseSection(bad); !errors.Is(err, ErrInvalidSection) {
|
||||
t.Errorf("ParseSection(%q) = %v, want ErrInvalidSection", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReady(t *testing.T) {
|
||||
l := newLib(t)
|
||||
if err := l.Ready(); err != nil {
|
||||
t.Fatalf("Ready = %v", err)
|
||||
}
|
||||
if err := New(filepath.Join(t.TempDir(), "absent")).Ready(); err == nil {
|
||||
t.Fatal("Ready on a missing root = nil, want error")
|
||||
}
|
||||
f := filepath.Join(t.TempDir(), "file")
|
||||
if err := os.WriteFile(f, nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := New(f).Ready(); err == nil {
|
||||
t.Fatal("Ready on a file root = nil, want error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
// Package server wires the mediamark HTTP surface: the library JSON API, the
|
||||
// key-brokered poster proxy, health probes, and the embedded SPA.
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.unkin.net/unkin/mediamark/internal/arr"
|
||||
"git.unkin.net/unkin/mediamark/internal/auth"
|
||||
"git.unkin.net/unkin/mediamark/internal/config"
|
||||
"git.unkin.net/unkin/mediamark/internal/library"
|
||||
)
|
||||
|
||||
// Server holds the resolved dependencies of the app.
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
lib *library.Library
|
||||
arrs map[library.Section]*arr.Client
|
||||
gate *auth.Middleware
|
||||
assets fs.FS
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New constructs a Server. arrs maps a section to the *arr instance that owns
|
||||
// it; a missing entry simply means titles in that section carry no metadata.
|
||||
func New(cfg *config.Config, lib *library.Library, arrs map[library.Section]*arr.Client, assets fs.FS, log *slog.Logger) *Server {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
lib: lib,
|
||||
arrs: arrs,
|
||||
gate: auth.New(cfg.GroupsHeader, cfg.AllowedGroups),
|
||||
assets: assets,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// Handler returns the root handler. Health probes are ungated (kubelet sends no
|
||||
// identity header); everything else — API and UI alike — is behind the group
|
||||
// gate, so an unauthorized user cannot even load the page shell.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /livez", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
mux.HandleFunc("GET /readyz", s.readyz)
|
||||
|
||||
gated := http.NewServeMux()
|
||||
gated.HandleFunc("GET /api/library/{section}", s.handleList)
|
||||
gated.HandleFunc("POST /api/library/{section}/{name}/mark", s.handleMark)
|
||||
gated.HandleFunc("POST /api/library/{section}/{name}/unmark", s.handleUnmark)
|
||||
gated.HandleFunc("GET /api/art/{section}/{id}", s.handleArt)
|
||||
gated.HandleFunc("/", s.handleUI)
|
||||
|
||||
mux.Handle("/", s.gate.Wrap(gated))
|
||||
return mux
|
||||
}
|
||||
|
||||
func (s *Server) readyz(w http.ResponseWriter, _ *http.Request) {
|
||||
if err := s.lib.Ready(); err != nil {
|
||||
s.log.Warn("readyz: media root unavailable", "err", err)
|
||||
http.Error(w, "media root unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// arrMeta is the *arr metadata attached to a library title, or null.
|
||||
type arrMeta struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
Overview string `json:"overview"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// titleView is one row of the library list API.
|
||||
type titleView struct {
|
||||
library.Title
|
||||
Arr *arrMeta `json:"arr"`
|
||||
ArtURL string `json:"artUrl,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
section, ok := s.section(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
titles, err := s.lib.List(section)
|
||||
if err != nil {
|
||||
s.log.Error("list library", "section", section, "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "cannot read library")
|
||||
return
|
||||
}
|
||||
|
||||
// *arr metadata is a nicety: a down sonarr must not blank the list, so a
|
||||
// lookup failure degrades to art-less rows.
|
||||
byDir := map[string]arr.Entry{}
|
||||
if c := s.arrs[section]; c != nil {
|
||||
entries, err := c.List(r.Context())
|
||||
if err != nil {
|
||||
s.log.Warn("arr list failed; serving library without metadata", "section", section, "err", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if d := e.Dir(); d != "" {
|
||||
byDir[d] = e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]titleView, 0, len(titles))
|
||||
for _, t := range titles {
|
||||
v := titleView{Title: t}
|
||||
if e, ok := byDir[t.Name]; ok {
|
||||
v.Arr = &arrMeta{ID: e.ID, Title: e.Title, Year: e.Year, Overview: e.Overview, Status: e.Status}
|
||||
v.ArtURL = "/api/art/" + string(section) + "/" + strconv.Itoa(e.ID)
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"section": string(section), "titles": out})
|
||||
}
|
||||
|
||||
func (s *Server) handleMark(w http.ResponseWriter, r *http.Request) {
|
||||
section, ok := s.section(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
name := r.PathValue("name")
|
||||
res, err := s.lib.Mark(section, name)
|
||||
if err != nil {
|
||||
s.writeLibErr(w, "mark", section, name, err)
|
||||
return
|
||||
}
|
||||
s.log.Info("marked title", "section", section, "name", name, "linked", res.Linked, "replaced", res.Replaced)
|
||||
s.writeState(w, section, name, res)
|
||||
}
|
||||
|
||||
func (s *Server) handleUnmark(w http.ResponseWriter, r *http.Request) {
|
||||
section, ok := s.section(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
name := r.PathValue("name")
|
||||
if err := s.lib.Unmark(section, name); err != nil {
|
||||
s.writeLibErr(w, "unmark", section, name, err)
|
||||
return
|
||||
}
|
||||
s.log.Info("unmarked title", "section", section, "name", name)
|
||||
s.writeState(w, section, name, library.SyncResult{})
|
||||
}
|
||||
|
||||
// writeState answers a mutation with the title's fresh on-disk state so the UI
|
||||
// never has to guess what the sync did.
|
||||
func (s *Server) writeState(w http.ResponseWriter, section library.Section, name string, res library.SyncResult) {
|
||||
t, err := s.lib.Stat(section, name)
|
||||
if err != nil && !errors.Is(err, library.ErrNotFound) {
|
||||
s.writeLibErr(w, "stat", section, name, err)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, library.ErrNotFound) {
|
||||
// Unmarking a title whose source has since vanished still succeeded.
|
||||
t = library.Title{Name: name}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"title": t, "sync": res})
|
||||
}
|
||||
|
||||
func (s *Server) handleArt(w http.ResponseWriter, r *http.Request) {
|
||||
section, ok := s.section(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil || id <= 0 {
|
||||
writeErr(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
c := s.arrs[section]
|
||||
if c == nil {
|
||||
writeErr(w, http.StatusNotFound, "no metadata source for section")
|
||||
return
|
||||
}
|
||||
art, err := c.Poster(r.Context(), id)
|
||||
if err != nil {
|
||||
// The upstream error may name the app but never the key; still, answer
|
||||
// with a fixed body so nothing upstream-shaped reaches the browser.
|
||||
s.log.Warn("poster proxy failed", "section", section, "id", id, "err", err)
|
||||
writeErr(w, http.StatusBadGateway, "poster unavailable")
|
||||
return
|
||||
}
|
||||
defer func() { _ = art.Body.Close() }()
|
||||
w.Header().Set("Content-Type", art.ContentType)
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
if _, err := io.Copy(w, art.Body); err != nil {
|
||||
s.log.Warn("poster copy failed", "id", id, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUI serves the embedded assets, falling back to index.html so client
|
||||
// routes like /movies deep-link correctly.
|
||||
func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) {
|
||||
p := strings.TrimPrefix(r.URL.Path, "/")
|
||||
if strings.HasPrefix(p, "api/") {
|
||||
writeErr(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if p != "" {
|
||||
if st, err := fs.Stat(s.assets, p); err == nil && !st.IsDir() {
|
||||
http.FileServerFS(s.assets).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
b, err := fs.ReadFile(s.assets, "index.html")
|
||||
if err != nil {
|
||||
http.Error(w, "index missing", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
|
||||
// section resolves and validates the {section} path value.
|
||||
func (s *Server) section(w http.ResponseWriter, r *http.Request) (library.Section, bool) {
|
||||
sec, err := library.ParseSection(r.PathValue("section"))
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, "unknown section")
|
||||
return "", false
|
||||
}
|
||||
return sec, true
|
||||
}
|
||||
|
||||
// writeLibErr maps a library error onto a status without leaking paths.
|
||||
func (s *Server) writeLibErr(w http.ResponseWriter, op string, section library.Section, name string, err error) {
|
||||
switch {
|
||||
case errors.Is(err, library.ErrInvalidName), errors.Is(err, library.ErrInvalidSection):
|
||||
writeErr(w, http.StatusBadRequest, "invalid title")
|
||||
case errors.Is(err, library.ErrNotFound):
|
||||
writeErr(w, http.StatusNotFound, "title not found")
|
||||
default:
|
||||
s.log.Error("library operation failed", "op", op, "section", section, "name", name, "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "library operation failed")
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeErr(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"git.unkin.net/unkin/mediamark/internal/arr"
|
||||
"git.unkin.net/unkin/mediamark/internal/config"
|
||||
"git.unkin.net/unkin/mediamark/internal/library"
|
||||
)
|
||||
|
||||
const upstreamKey = "upstream-secret-key"
|
||||
|
||||
// testEnv is a fully wired server over a temp media root and a fake *arr.
|
||||
type testEnv struct {
|
||||
h http.Handler
|
||||
root string
|
||||
lib *library.Library
|
||||
}
|
||||
|
||||
func newEnv(t *testing.T, arrHandler http.HandlerFunc) *testEnv {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for _, tree := range []string{library.SourceTree, library.KidsTree} {
|
||||
for _, s := range library.Sections {
|
||||
if err := os.MkdirAll(filepath.Join(root, tree, string(s)), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
arrs := map[library.Section]*arr.Client{}
|
||||
if arrHandler != nil {
|
||||
srv := httptest.NewServer(arrHandler)
|
||||
t.Cleanup(srv.Close)
|
||||
key := func(string) (string, bool) { return upstreamKey, true }
|
||||
sonarr := arr.New(arr.KindSonarr, srv.URL, key)
|
||||
radarr := arr.New(arr.KindRadarr, srv.URL, key)
|
||||
sonarr.SetCacheTTL(0)
|
||||
radarr.SetCacheTTL(0)
|
||||
arrs[library.SectionTVSeries] = sonarr
|
||||
arrs[library.SectionMovies] = radarr
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
MediaRoot: root,
|
||||
GroupsHeader: "X-Forwarded-Groups",
|
||||
AllowedGroups: []string{"akP-mediamark-user"},
|
||||
}
|
||||
lib := library.New(root)
|
||||
assets := fstest.MapFS{
|
||||
"index.html": &fstest.MapFile{Data: []byte("<html>mediamark</html>")},
|
||||
"app.js": &fstest.MapFile{Data: []byte("// js")},
|
||||
}
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
return &testEnv{h: New(cfg, lib, arrs, assets, log).Handler(), root: root, lib: lib}
|
||||
}
|
||||
|
||||
// do issues an authorized request.
|
||||
func (e *testEnv) do(t *testing.T, method, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
req.Header.Set("X-Forwarded-Groups", "akP-other,akP-mediamark-user")
|
||||
rec := httptest.NewRecorder()
|
||||
e.h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func (e *testEnv) writeTitle(t *testing.T, section library.Section, name, rel, content string) {
|
||||
t.Helper()
|
||||
p := filepath.Join(e.root, library.SourceTree, string(section), name, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthProbesAreUngated(t *testing.T) {
|
||||
e := newEnv(t, nil)
|
||||
for _, p := range []string{"/livez", "/readyz"} {
|
||||
rec := httptest.NewRecorder()
|
||||
e.h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, p, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s = %d, want 200", p, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyzFailsWhenMediaRootMissing(t *testing.T) {
|
||||
cfg := &config.Config{MediaRoot: "/definitely/not/here", GroupsHeader: "X-G", AllowedGroups: []string{"g"}}
|
||||
h := New(cfg, library.New(cfg.MediaRoot), nil, fstest.MapFS{}, slog.New(slog.NewTextHandler(io.Discard, nil))).Handler()
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("readyz = %d, want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Every gated surface — API, mutations, art, and the page shell — must 403 for
|
||||
// a caller outside the allowed group.
|
||||
func TestUnauthorizedGroupIsRejectedEverywhere(t *testing.T) {
|
||||
e := newEnv(t, nil)
|
||||
paths := []struct{ method, path string }{
|
||||
{http.MethodGet, "/"},
|
||||
{http.MethodGet, "/movies"},
|
||||
{http.MethodGet, "/api/library/movies"},
|
||||
{http.MethodPost, "/api/library/movies/Nemo/mark"},
|
||||
{http.MethodPost, "/api/library/movies/Nemo/unmark"},
|
||||
{http.MethodGet, "/api/art/movies/1"},
|
||||
}
|
||||
for _, p := range paths {
|
||||
for _, groups := range []string{"", "akP-somethingelse"} {
|
||||
req := httptest.NewRequest(p.method, p.path, nil)
|
||||
if groups != "" {
|
||||
req.Header.Set("X-Forwarded-Groups", groups)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
e.h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("%s %s (groups=%q) = %d, want 403", p.method, p.path, groups, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListJSONContract(t *testing.T) {
|
||||
e := newEnv(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Api-Key") != upstreamKey {
|
||||
t.Errorf("upstream request missing key")
|
||||
}
|
||||
_, _ = io.WriteString(w, `[
|
||||
{"id":9,"title":"Finding Nemo","year":2003,"overview":"fish","status":"released","path":"/media/fafflix/movies/Finding Nemo (2003)"}
|
||||
]`)
|
||||
})
|
||||
e.writeTitle(t, library.SectionMovies, "Finding Nemo (2003)", "movie.mkv", "0123456789")
|
||||
e.writeTitle(t, library.SectionMovies, "Unknown Film", "movie.mkv", "ab")
|
||||
|
||||
rec := e.do(t, http.MethodGet, "/api/library/movies")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body %s", rec.Code, rec.Body)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||
t.Errorf("Content-Type = %q", ct)
|
||||
}
|
||||
var body struct {
|
||||
Section string `json:"section"`
|
||||
Titles []struct {
|
||||
Name string `json:"name"`
|
||||
Marked bool `json:"marked"`
|
||||
NeedsSync bool `json:"needsSync"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
FileCount int `json:"fileCount"`
|
||||
ArtURL string `json:"artUrl"`
|
||||
Arr *struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
} `json:"arr"`
|
||||
} `json:"titles"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Section != "movies" || len(body.Titles) != 2 {
|
||||
t.Fatalf("body = %+v", body)
|
||||
}
|
||||
// Sorted case-insensitively: "Finding Nemo (2003)" then "Unknown Film".
|
||||
matched, unmatched := body.Titles[0], body.Titles[1]
|
||||
if matched.Arr == nil || matched.Arr.ID != 9 || matched.Arr.Year != 2003 {
|
||||
t.Fatalf("matched title missing arr metadata: %+v", matched)
|
||||
}
|
||||
if matched.ArtURL != "/api/art/movies/9" {
|
||||
t.Errorf("artUrl = %q", matched.ArtURL)
|
||||
}
|
||||
if matched.SizeBytes != 10 || matched.FileCount != 1 || matched.Marked {
|
||||
t.Errorf("matched stats = %+v", matched)
|
||||
}
|
||||
if unmatched.Arr != nil || unmatched.ArtURL != "" {
|
||||
t.Errorf("unmatched title should carry no arr metadata: %+v", unmatched)
|
||||
}
|
||||
// The upstream key must never surface in the response.
|
||||
if strings.Contains(rec.Body.String(), upstreamKey) {
|
||||
t.Fatal("list response leaked the upstream api key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDegradesWhenArrIsDown(t *testing.T) {
|
||||
e := newEnv(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "boom "+upstreamKey, http.StatusInternalServerError)
|
||||
})
|
||||
e.writeTitle(t, library.SectionMovies, "Nemo", "movie.mkv", "x")
|
||||
|
||||
rec := e.do(t, http.MethodGet, "/api/library/movies")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 with degraded metadata", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"name":"Nemo"`) {
|
||||
t.Fatalf("title missing from degraded list: %s", rec.Body)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), upstreamKey) {
|
||||
t.Fatal("degraded list leaked the upstream api key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkAndUnmarkRoundTrip(t *testing.T) {
|
||||
e := newEnv(t, nil)
|
||||
e.writeTitle(t, library.SectionTVSeries, "Bluey", "s01/e01.mkv", "one")
|
||||
e.writeTitle(t, library.SectionTVSeries, "Bluey", "s01/e02.mkv", "two")
|
||||
|
||||
rec := e.do(t, http.MethodPost, "/api/library/tvseries/Bluey/mark")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("mark = %d body %s", rec.Code, rec.Body)
|
||||
}
|
||||
var marked struct {
|
||||
Title library.Title `json:"title"`
|
||||
Sync library.SyncResult `json:"sync"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &marked); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !marked.Title.Marked || marked.Title.NeedsSync || marked.Sync.Linked != 2 {
|
||||
t.Fatalf("mark result = %+v", marked)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(e.root, library.KidsTree, "tvseries", "Bluey", "s01", "e01.mkv")); err != nil {
|
||||
t.Fatalf("hardlink missing: %v", err)
|
||||
}
|
||||
|
||||
// Marking again is idempotent.
|
||||
rec = e.do(t, http.MethodPost, "/api/library/tvseries/Bluey/mark")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("second mark = %d", rec.Code)
|
||||
}
|
||||
|
||||
rec = e.do(t, http.MethodPost, "/api/library/tvseries/Bluey/unmark")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unmark = %d body %s", rec.Code, rec.Body)
|
||||
}
|
||||
var unmarked struct {
|
||||
Title library.Title `json:"title"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &unmarked); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if unmarked.Title.Marked {
|
||||
t.Fatalf("still marked after unmark: %+v", unmarked)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(e.root, library.KidsTree, "tvseries", "Bluey")); !os.IsNotExist(err) {
|
||||
t.Fatal("kids directory survived unmark")
|
||||
}
|
||||
// Unmarking again is idempotent.
|
||||
if rec = e.do(t, http.MethodPost, "/api/library/tvseries/Bluey/unmark"); rec.Code != http.StatusOK {
|
||||
t.Fatalf("second unmark = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationRejectsBadSectionAndName(t *testing.T) {
|
||||
e := newEnv(t, nil)
|
||||
cases := []struct {
|
||||
path string
|
||||
want int
|
||||
}{
|
||||
{"/api/library/music/Nemo/mark", http.StatusNotFound},
|
||||
{"/api/library/movies/Ghost/mark", http.StatusNotFound},
|
||||
{"/api/library/movies/..%2f..%2fetc/mark", http.StatusBadRequest},
|
||||
{`/api/library/movies/..\..\etc/mark`, http.StatusBadRequest},
|
||||
// A literal ../ never reaches a handler: net/http's mux normalises the
|
||||
// path and redirects, so no mutation runs.
|
||||
{"/api/library/movies/../mark", http.StatusTemporaryRedirect},
|
||||
}
|
||||
for _, c := range cases {
|
||||
rec := e.do(t, http.MethodPost, c.path)
|
||||
if rec.Code != c.want {
|
||||
t.Errorf("POST %s = %d, want %d (%s)", c.path, rec.Code, c.want, rec.Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtProxyStreamsAndHidesTheKey(t *testing.T) {
|
||||
e := newEnv(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v3/mediacover/9/poster-250.jpg" {
|
||||
t.Errorf("art path = %q", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("X-Api-Key") != upstreamKey {
|
||||
t.Error("art request missing the api key")
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("JPEGDATA"))
|
||||
})
|
||||
|
||||
rec := e.do(t, http.MethodGet, "/api/art/movies/9")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("art = %d body %s", rec.Code, rec.Body)
|
||||
}
|
||||
if got := rec.Header().Get("Content-Type"); got != "image/jpeg" {
|
||||
t.Errorf("Content-Type = %q", got)
|
||||
}
|
||||
if got := rec.Header().Get("Cache-Control"); got != "public, max-age=86400" {
|
||||
t.Errorf("Cache-Control = %q", got)
|
||||
}
|
||||
if rec.Body.String() != "JPEGDATA" {
|
||||
t.Errorf("body = %q", rec.Body)
|
||||
}
|
||||
for k, vs := range rec.Header() {
|
||||
for _, v := range vs {
|
||||
if strings.Contains(v, upstreamKey) {
|
||||
t.Fatalf("header %s leaked the api key", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtProxyErrorsAreOpaque(t *testing.T) {
|
||||
e := newEnv(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "upstream said "+upstreamKey, http.StatusInternalServerError)
|
||||
})
|
||||
rec := e.do(t, http.MethodGet, "/api/art/movies/9")
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("art error status = %d, want 502", rec.Code)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), upstreamKey) {
|
||||
t.Fatalf("art error leaked the api key: %s", rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtRejectsBadIDAndUnknownSection(t *testing.T) {
|
||||
e := newEnv(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("x")) })
|
||||
for path, want := range map[string]int{
|
||||
"/api/art/movies/0": http.StatusBadRequest,
|
||||
"/api/art/movies/abc": http.StatusBadRequest,
|
||||
"/api/art/music/1": http.StatusNotFound,
|
||||
} {
|
||||
if rec := e.do(t, http.MethodGet, path); rec.Code != want {
|
||||
t.Errorf("GET %s = %d, want %d", path, rec.Code, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPAFallbackAndAssets(t *testing.T) {
|
||||
e := newEnv(t, nil)
|
||||
if rec := e.do(t, http.MethodGet, "/app.js"); rec.Code != http.StatusOK || rec.Body.String() != "// js" {
|
||||
t.Fatalf("asset = %d %q", rec.Code, rec.Body)
|
||||
}
|
||||
for _, p := range []string{"/", "/movies", "/tvseries/Bluey"} {
|
||||
rec := e.do(t, http.MethodGet, p)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "mediamark") {
|
||||
t.Errorf("SPA fallback for %s = %d %q", p, rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
// Unknown API routes must 404 as JSON rather than falling back to the SPA.
|
||||
rec := e.do(t, http.MethodGet, "/api/nope")
|
||||
if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "<html>") {
|
||||
t.Errorf("unknown api route = %d %q", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
// Package ui embeds the static mediamark assets.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
var embedded embed.FS
|
||||
|
||||
// Assets returns the static asset filesystem rooted at the asset directory.
|
||||
func Assets() fs.FS {
|
||||
sub, err := fs.Sub(embedded, "static")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return sub
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/* Arrstack UI — Bootstrap-3-style layout + material-elevation polish, dressed
|
||||
in a pirates-in-space / high-seas theme. Fully self-contained (no CDNs). */
|
||||
|
||||
:root {
|
||||
--sky-deep: #070d1f;
|
||||
--sea-deep: #0f2733;
|
||||
--sea: #16394a;
|
||||
--sea-mid: #1d4a5e;
|
||||
--parchment: #f2e4c9;
|
||||
--parchment-dk: #e4d0a7;
|
||||
--rope: #b8894b;
|
||||
--rope-dk: #8a5f2c;
|
||||
--brass: #c9a25a;
|
||||
--ink: #2b2015;
|
||||
--danger: #a5382e;
|
||||
--danger-dk: #7e281f;
|
||||
--ok: #3c6e4f;
|
||||
--shadow: 0 2px 6px rgba(0,0,0,.25), 0 8px 24px rgba(0,0,0,.18);
|
||||
--radius: 6px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
color: var(--ink);
|
||||
background-color: var(--sky-deep);
|
||||
background-image: url("bg-space-pirate.svg");
|
||||
background-size: cover;
|
||||
background-position: center top;
|
||||
background-attachment: fixed;
|
||||
background-repeat: no-repeat;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container { max-width: 960px; margin: 0 auto; padding: 0 16px; }
|
||||
|
||||
/* ---- navbar (rope-trimmed) ---- */
|
||||
.navbar {
|
||||
background: linear-gradient(180deg, var(--sea) 0%, var(--sea-deep) 100%);
|
||||
border-bottom: 4px solid var(--rope);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.navbar-inner { display: flex; align-items: baseline; gap: 12px; padding: 14px 16px; flex-wrap: wrap; }
|
||||
.brand { color: var(--parchment); font-size: 1.5rem; font-weight: 700; letter-spacing: .5px; }
|
||||
.brand-mark { color: var(--brass); }
|
||||
.brand-tag { color: var(--parchment-dk); font-style: italic; opacity: .85; }
|
||||
.whoami { margin-left: auto; color: var(--parchment-dk); font-size: .9rem; }
|
||||
|
||||
/* ---- hero ---- */
|
||||
.hero { padding: 32px 0 8px; }
|
||||
/* Hero copy sits directly on the dark starfield, so it uses light pirate-gold /
|
||||
parchment tones with a dark scrim shadow for readability over stars. */
|
||||
.hero h1 { margin: 0 0 6px; font-size: 2rem; color: var(--brass); text-shadow: 0 1px 4px rgba(0,0,0,.85); }
|
||||
.lead { font-size: 1.1rem; color: var(--parchment); margin: 0; text-shadow: 0 1px 3px rgba(0,0,0,.85); }
|
||||
|
||||
/* ---- app icon grid (square tiles) ---- */
|
||||
.app-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 16px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
.app-tile {
|
||||
aspect-ratio: 1 / 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
background: #fff8ea;
|
||||
border: 1px solid var(--parchment-dk);
|
||||
border-top: 4px solid var(--sea-mid);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 16px;
|
||||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
transition: transform .12s ease, box-shadow .12s ease;
|
||||
}
|
||||
a.app-tile:hover { transform: translateY(-3px); box-shadow: 0 6px 14px rgba(0,0,0,.28); }
|
||||
.app-tile-icon { width: 55%; max-width: 96px; height: auto; }
|
||||
.app-tile-name { font-weight: 700; font-size: 1.05rem; text-transform: capitalize; }
|
||||
.app-tile.locked { opacity: .55; filter: grayscale(.4); cursor: not-allowed; }
|
||||
.badge-locked { font-size: .7rem; background: var(--danger); color: #fff; padding: 2px 6px; border-radius: 3px; }
|
||||
|
||||
/* ---- panels ---- */
|
||||
.panel {
|
||||
background: #fff8ea;
|
||||
border: 1px solid var(--parchment-dk);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
margin: 24px 0 48px;
|
||||
}
|
||||
.panel-heading {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
background: linear-gradient(180deg, var(--sea-mid), var(--sea));
|
||||
color: var(--parchment);
|
||||
padding: 14px 20px;
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
border-bottom: 3px solid var(--rope);
|
||||
}
|
||||
.panel-heading h2 { margin: 0; font-size: 1.25rem; }
|
||||
.panel-icon { font-size: 1.4rem; }
|
||||
.panel-body { padding: 20px; }
|
||||
|
||||
/* ---- forms ---- */
|
||||
.form-group { margin-bottom: 16px; }
|
||||
label { display: block; font-weight: 600; margin-bottom: 6px; color: var(--sea-deep); }
|
||||
.form-control {
|
||||
width: 100%; padding: 9px 12px;
|
||||
border: 1px solid var(--parchment-dk); border-radius: var(--radius);
|
||||
background: #fffdf6; font-size: 1rem;
|
||||
transition: border-color .12s ease, box-shadow .12s ease;
|
||||
}
|
||||
.form-control:focus { outline: none; border-color: var(--brass); box-shadow: 0 0 0 3px rgba(201,162,90,.3); }
|
||||
.app-checks { display: flex; flex-wrap: wrap; gap: 14px; }
|
||||
.app-checks label { font-weight: 400; display: inline-flex; align-items: center; gap: 6px; margin: 0; }
|
||||
|
||||
/* ---- buttons (material elevation + ripple-ish) ---- */
|
||||
.btn {
|
||||
display: inline-block; border: none; border-radius: var(--radius);
|
||||
padding: 10px 18px; font-size: 1rem; font-weight: 600; cursor: pointer;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,.2); transition: transform .08s ease, box-shadow .12s ease, background .12s ease;
|
||||
text-decoration: none; text-align: center;
|
||||
}
|
||||
.btn:active { transform: translateY(1px); box-shadow: 0 1px 2px rgba(0,0,0,.2); }
|
||||
.btn-primary { background: var(--rope); color: #2b1c08; }
|
||||
.btn-primary:hover { background: var(--rope-dk); color: #fff; }
|
||||
.btn-copy { background: var(--brass); color: var(--ink); padding: 6px 12px; }
|
||||
.btn-danger { background: var(--danger); color: #fff; padding: 5px 12px; font-size: .85rem; }
|
||||
.btn-danger:hover { background: var(--danger-dk); }
|
||||
.btn:disabled { opacity: .55; cursor: not-allowed; }
|
||||
|
||||
/* ---- token reveal ---- */
|
||||
.mint-result { margin-top: 18px; padding: 16px; background: #2b2015; border-radius: var(--radius); }
|
||||
.mint-result .muted { color: var(--parchment-dk); }
|
||||
.token-reveal { display: flex; gap: 10px; align-items: center; }
|
||||
.token-reveal code {
|
||||
flex: 1; background: #12100b; color: #7ee0a1; padding: 10px 12px;
|
||||
border-radius: var(--radius); word-break: break-all; font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* ---- keys list ---- */
|
||||
.keys-title { margin-top: 28px; border-top: 2px dashed var(--parchment-dk); padding-top: 18px; color: var(--rope-dk); }
|
||||
.keys-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.key-row {
|
||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
background: #fffdf6; border: 1px solid var(--parchment-dk); border-left: 4px solid var(--sea-mid);
|
||||
border-radius: var(--radius); padding: 12px 14px;
|
||||
}
|
||||
.key-row.disabled { border-left-color: var(--danger); opacity: .6; }
|
||||
.key-label { font-weight: 700; }
|
||||
.key-meta { color: #7a6b52; font-size: .85rem; }
|
||||
.key-apps { display: flex; gap: 6px; }
|
||||
.chip { background: var(--sea-mid); color: var(--parchment); font-size: .72rem; padding: 2px 8px; border-radius: 10px; text-transform: capitalize; }
|
||||
.key-row .spacer { margin-left: auto; }
|
||||
|
||||
/* ---- utility ---- */
|
||||
.muted { color: #7a6b52; }
|
||||
.hidden { display: none !important; }
|
||||
code { font-family: "SF Mono", Menlo, Consolas, monospace; }
|
||||
|
||||
/* ---- toast ---- */
|
||||
.toast {
|
||||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||
background: var(--sea-deep); color: var(--parchment);
|
||||
padding: 12px 20px; border-radius: var(--radius); box-shadow: var(--shadow);
|
||||
border-left: 4px solid var(--brass); z-index: 50;
|
||||
}
|
||||
.toast.err { border-left-color: var(--danger); }
|
||||
|
||||
/* ==========================================================================
|
||||
Mediamark additions — landing tiles, sticky search, title list, detail.
|
||||
Same palette and elevation language as the arrstack skin above.
|
||||
========================================================================== */
|
||||
|
||||
a.brand { text-decoration: none; }
|
||||
.view.hidden { display: none; }
|
||||
kbd {
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace; font-size: .72rem;
|
||||
background: var(--ink); color: var(--parchment); padding: 1px 6px;
|
||||
border-radius: 3px; border-bottom: 2px solid #000;
|
||||
}
|
||||
|
||||
/* ---- landing: exactly two big square tiles ---- */
|
||||
.app-grid-2 { grid-template-columns: repeat(2, 1fr); max-width: 620px; gap: 24px; }
|
||||
.app-tile-icon { font-size: 3.4rem; line-height: 1; width: auto; }
|
||||
.app-tile-key { font-size: .78rem; color: #7a6b52; }
|
||||
.app-tile.selected {
|
||||
transform: translateY(-3px);
|
||||
border-top-color: var(--brass);
|
||||
box-shadow: 0 0 0 3px rgba(201,162,90,.55), 0 6px 14px rgba(0,0,0,.28);
|
||||
}
|
||||
|
||||
/* ---- sticky search bar ---- */
|
||||
.searchbar {
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
background: linear-gradient(180deg, var(--sea) 0%, var(--sea-deep) 100%);
|
||||
border-bottom: 4px solid var(--rope);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.searchbar-inner { display: flex; align-items: center; gap: 10px; padding: 12px 16px; }
|
||||
.searchbar .form-control { flex: 1; font-size: 1.05rem; }
|
||||
.search-count { color: var(--parchment-dk); font-size: .85rem; white-space: nowrap; }
|
||||
.btn-back {
|
||||
background: var(--sea-mid); color: var(--parchment);
|
||||
padding: 8px 14px; font-size: 1.1rem; line-height: 1;
|
||||
}
|
||||
.btn-back:hover { background: var(--rope); color: var(--ink); }
|
||||
.btn-back-block { display: inline-block; margin: 20px 0 0; }
|
||||
.section-title {
|
||||
color: var(--brass); text-shadow: 0 1px 4px rgba(0,0,0,.85);
|
||||
margin: 20px 0 12px; font-size: 1.6rem;
|
||||
}
|
||||
|
||||
/* ---- title list ---- */
|
||||
.title-list { display: flex; flex-direction: column; gap: 8px; padding-bottom: 24px; outline: none; }
|
||||
.title-row {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
background: #fff8ea; border: 1px solid var(--parchment-dk);
|
||||
border-left: 4px solid var(--sea-mid); border-radius: var(--radius);
|
||||
box-shadow: var(--shadow); padding: 10px 14px;
|
||||
text-decoration: none; color: var(--ink);
|
||||
transition: transform .1s ease, box-shadow .1s ease;
|
||||
}
|
||||
.title-row:hover { transform: translateX(2px); }
|
||||
.title-row.marked { border-left-color: var(--brass); }
|
||||
.title-row.selected {
|
||||
border-color: var(--brass);
|
||||
box-shadow: 0 0 0 3px rgba(201,162,90,.55), var(--shadow);
|
||||
}
|
||||
.title-art {
|
||||
width: 46px; height: 68px; flex: 0 0 46px;
|
||||
border-radius: 3px; overflow: hidden; background: var(--sea-deep);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.poster { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.poster-placeholder { color: var(--parchment-dk); font-size: 1.4rem; font-weight: 700; }
|
||||
.title-main { flex: 1; min-width: 0; }
|
||||
.title-name { font-weight: 700; font-size: 1.02rem; overflow-wrap: anywhere; }
|
||||
.title-name mark { background: rgba(201,162,90,.45); color: inherit; border-radius: 2px; padding: 0 1px; }
|
||||
.title-sub { font-size: .82rem; }
|
||||
.title-badges { display: flex; flex-direction: column; gap: 4px; align-items: flex-end; }
|
||||
.badge { font-size: .68rem; padding: 2px 8px; border-radius: 10px; white-space: nowrap; font-weight: 700; }
|
||||
.badge-marked { background: var(--brass); color: var(--ink); }
|
||||
.badge-sync { background: var(--danger); color: #fff; }
|
||||
.empty { padding: 20px 0 40px; }
|
||||
|
||||
/* ---- detail ---- */
|
||||
.detail-body { display: flex; gap: 24px; flex-wrap: wrap; }
|
||||
.detail-art { flex: 0 0 200px; }
|
||||
.poster-lg {
|
||||
width: 200px; height: 296px; border-radius: var(--radius);
|
||||
box-shadow: var(--shadow); object-fit: cover; background: var(--sea-deep);
|
||||
}
|
||||
.poster-lg.poster-placeholder { display: flex; align-items: center; justify-content: center; font-size: 3rem; }
|
||||
.detail-meta { flex: 1; min-width: 260px; }
|
||||
.detail-facts { margin: 0 0 10px; color: var(--rope-dk); font-weight: 700; }
|
||||
.detail-overview { margin: 0 0 18px; line-height: 1.5; }
|
||||
.detail-stats { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; margin: 0 0 20px; }
|
||||
.detail-stats dt { font-weight: 700; color: var(--sea-deep); }
|
||||
.detail-stats dd { margin: 0; }
|
||||
.detail-actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.btn-lg { padding: 14px 24px; font-size: 1.05rem; }
|
||||
|
||||
/* ---- footer key hints ---- */
|
||||
.keyhint {
|
||||
padding: 14px 16px 28px; color: var(--parchment-dk);
|
||||
font-size: .85rem; text-shadow: 0 1px 3px rgba(0,0,0,.85);
|
||||
}
|
||||
|
||||
/* ---- mobile ---- */
|
||||
@media (max-width: 600px) {
|
||||
.container { padding: 0 10px; }
|
||||
.app-grid-2 { gap: 12px; }
|
||||
.app-tile-icon { font-size: 2.6rem; }
|
||||
.title-row { padding: 8px 10px; gap: 10px; border-radius: 4px; }
|
||||
.title-art { width: 38px; height: 56px; flex-basis: 38px; }
|
||||
.title-badges { flex-direction: row; align-items: center; }
|
||||
.detail-art { flex: 0 0 100%; }
|
||||
.poster-lg { width: 100%; height: auto; aspect-ratio: 2 / 3; }
|
||||
.detail-actions .btn { flex: 1; }
|
||||
.hero h1 { font-size: 1.5rem; }
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
/* Mediamark SPA — keyboard-first library marking. No build step, no CDNs. */
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var SECTIONS = {
|
||||
movies: { label: "Movies", key: "m" },
|
||||
tvseries: { label: "TV Series", key: "t" }
|
||||
};
|
||||
|
||||
var state = {
|
||||
view: "home",
|
||||
section: null,
|
||||
titles: [],
|
||||
filtered: [],
|
||||
selected: 0,
|
||||
detail: null,
|
||||
homeTile: 0
|
||||
};
|
||||
|
||||
var el = {};
|
||||
["view-home", "view-section", "view-detail", "search", "search-count", "section-title",
|
||||
"title-list", "list-empty", "detail-title", "detail-poster", "detail-noposter",
|
||||
"detail-facts", "detail-overview", "detail-size", "detail-files", "detail-marked",
|
||||
"detail-toggle", "detail-sync", "detail-back", "toast", "keyhint-text", "home-tiles"
|
||||
].forEach(function (id) { el[id] = document.getElementById(id); });
|
||||
|
||||
/* ---------- fzf-ish subsequence matcher ----------
|
||||
Scores a subsequence match: contiguous runs and word-boundary hits score
|
||||
higher, and an earlier first match wins ties. Returns null for no match. */
|
||||
function fuzzy(needle, haystack) {
|
||||
if (!needle) return { score: 0, positions: [] };
|
||||
var n = needle.toLowerCase(), h = haystack.toLowerCase();
|
||||
var positions = [], score = 0, run = 0, hi = 0;
|
||||
for (var ni = 0; ni < n.length; ni++) {
|
||||
var ch = n.charAt(ni);
|
||||
if (ch === " ") { run = 0; continue; }
|
||||
var found = -1;
|
||||
for (var j = hi; j < h.length; j++) {
|
||||
if (h.charAt(j) === ch) { found = j; break; }
|
||||
}
|
||||
if (found < 0) return null;
|
||||
positions.push(found);
|
||||
score += 1;
|
||||
if (found === hi && ni > 0) { run++; score += 4 + run; } else { run = 0; }
|
||||
var prev = found > 0 ? h.charAt(found - 1) : "";
|
||||
if (found === 0) score += 8;
|
||||
else if (prev === " " || prev === "-" || prev === "." || prev === "(" || prev === ":") score += 6;
|
||||
hi = found + 1;
|
||||
}
|
||||
score -= positions[0] * 0.1;
|
||||
score -= (h.length - n.length) * 0.01;
|
||||
return { score: score, positions: positions };
|
||||
}
|
||||
|
||||
function highlight(text, positions) {
|
||||
if (!positions || !positions.length) return document.createTextNode(text);
|
||||
var frag = document.createDocumentFragment(), at = 0;
|
||||
for (var i = 0; i < positions.length; i++) {
|
||||
var p = positions[i];
|
||||
if (p < at) continue;
|
||||
if (p > at) frag.appendChild(document.createTextNode(text.slice(at, p)));
|
||||
var m = document.createElement("mark");
|
||||
m.textContent = text.charAt(p);
|
||||
frag.appendChild(m);
|
||||
at = p + 1;
|
||||
}
|
||||
if (at < text.length) frag.appendChild(document.createTextNode(text.slice(at)));
|
||||
return frag;
|
||||
}
|
||||
|
||||
/* ---------- helpers ---------- */
|
||||
function humanSize(bytes) {
|
||||
if (!bytes) return "0 B";
|
||||
var units = ["B", "KiB", "MiB", "GiB", "TiB"], i = 0, v = bytes;
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||
return (i === 0 ? v : v.toFixed(v < 10 ? 2 : 1)) + " " + units[i];
|
||||
}
|
||||
|
||||
var toastTimer = null;
|
||||
function toast(msg, isErr) {
|
||||
el.toast.textContent = msg;
|
||||
el.toast.className = "toast" + (isErr ? " err" : "");
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(function () { el.toast.className = "toast hidden"; }, 3200);
|
||||
}
|
||||
|
||||
function api(method, path) {
|
||||
return fetch(path, { method: method, headers: { Accept: "application/json" } })
|
||||
.then(function (res) {
|
||||
return res.json().catch(function () { return {}; }).then(function (body) {
|
||||
if (!res.ok) throw new Error(body.error || ("request failed (" + res.status + ")"));
|
||||
return body;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function displayName(t) { return (t.arr && t.arr.title) || t.name; }
|
||||
|
||||
/* ---------- rendering ---------- */
|
||||
function show(view) {
|
||||
state.view = view;
|
||||
el["view-home"].classList.toggle("hidden", view !== "home");
|
||||
el["view-section"].classList.toggle("hidden", view !== "section");
|
||||
el["view-detail"].classList.toggle("hidden", view !== "detail");
|
||||
el["keyhint-text"].textContent =
|
||||
view === "home" ? "m movies · t tv series · ←/→ select · enter open"
|
||||
: view === "section" ? "type to filter · ↑/↓ select · enter details · m toggle cheeztv · esc back"
|
||||
: "m toggle cheeztv · esc back";
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
var q = el.search.value.trim();
|
||||
var scored = [];
|
||||
for (var i = 0; i < state.titles.length; i++) {
|
||||
var t = state.titles[i];
|
||||
var r = fuzzy(q, displayName(t));
|
||||
if (!r && q) r = fuzzy(q, t.name);
|
||||
if (r) scored.push({ t: t, score: r.score, positions: q ? r.positions : [] });
|
||||
}
|
||||
if (q) scored.sort(function (a, b) { return b.score - a.score; });
|
||||
state.filtered = scored;
|
||||
if (state.selected >= scored.length) state.selected = Math.max(0, scored.length - 1);
|
||||
renderList();
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
el["title-list"].textContent = "";
|
||||
el["list-empty"].classList.toggle("hidden", state.filtered.length > 0);
|
||||
el["search-count"].textContent = state.filtered.length + " / " + state.titles.length;
|
||||
|
||||
state.filtered.forEach(function (row, idx) {
|
||||
var t = row.t;
|
||||
var a = document.createElement("a");
|
||||
a.className = "title-row" + (idx === state.selected ? " selected" : "") + (t.marked ? " marked" : "");
|
||||
a.href = "#/" + state.section + "/" + encodeURIComponent(t.name);
|
||||
a.setAttribute("role", "option");
|
||||
a.dataset.index = String(idx);
|
||||
|
||||
var art = document.createElement("div");
|
||||
art.className = "title-art";
|
||||
if (t.artUrl) {
|
||||
var img = document.createElement("img");
|
||||
img.src = t.artUrl;
|
||||
img.alt = "";
|
||||
img.loading = "lazy";
|
||||
img.className = "poster";
|
||||
art.appendChild(img);
|
||||
} else {
|
||||
art.appendChild(document.createTextNode("?"));
|
||||
art.classList.add("poster-placeholder");
|
||||
}
|
||||
a.appendChild(art);
|
||||
|
||||
var main = document.createElement("div");
|
||||
main.className = "title-main";
|
||||
var name = document.createElement("div");
|
||||
name.className = "title-name";
|
||||
name.appendChild(highlight(displayName(t), row.positions));
|
||||
main.appendChild(name);
|
||||
var sub = document.createElement("div");
|
||||
sub.className = "title-sub muted";
|
||||
var bits = [];
|
||||
if (t.arr && t.arr.year) bits.push(t.arr.year);
|
||||
if (t.arr && t.arr.status) bits.push(t.arr.status);
|
||||
bits.push(humanSize(t.sizeBytes));
|
||||
sub.textContent = bits.join(" · ");
|
||||
main.appendChild(sub);
|
||||
a.appendChild(main);
|
||||
|
||||
var badges = document.createElement("div");
|
||||
badges.className = "title-badges";
|
||||
if (t.marked) badges.appendChild(badge("cheeztv", "badge-marked"));
|
||||
if (t.needsSync) badges.appendChild(badge("needs sync", "badge-sync"));
|
||||
a.appendChild(badges);
|
||||
|
||||
el["title-list"].appendChild(a);
|
||||
});
|
||||
}
|
||||
|
||||
function badge(text, cls) {
|
||||
var s = document.createElement("span");
|
||||
s.className = "badge " + cls;
|
||||
s.textContent = text;
|
||||
return s;
|
||||
}
|
||||
|
||||
// Arrowing moves focus out of the search box and onto the list, so the
|
||||
// single-letter shortcuts (m) are unambiguous; typing any other character
|
||||
// hands focus straight back to the search box.
|
||||
function moveSelection(delta) {
|
||||
if (!state.filtered.length) return;
|
||||
state.selected = Math.min(state.filtered.length - 1, Math.max(0, state.selected + delta));
|
||||
renderList();
|
||||
el["title-list"].focus({ preventScroll: true });
|
||||
var sel = el["title-list"].querySelector(".title-row.selected");
|
||||
if (sel && sel.scrollIntoView) sel.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
function currentTitle() {
|
||||
if (state.view === "detail") return state.detail;
|
||||
var row = state.filtered[state.selected];
|
||||
return row ? row.t : null;
|
||||
}
|
||||
|
||||
/* ---------- data ---------- */
|
||||
function loadSection(section) {
|
||||
state.section = section;
|
||||
el["section-title"].textContent = SECTIONS[section].label;
|
||||
document.title = "Mediamark — " + SECTIONS[section].label;
|
||||
el["title-list"].textContent = "";
|
||||
return api("GET", "/api/library/" + section).then(function (body) {
|
||||
state.titles = body.titles || [];
|
||||
state.selected = 0;
|
||||
applyFilter();
|
||||
}).catch(function (e) {
|
||||
toast(e.message, true);
|
||||
state.titles = [];
|
||||
applyFilter();
|
||||
});
|
||||
}
|
||||
|
||||
function mergeTitle(updated) {
|
||||
for (var i = 0; i < state.titles.length; i++) {
|
||||
if (state.titles[i].name === updated.name) {
|
||||
["marked", "needsSync", "sizeBytes", "fileCount", "unlinkedFiles"].forEach(function (k) {
|
||||
state.titles[i][k] = updated[k];
|
||||
});
|
||||
if (state.detail && state.detail.name === updated.name) state.detail = state.titles[i];
|
||||
return state.titles[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toggleMark(t) {
|
||||
if (!t) return;
|
||||
var wantMark = !t.marked;
|
||||
var prev = { marked: t.marked, needsSync: t.needsSync };
|
||||
t.marked = wantMark; // optimistic
|
||||
t.needsSync = false;
|
||||
renderList();
|
||||
if (state.view === "detail") renderDetail();
|
||||
|
||||
api("POST", "/api/library/" + state.section + "/" + encodeURIComponent(t.name) + "/" + (wantMark ? "mark" : "unmark"))
|
||||
.then(function (body) {
|
||||
mergeTitle(body.title);
|
||||
var s = body.sync || {};
|
||||
toast(wantMark
|
||||
? "Marked " + displayName(t) + " — " + (s.linked || 0) + " linked, " + (s.unchanged || 0) + " already there"
|
||||
: "Removed " + displayName(t) + " from cheeztv");
|
||||
renderList();
|
||||
if (state.view === "detail") renderDetail();
|
||||
})
|
||||
.catch(function (e) {
|
||||
t.marked = prev.marked;
|
||||
t.needsSync = prev.needsSync;
|
||||
renderList();
|
||||
if (state.view === "detail") renderDetail();
|
||||
toast(e.message, true);
|
||||
});
|
||||
}
|
||||
|
||||
function syncTitle(t) {
|
||||
api("POST", "/api/library/" + state.section + "/" + encodeURIComponent(t.name) + "/mark")
|
||||
.then(function (body) {
|
||||
mergeTitle(body.title);
|
||||
var s = body.sync || {};
|
||||
toast("Synced " + displayName(t) + " — " + (s.linked || 0) + " new, " + (s.replaced || 0) + " replaced");
|
||||
renderList();
|
||||
renderDetail();
|
||||
})
|
||||
.catch(function (e) { toast(e.message, true); });
|
||||
}
|
||||
|
||||
/* ---------- detail ---------- */
|
||||
function renderDetail() {
|
||||
var t = state.detail;
|
||||
if (!t) return;
|
||||
el["detail-title"].textContent = displayName(t);
|
||||
el["detail-back"].href = "#/" + state.section;
|
||||
|
||||
var facts = [];
|
||||
if (t.arr && t.arr.year) facts.push(t.arr.year);
|
||||
if (t.arr && t.arr.status) facts.push(t.arr.status);
|
||||
facts.push(SECTIONS[state.section].label);
|
||||
el["detail-facts"].textContent = facts.join(" · ");
|
||||
el["detail-overview"].textContent = (t.arr && t.arr.overview) || "No overview available for this title.";
|
||||
el["detail-size"].textContent = humanSize(t.sizeBytes);
|
||||
el["detail-files"].textContent = String(t.fileCount);
|
||||
el["detail-marked"].textContent = t.marked
|
||||
? (t.needsSync ? "marked (" + t.unlinkedFiles + " files unsynced)" : "marked")
|
||||
: "not marked";
|
||||
|
||||
if (t.artUrl) {
|
||||
el["detail-poster"].src = t.artUrl;
|
||||
el["detail-poster"].classList.remove("hidden");
|
||||
el["detail-noposter"].classList.add("hidden");
|
||||
} else {
|
||||
el["detail-poster"].classList.add("hidden");
|
||||
el["detail-noposter"].classList.remove("hidden");
|
||||
}
|
||||
|
||||
el["detail-toggle"].textContent = t.marked ? "Remove from cheeztv" : "Add to cheeztv";
|
||||
el["detail-toggle"].className = "btn " + (t.marked ? "btn-danger btn-lg" : "btn-primary btn-lg");
|
||||
el["detail-sync"].classList.toggle("hidden", !t.needsSync);
|
||||
}
|
||||
|
||||
function openDetail(section, name) {
|
||||
var go = state.section === section && state.titles.length
|
||||
? Promise.resolve()
|
||||
: loadSection(section);
|
||||
return go.then(function () {
|
||||
var found = null;
|
||||
for (var i = 0; i < state.titles.length; i++) {
|
||||
if (state.titles[i].name === name) { found = state.titles[i]; break; }
|
||||
}
|
||||
if (!found) { toast("Title not found", true); location.hash = "#/" + section; return; }
|
||||
state.detail = found;
|
||||
show("detail");
|
||||
renderDetail();
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- routing ---------- */
|
||||
function route() {
|
||||
var parts = (location.hash || "#/").replace(/^#\/?/, "").split("/");
|
||||
var section = parts[0];
|
||||
if (!SECTIONS[section]) {
|
||||
show("home");
|
||||
document.title = "Mediamark";
|
||||
focusTile();
|
||||
return;
|
||||
}
|
||||
if (parts.length > 1 && parts[1]) {
|
||||
openDetail(section, decodeURIComponent(parts[1]));
|
||||
return;
|
||||
}
|
||||
show("section");
|
||||
var reload = state.section === section && state.titles.length
|
||||
? Promise.resolve(applyFilter())
|
||||
: loadSection(section);
|
||||
reload.then(function () { el.search.focus(); el.search.select(); });
|
||||
}
|
||||
|
||||
function tiles() { return Array.prototype.slice.call(el["home-tiles"].querySelectorAll(".app-tile")); }
|
||||
|
||||
function focusTile() {
|
||||
var ts = tiles();
|
||||
ts.forEach(function (t, i) { t.classList.toggle("selected", i === state.homeTile); });
|
||||
}
|
||||
|
||||
/* ---------- keyboard ---------- */
|
||||
document.addEventListener("keydown", function (ev) {
|
||||
if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
|
||||
var typingInSearch = ev.target === el.search;
|
||||
|
||||
if (state.view === "home") {
|
||||
var ts = tiles();
|
||||
if (ev.key === "m") { location.hash = "#/movies"; ev.preventDefault(); return; }
|
||||
if (ev.key === "t") { location.hash = "#/tvseries"; ev.preventDefault(); return; }
|
||||
if (ev.key === "ArrowRight" || ev.key === "ArrowDown") {
|
||||
state.homeTile = Math.min(ts.length - 1, state.homeTile + 1); focusTile(); ev.preventDefault(); return;
|
||||
}
|
||||
if (ev.key === "ArrowLeft" || ev.key === "ArrowUp") {
|
||||
state.homeTile = Math.max(0, state.homeTile - 1); focusTile(); ev.preventDefault(); return;
|
||||
}
|
||||
if (ev.key === "Enter") { ts[state.homeTile].click(); ev.preventDefault(); }
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "Escape") {
|
||||
if (state.view === "detail") { location.hash = "#/" + state.section; }
|
||||
else if (typingInSearch && el.search.value) { el.search.value = ""; applyFilter(); }
|
||||
else { location.hash = "#/"; }
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.view === "detail") {
|
||||
if (ev.key === "m") { toggleMark(state.detail); ev.preventDefault(); }
|
||||
return;
|
||||
}
|
||||
|
||||
// Section view.
|
||||
if (ev.key === "ArrowDown") { moveSelection(1); ev.preventDefault(); return; }
|
||||
if (ev.key === "ArrowUp") { moveSelection(-1); ev.preventDefault(); return; }
|
||||
if (ev.key === "Enter") {
|
||||
var t = currentTitle();
|
||||
if (t) location.hash = "#/" + state.section + "/" + encodeURIComponent(t.name);
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (ev.key === "m" && !typingInSearch) { toggleMark(currentTitle()); ev.preventDefault(); return; }
|
||||
if (!typingInSearch && ev.key.length === 1) {
|
||||
el.search.focus();
|
||||
el.search.value += ev.key;
|
||||
state.selected = 0;
|
||||
applyFilter();
|
||||
ev.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
el.search.addEventListener("input", function () { state.selected = 0; applyFilter(); });
|
||||
|
||||
el["title-list"].addEventListener("click", function (ev) {
|
||||
var row = ev.target.closest ? ev.target.closest(".title-row") : null;
|
||||
if (row) state.selected = Number(row.dataset.index);
|
||||
});
|
||||
|
||||
el["home-tiles"].addEventListener("mouseover", function (ev) {
|
||||
var tile = ev.target.closest ? ev.target.closest(".app-tile") : null;
|
||||
if (!tile) return;
|
||||
state.homeTile = tiles().indexOf(tile);
|
||||
focusTile();
|
||||
});
|
||||
|
||||
el["detail-toggle"].addEventListener("click", function () { toggleMark(state.detail); });
|
||||
el["detail-sync"].addEventListener("click", function () { syncTitle(state.detail); });
|
||||
|
||||
window.addEventListener("hashchange", route);
|
||||
route();
|
||||
})();
|
||||
@@ -0,0 +1,223 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 960" preserveAspectRatio="xMidYMid slice" role="img" aria-label="Cartoon pirate ship sailing a cosmic sea through outer space">
|
||||
<defs>
|
||||
<radialGradient id="sky" cx="50%" cy="40%" r="80%">
|
||||
<stop offset="0%" stop-color="#12213f"/>
|
||||
<stop offset="55%" stop-color="#0c1730"/>
|
||||
<stop offset="100%" stop-color="#070d1f"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="wave" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#3aa6d6"/>
|
||||
<stop offset="100%" stop-color="#2a3f8a"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="ring" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#9a6bd6"/>
|
||||
<stop offset="100%" stop-color="#6a3fae"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="galaxy" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#e9d6ff"/>
|
||||
<stop offset="40%" stop-color="#9a72d8"/>
|
||||
<stop offset="100%" stop-color="#0c1730" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="moon" cx="38%" cy="35%" r="70%">
|
||||
<stop offset="0%" stop-color="#f2a24a"/>
|
||||
<stop offset="100%" stop-color="#c56a1f"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="teal" cx="38%" cy="35%" r="70%">
|
||||
<stop offset="0%" stop-color="#5fd6c0"/>
|
||||
<stop offset="100%" stop-color="#2a9c86"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="comet" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#ff8a2a" stop-opacity="0"/>
|
||||
<stop offset="100%" stop-color="#ffd24a"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="hull" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#a9743a"/>
|
||||
<stop offset="100%" stop-color="#6e451d"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- space backdrop -->
|
||||
<rect width="1280" height="960" fill="url(#sky)"/>
|
||||
|
||||
<!-- stars + sparkles -->
|
||||
<g fill="#ffffff">
|
||||
<circle cx="90" cy="80" r="2.4"/><circle cx="180" cy="150" r="1.6"/><circle cx="260" cy="60" r="2"/>
|
||||
<circle cx="360" cy="120" r="1.4"/><circle cx="470" cy="70" r="2.2"/><circle cx="560" cy="140" r="1.5"/>
|
||||
<circle cx="700" cy="90" r="1.8"/><circle cx="820" cy="150" r="2"/><circle cx="930" cy="70" r="1.5"/>
|
||||
<circle cx="1040" cy="130" r="2.2"/><circle cx="1150" cy="80" r="1.6"/><circle cx="1220" cy="180" r="2"/>
|
||||
<circle cx="60" cy="300" r="1.7"/><circle cx="150" cy="420" r="2.1"/><circle cx="40" cy="520" r="1.5"/>
|
||||
<circle cx="110" cy="640" r="2"/><circle cx="1230" cy="360" r="1.8"/><circle cx="1180" cy="470" r="2.2"/>
|
||||
<circle cx="1250" cy="600" r="1.6"/><circle cx="1200" cy="720" r="2"/><circle cx="640" cy="40" r="1.4"/>
|
||||
<circle cx="380" cy="300" r="1.5"/><circle cx="900" cy="320" r="1.6"/><circle cx="1080" cy="260" r="1.5"/>
|
||||
</g>
|
||||
<g fill="#ffd24a">
|
||||
<path d="M240 240 l4 10 10 4 -10 4 -4 10 -4 -10 -10 -4 10 -4z"/>
|
||||
<path d="M1000 500 l4 10 10 4 -10 4 -4 10 -4 -10 -10 -4 10 -4z"/>
|
||||
<path d="M470 470 l3 8 8 3 -8 3 -3 8 -3 -8 -8 -3 8 -3z"/>
|
||||
<path d="M760 250 l3 8 8 3 -8 3 -3 8 -3 -8 -8 -3 8 -3z"/>
|
||||
</g>
|
||||
|
||||
<!-- purple ringed planet (top-left) -->
|
||||
<g transform="translate(150,180)">
|
||||
<ellipse cx="0" cy="0" rx="120" ry="34" fill="none" stroke="#d7a24a" stroke-width="10" transform="rotate(-20)"/>
|
||||
<circle cx="0" cy="0" r="62" fill="url(#ring)" stroke="#0a1226" stroke-width="4"/>
|
||||
<ellipse cx="0" cy="0" rx="120" ry="34" fill="none" stroke="#d7a24a" stroke-width="10" transform="rotate(-20)" stroke-dasharray="150 400" stroke-dashoffset="150"/>
|
||||
</g>
|
||||
|
||||
<!-- spiral galaxy (top-right) -->
|
||||
<g transform="translate(1080,190) rotate(25)">
|
||||
<ellipse cx="0" cy="0" rx="130" ry="70" fill="url(#galaxy)"/>
|
||||
<path d="M0 0 Q60 -30 90 20 Q40 40 0 0" fill="#c9a8ef" opacity="0.7"/>
|
||||
<path d="M0 0 Q-60 30 -90 -20 Q-40 -40 0 0" fill="#c9a8ef" opacity="0.7"/>
|
||||
<circle cx="0" cy="0" r="10" fill="#ffffff"/>
|
||||
</g>
|
||||
|
||||
<!-- teal planet (right) -->
|
||||
<circle cx="1180" cy="360" r="46" fill="url(#teal)" stroke="#0a1226" stroke-width="4"/>
|
||||
<ellipse cx="1165" cy="345" rx="16" ry="8" fill="#7fe6d2" opacity="0.6"/>
|
||||
|
||||
<!-- orange cratered moon (bottom-right) -->
|
||||
<g transform="translate(1130,800)">
|
||||
<circle r="90" fill="url(#moon)" stroke="#0a1226" stroke-width="5"/>
|
||||
<circle cx="-30" cy="-20" r="16" fill="#a85417" opacity="0.6"/>
|
||||
<circle cx="20" cy="10" r="22" fill="#a85417" opacity="0.5"/>
|
||||
<circle cx="-10" cy="40" r="12" fill="#a85417" opacity="0.6"/>
|
||||
<circle cx="40" cy="-30" r="9" fill="#a85417" opacity="0.6"/>
|
||||
</g>
|
||||
|
||||
<!-- blue asteroid/planet (bottom-left) -->
|
||||
<circle cx="90" cy="770" r="70" fill="#3f6fae" stroke="#0a1226" stroke-width="5"/>
|
||||
<circle cx="65" cy="745" r="14" fill="#5f8fce" opacity="0.6"/>
|
||||
<circle cx="110" cy="800" r="10" fill="#2f579a" opacity="0.6"/>
|
||||
|
||||
<!-- flaming comet (left) -->
|
||||
<g transform="translate(210,360)">
|
||||
<path d="M0 0 L-150 -40 L-150 40 Z" fill="url(#comet)"/>
|
||||
<circle r="22" fill="#ffce54" stroke="#e07b1f" stroke-width="4"/>
|
||||
<path d="M-6 -10 q10 -14 20 0 q10 -12 16 4 q-8 8 -20 6 q-14 4 -16 -10z" fill="#ff7a2a"/>
|
||||
</g>
|
||||
|
||||
<!-- cartoon rocket (right, angled up) -->
|
||||
<g transform="translate(1000,430) rotate(-30)">
|
||||
<path d="M0 -46 q22 22 22 60 h-44 q0 -38 22 -60z" fill="#eef2f7" stroke="#0a1226" stroke-width="4"/>
|
||||
<circle cx="0" cy="0" r="10" fill="#7fd0ff" stroke="#0a1226" stroke-width="3"/>
|
||||
<path d="M-22 14 l-18 22 18 -4z" fill="#d64a3a" stroke="#0a1226" stroke-width="3"/>
|
||||
<path d="M22 14 l18 22 -18 -4z" fill="#d64a3a" stroke="#0a1226" stroke-width="3"/>
|
||||
<path d="M-10 20 q10 26 20 0z" fill="#ff9a2a"/>
|
||||
<path d="M-6 24 q6 16 12 0z" fill="#ffd24a"/>
|
||||
</g>
|
||||
|
||||
<!-- scattered asteroids -->
|
||||
<g fill="#8a8f9c" stroke="#0a1226" stroke-width="3">
|
||||
<path d="M330 640 q-18 -10 -6 -26 q16 -12 30 -2 q16 8 8 26 q-10 16 -32 2z"/>
|
||||
<path d="M980 700 q-14 -8 -4 -22 q12 -10 24 -2 q12 6 6 22 q-8 12 -26 2z"/>
|
||||
<path d="M640 820 q-16 -8 -6 -24 q14 -10 28 -2 q14 8 6 24 q-10 14 -28 2z"/>
|
||||
</g>
|
||||
|
||||
<!-- ===== cosmic sea wave ===== -->
|
||||
<path d="M0 720
|
||||
q120 -60 240 -20 q120 40 240 0 q120 -40 240 0 q120 40 240 -10 q120 -50 240 0
|
||||
q40 20 80 10 L1280 960 L0 960 Z"
|
||||
fill="url(#wave)" stroke="#1a2c66" stroke-width="4"/>
|
||||
<g fill="#bfe9ff" opacity="0.85">
|
||||
<path d="M120 720 q30 -26 60 0 q-30 18 -60 0z"/>
|
||||
<path d="M420 706 q28 -24 56 0 q-28 18 -56 0z"/>
|
||||
<path d="M760 712 q30 -26 60 0 q-30 18 -60 0z"/>
|
||||
<path d="M1040 700 q28 -24 56 0 q-28 18 -56 0z"/>
|
||||
</g>
|
||||
<!-- foam swirl -->
|
||||
<path d="M0 760 q160 40 320 10 q160 -30 320 6 q160 36 320 0 q160 -36 320 8" fill="none" stroke="#dff3ff" stroke-width="6" stroke-linecap="round" opacity="0.8"/>
|
||||
|
||||
<!-- ===== pirate ship ===== -->
|
||||
<g transform="translate(640,560)">
|
||||
<!-- main mast + black skull sail -->
|
||||
<rect x="-6" y="-260" width="12" height="230" fill="#7a5228" stroke="#0a1226" stroke-width="3"/>
|
||||
<!-- pennant flag -->
|
||||
<path d="M6 -258 l70 16 -70 16z" fill="#c0392b" stroke="#0a1226" stroke-width="3"/>
|
||||
<!-- top small skull flag -->
|
||||
<g transform="translate(0,-250)">
|
||||
<rect x="-2" y="-8" width="4" height="10" fill="#7a5228"/>
|
||||
</g>
|
||||
<!-- big black sail -->
|
||||
<path d="M-150 -230 q150 30 300 0 q-30 130 0 200 q-150 30 -300 0 q30 -110 0 -200z"
|
||||
fill="#151515" stroke="#0a1226" stroke-width="4"/>
|
||||
<!-- skull and crossbones -->
|
||||
<g transform="translate(0,-120)" fill="#f5f5f5">
|
||||
<path d="M-60 0 q60 -14 120 0 M-60 20 q60 14 120 0" stroke="#f5f5f5" stroke-width="12" fill="none" stroke-linecap="round"/>
|
||||
<ellipse cx="0" cy="-8" rx="44" ry="40"/>
|
||||
<path d="M-30 24 q30 18 60 0 l-6 16 -12 -6 -6 10 -6 -10 -12 6z"/>
|
||||
<circle cx="-16" cy="-8" r="12" fill="#151515"/>
|
||||
<circle cx="16" cy="-8" r="12" fill="#151515"/>
|
||||
<path d="M-4 6 l4 -10 4 10z" fill="#151515"/>
|
||||
</g>
|
||||
|
||||
<!-- hull -->
|
||||
<path d="M-200 20 q0 90 200 90 q200 0 200 -90 q-30 26 -200 26 q-170 0 -200 -26z"
|
||||
fill="url(#hull)" stroke="#0a1226" stroke-width="5"/>
|
||||
<!-- deck trim -->
|
||||
<rect x="-200" y="8" width="400" height="18" rx="8" fill="#c69a5a" stroke="#0a1226" stroke-width="4"/>
|
||||
<!-- portholes -->
|
||||
<g fill="#3a2410" stroke="#0a1226" stroke-width="3">
|
||||
<circle cx="-120" cy="66" r="12"/><circle cx="-60" cy="76" r="12"/>
|
||||
<circle cx="0" cy="80" r="12"/><circle cx="60" cy="76" r="12"/><circle cx="120" cy="66" r="12"/>
|
||||
</g>
|
||||
|
||||
<!-- crow's nest with telescope pirate -->
|
||||
<g transform="translate(150,-190)">
|
||||
<path d="M-22 0 h44 l-6 26 h-32z" fill="#8a5f2c" stroke="#0a1226" stroke-width="3"/>
|
||||
<circle cx="0" cy="-10" r="12" fill="#f0c39a" stroke="#0a1226" stroke-width="2"/>
|
||||
<path d="M-12 -14 q12 -10 24 0 l-2 -10 -22 0z" fill="#c0392b"/>
|
||||
<rect x="6" y="-16" width="26" height="6" rx="3" fill="#3a2410" transform="rotate(-20 6 -16)" stroke="#0a1226" stroke-width="2"/>
|
||||
</g>
|
||||
|
||||
<!-- captain (center, cutlass) -->
|
||||
<g transform="translate(0,-30)">
|
||||
<path d="M-18 0 q18 -8 36 0 l-4 34 h-28z" fill="#c0392b" stroke="#0a1226" stroke-width="3"/>
|
||||
<circle cx="0" cy="-16" r="17" fill="#f0c39a" stroke="#0a1226" stroke-width="3"/>
|
||||
<path d="M-20 -22 q20 -18 40 0 q-6 -14 -20 -14 q-14 0 -20 14z" fill="#1b1b1b"/>
|
||||
<path d="M-8 -20 h6" stroke="#1b1b1b" stroke-width="3"/>
|
||||
<path d="M4 -20 h6" stroke="#1b1b1b" stroke-width="3"/>
|
||||
<path d="M-4 -8 q4 4 8 0" stroke="#7a3b1b" stroke-width="3" fill="none"/>
|
||||
<!-- cutlass -->
|
||||
<path d="M18 -6 q40 -30 60 -50" stroke="#d9dde3" stroke-width="6" fill="none" stroke-linecap="round"/>
|
||||
<rect x="14" y="-10" width="10" height="14" rx="3" fill="#c9a25a" stroke="#0a1226" stroke-width="2"/>
|
||||
</g>
|
||||
|
||||
<!-- pirate with telescope (left, on deck) -->
|
||||
<g transform="translate(-110,-24)">
|
||||
<path d="M-16 0 q16 -6 32 0 l-4 26 h-24z" fill="#2f6fae" stroke="#0a1226" stroke-width="3"/>
|
||||
<circle cx="0" cy="-14" r="14" fill="#f0c39a" stroke="#0a1226" stroke-width="3"/>
|
||||
<path d="M-16 -18 q16 -8 30 -2" stroke="#c0392b" stroke-width="8" fill="none" stroke-linecap="round"/>
|
||||
<rect x="8" y="-18" width="30" height="7" rx="3" fill="#3a2410" transform="rotate(-15 8 -18)" stroke="#0a1226" stroke-width="2"/>
|
||||
</g>
|
||||
|
||||
<!-- third pirate (right) -->
|
||||
<g transform="translate(80,-22)">
|
||||
<path d="M-14 0 q14 -6 28 0 l-4 24 h-20z" fill="#6a4aa0" stroke="#0a1226" stroke-width="3"/>
|
||||
<circle cx="0" cy="-14" r="13" fill="#f0c39a" stroke="#0a1226" stroke-width="3"/>
|
||||
<path d="M-14 -18 q14 -8 28 0 l-2 -9 -24 0z" fill="#c0392b"/>
|
||||
</g>
|
||||
|
||||
<!-- parrot on the rail -->
|
||||
<g transform="translate(120,-58)">
|
||||
<ellipse cx="0" cy="0" rx="12" ry="16" fill="#2ecc71" stroke="#0a1226" stroke-width="3"/>
|
||||
<circle cx="0" cy="-14" r="8" fill="#2ecc71" stroke="#0a1226" stroke-width="3"/>
|
||||
<path d="M6 -16 l12 4 -12 4z" fill="#f1c40f" stroke="#0a1226" stroke-width="2"/>
|
||||
<circle cx="1" cy="-16" r="2" fill="#0a1226"/>
|
||||
<path d="M-6 4 q-14 6 -18 20 q10 -6 20 -12z" fill="#e74c3c" stroke="#0a1226" stroke-width="2"/>
|
||||
<path d="M-4 -2 q-16 4 -20 16 q10 -4 20 -8z" fill="#f39c12" stroke="#0a1226" stroke-width="2"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- goofy fish/creature in the water -->
|
||||
<g transform="translate(830,700)">
|
||||
<ellipse cx="0" cy="0" rx="46" ry="30" fill="#e8b84a" stroke="#0a1226" stroke-width="4"/>
|
||||
<path d="M40 0 l34 -22 v44z" fill="#e8b84a" stroke="#0a1226" stroke-width="4"/>
|
||||
<circle cx="-18" cy="-8" r="12" fill="#ffffff" stroke="#0a1226" stroke-width="3"/>
|
||||
<circle cx="6" cy="-8" r="12" fill="#ffffff" stroke="#0a1226" stroke-width="3"/>
|
||||
<circle cx="-16" cy="-6" r="5" fill="#0a1226"/>
|
||||
<circle cx="8" cy="-6" r="5" fill="#0a1226"/>
|
||||
<path d="M-30 14 q18 16 44 0" stroke="#0a1226" stroke-width="4" fill="none" stroke-linecap="round"/>
|
||||
<path d="M-2 14 q6 22 14 8z" fill="#c0392b" stroke="#0a1226" stroke-width="2"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,92 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Mediamark — Chart the Kids' Course</title>
|
||||
<link rel="stylesheet" href="app.css">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%8F%B4%3C/text%3E%3C/svg%3E">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="container navbar-inner">
|
||||
<a class="brand" href="#/"><span class="brand-mark">⚓</span> Mediamark</a>
|
||||
<span class="brand-tag">stow the wholesome loot in cheeztv</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Landing: two big square tiles -->
|
||||
<div class="container view" id="view-home">
|
||||
<header class="hero">
|
||||
<h1>Which hold shall we raid?</h1>
|
||||
<p class="lead">Pick a section, then mark the titles fit for the wee crew.</p>
|
||||
</header>
|
||||
<section class="app-grid app-grid-2" id="home-tiles" aria-label="sections">
|
||||
<a class="app-tile" href="#/movies" data-section="movies" data-key="m">
|
||||
<span class="app-tile-icon" aria-hidden="true">🎬</span>
|
||||
<span class="app-tile-name">Movies</span>
|
||||
<span class="app-tile-key">press <kbd>m</kbd></span>
|
||||
</a>
|
||||
<a class="app-tile" href="#/tvseries" data-section="tvseries" data-key="t">
|
||||
<span class="app-tile-icon" aria-hidden="true">📺</span>
|
||||
<span class="app-tile-name">TV Series</span>
|
||||
<span class="app-tile-key">press <kbd>t</kbd></span>
|
||||
</a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Section: search + filtered list -->
|
||||
<div class="view hidden" id="view-section">
|
||||
<div class="searchbar">
|
||||
<div class="container searchbar-inner">
|
||||
<a class="btn btn-back" href="#/" title="Back (Esc)">←</a>
|
||||
<input class="form-control" id="search" type="search" autocomplete="off" spellcheck="false"
|
||||
placeholder="Search titles…" aria-label="Search titles">
|
||||
<span class="search-count muted" id="search-count"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<h2 class="section-title" id="section-title">Movies</h2>
|
||||
<div class="title-list" id="title-list" role="listbox" aria-label="titles" tabindex="-1"></div>
|
||||
<p class="muted empty hidden" id="list-empty">No titles match that search.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail panel -->
|
||||
<div class="container view hidden" id="view-detail">
|
||||
<a class="btn btn-back btn-back-block" href="#" id="detail-back">← Back (Esc)</a>
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<span class="panel-icon" aria-hidden="true">🎭</span>
|
||||
<h2 id="detail-title">Title</h2>
|
||||
</div>
|
||||
<div class="panel-body detail-body">
|
||||
<div class="detail-art">
|
||||
<img id="detail-poster" alt="" class="poster poster-lg hidden">
|
||||
<div id="detail-noposter" class="poster poster-lg poster-placeholder">?</div>
|
||||
</div>
|
||||
<div class="detail-meta">
|
||||
<p class="detail-facts" id="detail-facts"></p>
|
||||
<p id="detail-overview" class="detail-overview"></p>
|
||||
<dl class="detail-stats">
|
||||
<dt>On disk</dt><dd id="detail-size">—</dd>
|
||||
<dt>Files</dt><dd id="detail-files">—</dd>
|
||||
<dt>cheeztv</dt><dd id="detail-marked">—</dd>
|
||||
</dl>
|
||||
<div class="detail-actions">
|
||||
<button class="btn btn-primary" id="detail-toggle">Add to cheeztv</button>
|
||||
<button class="btn btn-copy hidden" id="detail-sync">Sync new files</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="container keyhint">
|
||||
<span id="keyhint-text"></span>
|
||||
</footer>
|
||||
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user