diff --git a/app/internal/imports.py b/app/internal/imports.py index bf8d1d08..515f2292 100755 --- a/app/internal/imports.py +++ b/app/internal/imports.py @@ -12,6 +12,7 @@ from modules.Debug import log from modules.EpisodeMap import EpisodeMap +from modules.SeriesInfo import SeriesInfo Percentage = lambda s: float(str(s).split('%')[0]) / 100.0 @@ -204,6 +205,13 @@ def parse_fonts( # Create NewNamedFont objects for all listed fonts fonts = [] for font_name, font_dict in all_fonts.items(): + # Skip if not a dictionary + if not isinstance(font_dict, dict): + raise HTTPException( + status_code=422, + detail=f'Invalid Font "{font_name}"', + ) + # Get replacements replacements = _get(font_dict, 'replacements', default={}) if not isinstance(replacements, dict): @@ -248,6 +256,8 @@ def parse_templates( List of NewTemplates that match any defined YAML templates. Raises: + HTTPException (404) if an indicated Font name cannot be found in + the database. HTTPException (422) if there are any YAML formatting errors. Pydantic ValidationError if a NewTemplate object cannot be created from the given YAML. @@ -266,6 +276,13 @@ def parse_templates( # Create NewTemplate objects for all listed templates templates = [] for template_name, template_dict in all_templates.items(): + # Skip if not a dictionary + if not isinstance(template_dict, dict): + raise HTTPException( + status_code=422, + detail=f'Invalid Template "{template_name}"', + ) + # Parse custom Font template_font = _get(template_dict, 'font') font_id = None @@ -340,4 +357,199 @@ def parse_templates( extra_values=list(extras.values()), )) - return templates \ No newline at end of file + return templates + + +def parse_series( + db: 'Database', + preferences: Preferences, + yaml_dict: dict[str, Any], + default_library: Optional[str] = None) -> list[NewSeries]: + """ + Create NewSeries objects for any defined series in the given YAML. + + Args: + db: Database to query for custom Fonts and Templates if + indicated by any series. + preferences: Preferences to standardize styles and query for + the default media server. + yaml_dict: Dictionary of YAML attributes to parse. + default_library: Optional default Library name to apply to the + Series if one is not manually specified within YAML. + + Returns: + List of NewSeries that match any defined YAML series. + + Raises: + HTTPException (404) if an indicated Font or Template name cannot + be found in the database. + HTTPException (422) if there are any YAML formatting errors. + Pydantic ValidationError if a NewSeries object cannot be + created from the given YAML. + """ + + # If series header was included, get those + if 'series' in yaml_dict: + all_series = yaml_dict['series'] + else: + all_series = yaml_dict + + # If not a dictionary of series, return empty list + if not isinstance(all_series, dict): + return [] + + # Determine which media server to assume libraries are for + if preferences.use_emby: + library_type = 'emby_library_name' + elif preferences.use_jellyfin: + library_type = 'jellyfin_library_name' + else: + library_type = 'plex_library_name' + + # Create NewSeries objects for all listed templates + series = [] + for series_name, series_dict in all_series.items(): + # Skip if not a dictionary + if not isinstance(series_dict, dict): + raise HTTPException( + status_code=422, + detail=f'Invalid Series "{series_name}"', + ) + + # Create SeriesInfo for this series - parsing name/year/ID's + series_info = SeriesInfo( + _get(series_dict, 'name', type_=str, default=series_name), + _get(series_dict, 'year', type_=int, default=None), + emby_id=_get(series_dict, 'emby_id', default=None), + imdb_id=_get(series_dict, 'imdb_id', default=None), + jellyfin_id=_get(series_dict, 'jellyfin_id', default=None), + sonarr_id=_get(series_dict, 'sonarr_id', default=None), + tmdb_id=_get(series_dict, 'tmdb_id', default=None), + tvdb_id=_get(series_dict, 'tvdb_id', default=None), + tvrage_id=_get(series_dict, 'tvrage_id', default=None), + ) + + # Parse custom Font + series_font = _get(series_dict, 'font', default={}) + font_id = None + if not isinstance(series_font, (str, dict)): + raise HTTPException( + status_code=422, + detail=f'Unrecognized Font in Series "{series_info}"', + ) + elif isinstance(series_font, str): + # Get Font ID of this Font (if indicated) + font = db.query(models.font.Font)\ + .filter_by(name=series_font).first() + if font is None: + raise HTTPException( + status_code=404, + detail=f'Font "{series_font}" not found', + ) + font_id = font.id + + # Parse template + template_id = None + if 'template' in series_dict: + # Get template name for querying + series_template = series_dict['template'] + if isinstance(series_template, str): + template_name = series_template + elif isinstance(series_template, dict) and 'name' in series_template: + template_name = series_template['name'] + else: + raise HTTPException( + status_code=422, + detail=f'Unrecognized Template in Series "{series_info}"', + ) + + # Get Template ID + template = db.query(models.template.Template)\ + .filter_by(name=template_name).first() + if template is None: + raise HTTPException( + status_code=404, + detail=f'Template "{template_name}" not found', + ) + template_id = template.id + + # Get season titles via episode_ranges or seasons + episode_map = EpisodeMap( + _get(series_dict, 'seasons', default={}), + _get(series_dict, 'episode_ranges', default={}), + ) + if not episode_map.valid: + raise HTTPException( + status_code=422, + detail=f'Invalid season titles in Series "{series_info}"', + ) + season_titles = episode_map.raw + + # Get extras + extras = _get(series_dict, 'extras', default={}) + if not isinstance(extras, dict): + raise HTTPException( + status_code=422, + detail=f'Invalid extras in Series "{series_info}"', + ) + + # Use default library if a manual one was not specified + if (library := _get(series_dict, 'library')) is None: + library = default_library + + # Create NewTemplate with all indicated customization + series.append(NewSeries( + name=series_info.name, + year=series_info.year, + template_id=template_id, + sync_specials=_get(series_dict, 'sync_specials', type_=bool), + card_filename_format=_get(series_dict, 'filename_format'), + episode_data_source=_parse_episode_data_source(series_dict), + match_titles=_get(series_dict, 'refresh_titles', default=True), + card_type=_get(series_dict, 'card_type'), + unwatched_style=_get( + series_dict, + 'unwatched_style', + type_=preferences.standardize_style + ), watched_style=_get( + series_dict, + 'watched_style', + type_=preferences.standardize_style + ), + translations=_parse_translations(series_dict), + font_id=font_id, + font_color=_get(series_dict, 'font', 'color'), + font_title_case=_get(series_dict, 'font', 'case'), + size=_get( + series_dict, + 'font', 'size', + default=1.0, type_=Percentage + ), kerning=_get( + series_dict, + 'font', 'kerning', + default=1.0, type_=Percentage + ), stroke_width=_get( + series_dict, + 'font', 'stroke_width', + default=1.0, type_=Percentage + ), interline_spacing=_get( + series_dict, + 'font', 'interline_spacing', + default=0, type_=int + ), vertical_shift=_get( + series_dict, + 'font', 'vertical_shift', + default=0, type_=int + ), directory=_get(series_dict, 'media_directory'), + **{library_type: library}, + **series_info.ids, + season_title_ranges=list(season_titles.keys()), + season_title_values=list(season_titles.values()), + hide_season_text=_get(series_dict, 'seasons', 'hide', type_=bool), + episode_text_format=_get(series_dict, 'episode_text_format'), + hide_episode_text=_get(series_dict, 'hide_episode_text', type_=bool), + extra_keys=list(extras.keys()), + extra_values=list(extras.values()), + )) + + return series \ No newline at end of file diff --git a/app/internal/series.py b/app/internal/series.py index a320b03f..790fb86f 100755 --- a/app/internal/series.py +++ b/app/internal/series.py @@ -1,18 +1,16 @@ from pathlib import Path from requests import get -from typing import Any, Optional +from typing import Optional from fastapi import HTTPException from modules.Debug import log from app.dependencies import ( - get_database, get_preferences, get_emby_interface, - get_imagemagick_interface, get_jellyfin_interface, get_plex_interface, - get_sonarr_interface, get_tmdb_interface + get_database, get_emby_interface, get_jellyfin_interface, + get_plex_interface, ) import app.models as models -from app.database.query import get_font, get_template from app.schemas.preferences import MediaServer from app.schemas.series import Series diff --git a/app/internal/sync.py b/app/internal/sync.py index 13171399..123ce42d 100755 --- a/app/internal/sync.py +++ b/app/internal/sync.py @@ -12,7 +12,7 @@ ) from app.internal.series import download_series_poster, set_series_database_ids from app.internal.sources import download_series_logo -from app.schemas.sync import Sync, UpdateSync +from app.schemas.sync import Sync from app.schemas.series import Series import app.models as models diff --git a/app/routers/imports.py b/app/routers/imports.py index 96bd9b66..7a76a9ec 100755 --- a/app/routers/imports.py +++ b/app/routers/imports.py @@ -1,12 +1,21 @@ -from typing import Any, Literal, Optional, Union - -from fastapi import APIRouter, Body, Depends, HTTPException +from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException from pydantic.error_wrappers import ValidationError -from app.dependencies import get_database, get_preferences -from app.internal.imports import parse_fonts, parse_raw_yaml, parse_templates +from app.dependencies import ( + get_database, get_preferences, get_emby_interface, + get_imagemagick_interface, get_jellyfin_interface, get_plex_interface, + get_sonarr_interface, get_tmdb_interface +) +from app.internal.imports import ( + parse_fonts, parse_raw_yaml, parse_series, parse_templates +) +from app.internal.series import download_series_poster, set_series_database_ids +from app.internal.sources import download_series_logo import app.models as models from app.schemas.font import NamedFont +from app.schemas.imports import ( + ImportFontYaml, ImportTemplateYaml, ImportSeriesYaml +) from app.schemas.preferences import Preferences from app.schemas.series import Series, Template @@ -30,18 +39,20 @@ def import_preferences_yaml( ... -@import_router.post('/fonts') +@import_router.post('/fonts', status_code=201) def import_fonts_yaml( - yaml: str = Body(...), + import_yaml: ImportFontYaml = Body(...), db = Depends(get_database)) -> list[NamedFont]: """ - Import all Fonts defined in the given YAML. + Import all Fonts defined in the given YAML. This does NOT import any + custom font files - these will need to be added separately. - - yaml: YAML string to parse and import + - import_yaml: ImportFontYAML with the YAML string to parse and + import. """ # Parse raw YAML into dictionary - yaml_dict = parse_raw_yaml(yaml) + yaml_dict = parse_raw_yaml(import_yaml.yaml) if len(yaml_dict) == 0: return [] @@ -56,29 +67,31 @@ def import_fonts_yaml( ) # Add each defined Font to the database - fonts = [] + all_fonts = [] for new_font in new_fonts: font = models.font.Font(**new_font.dict()) db.add(font) - fonts.append(font) + log.info(f'{font.log_str} imported to Database') + all_fonts.append(font) db.commit() - return fonts + return all_fonts -@import_router.post('/templates') +@import_router.post('/templates', status_code=201) def import_template_yaml( - yaml: str = Body(...), + import_yaml: ImportTemplateYaml = Body(...), db = Depends(get_database), preferences = Depends(get_preferences)) -> list[Template]: """ Import all Templates defined in the given YAML. - - yaml: YAML string to parse and import + - import_yaml: ImportTemplateYAML with the YAML string to parse and + import. """ # Parse raw YAML into dictionary - yaml_dict = parse_raw_yaml(yaml) + yaml_dict = parse_raw_yaml(import_yaml.yaml) if len(yaml_dict) == 0: return [] @@ -93,23 +106,84 @@ def import_template_yaml( ) # Add each defined Template to the database - templates = [] + all_templates = [] for new_template in new_templates: template = models.template.Template(**new_template.dict()) db.add(template) - templates.append(template) + log.info(f'{template.log_str} imported to Database') + all_templates.append(template) db.commit() - return templates + return all_templates @import_router.post('/series') def import_series_yaml( - yaml: str = Body(...), + background_tasks: BackgroundTasks, + import_yaml: ImportSeriesYaml = Body(...), db = Depends(get_database), - preferences = Depends(get_preferences)) -> list[Series]: + preferences = Depends(get_preferences), + emby_interface = Depends(get_emby_interface), + imagemagick_interface = Depends(get_imagemagick_interface), + jellyfin_interface = Depends(get_jellyfin_interface), + plex_interface = Depends(get_plex_interface), + sonarr_interface = Depends(get_sonarr_interface), + tmdb_interface = Depends(get_tmdb_interface)) -> list[Series]: """ - + Import all Series defined in the given YAML. + + - import_yaml: ImportSeriesYAML with the YAML string and library to + parse and import. """ - ... \ No newline at end of file + # Parse raw YAML into dictionary + yaml_dict = parse_raw_yaml(import_yaml.yaml) + if len(yaml_dict) == 0: + return [] + + # Create NewSeries objects from the YAML dictionary + try: + new_series = parse_series( + db, preferences, yaml_dict, import_yaml.default_library, + ) + except ValidationError as e: + log.exception(f'Invalid YAML', e) + raise HTTPException( + status_code=422, + detail=f'YAML is invalid - {e}' + ) + + # Add each defined Series to the database + all_series = [] + for new_series in new_series: + # Add to batabase + series = models.series.Series(**new_series.dict()) + db.add(series) + log.info(f'{series.log_str} imported to Database') + + # Add background tasks for setting ID's, downloading poster and logo + # Add background tasks to set ID's, download poster and logo + background_tasks.add_task( + # Function + set_series_database_ids, + # Arguments + series, db, emby_interface, jellyfin_interface, plex_interface, + sonarr_interface, tmdb_interface, + ) + background_tasks.add_task( + # Function + download_series_poster, + # Arguments + db, preferences, series, tmdb_interface, + ) + background_tasks.add_task( + # Function + download_series_logo, + # Arguments + db, preferences, emby_interface, imagemagick_interface, + jellyfin_interface, tmdb_interface, series + ) + all_series.append(series) + db.commit() + + return all_series \ No newline at end of file diff --git a/app/schemas/font.py b/app/schemas/font.py index 829a3e46..33afcbd7 100755 --- a/app/schemas/font.py +++ b/app/schemas/font.py @@ -1,6 +1,5 @@ -from typing import Literal, Optional, Union +from typing import Literal, Optional -from fastapi import UploadFile from pydantic import Field, validator, root_validator from app.schemas.base import Base, UNSPECIFIED, validate_argument_lists_to_dict diff --git a/app/schemas/ids.py b/app/schemas/ids.py index 2add02ca..af800169 100755 --- a/app/schemas/ids.py +++ b/app/schemas/ids.py @@ -1,8 +1,6 @@ -from typing import Any, Literal, Optional +from typing import Optional -from pydantic import constr, Field, PositiveInt - -from app.schemas.base import Base, UNSPECIFIED +from pydantic import constr, PositiveInt EmbyID = Optional[PositiveInt] IMDbID = Optional[constr(regex=r'^tt\d{4,}$')] diff --git a/app/schemas/imports.py b/app/schemas/imports.py new file mode 100755 index 00000000..8ca8073d --- /dev/null +++ b/app/schemas/imports.py @@ -0,0 +1,23 @@ +from typing import Optional + +from pydantic import Field + +from app.schemas.base import Base + +""" +Base classes +""" +class ImportBase(Base): + yaml: str + +""" +Return classes +""" +class ImportFontYaml(ImportBase): + ... + +class ImportTemplateYaml(ImportBase): + ... + +class ImportSeriesYaml(ImportBase): + default_library: Optional[str] = Field(default=None, min_length=1) \ No newline at end of file diff --git a/app/schemas/series.py b/app/schemas/series.py index 518fe0a8..6dee653b 100755 --- a/app/schemas/series.py +++ b/app/schemas/series.py @@ -69,7 +69,6 @@ class BaseSeries(BaseConfig): description='Year the series first aired' ) monitored: bool = Field(default=True) - source_directory: str template_id: Optional[int] = Field( default=None, title='Template ID',