tubesync/tubesync/sync/models.py

1274 lines
43 KiB
Python

import os
import uuid
import json
from xml.etree import ElementTree
from collections import OrderedDict
from datetime import datetime, timedelta
from pathlib import Path
from django.conf import settings
from django.db import models
from django.core.files.storage import FileSystemStorage
from django.utils.text import slugify
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from common.errors import NoFormatException
from .youtube import (get_media_info as get_youtube_media_info,
download_media as download_youtube_media)
from .utils import seconds_to_timestr, parse_media_format
from .matching import (get_best_combined_format, get_best_audio_format,
get_best_video_format)
from .mediaservers import PlexMediaServer
media_file_storage = FileSystemStorage(location=str(settings.DOWNLOAD_ROOT))
class Source(models.Model):
'''
A Source is a source of media. Currently, this is either a YouTube channel
or a YouTube playlist.
'''
SOURCE_TYPE_YOUTUBE_CHANNEL = 'c'
SOURCE_TYPE_YOUTUBE_CHANNEL_ID = 'i'
SOURCE_TYPE_YOUTUBE_PLAYLIST = 'p'
SOURCE_TYPES = (SOURCE_TYPE_YOUTUBE_CHANNEL, SOURCE_TYPE_YOUTUBE_CHANNEL_ID,
SOURCE_TYPE_YOUTUBE_PLAYLIST)
SOURCE_TYPE_CHOICES = (
(SOURCE_TYPE_YOUTUBE_CHANNEL, _('YouTube channel')),
(SOURCE_TYPE_YOUTUBE_CHANNEL_ID, _('YouTube channel by ID')),
(SOURCE_TYPE_YOUTUBE_PLAYLIST, _('YouTube playlist')),
)
SOURCE_RESOLUTION_360P = '360p'
SOURCE_RESOLUTION_480P = '480p'
SOURCE_RESOLUTION_720P = '720p'
SOURCE_RESOLUTION_1080P = '1080p'
SOURCE_RESOLUTION_1440P = '1440p'
SOURCE_RESOLUTION_2160P = '2160p'
SOURCE_RESOLUTION_4320P = '4320p'
SOURCE_RESOLUTION_AUDIO = 'audio'
SOURCE_RESOLUTIONS = (SOURCE_RESOLUTION_360P, SOURCE_RESOLUTION_480P,
SOURCE_RESOLUTION_720P, SOURCE_RESOLUTION_1080P,
SOURCE_RESOLUTION_1440P, SOURCE_RESOLUTION_2160P,
SOURCE_RESOLUTION_4320P, 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_1440P, _('1440p (2K)')),
(SOURCE_RESOLUTION_2160P, _('2160p (4K)')),
(SOURCE_RESOLUTION_4320P, _('4320p (8K)')),
(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_RESOLUTION_4320P: 4320,
}
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_MP4A = 'MP4A'
SOURCE_ACODEC_OPUS = 'OPUS'
SOURCE_ACODECS = (SOURCE_ACODEC_MP4A, SOURCE_ACODEC_OPUS)
SOURCE_ACODEC_PRIORITY = (SOURCE_ACODEC_OPUS, SOURCE_ACODEC_MP4A)
SOURCE_ACODEC_CHOICES = (
(SOURCE_ACODEC_MP4A, _('MP4A')),
(SOURCE_ACODEC_OPUS, _('OPUS')),
)
FALLBACK_FAIL = 'f'
FALLBACK_NEXT_BEST = 'n'
FALLBACK_NEXT_BEST_HD = 'h'
FALLBACKS = (FALLBACK_FAIL, FALLBACK_NEXT_BEST, FALLBACK_NEXT_BEST_HD)
FALLBACK_CHOICES = (
(FALLBACK_FAIL, _('Fail, do not download any media')),
(FALLBACK_NEXT_BEST, _('Get next best resolution 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
ICONS = {
SOURCE_TYPE_YOUTUBE_CHANNEL: '<i class="fab fa-youtube"></i>',
SOURCE_TYPE_YOUTUBE_CHANNEL_ID: '<i class="fab fa-youtube"></i>',
SOURCE_TYPE_YOUTUBE_PLAYLIST: '<i class="fab fa-youtube"></i>',
}
# Format to use to display a URL for the source
URLS = {
SOURCE_TYPE_YOUTUBE_CHANNEL: 'https://www.youtube.com/c/{key}',
SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'https://www.youtube.com/channel/{key}',
SOURCE_TYPE_YOUTUBE_PLAYLIST: 'https://www.youtube.com/playlist?list={key}',
}
# Callback functions to get a list of media from the source
INDEXERS = {
SOURCE_TYPE_YOUTUBE_CHANNEL: get_youtube_media_info,
SOURCE_TYPE_YOUTUBE_CHANNEL_ID: get_youtube_media_info,
SOURCE_TYPE_YOUTUBE_PLAYLIST: get_youtube_media_info,
}
# Field names to find the media ID used as the key when storing media
KEY_FIELD = {
SOURCE_TYPE_YOUTUBE_CHANNEL: 'id',
SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'id',
SOURCE_TYPE_YOUTUBE_PLAYLIST: 'id',
}
class CapChoices(models.IntegerChoices):
CAP_NOCAP = 0, _('No cap')
CAP_7DAYS = 604800, _('1 week (7 days)')
CAP_30DAYS = 2592000, _('1 month (30 days)')
CAP_90DAYS = 7776000, _('3 months (90 days)')
CAP_6MONTHS = 15552000, _('6 months (180 days)')
CAP_1YEAR = 31536000, _('1 year (365 days)')
CAP_2YEARs = 63072000, _('2 years (730 days)')
CAP_3YEARs = 94608000, _('3 years (1095 days)')
CAP_5YEARs = 157680000, _('5 years (1825 days)')
CAP_10YEARS = 315360000, _('10 years (3650 days)')
class IndexSchedule(models.IntegerChoices):
EVERY_HOUR = 3600, _('Every hour')
EVERY_2_HOURS = 7200, _('Every 2 hours')
EVERY_3_HOURS = 10800, _('Every 3 hours')
EVERY_4_HOURS = 14400, _('Every 4 hours')
EVERY_5_HOURS = 18000, _('Every 5 hours')
EVERY_6_HOURS = 21600, _('Every 6 hours')
EVERY_12_HOURS = 43200, _('Every 12 hours')
EVERY_24_HOURS = 86400, _('Every 24 hours')
uuid = models.UUIDField(
_('uuid'),
primary_key=True,
editable=False,
default=uuid.uuid4,
help_text=_('UUID of the source')
)
created = models.DateTimeField(
_('created'),
auto_now_add=True,
db_index=True,
help_text=_('Date and time the source was created')
)
last_crawl = models.DateTimeField(
_('last crawl'),
db_index=True,
null=True,
blank=True,
help_text=_('Date and time the source was last crawled')
)
source_type = models.CharField(
_('source type'),
max_length=1,
db_index=True,
choices=SOURCE_TYPE_CHOICES,
default=SOURCE_TYPE_YOUTUBE_CHANNEL,
help_text=_('Source type')
)
key = models.CharField(
_('key'),
max_length=100,
db_index=True,
unique=True,
help_text=_('Source key, such as exact YouTube channel name or playlist ID')
)
name = models.CharField(
_('name'),
max_length=100,
db_index=True,
unique=True,
help_text=_('Friendly name for the source, used locally in TubeSync only')
)
directory = models.CharField(
_('directory'),
max_length=100,
db_index=True,
unique=True,
help_text=_('Directory name to save the media into')
)
media_format = models.CharField(
_('media format'),
max_length=200,
default=settings.MEDIA_FORMATSTR_DEFAULT,
help_text=_('File format to use for saving files, detailed options at bottom of page.')
)
index_schedule = models.IntegerField(
_('index schedule'),
choices=IndexSchedule.choices,
db_index=True,
default=IndexSchedule.EVERY_6_HOURS,
help_text=_('Schedule of how often to index the source for new media')
)
download_cap = models.IntegerField(
_('download cap'),
choices=CapChoices.choices,
default=CapChoices.CAP_NOCAP,
help_text=_('Do not download media older than this capped date')
)
delete_old_media = models.BooleanField(
_('delete old media'),
default=False,
help_text=_('Delete old media after "days to keep" days?')
)
days_to_keep = models.PositiveSmallIntegerField(
_('days to keep'),
default=14,
help_text=_('If "delete old media" is ticked, the number of days after which '
'to automatically delete media')
)
source_resolution = models.CharField(
_('source resolution'),
max_length=8,
db_index=True,
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=True,
help_text=_('Where possible, prefer 60fps media for this source')
)
prefer_hdr = models.BooleanField(
_('prefer hdr'),
default=False,
help_text=_('Where possible, prefer HDR media for this source')
)
fallback = models.CharField(
_('fallback'),
max_length=1,
db_index=True,
choices=FALLBACK_CHOICES,
default=FALLBACK_NEXT_BEST_HD,
help_text=_('What do do when media in your source resolution and codecs is not available')
)
copy_thumbnails = models.BooleanField(
_('copy thumbnails'),
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,
help_text=_('Source has failed to index media')
)
def __str__(self):
return self.name
class Meta:
verbose_name = _('Source')
verbose_name_plural = _('Sources')
@property
def icon(self):
return self.ICONS.get(self.source_type)
@property
def slugname(self):
replaced = self.name.replace('_', '-').replace('&', 'and').replace('+', 'and')
return slugify(replaced)[:80]
@property
def is_audio(self):
return self.source_resolution == self.SOURCE_RESOLUTION_AUDIO
@property
def is_video(self):
return not self.is_audio
@property
def download_cap_date(self):
delta = self.download_cap
if delta > 0:
return timezone.now() - timedelta(seconds=delta)
else:
return False
@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.is_audio:
if self.source_acodec == self.SOURCE_ACODEC_MP4A:
return 'm4a'
elif self.source_acodec == self.SOURCE_ACODEC_OPUS:
return 'ogg'
else:
raise ValueError('Unable to choose audio extension, uknown acodec')
else:
return 'mkv'
@classmethod
def create_url(obj, source_type, key):
url = obj.URLS.get(source_type)
return url.format(key=key)
@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.is_video and self.prefer_60fps else ''
h = ' HDR' if self.is_video and self.prefer_hdr else ''
return f'{self.source_resolution} (video:{vc}, audio:{ac}){f}{h}'.strip()
@property
def directory_path(self):
download_dir = Path(media_file_storage.location)
if self.source_resolution == self.SOURCE_RESOLUTION_AUDIO:
return download_dir / settings.DOWNLOAD_AUDIO_DIR / self.directory
else:
return download_dir / settings.DOWNLOAD_VIDEO_DIR / self.directory
def make_directory(self):
return os.makedirs(self.directory_path, exist_ok=True)
def directory_exists(self):
return (os.path.isdir(self.directory_path) and
os.access(self.directory_path, os.W_OK))
@property
def key_field(self):
return self.KEY_FIELD.get(self.source_type, '')
@property
def source_resolution_height(self):
return self.RESOLUTION_MAP.get(self.source_resolution, 0)
@property
def can_fallback(self):
return self.fallback != self.FALLBACK_FAIL
@property
def example_media_format_dict(self):
'''
Populates a dict with real-ish and some placeholder data for media name
format strings. Used for example filenames and media_format validation.
'''
fmt = []
if self.source_resolution:
fmt.append(self.source_resolution)
if self.source_vcodec:
fmt.append(self.source_vcodec.lower())
if self.source_acodec:
fmt.append(self.source_acodec.lower())
if self.prefer_60fps:
fmt.append('60fps')
if self.prefer_hdr:
fmt.append('hdr')
return {
'yyyymmdd': timezone.now().strftime('%Y%m%d'),
'yyyy_mm_dd': timezone.now().strftime('%Y-%m-%d'),
'yyyy': timezone.now().strftime('%Y'),
'mm': timezone.now().strftime('%m'),
'dd': timezone.now().strftime('%d'),
'source': self.slugname,
'source_full': self.name,
'title': 'some-media-title-name',
'title_full': 'Some Media Title Name',
'key': 'SoMeUnIqUiD',
'format': '-'.join(fmt),
'playlist_index': 1,
'playlist_title': 'Some Playlist Title',
'ext': self.extension,
'resolution': self.source_resolution if self.source_resolution else '',
'height': '720' if self.source_resolution else '',
'width': '1280' if self.source_resolution else '',
'vcodec': self.source_vcodec.lower() if self.source_vcodec else '',
'acodec': self.source_acodec.lower(),
'fps': '24' if self.source_resolution else '',
'hdr': 'hdr' if self.source_resolution else ''
}
def get_example_media_format(self):
try:
return self.media_format.format(**self.example_media_format_dict)
except Exception as e:
return ''
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)
# Account for nested playlists, such as a channel of playlists of playlists
def _recurse_playlists(playlist):
videos = []
if not playlist:
return videos
entries = playlist.get('entries', [])
for entry in entries:
if not entry:
continue
subentries = entry.get('entries', [])
if subentries:
videos = videos + _recurse_playlists(entry)
else:
videos.append(entry)
return videos
return _recurse_playlists(response)
def get_media_thumb_path(instance, filename):
fileid = str(instance.uuid)
filename = f'{fileid.lower()}.jpg'
prefix = fileid[:2]
return Path('thumbs') / prefix / filename
def get_media_file_path(instance, filename):
return instance.filepath
class Media(models.Model):
'''
Media is a single piece of media, such as a single YouTube video linked to a
Source.
'''
# Format to use to display a URL for the media
URLS = {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'https://www.youtube.com/watch?v={key}',
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'https://www.youtube.com/watch?v={key}',
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'https://www.youtube.com/watch?v={key}',
}
# Maps standardised names to names used in source metdata
METADATA_FIELDS = {
'upload_date': {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'upload_date',
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'upload_date',
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'upload_date',
},
'title': {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'title',
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'title',
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'title',
},
'thumbnail': {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'thumbnail',
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'thumbnail',
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'thumbnail',
},
'description': {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'description',
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'description',
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'description',
},
'duration': {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'duration',
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'duration',
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'duration',
},
'formats': {
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',
},
'playlist_index': {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'playlist_index',
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'playlist_index',
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'playlist_index',
},
'playlist_title': {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'playlist_title',
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'playlist_title',
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'playlist_title',
},
}
STATE_UNKNOWN = 'unknown'
STATE_SCHEDULED = 'scheduled'
STATE_DOWNLOADING = 'downloading'
STATE_DOWNLOADED = 'downloaded'
STATE_ERROR = 'error'
STATES = (STATE_UNKNOWN, STATE_SCHEDULED, STATE_DOWNLOADING, STATE_DOWNLOADED,
STATE_ERROR)
STATE_ICONS = {
STATE_UNKNOWN: '<i class="far fa-question-circle" title="Unknown download state"></i>',
STATE_SCHEDULED: '<i class="far fa-clock" title="Scheduled to download"></i>',
STATE_DOWNLOADING: '<i class="fas fa-download" title="Downloading now"></i>',
STATE_DOWNLOADED: '<i class="far fa-check-circle" title="Downloaded"></i>',
STATE_ERROR: '<i class="fas fa-exclamation-triangle" title="Error downloading"></i>',
}
uuid = models.UUIDField(
_('uuid'),
primary_key=True,
editable=False,
default=uuid.uuid4,
help_text=_('UUID of the media')
)
created = models.DateTimeField(
_('created'),
auto_now_add=True,
db_index=True,
help_text=_('Date and time the media was created')
)
source = models.ForeignKey(
Source,
on_delete=models.CASCADE,
related_name='media_source',
help_text=_('Source the media belongs to')
)
published = models.DateTimeField(
_('published'),
db_index=True,
null=True,
blank=True,
help_text=_('Date and time the media was published on the source')
)
key = models.CharField(
_('key'),
max_length=100,
db_index=True,
help_text=_('Media key, such as exact YouTube video ID')
)
thumb = models.ImageField(
_('thumb'),
upload_to=get_media_thumb_path,
max_length=200,
blank=True,
null=True,
width_field='thumb_width',
height_field='thumb_height',
help_text=_('Thumbnail')
)
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(
_('metadata'),
blank=True,
null=True,
help_text=_('JSON encoded metadata for the media')
)
can_download = models.BooleanField(
_('can download'),
db_index=True,
default=False,
help_text=_('Media has a matching format and can be downloaded')
)
media_file = models.FileField(
_('media file'),
upload_to=get_media_file_path,
max_length=200,
blank=True,
null=True,
storage=media_file_storage,
help_text=_('Media file')
)
skip = models.BooleanField(
_('skip'),
db_index=True,
default=False,
help_text=_('Media will be skipped and not downloaded')
)
downloaded = models.BooleanField(
_('downloaded'),
db_index=True,
default=False,
help_text=_('Media has been downloaded')
)
download_date = models.DateTimeField(
_('download date'),
db_index=True,
blank=True,
null=True,
help_text=_('Date and time the download completed')
)
downloaded_format = models.CharField(
_('downloaded format'),
max_length=30,
blank=True,
null=True,
help_text=_('Audio codec of the downloaded media')
)
downloaded_height = models.PositiveIntegerField(
_('downloaded height'),
blank=True,
null=True,
help_text=_('Height in pixels of the downloaded media')
)
downloaded_width = models.PositiveIntegerField(
_('downloaded width'),
blank=True,
null=True,
help_text=_('Width in pixels of the downloaded media')
)
downloaded_audio_codec = models.CharField(
_('downloaded audio codec'),
max_length=30,
blank=True,
null=True,
help_text=_('Audio codec of the downloaded media')
)
downloaded_video_codec = models.CharField(
_('downloaded video codec'),
max_length=30,
blank=True,
null=True,
help_text=_('Video codec of the downloaded media')
)
downloaded_container = models.CharField(
_('downloaded container format'),
max_length=30,
blank=True,
null=True,
help_text=_('Container format of the downloaded media')
)
downloaded_fps = models.PositiveSmallIntegerField(
_('downloaded fps'),
blank=True,
null=True,
help_text=_('FPS of the downloaded media')
)
downloaded_hdr = models.BooleanField(
_('downloaded hdr'),
default=False,
help_text=_('Downloaded media has HDR')
)
downloaded_filesize = models.PositiveBigIntegerField(
_('downloaded filesize'),
db_index=True,
blank=True,
null=True,
help_text=_('Size of the downloaded media in bytes')
)
def __str__(self):
return self.key
class Meta:
verbose_name = _('Media')
verbose_name_plural = _('Media')
unique_together = (
('source', 'key'),
)
def get_metadata_field(self, field):
fields = self.METADATA_FIELDS.get(field, {})
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):
return get_best_combined_format(self)
def get_best_audio_format(self):
return get_best_audio_format(self)
def get_best_video_format(self):
return get_best_video_format(self)
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.
Returns boolean False if there is no valid downloadable combo.
'''
if self.source.is_audio:
audio_match, audio_format = self.get_best_audio_format()
if audio_format:
return str(audio_format)
else:
return False
else:
combined_match, combined_format = self.get_best_combined_format()
if combined_format:
return str(combined_format)
else:
audio_match, audio_format = self.get_best_audio_format()
video_match, video_format = self.get_best_video_format()
if audio_format and video_format:
return f'{video_format}+{audio_format}'
else:
return False
return False
def get_display_format(self, format_str):
'''
Returns a tuple used in the format component of the output filename. This
is the format(s) found by matching. Examples:
# Audio and video streams
('1080p', 'vp9', 'opus')
# Audio only stream
('opus',)
# Audio and video streams with additional flags
('720p', 'avc1', 'mp4a', '60fps', 'hdr')
'''
fmt = []
resolution = ''
vcodec = ''
acodec = ''
height = '0'
width = '0'
fps = ''
hdr = ''
# If the download has completed use existing values
if self.downloaded:
resolution = f'{self.downloaded_height}p'
if self.downloaded_format != 'audio':
vcodec = self.downloaded_video_codec.lower()
fmt.append(vcodec)
acodec = self.downloaded_audio_codec.lower()
fmt.append(acodec)
if self.downloaded_format != 'audio':
fps = str(self.downloaded_fps)
fmt.append(f'{fps}fps')
if self.downloaded_hdr:
hdr = 'hdr'
fmt.append(hdr)
height = str(self.downloaded_height)
width = str(self.downloaded_width)
return {
'resolution': resolution,
'height': height,
'width': width,
'vcodec': vcodec,
'acodec': acodec,
'fps': fps,
'hdr': hdr,
'format': tuple(fmt),
}
# Otherwise, calculate from matched format codes
vformat = None
aformat = None
if '+' in format_str:
# Seperate audio and video streams
vformat_code, aformat_code = format_str.split('+')
vformat = self.get_format_by_code(vformat_code)
aformat = self.get_format_by_code(aformat_code)
else:
# Combined stream or audio only
cformat = self.get_format_by_code(format_str)
aformat = cformat
if cformat['vcodec']:
# Combined
vformat = cformat
if vformat:
resolution = vformat['format'].lower()
fmt.append(resolution)
vcodec = vformat['vcodec'].lower()
fmt.append(vcodec)
acodec = aformat['acodec'].lower()
fmt.append(acodec)
if vformat:
if vformat['is_60fps']:
fps = '60fps'
fmt.append(fps)
if vformat['is_hdr']:
hdr = 'hdr'
fmt.append(hdr)
height = str(vformat['height'])
width = str(vformat['width'])
return {
'resolution': resolution,
'height': height,
'width': width,
'vcodec': vcodec,
'acodec': acodec,
'fps': fps,
'hdr': hdr,
'format': tuple(fmt),
}
def get_format_by_code(self, format_code):
'''
Matches a format code, such as '22', to a processed format dict.
'''
for fmt in self.iter_formats():
if format_code == fmt['id']:
return fmt
return False
@property
def format_dict(self):
'''
Returns a dict matching the media_format key requirements for this item
of media.
'''
format_str = self.get_format_str()
display_format = self.get_display_format(format_str)
dateobj = self.upload_date if self.upload_date else self.created
return {
'yyyymmdd': dateobj.strftime('%Y%m%d'),
'yyyy_mm_dd': dateobj.strftime('%Y-%m-%d'),
'yyyy': dateobj.strftime('%Y'),
'mm': dateobj.strftime('%m'),
'dd': dateobj.strftime('%d'),
'source': self.source.slugname,
'source_full': self.source.name,
'title': self.slugtitle,
'title_full': self.title,
'key': self.key,
'format': '-'.join(display_format['format']),
'playlist_index': self.playlist_index,
'playlist_title': self.playlist_title,
'ext': self.source.extension,
'resolution': display_format['resolution'],
'height': display_format['height'],
'width': display_format['width'],
'vcodec': display_format['vcodec'],
'acodec': display_format['acodec'],
'fps': display_format['fps'],
'hdr': display_format['hdr'],
}
@property
def loaded_metadata(self):
try:
return json.loads(self.metadata)
except Exception as e:
return {}
@property
def url(self):
url = self.URLS.get(self.source.source_type, '')
return url.format(key=self.key)
@property
def description(self):
field = self.get_metadata_field('description')
return self.loaded_metadata.get(field, '').strip()
@property
def title(self):
field = self.get_metadata_field('title')
return self.loaded_metadata.get(field, '').strip()
@property
def slugtitle(self):
replaced = self.title.replace('_', '-').replace('&', 'and').replace('+', 'and')
return slugify(replaced)[:80]
@property
def thumbnail(self):
field = self.get_metadata_field('thumbnail')
return self.loaded_metadata.get(field, '').strip()
@property
def name(self):
title = self.title
return title if title else self.key
@property
def upload_date(self):
field = self.get_metadata_field('upload_date')
upload_date_str = self.loaded_metadata.get(field, '').strip()
try:
return datetime.strptime(upload_date_str, '%Y%m%d')
except (AttributeError, ValueError) as e:
return None
@property
def duration(self):
field = self.get_metadata_field('duration')
return int(self.loaded_metadata.get(field, 0))
@property
def duration_formatted(self):
duration = self.duration
if duration > 0:
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')
return self.loaded_metadata.get(field, [])
@property
def playlist_index(self):
field = self.get_metadata_field('playlist_index')
return self.loaded_metadata.get(field, 0)
@property
def playlist_title(self):
field = self.get_metadata_field('playlist_title')
return self.loaded_metadata.get(field, '')
@property
def filename(self):
# Otherwise, create a suitable filename from the source media_format
media_format = str(self.source.media_format)
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):
# Otherwise, create a suitable filename from the source media_format
media_format = str(self.source.media_format)
media_details = self.format_dict
dirname = self.source.directory_path / media_format.format(**media_details)
return os.path.dirname(str(dirname))
@property
def filepath(self):
return self.source.directory_path / self.filename
@property
def thumb_file_exists(self):
if not self.thumb:
return False
return os.path.exists(self.thumb.path)
@property
def media_file_exists(self):
if not self.media_file:
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 = OrderedDict()
rating_attrs['name'] = 'youtube'
rating_attrs['max'] = '5'
rating_attrs['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 = OrderedDict()
uniqueid_attrs['type'] = 'youtube'
uniqueid_attrs['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
if task:
if task.locked_by_pid_running():
return self.STATE_DOWNLOADING
elif task.has_error():
return self.STATE_ERROR
else:
return self.STATE_SCHEDULED
return self.STATE_UNKNOWN
def get_download_state_icon(self, task=None):
state = self.get_download_state(task)
return self.STATE_ICONS.get(state, self.STATE_ICONS[self.STATE_UNKNOWN])
def download_media(self):
format_str = self.get_format_str()
if not format_str:
raise NoFormatException(f'Cannot download, media "{self.pk}" ({self}) has '
f'no valid format available')
# Download the media with youtube-dl
download_youtube_media(self.url, format_str, self.source.extension,
str(self.filepath))
# Return the download paramaters
return format_str, self.source.extension
class MediaServer(models.Model):
'''
A remote media server, such as a Plex server.
'''
SERVER_TYPE_PLEX = 'p'
SERVER_TYPES = (SERVER_TYPE_PLEX,)
SERVER_TYPE_CHOICES = (
(SERVER_TYPE_PLEX, _('Plex')),
)
ICONS = {
SERVER_TYPE_PLEX: '<i class="fas fa-server"></i>',
}
HANDLERS = {
SERVER_TYPE_PLEX: PlexMediaServer,
}
server_type = models.CharField(
_('server type'),
max_length=1,
db_index=True,
choices=SERVER_TYPE_CHOICES,
default=SERVER_TYPE_PLEX,
help_text=_('Server type')
)
host = models.CharField(
_('host'),
db_index=True,
max_length=200,
help_text=_('Hostname or IP address of the media server')
)
port = models.PositiveIntegerField(
_('port'),
db_index=True,
help_text=_('Port number of the media server')
)
use_https = models.BooleanField(
_('use https'),
default=True,
help_text=_('Connect to the media server over HTTPS')
)
verify_https = models.BooleanField(
_('verify https'),
default=False,
help_text=_('If connecting over HTTPS, verify the SSL certificate is valid')
)
options = models.TextField(
_('options'),
blank=True,
null=True,
help_text=_('JSON encoded options for the media server')
)
def __str__(self):
return f'{self.get_server_type_display()} server at {self.url}'
class Meta:
verbose_name = _('Media Server')
verbose_name_plural = _('Media Servers')
unique_together = (
('host', 'port'),
)
@property
def url(self):
scheme = 'https' if self.use_https else 'http'
return f'{scheme}://{self.host.strip()}:{self.port}'
@property
def icon(self):
return self.ICONS.get(self.server_type)
@property
def handler(self):
handler_class = self.HANDLERS.get(self.server_type)
return handler_class(self)
@property
def loaded_options(self):
try:
return json.loads(self.options)
except Exception as e:
return {}
def validate(self):
return self.handler.validate()
def update(self):
return self.handler.update()
def get_help_html(self):
return self.handler.HELP