From 4aaaaa786017278460e0b4f94cc0d3374e0e37e2 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 4 Oct 2026 23:03:17 +0200 Subject: [PATCH] chore(comments): drop epic, sprint and review-round ids from the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`, `deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence carrying a lesson is reworded around it ("a later attempt showed…"). Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids. Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only non-comment changes in TS/JS are test titles, one description string in a contract table, and five ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`). One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every 10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does. --- .env.example | 2 +- .git-blame-ignore-revs | 2 +- .trivyignore.yaml | 2 +- Dockerfile | 6 +- backend/app/audit.py | 6 +- backend/app/auth.py | 8 +- backend/app/bodylimit.py | 6 +- backend/app/collation.py | 2 +- backend/app/config.py | 16 +- backend/app/debugsession.py | 4 +- backend/app/diagnostics.py | 4 +- backend/app/discover/__init__.py | 2 +- backend/app/discover/budget.py | 2 +- backend/app/discover/pins.py | 4 +- backend/app/downloads/adapters.py | 6 +- backend/app/downloads/credentials.py | 4 +- backend/app/downloads/errors.py | 4 +- backend/app/downloads/formats.py | 8 +- backend/app/downloads/pipeline.py | 8 +- backend/app/downloads/upload.py | 2 +- backend/app/email.py | 4 +- backend/app/feedquery.py | 10 +- backend/app/issues/__init__.py | 4 +- backend/app/issues/bundle.py | 2 +- backend/app/issues/gitea.py | 10 +- backend/app/logs.py | 22 +-- backend/app/logsink.py | 8 +- backend/app/mailtext.py | 6 +- backend/app/main.py | 6 +- backend/app/models.py | 58 ++++---- backend/app/plex/cards.py | 2 +- backend/app/plex/client.py | 6 +- backend/app/plex/detail.py | 2 +- backend/app/plex/genres.py | 2 +- backend/app/plex/imgcache.py | 2 +- backend/app/plex/overlay.py | 8 +- backend/app/plex/people.py | 4 +- backend/app/plex/query.py | 20 +-- backend/app/plex/rollup.py | 4 +- backend/app/plex/stream.py | 26 ++-- backend/app/plex/sync.py | 12 +- backend/app/plex/tmdb.py | 22 +-- backend/app/plex/watch_sync.py | 13 +- backend/app/progress.py | 2 +- backend/app/routes/_common.py | 4 +- backend/app/routes/admin.py | 31 ++-- backend/app/routes/admin_logs.py | 2 +- backend/app/routes/auth.py | 46 +++--- backend/app/routes/channels.py | 8 +- backend/app/routes/config.py | 2 +- backend/app/routes/debug_sessions.py | 4 +- backend/app/routes/diagnostics.py | 8 +- backend/app/routes/discover.py | 6 +- backend/app/routes/downloads.py | 6 +- backend/app/routes/feed.py | 2 +- backend/app/routes/me.py | 6 +- backend/app/routes/plex/__init__.py | 2 +- backend/app/routes/plex/browse.py | 8 +- backend/app/routes/plex/detail.py | 8 +- backend/app/routes/plex/discover.py | 10 +- backend/app/routes/plex/people.py | 8 +- backend/app/routes/plex/playback.py | 8 +- backend/app/routes/plex/playlists.py | 2 +- backend/app/routes/saved_views.py | 2 +- backend/app/routes/setup.py | 8 +- backend/app/routes/sync.py | 2 +- backend/app/scheduler.py | 8 +- backend/app/sync/autotag.py | 6 +- backend/app/sync/maintenance.py | 2 +- backend/app/sync/playlists.py | 6 +- backend/app/sync/runner.py | 2 +- backend/app/sync/subscriptions.py | 4 +- backend/app/sync/videos.py | 2 +- backend/app/sysconfig.py | 14 +- backend/app/utils.py | 2 +- backend/app/youtube/client.py | 2 +- backend/app/youtube/search_scrape.py | 2 +- backend/docker-compose.test.yml | 4 +- backend/pyrightconfig.json | 2 +- backend/pytest.ini | 2 +- backend/requirements-dev.txt | 2 +- backend/requirements.lock | 4 +- backend/requirements.txt | 4 +- backend/ruff.toml | 10 +- backend/tests/conftest.py | 8 +- backend/tests/test_abuse_limits.py | 6 +- backend/tests/test_admin_logs.py | 2 +- backend/tests/test_admin_pending_access.py | 4 +- backend/tests/test_audit_count_rows.py | 2 +- backend/tests/test_auth_session.py | 18 +-- backend/tests/test_autotag_orphans.py | 4 +- backend/tests/test_body_limit.py | 18 +-- backend/tests/test_canonical_patterns.py | 2 +- backend/tests/test_channel_backfill.py | 4 +- backend/tests/test_channel_list_index_only.py | 2 +- backend/tests/test_channel_tags.py | 2 +- backend/tests/test_collation.py | 4 +- backend/tests/test_config_commit_ownership.py | 2 +- backend/tests/test_db_lane.py | 4 +- backend/tests/test_debug_sessions.py | 6 +- backend/tests/test_diagnostics.py | 2 +- backend/tests/test_discover.py | 12 +- backend/tests/test_download_credentials.py | 4 +- backend/tests/test_download_deprioritize.py | 2 +- backend/tests/test_download_errors.py | 6 +- .../tests/test_download_failure_catalog.py | 6 +- backend/tests/test_download_job_locks.py | 2 +- backend/tests/test_download_limits.py | 4 +- backend/tests/test_download_ref_count.py | 2 +- backend/tests/test_download_share_notice.py | 2 +- backend/tests/test_download_transitions.py | 2 +- backend/tests/test_email_canonical.py | 6 +- backend/tests/test_empty_listing_guards.py | 6 +- backend/tests/test_facebook_impersonation.py | 10 +- backend/tests/test_failure_lines.py | 2 +- backend/tests/test_feed_relevance_window.py | 4 +- backend/tests/test_feed_sort_indexes.py | 2 +- backend/tests/test_feed_views.py | 10 +- backend/tests/test_formats.py | 6 +- backend/tests/test_hls_reaper.py | 14 +- backend/tests/test_last_login.py | 2 +- backend/tests/test_links.py | 2 +- backend/tests/test_log_pii.py | 2 +- backend/tests/test_logger_names.py | 4 +- backend/tests/test_login_states.py | 8 +- backend/tests/test_logs.py | 4 +- backend/tests/test_logsink.py | 2 +- backend/tests/test_mail_language.py | 4 +- backend/tests/test_maintenance_lifecycle.py | 2 +- .../test_migration_rerun_invalid_index.py | 2 +- backend/tests/test_not_found_detail.py | 20 +-- backend/tests/test_og.py | 2 +- backend/tests/test_override_dep_convention.py | 4 +- backend/tests/test_playlist_add_position.py | 2 +- backend/tests/test_playlist_merge.py | 2 +- backend/tests/test_playlist_move.py | 4 +- backend/tests/test_playlist_position_lock.py | 4 +- backend/tests/test_playlist_sync_claim.py | 6 +- .../tests/test_playlist_sync_lock_order.py | 4 +- backend/tests/test_plex_admin_routes.py | 2 +- backend/tests/test_plex_cards.py | 2 +- backend/tests/test_plex_client.py | 6 +- backend/tests/test_plex_detail.py | 8 +- backend/tests/test_plex_discover.py | 2 +- backend/tests/test_plex_facets.py | 4 +- backend/tests/test_plex_facets_grid_parity.py | 6 +- .../tests/test_plex_field_scoped_search.py | 2 +- backend/tests/test_plex_hls_file_route.py | 14 +- backend/tests/test_plex_image_sizes.py | 2 +- backend/tests/test_plex_jsonb_normalize.py | 2 +- backend/tests/test_plex_keyword_filter.py | 2 +- backend/tests/test_plex_lang.py | 10 +- backend/tests/test_plex_last_watched_sort.py | 2 +- backend/tests/test_plex_overlay.py | 10 +- backend/tests/test_plex_paths.py | 2 +- backend/tests/test_plex_people_enrich.py | 2 +- backend/tests/test_plex_person.py | 4 +- backend/tests/test_plex_person_bio.py | 4 +- backend/tests/test_plex_playback_class.py | 2 +- backend/tests/test_plex_playback_session.py | 2 +- .../tests/test_plex_playlist_add_positions.py | 2 +- backend/tests/test_plex_query_conversion.py | 2 +- backend/tests/test_plex_stream.py | 2 +- backend/tests/test_plex_subtitle_auth.py | 2 +- backend/tests/test_plex_subtitle_prepare.py | 12 +- backend/tests/test_plex_sync_prune.py | 2 +- backend/tests/test_plex_sync_txn.py | 2 +- backend/tests/test_plex_tmdb.py | 6 +- backend/tests/test_plex_watch_reconcile.py | 10 +- backend/tests/test_plex_watch_sync.py | 6 +- backend/tests/test_python_engine_contract.py | 2 +- backend/tests/test_quota_ownership.py | 2 +- backend/tests/test_request_models.py | 4 +- backend/tests/test_reset_link_states.py | 2 +- backend/tests/test_response_checked.py | 4 +- backend/tests/test_review_round.py | 10 +- backend/tests/test_saved_views_source.py | 2 +- backend/tests/test_scheduler.py | 6 +- backend/tests/test_search_scrape.py | 4 +- backend/tests/test_set_password_link.py | 2 +- backend/tests/test_shorts_probe.py | 2 +- backend/tests/test_smoke.py | 2 +- backend/tests/test_swallowed_exceptions.py | 2 +- backend/tests/test_sync_guards.py | 2 +- .../tests/test_sysconfig_describe_queries.py | 2 +- backend/tests/test_sysconfig_registry.py | 6 +- backend/tests/test_tag_suggest.py | 2 +- backend/tests/test_upload.py | 16 +- backend/tests/test_utils.py | 4 +- backend/tests/test_welcome_mail.py | 2 +- backend/tests/test_youtube_refusals.py | 6 +- backend/tests/test_ytdlp_logging.py | 2 +- docker-compose.selfhost.yml | 2 +- docker-compose.yml | 2 +- frontend/e2e/channel.spec.ts | 8 +- frontend/e2e/feed.spec.ts | 4 +- frontend/e2e/global.setup.ts | 2 +- frontend/e2e/helpers.ts | 2 +- frontend/e2e/nav.spec.ts | 4 +- frontend/e2e/player.spec.ts | 2 +- frontend/e2e/tsconfig.json | 2 +- frontend/eslint.config.js | 84 +++++------ frontend/reactPlugin.ts | 4 +- frontend/src/App.tsx | 8 +- frontend/src/components/AboutCell.tsx | 2 +- frontend/src/components/AccountPanel.tsx | 2 +- frontend/src/components/AddToPlaylist.tsx | 4 +- .../src/components/AdminLogs.dom.test.tsx | 2 +- frontend/src/components/AdminLogs.tsx | 2 +- .../src/components/AdminUsers.dom.test.tsx | 10 +- frontend/src/components/AdminUsers.tsx | 6 +- frontend/src/components/AppCommandPalette.tsx | 4 +- frontend/src/components/AuditEarlierRuns.tsx | 2 +- frontend/src/components/AuditLog.tsx | 2 +- frontend/src/components/ChannelAbout.tsx | 2 +- frontend/src/components/ChannelDiscovery.tsx | 2 +- .../components/ChannelLayoutGrid.dom.test.tsx | 2 +- frontend/src/components/ChannelLayoutGrid.tsx | 4 +- .../ChannelPage.busyFocus.dom.test.tsx | 8 +- .../components/ChannelPage.fold.dom.test.tsx | 2 +- frontend/src/components/ChannelPage.tsx | 12 +- .../ChannelPage.watchState.dom.test.tsx | 2 +- .../ChannelStatsPopover.dom.test.tsx | 2 +- .../ChannelTags.suggest.dom.test.tsx | 2 +- frontend/src/components/ChannelTags.tsx | 6 +- frontend/src/components/Channels.tsx | 18 +-- .../components/ChannelsProvider.dom.test.tsx | 6 +- frontend/src/components/ChannelsProvider.tsx | 6 +- .../components/ConfigPanel.field.dom.test.tsx | 4 +- .../ConfigPanel.sections.dom.test.tsx | 4 +- frontend/src/components/ConfigPanel.tsx | 6 +- .../components/DateFilterSelect.dom.test.tsx | 2 +- .../src/components/DebugRecorder.dom.test.tsx | 2 +- frontend/src/components/DebugRecorder.tsx | 6 +- .../DiagnosticsSettings.dom.test.tsx | 2 +- .../src/components/DiagnosticsSettings.tsx | 2 +- .../DownloadCenter.confirm.dom.test.tsx | 4 +- .../DownloadCenter.menu.dom.test.tsx | 2 +- frontend/src/components/DownloadCenter.tsx | 6 +- frontend/src/components/DownloadDialog.tsx | 2 +- .../ErrorBoundary.message.dom.test.tsx | 2 +- frontend/src/components/ErrorBoundary.tsx | 2 +- .../ExcludedChannelList.dom.test.tsx | 2 +- .../components/Feed.countStale.dom.test.tsx | 2 +- .../Feed.nextPageFailed.dom.test.tsx | 2 +- .../src/components/Feed.reset.dom.test.tsx | 2 +- frontend/src/components/Feed.tsx | 2 +- .../src/components/Feed.ytsort.dom.test.tsx | 2 +- frontend/src/components/Header.tsx | 4 +- frontend/src/components/Home.dom.test.tsx | 22 +-- frontend/src/components/Home.pin.dom.test.tsx | 2 +- frontend/src/components/Home.tsx | 16 +- .../src/components/IssueComposer.dom.test.tsx | 4 +- frontend/src/components/IssueComposer.tsx | 8 +- frontend/src/components/LanguageSwitcher.tsx | 2 +- .../src/components/LibraryPlayer.dom.test.tsx | 2 +- frontend/src/components/LibraryPlayer.tsx | 2 +- .../src/components/MyLogViewer.dom.test.tsx | 2 +- frontend/src/components/NavSidebar.tsx | 2 +- .../NavigationProvider.dom.test.tsx | 8 +- .../NotificationsPanel.menu.dom.test.tsx | 6 +- .../src/components/NotificationsPanel.tsx | 16 +- .../OnboardingWizard.dialog.dom.test.tsx | 6 +- .../OnboardingWizard.escape.dom.test.tsx | 2 +- frontend/src/components/OnboardingWizard.tsx | 8 +- .../PersonalViewsModal.dom.test.tsx | 4 +- .../src/components/PlayerModal.dom.test.tsx | 14 +- frontend/src/components/PlayerModal.tsx | 12 +- .../PlaylistEditFailures.dom.test.tsx | 10 +- .../src/components/PlaylistLimitsHint.tsx | 2 +- .../src/components/Playlists.dom.test.tsx | 12 +- frontend/src/components/Playlists.tsx | 28 ++-- .../PlaylistsRail.rename.dom.test.tsx | 6 +- frontend/src/components/PlaylistsRail.tsx | 10 +- frontend/src/components/PlexBrowse.tsx | 2 +- .../src/components/PlexCollectionEditor.tsx | 2 +- .../PlexCollectionView.dom.test.tsx | 10 +- .../src/components/PlexCollectionView.tsx | 6 +- .../src/components/PlexDiscover.dom.test.tsx | 4 +- frontend/src/components/PlexDiscover.tsx | 2 +- frontend/src/components/PlexInfo.dom.test.tsx | 20 +-- frontend/src/components/PlexInfo.tsx | 17 +-- frontend/src/components/PlexMenu.dom.test.tsx | 2 +- .../components/PlexPersonView.dom.test.tsx | 14 +- .../PlexPlayer.backspace.dom.test.tsx | 2 +- .../PlexPlayer.controls.dom.test.tsx | 12 +- .../components/PlexPlayer.hls.dom.test.tsx | 6 +- .../PlexPlayer.session.dom.test.tsx | 19 ++- .../PlexPlayer.tabtrap.dom.test.tsx | 6 +- frontend/src/components/PlexPlayer.tsx | 55 ++++--- .../PlexPlayerSettings.dom.test.tsx | 2 +- .../src/components/PlexPlayerSettings.tsx | 2 +- frontend/src/components/PlexPlaylistAdd.tsx | 4 +- .../PlexPlaylistReorder.dom.test.tsx | 10 +- .../PlexPlaylistView.header.dom.test.tsx | 6 +- .../PlexPlaylistView.menu.dom.test.tsx | 8 +- frontend/src/components/PlexPlaylistView.tsx | 8 +- frontend/src/components/PlexProvider.tsx | 4 +- .../components/PlexScopeToggle.dom.test.tsx | 2 +- .../src/components/PlexSearchBox.dom.test.tsx | 6 +- frontend/src/components/PlexSearchBox.tsx | 4 +- .../PlexSidebar.picker.dom.test.tsx | 2 +- frontend/src/components/PlexSidebar.tsx | 8 +- frontend/src/components/PlexStatsOverlay.tsx | 4 +- frontend/src/components/PosterTile.tsx | 2 +- .../components/SavedViewsPopover.dom.test.tsx | 6 +- .../SavedViewsProvider.dom.test.tsx | 2 +- .../src/components/Scheduler.dom.test.tsx | 12 +- .../components/Scheduler.menu.dom.test.tsx | 2 +- frontend/src/components/Scheduler.tsx | 4 +- .../src/components/SearchBar.dom.test.tsx | 4 +- .../components/SearchBar.keys.dom.test.tsx | 2 +- frontend/src/components/SearchBar.tsx | 4 +- .../SettingsPanel.downloadAuth.dom.test.tsx | 2 +- frontend/src/components/SettingsPanel.tsx | 4 +- frontend/src/components/ShareDialog.tsx | 8 +- frontend/src/components/TagManager.tsx | 2 +- .../src/components/UpNextCard.dom.test.tsx | 2 +- frontend/src/components/UpNextCard.tsx | 2 +- .../src/components/UploadDialog.dom.test.tsx | 2 +- frontend/src/components/UploadDialog.tsx | 4 +- .../components/VideoCard.actions.dom.test.tsx | 6 +- .../components/VideoCard.channel.dom.test.tsx | 2 +- .../src/components/VideoCard.nav.dom.test.tsx | 8 +- frontend/src/components/VideoCard.tsx | 10 +- .../VideoEditor.partial.dom.test.tsx | 2 +- frontend/src/components/VideoEditor.tsx | 6 +- .../src/components/VideoInfoCard.dom.test.tsx | 2 +- frontend/src/components/VideoInfoCard.tsx | 4 +- .../src/components/VideoMenu.dom.test.tsx | 2 +- frontend/src/components/ViewSwitcher.tsx | 2 +- frontend/src/components/VirtualFeed.tsx | 4 +- .../src/components/WatchPage.dom.test.tsx | 14 +- frontend/src/components/WatchPage.tsx | 2 +- frontend/src/components/Welcome.tsx | 20 +-- .../WriteFailureReporting.dom.test.tsx | 10 +- frontend/src/components/YearFilter.tsx | 2 +- .../src/components/accountPanel.dom.test.tsx | 2 +- .../src/components/adminInvites.dom.test.tsx | 8 +- .../adminPendingAccess.dom.test.tsx | 2 +- .../src/components/auditColumns.dom.test.tsx | 8 +- frontend/src/components/auditColumns.tsx | 4 +- .../components/authCardStates.dom.test.tsx | 6 +- .../components/channelColumns.dom.test.tsx | 8 +- frontend/src/components/channelColumns.tsx | 14 +- .../channelsTagRow.contract.test.ts | 6 +- .../components/clientLogWiring.dom.test.tsx | 2 +- .../src/components/comfortPin.dom.test.ts | 2 +- .../comfortPin.langSwitch.dom.test.tsx | 4 +- frontend/src/components/comfortPin.tsx | 6 +- .../compilerCoverage.contract.test.ts | 12 +- frontend/src/components/copyItems.tsx | 4 +- .../components/descriptionLinks.dom.test.tsx | 2 +- .../src/components/downloadAdmin.dom.test.tsx | 2 +- frontend/src/components/downloadAdmin.tsx | 2 +- .../src/components/downloadRow.dom.test.tsx | 14 +- frontend/src/components/downloadRow.tsx | 8 +- frontend/src/components/facetOption.test.ts | 2 +- frontend/src/components/facetOption.ts | 2 +- .../legal/legalBodies.contract.dom.test.tsx | 2 +- .../listNav.wiring.contract.test.ts | 4 +- .../components/mediaPicture.contract.test.ts | 4 +- .../src/components/navigation.dom.test.ts | 8 +- frontend/src/components/navigationInit.ts | 2 +- .../src/components/playerModalControls.tsx | 2 +- frontend/src/components/playlistColumns.tsx | 6 +- frontend/src/components/playlistsInit.test.ts | 2 +- .../src/components/plexCards.dom.test.tsx | 16 +- .../components/plexCards.lang.dom.test.tsx | 2 +- .../plexCards.langSwitch.dom.test.tsx | 2 +- frontend/src/components/plexCards.tsx | 10 +- frontend/src/components/plexContext.ts | 2 +- frontend/src/components/plexDetailAside.tsx | 16 +- frontend/src/components/plexDetailUi.tsx | 6 +- .../src/components/plexPlayerControls.tsx | 2 +- .../components/plexShow.aside.dom.test.tsx | 4 +- frontend/src/components/plexSubUrl.test.ts | 8 +- frontend/src/components/plexSubUrl.ts | 4 +- .../components/plexViews.restart.dom.test.tsx | 2 +- frontend/src/components/plexViews.tsx | 20 +-- .../src/components/tagChipNames.dom.test.tsx | 2 +- .../src/components/ui/ActionMenu.dom.test.tsx | 6 +- frontend/src/components/ui/ActionMenu.tsx | 8 +- .../AnchoredPopover.returnFocus.dom.test.tsx | 2 +- .../src/components/ui/AnchoredPopover.tsx | 22 +-- .../src/components/ui/BackToTop.dom.test.tsx | 2 +- frontend/src/components/ui/BackToTop.tsx | 2 +- frontend/src/components/ui/Badge.tsx | 2 +- .../src/components/ui/Button.dom.test.tsx | 26 ++-- frontend/src/components/ui/Button.tsx | 2 +- .../src/components/ui/Carousel.dom.test.tsx | 2 +- frontend/src/components/ui/Chip.dom.test.tsx | 6 +- frontend/src/components/ui/Chip.tsx | 6 +- .../components/ui/ClampedText.dom.test.tsx | 4 +- .../src/components/ui/Combobox.dom.test.tsx | 12 +- frontend/src/components/ui/Combobox.tsx | 12 +- .../components/ui/CommandPalette.dom.test.tsx | 6 +- .../ui/CommandPalette.ordering.dom.test.tsx | 2 +- frontend/src/components/ui/CommandPalette.tsx | 14 +- .../components/ui/ContextMenu.dom.test.tsx | 4 +- .../ui/DataTable.fixedLayout.dom.test.tsx | 2 +- .../ui/DataTable.keyboard.dom.test.tsx | 2 +- .../ui/DataTable.progressive.dom.test.tsx | 6 +- frontend/src/components/ui/DataTable.tsx | 2 +- .../src/components/ui/FilterFold.dom.test.tsx | 2 +- .../ui/FocusSafeButton.select.dom.test.tsx | 2 +- .../src/components/ui/FocusSafeButton.tsx | 4 +- .../ui/HiddenFilterCue.dom.test.tsx | 2 +- .../src/components/ui/HiddenFilterCue.tsx | 2 +- .../src/components/ui/IconButton.dom.test.tsx | 2 +- frontend/src/components/ui/Input.dom.test.tsx | 2 +- frontend/src/components/ui/Input.tsx | 2 +- .../src/components/ui/ItemCount.dom.test.tsx | 2 +- frontend/src/components/ui/ItemCount.tsx | 4 +- .../components/ui/KeyboardHelp.dom.test.tsx | 4 +- frontend/src/components/ui/KeyboardHelp.tsx | 2 +- frontend/src/components/ui/MatchToggle.tsx | 2 +- frontend/src/components/ui/MediaCard.tsx | 2 +- frontend/src/components/ui/MediaPreview.tsx | 2 +- frontend/src/components/ui/Modal.dom.test.tsx | 8 +- .../ui/Modal.fullscreen.dom.test.tsx | 4 +- .../ui/Modal.strictmode.dom.test.tsx | 4 +- frontend/src/components/ui/Modal.tsx | 14 +- frontend/src/components/ui/NativeControls.tsx | 2 +- .../src/components/ui/NavRail.dom.test.tsx | 8 +- frontend/src/components/ui/NavRail.tsx | 4 +- frontend/src/components/ui/Overlay.tsx | 2 +- .../components/ui/PageScroller.dom.test.tsx | 8 +- frontend/src/components/ui/PageScroller.tsx | 4 +- .../src/components/ui/PageShell.dom.test.tsx | 6 +- frontend/src/components/ui/PageShell.tsx | 2 +- frontend/src/components/ui/Pager.dom.test.tsx | 2 +- .../components/ui/PanelGroups.dom.test.tsx | 2 +- frontend/src/components/ui/QueryState.tsx | 4 +- .../ui/SegmentedToggle.dom.test.tsx | 2 +- .../src/components/ui/SegmentedToggle.tsx | 2 +- frontend/src/components/ui/SelectTrigger.tsx | 6 +- .../ui/SidePanel.drawer.dom.test.tsx | 2 +- frontend/src/components/ui/SidePanel.tsx | 4 +- .../src/components/ui/SkipLink.dom.test.tsx | 2 +- .../src/components/ui/StatTiles.dom.test.tsx | 2 +- frontend/src/components/ui/StatTiles.tsx | 2 +- frontend/src/components/ui/Tabs.dom.test.tsx | 2 +- frontend/src/components/ui/Tabs.tsx | 6 +- .../src/components/ui/Toaster.dom.test.tsx | 4 +- .../src/components/ui/Toaster.hosts.test.ts | 6 +- .../ui/Toaster.strictmode.dom.test.tsx | 4 +- frontend/src/components/ui/Toaster.tsx | 8 +- .../components/ui/Tooltip.layer.dom.test.tsx | 2 +- frontend/src/components/ui/Tooltip.tsx | 2 +- frontend/src/components/ui/UndoToolbar.tsx | 2 +- frontend/src/components/ui/VirtualGrid.tsx | 4 +- .../src/components/ui/anchoredPopoverHooks.ts | 10 +- frontend/src/components/ui/backToTopState.ts | 2 +- frontend/src/components/ui/buttonStyles.ts | 2 +- frontend/src/components/ui/dialogFocus.ts | 14 +- frontend/src/components/ui/form.dom.test.tsx | 2 +- .../src/components/ui/gridColumns.test.ts | 2 +- frontend/src/components/ui/gridColumns.ts | 2 +- .../ui/hiddenFilterCue.contract.test.ts | 2 +- frontend/src/components/ui/mediaCardStyles.ts | 6 +- .../components/ui/mediaRail.contract.test.ts | 4 +- .../components/ui/menuCheck.contract.test.ts | 6 +- .../ui/pressableSelectable.contract.test.ts | 2 +- frontend/src/components/ui/progressiveRows.ts | 4 +- frontend/src/components/ui/reveal.ts | 2 +- frontend/src/components/ui/scrollFold.test.ts | 2 +- frontend/src/components/ui/scrollFold.ts | 2 +- frontend/src/components/ui/tableHeader.tsx | 4 +- .../ui/tailwindScan.contract.test.ts | 2 +- frontend/src/components/ui/useContextMenu.ts | 8 +- .../components/ui/useReturnFocus.dom.test.tsx | 2 +- .../components/useChannelActions.dom.test.tsx | 6 +- frontend/src/components/useChannelActions.tsx | 5 +- frontend/src/components/useDebugRecording.ts | 2 +- .../src/components/useDownloadActions.tsx | 6 +- .../usePlaylistActions.dom.test.tsx | 12 +- .../src/components/usePlaylistActions.tsx | 14 +- .../usePlexActiveFilters.dom.test.tsx | 4 +- .../src/components/usePlexActiveFilters.ts | 2 +- .../src/components/usePlexCardActions.tsx | 2 +- .../usePlexPlaylistEdits.dom.test.tsx | 8 +- .../src/components/usePlexPlaylistEdits.ts | 14 +- .../components/useTagSuggestions.dom.test.tsx | 2 +- frontend/src/components/useTagSuggestions.ts | 2 +- .../components/useVideoActions.dom.test.tsx | 4 +- frontend/src/components/useVideoActions.tsx | 6 +- .../welcomeMode.render.dom.test.tsx | 8 +- frontend/src/design-lab/DesignLab.tsx | 6 +- frontend/src/design-lab/batchA.tsx | 6 +- frontend/src/design-lab/batchB.tsx | 2 +- frontend/src/design-lab/batchC.tsx | 4 +- frontend/src/design-lab/batchCActions.tsx | 8 +- frontend/src/design-lab/batchD.tsx | 2 +- frontend/src/design-lab/labKit.tsx | 4 +- frontend/src/design-lab/labTokens.ts | 2 +- frontend/src/i18n/appCopy.dom.test.ts | 2 +- frontend/src/i18n/appCopy.ts | 2 +- frontend/src/i18n/backendIds.contract.test.ts | 4 +- frontend/src/i18n/copyRule.test.ts | 4 +- frontend/src/i18n/index.ts | 4 +- frontend/src/i18n/parity.test.ts | 2 +- frontend/src/i18n/plurals.test.ts | 4 +- frontend/src/index.css | 51 +++---- frontend/src/lib/actionErrors.test.ts | 4 +- frontend/src/lib/api.ts | 2 +- frontend/src/lib/api/account.ts | 6 +- frontend/src/lib/api/admin.ts | 10 +- frontend/src/lib/api/auth.ts | 6 +- frontend/src/lib/api/core.test.ts | 4 +- frontend/src/lib/api/core.ts | 12 +- frontend/src/lib/api/diagnostics.ts | 4 +- frontend/src/lib/api/discover.dom.test.ts | 2 +- frontend/src/lib/api/discover.ts | 4 +- frontend/src/lib/api/downloads.ts | 2 +- frontend/src/lib/api/facade.test.ts | 2 +- frontend/src/lib/api/feed.test.ts | 2 +- frontend/src/lib/api/feed.ts | 4 +- frontend/src/lib/api/plex.ts | 16 +- frontend/src/lib/api/plexFilters.dom.test.ts | 2 +- frontend/src/lib/api/plexLang.dom.test.ts | 2 +- frontend/src/lib/api/tags.ts | 2 +- frontend/src/lib/auditGroups.test.ts | 2 +- frontend/src/lib/auditGroups.ts | 2 +- .../src/lib/buttonPrimitive.contract.test.ts | 4 +- frontend/src/lib/captureTab.dom.test.ts | 2 +- frontend/src/lib/captureTab.ts | 2 +- frontend/src/lib/channelLayout.ts | 2 +- frontend/src/lib/channelOrder.dom.test.ts | 2 +- frontend/src/lib/channelOrder.ts | 2 +- .../src/lib/channelWriteFailures.dom.test.tsx | 2 +- frontend/src/lib/channelsUrlState.dom.test.ts | 4 +- frontend/src/lib/channelsUrlState.ts | 6 +- frontend/src/lib/chipLane.contract.test.ts | 8 +- frontend/src/lib/collate.ts | 2 +- frontend/src/lib/columnSort.test.ts | 2 +- frontend/src/lib/cropGeometry.ts | 2 +- frontend/src/lib/dateFilter.ts | 2 +- frontend/src/lib/debugRecorder.ts | 2 +- frontend/src/lib/detailLog.dom.test.ts | 2 +- frontend/src/lib/diagnostics.dom.test.ts | 2 +- .../src/lib/diagnostics.session.dom.test.ts | 2 +- frontend/src/lib/diagnostics.ts | 4 +- frontend/src/lib/downloadActions.test.ts | 2 +- frontend/src/lib/downloadActions.ts | 2 +- frontend/src/lib/downloadFailure.test.ts | 4 +- frontend/src/lib/downloads.test.ts | 6 +- frontend/src/lib/downloads.ts | 6 +- frontend/src/lib/entryUrl.ts | 6 +- frontend/src/lib/fadeEdge.contract.test.ts | 4 +- frontend/src/lib/feedView.ts | 2 +- frontend/src/lib/filterLog.ts | 2 +- frontend/src/lib/format.test.ts | 4 +- frontend/src/lib/format.ts | 10 +- frontend/src/lib/fullscreen.ts | 3 +- frontend/src/lib/gates.contract.test.ts | 26 ++-- frontend/src/lib/gates.snapshot.json | 140 +++++------------- frontend/src/lib/globalKeymap.dom.test.tsx | 6 +- frontend/src/lib/headings.contract.test.ts | 2 +- frontend/src/lib/history.dom.test.ts | 4 +- .../src/lib/history.navPosition.dom.test.tsx | 10 +- .../history.navRestore.reload.dom.test.tsx | 2 +- frontend/src/lib/history.subview.dom.test.ts | 8 +- frontend/src/lib/history.ts | 10 +- frontend/src/lib/homeIntent.ts | 2 +- .../src/lib/hoverAndLink.contract.test.ts | 2 +- .../src/lib/inputPrimitive.contract.test.ts | 2 +- frontend/src/lib/issueDraft.test.ts | 2 +- frontend/src/lib/issueDraft.ts | 2 +- frontend/src/lib/jobResult.dom.test.ts | 6 +- frontend/src/lib/jobResult.ts | 8 +- frontend/src/lib/keymap.test.ts | 6 +- frontend/src/lib/keymap.ts | 30 ++-- frontend/src/lib/layerStack.dom.test.ts | 2 +- frontend/src/lib/layerStack.ts | 6 +- frontend/src/lib/lintProbe.testkit.ts | 4 +- frontend/src/lib/listNav.dom.test.tsx | 16 +- frontend/src/lib/listNav.scope.dom.test.tsx | 6 +- frontend/src/lib/listNav.ts | 24 +-- frontend/src/lib/listPaging.test.ts | 2 +- frontend/src/lib/listPaging.ts | 2 +- frontend/src/lib/log.contract.test.ts | 2 +- frontend/src/lib/log.ts | 10 +- frontend/src/lib/logHooks.ts | 4 +- frontend/src/lib/mediaLog.ts | 2 +- .../src/lib/mutationFailures.contract.test.ts | 4 +- frontend/src/lib/nav.ts | 2 +- .../lib/navPosition.wiring.contract.test.ts | 4 +- frontend/src/lib/navRing.contract.test.ts | 2 +- .../src/lib/networkBoundary.contract.test.ts | 2 +- frontend/src/lib/nextPage.contract.test.ts | 4 +- frontend/src/lib/nextPage.dom.test.ts | 4 +- frontend/src/lib/nextPage.ts | 4 +- .../src/lib/nextPage.wiring.contract.test.ts | 6 +- .../lib/notifications.actionTtl.dom.test.ts | 2 +- frontend/src/lib/notifications.ts | 2 +- .../lib/playerModalHooks.apiload.dom.test.tsx | 2 +- frontend/src/lib/playerModalHooks.ts | 4 +- frontend/src/lib/playerStage.contract.test.ts | 4 +- frontend/src/lib/playlistView.test.ts | 2 +- frontend/src/lib/plexBackdrop.dom.test.tsx | 2 +- frontend/src/lib/plexDetail.ts | 10 +- frontend/src/lib/plexImage.contract.test.ts | 8 +- frontend/src/lib/plexImage.ts | 2 +- .../lib/plexPlayerHooks.controls.dom.test.tsx | 6 +- frontend/src/lib/plexPlayerHooks.ts | 28 ++-- frontend/src/lib/plexRating.ts | 2 +- frontend/src/lib/plexSearch.ts | 2 +- frontend/src/lib/plexStats.test.ts | 4 +- frontend/src/lib/plexStats.ts | 10 +- frontend/src/lib/plexUrlState.ts | 4 +- frontend/src/lib/pxBox.contract.test.ts | 2 +- frontend/src/lib/queryClient.dom.test.ts | 2 +- frontend/src/lib/queryClient.ts | 2 +- frontend/src/lib/queryKeys.ts | 17 +-- frontend/src/lib/reactEngine.contract.test.ts | 8 +- frontend/src/lib/reinsert.ts | 4 +- frontend/src/lib/releaseNotes.test.ts | 4 +- frontend/src/lib/releaseNotes.ts | 4 +- frontend/src/lib/repoScripts.contract.test.ts | 2 +- .../src/lib/rovingHighlight.contract.test.ts | 2 +- frontend/src/lib/saveLanguage.dom.test.ts | 2 +- frontend/src/lib/savedViews.test.ts | 2 +- frontend/src/lib/storage.ts | 6 +- frontend/src/lib/store.dom.test.tsx | 4 +- frontend/src/lib/store.ts | 4 +- frontend/src/lib/subscriptionCache.test.ts | 2 +- frontend/src/lib/subscriptionCache.ts | 2 +- frontend/src/lib/surfaceRole.contract.test.ts | 2 +- frontend/src/lib/tabState.dom.test.ts | 2 +- frontend/src/lib/tableColumns.ts | 2 +- frontend/src/lib/tagPicks.ts | 4 +- frontend/src/lib/textSelection.ts | 2 +- frontend/src/lib/theme.test.ts | 2 +- frontend/src/lib/theme.ts | 4 +- frontend/src/lib/toastDock.ts | 2 +- frontend/src/lib/touchTarget.contract.test.ts | 2 +- frontend/src/lib/uiBoundary.contract.test.ts | 8 +- frontend/src/lib/useCardPager.dom.test.tsx | 2 +- frontend/src/lib/useChannelPriority.ts | 2 +- frontend/src/lib/useChannelTagActions.ts | 2 +- .../src/lib/useChipOverflowFit.dom.test.tsx | 2 +- frontend/src/lib/useChipOverflowFit.ts | 4 +- frontend/src/lib/useDebounced.ts | 2 +- frontend/src/lib/useDismiss.dom.test.tsx | 2 +- frontend/src/lib/useDismiss.ts | 4 +- frontend/src/lib/useFocusKeeper.dom.test.tsx | 2 +- frontend/src/lib/useFocusKeeper.ts | 6 +- .../src/lib/useHoverFocusWithin.dom.test.tsx | 2 +- frontend/src/lib/useHoverFocusWithin.ts | 2 +- frontend/src/lib/useLatest.ts | 2 +- .../src/lib/usePref.saveFailure.dom.test.ts | 2 +- frontend/src/lib/usePref.ts | 2 +- frontend/src/lib/useTags.dom.test.tsx | 4 +- frontend/src/lib/useTags.ts | 4 +- frontend/src/lib/useUndoable.ts | 4 +- frontend/src/lib/useUrlMirror.dom.test.ts | 2 +- frontend/src/lib/useVerifyEntry.ts | 4 +- frontend/src/lib/verifyEntry.dom.test.tsx | 2 +- .../src/lib/viewportUnits.contract.test.ts | 2 +- frontend/src/lib/vocab.dom.test.ts | 4 +- frontend/src/lib/vocab.ts | 2 +- frontend/src/main.tsx | 4 +- frontend/tsconfig.node.json | 12 +- frontend/vitest.config.ts | 4 +- scripts/check-node-modules.mjs | 2 +- scripts/check.sh | 2 +- scripts/fe_complexity.mjs | 2 +- scripts/gen-changelog.mjs | 2 +- 669 files changed, 1831 insertions(+), 1904 deletions(-) diff --git a/.env.example b/.env.example index 224dcecc..fd17c2d5 100644 --- a/.env.example +++ b/.env.example @@ -28,7 +28,7 @@ ALLOWED_EMAILS= # Admin emails (subset of the above) get the admin role. ADMIN_EMAILS= -# Logging (E22). LOG_LEVEL is the BOOTSTRAP level — the level a process starts at and the only one +# Logging. LOG_LEVEL is the BOOTSTRAP level — the level a process starts at and the only one # the worker/download child read; the API's running level is the admin Configuration page's # `log_level` knob, which takes effect without a restart. TRACE | DEBUG | INFO | WARNING | ERROR, in # any letter case; any other value stops the boot with an error. LOG_FORMAT is "json" (one object per diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 90c742a1..25177014 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -2,7 +2,7 @@ # whole-repo reformatting does not bury the real authorship of each line. # Configure once per clone: git config blame.ignoreRevsFile .git-blame-ignore-revs # -# Prettier one-time repo-wide format pass (R2 S1b, 2026-07-21) +# Prettier one-time repo-wide format pass (2026-07-21) 51c36c2071728d2cd7ae580b7bfdf21f6867a347 # ruff format one-time backend + scripts pass (line-length 100, 2026-08-23) diff --git a/.trivyignore.yaml b/.trivyignore.yaml index 79dbc1c5..93a89011 100644 --- a/.trivyignore.yaml +++ b/.trivyignore.yaml @@ -1,4 +1,4 @@ -# The CVE gate's allowlist (E32). The publish gate scans the built image and the committed tree with +# The CVE gate's allowlist. The publish gate scans the built image and the committed tree with # Trivy and fails on any HIGH or CRITICAL finding that has a fix available. A finding that cannot be # fixed yet is let through ONLY by an entry here, and every entry carries both: # statement — why it is acceptable for now (what is exposed, what is not) diff --git a/Dockerfile b/Dockerfile index 2b9f9f77..2a31b361 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,13 +47,13 @@ RUN adduser --disabled-password --gecos "" appuser \ && mkdir -p /downloads \ && chown appuser /app /downloads -# ffmpeg: merge separate video+audio streams + postprocessing, and the Plex VAAPI transcode (E6 S1). +# ffmpeg: merge separate video+audio streams + postprocessing, and the Plex VAAPI transcode. # intel-media-va-driver (iHD): the VA driver ffmpeg's h264_vaapi needs — libva2 ships in the base but # no driver, so without this `vainfo`/`h264_vaapi` fail and transcode silently falls back to libx264. # Harmless on hosts with no /dev/dri (the driver just goes unused). Deno: the JavaScript runtime yt-dlp # uses to solve YouTube's "n" signature challenge for the web player clients (required alongside the PO # token, else only storyboards are offered). yt-dlp auto-detects deno on PATH. -# `apt-get upgrade` (E32): the base image is a snapshot, and the Debian security fixes released since it +# `apt-get upgrade`: the base image is a snapshot, and the Debian security fixes released since it # was cut are only in the archive — the first Trivy scan found one (pcre2) fixed upstream but not in it. ARG DENO_VERSION=v2.9.1 RUN apt-get update \ @@ -81,7 +81,7 @@ RUN printf 'Types: deb\nURIs: http://deb.debian.org/debian\nSuites: bookworm\nCo # Install from the PINNED lock (requirements.lock, generated from requirements.txt) so the image is # byte-reproducible and the exact versions are the ones the gate's pip-audit scanned — not whatever # floats up from the floor ranges on build day. Regenerate the lock when requirements.txt changes (see its header). -# pip itself is removed once it has installed the lock (E32): nothing runs it at runtime, and it carries +# pip itself is removed once it has installed the lock: nothing runs it at runtime, and it carries # its own VENDORED copies of urllib3, msgpack and setuptools, which Trivy reports as the image's own — # an attack surface and a CVE stream for a tool the running app never calls. COPY backend/requirements.lock . diff --git a/backend/app/audit.py b/backend/app/audit.py index 1419ceeb..14087279 100644 --- a/backend/app/audit.py +++ b/backend/app/audit.py @@ -27,7 +27,7 @@ class AuditAction: USER_SUSPEND = "user_suspend" USER_UNSUSPEND = "user_unsuspend" USER_DELETE = "user_delete" - # E9.5 S5: an admin let a stuck registrant in, or re-sent their confirmation link. Both are + # An admin let a stuck registrant in, or re-sent their confirmation link. Both are # recorded because both are the admin acting ON someone's access, not merely reading a list. USER_ACTIVATE = "user_activate" USER_VERIFY_RESEND = "user_verify_resend" @@ -36,7 +36,7 @@ class AuditAction: INVITE_DENY = "invite_deny" INVITE_ADD = "invite_add" INVITE_DELETE = "invite_delete" - # E9.5 S6: the admin chose to tell someone their address is whitelisted. Adding the address is + # The admin chose to tell someone their address is whitelisted. Adding the address is # silent, so this is the act that actually reaches a person — and the one worth a record. INVITE_SEND = "invite_send" DISCOVERY_PURGE = "discovery_purge" @@ -55,7 +55,7 @@ class AuditAction: DOWNLOAD_CREDENTIAL_DELETE = "download_credential_delete" # The log itself was cleared (a single row survives the clear as tamper-evidence) AUDIT_CLEAR = "audit_clear" - # E16: a user sent a debug session's issue to the tracker — the one act that takes their log + # A user sent a debug session's issue to the tracker — the one act that takes their log # off this instance, so it is recorded (target = the session, summary = the issue's URL). ISSUE_CREATE = "issue_create" diff --git a/backend/app/auth.py b/backend/app/auth.py index cf5a79cd..97485bfd 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -67,7 +67,7 @@ def has_write_scope(user: User) -> bool: def env_allowed_canonicals() -> set[str]: """The env bootstrap allow-list, in CANONICAL form — the only shape it may be compared in. - S3 canonicalised the Invite branch of `is_allowed` and left this one matching the literal string, + Canonicalising only the Invite branch of `is_allowed` left this one matching the literal string, which is the 2026-09-03 lockout with a different source of truth: an operator who writes `first.last@gmail.com` into ALLOWED_EMAILS is refused when Google answers with the undotted primary, and a `+tag`ged entry can never match at all, because a Google account cannot contain a @@ -340,12 +340,12 @@ def _session_user(request: Request, db: Session, user_id: int | None, is_default if default_id and default_id not in accounts: accounts.append(default_id) request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:] - # Attribute this request's log lines to the account (E22). Here, not in the request middleware: + # Attribute this request's log lines to the account. Here, not in the request middleware: # this is the one place a session becomes an account, and the middleware runs outside both the # session middleware and this task's context. `request.state` is shared across middleware layers # (it lives on the ASGI scope), so the access-log line can read it back. The user's consented - # diagnostics level rides along: their requests are logged — and stored — in that detail (S5). - # So does a debug session the request names (E16): only the caller's own open one counts, and a + # diagnostics level rides along: their requests are logged — and stored — in that detail. + # So does a debug session the request names: only the caller's own open one counts, and a # header naming anything else is ignored rather than refused — a stale tab must keep working. session = debugsession.from_header(db, user, request.headers.get(debugsession.HEADER)) level, until = debugsession.level_for(user, session) diff --git a/backend/app/bodylimit.py b/backend/app/bodylimit.py index ddd9660e..befa1d11 100644 --- a/backend/app/bodylimit.py +++ b/backend/app/bodylimit.py @@ -59,7 +59,7 @@ DEFAULT_MAX_BODY_BYTES = 1024 * 1024 # a say. # # Both listed routes now satisfy that. The screenshot route always did (`capped_body` streams it, -# and the owner check resolves first). The upload route did NOT until P1: it declared +# and the owner check resolves first). The upload route did NOT at first: it declared # `file: UploadFile = File(...)`, so an anonymous caller could make the process write the whole # per-file allowance to temp before the auth dependency refused them — this middleware bounded that # number but could not stop the writing. It now reads its own body from a dependency declared after @@ -73,12 +73,12 @@ DEFAULT_MAX_BODY_BYTES = 1024 * 1024 # `POST /api/diagnostics/events` is absent: its `capped_body` limit is 512 KiB, already below the # default, so listing it could only widen what it accepts. ROUTE_MAX_BODY_BYTES: dict[tuple[str, str], int] = { - # Ingesting a local file. The body IS the file since P1 — no multipart envelope to allow for — + # Ingesting a local file. The body IS the file now — no multipart envelope to allow for — # so this ceiling and the route's own cap count exactly the same bytes. `sysconfig`'s # `download_upload_max_bytes` is bounded by this same constant (see sysconfig.SPECS), so an # admin can only ever LOWER the route's cap below the edge, never raise it past one. # - # WHICH LAYER ANSWERS, measured rather than assumed (deep-review round 1): + # WHICH LAYER ANSWERS, measured rather than assumed: # * knob LOWERED (an override below the default) — the route's cap is the smaller number, so # `capped_stream_to` trips first and answers 422 `upload_too_large` carrying the admin's own # figure, after draining the rest of the body so the client can read it. diff --git a/backend/app/collation.py b/backend/app/collation.py index 846049a2..be993587 100644 --- a/backend/app/collation.py +++ b/backend/app/collation.py @@ -1,4 +1,4 @@ -"""Alphabetical order in the reader's language (E30 S3). +"""Alphabetical order in the reader's language. Why not the database default: the Postgres image is Alpine, and musl's `en_US.utf8` collation is BYTE order — every accented initial sorted after "Z" ("Zorró < Életrajzi < Ötödik"), in every A→Z list the diff --git a/backend/app/config.py b/backend/app/config.py index 3dbd21e8..b7680444 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -242,7 +242,7 @@ class Settings(BaseSettings): download_default_max_bytes: int = 5_368_709_120 # 5 GiB download_default_max_concurrent: int = 2 download_default_max_jobs: int = 50 - # Per-file cap for local uploads (E7). Unlike a download (whose size is unknown until the worker + # Per-file cap for local uploads. Unlike a download (whose size is unknown until the worker # finishes), an upload's size is known at ingest, so this bounds a single file directly; the # user's overall byte footprint is still governed by download_default_max_bytes. # ge=1: this number is also the EDGE ceiling (`bodylimit.ROUTE_MAX_BODY_BYTES`), where 0 is a @@ -266,7 +266,7 @@ class Settings(BaseSettings): # Default SponsorBlock (skip sponsor segments) for new custom profiles. sponsorblock_default: bool = False - # --- Diagnostics / logging (E22) --- + # --- Diagnostics / logging --- # BOOTSTRAP level only: the running level is the `log_level` admin knob (sysconfig), applied at # startup and on change without a restart. This is what a process logs at before it can reach # the database — and the only knob the worker/runner children read, since they are short-lived. @@ -299,7 +299,7 @@ class Settings(BaseSettings): plex_path_map: str = "" # Which Plex library section keys are exposed (comma-separated). Empty = all enabled sections. plex_libraries: str = "" - # HU-overlay mapping (E5 S5): comma-separated `huSectionKey:enSectionKey` pairs. Each named HU + # HU-overlay mapping: comma-separated `huSectionKey:enSectionKey` pairs. Each named HU # section is synced as a non-browsable overlay whose Hungarian metadata is written onto the EN # twin section's rows (matched by guid). Empty = no overlays. E.g. "3:4,5:1". plex_overlay_map: str = "" @@ -309,7 +309,7 @@ class Settings(BaseSettings): plex_sync_interval_min: int = 360 # How often the incremental Plex→Siftlode watch-state reconcile runs, in minutes (Phase C): # a cheap history + on-deck pull plus a re-push of any states that failed their immediate push. - # 15 min (S2): with Plex now the source of truth (prompt write-back on the ~10s checkpoint + + # 15 min: with Plex now the source of truth (prompt write-back on the ~10s checkpoint + # Plex-authoritative pull), a faster incremental pull keeps Siftlode close to Plex-side changes. plex_watch_sync_interval_min: int = 15 # How often the FULL watch-state reconcile runs, in minutes (Phase C). A whole-section rescan @@ -324,7 +324,7 @@ class Settings(BaseSettings): # each one is a whole segmented copy of the film on the scratch volume, written faster than # realtime. One account holds at most two regardless (plex/stream.py `_MAX_SESSIONS_PER_USER`). plex_max_sessions: int = 8 - # --- Transcode (playable="transcode") re-encode tuning (E6 S1) --- + # --- Transcode (playable="transcode") re-encode tuning --- # Hardware-encoder policy: "auto" = VAAPI (h264_vaapi) when the iGPU render node is present AND the # source codec is hardware-decodable, else libx264 software; "on" = force VAAPI wherever the render # node exists (skip the decodability gate); "off" = always software. See plex/stream.py `_select_encode`. @@ -347,7 +347,7 @@ class Settings(BaseSettings): # local disk, so the default is fine there. plex_hls_dir: str = "" - # --- TMDB enrichment (E5 S2; optional — enriches the Plex catalog from TMDB) --- + # --- TMDB enrichment (optional — enriches the Plex catalog from TMDB) --- # TMDB API key (v3 api_key, 32 hex chars). Server-side only; stored encrypted when set via the # admin Config page. Empty → the TMDB enrichment job no-ops. Feeds keywords / crew / budget / # revenue / TMDb score / recommendations / backdrops onto the mirrored Plex rows, matched by the @@ -357,7 +357,7 @@ class Settings(BaseSettings): # Metadata language for TMDB fetches (keyword/overview locale). Stored EN by default. tmdb_language: str = "en-US" - # --- Bug reports (E16): where a debug session's issue goes. All three empty = no tracker; the + # --- Bug reports: where a debug session's issue goes. All three empty = no tracker; the # composer still exports a zip. The repository must be PRIVATE — an issue carries the reporter's # log — and app/issues/gitea.py refuses a public one. Runtime knobs (sysconfig, admin UI). --- issues_gitea_url: str = "" @@ -367,7 +367,7 @@ class Settings(BaseSettings): tmdb_enrich_interval_min: int = 120 # Titles fetched per TMDB enrichment run (TMDB is fast + generously rate-limited). tmdb_enrich_batch: int = 100 - # How often the people job (E30 S6) fetches a batch of person bios from TMDB and portraits through Plex, + # How often the people job fetches a batch of person bios from TMDB and portraits through Plex, # in minutes, and how many of each per run: ~18k linked people at 300 per 10 min is about ten hours # once, then only the newcomers. people_enrich_interval_min: int = 10 diff --git a/backend/app/debugsession.py b/backend/app/debugsession.py index 0f0aa167..deaa27bc 100644 --- a/backend/app/debugsession.py +++ b/backend/app/debugsession.py @@ -1,4 +1,4 @@ -"""Debug sessions (E16): Start → reproduce → Stop, and the evidence a bug report is built from. +"""Debug sessions: Start → reproduce → Stop, and the evidence a bug report is built from. While a session is open its user's browser names it in `X-Debug-Session` on every request. The auth dependency accepts the header only for the caller's OWN open session (anything else is ignored, never @@ -269,7 +269,7 @@ def set_delivery(db: Session, session: DebugSession, state: dict, token: str) -> every step. It is stamped from `token` here instead, and only after the ROW confirms the claim is still ours. Without that check the write is a full-column overwrite with no WHERE: a send that lost its lease came back and stomped the take-over send's committed links, then put its - own dead token back on an idle session (deep-review round 4). `release_send` has always made + own dead token back on an idle session. `release_send` has always made the same check; this is the other half of it.""" if claim_owner(db, session) != token: raise SendClaimLost(session.id) diff --git a/backend/app/diagnostics.py b/backend/app/diagnostics.py index e88facdd..eb483ddb 100644 --- a/backend/app/diagnostics.py +++ b/backend/app/diagnostics.py @@ -1,4 +1,4 @@ -"""A user's diagnostics level (E22 S5): what they consented to, and for how long. +"""A user's diagnostics level: what they consented to, and for how long. Three levels, named in the UI Alap / Részletes / Teljes: @@ -26,7 +26,7 @@ DiagnosticsLevel = Literal["INFO", "DEBUG", "TRACE"] LEVELS: tuple[str, ...] = get_args(DiagnosticsLevel) BASE: DiagnosticsLevel = "INFO" ELEVATION_HOURS = 24 -# How long a debug session records at most (E16, app/debugsession.py). Here, beside the other +# How long a debug session records at most (app/debugsession.py). Here, beside the other # consent window, because the start dialog's consent text states it and reads it from `state`. SESSION_MINUTES = 60 diff --git a/backend/app/discover/__init__.py b/backend/app/discover/__init__.py index e6f779b7..b73252f2 100644 --- a/backend/app/discover/__init__.py +++ b/backend/app/discover/__init__.py @@ -1,4 +1,4 @@ -"""E24 — the read-model behind the Home surface ("Mit nézzek most?"). +"""The read-model behind the Home surface ("Mit nézzek most?"). The anti-paralysis answer layer: one hero suggestion, a cross-source resume rail, and the user's own comfort shelf, all served from our own Postgres with no upstream call at serve time (the same rule diff --git a/backend/app/discover/budget.py b/backend/app/discover/budget.py index 23e2f558..f5a7a851 100644 --- a/backend/app/discover/budget.py +++ b/backend/app/discover/budget.py @@ -1,4 +1,4 @@ -"""The time budget — E24's replacement for "what mood are you in?". +"""The time budget — the replacement for "what mood are you in?". `random-gondolatok` 15/D asked for a mood questionnaire before the recommendations. The same file's later BA-interview note (#23) says the mood half is not what makes this work: the friction is diff --git a/backend/app/discover/pins.py b/backend/app/discover/pins.py index 9b6ede72..c6fca272 100644 --- a/backend/app/discover/pins.py +++ b/backend/app/discover/pins.py @@ -46,8 +46,8 @@ def resolve_ref(db: Session, user: User, kind: str, ref: str) -> tuple[str, obje translation belongs here rather than in five call sites. The COLUMN half of every answer comes from `PIN_COLUMNS`; only the LOOKUP differs per kind - (a direct `db.get`, a rating-key translation, an int parse plus an ownership check). Before the - E24 review the branches spelled the column names out again and `PIN_COLUMNS` had no readers at + (a direct `db.get`, a rating-key translation, an int parse plus an ownership check). Before a + review the branches spelled the column names out again and `PIN_COLUMNS` had no readers at all — a constant that called itself the one vocabulary while nothing spoke it.""" col = PIN_COLUMNS.get(kind) if col is None: diff --git a/backend/app/downloads/adapters.py b/backend/app/downloads/adapters.py index 4749adc5..5c8e4150 100644 --- a/backend/app/downloads/adapters.py +++ b/backend/app/downloads/adapters.py @@ -156,7 +156,7 @@ class YouTubeAdapter(SourceAdapter): class FacebookAdapter(SourceAdapter): key = "facebook" - # Measured (E11.5, yt-dlp 2026.07.04): with the user's cookie, some reels answer a plain request + # Measured (yt-dlp 2026.07.04): with the user's cookie, some reels answer a plain request # with "Cannot parse data" and parse only when the request looks like Chrome. Impersonation is a # SECOND attempt behind that one error, not the default: the links that already download with the # cookie keep their proven path, and only the ones stuck at the wall go through curl_cffi. Without @@ -166,7 +166,7 @@ class FacebookAdapter(SourceAdapter): _POST_PAGE_MAX_BYTES = 16 * 1024 * 1024 def resolve_url(self, url: str, ydl_opts: dict) -> str: - # Measured (E11.5 S4): given a group post's link, yt-dlp either finds no formats or — worse — + # Measured: given a group post's link, yt-dlp either finds no formats or — worse — # downloads ANOTHER video from the page's feed data. The post's own video is the one Video node # in the page scripts that carry the post id; its watch link downloads (plain first, then the # Chrome retry above). Needs the cookie: signed out, the page is the login wall. @@ -176,7 +176,7 @@ class FacebookAdapter(SourceAdapter): with yt_dlp.YoutubeDL(ydl_opts) as ydl: raw = ydl.urlopen(url).read(self._POST_PAGE_MAX_BYTES) page = raw.decode("utf-8", "replace") - # Measured (E11.5 review): a refused or expired cookie gets the login page (~450 KB, a login + # Measured: a refused or expired cookie gets the login page (~450 KB, a login # form, no post id at all), which is not a post without a video — the user's cookie is the cause. if _quoted(post_id) not in page: raise StatedFailure(POST_NOT_SHOWN_MESSAGE) diff --git a/backend/app/downloads/credentials.py b/backend/app/downloads/credentials.py index bce4ca4c..f7cef6cd 100644 --- a/backend/app/downloads/credentials.py +++ b/backend/app/downloads/credentials.py @@ -249,8 +249,8 @@ def usable_cookie( If (2) fails the asset is the SHARED (unsalted) one — attaching the cookie would poison the global cache with a private-group video, so we attach nothing. An unreadable blob (rotated key → - InvalidToken) also yields None; the download then fails honestly on the login wall (S3's - `login_required`).""" + InvalidToken) also yields None; the download then fails honestly on the login wall (the + `login_required` category).""" host_key = host_key_for_url(url) if host_key is None: return None diff --git a/backend/app/downloads/errors.py b/backend/app/downloads/errors.py index eaa57424..3475d5f2 100644 --- a/backend/app/downloads/errors.py +++ b/backend/app/downloads/errors.py @@ -8,7 +8,7 @@ explanation (and decides whether a retry makes sense). The raw text stays availa "details" disclosure. This is the same string-matching approach the worker already trusts for `_RETRIABLE_MARKERS` — kept here as the single source of truth for "what kind of failure is this". -**The failure catalog** (`_CATALOG`, E11.5). A category is generic: "Sign-in required" cannot tell a +**The failure catalog** (`_CATALOG`). A category is generic: "Sign-in required" cannot tell a Facebook user that the cookie they already gave was refused, and yt-dlp words several different Facebook walls identically (`Cannot parse data`). A catalog entry is scoped to ONE host and may also depend on whether a cookie was attached and on the kind of link, and names a `detail` code the UI has @@ -49,7 +49,7 @@ from app.downloads.credentials import host_key_for_url # "produced no output file" have markers here; the urlguard refusal deliberately has none and # classifies as `unknown` — it is a refusal, not a diagnosis, and no category would say more. # -# A source adapter's verdicts on a link (`SourceAdapter.resolve_url`, E11.5 S4) are adapters.py's +# A source adapter's verdicts on a link (`SourceAdapter.resolve_url`) are adapters.py's # constants, imported above; each has a rule below that gives its category back, and a catalog entry # that gives the site's sentence. QUOTA_FULL_MESSAGE = "Your download storage is full — free up space, then try again." diff --git a/backend/app/downloads/formats.py b/backend/app/downloads/formats.py index 22e43f50..a8deef1e 100644 --- a/backend/app/downloads/formats.py +++ b/backend/app/downloads/formats.py @@ -13,7 +13,7 @@ spec shape (all keys optional; defaults + browser-playability clamp applied by ` vcodec always coerced to null → H.264 selected (see `normalize`); no opt-out embed_subs bool embed_chapters bool embed_thumbnail bool sponsorblock bool -`normalize` clamps container/vcodec/audio_format so EVERY download plays in-browser (E7); the +`normalize` clamps container/vcodec/audio_format so EVERY download plays in-browser; the `container`/`vcodec` keys survive only so an old stored profile deserializes without error. """ @@ -217,7 +217,7 @@ def normalize(spec: dict) -> dict: # Audio-only files can't embed subtitles/thumbnails as video; keep the flags meaningful. if s["mode"] == "a": s["embed_subs"] = False - # Browser-playability guarantee (E7): the Download module exists to download → share → PLAY + # Browser-playability guarantee: the Download module exists to download → share → PLAY # in-browser, so every output must play in EVERY browser, iOS/Safari/WebKit included. The only # universally-safe combo is mp4 + H.264 + AAC/MP3, so we CLAMP the spec to it HERE — the single # point every path (picker, stored profile, raw API) flows through — instead of only hiding @@ -274,7 +274,7 @@ def build_ydl_opts( `extractor_args` with the player-client set + bgutil PO-token base_url); it is merged onto the generic options below. Everything here is source-agnostic. - `cookiefile` is the acting user's per-host cookies.txt path (S6), attached ONLY by the worker + `cookiefile` is the acting user's per-host cookies.txt path, attached ONLY by the worker after the host-scope + private-asset gate in `downloads.credentials`. It is set AFTER the `extra` merge so a source adapter can never clobber it. @@ -329,7 +329,7 @@ def build_ydl_opts( if max_bytes and max_bytes > 0: opts["max_filesize"] = max_bytes - # The acting user's per-host cookie (S6). AFTER the adapter merge on purpose: an adapter must not + # The acting user's per-host cookie. AFTER the adapter merge on purpose: an adapter must not # be able to override the authenticated session the worker resolved for this specific user. if cookiefile: opts["cookiefile"] = cookiefile diff --git a/backend/app/downloads/pipeline.py b/backend/app/downloads/pipeline.py index 22c7c1ac..e2554fbe 100644 --- a/backend/app/downloads/pipeline.py +++ b/backend/app/downloads/pipeline.py @@ -73,7 +73,7 @@ _YTDLP_TEMP_RE = re.compile( def _notify_download_done(db, user_id: int | None, title: str | None, job_id, *, kind: str) -> None: """Post a best-effort 'ready' notice to the owner's inbox — downloads/edits used to finish - silently while scheduler jobs and playlist-push already notified (R11 feedback symmetry). A + silently while scheduler jobs and playlist-push already notified (feedback symmetry). A notification failure must never mask the completed job, so it is isolated + rolled back (mirrors scheduler._notify_done). `kind` ("download"/"edit") lets the inbox pick accurate copy; the English title here is only a fallback for an unknown type. `user_id`/`title` are captured by the @@ -219,7 +219,7 @@ def _ffmpeg_progress_writer(job_id: int, phase: str, *, now=time.monotonic): when the whole-percent value changed (the bar shows integers), plus a separate once-per-interval heartbeat while the percent is stuck (so a slow re-encode isn't mistaken for a stall).""" # Progress writes have their OWN 1/interval budget so a heartbeat can never delay a real write (the - # round-1 concern). The heartbeat, in turn, is gated on `last_touch` — the last DB write OR beat — + # original concern). The heartbeat, in turn, is gated on `last_touch` — the last DB write OR beat — # so a progress write (which already refreshes updated_at) suppresses a redundant heartbeat that # same interval, keeping active downloads at ~1 write/interval, not two. allow_write = _rate_limiter(_DB_WRITE_INTERVAL_S, now=now) @@ -341,7 +341,7 @@ def _extract_with_fallback( (adapters.POST_*_MESSAGE) instead of letting yt-dlp guess, and a failed page read fails the download as itself. - `cookiefile` (S6) is the acting user's per-host cookies.txt, already gated by + `cookiefile` is the acting user's per-host cookies.txt, already gated by `credentials.resolve_cookiefile`; passed to every attempt so the fallback stays authenticated. `max_bytes` (SEC-13) is the owner's remaining storage budget, applied to every attempt.""" @@ -483,7 +483,7 @@ def _download( urlguard.check_url(url) except urlguard.BlockedUrl as e: raise StatedFailure(f"Refusing to download from this address ({e.reason}).") from e - # S6: attach the acting user's per-host cookie ONLY when it's host-scoped to this URL AND + # Attach the acting user's per-host cookie ONLY when it's host-scoped to this URL AND # this asset is that user's private, salted identity (the double gate lives in credentials). remaining_bytes: int | None = None with SessionLocal() as cdb: diff --git a/backend/app/downloads/upload.py b/backend/app/downloads/upload.py index 1c91421e..ea565c50 100644 --- a/backend/app/downloads/upload.py +++ b/backend/app/downloads/upload.py @@ -1,4 +1,4 @@ -"""Local file ingest for the download center (E7 S5) — the non-yt-dlp path into the media library. +"""Local file ingest for the download center — the non-yt-dlp path into the media library. A user-uploaded file joins the SAME library as a downloaded one, so it inherits the whole function set already built (metadata chips, share links, and — for media — in-app play/edit). Unlike a diff --git a/backend/app/email.py b/backend/app/email.py index 8b92813a..2eeeaacf 100644 --- a/backend/app/email.py +++ b/backend/app/email.py @@ -197,7 +197,7 @@ def _next_step(verify_link: str | None, has_account: bool, hours: int, lang: str Three states, because a mail that guesses wrong sends someone looking for a link that isn't coming — or worse, tells them to sign in when their address is still unconfirmed and sign-in will - refuse them (E9.5 S4 named that state on screen; the mail must not contradict it). + refuse them (the sign-in screen names that state; the mail must not contradict it). The link branch is the point of the C option: in the common ordering — approve first, the person signs up later, or signs up and waits — they would otherwise have to find and open TWO separate @@ -253,7 +253,7 @@ def send_access_invitation( the closing line offers no deadline — nudging is the one thing this mail must not do. Its language is the one place the app has no signal to read: the admin typed this address in, so - nobody has ever told us how to address its owner. That is why the send button asks (E9.5 S7). + nobody has ever told us how to address its owner. That is why the send button asks. """ admin, contact = _reply_contact(lang) body = mailtext.text( diff --git a/backend/app/feedquery.py b/backend/app/feedquery.py index aa84af0e..dfedd76a 100644 --- a/backend/app/feedquery.py +++ b/backend/app/feedquery.py @@ -109,7 +109,7 @@ def _tag_visible(user: User): # The pseudo-category of a selected tag id no Tag row answers (a saved view or the feed state outlives # a deleted tag). It filters like a category whose tags no channel carries — to nothing, in the SQL and # in feed_summary's mirror alike — instead of the id being dropped, which showed the WHOLE feed under -# the view's old name (E32 S2). No real category is called this (language/topic/other). +# the view's old name. No real category is called this (language/topic/other). MISSING_TAG_CATEGORY = "missing" @@ -118,7 +118,7 @@ def tags_by_category(db: Session, user: User, tag_ids: list[int]) -> dict[str, l shared by the feed's tag WHERE (filtered_query) and the facet/count summary (feed_summary), so the two can't disagree on which selected tag belongs to which category. An unknown id lands in MISSING_TAG_CATEGORY — and so does another user's private tag: resolving it to its real category - told the caller which foreign ids exist (E32 review).""" + told the caller which foreign ids exist.""" if not tag_ids: return {} # skip the pointless `WHERE id IN ()` round-trip on the no-tag-filter hot path out: dict[str, list[int]] = {} @@ -463,7 +463,7 @@ def feed_summary(db: Session, user: User, **params) -> dict: def feed_years(db: Session, user: User, **params) -> list[dict]: - """Per-year video counts for the current filter context (item 8 / E8·S2): powers the year control's + """Per-year video counts for the current filter context: powers the year control's quick chips + the "more…" per-year-counts dialog. Every OTHER filter (tags, channels, content type, search, watch state, scope) still applies, but the publish-date narrowing itself is DROPPED — published_after/before and max_age_days — so the year list is the full set of years available and @@ -559,7 +559,7 @@ def sort_keys(sort: str, seed: int, rank_expr=None, lang: str | None = None) -> return table.get(sort) or table["newest"] -# Relevance ranks only the newest RANK_WINDOW matches (E31): ts_rank reads every match's tsvector, and +# Relevance ranks only the newest RANK_WINDOW matches: ts_rank reads every match's tsvector, and # a broad word matches most of the library ("a" ranked 193k rows in 6.8 s on prod, against 1.1 s to just # find them). The rows past the window keep rank 0, so they follow the ranked ones newest first; a word # that broad has no meaningful relevance order anyway, and a specific one fits the window whole. @@ -671,7 +671,7 @@ def encode_cursor(keys: list[dict], row, window: datetime | None = None) -> str: def _cursor_dt(v): """A cursor's timestamp: aware or refused. The server writes only aware ones (timestamptz), and windowed_relevance_rows compares them in Python, where a naive one beside an aware one raises — a - 500 for a hand-made cursor instead of the 400 (E31 review 2).""" + 500 for a hand-made cursor instead of the 400.""" if v is None: return None dt = datetime.fromisoformat(v) diff --git a/backend/app/issues/__init__.py b/backend/app/issues/__init__.py index dd2ed760..afc92b3a 100644 --- a/backend/app/issues/__init__.py +++ b/backend/app/issues/__init__.py @@ -1,3 +1,3 @@ -"""Bug reports built from debug sessions (E16): the files an issue carries, and where they go — -downloaded as a zip, or (S4) sent to a Gitea issue tracker. The session itself — what is recorded +"""Bug reports built from debug sessions: the files an issue carries, and where they go — +downloaded as a zip, or sent to a Gitea issue tracker. The session itself — what is recorded and tagged — is the core module `app/debugsession.py`.""" diff --git a/backend/app/issues/bundle.py b/backend/app/issues/bundle.py index 45c9def0..2b316c58 100644 --- a/backend/app/issues/bundle.py +++ b/backend/app/issues/bundle.py @@ -5,7 +5,7 @@ Gitea upload, so the two cannot drift apart. in their language; the server does not rewrite it); * `client.log` / `server.log` — the session's evidence (`debugsession.bundle`) minus the rows the user took out, one JSON object per line, browser rows and server rows apart. `.log`, not - `.jsonl`: Gitea refuses that extension (measured, plans/e16 S0); + `.jsonl`: Gitea refuses that extension (measured); * the screenshots, in the order they were taken. Every row is already redacted twice (at capture, and again when stored), and nothing here adds a diff --git a/backend/app/issues/gitea.py b/backend/app/issues/gitea.py index b492de71..12226f14 100644 --- a/backend/app/issues/gitea.py +++ b/backend/app/issues/gitea.py @@ -1,5 +1,5 @@ -"""The Gitea issue tracker a debug session's issue goes to (E16 S4) — measured against Gitea 1.27.1 -before a line of this was written (plans/e16-debug-issue.md, S0). +"""The Gitea issue tracker a debug session's issue goes to — measured against Gitea 1.27.1 +before a line of this was written. Configured by an admin (sysconfig `issues_gitea_url` / `_repo` / `_token`, the token encrypted at rest). The repository must be PRIVATE: an issue carries its reporter's log — page titles, filter @@ -79,7 +79,7 @@ def target(db: Session) -> dict | None: @contextmanager def _client(settings: tuple[str, str, str]) -> Iterator[tuple[httpx.Client, str]]: # Takes the settings already read, not the session: reading them HERE opened a transaction one - # line after `deliver` had ended one, and held it across the calls below (deep-review round 4). + # line after `deliver` had ended one, and held it across the calls below. url, repo, token = settings if not (url and repo and token): raise GiteaError("not_configured") @@ -203,7 +203,7 @@ def deliver( # call, so nothing holds a snapshot (and an idle connection) across minutes of tracker I/O. # The settings are read HERE and handed to `_client`, not read inside it: reading them there # was a SELECT one line after this commit, which reopened the transaction and held it — idle, - # with its snapshot pinned — across the repository check and the create (deep-review round 4 + # with its snapshot pinned — across the repository check and the create (a review # measured it in pg_stat_activity). A comment saying otherwise sat right here. checkpoint() with _client(settings) as (client, repo): @@ -223,7 +223,7 @@ def deliver( ) # Built ON the state read at the start so nothing already in it is dropped. The claim # is NOT among those things, whatever this comment used to say: `set_delivery` strips - # any `sending` passed to it and stamps the row's own from `token` (round 4 — removing + # any `sending` passed to it and stamps the row's own from `token` (removing # the spread left the whole suite green, because the claim never travelled here). state = { **state, diff --git a/backend/app/logs.py b/backend/app/logs.py index 1b099320..ecafc7d2 100644 --- a/backend/app/logs.py +++ b/backend/app/logs.py @@ -10,7 +10,7 @@ the logs were both unjoinable and leaky. `configure_logging()` replaces all four Event shape — the OpenTelemetry log data model, without the SDK; one JSON object per line: ts · level · sev · source · logger · event · msg · attrs plus whatever correlation the context carries (`request_id`, `user_id`, `debug_session_id`, -`job_id`) and the app version. That shape is what E22.6's admin console filters on and what a Gitea +`job_id`) and the app version. That shape is what the admin log console filters on and what a Gitea issue attachment will carry, so it is deliberately flat and stable. Call sites stay ordinary stdlib logging: @@ -106,7 +106,7 @@ class _RequestScope: self.user_id: int | None = None # The user's consented diagnostics level as a logging number; None at the base level. self.level: int | None = None - # The user's own open debug session this request named (E16), else None. + # The user's own open debug session this request named, else None. self.debug_session_id: str | None = None @@ -122,11 +122,11 @@ def set_user( debug_session: str | None = None, ) -> None: """Attribute the rest of this request's log lines to `user_id`, and log them at the user's - consented diagnostics `level` until `until` (E22 S5; "INFO" = no elevation). Called from the + consented diagnostics `level` until `until` ("INFO" = no elevation). Called from the auth dependency — the one place the session is actually resolved into an account — rather than from the request middleware, which runs OUTSIDE the session middleware. - `debug_session` tags every line with the user's open debug session (E16). The caller has + `debug_session` tags every line with the user's open debug session. The caller has already checked it is theirs and open; a session's level arrives here as `level`/`until`. The root logger's floor is tracked per elevation: a request without the session's header (an @@ -334,7 +334,7 @@ _RECORD_STANDARD = frozenset( class ContextFilter(logging.Filter): """Copies the correlation contextvars onto the record. A Filter (not the formatter) so the values are captured at emit time, before a QueueHandler can move the record to another - thread — the DB sink in S3 depends on that.""" + thread — the DB sink depends on that.""" def filter(self, record: logging.LogRecord) -> bool: for name, var in _CONTEXT_VARS.items(): @@ -355,7 +355,7 @@ class ContextFilter(logging.Filter): class LevelGate(logging.Filter): """The configured level, enforced at the HANDLER, with one exception: the lines of a request - whose user raised their diagnostics level (E22 S5). + whose user raised their diagnostics level. The root logger's level cannot do this alone: it is one number for the whole process, and the point is to log ONE user's requests in detail while everyone else's stay at the base level. So @@ -457,7 +457,7 @@ class TextFormatter(logging.Formatter): # --- configuration ------------------------------------------------------------------------- # Third-party loggers that are noisy at our own level. Pinned explicitly so the diagnostics knob can # lower the ROOT level to DEBUG/TRACE without drowning the output in SQL statements and HTTP chatter -# — raising THESE is a deliberate, separate act (E22.6's console). +# — raising THESE is a deliberate, separate act (the admin log console). _THIRD_PARTY_LEVELS = { "sqlalchemy": "WARNING", "sqlalchemy.engine": "WARNING", @@ -481,7 +481,7 @@ def get_logger(name: str) -> logging.Logger: `siftlode.plex.sync`. The names were literals before, and 25 of them covered 34 modules — six different Plex modules - all called themselves `siftlode.plex`, so neither a level pinned per logger nor E22.6's console + all called themselves `siftlode.plex`, so neither a level pinned per logger nor the console's filter could tell a sync line from a stream line. Derived from `__name__` there is exactly one name per module, it cannot drift from the file it sits in, and `test_logger_names.py` fails if a module hand-rolls one again. @@ -562,12 +562,12 @@ def current_level() -> str: return logging.getLevelName(_base_level or logging.getLogger().getEffectiveLevel()) -# --- per-user elevation (E22 S5) ----------------------------------------------------------- +# --- per-user elevation ----------------------------------------------------------- # The base level (`LOG_LEVEL`, then the admin knob). NOTSET until configure_logging runs — a unit # test, a script — so the gate lets everything through there, as it did before the gate existed. _base_level = logging.NOTSET # → (level number, expiry) for every elevation this process has seen and not yet expired: a user's -# own, by user id, and a debug session's (E16), by (user id, session id) — its requests alone carry +# own, by user id, and a debug session's, by (user id, session id) — its requests alone carry # its level, so it is tracked apart from the user's own requests. _ElevationKey = int | tuple[int, str] _elevations: dict[_ElevationKey, tuple[int, datetime]] = {} @@ -667,7 +667,7 @@ def current_source() -> str: # --- request logging ----------------------------------------------------------------------- -# A client may supply its own request id so a browser log line and a server line join up (E22 S4). +# A client may supply its own request id so a browser log line and a server line join up. # Accepted only in this shape: it ends up in a log field, and an unbounded header would be a way to # write arbitrary text into the log. _REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._-]{8,64}$") diff --git a/backend/app/logsink.py b/backend/app/logsink.py index b07acd1c..c63cea8f 100644 --- a/backend/app/logsink.py +++ b/backend/app/logsink.py @@ -1,4 +1,4 @@ -"""The `log_events` sink (E22 S3): the stored copy of the log the diagnostics console reads. +"""The `log_events` sink: the stored copy of the log the diagnostics console reads. stdout stays the complete log (docker keeps 3×50 MB of it); this table keeps the part worth querying for `log_retention_days`: @@ -6,7 +6,7 @@ for `log_retention_days`: * every WARNING and above, * the `http.request` line of every request at INFO or above (not the DEBUG-level health checks and static files), - * every line of a request whose user raised their diagnostics level (E22 S5 — `logs.LevelGate` + * every line of a request whose user raised their diagnostics level (`logs.LevelGate` marks them), which is the detail they consented to have kept, * nothing a caller marked `extra={logs.PERSIST: False}` — how the request log caps anonymous traffic per client. @@ -347,8 +347,8 @@ def gc(db: Session, retention_days: int) -> int: def serialize(row: LogEvent) -> dict: - """A stored row as the API sends it — one shape for every reader: the admin console (E22.6) and - a debug session's evidence list (E16).""" + """A stored row as the API sends it — one shape for every reader: the admin console and + a debug session's evidence list.""" return { "id": row.id, "ts": iso(row.ts), diff --git a/backend/app/mailtext.py b/backend/app/mailtext.py index f8be6152..4b4cba7e 100644 --- a/backend/app/mailtext.py +++ b/backend/app/mailtext.py @@ -1,5 +1,5 @@ """Bilingual text for every outbound mail, and the one resolver that decides which language a -recipient gets (E9.5 S7). +recipient gets. Why a catalog instead of literals in `email.py`: HU/EN parity is a house rule for every user-facing string, and a mail is the one user-facing surface the reader CANNOT re-render by switching the UI @@ -82,8 +82,8 @@ def lang_for_email(db: Session, email: str) -> str: # a name, not a translated string, and giving them keys would invite someone to "translate" them. MAIL_TEXT: dict[str, dict[str, str]] = { # ── How long a link lasts ─────────────────────────────────────────────────────────────── - # Rendered, not interpolated as a raw hour count: the approval mail's token lives seven days - # (E9.5 S6), and "expires in 168 hours" is a unit no reader converts. + # Rendered, not interpolated as a raw hour count: the approval mail's token lives seven days, + # and "expires in 168 hours" is a unit no reader converts. "duration.hours": {"en": "{n} hours", "hu": "{n} óra"}, "duration.days": {"en": "{n} days", "hu": "{n} nap"}, # ── Shared closing fragments ──────────────────────────────────────────────────────────────── diff --git a/backend/app/main.py b/backend/app/main.py index 37d39045..14a2dafa 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -131,7 +131,7 @@ async def _reap_hls_sessions() -> None: A FINISHED session is reaped on the short rule here, not kept for the full idle window: an open player checkpoints every 10 s and that route touches the session, so a paused viewer is no longer invisible to this pass (see `reap_idle`). Keeping every finished session for ten minutes instead - held one whole segmented copy of a film per item on the download volume (round 5).""" + held one whole segmented copy of a film per item on the download volume.""" while True: await asyncio.sleep(_HLS_REAP_EVERY_S) try: @@ -187,8 +187,8 @@ async def _nul_in_text(request: Request, exc: DataError) -> JSONResponse: NUL in text, so the driver refuses to even send the query — and a `%00` in a query parameter or an escaped NUL in a JSON body (a playlist's `contains=`, a reorder's key list, a collection search) was a 500 with a traceback wherever it reached a comparison. Guarding each site did not - hold: round 3 guarded the by-rating_key lookup and missed the rest. Any other DataError is left - alone — re-raised, it stays the 500 it always was.""" + hold: an earlier fix guarded the by-rating_key lookup and missed the rest. Any other DataError + is left alone — re-raised, it stays the 500 it always was.""" if "NUL (0x00)" not in str(exc.orig): raise exc return JSONResponse( diff --git a/backend/app/models.py b/backend/app/models.py index a13dc481..62d9c9ba 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -101,7 +101,7 @@ class User(Base, TimestampMixin, CanonicalEmailMixin): # Google sign-in that failed to find it), and a migration must not merge accounts on its own. email_canonical: Mapped[str] = mapped_column(String(320), index=True, server_default="") # The address Google asserted for this account, recorded at sign-in / link time. NOT the same - # thing as `email`: since E9.5 S3 a Google sign-in no longer overwrites the address the user + # thing as `email`: a Google sign-in no longer overwrites the address the user # registered with, so a deliberate `me+siftlode@gmail.com` survives — and this is where the other # half of the truth lives, for the UI to show when the two differ. NULL until the next Google # sign-in for accounts linked before this column existed. @@ -109,7 +109,7 @@ class User(Base, TimestampMixin, CanonicalEmailMixin): # argon2id hash for email+password login; NULL for Google-only accounts. password_hash: Mapped[str | None] = mapped_column(String(255)) # When SEC-10 dropped a preset password on Google adoption (see the single caller of - # auth.revoke_preset_password_if_unverified; admin approval no longer revokes — E9.5 S1). Without + # auth.revoke_preset_password_if_unverified; admin approval no longer revokes). Without # this trace a revoked account was indistinguishable from a Google-only one, so it failed the # password-reset eligibility check and could never be recovered — it could neither sign in nor # reset. Set = "had a password, lost it to the guard" -> reset-eligible. Cleared whenever a @@ -134,7 +134,7 @@ class User(Base, TimestampMixin, CanonicalEmailMixin): # routes/auth._establish_session. NULL = not since the column existed (0085; no backfill is # possible). last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - # The diagnostics level this user consented to (E22 S5) and when it lapses back to the base + # The diagnostics level this user consented to and when it lapses back to the base # level. Both NULL = base. Written only by `PUT /api/me/diagnostics`, which computes the expiry # itself; read through `app.diagnostics.effective_level`, never directly (an expired level is # not cleared eagerly — it simply stops counting). @@ -212,7 +212,7 @@ class Invite(Base, CanonicalEmailMixin): # The language this person asked us in ("en"/"hu"), captured when the request was made: the page # their form was on, or the locale Google reported for a refused sign-in. NULL for a row the # admin whitelisted by hand — nobody has told us anything about that mailbox, which is exactly - # why the invitation send asks (E9.5 S7). Captured rather than derived for the same reason + # why the invitation send asks. Captured rather than derived for the same reason # `preferences.language` and `google_email` are: the signal exists only in that one moment and # cannot be backfilled afterwards. lang: Mapped[str | None] = mapped_column(String(8)) @@ -567,7 +567,7 @@ class AuditLog(Base): class LogEvent(Base): - """One stored log event (E22 S3) — the `log_events` table the diagnostics console reads. Rows are + """One stored log event — the `log_events` table the diagnostics console reads. Rows are written by app/logsink.py's background writer, never inside a request's transaction, already redacted (the same `logs.record_fields` the stdout line is built from). @@ -580,7 +580,7 @@ class LogEvent(Base): __tablename__ = "log_events" __table_args__ = ( Index("ix_log_events_user_ts", "user_id", "ts"), - # E16: an issue's bundle is `WHERE debug_session_id = …`; nearly every row has none. + # An issue's bundle is `WHERE debug_session_id = …`; nearly every row has none. Index( "ix_log_events_debug_session", "debug_session_id", @@ -606,7 +606,7 @@ class LogEvent(Base): class DebugSession(Base): - """One recording: Start → reproduce → Stop (E16, app/debugsession.py). While it is open, every + """One recording: Start → reproduce → Stop (app/debugsession.py). While it is open, every request of its user that names it (`X-Debug-Session`) is logged at `level` and tagged with `id`, and the issue it ends in is built from the rows carrying that tag. `until` is the hard cap the server set at Start; an open session past it is over whether or not the sweep has closed it yet. @@ -631,12 +631,12 @@ class DebugSession(Base): # app/debugsession.py `EndReason`: stopped by the user, or past `until`. ended_reason: Mapped[str | None] = mapped_column(String(16)) issue_url: Mapped[str | None] = mapped_column(String(512)) - # The issue being written from it (E16 S3; app/debugsession.py `save_draft` holds its shape). + # The issue being written from it (app/debugsession.py `save_draft` holds its shape). draft: Mapped[dict | None] = mapped_column(JSONB) class DebugScreenshot(Base): - """A screenshot attached to a debug session (E16) — pasted, dropped, or captured from the pill. + """A screenshot attached to a debug session — pasted, dropped, or captured from the pill. Stored in the row: bounded per session (app/debugsession.py), and gone with it by cascade.""" __tablename__ = "debug_screenshots" @@ -1057,7 +1057,7 @@ class PlexLibrary(Base, TimestampMixin, UpdatedAtMixin): kind: Mapped[str] = mapped_column(String(16)) # "movie" | "show" enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true") synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - # HU-overlay support (E5 S5). `metadata_lang` tags the section's metadata language. An OVERLAY + # HU-overlay support. `metadata_lang` tags the section's metadata language. An OVERLAY # library (overlay_of set) is the Hungarian-metadata twin of another section: it is never browsable # on its own — its titles' HU fields are written onto the matched EN rows (paired by `guid`). metadata_lang: Mapped[str] = mapped_column(String(8), default="en", server_default="en") @@ -1086,7 +1086,7 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin): ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True ) # Canonical external id (e.g. "tmdb:12345") — the key that pairs this EN row with its HU-overlay - # twin during sync (E5 S5). Indexed for the guid->row overlay match. + # twin during sync. Indexed for the guid->row overlay match. guid: Mapped[str | None] = mapped_column(String(64), index=True) title: Mapped[str] = mapped_column(String(512)) summary: Mapped[str | None] = mapped_column(Text) @@ -1116,7 +1116,7 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin): ) # cast+directors, folded into search_vector # Set once the show's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3. people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - # Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast (E5 S5b) so + # Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast so # the info page serves from this row instead of a live plex.metadata() call. NULL until enriched. imdb_rating: Mapped[float | None] = mapped_column( Float @@ -1126,7 +1126,7 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin): full_cast: Mapped[list | None] = mapped_column( JSONB(none_as_null=True) ) # ordered [{name, role, thumb}] - # --- TMDB enrichment (E5 S2): see the identical block on plex_items for the full rationale. Additive; + # --- TMDB enrichment: see the identical block on plex_items for the full rationale. Additive; # served from the row; NULL until enriched. (`budget`/`revenue` stay NULL for shows — TV has none.) --- tmdb_id: Mapped[int | None] = mapped_column(Integer) tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) @@ -1144,10 +1144,10 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin): production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str] recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) backdrops: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) - # Hungarian-metadata twin fields, written by the overlay sync (E5 S5). NULL = no HU twin matched, + # Hungarian-metadata twin fields, written by the overlay sync. NULL = no HU twin matched, # so serving falls back to the EN columns. `people_text` is language-neutral, reused for both vectors. title_hu: Mapped[str | None] = mapped_column(String(512)) - tagline_hu: Mapped[str | None] = mapped_column(Text) # the HU overlay's tagline (E30) + tagline_hu: Mapped[str | None] = mapped_column(Text) # the HU overlay's tagline summary_hu: Mapped[str | None] = mapped_column(Text) genres_hu: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) search_vector: Mapped[object | None] = mapped_column( @@ -1209,7 +1209,7 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin): ) kind: Mapped[str] = mapped_column(String(16), index=True) # "movie" | "episode" # Canonical external id (e.g. "tmdb:12345") — pairs a MOVIE row with its HU-overlay twin during - # sync (E5 S5); indexed for the guid->row match. Episodes carry it too but it's unused for them. + # sync; indexed for the guid->row match. Episodes carry it too but it's unused for them. guid: Mapped[str | None] = mapped_column(String(64), index=True) show_id: Mapped[int | None] = mapped_column( ForeignKey("plex_shows.id", ondelete="CASCADE"), index=True @@ -1236,7 +1236,7 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin): # Browser-playability class from ffprobe: "direct" | "remux" | "transcode" (None = unprobed). playable: Mapped[str | None] = mapped_column(String(12), index=True) probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - # Transcode HW-capability facet (E7 playback-class filter). NULL for non-transcode items; for a + # Transcode HW-capability facet (the playback-class filter). NULL for non-transcode items; for a # `playable="transcode"` item: True = the prod iGPU can hardware-transcode it (codec is HW-decodable # AND the source is 8-bit SDR), False = software libx264 only (AV1/XviD, or a 10-bit/HDR source that # breaks the VAAPI nv12 pipeline — see plex/stream.py). Derived by `plex.sync.transcode_hw_capable`: @@ -1272,7 +1272,7 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin): people_text: Mapped[str | None] = mapped_column(Text) # Set once the movie's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3. people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - # Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast (E5 S5b) so + # Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast so # the info page serves from this row instead of a live plex.metadata() call. NULL until enriched. imdb_rating: Mapped[float | None] = mapped_column( Float @@ -1282,7 +1282,7 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin): full_cast: Mapped[list | None] = mapped_column( JSONB(none_as_null=True) ) # ordered [{name, role, thumb}] - # --- TMDB enrichment (E5 S2): matched by the canonical `guid` ("tmdb:12345"), filled by the TMDB + # --- TMDB enrichment: matched by the canonical `guid` ("tmdb:12345"), filled by the TMDB # backfill job, SERVED FROM THIS ROW (never a live TMDB call). Additive — imdb_rating/rating # above stay Plex-sourced; `tmdb_rating` is the distinct TMDb community score. NULL until enriched; # `tmdb_enriched_at` is the "tried" watermark (stamped on a definitive no-match too, so an @@ -1305,11 +1305,11 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin): recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) backdrops: Mapped[list | None] = mapped_column( JSONB(none_as_null=True) - ) # TMDB backdrop file_paths [str] (crossfade pool, S8) - # Hungarian-metadata twin fields, written by the overlay sync (E5 S5). NULL = no HU twin matched + ) # TMDB backdrop file_paths [str] (crossfade pool) + # Hungarian-metadata twin fields, written by the overlay sync. NULL = no HU twin matched # (serving falls back to the EN columns). `people_text` is language-neutral, reused for both vectors. title_hu: Mapped[str | None] = mapped_column(String(512)) - tagline_hu: Mapped[str | None] = mapped_column(Text) # the HU overlay's tagline (E30) + tagline_hu: Mapped[str | None] = mapped_column(Text) # the HU overlay's tagline summary_hu: Mapped[str | None] = mapped_column(Text) genres_hu: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) search_vector: Mapped[object | None] = mapped_column( @@ -1348,15 +1348,15 @@ class PlexPerson(Base, TimestampMixin, UpdatedAtMixin): id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(255), unique=True, index=True) photo_thumb: Mapped[str | None] = mapped_column(Text) # proxied /person-image URL, or None - # TMDB person id, linked by name from a title's TMDB credits (E5 S2 lays the foundation; the /person - # biography fetch + person-page render land in S6). NULL until a TMDB-enriched title names this person. + # TMDB person id, linked by name from a title's TMDB credits (the /person + # biography fetch builds on it). NULL until a TMDB-enriched title names this person. tmdb_person_id: Mapped[int | None] = mapped_column(Integer) - # --- TMDB person enrichment (E5 S6): fetched from /person/{tmdb_person_id} by the people_enrich job - # (E30 S6), or by the first person-page open the job hasn't reached, then SERVED FROM THE ROW. `tmdb_enriched_at` is the watermark (set even on a + # --- TMDB person enrichment: fetched from /person/{tmdb_person_id} by the people_enrich job, + # or by the first person-page open the job hasn't reached, then SERVED FROM THE ROW. `tmdb_enriched_at` is the watermark (set even on a # no-op fetch so it isn't retried every open). `known_for` = top combined-credits [{tmdb_id, # media_type, title}], resolved to library rows by guid at serve time (like recommendations). --- biography: Mapped[str | None] = mapped_column(Text) - # TMDB's Hungarian biography (E30 S4), from the same request's `translations`; a Hungarian reader is + # TMDB's Hungarian biography, from the same request's `translations`; a Hungarian reader is # served it, and the instance-language `biography` when TMDB has none. Birthplace, aliases and # keywords have no Hungarian on TMDB, so they stay in the instance language. biography_hu: Mapped[str | None] = mapped_column(Text) @@ -1397,7 +1397,7 @@ class PlexCollection(Base, TimestampMixin, UpdatedAtMixin): class PlexState(Base, UpdatedAtMixin): """Per-user watch state for a Plex item — mirrors VideoState. Lives in Siftlode's own DB so it works for users WITHOUT a Plex account (the whole point of the access-layer design). - `synced_to_plex` marks whether this state was pushed back to a linked Plex account (P5).""" + `synced_to_plex` marks whether this state was pushed back to a linked Plex account.""" __tablename__ = "plex_states" __table_args__ = (UniqueConstraint("user_id", "item_id", name="uq_plex_user_item"),) @@ -1476,7 +1476,7 @@ class PlexPlaylistItem(Base): class ComfortPin(Base, TimestampMixin): - """One entry on a user's comfort shelf — the anti-paralysis fallback: "just put on X" (E24). + """One entry on a user's comfort shelf — the anti-paralysis fallback: "just put on X". Deliberately NOT a boolean on `VideoState`/`PlexState`, and NOT a polymorphic `(kind, ref)` string pair. A flag cannot ORDER a shelf and cannot hold a channel or a show — and comfort media is diff --git a/backend/app/plex/cards.py b/backend/app/plex/cards.py index 620a964b..4571276f 100644 --- a/backend/app/plex/cards.py +++ b/backend/app/plex/cards.py @@ -25,7 +25,7 @@ def norm_lang(lang: str | None) -> str: return "hu" if lang == "hu" else "en" -# Every card speaks the reader's language (E30): `lang="hu"` serves the HU overlay twin's title and +# Every card speaks the reader's language: `lang="hu"` serves the HU overlay twin's title and # summary, falling back to EN where a row has none — the same rule as browse.unified_library's # `title_col`/`summary_col`. These rails once served English to a Hungarian reader. def display_title(row: PlexItem | PlexShow, lang: str) -> str: diff --git a/backend/app/plex/client.py b/backend/app/plex/client.py index ff83019e..38a698bd 100644 --- a/backend/app/plex/client.py +++ b/backend/app/plex/client.py @@ -88,14 +88,14 @@ class PlexClient: "type": item_type, # includeGuids=1 carries each item's Guid array (tmdb/imdb/tvdb ids) in the cheap # listing, so the sync can capture the canonical guid without a per-item rich fetch — - # the pairing key for the HU overlay (E5 S5). + # the pairing key for the HU overlay. "includeGuids": 1, "X-Plex-Container-Start": start, "X-Plex-Container-Size": size, }, ) # `totalSize` only: `size` is THIS page's count, and read as the total it made a single - # page look like the whole section — "complete" to a prune or a reconcile (E32 review). + # page look like the whole section — "complete" to a prune or a reconcile. total = mc.get("totalSize") return mc.get("Metadata", []) or [], None if total is None else int(total) @@ -142,7 +142,7 @@ class PlexClient: def machine_id(self) -> str: """Server machineIdentifier — needed in the `uri` of collection add/create calls. Cached. Without one the uri would read `server:///…`, a malformed write Plex answers in its own way; - it is refused here, by name (E32 S2).""" + it is refused here, by name.""" if not getattr(self, "_machine_id", None): mid = self._get("/identity").get("machineIdentifier") if not mid: diff --git a/backend/app/plex/detail.py b/backend/app/plex/detail.py index 7752b7d4..46ed2ac6 100644 --- a/backend/app/plex/detail.py +++ b/backend/app/plex/detail.py @@ -1,5 +1,5 @@ """Parse the "info page" rich metadata out of a full Plex metadata dict, and persist the subset that -the detail endpoints serve from the DB row (E5 S5b). +the detail endpoints serve from the DB row. Shared by the live info-page path (routes.plex.browse) and the background rich re-sync (plex.sync) so the two never diverge — the same one-parser rule that keeps `people.py`'s cast handling consistent. diff --git a/backend/app/plex/genres.py b/backend/app/plex/genres.py index 2b19cb4a..50dfc2b0 100644 --- a/backend/app/plex/genres.py +++ b/backend/app/plex/genres.py @@ -1,4 +1,4 @@ -"""One Hungarian word per Plex genre (E30). +"""One Hungarian word per Plex genre. Plex's Hungarian agent returns a MIXED vocabulary: some genres come back translated ("Dráma", "Vígjáték"), others stay English ("Action", "Adventure", "Biography"), and a title with no HU twin has diff --git a/backend/app/plex/imgcache.py b/backend/app/plex/imgcache.py index 8eed4e1b..bd7f34a0 100644 --- a/backend/app/plex/imgcache.py +++ b/backend/app/plex/imgcache.py @@ -26,7 +26,7 @@ _CT_EXT = {ct: ext for ext, ct in _EXT_CT} # Target box per variant — Plex resizes to these server-side. The full poster (400px) covers a detail # page's ≤224px poster at 2× DPR, the full art (1280px) a hero backdrop. A card is much smaller: a grid # poster is ~144px and a shelf's landscape art ~212px, so the `_sm` pair serves those at 1.33× DPR -# (E31 S4e: cards fetched the 400 poster, 42 KB, and the 1280 art for a 212px picture). The browser +# (cards used to fetch the 400 poster, 42 KB, and the 1280 art for a 212px picture). The browser # picks between a pair itself (`srcset`, built by the frontend's `lib/plexImage` cardImage, whose # widths plexImage.contract.test holds to these). SIZES = { diff --git a/backend/app/plex/overlay.py b/backend/app/plex/overlay.py index 7676a118..20118283 100644 --- a/backend/app/plex/overlay.py +++ b/backend/app/plex/overlay.py @@ -1,5 +1,5 @@ -"""The HU overlay: a Hungarian Plex section written onto its English twin's rows (E30), split out of -`plex/sync.py` (E32 S2), which runs it once per paired section during the full sync. +"""The HU overlay: a Hungarian Plex section written onto its English twin's rows, split out of +`plex/sync.py`, which runs it once per paired section during the full sync. The pairing is admin-configured (`plex_overlay_map`, ``huKey:enKey,...``). An overlay section creates no browsable rows of its own; it only fills the `*_hu` columns of the EN rows it matches by guid. @@ -41,7 +41,7 @@ def sync_overlay( """Write an overlay section's Hungarian metadata onto its EN twin's rows, matched by guid. Creates NO browsable rows — an overlay library is never listed on its own. Only title/summary/tagline/genres differ by language; the language-neutral people_text feeds both search vectors. A show section's - episodes are overlaid too (E30): the HU section lists every episode with its Hungarian title and + episodes are overlaid too: the HU section lists every episode with its Hungarian title and summary, and the episode rows live in the EN show library. This is a full RECONCILE: after a COMPLETE HU listing every guid-bearing EN row's HU fields are @@ -141,7 +141,7 @@ def _clear_hu(by_guid: dict, episodes: bool) -> None: cheap. The genres are the exception: every row gets Hungarian ones, from its HU twin or, lacking one, from its own English list — otherwise the HU genre list mixed "Drama" (no twin) with "Dráma". Only after a COMPLETE listing: one a Plex hiccup cut short would clear the HU text of every title - it failed to reach, until the next full sync (E32 S2, the watch reconcile's twin).""" + it failed to reach, until the next full sync (the watch reconcile's twin).""" for group in by_guid.values(): for row in group: row.title_hu = row.summary_hu = None diff --git a/backend/app/plex/people.py b/backend/app/plex/people.py index 84c65eb2..df161e9d 100644 --- a/backend/app/plex/people.py +++ b/backend/app/plex/people.py @@ -160,7 +160,7 @@ def people_by_credits( return [r[0] for r in rows] -# --- Portraits (E30 S6) --------------------------------------------------------------------------- +# --- Portraits --------------------------------------------------------------------------- # A Plex CDN headshot is a full-size original (~170 KB on average, measured). Plex's photo transcoder # fits it into this box (~13 KB), which covers the largest place a portrait is drawn (the person page's # 6rem avatar) at 2x density and a raised text size. The box is part of the cache key, like a poster's: @@ -226,7 +226,7 @@ _DEAD = frozenset({404, 410}) def warm_portraits(db: Session, limit: int) -> dict: - """Cache up to `limit` not-yet-cached headshots, the most-credited people first (E30 S6), so a + """Cache up to `limit` not-yet-cached headshots, the most-credited people first, so a person's face comes from local disk. Needs Plex (the resize) and the Plex module on; without them nothing is prefetched — full-size originals for every person would cost gigabytes. A headshot Plex answers "not found" for is marked and left for PORTRAIT_RETRY; Plex failing in a row ends the run.""" diff --git a/backend/app/plex/query.py b/backend/app/plex/query.py index 636d34f1..5cac29eb 100644 --- a/backend/app/plex/query.py +++ b/backend/app/plex/query.py @@ -31,9 +31,9 @@ PLEX_SORT_KEYS: tuple[str, ...] = ( "title", ) -# --- Language-aware column pickers (E5 S6) ---------------------------------- +# --- Language-aware column pickers ---------------------------------- # -# The HU-overlay twin (E5 S5) stores Hungarian metadata in `*_hu` columns on the SAME EN row. When +# The HU-overlay twin stores Hungarian metadata in `*_hu` columns on the SAME EN row. When # the app language is `hu` we serve those, falling back to the EN column where a row has no HU twin. # Two flavours of picker, on purpose: # * DISPLAY / FILTER (`title_col`/`genres_col`): `coalesce(*_hu, *)` — an EN fallback so a row with @@ -41,8 +41,8 @@ PLEX_SORT_KEYS: tuple[str, ...] = ( # HU summary directly off the loaded row, so no `summary_col` SQL helper is needed.) # * SEARCH (`vector_col` + `field_recheck`): the raw `*_hu` column coalesced to '' — MIRRORING the # generated `search_vector_hu` definition (which uses `coalesce(title_hu, '')`, NOT an EN -# fallback), so the field recheck stays a strict subset of the GIN vector prefilter (the S2 -# invariant). `people_text` is language-neutral and shared by both vectors. +# fallback), so the field recheck stays a strict subset of the GIN vector prefilter (the +# field-search invariant). `people_text` is language-neutral and shared by both vectors. def title_col(model, lang: str): @@ -216,7 +216,7 @@ def meta_filter_conds(model, p: dict, lang: str = "en") -> list: stds = split_csv(p.get("studios")) if stds: # OR conds.append(model.studio.in_(stds)) - for kw in split_csv(p.get("keywords")): # AND — TMDB keyword chips from the detail page (E5 S5) + for kw in split_csv(p.get("keywords")): # AND — TMDB keyword chips from the detail page conds.append(model.keywords.contains([kw])) csel = split_csv(p.get("collections")) if ( @@ -232,7 +232,7 @@ def apply_meta_filters(q, model, p: dict, lang: str = "en"): return q.where(*conds) if conds else q -# --- Playback-class filter (E7) — MOVIE-ONLY (a show is an aggregate with no single class) --------- +# --- Playback-class filter — MOVIE-ONLY (a show is an aggregate with no single class) --------- # The 4 classes the feed filter + facet expose, derived from the stored `playable` + `transcode_hw`. # ONE definition (this SQL CASE) so the filter WHERE and the facet counts can't drift: `playable_conds` # filters on it, `facets` groups by it. transcode_hw IS NOT True groups NULL (unprobed) with software. @@ -278,7 +278,7 @@ class LibraryFilters: actors: str | None = None actor_mode: str = "any" # how multiple actors combine: any (OR, default) | all (AND) studios: str | None = None - keywords: str | None = None # TMDB keyword chips (E5 S5), CSV; language-neutral + keywords: str | None = None # TMDB keyword chips, CSV; language-neutral collections: str | None = None # CSV of collection rating_keys collection_mode: str = "any" # how multiple collections combine: any (OR, default) | all (AND) playable: str | None = None # CSV of playback classes: direct|remux|transcode_hw|transcode_sw @@ -311,7 +311,7 @@ class LibraryFilters: # watch state. The SAME predicate has to be applied in two places — `unified_library` (the grid) and # `facets.wsq` (so the facet counts reflect exactly the visible set) — and they MUST stay identical: # a hidden movie kept out of the grid must not inflate the facets either. These helpers are the one -# home for that predicate so the two call sites can't drift (E3 S1; pinned by test_plex_facets_grid_ +# home for that predicate so the two call sites can't drift (pinned by test_plex_facets_grid_ # parity). Callers pass the already-joined state expressions, so this module stays model-agnostic: # * movies — `st` is an aliased PlexState outer-joined on (item_id, user_id). # * shows — `tot`/`wat`/`inp` are the coalesced totals from `rollup.agg_subquery`. @@ -367,9 +367,9 @@ def apply_show_state_filter(query, tot, wat, inp, show: str): def content_libs(db: Session) -> tuple[list[int], list[int]]: """The BROWSABLE content libraries, partitioned into (movie_lib_ids, show_lib_ids) — the single source of the browse scope. Excludes HU overlay libraries (`overlay_of` set): those are never - browsed on their own, they only lend their Hungarian metadata to the EN twin's rows (E5 S5). + browsed on their own, they only lend their Hungarian metadata to the EN twin's rows. - Lives here, in the query layer, rather than in `routes/plex/_shared.py` where it started: E24's + Lives here, in the query layer, rather than in `routes/plex/_shared.py` where it started: the Home surface needs the same scope, and a domain package importing from `routes/` would invert the one-way dependency CLAUDE.md draws. It never touched request state, so nothing was lost by the move.""" diff --git a/backend/app/plex/rollup.py b/backend/app/plex/rollup.py index 7358338b..40dd3f69 100644 --- a/backend/app/plex/rollup.py +++ b/backend/app/plex/rollup.py @@ -13,10 +13,10 @@ from app.models import PlexItem, PlexState # Sort sentinel for a rail row whose timestamp is missing, so `key=lambda r: r[0] or NO_TS` reads the # same at every rail that merges two type-homogeneous lists in Python. # -# It is NOT a crash fix, whatever the E24 commit that introduced it says. The `(ts is not None, ts)` +# It is NOT a crash fix, whatever the commit that introduced it says. The `(ts is not None, ts)` # key it replaced cannot raise: tuple comparison finds the first UNEQUAL element via `==`, so two # `(False, None)` keys compare equal and `None < None` is never reached — verified by execution -# during the E24 epic-end review, over every permutation of null/non-null rows. What the sentinel +# during a review, over every permutation of null/non-null rows. What the sentinel # buys is one idiom instead of two, and a key that says "missing sorts last" outright rather than # encoding it in a boolean flag. A BARE `r[0]` would genuinely raise, which is the mutation the # rails' tests do guard against. diff --git a/backend/app/plex/stream.py b/backend/app/plex/stream.py index f9ac6caa..daf89f94 100644 --- a/backend/app/plex/stream.py +++ b/backend/app/plex/stream.py @@ -15,7 +15,7 @@ HLS muxer does the segmentation (the only reliable way to cut a stream-copy at k Sessions live in this (single) API process; a periodic reaper kills idle ones and frees the temp segments. The remux is CPU-light (video copy + a tiny audio transcode), so it runs fine on the -CPU-only prod host; only P3's full transcode is CPU-heavy. +CPU-only prod host; only the full transcode is CPU-heavy. """ import os @@ -120,8 +120,8 @@ class HlsSession: # Unguessable per-session id, carried in the URLs the client is handed. A seek-restart # replaces the viewer's registered session for the item — and the old player's next request # for `seg_12.ts`, resolved by (viewer, rating_key) alone, was answered 200 with the NEW session's twelfth segment, produced from a different offset - # under `-copyts`. Wrong bytes, not an error, so the client's reclaim never fired on it - # (deep-review round 5). A request naming a session that is no longer registered is a 404, + # under `-copyts`. Wrong bytes, not an error, so the client's reclaim never fired on it. + # A request naming a session that is no longer registered is a 404, # which the player recovers from by restarting at the playhead. self.id = secrets.token_urlsafe(9) self.key = key # the item's rating_key (what the logs name) @@ -413,7 +413,7 @@ def _venc_opts( def _render_node_present() -> bool: """Whether the VAAPI render node is available — i.e. `/dev/dri` was passed into the container. Present on prod after the iGPU enablement; absent on the CPU-only dev box and in the localdev - Docker. Its own function so the selection is monkeypatchable in tests and one place for S1c's knob.""" + Docker. Its own function so the selection is monkeypatchable in tests and the one place to change it.""" return Path(_VAAPI_DEVICE).exists() @@ -576,7 +576,7 @@ def _ffmpeg_cmd( entry += ",default:yes" var.append(entry) # The name patterns come from the vocabulary functions, with ffmpeg's own placeholders passed - # through them — so the producer and the parser cannot spell the layout differently (round 7). + # through them — so the producer and the parser cannot spell the layout differently. args += _HLS_TAIL + [ "-master_pl_name", _MASTER_PL, @@ -887,7 +887,7 @@ def current_session(user_id: int, key: str) -> HlsSession | None: # The names ffmpeg writes into a session directory, in ONE place — and "one place" means the argv # BUILDS its patterns from these same functions (`segment_name("%d", "%v")`), not that a comment # says so. Getting this wrong is invisible in both directions: a name we failed to anticipate used -# to fall back to "wait and see" (round 6's HIGH), and now that the rule is positive it falls back +# to fall back to "wait and see", and now that the rule is positive it falls back # to a 404 instead — so the producer and the parser have to be literally the same spelling. # # MEASURED (ffmpeg 7.1.5 in the API container — the binary that actually writes these files — and @@ -901,7 +901,7 @@ def current_session(user_id: int, key: str) -> HlsSession | None: # SESSION and not to HLS in general: a single-audio session can never write `master.m3u8`, # `stream_*.m3u8` or any `seg__.ts`, and a multi-audio one can never write `index.m3u8` # or `seg_.ts`. Accepting the union meant a hopeless name of the other shape was waited out for -# the full playlist/segment timeout — the threadpool parking this guard exists to end (round 7). +# the full playlist/segment timeout — the threadpool parking this guard exists to end. _MASTER_PL = "master.m3u8" _INDEX_PL = "index.m3u8" _SEG_NAME_RE = re.compile(r"^seg_(?:([0-9a-z]{1,4})_)?(\d{1,9})\.ts$") @@ -953,12 +953,12 @@ def artifact_may_arrive(s: "HlsSession", filename: str) -> bool: name belonging to the other shape is as hopeless here as a random string. Reading those as "an HLS name, so wait and see" left the whole class reachable — measured through the real route against a live single-audio session, `master.m3u8` and `stream_q7z.m3u8` were 404s after 30.0 s - and `seg_a0_0.ts` / `seg_zzzz_0.ts` after 20.1 s each (deep-review round 7). + and `seg_a0_0.ts` / `seg_zzzz_0.ts` after 20.1 s each. Saying no at once matters because the route that waits is a SYNC one: it holds a thread off the ~40-slot pool, and a pooled DB connection with it, for the whole timeout. An ordinary seek past the produced region was enough to park one for 20 s, and forty concurrent requests for a name - that will never exist parked the whole API (deep-review round 5). Round 5's guard read only + that will never exist parked the whole API. An earlier guard read only single-audio segment names, so both halves of that were still fully reachable: every multi-audio name fell through, and so did any `.m3u8`/`.vtt` name at all.""" if filename in playlist_names(s.variants): @@ -1093,15 +1093,15 @@ def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int: A finished session is also what a PAUSED viewer has: a remux runs far faster than realtime, so ffmpeg is done a minute or two in and writes ENDLIST; hls.js then stops asking for the playlist, and with its forward buffer full a paused player fetches nothing at all. With the 30 s rule that - killed a one-minute pause, and playback died ~30 s after resuming (deep-review round 4). + killed a one-minute pause, and playback died ~30 s after resuming. What makes the short rule safe again is that fetching is no longer the only signal: a player parked MID-ITEM checkpoints its position every 10 s, and that route calls `touch_session`, so a - viewer who is merely paused says so far more often than this rule asks (round 5). The heartbeat + viewer who is merely paused says so far more often than this rule asks. The heartbeat stops once the item is finished or before a position is known (`usePlexProgress.flush`) — both states where reaping is correct, and where the player's 404 → reclaim recovers a viewer who - scrubs back in anyway. Do not restate this as "every 10 s whether or not it is playing": round 6 - found that claim in two comments and not in the code. Round 4's blanket + scrubs back in anyway. Do not restate this as "every 10 s whether or not it is playing": + a review found that claim in two comments and not in the code. An earlier blanket `reap_abandoned` bought the same safety by keeping every finished session for ten minutes — each one a whole segmented copy of the film on a volume shared with the download center.""" now = time.time() diff --git a/backend/app/plex/sync.py b/backend/app/plex/sync.py index c7712cad..9046430a 100644 --- a/backend/app/plex/sync.py +++ b/backend/app/plex/sync.py @@ -12,7 +12,7 @@ on a NON-empty listing — a section that returns zero items is left untouched, glitch that yields an empty page can't wipe a library (the metadata is cheap to re-sync; an accidental mass-delete is the only real risk). Library-level reconcile (dropping a whole removed section, the dead `enabled` column) is a separate follow-up. Cast + intro/credit markers are fetched lazily on the -item-detail/player path (P2), not here. +item-detail/player path, not here. """ from datetime import datetime, timezone @@ -150,7 +150,7 @@ _GUID_PREFERENCE = ("tmdb", "imdb", "tvdb") def _canonical_guid(meta: dict) -> str | None: - """The canonical external id used to pair an EN title with its HU-overlay twin (E5 S5). Reads the + """The canonical external id used to pair an EN title with its HU-overlay twin. Reads the `Guid` array (present only with includeGuids=1 in the listing), preferring tmdb > imdb > tvdb, normalised to ':' (e.g. 'tmdb:12345'). None when no known provider id is present.""" found: dict[str, str] = {} @@ -419,7 +419,7 @@ def _paginate(plex: PlexClient, key: str, item_type: int, progress: dict | None # A truthy `total` we reached = a trustworthy complete listing. `total` unknown/zero-with-rows # (a Plex that doesn't report totalSize) is treated as NOT complete, so prune stays conservative. # `total` itself is kept for a caller to whom a server that SAID 0 is news it can trust (the - # watch reconcile); an empty mirror or overlay must not read it so (E32 S2). + # watch reconcile); an empty mirror or overlay must not read it so. progress["complete"] = bool(total) and start >= total progress["total"] = total @@ -447,7 +447,7 @@ def _apply_facet_fields(row, meta: dict) -> None: # full cast (up to 24, grown by the rich backfill/info-page enrich), silently breaking the # person-page cast filter for names past the top ~20. The watermark gate below (`people_enriched_at # is None`) normally protects this, but it is NOT enough on its own: a migration that resets the - # watermark re-opens the regression (0065 nulled it, which is exactly how the S5b fix got undone in + # watermark re-opens the regression (0065 nulled it, which is exactly how a fix got undone in # prod). So guard STRUCTURALLY too, by LENGTH against the listing cap: a stored cast LONGER than the # cap can only be an enriched fuller list, so keep it (robust to any watermark reset). A stored cast # at or below the cap is itself listing-sourced, so take the fresh listing — which tracks Plex cast @@ -694,7 +694,7 @@ def resync_collection(db: Session, plex: PlexClient, lib: PlexLibrary, rk: str) def _believed_children(plex: PlexClient, rk: str, meta: dict) -> list[dict] | None: """A collection's members, or None when the answer is not believed: no members from a collection whose own listing counts some (`childCount`) is a glitch, not a collection emptied — the same - empty-answer rule as the collection listing's (E32 review). Rebuilding on it showed the + empty-answer rule as the collection listing's. Rebuilding on it showed the collection empty until the next sync.""" children = plex.collection_children(rk) count = str(meta.get("childCount") or "0") @@ -745,7 +745,7 @@ def _sync_collections(db: Session, plex: PlexClient, lib: PlexLibrary, stats: di if not cols and existing: # The catalog's own rule (see the module doc): an empty listing while we hold rows is a Plex # glitch, not a library emptied of collections. Reconciling on it dropped every collection - # (its user-set `editable` flag with it) and every member's keys (E32 S2). + # (its user-set `editable` flag with it) and every member's keys. log.warning( "Plex listed no collections for a library that has some; keeping them this run", extra=logs.event("plex.sync.collections_empty", library=lib.plex_key), diff --git a/backend/app/plex/tmdb.py b/backend/app/plex/tmdb.py index 0f0896c9..ad2d9642 100644 --- a/backend/app/plex/tmdb.py +++ b/backend/app/plex/tmdb.py @@ -1,4 +1,4 @@ -"""TMDB enrichment for the mirrored Plex catalog (E5 S2). +"""TMDB enrichment for the mirrored Plex catalog. A thin synchronous TMDB v3 client plus the parse/persist of the enrichment fields onto plex_items / plex_shows, matched by the stored canonical `guid` ("tmdb:12345", or imdb/tvdb resolved via /find). @@ -134,7 +134,7 @@ class TmdbClient: return cast("list[dict] | None", self._get("/configuration/countries")) def fetch_person(self, person_id: int) -> dict | None: - """One person's bio + combined credits (E5 S6) + the bio's translations (E30 S4), in a single + """One person's bio + combined credits + the bio's translations, in a single append_to_response request.""" return self._get( f"/person/{person_id}", {"append_to_response": "combined_credits,translations"} @@ -226,7 +226,7 @@ def parse_tmdb(payload: dict, media_type: str) -> dict: crew.sort(key=lambda c: priority.get(c["job"], 99)) crew = crew[:12] - # name -> tmdb person id, for the plex_people foundation (S6 fetches bios off these ids). + # name -> tmdb person id, for the plex_people foundation (bios are fetched off these ids). people: list[dict] = [] for c in (credits.get("cast") or [])[:30]: if c.get("name") and c.get("id"): @@ -266,7 +266,7 @@ def parse_tmdb(payload: dict, media_type: str) -> dict: c.get("name") for c in (payload.get("production_companies") or []) if c.get("name") ][:8] or None, - # The ISO code beside TMDB's English name (E30): the client names the country in the reader's + # The ISO code beside TMDB's English name: the client names the country in the reader's # language from the code; the name is the fallback for a code it cannot name. "production_countries": [ {"code": c.get("iso_3166_1") or None, "name": c["name"]} @@ -373,14 +373,14 @@ def enrich_batch(db: Session, tmdb: TmdbClient, limit: int) -> int: def _country(c: dict | str) -> dict: - """One stored production country as served: `{code, name}`. A row enriched before E30 stored the + """One stored production country as served: `{code, name}`. An older row stored the bare English name; until `backfill_country_codes` reaches it, it serves with no code (the client shows the name).""" return c if isinstance(c, dict) else {"code": None, "name": c} def backfill_country_codes(db: Session, tmdb: TmdbClient) -> int: - """Give the countries stored before E30 (bare English names) their ISO codes, from TMDB's own + """Give the older countries (bare English names) their ISO codes, from TMDB's own country list — the names came from that list, so they match it exactly. One request, and only while such a row is left: re-fetching each title instead would take the enrichment batch days. A name the list does not hold keeps no code. Returns the number of rows rewritten.""" @@ -418,7 +418,7 @@ def run_enrich(db: Session) -> dict: def run_people_enrich(db: Session) -> dict: - """Scheduler entry point of people_enrich (E30 S6): a batch of person bios (the most-credited + """Scheduler entry point of people_enrich: a batch of person bios (the most-credited first) and a batch of portraits. Each half is a no-op — never an error — without its TMDB key / without Plex.""" batch = settings.people_enrich_batch @@ -475,7 +475,7 @@ def resolve_recs(db: Session, recs: list[dict] | None, *, lang: str) -> list[dic """Tag each recommendation with whether it's in OUR library (matched by guid `tmdb:`, bucketed by media_type — see resolve_library_guids) and, if so, its rating_key — so the client can deep-link the ones we have and merely display the rest. One we have is named as the library names it, in - `lang` (E30); one we lack keeps the title TMDB gave.""" + `lang`; one we lack keeps the title TMDB gave.""" if not recs: return [] movies, shows = resolve_library_guids(db, recs) @@ -515,7 +515,7 @@ def serve_block(db: Session, row, *, lang: str) -> dict | None: } -# --- Person enrichment (E5 S6) ------------------------------------------------------------------- +# --- Person enrichment ------------------------------------------------------------------- # A person's bio + birth facts + a "Known for" credit pool, fetched once — by the people_enrich job, or # by the first person-page open if the job has not reached them yet — and served from the plex_people row. `known_for` stores the top combined-credits ids; the person # endpoint resolves them to library rows by guid at serve time (like recommendations). @@ -525,7 +525,7 @@ def serve_block(db: Session, row, *, lang: str) -> dict | None: # credits are often not the 15 a given library holds. _KNOWN_FOR_MAX = 40 # TMDB genre ids of the shows a person visits as a guest (News, Reality, Talk). Ranked by popularity, -# talk-show visits crowded out the films (Tom Cruise: 14 of 15 were talk shows; E30 S6). +# talk-show visits crowded out the films (Tom Cruise: 14 of 15 were talk shows). _GUEST_GENRES = {10763, 10764, 10767} # A credit in which the person plays themselves ("Self", "Himself - Guest", "Herself (archive footage)"). _SELF_CREDIT = re.compile(r"^\s*(self|himself|herself|themselves)\b", re.IGNORECASE) @@ -646,7 +646,7 @@ _PEOPLE_FAILS_IN_A_ROW = 3 def enrich_people_batch(db: Session, tmdb: TmdbClient, limit: int) -> dict: - """Fetch up to `limit` linked, not-yet-enriched people (E30 S6), the most-credited first, so a + """Fetch up to `limit` linked, not-yet-enriched people, the most-credited first, so a person page reads its bio from the row instead of waiting on TMDB. Each person is committed as it lands: a page open during the run already sees it. A failed lookup is skipped (left unstamped for the next run); _PEOPLE_FAILS_IN_A_ROW of them in a row end the batch.""" diff --git a/backend/app/plex/watch_sync.py b/backend/app/plex/watch_sync.py index a632b3bb..6a00db2f 100644 --- a/backend/app/plex/watch_sync.py +++ b/backend/app/plex/watch_sync.py @@ -11,7 +11,7 @@ already pages) and upsert them into the owner's `plex_states`. Plex wins on over exist only in Siftlode are left untouched (union, Plex-authoritative on the intersection). Phases B (Siftlode→Plex push via scrobble/timeline) and C (incremental Plex→Siftlode via the watch -history + onDeck) build on this and land in later ships. As of S2 Plex is the source of truth: the +history + onDeck) build on this and land in later ships. Plex is now the source of truth: the player pushes the resume position back to Plex on the periodic checkpoint (not just the final flush), and the incremental pull resolves a genuine divergence to Plex's state (see `_pull_apply`) rather than running a last-write-wins comparison. @@ -81,7 +81,7 @@ def _scan_plex_states( The settled keys matter to the reconcile alone: it reads "absent from the scan" as "Plex un-watched it", which a walk cut short by a Plex hiccup does not mean (see `_paginate`). Settled is decided PER SECTION: one section that never settles (a library emptied on Plex while its - mirrored items are kept) must not stop un-watches syncing in all the others (E32 review).""" + mirrored items are kept) must not stop un-watches syncing in all the others.""" item_id_by_rk = _rk_to_id(db) wanted = _enabled_section_keys(db) # The sections we mirror something from: a total of 0 from one of those contradicts the mirror. @@ -117,7 +117,7 @@ def _scan_plex_states( out[item_id] = res # A section the server SAYS is empty (total 0, nothing listed) settles too. Believed only for # a section we mirror nothing from: one whose items we hold answering 0 is a Plex that lost - # the library for a moment, not one that un-watched all of it (E32 review) — so it is left + # the library for a moment, not one that un-watched all of it — so it is left # unsettled, and its rows are not cleared. listed = scanned > before said_empty = progress.get("total") == 0 and not listed and key not in mirrored @@ -402,7 +402,7 @@ def _pull_apply( if st is not None and not st.synced_to_plex: st.synced_to_plex = True # already matches Plex → just settle the flag, no churn return None - # Plex-authoritative (S2): on a genuine divergence for an item Plex knows about, Plex wins — no + # Plex-authoritative: on a genuine divergence for an item Plex knows about, Plex wins — no # last-write-wins tug-of-war. This is safe because prompt write-back (playback.py pushes the resume # position to Plex on the ~10s checkpoint, not just on the final flush) keeps Plex current, so a # local edit that's "newer" than Plex has almost always already been pushed and matched above by @@ -620,9 +620,8 @@ def _clear_unwatched(db: Session, user_id: int, plex_states: dict, settled: set[ """Clear the user's rows Plex no longer has a record for — the mirrored ones (Plex un-watched them); hidden and dirty rows stay (a dirty one is re-pushed by `_repush_dirty`). Only rows of the sections the scan SETTLED: a walk a Plex hiccup cut short leaves out rows it merely failed to - reach, and clearing those un-watched them here until the next complete run (E32 S2); an item of - a library the allowlist leaves out is absent from every scan, and was cleared on every run (E32 - review).""" + reach, and clearing those un-watched them here until the next complete run; an item of + a library the allowlist leaves out is absent from every scan, and was cleared on every run.""" n = 0 rows = db.execute( select(PlexState) diff --git a/backend/app/progress.py b/backend/app/progress.py index 98126e7b..7aa3b4c3 100644 --- a/backend/app/progress.py +++ b/backend/app/progress.py @@ -25,7 +25,7 @@ def report(current: int, total: int | None = None, phase: str | None = None) -> class JobFailed(RuntimeError): - """A job's own failure, said so the reader's language can name it (E31): the client reads + """A job's own failure, said so the reader's language can name it: the client reads `scheduler.failure.` and interpolates `params` (keep them to values the reader may see — numbers, ids, short codes). The message is the English line the log and `last_error` keep. An upstream's own error (a timeout, a refused connection) is not one of these: it stays as raised, diff --git a/backend/app/routes/_common.py b/backend/app/routes/_common.py index 0fe3ff64..9776427a 100644 --- a/backend/app/routes/_common.py +++ b/backend/app/routes/_common.py @@ -1,4 +1,4 @@ -"""Route-layer shared helpers for the API contract (R6 S1a). +"""Route-layer shared helpers for the API contract. Small, dependency-light guards that several route modules had hand-rolled with subtle drift. Keeping them here (imported by the route modules) means the "fetch a row or 404" @@ -128,7 +128,7 @@ async def capped_stream_to( def issue_refused(exc: gitea.GiteaError) -> HTTPException: - """The refusal an issue-tracker failure (E16) answers as — the send, the preview and the + """The refusal an issue-tracker failure answers as — the send, the preview and the admin's connection test alike. Literal reasons, one per case: the frontend's contract test reads them to require a sentence for each in both languages.""" match exc.reason: diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index 76187fbd..d083a959 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -138,20 +138,21 @@ def _activate_user_by_email(db: Session, email: str) -> None: """Approving access activates a matching (e.g. pending password-registration) account so it can sign in. No-op if no such user exists yet (a Google user is created at first login). - E9.5 S1: this path deliberately does NOT revoke a preset password. Revoking here destroyed the + This path deliberately does NOT revoke a preset password. Revoking here destroyed the password a legitimate registrant had just chosen and made "your password was cleared for security" the DEFAULT experience, because approve-before-verify is the normal ordering. - S1 justified that by saying `password_login` refuses an unverified account BEFORE it looks at - `is_active`, so a pre-emptor could not sign in "approved or not". A review of the finished epic - showed that argument only holds while the address stays unverified — and S6's approval mail - hands the real owner a confirmation link precisely so they will end that state. Nothing replaced - the revoke, and that is deliberate: round 2 of the same review tried "the newest registration - owns an unverified account" and reverted it, because the rule is symmetric — an attacker can - always register last — and it handed anonymous callers a write onto a real account's credential. - The owner's recourse is password-reset, which proves the mailbox, rewrites the hash and bumps - the session epoch. The residual is logged as SEC-16. The revoke also stays on the Google-adoption path - (auth.revoke_preset_password_if_unverified), where ownership is actually attested.""" + That was once justified by saying `password_login` refuses an unverified account BEFORE it + looks at `is_active`, so a pre-emptor could not sign in "approved or not". A review of the + finished epic showed that argument only holds while the address stays unverified — and the + approval mail hands the real owner a confirmation link precisely so they will end that state. + Nothing replaced the revoke, and that is deliberate: the same review later tried "the newest + registration owns an unverified account" and reverted it, because the rule is symmetric — an + attacker can always register last — and it handed anonymous callers a write onto a real + account's credential. The owner's recourse is password-reset, which proves the mailbox, rewrites + the hash and bumps the session epoch. The residual is logged as SEC-16. The revoke also stays on + the Google-adoption path (auth.revoke_preset_password_if_unverified), where ownership is + actually attested.""" # Canonical, with the literal spelling preferred when both exist: approving # `npeter83.temu@gmail.com` must activate the account that registered as `npeter83temu@gmail.com`, # since Gmail delivers both to one mailbox and the admin decided about the mailbox. @@ -402,7 +403,7 @@ def _serialize_user(u: User) -> dict: # deciding what kind of help someone needs. "password_revoked_at": iso(u.password_revoked_at), "has_google": u.google_sub is not None, - # Only when it DIFFERS from the account address (E9.5 S3): "connected" is not the same + # Only when it DIFFERS from the account address: "connected" is not the same # statement as "connected as someone else", and the second one is the one that explains a # confusing sign-in. "google_email": u.google_email if u.google_email != u.email else None, @@ -551,7 +552,7 @@ def admin_delete_user( return {"deleted": user_id} -# --- Stuck sign-ups: the admin lifeline (E9.5 S5, roadmap UX-25) --------------------------------- +# --- Stuck sign-ups: the admin lifeline ----------------------------------------------------------- def _pending_access_rows(db: Session) -> list[User]: @@ -609,7 +610,7 @@ def user_access( _: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: - """Everything the account panel shows about why this person can or cannot get in (E12 S5). + """Everything the account panel shows about why this person can or cannot get in. Its own endpoint rather than extra columns on /users: `is_allowed` and `can_activate` each run a query, and the panel opens for ONE user at a time — folding them into the list would pay that cost @@ -628,7 +629,7 @@ def create_set_password_link( admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: - """Mint a one-time link that lets this account set a password, with no email involved (E12 S5). + """Mint a one-time link that lets this account set a password, with no email involved. The answer to SEC-15, SEC-16 and "no SMTP and someone forgot their password" at once. The admin hands the link over by whatever channel they trust; opening it sets a password and signs out diff --git a/backend/app/routes/admin_logs.py b/backend/app/routes/admin_logs.py index 07b4c44f..8202cafd 100644 --- a/backend/app/routes/admin_logs.py +++ b/backend/app/routes/admin_logs.py @@ -1,4 +1,4 @@ -"""The admin diagnostics console's read side (E22.6): query `log_events`, the server's stored log. +"""The admin diagnostics console's read side: query `log_events`, the server's stored log. Admin-only and read-only — rows leave the table through the retention job (`log_retention_days`), never through here. Reading is not audited: it changes nothing, and the console's live tail polls, diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index db7ec50f..0c8d587f 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -1,6 +1,6 @@ """Auth HTTP endpoints (OAuth login/callback, register/verify, password, session mgmt). -Split out of app/auth.py (R12 S2); the core auth surface (current_user, scopes, token +Split out of app/auth.py; the core auth surface (current_user, scopes, token helpers) stays in app/auth.py, imported below.""" import secrets @@ -53,7 +53,7 @@ VERIFY_TTL = timedelta(hours=24) # in and sets no password. APPROVAL_VERIFY_TTL = timedelta(days=7) RESET_TTL = timedelta(hours=1) -# An admin-issued set-password link (E12 S5) travels out of band — the admin has to actually reach the +# An admin-issued set-password link travels out of band — the admin has to actually reach the # person and hand it over — so the one-hour reset window is far too short. A day matches VERIFY_TTL and # the affordance the admin was shown. ADMIN_SETPW_TTL = timedelta(hours=24) @@ -163,7 +163,7 @@ class InviteRequest(NamedTuple): `invite` is the row the request now lives under — which is NOT always the address that was just typed: one Gmail mailbox spelled two ways settles on whichever row already exists, and that row's - address is where mail will go. Callers that tell the user what happened need it (E9.5 S4). + address is where mail will go. Callers that tell the user what happened need it. `is_new` is the old boolean return in a name: true only when a row was created or a denied one reopened, i.e. exactly when admins should hear about it. A repeat request must not re-spam them. """ @@ -188,7 +188,7 @@ def upsert_pending_invite(db: Session, email: str, lang: str | None = None) -> I The commit contract is unchanged: this still commits ONLY when it writes something. Callers rely on that — `_register_account` persists its own flushed user precisely because a no-op here - commits nothing (E9.5 S6). + commits nothing. """ email = (email or "").lower() if not valid_email(email): @@ -245,7 +245,7 @@ def _access_notice_url(kind: str, email: str = "", inv: Invite | None = None) -> It rides the FRAGMENT, not the query string, for the same reason the verify/reset tokens do (SB3): a fragment never reaches the server, a proxy access log or a Referer header — and this - notice now carries the user's address. Naming it is the point of E9.5 S4: on 2026-09-03 a + notice now carries the user's address. Naming it is the point: on 2026-09-03 a registration as `npeter83.temu@` met an OAuth claim of `npeter83temu@`, and a screen that said "request recorded" without saying FOR WHAT hid the mismatch for a day. @@ -297,9 +297,9 @@ class EmailPasswordIn(BaseModel): password: str | None = None # The language the sign-up page was in. Captured at registration because it is the ONLY moment # the choice exists anywhere the server can see: it lives in the browser until an account does, - # and a preference nobody recorded cannot be backfilled later (the lesson `google_email` taught - # in E9.5 S3 — those rows still read NULL until each user signs in again). Nothing reads it yet; - # the bilingual mail templates are E9.5 S7. + # and a preference nobody recorded cannot be backfilled later (as `google_email` showed: those + # rows still read NULL until each user signs in again). Nothing reads it yet; the bilingual + # mail templates will. lang: str | None = None @@ -510,7 +510,7 @@ def _sync_google_email(db: Session, user: User, email: str) -> None: of the spelling they chose, because sign-in matches the stored address first. Google returns the account's PRIMARY address, which need not be what they registered with — and can never contain a `+` at all — so without this guard every plus-tagged registration lost its tag on the first Google - sign-in. (E9.5 S3 made that reachable by matching adoption across spellings; the rewrite itself is + sign-in. (Matching adoption across spellings made that reachable; the rewrite itself is older.) Never overwrite with an address another account already owns either: that would hit the unique @@ -722,7 +722,7 @@ def _consume_password_token(db: Session, raw: str | None) -> tuple[User, str] | def issue_set_password_link(db: Session, user: User) -> tuple[str, datetime]: - """An admin-issued, single-use link that lets `user` set a password without any email (E12 S5). + """An admin-issued, single-use link that lets `user` set a password without any email. One mechanism answers three things: SEC-15 (on a no-SMTP instance every registration is auto-verified, so a pre-empted address cannot be recovered by mail), SEC-16 (a pre-empted address @@ -804,10 +804,10 @@ def _register_account(email: str, password: str, lang: str | None = None) -> Non # for the same input — so it is not a new capability, just the end of a dead end. An # account that is already confirmed still gets nothing: there is nothing to confirm. # - # And a re-send is ALL it does: the submitted password is discarded. E9.5's closing + # And a re-send is ALL it does: the submitted password is discarded. A closing # review tried "while the address is unverified, the newest registration owns it" # (replace the hash, kill the outstanding verify tokens) as a fix for address - # pre-emption, and round 2 showed it was worse than the problem — DO NOT REPEAT IT. + # pre-emption, and it proved worse than the problem — DO NOT REPEAT IT. # The rule is symmetric, and an attacker can always be last: it hands an unauthenticated # caller the power to overwrite a real registrant's chosen password and delete the # confirmation link already in their inbox, and on a SEC-10-stripped row (password_hash @@ -866,7 +866,7 @@ def _register_account(email: str, password: str, lang: str | None = None) -> Non def issue_verification_link(db: Session, user: User, ttl: timedelta | None = None) -> str: """A fresh confirmation link for `user`, returned rather than sent — for a mail that carries the - link inside a larger message (the branching approval/invitation mails, E9.5 S6). + link inside a larger message (the branching approval/invitation mails). Same token machinery and the same fragment (SB3) as the standalone confirmation mail, so the two kinds of link cannot drift apart in shape or in how they are consumed.""" @@ -881,8 +881,8 @@ def send_verification_link(db: Session, user: User) -> bool: address is the one its owner chose to be reachable at, and a `+tag` there is usually deliberate. The token rides the URL FRAGMENT (#) so it can never reach a proxy/access log or a Referer (SB3). - One definition, three callers — registration, the self-service resend, and the admin-side resend - (E9.5 S5). The link's shape and TTL must not drift between them; a verification mail that differs + One definition, three callers — registration, the self-service resend, and the admin-side resend. + The link's shape and TTL must not drift between them; a verification mail that differs by who triggered it is a bug waiting to be discovered by exactly one user. Returns whether the mail actually went out, so a caller with a human waiting on the answer (the @@ -905,8 +905,8 @@ def verify_email_confirm(body: TokenIn, db: Session = Depends(get_db)) -> dict: dropped the password this account registered with, so sign-in would answer with the deliberately uniform "Invalid email or password" — true, but unreadable to the person who just proved they own the mailbox. Saying so leaks nothing: holding a valid single-use verify token for this account - already proves that much. Since E9.5 S1 the common source of that state is gone (admin approval no - longer revokes), leaving accounts stripped before that change and the Google-adoption path, so this + already proves that much. The common source of that state is gone (admin approval no longer + revokes), leaving accounts stripped before that change and the Google-adoption path, so this flag is rare now — but it must stay, because those users have no other way back in.""" user = _consume_token(db, body.token, "verify") if user is None: @@ -966,7 +966,7 @@ def _login_refused(status: int, reason: str, user: User | None = None, **extra) that proof (`invalid`, `rate_limited`) carry nothing at all, so they stay byte-identical for a real account and an unknown one. - Since E9.5 S1 the two gates are order-independent, so `approved` really can be true while + The two gates are order-independent, so `approved` really can be true while `email_verified` is false, and vice versa — the SPA renders whichever is actually outstanding rather than assuming a sequence. """ @@ -1145,21 +1145,21 @@ def revoke_preset_password_if_unverified(user: User) -> None: session it minted; the owner re-sets one via password-reset. No-op for an already-verified account, so a legit no-SMTP registration is unaffected. - The Google-adoption callback is the ONLY caller (E9.5 S1). Admin approval used to call this too, and - dropping it was right for the reason S1 gave — the revoke destroyed the password a legitimate + The Google-adoption callback is the ONLY caller. Admin approval used to call this too, and + dropping it was right for the reason given — the revoke destroyed the password a legitimate registrant had just chosen, and since the admin normally decides before the user reads their mail, that made the "your password was cleared" detour the DEFAULT path rather than an edge case. - S1 also claimed the revoke was REDUNDANT there, because `password_login` refuses an unverified + The revoke was also called REDUNDANT there, because `password_login` refuses an unverified account before it reads `is_active`. That part was wrong, and the epic's own review caught it: the - gate holds only while the address is unverified, and S6's approval mail exists to get the real + gate holds only while the address is unverified, and the approval mail exists to get the real owner to end that state — at which point a pre-emptor's password works. Nothing replaced the revoke, and that is deliberate. The review tried "the newest registration owns an unverified account" and reverted it: the rule is symmetric, so an attacker can simply register last, and it would have handed anonymous callers a write onto a real account's credential. What the owner actually has is password-reset, which proves the mailbox, rewrites the hash and bumps the epoch. The residual is logged as SEC-16; re-adding a caller here would put "your password was cleared" - back in front of every legitimate registrant, which is the whole reason S1 removed it. + back in front of every legitimate registrant, which is the whole reason it was removed. `password_revoked_at` is what makes "the owner re-sets one via password-reset" actually true. The revoke leaves `password_hash IS NULL`, which is also what a Google-only account looks like — and diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index 8f9923f5..e30ffcaa 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -67,8 +67,8 @@ def list_channels(user: User = Depends(current_user), db: Session = Depends(get_ # Per-channel aggregates over the shared catalog (channel_aggregates), computed on read rather than # denormalised: an Index Only Scan over a ~20 MB index, where it used to read every video's heap page - # (681 MB on prod, a 2–6 s first open once something else had pushed it out of memory, E31 S4d). - # (The Normal/Shorts/Live breakdown moved to the channel-detail page in R14, so it is no longer + # (681 MB on prod, a 2–6 s first open once something else had pushed it out of memory). + # (The Normal/Shorts/Live breakdown moved to the channel-detail page, so it is no longer # computed here — the manager table dropped its N/S/L column.) channel_ids = [c.id for _, c in rows] agg: dict[str, dict] = {} @@ -276,7 +276,7 @@ def _channel_detail_dict( "explored": explored, "blocked": blocked, "stored_videos": stored, - # Per-type breakdown (Normal / Shorts / Live) — R14 moved it off the manager table onto the + # Per-type breakdown (Normal / Shorts / Live) — moved off the manager table onto the # channel page. Same aggregation the manager list uses (count_normal = stored − shorts − live). "count_normal": count_normal, "count_short": count_short, @@ -349,7 +349,7 @@ def channel_detail( ) blocked = _is_blocked(db, user, channel_id) # Stored total + the Normal/Shorts/Live split in one grouped pass (same filters as the manager - # list), so the channel page can show the per-type breakdown R14 moved off the table. + # list), so the channel page can show the per-type breakdown that moved off the table. n_all, n_short, n_live = db.execute( select( func.count(Video.id), diff --git a/backend/app/routes/config.py b/backend/app/routes/config.py index e5d82112..e4425ebb 100644 --- a/backend/app/routes/config.py +++ b/backend/app/routes/config.py @@ -139,7 +139,7 @@ def test_issue_tracker( _: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: - """Check the configured issue tracker (E16): the repository answers with this token, takes + """Check the configured issue tracker: the repository answers with this token, takes issues, and is PRIVATE — the one it must be, since an issue carries its reporter's log.""" try: return {"ok": True, **gitea.check(db)} diff --git a/backend/app/routes/debug_sessions.py b/backend/app/routes/debug_sessions.py index c1dd97c3..3154d149 100644 --- a/backend/app/routes/debug_sessions.py +++ b/backend/app/routes/debug_sessions.py @@ -1,4 +1,4 @@ -"""Debug sessions (E16): Start → reproduce → Stop, the screenshots, and the evidence under review. +"""Debug sessions: Start → reproduce → Stop, the screenshots, and the evidence under review. The session itself — what tags a request, its cap, its level — is `app/debugsession.py`. These routes are the user's handle on their OWN sessions only: another user's id 404s (`owned_or_404`), including @@ -181,7 +181,7 @@ def export_session( ) -# --- the issue tracker (S4) ----------------------------------------------------------------- +# --- the issue tracker ---------------------------------------------------------------------- # Issues a user may open per hour — counted once the tracker is ASKED to open one (an answer that # goes missing may still have created it, so an attempt cannot be given back); finishing a half-sent # one, or an attempt refused before the tracker was asked, is not counted. The composer sends one at diff --git a/backend/app/routes/diagnostics.py b/backend/app/routes/diagnostics.py index 8a7d4ddf..78c881fc 100644 --- a/backend/app/routes/diagnostics.py +++ b/backend/app/routes/diagnostics.py @@ -1,4 +1,4 @@ -"""The browser's log, sent while its user has a raised diagnostics level (E22 S5). +"""The browser's log, sent while its user has a raised diagnostics level. The client keeps its log in memory (frontend `lib/log.ts`). Only after the user consented to Részletes or Teljes in Settings → Diagnosztika does it stream that log here, in batches, and each batch lands in @@ -14,7 +14,7 @@ Nothing from the body is trusted as-is: modified client would not; * an event below the consented level is dropped (a Részletes consent does not store what only Teljes may carry), and so is the whole batch once the level has lapsed — where an open debug - session the request names counts as consent to ITS level (E16), and only its own events keep + session the request names counts as consent to ITS level, and only its own events keep its tag; * the timestamp is clamped to the consent window, so a row cannot dodge the retention job by claiming to come from the future. @@ -63,7 +63,7 @@ class ClientEvent(BaseModel): attrs: dict[str, Any] | None = None request_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9._-]{8,64}$") tab_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{1,32}$") - # The debug session the event was captured in (E16); kept only when it is the one this request + # The debug session the event was captured in; kept only when it is the one this request # names in `X-Debug-Session` and the auth dependency found it the caller's own and open. debug_session_id: str | None = Field(default=None, pattern=r"^[0-9a-f]{16}$") version: str | None = Field(default=None, max_length=64) @@ -138,7 +138,7 @@ def ingest_events( body: ClientEventsIn = Depends(_parsed_events), db: Session = Depends(get_db), ) -> dict: - # A debug session the request names raises the level for its own events (E16): starting one is + # A debug session the request names raises the level for its own events: starting one is # the consent to that level for its duration, whatever the user's own level is. session = debugsession.from_header(db, user, request.headers.get(debugsession.HEADER)) level, _ = debugsession.level_for(user, session) diff --git a/backend/app/routes/discover.py b/backend/app/routes/discover.py index 09a98572..104aea67 100644 --- a/backend/app/routes/discover.py +++ b/backend/app/routes/discover.py @@ -1,10 +1,10 @@ -"""E24 · the Home surface's HTTP shell. +"""The Home surface's HTTP shell. Thin by construction: every decision — what counts as in progress, what fits a time budget, which tier the hero comes from — lives in `app/discover/`, so the scheduler, a future CLI or a test can ask the same questions without a request. This file validates, gates and serializes. -The consolidated `GET /api/discover` mirrors the Plex Discover route's one-payload shape (E5 S4): a +The consolidated `GET /api/discover` mirrors the Plex Discover route's one-payload shape: a landing page that fires four requests to answer one question would spend its whole budget on the thing it exists to remove. """ @@ -72,7 +72,7 @@ def get_discover( # never reaches SQL — `hero.pick` uses it only as set membership over an already-bounded candidate # pool (the resume rail's `RAIL_LIMIT` rows, the shelf, `SURPRISE_DRAW` surprise draws) — so there is # no scan for a crafted URL to unbound, and the set is built in full before this slice anyway. The -# comment that claimed otherwise was corrected in round 2; the request line's own length is what +# comment that claimed otherwise was later corrected; the request line's own length is what # actually bounds a hostile `exclude`. MAX_EXCLUDE = 32 diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index 681464b1..6469d76f 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -498,7 +498,7 @@ def upload_download( upload: SpooledUpload = Depends(spool_upload), db: Session = Depends(get_db), ) -> dict: - """Ingest a local file into the caller's library (E7 S5). Any file type is accepted; media + """Ingest a local file into the caller's library. Any file type is accepted; media (video/audio) gets the full function set, a generic file is download-only. The body IS the file (see the header note above); `spool_upload` has already authenticated the caller and streamed it to a temp path under the download volume, so what is left here is the non-yt-dlp ingest which @@ -914,7 +914,7 @@ def share_download( user_id=recipient.id, type="download_shared", title=(job.display_name or "A download") + " was shared with you", - # The client names the notice in the reader's language from `data` (E30); the title is the + # The client names the notice in the reader's language from `data`; the title is the # English fallback only. data={ "kind": "download_shared", @@ -1033,7 +1033,7 @@ def list_links( class CreateLinkIn(BaseModel): password: str = "" - # D1 (E7 S3): a newly shared download is downloadable by default — the common intent is "let the + # D1: a newly shared download is downloadable by default — the common intent is "let the # recipient have the file", not stream-only. Forward-only: this only sets the default for links # minted from now on; existing links keep whatever `allow_download` they were created with (the # column default stays False, so no back-catalogue link silently flips). The owner can still diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 360c32eb..031e2870 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -235,7 +235,7 @@ def get_feed_years( user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: - """Per-year video counts for the year control (item 8 / E8·S2): the quick recent-year chips and the + """Per-year video counts for the year control: the quick recent-year chips and the "more…" per-year-counts dialog. Every filter EXCEPT the publish-date narrowing applies, so picking a year re-queries the feed but not this list. Response: {"years": [{"year": , "count": }]}, newest year first.""" diff --git a/backend/app/routes/me.py b/backend/app/routes/me.py index c1b0611e..e6b79e35 100644 --- a/backend/app/routes/me.py +++ b/backend/app/routes/me.py @@ -158,12 +158,12 @@ def get_me( "pending_activations": pending_activations, "email_configured": email_configured, "preferences": user.preferences or {}, - # The consented diagnostics level in force and when it lapses (E22 S5). The client applies + # The consented diagnostics level in force and when it lapses. The client applies # it to its own log and streams that log while it is raised. "diagnostics": diagnostics.state(db, user), - # The live debug session, if one is recording (E16) — a reload resumes it. + # The live debug session, if one is recording — a reload resumes it. "debug_session": _live_session(db, user), - # Where a bug report goes (E16 S4): the tracker's repository, or None — download only. + # Where a bug report goes: the tracker's repository, or None — download only. "issues": gitea.target(db), } diff --git a/backend/app/routes/plex/__init__.py b/backend/app/routes/plex/__init__.py index 604029b4..d930cfaa 100644 --- a/backend/app/routes/plex/__init__.py +++ b/backend/app/routes/plex/__init__.py @@ -4,7 +4,7 @@ Read endpoints (libraries/browse/show/image) are available to ANY authenticated (pure-Siftlode included) — this is an access layer to the Plex library WITHOUT requiring a plex.tv account, and playback is a local file. Admin endpoints test the connection and run the catalog sync. -Split out of the former single routes/plex.py (R12 S1). `router` keeps the same shape the app +Split out of the former single routes/plex.py. `router` keeps the same shape the app wiring expects (`plex_routes.router`), so main.py is unchanged. """ diff --git a/backend/app/routes/plex/browse.py b/backend/app/routes/plex/browse.py index 16acda1b..2277a1a2 100644 --- a/backend/app/routes/plex/browse.py +++ b/backend/app/routes/plex/browse.py @@ -285,7 +285,7 @@ def unified_library( episodes = [] if tsq is not None and want_shows and field in ("all", "episode"): ep_st = aliased(PlexState) - # In HU an episode is found by its Hungarian title too (E30: the overlay now fills it) — and + # In HU an episode is found by its Hungarian title too (the overlay fills it) — and # still by its English one, like `vector_match` does for the grid. if field == "episode": ep_vec = func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title, "")) @@ -360,8 +360,8 @@ def facets( # The shows that pass the watch-state scope, asked ONCE. The per-user episode rollup behind it is a # full pass over the user's episodes (~10 ms on a prod-sized library), and each of the show groups - # (genres, ratings, studios, years, rating max) asked it again: half of a filtered facets call - # (E31 S4e). The same filter on the same rows, so the counts do not change + # (genres, ratings, studios, years, rating max) asked it again: half of a filtered facets call. + # The same filter on the same rows, so the counts do not change # (test_plex_facets_grid_parity). shows_in_scope: list[int] | None = None if show_ids and show in ("watched", "in_progress", "unwatched"): @@ -545,7 +545,7 @@ def facets( for c in split_csv(f.playable): if c in PLAYBACK_CLASSES: playable_counts.setdefault(c, 0) - # Names in the reader's alphabet: the genres are Hungarian words for a Hungarian reader (E30 S1). + # Names in the reader's alphabet: the genres are Hungarian words for a Hungarian reader. alpha = {w: i for i, w in enumerate(sorted_words(db, [*genre_counts, *studio_counts], lang))} return { "genres": [ diff --git a/backend/app/routes/plex/detail.py b/backend/app/routes/plex/detail.py index fe2a9aee..94318d1d 100644 --- a/backend/app/routes/plex/detail.py +++ b/backend/app/routes/plex/detail.py @@ -153,7 +153,7 @@ def show_detail( all_cards.append(card) show_roll = rollup.rollup(all_cards) - # Cast + IMDb + tagline are served from the DB row (persisted by the cast backfill, E5 S5b). The one + # Cast + IMDb + tagline are served from the DB row (persisted by the cast backfill). The one # remaining live call is `related` — a Plex recommendation hub we don't mirror — which also carries # the metadata used to lazily fill a not-yet-enriched show. Any live failure just drops `related` and # the lazy fill; the row-served detail below still renders (outage-resilient). @@ -213,7 +213,7 @@ def show_detail( "first": show_roll["first"], "episode_count": show_roll["episode_count"], "collection_keys": sh.collection_keys or [], - # TMDB enrichment (E5 S2), served from the row. + # TMDB enrichment, served from the row. "tmdb": plex_tmdb.serve_block(db, sh, lang=lang), }, "seasons": [_season_block(se, by_season.get(se.id, [])) for se in seasons], @@ -353,7 +353,7 @@ _TRACK_TAIL = re.compile(r"\(([^()]+)\)\s*$") def _track_naming(s: dict) -> dict: - """What the player names a track from (E30). Plex's `displayTitle` is in the SERVER's language + """What the player names a track from. Plex's `displayTitle` is in the SERVER's language ("English (AC3 5.1)", "magyar Forced", "中文 (EAC3 Stereo)"), so it is sent only as the fallback `label`; the client builds the name in the reader's language from the language tag, the forced / SDH flags and the codec tail. No title and no language → no label (the client numbers it).""" @@ -538,6 +538,6 @@ def item_detail( "prev_id": prev_id, "next_id": next_id, "collections": _collection_strips(db, it, user.id, lang), - # TMDB enrichment (E5 S2), served from the row — movies only (episodes aren't enriched). + # TMDB enrichment, served from the row — movies only (episodes aren't enriched). "tmdb": plex_tmdb.serve_block(db, it, lang=lang) if it.kind == "movie" else None, } diff --git a/backend/app/routes/plex/discover.py b/backend/app/routes/plex/discover.py index 84c79c96..1201c01c 100644 --- a/backend/app/routes/plex/discover.py +++ b/backend/app/routes/plex/discover.py @@ -1,11 +1,11 @@ -"""Plex Discover: the rail-surface read-model (E5 S4). +"""Plex Discover: the rail-surface read-model. A consolidated payload for the dedicated Discover landing — a stack of horizontal rails: Continue watching · Recently added · Browse-by-genre tiles · Because you watched X — plus a separate, reshuffling Surprise-me sample. Serve-from-DB only: the recommendation rail reads the -recs the S2 TMDB engine already persisted (`PlexItem.recommendations`), never a live TMDB call, and +recs the TMDB engine already persisted (`PlexItem.recommendations`), never a live TMDB call, and every card comes from the same `movie_card`/`show_card` serializers the grid uses (so rails inherit -S1's `art` + S3's rating/summary for free). The rails carry EN metadata (like the other non-grid +their `art` + rating/summary for free). The rails carry EN metadata (like the other non-grid card paths); the HU overlay coalesces only in the main grid. The sidebar metadata filters (genre / rating / year / collection / …) narrow EVERY rail too, so the @@ -114,8 +114,8 @@ def _recently_added( else [] ) # Merge the two type-homogeneous lists by added_at, then serialize each side in one batch so the - # per-card state lookups stay batched (no N+1). Same `or NO_TS` key as the two rails below — the - # E24 review found this one still on the older `(is not None, value)` form, i.e. one rule in two + # per-card state lookups stay batched (no N+1). Same `or NO_TS` key as the two rails below — a + # review found this one still on the older `(is not None, value)` form, i.e. one rule in two # spellings, with the two comments explaining it differently. tagged = [(m.added_at, "movie", m) for m in movies] + [(s.added_at, "show", s) for s in shows] tagged.sort(key=lambda t: t[0] or rollup.NO_TS, reverse=True) diff --git a/backend/app/routes/plex/people.py b/backend/app/routes/plex/people.py index 2b4440b2..0902511b 100644 --- a/backend/app/routes/plex/people.py +++ b/backend/app/routes/plex/people.py @@ -176,10 +176,10 @@ def person_detail( people who share a name share a page. Each section is capped at _PERSON_SECTION_MAX. An unknown name simply returns empty sections. - E5 S6: the first time a page is opened, the person's TMDB bio + "Known for" credits are lazily - fetched (from the S2-linked `tmdb_person_id`) and persisted, then served from the row on every open. + The first time a page is opened, the person's TMDB bio + "Known for" credits are lazily + fetched (from the linked `tmdb_person_id`) and persisted, then served from the row on every open. The fetch runs OFF the request path (a background task); the first open serves whatever's in the row - with `enriching` set, and the page asks again until the bio lands. E30 S6: the people_enrich job + with `enriching` set, and the page asks again until the bio lands. The people_enrich job fetches everyone ahead of time, so this lazy path is only for a person the job hasn't reached yet. """ lang = norm_lang(lang) @@ -187,7 +187,7 @@ def person_detail( # Lazy one-time bio enrich, scheduled AFTER the response so the TMDB call is never on the serve path. # Gated by a per-person cooldown so concurrent/rapid opens (or repeated opens while TMDB is degraded) # can't pile background tasks onto the shared threadpool. - # `enriching` tells the page the bio is on its way (E30 S6): it asks again shortly instead of showing + # `enriching` tells the page the bio is on its way: it asks again shortly instead of showing # an empty page until the next open. True while a fetch is pending, including one an earlier open # already reserved. enriching = ( diff --git a/backend/app/routes/plex/playback.py b/backend/app/routes/plex/playback.py index f819f043..92fafd99 100644 --- a/backend/app/routes/plex/playback.py +++ b/backend/app/routes/plex/playback.py @@ -169,7 +169,7 @@ _PROGRESS_MIN_S = 5 _FINISH_MARGIN_S = 30 -# S2 write-back throttle: the resume position is now pushed to Plex on the periodic (non-final) +# Write-back throttle: the resume position is now pushed to Plex on the periodic (non-final) # checkpoint too — not only on the final pause/unmount flush — so Plex (the source of truth) stays # current in near-real-time. To keep a single watch from spraying `/:/timeline` calls, a push is # rate-limited to at most one per item per user every _PUSH_THROTTLE_S seconds; a `final` checkpoint @@ -360,7 +360,7 @@ def item_progress( ) -> dict: """Checkpoint the resume position while playing (near the end → mark watched + clear it). - S2 write-back: the resume position is mirrored to Plex (the source of truth) on the periodic + Write-back: the resume position is mirrored to Plex (the source of truth) on the periodic checkpoint too, not only on the final flush — throttled to ≥_PUSH_THROTTLE_S per item (see `_should_push_resume`). `final` (sent on pause / seek / pagehide / unmount) pushes immediately and resets the throttle window, so Plex sees a settled position the instant the user pauses or seeks.""" @@ -696,7 +696,7 @@ def stream_hls_file( (viewer, rating_key) and a seek-restart replaces the viewer's registered one, while the DIRECTORY is per (key, viewer, offset, tag) — so resolving by the key alone handed the old player the new session's `seg_12.ts`, produced from a different offset under `-copyts`: wrong - bytes with a 200, which the player has no way to notice (deep-review round 5). A request for a + bytes with a 200, which the player has no way to notice. A request for a session that is no longer the registered one is a 404, and the player reclaims at the playhead. That reclaim is also why a request with no live session is no longer auto-started here: the @@ -728,7 +728,7 @@ def stream_hls_file( # `proc` so a session whose ffmpeg has already exited answers at once instead of polling out the # whole timeout: this is a SYNC route on the ~40-slot threadpool, holding a pooled DB connection # with it, so every such wait is a worker (and a connection) parked for 20 s — an ordinary seek - # past the produced region was enough to do it (round 5). + # past the produced region was enough to do it. if not plex_stream.wait_for(path, timeout, proc=s.proc): raise refused(404, "not_ready") try: diff --git a/backend/app/routes/plex/playlists.py b/backend/app/routes/plex/playlists.py index 7d9d4a27..ad2356b2 100644 --- a/backend/app/routes/plex/playlists.py +++ b/backend/app/routes/plex/playlists.py @@ -296,7 +296,7 @@ def _items_by_rks(db: Session, rks: list) -> list[PlexItem]: class PlexItemAddBulkIn(PlexItemKeysIn): - # Where each key goes back (E28 S3e2, the list page's "Undo" of a removal): an index over the + # Where each key goes back (the list page's "Undo" of a removal): an index over the # ORDERED rows, one per key. The two-call shape (append, then send the whole order) leaves the rows # at the end whenever the second call fails, and the order it sends would overwrite anything # another tab changed in between. diff --git a/backend/app/routes/saved_views.py b/backend/app/routes/saved_views.py index 977a7315..cdc9d23f 100644 --- a/backend/app/routes/saved_views.py +++ b/backend/app/routes/saved_views.py @@ -54,7 +54,7 @@ def list_views( # A feed view's tag pick is read by more than the feed: the autotag job keeps every tag a view names # (sync/autotag.py). Held here to the shape the feed sends — a list of tag ids, at most this many — so -# a hand-made body (`"tags": 5`, or 60k ids) can't break that job for every user (E32 review). +# a hand-made body (`"tags": 5`, or 60k ids) can't break that job for every user. _MAX_VIEW_TAGS = 500 diff --git a/backend/app/routes/setup.py b/backend/app/routes/setup.py index 6ce2c20b..cddbfcec 100644 --- a/backend/app/routes/setup.py +++ b/backend/app/routes/setup.py @@ -71,10 +71,10 @@ def setup_admin(body: SetupAdminIn, db: Session = Depends(require_setup)) -> dic raise refused(400, "invalid_email") if len(password) < PASSWORD_MIN_LEN: raise refused(400, "weak_password", min=PASSWORD_MIN_LEN) - # Resolve canonically, not by the literal string (DX-17). The wizard can be re-run, and E9.5's - # whole point is that two spellings of one Gmail mailbox are one person — matching literally here - # would let a second run under the other spelling create the DUPLICATE admin row the epic exists - # to prevent. Every other lookup went canonical in S3; this one was outside that diff. + # Resolve canonically, not by the literal string (DX-17). The wizard can be re-run, and the + # whole point of canonical email is that two spellings of one Gmail mailbox are one person — + # matching literally here would let a second run under the other spelling create the DUPLICATE + # admin row it exists to prevent. Every other lookup was canonical already; this one was missed. user = db.execute( select(User).where(User.email_canonical == canonical_email(email)) ).scalar_one_or_none() diff --git a/backend/app/routes/sync.py b/backend/app/routes/sync.py index 4e032f6b..f4a04f95 100644 --- a/backend/app/routes/sync.py +++ b/backend/app/routes/sync.py @@ -37,7 +37,7 @@ def sync_subscriptions(user: User = Depends(current_user), db: Session = Depends result = import_subscriptions(db, user) except YouTubeError as exc: # The onboarding wizard's first call: an unguarded failure here was a raw 500 and the - # blocking "Server error" dialog, with nothing the user could act on (E9 F1). + # blocking "Server error" dialog, with nothing the user could act on. db.rollback() raise youtube_refusal(exc) from exc result["quota_used_estimate"] = m.used diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index d790bd9f..8b4e9b15 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -154,7 +154,7 @@ def _audit_run(db, actor_id, job_id, status, summary, result) -> None: they changed something, so routine no-op polls don't spam the trail. Best-effort.""" if actor_id is None and status == "ok" and not _run_changed(result): return - # The result as data too (E30): the audit log names it in the reader's language, as the inbox + # The result as data too: the audit log names it in the reader's language, as the inbox # does; `summary` stays the English line of the row's own summary column. data = _result_data(result) try: @@ -252,7 +252,7 @@ _PAUSED = {"skipped": "sync paused"} def _result_data(result) -> dict | None: - """A job's result as the client names it in the reader's language (E30): the dict itself (counts, + """A job's result as the client names it in the reader's language: the dict itself (counts, a `skipped` / `reason` word, a nested dict per phase), `enrich`'s bare count as one. The one-line `_summarize` is the English fallback.""" if isinstance(result, dict): @@ -401,7 +401,7 @@ def _tmdb_enrich_job() -> None: def _people_enrich_job() -> None: - # Make the people local (E30 S6): a batch of person bios from TMDB and a batch of portraits resized + # Make the people local: a batch of person bios from TMDB and a batch of portraits resized # through Plex, so a person page and a cast strip never wait on an outside service. _job("people_enrich", run_people_enrich) @@ -428,7 +428,7 @@ def _log_gc_job() -> None: days = sysconfig.get_int(db, "log_retention_days") reaped = logsink.gc(db, days) - # Debug sessions (E16) go with the rows they bundle; an open one past its cap is closed. + # Debug sessions go with the rows they bundle; an open one past its cap is closed. closed = debugsession.sweep(db) sessions = debugsession.gc(db, days) db.commit() diff --git a/backend/app/sync/autotag.py b/backend/app/sync/autotag.py index 7f3aa788..ce2965a9 100644 --- a/backend/app/sync/autotag.py +++ b/backend/app/sync/autotag.py @@ -116,7 +116,7 @@ _TOPIC_SLUGS = { "Fashion": "Fashion & Beauty", "Physical_attractiveness": "Fashion & Beauty", # YouTube links the British page (…/wiki/Humour); the key was "Humor", so no channel ever got Comedy - # from its topic (E30). + # from its topic. "Humour": "Comedy", "Entertainment": "Entertainment", "Film": "Film", @@ -300,11 +300,11 @@ def _cleanup_orphan_system_tags(db: Session) -> int: """Delete system tags that no longer link to any channel (e.g. after a mapping change) — but not one a saved feed view filters on. Views hold tag IDS, and a deleted tag comes back under a new id when a channel earns it again, so the view's filter would point at nothing; the feed drops an - unknown id, and the view showed the whole feed under its old name (E32 S2). + unknown id, and the view showed the whole feed under its old name. The views are read defensively, and the kept ids are filtered here rather than in SQL: a view stored before its route checked the shape (or written around it) must not raise for everyone's - job, nor push a `NOT IN` past the driver's bind-parameter limit (E32 review).""" + job, nor push a `NOT IN` past the driver's bind-parameter limit.""" in_views = { t for (filters,) in db.execute(select(SavedView.filters).where(SavedView.source == "feed")) diff --git a/backend/app/sync/maintenance.py b/backend/app/sync/maintenance.py index d0a2a247..fa1c2602 100644 --- a/backend/app/sync/maintenance.py +++ b/backend/app/sync/maintenance.py @@ -190,7 +190,7 @@ def _revalidate_rolling(db: Session, yt: YouTubeClient, now: datetime) -> dict: .where(Video.unavailable_since.is_(None)) # Not one this run already checked: with fewer available videos than the batch, the # loop used to wrap around and re-fetch the same ones until `checked` reached the - # batch, spending quota on answers it already had (E32 S2). + # batch, spending quota on answers it already had. .where(or_(Video.last_checked_at.is_(None), Video.last_checked_at < now)) # NULLS FIRST = never-checked first, then least-recently-checked; equivalent to the old # `(last_checked_at IS NULL) DESC, last_checked_at ASC` but in the exact shape of the diff --git a/backend/app/sync/playlists.py b/backend/app/sync/playlists.py index eff61053..cac99c2d 100644 --- a/backend/app/sync/playlists.py +++ b/backend/app/sync/playlists.py @@ -168,7 +168,7 @@ def _load_baseline(pl: Playlist) -> tuple[list[str] | None, set[str]]: def parse_baseline(pl: Playlist) -> list[str] | None: """The last-synced ordered id set, or None when no baseline has been recorded yet (a fresh - export, or a link created before S7). None makes the first merge union losslessly.""" + export, or an older link). None makes the first merge union losslessly.""" return _load_baseline(pl)[0] @@ -209,7 +209,7 @@ def _set_baseline_under_lock( meanwhile — lock the list and derive `dirty` from what is there, or the edit would read as synced and never reach YouTube. The name too: the lock re-reads a rename made meanwhile, and a baseline taken over THAT name matched it, so the list read clean with YouTube's title still the - old one (E28 review round 4).""" + old one.""" _lock_or_gone(db, pl) _set_baseline(pl, ids, unavailable, name=name) recompute_dirty(db, pl) @@ -322,7 +322,7 @@ def user_sync_claim(db: Session, user_id: int) -> AbstractContextManager[bool]: def _mirror_listing(yt: YouTubeClient, ytid: str, count: object, pl_id: int) -> list[str] | None: """A mirror's video ids from YouTube, or None when there is nothing to believe: the list was deleted between the pull's listing and this read (the next pull drops the mirror as vanished — - not a reason to fail every other list, E32 review round 2), or YouTube listed no items for a + not a reason to fail every other list), or YouTube listed no items for a list it counts as non-empty (`blank_listing`).""" try: ids = list(yt.iter_my_playlist_video_ids(ytid)) diff --git a/backend/app/sync/runner.py b/backend/app/sync/runner.py index 19292ad1..c13c0875 100644 --- a/backend/app/sync/runner.py +++ b/backend/app/sync/runner.py @@ -187,7 +187,7 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str # "fix this subscription"; `Connection refused` says "the egress is down". # The fallback's count rides along: a red card during an RSS outage should still say # whether the uploads are arriving another way. - # As data too (E31): the audit log and the inbox say it in the reader's language, the first + # As data too: the audit log and the inbox say it in the reader's language, the first # failure as its channel and its reason code (`http_404` vs `transport` is that difference). first_channel, first_reason = first_failure or ("", "error") raise progress.JobFailed( diff --git a/backend/app/sync/subscriptions.py b/backend/app/sync/subscriptions.py index b950566e..d715f677 100644 --- a/backend/app/sync/subscriptions.py +++ b/backend/app/sync/subscriptions.py @@ -123,7 +123,7 @@ def should_prune(fetched_count: int, existing_count: int) -> bool: def vanished[K, V](existing: dict[K, V], listed: Collection[K]) -> list[V]: """The local mirrors whose key the remote listing no longer has — none at all when the listing is an empty answer that contradicts them (`should_prune`). The YouTube playlist mirrors use it: - without it, one blip dropped every mirror (E32 S2).""" + without it, one blip dropped every mirror.""" if not should_prune(len(listed), len(existing)): return [] return [row for key, row in existing.items() if key not in listed] @@ -132,7 +132,7 @@ def vanished[K, V](existing: dict[K, V], listed: Collection[K]) -> list[V]: def blank_listing(listed: Collection, count: object) -> bool: """The same rule one level down: no members from a container whose own listing counts some (a playlist's `itemCount`) is a blip, not a list emptied. Replacing or merging on it deleted every - mirrored item and reset the merge baseline (E32 review).""" + mirrored item and reset the merge baseline.""" return not listed and isinstance(count, int) and count > 0 diff --git a/backend/app/sync/videos.py b/backend/app/sync/videos.py index 80dc772f..ee3355ed 100644 --- a/backend/app/sync/videos.py +++ b/backend/app/sync/videos.py @@ -107,7 +107,7 @@ def stub_from_playlist_item(item: dict, channel_id: str) -> dict | None: def _uploads_page(yt: YouTubeClient, channel_id: str, uploads: str, page_token: str | None) -> dict: """One page of the channel's uploads. YouTube answers the uploads list of a channel with no uploads at all as not found: an empty back-catalogue, so an empty last page — not a failure - that left `recent_synced_at` unstamped and charged the channel again on every run (E32 review). + that left `recent_synced_at` unstamped and charged the channel again on every run. Any other failure raises.""" try: return yt.get_playlist_items(uploads, page_token) diff --git a/backend/app/sysconfig.py b/backend/app/sysconfig.py index 0822fad1..35210ffd 100644 --- a/backend/app/sysconfig.py +++ b/backend/app/sysconfig.py @@ -181,7 +181,7 @@ SPECS: tuple[ConfigSpec, ...] = ( # --- Audit log --- # Days to keep admin audit-log rows before the audit_gc job prunes them (0 = keep forever). ConfigSpec("audit_retention_days", "int", "audit", "audit_retention_days", min=0, max=3650), - # --- Diagnostics (E22) --- + # --- Diagnostics --- # Runtime log level for the API process. Applied without a restart — see routes/config.py and # main.py's lifespan. ConfigSpec("log_level", "choice", "diagnostics", "log_level"), @@ -198,7 +198,7 @@ SPECS: tuple[ConfigSpec, ...] = ( # (plex_sync_interval_min is tuned from the Scheduler dashboard, not here — it's a scheduler job.) ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16), ConfigSpec("plex_max_sessions", "int", "plex", "plex_max_sessions", min=1, max=64), - # Transcode re-encode tuning (E6 S1). The libx264 knobs apply only to the software fallback. + # Transcode re-encode tuning. The libx264 knobs apply only to the software fallback. # crf 0–51 (x264 range); max_height 0 = source. ConfigSpec("plex_transcode_hw", "choice", "plex", "plex_transcode_hw"), ConfigSpec("plex_transcode_preset", "choice", "plex", "plex_transcode_preset"), @@ -210,10 +210,10 @@ SPECS: tuple[ConfigSpec, ...] = ( ConfigSpec("plex_audio_loudnorm", "bool", "plex", "plex_audio_loudnorm"), # NOTE: plex_watch_sync_interval_min is a config.py default read by the watch-sync scheduler job # (scheduler.py); it is intentionally NOT a runtime admin knob, so it stays out of SPECS. - # --- TMDB enrichment (E5 S2; optional) — the API key is the only secret --- + # --- TMDB enrichment (optional) — the API key is the only secret --- ConfigSpec("tmdb_api_key", "str", "tmdb", "tmdb_api_key", secret=True), ConfigSpec("tmdb_language", "str", "tmdb", "tmdb_language"), - # --- Bug reports (E16): the Gitea issue tracker a debug session's issue goes to --- + # --- Bug reports: the Gitea issue tracker a debug session's issue goes to --- ConfigSpec("issues_gitea_url", "str", "issues", "issues_gitea_url"), ConfigSpec("issues_gitea_repo", "str", "issues", "issues_gitea_repo"), ConfigSpec("issues_gitea_token", "str", "issues", "issues_gitea_token", secret=True), @@ -221,7 +221,7 @@ SPECS: tuple[ConfigSpec, ...] = ( # is a config.py default — both stay out of SPECS, like plex_sync_interval_min.) ) -# The admin page's sections (E31, the owner's pick): sixteen groups as tabs wrapped to three rows at 133% +# The admin page's sections (the owner's pick): sixteen groups as tabs wrapped to three rows at 133% # zoom, so they are a vertical list under four headings, in this order. Every group of SPECS sits in # exactly one section (test-held), so a new group cannot fall off the page. SECTIONS: tuple[tuple[str, tuple[str, ...]], ...] = ( @@ -275,7 +275,7 @@ def _coerce(s: ConfigSpec, raw: str): if s.type == "int": # Bounds are checked on READ, not only in `set_value`. Bounds get ADDED to a spec # (download_upload_max_bytes gained a `max` when SEC-20 started enforcing the same number at - # the edge; its `min` went 0 → 1 during E7) and env defaults get LOWERED, and either leaves + # the edge; its `min` went 0 → 1) and env defaults get LOWERED, and either leaves # a stored value that was legal when it was written. Serving it is how one layer ends up # acting on a number another layer refuses, with the admin page showing a value outside the # range it renders. @@ -430,7 +430,7 @@ def describe(db: Session) -> dict: only whether an override is set and whether an env default exists.""" groups: dict[str, list[dict]] = {} # Every override in one query: a db.get per key was one round trip each (two for a key with no - # row, which the session cannot cache), ~40 ms of the page's ~55 on prod (E31 S4d). + # row, which the session cannot cache), ~40 ms of the page's ~55 on prod. rows = {r.key: r for r in db.scalars(select(SystemConfig))} for s in SPECS: row = rows.get(s.key) diff --git a/backend/app/utils.py b/backend/app/utils.py index 2493cafb..21332c33 100644 --- a/backend/app/utils.py +++ b/backend/app/utils.py @@ -57,7 +57,7 @@ def canonical_email(value: str | None) -> str: # The shortest last word that is prefix-matched. A 1–2 letter prefix matches a large share of the # library and ts_rank then scores every match: "m" took ~5–8 s on prod against ~100 ms for a whole -# word (E31 S4e). A shorter last word is matched whole instead ("tv", "4k" still find themselves). +# word. A shorter last word is matched whole instead ("tv", "4k" still find themselves). MIN_PREFIX_LEN = 3 diff --git a/backend/app/youtube/client.py b/backend/app/youtube/client.py index 400ea78c..b7c549e0 100644 --- a/backend/app/youtube/client.py +++ b/backend/app/youtube/client.py @@ -135,7 +135,7 @@ def refusal_reason(exc: YouTubeError, *, playlist: bool = False) -> YouTubeRefus `playlist`: the action was on ONE playlist the user picked, so a missing playlist is that list, gone (deleted on YouTube) — neither a retry nor a reconnect helps. Anywhere else the playlist is one the user never named (a channel's uploads, one list of a bulk pull), and "this playlist - no longer exists" would describe nothing on their screen (E32 review round 2).""" + no longer exists" would describe nothing on their screen.""" if is_quota_refusal(exc): return "quota" if playlist and is_playlist_gone(exc): diff --git a/backend/app/youtube/search_scrape.py b/backend/app/youtube/search_scrape.py index 391416c8..95dc1b89 100644 --- a/backend/app/youtube/search_scrape.py +++ b/backend/app/youtube/search_scrape.py @@ -73,7 +73,7 @@ def _get_config(client: httpx.Client) -> tuple[str, str]: The fallback pair is never cached: it used to be, so one failed fetch (or a consent page with no keys on it) at the wrong moment pinned every later search to a hard-coded key and version for the life of the process — and when those went stale, search returned nothing, with no error - anywhere (E32 S2). Now the next search simply tries the live pair again.""" + anywhere. Now the next search simply tries the live pair again.""" global _config if _config is not None: return _config diff --git a/backend/docker-compose.test.yml b/backend/docker-compose.test.yml index 67dedcf6..057f0290 100644 --- a/backend/docker-compose.test.yml +++ b/backend/docker-compose.test.yml @@ -1,4 +1,4 @@ -# DB-backed pytest lane (R6 S3b): an ephemeral Postgres + the test image, wired together so the +# DB-backed pytest lane: an ephemeral Postgres + the test image, wired together so the # `db` fixture (tests/conftest.py) has a real database. The whole thing is throwaway — the pg data # lives in tmpfs (RAM) and nothing is persisted. The pure-logic lane still runs WITHOUT this file # (plain `docker run … python -m pytest` — DB tests skip when no Postgres is reachable). @@ -35,5 +35,5 @@ services: volumes: - ./:/app working_dir: /app - # Coverage is on demand, not in the gate (E35): see docs/development.md. + # Coverage is on demand, not in the gate: see docs/development.md. command: ["python", "-m", "pytest"] diff --git a/backend/pyrightconfig.json b/backend/pyrightconfig.json index e034ee46..03c48361 100644 --- a/backend/pyrightconfig.json +++ b/backend/pyrightconfig.json @@ -1,4 +1,4 @@ -// The backend type-check lane (E27 S3) — the gate's ninth lane, and the first thing that has ever +// The backend type-check lane — the gate's ninth lane, and the first thing that has ever // type-checked the ~36k lines of `app/`. Read as JSONC: pyright accepts comments here. // // basedpyright, not mypy: measured on this tree with the dependencies actually installed, both land diff --git a/backend/pytest.ini b/backend/pytest.ini index f92aa790..cc36472b 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -3,7 +3,7 @@ # import it as `app.*`, matching runtime. Most of the suite is pure-logic; the DB-backed lane opts in # via the `db` fixture and runs in the compose test image. `-rs` reports skip REASONS so a silently # skipped lane is visible (and conftest makes a missing DB/ffmpeg a hard error unless -# SIFTLODE_TEST_REQUIRE_DB=0). `--strict-markers` (E32): a misspelt `@pytest.mark.skipiff` is an error, +# SIFTLODE_TEST_REQUIRE_DB=0). `--strict-markers`: a misspelt `@pytest.mark.skipiff` is an error, # not an unknown marker that quietly does nothing. testpaths = tests pythonpath = . diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt index 862ae1cc..e681a5f8 100644 --- a/backend/requirements-dev.txt +++ b/backend/requirements-dev.txt @@ -4,7 +4,7 @@ # backend lint lane runs on the host (the gate fails when the host's ruff is not this one). httpx2: # Starlette 1.x's TestClient is built on it (`import httpx2 as httpx`) and only falls back to the # app's own httpx with a deprecation warning on every run. -# basedpyright: the backend type-check lane (E27 S3). Pinned like everything else here — a type +# basedpyright: the backend type-check lane. Pinned like everything else here — a type # checker whose version floats silently changes what "clean" means, and the ratchet baseline it # writes is keyed to the diagnostics THIS version produces. # pytest-cov: the on-demand coverage report (docs/development.md) — a number to read, deliberately diff --git a/backend/requirements.lock b/backend/requirements.lock index 2611fa16..de7bbd57 100644 --- a/backend/requirements.lock +++ b/backend/requirements.lock @@ -2,9 +2,9 @@ # Source of truth is requirements.txt (the floor ranges); regenerate after editing it with: # docker run --rm -v "$PWD":/b python:3.12-slim sh -c 'pip install -q -r /b/requirements.txt && pip freeze --exclude pip --exclude setuptools --exclude wheel' | sort # The prod Dockerfile + Dockerfile.test install THIS file; the gate's pip-audit (scripts/check.sh) -# scans it, blocking since E32. A security fix may be ONE pin bumped in place instead of a full +# scans it, blocking. A security fix may be ONE pin bumped in place instead of a full # regeneration, which would move every other package too — then `pip install -r requirements.lock && pip check` must -# pass. In place so far: urllib3 2.7.0 -> 2.8.0 (PYSEC-2026-4175/4176/4177, E32). +# pass. In place so far: urllib3 2.7.0 -> 2.8.0 (PYSEC-2026-4175/4176/4177). APScheduler==3.11.3 Authlib==1.7.2 Mako==1.4.1 diff --git a/backend/requirements.txt b/backend/requirements.txt index 6622eaf2..d8049a9d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -19,8 +19,8 @@ argon2-cffi>=23.1,<26 # keep the floor loose and bump it when extraction breaks. yt-dlp[default]>=2025.5.22 # Browser impersonation (`impersonate`) for Facebook downloads made with the user's cookie: some -# links answer a signed-in plain request with "Cannot parse data" and only parse as Chrome (E11.5, -# measured). The range is YT-DLP's, not ours — yt-dlp 2026.07.04 imports curl_cffi only in +# links answer a signed-in plain request with "Cannot parse data" and only parse as Chrome +# (measured). The range is YT-DLP's, not ours — yt-dlp 2026.07.04 imports curl_cffi only in # 0.10.x–0.15.x and silently reports every target "unavailable" outside it. Move it when bumping # yt-dlp; `tests/test_facebook_impersonation.py::test_the_locked_curl_cffi_can_impersonate_chrome` # fails the gate if the locked pair can no longer impersonate. diff --git a/backend/ruff.toml b/backend/ruff.toml index 57145a40..b5274832 100644 --- a/backend/ruff.toml +++ b/backend/ruff.toml @@ -1,7 +1,7 @@ # Ruff config for the Siftlode backend. Keeps ruff's default rule set (E4/E7/E9 + F = the # high-signal pyflakes/pycodestyle checks that catch real dead code + bugs), all of it on. E402 was -# ignored for main.py's logging setup; the ignore had let 11 incidental late imports in elsewhere -# (E32 review), so it is on now and main.py marks each deliberate one (`# noqa: E402 — `). +# ignored for main.py's logging setup; the ignore had let 11 incidental late imports in elsewhere, +# so it is on now and main.py marks each deliberate one (`# noqa: E402 — `). # `scripts/e2e_seed.py` edits `sys.path` before its imports, which E402 itself allows. # # line-length 100 (ruff's default is 88): matches the frontend prettier printWidth so both halves of @@ -14,7 +14,7 @@ line-length = 100 target-version = "py312" [lint] -# Beyond the default set, the rules that hold a LOG LINE to what E22.5 makes of it: +# Beyond the default set, the rules that hold a LOG LINE to what the log pipeline makes of it: # LOG — stdlib logging misuse (`.warn()`, a logger built by hand, `exc_info` on `.exception`) # G — the call's shape: %-style args, never an f-string the level filter cannot skip, and # never `str(exc)` where `exc_info` would carry the traceback @@ -32,11 +32,11 @@ target-version = "py312" # The wider rule these two enforce lives in tests/test_swallowed_exceptions.py: a handler that # neither logs nor re-raises says `# silent-ok: `. Ruff sees only the two narrow shapes. # -# E32 (gate integrity) added three more, each measured before it went on and with every hit fixed: +# Gate integrity added three more, each measured before it went on and with every hit fixed: # RUF100 — a `# noqa` that suppresses nothing. A stale one is a claim with no rule behind it, and # it stays armed for the day the line changes (24 were removed). A comment that merely # explains a noqa below it names the rule as `# TRY400: …`, never `# noqa TRY400`. -# PLW1510 — `subprocess.run` without an explicit `check=`. Found the real E32 bug: three ffprobe +# PLW1510 — `subprocess.run` without an explicit `check=`. Found a real bug: three ffprobe # probes read the output of a probe that had FAILED and took it for the file's truth. # B — flake8-bugbear: loop-variable capture, `raise` without `from` inside `except`, a # `zip` that silently truncates (each one is `strict=True` now — the lengths are an diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 05c88c34..0caa7b15 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,4 +1,4 @@ -"""Pytest fixtures for the DB-backed integration lane (R6 S3b). +"""Pytest fixtures for the DB-backed integration lane. The bulk of the suite is pure-logic and needs no database. This adds an OPT-IN `db` fixture that hands a test a real session against a throwaway Postgres (the app's own engine, bound to @@ -38,8 +38,8 @@ from app.models import User _MISSING = object() # A would-be silent skip (no DB, or no ffmpeg) is a hard error unless the run says otherwise — the -# suite must not print green while the DB-backed / ffmpeg tests were quietly not run. ON by default -# since E32: as an opt-in only the compose lane set, a hand-run `pytest` skipped ~60 DB tests +# suite must not print green while the DB-backed / ffmpeg tests were quietly not run. ON by default: +# as an opt-in only the compose lane set, a hand-run `pytest` skipped ~60 DB tests # and still passed. `SIFTLODE_TEST_REQUIRE_DB=0` is the explicit "pure-logic only" run. _REQUIRE_FULL = os.getenv("SIFTLODE_TEST_REQUIRE_DB", "1") != "0" @@ -117,7 +117,7 @@ def _request_dep(db, unfinished: list[str]): savepoint released inside the handler commits nothing. The test's own staged rows are flushed before the request starts — they are the test's business, not the handler's. A request that raises is rolled back, as production's `close()` rolls it back: without that, what a failing handler flushed but never committed stayed - readable to the rest of the test — the one state production cannot be in (E16: an issue created + readable to the rest of the test — the one state production cannot be in (an issue created on the tracker before a failed upload was kept only by an explicit commit, and nothing saw it missing).""" diff --git a/backend/tests/test_abuse_limits.py b/backend/tests/test_abuse_limits.py index 0593cacb..f9ec8303 100644 --- a/backend/tests/test_abuse_limits.py +++ b/backend/tests/test_abuse_limits.py @@ -1,4 +1,4 @@ -"""E12 · S3 — the route-level halves of SEC-12 and SEC-14. +"""The route-level halves of SEC-12 and SEC-14. `test_urlguard.py` pins the address judgement itself; this pins that the ENQUEUE route actually consults it and answers in the structured `{code, reason}` shape the frontend localizes, and that a @@ -38,7 +38,7 @@ def test_a_public_url_gets_past_the_guard(db, as_user, make_user, monkeypatch): ) # An inline spec, so nothing ELSE refuses it: without one the request stopped at the # profile lookup (400 no_profile) and "not url_blocked" held for a request the guard never - # had to wave through (E32 S2). + # had to wave through. r = as_user(user).post( "/api/downloads", json={"source": "https://example.com/v", "spec": {"format": "best"}} ) @@ -56,7 +56,7 @@ def test_youtube_sources_never_consult_the_guard(db, as_user, make_user, monkeyp db.commit() monkeypatch.setattr(urlguard.socket, "getaddrinfo", boom) # The job must actually be created: a request refused for any other reason would never reach - # the guard either, and the test would pass without proving anything (E32 S2). + # the guard either, and the test would pass without proving anything. r = as_user(user).post( "/api/downloads", json={"source": "dQw4w9WgXcQ", "spec": {"format": "best"}} ) diff --git a/backend/tests/test_admin_logs.py b/backend/tests/test_admin_logs.py index 87660e41..7277108c 100644 --- a/backend/tests/test_admin_logs.py +++ b/backend/tests/test_admin_logs.py @@ -1,4 +1,4 @@ -"""The admin diagnostics console's query API (E22.6 S1), DB-lane.""" +"""The admin diagnostics console's query API, DB-lane.""" from datetime import datetime, timedelta, timezone diff --git a/backend/tests/test_admin_pending_access.py b/backend/tests/test_admin_pending_access.py index c4cedd87..536d3dd2 100644 --- a/backend/tests/test_admin_pending_access.py +++ b/backend/tests/test_admin_pending_access.py @@ -1,4 +1,4 @@ -"""The admin lifeline for a sign-up that is stuck outside (E9.5 S5, roadmap UX-25), DB-lane. +"""The admin lifeline for a sign-up that is stuck outside, DB-lane. Approve an address BEFORE the person registers and their sign-up produces no queue row at all — `_decide` only takes a still-pending invite — so the Access-requests screen stayed empty while they @@ -133,7 +133,7 @@ def test_an_unconfirmed_account_is_listed_and_can_be_sent_a_fresh_link( lambda to, lang, link, **kw: sent.append((to, link)) is None, ) admin = make_user(email="admin@example.com", role="admin") - # Active but unconfirmed — a real state since E9.5 S1 made the two gates order-independent. It is + # Active but unconfirmed — a real state now that the two gates are order-independent. It is # not the admin's to decide, but the mail may simply never have arrived, so it is theirs to help # with. The approved invite is what puts them on this screen at all: the list is "the address may # sign in, this account still can't", so an undecided request stays with the queue that owns it. diff --git a/backend/tests/test_audit_count_rows.py b/backend/tests/test_audit_count_rows.py index 39f68d9b..cdf47d6b 100644 --- a/backend/tests/test_audit_count_rows.py +++ b/backend/tests/test_audit_count_rows.py @@ -1,4 +1,4 @@ -"""E30: an admin action that counts something keeps the count as data in its audit row's `after`, under +"""An admin action that counts something keeps the count as data in its audit row's `after`, under the key the audit log names in the reader's language (`scheduler.result.`, frontend auditColumns.tsx) — not only in the English `summary`. A renamed key would fall back to the raw word. DB lane (Postgres). diff --git a/backend/tests/test_auth_session.py b/backend/tests/test_auth_session.py index 040fd48f..25f8aec0 100644 --- a/backend/tests/test_auth_session.py +++ b/backend/tests/test_auth_session.py @@ -168,17 +168,17 @@ def test_reset_confirm_is_single_use_and_kills_existing_sessions(client, make_us def test_approving_an_unverified_account_keeps_its_password(db, make_user): - # E9.5 S1, inverting an earlier rule. Approval used to drop a password set while the email was + # This inverts an earlier rule. Approval used to drop a password set while the email was # unverified (SEC-10, account pre-emption). It was dropped because it destroyed the password a # legitimate registrant had just chosen, in what is the NORMAL ordering — the admin decides # before the user reads their mail — making "your password was cleared for security" the default # experience. # - # S1 ALSO argued the revoke was redundant, because password_login refuses an unverified account - # before it reads is_active. The epic's closing review refuted that: the gate holds only while - # the address is unverified, and S6's approval mail exists to get the real owner to end that - # state. Do not restore that reasoning from this comment — the residual is SEC-16, and round 2 - # of the review tried and reverted a guard for it (see test_review_round.py). + # It was ALSO argued that the revoke was redundant, because password_login refuses an + # unverified account before it reads is_active. The epic's closing review refuted that: the gate + # holds only while the address is unverified, and the approval mail exists to get the real + # owner to end that state. Do not restore that reasoning from this comment — the residual is + # SEC-16, and the same review tried and reverted a guard for it (see test_review_round.py). from app.routes.admin import _activate_user_by_email user = make_user( @@ -245,7 +245,7 @@ def test_verifying_afterwards_lets_the_original_password_in(client, db, make_use def test_activating_a_verified_account_keeps_its_password(db, make_user): # The no-SMTP shape: registration marks such an account verified up front, so it was never in - # scope for the SEC-10 guard even before E9.5 S1. Kept as the other half of the pair. + # scope for the SEC-10 guard even back then. Kept as the other half of the pair. from app.routes.admin import _activate_user_by_email user = make_user( @@ -354,7 +354,7 @@ def test_an_admin_can_activate_a_pre_approved_registrant(db, monkeypatch): db.expire_all() assert user.is_active is True - assert user.password_hash is not None # approval never touches the password (E9.5 S1) + assert user.password_hash is not None # approval never touches the password def test_registration_of_an_unknown_email_still_persists_account_and_pending_invite( @@ -483,7 +483,7 @@ def test_register_route_is_refused_when_registration_is_closed(client, db, monke # was a deliberately uniform "ok"/401. `password_revoked_at` is the trace that tells the two apart; # these pin each step of the way back in. # -# E9.5 S1 removed the admin-approval caller, so Google adoption is the only path that still produces +# The admin-approval caller is gone, so Google adoption is the only path that still produces # this state — plus every account stripped on prod before that change, which is why the whole recovery # route below must keep working. These tests manufacture the state through the surviving producer. diff --git a/backend/tests/test_autotag_orphans.py b/backend/tests/test_autotag_orphans.py index a9eb5605..76e01810 100644 --- a/backend/tests/test_autotag_orphans.py +++ b/backend/tests/test_autotag_orphans.py @@ -1,4 +1,4 @@ -"""sync/autotag's orphan cleanup (E32 S2: the module was 32% covered). +"""sync/autotag's orphan cleanup (the module was 32% covered). A system tag no channel carries any more is deleted — unless a saved feed view filters on it. The views hold tag ids; a deleted tag returns under a NEW id when a channel earns it again, and the feed @@ -38,7 +38,7 @@ def test_an_orphan_system_tag_goes_but_one_a_saved_view_filters_on_stays(db, mak def test_a_malformed_saved_view_cannot_break_the_cleanup(db, make_user): """A view stored around the route's shape check (or before it) must not raise for everyone's job - — `"tags": 5` raised TypeError — and `True` is not tag id 1 (E32 review).""" + — `"tags": 5` raised TypeError — and `True` is not tag id 1.""" user = make_user() orphan = Tag(user_id=None, name="Cooking", category="topic") db.add(orphan) diff --git a/backend/tests/test_body_limit.py b/backend/tests/test_body_limit.py index 9136eb79..4d55c4e7 100644 --- a/backend/tests/test_body_limit.py +++ b/backend/tests/test_body_limit.py @@ -209,12 +209,12 @@ def test_the_default_ceiling_clears_the_largest_body_a_default_route_takes(): # its route is not in ROUTE_MAX_BODY_BYTES, this middleware refuses a request the route would have # taken — silently, in production, on a path nobody tested. The point of the list is the third entry. # -# BOTH helpers, not just `capped_body`: P1 added `capped_stream_to` as its sibling and the registry +# BOTH helpers, not just `capped_body`: `capped_stream_to` was added later and the registry # did not follow, so the upload route — the one this whole invariant was written for — was the one -# site the drift guard could not see (deep-review round 1). The names come from `_common.py` itself +# site the drift guard could not see. The names come from `_common.py` itself # below, so a THIRD sibling cannot be added without this list noticing either — and a route that # skips the helpers altogether and awaits `request.body()` lands here as `` -# rather than vanishing (round 3). +# rather than vanishing. # # The module is the path relative to `app/routes/`, so a site under `plex/` is spelled # `plex/admin.py` and cannot collide with the top-level `admin.py`. @@ -255,7 +255,7 @@ def test_the_registry_covers_every_body_reading_helper(): # they are blind to the shape `_common.py` itself is written in. A route spelling `await # request.body()` inline declares no ceiling, hands no connection back, and would be invisible to # all three — reading as "no new sites" rather than as a failure, which is the exact way a drift -# guard dies quietly (deep-review round 3). +# guard dies quietly. RAW_BODY_READS = {"body", "stream", "form", "json"} @@ -476,7 +476,7 @@ def test_the_upload_ceiling_and_the_admin_knob_cannot_drift_apart(): constant. If the knob could be set above the edge, an admin raising it would silently start getting 413s from a layer the admin page never mentions. - Since P1 the two count the SAME bytes — the body is the file, with no multipart envelope + Now the two count the SAME bytes — the body is the file, with no multipart envelope wrapped round it — so the old allowance is gone and the edge is the knob's own maximum, exactly. A gap reappearing means someone re-wrapped the upload in an envelope without saying so. @@ -509,13 +509,13 @@ def _route_objects(root): @pytest.mark.parametrize(("method", "template"), sorted(bodylimit.ROUTE_MAX_BODY_BYTES)) def test_no_route_with_a_raised_ceiling_declares_a_body_parameter(method, template): - """P1's invariant, and the reason this is a test rather than a comment. + """The upload fix's invariant, and the reason this is a test rather than a comment. A raised ceiling is only honest when the route's own cap applies to the bytes AS THEY ARRIVE. FastAPI reads and spools a DECLARED body (`= File(...)`, `= Form(...)`, a Pydantic model) BEFORE the first dependency resolves — so on such a route this middleware's ceiling is the only thing between an anonymous caller and the whole allowance, the auth dependency never gets a say, and - the route's in-handler cap measures bytes that have already landed. That is exactly what P1 + the route's in-handler cap measures bytes that have already landed. That is exactly what a fix closed on the upload route, which sat in `ROUTE_MAX_BODY_BYTES` with a 500 MiB ceiling and a `file: UploadFile = File(...)` body for one release. @@ -529,7 +529,7 @@ def test_no_route_with_a_raised_ceiling_declares_a_body_parameter(method, templa itself consults when it decides to read the body. Measured on the pinned FastAPI: a body declared inside a sub-dependency leaves `body_params` EMPTY while the body is still read in full before auth, so the first version of this test was green over a reproduction of the very defect it - exists to forbid (deep-review round 1). The upload route's own idiom — a body read from a + exists to forbid. The upload route's own idiom — a body read from a `yield` dependency — is exactly the shape that would have slipped through.""" from app.main import app @@ -546,7 +546,7 @@ def test_no_route_with_a_raised_ceiling_declares_a_body_parameter(method, templa def test_a_stored_override_outside_the_bounds_falls_back_on_READ(db): """`set_value` refuses an out-of-range int, but neither bound has always been what it is now — - the upload knob had no `max` before SEC-20 and its `min` was 0 before E7 — so a value that was + the upload knob had no `max` before SEC-20 and its `min` used to be 0 — so a value that was legal when it was written can be sitting in the database. Served as-is it is exactly the drift the test above forbids: the route would accept bytes the edge refuses. diff --git a/backend/tests/test_canonical_patterns.py b/backend/tests/test_canonical_patterns.py index 7c347b12..6f330793 100644 --- a/backend/tests/test_canonical_patterns.py +++ b/backend/tests/test_canonical_patterns.py @@ -1,4 +1,4 @@ -"""Two rows of CLAUDE.md's canonical-patterns table, held in code (E35 S4). +"""Two rows of CLAUDE.md's canonical-patterns table, held in code. Both had drifted while the table said otherwise: seven `db.query(...)` calls stood beside the `select()` the table names, and four modules carried a private `_now` / `now_utc` helper (nine uses) diff --git a/backend/tests/test_channel_backfill.py b/backend/tests/test_channel_backfill.py index 45b03dab..7e651605 100644 --- a/backend/tests/test_channel_backfill.py +++ b/backend/tests/test_channel_backfill.py @@ -1,4 +1,4 @@ -"""R14 backend coverage: the channel-detail Normal/Shorts/Live counts (moved off the manager table), +"""Backend coverage: the channel-detail Normal/Shorts/Live counts (moved off the manager table), the update_channel -> immediate-backfill wiring, and the interactive "get full history" guard that keeps it from racing the scheduler's cursor or draining the budget.""" @@ -54,7 +54,7 @@ def test_channel_detail_returns_normal_shorts_live_counts(db, monkeypatch, overr @pytest.mark.parametrize("hidden", [True, False]) def test_channel_detail_surfaces_subscription_hidden_flag(db, monkeypatch, hidden, override_dep): - # S6: the detail GET must expose the per-subscription `hidden` flag so the channel page can offer + # The detail GET must expose the per-subscription `hidden` flag so the channel page can offer # the same Unhide toggle the manager has (the flag was previously omitted from the detail dict). ch = Channel(id="UC_hid", title="Hidden?", published_at=_PUB) db.add(ch) diff --git a/backend/tests/test_channel_list_index_only.py b/backend/tests/test_channel_list_index_only.py index cdcbd72b..670b153c 100644 --- a/backend/tests/test_channel_list_index_only.py +++ b/backend/tests/test_channel_list_index_only.py @@ -1,4 +1,4 @@ -"""The channel manager's sums and the feed sidebar's counts read the index alone (E31 S4d). +"""The channel manager's sums and the feed sidebar's counts read the index alone. `GET /api/channels` counts each subscribed channel's videos and sums their durations; `/feed/summary` and `/feed/years` count the reader's videos per channel and per year under the default filters. They diff --git a/backend/tests/test_channel_tags.py b/backend/tests/test_channel_tags.py index 1bd3a531..375673e7 100644 --- a/backend/tests/test_channel_tags.py +++ b/backend/tests/test_channel_tags.py @@ -1,4 +1,4 @@ -"""DB-backed regression (R8 Part 2): GET /api/channels/{id} returns exactly THIS user's personal +"""DB-backed regression: GET /api/channels/{id} returns exactly THIS user's personal tag links for the channel — not another user's, and not the auto/system links (user_id NULL) — so the channel detail page can reuse the manager's tag editor. Would ship broken if the tag_ids query ever dropped its user_id filter. diff --git a/backend/tests/test_collation.py b/backend/tests/test_collation.py index 1c4f3f97..84f9a1e8 100644 --- a/backend/tests/test_collation.py +++ b/backend/tests/test_collation.py @@ -1,4 +1,4 @@ -"""E30 S3: every A→Z list the server orders is in the reader's alphabet. The database image is Alpine, +"""Every A→Z list the server orders is in the reader's alphabet. The database image is Alpine, whose `en_US.utf8` is BYTE order: "Életrajzi" and "Ötödik" sorted after "Zorró" in the feed, the channel list, the Plex grid, the genre list. Hungarian puts "Ö" after "O" and "Cs" after "Cu". Needs the DB.""" @@ -209,7 +209,7 @@ def test_channel_discovery_keeps_that_order_after_enriching(db, monkeypatch, ove assert [c["title"] for c in r.json()] == HU -# --- The other lists the server puts in the reader's alphabet (E30 review): each call site, once. --- +# --- The other lists the server puts in the reader's alphabet: each call site, once. --- def _others(db, n_offset: int = 0) -> list[User]: diff --git a/backend/tests/test_config_commit_ownership.py b/backend/tests/test_config_commit_ownership.py index da035e58..e65a0033 100644 --- a/backend/tests/test_config_commit_ownership.py +++ b/backend/tests/test_config_commit_ownership.py @@ -1,4 +1,4 @@ -"""E3 S3 (B-core-10): the config/state mutators flush but do NOT self-commit, so the caller owns the +"""The config/state mutators flush but do NOT self-commit, so the caller owns the transaction and a change lands together with its audit row. These lock that contract — a rollback before the caller commits discards the change (proving no hidden internal commit), while a same-session read still sees it (flush visibility). DB lane (`db` fixture) — skipped without Postgres. diff --git a/backend/tests/test_db_lane.py b/backend/tests/test_db_lane.py index c144a13a..7e2e0711 100644 --- a/backend/tests/test_db_lane.py +++ b/backend/tests/test_db_lane.py @@ -1,6 +1,6 @@ -"""Smoke test proving the DB-backed lane works (R6 S3b): a real commit round-trips, and each +"""Smoke test proving the DB-backed lane works: a real commit round-trips, and each test starts from a clean database (TRUNCATE isolation), which is what lets the transaction- -ownership tests (R6 S2) trust what they commit.""" +ownership tests trust what they commit.""" from sqlalchemy import func, select diff --git a/backend/tests/test_debug_sessions.py b/backend/tests/test_debug_sessions.py index 5c21aef1..3972be56 100644 --- a/backend/tests/test_debug_sessions.py +++ b/backend/tests/test_debug_sessions.py @@ -1,4 +1,4 @@ -"""E16 S1 — debug sessions: Start → reproduce → Stop, the tag on every line, the screenshots. +"""Debug sessions: Start → reproduce → Stop, the tag on every line, the screenshots. Four parts: * the session's life: one open per user, a second Start joins it, a Stop ends it, the cap ends it @@ -774,11 +774,11 @@ def test_the_export_is_the_saved_draft_the_evidence_and_the_screenshots(db, make assert z.read("screenshot-1.png") == _PNG -# --- the issue tracker (S4) ----------------------------------------------------------------- +# --- the issue tracker ---------------------------------------------------------------------- class FakeGitea: - """Gitea 1.27 as measured (plans/e16 S0): a private repository, one file per asset upload, and + """Gitea 1.27 as measured: a private repository, one file per asset upload, and a way to make one upload fail.""" def __init__(self): diff --git a/backend/tests/test_diagnostics.py b/backend/tests/test_diagnostics.py index ad5c6932..9a10cd9d 100644 --- a/backend/tests/test_diagnostics.py +++ b/backend/tests/test_diagnostics.py @@ -1,4 +1,4 @@ -"""E22 S5 — a user's consented diagnostics level, and the browser log it lets in. +"""A user's consented diagnostics level, and the browser log it lets in. Three parts: * the level itself: the server computes its expiry, a lapsed one reads as the base (DB lane, diff --git a/backend/tests/test_discover.py b/backend/tests/test_discover.py index a798393f..d40ee832 100644 --- a/backend/tests/test_discover.py +++ b/backend/tests/test_discover.py @@ -1,4 +1,4 @@ -"""E24 S1 — the Home surface's read-model. +"""The Home surface's read-model. Covers the four things that can silently rot here: the time-budget bands (pure), the cross-source resume rail's merge order, the hero's tier ladder, and the comfort shelf (its schema-level one-of @@ -632,7 +632,7 @@ def test_a_pinned_video_survives_leaving_the_feed_scope(db): def test_the_plex_surprise_obeys_the_time_budget(db): """The one control on the page has to mean what it says: a 100-minute film is not an answer to - "10 perc". The YouTube half of this rule has had its own test since S1; the Plex half's duration + "10 perc". The YouTube half of this rule has long had its own test; the Plex half's duration bounds could be deleted with the whole suite still green, because every test that reached `plex_surprise` passed budget="any".""" user, _ = _seed_yt(db) @@ -923,7 +923,7 @@ def test_pinning_a_plex_show_resolves_to_the_episode_that_would_play(db, as_user def test_pinning_a_plex_movie_plays_itself_without_a_cycle(db, as_user): """The self-reference that once 500'd the whole shelf, on the other leaf kind: `next` has to be a - distinct dict, not the tile aliased into itself. Covered for a video since S1, never for a movie.""" + distinct dict, not the tile aliased into itself. Long covered for a video, never for a movie.""" user, _ = _seed_yt(db) _seed_plex(db, user) _enable_plex(db) @@ -941,7 +941,7 @@ def test_the_reroll_endpoint_parses_its_whole_exclusion_list_and_its_press_count hero comes back. NOT the `MAX_EXCLUDE` clamp, which this sends three keys against a cap of 32 and cannot see. The - docstring used to claim it did (round 2). The clamp is deliberately left unpinned: it guards + docstring used to claim it did. The clamp is deliberately left unpinned: it guards nothing the route would otherwise do — see the constant's own comment — and a test that sent 33 keys could only assert that the request still answers, which the comma split already proves.""" user, _ = _seed_yt(db) @@ -975,7 +975,7 @@ def test_pinning_something_that_does_not_exist_is_a_404(db, as_user): def test_a_pin_whose_target_vanishes_mid_request_is_a_404_not_a_500(db, as_user, monkeypatch): """`add_pin`'s `except IntegrityError` arm was reached by no test at all, so the 500→404 - narrowing could be reverted with the whole suite green (round 2). The 404 above comes from the + narrowing could be reverted with the whole suite green. The 404 above comes from the `resolve_ref` PRE-CHECK, a different branch that returns before the INSERT. The race the arm exists for — a Plex sync dropping the row between `resolve_ref` confirming it @@ -1091,7 +1091,7 @@ def test_two_overlapping_reorders_never_share_a_slot(db, as_user): row — two pins on one slot (deep review, debt burn). Locked, the second waits for the first and deals from its result: here a partial list (a tab with Plex off, one pin hidden), whose left-out pin must stay where the FIRST reorder put it — a lock taken in position order handed the waiter - fresh rows in its stale order, and it dealt that pin back to its old slot (round 2).""" + fresh rows in its stale order, and it dealt that pin back to its old slot.""" import threading from app.db import SessionLocal diff --git a/backend/tests/test_download_credentials.py b/backend/tests/test_download_credentials.py index 3cc8af39..33314592 100644 --- a/backend/tests/test_download_credentials.py +++ b/backend/tests/test_download_credentials.py @@ -1,4 +1,4 @@ -"""S6 — per-user, per-host download cookies: host-scope, cache privacy, encryption, write-only API. +"""Per-user, per-host download cookies: host-scope, cache privacy, encryption, write-only API. The two load-bearing security properties are covered as behaviour, not prose: * **cache privacy** — a cookie-authed download is a PRIVATE, salted asset; a second user without a @@ -283,7 +283,7 @@ class TestResolveCookiefile: # --- Retry / Re-download take a cookie added later (DB) ----------------------------------------- -# Measured on dev (E11.5): a Facebook reel failed on the login wall, the user added their cookie as +# Measured on dev: a Facebook reel failed on the login wall, the user added their cookie as # the failure copy tells them to, pressed Retry — and it failed again, because the job still held the # shared asset the worker never attaches a cookie to. Re-download forked a salt the gate could not # recognise, so even a job downloaded WITH a cookie rebuilt without it. diff --git a/backend/tests/test_download_deprioritize.py b/backend/tests/test_download_deprioritize.py index cfd769f1..0264a4c5 100644 --- a/backend/tests/test_download_deprioritize.py +++ b/backend/tests/test_download_deprioritize.py @@ -1,4 +1,4 @@ -"""Worker-child resource de-prioritization (S4): the encoder thread cap, the `-threads` flag on the +"""Worker-child resource de-prioritization: the encoder thread cap, the `-threads` flag on the browser-safe re-encode, and the child's startup renice/ionice/OMP handicap. These keep a CPU-heavy transcode from pegging every core on the box it shares with the API, Postgres, and Plex playback.""" diff --git a/backend/tests/test_download_errors.py b/backend/tests/test_download_errors.py index c97b55f2..285a5c5f 100644 --- a/backend/tests/test_download_errors.py +++ b/backend/tests/test_download_errors.py @@ -1,4 +1,4 @@ -"""Category classification of raw yt-dlp / ffmpeg download-failure strings (S2).""" +"""Category classification of raw yt-dlp / ffmpeg download-failure strings.""" from app.downloads import adapters from app.downloads import errors as dlerrors @@ -71,7 +71,7 @@ def test_the_redacted_network_text_classifies_back_to_network(): assert classify(dlerrors.NETWORK_REDACTED_MESSAGE) == "network" -# Verbatim yt-dlp 2026.07.04 output from prod jobs, measured on dev 2026-09-13 (E11.5). +# Verbatim yt-dlp 2026.07.04 output from prod jobs, measured on dev 2026-09-13. _FB_PARSE = ( "ERROR: [facebook] 1736360027626346: Cannot parse data; please report this issue on " "https://github.com/yt-dlp/yt-dlp/issues?q= , filling out the appropriate issue template." @@ -196,7 +196,7 @@ def test_our_post_not_shown_verdict_is_the_refused_cookie(): def test_our_post_without_a_video_verdict_names_the_group_link(): - # The adapter's own text when the post page holds no single video (S4): the message and its rule and + # The adapter's own text when the post page holds no single video: the message and its rule and # catalog entry are pinned together, or an edit to the wording orphans the copy. msg = adapters.POST_WITHOUT_VIDEO_MESSAGE assert classify(msg) == "format_unavailable" diff --git a/backend/tests/test_download_failure_catalog.py b/backend/tests/test_download_failure_catalog.py index ae7341ab..ed585e64 100644 --- a/backend/tests/test_download_failure_catalog.py +++ b/backend/tests/test_download_failure_catalog.py @@ -1,4 +1,4 @@ -"""DB-backed tests for the download failure catalog (E11.5 S1) at the places a failure is WRITTEN. +"""DB-backed tests for the download failure catalog at the places a failure is WRITTEN. `test_download_errors` pins which entry a message matches; these pin that every writer hands the matcher what it needs — the link and whether the user's cookie went with it — and that a stored entry @@ -8,7 +8,7 @@ never outlives the failure it describes: - Retry / Re-download, which requeue the job; - the `0084` backfill of the rows that failed before the catalog existed; - the downloads list, which is how the entry reaches the UI. -It also holds the one success-path check of the same child: the duration it stores (E11.5 S3). +It also holds the one success-path check of the same child: the duration it stores. """ import shutil @@ -226,7 +226,7 @@ def test_the_downloads_list_carries_the_catalog_entry(db, as_user): @pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available") def test_a_download_yt_dlp_gave_no_duration_stores_the_files_duration(db, monkeypatch, tmp_path): - # Facebook reel 257 (E11.5): yt-dlp's info had no duration, the asset stored NULL, and the Library + # Facebook reel 257: yt-dlp's info had no duration, the asset stored NULL, and the Library # row lost its trim action. Through the real finalize, with a real 3-second file. u = _user(db, "fb-duration@example.com") a, j = _asset_and_job(db, u, sig="shared", asset_status="downloading", job_status="running") diff --git a/backend/tests/test_download_job_locks.py b/backend/tests/test_download_job_locks.py index f55dbf13..a4f13d6c 100644 --- a/backend/tests/test_download_job_locks.py +++ b/backend/tests/test_download_job_locks.py @@ -1,4 +1,4 @@ -"""Overlapping requests on ONE job (E11.5 review) — DB-backed, two real sessions. +"""Overlapping requests on ONE job — DB-backed, two real sessions. The routes that move a job's asset ref count (resume, cancel, delete, redownload) used to decide on an unlocked read: two overlapping requests for one job (a double-click, a second tab, a replay after a diff --git a/backend/tests/test_download_limits.py b/backend/tests/test_download_limits.py index ce216652..2237dbd3 100644 --- a/backend/tests/test_download_limits.py +++ b/backend/tests/test_download_limits.py @@ -1,4 +1,4 @@ -"""DB-backed tests for R12 · S5 backend residue — the two download limits: +"""DB-backed tests for the two download limits: - **#2 claim fairness** (`worker._claim_job`): users already AT their per-user concurrency limit are excluded IN THE SQL, so one user flooding the queue past the fetch window can't starve @@ -173,7 +173,7 @@ def test_list_downloads_is_capped(db, monkeypatch): assert len(rows) == 2 # bounded by the cap, not the 3 rows on disk -# --- E12 · S3 · the worker-side max_bytes remainder ------------------------------------------ +# --- the worker-side max_bytes remainder ---------------------------------------------------------- def _sized_asset(db, size_bytes, ref): diff --git a/backend/tests/test_download_ref_count.py b/backend/tests/test_download_ref_count.py index f2fced9e..3b8a52ae 100644 --- a/backend/tests/test_download_ref_count.py +++ b/backend/tests/test_download_ref_count.py @@ -1,4 +1,4 @@ -"""E3 S2 characterization net — MediaAsset.ref_count enqueue/release accounting (B-sub-5). +"""Characterization net — MediaAsset.ref_count enqueue/release accounting. `ref_count` tracks how many DownloadJobs still hold a shared cache asset; when it hits zero a READY asset's file + row are freed. Enqueue +1's it and release_asset -1's it. Today those are Python diff --git a/backend/tests/test_download_share_notice.py b/backend/tests/test_download_share_notice.py index 39fab1cc..85c12376 100644 --- a/backend/tests/test_download_share_notice.py +++ b/backend/tests/test_download_share_notice.py @@ -1,4 +1,4 @@ -"""E30: the "shared with you" notice carries the download's title in its data, so the client can name +"""The "shared with you" notice carries the download's title in its data, so the client can name the notice in the reader's language. Its stored title is an English sentence ("Cats was shared with you") — the only place the title lived, so a Hungarian reader got the English. Needs the DB.""" diff --git a/backend/tests/test_download_transitions.py b/backend/tests/test_download_transitions.py index bb1ae4c7..67aab12f 100644 --- a/backend/tests/test_download_transitions.py +++ b/backend/tests/test_download_transitions.py @@ -1,4 +1,4 @@ -"""E3 S2 characterization net — download job state transitions (B-sub-8). +"""Characterization net — download job state transitions. pause / resume / cancel / delete / redownload currently live inline in the routes, each hand-rolling the asset bookkeeping (release, error-reset, fresh-asset rebuild). This pins their OBSERVABLE HTTP diff --git a/backend/tests/test_email_canonical.py b/backend/tests/test_email_canonical.py index 4b3fed24..0a3c49f1 100644 --- a/backend/tests/test_email_canonical.py +++ b/backend/tests/test_email_canonical.py @@ -1,4 +1,4 @@ -"""One mailbox, many spellings — the canonical-email identity rule (E9.5 S3). +"""One mailbox, many spellings — the canonical-email identity rule. Gmail delivers `x.y@gmail.com`, `xy@gmail.com` and `xy+tag@gmail.com` to ONE inbox. The invite gate used to compare literal strings, so on 2026-09-03 an approved user's Google sign-in became a silent @@ -351,9 +351,9 @@ def test_me_reports_the_google_address_only_when_it_differs(client, db, make_use def test_rerunning_the_wizard_with_another_gmail_spelling_updates_the_same_admin(db): - """The wizard can be re-run, and E9.5's premise is that two spellings of one Gmail mailbox are one + """The wizard can be re-run, and the premise is that two spellings of one Gmail mailbox are one person. Resolving by the literal string here would create a SECOND admin row — exactly the - duplicate the epic exists to prevent. This site was outside S3's sweep (DX-17). + duplicate canonical email exists to prevent. This site was missed by the first sweep (DX-17). Calls the handler directly: `require_setup` is an instance-state gate, not the behaviour under test, and the test fixtures present an already-configured instance.""" diff --git a/backend/tests/test_empty_listing_guards.py b/backend/tests/test_empty_listing_guards.py index 57a2c92d..0fcb44d1 100644 --- a/backend/tests/test_empty_listing_guards.py +++ b/backend/tests/test_empty_listing_guards.py @@ -1,11 +1,11 @@ -"""An empty answer is not an emptied library (E32 S2). +"""An empty answer is not an emptied library. Every mirror here reconciles: what the remote no longer lists is removed locally. The catalog (`_prune_stale`) and the subscriptions (`should_prune`) had long refused to do that on an empty listing that contradicts what we hold — a 200 with no items is a blip far more often than a user who removed everything. Two mirrors did not: the Plex collections (a row carries the user-set `editable` flag, and every member its keys) and the YouTube playlist mirrors. These hold both — and, -one level down (E32 review), a collection's members and a playlist's items: an empty answer that +one level down, a collection's members and a playlist's items: an empty answer that contradicts the remote's own count is not believed either. """ @@ -174,7 +174,7 @@ class _YTOneGone(_YT): def test_a_list_deleted_mid_pull_is_skipped_and_the_rest_still_sync(db, make_user, monkeypatch): # Not "this playlist no longer exists" for a bulk pull the user never named a list in, and not - # a failure for every other list either (E32 review round 2): the next pull drops the mirror. + # a failure for every other list either: the next pull drops the mirror. user = make_user() db.commit() listing = [ diff --git a/backend/tests/test_facebook_impersonation.py b/backend/tests/test_facebook_impersonation.py index 86b4fb83..fc5c7840 100644 --- a/backend/tests/test_facebook_impersonation.py +++ b/backend/tests/test_facebook_impersonation.py @@ -1,13 +1,13 @@ -"""E11.5 S3 + S4 — the Facebook download path with the user's cookie. +"""The Facebook download path with the user's cookie. -S3, measured on dev (yt-dlp 2026.07.04, the owner's cookie): reel 806379462330494 fails with the cookie +Measured on dev (yt-dlp 2026.07.04, the owner's cookie): reel 806379462330494 fails with the cookie alone and extracts 2560p with `--impersonate chrome`; the reels that already download with the cookie keep their plain first attempt. These pin the attempt order through the real download loop (`pipeline._extract_with_fallback`, with yt-dlp faked at the `YoutubeDL` boundary), and that the LOCKED curl_cffi can actually impersonate — the pair only works inside yt-dlp's own version range, and outside it every target silently reports "unavailable". -S4, measured on dev and in the owner's Chrome: given a group post's link, yt-dlp finds no formats or +Measured on dev and in the owner's Chrome: given a group post's link, yt-dlp finds no formats or downloads another video from the page's feed. The post's own video is the single Video node in the page scripts that carry the post id (prod posts 66, 145, 140, 143, 221; text posts have none while the page holds ~10 other videos), and its watch link downloads. @@ -139,7 +139,7 @@ def test_other_facebook_failures_are_not_retried_as_chrome(ydl, tmp_path): assert len(seen) == 1 and "impersonate" not in seen[0] -# --- S4: a group post downloads ITS video, or fails saying it has none ---------------------------- +# --- a group post downloads ITS video, or fails saying it has none -------------------------------- def test_a_cookie_group_post_downloads_the_posts_own_video(ydl, tmp_path): @@ -174,7 +174,7 @@ def test_a_group_post_without_a_single_video_fails_saying_so(ydl, tmp_path): def test_a_group_post_page_without_the_post_is_the_refused_cookie(ydl, tmp_path): - # Measured with a refused cookie (E11.5 review): Facebook answers the post link with its login page — + # Measured with a refused cookie: Facebook answers the post link with its login page — # a login form, no post id anywhere. Saying "this post has no video" would blame the post. _, _, web = ydl web["page"] = 'ChatGPT magyarul
' diff --git a/backend/tests/test_failure_lines.py b/backend/tests/test_failure_lines.py index 80b1f6b4..431fc8d8 100644 --- a/backend/tests/test_failure_lines.py +++ b/backend/tests/test_failure_lines.py @@ -1,4 +1,4 @@ -"""E22.5 review round 1: a failure is reported once, with its code, and a repeating one is not a +"""A failure is reported once, with its code, and a repeating one is not a flood. Each case here is a place where the first sweep got the line wrong in one of two directions: a diff --git a/backend/tests/test_feed_relevance_window.py b/backend/tests/test_feed_relevance_window.py index 396d37b6..bd1b1dee 100644 --- a/backend/tests/test_feed_relevance_window.py +++ b/backend/tests/test_feed_relevance_window.py @@ -1,4 +1,4 @@ -"""Relevance ranks only the newest RANK_WINDOW matches (E31), DB-lane. +"""Relevance ranks only the newest RANK_WINDOW matches, DB-lane. A broad word ("a") matched most of the prod library and ts_rank read every match's tsvector: 6.8 s a page. The ranking now covers the newest RANK_WINDOW matches and the rest follow newest first. The window's start @@ -126,7 +126,7 @@ def test_cursor_walk_matches_the_unpaged_order(db, as_user, monkeypatch, window, ) def test_a_hand_made_relevance_cursor_is_refused_not_a_crash(db, as_user, payload): # The page halves compare the cursor's timestamp with the window's in Python: a null or a naive - # one raised there, past the route's 400 — a 500 (E31 review 2). + # one raised there, past the route's 400 — a 500. client = as_user(_seed(db)) cursor = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode() resp = client.get(f"/api/feed?q=foo&sort=relevance&limit=2&cursor={cursor}") diff --git a/backend/tests/test_feed_sort_indexes.py b/backend/tests/test_feed_sort_indexes.py index 1184c68e..d0f5943a 100644 --- a/backend/tests/test_feed_sort_indexes.py +++ b/backend/tests/test_feed_sort_indexes.py @@ -1,4 +1,4 @@ -"""Every feed sort on a videos column rides an index, and an ICU upgrade rebuilds the title ones (E31 S4e). +"""Every feed sort on a videos column rides an index, and an ICU upgrade rebuilds the title ones. Without an index in the exact ORDER BY, each feed page read the whole videos heap and kept a top-N (prod: 350 ms a page for views/duration, 550–780 ms for title). Migration 0096 adds one per direction diff --git a/backend/tests/test_feed_views.py b/backend/tests/test_feed_views.py index ec8d8c78..46f35401 100644 --- a/backend/tests/test_feed_views.py +++ b/backend/tests/test_feed_views.py @@ -1,4 +1,4 @@ -"""Feed search + watch-state views (E2 S3), DB-lane. +"""Feed search + watch-state views, DB-lane. Regression nets for two query rewrites that must stay behaviour-equivalent: - PERF-12: the search channel-name branch moved from an ORed `unaccent(channels.title) ILIKE` (which @@ -178,7 +178,7 @@ _SUMMARY_CASES = [ def test_a_deleted_tags_id_filters_to_nothing_not_to_everything(db, as_user, make_user): """A saved view or the feed's filter state keeps a tag id after the tag is deleted. The feed used - to drop an unknown id, so that view silently showed the WHOLE feed under its old name (E32 S2). + to drop an unknown id, so that view silently showed the WHOLE feed under its old name. A filter on a tag that no longer exists matches nothing; the chip says the tag is gone.""" user, tag_ids = _seed_tagged(db, make_user) client = as_user(user) @@ -196,8 +196,8 @@ def test_a_deleted_tags_id_filters_to_nothing_not_to_everything(db, as_user, mak def test_another_users_private_tag_answers_like_one_that_does_not_exist(db, as_user, make_user): """The category split resolved any tag id, so a foreign PRIVATE tag landed in its real category - and the summary's facets differed from a non-existent id's: an oracle for which foreign ids exist - (E32 review). Both must now read as "missing".""" + and the summary's facets differed from a non-existent id's: an oracle for which foreign ids exist. + Both must now read as "missing".""" user, tag_ids = _seed_tagged(db, make_user) stranger = make_user(email="stranger@example.com") private = Tag(user_id=stranger.id, name="Secret", category="language") @@ -247,7 +247,7 @@ def test_untagged_filter_keeps_only_channels_the_user_has_not_tagged(db, as_user def test_feed_years_counts_ignore_the_date_narrowing_but_honour_other_filters( db, as_user, make_user ): - """The year facet (item 8 / E8·S2): `/api/feed/years` returns a per-year video count, newest first, + """The year facet: `/api/feed/years` returns a per-year video count, newest first, with the publish-date narrowing DROPPED (so switching years stays a drill-down) but every other filter still applied (here: a channel pick).""" user = make_user(email="years@example.com") diff --git a/backend/tests/test_formats.py b/backend/tests/test_formats.py index c49cb97b..abf79b1c 100644 --- a/backend/tests/test_formats.py +++ b/backend/tests/test_formats.py @@ -1,7 +1,7 @@ -"""build_ydl_opts: EVERY download is clamped to the mp4/H.264 compat profile (E7 browser- +"""build_ydl_opts: EVERY download is clamped to the mp4/H.264 compat profile (the browser- playability guarantee) and must SELECT H.264 (not merely prefer it), so shared downloads play in every browser incl. iOS/Safari. Non-mp4 containers and non-H.264 vcodecs are coerced, not honoured. -See the iPad AV1 broken-player fix + E7 format narrowing.""" +See the iPad AV1 broken-player fix + the format narrowing.""" from app.downloads import edit as editmod from app.downloads import formats @@ -58,7 +58,7 @@ def test_max_height_caps_every_branch(): def test_explicit_vcodec_is_clamped_to_the_compat_selector(): - # E7 browser-playability guarantee: a non-H.264 vcodec no longer opts out — normalize() coerces + # Browser-playability guarantee: a non-H.264 vcodec no longer opts out — normalize() coerces # vcodec→None, so the avc1 selector (and mp4 merge) is used regardless of what was requested. opts = _opts({"mode": "av", "container": "mp4", "vcodec": "vp9"}) assert opts["format"].startswith("bestvideo[vcodec^=avc1]") diff --git a/backend/tests/test_hls_reaper.py b/backend/tests/test_hls_reaper.py index 50babeae..6b88dea9 100644 --- a/backend/tests/test_hls_reaper.py +++ b/backend/tests/test_hls_reaper.py @@ -2,15 +2,15 @@ plex/stream.py promises "a periodic reaper kills idle ones", but `reap_idle` ran only inside POST /session: after the last playback, an abandoned session (its ffmpeg still working through the -film, its directory a whole segmented copy of it) lived until somebody pressed play again -(deep-review round 4). +film, its directory a whole segmented copy of it) lived until somebody pressed play again. And it must not take a PAUSED viewer's session: a remux finishes long before the film does, and a paused player with a full buffer fetches nothing, so "finished and unfetched for 30 s" does not mean -"abandoned". Round 4 bought that safety by keeping EVERY finished session for the full ten-minute -idle window — one segmented copy of a film per item, on the volume the download center shares. Round -5 replaced the blanket retention with the signal that already existed: an open player checkpoints its -position every 10 s whether or not it is playing, and that route says "still here". +"abandoned". An earlier fix bought that safety by keeping EVERY finished session for the full +ten-minute idle window — one segmented copy of a film per item, on the volume the download center +shares. Its successor replaced the blanket retention with the signal that already existed: a player +parked mid-item checkpoints its position every 10 s, paused or not, and that route says "still +here" (it stops once the item is finished, where reaping is correct). Both call sites are pinned here, because the bug each time was that only one of them moved: the periodic pass, and the fact that starting a session no longer sweeps OTHER viewers' sessions at all. @@ -174,7 +174,7 @@ def test_starting_a_session_leaves_another_viewers_paused_one_alone( ): """The opportunistic sweep this route used to run was not scoped to the key being started: it walked the WHOLE registry with the 30 s finished rule, so pressing play on any item killed a - paused viewer's session — the very case round 4 set out to protect (found in round 5).""" + paused viewer's session — the very case the retention fix set out to protect.""" monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(plex_paths, "local_media_path", lambda db_, fp: "/local/a.mkv") started = _library_item(db, "starting") diff --git a/backend/tests/test_last_login.py b/backend/tests/test_last_login.py index d4a16391..2ee163e0 100644 --- a/backend/tests/test_last_login.py +++ b/backend/tests/test_last_login.py @@ -1,4 +1,4 @@ -"""`users.last_login_at` (E22 S1), DB-lane. +"""`users.last_login_at`, DB-lane. The roadmap assumed stamping it in `_establish_session` would cover Google and password alike. It would not have: only password login called it, and the Google callback carried its own copy of the diff --git a/backend/tests/test_links.py b/backend/tests/test_links.py index 0dd5460c..78c18096 100644 --- a/backend/tests/test_links.py +++ b/backend/tests/test_links.py @@ -99,7 +99,7 @@ class TestPublicMetaAudioFlag: class TestCreateLinkDefault: - """D1 (E7 S3): a newly minted share link is downloadable by default. The one-click Share relies on + """D1: a newly minted share link is downloadable by default. The one-click Share relies on this — it POSTs no `allow_download`, so the API default decides. Forward-only lives here (the API default), NOT in a migration: the DownloadLink column default stays False, so no existing back- catalogue link silently flips to downloadable.""" diff --git a/backend/tests/test_log_pii.py b/backend/tests/test_log_pii.py index fe523eb9..13309d35 100644 --- a/backend/tests/test_log_pii.py +++ b/backend/tests/test_log_pii.py @@ -1,4 +1,4 @@ -"""E22.5 S1 — what a log line may NOT carry. +"""What a log line may NOT carry. `logs.redact_text` is a safety net over addresses and credential params, and it was doing the whole job: every one of these call sites was putting user content into a line and relying on the net to diff --git a/backend/tests/test_logger_names.py b/backend/tests/test_logger_names.py index 033e2c1d..a671703a 100644 --- a/backend/tests/test_logger_names.py +++ b/backend/tests/test_logger_names.py @@ -1,9 +1,9 @@ -"""E22.5 S2 — every module's logger is named after the module. +"""Every module's logger is named after the module. The names used to be literals, and 25 of them covered 34 modules: `plex/client.py`, `plex/stream.py`, `plex/subtitles.py`, `plex/sync.py` and two route modules all called themselves `siftlode.plex`, and `downloads/pipeline.py` called itself `siftlode.worker`. A level pinned per logger could not separate -them, and neither can E22.6's console filter — the `logger` field is in every stored row precisely so +them, and neither can the console's filter — the `logger` field is in every stored row precisely so it can be filtered on. So the name is derived, not written: `logs.get_logger(__name__)`. These cases pin the derivation and diff --git a/backend/tests/test_login_states.py b/backend/tests/test_login_states.py index 9b243853..b705e132 100644 --- a/backend/tests/test_login_states.py +++ b/backend/tests/test_login_states.py @@ -1,10 +1,10 @@ -"""What a refused sign-in SAYS (E9.5 S4), DB-lane. +"""What a refused sign-in SAYS, DB-lane. The states behind a 403 were true and useless: "Your account is awaiting admin approval." never revealed WHICH mailbox the account was on. On 2026-09-03 a registration as `npeter83.temu@gmail.com` met a Google claim of `npeter83temu@gmail.com`, and that missing address hid the mismatch for a day. -So these pin the DETAIL, not just the status code — the address, and both gates, since E9.5 S1 made -the two order-independent and the screen must report whichever is actually outstanding. +So these pin the DETAIL, not just the status code — the address, and both gates, since the two are +order-independent and the screen must report whichever is actually outstanding. The two refusals that answer BEFORE the password is proven carry nothing, and that is the security half of the same contract: `invalid` must stay byte-identical for a real account and an unknown one. @@ -34,7 +34,7 @@ def test_pending_approval_names_the_address_the_account_is_on(client, make_user) def test_an_already_approved_account_still_reports_its_missing_verification(client, make_user): - # S1 made the gates order-independent, so this really happens: approved, not yet verified. A + # The gates are order-independent, so this really happens: approved, not yet verified. A # screen that assumed a sequence would tell this user to wait for an admin who already decided. make_user( email="approved.first@example.com", password=_PW, email_verified=False, is_active=True diff --git a/backend/tests/test_logs.py b/backend/tests/test_logs.py index 587a1919..b28ea20c 100644 --- a/backend/tests/test_logs.py +++ b/backend/tests/test_logs.py @@ -1,4 +1,4 @@ -"""E22 S2 — the one logging configuration, its redaction, and the request line that replaced +"""The one logging configuration, its redaction, and the request line that replaced uvicorn's access log. Redaction is the part worth guarding hardest: it is applied at the HANDLER, so these cases are the @@ -148,7 +148,7 @@ def test_every_line_carries_the_agreed_fields(sink): def test_a_plain_log_call_still_produces_a_well_formed_line(sink): - """An event code is optional — 125 existing call sites have none yet (E22.5 adds them), and they + """An event code is optional — 125 existing call sites have none yet, and they must not be worse off than before this module.""" logging.getLogger("siftlode.test").warning("just a message") payload = sink.objects()[-1] diff --git a/backend/tests/test_logsink.py b/backend/tests/test_logsink.py index f0965c9f..d2dc11c0 100644 --- a/backend/tests/test_logsink.py +++ b/backend/tests/test_logsink.py @@ -1,4 +1,4 @@ -"""E22 S3 — the `log_events` sink: what reaches the table, in what shape, and what it refuses to do. +"""The `log_events` sink: what reaches the table, in what shape, and what it refuses to do. The sink is driven synchronously (`write_pending()` on the test's own thread) so nothing here waits on a timer. It is attached to a private logger rather than the root, for the same reason test_logs.py's diff --git a/backend/tests/test_mail_language.py b/backend/tests/test_mail_language.py index b5316fbd..3ecf928e 100644 --- a/backend/tests/test_mail_language.py +++ b/backend/tests/test_mail_language.py @@ -1,4 +1,4 @@ -"""E9.5 S7 — every outbound mail is written in the reader's language. +"""Every outbound mail is written in the reader's language. Two halves. The first is the catalog invariant: HU/EN parity with identical placeholders, enforced here because a mail is the one user-facing surface nobody can re-render by switching the UI language @@ -160,7 +160,7 @@ def test_asking_again_from_another_language_updates_the_row(db): db.expire_all() inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one() - # The newest thing they told us wins — and there is still exactly one row (S3's canonical upsert). + # The newest thing they told us wins — and there is still exactly one row (the canonical upsert). assert inv.lang == "hu" assert db.execute(select(Invite)).scalars().all() == [inv] diff --git a/backend/tests/test_maintenance_lifecycle.py b/backend/tests/test_maintenance_lifecycle.py index a8698771..af16ab04 100644 --- a/backend/tests/test_maintenance_lifecycle.py +++ b/backend/tests/test_maintenance_lifecycle.py @@ -1,4 +1,4 @@ -"""sync/maintenance — the job that HARD-DELETES videos (E32 S2: 19% covered). +"""sync/maintenance — the job that HARD-DELETES videos (it was 19% covered). Its lifecycle: a video missing from videos.list is flagged and hidden; after the grace period a still-missing one is deleted (cascading to every playlist that held it) and each affected user diff --git a/backend/tests/test_migration_rerun_invalid_index.py b/backend/tests/test_migration_rerun_invalid_index.py index 6d419755..6653c7ac 100644 --- a/backend/tests/test_migration_rerun_invalid_index.py +++ b/backend/tests/test_migration_rerun_invalid_index.py @@ -1,4 +1,4 @@ -"""A concurrent index build that failed part-way, then re-run (E31 S5 review), DB-lane. +"""A concurrent index build that failed part-way, then re-run, DB-lane. CREATE INDEX CONCURRENTLY that fails leaves an INVALID index under its name, and alembic_version is not stamped, so the next start runs the migration again. `IF NOT EXISTS` matched the name and kept the diff --git a/backend/tests/test_not_found_detail.py b/backend/tests/test_not_found_detail.py index b7e9645e..0351d71e 100644 --- a/backend/tests/test_not_found_detail.py +++ b/backend/tests/test_not_found_detail.py @@ -1,4 +1,4 @@ -"""A row that is gone answers with a reason the bilingual UI can translate (deep-review round 3). +"""A row that is gone answers with a reason the bilingual UI can translate. The 404 a user actually meets is the one for something deleted in another tab or on another device. `owned_or_404` / `get_or_404` sent it as a bare English string ("Unknown view", "Not found"), and the @@ -24,8 +24,8 @@ def test_a_gone_view_tag_or_pin_says_what_is_gone(db, make_user, as_user): def test_a_gone_channel_video_or_user_says_what_is_gone(db, make_user, as_user): - """The siblings that still hand-wrote `db.get(...) is None → 404 "Unknown …"` (deep-review - round 4): the same gone row, but the Hungarian UI showed the English.""" + """The siblings that still hand-wrote `db.get(...) is None → 404 "Unknown …"`: the same + gone row, but the Hungarian UI showed the English.""" admin = make_user(role="admin") client = as_user(admin) for method, url, body, what in [ @@ -45,12 +45,12 @@ def test_a_gone_channel_video_or_user_says_what_is_gone(db, make_user, as_user): def test_every_gone_channel_route_says_it_the_same_way(db, make_user, as_user, override_dep): - """The round-4 sweep converted ONE of the five `db.get(Channel, …) is None -> 404 "Unknown + """An earlier sweep converted ONE of the five `db.get(Channel, …) is None -> 404 "Unknown channel"` sites (block), and shipped the HU/EN string with it — so the Hungarian UI said it in - Hungarian on the Block button and in English on the four beside it (deep-review round 5). The + Hungarian on the Block button and in English on the four beside it. The asymmetric fix is the smell this repo bans, so all five are pinned together. - Literally all five: round 6 found this loop issuing three requests under a docstring claiming + Literally all five: a review found this loop issuing three requests under a docstring claiming five, with explore silently absent and subscribe excused as "covered by the tags route" — which reaches `_user_subscription`, a different site answering `reason: subscription`. Reverting either of those two to its bare English string left the whole lane green.""" @@ -77,7 +77,7 @@ def test_a_gone_subscription_recipient_or_job_says_what_is_gone(db, make_user, a verbatim into an otherwise Hungarian UI: "Not subscribed to this channel", "No user with that email.", "Unknown job". - All three, with a request each. Round 6 found this test naming three and issuing two: the + All three, with a request each. A review found this test naming three and issuing two: the recipient site had no test in the whole suite (nor did the `errors.notFound.recipient` keys added beside it), and `run_job_now` rode on its PATCH sibling's coverage.""" admin = make_user(role="admin") @@ -117,8 +117,8 @@ def test_a_gone_subscription_recipient_or_job_says_what_is_gone(db, make_user, a def test_a_nul_in_a_request_string_is_a_422_not_a_500(db, make_user, as_user): """Postgres cannot hold NUL in text, so the driver refuses the query outright: a DataError, i.e. - a 500 with a traceback, wherever a `%00` or an escaped NUL reached a comparison (deep-review - round 4 — round 3 had guarded only the by-rating_key lookup). One app-wide handler now answers + a 500 with a traceback, wherever a `%00` or an escaped NUL reached a comparison (an + earlier fix had guarded only the by-rating_key lookup). One app-wide handler now answers all of them.""" client = as_user(make_user()) r = client.get("/api/plex/playlists?contains=%00") @@ -132,7 +132,7 @@ def test_any_other_data_error_is_still_a_500(): registered for EVERY DataError on every route and decides on a substring of the driver's message, so only a NUL may become a 422. Without this, simplifying it to an unconditional 422 — it reads like dead defensive code — would answer a numeric overflow or an over-long ingested title with - "your input was bad", losing the traceback that names the real fault (deep-review round 5).""" + "your input was bad", losing the traceback that names the real fault.""" import asyncio import pytest diff --git a/backend/tests/test_og.py b/backend/tests/test_og.py index 9882ab73..2e9e41aa 100644 --- a/backend/tests/test_og.py +++ b/backend/tests/test_og.py @@ -1,4 +1,4 @@ -"""JPEG dimension parsing for the /watch OG image tags (S2, item 6). +"""JPEG dimension parsing for the /watch OG image tags. Facebook's scraper fetches og:image asynchronously on first scrape, so the card is image-less unless og:image:width/height are declared — which needs the poster's real dimensions, read from the diff --git a/backend/tests/test_override_dep_convention.py b/backend/tests/test_override_dep_convention.py index 32eeb61a..89aab606 100644 --- a/backend/tests/test_override_dep_convention.py +++ b/backend/tests/test_override_dep_convention.py @@ -39,7 +39,7 @@ def test_a_request_gets_the_session_that_checks_its_commit(): def test_the_commit_check_counts_only_the_root_transaction(db): """A savepoint released inside a handler commits nothing to the database; ending it must not - clear the check (round 2). Driven directly, as a request would drive the dependency.""" + clear the check. Driven directly, as a request would drive the dependency.""" import pytest from starlette.requests import HTTPConnection @@ -63,7 +63,7 @@ def test_the_commit_check_counts_only_the_root_transaction(db): def test_a_request_that_raises_leaves_nothing_it_did_not_commit(db): """Production closes a failing request's session, which rolls back whatever it flushed and did not commit; the shared test session must do the same, or a later read in the test sees a write - production would have lost (E16: the tracker's issue link, kept only by an explicit commit in + production would have lost (the tracker's issue link, kept only by an explicit commit in the handler's failure path). Driven directly, as a request would drive the dependency.""" import pytest from sqlalchemy import select diff --git a/backend/tests/test_playlist_add_position.py b/backend/tests/test_playlist_add_position.py index d351b0f2..e766b0be 100644 --- a/backend/tests/test_playlist_add_position.py +++ b/backend/tests/test_playlist_add_position.py @@ -1,4 +1,4 @@ -"""E28 S3e2 — adding a video AT a position: the list page's "Undo" of a removal. +"""Adding a video AT a position: the list page's "Undo" of a removal. The row has to come back where it was in ONE request. The two-call shape (append, then send the whole order) leaves it at the end of the list whenever the second call fails, and the reorder would diff --git a/backend/tests/test_playlist_merge.py b/backend/tests/test_playlist_merge.py index 8059e320..7755104a 100644 --- a/backend/tests/test_playlist_merge.py +++ b/backend/tests/test_playlist_merge.py @@ -1,4 +1,4 @@ -"""S7 — two-way (3-way) YouTube playlist merge. +"""Two-way (3-way) YouTube playlist merge. The load-bearing property: an item added (or deleted) on EITHER side survives the sync — the old push was one-directional (local wins, deletes everything not local from YouTube), so YouTube-side diff --git a/backend/tests/test_playlist_move.py b/backend/tests/test_playlist_move.py index 9d6470f7..31e941b5 100644 --- a/backend/tests/test_playlist_move.py +++ b/backend/tests/test_playlist_move.py @@ -1,4 +1,4 @@ -"""E10 S2 — the one-step "move this video to another list" endpoint, and the `updated_at` touch +"""The one-step "move this video to another list" endpoint, and the `updated_at` touch the rail's "recently changed" sort reads. Two properties are load-bearing and neither is visible from the happy path alone: @@ -238,7 +238,7 @@ def test_item_edits_bump_updated_at(db, make_user, as_user): assert db.get(Playlist, src.id).updated_at > old assert db.get(Playlist, dst.id).updated_at > old - # …and removing one, the fourth item-only write (it had no test: E10 review coverage debt). + # …and removing one, the fourth item-only write (it had no test). src.updated_at = old db.commit() assert client.delete(f"/api/playlists/{src.id}/items/v1").status_code == 200 diff --git a/backend/tests/test_playlist_position_lock.py b/backend/tests/test_playlist_position_lock.py index 74e69cab..3785bd7a 100644 --- a/backend/tests/test_playlist_position_lock.py +++ b/backend/tests/test_playlist_position_lock.py @@ -1,4 +1,4 @@ -"""E28 review — every request that reads a playlist's item positions and writes new ones locks the +"""Every request that reads a playlist's item positions and writes new ones locks the playlist row first. Without it two adds (two tabs; the rail and the page) both read the same `max(position)` and land @@ -7,7 +7,7 @@ rows is then whatever the planner returns. A true two-connection race is not rep demand, so the test holds the mechanism: the parent row is taken `FOR UPDATE` by each such route. A Plex reorder that omits a row (added in another tab meanwhile) must not collide with it either. -Round 2: the lock also comes FIRST — before the request reads or writes a single item row. A remove +The lock also comes FIRST — before the request reads or writes a single item row. A remove that deleted an item and then touched the playlist beside a reorder that went the other way was a deadlock (a 500 for one of them), and an add that checked "already there?" before the lock let two adds of one video both insert (a 500 on the unique key). diff --git a/backend/tests/test_playlist_sync_claim.py b/backend/tests/test_playlist_sync_claim.py index b627cef7..cf63d762 100644 --- a/backend/tests/test_playlist_sync_claim.py +++ b/backend/tests/test_playlist_sync_claim.py @@ -1,4 +1,4 @@ -"""E28 review C1 — one YouTube sync of a playlist at a time, whoever starts it. +"""One YouTube sync of a playlist at a time, whoever starts it. A push reads YouTube and then writes both sides for seconds, committing step by step, so the client's in-flight flag (one page's menus) and a row lock (gone at the first commit) both leave two @@ -67,7 +67,7 @@ def test_push_and_revert_refuse_while_the_list_is_claimed( r = c.post(f"/api/playlists/{pl.id}/{path}") assert r.status_code == 409 assert r.json()["detail"] == {"code": "playlist", "reason": "sync_busy"} - # a delete too: a push mid-way would plan the deleted list as emptied (E28 round 4) + # a delete too: a push mid-way would plan the deleted list as emptied r = c.delete(f"/api/playlists/{pl.id}") assert r.status_code == 409 assert r.json()["detail"] == {"code": "playlist", "reason": "sync_busy"} @@ -132,7 +132,7 @@ def test_the_scheduled_refresh_skips_a_mirror_being_synced(db, make_user, monkey def test_refused_reverts_do_not_spend_the_pull_cooldown( db, as_user, make_user, override_dep, monkeypatch ): - """Round 2: the cooldown (three pulls per ten minutes) is checked after the claim, so clicking + """The cooldown (three pulls per ten minutes) is checked after the claim, so clicking Revert while a push runs is refused as busy without using up a pull.""" user = make_user() pl = _linked(db, user) diff --git a/backend/tests/test_playlist_sync_lock_order.py b/backend/tests/test_playlist_sync_lock_order.py index 5c631510..80ac6a48 100644 --- a/backend/tests/test_playlist_sync_lock_order.py +++ b/backend/tests/test_playlist_sync_lock_order.py @@ -1,7 +1,7 @@ -"""E28 review round 3 — the YouTube sync paths take the playlist row BEFORE they touch an item, and +"""The YouTube sync paths take the playlist row BEFORE they touch an item, and hold it to the commit that writes them. -Round 2 made every route edit lock the playlist first. The sync paths were left item-first (a push's +Every route edit already locked the playlist first. The sync paths were left item-first (a push's and a re-pull's item replace, a delete's cascade), so a push beside a remove could deadlock (a 500), and the scheduled mirror refresh re-ingested inside its lock — its commit let go of the lock before the replace, so a remove made then was undone and the list read as synced. A push also replaced the diff --git a/backend/tests/test_plex_admin_routes.py b/backend/tests/test_plex_admin_routes.py index 6de5f0aa..d39a4cde 100644 --- a/backend/tests/test_plex_admin_routes.py +++ b/backend/tests/test_plex_admin_routes.py @@ -1,4 +1,4 @@ -"""routes/plex/admin — the connection test and the owner's watch-sync link (E32 S2: 37% covered). +"""routes/plex/admin — the connection test and the owner's watch-sync link (it was 37% covered). The connection test is the admin's one early warning that the media mount or path map is wrong, and it must not quote the server's URL back in a toast. The watch link runs the one-time "Plex is master" diff --git a/backend/tests/test_plex_cards.py b/backend/tests/test_plex_cards.py index fc35fe8c..b1f1267a 100644 --- a/backend/tests/test_plex_cards.py +++ b/backend/tests/test_plex_cards.py @@ -1,6 +1,6 @@ """Plex card serializers (app.plex.cards) — pure model->dict projections, no DB needed. -E5 S1: movie/show cards now also emit `art` (the wide backdrop, for a Discover rail's landscape tile +Movie/show cards now also emit `art` (the wide backdrop, for a Discover rail's landscape tile and the crossfading hero), but ONLY when the item actually has an `art_key` — otherwise the URL would resolve to a blank image on every card that tried to use it, so it must be null and let the client fall back to the poster.""" diff --git a/backend/tests/test_plex_client.py b/backend/tests/test_plex_client.py index a2be2326..76362a4f 100644 --- a/backend/tests/test_plex_client.py +++ b/backend/tests/test_plex_client.py @@ -1,4 +1,4 @@ -"""plex/client — the one door to the Plex server (E32 S2: 31% covered). +"""plex/client — the one door to the Plex server (it was 31% covered). What matters here is what the callers cannot see: a failure must surface as PlexError (never as an empty answer), the paged reads must stop where they should, and the write URIs must be well formed. @@ -79,7 +79,7 @@ def test_a_section_page_asks_for_guids_and_reports_the_server_total(plex): assert params["includeGuids"] == "1" and params["type"] == "1" assert params["X-Plex-Container-Start"] == "200" and params["X-Plex-Container-Size"] == "50" - # Measured on the real server (E32 review): a paged request carries `totalSize` even when the + # Measured on the real server: a paged request carries `totalSize` even when the # page is empty, past the end, or the section holds nothing — `size: 0, totalSize: 0`. plex.routes["/library/sections/7/all"] = {"Metadata": [], "size": 0, "totalSize": 0} assert plex.section_items("7", 2) == ([], 0) @@ -168,7 +168,7 @@ def test_a_collection_is_created_with_a_well_formed_uri(plex): def test_a_server_without_a_machine_id_refuses_the_write(plex): """No machineIdentifier used to become `server:///…` in the write URI — a malformed request - Plex answers in its own way. It is refused here, named (E32 S2).""" + Plex answers in its own way. It is refused here, named.""" plex.routes["/identity"] = {} plex.routes["/library/collections"] = {"Metadata": [{"ratingKey": "c1"}]} with pytest.raises(PlexError, match="machine"): diff --git a/backend/tests/test_plex_detail.py b/backend/tests/test_plex_detail.py index bf18f9c5..cca81afb 100644 --- a/backend/tests/test_plex_detail.py +++ b/backend/tests/test_plex_detail.py @@ -1,4 +1,4 @@ -"""Regression (E5 S5b): the info-page rich detail (IMDb score/id, tagline, full cast) is persisted at +"""Regression: the info-page rich detail (IMDb score/id, tagline, full cast) is persisted at rich-fetch time and served from the DB row instead of a live plex.metadata() call. - `rich_detail`/`apply_rich_detail` are pure — parse + persist the rich-only fields. @@ -64,7 +64,7 @@ def test_rich_detail_prefers_imdb_entry_and_builds_link(): def test_rich_detail_is_imdb_only_no_audience_fallback(): - # E5 S6: imdb_rating is IMDb-ONLY. With no imdb:// Rating entry it's NULL — the Plex audience score + # Imdb_rating is IMDb-ONLY. With no imdb:// Rating entry it's NULL — the Plex audience score # is NOT mislabelled as an IMDb score (it's served separately via the `rating` column). d = detail.rich_detail({"audienceRating": 7.25}) assert d["imdb_rating"] is None @@ -113,7 +113,7 @@ class _StubClient: def test_apply_facet_fields_preserves_enriched_cast_names(): """A full sync must NOT downgrade an already-enriched full cast back to the listing top-20 — since sync never clears people_enriched_at, nothing would regrow it and the person-page filter would lose - the names past the listing cap (regression guard for E5 S5b's enrich gating).""" + the names past the listing cap (regression guard for the enrich gating).""" # The cheap listing's top few cast + the (always-present) director. thin = { "Role": [{"tag": "Al Pacino"}, {"tag": "James Caan"}], @@ -357,7 +357,7 @@ def test_show_detail_lazy_fills_when_unenriched(db, monkeypatch, override_dep): def test_item_detail_names_tracks_from_their_language_not_plexs_title( db, monkeypatch, override_dep ): - """E30: Plex titles a track in the Plex SERVER's language ("English (AC3 5.1)", "magyar Forced", + """Plex titles a track in the Plex SERVER's language ("English (AC3 5.1)", "magyar Forced", "中文 (EAC3 Stereo)"), so a Hungarian reader got a mix. The route sends what the client names it from instead — the language tag, the forced / SDH flags, the codec tail — keeping Plex's title only as the fallback, and no invented English "Audio 2" for a track with neither.""" diff --git a/backend/tests/test_plex_discover.py b/backend/tests/test_plex_discover.py index 75477af8..e3b4c52b 100644 --- a/backend/tests/test_plex_discover.py +++ b/backend/tests/test_plex_discover.py @@ -1,4 +1,4 @@ -"""E5 S4: the Plex Discover rail-surface. GET /api/plex/discover returns the consolidated rail +"""The Plex Discover rail-surface. GET /api/plex/discover returns the consolidated rail payload (continue watching / recently added / genre tiles / because-you-watched) served from the DB, and GET /api/plex/surprise returns a scope-bounded random sample. DB lane (`db` fixture) — skipped without Postgres. diff --git a/backend/tests/test_plex_facets.py b/backend/tests/test_plex_facets.py index 3589db2e..09c334e3 100644 --- a/backend/tests/test_plex_facets.py +++ b/backend/tests/test_plex_facets.py @@ -1,4 +1,4 @@ -"""DB-backed regression for the 0.53.1 hotfix (R6 S3b-test): GET /api/plex/facets?scope=show +"""DB-backed regression for the 0.53.1 hotfix: GET /api/plex/facets?scope=show must return 200 — not the "cannot extract elements from a scalar" 500 — for a metadata-poor library whose show has a JSONB-scalar `null` genres (a pre-0.53.1 row). The endpoint's `jsonb_typeof(genres)='array'` read-guard is what makes it safe; this test would 500 without it. @@ -17,7 +17,7 @@ from app.models import PlexItem, PlexLibrary, PlexShow, User def test_facets_returns_studio_vocabulary(db, monkeypatch, override_dep): - """E5 S7: /facets returns the studio vocabulary (distinct studio + count) for the sidebar picker.""" + """/facets returns the studio vocabulary (distinct studio + count) for the sidebar picker.""" lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) db.add(lib) db.flush() diff --git a/backend/tests/test_plex_facets_grid_parity.py b/backend/tests/test_plex_facets_grid_parity.py index 151bbc55..ad17cbb3 100644 --- a/backend/tests/test_plex_facets_grid_parity.py +++ b/backend/tests/test_plex_facets_grid_parity.py @@ -1,6 +1,6 @@ -"""E3 S1 characterization net — /library grid ⟷ /facets equivalence across watch-state scopes. +"""Characterization net — /library grid ⟷ /facets equivalence across watch-state scopes. -The Plex god-module split (E3 S1) extracts the movie/show watch-state predicates that are currently +The Plex god-module split extracts the movie/show watch-state predicates that are currently HAND-DUPLICATED in two places: `unified_library` (the grid) and `facets.wsq` (the facet aggregation). The facets docstring promises the facets "reflect exactly the visible result set" — i.e. with NO metadata filter active (only the `show` state scope), a facet's genre/content-rating counts must equal @@ -218,7 +218,7 @@ def test_hidden_movie_absent_from_grid_and_facets(db, as_user): def test_a_watch_state_scope_rolls_the_episodes_up_once(db, as_user): """The per-user episode rollup behind a show's watch state is a pass over every episode; each show - facet group asked it again (E31 S4e: half of a filtered facets call). It is asked once now.""" + facet group asked it again (half of a filtered facets call). It is asked once now.""" from sqlalchemy import event from app.db import engine diff --git a/backend/tests/test_plex_field_scoped_search.py b/backend/tests/test_plex_field_scoped_search.py index 2b375f17..c2aafd5f 100644 --- a/backend/tests/test_plex_field_scoped_search.py +++ b/backend/tests/test_plex_field_scoped_search.py @@ -1,4 +1,4 @@ -"""S2 · field-scoped search — the `field` param on /api/plex/library. +"""Field-scoped search — the `field` param on /api/plex/library. The search box's scope-pill narrows a full-text query to ONE facet of the row: Everywhere (`all`, the untouched weighted `search_vector`), Titles, Cast & crew (`people`), Descriptions (`summary`), diff --git a/backend/tests/test_plex_hls_file_route.py b/backend/tests/test_plex_hls_file_route.py index 127086bb..56571696 100644 --- a/backend/tests/test_plex_hls_file_route.py +++ b/backend/tests/test_plex_hls_file_route.py @@ -5,11 +5,11 @@ seek-restart — or a second viewer of the same item — replaces the registered viewer's next request for `seg_12.ts`, resolved by rating_key, was answered 200 with the NEW session's twelfth segment: bytes produced from a different `-ss` offset under `-copyts`, i.e. a timeline the client is not on. Wrong content with a success status is the one failure a player cannot -notice, and the reclaim-on-404 added in round 4 could never fire on it (deep-review round 5). +notice, and the client's reclaim-on-404 could never fire on it. No `db` fixture: the route reads nothing from the database (its own `Depends(get_db)` went with the removed auto-start, and `current_user` is overridden wholesale), so these must not sit in the DB -lane — there they SKIP on a machine with no Postgres while pytest prints green (round 6). +lane — there they SKIP on a machine with no Postgres while pytest prints green. """ import time @@ -127,7 +127,7 @@ def test_a_segment_that_will_never_arrive_fails_at_once(client, tmp_path, sessio """`stream_hls_file` is a SYNC route: it runs on the ~40-slot threadpool and holds a pooled DB connection while it waits. It used to poll out the whole 20 s for a name that could never appear — an ordinary seek past the produced region was enough to park a worker and a connection, and - forty hopeless names parked the API (deep-review round 5). ffmpeg writes segments in order, so a + forty hopeless names parked the API. ffmpeg writes segments in order, so a segment whose predecessor is absent is not the next one, and that is knowable at once — even while ffmpeg is still running, which is the case `proc` alone cannot shorten.""" d = tmp_path / "m1_0_0_+0.00" @@ -152,11 +152,11 @@ def test_a_segment_that_will_never_arrive_fails_at_once(client, tmp_path, sessio def test_a_multi_audio_segment_beyond_the_region_fails_at_once_too( client, tmp_path, sessions, name ): - """The round-5 guard read `seg__.ts`, but ffmpeg substitutes `%v` FIRST — so the + """An earlier guard read `seg__.ts`, but ffmpeg substitutes `%v` FIRST — so the real names are `seg__.ts` and every multi-audio request fell through to the full 20 s wait: the video rendition because its variant "0" parsed as segment 0, the audio renditions (`a0`, `a1`) because they did not parse at all. Dual-audio is the common shape in this library, - so the guard was inert exactly where it was needed (round 6).""" + so the guard was inert exactly where it was needed.""" d = tmp_path / "m1_0_multi_+0.00" d.mkdir() (d / "seg_0_0.ts").write_bytes(b"x") @@ -261,7 +261,7 @@ def test_the_name_ffmpeg_is_asked_for_is_a_name_the_guard_understands(tmp_path, def test_the_argv_asks_for_exactly_the_names_the_guard_accepts(tmp_path): """The producer and the parser, pinned to each other through the argv itself. - Round 6's HIGH was a guard that read `seg_%v_%d.ts` sequence-first, so it was inert on every + The bug was a guard that read `seg_%v_%d.ts` sequence-first, so it was inert on every multi-audio item, and a warm-up that waited 8 s for a name ffmpeg never writes. The fix was to give the layout one spelling — but a comment saying so is what had just failed, so assert that the `-hls_segment_filename` pattern in the real argv is literally what `segment_name` produces, @@ -302,7 +302,7 @@ def test_a_name_only_the_other_argv_branch_writes_is_refused_at_once(tmp_path, c it can never write — and vice versa. Accepting the union read them as "an HLS name, so wait and see", and each one then held a threadpool worker and its pooled DB connection for the full timeout: measured through this route against a live single-audio session, `master.m3u8` took - 30.0 s and `seg_a0_0.ts` 20.1 s to answer 404 (deep-review round 7).""" + 30.0 s and `seg_a0_0.ts` 20.1 s to answer 404.""" single = tmp_path / "m1_0_0_+0.00" single.mkdir() sessions[(VIEWER, "m1")] = _Session("sid-a", single, running=True) diff --git a/backend/tests/test_plex_image_sizes.py b/backend/tests/test_plex_image_sizes.py index 3c250296..8091998d 100644 --- a/backend/tests/test_plex_image_sizes.py +++ b/backend/tests/test_plex_image_sizes.py @@ -1,4 +1,4 @@ -"""A card asks the Plex image proxy for a small cut; a detail page keeps the full one (E31 S4e). +"""A card asks the Plex image proxy for a small cut; a detail page keeps the full one. Cards fetched the 400×600 poster for a ~144px picture and the 1280×720 backdrop for a ~212px shelf picture. The `_sm` variants are cut by Plex at a card's size and cached apart from the full ones; an diff --git a/backend/tests/test_plex_jsonb_normalize.py b/backend/tests/test_plex_jsonb_normalize.py index c204af42..77fd2a04 100644 --- a/backend/tests/test_plex_jsonb_normalize.py +++ b/backend/tests/test_plex_jsonb_normalize.py @@ -1,4 +1,4 @@ -"""DB-backed test for the S3a data normalization (migration 0060): a JSONB scalar `null` in the +"""DB-backed test for the data normalization (migration 0060): a JSONB scalar `null` in the plex tag columns becomes SQL NULL, while a real array is left untouched. Runs the migration's exact UPDATE statements against seeded rows (the migration itself is a no-op in the empty test DB). diff --git a/backend/tests/test_plex_keyword_filter.py b/backend/tests/test_plex_keyword_filter.py index 4a0cbbdb..196ee9ed 100644 --- a/backend/tests/test_plex_keyword_filter.py +++ b/backend/tests/test_plex_keyword_filter.py @@ -1,4 +1,4 @@ -"""E5 S5: the TMDB keyword filter. A keyword chip on the detail page sets ?keywords=, which +"""The TMDB keyword filter. A keyword chip on the detail page sets ?keywords=, which narrows the unified library to rows whose persisted `keywords` JSONB array contains it. DB lane (`db` fixture) — skipped without Postgres. """ diff --git a/backend/tests/test_plex_lang.py b/backend/tests/test_plex_lang.py index 00da79c9..1c112575 100644 --- a/backend/tests/test_plex_lang.py +++ b/backend/tests/test_plex_lang.py @@ -1,4 +1,4 @@ -"""E5 S6: HU-overlay language serving. When ?lang=hu, the unified library, facets, genre filter and +"""HU-overlay language serving. When ?lang=hu, the unified library, facets, genre filter and detail endpoints serve the Hungarian `*_hu` columns (falling back to EN where a row has no HU twin); ?lang=en (the default) serves English. Also covers the imdb_rating IMDb-only / audience-score split. DB lane (`db` fixture) — skipped without Postgres. @@ -119,7 +119,7 @@ def test_genre_filter_matches_active_language_vocabulary(db, monkeypatch, overri def test_a_genre_filter_saved_in_english_still_matches_in_hungarian(db, monkeypatch, override_dep): - """E30: the HU column holds one Hungarian word per genre. A filter saved while the HU list still + """The HU column holds one Hungarian word per genre. A filter saved while the HU list still offered "Action" is read through the same map, so it finds the "Akció" title instead of nothing.""" user = _seed(db) resp = _get( @@ -129,7 +129,7 @@ def test_a_genre_filter_saved_in_english_still_matches_in_hungarian(db, monkeypa def test_a_genre_the_hungarian_list_offers_in_english_still_matches(db, monkeypatch, override_dep): - """E30 review: a row the sync has not given Hungarian genres yet is listed under its English word + """A row the sync has not given Hungarian genres yet is listed under its English word (the facets read coalesce(genres_hu, genres)); filtering by that word must find it.""" user = _seed(db) facets = _get(db, user, monkeypatch, override_dep, "/api/plex/facets?scope=movie&lang=hu") @@ -161,7 +161,7 @@ def test_item_detail_language_and_rating_split(db, monkeypatch, override_dep): body = resp.json() assert body["title"] == "Magyar Cím" assert body["summary"] == "Magyar összefoglaló." - assert body["tagline"] == "Magyar mottó." # E30: the HU overlay's tagline + assert body["tagline"] == "Magyar mottó." # the HU overlay's tagline en = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1").json() assert en["tagline"] == "English tagline." # imdb_rating is IMDb-only (None here); the audience score is served separately as `rating`. @@ -285,7 +285,7 @@ def _seed_rails(db): def test_every_card_rail_serves_the_readers_language(db, monkeypatch, override_dep): - """E30: the grid spoke Hungarian, but every other card surface (Discover rails, show page, + """The grid spoke Hungarian, but every other card surface (Discover rails, show page, search, collection, person page, playlist, Home) served the English title to a HU reader.""" user, pid = _seed_rails(db) diff --git a/backend/tests/test_plex_last_watched_sort.py b/backend/tests/test_plex_last_watched_sort.py index 9f34e808..f3ef2f31 100644 --- a/backend/tests/test_plex_last_watched_sort.py +++ b/backend/tests/test_plex_last_watched_sort.py @@ -1,4 +1,4 @@ -"""S3: the "last watched" browse sort (`sort=lastwatched`). Movies rank by +"""The "last watched" browse sort (`sort=lastwatched`). Movies rank by coalesce(progress_updated_at, watched_at); shows by max(coalesce(...)) across their episodes (rollup.resumed_at). Never-watched titles sink to the bottom (nullslast). DB lane (`db` fixture) — skipped without Postgres. diff --git a/backend/tests/test_plex_overlay.py b/backend/tests/test_plex_overlay.py index 3497da53..35148196 100644 --- a/backend/tests/test_plex_overlay.py +++ b/backend/tests/test_plex_overlay.py @@ -1,4 +1,4 @@ -"""DB-backed regression (E5 S5a): the HU-overlay sync writes Hungarian metadata onto the matched EN +"""DB-backed regression: the HU-overlay sync writes Hungarian metadata onto the matched EN rows (paired by guid), never creating browsable HU rows, and overlay libraries are excluded from the browse scope. Also covers the canonical-guid parse and that the generated search_vector_hu populates. @@ -197,7 +197,7 @@ def test_overlay_reconcile_clears_stale_hu_when_twin_vanishes(db): def test_a_listing_cut_short_keeps_the_hungarian_text_it_did_not_reach(db): - """E32 S2: the reconcile clears HU text only after a COMPLETE listing. A walk a Plex hiccup cut + """The reconcile clears HU text only after a COMPLETE listing. A walk a Plex hiccup cut short (an empty page before the server's total), or an empty section, says nothing about the titles it did not reach — clearing them dropped their Hungarian text until the next full sync.""" en_lib = PlexLibrary(plex_key="4", title="Movies", kind="movie") @@ -304,7 +304,7 @@ class _KindStub: def test_a_show_overlay_writes_hungarian_episode_titles_and_summaries(db): - """E30: the HU TV section lists every episode in Hungarian; they were never read, so a Hungarian + """The HU TV section lists every episode in Hungarian; they were never read, so a Hungarian reader saw every episode title and summary in English.""" en_lib = PlexLibrary(plex_key="1", title="TV Shows", kind="show") hu_lib = PlexLibrary(plex_key="9", title="Sorozatok", kind="show", metadata_lang="hu") @@ -341,7 +341,7 @@ def test_a_show_overlay_writes_hungarian_episode_titles_and_summaries(db): def test_the_overlay_gives_every_row_one_hungarian_word_per_genre(db): - """E30: Plex's HU agent mixes "Action" with "Dráma", and a title with no HU twin kept only its + """Plex's HU agent mixes "Action" with "Dráma", and a title with no HU twin kept only its English list — so the HU genre list offered "Drama" beside "Dráma".""" en_lib = PlexLibrary(plex_key="4", title="Movies", kind="movie") hu_lib = PlexLibrary(plex_key="9", title="Filmek", kind="movie", metadata_lang="hu") @@ -407,7 +407,7 @@ def test_a_twin_without_genres_keeps_the_rows_own_hungarian_list(db): def test_every_synced_row_gets_hungarian_genres(): - """E30 review: not only an overlaid row — the HU facets and filter read genres_hu for all.""" + """Not only an overlaid row — the HU facets and filter read genres_hu for all.""" from app.plex.sync import _apply_facet_fields row = PlexItem(title="Solo") diff --git a/backend/tests/test_plex_paths.py b/backend/tests/test_plex_paths.py index 446e8bd7..02ea1578 100644 --- a/backend/tests/test_plex_paths.py +++ b/backend/tests/test_plex_paths.py @@ -1,4 +1,4 @@ -"""plex/paths — the Plex-side path → local mount translation (E32 S2: 23% covered, and a wrong answer +"""plex/paths — the Plex-side path → local mount translation (it was 23% covered, and a wrong answer here reads as "media file not found" for a whole library at once, with nothing else failing). Pure logic: the admin map is read through sysconfig, stubbed per test. diff --git a/backend/tests/test_plex_people_enrich.py b/backend/tests/test_plex_people_enrich.py index 33ff2fb7..d10909d2 100644 --- a/backend/tests/test_plex_people_enrich.py +++ b/backend/tests/test_plex_people_enrich.py @@ -1,4 +1,4 @@ -"""E30 S6: the people are made local ahead of time. The people_enrich job fetches linked people's TMDB +"""The people are made local ahead of time. The people_enrich job fetches linked people's TMDB bios (the most-credited first) and caches their portraits resized through Plex; the person page says when a bio is still on its way (`enriching`), so it can ask again instead of staying empty. DB lane (Postgres); the portrait cache runs in a temp directory. diff --git a/backend/tests/test_plex_person.py b/backend/tests/test_plex_person.py index 9ce49958..9e7519f4 100644 --- a/backend/tests/test_plex_person.py +++ b/backend/tests/test_plex_person.py @@ -1,4 +1,4 @@ -"""DB-backed regression (E5 S4): GET /api/plex/person/{name} returns a cast/crew member's +"""DB-backed regression: GET /api/plex/person/{name} returns a cast/crew member's filmography — movies + shows in the ENABLED libraries whose cast OR directors include the name, each carrying the caller's watch-state, plus the cached headshot. Also covers `content_libs` scoping (disabled libraries excluded) and the kind filter (episodes are not movies). @@ -123,7 +123,7 @@ def test_person_detail_unknown_name_is_empty(db, monkeypatch, override_dep): resp = _get(db, user, monkeypatch, override_dep, "/api/plex/person/Ghost%20Actor") assert resp.status_code == 200, resp.text body = resp.json() - # An unknown name has no plex_people row → no bio, no titles (E5 S6 added the bio fields, all null). + # An unknown name has no plex_people row → no bio, no titles (the bio fields are all null). assert body == { "name": "Ghost Actor", "photo": None, diff --git a/backend/tests/test_plex_person_bio.py b/backend/tests/test_plex_person_bio.py index c52e570b..9dadf06d 100644 --- a/backend/tests/test_plex_person_bio.py +++ b/backend/tests/test_plex_person_bio.py @@ -1,4 +1,4 @@ -"""E5 S6: person page TMDB bio enrichment. `parse_person` shapes the /person payload (bio + a +"""Person page TMDB bio enrichment. `parse_person` shapes the /person payload (bio + a popularity-ranked, deduped `known_for`); `enrich_person` lazily fetches + persists once (guarded on a key + a tmdb_person_id); and /person/{name} serves the bio + resolves `known_for` to library rows. DB lane for the endpoint/enrich tests; parse_person is pure. Skipped without Postgres where DB-backed. @@ -335,7 +335,7 @@ def test_person_detail_serves_bio_and_resolves_known_for(db, monkeypatch, overri assert [c["title"] for c in j["known_for"]] == ["Known Movie"] -# --- E30 S4: the biography in the reader's language -------------------------------------------------- +# --- the biography in the reader's language ------------------------------------------------------- # The same /person request appends `translations`; the Hungarian one is kept beside the instance-language # biography, and a Hungarian reader is served it, falling back to the English one when TMDB has none. diff --git a/backend/tests/test_plex_playback_class.py b/backend/tests/test_plex_playback_class.py index b08b2ee1..82f16212 100644 --- a/backend/tests/test_plex_playback_class.py +++ b/backend/tests/test_plex_playback_class.py @@ -1,4 +1,4 @@ -"""The playback-class filter (E7): filter the Plex feed by Direct Play / Direct Stream / Transcode·HW / +"""The playback-class filter: filter the Plex feed by Direct Play / Direct Stream / Transcode·HW / Transcode·SW. Covers the shared classifier + Stream parsing (unit), the sync derivation + codec-change reset, the bit-depth backfill, and the /library filter + /facets counts (DB lane). diff --git a/backend/tests/test_plex_playback_session.py b/backend/tests/test_plex_playback_session.py index 5dd921da..d7339afb 100644 --- a/backend/tests/test_plex_playback_session.py +++ b/backend/tests/test_plex_playback_session.py @@ -1,4 +1,4 @@ -"""The playback-session route's mode routing (E6 S1b). +"""The playback-session route's mode routing. `POST /stream/{rk}/session` is the single switch that decides how an item plays: a browser-native file is served raw (`mode:"direct"`), everything else (re)starts an HLS session — and a diff --git a/backend/tests/test_plex_playlist_add_positions.py b/backend/tests/test_plex_playlist_add_positions.py index 3d8f970a..6c20ed6d 100644 --- a/backend/tests/test_plex_playlist_add_positions.py +++ b/backend/tests/test_plex_playlist_add_positions.py @@ -1,4 +1,4 @@ -"""E28 S3e2 — putting removed Plex playlist rows back WHERE THEY WERE: the list page's "Undo". +"""Putting removed Plex playlist rows back WHERE THEY WERE: the list page's "Undo". A removal can take one row, a season or a whole show, and a season's episodes need not sit together (a reorder inside the show interleaves them), so each key carries its own index — over the ORDERED diff --git a/backend/tests/test_plex_query_conversion.py b/backend/tests/test_plex_query_conversion.py index ba919ba3..5f63c507 100644 --- a/backend/tests/test_plex_query_conversion.py +++ b/backend/tests/test_plex_query_conversion.py @@ -1,4 +1,4 @@ -"""S1 characterization net for the Plex-package db.query() -> SQLAlchemy 2.0 select() conversion. +"""Characterization net for the Plex-package db.query() -> SQLAlchemy 2.0 select() conversion. These lock the OBSERVABLE behaviour of the previously-untested hot query paths — the unified_library union builder (+ its watch-state branches, ordering, search ranking, episode grouping), the rollup diff --git a/backend/tests/test_plex_stream.py b/backend/tests/test_plex_stream.py index 22859570..d8d6c2b3 100644 --- a/backend/tests/test_plex_stream.py +++ b/backend/tests/test_plex_stream.py @@ -832,7 +832,7 @@ class TestProbeVideoKind: class TestProbeExitStatus: - """A probe whose ffprobe EXITS NON-ZERO is a failed probe, not an answer (E32). Each probe used to + """A probe whose ffprobe EXITS NON-ZERO is a failed probe, not an answer. Each probe used to read stdout without looking at the exit status, so a refusing ffprobe silently picked the fallback — HW-safe, single audio, the requested offset — and skipped the warning its except branch promises. Drives the REAL subprocess.run with the probe's own kwargs against a stand-in command that prints a diff --git a/backend/tests/test_plex_subtitle_auth.py b/backend/tests/test_plex_subtitle_auth.py index edbc5fb3..c9beb4f8 100644 --- a/backend/tests/test_plex_subtitle_auth.py +++ b/backend/tests/test_plex_subtitle_auth.py @@ -3,7 +3,7 @@ They used to sit behind `_require_session`, the cookie-only gate the poster proxies use — a signed cookie was enough, with none of `current_user`'s checks (is_active, is_suspended, the session epoch that "log out everywhere" bumps). Fine for a poster; not for a route that starts reads of the media -library. Found by the 2026-09 bug-hunt deep-review; the gate itself pre-dates that round. +library. Found by a 2026-09 code review; the gate itself pre-dates that review. """ from fastapi.testclient import TestClient diff --git a/backend/tests/test_plex_subtitle_prepare.py b/backend/tests/test_plex_subtitle_prepare.py index 87677ab8..613af2e3 100644 --- a/backend/tests/test_plex_subtitle_prepare.py +++ b/backend/tests/test_plex_subtitle_prepare.py @@ -12,7 +12,7 @@ thread sat with it), and picking the second language cost the whole read again. These tests pin the three things that fixes it: the request never waits for ffmpeg, the extraction publishes a window around the playhead before the whole track, and one ffmpeg run covers every -embedded text track — plus what the deep-review of that fix found (cues doubled at the window/full +embedded text track — plus what the review of that fix found (cues doubled at the window/full seam, a truncated pass cached as complete, a poll that respawned ffmpeg every second, a sidecar stuck behind an unrelated pass). No Postgres (the auth half is in test_plex_subtitle_auth.py, which needs the DB). Almost every test stubs ffmpeg out, but not all: a few run a real child process, and @@ -316,7 +316,7 @@ def test_a_cached_vtt_from_an_older_build_counts_as_complete(tmp_path): assert subtitles.read_meta(key) == ([[0.0, 1_000_000_000.0]], True) -# --- the deep-review round: the seams between the window and the full pass ---------------------- +# --- the seams between the window and the full pass --------------------------------------------- def test_a_window_and_the_full_pass_do_not_double_a_cue(): @@ -614,7 +614,7 @@ def test_an_absurd_position_is_rejected_before_it_reaches_ffmpeg(client, monkeyp assert pool.jobs == [] -# --- the second deep-review round --------------------------------------------------------------- +# --- failed passes, seeks, saturation and shutdown ---------------------------------------------- def _window_raises(monkeypatch): @@ -865,7 +865,7 @@ def test_a_full_pass_resumed_mid_file_lines_up_on_a_real_file(tmp_path, monkeypa assert merged.count("-->") == 30, "a cue across the join was kept twice or lost" -# --- the third deep-review round ---------------------------------------------------------------- +# --- transient failures, read-ahead, memos and slot fairness ------------------------------------ def test_a_transient_failure_is_said_but_is_not_a_final_answer(monkeypatch): @@ -1094,7 +1094,7 @@ def test_a_nul_rating_key_names_no_row(): assert by_rating_key(None, PlexItem, "12\x0034") is None # type: ignore[arg-type] -# --- the fourth deep-review round --------------------------------------------------------------- +# --- output that could not be kept, absurd keys ------------------------------------------------- def test_a_pass_whose_output_could_not_be_kept_is_backed_off_like_a_failed_one(monkeypatch): @@ -1154,7 +1154,7 @@ def test_an_absurd_rating_key_or_ordinal_is_refused_not_a_500(client, monkeypatc # ENAMETOOLONG rather than the real filesystem: on Linux pathlib re-raises it (the defect), but # Windows reports ERROR_INVALID_NAME, which pathlib's `exists()` already swallows — so on a dev # machine this assertion passed with `_exists` reverted and only the container lane was honest - # about it (deep-review round 5). + # about it. def _too_long(self): raise OSError(errno.ENAMETOOLONG, "File name too long") diff --git a/backend/tests/test_plex_sync_prune.py b/backend/tests/test_plex_sync_prune.py index c7b06a10..cfb5232a 100644 --- a/backend/tests/test_plex_sync_prune.py +++ b/backend/tests/test_plex_sync_prune.py @@ -1,4 +1,4 @@ -"""E3 S1 catalog-prune (B-sub-6): a sync now deletes rows for titles that disappeared from Plex, +"""Catalog prune: a sync now deletes rows for titles that disappeared from Plex, diffing each section's freshly-listed rating_keys against the stored ones. The prune is GUARDED on a non-empty listing so a transient Plex glitch (an empty page) can never wipe a whole library. diff --git a/backend/tests/test_plex_sync_txn.py b/backend/tests/test_plex_sync_txn.py index f714b432..a2c19ea5 100644 --- a/backend/tests/test_plex_sync_txn.py +++ b/backend/tests/test_plex_sync_txn.py @@ -1,4 +1,4 @@ -"""DB-backed test for R6 S2's Plex per-section commit: a PlexError on a LATER library must keep the +"""DB-backed test for the Plex per-section commit: a PlexError on a LATER library must keep the libraries already mirrored (committed per section), not roll the whole reconcile back. Uses the DB lane (`db` fixture) with a faked PlexClient — skipped without Postgres. """ diff --git a/backend/tests/test_plex_tmdb.py b/backend/tests/test_plex_tmdb.py index e09335ee..7ee9cc44 100644 --- a/backend/tests/test_plex_tmdb.py +++ b/backend/tests/test_plex_tmdb.py @@ -1,4 +1,4 @@ -"""TMDB enrichment engine (E5 S2): the pure parser + persister, guid→tmdb-id resolution, and the +"""TMDB enrichment engine: the pure parser + persister, guid→tmdb-id resolution, and the DB-lane batch/serve integration (enrich stamps the watermark + links people; serve_block resolves recommendations to library rows; the whole thing no-ops without an API key). @@ -126,7 +126,7 @@ def test_parse_movie_pulls_all_fields(): }, ] # the Gaffer is filtered; the two Coppola jobs survive (dedup is by name+job) assert d["studios"] == ["Paramount", "Alfran"] - # E30: the code beside the English name, so the client can name the country in the reader's language. + # The code beside the English name, so the client can name the country in the reader's language. assert d["production_countries"] == [{"code": "US", "name": "United States of America"}] assert [r["tmdb_id"] for r in d["recommendations"]] == [240, 999] assert d["backdrops"] == ["/bd1.jpg", "/bd2.jpg"] @@ -432,7 +432,7 @@ def test_resolve_recs_matches_by_media_type_not_bare_id(db): assert hit[500]["in_library"] is True and hit[500]["rating_key"] == "s500" -# ---- E30: countries by ISO code ----------------------------------------------------------------- +# ---- countries by ISO code ----------------------------------------------------------------------- class FakeCountries(FakeTmdb): diff --git a/backend/tests/test_plex_watch_reconcile.py b/backend/tests/test_plex_watch_reconcile.py index ce50368c..1b2e7e61 100644 --- a/backend/tests/test_plex_watch_reconcile.py +++ b/backend/tests/test_plex_watch_reconcile.py @@ -1,5 +1,5 @@ """The daily full watch reconcile (`run_plex_watch_reconcile`) — the one pass that DELETES watch state: -a mirrored row Plex no longer has a record for is read as "Plex un-watched it" and cleared. E32 S2 +a mirrored row Plex no longer has a record for is read as "Plex un-watched it" and cleared. A review found it trusting a scan a Plex hiccup had cut short; these hold what it may and may not clear. """ @@ -149,7 +149,7 @@ _LISTED = [ def test_a_genuinely_empty_second_section_still_lets_the_reconcile_clear(db, monkeypatch, world): # The server SAYS the shows section holds 0: that is a complete listing, not a hiccup — before - # the E32 review it read as "incomplete" forever and un-watches never synced again. + # a review it read as "incomplete" forever and un-watches never synced again. state, rows = world _local_states(state) db.commit() @@ -181,8 +181,8 @@ def test_a_section_we_mirror_that_says_0_keeps_its_rows_and_does_not_hold_up_the db, monkeypatch, world ): # A total of 0 from a section our items come from is a Plex that lost the library for a moment, - # not one that un-watched all of it (E32 review round 2): its rows stay. And it is decided per - # section: before round 3 one such section (a library emptied on Plex, its mirrored items kept) + # not one that un-watched all of it: its rows stay. And it is decided per + # section: at first one such section (a library emptied on Plex, its mirrored items kept) # made EVERY run incomplete, and un-watches in every other section never synced again. state, rows = world _local_states(state) @@ -196,7 +196,7 @@ def test_a_section_we_mirror_that_says_0_keeps_its_rows_and_does_not_hold_up_the def test_a_library_the_allowlist_leaves_out_keeps_its_watch_states(db, monkeypatch, world): # Its items are absent from every scan because the scan never walks it — not because Plex - # un-watched them. It cleared them on every run (E32 review round 2). + # un-watched them. It cleared them on every run. state, _rows = world other = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) db.add(other) diff --git a/backend/tests/test_plex_watch_sync.py b/backend/tests/test_plex_watch_sync.py index c812d147..6e119974 100644 --- a/backend/tests/test_plex_watch_sync.py +++ b/backend/tests/test_plex_watch_sync.py @@ -1,4 +1,4 @@ -"""S2 watch-sync semantics: Plex is the source of truth. +"""Watch-sync semantics: Plex is the source of truth. Two independent pieces: - `_pull_apply` (DB lane): on a genuine divergence for an item Plex knows about, Plex now WINS — @@ -45,7 +45,7 @@ class _FakePlex: def test_pull_plex_wins_over_diverging_synced_local_resume(db): - """The core S2 change: a SYNCED local row (its state already reached Plex) that later diverges from + """The core change: a SYNCED local row (its state already reached Plex) that later diverges from Plex = a real external Plex-side change, so the incremental pull resolves to PLEX's position — Plex is authoritative, no LWW. (A DIRTY local row is protected instead — see the dirty-guard test.)""" item_id, user_id = _seed_item_and_user(db) @@ -210,7 +210,7 @@ def test_pull_settles_flag_on_matching_dirty_row(db): def test_movie_probe_flips_stale_in_progress_to_watched(db): - """S1 core: a local in-progress MOVIE that Plex has since finished (viewCount>0) and dropped from + """Core: a local in-progress MOVIE that Plex has since finished (viewCount>0) and dropped from Continue-Watching flips to watched on the incremental pass — without waiting for the daily reconcile. This is the "movie stuck in progress after scrubbing the credits" bug.""" item_id, user_id = _seed_item_and_user(db) diff --git a/backend/tests/test_python_engine_contract.py b/backend/tests/test_python_engine_contract.py index f7298c8a..808d9735 100644 --- a/backend/tests/test_python_engine_contract.py +++ b/backend/tests/test_python_engine_contract.py @@ -1,4 +1,4 @@ -"""The Python major is a contract, not a coincidence — the backend half (E27 S3). +"""The Python major is a contract, not a coincidence — the backend half. `0.93.2` was released to fix exactly this bug on the JavaScript side: four machines built or checked the app and none of them agreed on which Node they ran, the server gate went red for 73 of its last diff --git a/backend/tests/test_quota_ownership.py b/backend/tests/test_quota_ownership.py index 111fad14..a951ef98 100644 --- a/backend/tests/test_quota_ownership.py +++ b/backend/tests/test_quota_ownership.py @@ -1,4 +1,4 @@ -"""DB-backed tests for R6 S2 quota transaction ownership: quota accounting writes in its OWN +"""DB-backed tests for quota transaction ownership: quota accounting writes in its OWN session, so a spend is DURABLE even when the calling job rolls back (the units were really consumed at YouTube), and it NEVER commits the caller's session (the old behaviour flushed a job's partial state mid-run behind its own rollback). Uses the DB lane (`db` fixture) — skipped without Postgres. diff --git a/backend/tests/test_request_models.py b/backend/tests/test_request_models.py index 6309aa75..5b5d48b0 100644 --- a/backend/tests/test_request_models.py +++ b/backend/tests/test_request_models.py @@ -1,4 +1,4 @@ -"""R6 S1b: the request models turn payloads that used to 500 into clean validation errors, +"""The request models turn payloads that used to 500 into clean validation errors, and PATCH models distinguish an absent field from an explicit null. Pure-logic (no DB) — it exercises the Pydantic models the route modules define, not the endpoints.""" @@ -51,7 +51,7 @@ def test_admin_quota_rejects_non_numeric_limit(): def test_progress_accepts_fractional_seconds(): # The player may post a raw currentTime (a float). The field must accept it (truncated in the - # handler) — a plain `int` field would 422 on the progress hot path. (code-review round 1.) + # handler) — a plain `int` field would 422 on the progress hot path. assert VideoProgressIn(position_seconds=12.7).position_seconds == 12.7 assert int(VideoProgressIn(position_seconds=12.7).position_seconds) == 12 with pytest.raises(ValidationError): diff --git a/backend/tests/test_reset_link_states.py b/backend/tests/test_reset_link_states.py index 30f0e7c7..05380afc 100644 --- a/backend/tests/test_reset_link_states.py +++ b/backend/tests/test_reset_link_states.py @@ -1,4 +1,4 @@ -"""What a dead email link says, and what it costs to ask (E9.5 S4 follow-up), DB-lane. +"""What a dead email link says, and what it costs to ask, DB-lane. Reported 2026-09-05: delete an account, then open its password-reset link. The card drew a full "set a new password" form — because a `#reset=` fragment was present, nothing more — and only after diff --git a/backend/tests/test_response_checked.py b/backend/tests/test_response_checked.py index 655c8a23..b649078b 100644 --- a/backend/tests/test_response_checked.py +++ b/backend/tests/test_response_checked.py @@ -1,7 +1,7 @@ -"""No test sends a request and throws the response away (E32 S2). +"""No test sends a request and throws the response away. A discarded `client.post(...)` passes whatever it got back. As a SETUP step, a failure leaves the -test asserting on a state it never reached; as the step UNDER test, the test proves nothing. E32 +test asserting on a state it never reached; as the step UNDER test, the test proves nothing. We found `test_youtube_sources_never_consult_the_guard` passing on a 400 that stopped the request before the code it meant to exercise, and 53 more discarded responses across the suite. Each one now states the status it expects; this keeps it that way. diff --git a/backend/tests/test_review_round.py b/backend/tests/test_review_round.py index 68a6fb25..9f9266b0 100644 --- a/backend/tests/test_review_round.py +++ b/backend/tests/test_review_round.py @@ -1,10 +1,10 @@ -"""E9.5 review round — the invariants the epic's own review found unpinned. +"""Review round — the invariants the canonical-email work's own review found unpinned. Every test here corresponds to a defect a reviewer could still introduce with the whole suite green: a mail whose body never renders in any test, an activation that only works for the exact spelling, a "we already wrote to them" stamp for a mail that was dropped, an env allow-list that answers a -different question than the gate it backs, and the address pre-emption that E9.5 S1's argument -covered only while the address stayed unverified. +different question than the gate it backs, and the address pre-emption that the order-independent +gates' argument covered only while the address stayed unverified. """ from datetime import datetime, timedelta, timezone @@ -79,7 +79,7 @@ def test_every_mail_renders_in_every_language(wire, name, args, lang): def test_a_weeks_link_is_described_in_days_not_hours(wire): - # The S6 approval token lives seven days, and the mail used to say "expires in 168 hours". + # The approval token lives seven days, and the mail used to say "expires in 168 hours". email_mod.send_access_approved("to@example.com", "en", "https://x/#verify=t", True, 168) assert "7 days" in wire[0][2] and "168" not in wire[0][2] @@ -91,7 +91,7 @@ def test_a_weeks_link_is_described_in_days_not_hours(wire): def test_a_later_registration_never_touches_an_existing_password(db, client, smtp_on, wire): - """The rule E9.5's review round 2 put back, having tried the opposite and found it worse. + """The rule a review put back, having tried the opposite and found it worse. "While the address is unverified, the newest registration owns it" looked like a fix for address pre-emption. It is symmetric — an attacker can always be last — so it closed nothing, and it diff --git a/backend/tests/test_saved_views_source.py b/backend/tests/test_saved_views_source.py index 58b43e95..23abc3b5 100644 --- a/backend/tests/test_saved_views_source.py +++ b/backend/tests/test_saved_views_source.py @@ -70,7 +70,7 @@ def test_duplicate_name_within_a_source_conflicts(db, monkeypatch, override_dep) def test_a_feed_views_tag_pick_must_be_a_short_list_of_ids(db, monkeypatch, override_dep): - """The autotag job reads every view's tag pick (E32 review): the route holds it to the shape the + """The autotag job reads every view's tag pick: the route holds it to the shape the feed sends, on create and on update.""" user = _user(db) c = _client(db, user, monkeypatch, override_dep) diff --git a/backend/tests/test_scheduler.py b/backend/tests/test_scheduler.py index abeac71f..9c47d3c9 100644 --- a/backend/tests/test_scheduler.py +++ b/backend/tests/test_scheduler.py @@ -28,7 +28,7 @@ def test_claim_is_exclusive_until_released(): _release() -# ---- E30: the result as data ------------------------------------------------------------------- +# ---- the result as data -------------------------------------------------------------------------- def _snapshot_job(job_id): @@ -70,7 +70,7 @@ def test_a_failed_run_drops_the_previous_result(db): def test_a_job_runs_audit_row_carries_its_result_as_data(db): - """E30 review: the audit log names a run's result in the reader's language, as the inbox does — + """The audit log names a run's result in the reader's language, as the inbox does — from data, not from the English one-liner (a nested phase there was a Python dict repr).""" from app.models import AuditLog, User @@ -89,7 +89,7 @@ def test_a_job_runs_audit_row_carries_its_result_as_data(db): def test_a_failure_the_job_names_rides_along_as_data(db, make_user): - """E31: a failed RSS poll reached the Hungarian audit log as the server's English sentence. A + """A failed RSS poll reached the Hungarian audit log as the server's English sentence. A failure the job names (`progress.JobFailed`) is stored as data beside that sentence — on the dashboard, the manual-run notice and the audit row — so the client says it in the reader's language. Any other failure keeps only its own text (the test above).""" diff --git a/backend/tests/test_search_scrape.py b/backend/tests/test_search_scrape.py index b2dc0d79..79f071ae 100644 --- a/backend/tests/test_search_scrape.py +++ b/backend/tests/test_search_scrape.py @@ -1,4 +1,4 @@ -"""youtube/search_scrape — the zero-quota InnerTube search (E32 S2: the module was 20% covered). +"""youtube/search_scrape — the zero-quota InnerTube search (the module was 20% covered). Everything here breaks silently when YouTube moves: a parser that stops finding videos, a cursor that stops paging, a fallback key that outlives its usefulness. The network is an httpx MockTransport. @@ -168,7 +168,7 @@ def test_the_live_key_is_fetched_once_and_cached(net): def test_a_failed_key_fetch_falls_back_for_now_and_tries_again_next_time(net, results_html): """The fallback pair used to be CACHED for the life of the process: one failed fetch pinned every later search to a hard-coded key, and once that went stale search returned nothing, - with no error anywhere (E32 S2).""" + with no error anywhere.""" net["handler"] = _serve(results_html=results_html) search_scrape.search("a") assert net["calls"][-1].url.params["key"] == search_scrape._FALLBACK_KEY diff --git a/backend/tests/test_set_password_link.py b/backend/tests/test_set_password_link.py index e78e5c91..5dcfc722 100644 --- a/backend/tests/test_set_password_link.py +++ b/backend/tests/test_set_password_link.py @@ -1,4 +1,4 @@ -"""E12 · S5 — the admin-issued set-password link. +"""The admin-issued set-password link. One mechanism answering three things: SEC-15 (a no-SMTP instance auto-verifies every registration, so a pre-empted address cannot be recovered by mail), SEC-16 (a pre-empted address stays takeable until diff --git a/backend/tests/test_shorts_probe.py b/backend/tests/test_shorts_probe.py index a05cc7b1..89be4fc8 100644 --- a/backend/tests/test_shorts_probe.py +++ b/backend/tests/test_shorts_probe.py @@ -1,4 +1,4 @@ -"""youtube/shorts.probe_is_short — three answers, and the third is not the second (E32 S2: 33% covered). +"""youtube/shorts.probe_is_short — three answers, and the third is not the second (was 33% covered). A Short answers 200 at /shorts/; a regular video redirects to /watch. Anything else — an error page, a network failure — is "undetermined, ask again", never "not a Short": that would file a Short diff --git a/backend/tests/test_smoke.py b/backend/tests/test_smoke.py index 83a897ba..4c737728 100644 --- a/backend/tests/test_smoke.py +++ b/backend/tests/test_smoke.py @@ -7,7 +7,7 @@ route registration or import fails right here. Endpoints that actually answer (/healthz, /api/version, the auth/feed/downloads routers) all touch the database, so exercising THEM belongs in a DB-backed integration lane (needs a test Postgres + -migrations) — deferred; see siftlode-ops/ROADMAP.md R2 S2b. +migrations) — deferred. """ from fastapi.testclient import TestClient diff --git a/backend/tests/test_swallowed_exceptions.py b/backend/tests/test_swallowed_exceptions.py index 5e1343b7..85e5d442 100644 --- a/backend/tests/test_swallowed_exceptions.py +++ b/backend/tests/test_swallowed_exceptions.py @@ -1,4 +1,4 @@ -"""E22.5 S3 — every swallowed exception has been ruled on. +"""Every swallowed exception has been ruled on. A handler that neither logs nor re-raises is a decision: someone looked at it and decided the failure is not worth a line. 114 of them were in `app/` at the start of this epic, and almost none diff --git a/backend/tests/test_sync_guards.py b/backend/tests/test_sync_guards.py index 8408cbc7..5cbf6cca 100644 --- a/backend/tests/test_sync_guards.py +++ b/backend/tests/test_sync_guards.py @@ -227,7 +227,7 @@ class TestRssPollResult: with pytest.raises(RuntimeError, match="HTTP 404") as err: self._run(monkeypatch, fetch=dead, apply_=lambda db, ch, e: 1, channels=("UC_dead",)) - # E31: and as data, for the reader's language — whose feed, and the reason code that tells a + # And as data, for the reader's language — whose feed, and the reason code that tells a # dead feed (`http_404`) from a dead egress (`transport`). assert isinstance(err.value, progress.JobFailed) assert (err.value.reason, err.value.params) == ( diff --git a/backend/tests/test_sysconfig_describe_queries.py b/backend/tests/test_sysconfig_describe_queries.py index 9c1aff47..a1e7a59c 100644 --- a/backend/tests/test_sysconfig_describe_queries.py +++ b/backend/tests/test_sysconfig_describe_queries.py @@ -1,4 +1,4 @@ -"""The admin Configuration page reads every override in one query (E31 S4d). +"""The admin Configuration page reads every override in one query. `sysconfig.describe()` did a `db.get` per key to tell whether an override was set, and `get()` did one more for its value: ~55 keys, and two round trips for a key with no row, since the session cannot cache diff --git a/backend/tests/test_sysconfig_registry.py b/backend/tests/test_sysconfig_registry.py index 4190f71e..8a2f5d9b 100644 --- a/backend/tests/test_sysconfig_registry.py +++ b/backend/tests/test_sysconfig_registry.py @@ -3,10 +3,10 @@ knob is a CHOICE. The admin Configuration page used to keep its own hand-written group order, which had already drifted (`explore` was missing, so its tab sorted last). The page now follows the registry: SECTIONS orders the -groups under their headings (E31), and a group missing there would fall off the page — so each group +groups under their headings, and a group missing there would fall off the page — so each group of SPECS sits in exactly one section. -A closed-set admin knob is a CHOICE, not free text (E22 S2 follow-up). +A closed-set admin knob is a CHOICE, not free text. `log_level`, `search_source`, `download_layout` and the transcode `hw`/`preset` used to be free-text fields: the admin could type anything and every consumer carried its own `or "auto").lower()` guess @@ -81,7 +81,7 @@ class _FakeDb: return self.rows.get(key) def scalars(self, _stmt): - # describe() reads every override at once (E31 S4d). + # describe() reads every override at once. return list(self.rows.values()) diff --git a/backend/tests/test_tag_suggest.py b/backend/tests/test_tag_suggest.py index 3552a2c7..7274736f 100644 --- a/backend/tests/test_tag_suggest.py +++ b/backend/tests/test_tag_suggest.py @@ -1,4 +1,4 @@ -"""DB-backed regression (E8·S4): the manual-tag-pattern suggester. +"""DB-backed regression: the manual-tag-pattern suggester. compute_tag_suggestions learns which auto TOPIC tags are *characteristic* of each user tag (from the channels the user tagged), then proposes that tag for still-untagged subscribed channels whose own diff --git a/backend/tests/test_upload.py b/backend/tests/test_upload.py index cc5b16f4..6bda72d7 100644 --- a/backend/tests/test_upload.py +++ b/backend/tests/test_upload.py @@ -1,4 +1,4 @@ -"""E7 S5a — local file upload ingest (the non-yt-dlp path into the media library). +"""Local file upload ingest (the non-yt-dlp path into the media library). Covers the ingest spine: media vs generic-file classification, the download-only serving of a generic file (force-download so uploaded HTML/SVG can't execute inline), quota accounting with the @@ -131,7 +131,7 @@ def test_ingest_rejects_over_footprint_cap_before_writing(db, make_user, _root, assert db.query(MediaAsset).count() == 0 # and no orphan asset row -# --- route: the auth-first body (P1) -------------------------------------------------------- +# --- route: the auth-first body ------------------------------------------------------------ def _counting_body(chunks: int = 4, size: int = 1024): @@ -183,7 +183,7 @@ def _counting_multipart(chunks: int = 4, size: int = 1024): def test_an_anonymous_upload_is_refused_before_a_byte_of_it_is_read(client): - """P1 — the defect E27 bounded but did not close. + """The defect an earlier fix bounded but did not close. While the route declared `file: UploadFile = File(...)`, FastAPI parsed and spooled the body BEFORE the first dependency ran, so the auth dependency could not gate it: an unauthenticated @@ -433,7 +433,7 @@ def test_a_long_display_name_is_truncated_on_EVERY_route_that_takes_one( this varchar(255) column; `POST /api/downloads` and `POST /api/downloads/edit` kept the old `(x or "").strip() or None` and would still have answered a bare 500 (a psycopg DataError, which main.py re-raises for everything but the NUL case) to a name a user simply pasted. Found - by four independent readers in deep-review round 2 — a sweep is exactly the kind of thing that + by four independent readers of one review — a sweep is exactly the kind of thing that gets done four-fifths of the way, so this holds all of it at once. """ _probe(monkeypatch, ("h264", "aac", 640, 480, 60)) @@ -487,7 +487,7 @@ def _display_write_sites() -> dict[tuple[str, str], set[str]]: TWO SHAPES, because the first version of this guard walked only `ast.Assign` and was therefore blind to the shape the upload route itself uses — `SpooledUpload(..., display_name=...)` is an `ast.keyword`, not an assignment, so the one site with an unbounded REQUEST HEADER behind it was - the site the guard could not see (deep-review round 3). + the site the guard could not see. ANNOTATED assignments are excluded on purpose: `display_name: Mapped[str | None] = mapped_column(...)` in `models.py` and `display_name: str | None = None` in a Pydantic model are @@ -540,7 +540,7 @@ def _display_write_sites() -> dict[tuple[str, str], set[str]]: # # Held as EXACT EQUALITY rather than as a list of offenders. The offender form could only fail # upward: refactoring the writers into a shape the walker did not recognise left it asserting -# `not []` and enforcing nothing, which is how a drift guard dies quietly (round 3). This one fails +# `not []` and enforcing nothing, which is how a drift guard dies quietly. This one fails # when a site appears, when one disappears, and when one changes what it writes. DISPLAY_WRITE_SITES = { ("downloads/service.py", "redownload"): {"None"}, @@ -678,7 +678,7 @@ def test_the_usage_route_reports_the_per_file_upload_cap(as_user, make_user, db) """The dialog refuses an over-cap file off this number, and it is the only layer that produces a refusal carrying the limit on a default install — so the field is load-bearing, not decorative. Nothing held it: the route had no test at all, and the frontend's `DownloadUsage` - is an unvalidated cast, so dropping the key would have been green everywhere (round 2). + is an unvalidated cast, so dropping the key would have been green everywhere. """ from app import sysconfig @@ -914,7 +914,7 @@ class TestResolveMediaFacts: assert mediakind.derive(v, a) == mediakind.VIDEO # NOT 'file' def test_the_duration_comes_from_the_file_when_info_has_none(self, monkeypatch): - # Facebook reel 257 (E11.5): yt-dlp gave no duration, the file is 128.4 s. A NULL duration hid + # Facebook reel 257: yt-dlp gave no duration, the file is 128.4 s. A NULL duration hid # the Library row's trim action. self._ffprobe( monkeypatch, diff --git a/backend/tests/test_utils.py b/backend/tests/test_utils.py index 091f402a..e9f84ab1 100644 --- a/backend/tests/test_utils.py +++ b/backend/tests/test_utils.py @@ -1,4 +1,4 @@ -"""iso(): the single date/datetime→JSON serializer the routes share (R6 S1a); to_tsquery_str().""" +"""iso(): the single date/datetime→JSON serializer the routes share; to_tsquery_str().""" from datetime import date, datetime, timezone @@ -64,7 +64,7 @@ def test_tsquery_prefix_matches_a_last_word_of_three_or_more(): def test_tsquery_matches_a_short_last_word_whole(): - # A 1-2 letter prefix made ts_rank score most of the library (5-8 s on prod, E31 S4e). + # A 1-2 letter prefix made ts_rank score most of the library (5-8 s on prod). assert to_tsquery_str("m") == "m" assert to_tsquery_str("minecraft 4k") == "minecraft & 4k" assert to_tsquery_str("ti amo m") == "ti & amo & m" diff --git a/backend/tests/test_welcome_mail.py b/backend/tests/test_welcome_mail.py index 4ce4f99d..7a7e63a2 100644 --- a/backend/tests/test_welcome_mail.py +++ b/backend/tests/test_welcome_mail.py @@ -1,4 +1,4 @@ -"""One mail, one click — the branching welcome/invitation mail (E9.5 S6), DB-lane. +"""One mail, one click — the branching welcome/invitation mail, DB-lane. The funnel's common ordering makes a person open TWO messages: one saying their access is approved, another asking them to confirm their address. The C option carries the confirmation link inside the diff --git a/backend/tests/test_youtube_refusals.py b/backend/tests/test_youtube_refusals.py index c41b9765..2ec5c619 100644 --- a/backend/tests/test_youtube_refusals.py +++ b/backend/tests/test_youtube_refusals.py @@ -1,5 +1,5 @@ """An action YouTube refuses answers a 422 the UI can translate — never a raw 500 — and says what the -user can do about it (`routes/_common.youtube_refusal`). E9 F1: the two manual syncs had no handler +user can do about it (`routes/_common.youtube_refusal`). The two manual syncs had no handler at all, so a quota or transport failure opened the blocking "Server error" dialog.""" from types import SimpleNamespace @@ -41,7 +41,7 @@ def test_a_failure_is_sorted_by_what_the_user_can_do(exc, reason): def test_a_missing_playlist_is_gone_only_on_a_route_about_that_playlist(): # A playlist deleted on YouTube: neither a retry nor a reconnect helps (it said "reconnect"). # Said only where the user picked that one list — anywhere else "this playlist no longer - # exists" names nothing on their screen (E32 review round 2). + # exists" names nothing on their screen. exc = YouTubeError("m", reason="playlistNotFound", path="playlists") assert refusal_reason(exc, playlist=True) == "gone" assert refusal_reason(exc) == "unavailable" @@ -71,7 +71,7 @@ class _NoUploads: def test_a_channel_without_uploads_backfills_as_done_and_is_not_charged_again(db): # The recent pass stamped nothing on this answer, so the channel was picked — and charged a - # quota unit, with an ERROR traceback — on every backfill run, forever (E32 review round 3). + # quota unit, with an ERROR traceback — on every backfill run, forever. ch = Channel(id="UCempty", title="Empty", uploads_playlist_id="UUempty") db.add(ch) db.commit() diff --git a/backend/tests/test_ytdlp_logging.py b/backend/tests/test_ytdlp_logging.py index 02950256..bc98cacc 100644 --- a/backend/tests/test_ytdlp_logging.py +++ b/backend/tests/test_ytdlp_logging.py @@ -1,4 +1,4 @@ -"""E22.5 S4 — yt-dlp explains itself to our log, not to a stdout nobody reads. +"""Yt-dlp explains itself to our log, not to a stdout nobody reads. `quiet: True` was doing more than quieting: with no `logger`, yt-dlp's whole account of a download went to the child process's stdout, so "Requested format is not available", a signature-extraction diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml index e1ba83c0..d3087d9d 100644 --- a/docker-compose.selfhost.yml +++ b/docker-compose.selfhost.yml @@ -11,7 +11,7 @@ # first run. See docs/self-hosting.md. # Bounded container logs. Docker's json-file driver keeps every line forever by default, and the API -# now writes one structured line per request (E22 S2) — 3×50 MB per service is days of history on a +# now writes one structured line per request — 3×50 MB per service is days of history on a # busy instance and a hard ceiling on the disk it can take. x-logging: &logging driver: json-file diff --git a/docker-compose.yml b/docker-compose.yml index de724afe..fe760858 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,5 @@ # Bounded container logs. Docker's json-file driver keeps every line forever by default, and the API -# now writes one structured line per request (E22 S2) — 3×50 MB per service is days of history on a +# now writes one structured line per request — 3×50 MB per service is days of history on a # busy instance and a hard ceiling on the disk it can take. x-logging: &logging driver: json-file diff --git a/frontend/e2e/channel.spec.ts b/frontend/e2e/channel.spec.ts index 2b9a3c23..e7b080a6 100644 --- a/frontend/e2e/channel.spec.ts +++ b/frontend/e2e/channel.spec.ts @@ -30,7 +30,7 @@ test.describe("channel view", () => { await expect(page.getByTestId("video-card").first()).toBeVisible(); }); - // Bug 1 (Platform S4): opening a channel and coming Back restores the feed's scroll position + // Opening a channel and coming Back restores the feed's scroll position // rather than dumping you at the top. The shell owns the scroller and remembers each view's offset. test("Back restores the feed scroll position", async ({ page }) => { await openApp(page); @@ -60,7 +60,7 @@ test.describe("channel view", () => { .toBeGreaterThan(saved - 500); }); - // Bug 2 (Platform S5): opening a channel no longer FORCES Live/Upcoming on — the channel view + // Opening a channel no longer FORCES Live/Upcoming on — the channel view // seeds its content-type prefs (normal / shorts / live) from the global feed. Locked both ways so // a future refactor can't silently re-hardcode it. test("the channel view inherits the feed's content filters (no forced Live/Upcoming)", async ({ @@ -86,7 +86,7 @@ test.describe("channel view", () => { await expectContentType(page, "includeLive", true); }); - // Bug 2 (Platform S5): the channel view is its OWN filter scope — a content toggle made there + // The channel view is its OWN filter scope — a content toggle made there // must NOT leak back into the global feed. (FeedFiltersProvider owns the feed's filters; the // channel page keeps a separate local cell.) test("a channel's content toggle does not leak into the feed", async ({ page }) => { @@ -106,7 +106,7 @@ test.describe("channel view", () => { await expectContentType(page, "includeLive", false); }); - // Platform S4b + E31 S4c: the channel chrome sits outside the scroller. Scrolling into the videos + // The channel chrome sits outside the scroller. Scrolling into the videos // folds the banner and identity once (the name moves into the tab row); from then on the folded // chrome stays put while only the video grid scrolls. test("the channel chrome folds once, then stays fixed while the grid scrolls", async ({ diff --git a/frontend/e2e/feed.spec.ts b/frontend/e2e/feed.spec.ts index 55700b6f..b636d78c 100644 --- a/frontend/e2e/feed.spec.ts +++ b/frontend/e2e/feed.spec.ts @@ -51,7 +51,7 @@ test.describe("feed", () => { .toBe(true); }); - // Bug 4 (Platform S4): the toolbar (show/content chips, count, sort, view) is fixed chrome — it + // The toolbar (show/content chips, count, sort, view) is fixed chrome — it // pins in the shell's toolbar band above the scroller, so it stays put while the cards scroll. test("the feed toolbar stays pinned while cards scroll", async ({ page }) => { await openApp(page); @@ -66,7 +66,7 @@ test.describe("feed", () => { expect(Math.abs(topAfter - topBefore)).toBeLessThan(4); }); - // Bug 3 (Platform S4): changing a filter is a fresh result set, so the scroll jumps back to the + // Changing a filter is a fresh result set, so the scroll jumps back to the // top rather than stranding you deep in the old list's offset. test("a new filter resets the scroll to the top", async ({ page }) => { await openApp(page); diff --git a/frontend/e2e/global.setup.ts b/frontend/e2e/global.setup.ts index aa729556..782ca13e 100644 --- a/frontend/e2e/global.setup.ts +++ b/frontend/e2e/global.setup.ts @@ -76,7 +76,7 @@ setup("seed + authenticate", async ({ page }) => { // 4. Pin the UI locale, suppress the first-login onboarding wizard (a full-screen overlay // that would intercept every click — the test account has no YouTube connection, so it - // auto-opens), and opt the account out of starting on Home. Since E24 a bare URL lands on + // auto-opens), and opt the account out of starting on Home. A bare URL lands on // Home, and the feed, channel and player flows this net locks all open "/" expecting the // feed; the opt-out is the one Settings → Appearance → Startup writes. Both flags are // per-account: `siftlode.onboarding.dismissed.`, `siftlode.startOnHome.`. diff --git a/frontend/e2e/helpers.ts b/frontend/e2e/helpers.ts index 78dd0c80..d0681f04 100644 --- a/frontend/e2e/helpers.ts +++ b/frontend/e2e/helpers.ts @@ -18,7 +18,7 @@ export async function gotoPage(page: Page, id: string): Promise { * Playwright would scroll it into view to click it, moving the scroll position — fatal to any test * that then asserts on where the feed was. Being inside the viewport is not enough either: a card * can sit under the page chrome above the scroller (the update banner, the toolbar band), and the - * covered click makes Playwright scroll and retry — the same move (E32 S2: webkit failed 3 in 24). + * covered click makes Playwright scroll and retry — the same move (webkit failed 3 in 24). * So the link must be the element hit at its own centre. Returns false if none found. */ export async function clickVisibleChannelLink(page: Page): Promise { diff --git a/frontend/e2e/nav.spec.ts b/frontend/e2e/nav.spec.ts index 5637be7d..70142078 100644 --- a/frontend/e2e/nav.spec.ts +++ b/frontend/e2e/nav.spec.ts @@ -6,9 +6,9 @@ import { openApp } from "./helpers"; // this exercises is precisely what the module-registry refactor replaces. test.describe("page switching", () => { // Core + admin modules (the E2E account is an admin, matching the real primary user), so this - // also exercises the admin pages. Since E31 those sit behind ONE rail row ("Administration", + // also exercises the admin pages. They sit behind ONE rail row ("Administration", // `nav-admin`) that opens a panel of them — the spec still listed them as rail rows of their own, - // and it failed on every run from E31 to E32 without anyone seeing it, because e2e was in no + // and it failed on every run for a while without anyone seeing it, because e2e was in no // required gate (it is now part of `release`). const RAIL = ["channels", "playlists", "stats", "settings", "feed"]; const ADMIN = ["scheduler", "config", "users", "audit"]; diff --git a/frontend/e2e/player.spec.ts b/frontend/e2e/player.spec.ts index a0599392..0fdc3bfd 100644 --- a/frontend/e2e/player.spec.ts +++ b/frontend/e2e/player.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from "@playwright/test"; import { openApp, openFirstVideo } from "./helpers"; // Locks the in-app player's open/close contract. The player is a portaled overlay whose -// inconsistent mounting caused an E3 regression — exactly the kind of thing the refactor's +// inconsistent mounting caused a regression — exactly the kind of thing the refactor's // overlay-root convention must not break. We assert the modal shell, not YouTube playback. test.describe("player modal", () => { test.beforeEach(async ({ page }) => { diff --git a/frontend/e2e/tsconfig.json b/frontend/e2e/tsconfig.json index 81e33f9c..0972e972 100644 --- a/frontend/e2e/tsconfig.json +++ b/frontend/e2e/tsconfig.json @@ -11,7 +11,7 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - // E32: an index read is `T | undefined` here, as it really is (0 errors when switched on). + // An index read is `T | undefined` here, as it really is (0 errors when switched on). "noUncheckedIndexedAccess": true, "esModuleInterop": true, "resolveJsonModule": true diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 9b1aaa2b..eb786fef 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -10,21 +10,21 @@ import playwright from "eslint-plugin-playwright"; // Flat config (ESLint 9). Deliberately NARROW: the high-value rules here are react-hooks' (the // hook rules and the React Compiler's diagnostics). The 33 inline `exhaustive-deps` suppressions this -// config first met are all gone (0 left, E32 review); what is still suppressed is suppressed inline, +// config first met are all gone (0 left); what is still suppressed is suppressed inline, // one site at a time, each naming its invariant. tsc already owns unused vars, undefined names // and types (noUnusedLocals/Parameters + strict), so typescript-eslint's non-type-checked // `recommended` is layered on but its overlap with tsc is turned off below to avoid double-reporting // and a churny diff. `eslint-config-prettier` is LAST so formatting rules never fight Prettier. // // reportUnusedDisableDirectives: a `// eslint-disable` that no longer suppresses anything is itself -// an error — that is what keeps inert disable comments from re-accumulating (the S1b mandate). +// an error — that is what keeps inert disable comments from re-accumulating. // -// E32 (gate integrity): every switched-off or narrowed rule here is listed, with its reason and its +// Gate integrity: every switched-off or narrowed rule here is listed, with its reason and its // measured count, in docs/gates.md, and lib/gates.contract.test.ts fails when one is not. A rule // with a single real hit is never switched off — its sites are fixed, or each suppressed inline with // a `-- ` description (eslint-comments/require-description makes that mandatory). -// E22 S4: every way out of the browser goes through the one module that LOGS it — a request line +// Every way out of the browser goes through the one module that LOGS it — a request line // with a request id (lib/http.ts), the upload XHR (lib/api/core.ts). A bare `fetch` elsewhere is a // request the client log never sees, which is exactly how the baseline survey found four. Each owner // is exempt from its OWN global only; a global with no owner (null) has no exempt file at all. @@ -40,16 +40,16 @@ const networkGlobals = (exempt) => .map(([name, owner]) => ({ name, message: owner - ? `Use the logged request layer: \`${name}\` belongs to ${owner} only (E22 S4 — a request outside it never reaches the client log).` - : `No \`${name}\` without a logged owner module (E22 S4).`, + ? `Use the logged request layer: \`${name}\` belongs to ${owner} only — a request outside it never reaches the client log.` + : `No \`${name}\` without a logged owner module.`, })); const networkProperties = [ - { object: "window", property: "fetch", message: "Use lib/http.ts `send` (E22 S4)." }, - { object: "globalThis", property: "fetch", message: "Use lib/http.ts `send` (E22 S4)." }, + { object: "window", property: "fetch", message: "Use lib/http.ts `send`." }, + { object: "globalThis", property: "fetch", message: "Use lib/http.ts `send`." }, { object: "navigator", property: "sendBeacon", - message: "Use lib/api/core.ts `beacon` (E22 S4).", + message: "Use lib/api/core.ts `beacon`.", }, ]; @@ -70,7 +70,7 @@ const jsxCommentText = { }; }, }; -// E28 S3g + E30 S3 (docs/DESIGN.md "Copy"): a date, a number and an alphabetical order are all in the +// docs/DESIGN.md "Copy": a date, a number and an alphabetical order are all in the // UI language — dates and numbers built in lib/format.ts, text compared in lib/collate.ts. A bare // `toLocaleString()` formats in the BROWSER's locale (seven date sites had drifted into five shapes), a // bare `toFixed` writes the English decimal point, a bare `localeCompare` sorts in the browser's @@ -134,7 +134,7 @@ const localeFormat = { }; }, }; -// E28 S3b: one primitive per button family (docs/DESIGN.md). 268 hand-rolled buttons in 79 files had +// One primitive per button family (docs/DESIGN.md). 268 hand-rolled buttons in 79 files had // drifted into as many looks, heights and hover styles; they were swept onto ui/Button, IconButton, // Pressable, Chip and DragHandle, and this keeps the next one out. The raw element lives only in the // files that ARE a control primitive — not in the rest of components/ui/, whose composites (a pager, a @@ -144,7 +144,7 @@ const localeFormat = { // native disabled drops a focused keyboard user to the moment the button turns disabled (a // mutation pending, a list at its end, a form gone clean) — measured in Chrome, and ~60 buttons in this // app are disabled by their own click. ui/FocusSafeButton (and ui/Button on top of it) keeps the -// focus. (It was its own rule, `focus-safe-disabled`, until E35.) +// focus. (It used to be its own rule, `focus-safe-disabled`.) const BUTTON_PRIMITIVES = new Set([ "FocusSafeButton.tsx", // the focus-keeping )} - {/* Rename and delete — the playlist's own, rarer actions — sit in its "…" (E28 C1); a + {/* Rename and delete — the playlist's own, rarer actions — sit in its "…"; a rename opened from there hands the focus back to it. */} { ); }); - // E11 S4: SearchBar's Escape hands the keyboard back to the page (blur). With the panel open, the + // SearchBar's Escape hands the keyboard back to the page (blur). With the panel open, the // first Escape belongs to the panel: it must close it and leave the caret in the field; only the // next Escape leaves. it("spends the first Escape on the panel and only the second on leaving the field", async () => { @@ -220,7 +220,7 @@ describe("PlexSearchBox — typeahead", () => { expect(input.getAttribute("aria-controls")).toBeNull(); }); - // Round 2 (F4 sibling): a listbox may own options only — "Loading…" sat inside it. + // A listbox may own options only — "Loading…" sat inside it. it("shows its loading line beside the listbox, not in it", async () => { plexState.q = "matrix"; plexLibrary.mockReturnValue(new Promise(() => {})); diff --git a/frontend/src/components/PlexSearchBox.tsx b/frontend/src/components/PlexSearchBox.tsx index febb0383..10fc24bc 100644 --- a/frontend/src/components/PlexSearchBox.tsx +++ b/frontend/src/components/PlexSearchBox.tsx @@ -14,7 +14,7 @@ import { SURFACE } from "./ui/surfaces"; import { ROVING_HIGHLIGHT } from "./ui/AnchoredPopover"; import { cardImage } from "../lib/plexImage"; -// The Plex header search box: the shared SearchBar enriched with the S2 search scope-pill (a +// The Plex header search box: the shared SearchBar enriched with the search scope-pill (a // GitHub-style field selector inside the box) and an instant typeahead of the top matches attached // below it. Kept out of the generic SearchBar (and out of Header) so all Plex-specific wiring — // provider state, the suggest query, the jump-to hand-off — lives in one place. Everything reuses @@ -140,7 +140,7 @@ export default function PlexSearchBox() {
- {/* Beside the listbox, not in it: a listbox may own options only (round 2, F4). */} + {/* Beside the listbox, not in it: a listbox may own options only. */} {suggestions.length === 0 && suggestQ.isFetching && (
{t("plex.loading")}
)} diff --git a/frontend/src/components/PlexSidebar.picker.dom.test.tsx b/frontend/src/components/PlexSidebar.picker.dom.test.tsx index 1be3c43f..8c4c90be 100644 --- a/frontend/src/components/PlexSidebar.picker.dom.test.tsx +++ b/frontend/src/components/PlexSidebar.picker.dom.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom // -// The sidebar's playlist picker (E28 review round 2, P7): typing a name offers "Create …" — but only +// The sidebar's playlist picker: typing a name offers "Create …" — but only // once the lists have loaded. Over a failed load the create row pushed the error and its Retry out // of the list at the first keystroke, and whether the name already existed was unknown anyway. import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index d9df7f09..8279c1e7 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -69,7 +69,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl const facets = facetsQ.data; const fCount = plexFilterCount(filters); - // Scope and sort moved to the header / above-the-grid toolbar (S3); the panel badge now counts only + // Scope and sort moved to the header / above-the-grid toolbar; the panel badge now counts only // the filters that still live in this panel (expanded filters + watch-state). const activeCount = fCount + (show !== "all" ? 1 : 0); @@ -345,7 +345,7 @@ export function PlaylistPicker() { const options: ComboboxOption[] = [ ...matches.map((p) => ({ value: `pl:${p.id}`, label: p.title, count: p.item_count })), // Offer to create only when the typed name isn't already a playlist — which is unknown until the - // lists load: over a failed load the row pushed out the error and its Retry (round 2, P7). + // lists load: over a failed load the row pushed out the error and its Retry. ...(q && !exact && playlistsQ.data ? [{ value: CREATE_PLAYLIST, label: q }] : []), ]; @@ -355,7 +355,7 @@ export function PlaylistPicker() { qc.invalidateQueries({ queryKey: qk.plexPlaylists() }); onOpenPlaylist(pl.id); } catch (e) { - // Called without an await from the picker, so a refusal was an unhandled rejection (E11 S2b). + // Called without an await from the picker, so a refusal was an unhandled rejection. notifyActionError(e, t("plex.playlistAdd.createFailed")); } } @@ -403,7 +403,7 @@ export function PlaylistPicker() { ); } -// The collection filter (E5 S7 → combobox; now multi-select): a combi-input matching the genre/studio +// The collection filter (a multi-select combobox): a combi-input matching the genre/studio // pickers, its vocabulary searched SERVER-side (the union can run to hundreds). The generic // owns the field/keyboard/ARIA + the any/all match footer; this wrapper owns the query→fetch (gated on // `open`) and maps collections to options. A title can belong to several collections, so multiple can diff --git a/frontend/src/components/PlexStatsOverlay.tsx b/frontend/src/components/PlexStatsOverlay.tsx index 35a8492d..5234ee34 100644 --- a/frontend/src/components/PlexStatsOverlay.tsx +++ b/frontend/src/components/PlexStatsOverlay.tsx @@ -5,9 +5,9 @@ import { X } from "lucide-react"; import { buildStatRows, type FragMeter, type HlsLevelInfo, type StatRow } from "../lib/plexStats"; import { IconButton } from "./ui/IconButton"; -// "Stats for nerds" debug overlay (E6 S2). Samples the live HTML5
{/* Named by the title: its only text is the duration badge, so a screen reader heard the first - Tab stop of every card as "44:10" (E28 S3d4). */} + Tab stop of every card as "44:10". */} void; onToggleSave: (id: string, saved: boolean) => void; onOpen?: (v: Video, startAt?: number | null) => void; - /** Right-click / long-press props for the LIST's one context menu (E28 C1: the video's actions). + /** Right-click / long-press props for the LIST's one context menu (the video's actions). * A FUNCTION, not the already-built props object: `useContextMenu.triggerProps` is stable across * renders while its return value is a fresh object every call, and a fresh object prop would * blow past this component's memo for every card in the virtualized feed on every parent @@ -460,7 +460,7 @@ function VideoCard({ rel="noreferrer" onClick={(e) => openInApp(e, video, onOpen)} // A link is dragged, not selected: Chrome starts no selection on one even with the drag off, - // until it is also `user-select: text` (select-text below) — measured in the S3e1 UAT. + // until it is also `user-select: text` (select-text below) — measured in a real browser. draggable={false} // Enter on a keyboard-selected card clicks THIS link (lib/listNav), so it opens exactly like a // click: the in-app player, resuming where the reader left off. @@ -544,7 +544,7 @@ function VideoCard({ {...(navIndex !== undefined && navItemAttrs(navIndex))} className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")} > - {/* The title leads (E28 S3h, the owner's pick B, 2026-09-26): the actions used to lead in a + {/* The title leads (the owner's pick, 2026-09-26): the actions used to lead in a 128px column that sat EMPTY at rest, a hole at the start of every row. Now the row's end holds a faint "…" (always there: the menu's door on every device), and on hover or keyboard focus the quick toggles float in beside it OVER the date, which steps aside — diff --git a/frontend/src/components/VideoEditor.partial.dom.test.tsx b/frontend/src/components/VideoEditor.partial.dom.test.tsx index 52fd18f2..ca7d44a5 100644 --- a/frontend/src/components/VideoEditor.partial.dom.test.tsx +++ b/frontend/src/components/VideoEditor.partial.dom.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom // -// A split export that fails part-way (deep-review E11 rounds 2 and 3). Every clip is its own job, +// A split export that fails part-way. Every clip is its own job, // committed as it goes, so a failure at clip 2 of 2 (the job cap) left clip 1 queued while the editor // reported a plain failure and kept both selected — and trying again queued clip 1 a second time. It // now says how many made it (also when `req` has already shown the refusal in its dialog), deselects diff --git a/frontend/src/components/VideoEditor.tsx b/frontend/src/components/VideoEditor.tsx index cfa8ce2d..66178a43 100644 --- a/frontend/src/components/VideoEditor.tsx +++ b/frontend/src/components/VideoEditor.tsx @@ -77,7 +77,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos const [segments, setSegments] = useState([{ id: 0, start: 0, end: srcDur, keep: true }]); // Set while Create queues its clips. A part-way failure deselects the clips it queued by the ids // Create started with, so the cut list holds still until it is done: a split or a merge in between - // would deselect a range that was never queued, or keep one that was (deep-review E11 round 3). + // would deselect a range that was never queued, or keep one that was. const creating = useRef(false); // The "(i/N)" each clip of the last Create was named with, by segment id: a retry after a part-way // failure goes on with that series instead of renumbering what is left ("(1/2)" beside "(1/4)"). @@ -203,7 +203,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos // --- timeline drag (seek / boundary) + hover preview --- const [drag, setDrag] = useState(null); // The hover point, measured in the pointer handler (track-relative x and its time), so render never - // reads the track's geometry (a DOM read in render made the React Compiler skip the editor, E32). + // reads the track's geometry (a DOM read in render made the React Compiler skip the editor). const [hover, setHover] = useState<{ x: number; time: number } | null>(null); const timeAt = useCallback( (clientX: number) => { @@ -369,7 +369,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos setSegments((segs) => segs.map((s) => (done.includes(s.id) ? { ...s, keep: false } : s))); // Its own toast, not notifyActionError: a quota or edit refusal has already opened req's // error dialog and marked the error, so that helper would say nothing — and the count is - // what the dialog does not tell (deep-review E11 round 3). + // what the dialog does not tell. notify({ level: "error", title: t("editor.partlyCreated", { done: done.length, total: N }), diff --git a/frontend/src/components/VideoInfoCard.dom.test.tsx b/frontend/src/components/VideoInfoCard.dom.test.tsx index c4f22f49..bb97b84c 100644 --- a/frontend/src/components/VideoInfoCard.dom.test.tsx +++ b/frontend/src/components/VideoInfoCard.dom.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom // -// Contract of a video's details panel (E8·S3; opened from the video's menu since E28 C1): the +// Contract of a video's details panel (opened from the video's menu): the // header (full title + channel) renders IMMEDIATELY from the Video we already hold, while the // description (videoDetail) and the channel's tags (channelDetail.tag_ids resolved against the tag // list) fill in from their on-demand queries; an empty description falls back to the "no description" diff --git a/frontend/src/components/VideoInfoCard.tsx b/frontend/src/components/VideoInfoCard.tsx index ddc74a63..ad6e3d04 100644 --- a/frontend/src/components/VideoInfoCard.tsx +++ b/frontend/src/components/VideoInfoCard.tsx @@ -12,8 +12,8 @@ import { SURFACE } from "./ui/surfaces"; import { Badge } from "./ui/Badge"; import { useTags } from "../lib/useTags"; -// A video's details (E8·S3, decision D3): full (un-clamped) title, channel, views·duration·date, a -// description snippet, and the channel's tags. E28 C1 moved it from a hover ⓘ on the thumbnail into +// A video's details: full (un-clamped) title, channel, views·duration·date, a +// description snippet, and the channel's tags. It moved from a hover ⓘ on the thumbnail into // the video's action list ("Details"): the hover card was invisible on touch and one more button in // the card's hover row, and the menu is the one door every device has. It opens anchored to the card // the menu came from, as a small dialog. diff --git a/frontend/src/components/VideoMenu.dom.test.tsx b/frontend/src/components/VideoMenu.dom.test.tsx index feee2c03..75c2c62f 100644 --- a/frontend/src/components/VideoMenu.dom.test.tsx +++ b/frontend/src/components/VideoMenu.dom.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom // -// E28 C1: a video's ONE action list. The right-click and the card's "…" open the same menu, which +// A video's ONE action list. The right-click and the card's "…" open the same menu, which // holds everything the card no longer shows (download, details, copy, hide, reset, pin) beside the // everyday toggles, and each row fires the same handler the card's own buttons do. import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; diff --git a/frontend/src/components/ViewSwitcher.tsx b/frontend/src/components/ViewSwitcher.tsx index 8148edd1..710bf418 100644 --- a/frontend/src/components/ViewSwitcher.tsx +++ b/frontend/src/components/ViewSwitcher.tsx @@ -18,7 +18,7 @@ export interface ViewOption { // density modes; the managers' layouts) without re-deriving the menu behaviour. // // This was the house's textbook menu, with its own hand-rolled roving-tabindex/arrow/Escape engine. -// It now rides the R8 popover platform like every other menu, which supplies the same model +// It now rides the popover platform like every other menu, which supplies the same model // (arrows/Home/End rove, Escape closes and hands focus back to the trigger, Tab out closes) plus the // two things the hand-rolled version lacked: typeahead, and anchoring that flips and shifts instead // of being absolutely positioned inside whatever ancestor happens to clip it. diff --git a/frontend/src/components/VirtualFeed.tsx b/frontend/src/components/VirtualFeed.tsx index 3fc2d66a..52c2c63c 100644 --- a/frontend/src/components/VirtualFeed.tsx +++ b/frontend/src/components/VirtualFeed.tsx @@ -10,7 +10,7 @@ import VirtualGrid from "./ui/VirtualGrid"; // the very first paint and the scrollbar's initial guess. Taken from measuring each mode in the // browser rather than guessed — a bad estimate makes the scrollbar jump as you scroll in. // RE-MEASURE these when a mode's layout changes: they have gone stale twice already, most recently -// when the E28 C1 action row moved onto the card's picture (cards 340 -> 262, small 257 -> 226). +// when the action row moved onto the card's picture (cards 340 -> 262, small 257 -> 226). // The card figures are the roughest of the set by nature — a card's height follows its column width // (262 measured at 4 columns in a 1268px window), so no single constant is right for every window. const ROW_EST: Record = { @@ -48,7 +48,7 @@ export default function VirtualFeed({ fetchNextPage, }: VirtualFeedProps) { const { minCol, colMaxWidth, maxCols, thumb } = FEED_VIEW_SPEC[view]; - // The card menu (the video's whole action list, E28 C1) lives HERE rather than in each of the + // The card menu (the video's whole action list) lives HERE rather than in each of the // feed's callers: this is the list, and `useContextMenu`'s contract is one menu per list. Mounting // it at this level is also what gives the channel page the menu for free — it renders // ``, so it is the same component all the way down. diff --git a/frontend/src/components/WatchPage.dom.test.tsx b/frontend/src/components/WatchPage.dom.test.tsx index 15951517..1395cab8 100644 --- a/frontend/src/components/WatchPage.dom.test.tsx +++ b/frontend/src/components/WatchPage.dom.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom // -// S3 item 5a: the public watch page must render a native