Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/app-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,21 @@ jobs:
uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}

nix-checks:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6

- name: Install Nix
uses: cachix/install-nix-action@v31

- name: Evaluate flake
run: nix flake check --no-build

- name: Build package
run: nix build .#default -L

- name: Run unit tests
run: nix build .#checks.x86_64-linux.yamtrack-unit-tests -L
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ CLAUDE.md
requests.http
TODO.txt
.codex
result/
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,72 @@ Note that the setting must include the correct protocol (`https` or `http`), and

For detailed information on environment variables, please refer to the [Environment Variables wiki page](https://github.com/FuzzyGrim/Yamtrack/wiki/Environment-Variables).

## ❄️ Installing with Nix

[Nix](https://nixos.org/) is a declarative build tool which allows for reproducible builds. Yamtrack can be built and used with nix. NixOS can configure yamtrack without utilizing a container runtime.

### Standalone with `nix run`

Try Yamtrack without installing — this starts gunicorn on `localhost:8001` (requires a running Redis on `localhost:6379`), no preinstalled dependencies required:

```bash
nix run github:FuzzyGrim/Yamtrack
```

### NixOS module via flake

Add the flake to your NixOS configuration:

```nix
# flake.nix
{
inputs.yamtrack.url = "github:FuzzyGrim/Yamtrack";

outputs = { nixpkgs, yamtrack, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
modules = [
yamtrack.nixosModules.default
{
services.yamtrack = {
enable = true;
port = 8001;
# secretKeyFile = "/run/secrets/yamtrack-secret";
};
}
];
};
};
}
```

This sets up gunicorn, Celery worker, Celery beat, and Redis automatically. SQLite is the default database.

For PostgreSQL instead:

```nix
services.yamtrack = {
enable = true;
database.createLocally = true; # provisions PostgreSQL via Unix socket
};
```

See all module options in the [flake.nix](flake.nix) header comment.

### Running tests

```bash
# Sandboxed unit tests (no network needed)
nix build .#checks.x86_64-linux.yamtrack-unit-tests

# NixOS VM tests
nix build .#checks.x86_64-linux.yamtrack-sqlite
nix build .#checks.x86_64-linux.yamtrack-postgresql
nix build .#checks.x86_64-linux.yamtrack-playwright

# Full test suite with real API access (needs network)
nix run .#run-tests
```

## 💻 Local development

Clone the repository and change directory to it.
Expand Down
27 changes: 27 additions & 0 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

74 changes: 74 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Yamtrack - a media tracker built with Django
#
# Outputs:
# packages.x86_64-linux.default — Yamtrack Django application
# packages.x86_64-linux.run-tests — Test runner with network access
# apps.x86_64-linux.run-tests — `nix run .#run-tests` for full test suite
# checks.x86_64-linux.yamtrack-unit-tests — Sandboxed unit tests (460+)
# checks.x86_64-linux.yamtrack-sqlite — NixOS VM test with SQLite
# checks.x86_64-linux.yamtrack-postgresql — NixOS VM test with PostgreSQL
# checks.x86_64-linux.yamtrack-nginx — NixOS VM test with nginx reverse proxy
# checks.x86_64-linux.yamtrack-playwright — Playwright integration tests in VM
# nixosModules.default — NixOS service module
#
# Module options (services.yamtrack):
# enable — Enable Yamtrack service
# package — The Yamtrack package to use
# address / port — Gunicorn bind address (default: localhost:8001)
# database.createLocally — Use local PostgreSQL (default: false → SQLite)
# redis.createLocally — Create local Redis instance (default: true)
# secretKeyFile — Path to file with Django SECRET_KEY
# extraConfig — Extra environment variables
# user / group — Service user/group (default: yamtrack)
{
description = "Yamtrack - a media tracker built with Django";

inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};

outputs =
{ self, nixpkgs }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
python = pkgs.python3;

packages = import ./nix/package.nix { inherit self pkgs python; };
inherit (packages) yamtrack yamtrackDeps;

tests = import ./nix/tests.nix {
inherit
self
pkgs
system
yamtrack
yamtrackDeps
python
;
};
in
{
packages.${system} = {
default = yamtrack;
inherit (tests) run-tests;
};

apps.${system}.run-tests = {
type = "app";
program = "${tests.run-tests}/bin/yamtrack-run-tests";
};

checks.${system} = {
inherit (tests)
yamtrack-unit-tests
yamtrack-sqlite
yamtrack-postgresql
yamtrack-nginx
yamtrack-playwright
;
};

nixosModules.default = import ./nix/module.nix { inherit self system; };
};
}
179 changes: 179 additions & 0 deletions nix/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"""Pytest conftest that mocks all external API calls for sandboxed nix builds.

This file is injected by the nix test derivations so tests run without
network access. Individual tests that already carry their own @patch
decorators will override these auto‑use fixtures transparently.
"""

from datetime import UTC, datetime
from unittest.mock import patch

import pytest


def _mock_get_media_metadata(media_type, media_id, source,
season_numbers=None, episode_number=None):
"""Return plausible metadata for any media type."""
# Manual sources have no real API provider — return minimal data
# but include season keys so Episode.save() doesn't crash
if source == "manual":
sn_list = season_numbers or [1]
result = {
"title": "Manual Media",
"image": "http://example.com/image.jpg",
"max_progress": None,
"details": {"seasons": 0},
"related": {"seasons": []},
"episodes": [
{
"episode_number": i,
"image": "http://example.com/ep.jpg",
"air_date": None,
}
for i in range(1, 25)
],
}
for sn in sn_list:
result[f"season/{sn}"] = {
"image": "http://example.com/season.jpg",
"season_number": sn,
"episodes": [
{
"episode_number": i,
"image": "http://example.com/ep.jpg",
"air_date": None,
}
for i in range(1, 25)
],
}
return result

season_numbers = season_numbers or [1]

if media_type == "tv_with_seasons":
result = {
"title": "Test Show",
"image": "http://example.com/image.jpg",
"max_progress": 10,
"details": {"seasons": len(season_numbers)},
"related": {
"seasons": [
{
"season_number": sn,
"image": "http://example.com/season.jpg",
"first_air_date": datetime(2020, 1, 1, tzinfo=UTC),
}
for sn in season_numbers
],
},
}
for sn in season_numbers:
result[f"season/{sn}"] = {
"image": "http://example.com/season.jpg",
"season_number": sn,
"episodes": [
{
"episode_number": i,
"image": "http://example.com/ep.jpg",
"air_date": datetime(2020, 1, i, tzinfo=UTC),
}
for i in range(1, 25)
],
}
return result

if media_type == "season":
return {
"title": "Test Show",
"image": "http://example.com/season.jpg",
"episodes": [
{
"episode_number": i,
"image": "http://example.com/ep.jpg",
"air_date": datetime(2020, 1, i, tzinfo=UTC),
}
for i in range(1, 25)
],
}

if media_type == "tv":
num_seasons = 10
result = {
"title": "Test Show",
"image": "http://example.com/image.jpg",
"max_progress": num_seasons,
"details": {"seasons": num_seasons},
"related": {
"seasons": [
{
"season_number": sn,
"image": "http://example.com/season.jpg",
"first_air_date": datetime(2020, 1, 1, tzinfo=UTC),
}
for sn in range(1, num_seasons + 1)
],
},
}
for sn in range(1, num_seasons + 1):
result[f"season/{sn}"] = {
"image": "http://example.com/season.jpg",
"season_number": sn,
"episodes": [
{
"episode_number": i,
"image": "http://example.com/ep.jpg",
"air_date": datetime(2020, 1, i, tzinfo=UTC),
}
for i in range(1, 25)
],
}
return result

# Games are measured in minutes — no meaningful max_progress
if media_type == "game":
return {
"title": "Test Game",
"image": "http://example.com/image.jpg",
"max_progress": None,
}

# Anime, movie, manga, book, comic, boardgame — have episode/chapter counts
return {
"title": "Test Media",
"image": "http://example.com/image.jpg",
"max_progress": 26,
}


def _mock_search(media_type, query, page, source=None):
"""Return an empty search result."""
return []


@pytest.fixture(autouse=True)
def _mock_external_apis(request):
"""Auto-mock all external API calls to prevent network access.

Skips auto-mocking for tests in the providers/ directory since those
have their own fine-grained mocks.
"""
test_path = str(request.fspath)
if "/tests/providers/" in test_path or "/tests/calendar/" in test_path:
yield
return

with (
patch(
"app.providers.services.get_media_metadata",
side_effect=_mock_get_media_metadata,
),
patch(
"app.providers.services.search",
side_effect=_mock_search,
),
patch(
"app.providers.tmdb.watch_provider_regions",
return_value=[],
),
):
yield
Loading