From 3d786848d2d27344d4cdecf1bd0d54294c6ca160 Mon Sep 17 00:00:00 2001 From: Shane Israel Date: Fri, 5 Jun 2026 07:25:59 -0600 Subject: [PATCH 1/7] add missing login required decorator to discord webhook endpoints --- app/server/fireshare/api/misc.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/server/fireshare/api/misc.py b/app/server/fireshare/api/misc.py index 17b7f8f1..53a99475 100644 --- a/app/server/fireshare/api/misc.py +++ b/app/server/fireshare/api/misc.py @@ -253,6 +253,7 @@ def rss_feed(): @api.route('/api/test-discord-webhook', methods=['POST']) +@login_required @demo_restrict def test_discord_webhook(): data = request.get_json() @@ -276,6 +277,7 @@ def test_discord_webhook(): @api.route('/api/test-webhook', methods=['POST']) +@login_required @demo_restrict def test_webhook(): data = request.get_json() From 65a5acfe7ac5e4cb91e225a9d214ccfdde0e4b14 Mon Sep 17 00:00:00 2001 From: Shane Israel Date: Fri, 5 Jun 2026 07:28:24 -0600 Subject: [PATCH 2/7] bump version --- app/client/package-lock.json | 2 +- app/client/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/client/package-lock.json b/app/client/package-lock.json index fad82caa..d31d7a58 100644 --- a/app/client/package-lock.json +++ b/app/client/package-lock.json @@ -1,6 +1,6 @@ { "name": "fireshare", - "version": "1.6.15", + "version": "1.6.16", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/app/client/package.json b/app/client/package.json index c853a9f4..6dd4f1dd 100644 --- a/app/client/package.json +++ b/app/client/package.json @@ -1,6 +1,6 @@ { "name": "fireshare", - "version": "1.6.15", + "version": "1.6.16", "private": true, "dependencies": { "@emotion/react": "^11.9.0", From 6ed3434df1a96193581a8b010b4fdb88cb6a4610 Mon Sep 17 00:00:00 2001 From: Shane Israel Date: Fri, 5 Jun 2026 20:22:50 -0600 Subject: [PATCH 3/7] updated images created_at to use exif -> filename -> getmtime fallback, added a rescan dates action --- app/client/src/services/VideoService.js | 3 ++ app/client/src/views/Settings.js | 27 ++++++++++++++++ app/server/fireshare/api/scan.py | 43 +++++++++++++++++++++++++ app/server/fireshare/cli.py | 2 +- 4 files changed, 74 insertions(+), 1 deletion(-) diff --git a/app/client/src/services/VideoService.js b/app/client/src/services/VideoService.js index f40081f8..1991e60a 100644 --- a/app/client/src/services/VideoService.js +++ b/app/client/src/services/VideoService.js @@ -136,6 +136,9 @@ const service = { scanDates() { return Api().get('/api/manual/scan-dates') }, + rescanDates() { + return Api().get('/api/manual/rescan-dates') + }, getGameSuggestion(videoId) { return Api().get(`/api/videos/${videoId}/game/suggestion`) }, diff --git a/app/client/src/views/Settings.js b/app/client/src/views/Settings.js index bf589545..e4456826 100644 --- a/app/client/src/views/Settings.js +++ b/app/client/src/views/Settings.js @@ -29,6 +29,7 @@ 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 UpdateIcon from '@mui/icons-material/Update' import MoreVertIcon from '@mui/icons-material/MoreVert' import FolderIcon from '@mui/icons-material/Folder' import ImageIcon from '@mui/icons-material/Image' @@ -337,6 +338,23 @@ const Settings = () => { } } + const handleRescanDates = async () => { + try { + const response = await VideoService.rescanDates() + setAlert({ + open: true, + type: 'success', + message: `Rescan complete! Updated ${response.data.video_dates_updated}/${response.data.videos_scanned} video dates and ${response.data.image_dates_updated}/${response.data.images_scanned} image dates.`, + }) + } catch (err) { + setAlert({ + open: true, + type: 'error', + message: err.response?.data?.error || 'Failed to rescan dates', + }) + } + } + const handleDeleteFolderRule = async (unlinkVideos = false) => { const ruleId = deleteMenuRuleId setDeleteMenuAnchor(null) @@ -1466,6 +1484,15 @@ const Settings = () => { > Scan for Missing Dates + )} diff --git a/app/server/fireshare/api/scan.py b/app/server/fireshare/api/scan.py index 4e1e364b..3bf38a97 100644 --- a/app/server/fireshare/api/scan.py +++ b/app/server/fireshare/api/scan.py @@ -135,6 +135,49 @@ def manual_scan_dates(): return jsonify({'success': False, 'error': str(e)}), 500 +@api.route('/api/manual/rescan-dates') +@login_required +@demo_restrict +def manual_rescan_dates(): + """Re-extract and overwrite recorded_at for all videos and created_at for all images.""" + try: + paths = current_app.config['PATHS'] + videos_path = paths["video"] + images_path = paths["image"] + + videos = Video.query.all() + video_dates_updated = 0 + for video in videos: + video_file_path = videos_path / video.path + recorded_at = util.extract_date_from_file(video_file_path) + if recorded_at: + video.recorded_at = recorded_at + video_dates_updated += 1 + + images = Image.query.all() + image_dates_updated = 0 + for image in images: + image_file_path = images_path / image.path + created_at = util.extract_date_from_image_file(image_file_path) + if created_at: + image.created_at = created_at + image_dates_updated += 1 + + db.session.commit() + + return jsonify({ + 'success': True, + 'videos_scanned': len(videos), + 'video_dates_updated': video_dates_updated, + 'images_scanned': len(images), + 'image_dates_updated': image_dates_updated, + }), 200 + + except Exception as e: + logger.error(f"Error rescanning dates: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + @api.route('/api/scan-games/status') @login_required def get_game_scan_status(): diff --git a/app/server/fireshare/cli.py b/app/server/fireshare/cli.py index 6b317cbd..0b2d13b5 100755 --- a/app/server/fireshare/cli.py +++ b/app/server/fireshare/cli.py @@ -1033,7 +1033,7 @@ def scan_images(root): db.session.commit() logger.debug(f"Regenerated derived data for existing image {iid}") else: - created_at = datetime.fromtimestamp(os.path.getmtime(str(img_file))) + created_at = util.extract_date_from_image_file(img_file) updated_at = datetime.fromtimestamp(os.path.getmtime(str(img_file))) source_folder = rel_path.split('/')[0] if '/' in rel_path else None img = Image(image_id=iid, extension=img_file.suffix, path=rel_path, From a097d0f11b99176a766f05aafeb0c04942528f21 Mon Sep 17 00:00:00 2001 From: Shane Israel Date: Sat, 6 Jun 2026 17:53:50 -0600 Subject: [PATCH 4/7] fix image path --- app/server/fireshare/api/scan.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/server/fireshare/api/scan.py b/app/server/fireshare/api/scan.py index 3bf38a97..65de7c39 100644 --- a/app/server/fireshare/api/scan.py +++ b/app/server/fireshare/api/scan.py @@ -143,7 +143,7 @@ def manual_rescan_dates(): try: paths = current_app.config['PATHS'] videos_path = paths["video"] - images_path = paths["image"] + images_path = paths.get("images") videos = Video.query.all() video_dates_updated = 0 @@ -154,7 +154,7 @@ def manual_rescan_dates(): video.recorded_at = recorded_at video_dates_updated += 1 - images = Image.query.all() + images = Image.query.all() if images_path else [] image_dates_updated = 0 for image in images: image_file_path = images_path / image.path From f966d2f06339514a3ca7923e540d34edb7d5fea9 Mon Sep 17 00:00:00 2001 From: Shane Israel Date: Sun, 7 Jun 2026 10:56:22 -0600 Subject: [PATCH 5/7] run the rescan in a background thread --- app/client/src/views/Settings.js | 2 +- app/server/fireshare/api/scan.py | 65 ++++++++++++++++---------------- 2 files changed, 33 insertions(+), 34 deletions(-) diff --git a/app/client/src/views/Settings.js b/app/client/src/views/Settings.js index e4456826..f0bea06e 100644 --- a/app/client/src/views/Settings.js +++ b/app/client/src/views/Settings.js @@ -344,7 +344,7 @@ const Settings = () => { setAlert({ open: true, type: 'success', - message: `Rescan complete! Updated ${response.data.video_dates_updated}/${response.data.videos_scanned} video dates and ${response.data.image_dates_updated}/${response.data.images_scanned} image dates.`, + message: 'Date rescan started. This may take a few minutes depending on library size.', }) } catch (err) { setAlert({ diff --git a/app/server/fireshare/api/scan.py b/app/server/fireshare/api/scan.py index 65de7c39..4f4e010a 100644 --- a/app/server/fireshare/api/scan.py +++ b/app/server/fireshare/api/scan.py @@ -140,42 +140,41 @@ def manual_scan_dates(): @demo_restrict def manual_rescan_dates(): """Re-extract and overwrite recorded_at for all videos and created_at for all images.""" - try: - paths = current_app.config['PATHS'] - videos_path = paths["video"] - images_path = paths.get("images") - - videos = Video.query.all() - video_dates_updated = 0 - for video in videos: - video_file_path = videos_path / video.path - recorded_at = util.extract_date_from_file(video_file_path) - if recorded_at: - video.recorded_at = recorded_at - video_dates_updated += 1 - - images = Image.query.all() if images_path else [] - image_dates_updated = 0 - for image in images: - image_file_path = images_path / image.path - created_at = util.extract_date_from_image_file(image_file_path) - if created_at: - image.created_at = created_at - image_dates_updated += 1 + app = current_app._get_current_object() - db.session.commit() + def run_rescan(): + with app.app_context(): + try: + paths = app.config['PATHS'] + videos_path = paths["video"] + images_path = paths.get("images") + + videos = Video.query.all() + video_dates_updated = 0 + for video in videos: + recorded_at = util.extract_date_from_file(videos_path / video.path) + if recorded_at: + video.recorded_at = recorded_at + video_dates_updated += 1 + + images = Image.query.all() if images_path else [] + image_dates_updated = 0 + for image in images: + created_at = util.extract_date_from_image_file(images_path / image.path) + if created_at: + image.created_at = created_at + image_dates_updated += 1 + + db.session.commit() + logger.info(f"Rescan dates complete: {video_dates_updated}/{len(videos)} videos, {image_dates_updated}/{len(images)} images updated") + except Exception as e: + logger.error(f"Error rescanning dates: {e}") - return jsonify({ - 'success': True, - 'videos_scanned': len(videos), - 'video_dates_updated': video_dates_updated, - 'images_scanned': len(images), - 'image_dates_updated': image_dates_updated, - }), 200 + thread = threading.Thread(target=run_rescan) + thread.daemon = True + thread.start() - except Exception as e: - logger.error(f"Error rescanning dates: {e}") - return jsonify({'success': False, 'error': str(e)}), 500 + return jsonify({'success': True, 'message': 'Date rescan started in background'}), 200 @api.route('/api/scan-games/status') From a707e33a08dd14f760ffa279741374855774040c Mon Sep 17 00:00:00 2001 From: Shane Israel Date: Tue, 9 Jun 2026 13:20:09 -0600 Subject: [PATCH 6/7] fixed a discord webhook race condition --- app/server/fireshare/cli.py | 75 ++++++++++++++++++++++++++++-------- app/server/fireshare/util.py | 16 ++++++-- 2 files changed, 71 insertions(+), 20 deletions(-) diff --git a/app/server/fireshare/cli.py b/app/server/fireshare/cli.py index 0b2d13b5..2f20a02c 100755 --- a/app/server/fireshare/cli.py +++ b/app/server/fireshare/cli.py @@ -291,22 +291,58 @@ def scan_videos(root): info = VideoInfo(video_id=nv.video_id, title=Path(nv.path).stem, private=video_config["private"]) db.session.add(info) db.session.commit() - if discord_webhook_url: - for nv in new_videos: - logger.info(f"Posting to Discord webhook") - video_url = get_public_watch_url(nv.video_id, config, domain) + + # Generate metadata and posters for new videos, then fire webhooks + thumbnail_skip = current_app.config['THUMBNAIL_VIDEO_LOCATION'] or 0 + if thumbnail_skip > 0 and thumbnail_skip <= 100: + thumbnail_skip = thumbnail_skip / 100 + else: + thumbnail_skip = 0 + processed_root = Path(current_app.config['PROCESSED_DIRECTORY']) + for nv in new_videos: + video_link_path = video_links / (nv.video_id + nv.extension) + if not video_link_path.exists(): + continue + nv_info = VideoInfo.query.filter_by(video_id=nv.video_id).first() + if nv_info and not nv_info.duration: + media_info = util.get_media_info(video_link_path) + if media_info: + video_codecs = [s for s in media_info if s.get('codec_type') == 'video'] + if video_codecs: + vc = video_codecs[0] + nv_info.width = int(vc.get('width', 0)) or None + nv_info.height = int(vc.get('height', 0)) or None + duration = 0 + if 'duration' in vc: + duration = float(vc['duration']) + elif 'tags' in vc and 'DURATION' in vc['tags']: + duration = util.dur_string_to_seconds(vc['tags']['DURATION']) + nv_info.duration = duration or None + db.session.commit() + derived_path = processed_root / "derived" / nv.video_id + poster_path = derived_path / "poster.jpg" + if not poster_path.exists(): + if not derived_path.exists(): + derived_path.mkdir(parents=True) + duration = nv_info.duration if nv_info else 0 + poster_time = int((duration or 0) * thumbnail_skip) + util.create_poster(video_link_path, poster_path, poster_time) + poster_ready = poster_path.exists() and poster_path.stat().st_size > 0 + if not poster_ready: + logger.warning(f"Skipping webhook for {nv.video_id}: poster not ready") + continue + video_url = get_public_watch_url(nv.video_id, config, domain) + if discord_webhook_url: + logger.info(f"Posting to Discord webhook for {nv.video_id}") 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) + if generic_webhook_url: + logger.info(f"Posting to Generic webhook for {nv.video_id}") 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, + webhook_url=generic_webhook_url, + video_url=video_url, custom_payload=final_payload ) @@ -508,7 +544,9 @@ def scan_video(ctx, path, tag_ids, game_id, title): if not derived_path.exists(): derived_path.mkdir(parents=True) poster_time = int((info.duration or 0) * thumbnail_skip) - util.create_poster(video_path, derived_path / "poster.jpg", poster_time) + poster_ok = util.create_poster(video_path, derived_path / "poster.jpg", poster_time) + if not poster_ok: + logger.warning(f"Could not generate poster for video {info.video_id}") else: logger.debug(f"Skipping creation of poster for video {info.video_id} because it exists at {str(poster_path)}") db.session.commit() @@ -518,12 +556,15 @@ def scan_video(ctx, path, tag_ids, game_id, title): if not boomerang_path.exists(): util.create_boomerang_preview(video_path, boomerang_path) - if discord_webhook_url: + poster_ready = poster_path.exists() and poster_path.stat().st_size > 0 + if discord_webhook_url and poster_ready: 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: + elif discord_webhook_url and not poster_ready: + logger.warning(f"Skipping Discord webhook for {video_id}: poster not ready") + + if generic_webhook_url and poster_ready: logger.info(f"Posting to Generic webhook") video_url = get_public_watch_url(video_id, config, domain) payload_str = json.dumps(generic_webhook_payload) @@ -531,8 +572,8 @@ def scan_video(ctx, path, tag_ids, game_id, title): 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, + webhook_url=generic_webhook_url, + video_url=video_url, custom_payload=final_payload ) diff --git a/app/server/fireshare/util.py b/app/server/fireshare/util.py index 7265b978..cc1b5bc0 100755 --- a/app/server/fireshare/util.py +++ b/app/server/fireshare/util.py @@ -430,11 +430,21 @@ def create_audio_extract(source_path, out_path): def create_poster(video_path, out_path, second=0): s = time.time() - cmd = ['ffmpeg', '-v', 'quiet', '-y', '-i', str(video_path), '-ss', str(second), '-vframes', '1', '-vf', 'scale=iw:ih:force_original_aspect_ratio=decrease', str(out_path)] + # -ss before -i uses fast keyframe seek, reliable even for large high-bitrate files + cmd = ['ffmpeg', '-v', 'quiet', '-y', '-ss', str(second), '-i', str(video_path), '-vframes', '1', '-vf', 'scale=iw:ih:force_original_aspect_ratio=decrease', str(out_path)] logger.debug(f"$ {' '.join(cmd)}") - sp.call(cmd) + ret = sp.call(cmd) e = time.time() - logger.debug(f'Generated poster {str(out_path)} in {e-s}s') + out_path = Path(out_path) + success = ret == 0 and out_path.exists() and out_path.stat().st_size > 0 + if not success and second != 0: + # Fall back to first frame if the seek position failed + logger.warning(f"Poster generation failed at {second}s (exit {ret}), retrying with first frame") + cmd[3] = '0' + ret = sp.call(cmd) + success = ret == 0 and out_path.exists() and out_path.stat().st_size > 0 + logger.debug(f'Generated poster {str(out_path)} in {e-s}s (success={success})') + return success # --------------------------------------------------------------------------- From 0ec7add98ca839219ea021a3f9c483ee8359bbf8 Mon Sep 17 00:00:00 2001 From: Shane Israel Date: Tue, 9 Jun 2026 13:20:48 -0600 Subject: [PATCH 7/7] bump lockfile --- app/client/package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/client/package-lock.json b/app/client/package-lock.json index d31d7a58..5f26fc1a 100644 --- a/app/client/package-lock.json +++ b/app/client/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "fireshare", - "version": "1.6.15", + "version": "1.6.16", "dependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1",