2018-10-17 21:38:40 +00:00
|
|
|
import logging
|
2019-01-01 14:13:49 +00:00
|
|
|
import mimetypes
|
2018-10-20 22:20:31 +00:00
|
|
|
import os
|
2018-12-29 15:11:20 +00:00
|
|
|
from typing import Callable, Union, Any, Optional
|
2018-10-17 21:38:40 +00:00
|
|
|
|
2018-10-10 22:43:50 +00:00
|
|
|
from django.contrib.auth.models import User
|
2018-10-17 21:38:40 +00:00
|
|
|
from django.db import models
|
2018-10-14 21:45:08 +00:00
|
|
|
from django.db.models.functions import Lower
|
2018-12-29 15:11:20 +00:00
|
|
|
|
2018-10-13 20:01:45 +00:00
|
|
|
# help_text = user shown text
|
|
|
|
# verbose_name = user shown name
|
|
|
|
# null = nullable, blank = user is allowed to set value to empty
|
2018-10-27 00:33:45 +00:00
|
|
|
VIDEO_ORDER_CHOICES = [
|
|
|
|
('newest', 'Newest'),
|
|
|
|
('oldest', 'Oldest'),
|
|
|
|
('playlist', 'Playlist order'),
|
|
|
|
('playlist_reverse', 'Reverse playlist order'),
|
|
|
|
('popularity', 'Popularity'),
|
|
|
|
('rating', 'Top rated'),
|
|
|
|
]
|
|
|
|
|
|
|
|
VIDEO_ORDER_MAPPING = {
|
|
|
|
'newest': '-publish_date',
|
|
|
|
'oldest': 'publish_date',
|
|
|
|
'playlist': 'playlist_index',
|
|
|
|
'playlist_reverse': '-playlist_index',
|
|
|
|
'popularity': '-views',
|
|
|
|
'rating': '-rating'
|
|
|
|
}
|
2018-10-13 20:01:45 +00:00
|
|
|
|
2018-10-10 22:43:50 +00:00
|
|
|
|
2019-12-23 21:52:38 +00:00
|
|
|
class Provider(models.Model):
|
|
|
|
|
|
|
|
class_name = models.CharField(null=False, max_length=64, unique=True,
|
|
|
|
help_text='Class name in the "providers" package.')
|
|
|
|
|
|
|
|
config = models.CharField(max_length=1024,
|
|
|
|
help_text='Provider configuration (stored as JSON)')
|
|
|
|
|
|
|
|
|
2018-10-04 11:36:11 +00:00
|
|
|
class SubscriptionFolder(models.Model):
|
2019-12-23 21:52:38 +00:00
|
|
|
|
|
|
|
name = models.CharField(null=False, max_length=250,
|
|
|
|
help_text='Folder name')
|
|
|
|
|
|
|
|
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True,
|
|
|
|
help_text='Parent folder')
|
|
|
|
|
|
|
|
user = models.ForeignKey(User, on_delete=models.CASCADE, null=False, blank=False,
|
|
|
|
help_text='User who owns the subscription')
|
2018-10-04 11:36:11 +00:00
|
|
|
|
2018-10-17 21:38:40 +00:00
|
|
|
class Meta:
|
|
|
|
ordering = [Lower('parent__name'), Lower('name')]
|
|
|
|
|
2018-10-04 11:36:11 +00:00
|
|
|
def __str__(self):
|
2018-10-14 21:45:08 +00:00
|
|
|
s = ""
|
|
|
|
current = self
|
|
|
|
while current is not None:
|
|
|
|
s = current.name + " > " + s
|
|
|
|
current = current.parent
|
|
|
|
return s[:-3]
|
|
|
|
|
2018-10-29 16:52:09 +00:00
|
|
|
def __repr__(self):
|
|
|
|
return f'folder {self.id}, name="{self.name}"'
|
|
|
|
|
2018-10-17 21:38:40 +00:00
|
|
|
def delete_folder(self, keep_subscriptions: bool):
|
|
|
|
if keep_subscriptions:
|
|
|
|
|
|
|
|
def visit(node: Union["SubscriptionFolder", "Subscription"]):
|
|
|
|
if isinstance(node, Subscription):
|
|
|
|
node.parent_folder = None
|
|
|
|
node.save()
|
|
|
|
|
|
|
|
SubscriptionFolder.traverse(self.id, self.user, visit)
|
|
|
|
|
|
|
|
self.delete()
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def traverse(root_folder_id: Optional[int],
|
|
|
|
user: User,
|
|
|
|
visit_func: Callable[[Union["SubscriptionFolder", "Subscription"]], Any]):
|
|
|
|
|
|
|
|
data_collected = []
|
|
|
|
|
|
|
|
def collect(data):
|
|
|
|
if data is not None:
|
|
|
|
data_collected.append(data)
|
|
|
|
|
|
|
|
# Visit root
|
|
|
|
if root_folder_id is not None:
|
|
|
|
root_folder = SubscriptionFolder.objects.get(id=root_folder_id)
|
|
|
|
collect(visit_func(root_folder))
|
|
|
|
|
|
|
|
queue = [root_folder_id]
|
|
|
|
visited = []
|
|
|
|
|
|
|
|
while len(queue) > 0:
|
|
|
|
folder_id = queue.pop()
|
|
|
|
|
|
|
|
if folder_id in visited:
|
|
|
|
logging.error('Found folder tree cycle for folder id %d.', folder_id)
|
|
|
|
continue
|
|
|
|
visited.append(folder_id)
|
|
|
|
|
|
|
|
for folder in SubscriptionFolder.objects.filter(parent_id=folder_id, user=user).order_by(Lower('name')):
|
|
|
|
collect(visit_func(folder))
|
|
|
|
queue.append(folder.id)
|
|
|
|
|
|
|
|
for subscription in Subscription.objects.filter(parent_folder_id=folder_id, user=user).order_by(Lower('name')):
|
|
|
|
collect(visit_func(subscription))
|
|
|
|
|
|
|
|
return data_collected
|
2018-10-04 11:36:11 +00:00
|
|
|
|
|
|
|
|
|
|
|
class Subscription(models.Model):
|
2019-12-23 21:52:38 +00:00
|
|
|
name = models.CharField(null=False, max_length=1024,
|
|
|
|
help_text='Name of playlist or channel.')
|
2019-12-18 22:27:06 +00:00
|
|
|
|
2019-12-23 21:52:38 +00:00
|
|
|
description = models.TextField(help_text='Description of the playlist/channel.')
|
|
|
|
|
|
|
|
original_url = models.CharField(null=False, max_length=1024,
|
|
|
|
help_text='Original URL added by user.')
|
|
|
|
|
|
|
|
thumbnail = models.CharField(max_length=1024,
|
|
|
|
help_text='An URL to the thumbnail.')
|
|
|
|
|
|
|
|
#
|
|
|
|
provider = models.ForeignKey(Provider, null=True, on_delete=models.SET_DEFAULT,
|
|
|
|
help_text='Provider who manages this subscription (e.g. YouTube, Vimeo etc)')
|
|
|
|
|
|
|
|
provider_id = models.CharField(null=False, max_length=64,
|
|
|
|
help_text='Identifier according to provider (e.g. YouTube video ID)')
|
|
|
|
|
|
|
|
provider_data = models.CharField(null=True, max_length=1024,
|
|
|
|
help_text='Extra data stored by the provider serialized as JSON')
|
|
|
|
|
|
|
|
#
|
|
|
|
parent_folder = models.ForeignKey(SubscriptionFolder, on_delete=models.CASCADE, null=True, blank=True,
|
|
|
|
help_text='Parent folder')
|
2019-12-18 22:27:06 +00:00
|
|
|
|
2019-12-23 21:52:38 +00:00
|
|
|
user = models.ForeignKey(User, on_delete=models.CASCADE,
|
|
|
|
help_text='Owner user')
|
2018-10-10 22:43:50 +00:00
|
|
|
|
|
|
|
# overrides
|
2018-10-17 21:38:40 +00:00
|
|
|
auto_download = models.BooleanField(null=True, blank=True)
|
|
|
|
download_limit = models.IntegerField(null=True, blank=True)
|
2018-10-27 00:33:45 +00:00
|
|
|
download_order = models.CharField(
|
|
|
|
null=True, blank=True,
|
|
|
|
max_length=128,
|
|
|
|
choices=VIDEO_ORDER_CHOICES)
|
2018-12-29 21:16:04 +00:00
|
|
|
automatically_delete_watched = models.BooleanField(null=True, blank=True)
|
2018-10-27 00:33:45 +00:00
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
return self.name
|
2018-10-17 21:38:40 +00:00
|
|
|
|
2018-10-29 16:52:09 +00:00
|
|
|
def __repr__(self):
|
|
|
|
return f'subscription {self.id}, name="{self.name}", playlist_id="{self.playlist_id}"'
|
2018-10-17 21:38:40 +00:00
|
|
|
|
|
|
|
def delete_subscription(self, keep_downloaded_videos: bool):
|
|
|
|
self.delete()
|
2018-10-08 00:01:35 +00:00
|
|
|
|
2018-10-04 11:36:11 +00:00
|
|
|
|
|
|
|
class Video(models.Model):
|
|
|
|
name = models.TextField(null=False)
|
2018-10-08 00:01:35 +00:00
|
|
|
description = models.TextField()
|
|
|
|
publish_date = models.DateTimeField(null=False)
|
2019-08-19 18:05:13 +00:00
|
|
|
thumbnail = models.TextField()
|
2019-09-22 18:36:35 +00:00
|
|
|
uploader_name = models.CharField(null=False, max_length=255)
|
2019-12-18 22:27:06 +00:00
|
|
|
|
|
|
|
provider_id = models.CharField(null=False, max_length=64)
|
|
|
|
provider_data = models.CharField(null=True, max_length=1024)
|
|
|
|
|
|
|
|
playlist_index = models.IntegerField(null=False)
|
|
|
|
downloaded_path = models.TextField(null=True, blank=True)
|
|
|
|
subscription = models.ForeignKey(Subscription, on_delete=models.CASCADE)
|
|
|
|
|
|
|
|
watched = models.BooleanField(default=False, null=False)
|
|
|
|
new = models.BooleanField(default=True, null=False)
|
|
|
|
|
2018-10-13 20:01:45 +00:00
|
|
|
views = models.IntegerField(null=False, default=0)
|
|
|
|
rating = models.FloatField(null=False, default=0.5)
|
2018-10-04 11:36:11 +00:00
|
|
|
|
2018-10-20 22:20:31 +00:00
|
|
|
def mark_watched(self):
|
|
|
|
self.watched = True
|
2018-10-20 22:37:51 +00:00
|
|
|
self.save()
|
|
|
|
if self.downloaded_path is not None:
|
2018-12-29 21:16:04 +00:00
|
|
|
from YtManagerApp.management.appconfig import appconfig
|
2019-12-18 22:27:06 +00:00
|
|
|
from YtManagerApp.management.scheduler.jobs import DeleteVideoJob
|
|
|
|
from YtManagerApp.management.scheduler.jobs import SynchronizeJob
|
2018-10-20 22:37:51 +00:00
|
|
|
|
2018-12-29 21:16:04 +00:00
|
|
|
if appconfig.for_sub(self.subscription, 'automatically_delete_watched'):
|
2019-08-14 14:14:16 +00:00
|
|
|
DeleteVideoJob.schedule(self)
|
|
|
|
SynchronizeJob.schedule_now_for_subscription(self.subscription)
|
2018-10-20 22:20:31 +00:00
|
|
|
|
|
|
|
def mark_unwatched(self):
|
|
|
|
self.watched = False
|
2018-10-20 22:37:51 +00:00
|
|
|
self.save()
|
2019-12-18 22:27:06 +00:00
|
|
|
from YtManagerApp.management.scheduler.jobs.synchronize_job import SynchronizeJob
|
2019-08-14 14:14:16 +00:00
|
|
|
SynchronizeJob.schedule_now_for_subscription(self.subscription)
|
2018-10-20 22:20:31 +00:00
|
|
|
|
|
|
|
def get_files(self):
|
|
|
|
if self.downloaded_path is not None:
|
|
|
|
directory, file_pattern = os.path.split(self.downloaded_path)
|
|
|
|
for file in os.listdir(directory):
|
|
|
|
if file.startswith(file_pattern):
|
|
|
|
yield os.path.join(directory, file)
|
|
|
|
|
2019-01-01 14:13:49 +00:00
|
|
|
def find_video(self):
|
|
|
|
"""
|
|
|
|
Finds the video file from the downloaded files, and
|
|
|
|
returns
|
|
|
|
:return: Tuple containing file path and mime type
|
|
|
|
"""
|
|
|
|
for file in self.get_files():
|
|
|
|
mime, _ = mimetypes.guess_type(file)
|
|
|
|
if mime is not None and mime.startswith('video/'):
|
2019-08-19 13:42:29 +00:00
|
|
|
return file, mime
|
2019-01-01 14:13:49 +00:00
|
|
|
|
|
|
|
return None, None
|
|
|
|
|
2018-10-20 22:37:51 +00:00
|
|
|
def delete_files(self):
|
|
|
|
if self.downloaded_path is not None:
|
2019-12-18 22:27:06 +00:00
|
|
|
from YtManagerApp.management.scheduler.jobs import DeleteVideoJob
|
2018-12-29 21:16:04 +00:00
|
|
|
from YtManagerApp.management.appconfig import appconfig
|
2019-12-18 22:27:06 +00:00
|
|
|
from YtManagerApp.management.scheduler.jobs import SynchronizeJob
|
2018-10-20 22:37:51 +00:00
|
|
|
|
2019-08-19 13:42:29 +00:00
|
|
|
DeleteVideoJob.schedule(self)
|
2018-10-20 22:37:51 +00:00
|
|
|
|
|
|
|
# Mark watched?
|
2018-12-30 12:45:42 +00:00
|
|
|
if self.subscription.user.preferences['mark_deleted_as_watched']:
|
2018-10-20 22:37:51 +00:00
|
|
|
self.watched = True
|
2019-08-19 13:42:29 +00:00
|
|
|
SynchronizeJob.schedule_now_for_subscription(self.subscription)
|
2018-10-20 22:37:51 +00:00
|
|
|
|
|
|
|
def download(self):
|
|
|
|
if not self.downloaded_path:
|
2019-12-18 22:27:06 +00:00
|
|
|
from YtManagerApp.management.scheduler.jobs import DownloadVideoJob
|
2019-08-19 13:42:29 +00:00
|
|
|
DownloadVideoJob.schedule(self)
|
2018-10-20 22:37:51 +00:00
|
|
|
|
2018-10-04 11:36:11 +00:00
|
|
|
def __str__(self):
|
2018-10-20 22:37:51 +00:00
|
|
|
return self.name
|
2018-10-29 16:52:09 +00:00
|
|
|
|
|
|
|
def __repr__(self):
|
|
|
|
return f'video {self.id}, video_id="{self.video_id}"'
|
2019-08-14 14:14:16 +00:00
|
|
|
|
|
|
|
|
|
|
|
JOB_STATES = [
|
|
|
|
('running', 0),
|
|
|
|
('finished', 1),
|
|
|
|
('failed', 2),
|
|
|
|
('interrupted', 3),
|
|
|
|
]
|
|
|
|
|
|
|
|
JOB_STATES_MAP = {
|
|
|
|
'running': 0,
|
|
|
|
'finished': 1,
|
|
|
|
'failed': 2,
|
|
|
|
'interrupted': 3,
|
|
|
|
}
|
|
|
|
|
|
|
|
JOB_MESSAGE_LEVELS = [
|
|
|
|
('normal', 0),
|
|
|
|
('warning', 1),
|
|
|
|
('error', 2),
|
|
|
|
]
|
|
|
|
JOB_MESSAGE_LEVELS_MAP = {
|
|
|
|
'normal': 0,
|
|
|
|
'warning': 1,
|
|
|
|
'error': 2,
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class JobExecution(models.Model):
|
|
|
|
start_date = models.DateTimeField(auto_now=True, null=False)
|
|
|
|
end_date = models.DateTimeField(null=True)
|
|
|
|
user = models.ForeignKey(User, on_delete=models.CASCADE, null=True)
|
|
|
|
description = models.CharField(max_length=250, null=False, default="")
|
|
|
|
status = models.IntegerField(choices=JOB_STATES, null=False, default=0)
|
|
|
|
|
|
|
|
|
|
|
|
class JobMessage(models.Model):
|
|
|
|
timestamp = models.DateTimeField(auto_now=True, null=False)
|
|
|
|
job = models.ForeignKey(JobExecution, null=False, on_delete=models.CASCADE)
|
|
|
|
progress = models.FloatField(null=True)
|
|
|
|
message = models.CharField(max_length=1024, null=False, default="")
|
|
|
|
level = models.IntegerField(choices=JOB_MESSAGE_LEVELS, null=False, default=0)
|
|
|
|
suppress_notification = models.BooleanField(null=False, default=False)
|