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
4 changes: 2 additions & 2 deletions app/client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion app/client/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fireshare",
"version": "1.6.15",
"version": "1.6.16",
"private": true,
"dependencies": {
"@emotion/react": "^11.9.0",
Expand Down
3 changes: 3 additions & 0 deletions app/client/src/services/VideoService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
},
Expand Down
27 changes: 27 additions & 0 deletions app/client/src/views/Settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -337,6 +338,23 @@ const Settings = () => {
}
}

const handleRescanDates = async () => {
try {
const response = await VideoService.rescanDates()
setAlert({
open: true,
type: 'success',
message: 'Date rescan started. This may take a few minutes depending on library size.',
})
} 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)
Expand Down Expand Up @@ -1466,6 +1484,15 @@ const Settings = () => {
>
Scan for Missing Dates
</Button>
<Button
variant="contained"
startIcon={<UpdateIcon />}
onClick={handleRescanDates}
size="large"
sx={{ width: '100%', maxWidth: 400 }}
>
Rescan Image / Video Dates
</Button>
</Stack>
)}
</Box>
Expand Down
2 changes: 2 additions & 0 deletions app/server/fireshare/api/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand Down
42 changes: 42 additions & 0 deletions app/server/fireshare/api/scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,48 @@ 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."""
app = current_app._get_current_object()

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}")

thread = threading.Thread(target=run_rescan)
thread.daemon = True
thread.start()

return jsonify({'success': True, 'message': 'Date rescan started in background'}), 200


@api.route('/api/scan-games/status')
@login_required
def get_game_scan_status():
Expand Down
77 changes: 59 additions & 18 deletions app/server/fireshare/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down Expand Up @@ -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()
Expand All @@ -518,21 +556,24 @@ 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)
#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
)

Expand Down Expand Up @@ -1033,7 +1074,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,
Expand Down
16 changes: 13 additions & 3 deletions app/server/fireshare/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


# ---------------------------------------------------------------------------
Expand Down
Loading