Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ Direct links copied from the link copy buttons in Fireshare will allow websites
<p align="center">
<img src=".github/images/ogg-data.png" alt="Logo">
</p>
<h2 align="center">RSS Feed Support</h2>
<p align="center">
Stay up to date in your favorite RSS reader. Fireshare publishes a feed of the
latest public videos so you can subscribe and get new uploads automatically.
</p>


<h2 align="center">LDAP Authentication Support</h2>
<p align="center">
Expand Down
10 changes: 5 additions & 5 deletions app/client/src/components/nav/Navbar20.js
Original file line number Diff line number Diff line change
Expand Up @@ -429,8 +429,8 @@ function Navbar20({
<Divider />




{folderSize !== null ? (
open ? (
<Box
Expand Down Expand Up @@ -502,7 +502,7 @@ function Navbar20({
</Typography>
</Box>
</Tooltip>

)
) : (
<Box
Expand All @@ -520,7 +520,7 @@ function Navbar20({
fontSize: 13,
}}
>

{open ? <Typography variant="body2" color="textSecondary">Loading Disk Usage...</Typography> : <SyncIcon
sx={{
animation: "spin 2s linear infinite",
Expand All @@ -533,7 +533,7 @@ function Navbar20({
},
},
}}
/> }
/>}
</Box>

)}
Expand Down
132 changes: 92 additions & 40 deletions app/client/src/views/Settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
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 SportsEsportsIcon from '@mui/icons-material/SportsEsports'
import CalendarMonthIcon from '@mui/icons-material/CalendarMonth'
import VisibilityIcon from '@mui/icons-material/Visibility'
Expand Down Expand Up @@ -41,9 +42,18 @@ const Settings = ({ authenticated }) => {
React.useEffect(() => {
async function fetch() {
try {
const conf = (await ConfigService.getAdminConfig()).data
setConfig(conf)
setUpdatedConfig(conf)
const res = await ConfigService.getAdminConfig()
const conf = _.cloneDeep(res.data)

// Ensure rss_config exists and has default values for comparison
if (!conf.rss_config) {
conf.rss_config = { title: '', description: '' }
}
if (!conf.rss_config.title) conf.rss_config.title = ''
if (!conf.rss_config.description) conf.rss_config.description = ''

setConfig(_.cloneDeep(conf))
setUpdatedConfig(_.cloneDeep(conf))
await checkForWarnings()
} catch (err) {
console.error(err)
Expand All @@ -53,7 +63,9 @@ const Settings = ({ authenticated }) => {
}, [])

React.useEffect(() => {
setUpdateable(!_.isEqual(config, updatedConfig))
if (config && updatedConfig) {
setUpdateable(!_.isEqual(config, updatedConfig))
}
}, [updatedConfig, config])

React.useEffect(() => {
Expand All @@ -66,11 +78,11 @@ const Settings = ({ authenticated }) => {
try {
await ConfigService.updateConfig(updatedConfig)
setUpdateable(false)
setConfig((prev) => ({ ...prev, ...updatedConfig }))
setConfig(_.cloneDeep(updatedConfig))
setAlert({ open: true, message: 'Settings Updated! Changes may take a minute to take effect.', type: 'success' })
} catch (err) {
console.error(err)
setAlert({ open: true, message: err.response.data, type: 'error' })
setAlert({ open: true, message: err.response?.data || 'Error saving settings', type: 'error' })
}
}

Expand Down Expand Up @@ -135,41 +147,41 @@ const Settings = ({ authenticated }) => {
const checkForWarnings = async () =>{
let warnings = await WarningService.getAdminWarnings()

if (Object.keys(warnings.data).length === 0)
return;
if (Object.keys(warnings.data).length === 0)
return;

for (const warning of warnings.data) {
// Check if this is the SteamGridDB warning
if (warning.includes('SteamGridDB API key not configured')) {
setAlert({
open: true,
type: 'warning',
message: (
<span>
{warning.replace('Click here to set it up.', '')}
<a
href="#steamgrid-settings"
onClick={(e) => {
e.preventDefault();
document.getElementById('steamgrid-api-key-field')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
document.getElementById('steamgrid-api-key-field')?.focus();
}}
style={{ color: '#2684FF', textDecoration: 'underline', cursor: 'pointer', marginLeft: '4px' }}
>
Click here to set it up.
</a>
</span>
),
});
} else {
setAlert({
open: true,
type: 'warning',
message: warning,
});
}
await new Promise(r => setTimeout(r, 2000)); //Without this a second Warning would instantly overwrite the first...
for (const warning of warnings.data) {
// Check if this is the SteamGridDB warning
if (warning.includes('SteamGridDB API key not configured')) {
setAlert({
open: true,
type: 'warning',
message: (
<span>
{warning.replace('Click here to set it up.', '')}
<a
href="#steamgrid-settings"
onClick={(e) => {
e.preventDefault();
document.getElementById('steamgrid-api-key-field')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
document.getElementById('steamgrid-api-key-field')?.focus();
}}
style={{ color: '#2684FF', textDecoration: 'underline', cursor: 'pointer', marginLeft: '4px' }}
>
Click here to set it up.
</a>
</span>
),
});
} else {
setAlert({
open: true,
type: 'warning',
message: warning,
});
}
await new Promise(r => setTimeout(r, 2000)); //Without this a second Warning would instantly overwrite the first...
}
}

return (
Expand Down Expand Up @@ -467,10 +479,50 @@ const Settings = ({ authenticated }) => {
),
}}
/>
<Divider />
<Box sx={{ textAlign: 'center' }}>
<Typography variant="overline" sx={{ fontWeight: 700, fontSize: 18 }}>
Feeds
</Typography>
</Box>
<TextField
size="small"
label="RSS Feed Title"
value={updatedConfig.rss_config?.title || ''}
onChange={(e) =>
setUpdatedConfig((prev) => ({
...prev,
rss_config: { ...(prev.rss_config || {}), title: e.target.value },
}))
}
/>
<TextField
size="small"
label="RSS Feed Description"
multiline
rows={2}
value={updatedConfig.rss_config?.description || ''}
onChange={(e) =>
setUpdatedConfig((prev) => ({
...prev,
rss_config: { ...(prev.rss_config || {}), description: e.target.value },
}))
}
/>
<Button
variant="outlined"
startIcon={<RssFeedIcon />}
fullWidth
onClick={() => window.open('/api/feed/rss', '_blank')}
sx={{ borderColor: 'rgba(255, 255, 255, 0.23)', color: '#fff' }}
>
Open RSS Feed
</Button>
<Divider />
<Button
variant="contained"
startIcon={<SaveIcon />}
disabled={!updateable || (!isValidDiscordWebhook(discordUrl) && isDiscordUsed) }
disabled={!updateable || (!isValidDiscordWebhook(discordUrl) && isDiscordUsed)}
onClick={handleSave}
>
Save Changes
Expand Down
64 changes: 64 additions & 0 deletions app/server/fireshare/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1603,6 +1603,70 @@ def clear_all_corrupt_status():
count = clear_all_corrupt_videos()
return jsonify({'cleared': count})

@api.route('/api/feed/rss')
def rss_feed():
# Base URL for API calls (backend)
backend_domain = f"https://{current_app.config['DOMAIN']}" if current_app.config['DOMAIN'] else request.host_url.rstrip('/')

# URL for viewing (frontend)
# If we are on localhost:5000, the user wants both the link and video to point to the public dev port (3000)
frontend_domain = backend_domain
if "localhost:5000" in frontend_domain:
frontend_domain = frontend_domain.replace("localhost:5000", "localhost:3000")
elif "127.0.0.1:5000" in frontend_domain:
frontend_domain = frontend_domain.replace("127.0.0.1:5000", "localhost:3000")

# Load custom RSS config if it exists
paths = current_app.config['PATHS']
config_path = paths['data'] / 'config.json'
rss_title = "Fireshare Feed"
rss_description = "Latest videos from Fireshare"
if config_path.exists():
try:
with config_path.open() as f:
config = json.load(f)
rss_title = config.get("rss_config", {}).get("title", rss_title)
rss_description = config.get("rss_config", {}).get("description", rss_description)
except:
pass

# Only show public and available videos
videos = Video.query.join(VideoInfo).filter(
VideoInfo.private.is_(False),
Video.available.is_(True)
).order_by(Video.created_at.desc()).limit(50).all()

rss_items = []
for video in videos:
# Construct URLs
link = f"{frontend_domain}/#/w/{video.video_id}"
# Point both player link and video stream to the frontend port (3000) as requested
video_url = f"{frontend_domain}/api/video?id={video.video_id}"
poster_url = f"{frontend_domain}/api/video/poster?id={video.video_id}"

# XML escaping for description and title is handled by Jinja2 by default,
# but we should ensure dates are in RFC 822 format.
item = {
'title': video.info.title if video.info else video.video_id,
'link': link,
'description': video.info.description if video.info and video.info.description else f"Video: {video.info.title if video.info else video.video_id}",
'pubDate': video.created_at.strftime('%a, %d %b %Y %H:%M:%S +0000') if video.created_at else '',
'guid': video.video_id,
'enclosure': {
'url': video_url,
'type': 'video/mp4' # Or appropriate mimetype
},
'media_thumbnail': poster_url
}
rss_items.append(item)

now_str = datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S +0000')

return Response(
render_template('rss.xml', items=rss_items, domain=frontend_domain, now=now_str, feed_title=rss_title, feed_description=rss_description),
mimetype='application/rss+xml'
)

@api.after_request
def after_request(response):
response.headers.add('Accept-Ranges', 'bytes')
Expand Down
4 changes: 4 additions & 0 deletions app/server/fireshare/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
"integrations": {
"discord_webhook_url": "",
"steamgriddb_api_key": "",
},
"rss_config": {
"title": "Fireshare Feed",
"description": "Latest videos from Fireshare"
}
}

Expand Down
27 changes: 27 additions & 0 deletions app/server/fireshare/templates/rss.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>{{ feed_title }}</title>
<link>{{ domain }}</link>
<description>{{ feed_description }}</description>
<language>en-us</language>
<lastBuildDate>{{ now }}</lastBuildDate>
<pubDate>{{ now }}</pubDate>
<ttl>60</ttl>

{% for item in items %}
<item>
<title>{{ item.title }}</title>
<link>{{ item.link }}</link>
<description>{{ item.description }}</description>
<pubDate>{{ item.pubDate }}</pubDate>
<guid isPermaLink="false">{{ item.guid }}</guid>
<dc:creator>Fireshare</dc:creator>
<media:content url="{{ item.enclosure.url }}" type="{{ item.enclosure.type }}" medium="video" />
<media:thumbnail url="{{ item.media_thumbnail }}" />
<enclosure url="{{ item.enclosure.url }}" length="0" type="{{ item.enclosure.type }}" />
</item>
{% endfor %}

</channel>
</rss>