Skip to content

Commit

Permalink
Handle remote card types
Browse files Browse the repository at this point in the history
- Major part of #311
- Add scheduled task to refresh remote card types every 24 hours
- Handle remote card type identifiers in title card creation
  • Loading branch information
CollinHeist committed May 20, 2023
1 parent f724ffa commit 16e2429
Show file tree
Hide file tree
Showing 7 changed files with 192 additions and 16 deletions.
77 changes: 71 additions & 6 deletions app/internal/cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,10 @@

from fastapi import BackgroundTasks

from app.database.query import get_font, get_template
from app.dependencies import (
get_database, get_emby_interface, get_jellyfin_interface, get_preferences,
get_plex_interface
)
from app.internal.font import get_effective_fonts
from app.internal.templates import get_effective_templates
import app.models as models
from app.schemas.font import DefaultFont
Expand All @@ -19,6 +17,8 @@

from modules.CleanPath import CleanPath
from modules.Debug import log
from modules.RemoteCardType2 import RemoteCardType
from modules.RemoteFile import RemoteFile
from modules.SeasonTitleRanges import SeasonTitleRanges
from modules.TieredSettings import TieredSettings
from modules.Title import Title
Expand Down Expand Up @@ -51,6 +51,57 @@ def create_all_title_cards():
log.exception(f'Failed to create title cards', e)


def refresh_all_remote_card_types():
"""
Schedule-able function to refresh all specified RemoteCardTypes.
"""

try:
# Get the Database
with next(get_database()) as db:
refresh_remote_card_types(db, get_preferences())
except Exception as e:
log.exception(f'Failed to refresh remote card types', e)


def refresh_remote_card_types(db: 'Database', preferences: Preferences) -> None:
"""
Refresh all specified RemoteCardTypes. This re-downloads all
RemoteCardType and RemoteFile files.
Args:
db: Database to query for remote card type identifiers.
preferences: Preferences to load the RemoteCardType classes
into.
"""

# Function to get all unique card types for the table model
def _get_unique_card_types(model) -> set[str]:
return set(obj[0] for obj in db.query(model.card_type).distinct().all())

# Get all card types globally, from Templates, Series, and Episodes
card_identifiers = {preferences.default_card_type} \
| _get_unique_card_types(models.template.Template) \
| _get_unique_card_types(models.series.Series) \
| _get_unique_card_types(models.episode.Episode)

# Reset loaded remote file(s)
RemoteFile.reset_loaded_database()

# Refresh all remote card types
for card_identifier in card_identifiers:
# Card type is remote
if (card_identifier is not None
and card_identifier not in TitleCardCreator.CARD_TYPES):
log.debug(f'Loading RemoteCardType[{card_identifier}]..')
remote_card_type = RemoteCardType(card_identifier)
if remote_card_type.valid and remote_card_type is not None:
preferences.remote_card_types[card_identifier] =\
remote_card_type.card_class

return None


def add_card_to_database(db, card_model, card_settings) -> 'Card':
"""
Expand Down Expand Up @@ -78,7 +129,15 @@ def create_card(db, preferences, card_model, card_settings) -> None:
"""

# Initialize class of the card type being created
CardClass = TitleCardCreator.CARD_TYPES[card_settings.get('card_type')]
# Get the effective card class
if (card_type := card_settings['card_type']) in TitleCardCreator.CARD_TYPES:
CardClass = TitleCardCreator.CARD_TYPES[card_type]
elif card_type in preferences.remote_card_types:
CardClass = preferences.remote_card_types[card_type]
else:
log.error(f'Unable to identify card type "{card_type}", skipping')
return None

card_maker = CardClass(
**(card_settings | card_settings['extras']),
preferences=preferences,
Expand Down Expand Up @@ -158,8 +217,14 @@ def create_episode_card(
TieredSettings(card_extras, episode.translations)
card_settings['extras'] = card_extras | episode.translations

# Get the effective card type class
CardClass = TitleCardCreator.CARD_TYPES[card_settings.get('card_type')]
# Get the effective card class
if (card_type := card_settings['card_type']) in TitleCardCreator.CARD_TYPES:
CardClass = TitleCardCreator.CARD_TYPES[card_type]
elif card_type in preferences.remote_card_types:
CardClass = preferences.remote_card_types[card_type]
else:
log.error(f'Unable to identify card type "{card_type}", skipping')
return ['invalid']

# Add card default font stuff
if card_settings.get('font_file', None) is None:
Expand Down Expand Up @@ -271,7 +336,7 @@ def create_episode_card(
# Get any existing card for this episode
existing_card = db.query(models.card.Card)\
.filter_by(episode_id=episode.id).first()
card = NewTitleCard(**card_settings)
card = NewTitleCard(**card_settings, episode_id=episode.id)

# No existing card, add task to create and add to database
if existing_card is None:
Expand Down
3 changes: 2 additions & 1 deletion app/models/preferences.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def __init__(self) -> None:
self.season_folder_format = 'Season {season_number}'
self.sync_specials = True

self.remote_card_types = {}
self.default_card_type = 'standard'
self.excluded_card_types = []
self.default_watched_style = 'unique'
Expand Down Expand Up @@ -106,7 +107,7 @@ def __init__(self) -> None:

def __setattr__(self, name, value) -> None:
self.__dict__[name] = value
log.debug(f'preferences.__dict__[{name}] = {value}')
log.debug(f'preferences.{name} = {value}')
self._rewrite_preferences()


Expand Down
2 changes: 1 addition & 1 deletion app/routers/cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ def create_card_for_episode(
)

# Create Card for this Episode, record actions
actions = create_episode_card(db, preferences, None, episode.series, episode)
actions = create_episode_card(db, preferences, None, episode)
if actions:
all_actions = CardActions()
for action in actions:
Expand Down
21 changes: 18 additions & 3 deletions app/routers/schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@

from app.dependencies import get_scheduler
from app.internal.availability import get_latest_version
from app.internal.cards import create_all_title_cards
from app.internal.cards import (
create_all_title_cards, refresh_all_remote_card_types
)
from app.internal.episodes import refresh_all_episode_data
from app.internal.series import load_all_media_servers
from app.internal.sources import (
Expand Down Expand Up @@ -35,6 +37,7 @@
JOB_SYNC_INTERFACES: str = 'SyncInterfaces'
# Internal Job ID's
INTERNAL_JOB_CHECK_FOR_NEW_RELEASE: str = 'CheckForNewRelease'
INTERNAL_JOB_REFRESH_REMOTE_CARD_TYPES: str = 'RefreshRemoteCardTypes'

TaskID = Literal[
JOB_REFRESH_EPISODE_DATA, JOB_SYNC_INTERFACES, JOB_DOWNLOAD_SOURCE_IMAGES,
Expand Down Expand Up @@ -96,6 +99,12 @@ def wrapped_get_latest_version():
get_latest_version()
_wrap_after(INTERNAL_JOB_CHECK_FOR_NEW_RELEASE)

def wrapped_refresh_all_remote_cards():
_wrap_before(INTERNAL_JOB_REFRESH_REMOTE_CARD_TYPES)
refresh_all_remote_card_types()
_wrap_after(INTERNAL_JOB_REFRESH_REMOTE_CARD_TYPES)


"""
Dictionary of Job ID's to NewJob objects that contain the default Job
attributes for all major functions.
Expand Down Expand Up @@ -139,12 +148,18 @@ def wrapped_get_latest_version():
description='Download Logos for all Series',
),
# Internal (private) jobs
'CheckForNewRelease': NewJob(
id='CheckForNewRelease',
INTERNAL_JOB_CHECK_FOR_NEW_RELEASE: NewJob(
id=INTERNAL_JOB_CHECK_FOR_NEW_RELEASE,
function=wrapped_get_latest_version,
seconds=60 * 60 * 12,
description='Check for a new release of TitleCardMaker',
internal=True,
), INTERNAL_JOB_REFRESH_REMOTE_CARD_TYPES: NewJob(
id=INTERNAL_JOB_REFRESH_REMOTE_CARD_TYPES,
function=...,
seconds=60 * 60 * 24,
description='Refresh all RemoteCardType files',
internal=True,
),
}

Expand Down
4 changes: 1 addition & 3 deletions modules/RemoteCardType.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from pathlib import Path
from requests import get
from tinydb import TinyDB, where
from tinydb import where

from modules.CleanPath import CleanPath
from modules.Debug import log
Expand Down Expand Up @@ -50,8 +50,6 @@ def __init__(self, remote: str) -> None:

# Get database of loaded assets/cards
self.loaded = PersistentDatabase(self.LOADED)

# Assume file is valid until invalidated
self.valid = True

# If local file has been specified..
Expand Down
97 changes: 97 additions & 0 deletions modules/RemoteCardType2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import sys
from importlib.util import spec_from_file_location, module_from_spec

from pathlib import Path
from requests import get

from modules.CleanPath import CleanPath
from modules.Debug import log
from modules.RemoteFile import RemoteFile

class RemoteCardType:
"""
This class defines a remote CardType. This is an encapsulation of a
CardType class that, rather than being defined locally, queries the
Maker GitHub for Python classes to dynamically inject in the modules
namespace.
"""

"""Base URL for all remote Card Type files to download"""
URL_BASE = (
'https://raw.githubusercontent.com/CollinHeist/'
'TitleCardMaker-CardTypes/web-ui'
)

"""Temporary directory all card types are written to"""
TEMP_DIR = Path(__file__).parent / '.objects'

__slots__ = ('card_class', 'valid')


def __init__(self, remote: str) -> None:
"""
Construct a new RemoteCardType. This downloads the source file
at the specified location and loads it as a class in the global
modules, under the interpreted class name. If the given remote
specification is a file that exists, that file is loaded.
Args:
database_directory: Base Path to read/write any databases
from.
remote: URL to remote card to inject. Should omit repo base.
Should be specified like {username}/{class_name}. Can
also be a local filepath.
"""

# Get database of loaded assets/cards
self.valid = True

# If local file has been specified..
if (file := CleanPath(remote).sanitize()).exists():
# Get class name from file
class_name = file.stem
file_name = str(file.resolve())
else:
# Get username and class name from the remote specification
username = remote.split('/')[0]
class_name = remote.split('/')[-1]

# Download and write the CardType class into a temporary file
file_name = self.TEMP_DIR / f'{username}-{class_name}.py'
url = f'{self.URL_BASE}/{remote}.py'

# Make GET request for the contents of the specified value
if (response := get(url)).status_code >= 400:
log.error(f'Cannot identify remote Card Type "{remote}"')
self.valid = False
return None

# Write remote file contents to temporary class file
file_name.parent.mkdir(parents=True, exist_ok=True)
with (file_name).open('wb') as fh:
fh.write(response.content)

# Import new file as module
try:
# Create module for newly loaded file
spec = spec_from_file_location(class_name, file_name)
module = module_from_spec(spec)
sys.modules[class_name] = module
spec.loader.exec_module(module)

# Get class from module namespace
self.card_class = module.__dict__[class_name]

# Validate that each RemoteFile of this class loaded correctly
for attribute_name in dir(self.card_class):
attribute = getattr(self.card_class, attribute_name)
if isinstance(attribute, RemoteFile):
self.valid &= attribute.valid

# Add this url to the loaded database
log.debug(f'Loaded RemoteCardType "{remote}"')
except Exception as e:
# Some error in loading, set object as invalid
log.exception(f'Cannot load CardType "{remote}"', e)
self.card_class = None
self.valid = False
4 changes: 2 additions & 2 deletions modules/RemoteFile.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ def __init__(self, username: str, filename: str) -> None:
pass
except Exception as e:
self.valid = False
log.error(f'Could not download RemoteFile "{username}/{filename}", '
f'returned "{e}"')
log.exception(f'Could not download RemoteFile '
f'"{username}/{filename}"', e)


def __str__(self) -> str:
Expand Down

0 comments on commit 16e2429

Please sign in to comment.