media indexing and management

This commit is contained in:
meeb 2020-12-06 12:22:16 +11:00
parent 85844549df
commit 149d1357e6
21 changed files with 629 additions and 74 deletions

View File

@ -16,6 +16,7 @@ django-compressor = "*"
httptools = "*" httptools = "*"
youtube-dl = "*" youtube-dl = "*"
django-background-tasks = "*" django-background-tasks = "*"
requests = "*"
[requires] [requires]
python_version = "3" python_version = "3"

44
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "229a3783dcfd9d030ac58856ecb5d52d806db6acfa40eb6346eb6b58d79e91ab" "sha256": "a4bb556fc61ee4583f9588980450b071814298ee4d1a1023fad149c14d14aaba"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -23,6 +23,20 @@
], ],
"version": "==3.3.1" "version": "==3.3.1"
}, },
"certifi": {
"hashes": [
"sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd",
"sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4"
],
"version": "==2020.11.8"
},
"chardet": {
"hashes": [
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
],
"version": "==3.0.4"
},
"django": { "django": {
"hashes": [ "hashes": [
"sha256:5c866205f15e7a7123f1eec6ab939d22d5bde1416635cab259684af66d8e48a2", "sha256:5c866205f15e7a7123f1eec6ab939d22d5bde1416635cab259684af66d8e48a2",
@ -92,6 +106,13 @@
"index": "pypi", "index": "pypi",
"version": "==0.1.1" "version": "==0.1.1"
}, },
"idna": {
"hashes": [
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
],
"version": "==2.10"
},
"libsass": { "libsass": {
"hashes": [ "hashes": [
"sha256:1521d2a8d4b397c6ec90640a1f6b5529077035efc48ef1c2e53095544e713d1b", "sha256:1521d2a8d4b397c6ec90640a1f6b5529077035efc48ef1c2e53095544e713d1b",
@ -158,6 +179,14 @@
], ],
"version": "==1.0.6" "version": "==1.0.6"
}, },
"requests": {
"hashes": [
"sha256:7f1a0b932f4a60a1a65caa4263921bb7d9ee911957e0ae4a23a6dd08185ad5f8",
"sha256:e786fa28d8c9154e6a4de5d46a1d921b8749f8b74e28bde23768e5e16eece998"
],
"index": "pypi",
"version": "==2.25.0"
},
"rjsmin": { "rjsmin": {
"hashes": [ "hashes": [
"sha256:0ab825839125eaca57cc59581d72e596e58a7a56fbc0839996b7528f0343a0a8", "sha256:0ab825839125eaca57cc59581d72e596e58a7a56fbc0839996b7528f0343a0a8",
@ -190,6 +219,13 @@
], ],
"version": "==0.4.1" "version": "==0.4.1"
}, },
"urllib3": {
"hashes": [
"sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08",
"sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"
],
"version": "==1.26.2"
},
"whitenoise": { "whitenoise": {
"hashes": [ "hashes": [
"sha256:05ce0be39ad85740a78750c86a93485c40f08ad8c62a6006de0233765996e5c7", "sha256:05ce0be39ad85740a78750c86a93485c40f08ad8c62a6006de0233765996e5c7",
@ -200,11 +236,11 @@
}, },
"youtube-dl": { "youtube-dl": {
"hashes": [ "hashes": [
"sha256:bc82acb0b59b25b822fad85bef0cbe78e5754ca532e3bd6899fe06386e2b8e7c", "sha256:c73c79ccaabb7eabc223b36889ad9d3fbe04433d933312e8752d6a2c2bdc028d",
"sha256:daa514c0d36af478fe249ea93ca63ae3aaa60ac5f82aa3d5514bce9ba7ed8451" "sha256:cd7a072314ee67472de75a0f770bffc1363be46921c28bfc47a786a5188c417f"
], ],
"index": "pypi", "index": "pypi",
"version": "==2020.12.2" "version": "==2020.12.5"
} }
}, },
"develop": {} "develop": {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@ -40,6 +40,9 @@ $collection-no-items-text-colour: $colour-near-black;
$collection-background-hover-colour: $colour-orange; $collection-background-hover-colour: $colour-orange;
$collection-text-hover-colour: $colour-near-white; $collection-text-hover-colour: $colour-near-white;
$mediacard-title-background-colour: $colour-white;
$mediacard-title-text-colour: $colour-black;
$box-error-background-colour: $colour-red; $box-error-background-colour: $colour-red;
$box-error-text-colour: $colour-near-white; $box-error-text-colour: $colour-near-white;

View File

@ -104,6 +104,23 @@ main {
} }
} }
.mediacard {
.card-title {
padding: 3px 10px 3px 5px !important;
margin: 5px !important;
line-height: 1.5rem;
font-size: 1.5rem;
left: initial !important;
right: 0 !important;
max-width: 75% !important;
opacity: 80%;
background-color: $mediacard-title-background-colour !important;
color: $mediacard-title-text-colour !important;
span {
font-size: 1rem;
}
}
}
.pagination { .pagination {
width: 100%; width: 100%;

View File

@ -3,7 +3,7 @@
<div class="col s12"> <div class="col s12">
<div class="card infobox"> <div class="card infobox">
<div class="card-content"> <div class="card-content">
<i class="fas fa-info-circle"></i> {{ message }} <i class="fas fa-info-circle"></i> {{ message|safe }}
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,23 @@
# Generated by Django 3.1.3 on 2020-12-05 04:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sync', '0004_remove_source_url'),
]
operations = [
migrations.AlterField(
model_name='media',
name='thumb_height',
field=models.PositiveSmallIntegerField(blank=True, help_text='Height (Y) of the thumbnail', null=True, verbose_name='thumb height'),
),
migrations.AlterField(
model_name='media',
name='thumb_width',
field=models.PositiveSmallIntegerField(blank=True, help_text='Width (X) of the thumbnail', null=True, verbose_name='thumb width'),
),
]

View File

@ -0,0 +1,36 @@
# Generated by Django 3.1.3 on 2020-12-05 05:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sync', '0005_auto_20201205_0411'),
]
operations = [
migrations.RemoveField(
model_name='source',
name='output_format',
),
migrations.RemoveField(
model_name='source',
name='source_profile',
),
migrations.AddField(
model_name='source',
name='source_resolution',
field=models.CharField(choices=[('360p', '360p (SD)'), ('480p', '480p (SD)'), ('720p', '720p (HD)'), ('1080p', '1080p (Full HD)'), ('2160p', '2160p (4K)'), ('audio', 'Audio only')], db_index=True, default='1080p', help_text='Source resolution, desired video resolution to download', max_length=8, verbose_name='source resolution'),
),
migrations.AddField(
model_name='source',
name='source_vcodec',
field=models.CharField(choices=[('M4A', 'M4A'), ('OPUS', 'OPUS'), ('AAC', 'AAC')], db_index=True, default='OPUS', help_text='Source audio codec, desired audio encoding format to download', max_length=8, verbose_name='source audio codec'),
),
migrations.AlterField(
model_name='source',
name='prefer_60fps',
field=models.BooleanField(default=True, help_text='Where possible, prefer 60fps media for this source', verbose_name='prefer 60fps'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.1.3 on 2020-12-05 05:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sync', '0006_auto_20201205_0502'),
]
operations = [
migrations.AlterField(
model_name='source',
name='source_vcodec',
field=models.CharField(choices=[('M4A', 'M4A'), ('OPUS', 'OPUS')], db_index=True, default='OPUS', help_text='Source audio codec, desired audio encoding format to download', max_length=8, verbose_name='source audio codec'),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.1.3 on 2020-12-05 05:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sync', '0007_auto_20201205_0509'),
]
operations = [
migrations.AddField(
model_name='source',
name='source_acodec',
field=models.CharField(choices=[('M4A', 'M4A'), ('OPUS', 'OPUS')], db_index=True, default='OPUS', help_text='Source audio codec, desired audio encoding format to download', max_length=8, verbose_name='source audio codec'),
),
migrations.AlterField(
model_name='source',
name='source_vcodec',
field=models.CharField(choices=[('AVC1', 'AVC1 (H.264)'), ('VP9', 'VP9')], db_index=True, default='VP9', help_text='Source video codec, desired video encoding format to download', max_length=8, verbose_name='source video codec'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.1.3 on 2020-12-05 05:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sync', '0008_auto_20201205_0512'),
]
operations = [
migrations.AlterField(
model_name='source',
name='source_vcodec',
field=models.CharField(choices=[('AVC1', 'AVC1 (H.264)'), ('VP9', 'VP9')], db_index=True, default='VP9', help_text='Source video codec, desired video encoding format to download (ignored if "resolution" is audio only)', max_length=8, verbose_name='source video codec'),
),
]

View File

@ -1,7 +1,12 @@
import uuid import uuid
import json
from datetime import datetime
from pathlib import Path
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
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
class Source(models.Model): class Source(models.Model):
@ -18,36 +23,40 @@ class Source(models.Model):
(SOURCE_TYPE_YOUTUBE_PLAYLIST, _('YouTube playlist')), (SOURCE_TYPE_YOUTUBE_PLAYLIST, _('YouTube playlist')),
) )
SOURCE_PROFILE_360p = '360p' SOURCE_RESOLUTION_360p = '360p'
SOURCE_PROFILE_480p = '480p' SOURCE_RESOLUTION_480p = '480p'
SOURCE_PROFILE_720P = '720p' SOURCE_RESOLUTION_720P = '720p'
SOURCE_PROFILE_1080P = '1080p' SOURCE_RESOLUTION_1080P = '1080p'
SOURCE_PROFILE_2160P = '2160p' SOURCE_RESOLUTION_2160P = '2160p'
SOURCE_PROFILE_AUDIO = 'audio' SOURCE_RESOLUTION_AUDIO = 'audio'
SOURCE_PROFILES = (SOURCE_PROFILE_360p, SOURCE_PROFILE_480p, SOURCE_PROFILE_720P, SOURCE_RESOLUTIONS = (SOURCE_RESOLUTION_360p, SOURCE_RESOLUTION_480p,
SOURCE_PROFILE_1080P, SOURCE_PROFILE_2160P, SOURCE_RESOLUTION_720P, SOURCE_RESOLUTION_1080P,
SOURCE_PROFILE_AUDIO) SOURCE_RESOLUTION_2160P, SOURCE_RESOLUTION_AUDIO)
SOURCE_PROFILE_CHOICES = ( SOURCE_RESOLUTION_CHOICES = (
(SOURCE_PROFILE_360p, _('360p (SD)')), (SOURCE_RESOLUTION_360p, _('360p (SD)')),
(SOURCE_PROFILE_480p, _('480p (SD)')), (SOURCE_RESOLUTION_480p, _('480p (SD)')),
(SOURCE_PROFILE_720P, _('720p (HD)')), (SOURCE_RESOLUTION_720P, _('720p (HD)')),
(SOURCE_PROFILE_1080P, _('1080p (Full HD)')), (SOURCE_RESOLUTION_1080P, _('1080p (Full HD)')),
(SOURCE_PROFILE_2160P, _('2160p (4K)')), (SOURCE_RESOLUTION_2160P, _('2160p (4K)')),
(SOURCE_PROFILE_AUDIO, _('Audio only')), (SOURCE_RESOLUTION_AUDIO, _('Audio only')),
) )
OUTPUT_FORMAT_MP4 = 'mp4' SOURCE_VCODEC_AVC1 = 'AVC1'
OUTPUT_FORMAT_MKV = 'mkv' SOURCE_VCODEC_VP9 = 'VP9'
OUTPUT_FORMAT_M4A = 'm4a' SOURCE_VCODECS = (SOURCE_VCODEC_AVC1, SOURCE_VCODEC_VP9)
OUTPUT_FORMAT_OGG = 'ogg' SOURCE_VCODECS_PRIORITY = (SOURCE_VCODEC_VP9, SOURCE_VCODEC_AVC1)
OUTPUT_FORMATS = (OUTPUT_FORMAT_MP4, OUTPUT_FORMAT_MKV, OUTPUT_FORMAT_M4A, SOURCE_VCODEC_CHOICES = (
OUTPUT_FORMAT_OGG) (SOURCE_VCODEC_AVC1, _('AVC1 (H.264)')),
OUTPUT_FORMAT_CHOICES = ( (SOURCE_VCODEC_VP9, _('VP9')),
(OUTPUT_FORMAT_MP4, _('.mp4 container')), )
(OUTPUT_FORMAT_MKV, _('.mkv container')),
(OUTPUT_FORMAT_MKV, _('.webm container')), SOURCE_ACODEC_M4A = 'M4A'
(OUTPUT_FORMAT_M4A, _('.m4a container (audio only)')), SOURCE_ACODEC_OPUS = 'OPUS'
(OUTPUT_FORMAT_OGG, _('.ogg container (audio only)')), SOURCE_ACODECS = (SOURCE_ACODEC_M4A, SOURCE_ACODEC_OPUS)
SOURCE_ACODEC_PRIORITY = (SOURCE_ACODEC_OPUS, SOURCE_ACODEC_M4A)
SOURCE_ACODEC_CHOICES = (
(SOURCE_ACODEC_M4A, _('M4A')),
(SOURCE_ACODEC_OPUS, _('OPUS')),
) )
FALLBACK_FAIL = 'f' FALLBACK_FAIL = 'f'
@ -56,8 +65,8 @@ class Source(models.Model):
FALLBACKS = (FALLBACK_FAIL, FALLBACK_NEXT_SD, FALLBACK_NEXT_HD) FALLBACKS = (FALLBACK_FAIL, FALLBACK_NEXT_SD, FALLBACK_NEXT_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 instead')), (FALLBACK_NEXT_SD, _('Get next best SD media or codec instead')),
(FALLBACK_NEXT_HD, _('Get next best HD media instead')), (FALLBACK_NEXT_HD, _('Get next best HD media or codec instead')),
) )
ICONS = { ICONS = {
@ -70,6 +79,16 @@ class Source(models.Model):
SOURCE_TYPE_YOUTUBE_PLAYLIST: 'https://www.youtube.com/playlist?list={key}', SOURCE_TYPE_YOUTUBE_PLAYLIST: 'https://www.youtube.com/playlist?list={key}',
} }
INDEXERS = {
SOURCE_TYPE_YOUTUBE_CHANNEL: get_youtube_media_info,
SOURCE_TYPE_YOUTUBE_PLAYLIST: get_youtube_media_info,
}
KEY_FIELD = { # Field returned by indexing which contains a unique key
SOURCE_TYPE_YOUTUBE_CHANNEL: 'id',
SOURCE_TYPE_YOUTUBE_PLAYLIST: 'id',
}
uuid = models.UUIDField( uuid = models.UUIDField(
_('uuid'), _('uuid'),
primary_key=True, primary_key=True,
@ -127,17 +146,33 @@ class Source(models.Model):
help_text=_('If "delete old media" is ticked, the number of days after which ' help_text=_('If "delete old media" is ticked, the number of days after which '
'to automatically delete media') 'to automatically delete media')
) )
source_profile = models.CharField( source_resolution = models.CharField(
_('source profile'), _('source resolution'),
max_length=8, max_length=8,
db_index=True, db_index=True,
choices=SOURCE_PROFILE_CHOICES, choices=SOURCE_RESOLUTION_CHOICES,
default=SOURCE_PROFILE_1080P, default=SOURCE_RESOLUTION_1080P,
help_text=_('Source profile, the quality to attempt to download media') help_text=_('Source resolution, desired video resolution to download')
)
source_vcodec = models.CharField(
_('source video codec'),
max_length=8,
db_index=True,
choices=SOURCE_VCODEC_CHOICES,
default=SOURCE_VCODEC_VP9,
help_text=_('Source video codec, desired video encoding format to download (ignored if "resolution" is audio only)')
)
source_acodec = models.CharField(
_('source audio codec'),
max_length=8,
db_index=True,
choices=SOURCE_ACODEC_CHOICES,
default=SOURCE_ACODEC_OPUS,
help_text=_('Source audio codec, desired audio encoding format to download')
) )
prefer_60fps = models.BooleanField( prefer_60fps = models.BooleanField(
_('prefer 60fps'), _('prefer 60fps'),
default=False, default=True,
help_text=_('Where possible, prefer 60fps media for this source') help_text=_('Where possible, prefer 60fps media for this source')
) )
prefer_hdr = models.BooleanField( prefer_hdr = models.BooleanField(
@ -145,21 +180,13 @@ class Source(models.Model):
default=False, default=False,
help_text=_('Where possible, prefer HDR media for this source') help_text=_('Where possible, prefer HDR media for this source')
) )
output_format = models.CharField(
_('output format'),
max_length=8,
db_index=True,
choices=OUTPUT_FORMAT_CHOICES,
default=OUTPUT_FORMAT_MKV,
help_text=_('Output format, the file format container in which to save media')
)
fallback = models.CharField( fallback = models.CharField(
_('fallback'), _('fallback'),
max_length=1, max_length=1,
db_index=True, db_index=True,
choices=FALLBACK_CHOICES, choices=FALLBACK_CHOICES,
default=FALLBACK_FAIL, default=FALLBACK_FAIL,
help_text=_('What do do when media in your source profile is not available') help_text=_('What do do when media in your source resolution and codecs is not available')
) )
def __str__(self): def __str__(self):
@ -182,19 +209,47 @@ class Source(models.Model):
def url(self): def url(self):
return Source.create_url(self.source_type, self.key) return Source.create_url(self.source_type, self.key)
@property
def format_summary(self):
if self.source_resolution == Source.SOURCE_RESOLUTION_AUDIO:
vc = 'none'
else:
vc = self.source_vcodec
ac = self.source_acodec
f = '60FPS' if self.prefer_60fps else ''
h = 'HDR' if self.prefer_hdr else ''
return f'{self.source_resolution} (video:{vc}, audio:{ac}) {f} {h}'.strip()
@property @property
def directory_path(self): def directory_path(self):
if self.source_profile == self.SOURCE_PROFILE_AUDIO: if self.source_resolution == self.SOURCE_RESOLUTION_AUDIO:
return settings.SYNC_AUDIO_ROOT / self.directory return settings.SYNC_AUDIO_ROOT / self.directory
else: else:
return settings.SYNC_VIDEO_ROOT / self.directory return settings.SYNC_VIDEO_ROOT / self.directory
@property
def key_field(self):
return self.KEY_FIELD.get(self.source_type, '')
def index_media(self):
'''
Index the media source returning a list of media metadata as dicts.
'''
indexer = self.INDEXERS.get(self.source_type, None)
if not callable(indexer):
raise Exception(f'Source type f"{self.source_type}" has no indexer')
response = indexer(self.url)
return response.get('entries', [])
def get_media_thumb_path(instance, filename): def get_media_thumb_path(instance, filename):
fileid = str(instance.uuid) fileid = str(instance.uuid)
filename = f'{fileid.lower()}.{instance.image_type.lower()}' filename = f'{fileid.lower()}.jpg'
prefix = fileid[:2] prefix = fileid[:2]
return os.path.join('thumbs', prefix, filename) return Path('thumbs') / prefix / filename
_metadata_cache = {}
class Media(models.Model): class Media(models.Model):
@ -203,6 +258,11 @@ class Media(models.Model):
Source. Source.
''' '''
URLS = {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'https://www.youtube.com/watch?v={key}',
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'https://www.youtube.com/watch?v={key}',
}
uuid = models.UUIDField( uuid = models.UUIDField(
_('uuid'), _('uuid'),
primary_key=True, primary_key=True,
@ -248,11 +308,13 @@ class Media(models.Model):
thumb_width = models.PositiveSmallIntegerField( thumb_width = models.PositiveSmallIntegerField(
_('thumb width'), _('thumb width'),
blank=True, blank=True,
null=True,
help_text=_('Width (X) of the thumbnail') help_text=_('Width (X) of the thumbnail')
) )
thumb_height = models.PositiveSmallIntegerField( thumb_height = models.PositiveSmallIntegerField(
_('thumb height'), _('thumb height'),
blank=True, blank=True,
null=True,
help_text=_('Height (Y) of the thumbnail') help_text=_('Height (Y) of the thumbnail')
) )
metadata = models.TextField( metadata = models.TextField(
@ -317,3 +379,53 @@ class Media(models.Model):
class Meta: class Meta:
verbose_name = _('Media') verbose_name = _('Media')
verbose_name_plural = _('Media') verbose_name_plural = _('Media')
@property
def loaded_metadata(self):
if self.pk in _metadata_cache:
return _metadata_cache[self.pk]
_metadata_cache[self.pk] = json.loads(self.metadata)
return _metadata_cache[self.pk]
@property
def extension(self):
'''
The extension is also used by youtube-dl to set the output container. As
it is possible to quite easily pick combinations of codecs and containers
which are invalid (e.g. OPUS audio in an MP4 container) just set this for
people. All video is set to mkv containers, audio-only is set to m4a or ogg
depending on audio codec.
'''
if self.source.source_resolution == Source.SOURCE_RESOLUTION_AUDIO:
if self.source.source_acodec == Source.SOURCE_ACODEC_M4A:
return 'm4a'
elif self.source.source_acodec == Source.SOURCE_ACODEC_OPUS:
return 'ogg'
else:
raise ValueError('Unable to choose audio extension, uknown acodec')
else:
return 'mkv'
@property
def url(self):
url = self.URLS.get(self.source.source_type, '')
return url.format(key=self.key)
@property
def title(self):
return self.loaded_metadata.get('title', '').strip()
@property
def upload_date(self):
upload_date_str = self.loaded_metadata.get('upload_date', '').strip()
try:
return datetime.strptime(upload_date_str, '%Y%m%d')
except ValueError as e:
return None
@property
def filename(self):
upload_date = self.upload_date.strftime('%Y-%m-%d')
title = slugify(self.title.replace('&', 'and').replace('+', 'and'))
ext = self.extension
return f'{upload_date}_{title}.{ext}'

38
app/sync/signals.py Normal file
View File

@ -0,0 +1,38 @@
from django.conf import settings
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from .models import Source, Media
from .tasks import delete_index_source_task, index_source_task, download_media_thumbnail
@receiver(post_save, sender=Source)
def source_post_save(sender, instance, created, **kwargs):
# Triggered when a source is saved
if created:
# If the source is newly created schedule its indexing
index_source_task(str(instance.pk), repeat=settings.INDEX_SOURCE_EVERY)
@receiver(post_delete, sender=Source)
def source_post_delete(sender, instance, **kwargs):
# Triggered when a source is deleted
delete_index_source_task(str(instance.pk))
@receiver(post_save, sender=Media)
def media_post_save(sender, instance, created, **kwargs):
# Triggered when media is saved
if created:
# If the media is newly created fire a task off to download its thumbnail
metadata = instance.loaded_metadata
thumbnail_url = metadata.get('thumbnail', '')
if thumbnail_url:
download_media_thumbnail(str(instance.pk), thumbnail_url)
@receiver(post_delete, sender=Media)
def media_post_delete(sender, instance, **kwargs):
# Triggered when media is deleted
pass
# TODO: delete thumbnail and media file from disk

84
app/sync/tasks.py Normal file
View File

@ -0,0 +1,84 @@
'''
Start, stop and manage scheduled tasks. These are generally triggered by Django
signals (see signals.py).
'''
import json
from io import BytesIO
from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile
from background_task import background
from background_task.models import Task
from .models import Source, Media
from .utils import get_remote_image
def delete_index_source_task(source_id):
task = None
try:
# get_task currently returns a QuerySet, but catch DoesNotExist just in case
task = Task.objects.get_task('sync.tasks.index_source_task', args=(source_id,))
except Task.DoesNotExist:
pass
if task:
# A scheduled task exists for this Source, delete it
task.delete()
@background(schedule=0)
def index_source_task(source_id):
'''
Indexes media available from a Source object.
'''
try:
source = Source.objects.get(pk=source_id)
except Source.DoesNotExist:
# Task triggered but the Source has been deleted, delete the task
delete_index_source_task(source_id)
return
videos = source.index_media()
for video in videos:
# Create or update each video as a Media object
key = video.get(source.key_field, None)
if not key:
# Video has no unique key (ID), it can't be indexed
continue
try:
media = Media.objects.get(key=key)
except Media.DoesNotExist:
media = Media(key=key)
media.source = source
media.metadata = json.dumps(video)
media.save()
@background(schedule=0)
def download_media_thumbnail(media_id, url):
'''
Downloads an image from a URL and saves it as a local thumbnail attached to a
Media object.
'''
try:
media = Media.objects.get(pk=media_id)
except Media.DoesNotExist:
# Task triggered but the media no longer exists, ignore task
return
i = get_remote_image(url)
max_width, max_height = getattr(settings, 'MAX_MEDIA_THUMBNAIL_SIZE', (512, 512))
if i.width > max_width or i.height > max_height:
# Image is larger than we want to save, resize it
i.thumbnail(size=(max_width, max_height))
image_file = BytesIO()
i.save(image_file, 'JPEG', quality=80, optimize=True, progressive=True)
image_file.seek(0)
media.thumb.save(
'thumb',
SimpleUploadedFile(
'thumb',
image_file.read(),
'image/jpeg',
),
save=True
)
return True

View File

@ -0,0 +1,39 @@
{% extends 'base.html' %}
{% block headtitle %}Media - {{ media.key }}{% endblock %}
{% block content %}
<div class="row">
<div class="col s12">
<h1 class="truncate">Media <strong>{{ media.key }}</strong></h1>
<p class="truncate"><strong><a href="{{ media.url }}" target="_blank"><i class="fas fa-link"></i> {{ media.url }}</a></strong></p>
<p class="truncate">Saving to: <strong>{{ media.source.directory_path }}</strong></p>
</div>
</div>
<div class="row">
<div class="col s12">
<table class="striped">
<tr title="The media source">
<td class="hide-on-small-only">Source</td>
<td><span class="hide-on-med-and-up">Source<br></span><strong><a href="{% url 'sync:source' pk=media.source.pk %}">{{ media.source }}</a></strong></td>
</tr>
<tr title="The media title">
<td class="hide-on-small-only">Title</td>
<td><span class="hide-on-med-and-up">Title<br></span><strong>{{ media.title }}</strong></td>
</tr>
<tr title="The filename the media will be downloaded as">
<td class="hide-on-small-only">Filename</td>
<td><span class="hide-on-med-and-up">Filename<br></span><strong>{{ media.filename }}</strong></td>
</tr>
<tr title="The desired format">
<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>
</tr>
<tr title="Has the media been downloaded">
<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>
</tr>
</table>
</div>
</div>
{% endblock %}

View File

@ -1,11 +1,30 @@
{% extends 'base.html' %} {% extends 'base.html' %}{% load static %}
{% block headtitle %}Media{% endblock %} {% block headtitle %}Media{% if source %} - {{ source }}{% endif %}{% endblock %}
{% block content %} {% block content %}
<div class="row"> {% include 'infobox.html' with message=message %}
<div class="col s12"> <div class="row no-margin-bottom">
media {% for m in media %}
<div class="col s12 m6 l4 xl3">
<a href="{% url 'sync:media-item' pk=m.pk %}" class="collection-item">
<div class="card mediacard">
<div class="card-image">
<img src="{% if m.thumb %}{% url 'sync:media-thumb' pk=m.pk %}{% else %}{% static 'images/nothumb.jpg' %} {% endif %}">
<span class="card-title truncate">{{ m.source }}<br>
<span>{{ m }}</span>
</span>
</div> </div>
</div>
</a>
</div>
{% empty %}
<div class="col s12">
<div class="collection">
<span class="collection-item no-items"><i class="fas fa-info-circle"></i> No media has been indexed.</span>
</div>
</div>
{% endfor %}
</div> </div>
{% include 'pagination.html' with pagination=sources.paginator %}
{% endblock %} {% endblock %}

View File

@ -1,7 +1,7 @@
from django.urls import path from django.urls import path
from .views import (DashboardView, SourcesView, ValidateSourceView, AddSourceView, from .views import (DashboardView, SourcesView, ValidateSourceView, AddSourceView,
SourceView, UpdateSourceView, DeleteSourceView, MediaView, SourceView, UpdateSourceView, DeleteSourceView, MediaView,
TasksView, LogsView) MediaThumbView, MediaItemView, TasksView, LogsView)
app_name = 'sync' app_name = 'sync'
@ -41,12 +41,20 @@ urlpatterns = [
DeleteSourceView.as_view(), DeleteSourceView.as_view(),
name='delete-source'), name='delete-source'),
# Media URLs (note /media/ is the static media URL, don't use that) # Media URLs
path('mediafiles', path('media',
MediaView.as_view(), MediaView.as_view(),
name='media'), name='media'),
path('media-thumb/<uuid:pk>',
MediaThumbView.as_view(),
name='media-thumb'),
path('media-item/<uuid:pk>',
MediaItemView.as_view(),
name='media-item'),
# Task URLs # Task URLs
path('tasks', path('tasks',

View File

@ -1,4 +1,6 @@
import re import re
import requests
from PIL import Image
from urllib.parse import urlsplit, parse_qs from urllib.parse import urlsplit, parse_qs
from django.forms import ValidationError from django.forms import ValidationError
@ -42,3 +44,13 @@ def validate_url(url, validator):
elif extract_from == 'qs_args': elif extract_from == 'qs_args':
extract_value = url_query_parts[extract_param][0] extract_value = url_query_parts[extract_param][0]
return extract_value return extract_value
def get_remote_image(url):
headers = {
'user-agent': ('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/69.0.3497.64 Safari/537.36')
}
r = requests.get(url, headers=headers, stream=True, timeout=60)
r.raw.decode_content = True
return Image.open(r.raw)

View File

@ -1,16 +1,19 @@
from base64 import b64decode
from django.conf import settings from django.conf import settings
from django.http import Http404 from django.http import Http404
from django.views.generic import TemplateView, ListView, DetailView from django.views.generic import TemplateView, ListView, DetailView
from django.views.generic.edit import (FormView, FormMixin, CreateView, UpdateView, from django.views.generic.edit import (FormView, FormMixin, CreateView, UpdateView,
DeleteView) DeleteView)
from django.http import HttpResponse
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.forms import ValidationError from django.forms import ValidationError
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 common.utils import append_uri_params from common.utils import append_uri_params
from .models import Source from .models import Source, Media
from .forms import ValidateSourceForm, ConfirmDeleteSourceForm from .forms import ValidateSourceForm, ConfirmDeleteSourceForm
from .utils import validate_url from .utils import validate_url
from . import signals
from . import youtube from . import youtube
@ -196,8 +199,8 @@ class AddSourceView(CreateView):
template_name = 'sync/source-add.html' template_name = 'sync/source-add.html'
model = Source model = Source
fields = ('source_type', 'key', 'name', 'directory', 'delete_old_media', fields = ('source_type', 'key', 'name', 'directory', 'delete_old_media',
'days_to_keep', 'source_profile', 'prefer_60fps', 'prefer_hdr', 'days_to_keep', 'source_resolution', 'source_vcodec', 'source_acodec',
'output_format', 'fallback') 'prefer_60fps', 'prefer_hdr', 'fallback')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.prepopulated_data = {} self.prepopulated_data = {}
@ -240,8 +243,8 @@ class UpdateSourceView(UpdateView):
template_name = 'sync/source-update.html' template_name = 'sync/source-update.html'
model = Source model = Source
fields = ('source_type', 'key', 'name', 'directory', 'delete_old_media', fields = ('source_type', 'key', 'name', 'directory', 'delete_old_media',
'days_to_keep', 'source_profile', 'prefer_60fps', 'prefer_hdr', 'days_to_keep', 'source_resolution', 'source_vcodec', 'source_acodec',
'output_format', 'fallback') 'prefer_60fps', 'prefer_hdr', 'fallback')
def get_success_url(self): def get_success_url(self):
url = reverse_lazy('sync:sources') url = reverse_lazy('sync:sources')
@ -272,16 +275,76 @@ class DeleteSourceView(DeleteView, FormMixin):
return append_uri_params(url, {'message': 'source-deleted'}) return append_uri_params(url, {'message': 'source-deleted'})
class MediaView(TemplateView): class MediaView(ListView):
''' '''
A bare list of media added with their states. A bare list of media added with their states.
''' '''
template_name = 'sync/media.html' template_name = 'sync/media.html'
context_object_name = 'media'
paginate_by = settings.MEDIA_PER_PAGE
messages = {
'filter': _('Viewing media for source: <strong>{name}</strong>'),
}
def __init__(self, *args, **kwargs):
self.filter_source = None
super().__init__(*args, **kwargs)
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
filter_by = request.GET.get('filter', '')
if filter_by:
try:
self.filter_source = Source.objects.get(pk=filter_by)
except Source.DoesNotExist:
self.filter_source = None
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get_queryset(self):
if self.filter_source:
return Media.objects.filter(source=self.filter_source).order_by('-created')
else:
return Media.objects.all().order_by('-created')
def get_context_data(self, *args, **kwargs):
data = super().get_context_data(*args, **kwargs)
data['message'] = ''
data['source'] = None
if self.filter_source:
message = str(self.messages.get('filter', ''))
print(message)
data['message'] = message.format(name=self.filter_source.name)
data['source'] = self.filter_source
print(data)
return data
class MediaThumbView(DetailView):
'''
Shows a media thumbnail. Whitenose doesn't support post-start media image
serving and the images here are pretty small, just serve them manually. This
isn't fast, but it's not likely to be a serious bottleneck.
'''
model = Media
def get(self, request, *args, **kwargs):
media = self.get_object()
if media.thumb:
thumb = open(media.thumb.path, 'rb').read()
content_type = 'image/jpeg'
else:
thumb = b64decode('R0lGODlhAQABAIABAP///wAAACH5BAEKAAEALAA'
'AAAABAAEAAAICTAEAOw==')
content_type = 'image/gif'
return HttpResponse(thumb, content_type=content_type)
class MediaItemView(DetailView):
template_name = 'sync/media-item.html'
model = Media
class TasksView(TemplateView): class TasksView(TemplateView):
''' '''

View File

@ -15,22 +15,20 @@ class YouTubeError(youtube_dl.utils.DownloadError):
''' '''
Generic wrapped error for all errors that could be raised by youtube-dl. Generic wrapped error for all errors that could be raised by youtube-dl.
''' '''
pass pass
def extract_info(url): def get_media_info(url):
''' '''
Extracts information from a YouTube URL and returns it as a dict. For a channel Extracts information from a YouTube URL and returns it as a dict. For a channel
or playlist this returns a dict of all the videos on the channel or playlist or playlist this returns a dict of all the videos on the channel or playlist
as well as associated metadata. as well as associated metadata.
''' '''
opts = _defaults.update({ opts = _defaults.update({
'skip_download': True, 'skip_download': True,
'forcejson': True, 'forcejson': True,
'simulate': True, 'simulate': True,
'extract_flat': 'in_playlist',
'playlist_items': 1,
}) })
response = {} response = {}
with youtube_dl.YoutubeDL(opts) as y: with youtube_dl.YoutubeDL(opts) as y:

View File

@ -96,7 +96,7 @@ USE_TZ = True
STATIC_URL = '/static/' STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'static' STATIC_ROOT = BASE_DIR / 'static'
MEDIA_URL = '/media/' #MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media' MEDIA_ROOT = BASE_DIR / 'media'
SYNC_VIDEO_ROOT = BASE_DIR / 'downloads' / 'video' SYNC_VIDEO_ROOT = BASE_DIR / 'downloads' / 'video'
SYNC_AUDIO_ROOT = BASE_DIR / 'downloads' / 'audio' SYNC_AUDIO_ROOT = BASE_DIR / 'downloads' / 'audio'
@ -121,10 +121,17 @@ BACKGROUND_TASK_PRIORITY_ORDERING = 'DESC' # Process high priority tasks first
SOURCES_PER_PAGE = 25 SOURCES_PER_PAGE = 25
MEDIA_PER_PAGE = 25
INDEX_SOURCE_EVERY = 60 # Seconds between indexing sources, 21600 = every 6 hours
MAX_MEDIA_THUMBNAIL_SIZE = (320, 240) # Max size in pixels for media thumbnails
YOUTUBE_DEFAULTS = { YOUTUBE_DEFAULTS = {
'age_limit': 99, 'age_limit': 99, # Age in years to spoof the client as
} }