diff options
-rw-r--r-- | bazarr/api/badges/badges.py | 5 | ||||
-rw-r--r-- | bazarr/api/system/__init__.py | 2 | ||||
-rw-r--r-- | bazarr/api/system/announcements.py | 35 | ||||
-rw-r--r-- | bazarr/app/announcements.py | 113 | ||||
-rw-r--r-- | bazarr/app/database.py | 12 | ||||
-rw-r--r-- | bazarr/app/scheduler.py | 5 | ||||
-rw-r--r-- | bazarr/init.py | 5 | ||||
-rw-r--r-- | bazarr/main.py | 3 | ||||
-rw-r--r-- | frontend/src/Router/index.tsx | 8 | ||||
-rw-r--r-- | frontend/src/apis/hooks/system.ts | 41 | ||||
-rw-r--r-- | frontend/src/apis/queries/keys.ts | 1 | ||||
-rw-r--r-- | frontend/src/apis/raw/system.ts | 13 | ||||
-rw-r--r-- | frontend/src/pages/System/Announcements/index.tsx | 24 | ||||
-rw-r--r-- | frontend/src/pages/System/Announcements/table.tsx | 91 | ||||
-rw-r--r-- | frontend/src/pages/System/system.test.tsx | 5 | ||||
-rw-r--r-- | frontend/src/types/api.d.ts | 1 | ||||
-rw-r--r-- | frontend/src/types/form.d.ts | 4 | ||||
-rw-r--r-- | frontend/src/types/system.d.ts | 8 |
18 files changed, 372 insertions, 4 deletions
diff --git a/bazarr/api/badges/badges.py b/bazarr/api/badges/badges.py index 7d65f586f..834460deb 100644 --- a/bazarr/api/badges/badges.py +++ b/bazarr/api/badges/badges.py @@ -8,12 +8,13 @@ from flask_restx import Resource, Namespace, fields from app.database import get_exclusion_clause, TableEpisodes, TableShows, TableMovies from app.get_providers import get_throttled_providers from app.signalr_client import sonarr_signalr_client, radarr_signalr_client +from app.announcements import get_all_announcements from utilities.health import get_health_issues from ..utils import authenticate api_ns_badges = Namespace('Badges', description='Get badges count to update the UI (episodes and movies wanted ' - 'subtitles, providers with issues and health issues.') + 'subtitles, providers with issues, health issues and announcements.') @api_ns_badges.route('badges') @@ -25,6 +26,7 @@ class Badges(Resource): 'status': fields.Integer(), 'sonarr_signalr': fields.String(), 'radarr_signalr': fields.String(), + 'announcements': fields.Integer(), }) @authenticate @@ -62,5 +64,6 @@ class Badges(Resource): "status": health_issues, 'sonarr_signalr': "LIVE" if sonarr_signalr_client.connected else "", 'radarr_signalr': "LIVE" if radarr_signalr_client.connected else "", + 'announcements': len(get_all_announcements()), } return result diff --git a/bazarr/api/system/__init__.py b/bazarr/api/system/__init__.py index 4da8c633d..a491135a8 100644 --- a/bazarr/api/system/__init__.py +++ b/bazarr/api/system/__init__.py @@ -3,6 +3,7 @@ from .system import api_ns_system from .searches import api_ns_system_searches from .account import api_ns_system_account +from .announcements import api_ns_system_announcements from .backups import api_ns_system_backups from .tasks import api_ns_system_tasks from .logs import api_ns_system_logs @@ -17,6 +18,7 @@ from .notifications import api_ns_system_notifications api_ns_list_system = [ api_ns_system, api_ns_system_account, + api_ns_system_announcements, api_ns_system_backups, api_ns_system_health, api_ns_system_languages, diff --git a/bazarr/api/system/announcements.py b/bazarr/api/system/announcements.py new file mode 100644 index 000000000..27efcb815 --- /dev/null +++ b/bazarr/api/system/announcements.py @@ -0,0 +1,35 @@ +# coding=utf-8 + +from flask_restx import Resource, Namespace, reqparse + +from app.announcements import get_all_announcements, mark_announcement_as_dismissed + +from ..utils import authenticate + +api_ns_system_announcements = Namespace('System Announcements', description='List announcements relative to Bazarr') + + +@api_ns_system_announcements.route('system/announcements') +class SystemAnnouncements(Resource): + @authenticate + @api_ns_system_announcements.doc(parser=None) + @api_ns_system_announcements.response(200, 'Success') + @api_ns_system_announcements.response(401, 'Not Authenticated') + def get(self): + """List announcements relative to Bazarr""" + return {'data': get_all_announcements()} + + post_request_parser = reqparse.RequestParser() + post_request_parser.add_argument('hash', type=str, required=True, help='hash of the announcement to dismiss') + + @authenticate + @api_ns_system_announcements.doc(parser=post_request_parser) + @api_ns_system_announcements.response(204, 'Success') + @api_ns_system_announcements.response(401, 'Not Authenticated') + def post(self): + """Mark announcement as dismissed""" + args = self.post_request_parser.parse_args() + hashed_announcement = args.get('hash') + + mark_announcement_as_dismissed(hashed_announcement=hashed_announcement) + return '', 204 diff --git a/bazarr/app/announcements.py b/bazarr/app/announcements.py new file mode 100644 index 000000000..4c49dce24 --- /dev/null +++ b/bazarr/app/announcements.py @@ -0,0 +1,113 @@ +# coding=utf-8 + +import os +import hashlib +import requests +import logging +import json +import pretty + +from datetime import datetime +from operator import itemgetter + +from app.get_providers import get_providers +from app.database import TableAnnouncements +from .get_args import args + + +# Announcements as receive by browser must be in the form of a list of dicts converted to JSON +# [ +# { +# 'text': 'some text', +# 'link': 'http://to.somewhere.net', +# 'hash': '', +# 'dismissible': True, +# 'timestamp': 1676236978, +# 'enabled': True, +# }, +# ] + + +def parse_announcement_dict(announcement_dict): + announcement_dict['timestamp'] = pretty.date(announcement_dict['timestamp']) + announcement_dict['link'] = announcement_dict.get('link', '') + announcement_dict['dismissible'] = announcement_dict.get('dismissible', True) + announcement_dict['enabled'] = announcement_dict.get('enabled', True) + announcement_dict['hash'] = hashlib.sha256(announcement_dict['text'].encode('UTF8')).hexdigest() + + return announcement_dict + + +def get_announcements_to_file(): + try: + r = requests.get("https://raw.githubusercontent.com/morpheus65535/bazarr-binaries/master/announcements.json") + except requests.exceptions.HTTPError: + logging.exception("Error trying to get announcements from Github. Http error.") + except requests.exceptions.ConnectionError: + logging.exception("Error trying to get announcements from Github. Connection Error.") + except requests.exceptions.Timeout: + logging.exception("Error trying to get announcements from Github. Timeout Error.") + except requests.exceptions.RequestException: + logging.exception("Error trying to get announcements from Github.") + else: + with open(os.path.join(args.config_dir, 'config', 'announcements.json'), 'wb') as f: + f.write(r.content) + + +def get_online_announcements(): + try: + with open(os.path.join(args.config_dir, 'config', 'announcements.json'), 'r') as f: + data = json.load(f) + except (OSError, json.JSONDecodeError): + return [] + else: + for announcement in data['data']: + if 'enabled' not in announcement: + data['data'][announcement]['enabled'] = True + if 'dismissible' not in announcement: + data['data'][announcement]['dismissible'] = True + + return data['data'] + + +def get_local_announcements(): + announcements = [] + + # opensubtitles.org end-of-life + enabled_providers = get_providers() + if enabled_providers and 'opensubtitles' in enabled_providers: + announcements.append({ + 'text': 'Opensubtitles.org will be deprecated soon, migrate to Opensubtitles.com ASAP and disable this ' + 'provider to remove this announcement.', + 'link': 'https://wiki.bazarr.media/Troubleshooting/OpenSubtitles-migration/', + 'dismissible': False, + 'timestamp': 1676236978, + }) + + for announcement in announcements: + if 'enabled' not in announcement: + announcement['enabled'] = True + if 'dismissible' not in announcement: + announcement['dismissible'] = True + + return announcements + + +def get_all_announcements(): + # get announcements that haven't been dismissed yet + announcements = [parse_announcement_dict(x) for x in get_online_announcements() + get_local_announcements() if + x['enabled'] and (not x['dismissible'] or not TableAnnouncements.select() + .where(TableAnnouncements.hash == + hashlib.sha256(x['text'].encode('UTF8')).hexdigest()).get_or_none())] + + return sorted(announcements, key=itemgetter('timestamp'), reverse=True) + + +def mark_announcement_as_dismissed(hashed_announcement): + text = [x['text'] for x in get_all_announcements() if x['hash'] == hashed_announcement] + if text: + TableAnnouncements.insert({TableAnnouncements.hash: hashed_announcement, + TableAnnouncements.timestamp: datetime.now(), + TableAnnouncements.text: text[0]})\ + .on_conflict_ignore(ignore=True)\ + .execute() diff --git a/bazarr/app/database.py b/bazarr/app/database.py index 85b62387c..26096c26e 100644 --- a/bazarr/app/database.py +++ b/bazarr/app/database.py @@ -291,6 +291,15 @@ class TableCustomScoreProfileConditions(BaseModel): table_name = 'table_custom_score_profile_conditions' +class TableAnnouncements(BaseModel): + timestamp = DateTimeField() + hash = TextField(null=True, unique=True) + text = TextField(null=True) + + class Meta: + table_name = 'table_announcements' + + def init_db(): # Create tables if they don't exists. database.create_tables([System, @@ -307,7 +316,8 @@ def init_db(): TableShows, TableShowsRootfolder, TableCustomScoreProfiles, - TableCustomScoreProfileConditions]) + TableCustomScoreProfileConditions, + TableAnnouncements]) # add the system table single row if it's not existing # we must retry until the tables are created diff --git a/bazarr/app/scheduler.py b/bazarr/app/scheduler.py index d9c5403f4..bb843619a 100644 --- a/bazarr/app/scheduler.py +++ b/bazarr/app/scheduler.py @@ -17,6 +17,7 @@ from tzlocal.utils import ZoneInfoNotFoundError from dateutil import tz import logging +from app.announcements import get_announcements_to_file from sonarr.sync.series import update_series from sonarr.sync.episodes import sync_episodes, update_all_episodes from radarr.sync.movies import update_movies, update_all_movies @@ -262,6 +263,10 @@ class Scheduler: check_releases, IntervalTrigger(hours=3), max_instances=1, coalesce=True, misfire_grace_time=15, id='update_release', name='Update Release Info', replace_existing=True) + self.aps_scheduler.add_job( + get_announcements_to_file, IntervalTrigger(hours=6), max_instances=1, coalesce=True, misfire_grace_time=15, + id='update_announcements', name='Update Announcements File', replace_existing=True) + def __search_wanted_subtitles_task(self): if settings.general.getboolean('use_sonarr'): self.aps_scheduler.add_job( diff --git a/bazarr/init.py b/bazarr/init.py index 2685a1a81..c1b285970 100644 --- a/bazarr/init.py +++ b/bazarr/init.py @@ -177,6 +177,11 @@ if not os.path.exists(os.path.join(args.config_dir, 'config', 'releases.txt')): check_releases() logging.debug("BAZARR Created releases file") +if not os.path.exists(os.path.join(args.config_dir, 'config', 'announcements.txt')): + from app.announcements import get_announcements_to_file + get_announcements_to_file() + logging.debug("BAZARR Created announcements file") + config_file = os.path.normpath(os.path.join(args.config_dir, 'config', 'config.ini')) # Move GA visitor from config.ini to dedicated file diff --git a/bazarr/main.py b/bazarr/main.py index 18583e017..7035cc513 100644 --- a/bazarr/main.py +++ b/bazarr/main.py @@ -39,9 +39,12 @@ from app.notifier import update_notifier # noqa E402 from languages.get_languages import load_language_in_db # noqa E402 from app.signalr_client import sonarr_signalr_client, radarr_signalr_client # noqa E402 from app.server import webserver # noqa E402 +from app.announcements import get_announcements_to_file # noqa E402 configure_proxy_func() +get_announcements_to_file() + # Reset the updated once Bazarr have been restarted after an update System.update({System.updated: '0'}).execute() diff --git a/frontend/src/Router/index.tsx b/frontend/src/Router/index.tsx index 5e90290c8..d13ea1417 100644 --- a/frontend/src/Router/index.tsx +++ b/frontend/src/Router/index.tsx @@ -23,6 +23,7 @@ import SettingsSchedulerView from "@/pages/Settings/Scheduler"; import SettingsSonarrView from "@/pages/Settings/Sonarr"; import SettingsSubtitlesView from "@/pages/Settings/Subtitles"; import SettingsUIView from "@/pages/Settings/UI"; +import SystemAnnouncementsView from "@/pages/System/Announcements"; import SystemBackupsView from "@/pages/System/Backups"; import SystemLogsView from "@/pages/System/Logs"; import SystemProvidersView from "@/pages/System/Providers"; @@ -278,6 +279,12 @@ function useRoutes(): CustomRouteObject[] { name: "Releases", element: <SystemReleasesView></SystemReleasesView>, }, + { + path: "announcements", + name: "Announcements", + badge: data?.announcements, + element: <SystemAnnouncementsView></SystemAnnouncementsView>, + }, ], }, { @@ -299,6 +306,7 @@ function useRoutes(): CustomRouteObject[] { data?.providers, data?.sonarr_signalr, data?.radarr_signalr, + data?.announcements, radarr, sonarr, ] diff --git a/frontend/src/apis/hooks/system.ts b/frontend/src/apis/hooks/system.ts index 9be28c4d2..29e379a20 100644 --- a/frontend/src/apis/hooks/system.ts +++ b/frontend/src/apis/hooks/system.ts @@ -6,7 +6,15 @@ import { QueryKeys } from "../queries/keys"; import api from "../raw"; export function useBadges() { - return useQuery([QueryKeys.System, QueryKeys.Badges], () => api.badges.all()); + return useQuery( + [QueryKeys.System, QueryKeys.Badges], + () => api.badges.all(), + { + refetchOnWindowFocus: "always", + refetchInterval: 1000 * 60, + staleTime: 1000 * 10, + } + ); } export function useFileSystem( @@ -73,7 +81,7 @@ export function useSystemLogs() { return useQuery([QueryKeys.System, QueryKeys.Logs], () => api.system.logs(), { refetchOnWindowFocus: "always", refetchInterval: 1000 * 60, - staleTime: 1000, + staleTime: 1000 * 10, }); } @@ -90,6 +98,35 @@ export function useDeleteLogs() { ); } +export function useSystemAnnouncements() { + return useQuery( + [QueryKeys.System, QueryKeys.Announcements], + () => api.system.announcements(), + { + refetchOnWindowFocus: "always", + refetchInterval: 1000 * 60, + staleTime: 1000 * 10, + } + ); +} + +export function useSystemAnnouncementsAddDismiss() { + const client = useQueryClient(); + return useMutation( + [QueryKeys.System, QueryKeys.Announcements], + (param: { hash: string }) => { + const { hash } = param; + return api.system.addAnnouncementsDismiss(hash); + }, + { + onSuccess: (_, { hash }) => { + client.invalidateQueries([QueryKeys.System, QueryKeys.Announcements]); + client.invalidateQueries([QueryKeys.System, QueryKeys.Badges]); + }, + } + ); +} + export function useSystemTasks() { return useQuery( [QueryKeys.System, QueryKeys.Tasks], diff --git a/frontend/src/apis/queries/keys.ts b/frontend/src/apis/queries/keys.ts index a3b6e94a7..45f30f12e 100644 --- a/frontend/src/apis/queries/keys.ts +++ b/frontend/src/apis/queries/keys.ts @@ -13,6 +13,7 @@ export enum QueryKeys { Blacklist = "blacklist", Search = "search", Actions = "actions", + Announcements = "announcements", Tasks = "tasks", Backups = "backups", Logs = "logs", diff --git a/frontend/src/apis/raw/system.ts b/frontend/src/apis/raw/system.ts index c2f0382ef..1b64d6b24 100644 --- a/frontend/src/apis/raw/system.ts +++ b/frontend/src/apis/raw/system.ts @@ -87,6 +87,19 @@ class SystemApi extends BaseApi { await this.delete("/logs"); } + async announcements() { + const response = await this.get<DataWrapper<System.Announcements[]>>( + "/announcements" + ); + return response.data; + } + + async addAnnouncementsDismiss(hash: string) { + await this.post<DataWrapper<System.Announcements[]>>("/announcements", { + hash, + }); + } + async tasks() { const response = await this.get<DataWrapper<System.Task[]>>("/tasks"); return response.data; diff --git a/frontend/src/pages/System/Announcements/index.tsx b/frontend/src/pages/System/Announcements/index.tsx new file mode 100644 index 000000000..4e204431e --- /dev/null +++ b/frontend/src/pages/System/Announcements/index.tsx @@ -0,0 +1,24 @@ +import { useSystemAnnouncements } from "@/apis/hooks"; +import { QueryOverlay } from "@/components/async"; +import { Container } from "@mantine/core"; +import { useDocumentTitle } from "@mantine/hooks"; +import { FunctionComponent } from "react"; +import Table from "./table"; + +const SystemAnnouncementsView: FunctionComponent = () => { + const announcements = useSystemAnnouncements(); + + const { data } = announcements; + + useDocumentTitle("Announcements - Bazarr (System)"); + + return ( + <QueryOverlay result={announcements}> + <Container fluid px={0}> + <Table announcements={data ?? []}></Table> + </Container> + </QueryOverlay> + ); +}; + +export default SystemAnnouncementsView; diff --git a/frontend/src/pages/System/Announcements/table.tsx b/frontend/src/pages/System/Announcements/table.tsx new file mode 100644 index 000000000..97f8cbe3e --- /dev/null +++ b/frontend/src/pages/System/Announcements/table.tsx @@ -0,0 +1,91 @@ +import { useSystemAnnouncementsAddDismiss } from "@/apis/hooks"; +import { SimpleTable } from "@/components"; +import { MutateAction } from "@/components/async"; +import { useTableStyles } from "@/styles"; +import { faWindowClose } from "@fortawesome/free-solid-svg-icons"; +import { Anchor, Text } from "@mantine/core"; +import { FunctionComponent, useMemo } from "react"; +import { Column } from "react-table"; + +interface Props { + announcements: readonly System.Announcements[]; +} + +const Table: FunctionComponent<Props> = ({ announcements }) => { + const columns: Column<System.Announcements>[] = useMemo< + Column<System.Announcements>[] + >( + () => [ + { + Header: "Since", + accessor: "timestamp", + Cell: ({ value }) => { + const { classes } = useTableStyles(); + return <Text className={classes.primary}>{value}</Text>; + }, + }, + { + Header: "Announcement", + accessor: "text", + Cell: ({ value }) => { + const { classes } = useTableStyles(); + return <Text className={classes.primary}>{value}</Text>; + }, + }, + { + Header: "More info", + accessor: "link", + Cell: ({ value }) => { + if (value) { + return <Label link={value}>Link</Label>; + } else { + return <Text>n/a</Text>; + } + }, + }, + { + Header: "Dismiss", + accessor: "hash", + Cell: ({ row, value }) => { + const add = useSystemAnnouncementsAddDismiss(); + return ( + <MutateAction + label="Dismiss announcement" + disabled={!row.original.dismissible} + icon={faWindowClose} + mutation={add} + args={() => ({ + hash: value, + })} + ></MutateAction> + ); + }, + }, + ], + [] + ); + + return ( + <SimpleTable + columns={columns} + data={announcements} + tableStyles={{ emptyText: "No announcements for now, come back later!" }} + ></SimpleTable> + ); +}; + +export default Table; + +interface LabelProps { + link: string; + children: string; +} + +function Label(props: LabelProps): JSX.Element { + const { link, children } = props; + return ( + <Anchor href={link} target="_blank" rel="noopener noreferrer"> + {children} + </Anchor> + ); +} diff --git a/frontend/src/pages/System/system.test.tsx b/frontend/src/pages/System/system.test.tsx index f9c0b5dad..813654a7b 100644 --- a/frontend/src/pages/System/system.test.tsx +++ b/frontend/src/pages/System/system.test.tsx @@ -1,3 +1,4 @@ +import SystemAnnouncementsView from "@/pages/System/Announcements"; import { renderTest, RenderTestCase } from "@/tests/render"; import SystemBackupsView from "./Backups"; import SystemLogsView from "./Logs"; @@ -31,6 +32,10 @@ const cases: RenderTestCase[] = [ name: "tasks page", ui: SystemTasksView, }, + { + name: "announcements page", + ui: SystemAnnouncementsView, + }, ]; renderTest("System", cases); diff --git a/frontend/src/types/api.d.ts b/frontend/src/types/api.d.ts index ffd931d43..b19c682c0 100644 --- a/frontend/src/types/api.d.ts +++ b/frontend/src/types/api.d.ts @@ -5,6 +5,7 @@ interface Badge { status: number; sonarr_signalr: string; radarr_signalr: string; + announcements: number; } declare namespace Language { diff --git a/frontend/src/types/form.d.ts b/frontend/src/types/form.d.ts index 99b16da88..6019a3fa0 100644 --- a/frontend/src/types/form.d.ts +++ b/frontend/src/types/form.d.ts @@ -74,4 +74,8 @@ declare namespace FormType { subtitle: unknown; original_format: PythonBoolean; } + + interface AddAnnouncementsDismiss { + hash: number; + } } diff --git a/frontend/src/types/system.d.ts b/frontend/src/types/system.d.ts index dc4e33799..544d969ae 100644 --- a/frontend/src/types/system.d.ts +++ b/frontend/src/types/system.d.ts @@ -1,4 +1,12 @@ declare namespace System { + interface Announcements { + text: string; + link: string; + hash: string; + dismissible: boolean; + timestamp: string; + } + interface Task { interval: string; job_id: string; |