summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--bazarr/api/badges/badges.py5
-rw-r--r--bazarr/api/system/__init__.py2
-rw-r--r--bazarr/api/system/announcements.py35
-rw-r--r--bazarr/app/announcements.py113
-rw-r--r--bazarr/app/database.py12
-rw-r--r--bazarr/app/scheduler.py5
-rw-r--r--bazarr/init.py5
-rw-r--r--bazarr/main.py3
-rw-r--r--frontend/src/Router/index.tsx8
-rw-r--r--frontend/src/apis/hooks/system.ts41
-rw-r--r--frontend/src/apis/queries/keys.ts1
-rw-r--r--frontend/src/apis/raw/system.ts13
-rw-r--r--frontend/src/pages/System/Announcements/index.tsx24
-rw-r--r--frontend/src/pages/System/Announcements/table.tsx91
-rw-r--r--frontend/src/pages/System/system.test.tsx5
-rw-r--r--frontend/src/types/api.d.ts1
-rw-r--r--frontend/src/types/form.d.ts4
-rw-r--r--frontend/src/types/system.d.ts8
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;