diff --git a/app/server/fireshare/api.py b/app/server/fireshare/api.py index 43b8ce9..9228606 100644 --- a/app/server/fireshare/api.py +++ b/app/server/fireshare/api.py @@ -1284,6 +1284,59 @@ def reject_game_suggestion(video_id): return Response(status=204) +@api.route('/api/videos/corrupt', methods=["GET"]) +@login_required +def get_corrupt_videos(): + """Get a list of all videos marked as corrupt""" + from fireshare.cli import get_all_corrupt_videos + + corrupt_video_ids = get_all_corrupt_videos() + + # Get video details for all corrupt videos in a single query + video_info_map = {} + if corrupt_video_ids: + video_infos = VideoInfo.query.filter(VideoInfo.video_id.in_(corrupt_video_ids)).all() + video_info_map = {vi.video_id: vi for vi in video_infos} + + corrupt_videos = [] + for video_id in corrupt_video_ids: + vi = video_info_map.get(video_id) + if vi: + corrupt_videos.append({ + 'video_id': video_id, + 'title': vi.title, + 'path': vi.video.path if vi.video else None + }) + else: + # Video may have been deleted but still in corrupt list + corrupt_videos.append({ + 'video_id': video_id, + 'title': None, + 'path': None + }) + return jsonify(corrupt_videos) + +@api.route('/api/videos//corrupt', methods=["DELETE"]) +@login_required +def clear_corrupt_status(video_id): + """Clear the corrupt status for a specific video so it can be retried""" + from fireshare.cli import clear_video_corrupt, is_video_corrupt + + if not is_video_corrupt(video_id): + return Response(status=400, response="Video is not marked as corrupt") + + clear_video_corrupt(video_id) + return Response(status=204) + +@api.route('/api/videos/corrupt/clear-all', methods=["DELETE"]) +@login_required +def clear_all_corrupt_status(): + """Clear the corrupt status for all videos so they can be retried""" + from fireshare.cli import clear_all_corrupt_videos + + count = clear_all_corrupt_videos() + return jsonify({'cleared': count}) + @api.after_request def after_request(response): response.headers.add('Accept-Ranges', 'bytes') diff --git a/app/server/fireshare/cli.py b/app/server/fireshare/cli.py index 18273f7..f057af3 100755 --- a/app/server/fireshare/cli.py +++ b/app/server/fireshare/cli.py @@ -63,6 +63,68 @@ def delete_game_suggestion(video_id): return True return False +# Helper functions for persistent corrupt video tracking +def _get_corrupt_videos_file(): + """Get path to the corrupt videos JSON file""" + from flask import current_app + data_dir = Path(current_app.config.get('DATA_DIRECTORY', '/data')) + return data_dir / 'corrupt_videos.json' + +def _load_corrupt_videos(): + """Load corrupt videos list from JSON file""" + corrupt_file = _get_corrupt_videos_file() + if corrupt_file.exists(): + try: + with open(corrupt_file, 'r') as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + return [] + return [] + +def _save_corrupt_videos(corrupt_list): + """Save corrupt videos list to JSON file""" + corrupt_file = _get_corrupt_videos_file() + corrupt_file.parent.mkdir(parents=True, exist_ok=True) + try: + with open(corrupt_file, 'w') as f: + json.dump(corrupt_list, f) + except IOError as e: + logger.error(f"Failed to save corrupt videos list: {e}") + +def is_video_corrupt(video_id): + """Check if a video is marked as corrupt""" + corrupt_list = _load_corrupt_videos() + return video_id in corrupt_list + +def mark_video_corrupt(video_id): + """Mark a video as corrupt""" + corrupt_list = _load_corrupt_videos() + if video_id not in corrupt_list: + corrupt_list.append(video_id) + _save_corrupt_videos(corrupt_list) + logger.info(f"Marked video {video_id} as corrupt") + +def clear_video_corrupt(video_id): + """Clear the corrupt status for a video""" + corrupt_list = _load_corrupt_videos() + if video_id in corrupt_list: + corrupt_list.remove(video_id) + _save_corrupt_videos(corrupt_list) + logger.info(f"Cleared corrupt status for video {video_id}") + return True + return False + +def get_all_corrupt_videos(): + """Get list of all corrupt video IDs""" + return _load_corrupt_videos() + +def clear_all_corrupt_videos(): + """Clear all corrupt video statuses""" + count = len(_load_corrupt_videos()) + _save_corrupt_videos([]) + logger.info(f"Cleared corrupt status for {count} video(s)") + return count + def send_discord_webhook(webhook_url=None, video_url=None): payload = { "content": video_url, @@ -513,7 +575,8 @@ def create_boomerang_posters(regenerate): @cli.command() @click.option("--regenerate", "-r", help="Overwrite existing transcoded videos", is_flag=True) @click.option("--video", "-v", help="Transcode a specific video by id", default=None) -def transcode_videos(regenerate, video): +@click.option("--include-corrupt", help="Include videos previously marked as corrupt", is_flag=True) +def transcode_videos(regenerate, video, include_corrupt): """Transcode videos to 1080p and 720p variants""" with create_app().app_context(): if not current_app.config.get('ENABLE_TRANSCODING'): @@ -526,6 +589,16 @@ def transcode_videos(regenerate, video): # Get videos to transcode vinfos = VideoInfo.query.filter(VideoInfo.video_id==video).all() if video else VideoInfo.query.all() + + # Filter out corrupt videos unless explicitly included + corrupt_videos = set(get_all_corrupt_videos()) + if not include_corrupt and not video: + original_count = len(vinfos) + vinfos = [vi for vi in vinfos if vi.video_id not in corrupt_videos] + skipped_count = original_count - len(vinfos) + if skipped_count > 0: + logger.info(f"Skipping {skipped_count} video(s) previously marked as corrupt. Use --include-corrupt to retry them.") + logger.info(f'Processing {len(vinfos):,} videos for transcoding (GPU: {use_gpu}, Base timeout: {base_timeout}s)') for vi in vinfos: @@ -541,20 +614,25 @@ def transcode_videos(regenerate, video): # Determine which qualities to transcode original_height = vi.height or 0 + video_is_corrupt = False # Transcode to 1080p if original is higher and 1080p doesn't exist transcode_1080p_path = derived_path / f"{vi.video_id}-1080p.mp4" if original_height > 1080 and (not transcode_1080p_path.exists() or regenerate): logger.info(f"Transcoding {vi.video_id} to 1080p") - # Pass None for timeout to use smart calculation, or pass base_timeout if needed timeout = None # Uses smart calculation based on video duration success, failure_reason = util.transcode_video_quality(video_path, transcode_1080p_path, 1080, use_gpu, timeout) if success: vi.has_1080p = True + # Clear corrupt status if transcode succeeds (file may have been replaced) + if is_video_corrupt(vi.video_id): + clear_video_corrupt(vi.video_id) db.session.add(vi) db.session.commit() elif failure_reason == 'corruption': logger.warning(f"Skipping video {vi.video_id} 1080p transcode - source file appears corrupt") + mark_video_corrupt(vi.video_id) + video_is_corrupt = True else: logger.warning(f"Skipping video {vi.video_id} 1080p transcode - all encoders failed") elif transcode_1080p_path.exists(): @@ -563,19 +641,26 @@ def transcode_videos(regenerate, video): db.session.add(vi) db.session.commit() + # Skip 720p transcode if video was marked as corrupt + if video_is_corrupt: + continue + # Transcode to 720p if original is higher than 720p and 720p doesn't exist transcode_720p_path = derived_path / f"{vi.video_id}-720p.mp4" if original_height > 720 and (not transcode_720p_path.exists() or regenerate): logger.info(f"Transcoding {vi.video_id} to 720p") - # Pass None for timeout to use smart calculation, or pass base_timeout if needed timeout = None # Uses smart calculation based on video duration success, failure_reason = util.transcode_video_quality(video_path, transcode_720p_path, 720, use_gpu, timeout) if success: vi.has_720p = True + # Clear corrupt status if transcode succeeds (file may have been replaced) + if is_video_corrupt(vi.video_id): + clear_video_corrupt(vi.video_id) db.session.add(vi) db.session.commit() elif failure_reason == 'corruption': logger.warning(f"Skipping video {vi.video_id} 720p transcode - source file appears corrupt") + mark_video_corrupt(vi.video_id) else: logger.warning(f"Skipping video {vi.video_id} 720p transcode - all encoders failed") elif transcode_720p_path.exists():