diff --git a/Pipfile b/Pipfile
index 9d8e001..03e83ad 100644
--- a/Pipfile
+++ b/Pipfile
@@ -16,6 +16,7 @@ django-compressor = "*"
httptools = "*"
youtube-dl = "*"
django-background-tasks = "*"
+requests = "*"
[requires]
python_version = "3"
diff --git a/Pipfile.lock b/Pipfile.lock
index 9a4b1c7..cf3534a 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "229a3783dcfd9d030ac58856ecb5d52d806db6acfa40eb6346eb6b58d79e91ab"
+ "sha256": "a4bb556fc61ee4583f9588980450b071814298ee4d1a1023fad149c14d14aaba"
},
"pipfile-spec": 6,
"requires": {
@@ -23,6 +23,20 @@
],
"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": {
"hashes": [
"sha256:5c866205f15e7a7123f1eec6ab939d22d5bde1416635cab259684af66d8e48a2",
@@ -92,6 +106,13 @@
"index": "pypi",
"version": "==0.1.1"
},
+ "idna": {
+ "hashes": [
+ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
+ "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
+ ],
+ "version": "==2.10"
+ },
"libsass": {
"hashes": [
"sha256:1521d2a8d4b397c6ec90640a1f6b5529077035efc48ef1c2e53095544e713d1b",
@@ -158,6 +179,14 @@
],
"version": "==1.0.6"
},
+ "requests": {
+ "hashes": [
+ "sha256:7f1a0b932f4a60a1a65caa4263921bb7d9ee911957e0ae4a23a6dd08185ad5f8",
+ "sha256:e786fa28d8c9154e6a4de5d46a1d921b8749f8b74e28bde23768e5e16eece998"
+ ],
+ "index": "pypi",
+ "version": "==2.25.0"
+ },
"rjsmin": {
"hashes": [
"sha256:0ab825839125eaca57cc59581d72e596e58a7a56fbc0839996b7528f0343a0a8",
@@ -190,6 +219,13 @@
],
"version": "==0.4.1"
},
+ "urllib3": {
+ "hashes": [
+ "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08",
+ "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"
+ ],
+ "version": "==1.26.2"
+ },
"whitenoise": {
"hashes": [
"sha256:05ce0be39ad85740a78750c86a93485c40f08ad8c62a6006de0233765996e5c7",
@@ -200,11 +236,11 @@
},
"youtube-dl": {
"hashes": [
- "sha256:bc82acb0b59b25b822fad85bef0cbe78e5754ca532e3bd6899fe06386e2b8e7c",
- "sha256:daa514c0d36af478fe249ea93ca63ae3aaa60ac5f82aa3d5514bce9ba7ed8451"
+ "sha256:c73c79ccaabb7eabc223b36889ad9d3fbe04433d933312e8752d6a2c2bdc028d",
+ "sha256:cd7a072314ee67472de75a0f770bffc1363be46921c28bfc47a786a5188c417f"
],
"index": "pypi",
- "version": "==2020.12.2"
+ "version": "==2020.12.5"
}
},
"develop": {}
diff --git a/app/common/static/images/nothumb.jpg b/app/common/static/images/nothumb.jpg
new file mode 100644
index 0000000..c186a3c
Binary files /dev/null and b/app/common/static/images/nothumb.jpg differ
diff --git a/app/common/static/styles/_colours.scss b/app/common/static/styles/_colours.scss
index b5f4b70..07cb185 100644
--- a/app/common/static/styles/_colours.scss
+++ b/app/common/static/styles/_colours.scss
@@ -40,6 +40,9 @@ $collection-no-items-text-colour: $colour-near-black;
$collection-background-hover-colour: $colour-orange;
$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-text-colour: $colour-near-white;
diff --git a/app/common/static/styles/_template.scss b/app/common/static/styles/_template.scss
index 3a1c46f..fc5d181 100644
--- a/app/common/static/styles/_template.scss
+++ b/app/common/static/styles/_template.scss
@@ -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 {
width: 100%;
diff --git a/app/common/templates/infobox.html b/app/common/templates/infobox.html
index fcb9b38..269dbd1 100644
--- a/app/common/templates/infobox.html
+++ b/app/common/templates/infobox.html
@@ -3,7 +3,7 @@
- {{ message }}
+ {{ message|safe }}
diff --git a/app/sync/migrations/0005_auto_20201205_0411.py b/app/sync/migrations/0005_auto_20201205_0411.py
new file mode 100644
index 0000000..32511ae
--- /dev/null
+++ b/app/sync/migrations/0005_auto_20201205_0411.py
@@ -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'),
+ ),
+ ]
diff --git a/app/sync/migrations/0006_auto_20201205_0502.py b/app/sync/migrations/0006_auto_20201205_0502.py
new file mode 100644
index 0000000..4490d4e
--- /dev/null
+++ b/app/sync/migrations/0006_auto_20201205_0502.py
@@ -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'),
+ ),
+ ]
diff --git a/app/sync/migrations/0007_auto_20201205_0509.py b/app/sync/migrations/0007_auto_20201205_0509.py
new file mode 100644
index 0000000..e3c6486
--- /dev/null
+++ b/app/sync/migrations/0007_auto_20201205_0509.py
@@ -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'),
+ ),
+ ]
diff --git a/app/sync/migrations/0008_auto_20201205_0512.py b/app/sync/migrations/0008_auto_20201205_0512.py
new file mode 100644
index 0000000..aecc588
--- /dev/null
+++ b/app/sync/migrations/0008_auto_20201205_0512.py
@@ -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'),
+ ),
+ ]
diff --git a/app/sync/migrations/0009_auto_20201205_0512.py b/app/sync/migrations/0009_auto_20201205_0512.py
new file mode 100644
index 0000000..ca84fee
--- /dev/null
+++ b/app/sync/migrations/0009_auto_20201205_0512.py
@@ -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'),
+ ),
+ ]
diff --git a/app/sync/models.py b/app/sync/models.py
index befeb92..d4c4ae9 100644
--- a/app/sync/models.py
+++ b/app/sync/models.py
@@ -1,7 +1,12 @@
import uuid
+import json
+from datetime import datetime
+from pathlib import Path
from django.conf import settings
from django.db import models
+from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
+from .youtube import get_media_info as get_youtube_media_info
class Source(models.Model):
@@ -18,36 +23,40 @@ class Source(models.Model):
(SOURCE_TYPE_YOUTUBE_PLAYLIST, _('YouTube playlist')),
)
- SOURCE_PROFILE_360p = '360p'
- SOURCE_PROFILE_480p = '480p'
- SOURCE_PROFILE_720P = '720p'
- SOURCE_PROFILE_1080P = '1080p'
- SOURCE_PROFILE_2160P = '2160p'
- SOURCE_PROFILE_AUDIO = 'audio'
- SOURCE_PROFILES = (SOURCE_PROFILE_360p, SOURCE_PROFILE_480p, SOURCE_PROFILE_720P,
- SOURCE_PROFILE_1080P, SOURCE_PROFILE_2160P,
- SOURCE_PROFILE_AUDIO)
- SOURCE_PROFILE_CHOICES = (
- (SOURCE_PROFILE_360p, _('360p (SD)')),
- (SOURCE_PROFILE_480p, _('480p (SD)')),
- (SOURCE_PROFILE_720P, _('720p (HD)')),
- (SOURCE_PROFILE_1080P, _('1080p (Full HD)')),
- (SOURCE_PROFILE_2160P, _('2160p (4K)')),
- (SOURCE_PROFILE_AUDIO, _('Audio only')),
+ SOURCE_RESOLUTION_360p = '360p'
+ SOURCE_RESOLUTION_480p = '480p'
+ SOURCE_RESOLUTION_720P = '720p'
+ SOURCE_RESOLUTION_1080P = '1080p'
+ SOURCE_RESOLUTION_2160P = '2160p'
+ SOURCE_RESOLUTION_AUDIO = 'audio'
+ SOURCE_RESOLUTIONS = (SOURCE_RESOLUTION_360p, SOURCE_RESOLUTION_480p,
+ SOURCE_RESOLUTION_720P, SOURCE_RESOLUTION_1080P,
+ SOURCE_RESOLUTION_2160P, SOURCE_RESOLUTION_AUDIO)
+ SOURCE_RESOLUTION_CHOICES = (
+ (SOURCE_RESOLUTION_360p, _('360p (SD)')),
+ (SOURCE_RESOLUTION_480p, _('480p (SD)')),
+ (SOURCE_RESOLUTION_720P, _('720p (HD)')),
+ (SOURCE_RESOLUTION_1080P, _('1080p (Full HD)')),
+ (SOURCE_RESOLUTION_2160P, _('2160p (4K)')),
+ (SOURCE_RESOLUTION_AUDIO, _('Audio only')),
)
- OUTPUT_FORMAT_MP4 = 'mp4'
- OUTPUT_FORMAT_MKV = 'mkv'
- OUTPUT_FORMAT_M4A = 'm4a'
- OUTPUT_FORMAT_OGG = 'ogg'
- OUTPUT_FORMATS = (OUTPUT_FORMAT_MP4, OUTPUT_FORMAT_MKV, OUTPUT_FORMAT_M4A,
- OUTPUT_FORMAT_OGG)
- OUTPUT_FORMAT_CHOICES = (
- (OUTPUT_FORMAT_MP4, _('.mp4 container')),
- (OUTPUT_FORMAT_MKV, _('.mkv container')),
- (OUTPUT_FORMAT_MKV, _('.webm container')),
- (OUTPUT_FORMAT_M4A, _('.m4a container (audio only)')),
- (OUTPUT_FORMAT_OGG, _('.ogg container (audio only)')),
+ SOURCE_VCODEC_AVC1 = 'AVC1'
+ SOURCE_VCODEC_VP9 = 'VP9'
+ SOURCE_VCODECS = (SOURCE_VCODEC_AVC1, SOURCE_VCODEC_VP9)
+ SOURCE_VCODECS_PRIORITY = (SOURCE_VCODEC_VP9, SOURCE_VCODEC_AVC1)
+ SOURCE_VCODEC_CHOICES = (
+ (SOURCE_VCODEC_AVC1, _('AVC1 (H.264)')),
+ (SOURCE_VCODEC_VP9, _('VP9')),
+ )
+
+ SOURCE_ACODEC_M4A = 'M4A'
+ SOURCE_ACODEC_OPUS = 'OPUS'
+ 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'
@@ -56,8 +65,8 @@ class Source(models.Model):
FALLBACKS = (FALLBACK_FAIL, FALLBACK_NEXT_SD, FALLBACK_NEXT_HD)
FALLBACK_CHOICES = (
(FALLBACK_FAIL, _('Fail, do not download any media')),
- (FALLBACK_NEXT_SD, _('Get next best SD media instead')),
- (FALLBACK_NEXT_HD, _('Get next best HD media instead')),
+ (FALLBACK_NEXT_SD, _('Get next best SD media or codec instead')),
+ (FALLBACK_NEXT_HD, _('Get next best HD media or codec instead')),
)
ICONS = {
@@ -70,6 +79,16 @@ class Source(models.Model):
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'),
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 '
'to automatically delete media')
)
- source_profile = models.CharField(
- _('source profile'),
+ source_resolution = models.CharField(
+ _('source resolution'),
max_length=8,
db_index=True,
- choices=SOURCE_PROFILE_CHOICES,
- default=SOURCE_PROFILE_1080P,
- help_text=_('Source profile, the quality to attempt to download media')
+ choices=SOURCE_RESOLUTION_CHOICES,
+ default=SOURCE_RESOLUTION_1080P,
+ 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'),
- default=False,
+ default=True,
help_text=_('Where possible, prefer 60fps media for this source')
)
prefer_hdr = models.BooleanField(
@@ -145,21 +180,13 @@ class Source(models.Model):
default=False,
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'),
max_length=1,
db_index=True,
choices=FALLBACK_CHOICES,
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):
@@ -181,20 +208,48 @@ class Source(models.Model):
@property
def url(self):
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
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
else:
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):
fileid = str(instance.uuid)
- filename = f'{fileid.lower()}.{instance.image_type.lower()}'
+ filename = f'{fileid.lower()}.jpg'
prefix = fileid[:2]
- return os.path.join('thumbs', prefix, filename)
+ return Path('thumbs') / prefix / filename
+
+
+_metadata_cache = {}
class Media(models.Model):
@@ -203,6 +258,11 @@ class Media(models.Model):
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'),
primary_key=True,
@@ -248,11 +308,13 @@ class Media(models.Model):
thumb_width = models.PositiveSmallIntegerField(
_('thumb width'),
blank=True,
+ null=True,
help_text=_('Width (X) of the thumbnail')
)
thumb_height = models.PositiveSmallIntegerField(
_('thumb height'),
blank=True,
+ null=True,
help_text=_('Height (Y) of the thumbnail')
)
metadata = models.TextField(
@@ -317,3 +379,53 @@ class Media(models.Model):
class Meta:
verbose_name = _('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}'
diff --git a/app/sync/signals.py b/app/sync/signals.py
new file mode 100644
index 0000000..bdbbcd3
--- /dev/null
+++ b/app/sync/signals.py
@@ -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
diff --git a/app/sync/tasks.py b/app/sync/tasks.py
new file mode 100644
index 0000000..e51ceb1
--- /dev/null
+++ b/app/sync/tasks.py
@@ -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
diff --git a/app/sync/templates/sync/media-item.html b/app/sync/templates/sync/media-item.html
new file mode 100644
index 0000000..bbb3d70
--- /dev/null
+++ b/app/sync/templates/sync/media-item.html
@@ -0,0 +1,39 @@
+{% extends 'base.html' %}
+
+{% block headtitle %}Media - {{ media.key }}{% endblock %}
+
+{% block content %}
+
+
+
Media {{ media.key }}
+
{{ media.url }}
+
Saving to: {{ media.source.directory_path }}
+
+
+
+
+
+
+ Source |
+ Source {{ media.source }} |
+
+
+ Title |
+ Title {{ media.title }} |
+
+
+ Filename |
+ Filename {{ media.filename }} |
+
+
+ Desired format |
+ Desired format {{ media.source.format_summary }} |
+
+
+ Downloaded |
+ Downloaded {% if media.downloaded %}{% else %}{% endif %} |
+
+
+
+
+{% endblock %}
diff --git a/app/sync/templates/sync/media.html b/app/sync/templates/sync/media.html
index ab4a5ab..d779797 100644
--- a/app/sync/templates/sync/media.html
+++ b/app/sync/templates/sync/media.html
@@ -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 %}
-
-
- media
+{% include 'infobox.html' with message=message %}
+
+ {% for m in media %}
+
+ {% empty %}
+
+
+ No media has been indexed.
+
+
+ {% endfor %}
+{% include 'pagination.html' with pagination=sources.paginator %}
{% endblock %}
diff --git a/app/sync/urls.py b/app/sync/urls.py
index d6ee938..d60386a 100644
--- a/app/sync/urls.py
+++ b/app/sync/urls.py
@@ -1,7 +1,7 @@
from django.urls import path
from .views import (DashboardView, SourcesView, ValidateSourceView, AddSourceView,
SourceView, UpdateSourceView, DeleteSourceView, MediaView,
- TasksView, LogsView)
+ MediaThumbView, MediaItemView, TasksView, LogsView)
app_name = 'sync'
@@ -41,12 +41,20 @@ urlpatterns = [
DeleteSourceView.as_view(),
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(),
name='media'),
+ path('media-thumb/
',
+ MediaThumbView.as_view(),
+ name='media-thumb'),
+
+ path('media-item/',
+ MediaItemView.as_view(),
+ name='media-item'),
+
# Task URLs
path('tasks',
diff --git a/app/sync/utils.py b/app/sync/utils.py
index 3fec18e..5bde56b 100644
--- a/app/sync/utils.py
+++ b/app/sync/utils.py
@@ -1,4 +1,6 @@
import re
+import requests
+from PIL import Image
from urllib.parse import urlsplit, parse_qs
from django.forms import ValidationError
@@ -42,3 +44,13 @@ def validate_url(url, validator):
elif extract_from == 'qs_args':
extract_value = url_query_parts[extract_param][0]
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)
diff --git a/app/sync/views.py b/app/sync/views.py
index 8fabbbe..78f55af 100644
--- a/app/sync/views.py
+++ b/app/sync/views.py
@@ -1,16 +1,19 @@
+from base64 import b64decode
from django.conf import settings
from django.http import Http404
from django.views.generic import TemplateView, ListView, DetailView
from django.views.generic.edit import (FormView, FormMixin, CreateView, UpdateView,
DeleteView)
+from django.http import HttpResponse
from django.urls import reverse_lazy
from django.forms import ValidationError
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from common.utils import append_uri_params
-from .models import Source
+from .models import Source, Media
from .forms import ValidateSourceForm, ConfirmDeleteSourceForm
from .utils import validate_url
+from . import signals
from . import youtube
@@ -196,8 +199,8 @@ class AddSourceView(CreateView):
template_name = 'sync/source-add.html'
model = Source
fields = ('source_type', 'key', 'name', 'directory', 'delete_old_media',
- 'days_to_keep', 'source_profile', 'prefer_60fps', 'prefer_hdr',
- 'output_format', 'fallback')
+ 'days_to_keep', 'source_resolution', 'source_vcodec', 'source_acodec',
+ 'prefer_60fps', 'prefer_hdr', 'fallback')
def __init__(self, *args, **kwargs):
self.prepopulated_data = {}
@@ -240,8 +243,8 @@ class UpdateSourceView(UpdateView):
template_name = 'sync/source-update.html'
model = Source
fields = ('source_type', 'key', 'name', 'directory', 'delete_old_media',
- 'days_to_keep', 'source_profile', 'prefer_60fps', 'prefer_hdr',
- 'output_format', 'fallback')
+ 'days_to_keep', 'source_resolution', 'source_vcodec', 'source_acodec',
+ 'prefer_60fps', 'prefer_hdr', 'fallback')
def get_success_url(self):
url = reverse_lazy('sync:sources')
@@ -272,16 +275,76 @@ class DeleteSourceView(DeleteView, FormMixin):
return append_uri_params(url, {'message': 'source-deleted'})
-class MediaView(TemplateView):
+class MediaView(ListView):
'''
A bare list of media added with their states.
'''
template_name = 'sync/media.html'
+ context_object_name = 'media'
+ paginate_by = settings.MEDIA_PER_PAGE
+ messages = {
+ 'filter': _('Viewing media for source: {name}'),
+ }
+
+ def __init__(self, *args, **kwargs):
+ self.filter_source = None
+ super().__init__(*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)
+ 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):
'''
diff --git a/app/sync/youtube.py b/app/sync/youtube.py
index 27de315..0c3ac97 100644
--- a/app/sync/youtube.py
+++ b/app/sync/youtube.py
@@ -15,22 +15,20 @@ class YouTubeError(youtube_dl.utils.DownloadError):
'''
Generic wrapped error for all errors that could be raised by youtube-dl.
'''
-
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
or playlist this returns a dict of all the videos on the channel or playlist
as well as associated metadata.
'''
+
opts = _defaults.update({
'skip_download': True,
'forcejson': True,
'simulate': True,
- 'extract_flat': 'in_playlist',
- 'playlist_items': 1,
})
response = {}
with youtube_dl.YoutubeDL(opts) as y:
diff --git a/app/tubesync/settings.py b/app/tubesync/settings.py
index e657aeb..77d882d 100644
--- a/app/tubesync/settings.py
+++ b/app/tubesync/settings.py
@@ -96,7 +96,7 @@ USE_TZ = True
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'static'
-MEDIA_URL = '/media/'
+#MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
SYNC_VIDEO_ROOT = BASE_DIR / 'downloads' / 'video'
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
+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 = {
- 'age_limit': 99,
+ 'age_limit': 99, # Age in years to spoof the client as
}