media format detection

This commit is contained in:
meeb 2020-12-08 16:19:19 +11:00
parent d958f426d7
commit d7345c92c3
7 changed files with 292 additions and 83 deletions

View File

@ -0,0 +1,18 @@
# Generated by Django 3.1.4 on 2020-12-08 05:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sync', '0015_auto_20201207_0744'),
]
operations = [
migrations.AlterField(
model_name='source',
name='fallback',
field=models.CharField(choices=[('f', 'Fail, do not download any media'), ('n', 'Get next best resolution or codec instead'), ('h', 'Get next best resolution but at least HD')], db_index=True, default='h', help_text='What do do when media in your source resolution and codecs is not available', max_length=1, verbose_name='fallback'),
),
]

View File

@ -72,13 +72,13 @@ class Source(models.Model):
) )
FALLBACK_FAIL = 'f' FALLBACK_FAIL = 'f'
FALLBACK_NEXT_SD = 's' FALLBACK_NEXT_BEST = 'n'
FALLBACK_NEXT_HD = 'h' FALLBACK_NEXT_BEST_HD = 'h'
FALLBACKS = (FALLBACK_FAIL, FALLBACK_NEXT_SD, FALLBACK_NEXT_HD) FALLBACKS = (FALLBACK_FAIL, FALLBACK_NEXT_BEST, FALLBACK_NEXT_BEST_HD)
FALLBACK_CHOICES = ( FALLBACK_CHOICES = (
(FALLBACK_FAIL, _('Fail, do not download any media')), (FALLBACK_FAIL, _('Fail, do not download any media')),
(FALLBACK_NEXT_SD, _('Get next best SD media or codec instead')), (FALLBACK_NEXT_BEST, _('Get next best resolution or codec instead')),
(FALLBACK_NEXT_HD, _('Get next best HD media or codec instead')), (FALLBACK_NEXT_BEST_HD, _('Get next best resolution but at least HD'))
) )
# Fontawesome icons used for the source on the front end # Fontawesome icons used for the source on the front end
@ -218,7 +218,7 @@ class Source(models.Model):
max_length=1, max_length=1,
db_index=True, db_index=True,
choices=FALLBACK_CHOICES, choices=FALLBACK_CHOICES,
default=FALLBACK_NEXT_HD, default=FALLBACK_NEXT_BEST_HD,
help_text=_('What do do when media in your source resolution and codecs is not available') help_text=_('What do do when media in your source resolution and codecs is not available')
) )
has_failed = models.BooleanField( has_failed = models.BooleanField(
@ -300,6 +300,10 @@ class Source(models.Model):
def source_resolution_height(self): def source_resolution_height(self):
return self.RESOLUTION_MAP.get(self.source_resolution, 0) return self.RESOLUTION_MAP.get(self.source_resolution, 0)
@property
def can_fallback(self):
return self.fallback != self.FALLBACK_FAIL
def index_media(self): def index_media(self):
''' '''
Index the media source returning a list of media metadata as dicts. Index the media source returning a list of media metadata as dicts.
@ -494,94 +498,237 @@ class Media(models.Model):
fields = self.METADATA_FIELDS.get(field, {}) fields = self.METADATA_FIELDS.get(field, {})
return fields.get(self.source.source_type, '') return fields.get(self.source.source_type, '')
def iter_formats(self):
for fmt in self.formats:
yield parse_media_format(fmt)
def get_best_combined_format(self): def get_best_combined_format(self):
''' '''
Attempts to see if there is a single, combined audio and video format that Attempts to see if there is a single, combined audio and video format that
exactly matches the source requirements. This is used over separate audio exactly matches the source requirements. This is used over separate audio
and video formats if possible. and video formats if possible. Combined formats are the easiest to check
for as they must exactly match the source profile be be valid.
single format structure = {
'format_id': '22',
'url': '... long url ...',
'player_url': None,
'ext': 'mp4',
'width': 1280,
'height': 720,
'acodec': 'mp4a.40.2',
'abr': 192,
'vcodec': 'avc1.64001F',
'asr': 44100,
'filesize': None,
'format_note': '720p',
'fps': 30,
'tbr': 1571.695,
'format': '22 - 1280x720 (720p)',
'protocol': 'https',
'http_headers': {... dict of headers ...}
}
or for hdr = {
'format_id': '336',
'url': '... long url ...',
'player_url': None,
'asr': None,
'filesize': 312014985,
'format_note': '1440p60 HDR',
'fps': 60,
'height': 1440,
'tbr': 16900.587,
'width': 2560,
'ext': 'webm',
'vcodec': 'vp9.2',
'acodec': 'none',
'downloader_options': {'http_chunk_size': 10485760},
'format': '336 - 2560x1440 (1440p60 HDR)',
'protocol': 'https',
'http_headers': {... dict of headers ...}
}
''' '''
candidates = [] for fmt in self.iter_formats():
for fmt in self.formats:
parsed_fmt = parse_media_format(fmt)
# Check height matches # Check height matches
print(self.source.source_resolution_height, parsed_fmt['height']) if self.source.source_resolution.strip().upper() != fmt['format']:
if self.source.source_resolution_height != parsed_fmt['height']:
print()
continue continue
print('height OK')
# Check the video codec matches # Check the video codec matches
print(self.source.source_vcodec, parsed_fmt['vcodec']) if self.source.source_vcodec != fmt['vcodec']:
if self.source.source_vcodec != parsed_fmt['vcodec']:
print()
continue continue
print('vcodec OK')
# Check the audio codec matches # Check the audio codec matches
print(self.source.source_acodec, parsed_fmt['acodec']) if self.source.source_acodec != fmt['acodec']:
if self.source.source_acodec != parsed_fmt['acodec']:
print()
continue continue
print('acodec OK') # if the source prefers 60fps, check for it
# All OK so far... if self.source.prefer_60fps:
candidates.append(parsed_fmt) if not fmt['is_60fps']:
print() continue
for c in candidates: # If the source prefers HDR, check for it
print(c) if self.source.prefer_hdr:
return 'combined' if not fmt['is_hdr']:
continue
# If we reach here, we have a combined match!
return True, fmt['id']
return False, False
def get_best_audio_format(self): def get_best_audio_format(self):
''' '''
Finds the best match for the source required audio format. If the source Finds the best match for the source required audio format. If the source
has a 'fallback' of fail this can return no match. has a 'fallback' of fail this can return no match.
''' '''
return 'audio' # Order all audio-only formats by bitrate
audio_formats = []
for fmt in self.iter_formats():
# If the format has a video stream, skip it
if fmt['vcodec']:
continue
audio_formats.append(fmt)
audio_formats = list(reversed(sorted(audio_formats, key=lambda k: k['abr'])))
if not audio_formats:
# Media has no audio formats at all
return False, False
# Find the highest bitrate audio format with a matching codec
for fmt in audio_formats:
if self.source.source_acodec == fmt['acodec']:
# Matched!
return True, fmt['id']
# No codecs matched
if self.source.can_fallback:
# Can fallback, find the next highest bitrate non-matching codec
return False, audio_formats[0]
else:
# Can't fallback
return False, False
def get_best_video_format(self): def get_best_video_format(self):
''' '''
Finds the best match for the source required video format. If the source Finds the best match for the source required video format. If the source
has a 'fallback' of fail this can return no match. has a 'fallback' of fail this can return no match. Resolution is treated
as the most important factor to match.
''' '''
return 'video' min_height = getattr(settings, 'VIDEO_HEIGHT_CUTOFF', 360)
fallback_hd_cutoff = getattr(settings, 'VIDEO_HEIGHT_IS_HD', 500)
# Filter video-only formats by resolution that matches the source
video_formats = []
for fmt in self.iter_formats():
# If the format has an audio stream, skip it
if fmt['acodec']:
continue
if self.source.source_resolution.strip().upper() == fmt['format']:
video_formats.append(fmt)
# Check we matched some streams
if not video_formats:
# No streams match the requested resolution, see if we can fallback
if self.source.can_fallback:
# Find the next-best format matches by height
for fmt in self.iter_formats():
# If the format has an audio stream, skip it
if fmt['acodec']:
continue
if (fmt['height'] <= self.source.source_resolution_height and
fmt['height'] >= min_height):
video_formats.append(fmt)
else:
# Can't fallback
return False, False
video_formats = list(reversed(sorted(video_formats, key=lambda k: k['height'])))
if not video_formats:
# Still no matches
return False, False
exact_match, best_match = None, None
# Of our filtered video formats, check for resolution + codec + hdr + fps match
if self.source.prefer_60fps and self.source.prefer_hdr:
for fmt in video_formats:
# Check for an exact match
if (self.source.source_resolution.strip().upper() == fmt['format'] and
self.source.source_vcodec == fmt['vcodec'] and
fmt['is_hdr'] and
fmt['is_60fps']):
# Exact match
exact_match, best_match = True, fmt
break
if self.source.can_fallback:
if not best_match:
for fmt in video_formats:
# Check for a codec, hdr and fps match but drop the resolution
if (self.source.source_vcodec == fmt['vcodec'] and
fmt['is_hdr'] and fmt['is_60fps']):
# Close match
exact_match, best_match = False, fmt
break
if not best_match:
for fmt in video_formats:
# Check for hdr and fps match but drop the resolution and codec
if fmt['is_hdr'] and fmt['is_60fps']:
exact_match, best_match = False, fmt
break
if not best_match:
for fmt in video_formats:
# Check for fps match but drop the resolution and codec and hdr
if fmt['is_hdr'] and fmt['is_60fps']:
exact_match, best_match = False, fmt
break
if not best_match:
# Match the highest resolution
exact_match, best_match = False, video_formats[0]
# Check for resolution + codec + fps match
if self.source.prefer_60fps and not self.source.prefer_hdr:
for fmt in video_formats:
# Check for an exact match
if (self.source.source_resolution.strip().upper() == fmt['format'] and
self.source.source_vcodec == fmt['vcodec'] and
fmt['is_60fps']):
# Exact match
exact_match, best_match = True, fmt
break
if self.source.can_fallback:
if not best_match:
for fmt in video_formats:
# Check for a codec and fps match but drop the resolution
if (self.source.source_vcodec == fmt['vcodec'] and
fmt['is_60fps']):
exact_match, best_match = False, fmt
break
if not best_match:
for fmt in video_formats:
# Check for an fps match but drop the resolution and codec
if fmt['is_60fps']:
exact_match, best_match = False, fmt
break
if not best_match:
# Match the highest resolution
exact_match, best_match = False, video_formats[0]
# Check for resolution + codec + hdr
if self.source.prefer_hdr and not self.source.prefer_60fps:
for fmt in video_formats:
# Check for an exact match
if (self.source.source_resolution.strip().upper() == fmt['format'] and
self.source.source_vcodec == fmt['vcodec'] and
fmt['is_hdr']):
# Exact match
exact_match, best_match = True, fmt
break
if self.source.can_fallback:
if not best_match:
for fmt in video_formats:
# Check for a codec and hdr match but drop the resolution
if (self.source.source_vcodec == fmt['vcodec'] and
fmt['is_hdr']):
exact_match, best_match = True, fmt
break
if not best_match:
for fmt in video_formats:
# Check for an hdr match but drop the resolution and codec
if fmt['is_hdr']:
exact_match, best_match = False, fmt
break
if not best_match:
# Match the highest resolution
exact_match, best_match = False, video_formats[0]
# check for resolution + codec
if not self.source.prefer_hdr and not self.source.prefer_60fps:
for fmt in video_formats:
# Check for an exact match
if (self.source.source_resolution.strip().upper() == fmt['format'] and
self.source.source_vcodec == fmt['vcodec'] and
not fmt['is_60fps']):
# Exact match
exact_match, best_match = True, fmt
break
if self.source.can_fallback:
if not best_match:
for fmt in video_formats:
# Check for a codec match without 60fps and drop the resolution
if (self.source.source_vcodec == fmt['vcodec'] and
not fmt['is_60fps']):
exact_match, best_match = False, fmt
break
if not best_match:
for fmt in video_formats:
# Check for a codec match but drop the resolution
if self.source.source_vcodec == fmt['vcodec']:
# Close match
exact_match, best_match = False, fmt
break
if not best_match:
# Match the highest resolution
exact_match, best_match = False, video_formats[0]
# See if we found a match
if best_match:
# Final check to see if the match we found was good enough
if exact_match:
return True, best_match['id']
elif self.source.can_fallback:
# Allow the fallback if it meets requirements
if (self.source.fallback == self.source.FALLBACK_NEXT_BEST_HD and
best_match['height'] >= fallback_hd_cutoff):
return False, best_match['id']
elif self.source.fallback == self.source.FALLBACK_NEXT_BEST:
return False, best_match['id']
# Nope, failed to find match
return False, False
def get_format_str(self): def get_format_str(self):
''' '''

View File

@ -33,6 +33,10 @@
<td class="hide-on-small-only">Desired format</td> <td class="hide-on-small-only">Desired format</td>
<td><span class="hide-on-med-and-up">Desired format<br></span><strong>{{ media.source.format_summary }}</strong></td> <td><span class="hide-on-med-and-up">Desired format<br></span><strong>{{ media.source.format_summary }}</strong></td>
</tr> </tr>
<tr title="Fallback setting on the source">
<td class="hide-on-small-only">Fallback</td>
<td><span class="hide-on-med-and-up">Fallback<br></span><strong>{{ media.source.get_fallback_display }}</strong></td>
</tr>
<tr title="Has the media been downloaded"> <tr title="Has the media been downloaded">
<td class="hide-on-small-only">Downloaded</td> <td class="hide-on-small-only">Downloaded</td>
<td><span class="hide-on-med-and-up">Downloaded<br></span><strong>{% if media.downloaded %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td> <td><span class="hide-on-med-and-up">Downloaded<br></span><strong>{% if media.downloaded %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
@ -48,11 +52,11 @@
</td> </td>
</tr> </tr>
<tr title="Best available format for source requirements"> <tr title="Best available format for source requirements">
<td class="hide-on-small-only">Best match</td> <td class="hide-on-small-only">Matched formats</td>
<td><span class="hide-on-med-and-up">Best match<br></span><strong> <td><span class="hide-on-med-and-up">Matched formats<br></span>
audio: {{ media.get_best_audio_format }}<br> Combined: <strong>{% if combined_format %}{{ combined_format }} {% if combined_exact %}(exact match){% else %}(fallback){% endif %}{% else %}No match{% endif %}</strong><br>
video: {{ media.get_best_video_format }}<br> Audio: <strong>{% if audio_format %}{{ audio_format }} {% if audio_exact %}(exact match){% else %}(fallback){% endif %}{% else %}No match{% endif %}</strong><br>
combo: {{ media.get_best_combined_format }} Video: <strong>{% if video_format %}{{ video_format }} {% if video_exact %}(exact match){% else %}(fallback){% endif %}{% else %}No match{% endif %}
</strong></td> </strong></td>
</tr> </tr>
</table> </table>

View File

@ -18,6 +18,7 @@
<a href="{% url 'sync:tasks-completed' %}?filter={{ source.pk }}" class="btn">View tasks<span class="hide-on-small-only"> linked to this source</span> <i class="far fa-fw fa-clock"></i></a> <a href="{% url 'sync:tasks-completed' %}?filter={{ source.pk }}" class="btn">View tasks<span class="hide-on-small-only"> linked to this source</span> <i class="far fa-fw fa-clock"></i></a>
</div> </div>
</div> </div>
{% include 'infobox.html' with message=message %}
{% if source.has_failed %}{% include 'errorbox.html' with message='This source has encountered permanent failures listed at the bottom of this page, check its settings' %}{% endif %} {% if source.has_failed %}{% include 'errorbox.html' with message='This source has encountered permanent failures listed at the bottom of this page, check its settings' %}{% endif %}
<div class="row"> <div class="row">
<div class="col s12"> <div class="col s12">

View File

@ -142,11 +142,21 @@ def parse_media_format(format_dict):
acodec = None acodec = None
if acodec == 'NONE': if acodec == 'NONE':
acodec = None acodec = None
try:
fps = int(format_dict.get('fps', 0))
except (ValueError, TypeError):
fps = 0
format_full = format_dict.get('format_note', '').strip().upper()
format_str = format_full[:-2] if format_full.endswith('60') else format_full
return { return {
'id': format_dict.get('format_id', ''), 'id': format_dict.get('format_id', ''),
'format': format_str,
'format_verbose': format_dict.get('format', ''),
'height': format_dict.get('height', 0), 'height': format_dict.get('height', 0),
'is_60fps': format_dict.get('fps', 0) == 60,
'is_hdr': 'HDR' in format_dict.get('format', '').upper(),
'vcodec': vcodec, 'vcodec': vcodec,
'vbr': format_dict.get('tbr', 0),
'acodec': acodec, 'acodec': acodec,
'abr': format_dict.get('abr', 0),
'is_60fps': fps > 50,
'is_hdr': 'HDR' in format_dict.get('format', '').upper(),
} }

View File

@ -41,9 +41,7 @@ class SourcesView(ListView):
context_object_name = 'sources' context_object_name = 'sources'
paginate_by = settings.SOURCES_PER_PAGE paginate_by = settings.SOURCES_PER_PAGE
messages = { messages = {
'source-created': _('Your new source has been added'),
'source-deleted': _('Your selected source has been deleted.'), 'source-deleted': _('Your selected source has been deleted.'),
'source-updated': _('Your selected source has been updated.'),
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -235,7 +233,7 @@ class AddSourceView(CreateView):
return initial return initial
def get_success_url(self): def get_success_url(self):
url = reverse_lazy('sync:sources') url = reverse_lazy('sync:source', kwargs={'pk': self.object.pk})
return append_uri_params(url, {'message': 'source-created'}) return append_uri_params(url, {'message': 'source-created'})
@ -243,9 +241,23 @@ class SourceView(DetailView):
template_name = 'sync/source.html' template_name = 'sync/source.html'
model = Source model = Source
messages = {
'source-created': _('Your new source has been created'),
'source-updated': _('Your source has been updated.'),
}
def __init__(self, *args, **kwargs):
self.message = None
super().__init__(*args, **kwargs)
def dispatch(self, request, *args, **kwargs):
message_key = request.GET.get('message', '')
self.message = self.messages.get(message_key, '')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, *args, **kwargs): def get_context_data(self, *args, **kwargs):
data = super().get_context_data(*args, **kwargs) data = super().get_context_data(*args, **kwargs)
data['message'] = self.message
data['errors'] = [] data['errors'] = []
for error in get_source_completed_tasks(self.object.pk, only_errors=True): for error in get_source_completed_tasks(self.object.pk, only_errors=True):
error_message = get_error_message(error) error_message = get_error_message(error)
@ -264,7 +276,7 @@ class UpdateSourceView(UpdateView):
'source_acodec', 'prefer_60fps', 'prefer_hdr', 'fallback') 'source_acodec', 'prefer_60fps', 'prefer_hdr', 'fallback')
def get_success_url(self): def get_success_url(self):
url = reverse_lazy('sync:sources') url = reverse_lazy('sync:source', kwargs={'pk': self.object.pk})
return append_uri_params(url, {'message': 'source-updated'}) return append_uri_params(url, {'message': 'source-updated'})
@ -366,6 +378,19 @@ class MediaItemView(DetailView):
template_name = 'sync/media-item.html' template_name = 'sync/media-item.html'
model = Media model = Media
def get_context_data(self, *args, **kwargs):
data = super().get_context_data(*args, **kwargs)
combined_exact, combined_format = self.object.get_best_combined_format()
audio_exact, audio_format = self.object.get_best_audio_format()
video_exact, video_format = self.object.get_best_video_format()
data['combined_exact'] = combined_exact
data['combined_format'] = combined_format
data['audio_exact'] = audio_exact
data['audio_format'] = audio_format
data['video_exact'] = video_exact
data['video_format'] = video_format
return data
class TasksView(ListView): class TasksView(ListView):
''' '''

View File

@ -130,6 +130,10 @@ MEDIA_THUMBNAIL_WIDTH = 430 # Width in pixels to resize thumbnai
MEDIA_THUMBNAIL_HEIGHT = 240 # Height in pixels to resize thumbnails to MEDIA_THUMBNAIL_HEIGHT = 240 # Height in pixels to resize thumbnails to
VIDEO_HEIGHT_CUTOFF = 360 # Smallest resolution in pixels permitted to download
VIDEO_HEIGHT_IS_HD = 500 # Height in pixels to count as 'HD'
YOUTUBE_DEFAULTS = { YOUTUBE_DEFAULTS = {
'no_color': True, # Do not use colours in output 'no_color': True, # Do not use colours in output
'age_limit': 99, # 'Age in years' to spoof 'age_limit': 99, # 'Age in years' to spoof