├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── assets │ ├── OIDC-login.png │ ├── built-in-login.png │ ├── built-in-register.png │ ├── dashboard.png │ ├── logo.png │ ├── mobile.png │ └── tailscale.png ├── dependabot.yml └── workflows │ └── release.yml ├── .gitignore ├── .goreleaser.yml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── ci.Dockerfile ├── cmd └── dashbrr │ └── main.go ├── config.toml ├── docker-compose ├── docker-compose.discovery.yml ├── docker-compose.integration.yml ├── docker-compose.redis.yml ├── docker-compose.yml └── init.sql ├── docs ├── commands.md ├── config_management.md └── env_vars.md ├── go.mod ├── go.sum ├── internal ├── api │ ├── handlers │ │ ├── auth.go │ │ ├── auth_config.go │ │ ├── auth_test.go │ │ ├── autobrr.go │ │ ├── builtin_auth.go │ │ ├── events.go │ │ ├── health.go │ │ ├── health_test.go │ │ ├── maintainerr.go │ │ ├── omegabrr.go │ │ ├── overseerr.go │ │ ├── plex.go │ │ ├── prowlarr.go │ │ ├── queue_hash.go │ │ ├── radarr.go │ │ ├── settings.go │ │ ├── sonarr.go │ │ ├── tailscale.go │ │ └── testing │ │ │ └── mocks.go │ ├── middleware │ │ ├── auth.go │ │ ├── cache.go │ │ ├── cors.go │ │ ├── csrf.go │ │ ├── logging.go │ │ ├── ratelimit.go │ │ └── secure.go │ └── server.go ├── buildinfo │ └── buildinfo.go ├── commands │ ├── config.go │ ├── health.go │ ├── service.go │ ├── service_autobrr.go │ ├── service_general.go │ ├── service_maintainerr.go │ ├── service_omegabrr.go │ ├── service_overseerr.go │ ├── service_plex.go │ ├── service_prowlarr.go │ ├── service_radarr.go │ ├── service_sonarr.go │ ├── service_tailscale.go │ ├── user.go │ └── version.go ├── config │ └── config.go ├── database │ ├── database.go │ ├── database_integration_test.go │ ├── database_test.go │ └── migrations │ │ ├── migrations.go │ │ ├── postgres.go │ │ ├── postgres_schema.sql │ │ ├── sqlite.go │ │ └── sqlite_schema.sql ├── logger │ └── logger.go ├── models │ ├── registry.go │ ├── registry_test.go │ ├── service.go │ └── settings.go ├── services │ ├── arr │ │ ├── common.go │ │ ├── health.go │ │ └── warnings.go │ ├── autobrr │ │ ├── autobrr.go │ │ └── client.go │ ├── cache │ │ ├── cache.go │ │ ├── cache_test.go │ │ ├── init.go │ │ ├── interface.go │ │ ├── memory.go │ │ └── memory_test.go │ ├── core │ │ └── service.go │ ├── discovery │ │ ├── config_file.go │ │ ├── constants.go │ │ ├── discovery.go │ │ ├── docker.go │ │ └── kubernetes.go │ ├── general │ │ └── general.go │ ├── health.go │ ├── health_test.go │ ├── maintainerr │ │ └── maintainerr.go │ ├── manager │ │ └── service_manager.go │ ├── omegabrr │ │ └── omegabrr.go │ ├── overseerr │ │ └── overseerr.go │ ├── plex │ │ └── plex.go │ ├── prowlarr │ │ └── prowlarr.go │ ├── radarr │ │ └── radarr.go │ ├── resilience │ │ └── resilience.go │ ├── services.go │ ├── sonarr │ │ └── sonarr.go │ └── tailscale │ │ └── tailscale.go ├── types │ ├── auth.go │ ├── autobrr.go │ ├── overseerr.go │ ├── plex.go │ ├── prowlarr.go │ ├── radarr.go │ ├── sonarr.go │ └── types.go └── utils │ ├── auth.go │ └── type_conversion.go ├── pkg └── migrator │ └── migrator.go └── web ├── build.go ├── dist └── .gitkeep ├── eslint.config.js ├── index.html ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public ├── apple-touch-icon-ipad-76x76.png ├── apple-touch-icon-ipad-retina-152x152.png ├── apple-touch-icon-iphone-60x60.png ├── apple-touch-icon-iphone-retina-120x120.png ├── apple-touch-icon.png ├── favicon.ico ├── logo.svg ├── masked-icon.svg ├── pwa-192x192.png └── pwa-512x512.png ├── scripts └── generate-pwa-icons.js ├── src ├── App.css ├── App.tsx ├── assets │ ├── apple-touch-icon-ipad-76x76.png │ ├── apple-touch-icon-ipad-retina-152x152.png │ ├── apple-touch-icon-iphone-60x60.png │ ├── apple-touch-icon-iphone-retina-120x120.png │ ├── logo.svg │ ├── logo192.png │ ├── react.svg │ └── tailscale.svg ├── components │ ├── AddServicesMenu.tsx │ ├── Toast.tsx │ ├── auth │ │ ├── CallbackPage.tsx │ │ ├── LoginPage.tsx │ │ ├── ProtectedRoute.tsx │ │ └── withAuth.tsx │ ├── configuration │ │ └── ConfigurationForm.tsx │ ├── services │ │ ├── ServiceCard.tsx │ │ ├── ServiceGrid.tsx │ │ ├── ServiceHealthMonitor.tsx │ │ ├── TailscaleDeviceModal.tsx │ │ ├── TailscaleStatusBar.tsx │ │ ├── autobrr │ │ │ ├── AutobrrMessage.tsx │ │ │ └── AutobrrStats.tsx │ │ ├── common │ │ │ └── ArrMessage.tsx │ │ ├── general │ │ │ ├── GeneralMessage.tsx │ │ │ └── GeneralStats.tsx │ │ ├── maintainerr │ │ │ ├── MaintainerrCollections.tsx │ │ │ ├── MaintainerrMessage.tsx │ │ │ └── MaintainerrService.tsx │ │ ├── omegabrr │ │ │ ├── OmegabrrControls.tsx │ │ │ ├── OmegabrrMessage.tsx │ │ │ └── OmegabrrStats.tsx │ │ ├── overseerr │ │ │ ├── OverseerrMessage.tsx │ │ │ ├── OverseerrRequestModal.tsx │ │ │ └── OverseerrStats.tsx │ │ ├── plex │ │ │ ├── PlexMessage.tsx │ │ │ └── PlexStats.tsx │ │ ├── prowlarr │ │ │ ├── ProwlarrMessage.tsx │ │ │ └── ProwlarrStats.tsx │ │ ├── radarr │ │ │ ├── RadarrMessage.tsx │ │ │ └── RadarrStats.tsx │ │ └── sonarr │ │ │ ├── SonarrMessage.tsx │ │ │ └── SonarrStats.tsx │ ├── shared │ │ ├── Card.tsx │ │ ├── ErrorBoundary.tsx │ │ ├── Footer.tsx │ │ ├── HealthIndicator.tsx │ │ ├── LoadingSkeleton.tsx │ │ ├── LoadingState.tsx │ │ ├── ServiceActions.tsx │ │ ├── ServiceStatus.tsx │ │ ├── StatsLoadingSkeleton.tsx │ │ └── StatusCounters.tsx │ └── ui │ │ ├── AnimatedModal.tsx │ │ ├── Button.tsx │ │ ├── FormInput.tsx │ │ ├── Modal.tsx │ │ ├── ServiceHeader.tsx │ │ ├── StatusIcon.tsx │ │ └── StatusIndicator.tsx ├── config │ ├── api.ts │ ├── auth.ts │ ├── repoUrls.ts │ └── serviceTemplates.ts ├── contexts │ ├── AuthContext.tsx │ ├── ConfigurationContext.tsx │ ├── context.ts │ ├── types.ts │ └── useConfiguration.ts ├── hooks │ ├── useAuth.ts │ ├── useCachedServiceData.ts │ ├── useEventSource.ts │ ├── usePollingService.ts │ ├── useServiceData.ts │ ├── useServiceHealth.ts │ └── useServiceManagement.ts ├── index.css ├── main.tsx ├── types │ ├── auth.ts │ └── service.ts ├── utils │ ├── api.ts │ ├── cache.ts │ ├── index.ts │ └── mediaTypes.ts ├── vite-env.d.ts └── vite-pwa.d.ts ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [s0up4200, zze0s] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/assets/OIDC-login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autobrr/dashbrr/92a59a8c24a52afcc76ad290a9b10a91b80fad42/.github/assets/OIDC-login.png -------------------------------------------------------------------------------- /.github/assets/built-in-login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autobrr/dashbrr/92a59a8c24a52afcc76ad290a9b10a91b80fad42/.github/assets/built-in-login.png -------------------------------------------------------------------------------- /.github/assets/built-in-register.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autobrr/dashbrr/92a59a8c24a52afcc76ad290a9b10a91b80fad42/.github/assets/built-in-register.png -------------------------------------------------------------------------------- /.github/assets/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autobrr/dashbrr/92a59a8c24a52afcc76ad290a9b10a91b80fad42/.github/assets/dashboard.png -------------------------------------------------------------------------------- /.github/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autobrr/dashbrr/92a59a8c24a52afcc76ad290a9b10a91b80fad42/.github/assets/logo.png -------------------------------------------------------------------------------- /.github/assets/mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autobrr/dashbrr/92a59a8c24a52afcc76ad290a9b10a91b80fad42/.github/assets/mobile.png -------------------------------------------------------------------------------- /.github/assets/tailscale.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autobrr/dashbrr/92a59a8c24a52afcc76ad290a9b10a91b80fad42/.github/assets/tailscale.png -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | day: saturday 8 | time: "07:00" 9 | groups: 10 | github: 11 | patterns: 12 | - "*" 13 | 14 | - package-ecosystem: gomod 15 | directory: /backend 16 | schedule: 17 | interval: monthly 18 | groups: 19 | golang: 20 | patterns: 21 | - "*" 22 | 23 | - package-ecosystem: npm 24 | directory: / 25 | schedule: 26 | interval: monthly 27 | groups: 28 | npm: 29 | patterns: 30 | - "*" 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries and Executables 2 | ###################### 3 | dashbrr 4 | bin/ 5 | dist/ 6 | 7 | # Dependencies 8 | ###################### 9 | node_modules/ 10 | .pnp.* 11 | .yarn/* 12 | !.yarn/patches 13 | !.yarn/plugins 14 | !.yarn/releases 15 | !.yarn/sdks 16 | !.yarn/versions 17 | 18 | # Package Manager Files 19 | ###################### 20 | # If needed, package-lock.json shall be added manually using an explicit git add command 21 | package-lock.json 22 | # Using npm, not yarn 23 | yarn.lock 24 | 25 | # Build and Development 26 | ###################### 27 | web/build 28 | web/dev-dist/ 29 | web/dist/* 30 | !web/dist/.gitkeep 31 | web/tsconfig.app.tsbuildinfo 32 | web/tsconfig.node.tsbuildinfo 33 | 34 | # Database Files 35 | ###################### 36 | *.log 37 | *.sql 38 | *.sqlite 39 | *.db 40 | *.db-shm 41 | *.db-wal 42 | *.db* 43 | data/ 44 | 45 | # IDE and Editor Files 46 | ###################### 47 | .idea 48 | .vscode 49 | 50 | # Environment Files 51 | ###################### 52 | .env 53 | license.sh 54 | unit-tests.xml 55 | dump.rdb 56 | 57 | # OS Generated Files 58 | ###################### 59 | .DS_Store 60 | .DS_Store? 61 | ._* 62 | .Spotlight-V100 63 | .Trashes 64 | ehthumbs.db 65 | Thumbs.db 66 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | 7 | builds: 8 | - id: dashbrr 9 | env: 10 | - CGO_ENABLED=0 11 | goos: 12 | - linux 13 | - windows 14 | - darwin 15 | - freebsd 16 | goarch: 17 | - amd64 18 | - arm 19 | - arm64 20 | goarm: 21 | - "6" 22 | ignore: 23 | - goos: windows 24 | goarch: arm 25 | - goos: windows 26 | goarch: arm64 27 | - goos: darwin 28 | goarch: arm 29 | - goos: freebsd 30 | goarch: arm 31 | - goos: freebsd 32 | goarch: arm64 33 | main: ./cmd/dashbrr/main.go 34 | binary: dashbrr 35 | ldflags: 36 | - -s -w 37 | - -X github.com/autobrr/dashbrr/internal/buildinfo.Version={{.Version}} 38 | - -X github.com/autobrr/dashbrr/internal/buildinfo.Commit={{.Commit}} 39 | - -X github.com/autobrr/dashbrr/internal/buildinfo.Date={{.Date}} 40 | 41 | archives: 42 | - id: dashbrr 43 | builds: 44 | - dashbrr 45 | format_overrides: 46 | - goos: windows 47 | format: zip 48 | name_template: >- 49 | {{ .ProjectName }}_ 50 | {{- .Version }}_ 51 | {{- .Os }}_ 52 | {{- if eq .Arch "amd64" }}x86_64 53 | {{- else }}{{ .Arch }}{{ end }} 54 | 55 | release: 56 | prerelease: auto 57 | footer: | 58 | **Full Changelog**: https://github.com/autobrr/dashbrr/compare/{{ .PreviousTag }}...{{ .Tag }} 59 | 60 | ## Docker images 61 | 62 | - `docker pull ghcr.io/autobrr/dashbrr:{{ .Tag }}` 63 | 64 | ## What to do next? 65 | 66 | - Read the [documentation](https://github.com/autobrr/dashbrr/blob/main/README.md) 67 | - Join our [Discord server](https://discord.gg/WQ2eUycxyT) 68 | 69 | checksum: 70 | name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt" 71 | 72 | changelog: 73 | sort: asc 74 | use: github 75 | filters: 76 | exclude: 77 | - Merge pull request 78 | - Merge remote-tracking branch 79 | - Merge branch 80 | groups: 81 | - title: "New Features" 82 | regexp: "^.*feat[(\\w)]*:+.*$" 83 | order: 0 84 | - title: "Bug fixes" 85 | regexp: "^.*fix[(\\w)]*:+.*$" 86 | order: 10 87 | - title: Other work 88 | order: 999 89 | 90 | nfpms: 91 | - package_name: dashbrr 92 | homepage: https://autobrr.com 93 | maintainer: Autobrr 94 | description: |- 95 | dashbrr is a modern dashboard for autobrr and related services. 96 | formats: 97 | - apk 98 | - deb 99 | - rpm 100 | - archlinux 101 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # build web 2 | FROM node:22.10.0-alpine3.20 AS web-builder 3 | RUN corepack enable 4 | 5 | WORKDIR /app/web 6 | 7 | COPY web/package.json web/pnpm-lock.yaml ./ 8 | RUN pnpm install --frozen-lockfile 9 | 10 | COPY web/ ./ 11 | RUN pnpm run build 12 | 13 | # build app 14 | FROM golang:1.23-alpine3.20 AS app-builder 15 | 16 | ARG VERSION=dev 17 | ARG REVISION=dev 18 | ARG BUILDTIME 19 | 20 | RUN apk add --no-cache git build-base tzdata 21 | 22 | ENV SERVICE=dashbrr 23 | ENV CGO_ENABLED=0 24 | 25 | WORKDIR /src 26 | 27 | COPY go.mod go.sum ./ 28 | RUN go mod download 29 | 30 | COPY . ./ 31 | # Copy the built web assets to the web/dist directory for embedding 32 | COPY --from=web-builder /app/web/dist ./web/dist 33 | 34 | # Build with embedded assets 35 | RUN go build -ldflags "-s -w \ 36 | -X github.com/autobrr/dashbrr/internal/buildinfo.Version=${VERSION} \ 37 | -X github.com/autobrr/dashbrr/internal/buildinfo.Commit=${REVISION} \ 38 | -X github.com/autobrr/dashbrr/internal/buildinfo.Date=${BUILDTIME}" \ 39 | -o /app/dashbrr cmd/dashbrr/main.go 40 | 41 | # build runner 42 | FROM alpine:3.20 43 | 44 | LABEL org.opencontainers.image.source="https://github.com/autobrr/dashbrr" 45 | 46 | ENV HOME="/config" \ 47 | XDG_CONFIG_HOME="/config" \ 48 | XDG_DATA_HOME="/config" 49 | 50 | WORKDIR /config 51 | VOLUME /config 52 | 53 | COPY --from=app-builder /app/dashbrr /usr/local/bin/dashbrr 54 | 55 | EXPOSE 8080 56 | 57 | RUN addgroup -S dashbrr && \ 58 | adduser -S dashbrr -G dashbrr 59 | 60 | USER dashbrr 61 | 62 | ENTRYPOINT ["dashbrr"] 63 | CMD ["serve"] 64 | -------------------------------------------------------------------------------- /ci.Dockerfile: -------------------------------------------------------------------------------- 1 | # build web 2 | # FROM --platform=$BUILDPLATFORM node:22.10.0-alpine3.20 AS web-builder 3 | # RUN corepack enable 4 | 5 | # WORKDIR /app/web 6 | 7 | # COPY web/package.json web/pnpm-lock.yaml ./ 8 | # RUN pnpm install --frozen-lockfile 9 | 10 | # COPY web/ ./ 11 | # RUN pnpm run build 12 | 13 | # build app 14 | FROM --platform=$BUILDPLATFORM golang:1.23-alpine3.20 AS app-builder 15 | RUN apk add --no-cache git tzdata 16 | 17 | ENV SERVICE=dashbrr 18 | 19 | WORKDIR /src 20 | 21 | # Cache Go modules 22 | COPY go.mod go.sum ./ 23 | RUN go mod download 24 | 25 | COPY . ./ 26 | #COPY --from=web-builder /app/web/dist ./web/dist 27 | 28 | ARG VERSION=dev 29 | ARG REVISION=dev 30 | ARG BUILDTIME 31 | ARG TARGETOS 32 | ARG TARGETARCH 33 | ARG TARGETVARIANT 34 | 35 | RUN --network=none --mount=target=. \ 36 | export GOOS=$TARGETOS; \ 37 | export GOARCH=$TARGETARCH; \ 38 | [[ "$GOARCH" == "amd64" ]] && export GOAMD64=$TARGETVARIANT; \ 39 | [[ "$GOARCH" == "arm" ]] && [[ "$TARGETVARIANT" == "v6" ]] && export GOARM=6; \ 40 | [[ "$GOARCH" == "arm" ]] && [[ "$TARGETVARIANT" == "v7" ]] && export GOARM=7; \ 41 | echo $GOARCH $GOOS $GOARM$GOAMD64; \ 42 | go build -ldflags "-s -w \ 43 | -X github.com/autobrr/dashbrr/internal/buildinfo.Version=${VERSION} \ 44 | -X github.com/autobrr/dashbrr/internal/buildinfo.Commit=${REVISION} \ 45 | -X github.com/autobrr/dashbrr/internal/buildinfo.Date=${BUILDTIME}" \ 46 | -o /out/bin/dashbrr cmd/dashbrr/main.go 47 | 48 | # build runner 49 | FROM alpine:latest 50 | 51 | LABEL org.opencontainers.image.source="https://github.com/autobrr/dashbrr" 52 | LABEL org.opencontainers.image.licenses="GPL-2.0-or-later" 53 | LABEL org.opencontainers.image.base.name="alpine:latest" 54 | 55 | COPY --link --from=app-builder /out/bin/dashbrr /usr/local/bin/dashbrr 56 | EXPOSE 8080 57 | 58 | ENTRYPOINT ["/usr/local/bin/dashbrr"] 59 | CMD ["serve"] -------------------------------------------------------------------------------- /config.toml: -------------------------------------------------------------------------------- 1 | [server] 2 | listen_addr = ":8080" 3 | 4 | [database] 5 | type = "sqlite" 6 | path = "./data/dashbrr.db" 7 | -------------------------------------------------------------------------------- /docker-compose/docker-compose.discovery.yml: -------------------------------------------------------------------------------- 1 | services: 2 | dashbrr: 3 | container_name: dashbrr 4 | image: ghcr.io/autobrr/dashbrr:latest 5 | ports: 6 | - "8080:8080" 7 | environment: 8 | # Database configuration - uncomment desired database type 9 | # SQLite configuration 10 | # PostgreSQL configuration 11 | - DASHBRR__DB_TYPE=postgres 12 | - DASHBRR__DB_HOST=postgres 13 | - DASHBRR__DB_PORT=5432 14 | - DASHBRR__DB_USER=dashbrr 15 | - DASHBRR__DB_PASSWORD=dashbrr 16 | - DASHBRR__DB_NAME=dashbrr 17 | - DASHBRR__LISTEN_ADDR=0.0.0.0:8080 18 | # Service API keys 19 | - DASHBRR_RADARR_API_KEY=${DASHBRR_RADARR_API_KEY} 20 | volumes: 21 | - ./data:/data 22 | depends_on: 23 | postgres: 24 | condition: service_healthy 25 | restart: unless-stopped 26 | networks: 27 | - dashbrr-network 28 | 29 | postgres: 30 | container_name: dashbrr-postgres 31 | image: postgres:15-alpine 32 | ports: 33 | - "5432:5432" 34 | environment: 35 | - POSTGRES_USER=dashbrr 36 | - POSTGRES_PASSWORD=dashbrr 37 | - POSTGRES_DB=dashbrr 38 | volumes: 39 | - postgres_data:/var/lib/postgresql/data 40 | networks: 41 | - dashbrr-network 42 | healthcheck: 43 | test: ["CMD-SHELL", "pg_isready -U dashbrr"] 44 | interval: 10s 45 | timeout: 5s 46 | retries: 3 47 | restart: unless-stopped 48 | 49 | radarr: 50 | container_name: radarr 51 | image: linuxserver/radarr:latest 52 | ports: 53 | - "7878:7878" 54 | environment: 55 | - PUID=1000 56 | - PGID=1000 57 | - TZ=UTC 58 | - DASHBRR_RADARR_API_KEY=${DASHBRR_RADARR_API_KEY} 59 | volumes: 60 | - radarr_config:/config 61 | - movies:/movies 62 | - downloads:/downloads 63 | networks: 64 | - dashbrr-network 65 | restart: unless-stopped 66 | labels: 67 | com.dashbrr.service.type: "radarr" 68 | com.dashbrr.service.url: "http://radarr:7878" 69 | com.dashbrr.service.apikey: "${DASHBRR_RADARR_API_KEY}" 70 | com.dashbrr.service.name: "Movies" 71 | 72 | volumes: 73 | postgres_data: 74 | name: dashbrr_postgres_data 75 | radarr_config: 76 | movies: 77 | downloads: 78 | 79 | networks: 80 | dashbrr-network: 81 | name: dashbrr-network 82 | driver: bridge 83 | -------------------------------------------------------------------------------- /docker-compose/docker-compose.integration.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres:15 4 | environment: 5 | POSTGRES_USER: dashbrr 6 | POSTGRES_PASSWORD: dashbrr 7 | POSTGRES_DB: postgres 8 | ports: 9 | - "5432:5432" 10 | healthcheck: 11 | test: 12 | [ 13 | "CMD-SHELL", 14 | "pg_isready -U dashbrr && psql -U dashbrr -d dashbrr_test -c 'SELECT 1'", 15 | ] 16 | interval: 5s 17 | timeout: 5s 18 | retries: 5 19 | volumes: 20 | - ./init.sql:/docker-entrypoint-initdb.d/init.sql 21 | - postgres-data:/var/lib/postgresql/data 22 | 23 | volumes: 24 | postgres-data: 25 | driver: local 26 | -------------------------------------------------------------------------------- /docker-compose/docker-compose.redis.yml: -------------------------------------------------------------------------------- 1 | services: 2 | dashbrr: 3 | container_name: dashbrr 4 | image: ghcr.io/autobrr/dashbrr:latest 5 | ports: 6 | - "8080:8080" 7 | environment: 8 | # Using Redis cache 9 | - REDIS_HOST=redis 10 | - REDIS_PORT=6379 11 | # Database configuration - uncomment desired database type 12 | # SQLite configuration 13 | #- DASHBRR__DB_TYPE=sqlite 14 | #- DASHBRR__DB_PATH=/data/dashbrr.db 15 | # PostgreSQL configuration 16 | - DASHBRR__DB_TYPE=postgres 17 | - DASHBRR__DB_HOST=postgres 18 | - DASHBRR__DB_PORT=5432 19 | - DASHBRR__DB_USER=dashbrr 20 | - DASHBRR__DB_PASSWORD=dashbrr 21 | - DASHBRR__DB_NAME=dashbrr 22 | - DASHBRR__LISTEN_ADDR=0.0.0.0:8080 23 | 24 | #- OIDC_ISSUER=optional 25 | #- OIDC_CLIENT_ID=optional 26 | #- OIDC_CLIENT_SECRET=optional 27 | #- OIDC_REDIRECT_URL=optional 28 | volumes: 29 | - ./data:/data 30 | depends_on: 31 | redis: 32 | condition: service_healthy 33 | postgres: 34 | condition: service_healthy 35 | restart: unless-stopped 36 | networks: 37 | - dashbrr-network 38 | 39 | redis: 40 | container_name: dashbrr-redis 41 | image: redis:7-alpine 42 | ports: 43 | - "6379:6379" 44 | volumes: 45 | - redis_data:/data 46 | command: redis-server --appendonly yes --save 60 1 --loglevel warning 47 | restart: unless-stopped 48 | networks: 49 | - dashbrr-network 50 | healthcheck: 51 | test: ["CMD", "redis-cli", "ping"] 52 | interval: 10s 53 | timeout: 5s 54 | retries: 3 55 | 56 | postgres: 57 | container_name: dashbrr-postgres 58 | image: postgres:15-alpine 59 | ports: 60 | - "5432:5432" 61 | environment: 62 | - POSTGRES_USER=dashbrr 63 | - POSTGRES_PASSWORD=dashbrr 64 | - POSTGRES_DB=dashbrr 65 | volumes: 66 | - postgres_data:/var/lib/postgresql/data 67 | networks: 68 | - dashbrr-network 69 | healthcheck: 70 | test: ["CMD-SHELL", "pg_isready -U dashbrr"] 71 | interval: 10s 72 | timeout: 5s 73 | retries: 3 74 | restart: unless-stopped 75 | 76 | volumes: 77 | redis_data: 78 | name: dashbrr_redis_data 79 | postgres_data: 80 | name: dashbrr_postgres_data 81 | 82 | networks: 83 | dashbrr-network: 84 | name: dashbrr-network 85 | driver: bridge 86 | -------------------------------------------------------------------------------- /docker-compose/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | dashbrr: 3 | container_name: dashbrr 4 | image: ghcr.io/autobrr/dashbrr:latest 5 | ports: 6 | - "8080:8080" 7 | environment: 8 | # Database configuration - uncomment desired database type 9 | # SQLite configuration 10 | #- DASHBRR__DB_TYPE=sqlite 11 | #- DASHBRR__DB_PATH=/data/dashbrr.db 12 | # PostgreSQL configuration 13 | - DASHBRR__DB_TYPE=postgres 14 | - DASHBRR__DB_HOST=postgres 15 | - DASHBRR__DB_PORT=5432 16 | - DASHBRR__DB_USER=dashbrr 17 | - DASHBRR__DB_PASSWORD=dashbrr 18 | - DASHBRR__DB_NAME=dashbrr 19 | - DASHBRR__LISTEN_ADDR=0.0.0.0:8080 20 | 21 | #- OIDC_ISSUER=optional 22 | #- OIDC_CLIENT_ID=optional 23 | #- OIDC_CLIENT_SECRET=optional 24 | #- OIDC_REDIRECT_URL=optional 25 | volumes: 26 | - ./data:/data 27 | depends_on: 28 | postgres: 29 | condition: service_healthy 30 | restart: unless-stopped 31 | networks: 32 | - dashbrr-network 33 | 34 | postgres: 35 | container_name: dashbrr-postgres 36 | image: postgres:15-alpine 37 | ports: 38 | - "5432:5432" 39 | environment: 40 | - POSTGRES_USER=dashbrr 41 | - POSTGRES_PASSWORD=dashbrr 42 | - POSTGRES_DB=dashbrr 43 | volumes: 44 | - postgres_data:/var/lib/postgresql/data 45 | networks: 46 | - dashbrr-network 47 | healthcheck: 48 | test: ["CMD-SHELL", "pg_isready -U dashbrr"] 49 | interval: 10s 50 | timeout: 5s 51 | retries: 3 52 | restart: unless-stopped 53 | 54 | volumes: 55 | postgres_data: 56 | name: dashbrr_postgres_data 57 | 58 | networks: 59 | dashbrr-network: 60 | name: dashbrr-network 61 | driver: bridge 62 | -------------------------------------------------------------------------------- /docker-compose/init.sql: -------------------------------------------------------------------------------- 1 | -- init.sql 2 | -- 3 | -- This script initializes the PostgreSQL database for integration tests. 4 | -- It is automatically executed when the test database container starts via 5 | -- docker-compose.integration.yml. 6 | -- 7 | -- The script: 8 | -- 1. Creates a fresh test database 9 | -- 2. Sets up the required schema 10 | -- 3. Configures proper permissions 11 | -- 12 | -- Usage: Referenced in docker-compose.integration.yml as a volume mount: 13 | -- volumes: 14 | -- - ./init.sql:/docker-entrypoint-initdb.d/init.sql 15 | 16 | -- Drop the database if it exists and recreate it 17 | DROP DATABASE IF EXISTS dashbrr_test; 18 | CREATE DATABASE dashbrr_test; 19 | 20 | -- Connect to the test database 21 | \c dashbrr_test; 22 | 23 | -- Create the necessary tables 24 | CREATE TABLE IF NOT EXISTS users ( 25 | id SERIAL PRIMARY KEY, 26 | username TEXT UNIQUE NOT NULL, 27 | email TEXT UNIQUE NOT NULL, 28 | password_hash TEXT NOT NULL, 29 | created_at TIMESTAMP NOT NULL, 30 | updated_at TIMESTAMP NOT NULL 31 | ); 32 | 33 | CREATE TABLE IF NOT EXISTS service_configurations ( 34 | id SERIAL PRIMARY KEY, 35 | instance_id TEXT UNIQUE NOT NULL, 36 | display_name TEXT NOT NULL, 37 | url TEXT, 38 | api_key TEXT, 39 | access_url TEXT 40 | ); 41 | 42 | -- Grant all privileges to the dashbrr user 43 | GRANT ALL PRIVILEGES ON DATABASE dashbrr_test TO dashbrr; 44 | GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO dashbrr; 45 | GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO dashbrr; 46 | -------------------------------------------------------------------------------- /docs/config_management.md: -------------------------------------------------------------------------------- 1 | # Service Discovery and Configuration Management 2 | 3 | ## Overview 4 | 5 | Dashbrr supports automatic service discovery and configuration management through: 6 | 7 | - Docker container labels 8 | - Kubernetes service labels 9 | - External configuration files (YAML/JSON) 10 | 11 | ## Command Usage 12 | 13 | ### Service Discovery 14 | 15 | ```bash 16 | # Discover services from Docker containers 17 | dashbrr config discover --docker 18 | 19 | # Discover services from Kubernetes 20 | dashbrr config discover --k8s 21 | 22 | # Discover from both Docker and Kubernetes 23 | dashbrr config discover 24 | ``` 25 | 26 | ### Configuration Import/Export 27 | 28 | ```bash 29 | # Import services from configuration file 30 | dashbrr config import services.yaml 31 | 32 | # Export current configuration 33 | dashbrr config export --format=yaml --mask-secrets --output=services.yaml 34 | ``` 35 | 36 | ## Docker Label Configuration 37 | 38 | Configure services using Docker container labels: 39 | 40 | ```yaml 41 | labels: 42 | com.dashbrr.service.type: "radarr" # Required: Service type 43 | com.dashbrr.service.url: "http://radarr:7878" # Required: Service URL 44 | com.dashbrr.service.apikey: "${RADARR_API_KEY}" # Required: API key (supports env vars) 45 | com.dashbrr.service.name: "My Radarr" # Optional: Custom display name 46 | com.dashbrr.service.enabled: "true" # Optional: Enable/disable service 47 | ``` 48 | 49 | Example docker-compose.yml: 50 | 51 | ```yaml 52 | version: "3" 53 | services: 54 | radarr: 55 | image: linuxserver/radarr 56 | labels: 57 | com.dashbrr.service.type: "radarr" 58 | com.dashbrr.service.url: "http://radarr:7878" 59 | com.dashbrr.service.apikey: "${RADARR_API_KEY}" 60 | com.dashbrr.service.name: "Movies" 61 | ``` 62 | 63 | ## Kubernetes Label Configuration 64 | 65 | Configure services using Kubernetes service labels: 66 | 67 | ```yaml 68 | apiVersion: v1 69 | kind: Service 70 | metadata: 71 | name: radarr 72 | labels: 73 | com.dashbrr.service.type: "radarr" 74 | com.dashbrr.service.url: "http://radarr.media.svc:7878" 75 | com.dashbrr.service.apikey: "${RADARR_API_KEY}" 76 | com.dashbrr.service.name: "Movies" 77 | com.dashbrr.service.enabled: "true" 78 | spec: 79 | ports: 80 | - port: 7878 81 | selector: 82 | app: radarr 83 | ``` 84 | 85 | ## Configuration File Format 86 | 87 | Services can be configured using YAML or JSON files: 88 | 89 | ```yaml 90 | services: 91 | radarr: 92 | - url: "http://radarr:7878" 93 | apikey: "${RADARR_API_KEY}" 94 | name: "Movies" # Optional 95 | sonarr: 96 | - url: "http://sonarr:8989" 97 | apikey: "${SONARR_API_KEY}" 98 | name: "TV Shows" 99 | prowlarr: 100 | - url: "http://prowlarr:9696" 101 | apikey: "${PROWLARR_API_KEY}" 102 | ``` 103 | 104 | ## Environment Variables 105 | 106 | When using environment variables for API keys (${SERVICE_API_KEY}), the following naming convention is used: 107 | 108 | - `DASHBRR_RADARR_API_KEY` 109 | - `DASHBRR_SONARR_API_KEY` 110 | - `DASHBRR_PROWLARR_API_KEY` 111 | - `DASHBRR_OVERSEERR_API_KEY` 112 | - `DASHBRR_MAINTAINERR_API_KEY` 113 | - `DASHBRR_TAILSCALE_API_KEY` 114 | - `DASHBRR_PLEX_API_KEY` 115 | - `DASHBRR_AUTOBRR_API_KEY` 116 | - `DASHBRR_OMEGABRR_API_KEY` 117 | 118 | ## Security Considerations 119 | 120 | - API keys can be provided via environment variables for enhanced security 121 | - Use `--mask-secrets` when exporting configurations to avoid exposing API keys 122 | - Exported configurations with masked secrets will use environment variable references 123 | - Ensure proper access controls for configuration files containing sensitive information 124 | 125 | ## Best Practices 126 | 127 | 1. Service Discovery: 128 | 129 | - Use consistent naming conventions for services 130 | - Group related services in the same namespace/network 131 | - Use environment variables for API keys 132 | 133 | 2. Configuration Management: 134 | 135 | - Keep a backup of your configuration 136 | - Use version control for configuration files 137 | - Document any custom service configurations 138 | -------------------------------------------------------------------------------- /docs/env_vars.md: -------------------------------------------------------------------------------- 1 | # Environment Variables Documentation 2 | 3 | ## Server Configuration 4 | 5 | - `DASHBRR__LISTEN_ADDR` 6 | - Purpose: Listen address for the server 7 | - Format: `:` 8 | - Default: `0.0.0.0:8080` 9 | 10 | ## Configuration Path 11 | 12 | - `DASHBRR__CONFIG_PATH` 13 | - Purpose: Path to the configuration file 14 | - Default: `config.toml` 15 | - Priority: Environment variable > User config directory > Command line flag > Default value 16 | - Note: The application will check the following locations for the configuration file: 17 | 1. The path specified by the `DASHBRR__CONFIG_PATH` environment variable. 18 | 2. The user config directory (e.g., `~/.config/dashbrr`). 19 | 3. The current working directory for `config.toml`, `config.yaml`, or `config.yml`. 20 | 4. The `--config` command line flag can also be used to specify a different path. 21 | 22 | ## Cache Configuration 23 | 24 | - `CACHE_TYPE` 25 | - Purpose: Cache implementation to use 26 | - Values: `"redis"` or `"memory"` 27 | - Default: `"memory"` (if Redis settings not configured) 28 | 29 | ### Redis Settings 30 | 31 | (Only applicable when `CACHE_TYPE="redis"`) 32 | 33 | - `REDIS_HOST` 34 | 35 | - Purpose: Redis host address 36 | - Default: `localhost` 37 | 38 | - `REDIS_PORT` 39 | - Purpose: Redis port number 40 | - Default: `6379` 41 | 42 | ## Database Configuration 43 | 44 | ### SQLite Configuration 45 | 46 | (When `DASHBRR__DB_TYPE="sqlite"`) 47 | 48 | - `DASHBRR__DB_TYPE` 49 | - Set to: `"sqlite"` 50 | - `DASHBRR__DB_PATH` 51 | - Purpose: Path to SQLite database file 52 | - Example: `/data/dashbrr.db` 53 | - Note: If not set, the database will be created in a 'data' subdirectory of the config file's location. This can be overridden by: 54 | 1. Using the `--db-file` flag when starting dashbrr 55 | 2. Setting this environment variable 56 | 3. Specifying the path in the config file 57 | - Priority: Command line flag > Environment variable > Config file > Default location 58 | 59 | ### PostgreSQL Configuration 60 | 61 | (When `DASHBRR__DB_TYPE="postgres"`) 62 | 63 | - `DASHBRR__DB_TYPE` 64 | - Set to: `"postgres"` 65 | - `DASHBRR__DB_HOST` 66 | - Purpose: PostgreSQL host address 67 | - Default: `postgres` (in Docker) 68 | - `DASHBRR__DB_PORT` 69 | - Purpose: PostgreSQL port 70 | - Default: `5432` 71 | - `DASHBRR__DB_USER` 72 | - Purpose: PostgreSQL username 73 | - Default: `dashbrr` (in Docker) 74 | - `DASHBRR__DB_PASSWORD` 75 | - Purpose: PostgreSQL password 76 | - Default: `dashbrr` (in Docker) 77 | - `DASHBRR__DB_NAME` 78 | - Purpose: PostgreSQL database name 79 | - Default: `dashbrr` (in Docker) 80 | 81 | ## Authentication (OIDC) 82 | 83 | (Optional OpenID Connect configuration) 84 | 85 | - `OIDC_ISSUER` 86 | 87 | - Purpose: Your OIDC provider's issuer URL 88 | - Required if using OIDC 89 | 90 | - `OIDC_CLIENT_ID` 91 | 92 | - Purpose: Client ID from your OIDC provider 93 | - Required if using OIDC 94 | 95 | - `OIDC_CLIENT_SECRET` 96 | 97 | - Purpose: Client secret from your OIDC provider 98 | - Required if using OIDC 99 | 100 | - `OIDC_REDIRECT_URL` 101 | - Purpose: Callback URL for OIDC authentication 102 | - Example: `http://localhost:3000/api/auth/callback` 103 | - Required if using OIDC 104 | -------------------------------------------------------------------------------- /internal/api/handlers/auth_config.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, s0up and the autobrr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package handlers 5 | 6 | import ( 7 | "net/http" 8 | "os" 9 | 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | // GetAuthConfig returns the available authentication methods 14 | func GetAuthConfig(c *gin.Context) { 15 | hasOIDC := os.Getenv("OIDC_ISSUER") != "" && 16 | os.Getenv("OIDC_CLIENT_ID") != "" && 17 | os.Getenv("OIDC_CLIENT_SECRET") != "" 18 | 19 | defaultMethod := "builtin" 20 | if hasOIDC { 21 | defaultMethod = "oidc" 22 | } 23 | 24 | c.JSON(http.StatusOK, gin.H{ 25 | "methods": map[string]bool{ 26 | "builtin": !hasOIDC, // Built-in auth is only available when OIDC is not configured 27 | "oidc": hasOIDC, 28 | }, 29 | "default": defaultMethod, 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /internal/api/handlers/queue_hash.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, s0up and the autobrr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package handlers 5 | 6 | import ( 7 | "fmt" 8 | "strings" 9 | 10 | "github.com/autobrr/dashbrr/internal/types" 11 | ) 12 | 13 | // QueueRecordWrapper is a common wrapper for queue records 14 | type QueueRecordWrapper struct { 15 | ID int 16 | Title string 17 | Status string 18 | Size int64 19 | } 20 | 21 | // wrapRadarrQueue converts RadarrQueueResponse to slice of QueueRecordWrapper 22 | func wrapRadarrQueue(queue *types.RadarrQueueResponse) []QueueRecordWrapper { 23 | if queue == nil || len(queue.Records) == 0 { 24 | return nil 25 | } 26 | 27 | result := make([]QueueRecordWrapper, len(queue.Records)) 28 | for i, record := range queue.Records { 29 | result[i] = QueueRecordWrapper{ 30 | ID: record.ID, 31 | Title: record.Title, 32 | Status: record.Status, 33 | Size: record.Size, 34 | } 35 | } 36 | return result 37 | } 38 | 39 | // wrapSonarrQueue converts SonarrQueueResponse to slice of QueueRecordWrapper 40 | func wrapSonarrQueue(queue *types.SonarrQueueResponse) []QueueRecordWrapper { 41 | if queue == nil || len(queue.Records) == 0 { 42 | return nil 43 | } 44 | 45 | result := make([]QueueRecordWrapper, len(queue.Records)) 46 | for i, record := range queue.Records { 47 | result[i] = QueueRecordWrapper{ 48 | ID: record.ID, 49 | Title: record.Title, 50 | Status: record.Status, 51 | Size: record.Size, 52 | } 53 | } 54 | return result 55 | } 56 | 57 | // generateQueueHash creates a hash string from queue records 58 | func generateQueueHash(records []QueueRecordWrapper) string { 59 | if len(records) == 0 { 60 | return "" 61 | } 62 | 63 | var sb strings.Builder 64 | for _, record := range records { 65 | fmt.Fprintf(&sb, "%d:%s:%s:%d,", 66 | record.ID, 67 | record.Title, 68 | record.Status, 69 | record.Size) 70 | } 71 | return sb.String() 72 | } 73 | 74 | // detectQueueChanges determines the type of change in a queue 75 | func detectQueueChanges(oldHash, newHash string) string { 76 | if oldHash == "" { 77 | return "initial_queue" 78 | } 79 | 80 | oldRecords := strings.Split(oldHash, ",") 81 | newRecords := strings.Split(newHash, ",") 82 | 83 | if len(oldRecords) < len(newRecords) { 84 | return "download_added" 85 | } else if len(oldRecords) > len(newRecords) { 86 | return "download_completed" 87 | } 88 | 89 | return "download_updated" 90 | } 91 | -------------------------------------------------------------------------------- /internal/api/handlers/testing/mocks.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, s0up and the autobrr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package testing 5 | 6 | import ( 7 | "context" 8 | "github.com/autobrr/dashbrr/internal/models" 9 | "github.com/autobrr/dashbrr/internal/types" 10 | ) 11 | 12 | // MockDB implements database operations for testing 13 | type MockDB struct { 14 | FindServiceByFunc func(ctx context.Context, params types.FindServiceParams) (*models.ServiceConfiguration, error) 15 | GetAllServicesFunc func() ([]models.ServiceConfiguration, error) 16 | CreateServiceFunc func(*models.ServiceConfiguration) error 17 | UpdateServiceFunc func(*models.ServiceConfiguration) error 18 | DeleteServiceFunc func(string) error 19 | } 20 | 21 | // FindServiceBy implements the database method 22 | func (m *MockDB) FindServiceBy(ctx context.Context, params types.FindServiceParams) (*models.ServiceConfiguration, error) { 23 | if m.FindServiceByFunc != nil { 24 | return m.FindServiceByFunc(ctx, params) 25 | } 26 | return nil, nil 27 | } 28 | 29 | // GetAllServices implements the database method 30 | func (m *MockDB) GetAllServices() ([]models.ServiceConfiguration, error) { 31 | if m.GetAllServicesFunc != nil { 32 | return m.GetAllServicesFunc() 33 | } 34 | return []models.ServiceConfiguration{}, nil 35 | } 36 | 37 | // CreateService implements the database method 38 | func (m *MockDB) CreateService(config *models.ServiceConfiguration) error { 39 | if m.CreateServiceFunc != nil { 40 | return m.CreateServiceFunc(config) 41 | } 42 | return nil 43 | } 44 | 45 | // UpdateService implements the database method 46 | func (m *MockDB) UpdateService(config *models.ServiceConfiguration) error { 47 | if m.UpdateServiceFunc != nil { 48 | return m.UpdateServiceFunc(config) 49 | } 50 | return nil 51 | } 52 | 53 | // DeleteService implements the database method 54 | func (m *MockDB) DeleteService(instanceID string) error { 55 | if m.DeleteServiceFunc != nil { 56 | return m.DeleteServiceFunc(instanceID) 57 | } 58 | return nil 59 | } 60 | -------------------------------------------------------------------------------- /internal/api/middleware/cors.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, s0up and the autobrr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package middleware 5 | 6 | import ( 7 | "time" 8 | 9 | "github.com/gin-contrib/cors" 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | // SetupCORS returns the CORS middleware configuration 14 | func SetupCORS() gin.HandlerFunc { 15 | config := cors.Config{ 16 | AllowOrigins: []string{"*"}, 17 | AllowMethods: []string{ 18 | "GET", 19 | "POST", 20 | "PUT", 21 | "PATCH", 22 | "DELETE", 23 | "OPTIONS", 24 | }, 25 | AllowHeaders: []string{ 26 | "Origin", 27 | "Authorization", 28 | "Content-Type", 29 | "Accept", 30 | "X-Requested-With", 31 | }, 32 | ExposeHeaders: []string{"Content-Length", "Content-Type"}, 33 | MaxAge: 12 * time.Hour, 34 | } 35 | 36 | return cors.New(config) 37 | } 38 | -------------------------------------------------------------------------------- /internal/api/middleware/csrf.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, s0up and the autobrr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package middleware 5 | 6 | import ( 7 | "crypto/rand" 8 | "encoding/base64" 9 | "errors" 10 | "net/http" 11 | "strings" 12 | "time" 13 | 14 | "github.com/gin-gonic/gin" 15 | ) 16 | 17 | const ( 18 | csrfTokenLength = 32 19 | csrfTokenHeader = "X-CSRF-Token" 20 | csrfTokenCookie = "csrf_token" 21 | csrfTokenDuration = 24 * time.Hour 22 | ) 23 | 24 | var ( 25 | ErrTokenMissing = errors.New("CSRF token missing") 26 | ErrTokenMismatch = errors.New("CSRF token mismatch") 27 | ) 28 | 29 | // CSRFConfig holds configuration for CSRF protection 30 | type CSRFConfig struct { 31 | // Secure indicates if the cookie should be sent only over HTTPS 32 | Secure bool 33 | // Cookie path 34 | Path string 35 | // Cookie domain 36 | Domain string 37 | // Cookie max age in seconds 38 | MaxAge int 39 | // If true, cookie is not accessible via JavaScript 40 | HttpOnly bool 41 | // Methods that don't require CSRF validation 42 | ExemptMethods []string 43 | // Paths that don't require CSRF validation 44 | ExemptPaths []string 45 | } 46 | 47 | // DefaultCSRFConfig returns the default CSRF configuration 48 | func DefaultCSRFConfig() *CSRFConfig { 49 | return &CSRFConfig{ 50 | Secure: true, 51 | Path: "/", 52 | HttpOnly: true, 53 | MaxAge: int(csrfTokenDuration.Seconds()), 54 | ExemptMethods: []string{"GET", "HEAD", "OPTIONS"}, 55 | ExemptPaths: []string{}, 56 | } 57 | } 58 | 59 | // generateCSRFToken generates a random CSRF token 60 | func generateCSRFToken() (string, error) { 61 | b := make([]byte, csrfTokenLength) 62 | if _, err := rand.Read(b); err != nil { 63 | return "", err 64 | } 65 | return base64.URLEncoding.EncodeToString(b), nil 66 | } 67 | 68 | // CSRF returns a middleware that provides CSRF protection 69 | func CSRF(config *CSRFConfig) gin.HandlerFunc { 70 | if config == nil { 71 | config = DefaultCSRFConfig() 72 | } 73 | 74 | return func(c *gin.Context) { 75 | // Check if the path is exempt 76 | for _, path := range config.ExemptPaths { 77 | if strings.HasPrefix(c.Request.URL.Path, path) { 78 | c.Next() 79 | return 80 | } 81 | } 82 | 83 | // Check if the method is exempt 84 | method := strings.ToUpper(c.Request.Method) 85 | for _, m := range config.ExemptMethods { 86 | if method == m { 87 | // For GET requests, set a new token if one doesn't exist 88 | if method == "GET" { 89 | _, err := c.Cookie(csrfTokenCookie) 90 | if err == http.ErrNoCookie { 91 | token, err := generateCSRFToken() 92 | if err != nil { 93 | c.AbortWithStatus(http.StatusInternalServerError) 94 | return 95 | } 96 | c.SetCookie(csrfTokenCookie, token, config.MaxAge, config.Path, 97 | config.Domain, config.Secure, config.HttpOnly) 98 | c.Header(csrfTokenHeader, token) 99 | } 100 | } 101 | c.Next() 102 | return 103 | } 104 | } 105 | 106 | // Get the token from the cookie 107 | cookie, err := c.Cookie(csrfTokenCookie) 108 | if err != nil { 109 | c.JSON(http.StatusForbidden, gin.H{"error": ErrTokenMissing.Error()}) 110 | c.Abort() 111 | return 112 | } 113 | 114 | // Get the token from the header 115 | header := c.GetHeader(csrfTokenHeader) 116 | if header == "" { 117 | c.JSON(http.StatusForbidden, gin.H{"error": ErrTokenMissing.Error()}) 118 | c.Abort() 119 | return 120 | } 121 | 122 | // Compare the cookie token with the header token 123 | if cookie != header { 124 | c.JSON(http.StatusForbidden, gin.H{"error": ErrTokenMismatch.Error()}) 125 | c.Abort() 126 | return 127 | } 128 | 129 | // Generate a new token for the next request 130 | newToken, err := generateCSRFToken() 131 | if err != nil { 132 | c.AbortWithStatus(http.StatusInternalServerError) 133 | return 134 | } 135 | 136 | // Set the new token in both cookie and header 137 | c.SetCookie(csrfTokenCookie, newToken, config.MaxAge, config.Path, 138 | config.Domain, config.Secure, config.HttpOnly) 139 | c.Header(csrfTokenHeader, newToken) 140 | 141 | c.Next() 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /internal/api/middleware/logging.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, s0up and the autobrr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package middleware 5 | 6 | import ( 7 | "net/url" 8 | "os" 9 | "strings" 10 | 11 | "github.com/gin-gonic/gin" 12 | "github.com/rs/zerolog" 13 | "github.com/rs/zerolog/log" 14 | ) 15 | 16 | func init() { 17 | // Enable console writer with colors 18 | output := zerolog.ConsoleWriter{ 19 | Out: os.Stdout, 20 | NoColor: false, 21 | } 22 | log.Logger = zerolog.New(output).With().Timestamp().Logger() 23 | } 24 | 25 | // Logger returns a gin middleware for logging HTTP requests with zerolog 26 | func Logger() gin.HandlerFunc { 27 | return func(c *gin.Context) { 28 | //start := time.Now() 29 | 30 | // Process request 31 | c.Next() 32 | 33 | // Get the query string and redact sensitive information 34 | query := c.Request.URL.RawQuery 35 | if query != "" { 36 | parsedURL, err := url.ParseQuery(query) 37 | if err == nil { 38 | // List of parameter names to redact 39 | sensitiveParams := []string{ 40 | "apiKey", 41 | "api_key", 42 | "key", 43 | "token", 44 | "password", 45 | "secret", 46 | } 47 | 48 | // Redact sensitive parameters 49 | for param := range parsedURL { 50 | for _, sensitive := range sensitiveParams { 51 | if strings.Contains(strings.ToLower(param), strings.ToLower(sensitive)) { 52 | parsedURL.Set(param, "[REDACTED]") 53 | } 54 | } 55 | } 56 | query = parsedURL.Encode() 57 | } 58 | } 59 | 60 | // Create the path with redacted query string 61 | path := c.Request.URL.Path 62 | if query != "" { 63 | path = path + "?" + query 64 | } 65 | 66 | // Get error if exists 67 | //var err error 68 | //if len(c.Errors) > 0 { 69 | // err = c.Errors.Last() 70 | //} 71 | 72 | // Log the request with zerolog 73 | //event := log.Info() 74 | //if err != nil { 75 | // event = log.Error().Err(err) 76 | //} 77 | 78 | //event. 79 | // Str("method", c.Request.Method). 80 | // Str("path", path). 81 | // Int("status", c.Writer.Status()). 82 | // Dur("latency", time.Since(start)). 83 | // Str("ip", c.ClientIP()). 84 | // Int("bytes", c.Writer.Size()). 85 | // Msg("HTTP Request") 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /internal/api/middleware/ratelimit.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, s0up and the autobrr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package middleware 5 | 6 | import ( 7 | "fmt" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/gin-gonic/gin" 12 | "github.com/rs/zerolog/log" 13 | 14 | "github.com/autobrr/dashbrr/internal/services/cache" 15 | ) 16 | 17 | type RateLimiter struct { 18 | store cache.Store 19 | window time.Duration 20 | limit int 21 | keyPrefix string 22 | } 23 | 24 | // NewRateLimiter creates a new rate limiter with the specified configuration 25 | func NewRateLimiter(store cache.Store, window time.Duration, limit int, keyPrefix string) *RateLimiter { 26 | if window == 0 { 27 | window = time.Hour 28 | } 29 | if limit == 0 { 30 | limit = 1000 31 | } 32 | return &RateLimiter{ 33 | store: store, 34 | window: window, 35 | limit: limit, 36 | keyPrefix: keyPrefix, 37 | } 38 | } 39 | 40 | // RateLimit returns a Gin middleware function that implements rate limiting 41 | func (rl *RateLimiter) RateLimit() gin.HandlerFunc { 42 | return func(c *gin.Context) { 43 | // Get client IP 44 | clientIP := c.ClientIP() 45 | if clientIP == "" { 46 | c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not determine client IP"}) 47 | c.Abort() 48 | return 49 | } 50 | 51 | // Create key for this IP and endpoint 52 | endpoint := c.Request.URL.Path 53 | key := fmt.Sprintf("%s%s:%s", rl.keyPrefix, endpoint, clientIP) 54 | now := time.Now().Unix() 55 | windowStart := now - int64(rl.window.Seconds()) 56 | 57 | // Clean up old requests 58 | if err := rl.store.CleanAndCount(c, key, windowStart); err != nil { 59 | log.Error().Err(err).Msg("Failed to clean rate limit data") 60 | c.Next() // Continue on error 61 | return 62 | } 63 | 64 | // Get current count 65 | count, err := rl.store.GetCount(c, key) 66 | if err != nil { 67 | log.Error().Err(err).Msg("Failed to get rate limit count") 68 | c.Next() // Continue on error 69 | return 70 | } 71 | 72 | // Check if limit exceeded 73 | if count >= int64(rl.limit) { 74 | retryAfter := windowStart + int64(rl.window.Seconds()) - now 75 | c.Header("Retry-After", fmt.Sprintf("%d", retryAfter)) 76 | c.Header("X-RateLimit-Limit", fmt.Sprintf("%d", rl.limit)) 77 | c.Header("X-RateLimit-Remaining", "0") 78 | c.Header("X-RateLimit-Reset", fmt.Sprintf("%d", windowStart+int64(rl.window.Seconds()))) 79 | 80 | c.JSON(http.StatusTooManyRequests, gin.H{ 81 | "error": "Rate limit exceeded", 82 | "limit": rl.limit, 83 | "window": rl.window.String(), 84 | "retry_after": retryAfter, 85 | }) 86 | c.Abort() 87 | return 88 | } 89 | 90 | // Record this request 91 | if err := rl.store.Increment(c, key, now); err != nil { 92 | log.Error().Err(err).Msg("Failed to record request") 93 | c.Next() // Continue on error 94 | return 95 | } 96 | 97 | // Set expiration 98 | if err := rl.store.Expire(c, key, rl.window); err != nil { 99 | log.Error().Err(err).Msg("Failed to set expiration") 100 | } 101 | 102 | // Set rate limit headers 103 | remaining := rl.limit - int(count) - 1 104 | if remaining < 0 { 105 | remaining = 0 106 | } 107 | c.Header("X-RateLimit-Limit", fmt.Sprintf("%d", rl.limit)) 108 | c.Header("X-RateLimit-Remaining", fmt.Sprintf("%d", remaining)) 109 | c.Header("X-RateLimit-Reset", fmt.Sprintf("%d", windowStart+int64(rl.window.Seconds()))) 110 | 111 | c.Next() 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /internal/buildinfo/buildinfo.go: -------------------------------------------------------------------------------- 1 | package buildinfo 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "runtime" 7 | ) 8 | 9 | var ( 10 | Version = "dev" 11 | Commit = "" 12 | Date = "" 13 | ) 14 | 15 | // AttachUserAgentHeader attaches a User-Agent header to the request 16 | func AttachUserAgentHeader(req *http.Request) { 17 | agent := fmt.Sprintf("dashbrr/%s (%s %s)", Version, runtime.GOOS, runtime.GOARCH) 18 | 19 | req.Header.Set("User-Agent", agent) 20 | } 21 | -------------------------------------------------------------------------------- /internal/commands/service.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/autobrr/dashbrr/internal/database" 7 | 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func initializeDatabase() (*database.DB, error) { 12 | dbPath := "./data/dashbrr.db" 13 | db, err := database.InitDB(dbPath) 14 | if err != nil { 15 | return nil, fmt.Errorf("failed to initialize database: %v", err) 16 | } 17 | return db, nil 18 | } 19 | 20 | func ServiceCommand() *cobra.Command { 21 | command := &cobra.Command{ 22 | Use: "service", 23 | Short: "Manage services", 24 | Long: `Manage services`, 25 | Example: ` dashbrr service 26 | dashbrr service --help`, 27 | //SilenceUsage: true, 28 | } 29 | 30 | command.RunE = func(cmd *cobra.Command, args []string) error { 31 | return cmd.Usage() 32 | } 33 | 34 | command.AddCommand(ServiceListCommand()) 35 | 36 | command.AddCommand(ServiceAutobrrCommand()) 37 | command.AddCommand(ServiceGeneralCommand()) 38 | command.AddCommand(ServiceMaintainerrCommand()) 39 | command.AddCommand(ServiceOmegabrrCommand()) 40 | command.AddCommand(ServiceOverseerrCommand()) 41 | command.AddCommand(ServicePlexCommand()) 42 | command.AddCommand(ServiceProwlarrCommand()) 43 | command.AddCommand(ServiceRadarrCommand()) 44 | command.AddCommand(ServiceSonarrCommand()) 45 | command.AddCommand(ServiceTailscaleCommand()) 46 | 47 | return command 48 | } 49 | 50 | //func ServiceAddCommand() *cobra.Command { 51 | // command := &cobra.Command{ 52 | // Use: "add", 53 | // Short: "add", 54 | // Long: `add`, 55 | // Example: ` dashbrr service add 56 | // dashbrr service add --help`, 57 | // //SilenceUsage: true, 58 | // } 59 | // 60 | // command.RunE = func(cmd *cobra.Command, args []string) error { 61 | // return cmd.Usage() 62 | // } 63 | // 64 | // command.AddCommand(ServiceAutobrrAddCommand()) 65 | // 66 | // return command 67 | //} 68 | 69 | func ServiceListCommand() *cobra.Command { 70 | command := &cobra.Command{ 71 | Use: "list", 72 | Short: "list", 73 | Long: `list`, 74 | Example: ` dashbrr service list 75 | dashbrr service list --help`, 76 | //SilenceUsage: true, 77 | } 78 | 79 | command.RunE = func(cmd *cobra.Command, args []string) error { 80 | //"Manage service configurations", 81 | // " [arguments]\n\n"+ 82 | // " Service Types:\n"+ 83 | // " autobrr - Autobrr service management\n"+ 84 | // " maintainerr - Maintainerr service management\n"+ 85 | // " omegabrr - Omegabrr service management\n\n"+ 86 | // " overseerr - Overseerr service management\n"+ 87 | // " plex - Plex service management\n"+ 88 | // " prowlarr - Prowlarr service management\n"+ 89 | // " radarr - Radarr service management\n"+ 90 | // " sonarr - Sonarr service management\n"+ 91 | // " tailscale - Tailscale service management\n"+ 92 | // " general - General service management\n"+ 93 | // " Use 'dashbrr run help service ' for more information", 94 | return cmd.Usage() 95 | } 96 | 97 | command.AddCommand(ServiceAutobrrListCommand()) 98 | 99 | return command 100 | } 101 | 102 | //func ServiceRemoveCommand() *cobra.Command { 103 | // command := &cobra.Command{ 104 | // Use: "remove", 105 | // Short: "remove", 106 | // Long: `remove`, 107 | // Example: ` dashbrr service remove 108 | // dashbrr service remove --help`, 109 | // //SilenceUsage: true, 110 | // } 111 | // 112 | // command.RunE = func(cmd *cobra.Command, args []string) error { 113 | // return cmd.Usage() 114 | // } 115 | // 116 | // command.AddCommand(ServiceAutobrrRemoveCommand()) 117 | // 118 | // return command 119 | //} 120 | -------------------------------------------------------------------------------- /internal/commands/version.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "os" 10 | "time" 11 | 12 | "github.com/autobrr/dashbrr/internal/buildinfo" 13 | 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | const githubAPIURL = "https://api.github.com/repos/autobrr/dashbrr/releases/latest" 18 | 19 | type VersionInfo struct { 20 | Version string `json:"version"` 21 | Commit string `json:"commit"` 22 | Date string `json:"date"` 23 | } 24 | 25 | type GitHubRelease struct { 26 | TagName string `json:"tag_name"` 27 | Name string `json:"name"` 28 | PublishedAt time.Time `json:"published_at"` 29 | HTMLURL string `json:"html_url"` 30 | } 31 | 32 | func VersionCommand() *cobra.Command { 33 | command := &cobra.Command{ 34 | Use: "version", 35 | Short: "Print version and check for updates", 36 | Long: `Print version and check for updates`, 37 | Example: ` dashbrr version --check-github 38 | dashbrr version --json 39 | dashbrr version --help`, 40 | //SilenceUsage: true, 41 | } 42 | 43 | var ( 44 | outputJson = false 45 | checkUpdate = false 46 | ) 47 | 48 | command.Flags().BoolVar(&outputJson, "json", false, "output in JSON format") 49 | command.Flags().BoolVar(&checkUpdate, "check-github", false, "check for updates") 50 | 51 | command.RunE = func(cmd *cobra.Command, args []string) error { 52 | // Get current version info 53 | current := VersionInfo{ 54 | Version: buildinfo.Version, 55 | Commit: buildinfo.Commit, 56 | Date: buildinfo.Date, 57 | } 58 | 59 | if outputJson { 60 | return versionOutputJSON(checkUpdate, current) 61 | } 62 | 63 | // Print current version 64 | fmt.Printf("dashbrr version %s\n", current.Version) 65 | fmt.Printf("Commit: %s\n", current.Commit) 66 | fmt.Printf("Built: %s\n", current.Date) 67 | 68 | // Check GitHub if requested 69 | if checkUpdate { 70 | release, err := getLatestRelease(cmd.Context()) 71 | if err != nil { 72 | return fmt.Errorf("failed to check latest version: %w", err) 73 | } 74 | 75 | fmt.Printf("\nLatest release:\n") 76 | fmt.Printf("Version: %s\n", release.TagName) 77 | fmt.Printf("Name: %s\n", release.Name) 78 | fmt.Printf("Published: %s\n", release.PublishedAt.Format(time.RFC3339)) 79 | fmt.Printf("URL: %s\n", release.HTMLURL) 80 | 81 | if release.TagName != current.Version { 82 | fmt.Printf("\nUpdate available: %s -> %s\n", current.Version, release.TagName) 83 | } 84 | } 85 | 86 | return nil 87 | } 88 | 89 | return command 90 | } 91 | 92 | func getLatestRelease(ctx context.Context) (*GitHubRelease, error) { 93 | req, err := http.NewRequestWithContext(ctx, "GET", githubAPIURL, nil) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | req.Header.Set("Accept", "application/vnd.github.v3+json") 99 | buildinfo.AttachUserAgentHeader(req) 100 | 101 | resp, err := http.DefaultClient.Do(req) 102 | if err != nil { 103 | return nil, err 104 | } 105 | defer resp.Body.Close() 106 | 107 | if resp.StatusCode != http.StatusOK { 108 | body, _ := io.ReadAll(resp.Body) 109 | return nil, fmt.Errorf("GitHub API returned %d: %s", resp.StatusCode, string(body)) 110 | } 111 | 112 | var release GitHubRelease 113 | if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { 114 | return nil, err 115 | } 116 | 117 | return &release, nil 118 | } 119 | 120 | func versionOutputJSON(check bool, info VersionInfo) error { 121 | type jsonOutput struct { 122 | Current VersionInfo `json:"current"` 123 | Latest *GitHubRelease `json:"latest,omitempty"` 124 | } 125 | 126 | output := jsonOutput{ 127 | Current: info, 128 | } 129 | 130 | if check { 131 | latest, err := getLatestRelease(context.Background()) 132 | if err != nil { 133 | return err 134 | } 135 | output.Latest = latest 136 | } 137 | 138 | encoder := json.NewEncoder(os.Stdout) 139 | encoder.SetIndent("", " ") 140 | return encoder.Encode(output) 141 | } 142 | -------------------------------------------------------------------------------- /internal/database/migrations/migrations.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, s0up and the autobrr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package migrations 5 | 6 | import "embed" 7 | 8 | var ( 9 | //go:embed *.sql 10 | SchemaMigrations embed.FS 11 | ) 12 | -------------------------------------------------------------------------------- /internal/database/migrations/postgres.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, s0up and the autobrr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package migrations 5 | 6 | import ( 7 | "database/sql" 8 | 9 | "github.com/autobrr/dashbrr/pkg/migrator" 10 | 11 | "github.com/dcarbone/zadapters/zstdlog" 12 | "github.com/rs/zerolog" 13 | "github.com/rs/zerolog/log" 14 | ) 15 | 16 | func PostgresMigrator(db *sql.DB) error { 17 | migrate := migrator.NewMigrate( 18 | db, 19 | migrator.WithEmbedFS(SchemaMigrations), 20 | migrator.WithLogger(zstdlog.NewStdLoggerWithLevel(log.With().Str("module", "database-migrations").Logger(), zerolog.DebugLevel)), 21 | ) 22 | 23 | migrate.Add( 24 | &migrator.Migration{ 25 | Name: "000_base_schema", 26 | File: "postgres_schema.sql", 27 | }, 28 | &migrator.Migration{ 29 | Name: "001_add_service_access_url", 30 | RunTx: func(db *sql.Tx) error { 31 | _, err := db.Exec("ALTER TABLE service_configurations ADD COLUMN access_url TEXT") 32 | if err != nil { 33 | return err 34 | } 35 | return nil 36 | }, 37 | }, 38 | ) 39 | 40 | err := migrate.Migrate() 41 | if err != nil { 42 | return err 43 | } 44 | 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /internal/database/migrations/postgres_schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS users 2 | ( 3 | id SERIAL PRIMARY KEY, 4 | username TEXT UNIQUE NOT NULL, 5 | email TEXT UNIQUE NOT NULL, 6 | password_hash TEXT NOT NULL, 7 | created_at TIMESTAMP NOT NULL, 8 | updated_at TIMESTAMP NOT NULL 9 | ); 10 | 11 | CREATE TABLE IF NOT EXISTS service_configurations 12 | ( 13 | id SERIAL PRIMARY KEY, 14 | instance_id TEXT UNIQUE NOT NULL, 15 | display_name TEXT NOT NULL, 16 | url TEXT, 17 | api_key TEXT, 18 | access_url TEXT 19 | ); -------------------------------------------------------------------------------- /internal/database/migrations/sqlite.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, s0up and the autobrr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package migrations 5 | 6 | import ( 7 | "database/sql" 8 | 9 | "github.com/autobrr/dashbrr/pkg/migrator" 10 | 11 | "github.com/dcarbone/zadapters/zstdlog" 12 | "github.com/rs/zerolog" 13 | "github.com/rs/zerolog/log" 14 | ) 15 | 16 | func SQLiteMigrator(db *sql.DB) error { 17 | migrate := migrator.NewMigrate( 18 | db, 19 | migrator.WithEmbedFS(SchemaMigrations), 20 | migrator.WithLogger(zstdlog.NewStdLoggerWithLevel(log.With().Str("module", "database-migrations").Logger(), zerolog.DebugLevel)), 21 | ) 22 | 23 | migrate.Add( 24 | &migrator.Migration{ 25 | Name: "000_base_schema", 26 | File: "sqlite_schema.sql", 27 | }, 28 | &migrator.Migration{ 29 | Name: "001_add_service_access_url", 30 | RunTx: func(db *sql.Tx) error { 31 | _, err := db.Exec("ALTER TABLE service_configurations ADD COLUMN access_url TEXT") 32 | if err != nil { 33 | return err 34 | } 35 | return nil 36 | }, 37 | }, 38 | ) 39 | 40 | err := migrate.Migrate() 41 | if err != nil { 42 | return err 43 | } 44 | 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /internal/database/migrations/sqlite_schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS users 2 | ( 3 | id INTEGER PRIMARY KEY, 4 | username TEXT UNIQUE NOT NULL, 5 | email TEXT UNIQUE NOT NULL, 6 | password_hash TEXT NOT NULL, 7 | created_at TIMESTAMP NOT NULL, 8 | updated_at TIMESTAMP NOT NULL 9 | ); 10 | 11 | CREATE TABLE IF NOT EXISTS service_configurations 12 | ( 13 | id INTEGER PRIMARY KEY, 14 | instance_id TEXT UNIQUE NOT NULL, 15 | display_name TEXT NOT NULL, 16 | url TEXT, 17 | api_key TEXT, 18 | access_url TEXT 19 | ); 20 | -------------------------------------------------------------------------------- /internal/logger/logger.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, s0up and the autobrr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package logger 5 | 6 | import ( 7 | "os" 8 | "strings" 9 | 10 | "github.com/rs/zerolog" 11 | "github.com/rs/zerolog/log" 12 | ) 13 | 14 | // Init initializes the global logger with colored output 15 | func Init() { 16 | colors := map[string]string{ 17 | "trace": "\033[36m", // Cyan 18 | "debug": "\033[33m", // Yellow 19 | "info": "\033[34m", // Blue 20 | "warn": "\033[33m", // Yellow 21 | "error": "\033[31m", // Red 22 | "fatal": "\033[35m", // Magenta 23 | "panic": "\033[35m", // Magenta 24 | } 25 | 26 | output := zerolog.ConsoleWriter{ 27 | Out: os.Stdout, 28 | NoColor: false, 29 | FormatLevel: func(i interface{}) string { 30 | level, ok := i.(string) 31 | if !ok { 32 | return "???" 33 | } 34 | color := colors[level] 35 | if color == "" { 36 | color = "\033[37m" // Default to white 37 | } 38 | return color + strings.ToUpper(level) + "\033[0m" 39 | }, 40 | } 41 | log.Logger = zerolog.New(output).With().Timestamp().Logger() 42 | } 43 | -------------------------------------------------------------------------------- /internal/models/registry.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, s0up and the autobrr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package models 5 | 6 | import ( 7 | "strings" 8 | ) 9 | 10 | // ServiceCreator is responsible for creating service instances 11 | type ServiceCreator interface { 12 | CreateService(serviceType string) ServiceHealthChecker 13 | } 14 | 15 | // ServiceRegistry is the default implementation of ServiceCreator 16 | type ServiceRegistry struct{} 17 | 18 | // CreateService returns a new service instance based on the service type 19 | func (r *ServiceRegistry) CreateService(serviceType string) ServiceHealthChecker { 20 | switch strings.ToLower(serviceType) { 21 | case "autobrr": 22 | if NewAutobrrService != nil { 23 | return NewAutobrrService() 24 | } 25 | case "radarr": 26 | if NewRadarrService != nil { 27 | return NewRadarrService() 28 | } 29 | case "sonarr": 30 | if NewSonarrService != nil { 31 | return NewSonarrService() 32 | } 33 | case "prowlarr": 34 | if NewProwlarrService != nil { 35 | return NewProwlarrService() 36 | } 37 | case "overseerr": 38 | if NewOverseerrService != nil { 39 | return NewOverseerrService() 40 | } 41 | case "plex": 42 | if NewPlexService != nil { 43 | return NewPlexService() 44 | } 45 | case "omegabrr": 46 | if NewOmegabrrService != nil { 47 | return NewOmegabrrService() 48 | } 49 | case "tailscale": 50 | if NewTailscaleService != nil { 51 | return NewTailscaleService() 52 | } 53 | case "maintainerr": 54 | if NewMaintainerrService != nil { 55 | return NewMaintainerrService() 56 | } 57 | case "general": 58 | if NewGeneralService != nil { 59 | return NewGeneralService() 60 | } 61 | } 62 | // Return nil for unknown service types 63 | return nil 64 | } 65 | 66 | // NewServiceRegistry creates a new instance of ServiceRegistry 67 | func NewServiceRegistry() ServiceCreator { 68 | return &ServiceRegistry{} 69 | } 70 | -------------------------------------------------------------------------------- /internal/models/registry_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, s0up and the autobrr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package models 5 | 6 | import ( 7 | "testing" 8 | ) 9 | 10 | func TestNewServiceRegistry(t *testing.T) { 11 | registry := NewServiceRegistry() 12 | if registry == nil { 13 | t.Error("Expected non-nil registry") 14 | } 15 | } 16 | 17 | func TestCreateService(t *testing.T) { 18 | registry := NewServiceRegistry() 19 | 20 | // Test unknown service type 21 | service := registry.CreateService("nonexistent") 22 | if service != nil { 23 | t.Error("Expected nil for unknown service type") 24 | } 25 | 26 | // Test case insensitivity 27 | // Mock a service creator for testing 28 | originalAutobrrService := NewAutobrrService 29 | defer func() { NewAutobrrService = originalAutobrrService }() 30 | 31 | called := false 32 | NewAutobrrService = func() ServiceHealthChecker { 33 | called = true 34 | return nil 35 | } 36 | 37 | // Test with different cases 38 | registry.CreateService("AUTOBRR") 39 | if !called { 40 | t.Error("Service creator not called for uppercase service type") 41 | } 42 | 43 | called = false 44 | registry.CreateService("autobrr") 45 | if !called { 46 | t.Error("Service creator not called for lowercase service type") 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /internal/models/service.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, s0up and the autobrr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package models 5 | 6 | import ( 7 | "context" 8 | "time" 9 | ) 10 | 11 | // Service represents a configured service instance 12 | type Service struct { 13 | ID string `json:"id"` 14 | Type string `json:"type"` 15 | URL string `json:"url"` 16 | AccessURL string `json:"accessUrl,omitempty"` // New field for external access URL 17 | APIKey string `json:"apiKey,omitempty"` 18 | Name string `json:"name"` 19 | DisplayName string `json:"displayName,omitempty"` 20 | HealthEndpoint string `json:"healthEndpoint,omitempty"` 21 | } 22 | 23 | // ServiceHealth represents the health status of a service 24 | type ServiceHealth struct { 25 | Status string `json:"status"` 26 | ResponseTime int64 `json:"responseTime"` 27 | LastChecked time.Time `json:"lastChecked"` 28 | Message string `json:"message,omitempty"` 29 | Version string `json:"version,omitempty"` 30 | UpdateAvailable bool `json:"updateAvailable,omitempty"` 31 | ServiceID string `json:"serviceId"` 32 | Stats map[string]interface{} `json:"stats,omitempty"` 33 | Details map[string]interface{} `json:"details,omitempty"` 34 | } 35 | 36 | // ServiceHealthChecker defines the interface for service health checking 37 | type ServiceHealthChecker interface { 38 | CheckHealth(ctx context.Context, url, apiKey string) (ServiceHealth, int) 39 | } 40 | 41 | // Service creation function types 42 | var ( 43 | NewAutobrrService func() ServiceHealthChecker 44 | NewRadarrService func() ServiceHealthChecker 45 | NewSonarrService func() ServiceHealthChecker 46 | NewProwlarrService func() ServiceHealthChecker 47 | NewOverseerrService func() ServiceHealthChecker 48 | NewPlexService func() ServiceHealthChecker 49 | NewOmegabrrService func() ServiceHealthChecker 50 | NewTailscaleService func() ServiceHealthChecker 51 | NewMaintainerrService func() ServiceHealthChecker 52 | NewGeneralService func() ServiceHealthChecker 53 | ) 54 | -------------------------------------------------------------------------------- /internal/models/settings.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, s0up and the autobrr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package models 5 | 6 | // ServiceConfiguration is the database model 7 | type ServiceConfiguration struct { 8 | ID int64 `json:"-"` // Hide ID from JSON response 9 | InstanceID string `json:"instanceId" gorm:"uniqueIndex"` 10 | DisplayName string `json:"displayName"` 11 | URL string `json:"url"` 12 | APIKey string `json:"apiKey,omitempty"` 13 | AccessURL string `json:"accessUrl,omitempty"` 14 | } 15 | -------------------------------------------------------------------------------- /internal/services/arr/warnings.go: -------------------------------------------------------------------------------- 1 | package arr 2 | 3 | // WarningCategory represents the category of a warning message 4 | type WarningCategory string 5 | 6 | const ( 7 | SystemCategory WarningCategory = "System" 8 | DownloadCategory WarningCategory = "Download" 9 | IndexerCategory WarningCategory = "Indexer" 10 | MediaCategory WarningCategory = "Media" 11 | NotificationCategory WarningCategory = "Notification" 12 | ApplicationCategory WarningCategory = "Application" 13 | ) 14 | 15 | // WarningPattern represents a known warning pattern and its categorization 16 | type WarningPattern struct { 17 | Pattern string 18 | Category WarningCategory 19 | } 20 | 21 | // Known warning patterns for *arr applications 22 | var knownWarnings = []WarningPattern{ 23 | // System warnings 24 | {Pattern: "Branch is not a valid release branch", Category: SystemCategory}, 25 | {Pattern: "Update to .NET", Category: SystemCategory}, 26 | {Pattern: "installed mono version", Category: SystemCategory}, 27 | {Pattern: "installed SQLite version", Category: SystemCategory}, 28 | {Pattern: "Database Failed Integrity", Category: SystemCategory}, 29 | {Pattern: "update is available", Category: SystemCategory}, 30 | {Pattern: "folder is not writable", Category: SystemCategory}, 31 | {Pattern: "System Time is off", Category: SystemCategory}, 32 | {Pattern: "connect to signalR", Category: SystemCategory}, 33 | {Pattern: "resolve the IP Address", Category: SystemCategory}, 34 | {Pattern: "Proxy Failed Test", Category: SystemCategory}, 35 | {Pattern: "Branch is for a previous version", Category: SystemCategory}, 36 | {Pattern: "app translocation folder", Category: SystemCategory}, 37 | {Pattern: "Invalid API Key", Category: SystemCategory}, 38 | {Pattern: "Recycling Bin", Category: SystemCategory}, 39 | {Pattern: "Unable to reach", Category: SystemCategory}, 40 | 41 | // Download client warnings 42 | {Pattern: "download client is available", Category: DownloadCategory}, 43 | {Pattern: "communicate with download client", Category: DownloadCategory}, 44 | {Pattern: "Download clients are unavailable", Category: DownloadCategory}, 45 | {Pattern: "Completed Download Handling", Category: DownloadCategory}, 46 | {Pattern: "Docker bad remote path", Category: DownloadCategory}, 47 | {Pattern: "Downloading into Root", Category: DownloadCategory}, 48 | {Pattern: "Bad Download Client", Category: DownloadCategory}, 49 | {Pattern: "Remote Path Mapping", Category: DownloadCategory}, 50 | {Pattern: "download client requires sorting", Category: DownloadCategory}, 51 | {Pattern: "Unable to reach Freebox", Category: DownloadCategory}, 52 | 53 | // Indexer warnings 54 | {Pattern: "indexers available", Category: IndexerCategory}, 55 | {Pattern: "indexers are enabled", Category: IndexerCategory}, 56 | {Pattern: "Indexers are unavailable", Category: IndexerCategory}, 57 | {Pattern: "Jackett All Endpoint", Category: IndexerCategory}, 58 | {Pattern: "indexer unavailable due to failures", Category: IndexerCategory}, 59 | {Pattern: "RSS sync enabled", Category: IndexerCategory}, 60 | {Pattern: "automatic search enabled", Category: IndexerCategory}, 61 | {Pattern: "interactive search enabled", Category: IndexerCategory}, 62 | {Pattern: "No Definition", Category: IndexerCategory}, 63 | {Pattern: "Indexers are Obsolete", Category: IndexerCategory}, 64 | {Pattern: "Obsolete due to", Category: IndexerCategory}, 65 | {Pattern: "VIP Expiring", Category: IndexerCategory}, 66 | {Pattern: "VIP Expired", Category: IndexerCategory}, 67 | {Pattern: "Long-term indexer", Category: IndexerCategory}, 68 | {Pattern: "Unable to connect to indexer", Category: IndexerCategory}, 69 | {Pattern: "check your DNS settings", Category: IndexerCategory}, 70 | {Pattern: "Search failed", Category: IndexerCategory}, 71 | 72 | // Application warnings (Prowlarr specific) 73 | {Pattern: "Applications are unavailable", Category: ApplicationCategory}, 74 | {Pattern: "applications are unavailable", Category: ApplicationCategory}, 75 | 76 | // Media warnings 77 | {Pattern: "Root Folder", Category: MediaCategory}, 78 | {Pattern: "removed from TMDb", Category: MediaCategory}, 79 | {Pattern: "removed from TheTVDB", Category: MediaCategory}, 80 | {Pattern: "Mount is Read Only", Category: MediaCategory}, 81 | {Pattern: "Import List", Category: MediaCategory}, 82 | {Pattern: "Lists are unavailable", Category: MediaCategory}, 83 | {Pattern: "Test was aborted", Category: MediaCategory}, 84 | 85 | // Notification warnings 86 | {Pattern: "Notifications unavailable", Category: NotificationCategory}, 87 | {Pattern: "Discord as Slack", Category: NotificationCategory}, 88 | 89 | // Proxy warnings 90 | {Pattern: "resolve proxy", Category: SystemCategory}, 91 | {Pattern: "proxy failed", Category: SystemCategory}, 92 | } 93 | -------------------------------------------------------------------------------- /internal/services/autobrr/client.go: -------------------------------------------------------------------------------- 1 | package autobrr 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | ) 7 | 8 | // Client represents an Autobrr service client 9 | type Client struct { 10 | BaseURL string 11 | APIKey string 12 | http *http.Client 13 | } 14 | 15 | // HealthCheckResponse represents the response from a health check 16 | type HealthCheckResponse struct { 17 | Status string `json:"status"` 18 | Version string `json:"version"` 19 | } 20 | 21 | // NewClient creates a new Autobrr service client 22 | func NewClient(baseURL, apiKey string) *Client { 23 | return &Client{ 24 | BaseURL: baseURL, 25 | APIKey: apiKey, 26 | http: &http.Client{ 27 | Timeout: 10 * time.Second, 28 | }, 29 | } 30 | } 31 | 32 | // HealthCheck performs a health check on the Autobrr service 33 | func (c *Client) HealthCheck() (*HealthCheckResponse, error) { 34 | // For now, return a mock health check response 35 | // In a real implementation, you'd make an actual HTTP request 36 | return &HealthCheckResponse{ 37 | Status: "OK", 38 | Version: "1.0.0", // This would be dynamically retrieved in a real implementation 39 | }, nil 40 | } 41 | -------------------------------------------------------------------------------- /internal/services/cache/interface.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, s0up and the autobrr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package cache 5 | 6 | import ( 7 | "context" 8 | "time" 9 | ) 10 | 11 | // Store defines the caching operations. 12 | // Implementations must be safe for concurrent use. 13 | type Store interface { 14 | Get(ctx context.Context, key string, value interface{}) error 15 | Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error 16 | Delete(ctx context.Context, key string) error 17 | Increment(ctx context.Context, key string, timestamp int64) error 18 | CleanAndCount(ctx context.Context, key string, windowStart int64) error 19 | GetCount(ctx context.Context, key string) (int64, error) 20 | Expire(ctx context.Context, key string, expiration time.Duration) error 21 | Close() error 22 | } 23 | -------------------------------------------------------------------------------- /internal/services/discovery/constants.go: -------------------------------------------------------------------------------- 1 | package discovery 2 | 3 | const ( 4 | // labelPrefix is the common prefix for all dashbrr service labels 5 | labelPrefix = "com.dashbrr.service" 6 | 7 | // Common label suffixes 8 | labelTypeKey = "type" // Service type (e.g., radarr, sonarr) 9 | labelURLKey = "url" // Service URL 10 | labelAPIKeyKey = "apikey" // Service API key 11 | labelNameKey = "name" // Optional display name override 12 | labelEnabledKey = "enabled" // Optional service enabled state 13 | ) 14 | 15 | // GetLabelKey returns the full label key for a given suffix 16 | func GetLabelKey(suffix string) string { 17 | return labelPrefix + "." + suffix 18 | } 19 | -------------------------------------------------------------------------------- /internal/services/discovery/discovery.go: -------------------------------------------------------------------------------- 1 | package discovery 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/autobrr/dashbrr/internal/models" 8 | ) 9 | 10 | // ServiceDiscoverer defines the interface for service discovery implementations 11 | type ServiceDiscoverer interface { 12 | // DiscoverServices finds and returns service configurations 13 | DiscoverServices(ctx context.Context) ([]models.ServiceConfiguration, error) 14 | // Close cleans up any resources used by the discoverer 15 | Close() error 16 | } 17 | 18 | // Manager handles multiple service discovery methods 19 | type Manager struct { 20 | discoverers []ServiceDiscoverer 21 | } 22 | 23 | // NewManager creates a new discovery manager 24 | func NewManager() (*Manager, error) { 25 | var discoverers []ServiceDiscoverer 26 | 27 | // Try to initialize Docker discovery 28 | if docker, err := NewDockerDiscovery(); err == nil { 29 | discoverers = append(discoverers, docker) 30 | } 31 | 32 | // Try to initialize Kubernetes discovery 33 | if k8s, err := NewKubernetesDiscovery(); err == nil { 34 | discoverers = append(discoverers, k8s) 35 | } 36 | 37 | if len(discoverers) == 0 { 38 | return nil, fmt.Errorf("no service discovery methods available") 39 | } 40 | 41 | return &Manager{ 42 | discoverers: discoverers, 43 | }, nil 44 | } 45 | 46 | // DiscoverAll finds services using all available discovery methods 47 | func (m *Manager) DiscoverAll(ctx context.Context) ([]models.ServiceConfiguration, error) { 48 | var allServices []models.ServiceConfiguration 49 | 50 | for _, discoverer := range m.discoverers { 51 | services, err := discoverer.DiscoverServices(ctx) 52 | if err != nil { 53 | // Log error but continue with other discoverers 54 | fmt.Printf("Warning: Service discovery error: %v\n", err) 55 | continue 56 | } 57 | allServices = append(allServices, services...) 58 | } 59 | 60 | return allServices, nil 61 | } 62 | 63 | // Close cleans up all discoverers 64 | func (m *Manager) Close() error { 65 | var lastErr error 66 | for _, discoverer := range m.discoverers { 67 | if err := discoverer.Close(); err != nil { 68 | lastErr = err 69 | } 70 | } 71 | return lastErr 72 | } 73 | 74 | // ValidateService checks if a discovered service configuration is valid 75 | func ValidateService(service models.ServiceConfiguration) error { 76 | if service.InstanceID == "" { 77 | return fmt.Errorf("instance ID is required") 78 | } 79 | if service.DisplayName == "" { 80 | return fmt.Errorf("display name is required") 81 | } 82 | if service.URL == "" { 83 | return fmt.Errorf("URL is required") 84 | } 85 | if service.APIKey == "" { 86 | return fmt.Errorf("API key is required") 87 | } 88 | return nil 89 | } 90 | -------------------------------------------------------------------------------- /internal/services/discovery/docker.go: -------------------------------------------------------------------------------- 1 | package discovery 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/docker/docker/api/types/container" 10 | "github.com/docker/docker/api/types/filters" 11 | "github.com/docker/docker/client" 12 | 13 | "github.com/autobrr/dashbrr/internal/models" 14 | ) 15 | 16 | // DockerDiscovery handles service discovery from Docker labels 17 | type DockerDiscovery struct { 18 | client *client.Client 19 | } 20 | 21 | // NewDockerDiscovery creates a new Docker discovery instance 22 | func NewDockerDiscovery() (*DockerDiscovery, error) { 23 | cli, err := client.NewClientWithOpts(client.FromEnv) 24 | if err != nil { 25 | return nil, fmt.Errorf("failed to create Docker client: %w", err) 26 | } 27 | 28 | return &DockerDiscovery{ 29 | client: cli, 30 | }, nil 31 | } 32 | 33 | // DiscoverServices finds services configured via Docker labels 34 | func (d *DockerDiscovery) DiscoverServices(ctx context.Context) ([]models.ServiceConfiguration, error) { 35 | // Create a filter for dashbrr service labels 36 | f := filters.NewArgs() 37 | f.Add("label", GetLabelKey(labelTypeKey)) 38 | 39 | containers, err := d.client.ContainerList(ctx, container.ListOptions{ 40 | All: false, 41 | Filters: f, 42 | }) 43 | if err != nil { 44 | return nil, fmt.Errorf("failed to list containers: %w", err) 45 | } 46 | 47 | var services []models.ServiceConfiguration 48 | 49 | for _, container := range containers { 50 | service, err := d.parseContainerLabels(container.Labels) 51 | if err != nil { 52 | fmt.Printf("Warning: Failed to parse labels for container %s: %v\n", container.ID[:12], err) 53 | continue 54 | } 55 | if service != nil { 56 | services = append(services, *service) 57 | } 58 | } 59 | 60 | return services, nil 61 | } 62 | 63 | // parseContainerLabels extracts service configuration from container labels 64 | func (d *DockerDiscovery) parseContainerLabels(labels map[string]string) (*models.ServiceConfiguration, error) { 65 | serviceType := labels[GetLabelKey(labelTypeKey)] 66 | if serviceType == "" { 67 | return nil, fmt.Errorf("service type label not found") 68 | } 69 | 70 | url := labels[GetLabelKey(labelURLKey)] 71 | if url == "" { 72 | return nil, fmt.Errorf("service URL label not found") 73 | } 74 | 75 | // Handle environment variable substitution in API key 76 | apiKey := labels[GetLabelKey(labelAPIKeyKey)] 77 | if strings.HasPrefix(apiKey, "${") && strings.HasSuffix(apiKey, "}") { 78 | envVar := strings.TrimSuffix(strings.TrimPrefix(apiKey, "${"), "}") 79 | apiKey = os.Getenv(envVar) 80 | if apiKey == "" { 81 | return nil, fmt.Errorf("environment variable %s not set for API key", envVar) 82 | } 83 | } 84 | 85 | // Get optional display name or use service type 86 | displayName := labels[GetLabelKey(labelNameKey)] 87 | if displayName == "" { 88 | displayName = strings.Title(serviceType) 89 | } 90 | 91 | // Check if service is explicitly disabled 92 | if enabled := labels[GetLabelKey(labelEnabledKey)]; enabled == "false" { 93 | return nil, nil 94 | } 95 | 96 | // Generate instance ID based on service type 97 | instanceID := fmt.Sprintf("%s-docker", serviceType) 98 | 99 | return &models.ServiceConfiguration{ 100 | InstanceID: instanceID, 101 | DisplayName: displayName, 102 | URL: url, 103 | APIKey: apiKey, 104 | }, nil 105 | } 106 | 107 | // Close closes the Docker client connection 108 | func (d *DockerDiscovery) Close() error { 109 | if d.client != nil { 110 | return d.client.Close() 111 | } 112 | return nil 113 | } 114 | -------------------------------------------------------------------------------- /internal/services/discovery/kubernetes.go: -------------------------------------------------------------------------------- 1 | package discovery 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | "k8s.io/client-go/kubernetes" 12 | "k8s.io/client-go/tools/clientcmd" 13 | "k8s.io/client-go/util/homedir" 14 | 15 | "github.com/autobrr/dashbrr/internal/models" 16 | ) 17 | 18 | // KubernetesDiscovery handles service discovery from Kubernetes labels 19 | type KubernetesDiscovery struct { 20 | client *kubernetes.Clientset 21 | } 22 | 23 | // NewKubernetesDiscovery creates a new Kubernetes discovery instance 24 | func NewKubernetesDiscovery() (*KubernetesDiscovery, error) { 25 | // Try to load kubeconfig from standard locations 26 | var kubeconfig string 27 | if home := homedir.HomeDir(); home != "" { 28 | kubeconfig = filepath.Join(home, ".kube", "config") 29 | } 30 | 31 | // Allow overriding kubeconfig location via environment variable 32 | if envKubeconfig := os.Getenv("KUBECONFIG"); envKubeconfig != "" { 33 | kubeconfig = envKubeconfig 34 | } 35 | 36 | // Create the config from kubeconfig file 37 | config, err := clientcmd.BuildConfigFromFlags("", kubeconfig) 38 | if err != nil { 39 | return nil, fmt.Errorf("failed to build kubeconfig: %w", err) 40 | } 41 | 42 | // Create the clientset 43 | clientset, err := kubernetes.NewForConfig(config) 44 | if err != nil { 45 | return nil, fmt.Errorf("failed to create Kubernetes client: %w", err) 46 | } 47 | 48 | return &KubernetesDiscovery{ 49 | client: clientset, 50 | }, nil 51 | } 52 | 53 | // DiscoverServices finds services configured via Kubernetes labels 54 | func (k *KubernetesDiscovery) DiscoverServices(ctx context.Context) ([]models.ServiceConfiguration, error) { 55 | // List all services in all namespaces with dashbrr labels 56 | services, err := k.client.CoreV1().Services("").List(ctx, metav1.ListOptions{ 57 | LabelSelector: GetLabelKey(labelTypeKey), 58 | }) 59 | if err != nil { 60 | return nil, fmt.Errorf("failed to list services: %w", err) 61 | } 62 | 63 | var configurations []models.ServiceConfiguration 64 | 65 | for _, service := range services.Items { 66 | config, err := k.parseServiceLabels(service.Labels, service.Namespace) 67 | if err != nil { 68 | fmt.Printf("Warning: Failed to parse labels for service %s/%s: %v\n", 69 | service.Namespace, service.Name, err) 70 | continue 71 | } 72 | if config != nil { 73 | configurations = append(configurations, *config) 74 | } 75 | } 76 | 77 | return configurations, nil 78 | } 79 | 80 | // parseServiceLabels extracts service configuration from Kubernetes labels 81 | func (k *KubernetesDiscovery) parseServiceLabels(labels map[string]string, namespace string) (*models.ServiceConfiguration, error) { 82 | serviceType := labels[GetLabelKey(labelTypeKey)] 83 | if serviceType == "" { 84 | return nil, fmt.Errorf("service type label not found") 85 | } 86 | 87 | url := labels[GetLabelKey(labelURLKey)] 88 | if url == "" { 89 | return nil, fmt.Errorf("service URL label not found") 90 | } 91 | 92 | // Handle environment variable substitution in API key 93 | apiKey := labels[GetLabelKey(labelAPIKeyKey)] 94 | if strings.HasPrefix(apiKey, "${") && strings.HasSuffix(apiKey, "}") { 95 | envVar := strings.TrimSuffix(strings.TrimPrefix(apiKey, "${"), "}") 96 | apiKey = os.Getenv(envVar) 97 | if apiKey == "" { 98 | return nil, fmt.Errorf("environment variable %s not set for API key", envVar) 99 | } 100 | } 101 | 102 | // Get optional display name or use service type 103 | displayName := labels[GetLabelKey(labelNameKey)] 104 | if displayName == "" { 105 | displayName = strings.Title(serviceType) 106 | } 107 | 108 | // Check if service is explicitly disabled 109 | if enabled := labels[GetLabelKey(labelEnabledKey)]; enabled == "false" { 110 | return nil, nil 111 | } 112 | 113 | // Generate instance ID based on service type and namespace 114 | instanceID := fmt.Sprintf("%s-k8s-%s", serviceType, namespace) 115 | 116 | return &models.ServiceConfiguration{ 117 | InstanceID: instanceID, 118 | DisplayName: displayName, 119 | URL: url, 120 | APIKey: apiKey, 121 | }, nil 122 | } 123 | 124 | // Close is a no-op for Kubernetes client 125 | func (k *KubernetesDiscovery) Close() error { 126 | return nil 127 | } 128 | -------------------------------------------------------------------------------- /internal/services/general/general.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, s0up and the autobrr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package general 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "fmt" 10 | "io" 11 | "net/http" 12 | "strings" 13 | "time" 14 | 15 | "github.com/autobrr/dashbrr/internal/models" 16 | "github.com/autobrr/dashbrr/internal/services/core" 17 | ) 18 | 19 | func init() { 20 | models.NewGeneralService = NewGeneralService 21 | } 22 | 23 | func NewGeneralService() models.ServiceHealthChecker { 24 | service := &GeneralService{} 25 | service.Type = "general" 26 | service.DisplayName = "" // Allow display name to be set via configuration 27 | service.Description = "Generic health check service for any URL endpoint" 28 | service.SetTimeout(core.DefaultTimeout) 29 | return service 30 | } 31 | 32 | type GeneralService struct { 33 | core.ServiceCore 34 | } 35 | 36 | func (s *GeneralService) CheckHealth(ctx context.Context, url, apiKey string) (models.ServiceHealth, int) { 37 | startTime := time.Now() 38 | 39 | if url == "" { 40 | return s.CreateHealthResponse(startTime, "error", "URL is required"), http.StatusBadRequest 41 | } 42 | 43 | // Create a child context with timeout if needed 44 | healthCtx, cancel := context.WithTimeout(ctx, core.DefaultTimeout) 45 | defer cancel() 46 | 47 | headers := make(map[string]string) 48 | if apiKey != "" { 49 | headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey) 50 | } 51 | 52 | resp, err := s.MakeRequestWithContext(healthCtx, url, apiKey, headers) 53 | if err != nil { 54 | return s.CreateHealthResponse(startTime, "offline", fmt.Sprintf("Failed to connect: %v", err)), http.StatusServiceUnavailable 55 | } 56 | defer resp.Body.Close() 57 | 58 | // Calculate response time directly 59 | responseTime := time.Since(startTime).Milliseconds() 60 | 61 | body, err := io.ReadAll(resp.Body) 62 | if err != nil { 63 | return s.CreateHealthResponse(startTime, "error", fmt.Sprintf("Failed to read response: %v", err)), http.StatusInternalServerError 64 | } 65 | 66 | // Try to parse as JSON first 67 | var jsonResponse map[string]interface{} 68 | if err := json.Unmarshal(body, &jsonResponse); err == nil { 69 | // Handle JSON response 70 | status := "online" 71 | message := "" 72 | 73 | if statusVal, ok := jsonResponse["status"].(string); ok { 74 | // Map status values to our supported statuses 75 | switch strings.ToLower(statusVal) { 76 | case "healthy", "ok", "online": 77 | status = "online" 78 | case "unhealthy", "error", "offline": 79 | status = "offline" 80 | case "warning": 81 | status = "warning" 82 | default: 83 | status = "unknown" 84 | } 85 | } 86 | if messageVal, ok := jsonResponse["message"].(string); ok { 87 | message = messageVal 88 | } 89 | 90 | extras := map[string]interface{}{ 91 | "responseTime": responseTime, 92 | } 93 | 94 | return s.CreateHealthResponse(startTime, status, message, extras), resp.StatusCode 95 | } 96 | 97 | // If JSON parsing fails, treat as plain text 98 | textResponse := strings.TrimSpace(string(body)) 99 | extras := map[string]interface{}{ 100 | "responseTime": responseTime, 101 | } 102 | 103 | if strings.EqualFold(textResponse, "ok") { 104 | return s.CreateHealthResponse(startTime, "online", "", extras), resp.StatusCode 105 | } 106 | 107 | return s.CreateHealthResponse(startTime, "error", fmt.Sprintf("Unexpected response: %s", textResponse), extras), resp.StatusCode 108 | } 109 | 110 | func (s *GeneralService) GetVersion(ctx context.Context, url, apiKey string) (string, error) { 111 | return "", nil // Version not supported for general service 112 | } 113 | 114 | func (s *GeneralService) GetLatestVersion(ctx context.Context) (string, error) { 115 | return "", nil // Version not supported for general service 116 | } 117 | -------------------------------------------------------------------------------- /internal/services/resilience/resilience.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, s0up and the autobrr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package resilience 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "math/rand/v2" 10 | "sync" 11 | "time" 12 | ) 13 | 14 | const ( 15 | MaxRetries = 3 16 | InitialBackoff = 100 * time.Millisecond 17 | MaxBackoff = 2 * time.Second 18 | ) 19 | 20 | // CircuitBreaker implements a simple circuit breaker pattern 21 | type CircuitBreaker struct { 22 | failures int 23 | lastFailure time.Time 24 | mutex sync.RWMutex 25 | maxFailures int 26 | resetTimeout time.Duration 27 | } 28 | 29 | func NewCircuitBreaker(maxFailures int, resetTimeout time.Duration) *CircuitBreaker { 30 | return &CircuitBreaker{ 31 | maxFailures: maxFailures, 32 | resetTimeout: resetTimeout, 33 | } 34 | } 35 | 36 | func (cb *CircuitBreaker) IsOpen() bool { 37 | cb.mutex.RLock() 38 | defer cb.mutex.RUnlock() 39 | 40 | if cb.failures >= cb.maxFailures { 41 | if time.Since(cb.lastFailure) > cb.resetTimeout { 42 | // Reset circuit breaker after timeout 43 | cb.mutex.RUnlock() 44 | cb.mutex.Lock() 45 | cb.failures = 0 46 | cb.mutex.Unlock() 47 | cb.mutex.RLock() 48 | return false 49 | } 50 | return true 51 | } 52 | return false 53 | } 54 | 55 | func (cb *CircuitBreaker) RecordFailure() { 56 | cb.mutex.Lock() 57 | defer cb.mutex.Unlock() 58 | 59 | cb.failures++ 60 | cb.lastFailure = time.Now() 61 | } 62 | 63 | func (cb *CircuitBreaker) RecordSuccess() { 64 | cb.mutex.Lock() 65 | defer cb.mutex.Unlock() 66 | 67 | cb.failures = 0 68 | } 69 | 70 | // RetryWithBackoff implements exponential backoff retry logic 71 | func RetryWithBackoff(ctx context.Context, fn func() error) error { 72 | var err error 73 | backoff := InitialBackoff 74 | 75 | for i := 0; i < MaxRetries; i++ { 76 | if err = fn(); err == nil { 77 | return nil 78 | } 79 | 80 | // Check if context is cancelled before sleeping 81 | select { 82 | case <-ctx.Done(): 83 | return ctx.Err() 84 | case <-time.After(backoff): 85 | // Exponential backoff with jitter 86 | jitter := time.Duration(float64(backoff) * (0.5 + rand.Float64())) // Add 50-150% jitter 87 | backoff = time.Duration(float64(backoff) * 2) 88 | if backoff > MaxBackoff { 89 | backoff = MaxBackoff 90 | } 91 | backoff += jitter 92 | } 93 | } 94 | 95 | return fmt.Errorf("failed after %d retries: %w", MaxRetries, err) 96 | } 97 | -------------------------------------------------------------------------------- /internal/services/services.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, s0up and the autobrr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package services 5 | 6 | import ( 7 | // Import all services to register their init functions 8 | 9 | _ "github.com/autobrr/dashbrr/internal/services/autobrr" 10 | _ "github.com/autobrr/dashbrr/internal/services/general" 11 | _ "github.com/autobrr/dashbrr/internal/services/maintainerr" 12 | _ "github.com/autobrr/dashbrr/internal/services/omegabrr" 13 | _ "github.com/autobrr/dashbrr/internal/services/overseerr" 14 | _ "github.com/autobrr/dashbrr/internal/services/plex" 15 | _ "github.com/autobrr/dashbrr/internal/services/prowlarr" 16 | _ "github.com/autobrr/dashbrr/internal/services/radarr" 17 | _ "github.com/autobrr/dashbrr/internal/services/sonarr" 18 | _ "github.com/autobrr/dashbrr/internal/services/tailscale" 19 | ) 20 | -------------------------------------------------------------------------------- /internal/types/auth.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, s0up and the autobrr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package types 5 | 6 | import "time" 7 | 8 | // AuthConfig holds the OIDC configuration 9 | type AuthConfig struct { 10 | Issuer string 11 | ClientID string 12 | ClientSecret string 13 | RedirectURL string 14 | } 15 | 16 | // SessionData holds the session information 17 | type SessionData struct { 18 | AccessToken string `json:"access_token"` 19 | TokenType string `json:"token_type"` 20 | RefreshToken string `json:"refresh_token"` 21 | IDToken string `json:"id_token"` 22 | ExpiresAt time.Time `json:"expires_at"` 23 | UserID int64 `json:"user_id,omitempty"` // Added for built-in auth 24 | AuthType string `json:"auth_type,omitempty"` // "oidc" or "builtin" 25 | } 26 | 27 | // User represents a user in the system 28 | type User struct { 29 | ID int64 `json:"id"` 30 | Username string `json:"username"` 31 | Email string `json:"email"` 32 | PasswordHash string `json:"-"` 33 | CreatedAt time.Time `json:"created_at"` 34 | UpdatedAt time.Time `json:"updated_at"` 35 | } 36 | 37 | // LoginRequest represents the login credentials 38 | type LoginRequest struct { 39 | Username string `json:"username" binding:"required"` 40 | Password string `json:"password" binding:"required"` 41 | } 42 | 43 | // RegisterRequest represents the registration data 44 | type RegisterRequest struct { 45 | Username string `json:"username" binding:"required,min=3,max=32"` 46 | Email string `json:"email" binding:"required,email"` 47 | Password string `json:"password" binding:"required,min=8"` 48 | } 49 | -------------------------------------------------------------------------------- /internal/types/overseerr.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, s0up and the autobrr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package types 5 | 6 | import "time" 7 | 8 | type StatusResponse struct { 9 | Version string `json:"version"` 10 | CommitTag string `json:"commitTag"` 11 | Status int `json:"status"` 12 | UpdateAvailable bool `json:"updateAvailable"` 13 | } 14 | 15 | type RequestsResponse struct { 16 | PageInfo struct { 17 | Pages int `json:"pages"` 18 | PageSize int `json:"pageSize"` 19 | Results int `json:"results"` 20 | Page int `json:"page"` 21 | } `json:"pageInfo"` 22 | Results []MediaRequest `json:"results"` 23 | } 24 | 25 | type MediaRequest struct { 26 | ID int `json:"id"` 27 | Status int `json:"status"` 28 | CreatedAt time.Time `json:"createdAt"` 29 | UpdatedAt time.Time `json:"updatedAt"` 30 | Media struct { 31 | ID int `json:"id"` 32 | TmdbID int `json:"tmdbId"` 33 | TvdbID int `json:"tvdbId"` 34 | Status int `json:"status"` 35 | Requests []string `json:"requests"` 36 | CreatedAt string `json:"createdAt"` 37 | UpdatedAt string `json:"updatedAt"` 38 | MediaType string `json:"mediaType"` 39 | ServiceUrl string `json:"serviceUrl"` 40 | Title string `json:"title,omitempty"` 41 | ExternalServiceID int `json:"externalServiceId,omitempty"` 42 | } `json:"media"` 43 | RequestedBy struct { 44 | ID int `json:"id"` 45 | Email string `json:"email"` 46 | Username string `json:"username"` 47 | PlexToken string `json:"plexToken"` 48 | PlexUsername string `json:"plexUsername"` 49 | UserType int `json:"userType"` 50 | Permissions int `json:"permissions"` 51 | Avatar string `json:"avatar"` 52 | CreatedAt string `json:"createdAt"` 53 | UpdatedAt string `json:"updatedAt"` 54 | RequestCount int `json:"requestCount"` 55 | } `json:"requestedBy"` 56 | ModifiedBy struct { 57 | ID int `json:"id"` 58 | Email string `json:"email"` 59 | Username string `json:"username"` 60 | PlexToken string `json:"plexToken"` 61 | PlexUsername string `json:"plexUsername"` 62 | UserType int `json:"userType"` 63 | Permissions int `json:"permissions"` 64 | Avatar string `json:"avatar"` 65 | CreatedAt string `json:"createdAt"` 66 | UpdatedAt string `json:"updatedAt"` 67 | RequestCount int `json:"requestCount"` 68 | } `json:"modifiedBy"` 69 | Is4k bool `json:"is4k"` 70 | ServerID int `json:"serverId"` 71 | ProfileID int `json:"profileId"` 72 | RootFolder string `json:"rootFolder"` 73 | } 74 | 75 | type RequestsStats struct { 76 | PendingCount int `json:"pendingCount"` 77 | Requests []MediaRequest `json:"requests"` 78 | } 79 | 80 | type OverseerrStats struct { 81 | Requests []MediaRequest `json:"requests"` 82 | PendingCount int `json:"pendingCount"` 83 | } 84 | 85 | type OverseerrDetails struct { 86 | PendingCount int `json:"pendingCount"` 87 | TotalRequests int `json:"totalRequests"` 88 | } 89 | 90 | type OverseerrServiceHealth struct { 91 | ServiceID string `json:"serviceId"` 92 | Status string `json:"status"` 93 | Message string `json:"message"` 94 | LastChecked time.Time `json:"lastChecked"` 95 | Stats OverseerrStats `json:"stats"` 96 | Details OverseerrDetails `json:"details"` 97 | } 98 | -------------------------------------------------------------------------------- /internal/types/prowlarr.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, s0up and the autobrr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package types 5 | 6 | type ProwlarrStatsResponse struct { 7 | GrabCount int `json:"grabCount"` 8 | FailCount int `json:"failCount"` 9 | IndexerCount int `json:"indexerCount"` 10 | } 11 | 12 | type ProwlarrIndexer struct { 13 | ID int `json:"id"` 14 | Name string `json:"name"` 15 | Label string `json:"label"` 16 | Enable bool `json:"enable"` 17 | Priority int `json:"priority"` 18 | AverageResponseTime int `json:"averageResponseTime"` 19 | NumberOfGrabs int `json:"numberOfGrabs"` 20 | NumberOfQueries int `json:"numberOfQueries"` 21 | } 22 | 23 | type ProwlarrIndexerStats struct { 24 | ID int `json:"id"` 25 | IndexerID int `json:"indexerId"` 26 | IndexerName string `json:"indexerName"` 27 | AverageResponseTime int `json:"averageResponseTime"` 28 | NumberOfQueries int `json:"numberOfQueries"` 29 | NumberOfGrabs int `json:"numberOfGrabs"` 30 | NumberOfRssQueries int `json:"numberOfRssQueries"` 31 | NumberOfAuthQueries int `json:"numberOfAuthQueries"` 32 | NumberOfFailedQueries int `json:"numberOfFailedQueries"` 33 | NumberOfFailedGrabs int `json:"numberOfFailedGrabs"` 34 | NumberOfFailedRssQueries int `json:"numberOfFailedRssQueries"` 35 | NumberOfFailedAuthQueries int `json:"numberOfFailedAuthQueries"` 36 | } 37 | 38 | type ProwlarrIndexerStatsResponse struct { 39 | Indexers []ProwlarrIndexerStats `json:"indexers"` 40 | } 41 | -------------------------------------------------------------------------------- /internal/types/radarr.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, s0up and the autobrr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package types 5 | 6 | // RadarrQueueResponse represents the queue response from Radarr API 7 | type RadarrQueueResponse struct { 8 | Page int `json:"page"` 9 | PageSize int `json:"pageSize"` 10 | SortKey string `json:"sortKey"` 11 | SortDirection string `json:"sortDirection"` 12 | TotalRecords int `json:"totalRecords"` 13 | Records []RadarrQueueRecord `json:"records"` 14 | } 15 | 16 | // RadarrQueueRecord represents a record in the Radarr queue 17 | type RadarrQueueRecord struct { 18 | ID int `json:"id"` 19 | MovieID int `json:"movieId"` 20 | Title string `json:"title"` 21 | Status string `json:"status"` 22 | TimeLeft string `json:"timeleft,omitempty"` 23 | EstimatedCompletionTime string `json:"estimatedCompletionTime"` 24 | Protocol string `json:"protocol"` // "usenet" or "torrent" 25 | Indexer string `json:"indexer"` 26 | DownloadClient string `json:"downloadClient"` 27 | Size int64 `json:"size"` 28 | SizeLeft int64 `json:"sizeleft"` 29 | CustomFormatScore int `json:"customFormatScore"` 30 | TrackedDownloadStatus string `json:"trackedDownloadStatus"` 31 | TrackedDownloadState string `json:"trackedDownloadState"` 32 | StatusMessages []RadarrStatusMessage `json:"statusMessages"` 33 | ErrorMessage string `json:"errorMessage"` 34 | DownloadId string `json:"downloadId"` 35 | Movie RadarrMovie `json:"movie"` 36 | } 37 | 38 | // RadarrStatusMessage represents detailed status information for a queue record 39 | type RadarrStatusMessage struct { 40 | Title string `json:"title"` 41 | Messages []string `json:"messages"` 42 | } 43 | 44 | // RadarrMovie represents the movie information in a queue record 45 | type RadarrMovie struct { 46 | Title string `json:"title"` 47 | OriginalTitle string `json:"originalTitle"` 48 | Year int `json:"year"` 49 | FolderPath string `json:"folderPath"` 50 | CustomFormats []RadarrCustomFormat `json:"customFormats"` 51 | } 52 | 53 | // RadarrCustomFormat represents a custom format in Radarr 54 | type RadarrCustomFormat struct { 55 | ID int `json:"id"` 56 | Name string `json:"name"` 57 | } 58 | 59 | // RadarrMovieResponse represents a movie from Radarr's movie endpoint 60 | type RadarrMovieResponse struct { 61 | ID int `json:"id"` 62 | Title string `json:"title"` 63 | OriginalTitle string `json:"originalTitle"` 64 | Year int `json:"year"` 65 | Overview string `json:"overview"` 66 | ImdbId string `json:"imdbId"` 67 | TmdbId int `json:"tmdbId"` 68 | Status string `json:"status"` 69 | Added string `json:"added"` 70 | HasFile bool `json:"hasFile"` 71 | Path string `json:"path"` 72 | SizeOnDisk int64 `json:"sizeOnDisk"` 73 | Runtime int `json:"runtime"` 74 | Ratings Ratings `json:"ratings"` 75 | } 76 | 77 | // Ratings represents rating information for a movie 78 | type Ratings struct { 79 | Tmdb Rating `json:"tmdb"` 80 | Imdb Rating `json:"imdb"` 81 | Value int `json:"value"` 82 | Votes int `json:"votes"` 83 | } 84 | 85 | // Rating represents a single rating source 86 | type Rating struct { 87 | Value float64 `json:"value"` 88 | Votes int `json:"votes"` 89 | } 90 | 91 | // RadarrQueueDeleteOptions represents the options for deleting a queue item 92 | type RadarrQueueDeleteOptions struct { 93 | RemoveFromClient bool `json:"removeFromClient"` 94 | Blocklist bool `json:"blocklist"` 95 | SkipRedownload bool `json:"skipRedownload"` 96 | ChangeCategory bool `json:"changeCategory"` 97 | } 98 | 99 | // RadarrQueueStats represents statistics about the Radarr queue 100 | type RadarrQueueStats struct { 101 | TotalRecords int `json:"totalRecords"` // Total number of records in the queue 102 | DownloadingCount int `json:"downloadingCount"` // Number of items currently downloading 103 | TotalSize int64 `json:"totalSize"` // Total size of all items in the queue 104 | } 105 | -------------------------------------------------------------------------------- /internal/types/types.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, s0up and the autobrr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package types 5 | 6 | import "time" 7 | 8 | type ServiceHealth struct { 9 | Status string `json:"status"` 10 | ResponseTime int64 `json:"responseTime"` 11 | LastChecked time.Time `json:"lastChecked"` 12 | Message string `json:"message"` 13 | UpdateAvailable bool `json:"updateAvailable"` 14 | Version string `json:"version,omitempty"` 15 | } 16 | 17 | type ServiceConfigResponse struct { 18 | InstanceID string `json:"instanceId"` 19 | DisplayName string `json:"displayName"` 20 | URL string `json:"url"` 21 | APIKey string `json:"apiKey,omitempty"` 22 | } 23 | 24 | type WebhookProxyRequest struct { 25 | TargetUrl string `json:"targetUrl"` 26 | APIKey string `json:"apiKey"` 27 | } 28 | 29 | type UpdateResponse struct { 30 | Version string `json:"version"` 31 | Branch string `json:"branch"` 32 | ReleaseDate time.Time `json:"releaseDate"` 33 | FileName string `json:"fileName"` 34 | URL string `json:"url"` 35 | Installed bool `json:"installed"` 36 | InstalledOn time.Time `json:"installedOn"` 37 | Installable bool `json:"installable"` 38 | Latest bool `json:"latest"` 39 | Changes Changes `json:"changes"` 40 | Hash string `json:"hash"` 41 | } 42 | 43 | type Changes struct { 44 | New []string `json:"new"` 45 | Fixed []string `json:"fixed"` 46 | } 47 | 48 | type FindUserParams struct { 49 | ID int64 50 | Username string 51 | Email string 52 | } 53 | 54 | type FindServiceParams struct { 55 | InstanceID string 56 | InstancePrefix string 57 | URL string 58 | AccessURL string 59 | } 60 | -------------------------------------------------------------------------------- /internal/utils/auth.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024, s0up and the autobrr contributors. 2 | // SPDX-License-Identifier: GPL-2.0-or-later 3 | 4 | package utils 5 | 6 | import ( 7 | "crypto/rand" 8 | "encoding/base64" 9 | "fmt" 10 | 11 | "golang.org/x/crypto/bcrypt" 12 | ) 13 | 14 | const ( 15 | // Cost for bcrypt password hashing 16 | bcryptCost = 12 17 | ) 18 | 19 | // HashPassword creates a bcrypt hash of the password 20 | func HashPassword(password string) (string, error) { 21 | bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) 22 | if err != nil { 23 | return "", fmt.Errorf("failed to hash password: %v", err) 24 | } 25 | return string(bytes), nil 26 | } 27 | 28 | // CheckPassword checks if the provided password matches the hash 29 | func CheckPassword(password, hash string) bool { 30 | err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) 31 | return err == nil 32 | } 33 | 34 | // GenerateSecureToken generates a cryptographically secure random token 35 | func GenerateSecureToken(length int) (string, error) { 36 | if length <= 0 { 37 | return "", fmt.Errorf("token length must be positive") 38 | } 39 | 40 | // Calculate the number of random bytes needed to generate a base64 string 41 | // that will be at least as long as the requested length 42 | numBytes := (length * 6 / 8) + 1 43 | bytes := make([]byte, numBytes) 44 | 45 | if _, err := rand.Read(bytes); err != nil { 46 | return "", fmt.Errorf("failed to generate secure token: %v", err) 47 | } 48 | 49 | // Encode to base64URL and trim to desired length 50 | encoded := base64.URLEncoding.EncodeToString(bytes) 51 | if len(encoded) < length { 52 | return "", fmt.Errorf("failed to generate token of required length") 53 | } 54 | 55 | return encoded[:length], nil 56 | } 57 | 58 | // ValidatePassword checks if a password meets security requirements 59 | func ValidatePassword(password string) error { 60 | if len(password) < 8 { 61 | return fmt.Errorf("password must be at least 8 characters long") 62 | } 63 | 64 | var ( 65 | hasUpper bool 66 | hasLower bool 67 | hasNumber bool 68 | hasSpecial bool 69 | ) 70 | 71 | for _, char := range password { 72 | switch { 73 | case char >= 'A' && char <= 'Z': 74 | hasUpper = true 75 | case char >= 'a' && char <= 'z': 76 | hasLower = true 77 | case char >= '0' && char <= '9': 78 | hasNumber = true 79 | case char >= '!' && char <= '/' || char >= ':' && char <= '@' || char >= '[' && char <= '`' || char >= '{' && char <= '~': 80 | hasSpecial = true 81 | } 82 | } 83 | 84 | if !hasUpper { 85 | return fmt.Errorf("password must contain at least one uppercase letter") 86 | } 87 | if !hasLower { 88 | return fmt.Errorf("password must contain at least one lowercase letter") 89 | } 90 | if !hasNumber { 91 | return fmt.Errorf("password must contain at least one number") 92 | } 93 | if !hasSpecial { 94 | return fmt.Errorf("password must contain at least one special character") 95 | } 96 | 97 | return nil 98 | } 99 | -------------------------------------------------------------------------------- /web/dist/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autobrr/dashbrr/92a59a8c24a52afcc76ad290a9b10a91b80fad42/web/dist/.gitkeep -------------------------------------------------------------------------------- /web/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import reactHooks from "eslint-plugin-react-hooks"; 4 | import reactRefresh from "eslint-plugin-react-refresh"; 5 | import * as tseslint from "typescript-eslint"; 6 | 7 | export default tseslint.config( 8 | { ignores: ["dist", "dev-dist"] }, 9 | js.configs.recommended, 10 | ...tseslint.configs.recommended, 11 | { 12 | files: ["scripts/**/*.js"], 13 | languageOptions: { 14 | globals: { 15 | ...globals.node, 16 | process: true, 17 | console: true, 18 | }, 19 | }, 20 | }, 21 | { 22 | files: ["**/*.{ts,tsx}"], 23 | languageOptions: { 24 | ecmaVersion: 2020, 25 | globals: { 26 | ...globals.browser, 27 | }, 28 | parser: tseslint.parser, 29 | parserOptions: { 30 | project: ["./tsconfig.app.json", "./tsconfig.node.json"], 31 | }, 32 | }, 33 | plugins: { 34 | "react-hooks": reactHooks, 35 | "react-refresh": reactRefresh, 36 | }, 37 | rules: { 38 | ...reactHooks.configs.recommended.rules, 39 | "react-refresh/only-export-components": [ 40 | "warn", 41 | { allowConstantExport: true }, 42 | ], 43 | }, 44 | } 45 | ); 46 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Dashbrr 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dashbrr", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "packageManager": "pnpm@10.4.0", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "tsc -b && vite build", 10 | "lint": "eslint .", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "@dnd-kit/core": "^6.1.0", 15 | "@dnd-kit/sortable": "^8.0.0", 16 | "@dnd-kit/utilities": "^3.2.2", 17 | "@emotion/react": "^11.13.3", 18 | "@emotion/styled": "^11.13.0", 19 | "@fortawesome/fontawesome-svg-core": "^6.6.0", 20 | "@fortawesome/free-brands-svg-icons": "^6.6.0", 21 | "@fortawesome/free-solid-svg-icons": "^6.6.0", 22 | "@fortawesome/react-fontawesome": "^0.2.2", 23 | "@headlessui/react": "^2.2.0", 24 | "@heroicons/react": "^2.1.5", 25 | "@mui/icons-material": "^6.1.6", 26 | "@mui/material": "^6.1.6", 27 | "@tailwindcss/forms": "^0.5.9", 28 | "@types/axios": "^0.14.4", 29 | "@types/lodash": "^4.17.13", 30 | "@types/react-router-dom": "^5.3.3", 31 | "axios": "^1.7.7", 32 | "clsx": "^2.1.1", 33 | "lodash": "^4.17.21", 34 | "react": "^18.3.1", 35 | "react-dom": "^18.3.1", 36 | "react-grid-layout": "^1.5.0", 37 | "react-hot-toast": "^2.4.1", 38 | "react-icons": "^5.3.0", 39 | "react-masonry-css": "^1.0.16", 40 | "react-router-dom": "^6.27.0", 41 | "tailwind-lerp-colors": "^1.2.6", 42 | "vite-plugin-pwa": "^0.20.5", 43 | "vite-plugin-svgr": "^4.3.0" 44 | }, 45 | "devDependencies": { 46 | "@eslint/js": "^9.14.0", 47 | "@types/node": "^22.9.0", 48 | "@types/react": "^18.3.12", 49 | "@types/react-dom": "^18.3.1", 50 | "@types/react-grid-layout": "^1.3.5", 51 | "@vitejs/plugin-react": "^4.3.3", 52 | "autoprefixer": "^10.4.20", 53 | "eslint": "^9.14.0", 54 | "eslint-plugin-react-hooks": "^5.0.0", 55 | "eslint-plugin-react-refresh": "^0.4.14", 56 | "globals": "^15.12.0", 57 | "postcss": "^8.4.47", 58 | "sharp": "^0.33.5", 59 | "tailwindcss": "^3.4.14", 60 | "typescript": "~5.6.3", 61 | "typescript-eslint": "^8.13.0", 62 | "vite": "^5.4.10", 63 | "vite-svg-loader": "^5.1.0", 64 | "workbox-expiration": "^7.3.0", 65 | "workbox-precaching": "^7.3.0", 66 | "workbox-routing": "^7.3.0", 67 | "workbox-strategies": "^7.3.0", 68 | "workbox-window": "^7.3.0" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /web/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /web/public/apple-touch-icon-ipad-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autobrr/dashbrr/92a59a8c24a52afcc76ad290a9b10a91b80fad42/web/public/apple-touch-icon-ipad-76x76.png -------------------------------------------------------------------------------- /web/public/apple-touch-icon-ipad-retina-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autobrr/dashbrr/92a59a8c24a52afcc76ad290a9b10a91b80fad42/web/public/apple-touch-icon-ipad-retina-152x152.png -------------------------------------------------------------------------------- /web/public/apple-touch-icon-iphone-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autobrr/dashbrr/92a59a8c24a52afcc76ad290a9b10a91b80fad42/web/public/apple-touch-icon-iphone-60x60.png -------------------------------------------------------------------------------- /web/public/apple-touch-icon-iphone-retina-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autobrr/dashbrr/92a59a8c24a52afcc76ad290a9b10a91b80fad42/web/public/apple-touch-icon-iphone-retina-120x120.png -------------------------------------------------------------------------------- /web/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autobrr/dashbrr/92a59a8c24a52afcc76ad290a9b10a91b80fad42/web/public/apple-touch-icon.png -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autobrr/dashbrr/92a59a8c24a52afcc76ad290a9b10a91b80fad42/web/public/favicon.ico -------------------------------------------------------------------------------- /web/public/logo.svg: -------------------------------------------------------------------------------- 1 | 6 | 13 | 19 | 25 | 26 | -------------------------------------------------------------------------------- /web/public/masked-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /web/public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autobrr/dashbrr/92a59a8c24a52afcc76ad290a9b10a91b80fad42/web/public/pwa-192x192.png -------------------------------------------------------------------------------- /web/public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autobrr/dashbrr/92a59a8c24a52afcc76ad290a9b10a91b80fad42/web/public/pwa-512x512.png -------------------------------------------------------------------------------- /web/scripts/generate-pwa-icons.js: -------------------------------------------------------------------------------- 1 | import sharp from "sharp"; 2 | import { readFileSync } from "fs"; 3 | import { join } from "path"; 4 | 5 | const sizes = [192, 512]; 6 | const inputSvg = readFileSync(join(process.cwd(), "src/assets/logo.svg")); 7 | 8 | async function generateIcons() { 9 | try { 10 | for (const size of sizes) { 11 | // Create a dark background with correct color #18181B 12 | const background = await sharp({ 13 | create: { 14 | width: size, 15 | height: size, 16 | channels: 4, 17 | background: { r: 24, g: 24, b: 27, alpha: 1 }, // #18181B 18 | }, 19 | }) 20 | .png() 21 | .toBuffer(); 22 | 23 | // Resize the logo 24 | const resizedLogo = await sharp(inputSvg) 25 | .resize(Math.round(size * 0.7), Math.round(size * 0.7)) // Make logo slightly smaller than background 26 | .toBuffer(); 27 | 28 | // Composite them together 29 | await sharp(background) 30 | .composite([ 31 | { 32 | input: resizedLogo, 33 | blend: "over", 34 | gravity: "center", 35 | }, 36 | ]) 37 | .png() 38 | .toFile(join(process.cwd(), `public/pwa-${size}x${size}.png`)); 39 | } 40 | 41 | // Generate apple-touch-icon (180x180 is standard for Apple) 42 | const size = 180; 43 | const background = await sharp({ 44 | create: { 45 | width: size, 46 | height: size, 47 | channels: 4, 48 | background: { r: 24, g: 24, b: 27, alpha: 1 }, // #18181B 49 | }, 50 | }) 51 | .png() 52 | .toBuffer(); 53 | 54 | const resizedLogo = await sharp(inputSvg) 55 | .resize(Math.round(size * 0.7), Math.round(size * 0.7)) 56 | .toBuffer(); 57 | 58 | await sharp(background) 59 | .composite([ 60 | { 61 | input: resizedLogo, 62 | blend: "over", 63 | gravity: "center", 64 | }, 65 | ]) 66 | .png() 67 | .toFile(join(process.cwd(), "public/apple-touch-icon.png")); 68 | 69 | console.log("PWA icons generated successfully!"); 70 | } catch (error) { 71 | console.error("Error generating PWA icons:", error); 72 | } 73 | } 74 | 75 | generateIcons(); 76 | -------------------------------------------------------------------------------- /web/src/App.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | #root { 7 | margin: 0 auto; 8 | text-align: center; 9 | min-height: 100vh; 10 | width: 100%; 11 | } 12 | 13 | .logo { 14 | height: 6em; 15 | padding: 1.5em; 16 | will-change: filter; 17 | transition: filter 300ms; 18 | } 19 | .logo:hover { 20 | filter: drop-shadow(0 0 2em #646cffaa); 21 | } 22 | .logo.react:hover { 23 | filter: drop-shadow(0 0 2em #61dafbaa); 24 | } 25 | 26 | @keyframes logo-spin { 27 | from { 28 | transform: rotate(0deg); 29 | } 30 | to { 31 | transform: rotate(360deg); 32 | } 33 | } 34 | 35 | @media (prefers-reduced-motion: no-preference) { 36 | a:nth-of-type(2) .logo { 37 | animation: logo-spin infinite 20s linear; 38 | } 39 | } 40 | 41 | .card { 42 | padding: 2em; 43 | } 44 | 45 | .read-the-docs { 46 | color: #888; 47 | } 48 | -------------------------------------------------------------------------------- /web/src/assets/apple-touch-icon-ipad-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autobrr/dashbrr/92a59a8c24a52afcc76ad290a9b10a91b80fad42/web/src/assets/apple-touch-icon-ipad-76x76.png -------------------------------------------------------------------------------- /web/src/assets/apple-touch-icon-ipad-retina-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autobrr/dashbrr/92a59a8c24a52afcc76ad290a9b10a91b80fad42/web/src/assets/apple-touch-icon-ipad-retina-152x152.png -------------------------------------------------------------------------------- /web/src/assets/apple-touch-icon-iphone-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autobrr/dashbrr/92a59a8c24a52afcc76ad290a9b10a91b80fad42/web/src/assets/apple-touch-icon-iphone-60x60.png -------------------------------------------------------------------------------- /web/src/assets/apple-touch-icon-iphone-retina-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autobrr/dashbrr/92a59a8c24a52afcc76ad290a9b10a91b80fad42/web/src/assets/apple-touch-icon-iphone-retina-120x120.png -------------------------------------------------------------------------------- /web/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 6 | 13 | 19 | 25 | 26 | -------------------------------------------------------------------------------- /web/src/assets/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autobrr/dashbrr/92a59a8c24a52afcc76ad290a9b10a91b80fad42/web/src/assets/logo192.png -------------------------------------------------------------------------------- /web/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/assets/tailscale.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | + -------------------------------------------------------------------------------- /web/src/components/Toast.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import { FC } from "react"; 7 | import { CheckCircleIcon, ExclamationCircleIcon, ExclamationTriangleIcon, InformationCircleIcon, XMarkIcon } from "@heroicons/react/24/solid"; 8 | import { toast, Toast as Tooast } from "react-hot-toast"; 9 | import { classNames } from "../utils"; 10 | 11 | type Props = { 12 | type: "error" | "success" | "warning" | "info"; 13 | body?: string; 14 | t?: Tooast; 15 | }; 16 | 17 | const Toast: FC = ({ type, body, t }) => ( 18 |
24 |
25 |
26 |
27 | {type === "success" &&
32 |
33 |

34 | {type === "success" && "Success"} 35 | {type === "error" && "Error"} 36 | {type === "warning" && "Warning"} 37 | {type === "info" && "Info"} 38 |

39 | {body} 40 |
41 |
42 | 51 |
52 |
53 |
54 |
55 | ); 56 | 57 | export default Toast; 58 | -------------------------------------------------------------------------------- /web/src/components/auth/CallbackPage.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import { useEffect, useState } from "react"; 7 | import { useNavigate, useSearchParams } from "react-router-dom"; 8 | import { useAuth } from "../../hooks/useAuth"; 9 | 10 | export function CallbackPage() { 11 | const [error, setError] = useState(null); 12 | const [searchParams] = useSearchParams(); 13 | const navigate = useNavigate(); 14 | const { isAuthenticated } = useAuth(); 15 | 16 | useEffect(() => { 17 | const handleCallback = async () => { 18 | // First check for tokens in URL (in case of direct callback from Auth0) 19 | const accessToken = searchParams.get("access_token"); 20 | const idToken = searchParams.get("id_token"); 21 | 22 | if (accessToken && idToken) { 23 | // Store tokens 24 | localStorage.setItem("access_token", accessToken); 25 | localStorage.setItem("id_token", idToken); 26 | // Remove tokens from URL 27 | window.history.replaceState( 28 | {}, 29 | document.title, 30 | window.location.pathname 31 | ); 32 | // Redirect to home 33 | navigate("/", { replace: true }); 34 | return; 35 | } 36 | 37 | // If no tokens in URL, check for error 38 | const error = searchParams.get("error"); 39 | const errorDescription = searchParams.get("error_description"); 40 | 41 | if (error) { 42 | setError(errorDescription || error); 43 | return; 44 | } 45 | 46 | // If no tokens and no error, redirect to home 47 | navigate("/", { replace: true }); 48 | }; 49 | 50 | handleCallback(); 51 | }, [searchParams, navigate, isAuthenticated]); 52 | 53 | if (error) { 54 | return ( 55 |
56 |
57 |
58 |
59 |
60 |

61 | Authentication Error 62 |

63 |
64 |

{error}

65 |
66 |
67 | 73 |
74 |
75 |
76 |
77 |
78 |
79 | ); 80 | } 81 | 82 | return ( 83 |
84 |
85 | Completing authentication... 86 |
87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /web/src/components/auth/ProtectedRoute.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import React from "react"; 7 | import { Navigate, useLocation } from "react-router-dom"; 8 | import { useAuth } from "../../hooks/useAuth"; 9 | 10 | interface ProtectedRouteProps { 11 | children: React.ReactNode; 12 | } 13 | 14 | export function ProtectedRoute({ children }: ProtectedRouteProps) { 15 | const { isAuthenticated, loading } = useAuth(); 16 | const location = useLocation(); 17 | 18 | if (loading) { 19 | return ( 20 |
21 |
22 |
23 | ); 24 | } 25 | 26 | if (!isAuthenticated) { 27 | // Redirect to login page with the return url 28 | return ; 29 | } 30 | 31 | return <>{children}; 32 | } 33 | -------------------------------------------------------------------------------- /web/src/components/auth/withAuth.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ProtectedRoute } from "./ProtectedRoute"; 3 | 4 | export function withAuth

( 5 | WrappedComponent: React.ComponentType

6 | ) { 7 | return function WithAuthComponent(props: P) { 8 | return ( 9 | 10 | 11 | 12 | ); 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /web/src/components/services/ServiceHealthMonitor.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import { memo } from "react"; 7 | import { ServiceGrid } from "./ServiceGrid"; 8 | import { useServiceData } from "../../hooks/useServiceData"; 9 | import { useServiceManagement } from "../../hooks/useServiceManagement"; 10 | import LoadingSkeleton from "../shared/LoadingSkeleton"; 11 | 12 | export const ServiceHealthMonitor = memo(() => { 13 | const { removeServiceInstance } = useServiceManagement(); 14 | const { services, isLoading } = useServiceData(); 15 | 16 | if (isLoading) { 17 | return ( 18 |

19 | {[...Array(4)].map((_, i) => ( 20 | 21 | ))} 22 |
23 | ); 24 | } 25 | 26 | // Filter out tailscale services 27 | const displayServices = services.filter( 28 | (service) => service.type !== "tailscale" 29 | ); 30 | 31 | return ( 32 |
33 | 39 |
40 | ); 41 | }); 42 | 43 | ServiceHealthMonitor.displayName = "ServiceHealthMonitor"; 44 | -------------------------------------------------------------------------------- /web/src/components/services/general/GeneralStats.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import React from "react"; 7 | import { useServiceData } from "../../../hooks/useServiceData"; 8 | import { GeneralMessage } from "./GeneralMessage"; 9 | 10 | interface GeneralStatsProps { 11 | instanceId: string; 12 | } 13 | 14 | export const GeneralStats: React.FC = ({ instanceId }) => { 15 | const { services } = useServiceData(); 16 | const service = services.find((s) => s.instanceId === instanceId); 17 | const isLoading = service?.status === "loading"; 18 | 19 | if (isLoading) { 20 | return ( 21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | ); 33 | } 34 | 35 | if (!service) { 36 | return null; 37 | } 38 | 39 | // Only show message component if there's a message or status isn't online 40 | const showMessage = service.message || service.status !== "online"; 41 | 42 | return ( 43 |
44 | {showMessage && ( 45 | 46 | )} 47 |
48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /web/src/components/services/maintainerr/MaintainerrCollections.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import React from "react"; 7 | import { useServiceData } from "../../../hooks/useServiceData"; 8 | import { ArrowTopRightOnSquareIcon, ClockIcon, FilmIcon } from "@heroicons/react/24/outline"; 9 | 10 | interface Props { 11 | instanceId: string; 12 | } 13 | 14 | export const MaintainerrCollections: React.FC = ({ instanceId }) => { 15 | const { services } = useServiceData(); 16 | const service = services.find((s) => s.instanceId === instanceId); 17 | const collections = service?.stats?.maintainerr?.collections || []; 18 | const isLoading = !service || service.status === "loading"; 19 | 20 | if (isLoading) { 21 | return ( 22 |
23 | {[1, 2, 3].map((i) => ( 24 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | ))} 40 |
41 | ); 42 | } 43 | 44 | if (collections.length === 0) { 45 | return null; 46 | } 47 | 48 | return ( 49 | <> 50 |
51 | Collections: 52 |
53 | {collections.map((collection) => ( 54 |
55 |
56 | 69 |
70 |
71 | 72 | Delete after: 73 | {collection.deleteAfterDays} days 74 |
75 |
76 | 77 | {collection.media.length} 78 |
79 |
80 |
81 |
82 | ))} 83 | 84 | ); 85 | }; 86 | -------------------------------------------------------------------------------- /web/src/components/services/maintainerr/MaintainerrMessage.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import React from "react"; 7 | import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; 8 | 9 | interface Props { 10 | message: string; 11 | type: "error" | "warning" | "offline"; 12 | } 13 | 14 | export const MaintainerrMessage: React.FC = ({ message, type }) => { 15 | const isTemporaryError = 16 | message.includes("temporarily unavailable") || 17 | message.includes("timed out") || 18 | message.includes("Bad Gateway") || 19 | message.includes("502") || 20 | message.includes("503") || 21 | message.includes("504"); 22 | 23 | const getMessageStyle = () => { 24 | const baseStyles = 25 | "text-xs p-2 rounded-lg transition-all duration-200 backdrop-blur-sm"; 26 | 27 | switch (type) { 28 | case "error": 29 | return `${baseStyles} text-red-600 dark:text-red-400 bg-red-50/90 dark:bg-red-900/30 border border-red-100 dark:border-red-900/50`; 30 | case "warning": 31 | return `${baseStyles} text-amber-500 dark:text-amber-300 bg-amber-50/90 dark:bg-amber-900/20 border border-amber-100 dark:border-amber-800/40`; 32 | case "offline": 33 | return `${baseStyles} text-red-600 dark:text-red-400 bg-red-50/90 dark:bg-red-900/30 border border-red-100 dark:border-red-900/50`; 34 | default: 35 | return `${baseStyles} text-gray-600 dark:text-gray-400 bg-gray-50/90 dark:bg-gray-900/30 border border-gray-100 dark:border-gray-800`; 36 | } 37 | }; 38 | 39 | const getIconColor = () => { 40 | switch (type) { 41 | case "error": 42 | case "offline": 43 | return "text-red-500 dark:text-red-400"; 44 | case "warning": 45 | return "text-amber-500 dark:text-amber-300"; 46 | default: 47 | return "text-gray-500 dark:text-gray-400"; 48 | } 49 | }; 50 | 51 | const getMessage = () => { 52 | let finalMessage = message; 53 | 54 | if (type === "error" && isTemporaryError) { 55 | finalMessage += " - Please try again later"; 56 | } 57 | 58 | if (type === "warning") { 59 | // Add warning-specific context if needed 60 | if (message.includes("version mismatch")) { 61 | finalMessage += " - Consider updating your installation"; 62 | } 63 | } 64 | 65 | return finalMessage; 66 | }; 67 | 68 | return ( 69 |
70 |
71 | 72 | {getMessage()} 73 |
74 |
75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /web/src/components/services/maintainerr/MaintainerrService.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import React from "react"; 7 | import { useServiceData } from "../../../hooks/useServiceData"; 8 | import { MaintainerrCollections } from "./MaintainerrCollections"; 9 | import { MaintainerrMessage } from "./MaintainerrMessage"; 10 | import { 11 | ExclamationTriangleIcon, 12 | CheckCircleIcon, 13 | XCircleIcon, 14 | } from "@heroicons/react/24/outline"; 15 | 16 | interface MaintainerrServiceProps { 17 | instanceId: string; 18 | } 19 | 20 | export const MaintainerrService: React.FC = ({ 21 | instanceId, 22 | }) => { 23 | const { services } = useServiceData(); 24 | const service = services.find((s) => s.instanceId === instanceId); 25 | 26 | const renderStatus = () => { 27 | if (!service) return null; 28 | 29 | const getStatusColor = () => { 30 | switch (service.status) { 31 | case "online": 32 | return "text-green-500 dark:text-green-400"; 33 | case "error": 34 | case "offline": 35 | return "text-red-500 dark:text-red-400"; 36 | case "warning": 37 | return "text-amber-500 dark:text-amber-300"; 38 | case "loading": 39 | return "text-blue-500 dark:text-blue-400"; 40 | default: 41 | return "text-gray-500 dark:text-gray-400"; 42 | } 43 | }; 44 | 45 | const getStatusIcon = () => { 46 | switch (service.status) { 47 | case "online": 48 | return ; 49 | case "error": 50 | case "offline": 51 | return ; 52 | case "warning": 53 | return ; 54 | default: 55 | return null; 56 | } 57 | }; 58 | 59 | const getStatusText = () => { 60 | switch (service.status) { 61 | case "online": 62 | return "Healthy"; 63 | case "error": 64 | return "Error"; 65 | case "offline": 66 | return "Offline"; 67 | case "warning": 68 | return "Warning"; 69 | case "loading": 70 | return "Loading"; 71 | default: 72 | return "Unknown"; 73 | } 74 | }; 75 | 76 | return ( 77 |
78 | 79 | Status 80 | 81 |
82 | {getStatusText()} 83 | {getStatusIcon()} 84 | {service.status === "loading" && ( 85 | 86 | )} 87 |
88 |
89 | ); 90 | }; 91 | 92 | const renderMessages = () => { 93 | if (!service) return null; 94 | 95 | const messages = []; 96 | const error = service.status === "error" ? service.message : null; 97 | const warning = service.status === "warning" ? service.message : null; 98 | const isOffline = service.status === "offline"; 99 | 100 | if (error) { 101 | messages.push( 102 | 103 | ); 104 | } 105 | 106 | if (warning) { 107 | messages.push( 108 | 109 | ); 110 | } 111 | 112 | if (isOffline) { 113 | messages.push( 114 | 119 | ); 120 | } 121 | 122 | return messages.length > 0 ? ( 123 |
{messages}
124 | ) : null; 125 | }; 126 | 127 | return ( 128 |
129 | {renderStatus()} 130 | {renderMessages()} 131 | 132 |
133 | ); 134 | }; 135 | -------------------------------------------------------------------------------- /web/src/components/services/omegabrr/OmegabrrControls.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import React from "react"; 7 | import { 8 | triggerWebhookArrs, 9 | triggerWebhookLists, 10 | triggerWebhookAll, 11 | } from "../../../config/api"; 12 | import { toast } from "react-hot-toast"; 13 | 14 | interface OmegabrrControlsProps { 15 | url: string; 16 | apiKey: string; 17 | } 18 | 19 | export const OmegabrrControls: React.FC = ({ 20 | url, 21 | apiKey, 22 | }) => { 23 | const handleTriggerArrs = async () => { 24 | if (!apiKey || !url) { 25 | toast.error("Service URL and API key must be configured first."); 26 | return; 27 | } 28 | 29 | try { 30 | await triggerWebhookArrs(url, apiKey); 31 | toast.success("ARRs webhook triggered successfully"); 32 | } catch (err) { 33 | console.error("Failed to trigger ARRs webhook:", err); 34 | toast.error( 35 | "Failed to trigger ARRs webhook. Check the console for details." 36 | ); 37 | } 38 | }; 39 | 40 | const handleTriggerLists = async () => { 41 | if (!apiKey || !url) { 42 | toast.error("Service URL and API key must be configured first."); 43 | return; 44 | } 45 | 46 | try { 47 | await triggerWebhookLists(url, apiKey); 48 | toast.success("Lists webhook triggered successfully"); 49 | } catch (err) { 50 | console.error("Failed to trigger Lists webhook:", err); 51 | toast.error( 52 | "Failed to trigger Lists webhook. Check the console for details." 53 | ); 54 | } 55 | }; 56 | 57 | const handleTriggerAll = async () => { 58 | if (!apiKey || !url) { 59 | toast.error("Service URL and API key must be configured first."); 60 | return; 61 | } 62 | 63 | try { 64 | await triggerWebhookAll(url, apiKey); 65 | toast.success("All webhooks triggered successfully"); 66 | } catch (err) { 67 | console.error("Failed to trigger all webhooks:", err); 68 | toast.error( 69 | "Failed to trigger all webhooks. Check the console for details." 70 | ); 71 | } 72 | }; 73 | 74 | return ( 75 |
76 |

77 | Manual Triggers: 78 |

79 |
80 | 88 | 96 | 104 |
105 |
106 | ); 107 | }; 108 | -------------------------------------------------------------------------------- /web/src/components/services/omegabrr/OmegabrrStats.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import React from "react"; 7 | import { useServiceData } from "../../../hooks/useServiceData"; 8 | import { OmegabrrMessage } from "./OmegabrrMessage"; 9 | import { OmegabrrControls } from "./OmegabrrControls"; 10 | 11 | interface OmegabrrStatsProps { 12 | instanceId: string; 13 | } 14 | 15 | export const OmegabrrStats: React.FC = ({ instanceId }) => { 16 | const { services } = useServiceData(); 17 | const service = services.find((s) => s.instanceId === instanceId); 18 | const isLoading = service?.status === "loading"; 19 | 20 | if (isLoading) { 21 | return ( 22 |
23 | {[1, 2, 3].map((i) => ( 24 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | ))} 40 |
41 | ); 42 | } 43 | 44 | if (!service) { 45 | return null; 46 | } 47 | 48 | // Only show message component if there's a message or status isn't online 49 | const showMessage = service.message || service.status !== "online"; 50 | 51 | return ( 52 |
53 | {/* Status and Messages */} 54 | {showMessage && ( 55 | 56 | )} 57 | 58 | {/* Controls */} 59 | 60 |
61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /web/src/components/services/prowlarr/ProwlarrMessage.tsx: -------------------------------------------------------------------------------- 1 | import { ArrMessage } from "../common/ArrMessage"; 2 | export const ProwlarrMessage = ArrMessage; 3 | -------------------------------------------------------------------------------- /web/src/components/services/radarr/RadarrMessage.tsx: -------------------------------------------------------------------------------- 1 | import { ArrMessage } from "../common/ArrMessage"; 2 | export const RadarrMessage = ArrMessage; 3 | -------------------------------------------------------------------------------- /web/src/components/services/sonarr/SonarrMessage.tsx: -------------------------------------------------------------------------------- 1 | import { ArrMessage } from "../common/ArrMessage"; 2 | export const SonarrMessage = ArrMessage; 3 | -------------------------------------------------------------------------------- /web/src/components/shared/Card.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import React, { ReactNode } from "react"; 7 | 8 | interface CardProps { 9 | children: ReactNode; 10 | className?: string; 11 | header?: ReactNode; 12 | footer?: ReactNode; 13 | onClick?: () => void; 14 | hoverable?: boolean; 15 | variant?: "default" | "primary" | "secondary"; 16 | noPadding?: boolean; 17 | } 18 | 19 | export const Card: React.FC = ({ 20 | children, 21 | className = "", 22 | header, 23 | footer, 24 | onClick, 25 | hoverable = false, 26 | variant = "default", 27 | noPadding = false, 28 | }) => { 29 | const variants = { 30 | default: ` 31 | bg-white dark:bg-gray-800 32 | border border-gray-200 dark:border-gray-700 33 | `, 34 | primary: ` 35 | bg-blue-50/50 dark:bg-blue-900/20 36 | border border-blue-100 dark:border-blue-800/50 37 | `, 38 | secondary: ` 39 | bg-gray-50/50 dark:bg-gray-800/50 40 | border border-gray-200 dark:border-gray-700 41 | `, 42 | }; 43 | 44 | const baseClasses = ` 45 | relative 46 | rounded-lg 47 | transition-all duration-200 48 | overflow-hidden 49 | `; 50 | 51 | const hoverClasses = hoverable 52 | ? ` 53 | cursor-pointer 54 | transform hover:scale-[1.01] 55 | active:scale-[0.99] 56 | ` 57 | : ""; 58 | 59 | const combinedClasses = ` 60 | ${baseClasses} 61 | ${variants[variant]} 62 | ${hoverClasses} 63 | ${className} 64 | ` 65 | .replace(/\s+/g, " ") 66 | .trim(); 67 | 68 | return ( 69 |
70 | {header && ( 71 |
72 | {header} 73 |
74 | )} 75 |
{children}
76 | {footer && ( 77 |
78 | {footer} 79 |
80 | )} 81 |
82 | ); 83 | }; 84 | -------------------------------------------------------------------------------- /web/src/components/shared/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import { Component, ErrorInfo, ReactNode } from "react"; 7 | import { XCircleIcon, ArrowPathIcon } from "@heroicons/react/24/outline"; 8 | 9 | interface Props { 10 | children: ReactNode; 11 | fallback?: ReactNode; 12 | onError?: (error: Error, errorInfo: ErrorInfo) => void; 13 | } 14 | 15 | interface State { 16 | hasError: boolean; 17 | error?: Error; 18 | } 19 | 20 | export class ErrorBoundary extends Component { 21 | public state: State = { 22 | hasError: false, 23 | }; 24 | 25 | public static getDerivedStateFromError(error: Error): State { 26 | return { hasError: true, error }; 27 | } 28 | 29 | public componentDidCatch(error: Error, errorInfo: ErrorInfo) { 30 | console.error("ErrorBoundary caught an error:", error, errorInfo); 31 | this.props.onError?.(error, errorInfo); 32 | } 33 | 34 | private handleRetry = () => { 35 | this.setState({ hasError: false, error: undefined }); 36 | }; 37 | 38 | public render() { 39 | if (this.state.hasError) { 40 | if (this.props.fallback) { 41 | return this.props.fallback; 42 | } 43 | 44 | return ( 45 |
46 |
47 | {/* Header */} 48 |
49 |
50 | 51 |
52 |
53 |

54 | Something went wrong 55 |

56 |
57 |

58 | {this.state.error?.message || 59 | "An unexpected error occurred"} 60 |

61 | {this.state.error?.stack && ( 62 |
 63 |                       {this.state.error.stack}
 64 |                     
65 | )} 66 |
67 |
68 |
69 | 70 | {/* Actions */} 71 |
72 | 78 | 85 |
86 |
87 | 88 | {/* Gradient border effect */} 89 | 96 | ); 97 | } 98 | 99 | return this.props.children; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /web/src/components/shared/Footer.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 7 | import { faGithub } from "@fortawesome/free-brands-svg-icons"; 8 | 9 | export const Footer = () => { 10 | return ( 11 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /web/src/components/shared/HealthIndicator.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import React from "react"; 7 | import { LoadingState } from "./LoadingState"; 8 | 9 | export type HealthStatus = "healthy" | "unhealthy" | "unknown" | "checking"; 10 | 11 | interface HealthIndicatorProps { 12 | status: HealthStatus; 13 | lastChecked?: Date; 14 | message?: string; 15 | className?: string; 16 | } 17 | 18 | export const HealthIndicator: React.FC = ({ 19 | status, 20 | lastChecked, 21 | message, 22 | className = "", 23 | }) => { 24 | const statusConfig = { 25 | healthy: { 26 | color: "bg-green-500", 27 | icon: "✓", 28 | text: "Healthy", 29 | }, 30 | unhealthy: { 31 | color: "bg-red-500 animate-pulse", 32 | icon: "✕", 33 | text: "Unhealthy", 34 | }, 35 | unknown: { 36 | color: "bg-gray-500", 37 | icon: "?", 38 | text: "Unknown", 39 | }, 40 | checking: { 41 | color: "bg-blue-500", 42 | icon: null, 43 | text: "Checking", 44 | }, 45 | }; 46 | 47 | const config = statusConfig[status]; 48 | 49 | if (status === "checking") { 50 | return ; 51 | } 52 | 53 | return ( 54 |
55 |
58 | {config.icon} 59 |
60 |
61 | 62 | {message || config.text} 63 | 64 | {lastChecked && ( 65 | 66 | Last checked: {lastChecked.toLocaleTimeString()} 67 | 68 | )} 69 |
70 |
71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /web/src/components/shared/LoadingSkeleton.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import React from "react"; 7 | 8 | export const LoadingSkeleton: React.FC = () => ( 9 |
10 | {/* Shimmer effect overlay */} 11 |
12 |
13 |
14 | 15 |
16 | {/* Header */} 17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | 28 | {/* Status section */} 29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | 37 | {/* Content blocks */} 38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | 53 | {/* Footer */} 54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | 62 | {/* Gradient border effect */} 63 | 70 | ); 71 | 72 | export default LoadingSkeleton; 73 | -------------------------------------------------------------------------------- /web/src/components/shared/LoadingState.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import React from "react"; 7 | 8 | interface LoadingStateProps { 9 | message?: string; 10 | size?: "sm" | "md" | "lg"; 11 | fullScreen?: boolean; 12 | variant?: "primary" | "secondary" | "minimal"; 13 | } 14 | 15 | export const LoadingState: React.FC = ({ 16 | message = "Loading", 17 | size = "md", 18 | fullScreen = false, 19 | variant = "primary", 20 | }) => { 21 | const sizeClasses = { 22 | sm: { 23 | spinner: "w-4 h-4 border-2", 24 | text: "text-xs", 25 | container: "gap-2", 26 | }, 27 | md: { 28 | spinner: "w-8 h-8 border-3", 29 | text: "text-sm", 30 | container: "gap-3", 31 | }, 32 | lg: { 33 | spinner: "w-12 h-12 border-4", 34 | text: "text-base", 35 | container: "gap-4", 36 | }, 37 | }; 38 | 39 | const variantClasses = { 40 | primary: { 41 | spinner: "border-blue-500/20 border-t-blue-500", 42 | text: "text-gray-600 dark:text-gray-300", 43 | backdrop: "bg-white/80 dark:bg-gray-900/80", 44 | }, 45 | secondary: { 46 | spinner: 47 | "border-gray-300/30 border-t-gray-300 dark:border-gray-600/30 dark:border-t-gray-600", 48 | text: "text-gray-500 dark:text-gray-400", 49 | backdrop: "bg-gray-50/90 dark:bg-gray-800/90", 50 | }, 51 | minimal: { 52 | spinner: 53 | "border-gray-300/20 border-t-gray-300 dark:border-gray-700/20 dark:border-t-gray-700", 54 | text: "text-gray-400 dark:text-gray-500", 55 | backdrop: "bg-transparent", 56 | }, 57 | }; 58 | 59 | const containerClasses = fullScreen 60 | ? "fixed inset-0 flex items-center justify-center backdrop-blur-sm z-50" 61 | : "flex items-center justify-center p-4"; 62 | 63 | return ( 64 |
67 |
70 | {/* Spinner */} 71 |
72 |
81 | {/* Optional pulse effect */} 82 |
95 |
96 | 97 | {/* Message */} 98 | {message && ( 99 |
100 |

109 | {message} 110 | ... 111 |

112 |
113 | )} 114 |
115 |
116 | ); 117 | }; 118 | 119 | export default LoadingState; 120 | -------------------------------------------------------------------------------- /web/src/components/shared/ServiceActions.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import React from "react"; 7 | 8 | interface ServiceAction { 9 | label: string; 10 | onClick: () => void; 11 | variant?: "primary" | "secondary" | "danger"; 12 | icon?: React.ReactNode; 13 | disabled?: boolean; 14 | } 15 | 16 | interface ServiceActionsProps { 17 | actions: ServiceAction[]; 18 | className?: string; 19 | } 20 | 21 | export const ServiceActions: React.FC = ({ 22 | actions, 23 | className = "", 24 | }) => { 25 | const getVariantClasses = (variant: ServiceAction["variant"] = "primary") => { 26 | const variants = { 27 | primary: "bg-blue-500 hover:bg-blue-600 text-white", 28 | secondary: 29 | "bg-gray-200 hover:bg-gray-300 text-gray-800 dark:bg-gray-700 dark:hover:bg-gray-600 dark:text-white", 30 | danger: "bg-red-500 hover:bg-red-600 text-white", 31 | } as const; 32 | 33 | return variants[variant]; 34 | }; 35 | 36 | return ( 37 |
38 | {actions.map((action, index) => ( 39 | 54 | ))} 55 |
56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /web/src/components/shared/ServiceStatus.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import React from "react"; 7 | import { ServiceStatus as ServiceStatusType } from "../../types/service"; 8 | 9 | interface ServiceStatusProps { 10 | status: ServiceStatusType; 11 | message?: string; 12 | className?: string; 13 | } 14 | 15 | export const ServiceStatus: React.FC = ({ 16 | status, 17 | message, 18 | className = "", 19 | }) => { 20 | const statusConfig = { 21 | online: { 22 | color: "bg-green-500", 23 | text: "text-green-700 dark:text-green-300", 24 | label: "Online", 25 | }, 26 | offline: { 27 | color: "bg-gray-500", 28 | text: "text-gray-700 dark:text-gray-300", 29 | label: "Offline", 30 | }, 31 | error: { 32 | color: "bg-red-500", 33 | text: "text-red-700 dark:text-red-300", 34 | label: "Error", 35 | }, 36 | warning: { 37 | color: "bg-yellow-500", 38 | text: "text-yellow-700 dark:text-yellow-300", 39 | label: "Warning", 40 | }, 41 | loading: { 42 | color: "bg-blue-500", 43 | text: "text-blue-700 dark:text-blue-300", 44 | label: "Loading", 45 | }, 46 | pending: { 47 | color: "bg-purple-500", 48 | text: "text-purple-700 dark:text-purple-300", 49 | label: "Not Configured", 50 | }, 51 | unknown: { 52 | color: "bg-gray-500", 53 | text: "text-gray-700 dark:text-gray-300", 54 | label: "Unknown", 55 | }, 56 | }; 57 | 58 | const config = statusConfig[status] || statusConfig.unknown; 59 | 60 | return ( 61 |
62 |
63 | 64 | {message || config.label} 65 | 66 |
67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /web/src/components/shared/StatsLoadingSkeleton.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import React from "react"; 7 | 8 | interface StatsLoadingSkeletonProps { 9 | className?: string; 10 | size?: "sm" | "md" | "lg"; 11 | } 12 | 13 | export const StatsLoadingSkeleton: React.FC = ({ 14 | className = "", 15 | size = "md", 16 | }) => { 17 | const sizes = { 18 | sm: { 19 | label: "h-4 w-20", 20 | value: "h-3 w-14", 21 | gap: "space-y-1", 22 | }, 23 | md: { 24 | label: "h-6 w-24", 25 | value: "h-4 w-16", 26 | gap: "space-y-1.5", 27 | }, 28 | lg: { 29 | label: "h-7 w-32", 30 | value: "h-5 w-20", 31 | gap: "space-y-2", 32 | }, 33 | }; 34 | 35 | return ( 36 |
37 | {/* Shimmer effect overlay */} 38 |
39 |
40 |
41 | 42 | {/* Content */} 43 |
44 |
47 |
50 |
51 | 52 | {/* Optional gradient border effect */} 53 | 60 | ); 61 | }; 62 | 63 | export default StatsLoadingSkeleton; 64 | -------------------------------------------------------------------------------- /web/src/components/shared/StatusCounters.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import React from "react"; 7 | import { Service } from "../../types/service"; 8 | import { StatusIcon } from "../ui/StatusIcon"; 9 | 10 | interface StatusCountersProps { 11 | services: Service[]; 12 | } 13 | 14 | export const StatusCounters: React.FC = ({ services }) => { 15 | const counts = { 16 | error: 0, 17 | warning: 0, 18 | ok: 0, 19 | online: 0, 20 | offline: 0, 21 | healthy: 0, 22 | pending: 0, 23 | loading: 0, 24 | }; 25 | 26 | services.forEach((service) => { 27 | const status = service.status.toLowerCase(); 28 | if (status in counts) { 29 | counts[status as keyof typeof counts]++; 30 | } 31 | }); 32 | 33 | return ( 34 |
35 | {counts.error > 0 && ( 36 |
37 | 38 | 39 | {counts.error} 40 | 41 |
42 | )} 43 | {counts.warning > 0 && ( 44 |
45 | 46 | 47 | {counts.warning} 48 | 49 |
50 | )} 51 | {(counts.online > 0 || counts.healthy > 0) && ( 52 |
53 | 54 | 55 | {counts.online + counts.healthy} 56 | 57 |
58 | )} 59 |
60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /web/src/components/ui/AnimatedModal.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import React, { Fragment } from "react"; 7 | import { Dialog, Transition } from "@headlessui/react"; 8 | import { XMarkIcon } from "@heroicons/react/24/outline"; 9 | 10 | interface AnimatedModalProps { 11 | isOpen: boolean; 12 | onClose: () => void; 13 | children: React.ReactNode; 14 | title?: React.ReactNode; 15 | maxWidth?: "sm" | "md" | "lg" | "xl" | "2xl"; 16 | className?: string; 17 | } 18 | 19 | const AnimatedModal: React.FC = ({ 20 | isOpen, 21 | onClose, 22 | children, 23 | title, 24 | maxWidth = "lg", 25 | className, 26 | }) => { 27 | const maxWidthClasses = { 28 | sm: "sm:max-w-sm", 29 | md: "sm:max-w-md", 30 | lg: "sm:max-w-lg", 31 | xl: "sm:max-w-xl", 32 | "2xl": "sm:max-w-2xl", 33 | }; 34 | 35 | return ( 36 | 37 | 38 | 47 |
48 | 49 | 50 |
51 |
52 | 61 | 68 |
69 | {title && ( 70 |
71 | 75 | {title} 76 | 77 | 84 |
85 | )} 86 |
{children}
87 |
88 | 89 | {/* Modal Gradient Border Effect */} 90 | 99 |
100 |
101 |
102 | ); 103 | }; 104 | 105 | export default AnimatedModal; 106 | -------------------------------------------------------------------------------- /web/src/components/ui/Button.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import React from "react"; 7 | 8 | type ButtonVariant = "primary" | "secondary" | "danger"; 9 | type ButtonSize = "sm" | "md" | "lg"; 10 | 11 | interface ButtonProps extends React.ButtonHTMLAttributes { 12 | variant?: ButtonVariant; 13 | size?: ButtonSize; 14 | isLoading?: boolean; 15 | } 16 | 17 | export const Button: React.FC = ({ 18 | variant = "primary", 19 | size = "md", 20 | isLoading = false, 21 | children, 22 | className = "", 23 | disabled, 24 | ...props 25 | }) => { 26 | const baseStyles = 27 | "inline-flex items-center justify-center font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors"; 28 | 29 | const variantStyles = { 30 | primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500", 31 | secondary: 32 | "bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 focus:ring-blue-500 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600", 33 | danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500", 34 | }; 35 | 36 | const sizeStyles = { 37 | sm: "px-3 py-1.5 text-sm", 38 | md: "px-4 py-2 text-sm", 39 | lg: "px-6 py-3 text-base", 40 | }; 41 | 42 | const disabledStyles = "disabled:opacity-50 disabled:cursor-not-allowed"; 43 | 44 | const combinedClassName = ` 45 | ${baseStyles} 46 | ${variantStyles[variant]} 47 | ${sizeStyles[size]} 48 | ${disabledStyles} 49 | ${className} 50 | `.trim(); 51 | 52 | return ( 53 | 86 | ); 87 | }; 88 | -------------------------------------------------------------------------------- /web/src/components/ui/FormInput.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import React, { useState } from "react"; 7 | import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/solid"; 8 | 9 | interface FormInputProps { 10 | id: string; 11 | label: string; 12 | type: string; 13 | value: string; 14 | onChange: (e: React.ChangeEvent) => void; 15 | placeholder?: string; 16 | required?: boolean; 17 | disabled?: boolean; 18 | helpText?: { 19 | prefix: string; 20 | text: string; 21 | link: string | null; 22 | }; 23 | } 24 | 25 | export const FormInput: React.FC = ({ 26 | id, 27 | label, 28 | type = "text", 29 | value, 30 | onChange, 31 | placeholder, 32 | required = false, 33 | disabled = false, 34 | helpText, 35 | }) => { 36 | const [isVisible, setIsVisible] = useState(false); 37 | const isPassword = type === "password"; 38 | 39 | return ( 40 |
41 | 47 |
48 | 59 | {isPassword && ( 60 |
setIsVisible(!isVisible)} 63 | > 64 | {!isVisible ? ( 65 |
76 | )} 77 |
78 | {helpText && ( 79 |

80 | {helpText.prefix} 81 | {helpText.link ? ( 82 | 88 | {helpText.text} 89 | 90 | ) : ( 91 | helpText.text 92 | )} 93 |

94 | )} 95 |
96 | ); 97 | }; 98 | -------------------------------------------------------------------------------- /web/src/components/ui/Modal.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import React from "react"; 7 | 8 | interface ModalProps { 9 | isOpen: boolean; 10 | onClose: () => void; 11 | title: string; 12 | children: React.ReactNode; 13 | } 14 | 15 | export const Modal: React.FC = ({ 16 | isOpen, 17 | onClose, 18 | title, 19 | children, 20 | }) => { 21 | if (!isOpen) return null; 22 | 23 | const handleOverlayClick = (e: React.MouseEvent) => { 24 | if (e.target === e.currentTarget) { 25 | onClose(); 26 | } 27 | }; 28 | 29 | return ( 30 |
34 |
35 |

36 | {title} 37 |

38 | {children} 39 |
40 |
41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /web/src/components/ui/StatusIcon.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import React from "react"; 7 | 8 | export type StatusType = 9 | | "online" 10 | | "offline" 11 | | "warning" 12 | | "error" 13 | | "loading" 14 | | "unknown"; 15 | 16 | interface StatusIconProps { 17 | status: StatusType; 18 | } 19 | 20 | export const StatusIcon: React.FC = ({ status }) => { 21 | const getStatusStyles = (status: StatusType) => { 22 | const baseStyles = "w-2.5 h-2.5 rounded-full transition-all duration-200"; 23 | const pulseAnimation = 24 | "after:animate-ping after:absolute after:inset-0 after:rounded-full"; 25 | const glowEffect = 26 | "before:absolute before:inset-[-4px] before:rounded-full before:bg-current before:opacity-20 before:blur-sm"; 27 | 28 | switch (status) { 29 | case "online": 30 | return `${baseStyles} ${glowEffect} relative bg-green-500 after:bg-green-500/50 text-green-500`; 31 | case "offline": 32 | return `${baseStyles} ${glowEffect} relative bg-red-500 text-red-500`; 33 | case "error": 34 | return `${baseStyles} ${glowEffect} ${pulseAnimation} relative bg-red-500 after:bg-red-500/50 text-red-500`; 35 | case "warning": 36 | return `${baseStyles} ${glowEffect} ${pulseAnimation} relative bg-yellow-500 after:bg-yellow-500/50 text-yellow-500`; 37 | case "loading": 38 | return `${baseStyles} ${glowEffect} ${pulseAnimation} relative bg-blue-500 after:bg-blue-500/50 text-blue-500`; 39 | default: 40 | return `${baseStyles} ${glowEffect} relative bg-gray-500 text-gray-500`; 41 | } 42 | }; 43 | 44 | const getTooltipText = (status: StatusType) => { 45 | switch (status) { 46 | case "online": 47 | return "Service is online"; 48 | case "offline": 49 | return "Service is offline"; 50 | case "error": 51 | return "Service error"; 52 | case "warning": 53 | return "Service warning"; 54 | case "loading": 55 | return "Loading status..."; 56 | default: 57 | return "Status unknown"; 58 | } 59 | }; 60 | 61 | return ( 62 |
63 |
64 |
65 | {status === "loading" && ( 66 |
67 |
68 |
69 | )} 70 |
71 |
72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /web/src/config/auth.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | // Get the current frontend URL 7 | const getFrontendUrl = () => { 8 | // In development, use localhost:3000 9 | if (import.meta.env.DEV) { 10 | return 'http://localhost:3000'; 11 | } 12 | // In production, use the current origin 13 | return window.location.origin; 14 | }; 15 | 16 | // Common auth endpoints 17 | const COMMON_ENDPOINTS = { 18 | config: '/api/auth/config', 19 | userInfo: '/api/auth/userinfo', 20 | }; 21 | 22 | // OIDC-specific endpoints 23 | const OIDC_ENDPOINTS = { 24 | login: `/api/auth/oidc/login?frontendUrl=${encodeURIComponent(getFrontendUrl())}`, 25 | callback: `/api/auth/oidc/callback?frontendUrl=${encodeURIComponent(getFrontendUrl())}`, 26 | logout: `/api/auth/oidc/logout?frontendUrl=${encodeURIComponent(getFrontendUrl())}`, 27 | refresh: '/api/auth/oidc/refresh', 28 | verify: '/api/auth/oidc/verify', 29 | userInfo: '/api/auth/oidc/userinfo', 30 | }; 31 | 32 | // Built-in auth endpoints 33 | const BUILTIN_ENDPOINTS = { 34 | login: '/api/auth/login', 35 | register: '/api/auth/register', 36 | logout: '/api/auth/logout', 37 | verify: '/api/auth/verify', 38 | }; 39 | 40 | export const AUTH_URLS = { 41 | ...COMMON_ENDPOINTS, 42 | oidc: OIDC_ENDPOINTS, 43 | builtin: BUILTIN_ENDPOINTS, 44 | }; 45 | 46 | export interface AuthConfig { 47 | methods: { 48 | builtin: boolean; 49 | oidc: boolean; 50 | }; 51 | default: 'builtin' | 'oidc'; 52 | } 53 | 54 | export async function getAuthConfig(): Promise { 55 | try { 56 | const response = await fetch(AUTH_URLS.config); 57 | if (!response.ok) { 58 | throw new Error('Failed to fetch auth configuration'); 59 | } 60 | return await response.json(); 61 | } catch (error) { 62 | console.error('Error fetching auth config:', error); 63 | // Return default configuration if fetch fails 64 | return { 65 | methods: { 66 | builtin: true, 67 | oidc: false, 68 | }, 69 | default: 'builtin', 70 | }; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /web/src/config/repoUrls.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | interface RepoUrls { 7 | [key: string]: string; 8 | } 9 | 10 | export const repoUrls: RepoUrls = { 11 | "autobrr": "https://github.com/autobrr/autobrr/releases/", 12 | "omegabrr": "https://github.com/autobrr/omegabrr/releases/", 13 | "dashbrr": "https://github.com/autobrr/dashbrr/releases/", 14 | "maintainerr": "https://github.com/jorenn92/Maintainerr/releases/", 15 | "overseerr": "https://github.com/sct/overseerr/releases/", 16 | "prowlarr": "https://github.com/Prowlarr/Prowlarr/releases", 17 | "sonarr": "https://github.com/Sonarr/Sonarr/releases", 18 | "radarr": "https://github.com/Radarr/Radarr/releases", 19 | }; 20 | -------------------------------------------------------------------------------- /web/src/config/serviceTemplates.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import { Service } from '../types/service'; 7 | 8 | export const serviceTemplates: Omit[] = [ 9 | { 10 | name: "Autobrr", 11 | displayName: "", 12 | type: "autobrr", 13 | status: "offline", 14 | url: "", 15 | accessUrl: "", 16 | healthEndpoint: "/api/health/autobrr", 17 | }, 18 | { 19 | name: "Omegabrr", 20 | displayName: "", 21 | type: "omegabrr", 22 | status: "offline", 23 | url: "", 24 | accessUrl: "", 25 | healthEndpoint: "/api/health/omegabrr", 26 | }, 27 | { 28 | name: "Radarr", 29 | displayName: "", 30 | type: "radarr", 31 | status: "offline", 32 | url: "", 33 | accessUrl: "", 34 | healthEndpoint: "/api/health/radarr", 35 | }, 36 | { 37 | name: "Sonarr", 38 | displayName: "", 39 | type: "sonarr", 40 | status: "offline", 41 | url: "", 42 | accessUrl: "", 43 | healthEndpoint: "/api/health/sonarr", 44 | }, 45 | { 46 | name: "Prowlarr", 47 | displayName: "", 48 | type: "prowlarr", 49 | status: "offline", 50 | url: "", 51 | accessUrl: "", 52 | healthEndpoint: "/api/health/prowlarr", 53 | }, 54 | { 55 | name: "Overseerr", 56 | displayName: "", 57 | type: "overseerr", 58 | status: "offline", 59 | url: "", 60 | accessUrl: "", 61 | healthEndpoint: "/api/health/overseerr", 62 | }, 63 | { 64 | name: "Plex", 65 | displayName: "", 66 | type: "plex", 67 | status: "offline", 68 | url: "", 69 | accessUrl: "", 70 | healthEndpoint: "/api/health/plex", 71 | }, 72 | { 73 | name: "Tailscale", 74 | displayName: "", 75 | type: "tailscale", 76 | status: "offline", 77 | url: "", 78 | healthEndpoint: "/api/health/tailscale", 79 | }, 80 | { 81 | name: "Maintainerr", 82 | displayName: "", 83 | type: "maintainerr", 84 | status: "offline", 85 | url: "", 86 | accessUrl: "", 87 | healthEndpoint: "/api/health/maintainerr", 88 | }, 89 | { 90 | name: "General Service", 91 | displayName: "", 92 | type: "general", 93 | status: "offline", 94 | url: "", 95 | accessUrl: "", 96 | healthEndpoint: "", 97 | apiKey: undefined, 98 | }, 99 | ]; 100 | 101 | export default serviceTemplates; 102 | -------------------------------------------------------------------------------- /web/src/contexts/context.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import { createContext } from "react"; 7 | import { ConfigurationContextType } from "./types"; 8 | 9 | export const ConfigurationContext = createContext(undefined); 10 | -------------------------------------------------------------------------------- /web/src/contexts/types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import { ServiceConfig } from "../types/service"; 7 | 8 | export interface ConfigurationContextType { 9 | configurations: { [instanceId: string]: ServiceConfig }; 10 | updateConfiguration: (instanceId: string, config: ServiceConfig) => Promise; 11 | deleteConfiguration: (instanceId: string) => Promise; 12 | fetchConfigurations: () => Promise; // This is now forceRefresh 13 | isLoading: boolean; 14 | error: string | null; 15 | } 16 | -------------------------------------------------------------------------------- /web/src/contexts/useConfiguration.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import { useContext } from 'react'; 7 | import { ConfigurationContext } from './context'; 8 | 9 | export const useConfiguration = () => { 10 | const context = useContext(ConfigurationContext); 11 | if (!context) { 12 | throw new Error('useConfiguration must be used within a ConfigurationProvider'); 13 | } 14 | 15 | const validateServiceConfig = async (type: string, url: string, apiKey?: string) => { 16 | try { 17 | // Ensure URL is properly formatted 18 | const formattedUrl = url.endsWith('/') ? url.slice(0, -1) : url; 19 | 20 | // Build query parameters 21 | const params = new URLSearchParams(); 22 | params.append('url', formattedUrl); 23 | if (apiKey) { 24 | params.append('apiKey', apiKey); 25 | } 26 | 27 | // Construct the health check URL 28 | const healthCheckUrl = `/health/${type.toLowerCase()}?${params.toString()}`; 29 | 30 | const response = await fetch(healthCheckUrl, { 31 | method: 'GET', 32 | headers: { 33 | 'Accept': 'application/json', 34 | 'Content-Type': 'application/json', 35 | }, 36 | }); 37 | 38 | if (!response.ok) { 39 | throw new Error(`Service validation failed with status: ${response.status}`); 40 | } 41 | 42 | const contentType = response.headers.get('content-type'); 43 | if (!contentType || !contentType.includes('application/json')) { 44 | throw new Error('Invalid response format from service'); 45 | } 46 | 47 | const data = await response.json(); 48 | return data; 49 | } catch (error) { 50 | console.error('Service validation error:', error); 51 | throw error; 52 | } 53 | }; 54 | 55 | return { 56 | ...context, 57 | validateServiceConfig, 58 | }; 59 | }; 60 | 61 | export default useConfiguration; 62 | -------------------------------------------------------------------------------- /web/src/hooks/useAuth.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { AuthContext } from "../contexts/AuthContext"; 3 | 4 | export function useAuth() { 5 | const context = useContext(AuthContext); 6 | if (context === undefined) { 7 | throw new Error("useAuth must be used within an AuthProvider"); 8 | } 9 | return context; 10 | } -------------------------------------------------------------------------------- /web/src/hooks/useCachedServiceData.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import { useCallback } from 'react'; 7 | import { Service, ServiceType } from '../types/service'; 8 | import { useConfiguration } from '../contexts/useConfiguration'; 9 | import serviceTemplates from '../config/serviceTemplates'; 10 | import { cache } from '../utils/cache'; 11 | 12 | const CACHE_KEY = 'cached_services'; 13 | const CACHE_TTL = 300000; // 5 minutes 14 | 15 | interface CacheData { 16 | services: Service[]; 17 | timestamp: number; 18 | } 19 | 20 | export function useCachedServiceData() { 21 | const { configurations } = useConfiguration(); 22 | 23 | const getServices = useCallback((): Service[] => { 24 | // Try to get from cache first 25 | const cachedData = cache.get(CACHE_KEY); 26 | if (cachedData?.data?.services && (Date.now() - (cachedData.data.timestamp || 0) < CACHE_TTL)) { 27 | return cachedData.data.services; 28 | } 29 | 30 | if (!configurations) return []; 31 | 32 | const services = Object.entries(configurations).map(([instanceId, config]) => { 33 | const [type] = instanceId.split('-'); 34 | const template = serviceTemplates.find(t => t.type === type); 35 | const hasRequiredConfig = Boolean(config.url && config.apiKey); 36 | 37 | return { 38 | id: instanceId, 39 | instanceId, 40 | name: template?.name || 'Unknown Service', 41 | type: (template?.type || 'other') as ServiceType, 42 | status: hasRequiredConfig ? 'loading' : 'pending', 43 | url: config.url, 44 | apiKey: config.apiKey, 45 | displayName: config.displayName, 46 | healthEndpoint: template?.healthEndpoint, 47 | message: hasRequiredConfig ? 'Loading service status' : 'Service not configured' 48 | } as Service; 49 | }); 50 | 51 | // Update cache 52 | cache.set(CACHE_KEY, { services, timestamp: Date.now() }); 53 | return services; 54 | }, [configurations]); 55 | 56 | const services = getServices(); 57 | const isLoading = !configurations; 58 | 59 | return { 60 | services, 61 | isLoading, 62 | refresh: () => { 63 | cache.remove(CACHE_KEY); 64 | return getServices(); 65 | }, 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /web/src/hooks/useServiceHealth.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import { useCallback, useMemo } from 'react'; 7 | import { useServiceData } from './useServiceData'; 8 | import { ServiceStatus } from '../types/service'; 9 | import { api } from '../utils/api'; 10 | 11 | type StatusCount = Record; 12 | 13 | const initialStatusCount: StatusCount = { 14 | online: 0, 15 | offline: 0, 16 | warning: 0, 17 | error: 0, 18 | loading: 0, 19 | pending: 0, 20 | unknown: 0, 21 | }; 22 | 23 | interface HealthResponse { 24 | status: ServiceStatus; 25 | message?: string; 26 | version?: string; 27 | updateAvailable?: boolean; 28 | } 29 | 30 | export const useServiceHealth = () => { 31 | const { services, isLoading, refreshService } = useServiceData(); 32 | 33 | // Memoize status counts to prevent unnecessary recalculations 34 | const statusCounts = useMemo((): StatusCount => { 35 | return (services || []).reduce( 36 | (acc, service) => { 37 | const status = service.status || 'unknown'; 38 | return { 39 | ...acc, 40 | [status]: acc[status] + 1, 41 | }; 42 | }, 43 | { ...initialStatusCount } 44 | ); 45 | }, [services]); 46 | 47 | const refreshServiceHealth = useCallback(async (instanceId: string) => { 48 | try { 49 | const response = await api.get(`/api/health/${instanceId}`); 50 | if (response && response.status) { 51 | refreshService(instanceId, 'health'); 52 | } 53 | return response; 54 | } catch (error) { 55 | console.error(`Error refreshing health for service ${instanceId}:`, error); 56 | return null; 57 | } 58 | }, [refreshService]); 59 | 60 | return { 61 | services: services || [], 62 | isLoading, 63 | refreshServiceHealth, 64 | statusCounts, 65 | }; 66 | }; 67 | -------------------------------------------------------------------------------- /web/src/hooks/useServiceManagement.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import { useCallback, useState } from 'react'; 7 | import { ServiceType } from '../types/service'; 8 | import { useConfiguration } from '../contexts/useConfiguration'; 9 | import { toast } from 'react-hot-toast'; 10 | 11 | interface PendingService { 12 | type: ServiceType; 13 | name: string; 14 | instanceId: string; 15 | displayName: string; 16 | } 17 | 18 | interface ServiceConfig { 19 | url: string; 20 | apiKey: string; 21 | displayName: string; 22 | accessUrl?: string; 23 | } 24 | 25 | export const useServiceManagement = () => { 26 | const { configurations, updateConfiguration, deleteConfiguration } = useConfiguration(); 27 | const [showServiceConfig, setShowServiceConfig] = useState(false); 28 | const [pendingService, setPendingService] = useState(null); 29 | 30 | const addServiceInstance = useCallback(async (templateType: ServiceType, templateName: string) => { 31 | const existingInstances = Object.keys(configurations) 32 | .filter(key => key.startsWith(`${templateType}-`)) 33 | .length; 34 | const instanceNumber = existingInstances + 1; 35 | const instanceId = `${templateType}-${instanceNumber}`; 36 | 37 | // For general service, don't set an initial display name 38 | const displayName = templateType === 'general' 39 | ? '' 40 | : `${templateName}${instanceNumber > 1 ? ` ${instanceNumber}` : ''}`; 41 | 42 | setPendingService({ 43 | type: templateType, 44 | name: templateName, 45 | instanceId, 46 | displayName 47 | }); 48 | setShowServiceConfig(true); 49 | }, [configurations]); 50 | 51 | const confirmServiceAddition = useCallback(async (url: string, apiKey: string, displayName: string, accessUrl?: string) => { 52 | if (!pendingService) return; 53 | 54 | try { 55 | await updateConfiguration(pendingService.instanceId, { 56 | url, 57 | apiKey, 58 | displayName: displayName || pendingService.displayName, 59 | accessUrl 60 | } as ServiceConfig); 61 | 62 | toast.success(`Added new service instance`); 63 | setShowServiceConfig(false); 64 | setPendingService(null); 65 | } catch (err) { 66 | toast.error('Failed to add service instance'); 67 | console.error('Error adding service:', err); 68 | } 69 | }, [pendingService, updateConfiguration]); 70 | 71 | const cancelServiceAddition = useCallback(() => { 72 | setShowServiceConfig(false); 73 | setPendingService(null); 74 | }, []); 75 | 76 | const removeServiceInstance = useCallback(async (instanceId: string) => { 77 | try { 78 | await deleteConfiguration(instanceId); 79 | toast.success('Service instance removed'); 80 | } catch (err) { 81 | toast.error('Failed to remove service instance'); 82 | console.error('Error removing service:', err); 83 | } 84 | }, [deleteConfiguration]); 85 | 86 | return { 87 | addServiceInstance, 88 | removeServiceInstance, 89 | showServiceConfig, 90 | pendingService, 91 | confirmServiceAddition, 92 | cancelServiceAddition 93 | }; 94 | }; 95 | -------------------------------------------------------------------------------- /web/src/main.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import React from "react"; 7 | import ReactDOM from "react-dom/client"; 8 | import App from "./App"; 9 | import "./index.css"; 10 | import { registerSW } from "virtual:pwa-register"; 11 | 12 | // Force dark mode 13 | document.documentElement.classList.add("dark"); 14 | 15 | // Register service worker with auto update handling 16 | const updateSW = registerSW({ 17 | onNeedRefresh() { 18 | if (confirm("New version available! Click OK to update.")) { 19 | updateSW(true); 20 | } 21 | }, 22 | onOfflineReady() { 23 | console.log("App ready to work offline"); 24 | }, 25 | immediate: true, 26 | }); 27 | 28 | const root = document.getElementById("root"); 29 | if (!root) { 30 | throw new Error("Root element not found"); 31 | } 32 | 33 | ReactDOM.createRoot(root).render( 34 | 35 | 36 | 37 | ); 38 | -------------------------------------------------------------------------------- /web/src/types/auth.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | import { AuthConfig } from "../config/auth"; 7 | 8 | export interface User { 9 | id?: number; 10 | sub?: string; 11 | email?: string; 12 | name?: string; 13 | picture?: string; 14 | given_name?: string; 15 | family_name?: string; 16 | preferred_username?: string; 17 | email_verified?: boolean; 18 | username?: string; 19 | auth_type?: 'oidc' | 'builtin'; 20 | } 21 | 22 | export interface LoginCredentials { 23 | username: string; 24 | password: string; 25 | } 26 | 27 | export interface RegisterCredentials extends LoginCredentials { 28 | email: string; 29 | } 30 | 31 | export interface AuthResponse { 32 | access_token: string; 33 | token_type: string; 34 | expires_in: number; 35 | user: User; 36 | } 37 | 38 | export interface AuthState { 39 | isAuthenticated: boolean; 40 | user: User | null; 41 | loading: boolean; 42 | authConfig: AuthConfig | null; 43 | } 44 | 45 | export interface AuthContextType extends AuthState { 46 | login: (credentials?: LoginCredentials) => Promise; 47 | register: (credentials: RegisterCredentials) => Promise; 48 | logout: () => Promise; 49 | loginWithOIDC: () => void; 50 | } 51 | 52 | export interface AuthError { 53 | message: string; 54 | code?: string; 55 | } 56 | -------------------------------------------------------------------------------- /web/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024, s0up and the autobrr contributors. 3 | * SPDX-License-Identifier: GPL-2.0-or-later 4 | */ 5 | 6 | export function classNames(...classes: (string | boolean | undefined | null)[]): string { 7 | return classes.filter(Boolean).join(' '); 8 | } 9 | -------------------------------------------------------------------------------- /web/src/utils/mediaTypes.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FilmIcon, 3 | TvIcon, 4 | MusicalNoteIcon, 5 | BookOpenIcon, 6 | FolderIcon, 7 | CommandLineIcon, 8 | CpuChipIcon, 9 | NoSymbolIcon, 10 | SparklesIcon 11 | } from "@heroicons/react/24/outline"; 12 | 13 | export type MediaType = 'Movie' | 'Show' | 'Anime' | 'Game' | 'Book' | 'Audio' | 'Application' | 'Adult' | 'Other'; 14 | 15 | export function getMediaType(category: string): MediaType { 16 | const lowercase = category.toLowerCase(); 17 | 18 | // Anime specific detection 19 | if (lowercase.includes('anime') || lowercase.includes('ona') || lowercase.includes('ova')) { 20 | return 'Anime'; 21 | } 22 | 23 | // Movies 24 | if (lowercase.includes('movie') || lowercase.includes('3d') || lowercase.includes('bluray')) { 25 | return 'Movie'; 26 | } 27 | 28 | // TV Shows 29 | if (lowercase.includes('tv') || lowercase.includes('show') || 30 | lowercase.includes('episode') || lowercase.includes('season')) { 31 | return 'Show'; 32 | } 33 | 34 | // Games 35 | if (lowercase.includes('game') || lowercase.includes('nintendo') || 36 | lowercase.includes('playstation') || lowercase.includes('visual novel')) { 37 | return 'Game'; 38 | } 39 | 40 | // Books and Reading Material 41 | if (lowercase.includes('book') || lowercase.includes('manga') || 42 | lowercase.includes('comic') || lowercase.includes('magazine') || 43 | lowercase.includes('novel')) { 44 | return 'Book'; 45 | } 46 | 47 | // Audio content 48 | if (lowercase.includes('audio') || lowercase.includes('music') || 49 | lowercase.includes('flac')) { 50 | return 'Audio'; 51 | } 52 | 53 | // Applications and Software 54 | if (lowercase.includes('app') || lowercase.includes('software')) { 55 | return 'Application'; 56 | } 57 | 58 | // Adult content 59 | if (lowercase.includes('xxx') || lowercase.includes('adult')) { 60 | return 'Adult'; 61 | } 62 | 63 | return 'Other'; 64 | } 65 | 66 | export const mediaTypeIcons = { 67 | Movie: FilmIcon, 68 | Show: TvIcon, 69 | Anime: SparklesIcon, 70 | Game: CommandLineIcon, 71 | Book: BookOpenIcon, 72 | Audio: MusicalNoteIcon, 73 | Application: CpuChipIcon, 74 | Adult: NoSymbolIcon, 75 | Other: FolderIcon, 76 | } as const; 77 | 78 | // Add this type to handle the HeroIcon component type 79 | type HeroIcon = typeof mediaTypeIcons[keyof typeof mediaTypeIcons]; 80 | 81 | // Update the return type of getMediaTypeIcon 82 | export function getMediaTypeIcon(type: MediaType): HeroIcon { 83 | return mediaTypeIcons[type] || mediaTypeIcons.Other; 84 | } -------------------------------------------------------------------------------- /web/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | declare module 'virtual:pwa-register' { 5 | export interface RegisterSWOptions { 6 | immediate?: boolean 7 | onNeedRefresh?: () => void 8 | onOfflineReady?: () => void 9 | onRegistered?: (registration: ServiceWorkerRegistration | undefined) => void 10 | onRegisterError?: (error: Error) => void 11 | } 12 | 13 | export function registerSW(options?: RegisterSWOptions): (reloadPage?: boolean) => Promise 14 | } 15 | -------------------------------------------------------------------------------- /web/src/vite-pwa.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module 'virtual:pwa-register' { 4 | export interface RegisterSWOptions { 5 | immediate?: boolean 6 | onNeedRefresh?: () => void 7 | onOfflineReady?: () => void 8 | onRegistered?: (registration: ServiceWorkerRegistration | undefined) => void 9 | onRegisterError?: (error: Error) => void 10 | } 11 | 12 | export function registerSW(options?: RegisterSWOptions): (reloadPage?: boolean) => Promise 13 | } 14 | -------------------------------------------------------------------------------- /web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | import { lerpColors } from "tailwind-lerp-colors"; 2 | import forms from "@tailwindcss/forms"; 3 | 4 | const extendedColors = lerpColors(); 5 | 6 | /** @type {import('tailwindcss').Config} */ 7 | export default { 8 | content: ["./src/**/*.{tsx,ts,html,css}"], 9 | safelist: [ 10 | "col-span-1", 11 | "col-span-2", 12 | "col-span-3", 13 | "col-span-4", 14 | "col-span-5", 15 | "col-span-6", 16 | "col-span-7", 17 | "col-span-8", 18 | "col-span-9", 19 | "col-span-10", 20 | "col-span-11", 21 | "col-span-12", 22 | ], 23 | darkMode: "class", 24 | theme: { 25 | extend: { 26 | screens: { 27 | lg: "1024px", 28 | xl: "1440px", 29 | "2xl": "1836px", 30 | "3xl": "2900px", 31 | }, 32 | colors: { 33 | ...extendedColors, 34 | gray: { 35 | ...extendedColors.zinc, 36 | 815: "#232427", 37 | }, 38 | }, 39 | margin: { 40 | 2.5: "0.625rem", 41 | }, 42 | textShadow: { 43 | DEFAULT: "0 2px 4px var(--tw-shadow-color)", 44 | }, 45 | boxShadow: { 46 | table: "rgba(0, 0, 0, 0.1) 0px 4px 16px 0px", 47 | }, 48 | animation: { 49 | fadeIn: "fadeIn 0.5s ease-in-out", 50 | bounce: "bounce 1s infinite", 51 | shimmer: "shimmer 2s infinite linear", 52 | }, 53 | keyframes: { 54 | fadeIn: { 55 | "0%": { opacity: "0", transform: "translateY(10px)" }, 56 | "100%": { opacity: "1", transform: "translateY(0)" }, 57 | }, 58 | bounce: { 59 | "0%, 100%": { 60 | transform: "translateY(-25%)", 61 | animationTimingFunction: "cubic-bezier(0.8, 0, 1, 1)", 62 | }, 63 | "50%": { 64 | transform: "translateY(0)", 65 | animationTimingFunction: "cubic-bezier(0, 0, 0.2, 1)", 66 | }, 67 | }, 68 | shimmer: { 69 | "100%": { transform: "translateX(100%)" }, 70 | }, 71 | }, 72 | }, 73 | }, 74 | variants: { 75 | extend: {}, 76 | }, 77 | plugins: [forms], 78 | }; 79 | -------------------------------------------------------------------------------- /web/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "Bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | }, 23 | "include": ["src"] 24 | } 25 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /web/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2023"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | /* Bundler mode */ 9 | "moduleResolution": "Bundler", 10 | "allowImportingTsExtensions": true, 11 | "isolatedModules": true, 12 | "moduleDetection": "force", 13 | "noEmit": true, 14 | 15 | /* Linting */ 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "include": ["./vite.config.ts"] 22 | } 23 | --------------------------------------------------------------------------------