diff --git a/LDAP.md b/LDAP.md
index 9eb99377..2e20604b 100644
--- a/LDAP.md
+++ b/LDAP.md
@@ -2,38 +2,12 @@
Fireshare has LDAP support. The following environment variables are required to configure it:
-### `LDAP_ENABLE`
-
-Whether to enable LDAP support.
-Default: `false`
-
-### `LDAP_URL`
-
-LDAP Server connection URL.
-Example: `ldap://localhost:3890`
-
-### `LDAP_BINDDN`
-
-DN for the admin user.
-Example: `uid=admin,ou=people`
-
-### `LDAP_PASSWORD`
-
-Password for the admin user.
-
-### `LDAP_BASEDN`
-
-Base DN
-Example: `dc=example,dc=com`
-
-### `LDAP_USER_FILTER`
-
-User filter for LDAP login
-`{input}` replaced by username the user put in the webui
-Example for match email and uid: `(&(|(uid={input})(mail={input}))(objectClass=person))`
-
-### `LDAP_ADMIN_GROUP`
-
-LDAP group to be admin in fireshare. If not provided, everyone is admin.
-Uses `memberOf`
-Example: `lldap_admin`
+| Environment Variable | Description | Example | Default |
+|----------------------|-------------|---------|----------|
+| `LDAP_ENABLE` | Whether to enable LDAP support. || false |
+| `LDAP_URL` | LDAP Server connection URL |`ldap://localhost:3890`| |
+| `LDAP_BINDDN` | DN for the admin user |`uid=admin,ou=people` | |
+| `LDAP_PASSWORD` | Password for the admin user. | |
+| `LDAP_BASEDN` | Base DN |`dc=example,dc=com` | |
+| `LDAP_USER_FILTER` | User filter for LDAP login. `{input}` is replaced by the UI username. | |
+| `LDAP_ADMIN_GROUP` | LDAP group for admin privileges via `memberOf`. If empty, everyone is admin. | |
\ No newline at end of file
diff --git a/Notifications.md b/Notifications.md
new file mode 100644
index 00000000..84126734
--- /dev/null
+++ b/Notifications.md
@@ -0,0 +1,58 @@
+## Notifications
+Firesahre has a limited setup for notifications when a new video is uploaded. Primarily Discord and a Generic Webhook. Since Gaming and Discord is so ubiquitous it makes sense to have a dedicated Discord channel just for clip highlights to share with your friends. For this reason there is the Discord integration, to notify a channel when a new video has been uploaded. A similar premise has been made for the Generic Webhook. There are many notification systems, and to program them all would be an undertaking, so with the Generic Webhook, this allows what should be a means to still notify any system that can take a HTTP-POST and a JSON payload for webhooks.
+### Discord
+The Discord Notification integration is very simple, you just add the webhook URL to the channel you want it to be send to. You can learn how to generate a webhook URL for your Discord server and channel here: [Discord - Webhook Documentation](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks)
+
+Docker ENV example:
+
+`DISCORD_WEBHOOK_URL='https://discord.com/api/webhooks/123456789/abcdefghijklmnopqrstuvwxyz'`
+
+### Generic Webhook
+For any other service you would want to send a notification to, that also supports a generic JSON payload-based webhook. Please note, you will have to set not only the POST URL but also the JSON Payload. If you do not know what this is you can learn more here:
+
+Basically, you will need to enter valid JSON data into the "Generic Webhook JSON Payload" box on the integrations page, with the JSON payload that will work for your specific app or service. Please consult the webhook documentation for the service you are wanting to use, if they offer webhook support. For instance, the JSON data could look something like the following:
+
+```
+{
+ "Title": "Fireshare",
+ "message": "New Video Uploaded to Fireshare",
+}
+```
+
+There is one variable avaliable that can be used in the JSON payload that can inject the video perma link. This could be useful that when you see the notification on your service you have a direct link to this new video. This can be achived using this exact format anywhere it makes sense: `[video_url]`
+
+Example:
+```
+{
+ "Title": "Fireshare",
+ "message": "New Video Uploaded to Fireshare [video_url]",
+}
+```
+What this will look like send to your service as a json payload:
+
+```
+{
+ "Title": "Fireshare",
+ "message": "New Video Uploaded to Fireshare https://yourdomain.com/w/c415d34530d15b2892fa4a4e037b6c05",
+}
+```
+
+**Syntax Note**
+
+Please keep in mind that the json payload is not a simple string, it has key/value pairs that have string in it. This means these strings are usually wrapped in either single quotes `'` or double `"`. Meaning if you are just pasting your json via the gui, just pick one and fireshare will take care of the rest. However for Docker ENVs you need to make sure you are choosing one for the total encapuslation of the json, and then another for the actual internal json strings.
+
+Example:
+
+```
+GENERIC_WEBHOOK_PAYLOAD='{"Title": "Fireshare", "message": "New Video Uploaded to Fireshare [video_url]"}'
+#Notice this is a sinlge line ^
+```
+
+
+**Full Docker ENV example:**
+
+```
+GENERIC_WEBHOOK_URL='https://webhook.com/at/endpoint12345'
+GENERIC_WEBHOOK_PAYLOAD='{"Title": "Fireshare", "message": "New Video Uploaded to Fireshare [video_url]"}'
+# You must have both ENVs filled in for Generic Webhook to work
+```
diff --git a/README.md b/README.md
index 1b03f10e..868ce557 100644
--- a/README.md
+++ b/README.md
@@ -44,9 +44,18 @@
- Video Cropping
- Video Tags for improved search and categorization
- Open Graph metadata for rich link previews
+- [Notifications to Discord and others](./Notifications.md)
- RSS feed for new public videos
-- LDAP support
-- Optional video transcoding with CPU or NVIDIA GPU
+- [LDAP support](./LDAP.md)
+- Optional [video transcoding with CPU or NVIDIA GPU](#transcoding-optional)
+
+# Navigation
+
+- [Installation](#installation)
+- [Configuration](#configuration)
+- [Demo](#demo)
+- [Contributing](#contributing)
+
Dashboard
@@ -112,6 +121,11 @@ Open `http://localhost:8080`.
## Configuration
+- [LDAP](#ldap)
+- [Transcoding](#transcoding-optional)
+- [Docker ENV Variables](#docker-environment-variables)
+
+### LDAP
- LDAP setup: [LDAP.md](./LDAP.md)
### Transcoding (Optional)
@@ -157,6 +171,27 @@ When GPU mode is enabled, Fireshare selects the best available encoder:
- H.264 with CPU — Most compatible, faster encoding
- AV1 with CPU — Best compression, slower
+### Docker Environment Variables
+
+| Environment Variable | Description | Default | Required |
+|----------------------|-------------|---------|----------|
+| **App Configuration** | | |
+| `DOMAIN` | The base URL or domain name where the instance is hosted. This is needed for things like link sharing, and notifications to work properly| |
+| `STEAMGRIDDB_API_KEY` | API key for SteamGridDB integration to fetch game metadata and assets. | |
+| **Storage** | | |
+| `DATA_DIRECTORY` | Absolute path to the directory where application database and metadata are stored. | `$(pwd)/dev_root/dev_data/` | Yes |
+| `VIDEO_DIRECTORY` | Absolute path to the source directory containing raw video files. | `$(pwd)/dev_root/dev_videos/` | Yes |
+| `PROCESSED_DIRECTORY` | Absolute path to the directory where optimized/transcoded videos are stored. | `$(pwd)/dev_root/dev_processed/` | Yes |
+| `THUMBNAIL_VIDEO_LOCATION` | The timestamp (in seconds) used to capture the video thumbnail preview. | `50` |
+| **Security** | | |
+| `ADMIN_USERNAME` | The username for the initial administrative account. | `admin` | Yes |
+| `ADMIN_PASSWORD` | The password for the initial administrative account. | `admin` | Yes |
+| LDAP | See [LDAP.md](./LDAP.md) for full LDAP configuration instructions
+| **Integrations** | | |
+| `DISCORD_WEBHOOK_URL` | Discord Server/Channel webhook URL used to send a notification of a new fireshare upload. [See Docs](./Notifications.md#discord) | |
+| `GENERIC_WEBHOOK_URL` | Notification Integration, to send a generic webhook POST. Has to be used with `GENERIC_WEBHOOK_PAYLOAD` to work. [See Docs](./Notifications.md#generic-webhook) | |
+| `GENERIC_WEBHOOK_PAYLOAD` | JSON Based payload that will be POSTed to webhook url. Please [See Docs](./Notifications.md#generic-webhook) for full example and payload options | |
+
## Local Development
Requirements: Python 3, Node.js, and npm.
@@ -216,9 +251,11 @@ proxy_read_timeout 999999s;
If you use a different proxy, apply equivalent upload size and timeout settings there.
+
+
---
[card-view]: .github/images/card-view.png
[folders]: .github/images/folders.png
[folders-game]: .github/images/folders-game.png
-[edit]: .github/images/edit-details.png
+[edit]: .github/images/edit-details.png
\ No newline at end of file
diff --git a/app/client/package-lock.json b/app/client/package-lock.json
index 6e49859e..74998181 100644
--- a/app/client/package-lock.json
+++ b/app/client/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "fireshare",
- "version": "1.5.2",
+ "version": "1.5.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fireshare",
- "version": "1.5.2",
+ "version": "1.5.4",
"dependencies": {
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
diff --git a/app/client/src/views/Settings.js b/app/client/src/views/Settings.js
index a62cb24e..6b737335 100644
--- a/app/client/src/views/Settings.js
+++ b/app/client/src/views/Settings.js
@@ -24,6 +24,7 @@ import SnackbarAlert from '../components/alert/SnackbarAlert'
import SaveIcon from '@mui/icons-material/Save'
import SensorsIcon from '@mui/icons-material/Sensors'
import RssFeedIcon from '@mui/icons-material/RssFeed'
+import SendIcon from '@mui/icons-material/Send';
import SportsEsportsIcon from '@mui/icons-material/SportsEsports'
import CalendarMonthIcon from '@mui/icons-material/CalendarMonth'
import MoreVertIcon from '@mui/icons-material/MoreVert'
@@ -46,6 +47,25 @@ const isValidDiscordWebhook = (url) => {
const regex = /^https:\/\/discord\.com\/api\/webhooks\/\d{17,20}\/[\w-]{60,}$/
return regex.test(url)
}
+const isValidGenericWebhook = (url) => {
+ const regex = /^https?:\/\/[^\s\/$.?#].[^\s]*$/;
+ return regex.test(url)
+}
+const isValidJson = (str) => {
+ try {
+ JSON.parse(str);
+ return true;
+ } catch (e) {
+ return false;
+ }
+};
+const jsonPlaceholder =
+`#Example JSON Data:
+{
+ "title": "Fireshare",
+ "body": "New Fireshare Video Uploaded!",
+ "type": "info"
+}`;
const Settings = () => {
const [alert, setAlert] = React.useState({ open: false })
@@ -53,6 +73,8 @@ const Settings = () => {
const [updatedConfig, setUpdatedConfig] = React.useState({})
const [updateable, setUpdateable] = React.useState(false)
const [discordUrl, setDiscordUrl] = React.useState('')
+ const [webhookUrl, setWebhookUrl] = React.useState('')
+ const [webhookJson, setWebhookJson] = React.useState('')//needed?
const [showSteamGridKey, setShowSteamGridKey] = React.useState(false)
const [activeTab, setActiveTab] = React.useState(0)
const [transcodingStatus, setTranscodingStatus] = React.useState({
@@ -65,6 +87,74 @@ const Settings = () => {
const [deleteMenuRuleId, setDeleteMenuRuleId] = React.useState(null)
const [editingFolder, setEditingFolder] = React.useState(null)
const isDiscordUsed = discordUrl.trim() !== ''
+ const isWebhookUsed = webhookUrl.trim() !== ''
+
+const handleTestDiscordWebhook = async () => {
+ const urlToTest = discordUrl || updatedConfig.integrations?.discord_webhook_url;
+ if (!urlToTest) {
+ setAlert({ open: true, message: 'Please enter a Discord Webhook URL first', type: 'error' });
+ return;
+ }
+ try {
+ const response = await fetch('/api/test-discord-webhook', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ webhook_url: urlToTest,
+ video_url: "https://fireshare.test.worked"
+ }),
+ });
+ const result = await response.json();
+ if (response.ok) {
+ setAlert({ open: true, message: 'Discord Test Sent!', type: 'success' });
+ } else {
+ setAlert({ open: true, message: result.error || 'Discord test failed', type: 'error' });
+ }
+ } catch (err) {
+ console.error("Connection failed:", err);
+ setAlert({ open: true, message: 'Network error connecting to server', type: 'error' });
+ }
+};
+
+ const handleTestWebhook = async () => {
+ let payloadToTest = {};
+ try {
+ payloadToTest = webhookJson ? JSON.parse(webhookJson) : (updatedConfig.integrations?.generic_webhook_payload || {});
+ } catch (e) {
+ setAlert({ open: true, message: 'Invalid JSON in payload field', type: 'error' });
+ return;
+ }
+ const testData = {
+ webhook_url: webhookUrl,
+ video_url: "https://fireshare.test.worked",
+ payload: payloadToTest
+ };
+ if (!webhookUrl) {
+ setAlert({ open: true, message: 'Please enter a Webhook URL first', type: 'error' });
+ return;
+ }
+ try {
+ const response = await fetch('/api/test-webhook', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(testData),
+ });
+
+ const result = await response.json();
+ if (response.ok) {
+ setAlert({ open: true, message: 'Test Webhook Sent!', type: 'success' });
+ } else {
+ setAlert({ open: true, message: result.error || 'Failed to send test', type: 'error' });
+ }
+ } catch (err) {
+ console.error("Connection failed:", err);
+ setAlert({ open: true, message: 'Network error connecting to server', type: 'error' });
+ }
+ };
React.useEffect(() => {
async function fetch() {
@@ -119,6 +209,19 @@ const Settings = () => {
}
}, [updatedConfig])
+ React.useEffect(() => {
+ if (updatedConfig.integrations) {
+ if (updatedConfig.integrations.generic_webhook_url) {
+ setWebhookUrl(updatedConfig.integrations.generic_webhook_url);
+ }
+
+ if (updatedConfig.integrations.generic_webhook_payload) {
+ const jsonString = JSON.stringify(updatedConfig.integrations.generic_webhook_payload, null, 2);
+ setWebhookJson(jsonString);
+ }
+ }
+ }, [updatedConfig]);
+
const handleSave = async () => {
try {
await ConfigService.updateConfig(updatedConfig)
@@ -544,6 +647,7 @@ const Settings = () => {
{/* Integrations */}
{activeTab === 2 && (
+ Notifications {
helperText={
discordUrl !== '' && !isValidDiscordWebhook(discordUrl)
? 'Webhook Format should look like: https://discord.com/api/webhooks/12345/fj8903k'
- : ' '
+ :
+
+ Get Discord Webhook for you Server Channel - {' '}
+
+ Docs
+
+
}
onChange={(e) => {
const url = e.target.value
@@ -566,6 +681,102 @@ const Settings = () => {
}))
}}
/>
+ }
+ // Change this from handleCopyRssFeedUrl to your new function
+ onClick={handleTestDiscordWebhook}
+ sx={{
+ borderColor: 'rgba(255, 255, 255, 0.23)',
+ color: '#fff',
+ '&:hover': {
+ borderColor: '#fff',
+ backgroundColor: 'rgba(255, 255, 255, 0.08)'
+ }
+ }}
+ >
+ Test Discord
+
+
+
+
+
+ Used for API POST to Generic Webhook Endpoint - {' '}
+
+ Example
+
+
+ }
+ onChange={(e) => {
+ const url = e.target.value
+ setWebhookUrl(url)
+ setUpdatedConfig((prev) => ({
+ ...prev,
+ integrations: {
+ ...prev.integrations,
+ generic_webhook_url: url,
+ },
+ }))
+ }}
+ />
+ {
+ const val = e.target.value;
+ setWebhookJson(val);
+ if (isValidJson(val)) {
+ setUpdatedConfig((prev) => ({
+ ...prev,
+ integrations: {
+ ...prev.integrations,
+ generic_webhook_payload: JSON.parse(val),
+ },
+ }));
+ }
+ }}
+ />
+ }
+ onClick={handleTestWebhook}
+ sx={{
+ borderColor: 'rgba(255, 255, 255, 0.23)',
+ color: '#fff',
+ '&:hover': {
+ borderColor: '#fff',
+ backgroundColor: 'rgba(255, 255, 255, 0.08)'
+ }
+ }}
+ >
+ Test Webhook
+
+
+
+
+ Game Tagging {
}}
/>
+ RSS\x7F\x00-\x1F]", "-", filename)
@@ -2807,3 +2807,43 @@ def bulk_remove_tag():
def after_request(response):
response.headers.add('Accept-Ranges', 'bytes')
return response
+
+@api.route('/api/test-discord-webhook', methods=['POST'])
+def test_discord_webhook():
+ data = request.get_json()
+ webhook_url = data.get('webhook_url')
+ video_url = data.get('video_url', 'https://fireshare.test.worked')
+
+ if not webhook_url:
+ return jsonify({"error": "No Discord Webhook URL provided"}), 400
+ try:
+ result = send_discord_webhook(webhook_url, video_url)
+ if result and isinstance(result, dict):
+ if result.get("status") == "success":
+ return jsonify({"message": "Discord Webhook sent successfully!"}), 200
+ else:
+ return jsonify({"error": result.get("message", "Unknown discord error")}), 500
+ else:
+ return jsonify({"error": "Webhook function did not return a valid response object"}), 500
+ except Exception as e:
+ print(f"DEBUG ERROR: {str(e)}")
+ return jsonify({"error": f"Internal Server Error: {str(e)}"}), 500
+
+@api.route('/api/test-webhook', methods=['POST'])
+def test_webhook():
+ data = request.get_json()
+ webhook_url = data.get('webhook_url')
+ video_url = data.get('video_url')
+ payload = data.get('payload')
+
+ if not webhook_url:
+ return jsonify({"error": "No Webhook URL provided"}), 400
+ try:
+ result = send_generic_webhook(webhook_url, video_url, payload)
+ if result.get("status") == "success":
+ return jsonify({"message": "Webhook sent successfully!"}), 200
+ else:
+ return jsonify({"error": result.get("message")}), 500
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+
\ No newline at end of file
diff --git a/app/server/fireshare/cli.py b/app/server/fireshare/cli.py
index 993a66db..6584aba4 100755
--- a/app/server/fireshare/cli.py
+++ b/app/server/fireshare/cli.py
@@ -141,13 +141,25 @@ def send_discord_webhook(webhook_url=None, video_url=None):
"username": "Fireshare",
"avatar_url": "https://github.com/ShaneIsrael/fireshare/raw/develop/app/client/src/assets/logo_square.png",
}
-
try:
response = requests.post(webhook_url, json=payload)
response.raise_for_status()
print("Webhook sent successfully.")
+ return {"status": "success", "message": "Webhook sent successfully."}
except requests.exceptions.RequestException as e:
print(f"Failed to send webhook: {e}")
+ return {"status": "error", "message": str(e)}
+
+def send_generic_webhook(webhook_url, video_url=None, custom_payload=None):
+ payload = custom_payload if custom_payload is not None else {}
+ if not payload and video_url:
+ payload["content"] = video_url
+ try:
+ response = requests.post(webhook_url, json=payload)
+ response.raise_for_status()
+ return {"status": "success", "code": response.status_code}
+ except requests.exceptions.RequestException as e:
+ return {"status": "error", "message": str(e)}
def get_public_watch_url(video_id, config, host):
shareable_link_domain = config.get("ui_config", {}).get("shareable_link_domain", "")
@@ -195,6 +207,8 @@ def scan_videos(root):
config = json.load(config_file)
video_config = config["app_config"]["video_defaults"]
discord_webhook_url = config["integrations"]["discord_webhook_url"]
+ generic_webhook_url = config["integrations"]["generic_webhook_url"]
+ generic_webhook_payload = config["integrations"]["generic_webhook_payload"]
config_file.close()
if not video_links.is_dir():
@@ -282,6 +296,20 @@ def scan_videos(root):
logger.info(f"Posting to Discord webhook")
video_url = get_public_watch_url(nv.video_id, config, domain)
send_discord_webhook(webhook_url=discord_webhook_url, video_url=video_url)
+ if generic_webhook_url:
+ for nv in new_videos:
+ logger.info(f"Posting to Generic webhook")
+ video_url = get_public_watch_url(nv.video_id, config, domain)
+ payload_str = json.dumps(generic_webhook_payload)
+ #Replaces plain text json [[video_url]] with the real video_url python var
+ processed_payload_str = payload_str.replace("[[video_url]]", video_url)
+ final_payload = json.loads(processed_payload_str)
+ send_generic_webhook(
+ webhook_url=generic_webhook_url,
+ video_url=video_url,
+ custom_payload=final_payload
+ )
+
# Auto-tag new videos based on folder rules
auto_tagged = set()
@@ -360,6 +388,8 @@ def scan_video(ctx, path, tag_ids, game_id, title):
config = json.load(config_file)
video_config = config["app_config"]["video_defaults"]
discord_webhook_url = config["integrations"]["discord_webhook_url"]
+ generic_webhook_url = config["integrations"]["generic_webhook_url"]
+ generic_webhook_payload = config["integrations"]["generic_webhook_payload"]
config_file.close()
@@ -488,6 +518,19 @@ def scan_video(ctx, path, tag_ids, game_id, title):
logger.info(f"Posting to Discord webhook")
video_url = get_public_watch_url(video_id, config, domain)
send_discord_webhook(webhook_url=discord_webhook_url, video_url=video_url)
+
+ if generic_webhook_url:
+ logger.info(f"Posting to Generic webhook")
+ video_url = get_public_watch_url(video_id, config, domain)
+ payload_str = json.dumps(generic_webhook_payload)
+ #Replaces plain text json [[video_url]] with the real video_url python var
+ processed_payload_str = payload_str.replace("[[video_url]]", video_url)
+ final_payload = json.loads(processed_payload_str)
+ send_generic_webhook(
+ webhook_url=generic_webhook_url,
+ video_url=video_url,
+ custom_payload=final_payload
+ )
if current_app.config.get('ENABLE_TRANSCODING'):
auto_transcode = config.get('transcoding', {}).get('auto_transcode', True)
diff --git a/app/server/fireshare/constants.py b/app/server/fireshare/constants.py
index 6d6fa5ba..7f5778d7 100644
--- a/app/server/fireshare/constants.py
+++ b/app/server/fireshare/constants.py
@@ -14,6 +14,8 @@
},
"integrations": {
"discord_webhook_url": "",
+ "generic_webhook_url": "",
+ "generic_webhook_payload": {},
"steamgriddb_api_key": "",
},
"rss_config": {