Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added FFMPEG as a possible tool to detect corrupt video files. #10132

Merged
merged 23 commits into from
Jan 6, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
710ffe1
Added FFMPEG as a possible tool to detect corrupt video files.
p0psicles Nov 27, 2021
8c2d151
Fix false positives
p0psicles Nov 28, 2021
a2097dc
Fix failed download handling for Scheduled PP and Manual Pp.
p0psicles Nov 28, 2021
ba6dcef
Run process_failed() only if processing single resource.
p0psicles Nov 28, 2021
dee6639
Add video and audio stream check.
p0psicles Dec 2, 2021
ecc809b
Merge remote-tracking branch 'origin/develop' into feature/pp-check-c…
p0psicles Jan 5, 2022
a762faf
Remove check for corruption.
p0psicles Jan 5, 2022
ff2f811
yarn dev
p0psicles Jan 5, 2022
aa80b64
Removed ffmpegVersion in favor of ffprobeVersion.
p0psicles Jan 5, 2022
2eb2c88
yarn dev
p0psicles Jan 5, 2022
d7a5984
remove unused exceptions
p0psicles Jan 5, 2022
09c27b3
Merge branch 'feature/pp-check-corruption' of https://github.com/pyme…
p0psicles Jan 5, 2022
d6a69db
Update changelog
p0psicles Jan 5, 2022
5bec3a9
Fix test
p0psicles Jan 5, 2022
bbd0d3a
rm blank line at end of file
medariox Jan 5, 2022
dc9277e
Fix import statements are in the wrong order
medariox Jan 5, 2022
23cded0
update snapshot
p0psicles Jan 5, 2022
934b860
Merge branch 'feature/pp-check-corruption' of https://github.com/pyme…
p0psicles Jan 5, 2022
061a093
Update test
p0psicles Jan 5, 2022
0d84998
Merge remote-tracking branch 'origin/develop' into feature/pp-check-c…
p0psicles Jan 5, 2022
73d0006
yarn dev
p0psicles Jan 5, 2022
5f4590d
update snapshot
p0psicles Jan 6, 2022
867dcd5
Merge branch 'feature/pp-check-corruption' of https://github.com/pyme…
p0psicles Jan 6, 2022
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
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