feat: add overseerr version and update availability status to sidebar

sort of experimental so may be kinda broken. :)
pull/1418/head
sct 4 years ago
parent a035d60c19
commit ecf13123d2
No known key found for this signature in database
GPG Key ID: 77D146606D30DCCD

@ -0,0 +1,133 @@
import cacheManager from '../lib/cache';
import logger from '../logger';
import ExternalAPI from './externalapi';
interface GitHubRelease {
url: string;
assets_url: string;
upload_url: string;
html_url: string;
id: number;
node_id: string;
tag_name: string;
target_commitish: string;
name: string;
draft: boolean;
prerelease: boolean;
created_at: string;
published_at: string;
tarball_url: string;
zipball_url: string;
body: string;
}
interface GithubCommit {
sha: string;
node_id: string;
commit: {
author: {
name: string;
email: string;
date: string;
};
committer: {
name: string;
email: string;
date: string;
};
message: string;
tree: {
sha: string;
url: string;
};
url: string;
comment_count: number;
verification: {
verified: boolean;
reason: string;
signature: string;
payload: string;
};
};
url: string;
html_url: string;
comments_url: string;
parents: [
{
sha: string;
url: string;
html_url: string;
}
];
}
class GithubAPI extends ExternalAPI {
constructor() {
super(
'https://api.github.com',
{},
{
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
nodeCache: cacheManager.getCache('github').data,
}
);
}
public async getOverseerrReleases({
take = 20,
}: {
take?: number;
} = {}): Promise<GitHubRelease[]> {
try {
const data = await this.get<GitHubRelease[]>(
'/repos/sct/overseerr/releases',
{
params: {
per_page: take,
},
}
);
return data;
} catch (e) {
logger.warn(
"Failed to retrieve GitHub releases. This may be an issue on GitHub's end. Overseerr can't check if it's on the latest version.",
{ label: 'GitHub API', errorMessage: e.message }
);
return [];
}
}
public async getOverseerrCommits({
take = 20,
branch = 'develop',
}: {
take?: number;
branch?: string;
} = {}): Promise<GithubCommit[]> {
try {
const data = await this.get<GithubCommit[]>(
'/repos/sct/overseerr/commits',
{
params: {
per_page: take,
branch,
},
}
);
return data;
} catch (e) {
logger.warn(
"Failed to retrieve GitHub commits. This may be an issue on GitHub's end. Overseerr can't check if it's on the latest version.",
{ label: 'GitHub API', errorMessage: e.message }
);
return [];
}
}
}
export default GithubAPI;

@ -43,3 +43,10 @@ export interface CacheItem {
vsize: number; vsize: number;
}; };
} }
export interface StatusResponse {
version: string;
commitTag: string;
updateAvailable: boolean;
commitsBehind: number;
}

@ -1,6 +1,6 @@
import NodeCache from 'node-cache'; import NodeCache from 'node-cache';
export type AvailableCacheIds = 'tmdb' | 'radarr' | 'sonarr' | 'rt'; export type AvailableCacheIds = 'tmdb' | 'radarr' | 'sonarr' | 'rt' | 'github';
const DEFAULT_TTL = 300; const DEFAULT_TTL = 300;
const DEFAULT_CHECK_PERIOD = 120; const DEFAULT_CHECK_PERIOD = 120;
@ -44,6 +44,10 @@ class CacheManager {
stdTtl: 43200, stdTtl: 43200,
checkPeriod: 60 * 30, checkPeriod: 60 * 30,
}), }),
github: new Cache('github', 'GitHub API', {
stdTtl: 21600,
checkPeriod: 60 * 30,
}),
}; };
public getCache(id: AvailableCacheIds): Cache { public getCache(id: AvailableCacheIds): Cache {

@ -1,33 +1,75 @@
import { Router } from 'express'; import { Router } from 'express';
import user from './user'; import GithubAPI from '../api/github';
import authRoutes from './auth'; import TheMovieDb from '../api/themoviedb';
import { checkUser, isAuthenticated } from '../middleware/auth'; import { StatusResponse } from '../interfaces/api/settingsInterfaces';
import settingsRoutes from './settings';
import { Permission } from '../lib/permissions'; import { Permission } from '../lib/permissions';
import { getSettings } from '../lib/settings'; import { getSettings } from '../lib/settings';
import searchRoutes from './search'; import { checkUser, isAuthenticated } from '../middleware/auth';
import { mapProductionCompany } from '../models/Movie';
import { mapNetwork } from '../models/Tv';
import { appDataPath, appDataStatus } from '../utils/appDataVolume';
import { getAppVersion, getCommitTag } from '../utils/appVersion';
import authRoutes from './auth';
import collectionRoutes from './collection';
import discoverRoutes from './discover'; import discoverRoutes from './discover';
import requestRoutes from './request';
import movieRoutes from './movie';
import tvRoutes from './tv';
import mediaRoutes from './media'; import mediaRoutes from './media';
import movieRoutes from './movie';
import personRoutes from './person'; import personRoutes from './person';
import collectionRoutes from './collection'; import requestRoutes from './request';
import { getAppVersion, getCommitTag } from '../utils/appVersion'; import searchRoutes from './search';
import serviceRoutes from './service'; import serviceRoutes from './service';
import { appDataStatus, appDataPath } from '../utils/appDataVolume'; import settingsRoutes from './settings';
import TheMovieDb from '../api/themoviedb'; import tvRoutes from './tv';
import { mapProductionCompany } from '../models/Movie'; import user from './user';
import { mapNetwork } from '../models/Tv';
const router = Router(); const router = Router();
router.use(checkUser); router.use(checkUser);
router.get('/status', (req, res) => { router.get<unknown, StatusResponse>('/status', async (req, res) => {
const githubApi = new GithubAPI();
const currentVersion = getAppVersion();
const commitTag = getCommitTag();
let updateAvailable = false;
let commitsBehind = 0;
if (currentVersion.startsWith('develop-') && commitTag !== 'local') {
const commits = await githubApi.getOverseerrCommits();
if (commits.length) {
const filteredCommits = commits.filter(
(commit) => !commit.commit.message.includes('[skip ci]')
);
if (filteredCommits[0].sha !== commitTag) {
updateAvailable = true;
}
const commitIndex = filteredCommits.findIndex(
(commit) => commit.sha === commitTag
);
if (updateAvailable) {
commitsBehind = commitIndex;
}
}
} else if (commitTag !== 'local') {
const releases = await githubApi.getOverseerrReleases();
if (releases.length) {
const latestVersion = releases[0];
if (latestVersion.name !== currentVersion) {
updateAvailable = true;
}
}
}
return res.status(200).json({ return res.status(200).json({
version: getAppVersion(), version: getAppVersion(),
commitTag: getCommitTag(), commitTag: getCommitTag(),
updateAvailable,
commitsBehind,
}); });
}); });
@ -39,7 +81,7 @@ router.get('/status/appdata', (_req, res) => {
}); });
router.use('/user', isAuthenticated(), user); router.use('/user', isAuthenticated(), user);
router.get('/settings/public', (_req, res) => { router.get('/settings/public', async (_req, res) => {
const settings = getSettings(); const settings = getSettings();
return res.status(200).json(settings.fullPublicSettings); return res.status(200).json(settings.fullPublicSettings);

@ -5,6 +5,7 @@ import { defineMessages, useIntl } from 'react-intl';
import useClickOutside from '../../../hooks/useClickOutside'; import useClickOutside from '../../../hooks/useClickOutside';
import { Permission, useUser } from '../../../hooks/useUser'; import { Permission, useUser } from '../../../hooks/useUser';
import Transition from '../../Transition'; import Transition from '../../Transition';
import VersionStatus from '../VersionStatus';
const messages = defineMessages({ const messages = defineMessages({
dashboard: 'Discover', dashboard: 'Discover',
@ -122,6 +123,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
const intl = useIntl(); const intl = useIntl();
const { hasPermission } = useUser(); const { hasPermission } = useUser();
useClickOutside(navRef, () => setClosed()); useClickOutside(navRef, () => setClosed());
return ( return (
<> <>
<div className="md:hidden"> <div className="md:hidden">
@ -172,7 +174,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
</div> </div>
<div <div
ref={navRef} ref={navRef}
className="flex-1 h-0 pt-5 pb-4 overflow-y-auto" className="flex flex-col flex-1 h-0 pt-5 pb-4 overflow-y-auto"
> >
<div className="flex items-center flex-shrink-0 px-4"> <div className="flex items-center flex-shrink-0 px-4">
<span className="text-xl text-gray-50"> <span className="text-xl text-gray-50">
@ -181,7 +183,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
</a> </a>
</span> </span>
</div> </div>
<nav className="px-2 mt-5 space-y-1"> <nav className="flex-1 px-2 mt-5 space-y-1">
{SidebarLinks.filter((link) => {SidebarLinks.filter((link) =>
link.requiredPermission link.requiredPermission
? hasPermission(link.requiredPermission) ? hasPermission(link.requiredPermission)
@ -221,6 +223,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
); );
})} })}
</nav> </nav>
{hasPermission(Permission.ADMIN) && <VersionStatus />}
</div> </div>
</div> </div>
<div className="flex-shrink-0 w-14"> <div className="flex-shrink-0 w-14">
@ -273,6 +276,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
); );
})} })}
</nav> </nav>
{hasPermission(Permission.ADMIN) && <VersionStatus />}
</div> </div>
</div> </div>
</div> </div>

@ -0,0 +1,122 @@
import Link from 'next/link';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import { StatusResponse } from '../../../../server/interfaces/api/settingsInterfaces';
const messages = defineMessages({
streamdevelop: 'Overseerr Develop',
streamstable: 'Overseerr Stable',
outofdate: 'Out of date',
commitsbehind:
'{commitsBehind} {commitsBehind, plural, one {commit} other {commits}} behind',
});
const VersionStatus: React.FC = () => {
const intl = useIntl();
const { data } = useSWR<StatusResponse>('/api/v1/status', {
refreshInterval: 60 * 1000,
});
if (!data) {
return null;
}
const versionStream =
data.commitTag === 'local'
? 'Keep it up!'
: data.version.startsWith('develop-')
? intl.formatMessage(messages.streamdevelop)
: intl.formatMessage(messages.streamstable);
return (
<Link href="/settings/about">
<a
className={`flex items-center p-2 mx-2 text-xs transition duration-300 rounded-lg ring-1 ring-gray-700 ${
data.updateAvailable
? 'bg-green-500 text-white hover:bg-green-400'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
}`}
>
{data.commitTag === 'local' ? (
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
/>
</svg>
) : data.version.startsWith('develop-') ? (
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"
/>
</svg>
) : (
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
/>
</svg>
)}
<div className="flex flex-col flex-1 min-w-0 px-2 truncate last:pr-0">
<span className="font-bold">{versionStream}</span>
<span className="truncate">
{data.commitTag === 'local'
? '(⌐■_■)'
: data.commitsBehind > 0
? intl.formatMessage(messages.commitsbehind, {
commitsBehind: data.commitsBehind,
})
: data.commitsBehind === -1
? intl.formatMessage(messages.outofdate)
: data.version.replace('develop-', '')}
</span>
</div>
{data.updateAvailable && (
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 11l3-3m0 0l3 3m-3-3v8m0-13a9 9 0 110 18 9 9 0 010-18z"
/>
</svg>
)}
</a>
</Link>
);
};
export default VersionStatus;

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
import { StatusResponse } from '../../../server/interfaces/api/settingsInterfaces';
import Modal from '../Common/Modal'; import Modal from '../Common/Modal';
import Transition from '../Transition'; import Transition from '../Transition';
@ -13,12 +14,9 @@ const messages = defineMessages({
const StatusChecker: React.FC = () => { const StatusChecker: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const { data, error } = useSWR<{ version: string; commitTag: string }>( const { data, error } = useSWR<StatusResponse>('/api/v1/status', {
'/api/v1/status',
{
refreshInterval: 60 * 1000, refreshInterval: 60 * 1000,
} });
);
if (!data && !error) { if (!data && !error) {
return null; return null;

@ -43,6 +43,10 @@
"components.Layout.UserDropdown.myprofile": "Profile", "components.Layout.UserDropdown.myprofile": "Profile",
"components.Layout.UserDropdown.settings": "Settings", "components.Layout.UserDropdown.settings": "Settings",
"components.Layout.UserDropdown.signout": "Sign Out", "components.Layout.UserDropdown.signout": "Sign Out",
"components.Layout.VersionStatus.commitsbehind": "{commitsBehind} {commitsBehind, plural, one {commit} other {commits}} behind",
"components.Layout.VersionStatus.outofdate": "Out of date",
"components.Layout.VersionStatus.streamdevelop": "Overseerr Develop",
"components.Layout.VersionStatus.streamstable": "Overseerr Stable",
"components.Layout.alphawarning": "This is ALPHA software. Features may be broken and/or unstable. Please report any issues on GitHub!", "components.Layout.alphawarning": "This is ALPHA software. Features may be broken and/or unstable. Please report any issues on GitHub!",
"components.Login.email": "Email Address", "components.Login.email": "Email Address",
"components.Login.forgotpassword": "Forgot Password?", "components.Login.forgotpassword": "Forgot Password?",

Loading…
Cancel
Save