mirror of
https://github.com/morpheus65535/bazarr.git
synced 2025-04-23 14:17:46 -04:00
Added Announcements section in System to be able to inform users of Bazarr's news.
This commit is contained in:
parent
52df29a1f5
commit
58262bc299
18 changed files with 372 additions and 4 deletions
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
35
bazarr/api/system/announcements.py
Normal file
35
bazarr/api/system/announcements.py
Normal 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
113
bazarr/app/announcements.py
Normal 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()
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -13,6 +13,7 @@ export enum QueryKeys {
|
|||
Blacklist = "blacklist",
|
||||
Search = "search",
|
||||
Actions = "actions",
|
||||
Announcements = "announcements",
|
||||
Tasks = "tasks",
|
||||
Backups = "backups",
|
||||
Logs = "logs",
|
||||
|
|
|
@ -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;
|
||||
|
|
24
frontend/src/pages/System/Announcements/index.tsx
Normal file
24
frontend/src/pages/System/Announcements/index.tsx
Normal 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;
|
91
frontend/src/pages/System/Announcements/table.tsx
Normal file
91
frontend/src/pages/System/Announcements/table.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
|
|
1
frontend/src/types/api.d.ts
vendored
1
frontend/src/types/api.d.ts
vendored
|
@ -5,6 +5,7 @@ interface Badge {
|
|||
status: number;
|
||||
sonarr_signalr: string;
|
||||
radarr_signalr: string;
|
||||
announcements: number;
|
||||
}
|
||||
|
||||
declare namespace Language {
|
||||
|
|
4
frontend/src/types/form.d.ts
vendored
4
frontend/src/types/form.d.ts
vendored
|
@ -74,4 +74,8 @@ declare namespace FormType {
|
|||
subtitle: unknown;
|
||||
original_format: PythonBoolean;
|
||||
}
|
||||
|
||||
interface AddAnnouncementsDismiss {
|
||||
hash: number;
|
||||
}
|
||||
}
|
||||
|
|
8
frontend/src/types/system.d.ts
vendored
8
frontend/src/types/system.d.ts
vendored
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue