├── .biomeignore ├── .coderabbit.yml ├── .github ├── DESCRIPTION.md ├── DISCUSSION_TEMPLATE │ ├── general.md │ ├── ideas.md │ └── q_a.md ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── release-drafter.yml └── workflows │ ├── ci.yml │ ├── deploy-docs-production.yml │ └── docker-build.yml ├── .gitignore ├── .husky └── commit-msg ├── .nvmrc ├── LICENSE ├── README-detailed.md ├── README.md ├── assets ├── icons │ ├── pulsarr-lg.png │ ├── pulsarr.png │ └── pulsarr.svg └── screenshots │ ├── Content-Route-1.png │ ├── Content-Route-2.png │ ├── DM-New-Epp.png │ ├── DM-Season.png │ ├── Dashboard1.png │ ├── Dashboard2.png │ ├── Dashboard3.png │ ├── Dashboard5.png │ ├── Dashboard6.png │ ├── Delete-Sync-Dry.png │ ├── Delete-Sync-Error.png │ ├── Delete-Sync.png │ ├── Discord-Edit-Modal.png │ ├── Discord-Settings.png │ ├── Discord-Signup.png │ ├── Login.png │ ├── Notifications.png │ ├── Plex-Notifications.png │ ├── Plex1.png │ ├── Plex2.png │ ├── Radarr.png │ ├── Sonarr.png │ ├── User-Tags.png │ └── Webhook-grab.png ├── biome.json ├── commitlint.config.ts ├── components.json ├── data ├── db │ └── .gitkeep └── logs │ └── .gitkeep ├── docker-entrypoint.sh ├── dockerfile ├── docs ├── .gitignore ├── BASE_PATH_CONFIGURATION.md ├── README.md ├── deploy-test.sh ├── docs │ ├── api-documentation.md │ ├── api │ │ ├── authentication.tag.mdx │ │ ├── bulk-update-users.api.mdx │ │ ├── cleanup-orphaned-tags.api.mdx │ │ ├── config.tag.mdx │ │ ├── configure-plex-notifications.api.mdx │ │ ├── create-admin-user.api.mdx │ │ ├── create-radarr-instance.api.mdx │ │ ├── create-radarr-tag.api.mdx │ │ ├── create-router-rule.api.mdx │ │ ├── create-schedule.api.mdx │ │ ├── create-sonarr-instance.api.mdx │ │ ├── create-sonarr-tag.api.mdx │ │ ├── create-user-tags.api.mdx │ │ ├── create-user.api.mdx │ │ ├── delete-radarr-instance.api.mdx │ │ ├── delete-router-rule.api.mdx │ │ ├── delete-schedule.api.mdx │ │ ├── delete-sonarr-instance.api.mdx │ │ ├── discover-plex-servers.api.mdx │ │ ├── dry-run-delete-sync.api.mdx │ │ ├── generate-rss-feeds.api.mdx │ │ ├── get-all-dashboard-stats.api.mdx │ │ ├── get-all-router-rules.api.mdx │ │ ├── get-all-schedules.api.mdx │ │ ├── get-availability-stats.api.mdx │ │ ├── get-config.api.mdx │ │ ├── get-grabbed-to-notified-stats.api.mdx │ │ ├── get-instance-content-breakdown.api.mdx │ │ ├── get-most-watched-movies.api.mdx │ │ ├── get-most-watched-shows.api.mdx │ │ ├── get-notification-stats.api.mdx │ │ ├── get-others-watchlist-tokens.api.mdx │ │ ├── get-plex-notification-status.api.mdx │ │ ├── get-plugin-metadata.api.mdx │ │ ├── get-radarr-instances.api.mdx │ │ ├── get-radarr-quality-profiles.api.mdx │ │ ├── get-radarr-root-folders.api.mdx │ │ ├── get-radarr-tags.api.mdx │ │ ├── get-recent-activity.api.mdx │ │ ├── get-router-plugins.api.mdx │ │ ├── get-router-rule-by-id.api.mdx │ │ ├── get-router-rules-by-target-type.api.mdx │ │ ├── get-router-rules-by-target.api.mdx │ │ ├── get-router-rules-by-type.api.mdx │ │ ├── get-schedule-by-name.api.mdx │ │ ├── get-self-watchlist-items.api.mdx │ │ ├── get-self-watchlist-token.api.mdx │ │ ├── get-sonarr-instances.api.mdx │ │ ├── get-sonarr-quality-profiles.api.mdx │ │ ├── get-sonarr-root-folders.api.mdx │ │ ├── get-sonarr-tags.api.mdx │ │ ├── get-status-flow.api.mdx │ │ ├── get-status-transitions.api.mdx │ │ ├── get-tagging-status.api.mdx │ │ ├── get-top-genres.api.mdx │ │ ├── get-top-users.api.mdx │ │ ├── get-user-by-id.api.mdx │ │ ├── get-users-list.api.mdx │ │ ├── get-users-with-counts.api.mdx │ │ ├── get-watchlist-genres.api.mdx │ │ ├── get-watchlist-workflow-status.api.mdx │ │ ├── login-user.api.mdx │ │ ├── logout-user.api.mdx │ │ ├── parse-rss-watchlists.api.mdx │ │ ├── ping-plex.api.mdx │ │ ├── plex.tag.mdx │ │ ├── process-media-webhook.api.mdx │ │ ├── pulsarr-api.info.mdx │ │ ├── radarr.tag.mdx │ │ ├── remove-all-user-tags.api.mdx │ │ ├── remove-plex-notifications.api.mdx │ │ ├── run-job-now.api.mdx │ │ ├── sidebar.ts │ │ ├── sonarr.tag.mdx │ │ ├── start-discord-bot.api.mdx │ │ ├── start-watchlist-workflow.api.mdx │ │ ├── stop-discord-bot.api.mdx │ │ ├── stop-watchlist-workflow.api.mdx │ │ ├── sync-all-instances.api.mdx │ │ ├── sync-instance.api.mdx │ │ ├── sync-tautulli-notifiers.api.mdx │ │ ├── sync-user-tags.api.mdx │ │ ├── test-radarr-connection.api.mdx │ │ ├── test-sonarr-connection.api.mdx │ │ ├── test-tautulli-connection-with-credentials.api.mdx │ │ ├── test-tautulli-connection.api.mdx │ │ ├── toggle-router-rule.api.mdx │ │ ├── toggle-schedule.api.mdx │ │ ├── update-config.api.mdx │ │ ├── update-radarr-instance.api.mdx │ │ ├── update-router-rule.api.mdx │ │ ├── update-schedule.api.mdx │ │ ├── update-sonarr-instance.api.mdx │ │ ├── update-tagging-config.api.mdx │ │ ├── update-user-password.api.mdx │ │ ├── update-user.api.mdx │ │ ├── users.tag.mdx │ │ └── validate-discord-webhooks.api.mdx │ ├── architecture.md │ ├── contributing.md │ ├── features │ │ ├── _category_.json │ │ └── content-routing.md │ ├── installation │ │ ├── _category_.json │ │ ├── configuration.md │ │ └── quick-start.md │ ├── intro.md │ ├── notifications │ │ ├── _category_.json │ │ ├── apprise.md │ │ ├── discord.md │ │ └── tautulli.md │ └── utilities │ │ ├── _category_.json │ │ ├── delete-sync.md │ │ ├── plex-notifications.md │ │ └── user-tagging.md ├── docusaurus.config.ts ├── dynamic-base-path.md ├── package-lock.json ├── package.json ├── postcss.config.js ├── sidebars.ts ├── src │ ├── components │ │ ├── DocFeature.tsx │ │ ├── DocFeatureExample.tsx │ │ ├── DocModeToggle.tsx │ │ ├── GitHubStatsButton.tsx │ │ ├── GitHubStatsButtonWrapper.tsx │ │ ├── HomepageFeatures │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ │ ├── RetroTerminal.tsx │ │ ├── ThemeButtonAdapter.tsx │ │ └── WorkflowCard.tsx │ ├── css │ │ ├── custom.css │ │ ├── docfeature.css │ │ ├── docs.css │ │ ├── fonts.css │ │ ├── github-button.css │ │ ├── github-stats.css │ │ ├── globals.css │ │ ├── mode-toggle.css │ │ └── navbar.css │ ├── fonts.d.ts │ ├── pages │ │ ├── background-test.tsx │ │ ├── index.module.css │ │ ├── index.tsx │ │ └── markdown-page.md │ ├── theme │ │ ├── .biome.json │ │ ├── ColorModeToggle │ │ │ ├── custom-button.tsx │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ │ ├── Navbar │ │ │ ├── ColorModeToggle │ │ │ │ ├── index.tsx │ │ │ │ └── styles.module.css │ │ │ ├── Content │ │ │ │ ├── index.tsx │ │ │ │ └── styles.module.css │ │ │ ├── Layout │ │ │ │ ├── index.tsx │ │ │ │ └── styles.module.css │ │ │ ├── Logo │ │ │ │ └── index.tsx │ │ │ ├── MobileSidebar │ │ │ │ ├── Header │ │ │ │ │ └── index.tsx │ │ │ │ ├── Layout │ │ │ │ │ └── index.tsx │ │ │ │ ├── PrimaryMenu │ │ │ │ │ └── index.tsx │ │ │ │ ├── SecondaryMenu │ │ │ │ │ └── index.tsx │ │ │ │ ├── Toggle │ │ │ │ │ └── index.tsx │ │ │ │ └── index.tsx │ │ │ ├── Search │ │ │ │ ├── index.tsx │ │ │ │ └── styles.module.css │ │ │ └── index.tsx │ │ ├── NavbarItem │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ │ ├── Root.tsx │ │ └── font-loader.css │ └── types │ │ └── images.d.ts ├── static │ ├── .nojekyll │ ├── fonts │ │ ├── Shuttleblock-Bold.woff2 │ │ ├── Shuttleblock-Medium.woff2 │ │ └── Shuttleblock-MediumItalic.woff2 │ ├── gifs │ │ ├── Import.gif │ │ └── Plex-Grab.gif │ ├── img │ │ ├── Content-Route-1.png │ │ ├── Content-Route-2.png │ │ ├── DM-New-Epp.png │ │ ├── DM-Season.png │ │ ├── Dashboard1.png │ │ ├── Delete-Sync-Dry.png │ │ ├── Delete-Sync-Error.png │ │ ├── Discord-Edit-Modal.png │ │ ├── Discord-Notification.png │ │ ├── Discord-Settings.png │ │ ├── Discord-Signup.png │ │ ├── Plex-Notifications.png │ │ ├── User-Tags.png │ │ ├── Webhook-grab.png │ │ ├── favicon.ico │ │ ├── logo.svg │ │ ├── planet-m.webp │ │ ├── planet.webp │ │ ├── pulsarr-lg.png │ │ ├── pulsarr-social-card.png │ │ ├── pulsarr.svg │ │ └── ship.webp │ ├── openapi.json │ └── planet.webp ├── tailwind.config.ts └── tsconfig.json ├── migrations ├── knexfile.ts ├── migrate.ts └── migrations │ ├── 001_initial_schema.ts │ ├── 002_20250302_add_status_history.ts │ ├── 003_20250303_add_notifications_history.ts │ ├── 004_20250306_add_junction_tables.ts │ ├── 005_20250310_add_notification_tracking.ts │ ├── 006_20250311_add_sync_status.ts │ ├── 007_20250312_seed_genres.ts │ ├── 008_20250320_add_schedules.ts │ ├── 009_20250323_add_respect_user_sync_setting.ts │ ├── 010_20250325_add_delete_sync_notifications.ts │ ├── 011_20250328_add_apprise_integration.ts │ ├── 012-20250403_add_unified_routing.ts │ ├── 013_20250414_update_router_rules_format.ts │ ├── 014_20250425_add_monitor_new_items.ts │ ├── 015-20250427_add_user_tagging.ts │ ├── 016-20250428_add_primary_user_flag.ts │ ├── 017-20250428_extend_user_tagging.ts │ ├── 018_20250505_add_search_on_add.ts │ ├── 019_20250505_add_route_tags.ts │ ├── 020_20250506_add_minimum_availability.ts │ ├── 021_20250507_add_router_search_and_monitoring.ts │ ├── 022_20250508_add_plex_playlist_protection.ts │ ├── 023_20250513_add_tag_removal_options.ts │ ├── 024_20250513_add_tag_based_deletion_mode.ts │ ├── 025_20250514_add_series_type_to_sonarr.ts │ ├── 026_20250515_add_pending_webhooks.ts │ ├── 027_20250518_add_pending_webhook_config.ts │ ├── 028_20250527_add_tautulli_integration.ts │ └── 029_20250528_add_delete_sync_notify_only_on_deletion.ts ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── scripts ├── build-docs.sh ├── generate-openapi.ts └── openapi-app.ts ├── src ├── app.ts ├── client │ ├── assets │ │ ├── fonts │ │ │ ├── Shuttleblock-Bold.woff2 │ │ │ ├── Shuttleblock-Medium.woff2 │ │ │ └── Shuttleblock-MediumItalic.woff2 │ │ └── images │ │ │ ├── planet-m.webp │ │ │ ├── planet.webp │ │ │ ├── pulsarr-lg.png │ │ │ ├── pulsarr.png │ │ │ └── pulsarr.svg │ ├── components │ │ ├── nav.tsx │ │ ├── theme-provider.tsx │ │ └── ui │ │ │ ├── accordion.tsx │ │ │ ├── alert-dialog.tsx │ │ │ ├── alert.tsx │ │ │ ├── apprise-status-badge.tsx │ │ │ ├── aspect-ratio.tsx │ │ │ ├── asteroids.tsx │ │ │ ├── badge.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── carousel.tsx │ │ │ ├── certification-multi-select.tsx │ │ │ ├── chart.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── clear-settings-alert.tsx │ │ │ ├── command.tsx │ │ │ ├── credenza.tsx │ │ │ ├── crt-overlay.tsx │ │ │ ├── dialog.tsx │ │ │ ├── discord-bot-status-badge.tsx │ │ │ ├── drawer.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── editable-card-header.tsx │ │ │ ├── form.tsx │ │ │ ├── genre-multi-select.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── logout-alert.tsx │ │ │ ├── mode-toggle.tsx │ │ │ ├── multi-select.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── pulsar.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── route-card-header.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── settings-button.tsx │ │ │ ├── sheet.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── slider.tsx │ │ │ ├── starfield.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── tag-creation-dialog.tsx │ │ │ ├── tag-multi-select.tsx │ │ │ ├── tautulli-status-badge.tsx │ │ │ ├── time-input.tsx │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ ├── tooltip.tsx │ │ │ ├── user-multi-select.tsx │ │ │ ├── version-display.tsx │ │ │ └── workflow-status-badge.tsx │ ├── features │ │ ├── auth │ │ │ ├── components │ │ │ │ ├── login-error.tsx │ │ │ │ └── login-form.tsx │ │ │ ├── hooks │ │ │ │ └── useLoginForm.ts │ │ │ ├── index.tsx │ │ │ └── schemas │ │ │ │ └── login-schema.ts │ │ ├── content-router │ │ │ ├── components │ │ │ │ ├── accordion-content-router-section.tsx │ │ │ │ ├── accordion-route-card-skeleton.tsx │ │ │ │ ├── accordion-route-card.tsx │ │ │ │ ├── condition-builder.tsx │ │ │ │ ├── condition-group.tsx │ │ │ │ ├── condition-input.tsx │ │ │ │ └── delete-route-alert.tsx │ │ │ ├── constants.ts │ │ │ ├── hooks │ │ │ │ └── useContentRouter.ts │ │ │ ├── schemas │ │ │ │ └── content-router.schema.ts │ │ │ ├── types │ │ │ │ └── route-types.ts │ │ │ └── utils │ │ │ │ └── utils.ts │ │ ├── create-user │ │ │ ├── components │ │ │ │ ├── create-user-error.tsx │ │ │ │ └── create-user-form.tsx │ │ │ ├── hooks │ │ │ │ └── useCreateUserForm.ts │ │ │ ├── index.tsx │ │ │ └── schemas │ │ │ │ └── create-user-schema.ts │ │ ├── dashboard │ │ │ ├── components │ │ │ │ ├── analytics-dashboard.tsx │ │ │ │ ├── charts │ │ │ │ │ ├── content-distribution-chart.tsx │ │ │ │ │ ├── instance-content-breakdown-chart.tsx │ │ │ │ │ ├── notification-charts.tsx │ │ │ │ │ ├── status-transition-chart.tsx │ │ │ │ │ └── top-genres-chart.tsx │ │ │ │ ├── media-card-skeleton.tsx │ │ │ │ ├── media-card.tsx │ │ │ │ ├── popularity-rankings.tsx │ │ │ │ ├── stats-header.tsx │ │ │ │ └── watchlist-carousel.tsx │ │ │ ├── hooks │ │ │ │ ├── useChartData.ts │ │ │ │ └── useDashboardStats.ts │ │ │ ├── index.tsx │ │ │ └── store │ │ │ │ └── dashboardStore.ts │ │ ├── notifications │ │ │ ├── components │ │ │ │ ├── apprise │ │ │ │ │ └── apprise-form.tsx │ │ │ │ ├── discord │ │ │ │ │ ├── discord-bot-form.tsx │ │ │ │ │ ├── discord-clear-alert.tsx │ │ │ │ │ └── discord-webhook-form.tsx │ │ │ │ ├── email │ │ │ │ │ └── email-placeholder.tsx │ │ │ │ ├── general │ │ │ │ │ └── general-settings-form.tsx │ │ │ │ ├── notifications-section.tsx │ │ │ │ └── tautulli │ │ │ │ │ └── tautulli-form.tsx │ │ │ ├── hooks │ │ │ │ └── useNotificationsConfig.ts │ │ │ ├── index.tsx │ │ │ └── schemas │ │ │ │ └── form-schemas.ts │ │ ├── plex │ │ │ ├── components │ │ │ │ ├── connection │ │ │ │ │ ├── connection-section-skeleton.tsx │ │ │ │ │ └── connection-section.tsx │ │ │ │ ├── setup │ │ │ │ │ └── setup-modal.tsx │ │ │ │ ├── user │ │ │ │ │ ├── bulk-edit-modal.tsx │ │ │ │ │ ├── user-edit-modal.tsx │ │ │ │ │ ├── user-table-section.tsx │ │ │ │ │ ├── user-table-skeleton.tsx │ │ │ │ │ └── user-table.tsx │ │ │ │ └── watchlist │ │ │ │ │ └── watchlist-stats.tsx │ │ │ ├── hooks │ │ │ │ ├── usePlexBulkUpdate.ts │ │ │ │ ├── usePlexConnection.ts │ │ │ │ ├── usePlexRssFeeds.ts │ │ │ │ ├── usePlexSetup.ts │ │ │ │ ├── usePlexUser.ts │ │ │ │ └── usePlexWatchlist.ts │ │ │ ├── index.tsx │ │ │ └── store │ │ │ │ ├── constants.ts │ │ │ │ ├── schemas.ts │ │ │ │ └── types.ts │ │ ├── radarr │ │ │ ├── components │ │ │ │ ├── instance │ │ │ │ │ ├── delete-instance-alert.tsx │ │ │ │ │ ├── radarr-card-skeleton.tsx │ │ │ │ │ ├── radarr-connection-settings.tsx │ │ │ │ │ ├── radarr-instance-card.tsx │ │ │ │ │ └── radarr-sync-modal.tsx │ │ │ │ └── selects │ │ │ │ │ ├── radarr-selects.tsx │ │ │ │ │ └── radarr-synced-instance-select.tsx │ │ │ ├── hooks │ │ │ │ ├── content-router │ │ │ │ │ └── useRadarrContentRouterAdapter.ts │ │ │ │ ├── instance │ │ │ │ │ ├── useRadarrConnection.ts │ │ │ │ │ ├── useRadarrInstance.ts │ │ │ │ │ ├── useRadarrInstanceForms.ts │ │ │ │ │ └── useRadarrSyncProgress.ts │ │ │ │ └── selects │ │ │ │ │ ├── useRadarrSelects.ts │ │ │ │ │ └── useRadarrSync.ts │ │ │ ├── index.tsx │ │ │ ├── store │ │ │ │ ├── constants.ts │ │ │ │ ├── radarrStore.ts │ │ │ │ ├── responses.ts │ │ │ │ └── schemas.ts │ │ │ └── types │ │ │ │ └── types.ts │ │ ├── sonarr │ │ │ ├── components │ │ │ │ ├── instance │ │ │ │ │ ├── delete-instance-alert.tsx │ │ │ │ │ ├── sonarr-card-skeleton.tsx │ │ │ │ │ ├── sonarr-connection-settings.tsx │ │ │ │ │ ├── sonarr-instance-card.tsx │ │ │ │ │ └── sonarr-sync-modal.tsx │ │ │ │ └── selects │ │ │ │ │ ├── sonarr-selects.tsx │ │ │ │ │ └── sonarr-synced-instance-select.tsx │ │ │ ├── constants.ts │ │ │ ├── hooks │ │ │ │ ├── content-router │ │ │ │ │ └── useSonarrContentRouterAdapter.ts │ │ │ │ ├── instance │ │ │ │ │ ├── useSonarrConnection.ts │ │ │ │ │ ├── useSonarrInstance.ts │ │ │ │ │ ├── useSonarrInstanceForms.ts │ │ │ │ │ └── useSyncProgress.ts │ │ │ │ └── selects │ │ │ │ │ ├── useSonarrSelects.ts │ │ │ │ │ └── useSonarrSync.ts │ │ │ ├── index.tsx │ │ │ ├── store │ │ │ │ ├── constants.ts │ │ │ │ ├── responses.ts │ │ │ │ ├── schemas.ts │ │ │ │ └── sonarrStore.ts │ │ │ └── types │ │ │ │ └── types.ts │ │ └── utilities │ │ │ ├── components │ │ │ ├── delete-sync │ │ │ │ ├── delete-sync-confirmation-modal.tsx │ │ │ │ ├── delete-sync-dry-run-modal.tsx │ │ │ │ ├── delete-sync-form.tsx │ │ │ │ └── delete-sync-skeleton.tsx │ │ │ ├── plex-notifications │ │ │ │ ├── plex-notifications-confirmation-modal.tsx │ │ │ │ ├── plex-notifications-form.tsx │ │ │ │ └── plex-notifications-skeleton.tsx │ │ │ ├── user-tags │ │ │ │ ├── user-tags-delete-confirmation-modal.tsx │ │ │ │ ├── user-tags-form.tsx │ │ │ │ └── user-tags-skeleton.tsx │ │ │ └── utilities-dashboard.tsx │ │ │ ├── hooks │ │ │ ├── useDeleteSync.ts │ │ │ ├── useDeleteSyncActions.ts │ │ │ ├── useDeleteSyncForm.ts │ │ │ ├── useDeleteSyncSchedule.ts │ │ │ ├── usePlexNotifications.ts │ │ │ ├── usePlexServerDiscovery.ts │ │ │ ├── useTaggingProgress.ts │ │ │ └── useUserTags.ts │ │ │ ├── index.tsx │ │ │ └── stores │ │ │ └── utilitiesStore.ts │ ├── hooks │ │ ├── notifications │ │ │ ├── useDiscordStatus.ts │ │ │ └── useTautulliStatus.ts │ │ ├── use-media-query.tsx │ │ ├── use-toast.ts │ │ ├── useProgress.tsx │ │ ├── useVersionCheck.tsx │ │ └── workflow │ │ │ └── useWatchlistStatus.ts │ ├── index.html │ ├── layouts │ │ ├── authenticated.tsx │ │ └── window.tsx │ ├── lib │ │ └── utils.ts │ ├── main.tsx │ ├── router │ │ └── router.tsx │ ├── stores │ │ ├── configStore.ts │ │ └── progressStore.ts │ ├── styles │ │ ├── fonts.css │ │ └── globals.css │ ├── tsconfig.json │ ├── types │ │ └── globals.d.ts │ └── vite-env.d.ts ├── plugins │ ├── custom │ │ ├── apprise-notifications.ts │ │ ├── content-router.ts │ │ ├── database.ts │ │ ├── delete-sync.ts │ │ ├── discord-notifications.ts │ │ ├── pending-webhooks.ts │ │ ├── plex-watchlist.ts │ │ ├── progress.ts │ │ ├── radarr-manager.ts │ │ ├── scheduler.ts │ │ ├── scrypt.ts │ │ ├── sonarr-manager.ts │ │ ├── status-sync.ts │ │ ├── tautulli.ts │ │ ├── user-tag.plugin.ts │ │ └── watchlist-workflow.ts │ └── external │ │ ├── compress.ts │ │ ├── cors.ts │ │ ├── env.ts │ │ ├── helmet.ts │ │ ├── rate-limit.ts │ │ ├── sensible.ts │ │ ├── session.ts │ │ ├── sse.ts │ │ └── swagger.ts ├── router-evaluators │ ├── certification-evaluator.ts │ ├── conditional-evaluator.ts │ ├── genre-evaluator.ts │ ├── language-evaluator.ts │ ├── season-evaluator.ts │ ├── user-evaluator.ts │ └── year-evaluator.ts ├── routes │ ├── autohooks.ts │ └── v1 │ │ ├── config │ │ └── config.ts │ │ ├── content-router │ │ ├── content-router-management.ts │ │ └── content-router-plugins.ts │ │ ├── notifications │ │ ├── discord-controls.ts │ │ └── webhook.ts │ │ ├── plex │ │ ├── configure-notifications.ts │ │ ├── discover-servers.ts │ │ ├── generate-rss-feeds.ts │ │ ├── get-genres.ts │ │ ├── get-notification-status.ts │ │ ├── index.ts │ │ ├── others-watchlist-token.ts │ │ ├── parse-rss.ts │ │ ├── ping.ts │ │ ├── remove-notifications.ts │ │ └── self-watchlist-token.ts │ │ ├── radarr │ │ ├── create-tag.ts │ │ ├── quality-profiles.ts │ │ ├── radarr-tst.ts │ │ ├── root-folders.ts │ │ ├── tags.ts │ │ └── test-connection.ts │ │ ├── scheduler │ │ └── scheduler.ts │ │ ├── sonarr │ │ ├── create-tag.ts │ │ ├── quality-profiles.ts │ │ ├── root-folders.ts │ │ ├── sonar-tst.ts │ │ ├── tags.ts │ │ └── test-connection.ts │ │ ├── stats │ │ └── stats.ts │ │ ├── sync │ │ └── sync.ts │ │ ├── tags │ │ └── user-tags.ts │ │ ├── tautulli │ │ ├── sync-notifiers.ts │ │ ├── test-connection.ts │ │ └── test.ts │ │ ├── users │ │ ├── bulk-update.ts │ │ ├── change-password.ts │ │ ├── create-admin.ts │ │ ├── login.ts │ │ ├── logout.ts │ │ ├── users-list.ts │ │ └── users.ts │ │ └── watchlist-workflow │ │ └── watchlist-workflow.ts ├── schemas │ ├── auth │ │ ├── admin-user.ts │ │ ├── auth.ts │ │ ├── login.ts │ │ ├── logout.ts │ │ └── users.ts │ ├── config │ │ └── config.schema.ts │ ├── content-router │ │ ├── constants.ts │ │ ├── content-router.schema.ts │ │ └── evaluator-metadata.schema.ts │ ├── notifications │ │ ├── discord-control.schema.ts │ │ └── webhook.schema.ts │ ├── plex │ │ ├── configure-notifications.schema.ts │ │ ├── discover-servers.schema.ts │ │ ├── generate-rss-feeds.schema.ts │ │ ├── get-genres.schema.ts │ │ ├── get-notification-status.schema.ts │ │ ├── others-watchlist-token.schema.ts │ │ ├── parse-rss-feeds.schema.ts │ │ ├── ping.schema.ts │ │ ├── remove-notifications.schema.ts │ │ └── self-watchlist-token.schema.ts │ ├── radarr │ │ ├── create-tag.schema.ts │ │ ├── get-quality-profiles.schema.ts │ │ ├── get-root-folders.schema.ts │ │ ├── get-tags.schema.ts │ │ └── test-connection.schema.ts │ ├── scheduler │ │ └── scheduler.schema.ts │ ├── sonarr │ │ ├── create-tag.schema.ts │ │ ├── get-quality-profiles.schema.ts │ │ ├── get-root-folders.schema.ts │ │ ├── get-tags.schema.ts │ │ └── test-connection.schema.ts │ ├── stats │ │ └── stats.schema.ts │ ├── sync │ │ └── sync.schema.ts │ ├── tags │ │ └── user-tags.schema.ts │ ├── tautulli │ │ └── tautulli.schema.ts │ ├── users │ │ ├── users-list.schema.ts │ │ └── users.schema.ts │ └── watchlist-workflow │ │ └── watchlist-workflow.schema.ts ├── server.ts ├── services │ ├── apprise-notifications.service.ts │ ├── content-router.service.ts │ ├── database.service.ts │ ├── delete-sync.service.ts │ ├── discord-notifications.service.ts │ ├── event-emitter.service.ts │ ├── pending-webhooks.service.ts │ ├── plex-watchlist.service.ts │ ├── radarr-manager.service.ts │ ├── radarr.service.ts │ ├── scheduler.service.ts │ ├── sonarr-manager.service.ts │ ├── sonarr.service.ts │ ├── tautulli.service.ts │ ├── user-tag.service.ts │ ├── watchlist-status.service.ts │ └── watchlist-workflow.service.ts ├── types │ ├── apprise.types.ts │ ├── config.types.ts │ ├── content-lookup.types.ts │ ├── delete-sync.types.ts │ ├── discord.types.ts │ ├── errors.ts │ ├── pending-webhooks.types.ts │ ├── plex.types.ts │ ├── progress.types.ts │ ├── radarr.types.ts │ ├── router.types.ts │ ├── scheduler.types.ts │ ├── sonarr.types.ts │ ├── system-status.types.ts │ ├── tautulli.types.ts │ ├── watchlist-status.types.ts │ └── webhook.types.ts └── utils │ ├── auth-bypass.ts │ ├── content-router-formatter.ts │ ├── discord-commands │ └── notifications-command.ts │ ├── guid-handler.ts │ ├── ip.ts │ ├── logger.ts │ ├── plex-server.ts │ ├── plex.ts │ ├── rule-builder.ts │ ├── session.ts │ └── webhookQueue.ts ├── tailwind.config.ts ├── tsconfig.json └── vite.config.js /.biomeignore: -------------------------------------------------------------------------------- 1 | docs/src/theme/ -------------------------------------------------------------------------------- /.coderabbit.yml: -------------------------------------------------------------------------------- 1 | language: "en-US" 2 | early_access: false 3 | reviews: 4 | profile: "chill" 5 | request_changes_workflow: false 6 | high_level_summary: true 7 | poem: false 8 | review_status: true 9 | collapse_walkthrough: false 10 | auto_review: 11 | enabled: true 12 | drafts: false 13 | path_filters: 14 | - "!docs/**" 15 | chat: 16 | auto_reply: true 17 | -------------------------------------------------------------------------------- /.github/DISCUSSION_TEMPLATE/general.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Topic 4 | 5 | 6 | 7 | ## Background 8 | 9 | 10 | 11 | ## Questions/Points to Consider 12 | 13 | 14 | 15 | ## Additional Information 16 | 17 | -------------------------------------------------------------------------------- /.github/DISCUSSION_TEMPLATE/ideas.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Idea/Feature Suggestion 4 | 5 | 6 | 7 | ## Use Case 8 | 9 | 10 | 11 | ## Proposed Implementation 12 | 13 | 14 | 15 | ## Alternatives Considered 16 | 17 | -------------------------------------------------------------------------------- /.github/DISCUSSION_TEMPLATE/q_a.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Question 4 | 5 | 6 | 7 | ## Environment 8 | 9 | 10 | - Pulsarr Version: [e.g. v0.1.0-beta.2] 11 | - Deployment Method: [e.g. Docker, Node.js] 12 | - OS: [e.g. Ubuntu 22.04] 13 | - Browser: [e.g. Chrome] 14 | 15 | ## What I've Tried 16 | 17 | 18 | 19 | ## Relevant Screenshots or Logs 20 | 21 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | ko_fi: jamcalli 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a report to help improve Pulsarr 4 | title: '[BUG] ' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | ## Bug Description 10 | A clear and concise description of the bug. 11 | 12 | ## Steps To Reproduce 13 | 1. Go to '...' 14 | 2. Click on '....' 15 | 3. Scroll down to '....' 16 | 4. See error 17 | 18 | ## Expected Behavior 19 | A clear description of what you expected to happen. 20 | 21 | ## Screenshots 22 | If applicable, add screenshots to help explain your problem. 23 | 24 | ## Environment 25 | - Pulsarr Version: [e.g. v0.2.1-beta] 26 | - Deployment: [e.g. Docker, Node.js] 27 | - OS: [e.g. Ubuntu 22.04] 28 | - Browser: [e.g. Chrome 121] 29 | - Node Version: [e.g. 23.6.0] 30 | 31 | ## Logs 32 |
33 | Relevant Log Output 34 | 35 | ``` 36 | Paste your logs here 37 | ``` 38 |
39 | 40 | ## Additional Context 41 | Add any other context about the problem here. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for Pulsarr 4 | title: '[FEATURE] ' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | ## Feature Description 10 | 11 | 12 | ## Use Case 13 | 14 | 15 | ## Proposed Solution 16 | 17 | 18 | ## Alternatives Considered 19 | 20 | 21 | ## Additional Context 22 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | ## Related Issues 5 | 6 | 7 | ## Type of Change 8 | 9 | - [ ] Bug fix (non-breaking change that fixes an issue) 10 | - [ ] New feature (non-breaking change that adds functionality) 11 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 12 | - [ ] Performance improvement 13 | - [ ] Code refactoring 14 | - [ ] Documentation update 15 | - [ ] Dependency update 16 | 17 | ## Testing Performed 18 | 19 | 20 | ## Screenshots 21 | 22 | 23 | ## Checklist 24 | - [ ] My code follows the style guidelines of this project 25 | - [ ] I have performed a self-review of my own code 26 | - [ ] I have commented my code, particularly in hard-to-understand areas 27 | - [ ] I have made corresponding changes to the documentation 28 | - [ ] My changes generate no new warnings 29 | - [ ] My changes work with existing functionality -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$RESOLVED_VERSION' 2 | tag-template: 'v$RESOLVED_VERSION' 3 | categories: 4 | - title: '🚀 Features' 5 | labels: 6 | - 'feature' 7 | - 'enhancement' 8 | - title: '🐛 Bug Fixes' 9 | labels: 10 | - 'fix' 11 | - 'bugfix' 12 | - 'bug' 13 | - title: '🧰 Maintenance' 14 | labels: 15 | - 'chore' 16 | - 'maintenance' 17 | - 'documentation' 18 | - title: '⚡ Performance' 19 | labels: 20 | - 'performance' 21 | - 'optimization' 22 | - title: '🔄 Dependencies' 23 | labels: 24 | - 'dependencies' 25 | - 'deps' 26 | 27 | version-resolver: 28 | major: 29 | labels: 30 | - 'major' 31 | - 'breaking' 32 | minor: 33 | labels: 34 | - 'minor' 35 | - 'feature' 36 | - 'enhancement' 37 | patch: 38 | labels: 39 | - 'patch' 40 | - 'bug' 41 | - 'bugfix' 42 | - 'fix' 43 | - 'maintenance' 44 | - 'docs' 45 | - 'dependencies' 46 | default: patch 47 | 48 | template: | 49 | ## Changes 50 | 51 | $CHANGES 52 | 53 | ## Docker 54 | ``` 55 | docker pull lakker/pulsarr:$RESOLVED_VERSION 56 | ``` 57 | 58 | ## Contributors 59 | 60 | $CONTRIBUTORS -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | on: 3 | push: 4 | branches: [ master, develop ] 5 | pull_request: 6 | branches: [ master, develop ] 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Use Node.js 13 | uses: actions/setup-node@v4 14 | with: 15 | node-version: '23.6.0' 16 | cache: 'npm' 17 | - name: Install dependencies 18 | run: npm ci 19 | - name: Setup Biome 20 | uses: biomejs/setup-biome@v2 21 | - name: Run Biome 22 | run: biome ci . 23 | - name: TypeScript Check - Server 24 | run: tsc --noEmit 25 | - name: TypeScript Check - Client 26 | run: tsc --noEmit -p src/client/tsconfig.json 27 | - name: Build 28 | run: npm run build 29 | - name: Contribute List 30 | if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop') 31 | uses: akhilmhdh/contributors-readme-action@v2.3.10 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.ADMIN_TOKEN }} 34 | with: 35 | auto_detect_branch_protection: false 36 | -------------------------------------------------------------------------------- /.github/workflows/deploy-docs-production.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Documentation to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - 'docs/**' 9 | - 'src/**' # OpenAPI generation depends on source files 10 | - 'scripts/**' # Build scripts 11 | - 'package.json' 12 | - 'package-lock.json' 13 | - '.github/workflows/deploy-docs-production.yml' 14 | 15 | permissions: 16 | contents: write 17 | pages: write 18 | id-token: write 19 | 20 | jobs: 21 | deploy: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | with: 27 | fetch-depth: 0 # Needed for proper git history 28 | 29 | - name: Setup Node.js 30 | uses: actions/setup-node@v4 31 | with: 32 | node-version: '20.x' 33 | cache: 'npm' 34 | 35 | - name: Install root dependencies 36 | run: npm ci 37 | 38 | - name: Install docs dependencies 39 | run: | 40 | cd docs 41 | npm ci 42 | 43 | - name: Generate OpenAPI spec and build documentation 44 | run: npm run docs:build 45 | 46 | - name: Deploy to GitHub Pages 47 | run: | 48 | git config --global user.email "action@github.com" 49 | git config --global user.name "GitHub Action" 50 | cd docs 51 | npm run deploy 52 | env: 53 | GIT_USER: ${{ github.actor }} 54 | GIT_PASS: ${{ secrets.GITHUB_TOKEN }} 55 | USE_SSH: false -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | *.log 3 | 4 | # Dependency directories 5 | node_modules/ 6 | 7 | # Optional npm cache directory 8 | .npm 9 | 10 | # dotenv environment variables file 11 | .env 12 | .env.test 13 | 14 | # builds 15 | dist 16 | 17 | # builds 18 | backend/tests/ 19 | tests/ 20 | **/tests/ 21 | 22 | # configs 23 | config.json 24 | 25 | # TypeScript incremental compilation cache 26 | *.tsbuildinfo 27 | 28 | # databases 29 | *.db* 30 | 31 | # vscode 32 | .vscode/ 33 | 34 | # repomix 35 | repomix* 36 | **/.claude/settings.local.json 37 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no -- commitlint --edit "$1" 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* -------------------------------------------------------------------------------- /assets/icons/pulsarr-lg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/assets/icons/pulsarr-lg.png -------------------------------------------------------------------------------- /assets/icons/pulsarr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/assets/icons/pulsarr.png -------------------------------------------------------------------------------- /assets/screenshots/Content-Route-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/assets/screenshots/Content-Route-1.png -------------------------------------------------------------------------------- /assets/screenshots/Content-Route-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/assets/screenshots/Content-Route-2.png -------------------------------------------------------------------------------- /assets/screenshots/DM-New-Epp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/assets/screenshots/DM-New-Epp.png -------------------------------------------------------------------------------- /assets/screenshots/DM-Season.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/assets/screenshots/DM-Season.png -------------------------------------------------------------------------------- /assets/screenshots/Dashboard1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/assets/screenshots/Dashboard1.png -------------------------------------------------------------------------------- /assets/screenshots/Dashboard2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/assets/screenshots/Dashboard2.png -------------------------------------------------------------------------------- /assets/screenshots/Dashboard3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/assets/screenshots/Dashboard3.png -------------------------------------------------------------------------------- /assets/screenshots/Dashboard5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/assets/screenshots/Dashboard5.png -------------------------------------------------------------------------------- /assets/screenshots/Dashboard6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/assets/screenshots/Dashboard6.png -------------------------------------------------------------------------------- /assets/screenshots/Delete-Sync-Dry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/assets/screenshots/Delete-Sync-Dry.png -------------------------------------------------------------------------------- /assets/screenshots/Delete-Sync-Error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/assets/screenshots/Delete-Sync-Error.png -------------------------------------------------------------------------------- /assets/screenshots/Delete-Sync.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/assets/screenshots/Delete-Sync.png -------------------------------------------------------------------------------- /assets/screenshots/Discord-Edit-Modal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/assets/screenshots/Discord-Edit-Modal.png -------------------------------------------------------------------------------- /assets/screenshots/Discord-Settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/assets/screenshots/Discord-Settings.png -------------------------------------------------------------------------------- /assets/screenshots/Discord-Signup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/assets/screenshots/Discord-Signup.png -------------------------------------------------------------------------------- /assets/screenshots/Login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/assets/screenshots/Login.png -------------------------------------------------------------------------------- /assets/screenshots/Notifications.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/assets/screenshots/Notifications.png -------------------------------------------------------------------------------- /assets/screenshots/Plex-Notifications.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/assets/screenshots/Plex-Notifications.png -------------------------------------------------------------------------------- /assets/screenshots/Plex1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/assets/screenshots/Plex1.png -------------------------------------------------------------------------------- /assets/screenshots/Plex2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/assets/screenshots/Plex2.png -------------------------------------------------------------------------------- /assets/screenshots/Radarr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/assets/screenshots/Radarr.png -------------------------------------------------------------------------------- /assets/screenshots/Sonarr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/assets/screenshots/Sonarr.png -------------------------------------------------------------------------------- /assets/screenshots/User-Tags.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/assets/screenshots/User-Tags.png -------------------------------------------------------------------------------- /assets/screenshots/Webhook-grab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/assets/screenshots/Webhook-grab.png -------------------------------------------------------------------------------- /commitlint.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'body-max-line-length': [0, 'always'], 5 | 'footer-max-line-length': [0, 'always'], 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/client/styles/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /data/db/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/data/db/.gitkeep -------------------------------------------------------------------------------- /data/logs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/data/logs/.gitkeep -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Run migrations 4 | echo "Running database migrations..." 5 | npm run migrate 6 | 7 | # Start the application with arguments 8 | echo "Starting application with args: ${NODE_ARGS:-}" 9 | exec node dist/server.js ${NODE_ARGS:-} -------------------------------------------------------------------------------- /dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:23.6.0-alpine AS builder 2 | 3 | WORKDIR /app 4 | 5 | # Set cache dir 6 | ENV CACHE_DIR=/app/build-cache 7 | 8 | # Copy build essentials 9 | COPY package*.json ./ 10 | COPY src ./src 11 | COPY vite.config.js ./ 12 | COPY tsconfig.json ./ 13 | COPY tailwind.config.ts ./ 14 | COPY postcss.config.mjs ./ 15 | 16 | # Install dependencies 17 | RUN npm ci 18 | 19 | # Build 20 | RUN npm run build 21 | 22 | # Ensure cache dir 23 | RUN mkdir -p ${CACHE_DIR} 24 | 25 | FROM node:23.6.0-alpine 26 | 27 | WORKDIR /app 28 | 29 | # cache dir in final 30 | ENV CACHE_DIR=/app/build-cache 31 | 32 | # Copy package files and install dependencies 33 | COPY package*.json ./ 34 | RUN npm ci 35 | 36 | # Create necessary directories 37 | RUN mkdir -p /app/data/db && \ 38 | mkdir -p /app/data/log && \ 39 | mkdir -p ${CACHE_DIR} 40 | 41 | # Copy build artifacts, config, and cache 42 | COPY --from=builder /app/dist ./dist 43 | COPY --from=builder ${CACHE_DIR} ${CACHE_DIR} 44 | COPY vite.config.js ./ 45 | COPY migrations ./migrations 46 | COPY docker-entrypoint.sh ./ 47 | RUN chmod +x docker-entrypoint.sh 48 | 49 | # Set production environment 50 | ENV NODE_ENV=production 51 | 52 | # Make volumes 53 | VOLUME ${CACHE_DIR} 54 | VOLUME /app/data 55 | EXPOSE 3003 56 | 57 | CMD ["./docker-entrypoint.sh"] -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ yarn 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ``` 14 | $ yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ### Build 20 | 21 | ``` 22 | $ yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ### Deployment 28 | 29 | Using SSH: 30 | 31 | ``` 32 | $ USE_SSH=true yarn deploy 33 | ``` 34 | 35 | Not using SSH: 36 | 37 | ``` 38 | $ GIT_USER= yarn deploy 39 | ``` 40 | 41 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 42 | -------------------------------------------------------------------------------- /docs/deploy-test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Test deployment script for current branch 4 | echo "Building documentation..." 5 | npm run build 6 | 7 | echo "Deploying to GitHub Pages..." 8 | GIT_USER=$(git config user.name) npm run deploy 9 | 10 | echo "Documentation should be available at:" 11 | echo "https://jamcalli.github.io/pulsarr/" -------------------------------------------------------------------------------- /docs/docs/api-documentation.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 10 3 | --- 4 | 5 | # API Documentation 6 | 7 | Pulsarr provides a comprehensive REST API for integrating with external applications and services. The API is fully documented using OpenAPI 3.0 specification. 8 | 9 | ## API Reference 10 | 11 | Browse the complete API documentation with interactive examples: 12 | 13 | - [Authentication API](./api/authentication) - Login, logout, and session management 14 | - [Plex API](./api/plex) - User management, watchlist synchronization 15 | - [Sonarr API](./api/sonarr) - Instance configuration, series management 16 | - [Radarr API](./api/radarr) - Instance configuration, movie management 17 | - [Configuration API](./api/config) - Application settings and routing rules 18 | - [Users API](./api/users) - User management endpoints 19 | 20 | ## Getting Started 21 | 22 | All API requests require authentication via session cookies or API keys. The base URL for all endpoints is: 23 | 24 | ``` 25 | http://your-server:3003/api 26 | ``` 27 | 28 | ### Authentication 29 | 30 | Most endpoints require authentication. You can authenticate using: 31 | - Session cookies (web UI login) 32 | - API keys (coming soon) 33 | 34 | ### Response Format 35 | 36 | All responses are in JSON format with consistent error handling: 37 | 38 | ```json 39 | { 40 | "success": true, 41 | "data": { ... }, 42 | "message": "Optional message" 43 | } 44 | ``` 45 | 46 | ## OpenAPI Specification 47 | 48 | - [Download OpenAPI JSON](/openapi.json) 49 | - [Live API Documentation](http://localhost:3003/api/docs) (when server is running) -------------------------------------------------------------------------------- /docs/docs/api/authentication.tag.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | id: authentication 3 | title: "Authentication" 4 | description: "Authentication" 5 | custom_edit_url: null 6 | --- 7 | 8 | 9 | 10 | Authentication endpoints 11 | 12 | 13 | 14 | ```mdx-code-block 15 | import DocCardList from '@theme/DocCardList'; 16 | import {useCurrentSidebarCategory} from '@docusaurus/theme-common'; 17 | 18 | 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/docs/api/config.tag.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | id: config 3 | title: "Config" 4 | description: "Config" 5 | custom_edit_url: null 6 | --- 7 | 8 | 9 | 10 | Configuration endpoints 11 | 12 | 13 | 14 | ```mdx-code-block 15 | import DocCardList from '@theme/DocCardList'; 16 | import {useCurrentSidebarCategory} from '@docusaurus/theme-common'; 17 | 18 | 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/docs/api/plex.tag.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | id: plex 3 | title: "Plex" 4 | description: "Plex" 5 | custom_edit_url: null 6 | --- 7 | 8 | 9 | 10 | Plex related endpoints 11 | 12 | 13 | 14 | ```mdx-code-block 15 | import DocCardList from '@theme/DocCardList'; 16 | import {useCurrentSidebarCategory} from '@docusaurus/theme-common'; 17 | 18 | 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/docs/api/pulsarr-api.info.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | id: pulsarr-api 3 | title: "Pulsarr API" 4 | description: "API documentation for Pulsarr - a Plex watchlist integration for Sonarr and Radarr" 5 | sidebar_label: Introduction 6 | sidebar_position: 0 7 | hide_title: true 8 | custom_edit_url: null 9 | --- 10 | 11 | import ApiLogo from "@theme/ApiLogo"; 12 | import Heading from "@theme/Heading"; 13 | import SchemaTabs from "@theme/SchemaTabs"; 14 | import TabItem from "@theme/TabItem"; 15 | import Export from "@theme/ApiExplorer/Export"; 16 | 17 | 21 | 22 | 23 | 28 | 29 | 30 | 31 | 32 | API documentation for Pulsarr - a Plex watchlist integration for Sonarr and Radarr 33 | 34 | 35 | -------------------------------------------------------------------------------- /docs/docs/api/radarr.tag.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | id: radarr 3 | title: "Radarr" 4 | description: "Radarr" 5 | custom_edit_url: null 6 | --- 7 | 8 | 9 | 10 | Radarr related endpoints 11 | 12 | 13 | 14 | ```mdx-code-block 15 | import DocCardList from '@theme/DocCardList'; 16 | import {useCurrentSidebarCategory} from '@docusaurus/theme-common'; 17 | 18 | 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/docs/api/sonarr.tag.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | id: sonarr 3 | title: "Sonarr" 4 | description: "Sonarr" 5 | custom_edit_url: null 6 | --- 7 | 8 | 9 | 10 | Sonarr related endpoints 11 | 12 | 13 | 14 | ```mdx-code-block 15 | import DocCardList from '@theme/DocCardList'; 16 | import {useCurrentSidebarCategory} from '@docusaurus/theme-common'; 17 | 18 | 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/docs/api/users.tag.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | id: users 3 | title: "Users" 4 | description: "Users" 5 | custom_edit_url: null 6 | --- 7 | 8 | 9 | 10 | User management endpoints 11 | 12 | 13 | 14 | ```mdx-code-block 15 | import DocCardList from '@theme/DocCardList'; 16 | import {useCurrentSidebarCategory} from '@docusaurus/theme-common'; 17 | 18 | 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/docs/features/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Features", 3 | "position": 3, 4 | "link": { 5 | "type": "generated-index", 6 | "description": "Learn about Pulsarr's powerful feature set." 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /docs/docs/installation/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Installation", 3 | "position": 2, 4 | "link": { 5 | "type": "generated-index", 6 | "description": "Learn how to install Pulsarr on different platforms." 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /docs/docs/notifications/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Notifications", 3 | "position": 4, 4 | "link": { 5 | "type": "generated-index", 6 | "description": "Set up notification systems to keep users informed about content availability." 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /docs/docs/utilities/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Utilities", 3 | "position": 5, 4 | "link": { 5 | "type": "generated-index", 6 | "description": "Advanced utility features for content management and automation." 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /docs/docs/utilities/plex-notifications.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | import useBaseUrl from '@docusaurus/useBaseUrl'; 6 | 7 | # Plex Notifications 8 | 9 | ## Automatic Library Updates 10 | 11 | Pulsarr's Plex Notifications feature automatically configures webhooks in all your connected Sonarr and Radarr instances to keep your Plex libraries fresh without manual intervention. 12 | 13 | ### Key Features 14 | 15 | - **Automatic Configuration**: Sets up notification webhooks in all connected Sonarr and Radarr instances 16 | - **Server Discovery**: Easily find and select your Plex server with the built-in discovery tool 17 | - **Content Synchronization**: Keeps your Plex libraries updated when content is added, removed, or modified 18 | - **Multi-Instance Support**: Works across all your Sonarr and Radarr instances simultaneously 19 | - **SSL Support**: Secure connections to your Plex server 20 | 21 | ### Setup Instructions 22 | 23 | 1. Navigate to the **Utilities** section in the Pulsarr web interface 24 | 2. Enter your Plex authentication token (defaults to the token provided during setup) 25 | 3. Click "Find Servers" to automatically discover available Plex servers 26 | 4. Select your server or manually enter your Plex host, port, and SSL settings 27 | 5. Save your changes to automatically configure webhooks in all Sonarr and Radarr instances 28 | 29 | Plex Notifications Setup Interface 30 | 31 | Once configured, anytime content is added, modified, or removed via Sonarr or Radarr, your Plex libraries will automatically refresh to reflect these changes. -------------------------------------------------------------------------------- /docs/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /docs/sidebars.ts: -------------------------------------------------------------------------------- 1 | import type { SidebarsConfig } from '@docusaurus/plugin-content-docs' 2 | import apiSidebar from './docs/api/sidebar' 3 | 4 | // This runs in Node.js - Don't use client-side code here (browser APIs, JSX...) 5 | 6 | /** 7 | * Creating a sidebar enables you to: 8 | - create an ordered group of docs 9 | - render a sidebar for each doc of that group 10 | - provide next/previous navigation 11 | 12 | The sidebars can be generated from the filesystem, or explicitly defined here. 13 | 14 | Create as many sidebars as you want. 15 | */ 16 | const sidebars: SidebarsConfig = { 17 | // By default, Docusaurus generates a sidebar from the docs folder structure 18 | tutorialSidebar: [ 19 | 'intro', 20 | { 21 | type: 'category', 22 | label: 'Installation', 23 | items: ['installation/quick-start', 'installation/configuration'], 24 | }, 25 | { 26 | type: 'category', 27 | label: 'Features', 28 | items: ['features/content-routing'], 29 | }, 30 | { 31 | type: 'category', 32 | label: 'Notifications', 33 | items: [ 34 | 'notifications/discord', 35 | 'notifications/apprise', 36 | 'notifications/tautulli', 37 | ], 38 | }, 39 | { 40 | type: 'category', 41 | label: 'Utilities', 42 | items: [ 43 | 'utilities/delete-sync', 44 | 'utilities/plex-notifications', 45 | 'utilities/user-tagging', 46 | ], 47 | }, 48 | 'architecture', 49 | 'contributing', 50 | { 51 | type: 'category', 52 | label: 'API Reference', 53 | collapsed: false, 54 | items: apiSidebar, 55 | }, 56 | ], 57 | } 58 | 59 | export default sidebars 60 | -------------------------------------------------------------------------------- /docs/src/components/GitHubStatsButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { Github } from 'lucide-react' 3 | 4 | type GitHubStats = { 5 | stars: number 6 | } 7 | 8 | export default function GitHubStatsButton(): React.ReactElement { 9 | const [stats, setStats] = useState({ stars: 0 }) 10 | const [isLoading, setIsLoading] = useState(true) 11 | 12 | useEffect(() => { 13 | // Fetch GitHub stars 14 | fetch('https://api.github.com/repos/jamcalli/pulsarr') 15 | .then((response) => response.json()) 16 | .then((data) => { 17 | setStats({ stars: data.stargazers_count || 0 }) 18 | setIsLoading(false) 19 | }) 20 | .catch((error) => { 21 | console.error('Error fetching GitHub stats:', error) 22 | setIsLoading(false) 23 | }) 24 | }, []) 25 | 26 | return ( 27 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /docs/src/components/HomepageFeatures/styles.module.css: -------------------------------------------------------------------------------- 1 | .features { 2 | display: flex; 3 | align-items: center; 4 | padding: 2rem 0; 5 | width: 100%; 6 | } 7 | 8 | .featureSvg { 9 | height: 200px; 10 | width: 200px; 11 | } 12 | -------------------------------------------------------------------------------- /docs/src/css/docfeature.css: -------------------------------------------------------------------------------- 1 | /* DocFeature Component Styling */ 2 | 3 | /* Feature heading variants */ 4 | .feature-heading-primary { 5 | color: var(--main); 6 | } 7 | 8 | .feature-heading-danger { 9 | color: var(--error); 10 | } 11 | 12 | .feature-heading-fun { 13 | color: var(--fun); 14 | } 15 | 16 | .feature-heading-orange { 17 | color: var(--orange); 18 | } 19 | 20 | .feature-heading-blue { 21 | color: var(--blue); 22 | } 23 | 24 | /* Content layout helpers */ 25 | .feature-content { 26 | display: flex; 27 | flex-direction: column; 28 | gap: 1rem; 29 | } 30 | 31 | .feature-content h3 { 32 | font-size: 1.25rem; 33 | font-weight: 600; 34 | margin-bottom: 0.5rem; 35 | color: var(--text); 36 | } 37 | 38 | .feature-content p { 39 | margin-bottom: 0.75rem; 40 | line-height: 1.6; 41 | } 42 | 43 | .feature-grid { 44 | display: grid; 45 | grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 46 | gap: 1rem; 47 | margin: 1rem 0; 48 | } 49 | 50 | .feature-item { 51 | padding: 1rem; 52 | background-color: var(--bg); 53 | border: 2px solid var(--border); 54 | border-radius: var(--border-radius); 55 | } 56 | 57 | /* Proper dark mode styling */ 58 | html[data-theme="dark"] .feature-item { 59 | background-color: var(--bg); 60 | border-color: var(--border); 61 | } 62 | 63 | /* Media queries to adjust layout on small screens */ 64 | @media (max-width: 768px) { 65 | .feature-grid { 66 | grid-template-columns: 1fr; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /docs/src/css/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Shuttleblock"; 3 | src: url("/fonts/Shuttleblock-Medium.woff2") format("woff2"); 4 | font-weight: 400; 5 | font-style: normal; 6 | font-display: swap; 7 | } 8 | 9 | @font-face { 10 | font-family: "Shuttleblock"; 11 | src: url("/fonts/Shuttleblock-Bold.woff2") format("woff2"); 12 | font-weight: 700; 13 | font-style: normal; 14 | font-display: swap; 15 | } 16 | 17 | @font-face { 18 | font-family: "Shuttleblock"; 19 | src: url("/fonts/Shuttleblock-MediumItalic.woff2") format("woff2"); 20 | font-weight: 400; 21 | font-style: italic; 22 | font-display: swap; 23 | } 24 | -------------------------------------------------------------------------------- /docs/src/css/github-button.css: -------------------------------------------------------------------------------- 1 | /* This file is intentionally left empty as we're not using it anymore. */ 2 | -------------------------------------------------------------------------------- /docs/src/css/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --main: #48a9a6; 7 | --overlay: rgba(0, 0, 0, 0.8); 8 | --static-text: #c1666b; 9 | --error: #c1666b; 10 | --fun: #d4b483; 11 | --orange: #f6723a; 12 | --blue: #4a94b5; 13 | /* Static asteroid variables */ 14 | --static-asteroid-fill: #948d89; 15 | --static-asteroid-border: #dedede; 16 | 17 | --bg: #dfe5f2; 18 | --bw: #e4dfda; 19 | --blank: #000; 20 | --border: #000; 21 | --text: #000; 22 | --mtext: #000; 23 | --ring: #000; 24 | --ring-offset: #e4dfda; 25 | 26 | --border-radius: 5px; 27 | --box-shadow-x: 4px; 28 | --box-shadow-y: 4px; 29 | --reverse-box-shadow-x: -4px; 30 | --reverse-box-shadow-y: -4px; 31 | --base-font-weight: 500; 32 | --heading-font-weight: 700; 33 | 34 | --shadow: var(--box-shadow-x) var(--box-shadow-y) 0px 0px var(--border); 35 | 36 | --chart-1: 196 39% 33%; 37 | --chart-2: 183 37% 49%; 38 | --chart-3: 29 85% 87%; 39 | --chart-4: 19 91% 59%; 40 | --chart-5: 1 54% 50%; 41 | 42 | --color-movie: #1a5999; 43 | --color-show: #39b978; 44 | --color-count: #f47b30; 45 | } 46 | 47 | .dark { 48 | --bg: #272933; 49 | --bw: #212121; 50 | --blank: #e4dfda; 51 | --border: #000; 52 | --text: #e6e6e6; 53 | --mtext: #000; 54 | --ring: #e4dfda; 55 | --ring-offset: #000; 56 | 57 | --shadow: var(--box-shadow-x) var(--box-shadow-y) 0px 0px var(--border); 58 | } 59 | 60 | body { 61 | overflow: hidden; 62 | position: fixed; 63 | width: 100%; 64 | height: 100%; 65 | touch-action: pan-y pinch-zoom; 66 | } 67 | 68 | #app { 69 | position: fixed; 70 | width: 100%; 71 | height: 100%; 72 | overflow: hidden; 73 | } 74 | -------------------------------------------------------------------------------- /docs/src/css/mode-toggle.css: -------------------------------------------------------------------------------- 1 | /* Custom styling for mode toggle button in the navbar */ 2 | 3 | /* Target the theme toggle specifically */ 4 | html .react-toggle { 5 | margin-bottom: 0 !important; 6 | vertical-align: middle !important; 7 | align-self: center !important; 8 | } 9 | 10 | /* Adjust the vertical position of the toggle buttons */ 11 | .navbar-items--right [class*="toggleButton"] { 12 | margin: 0 !important; 13 | padding: 0 !important; 14 | display: flex !important; 15 | align-items: center !important; 16 | justify-content: center !important; 17 | } 18 | 19 | /* Fix the vertical alignment of all navbar right items */ 20 | .navbar__items--right { 21 | display: flex; 22 | align-items: center !important; 23 | } 24 | 25 | /* Remove margins from all navbar buttons */ 26 | .navbar__items--right button { 27 | margin: 0 !important; 28 | } 29 | 30 | /* Force all navbar items to be vertically centered */ 31 | .navbar__items--right > * { 32 | display: flex !important; 33 | align-items: center !important; 34 | justify-content: center !important; 35 | } 36 | -------------------------------------------------------------------------------- /docs/src/fonts.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.woff' { 2 | const src: string 3 | export default src 4 | } 5 | 6 | declare module '*.woff2' { 7 | const src: string 8 | export default src 9 | } 10 | 11 | declare module '*.ttf' { 12 | const src: string 13 | export default src 14 | } 15 | 16 | declare module '*.eot' { 17 | const src: string 18 | export default src 19 | } 20 | 21 | declare module '*.otf' { 22 | const src: string 23 | export default src 24 | } 25 | -------------------------------------------------------------------------------- /docs/src/pages/index.module.css: -------------------------------------------------------------------------------- 1 | /** 2 | * CSS files with the .module.css suffix will be treated as CSS modules 3 | * and scoped locally. 4 | */ 5 | 6 | /* Empty for now as we're using inline styles */ 7 | -------------------------------------------------------------------------------- /docs/src/pages/markdown-page.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Markdown page example 3 | --- 4 | 5 | # Markdown page example 6 | 7 | You don't need React to write simple standalone pages. 8 | -------------------------------------------------------------------------------- /docs/src/theme/.biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.5.3/schema.json", 3 | "linter": { 4 | "enabled": false 5 | }, 6 | "formatter": { 7 | "enabled": false 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /docs/src/theme/ColorModeToggle/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Moon, Sun } from 'lucide-react' 3 | import styles from './styles.module.css' 4 | 5 | type Props = { 6 | className?: string 7 | buttonClassName?: string 8 | value: string 9 | onChange: (newValue: string) => void 10 | type?: 'default' | 'noShadow' | 'neutralnoShadow' 11 | } 12 | 13 | export default function ColorModeToggle({ 14 | className, 15 | buttonClassName, 16 | value, 17 | onChange, 18 | type = 'default', 19 | }: Props): React.ReactElement { 20 | const isDarkMode = value === 'dark' 21 | 22 | const toggleColorMode = () => { 23 | onChange(isDarkMode ? 'light' : 'dark') 24 | } 25 | 26 | let buttonStyleClass = styles.buttonStyle 27 | 28 | if (type === 'noShadow') { 29 | buttonStyleClass = `${styles.buttonStyle} ${styles.noShadow}` 30 | } else if (type === 'neutralnoShadow') { 31 | buttonStyleClass = `${styles.buttonStyle} ${styles.noShadow} ${styles.neutral}` 32 | } 33 | 34 | return ( 35 |
36 | 48 |
49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /docs/src/theme/Navbar/ColorModeToggle/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { type ReactNode } from 'react' 2 | import { useThemeConfig } from '@docusaurus/theme-common' 3 | import type { Props } from '@theme/Navbar/ColorModeToggle' 4 | import { DocModeToggle } from '@site/src/components/DocModeToggle' 5 | 6 | export default function NavbarColorModeToggle({ className }: Props): ReactNode { 7 | const disabled = useThemeConfig().colorMode.disableSwitch 8 | 9 | if (disabled) { 10 | return null 11 | } 12 | 13 | return ( 14 |
15 | 16 |
17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /docs/src/theme/Navbar/ColorModeToggle/styles.module.css: -------------------------------------------------------------------------------- 1 | .darkNavbarColorModeToggle:hover { 2 | background: var(--ifm-color-gray-800); 3 | } 4 | 5 | /* Ensure vertical alignment */ 6 | div[class*="colorModeToggle"] { 7 | display: flex; 8 | align-items: center; 9 | height: 100%; 10 | margin: 0; 11 | padding: 0; 12 | } 13 | 14 | /* Remove any margins or padding */ 15 | button[class*="buttonStyle"] { 16 | margin: 0 !important; 17 | align-self: center; 18 | vertical-align: middle; 19 | } 20 | -------------------------------------------------------------------------------- /docs/src/theme/Navbar/Content/styles.module.css: -------------------------------------------------------------------------------- 1 | /* 2 | Hide color mode toggle in small viewports 3 | */ 4 | @media (max-width: 996px) { 5 | .colorModeToggle { 6 | display: none; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /docs/src/theme/Navbar/Layout/styles.module.css: -------------------------------------------------------------------------------- 1 | .navbarHideable { 2 | transition: transform var(--ifm-transition-fast) ease; 3 | } 4 | 5 | .navbarHidden { 6 | transform: translate3d(0, calc(-100% - 2px), 0); 7 | } 8 | -------------------------------------------------------------------------------- /docs/src/theme/Navbar/Logo/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { type ReactNode } from 'react' 2 | import Logo from '@theme/Logo' 3 | 4 | export default function NavbarLogo(): ReactNode { 5 | return ( 6 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /docs/src/theme/Navbar/MobileSidebar/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { type ReactNode } from 'react' 2 | import { useNavbarMobileSidebar } from '@docusaurus/theme-common/internal' 3 | import { translate } from '@docusaurus/Translate' 4 | import NavbarColorModeToggle from '@theme/Navbar/ColorModeToggle' 5 | import IconClose from '@theme/Icon/Close' 6 | import NavbarLogo from '@theme/Navbar/Logo' 7 | 8 | function CloseButton() { 9 | const mobileSidebar = useNavbarMobileSidebar() 10 | return ( 11 | 23 | ) 24 | } 25 | 26 | export default function NavbarMobileSidebarHeader(): ReactNode { 27 | return ( 28 |
29 | 30 | 31 | 32 |
33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /docs/src/theme/Navbar/MobileSidebar/Layout/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { type ReactNode } from 'react' 2 | import clsx from 'clsx' 3 | import { useNavbarSecondaryMenu } from '@docusaurus/theme-common/internal' 4 | import type { Props } from '@theme/Navbar/MobileSidebar/Layout' 5 | 6 | export default function NavbarMobileSidebarLayout({ 7 | header, 8 | primaryMenu, 9 | secondaryMenu, 10 | }: Props): ReactNode { 11 | const { shown: secondaryMenuShown } = useNavbarSecondaryMenu() 12 | return ( 13 |
14 | {header} 15 |
20 |
{primaryMenu}
21 |
{secondaryMenu}
22 |
23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /docs/src/theme/Navbar/MobileSidebar/PrimaryMenu/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { type ReactNode } from 'react' 2 | import { useThemeConfig } from '@docusaurus/theme-common' 3 | import { useNavbarMobileSidebar } from '@docusaurus/theme-common/internal' 4 | import NavbarItem, { type Props as NavbarItemConfig } from '@theme/NavbarItem' 5 | 6 | function useNavbarItems() { 7 | // TODO temporary casting until ThemeConfig type is improved 8 | return useThemeConfig().navbar.items as NavbarItemConfig[] 9 | } 10 | 11 | // The primary menu displays the navbar items 12 | export default function NavbarMobilePrimaryMenu(): ReactNode { 13 | const mobileSidebar = useNavbarMobileSidebar() 14 | 15 | // TODO how can the order be defined for mobile? 16 | // Should we allow providing a different list of items? 17 | const items = useNavbarItems() 18 | 19 | return ( 20 |
    21 | {items.map((item, i) => ( 22 | mobileSidebar.toggle()} 26 | key={i} 27 | /> 28 | ))} 29 |
30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /docs/src/theme/Navbar/MobileSidebar/SecondaryMenu/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { type ComponentProps, type ReactNode } from 'react' 2 | import { useThemeConfig } from '@docusaurus/theme-common' 3 | import { useNavbarSecondaryMenu } from '@docusaurus/theme-common/internal' 4 | import Translate from '@docusaurus/Translate' 5 | 6 | function SecondaryMenuBackButton(props: ComponentProps<'button'>) { 7 | return ( 8 | 16 | ) 17 | } 18 | 19 | // The secondary menu slides from the right and shows contextual information 20 | // such as the docs sidebar 21 | export default function NavbarMobileSidebarSecondaryMenu(): ReactNode { 22 | const isPrimaryMenuEmpty = useThemeConfig().navbar.items.length === 0 23 | const secondaryMenu = useNavbarSecondaryMenu() 24 | return ( 25 | <> 26 | {/* edge-case: prevent returning to the primaryMenu when it's empty */} 27 | {!isPrimaryMenuEmpty && ( 28 | secondaryMenu.hide()} /> 29 | )} 30 | {secondaryMenu.content} 31 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /docs/src/theme/Navbar/MobileSidebar/Toggle/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { type ReactNode } from 'react' 2 | import { useNavbarMobileSidebar } from '@docusaurus/theme-common/internal' 3 | import { translate } from '@docusaurus/Translate' 4 | import IconMenu from '@theme/Icon/Menu' 5 | 6 | export default function MobileSidebarToggle(): ReactNode { 7 | const { toggle, shown } = useNavbarMobileSidebar() 8 | return ( 9 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /docs/src/theme/Navbar/MobileSidebar/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { type ReactNode } from 'react' 2 | import { 3 | useLockBodyScroll, 4 | useNavbarMobileSidebar, 5 | } from '@docusaurus/theme-common/internal' 6 | import NavbarMobileSidebarLayout from '@theme/Navbar/MobileSidebar/Layout' 7 | import NavbarMobileSidebarHeader from '@theme/Navbar/MobileSidebar/Header' 8 | import NavbarMobileSidebarPrimaryMenu from '@theme/Navbar/MobileSidebar/PrimaryMenu' 9 | import NavbarMobileSidebarSecondaryMenu from '@theme/Navbar/MobileSidebar/SecondaryMenu' 10 | 11 | export default function NavbarMobileSidebar(): ReactNode { 12 | const mobileSidebar = useNavbarMobileSidebar() 13 | useLockBodyScroll(mobileSidebar.shown) 14 | 15 | if (!mobileSidebar.shouldRender) { 16 | return null 17 | } 18 | 19 | return ( 20 | } 22 | primaryMenu={} 23 | secondaryMenu={} 24 | /> 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /docs/src/theme/Navbar/Search/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { type ReactNode } from 'react' 2 | import clsx from 'clsx' 3 | import type { Props } from '@theme/Navbar/Search' 4 | 5 | import styles from './styles.module.css' 6 | 7 | export default function NavbarSearch({ 8 | children, 9 | className, 10 | }: Props): ReactNode { 11 | return ( 12 |
13 | {children} 14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /docs/src/theme/Navbar/Search/styles.module.css: -------------------------------------------------------------------------------- 1 | /* 2 | Workaround to avoid rendering empty search container 3 | See https://github.com/facebook/docusaurus/pull/9385 4 | */ 5 | .navbarSearchContainer:empty { 6 | display: none; 7 | } 8 | 9 | @media (max-width: 996px) { 10 | .navbarSearchContainer { 11 | position: absolute; 12 | right: var(--ifm-navbar-padding-horizontal); 13 | } 14 | } 15 | 16 | @media (min-width: 997px) { 17 | .navbarSearchContainer { 18 | padding: var(--ifm-navbar-item-padding-vertical) 19 | var(--ifm-navbar-item-padding-horizontal); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /docs/src/theme/Navbar/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { type ReactNode } from 'react' 2 | import NavbarLayout from '@theme/Navbar/Layout' 3 | import NavbarContent from '@theme/Navbar/Content' 4 | 5 | export default function Navbar(): ReactNode { 6 | return ( 7 | 8 | 9 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /docs/src/theme/NavbarItem/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import DefaultNavbarItem from '@theme/NavbarItem/DefaultNavbarItem' 3 | import DropdownNavbarItem from '@theme/NavbarItem/DropdownNavbarItem' 4 | import LocaleDropdownNavbarItem from '@theme/NavbarItem/LocaleDropdownNavbarItem' 5 | import SearchNavbarItem from '@theme/NavbarItem/SearchNavbarItem' 6 | import HtmlNavbarItem from '@theme/NavbarItem/HtmlNavbarItem' 7 | import DocNavbarItem from '@theme/NavbarItem/DocNavbarItem' 8 | import DocSidebarNavbarItem from '@theme/NavbarItem/DocSidebarNavbarItem' 9 | import DocsVersionNavbarItem from '@theme/NavbarItem/DocsVersionNavbarItem' 10 | import DocsVersionDropdownNavbarItem from '@theme/NavbarItem/DocsVersionDropdownNavbarItem' 11 | 12 | // We simply wrap the default Docusaurus component 13 | export default function NavbarItem(props) { 14 | const { type } = props 15 | 16 | const NavbarItemComponent = 17 | { 18 | default: DefaultNavbarItem, 19 | localeDropdown: LocaleDropdownNavbarItem, 20 | search: SearchNavbarItem, 21 | dropdown: DropdownNavbarItem, 22 | html: HtmlNavbarItem, 23 | doc: DocNavbarItem, 24 | docSidebar: DocSidebarNavbarItem, 25 | docsVersion: DocsVersionNavbarItem, 26 | docsVersionDropdown: DocsVersionDropdownNavbarItem, 27 | }[type] || DefaultNavbarItem 28 | 29 | return 30 | } 31 | -------------------------------------------------------------------------------- /docs/src/theme/NavbarItem/styles.module.css: -------------------------------------------------------------------------------- 1 | /* This file intentionally left empty */ 2 | -------------------------------------------------------------------------------- /docs/src/theme/font-loader.css: -------------------------------------------------------------------------------- 1 | /* Font loader CSS - loaded at the theme level */ 2 | @font-face { 3 | font-family: "Shuttleblock"; 4 | src: url("/fonts/Shuttleblock-Medium.woff2") format("woff2"); 5 | font-weight: 400; 6 | font-style: normal; 7 | font-display: swap; 8 | } 9 | 10 | @font-face { 11 | font-family: "Shuttleblock"; 12 | src: url("/fonts/Shuttleblock-Bold.woff2") format("woff2"); 13 | font-weight: 700; 14 | font-style: normal; 15 | font-display: swap; 16 | } 17 | 18 | @font-face { 19 | font-family: "Shuttleblock"; 20 | src: url("/fonts/Shuttleblock-MediumItalic.woff2") format("woff2"); 21 | font-weight: 400; 22 | font-style: italic; 23 | font-display: swap; 24 | } 25 | -------------------------------------------------------------------------------- /docs/src/types/images.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.webp' { 2 | const src: string 3 | export default src 4 | } 5 | 6 | declare module '*.svg' { 7 | const src: string 8 | export default src 9 | } 10 | 11 | declare module '*.png' { 12 | const src: string 13 | export default src 14 | } 15 | -------------------------------------------------------------------------------- /docs/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/docs/static/.nojekyll -------------------------------------------------------------------------------- /docs/static/fonts/Shuttleblock-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/docs/static/fonts/Shuttleblock-Bold.woff2 -------------------------------------------------------------------------------- /docs/static/fonts/Shuttleblock-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/docs/static/fonts/Shuttleblock-Medium.woff2 -------------------------------------------------------------------------------- /docs/static/fonts/Shuttleblock-MediumItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/docs/static/fonts/Shuttleblock-MediumItalic.woff2 -------------------------------------------------------------------------------- /docs/static/gifs/Import.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/docs/static/gifs/Import.gif -------------------------------------------------------------------------------- /docs/static/gifs/Plex-Grab.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/docs/static/gifs/Plex-Grab.gif -------------------------------------------------------------------------------- /docs/static/img/Content-Route-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/docs/static/img/Content-Route-1.png -------------------------------------------------------------------------------- /docs/static/img/Content-Route-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/docs/static/img/Content-Route-2.png -------------------------------------------------------------------------------- /docs/static/img/DM-New-Epp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/docs/static/img/DM-New-Epp.png -------------------------------------------------------------------------------- /docs/static/img/DM-Season.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/docs/static/img/DM-Season.png -------------------------------------------------------------------------------- /docs/static/img/Dashboard1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/docs/static/img/Dashboard1.png -------------------------------------------------------------------------------- /docs/static/img/Delete-Sync-Dry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/docs/static/img/Delete-Sync-Dry.png -------------------------------------------------------------------------------- /docs/static/img/Delete-Sync-Error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/docs/static/img/Delete-Sync-Error.png -------------------------------------------------------------------------------- /docs/static/img/Discord-Edit-Modal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/docs/static/img/Discord-Edit-Modal.png -------------------------------------------------------------------------------- /docs/static/img/Discord-Notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/docs/static/img/Discord-Notification.png -------------------------------------------------------------------------------- /docs/static/img/Discord-Settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/docs/static/img/Discord-Settings.png -------------------------------------------------------------------------------- /docs/static/img/Discord-Signup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/docs/static/img/Discord-Signup.png -------------------------------------------------------------------------------- /docs/static/img/Plex-Notifications.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/docs/static/img/Plex-Notifications.png -------------------------------------------------------------------------------- /docs/static/img/User-Tags.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/docs/static/img/User-Tags.png -------------------------------------------------------------------------------- /docs/static/img/Webhook-grab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/docs/static/img/Webhook-grab.png -------------------------------------------------------------------------------- /docs/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/docs/static/img/favicon.ico -------------------------------------------------------------------------------- /docs/static/img/planet-m.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/docs/static/img/planet-m.webp -------------------------------------------------------------------------------- /docs/static/img/planet.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/docs/static/img/planet.webp -------------------------------------------------------------------------------- /docs/static/img/pulsarr-lg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/docs/static/img/pulsarr-lg.png -------------------------------------------------------------------------------- /docs/static/img/pulsarr-social-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/docs/static/img/pulsarr-social-card.png -------------------------------------------------------------------------------- /docs/static/img/ship.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/docs/static/img/ship.webp -------------------------------------------------------------------------------- /docs/static/planet.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/docs/static/planet.webp -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // This file is not used in compilation. It is here just for a nice editor experience. 3 | "extends": "@docusaurus/tsconfig", 4 | "compilerOptions": { 5 | "baseUrl": ".", 6 | "paths": { 7 | "@site/*": ["*"], 8 | "@theme/*": [ 9 | "src/theme/*", 10 | "node_modules/@docusaurus/theme-classic/lib/theme/*" 11 | ], 12 | "@docusaurus/*": ["node_modules/@docusaurus/core/lib/client/exports/*"], 13 | "@/client/*": ["../src/client/*"], 14 | "@/hooks/*": ["../src/client/hooks/*"], 15 | "@/components/*": ["../src/client/components/*"], 16 | "@/features/*": ["../src/client/features/*"], 17 | "@/lib/*": ["../src/client/lib/*"], 18 | "@root/*": ["../src/*"], 19 | "@/*": ["../src/*"] 20 | }, 21 | "jsx": "react-jsx", 22 | "target": "ES2015", 23 | "module": "ESNext", 24 | "moduleResolution": "bundler", 25 | "strict": false, 26 | "esModuleInterop": true, 27 | "skipLibCheck": true, 28 | "forceConsistentCasingInFileNames": true, 29 | "resolveJsonModule": true, 30 | "isolatedModules": true, 31 | "noEmit": true, 32 | "allowJs": true, 33 | "types": ["@docusaurus/theme-classic", "react", "react-dom"] 34 | }, 35 | "include": ["src/**/*"], 36 | "exclude": [".docusaurus", "build", "node_modules"] 37 | } 38 | -------------------------------------------------------------------------------- /migrations/knexfile.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex' 2 | import { fileURLToPath } from 'url' 3 | import { dirname, resolve } from 'path' 4 | import fs from 'node:fs' 5 | 6 | const __filename = fileURLToPath(import.meta.url) 7 | const __dirname = dirname(__filename) 8 | const projectRoot = resolve(__dirname, '..') 9 | 10 | function ensureDbDirectory() { 11 | const dbDirectory = resolve(projectRoot, 'data', 'db') 12 | try { 13 | if (!fs.existsSync(dbDirectory)) { 14 | fs.mkdirSync(dbDirectory, { recursive: true }) 15 | } 16 | return dbDirectory 17 | } catch (err) { 18 | console.error('Failed to create database directory:', err) 19 | process.exit(1) 20 | } 21 | } 22 | 23 | const config: { [key: string]: Knex.Config } = { 24 | development: { 25 | client: 'better-sqlite3', 26 | connection: { 27 | filename: resolve(ensureDbDirectory(), 'pulsarr.db') 28 | }, 29 | useNullAsDefault: true, 30 | migrations: { 31 | directory: resolve(__dirname, 'migrations') 32 | }, 33 | pool: { 34 | afterCreate: (conn: any, cb: any) => { 35 | conn.exec('PRAGMA journal_mode = WAL;') 36 | conn.exec('PRAGMA foreign_keys = ON;') 37 | cb() 38 | } 39 | } 40 | } 41 | } 42 | 43 | export default config -------------------------------------------------------------------------------- /migrations/migrate.ts: -------------------------------------------------------------------------------- 1 | import knex from 'knex' 2 | import config from './knexfile.js' 3 | 4 | async function migrate() { 5 | const db = knex(config.development) 6 | 7 | try { 8 | await db.migrate.latest() 9 | console.log('Migrations completed successfully') 10 | } catch (err) { 11 | console.error('Error running migrations:', err) 12 | } finally { 13 | await db.destroy() 14 | } 15 | } 16 | 17 | migrate() -------------------------------------------------------------------------------- /migrations/migrations/002_20250302_add_status_history.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex' 2 | 3 | export async function up(knex: Knex): Promise { 4 | await knex.schema.createTable('watchlist_status_history', (table) => { 5 | table.increments('id').primary() 6 | table.integer('watchlist_item_id') 7 | .notNullable() 8 | .references('id') 9 | .inTable('watchlist_items') 10 | .onDelete('CASCADE') 11 | table.enum('status', ['pending', 'requested', 'grabbed', 'notified']) 12 | .notNullable() 13 | table.timestamp('timestamp').defaultTo(knex.fn.now()) 14 | table.index(['watchlist_item_id', 'status']) 15 | table.index('timestamp') 16 | }) 17 | } 18 | 19 | export async function down(knex: Knex): Promise { 20 | await knex.schema.dropTable('watchlist_status_history') 21 | } -------------------------------------------------------------------------------- /migrations/migrations/003_20250303_add_notifications_history.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex'; 2 | 3 | export async function up(knex: Knex): Promise { 4 | await knex.schema.createTable('notifications', (table) => { 5 | table.increments('id').primary(); 6 | table.integer('watchlist_item_id') 7 | .nullable() 8 | .references('id') 9 | .inTable('watchlist_items') 10 | .onDelete('CASCADE'); 11 | table.integer('user_id') 12 | .nullable() 13 | .references('id') 14 | .inTable('users') 15 | .onDelete('CASCADE'); 16 | table.enum('type', ['episode', 'season', 'movie', 'watchlist_add']).notNullable(); 17 | table.string('title').notNullable(); 18 | table.string('message').nullable(); 19 | table.integer('season_number').nullable(); 20 | table.integer('episode_number').nullable(); 21 | table.boolean('sent_to_discord').defaultTo(false); 22 | table.boolean('sent_to_email').defaultTo(false); 23 | table.boolean('sent_to_webhook').defaultTo(false); 24 | table.timestamp('created_at').defaultTo(knex.fn.now()); 25 | 26 | // Indexes 27 | table.index(['watchlist_item_id']); 28 | table.index(['user_id']); 29 | table.index(['created_at']); 30 | table.index(['type']); 31 | }); 32 | } 33 | 34 | export async function down(knex: Knex): Promise { 35 | await knex.schema.dropTable('notifications'); 36 | } -------------------------------------------------------------------------------- /migrations/migrations/005_20250310_add_notification_tracking.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex' 2 | 3 | export async function up(knex: Knex): Promise { 4 | await knex.schema.alterTable('notifications', (table) => { 5 | table.string('notification_status').defaultTo('active') 6 | }) 7 | 8 | await knex('notifications') 9 | .whereNull('notification_status') 10 | .update({ notification_status: 'active' }) 11 | 12 | await knex.schema.alterTable('notifications', (table) => { 13 | table.index(['watchlist_item_id', 'type', 'notification_status'], 'idx_notifications_status') 14 | }) 15 | } 16 | 17 | export async function down(knex: Knex): Promise { 18 | await knex.schema.alterTable('notifications', (table) => { 19 | table.dropIndex(['watchlist_item_id', 'type', 'notification_status'], 'idx_notifications_status') 20 | table.dropColumn('notification_status') 21 | }) 22 | } -------------------------------------------------------------------------------- /migrations/migrations/006_20250311_add_sync_status.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex' 2 | 3 | export async function up(knex: Knex): Promise { 4 | await knex.schema.alterTable('watchlist_radarr_instances', (table) => { 5 | table.boolean('syncing').defaultTo(false).notNullable() 6 | table.index('syncing') 7 | }) 8 | 9 | await knex.schema.alterTable('watchlist_sonarr_instances', (table) => { 10 | table.boolean('syncing').defaultTo(false).notNullable() 11 | table.index('syncing') 12 | }) 13 | } 14 | 15 | export async function down(knex: Knex): Promise { 16 | await knex.schema.alterTable('watchlist_radarr_instances', (table) => { 17 | table.dropColumn('syncing') 18 | }) 19 | 20 | await knex.schema.alterTable('watchlist_sonarr_instances', (table) => { 21 | table.dropColumn('syncing') 22 | }) 23 | } -------------------------------------------------------------------------------- /migrations/migrations/008_20250320_add_schedules.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex' 2 | 3 | export async function up(knex: Knex): Promise { 4 | await knex.schema.createTable('schedules', (table) => { 5 | table.increments('id').primary() 6 | table.string('name').notNullable().unique() // Unique name for the scheduled job 7 | table.string('type').notNullable() // 'interval' or 'cron' 8 | table.json('config').notNullable() // Configuration for the schedule 9 | table.boolean('enabled').defaultTo(true) 10 | table.json('last_run').nullable() // Info about last execution 11 | table.json('next_run').nullable() // Expected next run time 12 | table.timestamp('created_at').defaultTo(knex.fn.now()) 13 | table.timestamp('updated_at').defaultTo(knex.fn.now()) 14 | 15 | table.index('name') 16 | table.index('enabled') 17 | }) 18 | 19 | // Add default delete-sync schedule 20 | await knex('schedules').insert([ 21 | { 22 | name: 'delete-sync', 23 | type: 'cron', 24 | config: JSON.stringify({ 25 | expression: '0 1 * * 0' // Every Sunday at 1:00 AM 26 | }), 27 | enabled: false, 28 | created_at: knex.fn.now(), 29 | updated_at: knex.fn.now() 30 | } 31 | ]) 32 | } 33 | 34 | export async function down(knex: Knex): Promise { 35 | await knex.schema.dropTable('schedules') 36 | } -------------------------------------------------------------------------------- /migrations/migrations/009_20250323_add_respect_user_sync_setting.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex' 2 | 3 | export async function up(knex: Knex): Promise { 4 | await knex.schema.alterTable('configs', (table) => { 5 | table.boolean('respectUserSyncSetting').defaultTo(true) 6 | table.dropColumn('deleteIntervalDays') 7 | }) 8 | 9 | await knex('configs') 10 | .whereNull('respectUserSyncSetting') 11 | .update({ respectUserSyncSetting: true }) 12 | } 13 | 14 | export async function down(knex: Knex): Promise { 15 | await knex.schema.alterTable('configs', (table) => { 16 | table.dropColumn('respectUserSyncSetting') 17 | table.integer('deleteIntervalDays') 18 | }) 19 | } -------------------------------------------------------------------------------- /migrations/migrations/010_20250325_add_delete_sync_notifications.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex' 2 | 3 | export async function up(knex: Knex): Promise { 4 | await knex.schema.alterTable('configs', (table) => { 5 | table.string('deleteSyncNotify').defaultTo('none') 6 | table.integer('maxDeletionPrevention').defaultTo(10) 7 | }) 8 | 9 | await knex('configs') 10 | .whereNull('deleteSyncNotify') 11 | .update({ deleteSyncNotify: 'none' }) 12 | 13 | await knex('configs') 14 | .whereNull('maxDeletionPrevention') 15 | .update({ maxDeletionPrevention: 10 }) 16 | } 17 | 18 | export async function down(knex: Knex): Promise { 19 | await knex.schema.alterTable('configs', (table) => { 20 | table.dropColumn('deleteSyncNotify') 21 | table.dropColumn('maxDeletionPrevention') 22 | }) 23 | } -------------------------------------------------------------------------------- /migrations/migrations/014_20250425_add_monitor_new_items.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex' 2 | 3 | /** 4 | * Adds the `monitor_new_items` column to the `sonarr_instances` table with a default value of `'all'`. 5 | * 6 | * @remark 7 | * Also updates existing rows where `monitor_new_items` is `NULL` to `'all'` to ensure consistency. 8 | */ 9 | export async function up(knex: Knex): Promise { 10 | await knex.schema.alterTable('sonarr_instances', (table) => { 11 | // Add the monitor_new_items column with a default value of 'all' 12 | table.string('monitor_new_items').defaultTo('all') 13 | }) 14 | 15 | // Set default values for existing rows that don't have the field 16 | await knex('sonarr_instances') 17 | .whereNull('monitor_new_items') 18 | .update({ monitor_new_items: 'all' }) 19 | } 20 | 21 | /** 22 | * Reverts the migration by removing the `monitor_new_items` column from the `sonarr_instances` table. 23 | */ 24 | export async function down(knex: Knex): Promise { 25 | await knex.schema.alterTable('sonarr_instances', (table) => { 26 | table.dropColumn('monitor_new_items') 27 | }) 28 | } -------------------------------------------------------------------------------- /migrations/migrations/015-20250427_add_user_tagging.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex' 2 | 3 | /** 4 | * Adds user tagging configuration columns for Sonarr and Radarr to the `configs` table. 5 | * 6 | * Adds the `tagUsersInSonarr` and `tagUsersInRadarr` boolean columns with a default value of `false`, and updates any existing rows with `NULL` values in these columns to `false`. 7 | */ 8 | export async function up(knex: Knex): Promise { 9 | await knex.schema.alterTable('configs', (table) => { 10 | // Control which services get user tags 11 | table.boolean('tagUsersInSonarr').defaultTo(false) 12 | table.boolean('tagUsersInRadarr').defaultTo(false) 13 | }) 14 | 15 | // Set default values for existing configs row 16 | 17 | await knex('configs') 18 | .whereNull('tagUsersInSonarr') 19 | .update({ tagUsersInSonarr: false }) 20 | 21 | await knex('configs') 22 | .whereNull('tagUsersInRadarr') 23 | .update({ tagUsersInRadarr: false }) 24 | } 25 | 26 | /** 27 | * Reverts the migration by removing user tagging configuration columns from the `configs` table. 28 | * 29 | * Drops the `tagUsersInSonarr` and `tagUsersInRadarr` columns to undo the changes made in the corresponding migration. 30 | */ 31 | export async function down(knex: Knex): Promise { 32 | await knex.schema.alterTable('configs', (table) => { 33 | table.dropColumn('tagUsersInSonarr') 34 | table.dropColumn('tagUsersInRadarr') 35 | }) 36 | } -------------------------------------------------------------------------------- /migrations/migrations/017-20250428_extend_user_tagging.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex' 2 | 3 | /** 4 | * Adds user tagging configuration columns to the `configs` table. 5 | * 6 | * Introduces the `cleanupOrphanedTags`, `persistHistoricalTags`, and `tagPrefix` columns with default values, and updates existing rows to ensure these columns are not null. 7 | */ 8 | export async function up(knex: Knex): Promise { 9 | await knex.schema.alterTable('configs', (table) => { 10 | // Add extended tag management configuration columns 11 | table.boolean('cleanupOrphanedTags').defaultTo(true) 12 | table.boolean('persistHistoricalTags').defaultTo(false) 13 | table.string('tagPrefix').defaultTo('pulsarr:user') 14 | }) 15 | 16 | // Set default values for existing configs row 17 | await knex('configs') 18 | .whereNull('cleanupOrphanedTags') 19 | .update({ cleanupOrphanedTags: true }) 20 | 21 | await knex('configs') 22 | .whereNull('persistHistoricalTags') 23 | .update({ persistHistoricalTags: false }) 24 | 25 | await knex('configs') 26 | .whereNull('tagPrefix') 27 | .update({ tagPrefix: 'pulsarr:user' }) 28 | } 29 | 30 | /** 31 | * Removes the user tagging configuration columns from the `configs` table. 32 | * 33 | * Drops the `cleanupOrphanedTags`, `persistHistoricalTags`, and `tagPrefix` columns to revert the migration. 34 | */ 35 | export async function down(knex: Knex): Promise { 36 | await knex.schema.alterTable('configs', (table) => { 37 | table.dropColumn('cleanupOrphanedTags') 38 | table.dropColumn('persistHistoricalTags') 39 | table.dropColumn('tagPrefix') 40 | }) 41 | } -------------------------------------------------------------------------------- /migrations/migrations/019_20250505_add_route_tags.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Migration to add tags support to content router routes 3 | */ 4 | import type { Knex } from 'knex' 5 | 6 | /** 7 | * Adds a "tags" JSON column to the "router_rules" table with a default value of an empty array. 8 | * 9 | * The new "tags" column allows each router rule to store an array of associated tags. 10 | */ 11 | export async function up(knex: Knex): Promise { 12 | await knex.schema.alterTable('router_rules', (table) => { 13 | // Adding a JSON column to store an array of tags 14 | table.json('tags').defaultTo('[]') 15 | }) 16 | } 17 | 18 | /** 19 | * Drops the "tags" column from the "router_rules" table, reverting the schema to its previous state. 20 | */ 21 | export async function down(knex: Knex): Promise { 22 | await knex.schema.alterTable('router_rules', (table) => { 23 | table.dropColumn('tags') 24 | }) 25 | } -------------------------------------------------------------------------------- /migrations/migrations/020_20250506_add_minimum_availability.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex' 2 | 3 | /** 4 | * Adds the `minimum_availability` column to the `radarr_instances` table with a default value of 'released'. 5 | * 6 | * @remarks 7 | * This migration introduces a configuration option for Radarr instances to specify when movies are considered available. Possible values include 'announced', 'inCinemas', or 'released'. 8 | */ 9 | export async function up(knex: Knex): Promise { 10 | // Add minimum_availability to radarr_instances 11 | await knex.schema.alterTable('radarr_instances', (table) => { 12 | // Add the minimum_availability column with a default value of 'released' 13 | table.string('minimum_availability').defaultTo('released') 14 | }) 15 | 16 | // Set default values for existing rows that don't have the field 17 | await knex('radarr_instances') 18 | .whereNull('minimum_availability') 19 | .update({ minimum_availability: 'released' }) 20 | } 21 | 22 | /** 23 | * Removes the `minimum_availability` column from the `radarr_instances` table. 24 | * 25 | * Reverses the migration applied in the `up` function. 26 | */ 27 | export async function down(knex: Knex): Promise { 28 | await knex.schema.alterTable('radarr_instances', (table) => { 29 | table.dropColumn('minimum_availability') 30 | }) 31 | } -------------------------------------------------------------------------------- /migrations/migrations/021_20250507_add_router_search_and_monitoring.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex' 2 | 3 | /** 4 | * Alters the `router_rules` table by adding `search_on_add` (nullable boolean) and `season_monitoring` (nullable string) columns. 5 | * 6 | * The `search_on_add` column is intended to control automatic search behavior for Radarr and Sonarr routes, while `season_monitoring` specifies season monitoring preferences for Sonarr routes. 7 | */ 8 | export async function up(knex: Knex): Promise { 9 | await knex.schema.alterTable('router_rules', (table) => { 10 | // Add search_on_add column (nullable boolean) 11 | table.boolean('search_on_add').nullable() 12 | 13 | // Add season_monitoring column (nullable string) for Sonarr routes 14 | table.string('season_monitoring').nullable() 15 | }) 16 | } 17 | 18 | /** 19 | * Removes the 'search_on_add' and 'season_monitoring' columns from the 'router_rules' table to revert the migration. 20 | */ 21 | export async function down(knex: Knex): Promise { 22 | await knex.schema.alterTable('router_rules', (table) => { 23 | table.dropColumn('search_on_add') 24 | table.dropColumn('season_monitoring') 25 | }) 26 | } -------------------------------------------------------------------------------- /migrations/migrations/022_20250508_add_plex_playlist_protection.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex' 2 | 3 | /** 4 | * Adds Plex playlist protection configuration columns to the configs table. 5 | * 6 | * Alters the configs table by adding three columns: enablePlexPlaylistProtection (boolean, default false), plexProtectionPlaylistName (string, default "Do Not Delete"), and plexServerUrl (string, default "http://localhost:32400"). 7 | * 8 | * @remark The plexServerUrl column is optional, as the system can auto-detect the Plex server URL, but this setting allows manual configuration for custom environments. 9 | */ 10 | export async function up(knex: Knex): Promise { 11 | await knex.schema.alterTable('configs', (table) => { 12 | table.boolean('enablePlexPlaylistProtection').defaultTo(false) 13 | table.string('plexProtectionPlaylistName').defaultTo('Do Not Delete') 14 | table.string('plexServerUrl').defaultTo('http://localhost:32400') 15 | }) 16 | } 17 | 18 | /** 19 | * Reverts the schema changes by removing Plex playlist protection configuration columns from the `configs` table. 20 | * 21 | * Drops the `enablePlexPlaylistProtection`, `plexProtectionPlaylistName`, and `plexServerUrl` columns. 22 | */ 23 | export async function down(knex: Knex): Promise { 24 | await knex.schema.alterTable('configs', (table) => { 25 | table.dropColumn('enablePlexPlaylistProtection') 26 | table.dropColumn('plexProtectionPlaylistName') 27 | table.dropColumn('plexServerUrl') 28 | }) 29 | } -------------------------------------------------------------------------------- /migrations/migrations/025_20250514_add_series_type_to_sonarr.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex' 2 | 3 | /** 4 | * Adds a `series_type` column to the `sonarr_instances` and `router_rules` tables. 5 | * 6 | * @remarks 7 | * In `sonarr_instances`, the `series_type` column is a non-nullable string with a default value of `'standard'`. In `router_rules`, the column is nullable to support per-rule overrides. 8 | */ 9 | export async function up(knex: Knex): Promise { 10 | // Add series_type column to sonarr_instances table 11 | await knex.schema.alterTable('sonarr_instances', (table) => { 12 | table.string('series_type').defaultTo('standard') 13 | }) 14 | 15 | // Add series_type column to router_rules table for overrides 16 | await knex.schema.alterTable('router_rules', (table) => { 17 | table.string('series_type').nullable() 18 | }) 19 | } 20 | 21 | /** 22 | * Removes the `series_type` column from the `sonarr_instances` and `router_rules` tables. 23 | * 24 | * This function reverses the schema changes introduced by the corresponding migration, restoring the tables to their previous structure. 25 | */ 26 | export async function down(knex: Knex): Promise { 27 | await knex.schema.alterTable('sonarr_instances', (table) => { 28 | table.dropColumn('series_type') 29 | }) 30 | 31 | await knex.schema.alterTable('router_rules', (table) => { 32 | table.dropColumn('series_type') 33 | }) 34 | } -------------------------------------------------------------------------------- /migrations/migrations/029_20250528_add_delete_sync_notify_only_on_deletion.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex' 2 | 3 | /** 4 | * Adds the `deleteSyncNotifyOnlyOnDeletion` boolean column to the `configs` table with a default value of `false`. 5 | * 6 | * @param knex - The Knex schema builder instance. 7 | */ 8 | export async function up(knex: Knex): Promise { 9 | // Add deleteSyncNotifyOnlyOnDeletion to configs table 10 | await knex.schema.alterTable('configs', (table) => { 11 | table.boolean('deleteSyncNotifyOnlyOnDeletion').defaultTo(false) 12 | }) 13 | } 14 | 15 | /** 16 | * Reverts the migration by removing the `deleteSyncNotifyOnlyOnDeletion` column from the `configs` table. 17 | */ 18 | export async function down(knex: Knex): Promise { 19 | await knex.schema.alterTable('configs', (table) => { 20 | table.dropColumn('deleteSyncNotifyOnlyOnDeletion') 21 | }) 22 | } -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | } 7 | 8 | export default config 9 | -------------------------------------------------------------------------------- /scripts/build-docs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echo "🚀 Starting documentation build process..." 5 | 6 | # Store original directory 7 | ORIGINAL_DIR=$(pwd) 8 | 9 | # Step 1: Generate OpenAPI spec 10 | echo "📄 Generating OpenAPI spec..." 11 | npm run openapi:generate 12 | 13 | # Step 2: Generate Docusaurus OpenAPI docs 14 | echo "📚 Generating Docusaurus OpenAPI documentation..." 15 | cd docs || { echo "Failed to change to docs directory"; exit 1; } 16 | npx docusaurus gen-api-docs pulsarr 17 | cd "$ORIGINAL_DIR" || { echo "Failed to return to original directory"; exit 1; } 18 | 19 | # Step 3: Format all files with Biome 20 | echo "🎨 Formatting files with Biome..." 21 | npm run fix 22 | 23 | # Step 4: Build Docusaurus 24 | echo "🏗️ Building Docusaurus..." 25 | cd docs || { echo "Failed to change to docs directory"; exit 1; } 26 | npm run build 27 | cd "$ORIGINAL_DIR" || { echo "Failed to return to original directory"; exit 1; } 28 | 29 | echo "✅ Documentation build complete!" -------------------------------------------------------------------------------- /src/client/assets/fonts/Shuttleblock-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/src/client/assets/fonts/Shuttleblock-Bold.woff2 -------------------------------------------------------------------------------- /src/client/assets/fonts/Shuttleblock-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/src/client/assets/fonts/Shuttleblock-Medium.woff2 -------------------------------------------------------------------------------- /src/client/assets/fonts/Shuttleblock-MediumItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/src/client/assets/fonts/Shuttleblock-MediumItalic.woff2 -------------------------------------------------------------------------------- /src/client/assets/images/planet-m.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/src/client/assets/images/planet-m.webp -------------------------------------------------------------------------------- /src/client/assets/images/planet.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/src/client/assets/images/planet.webp -------------------------------------------------------------------------------- /src/client/assets/images/pulsarr-lg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/src/client/assets/images/pulsarr-lg.png -------------------------------------------------------------------------------- /src/client/assets/images/pulsarr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamcalli/Pulsarr/00c834b0a63ae202b113b115350d4590882c7ef6/src/client/assets/images/pulsarr.png -------------------------------------------------------------------------------- /src/client/components/ui/apprise-status-badge.tsx: -------------------------------------------------------------------------------- 1 | import { useConfigStore } from '@/stores/configStore' 2 | import { Badge } from '@/components/ui/badge' 3 | import { cn } from '@/lib/utils' 4 | 5 | export function AppriseStatusBadge() { 6 | const config = useConfigStore(state => state.config) 7 | 8 | // Get status directly from config 9 | const isEnabled = config?.enableApprise || false 10 | const status = isEnabled ? 'enabled' : 'disabled' 11 | 12 | const getBadgeVariant = () => { 13 | if (isEnabled) { 14 | return 'bg-green-500 hover:bg-green-500 text-white' 15 | } else { 16 | return 'bg-red-500 hover:bg-red-500 text-white' 17 | } 18 | } 19 | 20 | return ( 21 |
22 | 26 | {status.charAt(0).toUpperCase() + status.slice(1)} 27 | 28 |
29 | ) 30 | } -------------------------------------------------------------------------------- /src/client/components/ui/aspect-ratio.tsx: -------------------------------------------------------------------------------- 1 | import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" 2 | 3 | const AspectRatio = AspectRatioPrimitive.Root 4 | 5 | export { AspectRatio } 6 | -------------------------------------------------------------------------------- /src/client/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import { cva, type VariantProps } from "class-variance-authority" 2 | 3 | import type * as React from "react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const badgeVariants = cva( 8 | "inline-flex items-center rounded-base border-2 border-border px-2.5 font-base py-0.5 text-xs transition-colors focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-main text-mtext", 13 | neutral: "bg-bw text-text", 14 | warn: "bg-fun text-mtext", 15 | }, 16 | }, 17 | defaultVariants: { 18 | variant: "default", 19 | }, 20 | }, 21 | ) 22 | 23 | export interface BadgeProps 24 | extends React.HTMLAttributes, 25 | VariantProps {} 26 | 27 | function Badge({ className, variant, ...props }: BadgeProps) { 28 | return ( 29 |
30 | ) 31 | } 32 | 33 | export { Badge, badgeVariants } 34 | -------------------------------------------------------------------------------- /src/client/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 2 | import { Check } from "lucide-react" 3 | 4 | import * as React from "react" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Checkbox = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 21 | 24 | 25 | 26 | 27 | )) 28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 29 | 30 | export { Checkbox } 31 | -------------------------------------------------------------------------------- /src/client/components/ui/genre-multi-select.tsx: -------------------------------------------------------------------------------- 1 | import type { ControllerRenderProps } from 'react-hook-form' 2 | import { MultiSelect } from '@/components/ui/multi-select' 3 | 4 | interface GenreMultiSelectProps { 5 | field: ControllerRenderProps 6 | genres: string[] 7 | onDropdownOpen?: () => Promise 8 | } 9 | 10 | const GenreMultiSelect = ({ 11 | field, 12 | genres, 13 | onDropdownOpen, 14 | }: GenreMultiSelectProps) => { 15 | const options = genres.map(genre => ({ 16 | label: genre, 17 | value: genre, 18 | })) 19 | 20 | return ( 21 | { 24 | field.onChange(values.length === 1 ? values[0] : values) 25 | }} 26 | defaultValue={Array.isArray(field.value) ? field.value : field.value ? [field.value] : []} 27 | placeholder="Select genre(s)" 28 | modalPopover={true} 29 | maxCount={2} 30 | onDropdownOpen={onDropdownOpen} 31 | /> 32 | ) 33 | } 34 | 35 | export default GenreMultiSelect 36 | -------------------------------------------------------------------------------- /src/client/components/ui/hover-card.tsx: -------------------------------------------------------------------------------- 1 | import * as HoverCardPrimitive from "@radix-ui/react-hover-card" 2 | 3 | import * as React from "react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const HoverCard = HoverCardPrimitive.Root 8 | 9 | const HoverCardTrigger = HoverCardPrimitive.Trigger 10 | 11 | const HoverCardContent = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 15 | 25 | )) 26 | HoverCardContent.displayName = HoverCardPrimitive.Content.displayName 27 | 28 | export { HoverCard, HoverCardTrigger, HoverCardContent } 29 | -------------------------------------------------------------------------------- /src/client/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { cn } from '@/lib/utils' 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | }, 22 | ) 23 | Input.displayName = 'Input' 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /src/client/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as LabelPrimitive from '@radix-ui/react-label' 4 | import { cva, type VariantProps } from 'class-variance-authority' 5 | 6 | import * as React from 'react' 7 | 8 | import { cn } from '@/lib/utils' 9 | 10 | const labelVariants = cva( 11 | 'text-sm font-heading leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', 12 | ) 13 | 14 | const Label = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef & 17 | VariantProps 18 | >(({ className, ...props }, ref) => ( 19 | 24 | )) 25 | Label.displayName = LabelPrimitive.Root.displayName 26 | 27 | export { Label } 28 | -------------------------------------------------------------------------------- /src/client/components/ui/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | import { Moon, Sun } from 'lucide-react' 2 | import { useTheme } from '@/components/theme-provider' 3 | import { Button } from '@/components/ui/button' 4 | 5 | export function ModeToggle() { 6 | const { theme, setTheme } = useTheme() 7 | 8 | const toggleTheme = () => { 9 | const newTheme = theme === 'dark' ? 'light' : 'dark' 10 | setTheme(newTheme) 11 | } 12 | 13 | if (!theme) return null 14 | 15 | return ( 16 | 35 | ) 36 | } -------------------------------------------------------------------------------- /src/client/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | import * as PopoverPrimitive from "@radix-ui/react-popover" 2 | 3 | import * as React from "react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Popover = PopoverPrimitive.Root 8 | 9 | const PopoverTrigger = PopoverPrimitive.Trigger 10 | 11 | const PopoverContent = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 15 | 16 | 26 | 27 | )) 28 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 29 | 30 | export { Popover, PopoverTrigger, PopoverContent } 31 | -------------------------------------------------------------------------------- /src/client/components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as ProgressPrimitive from '@radix-ui/react-progress' 4 | 5 | import * as React from 'react' 6 | 7 | import { cn } from '@/lib/utils' 8 | 9 | const Progress = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, value, ...props }, ref) => ( 13 | 21 | 25 | 26 | )) 27 | Progress.displayName = ProgressPrimitive.Root.displayName 28 | 29 | export { Progress } -------------------------------------------------------------------------------- /src/client/components/ui/radio-group.tsx: -------------------------------------------------------------------------------- 1 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" 2 | import { Circle } from "lucide-react" 3 | 4 | import * as React from "react" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const RadioGroup = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => { 12 | return ( 13 | 18 | ) 19 | }) 20 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName 21 | 22 | const RadioGroupItem = React.forwardRef< 23 | React.ElementRef, 24 | React.ComponentPropsWithoutRef 25 | >(({ className, ...props }, ref) => { 26 | return ( 27 | 35 | 36 | 37 | 38 | 39 | ) 40 | }) 41 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName 42 | 43 | export { RadioGroup, RadioGroupItem } 44 | -------------------------------------------------------------------------------- /src/client/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Separator = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >( 10 | ( 11 | { className, orientation = "horizontal", decorative = true, ...props }, 12 | ref 13 | ) => ( 14 | 25 | ) 26 | ) 27 | Separator.displayName = SeparatorPrimitive.Root.displayName 28 | 29 | export { Separator } 30 | -------------------------------------------------------------------------------- /src/client/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/lib/utils' 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
15 | ) 16 | } 17 | 18 | export { Skeleton } -------------------------------------------------------------------------------- /src/client/components/ui/tautulli-status-badge.tsx: -------------------------------------------------------------------------------- 1 | import { useTautulliStatus } from '@/hooks/notifications/useTautulliStatus' 2 | import { Badge } from '@/components/ui/badge' 3 | import { cn } from '@/lib/utils' 4 | 5 | /** 6 | * Displays a status badge indicating the current Tautulli service state. 7 | * 8 | * The badge color reflects the status: green for "running", red for "disabled", and gray for any other value. The status text is capitalized for display. 9 | */ 10 | export function TautulliStatusBadge() { 11 | const status = useTautulliStatus() 12 | 13 | const getBadgeVariant = () => { 14 | switch (status) { 15 | case 'running': 16 | return 'bg-green-500 hover:bg-green-500 text-white' 17 | case 'disabled': 18 | return 'bg-red-500 hover:bg-red-500 text-white' 19 | default: 20 | return 'bg-gray-400 hover:bg-gray-400 text-white' 21 | } 22 | } 23 | 24 | return ( 25 |
26 | 30 | {status.charAt(0).toUpperCase() + status.slice(1).toLowerCase()} 31 | 32 |
33 | ) 34 | } -------------------------------------------------------------------------------- /src/client/components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | import { useToast } from '@/hooks/use-toast' 2 | import { 3 | Toast, 4 | ToastClose, 5 | ToastDescription, 6 | ToastProvider, 7 | ToastTitle, 8 | ToastViewport, 9 | } from '@/components/ui/toast' 10 | 11 | export function Toaster() { 12 | const { toasts } = useToast() 13 | 14 | return ( 15 | 16 | {toasts.map(({ id, title, description, action, ...props }) => ( 17 | 18 |
19 | {title && {title}} 20 | {description && ( 21 | {description} 22 | )} 23 |
24 | {action} 25 | 26 |
27 | ))} 28 | 29 |
30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/client/components/ui/version-display.tsx: -------------------------------------------------------------------------------- 1 | import type React from 'react' 2 | 3 | interface VersionDisplayProps { 4 | className?: string; 5 | style?: React.CSSProperties; 6 | } 7 | 8 | export function VersionDisplay({ className = '', style }: VersionDisplayProps) { 9 | return ( 10 |
11 | v{__APP_VERSION__} 12 |
13 | ) 14 | } -------------------------------------------------------------------------------- /src/client/features/auth/components/login-error.tsx: -------------------------------------------------------------------------------- 1 | import { AlertCircle } from 'lucide-react' 2 | import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' 3 | 4 | interface LoginErrorMessageProps { 5 | message: string 6 | } 7 | 8 | export function LoginErrorMessage({ message }: LoginErrorMessageProps) { 9 | return ( 10 | 11 | 12 | Error 13 | {message} 14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/client/features/auth/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardContent, 4 | CardDescription, 5 | CardHeader, 6 | } from '@/components/ui/card' 7 | import { ModeToggle } from '@/components/ui/mode-toggle' 8 | import { LoginForm } from '@/features/auth/components/login-form' 9 | 10 | export function LoginPage() { 11 | return ( 12 |
13 | 14 | 15 |

Pulsarr

16 | 17 | Enter your credentials to login 18 | 19 |
20 | 21 | 22 |
23 | 24 |
25 |
26 |
27 |
28 | ) 29 | } 30 | 31 | export default LoginPage 32 | -------------------------------------------------------------------------------- /src/client/features/auth/schemas/login-schema.ts: -------------------------------------------------------------------------------- 1 | import * as z from 'zod' 2 | 3 | const PasswordSchema = z 4 | .string() 5 | .min(8, 'Password must be at least 8 characters long') 6 | 7 | export const loginFormSchema = z.object({ 8 | email: z 9 | .string() 10 | .trim() 11 | .min(1, 'Email is required') 12 | .email('Please enter a valid email address') 13 | .refine((email) => email.includes('@'), { 14 | message: 'Please include an @ symbol in the email address', 15 | }) 16 | .refine((email) => email.includes('.'), { 17 | message: 'Please include a domain in the email address', 18 | }), 19 | password: PasswordSchema, 20 | }) 21 | 22 | export type LoginFormSchema = z.infer 23 | -------------------------------------------------------------------------------- /src/client/features/content-router/components/accordion-route-card-skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from '@/components/ui/skeleton' 2 | import { 3 | Accordion, 4 | AccordionItem, 5 | AccordionTrigger, 6 | } from '@/components/ui/accordion' 7 | 8 | export const AccordionRouteCardSkeleton = () => { 9 | return ( 10 | 11 | 15 | 16 |
17 |
18 | 19 |
20 | 21 |
22 |
23 |
24 |
25 | ) 26 | } 27 | 28 | export default AccordionRouteCardSkeleton 29 | -------------------------------------------------------------------------------- /src/client/features/content-router/constants.ts: -------------------------------------------------------------------------------- 1 | // Series type constants for content router UI 2 | export const ROUTER_SERIES_TYPES = ['standard', 'anime', 'daily'] as const 3 | export type RouterSeriesType = (typeof ROUTER_SERIES_TYPES)[number] 4 | -------------------------------------------------------------------------------- /src/client/features/content-router/utils/utils.ts: -------------------------------------------------------------------------------- 1 | export const generateUUID = () => { 2 | // Use crypto.randomUUID() if available (modern browsers) 3 | if ( 4 | typeof crypto !== 'undefined' && 5 | typeof crypto.randomUUID === 'function' 6 | ) { 7 | return crypto.randomUUID() 8 | } 9 | // Fallback for older browsers 10 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { 11 | const r = (Math.random() * 16) | 0 12 | const v = c === 'x' ? r : (r & 0x3) | 0x8 13 | return v.toString(16) 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /src/client/features/create-user/components/create-user-error.tsx: -------------------------------------------------------------------------------- 1 | import { AlertCircle } from 'lucide-react' 2 | import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' 3 | 4 | interface CreateUserErrorMessageProps { 5 | message: string 6 | } 7 | 8 | export function CreateUserErrorMessage({ 9 | message, 10 | }: CreateUserErrorMessageProps) { 11 | return ( 12 | 13 | 14 | Error 15 | {message} 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/client/features/create-user/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardContent, 4 | CardDescription, 5 | CardHeader, 6 | } from '@/components/ui/card' 7 | import { ModeToggle } from '@/components/ui/mode-toggle' 8 | import { CreateUserForm } from '@/features/create-user/components/create-user-form' 9 | 10 | export function CreateUserPage() { 11 | return ( 12 |
13 | 14 | 15 |

16 | Create User 17 |

18 | 19 | Enter details to create a new admin user 20 | 21 |
22 | 23 | 24 |
25 | 26 |
27 |
28 |
29 |
30 | ) 31 | } 32 | 33 | export default CreateUserPage 34 | -------------------------------------------------------------------------------- /src/client/features/create-user/schemas/create-user-schema.ts: -------------------------------------------------------------------------------- 1 | import * as z from 'zod' 2 | 3 | export const PasswordSchema = z 4 | .string() 5 | .min(8, 'Password must be at least 8 characters') 6 | 7 | export const EmailSchema = z 8 | .string() 9 | .trim() 10 | .min(1, 'Email is required') 11 | .email('Please enter a valid email address') 12 | .refine((email) => email.includes('@'), { 13 | message: 'Please include an @ symbol in the email address', 14 | }) 15 | .refine((email) => email.includes('.'), { 16 | message: 'Please include a domain in the email address', 17 | }) 18 | 19 | export const createUserFormSchema = z 20 | .object({ 21 | email: EmailSchema, 22 | username: z 23 | .string() 24 | .trim() 25 | .min(3, 'Username must be at least 3 characters') 26 | .max(255, 'Username must be less than 255 characters'), 27 | password: PasswordSchema, 28 | confirmPassword: z.string(), 29 | }) 30 | .refine((data) => data.password === data.confirmPassword, { 31 | message: "Passwords don't match", 32 | path: ['confirmPassword'], 33 | }) 34 | 35 | export type CreateUserFormSchema = z.infer 36 | -------------------------------------------------------------------------------- /src/client/features/dashboard/components/media-card-skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent } from '@/components/ui/card' 2 | import { Skeleton } from '@/components/ui/skeleton' 3 | import { AspectRatio } from '@/components/ui/aspect-ratio' 4 | 5 | export function MediaCardSkeleton() { 6 | return ( 7 | 8 | 9 | 13 | 14 | 15 |
16 | 17 |
18 |
19 |
20 | ) 21 | } 22 | 23 | export default MediaCardSkeleton 24 | -------------------------------------------------------------------------------- /src/client/features/dashboard/components/popularity-rankings.tsx: -------------------------------------------------------------------------------- 1 | import { WatchlistCarousel } from '@/features/dashboard/components/watchlist-carousel' 2 | import { useDashboardStats } from '@/features/dashboard/hooks/useDashboardStats' 3 | 4 | export function PopularityRankings() { 5 | const { mostWatchedShows, mostWatchedMovies, loadingStates, errorStates } = 6 | useDashboardStats() 7 | 8 | return ( 9 |
10 |

Popularity Rankings

11 | 12 |
13 | 19 | 20 | 26 |
27 |
28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/client/features/dashboard/components/stats-header.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2, RefreshCw } from 'lucide-react' 2 | import { Button } from '@/components/ui/button' 3 | import { WatchlistStatusBadge } from '@/components/ui/workflow-status-badge' 4 | import { useDashboardStats } from '@/features/dashboard/hooks/useDashboardStats' 5 | 6 | interface StatsHeaderProps { 7 | onRefresh: () => Promise 8 | } 9 | 10 | export function StatsHeader({ onRefresh }: StatsHeaderProps) { 11 | const { isLoading, lastRefreshed } = useDashboardStats() 12 | 13 | return ( 14 | <> 15 | {/* Dashboard Header */} 16 |
17 |
18 |

Main Workflow

19 | 20 |
21 |
22 | 23 | {/* Refresh and Last Updated Container */} 24 |
25 | 38 |

39 | Last updated: {lastRefreshed.toLocaleTimeString()} 40 |

41 |
42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/client/features/dashboard/index.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from 'react' 2 | import { StatsHeader } from '@/features/dashboard/components/stats-header' 3 | import { PopularityRankings } from '@/features/dashboard/components/popularity-rankings' 4 | import { AnalyticsDashboard } from '@/features/dashboard/components/analytics-dashboard' 5 | import { useDashboardStats } from '@/features/dashboard/hooks/useDashboardStats' 6 | 7 | export function DashboardPage() { 8 | const { refreshStats, isLoading } = useDashboardStats() 9 | 10 | useEffect(() => { 11 | refreshStats() 12 | }, [refreshStats]) 13 | 14 | const handleRefresh = useCallback(async () => { 15 | if (!isLoading) { 16 | await refreshStats() 17 | } 18 | }, [refreshStats, isLoading]) 19 | 20 | return ( 21 |
22 | 23 | 24 | 25 |
26 | ) 27 | } 28 | 29 | export default DashboardPage 30 | -------------------------------------------------------------------------------- /src/client/features/notifications/components/discord/discord-clear-alert.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Credenza, 3 | CredenzaContent, 4 | CredenzaHeader, 5 | CredenzaTitle, 6 | CredenzaDescription, 7 | CredenzaBody, 8 | CredenzaFooter, 9 | CredenzaClose, 10 | } from '@/components/ui/credenza' 11 | import { Button } from '@/components/ui/button' 12 | 13 | interface DiscordClearAlertProps { 14 | open: boolean 15 | onOpenChange: (open: boolean) => void 16 | onConfirm: () => Promise 17 | title: string 18 | description: string 19 | } 20 | 21 | export function DiscordClearAlert({ 22 | open, 23 | onOpenChange, 24 | onConfirm, 25 | title, 26 | description, 27 | }: DiscordClearAlertProps) { 28 | return ( 29 | 30 | 31 | 32 | {title} 33 | {description} 34 | 35 | 36 | 37 | 38 | 39 | 40 | 49 | 50 | 51 | 52 | 53 | ) 54 | } 55 | 56 | export default DiscordClearAlert 57 | -------------------------------------------------------------------------------- /src/client/features/notifications/components/email/email-placeholder.tsx: -------------------------------------------------------------------------------- 1 | export function EmailPlaceholder() { 2 | return ( 3 |
4 |

5 | Email notification settings are not yet implemented in the backend. 6 | Check back later for this feature. 7 |

8 |
9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /src/client/features/notifications/hooks/useNotificationsConfig.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { useConfigStore } from '@/stores/configStore' 3 | 4 | export function useNotificationsConfig() { 5 | const [isInitialized, setIsInitialized] = useState(false) 6 | const config = useConfigStore((state) => state.config) 7 | const initialize = useConfigStore((state) => state.initialize) 8 | 9 | useEffect(() => { 10 | initialize() 11 | }, [initialize]) 12 | 13 | useEffect(() => { 14 | if (config) { 15 | setIsInitialized(true) 16 | } 17 | }, [config]) 18 | 19 | return { 20 | isInitialized, 21 | config, 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/client/features/notifications/index.tsx: -------------------------------------------------------------------------------- 1 | import { NotificationsSection } from '@/features/notifications/components/notifications-section' 2 | import { useNotificationsConfig } from '@/features/notifications/hooks/useNotificationsConfig' 3 | 4 | export default function NotificationsConfigPage() { 5 | const { isInitialized } = useNotificationsConfig() 6 | 7 | return ( 8 |
9 | 10 |
11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /src/client/features/plex/hooks/usePlexSetup.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { useConfigStore } from '@/stores/configStore' 3 | 4 | export function usePlexSetup() { 5 | const [showSetupModal, setShowSetupModal] = useState(false) 6 | const updateConfig = useConfigStore((state) => state.updateConfig) 7 | const fetchUserData = useConfigStore((state) => state.fetchUserData) 8 | const refreshRssFeeds = useConfigStore((state) => state.refreshRssFeeds) 9 | 10 | // Function to handle setting up a new Plex token 11 | const setupPlexToken = async (token: string) => { 12 | // Update config with new token 13 | await updateConfig({ 14 | plexTokens: [token], 15 | }) 16 | 17 | // Sync watchlists 18 | await Promise.all([ 19 | fetch('/v1/plex/self-watchlist-token', { 20 | method: 'GET', 21 | headers: { Accept: 'application/json' }, 22 | }), 23 | fetch('/v1/plex/others-watchlist-token', { 24 | method: 'GET', 25 | headers: { Accept: 'application/json' }, 26 | }), 27 | ]) 28 | 29 | // Generate RSS feeds 30 | await refreshRssFeeds() 31 | 32 | // Refresh user data 33 | await fetchUserData() 34 | } 35 | 36 | return { 37 | showSetupModal, 38 | setShowSetupModal, 39 | setupPlexToken, 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/client/features/plex/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { useConfigStore } from '@/stores/configStore' 3 | import PlexConnectionSection from '@/features/plex/components/connection/connection-section' 4 | import UserTableSection from '@/features/plex/components/user/user-table-section' 5 | import SetupModal from '@/features/plex/components/setup/setup-modal' 6 | import { usePlexSetup } from '@/features/plex/hooks/usePlexSetup' 7 | 8 | export default function PlexConfigPage() { 9 | const config = useConfigStore((state) => state.config) 10 | const initialize = useConfigStore((state) => state.initialize) 11 | const { showSetupModal, setShowSetupModal } = usePlexSetup() 12 | 13 | useEffect(() => { 14 | initialize() 15 | }, [initialize]) 16 | 17 | // Check if Plex token is missing and show setup modal 18 | useEffect(() => { 19 | if (config && (!config.plexTokens || config.plexTokens.length === 0)) { 20 | setShowSetupModal(true) 21 | } 22 | }, [config, setShowSetupModal]) 23 | 24 | return ( 25 |
26 | 27 | 28 |
29 | {/* Plex Connection Section */} 30 | 31 | 32 | {/* User Table Section */} 33 | 34 |
35 |
36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /src/client/features/plex/store/constants.ts: -------------------------------------------------------------------------------- 1 | // Plex API Status Codes 2 | export const PLEX_STATUS = { 3 | SUCCESS: 200, 4 | UNAUTHORIZED: 401, 5 | NOT_FOUND: 404, 6 | } 7 | 8 | // Minimum loading delay for UI feedback (ms) 9 | export const MIN_LOADING_DELAY = 500 10 | -------------------------------------------------------------------------------- /src/client/features/plex/store/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | UpdateUser, 3 | BulkUpdateRequest, 4 | } from '@root/schemas/users/users.schema' 5 | import type { SelfWatchlistSuccess } from '@root/schemas/plex/self-watchlist-token.schema' 6 | import type { OthersWatchlistSuccess } from '@root/schemas/plex/others-watchlist-token.schema' 7 | import type { PingSuccess } from '@root/schemas/plex/ping.schema' 8 | import type { RssFeedsSuccess } from '@root/schemas/plex/generate-rss-feeds.schema' 9 | import type { UserWatchlistInfo } from '@/stores/configStore' 10 | import type { Row } from '@tanstack/react-table' 11 | 12 | export interface PlexConnectionValues { 13 | plexToken: string 14 | } 15 | 16 | export type ConnectionStatus = 17 | | 'idle' 18 | | 'loading' 19 | | 'testing' 20 | | 'success' 21 | | 'error' 22 | export type SyncStatus = 'idle' | 'loading' | 'success' | 'error' 23 | export type PlexUserTableRow = Row 24 | export type BulkUpdateStatus = 'idle' | 'loading' | 'success' | 'error' 25 | 26 | export type PlexUserUpdates = UpdateUser 27 | 28 | export interface PlexBulkUpdateRequest extends BulkUpdateRequest { 29 | userIds: number[] 30 | updates: PlexUserUpdates 31 | } 32 | 33 | export type SelfWatchlistResponse = SelfWatchlistSuccess 34 | export type OthersWatchlistResponse = OthersWatchlistSuccess 35 | export type RssFeedsResponse = RssFeedsSuccess 36 | export type PingResponse = PingSuccess 37 | -------------------------------------------------------------------------------- /src/client/features/radarr/hooks/content-router/useRadarrContentRouterAdapter.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | import { useRadarrStore } from '@/features/radarr/store/radarrStore' 3 | import { useContentRouter } from '@/features/content-router/hooks/useContentRouter' 4 | 5 | /** 6 | * Custom hook that integrates the Radarr store state with a Radarr-specific content router. 7 | * 8 | * Retrieves Radarr instances and genres from the store, initializes a content router with the target type "radarr", 9 | * and provides a memoized asynchronous function to fetch routing rules. Also exposes a handler to trigger genre fetching. 10 | * 11 | * @returns An object combining content router methods with: 12 | * - fetchRules: An async function that retrieves routing rules. 13 | * - instances: The Radarr instances from the store. 14 | * - genres: The genre data from the store. 15 | * - handleGenreDropdownOpen: A callback to initiate genre fetching. 16 | */ 17 | export function useRadarrContentRouterAdapter() { 18 | const instances = useRadarrStore((state) => state.instances) 19 | const genres = useRadarrStore((state) => state.genres) 20 | const fetchGenres = useRadarrStore((state) => state.fetchGenres) 21 | const contentRouter = useContentRouter({ targetType: 'radarr' }) 22 | 23 | const fetchRules = useCallback(async () => { 24 | const result = await contentRouter.fetchRules() 25 | return result 26 | }, [contentRouter]) 27 | 28 | return { 29 | ...contentRouter, 30 | fetchRules, 31 | instances, 32 | genres, 33 | handleGenreDropdownOpen: fetchGenres, 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/client/features/radarr/hooks/instance/useRadarrSyncProgress.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback, useRef } from 'react' 2 | import { useProgressStore } from '@/stores/progressStore' 3 | import type { ProgressEvent } from '@root/types/progress.types' 4 | 5 | export interface RadarrSyncProgressState { 6 | progress: number 7 | message: string 8 | phase: string 9 | operationId: string 10 | isComplete: boolean 11 | } 12 | 13 | export function useRadarrSyncProgress() { 14 | const [state, setState] = useState({ 15 | progress: 0, 16 | message: '', 17 | phase: '', 18 | operationId: '', 19 | isComplete: false, 20 | }) 21 | 22 | const mountedRef = useRef(true) 23 | 24 | const subscribeToType = useProgressStore((state) => state.subscribeToType) 25 | 26 | const handleEvent = useCallback((event: ProgressEvent) => { 27 | if (mountedRef.current) { 28 | setState({ 29 | progress: event.progress || 0, 30 | message: event.message || '', 31 | phase: event.phase || '', 32 | operationId: event.operationId || '', 33 | isComplete: event.phase === 'complete', 34 | }) 35 | } 36 | }, []) 37 | 38 | useEffect(() => { 39 | const unsubscribe = subscribeToType('sync', handleEvent) 40 | return () => { 41 | mountedRef.current = false 42 | unsubscribe() 43 | } 44 | }, [handleEvent, subscribeToType]) 45 | 46 | return state 47 | } 48 | -------------------------------------------------------------------------------- /src/client/features/radarr/hooks/selects/useRadarrSync.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import type { RadarrInstance } from '@/features/radarr/store/radarrStore' 3 | import { Computer } from 'lucide-react' 4 | 5 | export function useRadarrSync( 6 | currentInstanceId: number, 7 | instances: RadarrInstance[], 8 | ) { 9 | const availableInstances = useMemo( 10 | () => 11 | instances 12 | .filter( 13 | (inst) => 14 | inst.id !== currentInstanceId && inst.apiKey !== 'placeholder', 15 | ) 16 | .map((instance) => ({ 17 | value: instance.id.toString(), 18 | label: instance.name, 19 | icon: Computer, 20 | })), 21 | [currentInstanceId, instances], 22 | ) 23 | 24 | return { 25 | availableInstances, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/client/features/radarr/store/constants.ts: -------------------------------------------------------------------------------- 1 | export const API_KEY_PLACEHOLDER = 'placeholder' 2 | -------------------------------------------------------------------------------- /src/client/features/radarr/store/responses.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@root/types/config.types' 2 | 3 | export interface ConfigResponse { 4 | success: boolean 5 | config: Config 6 | } 7 | 8 | export interface GenresResponse { 9 | success: boolean 10 | genres: string[] 11 | } 12 | -------------------------------------------------------------------------------- /src/client/features/sonarr/constants.ts: -------------------------------------------------------------------------------- 1 | // Series type constants for Sonarr UI 2 | export const SONARR_SERIES_TYPES = ['standard', 'anime', 'daily'] as const 3 | export type SonarrSeriesType = (typeof SONARR_SERIES_TYPES)[number] 4 | 5 | // Display names for series types 6 | export const SERIES_TYPE_LABELS: Record = { 7 | standard: 'Standard', 8 | anime: 'Anime', 9 | daily: 'Daily', 10 | } 11 | -------------------------------------------------------------------------------- /src/client/features/sonarr/hooks/content-router/useSonarrContentRouterAdapter.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | import { useSonarrStore } from '@/features/sonarr/store/sonarrStore' 3 | import { useContentRouter } from '@/features/content-router/hooks/useContentRouter' 4 | 5 | /** 6 | * Integrates Sonarr store state with content routing functionalities. 7 | * 8 | * This custom hook combines data from the Sonarr store (such as instances and genres) with 9 | * content routing methods provided by a content router targeted for Sonarr. It memoizes a function 10 | * to asynchronously fetch routing rules and exposes a method to trigger genre fetching. 11 | * 12 | * @returns An object containing: 13 | * - All properties and methods from the Sonarr content router. 14 | * - A memoized `fetchRules` function to retrieve routing rules. 15 | * - The store's `instances` and `genres`. 16 | * - A `handleGenreDropdownOpen` method that triggers genre fetching. 17 | */ 18 | export function useSonarrContentRouterAdapter() { 19 | const instances = useSonarrStore((state) => state.instances) 20 | const genres = useSonarrStore((state) => state.genres) 21 | const fetchGenres = useSonarrStore((state) => state.fetchGenres) 22 | const contentRouter = useContentRouter({ targetType: 'sonarr' }) 23 | 24 | const fetchRules = useCallback(async () => { 25 | const result = await contentRouter.fetchRules() 26 | return result 27 | }, [contentRouter]) 28 | 29 | return { 30 | ...contentRouter, 31 | fetchRules, 32 | instances, 33 | genres, 34 | handleGenreDropdownOpen: fetchGenres, 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/client/features/sonarr/hooks/instance/useSyncProgress.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback, useRef } from 'react' 2 | import { useProgressStore } from '@/stores/progressStore' 3 | import type { ProgressEvent } from '@root/types/progress.types' 4 | 5 | export interface SyncProgressState { 6 | progress: number 7 | message: string 8 | phase: string 9 | operationId: string 10 | isComplete: boolean 11 | } 12 | 13 | export function useSyncProgress() { 14 | const [state, setState] = useState({ 15 | progress: 0, 16 | message: '', 17 | phase: '', 18 | operationId: '', 19 | isComplete: false, 20 | }) 21 | 22 | const mountedRef = useRef(true) 23 | 24 | const subscribeToType = useProgressStore((state) => state.subscribeToType) 25 | 26 | const handleEvent = useCallback((event: ProgressEvent) => { 27 | if (mountedRef.current) { 28 | setState({ 29 | progress: event.progress || 0, 30 | message: event.message || '', 31 | phase: event.phase || '', 32 | operationId: event.operationId || '', 33 | isComplete: event.phase === 'complete', 34 | }) 35 | } 36 | }, []) 37 | 38 | useEffect(() => { 39 | const unsubscribe = subscribeToType('sync', handleEvent) 40 | return () => { 41 | mountedRef.current = false 42 | unsubscribe() 43 | } 44 | }, [handleEvent, subscribeToType]) 45 | 46 | return state 47 | } 48 | -------------------------------------------------------------------------------- /src/client/features/sonarr/hooks/selects/useSonarrSync.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import type { SonarrInstance } from '@/features/sonarr/store/sonarrStore' 3 | import { Computer } from 'lucide-react' 4 | 5 | export function useSonarrSync( 6 | currentInstanceId: number, 7 | instances: SonarrInstance[], 8 | ) { 9 | const availableInstances = useMemo( 10 | () => 11 | instances 12 | .filter( 13 | (inst) => 14 | inst.id !== currentInstanceId && inst.apiKey !== 'placeholder', 15 | ) 16 | .map((instance) => ({ 17 | value: instance.id.toString(), 18 | label: instance.name, 19 | icon: Computer, 20 | })), 21 | [currentInstanceId, instances], 22 | ) 23 | 24 | return { 25 | availableInstances, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/client/features/sonarr/store/constants.ts: -------------------------------------------------------------------------------- 1 | import type { SonarrMonitoringType } from '@/features/sonarr/types/types' 2 | 3 | export const SONARR_MONITORING_OPTIONS: Record = { 4 | unknown: 'Unknown', 5 | all: 'All Seasons', 6 | future: 'Future Seasons', 7 | missing: 'Missing Episodes', 8 | existing: 'Existing Episodes', 9 | firstSeason: 'First Season', 10 | lastSeason: 'Last Season', 11 | latestSeason: 'Latest Season', 12 | pilot: 'Pilot Only', 13 | recent: 'Recent Episodes', 14 | monitorSpecials: 'Monitor Specials', 15 | unmonitorSpecials: 'Unmonitor Specials', 16 | none: 'None', 17 | skip: 'Skip', 18 | } 19 | 20 | export const API_KEY_PLACEHOLDER = 'placeholder' 21 | -------------------------------------------------------------------------------- /src/client/features/sonarr/store/responses.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@root/types/config.types' 2 | 3 | export interface ConfigResponse { 4 | success: boolean 5 | config: Config 6 | } 7 | 8 | export interface GenresResponse { 9 | success: boolean 10 | genres: string[] 11 | } 12 | -------------------------------------------------------------------------------- /src/client/features/utilities/hooks/useTaggingProgress.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { useProgressStore } from '@/stores/progressStore' 3 | 4 | // Define the specific string literal type that matches what subscribeToType accepts 5 | type ProgressEventType = 6 | | 'self-watchlist' 7 | | 'others-watchlist' 8 | | 'rss-feed' 9 | | 'system' 10 | | 'sync' 11 | | 'sonarr-tagging' 12 | | 'radarr-tagging' 13 | | 'sonarr-tag-removal' 14 | | 'radarr-tag-removal' 15 | 16 | /** 17 | * React hook that returns real-time progress and status message for a given tagging event type. 18 | * 19 | * Subscribes to progress updates for the specified {@link type} and provides the latest progress percentage and message. 20 | * 21 | * @param type - The tagging event type to track. 22 | * @returns An object containing the current progress value and message. 23 | */ 24 | export function useTaggingProgress(type: ProgressEventType) { 25 | const [progress, setProgress] = useState({ progress: 0, message: '' }) 26 | 27 | useEffect(() => { 28 | const unsubscribe = useProgressStore 29 | .getState() 30 | .subscribeToType(type, (event) => { 31 | if (event.progress !== undefined) { 32 | setProgress((prev) => ({ 33 | progress: event.progress, 34 | message: event.message || prev.message, 35 | })) 36 | } 37 | }) 38 | 39 | return () => { 40 | unsubscribe() 41 | } 42 | }, [type]) 43 | 44 | return progress 45 | } 46 | -------------------------------------------------------------------------------- /src/client/hooks/notifications/useDiscordStatus.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from 'react' 2 | import { useProgressStore } from '@/stores/progressStore' 3 | import type { ProgressEvent } from '@root/types/progress.types' 4 | 5 | export function useDiscordStatus() { 6 | const [status, setStatus] = useState('unknown') 7 | const subscribeToType = useProgressStore(state => state.subscribeToType) 8 | 9 | const handleEvent = useCallback((event: ProgressEvent) => { 10 | if (event.type === 'system' && event.message?.startsWith('Discord bot status:')) { 11 | const botStatus = event.message.replace('Discord bot status:', '').trim() 12 | setStatus(botStatus) 13 | } 14 | }, []) 15 | 16 | useEffect(() => { 17 | const unsubscribe = subscribeToType('system', handleEvent) 18 | return () => { 19 | unsubscribe() 20 | } 21 | }, [subscribeToType, handleEvent]) 22 | 23 | return status 24 | } -------------------------------------------------------------------------------- /src/client/hooks/notifications/useTautulliStatus.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from 'react' 2 | import { useProgressStore } from '@/stores/progressStore' 3 | import type { ProgressEvent } from '@root/types/progress.types' 4 | 5 | type TautulliStatus = 'running' | 'disabled' | 'unknown' 6 | 7 | /** 8 | * React hook that tracks and returns the current Tautulli status. 9 | * 10 | * Subscribes to system progress events and updates the status based on messages indicating Tautulli's state. 11 | * 12 | * @returns The current Tautulli status: 'running', 'disabled', or 'unknown'. 13 | * 14 | * @remark If an invalid status is received from an event, the status is set to 'unknown'. 15 | */ 16 | export function useTautulliStatus(): TautulliStatus { 17 | const [status, setStatus] = useState('unknown') 18 | const subscribeToType = useProgressStore(state => state.subscribeToType) 19 | 20 | const handleEvent = useCallback((event: ProgressEvent) => { 21 | if (event.type === 'system' && event.message?.startsWith('Tautulli status:')) { 22 | const tautulliStatus = event.message.replace('Tautulli status:', '').trim() 23 | // Validate the status before setting 24 | if (['running', 'disabled', 'unknown'].includes(tautulliStatus)) { 25 | setStatus(tautulliStatus as TautulliStatus) 26 | } else { 27 | console.warn(`Received invalid Tautulli status: ${tautulliStatus}`) 28 | setStatus('unknown') 29 | } 30 | } 31 | }, []) 32 | 33 | useEffect(() => { 34 | const unsubscribe = subscribeToType('system', handleEvent) 35 | return () => { 36 | unsubscribe() 37 | } 38 | }, [subscribeToType, handleEvent]) 39 | 40 | return status 41 | } -------------------------------------------------------------------------------- /src/client/hooks/use-media-query.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | export function useMediaQuery(query: string) { 4 | const [value, setValue] = React.useState(false) 5 | 6 | React.useEffect(() => { 7 | function onChange(event: MediaQueryListEvent) { 8 | setValue(event.matches) 9 | } 10 | 11 | const result = matchMedia(query) 12 | result.addEventListener("change", onChange) 13 | setValue(result.matches) 14 | 15 | return () => result.removeEventListener("change", onChange) 16 | }, [query]) 17 | 18 | return value 19 | } -------------------------------------------------------------------------------- /src/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Pulsarr 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /src/client/layouts/authenticated.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | import type { ReactNode } from 'react' 3 | import WindowedLayout from './window' 4 | import { useProgressStore } from '@/stores/progressStore' 5 | import { useVersionCheck } from '@/hooks/useVersionCheck' 6 | 7 | interface AuthenticatedLayoutProps { 8 | children: ReactNode 9 | } 10 | 11 | export default function AuthenticatedLayout({ 12 | children, 13 | }: AuthenticatedLayoutProps) { 14 | const initialize = useProgressStore((state) => state.initialize) 15 | const cleanup = useProgressStore((state) => state.cleanup) 16 | const initialized = useRef(false) 17 | 18 | useVersionCheck('jamcalli', 'Pulsarr') 19 | 20 | useEffect(() => { 21 | if (!initialized.current) { 22 | initialize() 23 | initialized.current = true 24 | } 25 | 26 | return () => { 27 | cleanup() 28 | initialized.current = false 29 | } 30 | }, [initialize, cleanup]) 31 | 32 | return {children} 33 | } 34 | -------------------------------------------------------------------------------- /src/client/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from 'clsx' 2 | import { twMerge } from 'tailwind-merge' 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/client/styles/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Shuttleblock"; 3 | src: url("@/assets/fonts/Shuttleblock-Medium.woff2") format("woff2"); 4 | font-weight: 400; 5 | font-style: normal; 6 | font-display: swap; 7 | } 8 | 9 | @font-face { 10 | font-family: "Shuttleblock"; 11 | src: url("@/assets/fonts/Shuttleblock-Bold.woff2") format("woff2"); 12 | font-weight: 700; 13 | font-style: normal; 14 | font-display: swap; 15 | } 16 | 17 | @font-face { 18 | font-family: "Shuttleblock"; 19 | src: url("@/assets/fonts/Shuttleblock-MediumItalic.woff2") format("woff2"); 20 | font-weight: 400; 21 | font-style: italic; 22 | font-display: swap; 23 | } 24 | -------------------------------------------------------------------------------- /src/client/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --main: #48a9a6; 7 | --overlay: rgba(0, 0, 0, 0.8); 8 | --static-text: #c1666b; 9 | --error: #c1666b; 10 | --fun: #d4b483; 11 | --orange: #f6723a; 12 | --blue: #4a94b5; 13 | /* Static asteroid variables */ 14 | --static-asteroid-fill: #948d89; 15 | --static-asteroid-border: #dedede; 16 | 17 | --bg: #dfe5f2; 18 | --bw: #e4dfda; 19 | --blank: #000; 20 | --border: #000; 21 | --text: #000; 22 | --mtext: #000; 23 | --ring: #000; 24 | --ring-offset: #e4dfda; 25 | 26 | --border-radius: 5px; 27 | --box-shadow-x: 4px; 28 | --box-shadow-y: 4px; 29 | --reverse-box-shadow-x: -4px; 30 | --reverse-box-shadow-y: -4px; 31 | --base-font-weight: 500; 32 | --heading-font-weight: 700; 33 | 34 | --shadow: var(--box-shadow-x) var(--box-shadow-y) 0px 0px var(--border); 35 | 36 | --chart-1: 196 39% 33%; 37 | --chart-2: 183 37% 49%; 38 | --chart-3: 29 85% 87%; 39 | --chart-4: 19 91% 59%; 40 | --chart-5: 1 54% 50%; 41 | 42 | --color-movie: #1a5999; 43 | --color-show: #39b978; 44 | --color-count: #f47b30; 45 | } 46 | 47 | .dark { 48 | --bg: #272933; 49 | --bw: #212121; 50 | --blank: #e4dfda; 51 | --border: #000; 52 | --text: #e6e6e6; 53 | --mtext: #000; 54 | --ring: #e4dfda; 55 | --ring-offset: #000; 56 | 57 | --shadow: var(--box-shadow-x) var(--box-shadow-y) 0px 0px var(--border); 58 | } 59 | 60 | body { 61 | overflow: hidden; 62 | position: fixed; 63 | width: 100%; 64 | height: 100%; 65 | touch-action: pan-y pinch-zoom; 66 | } 67 | 68 | #app { 69 | position: fixed; 70 | width: 100%; 71 | height: 100%; 72 | overflow: hidden; 73 | } 74 | -------------------------------------------------------------------------------- /src/client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "rootDir": "../..", 9 | "skipLibCheck": true, 10 | "baseUrl": ".", 11 | "paths": { 12 | "@/*": ["./*"], 13 | "@app/*": ["./*"], 14 | "@root/*": ["../../src/*"] 15 | }, 16 | 17 | /* Bundler mode */ 18 | "moduleResolution": "bundler", 19 | "resolveJsonModule": true, 20 | "isolatedModules": true, 21 | "jsx": "react-jsx", 22 | 23 | /* Linting */ 24 | "strict": true, 25 | "noUnusedLocals": true, 26 | "noUnusedParameters": true, 27 | "noFallthroughCasesInSwitch": true 28 | }, 29 | "include": ["**/*", "../../src/types/**/*", "../../src/schemas/**/*"], 30 | "exclude": ["dist"] 31 | } 32 | -------------------------------------------------------------------------------- /src/client/types/globals.d.ts: -------------------------------------------------------------------------------- 1 | // Global type declarations for client-side code 2 | 3 | declare const __APP_VERSION__: string 4 | -------------------------------------------------------------------------------- /src/client/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/plugins/custom/content-router.ts: -------------------------------------------------------------------------------- 1 | import fp from 'fastify-plugin' 2 | import type { FastifyInstance } from 'fastify' 3 | import { ContentRouterService } from '@services/content-router.service.js' 4 | 5 | declare module 'fastify' { 6 | interface FastifyInstance { 7 | contentRouter: ContentRouterService 8 | } 9 | } 10 | 11 | export default fp( 12 | async (fastify: FastifyInstance) => { 13 | fastify.log.info('Initializing content router plugin') 14 | 15 | const routerService = new ContentRouterService(fastify.log, fastify) 16 | await routerService.initialize() 17 | 18 | fastify.decorate('contentRouter', routerService) 19 | 20 | const pluginNames = routerService 21 | .getLoadedEvaluators() 22 | .map((p) => p.name) 23 | .join(', ') 24 | fastify.log.info(`Content router initialized with plugins: ${pluginNames}`) 25 | }, 26 | { 27 | name: 'content-router', 28 | dependencies: ['database', 'sonarr-manager', 'radarr-manager'], 29 | }, 30 | ) 31 | -------------------------------------------------------------------------------- /src/plugins/custom/plex-watchlist.ts: -------------------------------------------------------------------------------- 1 | import fp from 'fastify-plugin' 2 | import type { FastifyInstance } from 'fastify' 3 | import { PlexWatchlistService } from '@services/plex-watchlist.service.js' 4 | 5 | declare module 'fastify' { 6 | interface FastifyInstance { 7 | plexWatchlist: PlexWatchlistService 8 | } 9 | } 10 | 11 | export default fp( 12 | async (fastify: FastifyInstance) => { 13 | const service = new PlexWatchlistService(fastify.log, fastify, fastify.db) 14 | 15 | fastify.decorate('plexWatchlist', service) 16 | }, 17 | { 18 | name: 'plex-watchlist', 19 | dependencies: ['config', 'database', 'discord-notification-service'], 20 | }, 21 | ) 22 | -------------------------------------------------------------------------------- /src/plugins/custom/progress.ts: -------------------------------------------------------------------------------- 1 | import fp from 'fastify-plugin' 2 | import type { FastifyInstance } from 'fastify' 3 | import { randomUUID } from 'node:crypto' 4 | import { on } from 'node:events' 5 | import { ProgressService } from '@services/event-emitter.service.js' 6 | 7 | declare module 'fastify' { 8 | interface FastifyInstance { 9 | progress: ProgressService 10 | } 11 | } 12 | 13 | export default fp( 14 | async (fastify: FastifyInstance) => { 15 | const service = ProgressService.getInstance(fastify.log, fastify) 16 | fastify.decorate('progress', service) 17 | 18 | fastify.get('/api/progress', (request, reply) => { 19 | const connectionId = randomUUID() 20 | service.addConnection(connectionId) 21 | 22 | request.socket.on('close', () => { 23 | service.removeConnection(connectionId) 24 | }) 25 | 26 | return reply.sse( 27 | (async function* source() { 28 | for await (const [event] of on( 29 | service.getEventEmitter(), 30 | 'progress', 31 | )) { 32 | yield { 33 | id: event.operationId, 34 | data: JSON.stringify(event), 35 | } 36 | } 37 | })(), 38 | ) 39 | }) 40 | }, 41 | { 42 | name: 'progress', 43 | dependencies: ['fastify-sse-v2'], 44 | }, 45 | ) 46 | -------------------------------------------------------------------------------- /src/plugins/custom/radarr-manager.ts: -------------------------------------------------------------------------------- 1 | import fp from 'fastify-plugin' 2 | import type { FastifyInstance } from 'fastify' 3 | import { RadarrManagerService } from '@services/radarr-manager.service.js' 4 | 5 | declare module 'fastify' { 6 | interface FastifyInstance { 7 | radarrManager: RadarrManagerService 8 | } 9 | } 10 | 11 | export default fp( 12 | async (fastify: FastifyInstance) => { 13 | const manager = new RadarrManagerService(fastify.log, fastify) 14 | await manager.initialize() 15 | fastify.decorate('radarrManager', manager) 16 | fastify.addHook('onClose', async () => { 17 | // Any cleanup needed for the manager 18 | }) 19 | }, 20 | { 21 | name: 'radarr-manager', 22 | dependencies: ['database'], 23 | }, 24 | ) 25 | -------------------------------------------------------------------------------- /src/plugins/custom/scheduler.ts: -------------------------------------------------------------------------------- 1 | import fp from 'fastify-plugin' 2 | import type { FastifyInstance } from 'fastify' 3 | import { SchedulerService } from '@services/scheduler.service.js' 4 | 5 | /** 6 | * Fastify plugin for job scheduling 7 | * 8 | * This plugin integrates the SchedulerService into Fastify, making job scheduling 9 | * functionality available throughout the application. 10 | */ 11 | declare module 'fastify' { 12 | interface FastifyInstance { 13 | /** 14 | * The scheduler service instance 15 | */ 16 | scheduler: SchedulerService 17 | } 18 | } 19 | 20 | export default fp( 21 | async (fastify: FastifyInstance) => { 22 | // Create the scheduler service 23 | const scheduler = new SchedulerService(fastify.log, fastify) 24 | 25 | // Decorate fastify with the scheduler service only 26 | fastify.decorate('scheduler', scheduler) 27 | 28 | // Initialize jobs from database on ready 29 | fastify.addHook('onReady', async () => { 30 | await scheduler.initializeJobsFromDatabase() 31 | }) 32 | 33 | // Cleanup on close 34 | fastify.addHook('onClose', async () => { 35 | scheduler.stop() 36 | }) 37 | }, 38 | { 39 | name: 'scheduler', 40 | dependencies: ['database'], 41 | }, 42 | ) 43 | -------------------------------------------------------------------------------- /src/plugins/custom/sonarr-manager.ts: -------------------------------------------------------------------------------- 1 | import fp from 'fastify-plugin' 2 | import type { FastifyInstance } from 'fastify' 3 | import { SonarrManagerService } from '@services/sonarr-manager.service.js' 4 | 5 | declare module 'fastify' { 6 | interface FastifyInstance { 7 | sonarrManager: SonarrManagerService 8 | } 9 | } 10 | 11 | export default fp( 12 | async (fastify: FastifyInstance) => { 13 | const manager = new SonarrManagerService(fastify.log, fastify) 14 | await manager.initialize() 15 | fastify.decorate('sonarrManager', manager) 16 | fastify.addHook('onClose', async () => { 17 | // Any cleanup needed for the manager 18 | }) 19 | }, 20 | { 21 | name: 'sonarr-manager', 22 | dependencies: ['database'], 23 | }, 24 | ) 25 | -------------------------------------------------------------------------------- /src/plugins/custom/status-sync.ts: -------------------------------------------------------------------------------- 1 | import fp from 'fastify-plugin' 2 | import type { FastifyInstance } from 'fastify' 3 | import { StatusService } from '@services/watchlist-status.service.js' 4 | 5 | declare module 'fastify' { 6 | interface FastifyInstance { 7 | sync: StatusService 8 | } 9 | } 10 | 11 | export default fp( 12 | async (fastify: FastifyInstance) => { 13 | const service = new StatusService( 14 | fastify.log, 15 | fastify.db, 16 | fastify.sonarrManager, 17 | fastify.radarrManager, 18 | fastify, 19 | ) 20 | fastify.decorate('sync', service) 21 | }, 22 | { 23 | name: 'sync', 24 | dependencies: ['database', 'sonarr-manager', 'radarr-manager', 'user-tag'], 25 | }, 26 | ) 27 | -------------------------------------------------------------------------------- /src/plugins/custom/user-tag.plugin.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyPluginAsync } from 'fastify' 2 | import fp from 'fastify-plugin' 3 | import { UserTagService } from '@services/user-tag.service.js' 4 | 5 | /** 6 | * Plugin to register the user tag service 7 | */ 8 | const userTagPlugin: FastifyPluginAsync = async (fastify, opts) => { 9 | // Create the user tag service 10 | const userTagService = new UserTagService(fastify.log, fastify) 11 | 12 | fastify.decorate('userTags', userTagService) 13 | } 14 | 15 | export default fp(userTagPlugin, { 16 | name: 'user-tag', 17 | dependencies: ['database', 'sonarr-manager', 'radarr-manager'], 18 | }) 19 | 20 | // Add type definitions 21 | declare module 'fastify' { 22 | interface FastifyInstance { 23 | userTags: UserTagService 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/plugins/external/compress.ts: -------------------------------------------------------------------------------- 1 | import compress from '@fastify/compress' 2 | 3 | export const autoConfig = { 4 | // Set plugin options here 5 | } 6 | 7 | /** 8 | * This plugin adds compression 9 | * 10 | * @see {@link https://github.com/fastify/fastify-compress} 11 | */ 12 | export default compress 13 | -------------------------------------------------------------------------------- /src/plugins/external/cors.ts: -------------------------------------------------------------------------------- 1 | import fp from 'fastify-plugin' 2 | import cors from '@fastify/cors' 3 | import type { FastifyInstance } from 'fastify' 4 | import type { FastifyCorsOptions } from '@fastify/cors' 5 | 6 | const createCorsConfig = (fastify: FastifyInstance): FastifyCorsOptions => { 7 | fastify.log.info( 8 | `Using baseUrl: ${fastify.config.baseUrl} for service connections`, 9 | ) 10 | 11 | return { 12 | origin: true, 13 | credentials: true, 14 | methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], 15 | allowedHeaders: [ 16 | 'Origin', 17 | 'X-Requested-With', 18 | 'Content-Type', 19 | 'Accept', 20 | 'Authorization', 21 | ], 22 | } 23 | } 24 | 25 | export default fp( 26 | async (fastify: FastifyInstance) => { 27 | await fastify.register(cors, createCorsConfig(fastify)) 28 | }, 29 | { 30 | dependencies: ['config'], 31 | }, 32 | ) 33 | -------------------------------------------------------------------------------- /src/plugins/external/helmet.ts: -------------------------------------------------------------------------------- 1 | import fp from 'fastify-plugin' 2 | import helmet from '@fastify/helmet' 3 | import type { FastifyInstance } from 'fastify' 4 | import type { FastifyHelmetOptions } from '@fastify/helmet' 5 | 6 | const createHelmetConfig = (): FastifyHelmetOptions => ({ 7 | global: true, 8 | contentSecurityPolicy: false, 9 | crossOriginEmbedderPolicy: false, 10 | crossOriginResourcePolicy: false, 11 | crossOriginOpenerPolicy: false, 12 | hsts: false, 13 | 14 | hidePoweredBy: true, 15 | noSniff: true, 16 | dnsPrefetchControl: { 17 | allow: false, 18 | }, 19 | frameguard: { 20 | action: 'sameorigin', 21 | }, 22 | }) 23 | 24 | export default fp( 25 | async (fastify: FastifyInstance) => { 26 | await fastify.register(helmet, createHelmetConfig()) 27 | }, 28 | { 29 | name: 'helmet-plugin', 30 | dependencies: ['config'], 31 | }, 32 | ) 33 | -------------------------------------------------------------------------------- /src/plugins/external/rate-limit.ts: -------------------------------------------------------------------------------- 1 | import fastifyRateLimit from '@fastify/rate-limit' 2 | import type { FastifyInstance } from 'fastify' 3 | 4 | export const autoConfig = (fastify: FastifyInstance) => { 5 | return { 6 | max: fastify.config.rateLimitMax, 7 | timeWindow: '1 minute', 8 | } 9 | } 10 | 11 | /** 12 | * This plugins is low overhead rate limiter for your routes. 13 | * 14 | * @see {@link https://github.com/fastify/fastify-rate-limit} 15 | */ 16 | export default fastifyRateLimit 17 | -------------------------------------------------------------------------------- /src/plugins/external/sensible.ts: -------------------------------------------------------------------------------- 1 | import sensible from '@fastify/sensible' 2 | 3 | export const autoConfig = { 4 | // Set plugin options here 5 | } 6 | 7 | /** 8 | * This plugin adds some utilities to handle http errors 9 | * 10 | * @see {@link https://github.com/fastify/fastify-sensible} 11 | */ 12 | export default sensible 13 | -------------------------------------------------------------------------------- /src/plugins/external/session.ts: -------------------------------------------------------------------------------- 1 | import fastifySession from '@fastify/session' 2 | import fp from 'fastify-plugin' 3 | import type { Auth } from '@schemas/auth/auth.js' 4 | import fastifyCookie from '@fastify/cookie' 5 | 6 | declare module 'fastify' { 7 | interface Session { 8 | user: Auth 9 | } 10 | } 11 | 12 | /** 13 | * This plugins enables the use of session. 14 | * 15 | * @see {@link https://github.com/fastify/session} 16 | */ 17 | export default fp( 18 | async (fastify) => { 19 | fastify.register(fastifyCookie) 20 | fastify.register(fastifySession, { 21 | secret: fastify.config.cookieSecret, 22 | cookieName: fastify.config.cookieName, 23 | cookie: { 24 | secure: fastify.config.cookieSecured, 25 | httpOnly: true, 26 | maxAge: 604800000, 27 | }, 28 | }) 29 | }, 30 | { 31 | name: 'session', 32 | }, 33 | ) 34 | -------------------------------------------------------------------------------- /src/plugins/external/sse.ts: -------------------------------------------------------------------------------- 1 | import { FastifySSEPlugin } from 'fastify-sse-v2' 2 | 3 | export const autoConfig = { 4 | // Set plugin options here 5 | } 6 | 7 | /** 8 | * This plugin adds SSE 9 | * 10 | * @see {@link https://github.com/mpetrunic/fastify-sse-v2} 11 | */ 12 | export default FastifySSEPlugin 13 | -------------------------------------------------------------------------------- /src/routes/autohooks.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyInstance } from 'fastify' 2 | import { getAuthBypassStatus } from '@utils/auth-bypass.js' 3 | 4 | export default async function (fastify: FastifyInstance) { 5 | fastify.addHook('onRequest', async (request, reply) => { 6 | const publicPaths = [ 7 | '/v1/users/login', 8 | '/v1/users/create-admin', 9 | '/v1/notifications/webhook', 10 | ] 11 | 12 | // Skip authentication for public paths 13 | if (publicPaths.some((path) => request.url.startsWith(path))) { 14 | return 15 | } 16 | 17 | // Check if auth should be bypassed based on config and IP 18 | const { shouldBypass, isAuthDisabled, isLocalBypass } = getAuthBypassStatus( 19 | fastify, 20 | request, 21 | ) 22 | 23 | if (shouldBypass) { 24 | if (isAuthDisabled) { 25 | fastify.log.debug( 26 | { url: request.url }, 27 | 'Authentication disabled globally', 28 | ) 29 | } else if (isLocalBypass) { 30 | fastify.log.debug( 31 | { ip: request.ip, url: request.url }, 32 | 'Bypassing authentication for local address', 33 | ) 34 | } 35 | return 36 | } 37 | 38 | // Regular authentication check for all other cases 39 | if (!request.session.user) { 40 | reply.unauthorized('You must be authenticated to access this route.') 41 | } 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /src/routes/v1/plex/generate-rss-feeds.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' 2 | import { rssFeedsSchema } from '@schemas/plex/generate-rss-feeds.schema.js' 3 | 4 | export const generateRssFeedsRoute: FastifyPluginAsyncZod = async ( 5 | fastify, 6 | _opts, 7 | ) => { 8 | fastify.route({ 9 | method: 'GET', 10 | url: '/generate-rss-feeds', 11 | schema: rssFeedsSchema, 12 | handler: async (_request, reply) => { 13 | try { 14 | const response = await fastify.plexWatchlist.generateAndSaveRssFeeds() 15 | return reply.send(response) 16 | } catch (err) { 17 | fastify.log.error(err) 18 | return reply.code(500).send({ error: 'Unable to fetch watchlist URLs' }) 19 | } 20 | }, 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /src/routes/v1/plex/get-genres.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyPluginAsync } from 'fastify' 2 | import type { z } from 'zod' 3 | import { 4 | WatchlistGenresResponseSchema, 5 | WatchlistGenresErrorSchema, 6 | } from '@schemas/plex/get-genres.schema.js' 7 | 8 | export const getGenresRoute: FastifyPluginAsync = async (fastify) => { 9 | fastify.get<{ 10 | Reply: z.infer 11 | }>( 12 | '/genres', 13 | { 14 | schema: { 15 | summary: 'Get watchlist genres', 16 | operationId: 'getWatchlistGenres', 17 | description: 'Retrieve all genres from watchlist items', 18 | response: { 19 | 200: WatchlistGenresResponseSchema, 20 | 500: WatchlistGenresErrorSchema, 21 | }, 22 | tags: ['Plex'], 23 | }, 24 | }, 25 | async (request, reply) => { 26 | try { 27 | await fastify.db.syncGenresFromWatchlist() 28 | const genres = await fastify.db.getAllGenres() 29 | 30 | const response: z.infer = { 31 | success: true, 32 | genres: genres.map((genre) => genre.name), 33 | } 34 | 35 | reply.status(200) 36 | return response 37 | } catch (err) { 38 | fastify.log.error('Error fetching watchlist genres:', err) 39 | return reply.internalServerError('Unable to fetch watchlist genres') 40 | } 41 | }, 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /src/routes/v1/plex/index.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' 2 | import { selfWatchlistTokenRoute } from '@routes/v1/plex/self-watchlist-token.js' 3 | import { othersWatchlistTokenRoute } from '@routes/v1/plex/others-watchlist-token.js' 4 | import { pingRoute } from '@routes/v1/plex/ping.js' 5 | import { generateRssFeedsRoute } from '@routes/v1/plex/generate-rss-feeds.js' 6 | import { rssWatchlistRoute } from '@routes/v1/plex/parse-rss.js' 7 | import { getGenresRoute } from '@routes/v1/plex/get-genres.js' 8 | import { configureNotificationsRoute } from '@routes/v1/plex/configure-notifications.js' 9 | import { removeNotificationsRoute } from '@routes/v1/plex/remove-notifications.js' 10 | import { getNotificationStatusRoute } from '@routes/v1/plex/get-notification-status.js' 11 | import { discoverServersRoute } from '@routes/v1/plex/discover-servers.js' 12 | 13 | const plexPlugin: FastifyPluginAsyncZod = async (fastify) => { 14 | await fastify.register(selfWatchlistTokenRoute) 15 | await fastify.register(othersWatchlistTokenRoute) 16 | await fastify.register(pingRoute) 17 | await fastify.register(generateRssFeedsRoute) 18 | await fastify.register(rssWatchlistRoute) 19 | await fastify.register(getGenresRoute) 20 | await fastify.register(configureNotificationsRoute) 21 | await fastify.register(removeNotificationsRoute) 22 | await fastify.register(getNotificationStatusRoute) 23 | await fastify.register(discoverServersRoute) 24 | } 25 | 26 | export default plexPlugin 27 | -------------------------------------------------------------------------------- /src/routes/v1/plex/others-watchlist-token.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' 2 | import { othersWatchlistSchema } from '@schemas/plex/others-watchlist-token.schema.js' 3 | 4 | export const othersWatchlistTokenRoute: FastifyPluginAsyncZod = async ( 5 | fastify, 6 | _opts, 7 | ) => { 8 | fastify.route({ 9 | method: 'GET', 10 | url: '/others-watchlist-token', 11 | schema: othersWatchlistSchema, 12 | handler: async (_request, reply) => { 13 | try { 14 | const response = await fastify.plexWatchlist.getOthersWatchlists() 15 | return reply.send(response) 16 | } catch (err) { 17 | fastify.log.error(err) 18 | reply 19 | .code(500) 20 | .send({ error: "Unable to fetch others' watchlist items" }) 21 | } 22 | }, 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /src/routes/v1/plex/parse-rss.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' 2 | import { rssWatchlistSchema } from '@schemas/plex/parse-rss-feeds.schema.js' 3 | 4 | export const rssWatchlistRoute: FastifyPluginAsyncZod = async ( 5 | fastify, 6 | _opts, 7 | ) => { 8 | fastify.route({ 9 | method: 'GET', 10 | url: '/rss-watchlist', 11 | schema: rssWatchlistSchema, 12 | handler: async (_request, reply) => { 13 | try { 14 | const response = await fastify.plexWatchlist.processRssWatchlists() 15 | return reply.send(response) 16 | } catch (err) { 17 | fastify.log.error(err) 18 | return reply 19 | .code(500) 20 | .send({ error: 'Unable to fetch RSS watchlist items' }) 21 | } 22 | }, 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /src/routes/v1/plex/ping.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyPluginAsync } from 'fastify' 2 | import type { z } from 'zod' 3 | import { 4 | PingSuccessSchema, 5 | PingErrorSchema, 6 | } from '@schemas/plex/ping.schema.js' 7 | 8 | export const pingRoute: FastifyPluginAsync = async (fastify) => { 9 | fastify.get<{ 10 | Reply: z.infer 11 | }>( 12 | '/ping', 13 | { 14 | schema: { 15 | summary: 'Test Plex server connection', 16 | operationId: 'pingPlex', 17 | description: 'Verifies connectivity to the configured Plex server', 18 | response: { 19 | 200: PingSuccessSchema, 20 | 500: PingErrorSchema, 21 | }, 22 | tags: ['Plex'], 23 | }, 24 | }, 25 | async (_request, reply) => { 26 | try { 27 | const success = await fastify.plexWatchlist.pingPlex() 28 | return { success } 29 | } catch (error) { 30 | fastify.log.error(error) 31 | return reply.internalServerError('Failed to connect to Plex') 32 | } 33 | }, 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/routes/v1/plex/self-watchlist-token.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' 2 | import { selfWatchlistSchema } from '@schemas/plex/self-watchlist-token.schema.js' 3 | 4 | export const selfWatchlistTokenRoute: FastifyPluginAsyncZod = async ( 5 | fastify, 6 | _opts, 7 | ) => { 8 | fastify.route({ 9 | method: 'GET', 10 | url: '/self-watchlist-token', 11 | schema: selfWatchlistSchema, 12 | handler: async (_request, reply) => { 13 | try { 14 | const response = await fastify.plexWatchlist.getSelfWatchlist() 15 | return reply.send(response) 16 | } catch (err) { 17 | fastify.log.error(err) 18 | reply.code(500).send({ error: 'Unable to fetch watchlist items' }) 19 | } 20 | }, 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /src/schemas/auth/admin-user.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const CreateAdminResponseSchema = z.object({ 4 | success: z.boolean(), 5 | message: z.string(), 6 | }) 7 | 8 | export const CreateAdminErrorSchema = z.object({ 9 | message: z.string(), 10 | }) 11 | 12 | export const CreateAdminSchema = z.object({ 13 | email: z.string().email(), 14 | username: z.string().min(3).max(255), 15 | password: z.string().min(8, 'Password must be at least 8 characters'), 16 | }) 17 | 18 | export type CreateAdminResponse = z.infer 19 | export type CreateAdminError = z.infer 20 | export type CreateAdmin = z.infer 21 | -------------------------------------------------------------------------------- /src/schemas/auth/auth.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export interface AdminUser { 4 | id: number 5 | username: string 6 | email: string 7 | password: string 8 | role: string 9 | } 10 | 11 | export const CredentialsSchema = z.object({ 12 | email: z.string().email().max(255), 13 | password: z 14 | .string() 15 | .min(8, 'Password must be at least 8 characters') 16 | .max(255), 17 | }) 18 | 19 | export type Credentials = z.infer 20 | 21 | export const AuthSchema = CredentialsSchema.omit({ password: true }).extend({ 22 | id: z.number(), 23 | username: z.string().min(1).max(255), 24 | role: z.string(), 25 | }) 26 | 27 | export type Auth = z.infer 28 | -------------------------------------------------------------------------------- /src/schemas/auth/login.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const LoginResponseSchema = z.object({ 4 | success: z.boolean(), 5 | message: z.string().optional(), 6 | username: z.string(), 7 | redirectTo: z.string().optional(), 8 | }) 9 | 10 | export const LoginErrorSchema = z.object({ 11 | message: z.string(), 12 | }) 13 | 14 | export const PasswordSchema = z 15 | .string() 16 | .min(8, 'Password must be at least 8 characters') 17 | 18 | export const CredentialsSchema = z.object({ 19 | email: z.string().email().max(255), 20 | password: PasswordSchema, 21 | }) 22 | 23 | export type LoginResponse = z.infer 24 | export type LoginError = z.infer 25 | export type Credentials = z.infer 26 | -------------------------------------------------------------------------------- /src/schemas/auth/logout.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const LogoutBodySchema = z.object({}).strict() 4 | 5 | export const LogoutResponseSchema = z.object({ 6 | success: z.boolean(), 7 | message: z.string(), 8 | }) 9 | 10 | export type LogoutBody = z.infer 11 | export type LogoutResponse = z.infer 12 | -------------------------------------------------------------------------------- /src/schemas/auth/users.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | const PasswordSchema = z 4 | .string() 5 | .min(8, 'Password must be at least 8 characters') 6 | 7 | export const UpdateCredentialsSchema = z.object({ 8 | currentPassword: PasswordSchema, 9 | newPassword: PasswordSchema, 10 | }) 11 | 12 | export type UpdateCredentials = z.infer 13 | -------------------------------------------------------------------------------- /src/schemas/content-router/constants.ts: -------------------------------------------------------------------------------- 1 | // Series type constants for backend 2 | export const SERIES_TYPES = ['standard', 'anime', 'daily'] as const 3 | export type SeriesType = (typeof SERIES_TYPES)[number] 4 | -------------------------------------------------------------------------------- /src/schemas/notifications/discord-control.schema.ts: -------------------------------------------------------------------------------- 1 | // File: src/schemas/notifications/discord-control.schema.ts 2 | import { z } from 'zod' 3 | 4 | // Schema for Discord bot status responses 5 | export const DiscordBotResponseSchema = z.object({ 6 | success: z.boolean(), 7 | status: z.enum(['running', 'stopped', 'starting', 'stopping', 'unknown']), 8 | message: z.string().optional(), 9 | }) 10 | 11 | // Schema for webhook validation requests 12 | export const WebhookValidationRequestSchema = z.object({ 13 | webhookUrls: z.string().min(1, 'Webhook URLs are required'), 14 | }) 15 | 16 | // Schema for webhook validation responses 17 | export const WebhookValidationResponseSchema = z.object({ 18 | success: z.boolean(), 19 | valid: z.boolean(), 20 | urls: z.array( 21 | z.object({ 22 | url: z.string(), 23 | valid: z.boolean(), 24 | error: z.string().optional(), 25 | }), 26 | ), 27 | duplicateCount: z.number().optional(), 28 | message: z.string().optional(), 29 | }) 30 | 31 | // Common error schema 32 | export const ErrorSchema = z.object({ 33 | message: z.string(), 34 | }) 35 | 36 | // Type exports 37 | export type DiscordBotResponse = z.infer 38 | export type WebhookValidationRequest = z.infer< 39 | typeof WebhookValidationRequestSchema 40 | > 41 | export type WebhookValidationResponse = z.infer< 42 | typeof WebhookValidationResponseSchema 43 | > 44 | export type Error = z.infer 45 | -------------------------------------------------------------------------------- /src/schemas/plex/configure-notifications.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | const PlexNotificationConfigSchema = z.object({ 4 | plexToken: z.string().min(1, 'Plex token is required'), 5 | plexHost: z.string().min(1, 'Plex host is required'), 6 | plexPort: z.number().int().positive().default(32400), 7 | useSsl: z.boolean().default(false), 8 | }) 9 | 10 | const PlexInstanceResultSchema = z.object({ 11 | id: z.number(), 12 | name: z.string(), 13 | success: z.boolean(), 14 | message: z.string(), 15 | }) 16 | 17 | const PlexNotificationResponseSchema = z.object({ 18 | success: z.boolean(), 19 | message: z.string(), 20 | results: z.object({ 21 | radarr: z.array(PlexInstanceResultSchema), 22 | sonarr: z.array(PlexInstanceResultSchema), 23 | }), 24 | }) 25 | 26 | const ErrorSchema = z.object({ 27 | error: z.string(), 28 | }) 29 | 30 | export const plexConfigNotificationSchema = { 31 | summary: 'Configure Plex notifications', 32 | operationId: 'configurePlexNotifications', 33 | description: 34 | 'Configure Plex webhook notifications for Radarr and Sonarr instances', 35 | tags: ['Plex'], 36 | body: PlexNotificationConfigSchema, 37 | response: { 38 | 200: PlexNotificationResponseSchema, 39 | 400: ErrorSchema, 40 | 500: ErrorSchema, 41 | }, 42 | } 43 | 44 | export type PlexNotificationConfig = z.infer< 45 | typeof PlexNotificationConfigSchema 46 | > 47 | export type PlexNotificationResponse = z.infer< 48 | typeof PlexNotificationResponseSchema 49 | > 50 | export type PlexInstanceResult = z.infer 51 | export type Error = z.infer 52 | -------------------------------------------------------------------------------- /src/schemas/plex/discover-servers.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | // Schema for the token request 4 | export const PlexTokenSchema = z.object({ 5 | plexToken: z.string().min(1, 'Plex token is required'), 6 | }) 7 | 8 | // Schema for a single server in the response 9 | export const PlexServerSchema = z.object({ 10 | name: z.string(), 11 | host: z.string(), 12 | port: z.number(), 13 | useSsl: z.boolean(), 14 | local: z.boolean(), 15 | description: z.string().optional(), 16 | }) 17 | 18 | // Schema for server discovery response 19 | export const PlexServerResponseSchema = z.object({ 20 | success: z.boolean(), 21 | message: z.string().optional(), 22 | servers: z.array(PlexServerSchema), 23 | }) 24 | 25 | // Schema for error responses - matches Fastify sensible's error format 26 | export const PlexServerErrorSchema = z.object({ 27 | statusCode: z.number(), 28 | error: z.string(), 29 | message: z.string(), 30 | }) 31 | 32 | // Type exports 33 | export type PlexTokenRequest = z.infer 34 | export type PlexServer = z.infer 35 | export type PlexServerResponse = z.infer 36 | export type PlexServerError = z.infer 37 | -------------------------------------------------------------------------------- /src/schemas/plex/generate-rss-feeds.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | const RssFeedsSuccessSchema = z.object({ 4 | self: z.string(), 5 | friends: z.string(), 6 | }) 7 | 8 | const RssFeedsErrorSchema = z.object({ 9 | error: z.string(), 10 | }) 11 | 12 | const RssFeedsResponseSchema = z.union([ 13 | RssFeedsSuccessSchema, 14 | RssFeedsErrorSchema, 15 | ]) 16 | 17 | export const rssFeedsSchema = { 18 | summary: 'Generate RSS feeds', 19 | operationId: 'generateRssFeeds', 20 | description: 'Generate RSS feed URLs for Plex watchlists', 21 | tags: ['Plex'], 22 | response: { 23 | 200: RssFeedsResponseSchema, 24 | }, 25 | } 26 | 27 | export type RssFeedsResponse = z.infer 28 | export type RssFeedsSuccess = z.infer 29 | export type RssFeedsError = z.infer 30 | -------------------------------------------------------------------------------- /src/schemas/plex/get-genres.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const WatchlistGenresResponseSchema = z.object({ 4 | success: z.boolean(), 5 | genres: z.array(z.string()), 6 | }) 7 | 8 | export const WatchlistGenresErrorSchema = z.object({ 9 | error: z.string(), 10 | }) 11 | 12 | export type WatchlistGenresResponse = z.infer< 13 | typeof WatchlistGenresResponseSchema 14 | > 15 | export type WatchlistGenresError = z.infer 16 | -------------------------------------------------------------------------------- /src/schemas/plex/get-notification-status.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | const PlexInstanceResultSchema = z.object({ 4 | id: z.number(), 5 | name: z.string(), 6 | success: z.boolean(), 7 | message: z.string(), 8 | }) 9 | 10 | const PlexNotificationStatusResponseSchema = z.object({ 11 | success: z.boolean(), 12 | message: z.string(), 13 | results: z.object({ 14 | radarr: z.array(PlexInstanceResultSchema), 15 | sonarr: z.array(PlexInstanceResultSchema), 16 | }), 17 | }) 18 | 19 | const ErrorSchema = z.object({ 20 | error: z.string(), 21 | }) 22 | 23 | export const plexGetNotificationStatusSchema = { 24 | summary: 'Get Plex notification status', 25 | operationId: 'getPlexNotificationStatus', 26 | description: 27 | 'Check if Plex notifications are configured for Radarr and Sonarr instances', 28 | tags: ['Plex'], 29 | response: { 30 | 200: PlexNotificationStatusResponseSchema, 31 | 400: ErrorSchema, 32 | 500: ErrorSchema, 33 | }, 34 | } 35 | 36 | export type PlexNotificationStatusResponse = z.infer< 37 | typeof PlexNotificationStatusResponseSchema 38 | > 39 | export type PlexInstanceResult = z.infer 40 | export type Error = z.infer 41 | -------------------------------------------------------------------------------- /src/schemas/plex/others-watchlist-token.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | const WatchlistItemSchema = z.object({ 4 | title: z.string(), 5 | plexKey: z.string().optional(), 6 | type: z.string(), 7 | thumb: z.string().optional(), 8 | guids: z.array(z.string()), 9 | genres: z.array(z.string()), 10 | }) 11 | 12 | const UserSchema = z.object({ 13 | watchlistId: z.string(), 14 | username: z.string(), 15 | }) 16 | 17 | const OthersWatchlistSuccessSchema = z.object({ 18 | total: z.number(), 19 | users: z.array( 20 | z.object({ 21 | user: UserSchema, 22 | watchlist: z.array(WatchlistItemSchema), 23 | }), 24 | ), 25 | }) 26 | 27 | const OthersWatchlistErrorSchema = z.object({ 28 | error: z.string(), 29 | }) 30 | 31 | const OthersWatchlistResponseSchema = z.union([ 32 | OthersWatchlistSuccessSchema, 33 | OthersWatchlistErrorSchema, 34 | ]) 35 | 36 | export const othersWatchlistSchema = { 37 | summary: 'Get others watchlist tokens', 38 | operationId: 'getOthersWatchlistTokens', 39 | description: 'Retrieve watchlist items from other Plex users', 40 | tags: ['Plex'], 41 | response: { 42 | 200: OthersWatchlistResponseSchema, 43 | }, 44 | } 45 | 46 | export type OthersWatchlistResponse = z.infer< 47 | typeof OthersWatchlistResponseSchema 48 | > 49 | export type OthersWatchlistSuccess = z.infer< 50 | typeof OthersWatchlistSuccessSchema 51 | > 52 | export type OthersWatchlistError = z.infer 53 | export type WatchlistItem = z.infer 54 | export type User = z.infer 55 | -------------------------------------------------------------------------------- /src/schemas/plex/parse-rss-feeds.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | const WatchlistItemSchema = z.object({ 4 | title: z.string(), 5 | plexKey: z.string(), 6 | type: z.string(), 7 | thumb: z.string(), 8 | guids: z.array(z.string()), 9 | genres: z.array(z.string()), 10 | status: z.literal('pending'), 11 | }) 12 | 13 | const UserSchema = z.object({ 14 | watchlistId: z.string(), 15 | username: z.string(), 16 | userId: z.number(), 17 | }) 18 | 19 | const WatchlistSectionSchema = z.object({ 20 | total: z.number(), 21 | users: z.array( 22 | z.object({ 23 | user: UserSchema, 24 | watchlist: z.array(WatchlistItemSchema), 25 | }), 26 | ), 27 | }) 28 | 29 | const RssWatchlistSuccessSchema = z.object({ 30 | self: WatchlistSectionSchema, 31 | friends: WatchlistSectionSchema, 32 | }) 33 | 34 | const RssWatchlistErrorSchema = z.object({ 35 | error: z.string(), 36 | }) 37 | 38 | const RssWatchlistResponseSchema = z.union([ 39 | RssWatchlistSuccessSchema, 40 | RssWatchlistErrorSchema, 41 | ]) 42 | 43 | export const rssWatchlistSchema = { 44 | summary: 'Parse RSS watchlists', 45 | operationId: 'parseRssWatchlists', 46 | description: 'Parse and process RSS feed watchlist items', 47 | tags: ['Plex'], 48 | response: { 49 | 200: RssWatchlistResponseSchema, 50 | }, 51 | } 52 | 53 | export type RssWatchlistResponse = z.infer 54 | export type RssWatchlistSuccess = z.infer 55 | export type RssWatchlistError = z.infer 56 | export type WatchlistItem = z.infer 57 | export type User = z.infer 58 | -------------------------------------------------------------------------------- /src/schemas/plex/ping.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const PingSuccessSchema = z.object({ 4 | success: z.boolean(), 5 | }) 6 | 7 | export const PingErrorSchema = z.object({ 8 | success: z.literal(false), 9 | message: z.string(), 10 | }) 11 | 12 | export type PingSuccess = z.infer 13 | export type PingError = z.infer 14 | -------------------------------------------------------------------------------- /src/schemas/plex/remove-notifications.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | const PlexInstanceResultSchema = z.object({ 4 | id: z.number(), 5 | name: z.string(), 6 | success: z.boolean(), 7 | message: z.string(), 8 | }) 9 | 10 | const PlexRemoveNotificationResponseSchema = z.object({ 11 | success: z.boolean(), 12 | message: z.string(), 13 | results: z.object({ 14 | radarr: z.array(PlexInstanceResultSchema), 15 | sonarr: z.array(PlexInstanceResultSchema), 16 | }), 17 | }) 18 | 19 | const ErrorSchema = z.object({ 20 | error: z.string(), 21 | }) 22 | 23 | export const plexRemoveNotificationSchema = { 24 | summary: 'Remove Plex notifications', 25 | operationId: 'removePlexNotifications', 26 | description: 27 | 'Remove Plex webhook notifications from Radarr and Sonarr instances', 28 | tags: ['Plex'], 29 | response: { 30 | 200: PlexRemoveNotificationResponseSchema, 31 | 400: ErrorSchema, 32 | 500: ErrorSchema, 33 | }, 34 | } 35 | 36 | export type PlexRemoveNotificationResponse = z.infer< 37 | typeof PlexRemoveNotificationResponseSchema 38 | > 39 | export type PlexInstanceResult = z.infer 40 | export type Error = z.infer 41 | -------------------------------------------------------------------------------- /src/schemas/plex/self-watchlist-token.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | const WatchlistItemSchema = z.object({ 4 | title: z.string(), 5 | plexKey: z.string().optional(), 6 | type: z.string(), 7 | thumb: z.string().optional(), 8 | guids: z.array(z.string()), 9 | genres: z.array(z.string()), 10 | }) 11 | 12 | const UserSchema = z.object({ 13 | watchlistId: z.string(), 14 | username: z.string(), 15 | }) 16 | 17 | const UserWatchlistSchema = z.object({ 18 | user: UserSchema, 19 | watchlist: z.array(WatchlistItemSchema), 20 | }) 21 | 22 | const SelfWatchlistSuccessSchema = z.object({ 23 | total: z.number(), 24 | users: z.array(UserWatchlistSchema), 25 | }) 26 | 27 | const SelfWatchlistErrorSchema = z.object({ 28 | error: z.string(), 29 | }) 30 | 31 | const SelfWatchlistResponseSchema = z.union([ 32 | SelfWatchlistSuccessSchema, 33 | SelfWatchlistErrorSchema, 34 | ]) 35 | 36 | export const selfWatchlistSchema = { 37 | summary: 'Get self watchlist items', 38 | operationId: 'getSelfWatchlistItems', 39 | description: 'Retrieve the current user watchlist items from Plex', 40 | tags: ['Plex'], 41 | response: { 42 | 200: SelfWatchlistResponseSchema, 43 | }, 44 | } 45 | 46 | export type SelfWatchlistResponse = z.infer 47 | export type SelfWatchlistSuccess = z.infer 48 | export type SelfWatchlistError = z.infer 49 | export type WatchlistItem = z.infer 50 | export type User = z.infer 51 | export type UserWatchlist = z.infer 52 | -------------------------------------------------------------------------------- /src/schemas/radarr/create-tag.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const CreateTagBodySchema = z.object({ 4 | instanceId: z.number().int().positive('Instance ID is required'), 5 | label: z.string().min(1, 'Tag label is required'), 6 | }) 7 | 8 | export const CreateTagResponseSchema = z.object({ 9 | id: z.number(), 10 | label: z.string(), 11 | }) 12 | 13 | export const ErrorSchema = z.object({ 14 | message: z.string(), 15 | }) 16 | 17 | export type CreateTagBody = z.infer 18 | export type CreateTagResponse = z.infer 19 | export type Error = z.infer 20 | -------------------------------------------------------------------------------- /src/schemas/radarr/get-quality-profiles.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const QuerystringSchema = z.object({ 4 | instanceId: z.string(), 5 | }) 6 | 7 | export const InstanceInfoSchema = z.object({ 8 | id: z.number(), 9 | name: z.string(), 10 | baseUrl: z.string(), 11 | }) 12 | 13 | export const QualityProfilesResponseSchema = z.object({ 14 | success: z.boolean(), 15 | instance: InstanceInfoSchema, 16 | qualityProfiles: z.array(z.any()), 17 | }) 18 | 19 | export const ErrorSchema = z.object({ 20 | message: z.string(), 21 | }) 22 | 23 | export type Querystring = z.infer 24 | export type InstanceInfo = z.infer 25 | export type QualityProfilesResponse = z.infer< 26 | typeof QualityProfilesResponseSchema 27 | > 28 | export type Error = z.infer 29 | -------------------------------------------------------------------------------- /src/schemas/radarr/get-root-folders.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const QuerystringSchema = z.object({ 4 | instanceId: z.string(), 5 | }) 6 | 7 | export const InstanceInfoSchema = z.object({ 8 | id: z.number(), 9 | name: z.string(), 10 | baseUrl: z.string(), 11 | }) 12 | 13 | export const RootFoldersResponseSchema = z.object({ 14 | success: z.boolean(), 15 | instance: InstanceInfoSchema, 16 | rootFolders: z.array(z.any()), 17 | }) 18 | 19 | export const ErrorSchema = z.object({ 20 | message: z.string(), 21 | }) 22 | 23 | export type Querystring = z.infer 24 | export type InstanceInfo = z.infer 25 | export type RootFoldersResponse = z.infer 26 | export type Error = z.infer 27 | -------------------------------------------------------------------------------- /src/schemas/radarr/get-tags.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { InstanceInfoSchema } from './get-quality-profiles.schema.js' 3 | 4 | export const TagsResponseSchema = z.object({ 5 | success: z.boolean(), 6 | instance: InstanceInfoSchema, 7 | tags: z.array( 8 | z.object({ 9 | id: z.number(), 10 | label: z.string(), 11 | }), 12 | ), 13 | }) 14 | 15 | export type TagsResponse = z.infer 16 | -------------------------------------------------------------------------------- /src/schemas/radarr/test-connection.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const TestConnectionQuerySchema = z.object({ 4 | baseUrl: z.string().url('Invalid URL format'), 5 | apiKey: z.string().min(1, 'API key is required'), 6 | }) 7 | 8 | export const TestConnectionResponseSchema = z.object({ 9 | success: z.boolean(), 10 | message: z.string(), 11 | }) 12 | 13 | export const ErrorSchema = z.object({ 14 | message: z.string(), 15 | }) 16 | 17 | export type TestConnectionQuery = z.infer 18 | export type TestConnectionResponse = z.infer< 19 | typeof TestConnectionResponseSchema 20 | > 21 | -------------------------------------------------------------------------------- /src/schemas/sonarr/create-tag.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const CreateTagBodySchema = z.object({ 4 | instanceId: z.number().int().positive('Instance ID is required'), 5 | label: z.string().min(1, 'Tag label is required'), 6 | }) 7 | 8 | export const CreateTagResponseSchema = z.object({ 9 | id: z.number(), 10 | label: z.string(), 11 | }) 12 | 13 | export const ErrorSchema = z.object({ 14 | message: z.string(), 15 | }) 16 | 17 | export type CreateTagBody = z.infer 18 | export type CreateTagResponse = z.infer 19 | export type Error = z.infer 20 | -------------------------------------------------------------------------------- /src/schemas/sonarr/get-quality-profiles.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const QuerystringSchema = z.object({ 4 | instanceId: z.string(), 5 | }) 6 | 7 | export const InstanceInfoSchema = z.object({ 8 | id: z.number(), 9 | name: z.string(), 10 | baseUrl: z.string(), 11 | }) 12 | 13 | export const QualityProfilesResponseSchema = z.object({ 14 | success: z.boolean(), 15 | instance: InstanceInfoSchema, 16 | qualityProfiles: z.array(z.any()), 17 | }) 18 | 19 | export const ErrorSchema = z.object({ 20 | message: z.string(), 21 | }) 22 | 23 | export type Querystring = z.infer 24 | export type InstanceInfo = z.infer 25 | export type QualityProfilesResponse = z.infer< 26 | typeof QualityProfilesResponseSchema 27 | > 28 | export type Error = z.infer 29 | -------------------------------------------------------------------------------- /src/schemas/sonarr/get-root-folders.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const QuerystringSchema = z.object({ 4 | instanceId: z.string(), 5 | }) 6 | 7 | export const InstanceInfoSchema = z.object({ 8 | id: z.number(), 9 | name: z.string(), 10 | baseUrl: z.string(), 11 | }) 12 | 13 | export const RootFoldersResponseSchema = z.object({ 14 | success: z.boolean(), 15 | instance: InstanceInfoSchema, 16 | rootFolders: z.array(z.any()), 17 | }) 18 | 19 | export const ErrorSchema = z.object({ 20 | message: z.string(), 21 | }) 22 | 23 | export type Querystring = z.infer 24 | export type InstanceInfo = z.infer 25 | export type RootFoldersResponse = z.infer 26 | export type Error = z.infer 27 | -------------------------------------------------------------------------------- /src/schemas/sonarr/get-tags.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { InstanceInfoSchema } from './get-quality-profiles.schema.js' 3 | 4 | export const TagsResponseSchema = z.object({ 5 | success: z.boolean(), 6 | instance: InstanceInfoSchema, 7 | tags: z.array( 8 | z.object({ 9 | id: z.number(), 10 | label: z.string(), 11 | }), 12 | ), 13 | }) 14 | 15 | export type TagsResponse = z.infer 16 | -------------------------------------------------------------------------------- /src/schemas/sonarr/test-connection.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const TestConnectionQuerySchema = z.object({ 4 | baseUrl: z.string().url('Invalid URL format'), 5 | apiKey: z.string().min(1, 'API key is required'), 6 | }) 7 | 8 | export const TestConnectionResponseSchema = z.object({ 9 | success: z.boolean(), 10 | message: z.string(), 11 | }) 12 | 13 | export const ErrorSchema = z.object({ 14 | message: z.string(), 15 | }) 16 | 17 | export type TestConnectionQuery = z.infer 18 | export type TestConnectionResponse = z.infer< 19 | typeof TestConnectionResponseSchema 20 | > 21 | -------------------------------------------------------------------------------- /src/schemas/sync/sync.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const ErrorSchema = z.object({ 4 | message: z.string(), 5 | }) 6 | 7 | export const SyncInstanceResultSchema = z.object({ 8 | itemsCopied: z.number(), 9 | message: z.string(), 10 | }) 11 | 12 | export const SyncAllInstancesResultSchema = z.object({ 13 | radarr: z.array( 14 | z.object({ 15 | id: z.number(), 16 | name: z.string(), 17 | itemsCopied: z.number(), 18 | }), 19 | ), 20 | sonarr: z.array( 21 | z.object({ 22 | id: z.number(), 23 | name: z.string(), 24 | itemsCopied: z.number(), 25 | }), 26 | ), 27 | message: z.string(), 28 | }) 29 | 30 | export const InstanceIdParamsSchema = z.object({ 31 | instanceId: z.coerce.number().int().positive(), 32 | }) 33 | 34 | export const InstanceTypeQuerySchema = z.object({ 35 | type: z.enum(['radarr', 'sonarr']), 36 | }) 37 | -------------------------------------------------------------------------------- /src/schemas/tautulli/tautulli.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | // Test connection schemas 4 | export const TestConnectionBodySchema = z.object({ 5 | tautulliUrl: z.string().url('Invalid URL format'), 6 | tautulliApiKey: z.string().min(1, 'API key is required'), 7 | }) 8 | 9 | export const TestConnectionResponseSchema = z.object({ 10 | success: z.boolean(), 11 | message: z.string(), 12 | }) 13 | 14 | // Sync notifiers schemas 15 | export const SyncNotifiersResponseSchema = z.object({ 16 | success: z.boolean(), 17 | message: z.string(), 18 | eligibleUsers: z.number(), 19 | }) 20 | 21 | // Error schema 22 | export const ErrorSchema = z.object({ 23 | success: z.boolean(), 24 | message: z.string(), 25 | }) 26 | 27 | // Type exports 28 | export type TestConnectionBody = z.infer 29 | export type TestConnectionResponse = z.infer< 30 | typeof TestConnectionResponseSchema 31 | > 32 | export type SyncNotifiersResponse = z.infer 33 | -------------------------------------------------------------------------------- /src/schemas/users/users-list.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | const UserBaseSchema = z.object({ 4 | id: z.number(), 5 | name: z.string(), 6 | apprise: z.string().nullable(), 7 | alias: z.string().nullable(), 8 | discord_id: z.string().nullable(), 9 | notify_apprise: z.boolean(), 10 | notify_discord: z.boolean(), 11 | notify_tautulli: z.boolean(), 12 | can_sync: z.boolean(), 13 | created_at: z.string(), 14 | updated_at: z.string(), 15 | }) 16 | 17 | const UserWithCountSchema = UserBaseSchema.extend({ 18 | watchlist_count: z.number(), 19 | }) 20 | 21 | export const UserListResponseSchema = z.object({ 22 | success: z.boolean(), 23 | message: z.string(), 24 | users: z.array(UserBaseSchema), 25 | }) 26 | 27 | export const UserListWithCountsResponseSchema = z.object({ 28 | success: z.boolean(), 29 | message: z.string(), 30 | users: z.array(UserWithCountSchema), 31 | }) 32 | 33 | export const UserErrorSchema = z.object({ 34 | success: z.boolean(), 35 | message: z.string(), 36 | }) 37 | 38 | export type UserListResponse = z.infer 39 | export type UserListWithCountsResponse = z.infer< 40 | typeof UserListWithCountsResponseSchema 41 | > 42 | export type UserError = z.infer 43 | -------------------------------------------------------------------------------- /src/schemas/watchlist-workflow/watchlist-workflow.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | // Schema for Watchlist workflow status responses 4 | export const WatchlistWorkflowResponseSchema = z.object({ 5 | success: z.boolean(), 6 | status: z.enum(['running', 'stopped', 'starting', 'stopping']), 7 | message: z.string().optional(), 8 | }) 9 | 10 | // Common error schema 11 | export const ErrorSchema = z.object({ 12 | message: z.string(), 13 | }) 14 | 15 | // Type exports 16 | export type WatchlistWorkflowResponse = z.infer< 17 | typeof WatchlistWorkflowResponseSchema 18 | > 19 | export type Error = z.infer 20 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import Fastify from 'fastify' 2 | import fp from 'fastify-plugin' 3 | import closeWithGrace from 'close-with-grace' 4 | import serviceApp from './app.js' 5 | import { 6 | createLoggerConfig, 7 | validLogLevels, 8 | LogDestination, 9 | } from '@utils/logger.js' 10 | import type { LevelWithSilent } from 'pino' 11 | 12 | async function init() { 13 | const app = Fastify({ 14 | logger: createLoggerConfig(), 15 | ajv: { 16 | customOptions: { 17 | coerceTypes: 'array', 18 | removeAdditional: 'all', 19 | }, 20 | }, 21 | pluginTimeout: 60000, 22 | }) 23 | 24 | await app.register(fp(serviceApp)) 25 | await app.ready() 26 | 27 | const configLogLevel = app.config.logLevel 28 | if ( 29 | configLogLevel && 30 | validLogLevels.includes(configLogLevel as LevelWithSilent) 31 | ) { 32 | app.log.level = configLogLevel as LevelWithSilent 33 | } 34 | 35 | closeWithGrace( 36 | { 37 | delay: app.config.closeGraceDelay, 38 | }, 39 | async ({ err }) => { 40 | if (err != null) { 41 | app.log.error(err) 42 | } 43 | await app.close() 44 | }, 45 | ) 46 | 47 | try { 48 | await app.listen({ 49 | port: app.config.port, 50 | host: '0.0.0.0', 51 | }) 52 | } catch (err) { 53 | app.log.error(err) 54 | process.exit(1) 55 | } 56 | } 57 | 58 | init() 59 | -------------------------------------------------------------------------------- /src/services/event-emitter.service.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'node:events' 2 | import type { FastifyBaseLogger, FastifyInstance } from 'fastify' 3 | import type { ProgressEvent } from '@root/types/progress.types.js' 4 | 5 | export class ProgressService { 6 | private static instance: ProgressService 7 | private eventEmitter: EventEmitter 8 | private activeConnections: Set = new Set() 9 | 10 | private constructor( 11 | private readonly log: FastifyBaseLogger, 12 | private readonly fastify: FastifyInstance, 13 | ) { 14 | this.eventEmitter = new EventEmitter() 15 | } 16 | 17 | static getInstance( 18 | log: FastifyBaseLogger, 19 | fastify: FastifyInstance, 20 | ): ProgressService { 21 | if (!ProgressService.instance) { 22 | ProgressService.instance = new ProgressService(log, fastify) 23 | } 24 | return ProgressService.instance 25 | } 26 | 27 | addConnection(id: string) { 28 | this.activeConnections.add(id) 29 | this.log.debug(`Adding progress connection: ${id}`) 30 | } 31 | 32 | removeConnection(id: string) { 33 | this.activeConnections.delete(id) 34 | this.log.debug(`Removing progress connection: ${id}`) 35 | } 36 | 37 | emit(event: ProgressEvent) { 38 | this.log.debug({ event }, 'Emitting progress event') 39 | this.eventEmitter.emit('progress', event) 40 | } 41 | 42 | getEventEmitter() { 43 | return this.eventEmitter 44 | } 45 | 46 | hasActiveConnections(): boolean { 47 | return this.activeConnections.size > 0 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/types/apprise.types.ts: -------------------------------------------------------------------------------- 1 | export type AppriseMessageType = 'info' | 'success' | 'warning' | 'failure' 2 | 3 | export interface AppriseNotification { 4 | title: string 5 | body: string 6 | type?: AppriseMessageType 7 | tag?: string 8 | format?: 'text' | 'html' | 'markdown' 9 | // HTML formatted body - used alongside text body for services that support HTML 10 | body_html?: string 11 | // Image URL for thumbnail/attachment 12 | image?: string 13 | // Attach the image to the notification (for email and services that support attachments) 14 | attach?: string 15 | // Application icon URL 16 | attach_url?: string 17 | // Additional attributes for specific notification systems 18 | [key: string]: unknown 19 | } 20 | -------------------------------------------------------------------------------- /src/types/delete-sync.types.ts: -------------------------------------------------------------------------------- 1 | export type DeleteSyncResult = { 2 | total: { 3 | deleted: number 4 | skipped: number 5 | processed: number 6 | protected?: number 7 | } 8 | movies: { 9 | deleted: number 10 | skipped: number 11 | protected?: number 12 | items: Array<{ title: string; guid: string; instance: string }> 13 | } 14 | shows: { 15 | deleted: number 16 | skipped: number 17 | protected?: number 18 | items: Array<{ title: string; guid: string; instance: string }> 19 | } 20 | safetyTriggered?: boolean 21 | safetyMessage?: string 22 | } 23 | -------------------------------------------------------------------------------- /src/types/discord.types.ts: -------------------------------------------------------------------------------- 1 | export interface MediaNotification { 2 | type: 'movie' | 'show' 3 | title: string 4 | username: string 5 | posterUrl?: string 6 | episodeDetails?: { 7 | title?: string 8 | overview?: string 9 | seasonNumber?: number 10 | episodeNumber?: number 11 | airDateUtc?: string 12 | } 13 | } 14 | 15 | export interface DiscordEmbed { 16 | title?: string 17 | description?: string 18 | url?: string 19 | color?: number 20 | timestamp?: string 21 | footer?: { 22 | text: string 23 | icon_url?: string 24 | } 25 | thumbnail?: { 26 | url: string 27 | } 28 | image?: { 29 | url: string 30 | } 31 | author?: { 32 | name: string 33 | icon_url?: string 34 | } 35 | fields?: Array<{ 36 | name: string 37 | value: string 38 | inline?: boolean 39 | }> 40 | } 41 | 42 | export interface DiscordWebhookPayload { 43 | content?: string 44 | username?: string 45 | avatar_url?: string 46 | embeds?: DiscordEmbed[] 47 | } 48 | 49 | export interface SystemNotification { 50 | type: 'system' 51 | username: string 52 | title: string 53 | embedFields: Array<{ name: string; value: string; inline?: boolean }> 54 | safetyTriggered?: boolean 55 | } 56 | -------------------------------------------------------------------------------- /src/types/errors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Custom error classes for application-specific errors 3 | */ 4 | 5 | /** 6 | * Error thrown when an operation would leave the system without a default instance 7 | * when at least one default instance is required. 8 | */ 9 | export class DefaultInstanceError extends Error { 10 | constructor(message: string) { 11 | super(message) 12 | this.name = 'DefaultInstanceError' 13 | 14 | // This is needed for correct instanceof checks in TypeScript 15 | Object.setPrototypeOf(this, DefaultInstanceError.prototype) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/types/pending-webhooks.types.ts: -------------------------------------------------------------------------------- 1 | import type { WebhookPayload } from '@root/schemas/notifications/webhook.schema.js' 2 | 3 | export interface PendingWebhook { 4 | id?: number 5 | instance_type: 'radarr' | 'sonarr' 6 | instance_id: number | null 7 | guid: string 8 | title: string 9 | media_type: 'movie' | 'show' 10 | payload: WebhookPayload // Full webhook payload 11 | received_at: Date 12 | expires_at: Date 13 | } 14 | 15 | export interface PendingWebhookCreate { 16 | instance_type: 'radarr' | 'sonarr' 17 | instance_id: number | null 18 | guid: string 19 | title: string 20 | media_type: 'movie' | 'show' 21 | payload: WebhookPayload 22 | expires_at: Date 23 | } 24 | 25 | export interface PendingWebhooksConfig { 26 | retryInterval: number // in seconds (default: 20) 27 | maxAge: number // in minutes (default: 10) 28 | cleanupInterval: number // in seconds (default: 60) 29 | } 30 | -------------------------------------------------------------------------------- /src/types/progress.types.ts: -------------------------------------------------------------------------------- 1 | export type WorkflowMetadata = { 2 | syncMode: 'manual' | 'rss' 3 | rssAvailable: boolean 4 | } 5 | 6 | export type ProgressMetadata = WorkflowMetadata | Record 7 | 8 | export interface ProgressEvent { 9 | operationId: string 10 | type: 11 | | 'self-watchlist' 12 | | 'others-watchlist' 13 | | 'rss-feed' 14 | | 'system' 15 | | 'sync' 16 | | 'sonarr-tagging' 17 | | 'radarr-tagging' 18 | | 'sonarr-tag-removal' 19 | | 'radarr-tag-removal' 20 | phase: string 21 | progress: number 22 | message: string 23 | metadata?: ProgressMetadata 24 | } 25 | 26 | export interface ProgressService { 27 | emit(event: ProgressEvent): void 28 | hasActiveConnections(): boolean 29 | } 30 | 31 | export interface ProgressOptions { 32 | progress: ProgressService 33 | operationId: string 34 | type: 'self-watchlist' | 'others-watchlist' | 'rss-feed' 35 | } 36 | -------------------------------------------------------------------------------- /src/types/scheduler.types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Type for schedule information in the Db 3 | */ 4 | export type DbSchedule = 5 | | { 6 | id: number 7 | name: string 8 | type: 'interval' 9 | config: IntervalConfig 10 | enabled: boolean 11 | last_run: JobRunInfo | null 12 | next_run: JobRunInfo | null 13 | created_at: string 14 | updated_at: string 15 | } 16 | | { 17 | id: number 18 | name: string 19 | type: 'cron' 20 | config: CronConfig 21 | enabled: boolean 22 | last_run: JobRunInfo | null 23 | next_run: JobRunInfo | null 24 | created_at: string 25 | updated_at: string 26 | } 27 | 28 | /** 29 | * Type for job run status information 30 | */ 31 | export interface JobRunInfo { 32 | time: string 33 | status: 'completed' | 'failed' | 'pending' 34 | error?: string 35 | estimated?: boolean 36 | } 37 | 38 | /** 39 | * Type for configuration of interval jobs 40 | */ 41 | export interface IntervalConfig { 42 | days?: number 43 | hours?: number 44 | minutes?: number 45 | seconds?: number 46 | runImmediately?: boolean 47 | } 48 | 49 | /** 50 | * Type for configuration of cron jobs 51 | */ 52 | export interface CronConfig { 53 | expression: string 54 | } 55 | -------------------------------------------------------------------------------- /src/types/webhook.types.ts: -------------------------------------------------------------------------------- 1 | export interface QueuedWebhook { 2 | mediaInfo: { 3 | type: 'show' 4 | guid: string 5 | title: string 6 | episodes: { 7 | seasonNumber: number 8 | episodeNumber: number 9 | title: string 10 | overview?: string 11 | airDateUtc: string 12 | }[] 13 | } 14 | receivedAt: Date 15 | lastUpdated: Date 16 | } 17 | 18 | export interface RecentWebhook { 19 | timestamp: number 20 | isUpgrade: boolean 21 | } 22 | 23 | export interface SeasonQueue { 24 | episodes: Array<{ 25 | episodeNumber: number 26 | seasonNumber: number 27 | title: string 28 | overview?: string 29 | airDateUtc: string 30 | }> 31 | firstReceived: Date 32 | lastUpdated: Date 33 | notifiedSeasons: Set 34 | timeoutId: NodeJS.Timeout 35 | upgradeTracker: Map 36 | instanceId?: number | null 37 | } 38 | 39 | export interface WebhookQueue { 40 | [tvdbId: string]: { 41 | seasons: { 42 | [seasonNumber: number]: SeasonQueue 43 | } 44 | title: string 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/utils/auth-bypass.ts: -------------------------------------------------------------------------------- 1 | import { isLocalIpAddress } from '@utils/ip.js' 2 | import type { FastifyInstance, FastifyRequest } from 'fastify' 3 | 4 | /** 5 | * Returns authentication bypass status for a request based on server configuration and the request's IP address. 6 | * 7 | * @returns An object with boolean properties: {@link isAuthDisabled} (true if authentication is globally disabled), {@link isLocalBypass} (true if authentication is bypassed for local IPs), and {@link shouldBypass} (true if either condition applies). 8 | */ 9 | export function getAuthBypassStatus( 10 | fastify: FastifyInstance, 11 | request: FastifyRequest, 12 | ) { 13 | const authMethod = fastify.config.authenticationMethod as 14 | | 'disabled' 15 | | 'requiredExceptLocal' 16 | | 'required' 17 | const isAuthDisabled = authMethod === 'disabled' 18 | const isLocalBypass = 19 | authMethod === 'requiredExceptLocal' && isLocalIpAddress(request.ip) 20 | 21 | return { 22 | isAuthDisabled, 23 | isLocalBypass, 24 | shouldBypass: isAuthDisabled || isLocalBypass, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/session.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyRequest } from 'fastify' 2 | 3 | /** 4 | * Sets a fixed admin user in the session to simulate authentication bypass. 5 | * 6 | * @remark 7 | * Use only when authentication is disabled or bypassed, such as for local development or trusted IPs. The session user is assigned static admin credentials. 8 | * 9 | * @param request - The Fastify request object with session support. 10 | */ 11 | export function createTemporaryAdminSession(request: FastifyRequest): void { 12 | request.session.user = { 13 | id: 0, 14 | email: 'auth-bypass@local', 15 | username: 'auth-bypass', 16 | role: 'admin', 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "allowSyntheticDefaultImports": true, 5 | "composite": true, 6 | "lib": ["es2022"], 7 | "module": "NodeNext", 8 | "moduleResolution": "NodeNext", 9 | "noEmit": false, 10 | "outDir": "dist", 11 | "removeComments": true, 12 | "rootDir": "src", 13 | "skipLibCheck": true, 14 | "sourceMap": true, 15 | "strict": true, 16 | "target": "es2022", 17 | "baseUrl": "./src", 18 | "paths": { 19 | "@root/*": ["./*"], 20 | "@db/*": ["./db/*"], 21 | "@routes/*": ["./routes/*"], 22 | "@utils/*": ["./utils/*"], 23 | "@plex/*": ["./plex/*"], 24 | "@shared/*": ["./shared/*"], 25 | "@data/*": ["./data/*"], 26 | "@schemas/*": ["./schemas/*"], 27 | "@services/*": ["./services/*"], 28 | "@/*": ["./client/*"], 29 | "@commitlint/types": ["./node_modules/@commitlint/types"] 30 | } 31 | }, 32 | "include": ["src/**/*", "config"], 33 | "exclude": ["src/client/**/*"] 34 | } 35 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path' 2 | import { viteFastify } from '@fastify/vite/plugin' 3 | import viteReact from '@vitejs/plugin-react' 4 | import { readFileSync } from 'node:fs' 5 | 6 | // Read package.json to expose version for client 7 | const packageJson = JSON.parse( 8 | readFileSync(new URL('./package.json', import.meta.url), 'utf8'), 9 | ) 10 | 11 | /** @type {import('vite').UserConfig} */ 12 | export default { 13 | base: '/', 14 | root: resolve(import.meta.dirname, 'src/client'), 15 | plugins: [viteReact(), viteFastify({ spa: true })], 16 | build: { 17 | outDir: resolve(import.meta.dirname, 'dist/client'), 18 | emptyOutDir: false, 19 | assetsInclude: ['**/*.woff2', '**/*.woff'], 20 | }, 21 | resolve: { 22 | alias: { 23 | '@': resolve(import.meta.dirname, 'src/client'), 24 | '@root': resolve(import.meta.dirname, 'src'), 25 | }, 26 | }, 27 | define: { 28 | __APP_VERSION__: JSON.stringify(packageJson.version), 29 | }, 30 | cacheDir: process.env.NODE_ENV === 'production' ? false : undefined, 31 | } 32 | --------------------------------------------------------------------------------