Compare commits

...

141 Commits

Author SHA1 Message Date
Thomas Camlong
03e14ce3ce 🚀 Patch v0.9.2 : Small bug fixes and corrections 2022-08-12 15:38:33 +02:00
Thomas Camlong
d51de68ef3 Merge branch 'master' into dev 2022-08-12 15:37:44 +02:00
ajnart
090ae0e205 🔖 Bumb version to patch v0.9.2 2022-08-12 15:36:56 +02:00
ajnart
7ba27ef9f1 Add Dashdot name indicator 2022-08-12 15:30:53 +02:00
ajnart
48e1808992 🐛 Fix a bug with the openedUrl
Fixes #337
2022-08-12 15:09:45 +02:00
ajnart
85b1a2d7da 🐛 Fixing bug with image display
Fixes #336
2022-08-12 15:07:43 +02:00
Thomas Camlong
f680c01547 🟣 V0.9.1: Overserr integration and new design ! 2022-08-11 19:05:26 +02:00
ajnart
4f94999b07 🐛 Fix a small bug with the display of images 2022-08-11 17:08:39 +02:00
ajnart
cf89141f82 🐛 Fix a bug with the AppShelf accordion 2022-08-11 10:08:18 +02:00
ajnart
b46bdea72a 🔧 Adjust default config 2022-08-10 18:47:14 +02:00
ajnart
98af9794ec 💄 Very small UI changes 2022-08-10 14:15:02 +02:00
ajnart
3d63226372 💄 Very small UI changes 2022-08-10 14:13:20 +02:00
ajnart
2e8dff346e 💄 Very small UI changes 2022-08-10 14:01:23 +02:00
ajnart
430f3b52e9 🐛 Fixing small bugs 2022-08-10 13:59:46 +02:00
ajnart
901b68732f Add open result to overseerr button 2022-08-09 17:04:19 +02:00
ajnart
d83900e134 🐛 Fix a bug with searching just "!os" in overseerr 2022-08-09 15:06:00 +02:00
ajnart
6f0902d473 🐛 Fix Jellyseerr request 2022-08-09 15:04:39 +02:00
ajnart
a1d3fc66da 🔖 Bumb version to v0.9.1 2022-08-09 13:36:26 +02:00
ajnart
c76ef9643b 🐛 Fix Popover open state 2022-08-09 13:35:59 +02:00
ajnart
67a274804f Add Jellyseerr full support 2022-08-09 13:26:55 +02:00
ajnart
c157c94d95 ✏️ Fix color for MenuItem 2022-08-09 13:23:29 +02:00
ajnart
bd0d5bc663 Make icon Ctrl-clickable
Will open a new tab
2022-08-09 13:23:02 +02:00
ajnart
91d079c5ab 🐛 Fix quick color bug 2022-08-09 13:22:15 +02:00
ajnart
fe8919c6ad 🔖 Upgrade tag to v0.9.0 2022-08-08 16:03:58 +02:00
Thomas Camlong
f792a0df96 Merge branch 'master' into dev 2022-08-08 16:02:25 +02:00
Thomas Camlong
1283b48d6b 🔀 Merge pull request #326 from ajnart/overseerr-integration
 Overseerr integration
2022-08-08 16:00:26 +02:00
ajnart
6adb796b26 🎨 Small styling changes 2022-08-08 15:43:04 +02:00
ajnart
528e899066 🐛 Fix overseerr api key field 2022-08-08 15:17:51 +02:00
ajnart
659222643c 🐳 Revert docker image change 2022-08-08 14:49:06 +02:00
ajnart
20d61c8d2a 📦 Add package and fix bug in DownloadsModule 2022-08-08 14:30:22 +02:00
Thomas Camlong
53e0b098ff 🔀 Merge pull request #322 from ajnart/mantine-v5
⬆️ Upgrading to Mantine v5
2022-08-08 13:52:52 +02:00
ajnart
9fa4836038 ⬆️ Upgrade to Mantine v5.1.0 (from v5.0.2) 2022-08-08 13:52:07 +02:00
ajnart
439874e811 💄 Calendar styling 2022-08-08 13:47:34 +02:00
ajnart
60fc6732b8 📝 Add examples for JSON formats
I could possibly turn these into type declarations with some online parser but at the moment it stays here for developpment purposes
2022-08-08 13:47:15 +02:00
ajnart
772fe7622d 🐛 Fix bug with Downloadmodule width 2022-08-08 13:46:14 +02:00
ajnart
1e69e3a2b0 🐛 Fix onBlurCapture in the Dropdown of overseerr 2022-08-08 13:45:54 +02:00
ajnart
b430e24cdb ✏️ Fix request Modal 2022-08-08 13:45:36 +02:00
ajnart
f9caf6ef26 ⚰️ Remove allowTransparency from dashdot 2022-08-08 13:45:12 +02:00
ajnart
9a53f5d1ee Improve MediaDisplay overseerr 2022-08-08 13:44:58 +02:00
ajnart
04874e69f2 🐛 Fix module wrapper hover bug 2022-08-08 13:44:35 +02:00
ajnart
1741829761 🔥 Remove tryRequest page 2022-08-08 13:44:20 +02:00
ajnart
67f19b5186 💄 Linting 2022-08-07 17:20:59 +02:00
ajnart
68d1068059 ⬆️ Migration to Mantine v5.0 in Popover 2022-08-07 17:20:34 +02:00
ajnart
03dd4b33ac 📦 Add Mantine Modal 2022-08-07 17:19:39 +02:00
ajnart
60ef0fe5d6 🔀 Merge Mantine v5.0 into Overseerr-integaration 2022-08-07 12:25:23 +02:00
ajnart
70814d0bc6 Add Overseerr integration 2022-08-07 12:16:29 +02:00
ajnart
b489c07177 🐛 Fix a bug with mediadisplay 2022-08-07 12:16:15 +02:00
ajnart
a3bc9ab9f4 🏷️ Add type definitions for Movie/Tv/Request 2022-08-07 12:15:35 +02:00
ajnart
40a76593a2 🧪 Add testing page for overseerr request 2022-08-07 12:15:15 +02:00
ajnart
0e3c9e7ba8 🚧 Change query in SearchBar to use new API 2022-08-07 12:14:57 +02:00
ajnart
8abf2af212 Add ModalsProvider to the App 2022-08-07 12:14:37 +02:00
ajnart
13d70cf0fd ♻️ Rework Overseerr API 2022-08-07 12:14:17 +02:00
ajnart
f0bb3f08b0 🏷️ Fix missing types 2022-08-07 12:13:44 +02:00
ajnart
d07b51f67d 📦 Add Consola for logging 2022-08-07 12:13:26 +02:00
ajnart
6dfda07713 Merge branch 'dev' into mantine-v5 2022-08-02 23:03:06 +02:00
ajnart
fd3f58b501 🔧 Dashdot module changes
Fixes #316
2022-08-02 23:00:40 +02:00
ajnart
e4f91a1c00 🔧 Use PasswordInput for credentials 2022-08-02 23:00:38 +02:00
ajnart
f0d1c6daf9 🐳 Change docker image to Linuxserver 2022-08-02 22:59:20 +02:00
Thomas Camlong
33268fda53 ✏️ Fix spelling errors 2022-08-02 22:59:20 +02:00
ajnart
275aa30d45 📦 Bumb to Mantine v5.0.2 2022-08-02 22:56:18 +02:00
ajnart
7c0c986564 💃🏻 Styling and fixing lint errors 2022-08-02 05:22:38 +02:00
ajnart
c4d8fb2e00 🐛 Fix bugs in PingModule 2022-08-02 05:22:02 +02:00
ajnart
33b84b9039 💩 Write shitty code to fix MediaDisplay build 2022-08-02 05:21:30 +02:00
ajnart
498598424b 🔧 Move Calendar module to new popover api 2022-08-02 05:18:07 +02:00
ajnart
762690493a 💃🏻 Styling credits and Menu enabler 2022-08-02 05:17:19 +02:00
ajnart
456f0ff2ee 🐛 Fix a bug with toolstips 2022-08-02 05:16:54 +02:00
ajnart
12c13de1bd 💃🏻 Settings menu styling 2022-08-02 02:21:04 +02:00
ajnart
847e0855d8 💃🏻 Typing, Styling, Formatting 2022-08-02 00:21:51 +02:00
ajnart
4e75605ac0 💃🏻 Typing, Styling, Formatting 2022-08-02 00:21:11 +02:00
ajnart
eff2fc5ac7 🐛 Fix ModuleWrapper Hover state 2022-08-02 00:20:42 +02:00
ajnart
09a8dd7db8 🐛 Fix add or modify service undefined errors 2022-08-02 00:20:04 +02:00
ajnart
318dc83d2d 🐛 Fix Accordion in AppShelf 2022-08-02 00:19:39 +02:00
ajnart
c501cfae76 🐛 Fix keys attribute on ColorSelector 2022-08-01 21:11:37 +02:00
ajnart
8bc74f4e0f 🐛 Fix bug in AppShelfMenu
Menu was not closing on click
2022-08-01 21:11:11 +02:00
ajnart
af001d8dfa 🐛 Fix Hover in ModuleWrapper 2022-08-01 17:53:32 +02:00
ajnart
0e1419cc9a 🐛 Fix configLoader 2022-08-01 17:28:27 +02:00
Thomas Camlong
a7bcc5689a 🔀 Merge pull request #321 from ajnart/ajnart/issue307
 Add caching for icons with an image proxy
2022-08-01 17:14:09 +02:00
ajnart
bc05038427 Add caching for icons with an image proxy
Fixes #307
2022-08-01 17:12:18 +02:00
Thomas Camlong
b4bdf3737a 🔀 Merge pull request #320 from ajnart/ajnart/issue316
🔧 Dashdot module changes
2022-08-01 16:36:50 +02:00
ajnart
1fa2060e2b 🔧 Dashdot module changes
Fixes #316
2022-08-01 16:36:00 +02:00
ajnart
a8c5f07fb2 🚑 Hotfix Docker image with new NextJS version 2022-08-01 14:15:15 +02:00
ajnart
ff5a334f79 🔧 Use PasswordInput for credentials 2022-08-01 14:14:38 +02:00
Thomas Camlong
84fdd705b6 🔀 Merge pull request #317 from ajnart/ajnart/issue240
🐳 Change docker image to Linuxserver
2022-08-01 11:27:27 +02:00
ajnart
818bfad5f4 🐳 Change docker image to Linuxserver 2022-08-01 11:25:53 +02:00
Thomas Camlong
678059b1d3 ✏️ Fix spelling errors 2022-07-28 13:37:17 +02:00
ajnart
9f9566b27c ♻️ Refactor and WIP towards mantine v5 2022-07-26 01:21:04 +02:00
ajnart
d4d9e5cfcb 🚧 Work in progress for Mantine v5 2022-07-26 00:51:55 +02:00
ajnart
7fcdb17d84 ⬆️ Upgrade to Mantine V5.0 and React 18 2022-07-26 00:51:25 +02:00
ajnart
aa990671c1 💚 CI 2022-07-25 00:15:20 +02:00
ajnart
0ce3c4cd83 💚 CI 2022-07-25 00:05:28 +02:00
ajnart
77daffcc4b 💄 Small style changes 2022-07-24 23:54:57 +02:00
ajnart
b04171aa76 Add preview if media is available on Plex 2022-07-24 23:48:48 +02:00
ajnart
a3f5b252b9 🚧 WIP on Overseerr integration 2022-07-24 23:18:01 +02:00
ajnart
1f2d560893 🐛 Fix an old bug with the search bar 2022-07-24 21:54:42 +02:00
ajnart
87c55f264e 🔀 Merge branch 'dev' into overseerr-integration 2022-07-24 21:36:55 +02:00
Thomas Camlong
3fe8a4f7bc 🔀 Merge pull request #303 from manuel-rw/wiki-links-to-doc-links-migration
🚚 migrate wiki links to docusaurus links
2022-07-24 20:27:13 +02:00
ajnart
2cf89a1eb3 💚 Make CI not build the docker image on PRs 2022-07-24 20:22:24 +02:00
ajnart
68d81b97b4 ⬇️ Downgrade NextJS and React
Middleware didn't work in v12.2.3. Hopefully the password protection will work again now.
2022-07-23 22:22:55 +02:00
Manuel
c0917e31ed Merge branch 'dev' into wiki-links-to-doc-links-migration 2022-07-23 13:15:34 +02:00
ajnart
d438faa3d8 🚚 Rename dash. folder to dashdot
Was causing issues on non-unix systems
2022-07-23 13:10:10 +02:00
Manuel Ruwe
9dd5d50034 🚚 migrate wiki links to docusaurus links 2022-07-23 12:40:36 +02:00
Thomas Camlong
1d734633f0 🚑 Merge pull request #301 from ajnart/dev
🚑 Hotfix Docker image with new NextJS version
2022-07-23 00:25:45 +02:00
ajnart
2186756535 🚑 Hotfix Docker image with new NextJS version 2022-07-22 22:27:02 +02:00
Thomas Camlong
702428d24f 🚀 v0.8.2 🐋 Docker fixes and quality of life changes
https://github.com/ajnart/homarr/compare/v0.8.0...v0.8.2
2022-07-22 18:45:13 +02:00
ajnart
c8b0e7013d Improve Calendar module error handling 2022-07-22 18:30:15 +02:00
ajnart
385b4a3b24 🐛 Fix Docker integration actions timeouts 2022-07-22 18:08:32 +02:00
ajnart
5ccdf735ae Hide calendar module sensitive data
Working towards #259
2022-07-22 18:07:36 +02:00
ajnart
81a7789f9c Hide downloads module sensitive data
Working thowards #259
2022-07-22 17:18:33 +02:00
ajnart
a4defd330c 🔥 Remove console.log() statement 2022-07-22 16:21:15 +02:00
ajnart
4628d1d1d7 🚚 Change setCookies to setCookie 2022-07-22 16:20:59 +02:00
ajnart
7b719c2273 🐛 Fix bugs with async events from dockerode 2022-07-22 16:19:56 +02:00
ajnart
a9b840452e ✏️ Fix async data gathering with Dockerode 2022-07-22 16:19:28 +02:00
ajnart
3b0658fee2 Use tryMatchPort 2022-07-22 16:19:07 +02:00
ajnart
b5f1491fbb Add TryMatchPort function and update MatchingImages 2022-07-22 16:18:36 +02:00
ajnart
539903f053 ✏️ Remove resolutions in packagelock 2022-07-22 16:16:09 +02:00
Thomas Camlong
f7aa6338f1 🔀 Merge pull request #296 from RichyHBM/adding-docker-images
Adding docker images to match thanks to @RichyHBM !
2022-07-22 15:18:20 +02:00
Thomas "ajnart" Camlong
f20c2d4472 🔖 Bump version to v0.8.2 2022-07-22 13:20:34 +02:00
Thomas "ajnart" Camlong
d1d13396f8 💄 Linting and prettier 2022-07-22 13:20:02 +02:00
Thomas "ajnart" Camlong
bed08c84de ⬆️ Upgrade layout for new React and NextJS versions 2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
c0e1747e09 Make logo text togglable on/off 2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
ea8df25620 Add searching feature in docker table 2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
cd9e844001 🐛 Fix docker not getting all containers
Turned off containers will not be shown
2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
8eac0bed84 Improve login page
Styling and responsiveness
2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
d2eb31f510 ⬆️ Upgrade 404 page for NextJS latest 2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
ed72ab6ec7 🐛 Fix middleware due to new NextJS version
Fixes #297
2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
02d3766d60 ⬆️ Upgrade next.config.js for new NextJS version 2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
5b4f166216 📦 Upgrade to React18 2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
75ceab0cf1 🐛 Fix fetching images in MatchIcon 2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
91181aed13 🔧 Add vscode debug files 2022-07-22 13:18:46 +02:00
Thomas "ajnart" Camlong
3234f06a2d 🐛 Make docker container list scrollable
Fixes #295
2022-07-22 13:18:46 +02:00
Thomas Camlong
cac1059c16 Update feature-request.yml 2022-07-22 13:10:47 +02:00
RichyHBM
632376bed5 Additional hotio images 2022-07-21 18:50:27 +01:00
RichyHBM
64a29e7f4c Put qbittorrent in alphabetical order 2022-07-21 18:47:13 +01:00
RichyHBM
c6d8c9b2d8 Add hotio + other high usage images 2022-07-21 18:43:42 +01:00
RichyHBM
6915a1bfaf Add dashdot and linuxserver docker images to image to match list 2022-07-21 18:29:35 +01:00
Thomas "ajnart" Camlong
00751eeca5 Make discord integration a module
This allows for an error message if the docker integration fails to load
2022-07-21 11:43:43 +02:00
Thomas Camlong
715a4bd6c7 Merge pull request #292 from arghyadipchak/master
Fix Dash. compact view storage
2022-07-21 09:08:00 +02:00
Arghyadip Chakraborty
5df2c67c2f Fix Dash. compact view storage 2022-07-21 00:39:08 +05:30
ajnart
1de20d1583 Avancement on Overseerr integration 2022-05-29 21:39:57 +02:00
ajnart
596db5fefc ⬆️ Upgrade dependencies 2022-05-29 19:09:12 +02:00
ajnart
7ee56bd6ed add default overseer image display 2022-05-29 19:06:29 +02:00
109 changed files with 10183 additions and 12441 deletions

View File

@@ -3,7 +3,6 @@ module.exports = {
'mantine',
'plugin:@next/next/recommended',
'plugin:jest/recommended',
'plugin:storybook/recommended',
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"

View File

@@ -22,13 +22,3 @@ body:
- High (App breaking feature)
validations:
required: true
- type: checkboxes
id: idiot-check
attributes:
label: Please tick the boxes
description: Before submitting, please ensure that
options:
- label: You've read the [docs](https://github.com/ajnart/homarr#readme)
required: true
- label: You've checked for [duplicate issues](https://github.com/ajnart/homarr/issues)
required: true

View File

@@ -1,6 +1,8 @@
name: Master docker CI
# Workflow to build and publish docker image
name: Master CI
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
on:
push:
branches: [master]
@@ -22,72 +24,46 @@ jobs:
# Push image to GitHub Packages.
# See also https://docs.docker.com/docker-hub/builds/
yarn_install_and_build:
# Will run yarn install && yarn build
runs-on: ubuntu-latest
steps:
- name: Setup
uses: actions/setup-node@v3
- name: Checkout
uses: actions/checkout@v3
- name: Get yarn cache directory path
# to help speed up build times
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Yarn cache
# to help speed up build times
uses: actions/cache@v3
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-yarn-
- name: Nextjs cache
uses: actions/cache@v2
with:
# See here for caching with `yarn` https://github.com/actions/cache/blob/main/examples.md#node---yarn or you can leverage caching with actions/setup-node https://github.com/actions/setup-node
path: |
~/.npm
${{ github.workspace }}/.next/cache
# Generate a new cache whenever packages or source files change.
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
# If source files changed but packages didn't, rebuild from a prior cache.
restore-keys: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
- run: yarn install --immutable
- run: yarn build
- name: Cache build output
# to copy needed files to docker build job
uses: actions/cache@v2
id: restore-build
with:
path: |
./next.config.js
./pages/
./public/
./.next/static/
./.next/standalone/
./packages.json
key: ${{ github.sha }}
docker_image_build_and_push:
needs: [yarn_install_and_build]
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Setup
uses: actions/setup-node@v3
- name: Checkout
uses: actions/checkout@v2
- uses: actions/cache@v2
id: restore-build
uses: actions/checkout@v3
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn config get cacheFolder)"
- uses: actions/cache@v3
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Restore NextJS cache
uses: actions/cache@v2
with:
# See here for caching with `yarn` https://github.com/actions/cache/blob/main/examples.md#node---yarn or you can leverage caching with actions/setup-node https://github.com/actions/setup-node
path: |
./next.config.js
./pages/
./public/
./.next/static/
./.next/standalone/
./packages.json
key: ${{ github.sha }}
${{ github.workspace }}/.next/cache
# Generate a new cache whenever packages or source files change.
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
# If source files changed but packages didn't, rebuild from a prior cache.
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
- run: yarn install --immutable
- run: yarn build
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
@@ -98,10 +74,13 @@ jobs:
tags: |
type=raw,value=latest
type=pep440,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to GHCR
uses: docker/login-action@v2
with:
@@ -117,3 +96,5 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -15,9 +15,9 @@ on:
- '**.md'
workflow_dispatch:
inputs:
tags:
tag:
required: true
description: 'Tags to deploy to'
description: 'Tag to deploy to'
env:
# Use docker.io for Docker Hub if empty
@@ -30,6 +30,9 @@ jobs:
# See also https://docs.docker.com/docker-hub/builds/
yarn_install_and_build:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Setup
@@ -40,68 +43,34 @@ jobs:
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Yarn cache
uses: actions/cache@v3
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
run: echo "::set-output name=dir::$(yarn config get cacheFolder)"
- uses: actions/cache@v3
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-yarn-
restore-keys: |
${{ runner.os }}-yarn-
- name: Nextjs cache
- name: Restore NextJS cache
uses: actions/cache@v2
with:
# See here for caching with `yarn` https://github.com/actions/cache/blob/main/examples.md#node---yarn or you can leverage caching with actions/setup-node https://github.com/actions/setup-node
# See here for caching with `yarn` https://github.com/actions/cache/blob/main/examples.md#node---yarn or you can leverage caching with actions/setup-node https://github.com/actions/setup-node
path: |
~/.npm
${{ github.workspace }}/.next/cache
# Generate a new cache whenever packages or source files change.
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
# If source files changed but packages didn't, rebuild from a prior cache.
restore-keys: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
- run: yarn install --immutable
- run: yarn build
- name: Cache build output
uses: actions/cache@v2
id: restore-build
with:
path: |
./next.config.js
./pages/
./public/
./.next/static/
./.next/standalone/
./packages.json
key: ${{ github.sha }}
docker_image_build_and_push:
needs: [yarn_install_and_build]
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@v2
- uses: actions/cache@v2
id: restore-build
with:
path: |
./next.config.js
./pages/
./public/
./.next/static/
./.next/standalone/
./packages.json
key: ${{ github.sha }}
- name: Docker meta
if: github.event_name != 'pull_request'
id: meta
uses: docker/metadata-action@v4
with:
@@ -110,7 +79,8 @@ jobs:
# generate Docker tags based on the following events/attributes
tags: |
type=ref,event=pr
tpye=raw,value=dev,priority=1
type=raw,value=${{ github.event.inputs.tag }}, prefix=test-,enable=${{ github.event.inputs.tag != '' }}
tpye=raw,value=dev,priority=1,enable=${{ github.event.inputs.tag == '' }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
@@ -127,6 +97,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
if: github.event_name != 'pull_request'
uses: docker/build-push-action@v3
with:
platforms: linux/amd64,linux/arm64,linux/arm/v7
@@ -134,3 +105,5 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -1,30 +0,0 @@
module.exports = {
stories: ['../src/components/**/*.story.mdx', '../src/components/**/*.story.*'],
addons: [
'@storybook/addon-links',
'storybook-addon-mock/register',
'@storybook/addon-essentials',
],
typescript: {
check: false,
reactDocgen: false,
},
framework: '@storybook/react',
features: { emotionAlias: false },
webpackFinal: async (config, { configType }) => {
// `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION'
// You can change the configuration based on that.
// 'PRODUCTION' is used when building the static version of storybook.
// https://github.com/polkadot-js/extension/issues/621#issuecomment-759341776
// framer-motion uses the .mjs notation and we need to include it so that webpack will
// transpile it for us correctly (enables using a CJS module inside an ESM).
config.module.rules.push({
test: /\.mjs$/,
include: /node_modules/,
type: 'javascript/auto',
});
// Return the altered config
return config;
},
};

View File

@@ -1,16 +0,0 @@
import { MantineProvider, ColorSchemeProvider } from '@mantine/core';
import { NotificationsProvider } from '@mantine/notifications';
export const parameters = { layout: 'fullscreen' };
function ThemeWrapper(props: { children: React.ReactNode }) {
return (
<ColorSchemeProvider colorScheme="light" toggleColorScheme={() => {}}>
<MantineProvider withGlobalStyles withNormalizeCSS>
<NotificationsProvider>{props.children}</NotificationsProvider>
</MantineProvider>
</ColorSchemeProvider>
);
}
export const decorators = [(renderStory: Function) => <ThemeWrapper>{renderStory()}</ThemeWrapper>];

28
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,28 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug server-side",
"type": "node-terminal",
"request": "launch",
"command": "yarn dev"
},
{
"name": "Next.js: debug client-side",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000"
},
{
"name": "Next.js: debug full stack",
"type": "node-terminal",
"request": "launch",
"command": "yarn dev",
"serverReadyAction": {
"pattern": "started server on .+, url: (https?://.+)",
"uriFormat": "%s",
"action": "debugWithChrome"
}
}
]
}

View File

@@ -1,14 +1,21 @@
FROM node:16-alpine
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED 1
ENV NODE_ENV production
COPY /next.config.js ./
COPY /public ./public
COPY /package.json ./package.json
# Automatically leverage output traces to reduce image size. https://nextjs.org/docs/advanced-features/output-file-tracing
COPY /.next/standalone ./
COPY /.next/static ./.next/static
COPY next.config.js ./
COPY public ./public
COPY package.json ./package.json
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY .next/standalone ./
COPY .next/static ./.next/static
EXPOSE 7575
ENV PORT 7575
RUN apk add tzdata
VOLUME /app/data/configs
CMD ["node", "server.js"]

View File

@@ -21,7 +21,7 @@
<i>Join the discord! — Don't forget to star the repo if you are enjoying the project!</i>
</p>
<p align="center">
<a href="https://homarr.ajnart.fr/"><strong> Demo ↗️ </strong></a> • <a href="#-installation"><strong> Install ➡️ </strong></a> • <a href="https://github.com/ajnart/homarr/wiki"><strong> Read the Wiki 📄 </strong></a>
<a href="https://homarr.ajnart.fr/"><strong> Demo ↗️ </strong></a> • <a href="https://homarr.vercel.app/docs/quick-start/"><strong> Install ➡️ </strong></a> • <a href="https://homarr.vercel.app/docs/about"><strong> Read the Docs 📄 </strong></a>
</p>
---
@@ -33,7 +33,7 @@ Homarr is a simple and lightweight homepage for your server, that helps you easi
It integrates with the services you use to display information on the homepage (E.g. Show upcoming Sonarr/Radarr releases).
For a full list of integrations look at: [wiki/integrations](https://github.com/ajnart/homarr/wiki/Integrations)
For a full list of integrations, [head over to our documentation](https://homarr.vercel.app/docs/advanced-features/integrations).
If you have any questions about Homarr or want to share information with us, please go to one of the following places:
@@ -42,7 +42,7 @@ If you have any questions about Homarr or want to share information with us, ple
*Before you file an [issue](https://github.com/ajnart/homarr/issues/new/choose), make sure you have the read [known issues](#-known-issues) section.*
**For more information, [read the wiki!](https://github.com/ajnart/homarr/wiki)**
**For more information, [read the documentation!](https://homarr.vercel.app/docs/about)**
<details>
<summary><b>Table of Contents</b></summary>
@@ -64,9 +64,9 @@ If you have any questions about Homarr or want to share information with us, ple
## ✨ Features
- Integrates with services you use.
- Search the web direcetly from your homepage.
- Search the web directly from your homepage.
- Real-time status indicator for every service.
- Automatically finds icons while you type the name of a serivce.
- Automatically finds icons while you type the name of a service.
- Widgets that can display all types of information.
- Easy deployment with Docker.
- Very light-weight and fast.
@@ -195,7 +195,7 @@ SOFTWARE.
---
<p align="center">
<i>Thank you for visiting! <b>For more information <a href="https://github.com/ajnart/homarr/wiki">read the wiki!</a></b></i>
<i>Thank you for visiting! <b>For more information <a href="https://homarr.vercel.app/docs/about">read the documentation!</a></b></i>
<br/>
<br/>
</p>

View File

@@ -15,9 +15,6 @@
"modules": {
"Search Bar": {
"enabled": true
},
"Date": {
"enabled": false
}
}
}

View File

@@ -1,2 +1,2 @@
export const REPO_URL = 'ajnart/homarr';
export const CURRENT_VERSION = 'v0.8.0';
export const CURRENT_VERSION = 'v0.9.2';

View File

@@ -5,10 +5,12 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({
});
module.exports = withBundleAnalyzer({
images: {
domains: ['cdn.jsdelivr.net'],
},
reactStrictMode: false,
eslint: {
ignoreDuringBuilds: true,
experimental: {
outputStandalone: true,
},
output: 'standalone',
basePath: env.BASE_URL,
});

View File

@@ -1,7 +1,8 @@
{
"name": "homarr",
"version": "0.8.0",
"version": "0.9.2",
"description": "Homarr - A homepage for your server.",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/ajnart/homarr"
@@ -19,72 +20,72 @@
"prettier:check": "prettier --check \"**/*.{ts,tsx}\"",
"prettier:write": "prettier --write \"**/*.{ts,tsx}\"",
"test": "npm run prettier:check && npm run lint && npm run typecheck && npm run jest",
"storybook": "start-storybook -p 7001",
"storybook:build": "build-storybook",
"ci": "yarn test && yarn lint --fix && yarn typecheck && yarn prettier:write"
},
"dependencies": {
"@ctrl/deluge": "^4.1.0",
"@ctrl/qbittorrent": "^4.0.0",
"@ctrl/shared-torrent": "^4.1.0",
"@ctrl/qbittorrent": "^4.1.0",
"@ctrl/shared-torrent": "^4.1.1",
"@ctrl/transmission": "^4.1.1",
"@dnd-kit/core": "^6.0.1",
"@dnd-kit/sortable": "^7.0.0",
"@dnd-kit/core": "^6.0.5",
"@dnd-kit/sortable": "^7.0.1",
"@dnd-kit/utilities": "^3.2.0",
"@mantine/core": "^4.2.8",
"@mantine/dates": "^4.2.8",
"@mantine/dropzone": "^4.2.8",
"@mantine/form": "^4.2.8",
"@mantine/hooks": "^4.2.8",
"@mantine/next": "^4.2.8",
"@mantine/notifications": "^4.2.8",
"@mantine/prism": "^4.2.8",
"@emotion/react": "^11.10.0",
"@emotion/server": "^11.10.0",
"@mantine/carousel": "^5.1.0",
"@mantine/core": "^5.1.0",
"@mantine/dates": "^5.1.0",
"@mantine/dropzone": "^5.1.0",
"@mantine/form": "^5.1.0",
"@mantine/hooks": "^5.1.0",
"@mantine/modals": "^5.1.0",
"@mantine/next": "^5.1.0",
"@mantine/notifications": "^5.1.0",
"@mantine/prism": "^5.0.0",
"@nivo/core": "^0.79.0",
"@nivo/line": "^0.79.1",
"@tabler/icons": "^1.68.0",
"@tabler/icons": "^1.78.0",
"add": "^2.0.6",
"axios": "^0.27.2",
"consola": "^2.15.3",
"cookies-next": "^2.1.1",
"dayjs": "^1.11.3",
"dayjs": "^1.11.4",
"dockerode": "^3.3.2",
"framer-motion": "^6.3.1",
"embla-carousel-react": "^7.0.0",
"framer-motion": "^6.5.1",
"js-file-download": "^0.4.12",
"next": "^12.2.0",
"prism-react-renderer": "^1.3.1",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"systeminformation": "^5.11.16",
"uuid": "^8.3.2"
"next": "12.1.6",
"prism-react-renderer": "^1.3.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sharp": "^0.30.7",
"systeminformation": "^5.12.1",
"uuid": "^8.3.2",
"yarn": "^1.22.19"
},
"devDependencies": {
"@babel/core": "^7.17.8",
"@next/bundle-analyzer": "^12.2.0",
"@next/eslint-plugin-next": "^12.2.0",
"@storybook/react": "^6.5.4",
"@next/bundle-analyzer": "^12.1.4",
"@next/eslint-plugin-next": "^12.1.4",
"@types/dockerode": "^3.3.9",
"@types/node": "^17.0.23",
"@types/react": "17.0.43",
"@types/node": "17.0.1",
"@types/react": "17.0.1",
"@types/uuid": "^8.3.4",
"@typescript-eslint/eslint-plugin": "^5.16.0",
"@typescript-eslint/parser": "^5.16.0",
"eslint": "^8.11.0",
"@typescript-eslint/eslint-plugin": "^5.30.7",
"@typescript-eslint/parser": "^5.30.7",
"eslint": "^8.20.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^16.1.0",
"eslint-config-mantine": "1.1.0",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-jest": "^26.1.3",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.3.0",
"eslint-plugin-storybook": "^0.5.11",
"eslint-plugin-testing-library": "^5.2.0",
"eslint-config-airbnb-typescript": "^17.0.0",
"eslint-config-mantine": "^2.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jest": "^26.6.0",
"eslint-plugin-jsx-a11y": "^6.6.1",
"eslint-plugin-react": "^7.30.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-testing-library": "^5.5.1",
"eslint-plugin-unused-imports": "^2.0.0",
"jest": "^28.1.0",
"prettier": "^2.6.2",
"require-from-string": "^2.0.2",
"typescript": "4.6.4"
},
"resolutions": {
"@types/react": "17.0.30"
"jest": "^28.1.3",
"prettier": "^2.7.1",
"typescript": "^4.7.4"
},
"packageManager": "yarn@3.2.1"
}

View File

@@ -8,22 +8,22 @@ import {
LoadingOverlay,
Modal,
MultiSelect,
ScrollArea,
PasswordInput,
Select,
Stack,
Switch,
Tabs,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDebouncedValue } from '@mantine/hooks';
import { IconApps as Apps } from '@tabler/icons';
import { IconApps } from '@tabler/icons';
import { useEffect, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { useDebouncedValue } from '@mantine/hooks';
import { useConfig } from '../../tools/state';
import { ServiceTypeList, StatusCodes } from '../../tools/types';
import { tryMatchPort, ServiceTypeList, StatusCodes } from '../../tools/types';
import Tip from '../layout/Tip';
export function AddItemShelfButton(props: any) {
@@ -39,23 +39,24 @@ export function AddItemShelfButton(props: any) {
>
<AddAppShelfItemForm setOpened={setOpened} />
</Modal>
<ActionIcon
variant="default"
radius="md"
size="xl"
color="blue"
style={props.style}
onClick={() => setOpened(true)}
>
<Tooltip label="Add a service">
<Apps />
</Tooltip>
</ActionIcon>
<Tooltip withinPortal label="Add a service">
<ActionIcon
variant="default"
radius="md"
size="xl"
color="blue"
style={props.style}
onClick={() => setOpened(true)}
>
<IconApps />
</ActionIcon>
</Tooltip>
</>
);
}
function MatchIcon(name: string, form: any) {
function MatchIcon(name: string | undefined, form: any) {
if (name === undefined || name === '') return null;
fetch(
`https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/${name
.replace(/\s+/g, '-')
@@ -77,25 +78,7 @@ function MatchService(name: string, form: any) {
}
}
function MatchPort(name: string, form: any) {
const portmap = [
{ name: 'qbittorrent', value: '8080' },
{ name: 'sonarr', value: '8989' },
{ name: 'radarr', value: '7878' },
{ name: 'lidarr', value: '8686' },
{ name: 'readarr', value: '8787' },
{ name: 'deluge', value: '8112' },
{ name: 'transmission', value: '9091' },
{ name: 'dash.', value: '3001' },
];
// Match name with portmap key
const port = portmap.find((p) => p.name === name.toLowerCase());
if (port) {
form.setFieldValue('url', `http://localhost:${port.value}`);
}
}
const DEFAULT_ICON = '/favicon.svg';
const DEFAULT_ICON = '/favicon.png';
export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } & any) {
const { setOpened } = props;
@@ -103,25 +86,26 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
const [isLoading, setLoading] = useState(false);
// Extract all the categories from the services in config
const categoryList = config.services.reduce((acc, cur) => {
const InitialCategories = config.services.reduce((acc, cur) => {
if (cur.category && !acc.includes(cur.category)) {
acc.push(cur.category);
}
return acc;
}, [] as string[]);
const [categories, setCategories] = useState<string[]>(InitialCategories);
const form = useForm({
initialValues: {
id: props.id ?? uuidv4(),
type: props.type ?? 'Other',
category: props.category ?? undefined,
category: props.category ?? null,
name: props.name ?? '',
icon: props.icon ?? DEFAULT_ICON,
url: props.url ?? '',
apiKey: props.apiKey ?? (undefined as unknown as string),
username: props.username ?? (undefined as unknown as string),
password: props.password ?? (undefined as unknown as string),
openedUrl: props.openedUrl ?? (undefined as unknown as string),
apiKey: props.apiKey ?? undefined,
username: props.username ?? undefined,
password: props.password ?? undefined,
openedUrl: props.openedUrl ?? undefined,
status: props.status ?? ['200'],
newTab: props.newTab ?? true,
},
@@ -151,10 +135,16 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
const [debounced, cancel] = useDebouncedValue(form.values.name, 250);
useEffect(() => {
if (form.values.name !== debounced || form.values.icon !== DEFAULT_ICON) return;
if (
form.values.name !== debounced ||
form.values.icon !== DEFAULT_ICON ||
form.values.type !== 'Other'
) {
return;
}
MatchIcon(form.values.name, form);
MatchService(form.values.name, form);
MatchPort(form.values.name, form);
tryMatchPort(form.values.name, form);
}, [debounced]);
// Try to set const hostname to new URL(form.values.url).hostname)
@@ -168,7 +158,7 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
return (
<>
<Center>
<Center mb="lg">
<Image
height={120}
width={120}
@@ -180,21 +170,22 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
</Center>
<form
onSubmit={form.onSubmit(() => {
if (JSON.stringify(form.values.status) === JSON.stringify(['200'])) {
form.values.status = undefined;
}
if (form.values.newTab === true) {
form.values.newTab = undefined;
const newForm = { ...form.values };
if (newForm.newTab === true) newForm.newTab = undefined;
if (newForm.openedUrl === '') newForm.openedUrl = undefined;
if (newForm.category === null) newForm.category = undefined;
if (newForm.status.length === 1 && newForm.status[0] === '200') {
delete newForm.status;
}
// If service already exists, update it.
if (config.services && config.services.find((s) => s.id === form.values.id)) {
if (config.services && config.services.find((s) => s.id === newForm.id)) {
setConfig({
...config,
// replace the found item by matching ID
services: config.services.map((s) => {
if (s.id === form.values.id) {
if (s.id === newForm.id) {
return {
...form.values,
...newForm,
};
}
return s;
@@ -203,158 +194,162 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
} else {
setConfig({
...config,
services: [...config.services, form.values],
services: [...config.services, newForm],
});
}
setOpened(false);
form.reset();
})}
>
<Tabs grow>
<Tabs.Tab label="Options">
<ScrollArea style={{ height: 500 }} scrollbarSize={4}>
<Group direction="column" grow>
<TextInput
required
label="Service name"
placeholder="Plex"
{...form.getInputProps('name')}
/>
<TextInput
required
label="Icon URL"
placeholder={DEFAULT_ICON}
{...form.getInputProps('icon')}
/>
<TextInput
required
label="Service URL"
placeholder="http://localhost:7575"
{...form.getInputProps('url')}
/>
<TextInput
label="On Click URL"
placeholder="http://sonarr.example.com"
{...form.getInputProps('openedUrl')}
/>
<Select
label="Service type"
defaultValue="Other"
placeholder="Pick one"
required
searchable
data={ServiceTypeList}
{...form.getInputProps('type')}
/>
<Select
label="Category"
data={categoryList}
placeholder="Select a category or create a new one"
nothingFound="Nothing found"
searchable
clearable
creatable
onClick={(e) => {
e.preventDefault();
}}
getCreateLabel={(query) => `+ Create "${query}"`}
onCreate={(query) => {}}
{...form.getInputProps('category')}
/>
<LoadingOverlay visible={isLoading} />
{(form.values.type === 'Sonarr' ||
form.values.type === 'Radarr' ||
form.values.type === 'Lidarr' ||
form.values.type === 'Readarr') && (
<>
<TextInput
required
label="API key"
placeholder="Your API key"
value={form.values.apiKey}
onChange={(event) => {
form.setFieldValue('apiKey', event.currentTarget.value);
}}
error={form.errors.apiKey && 'Invalid API key'}
/>
<Tip>
Get your API key{' '}
<Anchor
target="_blank"
weight="bold"
style={{ fontStyle: 'inherit', fontSize: 'inherit' }}
href={`${hostname}/settings/general`}
>
here.
</Anchor>
</Tip>
</>
)}
{form.values.type === 'qBittorrent' && (
<>
<TextInput
required
label="Username"
placeholder="admin"
value={form.values.username}
onChange={(event) => {
form.setFieldValue('username', event.currentTarget.value);
}}
error={form.errors.username && 'Invalid username'}
/>
<TextInput
required
label="Password"
placeholder="adminadmin"
value={form.values.password}
onChange={(event) => {
form.setFieldValue('password', event.currentTarget.value);
}}
error={form.errors.password && 'Invalid password'}
/>
</>
)}
{form.values.type === 'Deluge' && (
<>
<TextInput
label="Password"
placeholder="password"
value={form.values.password}
onChange={(event) => {
form.setFieldValue('password', event.currentTarget.value);
}}
error={form.errors.password && 'Invalid password'}
/>
</>
)}
{form.values.type === 'Transmission' && (
<>
<TextInput
label="Username"
placeholder="admin"
value={form.values.username}
onChange={(event) => {
form.setFieldValue('username', event.currentTarget.value);
}}
error={form.errors.username && 'Invalid username'}
/>
<TextInput
label="Password"
placeholder="adminadmin"
value={form.values.password}
onChange={(event) => {
form.setFieldValue('password', event.currentTarget.value);
}}
error={form.errors.password && 'Invalid password'}
/>
</>
)}
</Group>
</ScrollArea>
</Tabs.Tab>
<Tabs.Tab label="Advanced Options">
<Group direction="column" grow>
<Tabs defaultValue="Options">
<Tabs.List grow>
<Tabs.Tab value="Options">Options</Tabs.Tab>
<Tabs.Tab value="Advanced Options">Advanced options</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="Options">
<Stack>
<TextInput
required
label="Service name"
placeholder="Plex"
{...form.getInputProps('name')}
/>
<TextInput
required
label="Icon URL"
placeholder={DEFAULT_ICON}
{...form.getInputProps('icon')}
/>
<TextInput
required
label="Service URL"
placeholder="http://localhost:7575"
{...form.getInputProps('url')}
/>
<TextInput
label="On Click URL"
placeholder="http://sonarr.example.com"
{...form.getInputProps('openedUrl')}
/>
<Select
label="Service type"
defaultValue="Other"
placeholder="Pick one"
required
searchable
data={ServiceTypeList}
{...form.getInputProps('type')}
/>
<Select
label="Category"
data={categories}
placeholder="Select a category or create a new one"
nothingFound="Nothing found"
searchable
clearable
creatable
onCreate={(query) => {
const item = { value: query, label: query };
setCategories([...InitialCategories, query]);
return item;
}}
getCreateLabel={(query) => `+ Create "${query}"`}
{...form.getInputProps('category')}
/>
<LoadingOverlay visible={isLoading} />
{(form.values.type === 'Sonarr' ||
form.values.type === 'Radarr' ||
form.values.type === 'Lidarr' ||
form.values.type === 'Overseerr' ||
form.values.type === 'Jellyseerr' ||
form.values.type === 'Readarr') && (
<>
<TextInput
required
label="API key"
placeholder="Your API key"
value={form.values.apiKey}
onChange={(event) => {
form.setFieldValue('apiKey', event.currentTarget.value);
}}
error={form.errors.apiKey && 'Invalid API key'}
/>
<Tip>
Get your API key{' '}
<Anchor
target="_blank"
weight="bold"
style={{ fontStyle: 'inherit', fontSize: 'inherit' }}
href={`${hostname}/settings/general`}
>
here.
</Anchor>
</Tip>
</>
)}
{form.values.type === 'qBittorrent' && (
<>
<TextInput
required
label="Username"
placeholder="admin"
value={form.values.username}
onChange={(event) => {
form.setFieldValue('username', event.currentTarget.value);
}}
error={form.errors.username && 'Invalid username'}
/>
<PasswordInput
required
label="Password"
placeholder="adminadmin"
value={form.values.password}
onChange={(event) => {
form.setFieldValue('password', event.currentTarget.value);
}}
error={form.errors.password && 'Invalid password'}
/>
</>
)}
{form.values.type === 'Deluge' && (
<>
<PasswordInput
label="Password"
placeholder="password"
value={form.values.password}
onChange={(event) => {
form.setFieldValue('password', event.currentTarget.value);
}}
error={form.errors.password && 'Invalid password'}
/>
</>
)}
{form.values.type === 'Transmission' && (
<>
<TextInput
label="Username"
placeholder="admin"
value={form.values.username}
onChange={(event) => {
form.setFieldValue('username', event.currentTarget.value);
}}
error={form.errors.username && 'Invalid username'}
/>
<PasswordInput
label="Password"
placeholder="adminadmin"
value={form.values.password}
onChange={(event) => {
form.setFieldValue('password', event.currentTarget.value);
}}
error={form.errors.password && 'Invalid password'}
/>
</>
)}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="Advanced Options">
<Stack>
<MultiSelect
required
label="HTTP Status Codes"
@@ -372,8 +367,8 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
defaultChecked={form.values.newTab}
{...form.getInputProps('newTab')}
/>
</Group>
</Tabs.Tab>
</Stack>
</Tabs.Panel>
</Tabs>
<Group grow position="center" mt="xl">
<Button type="submit">{props.message ?? 'Add service'}</Button>

View File

@@ -1,27 +0,0 @@
import { SimpleGrid } from '@mantine/core';
import AppShelf from './AppShelf';
import { AppShelfItem } from './AppShelfItem';
export default {
title: 'Item Shelf',
component: AppShelf,
args: {
service: {
name: 'qBittorrent',
url: 'http://',
icon: 'https://cdn.jsdelivr.net/gh/IceWhaleTech/CasaOS-AppStore@main/Apps/qBittorrent/icon.png',
type: 'qBittorrent',
apiKey: '',
},
},
};
export const Default = (args: any) => <AppShelf {...args} />;
export const One = (args: any) => <AppShelfItem {...args} />;
export const Ten = (args: any) => (
<SimpleGrid>
{Array.from(Array(10)).map((_, i) => (
<AppShelfItem {...args} key={i} />
))}
</SimpleGrid>
);

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Accordion, createStyles, Grid, Group, Paper, useMantineColorScheme } from '@mantine/core';
import { Accordion, Grid, Paper, Stack, useMantineColorScheme } from '@mantine/core';
import {
closestCenter,
DndContext,
@@ -14,48 +14,26 @@ import { useLocalStorage } from '@mantine/hooks';
import { useConfig } from '../../tools/state';
import { SortableAppShelfItem, AppShelfItem } from './AppShelfItem';
import { ModuleMenu, ModuleWrapper } from '../modules/moduleWrapper';
import { DownloadsModule } from '../modules';
import DownloadComponent from '../modules/downloads/DownloadsModule';
const useStyles = createStyles((theme, _params) => ({
item: {
overflow: 'hidden',
borderLeft: '3px solid transparent',
borderRight: '3px solid transparent',
borderBottom: '3px solid transparent',
borderRadius: '20px',
borderColor: theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1],
marginTop: theme.spacing.md,
},
control: {
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1],
borderRadius: theme.spacing.md,
'&:hover': {
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1],
},
},
content: {
margin: theme.spacing.md,
},
label: {
overflow: 'visible',
},
}));
import { ModuleMenu, ModuleWrapper } from '../../modules/moduleWrapper';
import { DownloadsModule } from '../../modules';
import DownloadComponent from '../../modules/downloads/DownloadsModule';
const AppShelf = (props: any) => {
const { classes, cx } = useStyles(props);
const [toggledCategories, settoggledCategories] = useLocalStorage({
const { config, setConfig } = useConfig();
// Extract all the categories from the services in config
const categoryList = config.services.reduce((acc, cur) => {
if (cur.category && !acc.includes(cur.category)) {
acc.push(cur.category);
}
return acc;
}, [] as string[]);
const [toggledCategories, setToggledCategories] = useLocalStorage({
key: 'app-shelf-toggled',
// This is a bit of a hack to get the 5 first categories to be toggled on by default
defaultValue: { 0: true, 1: true, 2: true, 3: true, 4: true } as Record<string, boolean>,
// This is a bit of a hack to toggle the categories on the first load, return a string[] of the categories
defaultValue: categoryList,
});
const [activeId, setActiveId] = useState(null);
const { config, setConfig } = useConfig();
const { colorScheme } = useMantineColorScheme();
const sensors = useSensors(
@@ -93,15 +71,8 @@ const AppShelf = (props: any) => {
setActiveId(null);
}
// Extract all the categories from the services in config
const categoryList = config.services.reduce((acc, cur) => {
if (cur.category && !acc.includes(cur.category)) {
acc.push(cur.category);
}
return acc;
}, [] as string[]);
const item = (filter?: string) => {
const getItems = (filter?: string) => {
// If filter is not set, return all the services without a category or a null category
let filtered = config.services;
if (!filter) {
@@ -155,54 +126,62 @@ const AppShelf = (props: any) => {
const downloadEnabled = config.modules?.[DownloadsModule.title]?.enabled ?? false;
// Create an item with 0: true, 1: true, 2: true... For each category
return (
// Return one item for each category
<Group grow direction="column">
// TODO: Style accordion so that the bar is transparent to the user settings
<Stack>
<Accordion
disableIconRotation
classNames={classes}
variant="separated"
radius="lg"
order={2}
iconPosition="right"
multiple
initialState={toggledCategories}
onChange={(idx) => settoggledCategories(idx)}
value={toggledCategories}
onChange={(state) => {
setToggledCategories([...state]);
}}
>
{categoryList.map((category, idx) => (
<Accordion.Item key={category} label={category}>
{item(category)}
<Accordion.Item key={category} value={idx.toString()}>
<Accordion.Control>{category}</Accordion.Control>
<Accordion.Panel>{getItems(category)}</Accordion.Panel>
</Accordion.Item>
))}
{/* Return the item for all services without category */}
{noCategory && noCategory.length > 0 ? (
<Accordion.Item key="Other" label="Other">
{item()}
<Accordion.Item key="Other" value="Other">
<Accordion.Control>Other</Accordion.Control>
<Accordion.Panel>{getItems()}</Accordion.Panel>
</Accordion.Item>
) : null}
{downloadEnabled ? (
<Accordion.Item key="Downloads" label="Your downloads">
<Paper
p="lg"
radius="lg"
style={{
background: `rgba(${colorScheme === 'dark' ? '37, 38, 43,' : '255, 255, 255,'} \
<Accordion.Item key="Downloads" value="Your downloads">
<Accordion.Control>Your downloads</Accordion.Control>
<Accordion.Panel>
<Paper
p="lg"
radius="lg"
style={{
background: `rgba(${colorScheme === 'dark' ? '37, 38, 43,' : '255, 255, 255,'} \
${(config.settings.appOpacity || 100) / 100}`,
borderColor: `rgba(${colorScheme === 'dark' ? '37, 38, 43,' : '233, 236, 239,'} \
borderColor: `rgba(${
colorScheme === 'dark' ? '37, 38, 43,' : '233, 236, 239,'
} \
${(config.settings.appOpacity || 100) / 100}`,
}}
>
<ModuleMenu module={DownloadsModule} />
<DownloadComponent />
</Paper>
</Accordion.Item>
}}
>
<ModuleMenu module={DownloadsModule} />
<DownloadComponent />
</Paper>
</Accordion.Panel>
</Accordion.Item>
) : null}
</Accordion>
</Group>
</Stack>
);
}
return (
<Group grow direction="column">
{item()}
<Stack>
{getItems()}
<ModuleWrapper mt="xl" module={DownloadsModule} />
</Group>
</Stack>
);
};

View File

@@ -3,17 +3,17 @@ import {
Card,
Anchor,
AspectRatio,
Image,
Center,
createStyles,
useMantineColorScheme,
Image,
} from '@mantine/core';
import { motion } from 'framer-motion';
import { useState } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { serviceItem } from '../../tools/types';
import PingComponent from '../modules/ping/PingModule';
import PingComponent from '../../modules/ping/PingModule';
import AppShelfMenu from './AppShelfMenu';
import { useConfig } from '../../tools/state';
@@ -120,18 +120,18 @@ export function AppShelfItem(props: any) {
scale: 1.1,
}}
>
<Image
styles={{ root: { cursor: 'pointer' } }}
width={80}
height={80}
src={service.icon}
fit="contain"
onClick={() => {
if (service.openedUrl) {
window.open(service.openedUrl, service.newTab === false ? '_top' : '_blank');
} else window.open(service.url, service.newTab === false ? '_top' : '_blank');
}}
/>
<Anchor
href={service.openedUrl ?? service.url}
target={service.newTab === false ? '_top' : '_blank'}
>
<Image
styles={{ root: { cursor: 'pointer' } }}
width={80}
height={80}
src={service.icon}
fit="contain"
/>
</Anchor>
</motion.i>
</AspectRatio>
<PingComponent url={service.url} status={service.status} />

View File

@@ -1,14 +1,16 @@
import { Menu, Modal, Text, useMantineTheme } from '@mantine/core';
import { ActionIcon, Menu, Modal, Text, useMantineTheme } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { useState } from 'react';
import { IconCheck as Check, IconEdit as Edit, IconTrash as Trash } from '@tabler/icons';
import { IconCheck as Check, IconEdit as Edit, IconMenu, IconTrash as Trash } from '@tabler/icons';
import { useConfig } from '../../tools/state';
import { serviceItem } from '../../tools/types';
import { AddAppShelfItemForm } from './AddAppShelfItem';
import { useColorTheme } from '../../tools/color';
export default function AppShelfMenu(props: any) {
const { service }: { service: serviceItem } = props;
const { config, setConfig } = useConfig();
const { secondaryColor } = useColorTheme();
const theme = useMantineTheme();
const [opened, setOpened] = useState(false);
return (
@@ -23,49 +25,54 @@ export default function AppShelfMenu(props: any) {
<AddAppShelfItemForm setOpened={setOpened} {...service} message="Save service" />
</Modal>
<Menu
position="right"
radius="md"
withinPortal
width={150}
shadow="xl"
withArrow
radius="md"
position="right"
styles={{
body: {
dropdown: {
// Add shadow and elevation to the body
boxShadow: '0 0 14px 14px rgba(0, 0, 0, 0.05)',
},
}}
>
<Menu.Label>Settings</Menu.Label>
<Menu.Item
color="primary"
icon={<Edit />}
// TODO: #2 Add the ability to edit the service.
onClick={() => setOpened(true)}
>
Edit
</Menu.Item>
<Menu.Label>Danger zone</Menu.Label>
<Menu.Item
color="red"
onClick={(e: any) => {
setConfig({
...config,
services: config.services.filter((s) => s.id !== service.id),
});
showNotification({
autoClose: 5000,
title: (
<Text>
Service <b>{service.name}</b> removed successfully!
</Text>
),
color: 'green',
icon: <Check />,
message: undefined,
});
}}
icon={<Trash />}
>
Delete
</Menu.Item>
<Menu.Target>
<ActionIcon style={{}}>
<IconMenu />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Settings</Menu.Label>
<Menu.Item color={secondaryColor} icon={<Edit />} onClick={() => setOpened(true)}>
Edit
</Menu.Item>
<Menu.Label>Danger zone</Menu.Label>
<Menu.Item
color="red"
onClick={(e: any) => {
setConfig({
...config,
services: config.services.filter((s) => s.id !== service.id),
});
showNotification({
autoClose: 5000,
title: (
<Text>
Service <b>{service.name}</b> removed successfully!
</Text>
),
color: 'green',
icon: <Check />,
message: undefined,
});
}}
icon={<Trash />}
>
Delete
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
);

View File

@@ -1,32 +1,34 @@
import { Center, Loader, Select, Tooltip } from '@mantine/core';
import { setCookies } from 'cookies-next';
import { setCookie } from 'cookies-next';
import { useEffect, useState } from 'react';
import { useConfig } from '../../tools/state';
export default function ConfigChanger() {
const { config, loadConfig, setConfig, getConfigs } = useConfig();
const [configList, setConfigList] = useState([] as string[]);
const [configList, setConfigList] = useState<string[]>([]);
const [value, setValue] = useState(config.name);
useEffect(() => {
getConfigs().then((configs) => setConfigList(configs));
// setConfig(initialConfig);
}, [config]);
// If configlist is empty, return a loading indicator
if (configList.length === 0) {
return (
<Center>
<Tooltip label={"Loading your configs. This doesn't load in vercel."}>
<Tooltip label={"Loading your configs. This doesn't load in vercel."}>
<Center>
<Loader />
</Tooltip>
</Center>
</Center>
</Tooltip>
);
}
// return <Select data={[{ value: '1', label: '1' },]} onChange={(e) => console.log(e)} value="1" />;
return (
<Select
defaultValue={config.name}
label="Config loader"
value={value}
defaultValue={config.name}
onChange={(e) => {
loadConfig(e ?? 'default');
setCookies('config-name', e ?? 'default', {
setCookie('config-name', e ?? 'default', {
maxAge: 60 * 60 * 24 * 30,
sameSite: 'strict',
});

View File

@@ -1,68 +1,18 @@
import { Group, Text, useMantineTheme, MantineTheme } from '@mantine/core';
import {
IconUpload as Upload,
IconPhoto as Photo,
IconX as X,
IconCheck as Check,
TablerIcon,
} from '@tabler/icons';
import { DropzoneStatus, FullScreenDropzone } from '@mantine/dropzone';
import { Group, Text, useMantineTheme } from '@mantine/core';
import { IconX as X, IconCheck as Check, IconX, IconPhoto, IconUpload } from '@tabler/icons';
import { showNotification } from '@mantine/notifications';
import { useRef } from 'react';
import { useRouter } from 'next/router';
import { setCookies } from 'cookies-next';
import { setCookie } from 'cookies-next';
import { Dropzone } from '@mantine/dropzone';
import { useConfig } from '../../tools/state';
import { Config } from '../../tools/types';
import { migrateToIdConfig } from '../../tools/migrate';
function getIconColor(status: DropzoneStatus, theme: MantineTheme) {
return status.accepted
? theme.colors[theme.primaryColor][theme.colorScheme === 'dark' ? 4 : 6]
: status.rejected
? theme.colors.red[theme.colorScheme === 'dark' ? 4 : 6]
: theme.colorScheme === 'dark'
? theme.colors.dark[0]
: theme.colors.gray[7];
}
function ImageUploadIcon({
status,
...props
}: React.ComponentProps<TablerIcon> & { status: DropzoneStatus }) {
if (status.accepted) {
return <Upload {...props} />;
}
if (status.rejected) {
return <X {...props} />;
}
return <Photo {...props} />;
}
export const dropzoneChildren = (status: DropzoneStatus, theme: MantineTheme) => (
<Group position="center" spacing="xl" style={{ minHeight: 220, pointerEvents: 'none' }}>
<ImageUploadIcon status={status} style={{ color: getIconColor(status, theme) }} size={80} />
<div>
<Text size="xl" inline>
Drag images here or click to select files
</Text>
<Text size="sm" color="dimmed" inline mt={7}>
Attach as many files as you like, each file should not exceed 5mb
</Text>
</div>
</Group>
);
export default function LoadConfigComponent(props: any) {
const { setConfig } = useConfig();
const theme = useMantineTheme();
const router = useRouter();
const openRef = useRef<() => void>();
return (
<FullScreenDropzone
<Dropzone.FullScreen
onDrop={(files) => {
files[0].text().then((e) => {
try {
@@ -90,7 +40,7 @@ export default function LoadConfigComponent(props: any) {
icon: <Check />,
message: undefined,
});
setCookies('config-name', newConfig.name, {
setCookie('config-name', newConfig.name, {
maxAge: 60 * 60 * 24 * 30,
sameSite: 'strict',
});
@@ -100,7 +50,31 @@ export default function LoadConfigComponent(props: any) {
}}
accept={['application/json']}
>
{(status) => dropzoneChildren(status, theme)}
</FullScreenDropzone>
<Group position="center" spacing="xl" style={{ minHeight: 220, pointerEvents: 'none' }}>
<Dropzone.Accept>
<Text size="xl" inline>
<IconUpload
size={50}
stroke={1.5}
color={theme.colors[theme.primaryColor][theme.colorScheme === 'dark' ? 4 : 6]}
/>
Drag files here to upload a config. Support for JSON only.
</Text>
</Dropzone.Accept>
<Dropzone.Reject>
<Text size="xl" inline>
<IconX
size={50}
stroke={1.5}
color={theme.colors.red[theme.colorScheme === 'dark' ? 4 : 6]}
/>
This file format is not supported. Please only upload JSON.
</Text>
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={50} stroke={1.5} />
</Dropzone.Idle>
</Group>
</Dropzone.FullScreen>
);
}

View File

@@ -1,53 +0,0 @@
import { ActionIcon, Drawer, Group, LoadingOverlay } from '@mantine/core';
import { IconBrandDocker } from '@tabler/icons';
import axios from 'axios';
import { useEffect, useState } from 'react';
import Docker from 'dockerode';
import ContainerActionBar from './ContainerActionBar';
import DockerTable from './DockerTable';
export default function DockerDrawer(props: any) {
const [opened, setOpened] = useState(false);
const [containers, setContainers] = useState<Docker.ContainerInfo[]>([]);
const [selection, setSelection] = useState<Docker.ContainerInfo[]>([]);
const [visible, setVisible] = useState(false);
function reload() {
setVisible(true);
setTimeout(() => {
axios.get('/api/docker/containers').then((res) => {
setContainers(res.data);
setSelection([]);
setVisible(false);
});
}, 300);
}
useEffect(() => {
reload();
}, []);
// Check if the user has at least one container
if (containers.length < 1) return null;
return (
<>
<Drawer opened={opened} onClose={() => setOpened(false)} padding="xl" size="full">
<ContainerActionBar selected={selection} reload={reload} />
<div style={{ position: 'relative' }}>
<LoadingOverlay transitionDuration={500} visible={visible} />
<DockerTable containers={containers} selection={selection} setSelection={setSelection} />
</div>
</Drawer>
<Group position="center">
<ActionIcon
variant="default"
radius="md"
size="xl"
color="blue"
onClick={() => setOpened(true)}
>
<IconBrandDocker />
</ActionIcon>
</Group>
</>
);
}

View File

@@ -1,91 +0,0 @@
import { Menu, Text, useMantineTheme } from '@mantine/core';
import { showNotification, updateNotification } from '@mantine/notifications';
import {
IconCheck,
IconCodePlus,
IconPlayerPlay,
IconPlayerStop,
IconRotateClockwise,
IconX,
} from '@tabler/icons';
import axios from 'axios';
import Dockerode from 'dockerode';
function sendNotification(action: string, containerId: string, containerName: string) {
showNotification({
id: 'load-data',
loading: true,
title: `${action}ing container ${containerName}`,
message: 'Your password is being checked...',
autoClose: false,
disallowClose: true,
});
axios.get(`/api/docker/container/${containerId}?action=${action}`).then((res) => {
setTimeout(() => {
if (res.data.success === true) {
updateNotification({
id: 'load-data',
title: 'Container restarted',
message: 'Your container was successfully restarted',
icon: <IconCheck />,
autoClose: 2000,
});
}
if (res.data.success === false) {
updateNotification({
id: 'load-data',
color: 'red',
title: 'There was an error restarting your container.',
message: 'Your container has encountered issues while restarting.',
icon: <IconX />,
autoClose: 2000,
});
}
}, 500);
});
}
function restart(container: Dockerode.ContainerInfo) {
sendNotification('restart', container.Id, container.Names[0]);
}
function stop(container: Dockerode.ContainerInfo) {
console.log('stoping container', container.Id);
}
function start(container: Dockerode.ContainerInfo) {
console.log('starting container', container.Id);
}
export default function DockerMenu(props: any) {
const { container }: { container: Dockerode.ContainerInfo } = props;
const theme = useMantineTheme();
if (container === undefined) {
return null;
}
return (
<Menu shadow="lg" radius="md">
<Menu.Label>Actions</Menu.Label>
<Menu.Item icon={<IconRotateClockwise color="orange" />} onClick={() => restart(container)}>
<Text>Restart</Text>
</Menu.Item>
{container.State === 'running' ? (
<Menu.Item icon={<IconPlayerStop color="red" />}>
<Text>Stop</Text>
</Menu.Item>
) : (
<Menu.Item icon={<IconPlayerPlay color="green" />}>
<Text>Start</Text>
</Menu.Item>
)}
{/* <Menu.Item icon={<IconDownload color="blue" />}>
<Text>Pull latest image </Text>
</Menu.Item>
<Menu.Item icon={<IconFileText color="grey" />}>
<Text>Logs</Text>
</Menu.Item> */}
<Menu.Label>Homarr</Menu.Label>
<Menu.Item icon={<IconCodePlus color={theme.primaryColor} />}>
<Text>Add to Homarr</Text>
</Menu.Item>
</Menu>
);
}

View File

@@ -1,90 +0,0 @@
import { Table, Checkbox, Group, Badge, createStyles } from '@mantine/core';
import Dockerode from 'dockerode';
import ContainerState from './ContainerState';
const useStyles = createStyles((theme) => ({
rowSelected: {
backgroundColor:
theme.colorScheme === 'dark'
? theme.fn.rgba(theme.colors[theme.primaryColor][7], 0.2)
: theme.colors[theme.primaryColor][0],
},
}));
export default function DockerTable({
containers,
selection,
setSelection,
}: {
setSelection: any;
containers: Dockerode.ContainerInfo[];
selection: Dockerode.ContainerInfo[];
}) {
const { classes, cx } = useStyles();
const toggleRow = (container: Dockerode.ContainerInfo) =>
setSelection((current: Dockerode.ContainerInfo[]) =>
current.includes(container) ? current.filter((c) => c !== container) : [...current, container]
);
const toggleAll = () =>
setSelection((current: any) =>
current.length === containers.length ? [] : containers.map((c) => c)
);
const rows = containers.map((element) => {
const selected = selection.includes(element);
return (
<tr key={element.Id} className={cx({ [classes.rowSelected]: selected })}>
<td>
<Checkbox
checked={selection.includes(element)}
onChange={() => toggleRow(element)}
transitionDuration={0}
/>
</td>
<td>{element.Names[0].replace('/', '')}</td>
<td>{element.Image}</td>
<td>
<Group>
{element.Ports.sort((a, b) => a.PrivatePort - b.PrivatePort)
.slice(-3)
.map((port) => (
<Badge key={port.PrivatePort} variant="outline">
{port.PrivatePort}:{port.PublicPort}
</Badge>
))}
{element.Ports.length > 3 && (
<Badge variant="filled">{element.Ports.length - 3} more</Badge>
)}
</Group>
</td>
<td>
<ContainerState state={element.State} />
</td>
</tr>
);
});
return (
<Table captionSide="bottom" highlightOnHover sx={{ minWidth: 800 }} verticalSpacing="sm">
<caption>your docker containers</caption>
<thead>
<tr>
<th style={{ width: 40 }}>
<Checkbox
onChange={toggleAll}
checked={selection.length === containers.length}
indeterminate={selection.length > 0 && selection.length !== containers.length}
transitionDuration={0}
/>
</th>
<th>Name</th>
<th>Image</th>
<th>Ports</th>
<th>State</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</Table>
);
}

View File

@@ -1,4 +1,4 @@
import { TextInput, Group, Button } from '@mantine/core';
import { TextInput, Button, Stack } from '@mantine/core';
import { useForm } from '@mantine/form';
import { useConfig } from '../../tools/state';
import { ColorSelector } from './ColorSelector';
@@ -37,14 +37,14 @@ export default function TitleChanger() {
};
return (
<Group direction="column" grow mb="lg">
<Stack mb="md" mr="sm" mt="xs">
<form onSubmit={form.onSubmit((values) => saveChanges(values))}>
<Group grow direction="column">
<Stack>
<TextInput label="Page title" placeholder="Homarr 🦞" {...form.getInputProps('title')} />
<TextInput label="Logo" placeholder="/img/logo.png" {...form.getInputProps('logo')} />
<TextInput
label="Favicon"
placeholder="/favicon.svg"
placeholder="/favicon.png"
{...form.getInputProps('favicon')}
/>
<TextInput
@@ -53,13 +53,13 @@ export default function TitleChanger() {
{...form.getInputProps('background')}
/>
<Button type="submit">Save</Button>
</Group>
</Stack>
</form>
<ColorSelector type="primary" />
<ColorSelector type="secondary" />
<ShadeSelector />
<OpacitySelector />
<AppCardWidthSelector />
</Group>
</Stack>
);
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Group, Text, Slider } from '@mantine/core';
import { Text, Slider, Stack } from '@mantine/core';
import { useConfig } from '../../tools/state';
export function AppCardWidthSelector() {
@@ -16,7 +16,7 @@ export function AppCardWidthSelector() {
};
return (
<Group direction="column" spacing="xs" grow>
<Stack spacing="xs">
<Text>App Width</Text>
<Slider
label={null}
@@ -27,6 +27,6 @@ export function AppCardWidthSelector() {
styles={{ markLabel: { fontSize: 'xx-small' } }}
onChange={(value) => setappCardWidth(value)}
/>
</Group>
</Stack>
);
}

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { ColorSwatch, Group, Popover, Text, useMantineTheme } from '@mantine/core';
import { ColorSwatch, Grid, Group, Popover, Text, useMantineTheme } from '@mantine/core';
import { useConfig } from '../../tools/state';
import { useColorTheme } from '../../tools/color';
@@ -44,51 +44,43 @@ export function ColorSelector({ type }: ColorControlProps) {
};
const swatches = colors.map(({ color, swatch }) => (
<ColorSwatch
component="button"
type="button"
onClick={() => setConfigColor(color)}
key={color}
color={swatch}
size={22}
style={{ color: theme.white, cursor: 'pointer' }}
/>
<Grid.Col span={2} key={color}>
<ColorSwatch
component="button"
type="button"
onClick={() => setConfigColor(color)}
color={swatch}
size={22}
style={{ cursor: 'pointer' }}
/>
</Grid.Col>
));
return (
<Group direction="row" spacing={3}>
<Group>
<Popover
width={250}
withinPortal
opened={opened}
onClose={() => setOpened(false)}
transitionDuration={0}
target={
position="left"
withArrow
>
<Popover.Target>
<ColorSwatch
component="button"
type="button"
color={theme.colors[configColor][6]}
onClick={() => setOpened((o) => !o)}
size={22}
style={{ display: 'block', cursor: 'pointer' }}
style={{ cursor: 'pointer' }}
/>
}
styles={{
root: {
marginRight: theme.spacing.xs,
},
body: {
width: 152,
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.white,
},
arrow: {
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.white,
},
}}
position="bottom"
placement="end"
withArrow
arrowSize={3}
>
<Group spacing="xs">{swatches}</Group>
</Popover.Target>
<Popover.Dropdown>
<Grid gutter="lg" columns={14}>
{swatches}
</Grid>
</Popover.Dropdown>
</Popover>
<Text>{type[0].toUpperCase() + type.slice(1)} color</Text>
</Group>

View File

@@ -1,4 +1,4 @@
import { Group, Text, SegmentedControl, TextInput } from '@mantine/core';
import { Text, SegmentedControl, TextInput, Stack } from '@mantine/core';
import { useState } from 'react';
import { useConfig } from '../../tools/state';
import { ColorSchemeSwitch } from '../ColorSchemeToggle/ColorSchemeSwitch';
@@ -24,8 +24,8 @@ export default function CommonSettings(args: any) {
);
return (
<Group direction="column" grow mb="lg">
<Group grow direction="column" spacing={0}>
<Stack mb="md" mr="sm">
<Stack spacing={0} mt="xs">
<Text>Search engine</Text>
<Tip>
Use the prefixes <b>!yt</b> and <b>!t</b> in front of your query to search on YouTube or
@@ -74,13 +74,13 @@ export default function CommonSettings(args: any) {
/>
</>
)}
</Group>
</Stack>
<ColorSchemeSwitch />
<WidgetsPositionSwitch />
<ModuleEnabler />
<ConfigChanger />
<SaveConfigComponent />
<Tip>Upload your config file by dragging and dropping it onto the page!</Tip>
</Group>
</Stack>
);
}

View File

@@ -4,7 +4,7 @@ import { CURRENT_VERSION } from '../../../data/constants';
export default function Credits(props: any) {
return (
<Group position="center" direction="row" mr="xs">
<Group position="center" mt="xs">
<Group spacing={0}>
<ActionIcon<'a'> component="a" href="https://github.com/ajnart/homarr" size="lg">
<IconBrandGithub size={18} />

View File

@@ -1,14 +1,14 @@
import { Checkbox, Group, SimpleGrid, Title } from '@mantine/core';
import * as Modules from '../modules';
import { Checkbox, SimpleGrid, Stack, Title } from '@mantine/core';
import * as Modules from '../../modules';
import { useConfig } from '../../tools/state';
export default function ModuleEnabler(props: any) {
const { config, setConfig } = useConfig();
const modules = Object.values(Modules).map((module) => module);
return (
<Group direction="column">
<Stack>
<Title order={4}>Module enabler</Title>
<SimpleGrid cols={2} spacing="md">
<SimpleGrid cols={3} spacing="xs">
{modules.map((module) => (
<Checkbox
key={module.title}
@@ -30,6 +30,6 @@ export default function ModuleEnabler(props: any) {
/>
))}
</SimpleGrid>
</Group>
</Stack>
);
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Group, Text, Slider } from '@mantine/core';
import { Text, Slider, Stack } from '@mantine/core';
import { useConfig } from '../../tools/state';
export function OpacitySelector() {
@@ -29,7 +29,7 @@ export function OpacitySelector() {
};
return (
<Group direction="column" spacing="xs" grow>
<Stack spacing="xs">
<Text>App Opacity</Text>
<Slider
defaultValue={config.settings.appOpacity || 100}
@@ -39,6 +39,6 @@ export function OpacitySelector() {
styles={{ markLabel: { fontSize: 'xx-small' } }}
onChange={(value) => setConfigOpacity(value)}
/>
</Group>
</Stack>
);
}

View File

@@ -1,10 +0,0 @@
import { SettingsMenuButton } from './SettingsMenu';
export default {
title: ' menu',
args: {
opened: false,
},
};
export const Default = (args: any) => <SettingsMenuButton {...args} />;

View File

@@ -8,17 +8,21 @@ import Credits from './Credits';
function SettingsMenu(props: any) {
return (
<Tabs grow>
<Tabs.Tab data-autofocus label="Common">
<Tabs defaultValue="Common">
<Tabs.List grow>
<Tabs.Tab value="Common">Common</Tabs.Tab>
<Tabs.Tab value="Customizations">Customizations</Tabs.Tab>
</Tabs.List>
<Tabs.Panel data-autofocus value="Common">
<ScrollArea style={{ height: '78vh' }} offsetScrollbars>
<CommonSettings />
</ScrollArea>
</Tabs.Tab>
<Tabs.Tab label="Customizations">
</Tabs.Panel>
<Tabs.Panel value="Customizations">
<ScrollArea style={{ height: '78vh' }} offsetScrollbars>
<AdvancedSettings />
</ScrollArea>
</Tabs.Tab>
</Tabs.Panel>
</Tabs>
);
}
@@ -40,18 +44,18 @@ export function SettingsMenuButton(props: any) {
<SettingsMenu />
<Credits />
</Drawer>
<ActionIcon
variant="default"
radius="md"
size="xl"
color="blue"
style={props.style}
onClick={() => setOpened(true)}
>
<Tooltip label="Settings">
<Tooltip label="Settings">
<ActionIcon
variant="default"
radius="md"
size="xl"
color="blue"
style={props.style}
onClick={() => setOpened(true)}
>
<IconSettings />
</Tooltip>
</ActionIcon>
</ActionIcon>
</Tooltip>
</>
);
}

View File

@@ -1,5 +1,14 @@
import React, { useState } from 'react';
import { ColorSwatch, Group, Popover, Text, useMantineTheme, MantineTheme } from '@mantine/core';
import {
ColorSwatch,
Group,
Popover,
Text,
useMantineTheme,
MantineTheme,
Stack,
Grid,
} from '@mantine/core';
import { useConfig } from '../../tools/state';
import { useColorTheme } from '../../tools/color';
@@ -31,36 +40,42 @@ export function ShadeSelector() {
};
const primarySwatches = primaryShades.map(({ swatch, shade }) => (
<ColorSwatch
component="button"
type="button"
onClick={() => setConfigShade(shade)}
key={Number(shade)}
color={swatch}
size={22}
style={{ color: theme.white, cursor: 'pointer' }}
/>
<Grid.Col span={1} key={Number(shade)}>
<ColorSwatch
component="button"
type="button"
onClick={() => setConfigShade(shade)}
color={swatch}
size={22}
style={{ cursor: 'pointer' }}
/>
</Grid.Col>
));
const secondarySwatches = secondaryShades.map(({ swatch, shade }) => (
<ColorSwatch
component="button"
type="button"
onClick={() => setConfigShade(shade)}
key={Number(shade)}
color={swatch}
size={22}
style={{ color: theme.white, cursor: 'pointer' }}
/>
<Grid.Col span={1} key={Number(shade)}>
<ColorSwatch
component="button"
type="button"
onClick={() => setConfigShade(shade)}
color={swatch}
size={22}
style={{ cursor: 'pointer' }}
/>
</Grid.Col>
));
return (
<Group direction="row" spacing={3}>
<Group>
<Popover
width={350}
withinPortal
opened={opened}
onClose={() => setOpened(false)}
transitionDuration={0}
target={
position="left"
withArrow
>
<Popover.Target>
<ColorSwatch
component="button"
type="button"
@@ -69,27 +84,15 @@ export function ShadeSelector() {
size={22}
style={{ display: 'block', cursor: 'pointer' }}
/>
}
styles={{
root: {
marginRight: theme.spacing.xs,
},
body: {
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.white,
},
arrow: {
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.white,
},
}}
position="bottom"
placement="end"
withArrow
arrowSize={3}
>
<Group direction="column" spacing="xs">
<Group spacing="xs">{primarySwatches}</Group>
<Group spacing="xs">{secondarySwatches}</Group>
</Group>
</Popover.Target>
<Popover.Dropdown>
<Stack spacing="xs">
<Grid gutter="lg" columns={10}>
{primarySwatches}
{secondarySwatches}
</Grid>
</Stack>
</Popover.Dropdown>
</Popover>
<Text>Shade</Text>
</Group>

View File

@@ -1,27 +1,8 @@
import {
ActionIcon,
Box,
Burger,
createStyles,
Drawer,
Group,
Header as Head,
ScrollArea,
Title,
Transition,
} from '@mantine/core';
import { useBooleanToggle } from '@mantine/hooks';
import { Box, createStyles, Group, Header as Head } from '@mantine/core';
import { AddItemShelfButton } from '../AppShelf/AddAppShelfItem';
import {
CalendarModule,
DateModule,
TotalDownloadsModule,
WeatherModule,
DashdotModule,
} from '../modules';
import { ModuleWrapper } from '../modules/moduleWrapper';
import DockerDrawer from '../Docker/DockerDrawer';
import SearchBar from '../modules/search/SearchModule';
import DockerMenuButton from '../../modules/docker/DockerModule';
import SearchBar from '../../modules/search/SearchModule';
import { SettingsMenuButton } from '../Settings/SettingsMenu';
import { Logo } from './Logo';
@@ -41,9 +22,7 @@ const useStyles = createStyles((theme) => ({
}));
export function Header(props: any) {
const [opened, toggleOpened] = useBooleanToggle(false);
const { classes, cx } = useStyles();
const [hidden, toggleHidden] = useBooleanToggle(true);
return (
<Head height="auto">
@@ -53,51 +32,9 @@ export function Header(props: any) {
</Box>
<Group noWrap>
<SearchBar />
<DockerDrawer />
<DockerMenuButton />
<SettingsMenuButton />
<AddItemShelfButton />
<ActionIcon className={classes.burger} variant="default" radius="md" size="xl">
<Burger
opened={!hidden}
onClick={(_) => {
toggleHidden();
toggleOpened();
}}
/>
</ActionIcon>
<Drawer
size="auto"
padding="xl"
position="right"
hidden={hidden}
title={<Title order={3}>Modules</Title>}
opened
onClose={() => {
toggleHidden();
}}
>
<Transition
mounted={opened}
transition="pop-top-right"
duration={300}
timingFunction="ease"
onExit={() => toggleOpened()}
>
{(styles) => (
<div style={styles}>
<ScrollArea offsetScrollbars style={{ height: '90vh' }}>
<Group my="sm" grow direction="column" style={{ width: 300 }}>
<ModuleWrapper module={CalendarModule} />
<ModuleWrapper module={TotalDownloadsModule} />
<ModuleWrapper module={WeatherModule} />
<ModuleWrapper module={DateModule} />
<ModuleWrapper module={DashdotModule} />
</Group>
</ScrollArea>
</div>
)}
</Transition>
</Drawer>
</Group>
</Group>
</Head>

View File

@@ -18,9 +18,10 @@ export default function Layout({ children, style }: any) {
return (
<AppShell
fixed={false}
header={<Header />}
navbar={widgetPosition ? <Navbar /> : <></>}
aside={widgetPosition ? <></> : <Aside />}
navbar={widgetPosition ? <Navbar /> : undefined}
aside={widgetPosition ? undefined : <Aside />}
footer={<Footer links={[]} />}
>
<HeaderConfig />

View File

@@ -4,7 +4,7 @@ import * as React from 'react';
import { useColorTheme } from '../../tools/color';
import { useConfig } from '../../tools/state';
export function Logo({ style }: any) {
export function Logo({ style, withoutText }: any) {
const { config } = useConfig();
const { primaryColor, secondaryColor } = useColorTheme();
@@ -17,26 +17,28 @@ export function Logo({ style }: any) {
position: 'relative',
}}
/>
<NextLink
href="/"
style={{
textDecoration: 'none',
position: 'relative',
}}
>
<Text
sx={style}
weight="bold"
variant="gradient"
gradient={{
from: primaryColor,
to: secondaryColor,
deg: 145,
{withoutText ? null : (
<NextLink
href="/"
style={{
textDecoration: 'none',
position: 'relative',
}}
>
{config.settings.title || 'Homarr'}
</Text>
</NextLink>
<Text
sx={style}
weight="bold"
variant="gradient"
gradient={{
from: primaryColor,
to: secondaryColor,
deg: 145,
}}
>
{config.settings.title || 'Homarr'}
</Text>
</NextLink>
)}
</Group>
);
}

View File

@@ -1,23 +1,16 @@
import { Group } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
import { CalendarModule, DateModule, TotalDownloadsModule, WeatherModule } from '../modules';
import { DashdotModule } from '../modules/dash.';
import { ModuleWrapper } from '../modules/moduleWrapper';
import { Stack } from '@mantine/core';
import { CalendarModule, DateModule, TotalDownloadsModule, WeatherModule } from '../../modules';
import { DashdotModule } from '../../modules/dashdot';
import { ModuleWrapper } from '../../modules/moduleWrapper';
export default function Widgets(props: any) {
const matches = useMediaQuery('(min-width: 800px)');
return (
<>
{matches && (
<Group my="sm" grow direction="column" style={{ width: 300 }}>
<ModuleWrapper module={CalendarModule} />
<ModuleWrapper module={TotalDownloadsModule} />
<ModuleWrapper module={WeatherModule} />
<ModuleWrapper module={DateModule} />
<ModuleWrapper module={DashdotModule} />
</Group>
)}
</>
<Stack my="sm" style={{ width: 300 }}>
<ModuleWrapper module={CalendarModule} />
<ModuleWrapper module={TotalDownloadsModule} />
<ModuleWrapper module={WeatherModule} />
<ModuleWrapper module={DateModule} />
<ModuleWrapper module={DashdotModule} />
</Stack>
);
}

View File

@@ -1,7 +0,0 @@
import CalendarComponent from './CalendarModule';
export default {
title: 'Calendar component',
};
export const Default = (args: any) => <CalendarComponent {...args} />;

View File

@@ -1,67 +0,0 @@
import { RadarrMediaDisplay } from './MediaDisplay';
export default {
title: 'Media display component',
args: {
media: {
title: 'Doctor Strange in the Multiverse of Madness',
originalTitle: 'Doctor Strange in the Multiverse of Madness',
originalLanguage: {
id: 1,
name: 'English',
},
secondaryYearSourceId: 0,
sortTitle: 'doctor strange in multiverse madness',
sizeOnDisk: 0,
status: 'announced',
overview:
'Doctor Strange, with the help of mystical allies both old and new, traverses the mind-bending and dangerous alternate realities of the Multiverse to confront a mysterious new adversary.',
inCinemas: '2022-05-04T00:00:00Z',
images: [
{
coverType: 'poster',
url: 'https://image.tmdb.org/t/p/original/wRnbWt44nKjsFPrqSmwYki5vZtF.jpg',
},
{
coverType: 'fanart',
url: 'https://image.tmdb.org/t/p/original/ndCSoasjIZAMMDIuMxuGnNWu4DU.jpg',
},
],
website: 'https://www.marvel.com/movies/doctor-strange-in-the-multiverse-of-madness',
year: 2022,
hasFile: false,
youTubeTrailerId: 'aWzlQ2N6qqg',
studio: 'Marvel Studios',
path: '/config/Doctor Strange in the Multiverse of Madness (2022)',
qualityProfileId: 1,
monitored: true,
minimumAvailability: 'announced',
isAvailable: true,
folderName: '/config/Doctor Strange in the Multiverse of Madness (2022)',
runtime: 126,
cleanTitle: 'doctorstrangeinmultiversemadness',
imdbId: 'tt9419884',
tmdbId: 453395,
titleSlug: '453395',
certification: 'PG-13',
genres: ['Fantasy', 'Action', 'Adventure'],
tags: [],
added: '2022-04-29T20:52:33Z',
ratings: {
tmdb: {
votes: 0,
value: 0,
type: 'user',
},
},
collection: {
name: 'Doctor Strange Collection',
tmdbId: 618529,
images: [],
},
id: 1,
},
},
};
export const Default = (args: any) => <RadarrMediaDisplay {...args} />;

View File

@@ -1,193 +0,0 @@
import {
Image,
Group,
Title,
Badge,
Text,
ActionIcon,
Anchor,
ScrollArea,
createStyles,
} from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
import { IconLink as Link } from '@tabler/icons';
import { useConfig } from '../../../tools/state';
import { serviceItem } from '../../../tools/types';
export interface IMedia {
overview: string;
imdbId?: any;
artist?: string;
title: string;
poster?: string;
genres: string[];
seasonNumber?: number;
episodeNumber?: number;
}
const useStyles = createStyles((theme) => ({
overview: {
[theme.fn.largerThan('sm')]: {
width: 400,
},
},
}));
export function MediaDisplay(props: { media: IMedia }) {
const { media }: { media: IMedia } = props;
const { classes, cx } = useStyles();
const phone = useMediaQuery('(min-width: 800px)');
return (
<Group position="apart">
<Text>
{media.poster && (
<Image
width={phone ? 250 : 100}
height={phone ? 400 : 160}
style={{
float: 'right',
}}
radius="md"
fit="cover"
src={media.poster}
alt={media.title}
/>
)}
<Group direction="column" style={{ minWidth: phone ? 450 : '65vw' }}>
<Group noWrap mr="sm" className={classes.overview}>
<Title order={3}>{media.title}</Title>
{media.imdbId && (
<Anchor href={`https://www.imdb.com/title/${media.imdbId}`} target="_blank">
<ActionIcon>
<Link />
</ActionIcon>
</Anchor>
)}
</Group>
{media.artist && (
<Text
style={{
textAlign: 'center',
color: 'gray',
}}
>
New release from {media.artist}
</Text>
)}
{media.episodeNumber && media.seasonNumber && (
<Text
style={{
textAlign: 'center',
color: 'gray',
}}
>
Season {media.seasonNumber} episode {media.episodeNumber}
</Text>
)}
</Group>
<Group direction="column" position="apart">
<ScrollArea style={{ height: 280, maxWidth: 700 }}>{media.overview}</ScrollArea>
<Group align="center" position="center" spacing="xs">
{media.genres.slice(-5).map((genre: string, i: number) => (
<Badge size="sm" key={i}>
{genre}
</Badge>
))}
</Group>
</Group>
</Text>
</Group>
);
}
export function ReadarrMediaDisplay(props: any) {
const { media }: { media: any } = props;
const { config } = useConfig();
// Find lidarr in services
const readarr = config.services.find((service: serviceItem) => service.type === 'Readarr');
// Find a poster CoverType
const poster = media.images.find((image: any) => image.coverType === 'cover');
if (!readarr) {
return null;
}
const baseUrl = new URL(readarr.url).origin;
// Remove '/' from the end of the lidarr url
const fullLink = `${baseUrl}${poster.url}`;
// Return a movie poster containting the title and the description
return (
<MediaDisplay
media={{
title: media.title,
poster: fullLink,
artist: media.author.authorName,
overview: media.overview,
genres: media.genres,
}}
/>
);
}
export function LidarrMediaDisplay(props: any) {
const { media }: { media: any } = props;
const { config } = useConfig();
// Find lidarr in services
const lidarr = config.services.find((service: serviceItem) => service.type === 'Lidarr');
// Find a poster CoverType
const poster = media.images.find((image: any) => image.coverType === 'cover');
if (!lidarr) {
return null;
}
const baseUrl = new URL(lidarr.url).origin;
// Remove '/' from the end of the lidarr url
const fullLink = poster ? `${baseUrl}${poster.url}` : undefined;
// Return a movie poster containting the title and the description
return (
<MediaDisplay
media={{
title: media.title,
poster: fullLink,
artist: media.artist.artistName,
overview: media.overview,
genres: media.genres,
}}
/>
);
}
export function RadarrMediaDisplay(props: any) {
const { media }: { media: any } = props;
// Find a poster CoverType
const poster = media.images.find((image: any) => image.coverType === 'poster');
// Return a movie poster containting the title and the description
return (
<MediaDisplay
media={{
imdbId: media.imdbId,
title: media.title,
overview: media.overview,
poster: poster.url,
genres: media.genres,
}}
/>
);
}
export function SonarrMediaDisplay(props: any) {
const { media }: { media: any } = props;
// Find a poster CoverType
const poster = media.series.images.find((image: any) => image.coverType === 'poster');
// Return a movie poster containting the title and the description
return (
<MediaDisplay
media={{
imdbId: media.series.imdbId,
title: media.series.title,
overview: media.series.overview,
poster: poster.url,
genres: media.series.genres,
seasonNumber: media.seasonNumber,
episodeNumber: media.episodeNumber,
}}
/>
);
}

View File

@@ -1,7 +0,0 @@
import DateComponent from './DateModule';
export default {
title: 'Date module',
};
export const Default = (args: any) => <DateComponent {...args} />;

View File

@@ -1,18 +0,0 @@
import { serviceItem } from '../../../tools/types';
import PingComponent from './PingModule';
export default {
title: 'Modules/Search bar',
};
const service: serviceItem = {
id: '1',
type: 'Other',
name: 'YouTube',
icon: 'https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/youtube.png',
url: 'https://youtube.com/',
status: ['200'],
newTab: false,
};
export const Default = (args: any) => <PingComponent service={service} />;

View File

@@ -1,10 +0,0 @@
import SearchBar from './SearchModule';
export default {
title: 'Search bar',
config: {
searchBar: false,
},
};
export const Default = (args: any) => <SearchBar {...args} />;

View File

@@ -1,127 +0,0 @@
import { Kbd, createStyles, Text, Popover, Autocomplete, Tooltip } from '@mantine/core';
import { useDebouncedValue, useForm, useHotkeys } from '@mantine/hooks';
import { useEffect, useRef, useState } from 'react';
import {
IconSearch as Search,
IconBrandYoutube as BrandYoutube,
IconDownload as Download,
} from '@tabler/icons';
import axios from 'axios';
import { useConfig } from '../../../tools/state';
import { IModule } from '../modules';
const useStyles = createStyles((theme) => ({
hide: {
[theme.fn.smallerThan('sm')]: {
display: 'none',
},
display: 'flex',
alignItems: 'center',
},
}));
export const SearchModule: IModule = {
title: 'Search Bar',
description: 'Show the current time and date in a card',
icon: Search,
component: SearchBar,
};
export default function SearchBar(props: any) {
const { config, setConfig } = useConfig();
const [opened, setOpened] = useState(false);
const [icon, setIcon] = useState(<Search />);
const queryUrl = config.settings.searchUrl ?? 'https://www.google.com/search?q=';
const textInput = useRef<HTMLInputElement>();
// Find a service with the type of 'Overseerr'
const form = useForm({
initialValues: {
query: '',
},
});
const [debounced, cancel] = useDebouncedValue(form.values.query, 250);
const [results, setResults] = useState<any[]>([]);
useEffect(() => {
if (form.values.query !== debounced || form.values.query === '') return;
axios
.get(`/api/modules/search?q=${form.values.query}`)
.then((res) => setResults(res.data ?? []));
}, [debounced]);
useHotkeys([['ctrl+K', () => textInput.current && textInput.current.focus()]]);
const { classes, cx } = useStyles();
const rightSection = (
<div className={classes.hide}>
<Kbd>Ctrl</Kbd>
<span style={{ margin: '0 5px' }}>+</span>
<Kbd>K</Kbd>
</div>
);
// If enabled modules doesn't contain the module, return null
// If module in enabled
const exists = config.modules?.[SearchModule.title]?.enabled ?? false;
if (!exists) {
return null;
}
const autocompleteData = results.map((result) => ({
label: result.phrase,
value: result.phrase,
}));
return (
<form
onChange={() => {
// If query contains !yt or !t add "Searching on YouTube" or "Searching torrent"
const query = form.values.query.trim();
const isYoutube = query.startsWith('!yt');
const isTorrent = query.startsWith('!t');
if (isYoutube) {
setIcon(<BrandYoutube size={22} />);
} else if (isTorrent) {
setIcon(<Download size={22} />);
} else {
setIcon(<Search size={22} />);
}
}}
onSubmit={form.onSubmit((values) => {
const query = values.query.trim();
const isYoutube = query.startsWith('!yt');
const isTorrent = query.startsWith('!t');
form.setValues({ query: '' });
setTimeout(() => {
if (isYoutube) {
window.open(`https://www.youtube.com/results?search_query=${query.substring(3)}`);
} else if (isTorrent) {
window.open(`https://bitsearch.to/search?q=${query.substring(3)}`);
} else {
window.open(
`${
queryUrl.includes('%s')
? queryUrl.replace('%s', values.query)
: queryUrl + values.query
}`
);
}
}, 20);
})}
>
<Autocomplete
autoFocus
variant="filled"
data={autocompleteData}
icon={icon}
ref={textInput}
rightSectionWidth={90}
rightSection={rightSection}
radius="md"
size="md"
styles={{ rightSection: { pointerEvents: 'none' } }}
placeholder="Search the web..."
{...props}
{...form.getInputProps('query')}
/>
</form>
);
}

View File

@@ -1,59 +0,0 @@
import { Center, Group, RingProgress, Title, useMantineTheme } from '@mantine/core';
import { IconCpu } from '@tabler/icons';
import { useEffect, useState } from 'react';
import axios from 'axios';
import si from 'systeminformation';
import { useListState } from '@mantine/hooks';
import { IModule } from '../modules';
import { useSetSafeInterval } from '../../../tools/hooks/useSetSafeInterval';
export const SystemModule: IModule = {
title: 'System info',
description: 'Show the current CPU usage and memory usage',
icon: IconCpu,
component: SystemInfo,
};
interface ApiResponse {
cpu: si.Systeminformation.CpuData;
os: si.Systeminformation.OsData;
memory: si.Systeminformation.MemData;
load: si.Systeminformation.CurrentLoadData;
}
export default function SystemInfo(args: any) {
const [data, setData] = useState<ApiResponse>();
const setSafeInterval = useSetSafeInterval();
// Refresh data every second
useEffect(() => {
setSafeInterval(() => {
axios.get('/api/modules/systeminfo').then((res) => setData(res.data));
}, 1000);
}, []);
// Update data every time data changes
const [cpuLoadHistory, cpuLoadHistoryHandlers] =
useListState<si.Systeminformation.CurrentLoadData>([]);
// useEffect(() => {
// }, [data]);
const theme = useMantineTheme();
const currentLoad = data?.load?.currentLoad ?? 0;
return (
<Center>
<Group p="sm" direction="column" align="center">
<Title order={3}>Current CPU load</Title>
<RingProgress
size={150}
label={<Center>{`${currentLoad.toFixed(2)}%`}</Center>}
thickness={15}
roundCaps
sections={[{ value: currentLoad ?? 0, color: 'cyan' }]}
/>
</Group>
</Center>
);
}

View File

@@ -1 +0,0 @@
export { SystemModule } from './SystemModule';

View File

@@ -1,11 +1,14 @@
// This interface is to be used in all the modules of the project
// Each module should have its own interface and call the following function:
// TODO: Add a function to register a module
import { TablerIcon } from '@tabler/icons';
// Note: Maybe use context to keep track of the modules
export interface IModule {
title: string;
description: string;
icon: React.ReactNode;
icon: TablerIcon;
component: React.ComponentType;
options?: Option;
}

View File

@@ -12,16 +12,17 @@ import React, { useEffect, useState } from 'react';
import { Calendar } from '@mantine/dates';
import { IconCalendar as CalendarIcon } from '@tabler/icons';
import axios from 'axios';
import { useConfig } from '../../../tools/state';
import { IModule } from '../modules';
import { useDisclosure } from '@mantine/hooks';
import { useConfig } from '../../tools/state';
import { IModule } from '../ModuleTypes';
import {
SonarrMediaDisplay,
RadarrMediaDisplay,
LidarrMediaDisplay,
ReadarrMediaDisplay,
} from '../common';
import { serviceItem } from '../../../tools/types';
import { useColorTheme } from '../../../tools/color';
import { serviceItem } from '../../tools/types';
import { useColorTheme } from '../../tools/color';
export const CalendarModule: IModule = {
title: 'Calendar',
@@ -63,7 +64,7 @@ export default function CalendarComponent(props: any) {
if (!service || !service.apiKey) {
return Promise.resolve({ data: [] });
}
return axios.post(`/api/modules/calendar?type=${type}`, { ...service });
return axios.post(`/api/modules/calendar?type=${type}`, { id: service.id });
}
useEffect(() => {
@@ -170,7 +171,7 @@ function DayComponent(props: any) {
readarrmedias,
}: { renderdate: Date; sonarrmedias: []; radarrmedias: []; lidarrmedias: []; readarrmedias: [] } =
props;
const [opened, setOpened] = useState(false);
const [opened, { close, open }] = useDisclosure(false);
const day = renderdate.getDate();
@@ -191,124 +192,129 @@ function DayComponent(props: any) {
const date = new Date(media.inCinemas);
return date.toDateString() === renderdate.toDateString();
});
if (
sonarrFiltered.length === 0 &&
radarrFiltered.length === 0 &&
lidarrFiltered.length === 0 &&
readarrFiltered.length === 0
) {
const totalFiltered = [
...readarrFiltered,
...lidarrFiltered,
...sonarrFiltered,
...radarrFiltered,
];
if (totalFiltered.length === 0) {
return <div>{day}</div>;
}
return (
<Box
onClick={() => {
setOpened(true);
}}
<Popover
position="bottom"
withArrow
withinPortal
radius="lg"
shadow="sm"
transition="pop"
onClose={close}
opened={opened}
>
{readarrFiltered.length > 0 && (
<Indicator
size={10}
withBorder
<Popover.Target>
<Box onClick={open}>
{readarrFiltered.length > 0 && (
<Indicator
size={10}
withBorder
style={{
position: 'absolute',
bottom: 8,
left: 8,
}}
color="red"
children={null}
/>
)}
{radarrFiltered.length > 0 && (
<Indicator
size={10}
withBorder
style={{
position: 'absolute',
top: 8,
left: 8,
}}
color="yellow"
children={null}
/>
)}
{sonarrFiltered.length > 0 && (
<Indicator
size={10}
withBorder
style={{
position: 'absolute',
top: 8,
right: 8,
}}
color="blue"
children={null}
/>
)}
{lidarrFiltered.length > 0 && (
<Indicator
size={10}
withBorder
style={{
position: 'absolute',
bottom: 8,
right: 8,
}}
color="green"
children={undefined}
/>
)}
<div>{day}</div>
</Box>
</Popover.Target>
<Popover.Dropdown>
<ScrollArea
offsetScrollbars
scrollbarSize={5}
style={{
position: 'absolute',
bottom: 8,
left: 8,
height:
totalFiltered.slice(0, 2).length > 1 ? totalFiltered.slice(0, 2).length * 150 : 200,
width: 400,
}}
color="red"
children={null}
/>
)}
{radarrFiltered.length > 0 && (
<Indicator
size={10}
withBorder
style={{
position: 'absolute',
top: 8,
left: 8,
}}
color="yellow"
children={null}
/>
)}
{sonarrFiltered.length > 0 && (
<Indicator
size={10}
withBorder
style={{
position: 'absolute',
top: 8,
right: 8,
}}
color="blue"
children={null}
/>
)}
{lidarrFiltered.length > 0 && (
<Indicator
size={10}
withBorder
style={{
position: 'absolute',
bottom: 8,
right: 8,
}}
color="green"
children={undefined}
/>
)}
<Popover
position="bottom"
radius="lg"
shadow="xl"
transition="pop"
styles={{
body: {
boxShadow: '0 0 14px 14px rgba(0, 0, 0, 0.1), 0 14px 11px rgba(0, 0, 0, 0.1)',
},
}}
width="auto"
onClose={() => setOpened(false)}
opened={opened}
target={day}
>
<ScrollArea style={{ height: 400 }}>
>
{sonarrFiltered.map((media: any, index: number) => (
<React.Fragment key={index}>
<SonarrMediaDisplay media={media} />
{index < sonarrFiltered.length - 1 && <Divider variant="dashed" my="xl" />}
{index < sonarrFiltered.length - 1 && <Divider variant="dashed" size="sm" my="xl" />}
</React.Fragment>
))}
{radarrFiltered.length > 0 && sonarrFiltered.length > 0 && (
<Divider variant="dashed" my="xl" />
<Divider variant="dashed" size="sm" my="xl" />
)}
{radarrFiltered.map((media: any, index: number) => (
<React.Fragment key={index}>
<RadarrMediaDisplay media={media} />
{index < radarrFiltered.length - 1 && <Divider variant="dashed" my="xl" />}
{index < radarrFiltered.length - 1 && <Divider variant="dashed" size="sm" my="xl" />}
</React.Fragment>
))}
{sonarrFiltered.length > 0 && lidarrFiltered.length > 0 && (
<Divider variant="dashed" my="xl" />
<Divider variant="dashed" size="sm" my="xl" />
)}
{lidarrFiltered.map((media: any, index: number) => (
<React.Fragment key={index}>
<LidarrMediaDisplay media={media} />
{index < lidarrFiltered.length - 1 && <Divider variant="dashed" my="xl" />}
{index < lidarrFiltered.length - 1 && <Divider variant="dashed" size="sm" my="xl" />}
</React.Fragment>
))}
{lidarrFiltered.length > 0 && readarrFiltered.length > 0 && (
<Divider variant="dashed" my="xl" />
<Divider variant="dashed" size="sm" my="xl" />
)}
{readarrFiltered.map((media: any, index: number) => (
<React.Fragment key={index}>
<ReadarrMediaDisplay media={media} />
{index < readarrFiltered.length - 1 && <Divider variant="dashed" my="xl" />}
{index < readarrFiltered.length - 1 && <Divider variant="dashed" size="sm" my="xl" />}
</React.Fragment>
))}
</ScrollArea>
</Popover>
</Box>
</Popover.Dropdown>
</Popover>
);
}

View File

@@ -0,0 +1,260 @@
import { Badge, Button, Group, Image, Stack, Text, Title } from '@mantine/core';
import { IconDownload, IconExternalLink, IconPlayerPlay } from '@tabler/icons';
import { useState } from 'react';
import { useColorTheme } from '../../tools/color';
import { useConfig } from '../../tools/state';
import { serviceItem } from '../../tools/types';
import { RequestModal } from '../overseerr/RequestModal';
import { Result } from '../overseerr/SearchResult';
export interface IMedia {
overview: string;
imdbId?: any;
tmdbId?: any;
artist?: string;
title?: string;
type: 'movie' | 'tvshow' | 'book' | 'music' | 'overseer';
episodetitle?: string;
voteAverage?: string;
poster?: string;
genres: string[];
seasonNumber?: number;
plexUrl?: string;
episodeNumber?: number;
[key: string]: any;
}
export function OverseerrMediaDisplay(props: any) {
const { media }: { media: Result } = props;
const { config } = useConfig();
const service = config.services.find(
(service) => service.type === 'Overseerr' || service.type === 'Jellyseerr'
);
return (
<MediaDisplay
media={{
...media,
genres: [],
overview: media.overview ?? '',
title: media.title ?? media.name ?? media.originalName,
poster: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${media.posterPath}`,
seasonNumber: media.mediaInfo?.seasons.length,
episodetitle: media.title,
plexUrl: media.mediaInfo?.plexUrl ?? media.mediaInfo?.mediaUrl,
voteAverage: media.voteAverage?.toString(),
overseerrResult: media,
overseerrId: `${service?.openedUrl ? service?.openedUrl : service?.url}/${
media.mediaType
}/${media.id}`,
type: 'overseer',
}}
/>
);
}
export function ReadarrMediaDisplay(props: any) {
const { media }: { media: any } = props;
const { config } = useConfig();
// Find lidarr in services
const readarr = config.services.find((service: serviceItem) => service.type === 'Readarr');
// Find a poster CoverType
const poster = media.images.find((image: any) => image.coverType === 'cover');
if (!readarr) {
return null;
}
const baseUrl = readarr.openedUrl
? new URL(readarr.openedUrl).origin
: new URL(readarr.url).origin;
// Remove '/' from the end of the lidarr url
const fullLink = poster ? `${baseUrl}${poster.url}` : undefined;
// Return a movie poster containting the title and the description
return (
<MediaDisplay
media={{
...media,
title: media.title,
poster: fullLink,
artist: media.authorTitle,
overview: `new book release by ${media.authorTitle}`,
genres: media.genres ?? [],
voteAverage: media.ratings.value.toString(),
type: 'book',
}}
/>
);
}
export function LidarrMediaDisplay(props: any) {
const { media }: { media: any } = props;
const { config } = useConfig();
// Find lidarr in services
const lidarr = config.services.find((service: serviceItem) => service.type === 'Lidarr');
// Find a poster CoverType
const poster = media.images.find((image: any) => image.coverType === 'cover');
if (!lidarr) {
return null;
}
const baseUrl = lidarr.openedUrl ? new URL(lidarr.openedUrl).origin : new URL(lidarr.url).origin;
// Remove '/' from the end of the lidarr url
const fullLink = poster ? `${baseUrl}${poster.url}` : undefined;
// Return a movie poster containting the title and the description
return (
<MediaDisplay
media={{
type: 'music',
title: media.title,
poster: fullLink,
artist: media.artist.artistName,
overview: media.overview,
genres: media.genres,
}}
/>
);
}
export function RadarrMediaDisplay(props: any) {
const { media }: { media: any } = props;
// Find a poster CoverType
return (
<MediaDisplay
media={{
...media,
title: media.title ?? media.originalTitle,
overview: media.overview ?? '',
genres: media.genres ?? [],
poster: media.images.find((image: any) => image.coverType === 'poster')?.url,
voteAverage: media.ratings.tmdb.value.toString(),
imdbId: media.imdbId,
type: 'movie',
}}
/>
);
}
export function SonarrMediaDisplay(props: any) {
const { media }: { media: any } = props;
// Find a poster CoverType
const poster = media.series.images.find((image: any) => image.coverType === 'poster');
// Return a movie poster containting the title and the description
return (
<MediaDisplay
media={{
...media,
genres: media.series.genres ?? [],
overview: media.overview ?? media.series.overview ?? '',
title: media.series.title,
poster: poster ? poster.url : undefined,
episodeNumber: media.episodeNumber,
seasonNumber: media.seasonNumber,
episodetitle: media.title,
imdbId: media.series.imdbId,
voteAverage: media.series.ratings.value.toString(),
type: 'tvshow',
}}
/>
);
}
export function MediaDisplay({ media }: { media: IMedia }) {
const [opened, setOpened] = useState(false);
const { secondaryColor } = useColorTheme();
return (
<Group mr="xs" align="stretch" noWrap style={{ maxHeight: 200 }}>
<Image src={media.poster} height={200} width={150} radius="md" fit="cover" />
<Stack justify="space-around">
<Stack spacing="sm">
<Text lineClamp={2}>
<Title order={5}>{media.title}</Title>
</Text>
<Group spacing="xs">
{media.type === 'tvshow' && (
<Badge variant="dot" size="xs" radius="md" color="blue">
s{media.seasonNumber}e{media.episodeNumber} - {media.episodetitle}
</Badge>
)}
{media.type === 'music' && (
<Badge variant="dot" size="xs" radius="md" color="green">
{media.artist}
</Badge>
)}
{media.type === 'movie' && (
<Badge variant="dot" size="xs" radius="md" color="orange">
Radarr
</Badge>
)}
{media.type === 'book' && (
<Badge variant="dot" size="xs" radius="md" color="red">
Readarr
</Badge>
)}
{media.genres.slice(0, 2).map((genre) => (
<Badge size="xs" radius="md" key={genre}>
{genre}
</Badge>
))}
</Group>
<Text color="dimmed" size="xs" lineClamp={4}>
{media.overview}
</Text>
</Stack>
<Group noWrap>
{media.plexUrl && (
<Button
component="a"
target="_blank"
variant="outline"
href={media.plexUrl}
size="sm"
rightIcon={<IconPlayerPlay size={15} />}
>
Play
</Button>
)}
{media.imdbId && (
<Button
component="a"
target="_blank"
href={`https://www.imdb.com/title/${media.imdbId}`}
variant="outline"
size="sm"
rightIcon={<IconExternalLink size={15} />}
>
IMDb
</Button>
)}
{media.overseerrId && (
<Button
component="a"
target="_blank"
href={media.overseerrId}
variant="outline"
size="sm"
rightIcon={<IconExternalLink size={15} />}
>
TMDb
</Button>
)}
{media.type === 'overseer' && !media.overseerrResult?.mediaInfo?.mediaAddedAt && (
<>
<RequestModal
base={media.overseerrResult as Result}
opened={opened}
setOpened={setOpened}
/>
<Button
onClick={() => setOpened(true)}
color={secondaryColor}
size="sm"
rightIcon={<IconDownload size={15} />}
>
Request
</Button>
</>
)}
</Group>
</Stack>
</Group>
);
}

View File

@@ -0,0 +1,57 @@
{
"title": "Mika in Real Life",
"authorTitle": "jean, emiko Mika in Real Life",
"seriesTitle": "",
"disambiguation": "",
"authorId": 1,
"foreignBookId": "93584169",
"titleSlug": "93584169",
"monitored": true,
"anyEditionOk": false,
"ratings": {
"votes": 149,
"value": 4.15,
"popularity": 618.35
},
"releaseDate": "2022-08-09T00:00:00Z",
"pageCount": 384,
"genres": [
"fiction",
"romance",
"contemporary",
"adult",
"adult-fiction",
"chick-lit",
"womens-fiction",
"asian-literature",
"family",
"lgbt"
],
"images": [
{
"url": "/MediaCover/Books/1/cover.jpg?lastWrite=637899714580000000",
"coverType": "cover",
"extension": ".jpg"
}
],
"links": [
{
"url": "https://www.goodreads.com/work/editions/93584169",
"name": "Goodreads Editions"
},
{
"url": "https://www.goodreads.com/book/show/59430548-mika-in-real-life",
"name": "Goodreads Book"
}
],
"statistics": {
"bookFileCount": 0,
"bookCount": 0,
"totalBookCount": 1,
"sizeOnDisk": 0,
"percentOfBooks": 0
},
"added": "2022-08-07T20:48:09Z",
"grabbed": false,
"id": 1
}

View File

@@ -0,0 +1,70 @@
{
"title": "The Tunnel to Summer, the Exit of Goodbyes",
"originalTitle": "夏へのトンネル、さよならの出口",
"originalLanguage": {
"id": 8,
"name": "Japanese"
},
"alternateTitles": [
{
"sourceType": "tmdb",
"movieId": 1,
"title": "Natsu e no Tunnel, Sayonara no Deguchi",
"sourceId": 0,
"votes": 0,
"voteCount": 0,
"language": {
"id": 1,
"name": "English"
},
"id": 1
}
],
"secondaryYearSourceId": 0,
"sortTitle": "tunnel to summer exit goodbyes",
"sizeOnDisk": 0,
"status": "announced",
"overview": "Tono Kaoru heard a rumor: The laws of space and time mean nothing to the Urashima Tunnel. If you find it, walk through and you'll find your heart's desire on the other side...in exchange for years of your own life. On the night Kaoru just so happens to find himself standing in front of a tunnel that looks suspiciously like the one the rumor describes, he finds himself thinking of Karen, the sister he lost in an accident five years ago. To Kaoru's surprise, he's been followed by the new transfer student Anzu Hanaki, who promises to help him experiment with the mysterious tunnel--but what does she want from Kaoru in exchange? And what will he have left to give, after the tunnel's done with him?",
"inCinemas": "2022-09-09T00:00:00Z",
"images": [
{
"coverType": "poster",
"url": "https://image.tmdb.org/t/p/original/3x5gc6dHsfNqZryipu159IALEPH.jpg"
},
{
"coverType": "fanart",
"url": "https://image.tmdb.org/t/p/original/zO3QSYs858SqiapafD7iJp17KVD.jpg"
}
],
"website": "https://natsuton.com/",
"year": 2022,
"hasFile": false,
"youTubeTrailerId": "",
"studio": "Pony Canyon",
"path": "/data/Library/Movies/The Tunnel to Summer, the Exit of Goodbyes (2022)",
"qualityProfileId": 4,
"monitored": true,
"minimumAvailability": "announced",
"isAvailable": true,
"folderName": "/data/Library/Movies/The Tunnel to Summer, the Exit of Goodbyes (2022)",
"runtime": 0,
"cleanTitle": "thetunneltosummerexitgoodbyes",
"imdbId": "tt17382524",
"tmdbId": 916192,
"titleSlug": "916192",
"genres": [
"Animation",
"Drama",
"Mystery"
],
"tags": [],
"added": "2022-07-05T07:50:42Z",
"ratings": {
"tmdb": {
"votes": 0,
"value": 0,
"type": "user"
}
},
"id": 1
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,409 @@
{
"page": 1,
"totalPages": 2,
"totalResults": 21,
"results": [
{
"id": 66025,
"firstAirDate": "2016-06-14",
"genreIds": [
80,
18
],
"mediaType": "tv",
"name": "Animal Kingdom",
"originCountry": [
"US"
],
"originalLanguage": "en",
"originalName": "Animal Kingdom",
"overview": "Un jeune homme de dix-sept ans emménage avec la famille Cody après le décès de sa mère, une fratrie baignant dans la criminalité gouvernée d'une main de maître par la matriarche, Smurf.",
"popularity": 75.653,
"voteAverage": 7.7,
"voteCount": 318,
"backdropPath": "/eQJwfyMqSra10ck8HOoiCrbQR32.jpg",
"posterPath": "/rzvdKrnSRKPFI0pgqMQknDPpRC9.jpg",
"mediaInfo": {
"downloadStatus": [],
"downloadStatus4k": [],
"id": 217,
"mediaType": "tv",
"tmdbId": 66025,
"tvdbId": 304262,
"imdbId": null,
"status": 3,
"status4k": 1,
"createdAt": "2022-08-08T11:06:20.000Z",
"updatedAt": "2022-08-08T11:06:23.000Z",
"lastSeasonChange": "2022-08-08T11:06:20.000Z",
"mediaAddedAt": null,
"serviceId": 0,
"serviceId4k": null,
"externalServiceId": 56,
"externalServiceId4k": null,
"externalServiceSlug": "animal-kingdom-2016",
"externalServiceSlug4k": null,
"ratingKey": null,
"ratingKey4k": null,
"seasons": [],
"serviceUrl": "http://sonarr:8989/series/animal-kingdom-2016"
}
},
{
"id": 44629,
"mediaType": "movie",
"adult": false,
"genreIds": [
18,
53,
80,
9648
],
"originalLanguage": "en",
"originalTitle": "Animal Kingdom",
"overview": "Une rue anonyme dans la banlieue de Melbourne. Cest là que vit la famille Cody. Profession: criminels. Lirruption parmi eux de Joshua, un neveu éloigné, offre à la police le moyen de les infiltrer. Il ne reste plus à Joshua quà choisir son camp...",
"popularity": 11.839,
"releaseDate": "2010-06-03",
"title": "Animal Kingdom",
"video": false,
"voteAverage": 6.8,
"voteCount": 643,
"backdropPath": "/dxOv6K3LNbZfQaGDyx7Tp94Koy.jpg",
"posterPath": "/qrVjc5JcaujL58SMMW9lqrp3bBX.jpg"
},
{
"id": 95731,
"firstAirDate": "2020-09-25",
"genreIds": [
99
],
"mediaType": "tv",
"name": "Au cœur de Disney's Animal Kingdom",
"originCountry": [],
"originalLanguage": "en",
"originalName": "Magic of Disney's Animal Kingdom",
"overview": "Au cœur dAnimal Kingdom narrée par Josh Gad, une célébrité parmi les fans de Disney, nous emmène en coulisses découvrir la magie de deux des animations animalières les plus visitées au monde : le parc à thème de Disney, Animal Kingdom, et The Seas with Nemo & Friends à Epcot. Les spectateurs sapprochent au plus près de créatures parmi les plus rares et les plus belles de la planète et rencontrent les experts en soins animaliers qui ont tissé des liens stupéfiants avec les 5 000 et plus animaux du parc. Chacun des huit épisodes plonge au cœur de lendroit le plus magique sur Terre, dévoilant les multiples facettes de sa conception et de sa gestion.",
"popularity": 3.367,
"voteAverage": 8,
"voteCount": 4,
"backdropPath": "/gMTMnd54VVAbGiodBqMTGCjM3b2.jpg",
"posterPath": "/gvNTeRAfu4KN3dD5HUO4Nbnri07.jpg"
},
{
"id": 120862,
"mediaType": "movie",
"adult": false,
"genreIds": [
35,
18,
10749
],
"originalLanguage": "en",
"originalTitle": "The Animal Kingdom",
"overview": "Tom Collier, jeune éditeur, a entretenu une liaison passionnée et intellectuelle avec une dessinatrice, Daisy Sage. Celle-ci ayant mis un terme à leur relation, il a fait la connaissance de Cecilia, qu'il a rapidement décidé d'épouser. Alors que les fiançailles sont annoncées, Daisy, toujours amoureuse, fait son retour, mais trop tard. Le mariage a lieu. Sous l'influence de Cecilia, Tom Collier, qui était un éditeur intègre et exigeant, fait de plus en plus de concessions commerciales. Daisy, elle demeure fidèle à elle-même. Tom Collier, se retrouve a évoluer, par amour pour sa femme, dans un milieu de conventions bourgeoises qui ne l'intéressent pas.",
"popularity": 2.102,
"releaseDate": "1932-12-28",
"title": "The Animal Kingdom",
"video": false,
"voteAverage": 6.3,
"voteCount": 13,
"backdropPath": "/5P1Hx46wvCVx9D9yT8M5rdUIHZB.jpg",
"posterPath": "/3sLWwNvS77xynAGLkbiHVXlO3UH.jpg"
},
{
"id": 311015,
"mediaType": "movie",
"adult": false,
"genreIds": [
99
],
"originalLanguage": "en",
"originalTitle": "Disney Parks: Disney's Animal Kingdom",
"overview": "",
"popularity": 1.208,
"releaseDate": "2010-01-01",
"title": "Disney Parks: Disney's Animal Kingdom",
"video": true,
"voteAverage": 9,
"voteCount": 2,
"backdropPath": null,
"posterPath": "/93OEKY5vnKqGFbOyHtUAdcEz8NV.jpg"
},
{
"id": 291774,
"mediaType": "movie",
"adult": false,
"genreIds": [],
"originalLanguage": "en",
"originalTitle": "Kenya 3D: Animal Kingdom",
"overview": "",
"popularity": 0.6,
"releaseDate": "2013-03-08",
"title": "Kenya 3D: Animal Kingdom",
"video": false,
"voteAverage": 0,
"voteCount": 0,
"backdropPath": null,
"posterPath": null
},
{
"id": 640253,
"mediaType": "movie",
"adult": false,
"genreIds": [],
"originalLanguage": "it",
"originalTitle": "Animal Kingdom",
"overview": "",
"popularity": 0.6,
"releaseDate": "2016-11-12",
"title": "Animal Kingdom",
"video": false,
"voteAverage": 0,
"voteCount": 0,
"backdropPath": null,
"posterPath": "/vJFK5cCcIh4X4op0oeK5iY2ibPv.jpg"
},
{
"id": 507434,
"mediaType": "movie",
"adult": false,
"genreIds": [
27
],
"originalLanguage": "en",
"originalTitle": "Animal Kingdom",
"overview": "",
"popularity": 0.6,
"releaseDate": "2017-02-25",
"title": "Animal Kingdom",
"video": false,
"voteAverage": 0,
"voteCount": 0,
"backdropPath": "/8QxSJRLLw2m8ymrFsC2xJ26yd1n.jpg",
"posterPath": "/s77Q92boNGgkT2J5se3gwq5N8Xp.jpg"
},
{
"id": 775877,
"mediaType": "movie",
"adult": false,
"genreIds": [],
"originalLanguage": "en",
"originalTitle": "Disney's Animal Kingdom",
"overview": "",
"popularity": 0.6,
"releaseDate": "2004-05-12",
"title": "Disney's Animal Kingdom",
"video": true,
"voteAverage": 0,
"voteCount": 0,
"backdropPath": null,
"posterPath": null
},
{
"id": 318575,
"mediaType": "movie",
"adult": false,
"genreIds": [
99
],
"originalLanguage": "en",
"originalTitle": "Nature: Love in the Animal Kingdom",
"overview": "",
"popularity": 0.655,
"releaseDate": "2013-11-06",
"title": "Nature: Love in the Animal Kingdom",
"video": true,
"voteAverage": 9.5,
"voteCount": 2,
"backdropPath": "/vx2dfrXPTn0dKoyIqCEgrGvzwkd.jpg",
"posterPath": "/1fd53UCxtLAItNI5jMtVetFuw6v.jpg"
},
{
"id": 743266,
"mediaType": "movie",
"adult": false,
"genreIds": [],
"originalLanguage": "en",
"originalTitle": "Animal Kingdom: Great Are Thy Works",
"overview": "",
"popularity": 0.6,
"releaseDate": "1993-01-01",
"title": "Animal Kingdom: Great Are Thy Works",
"video": false,
"voteAverage": 0,
"voteCount": 0,
"backdropPath": null,
"posterPath": "/vjnsGLvymjG7dAIbjwzgFCdbhl6.jpg"
},
{
"id": 828152,
"mediaType": "movie",
"adult": false,
"genreIds": [
99
],
"originalLanguage": "en",
"originalTitle": "Disney's Animal Kingdom: Alive with Magic",
"overview": "",
"popularity": 0.6,
"releaseDate": "2017-06-27",
"title": "Disney's Animal Kingdom: Alive with Magic",
"video": false,
"voteAverage": 0,
"voteCount": 0,
"backdropPath": null,
"posterPath": "/amzVT8T9Ju3KLCDnBq4Rhf3LO8j.jpg"
},
{
"id": 280391,
"mediaType": "movie",
"adult": false,
"genreIds": [
12,
35,
16
],
"originalLanguage": "fr",
"originalTitle": "Pourquoi j'ai pas mangé mon père",
"overview": "Lhistoire trépidante dÉdouard, fils aîné du roi des simiens, qui, considéré à sa naissance comme trop malingre, est rejeté par sa tribu. Il grandit loin deux, auprès de son ami Ian, et, incroyablement ingénieux, il découvre le feu, la chasse, lhabitat moderne, lamour et même… lespoir. Généreux, il veut tout partager, révolutionne lordre établi, et mène son peuple avec éclat et humour vers la véritable humanité… celle où on ne mange pas son père.",
"popularity": 12.971,
"releaseDate": "2015-04-08",
"title": "Pourquoi j'ai pas mangé mon père",
"video": false,
"voteAverage": 5.3,
"voteCount": 303,
"backdropPath": "/msDLrSt7Ozpe6oOg4XJrsQJd2IE.jpg",
"posterPath": "/efpzs2g1uRNcP8wPbIKSRPPH0aC.jpg"
},
{
"id": 775559,
"mediaType": "movie",
"adult": false,
"genreIds": [],
"originalLanguage": "en",
"originalTitle": "A New species of Theme Park: Disneys Animal Kingdom",
"overview": "",
"popularity": 0.6,
"releaseDate": "1998-04-14",
"title": "A New species of Theme Park: Disneys Animal Kingdom",
"video": true,
"voteAverage": 0,
"voteCount": 0,
"backdropPath": null,
"posterPath": null
},
{
"id": 775831,
"mediaType": "movie",
"adult": false,
"genreIds": [],
"originalLanguage": "en",
"originalTitle": "Disney Animal Kingdom Villas: A Village Comes to Life",
"overview": "",
"popularity": 0.6,
"releaseDate": "2007-06-14",
"title": "Disney Animal Kingdom Villas: A Village Comes to Life",
"video": true,
"voteAverage": 0,
"voteCount": 0,
"backdropPath": null,
"posterPath": null
},
{
"id": 432906,
"mediaType": "movie",
"adult": false,
"genreIds": [
99
],
"originalLanguage": "en",
"originalTitle": "Out in Nature: Homosexual Behaviour in the Animal Kingdom",
"overview": "",
"popularity": 0.6,
"releaseDate": "2001-09-07",
"title": "Out in Nature: Homosexual Behaviour in the Animal Kingdom",
"video": false,
"voteAverage": 6.8,
"voteCount": 4,
"backdropPath": null,
"posterPath": "/jjxhR9ZxZ3vhauK8IDR6wIBlCLI.jpg"
},
{
"id": 128887,
"mediaType": "movie",
"adult": false,
"genreIds": [
16,
35
],
"originalLanguage": "ja",
"originalTitle": "クレヨンしんちゃん オタケべ!カスカベ野生王国",
"overview": "",
"popularity": 5.365,
"releaseDate": "2009-04-18",
"title": "クレヨンしんちゃん オタケべ!カスカベ野生王国",
"video": false,
"voteAverage": 8.5,
"voteCount": 10,
"backdropPath": "/azvwXB25Wvbx2Cou3Th7lbnjrqP.jpg",
"posterPath": "/h7LipCtdCyBOKR1By5wSP2Ufy3c.jpg"
},
{
"id": 579733,
"mediaType": "movie",
"adult": false,
"genreIds": [],
"originalLanguage": "no",
"originalTitle": "Dyreriket",
"overview": "",
"popularity": 0.6,
"releaseDate": "2018-05-01",
"title": "Dyreriket",
"video": false,
"voteAverage": 0,
"voteCount": 0,
"backdropPath": null,
"posterPath": null
},
{
"id": 111612,
"firstAirDate": "2018-10-12",
"genreIds": [
10764
],
"mediaType": "tv",
"name": "坂上どうぶつ王国",
"originCountry": [
"JP"
],
"originalLanguage": "ja",
"originalName": "坂上どうぶつ王国",
"overview": "",
"popularity": 1.186,
"voteAverage": 0,
"voteCount": 0,
"backdropPath": "/op8bK5R76L9QpwcVTnYG7nKXKsU.jpg",
"posterPath": "/2VPq9RYaDohOT8YqTibKZMMT2Ue.jpg"
},
{
"id": 156216,
"firstAirDate": "2022-01-17",
"genreIds": [
16
],
"mediaType": "tv",
"name": "动物王国的故事",
"originCountry": [
"CN"
],
"originalLanguage": "zh",
"originalName": "动物王国的故事",
"overview": "",
"popularity": 0.6,
"voteAverage": 0,
"voteCount": 0,
"backdropPath": "/uxIJQnjzIQn2MGHk17nNhoIEkxU.jpg",
"posterPath": "/v90bqYZRUT30n22DdwahmW18LFn.jpg"
}
]
}

View File

@@ -0,0 +1,832 @@
{
"title": "Celebrate",
"disambiguation": "",
"overview": "",
"artistId": 9,
"foreignAlbumId": "bfedab35-92b7-449b-adf0-875439ec9a85",
"monitored": true,
"anyReleaseOk": true,
"profileId": 1,
"duration": 1818062,
"albumType": "Album",
"secondaryTypes": [],
"mediumCount": 1,
"ratings": {
"votes": 1,
"value": 10
},
"releaseDate": "2022-07-27T00:00:00Z",
"releases": [
{
"id": 202,
"albumId": 32,
"foreignReleaseId": "22bd49a1-f858-427d-94ee-1788b54fb508",
"title": "Celebrate",
"status": "Official",
"duration": 0,
"trackCount": 9,
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "CD"
}
],
"mediumCount": 1,
"disambiguation": "ONCE JAPAN限定盤",
"country": [
"Japan"
],
"label": [
"Warner Music Japan"
],
"format": "CD",
"monitored": false
},
{
"id": 203,
"albumId": 32,
"foreignReleaseId": "52c73f5f-4f91-451b-96d1-3ac3ef9371ee",
"title": "Celebrate",
"status": "Official",
"duration": 0,
"trackCount": 9,
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "CD"
}
],
"mediumCount": 1,
"disambiguation": "初回限定盤B",
"country": [
"Japan"
],
"label": [
"Warner Music Japan"
],
"format": "CD",
"monitored": false
},
{
"id": 204,
"albumId": 32,
"foreignReleaseId": "5745040b-a5fa-4dae-ad31-0bce9d501e23",
"title": "Celebrate",
"status": "Official",
"duration": 0,
"trackCount": 9,
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "CD"
}
],
"mediumCount": 1,
"disambiguation": "JEONGYEON盤",
"country": [
"Japan"
],
"label": [
"Warner Music Japan"
],
"format": "CD",
"monitored": false
},
{
"id": 205,
"albumId": 32,
"foreignReleaseId": "006f9135-454b-4182-a057-47d1b002a282",
"title": "Celebrate",
"status": "Official",
"duration": 0,
"trackCount": 9,
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "CD"
}
],
"mediumCount": 1,
"disambiguation": "NAYEON盤",
"country": [
"Japan"
],
"label": [
"Warner Music Japan"
],
"format": "CD",
"monitored": false
},
{
"id": 206,
"albumId": 32,
"foreignReleaseId": "eeacd54b-a2bd-48f8-8d7c-3ab55b68f17c",
"title": "Celebrate",
"status": "Official",
"duration": 0,
"trackCount": 81,
"media": [
{
"mediumNumber": 1,
"mediumName": "NAYEON盤",
"mediumFormat": "CD"
},
{
"mediumNumber": 2,
"mediumName": "JEONGYEON盤",
"mediumFormat": "CD"
},
{
"mediumNumber": 3,
"mediumName": "MOMO盤",
"mediumFormat": "CD"
},
{
"mediumNumber": 4,
"mediumName": "SANA盤",
"mediumFormat": "CD"
},
{
"mediumNumber": 5,
"mediumName": "JIHYO盤",
"mediumFormat": "CD"
},
{
"mediumNumber": 6,
"mediumName": "MINA盤",
"mediumFormat": "CD"
},
{
"mediumNumber": 7,
"mediumName": "DAHYUN盤",
"mediumFormat": "CD"
},
{
"mediumNumber": 8,
"mediumName": "CHAEYOUNG盤",
"mediumFormat": "CD"
},
{
"mediumNumber": 9,
"mediumName": "TZUYU盤",
"mediumFormat": "CD"
}
],
"mediumCount": 9,
"disambiguation": "5th Anniversary Collection BOX",
"country": [
"Japan"
],
"label": [
"Warner Music Japan",
"Warner Music Japan",
"Warner Music Japan",
"Warner Music Japan",
"Warner Music Japan",
"Warner Music Japan",
"Warner Music Japan",
"Warner Music Japan",
"Warner Music Japan"
],
"format": "9xCD",
"monitored": false
},
{
"id": 207,
"albumId": 32,
"foreignReleaseId": "8ddd43f0-859e-4cff-be7c-daf6806cc035",
"title": "Celebrate",
"status": "Official",
"duration": 0,
"trackCount": 9,
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "CD"
}
],
"mediumCount": 1,
"disambiguation": "JIHYO盤",
"country": [
"Japan"
],
"label": [
"Warner Music Japan"
],
"format": "CD",
"monitored": false
},
{
"id": 208,
"albumId": 32,
"foreignReleaseId": "ad8e0553-97de-499b-8010-85bd02c62859",
"title": "Celebrate",
"status": "Official",
"duration": 0,
"trackCount": 9,
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "CD"
}
],
"mediumCount": 1,
"disambiguation": "TZUYU盤",
"country": [
"Japan"
],
"label": [
"Warner Music Japan"
],
"format": "CD",
"monitored": false
},
{
"id": 209,
"albumId": 32,
"foreignReleaseId": "276bf831-8cae-49a0-bc50-479869d401ac",
"title": "Celebrate",
"status": "Official",
"duration": 0,
"trackCount": 9,
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "CD"
}
],
"mediumCount": 1,
"disambiguation": "MOMO盤",
"country": [
"Japan"
],
"label": [
"Warner Music Japan"
],
"format": "CD",
"monitored": false
},
{
"id": 210,
"albumId": 32,
"foreignReleaseId": "3d201058-deb0-4159-a82f-d9076a608036",
"title": "Celebrate",
"status": "Official",
"duration": 0,
"trackCount": 9,
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "CD"
}
],
"mediumCount": 1,
"disambiguation": "MINA盤",
"country": [
"Japan"
],
"label": [
"Warner Music Japan"
],
"format": "CD",
"monitored": false
},
{
"id": 211,
"albumId": 32,
"foreignReleaseId": "e1fbf96d-f83e-478c-be7d-f0f6dd5305d1",
"title": "Celebrate",
"status": "Official",
"duration": 0,
"trackCount": 9,
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "CD"
}
],
"mediumCount": 1,
"disambiguation": "DAHYUN盤",
"country": [
"Japan"
],
"label": [
"Warner Music Japan"
],
"format": "CD",
"monitored": false
},
{
"id": 212,
"albumId": 32,
"foreignReleaseId": "769a7006-763b-4cd8-8d1f-d389d52ec002",
"title": "Celebrate",
"status": "Official",
"duration": 0,
"trackCount": 9,
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "CD"
}
],
"mediumCount": 1,
"disambiguation": "CHAEYOUNG盤",
"country": [
"Japan"
],
"label": [
"Warner Music Japan"
],
"format": "CD",
"monitored": false
},
{
"id": 213,
"albumId": 32,
"foreignReleaseId": "42e74581-0ef3-4db9-8a20-ba8a3daa1cf0",
"title": "Celebrate",
"status": "Official",
"duration": 0,
"trackCount": 9,
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "CD"
}
],
"mediumCount": 1,
"disambiguation": "初回限定盤A",
"country": [
"Japan"
],
"label": [
"Warner Music Japan",
"Warner Music Japan"
],
"format": "CD",
"monitored": false
},
{
"id": 214,
"albumId": 32,
"foreignReleaseId": "81bdf07f-61ad-4436-bfae-63cd1d9e700c",
"title": "Celebrate",
"status": "Official",
"duration": 0,
"trackCount": 9,
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "CD"
}
],
"mediumCount": 1,
"disambiguation": "通常盤",
"country": [
"Japan"
],
"label": [
"Warner Music Japan"
],
"format": "CD",
"monitored": false
},
{
"id": 215,
"albumId": 32,
"foreignReleaseId": "273b3ba1-88e8-4653-a542-c8b0489c1772",
"title": "Celebrate",
"status": "Official",
"duration": 0,
"trackCount": 9,
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "CD"
}
],
"mediumCount": 1,
"disambiguation": "SANA盤",
"country": [
"Japan"
],
"label": [
"Warner Music Japan"
],
"format": "CD",
"monitored": false
},
{
"id": 216,
"albumId": 32,
"foreignReleaseId": "2442df5f-4090-452c-be7f-5885dffee8e2",
"title": "Celebrate",
"status": "Official",
"duration": 1818062,
"trackCount": 9,
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "Digital Media"
}
],
"mediumCount": 1,
"disambiguation": "",
"country": [
"Algeria",
"Angola",
"Anguilla",
"Antigua and Barbuda",
"Argentina",
"Armenia",
"Australia",
"Austria",
"Azerbaijan",
"Bahamas",
"Bahrain",
"Barbados",
"Belgium",
"Belize",
"Benin",
"Bermuda",
"Bhutan",
"Bolivia",
"Bosnia and Herzegovina",
"Botswana",
"Brazil",
"Brunei",
"Bulgaria",
"Burkina Faso",
"Cambodia",
"Cameroon",
"Canada",
"Cape Verde",
"Cayman Islands",
"Chad",
"Chile",
"China",
"Colombia",
"Congo",
"Costa Rica",
"Côte d'Ivoire",
"Croatia",
"Cyprus",
"Czech Republic",
"Denmark",
"Dominica",
"Dominican Republic",
"Ecuador",
"Egypt",
"El Salvador",
"Estonia",
"Fiji",
"Finland",
"France",
"Gabon",
"Gambia",
"Georgia",
"Germany",
"Ghana",
"Greece",
"Grenada",
"Guatemala",
"Guinea-Bissau",
"Guyana",
"Honduras",
"Hong Kong",
"Hungary",
"Iceland",
"India",
"Indonesia",
"Iraq",
"Ireland",
"Israel",
"Italy",
"Jamaica",
"Japan",
"Jordan",
"Kazakhstan",
"Kenya",
"Kuwait",
"Kyrgyzstan",
"Laos",
"Latvia",
"Lebanon",
"Liberia",
"Libya",
"Lithuania",
"Luxembourg",
"Macao",
"North Macedonia",
"Madagascar",
"Malawi",
"Malaysia",
"Maldives",
"Mali",
"Malta",
"Mauritania",
"Mauritius",
"Mexico",
"Federated States of Micronesia",
"Moldova",
"Mongolia",
"Montserrat",
"Morocco",
"Mozambique",
"Myanmar",
"Namibia",
"Nepal",
"Netherlands",
"New Zealand",
"Nicaragua",
"Niger",
"Nigeria",
"Norway",
"Oman",
"Panama",
"Papua New Guinea",
"Paraguay",
"Peru",
"Philippines",
"Poland",
"Portugal",
"Qatar",
"Romania",
"Rwanda",
"Saint Kitts and Nevis",
"Saint Lucia",
"Saint Vincent and The Grenadines",
"Saudi Arabia",
"Senegal",
"Seychelles",
"Sierra Leone",
"Singapore",
"Slovakia",
"Slovenia",
"Solomon Islands",
"South Africa",
"Spain",
"Sri Lanka",
"Suriname",
"Eswatini",
"Sweden",
"Switzerland",
"Taiwan",
"Tajikistan",
"Tanzania",
"Thailand",
"Tonga",
"Trinidad and Tobago",
"Tunisia",
"Turkey",
"Turkmenistan",
"Turks and Caicos Islands",
"Uganda",
"Ukraine",
"United Arab Emirates",
"United Kingdom",
"United States",
"Uruguay",
"Uzbekistan",
"Vanuatu",
"Venezuela",
"Vietnam",
"British Virgin Islands",
"Yemen",
"Democratic Republic of the Congo",
"Zambia",
"Zimbabwe",
"Montenegro",
"Serbia",
"Kosovo"
],
"label": [
"Warner Music Japan"
],
"format": "Digital Media",
"monitored": true
}
],
"genres": [],
"media": [
{
"mediumNumber": 1,
"mediumName": "",
"mediumFormat": "Digital Media"
}
],
"artist": {
"artistMetadataId": 14,
"status": "continuing",
"ended": false,
"artistName": "TWICE",
"foreignArtistId": "8da127cc-c432-418f-b356-ef36210d82ac",
"tadbId": 0,
"discogsId": 0,
"overview": "Twice (Korean: 트와이스; RR: Teuwaiseu; Japanese: トゥワイス, Hepburn: To~uwaisu; commonly stylized in all caps as TWICE) is a South Korean girl group formed by JYP Entertainment. The group is composed of nine members: Nayeon, Jeongyeon, Momo, Sana, Jihyo, Mina, Dahyun, Chaeyoung, and Tzuyu. Twice was formed under the television program Sixteen (2015) and debuted on October 20, 2015, with the extended play (EP) The Story Begins.\nTwice rose to domestic fame in 2016 with their single \"Cheer Up\", which charted at number one on the Gaon Digital Chart, became the best-performing single of the year, and won \"Song of the Year\" at the Melon Music Awards and Mnet Asian Music Awards. Their next single, \"TT\", from their third EP Twicecoaster: Lane 1, topped the Gaon charts for four consecutive weeks. The EP was the highest selling Korean girl group album of 2016. Within 19 months after debut, Twice had already sold over 1.2 million units of their four EPs and special album. As of December 2020, the group has sold over 10 million albums cumulatively in South Korea and Japan, becoming the highest-selling K-Pop girl group of all time.The group debuted in Japan on June 28, 2017, under Warner Music Japan, with the release of a compilation album titled #Twice. The album charted at number 2 on the Oricon Albums Chart with the highest first-week album sales by a K-pop artist in Japan in two years. It was followed by the release of Twice's first original Japanese maxi single titled \"One More Time\" in October. Twice became the first Korean girl group to earn a platinum certification from the Recording Industry Association of Japan (RIAJ) for both an album and CD single in the same year. Twice ranked third in the Top Artist category of Billboard Japan's 2017 Year-end Rankings, and in 2019, they became the first Korean girl group to embark on a Japanese dome tour.\nTwice is the first female Korean act to simultaneously top both Billboard's World Albums and World Digital Song Sales charts with the release of their first studio album Twicetagram and its lead single \"Likey\" in 2017. With the release of their single \"Feel Special\" in 2019, Twice became the third female Korean act to chart into the Canadian Hot 100. After signing with Republic Records for American promotions as part of a partnership with JYP Entertainment, the group has charted into the US Billboard 200 with More & More and Eyes Wide Open in 2020 and Taste of Love and Formula of Love: O+T=<3 in 2021. Their first official English-language single, \"The Feels\", became their first song to enter the US Billboard Hot 100 and the UK Singles Chart, peaking at the 83rd and 80th positions of the charts, respectively. They have been dubbed the next \"Nation's Girl Group\", and their point choreography—including for \"Cheer Up\" (2016), \"TT\" (2016), \"Signal\" (2017), and \"What Is Love?\" (2018)—became dance crazes and viral memes imitated by many celebrities.",
"artistType": "Group",
"disambiguation": "South Korean girl group",
"links": [
{
"url": "https://www.generasia.com/wiki/Twice",
"name": "generasia"
},
{
"url": "http://twice.jype.com/",
"name": "jype"
},
{
"url": "https://twitter.com/JYPETWICE",
"name": "twitter"
},
{
"url": "https://www.facebook.com/JYPETWICE",
"name": "facebook"
},
{
"url": "https://www.instagram.com/twicetagram/",
"name": "instagram"
},
{
"url": "https://www.wikidata.org/wiki/Q20645861",
"name": "wikidata"
},
{
"url": "http://fans.jype.com/twice",
"name": "jype"
},
{
"url": "https://commons.wikimedia.org/wiki/File:Twice_performing_at_SAC_2016_02_(cropped).jpg",
"name": "wikimedia"
},
{
"url": "https://www.discogs.com/artist/4786543",
"name": "discogs"
},
{
"url": "https://www.last.fm/music/%ED%8A%B8%EC%99%80%EC%9D%B4%EC%8A%A4",
"name": "last"
},
{
"url": "https://www.last.fm/music/TWICE",
"name": "last"
},
{
"url": "https://commons.wikimedia.org/wiki/File:160507_Twice_guerrilla_concert.jpg",
"name": "wikimedia"
},
{
"url": "https://open.spotify.com/artist/7n2Ycct7Beij7Dj7meI4X0",
"name": "spotify"
},
{
"url": "http://www.twicejapan.com/",
"name": "twicejapan"
},
{
"url": "https://www.instagram.com/jypetwice_japan/",
"name": "instagram"
},
{
"url": "https://twitter.com/JYPETWICE_JAPAN",
"name": "twitter"
},
{
"url": "https://itunes.apple.com/jp/artist/id1203816887",
"name": "apple"
},
{
"url": "https://commons.wikimedia.org/wiki/File:(TV10)_%EC%97%AC%EC%9E%90%EC%B9%9C%EA%B5%AC%C2%B7%ED%8A%B8%EC%99%80%EC%9D%B4%EC%8A%A4%C2%B7%EB%B8%94%EB%9E%99%ED%95%91%ED%81%AC,_%EB%A0%88%EB%93%9C%EC%B9%B4%ED%8E%AB_%EA%B0%81%EC%96%91%EA%B0%81%EC%83%89_%ED%8C%A8%EC%85%98_%EC%97%B4%EC%A0%84_(2017_%EA%B3%A8%EB%93%A0%EB%94%94%EC%8A%A4%ED%81%AC_%EB%A0%88%EB%93%9C%EC%B9%B4%ED%8E%AB)_2m19s.jpg",
"name": "wikimedia"
},
{
"url": "https://itunes.apple.com/us/artist/id1203816887",
"name": "apple"
},
{
"url": "http://viaf.org/viaf/178150468353504172529",
"name": "viaf"
},
{
"url": "https://www.deezer.com/artist/161553",
"name": "deezer"
},
{
"url": "https://imvdb.com/n/twice",
"name": "imvdb"
},
{
"url": "https://listen.tidal.com/artist/3577941",
"name": "tidal"
},
{
"url": "https://www.youtube.com/TWICE",
"name": "youtube"
},
{
"url": "https://www.youtube.com/twicejapan_official",
"name": "youtube"
},
{
"url": "https://music.apple.com/mx/artist/1203816887",
"name": "apple"
},
{
"url": "https://www.imdb.com/name/nm9652049/",
"name": "imdb"
},
{
"url": "https://www.tiktok.com/@twice_tiktok_officialjp",
"name": "tiktok"
},
{
"url": "https://music.youtube.com/channel/UCAq0pFGa2w9SjxOq0ZxKVIw",
"name": "youtube"
}
],
"images": [
{
"url": "http://assets.fanart.tv/fanart/music/8da127cc-c432-418f-b356-ef36210d82ac/musicbanner/twice-58fb678fb1219.jpg",
"coverType": "banner",
"extension": ".jpg"
},
{
"url": "http://assets.fanart.tv/fanart/music/8da127cc-c432-418f-b356-ef36210d82ac/artistbackground/twice-619421e3c57cc.jpg",
"coverType": "fanart",
"extension": ".jpg"
},
{
"url": "http://assets.fanart.tv/fanart/music/8da127cc-c432-418f-b356-ef36210d82ac/hdmusiclogo/twice-58d833d0a608a.png",
"coverType": "logo",
"extension": ".png"
},
{
"url": "http://assets.fanart.tv/fanart/music/8da127cc-c432-418f-b356-ef36210d82ac/artistthumb/twice-58fb69c0c2b00.jpg",
"coverType": "poster",
"extension": ".jpg"
}
],
"path": "/data/Library/Music/TWICE",
"qualityProfileId": 1,
"metadataProfileId": 1,
"monitored": true,
"monitorNewItems": "all",
"genres": [
"Dance",
"Electronica",
"K-Pop",
"Pop",
"R&B"
],
"cleanName": "twice",
"sortName": "twice",
"tags": [],
"added": "2022-07-30T19:32:06Z",
"ratings": {
"votes": 4,
"value": 9.5
},
"statistics": {
"albumCount": 0,
"trackFileCount": 0,
"trackCount": 0,
"totalTrackCount": 0,
"sizeOnDisk": 0,
"percentOfTracks": 0
},
"id": 9
},
"images": [
{
"url": "/MediaCover/Albums/32/cover.jpg?lastWrite=637927379160000000",
"coverType": "cover",
"extension": ".jpg",
"remoteUrl": "https://imagecache.lidarr.audio/v1/caa/22bd49a1-f858-427d-94ee-1788b54fb508/32961181216-1200.jpg"
}
],
"links": [],
"statistics": {
"trackFileCount": 9,
"trackCount": 9,
"totalTrackCount": 9,
"sizeOnDisk": 74968875,
"percentOfTracks": 100
},
"grabbed": false,
"id": 32
}

View File

@@ -0,0 +1,47 @@
{
"id": 634649,
"mediaType": "movie",
"adult": false,
"genreIds": [
28,
12,
878
],
"originalLanguage": "en",
"originalTitle": "Spider-Man: No Way Home",
"overview": "Après les événements liés à l'affrontement avec Mysterio, l'identité secrète de Spider-Man a été révélée. Il est poursuivi par le gouvernement américain, qui l'accuse du meurtre de Mysterio, et traqué par les médias. Cet événement a également des conséquences terribles sur la vie de sa petite-amie M.J. et de son meilleur ami Ned. Désemparé, Peter Parker demande alors de l'aide au docteur Strange. Ce dernier lance un sort pour que tout le monde oublie que Peter est Spider-Man. Mais les choses ne se passent pas comme prévu, et cette action altère la stabilité de l'espace-temps. Cela ouvre le « multivers », un concept terrifiant dont ils ne savent quasiment rien...",
"popularity": 1643.549,
"releaseDate": "2021-12-15",
"title": "Spider-Man: No Way Home",
"video": false,
"voteAverage": 8,
"voteCount": 14510,
"backdropPath": "/ocUp7DJBIc8VJgLEw1prcyK1dYv.jpg",
"posterPath": "/3SyG7dq2q0ollxJ4pSsrqcfRmVj.jpg",
"mediaInfo": {
"downloadStatus": [],
"downloadStatus4k": [],
"id": 91,
"mediaType": "movie",
"tmdbId": 634649,
"tvdbId": null,
"imdbId": null,
"status": 5,
"status4k": 1,
"createdAt": "2021-11-15T15:15:57.000Z",
"updatedAt": "2022-08-01T08:40:19.000Z",
"lastSeasonChange": "2021-11-15T15:15:57.000Z",
"mediaAddedAt": "2021-12-23T12:04:39.000Z",
"serviceId": 0,
"serviceId4k": null,
"externalServiceId": 89,
"externalServiceId4k": null,
"externalServiceSlug": "634649",
"externalServiceSlug4k": null,
"ratingKey": "823",
"ratingKey4k": null,
"seasons": [],
"plexUrl": "https://app.plex.tv/desktop#!/server/719240db84d0795f30baa1c7283588fea536bb21/details?key=%2Flibrary%2Fmetadata%2F823",
"serviceUrl": "http://radarr:7878/movie/634649"
}
}

View File

@@ -0,0 +1,490 @@
{
"page": 1,
"totalPages": 43,
"totalResults": 847,
"results": [
{
"id": 66732,
"firstAirDate": "2016-07-15",
"genreIds": [
18,
10765,
9648
],
"mediaType": "tv",
"name": "Stranger Things",
"originCountry": [
"US"
],
"originalLanguage": "en",
"originalName": "Stranger Things",
"overview": "Quand un jeune garçon disparaît, une petite ville découvre une affaire mystérieuse, des expériences secrètes, des forces surnaturelles terrifiantes... et une fillette.",
"popularity": 1750.831,
"voteAverage": 8.6,
"voteCount": 12763,
"backdropPath": "/56v2KjBlU4XaOv9rVYEQypROD7P.jpg",
"posterPath": "/r2w5UNf2mO2Mdl4q6HopuBms6XM.jpg",
"mediaInfo": {
"downloadStatus": [],
"downloadStatus4k": [],
"id": 202,
"mediaType": "tv",
"tmdbId": 66732,
"tvdbId": 305288,
"imdbId": null,
"status": 4,
"status4k": 1,
"createdAt": "2022-08-01T08:55:00.000Z",
"updatedAt": "2022-08-02T02:30:09.000Z",
"lastSeasonChange": "2022-08-01T08:55:00.000Z",
"mediaAddedAt": "2022-08-01T08:49:00.000Z",
"serviceId": 0,
"serviceId4k": null,
"externalServiceId": 42,
"externalServiceId4k": null,
"externalServiceSlug": "stranger-things",
"externalServiceSlug4k": null,
"ratingKey": "2012",
"ratingKey4k": null,
"seasons": [
{
"id": 166,
"seasonNumber": 1,
"status": 3,
"status4k": 1,
"createdAt": "2022-08-01T08:55:00.000Z",
"updatedAt": "2022-08-02T02:30:09.000Z"
},
{
"id": 167,
"seasonNumber": 2,
"status": 3,
"status4k": 1,
"createdAt": "2022-08-01T08:55:00.000Z",
"updatedAt": "2022-08-02T02:30:09.000Z"
},
{
"id": 168,
"seasonNumber": 3,
"status": 3,
"status4k": 1,
"createdAt": "2022-08-01T08:55:00.000Z",
"updatedAt": "2022-08-02T02:30:09.000Z"
},
{
"id": 169,
"seasonNumber": 4,
"status": 5,
"status4k": 1,
"createdAt": "2022-08-01T08:55:00.000Z",
"updatedAt": "2022-08-01T08:55:00.000Z"
}
],
"plexUrl": "https://app.plex.tv/desktop#!/server/719240db84d0795f30baa1c7283588fea536bb21/details?key=%2Flibrary%2Fmetadata%2F2012",
"serviceUrl": "http://sonarr:8989/series/stranger-things"
}
},
{
"id": 74851,
"firstAirDate": "2017-10-27",
"genreIds": [
10767
],
"mediaType": "tv",
"name": "Beyond Stranger Things",
"originCountry": [
"US"
],
"originalLanguage": "en",
"originalName": "Beyond Stranger Things",
"overview": "Les secrets de l'univers de \"Stranger Things 2\" sont révélés tandis que comédiens et artistes invités évoquent les derniers épisodes avec Jim Rash. Attention, spoilers !",
"popularity": 72.277,
"voteAverage": 7.5,
"voteCount": 74,
"backdropPath": "/qevaCqIekzc7Bp5f2kGAi92kO39.jpg",
"posterPath": "/rHCFO8RJ3Hg6a8KjWAsvAsa38hp.jpg"
},
{
"id": 182026,
"mediaType": "movie",
"adult": false,
"genreIds": [
18
],
"originalLanguage": "en",
"originalTitle": "Stranger Things",
"overview": "",
"popularity": 76.465,
"releaseDate": "2013-04-05",
"title": "Stranger Things",
"video": false,
"voteAverage": 8.6,
"voteCount": 51,
"backdropPath": null,
"posterPath": "/4TKdguyacjYrC1Hnbi3PjSP8r3M.jpg"
},
{
"id": 1865,
"mediaType": "movie",
"adult": false,
"genreIds": [
12,
28,
14
],
"originalLanguage": "en",
"originalTitle": "Pirates of the Caribbean: On Stranger Tides",
"overview": "Dans cette histoire pleine daction, où vérité, trahison, jeunesse éternelle et mort forment un cocktail explosif, le capitaine Jack Sparrow retrouve une femme quil a connu autrefois. Leurs liens sontils faits damour ou, cette femme nestelle quune aventurière sans scrupules qui cherche à lutiliser pour découvrir la légendaire Fontaine de Jouvence? Lorsquelle loblige à embarquer à bord du Queen Annes Revenge, le bateau du terrible pirate BarbeNoire, Jack ne sait plus ce quil doit craindre le plus : Le redoutable maître du bateau ou cette femme surgit de son passé…",
"popularity": 251.27,
"releaseDate": "2011-05-14",
"title": "Pirates des Caraïbes : La Fontaine de jouvence",
"video": false,
"voteAverage": 6.5,
"voteCount": 12180,
"backdropPath": "/uzIGtyS6bbnJzGsPL93WCF1FWm8.jpg",
"posterPath": "/5JjjjGg24IGRXIQtaZkPU59acjV.jpg"
},
{
"id": 96608,
"firstAirDate": "2020-01-30",
"genreIds": [
9648,
80
],
"mediaType": "tv",
"name": "Intimidation",
"originCountry": [
"GB"
],
"originalLanguage": "en",
"originalName": "The Stranger",
"overview": "Adam Price mène une vie idyllique : il a un bon travail, deux fils merveilleux et son mariage semble sans faille. Mais son bonheur va soudainement voler en éclats lorsque « The Stranger » dévoile un secret choquant au sujet de sa femme.",
"popularity": 15.11,
"voteAverage": 7.4,
"voteCount": 283,
"backdropPath": "/97pA0UjBqqgcZFbREQL3U1BQDgX.jpg",
"posterPath": "/y9mX3A3O4SxffDIAlK8Li8AL8BD.jpg"
},
{
"id": 7183,
"mediaType": "movie",
"adult": false,
"genreIds": [
53
],
"originalLanguage": "en",
"originalTitle": "Perfect Stranger",
"overview": "Rowena est une journaliste d'investigation. Lorsqu'elle découvre que Harrison Hill, le très puissant publicitaire, est peut-être lié au meurtre de son amie, elle décide de mener son enquête. Pour se faire et l'approcher, elle va endosser deux identités, celle de Katherine, une intérimaire employée de sa société et Veronica, une jeune femme avec laquelle Hill flirte sur internet.",
"popularity": 20.863,
"releaseDate": "2007-04-11",
"title": "Dangereuse séduction",
"video": false,
"voteAverage": 5.8,
"voteCount": 756,
"backdropPath": "/sG7flxRI3ujV5t2scYpbmREVQbv.jpg",
"posterPath": "/jpQoXiLjTN8uqU9Ym9TMaz2D9aS.jpg"
},
{
"id": 99282,
"firstAirDate": "2020-04-13",
"genreIds": [
18,
9648,
80
],
"mediaType": "tv",
"name": "The Stranger",
"originCountry": [
"US"
],
"originalLanguage": "en",
"originalName": "The Stranger",
"overview": "Un jeune conducteur sans scrupule prend un mystérieux passager d'Hollywood Hills. Sur 12 heures, les deux hommes naviguent dans les bas-fonds sordides de Los Angeles...",
"popularity": 7.158,
"voteAverage": 7.5,
"voteCount": 25,
"backdropPath": "/g8n6jB5Mkn6FUGQ5MbqEMIHrZba.jpg",
"posterPath": "/4KrCPwB6yNBR8Chg5quigrrUFCD.jpg"
},
{
"id": 10053,
"mediaType": "movie",
"adult": false,
"genreIds": [
27,
9648
],
"originalLanguage": "en",
"originalTitle": "When a Stranger Calls",
"overview": "Alors qu'elle garde des enfants, une étudiante est terrorisée par un homme qui la harcèle au téléphone en lui demandant si elle a bien vérifié que tout était normal avec les petits dont elle a la charge. Les policiers qu'elle a appelés finissent par localiser les appels et l'informent que ceux-ci proviennent de la maison où elle se trouve...",
"popularity": 18.475,
"releaseDate": "2006-02-03",
"title": "Terreur sur la Ligne",
"video": false,
"voteAverage": 5.7,
"voteCount": 873,
"backdropPath": "/lF3ojoSmCZgrh9nyy2lOxoWL7KD.jpg",
"posterPath": "/xva4IuEfaT6c8tZLpNK2LKCtNGf.jpg"
},
{
"id": 291151,
"mediaType": "movie",
"adult": false,
"genreIds": [
53,
27,
9648
],
"originalLanguage": "en",
"originalTitle": "The Stranger",
"overview": "Un homme mystérieux à la recherche de sa femme arrive dans une petite ville du Canada, sa présence va quelque peu bouleverser l'apparente tranquillité qui y règne.",
"popularity": 7.932,
"releaseDate": "2014-06-12",
"title": "The Stranger",
"video": false,
"voteAverage": 4.7,
"voteCount": 77,
"backdropPath": "/plTx6iHNbLxNXKL4swZxl4RVT2w.jpg",
"posterPath": "/8YjSy1vG4yuuatgdAU1NbitA52F.jpg"
},
{
"id": 1262,
"mediaType": "movie",
"adult": false,
"genreIds": [
35,
18,
14,
10749
],
"originalLanguage": "en",
"originalTitle": "Stranger Than Fiction",
"overview": "Un beau matin, Harold Crick, un obscur fonctionnaire du fisc, entend soudain une voix de femme qui se met à commenter tout ce qu'il vit, y compris ses pensées les plus intimes. Pour Harold, c'est un cauchemar qui dérègle sa vie parfaitement agencée, mais cela devient encore plus grave lorsque la voix annonce qu'il va bientôt mourir...Harold découvre que cette voix est celle d'une romancière, Karen Eiffel, qui s'efforce désespérément d'écrire la fin de ce qui pourrait être son meilleur livre. Il ne lui reste plus qu'à trouver comment tuer son personnage principal : Harold ! Elle ignore que celui-ci existe, qu'il entend ses mots et connaît le sort qu'elle lui réserve...Pour s'en sortir vivant, Harold doit changer son destin. Sa seule chance est de devenir un personnage de comédie, puisque ceux-ci ne sont jamais tués...",
"popularity": 12.475,
"releaseDate": "2006-09-09",
"title": "L'Incroyable Destin de Harold Crick",
"video": false,
"voteAverage": 7.3,
"voteCount": 1875,
"backdropPath": "/d9eONXYtCmQnPWw61w9pNMGlSzK.jpg",
"posterPath": "/hZpCDBXmKqDBBonBKGAcZ95Qmvi.jpg"
},
{
"id": 87692,
"firstAirDate": "2019-04-06",
"genreIds": [
16,
35,
10765
],
"mediaType": "tv",
"name": "Chou Kadou Girl",
"originCountry": [
"JP"
],
"originalLanguage": "ja",
"originalName": "超可動ガール⅙ AMAZING STRANGER",
"overview": "Haruto est un otaku qui ne s'intéresse pas aux (vraies) filles en 3D ! Un jour, sa dernière acquisition, une figurine de son héroïne préférée Nona, se met à bouger toute seule. Ainsi commence la drôle de vie conjugale entre un otaku et un robot...",
"popularity": 11.422,
"voteAverage": 6.4,
"voteCount": 5,
"backdropPath": "/yl4Ltag61cTv0XtwbwMpvzxt7ov.jpg",
"posterPath": "/pPxakEs1TP6JhclPceGxHBoE8Ey.jpg"
},
{
"id": 455108,
"mediaType": "movie",
"adult": false,
"genreIds": [
9648,
18,
27,
36
],
"originalLanguage": "en",
"originalTitle": "The Little Stranger",
"overview": "Fils dune modeste domestique, le docteur Faraday sest construit une existence tranquille et respectable en devenant médecin de campagne. En 1947, lors dun été particulièrement long et chaud, il est appelé au chevet dune patiente à Hundreds Hall, où sa mère fut employée autrefois. Le domaine, qui appartient depuis plus de deux siècles à la famille Ayres, est aujourdhui en piteux état, et ses habitants la mère, son fils et sa fille sont hantés par quelque chose de bien plus effrayant encore que le déclin de leurs finances. Faraday ne simagine pas à quel point le destin de cette famille et le sien sont liés, ni ce que cela a de terrifiant…",
"popularity": 12.538,
"releaseDate": "2018-08-30",
"title": "The Little Stranger",
"video": false,
"voteAverage": 5.7,
"voteCount": 216,
"backdropPath": "/eyrUZ6jvg1Qy3jUz5YH8U4UkFLP.jpg",
"posterPath": "/qm1KJU9coK2voDIFD6AUvSgVG56.jpg"
},
{
"id": 38166,
"mediaType": "movie",
"adult": false,
"genreIds": [
28,
18,
53,
9648
],
"originalLanguage": "en",
"originalTitle": "The Stranger",
"overview": "Un agent du F.B.I poursuit le témoin matériel d'une enquête classée secret défense.",
"popularity": 6.555,
"releaseDate": "2010-06-01",
"title": "The Stranger",
"video": false,
"voteAverage": 4.9,
"voteCount": 57,
"backdropPath": "/kjFC8S6y9wKiRXRpOPwQQu6e9cJ.jpg",
"posterPath": "/fXg4MXYruDKrssFmfzKlf2TINJb.jpg"
},
{
"id": 20246,
"mediaType": "movie",
"adult": false,
"genreIds": [
80,
18,
9648,
53
],
"originalLanguage": "en",
"originalTitle": "The Stranger",
"overview": "L'inspecteur Wilson, de la commission contre les crimes de guerre, décide de relâcher un ancien chef de camp d'extermination nazi, dans l'espoir qu'il le conduira jusqu'à son supérieur, Franz Kindler. L'Allemand, qui circule sous un nom d'emprunt, se rend dans la petite ville de Harper. L'inspecteur le suit. Se sachant surveillé, l'ex-détenu attire le policier dans le gymnase de l'école. Là, il l'assomme et se précipite dans la maison voisine, qui n'est autre que celle de Franz Kindler, aujourd'hui professeur dans ce collège, pour le prévenir de l'arrivée de la police…",
"popularity": 7.449,
"releaseDate": "1946-07-02",
"title": "Le Criminel",
"video": false,
"voteAverage": 7.3,
"voteCount": 449,
"backdropPath": "/eewSm2QKPMueCM3ix5r3aE5eIur.jpg",
"posterPath": "/ee3F8CvNMSJZvYiwW2DKSvU9rQj.jpg"
},
{
"id": 469,
"mediaType": "movie",
"adult": false,
"genreIds": [
35,
18
],
"originalLanguage": "en",
"originalTitle": "Stranger Than Paradise",
"overview": "Eva, 16 ans, quitte la Hongrie et retrouve son cousin Willie, installé depuis 10 ans aux États-Unis. Inadaptés à cette terre de désillusions, ils partent de Miami découvrir le paradis de la Floride, royaume du jeu et dernier espoir d'un exil douloureux.",
"popularity": 9.713,
"releaseDate": "1984-10-01",
"title": "Stranger Than Paradise",
"video": false,
"voteAverage": 7.2,
"voteCount": 394,
"backdropPath": "/tAEV7htL9Yi0hMHtxlv2VAm9Rbe.jpg",
"posterPath": "/fxlMexOi2D64ugS07Sv2hJZYM3R.jpg"
},
{
"id": 45964,
"mediaType": "movie",
"adult": false,
"genreIds": [
27,
53,
18,
80,
9648
],
"originalLanguage": "en",
"originalTitle": "When a Stranger Calls",
"overview": "Au cours dune nuit où elle garde les enfants dun couple marié, une baby-sitter se fait harceler au téléphone par un inconnu qui lui pose systématiquement la même question : « êtes-vous allée voir les enfants ? ». De plus en plus inquiète à mesure que les appels se succèdent, la jeune femme décide de contacter la police.",
"popularity": 9.731,
"releaseDate": "1979-10-26",
"title": "Terreur sur la ligne",
"video": false,
"voteAverage": 6.2,
"voteCount": 178,
"backdropPath": "/3dK12SaczU7Tf8btq7K2F5HQg6F.jpg",
"posterPath": "/x4d8XUXbWLjiro51iQ2qiFhT6t4.jpg"
},
{
"id": 105024,
"firstAirDate": "2020-06-24",
"genreIds": [
35,
18
],
"mediaType": "tv",
"name": "Hello, Stranger",
"originCountry": [
"PH"
],
"originalLanguage": "tl",
"originalName": "Hello, Stranger",
"overview": "",
"popularity": 3.554,
"voteAverage": 7.3,
"voteCount": 3,
"backdropPath": "/8uXYX9F92gc0RlVlTEYVrze83fo.jpg",
"posterPath": "/uu8yWT64FP0W39whxIcs2aMv1Wb.jpg"
},
{
"id": 618352,
"mediaType": "movie",
"adult": false,
"genreIds": [
16,
28,
27,
14
],
"originalLanguage": "en",
"originalTitle": "DC Showcase: The Phantom Stranger",
"overview": "L'histoire se situe dans les années 1970, quand une jeune femme du nom de Jess et ses amis se rendent à une soirée dans un vieux manoir qui appartient à un certain Seth, les choses tournent au vinaigre, le Phantom Stranger arrivera pour leur porter secours.",
"popularity": 8.072,
"releaseDate": "2020-02-25",
"title": "DC Showcase: The Phantom Stranger",
"video": false,
"voteAverage": 7.5,
"voteCount": 49,
"backdropPath": "/vQkGZ0u9E8PgBbjg8vo61KHxQDc.jpg",
"posterPath": "/tqcL1YEiGUKsW1Ofka59m4MIKr1.jpg"
},
{
"id": 413852,
"mediaType": "movie",
"adult": false,
"genreIds": [
18,
9648,
53,
878
],
"originalLanguage": "en",
"originalTitle": "Welcome the Stranger",
"overview": "Alice arrive inopinément chez son frère, Ethan, en espérant se réconcilier avec lui. D'étranges visions et le retour de la petite amie d'Ethan perturbent son projet...",
"popularity": 5.994,
"releaseDate": "2018-03-20",
"title": "Welcome the Stranger",
"video": false,
"voteAverage": 5,
"voteCount": 33,
"backdropPath": "/51aiE8fEXchmbLIyX7Smm3zJavV.jpg",
"posterPath": "/fZch4FhfexA18gUUQjHXKnLmkjh.jpg"
},
{
"id": 41670,
"mediaType": "movie",
"adult": false,
"genreIds": [
18,
10749,
80
],
"originalLanguage": "en",
"originalTitle": "A Stranger Among Us",
"overview": "Chargée d'enquêter sur un meurtre au sein de la communauté hassidique de la ville de New-York, la détective Emily Eden parvient à se faire accepter au sein de cette secte si hermétique.",
"popularity": 7.016,
"releaseDate": "1992-07-17",
"title": "Une étrangère parmi nous",
"video": false,
"voteAverage": 5.7,
"voteCount": 71,
"backdropPath": "/hL0hkMFGWgOvC0P4le6gzRzwa62.jpg",
"posterPath": "/rvk00cSV6cGWQQIppEPYLnDebQ1.jpg"
}
]
}

View File

@@ -0,0 +1,110 @@
{
"seriesId": 37,
"episodeFileId": 7387,
"seasonNumber": 1,
"episodeNumber": 4,
"title": "Part IV",
"airDate": "2022-06-08",
"airDateUtc": "2022-06-08T07:00:00Z",
"overview": "Obi-Wan Kenobi plots a daring mission into enemy territory.",
"episodeFile": {
"seriesId": 37,
"seasonNumber": 1,
"relativePath": "Season 1/Obi-Wan.Kenobi.S01E04.1080p.WEB.h264-KOGi[rartv].mkv",
"path": "/tv/Obi-Wan Kenobi/Season 1/Obi-Wan.Kenobi.S01E04.1080p.WEB.h264-KOGi[rartv].mkv",
"size": 1893191174,
"dateAdded": "2022-06-08T07:32:27.158296Z",
"sceneName": "Obi-Wan.Kenobi.S01E04.1080p.WEB.h264-KOGi[rartv]",
"quality": {
"quality": {
"id": 3,
"name": "WEBDL-1080p",
"source": "web",
"resolution": 1080
},
"revision": {
"version": 1,
"real": 0,
"isRepack": false
}
},
"language": {
"id": 1,
"name": "English"
},
"mediaInfo": {
"audioChannels": 5.1,
"audioCodec": "EAC3 Atmos",
"videoCodec": "h264"
},
"originalFilePath": "Obi-Wan.Kenobi.S01E04.1080p.WEB.h264-KOGi[rarbg]/Obi-Wan.Kenobi.S01E04.1080p.WEB.h264-KOGi.mkv",
"qualityCutoffNotMet": false,
"id": 7387
},
"hasFile": true,
"monitored": true,
"unverifiedSceneNumbering": false,
"series": {
"title": "Obi-Wan Kenobi",
"sortTitle": "obiwan kenobi",
"seasonCount": 1,
"status": "ended",
"overview": "During the reign of the Empire, Obi-Wan Kenobi embarks on a crucial mission.",
"network": "Disney+",
"airTime": "03:00",
"images": [
{
"coverType": "banner",
"url": "https://artworks.thetvdb.com/banners/v4/series/393199/banners/6290d38b8c283.jpg"
},
{
"coverType": "poster",
"url": "https://artworks.thetvdb.com/banners/v4/series/393199/posters/629668351aca3.jpg"
},
{
"coverType": "fanart",
"url": "https://artworks.thetvdb.com/banners/v4/series/393199/backgrounds/62912a0fe623d.jpg"
}
],
"seasons": [
{
"seasonNumber": 1,
"monitored": true
}
],
"year": 2022,
"path": "/tv/Obi-Wan Kenobi",
"profileId": 1,
"languageProfileId": 1,
"seasonFolder": true,
"monitored": true,
"useSceneNumbering": false,
"runtime": 39,
"tvdbId": 393199,
"tvRageId": 0,
"tvMazeId": 52260,
"firstAired": "2022-05-27T00:00:00Z",
"lastInfoSync": "2022-07-22T03:36:34.392414Z",
"seriesType": "standard",
"cleanTitle": "obiwankenobi",
"imdbId": "tt8466564",
"titleSlug": "obi-wan-kenobi",
"certification": "TV-14",
"genres": [
"Action",
"Adventure",
"Fantasy",
"Mini-Series",
"Science Fiction"
],
"tags": [],
"added": "2022-05-03T20:22:10.47688Z",
"ratings": {
"votes": 0,
"value": 0
},
"qualityProfileId": 1,
"id": 37
},
"id": 1407
}

View File

@@ -1,10 +1,10 @@
import { createStyles, useMantineColorScheme, useMantineTheme } from '@mantine/core';
import { createStyles, Stack, Title, useMantineColorScheme, useMantineTheme } from '@mantine/core';
import { IconCalendar as CalendarIcon } from '@tabler/icons';
import axios from 'axios';
import { useEffect, useState } from 'react';
import { useConfig } from '../../../tools/state';
import { serviceItem } from '../../../tools/types';
import { IModule } from '../modules';
import { useConfig } from '../../tools/state';
import { serviceItem } from '../../tools/types';
import { IModule } from '../ModuleTypes';
const asModule = <T extends IModule>(t: T) => t;
export const DashdotModule = asModule({
@@ -30,6 +30,10 @@ export const DashdotModule = asModule({
value: ['CPU', 'RAM', 'Storage', 'Network'],
options: ['CPU', 'RAM', 'Storage', 'Network', 'GPU'],
},
url: {
name: 'Dash. URL',
value: '',
},
},
});
@@ -88,12 +92,12 @@ const bytePrettyPrint = (byte: number): string =>
? `${(byte / 1024).toFixed(1)} KiB`
: `${byte.toFixed(1)} B`;
const useJson = (service: serviceItem | undefined, url: string) => {
const useJson = (targetUrl: string, url: string) => {
const [data, setData] = useState<any | undefined>();
const doRequest = async () => {
try {
const resp = await axios.get(url, { baseURL: service?.url });
const resp = await axios.get(`/api/modules/dashdot?url=${url}&base=${targetUrl}`);
setData(resp.data);
// eslint-disable-next-line no-empty
@@ -101,10 +105,10 @@ const useJson = (service: serviceItem | undefined, url: string) => {
};
useEffect(() => {
if (service?.url) {
if (targetUrl) {
doRequest();
}
}, [service?.url]);
}, [targetUrl]);
return data;
};
@@ -118,8 +122,10 @@ export function DashdotComponent() {
const dashConfig = config.modules?.[DashdotModule.title]
.options as typeof DashdotModule['options'];
const isCompact = dashConfig?.useCompactView?.value ?? false;
const dashdotService = config.services.filter((service) => service.type === 'Dash.')[0];
const dashdotService: serviceItem | undefined = config.services.filter(
(service) => service.type === 'Dash.'
)[0];
const dashdotUrl = dashdotService?.url ?? dashConfig?.url?.value ?? '';
const enabledGraphs = dashConfig?.graphs?.value ?? ['CPU', 'RAM', 'Storage', 'Network'];
const cpuEnabled = enabledGraphs.includes('CPU');
const storageEnabled = enabledGraphs.includes('Storage');
@@ -127,8 +133,8 @@ export function DashdotComponent() {
const networkEnabled = enabledGraphs.includes('Network');
const gpuEnabled = enabledGraphs.includes('GPU');
const info = useJson(dashdotService, '/info');
const storageLoad = useJson(dashdotService, '/load/storage');
const info = useJson(dashdotUrl, '/info');
const storageLoad = useJson(dashdotUrl, '/load/storage');
const totalUsed =
(storageLoad?.layout as any[])?.reduce((acc, curr) => (curr.load ?? 0) + acc, 0) ?? 0;
@@ -166,13 +172,23 @@ export function DashdotComponent() {
},
].filter((g) => g.enabled);
if (dashdotUrl === '') {
return (
<div>
<h2 className={classes.heading}>Dash.</h2>
<p>
No dash. service found. Please add one to your Homarr dashboard or set a dashdot URL in
the module options
</p>
</div>
);
}
return (
<div>
<h2 className={classes.heading}>Dash.</h2>
{!dashdotService ? (
<p>No dash. service found. Please add one to your Homarr dashboard.</p>
) : !info ? (
{!info ? (
<p>Cannot acquire information from dash. - are you running the latest version?</p>
) : (
<div className={classes.graphsContainer}>
@@ -181,7 +197,7 @@ export function DashdotComponent() {
<div className={classes.tableRow}>
<p className={classes.tableLabel}>Storage:</p>
<p className={classes.tableValue}>
{(totalUsed / (totalSize || 1)).toFixed(1)}%{'\n'}
{((100 * totalUsed) / (totalSize || 1)).toFixed(1)}%{'\n'}
{bytePrettyPrint(totalUsed)} / {bytePrettyPrint(totalSize)}
</p>
</div>
@@ -198,33 +214,35 @@ export function DashdotComponent() {
</div>
{graphs.map((graph) => (
<iframe
className={classes.iframe}
style={
isCompact
? {
width: graph.spanTwo ? '100%' : 'calc(50% - 5px)',
}
: undefined
}
key={graph.name}
title={graph.name}
src={`${
dashdotService.url
}?singleGraphMode=true&graph=${graph.name.toLowerCase()}&theme=${colorScheme}&surface=${(colorScheme ===
'dark'
? theme.colors.dark[7]
: theme.colors.gray[0]
).substring(1)}${isCompact ? '&gap=10' : '&gap=5'}&innerRadius=${theme.radius.lg}${
graph.params
? `&${Object.entries(graph.params)
.map(([key, value]) => `${key}=${value.toString()}`)
.join('&')}`
: ''
}`}
frameBorder="0"
allowTransparency
/>
<Stack>
<Title style={{ position: 'absolute', right: 0 }} order={4} mt={10} mr={25}>
{graph.name}
</Title>
<iframe
className={classes.iframe}
style={
isCompact
? {
width: graph.spanTwo ? '100%' : 'calc(50% - 5px)',
}
: undefined
}
key={graph.name}
title={graph.name}
src={`${dashdotUrl}?singleGraphMode=true&graph=${graph.name.toLowerCase()}&theme=${colorScheme}&surface=${(colorScheme ===
'dark'
? theme.colors.dark[7]
: theme.colors.gray[0]
).substring(1)}${isCompact ? '&gap=10' : '&gap=5'}&innerRadius=${theme.radius.lg}${
graph.params
? `&${Object.entries(graph.params)
.map(([key, value]) => `${key}=${value.toString()}`)
.join('&')}`
: ''
}`}
frameBorder="0"
/>
</Stack>
))}
</div>
)}

View File

@@ -2,9 +2,9 @@ import { Group, Text, Title } from '@mantine/core';
import dayjs from 'dayjs';
import { useEffect, useState } from 'react';
import { IconClock as Clock } from '@tabler/icons';
import { useConfig } from '../../../tools/state';
import { IModule } from '../modules';
import { useSetSafeInterval } from '../../../tools/hooks/useSetSafeInterval';
import { useConfig } from '../../tools/state';
import { IModule } from '../ModuleTypes';
import { useSetSafeInterval } from '../../tools/hooks/useSetSafeInterval';
export const DateModule: IModule = {
title: 'Date',
@@ -34,9 +34,9 @@ export default function DateComponent(props: any) {
}, []);
return (
<Group p="sm" spacing="xs" direction="column">
<Group p="sm" spacing="xs">
<Title>{dayjs(date).format(formatString)}</Title>
<Text size="xl">{dayjs(date).format('dddd, MMMM D')}</Text>
<Text size="lg">{dayjs(date).format('dddd, MMMM D')}</Text>
</Group>
);
}

View File

@@ -1,5 +1,4 @@
import { Button, Group, Modal, Title } from '@mantine/core';
import { useBooleanToggle } from '@mantine/hooks';
import { showNotification, updateNotification } from '@mantine/notifications';
import {
IconCheck,
@@ -9,46 +8,50 @@ import {
IconRefresh,
IconRotateClockwise,
IconTrash,
IconX,
} from '@tabler/icons';
import axios from 'axios';
import Dockerode from 'dockerode';
import { tryMatchService } from '../../tools/addToHomarr';
import { useConfig } from '../../tools/state';
import { AddAppShelfItemForm } from '../AppShelf/AddAppShelfItem';
import { AddAppShelfItemForm } from '../../components/AppShelf/AddAppShelfItem';
import { useState } from 'react';
function sendDockerCommand(action: string, containerId: string, containerName: string) {
function sendDockerCommand(
action: string,
containerId: string,
containerName: string,
reload: () => void
) {
showNotification({
id: containerId,
loading: true,
title: `${action}ing container ${containerName.substring(1)}`,
title: `${action}ing container ${containerName}`,
message: undefined,
autoClose: false,
disallowClose: true,
});
axios.get(`/api/docker/container/${containerId}?action=${action}`).then((res) => {
setTimeout(() => {
if (res.data.success === true) {
updateNotification({
id: containerId,
title: `Container ${containerName} ${action}ed`,
message: `Your container was successfully ${action}ed`,
icon: <IconCheck />,
autoClose: 2000,
});
}
if (res.data.success === false) {
updateNotification({
id: containerId,
color: 'red',
title: 'There was an error with your container.',
message: undefined,
icon: <IconX />,
autoClose: 2000,
});
}
}, 500);
});
axios
.get(`/api/docker/container/${containerId}?action=${action}`)
.then((res) => {
updateNotification({
id: containerId,
title: `Container ${containerName} ${action}ed`,
message: `Your container was successfully ${action}ed`,
icon: <IconCheck />,
autoClose: 2000,
});
})
.catch((err) => {
updateNotification({
id: containerId,
color: 'red',
title: 'There was an error',
message: err.response.data.reason,
autoClose: 2000,
});
})
.finally(() => {
reload();
});
}
export interface ContainerActionBarProps {
@@ -57,8 +60,7 @@ export interface ContainerActionBarProps {
}
export default function ContainerActionBar({ selected, reload }: ContainerActionBarProps) {
const { config, setConfig } = useConfig();
const [opened, setOpened] = useBooleanToggle(false);
const [opened, setOpened] = useState<boolean>(false);
return (
<Group>
<Modal
@@ -79,9 +81,9 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
onClick={() =>
Promise.all(
selected.map((container) =>
sendDockerCommand('restart', container.Id, container.Names[0].substring(1))
sendDockerCommand('restart', container.Id, container.Names[0].substring(1), reload)
)
).then(() => reload())
)
}
variant="light"
color="orange"
@@ -93,22 +95,10 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
leftIcon={<IconPlayerStop />}
onClick={() =>
Promise.all(
selected.map((container) => {
if (
container.State === 'stopped' ||
container.State === 'created' ||
container.State === 'exited'
) {
return showNotification({
id: container.Id,
title: `Failed to stop ${container.Names[0].substring(1)}`,
message: "You can't stop a stopped container",
autoClose: 1000,
});
}
return sendDockerCommand('stop', container.Id, container.Names[0].substring(1));
})
).then(() => reload())
selected.map((container) =>
sendDockerCommand('stop', container.Id, container.Names[0].substring(1), reload)
)
)
}
variant="light"
color="red"
@@ -121,9 +111,9 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
onClick={() =>
Promise.all(
selected.map((container) =>
sendDockerCommand('start', container.Id, container.Names[0].substring(1))
sendDockerCommand('start', container.Id, container.Names[0].substring(1), reload)
)
).then(() => reload())
)
}
variant="light"
color="green"
@@ -143,7 +133,7 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
if (selected.length !== 1) {
showNotification({
autoClose: 5000,
title: <Title order={4}>Please only add one service at a time!</Title>,
title: <Title order={5}>Please only add one service at a time!</Title>,
color: 'red',
message: undefined,
});
@@ -161,18 +151,10 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
radius="md"
onClick={() =>
Promise.all(
selected.map((container) => {
if (container.State === 'running') {
return showNotification({
id: container.Id,
title: `Failed to delete ${container.Names[0].substring(1)}`,
message: "You can't delete a running container",
autoClose: 1000,
});
}
return sendDockerCommand('remove', container.Id, container.Names[0].substring(1));
})
).then(() => reload())
selected.map((container) =>
sendDockerCommand('remove', container.Id, container.Names[0].substring(1), reload)
)
)
}
>
Remove

View File

@@ -0,0 +1,83 @@
import { ActionIcon, Drawer, Group, LoadingOverlay, Text, Tooltip } from '@mantine/core';
import axios from 'axios';
import { useEffect, useState } from 'react';
import Docker from 'dockerode';
import { IconBrandDocker, IconX } from '@tabler/icons';
import { showNotification } from '@mantine/notifications';
import ContainerActionBar from './ContainerActionBar';
import DockerTable from './DockerTable';
import { useConfig } from '../../tools/state';
import { IModule } from '../ModuleTypes';
export const DockerModule: IModule = {
title: 'Docker',
description: 'Allows you to easily manage your torrents',
icon: IconBrandDocker,
component: DockerMenuButton,
};
export default function DockerMenuButton(props: any) {
const [opened, setOpened] = useState(false);
const [containers, setContainers] = useState<Docker.ContainerInfo[]>([]);
const [selection, setSelection] = useState<Docker.ContainerInfo[]>([]);
const { config } = useConfig();
const moduleEnabled = config.modules?.[DockerModule.title]?.enabled ?? false;
useEffect(() => {
reload();
}, [config.modules]);
function reload() {
if (!moduleEnabled) {
return;
}
setTimeout(() => {
axios
.get('/api/docker/containers')
.then((res) => {
setContainers(res.data);
setSelection([]);
})
.catch(() =>
// Send an Error notification
showNotification({
autoClose: 1500,
title: <Text>Docker integration failed</Text>,
color: 'red',
icon: <IconX />,
message: 'Did you forget to mount the docker socket ?',
})
);
}, 300);
}
const exists = config.modules?.[DockerModule.title]?.enabled ?? false;
if (!exists) {
return null;
}
// Check if the user has at least one container
if (containers.length < 1) return null;
return (
<>
<Drawer
opened={opened}
onClose={() => setOpened(false)}
padding="xl"
size="full"
title={<ContainerActionBar selected={selection} reload={reload} />}
>
<DockerTable containers={containers} selection={selection} setSelection={setSelection} />
</Drawer>
<Tooltip label="Docker">
<ActionIcon
variant="default"
radius="md"
size="xl"
color="blue"
onClick={() => setOpened(true)}
>
<IconBrandDocker />
</ActionIcon>
</Tooltip>
</>
);
}

View File

@@ -0,0 +1,124 @@
import { Table, Checkbox, Group, Badge, createStyles, ScrollArea, TextInput } from '@mantine/core';
import { IconSearch } from '@tabler/icons';
import Dockerode from 'dockerode';
import { useEffect, useState } from 'react';
import ContainerState from './ContainerState';
const useStyles = createStyles((theme) => ({
rowSelected: {
backgroundColor:
theme.colorScheme === 'dark'
? theme.fn.rgba(theme.colors[theme.primaryColor][7], 0.2)
: theme.colors[theme.primaryColor][0],
},
}));
export default function DockerTable({
containers,
selection,
setSelection,
}: {
setSelection: any;
containers: Dockerode.ContainerInfo[];
selection: Dockerode.ContainerInfo[];
}) {
const [usedContainers, setContainers] = useState<Dockerode.ContainerInfo[]>(containers);
const { classes, cx } = useStyles();
const [search, setSearch] = useState('');
useEffect(() => {
setContainers(containers);
}, [containers]);
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.currentTarget;
setSearch(value);
setContainers(filterContainers(containers, value));
};
function filterContainers(data: Dockerode.ContainerInfo[], search: string) {
const query = search.toLowerCase().trim();
return data.filter((item) =>
item.Names.some((name) => name.toLowerCase().includes(query) || item.Image.includes(query))
);
}
const toggleRow = (container: Dockerode.ContainerInfo) =>
setSelection((current: Dockerode.ContainerInfo[]) =>
current.includes(container) ? current.filter((c) => c !== container) : [...current, container]
);
const toggleAll = () =>
setSelection((current: any) =>
current.length === usedContainers.length ? [] : usedContainers.map((c) => c)
);
const rows = usedContainers.map((element) => {
const selected = selection.includes(element);
return (
<tr key={element.Id} className={cx({ [classes.rowSelected]: selected })}>
<td>
<Checkbox
checked={selection.includes(element)}
onChange={() => toggleRow(element)}
transitionDuration={0}
/>
</td>
<td>{element.Names[0].replace('/', '')}</td>
<td>{element.Image}</td>
<td>
<Group>
{element.Ports.sort((a, b) => a.PrivatePort - b.PrivatePort)
// Remove duplicates with filter function
.filter(
(port, index, self) =>
index === self.findIndex((t) => t.PrivatePort === port.PrivatePort)
)
.slice(-3)
.map((port) => (
<Badge key={port.PrivatePort} variant="outline">
{port.PrivatePort}:{port.PublicPort}
</Badge>
))}
{element.Ports.length > 3 && (
<Badge variant="filled">{element.Ports.length - 3} more</Badge>
)}
</Group>
</td>
<td>
<ContainerState state={element.State} />
</td>
</tr>
);
});
return (
<ScrollArea style={{ height: '80vh' }}>
<TextInput
placeholder="Search by container or image name"
mt="md"
icon={<IconSearch size={14} />}
value={search}
onChange={handleSearchChange}
/>
<Table captionSide="bottom" highlightOnHover sx={{ minWidth: 800 }} verticalSpacing="sm">
<thead>
<tr>
<th style={{ width: 40 }}>
<Checkbox
onChange={toggleAll}
checked={selection.length === usedContainers.length}
indeterminate={selection.length > 0 && selection.length !== usedContainers.length}
transitionDuration={0}
/>
</th>
<th>Name</th>
<th>Image</th>
<th>Ports</th>
<th>State</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</Table>
</ScrollArea>
);
}

View File

@@ -0,0 +1 @@
export { DockerModule } from './DockerModule';

View File

@@ -8,18 +8,18 @@ import {
Skeleton,
ScrollArea,
Center,
Image,
} from '@mantine/core';
import { IconDownload as Download } from '@tabler/icons';
import { useEffect, useState } from 'react';
import axios from 'axios';
import { NormalizedTorrent } from '@ctrl/shared-torrent';
import { useViewportSize } from '@mantine/hooks';
import { IModule } from '../modules';
import { useConfig } from '../../../tools/state';
import { AddItemShelfButton } from '../../AppShelf/AddAppShelfItem';
import { useSetSafeInterval } from '../../../tools/hooks/useSetSafeInterval';
import { humanFileSize } from '../../../tools/humanFileSize';
import { showNotification } from '@mantine/notifications';
import { IModule } from '../ModuleTypes';
import { useConfig } from '../../tools/state';
import { AddItemShelfButton } from '../../components/AppShelf/AddAppShelfItem';
import { useSetSafeInterval } from '../../tools/hooks/useSetSafeInterval';
import { humanFileSize } from '../../tools/humanFileSize';
export const DownloadsModule: IModule = {
title: 'Torrent',
@@ -52,21 +52,39 @@ export default function DownloadComponent() {
useEffect(() => {
setIsLoading(true);
if (downloadServices.length === 0) return;
setSafeInterval(() => {
const interval = setInterval(() => {
// Send one request with each download service inside
axios.post('/api/modules/downloads', { config }).then((response) => {
setTorrents(response.data);
setIsLoading(false);
});
axios
.post('/api/modules/downloads')
.then((response) => {
setTorrents(response.data);
setIsLoading(false);
})
.catch((error) => {
setTorrents([]);
// eslint-disable-next-line no-console
console.error('Error while fetching torrents', error.response.data);
setIsLoading(false);
showNotification({
title: 'Error fetching torrents',
autoClose: 1000,
disallowClose: true,
id: 'fail-torrent-downloads-module',
color: 'red',
message:
'Please check your config for any potential errors, check the console for more info',
});
clearInterval(interval);
});
}, 5000);
}, []);
if (downloadServices.length === 0) {
return (
<Group direction="column">
<Group>
<Title order={3}>No supported download clients found!</Title>
<Group>
<Text>Add a download service to view your current downloads...</Text>
<Text>Add a download service to view your current downloads</Text>
<AddItemShelfButton />
</Group>
</Group>
@@ -168,23 +186,18 @@ export default function DownloadComponent() {
);
});
const easteregg = (
<Center style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Image fit="cover" height={300} src="https://danjohnvelasco.github.io/images/empty.png" />
</Center>
);
return (
<Group noWrap grow direction="column" mt="xl">
<ScrollArea sx={{ height: 300 }}>
{rows.length > 0 ? (
<Table highlightOnHover>
<thead>{ths}</thead>
<tbody>{rows}</tbody>
</Table>
) : (
easteregg
)}
</ScrollArea>
</Group>
<ScrollArea mt="xl" sx={{ height: 300, width: '100%' }}>
{rows.length > 0 ? (
<Table highlightOnHover>
<thead>{ths}</thead>
<tbody>{rows}</tbody>
</Table>
) : (
<Center style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Title order={3}>No torrents found</Title>
</Center>
)}
</ScrollArea>
);
}

View File

@@ -1,4 +1,4 @@
import { Text, Title, Group, useMantineTheme, Box, Card, ColorSwatch } from '@mantine/core';
import { Text, Title, Group, useMantineTheme, Box, Card, ColorSwatch, Stack } from '@mantine/core';
import { IconDownload as Download } from '@tabler/icons';
import { useEffect, useState } from 'react';
import axios from 'axios';
@@ -6,11 +6,12 @@ import { NormalizedTorrent } from '@ctrl/shared-torrent';
import { linearGradientDef } from '@nivo/core';
import { Datum, ResponsiveLine } from '@nivo/line';
import { useListState } from '@mantine/hooks';
import { AddItemShelfButton } from '../../AppShelf/AddAppShelfItem';
import { useConfig } from '../../../tools/state';
import { humanFileSize } from '../../../tools/humanFileSize';
import { IModule } from '../modules';
import { useSetSafeInterval } from '../../../tools/hooks/useSetSafeInterval';
import { showNotification } from '@mantine/notifications';
import { AddItemShelfButton } from '../../components/AppShelf/AddAppShelfItem';
import { useConfig } from '../../tools/state';
import { humanFileSize } from '../../tools/humanFileSize';
import { IModule } from '../ModuleTypes';
import { useSetSafeInterval } from '../../tools/hooks/useSetSafeInterval';
export const TotalDownloadsModule: IModule = {
title: 'Download Speed',
@@ -43,10 +44,28 @@ export default function TotalDownloadsComponent() {
const totalUploadSpeed = torrents.reduce((acc, torrent) => acc + torrent.uploadSpeed, 0);
useEffect(() => {
if (downloadServices.length === 0) return;
setSafeInterval(() => {
axios.post('/api/modules/downloads', { config }).then((response) => {
setTorrents(response.data);
});
const interval = setSafeInterval(() => {
// Send one request with each download service inside
axios
.post('/api/modules/downloads')
.then((response) => {
setTorrents(response.data);
})
.catch((error) => {
setTorrents([]);
// eslint-disable-next-line no-console
console.error('Error while fetching torrents', error.response.data);
showNotification({
title: 'Torrent speed module failed to fetch torrents',
autoClose: 1000,
disallowClose: true,
id: 'fail-torrent-speed-module',
color: 'red',
message:
'Error fetching torrents, please check your config for any potential errors, check the console for more info',
});
clearInterval(interval);
});
}, 1000);
}, [config.services]);
@@ -60,12 +79,16 @@ export default function TotalDownloadsComponent() {
if (downloadServices.length === 0) {
return (
<Group direction="column">
<Group>
<Title order={4}>No supported download clients found!</Title>
<Group noWrap>
<Text>Add a download service to view your current downloads...</Text>
<AddItemShelfButton />
</Group>
<div>
<AddItemShelfButton
style={{
float: 'inline-end',
}}
/>
Add a download service to view your current downloads
</div>
</Group>
);
}
@@ -83,9 +106,9 @@ export default function TotalDownloadsComponent() {
})) as Datum[];
return (
<Group noWrap direction="column" grow>
<Stack>
<Title order={4}>Current download speed</Title>
<Group direction="column">
<Stack>
<Group>
<ColorSwatch size={12} color={theme.colors.green[5]} />
<Text>Download: {humanFileSize(totalDownloadSpeed)}/s</Text>
@@ -94,7 +117,7 @@ export default function TotalDownloadsComponent() {
<ColorSwatch size={12} color={theme.colors.blue[5]} />
<Text>Upload: {humanFileSize(totalUploadSpeed)}/s</Text>
</Group>
</Group>
</Stack>
<Box
style={{
height: 200,
@@ -115,7 +138,7 @@ export default function TotalDownloadsComponent() {
<Card p="sm" radius="md" withBorder>
<Text size="md">{roundedSeconds} seconds ago</Text>
<Card.Section p="sm">
<Group direction="column">
<Stack>
<Group>
<ColorSwatch size={10} color={theme.colors.green[5]} />
<Text size="md">Download: {humanFileSize(Download)}</Text>
@@ -124,7 +147,7 @@ export default function TotalDownloadsComponent() {
<ColorSwatch size={10} color={theme.colors.blue[5]} />
<Text size="md">Upload: {humanFileSize(Upload)}</Text>
</Group>
</Group>
</Stack>
</Card.Section>
</Card>
);
@@ -163,6 +186,6 @@ export default function TotalDownloadsComponent() {
]}
/>
</Box>
</Group>
</Stack>
);
}

View File

@@ -1,7 +1,9 @@
export * from './calendar';
export * from './dash.';
export * from './dashdot';
export * from './date';
export * from './downloads';
export * from './ping';
export * from './search';
export * from './weather';
export * from './docker';
export * from './overseerr';

View File

@@ -1,4 +1,5 @@
import {
ActionIcon,
Button,
Card,
Group,
@@ -8,8 +9,11 @@ import {
TextInput,
useMantineColorScheme,
} from '@mantine/core';
import { useConfig } from '../../tools/state';
import { IModule } from './modules';
import { IconAdjustments } from '@tabler/icons';
import { motion } from 'framer-motion';
import { useState } from 'react';
import { useConfig } from '../tools/state';
import { IModule } from './ModuleTypes';
function getItems(module: IModule) {
const { config, setConfig } = useConfig();
@@ -78,7 +82,7 @@ function getItems(module: IModule) {
});
}}
>
<Group noWrap align="end" position="center" mt={0}>
<Group noWrap align="end">
<TextInput
key={optionName}
id={optionName}
@@ -142,6 +146,8 @@ export function ModuleWrapper(props: any) {
const enabledModules = config.modules ?? {};
// Remove 'Module' from enabled modules titles
const isShown = enabledModules[module.title]?.enabled ?? false;
//TODO: fix the hover problem
const [hovering, setHovering] = useState(false);
if (!isShown) {
return null;
@@ -150,6 +156,7 @@ export function ModuleWrapper(props: any) {
return (
<Card
{...props}
key={module.title}
hidden={!isShown}
withBorder
radius="lg"
@@ -161,47 +168,60 @@ export function ModuleWrapper(props: any) {
${(config.settings.appOpacity || 100) / 100}`,
}}
>
<ModuleMenu
module={module}
styles={{
root: {
position: 'absolute',
top: 12,
right: 12,
},
<motion.div
onHoverStart={() => {
setHovering(true);
}}
/>
<module.component />
onHoverEnd={() => {
setHovering(false);
}}
>
<ModuleMenu module={module} hovered={hovering} />
<module.component />
</motion.div>
</Card>
);
}
export function ModuleMenu(props: any) {
const { module, styles } = props;
const { module, styles, hovered } = props;
const items: JSX.Element[] = getItems(module);
return (
<>
{module.options && (
<Menu
size="lg"
key={module.title}
withinPortal
width="lg"
shadow="xl"
withArrow
closeOnItemClick={false}
radius="md"
position="left"
styles={{
root: {
...props?.styles?.root,
},
body: {
// Add shadow and elevation to the body
boxShadow: '0 0 14px 14px rgba(0, 0, 0, 0.05)',
},
}}
>
<Menu.Label>Settings</Menu.Label>
{items.map((item) => (
<Menu.Item key={item.key}>{item}</Menu.Item>
))}
<Menu.Target>
<motion.div
style={{
position: 'absolute',
top: 15,
right: 15,
alignSelf: 'flex-end',
}}
animate={{
opacity: hovered === true ? 1 : 0,
}}
>
<ActionIcon>
<IconAdjustments />
</ActionIcon>
</motion.div>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Settings</Menu.Label>
{items.map((item) => (
<Menu.Item key={item.key}>{item}</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
</>

248
src/modules/overseerr/Movie.d.ts vendored Normal file
View File

@@ -0,0 +1,248 @@
export interface MovieResult {
id: number;
adult: boolean;
budget: number;
genres: Genre[];
relatedVideos: RelatedVideo[];
originalLanguage: string;
originalTitle: string;
popularity: number;
productionCompanies: ProductionCompany[];
productionCountries: ProductionCountry[];
releaseDate: Date;
releases: Releases;
revenue: number;
spokenLanguages: SpokenLanguage[];
status: string;
title: string;
video: boolean;
voteAverage: number;
voteCount: number;
backdropPath: string;
homepage: string;
imdbId: string;
overview: string;
posterPath: string;
runtime: number;
tagline: string;
credits: Credits;
collection: Collection;
externalIds: ExternalIDS;
mediaInfo: Media;
watchProviders: WatchProvider[];
}
export interface Collection {
id: number;
name: string;
posterPath: string;
backdropPath: string;
}
export interface Credits {
cast: Cast[];
crew: Crew[];
}
export interface Cast {
castId: number;
character: string;
creditId: string;
id: number;
name: string;
order: number;
gender: number;
profilePath: null | string;
}
export interface Crew {
creditId: string;
department: Department;
id: number;
job: string;
name: string;
gender: number;
profilePath: null | string;
}
export enum Department {
Art = 'Art',
Camera = 'Camera',
CostumeMakeUp = 'Costume & Make-Up',
Crew = 'Crew',
Directing = 'Directing',
Editing = 'Editing',
Production = 'Production',
Sound = 'Sound',
VisualEffects = 'Visual Effects',
Writing = 'Writing',
}
export interface ExternalIDS {
facebookId: string;
imdbId: string;
instagramId: string;
twitterId: string;
}
export interface Genre {
id: number;
name: string;
}
export interface Request {
id: number;
status: number;
createdAt: Date;
updatedAt: Date;
type: string;
is4k: boolean;
serverId: number;
profileId: number;
rootFolder: string;
languageProfileId: null;
tags: any[];
media: Media;
requestedBy: EdBy;
modifiedBy: EdBy;
seasons: any[];
seasonCount: number;
}
export interface Media {
downloadStatus: any[];
downloadStatus4k: any[];
id: number;
mediaType: string;
tmdbId: number;
tvdbId: null;
imdbId: null;
status: number;
status4k: number;
createdAt: Date;
updatedAt: Date;
lastSeasonChange: Date;
mediaAddedAt: Date;
serviceId: number;
serviceId4k: null;
externalServiceId: number;
externalServiceId4k: null;
externalServiceSlug: string;
externalServiceSlug4k: null;
ratingKey: string;
ratingKey4k: null;
requests?: Request[];
issues?: any[];
seasons: any[];
plexUrl: string;
serviceUrl: string;
}
export interface EdBy {
permissions: number;
id: number;
email: string;
plexUsername: string;
username: string;
recoveryLinkExpirationDate: null;
userType: number;
avatar: string;
movieQuotaLimit: null;
movieQuotaDays: null;
tvQuotaLimit: null;
tvQuotaDays: null;
createdAt: Date;
updatedAt: Date;
settings: Settings;
requestCount: number;
displayName: string;
}
export interface Settings {
id: number;
locale: string;
region: string;
originalLanguage: null;
pgpKey: null;
discordId: string;
pushbulletAccessToken: null;
pushoverApplicationToken: null;
pushoverUserKey: null;
telegramChatId: null;
telegramSendSilently: null;
notificationTypes: NotificationTypes;
}
export interface NotificationTypes {
discord: number;
email: number;
webpush: number;
}
export interface ProductionCompany {
id: number;
name: string;
originCountry?: string;
logoPath: string;
displayPriority?: number;
}
export interface ProductionCountry {
iso_3166_1: string;
name: string;
}
export interface RelatedVideo {
site: string;
key: string;
name: string;
size: number;
type: string;
url: string;
}
export interface Releases {
results: Result[];
}
export interface Result {
iso_3166_1: string;
release_dates: ReleaseDate[];
}
export interface ReleaseDate {
certification: string;
iso_639_1: ISO639_1 | null;
note: Note;
release_date: Date;
type: number;
}
export enum ISO639_1 {
CS = 'cs',
Empty = '',
}
export enum Note {
Empty = '',
HBOMax = 'HBO Max',
LosAngelesCalifornia = 'Los Angeles, California',
Starz = 'STARZ',
The4KUHDBluRayDVD = '4K UHD, Blu-ray & DVD',
TheMoreFunStuffVersion = 'The More Fun Stuff Version',
Tvod = 'TVOD',
VOD = 'VOD',
}
export interface SpokenLanguage {
english_name: string;
iso_639_1: string;
name: string;
}
export interface WatchProvider {
iso_3166_1: string;
link: string;
buy: ProductionCompany[];
flatrate: ProductionCompany[];
}

View File

@@ -0,0 +1,14 @@
import { IconEyeglass } from '@tabler/icons';
import { OverseerrMediaDisplay } from '../common';
import { IModule } from '../ModuleTypes';
export const OverseerrModule: IModule = {
title: 'Overseerr',
description: 'Allows you to search and add media from Overseerr/Jellyseerr',
icon: IconEyeglass,
component: OverseerrMediaDisplay,
};
export interface OverseerSearchProps {
query: string;
}

View File

@@ -0,0 +1,240 @@
import { Alert, Button, Checkbox, createStyles, Group, Modal, Stack, Table } from '@mantine/core';
import { showNotification, updateNotification } from '@mantine/notifications';
import { IconAlertCircle, IconCheck, IconDownload } from '@tabler/icons';
import axios from 'axios';
import Consola from 'consola';
import { useState } from 'react';
import { useColorTheme } from '../../tools/color';
import { MovieResult } from './Movie.d';
import { MediaType, Result } from './SearchResult.d';
import { TvShowResult, TvShowResultSeason } from './TvShow.d';
interface RequestModalProps {
base: Result;
opened: boolean;
setOpened: (opened: boolean) => void;
}
const useStyles = createStyles((theme) => ({
rowSelected: {
backgroundColor:
theme.colorScheme === 'dark'
? theme.fn.rgba(theme.colors[theme.primaryColor][7], 0.2)
: theme.colors[theme.primaryColor][0],
},
}));
export function RequestModal({ base, opened, setOpened }: RequestModalProps) {
const [result, setResult] = useState<MovieResult | TvShowResult>();
const { secondaryColor } = useColorTheme();
function getResults(base: Result) {
axios.get(`/api/modules/overseerr/${base.id}?type=${base.mediaType}`).then((res) => {
setResult(res.data);
});
}
if (opened && !result) {
getResults(base);
}
if (!result || !opened) {
return null;
}
return base.mediaType === 'movie' ? (
<MovieRequestModal result={result as MovieResult} opened={opened} setOpened={setOpened} />
) : (
<TvRequestModal result={result as TvShowResult} opened={opened} setOpened={setOpened} />
);
}
export function MovieRequestModal({
result,
opened,
setOpened,
}: {
result: MovieResult;
opened: boolean;
setOpened: (opened: boolean) => void;
}) {
const { secondaryColor } = useColorTheme();
return (
<Modal
onClose={() => setOpened(false)}
radius="lg"
size="lg"
trapFocus
zIndex={150}
withinPortal
opened={opened}
title={
<Group>
<IconDownload />
Ask for {result.title}
</Group>
}
>
<Stack>
<Alert
icon={<IconAlertCircle size={16} />}
title="Using API key"
color={secondaryColor}
radius="md"
variant="filled"
>
This request will be automatically approved
</Alert>
<Group>
<Button variant="outline" color="gray" onClick={() => setOpened(false)}>
Cancel
</Button>
<Button
variant="outline"
onClick={() => {
askForMedia(MediaType.Movie, result.id, result.title, []);
}}
>
Request
</Button>
</Group>
</Stack>
</Modal>
);
}
export function TvRequestModal({
result,
opened,
setOpened,
}: {
result: TvShowResult;
opened: boolean;
setOpened: (opened: boolean) => void;
}) {
const [selection, setSelection] = useState<TvShowResultSeason[]>(result.seasons);
const { classes, cx } = useStyles();
const toggleRow = (container: TvShowResultSeason) =>
setSelection((current: TvShowResultSeason[]) =>
current.includes(container) ? current.filter((c) => c !== container) : [...current, container]
);
const toggleAll = () =>
setSelection((current: any) =>
current.length === result.seasons.length ? [] : result.seasons.map((c) => c)
);
const rows = result.seasons.map((element) => {
const selected = selection.includes(element);
return (
<tr key={element.id} className={cx({ [classes.rowSelected]: selected })}>
<td>
<Checkbox
key={element.id}
checked={selection.includes(element)}
onChange={() => toggleRow(element)}
transitionDuration={0}
/>
</td>
<td>{element.name}</td>
<td>{element.episodeCount}</td>
</tr>
);
});
const { secondaryColor } = useColorTheme();
return (
<Modal
onClose={() => setOpened(false)}
radius="lg"
size="lg"
opened={opened}
title={
<Group>
<IconDownload />
Ask for {result.name ?? result.originalName ?? 'a TV show'}
</Group>
}
>
<Stack>
<Alert
icon={<IconAlertCircle size={16} />}
title="Using API key"
color={secondaryColor}
radius="md"
variant="filled"
>
This request will be automatically approved
</Alert>
<Table captionSide="bottom" highlightOnHover>
<caption>Tick the seasons that you want to be downloaded</caption>
<thead>
<tr>
<th>
<Checkbox
onChange={toggleAll}
checked={selection.length === result.seasons.length}
indeterminate={selection.length > 0 && selection.length !== result.seasons.length}
transitionDuration={0}
/>
</th>
<th>Season</th>
<th>Number of episodes</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</Table>
<Group>
<Button variant="outline" color="gray" onClick={() => setOpened(false)}>
Cancel
</Button>
<Button
variant="outline"
disabled={selection.length === 0}
onClick={() => {
askForMedia(
MediaType.Tv,
result.id,
result.name,
selection.map((s) => s.seasonNumber)
);
}}
>
Request
</Button>
</Group>
</Stack>
</Modal>
);
}
function askForMedia(type: MediaType, id: number, name: string, seasons?: number[]) {
Consola.info(`Requesting ${type} ${id} ${name}`);
showNotification({
title: 'Request',
id: id.toString(),
message: `Requesting media ${name}`,
color: 'orange',
loading: true,
autoClose: false,
disallowClose: true,
icon: <IconAlertCircle />,
});
axios
.post(`/api/modules/overseerr/${id}`, { type, seasons })
.then(() => {
updateNotification({
id: id.toString(),
title: '',
color: 'green',
message: ` ${name} requested`,
icon: <IconCheck />,
autoClose: 2000,
});
})
.catch((err) => {
updateNotification({
id: id.toString(),
color: 'red',
title: 'There was an error',
message: err.message,
autoClose: 2000,
});
});
}

66
src/modules/overseerr/SearchResult.d.ts vendored Normal file
View File

@@ -0,0 +1,66 @@
export interface SearchResult {
page: number;
totalPages: number;
totalResults: number;
results: Result[];
}
export interface Result {
id: number;
mediaType: MediaType;
adult?: boolean;
genreIds: number[];
originalLanguage: OriginalLanguage;
originalTitle?: string;
overview: string;
popularity: number;
releaseDate?: Date;
title?: string;
video?: boolean;
voteAverage: number;
voteCount: number;
backdropPath: null | string;
posterPath: string;
mediaInfo?: MediaInfo;
firstAirDate?: Date;
name?: string;
originCountry?: string[];
originalName?: string;
}
export interface MediaInfo {
downloadStatus: any[];
downloadStatus4k: any[];
id: number;
mediaType: MediaType;
tmdbId: number;
tvdbId: null;
imdbId: null;
status: number;
status4k: number;
createdAt: Date;
updatedAt: Date;
lastSeasonChange: Date;
mediaAddedAt: Date;
serviceId: number;
serviceId4k: null;
externalServiceId: number;
externalServiceId4k: null;
externalServiceSlug: string;
externalServiceSlug4k: null;
ratingKey: string;
ratingKey4k: null;
seasons: any[];
plexUrl: string;
serviceUrl: string;
mediaUrl?: string;
}
export enum MediaType {
Movie = 'movie',
Tv = 'tv',
}
export enum OriginalLanguage {
En = 'en',
}

295
src/modules/overseerr/TvShow.d.ts vendored Normal file
View File

@@ -0,0 +1,295 @@
export interface TvShowResult {
createdBy: CreatedBy[];
episodeRunTime: number[];
firstAirDate: Date;
genres: Genre[];
relatedVideos: RelatedVideo[];
homepage: string;
id: number;
inProduction: boolean;
languages: string[];
lastAirDate: Date;
name: string;
networks: Network[];
numberOfEpisodes: number;
numberOfSeasons: number;
originCountry: string[];
originalLanguage: string;
originalName: string;
tagline: string;
overview: string;
popularity: number;
productionCompanies: Network[];
productionCountries: ProductionCountry[];
contentRatings: ContentRatings;
spokenLanguages: SpokenLanguage[];
seasons: TvShowResultSeason[];
status: string;
type: string;
voteAverage: number;
voteCount: number;
backdropPath: string;
lastEpisodeToAir: LastEpisodeToAir;
posterPath: string;
credits: Credits;
externalIds: ExternalIDS;
keywords: Genre[];
mediaInfo: Media;
watchProviders: WatchProvider[];
}
export interface ContentRatings {
results: Result[];
}
export interface Result {
iso_3166_1: string;
rating: string;
}
export interface CreatedBy {
id: number;
credit_id: string;
name: string;
gender: number;
profile_path: string;
}
export interface Credits {
cast: Cast[];
crew: Crew[];
}
export interface Cast {
character: string;
creditId: string;
id: number;
name: string;
order: number;
gender: number;
profilePath: null | string;
}
export interface Crew {
creditId: string;
department: string;
id: number;
job: string;
name: string;
gender: number;
profilePath: string;
}
export interface ExternalIDS {
facebookId: string;
freebaseId: null;
freebaseMid: string;
imdbId: string;
instagramId: string;
tvdbId: number;
tvrageId: number;
twitterId: string;
}
export interface Genre {
id: number;
name: string;
}
export interface LastEpisodeToAir {
id: number;
airDate: Date;
episodeNumber: number;
name: string;
overview: string;
productionCode: string;
seasonNumber: number;
showId: number;
voteAverage: number;
stillPath: string;
}
export interface Request {
id: number;
status: number;
createdAt: Date;
updatedAt: Date;
type: Type;
is4k: boolean;
serverId: null;
profileId: null;
rootFolder: null;
languageProfileId: null;
tags: null;
media: Media;
requestedBy: EdBy;
modifiedBy: EdBy;
seasons: MediaInfoSeason[];
seasonCount: number;
}
export interface Media {
downloadStatus: DownloadStatus[];
downloadStatus4k: any[];
id: number;
mediaType: Type;
tmdbId: number;
tvdbId: number;
imdbId: null;
status: number;
status4k: number;
createdAt: Date;
updatedAt: Date;
lastSeasonChange: Date;
mediaAddedAt: Date;
serviceId: number;
serviceId4k: null;
externalServiceId: number;
externalServiceId4k: null;
externalServiceSlug: string;
externalServiceSlug4k: null;
ratingKey: string;
ratingKey4k: null;
requests?: Request[];
issues?: any[];
seasons: MediaInfoSeason[];
plexUrl: string;
serviceUrl: string;
}
export interface EdBy {
permissions: number;
id: number;
email: string;
plexUsername: string;
username: string;
recoveryLinkExpirationDate: null;
userType: number;
avatar: string;
movieQuotaLimit: null;
movieQuotaDays: null;
tvQuotaLimit: null;
tvQuotaDays: null;
createdAt: Date;
updatedAt: Date;
settings: Settings;
requestCount: number;
displayName: string;
}
export interface Settings {
id: number;
locale: string;
region: string;
originalLanguage: null;
pgpKey: null;
discordId: string;
pushbulletAccessToken: null;
pushoverApplicationToken: null;
pushoverUserKey: null;
telegramChatId: null;
telegramSendSilently: null;
notificationTypes: NotificationTypes;
}
export interface NotificationTypes {
discord: number;
email: number;
webpush: number;
}
export interface MediaInfoSeason {
id: number;
seasonNumber: number;
status: number;
status4k?: number;
createdAt: Date;
updatedAt: Date;
}
export enum Type {
Tv = 'tv',
}
export interface DownloadStatus {
externalId: number;
estimatedCompletionTime: Date;
mediaType: Type;
size: number;
sizeLeft: number;
status: Status;
timeLeft: string;
title: string;
}
export enum Status {
Completed = 'completed',
Downloading = 'downloading',
}
export interface Network {
id: number;
name: Name;
originCountry?: string;
logoPath: LogoPath | null;
displayPriority?: number;
}
export enum LogoPath {
HbifXPpM55B1FL5WPo7T72VzN78PNG = '/hbifXPpM55B1fL5wPo7t72vzN78.png',
KhiCshsZBdtUUYOr4VLoCtuqCEqPNG = '/khiCshsZBdtUUYOr4VLoCtuqCEq.png',
O9ExgOSLF3OTwR6T3DJOuwOKJgqJpg = '/o9ExgOSLF3OTwR6T3DJOuwOKJgq.jpg',
PEURlLlr8JggOwK53FJ5WdQl05YJpg = '/peURlLlr8jggOwK53fJ5wdQl05y.jpg',
T2YyOv40HZeVlLjYsCSPHnWLk4WJpg = '/t2yyOv40HZeVlLjYsCsPHnWLk4W.jpg',
TBEdFQDwx5LEVr8WpSEXQSIirVqJpg = '/tbEdFQDwx5LEVr8WpSeXQSIirVq.jpg',
The5NyLm42TmCqCMOZFvH4FcoSNKEWJpg = '/5NyLm42TmCqCMOZFvH4fcoSNKEW.jpg',
WwemzKWzjKYJFfCeiB57Q3R4BcmPNG = '/wwemzKWzjKYJFfCeiB57q3r4Bcm.png',
}
export enum Name {
AmazonVideo = 'Amazon Video',
AppleITunes = 'Apple iTunes',
Channel4 = 'Channel 4',
GooglePlayMovies = 'Google Play Movies',
HouseOfTomorrow = 'House of Tomorrow',
Ivi = 'Ivi',
Netflix = 'Netflix',
Zeppotron = 'Zeppotron',
}
export interface ProductionCountry {
iso_3166_1: string;
name: string;
}
export interface RelatedVideo {
site: string;
key: string;
name: string;
size: number;
type: string;
url: string;
}
export interface TvShowResultSeason {
airDate: Date;
episodeCount: number;
id: number;
name: string;
overview: string;
seasonNumber: number;
posterPath: string;
}
export interface SpokenLanguage {
englishName: string;
iso_639_1: string;
name: string;
}
export interface WatchProvider {
iso_3166_1: string;
link: string;
buy: Network[];
flatrate: Network[];
}

View File

@@ -0,0 +1,72 @@
{
"id": 86831,
"firstAirDate": "2019-03-15",
"genreIds": [
16,
10765
],
"mediaType": "tv",
"name": "Love, Death & Robots",
"originCountry": [
"US"
],
"originalLanguage": "en",
"originalName": "Love, Death & Robots",
"overview": "Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.",
"popularity": 623.833,
"voteAverage": 8.2,
"voteCount": 1720,
"backdropPath": "/78NtUwwo3lhH7QGh4vG3U1qK1mc.jpg",
"posterPath": "/cRiDlzzZC5lL7fvImuSjs04SUIJ.jpg",
"mediaInfo": {
"downloadStatus": [],
"downloadStatus4k": [],
"id": 79,
"mediaType": "tv",
"tmdbId": 86831,
"tvdbId": 357888,
"imdbId": null,
"status": 4,
"status4k": 1,
"createdAt": "2022-02-05T04:30:01.000Z",
"updatedAt": "2022-02-05T09:25:22.000Z",
"lastSeasonChange": "2022-02-05T04:30:01.000Z",
"mediaAddedAt": "2022-02-04T01:16:35.000Z",
"serviceId": 0,
"serviceId4k": null,
"externalServiceId": 7,
"externalServiceId4k": null,
"externalServiceSlug": "love-death-and-robots",
"externalServiceSlug4k": null,
"ratingKey": "182",
"ratingKey4k": null,
"seasons": [
{
"id": 11,
"seasonNumber": 1,
"status": 1,
"status4k": 1,
"createdAt": "2022-02-05T04:30:01.000Z",
"updatedAt": "2022-02-05T04:30:01.000Z"
},
{
"id": 24,
"seasonNumber": 2,
"status": 5,
"status4k": 1,
"createdAt": "2022-02-05T04:30:01.000Z",
"updatedAt": "2022-02-05T04:30:01.000Z"
},
{
"id": 85,
"seasonNumber": 3,
"status": 3,
"status4k": 1,
"createdAt": "2022-04-26T04:30:02.000Z",
"updatedAt": "2022-04-26T04:30:02.000Z"
}
],
"plexUrl": "https://app.plex.tv/desktop#!/server/5b88b3c20d2d092c0ee848f9044f3f3bee033d91/details?key=%2Flibrary%2Fmetadata%2F182",
"serviceUrl": "http://server:8989/series/love-death-and-robots"
}
}

View File

@@ -0,0 +1 @@
export { OverseerrModule } from './OverseerrModule';

View File

@@ -3,8 +3,8 @@ import axios, { AxiosResponse } from 'axios';
import { motion } from 'framer-motion';
import { useEffect, useState } from 'react';
import { IconPlug as Plug } from '@tabler/icons';
import { useConfig } from '../../../tools/state';
import { IModule } from '../modules';
import { useConfig } from '../../tools/state';
import { IModule } from '../ModuleTypes';
export const PingModule: IModule = {
title: 'Ping Services',
@@ -56,22 +56,23 @@ export default function PingComponent(props: any) {
return null;
}
return (
<Tooltip
radius="lg"
<motion.div
style={{ position: 'absolute', bottom: 20, right: 20 }}
label={
isOnline === 'loading'
? 'Loading...'
: isOnline === 'online'
? `Online - ${response}`
: `Offline - ${response}`
}
animate={{
scale: isOnline === 'online' ? [1, 0.8, 1] : 1,
}}
transition={{ repeat: Infinity, duration: 2.5, ease: 'easeInOut' }}
>
<motion.div
animate={{
scale: isOnline === 'online' ? [1, 0.8, 1] : 1,
}}
transition={{ repeat: Infinity, duration: 2.5, ease: 'easeInOut' }}
<Tooltip
withinPortal
radius="lg"
label={
isOnline === 'loading'
? 'Loading...'
: isOnline === 'online'
? `Online - ${response}`
: `Offline - ${response}`
}
>
<Indicator
size={13}
@@ -79,7 +80,7 @@ export default function PingComponent(props: any) {
>
{null}
</Indicator>
</motion.div>
</Tooltip>
</Tooltip>
</motion.div>
);
}

View File

@@ -0,0 +1,199 @@
import { Kbd, createStyles, Autocomplete, Popover, ScrollArea, Divider } from '@mantine/core';
import { useClickOutside, useDebouncedValue, useHotkeys } from '@mantine/hooks';
import { useForm } from '@mantine/form';
import React, { useEffect, useRef, useState } from 'react';
import {
IconSearch as Search,
IconBrandYoutube as BrandYoutube,
IconDownload as Download,
IconMovie,
} from '@tabler/icons';
import axios from 'axios';
import { showNotification } from '@mantine/notifications';
import { useConfig } from '../../tools/state';
import { IModule } from '../ModuleTypes';
import { OverseerrModule } from '../overseerr';
import { OverseerrMediaDisplay } from '../common';
const useStyles = createStyles((theme) => ({
hide: {
[theme.fn.smallerThan('sm')]: {
display: 'none',
},
display: 'flex',
alignItems: 'center',
},
}));
export const SearchModule: IModule = {
title: 'Search Bar',
description: 'Search bar to search the web, youtube, torrents or overseerr',
icon: Search,
component: SearchBar,
};
export default function SearchBar(props: any) {
const { classes, cx } = useStyles();
// Config
const { config } = useConfig();
const isModuleEnabled = config.modules?.[SearchModule.title]?.enabled ?? false;
const isOverseerrEnabled = config.modules?.[OverseerrModule.title]?.enabled ?? false;
const OverseerrService = config.services.find(
(service) => service.type === 'Overseerr' || service.type === 'Jellyseerr'
);
const queryUrl = config.settings.searchUrl ?? 'https://www.google.com/search?q=';
const [OverseerrResults, setOverseerrResults] = useState<any[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [icon, setIcon] = useState(<Search />);
const [results, setResults] = useState<any[]>([]);
const [opened, setOpened] = useState(false);
const ref = useClickOutside(() => setOpened(false));
const textInput = useRef<HTMLInputElement>();
useHotkeys([['ctrl+K', () => textInput.current && textInput.current.focus()]]);
const form = useForm({
initialValues: {
query: '',
},
});
const [debounced, cancel] = useDebouncedValue(form.values.query, 250);
useEffect(() => {
if (OverseerrService === undefined && isOverseerrEnabled) {
showNotification({
title: 'Overseerr integration',
message:
'Module enabled but no service is configured with the type "Overseerr" / "Jellyseerr"',
color: 'red',
});
}
}, [OverseerrService, isOverseerrEnabled]);
useEffect(() => {
if (
form.values.query !== debounced ||
form.values.query === '' ||
(form.values.query.startsWith('!') && !form.values.query.startsWith('!os'))
) {
return;
}
if (form.values.query.startsWith('!os')) {
axios
.get(`/api/modules/overseerr?query=${form.values.query.replace('!os', '').trim()}`)
.then((res) => {
setOverseerrResults(res.data.results ?? []);
setLoading(false);
});
setLoading(true);
} else {
setOverseerrResults([]);
axios
.get(`/api/modules/search?q=${form.values.query}`)
.then((res) => setResults(res.data ?? []));
}
}, [debounced]);
if (!isModuleEnabled) {
return null;
}
const autocompleteData = results.map((result) => ({
label: result.phrase,
value: result.phrase,
}));
return (
<form
onChange={() => {
// If query contains !yt or !t add "Searching on YouTube" or "Searching torrent"
const query = form.values.query.trim();
switch (query.substring(0, 3)) {
case '!yt':
setIcon(<BrandYoutube />);
break;
case '!t ':
setIcon(<Download />);
break;
case '!os':
setIcon(<IconMovie />);
break;
default:
setIcon(<Search />);
break;
}
}}
onSubmit={form.onSubmit((values) => {
const query = values.query.trim();
setTimeout(() => {
form.setValues({ query: '' });
switch (query.substring(0, 3)) {
case '!yt':
window.open(`https://www.youtube.com/results?search_query=${query.substring(3)}`);
break;
case '!t ':
window.open(`https://www.torrentdownloads.me/search/?search=${query.substring(3)}`);
break;
case '!os':
break;
default:
window.open(
`${queryUrl.includes('%s') ? queryUrl.replace('%s', query) : `${queryUrl}${query}`}`
);
break;
}
}, 500);
})}
>
<Popover
opened={OverseerrResults.length > 0 && opened}
position="bottom"
withArrow
withinPortal
shadow="md"
radius="md"
zIndex={100}
trapFocus
transition="pop-top-right"
>
<Popover.Target>
<Autocomplete
onFocusCapture={() => setOpened(true)}
autoFocus
variant="filled"
data={autocompleteData}
icon={icon}
ref={textInput}
rightSectionWidth={90}
rightSection={
<div className={classes.hide}>
<Kbd>Ctrl</Kbd>
<span style={{ margin: '0 5px' }}>+</span>
<Kbd>K</Kbd>
</div>
}
radius="md"
size="md"
styles={{ rightSection: { pointerEvents: 'none' } }}
placeholder="Search the web..."
{...props}
{...form.getInputProps('query')}
/>
</Popover.Target>
<Popover.Dropdown>
<div ref={ref}>
<ScrollArea style={{ height: 400, width: 400 }} offsetScrollbars>
{OverseerrResults.slice(0, 5).map((result, index) => (
<React.Fragment key={index}>
<OverseerrMediaDisplay key={result.id} media={result} />
{index < OverseerrResults.length - 1 && <Divider variant="dashed" my="xl" />}
</React.Fragment>
))}
</ScrollArea>
</div>
</Popover.Dropdown>
</Popover>
</form>
);
}

View File

@@ -1,4 +1,4 @@
import { Group, Space, Title, Tooltip, Skeleton } from '@mantine/core';
import { Group, Space, Title, Tooltip, Skeleton, Stack, Box } from '@mantine/core';
import axios from 'axios';
import { useEffect, useState } from 'react';
import {
@@ -13,8 +13,8 @@ import {
IconSnowflake as Snowflake,
IconSun as Sun,
} from '@tabler/icons';
import { useConfig } from '../../../tools/state';
import { IModule } from '../modules';
import { useConfig } from '../../tools/state';
import { IModule } from '../ModuleTypes';
import { WeatherResponse } from './WeatherInterface';
export const WeatherModule: IModule = {
@@ -124,8 +124,10 @@ export function WeatherIcon(props: any) {
}
}
return (
<Tooltip label={data.name}>
<data.icon size={50} />
<Tooltip withinPortal withArrow label={data.name}>
<Box>
<data.icon size={50} />
</Box>
</Tooltip>
);
}
@@ -157,10 +159,10 @@ export default function WeatherComponent(props: any) {
});
}, [cityInput]);
if (!weather.current_weather) {
return (
return (
<>
<Skeleton height={40} width={100} mb="xl" />
<Group noWrap direction="row">
<Group noWrap>
<Skeleton height={50} circle />
<Group>
<Skeleton height={25} width={70} mr="lg" />
@@ -174,7 +176,7 @@ export default function WeatherComponent(props: any) {
return isFahrenheit ? `${(value * (9 / 5) + 32).toFixed(1)}°F` : `${value.toFixed(1)}°C`;
}
return (
<Group p="sm" spacing="xs" direction="column">
<Stack p="sm" spacing="xs">
<Title>{usePerferedUnit(weather.current_weather.temperature)}</Title>
<Group spacing={0}>
<WeatherIcon code={weather.current_weather.weathercode} />
@@ -185,6 +187,6 @@ export default function WeatherComponent(props: any) {
<span>{usePerferedUnit(weather.daily.temperature_2m_min[0])}</span>
<ArrowDownRight size={16} />
</Group>
</Group>
</Stack>
);
}

View File

@@ -58,7 +58,7 @@ const useStyles = createStyles((theme) => ({
},
}));
export function Illustration(props: React.ComponentPropsWithoutRef<'svg'>) {
function Illustration(props: React.ComponentPropsWithoutRef<'svg'>) {
const theme = useMantineTheme();
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 362 145" {...props}>
@@ -70,7 +70,7 @@ export function Illustration(props: React.ComponentPropsWithoutRef<'svg'>) {
);
}
export default function NothingFoundBackground() {
export default function Custom404() {
const { classes } = useStyles();
return (

View File

@@ -1,14 +1,14 @@
import { GetServerSidePropsContext } from 'next';
import { useState } from 'react';
import { AppProps } from 'next/app';
import { getCookie, setCookies } from 'cookies-next';
import { getCookie, setCookie } from 'cookies-next';
import Head from 'next/head';
import { MantineProvider, ColorScheme, ColorSchemeProvider, MantineTheme } from '@mantine/core';
import { NotificationsProvider } from '@mantine/notifications';
import { useHotkeys } from '@mantine/hooks';
import { ModalsProvider } from '@mantine/modals';
import { ConfigProvider } from '../tools/state';
import { theme } from '../tools/theme';
import { styles } from '../tools/styles';
import { ColorTheme } from '../tools/color';
export default function App(this: any, props: AppProps & { colorScheme: ColorScheme }) {
@@ -30,7 +30,7 @@ export default function App(this: any, props: AppProps & { colorScheme: ColorSch
const toggleColorScheme = (value?: ColorScheme) => {
const nextColorScheme = value || (colorScheme === 'dark' ? 'light' : 'dark');
setColorScheme(nextColorScheme);
setCookies('color-scheme', nextColorScheme, { maxAge: 60 * 60 * 24 * 30 });
setCookie('color-scheme', nextColorScheme, { maxAge: 60 * 60 * 24 * 30 });
};
useHotkeys([['mod+J', () => toggleColorScheme()]]);
@@ -45,20 +45,33 @@ export default function App(this: any, props: AppProps & { colorScheme: ColorSch
<MantineProvider
theme={{
...theme,
components: {
Checkbox: {
styles: {
input: { cursor: 'pointer' },
label: { cursor: 'pointer' },
},
},
Switch: {
styles: {
input: { cursor: 'pointer' },
label: { cursor: 'pointer' },
},
},
},
primaryColor,
primaryShade,
colorScheme,
}}
styles={{
...styles,
}}
withGlobalStyles
withNormalizeCSS
>
<NotificationsProvider limit={4} position="bottom-left">
<ConfigProvider>
<Component {...pageProps} />
</ConfigProvider>
<ModalsProvider>
<ConfigProvider>
<Component {...pageProps} />
</ConfigProvider>
</ModalsProvider>
</NotificationsProvider>
</MantineProvider>
</ColorTheme.Provider>

View File

@@ -1,21 +1,20 @@
import Document, { DocumentContext } from 'next/document';
import { ServerStyles, createStylesServer } from '@mantine/next';
import { createGetInitialProps } from '@mantine/next';
import Document, { Head, Html, Main, NextScript } from 'next/document';
const stylesServer = createStylesServer();
const getInitialProps = createGetInitialProps();
export default class _Document extends Document {
static async getInitialProps(ctx: DocumentContext) {
const initialProps = await Document.getInitialProps(ctx);
// Add your app specific logic here
static getInitialProps = getInitialProps;
return {
...initialProps,
styles: (
<>
{initialProps.styles}
<ServerStyles html={initialProps.html} server={stylesServer} />
</>
),
};
render() {
return (
<Html>
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
}

View File

@@ -1,15 +1,16 @@
import { NextFetchEvent, NextRequest, NextResponse } from 'next/server';
// eslint-disable-next-line consistent-return
export function middleware(req: NextRequest, ev: NextFetchEvent) {
const ok = req.cookies.get('password') === process.env.PASSWORD;
const isCorrectPassword = req.cookies.password === process.env.PASSWORD;
const url = req.nextUrl.clone();
if (
!ok &&
!isCorrectPassword &&
url.pathname !== '/login' &&
process.env.PASSWORD &&
url.pathname !== '/api/configs/tryPassword'
) {
url.pathname = '/login';
return NextResponse.rewrite(url);
}
return NextResponse.rewrite(url);
}

View File

@@ -21,37 +21,29 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
}
// Get the container with the ID
const container = docker.getContainer(id);
// Get the container info
container.inspect((err, data) => {
if (err) {
res.status(500).json({
message: err,
});
}
});
try {
const startAction = async () => {
switch (action) {
case 'remove':
await container.remove();
break;
return container.remove();
case 'start':
container.start();
break;
return container.start();
case 'stop':
container.stop();
break;
return container.stop();
case 'restart':
container.restart();
break;
return container.restart();
default:
return Promise;
}
} catch (err) {
res.status(500).json({
message: err,
};
try {
await startAction();
return res.status(200).json({
statusCode: 200,
message: `Container ${id} ${action}ed`,
});
} catch (err) {
return res.status(500).json(err);
}
return res.status(200).json({
success: true,
});
}
export default async (req: NextApiRequest, res: NextApiResponse) => {

View File

@@ -2,11 +2,14 @@ import { NextApiRequest, NextApiResponse } from 'next';
import Docker from 'dockerode';
const docker = new Docker();
async function Get(req: NextApiRequest, res: NextApiResponse) {
const containers = await docker.listContainers({ all: true });
return res.status(200).json(containers);
try {
const docker = new Docker();
const containers = await docker.listContainers({ all: true });
res.status(200).json(containers);
} catch (err) {
res.status(500).json({ err });
}
}
export default async (req: NextApiRequest, res: NextApiResponse) => {

View File

@@ -0,0 +1,10 @@
import { NextApiRequest, NextApiResponse } from 'next';
export default async (req: NextApiRequest, res: NextApiResponse) => {
const url = decodeURIComponent(req.query.url as string);
const result = await fetch(url);
const body = await result.body;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
body.pipe(res);
};

View File

@@ -1,9 +1,24 @@
import axios from 'axios';
import { getCookie } from 'cookies-next';
import { NextApiRequest, NextApiResponse } from 'next';
import { serviceItem } from '../../../tools/types';
import { getConfig } from '../../../tools/getConfig';
import { Config } from '../../../tools/types';
async function Post(req: NextApiRequest, res: NextApiResponse) {
// Parse req.body as a ServiceItem
const { id } = req.body;
const { type } = req.query;
const configName = getCookie('config-name', { req });
const { config }: { config: Config } = getConfig(configName?.toString() ?? 'default').props;
// Find service with serviceId in config
const service = config.services.find((service) => service.id === id);
if (!service) {
return res.status(500).json({
statusCode: 500,
message: 'Missing service',
});
}
const nextMonth = new Date(new Date().setMonth(new Date().getMonth() + 2)).toISOString();
const lastMonth = new Date(new Date().setMonth(new Date().getMonth() - 2)).toISOString();
const TypeToUrl: { service: string; url: string }[] = [
@@ -24,8 +39,6 @@ async function Post(req: NextApiRequest, res: NextApiResponse) {
url: '/api/v1/calendar',
},
];
const service: serviceItem = req.body;
const { type } = req.query;
if (!type) {
return res.status(400).json({
message: 'Missing required parameter in url: type',
@@ -49,10 +62,10 @@ async function Post(req: NextApiRequest, res: NextApiResponse) {
origin = origin.slice(0, -1);
}
const pined = `${origin}${url?.url}?apiKey=${service.apiKey}&end=${nextMonth}&start=${lastMonth}`;
const data = await axios.get(
`${origin}${url?.url}?apiKey=${service.apiKey}&end=${nextMonth}&start=${lastMonth}`
);
return res.status(200).json(data.data);
return axios
.get(`${origin}${url?.url}?apiKey=${service.apiKey}&end=${nextMonth}&start=${lastMonth}`)
.then((response) => res.status(200).json(response.data))
.catch((e) => res.status(500).json(e));
// // Make a request to the URL
// const response = await axios.get(url);
// // Return the response

View File

@@ -0,0 +1,29 @@
import axios from 'axios';
import { NextApiRequest, NextApiResponse } from 'next';
async function Get(req: NextApiRequest, res: NextApiResponse) {
// Extract url from req.query as string
const { url, base } = req.query;
// If no url is provided, return an error
if (!url || !base) {
return res.status(400).json({
message: 'Missing required parameter in url',
});
}
// Get the origin URL
const response = await axios.get(url as string, { baseURL: base as string });
// Return the response
return res.status(200).json(response.data);
}
export default async (req: NextApiRequest, res: NextApiResponse) => {
// Filter out if the reuqest is a POST or a GET
if (req.method === 'GET') {
return Get(req, res);
}
return res.status(405).json({
statusCode: 405,
message: 'Method not allowed',
});
};

View File

@@ -2,12 +2,15 @@ import { Deluge } from '@ctrl/deluge';
import { QBittorrent } from '@ctrl/qbittorrent';
import { NormalizedTorrent } from '@ctrl/shared-torrent';
import { Transmission } from '@ctrl/transmission';
import { getCookie } from 'cookies-next';
import { NextApiRequest, NextApiResponse } from 'next';
import { getConfig } from '../../../tools/getConfig';
import { Config } from '../../../tools/types';
async function Post(req: NextApiRequest, res: NextApiResponse) {
// Get the type of service from the request url
const { config }: { config: Config } = req.body;
const configName = getCookie('config-name', { req });
const { config }: { config: Config } = getConfig(configName?.toString() ?? 'default').props;
const qBittorrentServices = config.services.filter((service) => service.type === 'qBittorrent');
const delugeServices = config.services.filter((service) => service.type === 'Deluge');
const transmissionServices = config.services.filter((service) => service.type === 'Transmission');
@@ -20,40 +23,44 @@ async function Post(req: NextApiRequest, res: NextApiResponse) {
message: 'Missing services',
});
}
await Promise.all(
qBittorrentServices.map((service) =>
new QBittorrent({
baseUrl: service.url,
username: service.username,
password: service.password,
})
.getAllData()
.then((e) => torrents.push(...e.torrents))
)
);
await Promise.all(
delugeServices.map((service) =>
new Deluge({
baseUrl: service.url,
password: 'password' in service ? service.password : '',
})
.getAllData()
.then((e) => torrents.push(...e.torrents))
)
);
// Map transmissionServices
await Promise.all(
transmissionServices.map((service) =>
new Transmission({
baseUrl: service.url,
username: 'username' in service ? service.username : '',
password: 'password' in service ? service.password : '',
})
.getAllData()
.then((e) => torrents.push(...e.torrents))
)
);
res.status(200).json(torrents);
try {
await Promise.all(
qBittorrentServices.map((service) =>
new QBittorrent({
baseUrl: service.url,
username: service.username,
password: service.password,
})
.getAllData()
.then((e) => torrents.push(...e.torrents))
)
);
await Promise.all(
delugeServices.map((service) =>
new Deluge({
baseUrl: service.url,
password: 'password' in service ? service.password : '',
})
.getAllData()
.then((e) => torrents.push(...e.torrents))
)
);
// Map transmissionServices
await Promise.all(
transmissionServices.map((service) =>
new Transmission({
baseUrl: service.url,
username: 'username' in service ? service.username : '',
password: 'password' in service ? service.password : '',
})
.getAllData()
.then((e) => torrents.push(...e.torrents))
)
);
} catch (e: any) {
return res.status(401).json(e);
}
return res.status(200).json(torrents);
}
export default async (req: NextApiRequest, res: NextApiResponse) => {

View File

@@ -0,0 +1,130 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { getCookie } from 'cookies-next';
import axios from 'axios';
import Consola from 'consola';
import { getConfig } from '../../../../tools/getConfig';
import { Config } from '../../../../tools/types';
import { MediaType } from '../../../../modules/overseerr/SearchResult';
async function Get(req: NextApiRequest, res: NextApiResponse) {
// Get the slug of the request
const { id, type } = req.query as { id: string; type: string };
const configName = getCookie('config-name', { req });
const { config }: { config: Config } = getConfig(configName?.toString() ?? 'default').props;
const service = config.services.find(
(service) => service.type === 'Overseerr' || service.type === 'Jellyseerr'
);
if (!id) {
return res.status(400).json({ error: 'No id provided' });
}
if (!type) {
return res.status(400).json({ error: 'No type provided' });
}
if (!service?.apiKey) {
return res.status(400).json({ error: 'No service found' });
}
const serviceUrl = new URL(service.url);
switch (type) {
case 'movie':
return axios
.get(`${serviceUrl.origin}/api/v1/movie/${id}`, {
headers: {
// Set X-Api-Key to the value of the API key
'X-Api-Key': service.apiKey,
},
})
.then((axiosres) => res.status(200).json(axiosres.data))
.catch((err) => {
Consola.error(err);
return res.status(500).json({
message: 'Something went wrong',
});
});
case 'tv':
// Make request to the tv api
return axios
.get(`${serviceUrl.origin}/api/v1/tv/${id}`, {
headers: {
// Set X-Api-Key to the value of the API key
'X-Api-Key': service.apiKey,
},
})
.then((axiosres) => res.status(200).json(axiosres.data))
.catch((err) => {
Consola.error(err);
return res.status(500).json({
message: 'Something went wrong',
});
});
default:
return res.status(400).json({
message: 'Wrong request, type should be movie or tv',
});
}
}
async function Post(req: NextApiRequest, res: NextApiResponse) {
// Get the slug of the request
const { id } = req.query as { id: string };
const { seasons, type } = req.body as { seasons?: number[]; type: MediaType };
const configName = getCookie('config-name', { req });
const { config }: { config: Config } = getConfig(configName?.toString() ?? 'default').props;
const service = config.services.find(
(service) => service.type === 'Overseerr' || service.type === 'Jellyseerr'
);
if (!id) {
return res.status(400).json({ error: 'No id provided' });
}
if (!type) {
return res.status(400).json({ error: 'No type provided' });
}
if (!service?.apiKey) {
return res.status(400).json({ error: 'No service found' });
}
if (type === 'movie' && !seasons) {
return res.status(400).json({ error: 'No seasons provided' });
}
const serviceUrl = new URL(service.url);
Consola.info('Got an Overseerr request with these arguments', {
mediaType: type,
mediaId: id,
seasons,
});
return axios
.post(
`${serviceUrl.origin}/api/v1/request`,
{
mediaType: type,
mediaId: Number(id),
seasons,
},
{
headers: {
// Set X-Api-Key to the value of the API key
'X-Api-Key': service.apiKey,
},
}
)
.then((axiosres) => res.status(200).json(axiosres.data))
.catch((err) =>
res.status(500).json({
message: err.message,
})
);
}
export default async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'POST') {
return Post(req, res);
}
if (req.method === 'GET') {
return Get(req, res);
}
return res.status(405).json({
statusCode: 405,
message: 'Method not allowed',
});
};

Some files were not shown because too many files have changed in this diff Show More