v0.5.1 : Readarr and Lidarr integrations !

### New Features
-  Lidarr and Readarr integrations
-  Add a way to delete a config via the API
-  Add a way to save a config and delete it
-  Add a key bind to open settings (CTRL + L)

### Bug Fixes
- 🐛 Fixing date issues with weather module
- 🐛 Fix Readarr date match

### UI Changes
- 💄 Totally rework how the media previews work!
- 💄 Make the settings menu a drawer instead
- 💄 Change the way the footer is displayed

### GitHub Changes
- 📝 (README): Updates documentation & Move to Wiki

### Other Changes
- 🧑‍💻 Added strings as an option type for modules
- 🏗️ Make the max notifications to 4
This commit is contained in:
Thomas Camlong
2022-05-25 13:17:54 +02:00
committed by GitHub
18 changed files with 576 additions and 235 deletions

283
README.md
View File

@@ -1,69 +1,97 @@
<h3 align="center">Homarr</h3>
<br>
<p align="center">
<i>Don't forget to star the repo if you enjoy the Homarr project!</i>
<br>
<img src="https://img.shields.io/github/stars/ajnart/homarr?label=%E2%AD%90%20Stars&style=flat-square?branch=master&kill_cache=1%22">
<a href="https://github.com/ajnart/homarr/actions/workflows/docker.yml">
<img title="Docker CI Status" src="https://github.com/ajnart/homarr/actions/workflows/docker.yml/badge.svg" alt="CI Status"></a>
<a href="https://github.com/ajnart/homarr/releases/latest">
<img alt="GitHub release (latest SemVer)" src="https://img.shields.io/github/v/release/ajnart/homarr"></a>
<a href="https://github.com/ajnart/homarr/pkgs/container/homarr">
<img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/ajnart/homarr?label=Downloads%20"></a>
</p>
<p align="center">
<img align="end" width=600 src="https://user-images.githubusercontent.com/71191962/169860380-856634fb-4f41-47cb-ba54-6a9e7b3b9c81.gif" />
</p> <!-- Project Title -->
<p align = "center"> <h1 align="center">Homarr</h1>
A homepage for <i>your</i> server.
<br/> <!-- Badges -->
<a href = "https://homarr.netlify.app/" > <strong> Demo ↗️ </strong> </a> • <a href = "#-installation" > <strong> Install ➡️ </strong> </a> <p align="center">
<br /> <img src="https://img.shields.io/github/stars/ajnart/homarr?label=%E2%AD%90%20Stars&style=flat-square?branch=master&kill_cache=1%22">
<br /> <a href="https://github.com/ajnart/homarr/releases/latest">
<i>Join the discord!</i> <img alt="Latest Release (Semver)" src="https://img.shields.io/github/v/release/ajnart/homarr?label=%F0%9F%9A%80%20Release">
<br /> </a>
<a href = "https://discord.gg/aCsmEV5RgA" > <img title="Discord" src="https://discordapp.com/api/guilds/972958686051962910/widget.png?style=shield" > </a> <a href="https://github.com/ajnart/homarr/actions/workflows/docker.yml">
<br/> <img title="Docker CI Status" src="https://github.com/ajnart/homarr/actions/workflows/docker.yml/badge.svg" alt="CI Status">
<br/> </a>
<a href="https://discord.gg/aCsmEV5RgA">
<img title="Discord" src="https://discordapp.com/api/guilds/972958686051962910/widget.png?style=shield">
</a>
</p> </p>
# 📃 Table of Contents <!-- Links -->
- [📃 Table of Contents](#-table-of-contents) <p align="center">
- [🚀 Getting Started](#-getting-started) <i>Join the discord! — Don't forget to star the repo if you are enjoying the project!</i>
- [ About](#-about) </p>
- [💥 Known Issues](#-known-issues) <p align="center">
- [⚡ Installation](#-installation) <a href="https://homarr.netlify.app/"><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>
- [🐳 Deploying from Docker Image](#-deploying-from-docker-image) </p>
- [🛠️ Building from Source](#%EF%B8%8F-building-from-source)
- [📖 Guides](#-guides)
- [🔁 Drag and Drop (Rearrange)](#-drag-and-drop-rearrange)
- [🔧 Configuration](#-configuration)
- [🧩 Integrations](#--integrations)
- [🧑‍🤝‍🧑 Multiple Configs](#-multiple-configs)
- [🐻 Icons](#-icons)
- [📊 Modules](#-modules)
- [🔍 Search Bar](#-search-bar)
- [💖 Contributing](#-contributing)
- [🍏 Request Icons](#-request-icons)
---
<!-- Getting Started --> <!-- Homarr Description -->
# 🚀 Getting Started <img align="right" width=250 src="public/imgs/logo-color.svg" />
## About
Homarr is a simple and lightweight homepage for your server, that helps you easily access all of your services in one place. Homarr is a simple and lightweight homepage for your server, that helps you easily access all of your services in one place.
**[⤴️ Back to Top](#-table-of-contents)** 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](#).
If you have any questions about Homarr or want to share information with us, please go to one of the following places:
- [Github Discussions](https://github.com/ajnart/homarr/discussions)
- [Discord Server](https://discord.gg/aCsmEV5RgA)
*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)**
<details>
<summary><b>Table of Contents</b></summary>
<p>
- [✨ Features](#-features)
- [👀 Preview](#-preview)
- [💥 Known Issues](#-known-issues)
- [🚀 Installation](#-installation)
- [🐳 Deploying from Docker Image](#-deploying-from-docker-image)
- [🛠️ Building from Source](#-building-from-source)
- [💖 Contributing](#-contributing)
- [📜 License](#-license)
</p>
</details>
---
## ✨ Features
- Integrates with services you use.
- Search the web direcetly from your homepage.
- Real-time status indicator for every service.
- Automatically finds icons while you type the name of a serivce.
- Widgets that can display all types of information.
- Easy deployment with Docker.
- Very light-weight and fast.
- Free and Open-Source.
- And more...
**[⤴️ Back to Top](#homarr)**
---
## 👀 Preview
<img alt="Homarr Preview" align="center" width="100%" src="https://user-images.githubusercontent.com/71191962/169860380-856634fb-4f41-47cb-ba54-6a9e7b3b9c81.gif" />
**[⤴️ Back to Top](#homarr)**
---
## 💥 Known Issues ## 💥 Known Issues
- Posters on the Calendar get blocked by adblockers. (IMDb posters) - Posters on the Calendar get blocked by adblockers. (IMDb posters)
**[⤴️ Back to Top](#-table-of-contents)** **[⤴️ Back to Top](#homarr)**
## ⚡ Installation ---
## 🚀 Installation
### 🐳 Deploying from Docker Image ### 🐳 Deploying from Docker Image
> Supported architectures: x86-64, ARM, ARM64 > Supported architectures: x86-64, ARM, ARM64
@@ -72,16 +100,16 @@ _Requirements_:
**Standard Docker Install** **Standard Docker Install**
```sh ```sh
docker run --name homarr -p 7575:7575 -v /data/docker/homarr:/app/data/configs -d ghcr.io/ajnart/homarr:latest docker run --name homarr --restart unless-stopped -p 7575:7575 -v /data/docker/homarr:/app/data/configs -d ghcr.io/ajnart/homarr:latest
``` ```
**Docker Compose** **Docker Compose**
```yml ```yml
--- ---
version: '3' version: '3'
#--------------------------------------------------------------------------------------------# #---------------------------------------------------------------------#
# Homarr - A homepage for your server. # # Homarr - A homepage for your server. #
#--------------------------------------------------------------------------------------------# #---------------------------------------------------------------------#
services: services:
homarr: homarr:
container_name: homarr container_name: homarr
@@ -93,7 +121,13 @@ services:
- '7575:7575' - '7575:7575'
``` ```
***Getting EACCESS errors in the logs? Try running `sudo chmod 775 /directory-you-mounted-to`!*** ```sh
docker compose up -d
```
*Getting EACCESS errors in the logs? Try running `sudo chmod 777 /directory-you-mounted-to`!*
**[⤴️ Back to Top](#homarr)**
### 🛠️ Building from Source ### 🛠️ Building from Source
@@ -110,101 +144,54 @@ _Requirements_:
- Start the NextJS web server: ``yarn start`` - Start the NextJS web server: ``yarn start``
- *Note: If you want to update the code in real time, launch with ``yarn dev``* - *Note: If you want to update the code in real time, launch with ``yarn dev``*
## 📖 Guides **[⤴️ Back to Top](#homarr)**
### 🔁 Drag and Drop (Rearrange) ---
You can rearrange items by Drag and Dropping them to any position. To Drag an Drop, click and hold an icon for 250ms and then drag it to the desired position.
## 🔧 Configuration ## 💖 Contributing
### 🧩 Integrations
Homarr natively integrates with your services. Here is a list of all supported services.
**Emby**
*The Emby integration is still in development.*
**Lidarr**
*The Lidarr integration is still in development.*
**Sonarr**
*Sonarr needs an API key.*<br>
Make a new API key in `Advanced > Security > Create new API key`<br>
**Current integration:** Upcoming media is displayed in the **Calendar** module.
**Plex**
*The Plex integration is still in development.*
**Radarr**
*Radarr needs an API key.*<br>
Make a new API key in `Advanced > Security > Create new API key`<br>
**Current integration:** Upcoming media is displayed in the **Calendar** module.
**qBittorent**
*The qBittorent integration is still in development.*
**[⤴️ Back to Top](#-table-of-contents)**
### 🧑‍🤝‍🧑 Multiple Configs
Homarr allows the usage of multiple configs. You can add a new config in two ways.
**Drag-and-Drop**
1. Download your config from the Homarr settings.
2. Change the name of the `.json` file and the name in the `.json` file to any name you want *(just make sure it's different)*.
3. Drag-and-Drop the file into the Homarr tab in your browser.
4. Change the config in settings.
**Using a filebrowser**
1. Locate your mounted `default.json` file.
2. Duplicate your `default.json` file.
3. Change the name of the `.json` file and the name in the `.json` file to any name you want *(just make sure it's different)*.
4. Refresh the Homarr tab in your browser.
5. Change the config in settings.
**[⤴️ Back to Top](#-table-of-contents)**
### 🐻 Icons
The icons used in Homarr are automatically requested from the [dashboard-icons](https://github.com/walkxhub/dashboard-icons) repo.
Icons are requested in the following way: <br>
`Grab name > Replace ' ' with '-' > .toLower() > https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/{name}.png`
**[⤴️ Back to Top](#-table-of-contents)**
### 📊 Modules
Modules are blocks shown on the sides of the Homarr dashboard that display information. They can be enabled in settings.
**Clock Module**
The Clock Module will display your current time and date.
**Calendar Module**
The Calendar Module uses [integrations](#--integrations-1) to display new content.
**Weather Module**
The Weather Module uses your devices location to display the current, highest, and lowest temperature.
**[⤴️ Back to Top](#-table-of-contents)**
### 🔍 Search Bar
The Search Bar will open any Search Query after the Query URL you've specified in settings.
*(Eg. `https://www.google.com/search?q=*Your Query will be inserted here*`)*
**[⤴️ Back to Top](#-table-of-contents)**
# 💖 Contributing
**Please read our [Contribution Guidelines](/CONTRIBUTING.md)** **Please read our [Contribution Guidelines](/CONTRIBUTING.md)**
All contributions are highly appreciated. All contributions are highly appreciated.
**[⤴️ Back to Top](#-table-of-contents)** **[⤴️ Back to Top](#homarr)**
## 🍏 Request Icons ---
The icons used in Homarr are automatically requested from the [dashboard-icons](https://github.com/walkxhub/dashboard-icons) repo. You can make a icon request by creating an [issue](https://github.com/walkxhub/dashboard-icons/issues/new/choose).
## 📜 License
**[⤴️ Back to Top](#-table-of-contents)** Homarr is Licensed under [MIT](https://en.wikipedia.org/wiki/MIT_License)
```txt
Copyright © 2022 Thomas "ajnart" Camlong
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
**[⤴️ Back to Top](#homarr)**
---
<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>
<br/>
<br/>
<a href="https://trackgit.com">
<img src="https://us-central1-trackgit-analytics.cloudfunctions.net/token/ping/l3khzc3a3pexzw5w5whl" alt="trackgit-views" />
</a>
</p>

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "homarr", "name": "homarr",
"version": "0.5.0", "version": "0.5.1",
"private": "false", "private": "false",
"description": "Homarr - A homepage for your server.", "description": "Homarr - A homepage for your server.",
"repository": { "repository": {

0
public/icons/.gitkeep Normal file
View File

View File

@@ -173,7 +173,10 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
{...form.getInputProps('type')} {...form.getInputProps('type')}
/> />
<LoadingOverlay visible={isLoading} /> <LoadingOverlay visible={isLoading} />
{(form.values.type === 'Sonarr' || form.values.type === 'Radarr') && ( {(form.values.type === 'Sonarr' ||
form.values.type === 'Radarr' ||
form.values.type === 'Lidarr' ||
form.values.type === 'Readarr') && (
<TextInput <TextInput
required required
label="API key" label="API key"

View File

@@ -1,12 +1,4 @@
import { import { Text, Card, Anchor, AspectRatio, Image, Center, createStyles } from '@mantine/core';
Text,
Card,
Anchor,
AspectRatio,
Image,
Center,
createStyles,
} from '@mantine/core';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { useState } from 'react'; import { useState } from 'react';
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';

View File

@@ -9,7 +9,7 @@ export default function ConfigChanger() {
useEffect(() => { useEffect(() => {
getConfigs().then((configs) => setConfigList(configs)); getConfigs().then((configs) => setConfigList(configs));
// setConfig(initialConfig); // setConfig(initialConfig);
}, [config]); }, [config.name]);
// If configlist is empty, return a loading indicator // If configlist is empty, return a loading indicator
if (configList.length === 0) { if (configList.length === 0) {
return ( return (

View File

@@ -1,18 +1,96 @@
import { Button } from '@mantine/core'; import { Button, Group, Modal, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { showNotification } from '@mantine/notifications';
import axios from 'axios';
import fileDownload from 'js-file-download'; import fileDownload from 'js-file-download';
import { Download } from 'tabler-icons-react'; import { useState } from 'react';
import { Check, Download, Plus, Trash, X } from 'tabler-icons-react';
import { useConfig } from '../../tools/state'; import { useConfig } from '../../tools/state';
export default function SaveConfigComponent(props: any) { export default function SaveConfigComponent(props: any) {
const { config } = useConfig(); const [opened, setOpened] = useState(false);
const { config, setConfig } = useConfig();
const form = useForm({
initialValues: {
configName: config.name,
},
});
function onClick(e: any) { function onClick(e: any) {
if (config) { if (config) {
fileDownload(JSON.stringify(config, null, '\t'), `${config.name}.json`); fileDownload(JSON.stringify(config, null, '\t'), `${config.name}.json`);
} }
} }
return ( return (
<Button leftIcon={<Download />} variant="outline" onClick={onClick}> <Group>
Download your config <Modal
</Button> radius="md"
opened={opened}
onClose={() => setOpened(false)}
title="Choose the name of your new config"
>
<form
onSubmit={form.onSubmit((values) => {
setConfig({ ...config, name: values.configName });
setOpened(false);
showNotification({
title: 'Config saved',
icon: <Check />,
color: 'green',
autoClose: 1500,
radius: 'md',
message: `Config saved as ${values.configName}`,
});
})}
>
<TextInput
required
defaultValue={config.name}
label="Config name"
placeholder="Your new config name"
{...form.getInputProps('configName')}
/>
<Group position="right" mt="md">
<Button type="submit">Confirm</Button>
</Group>
</form>
</Modal>
<Button leftIcon={<Download />} variant="outline" onClick={onClick}>
Download your config
</Button>
<Button
leftIcon={<Trash />}
variant="outline"
onClick={() => {
axios
.delete(`/api/configs/${config.name}`)
.then(() => {
showNotification({
title: 'Config deleted',
icon: <Check />,
color: 'green',
autoClose: 1500,
radius: 'md',
message: 'Config deleted',
});
})
.catch(() => {
showNotification({
title: 'Config delete failed',
icon: <X />,
color: 'red',
autoClose: 1500,
radius: 'md',
message: 'Config delete failed',
});
});
setConfig({ ...config, name: 'default' });
}}
>
Delete current config
</Button>
<Button leftIcon={<Plus />} variant="outline" onClick={() => setOpened(true)}>
Save a copy of your config
</Button>
</Group>
); );
} }

View File

@@ -1,14 +1,14 @@
import { import {
ActionIcon, ActionIcon,
Group, Group,
Modal,
Title, Title,
Text, Text,
Tooltip, Tooltip,
SegmentedControl, SegmentedControl,
TextInput, TextInput,
Drawer,
} from '@mantine/core'; } from '@mantine/core';
import { useColorScheme } from '@mantine/hooks'; import { useColorScheme, useHotkeys } from '@mantine/hooks';
import { useState } from 'react'; import { useState } from 'react';
import { Settings as SettingsIcon } from 'tabler-icons-react'; import { Settings as SettingsIcon } from 'tabler-icons-react';
import { useConfig } from '../../tools/state'; import { useConfig } from '../../tools/state';
@@ -97,18 +97,21 @@ function SettingsMenu(props: any) {
} }
export function SettingsMenuButton(props: any) { export function SettingsMenuButton(props: any) {
useHotkeys([['ctrl+L', () => setOpened(!opened)]]);
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
return ( return (
<> <>
<Modal <Drawer
size="xl" size="auto"
radius="md" padding="xl"
position="right"
title={<Title order={3}>Settings</Title>} title={<Title order={3}>Settings</Title>}
opened={props.opened || opened} opened={props.opened || opened}
onClose={() => setOpened(false)} onClose={() => setOpened(false)}
> >
<SettingsMenu /> <SettingsMenu />
</Modal> </Drawer>
<ActionIcon <ActionIcon
variant="default" variant="default"
radius="md" radius="md"

View File

@@ -81,9 +81,6 @@ export function Footer({ links }: FooterCenteredProps) {
background: 'none', background: 'none',
border: 'none', border: 'none',
clear: 'both', clear: 'both',
position: 'fixed',
bottom: '0',
left: '0',
}} }}
> >
<Group position="apart" direction="row" style={{ alignItems: 'end' }} mr="xs" mb="xs"> <Group position="apart" direction="row" style={{ alignItems: 'end' }} mr="xs" mb="xs">

View File

@@ -1,12 +1,17 @@
/* eslint-disable react/no-children-prop */ /* eslint-disable react/no-children-prop */
import { Popover, Box, ScrollArea, Divider, Indicator, useMantineTheme } from '@mantine/core'; import { Box, Divider, Indicator, Popover, ScrollArea, useMantineTheme } from '@mantine/core';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Calendar } from '@mantine/dates'; import { Calendar } from '@mantine/dates';
import { showNotification } from '@mantine/notifications'; import { showNotification } from '@mantine/notifications';
import { Calendar as CalendarIcon, Check } from 'tabler-icons-react'; import { Calendar as CalendarIcon, Check } from 'tabler-icons-react';
import { RadarrMediaDisplay, SonarrMediaDisplay } from './MediaDisplay';
import { useConfig } from '../../../tools/state'; import { useConfig } from '../../../tools/state';
import { IModule } from '../modules'; import { IModule } from '../modules';
import {
SonarrMediaDisplay,
RadarrMediaDisplay,
LidarrMediaDisplay,
ReadarrMediaDisplay,
} from './MediaDisplay';
export const CalendarModule: IModule = { export const CalendarModule: IModule = {
title: 'Calendar', title: 'Calendar',
@@ -19,17 +24,25 @@ export const CalendarModule: IModule = {
export default function CalendarComponent(props: any) { export default function CalendarComponent(props: any) {
const { config } = useConfig(); const { config } = useConfig();
const [sonarrMedias, setSonarrMedias] = useState([] as any); const [sonarrMedias, setSonarrMedias] = useState([] as any);
const [lidarrMedias, setLidarrMedias] = useState([] as any);
const [radarrMedias, setRadarrMedias] = useState([] as any); const [radarrMedias, setRadarrMedias] = useState([] as any);
const [readarrMedias, setReadarrMedias] = useState([] as any);
useEffect(() => { useEffect(() => {
// Filter only sonarr and radarr services // Filter only sonarr and radarr services
const filtered = config.services.filter( const filtered = config.services.filter(
(service) => service.type === 'Sonarr' || service.type === 'Radarr' (service) =>
service.type === 'Sonarr' ||
service.type === 'Radarr' ||
service.type === 'Lidarr' ||
service.type === 'Readarr'
); );
// Get the url and apiKey for all Sonarr and Radarr services // Get the url and apiKey for all Sonarr and Radarr services
const sonarrService = filtered.filter((service) => service.type === 'Sonarr').at(0); const sonarrService = filtered.filter((service) => service.type === 'Sonarr').at(0);
const radarrService = filtered.filter((service) => service.type === 'Radarr').at(0); const radarrService = filtered.filter((service) => service.type === 'Radarr').at(0);
const lidarrService = filtered.filter((service) => service.type === 'Lidarr').at(0);
const readarrService = filtered.filter((service) => service.type === 'Readarr').at(0);
const nextMonth = new Date(new Date().setMonth(new Date().getMonth() + 2)).toISOString(); const nextMonth = new Date(new Date().setMonth(new Date().getMonth() + 2)).toISOString();
if (sonarrService && sonarrService.apiKey) { if (sonarrService && sonarrService.apiKey) {
const baseUrl = new URL(sonarrService.url).origin; const baseUrl = new URL(sonarrService.url).origin;
@@ -69,6 +82,44 @@ export default function CalendarComponent(props: any) {
} }
); );
} }
if (lidarrService && lidarrService.apiKey) {
const baseUrl = new URL(lidarrService.url).origin;
fetch(`${baseUrl}/api/v1/calendar?apikey=${lidarrService?.apiKey}&end=${nextMonth}`).then(
(response) => {
response.ok &&
response.json().then((data) => {
setLidarrMedias(data);
showNotification({
title: 'Lidarr',
icon: <Check />,
color: 'green',
autoClose: 1500,
radius: 'md',
message: `Loaded ${data.length} releases`,
});
});
}
);
}
if (readarrService && readarrService.apiKey) {
const baseUrl = new URL(readarrService.url).origin;
fetch(`${baseUrl}/api/v1/calendar?apikey=${readarrService?.apiKey}&end=${nextMonth}`).then(
(response) => {
response.ok &&
response.json().then((data) => {
setReadarrMedias(data);
showNotification({
title: 'Readarr',
icon: <Check />,
color: 'green',
autoClose: 1500,
radius: 'md',
message: `Loaded ${data.length} releases`,
});
});
}
);
}
}, [config.services]); }, [config.services]);
if (sonarrMedias === undefined && radarrMedias === undefined) { if (sonarrMedias === undefined && radarrMedias === undefined) {
@@ -82,6 +133,8 @@ export default function CalendarComponent(props: any) {
renderdate={renderdate} renderdate={renderdate}
sonarrmedias={sonarrMedias} sonarrmedias={sonarrMedias}
radarrmedias={radarrMedias} radarrmedias={radarrMedias}
lidarrmedias={lidarrMedias}
readarrmedias={readarrMedias}
/> />
)} )}
/> />
@@ -93,12 +146,25 @@ function DayComponent(props: any) {
renderdate, renderdate,
sonarrmedias, sonarrmedias,
radarrmedias, radarrmedias,
}: { renderdate: Date; sonarrmedias: []; radarrmedias: [] } = props; lidarrmedias,
readarrmedias,
}: { renderdate: Date; sonarrmedias: []; radarrmedias: []; lidarrmedias: []; readarrmedias: [] } =
props;
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
const theme = useMantineTheme(); const theme = useMantineTheme();
const day = renderdate.getDate(); const day = renderdate.getDate();
// Itterate over the medias and filter the ones that are on the same day
const readarrFiltered = readarrmedias.filter((media: any) => {
const date = new Date(media.releaseDate);
return date.getDate() === day && date.getMonth() === renderdate.getMonth();
});
const lidarrFiltered = lidarrmedias.filter((media: any) => {
const date = new Date(media.releaseDate);
// Return true if the date is renerdate without counting hours and minutes
return date.getDate() === day && date.getMonth() === renderdate.getMonth();
});
const sonarrFiltered = sonarrmedias.filter((media: any) => { const sonarrFiltered = sonarrmedias.filter((media: any) => {
const date = new Date(media.airDate); const date = new Date(media.airDate);
// Return true if the date is renerdate without counting hours and minutes // Return true if the date is renerdate without counting hours and minutes
@@ -109,7 +175,12 @@ function DayComponent(props: any) {
// Return true if the date is renerdate without counting hours and minutes // Return true if the date is renerdate without counting hours and minutes
return date.getDate() === day && date.getMonth() === renderdate.getMonth(); return date.getDate() === day && date.getMonth() === renderdate.getMonth();
}); });
if (sonarrFiltered.length === 0 && radarrFiltered.length === 0) { if (
sonarrFiltered.length === 0 &&
radarrFiltered.length === 0 &&
lidarrFiltered.length === 0 &&
readarrFiltered.length === 0
) {
return <div>{day}</div>; return <div>{day}</div>;
} }
@@ -119,8 +190,58 @@ function DayComponent(props: any) {
setOpened(true); setOpened(true);
}} }}
> >
{radarrFiltered.length > 0 && <Indicator size={7} color="yellow" children={null} />} {readarrFiltered.length > 0 && (
{sonarrFiltered.length > 0 && <Indicator size={7} offset={8} color="blue" children={null} />} <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}
/>
)}
<Popover <Popover
position="left" position="left"
radius="lg" radius="lg"
@@ -147,6 +268,18 @@ function DayComponent(props: any) {
{index < radarrFiltered.length - 1 && <Divider variant="dashed" my="xl" />} {index < radarrFiltered.length - 1 && <Divider variant="dashed" my="xl" />}
</React.Fragment> </React.Fragment>
))} ))}
{lidarrFiltered.map((media: any, index: number) => (
<React.Fragment key={index}>
<LidarrMediaDisplay media={media} />
{index < lidarrFiltered.length - 1 && <Divider variant="dashed" my="xl" />}
</React.Fragment>
))}
{readarrFiltered.map((media: any, index: number) => (
<React.Fragment key={index}>
<ReadarrMediaDisplay media={media} />
{index < readarrFiltered.length - 1 && <Divider variant="dashed" my="xl" />}
</React.Fragment>
))}
</ScrollArea> </ScrollArea>
</Popover> </Popover>
</Box> </Box>

View File

@@ -1,11 +1,14 @@
import { Stack, Image, Group, Title, Badge, Text, ActionIcon, Anchor } from '@mantine/core'; import { Image, Group, Title, Badge, Text, ActionIcon, Anchor, ScrollArea } from '@mantine/core';
import { Link } from 'tabler-icons-react'; import { Link } from 'tabler-icons-react';
import { useConfig } from '../../../tools/state';
import { serviceItem } from '../../../tools/types';
export interface IMedia { export interface IMedia {
overview: string; overview: string;
imdbId: any; imdbId?: any;
artist?: string;
title: string; title: string;
poster: string; poster?: string;
genres: string[]; genres: string[];
seasonNumber?: number; seasonNumber?: number;
episodeNumber?: number; episodeNumber?: number;
@@ -14,30 +17,44 @@ export interface IMedia {
function MediaDisplay(props: { media: IMedia }) { function MediaDisplay(props: { media: IMedia }) {
const { media }: { media: IMedia } = props; const { media }: { media: IMedia } = props;
return ( return (
<Group noWrap align="self-start" mr={15}> <Group position="apart">
<Image <Text>
radius="md" {media.poster && (
fit="cover" <Image
src={media.poster} style={{
alt={media.title} float: 'right',
width={300} }}
height={400} radius="md"
/> fit="cover"
<Stack src={media.poster}
justify="space-between" alt={media.title}
sx={(theme) => ({ width={250}
height: 400, height={400}
})} />
> )}
<Group direction="column"> <Group direction="row">
<Group noWrap> <Title order={3}>{media.title}</Title>
<Title order={3}>{media.title}</Title> {media.imdbId && (
<Anchor href={`https://www.imdb.com/title/${media.imdbId}`} target="_blank"> <Anchor
href={`https://www.imdb.com/title/${media.imdbId}`}
target="_blank"
rel="noopener noreferrer"
>
<ActionIcon> <ActionIcon>
<Link /> <Link />
</ActionIcon> </ActionIcon>
</Anchor> </Anchor>
</Group> )}
{media.artist && (
<Text
style={{
textAlign: 'center',
color: '#a0aec0',
}}
>
New release from {media.artist}
</Text>
)}
{media.episodeNumber && media.seasonNumber && ( {media.episodeNumber && media.seasonNumber && (
<Text <Text
style={{ style={{
@@ -48,21 +65,77 @@ function MediaDisplay(props: { media: IMedia }) {
Season {media.seasonNumber} episode {media.episodeNumber} Season {media.seasonNumber} episode {media.episodeNumber}
</Text> </Text>
)} )}
<Text lineClamp={12} align="justify">
{media.overview}
</Text>
</Group> </Group>
{/*Add the genres at the bottom of the poster*/} <Group direction="column" position="apart">
<Group> <ScrollArea style={{ height: 250 }}>{media.overview}</ScrollArea>
{media.genres.map((genre: string, i: number) => ( <Group align="center" position="center" spacing="xs">
<Badge key={i}>{genre}</Badge> {media.genres.map((genre: string, i: number) => (
))} <Badge size="sm" key={i}>
{genre}
</Badge>
))}
</Group>
</Group> </Group>
</Stack> </Text>
</Group> </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
console.log(poster);
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 = `${baseUrl}${poster.url}`;
// 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) { export function RadarrMediaDisplay(props: any) {
const { media }: { media: any } = props; const { media }: { media: any } = props;
// Find a poster CoverType // Find a poster CoverType

View File

@@ -1,4 +1,4 @@
import { Card, Menu, Switch, useMantineTheme } from '@mantine/core'; import { Button, Card, Group, Menu, Switch, TextInput, useMantineTheme } from '@mantine/core';
import { useConfig } from '../../tools/state'; import { useConfig } from '../../tools/state';
import { IModule } from './modules'; import { IModule } from './modules';
@@ -19,13 +19,51 @@ export function ModuleWrapper(props: any) {
types.forEach((type, index) => { types.forEach((type, index) => {
const optionName = `${module.title}.${keys[index]}`; const optionName = `${module.title}.${keys[index]}`;
const moduleInConfig = config.modules?.[module.title]; const moduleInConfig = config.modules?.[module.title];
if (type === 'string') {
items.push(
<form
onSubmit={(e) => {
e.preventDefault();
setConfig({
...config,
modules: {
...config.modules,
[module.title]: {
...config.modules[module.title],
options: {
...config.modules[module.title].options,
[keys[index]]: {
...config.modules[module.title].options[keys[index]],
value: (e.target as any)[0].value,
},
},
},
},
});
}}
>
<Group noWrap align="end" position="center" mt={0}>
<TextInput
key={optionName}
id={optionName}
name={optionName}
label={values[index].name}
defaultValue={(moduleInConfig?.options?.[keys[index]]?.value as string) ?? ''}
onChange={(e) => {}}
/>
<Button type="submit">Save</Button>
</Group>
</form>
);
}
// TODO: Add support for other types // TODO: Add support for other types
if (type === 'boolean') { if (type === 'boolean') {
items.push( items.push(
<Switch <Switch
defaultChecked={ defaultChecked={
// Set default checked to the value of the option if it exists // Set default checked to the value of the option if it exists
moduleInConfig?.options?.[keys[index]]?.value ?? false (moduleInConfig?.options?.[keys[index]]?.value as boolean) ?? false
} }
key={keys[index]} key={keys[index]}
onClick={(e) => { onClick={(e) => {
@@ -59,7 +97,7 @@ export function ModuleWrapper(props: any) {
<Card hidden={!isShown} mx="sm" withBorder radius="lg" shadow="sm"> <Card hidden={!isShown} mx="sm" withBorder radius="lg" shadow="sm">
{module.options && ( {module.options && (
<Menu <Menu
size="md" size="lg"
shadow="xl" shadow="xl"
closeOnItemClick={false} closeOnItemClick={false}
radius="md" radius="md"

View File

@@ -16,5 +16,5 @@ interface Option {
export interface OptionValues { export interface OptionValues {
name: string; name: string;
value: boolean; value: boolean | string;
} }

View File

@@ -27,6 +27,10 @@ export const WeatherModule: IModule = {
name: 'Display in Fahrenheit', name: 'Display in Fahrenheit',
value: false, value: false,
}, },
location: {
name: 'Current location',
value: '',
},
}, },
}; };
@@ -128,27 +132,30 @@ export function WeatherIcon(props: any) {
export default function WeatherComponent(props: any) { export default function WeatherComponent(props: any) {
// Get location from browser // Get location from browser
const [location, setLocation] = useState({ lat: 0, lng: 0 });
const { config } = useConfig(); const { config } = useConfig();
const [weather, setWeather] = useState({} as WeatherResponse); const [weather, setWeather] = useState({} as WeatherResponse);
const cityInput: string =
(config?.modules?.[WeatherModule.title]?.options?.location?.value as string) ?? '';
const isFahrenheit: boolean = const isFahrenheit: boolean =
config?.modules?.[WeatherModule.title]?.options?.freedomunit?.value ?? false; (config?.modules?.[WeatherModule.title]?.options?.freedomunit?.value as boolean) ?? false;
if ('geolocation' in navigator && location.lat === 0 && location.lng === 0) {
navigator.geolocation.getCurrentPosition((position) => {
setLocation({ lat: position.coords.latitude, lng: position.coords.longitude });
});
}
useEffect(() => { useEffect(() => {
axios axios
.get( .get(`https://geocoding-api.open-meteo.com/v1/search?name=${cityInput}`)
`https://api.open-meteo.com/v1/forecast?latitude=${location.lat}&longitude=${location.lng}&daily=weathercode,temperature_2m_max,temperature_2m_min&current_weather=true&timezone=Europe%2FLondon` .then((response) => {
) // Check if results exists
.then((res) => { const { latitude, longitude } = response.data.results
setWeather(res.data); ? response.data.results[0]
: { latitude: 0, longitude: 0 };
axios
.get(
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min&current_weather=true&timezone=Europe%2FLondon`
)
.then((res) => {
setWeather(res.data);
});
}); });
}, []); }, [cityInput]);
if (!weather.current_weather) { if (!weather.current_weather) {
return null; return null;
} }

View File

@@ -38,7 +38,7 @@ export default function App(props: AppProps & { colorScheme: ColorScheme }) {
withGlobalStyles withGlobalStyles
withNormalizeCSS withNormalizeCSS
> >
<NotificationsProvider limit={2} position="top-right"> <NotificationsProvider limit={4} position="top-right">
<ConfigProvider> <ConfigProvider>
<Layout> <Layout>
<Component {...pageProps} /> <Component {...pageProps} />

View File

@@ -51,6 +51,9 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'PUT') { if (req.method === 'PUT') {
return Put(req, res); return Put(req, res);
} }
if (req.method === 'DELETE') {
return Delete(req, res);
}
if (req.method === 'GET') { if (req.method === 'GET') {
return Get(req, res); return Get(req, res);
} }
@@ -59,3 +62,28 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
message: 'Method not allowed', message: 'Method not allowed',
}); });
}; };
function Delete(req: NextApiRequest, res: NextApiResponse<any>) {
// Get the slug of the request
const { slug } = req.query as { slug: string };
if (!slug) {
return res.status(400).json({
message: 'Wrong request',
});
}
// Loop over all the files in the /data/configs directory
const files = fs.readdirSync('data/configs');
// Strip the .json extension from the file name
const configs = files.map((file) => file.replace('.json', ''));
// If the target is not in the list of files, return an error
if (!configs.includes(slug)) {
return res.status(404).json({
message: 'Target not found',
});
}
// Delete the file
fs.unlinkSync(path.join('data/configs', `${slug}.json`));
return res.status(200).json({
message: 'Configuration deleted with success',
});
}

View File

@@ -27,6 +27,7 @@ export const ServiceTypeList = [
'Lidarr', 'Lidarr',
'Plex', 'Plex',
'Radarr', 'Radarr',
'Readarr',
'Sonarr', 'Sonarr',
'qBittorrent', 'qBittorrent',
]; ];
@@ -36,6 +37,7 @@ export type ServiceType =
| 'Lidarr' | 'Lidarr'
| 'Plex' | 'Plex'
| 'Radarr' | 'Radarr'
| 'Readarr'
| 'Sonarr' | 'Sonarr'
| 'qBittorrent'; | 'qBittorrent';