start of media format matching

This commit is contained in:
meeb 2020-12-07 01:11:48 +11:00
parent a249d6b3b0
commit 5f8f3028f2
4 changed files with 168 additions and 22 deletions

View File

@ -7,7 +7,7 @@ from django.db import models
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .youtube import get_media_info as get_youtube_media_info from .youtube import get_media_info as get_youtube_media_info
from .utils import seconds_to_timestr from .utils import seconds_to_timestr, parse_media_format
class Source(models.Model): class Source(models.Model):
@ -24,26 +24,34 @@ class Source(models.Model):
(SOURCE_TYPE_YOUTUBE_PLAYLIST, _('YouTube playlist')), (SOURCE_TYPE_YOUTUBE_PLAYLIST, _('YouTube playlist')),
) )
SOURCE_RESOLUTION_360p = '360p' SOURCE_RESOLUTION_360P = '360p'
SOURCE_RESOLUTION_480p = '480p' SOURCE_RESOLUTION_480P = '480p'
SOURCE_RESOLUTION_720P = '720p' SOURCE_RESOLUTION_720P = '720p'
SOURCE_RESOLUTION_1080P = '1080p' SOURCE_RESOLUTION_1080P = '1080p'
SOURCE_RESOLUTION_1440P = '1440p' SOURCE_RESOLUTION_1440P = '1440p'
SOURCE_RESOLUTION_2160P = '2160p' SOURCE_RESOLUTION_2160P = '2160p'
SOURCE_RESOLUTION_AUDIO = 'audio' SOURCE_RESOLUTION_AUDIO = 'audio'
SOURCE_RESOLUTIONS = (SOURCE_RESOLUTION_360p, SOURCE_RESOLUTION_480p, SOURCE_RESOLUTIONS = (SOURCE_RESOLUTION_360P, SOURCE_RESOLUTION_480P,
SOURCE_RESOLUTION_720P, SOURCE_RESOLUTION_1080P, SOURCE_RESOLUTION_720P, SOURCE_RESOLUTION_1080P,
SOURCE_RESOLUTION_1440P, SOURCE_RESOLUTION_2160P, SOURCE_RESOLUTION_1440P, SOURCE_RESOLUTION_2160P,
SOURCE_RESOLUTION_AUDIO) SOURCE_RESOLUTION_AUDIO)
SOURCE_RESOLUTION_CHOICES = ( SOURCE_RESOLUTION_CHOICES = (
(SOURCE_RESOLUTION_360p, _('360p (SD)')), (SOURCE_RESOLUTION_360P, _('360p (SD)')),
(SOURCE_RESOLUTION_480p, _('480p (SD)')), (SOURCE_RESOLUTION_480P, _('480p (SD)')),
(SOURCE_RESOLUTION_720P, _('720p (HD)')), (SOURCE_RESOLUTION_720P, _('720p (HD)')),
(SOURCE_RESOLUTION_1080P, _('1080p (Full HD)')), (SOURCE_RESOLUTION_1080P, _('1080p (Full HD)')),
(SOURCE_RESOLUTION_1440P, _('1440p (2K)')), (SOURCE_RESOLUTION_1440P, _('1440p (2K)')),
(SOURCE_RESOLUTION_2160P, _('2160p (4K)')), (SOURCE_RESOLUTION_2160P, _('2160p (4K)')),
(SOURCE_RESOLUTION_AUDIO, _('Audio only')), (SOURCE_RESOLUTION_AUDIO, _('Audio only')),
) )
RESOLUTION_MAP = {
SOURCE_RESOLUTION_360P: 360,
SOURCE_RESOLUTION_480P: 480,
SOURCE_RESOLUTION_720P: 720,
SOURCE_RESOLUTION_1080P: 1080,
SOURCE_RESOLUTION_1440P: 1440,
SOURCE_RESOLUTION_2160P: 2160
}
SOURCE_VCODEC_AVC1 = 'AVC1' SOURCE_VCODEC_AVC1 = 'AVC1'
SOURCE_VCODEC_VP9 = 'VP9' SOURCE_VCODEC_VP9 = 'VP9'
@ -54,12 +62,12 @@ class Source(models.Model):
(SOURCE_VCODEC_VP9, _('VP9')), (SOURCE_VCODEC_VP9, _('VP9')),
) )
SOURCE_ACODEC_M4A = 'M4A' SOURCE_ACODEC_MP4A = 'MP4A'
SOURCE_ACODEC_OPUS = 'OPUS' SOURCE_ACODEC_OPUS = 'OPUS'
SOURCE_ACODECS = (SOURCE_ACODEC_M4A, SOURCE_ACODEC_OPUS) SOURCE_ACODECS = (SOURCE_ACODEC_MP4A, SOURCE_ACODEC_OPUS)
SOURCE_ACODEC_PRIORITY = (SOURCE_ACODEC_OPUS, SOURCE_ACODEC_M4A) SOURCE_ACODEC_PRIORITY = (SOURCE_ACODEC_OPUS, SOURCE_ACODEC_MP4A)
SOURCE_ACODEC_CHOICES = ( SOURCE_ACODEC_CHOICES = (
(SOURCE_ACODEC_M4A, _('M4A')), (SOURCE_ACODEC_MP4A, _('MP4A')),
(SOURCE_ACODEC_OPUS, _('OPUS')), (SOURCE_ACODEC_OPUS, _('OPUS')),
) )
@ -266,6 +274,10 @@ class Source(models.Model):
def key_field(self): def key_field(self):
return self.KEY_FIELD.get(self.source_type, '') return self.KEY_FIELD.get(self.source_type, '')
@property
def source_resolution_height(self):
return self.RESOLUTION_MAP.get(self.source_resolution, 0)
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.
@ -458,6 +470,113 @@ 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 get_best_combined_format(self):
'''
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
and video formats if possible.
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.formats:
parsed_fmt = parse_media_format(fmt)
# Check height matches
print(self.source.source_resolution_height, parsed_fmt['height'])
if self.source.source_resolution_height != parsed_fmt['height']:
print()
continue
print('height OK')
# Check the video codec matches
print(self.source.source_vcodec, parsed_fmt['vcodec'])
if self.source.source_vcodec != parsed_fmt['vcodec']:
print()
continue
print('vcodec OK')
# Check the audio codec matches
print(self.source.source_acodec, parsed_fmt['acodec'])
if self.source.source_acodec != parsed_fmt['acodec']:
print()
continue
print('acodec OK')
# All OK so far...
candidates.append(parsed_fmt)
print()
for c in candidates:
print(c)
return 'combined'
def get_best_audio_format(self):
'''
Finds the best match for the source required audio format. If the source
has a 'fallback' of fail this can return no match.
'''
return 'audio'
def get_best_video_format(self):
'''
Finds the best match for the source required video format. If the source
has a 'fallback' of fail this can return no match.
'''
return 'video'
def get_format_str(self):
'''
Returns a youtube-dl compatible format string for the best matches
combination of source requirements and available audio and video formats.
'''
if self.source.is_audio:
audio_format = self.get_best_audio_format()
return 'a'
else:
combined_format = self.get_best_combined_format()
if combined_format:
return 'c'
else:
audio_format = self.get_best_audio_format()
video_format = self.get_best_video_format()
return 'a+v'
return False
@property @property
def loaded_metadata(self): def loaded_metadata(self):
if self.pk in _metadata_cache: if self.pk in _metadata_cache:

View File

@ -41,12 +41,20 @@
<td class="hide-on-small-only">Available formats</td> <td class="hide-on-small-only">Available formats</td>
<td><span class="hide-on-med-and-up">Available formats<br></span> <td><span class="hide-on-med-and-up">Available formats<br></span>
{% for format in media.formats %} {% for format in media.formats %}
<strong>{{ format.format }}</strong><br> <span class="truncate">ID: <strong>{{ format.format_id }}</strong>{% if format.vcodec|lower != 'none' %}, <strong>{{ format.format_note }} ({{ format.width }}x{{ format.height }})</strong>, fps:<strong>{{ format.fps|lower }}</strong>, video:<strong>{{ format.vcodec }} @{{ format.tbr }}k</strong>{% endif %}{% if format.acodec|lower != 'none' %}, audio:<strong>{{ format.acodec }} @{{ format.abr }}k / {{ format.asr }}Hz</strong>{% endif %}</span>
{% empty %} {% empty %}
Media has no detected available formats Media has no indexed available formats
{% endfor %} {% endfor %}
</td> </td>
</tr> </tr>
<tr title="Best available format for source requirements">
<td class="hide-on-small-only">Best match</td>
<td><span class="hide-on-med-and-up">Best match<br></span><strong>
audio: {{ media.get_best_audio_format }}<br>
video: {{ media.get_best_video_format }}<br>
combo: {{ media.get_best_combined_format }}
</strong></td>
</tr>
</table> </table>
</div> </div>
</div> </div>

View File

@ -66,6 +66,7 @@ def resize_image_to_height(image, width, height):
is larger than 'width' then crop it. If the resulting width is smaller than is larger than 'width' then crop it. If the resulting width is smaller than
'width' then stretch it. 'width' then stretch it.
''' '''
image = image.convert('RGB')
ratio = image.width / image.height ratio = image.width / image.height
scaled_width = math.ceil(height * ratio) scaled_width = math.ceil(height * ratio)
if scaled_width < width: if scaled_width < width:
@ -116,3 +117,30 @@ def seconds_to_timestr(seconds):
minutes = seconds // 60 minutes = seconds // 60
seconds %= 60 seconds %= 60
return '{:02d}:{:02d}:{:02d}'.format(hour, minutes, seconds) return '{:02d}:{:02d}:{:02d}'.format(hour, minutes, seconds)
def parse_media_format(format_dict):
vcodec_full = format_dict.get('vcodec', '')
vcodec_parts = vcodec_full.split('.')
if len(vcodec_parts) > 0:
vcodec = vcodec_parts[0].strip().upper()
else:
vcodec = None
if vcodec == 'NONE':
vcodec = None
acodec_full = format_dict.get('acodec', '')
acodec_parts = acodec_full.split('.')
if len(acodec_parts) > 0:
acodec = acodec_parts[0].strip().upper()
else:
acodec = None
if acodec == 'NONE':
acodec = None
return {
'id': format_dict.get('format_id', ''),
'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,
'acodec': acodec,
}

View File

@ -335,6 +335,7 @@ class MediaThumbView(DetailView):
thumb = open(media.thumb.path, 'rb').read() thumb = open(media.thumb.path, 'rb').read()
content_type = 'image/jpeg' content_type = 'image/jpeg'
else: else:
# No thumbnail on disk, return a blank 1x1 gif
thumb = b64decode('R0lGODlhAQABAIABAP///wAAACH5BAEKAAEALAA' thumb = b64decode('R0lGODlhAQABAIABAP///wAAACH5BAEKAAEALAA'
'AAAABAAEAAAICTAEAOw==') 'AAAABAAEAAAICTAEAOw==')
content_type = 'image/gif' content_type = 'image/gif'
@ -361,13 +362,3 @@ class TasksView(TemplateView):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
'''
The last X days of logs.
'''
template_name = 'sync/logs.html'
def dispatch(self, request, *args, **kwargs):
return super().dispatch(request, *args, **kwargs)