",
- "category": 5030,
- "category_name": "TV > SD",
- "added": "Wed, 20 Nov 2024 12:00:00 +0000",
- "size": 450971565,
- "files": 44,
- "grabs": 3,
- "comments": 0,
- "password": 0,
- "usenetdate": "Wed, 20 Nov 2024 10:00:00 +0000",
- "tvairdate": "2024-11-19",
- "tvdbid": 12345,
- "traktid": 67890,
- "tvrageid": null,
- "tvmazeid": null,
- "imdbid": 1234567,
- "tmdbid": 98765
-}
-```
+## Error Response Conventions
-## Status Codes and Error Body Cheat Sheet
+- Missing/invalid token: JSON `403`
+- Invalid `maxage`: JSON `400`
+- Invalid `sort`: JSON `400`
+- Missing required endpoint parameter (`id`, etc.): JSON `400`
+- Missing GUID in `/getnzb`: JSON `404`
-| Endpoint(s) | HTTP | Body |
-|---|---:|---|
-| `/movies`, `/search`, `/tv`, `/getnzb`, `/details` (token failure in controller) | 403 | `{ "error": "Missing or invalid API key" }` |
-| `/details` (missing `id`) | 400 | `{ "error": "Missing parameter (guid is required for single release details)" }` |
-| `/getnzb` (GUID not found) | 404 | `{ "data": "No such item (the guid you provided has no release in our database)" }` |
+## Unsupported in v2
-## Postman / Documenter Sync Source
+The following are intentionally not part of v2 JSON API:
-If you maintain the public Postman page, use this markdown as the source of truth and mirror:
+- `register`
+- `user`
+- `comments`
+- `commentadd`
+- `cartadd`
+- `cartdel`
+- `nzbadd`
-1. endpoint auth requirements
-2. request parameter descriptions
-3. response envelope vs details-object differences
-4. exact error messages
+NZB upload remains in v1 (`/api/v1/api?t=nzbadd`).
-Starter collection (import into Postman, then publish with Documenter):
+## Postman Collection
- `docs/postman/nntmux_api_v2.postman_collection.json`
-
-This prevents drift between code and `https://documenter.getpostman.com/view/3059471/RW8FGS9E`.
diff --git a/resources/views/api/apidesc.blade.php b/resources/views/api/apidesc.blade.php
index 547ececa8..e5a3119f1 100644
--- a/resources/views/api/apidesc.blade.php
+++ b/resources/views/api/apidesc.blade.php
@@ -288,6 +288,52 @@
+
+ Sorting Results (v1)
+
+
+ Search-style endpoints support sort=field_direction.
+ Allowed fields: cat, name, size, files, stats, posted. Direction is asc or desc.
+
+
+
+
Sort request examples
+
+
+
+
+
+
XML sort response snippet (sort=size_desc)
+
<item>
+ <title>Ubuntu ISO x64</title>
+ <newznab:attr name="size" value="734003200"/>
+</item>
+<item>
+ <title>Ubuntu ISO x86</title>
+ <newznab:attr name="size" value="367001600"/>
+</item>
+
Larger release appears first because size_desc sorts descending.
+
+
Output Format
diff --git a/resources/views/api/apiv2desc.blade.php b/resources/views/api/apiv2desc.blade.php
index 8183744f5..afc5ca4b3 100644
--- a/resources/views/api/apiv2desc.blade.php
+++ b/resources/views/api/apiv2desc.blade.php
@@ -275,6 +275,52 @@
+
+ Sorting Results (v2)
+
+
+ Search endpoints support sort=field_direction.
+ Allowed fields: cat, name, size, files, stats, posted. Direction is asc or desc.
+
+
+
+
Sort request examples
+
+
+
+
+
+
JSON sort response snippet (sort=size_desc)
+
{
+ "Total": 2,
+ "Results": [
+ { "title": "Ubuntu ISO x64", "size": 734003200 },
+ { "title": "Ubuntu ISO x86", "size": 367001600 }
+ ]
+}
+
Results are largest-to-smallest when sort=size_desc.
+
+
+
Output Format
diff --git a/tests/Feature/ApiRequestMatrixTest.php b/tests/Feature/ApiRequestMatrixTest.php
new file mode 100644
index 000000000..d4ee44934
--- /dev/null
+++ b/tests/Feature/ApiRequestMatrixTest.php
@@ -0,0 +1,285 @@
+ 'sqlite',
+ 'database.connections.sqlite.database' => ':memory:',
+ 'mail.from.address' => 'api-matrix@example.test',
+ 'app.key' => 'base64:'.base64_encode(random_bytes(32)),
+ ]);
+
+ DB::purge();
+ DB::reconnect();
+ Cache::flush();
+
+ $this->createSchema();
+ $this->seedData();
+ }
+
+ public function test_v1_invalid_sort_returns_xml_201_error(): void
+ {
+ $token = (string) DB::table('users')->value('api_token');
+
+ $response = $this->get('/api/v1/api?t=search&apikey='.$token.'&q=test&sort=bad_value');
+
+ $response->assertOk();
+ $response->assertSee('assertSee('Incorrect parameter (sort', false);
+ }
+
+ public function test_v1_invalid_maxage_returns_xml_201_error(): void
+ {
+ $token = (string) DB::table('users')->value('api_token');
+
+ $response = $this->get('/api/v1/api?t=search&apikey='.$token.'&q=test&maxage=abc');
+
+ $response->assertOk();
+ $response->assertSee('assertSee('maxage must be numeric', false);
+ }
+
+ public function test_v2_invalid_sort_returns_json_400_error(): void
+ {
+ $token = (string) DB::table('users')->value('api_token');
+
+ $this->getJson('/api/v2/search?api_token='.$token.'&id=test&sort=bad_value')
+ ->assertStatus(400)
+ ->assertJsonPath('error', 'Incorrect parameter (sort must be one of: cat_asc/desc, name_asc/desc, size_asc/desc, files_asc/desc, stats_asc/desc, posted_asc/desc)');
+ }
+
+ public function test_v2_invalid_maxage_returns_json_400_error(): void
+ {
+ $token = (string) DB::table('users')->value('api_token');
+
+ $this->getJson('/api/v2/search?api_token='.$token.'&id=test&maxage=abc')
+ ->assertStatus(400)
+ ->assertJsonPath('error', 'Incorrect parameter (maxage must be numeric)');
+ }
+
+ public function test_v1_caps_menu_data_includes_groups_and_genres(): void
+ {
+ $apiController = app(ApiController::class);
+ $reflection = new ReflectionClass($apiController);
+ $typeProperty = $reflection->getProperty('type');
+ $typeProperty->setAccessible(true);
+ $typeProperty->setValue($apiController, 'caps');
+
+ $menu = $apiController->getForMenu();
+
+ $this->assertSame('alt.binaries.test', $menu['groups'][0]['name']);
+ $this->assertSame('Test Genre', $menu['genres'][0]['name']);
+ }
+
+ public function test_v2_capabilities_includes_groups_and_genres(): void
+ {
+ $this->getJson('/api/v2/capabilities')
+ ->assertOk()
+ ->assertJsonPath('groups.0.name', 'alt.binaries.test')
+ ->assertJsonPath('genres.0.name', 'Test Genre');
+ }
+
+ private function createSchema(): void
+ {
+ Schema::create('roles', function (Blueprint $table): void {
+ $table->increments('id');
+ $table->string('name');
+ $table->string('guard_name')->default('web');
+ $table->integer('rate_limit')->default(60);
+ $table->integer('apirequests')->default(1000);
+ $table->integer('downloadrequests')->default(100);
+ $table->integer('addyears')->default(0);
+ $table->timestamps();
+ });
+
+ Schema::create('users', function (Blueprint $table): void {
+ $table->increments('id');
+ $table->string('username')->unique();
+ $table->string('email')->unique();
+ $table->string('password');
+ $table->unsignedInteger('roles_id')->default(1);
+ $table->string('api_token')->nullable()->index();
+ $table->string('host')->nullable();
+ $table->timestamp('apiaccess')->nullable();
+ $table->boolean('verified')->default(true);
+ $table->timestamp('email_verified_at')->nullable();
+ $table->integer('rate_limit')->default(60);
+ $table->timestamps();
+ $table->softDeletes();
+ });
+
+ Schema::create('permissions', function (Blueprint $table): void {
+ $table->increments('id');
+ $table->string('name');
+ $table->string('guard_name')->default('web');
+ $table->timestamps();
+ });
+
+ Schema::create('model_has_roles', function (Blueprint $table): void {
+ $table->unsignedInteger('role_id');
+ $table->string('model_type');
+ $table->unsignedInteger('model_id');
+ $table->primary(['role_id', 'model_id', 'model_type']);
+ });
+
+ Schema::create('model_has_permissions', function (Blueprint $table): void {
+ $table->unsignedInteger('permission_id');
+ $table->string('model_type');
+ $table->unsignedInteger('model_id');
+ $table->primary(['permission_id', 'model_id', 'model_type']);
+ });
+
+ Schema::create('role_has_permissions', function (Blueprint $table): void {
+ $table->unsignedInteger('permission_id');
+ $table->unsignedInteger('role_id');
+ $table->primary(['permission_id', 'role_id']);
+ });
+
+ Schema::create('settings', function (Blueprint $table): void {
+ $table->string('name')->primary();
+ $table->text('value')->nullable();
+ });
+
+ Schema::create('root_categories', function (Blueprint $table): void {
+ $table->increments('id');
+ $table->string('title')->default('');
+ $table->integer('status')->default(1);
+ });
+
+ Schema::create('categories', function (Blueprint $table): void {
+ $table->increments('id');
+ $table->string('title')->default('');
+ $table->unsignedInteger('root_categories_id')->nullable();
+ $table->integer('status')->default(1);
+ $table->text('description')->nullable();
+ });
+
+ Schema::create('user_excluded_categories', function (Blueprint $table): void {
+ $table->increments('id');
+ $table->unsignedInteger('users_id');
+ $table->unsignedInteger('categories_id');
+ });
+
+ Schema::create('user_requests', function (Blueprint $table): void {
+ $table->increments('id');
+ $table->unsignedInteger('users_id');
+ $table->text('request')->nullable();
+ $table->timestamp('timestamp')->nullable();
+ });
+
+ Schema::create('user_downloads', function (Blueprint $table): void {
+ $table->increments('id');
+ $table->unsignedInteger('users_id');
+ $table->timestamp('timestamp')->nullable();
+ });
+
+ Schema::create('usenet_groups', function (Blueprint $table): void {
+ $table->increments('id');
+ $table->string('name');
+ $table->boolean('active')->default(true);
+ $table->string('description')->nullable();
+ $table->timestamp('last_updated')->nullable();
+ });
+
+ Schema::create('genres', function (Blueprint $table): void {
+ $table->increments('id');
+ $table->string('title');
+ $table->integer('type')->default(3000);
+ $table->boolean('disabled')->default(false);
+ });
+
+ Schema::create('registration_periods', function (Blueprint $table): void {
+ $table->increments('id');
+ $table->string('name');
+ $table->dateTime('starts_at');
+ $table->dateTime('ends_at');
+ $table->boolean('is_enabled')->default(true);
+ $table->text('notes')->nullable();
+ $table->unsignedInteger('created_by')->nullable();
+ $table->unsignedInteger('updated_by')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ private function seedData(): void
+ {
+ DB::table('settings')->insert([
+ ['name' => 'strapline', 'value' => 'Test strapline'],
+ ['name' => 'metakeywords', 'value' => 'test,api'],
+ ['name' => 'registerstatus', 'value' => '0'],
+ ['name' => 'catwebdl', 'value' => '0'],
+ ['name' => 'title', 'value' => 'NNTmux Test'],
+ ['name' => 'home_link', 'value' => '/'],
+ ]);
+
+ DB::table('roles')->insert([
+ 'id' => 1,
+ 'name' => 'User',
+ 'guard_name' => 'web',
+ 'rate_limit' => 60,
+ 'apirequests' => 1000,
+ 'downloadrequests' => 100,
+ 'addyears' => 0,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ DB::table('users')->insert([
+ 'username' => 'matrix_user',
+ 'email' => 'matrix@example.test',
+ 'password' => bcrypt('secret'),
+ 'roles_id' => 1,
+ 'api_token' => Str::random(32),
+ 'verified' => 1,
+ 'email_verified_at' => now(),
+ 'rate_limit' => 60,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ DB::table('root_categories')->insert([
+ 'id' => 5000,
+ 'title' => 'TV',
+ 'status' => 1,
+ ]);
+
+ DB::table('categories')->insert([
+ 'id' => 5030,
+ 'title' => 'SD',
+ 'root_categories_id' => 5000,
+ 'status' => 1,
+ 'description' => 'TV SD',
+ ]);
+
+ DB::table('usenet_groups')->insert([
+ 'name' => 'alt.binaries.test',
+ 'active' => 1,
+ 'description' => 'Test usenet group',
+ 'last_updated' => now(),
+ ]);
+
+ DB::table('genres')->insert([
+ 'id' => 1,
+ 'title' => 'Test Genre',
+ 'type' => 3000,
+ 'disabled' => 0,
+ ]);
+ }
+}