Add basis of webstudio selector server UI.
This commit is contained in:
parent
964977bfdf
commit
8c7df0f5ab
8 changed files with 172 additions and 10 deletions
1
.env
1
.env
|
@ -2,4 +2,5 @@ HOST=local-development.ury.org.uk
|
||||||
REACT_APP_VERSION=$npm_package_version
|
REACT_APP_VERSION=$npm_package_version
|
||||||
REACT_APP_MYRADIO_NONAPI_BASE=https://ury.org.uk/myradio-staging
|
REACT_APP_MYRADIO_NONAPI_BASE=https://ury.org.uk/myradio-staging
|
||||||
REACT_APP_MYRADIO_BASE=https://ury.org.uk/api-staging/v2
|
REACT_APP_MYRADIO_BASE=https://ury.org.uk/api-staging/v2
|
||||||
|
REACT_APP_BROADCAST_API_BASE=https://ury.org.uk/webstudio/api/v1
|
||||||
REACT_APP_WS_URL=wss://audio.ury.org.uk/webstudio/stream
|
REACT_APP_WS_URL=wss://audio.ury.org.uk/webstudio/stream
|
|
@ -1,4 +1,5 @@
|
||||||
REACT_APP_VERSION=$npm_package_version
|
REACT_APP_VERSION=$npm_package_version
|
||||||
REACT_APP_MYRADIO_NONAPI_BASE=https://ury.org.uk/myradio
|
REACT_APP_MYRADIO_NONAPI_BASE=https://ury.org.uk/myradio
|
||||||
REACT_APP_MYRADIO_BASE=https://ury.org.uk/api/v2
|
REACT_APP_MYRADIO_BASE=https://ury.org.uk/api/v2
|
||||||
|
REACT_APP_BROADCAST_API_BASE=https://ury.org.uk/webstudio/api/v1
|
||||||
REACT_APP_WS_URL=wss://audio.ury.org.uk/webstudio/stream
|
REACT_APP_WS_URL=wss://audio.ury.org.uk/webstudio/stream
|
|
@ -76,6 +76,7 @@
|
||||||
"react-dnd": "^9.4.0",
|
"react-dnd": "^9.4.0",
|
||||||
"react-dnd-html5-backend": "^9.4.0",
|
"react-dnd-html5-backend": "^9.4.0",
|
||||||
"react-dom": "^16.13.1",
|
"react-dom": "^16.13.1",
|
||||||
|
"react-live-clock": "^4.0.5",
|
||||||
"react-modal": "^3.11.2",
|
"react-modal": "^3.11.2",
|
||||||
"react-redux": "^7.1.3",
|
"react-redux": "^7.1.3",
|
||||||
"reactstrap": "^8.4.1",
|
"reactstrap": "^8.4.1",
|
||||||
|
|
14
src/App.css
14
src/App.css
|
@ -21,6 +21,20 @@
|
||||||
color: #09d3ac;
|
color: #09d3ac;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#timelord {
|
||||||
|
background: black;
|
||||||
|
border:red 1px solid;
|
||||||
|
padding:0;
|
||||||
|
margin:0;
|
||||||
|
color: white;
|
||||||
|
width: 300px;
|
||||||
|
max-width: 40vw;
|
||||||
|
}
|
||||||
|
#timelord .time {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
.sp-container {
|
.sp-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
30
src/api.ts
30
src/api.ts
|
@ -4,19 +4,22 @@ export const MYRADIO_NON_API_BASE =
|
||||||
process.env.REACT_APP_MYRADIO_NONAPI_BASE!;
|
process.env.REACT_APP_MYRADIO_NONAPI_BASE!;
|
||||||
export const MYRADIO_BASE_URL =
|
export const MYRADIO_BASE_URL =
|
||||||
process.env.REACT_APP_MYRADIO_BASE!;
|
process.env.REACT_APP_MYRADIO_BASE!;
|
||||||
|
export const BROADCAST_API_BASE_URL =
|
||||||
|
process.env.REACT_APP_BROADCAST_API_BASE!;
|
||||||
const MYRADIO_API_KEY = process.env.REACT_APP_MYRADIO_KEY!;
|
const MYRADIO_API_KEY = process.env.REACT_APP_MYRADIO_KEY!;
|
||||||
|
|
||||||
class ApiException extends Error {}
|
class ApiException extends Error {}
|
||||||
|
|
||||||
export async function myradioRequest(
|
export async function apiRequest(
|
||||||
url: string,
|
url: string,
|
||||||
method: "GET" | "POST" | "PUT",
|
method: "GET" | "POST" | "PUT",
|
||||||
params: any
|
params: any,
|
||||||
|
need_auth: boolean = true
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
let req = null;
|
let req = null;
|
||||||
if (method === "GET") {
|
if (method === "GET") {
|
||||||
req = fetch(url + qs.stringify(params, { addQueryPrefix: true }), {
|
req = fetch(url + qs.stringify(params, { addQueryPrefix: true }), {
|
||||||
credentials: "include"
|
credentials: (need_auth ? "include" : "omit")
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const body = JSON.stringify(params);
|
const body = JSON.stringify(params);
|
||||||
|
@ -27,7 +30,7 @@ export async function myradioRequest(
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json; charset=UTF-8"
|
"Content-Type": "application/json; charset=UTF-8"
|
||||||
},
|
},
|
||||||
credentials: "include"
|
credentials: (need_auth ? "include" : "omit")
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return await req;
|
return await req;
|
||||||
|
@ -38,7 +41,22 @@ export async function myradioApiRequest(
|
||||||
method: "GET" | "POST" | "PUT",
|
method: "GET" | "POST" | "PUT",
|
||||||
params: any
|
params: any
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const res = await myradioRequest(MYRADIO_BASE_URL + endpoint, method, params);
|
const res = await apiRequest(MYRADIO_BASE_URL + endpoint, method, params);
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.status === "OK") {
|
||||||
|
return json.payload;
|
||||||
|
} else {
|
||||||
|
console.error(json.payload);
|
||||||
|
throw new ApiException("Request failed!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function broadcastApiRequest(
|
||||||
|
endpoint: string,
|
||||||
|
method: "GET" | "POST" | "PUT",
|
||||||
|
params: any
|
||||||
|
): Promise<any> {
|
||||||
|
const res = await apiRequest(BROADCAST_API_BASE_URL + endpoint, method, params, false);
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (json.status === "OK") {
|
if (json.status === "OK") {
|
||||||
return json.payload;
|
return json.payload;
|
||||||
|
@ -183,7 +201,7 @@ export function getAuxPlaylists(): Promise<Array<ManagedPlaylist>> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadAuxLibrary(libraryId: string): Promise<AuxItem[]> {
|
export function loadAuxLibrary(libraryId: string): Promise<AuxItem[]> {
|
||||||
return myradioRequest(MYRADIO_NON_API_BASE + "/NIPSWeb/load_aux_lib", "GET", {
|
return apiRequest(MYRADIO_NON_API_BASE + "/NIPSWeb/load_aux_lib", "GET", {
|
||||||
libraryid: libraryId
|
libraryid: libraryId
|
||||||
}).then(res => res.json());
|
}).then(res => res.json());
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||||
import { AppThunk } from "../store";
|
import { AppThunk } from "../store";
|
||||||
import { myradioApiRequest } from "../api";
|
import { myradioApiRequest, broadcastApiRequest } from "../api";
|
||||||
import { WebRTCStreamer } from "./rtc_streamer";
|
import { WebRTCStreamer } from "./rtc_streamer";
|
||||||
import * as MixerState from "../mixer/state";
|
import * as MixerState from "../mixer/state";
|
||||||
import * as NavbarState from "../navbar/state";
|
import * as NavbarState from "../navbar/state";
|
||||||
|
@ -9,15 +9,31 @@ import { RecordingStreamer } from "./recording_streamer";
|
||||||
|
|
||||||
export let streamer: WebRTCStreamer | null = null;
|
export let streamer: WebRTCStreamer | null = null;
|
||||||
|
|
||||||
|
export type BroadcastStageEnum =
|
||||||
|
| "NOT_REGISTERED"
|
||||||
|
| "REGISTERED"
|
||||||
|
| "FAILED_REGISTRATION";
|
||||||
|
|
||||||
|
|
||||||
interface BroadcastState {
|
interface BroadcastState {
|
||||||
|
stage: BroadcastStageEnum;
|
||||||
|
connID: number | null;
|
||||||
tracklisting: boolean;
|
tracklisting: boolean;
|
||||||
connectionState: ConnectionStateEnum;
|
connectionState: ConnectionStateEnum;
|
||||||
recordingState: ConnectionStateEnum;
|
recordingState: ConnectionStateEnum;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Overall states:
|
||||||
|
hasn't registered
|
||||||
|
registered
|
||||||
|
on air
|
||||||
|
|
||||||
|
*/
|
||||||
const broadcastState = createSlice({
|
const broadcastState = createSlice({
|
||||||
name: "Broadcast",
|
name: "Broadcast",
|
||||||
initialState: {
|
initialState: {
|
||||||
|
stage: "NOT_REGISTERED",
|
||||||
|
connID: null,
|
||||||
tracklisting: false,
|
tracklisting: false,
|
||||||
connectionState: "NOT_CONNECTED",
|
connectionState: "NOT_CONNECTED",
|
||||||
recordingState: "NOT_CONNECTED"
|
recordingState: "NOT_CONNECTED"
|
||||||
|
@ -26,6 +42,14 @@ const broadcastState = createSlice({
|
||||||
toggleTracklisting(state) {
|
toggleTracklisting(state) {
|
||||||
state.tracklisting = !state.tracklisting;
|
state.tracklisting = !state.tracklisting;
|
||||||
},
|
},
|
||||||
|
setConnID(state, action: PayloadAction<number | null>) {
|
||||||
|
state.connID = action.payload;
|
||||||
|
if (action.payload != null) {
|
||||||
|
state.stage = "REGISTERED"
|
||||||
|
} else {
|
||||||
|
state.stage = "NOT_REGISTERED"
|
||||||
|
}
|
||||||
|
},
|
||||||
setConnectionState(state, action: PayloadAction<ConnectionStateEnum>) {
|
setConnectionState(state, action: PayloadAction<ConnectionStateEnum>) {
|
||||||
state.connectionState = action.payload;
|
state.connectionState = action.payload;
|
||||||
},
|
},
|
||||||
|
@ -41,6 +65,60 @@ export interface TrackListItem {
|
||||||
audiologid: number;
|
audiologid: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const registerTimeslot = (): AppThunk => async (dispatch, getState) => {
|
||||||
|
if (getState().broadcast.stage === "NOT_REGISTERED") {
|
||||||
|
var state = getState().session;
|
||||||
|
const memberid = state.currentUser?.memberid;
|
||||||
|
const timeslotid = state.currentTimeslot?.timeslot_id;
|
||||||
|
console.log("Attempting to Register for Broadcast.");
|
||||||
|
var sourceid = 4; // TODO: make UI for this.
|
||||||
|
var connID = (await sendBroadcastRegister(timeslotid, memberid, sourceid));
|
||||||
|
if (connID !== undefined) {
|
||||||
|
dispatch(broadcastState.actions.setConnID(connID["connid"]));
|
||||||
|
dispatch(startStreaming());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cancelTimeslot = (): AppThunk => async (dispatch, getState) => {
|
||||||
|
if (getState().broadcast.stage === "REGISTERED") {
|
||||||
|
console.log("Attempting to Cancel Broadcast.");
|
||||||
|
var response = (await sendBroadcastCancel(getState().broadcast.connID));
|
||||||
|
dispatch(stopStreaming());
|
||||||
|
if (response != null) {
|
||||||
|
dispatch(broadcastState.actions.setConnID(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function sendBroadcastRegister(timeslotid: number | undefined, memberid: number | undefined, sourceid: number): Promise<any> {
|
||||||
|
return broadcastApiRequest("/registerTimeslot", "POST", {
|
||||||
|
memberid: memberid,
|
||||||
|
timeslotid: timeslotid,
|
||||||
|
sourceid: sourceid
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function sendBroadcastCancel(connid: number | null): Promise<string | null> {
|
||||||
|
return broadcastApiRequest("/cancelTimeslot", "POST", {
|
||||||
|
connid: connid
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const { toggleTracklisting } = broadcastState.actions;
|
export const { toggleTracklisting } = broadcastState.actions;
|
||||||
|
|
||||||
export const tracklistStart = (
|
export const tracklistStart = (
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import React, { useRef, useEffect } from "react";
|
import React, { useRef, useEffect } from "react";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import Clock from 'react-live-clock';
|
||||||
|
|
||||||
import { RootState } from "../rootReducer";
|
import { RootState } from "../rootReducer";
|
||||||
|
|
||||||
import * as BroadcastState from "../broadcast/state";
|
import * as BroadcastState from "../broadcast/state";
|
||||||
|
@ -16,7 +18,7 @@ export function NavBar() {
|
||||||
const redirect_url = encodeURIComponent(window.location.toString());
|
const redirect_url = encodeURIComponent(window.location.toString());
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="navbar-nav">
|
<div className="navbar-nav navbar-">
|
||||||
<a className="navbar-brand" href="/">
|
<a className="navbar-brand" href="/">
|
||||||
<img
|
<img
|
||||||
src="//ury.org.uk/myradio/img/URY.svg"
|
src="//ury.org.uk/myradio/img/URY.svg"
|
||||||
|
@ -28,6 +30,26 @@ export function NavBar() {
|
||||||
<a className="navbar-brand" href="/">
|
<a className="navbar-brand" href="/">
|
||||||
<img src={appLogo} height="28" alt="Web Studio Logo" />
|
<img src={appLogo} height="28" alt="Web Studio Logo" />
|
||||||
</a>
|
</a>
|
||||||
|
<div id="timelord" onClick={() => {
|
||||||
|
switch (broadcastState.stage) {
|
||||||
|
case "NOT_REGISTERED":
|
||||||
|
dispatch(
|
||||||
|
BroadcastState.registerTimeslot()
|
||||||
|
)
|
||||||
|
break;
|
||||||
|
case "REGISTERED":
|
||||||
|
dispatch(
|
||||||
|
BroadcastState.cancelTimeslot()
|
||||||
|
)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<div className="time"><Clock format={'HH:mm:ss'} ticking={true} timezone={'EU/London'} /></div>
|
||||||
|
<div className="message">
|
||||||
|
{broadcastState.stage === "NOT_REGISTERED" && "Register for show"}
|
||||||
|
{broadcastState.stage === "REGISTERED" && "Cancel show"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="nav navbar-nav navbar-right">
|
<ul className="nav navbar-nav navbar-right">
|
||||||
|
@ -193,7 +215,7 @@ export function CombinedNavAlertBar() {
|
||||||
<>
|
<>
|
||||||
<AlertBar />
|
<AlertBar />
|
||||||
<header className="navbar navbar-ury navbar-expand-md p-0 bd-navbar">
|
<header className="navbar navbar-ury navbar-expand-md p-0 bd-navbar">
|
||||||
<nav className="container">
|
<nav className="container-fluid">
|
||||||
<button
|
<button
|
||||||
className="navbar-toggler"
|
className="navbar-toggler"
|
||||||
type="button"
|
type="button"
|
||||||
|
|
29
yarn.lock
29
yarn.lock
|
@ -6951,6 +6951,18 @@ mkdirp@^0.5.1, mkdirp@~0.5.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
minimist "^1.2.5"
|
minimist "^1.2.5"
|
||||||
|
|
||||||
|
moment-timezone@0.5.27:
|
||||||
|
version "0.5.27"
|
||||||
|
resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.27.tgz#73adec8139b6fe30452e78f210f27b1f346b8877"
|
||||||
|
integrity sha512-EIKQs7h5sAsjhPCqN6ggx6cEbs94GK050254TIJySD1bzoM5JTYDwAU1IoVOeTOL6Gm27kYJ51/uuvq1kIlrbw==
|
||||||
|
dependencies:
|
||||||
|
moment ">= 2.9.0"
|
||||||
|
|
||||||
|
moment@2.24.0, "moment@>= 2.9.0":
|
||||||
|
version "2.24.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b"
|
||||||
|
integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==
|
||||||
|
|
||||||
move-concurrently@^1.0.1:
|
move-concurrently@^1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"
|
resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"
|
||||||
|
@ -8563,7 +8575,7 @@ prompts@^2.0.1:
|
||||||
kleur "^3.0.3"
|
kleur "^3.0.3"
|
||||||
sisteransi "^1.0.4"
|
sisteransi "^1.0.4"
|
||||||
|
|
||||||
prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2:
|
prop-types@15.7.2, prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2:
|
||||||
version "15.7.2"
|
version "15.7.2"
|
||||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
|
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
|
||||||
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
|
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
|
||||||
|
@ -8851,6 +8863,16 @@ react-lifecycles-compat@^3.0.0, react-lifecycles-compat@^3.0.4:
|
||||||
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
|
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
|
||||||
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
|
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
|
||||||
|
|
||||||
|
react-live-clock@^4.0.5:
|
||||||
|
version "4.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-live-clock/-/react-live-clock-4.0.5.tgz#d53b16be62be7c4fd9b8183db31f58bffa1c9142"
|
||||||
|
integrity sha512-NsXuUAGrUPAnJJc4RWVNE63hbb9gZNO0gN0bMbI/fMT5Iq8Oc/KmnD1yV6W/7EzhlU4jmErng/tVWmp0pDxYHw==
|
||||||
|
dependencies:
|
||||||
|
moment "2.24.0"
|
||||||
|
moment-timezone "0.5.27"
|
||||||
|
prop-types "15.7.2"
|
||||||
|
react-moment "0.9.7"
|
||||||
|
|
||||||
react-modal@^3.11.2:
|
react-modal@^3.11.2:
|
||||||
version "3.11.2"
|
version "3.11.2"
|
||||||
resolved "https://registry.yarnpkg.com/react-modal/-/react-modal-3.11.2.tgz#bad911976d4add31aa30dba8a41d11e21c4ac8a4"
|
resolved "https://registry.yarnpkg.com/react-modal/-/react-modal-3.11.2.tgz#bad911976d4add31aa30dba8a41d11e21c4ac8a4"
|
||||||
|
@ -8861,6 +8883,11 @@ react-modal@^3.11.2:
|
||||||
react-lifecycles-compat "^3.0.0"
|
react-lifecycles-compat "^3.0.0"
|
||||||
warning "^4.0.3"
|
warning "^4.0.3"
|
||||||
|
|
||||||
|
react-moment@0.9.7:
|
||||||
|
version "0.9.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-moment/-/react-moment-0.9.7.tgz#ca570466595b1aa4f7619e62da18b3bb2de8b6f3"
|
||||||
|
integrity sha512-ifzUrUGF6KRsUN2pRG5k56kO0mJBr8kRkWb0wNvtFIsBIxOuPxhUpL1YlXwpbQCbHq23hUu6A0VEk64HsFxk9g==
|
||||||
|
|
||||||
react-popper@^1.3.6:
|
react-popper@^1.3.6:
|
||||||
version "1.3.7"
|
version "1.3.7"
|
||||||
resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-1.3.7.tgz#f6a3471362ef1f0d10a4963673789de1baca2324"
|
resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-1.3.7.tgz#f6a3471362ef1f0d10a4963673789de1baca2324"
|
||||||
|
|
Loading…
Reference in a new issue