diff --git a/server/api/github.ts b/server/api/github.ts new file mode 100644 index 00000000..48b8854b --- /dev/null +++ b/server/api/github.ts @@ -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 { + try { + const data = await this.get( + '/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 { + try { + const data = await this.get( + '/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; diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 72ac9b8a..7c40c6db 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -43,3 +43,10 @@ export interface CacheItem { vsize: number; }; } + +export interface StatusResponse { + version: string; + commitTag: string; + updateAvailable: boolean; + commitsBehind: number; +} diff --git a/server/lib/cache.ts b/server/lib/cache.ts index aaf3bd44..3aa18244 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -1,6 +1,6 @@ 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_CHECK_PERIOD = 120; @@ -44,6 +44,10 @@ class CacheManager { stdTtl: 43200, checkPeriod: 60 * 30, }), + github: new Cache('github', 'GitHub API', { + stdTtl: 21600, + checkPeriod: 60 * 30, + }), }; public getCache(id: AvailableCacheIds): Cache { diff --git a/server/routes/index.ts b/server/routes/index.ts index af9537db..d9e2342b 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -1,33 +1,75 @@ import { Router } from 'express'; -import user from './user'; -import authRoutes from './auth'; -import { checkUser, isAuthenticated } from '../middleware/auth'; -import settingsRoutes from './settings'; +import GithubAPI from '../api/github'; +import TheMovieDb from '../api/themoviedb'; +import { StatusResponse } from '../interfaces/api/settingsInterfaces'; import { Permission } from '../lib/permissions'; 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 requestRoutes from './request'; -import movieRoutes from './movie'; -import tvRoutes from './tv'; import mediaRoutes from './media'; +import movieRoutes from './movie'; import personRoutes from './person'; -import collectionRoutes from './collection'; -import { getAppVersion, getCommitTag } from '../utils/appVersion'; +import requestRoutes from './request'; +import searchRoutes from './search'; import serviceRoutes from './service'; -import { appDataStatus, appDataPath } from '../utils/appDataVolume'; -import TheMovieDb from '../api/themoviedb'; -import { mapProductionCompany } from '../models/Movie'; -import { mapNetwork } from '../models/Tv'; +import settingsRoutes from './settings'; +import tvRoutes from './tv'; +import user from './user'; const router = Router(); router.use(checkUser); -router.get('/status', (req, res) => { +router.get('/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({ version: getAppVersion(), commitTag: getCommitTag(), + updateAvailable, + commitsBehind, }); }); @@ -39,7 +81,7 @@ router.get('/status/appdata', (_req, res) => { }); router.use('/user', isAuthenticated(), user); -router.get('/settings/public', (_req, res) => { +router.get('/settings/public', async (_req, res) => { const settings = getSettings(); return res.status(200).json(settings.fullPublicSettings); diff --git a/src/components/Layout/Sidebar/index.tsx b/src/components/Layout/Sidebar/index.tsx index de57d9d6..d33f66cb 100644 --- a/src/components/Layout/Sidebar/index.tsx +++ b/src/components/Layout/Sidebar/index.tsx @@ -5,6 +5,7 @@ import { defineMessages, useIntl } from 'react-intl'; import useClickOutside from '../../../hooks/useClickOutside'; import { Permission, useUser } from '../../../hooks/useUser'; import Transition from '../../Transition'; +import VersionStatus from '../VersionStatus'; const messages = defineMessages({ dashboard: 'Discover', @@ -122,6 +123,7 @@ const Sidebar: React.FC = ({ open, setClosed }) => { const intl = useIntl(); const { hasPermission } = useUser(); useClickOutside(navRef, () => setClosed()); + return ( <>
@@ -172,7 +174,7 @@ const Sidebar: React.FC = ({ open, setClosed }) => {
@@ -181,7 +183,7 @@ const Sidebar: React.FC = ({ open, setClosed }) => {
-
@@ -273,6 +276,7 @@ const Sidebar: React.FC = ({ open, setClosed }) => { ); })} + {hasPermission(Permission.ADMIN) && }
diff --git a/src/components/Layout/VersionStatus/index.tsx b/src/components/Layout/VersionStatus/index.tsx new file mode 100644 index 00000000..1677e682 --- /dev/null +++ b/src/components/Layout/VersionStatus/index.tsx @@ -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('/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 ( + + + {data.commitTag === 'local' ? ( + + + + ) : data.version.startsWith('develop-') ? ( + + + + ) : ( + + + + )} +
+ {versionStream} + + {data.commitTag === 'local' + ? '(⌐■_■)' + : data.commitsBehind > 0 + ? intl.formatMessage(messages.commitsbehind, { + commitsBehind: data.commitsBehind, + }) + : data.commitsBehind === -1 + ? intl.formatMessage(messages.outofdate) + : data.version.replace('develop-', '')} + +
+ {data.updateAvailable && ( + + + + )} +
+ + ); +}; + +export default VersionStatus; diff --git a/src/components/StatusChacker/index.tsx b/src/components/StatusChacker/index.tsx index e63fc98a..af26a24e 100644 --- a/src/components/StatusChacker/index.tsx +++ b/src/components/StatusChacker/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; +import { StatusResponse } from '../../../server/interfaces/api/settingsInterfaces'; import Modal from '../Common/Modal'; import Transition from '../Transition'; @@ -13,12 +14,9 @@ const messages = defineMessages({ const StatusChecker: React.FC = () => { const intl = useIntl(); - const { data, error } = useSWR<{ version: string; commitTag: string }>( - '/api/v1/status', - { - refreshInterval: 60 * 1000, - } - ); + const { data, error } = useSWR('/api/v1/status', { + refreshInterval: 60 * 1000, + }); if (!data && !error) { return null; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 537af008..e7cf28a5 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -43,6 +43,10 @@ "components.Layout.UserDropdown.myprofile": "Profile", "components.Layout.UserDropdown.settings": "Settings", "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.Login.email": "Email Address", "components.Login.forgotpassword": "Forgot Password?",