Resolved conflicts

This commit is contained in:
StruckCroissant 2022-02-05 18:30:39 -06:00
commit f76ff8703d
12 changed files with 371 additions and 76 deletions

View file

@ -33,8 +33,14 @@ async function apiCall(url, method, body, ms = 10000) {
try {
// await the response or error.
let response = await timeoutPromise(ms, promise);
if (200 <= response.status < 300) {
// Request was successful.
return response;
}
// Request encountered an error.
let copy = response.clone();
if (response.status === 403 && (await copy.json())['code'] === 17) {
let code = (await copy.json())['code'];
if (response.status === 403 && code === 17) {
toast({
type: 'error',
title: 'WROL Mode is enabled!',
@ -359,17 +365,24 @@ export async function killDownload(download_id) {
export async function killDownloads() {
let response = await apiPost(`${API_URI}/download/kill`);
console.log('killDownloads', response);
return response;
}
export async function startDownloads() {
let response = await apiPost(`${API_URI}/download/enable`);
console.log('startDownloads', response);
return response;
}
export async function getDownloaders() {
let response = await apiGet(`${API_URI}/downloaders`);
return await response.json();
try {
let response = await apiGet(`${API_URI}/downloaders`);
return await response.json();
} catch (e) {
console.log('getDownloaders', e);
return {};
}
}
const replaceFileDatetimes = (files) => {
@ -423,3 +436,54 @@ export async function getVersion() {
let data = await response.json();
return data['version'];
}
export async function getHotspotStatus() {
let response = await apiGet(`${API_URI}/hotspot`);
if (response.status === 200) {
let {status} = await response.json();
return status;
} else {
let code = (await response.json())['code'];
if (response.status === 400 && code === 34) {
// Must not be running on a RPi. Don't warn the user.
return null;
} else {
toast({
type: 'error',
title: "Can't get Hotspot status",
description: 'Failed to get Hotspot status. Check client and server logs.',
time: 5000,
});
}
}
return null;
}
export async function setHotspot(on) {
let response;
if (on) {
response = await apiPost(`${API_URI}/hotspot/on`);
} else {
response = await apiPost(`${API_URI}/hotspot/off`);
}
if (response.status === 204) {
return null;
} else {
let code = (await response.json())['code'];
if (code === 34) {
toast({
type: 'error',
title: 'Unsupported!',
description: 'Hotspot is only supported on a Raspberry Pi!',
time: 5000,
});
} else {
toast({
type: 'error',
title: 'Error!',
description: 'Could not modify hotspot. See server logs.',
time: 5000,
});
}
}
}

View file

@ -11,18 +11,18 @@ import {
Placeholder,
Table
} from "semantic-ui-react";
import {
getConfig,
getDownloaders,
getDownloads,
killDownload,
killDownloads,
postDownload,
saveConfig,
startDownloads
} from "../api";
import {getConfig, getDownloads, killDownload, postDownload, saveConfig} from "../api";
import TimezoneSelect from 'react-timezone-select';
import {PageContainer, secondsToDate, secondsToFrequency, TabLinks, textEllipsis, WROLModeMessage} from "./Common";
import {
DisableDownloadsToggle,
HotspotToggle,
PageContainer,
secondsToDate,
secondsToFrequency,
TabLinks,
textEllipsis,
WROLModeMessage
} from "./Common";
import {Route} from "react-router-dom";
class Settings extends React.Component {
@ -78,6 +78,10 @@ class Settings extends React.Component {
return (
<Container fluid>
<WROLModeMessage content='Settings are disabled because WROL Mode is enabled.'/>
<HotspotToggle/>
<Divider/>
<p>
The global settings for your server.
</p>
@ -305,17 +309,6 @@ class Downloads extends React.Component {
async componentDidMount() {
await this.fetchDownloads();
await this.fetchStatus();
}
killDownloads = async () => {
await killDownloads();
this.setState({stopOpen: false}, this.fetchStatus);
}
startDownloads = async () => {
await startDownloads();
this.setState({stopOpen: false}, this.fetchStatus);
}
closeStop = () => {
@ -335,11 +328,6 @@ class Downloads extends React.Component {
});
}
fetchStatus = async () => {
let data = await getDownloaders();
this.setState({disabled: data['manager_disabled']});
}
render() {
let tablePlaceholder = (
<Placeholder>
@ -378,7 +366,9 @@ class Downloads extends React.Component {
onceTable = (<Table>
{stoppableHeader}
<Table.Body>
{this.state.once_downloads.map((i) => <StoppableRow {...i} fetchDownloads={this.fetchDownloads}/>)}
{this.state.once_downloads.map((i) =>
<StoppableRow {...i} fetchDownloads={this.fetchDownloads} key={i.id}/>
)}
</Table.Body>
</Table>);
}
@ -390,46 +380,15 @@ class Downloads extends React.Component {
recurringTable = (<Table>
{nonStoppableHeader}
<Table.Body>
{this.state.recurring_downloads.map((i) => <DownloadRow {...i}/>)}
{this.state.recurring_downloads.map((i) => <DownloadRow {...i} key={i.id}/>)}
</Table.Body>
</Table>);
}
let {stopOpen} = this.state;
let allButton = (<>
<Button
onClick={this.openStop}
color='red'
>Disable Downloads</Button>
<Confirm
open={stopOpen}
content='Are you sure you want to stop all downloads?'
confirmButton='Stop'
onCancel={this.closeStop}
onConfirm={this.killDownloads}
/>
</>
);
if (this.state.disabled === true) {
allButton = (<>
<Button
onClick={this.openStop}
color='green'
>Enable Downloads</Button>
<Confirm
open={stopOpen}
content='Are you sure you want to start all downloads?'
confirmButton='Start'
onCancel={this.closeStop}
onConfirm={this.startDownloads}
/>
</>);
}
return (
<div>
<WROLModeMessage content='Downloads are disabled because WROL Mode is enabled.'/>
{allButton}
<DisableDownloadsToggle/>
<Header as='h1'>Downloads</Header>
{onceTable}

View file

@ -1,10 +1,11 @@
import React, {useEffect, useState} from "react";
import {Card, Container, Form, Icon, Image, Input, Menu, Pagination, Responsive} from 'semantic-ui-react';
import {Card, Checkbox, Container, Form, Icon, Image, Input, Menu, Pagination, Responsive} from 'semantic-ui-react';
import {Link, NavLink} from "react-router-dom";
import Button from "semantic-ui-react/dist/commonjs/elements/Button";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import Message from "semantic-ui-react/dist/commonjs/collections/Message";
import {getConfig} from "../api";
import {useDownloaders, useHotspot} from "../hooks/customHooks";
export const API_URI = `http://${window.location.host}/api`;
export const VIDEOS_API = `${API_URI}/videos`;
@ -643,3 +644,23 @@ export function CardGroupCentered(props) {
</>
)
}
export function HotspotToggle() {
let {on, setHotspot} = useHotspot();
return <Checkbox toggle
label='WiFi Hotspot'
disabled={on === null}
checked={on === true}
onChange={(e, data) => setHotspot(data.checked)}
/>;
}
export function DisableDownloadsToggle() {
let {on, setDownloads} = useDownloaders();
return <Checkbox toggle
label={on ? 'Downloading Enabled' : 'Downloading Disabled'}
disabled={on === null}
checked={on === true}
onChange={(e, data) => setDownloads(data.checked)}
/>;
}

View file

@ -1,5 +1,17 @@
import {useEffect, useState} from "react";
import {fetchDomains, filesSearch, getFiles, getVersion, searchArchives, searchVideos} from "../api";
import {
fetchDomains,
filesSearch,
getDownloaders,
getFiles,
getHotspotStatus,
getVersion,
killDownloads,
searchArchives,
searchVideos,
setHotspot,
startDownloads
} from "../api";
import {useHistory} from "react-router-dom";
export const useSearchParam = (key, defaultValue = null) => {
@ -178,3 +190,60 @@ export const useBrowseFiles = () => {
return {browseFiles, openFolders, setOpenFolders, fetchFiles};
}
export const useHotspot = () => {
const [on, setOn] = useState(null);
const fetchHotspotStatus = async () => {
const status = await getHotspotStatus();
if (status === 'connected') {
// Hotspot is on.
setOn(true);
} else if (status === 'disconnected' || status === 'unavailable') {
// Hotspot can be turned on.
setOn(false);
} else {
// Hotspot is not supported. API is probably running in a Docker container.
setOn(null);
}
}
useEffect(() => {
fetchHotspotStatus();
}, []);
const localSetHotspot = async (on) => {
setOn(null);
await setHotspot(on);
await fetchHotspotStatus();
}
return {on, setOn, setHotspot: localSetHotspot};
}
export const useDownloaders = () => {
const [on, setOn] = useState(null);
const [downloaders, setDownloaders] = useState(null);
const fetchDownloaders = async () => {
let data = await getDownloaders();
setDownloaders(data['downloaders']);
setOn(!data['manager_disabled']);
}
useEffect(() => {
fetchDownloaders();
}, []);
const localSetDownloads = async (on) => {
setOn(null);
if (on) {
await startDownloads();
} else {
await killDownloads();
}
await fetchDownloaders();
}
return {on, setOn, downloaders, setDownloads: localSetDownloads};
}

View file

@ -17,7 +17,4 @@ COPY docker/archive /app
# Install Readability too
RUN npm install -g 'git+https://github.com/pirate/readability-extractor'
# Block ads using /etc/hosts
#RUN wget https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts -O /etc/hosts
ENTRYPOINT [ "python3", "/app/main.py"]

View file

@ -19,4 +19,4 @@ SQLAlchemy==1.3.22
srt==3.5.1
websockets==10.1
webvtt-py==0.4.6
yt-dlp==2022.1.21
yt-dlp==2022.2.4

67
wrolpi/admin.py Normal file
View file

@ -0,0 +1,67 @@
import enum
import pathlib
import subprocess
from wrolpi.common import logger
from wrolpi.vars import PYTEST
logger = logger.getChild(__name__)
# Location on an RPi
SUDO_BIN = pathlib.Path('/usr/bin/sudo')
if not SUDO_BIN.is_file() and not PYTEST:
logger.error('COULD NOT FIND sudo!!!')
NMCLI_BIN = pathlib.Path('/usr/bin/nmcli')
if not NMCLI_BIN.is_file() and not PYTEST:
logger.error('COULD NOT FIND nmcli!!!')
class HotspotStatus(enum.Enum):
disconnected = enum.auto() # Radio is on, but Hotspot is not connected.
unavailable = enum.auto() # Radio is off.
connected = enum.auto() # Radio is on, Hotspot is on.
def hotspot_status() -> HotspotStatus:
cmd = (NMCLI_BIN,)
output = subprocess.check_output(cmd).decode().strip()
for line in output.splitlines():
if line.startswith('wlan0: connected'):
return HotspotStatus.connected
elif line.startswith('wlan0: disconnected'):
return HotspotStatus.disconnected
elif line.startswith('wlan0: unavailable'):
return HotspotStatus.unavailable
raise Exception(f'Unknown status! {output=}')
def enable_hotspot():
"""
Turn the wlan0 interface into a hotspot. If wlan0 is already running, replace that with a hotspot.
"""
status = hotspot_status()
logger.warning(f'Hotspot status: {status}')
if status == HotspotStatus.connected:
disable_hotspot()
return enable_hotspot()
elif status == HotspotStatus.disconnected:
# Radio is on, but not connected. Good, turn it into a hotspot.
cmd = (SUDO_BIN, NMCLI_BIN, 'device', 'wifi', 'hotspot', 'ifname', 'wlan0',
'ssid', 'WROLPi', 'password', 'wrolpi hotspot')
subprocess.check_call(cmd)
return True
elif status == HotspotStatus.unavailable:
# Radio is not on, turn it on.
cmd = (SUDO_BIN, NMCLI_BIN, 'radio', 'wifi', 'on')
subprocess.check_call(cmd)
return enable_hotspot()
def disable_hotspot():
"""Turn off the wlan0 interface."""
cmd = (SUDO_BIN, NMCLI_BIN, 'radio', 'wifi', 'off')
subprocess.check_call(cmd)
return True

View file

@ -27,8 +27,9 @@ from sqlalchemy import types
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.ext.declarative import declarative_base
from wrolpi.errors import WROLModeEnabled
from wrolpi.vars import CONFIG_PATH, EXAMPLE_CONFIG_PATH, PUBLIC_HOST, PUBLIC_PORT, PROJECT_DIR, PYTEST, MODULES_DIR
from wrolpi.errors import WROLModeEnabled, NativeOnly
from wrolpi.vars import CONFIG_PATH, EXAMPLE_CONFIG_PATH, PUBLIC_HOST, PUBLIC_PORT, PROJECT_DIR, PYTEST, MODULES_DIR, \
DOCKERIZED
logger = logging.getLogger()
ch = logging.StreamHandler()
@ -655,3 +656,14 @@ INVALID_FILE_CHARS = re.compile(r'[/<>:\|"\\\?\*]')
def escape_file_name(name: str) -> str:
"""Replace any invalid characters in a file name with "_"."""
return INVALID_FILE_CHARS.sub('', name)
def native_only(func: callable):
"""Wraps a function. Raises NativeOnly if run while Dockerized."""
@wraps(func)
def wrapped(*a, **kw):
if DOCKERIZED:
raise NativeOnly('Only supported on a Raspberry Pi')
return func(*a, **kw)
return wrapped

View file

@ -140,6 +140,10 @@ class InvalidFile(APIError):
pass
class NativeOnly(APIError):
pass
error_codes = iter(range(1, 1000))
API_ERRORS = {
@ -307,5 +311,10 @@ API_ERRORS = {
'code': next(error_codes),
'message': 'File does not exist or is a directory',
'status': HTTPStatus.BAD_REQUEST,
}
},
NativeOnly: {
'code': next(error_codes),
'message': 'This functionality is only supported outside a docker container',
'status': HTTPStatus.BAD_REQUEST,
},
}

View file

@ -14,8 +14,10 @@ from sanic.response import HTTPResponse
from sanic_ext import validate
from sanic_ext.extensions.openapi import openapi
from wrolpi import admin
from wrolpi.admin import HotspotStatus
from wrolpi.common import set_sanic_url_parts, logger, get_config, wrol_mode_enabled, save_settings_config, \
Base, get_media_directory, wrol_mode_check
Base, get_media_directory, wrol_mode_check, native_only
from wrolpi.dates import set_timezone
from wrolpi.downloader import download_manager
from wrolpi.errors import WROLModeEnabled, InvalidTimezone, API_ERRORS, APIError, ValidationError
@ -210,6 +212,38 @@ async def get_downloaders(_: Request):
return json_response(ret)
@root_api.get('/hotspot')
@native_only
@openapi.description('Get the status of the hotspot')
async def hotspot(_: Request):
status = admin.hotspot_status()
# Get the name of the enum.
status = status.name
ret = dict(status=status)
return json_response(ret)
@root_api.post('/hotspot/on')
@openapi.description('Turn on the hotspot')
@native_only
async def hotspot_on(_: Request):
result = admin.enable_hotspot()
if result:
return response.empty()
return response.empty(HTTPStatus.INTERNAL_SERVER_ERROR)
@root_api.post('/hotspot/off')
@openapi.description('Turn off the hotspot')
@native_only
async def hotspot_off(_: Request):
result = admin.disable_hotspot()
if result:
return response.empty()
return response.empty(HTTPStatus.INTERNAL_SERVER_ERROR)
class CustomJSONEncoder(json.JSONEncoder):
def default(self, obj):

View file

@ -1,5 +1,5 @@
from dataclasses import dataclass
from typing import List
from typing import List, Optional
@dataclass
@ -52,7 +52,7 @@ class EventsResponse:
@dataclass
class DownloadRequest:
urls: str
downloader: str
downloader: Optional[str] = None
@dataclass

63
wrolpi/test/test_admin.py Normal file
View file

@ -0,0 +1,63 @@
from pathlib import PosixPath
from unittest import mock
from unittest.mock import call
from wrolpi import admin
from wrolpi.admin import HotspotStatus
def test_hotspot_status():
with mock.patch('wrolpi.admin.subprocess') as mock_subprocess:
mock_subprocess.check_output.return_value = b'wlan0: unavailable'
assert admin.hotspot_status() == HotspotStatus.unavailable
mock_subprocess.check_output.return_value = b'wlan0: disconnected'
assert admin.hotspot_status() == HotspotStatus.disconnected
mock_subprocess.check_output.return_value = b'wlan0: connected'
assert admin.hotspot_status() == HotspotStatus.connected
def test_enable_hotspot_disconnected():
"""Enable hotspot with wifi on."""
with mock.patch('wrolpi.admin.hotspot_status') as mock_hotspot_status, \
mock.patch('wrolpi.admin.subprocess') as mock_subprocess:
mock_hotspot_status.return_value = HotspotStatus.disconnected
mock_subprocess.check_output.return_value = 0
assert admin.enable_hotspot() is True
mock_subprocess.check_call.assert_called_once_with(
(PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'device', 'wifi', 'hotspot', 'ifname', 'wlan0',
'ssid', 'WROLPi', 'password', 'wrolpi hotspot'))
def test_enable_hotspot_unavailable():
"""Enable hotspot with wifi off."""
with mock.patch('wrolpi.admin.hotspot_status') as mock_hotspot_status, \
mock.patch('wrolpi.admin.subprocess') as mock_subprocess:
mock_hotspot_status.side_effect = [HotspotStatus.unavailable, HotspotStatus.disconnected]
mock_subprocess.check_output.return_value = 0
assert admin.enable_hotspot() is True
mock_subprocess.check_call.assert_has_calls(
[
call((PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'radio', 'wifi', 'on')),
call((PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'device', 'wifi', 'hotspot', 'ifname',
'wlan0', 'ssid', 'WROLPi', 'password', 'wrolpi hotspot'))]
)
def test_enable_hotspot_connected():
"""Enable hotspot with the hotspot alaready enabled."""
with mock.patch('wrolpi.admin.hotspot_status') as mock_hotspot_status, \
mock.patch('wrolpi.admin.subprocess') as mock_subprocess:
mock_hotspot_status.side_effect = [
HotspotStatus.connected,
HotspotStatus.unavailable,
HotspotStatus.disconnected,
]
mock_subprocess.check_output.return_value = 0
assert admin.enable_hotspot() is True
mock_subprocess.check_call.assert_has_calls(
[
call((PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'radio', 'wifi', 'off')),
call((PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'radio', 'wifi', 'on')),
call((PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'device', 'wifi', 'hotspot', 'ifname',
'wlan0', 'ssid', 'WROLPi', 'password', 'wrolpi hotspot'))]
)