Added Announcements section in System to be able to inform users of Bazarr's news.

This commit is contained in:
morpheus65535 2023-02-20 16:04:09 -05:00 committed by GitHub
parent 52df29a1f5
commit 58262bc299
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 372 additions and 4 deletions

View File

@ -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

View File

@ -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,

View File

@ -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

113
bazarr/app/announcements.py Normal file
View File

@ -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()

View File

@ -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

View File

@ -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(

View File

@ -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

View File

@ -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()

View File

@ -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,
]

View File

@ -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],

View File

@ -13,6 +13,7 @@ export enum QueryKeys {
Blacklist = "blacklist",
Search = "search",
Actions = "actions",
Announcements = "announcements",
Tasks = "tasks",
Backups = "backups",
Logs = "logs",

View File

@ -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;

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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);

View File

@ -5,6 +5,7 @@ interface Badge {
status: number;
sonarr_signalr: string;
radarr_signalr: string;
announcements: number;
}
declare namespace Language {

View File

@ -74,4 +74,8 @@ declare namespace FormType {
subtitle: unknown;
original_format: PythonBoolean;
}
interface AddAnnouncementsDismiss {
hash: number;
}
}

View File

@ -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;