From 96f0ade10e3e4398b3b93379d3aebf4f6d118e0d Mon Sep 17 00:00:00 2001 From: Daan Meijer Date: Fri, 9 Oct 2026 00:05:24 +0200 Subject: [PATCH] Rebuild: fresh AdonisJS 7 API app on Postgres The rebuild branch starts over (REBUILD.md): the API starter kit, with Postgres instead of SQLite (its own database, DB_SEARCH_PATH for a shared one in development) and the #lib alias. Phase 1's code is imported from the current app module by module. Co-Authored-By: Claude Opus 5.5 --- .adonisjs/client/manifest.d.ts | 5 - .adonisjs/server/controllers.ts | 3 - .adonisjs/server/routes.d.ts | 12 - .adonisjs/server/routes.json | 1 - .dockerignore | 11 - .env.example | 11 +- Dockerfile | 29 - adonisrc.ts | 8 +- app/controllers/newznab_controller.ts | 123 - app/controllers/releases_controller.ts | 65 - app/controllers/seed_controller.ts | 156 -- app/middleware/seed_node_middleware.ts | 26 - app/models/base_model.ts | 4 - app/models/file.ts | 41 - app/models/group.ts | 32 - app/services/ArchiveService.ts | 170 -- app/services/ArchiveWalkService.ts | 160 -- app/services/BackfillService.ts | 112 - app/services/BinaryFileService.ts | 144 - app/services/ContentService.ts | 321 --- app/services/DeferredService.ts | 264 -- app/services/FetchRangeService.ts | 118 - app/services/FileKeys.ts | 60 - app/services/FilePartitions.ts | 50 - app/services/FileStore.ts | 138 - app/services/GarbageFilter.ts | 217 -- app/services/IndexMaintenance.ts | 85 - app/services/IngestService.ts | 424 --- app/services/InspectionQueue.ts | 66 - app/services/NameService.ts | 128 - app/services/NntpService.ts | 141 - app/services/NzbImportService.ts | 229 -- app/services/Par2Service.ts | 133 - app/services/QueueService.ts | 65 - app/services/RedisService.ts | 25 - app/services/ReleaseCompleteness.ts | 35 - app/services/ReleaseMergeService.ts | 42 - app/services/RemoteContentService.ts | 168 -- app/services/SearchService.ts | 421 --- app/services/SeedFetchPool.ts | 132 - app/services/SeedOrchestrator.ts | 234 -- app/services/SeedService.ts | 152 -- app/services/SevenZipService.ts | 36 - app/services/SfvService.ts | 96 - app/services/Shutdown.ts | 45 - app/services/SplitService.ts | 146 - app/services/SpotService.ts | 493 ---- app/services/StagingService.ts | 252 -- bin/console.ts | 8 +- bin/server.ts | 2 +- bin/test.ts | 2 +- commands/ArchiveWalk.ts | 70 - commands/BodyQueuePrune.ts | 97 - commands/ContentBackfill.ts | 91 - commands/DbReindex.ts | 22 - commands/DeferredExpandRanges.ts | 32 - commands/FilesCleanNames.ts | 35 - commands/FilesMoveDetails.ts | 36 - commands/GarbageLearn.ts | 38 - commands/GcScheduler.ts | 424 --- commands/GroupAdd.ts | 79 - commands/GroupBackfill.ts | 52 - commands/GroupsTop.ts | 176 -- commands/IndexScheduler.ts | 144 - commands/InspectionFromRedis.ts | 56 - commands/ReleaseMergeSplit.ts | 21 - commands/ReleaseRebuild.ts | 165 -- commands/ReleaseRename.ts | 27 - commands/ReleaseStats.ts | 57 - commands/SeedNode.ts | 461 ---- commands/SeedPlan.ts | 47 - commands/SeedRange.ts | 126 - commands/SeedStatus.ts | 26 - commands/SeedToken.ts | 37 - commands/SpotPasswords.ts | 25 - commands/SpotPostprocess.ts | 55 - commands/SpotReimport.ts | 51 - commands/workers/Body.ts | 97 - commands/workers/Collection.ts | 75 - commands/workers/Deferred.ts | 63 - commands/workers/Fetch.ts | 118 - commands/workers/File.ts | 54 - commands/workers/Spots.ts | 76 - config/app.ts | 4 + config/bodyparser.ts | 5 - config/database.ts | 129 +- config/gc.ts | 91 - config/indexer.ts | 121 - config/nntp.ts | 15 - config/queue.ts | 8 - config/redis.ts | 12 - config/static.ts | 17 - .../1768620764700_create_groups_table.ts | 21 - .../1768620764710_create_files_table.ts | 24 - .../1768620764720_create_binaries_table.ts | 32 - .../1768620764730_create_parts_table.ts | 24 - ...68620764740_add_file_key_to_files_table.ts | 17 - .../1768620764750_create_releases_table.ts | 38 - .../1768620764760_add_par2_matching.ts | 33 - ...1768620764770_create_fetch_ranges_table.ts | 37 - .../1768620764780_add_archive_contents.ts | 25 - ...68620764790_create_part_conflicts_table.ts | 32 - .../1768620764800_add_md5_to_release_files.ts | 16 - ...810_exclude_legacy_conflicts_from_split.ts | 36 - .../1768620764820_add_crcs_to_parts.ts | 30 - .../1768620764830_add_content_names.ts | 40 - .../1768620764840_add_search_indexes.ts | 43 - .../1768620764850_add_release_totals.ts | 56 - .../1768620764860_add_release_completeness.ts | 107 - .../1768620764870_strict_par2_matching.ts | 28 - .../1768620764880_add_release_category.ts | 45 - .../1768620764890_ignore_identical_reposts.ts | 31 - ...768620764900_add_fetch_bodies_to_groups.ts | 24 - ...68620764910_readable_name_any_extension.ts | 23 - .../1768620764920_add_content_types.ts | 22 - .../1768620764930_add_nzb_import.ts | 24 - .../1768620764940_name_stem_subtitles.ts | 17 - .../1768620764950_name_stem_split_volumes.ts | 21 - ...1768620764960_name_stem_more_extensions.ts | 18 - ...68620764970_create_deferred_files_table.ts | 47 - ...8620764980_create_deferred_ranges_table.ts | 28 - .../1768620764990_create_spots_table.ts | 40 - .../1768620765000_partial_indexes.ts | 44 - .../migrations/1768620765010_spot_listings.ts | 46 - .../migrations/1768620765020_spot_details.ts | 24 - .../1768620765030_release_password_source.ts | 16 - .../1768620765040_pack_file_segments.ts | 63 - .../1768620765050_group_backfill.ts | 26 - ...620765060_search_text_and_index_cleanup.ts | 144 - ...1768620765070_readable_name_more_shapes.ts | 43 - .../1768620765080_create_sfv_entries.ts | 23 - .../1768620765090_files_name_index.ts | 15 - .../1768620765100_readable_name_generic.ts | 37 - .../1768620765110_create_staged_segments.ts | 24 - ...68620765120_staged_segments_counter_key.ts | 20 - .../1768620765130_release_extensions.ts | 109 - .../1768620765140_seed_orchestrator.ts | 47 - .../1768620765150_partition_files.ts | 77 - .../migrations/1768620765160_files_bigint.ts | 89 - .../1768620765170_release_contents_walked.ts | 14 - .../1768620765180_release_complete_faster.ts | 69 - ...8620765190_release_totals_per_statement.ts | 110 - .../1768620765200_seed_range_priority.ts | 14 - .../1768620765210_binary_part_count.ts | 21 - .../migrations/1768620765220_file_keys.ts | 69 - ...8620765230_binaries_awaiting_inspection.ts | 20 - .../1768620765240_binaries_gc_indexes.ts | 32 - ...620765250_release_completeness_narrower.ts | 94 - .../1768620765260_inspection_queue.ts | 23 - .../1768620765270_garbage_filter.ts | 64 - .../1768620765280_spot_listing_reads.ts | 23 - .../migrations/1768620765290_file_details.ts | 28 - database/schema.ts | 660 ----- database/seeders/group_seeder.ts | 11 - lib/aggregate/index.ts | 86 - lib/content/archive_names.ts | 40 - lib/content/index.ts | 7 - lib/content/nicovideo.ts | 76 - lib/content/probe.ts | 205 -- lib/content/sfv.ts | 29 - lib/content/sniff.ts | 81 - lib/garbage/index.ts | 52 - lib/ingest/segment.ts | 105 - lib/newznab/categories.ts | 40 - lib/newznab/index.ts | 5 - lib/newznab/xml.ts | 107 - lib/nntp/date_search.ts | 27 - lib/nntp/delimited_reader.ts | 76 - lib/nntp/group.ts | 23 - lib/nntp/nntp_js.ts | 39 - lib/nntp/xover_client.ts | 238 -- lib/nntp/xref.ts | 13 - lib/nzb/build.ts | 51 - lib/nzb/index.ts | 6 - lib/nzb/parse.ts | 72 - lib/nzb/segments.ts | 33 - lib/par2/index.ts | 5 - lib/par2/parse.ts | 74 - lib/rar/extract.ts | 48 - lib/rar/index.ts | 8 - lib/rar/parse.ts | 351 --- lib/rar/segment_reader.ts | 82 - lib/rar/walk.ts | 77 - lib/release/index.ts | 4 - lib/release/release_key.ts | 223 -- lib/seed/plan.ts | 27 - lib/seed/runner.ts | 89 - lib/seed/wire.ts | 73 - lib/sevenzip/index.ts | 164 -- lib/sevenzip/list.ts | 171 -- lib/split/index.ts | 4 - lib/split/instances.ts | 178 -- lib/spotnet/categories.ts | 333 --- lib/spotnet/header.ts | 162 -- lib/spotnet/index.ts | 9 - lib/spotnet/password.ts | 83 - lib/spotnet/xml.ts | 131 - lib/subject/file_key.ts | 47 - lib/subject/index.ts | 5 - lib/subject/parse.ts | 59 - lib/yenc/decode.ts | 76 - lib/yenc/errors.ts | 16 - lib/yenc/index.ts | 7 - lib/yenc/meta.ts | 140 - lib/yenc/yenc_file.ts | 86 - package-lock.json | 2387 +++++------------ package.json | 31 +- public/app.css | 844 ------ public/app.js | 559 ---- public/index.html | 118 - seed-node/README.md | 119 - seed-node/compose.yaml | 18 - seed-node/node.env.example | 20 - start/env.ts | 103 +- start/kernel.ts | 2 - start/routes.ts | 45 +- tests/bootstrap.ts | 8 +- tests/functional/backfill.spec.ts | 38 - tests/functional/binary_files.spec.ts | 86 - tests/functional/counter_grouping.spec.ts | 70 - tests/functional/deferred.spec.ts | 67 - tests/functional/fetch_ranges.spec.ts | 46 - tests/functional/file_keys.spec.ts | 41 - tests/functional/file_partitions.spec.ts | 51 - tests/functional/garbage.spec.ts | 190 -- tests/functional/ingest.spec.ts | 168 -- tests/functional/inspection_queue.spec.ts | 36 - tests/functional/names.spec.ts | 94 - tests/functional/newznab.spec.ts | 91 - tests/functional/nzb_import.spec.ts | 137 - tests/functional/par2_attach.spec.ts | 56 - tests/functional/random_names.spec.ts | 27 - tests/functional/release_complete.spec.ts | 94 - tests/functional/release_merge.spec.ts | 42 - tests/functional/search_text.spec.ts | 41 - tests/functional/seed.spec.ts | 87 - tests/functional/seed_api.spec.ts | 265 -- tests/functional/sfv.spec.ts | 59 - tests/functional/split.spec.ts | 84 - tests/functional/spot_listing.spec.ts | 193 -- tests/functional/staging.spec.ts | 70 - tests/functional/web_api.spec.ts | 145 - tests/functional/yenc_name.spec.ts | 40 - tests/unit/aggregate.spec.ts | 56 - tests/unit/content.spec.ts | 65 - tests/unit/date_search.spec.ts | 14 - tests/unit/delimited_reader.spec.ts | 52 - tests/unit/nicovideo.spec.ts | 46 - tests/unit/nntp_group.spec.ts | 18 - tests/unit/nzb.spec.ts | 67 - tests/unit/par2.spec.ts | 60 - tests/unit/probe.spec.ts | 86 - tests/unit/rar.spec.ts | 376 --- tests/unit/release.spec.ts | 169 -- tests/unit/seed_fetch_pool.spec.ts | 48 - tests/unit/seed_plan.spec.ts | 16 - tests/unit/sevenzip.spec.ts | 95 - tests/unit/split.spec.ts | 162 -- tests/unit/spotnet.spec.ts | 186 -- tests/unit/subject.spec.ts | 108 - tests/unit/xover_client.spec.ts | 106 - tests/unit/yenc.spec.ts | 192 -- tsconfig.json | 3 +- types/yencode.d.ts | 15 - 264 files changed, 777 insertions(+), 24132 deletions(-) delete mode 100644 .adonisjs/server/routes.json delete mode 100644 .dockerignore delete mode 100644 Dockerfile delete mode 100644 app/controllers/newznab_controller.ts delete mode 100644 app/controllers/releases_controller.ts delete mode 100644 app/controllers/seed_controller.ts delete mode 100644 app/middleware/seed_node_middleware.ts delete mode 100644 app/models/base_model.ts delete mode 100644 app/models/file.ts delete mode 100644 app/models/group.ts delete mode 100644 app/services/ArchiveService.ts delete mode 100644 app/services/ArchiveWalkService.ts delete mode 100644 app/services/BackfillService.ts delete mode 100644 app/services/BinaryFileService.ts delete mode 100644 app/services/ContentService.ts delete mode 100644 app/services/DeferredService.ts delete mode 100644 app/services/FetchRangeService.ts delete mode 100644 app/services/FileKeys.ts delete mode 100644 app/services/FilePartitions.ts delete mode 100644 app/services/FileStore.ts delete mode 100644 app/services/GarbageFilter.ts delete mode 100644 app/services/IndexMaintenance.ts delete mode 100644 app/services/IngestService.ts delete mode 100644 app/services/InspectionQueue.ts delete mode 100644 app/services/NameService.ts delete mode 100644 app/services/NntpService.ts delete mode 100644 app/services/NzbImportService.ts delete mode 100644 app/services/Par2Service.ts delete mode 100644 app/services/QueueService.ts delete mode 100644 app/services/RedisService.ts delete mode 100644 app/services/ReleaseCompleteness.ts delete mode 100644 app/services/ReleaseMergeService.ts delete mode 100644 app/services/RemoteContentService.ts delete mode 100644 app/services/SearchService.ts delete mode 100644 app/services/SeedFetchPool.ts delete mode 100644 app/services/SeedOrchestrator.ts delete mode 100644 app/services/SeedService.ts delete mode 100644 app/services/SevenZipService.ts delete mode 100644 app/services/SfvService.ts delete mode 100644 app/services/Shutdown.ts delete mode 100644 app/services/SplitService.ts delete mode 100644 app/services/SpotService.ts delete mode 100644 app/services/StagingService.ts delete mode 100644 commands/ArchiveWalk.ts delete mode 100644 commands/BodyQueuePrune.ts delete mode 100644 commands/ContentBackfill.ts delete mode 100644 commands/DbReindex.ts delete mode 100644 commands/DeferredExpandRanges.ts delete mode 100644 commands/FilesCleanNames.ts delete mode 100644 commands/FilesMoveDetails.ts delete mode 100644 commands/GarbageLearn.ts delete mode 100644 commands/GcScheduler.ts delete mode 100644 commands/GroupAdd.ts delete mode 100644 commands/GroupBackfill.ts delete mode 100644 commands/GroupsTop.ts delete mode 100644 commands/IndexScheduler.ts delete mode 100644 commands/InspectionFromRedis.ts delete mode 100644 commands/ReleaseMergeSplit.ts delete mode 100644 commands/ReleaseRebuild.ts delete mode 100644 commands/ReleaseRename.ts delete mode 100644 commands/ReleaseStats.ts delete mode 100644 commands/SeedNode.ts delete mode 100644 commands/SeedPlan.ts delete mode 100644 commands/SeedRange.ts delete mode 100644 commands/SeedStatus.ts delete mode 100644 commands/SeedToken.ts delete mode 100644 commands/SpotPasswords.ts delete mode 100644 commands/SpotPostprocess.ts delete mode 100644 commands/SpotReimport.ts delete mode 100644 commands/workers/Body.ts delete mode 100644 commands/workers/Collection.ts delete mode 100644 commands/workers/Deferred.ts delete mode 100644 commands/workers/Fetch.ts delete mode 100644 commands/workers/File.ts delete mode 100644 commands/workers/Spots.ts delete mode 100644 config/gc.ts delete mode 100644 config/indexer.ts delete mode 100644 config/nntp.ts delete mode 100644 config/queue.ts delete mode 100644 config/redis.ts delete mode 100644 config/static.ts delete mode 100644 database/migrations/1768620764700_create_groups_table.ts delete mode 100644 database/migrations/1768620764710_create_files_table.ts delete mode 100644 database/migrations/1768620764720_create_binaries_table.ts delete mode 100644 database/migrations/1768620764730_create_parts_table.ts delete mode 100644 database/migrations/1768620764740_add_file_key_to_files_table.ts delete mode 100644 database/migrations/1768620764750_create_releases_table.ts delete mode 100644 database/migrations/1768620764760_add_par2_matching.ts delete mode 100644 database/migrations/1768620764770_create_fetch_ranges_table.ts delete mode 100644 database/migrations/1768620764780_add_archive_contents.ts delete mode 100644 database/migrations/1768620764790_create_part_conflicts_table.ts delete mode 100644 database/migrations/1768620764800_add_md5_to_release_files.ts delete mode 100644 database/migrations/1768620764810_exclude_legacy_conflicts_from_split.ts delete mode 100644 database/migrations/1768620764820_add_crcs_to_parts.ts delete mode 100644 database/migrations/1768620764830_add_content_names.ts delete mode 100644 database/migrations/1768620764840_add_search_indexes.ts delete mode 100644 database/migrations/1768620764850_add_release_totals.ts delete mode 100644 database/migrations/1768620764860_add_release_completeness.ts delete mode 100644 database/migrations/1768620764870_strict_par2_matching.ts delete mode 100644 database/migrations/1768620764880_add_release_category.ts delete mode 100644 database/migrations/1768620764890_ignore_identical_reposts.ts delete mode 100644 database/migrations/1768620764900_add_fetch_bodies_to_groups.ts delete mode 100644 database/migrations/1768620764910_readable_name_any_extension.ts delete mode 100644 database/migrations/1768620764920_add_content_types.ts delete mode 100644 database/migrations/1768620764930_add_nzb_import.ts delete mode 100644 database/migrations/1768620764940_name_stem_subtitles.ts delete mode 100644 database/migrations/1768620764950_name_stem_split_volumes.ts delete mode 100644 database/migrations/1768620764960_name_stem_more_extensions.ts delete mode 100644 database/migrations/1768620764970_create_deferred_files_table.ts delete mode 100644 database/migrations/1768620764980_create_deferred_ranges_table.ts delete mode 100644 database/migrations/1768620764990_create_spots_table.ts delete mode 100644 database/migrations/1768620765000_partial_indexes.ts delete mode 100644 database/migrations/1768620765010_spot_listings.ts delete mode 100644 database/migrations/1768620765020_spot_details.ts delete mode 100644 database/migrations/1768620765030_release_password_source.ts delete mode 100644 database/migrations/1768620765040_pack_file_segments.ts delete mode 100644 database/migrations/1768620765050_group_backfill.ts delete mode 100644 database/migrations/1768620765060_search_text_and_index_cleanup.ts delete mode 100644 database/migrations/1768620765070_readable_name_more_shapes.ts delete mode 100644 database/migrations/1768620765080_create_sfv_entries.ts delete mode 100644 database/migrations/1768620765090_files_name_index.ts delete mode 100644 database/migrations/1768620765100_readable_name_generic.ts delete mode 100644 database/migrations/1768620765110_create_staged_segments.ts delete mode 100644 database/migrations/1768620765120_staged_segments_counter_key.ts delete mode 100644 database/migrations/1768620765130_release_extensions.ts delete mode 100644 database/migrations/1768620765140_seed_orchestrator.ts delete mode 100644 database/migrations/1768620765150_partition_files.ts delete mode 100644 database/migrations/1768620765160_files_bigint.ts delete mode 100644 database/migrations/1768620765170_release_contents_walked.ts delete mode 100644 database/migrations/1768620765180_release_complete_faster.ts delete mode 100644 database/migrations/1768620765190_release_totals_per_statement.ts delete mode 100644 database/migrations/1768620765200_seed_range_priority.ts delete mode 100644 database/migrations/1768620765210_binary_part_count.ts delete mode 100644 database/migrations/1768620765220_file_keys.ts delete mode 100644 database/migrations/1768620765230_binaries_awaiting_inspection.ts delete mode 100644 database/migrations/1768620765240_binaries_gc_indexes.ts delete mode 100644 database/migrations/1768620765250_release_completeness_narrower.ts delete mode 100644 database/migrations/1768620765260_inspection_queue.ts delete mode 100644 database/migrations/1768620765270_garbage_filter.ts delete mode 100644 database/migrations/1768620765280_spot_listing_reads.ts delete mode 100644 database/migrations/1768620765290_file_details.ts delete mode 100644 database/seeders/group_seeder.ts delete mode 100644 lib/aggregate/index.ts delete mode 100644 lib/content/archive_names.ts delete mode 100644 lib/content/index.ts delete mode 100644 lib/content/nicovideo.ts delete mode 100644 lib/content/probe.ts delete mode 100644 lib/content/sfv.ts delete mode 100644 lib/content/sniff.ts delete mode 100644 lib/garbage/index.ts delete mode 100644 lib/ingest/segment.ts delete mode 100644 lib/newznab/categories.ts delete mode 100644 lib/newznab/index.ts delete mode 100644 lib/newznab/xml.ts delete mode 100644 lib/nntp/date_search.ts delete mode 100644 lib/nntp/delimited_reader.ts delete mode 100644 lib/nntp/group.ts delete mode 100644 lib/nntp/nntp_js.ts delete mode 100644 lib/nntp/xover_client.ts delete mode 100644 lib/nntp/xref.ts delete mode 100644 lib/nzb/build.ts delete mode 100644 lib/nzb/index.ts delete mode 100644 lib/nzb/parse.ts delete mode 100644 lib/nzb/segments.ts delete mode 100644 lib/par2/index.ts delete mode 100644 lib/par2/parse.ts delete mode 100644 lib/rar/extract.ts delete mode 100644 lib/rar/index.ts delete mode 100644 lib/rar/parse.ts delete mode 100644 lib/rar/segment_reader.ts delete mode 100644 lib/rar/walk.ts delete mode 100644 lib/release/index.ts delete mode 100644 lib/release/release_key.ts delete mode 100644 lib/seed/plan.ts delete mode 100644 lib/seed/runner.ts delete mode 100644 lib/seed/wire.ts delete mode 100644 lib/sevenzip/index.ts delete mode 100644 lib/sevenzip/list.ts delete mode 100644 lib/split/index.ts delete mode 100644 lib/split/instances.ts delete mode 100644 lib/spotnet/categories.ts delete mode 100644 lib/spotnet/header.ts delete mode 100644 lib/spotnet/index.ts delete mode 100644 lib/spotnet/password.ts delete mode 100644 lib/spotnet/xml.ts delete mode 100644 lib/subject/file_key.ts delete mode 100644 lib/subject/index.ts delete mode 100644 lib/subject/parse.ts delete mode 100644 lib/yenc/decode.ts delete mode 100644 lib/yenc/errors.ts delete mode 100644 lib/yenc/index.ts delete mode 100644 lib/yenc/meta.ts delete mode 100644 lib/yenc/yenc_file.ts delete mode 100644 public/app.css delete mode 100644 public/app.js delete mode 100644 public/index.html delete mode 100644 seed-node/README.md delete mode 100644 seed-node/compose.yaml delete mode 100644 seed-node/node.env.example delete mode 100644 tests/functional/backfill.spec.ts delete mode 100644 tests/functional/binary_files.spec.ts delete mode 100644 tests/functional/counter_grouping.spec.ts delete mode 100644 tests/functional/deferred.spec.ts delete mode 100644 tests/functional/fetch_ranges.spec.ts delete mode 100644 tests/functional/file_keys.spec.ts delete mode 100644 tests/functional/file_partitions.spec.ts delete mode 100644 tests/functional/garbage.spec.ts delete mode 100644 tests/functional/ingest.spec.ts delete mode 100644 tests/functional/inspection_queue.spec.ts delete mode 100644 tests/functional/names.spec.ts delete mode 100644 tests/functional/newznab.spec.ts delete mode 100644 tests/functional/nzb_import.spec.ts delete mode 100644 tests/functional/par2_attach.spec.ts delete mode 100644 tests/functional/random_names.spec.ts delete mode 100644 tests/functional/release_complete.spec.ts delete mode 100644 tests/functional/release_merge.spec.ts delete mode 100644 tests/functional/search_text.spec.ts delete mode 100644 tests/functional/seed.spec.ts delete mode 100644 tests/functional/seed_api.spec.ts delete mode 100644 tests/functional/sfv.spec.ts delete mode 100644 tests/functional/split.spec.ts delete mode 100644 tests/functional/spot_listing.spec.ts delete mode 100644 tests/functional/staging.spec.ts delete mode 100644 tests/functional/web_api.spec.ts delete mode 100644 tests/functional/yenc_name.spec.ts delete mode 100644 tests/unit/aggregate.spec.ts delete mode 100644 tests/unit/content.spec.ts delete mode 100644 tests/unit/date_search.spec.ts delete mode 100644 tests/unit/delimited_reader.spec.ts delete mode 100644 tests/unit/nicovideo.spec.ts delete mode 100644 tests/unit/nntp_group.spec.ts delete mode 100644 tests/unit/nzb.spec.ts delete mode 100644 tests/unit/par2.spec.ts delete mode 100644 tests/unit/probe.spec.ts delete mode 100644 tests/unit/rar.spec.ts delete mode 100644 tests/unit/release.spec.ts delete mode 100644 tests/unit/seed_fetch_pool.spec.ts delete mode 100644 tests/unit/seed_plan.spec.ts delete mode 100644 tests/unit/sevenzip.spec.ts delete mode 100644 tests/unit/split.spec.ts delete mode 100644 tests/unit/spotnet.spec.ts delete mode 100644 tests/unit/subject.spec.ts delete mode 100644 tests/unit/xover_client.spec.ts delete mode 100644 tests/unit/yenc.spec.ts delete mode 100644 types/yencode.d.ts diff --git a/.adonisjs/client/manifest.d.ts b/.adonisjs/client/manifest.d.ts index 852b3d3..9fc3356 100644 --- a/.adonisjs/client/manifest.d.ts +++ b/.adonisjs/client/manifest.d.ts @@ -5,10 +5,5 @@ /// /// -/// /// -/// /// -/// -/// -/// diff --git a/.adonisjs/server/controllers.ts b/.adonisjs/server/controllers.ts index 34f4bc9..202eb41 100644 --- a/.adonisjs/server/controllers.ts +++ b/.adonisjs/server/controllers.ts @@ -6,8 +6,5 @@ export const controllers = { AccessTokens: () => import('#controllers/access_tokens_controller'), NewAccount: () => import('#controllers/new_account_controller'), - Newznab: () => import('#controllers/newznab_controller'), Profile: () => import('#controllers/profile_controller'), - Releases: () => import('#controllers/releases_controller'), - Seed: () => import('#controllers/seed_controller'), } diff --git a/.adonisjs/server/routes.d.ts b/.adonisjs/server/routes.d.ts index 37d0ca6..9aa155f 100644 --- a/.adonisjs/server/routes.d.ts +++ b/.adonisjs/server/routes.d.ts @@ -4,27 +4,15 @@ type ParamValue = string | number | bigint | boolean export type ScannedRoutes = { ALL: { - 'releases.index': { paramsTuple?: []; params?: {} } - 'releases.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } - 'releases.nzb': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } - 'releases.stats': { paramsTuple?: []; params?: {} } 'auth.new_account.store': { paramsTuple?: []; params?: {} } 'auth.access_tokens.store': { paramsTuple?: []; params?: {} } 'profile.profile.show': { paramsTuple?: []; params?: {} } 'profile.access_tokens.destroy': { paramsTuple?: []; params?: {} } } GET: { - 'releases.index': { paramsTuple?: []; params?: {} } - 'releases.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } - 'releases.nzb': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } - 'releases.stats': { paramsTuple?: []; params?: {} } 'profile.profile.show': { paramsTuple?: []; params?: {} } } HEAD: { - 'releases.index': { paramsTuple?: []; params?: {} } - 'releases.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } - 'releases.nzb': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } - 'releases.stats': { paramsTuple?: []; params?: {} } 'profile.profile.show': { paramsTuple?: []; params?: {} } } POST: { diff --git a/.adonisjs/server/routes.json b/.adonisjs/server/routes.json deleted file mode 100644 index b0765b6..0000000 --- a/.adonisjs/server/routes.json +++ /dev/null @@ -1 +0,0 @@ -{"root":[{"domain":"root","pattern":"/api/v1/releases","matchers":{},"tokens":[{"old":"/api/v1/releases","type":0,"val":"api","end":""},{"old":"/api/v1/releases","type":0,"val":"v1","end":""},{"old":"/api/v1/releases","type":0,"val":"releases","end":""}],"meta":{"params":[]},"name":"releases.index","handler":{"method":"index","reference":[null,"index"],"importExpression":"()=>import('#controllers/releases_controller')","name":"ReleasesController"},"methods":["GET","HEAD"],"middleware":{}},{"domain":"root","pattern":"/api/v1/releases/:id","matchers":{"id":{"match":{}}},"tokens":[{"old":"/api/v1/releases/:id","type":0,"val":"api","end":""},{"old":"/api/v1/releases/:id","type":0,"val":"v1","end":""},{"old":"/api/v1/releases/:id","type":0,"val":"releases","end":""},{"old":"/api/v1/releases/:id","type":1,"val":"id","end":"","matcher":{}}],"meta":{"params":["id"]},"name":"releases.show","handler":{"method":"show","reference":[null,"show"],"importExpression":"()=>import('#controllers/releases_controller')","name":"ReleasesController"},"methods":["GET","HEAD"],"middleware":{}},{"domain":"root","pattern":"/api/v1/releases/:id/nzb","matchers":{"id":{"match":{}}},"tokens":[{"old":"/api/v1/releases/:id/nzb","type":0,"val":"api","end":""},{"old":"/api/v1/releases/:id/nzb","type":0,"val":"v1","end":""},{"old":"/api/v1/releases/:id/nzb","type":0,"val":"releases","end":""},{"old":"/api/v1/releases/:id/nzb","type":1,"val":"id","end":"","matcher":{}},{"old":"/api/v1/releases/:id/nzb","type":0,"val":"nzb","end":""}],"meta":{"params":["id"]},"name":"releases.nzb","handler":{"method":"nzb","reference":[null,"nzb"],"importExpression":"()=>import('#controllers/releases_controller')","name":"ReleasesController"},"methods":["GET","HEAD"],"middleware":{}},{"domain":"root","pattern":"/api/v1/stats","matchers":{},"tokens":[{"old":"/api/v1/stats","type":0,"val":"api","end":""},{"old":"/api/v1/stats","type":0,"val":"v1","end":""},{"old":"/api/v1/stats","type":0,"val":"stats","end":""}],"meta":{"params":[]},"name":"releases.stats","handler":{"method":"stats","reference":[null,"stats"],"importExpression":"()=>import('#controllers/releases_controller')","name":"ReleasesController"},"methods":["GET","HEAD"],"middleware":{}},{"domain":"root","pattern":"/api/v1/auth/signup","matchers":{},"tokens":[{"old":"/api/v1/auth/signup","type":0,"val":"api","end":""},{"old":"/api/v1/auth/signup","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/signup","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/signup","type":0,"val":"signup","end":""}],"meta":{"params":[]},"name":"auth.new_account.store","handler":{"method":"store","reference":[null,"store"],"importExpression":"()=>import('#controllers/new_account_controller')","name":"NewAccount"},"methods":["POST"],"middleware":{}},{"domain":"root","pattern":"/api/v1/auth/login","matchers":{},"tokens":[{"old":"/api/v1/auth/login","type":0,"val":"api","end":""},{"old":"/api/v1/auth/login","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/login","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/login","type":0,"val":"login","end":""}],"meta":{"params":[]},"name":"auth.access_tokens.store","handler":{"method":"store","reference":[null,"store"],"importExpression":"()=>import('#controllers/access_tokens_controller')","name":"AccessTokens"},"methods":["POST"],"middleware":{}},{"domain":"root","pattern":"/api/v1/account/profile","matchers":{},"tokens":[{"old":"/api/v1/account/profile","type":0,"val":"api","end":""},{"old":"/api/v1/account/profile","type":0,"val":"v1","end":""},{"old":"/api/v1/account/profile","type":0,"val":"account","end":""},{"old":"/api/v1/account/profile","type":0,"val":"profile","end":""}],"meta":{"params":[]},"name":"profile.profile.show","handler":{"method":"show","reference":[null,"show"],"importExpression":"()=>import('#controllers/profile_controller')","name":"Profile"},"methods":["GET","HEAD"],"middleware":{}},{"domain":"root","pattern":"/api/v1/account/logout","matchers":{},"tokens":[{"old":"/api/v1/account/logout","type":0,"val":"api","end":""},{"old":"/api/v1/account/logout","type":0,"val":"v1","end":""},{"old":"/api/v1/account/logout","type":0,"val":"account","end":""},{"old":"/api/v1/account/logout","type":0,"val":"logout","end":""}],"meta":{"params":[]},"name":"profile.access_tokens.destroy","handler":{"method":"destroy","reference":[null,"destroy"],"importExpression":"()=>import('#controllers/access_tokens_controller')","name":"AccessTokens"},"methods":["POST"],"middleware":{}}]} \ No newline at end of file diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 864de83..0000000 --- a/.dockerignore +++ /dev/null @@ -1,11 +0,0 @@ -node_modules -build -tmp -debug -.git -.idea -.vscode -.env -.env.* -!.env.example -*.log diff --git a/.env.example b/.env.example index f153372..ee3ebe3 100644 --- a/.env.example +++ b/.env.example @@ -12,9 +12,14 @@ APP_URL=http://${HOST}:${PORT} # Session SESSION_DRIVER=cookie +# Database (the rebuild's own) +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_USER=usenet_indexer +DB_PASSWORD= +DB_DATABASE=usenet_rebuild + #-------------------------------------------------------------------- # CORS (configure allowed origins for API access) #-------------------------------------------------------------------- -# CORS_ORIGIN=http://localhost:5173,http://localhost:3000 -# Newznab API key (/api?apikey=...); leave empty to allow access without a key -NEWZNAB_API_KEY= +# CORS_ORIGIN=http://localhost:5173,http://localhost:3000 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 3b1dab0..0000000 --- a/Dockerfile +++ /dev/null @@ -1,29 +0,0 @@ -# syntax=docker/dockerfile:1 - -# Node is pinned: on 24.21, jsonschema 1.5.0 (used by Ace to validate commands) -# throws "Invalid URL" on $ref, so no ace command starts. See LOG.md. -ARG NODE_VERSION=24.16 - -# Build stage: dev dependencies, TypeScript compile, native addons. -# The full node image ships python3/make/g++, needed to compile yencode. -FROM node:${NODE_VERSION}-bookworm AS build -WORKDIR /app -COPY package.json package-lock.json ./ -RUN npm ci -COPY . . -RUN node ace build \ - && cd build \ - && npm ci --omit=dev - -# Runtime stage: compiled app with production dependencies only. -FROM node:${NODE_VERSION}-bookworm-slim -ENV NODE_ENV=production -# bsdtar (libarchive) unpacks compressed RAR entries, e.g. NZBs posted inside RARs. -RUN apt-get update \ - && apt-get install -y --no-install-recommends libarchive-tools \ - && rm -rf /var/lib/apt/lists/* -WORKDIR /app -COPY --from=build /app/build ./ -USER node -# Every service picks its own command, e.g. `node ace worker:fetch`. -CMD ["node", "ace", "list"] diff --git a/adonisrc.ts b/adonisrc.ts index 832e211..71a369f 100644 --- a/adonisrc.ts +++ b/adonisrc.ts @@ -1,4 +1,4 @@ -import { indexEntities } from '@adonisjs/core' +import { indexEntities } from '@adonisjs/core/generators' import { defineConfig } from '@adonisjs/core/app' import { generateRegistry } from '@tuyau/core/hooks' @@ -53,7 +53,6 @@ export default defineConfig({ () => import('@adonisjs/cors/cors_provider'), () => import('@adonisjs/auth/auth_provider'), () => import('#providers/api_provider'), - () => import('@adonisjs/static/static_provider') ], /* @@ -104,10 +103,7 @@ export default defineConfig({ | the production build. | */ - metaFiles: [{ - pattern: 'public/**', - reloadServer: false, - }], + metaFiles: [], hooks: { init: [ diff --git a/app/controllers/newznab_controller.ts b/app/controllers/newznab_controller.ts deleted file mode 100644 index 7ee24d6..0000000 --- a/app/controllers/newznab_controller.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { HttpContext } from '@adonisjs/core/http' -import env from '#start/env' -import SearchService from '#services/SearchService' -import { capsXml, errorXml, expandCategories, resultsXml, type NewznabServer } from '#lib/newznab/index' - -const LIMITS = { max: 100, default: 50 } - -/** Search functions and the category each defaults to when `cat` is absent. */ -const SEARCH_FUNCTIONS: Record = { - 'search': undefined, - 'tvsearch': 5000, - 'tv-search': 5000, - 'movie': 2000, - 'movie-search': 2000, - 'music': 3000, - 'audio': 3000, - 'book': 7000, -} - -/** The configured key, if any (a Secret from .env, or a plain string set at runtime). */ -function configuredApiKey(): string | undefined { - const value: any = env.get('NEWZNAB_API_KEY') - const key = typeof value === 'string' ? value : value?.release() - return key || undefined -} - -function int(value: unknown): number | undefined { - return typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : undefined -} - -/** - * Newznab API (CONCEPT.md D23) for Prowlarr, Sonarr, Radarr and the like: - * GET /api?t=caps|search|tvsearch|movie|music|book|get&apikey=… - */ -export default class NewznabController { - async handle({ request, response }: HttpContext) { - const qs = request.qs() - const t = String(qs.t ?? '') - const server = this.server(request) - - const xml = (body: string, type = 'application/xml') => { - response.header('Content-Type', `${type}; charset=utf-8`) - return response.send(body) - } - - const apiKey = configuredApiKey() - if (apiKey && qs.apikey !== apiKey) return xml(errorXml(100, 'Incorrect user credentials')) - - if (t === 'caps') return xml(capsXml(server, LIMITS)) - - if (t === 'get') { - const id = int(qs.id) - if (id === undefined) return xml(errorXml(200, 'Missing parameter (id)')) - const nzb = await SearchService.nzb(id) - if (!nzb) return xml(errorXml(300, 'No such item')) - const filename = `${nzb.name.replace(/[^\w.\-()[\] ]+/g, '_').slice(0, 180) || 'release'}.nzb` - response.header('Content-Disposition', `attachment; filename="${filename}"`) - return xml(nzb.xml, 'application/x-nzb') - } - - if (!(t in SEARCH_FUNCTIONS)) return xml(errorXml(202, `No such function (${t || 'none'})`)) - - const requested = String(qs.cat ?? '') - .split(',') - .map((c) => int(c.trim())) - .filter((c): c is number => c !== undefined) - const fallback = SEARCH_FUNCTIONS[t] - const categories = expandCategories(requested.length ? requested : fallback ? [fallback] : []) - const limit = Math.min(int(qs.limit) ?? LIMITS.default, LIMITS.max) - const offset = int(qs.offset) ?? 0 - - // ID-only lookups (tvdbid, imdbid, …) are not supported: caps says so, but - // answer with an empty result rather than everything. - const q = typeof qs.q === 'string' ? qs.q.trim() : '' - const idOnly = !q && ['tvdbid', 'tvmazeid', 'rid', 'imdbid', 'tmdbid'].some((p) => qs[p]) - - const result = idOnly - ? { total: 0, releases: [] } - : await SearchService.search({ - q, - categories, - readable: true, - notIncomplete: true, - hideContainers: true, - maxAgeDays: int(qs.maxage), - season: int(qs.season), - episode: int(qs.season) !== undefined ? int(qs.ep) : undefined, - sort: 'newest', - perPage: limit, - offset, - }) - - const items = result.releases.map((r: any) => { - const tv = /(?:^|[._ -])S(\d{1,2})E(\d{1,3})/i.exec(r.name) - return { - id: r.id, - title: r.name, - category: r.category ?? 8010, - size: r.size, - files: r.fileCount, - postedAt: r.postedAt ? new Date(r.postedAt) : null, - poster: r.poster, - groups: r.groups ?? [], - // 1 only when a password is needed that we can't give; a known one is in our NZB. - password: r.passwordState === 'missing', - description: r.spotDescription ?? undefined, - season: tv ? `S${tv[1].padStart(2, '0')}` : undefined, - episode: tv ? `E${tv[2].padStart(2, '0')}` : undefined, - } - }) - return xml(resultsXml(server, items, offset, result.total), 'application/rss+xml') - } - - private server(request: HttpContext['request']): NewznabServer { - const proto = request.header('x-forwarded-proto') ?? 'http' - const host = request.header('x-forwarded-host') ?? request.header('host') ?? 'localhost' - return { - title: 'Usenet Indexer', - baseUrl: `${proto}://${host}`, - apiKey: configuredApiKey(), - } - } -} diff --git a/app/controllers/releases_controller.ts b/app/controllers/releases_controller.ts deleted file mode 100644 index d2fdc97..0000000 --- a/app/controllers/releases_controller.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { HttpContext } from '@adonisjs/core/http' -import SearchService, { type SortOrder } from '#services/SearchService' - -const flag = (value: unknown) => value === '1' || value === 'true' || value === true - -/** A size in MB from the query string, as bytes. */ -function megabytes(value: unknown): number | undefined { - const mb = Number(value) - return value !== undefined && value !== '' && Number.isFinite(mb) && mb >= 0 ? Math.round(mb * 1024 * 1024) : undefined -} - -export default class ReleasesController { - /** GET /api/v1/releases?q=&readable=1&complete=1&hide_encrypted=1&min_size=&max_size=(MB)&ext=mkv,flac&source=&poster=&sort=&page=&per_page= */ - async index({ request }: HttpContext) { - const qs = request.qs() - return SearchService.search({ - q: typeof qs.q === 'string' ? qs.q : undefined, - readable: flag(qs.readable), - complete: flag(qs.complete), - hideEncrypted: flag(qs.hide_encrypted), - includePar2Only: flag(qs.par2_only), - source: typeof qs.source === 'string' && qs.source ? qs.source : undefined, - poster: typeof qs.poster === 'string' && qs.poster ? qs.poster : undefined, - minSize: megabytes(qs.min_size), - maxSize: megabytes(qs.max_size), - extensions: typeof qs.ext === 'string' && qs.ext ? qs.ext.split(/[\s,]+/) : undefined, - sort: qs.sort as SortOrder | undefined, - page: Number(qs.page) || 1, - perPage: Number(qs.per_page) || 50, - // The page shows "1,000+"; counting further made filter clicks take seconds. - countLimit: 1000, - }) - } - - /** GET /api/v1/releases/:id */ - async show({ params, response }: HttpContext) { - const release = await SearchService.release(Number(params.id)) - return release ?? response.notFound({ error: 'Release not found' }) - } - - /** GET /api/v1/releases/:id/nzb */ - async nzb({ params, response }: HttpContext) { - const nzb = await SearchService.nzb(Number(params.id)) - if (!nzb) return response.notFound({ error: 'Release not found' }) - const filename = `${nzb.name.replace(/[^\w.\-()[\] ]+/g, '_').slice(0, 180) || 'release'}.nzb` - response.header('Content-Type', 'application/x-nzb') - response.header('Content-Disposition', `attachment; filename="${filename}"`) - return response.send(nzb.xml) - } - - /** GET /api/v1/releases/:id/image: the image of the release's spot, read from Usenet. */ - async image({ params, response }: HttpContext) { - const image = await SearchService.spotImage(Number(params.id)) - if (!image) return response.notFound({ error: 'No image' }) - response.header('Content-Type', image.type) - // A spot's image never changes. - response.header('Cache-Control', 'public, max-age=604800, immutable') - return response.send(image.data) - } - - /** GET /api/v1/stats */ - async stats() { - return SearchService.stats() - } -} diff --git a/app/controllers/seed_controller.ts b/app/controllers/seed_controller.ts deleted file mode 100644 index ee79f61..0000000 --- a/app/controllers/seed_controller.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { HttpContext } from '@adonisjs/core/http' -import SeedOrchestrator, { LeaseLost } from '#services/SeedOrchestrator' -import SeedFetchPool from '#services/SeedFetchPool' -import RemoteContentService, { ArticleRelayOff, JobLost } from '#services/RemoteContentService' -import type { SeedBatch } from '#lib/seed/wire' - -/** - * The seed API (CONCEPT.md D38). Nodes make outgoing HTTPS requests only: - * lease a range, post batches of what they found, then finish or release - * the range. Bodies may be gzip-compressed (Content-Encoding: gzip). - */ -export default class SeedController { - /** POST /api/v1/seed/lease → 200 { range } or 204 when there's nothing to do. */ - async lease({ seedNode, response }: HttpContext) { - const range = await SeedOrchestrator.lease(seedNode.id) - return range ? { range } : response.noContent() - } - - /** POST /api/v1/seed/ranges/:id/batches with a SeedBatch. */ - async batch({ seedNode, params, request, response }: HttpContext) { - const batch = request.body() as SeedBatch - if (!Number.isSafeInteger(batch?.next) || !Array.isArray(batch.files) || !Array.isArray(batch.fragments)) { - return response.badRequest({ error: 'Expected { next, headers, files[], fragments[] }' }) - } - return this.leased(response, async () => { - const stored = await SeedOrchestrator.batch(seedNode.id, Number(params.id), { - ...batch, - headers: Number(batch.headers) || 0, - obfuscated: Number(batch.obfuscated) || 0, - deferred: Number(batch.deferred) || 0, - }) - return { stored } - }) - } - - /** - * GET /api/v1/seed/ranges/:id/chunk?from=&to= → the overviews of those - * articles as a zlib/gzip stream (relay nodes fetch through the indexer). - */ - async chunk({ seedNode, params, request, response }: HttpContext) { - if (SeedFetchPool.size === 0) return response.serviceUnavailable({ error: 'Relay is off (SEED_NNTP_CONNECTIONS=0); fetch directly' }) - const from = Number(request.input('from')) - const to = Number(request.input('to')) - if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to)) return response.badRequest({ error: 'Expected ?from=&to=' }) - return this.leased(response, async () => { - try { - const raw = await SeedOrchestrator.chunk(seedNode.id, Number(params.id), from, to) - response.header('content-type', 'application/octet-stream') - return response.send(raw) - } catch (error) { - if (error instanceof RangeError) return response.badRequest({ error: error.message }) - throw error - } - }) - } - - /** POST /api/v1/seed/ranges/:id/done */ - async done({ seedNode, params, response }: HttpContext) { - return this.leased(response, async () => { - await SeedOrchestrator.finish(seedNode.id, Number(params.id)) - return { ok: true } - }) - } - - /** POST /api/v1/seed/ranges/:id/release { error?, uncounted? } */ - async release({ seedNode, params, request, response }: HttpContext) { - const error = request.input('error') - const uncounted = request.input('uncounted') === true - return this.leased(response, async () => { - await SeedOrchestrator.release(seedNode.id, Number(params.id), typeof error === 'string' ? error : undefined, uncounted) - return { ok: true } - }) - } - - /** - * POST /api/v1/seed/inspect/lease { max } → 200 { jobs } or 204: content - * inspection and archive walks for the node (CONCEPT.md D41). - */ - async inspectLease({ seedNode, request, response }: HttpContext) { - const max = Math.min(10, Math.max(1, Number(request.input('max')) || 1)) - const jobs = await RemoteContentService.lease(seedNode.id, max) - return jobs.length > 0 ? { jobs } : response.noContent() - } - - /** - * POST /api/v1/seed/inspect/done { queue, jobId, token, result } where - * result is the probe as a JSON string (the body parser would turn the - * empty strings inside it into nulls). - */ - async inspectDone({ request, response }: HttpContext) { - const { queue, jobId, token, result } = request.only(['queue', 'jobId', 'token', 'result']) - if (!RemoteContentService.isQueue(queue) || typeof jobId !== 'string' || typeof token !== 'string' || typeof result !== 'string') { - return response.badRequest({ error: 'Expected { queue, jobId, token, result: JSON string }' }) - } - return this.job(response, () => RemoteContentService.finish(queue, jobId, token, JSON.parse(result))) - } - - /** POST /api/v1/seed/inspect/failed { queue, jobId, token, error } */ - async inspectFailed({ request, response }: HttpContext) { - const { queue, jobId, token, error } = request.only(['queue', 'jobId', 'token', 'error']) - if (!RemoteContentService.isQueue(queue) || typeof jobId !== 'string' || typeof token !== 'string') { - return response.badRequest({ error: 'Expected { queue, jobId, token, error }' }) - } - return this.job(response, () => RemoteContentService.fail(queue, jobId, token, String(error ?? 'unknown error'))) - } - - /** POST /api/v1/seed/inspect/release { queue, jobId, token }: a stopping node gives a job back. */ - async inspectRelease({ request, response }: HttpContext) { - const { queue, jobId, token } = request.only(['queue', 'jobId', 'token']) - if (!RemoteContentService.isQueue(queue) || typeof jobId !== 'string' || typeof token !== 'string') { - return response.badRequest({ error: 'Expected { queue, jobId, token }' }) - } - return this.job(response, () => RemoteContentService.release(queue, jobId, token)) - } - - /** GET /api/v1/seed/articles?id= → the raw article body; 410 when the server doesn't have it. */ - async article({ request, response }: HttpContext) { - const id = request.input('id') - if (typeof id !== 'string' || id.length === 0 || id.length > 250) return response.badRequest({ error: 'Expected ?id=' }) - try { - const body = await RemoteContentService.article(id) - if (!body) return response.gone({ error: 'No such article' }) - response.header('content-type', 'application/octet-stream') - return response.send(body) - } catch (error: any) { - if (error instanceof ArticleRelayOff) return response.serviceUnavailable({ error: error.message }) - return response.badGateway({ error: `NNTP: ${error.message}` }) - } - } - - /** GET /api/v1/seed/status */ - async status() { - return SeedOrchestrator.status() - } - - /** 409 when the job's lock is gone (it was given to another node meanwhile). */ - private async job(response: HttpContext['response'], action: () => Promise) { - try { - await action() - return { ok: true } - } catch (error) { - if (error instanceof JobLost) return response.conflict({ error: error.message }) - throw error - } - } - - /** 409 when the range was given to another node meanwhile; the node should drop it. */ - private async leased(response: HttpContext['response'], action: () => Promise) { - try { - return await action() - } catch (error) { - if (error instanceof LeaseLost) return response.conflict({ error: error.message }) - throw error - } - } -} diff --git a/app/middleware/seed_node_middleware.ts b/app/middleware/seed_node_middleware.ts deleted file mode 100644 index b8865d0..0000000 --- a/app/middleware/seed_node_middleware.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { HttpContext } from '@adonisjs/core/http' -import type { NextFn } from '@adonisjs/core/types/http' -import SeedOrchestrator from '#services/SeedOrchestrator' - -declare module '@adonisjs/core/http' { - interface HttpContext { - /** The seeding node that made this request (CONCEPT.md D38). */ - seedNode: { id: number; name: string } - } -} - -/** - * Seed API requests carry a node's bearer token (`node ace seed:token - * `); anything else gets 401. The seed API is the only part of the app - * reachable from the internet (traefik routes only /api/v1/seed there). - */ -export default class SeedNodeMiddleware { - async handle(ctx: HttpContext, next: NextFn) { - const header = ctx.request.header('authorization') ?? '' - const token = header.startsWith('Bearer ') ? header.slice(7).trim() : '' - const node = token ? await SeedOrchestrator.nodeForToken(token) : null - if (!node) return ctx.response.unauthorized({ error: 'Unknown or disabled seed node token' }) - ctx.seedNode = node - return next() - } -} diff --git a/app/models/base_model.ts b/app/models/base_model.ts deleted file mode 100644 index 44cf9ca..0000000 --- a/app/models/base_model.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { BaseModel as AdonisBaseModel } from '@adonisjs/lucid/orm' - -export default class BaseModel extends AdonisBaseModel { -} diff --git a/app/models/file.ts b/app/models/file.ts deleted file mode 100644 index a82f5ed..0000000 --- a/app/models/file.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { DateTime } from 'luxon' -import { column } from '@adonisjs/lucid/orm' -import BaseModel from '#models/base_model' -import { loadedSegments, storedSegments } from '#services/FileStore' -import type { Segment } from '#lib/nzb/index' - -export default class File extends BaseModel { - @column({ isPrimary: true }) - declare id: number - - @column({ columnName: 'file_key' }) - declare fileKey: string | null - - @column() - declare filename: string - - @column() - declare poster: string - - @column() - declare date: number - - @column() - declare parts: number - - /** Stored compressed (see FileStore); the model sees the list. */ - @column({ prepare: (value: Segment[]) => storedSegments(value), consume: (value: Buffer | null) => loadedSegments(value) }) - declare segments: Segment[] - - @column({ columnName: 'first_message_id' }) - declare firstMessageId: string | null - - @column() - declare groups: string[] - - @column.dateTime({ autoCreate: true }) - declare createdAt: DateTime - - @column.dateTime({ autoCreate: true, autoUpdate: true }) - declare updatedAt: DateTime -} diff --git a/app/models/group.ts b/app/models/group.ts deleted file mode 100644 index b9ecd05..0000000 --- a/app/models/group.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { DateTime } from 'luxon' -import { column } from '@adonisjs/lucid/orm' -import BaseModel from '#models/base_model' - -export default class Group extends BaseModel { - @column({ isPrimary: true }) - declare id: number - - @column() - declare name: string - - @column() - declare active: boolean - - /** Every article up to here was fetched successfully (see FetchRangeService). */ - @column({ columnName: 'last_indexed_id' }) - declare lastIndexedId: bigint - - /** Download bodies of obfuscated articles to identify them (bandwidth-heavy). */ - @column({ columnName: 'fetch_bodies' }) - declare fetchBodies: boolean - - /** How far the scheduler has queued fetch jobs. */ - @column({ columnName: 'last_queued_id' }) - declare lastQueuedId: bigint - - @column.dateTime({ autoCreate: true }) - declare createdAt: DateTime - - @column.dateTime({ autoCreate: true, autoUpdate: true }) - declare updatedAt: DateTime -} diff --git a/app/services/ArchiveService.ts b/app/services/ArchiveService.ts deleted file mode 100644 index 683187d..0000000 --- a/app/services/ArchiveService.ts +++ /dev/null @@ -1,170 +0,0 @@ -import db from '@adonisjs/lucid/services/db' -import logger from '@adonisjs/core/services/logger' -import FileStore from '#services/FileStore' -import IngestService from '#services/IngestService' -import type { RarInfo } from '#lib/rar/index' -import { fileStem, isGenericName, rarReleaseIdentity } from '#lib/release/index' - -/** Stored per file and release; a listing of thousands of files is not useful. */ -const MAX_ENTRIES = 500 - -export interface ArchiveResult { - info: RarInfo - releaseId?: number - releaseRenamed?: string - grouped?: boolean -} - -/** - * Records what a RAR volume's headers say: the listing on the file, the - * release it belongs to (from the shared directory or continued entry, when - * the file has none yet), and for first volumes the contents and password - * flag on the release. See CONCEPT.md (D19, D21). - */ -class ArchiveService { - async record(file: any, binary: any, info: RarInfo): Promise { - const { next: _next, ...listing } = info - const stored = { ...listing, entries: info.entries.slice(0, MAX_ENTRIES).map(({ stored: _s, dataOffset: _d, ...entry }) => entry) } - await FileStore.setDetails(Number(file.id), { archive: stored }) - - let releaseId = file.release_id ?? binary?.release_id ?? null - let grouped = false - const identity = rarReleaseIdentity(info.entries) - if (!releaseId && identity) { - const ids = await IngestService.upsertReleases(db.connection().getWriteClient(), [ - { - release: identity, - poster: binary?.poster ?? file.poster, - weakIdentity: true, - groups: binary?.groups ?? file.groups ?? [], - postedAt: binary?.posted_at ? new Date(binary.posted_at) : undefined, - }, - ]) - releaseId = ids.get(identity.key)! - grouped = true - if (binary) await db.from('binaries').where('id', binary.id).update({ release_id: releaseId }) - await db.from('files').where('id', file.id).update({ release_id: releaseId }) - } - if (!releaseId) return { info } - - const release = await db.from('releases').where('id', releaseId).first() - const update: Record = {} - if (info.firstVolume !== false) { - update.encrypted = info.headersEncrypted || info.entries.some((e) => e.encrypted) - if (info.entries.length > 0) update.contents = JSON.stringify(stored.entries) - } else if (info.headersEncrypted) { - update.encrypted = true - } - const better = releaseNameFromContents(info) - // A spot's title is kept (CONCEPT.md D29). - const renamed = - release && better && release.source !== 'spot' && (looksRandom(release.name) || release.source === 'post') ? better : undefined - if (renamed) update.name = renamed - if (Object.keys(update).length > 0) await db.from('releases').where('id', releaseId).update(update) - - if (renamed) logger.info(`Release ${releaseId} renamed "${release.name}" → "${renamed}" from its archive.`) - return { info, releaseId, releaseRenamed: renamed, grouped } - } -} - -/** - * A release name from an archive listing: the top-level directory shared by - * all entries when it reads like a release name, otherwise the stem of the - * largest entry with a readable name (e.g. an archive holding just - * `Joker.Folie.a.Deux.2024.2160p….nzb`). Samples are skipped. - */ -export function releaseNameFromContents(info: Pick): string | undefined { - // Hidden entries name nothing: one poster adds the same 22 GB ".1917-….mkv" decoy to every upload (LOG.md 2026-10-06 (2)). - const listed = info.entries.filter((e) => !isHidden(e.name)) - const entries = listed.filter((e) => !e.directory) - const tops = new Set(listed.map((e) => e.name.split(/[\\/]/)[0])) - if (tops.size === 1 && listed.some((e) => /[\\/]/.test(e.name))) { - const [top] = tops - if (/[A-Za-z]{2,}.*[._-].*[A-Za-z0-9]{2,}/.test(top) && !looksRandom(top)) return top - } - const candidates = entries - .map((e) => ({ stem: fileStem(e.name.split(/[\\/]/).pop() ?? e.name), size: e.size ?? 0 })) - .filter(({ stem }) => !/(^|[._ -])sample([._ -]|$)/i.test(stem)) - .filter(({ stem }) => /[A-Za-z]{2,}.*[._ -].*[A-Za-z0-9]{2,}/.test(stem) && !looksRandom(stem)) - .sort((a, b) => b.size - a.size) - return candidates[0]?.stem -} - -/** An entry in a hidden directory or a hidden file itself (`.x/…`, `dir/.x`). */ -export function isHidden(name: string): boolean { - return name.split(/[\\/]/).some((part) => part.startsWith('.')) -} - -/** - * Releases named after a hidden archive entry before those were skipped: - * renamed from their stored listing (no downloads). Used by - * `release:rename --hidden`. - */ -export async function renameHiddenNamed(): Promise { - const releases = await db - .from('releases') - .where('name', 'like', '.%') - .whereNot('source', 'spot') - .whereNotNull('contents') - .select('id', 'name', 'contents') - let renamed = 0 - for (const release of releases) { - const entries: any[] = Array.isArray(release.contents) ? release.contents : [] - const fromHidden = entries.some((e) => isHidden(e.name) && fileStem(String(e.name).split(/[\\/]/).pop() ?? '') === release.name) - if (!fromHidden) continue - const better = releaseNameFromContents({ entries }) - if (!better || better === release.name) continue - await db.from('releases').where('id', release.id).update({ name: better }) - renamed++ - } - return renamed -} - -/** - * Renames random-named releases from their stored archive listing (no - * downloads). Used by `release:rename`. - */ -export async function renameFromStoredContents(): Promise { - const releases = await db - .from('releases') - .whereNotNull('contents') - .whereRaw("jsonb_array_length(contents) > 0") - .select('id', 'name', 'source', 'contents') - let renamed = 0 - for (const release of releases) { - if (release.source === 'spot' || (!looksRandom(release.name) && release.source !== 'post')) continue - const better = releaseNameFromContents({ entries: release.contents }) - if (!better || better === release.name) continue - await db.from('releases').where('id', release.id).update({ name: better }) - renamed++ - } - return renamed -} - -/** - * Obfuscated names: one long token of mixed letters and digits, no - * separators, and the other generated shapes seen on Usenet (LOG.md - * 2026-10-01 (3)). Kept in step with SQL's is_readable_name(). - */ -export function looksRandom(name: string): boolean { - // An unknown short extension (e.g. ".bin") says nothing about the name. - name = name.replace(/\.[A-Za-z0-9]{1,4}$/, '') - // VIDEO_TS, Sample, CD1: real words, but no name. - if (isGenericName(name)) return true - if (/^[0-9a-f]{16,}$/i.test(name)) return true - // 6327d9fa2d6acf3e9a61fb053359d5e7.1790574045 (hex and a Unix time) - if (/^[0-9a-f]{16,}\.\d{9,}$/i.test(name)) return true - // UUIDs - if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(name)) return true - // 1650777167 (yEncBin), "83111763 n" (rclone uploads) - if (/^\d{6,}( n)?$/.test(name)) return true - // prvjr7hapd0i1ny14pz4, and rclone's long base32 names - if (/^[a-z0-9]{12,}$/.test(name) && (name.match(/\d/g)?.length ?? 0) >= 2 && (name.match(/[a-z]/g)?.length ?? 0) >= 4) return true - // wHJ7s71sAGE: short, but capitals all over the token, not only at word starts - if (/^[A-Za-z0-9]{8,15}$/.test(name) && /\d/.test(name) && /[a-z]/.test(name) && (name.slice(1).match(/[A-Z]/g)?.length ?? 0) >= 3) return true - if (!/^[A-Za-z0-9]{16,}$/.test(name) || !/[a-z]/.test(name) || !/[A-Z]/.test(name)) return false - // Digits, or many capitals without separators (e.g. RmISakytFwXSCEzjVEpaKk). - return /\d/.test(name) || (name.match(/[A-Z]/g) ?? []).length >= 6 -} - -export default new ArchiveService() diff --git a/app/services/ArchiveWalkService.ts b/app/services/ArchiveWalkService.ts deleted file mode 100644 index 67c4d44..0000000 --- a/app/services/ArchiveWalkService.ts +++ /dev/null @@ -1,160 +0,0 @@ -import db from '@adonisjs/lucid/services/db' -import env from '#start/env' -import NntpService from '#services/NntpService' -import QueueService from '#services/QueueService' -import { segmentsOf } from '#services/FileStore' -import { looksRandom, releaseNameFromContents } from '#services/ArchiveService' -import { rarVolume } from '#lib/content/index' -import type { RarEntry, RarInfo } from '#lib/rar/index' -import { probeWalk, type WalkProbe, type WalkTask } from '#lib/content/probe' - -/** Stored per release, as for a first-volume listing (ArchiveService). */ -const MAX_ENTRIES = 500 - -/** Listed sizes below this share of the release's size: the first segment listed only part of it. */ -const COVERED = 0.7 - -/** Inspection queues a walk this long after the first volume, when the release has had time to complete. */ -const WALK_DELAY_MS = 30 * 60 * 1000 - -export interface WalkOutcome { - releaseId: number - /** Nothing downloaded: why. */ - skipped?: 'missing' | 'covered' | 'volumes' - entries?: number - before?: number - complete?: boolean - stopped?: string - segments?: number - renamed?: string -} - -/** - * Complete archive listings by walking RAR headers (CONCEPT.md D40). The - * first segment of a first volume lists the entries that start in it: for - * a set of many small files (an audiobook's MP3s, a music album) that is - * the first one or two. The walk reads one segment per further entry and - * volume, at low priority: its own queue, one job at a time per collection - * worker, and NNTP connections only when inspection isn't waiting for one. - */ -class ArchiveWalkService { - /** Segments one walk may download (~0.7 MB each). */ - readonly maxSegments = env.get('ARCHIVE_WALK_MAX_SEGMENTS', 400) - - /** A listing that leaves most of the release unaccounted for. */ - worthWalking(entries: number, listedBytes: number, releaseSize: number): boolean { - if (entries === 0 || entries >= MAX_ENTRIES) return false - return listedBytes < COVERED * releaseSize - } - - /** After inspection listed a first RAR volume whose listing ends inside the set. */ - async queueAfterInspection(releaseId: number | null, info: RarInfo) { - if (!releaseId || info.format === '7z' || info.firstVolume === false) return - if (!info.truncated || info.headersEncrypted || info.entries.length === 0) return - await QueueService.archiveWalkQueue.add('walk', { releaseId }, { jobId: `walk-${releaseId}`, delay: WALK_DELAY_MS }) - } - - /** - * The database half before a walk (D41): the release's volumes, or the - * outcome when there's nothing to walk. - */ - async prepare(releaseId: number): Promise<{ task: WalkTask } | { outcome: WalkOutcome }> { - const release = await db.from('releases').where('id', releaseId).select('id', 'size', 'contents').first() - if (!release) return { outcome: { releaseId, skipped: 'missing' } } - const before = Array.isArray(release.contents) ? release.contents : [] - const listed = before.reduce((sum: number, e: any) => sum + Number(e.size ?? 0), 0) - if (!this.worthWalking(before.length, listed, Number(release.size))) { - return { outcome: await this.done(releaseId, { releaseId, skipped: 'covered' }) } - } - const volumes = await this.volumes(releaseId) - if (!volumes) return { outcome: await this.done(releaseId, { releaseId, skipped: 'volumes' }) } - return { - task: { - kind: 'walk', - releaseId, - volumes: volumes.map((file) => segmentsOf(file).map(({ number, id }) => ({ number, id }))), - maxSegments: this.maxSegments, - }, - } - } - - /** The database half after a walk: the fuller listing, the password flag and a better name. */ - async apply(releaseId: number, walked: WalkProbe): Promise { - const release = await db.from('releases').where('id', releaseId).select('id', 'name', 'source', 'contents', 'encrypted').first() - if (!release) return { releaseId, skipped: 'missing' } - const before = Array.isArray(release.contents) ? release.contents : [] - const outcome: WalkOutcome = { - releaseId, - before: before.length, - entries: walked.entries.length, - complete: walked.complete, - stopped: walked.stopped, - segments: walked.segments, - } - if (walked.entries.length > before.length) { - const contents = walked.entries.slice(0, MAX_ENTRIES) - const update: Record = { - contents: JSON.stringify(contents), - encrypted: Boolean(release.encrypted) || walked.headersEncrypted || walked.entries.some((e) => e.encrypted), - } - const better = releaseNameFromContents({ entries: walked.entries as RarEntry[] }) - // The rule of ArchiveService.record: random and post-subject names give way, a spot's title stays. - if (better && better !== release.name && release.source !== 'spot' && (looksRandom(release.name) || release.source === 'post')) { - update.name = better - outcome.renamed = better - } - await db.from('releases').where('id', releaseId).update(update) - } - return this.done(releaseId, outcome) - } - - /** Walks a release here, on the inspection pool's connections when inspection isn't waiting for one. */ - async walk(releaseId: number): Promise { - const prepared = await this.prepare(releaseId) - if ('outcome' in prepared) return prepared.outcome - return this.apply(releaseId, await probeWalk(prepared.task, fetchLow)) - } - - private async done(releaseId: number, outcome: WalkOutcome) { - await db.from('releases').where('id', releaseId).update({ contents_walked_at: new Date() }) - return outcome - } - - /** - * The release's RAR volumes in order, from volume 1 up to the first gap. - * Null without a first volume (e.g. volumes with random names). - */ - private async volumes(releaseId: number): Promise { - const files = await db.from('files').where('release_id', releaseId).select('id', 'filename', 'real_name', 'segments') - const sets = new Map>() - for (const file of files) { - const volume = rarVolume(String(file.real_name ?? file.filename)) - if (!volume || segmentsOf(file).length === 0) continue - const set = sets.get(volume.base) ?? new Map() - if (!set.has(volume.number)) set.set(volume.number, file) - sets.set(volume.base, set) - } - // A release holds one set, now and then a second (a sample, subs): the biggest. - const set = [...sets.values()].sort((a, b) => b.size - a.size)[0] - if (!set?.has(1)) return null - const ordered: any[] = [] - for (let n = 1; set.has(n); n++) ordered.push(set.get(n)) - return ordered - } -} - -/** One article body on a pooled connection, behind inspection's waiting jobs. */ -async function fetchLow(messageId: string): Promise { - const conn = await NntpService.acquire(true) - let failed = false - try { - return await NntpService.timed(NntpService.fetchBody(conn, messageId), 'BODY') - } catch (error) { - failed = true - throw error - } finally { - NntpService.release(conn, failed) - } -} - -export default new ArchiveWalkService() diff --git a/app/services/BackfillService.ts b/app/services/BackfillService.ts deleted file mode 100644 index 600a3de..0000000 --- a/app/services/BackfillService.ts +++ /dev/null @@ -1,112 +0,0 @@ -import db from '@adonisjs/lucid/services/db' -import logger from '@adonisjs/core/services/logger' -import indexerConfig from '#config/indexer' -import NntpService from '#services/NntpService' -import FetchRangeService from '#services/FetchRangeService' -import { articleAtDate } from '#lib/nntp/date_search' - -/** Articles XOVERed to find a posting date near an article number. */ -const PROBE = 200n - -/** - * Header backfill (CONCEPT.md D32). `start` gives a group a cursor from - * where live indexing began back to the article posted N days ago; `tick` - * (every scheduler run) queues low-priority, header-only fetch ranges from - * that cursor downwards, newest first, while few are waiting and the - * database stays within its size limits. - */ -class BackfillService { - /** Sets the group's backfill to reach back `days` days. Returns its range. */ - async start(groupName: string, days: number): Promise<{ from: bigint; to: bigint } | null> { - const group = await db.from('groups').where('name', groupName).first() - if (!group) throw new Error(`Unknown group ${groupName}`) - const conn = await NntpService.acquire() - let failed = false - try { - const { first, last } = await NntpService.timed(NntpService.selectGroup(conn, groupName), `GROUP ${groupName}`) - const dateOf = async (n: bigint) => { - const { overviews } = await NntpService.timed(conn.xover(n, n + PROBE), `XOVER ${groupName} ${n}`) - const header = overviews.find(([number, h]: [number | null, any]) => number !== null && h?.date)?.[1] - const date = header ? Date.parse(header.date) : Number.NaN - return Number.isNaN(date) ? null : date - } - const until = await articleAtDate(first, last, Date.now() - days * 24 * 60 * 60 * 1000, dateOf) - // Live indexing began a little before the group was added: continue from there. - const began = await articleAtDate(first, last, new Date(group.created_at).getTime(), dateOf) - const next = group.backfill_next_id !== null && BigInt(group.backfill_next_id) < began ? BigInt(group.backfill_next_id) : began - await db.from('groups').where('id', group.id).update({ backfill_until_id: until.toString(), backfill_next_id: next.toString() }) - return next > until ? { from: until, to: next } : null - } catch (error) { - failed = true - throw error - } finally { - NntpService.release(conn, failed) - } - } - - /** Stops a group's backfill (queued ranges still run). */ - async stop(groupName: string) { - await db.from('groups').where('name', groupName).update({ backfill_until_id: null, backfill_next_id: null }) - } - - /** Queues the next backfill ranges. Returns how many. */ - async tick(): Promise { - const { maxQueued, maxDefaultGb, maxHddGb } = indexerConfig.backfill - const groups = await db - .from('groups') - .where('active', true) - .whereNotNull('backfill_until_id') - .whereRaw('backfill_next_id > backfill_until_id') - .orderBy('id') - if (groups.length === 0) return 0 - - const room = await this.room(maxDefaultGb, maxHddGb) - if (room !== true) { - logger.warn(`Backfill paused: ${room}.`) - return 0 - } - const [{ count }] = await db.from('fetch_ranges').where('backfill', true).where('status', 'queued').count('* as count') - let free = maxQueued - Number(count) - let queued = 0 - // Round-robin over the groups, one range each per pass. - while (free > 0 && groups.length > 0) { - for (const group of [...groups]) { - if (free <= 0) break - const next = BigInt(group.backfill_next_id) - const until = BigInt(group.backfill_until_id) - const start = next - indexerConfig.batchSize + 1n > until ? next - indexerConfig.batchSize + 1n : until + 1n - await FetchRangeService.queue(group, start, next, { backfill: true }) - group.backfill_next_id = (start - 1n).toString() - await db.from('groups').where('id', group.id).update({ backfill_next_id: group.backfill_next_id }) - free-- - queued++ - if (start - 1n <= until) { - groups.splice(groups.indexOf(group), 1) - logger.info(`${group.name}: backfill fully queued.`) - } - } - } - return queued - } - - /** True when both parts of the database are under their limits, else why not. */ - /** - * Whether the database has room for more history: the default tablespace - * (NVMe) and tablespace `hdd` against BACKFILL_MAX_NVME_GB/_HDD_GB. Seed - * leases (CONCEPT.md D38) check the same limits. - */ - async room( - maxDefaultGb = indexerConfig.backfill.maxDefaultGb, - maxHddGb = indexerConfig.backfill.maxHddGb - ): Promise { - const { rows } = await db.rawQuery( - `SELECT spcname, pg_tablespace_size(oid) AS bytes FROM pg_tablespace WHERE spcname IN ('pg_default', 'hdd')` - ) - const gb = (name: string) => Number(rows.find((r: any) => r.spcname === name)?.bytes ?? 0) / 1024 ** 3 - if (gb('pg_default') > maxDefaultGb) return `database on the NVMe is ${gb('pg_default').toFixed(1)} GB (limit ${maxDefaultGb})` - if (gb('hdd') > maxHddGb) return `files table on the HDD is ${gb('hdd').toFixed(1)} GB (limit ${maxHddGb})` - return true - } -} - -export default new BackfillService() diff --git a/app/services/BinaryFileService.ts b/app/services/BinaryFileService.ts deleted file mode 100644 index e7700d6..0000000 --- a/app/services/BinaryFileService.ts +++ /dev/null @@ -1,144 +0,0 @@ -import db from '@adonisjs/lucid/services/db' -import FileStore from '#services/FileStore' -import SfvService from '#services/SfvService' -import ContentService from '#services/ContentService' -import InspectionQueue from '#services/InspectionQueue' - -export interface BinaryFilesResult { - /** New files. */ - saved: number - /** Binaries whose file already existed (same first segment or file_key). */ - existing: number - /** Conflicted, gone or already linked: nothing to do. */ - skipped: number - /** Not all parts stored yet (the job is retried for these). */ - incomplete: number[] - inspections: number -} - -/** - * Turns completed binaries into files (worker:file), a batch at a time - * (LOG.md 2026-10-06 (7)): one query each for the binaries, their parts and - * files that exist already, one INSERT for the new files (so the files - * statement trigger updates each release once), and one UPDATE and DELETE - * for the binaries' links and copied parts. It was one job of ~8 queries per - * binary, ~700 a minute. - */ -class BinaryFileService { - async store(binaryIds: number[]): Promise { - const result: BinaryFilesResult = { saved: 0, existing: 0, skipped: 0, incomplete: [], inspections: 0 } - const ids = [...new Set(binaryIds)] - const binaries = await db - .from('binaries') - .whereIn('id', ids) - .whereNull('conflicted_at') - .whereNull('file_id') - .select('id', 'file_key', 'name', 'real_name', 'poster', 'posted_at', 'groups', 'release_id', 'total_parts') - result.skipped = ids.length - binaries.length - if (binaries.length === 0) return result - - const partsOf = new Map() - const parts = await db - .from('parts') - .whereIn('binary_id', binaries.map((b) => b.id)) - .orderBy([{ column: 'binary_id' }, { column: 'number' }]) - .select('binary_id', 'number', 'message_id', 'bytes') - for (const part of parts) { - const list = partsOf.get(Number(part.binary_id)) ?? [] - list.push({ number: Number(part.number), id: part.message_id, size: Number(part.bytes) }) - partsOf.set(Number(part.binary_id), list) - } - const complete = binaries.filter((b) => { - const ok = (partsOf.get(Number(b.id))?.length ?? 0) >= b.total_parts - if (!ok) result.incomplete.push(Number(b.id)) - return ok - }) - if (complete.length === 0) return result - - // Already known as a file, e.g. added from a posted NZB before its own articles completed (id only: the row holds the segment list). - const firsts = complete.map((b) => partsOf.get(Number(b.id))![0].id) - const byFirst = new Map() - for (const row of await db.from('files').whereIn('first_message_id', firsts).select('id', 'first_message_id')) { - byFirst.set(row.first_message_id, Number(row.id)) - } - const fileIdOf = new Map() - const fresh = complete.filter((b) => { - const existing = byFirst.get(partsOf.get(Number(b.id))![0].id) - if (existing) fileIdOf.set(Number(b.id), existing) - return !existing - }) - - const now = new Date() - // file_key is unique: a second completion of the same upload inserts nothing. - const inserted = await FileStore.insert( - fresh.map((b) => { - const segments = partsOf.get(Number(b.id))! - return { - file_key: b.file_key, - filename: b.name, - poster: b.poster, - date: b.posted_at ? new Date(b.posted_at).getTime() : now.getTime(), - parts: segments.length, - segments, - groups: JSON.stringify(b.groups), - release_id: b.release_id, - real_name: b.real_name, - size: segments.reduce((sum, s) => sum + s.size, 0), - created_at: now, - updated_at: now, - } - }) - ) - const insertedByKey = new Map(inserted.map((row) => [row.file_key, row.id])) - const missing = fresh.filter((b) => !insertedByKey.has(b.file_key)).map((b) => b.file_key) - const byKey = new Map() - if (missing.length > 0) { - for (const row of await db.from('files').whereIn('file_key', missing).select('id', 'file_key')) byKey.set(row.file_key, Number(row.id)) - } - for (const b of fresh) { - const id = insertedByKey.get(b.file_key) ?? byKey.get(b.file_key) - if (id) fileIdOf.set(Number(b.id), id) - } - const fresher = new Map(fresh.map((b) => [b.file_key, b])) - result.saved = inserted.length - result.existing = fileIdOf.size - inserted.length - - // Links, locked in file_key order like ingest's upsert, so the two can't deadlock. - const linked = complete.filter((b) => fileIdOf.has(Number(b.id))).sort((a, b) => a.file_key.localeCompare(b.file_key)) - if (linked.length > 0) { - await db.transaction(async (trx) => { - await trx.rawQuery( - `SELECT id FROM binaries WHERE id IN (SELECT unnest(?::bigint[])) ORDER BY file_key FOR UPDATE`, - [linked.map((b) => b.id)] - ) - // Files queued for inspection below keep their binary until it has its fingerprint (par2 naming, D16). - const queued = new Set(inserted.filter((row) => row.file_key && ContentService.isCandidate(String(fresher.get(row.file_key)?.name ?? ''))).map((row) => row.id)) - await trx.rawQuery( - `UPDATE binaries b SET file_id = v.file_id, awaiting_inspection = CASE WHEN v.queued THEN true ELSE b.awaiting_inspection END - FROM unnest(?::bigint[], ?::bigint[], ?::boolean[]) AS v(id, file_id, queued) WHERE b.id = v.id`, - [linked.map((b) => b.id), linked.map((b) => fileIdOf.get(Number(b.id))!), linked.map((b) => queued.has(fileIdOf.get(Number(b.id))!))] - ) - }) - // Header-derived parts are in the file's segment list now; body-derived ones (with a CRC) stay for split checks (D20). - await db.rawQuery(`DELETE FROM parts WHERE binary_id = ANY(?::bigint[]) AND pcrc32 IS NULL`, [linked.map((b) => b.id)]) - } - - // As for one file before: an SFV posted earlier may list them (D34), then inspection. - const added = inserted.flatMap((row) => { - const b = row.file_key ? fresher.get(row.file_key) : undefined - return b - ? [{ id: row.id, name: b.real_name ?? b.name, posted: String(b.name), poster: b.poster, date: b.posted_at ? new Date(b.posted_at).getTime() : null, release_id: b.release_id }] - : [] - }) - if (added.length > 0) await SfvService.claimAll(added.map(({ posted: _p, ...file }) => file)) - // By the posted name, as before: inspection skips later volumes and readable media names anyway. - const jobs = added - .filter((file) => ContentService.isCandidate(file.posted)) - .map((file) => file.id) - if (jobs.length > 0) await InspectionQueue.add(jobs) - result.inspections = jobs.length - return result - } -} - -export default new BinaryFileService() diff --git a/app/services/ContentService.ts b/app/services/ContentService.ts deleted file mode 100644 index 1b4bb5d..0000000 --- a/app/services/ContentService.ts +++ /dev/null @@ -1,321 +0,0 @@ -import db from '@adonisjs/lucid/services/db' -import logger from '@adonisjs/core/services/logger' -import NntpService from '#services/NntpService' -import Par2Service, { type Par2Result } from '#services/Par2Service' -import ArchiveService, { looksRandom, type ArchiveResult } from '#services/ArchiveService' -import NameService from '#services/NameService' -import { decodeSegment } from '#lib/yenc/index' -import NzbImportService, { type NzbImportResult } from '#services/NzbImportService' -import { - isArchiveStart, - isLaterVolume, - NICOVIDEO_FILE, - NICOVIDEO_RELEASE, - nicovideoReleaseName, - type ContentType, - type NicovideoInfo, -} from '#lib/content/index' -import { chunkMd5, probeInspection, type InspectProbe, type InspectTask, type TaskSegment } from '#lib/content/probe' -import type { Segment } from '#lib/nzb/index' -import { fileStem, releaseIdentity } from '#lib/release/index' -import IngestService from '#services/IngestService' -import { looksLikeFilename } from '#lib/subject/index' -import { sevenZipVolume } from '#lib/sevenzip/index' -import SevenZipService from '#services/SevenZipService' -import SfvService from '#services/SfvService' -import ArchiveWalkService from '#services/ArchiveWalkService' -import FileStore, { segmentsOf } from '#services/FileStore' -import GarbageFilter from '#services/GarbageFilter' - -/** - * MD5 of the first 16 KiB of a file, as par2 records it, from the body of - * the file's first part. Undefined when that part is shorter than 16 KiB but - * the file is not (the chunk would span two parts). - */ -export function firstChunkMd5(body: Buffer, fileSize: number): string | undefined { - return chunkMd5(decodeSegment(body).data, fileSize) -} - -export interface ContentResult { - par2?: Par2Result - archive?: ArchiveResult - named: number - contentType: ContentType | null - nzb?: NzbImportResult - /** NZBs read from inside an archive. */ - archiveNzbs?: NzbImportResult[] - /** Files an SFV's listing moved into its release. */ - sfvMoved?: number - /** A niconico capture's release name from its API response (D34). */ - nicovideo?: string - /** The real name from the yEnc header, for a file posted under a random subject (D36). */ - yencName?: string - /** Why the file's release was deleted as garbage after this probe (D42). */ - garbage?: string -} - -/** - * Takes names and groupings from file contents (CONCEPT.md D21). For a - * completed file with a random name, one article (the first) is enough: - * - its fingerprint (size + first-16-KiB MD5) matches it to a par2 entry, - * - par2 files are recognised by content and name every file of their set, - * - RAR headers give the release grouping, name and contents. - * Inspection is prepare (database) → probe (downloads and parsing, - * lib/content/probe; here or on a seeding node, D41) → apply (database). - */ -class ContentService { - /** Random or missing names, and named first RAR volumes (for contents and password flag). */ - isCandidate(name: string) { - // Even under a random stem: the first volume and the index .par2 are inspected instead. - if (isLaterVolume(name)) return false - if (!looksLikeFilename(name) || looksRandom(fileStem(name))) return true - if (isArchiveStart(name)) return true - if (/\.nzb$/i.test(name)) return true - // SFVs name the files of their release; niconico captures carry their title (D34). - if (/\.sfv$/i.test(name) || NICOVIDEO_FILE.test(name)) return true - // Every 7z volume: the set is listed when its last volume is in (see inspect). - if (sevenZipVolume(name) !== null) return true - return /\.part0*1\.rar$/i.test(name) || (/\.rar$/i.test(name) && !/\.part\d+\.rar$/i.test(name)) - } - - /** - * Uploads that randomise the subject per article often keep the real file - * name in the yEnc header inside the body (`Deep.Strike.S01E16.1080p….part01.rar`; - * LOG.md 2026-10-02 (2)). A file with a random name takes that name, and - * leaves its weak one-file release for the release its name gives, where - * the other files of the upload gather. Returns the name, or undefined. - */ - private async nameFromYenc(file: any, binary: any, yencName: string): Promise { - const current = String(file.real_name ?? file.filename) - if (file.real_name || !looksRandom(fileStem(current))) return undefined - if (!looksLikeFilename(yencName) || looksRandom(fileStem(yencName)) || yencName === current) return undefined - - const release = file.release_id ? await db.from('releases').where('id', file.release_id).first() : null - const weak = !release || (['stem', 'weak-stem'].includes(release.source) && Number(release.file_count) <= 1) - let releaseId = file.release_id - if (weak) { - const identity = releaseIdentity({ poster: file.poster, subject: '', name: yencName, weak: true }) - if (identity) { - const ids = await IngestService.upsertReleases(db.connection().getWriteClient(), [ - { release: identity, poster: file.poster, groups: file.groups ?? [], postedAt: file.date ? new Date(Number(file.date)) : undefined, weakIdentity: true }, - ]) - releaseId = ids.get(identity.key) ?? releaseId - } - } - await db.from('files').where('id', file.id).update({ real_name: yencName, release_id: releaseId }) - if (binary) await db.from('binaries').where('id', binary.id).update({ real_name: yencName, release_id: releaseId }) - return yencName - } - - /** - * Names a niconico capture's release after the video, while it still has - * the archiver's `