├── .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 | 5 | 10 | 11 | 14 | 15 | 17 | 18 | -------------------------------------------------------------------------------- /client/src/assets/cheddarr-post.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 10 | 14 | 15 | 16 | 19 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /client/src/assets/cheddarr-pre.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 10 | 14 | 18 | 23 | 24 | 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 |
27 | 28 | onSubmit()}> 29 | Delete account 30 | 31 | 34 | 35 |
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 |
19 |

{props.title}

20 |
21 | {props.description &&
{props.description}
} 22 |
23 | 24 | props.action()}> 25 | {props.actionLabel} 26 | 27 | 30 | 31 |
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 | 52 | 53 | 54 | 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 | Plex logo 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 | User 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 |
59 | 60 | 61 | Request 62 | 63 | 66 | 67 |
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 |
37 | 38 | 39 | 40 | 41 |
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 | 13 | 14 | 15 | 19 | 20 | 21 | 24 | 25 |
11 | Cheddarr 12 |
16 | {% block body %} 17 | {% endblock %} 18 |
22 | © Cheddarr 23 |
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 --------------------------------------------------------------------------------