mirror of
https://github.com/chibicitiberiu/ytsm.git
synced 2024-02-24 05:43:31 +00:00
Major refactor of many things.
This commit is contained in:
parent
fd5d05232f
commit
6b843f1fc2
@ -5,7 +5,7 @@ import sys
|
||||
|
||||
from django.conf import settings as dj_settings
|
||||
|
||||
from YtManagerApp.scheduler.jobs.synchronize_job import SynchronizeJob
|
||||
from YtManagerApp.services.scheduler.jobs.synchronize_job import SynchronizeJob
|
||||
from YtManagerApp.services import Services
|
||||
from django.db.utils import OperationalError
|
||||
|
||||
@ -35,8 +35,8 @@ def main():
|
||||
__initialize_logger()
|
||||
|
||||
try:
|
||||
if Services.appConfig.initialized:
|
||||
Services.scheduler.initialize()
|
||||
if Services.appConfig().initialized:
|
||||
Services.scheduler().initialize()
|
||||
SynchronizeJob.schedule_global_job()
|
||||
except OperationalError:
|
||||
# Settings table is not created when running migrate or makemigrations;
|
||||
|
@ -7,8 +7,6 @@ from django.contrib.auth.models import User
|
||||
from django.db import models
|
||||
from django.db.models.functions import Lower
|
||||
|
||||
from YtManagerApp.utils import youtube
|
||||
|
||||
# help_text = user shown text
|
||||
# verbose_name = user shown name
|
||||
# null = nullable, blank = user is allowed to set value to empty
|
||||
@ -101,15 +99,16 @@ class SubscriptionFolder(models.Model):
|
||||
|
||||
class Subscription(models.Model):
|
||||
name = models.CharField(null=False, max_length=1024)
|
||||
parent_folder = models.ForeignKey(SubscriptionFolder, on_delete=models.CASCADE, null=True, blank=True)
|
||||
playlist_id = models.CharField(null=False, max_length=128)
|
||||
description = models.TextField()
|
||||
channel_id = models.CharField(max_length=128)
|
||||
channel_name = models.CharField(max_length=1024)
|
||||
original_url = models.CharField(null=False, max_length=1024)
|
||||
thumbnail = models.CharField(max_length=1024)
|
||||
|
||||
provider = models.CharField(null=False, max_length=64)
|
||||
provider_id = models.CharField(null=False, max_length=64)
|
||||
provider_data = models.CharField(null=True, max_length=1024)
|
||||
|
||||
parent_folder = models.ForeignKey(SubscriptionFolder, on_delete=models.CASCADE, null=True, blank=True)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
# youtube adds videos to the 'Uploads' playlist at the top instead of the bottom
|
||||
rewrite_playlist_indices = models.BooleanField(null=False, default=False)
|
||||
|
||||
# overrides
|
||||
auto_download = models.BooleanField(null=True, blank=True)
|
||||
@ -126,81 +125,37 @@ class Subscription(models.Model):
|
||||
def __repr__(self):
|
||||
return f'subscription {self.id}, name="{self.name}", playlist_id="{self.playlist_id}"'
|
||||
|
||||
def fill_from_playlist(self, info_playlist: youtube.Playlist):
|
||||
self.name = info_playlist.title
|
||||
self.playlist_id = info_playlist.id
|
||||
self.description = info_playlist.description
|
||||
self.channel_id = info_playlist.channel_id
|
||||
self.channel_name = info_playlist.channel_title
|
||||
self.thumbnail = youtube.best_thumbnail(info_playlist).url
|
||||
|
||||
def copy_from_channel(self, info_channel: youtube.Channel):
|
||||
# No point in storing info about the 'uploads from X' playlist
|
||||
self.name = info_channel.title
|
||||
self.playlist_id = info_channel.uploads_playlist.id
|
||||
self.description = info_channel.description
|
||||
self.channel_id = info_channel.id
|
||||
self.channel_name = info_channel.title
|
||||
self.thumbnail = youtube.best_thumbnail(info_channel).url
|
||||
self.rewrite_playlist_indices = True
|
||||
|
||||
def fetch_from_url(self, url, yt_api: youtube.YoutubeAPI):
|
||||
url_parsed = yt_api.parse_url(url)
|
||||
if 'playlist' in url_parsed:
|
||||
info_playlist = yt_api.playlist(url=url)
|
||||
if info_playlist is None:
|
||||
raise ValueError('Invalid playlist ID!')
|
||||
|
||||
self.fill_from_playlist(info_playlist)
|
||||
else:
|
||||
info_channel = yt_api.channel(url=url)
|
||||
if info_channel is None:
|
||||
raise ValueError('Cannot find channel!')
|
||||
|
||||
self.copy_from_channel(info_channel)
|
||||
|
||||
def delete_subscription(self, keep_downloaded_videos: bool):
|
||||
self.delete()
|
||||
|
||||
|
||||
class Video(models.Model):
|
||||
video_id = models.CharField(null=False, max_length=12)
|
||||
name = models.TextField(null=False)
|
||||
description = models.TextField()
|
||||
watched = models.BooleanField(default=False, null=False)
|
||||
new = models.BooleanField(default=True, null=False)
|
||||
downloaded_path = models.TextField(null=True, blank=True)
|
||||
subscription = models.ForeignKey(Subscription, on_delete=models.CASCADE)
|
||||
playlist_index = models.IntegerField(null=False)
|
||||
publish_date = models.DateTimeField(null=False)
|
||||
thumbnail = models.TextField()
|
||||
uploader_name = models.CharField(null=False, max_length=255)
|
||||
|
||||
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)
|
||||
|
||||
views = models.IntegerField(null=False, default=0)
|
||||
rating = models.FloatField(null=False, default=0.5)
|
||||
|
||||
@staticmethod
|
||||
def create(playlist_item: youtube.PlaylistItem, subscription: Subscription):
|
||||
video = Video()
|
||||
video.video_id = playlist_item.resource_video_id
|
||||
video.name = playlist_item.title
|
||||
video.description = playlist_item.description
|
||||
video.watched = False
|
||||
video.new = True
|
||||
video.downloaded_path = None
|
||||
video.subscription = subscription
|
||||
video.playlist_index = playlist_item.position
|
||||
video.publish_date = playlist_item.published_at
|
||||
video.thumbnail = youtube.best_thumbnail(playlist_item).url
|
||||
video.save()
|
||||
return video
|
||||
|
||||
def mark_watched(self):
|
||||
self.watched = True
|
||||
self.save()
|
||||
if self.downloaded_path is not None:
|
||||
from YtManagerApp.management.appconfig import appconfig
|
||||
from YtManagerApp.scheduler.jobs import DeleteVideoJob
|
||||
from YtManagerApp.scheduler.jobs import SynchronizeJob
|
||||
from YtManagerApp.management.scheduler.jobs import DeleteVideoJob
|
||||
from YtManagerApp.management.scheduler.jobs import SynchronizeJob
|
||||
|
||||
if appconfig.for_sub(self.subscription, 'automatically_delete_watched'):
|
||||
DeleteVideoJob.schedule(self)
|
||||
@ -209,7 +164,7 @@ class Video(models.Model):
|
||||
def mark_unwatched(self):
|
||||
self.watched = False
|
||||
self.save()
|
||||
from YtManagerApp.scheduler.jobs.synchronize_job import SynchronizeJob
|
||||
from YtManagerApp.management.scheduler.jobs.synchronize_job import SynchronizeJob
|
||||
SynchronizeJob.schedule_now_for_subscription(self.subscription)
|
||||
|
||||
def get_files(self):
|
||||
@ -234,9 +189,9 @@ class Video(models.Model):
|
||||
|
||||
def delete_files(self):
|
||||
if self.downloaded_path is not None:
|
||||
from YtManagerApp.scheduler.jobs import DeleteVideoJob
|
||||
from YtManagerApp.management.scheduler.jobs import DeleteVideoJob
|
||||
from YtManagerApp.management.appconfig import appconfig
|
||||
from YtManagerApp.scheduler.jobs import SynchronizeJob
|
||||
from YtManagerApp.management.scheduler.jobs import SynchronizeJob
|
||||
|
||||
DeleteVideoJob.schedule(self)
|
||||
|
||||
@ -247,7 +202,7 @@ class Video(models.Model):
|
||||
|
||||
def download(self):
|
||||
if not self.downloaded_path:
|
||||
from YtManagerApp.scheduler.jobs.download_video_job import DownloadVideoJob
|
||||
from YtManagerApp.management.scheduler.jobs import DownloadVideoJob
|
||||
DownloadVideoJob.schedule(self)
|
||||
|
||||
def __str__(self):
|
||||
|
@ -1,13 +0,0 @@
|
||||
import dependency_injector.containers as containers
|
||||
import dependency_injector.providers as providers
|
||||
from dynamic_preferences.registries import global_preferences_registry
|
||||
from YtManagerApp.management.appconfig import AppConfig
|
||||
from YtManagerApp.management.youtube_dl_manager import YoutubeDlManager
|
||||
from YtManagerApp.scheduler.scheduler import YtsmScheduler
|
||||
|
||||
|
||||
class Services(containers.DeclarativeContainer):
|
||||
globalPreferencesRegistry = providers.Object(global_preferences_registry.manager())
|
||||
appConfig = providers.Singleton(AppConfig, globalPreferencesRegistry)
|
||||
scheduler = providers.Singleton(YtsmScheduler, appConfig)
|
||||
youtubeDLManager = providers.Singleton(YoutubeDlManager)
|
15
app/YtManagerApp/services/__init__.py
Normal file
15
app/YtManagerApp/services/__init__.py
Normal file
@ -0,0 +1,15 @@
|
||||
import dependency_injector.containers as di_containers
|
||||
import dependency_injector.providers as di_providers
|
||||
from dynamic_preferences.registries import global_preferences_registry
|
||||
from YtManagerApp.services.appconfig import AppConfig
|
||||
from YtManagerApp.services.youtube_dl_manager import YoutubeDlManager
|
||||
from YtManagerApp.services.scheduler.scheduler import YtsmScheduler
|
||||
from YtManagerApp.services.video_provider_manager import VideoProviderManager
|
||||
|
||||
|
||||
class Services(di_containers.DeclarativeContainer):
|
||||
globalPreferencesRegistry = di_providers.Object(global_preferences_registry.manager())
|
||||
appConfig = di_providers.Singleton(AppConfig, globalPreferencesRegistry)
|
||||
scheduler = di_providers.Singleton(YtsmScheduler, appConfig)
|
||||
youtubeDLManager = di_providers.Singleton(YoutubeDlManager)
|
||||
providerManager = di_providers.Singleton(VideoProviderManager)
|
@ -1,4 +1,4 @@
|
||||
from YtManagerApp.scheduler.jobs.download_video_job import DownloadVideoJob
|
||||
from YtManagerApp.services.scheduler.jobs.download_video_job import DownloadVideoJob
|
||||
from YtManagerApp.models import Video, Subscription, VIDEO_ORDER_MAPPING
|
||||
from YtManagerApp.utils import first_non_null
|
||||
from django.conf import settings as srv_settings
|
88
app/YtManagerApp/services/providers/video_provider.py
Normal file
88
app/YtManagerApp/services/providers/video_provider.py
Normal file
@ -0,0 +1,88 @@
|
||||
from abc import abstractmethod, ABC
|
||||
from typing import Iterable, ClassVar
|
||||
|
||||
from django import forms
|
||||
|
||||
from YtManagerApp.models import Subscription, Video
|
||||
from YtManagerApp.services.scheduler.progress_tracker import ProgressTracker
|
||||
|
||||
|
||||
class VideoProvider(ABC):
|
||||
"""
|
||||
Represents a video hosting service that provides videos and playlists (e.g. YouTube, Vimeo).
|
||||
Note: the method implementations should be thread safe, as they may be called from multiple jobs running in
|
||||
parallel.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def update_configuration(self, **kwargs):
|
||||
"""
|
||||
Updates the configuration options of this video provider.
|
||||
This method is called first when the provider is registered using the configuration stored in the
|
||||
database. After that, the method will be called when the user changes any configuration options.
|
||||
:param kwargs: Configuration arguments
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_display_name(self) -> str:
|
||||
"""
|
||||
Returns an user friendly name for this provider.
|
||||
:return:
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_provider_id(self) -> str:
|
||||
"""
|
||||
Returns an identifier that uniquely identifies this provider.
|
||||
:return:
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def validate_playlist_url(self, url: str) -> bool:
|
||||
"""
|
||||
Validates that the given playlist URL is valid for the given video provider service.
|
||||
:param url:
|
||||
:return:
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def fetch_playlist(self, url: str) -> Subscription:
|
||||
"""
|
||||
Gets metadata about the playlist identified by the given URL.
|
||||
:param url:
|
||||
:return:
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def fetch_videos(self, subscription: Subscription) -> Iterable[Video]:
|
||||
"""
|
||||
Gets metadata about the videos in the given playlist.
|
||||
:param subscription:
|
||||
:return:
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update_videos(self, videos: Iterable[Video], progress_tracker: ProgressTracker, update_info: bool = True, update_stats: bool = False):
|
||||
"""
|
||||
Updates metadata about given videos.
|
||||
:param update_info: If set to true, basic information such as title, description will be updated
|
||||
:param update_stats: If set to true, video statistics (such as rating, view counts) will be updated
|
||||
:param videos: Videos to be updated.
|
||||
:param progress_tracker: Used to track the progress of the update process
|
||||
:return:
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_config_form(self) -> ClassVar[forms.Form]:
|
||||
"""
|
||||
Gets the configuration form
|
||||
:return:
|
||||
"""
|
||||
pass
|
167
app/YtManagerApp/services/providers/youtube_provider.py
Normal file
167
app/YtManagerApp/services/providers/youtube_provider.py
Normal file
@ -0,0 +1,167 @@
|
||||
import json
|
||||
from typing import ClassVar, Iterable, Optional
|
||||
|
||||
from django import forms
|
||||
from external.pytaw.pytaw.utils import iterate_chunks
|
||||
from external.pytaw.pytaw.youtube import YouTube, Thumbnail, InvalidURL, Resource, Video
|
||||
|
||||
from YtManagerApp.models import Video, Subscription
|
||||
from YtManagerApp.services.providers.video_provider import VideoProvider
|
||||
from YtManagerApp.services.scheduler.progress_tracker import ProgressTracker
|
||||
|
||||
|
||||
class YouTubeConfigForm(forms.Form):
|
||||
api_key = forms.CharField(label="YouTube API Key:")
|
||||
|
||||
|
||||
class YouTubeProvider(VideoProvider):
|
||||
|
||||
def __init__(self):
|
||||
self._apiKey: str = None
|
||||
self._api: YouTube = None
|
||||
|
||||
def _sanity_check(self):
|
||||
if self._apiKey is None:
|
||||
raise ValueError("The YouTube API key is not set!")
|
||||
|
||||
@staticmethod
|
||||
def _best_thumbnail(resource: Resource) -> Optional[Thumbnail]:
|
||||
"""
|
||||
Gets the best thumbnail available for a resource.
|
||||
:param resource:
|
||||
:return:
|
||||
"""
|
||||
thumbs = getattr(resource, 'thumbnails', None)
|
||||
|
||||
if thumbs is None or len(thumbs) <= 0:
|
||||
return None
|
||||
|
||||
return max(thumbs, key=lambda t: t.width * t.height)
|
||||
|
||||
def update_configuration(self, **kwargs):
|
||||
self._apiKey = kwargs.get('apiKey')
|
||||
self._sanity_check()
|
||||
self._api = YouTube(key=self._apiKey)
|
||||
|
||||
def get_display_name(self) -> str:
|
||||
return "YouTube API Provider"
|
||||
|
||||
def get_provider_id(self) -> str:
|
||||
return 'youtube'
|
||||
|
||||
def validate_playlist_url(self, url: str) -> bool:
|
||||
try:
|
||||
parsed_url = self._api.parse_url(url)
|
||||
except InvalidURL:
|
||||
return False
|
||||
|
||||
is_playlist = 'playlist' in parsed_url
|
||||
is_channel = parsed_url['type'] in ('channel', 'user', 'channel_custom')
|
||||
|
||||
return is_playlist or is_channel
|
||||
|
||||
def fetch_playlist(self, url: str) -> Subscription:
|
||||
|
||||
if not self.validate_playlist_url(url):
|
||||
raise ValueError("Invalid playlist or channel URL")
|
||||
|
||||
parsed_url = self._api.parse_url(url)
|
||||
sub = Subscription()
|
||||
|
||||
if 'playlist' in parsed_url:
|
||||
info = self._api.playlist(url=url)
|
||||
if info is None:
|
||||
raise ValueError('Invalid playlist ID!')
|
||||
provider_data = {
|
||||
'channel_id': None,
|
||||
'rewrite_indices': False
|
||||
}
|
||||
sub.provider_id = info.id
|
||||
|
||||
else:
|
||||
info = self._api.channel(url=url)
|
||||
if info is None:
|
||||
raise ValueError('Cannot find channel!')
|
||||
|
||||
provider_data = {
|
||||
'channel_id': info.id,
|
||||
'rewrite_indices': True
|
||||
}
|
||||
sub.provider_id = info.uploads_playlist.id
|
||||
|
||||
sub.name = info.title
|
||||
sub.description = info.description
|
||||
sub.original_url = url
|
||||
sub.thumbnail = YouTubeProvider._best_thumbnail(info).url
|
||||
|
||||
sub.provider = self.get_provider_id()
|
||||
sub.provider_data = json.dumps(provider_data)
|
||||
|
||||
return sub
|
||||
|
||||
def fetch_videos(self, subscription: Subscription) -> Iterable[Video]:
|
||||
provider_data = json.loads(subscription.provider_data)
|
||||
playlist_items = self._api.playlist_items(subscription.provider_id)
|
||||
|
||||
if provider_data.get('rewrite_indices'):
|
||||
playlist_items = sorted(playlist_items, key=lambda x: x.published_at)
|
||||
else:
|
||||
playlist_items = sorted(playlist_items, key=lambda x: x.position)
|
||||
i = 1
|
||||
|
||||
for playlist_item in playlist_items:
|
||||
video = Video()
|
||||
video.name = playlist_item.title
|
||||
video.description = playlist_item.description
|
||||
video.publish_date = playlist_item.published_at
|
||||
video.thumbnail = YouTubeProvider._best_thumbnail(playlist_item).url
|
||||
video.uploader_name = ""
|
||||
|
||||
video.provider_id = playlist_item.resource_video_id
|
||||
video.provider_data = None
|
||||
|
||||
if provider_data.get('rewrite_indices'):
|
||||
video.playlist_index = i
|
||||
i += 1
|
||||
else:
|
||||
video.playlist_index = playlist_item.position
|
||||
video.downloaded_path = None
|
||||
video.subscription = subscription
|
||||
|
||||
video.watched = False
|
||||
video.new = True
|
||||
|
||||
yield video
|
||||
|
||||
def update_videos(self, videos: Iterable[Video], progress_tracker: ProgressTracker, update_info: bool = True, update_stats: bool = False):
|
||||
videos_list = list(videos)
|
||||
progress_tracker.total_steps = len(videos_list)
|
||||
|
||||
parts = 'id'
|
||||
if update_info:
|
||||
parts += ',snippet'
|
||||
if update_stats:
|
||||
parts += ',statistics'
|
||||
|
||||
for batch in iterate_chunks(videos_list, 50):
|
||||
batch_ids = [video.video_id for video in batch]
|
||||
videos_new = {v.id: v for v in self._api.videos(batch_ids, part=parts)}
|
||||
|
||||
for video in batch:
|
||||
progress_tracker.advance(1, "Updating video " + video.name)
|
||||
video_new = videos_new.get(video.provider_id)
|
||||
if video_new is None:
|
||||
continue
|
||||
|
||||
if update_info:
|
||||
video.name = video_new.title
|
||||
video.description = video_new.description
|
||||
if update_stats:
|
||||
if video_new.n_likes is not None \
|
||||
and video_new.n_dislikes is not None \
|
||||
and video_new.n_likes + video_new.n_dislikes > 0:
|
||||
video.rating = video_new.n_likes / (video_new.n_likes + video_new.n_dislikes)
|
||||
video.views = video_new.n_views
|
||||
|
||||
def get_config_form(self) -> ClassVar[forms.Form]:
|
||||
return YouTubeConfigForm
|
@ -1,12 +1,12 @@
|
||||
import logging
|
||||
from abc import abstractmethod
|
||||
from abc import abstractmethod, ABC
|
||||
from typing import Optional
|
||||
|
||||
from YtManagerApp.models import JOB_MESSAGE_LEVELS_MAP, JobMessage
|
||||
from .progress_tracker import ProgressTracker
|
||||
|
||||
|
||||
class Job(object):
|
||||
class Job(ABC):
|
||||
name = 'GenericJob'
|
||||
|
||||
"""
|
@ -1,7 +1,7 @@
|
||||
import os
|
||||
|
||||
from YtManagerApp.models import Video
|
||||
from YtManagerApp.scheduler.job import Job
|
||||
from YtManagerApp.services.scheduler.job import Job
|
||||
|
||||
|
||||
class DeleteVideoJob(Job):
|
||||
@ -28,13 +28,13 @@ class DeleteVideoJob(Job):
|
||||
|
||||
except OSError as e:
|
||||
self.log.error("Failed to delete video %d [%s %s]. Error: %s", self._video.id,
|
||||
self._video.video_id, self._video.name, e)
|
||||
self._video.provider_id, self._video.name, e)
|
||||
|
||||
self._video.downloaded_path = None
|
||||
self._video.save()
|
||||
|
||||
self.log.info('Deleted video %d successfully! (%d files) [%s %s]', self._video.id, count,
|
||||
self._video.video_id, self._video.name)
|
||||
self._video.provider_id, self._video.name)
|
||||
|
||||
@staticmethod
|
||||
def schedule(video: Video):
|
||||
@ -44,4 +44,4 @@ class DeleteVideoJob(Job):
|
||||
:return:
|
||||
"""
|
||||
from YtManagerApp.services import Services
|
||||
Services.scheduler.add_job(DeleteVideoJob, args=[video])
|
||||
Services.scheduler().add_job(DeleteVideoJob, args=[video])
|
@ -6,7 +6,7 @@ from threading import Lock
|
||||
import youtube_dl
|
||||
|
||||
from YtManagerApp.models import Video
|
||||
from YtManagerApp.scheduler.job import Job
|
||||
from YtManagerApp.services.scheduler.job import Job
|
||||
|
||||
|
||||
class DownloadVideoJob(Job):
|
||||
@ -132,5 +132,5 @@ class DownloadVideoJob(Job):
|
||||
:param attempt:
|
||||
:return:
|
||||
"""
|
||||
from YtManagerApp.services import Services
|
||||
from YtManagerApp.management.services import Services
|
||||
Services.scheduler.add_job(DownloadVideoJob, args=[video, attempt])
|
@ -6,11 +6,10 @@ from apscheduler.triggers.cron import CronTrigger
|
||||
from django.db.models import Max
|
||||
from django.conf import settings
|
||||
|
||||
from YtManagerApp.management.downloader import fetch_thumbnail, downloader_process_subscription
|
||||
from YtManagerApp.services.downloader import fetch_thumbnail, downloader_process_subscription
|
||||
from YtManagerApp.models import *
|
||||
from YtManagerApp.scheduler.job import Job
|
||||
from YtManagerApp.services.scheduler.job import Job
|
||||
from YtManagerApp.services import Services
|
||||
from YtManagerApp.utils import youtube
|
||||
from external.pytaw.pytaw.utils import iterate_chunks
|
||||
|
||||
_ENABLE_UPDATE_STATS = True
|
||||
@ -115,7 +114,7 @@ class SynchronizeJob(Job):
|
||||
if isinstance(obj, Subscription):
|
||||
obj.thumbnail = fetch_thumbnail(obj.thumbnail, 'sub', obj.playlist_id, settings.THUMBNAIL_SIZE_SUBSCRIPTION)
|
||||
elif isinstance(obj, Video):
|
||||
obj.thumbnail = fetch_thumbnail(obj.thumbnail, 'video', obj.video_id, settings.THUMBNAIL_SIZE_VIDEO)
|
||||
obj.thumbnail = fetch_thumbnail(obj.thumbnail, 'video', obj.provider_id, settings.THUMBNAIL_SIZE_VIDEO)
|
||||
obj.save()
|
||||
|
||||
def check_video_deleted(self, video: Video):
|
||||
@ -138,7 +137,7 @@ class SynchronizeJob(Job):
|
||||
|
||||
# Video not found, we can safely assume that the video was deleted.
|
||||
if not found_video:
|
||||
self.log.info("Video %d was deleted! [%s %s]", video.id, video.video_id, video.name)
|
||||
self.log.info("Video %d was deleted! [%s %s]", video.id, video.provider_id, video.name)
|
||||
# Clean up
|
||||
for file in files:
|
||||
try:
|
||||
@ -166,11 +165,11 @@ class SynchronizeJob(Job):
|
||||
|
||||
@staticmethod
|
||||
def schedule_global_job():
|
||||
trigger = CronTrigger.from_crontab(Services.appConfig.sync_schedule)
|
||||
trigger = CronTrigger.from_crontab(Services.appConfig().sync_schedule)
|
||||
|
||||
if SynchronizeJob.__global_sync_job is None:
|
||||
trigger = CronTrigger.from_crontab(Services.appConfig.sync_schedule)
|
||||
SynchronizeJob.__global_sync_job = Services.scheduler.add_job(SynchronizeJob, trigger, max_instances=1, coalesce=True)
|
||||
trigger = CronTrigger.from_crontab(Services.appConfig().sync_schedule)
|
||||
SynchronizeJob.__global_sync_job = Services.scheduler().add_job(SynchronizeJob, trigger, max_instances=1, coalesce=True)
|
||||
|
||||
else:
|
||||
SynchronizeJob.__global_sync_job.reschedule(trigger, max_instances=1, coalesce=True)
|
@ -0,0 +1,18 @@
|
||||
from YtManagerApp.models import Video
|
||||
from YtManagerApp.services.scheduler.job import Job
|
||||
|
||||
|
||||
class YouTubeDLUpdateJob(Job):
|
||||
name = "YouTubeDLUpdateJob"
|
||||
|
||||
def __init__(self, job_execution):
|
||||
super().__init__(job_execution)
|
||||
|
||||
def get_description(self):
|
||||
return f"Updating youtube-dl runtime"
|
||||
|
||||
def run(self):
|
||||
from YtManagerApp.services import Services
|
||||
self.set_total_steps(1)
|
||||
Services.youtubeDLManager.install()
|
||||
self.progress_advance(1)
|
@ -6,11 +6,13 @@ from typing import Type, Union, Optional
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.base import BaseTrigger
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from YtManagerApp.management.appconfig import AppConfig
|
||||
from YtManagerApp.services.appconfig import AppConfig
|
||||
from YtManagerApp.models import JobExecution, JOB_STATES_MAP
|
||||
from YtManagerApp.scheduler.job import Job
|
||||
from YtManagerApp.services.scheduler.job import Job
|
||||
from YtManagerApp.services.scheduler.jobs.youtubedl_update_job import YouTubeDLUpdateJob
|
||||
|
||||
|
||||
class YtsmScheduler(object):
|
||||
@ -27,6 +29,10 @@ class YtsmScheduler(object):
|
||||
|
||||
self._configure_scheduler()
|
||||
self._ap_scheduler.start()
|
||||
self._schedule_main_jobs()
|
||||
|
||||
def _schedule_main_jobs(self):
|
||||
self.add_job(YouTubeDLUpdateJob, trigger=IntervalTrigger(days=1))
|
||||
|
||||
def _configure_scheduler(self):
|
||||
logger = logging.getLogger('scheduler')
|
11
app/YtManagerApp/services/video_provider_manager.py
Normal file
11
app/YtManagerApp/services/video_provider_manager.py
Normal file
@ -0,0 +1,11 @@
|
||||
from YtManagerApp.services.providers.video_provider import VideoProvider
|
||||
|
||||
|
||||
class VideoProviderManager(object):
|
||||
|
||||
def __init__(self):
|
||||
self._providers: Dict[str, VideoProvider] = {}
|
||||
|
||||
def register_provider(self, provider: VideoProvider):
|
||||
pid = provider.get_provider_id()
|
||||
self._providers[pid] = provider
|
@ -28,12 +28,10 @@ class YoutubeDlManager(object):
|
||||
def __init__(self):
|
||||
self.verbose = False
|
||||
self.progress = False
|
||||
self._install_path = os.path.join(dj_settings.DATA_DIR, 'youtube-dl')
|
||||
|
||||
def _get_path(self):
|
||||
return os.path.join(dj_settings.DATA_DIR, 'youtube-dl')
|
||||
|
||||
def _check_installed(self, path):
|
||||
return os.path.isfile(path) and os.access(path, os.X_OK)
|
||||
def _check_installed(self):
|
||||
return os.path.isfile(self._install_path) and os.access(self._install_path, os.X_OK)
|
||||
|
||||
def _get_run_args(self):
|
||||
run_args = []
|
||||
@ -47,13 +45,12 @@ class YoutubeDlManager(object):
|
||||
return run_args
|
||||
|
||||
def run(self, *args):
|
||||
path = self._get_path()
|
||||
if not self._check_installed(path):
|
||||
if not self._check_installed():
|
||||
log.error("Cannot run youtube-dl, it is not installed!")
|
||||
raise YoutubeDlNotInstalledException
|
||||
|
||||
run_args = self._get_run_args()
|
||||
ret = subprocess.run([sys.executable, path, *run_args, *args], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
ret = subprocess.run([sys.executable, self._install_path, *run_args, *args], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
stdout = ret.stdout.decode('utf-8')
|
||||
if len(stdout) > 0:
|
||||
@ -93,15 +90,14 @@ class YoutubeDlManager(object):
|
||||
resp = requests.get(LATEST_URL, allow_redirects=True, stream=True)
|
||||
resp.raise_for_status()
|
||||
|
||||
path = self._get_path()
|
||||
with open(path + ".tmp", "wb") as f:
|
||||
with open(self._install_path + ".tmp", "wb") as f:
|
||||
for chunk in resp.iter_content(10 * 1024):
|
||||
f.write(chunk)
|
||||
|
||||
# Replace
|
||||
os.unlink(path)
|
||||
os.rename(path + ".tmp", path)
|
||||
os.chmod(path, 555)
|
||||
os.unlink(self._install_path)
|
||||
os.rename(self._install_path + ".tmp", self._install_path)
|
||||
os.chmod(self._install_path, 555)
|
||||
|
||||
# Test run
|
||||
newver = self.get_installed_version()
|
@ -1,49 +0,0 @@
|
||||
from django.conf import settings
|
||||
from external.pytaw.pytaw.youtube import YouTube, Channel, Playlist, PlaylistItem, Thumbnail, InvalidURL, Resource, Video
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class YoutubeAPI(YouTube):
|
||||
|
||||
@staticmethod
|
||||
def build_public() -> 'YoutubeAPI':
|
||||
from YtManagerApp.management.appconfig import appconfig
|
||||
return YoutubeAPI(key=appconfig.youtube_api_key)
|
||||
|
||||
# @staticmethod
|
||||
# def build_oauth() -> 'YoutubeAPI':
|
||||
# flow =
|
||||
# credentials =
|
||||
# service = build(API_SERVICE_NAME, API_VERSION, credentials)
|
||||
|
||||
|
||||
def default_thumbnail(resource: Resource) -> Optional[Thumbnail]:
|
||||
"""
|
||||
Gets the default thumbnail for a resource.
|
||||
Searches in the list of thumbnails for one with the label 'default', or takes the first one.
|
||||
:param resource:
|
||||
:return:
|
||||
"""
|
||||
thumbs = getattr(resource, 'thumbnails', None)
|
||||
|
||||
if thumbs is None or len(thumbs) <= 0:
|
||||
return None
|
||||
|
||||
return next(
|
||||
(i for i in thumbs if i.id == 'default'),
|
||||
thumbs[0]
|
||||
)
|
||||
|
||||
|
||||
def best_thumbnail(resource: Resource) -> Optional[Thumbnail]:
|
||||
"""
|
||||
Gets the best thumbnail available for a resource.
|
||||
:param resource:
|
||||
:return:
|
||||
"""
|
||||
thumbs = getattr(resource, 'thumbnails', None)
|
||||
|
||||
if thumbs is None or len(thumbs) <= 0:
|
||||
return None
|
||||
|
||||
return max(thumbs, key=lambda t: t.width * t.height)
|
@ -3,7 +3,7 @@ from django.http import JsonResponse
|
||||
from django.views.generic import View
|
||||
|
||||
from YtManagerApp.models import Video
|
||||
from YtManagerApp.scheduler.jobs.synchronize_job import SynchronizeJob
|
||||
from YtManagerApp.services.scheduler.jobs.synchronize_job import SynchronizeJob
|
||||
|
||||
|
||||
class SyncNowView(LoginRequiredMixin, View):
|
||||
|
@ -36,7 +36,7 @@ class RegisterView(FormView):
|
||||
return context
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
if not Services.appConfig.allow_registrations:
|
||||
if not Services.appConfig().allow_registrations:
|
||||
return HttpResponseForbidden("Registrations are disabled!")
|
||||
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
@ -8,7 +8,7 @@ from django.shortcuts import redirect
|
||||
from django.urls import reverse_lazy
|
||||
from django.views.generic import FormView
|
||||
|
||||
from YtManagerApp.scheduler.jobs.synchronize_job import SynchronizeJob
|
||||
from YtManagerApp.services.scheduler.jobs.synchronize_job import SynchronizeJob
|
||||
from YtManagerApp.services import Services
|
||||
from YtManagerApp.views.forms.first_time import WelcomeForm, ApiKeyForm, PickAdminUserForm, ServerConfigForm, DoneForm, \
|
||||
UserCreationForm, LoginForm
|
||||
@ -24,7 +24,7 @@ class WizardStepMixin:
|
||||
def get(self, request, *args, **kwargs):
|
||||
|
||||
# Prevent access if application is already initialized
|
||||
if Services.appConfig.initialized:
|
||||
if Services.appConfig().initialized:
|
||||
logger.debug(f"Attempted to access {request.path}, but first time setup already run. Redirected to home "
|
||||
f"page.")
|
||||
return redirect('home')
|
||||
@ -32,7 +32,7 @@ class WizardStepMixin:
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
if Services.appConfig.initialized:
|
||||
if Services.appConfig().initialized:
|
||||
logger.debug(f"Attempted to post {request.path}, but first time setup already run.")
|
||||
return HttpResponseForbidden()
|
||||
return super().post(request, *args, **kwargs)
|
||||
@ -65,14 +65,14 @@ class Step1ApiKeyView(WizardStepMixin, FormView):
|
||||
|
||||
def get_initial(self):
|
||||
initial = super().get_initial()
|
||||
initial['api_key'] = Services.appConfig.youtube_api_key
|
||||
initial['api_key'] = Services.appConfig().youtube_api_key
|
||||
return initial
|
||||
|
||||
def form_valid(self, form):
|
||||
key = form.cleaned_data['api_key']
|
||||
# TODO: validate key
|
||||
if key is not None and len(key) > 0:
|
||||
Services.appConfig.youtube_api_key = key
|
||||
Services.appConfig().youtube_api_key = key
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
@ -149,8 +149,8 @@ class Step3ConfigureView(WizardStepMixin, FormView):
|
||||
|
||||
def get_initial(self):
|
||||
initial = super().get_initial()
|
||||
initial['allow_registrations'] = Services.appConfig.allow_registrations
|
||||
initial['sync_schedule'] = Services.appConfig.sync_schedule
|
||||
initial['allow_registrations'] = Services.appConfig().allow_registrations
|
||||
initial['sync_schedule'] = Services.appConfig().sync_schedule
|
||||
initial['auto_download'] = self.request.user.preferences['auto_download']
|
||||
initial['download_location'] = self.request.user.preferences['download_path']
|
||||
return initial
|
||||
@ -158,11 +158,11 @@ class Step3ConfigureView(WizardStepMixin, FormView):
|
||||
def form_valid(self, form):
|
||||
allow_registrations = form.cleaned_data['allow_registrations']
|
||||
if allow_registrations is not None:
|
||||
Services.appConfig.allow_registrations = allow_registrations
|
||||
Services.appConfig().allow_registrations = allow_registrations
|
||||
|
||||
sync_schedule = form.cleaned_data['sync_schedule']
|
||||
if sync_schedule is not None and len(sync_schedule) > 0:
|
||||
Services.appConfig.sync_schedule = sync_schedule
|
||||
Services.appConfig().sync_schedule = sync_schedule
|
||||
|
||||
auto_download = form.cleaned_data['auto_download']
|
||||
if auto_download is not None:
|
||||
@ -173,7 +173,7 @@ class Step3ConfigureView(WizardStepMixin, FormView):
|
||||
self.request.user.preferences['download_path'] = download_location
|
||||
|
||||
# Set initialized to true
|
||||
Services.appConfig.initialized = True
|
||||
Services.appConfig().initialized = True
|
||||
|
||||
# Start scheduler if not started
|
||||
Services.scheduler.initialize()
|
||||
|
@ -7,7 +7,7 @@ from YtManagerApp.dynamic_preferences_registry import MarkDeletedAsWatched, Auto
|
||||
DownloadPath, DownloadFilePattern, DownloadFormat, DownloadSubtitles, DownloadAutogeneratedSubtitles, \
|
||||
DownloadAllSubtitles, DownloadSubtitlesLangs, DownloadSubtitlesFormat
|
||||
from YtManagerApp.models import VIDEO_ORDER_CHOICES
|
||||
from YtManagerApp.services import Services
|
||||
from YtManagerApp.management.services import Services
|
||||
|
||||
|
||||
class SettingsForm(forms.Form):
|
||||
|
@ -12,7 +12,7 @@ from django.conf import settings
|
||||
from django.core.paginator import Paginator
|
||||
from YtManagerApp.management.videos import get_videos
|
||||
from YtManagerApp.models import Subscription, SubscriptionFolder, VIDEO_ORDER_CHOICES, VIDEO_ORDER_MAPPING
|
||||
from YtManagerApp.services import Services
|
||||
from YtManagerApp.management.services import Services
|
||||
from YtManagerApp.utils import youtube, subscription_file_parser
|
||||
from YtManagerApp.views.controls.modal import ModalMixin
|
||||
|
||||
|
@ -3,7 +3,7 @@ from django.http import HttpResponseForbidden
|
||||
from django.urls import reverse_lazy
|
||||
from django.views.generic import FormView
|
||||
|
||||
from YtManagerApp.scheduler.jobs.synchronize_job import SynchronizeJob
|
||||
from YtManagerApp.management.scheduler.jobs.synchronize_job import SynchronizeJob
|
||||
from YtManagerApp.views.forms.settings import SettingsForm, AdminSettingsForm
|
||||
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user