diff --git a/tubesync/sync/migrations/0005_auto_20201219_0312.py b/tubesync/sync/migrations/0005_auto_20201219_0312.py new file mode 100644 index 0000000..11ee8e2 --- /dev/null +++ b/tubesync/sync/migrations/0005_auto_20201219_0312.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.4 on 2020-12-19 03:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sync', '0004_source_media_format'), + ] + + operations = [ + migrations.AlterField( + model_name='source', + name='source_type', + field=models.CharField(choices=[('c', 'YouTube channel'), ('i', 'YouTube channel by ID'), ('p', 'YouTube playlist')], db_index=True, default='c', help_text='Source type', max_length=1, verbose_name='source type'), + ), + ] diff --git a/tubesync/sync/migrations/0006_source_write_nfo.py b/tubesync/sync/migrations/0006_source_write_nfo.py new file mode 100644 index 0000000..d5fe936 --- /dev/null +++ b/tubesync/sync/migrations/0006_source_write_nfo.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.4 on 2020-12-19 03:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sync', '0005_auto_20201219_0312'), + ] + + operations = [ + migrations.AddField( + model_name='source', + name='write_nfo', + field=models.BooleanField(default=False, help_text='Write an NFO file with the media, these may be detected and used by some media servers', verbose_name='write nfo'), + ), + ] diff --git a/tubesync/sync/models.py b/tubesync/sync/models.py index 874bc7f..2b2ec4e 100644 --- a/tubesync/sync/models.py +++ b/tubesync/sync/models.py @@ -1,6 +1,7 @@ import os import uuid import json +from xml.etree import ElementTree from datetime import datetime from pathlib import Path from django.conf import settings @@ -253,6 +254,11 @@ class Source(models.Model): default=False, help_text=_('Copy thumbnails with the media, these may be detected and used by some media servers') ) + write_nfo = models.BooleanField( + _('write nfo'), + default=False, + help_text=_('Write an NFO file in XML with the media info, these may be detected and used by some media servers') + ) has_failed = models.BooleanField( _('has failed'), default=False, @@ -476,7 +482,38 @@ class Media(models.Model): Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'formats', Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'formats', Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'formats', - } + }, + 'categories': { + Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'categories', + Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'categories', + Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'categories', + }, + 'rating': { + Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'average_rating', + Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'average_rating', + Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'average_rating', + }, + 'age_limit': { + Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'age_limit', + Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'age_limit', + Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'age_limit', + }, + 'uploader': { + Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'uploader', + Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'uploader', + Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'uploader', + }, + 'upvotes': { + Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'like_count', + Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'like_count', + Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'like_count', + }, + 'downvotes': { + Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'dislike_count', + Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'dislike_count', + Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'dislike_count', + }, + } STATE_UNKNOWN = 'unknown' STATE_SCHEDULED = 'scheduled' @@ -883,6 +920,34 @@ class Media(models.Model): return seconds_to_timestr(duration) return '??:??:??' + @property + def categories(self): + field = self.get_metadata_field('categories') + return self.loaded_metadata.get(field, []) + + @property + def rating(self): + field = self.get_metadata_field('rating') + return self.loaded_metadata.get(field, 0) + + @property + def votes(self): + field = self.get_metadata_field('upvotes') + upvotes = self.loaded_metadata.get(field, 0) + field = self.get_metadata_field('downvotes') + downvotes = self.loaded_metadata.get(field, 0) + return upvotes + downvotes + + @property + def age_limit(self): + field = self.get_metadata_field('age_limit') + return self.loaded_metadata.get(field, 0) + + @property + def uploader(self): + field = self.get_metadata_field('uploader') + return self.loaded_metadata.get(field, '') + @property def formats(self): field = self.get_metadata_field('formats') @@ -898,6 +963,26 @@ class Media(models.Model): media_details = self.format_dict return media_format.format(**media_details) + @property + def thumbname(self): + filename = self.filename + prefix, ext = os.path.splitext(filename) + return f'{prefix}.jpg' + + @property + def thumbpath(self): + return self.source.directory_path / self.thumbname + + @property + def nfoname(self): + filename = self.filename + prefix, ext = os.path.splitext(filename) + return f'{prefix}.nfo' + + @property + def nfopath(self): + return self.source.directory_path / self.nfoname + @property def directory_path(self): # If a media_file has been downloaded use its existing directory @@ -925,6 +1010,98 @@ class Media(models.Model): return False return os.path.exists(self.media_file.path) + @property + def nfoxml(self): + ''' + Returns an NFO formatted (prettified) XML string. + ''' + nfo = ElementTree.Element('episodedetails') + nfo.text = '\n ' + # title = media metadata title + title = nfo.makeelement('title', {}) + title.text = str(self.name).strip() + title.tail = '\n ' + nfo.append(title) + # showtitle = source name + showtitle = nfo.makeelement('showtitle', {}) + showtitle.text = str(self.source.name).strip() + showtitle.tail = '\n ' + nfo.append(showtitle) + # ratings = media metadata youtube rating + value = nfo.makeelement('value', {}) + value.text = str(self.rating) + value.tail = '\n ' + votes = nfo.makeelement('votes', {}) + votes.text = str(self.votes) + votes.tail = '\n ' + rating_attrs = {'name': 'youtube', 'max': '5', 'default': 'True'} + rating = nfo.makeelement('rating', rating_attrs) + rating.text = '\n ' + rating.append(value) + rating.append(votes) + rating.tail = '\n ' + ratings = nfo.makeelement('ratings', {}) + ratings.text = '\n ' + ratings.append(rating) + ratings.tail = '\n ' + nfo.append(ratings) + # plot = media metadata description + plot = nfo.makeelement('plot', {}) + plot.text = str(self.description).strip() + plot.tail = '\n ' + nfo.append(plot) + # thumb = local path to media thumbnail + thumb = nfo.makeelement('thumb', {}) + thumb.text = self.thumbname if self.source.copy_thumbnails else '' + thumb.tail = '\n ' + nfo.append(thumb) + # mpaa = media metadata age requirement + mpaa = nfo.makeelement('mpaa', {}) + mpaa.text = str(self.age_limit) + mpaa.tail = '\n ' + nfo.append(mpaa) + # runtime = media metadata duration in seconds + runtime = nfo.makeelement('runtime', {}) + runtime.text = str(self.duration) + runtime.tail = '\n ' + nfo.append(runtime) + # id = media key + idn = nfo.makeelement('id', {}) + idn.text = str(self.key).strip() + idn.tail = '\n ' + nfo.append(idn) + # uniqueid = media key + uniqueid_attrs = {'type': 'youtube', 'default': 'True'} + uniqueid = nfo.makeelement('uniqueid', uniqueid_attrs) + uniqueid.text = str(self.key).strip() + uniqueid.tail = '\n ' + nfo.append(uniqueid) + # studio = media metadata uploader + studio = nfo.makeelement('studio', {}) + studio.text = str(self.uploader).strip() + studio.tail = '\n ' + nfo.append(studio) + # aired = media metadata uploaded date + aired = nfo.makeelement('aired', {}) + upload_date = self.upload_date + aired.text = upload_date.strftime('%Y-%m-%d') if upload_date else '' + aired.tail = '\n ' + nfo.append(aired) + # dateadded = date and time media was created in tubesync + dateadded = nfo.makeelement('dateadded', {}) + dateadded.text = self.created.strftime('%Y-%m-%d %H:%M:%S') + dateadded.tail = '\n ' + nfo.append(dateadded) + # genre = any media metadata categories if they exist + for category_str in self.categories: + genre = nfo.makeelement('genre', {}) + genre.text = str(category_str).strip() + genre.tail = '\n ' + nfo.append(genre) + nfo[-1].tail = '\n' + # Return XML tree as a prettified string + return ElementTree.tostring(nfo, encoding='utf8', method='xml').decode('utf8') + def get_download_state(self, task=None): if self.downloaded: return self.STATE_DOWNLOADED diff --git a/tubesync/sync/signals.py b/tubesync/sync/signals.py index bd45a8a..c5d3fcd 100644 --- a/tubesync/sync/signals.py +++ b/tubesync/sync/signals.py @@ -145,20 +145,6 @@ def media_pre_delete(sender, instance, **kwargs): if thumbnail_url: delete_task_by_media('sync.tasks.download_media_thumbnail', (str(instance.pk), thumbnail_url)) - # Delete media thumbnail if it exists - if instance.thumb: - log.info(f'Deleting thumbnail for: {instance} path: {instance.thumb.path}') - delete_file(instance.thumb.path) - # Delete the media file if it exists - if instance.media_file: - filepath = instance.media_file.path - log.info(f'Deleting media for: {instance} path: {filepath}') - delete_file(filepath) - # Delete thumbnail copy if it exists - barefilepath, fileext = os.path.splitext(filepath) - thumbpath = f'{barefilepath}.jpg' - log.info(f'Deleting thumbnail for: {instance} path: {thumbpath}') - delete_file(thumbpath) @receiver(post_delete, sender=Media) diff --git a/tubesync/sync/tasks.py b/tubesync/sync/tasks.py index 96b0266..28a8406 100644 --- a/tubesync/sync/tasks.py +++ b/tubesync/sync/tasks.py @@ -23,7 +23,8 @@ from background_task.models import Task, CompletedTask from common.logger import log from common.errors import NoMediaException, DownloadFailedException from .models import Source, Media, MediaServer -from .utils import get_remote_image, resize_image_to_height, delete_file +from .utils import (get_remote_image, resize_image_to_height, delete_file, + write_text_file) def get_hash(task_name, pk): @@ -317,11 +318,13 @@ def download_media(media_id): media.save() # If selected, copy the thumbnail over as well if media.source.copy_thumbnails and media.thumb: - barefilepath, fileext = os.path.splitext(filepath) - thumbpath = f'{barefilepath}.jpg' log.info(f'Copying media thumbnail from: {media.thumb.path} ' - f'to: {thumbpath}') - copyfile(media.thumb.path, thumbpath) + f'to: {media.thumbpath}') + copyfile(media.thumb.path, media.thumbpath) + # If selected, write an NFO file + if media.source.write_nfo: + log.info(f'Writing media NFO file to: to: {media.nfopath}') + write_text_file(media.nfopath, media.nfoxml) # Schedule a task to update media servers for mediaserver in MediaServer.objects.all(): log.info(f'Scheduling media server updates') diff --git a/tubesync/sync/templates/sync/source.html b/tubesync/sync/templates/sync/source.html index 221bde6..84c9957 100644 --- a/tubesync/sync/templates/sync/source.html +++ b/tubesync/sync/templates/sync/source.html @@ -97,6 +97,10 @@