├── .dockerignore
├── .github
├── ISSUE_TEMPLATE
│ └── bug_report.md
└── workflows
│ ├── ci.yml
│ ├── codeql-analysis.yml
│ └── lint.yml
├── .gitignore
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── README.md
├── cheddarr.py
├── client
├── .prettierignore
├── .prettierrc
├── README.md
├── package.json
├── public
│ ├── favicon.ico
│ ├── index.html
│ ├── manifest.json
│ └── robots.txt
├── src
│ ├── App.test.ts
│ ├── App.tsx
│ ├── DynamicApp.tsx
│ ├── GlobalStyles.tsx
│ ├── Themes.ts
│ ├── assets
│ │ ├── cheddarr-min.svg
│ │ ├── cheddarr-post.svg
│ │ ├── cheddarr-pre.svg
│ │ ├── cheddarr-small.png
│ │ ├── cheddarr.png
│ │ ├── cheddarr.svg
│ │ └── plex.png
│ ├── axiosInstance.ts
│ ├── index.scss
│ ├── index.tsx
│ ├── logged-in-app
│ │ ├── LoggedInApp.tsx
│ │ ├── navbar
│ │ │ ├── Navbar.tsx
│ │ │ ├── NavbarCommon.tsx
│ │ │ ├── NavbarMobile.tsx
│ │ │ └── components
│ │ │ │ ├── search-bar
│ │ │ │ ├── SearchBar.tsx
│ │ │ │ └── SearchDropdownType.tsx
│ │ │ │ └── user-dropdown
│ │ │ │ ├── UserAvatar.tsx
│ │ │ │ └── UserDropdown.tsx
│ │ ├── pages
│ │ │ ├── Home.tsx
│ │ │ ├── Requests.tsx
│ │ │ ├── Search.tsx
│ │ │ ├── settings
│ │ │ │ ├── Settings.tsx
│ │ │ │ ├── general
│ │ │ │ │ └── GeneralSettings.tsx
│ │ │ │ ├── jobs
│ │ │ │ │ └── JobsSettings.tsx
│ │ │ │ ├── media-providers
│ │ │ │ │ ├── MediaProvidersSettings.tsx
│ │ │ │ │ ├── PickMediaProviderTypeModal.tsx
│ │ │ │ │ ├── radarr
│ │ │ │ │ │ ├── EditRadarrSettingsModal.tsx
│ │ │ │ │ │ ├── RadarrSettingsBoxPreview.tsx
│ │ │ │ │ │ └── RadarrSettingsForm.tsx
│ │ │ │ │ └── sonarr
│ │ │ │ │ │ ├── EditSonarrSettingsModal.tsx
│ │ │ │ │ │ ├── SonarrSettingsBoxPreview.tsx
│ │ │ │ │ │ └── SonarrSettingsForm.tsx
│ │ │ │ ├── media-servers
│ │ │ │ │ ├── MediaServersInfo.tsx
│ │ │ │ │ ├── MediaServersSettings.tsx
│ │ │ │ │ ├── PickMediaServerTypeModal.tsx
│ │ │ │ │ └── plex
│ │ │ │ │ │ ├── AddPlexSettings.tsx
│ │ │ │ │ │ ├── EditPlexSettingsModal.tsx
│ │ │ │ │ │ ├── PlexSettingsBoxPreview.tsx
│ │ │ │ │ │ └── PlexSettingsForm.tsx
│ │ │ │ ├── notifications
│ │ │ │ │ ├── Email.tsx
│ │ │ │ │ ├── NotificationsServicesSettings.tsx
│ │ │ │ │ ├── PickNotificationsServiceTypeModal.tsx
│ │ │ │ │ └── email
│ │ │ │ │ │ ├── EditEmailSettingsModal.tsx
│ │ │ │ │ │ ├── EmailSettingsBoxPreview.tsx
│ │ │ │ │ │ └── EmailSettingsForm.tsx
│ │ │ │ ├── server-logs
│ │ │ │ │ └── ServerLogs.tsx
│ │ │ │ └── users
│ │ │ │ │ ├── UsersConfirmed.tsx
│ │ │ │ │ ├── UsersPending.tsx
│ │ │ │ │ └── UsersSettings.tsx
│ │ │ └── user-profile
│ │ │ │ ├── Profile.tsx
│ │ │ │ └── account
│ │ │ │ ├── ChangeEmailModal.tsx
│ │ │ │ ├── ChangePasswordModal.tsx
│ │ │ │ ├── ChangeUsernameModal.tsx
│ │ │ │ ├── DeleteAccountModal.tsx
│ │ │ │ └── UpdateProfile.tsx
│ │ └── sidebarMenu
│ │ │ ├── SidebarMenu.tsx
│ │ │ ├── SidebarMenuCommon.tsx
│ │ │ └── SidebarMenuMobile.tsx
│ ├── logged-out-app
│ │ ├── SignInForm.tsx
│ │ ├── SignUpForm.tsx
│ │ └── elements
│ │ │ ├── InitResetPasswordModal.tsx
│ │ │ ├── ResetPassword.tsx
│ │ │ └── ResetPasswordForm.tsx
│ ├── react-app-env.d.ts
│ ├── router
│ │ ├── LoggedInRoute.tsx
│ │ ├── LoggedOutRoute.tsx
│ │ ├── SwitchRoutes.tsx
│ │ └── routes.ts
│ ├── serviceWorker.ts
│ ├── setupTests.ts
│ ├── shared
│ │ ├── Home.tsx
│ │ ├── components
│ │ │ ├── Alert.tsx
│ │ │ ├── Button.tsx
│ │ │ ├── ClosableTitle.tsx
│ │ │ ├── DeleteDataModal.tsx
│ │ │ ├── Divider.tsx
│ │ │ ├── Footer.tsx
│ │ │ ├── FullWidthTag.tsx
│ │ │ ├── GithubButton.tsx
│ │ │ ├── Help.tsx
│ │ │ ├── Icon.tsx
│ │ │ ├── Image.tsx
│ │ │ ├── ItemBox.tsx
│ │ │ ├── LinkPlexAccount.tsx
│ │ │ ├── ManageLogLevels.tsx
│ │ │ ├── PageLoader.tsx
│ │ │ ├── PaginationArrows.tsx
│ │ │ ├── PlexButton.tsx
│ │ │ ├── RolesTree.tsx
│ │ │ ├── SignInButton.tsx
│ │ │ ├── SignUpButton.tsx
│ │ │ ├── Spinner.tsx
│ │ │ ├── SubmitConfig.tsx
│ │ │ ├── Tag.tsx
│ │ │ ├── Titles.tsx
│ │ │ ├── Tooltiped.tsx
│ │ │ ├── UserSmallCard.tsx
│ │ │ ├── animations
│ │ │ │ ├── Animate.tsx
│ │ │ │ └── Animations.ts
│ │ │ ├── errors
│ │ │ │ ├── InternalServerError.tsx
│ │ │ │ ├── NotFound.tsx
│ │ │ │ ├── RequestExpired.tsx
│ │ │ │ ├── SwitchErrors.tsx
│ │ │ │ ├── UnhandledError.tsx
│ │ │ │ └── UnprocessableEntity.tsx
│ │ │ ├── inputs
│ │ │ │ ├── Checkbox.tsx
│ │ │ │ └── InputField.tsx
│ │ │ ├── layout
│ │ │ │ ├── Buttons.tsx
│ │ │ │ ├── Carousel.tsx
│ │ │ │ ├── CenteredContent.tsx
│ │ │ │ ├── Hero.tsx
│ │ │ │ ├── Modal.tsx
│ │ │ │ ├── Row.tsx
│ │ │ │ └── Tabs.tsx
│ │ │ ├── media
│ │ │ │ ├── Episode.tsx
│ │ │ │ ├── Media.tsx
│ │ │ │ ├── MediaBackground.tsx
│ │ │ │ ├── MediaCardsLoader.tsx
│ │ │ │ ├── MediaCarouselWidget.tsx
│ │ │ │ ├── MediaPersonCarousel.tsx
│ │ │ │ ├── MediaPreviewCard.tsx
│ │ │ │ ├── MediaRating.tsx
│ │ │ │ ├── Movie.tsx
│ │ │ │ ├── Season.tsx
│ │ │ │ └── Series.tsx
│ │ │ └── requests
│ │ │ │ ├── RequestButton.tsx
│ │ │ │ ├── RequestLayout.tsx
│ │ │ │ ├── RequestMediaModal.tsx
│ │ │ │ ├── RequestSeriesCard.tsx
│ │ │ │ ├── RequestsReceived.tsx
│ │ │ │ ├── RequestsSent.tsx
│ │ │ │ ├── SeriesRequestEpisodesList.tsx
│ │ │ │ ├── SeriesRequestOptionsPreview.tsx
│ │ │ │ └── SeriesRequestSeasonsList.tsx
│ │ ├── contexts
│ │ │ ├── AlertContext.tsx
│ │ │ ├── AuthenticationContext.tsx
│ │ │ ├── NotificationsServicesContext.tsx
│ │ │ ├── PlexAuthContext.tsx
│ │ │ ├── PlexConfigContext.tsx
│ │ │ ├── RadarrConfigsContext.tsx
│ │ │ ├── RequestsContext.tsx
│ │ │ ├── SeriesRequestOptionsContext.tsx
│ │ │ ├── SessionContext.tsx
│ │ │ ├── SonarrConfigContext.tsx
│ │ │ ├── TabsContext.tsx
│ │ │ └── ThemeContext.tsx
│ │ ├── enums
│ │ │ ├── APIRoutes.ts
│ │ │ ├── ComponentSizes.ts
│ │ │ ├── ErrorsMessage.ts
│ │ │ ├── FormDefaultValidators.ts
│ │ │ ├── LogLevels.tsx
│ │ │ ├── MediaProvidersTypes.ts
│ │ │ ├── MediaServersTypes.ts
│ │ │ ├── MediaTypes.ts
│ │ │ ├── Messages.ts
│ │ │ ├── NotificationsServicesTypes.ts
│ │ │ ├── ProviderTypes.ts
│ │ │ ├── RequestSeriesOptions.ts
│ │ │ ├── RequestStatus.ts
│ │ │ ├── RequestTypes.ts
│ │ │ ├── Roles.ts
│ │ │ ├── SearchFilters.ts
│ │ │ └── StaticStyles.ts
│ │ ├── hooks
│ │ │ ├── useAPI.tsx
│ │ │ ├── useConfig.tsx
│ │ │ ├── useEpisode.tsx
│ │ │ ├── useImage.ts
│ │ │ ├── useJobs.ts
│ │ │ ├── useMedia.ts
│ │ │ ├── useMediaServerLibrariesService.ts
│ │ │ ├── useMovie.tsx
│ │ │ ├── useOutsideAlerter.ts
│ │ │ ├── usePagination.ts
│ │ │ ├── usePlexServers.tsx
│ │ │ ├── useRadarrConfigs.tsx
│ │ │ ├── useRequestMedia.tsx
│ │ │ ├── useRequests.ts
│ │ │ ├── useRoleGuard.ts
│ │ │ ├── useScrollPosition.tsx
│ │ │ ├── useSearchMedia.tsx
│ │ │ ├── useSeason.ts
│ │ │ ├── useSeries.tsx
│ │ │ ├── useSonarrConfigs.tsx
│ │ │ ├── useUser.ts
│ │ │ └── useWindowSize.ts
│ │ ├── models
│ │ │ ├── IAsyncCall.ts
│ │ │ ├── IAsyncData.ts
│ │ │ ├── IConfig.ts
│ │ │ ├── IDecodedToken.ts
│ │ │ ├── IEmailConfig.ts
│ │ │ ├── IEncodedToken.ts
│ │ │ ├── IJob.ts
│ │ │ ├── ILog.ts
│ │ │ ├── IMedia.ts
│ │ │ ├── IMediaProviderConfig.ts
│ │ │ ├── IMediaRequest.ts
│ │ │ ├── IMediaServerConfig.ts
│ │ │ ├── INotificationsConfig.ts
│ │ │ ├── IPaginated.ts
│ │ │ ├── IProviderSettingsBase.ts
│ │ │ ├── IQualityProfile.ts
│ │ │ ├── IRadarrConfig.ts
│ │ │ ├── IRadarrInstanceInfo.ts
│ │ │ ├── IRequestCreate.ts
│ │ │ ├── IRequestSeriesOptions.ts
│ │ │ ├── IRequestUpdate.ts
│ │ │ ├── ISession.ts
│ │ │ ├── ISignInFormData.ts
│ │ │ ├── ISignUpFormData.ts
│ │ │ ├── ISonarrConfig.ts
│ │ │ ├── ISonarrInstanceInfo.ts
│ │ │ └── IUser.ts
│ │ └── toRefactor
│ │ │ └── useUserService.tsx
│ ├── styled.d.ts
│ └── utils
│ │ ├── media-utils.ts
│ │ ├── objects.ts
│ │ ├── roles.ts
│ │ └── strings.ts
├── tsconfig.json
└── yarn.lock
├── poetry.lock
├── pyproject.toml
└── server
├── __init__.py
├── alembic.ini
├── api
├── __init__.py
├── dependencies.py
└── v1
│ ├── __init__.py
│ ├── endpoints
│ ├── __init__.py
│ ├── auth.py
│ ├── movies.py
│ ├── notifications.py
│ ├── requests.py
│ ├── search.py
│ ├── series.py
│ ├── settings.py
│ ├── system.py
│ └── users.py
│ └── router.py
├── core
├── __init__.py
├── config.py
├── http_client.py
├── logger.py
├── scheduler.py
├── security.py
└── utils.py
├── database
├── __init__.py
├── base.py
├── init_db.py
├── migrations
│ ├── env.py
│ ├── script.py.mako
│ └── versions
│ │ ├── 1b33a8f77eda_.py
│ │ ├── 2337500663a0_.py
│ │ ├── 2ab75f3fa7c6_.py
│ │ ├── 81c905106fb6_.py
│ │ └── c446bad70344_.py
└── session.py
├── jobs
├── __init__.py
├── plex.py
├── radarr.py
└── sonarr.py
├── main.py
├── models
├── __init__.py
├── media.py
├── notifications.py
├── requests.py
├── settings.py
└── users.py
├── repositories
├── __init__.py
├── base.py
├── media.py
├── notifications.py
├── requests.py
├── settings.py
└── users.py
├── schemas
├── __init__.py
├── auth.py
├── core.py
├── external_services.py
├── media.py
├── notifications.py
├── requests.py
├── settings.py
├── system.py
└── users.py
├── services
├── __init__.py
├── core.py
├── plex.py
├── radarr.py
├── sonarr.py
└── tmdb.py
├── site.py
├── static
└── images
│ └── users
│ ├── cheese-blue.png
│ ├── cheese-cyan.png
│ ├── cheese-green.png
│ ├── cheese-ocean.png
│ ├── cheese-orange.png
│ ├── cheese-pink.png
│ ├── cheese-purple.png
│ └── cheese-red.png
├── templates
└── email
│ ├── change_password_notice.html
│ ├── email_confirmation.html
│ ├── layout.html
│ ├── reset_password_instructions.html
│ ├── reset_password_notice.html
│ └── welcome.html
└── tests
├── __init__.py
├── api
├── __init__.py
└── v1
│ ├── __init__.py
│ ├── test_auth.py
│ ├── test_requests.py
│ └── test_users.py
├── conftest.py
└── utils.py
/.dockerignore:
--------------------------------------------------------------------------------
1 | client/node_modules
2 | client/build
3 | client/.prettierignore
4 | client/..prettierrc
5 | .dockerignore
6 | Dockerfile
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: bug
6 |
7 | ---
8 |
9 | **Describe the bug**
10 | A clear and concise description of what the bug is.
11 |
12 | **To Reproduce**
13 | Steps to reproduce the behavior:
14 | 1. Go to '...'
15 | 2. Click on '....'
16 | 3. Scroll down to '....'
17 | 4. See error
18 |
19 | **Expected behavior**
20 | A clear and concise description of what you expected to happen.
21 |
22 | **Screenshots**
23 | If applicable, add screenshots to help explain your problem.
24 |
25 | **Platform (please complete the following information):**
26 | - OS: [e.g. iOS]
27 | - Browser [e.g. chrome, safari]
28 | - Version [e.g. 22]
29 |
30 | **Additional context**
31 | Add any other context about the problem here.
32 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on: [push,pull_request]
4 |
5 | jobs:
6 | ci:
7 | strategy:
8 | matrix:
9 | node-version: [16.x]
10 | python-version: [3.9.x]
11 | os: [ubuntu-latest]
12 | runs-on: ${{ matrix.os }}
13 | steps:
14 | - uses: actions/checkout@v2
15 | - name: Use Node.js ${{ matrix.node-version }}
16 | uses: actions/setup-node@v1
17 | with:
18 | node-version: ${{ matrix.node-version }}
19 | - name: Build Node
20 | run: |
21 | cd client
22 | yarn install
23 | yarn run build --if-present
24 | yarn test
25 | env:
26 | CI: true
27 | - name: Set up Python
28 | uses: actions/setup-python@v2
29 | with:
30 | python-version: ${{ matrix.python-version }}
31 | - name: Python Poetry Action
32 | uses: abatilo/actions-poetry@v2.1.0
33 | - name: Poetry install
34 | run: poetry install
35 | - name: Test with pytest
36 | run: poetry run python -m pytest -v
37 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: Lint
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | lint:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v2
10 | - uses: actions/setup-python@v2
11 | - uses: psf/black@stable
12 | - name: Lint with flake8
13 | run: |
14 | pip install flake8
15 | # stop the build if there are Python syntax errors or undefined names
16 | flake8 ./server --count --select=E9,F63,F7,F82 --show-source --statistics
17 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
18 | flake8 ./server --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
19 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 |
2 | # Contributing to Cheddarr
3 |
4 | ## Development
5 |
6 | ### Tools Required
7 |
8 | - [Python](https://www.python.org/downloads) (3.9 or higher)
9 | - [NodeJS](https://nodejs.org/en/download/) (14.x or higher)
10 | - [Yarn](https://yarnpkg.com/)
11 | - [Git](https://git-scm.com/downloads)
12 |
13 | ### Getting Started
14 |
15 | 1. Fork the repository to your own GitHub account and clone it to your local device:
16 |
17 | ```bash
18 | git clone https://github.com/YOUR_USERNAME/cheddarr.git
19 | cd cheddarr/
20 | ```
21 |
22 | 2. Add the remote `upstream`:
23 |
24 | ```bash
25 | git remote add upstream https://github.com/sct/cheddarr.git
26 | ```
27 |
28 | 3. Create a new branch:
29 |
30 | ```bash
31 | git checkout -b BRANCH_NAME develop
32 | ```
33 |
34 | 4. Run the development environment (backend and frontend):
35 |
36 | ```bash
37 | cheddarr.py -d run
38 | yarn
39 | yarn start
40 | ```
41 |
42 | 5. Create your patch and test your changes.
43 |
44 | - Be sure to follow both the [code](#contributing-code) guidelines.
45 | - Should you need to update your fork, you can do so by rebasing from `upstream`:
46 | ```bash
47 | git fetch upstream
48 | git rebase upstream/develop
49 | git push origin BRANCH_NAME -f
50 | ```
51 |
52 | ### Contributing Code
53 |
54 | - If you are taking on an existing bug or feature, please comment on the [issue](https://github.com/Jeroli-co/Cheddarr/issues) to avoid multiple people working on the same thing.
55 | - Always rebase your commit to the latest `develop` branch. Do **not** merge `develop` into your branch.
56 | - You can create a "draft" pull request early to get feedback on your work.
57 | - Your code **must** be formatted correctly ([Prettier](https://prettier.io/docs/en/install.html) for frontend and [Black](https://black.readthedocs.io/en/stable/integrations/index.html) for backend), or the tests will fail.
58 | - If you have questions or need help, you can reach out via [Discussions](https://github.com/Jeroli-co/Cheddarr/discussions) or our [Discord server](https://discord.gg/xC3cSjwSVr).
59 | - Only open pull requests to `develop`, never `master`! Any pull requests opened to `master` will be closed.
60 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:15.7.0-alpine AS FRONT_STAGE
2 | WORKDIR /app
3 |
4 | # Copy frontend sources
5 | COPY /client ./client
6 |
7 | # Install frontend dependencies and build
8 | RUN cd client && yarn install && yarn cache clean && yarn build --production
9 |
10 |
11 | FROM python:3.9.5-slim
12 | WORKDIR /app
13 |
14 | # Copy front build
15 | COPY --from=FRONT_STAGE /app/client/build ./client/build
16 |
17 | # Copy poetry.lock* in case it doesn't exist in the repo
18 | COPY /pyproject.toml /poetry.lock* /app/
19 |
20 | # Install Poetry and backend dependencies
21 | RUN pip install poetry alembic && \
22 | poetry config virtualenvs.create false && \
23 | poetry install --no-root --no-dev
24 |
25 | # Copy backend sources
26 | COPY /server ./server
27 | COPY cheddarr.py .
28 |
29 | EXPOSE 9090
30 | ENTRYPOINT ["python", "cheddarr.py", "run"]
31 |
32 |
--------------------------------------------------------------------------------
/cheddarr.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | from pathlib import Path
3 |
4 | import click
5 |
6 | """USAGE:
7 | python cheddarr.py [OPTIONS] COMMAND
8 | """
9 |
10 |
11 | @click.group(
12 | help="""A utility script for the Cheddarr application""",
13 | )
14 | @click.option(
15 | "--debug",
16 | "-d",
17 | default=False,
18 | is_flag=True,
19 | )
20 | @click.pass_context
21 | def cli(ctx, debug):
22 | ctx.obj["DEBUG"] = debug
23 |
24 |
25 | @cli.command("init-db")
26 | def init_db():
27 | """Initialize the database."""
28 | from server.database.init_db import init_db
29 |
30 | init_db()
31 | click.echo("Database initialized.")
32 |
33 |
34 | @cli.command("test")
35 | def test():
36 | """Run the tests."""
37 | import pytest
38 |
39 | rv = pytest.main(["./server/tests", "--verbose"])
40 | exit(rv)
41 |
42 |
43 | @cli.command("run")
44 | @click.pass_context
45 | def run(ctx):
46 | import uvicorn
47 |
48 | debug = ctx.obj["DEBUG"]
49 | if not debug:
50 | from alembic.command import upgrade
51 | from alembic.config import Config
52 |
53 | upgrade(Config(Path.cwd() / "server/alembic.ini"), "head")
54 | uvicorn.run(
55 | "server.main:app",
56 | host="0.0.0.0",
57 | port=9090,
58 | reload=debug,
59 | debug=debug,
60 | access_log=debug,
61 | )
62 |
63 |
64 | if __name__ == "__main__":
65 | cli(obj={})
66 |
--------------------------------------------------------------------------------
/client/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | build/
--------------------------------------------------------------------------------
/client/.prettierrc:
--------------------------------------------------------------------------------
1 | {}
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "client",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@fortawesome/fontawesome-svg-core": "^1.2.32",
7 | "@fortawesome/free-brands-svg-icons": "^5.15.1",
8 | "@fortawesome/free-regular-svg-icons": "^5.15.1",
9 | "@fortawesome/free-solid-svg-icons": "^5.15.1",
10 | "@fortawesome/react-fontawesome": "^0.1.14",
11 | "@hookform/strictly-typed": "^0.0.4",
12 | "@testing-library/jest-dom": "^4.2.4",
13 | "@testing-library/react": "^9.4.0",
14 | "@testing-library/user-event": "^7.2.1",
15 | "@types/classnames": "^2.2.11",
16 | "@types/humps": "^2.0.0",
17 | "@types/jest": "^26.0.20",
18 | "@types/js-cookie": "^2.2.6",
19 | "@types/jwt-decode": "^2.2.1",
20 | "@types/node": "^14.14.20",
21 | "@types/react": "^16.14.2",
22 | "@types/react-dom": "^16.9.10",
23 | "@types/react-router": "^5.1.10",
24 | "@types/react-router-dom": "^5.1.7",
25 | "@types/smoothscroll-polyfill": "^0.3.1",
26 | "@types/styled-components": "^5.1.7",
27 | "axios": "^0.19.2",
28 | "bulma": "^0.9.2",
29 | "classnames": "^2.2.6",
30 | "cross-env": "^7.0.3",
31 | "humps": "^2.0.1",
32 | "js-cookie": "^2.2.1",
33 | "mutationobserver-shim": "^0.3.7",
34 | "react": "^16.14.0",
35 | "react-dom": "^16.14.0",
36 | "react-hook-form": "^6.15.5",
37 | "react-router": "^5.2.0",
38 | "react-router-dom": "^5.2.0",
39 | "react-scripts": "^3.4.4",
40 | "sass": "^1.32.2",
41 | "smoothscroll-polyfill": "^0.4.4",
42 | "styled-components": "^5.2.1",
43 | "typescript": "^4.1.3"
44 | },
45 | "scripts": {
46 | "start": "react-scripts start",
47 | "build": "cross-env INLINE_RUNTIME_CHUNK=false && react-scripts build",
48 | "test": "react-scripts test",
49 | "eject": "react-scripts eject"
50 | },
51 | "eslintConfig": {
52 | "extends": "react-app"
53 | },
54 | "browserslist": {
55 | "production": [
56 | ">0.2%",
57 | "not dead",
58 | "not op_mini all"
59 | ],
60 | "development": [
61 | "last 1 chrome version",
62 | "last 1 firefox version",
63 | "last 1 safari version"
64 | ]
65 | },
66 | "proxy": "http://localhost:9090",
67 | "devDependencies": {
68 | "prettier": "2.0.4"
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/client/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Jeroli-co/Cheddarr/368d270e60468c2201bb7f5cfa67aac0e83be6d7/client/public/favicon.ico
--------------------------------------------------------------------------------
/client/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
29 | Cheddarr
30 |
31 |
32 |
33 |
34 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/client/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Cheddarr",
3 | "name": "Cheddarr",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/client/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/client/src/App.test.ts:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { render } from "@testing-library/react";
3 | import { App } from "./App";
4 |
5 | test("Component className is App", () => {
6 | const app = React.createElement(App);
7 | const tree: any = render(app);
8 | expect(tree.container.firstChild).toHaveClass("App");
9 | });
10 |
--------------------------------------------------------------------------------
/client/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { config } from "@fortawesome/fontawesome-svg-core";
3 | import "@fortawesome/fontawesome-svg-core/styles.css";
4 | import { AlertContextProvider } from "./shared/contexts/AlertContext";
5 | import { BrowserRouter } from "react-router-dom";
6 | import { ThemeContext } from "./shared/contexts/ThemeContext";
7 | import { SessionContextProvider } from "./shared/contexts/SessionContext";
8 | import { DynamicApp } from "./DynamicApp";
9 | import PlexAuthContextProvider from "./shared/contexts/PlexAuthContext";
10 |
11 | const App = () => {
12 | config.autoAddCss = false;
13 |
14 | return (
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | );
29 | };
30 |
31 | export { App };
32 |
--------------------------------------------------------------------------------
/client/src/DynamicApp.tsx:
--------------------------------------------------------------------------------
1 | import React, { Suspense } from "react";
2 | import { useSession } from "./shared/contexts/SessionContext";
3 | import { PageLoader } from "./shared/components/PageLoader";
4 | import { LoggedInApp } from "./logged-in-app/LoggedInApp";
5 |
6 | const AuthenticationContextProvider = React.lazy(() =>
7 | import("./shared/contexts/AuthenticationContext")
8 | );
9 | const PlexConfigContextProvider = React.lazy(() =>
10 | import("./shared/contexts/PlexConfigContext")
11 | );
12 | const SwitchRoutes = React.lazy(() => import("./router/SwitchRoutes"));
13 |
14 | export const DynamicApp = () => {
15 | const {
16 | session: { isAuthenticated, isLoading },
17 | } = useSession();
18 |
19 | if (isLoading) {
20 | return ;
21 | }
22 |
23 | return isAuthenticated ? (
24 | }>
25 |
26 |
27 |
28 |
29 | ) : (
30 | }>
31 |
32 |
33 |
34 |
35 | );
36 | };
37 |
--------------------------------------------------------------------------------
/client/src/GlobalStyles.tsx:
--------------------------------------------------------------------------------
1 | import { createGlobalStyle } from "styled-components";
2 |
3 | export const GlobalStyle = createGlobalStyle`
4 | body {
5 | margin: 0;
6 | min-height: 100vh;
7 | max-width: 100vw;
8 | font-family: 'Tajawal', sans-serif;
9 | //font-family: 'Montserrat', sans-serif;
10 | //font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
11 | -webkit-font-smoothing: antialiased;
12 | -moz-osx-font-smoothing: grayscale;
13 | background-color: ${(props) => props.theme.bgColor};
14 | color: ${(props) => props.theme.color};
15 | transition: background .5s ease;
16 | overflow-x: hidden;
17 | }
18 |
19 | a {
20 | color: ${(props) => props.theme.color};
21 | text-decoration: none;
22 | &:hover {
23 | color: ${(props) => props.theme.grey};
24 | }
25 | }
26 | `;
27 |
--------------------------------------------------------------------------------
/client/src/Themes.ts:
--------------------------------------------------------------------------------
1 | import { DefaultTheme } from "styled-components";
2 |
3 | export const THEMES: DefaultTheme = {
4 | bgColor: "#0D1117",
5 | color: "#fdfdf7",
6 | black: "hsl(214, 24%, 0%)",
7 | primary: "hsl(214, 24%, 10%)",
8 | primaryLight: "hsl(214, 24%, 20%)",
9 | primaryLighter: "hsl(214, 24%, 30%)",
10 | white: "hsl(214, 24%, 100%)",
11 | secondary: "hsl(23, 99%, 48%)",
12 | success: "hsl(129, 30%, 52%)",
13 | danger: "hsl(0, 91%, 49%)",
14 | dangerLight: "hsl(0, 91%, 59%)",
15 | warning: "hsl(38,91%,49%)",
16 | warningLight: "hsl(38,91%,59%)",
17 | grey: "#a1978a",
18 | plex: "#282a2d",
19 | movie: "hsl(223,81%,62%)",
20 | series: "hsl(259,50%,50%)",
21 | season: "hsl(17,71%,51%)",
22 | episode: "hsl(174,78%,32%)",
23 | };
24 |
--------------------------------------------------------------------------------
/client/src/assets/cheddarr-min.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
18 |
--------------------------------------------------------------------------------
/client/src/assets/cheddarr-post.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
24 |
--------------------------------------------------------------------------------
/client/src/assets/cheddarr-pre.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
25 |
--------------------------------------------------------------------------------
/client/src/assets/cheddarr-small.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Jeroli-co/Cheddarr/368d270e60468c2201bb7f5cfa67aac0e83be6d7/client/src/assets/cheddarr-small.png
--------------------------------------------------------------------------------
/client/src/assets/cheddarr.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Jeroli-co/Cheddarr/368d270e60468c2201bb7f5cfa67aac0e83be6d7/client/src/assets/cheddarr.png
--------------------------------------------------------------------------------
/client/src/assets/plex.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Jeroli-co/Cheddarr/368d270e60468c2201bb7f5cfa67aac0e83be6d7/client/src/assets/plex.png
--------------------------------------------------------------------------------
/client/src/axiosInstance.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import Cookies from "js-cookie";
3 | import { routes } from "./router/routes";
4 | import humps from "humps";
5 |
6 | const JSON_TYPE = "application/json";
7 | const FORM_URL_ENCODED_TYPE = "application/x-www-form-urlencoded";
8 | const API_VERSION = "v1";
9 |
10 | const instance = axios.create({
11 | baseURL: "/api/" + API_VERSION,
12 | });
13 |
14 | instance.defaults.headers.common["Accept"] = JSON_TYPE;
15 | instance.defaults.headers.post["Content-Type"] = JSON_TYPE;
16 | instance.defaults.headers.put["Content-Type"] = JSON_TYPE;
17 | instance.defaults.headers.patch["Content-Type"] = JSON_TYPE;
18 |
19 | instance.interceptors.request.use(
20 | (request) => {
21 | const tokenType = Cookies.get("token_type");
22 | const accessToken = Cookies.get("access_token");
23 |
24 | if (tokenType && accessToken) {
25 | request.headers.common["Authorization"] = tokenType.concat(
26 | " ",
27 | accessToken
28 | );
29 | }
30 |
31 | if (request.url?.startsWith(routes.SIGN_IN.url())) {
32 | request.headers.post["Content-Type"] = FORM_URL_ENCODED_TYPE;
33 | } else if (!request.url?.startsWith(routes.CONFIRM_PLEX_SIGNIN.url)) {
34 | if (request.data) {
35 | request.data = humps.decamelizeKeys(request.data);
36 | }
37 | }
38 |
39 | return request;
40 | },
41 | (error) => {
42 | return Promise.reject(error);
43 | }
44 | );
45 |
46 | instance.interceptors.response.use(
47 | (response) => {
48 | if (
49 | !response.config.url?.startsWith(routes.SIGN_IN.url()) &&
50 | !response.config.url?.startsWith(routes.CONFIRM_PLEX_SIGNIN.url) &&
51 | response.data
52 | ) {
53 | response.data = humps.camelizeKeys(response.data);
54 | }
55 |
56 | return response;
57 | },
58 | (error) => {
59 | return Promise.reject(error);
60 | }
61 | );
62 |
63 | export { instance };
64 |
--------------------------------------------------------------------------------
/client/src/index.scss:
--------------------------------------------------------------------------------
1 | @import "~bulma";
--------------------------------------------------------------------------------
/client/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import * as ReactDOM from "react-dom";
3 | import "./index.scss";
4 | import { App } from "./App";
5 | import * as serviceWorker from "./serviceWorker";
6 |
7 | ReactDOM.render(, document.getElementById("root"));
8 |
9 | // If you want your app to work offline and load faster, you can change
10 | // unregister() to register() below. Note this comes with some pitfalls.
11 | // Learn more about service workers: https://bit.ly/CRA-PWA
12 | serviceWorker.unregister();
13 |
--------------------------------------------------------------------------------
/client/src/logged-in-app/navbar/NavbarCommon.tsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 | import { STATIC_STYLES } from "../../shared/enums/StaticStyles";
3 |
4 | export const NavbarContainer = styled.div`
5 | position: fixed;
6 | top: 0;
7 | right: 0;
8 | width: 100%;
9 | height: ${STATIC_STYLES.NAVBAR_HEIGHT}px;
10 | background: ${(props) => props.theme.primary};
11 | z-index: 1;
12 | transition: width ${STATIC_STYLES.SIDEBAR_TRANSITION_DURATION} ease;
13 | `;
14 |
15 | export const NavbarUserAvatar = styled.img`
16 | cursor: pointer;
17 | width: 50px;
18 | height: 50px;
19 |
20 | @media screen and (max-width: ${STATIC_STYLES.MOBILE_MAX_WIDTH}px) {
21 | width: 45px;
22 | height: 45px;
23 | }
24 | `;
25 |
--------------------------------------------------------------------------------
/client/src/logged-in-app/navbar/components/user-dropdown/UserAvatar.tsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | const UserAvatar = styled.img`
4 | border-radius: 50%;
5 | min-height: 50px;
6 | max-height: 50px;
7 | min-width: 50px;
8 | max-width: 50px;
9 | `;
10 |
11 | export { UserAvatar };
12 |
--------------------------------------------------------------------------------
/client/src/logged-in-app/pages/Home.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { usePlexConfig } from "../../shared/contexts/PlexConfigContext";
3 | import { MediaCarouselWidget } from "../../shared/components/media/MediaCarouselWidget";
4 | import { APIRoutes } from "../../shared/enums/APIRoutes";
5 | import { MediaTypes } from "../../shared/enums/MediaTypes";
6 |
7 | export default function Home() {
8 | const { configs } = usePlexConfig();
9 | return (
10 |
11 | {!configs.isLoading && configs.data && configs.data.length > 0 && (
12 | <>
13 |
18 |
19 |
24 |
25 | >
26 | )}
27 |
31 |
32 |
36 |
37 |
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/client/src/logged-in-app/pages/Requests.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from "react";
2 | import { Route, Switch } from "react-router";
3 | import { routes } from "../../router/routes";
4 | import { Tab, TabsContextProvider } from "../../shared/contexts/TabsContext";
5 | import { RequestsContextProvider } from "../../shared/contexts/RequestsContext";
6 | import { useRoleGuard } from "../../shared/hooks/useRoleGuard";
7 | import { Roles } from "../../shared/enums/Roles";
8 | import { checkRole } from "../../utils/roles";
9 | import { useSession } from "../../shared/contexts/SessionContext";
10 |
11 | const Requests = () => {
12 | const {
13 | session: { user },
14 | } = useSession();
15 |
16 | useRoleGuard([Roles.REQUEST, Roles.MANAGE_REQUEST], true);
17 |
18 | const [tabs, setTabs] = useState([]);
19 | const [hasRequestRole, setHasRequestRole] = useState(false);
20 | const [hasManageRequestRole, setHasManageRequestRole] = useState(false);
21 |
22 | useEffect(() => {
23 | let tabsTmp: Tab[] = [];
24 |
25 | if (user && checkRole(user.roles, [Roles.MANAGE_REQUEST])) {
26 | tabsTmp = [{ label: "Received", uri: "incoming" }];
27 | setHasManageRequestRole(true);
28 | }
29 |
30 | if (user && checkRole(user.roles, [Roles.REQUEST])) {
31 | tabsTmp = [...tabsTmp, { label: "Sent", uri: "outgoing" }];
32 | setHasRequestRole(true);
33 | }
34 |
35 | setTabs(tabsTmp);
36 | // eslint-disable-next-line react-hooks/exhaustive-deps
37 | }, [user]);
38 |
39 | return (
40 |
41 |
42 |
43 | {hasManageRequestRole && (
44 |
53 | )}
54 | {hasRequestRole && (
55 |
59 | )}
60 |
61 |
62 |
63 | );
64 | };
65 |
66 | export { Requests };
67 |
--------------------------------------------------------------------------------
/client/src/logged-in-app/pages/settings/general/GeneralSettings.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useRoleGuard } from "../../../../shared/hooks/useRoleGuard";
3 | import { Roles } from "../../../../shared/enums/Roles";
4 | import { H2, H3 } from "../../../../shared/components/Titles";
5 | import {
6 | PrimaryDivider,
7 | PrimaryLightDivider,
8 | } from "../../../../shared/components/Divider";
9 | import { useConfig } from "../../../../shared/hooks/useConfig";
10 | import { RolesTree } from "../../../../shared/components/RolesTree";
11 | import { useAlert } from "../../../../shared/contexts/AlertContext";
12 | import { ManageLogLevels } from "../../../../shared/components/ManageLogLevels";
13 | import { LogLevels } from "../../../../shared/enums/LogLevels";
14 |
15 | export const GeneralSettings = () => {
16 | useRoleGuard([Roles.ADMIN]);
17 |
18 | const { config, updateConfig } = useConfig();
19 | const { pushSuccess } = useAlert();
20 |
21 | const onDefaultRolesSave = (roles: number) => {
22 | updateConfig({ defaultRoles: roles }).then((res) => {
23 | if (res.status === 200) {
24 | pushSuccess("Default roles updated");
25 | }
26 | });
27 | };
28 |
29 | const onLogLevelSave = (logLevel: LogLevels) => {
30 | updateConfig({ logLevel: logLevel }).then((res) => {
31 | if (res.status === 200) {
32 | pushSuccess("Log level updated");
33 | }
34 | });
35 | };
36 |
37 | return (
38 | <>
39 | Users
40 |
41 | Default user roles
42 | {config.data && config.data.defaultRoles && (
43 |
47 | )}
48 |
49 | General
50 |
51 | {config.data && config.data.logLevel && (
52 |
56 | )}{" "}
57 | >
58 | );
59 | };
60 |
--------------------------------------------------------------------------------
/client/src/logged-in-app/pages/settings/media-providers/MediaProvidersSettings.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { H1 } from "../../../../shared/components/Titles";
3 | import { Row } from "../../../../shared/components/layout/Row";
4 | import { PickMediaProviderTypeModal } from "./PickMediaProviderTypeModal";
5 | import { RadarrSettingsBoxPreview } from "./radarr/RadarrSettingsBoxPreview";
6 | import { SonarrSettingsBoxPreview } from "./sonarr/SonarrSettingsBoxPreview";
7 | import {
8 | AddItemBox,
9 | SpinnerItemBox,
10 | } from "../../../../shared/components/ItemBox";
11 | import { useSonarrConfigsContext } from "../../../../shared/contexts/SonarrConfigContext";
12 | import { useRadarrConfigsContext } from "../../../../shared/contexts/RadarrConfigsContext";
13 |
14 | export const MediaProvidersSettings = () => {
15 | const [
16 | isPickMediaProvidersTypeModalOpen,
17 | setIsPickMediaProvidersTypeModalOpen,
18 | ] = useState(false);
19 |
20 | const { radarrConfigs } = useRadarrConfigsContext();
21 | const { sonarrConfigs } = useSonarrConfigsContext();
22 |
23 | return (
24 |
25 |
Configure your media providers
26 |
27 |
28 | setIsPickMediaProvidersTypeModalOpen(true)}
30 | />
31 | {radarrConfigs.isLoading && }
32 | {!radarrConfigs.isLoading &&
33 | radarrConfigs.data &&
34 | radarrConfigs.data.map((config, index) => {
35 | return (
36 |
37 | );
38 | })}
39 | {sonarrConfigs.isLoading && }
40 | {!sonarrConfigs.isLoading &&
41 | sonarrConfigs.data &&
42 | sonarrConfigs.data.map((config, index) => {
43 | return (
44 |
45 | );
46 | })}
47 |
48 | {isPickMediaProvidersTypeModalOpen && (
49 |
setIsPickMediaProvidersTypeModalOpen(false)}
51 | />
52 | )}
53 |
54 | );
55 | };
56 |
--------------------------------------------------------------------------------
/client/src/logged-in-app/pages/settings/media-providers/radarr/RadarrSettingsBoxPreview.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { IRadarrConfig } from "../../../../../shared/models/IRadarrConfig";
3 | import { EditRadarrSettingsModal } from "./EditRadarrSettingsModal";
4 | import { ItemBox } from "../../../../../shared/components/ItemBox";
5 | import { isEmpty } from "../../../../../utils/strings";
6 |
7 | type RadarrSettingsBoxPreviewProps = {
8 | radarrConfig: IRadarrConfig;
9 | };
10 |
11 | export const RadarrSettingsBoxPreview = (
12 | props: RadarrSettingsBoxPreviewProps
13 | ) => {
14 | const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
15 |
16 | return (
17 | <>
18 | setIsSettingsModalOpen(true)}>
19 | {isEmpty(props.radarrConfig.name) ? "Radarr" : props.radarrConfig.name}
20 |
21 | {isSettingsModalOpen && (
22 | setIsSettingsModalOpen(false)}
24 | radarrConfig={props.radarrConfig}
25 | />
26 | )}
27 | >
28 | );
29 | };
30 |
--------------------------------------------------------------------------------
/client/src/logged-in-app/pages/settings/media-providers/sonarr/SonarrSettingsBoxPreview.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { ISonarrConfig } from "../../../../../shared/models/ISonarrConfig";
3 | import { EditSonarrSettingsModal } from "./EditSonarrSettingsModal";
4 | import { ItemBox } from "../../../../../shared/components/ItemBox";
5 | import { isEmpty } from "../../../../../utils/strings";
6 |
7 | type SonarrSettingsBoxPreviewProps = {
8 | sonarrConfig: ISonarrConfig;
9 | };
10 |
11 | export const SonarrSettingsBoxPreview = (
12 | props: SonarrSettingsBoxPreviewProps
13 | ) => {
14 | const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
15 | return (
16 | <>
17 | setIsSettingsModalOpen(true)}>
18 | {isEmpty(props.sonarrConfig.name) ? "Sonarr" : props.sonarrConfig.name}
19 |
20 | {isSettingsModalOpen && (
21 | setIsSettingsModalOpen(false)}
23 | sonarrConfig={props.sonarrConfig}
24 | />
25 | )}
26 | >
27 | );
28 | };
29 |
--------------------------------------------------------------------------------
/client/src/logged-in-app/pages/settings/media-servers/MediaServersSettings.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { H1 } from "../../../../shared/components/Titles";
3 | import { Row } from "../../../../shared/components/layout/Row";
4 | import { usePlexConfig } from "../../../../shared/contexts/PlexConfigContext";
5 | import { PlexSettingsBoxPreview } from "./plex/PlexSettingsBoxPreview";
6 | import { AddItemBox } from "../../../../shared/components/ItemBox";
7 | import { PickMediaServerTypeModal } from "./PickMediaServerTypeModal";
8 | import { FullWidthTag } from "../../../../shared/components/FullWidthTag";
9 | import { PrimaryDivider } from "../../../../shared/components/Divider";
10 | import { MediaServersInfo } from "./MediaServersInfo";
11 |
12 | export const MediaServersSettings = () => {
13 | const [
14 | isPickMediaServersTypeModalOpen,
15 | setIsPickMediaServersTypeModalOpen,
16 | ] = useState(false);
17 | const { configs: plexSettingsList } = usePlexConfig();
18 |
19 | return (
20 |
21 |
More media servers will be supported soon
22 |
23 |
Configure your media servers
24 |
25 |
26 | setIsPickMediaServersTypeModalOpen(true)} />
27 | {plexSettingsList.data &&
28 | plexSettingsList.data.map((plexSettings, index) => {
29 | return (
30 |
31 | );
32 | })}
33 |
34 | {isPickMediaServersTypeModalOpen && (
35 |
setIsPickMediaServersTypeModalOpen(false)}
37 | />
38 | )}
39 |
40 | {plexSettingsList.data && (
41 |
42 | )}
43 |
44 | );
45 | };
46 |
--------------------------------------------------------------------------------
/client/src/logged-in-app/pages/settings/media-servers/plex/PlexSettingsBoxPreview.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { IMediaServerConfig } from "../../../../../shared/models/IMediaServerConfig";
3 | import { EditPlexSettingsModal } from "./EditPlexSettingsModal";
4 | import { ItemBox } from "../../../../../shared/components/ItemBox";
5 | import { isEmpty } from "../../../../../utils/strings";
6 |
7 | type PlexSettingsBoxPreview = {
8 | plexSettings: IMediaServerConfig;
9 | };
10 |
11 | export const PlexSettingsBoxPreview = (props: PlexSettingsBoxPreview) => {
12 | const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
13 | return (
14 | <>
15 | setIsSettingsModalOpen(true)}>
16 | {isEmpty(props.plexSettings.name)
17 | ? props.plexSettings.serverName
18 | : props.plexSettings.name}
19 |
20 | {isSettingsModalOpen && (
21 | setIsSettingsModalOpen(false)}
23 | plexSettings={props.plexSettings}
24 | />
25 | )}
26 | >
27 | );
28 | };
29 |
--------------------------------------------------------------------------------
/client/src/logged-in-app/pages/settings/notifications/NotificationsServicesSettings.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { FullWidthTag } from "../../../../shared/components/FullWidthTag";
3 | import { H1 } from "../../../../shared/components/Titles";
4 | import { Row } from "../../../../shared/components/layout/Row";
5 | import {
6 | AddItemBox,
7 | SpinnerItemBox,
8 | } from "../../../../shared/components/ItemBox";
9 | import { EmailSettingsBoxPreview } from "./email/EmailSettingsBoxPreview";
10 | import { PickNotificationsServiceTypeModal } from "./PickNotificationsServiceTypeModal";
11 | import { useNotificationsServicesContext } from "../../../../shared/contexts/NotificationsServicesContext";
12 | import { useRoleGuard } from "../../../../shared/hooks/useRoleGuard";
13 | import { Roles } from "../../../../shared/enums/Roles";
14 |
15 | export const NotificationsServicesSettings = () => {
16 | const [
17 | isPickNotificationsServicesTypeModalOpen,
18 | setIsPickNotificationsServicesTypeModalOpen,
19 | ] = useState(false);
20 |
21 | const { emailConfig } = useNotificationsServicesContext();
22 | useRoleGuard([Roles.ADMIN]);
23 |
24 | return (
25 |
26 |
More notifications systems are coming soon
27 |
28 |
Configure your notifications service
29 |
30 |
31 | setIsPickNotificationsServicesTypeModalOpen(true)}
33 | />
34 | {emailConfig.isLoading && }
35 | {!emailConfig.isLoading && emailConfig.data && (
36 |
37 | )}
38 |
39 | {isPickNotificationsServicesTypeModalOpen && (
40 |
setIsPickNotificationsServicesTypeModalOpen(false)}
42 | />
43 | )}
44 |
45 | );
46 | };
47 |
--------------------------------------------------------------------------------
/client/src/logged-in-app/pages/settings/notifications/email/EmailSettingsBoxPreview.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { ItemBox } from "../../../../../shared/components/ItemBox";
3 | import { EditEmailSettingsModal } from "./EditEmailSettingsModal";
4 | import { INotificationsConfig } from "../../../../../shared/models/INotificationsConfig";
5 |
6 | type EmailSettingsBoxPreviewProps = {
7 | emailConfig: INotificationsConfig;
8 | };
9 |
10 | export const EmailSettingsBoxPreview = (
11 | props: EmailSettingsBoxPreviewProps
12 | ) => {
13 | const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
14 |
15 | return (
16 | <>
17 | setIsSettingsModalOpen(true)}>Email
18 | {isSettingsModalOpen && (
19 | setIsSettingsModalOpen(false)}
21 | emailConfig={props.emailConfig}
22 | />
23 | )}
24 | >
25 | );
26 | };
27 |
--------------------------------------------------------------------------------
/client/src/logged-in-app/pages/settings/users/UsersSettings.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { H1 } from "../../../../shared/components/Titles";
3 | import {
4 | TabSide,
5 | TabsStyle,
6 | TabStyle,
7 | } from "../../../../shared/components/layout/Tabs";
8 | import { UsersConfirmed } from "./UsersConfirmed";
9 | import { UsersPending } from "./UsersPending";
10 | import { useRoleGuard } from "../../../../shared/hooks/useRoleGuard";
11 | import { Roles } from "../../../../shared/enums/Roles";
12 |
13 | export const UsersSettings = () => {
14 | const tabs = [
15 | { label: "Confirmed", uri: "confirmed" },
16 | { label: "Pending", uri: "pending" },
17 | ];
18 |
19 | const [activeTab, setActiveTab] = useState<"confirmed" | "pending">(
20 | "confirmed"
21 | );
22 |
23 | useRoleGuard([Roles.MANAGE_USERS]);
24 |
25 | return (
26 | <>
27 | Manage users
28 |
29 |
30 |
31 | {tabs.map((tab, index) => {
32 | return (
33 |
36 | setActiveTab(
37 | activeTab === "confirmed" ? "pending" : "confirmed"
38 | )
39 | }
40 | key={index}
41 | >
42 | {tab.label}
43 |
44 | );
45 | })}
46 |
47 |
48 | {activeTab === "confirmed" && }
49 | {activeTab === "pending" && }
50 | >
51 | );
52 | };
53 |
--------------------------------------------------------------------------------
/client/src/logged-in-app/pages/user-profile/account/DeleteAccountModal.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useUserService } from "../../../../shared/toRefactor/useUserService";
3 | import { Modal } from "../../../../shared/components/layout/Modal";
4 | import { H2 } from "../../../../shared/components/Titles";
5 | import { Buttons } from "../../../../shared/components/layout/Buttons";
6 | import { Button, DangerButton } from "../../../../shared/components/Button";
7 |
8 | type DeleteAccountModalProps = {
9 | closeModal: () => void;
10 | };
11 |
12 | const DeleteAccountModal = (props: DeleteAccountModalProps) => {
13 | const { deleteAccount } = useUserService();
14 |
15 | const onSubmit = () => {
16 | deleteAccount().then((res) => {
17 | if (res.status === 200) props.closeModal();
18 | });
19 | };
20 |
21 | return (
22 |
23 |
24 | Are you sure you want to delete your account ?
25 |
26 |
36 |
37 | );
38 | };
39 |
40 | export { DeleteAccountModal };
41 |
--------------------------------------------------------------------------------
/client/src/logged-in-app/sidebarMenu/SidebarMenuCommon.tsx:
--------------------------------------------------------------------------------
1 | import styled, { css } from "styled-components";
2 | import { STATIC_STYLES } from "../../shared/enums/StaticStyles";
3 |
4 | export const SidebarMenuContainer = styled.aside<{ isOpen: boolean }>`
5 | background: ${(props) => props.theme.primaryLight};
6 | overflow: hidden;
7 | position: fixed;
8 | z-index: 1;
9 | left: 0;
10 | top: 0;
11 | height: 100%;
12 | transition: width ${STATIC_STYLES.SIDEBAR_TRANSITION_DURATION};
13 | `;
14 |
15 | export const SidebarMenuElement = styled.div<{ isActive?: boolean }>`
16 | display: flex;
17 | align-items: center;
18 | cursor: pointer;
19 | width: 100%;
20 | height: ${STATIC_STYLES.NAVBAR_HEIGHT}px;
21 | font-size: 20px;
22 | user-select: none;
23 | color: ${(props) => props.theme.white};
24 |
25 | ${(props) =>
26 | props.isActive &&
27 | css`
28 | color: ${(props) => props.theme.secondary};
29 | `};
30 |
31 | &:hover {
32 | ${(props) =>
33 | !props.isActive &&
34 | css`
35 | background: ${(props) => props.theme.secondary};
36 | `}
37 | }
38 | `;
39 |
40 | export const SidebarMenuElementIcon = styled.span`
41 | margin: 0;
42 | display: flex;
43 | align-items: center;
44 | justify-content: center;
45 | min-width: ${STATIC_STYLES.SIDEBAR_CLOSED_WIDTH}px;
46 | max-width: ${STATIC_STYLES.SIDEBAR_CLOSED_WIDTH}px;
47 | height: ${STATIC_STYLES.SIDEBAR_CLOSED_WIDTH}px;
48 | font-size: 16px;
49 | `;
50 |
51 | export type SidebarMenuProps = {
52 | isOpen: boolean;
53 | toggle: () => void;
54 | };
55 |
--------------------------------------------------------------------------------
/client/src/logged-out-app/elements/ResetPassword.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 | import { useParams } from "react-router";
3 | import { ResetPasswordForm } from "./ResetPasswordForm";
4 | import { APIRoutes } from "../../shared/enums/APIRoutes";
5 | import { useAPI } from "../../shared/hooks/useAPI";
6 | import { useAlert } from "../../shared/contexts/AlertContext";
7 | import { PrimaryHero } from "../../shared/components/layout/Hero";
8 | import { H1 } from "../../shared/components/Titles";
9 |
10 | type ResetPasswordRouteParams = {
11 | token: string;
12 | };
13 |
14 | const ResetPassword = () => {
15 | const { token } = useParams();
16 | const { get } = useAPI();
17 | const { pushDanger } = useAlert();
18 | useEffect(() => {
19 | get(APIRoutes.GET_RESET_PASSWORD_TOKEN_VALIDITY(token)).then((res) => {
20 | if (res.status !== 200) {
21 | pushDanger("Expired");
22 | }
23 | });
24 | // eslint-disable-next-line react-hooks/exhaustive-deps
25 | }, []);
26 |
27 | return (
28 |
29 |
30 | Reset your Cheddarr password account
31 |
32 |
33 |
34 |
35 | );
36 | };
37 |
38 | export { ResetPassword };
39 |
--------------------------------------------------------------------------------
/client/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/client/src/router/LoggedInRoute.tsx:
--------------------------------------------------------------------------------
1 | import { Redirect, Route, RouteProps } from "react-router-dom";
2 | import React from "react";
3 | import { useSession } from "../shared/contexts/SessionContext";
4 | import { PageLoader } from "../shared/components/PageLoader";
5 | import { routes } from "./routes";
6 |
7 | type LoggedInRouteProps = {
8 | component: React.ComponentType;
9 | rest: any;
10 | };
11 |
12 | export const LoggedInRoute = ({
13 | component: Component,
14 | ...rest
15 | }: LoggedInRouteProps) => {
16 | const {
17 | session: { isAuthenticated, isLoading },
18 | } = useSession();
19 |
20 | return (
21 | {
24 | if (isAuthenticated) {
25 | return ;
26 | } else if (isLoading) {
27 | return ;
28 | } else {
29 | return ;
30 | }
31 | }}
32 | />
33 | );
34 | };
35 |
--------------------------------------------------------------------------------
/client/src/router/LoggedOutRoute.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useSession } from "../shared/contexts/SessionContext";
3 | import { Route, RouteProps } from "react-router-dom";
4 | import { Redirect } from "react-router";
5 | import { PageLoader } from "../shared/components/PageLoader";
6 | import { routes } from "./routes";
7 |
8 | type LoggedOutRouteProps = {
9 | component: React.ComponentType;
10 | rest: any;
11 | };
12 |
13 | export const LoggedOutRoute = ({
14 | component: Component,
15 | ...rest
16 | }: LoggedOutRouteProps) => {
17 | const {
18 | session: { isAuthenticated, isLoading },
19 | } = useSession();
20 |
21 | return (
22 | {
25 | if (!isAuthenticated) {
26 | return ;
27 | } else if (isLoading) {
28 | return ;
29 | } else {
30 | return ;
31 | }
32 | }}
33 | />
34 | );
35 | };
36 |
--------------------------------------------------------------------------------
/client/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom/extend-expect';
6 | import 'mutationobserver-shim';
--------------------------------------------------------------------------------
/client/src/shared/Home.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useSession } from "./contexts/SessionContext";
3 | import { default as LoggedInHome } from "../logged-in-app/pages/Home";
4 | import { Redirect } from "react-router-dom";
5 | import { routes } from "../router/routes";
6 |
7 | export const Home = () => {
8 | const {
9 | session: { isAuthenticated },
10 | } = useSession();
11 |
12 | if (isAuthenticated) {
13 | return ;
14 | } else {
15 | return ;
16 | }
17 | };
18 |
--------------------------------------------------------------------------------
/client/src/shared/components/Alert.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useContext } from "react";
3 | import { IAlert, AlertContext } from "../contexts/AlertContext";
4 | import { faTimes } from "@fortawesome/free-solid-svg-icons";
5 | import styled from "styled-components";
6 | import { STATIC_STYLES } from "../enums/StaticStyles";
7 | import { Icon } from "./Icon";
8 |
9 | type NotificationStyleProps = {
10 | backgroundColor: string;
11 | };
12 |
13 | const Container = styled.div`
14 | position: fixed;
15 | bottom: 5px;
16 | left: 50%;
17 | transform: translateX(-50%);
18 | z-index: 150;
19 | color: ${(props) => props.color};
20 | background-color: ${(props) => props.backgroundColor};
21 | padding: 10px 20px;
22 | border-radius: 6px;
23 | display: flex;
24 | justify-content: space-between;
25 | align-items: center;
26 |
27 | p {
28 | margin-right: 20px;
29 | }
30 |
31 | width: 30%;
32 | @media screen and (max-width: ${STATIC_STYLES.TABLET_MAX_WIDTH}px) {
33 | width: 50%;
34 | }
35 | @media screen and (max-width: ${STATIC_STYLES.MOBILE_MAX_WIDTH}px) {
36 | width: 95%;
37 | }
38 | `;
39 |
40 | type AlertProps = {
41 | notification: IAlert | null;
42 | };
43 |
44 | const Alert = ({ notification }: AlertProps) => {
45 | const { removeNotification } = useContext(AlertContext);
46 |
47 | if (notification === null) return ;
48 |
49 | return (
50 |
54 | {notification.message}
55 |
56 |
57 |
58 |
59 | );
60 | };
61 |
62 | export { Alert };
63 |
--------------------------------------------------------------------------------
/client/src/shared/components/ClosableTitle.tsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | export const ClosableTitle = styled.div`
4 | width: 100%;
5 | display: flex;
6 | justify-content: space-between;
7 | align-items: center;
8 | `;
9 |
--------------------------------------------------------------------------------
/client/src/shared/components/DeleteDataModal.tsx:
--------------------------------------------------------------------------------
1 | import { Modal } from "./layout/Modal";
2 | import { H2 } from "./Titles";
3 | import React from "react";
4 | import { Buttons } from "./layout/Buttons";
5 | import { Button, DangerButton } from "./Button";
6 |
7 | type DeleteDataModalProps = {
8 | title: string;
9 | description?: string;
10 | action: () => void;
11 | actionLabel: string;
12 | closeModal: () => void;
13 | };
14 |
15 | export const DeleteDataModal = (props: DeleteDataModalProps) => {
16 | return (
17 |
18 |
21 | {props.description && }
22 |
32 |
33 | );
34 | };
35 |
--------------------------------------------------------------------------------
/client/src/shared/components/Divider.tsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | export const Divider = styled.div`
4 | width: 100%;
5 | height: 0;
6 | margin-top: 20px;
7 | margin-bottom: 20px;
8 | border-top: 1px solid ${(props) => props.theme.color};
9 | `;
10 |
11 | export const PrimaryDivider = styled(Divider)`
12 | border-top: 1px solid ${(props) => props.theme.primary};
13 | `;
14 |
15 | export const PrimaryLightDivider = styled(Divider)`
16 | border-top: 1px solid ${(props) => props.theme.primaryLight};
17 | `;
18 |
19 | export const SecondaryDivider = styled(Divider)`
20 | border-top: 1px solid ${(props) => props.theme.secondary};
21 | `;
22 |
23 | export const VerticalDivider = styled.div`
24 | width: 0;
25 | height: 100%;
26 | margin-left: 20px;
27 | margin-right: 20px;
28 | border-left: 1px solid ${(props) => props.theme.color};
29 | `;
30 |
31 | export const PrimaryVerticalDivider = styled(VerticalDivider)`
32 | border-left: 1px solid ${(props) => props.theme.primary};
33 | `;
34 |
35 | export const SecondaryVerticalDivider = styled(VerticalDivider)`
36 | border-left: 1px solid ${(props) => props.theme.secondary};
37 | `;
38 |
--------------------------------------------------------------------------------
/client/src/shared/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 | import { STATIC_STYLES } from "../enums/StaticStyles";
4 |
5 | const Container = styled.footer`
6 | height: ${STATIC_STYLES.FOOTER_HEIGHT}px;
7 | background: ${(props) => props.theme.primary};
8 | `;
9 |
10 | export const Footer = () => {
11 | return ;
12 | };
13 |
--------------------------------------------------------------------------------
/client/src/shared/components/FullWidthTag.tsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | export const FullWidthTag = styled.div`
4 | width: 100%;
5 | padding: 20px;
6 | background: ${(props) => props.theme.primary};
7 | border-radius: 6px;
8 | `;
9 |
--------------------------------------------------------------------------------
/client/src/shared/components/GithubButton.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
4 | import { faGithub } from "@fortawesome/free-brands-svg-icons";
5 |
6 | const GitHubButtonStyle = styled.a`
7 | margin: 0;
8 | width: 50px;
9 | height: 50px;
10 | position: absolute;
11 | right: 80px;
12 | display: flex;
13 | justify-content: center;
14 | align-items: center;
15 | color: ${(props) => props.theme.primaryLighter};
16 | opacity: 0.8;
17 | transition: opacity 0.5s ease;
18 | &:hover {
19 | opacity: 1;
20 | }
21 | `;
22 |
23 | const GitHubButton = () => {
24 | return (
25 |
30 |
31 |
32 | );
33 | };
34 |
35 | export { GitHubButton };
36 |
--------------------------------------------------------------------------------
/client/src/shared/components/Help.tsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 | import { STATIC_STYLES } from "../enums/StaticStyles";
3 |
4 | export const Help = styled.p`
5 | color: ${(props) => props.theme.grey};
6 | font-size: 14px;
7 | @media screen and (max-width: ${STATIC_STYLES.MOBILE_MAX_WIDTH}px) {
8 | font-size: 12px;
9 | }
10 | `;
11 |
12 | export const HelpDanger = styled(Help)`
13 | color: ${(props) => props.theme.danger};
14 | `;
15 |
16 | export const HelpLink = styled(Help)`
17 | cursor: pointer;
18 | &:hover {
19 | color: ${(props) => props.theme.primaryLighter};
20 | }
21 | `;
22 |
--------------------------------------------------------------------------------
/client/src/shared/components/Icon.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3 | import { FontAwesomeIconProps } from "@fortawesome/react-fontawesome";
4 |
5 | export const Icon = (props: FontAwesomeIconProps) => {
6 | return ;
7 | };
8 |
--------------------------------------------------------------------------------
/client/src/shared/components/Image.tsx:
--------------------------------------------------------------------------------
1 | import styled, { css } from "styled-components";
2 |
3 | type ImageProps = {
4 | width?: string;
5 | height?: string;
6 | cursor?: string;
7 | borderRadius?: string;
8 | loaded: boolean;
9 | };
10 |
11 | export const Image = styled.img`
12 | width: ${(props) => (props.width ? props.width : "100%")};
13 | height: ${(props) => (props.height ? props.height : "100%")};
14 | cursor: ${(props) => (props.cursor ? props.cursor : "default")};
15 | border-radius: ${(props) => (props.borderRadius ? props.borderRadius : "0")};
16 |
17 | -webkit-transition-property: opacity;
18 | transition-property: opacity;
19 | -webkit-transition-duration: 0.3s;
20 | transition-duration: 0.3s;
21 | -webkit-transition-timing-function: cubic-bezier(0.3, 0, 0.4, 1);
22 | transition-timing-function: cubic-bezier(0.3, 0, 0.4, 1);
23 | opacity: 0;
24 |
25 | ${(props) =>
26 | props.loaded &&
27 | css`
28 | opacity: 1;
29 | `}
30 | `;
31 |
--------------------------------------------------------------------------------
/client/src/shared/components/ItemBox.tsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 | import { STATIC_STYLES } from "../enums/StaticStyles";
3 | import { Icon } from "./Icon";
4 | import { faPlus } from "@fortawesome/free-solid-svg-icons";
5 | import React from "react";
6 | import { Spinner } from "./Spinner";
7 |
8 | export const ItemBox = styled.div`
9 | width: 100%;
10 | border: 5px solid ${(props) => props.theme.primaryLighter};
11 | border-radius: 8px;
12 | display: flex;
13 | justify-content: center;
14 | align-items: center;
15 | color: ${(props) => props.theme.white};
16 | cursor: pointer;
17 | margin: 10px;
18 | padding: 30px 0;
19 |
20 | @media screen and (min-width: ${STATIC_STYLES.TABLET_MAX_WIDTH}px) {
21 | width: 260px;
22 | max-width: 260px;
23 | height: 120px;
24 | max-height: 120px;
25 | }
26 | `;
27 |
28 | const AddItemBoxStyle = styled(ItemBox)`
29 | font-size: 4em;
30 | color: ${(props) => props.theme.primary};
31 | border-color: ${(props) => props.theme.primary};
32 | &:hover {
33 | color: ${(props) => props.theme.primaryLighter};
34 | border-color: ${(props) => props.theme.primaryLighter};
35 | }
36 | `;
37 |
38 | type ClickableItemBoxProps = {
39 | onClick: () => void;
40 | };
41 |
42 | export const AddItemBox = (props: ClickableItemBoxProps) => {
43 | return (
44 | props.onClick()}>
45 |
46 |
47 | );
48 | };
49 |
50 | export const SpinnerItemBox = () => {
51 | return (
52 |
53 |
54 |
55 | );
56 | };
57 |
--------------------------------------------------------------------------------
/client/src/shared/components/LinkPlexAccount.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { usePlexAuth } from "../contexts/PlexAuthContext";
3 | import { SecondaryButton } from "./Button";
4 |
5 | type LinkPlexAccountProps = {
6 | redirectURI?: string;
7 | };
8 |
9 | export const LinkPlexAccount = (props: LinkPlexAccountProps) => {
10 | const { signInWithPlex } = usePlexAuth();
11 | return (
12 |
15 | props.redirectURI ? signInWithPlex(props.redirectURI) : signInWithPlex()
16 | }
17 | >
18 | Link Plex account
19 |
20 | );
21 | };
22 |
--------------------------------------------------------------------------------
/client/src/shared/components/ManageLogLevels.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { H3 } from "./Titles";
3 | import { PrimaryButton } from "./Button";
4 | import { LogLevels } from "../enums/LogLevels";
5 |
6 | type ManageLogLevelsProps = {
7 | defaultValue: LogLevels;
8 | onSave: (logLevel: LogLevels) => void;
9 | };
10 |
11 | export const ManageLogLevels = (props: ManageLogLevelsProps) => {
12 | const [logLevel, setLogLevel] = useState(props.defaultValue);
13 |
14 | const onLogLevelChange = (ll: LogLevels) => {
15 | setLogLevel(ll);
16 | };
17 |
18 | const onSave = () => {
19 | props.onSave(logLevel);
20 | };
21 |
22 | return (
23 |
24 |
Log level
25 |
26 |
33 |
34 |
35 |
onSave()}>
36 | Save
37 |
38 |
39 | );
40 | };
41 |
--------------------------------------------------------------------------------
/client/src/shared/components/PageLoader.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled, { keyframes } from "styled-components";
3 |
4 | const Pulse = () => {
5 | return keyframes`
6 | from {
7 | stroke-width: 3px;
8 | stroke-opacity: 1;
9 | transform: scale(0.3);
10 | }
11 | to {
12 | stroke-width: 0;
13 | stroke-opacity: 0;
14 | transform: scale(2);
15 | }
16 | `;
17 | };
18 |
19 | const Container = styled.div`
20 | z-index: 10;
21 | background: ${(props) => props.theme.black};
22 | display: flex;
23 | justify-content: center;
24 | align-items: center;
25 | flex-direction: column;
26 | position: fixed;
27 | top: 0;
28 | left: 0;
29 | bottom: 0;
30 | right: 0;
31 |
32 | circle {
33 | stroke: white;
34 | stroke-width: 2px;
35 | stroke-opacity: 1;
36 |
37 | .pulse {
38 | fill: white;
39 | fill-opacity: 0;
40 | transform-origin: 50% 50%;
41 | animation-duration: 2s;
42 | animation-name: ${Pulse};
43 | animation-iteration-count: infinite;
44 | }
45 | }
46 | `;
47 |
48 | const PageLoader = () => {
49 | return (
50 |
51 |
55 | Wait ...
56 |
57 | );
58 | };
59 |
60 | export { PageLoader };
61 |
--------------------------------------------------------------------------------
/client/src/shared/components/PaginationArrows.tsx:
--------------------------------------------------------------------------------
1 | import { PrimaryButton } from "./Button";
2 | import { Icon } from "./Icon";
3 | import { faArrowLeft, faArrowRight } from "@fortawesome/free-solid-svg-icons";
4 | import React from "react";
5 | import styled from "styled-components";
6 |
7 | const Container = styled.footer`
8 | display: flex;
9 | justify-content: center;
10 | align-items: center;
11 | padding: 20px;
12 | background: ${(props) => props.theme.primaryLight};
13 | border-bottom-left-radius: 12px;
14 | border-bottom-right-radius: 12px;
15 |
16 | p {
17 | padding: 20px;
18 | }
19 | `;
20 |
21 | type PaginationArrowsProps = {
22 | currentPage?: number;
23 | totalPages?: number;
24 | onLoadNext: () => void;
25 | onLoadPrev: () => void;
26 | onLoadPage?: (n: number) => void;
27 | };
28 |
29 | export const PaginationArrows = (props: PaginationArrowsProps) => {
30 | if (props.totalPages === undefined || props.totalPages <= 0) {
31 | return ;
32 | }
33 |
34 | return (
35 |
36 | props.onLoadPrev()}>
37 |
38 |
39 |
40 | {props.currentPage} ... {props.totalPages}
41 |
42 | props.onLoadNext()}>
43 |
44 |
45 | {props.onLoadPage !== undefined && (
46 |
53 | props.onLoadPage && props.onLoadPage(parseInt(e.target.value, 10))
54 | }
55 | />
56 | )}
57 |
58 | );
59 | };
60 |
--------------------------------------------------------------------------------
/client/src/shared/components/PlexButton.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 | const logo = require("../../assets/plex.png");
4 |
5 | const PlexButtonStyle = styled.button`
6 | background-color: ${(props) => props.theme.plex};
7 | color: LightGrey;
8 | border: none;
9 | display: flex;
10 | justify-content: space-around;
11 | align-items: center;
12 | cursor: pointer;
13 | border-radius: 3px;
14 | min-width: 120px;
15 | font-size: 1em;
16 | padding-top: 0.5em;
17 | padding-bottom: 0.5em;
18 | padding-left: 1em;
19 | padding-right: 1em;
20 | max-height: 45px;
21 |
22 | &:hover {
23 | color: white;
24 | }
25 |
26 | .plex-logo {
27 | width: 25px;
28 | height: auto;
29 | margin-right: 1em;
30 | }
31 | `;
32 |
33 | type PlexButtonProps = {
34 | onClick: () => void;
35 | text: string;
36 | width?: string;
37 | };
38 |
39 | const PlexButton = ({ onClick, text }: PlexButtonProps) => {
40 | return (
41 | onClick()}>
42 |
43 | {text}
44 |
45 | );
46 | };
47 |
48 | export { PlexButton };
49 |
--------------------------------------------------------------------------------
/client/src/shared/components/SignInButton.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3 | import { faSignInAlt } from "@fortawesome/free-solid-svg-icons";
4 | import { useHistory } from "react-router";
5 | import { PrimaryRoundedButton } from "./Button";
6 | import { routes } from "../../router/routes";
7 |
8 | export const SignInButton = () => {
9 | const history = useHistory();
10 | return (
11 | history.push(routes.SIGN_IN.url())}>
12 |
13 |
14 |
15 | Sign in
16 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/client/src/shared/components/SignUpButton.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3 | import { faUserPlus } from "@fortawesome/free-solid-svg-icons";
4 | import { routes } from "../../router/routes";
5 | import { PrimaryRoundedButton } from "./Button";
6 | import { useHistory } from "react-router-dom";
7 |
8 | export const SignUpButton = () => {
9 | const history = useHistory();
10 | return (
11 | history.push(routes.SIGN_UP.url)}>
12 |
13 |
14 |
15 | Sign up
16 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/client/src/shared/components/Spinner.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 | import { ComponentSizes } from "../enums/ComponentSizes";
4 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
5 | import { faSpinner } from "@fortawesome/free-solid-svg-icons";
6 |
7 | const SpinnerStyle = styled.div`
8 | color: ${(props) => props.theme.white};
9 | `;
10 |
11 | type SpinnerProps = {
12 | size?: ComponentSizes;
13 | };
14 |
15 | export const Spinner = (props: SpinnerProps) => {
16 | const getSize = () => {
17 | switch (props.size) {
18 | case ComponentSizes.SMALL:
19 | return "xs";
20 | case ComponentSizes.MEDIUM:
21 | return "sm";
22 | case ComponentSizes.LARGE:
23 | return "lg";
24 | case ComponentSizes.XLARGE:
25 | return "2x";
26 | default:
27 | return "lg";
28 | }
29 | };
30 | return (
31 |
32 |
33 |
34 | );
35 | };
36 |
--------------------------------------------------------------------------------
/client/src/shared/components/SubmitConfig.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 | import { FadeIn, FadeOut } from "./animations/Animations";
4 | import { Animate } from "./animations/Animate";
5 |
6 | const SubmitContainer = styled.div`
7 | position: fixed;
8 | left: 0;
9 | bottom: 0;
10 | width: 100%;
11 | height: 70px;
12 | display: flex;
13 | align-items: center;
14 | padding: 20px;
15 | border-top: 1px solid ${(props) => props.theme.primaryLight};
16 | background-color: ${(props) => props.theme.bgColor};
17 | z-index: 10;
18 | `;
19 |
20 | const SubmitButton = styled.button`
21 | background: transparent;
22 | border-radius: 3px;
23 | border: 2px solid ${(props) => props.theme.primary};
24 | color: ${(props) => props.theme.primary};
25 | padding: 0.25em 1em;
26 | font-size: 1em;
27 | width: 150px;
28 | height: 40px;
29 |
30 | &:hover {
31 | cursor: pointer;
32 | color: white;
33 | background-color: ${(props) => props.theme.primary};
34 | }
35 | `;
36 |
37 | type SubmitConfigProps = {
38 | isFormDirty: boolean;
39 | };
40 |
41 | const SubmitConfig = ({ isFormDirty }: SubmitConfigProps) => {
42 | return (
43 |
51 |
52 | Save changes
53 |
54 |
55 | );
56 | };
57 |
58 | export { SubmitConfig };
59 |
--------------------------------------------------------------------------------
/client/src/shared/components/Tag.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 | import { MediaTypes } from "../enums/MediaTypes";
4 | import {
5 | IMedia,
6 | isEpisode,
7 | isMovie,
8 | isSeason,
9 | isSeries,
10 | } from "../models/IMedia";
11 |
12 | export const Tag = styled.div`
13 | display: inline-block;
14 | height: 25px;
15 | padding: 0 0.5em;
16 | border-radius: 6px;
17 | text-align: center;
18 | line-height: 25px;
19 | background: ${(props) => props.theme.grey};
20 | white-space: nowrap;
21 | margin: 10px;
22 | `;
23 |
24 | export const IconTag = styled(Tag)`
25 | display: flex;
26 | justify-content: center;
27 | align-items: center;
28 | text-align: center;
29 | border-radius: 50%;
30 | font-size: 8px;
31 | width: 20px;
32 | height: 20px;
33 | padding: 0;
34 | `;
35 |
36 | export const WarningTag = styled(Tag)`
37 | background: ${(props) => props.theme.warning};
38 | `;
39 |
40 | export const SuccessTag = styled(Tag)`
41 | background: ${(props) => props.theme.success};
42 | `;
43 |
44 | export const SuccessIconTag = styled(IconTag)`
45 | background: ${(props) => props.theme.success};
46 | `;
47 |
48 | export const DangerTag = styled(Tag)`
49 | background: ${(props) => props.theme.danger};
50 | `;
51 |
52 | const MovieTag = styled(Tag)`
53 | background: ${(props) => props.theme.movie};
54 | `;
55 |
56 | const SeriesTag = styled(Tag)`
57 | background: ${(props) => props.theme.series};
58 | `;
59 |
60 | const SeasonTag = styled(Tag)`
61 | background: ${(props) => props.theme.season};
62 | `;
63 |
64 | const EpisodeTag = styled(Tag)`
65 | background: ${(props) => props.theme.episode};
66 | `;
67 |
68 | type MediaTagProps = {
69 | media?: IMedia;
70 | type?: MediaTypes;
71 | };
72 |
73 | export const MediaTag = (props: MediaTagProps) => {
74 | if (
75 | (props.type && props.type === MediaTypes.MOVIES) ||
76 | isMovie(props.media)
77 | ) {
78 | return Movie;
79 | } else if (
80 | (props.type && props.type === MediaTypes.SERIES) ||
81 | isSeries(props.media)
82 | ) {
83 | return Series;
84 | } else if (isSeason(props.media)) {
85 | return Season;
86 | } else if (isEpisode(props.media)) {
87 | return Episode;
88 | } else {
89 | return <>>;
90 | }
91 | };
92 |
--------------------------------------------------------------------------------
/client/src/shared/components/Titles.tsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 | import { STATIC_STYLES } from "../enums/StaticStyles";
3 |
4 | export const H1 = styled.h1`
5 | font-size: 30px;
6 |
7 | @media (max-width: ${STATIC_STYLES.TABLET_MAX_WIDTH}px) {
8 | font-size: 28px;
9 | }
10 |
11 | @media (max-width: ${STATIC_STYLES.MOBILE_MAX_WIDTH}px) {
12 | font-size: 26px;
13 | }
14 | `;
15 |
16 | export const H2 = styled.h2`
17 | font-size: 24px;
18 |
19 | @media (max-width: ${STATIC_STYLES.TABLET_MAX_WIDTH}px) {
20 | font-size: 22px;
21 | }
22 |
23 | @media (max-width: ${STATIC_STYLES.MOBILE_MAX_WIDTH}px) {
24 | font-size: 20px;
25 | }
26 | `;
27 |
28 | export const H3 = styled.h3`
29 | font-size: 18px;
30 |
31 | @media (max-width: ${STATIC_STYLES.TABLET_MAX_WIDTH}px) {
32 | font-size: 16px;
33 | }
34 |
35 | @media (max-width: ${STATIC_STYLES.MOBILE_MAX_WIDTH}px) {
36 | font-size: 14px;
37 | }
38 | `;
39 |
--------------------------------------------------------------------------------
/client/src/shared/components/Tooltiped.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 |
4 | type TooltipProps = {
5 | text: string;
6 | children: any;
7 | };
8 |
9 | const TooltipContainer = styled.div`
10 | position: relative;
11 | display: inline-block;
12 |
13 | .tooltip-text {
14 | visibility: hidden;
15 | width: 120px;
16 | background-color: rgba(0, 0, 0, 0.8);
17 | color: #fff;
18 | text-align: center;
19 | padding: 5px 0;
20 | border-radius: 6px;
21 | opacity: 0;
22 | transition: opacity 1s;
23 |
24 | /* Position the tooltip text - see examples below! */
25 | position: absolute;
26 | z-index: 1;
27 |
28 | // BOTTOM
29 | top: 100%;
30 | left: 50%;
31 | margin-left: -60px;
32 |
33 | &:after {
34 | content: " ";
35 | position: absolute;
36 | bottom: 100%; /* At the top of the tooltip */
37 | left: 50%;
38 | margin-left: -5px;
39 | border-width: 5px;
40 | border-style: solid;
41 | border-color: transparent transparent rgba(0, 0, 0, 0.8) transparent;
42 | }
43 | }
44 |
45 | &:hover .tooltip-text {
46 | visibility: visible;
47 | opacity: 1;
48 | }
49 | `;
50 |
51 | export const Tooltiped = ({ text, children }: TooltipProps) => {
52 | return (
53 |
54 | {text}
55 | {children}
56 |
57 | );
58 | };
59 |
--------------------------------------------------------------------------------
/client/src/shared/components/UserSmallCard.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { IUser } from "../models/IUser";
3 | import styled from "styled-components";
4 | import { useHistory } from "react-router-dom";
5 | import { routes } from "../../router/routes";
6 |
7 | const Container = styled.div`
8 | display: flex;
9 | align-items: center;
10 | cursor: pointer;
11 | `;
12 |
13 | const Image = styled.img`
14 | margin-right: 10px;
15 | width: 64px;
16 | height: 64px;
17 | `;
18 |
19 | type UserSmallCardProps = {
20 | user: IUser;
21 | };
22 |
23 | export const UserSmallCard = ({ user }: UserSmallCardProps) => {
24 | const history = useHistory();
25 | return (
26 | history.push(routes.PROFILE.url(user.id))}>
27 |
28 | {user.username}
29 |
30 | );
31 | };
32 |
--------------------------------------------------------------------------------
/client/src/shared/components/animations/Animate.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { useEffect, useState } from "react";
3 | import styled, { css } from "styled-components";
4 |
5 | interface AnimationProps {
6 | readonly isActive: boolean;
7 | readonly size: number | null;
8 | readonly animationIn: any;
9 | readonly animationOut: any;
10 | readonly duration: number;
11 | readonly count: number | null;
12 | readonly isVisible: boolean;
13 | }
14 |
15 | const AnimationStyle = styled.div`
16 | padding: 0;
17 | visibility: ${({ isActive }) => (isActive ? "visible" : "hidden")};
18 | margin-bottom: ${({ isActive, size }) =>
19 | isActive && size ? "0" : "-" + size}px;
20 | ${({
21 | isActive,
22 | isVisible,
23 | animationIn,
24 | animationOut,
25 | size,
26 | duration,
27 | count,
28 | }) =>
29 | isActive &&
30 | css`
31 | animation-name: ${isVisible
32 | ? size
33 | ? animationIn(size)
34 | : animationIn()
35 | : size
36 | ? animationOut(size)
37 | : animationOut()};
38 | animation-duration: ${duration}s;
39 | animation-timing-function: linear;
40 | animation-iteration-count: ${count ? count : 1};
41 | animation-fill-mode: forwards;
42 | `}
43 | `;
44 |
45 | interface AnimateProps {
46 | animationIn: any;
47 | animationOut: any;
48 | isVisible: boolean;
49 | size: number | null;
50 | duration: number;
51 | count: number | null;
52 | children: any;
53 | }
54 |
55 | const Animate = ({
56 | animationIn,
57 | animationOut,
58 | isVisible,
59 | size,
60 | duration,
61 | count,
62 | children,
63 | }: AnimateProps) => {
64 | const [active, setActive] = useState(false);
65 |
66 | useEffect(() => {
67 | if (!active && isVisible) {
68 | setActive(true);
69 | }
70 | // eslint-disable-next-line react-hooks/exhaustive-deps
71 | }, [isVisible]);
72 |
73 | return (
74 |
83 | {children}
84 |
85 | );
86 | };
87 |
88 | export { Animate };
89 |
--------------------------------------------------------------------------------
/client/src/shared/components/animations/Animations.ts:
--------------------------------------------------------------------------------
1 | import { keyframes } from "styled-components";
2 |
3 | export const Flip = () => {
4 | return keyframes`
5 | 0% {
6 | transform: scaleX(0);
7 | }
8 | 50% {
9 | transform: scaleX(-1);
10 | }
11 | 100% {
12 | transform: scaleX(0);;
13 | }
14 | `;
15 | };
16 |
17 | export const Spin = () => {
18 | return keyframes`
19 | from {
20 | transform: rotate(0deg);
21 | }
22 | to {
23 | transform: rotate(360deg);
24 | }
25 | `;
26 | };
27 |
28 | const FadeInUp = (height: number) => {
29 | return keyframes`
30 | from {
31 | visibility: visible;
32 | opacity: 0;
33 | margin-bottom: -${height}px;
34 | }
35 |
36 | to {
37 | opacity: 1;
38 | visibility: visible;
39 | margin-bottom: 0px;
40 | }
41 | `;
42 | };
43 |
44 | const FadeOutDown = (height: number) => {
45 | return keyframes`
46 | 0% {
47 | opacity: 1;
48 | margin-bottom: 0px;
49 | }
50 |
51 | 50% {
52 | opacity: .8;
53 | }
54 |
55 | 100% {
56 | visibility: hidden;
57 | opacity: 0;
58 | margin-bottom: -${height}px;
59 | }
60 | `;
61 | };
62 |
63 | const FadeIn = () => {
64 | return keyframes`
65 | from {
66 | visibility: visible;
67 | opacity: 0;
68 | }
69 |
70 | to {
71 | visibility: visible;
72 | opacity: 1;
73 | }
74 | `;
75 | };
76 |
77 | const FadeOut = () => {
78 | return keyframes`
79 | from {
80 | opacity: 1;
81 | }
82 |
83 | to {
84 | opacity: 0;
85 | visibility: hidden;
86 | }
87 | `;
88 | };
89 |
90 | export { FadeInUp, FadeOutDown, FadeIn, FadeOut };
91 |
--------------------------------------------------------------------------------
/client/src/shared/components/errors/InternalServerError.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export const InternalServerError = () => {
4 | return 500...
;
5 | };
6 |
--------------------------------------------------------------------------------
/client/src/shared/components/errors/NotFound.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const NotFound = () => {
4 | return (
5 |
9 |
10 |
11 |
12 | 404 ! Oops the resource has not been found...
13 |
14 |
15 |
16 |
17 | );
18 | };
19 |
20 | export { NotFound };
21 |
--------------------------------------------------------------------------------
/client/src/shared/components/errors/RequestExpired.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export const RequestExpired = () => {
4 | return Request expired
;
5 | };
6 |
--------------------------------------------------------------------------------
/client/src/shared/components/errors/SwitchErrors.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { UnprocessableEntity } from "./UnprocessableEntity";
3 | import { InternalServerError } from "./InternalServerError";
4 | import { UnhandledError } from "./UnhandledError";
5 |
6 | type SwitchErrorsProps = {
7 | status: number;
8 | };
9 |
10 | export const SwitchErrors = ({ status }: SwitchErrorsProps) => {
11 | switch (status) {
12 | case 422:
13 | return ;
14 | case 500:
15 | return ;
16 | default:
17 | return ;
18 | }
19 | };
20 |
--------------------------------------------------------------------------------
/client/src/shared/components/errors/UnhandledError.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export const UnhandledError = () => {
4 | return Unhandled error
;
5 | };
6 |
--------------------------------------------------------------------------------
/client/src/shared/components/errors/UnprocessableEntity.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export const UnprocessableEntity = () => {
4 | return Unprocessable entity
;
5 | };
6 |
--------------------------------------------------------------------------------
/client/src/shared/components/inputs/InputField.tsx:
--------------------------------------------------------------------------------
1 | import styled, { css } from "styled-components";
2 | import { STATIC_STYLES } from "../../enums/StaticStyles";
3 |
4 | type InputFieldProps = {
5 | width?: string;
6 | withIcon?: boolean;
7 | isInline?: boolean;
8 | hidden?: boolean;
9 | };
10 |
11 | export const InputField = styled.div`
12 | display: flex;
13 | flex-direction: ${(props) => (props.isInline ? "row" : "column")};
14 | margin: 8px 0;
15 |
16 | label {
17 | white-space: nowrap;
18 | }
19 |
20 | ${(props) =>
21 | props.width &&
22 | css`
23 | width: ${props.width};
24 | `};
25 |
26 | @media screen and (max-width: ${STATIC_STYLES.MOBILE_MAX_WIDTH}px) {
27 | width: 100%;
28 | }
29 |
30 | ${(props) =>
31 | props.hidden &&
32 | css`
33 | visibility: hidden;
34 | width: 0;
35 | height: 0;
36 | margin: 0;
37 | padding: 0;
38 | `};
39 |
40 | ${(props) =>
41 | props.isInline &&
42 | css`
43 | align-items: center;
44 | label {
45 | &:first-child {
46 | padding-right: 10px;
47 | }
48 | &:not(:first-child) {
49 | padding-left: 10px;
50 | }
51 | }
52 | `};
53 |
54 | .with-left-icon {
55 | position: relative;
56 |
57 | .icon {
58 | position: absolute;
59 | top: 50%;
60 | left: 10px;
61 | transform: translateY(-50%);
62 | }
63 | }
64 |
65 | input[type="text"],
66 | input[type="password"],
67 | input[type="email"],
68 | input[type="number"],
69 | input[type="search"],
70 | select {
71 | width: 100%;
72 | padding: 12px 20px;
73 | display: inline-block;
74 | border: 3px solid ${(props) => props.theme.primaryLight};
75 | border-radius: 4px;
76 | box-sizing: border-box;
77 | background: ${(props) => props.theme.primary};
78 | opacity: 0.6;
79 | transition: opacity 0.3s ease;
80 | color: ${(props) => props.theme.white};
81 |
82 | ${(props) =>
83 | props.withIcon &&
84 | css`
85 | padding-left: 40px;
86 | `}
87 |
88 | &:focus {
89 | opacity: 1;
90 | outline: none;
91 | }
92 |
93 | &::placeholder {
94 | color: ${(props) => props.theme.white};
95 | }
96 | }
97 | `;
98 |
--------------------------------------------------------------------------------
/client/src/shared/components/layout/Buttons.tsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | export const Buttons = styled.div`
4 | display: flex;
5 | align-items: center;
6 | padding: 10px;
7 | > *:not(:last-child) {
8 | margin-right: 10px;
9 | }
10 | `;
11 |
--------------------------------------------------------------------------------
/client/src/shared/components/layout/CenteredContent.tsx:
--------------------------------------------------------------------------------
1 | import styled, { css } from "styled-components";
2 |
3 | export const CenteredContent = styled.section<{ height?: string }>`
4 | display: flex;
5 | justify-content: center;
6 | align-items: center;
7 | flex-direction: column;
8 | ${(props) =>
9 | props.height &&
10 | css`
11 | height: ${props.height};
12 | `};
13 | `;
14 |
--------------------------------------------------------------------------------
/client/src/shared/components/layout/Hero.tsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | export const Hero = styled.div`
4 | width: 100%;
5 | display: flex;
6 | justify-content: center;
7 | align-items: center;
8 | text-align: center;
9 | white-space: nowrap;
10 | `;
11 |
12 | export const PrimaryHero = styled(Hero)`
13 | background: ${(props) => props.theme.primary};
14 | color: ${(props) => props.theme.secondary};
15 | font-size: 2em;
16 | height: 20vh;
17 | `;
18 |
--------------------------------------------------------------------------------
/client/src/shared/components/layout/Row.tsx:
--------------------------------------------------------------------------------
1 | import styled, { css } from "styled-components";
2 |
3 | type RowProps = {
4 | justifyContent?:
5 | | "flex-start"
6 | | "flex-end"
7 | | "center"
8 | | "space-around"
9 | | "space-between";
10 | alignItems?: "center";
11 | width?: string;
12 | wrap?: "wrap" | "nowrap";
13 | };
14 |
15 | export const Row = styled.div`
16 | display: flex;
17 | ${(props) =>
18 | props.justifyContent &&
19 | css`
20 | justify-content: ${props.justifyContent};
21 | `}
22 | ${(props) =>
23 | props.alignItems &&
24 | css`
25 | align-items: ${props.alignItems};
26 | `}
27 |
28 | width: ${(props) => (props.width ? props.width : "100%")};
29 | flex-wrap: ${(props) => (props.wrap ? props.wrap : "wrap")};
30 | `;
31 |
--------------------------------------------------------------------------------
/client/src/shared/components/layout/Tabs.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled, { css } from "styled-components";
3 | import { useHistory } from "react-router-dom";
4 | import { Tab } from "../../contexts/TabsContext";
5 |
6 | export const TabsStyle = styled.div`
7 | overflow: hidden;
8 | overflow-x: auto;
9 | user-select: none;
10 | display: flex;
11 | align-items: stretch;
12 | font-size: 1rem;
13 | white-space: nowrap;
14 | margin-bottom: 10px;
15 | `;
16 |
17 | export const TabSide = styled.div`
18 | border-bottom: 1px solid ${(props) => props.theme.primaryLighter};
19 | width: 100%;
20 | `;
21 |
22 | export const TabStyle = styled.div<{ isActive: boolean }>`
23 | border-top: 1px solid
24 | ${(props) => (props.isActive ? props.theme.primaryLighter : "none")};
25 | border-left: 1px solid
26 | ${(props) => (props.isActive ? props.theme.primaryLighter : "none")};
27 | border-right: 1px solid
28 | ${(props) => (props.isActive ? props.theme.primaryLighter : "none")};
29 | border-bottom: 1px solid
30 | ${(props) => (props.isActive ? "none" : props.theme.primaryLighter)};
31 |
32 | border-top-left-radius: 6px;
33 | border-top-right-radius: 6px;
34 |
35 | padding: 0.75rem 2rem;
36 | width: 100%;
37 | display: flex;
38 | justify-content: center;
39 |
40 | :hover {
41 | ${(props) =>
42 | !props.isActive &&
43 | css`
44 | background: ${(props) => props.theme.primaryLight};
45 | `}
46 |
47 | border-top-left-radius: 6px;
48 | border-top-right-radius: 6px;
49 | }
50 | `;
51 |
52 | type TabsProps = {
53 | tabs: Tab[];
54 | activeTab: Tab;
55 | url: string;
56 | };
57 |
58 | export const Tabs = ({ tabs, activeTab, url }: TabsProps) => {
59 | const history = useHistory();
60 | return (
61 |
62 |
63 | {tabs.map((tab, index) => {
64 | return (
65 | history.push(url + "/" + tab.uri.toLowerCase())}
68 | key={index}
69 | >
70 | {tab.label}
71 |
72 | );
73 | })}
74 |
75 |
76 | );
77 | };
78 |
--------------------------------------------------------------------------------
/client/src/shared/components/media/Episode.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from "react";
2 | import { Spinner } from "../Spinner";
3 | import { SwitchErrors } from "../errors/SwitchErrors";
4 | import { Media } from "./Media";
5 | import { useEpisode } from "../../hooks/useEpisode";
6 |
7 | type EpisodeProps = {
8 | seriesId: string;
9 | seasonNumber: number;
10 | episodeNumber: number;
11 | };
12 |
13 | export const Episode = (props: EpisodeProps) => {
14 | const episode = useEpisode(
15 | props.seriesId,
16 | props.seasonNumber,
17 | props.episodeNumber
18 | );
19 | const episodeRef = useRef(null);
20 |
21 | useEffect(() => {
22 | if (episodeRef.current) {
23 | episodeRef.current.scrollIntoView({
24 | behavior: "smooth",
25 | block: "nearest",
26 | inline: "nearest",
27 | });
28 | }
29 | }, [episode.data]);
30 |
31 | if (episode.isLoading) {
32 | return ;
33 | }
34 |
35 | if (episode.data === null) {
36 | return ;
37 | }
38 |
39 | return ;
40 | };
41 |
--------------------------------------------------------------------------------
/client/src/shared/components/media/MediaBackground.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 |
4 | const MediaBackgroundContainerStyle = styled.div`
5 | position: relative;
6 | min-width: 100%;
7 | max-width: 100%;
8 | min-height: 100vh;
9 | z-index: 0;
10 | `;
11 |
12 | const MediaBackgroundStyle = styled.div`
13 | position: absolute;
14 | top: 0;
15 | left: 0;
16 | width: 100%;
17 | height: 100%;
18 | z-index: -2;
19 | `;
20 |
21 | const MediaBackgroundImage = styled.div<{ backgroundImage: string }>`
22 | position: absolute;
23 | top: 0;
24 | left: 0;
25 | width: 100%;
26 | height: 100%;
27 | background-image: url('${(props) => props.backgroundImage}');
28 | background-repeat: no-repeat;
29 | background-position: 0 0;
30 | background-size: cover;
31 | opacity: 0.2;
32 | z-index: -1;
33 | `;
34 |
35 | const MediaBackground = ({ image, children }: any) => {
36 | return (
37 |
38 | {children}
39 |
40 |
41 |
42 | );
43 | };
44 |
45 | export { MediaBackground };
46 |
--------------------------------------------------------------------------------
/client/src/shared/components/media/MediaCardsLoader.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled, { keyframes } from "styled-components";
3 | import { MediaPreviewCardContainer } from "./MediaPreviewCard";
4 |
5 | const Shine = () => {
6 | return keyframes`
7 | 0% {
8 | filter: brightness(100%);
9 | }
10 | 100% {
11 | filter: brightness(200%);
12 | }`;
13 | };
14 |
15 | const MediaLoadingCardContainer = styled(MediaPreviewCardContainer)<{
16 | index: number;
17 | n: number;
18 | }>`
19 | background: ${(props) => props.theme.primary};
20 | animation: 1s ease infinite running;
21 | padding: 0;
22 | margin: 4px;
23 |
24 | &:nth-last-child(-n + ${(props) => props.n}) {
25 | animation: ${Shine} 0.5s alternate infinite linear;
26 | animation-delay: ${(props) => props.index / props.n}s;
27 | }
28 |
29 | &:nth-child(-n + ${(props) => props.n}) {
30 | animation: ${Shine} 0.5s alternate infinite linear;
31 | animation-delay: ${(props) => props.index / props.n}s;
32 | }
33 | `;
34 |
35 | type MediaCardsLoaderProps = {
36 | n: number;
37 | refIndex?: number;
38 | };
39 |
40 | export const MediaCardsLoader = React.forwardRef<
41 | HTMLDivElement,
42 | MediaCardsLoaderProps
43 | >((props, ref) => {
44 | return (
45 | <>
46 | {[...Array(props.n)].map((_, index) => (
47 |
53 |
54 |
55 | ))}
56 | >
57 | );
58 | });
59 |
--------------------------------------------------------------------------------
/client/src/shared/components/media/MediaPersonCarousel.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 | import { Carousel } from "../layout/Carousel";
4 | import { getActorInitial } from "../../../utils/media-utils";
5 | import { IPerson } from "../../models/IMedia";
6 |
7 | const Container = styled.div`
8 | width: 100%;
9 | margin-top: 1em;
10 | `;
11 |
12 | const Person = styled.div`
13 | margin: 1em;
14 | `;
15 |
16 | const PersonPicture = styled.img`
17 | min-width: 120px;
18 | max-width: 120px;
19 | height: 120px;
20 | border-radius: 50%;
21 | object-fit: cover;
22 | object-position: 50% 50%;
23 | `;
24 |
25 | const PersonInitials = styled.div`
26 | min-width: 120px;
27 | max-width: 120px;
28 | height: 120px;
29 | border-radius: 50%;
30 | display: flex;
31 | justify-content: center;
32 | align-items: center;
33 | background-color: rgba(146, 146, 146, 0.5);
34 | font-size: 2em;
35 | `;
36 |
37 | type MediaPersonCarouselProps = {
38 | personList: IPerson[];
39 | };
40 |
41 | const MediaPersonCarousel = ({ personList }: MediaPersonCarouselProps) => {
42 | return (
43 |
44 |
45 | {personList.map((person, index) => (
46 |
47 | {person.pictureUrl ? (
48 |
49 | ) : (
50 |
51 | {getActorInitial(person.name)}
52 |
53 | )}
54 |
55 |
{person.name}
56 |
{person.role}
57 |
58 |
59 | ))}
60 |
61 |
62 | );
63 | };
64 |
65 | export { MediaPersonCarousel };
66 |
--------------------------------------------------------------------------------
/client/src/shared/components/media/MediaRating.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 | import {
4 | getColorRating,
5 | getRatingPercentage,
6 | } from "../../../utils/media-utils";
7 | import { IMedia } from "../../models/IMedia";
8 |
9 | const Container = styled.div<{ backgroundColor: string }>`
10 | display: flex;
11 | justify-content: center;
12 | align-items: center;
13 | border-radius: 50%;
14 | width: 60px;
15 | height: 60px;
16 | background-color: ${(props) => props.backgroundColor};
17 | `;
18 |
19 | const RatingValue = styled.div`
20 | display: flex;
21 | justify-content: center;
22 | align-items: center;
23 | border-radius: 50%;
24 | width: 50px;
25 | height: 50px;
26 | background-color: ${(props) => props.theme.primary};
27 | `;
28 |
29 | type MediaRatingProps = {
30 | media: IMedia;
31 | };
32 |
33 | const MediaRating = ({ media }: MediaRatingProps) => {
34 | if (!media || !media.rating) return ;
35 |
36 | return (
37 |
42 |
43 | {getRatingPercentage(media.rating) + "%"}
44 |
45 |
46 | );
47 | };
48 |
49 | export { MediaRating };
50 |
--------------------------------------------------------------------------------
/client/src/shared/components/media/Movie.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useParams } from "react-router";
3 | import { useMovie } from "../../hooks/useMovie";
4 | import { Media } from "./Media";
5 | import { CenteredContent } from "../layout/CenteredContent";
6 | import { Spinner } from "../Spinner";
7 | import { SwitchErrors } from "../errors/SwitchErrors";
8 |
9 | type MovieParams = {
10 | id: string;
11 | };
12 |
13 | export const Movie = () => {
14 | const { id } = useParams();
15 | const movie = useMovie(id);
16 |
17 | if (movie.isLoading) {
18 | return (
19 |
20 |
21 |
22 | );
23 | }
24 |
25 | if (movie.data === null) {
26 | return ;
27 | }
28 |
29 | return (
30 | <>
31 |
32 | >
33 | );
34 | };
35 |
--------------------------------------------------------------------------------
/client/src/shared/components/media/Season.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from "react";
2 | import { useSeason } from "../../hooks/useSeason";
3 | import { Media } from "./Media";
4 | import { Spinner } from "../Spinner";
5 | import { SwitchErrors } from "../errors/SwitchErrors";
6 | import { H2 } from "../Titles";
7 | import { Row } from "../layout/Row";
8 | import { MediaPreviewCard } from "./MediaPreviewCard";
9 | import { Episode } from "./Episode";
10 |
11 | type SeasonProps = {
12 | seriesId: string;
13 | seasonNumber: number;
14 | episodeNumber?: number;
15 | };
16 |
17 | export const Season = (props: SeasonProps) => {
18 | const season = useSeason(props.seriesId, props.seasonNumber);
19 | const seasonRef = useRef(null);
20 |
21 | useEffect(() => {
22 | if (seasonRef.current) {
23 | seasonRef.current.scrollIntoView({
24 | behavior: "smooth",
25 | block: "nearest",
26 | inline: "nearest",
27 | });
28 | }
29 | }, [season.data]);
30 |
31 | if (season.isLoading) {
32 | return ;
33 | }
34 |
35 | if (season.data === null) {
36 | return ;
37 | }
38 |
39 | return (
40 | <>
41 |
42 | Episodes
43 |
44 | {season.data &&
45 | season.data.episodes &&
46 | season.data.episodes.map((episode) => (
47 |
48 | ))}
49 |
50 | {season.data && props.episodeNumber && (
51 | <>
52 |
57 | >
58 | )}
59 | >
60 | );
61 | };
62 |
--------------------------------------------------------------------------------
/client/src/shared/components/media/Series.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useParams } from "react-router";
3 | import { CenteredContent } from "../layout/CenteredContent";
4 | import { Spinner } from "../Spinner";
5 | import { SwitchErrors } from "../errors/SwitchErrors";
6 | import { Media } from "./Media";
7 | import { useSeries } from "../../hooks/useSeries";
8 | import { PrimaryDivider } from "../Divider";
9 | import { H2 } from "../Titles";
10 | import { Row } from "../layout/Row";
11 | import { MediaPreviewCard } from "./MediaPreviewCard";
12 | import { Season } from "./Season";
13 |
14 | type SeriesParams = {
15 | id: string;
16 | seasonNumber?: string;
17 | episodeNumber?: string;
18 | };
19 |
20 | export const Series = () => {
21 | const { id, seasonNumber, episodeNumber } = useParams();
22 | const series = useSeries(id);
23 |
24 | if (series.isLoading) {
25 | return (
26 |
27 |
28 |
29 | );
30 | }
31 |
32 | if (series.data === null) {
33 | return ;
34 | }
35 |
36 | return (
37 | <>
38 |
39 |
40 | Seasons
41 |
42 | {series.data &&
43 | series.data.seasons &&
44 | series.data.seasons.map((season) => (
45 |
46 | ))}
47 |
48 | {series.data && seasonNumber && (
49 | <>
50 |
51 |
58 | >
59 | )}
60 | >
61 | );
62 | };
63 |
--------------------------------------------------------------------------------
/client/src/shared/components/requests/RequestButton.tsx:
--------------------------------------------------------------------------------
1 | import { PrimaryButton } from "../Button";
2 | import React, { MouseEvent, useState } from "react";
3 | import { SeriesRequestOptionsContextProvider } from "../../contexts/SeriesRequestOptionsContext";
4 | import { RequestMediaModal } from "./RequestMediaModal";
5 | import { IMedia } from "../../models/IMedia";
6 | import { useSession } from "../../contexts/SessionContext";
7 | import { checkRole } from "../../../utils/roles";
8 | import { Roles } from "../../enums/Roles";
9 |
10 | type RequestButtonProps = {
11 | media: IMedia;
12 | };
13 |
14 | export const RequestButton = (props: RequestButtonProps) => {
15 | const {
16 | session: { user },
17 | } = useSession();
18 | const [isRequestMediaModalOpen, setIsRequestMediaModalOpen] = useState(false);
19 |
20 | const onRequestClick = (e: MouseEvent) => {
21 | setIsRequestMediaModalOpen(true);
22 | e.stopPropagation();
23 | };
24 |
25 | if (!user || (user && !checkRole(user.roles, [Roles.REQUEST]))) {
26 | return <>>;
27 | }
28 |
29 | return (
30 | <>
31 | onRequestClick(e)}
35 | >
36 | Request
37 |
38 | {isRequestMediaModalOpen && (
39 |
40 | setIsRequestMediaModalOpen(false)}
43 | />
44 |
45 | )}
46 | >
47 | );
48 | };
49 |
--------------------------------------------------------------------------------
/client/src/shared/components/requests/RequestMediaModal.tsx:
--------------------------------------------------------------------------------
1 | import React, { MouseEvent } from "react";
2 | import { Modal } from "../layout/Modal";
3 | import { IMedia, ISeries } from "../../models/IMedia";
4 | import { H2 } from "../Titles";
5 | import { Row } from "../layout/Row";
6 | import { MediaTag } from "../Tag";
7 | import { MediaTypes } from "../../enums/MediaTypes";
8 | import { RequestSeriesCard } from "./RequestSeriesCard";
9 | import { Buttons } from "../layout/Buttons";
10 | import { Button, PrimaryButton } from "../Button";
11 | import { useRequestMedia } from "../../hooks/useRequestMedia";
12 | import { useSeriesRequestOptionsContext } from "../../contexts/SeriesRequestOptionsContext";
13 |
14 | type RequestMediaModalProps = {
15 | media: IMedia;
16 | closeModal: () => void;
17 | };
18 |
19 | export const RequestMediaModal = (props: RequestMediaModalProps) => {
20 | const { requestMovie, requestSeries } = useRequestMedia();
21 | const { options } = useSeriesRequestOptionsContext();
22 |
23 | const onRequestMedia = (e: MouseEvent) => {
24 | if (props.media.mediaType === MediaTypes.MOVIES) {
25 | requestMovie({
26 | tmdbId: props.media.tmdbId,
27 | }).then((res) => {
28 | if (res.status === 201) {
29 | props.closeModal();
30 | }
31 | });
32 | } else if (props.media.mediaType === MediaTypes.SERIES) {
33 | requestSeries({
34 | tmdbId: props.media.tmdbId,
35 | seasons: options.seasons,
36 | }).then((res) => {
37 | if (res.status === 201) {
38 | props.closeModal();
39 | }
40 | });
41 | }
42 | e.stopPropagation();
43 | };
44 |
45 | return (
46 | props.closeModal()}>
47 |
48 |
49 | {props.media.title}
50 |
51 |
52 |
53 |
54 | {props.media.mediaType === MediaTypes.SERIES && (
55 |
56 | )}
57 |
58 |
68 |
69 | );
70 | };
71 |
--------------------------------------------------------------------------------
/client/src/shared/components/requests/RequestSeriesCard.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { InputField } from "../inputs/InputField";
3 | import { RequestSeriesOptions } from "../../enums/RequestSeriesOptions";
4 | import { SeriesRequestSeasonsList } from "./SeriesRequestSeasonsList";
5 | import { ISeries } from "../../models/IMedia";
6 | import { SeriesRequestOptionsPreview } from "./SeriesRequestOptionsPreview";
7 | import { PrimaryLightDivider } from "../Divider";
8 |
9 | type RequestSeriesCardProps = {
10 | series: ISeries;
11 | };
12 |
13 | export const RequestSeriesCard = (props: RequestSeriesCardProps) => {
14 | const [seriesRequestScopeOptions, setSeriesRequestScopeOptions] = useState(
15 | RequestSeriesOptions.ALL
16 | );
17 |
18 | return (
19 |
20 |
21 |
22 |
34 |
35 | {seriesRequestScopeOptions === RequestSeriesOptions.SELECT && (
36 |
42 | )}
43 |
44 | );
45 | };
46 |
--------------------------------------------------------------------------------
/client/src/shared/components/requests/RequestsSent.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Spinner } from "../Spinner";
3 | import {
4 | RequestFooter,
5 | RequestHeader,
6 | RequestLayout,
7 | ScrollingTable,
8 | } from "./RequestLayout";
9 | import { CenteredContent } from "../layout/CenteredContent";
10 | import { ComponentSizes } from "../../enums/ComponentSizes";
11 | import { useRequestsContext } from "../../contexts/RequestsContext";
12 | import { RequestTypes } from "../../enums/RequestTypes";
13 | import { FullWidthTag } from "../FullWidthTag";
14 |
15 | const RequestsSent = () => {
16 | const { requestsSent, onLoadPrev, onLoadNext } = useRequestsContext();
17 |
18 | return (
19 |
20 |
21 |
22 | {requestsSent.isLoading && (
23 |
24 |
25 |
26 | )}
27 | {!requestsSent.isLoading &&
28 | requestsSent.data &&
29 | requestsSent.data.results &&
30 | requestsSent.data.results.map((request, index) => (
31 |
36 | ))}
37 | {!requestsSent.isLoading &&
38 | requestsSent.data &&
39 | requestsSent.data.results &&
40 | requestsSent.data.results.length === 0 && (
41 | No requests sent
42 | )}
43 | onLoadPrev(RequestTypes.OUTGOING)}
47 | onLoadNext={() => onLoadNext(RequestTypes.OUTGOING)}
48 | />
49 |
50 | );
51 | };
52 |
53 | export { RequestsSent };
54 |
--------------------------------------------------------------------------------
/client/src/shared/components/requests/SeriesRequestOptionsPreview.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { H3 } from "../Titles";
3 | import { useSeriesRequestOptionsContext } from "../../contexts/SeriesRequestOptionsContext";
4 | import { Tag } from "../Tag";
5 | import { Row } from "../layout/Row";
6 | import { Tooltiped } from "../Tooltiped";
7 | import { Help } from "../Help";
8 |
9 | export const SeriesRequestOptionsPreview = () => {
10 | const { options } = useSeriesRequestOptionsContext();
11 |
12 | return (
13 |
14 |
Request preview :
15 |
16 | {options.seasons.length === 0 && (
17 |
18 | No elements selected
19 |
20 | )}
21 | {options.seasons.length > 0 &&
22 | options.seasons.map((s) => (
23 |
24 | Season {s.seasonNumber}{" "}
25 | {s.episodes.length > 0 && (
26 | ({s.episodes.length})
27 | )}
28 |
29 | ))}
30 |
31 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/client/src/shared/contexts/RadarrConfigsContext.tsx:
--------------------------------------------------------------------------------
1 | import React, { createContext, useContext } from "react";
2 | import { DefaultAsyncCall, IAsyncCall } from "../models/IAsyncCall";
3 | import { IProviderSettingsBase } from "../models/IProviderSettingsBase";
4 | import { IRadarrConfig } from "../models/IRadarrConfig";
5 | import { IRadarrInstanceInfo } from "../models/IRadarrInstanceInfo";
6 | import { useRadarrConfigs } from "../hooks/useRadarrConfigs";
7 |
8 | interface IRadarrConfigsContext {
9 | radarrConfigs: IAsyncCall;
10 | getRadarrInstanceInfo: (
11 | config: IProviderSettingsBase,
12 | withAlert: boolean
13 | ) => Promise>;
14 | createRadarrConfig: (
15 | config: IRadarrConfig
16 | ) => Promise>;
17 | updateRadarrConfig: (
18 | id: string,
19 | config: IRadarrConfig
20 | ) => Promise>;
21 | deleteRadarrConfig: (id: string) => void;
22 | }
23 |
24 | const RadarrConfigsContextDefaultImpl: IRadarrConfigsContext = {
25 | radarrConfigs: DefaultAsyncCall,
26 | createRadarrConfig(): Promise> {
27 | return Promise.resolve(DefaultAsyncCall);
28 | },
29 | deleteRadarrConfig(): Promise {
30 | return Promise.resolve(undefined);
31 | },
32 | getRadarrInstanceInfo(): Promise> {
33 | return Promise.resolve(DefaultAsyncCall);
34 | },
35 | updateRadarrConfig(): Promise> {
36 | return Promise.resolve(DefaultAsyncCall);
37 | },
38 | };
39 |
40 | const RadarrConfigsContext = createContext(
41 | RadarrConfigsContextDefaultImpl
42 | );
43 |
44 | export const useRadarrConfigsContext = () => useContext(RadarrConfigsContext);
45 |
46 | export const RadarrConfigsContextProvider = (props: any) => {
47 | const radarrConfigsHook = useRadarrConfigs();
48 | return (
49 |
50 | {props.children}
51 |
52 | );
53 | };
54 |
--------------------------------------------------------------------------------
/client/src/shared/contexts/SonarrConfigContext.tsx:
--------------------------------------------------------------------------------
1 | import React, { createContext, useContext } from "react";
2 | import { useSonarrConfigs } from "../hooks/useSonarrConfigs";
3 | import { ISonarrConfig } from "../models/ISonarrConfig";
4 | import { DefaultAsyncCall, IAsyncCall } from "../models/IAsyncCall";
5 | import { ISonarrInstanceInfo } from "../models/ISonarrInstanceInfo";
6 | import { IProviderSettingsBase } from "../models/IProviderSettingsBase";
7 |
8 | interface ISonarrConfigsContext {
9 | sonarrConfigs: IAsyncCall;
10 | getSonarrInstanceInfo: (
11 | config: IProviderSettingsBase,
12 | withAlert: boolean
13 | ) => Promise>;
14 | createSonarrConfig: (
15 | config: ISonarrConfig
16 | ) => Promise>;
17 | updateSonarrConfig: (
18 | id: string,
19 | config: ISonarrConfig
20 | ) => Promise>;
21 | deleteSonarrConfig: (id: string) => void;
22 | }
23 |
24 | const SonarrConfigsContextDefaultImpl: ISonarrConfigsContext = {
25 | sonarrConfigs: DefaultAsyncCall,
26 | createSonarrConfig(): Promise> {
27 | return Promise.resolve(DefaultAsyncCall);
28 | },
29 | deleteSonarrConfig(): Promise {
30 | return Promise.resolve(undefined);
31 | },
32 | getSonarrInstanceInfo(): Promise> {
33 | return Promise.resolve(DefaultAsyncCall);
34 | },
35 | updateSonarrConfig(): Promise> {
36 | return Promise.resolve(DefaultAsyncCall);
37 | },
38 | };
39 |
40 | const SonarrConfigsContext = createContext(
41 | SonarrConfigsContextDefaultImpl
42 | );
43 |
44 | export const useSonarrConfigsContext = () => useContext(SonarrConfigsContext);
45 |
46 | export const SonarrConfigsContextProvider = (props: any) => {
47 | const sonarrConfigsHook = useSonarrConfigs();
48 | return (
49 |
50 | {props.children}
51 |
52 | );
53 | };
54 |
--------------------------------------------------------------------------------
/client/src/shared/contexts/TabsContext.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useLocation } from "react-router-dom";
3 | import { createContext, useContext } from "react";
4 | import { Tabs } from "../components/layout/Tabs";
5 |
6 | export type Tab = {
7 | label: string;
8 | uri: string;
9 | };
10 |
11 | export const TabsContext = createContext({});
12 |
13 | export const useTabs = () => useContext(TabsContext);
14 |
15 | type TabsContextProviderProps = {
16 | url: string;
17 | tabs: Tab[];
18 | children: any;
19 | };
20 |
21 | export const TabsContextProvider = ({
22 | url,
23 | tabs,
24 | children,
25 | }: TabsContextProviderProps) => {
26 | const location = useLocation();
27 |
28 | const isActiveTab = (uri: string) => {
29 | return (
30 | location.pathname === url + "/" + uri.toLowerCase() ||
31 | location.pathname === url + "/" + uri.toLowerCase() + "/"
32 | );
33 | };
34 |
35 | const getActiveTab = () => {
36 | const activeTab = tabs.find((t: Tab) => isActiveTab(t.uri));
37 | return activeTab ? activeTab : tabs[0];
38 | };
39 |
40 | return (
41 |
42 |
43 | {children}
44 |
45 | );
46 | };
47 |
--------------------------------------------------------------------------------
/client/src/shared/contexts/ThemeContext.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { ThemeProvider } from "styled-components";
3 | import { GlobalStyle } from "../../GlobalStyles";
4 | import { THEMES } from "../../Themes";
5 |
6 | export const ThemeContext = ({ children }: any) => {
7 | return (
8 |
9 |
10 | {children}
11 |
12 | );
13 | };
14 |
--------------------------------------------------------------------------------
/client/src/shared/enums/ComponentSizes.ts:
--------------------------------------------------------------------------------
1 | export enum ComponentSizes {
2 | SMALL,
3 | MEDIUM,
4 | LARGE,
5 | XLARGE,
6 | }
7 |
--------------------------------------------------------------------------------
/client/src/shared/enums/ErrorsMessage.ts:
--------------------------------------------------------------------------------
1 | const ERRORS_MESSAGE = {
2 | NONE: "Everything is fine",
3 | STATUS_401_UNAUTHORIZED: "This link has expired or is invalid",
4 | STATUS_410_GONE: "This link has expired or is invalid",
5 | STATUS_409_CONFLICT: "Already exist",
6 | STATUS_422_UNPROCESSABLE: "This resource is unprocessable",
7 | STATUS_403_FORBIDDEN: "Forbidden",
8 | STATUS_400_BAD_REQUEST: "Bad request",
9 | USER_ALREADY_CONFIRMED: "User already confirmed",
10 | INTERNAL_SERVER_ERROR: "Internal server error",
11 | UNEXPECTED_ERROR: "An unexpected error occurred",
12 | USER_ALREADY_EXIST: "User already exist",
13 | UNHANDLED_STATUS: (code: number | string) => "Unhandled status code " + code,
14 | };
15 |
16 | export { ERRORS_MESSAGE };
17 |
--------------------------------------------------------------------------------
/client/src/shared/enums/FormDefaultValidators.ts:
--------------------------------------------------------------------------------
1 | const FORM_DEFAULT_VALIDATOR = {
2 | REQUIRED: { value: null, message: "This is required" },
3 | MIN_LENGTH: { value: 4, message: "Must contain at least 4 character" },
4 | MAX_LENGTH: { value: 128, message: "Must contain a maximum of 128 characters" },
5 | PASSWORD_PATTERN: { value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9])(?!.*\s).{8,128}$/, message: "Password must contain at least 8 characters with at least one lowercase letter, one uppercase letter, one numeric digit, and one special character" },
6 | EMAIL_PATTERN: { value: /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/i, message: "This in not a valid email" },
7 | WATCH_PASSWORD: { value: null, message: "Password are not equals" },
8 | USERNAME_PATTERN: { value: /^[a-zA-Z0-9]+$/, message: "Special character are not allowed" }
9 | };
10 |
11 | export {
12 | FORM_DEFAULT_VALIDATOR
13 | }
--------------------------------------------------------------------------------
/client/src/shared/enums/LogLevels.tsx:
--------------------------------------------------------------------------------
1 | export enum LogLevels {
2 | DEBUG = "DEBUG",
3 | INFO = "INFO",
4 | WARNING = "WARNING",
5 | ERROR = "ERROR",
6 | CRITICAL = "CRITICAL",
7 | }
8 |
--------------------------------------------------------------------------------
/client/src/shared/enums/MediaProvidersTypes.ts:
--------------------------------------------------------------------------------
1 | export enum MediaProvidersTypes {
2 | RADARR = "radarr",
3 | SONARR = "sonarr",
4 | }
5 |
--------------------------------------------------------------------------------
/client/src/shared/enums/MediaServersTypes.ts:
--------------------------------------------------------------------------------
1 | export enum MediaServerTypes {
2 | PLEX = "plex",
3 | }
4 |
--------------------------------------------------------------------------------
/client/src/shared/enums/MediaTypes.ts:
--------------------------------------------------------------------------------
1 | export enum MediaTypes {
2 | MOVIES = "movies",
3 | SERIES = "series",
4 | SEASONS = "seasons",
5 | EPISODES = "episodes",
6 | }
7 |
8 | export enum SeriesTypes {
9 | ANIME = "anime",
10 | STANDARD = "standard",
11 | }
12 |
--------------------------------------------------------------------------------
/client/src/shared/enums/Messages.ts:
--------------------------------------------------------------------------------
1 | export const MESSAGES = {
2 | EMAIL_CONFIRMED: "Well done ! You confirmed your email !",
3 | EMAIL_CONFIRMATION_RESENT: "Email send, please check your inbox",
4 | SIGN_UP_SUCCESS: "Your account has been created, please try to sign in.",
5 | USERNAME_CHANGED: "Username has change.",
6 | };
7 |
--------------------------------------------------------------------------------
/client/src/shared/enums/NotificationsServicesTypes.ts:
--------------------------------------------------------------------------------
1 | export enum NotificationsServicesTypes {
2 | EMAIL = "email",
3 | }
4 |
--------------------------------------------------------------------------------
/client/src/shared/enums/ProviderTypes.ts:
--------------------------------------------------------------------------------
1 | export enum ProviderTypes {
2 | MOVIES = "movies_provider",
3 | SERIES = "series_provider",
4 | }
5 |
--------------------------------------------------------------------------------
/client/src/shared/enums/RequestSeriesOptions.ts:
--------------------------------------------------------------------------------
1 | export enum RequestSeriesOptions {
2 | ALL = "All series",
3 | SELECT = "Select...",
4 | }
5 |
--------------------------------------------------------------------------------
/client/src/shared/enums/RequestStatus.ts:
--------------------------------------------------------------------------------
1 | export enum RequestStatus {
2 | APPROVED = "approved",
3 | REFUSED = "refused",
4 | PENDING = "pending",
5 | }
6 |
--------------------------------------------------------------------------------
/client/src/shared/enums/RequestTypes.ts:
--------------------------------------------------------------------------------
1 | export enum RequestTypes {
2 | INCOMING = "incoming",
3 | OUTGOING = "outgoing",
4 | }
5 |
--------------------------------------------------------------------------------
/client/src/shared/enums/Roles.ts:
--------------------------------------------------------------------------------
1 | export enum Roles {
2 | NONE,
3 | ADMIN = 2,
4 | REQUEST = 4,
5 | MANAGE_SETTINGS = 8,
6 | MANAGE_REQUEST = 16,
7 | MANAGE_USERS = 32,
8 | AUTO_APPROVE = 64,
9 | }
10 |
--------------------------------------------------------------------------------
/client/src/shared/enums/SearchFilters.ts:
--------------------------------------------------------------------------------
1 | export enum SearchFilters {
2 | ALL = "all",
3 | MOVIES = "movies",
4 | SERIES = "series",
5 | }
6 |
--------------------------------------------------------------------------------
/client/src/shared/enums/StaticStyles.ts:
--------------------------------------------------------------------------------
1 | const STATIC_STYLES = {
2 | NAVBAR_HEIGHT: 75,
3 | FOOTER_HEIGHT: 70,
4 | SIDEBAR_CLOSED_WIDTH: 75,
5 | SIDEBAR_OPEN_WIDTH: 240,
6 | SIDEBAR_TRANSITION_DURATION: "0.4s",
7 | SEARCH_BAR_HEIGHT: 40,
8 | PAGE_CONTAINER_HEIGHT_DESKTOP_PADDING: 185,
9 | PAGE_CONTAINER_HEIGHT_TABLET_MOBILE_PADDING: 225,
10 |
11 | TABLET_MAX_WIDTH: 1025,
12 | MOBILE_MAX_WIDTH: 580,
13 | };
14 |
15 | export { STATIC_STYLES };
16 |
--------------------------------------------------------------------------------
/client/src/shared/hooks/useConfig.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { useAPI } from "./useAPI";
3 | import { useAlert } from "../contexts/AlertContext";
4 | import { IConfig } from "../models/IConfig";
5 | import { APIRoutes } from "../enums/APIRoutes";
6 | import { ERRORS_MESSAGE } from "../enums/ErrorsMessage";
7 | import { DefaultAsyncCall, IAsyncCall } from "../models/IAsyncCall";
8 |
9 | export const useConfig = () => {
10 | const [config, setConfig] = useState>(
11 | DefaultAsyncCall
12 | );
13 | const { get, patch } = useAPI();
14 | const { pushDanger } = useAlert();
15 |
16 | useEffect(() => {
17 | get(APIRoutes.CONFIG).then((res) => {
18 | if (res.status === 200) {
19 | setConfig(res);
20 | } else {
21 | pushDanger(ERRORS_MESSAGE.UNHANDLED_STATUS(res.status));
22 | }
23 | });
24 |
25 | // eslint-disable-next-line react-hooks/exhaustive-deps
26 | }, []);
27 |
28 | const updateConfig = (payload: Partial) => {
29 | return patch(APIRoutes.CONFIG, payload).then((res) => {
30 | if (res.status === 200) {
31 | setConfig(res);
32 | } else {
33 | pushDanger("Cannot update config");
34 | }
35 | return res;
36 | });
37 | };
38 |
39 | return {
40 | config,
41 | updateConfig,
42 | };
43 | };
44 |
--------------------------------------------------------------------------------
/client/src/shared/hooks/useEpisode.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { DefaultAsyncCall, IAsyncCall } from "../models/IAsyncCall";
3 | import { useAPI } from "./useAPI";
4 | import { APIRoutes } from "../enums/APIRoutes";
5 | import { useAlert } from "../contexts/AlertContext";
6 | import { IEpisode } from "../models/IMedia";
7 |
8 | export const useEpisode = (
9 | seriesId: string,
10 | seasonNumber: number,
11 | episodeNumber: number
12 | ) => {
13 | const [episode, setEpisode] = useState>({
14 | ...DefaultAsyncCall,
15 | isLoading: false,
16 | });
17 | const { get } = useAPI();
18 | const { pushDanger } = useAlert();
19 |
20 | const fetchEpisode = () => {
21 | get(
22 | APIRoutes.GET_EPISODE(seriesId, seasonNumber, episodeNumber)
23 | ).then((res) => {
24 | if (res.status !== 200) {
25 | pushDanger("Cannot get episode");
26 | } else {
27 | setEpisode(res);
28 | }
29 | });
30 | };
31 |
32 | useEffect(() => {
33 | if (!episode.isLoading) {
34 | setEpisode(DefaultAsyncCall);
35 | }
36 | // eslint-disable-next-line react-hooks/exhaustive-deps
37 | }, [episodeNumber]);
38 |
39 | useEffect(() => {
40 | if (episode.isLoading) {
41 | fetchEpisode();
42 | }
43 |
44 | // eslint-disable-next-line react-hooks/exhaustive-deps
45 | }, [episode.isLoading]);
46 |
47 | return episode;
48 | };
49 |
--------------------------------------------------------------------------------
/client/src/shared/hooks/useImage.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | export const useImage = (src: string | null | undefined) => {
4 | const [loaded, setLoaded] = useState(false);
5 | const [error, setError] = useState(false);
6 |
7 | useEffect(() => {
8 | if (src) {
9 | const img = new Image();
10 | img.src = src;
11 | img.onload = () => setLoaded(true);
12 | img.onerror = () => setError(true);
13 | }
14 | }, [src]);
15 |
16 | return {
17 | loaded,
18 | error,
19 | };
20 | };
21 |
--------------------------------------------------------------------------------
/client/src/shared/hooks/useJobs.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { DefaultAsyncCall, IAsyncCall } from "../models/IAsyncCall";
3 | import { IJob, JobActionsEnum } from "../models/IJob";
4 | import { useAPI } from "./useAPI";
5 | import { APIRoutes } from "../enums/APIRoutes";
6 | import { useAlert } from "../contexts/AlertContext";
7 |
8 | export const useJobs = () => {
9 | const [jobs, setJobs] = useState>(DefaultAsyncCall);
10 | const { get, patch } = useAPI();
11 | const { pushInfo, pushDanger } = useAlert();
12 |
13 | useEffect(() => {
14 | get(APIRoutes.JOBS()).then((res) => setJobs(res));
15 | // eslint-disable-next-line react-hooks/exhaustive-deps
16 | }, []);
17 |
18 | const patchJob = (id: string, action: JobActionsEnum) => {
19 | patch(APIRoutes.JOBS(id), { action: action }).then((res) => {
20 | if (res.status === 200) {
21 | pushInfo("Job state: " + action);
22 | const jobsTmp = jobs.data;
23 | if (jobsTmp && res.data) {
24 | const index = jobsTmp.findIndex((j) => j.id === id);
25 | if (index !== -1) {
26 | jobsTmp.splice(index, 1, res.data);
27 | setJobs({ ...jobs, data: [...jobsTmp] });
28 | }
29 | }
30 | } else {
31 | pushDanger("Cannot patch job");
32 | }
33 | return res;
34 | });
35 | };
36 |
37 | return {
38 | jobs,
39 | patchJob,
40 | };
41 | };
42 |
--------------------------------------------------------------------------------
/client/src/shared/hooks/useMedia.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { DefaultAsyncCall, IAsyncCall } from "../models/IAsyncCall";
3 | import { useAPI } from "./useAPI";
4 | import { APIRoutes } from "../enums/APIRoutes";
5 | import { IMedia } from "../models/IMedia";
6 | import { MediaTypes } from "../enums/MediaTypes";
7 |
8 | export const useMedia = (type: MediaTypes, mediaId: string) => {
9 | const [media, setMedia] = useState>(
10 | DefaultAsyncCall
11 | );
12 | const { get } = useAPI();
13 |
14 | const fetchMedia = () => {
15 | get(
16 | type === MediaTypes.MOVIES
17 | ? APIRoutes.GET_MOVIE(mediaId)
18 | : APIRoutes.GET_SERIES(mediaId)
19 | ).then((res) => {
20 | if (res.status === 200) {
21 | setMedia(res);
22 | }
23 | });
24 | };
25 |
26 | useEffect(() => {
27 | if (!media.isLoading) {
28 | setMedia(DefaultAsyncCall);
29 | }
30 | // eslint-disable-next-line react-hooks/exhaustive-deps
31 | }, [mediaId]);
32 |
33 | useEffect(() => {
34 | if (media.isLoading) {
35 | fetchMedia();
36 | }
37 | // eslint-disable-next-line react-hooks/exhaustive-deps
38 | }, [media.isLoading]);
39 |
40 | return media;
41 | };
42 |
--------------------------------------------------------------------------------
/client/src/shared/hooks/useMediaServerLibrariesService.ts:
--------------------------------------------------------------------------------
1 | import { useAPI } from "./useAPI";
2 | import { APIRoutes } from "../enums/APIRoutes";
3 | import { useAlert } from "../contexts/AlertContext";
4 | import { IMediaServerLibrary } from "../models/IMediaServerConfig";
5 | import { useEffect, useState } from "react";
6 | import { DefaultAsyncCall, IAsyncCall } from "../models/IAsyncCall";
7 | import { MediaServerTypes } from "../enums/MediaServersTypes";
8 |
9 | export const useMediaServerLibraries = (
10 | mediaServerType: MediaServerTypes,
11 | configId: string
12 | ) => {
13 | const { get, patch } = useAPI();
14 | const { pushSuccess, pushDanger } = useAlert();
15 | const [libraries, setLibraries] = useState<
16 | IAsyncCall
17 | >(DefaultAsyncCall);
18 |
19 | useEffect(() => {
20 | get(
21 | APIRoutes.GET_MEDIA_SERVERS_LIBRARIES(mediaServerType, configId)
22 | ).then((res) => {
23 | if (res.status === 200) {
24 | setLibraries(res);
25 | } else {
26 | setLibraries({ ...DefaultAsyncCall, isLoading: false });
27 | pushDanger("Cannot sync libraries");
28 | }
29 | });
30 | // eslint-disable-next-line react-hooks/exhaustive-deps
31 | }, []);
32 |
33 | const updateLibrary = (library: IMediaServerLibrary) => {
34 | library.enabled = !library.enabled;
35 | patch(APIRoutes.GET_MEDIA_SERVERS_LIBRARIES(mediaServerType, configId), [
36 | library,
37 | ]).then((res) => {
38 | if (res.status === 200) {
39 | const libTmp = libraries.data;
40 | if (libTmp) {
41 | const index = libTmp.findIndex(
42 | (l) => l.libraryId === library.libraryId
43 | );
44 | if (index !== -1) {
45 | libTmp.splice(index, 1, library);
46 | setLibraries({ ...libraries, data: [...libTmp] });
47 | }
48 | pushSuccess("Library " + library.name + " updated");
49 | }
50 | }
51 | });
52 | };
53 |
54 | return { libraries, updateLibrary };
55 | };
56 |
--------------------------------------------------------------------------------
/client/src/shared/hooks/useMovie.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { DefaultAsyncCall, IAsyncCall } from "../models/IAsyncCall";
3 | import { useAPI } from "./useAPI";
4 | import { APIRoutes } from "../enums/APIRoutes";
5 | import { useAlert } from "../contexts/AlertContext";
6 | import { IMovie } from "../models/IMedia";
7 |
8 | export const useMovie = (movieId: string) => {
9 | const [movie, setMovie] = useState>(
10 | DefaultAsyncCall
11 | );
12 | const { get } = useAPI();
13 | const { pushDanger } = useAlert();
14 |
15 | const fetchMovie = () => {
16 | get(APIRoutes.GET_MOVIE(movieId)).then((res) => {
17 | if (res.status !== 200) {
18 | pushDanger("Cannot get movie");
19 | setMovie({ ...DefaultAsyncCall, isLoading: false });
20 | } else {
21 | setMovie(res);
22 | }
23 | });
24 | };
25 |
26 | useEffect(() => {
27 | if (!movie.isLoading) {
28 | setMovie(DefaultAsyncCall);
29 | }
30 | // eslint-disable-next-line react-hooks/exhaustive-deps
31 | }, [movieId]);
32 |
33 | useEffect(() => {
34 | if (movie.isLoading) {
35 | fetchMovie();
36 | }
37 | // eslint-disable-next-line react-hooks/exhaustive-deps
38 | }, [movie.isLoading]);
39 |
40 | return movie;
41 | };
42 |
--------------------------------------------------------------------------------
/client/src/shared/hooks/useOutsideAlerter.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 |
3 | const useOutsideAlerter = (refs: any[], onOutsideClick: () => any) => {
4 | useEffect(() => {
5 | function handleClickOutside(event: any) {
6 | let isOutside = true;
7 | refs.forEach((ref) => {
8 | isOutside =
9 | isOutside && ref.current && !ref.current.contains(event.target);
10 | });
11 | if (isOutside) {
12 | onOutsideClick();
13 | }
14 | }
15 | // Bind the event listener
16 | document.addEventListener("mousedown", handleClickOutside);
17 | return () => {
18 | // Unbind the event listener on clean up
19 | document.removeEventListener("mousedown", handleClickOutside);
20 | };
21 | // eslint-disable-next-line react-hooks/exhaustive-deps
22 | }, [refs]);
23 | };
24 |
25 | export { useOutsideAlerter };
26 |
--------------------------------------------------------------------------------
/client/src/shared/hooks/usePlexServers.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { DefaultAsyncCall, IAsyncCall } from "../models/IAsyncCall";
3 | import { useAPI } from "./useAPI";
4 | import { APIRoutes } from "../enums/APIRoutes";
5 | import { IMediaServerConfig } from "../models/IMediaServerConfig";
6 |
7 | export const usePlexServers = () => {
8 | const [plexServers, setPlexServers] = useState<
9 | IAsyncCall
10 | >(DefaultAsyncCall);
11 | const { get } = useAPI();
12 |
13 | useEffect(() => {
14 | get(APIRoutes.GET_PLEX_SERVERS).then((res) =>
15 | setPlexServers(res)
16 | );
17 | // eslint-disable-next-line react-hooks/exhaustive-deps
18 | }, []);
19 |
20 | return plexServers;
21 | };
22 |
--------------------------------------------------------------------------------
/client/src/shared/hooks/useRequestMedia.tsx:
--------------------------------------------------------------------------------
1 | import { useAPI } from "./useAPI";
2 | import { APIRoutes } from "../enums/APIRoutes";
3 | import { IMovieRequest, ISeriesRequest } from "../models/IMediaRequest";
4 | import {
5 | IMovieRequestCreate,
6 | ISeriesRequestCreate,
7 | } from "../models/IRequestCreate";
8 | import { useAlert } from "../contexts/AlertContext";
9 |
10 | export const useRequestMedia = () => {
11 | const { post } = useAPI();
12 | const { pushSuccess, pushDanger } = useAlert();
13 |
14 | const requestMovie = (request: IMovieRequestCreate) => {
15 | return post(APIRoutes.CREATE_REQUEST_MOVIE, request).then(
16 | (res) => {
17 | if (res.status === 201) {
18 | pushSuccess("Movie requested");
19 | } else if (res.status === 409) {
20 | pushDanger("Movie already requested");
21 | } else {
22 | pushDanger("Cannot request movie");
23 | }
24 | return res;
25 | }
26 | );
27 | };
28 |
29 | const requestSeries = (request: ISeriesRequestCreate) => {
30 | return post(APIRoutes.CREATE_REQUEST_SERIES, request).then(
31 | (res) => {
32 | if (res.status === 201) {
33 | pushSuccess("Series requested");
34 | } else if (res.status === 409) {
35 | pushDanger("Series already requested");
36 | } else {
37 | pushDanger("Cannot request series");
38 | }
39 | return res;
40 | }
41 | );
42 | };
43 |
44 | return {
45 | requestMovie,
46 | requestSeries,
47 | };
48 | };
49 |
--------------------------------------------------------------------------------
/client/src/shared/hooks/useRequests.ts:
--------------------------------------------------------------------------------
1 | import { RequestTypes } from "../enums/RequestTypes";
2 | import { IMediaRequest } from "../models/IMediaRequest";
3 | import { RequestStatus } from "../enums/RequestStatus";
4 | import { APIRoutes } from "../enums/APIRoutes";
5 | import { usePagination } from "./usePagination";
6 |
7 | export const useRequests = (requestsType: RequestTypes) => {
8 | const {
9 | data,
10 | loadPrev,
11 | loadNext,
12 | updateData,
13 | deleteData,
14 | sortData,
15 | } = usePagination(APIRoutes.GET_REQUESTS(requestsType), true);
16 |
17 | const updateRequest = (requestId: number, requestStatus: RequestStatus) => {
18 | const findRequest = (r: IMediaRequest) => {
19 | return r.id === requestId;
20 | };
21 | const updateRequest = (r: IMediaRequest) => {
22 | r.status = requestStatus;
23 | };
24 | if (requestsType === RequestTypes.INCOMING) {
25 | updateData(findRequest, updateRequest);
26 | }
27 | };
28 |
29 | const deleteRequest = (requestId: number) => {
30 | const findRequest = (r: IMediaRequest) => {
31 | return r.id === requestId;
32 | };
33 | deleteData(findRequest);
34 | };
35 |
36 | const sortRequests = (
37 | compare: (first: IMediaRequest, second: IMediaRequest) => number
38 | ) => {
39 | sortData(compare);
40 | };
41 |
42 | return {
43 | data,
44 | loadPrev,
45 | loadNext,
46 | updateRequest,
47 | deleteRequest,
48 | sortRequests,
49 | };
50 | };
51 |
--------------------------------------------------------------------------------
/client/src/shared/hooks/useRoleGuard.ts:
--------------------------------------------------------------------------------
1 | import { useSession } from "../contexts/SessionContext";
2 | import { useHistory } from "react-router-dom";
3 | import { checkRole } from "../../utils/roles";
4 | import { Roles } from "../enums/Roles";
5 | import { routes } from "../../router/routes";
6 | import { useEffect } from "react";
7 |
8 | export const useRoleGuard = (neededRoles: Roles[], hasOne?: boolean) => {
9 | const {
10 | session: { user },
11 | } = useSession();
12 |
13 | const history = useHistory();
14 |
15 | useEffect(() => {
16 | if (user && !checkRole(user.roles, neededRoles, hasOne)) {
17 | history.push(routes.HOME.url);
18 | }
19 | // eslint-disable-next-line react-hooks/exhaustive-deps
20 | }, [user]);
21 | };
22 |
--------------------------------------------------------------------------------
/client/src/shared/hooks/useScrollPosition.tsx:
--------------------------------------------------------------------------------
1 | import { useRef, useLayoutEffect } from "react";
2 |
3 | const isBrowser = typeof window !== `undefined`;
4 |
5 | function getScrollPosition({ element, useWindow }: any) {
6 | if (!isBrowser) return { x: 0, y: 0 };
7 |
8 | const target = element ? element.current : document.body;
9 | const position = target.getBoundingClientRect();
10 |
11 | return useWindow
12 | ? { x: window.scrollX, y: window.scrollY }
13 | : { x: position.left, y: position.top };
14 | }
15 |
16 | export function useScrollPosition(
17 | effect: ({ prevPos, currPos }: any) => void,
18 | deps: any,
19 | element: boolean,
20 | useWindow: boolean,
21 | wait: number
22 | ) {
23 | const position = useRef(getScrollPosition({ useWindow }));
24 |
25 | let throttleTimeout: any = null;
26 |
27 | const callBack = () => {
28 | const currPos = getScrollPosition({ element, useWindow });
29 | effect({ prevPos: position.current, currPos });
30 | position.current = currPos;
31 | throttleTimeout = null;
32 | };
33 |
34 | useLayoutEffect(() => {
35 | const handleScroll = () => {
36 | if (wait) {
37 | if (throttleTimeout === null) {
38 | throttleTimeout = setTimeout(callBack, wait);
39 | }
40 | } else {
41 | callBack();
42 | }
43 | };
44 |
45 | window.addEventListener("scroll", handleScroll);
46 |
47 | return () => window.removeEventListener("scroll", handleScroll);
48 | }, deps);
49 |
50 | return position;
51 | }
52 |
--------------------------------------------------------------------------------
/client/src/shared/hooks/useSearchMedia.tsx:
--------------------------------------------------------------------------------
1 | import { useAPI } from "./useAPI";
2 | import { APIRoutes } from "../enums/APIRoutes";
3 | import { useAlert } from "../contexts/AlertContext";
4 | import { IMedia } from "../models/IMedia";
5 | import { SearchFilters } from "../enums/SearchFilters";
6 | import { IPaginated } from "../models/IPaginated";
7 | import { useEffect, useState } from "react";
8 | import { DefaultAsyncCall, IAsyncCall } from "../models/IAsyncCall";
9 |
10 | export const useSearchMedia = (
11 | title: string,
12 | type: SearchFilters,
13 | page: number
14 | ) => {
15 | const [media, setMedia] = useState | null>>(
16 | DefaultAsyncCall
17 | );
18 | const { get } = useAPI();
19 | const { pushDanger } = useAlert();
20 |
21 | const fetchMedia = () => {
22 | get>(
23 | APIRoutes.GET_MEDIA(title, page, type !== SearchFilters.ALL ? type : null)
24 | ).then((res) => {
25 | if (res.status !== 200) {
26 | pushDanger("Cannot get list of media");
27 | } else {
28 | setMedia(res);
29 | }
30 | });
31 | };
32 |
33 | useEffect(() => {
34 | // @ts-ignore
35 | const timer = setTimeout(() => {
36 | setMedia(DefaultAsyncCall);
37 | }, 800);
38 | return () => clearTimeout(timer);
39 | // eslint-disable-next-line react-hooks/exhaustive-deps
40 | }, [title]);
41 |
42 | useEffect(() => {
43 | if (!media.isLoading) {
44 | setMedia(DefaultAsyncCall);
45 | }
46 | // eslint-disable-next-line react-hooks/exhaustive-deps
47 | }, [type, page]);
48 |
49 | useEffect(() => {
50 | if (media.isLoading) {
51 | fetchMedia();
52 | }
53 | // eslint-disable-next-line react-hooks/exhaustive-deps
54 | }, [media]);
55 |
56 | return media;
57 | };
58 |
--------------------------------------------------------------------------------
/client/src/shared/hooks/useSeason.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { DefaultAsyncCall, IAsyncCall } from "../models/IAsyncCall";
3 | import { useAPI } from "./useAPI";
4 | import { APIRoutes } from "../enums/APIRoutes";
5 | import { ISeason } from "../models/IMedia";
6 | import { useAlert } from "../contexts/AlertContext";
7 |
8 | export const useSeason = (seriesId: string, seasonNumber: number) => {
9 | const [season, setSeason] = useState>({
10 | ...DefaultAsyncCall,
11 | isLoading: false,
12 | });
13 | const { get } = useAPI();
14 | const { pushDanger } = useAlert();
15 |
16 | const fetchSeason = () => {
17 | get(APIRoutes.GET_SEASON(seriesId, seasonNumber)).then((res) => {
18 | if (res.status !== 200) {
19 | pushDanger("Cannot get season");
20 | } else {
21 | setSeason(res);
22 | }
23 | });
24 | };
25 |
26 | useEffect(() => {
27 | if (!season.isLoading) {
28 | setSeason(DefaultAsyncCall);
29 | }
30 | // eslint-disable-next-line react-hooks/exhaustive-deps
31 | }, [seasonNumber]);
32 |
33 | useEffect(() => {
34 | if (season.isLoading) {
35 | fetchSeason();
36 | }
37 |
38 | // eslint-disable-next-line react-hooks/exhaustive-deps
39 | }, [season.isLoading]);
40 |
41 | return season;
42 | };
43 |
--------------------------------------------------------------------------------
/client/src/shared/hooks/useSeries.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { DefaultAsyncCall, IAsyncCall } from "../models/IAsyncCall";
3 | import { useAPI } from "./useAPI";
4 | import { APIRoutes } from "../enums/APIRoutes";
5 | import { ISeries } from "../models/IMedia";
6 | import { useAlert } from "../contexts/AlertContext";
7 |
8 | export const useSeries = (seriesId: string) => {
9 | const [series, setSeries] = useState>(
10 | DefaultAsyncCall
11 | );
12 | const { get } = useAPI();
13 | const { pushDanger } = useAlert();
14 |
15 | const fetchSeries = () => {
16 | get(APIRoutes.GET_SERIES(seriesId)).then((res) => {
17 | if (res.status !== 200) {
18 | pushDanger("Cannot get series");
19 | setSeries({ ...DefaultAsyncCall, isLoading: false });
20 | } else {
21 | setSeries(res);
22 | }
23 | });
24 | };
25 |
26 | useEffect(() => {
27 | if (!series.isLoading) {
28 | setSeries(DefaultAsyncCall);
29 | }
30 | // eslint-disable-next-line react-hooks/exhaustive-deps
31 | }, [seriesId]);
32 |
33 | useEffect(() => {
34 | if (series.isLoading) {
35 | fetchSeries();
36 | }
37 | // eslint-disable-next-line react-hooks/exhaustive-deps
38 | }, [series.isLoading]);
39 |
40 | return series;
41 | };
42 |
--------------------------------------------------------------------------------
/client/src/shared/hooks/useUser.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { DefaultAsyncCall, IAsyncCall } from "../models/IAsyncCall";
3 | import { IUser } from "../models/IUser";
4 | import { useSession } from "../contexts/SessionContext";
5 | import { useUserService } from "../toRefactor/useUserService";
6 | import { checkRole } from "../../utils/roles";
7 | import { Roles } from "../enums/Roles";
8 | import { useHistory } from "react-router-dom";
9 | import { routes } from "../../router/routes";
10 |
11 | export const useUser = (id?: string) => {
12 | const [currentUser, setCurrentUser] = useState>(
13 | DefaultAsyncCall
14 | );
15 | const {
16 | session: { user },
17 | } = useSession();
18 | const { getUserById } = useUserService();
19 | const history = useHistory();
20 |
21 | useEffect(() => {
22 | if (
23 | id &&
24 | user &&
25 | id !== user.id.toString(10) &&
26 | checkRole(user.roles, [Roles.MANAGE_USERS, Roles.ADMIN], true)
27 | ) {
28 | getUserById(parseInt(id, 10)).then((res) => {
29 | if (res.status === 200) {
30 | setCurrentUser(res);
31 | }
32 | });
33 | } else if (user && id === undefined) {
34 | setCurrentUser({ isLoading: false, data: user, status: 200 });
35 | } else {
36 | history.push(routes.PROFILE.url());
37 | }
38 |
39 | // eslint-disable-next-line react-hooks/exhaustive-deps
40 | }, [id, user]);
41 |
42 | const updateUser = (user: IUser) => {
43 | setCurrentUser({ ...currentUser, data: user });
44 | };
45 |
46 | return { currentUser, updateUser };
47 | };
48 |
--------------------------------------------------------------------------------
/client/src/shared/hooks/useWindowSize.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { STATIC_STYLES } from "../enums/StaticStyles";
3 |
4 | const getWindowSize = () => {
5 | const { innerWidth: width, innerHeight: height } = window;
6 | return {
7 | width,
8 | height,
9 | };
10 | };
11 |
12 | const useWindowSize = () => {
13 | const [windowSize, setWindowSize] = useState(getWindowSize());
14 |
15 | useEffect(() => {
16 | const handleResize = () => {
17 | const size = getWindowSize();
18 | setWindowSize(size);
19 | };
20 |
21 | window.addEventListener("resize", handleResize);
22 | return () => window.removeEventListener("resize", handleResize);
23 | }, []);
24 |
25 | const getHeightMinusNavbar = () =>
26 | windowSize.height - STATIC_STYLES.NAVBAR_HEIGHT;
27 |
28 | return {
29 | ...windowSize,
30 | getHeightMinusNavbar,
31 | };
32 | };
33 |
34 | export { useWindowSize };
35 |
--------------------------------------------------------------------------------
/client/src/shared/models/IAsyncCall.ts:
--------------------------------------------------------------------------------
1 | import { AxiosError, AxiosResponse } from "axios";
2 |
3 | export interface IAsyncCall {
4 | data: T | null;
5 | isLoading: boolean;
6 | status: number;
7 | }
8 |
9 | export const DefaultAsyncCall: IAsyncCall = {
10 | data: null,
11 | isLoading: true,
12 | status: -1,
13 | };
14 |
15 | export function createSuccessAsyncCall(
16 | response: AxiosResponse
17 | ): IAsyncCall {
18 | return {
19 | data: response.data,
20 | isLoading: false,
21 | status: response.status,
22 | };
23 | }
24 |
25 | const getErrorStatus = (error: AxiosError) => {
26 | return error.response ? error.response.status : 500;
27 | };
28 |
29 | export function createErrorAsyncCall(error: AxiosError): IAsyncCall {
30 | return {
31 | data: null,
32 | isLoading: false,
33 | status: getErrorStatus(error),
34 | };
35 | }
36 |
--------------------------------------------------------------------------------
/client/src/shared/models/IAsyncData.ts:
--------------------------------------------------------------------------------
1 | export interface IAsyncData {
2 | data: T | null;
3 | isLoading: boolean;
4 | }
5 |
6 | export const DefaultAsyncData: IAsyncData = {
7 | data: null,
8 | isLoading: true,
9 | };
10 |
--------------------------------------------------------------------------------
/client/src/shared/models/IConfig.ts:
--------------------------------------------------------------------------------
1 | import { LogLevels } from "../enums/LogLevels";
2 |
3 | export interface IConfig {
4 | logLevel: LogLevels;
5 | defaultRoles: number;
6 | }
7 |
--------------------------------------------------------------------------------
/client/src/shared/models/IDecodedToken.ts:
--------------------------------------------------------------------------------
1 | import { Roles } from "../enums/Roles";
2 |
3 | export interface IDecodedToken {
4 | readonly id: string;
5 | readonly exp: number;
6 | readonly username: string;
7 | readonly avatar: string;
8 | readonly roles: Roles;
9 | }
10 |
--------------------------------------------------------------------------------
/client/src/shared/models/IEmailConfig.ts:
--------------------------------------------------------------------------------
1 | export interface IEmailConfig {
2 | enabled: boolean;
3 | smtpPort: number;
4 | smtpHost: string;
5 | smtpUser: string;
6 | smtpPassword: string;
7 | senderAddress: string;
8 | senderName: string;
9 | ssl: boolean;
10 | }
11 |
--------------------------------------------------------------------------------
/client/src/shared/models/IEncodedToken.ts:
--------------------------------------------------------------------------------
1 | export interface IEncodedToken {
2 | readonly token_type: string;
3 | readonly access_token: string;
4 | }
5 |
--------------------------------------------------------------------------------
/client/src/shared/models/IJob.ts:
--------------------------------------------------------------------------------
1 | export interface IJob {
2 | id: string;
3 | name: string;
4 | nextRunTime?: Date;
5 | }
6 |
7 | export enum JobActionsEnum {
8 | RUN = "run",
9 | PAUSE = "pause",
10 | RESUME = "resume",
11 | }
12 |
--------------------------------------------------------------------------------
/client/src/shared/models/ILog.ts:
--------------------------------------------------------------------------------
1 | import { LogLevels } from "../enums/LogLevels";
2 |
3 | export interface ILog {
4 | time: string;
5 | level: LogLevels;
6 | process: string;
7 | message: string;
8 | }
9 |
--------------------------------------------------------------------------------
/client/src/shared/models/IMediaProviderConfig.ts:
--------------------------------------------------------------------------------
1 | import { IProviderSettingsBase } from "./IProviderSettingsBase";
2 | import { ProviderTypes } from "../enums/ProviderTypes";
3 |
4 | export interface IMediaProviderConfig extends IProviderSettingsBase {
5 | id: string;
6 | name: string;
7 | enabled: boolean;
8 | isDefault: boolean;
9 | providerType: ProviderTypes;
10 | rootFolder: string;
11 | qualityProfileId: number;
12 | version: number;
13 | }
14 |
--------------------------------------------------------------------------------
/client/src/shared/models/IMediaServerConfig.ts:
--------------------------------------------------------------------------------
1 | import { IProviderSettingsBase } from "./IProviderSettingsBase";
2 |
3 | export interface IMediaServerConfig extends IProviderSettingsBase {
4 | id: string;
5 | name: string;
6 | enabled: boolean;
7 | serverName: string;
8 | serverId: string;
9 | libraries: IMediaServerLibrary[];
10 | }
11 |
12 | export interface IMediaServerLibrary {
13 | libraryId: number;
14 | name: string;
15 | enabled: boolean;
16 | }
17 |
--------------------------------------------------------------------------------
/client/src/shared/models/INotificationsConfig.ts:
--------------------------------------------------------------------------------
1 | export interface INotificationsConfig {
2 | enabled: boolean;
3 | settings: any;
4 | }
5 |
--------------------------------------------------------------------------------
/client/src/shared/models/IPaginated.ts:
--------------------------------------------------------------------------------
1 | export interface IPaginated {
2 | page: number;
3 | totalPages: number;
4 | totalResults: number;
5 | results: T[];
6 | }
7 |
--------------------------------------------------------------------------------
/client/src/shared/models/IProviderSettingsBase.ts:
--------------------------------------------------------------------------------
1 | export interface IProviderSettingsBase {
2 | host: string;
3 | port: number | string | null;
4 | ssl: boolean;
5 | apiKey: string;
6 | version: number;
7 | }
8 |
--------------------------------------------------------------------------------
/client/src/shared/models/IQualityProfile.ts:
--------------------------------------------------------------------------------
1 | export interface IQualityProfile {
2 | readonly id: number;
3 | readonly name: string;
4 | }
5 |
--------------------------------------------------------------------------------
/client/src/shared/models/IRadarrConfig.ts:
--------------------------------------------------------------------------------
1 | import { IProviderSettingsBase } from "./IProviderSettingsBase";
2 | import { ProviderTypes } from "../enums/ProviderTypes";
3 |
4 | export interface IRadarrConfig extends IProviderSettingsBase {
5 | readonly id: string;
6 | readonly name: string;
7 | readonly enabled: boolean;
8 | readonly isDefault: boolean;
9 | readonly providerType: ProviderTypes;
10 | readonly rootFolder: string;
11 | readonly qualityProfileId: number;
12 | readonly version: number;
13 | }
14 |
--------------------------------------------------------------------------------
/client/src/shared/models/IRadarrInstanceInfo.ts:
--------------------------------------------------------------------------------
1 | import { IQualityProfile } from "./IQualityProfile";
2 |
3 | export interface IRadarrInstanceInfo {
4 | readonly rootFolders: string[];
5 | readonly qualityProfiles: IQualityProfile[];
6 | }
7 |
--------------------------------------------------------------------------------
/client/src/shared/models/IRequestCreate.ts:
--------------------------------------------------------------------------------
1 | export interface IRequestCreate {
2 | tmdbId: string;
3 | }
4 |
5 | export interface IMovieRequestCreate extends IRequestCreate {}
6 |
7 | export interface ISeriesRequestCreate extends IRequestCreate {
8 | seasons: {
9 | seasonNumber: number;
10 | episodes: {
11 | episodeNumber: number;
12 | }[];
13 | }[];
14 | }
15 |
16 | export const isMovieRequestCreate = (arg: any): arg is IMovieRequestCreate => {
17 | return arg && arg.tmdbId && typeof arg.tmdbId == "number";
18 | };
19 |
20 | export const isSeriesRequestCreate = (
21 | arg: any
22 | ): arg is ISeriesRequestCreate => {
23 | return arg && arg.tvdbId && typeof arg.tvdbId == "number";
24 | };
25 |
--------------------------------------------------------------------------------
/client/src/shared/models/IRequestSeriesOptions.ts:
--------------------------------------------------------------------------------
1 | export interface IRequestSeriesOptions {
2 | seasons: {
3 | seasonNumber: number;
4 | episodes: {
5 | episodeNumber: number;
6 | }[];
7 | }[];
8 | }
9 |
--------------------------------------------------------------------------------
/client/src/shared/models/IRequestUpdate.ts:
--------------------------------------------------------------------------------
1 | import { RequestStatus } from "../enums/RequestStatus";
2 |
3 | export interface IRequestUpdate {
4 | readonly status: RequestStatus;
5 | readonly providerId: string | null;
6 | }
7 |
--------------------------------------------------------------------------------
/client/src/shared/models/ISession.ts:
--------------------------------------------------------------------------------
1 | import { IUser } from "./IUser";
2 |
3 | export interface ISession {
4 | isAuthenticated: boolean;
5 | user: IUser | null;
6 | isLoading: boolean;
7 | }
8 |
9 | export const SessionDefaultImpl: ISession = {
10 | isAuthenticated: false,
11 | user: null,
12 | isLoading: true,
13 | };
14 |
--------------------------------------------------------------------------------
/client/src/shared/models/ISignInFormData.ts:
--------------------------------------------------------------------------------
1 | export interface ISignInFormData {
2 | readonly username: string;
3 | readonly password: string;
4 | }
5 |
--------------------------------------------------------------------------------
/client/src/shared/models/ISignUpFormData.ts:
--------------------------------------------------------------------------------
1 | export interface ISignUpFormData {
2 | username: string;
3 | password: string;
4 | passwordConfirmation: string;
5 | }
6 |
--------------------------------------------------------------------------------
/client/src/shared/models/ISonarrConfig.ts:
--------------------------------------------------------------------------------
1 | import { ProviderTypes } from "../enums/ProviderTypes";
2 | import { IProviderSettingsBase } from "./IProviderSettingsBase";
3 |
4 | export interface ISonarrConfig extends IProviderSettingsBase {
5 | readonly id: string;
6 | readonly name: string;
7 | readonly enabled: boolean;
8 | readonly isDefault: boolean;
9 | readonly providerType: ProviderTypes;
10 | readonly rootFolder: string;
11 | readonly animeRootFolder: string | null;
12 | readonly qualityProfileId: number;
13 | readonly animeQualityProfileId: string | null;
14 | readonly languageProfileId: number | null;
15 | readonly animeLanguageProfileId: number | null;
16 | readonly version: number;
17 | }
18 |
--------------------------------------------------------------------------------
/client/src/shared/models/ISonarrInstanceInfo.ts:
--------------------------------------------------------------------------------
1 | import { IQualityProfile } from "./IQualityProfile";
2 |
3 | interface ILanguageProfile {
4 | readonly id: number;
5 | readonly name: string;
6 | }
7 |
8 | export interface ISonarrInstanceInfo {
9 | readonly rootFolders: string[];
10 | readonly qualityProfiles: IQualityProfile[];
11 | readonly languageProfiles: ILanguageProfile[];
12 | }
13 |
--------------------------------------------------------------------------------
/client/src/shared/models/IUser.ts:
--------------------------------------------------------------------------------
1 | export interface IUser {
2 | id: number;
3 | username: string;
4 | email: string;
5 | avatar: string;
6 | roles: number;
7 | confirmed: boolean;
8 | }
9 |
10 | export const isUser = (arg: any): arg is IUser => {
11 | return (
12 | arg &&
13 | arg.id &&
14 | typeof arg.id == "number" &&
15 | arg.username &&
16 | typeof arg.username == "string" &&
17 | arg.email &&
18 | typeof arg.email == "string" &&
19 | arg.avatar &&
20 | typeof arg.avatar == "string" &&
21 | arg.roles &&
22 | typeof arg.roles == "number"
23 | );
24 | };
25 |
--------------------------------------------------------------------------------
/client/src/styled.d.ts:
--------------------------------------------------------------------------------
1 | import "styled-components";
2 |
3 | declare module "styled-components" {
4 | export interface DefaultTheme {
5 | bgColor: string;
6 | color: string;
7 | primary: string;
8 | primaryLight: string;
9 | primaryLighter: string;
10 | secondary: string;
11 | success: string;
12 | danger: string;
13 | dangerLight: string;
14 | warning: string;
15 | warningLight: string;
16 | black: string;
17 | white: string;
18 | grey: string;
19 | plex: string;
20 | movie: string;
21 | series: string;
22 | season: string;
23 | episode: string;
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/client/src/utils/media-utils.ts:
--------------------------------------------------------------------------------
1 | export const minToHoursMinutes = (min: number) => {
2 | const hours = Math.floor(min / 60);
3 | const minutes = Math.ceil(min - hours * 60);
4 | return hours + "h " + minutes + "m";
5 | };
6 |
7 | export const getColorRating = (rating: any) => "hsl(" + rating + ", 100%, 50%)";
8 |
9 | export const getRatingPercentage = (rating: any) => rating * 10;
10 |
11 | export const getActorInitial = (name: string) => {
12 | const splitedName = name.split(" ");
13 | if (splitedName.length >= 2) {
14 | return splitedName[0][0] + " " + splitedName[splitedName.length - 1][0];
15 | } else if (splitedName.length === 1) {
16 | return splitedName[0][0];
17 | } else {
18 | return ".";
19 | }
20 | };
21 |
--------------------------------------------------------------------------------
/client/src/utils/objects.ts:
--------------------------------------------------------------------------------
1 | export const isEmptyObject = (obj: object) => {
2 | for (let key in obj) return false;
3 | return true;
4 | };
5 |
--------------------------------------------------------------------------------
/client/src/utils/roles.ts:
--------------------------------------------------------------------------------
1 | import { Roles } from "../shared/enums/Roles";
2 |
3 | export const checkRole = (
4 | userRole: Roles,
5 | roles: Roles[],
6 | hasOne?: boolean
7 | ) => {
8 | if (userRole === Roles.ADMIN) {
9 | return true;
10 | }
11 |
12 | if (!hasOne) {
13 | return roles.every((r) => userRole & r);
14 | } else {
15 | return roles.some((r) => userRole & r);
16 | }
17 | };
18 |
19 | export const calcRolesSumExceptAdmin = () => {
20 | let sum = 0;
21 | Object.values(Roles).forEach((r) => {
22 | if (typeof r == "number" && r > Roles.ADMIN) {
23 | sum += r;
24 | }
25 | });
26 | return sum;
27 | };
28 |
--------------------------------------------------------------------------------
/client/src/utils/strings.ts:
--------------------------------------------------------------------------------
1 | const TITLE_SIZES = {
2 | one: "36px",
3 | two: "28px",
4 | };
5 |
6 | const isEmpty = (s: string) => {
7 | return s.replace(/\s/g, "").length === 0;
8 | };
9 |
10 | const withoutDash = (s: string) => {
11 | return s.replace(/-/g, "");
12 | };
13 |
14 | const isArrayOfStrings = (value: any): boolean => {
15 | return (
16 | Array.isArray(value) && value.every((item) => typeof item === "string")
17 | );
18 | };
19 |
20 | export const uppercaseFirstLetter = (s: string) => {
21 | return s.length > 0 ? s.charAt(0).toUpperCase() + s.slice(1) : s;
22 | };
23 |
24 | export { isEmpty, withoutDash, TITLE_SIZES, isArrayOfStrings };
25 |
--------------------------------------------------------------------------------
/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "noEmit": true,
20 | "jsx": "react"
21 | },
22 | "include": [
23 | "src"
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "cheddarr"
3 | version = "0.1.0"
4 | description = "Hub for media server architecture"
5 | authors = ["Jeroli-co"]
6 | license = "GNU GPL v3"
7 |
8 |
9 | [tool.poetry.dependencies]
10 | python = "^3.9.5"
11 | aiofiles = "^0.7.0"
12 | aiosqlite = "^0.17.0"
13 | alembic = "^1.6.5"
14 | appdirs = "^1.4.4"
15 | APScheduler = "^3.7.0"
16 | asgiref = "^3.3.4"
17 | click = "^8.0.1"
18 | fastapi = "^0.65.2"
19 | emails = "^0.6"
20 | email-validator = "^1.1.3"
21 | gunicorn = "^20.1.0"
22 | httpx = "^0.18.2"
23 | itsdangerous = "^2.0.1"
24 | Jinja2 = "^3.0.1"
25 | loguru = "^0.5.3"
26 | passlib = {version = "^1.7.4", extras = ["bcrypt"]}
27 | PlexAPI = "^4.6.1"
28 | pydantic = "^1.8.2"
29 | PyJWT = {version = "^2.1.0", extras = ["crypto"]}
30 | python-multipart = "^0.0.5"
31 | SQLAlchemy = "^1.4.18"
32 | uvicorn = "^0.14.0"
33 |
34 |
35 | [tool.poetry.dev-dependencies]
36 | autoflake = "^1.4"
37 | black = "^21.6b0"
38 | flake8 = "^3.9.2"
39 | pytest = "^6.2.4"
40 | pytest-asyncio = "^0.15.1"
41 | pytest-cov = "^2.12.1"
42 | pytest-mock = "^3.6.1"
43 |
44 |
45 | [tool.black]
46 | line-length = 99
47 | exclude = '''
48 |
49 | (
50 | /(
51 | \.git
52 | | \.venv
53 | | venv
54 | | build
55 | | migrations
56 | )/
57 | )
58 | '''
59 |
60 |
61 | [build-system]
62 | requires = ["poetry-core>=1.0.0"]
63 | build-backend = "poetry.core.masonry.api"
64 |
--------------------------------------------------------------------------------
/server/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Jeroli-co/Cheddarr/368d270e60468c2201bb7f5cfa67aac0e83be6d7/server/__init__.py
--------------------------------------------------------------------------------
/server/alembic.ini:
--------------------------------------------------------------------------------
1 | # A generic, single database configuration.
2 |
3 | [alembic]
4 | # path to migration scripts
5 | script_location = %(here)s/database/migrations
6 |
7 | # template used to generate migration files
8 | # file_template = %%(rev)s_%%(slug)s
9 |
10 | # timezone to use when rendering the date
11 | # within the migration file as well as the filename.
12 | # string value is passed to dateutil.tz.gettz()
13 | # leave blank for localtime
14 | # timezone =
15 |
16 | # max length of characters to apply to the
17 | # "slug" field
18 | # truncate_slug_length = 40
19 |
20 | # set to 'true' to run the environment during
21 | # the 'revision' command, regardless of autogenerate
22 | # revision_environment = false
23 |
24 | # set to 'true' to allow .pyc and .pyo files without
25 | # a source .py file to be detected as revisions in the
26 | # versions/ directory
27 | # sourceless = false
28 |
29 | # version location specification; this defaults
30 | # to migrations/versions. When using multiple version
31 | # directories, initial revisions must be specified with --version-path
32 | # version_locations = %(here)s/bar %(here)s/bat migrations/versions
33 |
34 | # the output encoding used when revision files
35 | # are written from script.py.mako
36 | # output_encoding = utf-8
37 |
38 |
39 | [post_write_hooks]
40 | # post_write_hooks defines scripts or Python functions that are run
41 | # on newly generated revision scripts. See the documentation for further
42 | # detail and examples
43 |
44 | # format using "black" - use the console_scripts runner, against the "black" entrypoint
45 | # hooks=black
46 | # black.type=console_scripts
47 | # black.entrypoint=black
48 | # black.options=-l 79
49 |
50 | # Logging configuration
51 | [loggers]
52 | keys = root,sqlalchemy,alembic
53 |
54 | [handlers]
55 | keys = console
56 |
57 | [formatters]
58 | keys = generic
59 |
60 | [logger_root]
61 | level = WARN
62 | handlers = console
63 | qualname =
64 |
65 | [logger_sqlalchemy]
66 | level = WARN
67 | handlers =
68 | qualname = sqlalchemy.engine
69 |
70 | [logger_alembic]
71 | level = INFO
72 | handlers =
73 | qualname = alembic
74 |
75 | [handler_console]
76 | class = StreamHandler
77 | args = (sys.stderr,)
78 | level = NOTSET
79 | formatter = generic
80 |
81 | [formatter_generic]
82 | format = %(levelname)-5.5s [%(name)s] %(message)s
83 | datefmt = %H:%M:%S
84 |
--------------------------------------------------------------------------------
/server/api/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Jeroli-co/Cheddarr/368d270e60468c2201bb7f5cfa67aac0e83be6d7/server/api/__init__.py
--------------------------------------------------------------------------------
/server/api/v1/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Jeroli-co/Cheddarr/368d270e60468c2201bb7f5cfa67aac0e83be6d7/server/api/v1/__init__.py
--------------------------------------------------------------------------------
/server/api/v1/endpoints/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Jeroli-co/Cheddarr/368d270e60468c2201bb7f5cfa67aac0e83be6d7/server/api/v1/endpoints/__init__.py
--------------------------------------------------------------------------------
/server/api/v1/endpoints/search.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from fastapi import APIRouter, Depends
4 |
5 | from server.api import dependencies as deps
6 | from server.models.media import MediaType
7 | from server.repositories.media import MediaServerMediaRepository
8 | from server.schemas.external_services import PlexMediaInfo
9 | from server.schemas.media import MediaSearchResult
10 | from server.services import tmdb
11 |
12 | router = APIRouter()
13 |
14 |
15 | @router.get(
16 | "",
17 | response_model=MediaSearchResult,
18 | response_model_exclude_none=True,
19 | dependencies=[Depends(deps.get_current_user)],
20 | )
21 | async def search_media(
22 | value: str,
23 | page: int = 1,
24 | media_type: Optional[MediaType] = None,
25 | server_media_repo: MediaServerMediaRepository = Depends(
26 | deps.get_repository(MediaServerMediaRepository)
27 | ),
28 | ):
29 | if media_type == MediaType.series:
30 | media_results, total_pages, total_results = await tmdb.search_tmdb_series(value, page)
31 | elif media_type == MediaType.movie:
32 | media_results, total_pages, total_results = await tmdb.search_tmdb_movies(value, page)
33 | else:
34 | media_results, total_pages, total_results = await tmdb.search_tmdb_media(value, page)
35 |
36 | for media in media_results:
37 | db_media = await server_media_repo.find_by_media_external_id(
38 | tmdb_id=media.tmdb_id,
39 | imdb_id=media.imdb_id,
40 | tvdb_id=media.tvdb_id,
41 | )
42 | media.media_servers_info = [
43 | PlexMediaInfo(**server_media.as_dict()) for server_media in db_media
44 | ]
45 |
46 | search_result = MediaSearchResult(
47 | results=[m.dict() for m in media_results],
48 | page=page,
49 | total_pages=total_pages,
50 | total_results=total_results,
51 | )
52 | return search_result
53 |
--------------------------------------------------------------------------------
/server/api/v1/router.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter, FastAPI
2 |
3 | from .endpoints import (
4 | auth,
5 | movies,
6 | notifications,
7 | requests,
8 | search,
9 | series,
10 | settings,
11 | system,
12 | users,
13 | )
14 |
15 | version = "v1"
16 |
17 | router = APIRouter()
18 | router.include_router(auth.router, tags=["auth"])
19 | router.include_router(movies.router, prefix="/movies", tags=["movies"])
20 | router.include_router(notifications.router, prefix="/notifications", tags=["notifications"])
21 | router.include_router(requests.router, prefix="/requests", tags=["requests"])
22 | router.include_router(search.router, prefix="/search", tags=["search"])
23 | router.include_router(series.router, prefix="/series", tags=["series"])
24 | router.include_router(
25 | settings.router,
26 | prefix="/settings",
27 | tags=["settings"],
28 | )
29 | router.include_router(system.router, prefix="/system", tags=["system"])
30 | router.include_router(users.current_user_router, prefix="/user", tags=["current user"])
31 | router.include_router(users.users_router, prefix="/users", tags=["users"])
32 |
33 | application = FastAPI(title="Cheddarr", version=version)
34 | application.include_router(router)
35 |
--------------------------------------------------------------------------------
/server/core/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Jeroli-co/Cheddarr/368d270e60468c2201bb7f5cfa67aac0e83be6d7/server/core/__init__.py
--------------------------------------------------------------------------------
/server/core/http_client.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Mapping, Optional
2 |
3 | import httpx
4 | from fastapi import HTTPException
5 | from loguru import logger
6 |
7 |
8 | class HttpClient:
9 | http_client: Optional[httpx.AsyncClient] = None
10 |
11 | @classmethod
12 | def get_http_client(cls) -> httpx.AsyncClient:
13 | if cls.http_client is None:
14 | cls.http_client = httpx.AsyncClient(timeout=20)
15 | return cls.http_client
16 |
17 | @classmethod
18 | async def close_http_client(cls) -> None:
19 | if cls.http_client:
20 | await cls.http_client.aclose()
21 | cls.http_client = None
22 |
23 | @classmethod
24 | async def request(
25 | cls,
26 | method: str,
27 | url: str,
28 | *,
29 | params: Optional[Mapping[str, Any]] = None,
30 | headers: Optional[Mapping[str, Any]] = None,
31 | data: Any = None,
32 | ) -> Any:
33 | client = cls.get_http_client()
34 | try:
35 | resp = await client.request(method, url, params=params, headers=headers, data=data)
36 | if 200 < resp.status_code > 300:
37 | raise HTTPException(resp.status_code, resp.text)
38 | json_result = resp.json()
39 | except Exception as e:
40 | logger.error(f"An error occurred when calling {url}: {str(e)}")
41 | raise
42 | return json_result
43 |
--------------------------------------------------------------------------------
/server/core/scheduler.py:
--------------------------------------------------------------------------------
1 | from apscheduler.schedulers.asyncio import AsyncIOScheduler
2 |
3 | scheduler = AsyncIOScheduler()
4 |
--------------------------------------------------------------------------------
/server/core/security.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timedelta
2 | from typing import Literal
3 |
4 | import jwt
5 | from itsdangerous import URLSafeSerializer, URLSafeTimedSerializer
6 | from passlib import pwd
7 | from passlib.context import CryptContext
8 |
9 | from server.core.config import get_config
10 | from server.schemas.auth import TokenPayload
11 |
12 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
13 |
14 |
15 | def create_jwt_access_token(payload: TokenPayload, expires_delta: timedelta = None) -> str:
16 | to_encode = payload.dict()
17 | if expires_delta:
18 | expire = datetime.utcnow() + expires_delta
19 | else:
20 | expire = datetime.utcnow() + timedelta(minutes=get_config().access_token_expire_minutes)
21 | to_encode.update({"exp": expire})
22 | encoded_jwt = jwt.encode(
23 | to_encode, get_config().secret_key, algorithm=get_config().signing_algorithm
24 | )
25 | return encoded_jwt
26 |
27 |
28 | def verify_password(plain_password: str, hashed_password: str) -> bool:
29 | return pwd_context.verify(plain_password, hashed_password)
30 |
31 |
32 | def hash_password(password: str) -> str:
33 | return pwd_context.hash(password)
34 |
35 |
36 | def get_random_password() -> str:
37 | return pwd.genword(entropy=56)
38 |
39 |
40 | def generate_token(data):
41 | serializer = URLSafeSerializer(get_config().secret_key)
42 | return serializer.dumps(data)
43 |
44 |
45 | def confirm_token(data):
46 | serializer = URLSafeSerializer(get_config().secret_key)
47 | return serializer.loads(data)
48 |
49 |
50 | def generate_timed_token(data):
51 | serializer = URLSafeTimedSerializer(get_config().secret_key)
52 | return serializer.dumps(data)
53 |
54 |
55 | def confirm_timed_token(token: str, expiration_minutes: int = 30):
56 | serializer = URLSafeTimedSerializer(get_config().secret_key)
57 | try:
58 | data = serializer.loads(token, max_age=expiration_minutes * 60)
59 | except Exception:
60 | raise Exception
61 | return data
62 |
63 |
64 | def check_permissions(
65 | user_roles: int, permissions: list, options: Literal["and", "or"] = "and"
66 | ) -> bool:
67 | from server.models.users import UserRole
68 |
69 | if user_roles & UserRole.admin:
70 | return True
71 | elif options == "and":
72 | return all(user_roles & permission for permission in permissions)
73 | elif options == "or":
74 | return any(user_roles & permission for permission in permissions)
75 | return False
76 |
--------------------------------------------------------------------------------
/server/core/utils.py:
--------------------------------------------------------------------------------
1 | import os
2 | import re
3 | from os import listdir
4 | from pathlib import Path
5 | from random import choice
6 | from urllib.parse import urlencode
7 |
8 | import emails
9 | from emails.template import JinjaTemplate
10 | from jinja2 import Environment, FileSystemLoader
11 |
12 | from server.core.config import get_config
13 |
14 |
15 | def send_email(
16 | email_options: dict,
17 | to_email: str,
18 | subject: str,
19 | html_template_name: str,
20 | environment: dict = None,
21 | ):
22 | environment = environment or {}
23 | for k, v in environment.items():
24 | environment[k] = re.sub(f"{get_config().api_prefix}/v[0-9]+", "", v)
25 | with open(Path(get_config().mail_templates_folder) / html_template_name) as f:
26 | template_str = f.read()
27 | message = emails.Message(
28 | mail_from=(email_options["sender_name"], email_options["sender_address"]),
29 | subject=subject,
30 | html=JinjaTemplate(
31 | template_str,
32 | environment=Environment(loader=FileSystemLoader(get_config().mail_templates_folder)),
33 | ),
34 | )
35 |
36 | smtp_options = {
37 | "host": email_options["smtp_host"],
38 | "port": email_options["smtp_port"],
39 | "user": email_options["smtp_user"],
40 | "password": email_options["smtp_password"],
41 | "ssl": email_options["ssl"],
42 | }
43 | message.send(
44 | to=to_email,
45 | render=environment,
46 | smtp=smtp_options,
47 | )
48 |
49 |
50 | def get_random_avatar():
51 | profile_images_path = os.path.join(get_config().images_folder, "users")
52 | avatar = choice(listdir(profile_images_path))
53 | return f"/images/users/{avatar}"
54 |
55 |
56 | def make_url(url: str, queries_dict: dict = None):
57 | queries_dict = queries_dict or {}
58 | parameters = urlencode(queries_dict)
59 | return url + "?" + parameters
60 |
--------------------------------------------------------------------------------
/server/database/__init__.py:
--------------------------------------------------------------------------------
1 | from .base import Base, Model, ModelType, Timestamp # noqa
2 |
--------------------------------------------------------------------------------
/server/database/base.py:
--------------------------------------------------------------------------------
1 | from datetime import timezone
2 | from typing import TypeVar
3 |
4 | import pytz
5 | from sqlalchemy import Column, DateTime as DBDateTime, func, inspect, TypeDecorator
6 | from sqlalchemy.orm import declarative_base, declared_attr
7 |
8 | from server.core.config import get_config
9 |
10 | Base = declarative_base()
11 | Base.metadata.naming_convention = {
12 | "ix": "ix_%(column_0_label)s",
13 | "uq": "uq_%(table_name)s_%(column_0_name)s",
14 | "ck": "ck_%(table_name)s_%(constraint_name)s",
15 | "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
16 | "pk": "pk_%(table_name)s",
17 | }
18 |
19 |
20 | class Model(Base):
21 | """Base Model class."""
22 |
23 | __mapper_args__ = {"eager_defaults": True, "always_refresh": True}
24 | __abstract__ = True
25 | __repr_props__ = ()
26 |
27 | @declared_attr
28 | def __tablename__(cls):
29 | return cls.__name__.lower()
30 |
31 | def __repr__(self):
32 | properties = [
33 | f"{prop}={getattr(self, prop)!r}"
34 | for prop in self.__repr_props__
35 | if hasattr(self, prop)
36 | ]
37 | return f"<{self.__class__.__name__} {' '.join(properties)}>"
38 |
39 | def as_dict(self) -> dict:
40 | return {c.key: getattr(self, c.key) for c in inspect(self).mapper.column_attrs}
41 |
42 |
43 | class DateTime(TypeDecorator):
44 | impl = DBDateTime
45 |
46 | def process_bind_param(self, value, engine):
47 | if value is not None:
48 | if not value.tzinfo:
49 | raise TypeError("tzinfo is required")
50 | value = value.astimezone(timezone.utc).replace(tzinfo=None)
51 | return value
52 |
53 | def process_result_value(self, value, engine):
54 | if value is not None:
55 | value = value.replace(tzinfo=pytz.utc).astimezone(pytz.timezone(get_config().tz))
56 | return value
57 |
58 |
59 | class Timestamp(object):
60 | """Mixin that define timestamp columns."""
61 |
62 | __datetime_func__ = func.now()
63 |
64 | created_at = Column(DateTime, server_default=__datetime_func__, nullable=False)
65 |
66 | updated_at = Column(
67 | DateTime,
68 | server_default=__datetime_func__,
69 | onupdate=__datetime_func__,
70 | nullable=False,
71 | )
72 |
73 |
74 | def mapper_args(mapper_args_dict: dict) -> dict:
75 | return {**Model.__mapper_args__, **mapper_args_dict}
76 |
77 |
78 | ModelType = TypeVar("ModelType", bound=Model)
79 |
--------------------------------------------------------------------------------
/server/database/init_db.py:
--------------------------------------------------------------------------------
1 | # Import all the models, so that Base has them fot creating the tables
2 | from server import models # noqa
3 | from .base import Base
4 |
5 |
6 | def init_db():
7 | from server.database.session import EngineMaker
8 |
9 | with EngineMaker.create_sync_engine().begin() as conn:
10 | Base.metadata.drop_all(bind=conn)
11 | Base.metadata.create_all(bind=conn)
12 |
--------------------------------------------------------------------------------
/server/database/migrations/script.py.mako:
--------------------------------------------------------------------------------
1 | """${message}
2 |
3 | Revision ID: ${up_revision}
4 | Revises: ${down_revision | comma,n}
5 | Create Date: ${create_date}
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 | ${imports if imports else ""}
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = ${repr(up_revision)}
14 | down_revision = ${repr(down_revision)}
15 | branch_labels = ${repr(branch_labels)}
16 | depends_on = ${repr(depends_on)}
17 |
18 |
19 | def upgrade():
20 | ${upgrades if upgrades else "pass"}
21 |
22 |
23 | def downgrade():
24 | ${downgrades if downgrades else "pass"}
25 |
--------------------------------------------------------------------------------
/server/database/migrations/versions/1b33a8f77eda_.py:
--------------------------------------------------------------------------------
1 | """empty message
2 |
3 | Revision ID: 1b33a8f77eda
4 | Revises: 81c905106fb6
5 | Create Date: 2021-07-13 21:28:37.805508
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 |
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = '1b33a8f77eda'
14 | down_revision = '81c905106fb6'
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade():
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | with op.batch_alter_table('media', schema=None) as batch_op:
22 | batch_op.drop_index('ix_media_imdb_id')
23 | batch_op.drop_index('ix_media_tmdb_id')
24 | batch_op.drop_index('ix_media_tvdb_id')
25 | batch_op.create_unique_constraint(batch_op.f('uq_media_imdb_id'), ['imdb_id', 'media_type'])
26 | batch_op.create_unique_constraint(batch_op.f('uq_media_tmdb_id'), ['tmdb_id', 'media_type'])
27 | batch_op.create_unique_constraint(batch_op.f('uq_media_tvdb_id'), ['tvdb_id', 'media_type'])
28 |
29 | # ### end Alembic commands ###
30 |
31 |
32 | def downgrade():
33 | # ### commands auto generated by Alembic - please adjust! ###
34 | with op.batch_alter_table('media', schema=None) as batch_op:
35 | batch_op.drop_constraint(batch_op.f('uq_media_tvdb_id'), type_='unique')
36 | batch_op.drop_constraint(batch_op.f('uq_media_tmdb_id'), type_='unique')
37 | batch_op.drop_constraint(batch_op.f('uq_media_imdb_id'), type_='unique')
38 | batch_op.create_index('ix_media_tvdb_id', ['tvdb_id'], unique=False)
39 | batch_op.create_index('ix_media_tmdb_id', ['tmdb_id'], unique=False)
40 | batch_op.create_index('ix_media_imdb_id', ['imdb_id'], unique=False)
41 |
42 | # ### end Alembic commands ###
43 |
--------------------------------------------------------------------------------
/server/database/migrations/versions/2ab75f3fa7c6_.py:
--------------------------------------------------------------------------------
1 | """empty message
2 |
3 | Revision ID: 2ab75f3fa7c6
4 | Revises: 2337500663a0
5 | Create Date: 2021-04-05 01:23:13.476200
6 |
7 | """
8 | import sqlalchemy as sa
9 | from alembic import op
10 |
11 | # revision identifiers, used by Alembic.
12 | revision = '2ab75f3fa7c6'
13 | down_revision = '2337500663a0'
14 | branch_labels = None
15 | depends_on = None
16 |
17 |
18 | def upgrade():
19 | # ### commands auto generated by Alembic - please adjust! ###
20 | op.add_column('mediarequest', sa.Column('language_profile_id', sa.Integer(), nullable=True))
21 | op.add_column('mediarequest', sa.Column('quality_profile_id', sa.Integer(), nullable=True))
22 | op.add_column('mediarequest', sa.Column('root_folder', sa.String(), nullable=True))
23 | # ### end Alembic commands ###
24 |
25 |
26 | def downgrade():
27 | # ### commands auto generated by Alembic - please adjust! ###
28 | op.drop_column('mediarequest', 'root_folder')
29 | op.drop_column('mediarequest', 'quality_profile_id')
30 | op.drop_column('mediarequest', 'language_profile_id')
31 | # ### end Alembic commands ###
32 |
--------------------------------------------------------------------------------
/server/database/migrations/versions/81c905106fb6_.py:
--------------------------------------------------------------------------------
1 | """empty message
2 |
3 | Revision ID: 81c905106fb6
4 | Revises: c446bad70344
5 | Create Date: 2021-06-16 23:07:21.869343
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 |
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = '81c905106fb6'
14 | down_revision = 'c446bad70344'
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade():
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | with op.batch_alter_table('mediaserverepisode', schema=None) as batch_op:
22 | batch_op.alter_column('server_id',
23 | existing_type=sa.VARCHAR(),
24 | nullable=False)
25 |
26 | with op.batch_alter_table('mediaservermedia', schema=None) as batch_op:
27 | batch_op.alter_column('server_id',
28 | existing_type=sa.VARCHAR(),
29 | nullable=False)
30 |
31 | with op.batch_alter_table('mediaserverseason', schema=None) as batch_op:
32 | batch_op.alter_column('server_id',
33 | existing_type=sa.VARCHAR(),
34 | nullable=False)
35 |
36 | # ### end Alembic commands ###
37 |
38 |
39 | def downgrade():
40 | # ### commands auto generated by Alembic - please adjust! ###
41 | with op.batch_alter_table('mediaserverseason', schema=None) as batch_op:
42 | batch_op.alter_column('server_id',
43 | existing_type=sa.VARCHAR(),
44 | nullable=True)
45 |
46 | with op.batch_alter_table('mediaservermedia', schema=None) as batch_op:
47 | batch_op.alter_column('server_id',
48 | existing_type=sa.VARCHAR(),
49 | nullable=True)
50 |
51 | with op.batch_alter_table('mediaserverepisode', schema=None) as batch_op:
52 | batch_op.alter_column('server_id',
53 | existing_type=sa.VARCHAR(),
54 | nullable=True)
55 |
56 | # ### end Alembic commands ###
57 |
--------------------------------------------------------------------------------
/server/database/session.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import create_engine
2 | from sqlalchemy.engine import Engine
3 | from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
4 | from sqlalchemy.orm import sessionmaker
5 |
6 | from server.core.config import get_config
7 |
8 |
9 | class EngineMaker:
10 | engine: AsyncEngine = None
11 |
12 | @classmethod
13 | def get_engine(cls) -> AsyncEngine:
14 | if cls.engine is None:
15 | cls.engine = cls.create_engine()
16 | return cls.engine
17 |
18 | @classmethod
19 | def create_engine(cls) -> AsyncEngine:
20 | return create_async_engine(get_config().db_url, connect_args={"check_same_thread": False})
21 |
22 | @classmethod
23 | def create_sync_engine(cls) -> Engine:
24 | url = "sqlite:///" + str(get_config().db_folder / get_config().db_filename)
25 | return create_engine(url, connect_args={"check_same_thread": False})
26 |
27 |
28 | Session = sessionmaker(EngineMaker.get_engine(), expire_on_commit=False, class_=AsyncSession)
29 |
--------------------------------------------------------------------------------
/server/jobs/__init__.py:
--------------------------------------------------------------------------------
1 | from . import plex, radarr, sonarr
2 |
--------------------------------------------------------------------------------
/server/jobs/radarr.py:
--------------------------------------------------------------------------------
1 | from server.core.scheduler import scheduler
2 | from server.database.session import Session
3 | from server.models.media import MediaType
4 | from server.models.requests import RequestStatus
5 | from server.repositories.requests import MediaRequestRepository
6 | from server.services import radarr
7 |
8 |
9 | @scheduler.scheduled_job(
10 | "interval", id="radarr-sync", name="Radarr Sync", coalesce=True, minutes=10
11 | )
12 | async def sync_radarr():
13 | async with Session() as db_session:
14 | media_request_repo = MediaRequestRepository(db_session)
15 | requests = await media_request_repo.find_all_by(
16 | status=RequestStatus.approved, media_type=MediaType.movie
17 | )
18 | for request in requests:
19 | setting = request.selected_provider
20 | movie_lookup = await radarr.lookup(
21 | setting, tmdb_id=request.media.tmdb_id, title=request.media.title
22 | )
23 | if movie_lookup is None:
24 | continue
25 | if movie_lookup.id is None:
26 | request.status = RequestStatus.refused
27 | await media_request_repo.save(request)
28 | continue
29 | if movie_lookup.has_file:
30 | request.status = RequestStatus.available
31 | await media_request_repo.save(request)
32 |
--------------------------------------------------------------------------------
/server/main.py:
--------------------------------------------------------------------------------
1 | from fastapi import FastAPI
2 | from fastapi.middleware.cors import CORSMiddleware
3 |
4 | from server.api.v1 import router
5 | from server.core.config import get_config
6 | from server.core.http_client import HttpClient
7 | from server.core.logger import Logger
8 | from server.core.scheduler import scheduler
9 | from server.site import site
10 |
11 |
12 | def setup_app() -> FastAPI:
13 | application = FastAPI(
14 | title="Cheddarr",
15 | docs_url=None,
16 | redoc_url=None,
17 | on_startup=[on_start_up],
18 | on_shutdown=[on_shutdown],
19 | )
20 | application.mount(f"{get_config().api_prefix}/{router.version}", router.application)
21 | application.mount("/", site)
22 | application.add_middleware(
23 | CORSMiddleware,
24 | allow_origins=[str(origin) for origin in get_config().backend_cors_origin],
25 | allow_credentials=True,
26 | allow_methods=["*"],
27 | allow_headers=["*"],
28 | )
29 | application.logger = Logger.make_logger()
30 |
31 | from server import jobs # noqa
32 |
33 | scheduler.start()
34 |
35 | return application
36 |
37 |
38 | async def on_start_up() -> None:
39 | HttpClient.get_http_client()
40 |
41 |
42 | async def on_shutdown() -> None:
43 | await HttpClient.close_http_client()
44 |
45 |
46 | app = setup_app()
47 |
--------------------------------------------------------------------------------
/server/models/__init__.py:
--------------------------------------------------------------------------------
1 | from . import media, notifications, requests, settings, users # noqa
2 |
--------------------------------------------------------------------------------
/server/models/notifications.py:
--------------------------------------------------------------------------------
1 | from enum import Enum
2 |
3 | from sqlalchemy import Boolean, Column, Enum as DBEnum, ForeignKey, Integer, JSON, Text
4 |
5 | from server.database import Model, Timestamp
6 |
7 |
8 | class Agent(str, Enum):
9 | email = "email"
10 |
11 |
12 | class NotificationAgent(Model):
13 | name = Column(DBEnum(Agent), primary_key=True)
14 | enabled = Column(Boolean, default=True)
15 | settings = Column(JSON)
16 |
17 |
18 | class Notification(Model, Timestamp):
19 | id = Column(Integer, primary_key=True)
20 | message = Column(Text, nullable=False)
21 | read = Column(Boolean, nullable=False, default=False)
22 | user_id = Column(ForeignKey("user.id"), nullable=False)
23 |
--------------------------------------------------------------------------------
/server/models/users.py:
--------------------------------------------------------------------------------
1 | from enum import Enum
2 |
3 | from sqlalchemy import Boolean, Column, Integer, String
4 | from sqlalchemy.ext.hybrid import hybrid_property
5 |
6 | from server.core.config import get_config
7 | from server.core.security import hash_password
8 | from server.core.utils import get_random_avatar
9 | from server.database import Model, Timestamp
10 |
11 |
12 | class UserRole(int, Enum):
13 | none = 0
14 | admin = 2
15 | request = 4
16 | manage_settings = 8
17 | manage_requests = 16
18 | manage_users = 32
19 | auto_approve = 64
20 | request_movies = 128
21 | request_series = 256
22 |
23 |
24 | class User(Model, Timestamp):
25 | __repr_props__ = ("username", "email", "roles", "confirmed")
26 |
27 | id = Column(Integer, primary_key=True)
28 | username = Column(String, nullable=False, unique=True, index=True)
29 | email = Column(String, unique=True, index=True)
30 | password_hash = Column(String, nullable=False)
31 | avatar = Column(String, default=get_random_avatar())
32 | confirmed = Column(Boolean, nullable=False, default=False)
33 | roles = Column(Integer, default=get_config().default_roles)
34 | plex_user_id = Column(Integer)
35 | plex_api_key = Column(String)
36 |
37 | @hybrid_property
38 | def password(self):
39 | return self.password_hash
40 |
41 | @password.setter
42 | def password(self, plain):
43 | self.password_hash = hash_password(plain)
44 |
--------------------------------------------------------------------------------
/server/repositories/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Jeroli-co/Cheddarr/368d270e60468c2201bb7f5cfa67aac0e83be6d7/server/repositories/__init__.py
--------------------------------------------------------------------------------
/server/repositories/notifications.py:
--------------------------------------------------------------------------------
1 | from server.models.notifications import Notification, NotificationAgent
2 | from server.repositories.base import BaseRepository
3 |
4 |
5 | class NotificationRepository(BaseRepository[Notification]):
6 | ...
7 |
8 |
9 | class NotificationAgentRepository(BaseRepository[NotificationAgent]):
10 | ...
11 |
--------------------------------------------------------------------------------
/server/repositories/requests.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 |
3 | from sqlalchemy import select
4 |
5 | from server.models.media import Media
6 | from server.models.requests import MediaRequest
7 | from server.repositories.base import BaseRepository
8 |
9 |
10 | class MediaRequestRepository(BaseRepository[MediaRequest]):
11 | async def find_all_by_tmdb_id(self, tmdb_id: int, **filters) -> List[MediaRequest]:
12 | query = select(self.model).filter_by(**filters).join(Media).where(Media.tmdb_id == tmdb_id)
13 | result = await self.execute(query)
14 | return result.all()
15 |
--------------------------------------------------------------------------------
/server/repositories/settings.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Dict, Union
2 |
3 | from pydantic import BaseModel
4 | from sqlalchemy import update
5 |
6 | from server.models.settings import (
7 | MediaProviderSetting,
8 | MediaServerSetting,
9 | PlexSetting,
10 | RadarrSetting,
11 | SonarrSetting,
12 | )
13 | from server.repositories.base import BaseRepository
14 |
15 |
16 | class MediaProviderSettingRepository(BaseRepository[MediaProviderSetting]):
17 | async def save(self, db_obj: MediaProviderSetting) -> MediaProviderSetting:
18 | await super().save(db_obj)
19 | if db_obj.is_default:
20 | await self.session.execute(
21 | update(self.model)
22 | .where(
23 | self.model.provider_type == db_obj.provider_type, self.model.id != db_obj.id
24 | )
25 | .values(is_default=False)
26 | )
27 | db_obj.is_default = True
28 | await super().save(db_obj)
29 | return db_obj
30 |
31 | async def update(
32 | self,
33 | db_obj: MediaProviderSetting,
34 | obj_in: Union[BaseModel, Dict[str, Any]],
35 | ) -> MediaProviderSetting:
36 |
37 | db_obj = await super().update(db_obj, obj_in)
38 | if db_obj.is_default:
39 | await self.session.execute(
40 | update(self.model)
41 | .where(self.model.provider_type == db_obj.provider_type)
42 | .values(is_default=False)
43 | )
44 | db_obj.is_default = True
45 | await super().save(db_obj)
46 | return db_obj
47 |
48 |
49 | class MediaServerSettingRepository(BaseRepository[MediaServerSetting]):
50 | ...
51 |
52 |
53 | class PlexSettingRepository(MediaServerSettingRepository, BaseRepository[PlexSetting]):
54 | ...
55 |
56 |
57 | class RadarrSettingRepository(MediaProviderSettingRepository, BaseRepository[RadarrSetting]):
58 | ...
59 |
60 |
61 | class SonarrSettingRepository(MediaProviderSettingRepository, BaseRepository[SonarrSetting]):
62 | ...
63 |
--------------------------------------------------------------------------------
/server/repositories/users.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from sqlalchemy import func, select
4 |
5 | from server.models.users import User
6 | from .base import BaseRepository
7 |
8 |
9 | class UserRepository(BaseRepository[User]):
10 | async def find_by_email(self, email: str) -> Optional[User]:
11 | result = await self.execute(
12 | select(self.model).where(func.lower(self.model.email) == email.lower())
13 | )
14 | return result.one_or_none()
15 |
16 | async def find_by_username(self, username: str) -> Optional[User]:
17 | result = await self.execute(
18 | select(self.model).where(func.lower(self.model.username) == username.lower())
19 | )
20 | return result.one_or_none()
21 |
22 | async def find_by_username_or_email(self, username_or_email: str) -> Optional[User]:
23 | result = await self.execute(
24 | select(self.model).where(
25 | (func.lower(self.model.email) == username_or_email.lower())
26 | | (func.lower(self.model.username) == username_or_email.lower())
27 | )
28 | )
29 | return result.one_or_none()
30 |
--------------------------------------------------------------------------------
/server/schemas/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Jeroli-co/Cheddarr/368d270e60468c2201bb7f5cfa67aac0e83be6d7/server/schemas/__init__.py
--------------------------------------------------------------------------------
/server/schemas/auth.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from pydantic import BaseModel, EmailStr
4 |
5 | from .core import APIModel
6 |
7 |
8 | class Token(BaseModel):
9 | access_token: str
10 | token_type: str
11 |
12 |
13 | class TokenPayload(BaseModel):
14 | sub: str
15 |
16 |
17 | class EmailConfirm(APIModel):
18 | email: EmailStr
19 | old_email: Optional[EmailStr]
20 |
21 |
22 | class PlexAuthorizeSignin(APIModel):
23 | key: str
24 | code: str
25 | redirect_uri: str = ""
26 | user_id: Optional[int]
27 |
--------------------------------------------------------------------------------
/server/schemas/core.py:
--------------------------------------------------------------------------------
1 | from typing import List, Type
2 |
3 | from pydantic import BaseModel
4 |
5 | from server.repositories.base import ModelType
6 |
7 |
8 | class APIModel(BaseModel):
9 | class Config:
10 | orm_mode = True
11 | allow_population_by_field_name = True
12 |
13 | def to_orm(self, orm_model: Type[ModelType], exclude=None) -> ModelType:
14 | return orm_model(**self.dict(include=vars(orm_model).keys(), exclude=exclude))
15 |
16 |
17 | class ResponseMessage(BaseModel):
18 | detail: str
19 |
20 |
21 | class PaginatedResult(BaseModel):
22 | page: int = 1
23 | total_pages: int
24 | total_results: int
25 | results: List
26 |
--------------------------------------------------------------------------------
/server/schemas/notifications.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 | from server.schemas.users import UserSchema
4 | from .core import APIModel
5 |
6 |
7 | class NotificationSchema(APIModel):
8 | message: str
9 | read: bool
10 | user: UserSchema
11 |
12 |
13 | class NotificationAgentSchema(APIModel):
14 | enabled: bool
15 | settings: Any
16 |
17 |
18 | class EmailAgentSettings(APIModel):
19 | smtp_port: int
20 | smtp_host: str
21 | smtp_user: str
22 | smtp_password: str
23 | sender_address: str
24 | sender_name: str
25 | ssl: bool
26 |
27 |
28 | class EmailAgentSchema(NotificationAgentSchema):
29 | settings: EmailAgentSettings
30 |
--------------------------------------------------------------------------------
/server/schemas/requests.py:
--------------------------------------------------------------------------------
1 | from abc import ABC
2 | from datetime import datetime
3 | from typing import List, Optional, Union
4 |
5 | from server.models.requests import RequestStatus
6 | from server.schemas.media import MovieSchema, SeriesSchema
7 | from server.schemas.users import UserSchema
8 | from .core import APIModel, PaginatedResult
9 | from ..models.media import MediaType
10 |
11 |
12 | class MediaRequest(APIModel, ABC):
13 | id: int
14 | status: RequestStatus
15 | requesting_user: UserSchema
16 | created_at: datetime
17 | updated_at: datetime
18 | media_type: MediaType
19 |
20 |
21 | class MediaRequestCreate(APIModel):
22 | tmdb_id: int
23 | root_folder: Optional[str]
24 | quality_profile_id: Optional[int]
25 | language_profile_id: Optional[int]
26 |
27 |
28 | class MediaRequestUpdate(APIModel):
29 | status: RequestStatus
30 | provider_id: Optional[str]
31 | comment: Optional[str]
32 |
33 |
34 | class MovieRequestSchema(MediaRequest):
35 | media: MovieSchema
36 |
37 |
38 | class MovieRequestCreate(MediaRequestCreate):
39 | ...
40 |
41 |
42 | class EpisodeRequestSchema(APIModel):
43 | episode_number: int
44 |
45 |
46 | class SeasonRequestSchema(APIModel):
47 | season_number: int
48 | episodes: Optional[List[EpisodeRequestSchema]]
49 |
50 |
51 | class SeriesRequestSchema(MediaRequest):
52 | media: SeriesSchema
53 | seasons: Optional[List[SeasonRequestSchema]]
54 |
55 |
56 | class SeriesRequestCreate(MediaRequestCreate):
57 | seasons: Optional[List[SeasonRequestSchema]]
58 |
59 |
60 | class MediaRequestSearchResult(PaginatedResult):
61 | results: List[Union[SeriesRequestSchema, MovieRequestSchema]]
62 |
--------------------------------------------------------------------------------
/server/schemas/system.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from typing import List, Optional
3 |
4 | from pydantic import BaseModel
5 |
6 | from server.schemas.core import PaginatedResult
7 |
8 |
9 | class PublicConfig(BaseModel):
10 | log_level: Optional[str]
11 | default_roles: Optional[int]
12 |
13 |
14 | class Log(BaseModel):
15 | time: str
16 | level: str
17 | process: str
18 | message: str
19 |
20 |
21 | class LogResult(PaginatedResult):
22 | results: List[Log]
23 |
24 |
25 | class Job(BaseModel):
26 | id: str
27 | name: str
28 | next_run_time: Optional[datetime]
29 |
--------------------------------------------------------------------------------
/server/schemas/users.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from pathlib import Path
3 | from typing import List, Optional, Union
4 |
5 | from pydantic import AnyHttpUrl, EmailStr
6 |
7 | from .core import APIModel, PaginatedResult
8 |
9 |
10 | class UserBase(APIModel):
11 | username: str
12 | email: Optional[EmailStr]
13 |
14 |
15 | class UserSchema(UserBase):
16 | id: int
17 | avatar: Optional[Union[Path, AnyHttpUrl]]
18 | confirmed: bool
19 | roles: int
20 | created_at: datetime
21 | updated_at: datetime
22 |
23 |
24 | class UserCreate(UserBase):
25 | password: str
26 |
27 |
28 | class UserUpdate(UserBase):
29 | username: Optional[str]
30 | email: Optional[EmailStr]
31 | old_password: Optional[str]
32 | password: Optional[str]
33 | roles: Optional[int]
34 | confirmed: Optional[bool]
35 |
36 |
37 | class UserSearchResult(PaginatedResult):
38 | results: List[UserSchema]
39 |
40 |
41 | class PasswordResetCreate(APIModel):
42 | email: EmailStr
43 |
44 |
45 | class PasswordResetConfirm(APIModel):
46 | password: str
47 |
--------------------------------------------------------------------------------
/server/services/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Jeroli-co/Cheddarr/368d270e60468c2201bb7f5cfa67aac0e83be6d7/server/services/__init__.py
--------------------------------------------------------------------------------
/server/services/plex.py:
--------------------------------------------------------------------------------
1 | from typing import List, Optional
2 |
3 | from asgiref.sync import sync_to_async
4 | from plexapi.exceptions import PlexApiException
5 | from plexapi.myplex import MyPlexAccount
6 | from plexapi.server import PlexServer as PlexAPIServer
7 |
8 | from server.schemas.settings import PlexLibrarySection, PlexServer
9 |
10 |
11 | @sync_to_async
12 | def get_plex_account_servers(api_key: str) -> List[PlexServer]:
13 | plex_account = MyPlexAccount(api_key)
14 | servers = []
15 | for resource in plex_account.resources():
16 | if resource.provides == "server":
17 | for connection in resource.connections:
18 | if not connection.relay:
19 | servers.append(
20 | PlexServer(
21 | server_id=resource.clientIdentifier,
22 | server_name=resource.name + " [local]"
23 | if connection.local
24 | else resource.name + " [remote]",
25 | api_key=resource.accessToken,
26 | host=connection.address,
27 | port=connection.port,
28 | ssl=connection.protocol == "HTTPS",
29 | local=connection.local,
30 | )
31 | )
32 | return servers
33 |
34 |
35 | async def get_plex_server_library_sections(
36 | base_url: str, port: int, ssl: bool, api_key: str
37 | ) -> Optional[List[PlexLibrarySection]]:
38 | server = await get_server(base_url, port, ssl, api_key)
39 | if server is None:
40 | return None
41 | sections = []
42 | for library in await sync_to_async(server.library.sections)():
43 | sections.append(PlexLibrarySection(library_id=library.key, name=library.title))
44 |
45 | return sections
46 |
47 |
48 | async def get_server(base_url: str, port: int, ssl: bool, api_key: str) -> Optional[PlexAPIServer]:
49 | url = f"{'https' if ssl else 'http'}://{base_url}{':' + str(port) if port else ''}"
50 | try:
51 | server = await sync_to_async(PlexAPIServer)(url, api_key)
52 | except PlexApiException:
53 | return None
54 | return server
55 |
--------------------------------------------------------------------------------
/server/site.py:
--------------------------------------------------------------------------------
1 | from fastapi import FastAPI, HTTPException, Request, status
2 | from fastapi.responses import FileResponse
3 | from fastapi.routing import Mount
4 | from fastapi.staticfiles import StaticFiles
5 | from fastapi.templating import Jinja2Templates
6 |
7 | from server.core.config import get_config
8 |
9 | site_routes = [
10 | Mount("/images", StaticFiles(directory=str(get_config().images_folder)), name="images"),
11 | Mount(
12 | "/static",
13 | StaticFiles(directory=str(get_config().react_static_folder)),
14 | name="static",
15 | ),
16 | ]
17 | site = FastAPI(routes=site_routes, docs_url=None, redoc_url=None)
18 | site_templates = Jinja2Templates(str(get_config().react_build_folder))
19 |
20 |
21 | @site.get("/favicon.ico")
22 | async def favicon():
23 | return FileResponse(str(get_config().react_build_folder / "favicon.ico"))
24 |
25 |
26 | @site.get("/manifest.json")
27 | async def manifest():
28 | return FileResponse(str(get_config().react_build_folder / "manifest.json"))
29 |
30 |
31 | @site.get("{path:path}")
32 | async def index(request: Request, path: str):
33 | if path.startswith(get_config().api_prefix):
34 | raise HTTPException(status.HTTP_404_NOT_FOUND)
35 | return site_templates.TemplateResponse("index.html", {"request": request})
36 |
--------------------------------------------------------------------------------
/server/static/images/users/cheese-blue.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Jeroli-co/Cheddarr/368d270e60468c2201bb7f5cfa67aac0e83be6d7/server/static/images/users/cheese-blue.png
--------------------------------------------------------------------------------
/server/static/images/users/cheese-cyan.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Jeroli-co/Cheddarr/368d270e60468c2201bb7f5cfa67aac0e83be6d7/server/static/images/users/cheese-cyan.png
--------------------------------------------------------------------------------
/server/static/images/users/cheese-green.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Jeroli-co/Cheddarr/368d270e60468c2201bb7f5cfa67aac0e83be6d7/server/static/images/users/cheese-green.png
--------------------------------------------------------------------------------
/server/static/images/users/cheese-ocean.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Jeroli-co/Cheddarr/368d270e60468c2201bb7f5cfa67aac0e83be6d7/server/static/images/users/cheese-ocean.png
--------------------------------------------------------------------------------
/server/static/images/users/cheese-orange.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Jeroli-co/Cheddarr/368d270e60468c2201bb7f5cfa67aac0e83be6d7/server/static/images/users/cheese-orange.png
--------------------------------------------------------------------------------
/server/static/images/users/cheese-pink.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Jeroli-co/Cheddarr/368d270e60468c2201bb7f5cfa67aac0e83be6d7/server/static/images/users/cheese-pink.png
--------------------------------------------------------------------------------
/server/static/images/users/cheese-purple.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Jeroli-co/Cheddarr/368d270e60468c2201bb7f5cfa67aac0e83be6d7/server/static/images/users/cheese-purple.png
--------------------------------------------------------------------------------
/server/static/images/users/cheese-red.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Jeroli-co/Cheddarr/368d270e60468c2201bb7f5cfa67aac0e83be6d7/server/static/images/users/cheese-red.png
--------------------------------------------------------------------------------
/server/templates/email/change_password_notice.html:
--------------------------------------------------------------------------------
1 | {% extends 'email/layout.html' %}
2 |
3 | {% block body %}
4 | Your password has been changed.
5 | {% endblock %}
--------------------------------------------------------------------------------
/server/templates/email/email_confirmation.html:
--------------------------------------------------------------------------------
1 | {% extends 'email/layout.html' %}
2 |
3 | {% block body %}
4 | Please confirm your email through the link below to be able to login:
5 | {{ confirm_url }}
6 | {% endblock %}
--------------------------------------------------------------------------------
/server/templates/email/layout.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | Cheddarr
12 | |
13 |
14 |
15 |
16 | {% block body %}
17 | {% endblock %}
18 | |
19 |
20 |
21 |
22 | © Cheddarr
23 | |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/server/templates/email/reset_password_instructions.html:
--------------------------------------------------------------------------------
1 | {% extends 'email/layout.html' %}
2 |
3 | {% block body %}
4 | Click the link below to reset your password:
5 | {{ reset_url }}
6 | {% endblock %}
--------------------------------------------------------------------------------
/server/templates/email/reset_password_notice.html:
--------------------------------------------------------------------------------
1 | {% extends 'email/layout.html' %}
2 |
3 | {% block body %}
4 | Your password has been reset.
5 | {% endblock %}
--------------------------------------------------------------------------------
/server/templates/email/welcome.html:
--------------------------------------------------------------------------------
1 | {% extends 'email/layout.html' %}
2 |
3 | {% block body %}
4 | Welcome to Cheddarr, {{ username }}!
5 |
6 | Please confirm your email by clicking the link below to be able to login:
7 | Confirm my account
8 | Or copy and paste it into your browser: {{ confirm_url }}
9 | {% endblock %}
10 |
--------------------------------------------------------------------------------
/server/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Jeroli-co/Cheddarr/368d270e60468c2201bb7f5cfa67aac0e83be6d7/server/tests/__init__.py
--------------------------------------------------------------------------------
/server/tests/api/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Jeroli-co/Cheddarr/368d270e60468c2201bb7f5cfa67aac0e83be6d7/server/tests/api/__init__.py
--------------------------------------------------------------------------------
/server/tests/api/v1/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Jeroli-co/Cheddarr/368d270e60468c2201bb7f5cfa67aac0e83be6d7/server/tests/api/v1/__init__.py
--------------------------------------------------------------------------------