Skip to content

Commit

Permalink
Added FFMPEG as a possible tool to detect corrupt video files. (#10132)
Browse files Browse the repository at this point in the history
* Added FFMPEG as a possible tool to detect corrupt video files.

* Fix false positives

* Fix failed download handling for Scheduled PP and Manual Pp.

* Run process_failed() only if processing single resource.

* Add video and audio stream check.

* Remove check for corruption.

* yarn dev

* Removed ffmpegVersion in favor of ffprobeVersion.

* yarn dev

* remove unused exceptions

* Update changelog

* Fix test

* rm blank line at end of file

* Fix import statements are in the wrong order

* update snapshot

* Update test

* yarn dev

* update snapshot

Co-authored-by: Dario <[email protected]>
  • Loading branch information
p0psicles and medariox authored Jan 6, 2022
1 parent 1928487 commit b917928
Show file tree
Hide file tree
Showing 19 changed files with 429 additions and 21 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
## Unreleased

#### New Features
- Add option for using ffprobe to validate postprocessed media ([10132](https://github.com/pymedusa/Medusa/pull/10132))

#### Improvements
- Add column sorting for the add new show page search results ([10217](https://github.com/pymedusa/Medusa/pull/10217))
Expand Down
7 changes: 7 additions & 0 deletions medusa/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,9 @@ def initialize(self, console_logging=True):
app.CREATE_MISSING_SHOW_DIRS = bool(check_setting_int(app.CFG, 'General', 'create_missing_show_dirs', 0))
app.ADD_SHOWS_WO_DIR = bool(check_setting_int(app.CFG, 'General', 'add_shows_wo_dir', 0))

app.FFMPEG_CHECK_STREAMS = bool(check_setting_int(app.CFG, 'ffmpeg', 'ffmpeg_check_streams', 0))
app.FFMPEG_PATH = check_setting_str(app.CFG, 'ffmpeg', 'ffmpeg_path', '')

app.PROWLARR_URL = check_setting_str(app.CFG, 'Prowlarr', 'url', '', censor_log='normal')
app.PROWLARR_APIKEY = check_setting_str(app.CFG, 'Prowlarr', 'apikey', '', censor_log='high')

Expand Down Expand Up @@ -1733,6 +1736,10 @@ def save_config():
new_config['General']['fallback_plex_notifications'] = app.FALLBACK_PLEX_NOTIFICATIONS
new_config['General']['fallback_plex_timeout'] = app.FALLBACK_PLEX_TIMEOUT

new_config['ffmpeg'] = {}
new_config['ffmpeg']['ffmpeg_check_streams'] = app.FFMPEG_CHECK_STREAMS
new_config['ffmpeg']['ffmpeg_path'] = app.FFMPEG_PATH

new_config['Recommended'] = {}
new_config['Recommended']['cache_shows'] = app.CACHE_RECOMMENDED_SHOWS
new_config['Recommended']['cache_trakt'] = app.CACHE_RECOMMENDED_TRAKT
Expand Down
3 changes: 3 additions & 0 deletions medusa/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,9 @@ def __init__(self):
self.SKIP_REMOVED_FILES = False
self.ALLOWED_EXTENSIONS = ['srt', 'nfo', 'sub', 'idx']

self.FFMPEG_CHECK_STREAMS = False
self.FFMPEG_PATH = ''

self.NZBS = False
self.NZBS_UID = None
self.NZBS_HASH = None
Expand Down
190 changes: 190 additions & 0 deletions medusa/helpers/ffmpeg.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
"""FFmpeg wrapper."""

import json
import logging
import subprocess
from os.path import join

from medusa import app
from medusa.logger.adapters.style import CustomBraceAdapter

log = CustomBraceAdapter(logging.getLogger(__name__))
log.logger.addHandler(logging.NullHandler())


class FfMpegException(Exception):
"""Base FFMPEG / FFPROBE Exception."""


class FfmpegBinaryException(FfMpegException):
"""FFMPEG Binary exception."""


class FfprobeBinaryException(FfMpegException):
"""FFPROBE Binary exception."""


class FfMpeg(object):
"""Wrapper for running FFmpeg checks on video files."""

def __init__(self):
"""Ffmpeg constructor."""
super().__init__()
self.ffprobe = 'ffprobe'
self.ffmpeg = 'ffmpeg'
self.ffmpeg_path = app.FFMPEG_PATH
if self.ffmpeg_path:
self.ffprobe = join(self.ffmpeg_path, self.ffprobe)
self.ffmpeg = join(self.ffmpeg_path, self.ffmpeg)

def get_video_details(self, video_file):
"""Read media info."""
command = [
self.ffprobe,
'-v', 'quiet',
'-print_format', 'json',
'-show_streams',
'-show_error',
video_file
]

process = subprocess.Popen(
command, stderr=subprocess.PIPE, stdout=subprocess.PIPE,
encoding='utf-8',
)
output, err = process.communicate()
output = json.loads(output)
return output

def check_for_video_and_audio_streams(self, video_file):
"""Read media info and check for a video and audio stream."""
video_details = self.get_video_details(video_file)
video_streams = [item for item in video_details['streams'] if item['codec_type'] == 'video']
audio_streams = [item for item in video_details['streams'] if item['codec_type'] == 'audio']
if len(video_streams) > 0 and len(audio_streams) > 0:
return True

return False

def scan_for_errors(self, video_file):
"""Check for the last 60 seconds of a video for corruption."""
command = [
self.ffmpeg,
'-v', 'error',
'-sseof', '-60',
'-i', video_file,
'-f', 'null', '-'
]

process = subprocess.Popen(
command, stderr=subprocess.PIPE, stdout=subprocess.PIPE,
encoding='utf-8', universal_newlines=False
)

_, errors = process.communicate()
err = [err.strip() for err in errors.split('\n') if err]
if(err):
print(err)
return {
'file': video_file,
'errors': err
}

return {
'file': video_file,
'errors': False
}

def test_ffmpeg_binary(self):
"""Test for ffmpeg binary."""
command = [
self.ffmpeg,
'-version'
]

try:
process = subprocess.Popen(
command, stderr=subprocess.PIPE, stdout=subprocess.PIPE,
encoding='utf-8', universal_newlines=False
)

process.communicate()
except (FileNotFoundError, PermissionError) as error:
raise FfmpegBinaryException(f'Error trying to access binary for {self.ffmpeg}, error: {error}')
except Exception as error:
log.warning('Failed to test for the ffmpeg binary. Error: {error}', {'error': error})
raise FfmpegBinaryException(f'Error trying to access binary for {self.ffmpeg}, error: {error}')

return True

def test_ffprobe_binary(self):
"""Test for ffmpeg binary."""
command = [
self.ffprobe,
'-version'
]

try:
process = subprocess.Popen(
command, stderr=subprocess.PIPE, stdout=subprocess.PIPE,
encoding='utf-8', universal_newlines=False
)

process.communicate()
except (FileNotFoundError, PermissionError) as error:
raise FfprobeBinaryException(f'Error trying to access binary for {self.ffmpeg}, error: {error}')
except Exception as error:
log.warning('Failed to test for the ffmpeg binary. Error: {error}', {'error': error})
raise FfprobeBinaryException(f'Error trying to access binary for {self.ffmpeg}, error: {error}')

return True

def get_ffmpeg_version(self):
"""Test for the ffmpeg version."""
command = [
self.ffmpeg,
'-version'
]

try:
self.test_ffmpeg_binary()
process = subprocess.Popen(
command, stderr=subprocess.PIPE, stdout=subprocess.PIPE,
encoding='utf-8', universal_newlines=False
)

output, _ = process.communicate()
except FileNotFoundError:
return False

if output:
output = output.split(' ')
# Cut out the version
return output[2]

return False

def get_ffprobe_version(self):
"""Test for the ffprobe version."""
command = [
self.ffprobe,
'-version'
]

try:
self.test_ffprobe_binary()
process = subprocess.Popen(
command, stderr=subprocess.PIPE, stdout=subprocess.PIPE,
encoding='utf-8', universal_newlines=False
)

output, _ = process.communicate()
except FileNotFoundError:
return False

if output:
output = output.split(' ')
# Cut out the version
return output[2]

return False
16 changes: 16 additions & 0 deletions medusa/post_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
)
from medusa.helpers import is_subtitle, verify_freespace
from medusa.helpers.anidb import set_up_anidb_connection
from medusa.helpers.ffmpeg import FfMpeg, FfprobeBinaryException
from medusa.helpers.utils import generate
from medusa.name_parser.parser import (
InvalidNameException,
Expand Down Expand Up @@ -1038,6 +1039,21 @@ def process(self):
self.log(u'File {0} is ignored type, skipping'.format(self.file_path))
return False

ffmpeg = FfMpeg()
if app.FFMPEG_CHECK_STREAMS:
try:
ffmpeg.test_ffprobe_binary()
self.log(f'Checking {self.file_path} for minimal one video and audio stream')
result = FfMpeg().check_for_video_and_audio_streams(self.file_path)
except FfprobeBinaryException:
self.log('Cannot access ffprobe binary. Make sure ffprobe is accessable throug your environment variables or configure a path.')
else:
if not result:
self.log('ffprobe reported an error while checking {file_path} for a video and audio stream. Error: {error}'.format(
file_path=self.file_path, error=result['errors']), logger.WARNING
)
raise EpisodePostProcessingFailedException(f'ffmpeg detected a corruption in this video file: {self.file_path}')

# reset in_history
self.in_history = False

Expand Down
29 changes: 23 additions & 6 deletions medusa/process_tv.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class PostProcessQueueItem(generic_queue.QueueItem):

def __init__(self, path=None, info_hash=None, resource_name=None, force=False,
is_priority=False, process_method=None, delete_on=False, failed=False,
proc_type='auto', ignore_subs=False, episodes=[], client_type=None):
proc_type='auto', ignore_subs=False, episodes=[], client_type=None, process_single_resource=False):
"""Initialize the class."""
generic_queue.QueueItem.__init__(self, u'Post Process')

Expand All @@ -52,6 +52,7 @@ def __init__(self, path=None, info_hash=None, resource_name=None, force=False,
self.proc_type = proc_type
self.ignore_subs = ignore_subs
self.episodes = episodes
self.process_single_resource = process_single_resource

# torrent or nzb. Pass info on what sort of download we're processing.
# We might need this when determining the PROCESS_METHOD.
Expand Down Expand Up @@ -132,7 +133,11 @@ def update_history_processed(self, process_results):

def process_path(self):
"""Process for when we have a valid path."""
process_results = ProcessResult(self.path, self.process_method, failed=self.failed, episodes=self.episodes)
process_results = ProcessResult(
self.path, self.process_method,
failed=self.failed, episodes=self.episodes,
process_single_resource=self.process_single_resource
)
process_results.process(
resource_name=self.resource_name,
force=self.force,
Expand All @@ -143,7 +148,11 @@ def process_path(self):
)

# A user might want to use advanced post-processing, but opt-out of failed download handling.
if (app.USE_FAILED_DOWNLOADS and (process_results.failed or (not process_results.succeeded and self.resource_name))):
if (
app.USE_FAILED_DOWNLOADS
and self.process_single_resource
and (process_results.failed or not process_results.succeeded)
):
process_results.process_failed(self.path)

# In case we have an info_hash or (nzbid), update the history table with the pp results.
Expand Down Expand Up @@ -237,7 +246,7 @@ class ProcessResult(object):

IGNORED_FOLDERS = ('@eaDir', '#recycle', '.@__thumb',)

def __init__(self, path, process_method=None, failed=False, episodes=[]):
def __init__(self, path, process_method=None, failed=False, episodes=[], process_single_resource=False):
"""
Initialize ProcessResult object.
Expand All @@ -262,6 +271,7 @@ def __init__(self, path, process_method=None, failed=False, episodes=[]):
# When multiple media folders/files processed. Flag postpone_any of any them was postponed.
self.postpone_any = False
self.episodes = episodes
self.process_single_resource = process_single_resource

@property
def directory(self):
Expand Down Expand Up @@ -879,6 +889,11 @@ def process_media(self, path, video_files, force=False, is_priority=None, ignore
self.missed_files.append('{0}: Processing failed: {1}'.format(file_path, process_fail_message))
self.succeeded = False

if not self.process_single_resource:
# If this PostprocessQueueItem wasn't started through the download handler
# or apiv2 we want to fail the media item right here.
self.process_failed(path, resource_name=video)

def _process_postponed(self, processor, path, video, ignore_subs):
if not ignore_subs:
if self.subtitles_enabled(path, self.resource_name):
Expand Down Expand Up @@ -910,10 +925,12 @@ def _process_postponed(self, processor, path, video, ignore_subs):
'Continuing the post-processing of this file: {video}', **{'video': video})
return True

def process_failed(self, path):
def process_failed(self, path, resource_name=None):
"""Process a download that did not complete correctly."""
try:
processor = failed_processor.FailedProcessor(path, self.resource_name, self.episodes)
processor = failed_processor.FailedProcessor(
path, resource_name or self.resource_name, self.episodes
)
self.result = processor.process()
process_fail_message = ''
except FailedPostProcessingFailedException as error:
Expand Down
3 changes: 2 additions & 1 deletion medusa/schedulers/download_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,8 @@ def _postprocess(self, path, info_hash, resource_name, failed=False, client_type

queue_item = PostProcessQueueItem(
path, info_hash, resource_name=resource_name,
failed=failed, episodes=episodes, client_type=client_type
failed=failed, episodes=episodes, client_type=client_type,
process_single_resource=True
)
app.post_processor_queue_scheduler.action.add_item(queue_item)

Expand Down
12 changes: 12 additions & 0 deletions medusa/server/api/v2/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
)
from medusa.app import app
from medusa.common import IGNORED, Quality, SKIPPED, WANTED, cpu_presets
from medusa.helpers.ffmpeg import FfMpeg, FfprobeBinaryException
from medusa.helpers.utils import int_default, to_camel_case
from medusa.indexers.config import INDEXER_TVDBV2, get_indexer_config
from medusa.logger.adapters.style import BraceAdapter
Expand Down Expand Up @@ -275,6 +276,9 @@ class ConfigHandler(BaseRequestHandler):
'postProcessing.downloadHandler.torrentSeedRatio': FloatField(app, 'TORRENT_SEED_RATIO'),
'postProcessing.downloadHandler.torrentSeedAction': StringField(app, 'TORRENT_SEED_ACTION'),

'postProcessing.ffmpeg.checkStreams': BooleanField(app, 'FFMPEG_CHECK_STREAMS'),
'postProcessing.ffmpeg.path': StringField(app, 'FFMPEG_PATH'),

'search.general.randomizeProviders': BooleanField(app, 'RANDOMIZE_PROVIDERS'),
'search.general.downloadPropers': BooleanField(app, 'DOWNLOAD_PROPERS'),
'search.general.checkPropersInterval': StringField(app, 'CHECK_PROPERS_INTERVAL'),
Expand Down Expand Up @@ -1140,6 +1144,10 @@ def data_system():
section_data['gitRemoteBranches'] = app.GIT_REMOTE_BRANCHES
section_data['cpuPresets'] = cpu_presets
section_data['newestVersionMessage'] = app.NEWEST_VERSION_STRING
try:
section_data['ffprobeVersion'] = FfMpeg().get_ffprobe_version()
except FfprobeBinaryException:
section_data['ffprobeVersion'] = 'ffprobe not available'

section_data['news'] = {}
section_data['news']['lastRead'] = app.NEWS_LAST_READ
Expand Down Expand Up @@ -1251,6 +1259,10 @@ def data_postprocessing():
section_data['downloadHandler']['torrentSeedRatio'] = float(app.TORRENT_SEED_RATIO) if app.TORRENT_SEED_RATIO is not None else -1
section_data['downloadHandler']['torrentSeedAction'] = app.TORRENT_SEED_ACTION

section_data['ffmpeg'] = {}
section_data['ffmpeg']['checkStreams'] = bool(app.FFMPEG_CHECK_STREAMS)
section_data['ffmpeg']['path'] = app.FFMPEG_PATH

return section_data

@staticmethod
Expand Down
Loading

0 comments on commit b917928

Please sign in to comment.